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 +17 -3
- owlplanner/plan.py +188 -84
- owlplanner/rates.py +377 -366
- owlplanner/tax2026.py +11 -9
- owlplanner/version.py +1 -1
- {owlplanner-2026.1.26.dist-info → owlplanner-2026.2.2.dist-info}/METADATA +2 -2
- {owlplanner-2026.1.26.dist-info → owlplanner-2026.2.2.dist-info}/RECORD +11 -12
- owlplanner/In Discussion #58, the case of Kim and Sam.md +0 -307
- {owlplanner-2026.1.26.dist-info → owlplanner-2026.2.2.dist-info}/WHEEL +0 -0
- {owlplanner-2026.1.26.dist-info → owlplanner-2026.2.2.dist-info}/entry_points.txt +0 -0
- {owlplanner-2026.1.26.dist-info → owlplanner-2026.2.2.dist-info}/licenses/AUTHORS +0 -0
- {owlplanner-2026.1.26.dist-info → owlplanner-2026.2.2.dist-info}/licenses/LICENSE +0 -0
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
55
|
-
REL_TOL =
|
|
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
|
-
|
|
604
|
-
iage = int(
|
|
605
|
-
fraction = 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,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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"],
|
|
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
|
-
|
|
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",
|
|
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 =
|
|
1734
|
-
if rhsopt
|
|
1735
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
for
|
|
1775
|
-
|
|
1776
|
-
|
|
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
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
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
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1830
|
+
n2 = n - 2
|
|
1831
|
+
rhs = (self.fixed_assets_ordinary_income_n[n2]
|
|
1832
|
+
+ self.fixed_assets_capital_gains_n[n2])
|
|
1800
1833
|
|
|
1801
|
-
|
|
1802
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
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.
|
|
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
|
|
1833
|
-
row.addElem(_q2(self.C["zm"], nn, q, Nmed, self.N_q
|
|
1834
|
-
self.A.addRow(row,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
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,
|
|
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
|
-
"
|
|
2006
|
-
"
|
|
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
|
-
|
|
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:
|
|
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
|
-
#
|
|
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.
|