owlplanner 2025.1.28__tar.gz → 2025.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. {owlplanner-2025.1.28 → owlplanner-2025.2}/PKG-INFO +1 -1
  2. {owlplanner-2025.1.28 → owlplanner-2025.2}/pyproject.toml +1 -1
  3. {owlplanner-2025.1.28 → owlplanner-2025.2}/src/owlplanner/plan.py +16 -31
  4. {owlplanner-2025.1.28 → owlplanner-2025.2}/src/owlplanner/tax2025.py +5 -5
  5. owlplanner-2025.2/src/owlplanner/version.py +1 -0
  6. {owlplanner-2025.1.28 → owlplanner-2025.2}/ui/Case_Worksheets.py +1 -2
  7. {owlplanner-2025.1.28 → owlplanner-2025.2}/ui/Documentation.py +3 -0
  8. {owlplanner-2025.1.28 → owlplanner-2025.2}/ui/Optimization_Parameters.py +19 -6
  9. {owlplanner-2025.1.28 → owlplanner-2025.2}/ui/Rates_Selection.py +3 -3
  10. {owlplanner-2025.1.28 → owlplanner-2025.2}/ui/owlbridge.py +35 -151
  11. {owlplanner-2025.1.28 → owlplanner-2025.2}/ui/requirements.txt +1 -1
  12. {owlplanner-2025.1.28 → owlplanner-2025.2}/ui/sskeys.py +115 -6
  13. owlplanner-2025.1.28/src/owlplanner/version.py +0 -1
  14. {owlplanner-2025.1.28 → owlplanner-2025.2}/.devcontainer/devcontainer.json +0 -0
  15. {owlplanner-2025.1.28 → owlplanner-2025.2}/.flake8 +0 -0
  16. {owlplanner-2025.1.28 → owlplanner-2025.2}/.github/workflows/github-actions-runtests.yml +0 -0
  17. {owlplanner-2025.1.28 → owlplanner-2025.2}/.gitignore +0 -0
  18. {owlplanner-2025.1.28 → owlplanner-2025.2}/INSTALL.md +0 -0
  19. {owlplanner-2025.1.28 → owlplanner-2025.2}/LICENSE +0 -0
  20. {owlplanner-2025.1.28 → owlplanner-2025.2}/README.md +0 -0
  21. {owlplanner-2025.1.28 → owlplanner-2025.2}/docs/images/AD-taxDef.png +0 -0
  22. {owlplanner-2025.1.28 → owlplanner-2025.2}/docs/images/AD-taxFree.png +0 -0
  23. {owlplanner-2025.1.28 → owlplanner-2025.2}/docs/images/AD-taxable.png +0 -0
  24. {owlplanner-2025.1.28 → owlplanner-2025.2}/docs/images/Hist_Bequest.png +0 -0
  25. {owlplanner-2025.1.28 → owlplanner-2025.2}/docs/images/Hist_Spending.png +0 -0
  26. {owlplanner-2025.1.28 → owlplanner-2025.2}/docs/images/MC-tutorial2a.png +0 -0
  27. {owlplanner-2025.1.28 → owlplanner-2025.2}/docs/images/MC-tutorial2b.png +0 -0
  28. {owlplanner-2025.1.28 → owlplanner-2025.2}/docs/images/OwlUI.png +0 -0
  29. {owlplanner-2025.1.28 → owlplanner-2025.2}/docs/images/allocations.png +0 -0
  30. {owlplanner-2025.1.28 → owlplanner-2025.2}/docs/images/owl.png +0 -0
  31. {owlplanner-2025.1.28 → owlplanner-2025.2}/docs/images/profile.png +0 -0
  32. {owlplanner-2025.1.28 → owlplanner-2025.2}/docs/images/ratesCorrelations.png +0 -0
  33. {owlplanner-2025.1.28 → owlplanner-2025.2}/docs/images/ratesPlot.png +0 -0
  34. {owlplanner-2025.1.28 → owlplanner-2025.2}/docs/images/savingsPlot.png +0 -0
  35. {owlplanner-2025.1.28 → owlplanner-2025.2}/docs/images/sourcesPlot.png +0 -0
  36. {owlplanner-2025.1.28 → owlplanner-2025.2}/docs/images/spendingPlot.png +0 -0
  37. {owlplanner-2025.1.28 → owlplanner-2025.2}/docs/images/taxIncomePlot.png +0 -0
  38. {owlplanner-2025.1.28 → owlplanner-2025.2}/docs/images/taxesPlot.png +0 -0
  39. {owlplanner-2025.1.28 → owlplanner-2025.2}/docs/owl.pdf +0 -0
  40. {owlplanner-2025.1.28 → owlplanner-2025.2}/docs/owl.tex +0 -0
  41. {owlplanner-2025.1.28 → owlplanner-2025.2}/examples/case_jack+jill.toml +0 -0
  42. {owlplanner-2025.1.28 → owlplanner-2025.2}/examples/case_joe.toml +0 -0
  43. {owlplanner-2025.1.28 → owlplanner-2025.2}/examples/case_john+sally.toml +0 -0
  44. {owlplanner-2025.1.28 → owlplanner-2025.2}/examples/case_kim+sam-bequest.toml +0 -0
  45. {owlplanner-2025.1.28 → owlplanner-2025.2}/examples/case_kim+sam-spending.toml +0 -0
  46. {owlplanner-2025.1.28 → owlplanner-2025.2}/examples/jack+jill.xlsx +0 -0
  47. {owlplanner-2025.1.28 → owlplanner-2025.2}/examples/joe.xlsx +0 -0
  48. {owlplanner-2025.1.28 → owlplanner-2025.2}/examples/john+sally.xlsx +0 -0
  49. {owlplanner-2025.1.28 → owlplanner-2025.2}/examples/template.xlsx +0 -0
  50. {owlplanner-2025.1.28 → owlplanner-2025.2}/notebooks/john+sally.ipynb +0 -0
  51. {owlplanner-2025.1.28 → owlplanner-2025.2}/notebooks/kim+sam.ipynb +0 -0
  52. {owlplanner-2025.1.28 → owlplanner-2025.2}/notebooks/template.ipynb +0 -0
  53. {owlplanner-2025.1.28 → owlplanner-2025.2}/notebooks/tutorial_1.ipynb +0 -0
  54. {owlplanner-2025.1.28 → owlplanner-2025.2}/notebooks/tutorial_2.ipynb +0 -0
  55. {owlplanner-2025.1.28 → owlplanner-2025.2}/notebooks/tutorial_3.ipynb +0 -0
  56. {owlplanner-2025.1.28 → owlplanner-2025.2}/owlplanner.cmd +0 -0
  57. {owlplanner-2025.1.28 → owlplanner-2025.2}/requirements.txt +0 -0
  58. {owlplanner-2025.1.28 → owlplanner-2025.2}/src/owlplanner/__init__.py +0 -0
  59. {owlplanner-2025.1.28 → owlplanner-2025.2}/src/owlplanner/abcapi.py +0 -0
  60. {owlplanner-2025.1.28 → owlplanner-2025.2}/src/owlplanner/config.py +0 -0
  61. {owlplanner-2025.1.28 → owlplanner-2025.2}/src/owlplanner/data/__init__.py +0 -0
  62. {owlplanner-2025.1.28 → owlplanner-2025.2}/src/owlplanner/data/rates.csv +0 -0
  63. {owlplanner-2025.1.28 → owlplanner-2025.2}/src/owlplanner/logging.py +0 -0
  64. {owlplanner-2025.1.28 → owlplanner-2025.2}/src/owlplanner/progress.py +0 -0
  65. {owlplanner-2025.1.28 → owlplanner-2025.2}/src/owlplanner/rates.py +0 -0
  66. {owlplanner-2025.1.28 → owlplanner-2025.2}/src/owlplanner/timelists.py +0 -0
  67. {owlplanner-2025.1.28 → owlplanner-2025.2}/src/owlplanner/utils.py +0 -0
  68. {owlplanner-2025.1.28 → owlplanner-2025.2}/tests/test_logger.py +0 -0
  69. {owlplanner-2025.1.28 → owlplanner-2025.2}/tests/test_regressions.py +0 -0
  70. {owlplanner-2025.1.28 → owlplanner-2025.2}/tests/test_repro.py +0 -0
  71. {owlplanner-2025.1.28 → owlplanner-2025.2}/tests/test_toml_cases.py +0 -0
  72. {owlplanner-2025.1.28 → owlplanner-2025.2}/tests/test_units.py +0 -0
  73. {owlplanner-2025.1.28 → owlplanner-2025.2}/ui/About_Owl.py +0 -0
  74. {owlplanner-2025.1.28 → owlplanner-2025.2}/ui/Asset_Allocation.py +0 -0
  75. {owlplanner-2025.1.28 → owlplanner-2025.2}/ui/Assets.py +0 -0
  76. {owlplanner-2025.1.28 → owlplanner-2025.2}/ui/Basic_Info.py +0 -0
  77. {owlplanner-2025.1.28 → owlplanner-2025.2}/ui/Case_Results.py +0 -0
  78. {owlplanner-2025.1.28 → owlplanner-2025.2}/ui/Case_Summary.py +0 -0
  79. {owlplanner-2025.1.28 → owlplanner-2025.2}/ui/Fixed_Income.py +0 -0
  80. {owlplanner-2025.1.28 → owlplanner-2025.2}/ui/Historical_Range.py +0 -0
  81. {owlplanner-2025.1.28 → owlplanner-2025.2}/ui/Logs.py +0 -0
  82. {owlplanner-2025.1.28 → owlplanner-2025.2}/ui/Monte_Carlo.py +0 -0
  83. {owlplanner-2025.1.28 → owlplanner-2025.2}/ui/Quick_Start.py +0 -0
  84. {owlplanner-2025.1.28 → owlplanner-2025.2}/ui/README.md +0 -0
  85. {owlplanner-2025.1.28 → owlplanner-2025.2}/ui/Settings.py +0 -0
  86. {owlplanner-2025.1.28 → owlplanner-2025.2}/ui/Wages_And_Contributions.py +0 -0
  87. {owlplanner-2025.1.28 → owlplanner-2025.2}/ui/main.py +0 -0
  88. {owlplanner-2025.1.28 → owlplanner-2025.2}/ui/plots.py +0 -0
  89. {owlplanner-2025.1.28 → owlplanner-2025.2}/ui/progress.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: owlplanner
3
- Version: 2025.1.28
3
+ Version: 2025.2
4
4
  Summary: Owl: Retirement planner with great wisdom
5
5
  Project-URL: HomePage, https://github.com/mdlacasse/owl
6
6
  Project-URL: Repository, https://github.com/mdlacasse/owl
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "owlplanner"
7
- version = "2025.01.28"
7
+ version = "2025.02"
8
8
  authors = [
9
9
  { name="Martin-D. Lacasse", email="martin.d.lacasse@gmail.com" },
10
10
  ]
@@ -295,6 +295,9 @@ class Plan(object):
295
295
  self.myRothX_in = np.zeros((self.N_i, self.N_n))
296
296
  self.kappa_ijn = np.zeros((self.N_i, self.N_j, self.N_n))
297
297
 
298
+ # Previous 2 years for Medicare.
299
+ self.prevMAGI = np.zeros((2))
300
+
298
301
  # Scenario starts at the beginning of this year and ends at the end of the last year.
299
302
  self.mylog.vprint('Preparing scenario of %d years for %d individual%s.'
300
303
  % (self.N_n, self.N_i, ['', 's'][self.N_i - 1]))
@@ -403,8 +406,6 @@ class Plan(object):
403
406
  self.mylog.vprint('Renaming plan %s -> %s.' % (self._name, newname))
404
407
  self._name = newname
405
408
 
406
- return None
407
-
408
409
  def setSpousalDepositFraction(self, eta):
409
410
  """
410
411
  Set spousal deposit and withdrawal fraction. Default 0.5.
@@ -424,8 +425,6 @@ class Plan(object):
424
425
  self.mylog.vprint('\t%s: %.1f, %s: %.1f' % (self.inames[0], (1 - eta), self.inames[1], eta))
425
426
  self.eta = eta
426
427
 
427
- return None
428
-
429
428
  def setDefaultPlots(self, value):
430
429
  """
431
430
  Set plots between nominal values or today's $.
@@ -434,8 +433,6 @@ class Plan(object):
434
433
  self.defaultPlots = self._checkValue(value)
435
434
  self.mylog.vprint('Setting plots default value to %s.' % value)
436
435
 
437
- return None
438
-
439
436
  def setDividendRate(self, mu):
440
437
  """
441
438
  Set dividend rate on equities. Rate is in percent. Default 2%.
@@ -446,8 +443,6 @@ class Plan(object):
446
443
  self.mu = mu
447
444
  self.caseStatus = 'modified'
448
445
 
449
- return None
450
-
451
446
  def setLongTermCapitalTaxRate(self, psi):
452
447
  """
453
448
  Set long-term income tax rate. Rate is in percent. Default 15%.
@@ -458,8 +453,6 @@ class Plan(object):
458
453
  self.psi = psi
459
454
  self.caseStatus = 'modified'
460
455
 
461
- return None
462
-
463
456
  def setBeneficiaryFractions(self, phi):
464
457
  """
465
458
  Set fractions of savings accounts that is left to surviving spouse.
@@ -477,8 +470,6 @@ class Plan(object):
477
470
  self.mylog.vprint('Consider changing spousal deposit fraction for better convergence.')
478
471
  self.mylog.vprint('\tRecommended: setSpousalDepositFraction(%d)' % self.i_d)
479
472
 
480
- return None
481
-
482
473
  def setHeirsTaxRate(self, nu):
483
474
  """
484
475
  Set the heirs tax rate on the tax-deferred portion of the estate.
@@ -490,8 +481,6 @@ class Plan(object):
490
481
  self.nu = nu
491
482
  self.caseStatus = 'modified'
492
483
 
493
- return None
494
-
495
484
  def setPension(self, amounts, ages, units='k'):
496
485
  """
497
486
  Set value of pension for each individual and commencement age.
@@ -521,8 +510,6 @@ class Plan(object):
521
510
  self.pensionAges = np.array(ages, dtype=np.int32)
522
511
  self.caseStatus = 'modified'
523
512
 
524
- return None
525
-
526
513
  def setSocialSecurity(self, amounts, ages, units='k'):
527
514
  """
528
515
  Set value of social security for each individual and commencement age.
@@ -560,8 +547,6 @@ class Plan(object):
560
547
  self.caseStatus = 'modified'
561
548
  self._adjustedParameters = False
562
549
 
563
- return None
564
-
565
550
  def setSpendingProfile(self, profile, percent=60, dip=15, increase=12, delay=0):
566
551
  """
567
552
  Generate time series for spending profile. Surviving spouse fraction can be specified
@@ -589,8 +574,6 @@ class Plan(object):
589
574
  self.smileDelay = delay
590
575
  self.caseStatus = 'modified'
591
576
 
592
- return None
593
-
594
577
  def setRates(self, method, frm=None, to=None, values=None, stdev=None, corr=None):
595
578
  """
596
579
  Generate rates for return and inflation based on the method and
@@ -632,8 +615,6 @@ class Plan(object):
632
615
  self._adjustedParameters = False
633
616
  self.caseStatus = 'modified'
634
617
 
635
- return None
636
-
637
618
  def regenRates(self):
638
619
  """
639
620
  Regenerate the rates using the arguments specified during last setRates() call.
@@ -648,8 +629,6 @@ class Plan(object):
648
629
  corr=self.rateCorr,
649
630
  )
650
631
 
651
- return None
652
-
653
632
  def value(self, amount, year):
654
633
  """
655
634
  Return value of amount deflated or inflated at the beginning
@@ -711,8 +690,6 @@ class Plan(object):
711
690
  u.d(np.sum(taxable) + 0.7 * np.sum(taxDeferred) + np.sum(taxFree)),
712
691
  )
713
692
 
714
- return None
715
-
716
693
  def setInterpolationMethod(self, method, center=15, width=5):
717
694
  """
718
695
  Interpolate assets allocation ratios from initial value (today) to
@@ -739,8 +716,6 @@ class Plan(object):
739
716
 
740
717
  self.mylog.vprint('Asset allocation interpolation method set to %s.' % method)
741
718
 
742
- return None
743
-
744
719
  def setAllocationRatios(self, allocType, taxable=None, taxDeferred=None, taxFree=None, generic=None):
745
720
  """
746
721
  Single function for setting all types of asset allocations.
@@ -874,8 +849,6 @@ class Plan(object):
874
849
 
875
850
  self.mylog.vprint('Interpolating assets allocation ratios using', self.interpMethod, 'method.')
876
851
 
877
- return None
878
-
879
852
  def readContributions(self, filename):
880
853
  """
881
854
  Provide the name of the file containing the financial events
@@ -1636,6 +1609,7 @@ class Plan(object):
1636
1609
  'noRothConversions',
1637
1610
  'withMedicare',
1638
1611
  'solver',
1612
+ 'previousMAGIs',
1639
1613
  ]
1640
1614
  # We will modify options if required.
1641
1615
  if options is None:
@@ -1664,6 +1638,17 @@ class Plan(object):
1664
1638
  if objective == 'maxSpending' and 'bequest' not in myoptions:
1665
1639
  self.mylog.vprint('Using bequest of $1.')
1666
1640
 
1641
+ if 'previousMAGIs' in myoptions:
1642
+ magi = myoptions['previousMAGIs']
1643
+ if len(magi) != 2:
1644
+ raise ValueError("previousMAGIs must have two values.")
1645
+
1646
+ if 'units' in options:
1647
+ units = u.getUnits(options['units'])
1648
+ else:
1649
+ units = 1000
1650
+ self.prevMAGI = units * np.array(magi)
1651
+
1667
1652
  self._adjustParameters()
1668
1653
 
1669
1654
  if 'solver' in options:
@@ -1897,7 +1882,7 @@ class Plan(object):
1897
1882
  self.F_tn = self.F_tn.reshape((self.N_t, self.N_n))
1898
1883
  MAGI_n = np.sum(self.F_tn, axis=0) + np.array(x[self.C['e']:self.C['F']])
1899
1884
 
1900
- self.M_n = tx.mediCosts(self.yobs, self.horizons, MAGI_n, self.gamma_n[:-1], self.N_n)
1885
+ self.M_n = tx.mediCosts(self.yobs, self.horizons, MAGI_n, self.prevMAGI, self.gamma_n[:-1], self.N_n)
1901
1886
 
1902
1887
  return None
1903
1888
 
@@ -69,7 +69,7 @@ extra65Deduction_2025 = np.array([2000, 1600])
69
69
  ##############################################################################
70
70
 
71
71
 
72
- def mediCosts(yobs, horizons, magi, gamma_n, Nn):
72
+ def mediCosts(yobs, horizons, magi, prevmagi, gamma_n, Nn):
73
73
  """
74
74
  Compute Medicare costs directly.
75
75
  """
@@ -79,14 +79,14 @@ def mediCosts(yobs, horizons, magi, gamma_n, Nn):
79
79
  for n in range(Nn):
80
80
  for i in range(Ni):
81
81
  if thisyear + n - yobs[i] >= 65 and n < horizons[i]:
82
- # The standard Medicare part B premium
82
+ # Start with the (indexed) basic Medicare part B premium.
83
83
  costs[n] += gamma_n[n] * irmaaFees_2025[0]
84
84
  if n < 2:
85
- nn = n
85
+ mymagi = prevmagi[n]
86
86
  else:
87
- nn = 2
87
+ mymagi = magi[n - 2]
88
88
  for q in range(1, 6):
89
- if magi[n - nn] > gamma_n[n] * irmaaBrackets_2025[Ni - 1][q]:
89
+ if mymagi > gamma_n[n] * irmaaBrackets_2025[Ni - 1][q]:
90
90
  costs[n] += gamma_n[n] * irmaaFees_2025[q]
91
91
 
92
92
  return costs
@@ -0,0 +1 @@
1
+ __version__ = "2025.02"
@@ -14,12 +14,11 @@ else:
14
14
  else:
15
15
  owb.showWorkbook()
16
16
  st.divider()
17
- # if not owb.isCaseUnsolved():
18
17
  if kz.caseHasPlan():
19
18
  download2 = st.download_button(
20
19
  label="Download data as an Excel workbook...",
21
20
  data=owb.saveWorkbook(),
22
21
  file_name='Workbook_'+kz.getKey('name')+'.xlsx',
23
22
  mime='application/vnd.ms-excel',
24
- disabled=owb.isCaseUnsolved()
23
+ disabled=kz.isCaseUnsolved()
25
24
  )
@@ -249,6 +249,9 @@ the column `RothX` found in the
249
249
 
250
250
  Calculations of Medicare and IRMAA can be turned on or off. This will typically speed up
251
251
  the calculations by a factor of 2 to 3, which can be useful when running Monte Carlo simulations.
252
+ If the age of individuals makes them eligible for Medicare within the next two years,
253
+ additional cells will appear for entering the Modified Adjusted Gross Income (MAGI) for past years.
254
+ These numbers are needed to calculate the Income-Related Monthly Adjusted Amounts (IRMAA).
252
255
 
253
256
  The time profile of the net spending amount
254
257
  can be selected to either be *flat* or follow a *smile* shape.
@@ -24,6 +24,7 @@ if ret is None or kz.caseHasNoPlan():
24
24
  else:
25
25
  kz.runOncePerCase(initProfile)
26
26
 
27
+ st.write('##### Objective')
27
28
  col1, col2 = st.columns(2, gap='large', vertical_alignment='top')
28
29
  with col1:
29
30
  choices = ['Net spending', 'Bequest']
@@ -41,6 +42,7 @@ else:
41
42
  ret = kz.getNum("Desired annual net spending (\\$k)", 'netSpending', help=helpmsg)
42
43
 
43
44
  st.divider()
45
+ st.write('##### Roth Conversions')
44
46
  col1, col2 = st.columns(2, gap='large', vertical_alignment='top')
45
47
  with col1:
46
48
  iname0 = kz.getKey('iname0')
@@ -49,7 +51,6 @@ else:
49
51
  fromFile = kz.getKey('readRothX')
50
52
  kz.initKey('maxRothConversion', 50)
51
53
  ret = kz.getNum("Maximum Roth conversion (\\$k)", 'maxRothConversion', disabled=fromFile, help=helpmsg)
52
- # caseHasNoContributions = (kz.getKey('stTimeLists') is None)
53
54
  ret = kz.getToggle('Convert as in wages and contributions tables', 'readRothX')
54
55
 
55
56
  with col2:
@@ -62,21 +63,33 @@ else:
62
63
  "noRothConversions", help=helpmsg)
63
64
 
64
65
  st.divider()
66
+ st.write('##### Medicare')
65
67
  kz.initKey('withMedicare', True)
66
68
  col1, col2 = st.columns(2, gap='large', vertical_alignment='top')
67
69
  with col1:
68
70
  helpmsg = "Do or do not perform additional Medicare and IRMAA calculations."
69
71
  ret = kz.getToggle('Medicare and IRMAA calculations', 'withMedicare', help=helpmsg)
70
72
  with col2:
71
- if owb.hasMOSEK():
72
- choices = ['HiGHS', 'MOSEK']
73
- kz.initKey('solver', choices[0])
74
- ret = kz.getRadio('Solver', choices, 'solver')
73
+ if kz.getKey('withMedicare'):
74
+ helpmsg = "MAGI in nominal $k for that previous year."
75
+ years = owb.backYearsMAGI()
76
+ for ii in range(2):
77
+ kz.initKey('MAGI'+str(ii), 0)
78
+ if years[ii] > 0:
79
+ ret = kz.getNum(f"MAGI for year {years[ii]} ($k)", 'MAGI'+str(ii), help=helpmsg)
80
+
81
+ if owb.hasMOSEK():
82
+ st.divider()
83
+ st.write('##### Solver')
84
+ choices = ['HiGHS', 'MOSEK']
85
+ kz.initKey('solver', choices[0])
86
+ ret = kz.getRadio('Linear programming solver', choices, 'solver')
75
87
 
76
88
  st.divider()
89
+ st.write('##### Spending Profile')
77
90
  col1, col2, col3 = st.columns(3, gap='medium', vertical_alignment='top')
78
91
  with col1:
79
- ret = kz.getRadio("Spending profile", profileChoices, 'spendingProfile', callback=owb.setProfile)
92
+ ret = kz.getRadio("Type of profile", profileChoices, 'spendingProfile', callback=owb.setProfile)
80
93
  with col2:
81
94
  if kz.getKey('status') == 'married':
82
95
  helpmsg = 'Percentage of spending required for the surviving spouse.'
@@ -198,17 +198,17 @@ else:
198
198
  kz.initKey('divRate', 2)
199
199
  helpmsg = 'Average annual dividend return rate on stock portfolio.'
200
200
  ret = kz.getNum('Dividends return rate (%)', 'divRate', max_value=100., format='%.2f',
201
- help=helpmsg, callback=owb.setDividendRate, step=1.)
201
+ help=helpmsg, step=1.)
202
202
 
203
203
  st.write('#### Income taxes')
204
204
  col1, col2 = st.columns(2, gap='large', vertical_alignment='top')
205
205
  with col1:
206
206
  kz.initKey('gainTx', 15)
207
207
  ret = kz.getNum('Long-term capital gains tax rate (%)', 'gainTx', max_value=100.,
208
- callback=owb.setLongTermCapitalTaxRate, step=1.)
208
+ step=1.)
209
209
 
210
210
  with col2:
211
211
  kz.initKey('heirsTx', 30)
212
212
  helpmsg = 'Marginal tax rate that heirs would have to pay on inherited tax-deferred balance.'
213
213
  ret = kz.getNum('Heirs marginal tax rate (%)', 'heirsTx', max_value=100., help=helpmsg,
214
- callback=owb.setHeirsTaxRate, step=1.)
214
+ step=1.)
@@ -59,67 +59,25 @@ def _checkPlan(func):
59
59
  return wrapper
60
60
 
61
61
 
62
- def getFixedIncome(ni, what):
63
- amounts = []
64
- ages = []
65
- for i in range(ni):
66
- amounts.append(kz.getKey(what+'Amt'+str(i)))
67
- ages.append(kz.getKey(what+'Age'+str(i)))
68
-
69
- return amounts, ages
70
-
71
-
72
- def getAccountBalances(ni):
73
- bal = [[], [], []]
74
- accounts = ['txbl', 'txDef', 'txFree']
75
- for j, acc in enumerate(accounts):
76
- for i in range(ni):
77
- bal[j].append(kz.getKey(acc+str(i)))
78
-
79
- return bal
80
-
81
-
82
- def getSolveParameters():
83
- maximize = kz.getKey('objective')
84
- if maximize is None:
85
- return None
86
- if 'spending' in maximize:
87
- objective = 'maxSpending'
88
- else:
89
- objective = 'maxBequest'
90
-
91
- options = {}
92
- optList = ['netSpending', 'maxRothConversion', 'noRothConversions',
93
- 'withMedicare', 'bequest', 'solver']
94
- for opt in optList:
95
- val = kz.getKey(opt)
96
- if val is not None:
97
- options[opt] = val
98
-
99
- if kz.getKey('readRothX'):
100
- options['maxRothConversion'] = 'file'
101
-
102
- return objective, options
103
-
104
-
62
+ # _checkPlan
105
63
  def prepareRun(plan):
106
64
  ni = 2 if kz.getKey('status') == 'married' else 1
107
65
 
108
- bal = getAccountBalances(ni)
66
+ bal = kz.getAccountBalances(ni)
109
67
  try:
110
68
  plan.setAccountBalances(taxable=bal[0], taxDeferred=bal[1], taxFree=bal[2])
111
69
  except Exception as e:
112
70
  st.error('Setting account balances failed: %s' % e)
113
71
  return
114
72
 
115
- amounts, ages = getFixedIncome(ni, 'p')
73
+ amounts, ages = kz.getFixedIncome(ni, 'p')
116
74
  try:
117
75
  plan.setPension(amounts, ages)
118
76
  except Exception as e:
119
77
  st.error('Failed setting pensions: %s' % e)
120
78
  return
121
79
 
122
- amounts, ages = getFixedIncome(ni, 'ss')
80
+ amounts, ages = kz.getFixedIncome(ni, 'ss')
123
81
  try:
124
82
  plan.setSocialSecurity(amounts, ages)
125
83
  except Exception as e:
@@ -141,26 +99,19 @@ def prepareRun(plan):
141
99
  st.error('Failed setting beneficiary fractions: %s' % e)
142
100
  return
143
101
 
102
+ plan.setHeirsTaxRate(kz.getKey('heirsTx'))
103
+ plan.setLongTermCapitalTaxRate(kz.getKey('gainTx'))
104
+ plan.setDividendRate(kz.getKey('divRate'))
105
+
144
106
  setRates()
145
107
  setContributions()
146
108
 
147
109
 
148
- def isCaseUnsolved():
149
- if kz.getKey('plan') is None:
150
- return True
151
- return kz.getKey('caseStatus') != 'solved'
152
-
153
-
154
- @_checkPlan
155
- def caseStatus(plan):
156
- return plan.caseStatus
157
-
158
-
159
110
  @_checkPlan
160
111
  def runPlan(plan):
161
112
  prepareRun(plan)
162
113
 
163
- objective, options = getSolveParameters()
114
+ objective, options = kz.getSolveParameters()
164
115
  try:
165
116
  plan.solve(objective, options=options)
166
117
  except Exception as e:
@@ -183,7 +134,7 @@ def runHistorical(plan):
183
134
  hyfrm = kz.getKey('hyfrm')
184
135
  hyto = kz.getKey('hyto')
185
136
 
186
- objective, options = getSolveParameters()
137
+ objective, options = kz.getSolveParameters()
187
138
  try:
188
139
  mybar = progress.Progress(None)
189
140
  fig, summary = plan.runHistoricalRange(objective, options, hyfrm, hyto, figure=True, progcall=mybar)
@@ -207,7 +158,7 @@ def runMC(plan):
207
158
 
208
159
  N = kz.getKey('MC_cases')
209
160
 
210
- objective, options = getSolveParameters()
161
+ objective, options = kz.getSolveParameters()
211
162
  try:
212
163
  mybar = progress.Progress(None)
213
164
  fig, summary = plan.runMC(objective, options, N, figure=True, progcall=mybar)
@@ -386,66 +337,20 @@ def resetContributions(plan):
386
337
  def setAllocationRatios(plan):
387
338
  if kz.getKey('allocType') == 'individual':
388
339
  try:
389
- generic = getIndividualAllocationRatios()
340
+ generic = kz.getIndividualAllocationRatios()
390
341
  plan.setAllocationRatios('individual', generic=generic)
391
342
  except Exception as e:
392
343
  st.error('Setting asset allocations failed: %s' % e)
393
344
  return
394
345
  elif kz.getKey('allocType') == 'account':
395
346
  try:
396
- acc = getAccountAllocationRatios()
347
+ acc = kz.getAccountAllocationRatios()
397
348
  plan.setAllocationRatios('account', taxable=acc[0], taxDeferred=acc[1], taxFree=acc[2])
398
349
  except Exception as e:
399
350
  st.error('Setting asset allocations failed: %s' % e)
400
351
  return
401
352
 
402
353
 
403
- def getIndividualAllocationRatios():
404
- generic = []
405
- initial = []
406
- final = []
407
- for k1 in range(4):
408
- initial.append(int(kz.getKey('j3_init%'+str(k1)+'_0')))
409
- final.append(int(kz.getKey('j3_fin%'+str(k1)+'_0')))
410
- gen0 = [initial, final]
411
- generic = [gen0]
412
-
413
- if kz.getKey('status') == 'married':
414
- initial = []
415
- final = []
416
- for k1 in range(4):
417
- initial.append(int(kz.getKey('j3_init%'+str(k1)+'_1')))
418
- final.append(int(kz.getKey('j3_fin%'+str(k1)+'_1')))
419
- gen1 = [initial, final]
420
- generic.append(gen1)
421
-
422
- return generic
423
-
424
-
425
- def getAccountAllocationRatios():
426
- accounts = [[], [], []]
427
- for j1 in range(3):
428
- initial = []
429
- final = []
430
- for k1 in range(4):
431
- initial.append(int(kz.getKey(f'j{j1}_init%'+str(k1)+'_0')))
432
- final.append(int(kz.getKey(f'j{j1}_fin%'+str(k1)+'_0')))
433
- tmp = [initial, final]
434
- accounts[j1].append(tmp)
435
-
436
- if kz.getKey('status') == 'married':
437
- for j1 in range(3):
438
- initial = []
439
- final = []
440
- for k1 in range(4):
441
- initial.append(int(kz.getKey(f'j{j1}_init%'+str(k1)+'_1')))
442
- final.append(int(kz.getKey(f'j{j1}_fin%'+str(k1)+'_1')))
443
- tmp = [initial, final]
444
- accounts[j1].append(tmp)
445
-
446
- return accounts
447
-
448
-
449
354
  @_checkPlan
450
355
  def plotSingleResults(plan):
451
356
  c = 0
@@ -510,24 +415,6 @@ def setProfile(plan, key, pull=True):
510
415
  plan.setSpendingProfile(profile, survivor, dip, increase, delay)
511
416
 
512
417
 
513
- @_checkPlan
514
- def setHeirsTaxRate(plan, key):
515
- val = kz.setpull(key)
516
- plan.setHeirsTaxRate(val)
517
-
518
-
519
- @_checkPlan
520
- def setLongTermCapitalTaxRate(plan, key):
521
- val = kz.setpull(key)
522
- plan.setLongTermCapitalTaxRate(val)
523
-
524
-
525
- @_checkPlan
526
- def setDividendRate(plan, key):
527
- val = kz.setpull(key)
528
- plan.setDividendRate(val)
529
-
530
-
531
418
  @_checkPlan
532
419
  def setDefaultPlots(plan, key):
533
420
  val = kz.storepull(key)
@@ -612,7 +499,7 @@ def saveContributions(plan):
612
499
  @_checkPlan
613
500
  def saveCaseFile(plan):
614
501
  stringBuffer = StringIO()
615
- if getSolveParameters() is None:
502
+ if kz.getSolveParameters() is None:
616
503
  return ''
617
504
  plan.saveConfig(stringBuffer)
618
505
  encoded_data = stringBuffer.getvalue().encode('utf-8')
@@ -635,23 +522,7 @@ def createCaseFromFile(file):
635
522
 
636
523
  return name, mydic
637
524
 
638
- # keynames = ['name', 'status', 'plan', 'summary', 'logs', 'startDate',
639
- # 'timeList', 'plots', 'interpMethod', 'interpCenter', 'interpWidth',
640
- # 'objective', 'withMedicare', 'bequest', 'netSpending',
641
- # 'noRothConversions', 'maxRothConversion',
642
- # 'rateType', 'fixedType', 'varyingType', 'yfrm', 'yto',
643
- # 'divRate', 'heirsTx', 'gainTx', 'spendingProfile', 'survivor',
644
- # 'surplusFraction', ]
645
- # keynamesJ = ['benf', ]
646
- # keynamesK = ['fxRate', 'mean', 'stdev']
647
- # keynamesI = ['iname', 'yob', 'life', 'txbl', 'txDef', 'txFree',
648
- # 'ssAge', 'ssAmt', 'pAge', 'pAmt', 'df',
649
- # 'jX_init%0_', 'jX_init%1_', 'jX_init%2_', 'jX_init%3_',
650
- # 'jX_fin%0_', 'jX_fin%1_', 'jX_fin%2_', 'jX_fin%3_']
651
- # keynames6 = ['corr']
652
-
653
-
654
- # @_checkPlan
525
+
655
526
  def genDic(plan):
656
527
  accName = ['txbl', 'txDef', 'txFree']
657
528
  dic = {}
@@ -726,6 +597,10 @@ def genDic(plan):
726
597
  if key in optionKeys:
727
598
  dic[key] = plan.solverOptions[key]
728
599
 
600
+ if 'previousMAGIs' in optionKeys:
601
+ dic['MAGI0'] = plan.solverOptions['previousMAGIs'][0]
602
+ dic['MAGI1'] = plan.solverOptions['previousMAGIs'][1]
603
+
729
604
  if plan.objective == 'maxSpending':
730
605
  dic['objective'] = 'Net spending'
731
606
  else:
@@ -740,21 +615,21 @@ def genDic(plan):
740
615
 
741
616
  # Initialize in both cases.
742
617
  for k1 in range(plan.N_k):
743
- dic['fxRate'+str(k1)] = 100*plan.rateValues[k1]
618
+ dic['fxRate'+str(k1)] = 100 * plan.rateValues[k1]
744
619
 
745
620
  if plan.rateMethod in ['historical average', 'histochastic', 'historical']:
746
621
  dic['yfrm'] = plan.rateFrm
747
622
  dic['yto'] = plan.rateTo
748
623
  else:
749
624
  dic['yfrm'] = FROM
750
- # Rates avalability are trailing by 1 or 2 years.
751
- dic['yto'] = date.today().year - 2
625
+ # Rates availability are trailing by 1 year.
626
+ dic['yto'] = date.today().year - 1
752
627
 
753
628
  if plan.rateMethod in ['stochastic', 'histochastic']:
754
629
  qq = 1
755
630
  for k1 in range(plan.N_k):
756
- dic['mean'+str(k1)] = 100*plan.rateValues[k1]
757
- dic['stdev'+str(k1)] = 100*plan.rateStdev[k1]
631
+ dic['mean'+str(k1)] = 100 * plan.rateValues[k1]
632
+ dic['stdev'+str(k1)] = 100 * plan.rateStdev[k1]
758
633
  for k2 in range(k1+1, plan.N_k):
759
634
  dic['corr'+str(qq)] = plan.rateCorr[k1, k2]
760
635
  qq += 1
@@ -762,8 +637,17 @@ def genDic(plan):
762
637
  return plan._name, dic
763
638
 
764
639
 
765
- def clone(plan, newname, logstreams=None):
766
- return owl.clone(plan, newname, logstreams=logstreams)
640
+ @_checkPlan
641
+ def backYearsMAGI(plan):
642
+ thisyear = date.today().year
643
+ backyears = [0, 0]
644
+ for i in range(plan.N_i):
645
+ if thisyear - plan.yobs[i] >= 65:
646
+ backyears[0] = thisyear - 2
647
+ elif thisyear - plan.yobs[i] >= 64:
648
+ backyears[1] = thisyear - 1
649
+
650
+ return backyears
767
651
 
768
652
 
769
653
  def version():
@@ -7,4 +7,4 @@ scipy
7
7
  streamlit
8
8
  toml
9
9
  # --extra-index-url https://test.pypi.org/simple
10
- owlplanner >= 2025.1.25
10
+ owlplanner >= 2025.02
@@ -98,10 +98,9 @@ def updateContributions():
98
98
 
99
99
 
100
100
  def switchToCase(key):
101
- import owlbridge as owb
102
101
  # Catch case where switch happens while editing W&W tables.
103
102
  if getGlobalKey('currentPageName') == 'Wages And Contributions':
104
- owb.updateContributions()
103
+ updateContributions()
105
104
  ss.currentCase = ss['_'+key]
106
105
 
107
106
 
@@ -110,6 +109,12 @@ def isIncomplete():
110
109
  or (getKey('status') == 'married' and getKey('iname1') in [None, '']))
111
110
 
112
111
 
112
+ def isCaseUnsolved():
113
+ if caseHasNoPlan():
114
+ return True
115
+ return getKey('caseStatus') != 'solved'
116
+
117
+
113
118
  def caseHasNoPlan():
114
119
  return getKey('plan') is None
115
120
 
@@ -214,7 +219,7 @@ def createNewCase(case):
214
219
  st.error("Case name '%s' already exists." % casename)
215
220
  return
216
221
 
217
- ss.cases[casename] = {'name': casename, 'caseStatus': '', 'summary': '', 'logs': None}
222
+ ss.cases[casename] = {'name': casename, 'caseStatus': 'unknown', 'summary': '', 'logs': None}
218
223
  setCurrentCase(ss._newcase)
219
224
 
220
225
 
@@ -301,6 +306,109 @@ def getDict(key=ss.currentCase):
301
306
  return ss.cases[key]
302
307
 
303
308
 
309
+ def getAccountBalances(ni):
310
+ bal = [[], [], []]
311
+ accounts = ['txbl', 'txDef', 'txFree']
312
+ for j, acc in enumerate(accounts):
313
+ for i in range(ni):
314
+ bal[j].append(getKey(acc+str(i)))
315
+
316
+ return bal
317
+
318
+
319
+ def getSolveParameters():
320
+ maximize = getKey('objective')
321
+ if maximize is None:
322
+ return None
323
+ if 'spending' in maximize:
324
+ objective = 'maxSpending'
325
+ else:
326
+ objective = 'maxBequest'
327
+
328
+ options = {}
329
+ optList = ['netSpending', 'maxRothConversion', 'noRothConversions',
330
+ 'withMedicare', 'bequest', 'solver']
331
+ for opt in optList:
332
+ val = getKey(opt)
333
+ if val is not None:
334
+ options[opt] = val
335
+
336
+ if getKey('readRothX'):
337
+ options['maxRothConversion'] = 'file'
338
+
339
+ previousMAGIs = getPreviousMAGIs()
340
+ if previousMAGIs[0] > 0 or previousMAGIs[1] > 0:
341
+ options['previousMAGIs'] = previousMAGIs
342
+
343
+ return objective, options
344
+
345
+
346
+ def getIndividualAllocationRatios():
347
+ generic = []
348
+ initial = []
349
+ final = []
350
+ for k1 in range(4):
351
+ initial.append(int(getKey('j3_init%'+str(k1)+'_0')))
352
+ final.append(int(getKey('j3_fin%'+str(k1)+'_0')))
353
+ gen0 = [initial, final]
354
+ generic = [gen0]
355
+
356
+ if getKey('status') == 'married':
357
+ initial = []
358
+ final = []
359
+ for k1 in range(4):
360
+ initial.append(int(getKey('j3_init%'+str(k1)+'_1')))
361
+ final.append(int(getKey('j3_fin%'+str(k1)+'_1')))
362
+ gen1 = [initial, final]
363
+ generic.append(gen1)
364
+
365
+ return generic
366
+
367
+
368
+ def getAccountAllocationRatios():
369
+ accounts = [[], [], []]
370
+ for j1 in range(3):
371
+ initial = []
372
+ final = []
373
+ for k1 in range(4):
374
+ initial.append(int(getKey(f'j{j1}_init%'+str(k1)+'_0')))
375
+ final.append(int(getKey(f'j{j1}_fin%'+str(k1)+'_0')))
376
+ tmp = [initial, final]
377
+ accounts[j1].append(tmp)
378
+
379
+ if getKey('status') == 'married':
380
+ for j1 in range(3):
381
+ initial = []
382
+ final = []
383
+ for k1 in range(4):
384
+ initial.append(int(getKey(f'j{j1}_init%'+str(k1)+'_1')))
385
+ final.append(int(getKey(f'j{j1}_fin%'+str(k1)+'_1')))
386
+ tmp = [initial, final]
387
+ accounts[j1].append(tmp)
388
+
389
+ return accounts
390
+
391
+
392
+ def getPreviousMAGIs():
393
+ backMAGIs = [0, 0]
394
+ for ii in range(2):
395
+ val = getKey('MAGI'+str(ii))
396
+ if val:
397
+ backMAGIs[ii] = val
398
+
399
+ return backMAGIs
400
+
401
+
402
+ def getFixedIncome(ni, what):
403
+ amounts = []
404
+ ages = []
405
+ for i in range(ni):
406
+ amounts.append(getKey(what+'Amt'+str(i)))
407
+ ages.append(getKey(what+'Age'+str(i)))
408
+
409
+ return amounts, ages
410
+
411
+
304
412
  def getIntNum(text, nkey, disabled=False, callback=setpull, step=1, help=None, min_value=0, max_value=None):
305
413
  return st.number_input(text,
306
414
  value=int(getKey(nkey)),
@@ -333,11 +441,11 @@ def getText(text, nkey, disabled=False, callback=setpull, placeholder=None):
333
441
  placeholder=placeholder)
334
442
 
335
443
 
336
- def getRadio(text, choices, nkey, callback=setpull, help=None):
444
+ def getRadio(text, choices, nkey, callback=setpull, disabled=False, help=None):
337
445
  return st.radio(text, choices,
338
446
  index=choices.index(getKey(nkey)),
339
447
  on_change=callback, args=[nkey], key='_'+nkey,
340
- horizontal=True, help=help)
448
+ disabled=disabled, horizontal=True, help=help)
341
449
 
342
450
 
343
451
  def getToggle(text, nkey, callback=setpull, disabled=False, help=None):
@@ -350,6 +458,7 @@ def orangeDivider():
350
458
 
351
459
 
352
460
  def caseHeader(txt):
353
- st.html('<div style="text-align: right;color: orange;font-style: italic;">%s</div>' % currentCaseName())
461
+ # st.html('<div style="text-align: right;color: orange;font-style: italic;">%s</div>' % currentCaseName())
462
+ st.html('<div style="text-align: left;color: orange;font-style: italic;">%s</div>' % currentCaseName())
354
463
  st.write('## ' + txt)
355
464
  orangeDivider()
@@ -1 +0,0 @@
1
- __version__ = "2025.1.28"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes