owlplanner 2026.1.26__py3-none-any.whl → 2026.2.2__py3-none-any.whl

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.
owlplanner/config.py CHANGED
@@ -81,6 +81,8 @@ _KEY_TRANSLATION = {
81
81
  "Correlations": "correlations",
82
82
  "From": "from",
83
83
  "To": "to",
84
+ "Reverse sequence": "reverse_sequence",
85
+ "Roll sequence": "roll_sequence",
84
86
  # Asset Allocation keys
85
87
  "Interpolation method": "interpolation_method",
86
88
  "Interpolation center": "interpolation_center",
@@ -266,6 +268,8 @@ def saveConfig(myplan, file, mylog):
266
268
  else:
267
269
  diconf["rates_selection"]["from"] = int(FROM)
268
270
  diconf["rates_selection"]["to"] = int(TO)
271
+ diconf["rates_selection"]["reverse_sequence"] = bool(myplan.rateReverse)
272
+ diconf["rates_selection"]["roll_sequence"] = int(myplan.rateRoll)
269
273
 
270
274
  # Asset Allocation.
271
275
  diconf["asset_allocation"] = {
@@ -427,7 +431,10 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
427
431
  rateSeed = int(rateSeed)
428
432
  reproducibleRates = diconf["rates_selection"].get("reproducible_rates", False)
429
433
  p.setReproducible(reproducibleRates, seed=rateSeed)
430
- p.setRates(rateMethod, frm, to, rateValues, stdev, rateCorr)
434
+ reverseSequence = diconf["rates_selection"].get("reverse_sequence", False)
435
+ rollSequence = diconf["rates_selection"].get("roll_sequence", 0)
436
+ p.setRates(rateMethod, frm, to, rateValues, stdev, rateCorr,
437
+ reverse=reverseSequence, roll=rollSequence)
431
438
 
432
439
  # Asset Allocation.
433
440
  boundsAR = {}
@@ -473,11 +480,18 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
473
480
  p.setSpendingProfile(profile, survivor, dip, increase, delay)
474
481
 
475
482
  # Solver Options.
476
- p.solverOptions = diconf["solver_options"]
483
+ p.solverOptions = dict(diconf["solver_options"])
484
+
485
+ # Defaults for options not present in case file (e.g. Case_joe.toml).
486
+ # Ensures Medicare is computed in loop mode and self-consistent loop runs.
487
+ if "withMedicare" not in p.solverOptions:
488
+ p.solverOptions["withMedicare"] = "loop"
489
+ if "withSCLoop" not in p.solverOptions:
490
+ p.solverOptions["withSCLoop"] = True
477
491
 
478
492
  # Address legacy case files.
479
493
  # Convert boolean values (True/False) to string format, but preserve string values
480
- withMedicare = diconf["solver_options"].get("withMedicare")
494
+ withMedicare = p.solverOptions.get("withMedicare")
481
495
  if isinstance(withMedicare, bool):
482
496
  p.solverOptions["withMedicare"] = "loop" if withMedicare else "None"
483
497
 
owlplanner/plan.py CHANGED
@@ -46,14 +46,26 @@ from .plotting.factory import PlotFactory
46
46
 
47
47
 
48
48
  # Default values
49
- BIGM_XOR = 5e7 # 100 times large withdrawals or conversions
50
- BIGM_IRMAA = 5e7 # 100 times large MAGI
49
+ BIGM_AMO = 5e7 # 100 times large withdrawals or conversions
51
50
  GAP = 1e-4
52
51
  MILP_GAP = 10 * GAP
53
52
  MAX_ITERATIONS = 29
54
- ABS_TOL = 20
55
- REL_TOL = 1e-6
53
+ ABS_TOL = 50
54
+ REL_TOL = 5e-6
56
55
  TIME_LIMIT = 900
56
+ EPSILON = 1e-9
57
+
58
+
59
+ def _apply_rate_sequence_transform(tau_kn, reverse, roll):
60
+ """
61
+ Apply reverse and/or roll to a rate series (N_k x N_n).
62
+ Returns a new array; does not modify the input.
63
+ """
64
+ if reverse:
65
+ tau_kn = tau_kn[:, ::-1]
66
+ if roll != 0:
67
+ tau_kn = np.roll(tau_kn, int(roll), axis=1)
68
+ return tau_kn
57
69
 
58
70
 
59
71
  def _genGamma_n(tau):
@@ -398,6 +410,8 @@ class Plan:
398
410
  self.rateMethod = None
399
411
  self.reproducibleRates = False
400
412
  self.rateSeed = None
413
+ self.rateReverse = False
414
+ self.rateRoll = 0
401
415
 
402
416
  self.ARCoord = None
403
417
  self.objective = "unknown"
@@ -600,9 +614,9 @@ class Plan:
600
614
  for i in range(self.N_i):
601
615
  if amounts[i] != 0:
602
616
  # Check if claim age added to birth month falls next year.
603
- realage = ages[i] + (self.mobs[i] - 1)/12
604
- iage = int(realage)
605
- fraction = 1 - (realage % 1.)
617
+ yearage = ages[i] + (self.mobs[i] - 1)/12
618
+ iage = int(yearage)
619
+ fraction = 1 - (yearage % 1.)
606
620
  realns = iage - thisyear + self.yobs[i]
607
621
  ns = max(0, realns)
608
622
  nd = self.horizons[i]
@@ -757,7 +771,8 @@ class Plan:
757
771
  # setRates() will generate a new seed each time it's called
758
772
  self.rateSeed = None
759
773
 
760
- def setRates(self, method, frm=None, to=None, values=None, stdev=None, corr=None, override_reproducible=False):
774
+ def setRates(self, method, frm=None, to=None, values=None, stdev=None, corr=None,
775
+ override_reproducible=False, reverse=False, roll=0):
761
776
  """
762
777
  Generate rates for return and inflation based on the method and
763
778
  years selected. Note that last bound is included.
@@ -780,6 +795,8 @@ class Plan:
780
795
  Args:
781
796
  override_reproducible: If True, override reproducibility setting and always generate new rates.
782
797
  Used by Monte-Carlo runs to ensure different rates each time.
798
+ reverse: If True, reverse the rate sequence along the time axis (default False).
799
+ roll: Number of years to roll the sequence; positive rolls toward the end (default 0).
783
800
  """
784
801
  if frm is not None and to is None:
785
802
  to = frm + self.N_n - 1 # 'to' is inclusive.
@@ -807,7 +824,15 @@ class Plan:
807
824
  self.rateMethod = method
808
825
  self.rateFrm = frm
809
826
  self.rateTo = to
827
+ self.rateReverse = bool(reverse)
828
+ self.rateRoll = int(roll)
810
829
  self.tau_kn = dr.genSeries(self.N_n).transpose()
830
+ # Reverse and roll are no-ops for constant (fixed) rate methods; ignore with a warning.
831
+ if method in rates.CONSTANT_RATE_METHODS and (reverse or roll != 0):
832
+ self.mylog.print("Warning: reverse and roll are ignored for constant (fixed) rate methods.")
833
+ else:
834
+ self.tau_kn = _apply_rate_sequence_transform(
835
+ self.tau_kn, self.rateReverse, self.rateRoll)
811
836
  self.mylog.vprint(f"Generating rate series of {len(self.tau_kn[0])} years using '{method}' method.")
812
837
  if method in ["stochastic", "histochastic"]:
813
838
  repro_status = "reproducible" if self.reproducibleRates else "non-reproducible"
@@ -831,9 +856,7 @@ class Plan:
831
856
  Used by Monte-Carlo runs to ensure each run gets different rates.
832
857
  """
833
858
  # Fixed rate methods don't need regeneration - they produce the same values
834
- fixed_methods = ["default", "optimistic", "conservative", "user",
835
- "historical average", "historical"]
836
- if self.rateMethod in fixed_methods:
859
+ if self.rateMethod in rates.RATE_METHODS_NO_REGEN:
837
860
  return
838
861
 
839
862
  # Only stochastic methods reach here
@@ -851,6 +874,8 @@ class Plan:
851
874
  stdev=100 * self.rateStdev,
852
875
  corr=self.rateCorr,
853
876
  override_reproducible=override_reproducible,
877
+ reverse=self.rateReverse,
878
+ roll=self.rateRoll,
854
879
  )
855
880
 
856
881
  def setAccountBalances(self, *, taxable, taxDeferred, taxFree, startDate=None, units="k"):
@@ -1318,7 +1343,7 @@ class Plan:
1318
1343
  if self.pensionIsIndexed[i]:
1319
1344
  self.piBar_in[i] *= gamma_n[:-1]
1320
1345
 
1321
- self.nm, self.L_nq, self.C_nq = tx.mediVals(self.yobs, self.horizons, gamma_n, self.N_n, self.N_q)
1346
+ self.nm, self.Lbar_nq, self.Cbar_nq = tx.mediVals(self.yobs, self.horizons, gamma_n, self.N_n, self.N_q)
1322
1347
 
1323
1348
  self._adjustedParameters = True
1324
1349
 
@@ -1331,6 +1356,7 @@ class Plan:
1331
1356
  All binary variables must be lumped at the end of the vector.
1332
1357
  """
1333
1358
  medi = options.get("withMedicare", "loop") == "optimize"
1359
+ Nmed = self.N_n - self.nm
1334
1360
 
1335
1361
  # Stack all variables in a single block vector with all binary variables at the end.
1336
1362
  C = {}
@@ -1339,13 +1365,17 @@ class Plan:
1339
1365
  C["e"] = _qC(C["d"], self.N_i, self.N_n)
1340
1366
  C["f"] = _qC(C["e"], self.N_n)
1341
1367
  C["g"] = _qC(C["f"], self.N_t, self.N_n)
1342
- C["m"] = _qC(C["g"], self.N_n)
1368
+ if medi:
1369
+ C["h"] = _qC(C["g"], self.N_n)
1370
+ C["m"] = _qC(C["h"], Nmed, self.N_q)
1371
+ else:
1372
+ C["m"] = _qC(C["g"], self.N_n)
1343
1373
  C["s"] = _qC(C["m"], self.N_n)
1344
1374
  C["w"] = _qC(C["s"], self.N_n)
1345
1375
  C["x"] = _qC(C["w"], self.N_i, self.N_j, self.N_n)
1346
1376
  C["zx"] = _qC(C["x"], self.N_i, self.N_n)
1347
1377
  C["zm"] = _qC(C["zx"], self.N_n, self.N_zx)
1348
- self.nvars = _qC(C["zm"], self.N_n - self.nm, self.N_q - 1) if medi else C["zm"]
1378
+ self.nvars = _qC(C["zm"], Nmed, self.N_q) if medi else C["zm"]
1349
1379
  self.nbins = self.nvars - C["zx"]
1350
1380
  self.nconts = C["zx"]
1351
1381
  self.nbals = C["d"]
@@ -1383,7 +1413,7 @@ class Plan:
1383
1413
  self._configure_Medicare_binary_variables(options)
1384
1414
  self._add_Medicare_costs(options)
1385
1415
  self._configure_exclusion_binary_variables(options)
1386
- self._build_objective_vector(objective)
1416
+ self._build_objective_vector(objective, options)
1387
1417
 
1388
1418
  def _add_rmd_inequalities(self):
1389
1419
  for i in range(self.N_i):
@@ -1689,7 +1719,8 @@ class Plan:
1689
1719
  rhs += self.omega_in[i, n] + self.Psi_n[n] * self.zetaBar_in[i, n] + self.piBar_in[i, n]
1690
1720
  row.addElem(_q3(self.C["w"], i, 1, n, self.N_i, self.N_j, self.N_n), -1)
1691
1721
  row.addElem(_q2(self.C["x"], i, n, self.N_i, self.N_n), -1)
1692
- fak = np.sum(self.tau_kn[1:self.N_k, n] * self.alpha_ijkn[i, 0, 1:self.N_k, n], axis=0)
1722
+ # Only positive returns are taxable (interest/dividends); losses don't reduce income.
1723
+ fak = np.sum(np.maximum(0, self.tau_kn[1:self.N_k, n]) * self.alpha_ijkn[i, 0, 1:self.N_k, n], axis=0)
1693
1724
  rhs += 0.5 * fak * self.kappa_ijn[i, 0, n]
1694
1725
  row.addElem(_q3(self.C["b"], i, 0, n, self.N_i, self.N_j, self.N_n + 1), -fak)
1695
1726
  row.addElem(_q3(self.C["w"], i, 0, n, self.N_i, self.N_j, self.N_n), fak)
@@ -1702,7 +1733,7 @@ class Plan:
1702
1733
  if not options.get("amoConstraints", True):
1703
1734
  return
1704
1735
 
1705
- bigM = u.get_numeric_option(options, "bigMamo", BIGM_XOR, min_value=0)
1736
+ bigM = u.get_numeric_option(options, "bigMamo", BIGM_AMO, min_value=0)
1706
1737
 
1707
1738
  if options.get("amoSurplus", True):
1708
1739
  for n in range(self.N_n):
@@ -1730,9 +1761,11 @@ class Plan:
1730
1761
  )
1731
1762
 
1732
1763
  if "maxRothConversion" in options:
1733
- rhsopt = u.get_numeric_option(options, "maxRothConversion", 0)
1734
- if rhsopt < -1:
1735
- return
1764
+ rhsopt = options.get("maxRothConversion")
1765
+ if rhsopt != "file":
1766
+ rhsopt = u.get_numeric_option(options, "maxRothConversion", 0)
1767
+ if rhsopt < -1:
1768
+ return
1736
1769
 
1737
1770
  # Turning off this constraint for maxRothConversions = 0 makes solution infeasible.
1738
1771
  if options.get("amoRoth", True):
@@ -1764,61 +1797,92 @@ class Plan:
1764
1797
  if options.get("withMedicare", "loop") != "optimize":
1765
1798
  return
1766
1799
 
1767
- # Default: 5e7 (50 million) - bounds aggregate MAGI, typically larger than bigMamo
1768
- bigM = u.get_numeric_option(options, "bigMirmaa", BIGM_IRMAA, min_value=0)
1769
-
1800
+ bigM = u.get_numeric_option(options, "bigMamo", BIGM_AMO, min_value=0)
1770
1801
  Nmed = self.N_n - self.nm
1771
- offset = 0
1772
- if self.nm < 2:
1773
- offset = 2 - self.nm
1774
- for nn in range(offset):
1775
- n = self.nm + nn
1776
- for q in range(self.N_q - 1):
1777
- self.A.addNewRow({_q2(self.C["zm"], nn, q, Nmed, self.N_q - 1): bigM},
1778
- self.prevMAGI[n] - self.L_nq[nn, q], np.inf)
1779
-
1780
- for nn in range(offset, Nmed):
1781
- n2 = self.nm + nn - 2 # n - 2
1782
- for q in range(self.N_q - 1):
1783
- rhs = self.L_nq[nn, q]
1784
- rhs -= (self.fixed_assets_ordinary_income_n[n2]
1785
- + self.fixed_assets_capital_gains_n[n2])
1786
- row = self.A.newRow()
1802
+ # Select exactly one IRMAA bracket per year (SOS1 behavior).
1803
+ for nn in range(Nmed):
1804
+ row = self.A.newRow()
1805
+ for q in range(self.N_q):
1806
+ row.addElem(_q2(self.C["zm"], nn, q, Nmed, self.N_q), 1)
1807
+ self.A.addRow(row, 1, 1)
1787
1808
 
1788
- row.addElem(_q2(self.C["zm"], nn, q, Nmed, self.N_q - 1), -bigM*self.gamma_n[nn])
1789
- # Using e_n slows convergence like crazy.
1790
- # Maybe replace with full exemption at the risk of creating negative income?
1791
- # rhs -= self.sigmaBar_n[n2]
1792
- row.addElem(_q1(self.C["e"], n2, self.N_n), +1)
1793
- for i in range(self.N_i):
1794
- row.addElem(_q3(self.C["w"], i, 1, n2, self.N_i, self.N_j, self.N_n), +1)
1795
- row.addElem(_q2(self.C["x"], i, n2, self.N_i, self.N_n), +1)
1809
+ # MAGI decomposition into bracket portions: sum_q h_{q} = MAGI.
1810
+ for nn in range(Nmed):
1811
+ n = self.nm + nn
1812
+ row = self.A.newRow()
1813
+ for q in range(self.N_q):
1814
+ row.addElem(_q2(self.C["h"], nn, q, Nmed, self.N_q), 1)
1815
+
1816
+ if n < 2:
1817
+ self.A.addRow(row, self.prevMAGI[n], self.prevMAGI[n])
1818
+ # Fix bracket selection for known previous MAGI.
1819
+ magi = self.prevMAGI[n]
1820
+ qsel = 0
1821
+ for q in range(1, self.N_q):
1822
+ if magi > self.Lbar_nq[nn, q - 1]:
1823
+ qsel = q
1824
+ for q in range(self.N_q):
1825
+ idx = _q2(self.C["zm"], nn, q, Nmed, self.N_q)
1826
+ val = 1 if q == qsel else 0
1827
+ self.B.setRange(idx, val, val)
1828
+ continue
1796
1829
 
1797
- # Dividends and interest gains for year n2.
1798
- afac = (self.mu*self.alpha_ijkn[i, 0, 0, n2]
1799
- + np.sum(self.alpha_ijkn[i, 0, 1:, n2]*self.tau_kn[1:, n2]))
1830
+ n2 = n - 2
1831
+ rhs = (self.fixed_assets_ordinary_income_n[n2]
1832
+ + self.fixed_assets_capital_gains_n[n2])
1800
1833
 
1801
- row.addElem(_q3(self.C["b"], i, 0, n2, self.N_i, self.N_j, self.N_n + 1), +afac)
1802
- row.addElem(_q2(self.C["d"], i, n2, self.N_i, self.N_n), +afac)
1834
+ row.addElem(_q1(self.C["e"], n2, self.N_n), -1)
1835
+ for i in range(self.N_i):
1836
+ row.addElem(_q3(self.C["w"], i, 1, n2, self.N_i, self.N_j, self.N_n), -1)
1837
+ row.addElem(_q2(self.C["x"], i, n2, self.N_i, self.N_n), -1)
1838
+
1839
+ # Dividends and interest gains for year n2. Only positive returns are taxable.
1840
+ afac = (self.mu * self.alpha_ijkn[i, 0, 0, n2]
1841
+ + np.sum(self.alpha_ijkn[i, 0, 1:, n2] * np.maximum(0, self.tau_kn[1:, n2])))
1842
+
1843
+ row.addElem(_q3(self.C["b"], i, 0, n2, self.N_i, self.N_j, self.N_n + 1), -afac)
1844
+ row.addElem(_q2(self.C["d"], i, n2, self.N_i, self.N_n), -afac)
1845
+
1846
+ # Capital gains on stocks sold from taxable account accrued in year n2 - 1.
1847
+ # Capital gains = price appreciation only (total return - dividend rate)
1848
+ # to avoid double taxation of dividends.
1849
+ tau_prev = self.tau_kn[0, max(0, n2 - 1)]
1850
+ bfac = self.alpha_ijkn[i, 0, 0, n2] * max(0, tau_prev - self.mu)
1851
+ row.addElem(_q3(self.C["w"], i, 0, n2, self.N_i, self.N_j, self.N_n), afac - bfac)
1852
+
1853
+ # MAGI includes total Social Security (taxable + non-taxable) for IRMAA.
1854
+ sumoni = (self.omega_in[i, n2]
1855
+ + self.zetaBar_in[i, n2]
1856
+ + self.piBar_in[i, n2]
1857
+ + 0.5 * self.kappa_ijn[i, 0, n2] * afac)
1858
+ rhs += sumoni
1803
1859
 
1804
- # Capital gains on stocks sold from taxable account accrued in year n2 - 1.
1805
- # Capital gains = price appreciation only (total return - dividend rate)
1806
- # to avoid double taxation of dividends.
1807
- tau_prev = self.tau_kn[0, max(0, n2-1)]
1808
- bfac = self.alpha_ijkn[i, 0, 0, n2] * max(0, tau_prev - self.mu)
1809
- row.addElem(_q3(self.C["w"], i, 0, n2, self.N_i, self.N_j, self.N_n), -afac + bfac)
1860
+ self.A.addRow(row, rhs, rhs)
1810
1861
 
1811
- # MAGI includes total Social Security (taxable + non-taxable) for IRMAA.
1812
- sumoni = (self.omega_in[i, n2]
1813
- + self.zetaBar_in[i, n2]
1814
- + self.piBar_in[i, n2]
1815
- + 0.5 * self.kappa_ijn[i, 0, n2] * afac)
1816
- rhs -= sumoni
1862
+ # Bracket bounds: L_{q-1} z_q <= mg_q <= L_q z_q.
1863
+ for nn in range(Nmed):
1864
+ for q in range(self.N_q):
1865
+ mg_idx = _q2(self.C["h"], nn, q, Nmed, self.N_q)
1866
+ zm_idx = _q2(self.C["zm"], nn, q, Nmed, self.N_q)
1817
1867
 
1818
- self.A.addRow(row, -np.inf, rhs)
1868
+ lower = 0 if q == 0 else self.Lbar_nq[nn, q - 1]
1869
+ if lower > 0:
1870
+ self.A.addNewRow({mg_idx: 1, zm_idx: -lower}, 0, np.inf)
1871
+
1872
+ if q < self.N_q - 1:
1873
+ upper = self.Lbar_nq[nn, q]
1874
+ self.A.addNewRow({mg_idx: 1, zm_idx: -upper}, -np.inf, 0)
1875
+ else:
1876
+ # Upper bound for last bracket so h_qn = 0 when z_q = 0.
1877
+ upper = bigM * self.gamma_n[self.nm + nn]
1878
+ self.A.addNewRow({mg_idx: 1, zm_idx: -upper}, -np.inf, 0)
1819
1879
 
1820
1880
  def _add_Medicare_costs(self, options):
1821
1881
  if options.get("withMedicare", "loop") != "optimize":
1882
+ # In loop mode, Medicare costs are computed outside the solver (M_n).
1883
+ # Ensure the in-model Medicare variable (m_n) stays at zero.
1884
+ for n in range(self.N_n):
1885
+ self.B.setRange(_q1(self.C["m"], n, self.N_n), 0, 0)
1822
1886
  return
1823
1887
 
1824
1888
  for n in range(self.nm):
@@ -1829,30 +1893,54 @@ class Plan:
1829
1893
  n = self.nm + nn
1830
1894
  row = self.A.newRow()
1831
1895
  row.addElem(_q1(self.C["m"], n, self.N_n), 1)
1832
- for q in range(self.N_q - 1):
1833
- row.addElem(_q2(self.C["zm"], nn, q, Nmed, self.N_q - 1), -self.C_nq[nn, q+1])
1834
- self.A.addRow(row, self.C_nq[nn, 0], self.C_nq[nn, 0])
1896
+ for q in range(self.N_q):
1897
+ row.addElem(_q2(self.C["zm"], nn, q, Nmed, self.N_q), -self.Cbar_nq[nn, q])
1898
+ self.A.addRow(row, 0, 0)
1835
1899
 
1836
- def _build_objective_vector(self, objective):
1837
- c = abc.Objective(self.nvars)
1900
+ def _build_objective_vector(self, objective, options):
1901
+ c_arr = np.zeros(self.nvars)
1838
1902
  if objective == "maxSpending":
1839
1903
  for n in range(self.N_n):
1840
- c.setElem(_q1(self.C["g"], n, self.N_n), -1/self.gamma_n[n])
1904
+ c_arr[_q1(self.C["g"], n, self.N_n)] = -1/self.gamma_n[n]
1841
1905
  elif objective == "maxBequest":
1842
1906
  for i in range(self.N_i):
1843
- c.setElem(_q3(self.C["b"], i, 0, self.N_n, self.N_i, self.N_j, self.N_n + 1), -1)
1844
- c.setElem(_q3(self.C["b"], i, 1, self.N_n, self.N_i, self.N_j, self.N_n + 1), -(1 - self.nu))
1845
- c.setElem(_q3(self.C["b"], i, 2, self.N_n, self.N_i, self.N_j, self.N_n + 1), -1)
1907
+ c_arr[_q3(self.C["b"], i, 0, self.N_n, self.N_i, self.N_j, self.N_n + 1)] = -1
1908
+ c_arr[_q3(self.C["b"], i, 1, self.N_n, self.N_i, self.N_j, self.N_n + 1)] = -(1 - self.nu)
1909
+ c_arr[_q3(self.C["b"], i, 2, self.N_n, self.N_i, self.N_j, self.N_n + 1)] = -1
1846
1910
  else:
1847
1911
  raise RuntimeError("Internal error in objective function.")
1912
+
1913
+ # Turn on epsilon by default when optimizing Medicare.
1914
+ withMedicare = options.get("withMedicare", "loop")
1915
+ default_epsilon = EPSILON if withMedicare == "optimize" else 0
1916
+ epsilon = u.get_numeric_option(options, "epsilon", default_epsilon, min_value=0)
1917
+ if epsilon > 0:
1918
+ # Penalize Roth conversions to reduce churn.
1919
+ for i in range(self.N_i):
1920
+ for n in range(self.N_n):
1921
+ c_arr[_q2(self.C["x"], i, n, self.N_i, self.N_n)] += epsilon
1922
+
1923
+ if self.N_i == 2:
1924
+ # Favor withdrawals from spouse 0 by penalizing spouse 1 withdrawals.
1925
+ for j in range(self.N_j):
1926
+ for n in range(self.N_n):
1927
+ c_arr[_q3(self.C["w"], 1, j, n, self.N_i, self.N_j, self.N_n)] += epsilon
1928
+
1929
+ c = abc.Objective(self.nvars)
1930
+ for idx in np.flatnonzero(c_arr):
1931
+ c.setElem(idx, c_arr[idx])
1848
1932
  self.c = c
1849
1933
 
1850
1934
  @_timer
1851
- def runHistoricalRange(self, objective, options, ystart, yend, *, verbose=False, figure=False, progcall=None):
1935
+ def runHistoricalRange(self, objective, options, ystart, yend, *, verbose=False, figure=False,
1936
+ progcall=None, reverse=False, roll=0):
1852
1937
  """
1853
1938
  Run historical scenarios on plan over a range of years.
1854
- """
1855
1939
 
1940
+ For each year in [ystart, yend], rates are set to the historical sequence
1941
+ starting at that year. Optional reverse and roll apply to each sequence
1942
+ (same semantics as setRates).
1943
+ """
1856
1944
  if yend + self.N_n > self.year_n[0]:
1857
1945
  yend = self.year_n[0] - self.N_n - 1
1858
1946
  self.mylog.print(f"Warning: Upper bound for year range re-adjusted to {yend}.")
@@ -1882,7 +1970,7 @@ class Plan:
1882
1970
  progcall.start()
1883
1971
 
1884
1972
  for year in range(ystart, yend + 1):
1885
- self.setRates("historical", year)
1973
+ self.setRates("historical", year, reverse=reverse, roll=roll)
1886
1974
  self.solve(objective, options)
1887
1975
  if not verbose:
1888
1976
  progcall.show((year - ystart + 1) / N)
@@ -2002,8 +2090,8 @@ class Plan:
2002
2090
  "amoRoth",
2003
2091
  "amoSurplus",
2004
2092
  "bequest",
2005
- "bigMirmaa", # Big-M value for Medicare IRMAA constraints (default: 5e7)
2006
- "bigMamo", # Big-M value for XOR constraints (default: 5e7)
2093
+ "bigMamo", # Big-M value for AMO constraints (default: 5e7)
2094
+ "epsilon",
2007
2095
  "gap",
2008
2096
  "maxIter",
2009
2097
  "maxRothConversion",
@@ -2064,7 +2152,7 @@ class Plan:
2064
2152
  if "gap" not in myoptions and myoptions.get("withMedicare", "loop") == "optimize":
2065
2153
  fac = 1
2066
2154
  maxRoth = myoptions.get("maxRothConversion", 100)
2067
- if maxRoth <= 15:
2155
+ if maxRoth != "file" and maxRoth <= 15:
2068
2156
  fac = 10
2069
2157
  # Loosen default MIP gap when Medicare is optimized. Even more if rothX == 0
2070
2158
  gap = fac * MILP_GAP
@@ -2160,6 +2248,14 @@ class Plan:
2160
2248
  break
2161
2249
 
2162
2250
  if not withSCLoop:
2251
+ # When Medicare is in loop mode, M_n was zero in the constraint for this
2252
+ # single solve. Update M_n (and J_n) from solution for reporting.
2253
+ if includeMedicare:
2254
+ self._computeNLstuff(xx, includeMedicare)
2255
+ self.mylog.print(
2256
+ "Warning: Self-consistent loop is off; Medicare premiums are "
2257
+ "computed for display but were not in the budget constraint."
2258
+ )
2163
2259
  break
2164
2260
 
2165
2261
  self._computeNLstuff(xx, includeMedicare)
@@ -2182,7 +2278,9 @@ class Plan:
2182
2278
  prev_scaled_obj = (-old_objfns[-1]) * objFac
2183
2279
  scale = max(1.0, abs(scaled_obj), abs(prev_scaled_obj))
2184
2280
  tol = max(abs_tol, rel_tol * scale)
2185
- if absObjDiff <= tol:
2281
+ # With Medicare in loop mode, the first solve uses M_n=0; require at least
2282
+ # one re-solve so the accepted solution had Medicare in the budget.
2283
+ if absObjDiff <= tol and (not includeMedicare or it >= 1):
2186
2284
  # Check if convergence was monotonic or oscillatory
2187
2285
  # old_objfns stores -objfn values, so we need to scale them to match displayed values
2188
2286
  # For monotonic convergence, the scaled objective (objfn * objFac) should be non-increasing
@@ -2522,6 +2620,7 @@ class Plan:
2522
2620
  Ce = self.C["e"]
2523
2621
  Cf = self.C["f"]
2524
2622
  Cg = self.C["g"]
2623
+ Ch = self.C.get("h", self.C["m"])
2525
2624
  Cm = self.C["m"]
2526
2625
  Cs = self.C["s"]
2527
2626
  Cw = self.C["w"]
@@ -2545,7 +2644,11 @@ class Plan:
2545
2644
  self.f_tn = np.array(x[Cf:Cg])
2546
2645
  self.f_tn = self.f_tn.reshape((Nt, Nn))
2547
2646
 
2548
- self.g_n = np.array(x[Cg:Cm])
2647
+ self.g_n = np.array(x[Cg:Ch])
2648
+
2649
+ if "h" in self.C:
2650
+ self.h_qn = np.array(x[Ch:Cm])
2651
+ self.h_qn = self.h_qn.reshape((self.N_n - self.nm, self.N_q))
2549
2652
 
2550
2653
  self.m_n = np.array(x[Cm:Cs])
2551
2654
 
@@ -2586,9 +2689,10 @@ class Plan:
2586
2689
  self.MAGI_n = (self.G_n + self.e_n + self.Q_n
2587
2690
  + np.sum((1 - self.Psi_n) * self.zetaBar_in, axis=0))
2588
2691
 
2692
+ # Only positive returns count as interest/dividend income (matches _add_taxable_income).
2589
2693
  I_in = ((self.b_ijn[:, 0, :-1] + self.d_in - self.w_ijn[:, 0, :])
2590
- * np.sum(self.alpha_ijkn[:, 0, 1:, :Nn] * self.tau_kn[1:, :], axis=1))
2591
- # Clamp interest/dividend income to non-negative. Sum over individuals to share losses across spouses.
2694
+ * np.sum(self.alpha_ijkn[:, 0, 1:, :Nn] * np.maximum(0, self.tau_kn[1:, :]), axis=1))
2695
+ # Sum over individuals to share losses across spouses; clamp to non-negative.
2592
2696
  self.I_n = np.maximum(0, np.sum(I_in, axis=0))
2593
2697
 
2594
2698
  # Stop after building minimum required for self-consistent loop.