owlplanner 2025.12.20__py3-none-any.whl → 2026.1.26__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/In Discussion #58, the case of Kim and Sam.md +307 -0
- owlplanner/__init__.py +20 -1
- owlplanner/abcapi.py +18 -17
- owlplanner/cli/README.md +50 -0
- owlplanner/cli/_main.py +52 -0
- owlplanner/cli/cli_logging.py +56 -0
- owlplanner/cli/cmd_list.py +83 -0
- owlplanner/cli/cmd_run.py +86 -0
- owlplanner/config.py +300 -117
- owlplanner/data/__init__.py +21 -0
- owlplanner/data/rates.csv +99 -98
- owlplanner/debts.py +36 -8
- owlplanner/fixedassets.py +95 -21
- owlplanner/mylogging.py +157 -25
- owlplanner/plan.py +793 -349
- owlplanner/plotting/__init__.py +16 -3
- owlplanner/plotting/base.py +17 -3
- owlplanner/plotting/factory.py +16 -3
- owlplanner/plotting/matplotlib_backend.py +30 -7
- owlplanner/plotting/plotly_backend.py +32 -9
- owlplanner/progress.py +16 -3
- owlplanner/rates.py +366 -361
- owlplanner/socialsecurity.py +28 -19
- owlplanner/tax2026.py +109 -30
- owlplanner/timelists.py +194 -18
- owlplanner/utils.py +179 -4
- owlplanner/version.py +20 -1
- {owlplanner-2025.12.20.dist-info → owlplanner-2026.1.26.dist-info}/METADATA +10 -2
- owlplanner-2026.1.26.dist-info/RECORD +36 -0
- owlplanner-2026.1.26.dist-info/entry_points.txt +2 -0
- owlplanner-2026.1.26.dist-info/licenses/AUTHORS +15 -0
- owlplanner/tax2025.py +0 -359
- owlplanner-2025.12.20.dist-info/RECORD +0 -29
- {owlplanner-2025.12.20.dist-info → owlplanner-2026.1.26.dist-info}/WHEEL +0 -0
- {owlplanner-2025.12.20.dist-info → owlplanner-2026.1.26.dist-info}/licenses/LICENSE +0 -0
owlplanner/plan.py
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
"""
|
|
2
|
+
Core retirement planning module using linear programming optimization.
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
A retirement planner using linear programming optimization.
|
|
7
|
-
|
|
8
|
-
See companion PDF document for an explanation of the underlying
|
|
4
|
+
This module implements the main Plan class and optimization logic for retirement
|
|
5
|
+
financial planning. See companion PDF document for an explanation of the underlying
|
|
9
6
|
mathematical model and a description of all variables and parameters.
|
|
10
7
|
|
|
11
|
-
Copyright
|
|
8
|
+
Copyright (C) 2025-2026 The Owlplanner Authors
|
|
12
9
|
|
|
13
|
-
|
|
10
|
+
This program is free software: you can redistribute it and/or modify
|
|
11
|
+
it under the terms of the GNU General Public License as published by
|
|
12
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
13
|
+
(at your option) any later version.
|
|
14
14
|
|
|
15
|
+
This program is distributed in the hope that it will be useful,
|
|
16
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
17
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
18
|
+
GNU General Public License for more details.
|
|
19
|
+
|
|
20
|
+
You should have received a copy of the GNU General Public License
|
|
21
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
15
22
|
"""
|
|
16
23
|
|
|
17
24
|
###########################################################################
|
|
@@ -22,9 +29,10 @@ from functools import wraps
|
|
|
22
29
|
from openpyxl import Workbook
|
|
23
30
|
from openpyxl.utils.dataframe import dataframe_to_rows
|
|
24
31
|
import time
|
|
32
|
+
import textwrap
|
|
25
33
|
|
|
26
34
|
from . import utils as u
|
|
27
|
-
from . import
|
|
35
|
+
from . import tax2026 as tx
|
|
28
36
|
from . import abcapi as abc
|
|
29
37
|
from . import rates
|
|
30
38
|
from . import config
|
|
@@ -37,6 +45,17 @@ from . import progress
|
|
|
37
45
|
from .plotting.factory import PlotFactory
|
|
38
46
|
|
|
39
47
|
|
|
48
|
+
# Default values
|
|
49
|
+
BIGM_XOR = 5e7 # 100 times large withdrawals or conversions
|
|
50
|
+
BIGM_IRMAA = 5e7 # 100 times large MAGI
|
|
51
|
+
GAP = 1e-4
|
|
52
|
+
MILP_GAP = 10 * GAP
|
|
53
|
+
MAX_ITERATIONS = 29
|
|
54
|
+
ABS_TOL = 20
|
|
55
|
+
REL_TOL = 1e-6
|
|
56
|
+
TIME_LIMIT = 900
|
|
57
|
+
|
|
58
|
+
|
|
40
59
|
def _genGamma_n(tau):
|
|
41
60
|
"""
|
|
42
61
|
Utility function to generate a cumulative inflation multiplier
|
|
@@ -85,7 +104,7 @@ def _genXi_n(profile, fraction, n_d, N_n, a, b, c):
|
|
|
85
104
|
xi[n_d:] *= fraction
|
|
86
105
|
xi *= neutralSum / xi.sum()
|
|
87
106
|
else:
|
|
88
|
-
raise ValueError(f"Unknown profile type {profile}.")
|
|
107
|
+
raise ValueError(f"Unknown profile type '{profile}'.")
|
|
89
108
|
|
|
90
109
|
return xi
|
|
91
110
|
|
|
@@ -127,20 +146,18 @@ def _q4(C, l1, l2, l3, l4, N1, N2, N3, N4):
|
|
|
127
146
|
|
|
128
147
|
def clone(plan, newname=None, *, verbose=True, logstreams=None):
|
|
129
148
|
"""
|
|
130
|
-
Return an almost identical copy of plan: only the name of the
|
|
149
|
+
Return an almost identical copy of plan: only the name of the case
|
|
131
150
|
has been modified and appended the string '(copy)',
|
|
132
151
|
unless a new name is provided as an argument.
|
|
133
152
|
"""
|
|
134
153
|
import copy
|
|
135
154
|
|
|
136
|
-
#
|
|
137
|
-
mylogger = plan.logger()
|
|
138
|
-
plan.setLogger(None)
|
|
155
|
+
# logger __deepcopy__ sets the logstreams of new logger to None
|
|
139
156
|
newplan = copy.deepcopy(plan)
|
|
140
|
-
plan.setLogger(mylogger)
|
|
141
157
|
|
|
142
158
|
if logstreams is None:
|
|
143
|
-
|
|
159
|
+
original_logger = plan.logger()
|
|
160
|
+
newplan.setLogger(original_logger)
|
|
144
161
|
else:
|
|
145
162
|
newplan.setLogstreams(verbose, logstreams)
|
|
146
163
|
|
|
@@ -164,7 +181,7 @@ def _checkCaseStatus(func):
|
|
|
164
181
|
@wraps(func)
|
|
165
182
|
def wrapper(self, *args, **kwargs):
|
|
166
183
|
if self.caseStatus != "solved":
|
|
167
|
-
self.mylog.
|
|
184
|
+
self.mylog.print(f"Preventing to run method {func.__name__}() while case is {self.caseStatus}.")
|
|
168
185
|
return None
|
|
169
186
|
return func(self, *args, **kwargs)
|
|
170
187
|
|
|
@@ -181,11 +198,11 @@ def _checkConfiguration(func):
|
|
|
181
198
|
def wrapper(self, *args, **kwargs):
|
|
182
199
|
if self.xi_n is None:
|
|
183
200
|
msg = f"You must define a spending profile before calling {func.__name__}()."
|
|
184
|
-
self.mylog.
|
|
201
|
+
self.mylog.print(msg)
|
|
185
202
|
raise RuntimeError(msg)
|
|
186
203
|
if self.alpha_ijkn is None:
|
|
187
204
|
msg = f"You must define an allocation profile before calling {func.__name__}()."
|
|
188
|
-
self.mylog.
|
|
205
|
+
self.mylog.print(msg)
|
|
189
206
|
raise RuntimeError(msg)
|
|
190
207
|
return func(self, *args, **kwargs)
|
|
191
208
|
|
|
@@ -204,7 +221,8 @@ def _timer(func):
|
|
|
204
221
|
result = func(self, *args, **kwargs)
|
|
205
222
|
pt = time.process_time() - pt0
|
|
206
223
|
rt = time.time() - rt0
|
|
207
|
-
self.mylog.vprint(f"CPU time used: {int(pt / 60)}m{pt % 60:.1f}s, Wall time: {int(rt / 60)}m{rt % 60:.1f}s."
|
|
224
|
+
self.mylog.vprint(f"CPU time used: {int(pt / 60)}m{pt % 60:.1f}s, Wall time: {int(rt / 60)}m{rt % 60:.1f}s.",
|
|
225
|
+
tag="INFO")
|
|
208
226
|
return result
|
|
209
227
|
|
|
210
228
|
return wrapper
|
|
@@ -214,6 +232,17 @@ class Plan:
|
|
|
214
232
|
"""
|
|
215
233
|
This is the main class of the Owl Project.
|
|
216
234
|
"""
|
|
235
|
+
# Class-level counter for unique Plan IDs
|
|
236
|
+
_id_counter = 0
|
|
237
|
+
|
|
238
|
+
@classmethod
|
|
239
|
+
def get_next_id(cls):
|
|
240
|
+
cls._id_counter += 1
|
|
241
|
+
return cls._id_counter
|
|
242
|
+
|
|
243
|
+
@classmethod
|
|
244
|
+
def get_current_id(cls):
|
|
245
|
+
return cls._id_counter
|
|
217
246
|
|
|
218
247
|
def __init__(self, inames, dobs, expectancy, name, *, verbose=False, logstreams=None):
|
|
219
248
|
"""
|
|
@@ -221,11 +250,14 @@ class Plan:
|
|
|
221
250
|
one contains the name(s) of the individual(s),
|
|
222
251
|
the second one is the year of birth of each individual,
|
|
223
252
|
and the third the life expectancy. Last argument is a name for
|
|
224
|
-
the
|
|
253
|
+
the case.
|
|
225
254
|
"""
|
|
226
255
|
if name == "":
|
|
227
256
|
raise ValueError("Plan must have a name")
|
|
228
257
|
|
|
258
|
+
# Generate unique ID for this Plan instance using the class method
|
|
259
|
+
self._id = Plan.get_next_id()
|
|
260
|
+
|
|
229
261
|
self._name = name
|
|
230
262
|
self.setLogstreams(verbose, logstreams)
|
|
231
263
|
|
|
@@ -234,8 +266,8 @@ class Plan:
|
|
|
234
266
|
self.N_q = 6
|
|
235
267
|
self.N_j = 3
|
|
236
268
|
self.N_k = 4
|
|
237
|
-
#
|
|
238
|
-
self.N_zx =
|
|
269
|
+
# 4 binary variables for exclusions.
|
|
270
|
+
self.N_zx = 4
|
|
239
271
|
|
|
240
272
|
# Default interpolation parameters for allocation ratios.
|
|
241
273
|
self.interpMethod = "linear"
|
|
@@ -274,8 +306,11 @@ class Plan:
|
|
|
274
306
|
thisyear = date.today().year
|
|
275
307
|
self.horizons = self.yobs + self.expectancy - thisyear + 1
|
|
276
308
|
self.N_n = np.max(self.horizons)
|
|
309
|
+
if self.N_n <= 2:
|
|
310
|
+
raise ValueError(f"Plan needs more than {self.N_n} years.")
|
|
311
|
+
|
|
277
312
|
self.year_n = np.linspace(thisyear, thisyear + self.N_n - 1, self.N_n, dtype=np.int32)
|
|
278
|
-
# Year index in the
|
|
313
|
+
# Year index in the case (if any) where individuals turn 59. For 10% withdrawal penalty.
|
|
279
314
|
self.n59 = 59 - thisyear + self.yobs
|
|
280
315
|
self.n59[self.n59 < 0] = 0
|
|
281
316
|
# Handle passing of one spouse before the other.
|
|
@@ -290,9 +325,11 @@ class Plan:
|
|
|
290
325
|
|
|
291
326
|
# Default parameters:
|
|
292
327
|
self.psi_n = np.zeros(self.N_n) # Long-term income tax rate on capital gains (decimal)
|
|
293
|
-
|
|
294
|
-
self.
|
|
295
|
-
self.
|
|
328
|
+
# Fraction of social security benefits that is taxed (fixed at 85% for now).
|
|
329
|
+
self.Psi_n = np.ones(self.N_n) * 0.85
|
|
330
|
+
self.chi = 0.60 # Survivor fraction
|
|
331
|
+
self.mu = 0.0172 # Dividend rate (decimal)
|
|
332
|
+
self.nu = 0.300 # Heirs tax rate (decimal)
|
|
296
333
|
self.eta = (self.N_i - 1) / 2 # Spousal deposit ratio (0 or .5)
|
|
297
334
|
self.phi_j = np.array([1, 1, 1]) # Fractions left to other spouse at death
|
|
298
335
|
self.smileDip = 15 # Percent to reduce smile profile
|
|
@@ -330,6 +367,7 @@ class Plan:
|
|
|
330
367
|
# Previous 2 years of MAGI needed for Medicare.
|
|
331
368
|
self.prevMAGI = np.zeros((2))
|
|
332
369
|
self.MAGI_n = np.zeros(self.N_n)
|
|
370
|
+
self.solverOptions = {}
|
|
333
371
|
|
|
334
372
|
# Init current balances to none.
|
|
335
373
|
self.beta_ij = None
|
|
@@ -340,13 +378,13 @@ class Plan:
|
|
|
340
378
|
|
|
341
379
|
# Scenario starts at the beginning of this year and ends at the end of the last year.
|
|
342
380
|
s = ("", "s")[self.N_i - 1]
|
|
343
|
-
self.mylog.vprint(f"Preparing scenario of {self.N_n} years for {self.N_i} individual{s}.")
|
|
381
|
+
self.mylog.vprint(f"Preparing scenario '{self._id}' of {self.N_n} years for {self.N_i} individual{s}.")
|
|
344
382
|
for i in range(self.N_i):
|
|
345
383
|
endyear = thisyear + self.horizons[i] - 1
|
|
346
384
|
self.mylog.vprint(f"{self.inames[i]:>14}: life horizon from {thisyear} -> {endyear}.")
|
|
347
385
|
|
|
348
386
|
# Prepare RMD time series.
|
|
349
|
-
self.rho_in = tx.rho_in(self.yobs, self.N_n)
|
|
387
|
+
self.rho_in = tx.rho_in(self.yobs, self.expectancy, self.N_n)
|
|
350
388
|
|
|
351
389
|
# Initialize guardrails to ensure proper configuration.
|
|
352
390
|
self._adjustedParameters = False
|
|
@@ -355,7 +393,11 @@ class Plan:
|
|
|
355
393
|
self.houseLists = {}
|
|
356
394
|
self.zeroContributions()
|
|
357
395
|
self.caseStatus = "unsolved"
|
|
396
|
+
# "monotonic", "oscillatory", "max iteration", or "undefined" - how solution was obtained
|
|
397
|
+
self.convergenceType = "undefined"
|
|
358
398
|
self.rateMethod = None
|
|
399
|
+
self.reproducibleRates = False
|
|
400
|
+
self.rateSeed = None
|
|
359
401
|
|
|
360
402
|
self.ARCoord = None
|
|
361
403
|
self.objective = "unknown"
|
|
@@ -371,7 +413,7 @@ class Plan:
|
|
|
371
413
|
|
|
372
414
|
def setLogstreams(self, verbose, logstreams):
|
|
373
415
|
self.mylog = log.Logger(verbose, logstreams)
|
|
374
|
-
|
|
416
|
+
self.mylog.vprint(f"Setting logger with logstreams {logstreams}.")
|
|
375
417
|
|
|
376
418
|
def logger(self):
|
|
377
419
|
return self.mylog
|
|
@@ -386,7 +428,7 @@ class Plan:
|
|
|
386
428
|
|
|
387
429
|
def _setStartingDate(self, mydate):
|
|
388
430
|
"""
|
|
389
|
-
Set the date when the
|
|
431
|
+
Set the date when the case starts in the current year.
|
|
390
432
|
This is mostly for reproducibility purposes and back projecting known balances to Jan 1st.
|
|
391
433
|
String format of mydate is 'MM/DD', 'MM-DD', 'YYYY-MM-DD', or 'YYYY/MM/DD'. Year is ignored.
|
|
392
434
|
"""
|
|
@@ -432,16 +474,16 @@ class Plan:
|
|
|
432
474
|
|
|
433
475
|
def rename(self, newname):
|
|
434
476
|
"""
|
|
435
|
-
Override name of the
|
|
477
|
+
Override name of the case. Case name is used
|
|
436
478
|
to distinguish graph outputs and as base name for
|
|
437
479
|
saving configurations and workbooks.
|
|
438
480
|
"""
|
|
439
|
-
self.mylog.vprint(f"Renaming
|
|
481
|
+
self.mylog.vprint(f"Renaming case '{self._name}' -> '{newname}'.")
|
|
440
482
|
self._name = newname
|
|
441
483
|
|
|
442
484
|
def setDescription(self, description):
|
|
443
485
|
"""
|
|
444
|
-
Set a text description of the
|
|
486
|
+
Set a text description of the case.
|
|
445
487
|
"""
|
|
446
488
|
self._description = description
|
|
447
489
|
|
|
@@ -458,7 +500,7 @@ class Plan:
|
|
|
458
500
|
if not (0 <= eta <= 1):
|
|
459
501
|
raise ValueError("Fraction must be between 0 and 1.")
|
|
460
502
|
if self.N_i != 2:
|
|
461
|
-
self.mylog.
|
|
503
|
+
self.mylog.print("Deposit fraction can only be 0 for single individuals.")
|
|
462
504
|
eta = 0
|
|
463
505
|
else:
|
|
464
506
|
self.mylog.vprint(f"Setting spousal surplus deposit fraction to {eta:.1f}.")
|
|
@@ -471,7 +513,7 @@ class Plan:
|
|
|
471
513
|
"""
|
|
472
514
|
|
|
473
515
|
self.defaultPlots = self._checkValueType(value)
|
|
474
|
-
self.mylog.vprint(f"Setting plots default value to {value}.")
|
|
516
|
+
self.mylog.vprint(f"Setting plots default value to '{value}'.")
|
|
475
517
|
|
|
476
518
|
def setPlotBackend(self, backend: str):
|
|
477
519
|
"""
|
|
@@ -479,12 +521,12 @@ class Plan:
|
|
|
479
521
|
"""
|
|
480
522
|
|
|
481
523
|
if backend not in ("matplotlib", "plotly"):
|
|
482
|
-
raise ValueError(f"Backend {backend} not a valid option.")
|
|
524
|
+
raise ValueError(f"Backend '{backend}' not a valid option.")
|
|
483
525
|
|
|
484
526
|
if backend != self._plotterName:
|
|
485
527
|
self._plotter = PlotFactory.createBackend(backend)
|
|
486
528
|
self._plotterName = backend
|
|
487
|
-
self.mylog.vprint(f"Setting plotting backend to {backend}.")
|
|
529
|
+
self.mylog.vprint(f"Setting plotting backend to '{backend}'.")
|
|
488
530
|
|
|
489
531
|
def setDividendRate(self, mu):
|
|
490
532
|
"""
|
|
@@ -522,8 +564,8 @@ class Plan:
|
|
|
522
564
|
self.caseStatus = "modified"
|
|
523
565
|
|
|
524
566
|
if np.any(self.phi_j != 1):
|
|
525
|
-
self.mylog.
|
|
526
|
-
self.mylog.
|
|
567
|
+
self.mylog.print("Consider changing spousal deposit fraction for better convergence.")
|
|
568
|
+
self.mylog.print(f"\tRecommended: setSpousalDepositFraction({self.i_d}.)")
|
|
527
569
|
|
|
528
570
|
def setHeirsTaxRate(self, nu):
|
|
529
571
|
"""
|
|
@@ -581,6 +623,9 @@ class Plan:
|
|
|
581
623
|
def setSocialSecurity(self, pias, ages):
|
|
582
624
|
"""
|
|
583
625
|
Set value of social security for each individual and claiming age.
|
|
626
|
+
|
|
627
|
+
Note: Social Security benefits are paid in arrears (one month after eligibility).
|
|
628
|
+
The zeta_in array represents when checks actually arrive, not when eligibility starts.
|
|
584
629
|
"""
|
|
585
630
|
if len(pias) != self.N_i:
|
|
586
631
|
raise ValueError(f"Principal Insurance Amount must have {self.N_i} entries.")
|
|
@@ -594,51 +639,52 @@ class Plan:
|
|
|
594
639
|
fras = socsec.getFRAs(self.yobs)
|
|
595
640
|
spousalBenefits = socsec.getSpousalBenefits(pias)
|
|
596
641
|
|
|
597
|
-
self.mylog.vprint(
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
)
|
|
601
|
-
self.mylog.vprint(
|
|
602
|
-
"Benefits requested to start at age(s)", [ages[i] for i in range(self.N_i)],
|
|
603
|
-
)
|
|
642
|
+
self.mylog.vprint("SS monthly PIAs set to", [u.d(pias[i]) for i in range(self.N_i)])
|
|
643
|
+
self.mylog.vprint("SS FRAs(s)", [fras[i] for i in range(self.N_i)])
|
|
644
|
+
self.mylog.vprint("SS benefits claimed at age(s)", [ages[i] for i in range(self.N_i)])
|
|
604
645
|
|
|
605
646
|
thisyear = date.today().year
|
|
606
647
|
self.zeta_in = np.zeros((self.N_i, self.N_n))
|
|
607
648
|
for i in range(self.N_i):
|
|
608
649
|
# Check if age is in bound.
|
|
609
650
|
bornOnFirstDays = (self.tobs[i] <= 2)
|
|
610
|
-
bornOnFirst = (self.tobs[i] == 1)
|
|
611
651
|
|
|
612
652
|
eligible = 62 if bornOnFirstDays else 62 + 1/12
|
|
613
653
|
if ages[i] < eligible:
|
|
614
|
-
self.mylog.
|
|
654
|
+
self.mylog.print(f"Resetting SS claiming age of {self.inames[i]} to {eligible}.")
|
|
615
655
|
ages[i] = eligible
|
|
616
656
|
|
|
617
657
|
# Check if claim age added to birth month falls next year.
|
|
618
|
-
# janage is age with reference to Jan 1 of yob.
|
|
658
|
+
# janage is age with reference to Jan 1 of yob when eligibility starts.
|
|
619
659
|
janage = ages[i] + (self.mobs[i] - 1)/12
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
660
|
+
|
|
661
|
+
# Social Security benefits are paid in arrears (one month after eligibility).
|
|
662
|
+
# Calculate when payments actually start (checks arrive).
|
|
663
|
+
paymentJanage = janage + 1/12
|
|
664
|
+
paymentIage = int(paymentJanage)
|
|
665
|
+
paymentRealns = self.yobs[i] + paymentIage - thisyear
|
|
666
|
+
ns = max(0, paymentRealns)
|
|
623
667
|
nd = self.horizons[i]
|
|
624
668
|
self.zeta_in[i, ns:nd] = pias[i]
|
|
625
|
-
# Reduce starting year due to month offset. If
|
|
626
|
-
if
|
|
627
|
-
self.zeta_in[i, ns] *= 1 - (
|
|
669
|
+
# Reduce starting year due to month offset. If paymentRealns < 0, this has happened already.
|
|
670
|
+
if paymentRealns >= 0:
|
|
671
|
+
self.zeta_in[i, ns] *= 1 - (paymentJanage % 1.)
|
|
628
672
|
|
|
629
673
|
# Increase/decrease PIA due to claiming age.
|
|
630
|
-
self.zeta_in[i, :] *= socsec.getSelfFactor(fras[i], ages[i],
|
|
674
|
+
self.zeta_in[i, :] *= socsec.getSelfFactor(fras[i], ages[i], bornOnFirstDays)
|
|
631
675
|
|
|
632
676
|
# Add spousal benefits if applicable.
|
|
633
677
|
if self.N_i == 2 and spousalBenefits[i] > 0:
|
|
634
|
-
# The latest of the two spouses to claim.
|
|
678
|
+
# The latest of the two spouses to claim (eligibility start).
|
|
635
679
|
claimYear = max(self.yobs + (self.mobs - 1)/12 + ages)
|
|
636
680
|
claimAge = claimYear - self.yobs[i] - (self.mobs[i] - 1)/12
|
|
637
|
-
|
|
638
|
-
|
|
681
|
+
# Spousal benefits are also paid in arrears (one month after eligibility).
|
|
682
|
+
paymentClaimYear = claimYear + 1/12
|
|
683
|
+
ns2 = max(0, int(paymentClaimYear) - thisyear)
|
|
684
|
+
spousalFactor = socsec.getSpousalFactor(fras[i], claimAge, bornOnFirstDays)
|
|
639
685
|
self.zeta_in[i, ns2:nd] += spousalBenefits[i] * spousalFactor
|
|
640
686
|
# Reduce first year of benefit by month offset.
|
|
641
|
-
self.zeta_in[i, ns2] -= spousalBenefits[i] * spousalFactor * (
|
|
687
|
+
self.zeta_in[i, ns2] -= spousalBenefits[i] * spousalFactor * (paymentClaimYear % 1.)
|
|
642
688
|
|
|
643
689
|
# Switch survivor to spousal survivor benefits.
|
|
644
690
|
# Assumes both deceased and survivor already have claimed last year before passing (at n_d - 1).
|
|
@@ -682,7 +728,36 @@ class Plan:
|
|
|
682
728
|
self.smileDelay = delay
|
|
683
729
|
self.caseStatus = "modified"
|
|
684
730
|
|
|
685
|
-
def
|
|
731
|
+
def setReproducible(self, reproducible, seed=None):
|
|
732
|
+
"""
|
|
733
|
+
Set whether rates should be reproducible for stochastic methods.
|
|
734
|
+
This should be called before setting rates. It only sets configuration
|
|
735
|
+
and does not regenerate existing rates.
|
|
736
|
+
|
|
737
|
+
Args:
|
|
738
|
+
reproducible: Boolean indicating if rates should be reproducible.
|
|
739
|
+
seed: Optional seed value. If None and reproducible is True,
|
|
740
|
+
generates a new seed from current time. If None and
|
|
741
|
+
reproducible is False, generates a seed but won't reuse it.
|
|
742
|
+
"""
|
|
743
|
+
self.reproducibleRates = bool(reproducible)
|
|
744
|
+
if reproducible:
|
|
745
|
+
if seed is None:
|
|
746
|
+
if self.rateSeed is not None:
|
|
747
|
+
# Reuse existing seed if available
|
|
748
|
+
seed = self.rateSeed
|
|
749
|
+
else:
|
|
750
|
+
# Generate new seed from current time
|
|
751
|
+
seed = int(time.time() * 1000000) # Use microseconds
|
|
752
|
+
else:
|
|
753
|
+
seed = int(seed)
|
|
754
|
+
self.rateSeed = seed
|
|
755
|
+
else:
|
|
756
|
+
# For non-reproducible rates, clear the seed
|
|
757
|
+
# setRates() will generate a new seed each time it's called
|
|
758
|
+
self.rateSeed = None
|
|
759
|
+
|
|
760
|
+
def setRates(self, method, frm=None, to=None, values=None, stdev=None, corr=None, override_reproducible=False):
|
|
686
761
|
"""
|
|
687
762
|
Generate rates for return and inflation based on the method and
|
|
688
763
|
years selected. Note that last bound is included.
|
|
@@ -697,28 +772,77 @@ class Plan:
|
|
|
697
772
|
must be provided, and optionally an ending year.
|
|
698
773
|
|
|
699
774
|
Valid year range is from 1928 to last year.
|
|
775
|
+
|
|
776
|
+
Note: For stochastic methods, setReproducible() should be called before
|
|
777
|
+
setRates() to set the reproducibility flag and seed. If not called,
|
|
778
|
+
defaults to non-reproducible behavior.
|
|
779
|
+
|
|
780
|
+
Args:
|
|
781
|
+
override_reproducible: If True, override reproducibility setting and always generate new rates.
|
|
782
|
+
Used by Monte-Carlo runs to ensure different rates each time.
|
|
700
783
|
"""
|
|
701
784
|
if frm is not None and to is None:
|
|
702
785
|
to = frm + self.N_n - 1 # 'to' is inclusive.
|
|
703
786
|
|
|
704
|
-
|
|
787
|
+
# Handle seed for stochastic methods
|
|
788
|
+
if method in ["stochastic", "histochastic"]:
|
|
789
|
+
if self.reproducibleRates and not override_reproducible:
|
|
790
|
+
if self.rateSeed is None:
|
|
791
|
+
raise RuntimeError("Config error: reproducibleRates is True but rateSeed is None.")
|
|
792
|
+
|
|
793
|
+
seed = self.rateSeed
|
|
794
|
+
else:
|
|
795
|
+
# For non-reproducible rates or when overriding reproducibility, generate a new seed from time.
|
|
796
|
+
# This ensures we always have a seed stored in config, but it won't be reused.
|
|
797
|
+
seed = int(time.time() * 1000000)
|
|
798
|
+
if not override_reproducible:
|
|
799
|
+
self.rateSeed = seed
|
|
800
|
+
else:
|
|
801
|
+
# For non-stochastic methods, seed is not used but we preserve it
|
|
802
|
+
# so that if user switches back to stochastic, their reproducibility settings are maintained.
|
|
803
|
+
seed = None
|
|
804
|
+
|
|
805
|
+
dr = rates.Rates(self.mylog, seed=seed)
|
|
705
806
|
self.rateValues, self.rateStdev, self.rateCorr = dr.setMethod(method, frm, to, values, stdev, corr)
|
|
706
807
|
self.rateMethod = method
|
|
707
808
|
self.rateFrm = frm
|
|
708
809
|
self.rateTo = to
|
|
709
810
|
self.tau_kn = dr.genSeries(self.N_n).transpose()
|
|
710
|
-
self.mylog.vprint(f"Generating rate series of {len(self.tau_kn[0])} years using {method} method.")
|
|
811
|
+
self.mylog.vprint(f"Generating rate series of {len(self.tau_kn[0])} years using '{method}' method.")
|
|
812
|
+
if method in ["stochastic", "histochastic"]:
|
|
813
|
+
repro_status = "reproducible" if self.reproducibleRates else "non-reproducible"
|
|
814
|
+
self.mylog.print(f"Using seed {seed} for {repro_status} rates.")
|
|
711
815
|
|
|
712
816
|
# Once rates are selected, (re)build cumulative inflation multipliers.
|
|
713
817
|
self.gamma_n = _genGamma_n(self.tau_kn)
|
|
714
818
|
self._adjustedParameters = False
|
|
715
819
|
self.caseStatus = "modified"
|
|
716
820
|
|
|
717
|
-
def regenRates(self):
|
|
821
|
+
def regenRates(self, override_reproducible=False):
|
|
718
822
|
"""
|
|
719
823
|
Regenerate the rates using the arguments specified during last setRates() call.
|
|
720
824
|
This method is used to regenerate stochastic time series.
|
|
721
|
-
|
|
825
|
+
Only stochastic and histochastic methods need regeneration.
|
|
826
|
+
All fixed rate methods (default, optimistic, conservative, user, historical average,
|
|
827
|
+
historical) don't need regeneration as they produce the same values.
|
|
828
|
+
|
|
829
|
+
Args:
|
|
830
|
+
override_reproducible: If True, override reproducibility setting and always generate new rates.
|
|
831
|
+
Used by Monte-Carlo runs to ensure each run gets different rates.
|
|
832
|
+
"""
|
|
833
|
+
# 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:
|
|
837
|
+
return
|
|
838
|
+
|
|
839
|
+
# Only stochastic methods reach here
|
|
840
|
+
# If reproducibility is enabled and we're not overriding it, don't regenerate
|
|
841
|
+
# (rates should stay the same for reproducibility)
|
|
842
|
+
if self.reproducibleRates and not override_reproducible:
|
|
843
|
+
return
|
|
844
|
+
|
|
845
|
+
# Regenerate with new random values
|
|
722
846
|
self.setRates(
|
|
723
847
|
self.rateMethod,
|
|
724
848
|
frm=self.rateFrm,
|
|
@@ -726,6 +850,7 @@ class Plan:
|
|
|
726
850
|
values=100 * self.rateValues,
|
|
727
851
|
stdev=100 * self.rateStdev,
|
|
728
852
|
corr=self.rateCorr,
|
|
853
|
+
override_reproducible=override_reproducible,
|
|
729
854
|
)
|
|
730
855
|
|
|
731
856
|
def setAccountBalances(self, *, taxable, taxDeferred, taxFree, startDate=None, units="k"):
|
|
@@ -786,12 +911,12 @@ class Plan:
|
|
|
786
911
|
self.interpCenter = center
|
|
787
912
|
self.interpWidth = width
|
|
788
913
|
else:
|
|
789
|
-
raise ValueError(f"Method {method} not supported.")
|
|
914
|
+
raise ValueError(f"Method '{method}' not supported.")
|
|
790
915
|
|
|
791
916
|
self.interpMethod = method
|
|
792
917
|
self.caseStatus = "modified"
|
|
793
918
|
|
|
794
|
-
self.mylog.vprint(f"Asset allocation interpolation method set to {method}.")
|
|
919
|
+
self.mylog.vprint(f"Asset allocation interpolation method set to '{method}'.")
|
|
795
920
|
|
|
796
921
|
def setAllocationRatios(self, allocType, taxable=None, taxDeferred=None, taxFree=None, generic=None):
|
|
797
922
|
"""
|
|
@@ -815,6 +940,11 @@ class Plan:
|
|
|
815
940
|
generic = [[ko00, ko01, ko02, ko03], [kf00, kf01, kf02, kf02]]
|
|
816
941
|
as assets are coordinated between accounts and spouses.
|
|
817
942
|
"""
|
|
943
|
+
# Validate allocType parameter
|
|
944
|
+
validTypes = ["account", "individual", "spouses"]
|
|
945
|
+
if allocType not in validTypes:
|
|
946
|
+
raise ValueError(f"allocType must be one of {validTypes}, got '{allocType}'.")
|
|
947
|
+
|
|
818
948
|
self.boundsAR = {}
|
|
819
949
|
self.alpha_ijkn = np.zeros((self.N_i, self.N_j, self.N_k, self.N_n + 1))
|
|
820
950
|
if allocType == "account":
|
|
@@ -833,7 +963,7 @@ class Plan:
|
|
|
833
963
|
raise ValueError("Sum of percentages must add to 100.")
|
|
834
964
|
|
|
835
965
|
for i in range(self.N_i):
|
|
836
|
-
self.mylog.vprint(f"{self.inames[i]}: Setting gliding allocation ratios (%) to {allocType}.")
|
|
966
|
+
self.mylog.vprint(f"{self.inames[i]}: Setting gliding allocation ratios (%) to '{allocType}'.")
|
|
837
967
|
self.mylog.vprint(f" taxable: {taxable[i][0]} -> {taxable[i][1]}")
|
|
838
968
|
self.mylog.vprint(f" taxDeferred: {taxDeferred[i][0]} -> {taxDeferred[i][1]}")
|
|
839
969
|
self.mylog.vprint(f" taxFree: {taxFree[i][0]} -> {taxFree[i][1]}")
|
|
@@ -870,7 +1000,7 @@ class Plan:
|
|
|
870
1000
|
raise ValueError("Sum of percentages must add to 100.")
|
|
871
1001
|
|
|
872
1002
|
for i in range(self.N_i):
|
|
873
|
-
self.mylog.vprint(f"{self.inames[i]}: Setting gliding allocation ratios (%) to {allocType}.")
|
|
1003
|
+
self.mylog.vprint(f"{self.inames[i]}: Setting gliding allocation ratios (%) to '{allocType}'.")
|
|
874
1004
|
self.mylog.vprint(f"\t{generic[i][0]} -> {generic[i][1]}")
|
|
875
1005
|
|
|
876
1006
|
for i in range(self.N_i):
|
|
@@ -905,9 +1035,9 @@ class Plan:
|
|
|
905
1035
|
self.ARCoord = allocType
|
|
906
1036
|
self.caseStatus = "modified"
|
|
907
1037
|
|
|
908
|
-
self.mylog.vprint(f"Interpolating assets allocation ratios using {self.interpMethod} method.")
|
|
1038
|
+
self.mylog.vprint(f"Interpolating assets allocation ratios using '{self.interpMethod}' method.")
|
|
909
1039
|
|
|
910
|
-
def readContributions(self, filename):
|
|
1040
|
+
def readContributions(self, filename, filename_for_logging=None):
|
|
911
1041
|
"""
|
|
912
1042
|
Provide the name of the file containing the financial events
|
|
913
1043
|
over the anticipated life span determined by the
|
|
@@ -927,13 +1057,24 @@ class Plan:
|
|
|
927
1057
|
|
|
928
1058
|
in any order. A template is provided as an example.
|
|
929
1059
|
Missing rows (years) are populated with zero values.
|
|
1060
|
+
|
|
1061
|
+
Parameters
|
|
1062
|
+
----------
|
|
1063
|
+
filename : file-like object, str, or dict
|
|
1064
|
+
Input file or dictionary of DataFrames
|
|
1065
|
+
filename_for_logging : str, optional
|
|
1066
|
+
Explicit filename for logging purposes. If provided, this will be used
|
|
1067
|
+
in log messages instead of trying to extract it from filename.
|
|
930
1068
|
"""
|
|
931
1069
|
try:
|
|
932
|
-
|
|
1070
|
+
returned_filename, self.timeLists, self.houseLists = timelists.read(
|
|
1071
|
+
filename, self.inames, self.horizons, self.mylog, filename=filename_for_logging
|
|
1072
|
+
)
|
|
933
1073
|
except Exception as e:
|
|
934
1074
|
raise Exception(f"Unsuccessful read of Household Financial Profile: {e}") from e
|
|
935
1075
|
|
|
936
|
-
|
|
1076
|
+
# Use filename_for_logging if provided, otherwise use returned filename
|
|
1077
|
+
self.timeListsFileName = filename_for_logging if filename_for_logging is not None else returned_filename
|
|
937
1078
|
self.setContributions()
|
|
938
1079
|
|
|
939
1080
|
return True
|
|
@@ -954,12 +1095,20 @@ class Plan:
|
|
|
954
1095
|
|
|
955
1096
|
# Values for last 5 years of Roth conversion and contributions stored at the end
|
|
956
1097
|
# of array and accessed with negative index.
|
|
957
|
-
self.kappa_ijn[i, 0, :h
|
|
958
|
-
self.kappa_ijn[i, 1, :h
|
|
959
|
-
self.kappa_ijn[i, 1, :h
|
|
960
|
-
self.kappa_ijn[i, 2, :h
|
|
961
|
-
self.kappa_ijn[i, 2, :h
|
|
962
|
-
self.myRothX_in[i, :h
|
|
1098
|
+
self.kappa_ijn[i, 0, :h] = self.timeLists[iname]["taxable ctrb"][5:h+5]
|
|
1099
|
+
self.kappa_ijn[i, 1, :h] = self.timeLists[iname]["401k ctrb"][5:h+5]
|
|
1100
|
+
self.kappa_ijn[i, 1, :h] += self.timeLists[iname]["IRA ctrb"][5:h+5]
|
|
1101
|
+
self.kappa_ijn[i, 2, :h] = self.timeLists[iname]["Roth 401k ctrb"][5:h+5]
|
|
1102
|
+
self.kappa_ijn[i, 2, :h] += self.timeLists[iname]["Roth IRA ctrb"][5:h+5]
|
|
1103
|
+
self.myRothX_in[i, :h] = self.timeLists[iname]["Roth conv"][5:h+5]
|
|
1104
|
+
|
|
1105
|
+
# Last 5 years are at the end of the N_n array.
|
|
1106
|
+
self.kappa_ijn[i, 0, -5:] = self.timeLists[iname]["taxable ctrb"][:5]
|
|
1107
|
+
self.kappa_ijn[i, 1, -5:] = self.timeLists[iname]["401k ctrb"][:5]
|
|
1108
|
+
self.kappa_ijn[i, 1, -5:] += self.timeLists[iname]["IRA ctrb"][:5]
|
|
1109
|
+
self.kappa_ijn[i, 2, -5:] = self.timeLists[iname]["Roth 401k ctrb"][:5]
|
|
1110
|
+
self.kappa_ijn[i, 2, -5:] += self.timeLists[iname]["Roth IRA ctrb"][:5]
|
|
1111
|
+
self.myRothX_in[i, -5:] = self.timeLists[iname]["Roth conv"][:5]
|
|
963
1112
|
|
|
964
1113
|
self.caseStatus = "modified"
|
|
965
1114
|
|
|
@@ -1062,7 +1211,7 @@ class Plan:
|
|
|
1062
1211
|
else:
|
|
1063
1212
|
# Create empty Debts sheet with proper columns
|
|
1064
1213
|
ws = wb.create_sheet("Debts")
|
|
1065
|
-
df = pd.DataFrame(columns=
|
|
1214
|
+
df = pd.DataFrame(columns=timelists._debtItems)
|
|
1066
1215
|
for row in dataframe_to_rows(df, index=False, header=True):
|
|
1067
1216
|
ws.append(row)
|
|
1068
1217
|
_formatDebtsSheet(ws)
|
|
@@ -1077,7 +1226,7 @@ class Plan:
|
|
|
1077
1226
|
else:
|
|
1078
1227
|
# Create empty Fixed Assets sheet with proper columns
|
|
1079
1228
|
ws = wb.create_sheet("Fixed Assets")
|
|
1080
|
-
df = pd.DataFrame(columns=
|
|
1229
|
+
df = pd.DataFrame(columns=timelists._fixedAssetItems)
|
|
1081
1230
|
for row in dataframe_to_rows(df, index=False, header=True):
|
|
1082
1231
|
ws.append(row)
|
|
1083
1232
|
_formatFixedAssetsSheet(ws)
|
|
@@ -1195,9 +1344,11 @@ class Plan:
|
|
|
1195
1344
|
C["w"] = _qC(C["s"], self.N_n)
|
|
1196
1345
|
C["x"] = _qC(C["w"], self.N_i, self.N_j, self.N_n)
|
|
1197
1346
|
C["zx"] = _qC(C["x"], self.N_i, self.N_n)
|
|
1198
|
-
C["zm"] = _qC(C["zx"], self.
|
|
1347
|
+
C["zm"] = _qC(C["zx"], self.N_n, self.N_zx)
|
|
1199
1348
|
self.nvars = _qC(C["zm"], self.N_n - self.nm, self.N_q - 1) if medi else C["zm"]
|
|
1200
1349
|
self.nbins = self.nvars - C["zx"]
|
|
1350
|
+
self.nconts = C["zx"]
|
|
1351
|
+
self.nbals = C["d"]
|
|
1201
1352
|
|
|
1202
1353
|
self.C = C
|
|
1203
1354
|
self.mylog.vprint(
|
|
@@ -1224,7 +1375,7 @@ class Plan:
|
|
|
1224
1375
|
self._add_conversion_limits()
|
|
1225
1376
|
self._add_objective_constraints(objective, options)
|
|
1226
1377
|
self._add_initial_balances()
|
|
1227
|
-
self._add_surplus_deposit_linking()
|
|
1378
|
+
self._add_surplus_deposit_linking(options)
|
|
1228
1379
|
self._add_account_balance_carryover()
|
|
1229
1380
|
self._add_net_cash_flow()
|
|
1230
1381
|
self._add_income_profile()
|
|
@@ -1300,42 +1451,70 @@ class Plan:
|
|
|
1300
1451
|
self.A.addRow(row, rhs, np.inf)
|
|
1301
1452
|
|
|
1302
1453
|
def _add_roth_conversion_constraints(self, options):
|
|
1454
|
+
# Values in file supercedes everything.
|
|
1303
1455
|
if "maxRothConversion" in options and options["maxRothConversion"] == "file":
|
|
1304
1456
|
for i in range(self.N_i):
|
|
1305
1457
|
for n in range(self.horizons[i]):
|
|
1306
1458
|
rhs = self.myRothX_in[i][n]
|
|
1307
1459
|
self.B.setRange(_q2(self.C["x"], i, n, self.N_i, self.N_n), rhs, rhs)
|
|
1308
1460
|
else:
|
|
1461
|
+
# Don't exclude anyone by default.
|
|
1462
|
+
i_xcluded = -1
|
|
1463
|
+
if "noRothConversions" in options and options["noRothConversions"] != "None":
|
|
1464
|
+
rhsopt = options["noRothConversions"]
|
|
1465
|
+
try:
|
|
1466
|
+
i_xcluded = self.inames.index(rhsopt)
|
|
1467
|
+
except ValueError as e:
|
|
1468
|
+
raise ValueError(f"Unknown individual '{rhsopt}' for noRothConversions:") from e
|
|
1469
|
+
for n in range(self.horizons[i_xcluded]):
|
|
1470
|
+
self.B.setRange(_q2(self.C["x"], i_xcluded, n, self.N_i, self.N_n), 0, 0)
|
|
1471
|
+
|
|
1309
1472
|
if "maxRothConversion" in options:
|
|
1310
|
-
rhsopt = options
|
|
1311
|
-
if not isinstance(rhsopt, (int, float)):
|
|
1312
|
-
raise ValueError(f"Specified maxRothConversion {rhsopt} is not a number.")
|
|
1473
|
+
rhsopt = u.get_numeric_option(options, "maxRothConversion", 0)
|
|
1313
1474
|
|
|
1314
1475
|
if rhsopt >= 0:
|
|
1315
1476
|
rhsopt *= self.optionsUnits
|
|
1316
1477
|
for i in range(self.N_i):
|
|
1478
|
+
if i == i_xcluded:
|
|
1479
|
+
continue
|
|
1317
1480
|
for n in range(self.horizons[i]):
|
|
1318
|
-
|
|
1481
|
+
# Apply the cap per individual (legacy behavior).
|
|
1482
|
+
self.B.setRange(_q2(self.C["x"], i, n, self.N_i, self.N_n), 0, rhsopt)
|
|
1319
1483
|
|
|
1320
1484
|
if "startRothConversions" in options:
|
|
1321
|
-
rhsopt = options
|
|
1322
|
-
if not isinstance(rhsopt, (int, float)):
|
|
1323
|
-
raise ValueError(f"Specified startRothConversions {rhsopt} is not a number.")
|
|
1485
|
+
rhsopt = int(u.get_numeric_option(options, "startRothConversions", 0))
|
|
1324
1486
|
thisyear = date.today().year
|
|
1325
1487
|
yearn = max(rhsopt - thisyear, 0)
|
|
1326
1488
|
for i in range(self.N_i):
|
|
1489
|
+
if i == i_xcluded:
|
|
1490
|
+
continue
|
|
1327
1491
|
nstart = min(yearn, self.horizons[i])
|
|
1328
1492
|
for n in range(0, nstart):
|
|
1329
1493
|
self.B.setRange(_q2(self.C["x"], i, n, self.N_i, self.N_n), 0, 0)
|
|
1330
1494
|
|
|
1331
|
-
if "
|
|
1332
|
-
rhsopt = options
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1495
|
+
if "swapRothConverters" in options and i_xcluded == -1:
|
|
1496
|
+
rhsopt = int(u.get_numeric_option(options, "swapRothConverters", 0))
|
|
1497
|
+
if self.N_i == 2 and rhsopt != 0:
|
|
1498
|
+
thisyear = date.today().year
|
|
1499
|
+
absrhsopt = abs(rhsopt)
|
|
1500
|
+
yearn = max(absrhsopt - thisyear, 0)
|
|
1501
|
+
i_x = 0 if rhsopt > 0 else 1
|
|
1502
|
+
i_y = (i_x + 1) % 2
|
|
1503
|
+
|
|
1504
|
+
transy = min(yearn, self.horizons[i_y])
|
|
1505
|
+
for n in range(0, transy):
|
|
1506
|
+
self.B.setRange(_q2(self.C["x"], i_y, n, self.N_i, self.N_n), 0, 0)
|
|
1507
|
+
|
|
1508
|
+
transx = min(yearn, self.horizons[i_x])
|
|
1509
|
+
for n in range(transx, self.horizons[i_x]):
|
|
1510
|
+
self.B.setRange(_q2(self.C["x"], i_x, n, self.N_i, self.N_n), 0, 0)
|
|
1511
|
+
|
|
1512
|
+
# Disallow Roth conversions in last two years alive. Plan has at least 2 years.
|
|
1513
|
+
for i in range(self.N_i):
|
|
1514
|
+
if i == i_xcluded:
|
|
1515
|
+
continue
|
|
1516
|
+
self.B.setRange(_q2(self.C["x"], i, self.horizons[i] - 2, self.N_i, self.N_n), 0, 0)
|
|
1517
|
+
self.B.setRange(_q2(self.C["x"], i, self.horizons[i] - 1, self.N_i, self.N_n), 0, 0)
|
|
1339
1518
|
|
|
1340
1519
|
def _add_withdrawal_limits(self):
|
|
1341
1520
|
for i in range(self.N_i):
|
|
@@ -1358,9 +1537,7 @@ class Plan:
|
|
|
1358
1537
|
def _add_objective_constraints(self, objective, options):
|
|
1359
1538
|
if objective == "maxSpending":
|
|
1360
1539
|
if "bequest" in options:
|
|
1361
|
-
bequest = options
|
|
1362
|
-
if not isinstance(bequest, (int, float)):
|
|
1363
|
-
raise ValueError(f"Desired bequest {bequest} is not a number.")
|
|
1540
|
+
bequest = u.get_numeric_option(options, "bequest", 1)
|
|
1364
1541
|
bequest *= self.optionsUnits * self.gamma_n[-1]
|
|
1365
1542
|
else:
|
|
1366
1543
|
bequest = 1
|
|
@@ -1379,9 +1556,7 @@ class Plan:
|
|
|
1379
1556
|
row.addElem(_q3(self.C["b"], i, 2, self.N_n, self.N_i, self.N_j, self.N_n + 1), 1)
|
|
1380
1557
|
self.A.addRow(row, total_bequest_value, total_bequest_value)
|
|
1381
1558
|
elif objective == "maxBequest":
|
|
1382
|
-
spending = options
|
|
1383
|
-
if not isinstance(spending, (int, float)):
|
|
1384
|
-
raise ValueError(f"Desired spending provided {spending} is not a number.")
|
|
1559
|
+
spending = u.get_numeric_option(options, "netSpending", 1)
|
|
1385
1560
|
spending *= self.optionsUnits
|
|
1386
1561
|
self.B.setRange(_q1(self.C["g"], 0, self.N_n), spending, spending)
|
|
1387
1562
|
|
|
@@ -1395,7 +1570,7 @@ class Plan:
|
|
|
1395
1570
|
rhs = self.beta_ij[i, j] / backTau
|
|
1396
1571
|
self.B.setRange(_q3(self.C["b"], i, j, 0, self.N_i, self.N_j, self.N_n + 1), rhs, rhs)
|
|
1397
1572
|
|
|
1398
|
-
def _add_surplus_deposit_linking(self):
|
|
1573
|
+
def _add_surplus_deposit_linking(self, options):
|
|
1399
1574
|
for i in range(self.N_i):
|
|
1400
1575
|
fac1 = u.krond(i, 0) * (1 - self.eta) + u.krond(i, 1) * self.eta
|
|
1401
1576
|
for n in range(self.n_d):
|
|
@@ -1405,8 +1580,12 @@ class Plan:
|
|
|
1405
1580
|
for n in range(self.n_d, self.N_n):
|
|
1406
1581
|
rowDic = {_q2(self.C["d"], i, n, self.N_i, self.N_n): 1, _q1(self.C["s"], n, self.N_n): -fac2}
|
|
1407
1582
|
self.A.addNewRow(rowDic, 0, 0)
|
|
1408
|
-
|
|
1409
|
-
|
|
1583
|
+
|
|
1584
|
+
# Prevent surplus on two last year as they have little tax and/or growth consequence.
|
|
1585
|
+
disallow = options.get("noLateSurplus", False)
|
|
1586
|
+
if disallow:
|
|
1587
|
+
self.B.setRange(_q1(self.C["s"], self.N_n - 2, self.N_n), 0, 0)
|
|
1588
|
+
self.B.setRange(_q1(self.C["s"], self.N_n - 1, self.N_n), 0, 0)
|
|
1410
1589
|
|
|
1411
1590
|
def _add_account_balance_carryover(self):
|
|
1412
1591
|
tau_ijn = np.zeros((self.N_i, self.N_j, self.N_n))
|
|
@@ -1459,8 +1638,10 @@ class Plan:
|
|
|
1459
1638
|
tau_0prev[tau_0prev < 0] = 0
|
|
1460
1639
|
for n in range(self.N_n):
|
|
1461
1640
|
rhs = -self.M_n[n] - self.J_n[n]
|
|
1462
|
-
# Add fixed assets
|
|
1463
|
-
rhs += self.fixed_assets_tax_free_n[n]
|
|
1641
|
+
# Add fixed assets proceeds (positive cash flow)
|
|
1642
|
+
rhs += (self.fixed_assets_tax_free_n[n]
|
|
1643
|
+
+ self.fixed_assets_ordinary_income_n[n]
|
|
1644
|
+
+ self.fixed_assets_capital_gains_n[n])
|
|
1464
1645
|
# Subtract debt payments (negative cash flow)
|
|
1465
1646
|
rhs -= self.debt_payments_n[n]
|
|
1466
1647
|
row = self.A.newRow({_q1(self.C["g"], n, self.N_n): 1})
|
|
@@ -1505,7 +1686,7 @@ class Plan:
|
|
|
1505
1686
|
row = self.A.newRow()
|
|
1506
1687
|
row.addElem(_q1(self.C["e"], n, self.N_n), 1)
|
|
1507
1688
|
for i in range(self.N_i):
|
|
1508
|
-
rhs += self.omega_in[i, n] +
|
|
1689
|
+
rhs += self.omega_in[i, n] + self.Psi_n[n] * self.zetaBar_in[i, n] + self.piBar_in[i, n]
|
|
1509
1690
|
row.addElem(_q3(self.C["w"], i, 1, n, self.N_i, self.N_j, self.N_n), -1)
|
|
1510
1691
|
row.addElem(_q2(self.C["x"], i, n, self.N_i, self.N_n), -1)
|
|
1511
1692
|
fak = np.sum(self.tau_kn[1:self.N_k, n] * self.alpha_ijkn[i, 0, 1:self.N_k, n], axis=0)
|
|
@@ -1518,53 +1699,73 @@ class Plan:
|
|
|
1518
1699
|
self.A.addRow(row, rhs, rhs)
|
|
1519
1700
|
|
|
1520
1701
|
def _configure_exclusion_binary_variables(self, options):
|
|
1521
|
-
if not options.get("
|
|
1702
|
+
if not options.get("amoConstraints", True):
|
|
1522
1703
|
return
|
|
1523
1704
|
|
|
1524
|
-
bigM =
|
|
1525
|
-
|
|
1526
|
-
|
|
1705
|
+
bigM = u.get_numeric_option(options, "bigMamo", BIGM_XOR, min_value=0)
|
|
1706
|
+
|
|
1707
|
+
if options.get("amoSurplus", True):
|
|
1708
|
+
for n in range(self.N_n):
|
|
1709
|
+
# Make z_0 and z_1 exclusive binary variables.
|
|
1710
|
+
dic0 = {_q2(self.C["zx"], n, 0, self.N_n, self.N_zx): bigM*self.gamma_n[n],
|
|
1711
|
+
_q3(self.C["w"], 0, 0, n, self.N_i, self.N_j, self.N_n): -1,
|
|
1712
|
+
_q3(self.C["w"], 0, 2, n, self.N_i, self.N_j, self.N_n): -1}
|
|
1713
|
+
if self.N_i == 2:
|
|
1714
|
+
dic1 = {_q3(self.C["w"], 1, 0, n, self.N_i, self.N_j, self.N_n): -1,
|
|
1715
|
+
_q3(self.C["w"], 1, 2, n, self.N_i, self.N_j, self.N_n): -1}
|
|
1716
|
+
dic0.update(dic1)
|
|
1717
|
+
|
|
1718
|
+
self.A.addNewRow(dic0, 0, np.inf)
|
|
1527
1719
|
|
|
1528
|
-
for i in range(self.N_i):
|
|
1529
|
-
for n in range(self.horizons[i]):
|
|
1530
1720
|
self.A.addNewRow(
|
|
1531
|
-
{
|
|
1721
|
+
{_q2(self.C["zx"], n, 1, self.N_n, self.N_zx): bigM*self.gamma_n[n],
|
|
1532
1722
|
_q1(self.C["s"], n, self.N_n): -1},
|
|
1533
|
-
0,
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
self.A.addNewRow(
|
|
1537
|
-
{
|
|
1538
|
-
_q3(self.C["zx"], i, n, 0, self.N_i, self.N_n, self.N_zx): bigM,
|
|
1539
|
-
_q3(self.C["w"], i, 0, n, self.N_i, self.N_j, self.N_n): 1,
|
|
1540
|
-
_q3(self.C["w"], i, 2, n, self.N_i, self.N_j, self.N_n): 1,
|
|
1541
|
-
},
|
|
1542
|
-
0,
|
|
1543
|
-
bigM,
|
|
1544
|
-
)
|
|
1723
|
+
0, np.inf)
|
|
1724
|
+
|
|
1725
|
+
# As both can be zero, bound as z_0 + z_1 <= 1
|
|
1545
1726
|
self.A.addNewRow(
|
|
1546
|
-
{
|
|
1547
|
-
_q2(self.C["
|
|
1548
|
-
0,
|
|
1549
|
-
bigM,
|
|
1727
|
+
{_q2(self.C["zx"], n, 0, self.N_n, self.N_zx): +1,
|
|
1728
|
+
_q2(self.C["zx"], n, 1, self.N_n, self.N_zx): +1},
|
|
1729
|
+
0, 1
|
|
1550
1730
|
)
|
|
1731
|
+
|
|
1732
|
+
if "maxRothConversion" in options:
|
|
1733
|
+
rhsopt = u.get_numeric_option(options, "maxRothConversion", 0)
|
|
1734
|
+
if rhsopt < -1:
|
|
1735
|
+
return
|
|
1736
|
+
|
|
1737
|
+
# Turning off this constraint for maxRothConversions = 0 makes solution infeasible.
|
|
1738
|
+
if options.get("amoRoth", True):
|
|
1739
|
+
for n in range(self.N_n):
|
|
1740
|
+
# Make z_2 and z_3 exclusive binary variables.
|
|
1741
|
+
dic0 = {_q2(self.C["zx"], n, 2, self.N_n, self.N_zx): bigM*self.gamma_n[n],
|
|
1742
|
+
_q2(self.C["x"], 0, n, self.N_i, self.N_n): -1}
|
|
1743
|
+
if self.N_i == 2:
|
|
1744
|
+
dic1 = {_q2(self.C["x"], 1, n, self.N_i, self.N_n): -1}
|
|
1745
|
+
dic0.update(dic1)
|
|
1746
|
+
|
|
1747
|
+
self.A.addNewRow(dic0, 0, np.inf)
|
|
1748
|
+
|
|
1749
|
+
dic0 = {_q2(self.C["zx"], n, 3, self.N_n, self.N_zx): bigM*self.gamma_n[n],
|
|
1750
|
+
_q3(self.C["w"], 0, 2, n, self.N_i, self.N_j, self.N_n): -1}
|
|
1751
|
+
if self.N_i == 2:
|
|
1752
|
+
dic1 = {_q3(self.C["w"], 1, 2, n, self.N_i, self.N_j, self.N_n): -1}
|
|
1753
|
+
dic0.update(dic1)
|
|
1754
|
+
|
|
1755
|
+
self.A.addNewRow(dic0, 0, np.inf)
|
|
1756
|
+
|
|
1551
1757
|
self.A.addNewRow(
|
|
1552
|
-
{
|
|
1553
|
-
|
|
1554
|
-
0,
|
|
1555
|
-
bigM,
|
|
1758
|
+
{_q2(self.C["zx"], n, 2, self.N_n, self.N_zx): +1,
|
|
1759
|
+
_q2(self.C["zx"], n, 3, self.N_n, self.N_zx): +1},
|
|
1760
|
+
0, 1
|
|
1556
1761
|
)
|
|
1557
|
-
for n in range(self.horizons[i], self.N_n):
|
|
1558
|
-
self.B.setRange(_q3(self.C["zx"], i, n, 0, self.N_i, self.N_n, self.N_zx), 0, 0)
|
|
1559
|
-
self.B.setRange(_q3(self.C["zx"], i, n, 1, self.N_i, self.N_n, self.N_zx), 0, 0)
|
|
1560
1762
|
|
|
1561
1763
|
def _configure_Medicare_binary_variables(self, options):
|
|
1562
1764
|
if options.get("withMedicare", "loop") != "optimize":
|
|
1563
1765
|
return
|
|
1564
1766
|
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
raise ValueError(f"bigM {bigM} is not a number.")
|
|
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)
|
|
1568
1769
|
|
|
1569
1770
|
Nmed = self.N_n - self.nm
|
|
1570
1771
|
offset = 0
|
|
@@ -1574,50 +1775,47 @@ class Plan:
|
|
|
1574
1775
|
n = self.nm + nn
|
|
1575
1776
|
for q in range(self.N_q - 1):
|
|
1576
1777
|
self.A.addNewRow({_q2(self.C["zm"], nn, q, Nmed, self.N_q - 1): bigM},
|
|
1577
|
-
|
|
1578
|
-
self.A.addNewRow({_q2(self.C["zm"], nn, q, Nmed, self.N_q - 1): -bigM},
|
|
1579
|
-
-np.inf, self.L_nq[nn, q] - self.prevMAGI[n])
|
|
1778
|
+
self.prevMAGI[n] - self.L_nq[nn, q], np.inf)
|
|
1580
1779
|
|
|
1581
1780
|
for nn in range(offset, Nmed):
|
|
1582
1781
|
n2 = self.nm + nn - 2 # n - 2
|
|
1583
1782
|
for q in range(self.N_q - 1):
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
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()
|
|
1588
1787
|
|
|
1589
|
-
|
|
1590
|
-
|
|
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)
|
|
1591
1793
|
for i in range(self.N_i):
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
row1.addElem(_q2(self.C["x"], i, n2, self.N_i, self.N_n), -1)
|
|
1596
|
-
row2.addElem(_q2(self.C["x"], i, n2, self.N_i, self.N_n), +1)
|
|
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)
|
|
1597
1796
|
|
|
1598
1797
|
# Dividends and interest gains for year n2.
|
|
1599
1798
|
afac = (self.mu*self.alpha_ijkn[i, 0, 0, n2]
|
|
1600
1799
|
+ np.sum(self.alpha_ijkn[i, 0, 1:, n2]*self.tau_kn[1:, n2]))
|
|
1601
1800
|
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
row1.addElem(_q2(self.C["d"], i, n2, self.N_i, self.N_n), -afac)
|
|
1606
|
-
row2.addElem(_q2(self.C["d"], i, n2, self.N_i, self.N_n), +afac)
|
|
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)
|
|
1607
1803
|
|
|
1608
1804
|
# Capital gains on stocks sold from taxable account accrued in year n2 - 1.
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
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)
|
|
1810
|
+
|
|
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]
|
|
1615
1815
|
+ 0.5 * self.kappa_ijn[i, 0, n2] * afac)
|
|
1616
|
-
|
|
1617
|
-
rhs2 -= sumoni
|
|
1816
|
+
rhs -= sumoni
|
|
1618
1817
|
|
|
1619
|
-
self.A.addRow(
|
|
1620
|
-
self.A.addRow(row2, -np.inf, rhs2)
|
|
1818
|
+
self.A.addRow(row, -np.inf, rhs)
|
|
1621
1819
|
|
|
1622
1820
|
def _add_Medicare_costs(self, options):
|
|
1623
1821
|
if options.get("withMedicare", "loop") != "optimize":
|
|
@@ -1657,7 +1855,7 @@ class Plan:
|
|
|
1657
1855
|
|
|
1658
1856
|
if yend + self.N_n > self.year_n[0]:
|
|
1659
1857
|
yend = self.year_n[0] - self.N_n - 1
|
|
1660
|
-
self.mylog.
|
|
1858
|
+
self.mylog.print(f"Warning: Upper bound for year range re-adjusted to {yend}.")
|
|
1661
1859
|
|
|
1662
1860
|
if yend < ystart:
|
|
1663
1861
|
raise ValueError(f"Starting year is too large to support a lifespan of {self.N_n} years.")
|
|
@@ -1672,8 +1870,8 @@ class Plan:
|
|
|
1672
1870
|
elif objective == "maxBequest":
|
|
1673
1871
|
columns = ["partial", "final"]
|
|
1674
1872
|
else:
|
|
1675
|
-
self.mylog.print(f"Invalid objective {objective}.")
|
|
1676
|
-
raise ValueError(f"Invalid objective {objective}.")
|
|
1873
|
+
self.mylog.print(f"Invalid objective '{objective}'.")
|
|
1874
|
+
raise ValueError(f"Invalid objective '{objective}'.")
|
|
1677
1875
|
|
|
1678
1876
|
df = pd.DataFrame(columns=columns)
|
|
1679
1877
|
|
|
@@ -1725,7 +1923,7 @@ class Plan:
|
|
|
1725
1923
|
elif objective == "maxBequest":
|
|
1726
1924
|
columns = ["partial", "final"]
|
|
1727
1925
|
else:
|
|
1728
|
-
self.mylog.print(f"Invalid objective {objective}.")
|
|
1926
|
+
self.mylog.print(f"Invalid objective '{objective}'.")
|
|
1729
1927
|
return None
|
|
1730
1928
|
|
|
1731
1929
|
df = pd.DataFrame(columns=columns)
|
|
@@ -1737,7 +1935,7 @@ class Plan:
|
|
|
1737
1935
|
progcall.start()
|
|
1738
1936
|
|
|
1739
1937
|
for n in range(N):
|
|
1740
|
-
self.regenRates()
|
|
1938
|
+
self.regenRates(override_reproducible=True)
|
|
1741
1939
|
self.solve(objective, myoptions)
|
|
1742
1940
|
if not verbose:
|
|
1743
1941
|
progcall.show((n + 1) / N)
|
|
@@ -1786,52 +1984,68 @@ class Plan:
|
|
|
1786
1984
|
|
|
1787
1985
|
Refer to companion document for implementation details.
|
|
1788
1986
|
"""
|
|
1987
|
+
|
|
1789
1988
|
if self.rateMethod is None:
|
|
1790
1989
|
raise RuntimeError("Rate method must be selected before solving.")
|
|
1791
1990
|
|
|
1792
1991
|
# Assume unsuccessful until problem solved.
|
|
1793
1992
|
self.caseStatus = "unsuccessful"
|
|
1993
|
+
self.convergenceType = "undefined"
|
|
1794
1994
|
|
|
1795
1995
|
# Check objective and required options.
|
|
1796
1996
|
knownObjectives = ["maxBequest", "maxSpending"]
|
|
1797
1997
|
knownSolvers = ["HiGHS", "PuLP/CBC", "PuLP/HiGHS", "MOSEK"]
|
|
1798
1998
|
|
|
1799
1999
|
knownOptions = [
|
|
2000
|
+
"absTol",
|
|
2001
|
+
"amoConstraints",
|
|
2002
|
+
"amoRoth",
|
|
2003
|
+
"amoSurplus",
|
|
1800
2004
|
"bequest",
|
|
1801
|
-
"
|
|
2005
|
+
"bigMirmaa", # Big-M value for Medicare IRMAA constraints (default: 5e7)
|
|
2006
|
+
"bigMamo", # Big-M value for XOR constraints (default: 5e7)
|
|
2007
|
+
"gap",
|
|
2008
|
+
"maxIter",
|
|
1802
2009
|
"maxRothConversion",
|
|
1803
2010
|
"netSpending",
|
|
2011
|
+
"noLateSurplus",
|
|
1804
2012
|
"noRothConversions",
|
|
1805
2013
|
"oppCostX",
|
|
1806
|
-
"withMedicare",
|
|
1807
2014
|
"previousMAGIs",
|
|
2015
|
+
"relTol",
|
|
1808
2016
|
"solver",
|
|
1809
2017
|
"spendingSlack",
|
|
1810
2018
|
"startRothConversions",
|
|
2019
|
+
"swapRothConverters",
|
|
2020
|
+
"maxTime",
|
|
1811
2021
|
"units",
|
|
1812
|
-
"
|
|
2022
|
+
"verbose",
|
|
2023
|
+
"withMedicare",
|
|
1813
2024
|
"withSCLoop",
|
|
1814
2025
|
]
|
|
1815
|
-
# We might modify options if required.
|
|
1816
2026
|
options = {} if options is None else options
|
|
2027
|
+
|
|
2028
|
+
# We might modify options if required.
|
|
1817
2029
|
myoptions = dict(options)
|
|
1818
2030
|
|
|
1819
|
-
for opt in myoptions:
|
|
2031
|
+
for opt in list(myoptions.keys()):
|
|
1820
2032
|
if opt not in knownOptions:
|
|
1821
|
-
raise ValueError(f"Option {opt} is not one of {knownOptions}.")
|
|
2033
|
+
# raise ValueError(f"Option '{opt}' is not one of {knownOptions}.")
|
|
2034
|
+
self.mylog.print(f"Ignoring unknown solver option '{opt}'.")
|
|
2035
|
+
myoptions.pop(opt)
|
|
1822
2036
|
|
|
1823
2037
|
if objective not in knownObjectives:
|
|
1824
|
-
raise ValueError(f"Objective {objective} is not one of {knownObjectives}.")
|
|
2038
|
+
raise ValueError(f"Objective '{objective}' is not one of {knownObjectives}.")
|
|
1825
2039
|
|
|
1826
2040
|
if objective == "maxBequest" and "netSpending" not in myoptions:
|
|
1827
|
-
raise RuntimeError(f"Objective {objective} needs netSpending option.")
|
|
2041
|
+
raise RuntimeError(f"Objective '{objective}' needs netSpending option.")
|
|
1828
2042
|
|
|
1829
2043
|
if objective == "maxBequest" and "bequest" in myoptions:
|
|
1830
|
-
self.mylog.
|
|
2044
|
+
self.mylog.print("Ignoring bequest option provided.")
|
|
1831
2045
|
myoptions.pop("bequest")
|
|
1832
2046
|
|
|
1833
2047
|
if objective == "maxSpending" and "netSpending" in myoptions:
|
|
1834
|
-
self.mylog.
|
|
2048
|
+
self.mylog.print("Ignoring netSpending option provided.")
|
|
1835
2049
|
myoptions.pop("netSpending")
|
|
1836
2050
|
|
|
1837
2051
|
if objective == "maxSpending" and "bequest" not in myoptions:
|
|
@@ -1839,9 +2053,24 @@ class Plan:
|
|
|
1839
2053
|
|
|
1840
2054
|
self.optionsUnits = u.getUnits(myoptions.get("units", "k"))
|
|
1841
2055
|
|
|
1842
|
-
oppCostX =
|
|
2056
|
+
oppCostX = myoptions.get("oppCostX", 0.)
|
|
1843
2057
|
self.xnet = 1 - oppCostX / 100.
|
|
1844
2058
|
|
|
2059
|
+
if "swapRothConverters" in myoptions and "noRothConversions" in myoptions:
|
|
2060
|
+
self.mylog.print("Ignoring 'noRothConversions' as 'swapRothConverters' option present.")
|
|
2061
|
+
myoptions.pop("noRothConversions")
|
|
2062
|
+
|
|
2063
|
+
# Go easy on MILP - auto gap somehow.
|
|
2064
|
+
if "gap" not in myoptions and myoptions.get("withMedicare", "loop") == "optimize":
|
|
2065
|
+
fac = 1
|
|
2066
|
+
maxRoth = myoptions.get("maxRothConversion", 100)
|
|
2067
|
+
if maxRoth <= 15:
|
|
2068
|
+
fac = 10
|
|
2069
|
+
# Loosen default MIP gap when Medicare is optimized. Even more if rothX == 0
|
|
2070
|
+
gap = fac * MILP_GAP
|
|
2071
|
+
myoptions["gap"] = gap
|
|
2072
|
+
self.mylog.vprint(f"Using restricted gap of {gap:.1e}.")
|
|
2073
|
+
|
|
1845
2074
|
self.prevMAGI = np.zeros(2)
|
|
1846
2075
|
if "previousMAGIs" in myoptions:
|
|
1847
2076
|
magi = myoptions["previousMAGIs"]
|
|
@@ -1852,7 +2081,7 @@ class Plan:
|
|
|
1852
2081
|
|
|
1853
2082
|
lambdha = myoptions.get("spendingSlack", 0)
|
|
1854
2083
|
if not (0 <= lambdha <= 50):
|
|
1855
|
-
raise ValueError(f"Slack value out of range
|
|
2084
|
+
raise ValueError(f"Slack value {lambdha} out of range.")
|
|
1856
2085
|
self.lambdha = lambdha / 100
|
|
1857
2086
|
|
|
1858
2087
|
# Reset long-term capital gain tax rate and MAGI to zero.
|
|
@@ -1862,14 +2091,14 @@ class Plan:
|
|
|
1862
2091
|
self.M_n = np.zeros(self.N_n)
|
|
1863
2092
|
|
|
1864
2093
|
self._adjustParameters(self.gamma_n, self.MAGI_n)
|
|
1865
|
-
self._buildOffsetMap(
|
|
2094
|
+
self._buildOffsetMap(myoptions)
|
|
1866
2095
|
|
|
1867
2096
|
# Process debts and fixed assets
|
|
1868
2097
|
self.processDebtsAndFixedAssets()
|
|
1869
2098
|
|
|
1870
2099
|
solver = myoptions.get("solver", self.defaultSolver)
|
|
1871
2100
|
if solver not in knownSolvers:
|
|
1872
|
-
raise ValueError(f"Unknown solver {solver}.")
|
|
2101
|
+
raise ValueError(f"Unknown solver '{solver}'.")
|
|
1873
2102
|
|
|
1874
2103
|
if solver == "HiGHS":
|
|
1875
2104
|
solverMethod = self._milpSolve
|
|
@@ -1880,7 +2109,10 @@ class Plan:
|
|
|
1880
2109
|
else:
|
|
1881
2110
|
raise RuntimeError("Internal error in defining solverMethod.")
|
|
1882
2111
|
|
|
1883
|
-
self.
|
|
2112
|
+
self.mylog.vprint(f"Using '{solver}' solver.")
|
|
2113
|
+
myoptions_txt = textwrap.fill(f"{myoptions}", initial_indent="\t", subsequent_indent="\t", width=100)
|
|
2114
|
+
self.mylog.vprint(f"Solver options:\n{myoptions_txt}.")
|
|
2115
|
+
self._scSolve(objective, myoptions, solverMethod)
|
|
1884
2116
|
|
|
1885
2117
|
self.objective = objective
|
|
1886
2118
|
self.solverOptions = myoptions
|
|
@@ -1894,6 +2126,20 @@ class Plan:
|
|
|
1894
2126
|
includeMedicare = options.get("withMedicare", "loop") == "loop"
|
|
1895
2127
|
withSCLoop = options.get("withSCLoop", True)
|
|
1896
2128
|
|
|
2129
|
+
# Convergence uses a relative tolerance tied to MILP gap,
|
|
2130
|
+
# with an absolute floor to avoid zero/near-zero objectives.
|
|
2131
|
+
gap = u.get_numeric_option(options, "gap", GAP, min_value=0)
|
|
2132
|
+
abs_tol = u.get_numeric_option(options, "absTol", ABS_TOL, min_value=0)
|
|
2133
|
+
rel_tol = options.get("relTol")
|
|
2134
|
+
if rel_tol is None:
|
|
2135
|
+
# Keep rel_tol aligned with solver gap to avoid SC loop chasing noise.
|
|
2136
|
+
rel_tol = max(REL_TOL, gap / 300)
|
|
2137
|
+
# rel_tol = u.get_numeric_option({"relTol": rel_tol}, "relTol", REL_TOL, min_value=0)
|
|
2138
|
+
self.mylog.print(f"Using relTol={rel_tol:.1e}, absTol={abs_tol:.1e}, and gap={gap:.1e}.")
|
|
2139
|
+
|
|
2140
|
+
max_iterations = int(u.get_numeric_option(options, "maxIter", MAX_ITERATIONS, min_value=1))
|
|
2141
|
+
self.mylog.print(f"Using maxIter={max_iterations}.")
|
|
2142
|
+
|
|
1897
2143
|
if objective == "maxSpending":
|
|
1898
2144
|
objFac = -1 / self.xi_n[0]
|
|
1899
2145
|
else:
|
|
@@ -1902,12 +2148,15 @@ class Plan:
|
|
|
1902
2148
|
it = 0
|
|
1903
2149
|
old_x = np.zeros(self.nvars)
|
|
1904
2150
|
old_objfns = [np.inf]
|
|
2151
|
+
scaled_obj_history = [] # Track scaled objective values for oscillation detection
|
|
2152
|
+
sol_history = [] # Track solutions aligned with scaled_obj_history
|
|
2153
|
+
obj_history = [] # Track raw objective values aligned with scaled_obj_history
|
|
1905
2154
|
self._computeNLstuff(None, includeMedicare)
|
|
1906
2155
|
while True:
|
|
1907
|
-
objfn, xx, solverSuccess, solverMsg = solverMethod(objective, options)
|
|
2156
|
+
objfn, xx, solverSuccess, solverMsg, solgap = solverMethod(objective, options)
|
|
1908
2157
|
|
|
1909
2158
|
if not solverSuccess or objfn is None:
|
|
1910
|
-
self.mylog.
|
|
2159
|
+
self.mylog.print("Solver failed:", solverMsg, solverSuccess)
|
|
1911
2160
|
break
|
|
1912
2161
|
|
|
1913
2162
|
if not withSCLoop:
|
|
@@ -1916,24 +2165,64 @@ class Plan:
|
|
|
1916
2165
|
self._computeNLstuff(xx, includeMedicare)
|
|
1917
2166
|
|
|
1918
2167
|
delta = xx - old_x
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
2168
|
+
# Only consider account balances in dX.
|
|
2169
|
+
absSolDiff = np.sum(np.abs(delta[:self.nbals]), axis=0)/self.nbals
|
|
2170
|
+
absObjDiff = abs(objFac*(objfn + old_objfns[-1]))
|
|
2171
|
+
scaled_obj = objfn * objFac
|
|
2172
|
+
scaled_obj_history.append(scaled_obj)
|
|
2173
|
+
sol_history.append(xx)
|
|
2174
|
+
obj_history.append(objfn)
|
|
2175
|
+
self.mylog.vprint(f"Iter: {it:02}; f: {u.d(scaled_obj, f=0)}; gap: {solgap:.1e};"
|
|
2176
|
+
f" |dX|: {absSolDiff:.0f}; |df|: {u.d(absObjDiff, f=0)}")
|
|
2177
|
+
|
|
2178
|
+
# Solution difference is calculated and reported but not used for convergence
|
|
2179
|
+
# since it scales with problem size and can prevent convergence for large cases.
|
|
2180
|
+
prev_scaled_obj = scaled_obj
|
|
2181
|
+
if np.isfinite(old_objfns[-1]):
|
|
2182
|
+
prev_scaled_obj = (-old_objfns[-1]) * objFac
|
|
2183
|
+
scale = max(1.0, abs(scaled_obj), abs(prev_scaled_obj))
|
|
2184
|
+
tol = max(abs_tol, rel_tol * scale)
|
|
2185
|
+
if absObjDiff <= tol:
|
|
2186
|
+
# Check if convergence was monotonic or oscillatory
|
|
2187
|
+
# old_objfns stores -objfn values, so we need to scale them to match displayed values
|
|
2188
|
+
# For monotonic convergence, the scaled objective (objfn * objFac) should be non-increasing
|
|
2189
|
+
# Include current iteration's scaled objfn value
|
|
2190
|
+
scaled_objfns = [(-val) * objFac for val in old_objfns[1:]] + [scaled_obj]
|
|
2191
|
+
# Check if scaled objective function is non-increasing (monotonic convergence)
|
|
2192
|
+
is_monotonic = all(scaled_objfns[i] <= scaled_objfns[i-1] + tol
|
|
2193
|
+
for i in range(1, len(scaled_objfns)))
|
|
2194
|
+
if is_monotonic:
|
|
2195
|
+
self.convergenceType = "monotonic"
|
|
2196
|
+
else:
|
|
2197
|
+
self.convergenceType = "oscillatory"
|
|
2198
|
+
self.mylog.print(f"Converged on full solution with {self.convergenceType} behavior.")
|
|
1927
2199
|
break
|
|
1928
2200
|
|
|
1929
|
-
#
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
2201
|
+
# Check for oscillation (need at least 4 iterations to detect a 2-cycle)
|
|
2202
|
+
if it >= 3:
|
|
2203
|
+
cycle_len = self._detectOscillation(scaled_obj_history, tol)
|
|
2204
|
+
if cycle_len is not None:
|
|
2205
|
+
# Find the best (maximum) objective in the cycle
|
|
2206
|
+
cycle_values = scaled_obj_history[-cycle_len:]
|
|
2207
|
+
best_idx = np.argmax(cycle_values)
|
|
2208
|
+
best_obj = cycle_values[best_idx]
|
|
2209
|
+
self.convergenceType = f"oscillatory (cycle length {cycle_len})"
|
|
2210
|
+
self.mylog.print(f"Oscillation detected: {cycle_len}-cycle pattern identified.")
|
|
2211
|
+
self.mylog.print(f"Best objective in cycle: {u.d(best_obj, f=2)}")
|
|
2212
|
+
|
|
2213
|
+
# Select the solution corresponding to the best objective in the detected cycle.
|
|
2214
|
+
cycle_solutions = sol_history[-cycle_len:]
|
|
2215
|
+
cycle_objfns = obj_history[-cycle_len:]
|
|
2216
|
+
xx = cycle_solutions[best_idx]
|
|
2217
|
+
objfn = cycle_objfns[best_idx]
|
|
2218
|
+
self.mylog.print("Using best solution from detected cycle.")
|
|
2219
|
+
|
|
2220
|
+
self.mylog.print("Accepting solution from cycle and terminating.")
|
|
2221
|
+
break
|
|
1934
2222
|
|
|
1935
|
-
if it
|
|
1936
|
-
self.
|
|
2223
|
+
if it >= max_iterations:
|
|
2224
|
+
self.convergenceType = "max iteration"
|
|
2225
|
+
self.mylog.print("Warning: Exiting loop on maximum iterations.")
|
|
1937
2226
|
break
|
|
1938
2227
|
|
|
1939
2228
|
it += 1
|
|
@@ -1941,15 +2230,15 @@ class Plan:
|
|
|
1941
2230
|
old_x = xx
|
|
1942
2231
|
|
|
1943
2232
|
if solverSuccess:
|
|
1944
|
-
self.mylog.
|
|
1945
|
-
self.mylog.
|
|
1946
|
-
self.mylog.
|
|
2233
|
+
self.mylog.print(f"Self-consistent loop returned after {it+1} iterations.")
|
|
2234
|
+
self.mylog.print(solverMsg)
|
|
2235
|
+
self.mylog.print(f"Objective: {u.d(objfn * objFac)}")
|
|
1947
2236
|
# self.mylog.vprint('Upper bound:', u.d(-solution.mip_dual_bound))
|
|
1948
2237
|
self._aggregateResults(xx)
|
|
1949
2238
|
self._timestamp = datetime.now().strftime("%Y-%m-%d at %H:%M:%S")
|
|
1950
2239
|
self.caseStatus = "solved"
|
|
1951
2240
|
else:
|
|
1952
|
-
self.mylog.
|
|
2241
|
+
self.mylog.print("Warning: Optimization failed:", solverMsg, solverSuccess)
|
|
1953
2242
|
self.caseStatus = "unsuccessful"
|
|
1954
2243
|
|
|
1955
2244
|
return None
|
|
@@ -1960,12 +2249,17 @@ class Plan:
|
|
|
1960
2249
|
"""
|
|
1961
2250
|
from scipy import optimize
|
|
1962
2251
|
|
|
2252
|
+
time_limit = u.get_numeric_option(options, "maxTime", TIME_LIMIT, min_value=0) # seconds
|
|
2253
|
+
mygap = u.get_numeric_option(options, "gap", GAP, min_value=0)
|
|
2254
|
+
verbose = options.get("verbose", False)
|
|
2255
|
+
|
|
1963
2256
|
# Optimize solver parameters
|
|
1964
2257
|
milpOptions = {
|
|
1965
|
-
"disp":
|
|
1966
|
-
"mip_rel_gap": 1e-
|
|
2258
|
+
"disp": bool(verbose),
|
|
2259
|
+
"mip_rel_gap": mygap, # Internal default in milp is 1e-4
|
|
1967
2260
|
"presolve": True,
|
|
1968
|
-
"
|
|
2261
|
+
"time_limit": time_limit,
|
|
2262
|
+
"node_limit": 1000000 # Limit search nodes for faster solutions
|
|
1969
2263
|
}
|
|
1970
2264
|
|
|
1971
2265
|
self._buildConstraints(objective, options)
|
|
@@ -1984,7 +2278,7 @@ class Plan:
|
|
|
1984
2278
|
options=milpOptions,
|
|
1985
2279
|
)
|
|
1986
2280
|
|
|
1987
|
-
return solution.fun, solution.x, solution.success, solution.message
|
|
2281
|
+
return solution.fun, solution.x, solution.success, solution.message, solution.mip_gap
|
|
1988
2282
|
|
|
1989
2283
|
def _pulpSolve(self, objective, options):
|
|
1990
2284
|
"""
|
|
@@ -2049,7 +2343,7 @@ class Plan:
|
|
|
2049
2343
|
solution = np.dot(c, xx)
|
|
2050
2344
|
success = (pulp.LpStatus[prob.status] == "Optimal")
|
|
2051
2345
|
|
|
2052
|
-
return solution, xx, success, pulp.LpStatus[prob.status]
|
|
2346
|
+
return solution, xx, success, pulp.LpStatus[prob.status], -1
|
|
2053
2347
|
|
|
2054
2348
|
def _mosekSolve(self, objective, options):
|
|
2055
2349
|
"""
|
|
@@ -2068,6 +2362,7 @@ class Plan:
|
|
|
2068
2362
|
solverMsg = str()
|
|
2069
2363
|
|
|
2070
2364
|
def _streamPrinter(text, msg=solverMsg):
|
|
2365
|
+
self.mylog.vprint(text.strip())
|
|
2071
2366
|
msg += text
|
|
2072
2367
|
|
|
2073
2368
|
self._buildConstraints(objective, options)
|
|
@@ -2078,10 +2373,24 @@ class Plan:
|
|
|
2078
2373
|
vkeys = self.B.keys()
|
|
2079
2374
|
cind, cval = self.c.lists()
|
|
2080
2375
|
|
|
2376
|
+
time_limit = u.get_numeric_option(options, "maxTime", TIME_LIMIT, min_value=0)
|
|
2377
|
+
mygap = u.get_numeric_option(options, "gap", GAP, min_value=0)
|
|
2378
|
+
|
|
2379
|
+
verbose = options.get("verbose", False)
|
|
2380
|
+
|
|
2081
2381
|
task = mosek.Task()
|
|
2082
|
-
|
|
2083
|
-
# task.putdouparam(mosek.dparam.
|
|
2084
|
-
|
|
2382
|
+
task.putdouparam(mosek.dparam.mio_max_time, time_limit) # Default -1
|
|
2383
|
+
# task.putdouparam(mosek.dparam.mio_rel_gap_const, 1e-6) # Default 1e-10
|
|
2384
|
+
task.putdouparam(mosek.dparam.mio_tol_rel_gap, mygap) # Default 1e-4
|
|
2385
|
+
# task.putdouparam(mosek.dparam.mio_tol_abs_relax_int, 2e-5) # Default 1e-5
|
|
2386
|
+
# task.putdouparam(mosek.iparam.mio_heuristic_level, 3) # Default -1
|
|
2387
|
+
|
|
2388
|
+
# task.set_Stream(mosek.streamtype.wrn, _streamPrinter)
|
|
2389
|
+
task.set_Stream(mosek.streamtype.err, _streamPrinter)
|
|
2390
|
+
if verbose:
|
|
2391
|
+
# task.set_Stream(mosek.streamtype.log, _streamPrinter)
|
|
2392
|
+
task.set_Stream(mosek.streamtype.msg, _streamPrinter)
|
|
2393
|
+
|
|
2085
2394
|
task.appendcons(self.A.ncons)
|
|
2086
2395
|
task.appendvars(self.A.nvars)
|
|
2087
2396
|
|
|
@@ -2104,14 +2413,67 @@ class Plan:
|
|
|
2104
2413
|
# Problem MUST contain binary variables to make these calls.
|
|
2105
2414
|
solsta = task.getsolsta(mosek.soltype.itg)
|
|
2106
2415
|
solverSuccess = (solsta == mosek.solsta.integer_optimal)
|
|
2416
|
+
rel_gap = task.getdouinf(mosek.dinfitem.mio_obj_rel_gap) if solverSuccess else -1
|
|
2107
2417
|
|
|
2108
2418
|
xx = np.array(task.getxx(mosek.soltype.itg))
|
|
2109
2419
|
solution = task.getprimalobj(mosek.soltype.itg)
|
|
2110
|
-
task.set_Stream(mosek.streamtype.wrn, _streamPrinter)
|
|
2111
2420
|
task.solutionsummary(mosek.streamtype.msg)
|
|
2112
2421
|
# task.writedata(self._name+'.ptf')
|
|
2113
2422
|
|
|
2114
|
-
return solution, xx, solverSuccess, solverMsg
|
|
2423
|
+
return solution, xx, solverSuccess, solverMsg, rel_gap
|
|
2424
|
+
|
|
2425
|
+
def _detectOscillation(self, obj_history, tolerance, max_cycle_length=15):
|
|
2426
|
+
"""
|
|
2427
|
+
Detect if the objective function is oscillating in a repeating cycle.
|
|
2428
|
+
|
|
2429
|
+
This function checks for repeating patterns of any length (2, 3, 4, etc.)
|
|
2430
|
+
in the recent objective function history. It handles numerical precision
|
|
2431
|
+
by using a tolerance for "close enough" matching.
|
|
2432
|
+
|
|
2433
|
+
Parameters
|
|
2434
|
+
----------
|
|
2435
|
+
obj_history : list
|
|
2436
|
+
List of recent objective function values (most recent last)
|
|
2437
|
+
tolerance : float
|
|
2438
|
+
Tolerance for considering two values "equal" (same as convergence tolerance)
|
|
2439
|
+
max_cycle_length : int
|
|
2440
|
+
Maximum cycle length to check for (default 15)
|
|
2441
|
+
|
|
2442
|
+
Returns
|
|
2443
|
+
-------
|
|
2444
|
+
int or None
|
|
2445
|
+
Cycle length if oscillation detected, None otherwise
|
|
2446
|
+
"""
|
|
2447
|
+
if len(obj_history) < 4: # Need at least 4 values to detect a 2-cycle
|
|
2448
|
+
return None
|
|
2449
|
+
|
|
2450
|
+
# Check for cycles of length 2, 3, 4, ... up to max_cycle_length
|
|
2451
|
+
# We need at least 2*cycle_length values to confirm a cycle
|
|
2452
|
+
for cycle_len in range(2, min(max_cycle_length + 1, len(obj_history) // 2 + 1)):
|
|
2453
|
+
# Check if the last cycle_len values match the previous cycle_len values
|
|
2454
|
+
if len(obj_history) < 2 * cycle_len:
|
|
2455
|
+
continue
|
|
2456
|
+
|
|
2457
|
+
recent = obj_history[-cycle_len:]
|
|
2458
|
+
previous = obj_history[-2*cycle_len:-cycle_len]
|
|
2459
|
+
|
|
2460
|
+
# Check if all pairs match within tolerance
|
|
2461
|
+
matches = all(abs(recent[i] - previous[i]) <= tolerance
|
|
2462
|
+
for i in range(cycle_len))
|
|
2463
|
+
|
|
2464
|
+
if matches:
|
|
2465
|
+
# Verify it's a true cycle by checking one more period back if available
|
|
2466
|
+
if len(obj_history) >= 3 * cycle_len:
|
|
2467
|
+
earlier = obj_history[-3*cycle_len:-2*cycle_len]
|
|
2468
|
+
if all(abs(recent[i] - earlier[i]) <= tolerance
|
|
2469
|
+
for i in range(cycle_len)):
|
|
2470
|
+
return cycle_len
|
|
2471
|
+
else:
|
|
2472
|
+
# If we don't have enough history, still report the cycle
|
|
2473
|
+
# but it's less certain
|
|
2474
|
+
return cycle_len
|
|
2475
|
+
|
|
2476
|
+
return None
|
|
2115
2477
|
|
|
2116
2478
|
def _computeNLstuff(self, x, includeMedicare):
|
|
2117
2479
|
"""
|
|
@@ -2128,7 +2490,13 @@ class Plan:
|
|
|
2128
2490
|
self._aggregateResults(x, short=True)
|
|
2129
2491
|
|
|
2130
2492
|
self.J_n = tx.computeNIIT(self.N_i, self.MAGI_n, self.I_n, self.Q_n, self.n_d, self.N_n)
|
|
2131
|
-
|
|
2493
|
+
ltcg_n = np.maximum(self.Q_n, 0)
|
|
2494
|
+
tx_income_n = self.e_n + ltcg_n
|
|
2495
|
+
cg_tax_n = tx.capitalGainTax(self.N_i, tx_income_n, ltcg_n, self.gamma_n[:-1], self.n_d, self.N_n)
|
|
2496
|
+
self.psi_n = np.zeros(self.N_n)
|
|
2497
|
+
has_ltcg = ltcg_n > 0
|
|
2498
|
+
self.psi_n[has_ltcg] = cg_tax_n[has_ltcg] / ltcg_n[has_ltcg]
|
|
2499
|
+
self.U_n = cg_tax_n
|
|
2132
2500
|
# Compute Medicare through self-consistent loop.
|
|
2133
2501
|
if includeMedicare:
|
|
2134
2502
|
self.M_n = tx.mediCosts(self.yobs, self.horizons, self.MAGI_n, self.prevMAGI, self.gamma_n[:-1], self.N_n)
|
|
@@ -2196,27 +2564,32 @@ class Plan:
|
|
|
2196
2564
|
self.G_n = np.sum(self.f_tn, axis=0)
|
|
2197
2565
|
|
|
2198
2566
|
tau_0 = np.array(self.tau_kn[0, :])
|
|
2199
|
-
tau_0[tau_0 < 0] = 0
|
|
2200
2567
|
# Last year's rates.
|
|
2201
2568
|
tau_0prev = np.roll(tau_0, 1)
|
|
2569
|
+
# Capital gains = price appreciation only (total return - dividend rate)
|
|
2570
|
+
# to avoid double taxation of dividends. No tax harvesting here.
|
|
2571
|
+
capital_gains_rate = np.maximum(0, tau_0prev - self.mu)
|
|
2202
2572
|
self.Q_n = np.sum(
|
|
2203
2573
|
(
|
|
2204
2574
|
self.mu
|
|
2205
2575
|
* (self.b_ijn[:, 0, :Nn] - self.w_ijn[:, 0, :] + self.d_in[:, :] + 0.5 * self.kappa_ijn[:, 0, :Nn])
|
|
2206
|
-
+
|
|
2576
|
+
+ capital_gains_rate * self.w_ijn[:, 0, :]
|
|
2207
2577
|
)
|
|
2208
2578
|
* self.alpha_ijkn[:, 0, 0, :Nn],
|
|
2209
2579
|
axis=0,
|
|
2210
2580
|
)
|
|
2211
|
-
# Add fixed assets capital gains
|
|
2581
|
+
# Add fixed assets capital gains.
|
|
2212
2582
|
self.Q_n += self.fixed_assets_capital_gains_n
|
|
2213
2583
|
self.U_n = self.psi_n * self.Q_n
|
|
2214
2584
|
|
|
2215
|
-
|
|
2585
|
+
# Also add back non-taxable part of SS.
|
|
2586
|
+
self.MAGI_n = (self.G_n + self.e_n + self.Q_n
|
|
2587
|
+
+ np.sum((1 - self.Psi_n) * self.zetaBar_in, axis=0))
|
|
2216
2588
|
|
|
2217
2589
|
I_in = ((self.b_ijn[:, 0, :-1] + self.d_in - self.w_ijn[:, 0, :])
|
|
2218
2590
|
* np.sum(self.alpha_ijkn[:, 0, 1:, :Nn] * self.tau_kn[1:, :], axis=1))
|
|
2219
|
-
|
|
2591
|
+
# Clamp interest/dividend income to non-negative. Sum over individuals to share losses across spouses.
|
|
2592
|
+
self.I_n = np.maximum(0, np.sum(I_in, axis=0))
|
|
2220
2593
|
|
|
2221
2594
|
# Stop after building minimum required for self-consistent loop.
|
|
2222
2595
|
if short:
|
|
@@ -2282,33 +2655,12 @@ class Plan:
|
|
|
2282
2655
|
sources["tax-free wdrwl"] = self.w_ijn[:, 2, :]
|
|
2283
2656
|
sources["BTI"] = self.Lambda_in
|
|
2284
2657
|
# Debts and fixed assets (debts are negative as expenses)
|
|
2285
|
-
#
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
else:
|
|
2292
|
-
# For married couples, split using eta between individuals.
|
|
2293
|
-
debt_array = np.zeros((self.N_i, self.N_n))
|
|
2294
|
-
debt_array[0, :] = -self.debt_payments_n * (1 - self.eta)
|
|
2295
|
-
debt_array[1, :] = -self.debt_payments_n * self.eta
|
|
2296
|
-
sources["debt payments"] = debt_array
|
|
2297
|
-
|
|
2298
|
-
fa_tax_free = np.zeros((self.N_i, self.N_n))
|
|
2299
|
-
fa_tax_free[0, :] = self.fixed_assets_tax_free_n * (1 - self.eta)
|
|
2300
|
-
fa_tax_free[1, :] = self.fixed_assets_tax_free_n * self.eta
|
|
2301
|
-
sources["fixed assets tax-free"] = fa_tax_free
|
|
2302
|
-
|
|
2303
|
-
fa_ordinary = np.zeros((self.N_i, self.N_n))
|
|
2304
|
-
fa_ordinary[0, :] = self.fixed_assets_ordinary_income_n * (1 - self.eta)
|
|
2305
|
-
fa_ordinary[1, :] = self.fixed_assets_ordinary_income_n * self.eta
|
|
2306
|
-
sources["fixed assets ordinary"] = fa_ordinary
|
|
2307
|
-
|
|
2308
|
-
fa_capital = np.zeros((self.N_i, self.N_n))
|
|
2309
|
-
fa_capital[0, :] = self.fixed_assets_capital_gains_n * (1 - self.eta)
|
|
2310
|
-
fa_capital[1, :] = self.fixed_assets_capital_gains_n * self.eta
|
|
2311
|
-
sources["fixed assets capital gains"] = fa_capital
|
|
2658
|
+
# Show as household totals, not split between individuals
|
|
2659
|
+
# Reshape to (1, N_n) to indicate household-level source
|
|
2660
|
+
sources["FA ord inc"] = self.fixed_assets_ordinary_income_n.reshape(1, -1)
|
|
2661
|
+
sources["FA cap gains"] = self.fixed_assets_capital_gains_n.reshape(1, -1)
|
|
2662
|
+
sources["FA tax-free"] = self.fixed_assets_tax_free_n.reshape(1, -1)
|
|
2663
|
+
sources["debt pmts"] = -self.debt_payments_n.reshape(1, -1)
|
|
2312
2664
|
|
|
2313
2665
|
savings = {}
|
|
2314
2666
|
savings["taxable"] = self.b_ijn[:, 0, :]
|
|
@@ -2340,75 +2692,80 @@ class Plan:
|
|
|
2340
2692
|
return None
|
|
2341
2693
|
|
|
2342
2694
|
@_checkCaseStatus
|
|
2343
|
-
def summary(self):
|
|
2695
|
+
def summary(self, N=None):
|
|
2344
2696
|
"""
|
|
2345
2697
|
Print summary in logs.
|
|
2346
2698
|
"""
|
|
2347
2699
|
self.mylog.print("SUMMARY ================================================================")
|
|
2348
|
-
dic = self.summaryDic()
|
|
2700
|
+
dic = self.summaryDic(N)
|
|
2349
2701
|
for key, value in dic.items():
|
|
2350
2702
|
self.mylog.print(f"{key}: {value}")
|
|
2351
2703
|
self.mylog.print("------------------------------------------------------------------------")
|
|
2352
2704
|
|
|
2353
2705
|
return None
|
|
2354
2706
|
|
|
2355
|
-
def summaryList(self):
|
|
2707
|
+
def summaryList(self, N=None):
|
|
2356
2708
|
"""
|
|
2357
2709
|
Return summary as a list.
|
|
2358
2710
|
"""
|
|
2359
2711
|
mylist = []
|
|
2360
|
-
dic = self.summaryDic()
|
|
2712
|
+
dic = self.summaryDic(N)
|
|
2361
2713
|
for key, value in dic.items():
|
|
2362
2714
|
mylist.append(f"{key}: {value}")
|
|
2363
2715
|
|
|
2364
2716
|
return mylist
|
|
2365
2717
|
|
|
2366
|
-
def summaryDf(self):
|
|
2718
|
+
def summaryDf(self, N=None):
|
|
2367
2719
|
"""
|
|
2368
2720
|
Return summary as a dataframe.
|
|
2369
2721
|
"""
|
|
2370
|
-
return pd.DataFrame(self.summaryDic(), index=[self._name])
|
|
2722
|
+
return pd.DataFrame(self.summaryDic(N), index=[self._name])
|
|
2371
2723
|
|
|
2372
|
-
def summaryString(self):
|
|
2724
|
+
def summaryString(self, N=None):
|
|
2373
2725
|
"""
|
|
2374
2726
|
Return summary as a string.
|
|
2375
2727
|
"""
|
|
2376
2728
|
string = "Synopsis\n"
|
|
2377
|
-
dic = self.summaryDic()
|
|
2729
|
+
dic = self.summaryDic(N)
|
|
2378
2730
|
for key, value in dic.items():
|
|
2379
|
-
string += f"{key:>
|
|
2731
|
+
string += f"{key:>77}: {value}\n"
|
|
2380
2732
|
|
|
2381
2733
|
return string
|
|
2382
2734
|
|
|
2383
|
-
def summaryDic(self):
|
|
2735
|
+
def summaryDic(self, N=None):
|
|
2384
2736
|
"""
|
|
2385
2737
|
Return dictionary containing summary of values.
|
|
2386
2738
|
"""
|
|
2739
|
+
if N is None:
|
|
2740
|
+
N = self.N_n
|
|
2741
|
+
if not (0 < N <= self.N_n):
|
|
2742
|
+
raise ValueError(f"Value N={N} is out of reange")
|
|
2743
|
+
|
|
2387
2744
|
now = self.year_n[0]
|
|
2388
2745
|
dic = {}
|
|
2389
2746
|
# Results
|
|
2390
|
-
dic["
|
|
2747
|
+
dic["Case name"] = self._name
|
|
2391
2748
|
dic["Net yearly spending basis" + 26*" ."] = u.d(self.g_n[0] / self.xi_n[0])
|
|
2392
2749
|
dic[f"Net spending for year {now}"] = u.d(self.g_n[0])
|
|
2393
2750
|
dic[f"Net spending remaining in year {now}"] = u.d(self.g_n[0] * self.yearFracLeft)
|
|
2394
2751
|
|
|
2395
|
-
totSpending = np.sum(self.g_n, axis=0)
|
|
2396
|
-
totSpendingNow = np.sum(self.g_n / self.gamma_n[
|
|
2752
|
+
totSpending = np.sum(self.g_n[:N], axis=0)
|
|
2753
|
+
totSpendingNow = np.sum(self.g_n[:N] / self.gamma_n[:N], axis=0)
|
|
2397
2754
|
dic[" Total net spending"] = f"{u.d(totSpendingNow)}"
|
|
2398
2755
|
dic["[Total net spending]"] = f"{u.d(totSpending)}"
|
|
2399
2756
|
|
|
2400
|
-
totRoth = np.sum(self.x_in, axis=(0, 1))
|
|
2401
|
-
totRothNow = np.sum(np.sum(self.x_in, axis=0) / self.gamma_n[
|
|
2757
|
+
totRoth = np.sum(self.x_in[:, :N], axis=(0, 1))
|
|
2758
|
+
totRothNow = np.sum(np.sum(self.x_in[:, :N], axis=0) / self.gamma_n[:N], axis=0)
|
|
2402
2759
|
dic[" Total Roth conversions"] = f"{u.d(totRothNow)}"
|
|
2403
2760
|
dic["[Total Roth conversions]"] = f"{u.d(totRoth)}"
|
|
2404
2761
|
|
|
2405
|
-
taxPaid = np.sum(self.T_n, axis=0)
|
|
2406
|
-
taxPaidNow = np.sum(self.T_n / self.gamma_n[
|
|
2762
|
+
taxPaid = np.sum(self.T_n[:N], axis=0)
|
|
2763
|
+
taxPaidNow = np.sum(self.T_n[:N] / self.gamma_n[:N], axis=0)
|
|
2407
2764
|
dic[" Total tax paid on ordinary income"] = f"{u.d(taxPaidNow)}"
|
|
2408
2765
|
dic["[Total tax paid on ordinary income]"] = f"{u.d(taxPaid)}"
|
|
2409
2766
|
for t in range(self.N_t):
|
|
2410
|
-
taxPaid = np.sum(self.T_tn[t], axis=0)
|
|
2411
|
-
taxPaidNow = np.sum(self.T_tn[t] / self.gamma_n[
|
|
2767
|
+
taxPaid = np.sum(self.T_tn[t, :N], axis=0)
|
|
2768
|
+
taxPaidNow = np.sum(self.T_tn[t, :N] / self.gamma_n[:N], axis=0)
|
|
2412
2769
|
if t >= len(tx.taxBracketNames):
|
|
2413
2770
|
tname = f"Bracket {t}"
|
|
2414
2771
|
else:
|
|
@@ -2416,33 +2773,33 @@ class Plan:
|
|
|
2416
2773
|
dic[f"» Subtotal in tax bracket {tname}"] = f"{u.d(taxPaidNow)}"
|
|
2417
2774
|
dic[f"» [Subtotal in tax bracket {tname}]"] = f"{u.d(taxPaid)}"
|
|
2418
2775
|
|
|
2419
|
-
penaltyPaid = np.sum(self.P_n, axis=0)
|
|
2420
|
-
penaltyPaidNow = np.sum(self.P_n / self.gamma_n[
|
|
2776
|
+
penaltyPaid = np.sum(self.P_n[:N], axis=0)
|
|
2777
|
+
penaltyPaidNow = np.sum(self.P_n[:N] / self.gamma_n[:N], axis=0)
|
|
2421
2778
|
dic["» Subtotal in early withdrawal penalty"] = f"{u.d(penaltyPaidNow)}"
|
|
2422
2779
|
dic["» [Subtotal in early withdrawal penalty]"] = f"{u.d(penaltyPaid)}"
|
|
2423
2780
|
|
|
2424
|
-
taxPaid = np.sum(self.U_n, axis=0)
|
|
2425
|
-
taxPaidNow = np.sum(self.U_n / self.gamma_n[
|
|
2781
|
+
taxPaid = np.sum(self.U_n[:N], axis=0)
|
|
2782
|
+
taxPaidNow = np.sum(self.U_n[:N] / self.gamma_n[:N], axis=0)
|
|
2426
2783
|
dic[" Total tax paid on gains and dividends"] = f"{u.d(taxPaidNow)}"
|
|
2427
2784
|
dic["[Total tax paid on gains and dividends]"] = f"{u.d(taxPaid)}"
|
|
2428
2785
|
|
|
2429
|
-
taxPaid = np.sum(self.J_n, axis=0)
|
|
2430
|
-
taxPaidNow = np.sum(self.J_n / self.gamma_n[
|
|
2786
|
+
taxPaid = np.sum(self.J_n[:N], axis=0)
|
|
2787
|
+
taxPaidNow = np.sum(self.J_n[:N] / self.gamma_n[:N], axis=0)
|
|
2431
2788
|
dic[" Total net investment income tax paid"] = f"{u.d(taxPaidNow)}"
|
|
2432
2789
|
dic["[Total net investment income tax paid]"] = f"{u.d(taxPaid)}"
|
|
2433
2790
|
|
|
2434
|
-
taxPaid = np.sum(self.m_n + self.M_n, axis=0)
|
|
2435
|
-
taxPaidNow = np.sum((self.m_n + self.M_n) / self.gamma_n[
|
|
2791
|
+
taxPaid = np.sum(self.m_n[:N] + self.M_n[:N], axis=0)
|
|
2792
|
+
taxPaidNow = np.sum((self.m_n[:N] + self.M_n[:N]) / self.gamma_n[:N], axis=0)
|
|
2436
2793
|
dic[" Total Medicare premiums paid"] = f"{u.d(taxPaidNow)}"
|
|
2437
2794
|
dic["[Total Medicare premiums paid]"] = f"{u.d(taxPaid)}"
|
|
2438
2795
|
|
|
2439
|
-
totDebtPayments = np.sum(self.debt_payments_n, axis=0)
|
|
2796
|
+
totDebtPayments = np.sum(self.debt_payments_n[:N], axis=0)
|
|
2440
2797
|
if totDebtPayments > 0:
|
|
2441
|
-
totDebtPaymentsNow = np.sum(self.debt_payments_n / self.gamma_n[
|
|
2798
|
+
totDebtPaymentsNow = np.sum(self.debt_payments_n[:N] / self.gamma_n[:N], axis=0)
|
|
2442
2799
|
dic[" Total debt payments"] = f"{u.d(totDebtPaymentsNow)}"
|
|
2443
2800
|
dic["[Total debt payments]"] = f"{u.d(totDebtPayments)}"
|
|
2444
2801
|
|
|
2445
|
-
if self.N_i == 2 and self.n_d < self.N_n:
|
|
2802
|
+
if self.N_i == 2 and self.n_d < self.N_n and N == self.N_n:
|
|
2446
2803
|
p_j = self.partialEstate_j * (1 - self.phi_j)
|
|
2447
2804
|
p_j[1] *= 1 - self.nu
|
|
2448
2805
|
nx = self.n_d - 1
|
|
@@ -2453,58 +2810,66 @@ class Plan:
|
|
|
2453
2810
|
totSpousal = np.sum(q_j)
|
|
2454
2811
|
iname_s = self.inames[self.i_s]
|
|
2455
2812
|
iname_d = self.inames[self.i_d]
|
|
2456
|
-
dic["Year of partial bequest"] =
|
|
2457
|
-
dic[f" Sum of spousal transfer to {iname_s}"] =
|
|
2458
|
-
dic[f"[Sum of spousal transfer to {iname_s}]"] =
|
|
2459
|
-
dic[f"» Spousal transfer to {iname_s} - taxable"] =
|
|
2460
|
-
dic[f"» [Spousal transfer to {iname_s} - taxable]"] =
|
|
2461
|
-
dic[f"» Spousal transfer to {iname_s} - tax-def"] =
|
|
2462
|
-
dic[f"» [Spousal transfer to {iname_s} - tax-def]"] =
|
|
2463
|
-
dic[f"» Spousal transfer to {iname_s} - tax-free"] =
|
|
2464
|
-
dic[f"» [Spousal transfer to {iname_s} - tax-free]"] =
|
|
2465
|
-
|
|
2466
|
-
dic[f" Sum of post-tax non-spousal bequest from {iname_d}"] =
|
|
2467
|
-
dic[f"[Sum of post-tax non-spousal bequest from {iname_d}]"] =
|
|
2468
|
-
dic[f"» Post-tax non-spousal bequest from {iname_d} - taxable"] =
|
|
2469
|
-
dic[f"» [Post-tax non-spousal bequest from {iname_d} - taxable]"] =
|
|
2470
|
-
dic[f"» Post-tax non-spousal bequest from {iname_d} - tax-def"] =
|
|
2471
|
-
dic[f"» [Post-tax non-spousal bequest from {iname_d} - tax-def]"] =
|
|
2472
|
-
dic[f"» Post-tax non-spousal bequest from {iname_d} - tax-free"] =
|
|
2473
|
-
dic[f"» [Post-tax non-spousal bequest from {iname_d} - tax-free]"] =
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
dic["
|
|
2488
|
-
dic["
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
dic["»
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2813
|
+
dic["Year of partial bequest"] = f"{ynx}"
|
|
2814
|
+
dic[f" Sum of spousal transfer to {iname_s}"] = f"{u.d(ynxNow*totSpousal)}"
|
|
2815
|
+
dic[f"[Sum of spousal transfer to {iname_s}]"] = f"{u.d(totSpousal)}"
|
|
2816
|
+
dic[f"» Spousal transfer to {iname_s} - taxable"] = f"{u.d(ynxNow*q_j[0])}"
|
|
2817
|
+
dic[f"» [Spousal transfer to {iname_s} - taxable]"] = f"{u.d(q_j[0])}"
|
|
2818
|
+
dic[f"» Spousal transfer to {iname_s} - tax-def"] = f"{u.d(ynxNow*q_j[1])}"
|
|
2819
|
+
dic[f"» [Spousal transfer to {iname_s} - tax-def]"] = f"{u.d(q_j[1])}"
|
|
2820
|
+
dic[f"» Spousal transfer to {iname_s} - tax-free"] = f"{u.d(ynxNow*q_j[2])}"
|
|
2821
|
+
dic[f"» [Spousal transfer to {iname_s} - tax-free]"] = f"{u.d(q_j[2])}"
|
|
2822
|
+
|
|
2823
|
+
dic[f" Sum of post-tax non-spousal bequest from {iname_d}"] = f"{u.d(ynxNow*totOthers)}"
|
|
2824
|
+
dic[f"[Sum of post-tax non-spousal bequest from {iname_d}]"] = f"{u.d(totOthers)}"
|
|
2825
|
+
dic[f"» Post-tax non-spousal bequest from {iname_d} - taxable"] = f"{u.d(ynxNow*p_j[0])}"
|
|
2826
|
+
dic[f"» [Post-tax non-spousal bequest from {iname_d} - taxable]"] = f"{u.d(p_j[0])}"
|
|
2827
|
+
dic[f"» Post-tax non-spousal bequest from {iname_d} - tax-def"] = f"{u.d(ynxNow*p_j[1])}"
|
|
2828
|
+
dic[f"» [Post-tax non-spousal bequest from {iname_d} - tax-def]"] = f"{u.d(p_j[1])}"
|
|
2829
|
+
dic[f"» Post-tax non-spousal bequest from {iname_d} - tax-free"] = f"{u.d(ynxNow*p_j[2])}"
|
|
2830
|
+
dic[f"» [Post-tax non-spousal bequest from {iname_d} - tax-free]"] = f"{u.d(p_j[2])}"
|
|
2831
|
+
|
|
2832
|
+
if N == self.N_n:
|
|
2833
|
+
estate = np.sum(self.b_ijn[:, :, self.N_n], axis=0)
|
|
2834
|
+
heirsTaxLiability = estate[1] * self.nu
|
|
2835
|
+
estate[1] *= 1 - self.nu
|
|
2836
|
+
endyear = self.year_n[-1]
|
|
2837
|
+
lyNow = 1./self.gamma_n[-1]
|
|
2838
|
+
# Add fixed assets bequest value (assets with yod past plan end)
|
|
2839
|
+
debts = self.remaining_debt_balance
|
|
2840
|
+
savingsEstate = np.sum(estate)
|
|
2841
|
+
totEstate = savingsEstate - debts + self.fixed_assets_bequest_value
|
|
2842
|
+
|
|
2843
|
+
dic["Year of final bequest"] = f"{endyear}"
|
|
2844
|
+
dic[" Total after-tax value of final bequest"] = f"{u.d(lyNow*totEstate)}"
|
|
2845
|
+
dic["» After-tax value of savings assets"] = f"{u.d(lyNow*savingsEstate)}"
|
|
2846
|
+
dic["» Fixed assets liquidated at end of plan"] = f"{u.d(lyNow*self.fixed_assets_bequest_value)}"
|
|
2847
|
+
dic["» With heirs assuming tax liability of"] = f"{u.d(lyNow*heirsTaxLiability)}"
|
|
2848
|
+
dic["» After paying remaining debts of"] = f"{u.d(lyNow*debts)}"
|
|
2849
|
+
|
|
2850
|
+
dic["[Total after-tax value of final bequest]"] = f"{u.d(totEstate)}"
|
|
2851
|
+
dic["[» After-tax value of savings assets]"] = f"{u.d(savingsEstate)}"
|
|
2852
|
+
dic["[» Fixed assets liquidated at end of plan]"] = f"{u.d(self.fixed_assets_bequest_value)}"
|
|
2853
|
+
dic["[» With heirs assuming tax liability of"] = f"{u.d(heirsTaxLiability)}"
|
|
2854
|
+
dic["[» After paying remaining debts of]"] = f"{u.d(debts)}"
|
|
2855
|
+
|
|
2856
|
+
dic["» Post-tax final bequest account value - taxable"] = f"{u.d(lyNow*estate[0])}"
|
|
2857
|
+
dic["» [Post-tax final bequest account value - taxable]"] = f"{u.d(estate[0])}"
|
|
2858
|
+
dic["» Post-tax final bequest account value - tax-def"] = f"{u.d(lyNow*estate[1])}"
|
|
2859
|
+
dic["» [Post-tax final bequest account value - tax-def]"] = f"{u.d(estate[1])}"
|
|
2860
|
+
dic["» Post-tax final bequest account value - tax-free"] = f"{u.d(lyNow*estate[2])}"
|
|
2861
|
+
dic["» [Post-tax final bequest account value - tax-free]"] = f"{u.d(estate[2])}"
|
|
2862
|
+
|
|
2863
|
+
dic["Case starting date"] = str(self.startDate)
|
|
2864
|
+
dic["Cumulative inflation factor at end of final year"] = f"{self.gamma_n[N]:.2f}"
|
|
2501
2865
|
for i in range(self.N_i):
|
|
2502
|
-
dic[f"{self.inames[i]:>14}'s life horizon"] =
|
|
2503
|
-
dic[f"{self.inames[i]:>14}'s years planned"] =
|
|
2866
|
+
dic[f"{self.inames[i]:>14}'s life horizon"] = f"{now} -> {now + self.horizons[i] - 1}"
|
|
2867
|
+
dic[f"{self.inames[i]:>14}'s years planned"] = f"{self.horizons[i]}"
|
|
2504
2868
|
|
|
2505
|
-
dic["
|
|
2869
|
+
dic["Case name"] = self._name
|
|
2506
2870
|
dic["Number of decision variables"] = str(self.A.nvars)
|
|
2507
2871
|
dic["Number of constraints"] = str(self.A.ncons)
|
|
2872
|
+
dic["Convergence"] = self.convergenceType
|
|
2508
2873
|
dic["Case executed on"] = str(self._timestamp)
|
|
2509
2874
|
|
|
2510
2875
|
return dic
|
|
@@ -2516,9 +2881,26 @@ class Plan:
|
|
|
2516
2881
|
A tag string can be set to add information to the title of the plot.
|
|
2517
2882
|
"""
|
|
2518
2883
|
if self.rateMethod in [None, "user", "historical average", "conservative"]:
|
|
2519
|
-
self.mylog.
|
|
2884
|
+
self.mylog.print(f"Warning: Cannot plot correlations for {self.rateMethod} rate method.")
|
|
2520
2885
|
return None
|
|
2521
2886
|
|
|
2887
|
+
# Check if rates are constant (all values are the same for each rate type)
|
|
2888
|
+
# This can happen with fixed rates
|
|
2889
|
+
if self.tau_kn is not None:
|
|
2890
|
+
# Check if all rates are constant (no variation)
|
|
2891
|
+
rates_are_constant = True
|
|
2892
|
+
for k in range(self.N_k):
|
|
2893
|
+
# Check if all values in this rate series are (approximately) the same
|
|
2894
|
+
rate_std = np.std(self.tau_kn[k])
|
|
2895
|
+
# Use a small threshold to account for floating point precision
|
|
2896
|
+
if rate_std > 1e-10: # If standard deviation is non-zero, rates vary
|
|
2897
|
+
rates_are_constant = False
|
|
2898
|
+
break
|
|
2899
|
+
|
|
2900
|
+
if rates_are_constant:
|
|
2901
|
+
self.mylog.print("Warning: Cannot plot correlations for constant rates (no variation in rate values).")
|
|
2902
|
+
return None
|
|
2903
|
+
|
|
2522
2904
|
fig = self._plotter.plot_rates_correlations(self._name, self.tau_kn, self.N_n, self.rateMethod,
|
|
2523
2905
|
self.rateFrm, self.rateTo, tag, shareRange)
|
|
2524
2906
|
|
|
@@ -2547,7 +2929,7 @@ class Plan:
|
|
|
2547
2929
|
A tag string can be set to add information to the title of the plot.
|
|
2548
2930
|
"""
|
|
2549
2931
|
if self.rateMethod is None:
|
|
2550
|
-
self.mylog.
|
|
2932
|
+
self.mylog.print("Warning: Rate method must be selected before plotting.")
|
|
2551
2933
|
return None
|
|
2552
2934
|
|
|
2553
2935
|
fig = self._plotter.plot_rates(self._name, self.tau_kn, self.year_n,
|
|
@@ -2566,7 +2948,7 @@ class Plan:
|
|
|
2566
2948
|
A tag string can be set to add information to the title of the plot.
|
|
2567
2949
|
"""
|
|
2568
2950
|
if self.xi_n is None:
|
|
2569
|
-
self.mylog.
|
|
2951
|
+
self.mylog.print("Warning: Profile must be selected before plotting.")
|
|
2570
2952
|
return None
|
|
2571
2953
|
title = self._name + "\nSpending Profile"
|
|
2572
2954
|
if tag:
|
|
@@ -2736,7 +3118,7 @@ class Plan:
|
|
|
2736
3118
|
self._plotter.jupyter_renderer(fig)
|
|
2737
3119
|
return None
|
|
2738
3120
|
|
|
2739
|
-
def saveWorkbook(self, overwrite=False, *, basename=None, saveToFile=True):
|
|
3121
|
+
def saveWorkbook(self, overwrite=False, *, basename=None, saveToFile=True, with_config="no"):
|
|
2740
3122
|
"""
|
|
2741
3123
|
Save instance in an Excel spreadsheet.
|
|
2742
3124
|
The first worksheet will contain income in the following
|
|
@@ -2745,10 +3127,10 @@ class Plan:
|
|
|
2745
3127
|
- taxable ordinary income
|
|
2746
3128
|
- taxable dividends
|
|
2747
3129
|
- tax bills (federal only, including IRMAA)
|
|
2748
|
-
for all the years for the time span of the
|
|
3130
|
+
for all the years for the time span of the case.
|
|
2749
3131
|
|
|
2750
3132
|
The second worksheet contains the rates
|
|
2751
|
-
used for the
|
|
3133
|
+
used for the case as follows:
|
|
2752
3134
|
- S&P 500
|
|
2753
3135
|
- Corporate Baa bonds
|
|
2754
3136
|
- Treasury notes (10y)
|
|
@@ -2770,7 +3152,30 @@ class Plan:
|
|
|
2770
3152
|
- tax-free account.
|
|
2771
3153
|
|
|
2772
3154
|
Last worksheet contains summary.
|
|
3155
|
+
|
|
3156
|
+
with_config controls whether to insert the current case configuration
|
|
3157
|
+
as a TOML sheet. Valid values are:
|
|
3158
|
+
- "no": do not include config
|
|
3159
|
+
- "first": insert config as the first sheet
|
|
3160
|
+
- "last": insert config as the last sheet
|
|
2773
3161
|
"""
|
|
3162
|
+
def add_config_sheet(position):
|
|
3163
|
+
if with_config == "no":
|
|
3164
|
+
return
|
|
3165
|
+
if with_config not in {"no", "first", "last"}:
|
|
3166
|
+
raise ValueError(f"Invalid with_config option '{with_config}'.")
|
|
3167
|
+
if position != with_config:
|
|
3168
|
+
return
|
|
3169
|
+
|
|
3170
|
+
from io import StringIO
|
|
3171
|
+
|
|
3172
|
+
config_buffer = StringIO()
|
|
3173
|
+
config.saveConfig(self, config_buffer, self.mylog)
|
|
3174
|
+
config_buffer.seek(0)
|
|
3175
|
+
|
|
3176
|
+
ws_config = wb.create_sheet(title="Config (.toml)", index=0 if position == "first" else None)
|
|
3177
|
+
for row_idx, line in enumerate(config_buffer.getvalue().splitlines(), start=1):
|
|
3178
|
+
ws_config.cell(row=row_idx, column=1, value=line)
|
|
2774
3179
|
|
|
2775
3180
|
def fillsheet(sheet, dic, datatype, op=lambda x: x):
|
|
2776
3181
|
rawData = {}
|
|
@@ -2790,6 +3195,7 @@ class Plan:
|
|
|
2790
3195
|
_formatSpreadsheet(ws, datatype)
|
|
2791
3196
|
|
|
2792
3197
|
wb = Workbook()
|
|
3198
|
+
add_config_sheet("first")
|
|
2793
3199
|
|
|
2794
3200
|
# Income.
|
|
2795
3201
|
ws = wb.active
|
|
@@ -2811,6 +3217,10 @@ class Plan:
|
|
|
2811
3217
|
"all pensions": np.sum(self.piBar_in, axis=0),
|
|
2812
3218
|
"all soc sec": np.sum(self.zetaBar_in, axis=0),
|
|
2813
3219
|
"all BTI's": np.sum(self.Lambda_in, axis=0),
|
|
3220
|
+
"FA ord inc": self.fixed_assets_ordinary_income_n,
|
|
3221
|
+
"FA cap gains": self.fixed_assets_capital_gains_n,
|
|
3222
|
+
"FA tax-free": self.fixed_assets_tax_free_n,
|
|
3223
|
+
"debt pmts": -self.debt_payments_n,
|
|
2814
3224
|
"all wdrwls": np.sum(self.w_ijn, axis=(0, 1)),
|
|
2815
3225
|
"all deposits": -np.sum(self.d_in, axis=0),
|
|
2816
3226
|
"ord taxes": -self.T_n - self.J_n,
|
|
@@ -2839,6 +3249,16 @@ class Plan:
|
|
|
2839
3249
|
ws = wb.create_sheet(sname)
|
|
2840
3250
|
fillsheet(ws, srcDic, "currency", op=lambda x: x[i]) # noqa: B023
|
|
2841
3251
|
|
|
3252
|
+
# Household sources (debts and fixed assets)
|
|
3253
|
+
householdSrcDic = {
|
|
3254
|
+
"FA ord inc": self.sources_in["FA ord inc"],
|
|
3255
|
+
"FA cap gains": self.sources_in["FA cap gains"],
|
|
3256
|
+
"FA tax-free": self.sources_in["FA tax-free"],
|
|
3257
|
+
"debt pmts": self.sources_in["debt pmts"],
|
|
3258
|
+
}
|
|
3259
|
+
ws = wb.create_sheet("Household Sources")
|
|
3260
|
+
fillsheet(ws, householdSrcDic, "currency", op=lambda x: x[0])
|
|
3261
|
+
|
|
2842
3262
|
# Account balances except final year.
|
|
2843
3263
|
accDic = {
|
|
2844
3264
|
"taxable bal": self.b_ijn[:, 0, :-1],
|
|
@@ -2933,6 +3353,7 @@ class Plan:
|
|
|
2933
3353
|
ws.append(row)
|
|
2934
3354
|
|
|
2935
3355
|
_formatSpreadsheet(ws, "summary")
|
|
3356
|
+
add_config_sheet("last")
|
|
2936
3357
|
|
|
2937
3358
|
if saveToFile:
|
|
2938
3359
|
if basename is None:
|
|
@@ -2952,11 +3373,29 @@ class Plan:
|
|
|
2952
3373
|
|
|
2953
3374
|
planData = {}
|
|
2954
3375
|
planData["year"] = self.year_n
|
|
3376
|
+
|
|
3377
|
+
# Income data
|
|
2955
3378
|
planData["net spending"] = self.g_n
|
|
2956
3379
|
planData["taxable ord. income"] = self.G_n
|
|
2957
3380
|
planData["taxable gains/divs"] = self.Q_n
|
|
2958
|
-
planData["
|
|
2959
|
-
|
|
3381
|
+
planData["Tax bills + Med."] = self.T_n + self.U_n + self.m_n + self.M_n + self.J_n
|
|
3382
|
+
|
|
3383
|
+
# Cash flow data (matching Cash Flow worksheet)
|
|
3384
|
+
planData["all wages"] = np.sum(self.omega_in, axis=0)
|
|
3385
|
+
planData["all pensions"] = np.sum(self.piBar_in, axis=0)
|
|
3386
|
+
planData["all soc sec"] = np.sum(self.zetaBar_in, axis=0)
|
|
3387
|
+
planData["all BTI's"] = np.sum(self.Lambda_in, axis=0)
|
|
3388
|
+
planData["FA ord inc"] = self.fixed_assets_ordinary_income_n
|
|
3389
|
+
planData["FA cap gains"] = self.fixed_assets_capital_gains_n
|
|
3390
|
+
planData["FA tax-free"] = self.fixed_assets_tax_free_n
|
|
3391
|
+
planData["debt pmts"] = -self.debt_payments_n
|
|
3392
|
+
planData["all wdrwls"] = np.sum(self.w_ijn, axis=(0, 1))
|
|
3393
|
+
planData["all deposits"] = -np.sum(self.d_in, axis=0)
|
|
3394
|
+
planData["ord taxes"] = -self.T_n - self.J_n
|
|
3395
|
+
planData["div taxes"] = -self.U_n
|
|
3396
|
+
planData["Medicare"] = -self.m_n - self.M_n
|
|
3397
|
+
|
|
3398
|
+
# Individual account data
|
|
2960
3399
|
for i in range(self.N_i):
|
|
2961
3400
|
planData[self.inames[i] + " txbl bal"] = self.b_ijn[i, 0, :-1]
|
|
2962
3401
|
planData[self.inames[i] + " txbl dep"] = self.d_in[i, :]
|
|
@@ -2971,6 +3410,7 @@ class Plan:
|
|
|
2971
3410
|
planData[self.inames[i] + " tax-free wdrwl"] = self.w_ijn[i, 2, :]
|
|
2972
3411
|
planData[self.inames[i] + " big-ticket items"] = self.Lambda_in[i, :]
|
|
2973
3412
|
|
|
3413
|
+
# Rates
|
|
2974
3414
|
ratesDic = {"S&P 500": 0, "Corporate Baa": 1, "T Bonds": 2, "inflation": 3}
|
|
2975
3415
|
for key in ratesDic:
|
|
2976
3416
|
planData[key] = self.tau_kn[ratesDic[key]]
|
|
@@ -3020,7 +3460,7 @@ def _saveWorkbook(wb, basename, overwrite, mylog):
|
|
|
3020
3460
|
mylog.print(f'File "{fname}" already exists.')
|
|
3021
3461
|
key = input("Overwrite? [Ny] ")
|
|
3022
3462
|
if key != "y":
|
|
3023
|
-
mylog.
|
|
3463
|
+
mylog.print("Skipping save and returning.")
|
|
3024
3464
|
return None
|
|
3025
3465
|
|
|
3026
3466
|
for _ in range(3):
|
|
@@ -3067,7 +3507,11 @@ def _formatSpreadsheet(ws, ftype):
|
|
|
3067
3507
|
# col[0].style = 'Title'
|
|
3068
3508
|
width = max(len(str(col[0].value)) + 4, 10)
|
|
3069
3509
|
ws.column_dimensions[column].width = width
|
|
3070
|
-
if column
|
|
3510
|
+
if column == "A":
|
|
3511
|
+
# Format year column as integer without commas
|
|
3512
|
+
for cell in col:
|
|
3513
|
+
cell.number_format = "0"
|
|
3514
|
+
else:
|
|
3071
3515
|
for cell in col:
|
|
3072
3516
|
cell.number_format = fstring
|
|
3073
3517
|
|
|
@@ -3098,8 +3542,8 @@ def _formatDebtsSheet(ws):
|
|
|
3098
3542
|
# Apply formatting based on column name
|
|
3099
3543
|
for col_letter, col_name in col_map.items():
|
|
3100
3544
|
if col_name in ["year", "term"]:
|
|
3101
|
-
# Integer format
|
|
3102
|
-
fstring = "
|
|
3545
|
+
# Integer format without commas
|
|
3546
|
+
fstring = "0"
|
|
3103
3547
|
elif col_name in ["rate"]:
|
|
3104
3548
|
# Number format (2 decimal places for percentages stored as numbers)
|
|
3105
3549
|
fstring = "#,##0.00"
|
|
@@ -3143,8 +3587,8 @@ def _formatFixedAssetsSheet(ws):
|
|
|
3143
3587
|
# Apply formatting based on column name
|
|
3144
3588
|
for col_letter, col_name in col_map.items():
|
|
3145
3589
|
if col_name in ["yod"]:
|
|
3146
|
-
# Integer format
|
|
3147
|
-
fstring = "
|
|
3590
|
+
# Integer format without commas
|
|
3591
|
+
fstring = "0"
|
|
3148
3592
|
elif col_name in ["rate", "commission"]:
|
|
3149
3593
|
# Number format (1 decimal place for percentages stored as numbers)
|
|
3150
3594
|
fstring = "#,##0.00"
|