owlplanner 2025.12.20__py3-none-any.whl → 2026.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- owlplanner/__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 +315 -118
- 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 +938 -390
- 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 +50 -34
- owlplanner/socialsecurity.py +28 -19
- owlplanner/tax2026.py +119 -38
- owlplanner/timelists.py +194 -18
- owlplanner/utils.py +179 -4
- owlplanner/version.py +20 -1
- {owlplanner-2025.12.20.dist-info → owlplanner-2026.2.2.dist-info}/METADATA +11 -3
- owlplanner-2026.2.2.dist-info/RECORD +35 -0
- owlplanner-2026.2.2.dist-info/entry_points.txt +2 -0
- owlplanner-2026.2.2.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.2.2.dist-info}/WHEEL +0 -0
- {owlplanner-2025.12.20.dist-info → owlplanner-2026.2.2.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
|
|
9
|
+
|
|
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.
|
|
12
14
|
|
|
13
|
-
|
|
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.
|
|
14
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,29 @@ from . import progress
|
|
|
37
45
|
from .plotting.factory import PlotFactory
|
|
38
46
|
|
|
39
47
|
|
|
48
|
+
# Default values
|
|
49
|
+
BIGM_AMO = 5e7 # 100 times large withdrawals or conversions
|
|
50
|
+
GAP = 1e-4
|
|
51
|
+
MILP_GAP = 10 * GAP
|
|
52
|
+
MAX_ITERATIONS = 29
|
|
53
|
+
ABS_TOL = 50
|
|
54
|
+
REL_TOL = 5e-6
|
|
55
|
+
TIME_LIMIT = 900
|
|
56
|
+
EPSILON = 1e-9
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _apply_rate_sequence_transform(tau_kn, reverse, roll):
|
|
60
|
+
"""
|
|
61
|
+
Apply reverse and/or roll to a rate series (N_k x N_n).
|
|
62
|
+
Returns a new array; does not modify the input.
|
|
63
|
+
"""
|
|
64
|
+
if reverse:
|
|
65
|
+
tau_kn = tau_kn[:, ::-1]
|
|
66
|
+
if roll != 0:
|
|
67
|
+
tau_kn = np.roll(tau_kn, int(roll), axis=1)
|
|
68
|
+
return tau_kn
|
|
69
|
+
|
|
70
|
+
|
|
40
71
|
def _genGamma_n(tau):
|
|
41
72
|
"""
|
|
42
73
|
Utility function to generate a cumulative inflation multiplier
|
|
@@ -85,7 +116,7 @@ def _genXi_n(profile, fraction, n_d, N_n, a, b, c):
|
|
|
85
116
|
xi[n_d:] *= fraction
|
|
86
117
|
xi *= neutralSum / xi.sum()
|
|
87
118
|
else:
|
|
88
|
-
raise ValueError(f"Unknown profile type {profile}.")
|
|
119
|
+
raise ValueError(f"Unknown profile type '{profile}'.")
|
|
89
120
|
|
|
90
121
|
return xi
|
|
91
122
|
|
|
@@ -127,20 +158,18 @@ def _q4(C, l1, l2, l3, l4, N1, N2, N3, N4):
|
|
|
127
158
|
|
|
128
159
|
def clone(plan, newname=None, *, verbose=True, logstreams=None):
|
|
129
160
|
"""
|
|
130
|
-
Return an almost identical copy of plan: only the name of the
|
|
161
|
+
Return an almost identical copy of plan: only the name of the case
|
|
131
162
|
has been modified and appended the string '(copy)',
|
|
132
163
|
unless a new name is provided as an argument.
|
|
133
164
|
"""
|
|
134
165
|
import copy
|
|
135
166
|
|
|
136
|
-
#
|
|
137
|
-
mylogger = plan.logger()
|
|
138
|
-
plan.setLogger(None)
|
|
167
|
+
# logger __deepcopy__ sets the logstreams of new logger to None
|
|
139
168
|
newplan = copy.deepcopy(plan)
|
|
140
|
-
plan.setLogger(mylogger)
|
|
141
169
|
|
|
142
170
|
if logstreams is None:
|
|
143
|
-
|
|
171
|
+
original_logger = plan.logger()
|
|
172
|
+
newplan.setLogger(original_logger)
|
|
144
173
|
else:
|
|
145
174
|
newplan.setLogstreams(verbose, logstreams)
|
|
146
175
|
|
|
@@ -164,7 +193,7 @@ def _checkCaseStatus(func):
|
|
|
164
193
|
@wraps(func)
|
|
165
194
|
def wrapper(self, *args, **kwargs):
|
|
166
195
|
if self.caseStatus != "solved":
|
|
167
|
-
self.mylog.
|
|
196
|
+
self.mylog.print(f"Preventing to run method {func.__name__}() while case is {self.caseStatus}.")
|
|
168
197
|
return None
|
|
169
198
|
return func(self, *args, **kwargs)
|
|
170
199
|
|
|
@@ -181,11 +210,11 @@ def _checkConfiguration(func):
|
|
|
181
210
|
def wrapper(self, *args, **kwargs):
|
|
182
211
|
if self.xi_n is None:
|
|
183
212
|
msg = f"You must define a spending profile before calling {func.__name__}()."
|
|
184
|
-
self.mylog.
|
|
213
|
+
self.mylog.print(msg)
|
|
185
214
|
raise RuntimeError(msg)
|
|
186
215
|
if self.alpha_ijkn is None:
|
|
187
216
|
msg = f"You must define an allocation profile before calling {func.__name__}()."
|
|
188
|
-
self.mylog.
|
|
217
|
+
self.mylog.print(msg)
|
|
189
218
|
raise RuntimeError(msg)
|
|
190
219
|
return func(self, *args, **kwargs)
|
|
191
220
|
|
|
@@ -204,7 +233,8 @@ def _timer(func):
|
|
|
204
233
|
result = func(self, *args, **kwargs)
|
|
205
234
|
pt = time.process_time() - pt0
|
|
206
235
|
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."
|
|
236
|
+
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.",
|
|
237
|
+
tag="INFO")
|
|
208
238
|
return result
|
|
209
239
|
|
|
210
240
|
return wrapper
|
|
@@ -214,6 +244,17 @@ class Plan:
|
|
|
214
244
|
"""
|
|
215
245
|
This is the main class of the Owl Project.
|
|
216
246
|
"""
|
|
247
|
+
# Class-level counter for unique Plan IDs
|
|
248
|
+
_id_counter = 0
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def get_next_id(cls):
|
|
252
|
+
cls._id_counter += 1
|
|
253
|
+
return cls._id_counter
|
|
254
|
+
|
|
255
|
+
@classmethod
|
|
256
|
+
def get_current_id(cls):
|
|
257
|
+
return cls._id_counter
|
|
217
258
|
|
|
218
259
|
def __init__(self, inames, dobs, expectancy, name, *, verbose=False, logstreams=None):
|
|
219
260
|
"""
|
|
@@ -221,11 +262,14 @@ class Plan:
|
|
|
221
262
|
one contains the name(s) of the individual(s),
|
|
222
263
|
the second one is the year of birth of each individual,
|
|
223
264
|
and the third the life expectancy. Last argument is a name for
|
|
224
|
-
the
|
|
265
|
+
the case.
|
|
225
266
|
"""
|
|
226
267
|
if name == "":
|
|
227
268
|
raise ValueError("Plan must have a name")
|
|
228
269
|
|
|
270
|
+
# Generate unique ID for this Plan instance using the class method
|
|
271
|
+
self._id = Plan.get_next_id()
|
|
272
|
+
|
|
229
273
|
self._name = name
|
|
230
274
|
self.setLogstreams(verbose, logstreams)
|
|
231
275
|
|
|
@@ -234,8 +278,8 @@ class Plan:
|
|
|
234
278
|
self.N_q = 6
|
|
235
279
|
self.N_j = 3
|
|
236
280
|
self.N_k = 4
|
|
237
|
-
#
|
|
238
|
-
self.N_zx =
|
|
281
|
+
# 4 binary variables for exclusions.
|
|
282
|
+
self.N_zx = 4
|
|
239
283
|
|
|
240
284
|
# Default interpolation parameters for allocation ratios.
|
|
241
285
|
self.interpMethod = "linear"
|
|
@@ -274,8 +318,11 @@ class Plan:
|
|
|
274
318
|
thisyear = date.today().year
|
|
275
319
|
self.horizons = self.yobs + self.expectancy - thisyear + 1
|
|
276
320
|
self.N_n = np.max(self.horizons)
|
|
321
|
+
if self.N_n <= 2:
|
|
322
|
+
raise ValueError(f"Plan needs more than {self.N_n} years.")
|
|
323
|
+
|
|
277
324
|
self.year_n = np.linspace(thisyear, thisyear + self.N_n - 1, self.N_n, dtype=np.int32)
|
|
278
|
-
# Year index in the
|
|
325
|
+
# Year index in the case (if any) where individuals turn 59. For 10% withdrawal penalty.
|
|
279
326
|
self.n59 = 59 - thisyear + self.yobs
|
|
280
327
|
self.n59[self.n59 < 0] = 0
|
|
281
328
|
# Handle passing of one spouse before the other.
|
|
@@ -290,9 +337,11 @@ class Plan:
|
|
|
290
337
|
|
|
291
338
|
# Default parameters:
|
|
292
339
|
self.psi_n = np.zeros(self.N_n) # Long-term income tax rate on capital gains (decimal)
|
|
293
|
-
|
|
294
|
-
self.
|
|
295
|
-
self.
|
|
340
|
+
# Fraction of social security benefits that is taxed (fixed at 85% for now).
|
|
341
|
+
self.Psi_n = np.ones(self.N_n) * 0.85
|
|
342
|
+
self.chi = 0.60 # Survivor fraction
|
|
343
|
+
self.mu = 0.0172 # Dividend rate (decimal)
|
|
344
|
+
self.nu = 0.300 # Heirs tax rate (decimal)
|
|
296
345
|
self.eta = (self.N_i - 1) / 2 # Spousal deposit ratio (0 or .5)
|
|
297
346
|
self.phi_j = np.array([1, 1, 1]) # Fractions left to other spouse at death
|
|
298
347
|
self.smileDip = 15 # Percent to reduce smile profile
|
|
@@ -330,6 +379,7 @@ class Plan:
|
|
|
330
379
|
# Previous 2 years of MAGI needed for Medicare.
|
|
331
380
|
self.prevMAGI = np.zeros((2))
|
|
332
381
|
self.MAGI_n = np.zeros(self.N_n)
|
|
382
|
+
self.solverOptions = {}
|
|
333
383
|
|
|
334
384
|
# Init current balances to none.
|
|
335
385
|
self.beta_ij = None
|
|
@@ -340,13 +390,13 @@ class Plan:
|
|
|
340
390
|
|
|
341
391
|
# Scenario starts at the beginning of this year and ends at the end of the last year.
|
|
342
392
|
s = ("", "s")[self.N_i - 1]
|
|
343
|
-
self.mylog.vprint(f"Preparing scenario of {self.N_n} years for {self.N_i} individual{s}.")
|
|
393
|
+
self.mylog.vprint(f"Preparing scenario '{self._id}' of {self.N_n} years for {self.N_i} individual{s}.")
|
|
344
394
|
for i in range(self.N_i):
|
|
345
395
|
endyear = thisyear + self.horizons[i] - 1
|
|
346
396
|
self.mylog.vprint(f"{self.inames[i]:>14}: life horizon from {thisyear} -> {endyear}.")
|
|
347
397
|
|
|
348
398
|
# Prepare RMD time series.
|
|
349
|
-
self.rho_in = tx.rho_in(self.yobs, self.N_n)
|
|
399
|
+
self.rho_in = tx.rho_in(self.yobs, self.expectancy, self.N_n)
|
|
350
400
|
|
|
351
401
|
# Initialize guardrails to ensure proper configuration.
|
|
352
402
|
self._adjustedParameters = False
|
|
@@ -355,7 +405,13 @@ class Plan:
|
|
|
355
405
|
self.houseLists = {}
|
|
356
406
|
self.zeroContributions()
|
|
357
407
|
self.caseStatus = "unsolved"
|
|
408
|
+
# "monotonic", "oscillatory", "max iteration", or "undefined" - how solution was obtained
|
|
409
|
+
self.convergenceType = "undefined"
|
|
358
410
|
self.rateMethod = None
|
|
411
|
+
self.reproducibleRates = False
|
|
412
|
+
self.rateSeed = None
|
|
413
|
+
self.rateReverse = False
|
|
414
|
+
self.rateRoll = 0
|
|
359
415
|
|
|
360
416
|
self.ARCoord = None
|
|
361
417
|
self.objective = "unknown"
|
|
@@ -371,7 +427,7 @@ class Plan:
|
|
|
371
427
|
|
|
372
428
|
def setLogstreams(self, verbose, logstreams):
|
|
373
429
|
self.mylog = log.Logger(verbose, logstreams)
|
|
374
|
-
|
|
430
|
+
self.mylog.vprint(f"Setting logger with logstreams {logstreams}.")
|
|
375
431
|
|
|
376
432
|
def logger(self):
|
|
377
433
|
return self.mylog
|
|
@@ -386,7 +442,7 @@ class Plan:
|
|
|
386
442
|
|
|
387
443
|
def _setStartingDate(self, mydate):
|
|
388
444
|
"""
|
|
389
|
-
Set the date when the
|
|
445
|
+
Set the date when the case starts in the current year.
|
|
390
446
|
This is mostly for reproducibility purposes and back projecting known balances to Jan 1st.
|
|
391
447
|
String format of mydate is 'MM/DD', 'MM-DD', 'YYYY-MM-DD', or 'YYYY/MM/DD'. Year is ignored.
|
|
392
448
|
"""
|
|
@@ -432,16 +488,16 @@ class Plan:
|
|
|
432
488
|
|
|
433
489
|
def rename(self, newname):
|
|
434
490
|
"""
|
|
435
|
-
Override name of the
|
|
491
|
+
Override name of the case. Case name is used
|
|
436
492
|
to distinguish graph outputs and as base name for
|
|
437
493
|
saving configurations and workbooks.
|
|
438
494
|
"""
|
|
439
|
-
self.mylog.vprint(f"Renaming
|
|
495
|
+
self.mylog.vprint(f"Renaming case '{self._name}' -> '{newname}'.")
|
|
440
496
|
self._name = newname
|
|
441
497
|
|
|
442
498
|
def setDescription(self, description):
|
|
443
499
|
"""
|
|
444
|
-
Set a text description of the
|
|
500
|
+
Set a text description of the case.
|
|
445
501
|
"""
|
|
446
502
|
self._description = description
|
|
447
503
|
|
|
@@ -458,7 +514,7 @@ class Plan:
|
|
|
458
514
|
if not (0 <= eta <= 1):
|
|
459
515
|
raise ValueError("Fraction must be between 0 and 1.")
|
|
460
516
|
if self.N_i != 2:
|
|
461
|
-
self.mylog.
|
|
517
|
+
self.mylog.print("Deposit fraction can only be 0 for single individuals.")
|
|
462
518
|
eta = 0
|
|
463
519
|
else:
|
|
464
520
|
self.mylog.vprint(f"Setting spousal surplus deposit fraction to {eta:.1f}.")
|
|
@@ -471,7 +527,7 @@ class Plan:
|
|
|
471
527
|
"""
|
|
472
528
|
|
|
473
529
|
self.defaultPlots = self._checkValueType(value)
|
|
474
|
-
self.mylog.vprint(f"Setting plots default value to {value}.")
|
|
530
|
+
self.mylog.vprint(f"Setting plots default value to '{value}'.")
|
|
475
531
|
|
|
476
532
|
def setPlotBackend(self, backend: str):
|
|
477
533
|
"""
|
|
@@ -479,12 +535,12 @@ class Plan:
|
|
|
479
535
|
"""
|
|
480
536
|
|
|
481
537
|
if backend not in ("matplotlib", "plotly"):
|
|
482
|
-
raise ValueError(f"Backend {backend} not a valid option.")
|
|
538
|
+
raise ValueError(f"Backend '{backend}' not a valid option.")
|
|
483
539
|
|
|
484
540
|
if backend != self._plotterName:
|
|
485
541
|
self._plotter = PlotFactory.createBackend(backend)
|
|
486
542
|
self._plotterName = backend
|
|
487
|
-
self.mylog.vprint(f"Setting plotting backend to {backend}.")
|
|
543
|
+
self.mylog.vprint(f"Setting plotting backend to '{backend}'.")
|
|
488
544
|
|
|
489
545
|
def setDividendRate(self, mu):
|
|
490
546
|
"""
|
|
@@ -522,8 +578,8 @@ class Plan:
|
|
|
522
578
|
self.caseStatus = "modified"
|
|
523
579
|
|
|
524
580
|
if np.any(self.phi_j != 1):
|
|
525
|
-
self.mylog.
|
|
526
|
-
self.mylog.
|
|
581
|
+
self.mylog.print("Consider changing spousal deposit fraction for better convergence.")
|
|
582
|
+
self.mylog.print(f"\tRecommended: setSpousalDepositFraction({self.i_d}.)")
|
|
527
583
|
|
|
528
584
|
def setHeirsTaxRate(self, nu):
|
|
529
585
|
"""
|
|
@@ -558,9 +614,9 @@ class Plan:
|
|
|
558
614
|
for i in range(self.N_i):
|
|
559
615
|
if amounts[i] != 0:
|
|
560
616
|
# Check if claim age added to birth month falls next year.
|
|
561
|
-
|
|
562
|
-
iage = int(
|
|
563
|
-
fraction = 1 - (
|
|
617
|
+
yearage = ages[i] + (self.mobs[i] - 1)/12
|
|
618
|
+
iage = int(yearage)
|
|
619
|
+
fraction = 1 - (yearage % 1.)
|
|
564
620
|
realns = iage - thisyear + self.yobs[i]
|
|
565
621
|
ns = max(0, realns)
|
|
566
622
|
nd = self.horizons[i]
|
|
@@ -581,6 +637,9 @@ class Plan:
|
|
|
581
637
|
def setSocialSecurity(self, pias, ages):
|
|
582
638
|
"""
|
|
583
639
|
Set value of social security for each individual and claiming age.
|
|
640
|
+
|
|
641
|
+
Note: Social Security benefits are paid in arrears (one month after eligibility).
|
|
642
|
+
The zeta_in array represents when checks actually arrive, not when eligibility starts.
|
|
584
643
|
"""
|
|
585
644
|
if len(pias) != self.N_i:
|
|
586
645
|
raise ValueError(f"Principal Insurance Amount must have {self.N_i} entries.")
|
|
@@ -594,51 +653,52 @@ class Plan:
|
|
|
594
653
|
fras = socsec.getFRAs(self.yobs)
|
|
595
654
|
spousalBenefits = socsec.getSpousalBenefits(pias)
|
|
596
655
|
|
|
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
|
-
)
|
|
656
|
+
self.mylog.vprint("SS monthly PIAs set to", [u.d(pias[i]) for i in range(self.N_i)])
|
|
657
|
+
self.mylog.vprint("SS FRAs(s)", [fras[i] for i in range(self.N_i)])
|
|
658
|
+
self.mylog.vprint("SS benefits claimed at age(s)", [ages[i] for i in range(self.N_i)])
|
|
604
659
|
|
|
605
660
|
thisyear = date.today().year
|
|
606
661
|
self.zeta_in = np.zeros((self.N_i, self.N_n))
|
|
607
662
|
for i in range(self.N_i):
|
|
608
663
|
# Check if age is in bound.
|
|
609
664
|
bornOnFirstDays = (self.tobs[i] <= 2)
|
|
610
|
-
bornOnFirst = (self.tobs[i] == 1)
|
|
611
665
|
|
|
612
666
|
eligible = 62 if bornOnFirstDays else 62 + 1/12
|
|
613
667
|
if ages[i] < eligible:
|
|
614
|
-
self.mylog.
|
|
668
|
+
self.mylog.print(f"Resetting SS claiming age of {self.inames[i]} to {eligible}.")
|
|
615
669
|
ages[i] = eligible
|
|
616
670
|
|
|
617
671
|
# Check if claim age added to birth month falls next year.
|
|
618
|
-
# janage is age with reference to Jan 1 of yob.
|
|
672
|
+
# janage is age with reference to Jan 1 of yob when eligibility starts.
|
|
619
673
|
janage = ages[i] + (self.mobs[i] - 1)/12
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
674
|
+
|
|
675
|
+
# Social Security benefits are paid in arrears (one month after eligibility).
|
|
676
|
+
# Calculate when payments actually start (checks arrive).
|
|
677
|
+
paymentJanage = janage + 1/12
|
|
678
|
+
paymentIage = int(paymentJanage)
|
|
679
|
+
paymentRealns = self.yobs[i] + paymentIage - thisyear
|
|
680
|
+
ns = max(0, paymentRealns)
|
|
623
681
|
nd = self.horizons[i]
|
|
624
682
|
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 - (
|
|
683
|
+
# Reduce starting year due to month offset. If paymentRealns < 0, this has happened already.
|
|
684
|
+
if paymentRealns >= 0:
|
|
685
|
+
self.zeta_in[i, ns] *= 1 - (paymentJanage % 1.)
|
|
628
686
|
|
|
629
687
|
# Increase/decrease PIA due to claiming age.
|
|
630
|
-
self.zeta_in[i, :] *= socsec.getSelfFactor(fras[i], ages[i],
|
|
688
|
+
self.zeta_in[i, :] *= socsec.getSelfFactor(fras[i], ages[i], bornOnFirstDays)
|
|
631
689
|
|
|
632
690
|
# Add spousal benefits if applicable.
|
|
633
691
|
if self.N_i == 2 and spousalBenefits[i] > 0:
|
|
634
|
-
# The latest of the two spouses to claim.
|
|
692
|
+
# The latest of the two spouses to claim (eligibility start).
|
|
635
693
|
claimYear = max(self.yobs + (self.mobs - 1)/12 + ages)
|
|
636
694
|
claimAge = claimYear - self.yobs[i] - (self.mobs[i] - 1)/12
|
|
637
|
-
|
|
638
|
-
|
|
695
|
+
# Spousal benefits are also paid in arrears (one month after eligibility).
|
|
696
|
+
paymentClaimYear = claimYear + 1/12
|
|
697
|
+
ns2 = max(0, int(paymentClaimYear) - thisyear)
|
|
698
|
+
spousalFactor = socsec.getSpousalFactor(fras[i], claimAge, bornOnFirstDays)
|
|
639
699
|
self.zeta_in[i, ns2:nd] += spousalBenefits[i] * spousalFactor
|
|
640
700
|
# Reduce first year of benefit by month offset.
|
|
641
|
-
self.zeta_in[i, ns2] -= spousalBenefits[i] * spousalFactor * (
|
|
701
|
+
self.zeta_in[i, ns2] -= spousalBenefits[i] * spousalFactor * (paymentClaimYear % 1.)
|
|
642
702
|
|
|
643
703
|
# Switch survivor to spousal survivor benefits.
|
|
644
704
|
# Assumes both deceased and survivor already have claimed last year before passing (at n_d - 1).
|
|
@@ -682,7 +742,37 @@ class Plan:
|
|
|
682
742
|
self.smileDelay = delay
|
|
683
743
|
self.caseStatus = "modified"
|
|
684
744
|
|
|
685
|
-
def
|
|
745
|
+
def setReproducible(self, reproducible, seed=None):
|
|
746
|
+
"""
|
|
747
|
+
Set whether rates should be reproducible for stochastic methods.
|
|
748
|
+
This should be called before setting rates. It only sets configuration
|
|
749
|
+
and does not regenerate existing rates.
|
|
750
|
+
|
|
751
|
+
Args:
|
|
752
|
+
reproducible: Boolean indicating if rates should be reproducible.
|
|
753
|
+
seed: Optional seed value. If None and reproducible is True,
|
|
754
|
+
generates a new seed from current time. If None and
|
|
755
|
+
reproducible is False, generates a seed but won't reuse it.
|
|
756
|
+
"""
|
|
757
|
+
self.reproducibleRates = bool(reproducible)
|
|
758
|
+
if reproducible:
|
|
759
|
+
if seed is None:
|
|
760
|
+
if self.rateSeed is not None:
|
|
761
|
+
# Reuse existing seed if available
|
|
762
|
+
seed = self.rateSeed
|
|
763
|
+
else:
|
|
764
|
+
# Generate new seed from current time
|
|
765
|
+
seed = int(time.time() * 1000000) # Use microseconds
|
|
766
|
+
else:
|
|
767
|
+
seed = int(seed)
|
|
768
|
+
self.rateSeed = seed
|
|
769
|
+
else:
|
|
770
|
+
# For non-reproducible rates, clear the seed
|
|
771
|
+
# setRates() will generate a new seed each time it's called
|
|
772
|
+
self.rateSeed = None
|
|
773
|
+
|
|
774
|
+
def setRates(self, method, frm=None, to=None, values=None, stdev=None, corr=None,
|
|
775
|
+
override_reproducible=False, reverse=False, roll=0):
|
|
686
776
|
"""
|
|
687
777
|
Generate rates for return and inflation based on the method and
|
|
688
778
|
years selected. Note that last bound is included.
|
|
@@ -697,28 +787,85 @@ class Plan:
|
|
|
697
787
|
must be provided, and optionally an ending year.
|
|
698
788
|
|
|
699
789
|
Valid year range is from 1928 to last year.
|
|
790
|
+
|
|
791
|
+
Note: For stochastic methods, setReproducible() should be called before
|
|
792
|
+
setRates() to set the reproducibility flag and seed. If not called,
|
|
793
|
+
defaults to non-reproducible behavior.
|
|
794
|
+
|
|
795
|
+
Args:
|
|
796
|
+
override_reproducible: If True, override reproducibility setting and always generate new rates.
|
|
797
|
+
Used by Monte-Carlo runs to ensure different rates each time.
|
|
798
|
+
reverse: If True, reverse the rate sequence along the time axis (default False).
|
|
799
|
+
roll: Number of years to roll the sequence; positive rolls toward the end (default 0).
|
|
700
800
|
"""
|
|
701
801
|
if frm is not None and to is None:
|
|
702
802
|
to = frm + self.N_n - 1 # 'to' is inclusive.
|
|
703
803
|
|
|
704
|
-
|
|
804
|
+
# Handle seed for stochastic methods
|
|
805
|
+
if method in ["stochastic", "histochastic"]:
|
|
806
|
+
if self.reproducibleRates and not override_reproducible:
|
|
807
|
+
if self.rateSeed is None:
|
|
808
|
+
raise RuntimeError("Config error: reproducibleRates is True but rateSeed is None.")
|
|
809
|
+
|
|
810
|
+
seed = self.rateSeed
|
|
811
|
+
else:
|
|
812
|
+
# For non-reproducible rates or when overriding reproducibility, generate a new seed from time.
|
|
813
|
+
# This ensures we always have a seed stored in config, but it won't be reused.
|
|
814
|
+
seed = int(time.time() * 1000000)
|
|
815
|
+
if not override_reproducible:
|
|
816
|
+
self.rateSeed = seed
|
|
817
|
+
else:
|
|
818
|
+
# For non-stochastic methods, seed is not used but we preserve it
|
|
819
|
+
# so that if user switches back to stochastic, their reproducibility settings are maintained.
|
|
820
|
+
seed = None
|
|
821
|
+
|
|
822
|
+
dr = rates.Rates(self.mylog, seed=seed)
|
|
705
823
|
self.rateValues, self.rateStdev, self.rateCorr = dr.setMethod(method, frm, to, values, stdev, corr)
|
|
706
824
|
self.rateMethod = method
|
|
707
825
|
self.rateFrm = frm
|
|
708
826
|
self.rateTo = to
|
|
827
|
+
self.rateReverse = bool(reverse)
|
|
828
|
+
self.rateRoll = int(roll)
|
|
709
829
|
self.tau_kn = dr.genSeries(self.N_n).transpose()
|
|
710
|
-
|
|
830
|
+
# Reverse and roll are no-ops for constant (fixed) rate methods; ignore with a warning.
|
|
831
|
+
if method in rates.CONSTANT_RATE_METHODS and (reverse or roll != 0):
|
|
832
|
+
self.mylog.print("Warning: reverse and roll are ignored for constant (fixed) rate methods.")
|
|
833
|
+
else:
|
|
834
|
+
self.tau_kn = _apply_rate_sequence_transform(
|
|
835
|
+
self.tau_kn, self.rateReverse, self.rateRoll)
|
|
836
|
+
self.mylog.vprint(f"Generating rate series of {len(self.tau_kn[0])} years using '{method}' method.")
|
|
837
|
+
if method in ["stochastic", "histochastic"]:
|
|
838
|
+
repro_status = "reproducible" if self.reproducibleRates else "non-reproducible"
|
|
839
|
+
self.mylog.print(f"Using seed {seed} for {repro_status} rates.")
|
|
711
840
|
|
|
712
841
|
# Once rates are selected, (re)build cumulative inflation multipliers.
|
|
713
842
|
self.gamma_n = _genGamma_n(self.tau_kn)
|
|
714
843
|
self._adjustedParameters = False
|
|
715
844
|
self.caseStatus = "modified"
|
|
716
845
|
|
|
717
|
-
def regenRates(self):
|
|
846
|
+
def regenRates(self, override_reproducible=False):
|
|
718
847
|
"""
|
|
719
848
|
Regenerate the rates using the arguments specified during last setRates() call.
|
|
720
849
|
This method is used to regenerate stochastic time series.
|
|
850
|
+
Only stochastic and histochastic methods need regeneration.
|
|
851
|
+
All fixed rate methods (default, optimistic, conservative, user, historical average,
|
|
852
|
+
historical) don't need regeneration as they produce the same values.
|
|
853
|
+
|
|
854
|
+
Args:
|
|
855
|
+
override_reproducible: If True, override reproducibility setting and always generate new rates.
|
|
856
|
+
Used by Monte-Carlo runs to ensure each run gets different rates.
|
|
721
857
|
"""
|
|
858
|
+
# Fixed rate methods don't need regeneration - they produce the same values
|
|
859
|
+
if self.rateMethod in rates.RATE_METHODS_NO_REGEN:
|
|
860
|
+
return
|
|
861
|
+
|
|
862
|
+
# Only stochastic methods reach here
|
|
863
|
+
# If reproducibility is enabled and we're not overriding it, don't regenerate
|
|
864
|
+
# (rates should stay the same for reproducibility)
|
|
865
|
+
if self.reproducibleRates and not override_reproducible:
|
|
866
|
+
return
|
|
867
|
+
|
|
868
|
+
# Regenerate with new random values
|
|
722
869
|
self.setRates(
|
|
723
870
|
self.rateMethod,
|
|
724
871
|
frm=self.rateFrm,
|
|
@@ -726,6 +873,9 @@ class Plan:
|
|
|
726
873
|
values=100 * self.rateValues,
|
|
727
874
|
stdev=100 * self.rateStdev,
|
|
728
875
|
corr=self.rateCorr,
|
|
876
|
+
override_reproducible=override_reproducible,
|
|
877
|
+
reverse=self.rateReverse,
|
|
878
|
+
roll=self.rateRoll,
|
|
729
879
|
)
|
|
730
880
|
|
|
731
881
|
def setAccountBalances(self, *, taxable, taxDeferred, taxFree, startDate=None, units="k"):
|
|
@@ -786,12 +936,12 @@ class Plan:
|
|
|
786
936
|
self.interpCenter = center
|
|
787
937
|
self.interpWidth = width
|
|
788
938
|
else:
|
|
789
|
-
raise ValueError(f"Method {method} not supported.")
|
|
939
|
+
raise ValueError(f"Method '{method}' not supported.")
|
|
790
940
|
|
|
791
941
|
self.interpMethod = method
|
|
792
942
|
self.caseStatus = "modified"
|
|
793
943
|
|
|
794
|
-
self.mylog.vprint(f"Asset allocation interpolation method set to {method}.")
|
|
944
|
+
self.mylog.vprint(f"Asset allocation interpolation method set to '{method}'.")
|
|
795
945
|
|
|
796
946
|
def setAllocationRatios(self, allocType, taxable=None, taxDeferred=None, taxFree=None, generic=None):
|
|
797
947
|
"""
|
|
@@ -815,6 +965,11 @@ class Plan:
|
|
|
815
965
|
generic = [[ko00, ko01, ko02, ko03], [kf00, kf01, kf02, kf02]]
|
|
816
966
|
as assets are coordinated between accounts and spouses.
|
|
817
967
|
"""
|
|
968
|
+
# Validate allocType parameter
|
|
969
|
+
validTypes = ["account", "individual", "spouses"]
|
|
970
|
+
if allocType not in validTypes:
|
|
971
|
+
raise ValueError(f"allocType must be one of {validTypes}, got '{allocType}'.")
|
|
972
|
+
|
|
818
973
|
self.boundsAR = {}
|
|
819
974
|
self.alpha_ijkn = np.zeros((self.N_i, self.N_j, self.N_k, self.N_n + 1))
|
|
820
975
|
if allocType == "account":
|
|
@@ -833,7 +988,7 @@ class Plan:
|
|
|
833
988
|
raise ValueError("Sum of percentages must add to 100.")
|
|
834
989
|
|
|
835
990
|
for i in range(self.N_i):
|
|
836
|
-
self.mylog.vprint(f"{self.inames[i]}: Setting gliding allocation ratios (%) to {allocType}.")
|
|
991
|
+
self.mylog.vprint(f"{self.inames[i]}: Setting gliding allocation ratios (%) to '{allocType}'.")
|
|
837
992
|
self.mylog.vprint(f" taxable: {taxable[i][0]} -> {taxable[i][1]}")
|
|
838
993
|
self.mylog.vprint(f" taxDeferred: {taxDeferred[i][0]} -> {taxDeferred[i][1]}")
|
|
839
994
|
self.mylog.vprint(f" taxFree: {taxFree[i][0]} -> {taxFree[i][1]}")
|
|
@@ -870,7 +1025,7 @@ class Plan:
|
|
|
870
1025
|
raise ValueError("Sum of percentages must add to 100.")
|
|
871
1026
|
|
|
872
1027
|
for i in range(self.N_i):
|
|
873
|
-
self.mylog.vprint(f"{self.inames[i]}: Setting gliding allocation ratios (%) to {allocType}.")
|
|
1028
|
+
self.mylog.vprint(f"{self.inames[i]}: Setting gliding allocation ratios (%) to '{allocType}'.")
|
|
874
1029
|
self.mylog.vprint(f"\t{generic[i][0]} -> {generic[i][1]}")
|
|
875
1030
|
|
|
876
1031
|
for i in range(self.N_i):
|
|
@@ -905,9 +1060,9 @@ class Plan:
|
|
|
905
1060
|
self.ARCoord = allocType
|
|
906
1061
|
self.caseStatus = "modified"
|
|
907
1062
|
|
|
908
|
-
self.mylog.vprint(f"Interpolating assets allocation ratios using {self.interpMethod} method.")
|
|
1063
|
+
self.mylog.vprint(f"Interpolating assets allocation ratios using '{self.interpMethod}' method.")
|
|
909
1064
|
|
|
910
|
-
def readContributions(self, filename):
|
|
1065
|
+
def readContributions(self, filename, filename_for_logging=None):
|
|
911
1066
|
"""
|
|
912
1067
|
Provide the name of the file containing the financial events
|
|
913
1068
|
over the anticipated life span determined by the
|
|
@@ -927,13 +1082,24 @@ class Plan:
|
|
|
927
1082
|
|
|
928
1083
|
in any order. A template is provided as an example.
|
|
929
1084
|
Missing rows (years) are populated with zero values.
|
|
1085
|
+
|
|
1086
|
+
Parameters
|
|
1087
|
+
----------
|
|
1088
|
+
filename : file-like object, str, or dict
|
|
1089
|
+
Input file or dictionary of DataFrames
|
|
1090
|
+
filename_for_logging : str, optional
|
|
1091
|
+
Explicit filename for logging purposes. If provided, this will be used
|
|
1092
|
+
in log messages instead of trying to extract it from filename.
|
|
930
1093
|
"""
|
|
931
1094
|
try:
|
|
932
|
-
|
|
1095
|
+
returned_filename, self.timeLists, self.houseLists = timelists.read(
|
|
1096
|
+
filename, self.inames, self.horizons, self.mylog, filename=filename_for_logging
|
|
1097
|
+
)
|
|
933
1098
|
except Exception as e:
|
|
934
1099
|
raise Exception(f"Unsuccessful read of Household Financial Profile: {e}") from e
|
|
935
1100
|
|
|
936
|
-
|
|
1101
|
+
# Use filename_for_logging if provided, otherwise use returned filename
|
|
1102
|
+
self.timeListsFileName = filename_for_logging if filename_for_logging is not None else returned_filename
|
|
937
1103
|
self.setContributions()
|
|
938
1104
|
|
|
939
1105
|
return True
|
|
@@ -954,12 +1120,20 @@ class Plan:
|
|
|
954
1120
|
|
|
955
1121
|
# Values for last 5 years of Roth conversion and contributions stored at the end
|
|
956
1122
|
# 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
|
|
1123
|
+
self.kappa_ijn[i, 0, :h] = self.timeLists[iname]["taxable ctrb"][5:h+5]
|
|
1124
|
+
self.kappa_ijn[i, 1, :h] = self.timeLists[iname]["401k ctrb"][5:h+5]
|
|
1125
|
+
self.kappa_ijn[i, 1, :h] += self.timeLists[iname]["IRA ctrb"][5:h+5]
|
|
1126
|
+
self.kappa_ijn[i, 2, :h] = self.timeLists[iname]["Roth 401k ctrb"][5:h+5]
|
|
1127
|
+
self.kappa_ijn[i, 2, :h] += self.timeLists[iname]["Roth IRA ctrb"][5:h+5]
|
|
1128
|
+
self.myRothX_in[i, :h] = self.timeLists[iname]["Roth conv"][5:h+5]
|
|
1129
|
+
|
|
1130
|
+
# Last 5 years are at the end of the N_n array.
|
|
1131
|
+
self.kappa_ijn[i, 0, -5:] = self.timeLists[iname]["taxable ctrb"][:5]
|
|
1132
|
+
self.kappa_ijn[i, 1, -5:] = self.timeLists[iname]["401k ctrb"][:5]
|
|
1133
|
+
self.kappa_ijn[i, 1, -5:] += self.timeLists[iname]["IRA ctrb"][:5]
|
|
1134
|
+
self.kappa_ijn[i, 2, -5:] = self.timeLists[iname]["Roth 401k ctrb"][:5]
|
|
1135
|
+
self.kappa_ijn[i, 2, -5:] += self.timeLists[iname]["Roth IRA ctrb"][:5]
|
|
1136
|
+
self.myRothX_in[i, -5:] = self.timeLists[iname]["Roth conv"][:5]
|
|
963
1137
|
|
|
964
1138
|
self.caseStatus = "modified"
|
|
965
1139
|
|
|
@@ -1062,7 +1236,7 @@ class Plan:
|
|
|
1062
1236
|
else:
|
|
1063
1237
|
# Create empty Debts sheet with proper columns
|
|
1064
1238
|
ws = wb.create_sheet("Debts")
|
|
1065
|
-
df = pd.DataFrame(columns=
|
|
1239
|
+
df = pd.DataFrame(columns=timelists._debtItems)
|
|
1066
1240
|
for row in dataframe_to_rows(df, index=False, header=True):
|
|
1067
1241
|
ws.append(row)
|
|
1068
1242
|
_formatDebtsSheet(ws)
|
|
@@ -1077,7 +1251,7 @@ class Plan:
|
|
|
1077
1251
|
else:
|
|
1078
1252
|
# Create empty Fixed Assets sheet with proper columns
|
|
1079
1253
|
ws = wb.create_sheet("Fixed Assets")
|
|
1080
|
-
df = pd.DataFrame(columns=
|
|
1254
|
+
df = pd.DataFrame(columns=timelists._fixedAssetItems)
|
|
1081
1255
|
for row in dataframe_to_rows(df, index=False, header=True):
|
|
1082
1256
|
ws.append(row)
|
|
1083
1257
|
_formatFixedAssetsSheet(ws)
|
|
@@ -1169,7 +1343,7 @@ class Plan:
|
|
|
1169
1343
|
if self.pensionIsIndexed[i]:
|
|
1170
1344
|
self.piBar_in[i] *= gamma_n[:-1]
|
|
1171
1345
|
|
|
1172
|
-
self.nm, self.
|
|
1346
|
+
self.nm, self.Lbar_nq, self.Cbar_nq = tx.mediVals(self.yobs, self.horizons, gamma_n, self.N_n, self.N_q)
|
|
1173
1347
|
|
|
1174
1348
|
self._adjustedParameters = True
|
|
1175
1349
|
|
|
@@ -1182,6 +1356,7 @@ class Plan:
|
|
|
1182
1356
|
All binary variables must be lumped at the end of the vector.
|
|
1183
1357
|
"""
|
|
1184
1358
|
medi = options.get("withMedicare", "loop") == "optimize"
|
|
1359
|
+
Nmed = self.N_n - self.nm
|
|
1185
1360
|
|
|
1186
1361
|
# Stack all variables in a single block vector with all binary variables at the end.
|
|
1187
1362
|
C = {}
|
|
@@ -1190,14 +1365,20 @@ class Plan:
|
|
|
1190
1365
|
C["e"] = _qC(C["d"], self.N_i, self.N_n)
|
|
1191
1366
|
C["f"] = _qC(C["e"], self.N_n)
|
|
1192
1367
|
C["g"] = _qC(C["f"], self.N_t, self.N_n)
|
|
1193
|
-
|
|
1368
|
+
if medi:
|
|
1369
|
+
C["h"] = _qC(C["g"], self.N_n)
|
|
1370
|
+
C["m"] = _qC(C["h"], Nmed, self.N_q)
|
|
1371
|
+
else:
|
|
1372
|
+
C["m"] = _qC(C["g"], self.N_n)
|
|
1194
1373
|
C["s"] = _qC(C["m"], self.N_n)
|
|
1195
1374
|
C["w"] = _qC(C["s"], self.N_n)
|
|
1196
1375
|
C["x"] = _qC(C["w"], self.N_i, self.N_j, self.N_n)
|
|
1197
1376
|
C["zx"] = _qC(C["x"], self.N_i, self.N_n)
|
|
1198
|
-
C["zm"] = _qC(C["zx"], self.
|
|
1199
|
-
self.nvars = _qC(C["zm"],
|
|
1377
|
+
C["zm"] = _qC(C["zx"], self.N_n, self.N_zx)
|
|
1378
|
+
self.nvars = _qC(C["zm"], Nmed, self.N_q) if medi else C["zm"]
|
|
1200
1379
|
self.nbins = self.nvars - C["zx"]
|
|
1380
|
+
self.nconts = C["zx"]
|
|
1381
|
+
self.nbals = C["d"]
|
|
1201
1382
|
|
|
1202
1383
|
self.C = C
|
|
1203
1384
|
self.mylog.vprint(
|
|
@@ -1224,7 +1405,7 @@ class Plan:
|
|
|
1224
1405
|
self._add_conversion_limits()
|
|
1225
1406
|
self._add_objective_constraints(objective, options)
|
|
1226
1407
|
self._add_initial_balances()
|
|
1227
|
-
self._add_surplus_deposit_linking()
|
|
1408
|
+
self._add_surplus_deposit_linking(options)
|
|
1228
1409
|
self._add_account_balance_carryover()
|
|
1229
1410
|
self._add_net_cash_flow()
|
|
1230
1411
|
self._add_income_profile()
|
|
@@ -1232,7 +1413,7 @@ class Plan:
|
|
|
1232
1413
|
self._configure_Medicare_binary_variables(options)
|
|
1233
1414
|
self._add_Medicare_costs(options)
|
|
1234
1415
|
self._configure_exclusion_binary_variables(options)
|
|
1235
|
-
self._build_objective_vector(objective)
|
|
1416
|
+
self._build_objective_vector(objective, options)
|
|
1236
1417
|
|
|
1237
1418
|
def _add_rmd_inequalities(self):
|
|
1238
1419
|
for i in range(self.N_i):
|
|
@@ -1300,42 +1481,70 @@ class Plan:
|
|
|
1300
1481
|
self.A.addRow(row, rhs, np.inf)
|
|
1301
1482
|
|
|
1302
1483
|
def _add_roth_conversion_constraints(self, options):
|
|
1484
|
+
# Values in file supercedes everything.
|
|
1303
1485
|
if "maxRothConversion" in options and options["maxRothConversion"] == "file":
|
|
1304
1486
|
for i in range(self.N_i):
|
|
1305
1487
|
for n in range(self.horizons[i]):
|
|
1306
1488
|
rhs = self.myRothX_in[i][n]
|
|
1307
1489
|
self.B.setRange(_q2(self.C["x"], i, n, self.N_i, self.N_n), rhs, rhs)
|
|
1308
1490
|
else:
|
|
1491
|
+
# Don't exclude anyone by default.
|
|
1492
|
+
i_xcluded = -1
|
|
1493
|
+
if "noRothConversions" in options and options["noRothConversions"] != "None":
|
|
1494
|
+
rhsopt = options["noRothConversions"]
|
|
1495
|
+
try:
|
|
1496
|
+
i_xcluded = self.inames.index(rhsopt)
|
|
1497
|
+
except ValueError as e:
|
|
1498
|
+
raise ValueError(f"Unknown individual '{rhsopt}' for noRothConversions:") from e
|
|
1499
|
+
for n in range(self.horizons[i_xcluded]):
|
|
1500
|
+
self.B.setRange(_q2(self.C["x"], i_xcluded, n, self.N_i, self.N_n), 0, 0)
|
|
1501
|
+
|
|
1309
1502
|
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.")
|
|
1503
|
+
rhsopt = u.get_numeric_option(options, "maxRothConversion", 0)
|
|
1313
1504
|
|
|
1314
1505
|
if rhsopt >= 0:
|
|
1315
1506
|
rhsopt *= self.optionsUnits
|
|
1316
1507
|
for i in range(self.N_i):
|
|
1508
|
+
if i == i_xcluded:
|
|
1509
|
+
continue
|
|
1317
1510
|
for n in range(self.horizons[i]):
|
|
1318
|
-
|
|
1511
|
+
# Apply the cap per individual (legacy behavior).
|
|
1512
|
+
self.B.setRange(_q2(self.C["x"], i, n, self.N_i, self.N_n), 0, rhsopt)
|
|
1319
1513
|
|
|
1320
1514
|
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.")
|
|
1515
|
+
rhsopt = int(u.get_numeric_option(options, "startRothConversions", 0))
|
|
1324
1516
|
thisyear = date.today().year
|
|
1325
1517
|
yearn = max(rhsopt - thisyear, 0)
|
|
1326
1518
|
for i in range(self.N_i):
|
|
1519
|
+
if i == i_xcluded:
|
|
1520
|
+
continue
|
|
1327
1521
|
nstart = min(yearn, self.horizons[i])
|
|
1328
1522
|
for n in range(0, nstart):
|
|
1329
1523
|
self.B.setRange(_q2(self.C["x"], i, n, self.N_i, self.N_n), 0, 0)
|
|
1330
1524
|
|
|
1331
|
-
if "
|
|
1332
|
-
rhsopt = options
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1525
|
+
if "swapRothConverters" in options and i_xcluded == -1:
|
|
1526
|
+
rhsopt = int(u.get_numeric_option(options, "swapRothConverters", 0))
|
|
1527
|
+
if self.N_i == 2 and rhsopt != 0:
|
|
1528
|
+
thisyear = date.today().year
|
|
1529
|
+
absrhsopt = abs(rhsopt)
|
|
1530
|
+
yearn = max(absrhsopt - thisyear, 0)
|
|
1531
|
+
i_x = 0 if rhsopt > 0 else 1
|
|
1532
|
+
i_y = (i_x + 1) % 2
|
|
1533
|
+
|
|
1534
|
+
transy = min(yearn, self.horizons[i_y])
|
|
1535
|
+
for n in range(0, transy):
|
|
1536
|
+
self.B.setRange(_q2(self.C["x"], i_y, n, self.N_i, self.N_n), 0, 0)
|
|
1537
|
+
|
|
1538
|
+
transx = min(yearn, self.horizons[i_x])
|
|
1539
|
+
for n in range(transx, self.horizons[i_x]):
|
|
1540
|
+
self.B.setRange(_q2(self.C["x"], i_x, n, self.N_i, self.N_n), 0, 0)
|
|
1541
|
+
|
|
1542
|
+
# Disallow Roth conversions in last two years alive. Plan has at least 2 years.
|
|
1543
|
+
for i in range(self.N_i):
|
|
1544
|
+
if i == i_xcluded:
|
|
1545
|
+
continue
|
|
1546
|
+
self.B.setRange(_q2(self.C["x"], i, self.horizons[i] - 2, self.N_i, self.N_n), 0, 0)
|
|
1547
|
+
self.B.setRange(_q2(self.C["x"], i, self.horizons[i] - 1, self.N_i, self.N_n), 0, 0)
|
|
1339
1548
|
|
|
1340
1549
|
def _add_withdrawal_limits(self):
|
|
1341
1550
|
for i in range(self.N_i):
|
|
@@ -1358,9 +1567,7 @@ class Plan:
|
|
|
1358
1567
|
def _add_objective_constraints(self, objective, options):
|
|
1359
1568
|
if objective == "maxSpending":
|
|
1360
1569
|
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.")
|
|
1570
|
+
bequest = u.get_numeric_option(options, "bequest", 1)
|
|
1364
1571
|
bequest *= self.optionsUnits * self.gamma_n[-1]
|
|
1365
1572
|
else:
|
|
1366
1573
|
bequest = 1
|
|
@@ -1379,9 +1586,7 @@ class Plan:
|
|
|
1379
1586
|
row.addElem(_q3(self.C["b"], i, 2, self.N_n, self.N_i, self.N_j, self.N_n + 1), 1)
|
|
1380
1587
|
self.A.addRow(row, total_bequest_value, total_bequest_value)
|
|
1381
1588
|
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.")
|
|
1589
|
+
spending = u.get_numeric_option(options, "netSpending", 1)
|
|
1385
1590
|
spending *= self.optionsUnits
|
|
1386
1591
|
self.B.setRange(_q1(self.C["g"], 0, self.N_n), spending, spending)
|
|
1387
1592
|
|
|
@@ -1395,7 +1600,7 @@ class Plan:
|
|
|
1395
1600
|
rhs = self.beta_ij[i, j] / backTau
|
|
1396
1601
|
self.B.setRange(_q3(self.C["b"], i, j, 0, self.N_i, self.N_j, self.N_n + 1), rhs, rhs)
|
|
1397
1602
|
|
|
1398
|
-
def _add_surplus_deposit_linking(self):
|
|
1603
|
+
def _add_surplus_deposit_linking(self, options):
|
|
1399
1604
|
for i in range(self.N_i):
|
|
1400
1605
|
fac1 = u.krond(i, 0) * (1 - self.eta) + u.krond(i, 1) * self.eta
|
|
1401
1606
|
for n in range(self.n_d):
|
|
@@ -1405,8 +1610,12 @@ class Plan:
|
|
|
1405
1610
|
for n in range(self.n_d, self.N_n):
|
|
1406
1611
|
rowDic = {_q2(self.C["d"], i, n, self.N_i, self.N_n): 1, _q1(self.C["s"], n, self.N_n): -fac2}
|
|
1407
1612
|
self.A.addNewRow(rowDic, 0, 0)
|
|
1408
|
-
|
|
1409
|
-
|
|
1613
|
+
|
|
1614
|
+
# Prevent surplus on two last year as they have little tax and/or growth consequence.
|
|
1615
|
+
disallow = options.get("noLateSurplus", False)
|
|
1616
|
+
if disallow:
|
|
1617
|
+
self.B.setRange(_q1(self.C["s"], self.N_n - 2, self.N_n), 0, 0)
|
|
1618
|
+
self.B.setRange(_q1(self.C["s"], self.N_n - 1, self.N_n), 0, 0)
|
|
1410
1619
|
|
|
1411
1620
|
def _add_account_balance_carryover(self):
|
|
1412
1621
|
tau_ijn = np.zeros((self.N_i, self.N_j, self.N_n))
|
|
@@ -1459,8 +1668,10 @@ class Plan:
|
|
|
1459
1668
|
tau_0prev[tau_0prev < 0] = 0
|
|
1460
1669
|
for n in range(self.N_n):
|
|
1461
1670
|
rhs = -self.M_n[n] - self.J_n[n]
|
|
1462
|
-
# Add fixed assets
|
|
1463
|
-
rhs += self.fixed_assets_tax_free_n[n]
|
|
1671
|
+
# Add fixed assets proceeds (positive cash flow)
|
|
1672
|
+
rhs += (self.fixed_assets_tax_free_n[n]
|
|
1673
|
+
+ self.fixed_assets_ordinary_income_n[n]
|
|
1674
|
+
+ self.fixed_assets_capital_gains_n[n])
|
|
1464
1675
|
# Subtract debt payments (negative cash flow)
|
|
1465
1676
|
rhs -= self.debt_payments_n[n]
|
|
1466
1677
|
row = self.A.newRow({_q1(self.C["g"], n, self.N_n): 1})
|
|
@@ -1505,10 +1716,11 @@ class Plan:
|
|
|
1505
1716
|
row = self.A.newRow()
|
|
1506
1717
|
row.addElem(_q1(self.C["e"], n, self.N_n), 1)
|
|
1507
1718
|
for i in range(self.N_i):
|
|
1508
|
-
rhs += self.omega_in[i, n] +
|
|
1719
|
+
rhs += self.omega_in[i, n] + self.Psi_n[n] * self.zetaBar_in[i, n] + self.piBar_in[i, n]
|
|
1509
1720
|
row.addElem(_q3(self.C["w"], i, 1, n, self.N_i, self.N_j, self.N_n), -1)
|
|
1510
1721
|
row.addElem(_q2(self.C["x"], i, n, self.N_i, self.N_n), -1)
|
|
1511
|
-
|
|
1722
|
+
# Only positive returns are taxable (interest/dividends); losses don't reduce income.
|
|
1723
|
+
fak = np.sum(np.maximum(0, self.tau_kn[1:self.N_k, n]) * self.alpha_ijkn[i, 0, 1:self.N_k, n], axis=0)
|
|
1512
1724
|
rhs += 0.5 * fak * self.kappa_ijn[i, 0, n]
|
|
1513
1725
|
row.addElem(_q3(self.C["b"], i, 0, n, self.N_i, self.N_j, self.N_n + 1), -fak)
|
|
1514
1726
|
row.addElem(_q3(self.C["w"], i, 0, n, self.N_i, self.N_j, self.N_n), fak)
|
|
@@ -1518,109 +1730,159 @@ class Plan:
|
|
|
1518
1730
|
self.A.addRow(row, rhs, rhs)
|
|
1519
1731
|
|
|
1520
1732
|
def _configure_exclusion_binary_variables(self, options):
|
|
1521
|
-
if not options.get("
|
|
1733
|
+
if not options.get("amoConstraints", True):
|
|
1522
1734
|
return
|
|
1523
1735
|
|
|
1524
|
-
bigM =
|
|
1525
|
-
|
|
1526
|
-
|
|
1736
|
+
bigM = u.get_numeric_option(options, "bigMamo", BIGM_AMO, min_value=0)
|
|
1737
|
+
|
|
1738
|
+
if options.get("amoSurplus", True):
|
|
1739
|
+
for n in range(self.N_n):
|
|
1740
|
+
# Make z_0 and z_1 exclusive binary variables.
|
|
1741
|
+
dic0 = {_q2(self.C["zx"], n, 0, self.N_n, self.N_zx): bigM*self.gamma_n[n],
|
|
1742
|
+
_q3(self.C["w"], 0, 0, n, self.N_i, self.N_j, self.N_n): -1,
|
|
1743
|
+
_q3(self.C["w"], 0, 2, n, self.N_i, self.N_j, self.N_n): -1}
|
|
1744
|
+
if self.N_i == 2:
|
|
1745
|
+
dic1 = {_q3(self.C["w"], 1, 0, n, self.N_i, self.N_j, self.N_n): -1,
|
|
1746
|
+
_q3(self.C["w"], 1, 2, n, self.N_i, self.N_j, self.N_n): -1}
|
|
1747
|
+
dic0.update(dic1)
|
|
1748
|
+
|
|
1749
|
+
self.A.addNewRow(dic0, 0, np.inf)
|
|
1527
1750
|
|
|
1528
|
-
for i in range(self.N_i):
|
|
1529
|
-
for n in range(self.horizons[i]):
|
|
1530
1751
|
self.A.addNewRow(
|
|
1531
|
-
{
|
|
1752
|
+
{_q2(self.C["zx"], n, 1, self.N_n, self.N_zx): bigM*self.gamma_n[n],
|
|
1532
1753
|
_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
|
-
)
|
|
1545
|
-
self.A.addNewRow(
|
|
1546
|
-
{_q3(self.C["zx"], i, n, 1, self.N_i, self.N_n, self.N_zx): bigM,
|
|
1547
|
-
_q2(self.C["x"], i, n, self.N_i, self.N_n): -1},
|
|
1548
|
-
0,
|
|
1549
|
-
bigM,
|
|
1550
|
-
)
|
|
1754
|
+
0, np.inf)
|
|
1755
|
+
|
|
1756
|
+
# As both can be zero, bound as z_0 + z_1 <= 1
|
|
1551
1757
|
self.A.addNewRow(
|
|
1552
|
-
{
|
|
1553
|
-
|
|
1554
|
-
0,
|
|
1555
|
-
bigM,
|
|
1758
|
+
{_q2(self.C["zx"], n, 0, self.N_n, self.N_zx): +1,
|
|
1759
|
+
_q2(self.C["zx"], n, 1, 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
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1763
|
+
if "maxRothConversion" in options:
|
|
1764
|
+
rhsopt = options.get("maxRothConversion")
|
|
1765
|
+
if rhsopt != "file":
|
|
1766
|
+
rhsopt = u.get_numeric_option(options, "maxRothConversion", 0)
|
|
1767
|
+
if rhsopt < -1:
|
|
1768
|
+
return
|
|
1564
1769
|
|
|
1565
|
-
|
|
1566
|
-
if
|
|
1567
|
-
|
|
1770
|
+
# Turning off this constraint for maxRothConversions = 0 makes solution infeasible.
|
|
1771
|
+
if options.get("amoRoth", True):
|
|
1772
|
+
for n in range(self.N_n):
|
|
1773
|
+
# Make z_2 and z_3 exclusive binary variables.
|
|
1774
|
+
dic0 = {_q2(self.C["zx"], n, 2, self.N_n, self.N_zx): bigM*self.gamma_n[n],
|
|
1775
|
+
_q2(self.C["x"], 0, n, self.N_i, self.N_n): -1}
|
|
1776
|
+
if self.N_i == 2:
|
|
1777
|
+
dic1 = {_q2(self.C["x"], 1, n, self.N_i, self.N_n): -1}
|
|
1778
|
+
dic0.update(dic1)
|
|
1568
1779
|
|
|
1569
|
-
|
|
1570
|
-
offset = 0
|
|
1571
|
-
if self.nm < 2:
|
|
1572
|
-
offset = 2 - self.nm
|
|
1573
|
-
for nn in range(offset):
|
|
1574
|
-
n = self.nm + nn
|
|
1575
|
-
for q in range(self.N_q - 1):
|
|
1576
|
-
self.A.addNewRow({_q2(self.C["zm"], nn, q, Nmed, self.N_q - 1): bigM},
|
|
1577
|
-
-np.inf, bigM - self.L_nq[nn, q] + self.prevMAGI[n])
|
|
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])
|
|
1580
|
-
|
|
1581
|
-
for nn in range(offset, Nmed):
|
|
1582
|
-
n2 = self.nm + nn - 2 # n - 2
|
|
1583
|
-
for q in range(self.N_q - 1):
|
|
1584
|
-
rhs1 = bigM - self.L_nq[nn, q]
|
|
1585
|
-
rhs2 = self.L_nq[nn, q]
|
|
1586
|
-
row1 = self.A.newRow()
|
|
1587
|
-
row2 = self.A.newRow()
|
|
1588
|
-
|
|
1589
|
-
row1.addElem(_q2(self.C["zm"], nn, q, Nmed, self.N_q - 1), +bigM)
|
|
1590
|
-
row2.addElem(_q2(self.C["zm"], nn, q, Nmed, self.N_q - 1), -bigM)
|
|
1591
|
-
for i in range(self.N_i):
|
|
1592
|
-
row1.addElem(_q3(self.C["w"], i, 1, n2, self.N_i, self.N_j, self.N_n), -1)
|
|
1593
|
-
row2.addElem(_q3(self.C["w"], i, 1, n2, self.N_i, self.N_j, self.N_n), +1)
|
|
1780
|
+
self.A.addNewRow(dic0, 0, np.inf)
|
|
1594
1781
|
|
|
1595
|
-
|
|
1596
|
-
|
|
1782
|
+
dic0 = {_q2(self.C["zx"], n, 3, self.N_n, self.N_zx): bigM*self.gamma_n[n],
|
|
1783
|
+
_q3(self.C["w"], 0, 2, n, self.N_i, self.N_j, self.N_n): -1}
|
|
1784
|
+
if self.N_i == 2:
|
|
1785
|
+
dic1 = {_q3(self.C["w"], 1, 2, n, self.N_i, self.N_j, self.N_n): -1}
|
|
1786
|
+
dic0.update(dic1)
|
|
1597
1787
|
|
|
1598
|
-
|
|
1599
|
-
afac = (self.mu*self.alpha_ijkn[i, 0, 0, n2]
|
|
1600
|
-
+ np.sum(self.alpha_ijkn[i, 0, 1:, n2]*self.tau_kn[1:, n2]))
|
|
1788
|
+
self.A.addNewRow(dic0, 0, np.inf)
|
|
1601
1789
|
|
|
1602
|
-
|
|
1603
|
-
|
|
1790
|
+
self.A.addNewRow(
|
|
1791
|
+
{_q2(self.C["zx"], n, 2, self.N_n, self.N_zx): +1,
|
|
1792
|
+
_q2(self.C["zx"], n, 3, self.N_n, self.N_zx): +1},
|
|
1793
|
+
0, 1
|
|
1794
|
+
)
|
|
1604
1795
|
|
|
1605
|
-
|
|
1606
|
-
|
|
1796
|
+
def _configure_Medicare_binary_variables(self, options):
|
|
1797
|
+
if options.get("withMedicare", "loop") != "optimize":
|
|
1798
|
+
return
|
|
1607
1799
|
|
|
1608
|
-
|
|
1609
|
-
|
|
1800
|
+
bigM = u.get_numeric_option(options, "bigMamo", BIGM_AMO, min_value=0)
|
|
1801
|
+
Nmed = self.N_n - self.nm
|
|
1802
|
+
# Select exactly one IRMAA bracket per year (SOS1 behavior).
|
|
1803
|
+
for nn in range(Nmed):
|
|
1804
|
+
row = self.A.newRow()
|
|
1805
|
+
for q in range(self.N_q):
|
|
1806
|
+
row.addElem(_q2(self.C["zm"], nn, q, Nmed, self.N_q), 1)
|
|
1807
|
+
self.A.addRow(row, 1, 1)
|
|
1610
1808
|
|
|
1611
|
-
|
|
1612
|
-
|
|
1809
|
+
# MAGI decomposition into bracket portions: sum_q h_{q} = MAGI.
|
|
1810
|
+
for nn in range(Nmed):
|
|
1811
|
+
n = self.nm + nn
|
|
1812
|
+
row = self.A.newRow()
|
|
1813
|
+
for q in range(self.N_q):
|
|
1814
|
+
row.addElem(_q2(self.C["h"], nn, q, Nmed, self.N_q), 1)
|
|
1815
|
+
|
|
1816
|
+
if n < 2:
|
|
1817
|
+
self.A.addRow(row, self.prevMAGI[n], self.prevMAGI[n])
|
|
1818
|
+
# Fix bracket selection for known previous MAGI.
|
|
1819
|
+
magi = self.prevMAGI[n]
|
|
1820
|
+
qsel = 0
|
|
1821
|
+
for q in range(1, self.N_q):
|
|
1822
|
+
if magi > self.Lbar_nq[nn, q - 1]:
|
|
1823
|
+
qsel = q
|
|
1824
|
+
for q in range(self.N_q):
|
|
1825
|
+
idx = _q2(self.C["zm"], nn, q, Nmed, self.N_q)
|
|
1826
|
+
val = 1 if q == qsel else 0
|
|
1827
|
+
self.B.setRange(idx, val, val)
|
|
1828
|
+
continue
|
|
1829
|
+
|
|
1830
|
+
n2 = n - 2
|
|
1831
|
+
rhs = (self.fixed_assets_ordinary_income_n[n2]
|
|
1832
|
+
+ self.fixed_assets_capital_gains_n[n2])
|
|
1833
|
+
|
|
1834
|
+
row.addElem(_q1(self.C["e"], n2, self.N_n), -1)
|
|
1835
|
+
for i in range(self.N_i):
|
|
1836
|
+
row.addElem(_q3(self.C["w"], i, 1, n2, self.N_i, self.N_j, self.N_n), -1)
|
|
1837
|
+
row.addElem(_q2(self.C["x"], i, n2, self.N_i, self.N_n), -1)
|
|
1838
|
+
|
|
1839
|
+
# Dividends and interest gains for year n2. Only positive returns are taxable.
|
|
1840
|
+
afac = (self.mu * self.alpha_ijkn[i, 0, 0, n2]
|
|
1841
|
+
+ np.sum(self.alpha_ijkn[i, 0, 1:, n2] * np.maximum(0, self.tau_kn[1:, n2])))
|
|
1842
|
+
|
|
1843
|
+
row.addElem(_q3(self.C["b"], i, 0, n2, self.N_i, self.N_j, self.N_n + 1), -afac)
|
|
1844
|
+
row.addElem(_q2(self.C["d"], i, n2, self.N_i, self.N_n), -afac)
|
|
1845
|
+
|
|
1846
|
+
# Capital gains on stocks sold from taxable account accrued in year n2 - 1.
|
|
1847
|
+
# Capital gains = price appreciation only (total return - dividend rate)
|
|
1848
|
+
# to avoid double taxation of dividends.
|
|
1849
|
+
tau_prev = self.tau_kn[0, max(0, n2 - 1)]
|
|
1850
|
+
bfac = self.alpha_ijkn[i, 0, 0, n2] * max(0, tau_prev - self.mu)
|
|
1851
|
+
row.addElem(_q3(self.C["w"], i, 0, n2, self.N_i, self.N_j, self.N_n), afac - bfac)
|
|
1852
|
+
|
|
1853
|
+
# MAGI includes total Social Security (taxable + non-taxable) for IRMAA.
|
|
1854
|
+
sumoni = (self.omega_in[i, n2]
|
|
1855
|
+
+ self.zetaBar_in[i, n2]
|
|
1856
|
+
+ self.piBar_in[i, n2]
|
|
1857
|
+
+ 0.5 * self.kappa_ijn[i, 0, n2] * afac)
|
|
1858
|
+
rhs += sumoni
|
|
1613
1859
|
|
|
1614
|
-
|
|
1615
|
-
+ 0.5 * self.kappa_ijn[i, 0, n2] * afac)
|
|
1616
|
-
rhs1 += sumoni
|
|
1617
|
-
rhs2 -= sumoni
|
|
1860
|
+
self.A.addRow(row, rhs, rhs)
|
|
1618
1861
|
|
|
1619
|
-
|
|
1620
|
-
|
|
1862
|
+
# Bracket bounds: L_{q-1} z_q <= mg_q <= L_q z_q.
|
|
1863
|
+
for nn in range(Nmed):
|
|
1864
|
+
for q in range(self.N_q):
|
|
1865
|
+
mg_idx = _q2(self.C["h"], nn, q, Nmed, self.N_q)
|
|
1866
|
+
zm_idx = _q2(self.C["zm"], nn, q, Nmed, self.N_q)
|
|
1867
|
+
|
|
1868
|
+
lower = 0 if q == 0 else self.Lbar_nq[nn, q - 1]
|
|
1869
|
+
if lower > 0:
|
|
1870
|
+
self.A.addNewRow({mg_idx: 1, zm_idx: -lower}, 0, np.inf)
|
|
1871
|
+
|
|
1872
|
+
if q < self.N_q - 1:
|
|
1873
|
+
upper = self.Lbar_nq[nn, q]
|
|
1874
|
+
self.A.addNewRow({mg_idx: 1, zm_idx: -upper}, -np.inf, 0)
|
|
1875
|
+
else:
|
|
1876
|
+
# Upper bound for last bracket so h_qn = 0 when z_q = 0.
|
|
1877
|
+
upper = bigM * self.gamma_n[self.nm + nn]
|
|
1878
|
+
self.A.addNewRow({mg_idx: 1, zm_idx: -upper}, -np.inf, 0)
|
|
1621
1879
|
|
|
1622
1880
|
def _add_Medicare_costs(self, options):
|
|
1623
1881
|
if options.get("withMedicare", "loop") != "optimize":
|
|
1882
|
+
# In loop mode, Medicare costs are computed outside the solver (M_n).
|
|
1883
|
+
# Ensure the in-model Medicare variable (m_n) stays at zero.
|
|
1884
|
+
for n in range(self.N_n):
|
|
1885
|
+
self.B.setRange(_q1(self.C["m"], n, self.N_n), 0, 0)
|
|
1624
1886
|
return
|
|
1625
1887
|
|
|
1626
1888
|
for n in range(self.nm):
|
|
@@ -1631,33 +1893,57 @@ class Plan:
|
|
|
1631
1893
|
n = self.nm + nn
|
|
1632
1894
|
row = self.A.newRow()
|
|
1633
1895
|
row.addElem(_q1(self.C["m"], n, self.N_n), 1)
|
|
1634
|
-
for q in range(self.N_q
|
|
1635
|
-
row.addElem(_q2(self.C["zm"], nn, q, Nmed, self.N_q
|
|
1636
|
-
self.A.addRow(row,
|
|
1896
|
+
for q in range(self.N_q):
|
|
1897
|
+
row.addElem(_q2(self.C["zm"], nn, q, Nmed, self.N_q), -self.Cbar_nq[nn, q])
|
|
1898
|
+
self.A.addRow(row, 0, 0)
|
|
1637
1899
|
|
|
1638
|
-
def _build_objective_vector(self, objective):
|
|
1639
|
-
|
|
1900
|
+
def _build_objective_vector(self, objective, options):
|
|
1901
|
+
c_arr = np.zeros(self.nvars)
|
|
1640
1902
|
if objective == "maxSpending":
|
|
1641
1903
|
for n in range(self.N_n):
|
|
1642
|
-
|
|
1904
|
+
c_arr[_q1(self.C["g"], n, self.N_n)] = -1/self.gamma_n[n]
|
|
1643
1905
|
elif objective == "maxBequest":
|
|
1644
1906
|
for i in range(self.N_i):
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1907
|
+
c_arr[_q3(self.C["b"], i, 0, self.N_n, self.N_i, self.N_j, self.N_n + 1)] = -1
|
|
1908
|
+
c_arr[_q3(self.C["b"], i, 1, self.N_n, self.N_i, self.N_j, self.N_n + 1)] = -(1 - self.nu)
|
|
1909
|
+
c_arr[_q3(self.C["b"], i, 2, self.N_n, self.N_i, self.N_j, self.N_n + 1)] = -1
|
|
1648
1910
|
else:
|
|
1649
1911
|
raise RuntimeError("Internal error in objective function.")
|
|
1912
|
+
|
|
1913
|
+
# Turn on epsilon by default when optimizing Medicare.
|
|
1914
|
+
withMedicare = options.get("withMedicare", "loop")
|
|
1915
|
+
default_epsilon = EPSILON if withMedicare == "optimize" else 0
|
|
1916
|
+
epsilon = u.get_numeric_option(options, "epsilon", default_epsilon, min_value=0)
|
|
1917
|
+
if epsilon > 0:
|
|
1918
|
+
# Penalize Roth conversions to reduce churn.
|
|
1919
|
+
for i in range(self.N_i):
|
|
1920
|
+
for n in range(self.N_n):
|
|
1921
|
+
c_arr[_q2(self.C["x"], i, n, self.N_i, self.N_n)] += epsilon
|
|
1922
|
+
|
|
1923
|
+
if self.N_i == 2:
|
|
1924
|
+
# Favor withdrawals from spouse 0 by penalizing spouse 1 withdrawals.
|
|
1925
|
+
for j in range(self.N_j):
|
|
1926
|
+
for n in range(self.N_n):
|
|
1927
|
+
c_arr[_q3(self.C["w"], 1, j, n, self.N_i, self.N_j, self.N_n)] += epsilon
|
|
1928
|
+
|
|
1929
|
+
c = abc.Objective(self.nvars)
|
|
1930
|
+
for idx in np.flatnonzero(c_arr):
|
|
1931
|
+
c.setElem(idx, c_arr[idx])
|
|
1650
1932
|
self.c = c
|
|
1651
1933
|
|
|
1652
1934
|
@_timer
|
|
1653
|
-
def runHistoricalRange(self, objective, options, ystart, yend, *, verbose=False, figure=False,
|
|
1935
|
+
def runHistoricalRange(self, objective, options, ystart, yend, *, verbose=False, figure=False,
|
|
1936
|
+
progcall=None, reverse=False, roll=0):
|
|
1654
1937
|
"""
|
|
1655
1938
|
Run historical scenarios on plan over a range of years.
|
|
1656
|
-
"""
|
|
1657
1939
|
|
|
1940
|
+
For each year in [ystart, yend], rates are set to the historical sequence
|
|
1941
|
+
starting at that year. Optional reverse and roll apply to each sequence
|
|
1942
|
+
(same semantics as setRates).
|
|
1943
|
+
"""
|
|
1658
1944
|
if yend + self.N_n > self.year_n[0]:
|
|
1659
1945
|
yend = self.year_n[0] - self.N_n - 1
|
|
1660
|
-
self.mylog.
|
|
1946
|
+
self.mylog.print(f"Warning: Upper bound for year range re-adjusted to {yend}.")
|
|
1661
1947
|
|
|
1662
1948
|
if yend < ystart:
|
|
1663
1949
|
raise ValueError(f"Starting year is too large to support a lifespan of {self.N_n} years.")
|
|
@@ -1672,8 +1958,8 @@ class Plan:
|
|
|
1672
1958
|
elif objective == "maxBequest":
|
|
1673
1959
|
columns = ["partial", "final"]
|
|
1674
1960
|
else:
|
|
1675
|
-
self.mylog.print(f"Invalid objective {objective}.")
|
|
1676
|
-
raise ValueError(f"Invalid objective {objective}.")
|
|
1961
|
+
self.mylog.print(f"Invalid objective '{objective}'.")
|
|
1962
|
+
raise ValueError(f"Invalid objective '{objective}'.")
|
|
1677
1963
|
|
|
1678
1964
|
df = pd.DataFrame(columns=columns)
|
|
1679
1965
|
|
|
@@ -1684,7 +1970,7 @@ class Plan:
|
|
|
1684
1970
|
progcall.start()
|
|
1685
1971
|
|
|
1686
1972
|
for year in range(ystart, yend + 1):
|
|
1687
|
-
self.setRates("historical", year)
|
|
1973
|
+
self.setRates("historical", year, reverse=reverse, roll=roll)
|
|
1688
1974
|
self.solve(objective, options)
|
|
1689
1975
|
if not verbose:
|
|
1690
1976
|
progcall.show((year - ystart + 1) / N)
|
|
@@ -1725,7 +2011,7 @@ class Plan:
|
|
|
1725
2011
|
elif objective == "maxBequest":
|
|
1726
2012
|
columns = ["partial", "final"]
|
|
1727
2013
|
else:
|
|
1728
|
-
self.mylog.print(f"Invalid objective {objective}.")
|
|
2014
|
+
self.mylog.print(f"Invalid objective '{objective}'.")
|
|
1729
2015
|
return None
|
|
1730
2016
|
|
|
1731
2017
|
df = pd.DataFrame(columns=columns)
|
|
@@ -1737,7 +2023,7 @@ class Plan:
|
|
|
1737
2023
|
progcall.start()
|
|
1738
2024
|
|
|
1739
2025
|
for n in range(N):
|
|
1740
|
-
self.regenRates()
|
|
2026
|
+
self.regenRates(override_reproducible=True)
|
|
1741
2027
|
self.solve(objective, myoptions)
|
|
1742
2028
|
if not verbose:
|
|
1743
2029
|
progcall.show((n + 1) / N)
|
|
@@ -1786,52 +2072,68 @@ class Plan:
|
|
|
1786
2072
|
|
|
1787
2073
|
Refer to companion document for implementation details.
|
|
1788
2074
|
"""
|
|
2075
|
+
|
|
1789
2076
|
if self.rateMethod is None:
|
|
1790
2077
|
raise RuntimeError("Rate method must be selected before solving.")
|
|
1791
2078
|
|
|
1792
2079
|
# Assume unsuccessful until problem solved.
|
|
1793
2080
|
self.caseStatus = "unsuccessful"
|
|
2081
|
+
self.convergenceType = "undefined"
|
|
1794
2082
|
|
|
1795
2083
|
# Check objective and required options.
|
|
1796
2084
|
knownObjectives = ["maxBequest", "maxSpending"]
|
|
1797
2085
|
knownSolvers = ["HiGHS", "PuLP/CBC", "PuLP/HiGHS", "MOSEK"]
|
|
1798
2086
|
|
|
1799
2087
|
knownOptions = [
|
|
2088
|
+
"absTol",
|
|
2089
|
+
"amoConstraints",
|
|
2090
|
+
"amoRoth",
|
|
2091
|
+
"amoSurplus",
|
|
1800
2092
|
"bequest",
|
|
1801
|
-
"
|
|
2093
|
+
"bigMamo", # Big-M value for AMO constraints (default: 5e7)
|
|
2094
|
+
"epsilon",
|
|
2095
|
+
"gap",
|
|
2096
|
+
"maxIter",
|
|
1802
2097
|
"maxRothConversion",
|
|
1803
2098
|
"netSpending",
|
|
2099
|
+
"noLateSurplus",
|
|
1804
2100
|
"noRothConversions",
|
|
1805
2101
|
"oppCostX",
|
|
1806
|
-
"withMedicare",
|
|
1807
2102
|
"previousMAGIs",
|
|
2103
|
+
"relTol",
|
|
1808
2104
|
"solver",
|
|
1809
2105
|
"spendingSlack",
|
|
1810
2106
|
"startRothConversions",
|
|
2107
|
+
"swapRothConverters",
|
|
2108
|
+
"maxTime",
|
|
1811
2109
|
"units",
|
|
1812
|
-
"
|
|
2110
|
+
"verbose",
|
|
2111
|
+
"withMedicare",
|
|
1813
2112
|
"withSCLoop",
|
|
1814
2113
|
]
|
|
1815
|
-
# We might modify options if required.
|
|
1816
2114
|
options = {} if options is None else options
|
|
2115
|
+
|
|
2116
|
+
# We might modify options if required.
|
|
1817
2117
|
myoptions = dict(options)
|
|
1818
2118
|
|
|
1819
|
-
for opt in myoptions:
|
|
2119
|
+
for opt in list(myoptions.keys()):
|
|
1820
2120
|
if opt not in knownOptions:
|
|
1821
|
-
raise ValueError(f"Option {opt} is not one of {knownOptions}.")
|
|
2121
|
+
# raise ValueError(f"Option '{opt}' is not one of {knownOptions}.")
|
|
2122
|
+
self.mylog.print(f"Ignoring unknown solver option '{opt}'.")
|
|
2123
|
+
myoptions.pop(opt)
|
|
1822
2124
|
|
|
1823
2125
|
if objective not in knownObjectives:
|
|
1824
|
-
raise ValueError(f"Objective {objective} is not one of {knownObjectives}.")
|
|
2126
|
+
raise ValueError(f"Objective '{objective}' is not one of {knownObjectives}.")
|
|
1825
2127
|
|
|
1826
2128
|
if objective == "maxBequest" and "netSpending" not in myoptions:
|
|
1827
|
-
raise RuntimeError(f"Objective {objective} needs netSpending option.")
|
|
2129
|
+
raise RuntimeError(f"Objective '{objective}' needs netSpending option.")
|
|
1828
2130
|
|
|
1829
2131
|
if objective == "maxBequest" and "bequest" in myoptions:
|
|
1830
|
-
self.mylog.
|
|
2132
|
+
self.mylog.print("Ignoring bequest option provided.")
|
|
1831
2133
|
myoptions.pop("bequest")
|
|
1832
2134
|
|
|
1833
2135
|
if objective == "maxSpending" and "netSpending" in myoptions:
|
|
1834
|
-
self.mylog.
|
|
2136
|
+
self.mylog.print("Ignoring netSpending option provided.")
|
|
1835
2137
|
myoptions.pop("netSpending")
|
|
1836
2138
|
|
|
1837
2139
|
if objective == "maxSpending" and "bequest" not in myoptions:
|
|
@@ -1839,9 +2141,24 @@ class Plan:
|
|
|
1839
2141
|
|
|
1840
2142
|
self.optionsUnits = u.getUnits(myoptions.get("units", "k"))
|
|
1841
2143
|
|
|
1842
|
-
oppCostX =
|
|
2144
|
+
oppCostX = myoptions.get("oppCostX", 0.)
|
|
1843
2145
|
self.xnet = 1 - oppCostX / 100.
|
|
1844
2146
|
|
|
2147
|
+
if "swapRothConverters" in myoptions and "noRothConversions" in myoptions:
|
|
2148
|
+
self.mylog.print("Ignoring 'noRothConversions' as 'swapRothConverters' option present.")
|
|
2149
|
+
myoptions.pop("noRothConversions")
|
|
2150
|
+
|
|
2151
|
+
# Go easy on MILP - auto gap somehow.
|
|
2152
|
+
if "gap" not in myoptions and myoptions.get("withMedicare", "loop") == "optimize":
|
|
2153
|
+
fac = 1
|
|
2154
|
+
maxRoth = myoptions.get("maxRothConversion", 100)
|
|
2155
|
+
if maxRoth != "file" and maxRoth <= 15:
|
|
2156
|
+
fac = 10
|
|
2157
|
+
# Loosen default MIP gap when Medicare is optimized. Even more if rothX == 0
|
|
2158
|
+
gap = fac * MILP_GAP
|
|
2159
|
+
myoptions["gap"] = gap
|
|
2160
|
+
self.mylog.vprint(f"Using restricted gap of {gap:.1e}.")
|
|
2161
|
+
|
|
1845
2162
|
self.prevMAGI = np.zeros(2)
|
|
1846
2163
|
if "previousMAGIs" in myoptions:
|
|
1847
2164
|
magi = myoptions["previousMAGIs"]
|
|
@@ -1852,7 +2169,7 @@ class Plan:
|
|
|
1852
2169
|
|
|
1853
2170
|
lambdha = myoptions.get("spendingSlack", 0)
|
|
1854
2171
|
if not (0 <= lambdha <= 50):
|
|
1855
|
-
raise ValueError(f"Slack value out of range
|
|
2172
|
+
raise ValueError(f"Slack value {lambdha} out of range.")
|
|
1856
2173
|
self.lambdha = lambdha / 100
|
|
1857
2174
|
|
|
1858
2175
|
# Reset long-term capital gain tax rate and MAGI to zero.
|
|
@@ -1862,14 +2179,14 @@ class Plan:
|
|
|
1862
2179
|
self.M_n = np.zeros(self.N_n)
|
|
1863
2180
|
|
|
1864
2181
|
self._adjustParameters(self.gamma_n, self.MAGI_n)
|
|
1865
|
-
self._buildOffsetMap(
|
|
2182
|
+
self._buildOffsetMap(myoptions)
|
|
1866
2183
|
|
|
1867
2184
|
# Process debts and fixed assets
|
|
1868
2185
|
self.processDebtsAndFixedAssets()
|
|
1869
2186
|
|
|
1870
2187
|
solver = myoptions.get("solver", self.defaultSolver)
|
|
1871
2188
|
if solver not in knownSolvers:
|
|
1872
|
-
raise ValueError(f"Unknown solver {solver}.")
|
|
2189
|
+
raise ValueError(f"Unknown solver '{solver}'.")
|
|
1873
2190
|
|
|
1874
2191
|
if solver == "HiGHS":
|
|
1875
2192
|
solverMethod = self._milpSolve
|
|
@@ -1880,7 +2197,10 @@ class Plan:
|
|
|
1880
2197
|
else:
|
|
1881
2198
|
raise RuntimeError("Internal error in defining solverMethod.")
|
|
1882
2199
|
|
|
1883
|
-
self.
|
|
2200
|
+
self.mylog.vprint(f"Using '{solver}' solver.")
|
|
2201
|
+
myoptions_txt = textwrap.fill(f"{myoptions}", initial_indent="\t", subsequent_indent="\t", width=100)
|
|
2202
|
+
self.mylog.vprint(f"Solver options:\n{myoptions_txt}.")
|
|
2203
|
+
self._scSolve(objective, myoptions, solverMethod)
|
|
1884
2204
|
|
|
1885
2205
|
self.objective = objective
|
|
1886
2206
|
self.solverOptions = myoptions
|
|
@@ -1894,6 +2214,20 @@ class Plan:
|
|
|
1894
2214
|
includeMedicare = options.get("withMedicare", "loop") == "loop"
|
|
1895
2215
|
withSCLoop = options.get("withSCLoop", True)
|
|
1896
2216
|
|
|
2217
|
+
# Convergence uses a relative tolerance tied to MILP gap,
|
|
2218
|
+
# with an absolute floor to avoid zero/near-zero objectives.
|
|
2219
|
+
gap = u.get_numeric_option(options, "gap", GAP, min_value=0)
|
|
2220
|
+
abs_tol = u.get_numeric_option(options, "absTol", ABS_TOL, min_value=0)
|
|
2221
|
+
rel_tol = options.get("relTol")
|
|
2222
|
+
if rel_tol is None:
|
|
2223
|
+
# Keep rel_tol aligned with solver gap to avoid SC loop chasing noise.
|
|
2224
|
+
rel_tol = max(REL_TOL, gap / 300)
|
|
2225
|
+
# rel_tol = u.get_numeric_option({"relTol": rel_tol}, "relTol", REL_TOL, min_value=0)
|
|
2226
|
+
self.mylog.print(f"Using relTol={rel_tol:.1e}, absTol={abs_tol:.1e}, and gap={gap:.1e}.")
|
|
2227
|
+
|
|
2228
|
+
max_iterations = int(u.get_numeric_option(options, "maxIter", MAX_ITERATIONS, min_value=1))
|
|
2229
|
+
self.mylog.print(f"Using maxIter={max_iterations}.")
|
|
2230
|
+
|
|
1897
2231
|
if objective == "maxSpending":
|
|
1898
2232
|
objFac = -1 / self.xi_n[0]
|
|
1899
2233
|
else:
|
|
@@ -1902,38 +2236,91 @@ class Plan:
|
|
|
1902
2236
|
it = 0
|
|
1903
2237
|
old_x = np.zeros(self.nvars)
|
|
1904
2238
|
old_objfns = [np.inf]
|
|
2239
|
+
scaled_obj_history = [] # Track scaled objective values for oscillation detection
|
|
2240
|
+
sol_history = [] # Track solutions aligned with scaled_obj_history
|
|
2241
|
+
obj_history = [] # Track raw objective values aligned with scaled_obj_history
|
|
1905
2242
|
self._computeNLstuff(None, includeMedicare)
|
|
1906
2243
|
while True:
|
|
1907
|
-
objfn, xx, solverSuccess, solverMsg = solverMethod(objective, options)
|
|
2244
|
+
objfn, xx, solverSuccess, solverMsg, solgap = solverMethod(objective, options)
|
|
1908
2245
|
|
|
1909
2246
|
if not solverSuccess or objfn is None:
|
|
1910
|
-
self.mylog.
|
|
2247
|
+
self.mylog.print("Solver failed:", solverMsg, solverSuccess)
|
|
1911
2248
|
break
|
|
1912
2249
|
|
|
1913
2250
|
if not withSCLoop:
|
|
2251
|
+
# When Medicare is in loop mode, M_n was zero in the constraint for this
|
|
2252
|
+
# single solve. Update M_n (and J_n) from solution for reporting.
|
|
2253
|
+
if includeMedicare:
|
|
2254
|
+
self._computeNLstuff(xx, includeMedicare)
|
|
2255
|
+
self.mylog.print(
|
|
2256
|
+
"Warning: Self-consistent loop is off; Medicare premiums are "
|
|
2257
|
+
"computed for display but were not in the budget constraint."
|
|
2258
|
+
)
|
|
1914
2259
|
break
|
|
1915
2260
|
|
|
1916
2261
|
self._computeNLstuff(xx, includeMedicare)
|
|
1917
2262
|
|
|
1918
2263
|
delta = xx - old_x
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
2264
|
+
# Only consider account balances in dX.
|
|
2265
|
+
absSolDiff = np.sum(np.abs(delta[:self.nbals]), axis=0)/self.nbals
|
|
2266
|
+
absObjDiff = abs(objFac*(objfn + old_objfns[-1]))
|
|
2267
|
+
scaled_obj = objfn * objFac
|
|
2268
|
+
scaled_obj_history.append(scaled_obj)
|
|
2269
|
+
sol_history.append(xx)
|
|
2270
|
+
obj_history.append(objfn)
|
|
2271
|
+
self.mylog.vprint(f"Iter: {it:02}; f: {u.d(scaled_obj, f=0)}; gap: {solgap:.1e};"
|
|
2272
|
+
f" |dX|: {absSolDiff:.0f}; |df|: {u.d(absObjDiff, f=0)}")
|
|
2273
|
+
|
|
2274
|
+
# Solution difference is calculated and reported but not used for convergence
|
|
2275
|
+
# since it scales with problem size and can prevent convergence for large cases.
|
|
2276
|
+
prev_scaled_obj = scaled_obj
|
|
2277
|
+
if np.isfinite(old_objfns[-1]):
|
|
2278
|
+
prev_scaled_obj = (-old_objfns[-1]) * objFac
|
|
2279
|
+
scale = max(1.0, abs(scaled_obj), abs(prev_scaled_obj))
|
|
2280
|
+
tol = max(abs_tol, rel_tol * scale)
|
|
2281
|
+
# With Medicare in loop mode, the first solve uses M_n=0; require at least
|
|
2282
|
+
# one re-solve so the accepted solution had Medicare in the budget.
|
|
2283
|
+
if absObjDiff <= tol and (not includeMedicare or it >= 1):
|
|
2284
|
+
# Check if convergence was monotonic or oscillatory
|
|
2285
|
+
# old_objfns stores -objfn values, so we need to scale them to match displayed values
|
|
2286
|
+
# For monotonic convergence, the scaled objective (objfn * objFac) should be non-increasing
|
|
2287
|
+
# Include current iteration's scaled objfn value
|
|
2288
|
+
scaled_objfns = [(-val) * objFac for val in old_objfns[1:]] + [scaled_obj]
|
|
2289
|
+
# Check if scaled objective function is non-increasing (monotonic convergence)
|
|
2290
|
+
is_monotonic = all(scaled_objfns[i] <= scaled_objfns[i-1] + tol
|
|
2291
|
+
for i in range(1, len(scaled_objfns)))
|
|
2292
|
+
if is_monotonic:
|
|
2293
|
+
self.convergenceType = "monotonic"
|
|
2294
|
+
else:
|
|
2295
|
+
self.convergenceType = "oscillatory"
|
|
2296
|
+
self.mylog.print(f"Converged on full solution with {self.convergenceType} behavior.")
|
|
1927
2297
|
break
|
|
1928
2298
|
|
|
1929
|
-
#
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
2299
|
+
# Check for oscillation (need at least 4 iterations to detect a 2-cycle)
|
|
2300
|
+
if it >= 3:
|
|
2301
|
+
cycle_len = self._detectOscillation(scaled_obj_history, tol)
|
|
2302
|
+
if cycle_len is not None:
|
|
2303
|
+
# Find the best (maximum) objective in the cycle
|
|
2304
|
+
cycle_values = scaled_obj_history[-cycle_len:]
|
|
2305
|
+
best_idx = np.argmax(cycle_values)
|
|
2306
|
+
best_obj = cycle_values[best_idx]
|
|
2307
|
+
self.convergenceType = f"oscillatory (cycle length {cycle_len})"
|
|
2308
|
+
self.mylog.print(f"Oscillation detected: {cycle_len}-cycle pattern identified.")
|
|
2309
|
+
self.mylog.print(f"Best objective in cycle: {u.d(best_obj, f=2)}")
|
|
2310
|
+
|
|
2311
|
+
# Select the solution corresponding to the best objective in the detected cycle.
|
|
2312
|
+
cycle_solutions = sol_history[-cycle_len:]
|
|
2313
|
+
cycle_objfns = obj_history[-cycle_len:]
|
|
2314
|
+
xx = cycle_solutions[best_idx]
|
|
2315
|
+
objfn = cycle_objfns[best_idx]
|
|
2316
|
+
self.mylog.print("Using best solution from detected cycle.")
|
|
2317
|
+
|
|
2318
|
+
self.mylog.print("Accepting solution from cycle and terminating.")
|
|
2319
|
+
break
|
|
1934
2320
|
|
|
1935
|
-
if it
|
|
1936
|
-
self.
|
|
2321
|
+
if it >= max_iterations:
|
|
2322
|
+
self.convergenceType = "max iteration"
|
|
2323
|
+
self.mylog.print("Warning: Exiting loop on maximum iterations.")
|
|
1937
2324
|
break
|
|
1938
2325
|
|
|
1939
2326
|
it += 1
|
|
@@ -1941,15 +2328,15 @@ class Plan:
|
|
|
1941
2328
|
old_x = xx
|
|
1942
2329
|
|
|
1943
2330
|
if solverSuccess:
|
|
1944
|
-
self.mylog.
|
|
1945
|
-
self.mylog.
|
|
1946
|
-
self.mylog.
|
|
2331
|
+
self.mylog.print(f"Self-consistent loop returned after {it+1} iterations.")
|
|
2332
|
+
self.mylog.print(solverMsg)
|
|
2333
|
+
self.mylog.print(f"Objective: {u.d(objfn * objFac)}")
|
|
1947
2334
|
# self.mylog.vprint('Upper bound:', u.d(-solution.mip_dual_bound))
|
|
1948
2335
|
self._aggregateResults(xx)
|
|
1949
2336
|
self._timestamp = datetime.now().strftime("%Y-%m-%d at %H:%M:%S")
|
|
1950
2337
|
self.caseStatus = "solved"
|
|
1951
2338
|
else:
|
|
1952
|
-
self.mylog.
|
|
2339
|
+
self.mylog.print("Warning: Optimization failed:", solverMsg, solverSuccess)
|
|
1953
2340
|
self.caseStatus = "unsuccessful"
|
|
1954
2341
|
|
|
1955
2342
|
return None
|
|
@@ -1960,12 +2347,17 @@ class Plan:
|
|
|
1960
2347
|
"""
|
|
1961
2348
|
from scipy import optimize
|
|
1962
2349
|
|
|
2350
|
+
time_limit = u.get_numeric_option(options, "maxTime", TIME_LIMIT, min_value=0) # seconds
|
|
2351
|
+
mygap = u.get_numeric_option(options, "gap", GAP, min_value=0)
|
|
2352
|
+
verbose = options.get("verbose", False)
|
|
2353
|
+
|
|
1963
2354
|
# Optimize solver parameters
|
|
1964
2355
|
milpOptions = {
|
|
1965
|
-
"disp":
|
|
1966
|
-
"mip_rel_gap": 1e-
|
|
2356
|
+
"disp": bool(verbose),
|
|
2357
|
+
"mip_rel_gap": mygap, # Internal default in milp is 1e-4
|
|
1967
2358
|
"presolve": True,
|
|
1968
|
-
"
|
|
2359
|
+
"time_limit": time_limit,
|
|
2360
|
+
"node_limit": 1000000 # Limit search nodes for faster solutions
|
|
1969
2361
|
}
|
|
1970
2362
|
|
|
1971
2363
|
self._buildConstraints(objective, options)
|
|
@@ -1984,7 +2376,7 @@ class Plan:
|
|
|
1984
2376
|
options=milpOptions,
|
|
1985
2377
|
)
|
|
1986
2378
|
|
|
1987
|
-
return solution.fun, solution.x, solution.success, solution.message
|
|
2379
|
+
return solution.fun, solution.x, solution.success, solution.message, solution.mip_gap
|
|
1988
2380
|
|
|
1989
2381
|
def _pulpSolve(self, objective, options):
|
|
1990
2382
|
"""
|
|
@@ -2049,7 +2441,7 @@ class Plan:
|
|
|
2049
2441
|
solution = np.dot(c, xx)
|
|
2050
2442
|
success = (pulp.LpStatus[prob.status] == "Optimal")
|
|
2051
2443
|
|
|
2052
|
-
return solution, xx, success, pulp.LpStatus[prob.status]
|
|
2444
|
+
return solution, xx, success, pulp.LpStatus[prob.status], -1
|
|
2053
2445
|
|
|
2054
2446
|
def _mosekSolve(self, objective, options):
|
|
2055
2447
|
"""
|
|
@@ -2068,6 +2460,7 @@ class Plan:
|
|
|
2068
2460
|
solverMsg = str()
|
|
2069
2461
|
|
|
2070
2462
|
def _streamPrinter(text, msg=solverMsg):
|
|
2463
|
+
self.mylog.vprint(text.strip())
|
|
2071
2464
|
msg += text
|
|
2072
2465
|
|
|
2073
2466
|
self._buildConstraints(objective, options)
|
|
@@ -2078,10 +2471,24 @@ class Plan:
|
|
|
2078
2471
|
vkeys = self.B.keys()
|
|
2079
2472
|
cind, cval = self.c.lists()
|
|
2080
2473
|
|
|
2474
|
+
time_limit = u.get_numeric_option(options, "maxTime", TIME_LIMIT, min_value=0)
|
|
2475
|
+
mygap = u.get_numeric_option(options, "gap", GAP, min_value=0)
|
|
2476
|
+
|
|
2477
|
+
verbose = options.get("verbose", False)
|
|
2478
|
+
|
|
2081
2479
|
task = mosek.Task()
|
|
2082
|
-
|
|
2083
|
-
# task.putdouparam(mosek.dparam.
|
|
2084
|
-
|
|
2480
|
+
task.putdouparam(mosek.dparam.mio_max_time, time_limit) # Default -1
|
|
2481
|
+
# task.putdouparam(mosek.dparam.mio_rel_gap_const, 1e-6) # Default 1e-10
|
|
2482
|
+
task.putdouparam(mosek.dparam.mio_tol_rel_gap, mygap) # Default 1e-4
|
|
2483
|
+
# task.putdouparam(mosek.dparam.mio_tol_abs_relax_int, 2e-5) # Default 1e-5
|
|
2484
|
+
# task.putdouparam(mosek.iparam.mio_heuristic_level, 3) # Default -1
|
|
2485
|
+
|
|
2486
|
+
# task.set_Stream(mosek.streamtype.wrn, _streamPrinter)
|
|
2487
|
+
task.set_Stream(mosek.streamtype.err, _streamPrinter)
|
|
2488
|
+
if verbose:
|
|
2489
|
+
# task.set_Stream(mosek.streamtype.log, _streamPrinter)
|
|
2490
|
+
task.set_Stream(mosek.streamtype.msg, _streamPrinter)
|
|
2491
|
+
|
|
2085
2492
|
task.appendcons(self.A.ncons)
|
|
2086
2493
|
task.appendvars(self.A.nvars)
|
|
2087
2494
|
|
|
@@ -2104,14 +2511,67 @@ class Plan:
|
|
|
2104
2511
|
# Problem MUST contain binary variables to make these calls.
|
|
2105
2512
|
solsta = task.getsolsta(mosek.soltype.itg)
|
|
2106
2513
|
solverSuccess = (solsta == mosek.solsta.integer_optimal)
|
|
2514
|
+
rel_gap = task.getdouinf(mosek.dinfitem.mio_obj_rel_gap) if solverSuccess else -1
|
|
2107
2515
|
|
|
2108
2516
|
xx = np.array(task.getxx(mosek.soltype.itg))
|
|
2109
2517
|
solution = task.getprimalobj(mosek.soltype.itg)
|
|
2110
|
-
task.set_Stream(mosek.streamtype.wrn, _streamPrinter)
|
|
2111
2518
|
task.solutionsummary(mosek.streamtype.msg)
|
|
2112
2519
|
# task.writedata(self._name+'.ptf')
|
|
2113
2520
|
|
|
2114
|
-
return solution, xx, solverSuccess, solverMsg
|
|
2521
|
+
return solution, xx, solverSuccess, solverMsg, rel_gap
|
|
2522
|
+
|
|
2523
|
+
def _detectOscillation(self, obj_history, tolerance, max_cycle_length=15):
|
|
2524
|
+
"""
|
|
2525
|
+
Detect if the objective function is oscillating in a repeating cycle.
|
|
2526
|
+
|
|
2527
|
+
This function checks for repeating patterns of any length (2, 3, 4, etc.)
|
|
2528
|
+
in the recent objective function history. It handles numerical precision
|
|
2529
|
+
by using a tolerance for "close enough" matching.
|
|
2530
|
+
|
|
2531
|
+
Parameters
|
|
2532
|
+
----------
|
|
2533
|
+
obj_history : list
|
|
2534
|
+
List of recent objective function values (most recent last)
|
|
2535
|
+
tolerance : float
|
|
2536
|
+
Tolerance for considering two values "equal" (same as convergence tolerance)
|
|
2537
|
+
max_cycle_length : int
|
|
2538
|
+
Maximum cycle length to check for (default 15)
|
|
2539
|
+
|
|
2540
|
+
Returns
|
|
2541
|
+
-------
|
|
2542
|
+
int or None
|
|
2543
|
+
Cycle length if oscillation detected, None otherwise
|
|
2544
|
+
"""
|
|
2545
|
+
if len(obj_history) < 4: # Need at least 4 values to detect a 2-cycle
|
|
2546
|
+
return None
|
|
2547
|
+
|
|
2548
|
+
# Check for cycles of length 2, 3, 4, ... up to max_cycle_length
|
|
2549
|
+
# We need at least 2*cycle_length values to confirm a cycle
|
|
2550
|
+
for cycle_len in range(2, min(max_cycle_length + 1, len(obj_history) // 2 + 1)):
|
|
2551
|
+
# Check if the last cycle_len values match the previous cycle_len values
|
|
2552
|
+
if len(obj_history) < 2 * cycle_len:
|
|
2553
|
+
continue
|
|
2554
|
+
|
|
2555
|
+
recent = obj_history[-cycle_len:]
|
|
2556
|
+
previous = obj_history[-2*cycle_len:-cycle_len]
|
|
2557
|
+
|
|
2558
|
+
# Check if all pairs match within tolerance
|
|
2559
|
+
matches = all(abs(recent[i] - previous[i]) <= tolerance
|
|
2560
|
+
for i in range(cycle_len))
|
|
2561
|
+
|
|
2562
|
+
if matches:
|
|
2563
|
+
# Verify it's a true cycle by checking one more period back if available
|
|
2564
|
+
if len(obj_history) >= 3 * cycle_len:
|
|
2565
|
+
earlier = obj_history[-3*cycle_len:-2*cycle_len]
|
|
2566
|
+
if all(abs(recent[i] - earlier[i]) <= tolerance
|
|
2567
|
+
for i in range(cycle_len)):
|
|
2568
|
+
return cycle_len
|
|
2569
|
+
else:
|
|
2570
|
+
# If we don't have enough history, still report the cycle
|
|
2571
|
+
# but it's less certain
|
|
2572
|
+
return cycle_len
|
|
2573
|
+
|
|
2574
|
+
return None
|
|
2115
2575
|
|
|
2116
2576
|
def _computeNLstuff(self, x, includeMedicare):
|
|
2117
2577
|
"""
|
|
@@ -2128,7 +2588,13 @@ class Plan:
|
|
|
2128
2588
|
self._aggregateResults(x, short=True)
|
|
2129
2589
|
|
|
2130
2590
|
self.J_n = tx.computeNIIT(self.N_i, self.MAGI_n, self.I_n, self.Q_n, self.n_d, self.N_n)
|
|
2131
|
-
|
|
2591
|
+
ltcg_n = np.maximum(self.Q_n, 0)
|
|
2592
|
+
tx_income_n = self.e_n + ltcg_n
|
|
2593
|
+
cg_tax_n = tx.capitalGainTax(self.N_i, tx_income_n, ltcg_n, self.gamma_n[:-1], self.n_d, self.N_n)
|
|
2594
|
+
self.psi_n = np.zeros(self.N_n)
|
|
2595
|
+
has_ltcg = ltcg_n > 0
|
|
2596
|
+
self.psi_n[has_ltcg] = cg_tax_n[has_ltcg] / ltcg_n[has_ltcg]
|
|
2597
|
+
self.U_n = cg_tax_n
|
|
2132
2598
|
# Compute Medicare through self-consistent loop.
|
|
2133
2599
|
if includeMedicare:
|
|
2134
2600
|
self.M_n = tx.mediCosts(self.yobs, self.horizons, self.MAGI_n, self.prevMAGI, self.gamma_n[:-1], self.N_n)
|
|
@@ -2154,6 +2620,7 @@ class Plan:
|
|
|
2154
2620
|
Ce = self.C["e"]
|
|
2155
2621
|
Cf = self.C["f"]
|
|
2156
2622
|
Cg = self.C["g"]
|
|
2623
|
+
Ch = self.C.get("h", self.C["m"])
|
|
2157
2624
|
Cm = self.C["m"]
|
|
2158
2625
|
Cs = self.C["s"]
|
|
2159
2626
|
Cw = self.C["w"]
|
|
@@ -2177,7 +2644,11 @@ class Plan:
|
|
|
2177
2644
|
self.f_tn = np.array(x[Cf:Cg])
|
|
2178
2645
|
self.f_tn = self.f_tn.reshape((Nt, Nn))
|
|
2179
2646
|
|
|
2180
|
-
self.g_n = np.array(x[Cg:
|
|
2647
|
+
self.g_n = np.array(x[Cg:Ch])
|
|
2648
|
+
|
|
2649
|
+
if "h" in self.C:
|
|
2650
|
+
self.h_qn = np.array(x[Ch:Cm])
|
|
2651
|
+
self.h_qn = self.h_qn.reshape((self.N_n - self.nm, self.N_q))
|
|
2181
2652
|
|
|
2182
2653
|
self.m_n = np.array(x[Cm:Cs])
|
|
2183
2654
|
|
|
@@ -2196,27 +2667,33 @@ class Plan:
|
|
|
2196
2667
|
self.G_n = np.sum(self.f_tn, axis=0)
|
|
2197
2668
|
|
|
2198
2669
|
tau_0 = np.array(self.tau_kn[0, :])
|
|
2199
|
-
tau_0[tau_0 < 0] = 0
|
|
2200
2670
|
# Last year's rates.
|
|
2201
2671
|
tau_0prev = np.roll(tau_0, 1)
|
|
2672
|
+
# Capital gains = price appreciation only (total return - dividend rate)
|
|
2673
|
+
# to avoid double taxation of dividends. No tax harvesting here.
|
|
2674
|
+
capital_gains_rate = np.maximum(0, tau_0prev - self.mu)
|
|
2202
2675
|
self.Q_n = np.sum(
|
|
2203
2676
|
(
|
|
2204
2677
|
self.mu
|
|
2205
2678
|
* (self.b_ijn[:, 0, :Nn] - self.w_ijn[:, 0, :] + self.d_in[:, :] + 0.5 * self.kappa_ijn[:, 0, :Nn])
|
|
2206
|
-
+
|
|
2679
|
+
+ capital_gains_rate * self.w_ijn[:, 0, :]
|
|
2207
2680
|
)
|
|
2208
2681
|
* self.alpha_ijkn[:, 0, 0, :Nn],
|
|
2209
2682
|
axis=0,
|
|
2210
2683
|
)
|
|
2211
|
-
# Add fixed assets capital gains
|
|
2684
|
+
# Add fixed assets capital gains.
|
|
2212
2685
|
self.Q_n += self.fixed_assets_capital_gains_n
|
|
2213
2686
|
self.U_n = self.psi_n * self.Q_n
|
|
2214
2687
|
|
|
2215
|
-
|
|
2688
|
+
# Also add back non-taxable part of SS.
|
|
2689
|
+
self.MAGI_n = (self.G_n + self.e_n + self.Q_n
|
|
2690
|
+
+ np.sum((1 - self.Psi_n) * self.zetaBar_in, axis=0))
|
|
2216
2691
|
|
|
2692
|
+
# Only positive returns count as interest/dividend income (matches _add_taxable_income).
|
|
2217
2693
|
I_in = ((self.b_ijn[:, 0, :-1] + self.d_in - self.w_ijn[:, 0, :])
|
|
2218
|
-
* np.sum(self.alpha_ijkn[:, 0, 1:, :Nn] * self.tau_kn[1:, :], axis=1))
|
|
2219
|
-
|
|
2694
|
+
* np.sum(self.alpha_ijkn[:, 0, 1:, :Nn] * np.maximum(0, self.tau_kn[1:, :]), axis=1))
|
|
2695
|
+
# Sum over individuals to share losses across spouses; clamp to non-negative.
|
|
2696
|
+
self.I_n = np.maximum(0, np.sum(I_in, axis=0))
|
|
2220
2697
|
|
|
2221
2698
|
# Stop after building minimum required for self-consistent loop.
|
|
2222
2699
|
if short:
|
|
@@ -2282,33 +2759,12 @@ class Plan:
|
|
|
2282
2759
|
sources["tax-free wdrwl"] = self.w_ijn[:, 2, :]
|
|
2283
2760
|
sources["BTI"] = self.Lambda_in
|
|
2284
2761
|
# 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
|
|
2762
|
+
# Show as household totals, not split between individuals
|
|
2763
|
+
# Reshape to (1, N_n) to indicate household-level source
|
|
2764
|
+
sources["FA ord inc"] = self.fixed_assets_ordinary_income_n.reshape(1, -1)
|
|
2765
|
+
sources["FA cap gains"] = self.fixed_assets_capital_gains_n.reshape(1, -1)
|
|
2766
|
+
sources["FA tax-free"] = self.fixed_assets_tax_free_n.reshape(1, -1)
|
|
2767
|
+
sources["debt pmts"] = -self.debt_payments_n.reshape(1, -1)
|
|
2312
2768
|
|
|
2313
2769
|
savings = {}
|
|
2314
2770
|
savings["taxable"] = self.b_ijn[:, 0, :]
|
|
@@ -2340,75 +2796,80 @@ class Plan:
|
|
|
2340
2796
|
return None
|
|
2341
2797
|
|
|
2342
2798
|
@_checkCaseStatus
|
|
2343
|
-
def summary(self):
|
|
2799
|
+
def summary(self, N=None):
|
|
2344
2800
|
"""
|
|
2345
2801
|
Print summary in logs.
|
|
2346
2802
|
"""
|
|
2347
2803
|
self.mylog.print("SUMMARY ================================================================")
|
|
2348
|
-
dic = self.summaryDic()
|
|
2804
|
+
dic = self.summaryDic(N)
|
|
2349
2805
|
for key, value in dic.items():
|
|
2350
2806
|
self.mylog.print(f"{key}: {value}")
|
|
2351
2807
|
self.mylog.print("------------------------------------------------------------------------")
|
|
2352
2808
|
|
|
2353
2809
|
return None
|
|
2354
2810
|
|
|
2355
|
-
def summaryList(self):
|
|
2811
|
+
def summaryList(self, N=None):
|
|
2356
2812
|
"""
|
|
2357
2813
|
Return summary as a list.
|
|
2358
2814
|
"""
|
|
2359
2815
|
mylist = []
|
|
2360
|
-
dic = self.summaryDic()
|
|
2816
|
+
dic = self.summaryDic(N)
|
|
2361
2817
|
for key, value in dic.items():
|
|
2362
2818
|
mylist.append(f"{key}: {value}")
|
|
2363
2819
|
|
|
2364
2820
|
return mylist
|
|
2365
2821
|
|
|
2366
|
-
def summaryDf(self):
|
|
2822
|
+
def summaryDf(self, N=None):
|
|
2367
2823
|
"""
|
|
2368
2824
|
Return summary as a dataframe.
|
|
2369
2825
|
"""
|
|
2370
|
-
return pd.DataFrame(self.summaryDic(), index=[self._name])
|
|
2826
|
+
return pd.DataFrame(self.summaryDic(N), index=[self._name])
|
|
2371
2827
|
|
|
2372
|
-
def summaryString(self):
|
|
2828
|
+
def summaryString(self, N=None):
|
|
2373
2829
|
"""
|
|
2374
2830
|
Return summary as a string.
|
|
2375
2831
|
"""
|
|
2376
2832
|
string = "Synopsis\n"
|
|
2377
|
-
dic = self.summaryDic()
|
|
2833
|
+
dic = self.summaryDic(N)
|
|
2378
2834
|
for key, value in dic.items():
|
|
2379
|
-
string += f"{key:>
|
|
2835
|
+
string += f"{key:>77}: {value}\n"
|
|
2380
2836
|
|
|
2381
2837
|
return string
|
|
2382
2838
|
|
|
2383
|
-
def summaryDic(self):
|
|
2839
|
+
def summaryDic(self, N=None):
|
|
2384
2840
|
"""
|
|
2385
2841
|
Return dictionary containing summary of values.
|
|
2386
2842
|
"""
|
|
2843
|
+
if N is None:
|
|
2844
|
+
N = self.N_n
|
|
2845
|
+
if not (0 < N <= self.N_n):
|
|
2846
|
+
raise ValueError(f"Value N={N} is out of reange")
|
|
2847
|
+
|
|
2387
2848
|
now = self.year_n[0]
|
|
2388
2849
|
dic = {}
|
|
2389
2850
|
# Results
|
|
2390
|
-
dic["
|
|
2851
|
+
dic["Case name"] = self._name
|
|
2391
2852
|
dic["Net yearly spending basis" + 26*" ."] = u.d(self.g_n[0] / self.xi_n[0])
|
|
2392
2853
|
dic[f"Net spending for year {now}"] = u.d(self.g_n[0])
|
|
2393
2854
|
dic[f"Net spending remaining in year {now}"] = u.d(self.g_n[0] * self.yearFracLeft)
|
|
2394
2855
|
|
|
2395
|
-
totSpending = np.sum(self.g_n, axis=0)
|
|
2396
|
-
totSpendingNow = np.sum(self.g_n / self.gamma_n[
|
|
2856
|
+
totSpending = np.sum(self.g_n[:N], axis=0)
|
|
2857
|
+
totSpendingNow = np.sum(self.g_n[:N] / self.gamma_n[:N], axis=0)
|
|
2397
2858
|
dic[" Total net spending"] = f"{u.d(totSpendingNow)}"
|
|
2398
2859
|
dic["[Total net spending]"] = f"{u.d(totSpending)}"
|
|
2399
2860
|
|
|
2400
|
-
totRoth = np.sum(self.x_in, axis=(0, 1))
|
|
2401
|
-
totRothNow = np.sum(np.sum(self.x_in, axis=0) / self.gamma_n[
|
|
2861
|
+
totRoth = np.sum(self.x_in[:, :N], axis=(0, 1))
|
|
2862
|
+
totRothNow = np.sum(np.sum(self.x_in[:, :N], axis=0) / self.gamma_n[:N], axis=0)
|
|
2402
2863
|
dic[" Total Roth conversions"] = f"{u.d(totRothNow)}"
|
|
2403
2864
|
dic["[Total Roth conversions]"] = f"{u.d(totRoth)}"
|
|
2404
2865
|
|
|
2405
|
-
taxPaid = np.sum(self.T_n, axis=0)
|
|
2406
|
-
taxPaidNow = np.sum(self.T_n / self.gamma_n[
|
|
2866
|
+
taxPaid = np.sum(self.T_n[:N], axis=0)
|
|
2867
|
+
taxPaidNow = np.sum(self.T_n[:N] / self.gamma_n[:N], axis=0)
|
|
2407
2868
|
dic[" Total tax paid on ordinary income"] = f"{u.d(taxPaidNow)}"
|
|
2408
2869
|
dic["[Total tax paid on ordinary income]"] = f"{u.d(taxPaid)}"
|
|
2409
2870
|
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[
|
|
2871
|
+
taxPaid = np.sum(self.T_tn[t, :N], axis=0)
|
|
2872
|
+
taxPaidNow = np.sum(self.T_tn[t, :N] / self.gamma_n[:N], axis=0)
|
|
2412
2873
|
if t >= len(tx.taxBracketNames):
|
|
2413
2874
|
tname = f"Bracket {t}"
|
|
2414
2875
|
else:
|
|
@@ -2416,33 +2877,33 @@ class Plan:
|
|
|
2416
2877
|
dic[f"» Subtotal in tax bracket {tname}"] = f"{u.d(taxPaidNow)}"
|
|
2417
2878
|
dic[f"» [Subtotal in tax bracket {tname}]"] = f"{u.d(taxPaid)}"
|
|
2418
2879
|
|
|
2419
|
-
penaltyPaid = np.sum(self.P_n, axis=0)
|
|
2420
|
-
penaltyPaidNow = np.sum(self.P_n / self.gamma_n[
|
|
2880
|
+
penaltyPaid = np.sum(self.P_n[:N], axis=0)
|
|
2881
|
+
penaltyPaidNow = np.sum(self.P_n[:N] / self.gamma_n[:N], axis=0)
|
|
2421
2882
|
dic["» Subtotal in early withdrawal penalty"] = f"{u.d(penaltyPaidNow)}"
|
|
2422
2883
|
dic["» [Subtotal in early withdrawal penalty]"] = f"{u.d(penaltyPaid)}"
|
|
2423
2884
|
|
|
2424
|
-
taxPaid = np.sum(self.U_n, axis=0)
|
|
2425
|
-
taxPaidNow = np.sum(self.U_n / self.gamma_n[
|
|
2885
|
+
taxPaid = np.sum(self.U_n[:N], axis=0)
|
|
2886
|
+
taxPaidNow = np.sum(self.U_n[:N] / self.gamma_n[:N], axis=0)
|
|
2426
2887
|
dic[" Total tax paid on gains and dividends"] = f"{u.d(taxPaidNow)}"
|
|
2427
2888
|
dic["[Total tax paid on gains and dividends]"] = f"{u.d(taxPaid)}"
|
|
2428
2889
|
|
|
2429
|
-
taxPaid = np.sum(self.J_n, axis=0)
|
|
2430
|
-
taxPaidNow = np.sum(self.J_n / self.gamma_n[
|
|
2890
|
+
taxPaid = np.sum(self.J_n[:N], axis=0)
|
|
2891
|
+
taxPaidNow = np.sum(self.J_n[:N] / self.gamma_n[:N], axis=0)
|
|
2431
2892
|
dic[" Total net investment income tax paid"] = f"{u.d(taxPaidNow)}"
|
|
2432
2893
|
dic["[Total net investment income tax paid]"] = f"{u.d(taxPaid)}"
|
|
2433
2894
|
|
|
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[
|
|
2895
|
+
taxPaid = np.sum(self.m_n[:N] + self.M_n[:N], axis=0)
|
|
2896
|
+
taxPaidNow = np.sum((self.m_n[:N] + self.M_n[:N]) / self.gamma_n[:N], axis=0)
|
|
2436
2897
|
dic[" Total Medicare premiums paid"] = f"{u.d(taxPaidNow)}"
|
|
2437
2898
|
dic["[Total Medicare premiums paid]"] = f"{u.d(taxPaid)}"
|
|
2438
2899
|
|
|
2439
|
-
totDebtPayments = np.sum(self.debt_payments_n, axis=0)
|
|
2900
|
+
totDebtPayments = np.sum(self.debt_payments_n[:N], axis=0)
|
|
2440
2901
|
if totDebtPayments > 0:
|
|
2441
|
-
totDebtPaymentsNow = np.sum(self.debt_payments_n / self.gamma_n[
|
|
2902
|
+
totDebtPaymentsNow = np.sum(self.debt_payments_n[:N] / self.gamma_n[:N], axis=0)
|
|
2442
2903
|
dic[" Total debt payments"] = f"{u.d(totDebtPaymentsNow)}"
|
|
2443
2904
|
dic["[Total debt payments]"] = f"{u.d(totDebtPayments)}"
|
|
2444
2905
|
|
|
2445
|
-
if self.N_i == 2 and self.n_d < self.N_n:
|
|
2906
|
+
if self.N_i == 2 and self.n_d < self.N_n and N == self.N_n:
|
|
2446
2907
|
p_j = self.partialEstate_j * (1 - self.phi_j)
|
|
2447
2908
|
p_j[1] *= 1 - self.nu
|
|
2448
2909
|
nx = self.n_d - 1
|
|
@@ -2453,58 +2914,66 @@ class Plan:
|
|
|
2453
2914
|
totSpousal = np.sum(q_j)
|
|
2454
2915
|
iname_s = self.inames[self.i_s]
|
|
2455
2916
|
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
|
-
|
|
2917
|
+
dic["Year of partial bequest"] = f"{ynx}"
|
|
2918
|
+
dic[f" Sum of spousal transfer to {iname_s}"] = f"{u.d(ynxNow*totSpousal)}"
|
|
2919
|
+
dic[f"[Sum of spousal transfer to {iname_s}]"] = f"{u.d(totSpousal)}"
|
|
2920
|
+
dic[f"» Spousal transfer to {iname_s} - taxable"] = f"{u.d(ynxNow*q_j[0])}"
|
|
2921
|
+
dic[f"» [Spousal transfer to {iname_s} - taxable]"] = f"{u.d(q_j[0])}"
|
|
2922
|
+
dic[f"» Spousal transfer to {iname_s} - tax-def"] = f"{u.d(ynxNow*q_j[1])}"
|
|
2923
|
+
dic[f"» [Spousal transfer to {iname_s} - tax-def]"] = f"{u.d(q_j[1])}"
|
|
2924
|
+
dic[f"» Spousal transfer to {iname_s} - tax-free"] = f"{u.d(ynxNow*q_j[2])}"
|
|
2925
|
+
dic[f"» [Spousal transfer to {iname_s} - tax-free]"] = f"{u.d(q_j[2])}"
|
|
2926
|
+
|
|
2927
|
+
dic[f" Sum of post-tax non-spousal bequest from {iname_d}"] = f"{u.d(ynxNow*totOthers)}"
|
|
2928
|
+
dic[f"[Sum of post-tax non-spousal bequest from {iname_d}]"] = f"{u.d(totOthers)}"
|
|
2929
|
+
dic[f"» Post-tax non-spousal bequest from {iname_d} - taxable"] = f"{u.d(ynxNow*p_j[0])}"
|
|
2930
|
+
dic[f"» [Post-tax non-spousal bequest from {iname_d} - taxable]"] = f"{u.d(p_j[0])}"
|
|
2931
|
+
dic[f"» Post-tax non-spousal bequest from {iname_d} - tax-def"] = f"{u.d(ynxNow*p_j[1])}"
|
|
2932
|
+
dic[f"» [Post-tax non-spousal bequest from {iname_d} - tax-def]"] = f"{u.d(p_j[1])}"
|
|
2933
|
+
dic[f"» Post-tax non-spousal bequest from {iname_d} - tax-free"] = f"{u.d(ynxNow*p_j[2])}"
|
|
2934
|
+
dic[f"» [Post-tax non-spousal bequest from {iname_d} - tax-free]"] = f"{u.d(p_j[2])}"
|
|
2935
|
+
|
|
2936
|
+
if N == self.N_n:
|
|
2937
|
+
estate = np.sum(self.b_ijn[:, :, self.N_n], axis=0)
|
|
2938
|
+
heirsTaxLiability = estate[1] * self.nu
|
|
2939
|
+
estate[1] *= 1 - self.nu
|
|
2940
|
+
endyear = self.year_n[-1]
|
|
2941
|
+
lyNow = 1./self.gamma_n[-1]
|
|
2942
|
+
# Add fixed assets bequest value (assets with yod past plan end)
|
|
2943
|
+
debts = self.remaining_debt_balance
|
|
2944
|
+
savingsEstate = np.sum(estate)
|
|
2945
|
+
totEstate = savingsEstate - debts + self.fixed_assets_bequest_value
|
|
2946
|
+
|
|
2947
|
+
dic["Year of final bequest"] = f"{endyear}"
|
|
2948
|
+
dic[" Total after-tax value of final bequest"] = f"{u.d(lyNow*totEstate)}"
|
|
2949
|
+
dic["» After-tax value of savings assets"] = f"{u.d(lyNow*savingsEstate)}"
|
|
2950
|
+
dic["» Fixed assets liquidated at end of plan"] = f"{u.d(lyNow*self.fixed_assets_bequest_value)}"
|
|
2951
|
+
dic["» With heirs assuming tax liability of"] = f"{u.d(lyNow*heirsTaxLiability)}"
|
|
2952
|
+
dic["» After paying remaining debts of"] = f"{u.d(lyNow*debts)}"
|
|
2953
|
+
|
|
2954
|
+
dic["[Total after-tax value of final bequest]"] = f"{u.d(totEstate)}"
|
|
2955
|
+
dic["[» After-tax value of savings assets]"] = f"{u.d(savingsEstate)}"
|
|
2956
|
+
dic["[» Fixed assets liquidated at end of plan]"] = f"{u.d(self.fixed_assets_bequest_value)}"
|
|
2957
|
+
dic["[» With heirs assuming tax liability of"] = f"{u.d(heirsTaxLiability)}"
|
|
2958
|
+
dic["[» After paying remaining debts of]"] = f"{u.d(debts)}"
|
|
2959
|
+
|
|
2960
|
+
dic["» Post-tax final bequest account value - taxable"] = f"{u.d(lyNow*estate[0])}"
|
|
2961
|
+
dic["» [Post-tax final bequest account value - taxable]"] = f"{u.d(estate[0])}"
|
|
2962
|
+
dic["» Post-tax final bequest account value - tax-def"] = f"{u.d(lyNow*estate[1])}"
|
|
2963
|
+
dic["» [Post-tax final bequest account value - tax-def]"] = f"{u.d(estate[1])}"
|
|
2964
|
+
dic["» Post-tax final bequest account value - tax-free"] = f"{u.d(lyNow*estate[2])}"
|
|
2965
|
+
dic["» [Post-tax final bequest account value - tax-free]"] = f"{u.d(estate[2])}"
|
|
2966
|
+
|
|
2967
|
+
dic["Case starting date"] = str(self.startDate)
|
|
2968
|
+
dic["Cumulative inflation factor at end of final year"] = f"{self.gamma_n[N]:.2f}"
|
|
2501
2969
|
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"] =
|
|
2970
|
+
dic[f"{self.inames[i]:>14}'s life horizon"] = f"{now} -> {now + self.horizons[i] - 1}"
|
|
2971
|
+
dic[f"{self.inames[i]:>14}'s years planned"] = f"{self.horizons[i]}"
|
|
2504
2972
|
|
|
2505
|
-
dic["
|
|
2973
|
+
dic["Case name"] = self._name
|
|
2506
2974
|
dic["Number of decision variables"] = str(self.A.nvars)
|
|
2507
2975
|
dic["Number of constraints"] = str(self.A.ncons)
|
|
2976
|
+
dic["Convergence"] = self.convergenceType
|
|
2508
2977
|
dic["Case executed on"] = str(self._timestamp)
|
|
2509
2978
|
|
|
2510
2979
|
return dic
|
|
@@ -2516,9 +2985,26 @@ class Plan:
|
|
|
2516
2985
|
A tag string can be set to add information to the title of the plot.
|
|
2517
2986
|
"""
|
|
2518
2987
|
if self.rateMethod in [None, "user", "historical average", "conservative"]:
|
|
2519
|
-
self.mylog.
|
|
2988
|
+
self.mylog.print(f"Warning: Cannot plot correlations for {self.rateMethod} rate method.")
|
|
2520
2989
|
return None
|
|
2521
2990
|
|
|
2991
|
+
# Check if rates are constant (all values are the same for each rate type)
|
|
2992
|
+
# This can happen with fixed rates
|
|
2993
|
+
if self.tau_kn is not None:
|
|
2994
|
+
# Check if all rates are constant (no variation)
|
|
2995
|
+
rates_are_constant = True
|
|
2996
|
+
for k in range(self.N_k):
|
|
2997
|
+
# Check if all values in this rate series are (approximately) the same
|
|
2998
|
+
rate_std = np.std(self.tau_kn[k])
|
|
2999
|
+
# Use a small threshold to account for floating point precision
|
|
3000
|
+
if rate_std > 1e-10: # If standard deviation is non-zero, rates vary
|
|
3001
|
+
rates_are_constant = False
|
|
3002
|
+
break
|
|
3003
|
+
|
|
3004
|
+
if rates_are_constant:
|
|
3005
|
+
self.mylog.print("Warning: Cannot plot correlations for constant rates (no variation in rate values).")
|
|
3006
|
+
return None
|
|
3007
|
+
|
|
2522
3008
|
fig = self._plotter.plot_rates_correlations(self._name, self.tau_kn, self.N_n, self.rateMethod,
|
|
2523
3009
|
self.rateFrm, self.rateTo, tag, shareRange)
|
|
2524
3010
|
|
|
@@ -2547,7 +3033,7 @@ class Plan:
|
|
|
2547
3033
|
A tag string can be set to add information to the title of the plot.
|
|
2548
3034
|
"""
|
|
2549
3035
|
if self.rateMethod is None:
|
|
2550
|
-
self.mylog.
|
|
3036
|
+
self.mylog.print("Warning: Rate method must be selected before plotting.")
|
|
2551
3037
|
return None
|
|
2552
3038
|
|
|
2553
3039
|
fig = self._plotter.plot_rates(self._name, self.tau_kn, self.year_n,
|
|
@@ -2566,7 +3052,7 @@ class Plan:
|
|
|
2566
3052
|
A tag string can be set to add information to the title of the plot.
|
|
2567
3053
|
"""
|
|
2568
3054
|
if self.xi_n is None:
|
|
2569
|
-
self.mylog.
|
|
3055
|
+
self.mylog.print("Warning: Profile must be selected before plotting.")
|
|
2570
3056
|
return None
|
|
2571
3057
|
title = self._name + "\nSpending Profile"
|
|
2572
3058
|
if tag:
|
|
@@ -2736,7 +3222,7 @@ class Plan:
|
|
|
2736
3222
|
self._plotter.jupyter_renderer(fig)
|
|
2737
3223
|
return None
|
|
2738
3224
|
|
|
2739
|
-
def saveWorkbook(self, overwrite=False, *, basename=None, saveToFile=True):
|
|
3225
|
+
def saveWorkbook(self, overwrite=False, *, basename=None, saveToFile=True, with_config="no"):
|
|
2740
3226
|
"""
|
|
2741
3227
|
Save instance in an Excel spreadsheet.
|
|
2742
3228
|
The first worksheet will contain income in the following
|
|
@@ -2745,10 +3231,10 @@ class Plan:
|
|
|
2745
3231
|
- taxable ordinary income
|
|
2746
3232
|
- taxable dividends
|
|
2747
3233
|
- tax bills (federal only, including IRMAA)
|
|
2748
|
-
for all the years for the time span of the
|
|
3234
|
+
for all the years for the time span of the case.
|
|
2749
3235
|
|
|
2750
3236
|
The second worksheet contains the rates
|
|
2751
|
-
used for the
|
|
3237
|
+
used for the case as follows:
|
|
2752
3238
|
- S&P 500
|
|
2753
3239
|
- Corporate Baa bonds
|
|
2754
3240
|
- Treasury notes (10y)
|
|
@@ -2770,7 +3256,30 @@ class Plan:
|
|
|
2770
3256
|
- tax-free account.
|
|
2771
3257
|
|
|
2772
3258
|
Last worksheet contains summary.
|
|
3259
|
+
|
|
3260
|
+
with_config controls whether to insert the current case configuration
|
|
3261
|
+
as a TOML sheet. Valid values are:
|
|
3262
|
+
- "no": do not include config
|
|
3263
|
+
- "first": insert config as the first sheet
|
|
3264
|
+
- "last": insert config as the last sheet
|
|
2773
3265
|
"""
|
|
3266
|
+
def add_config_sheet(position):
|
|
3267
|
+
if with_config == "no":
|
|
3268
|
+
return
|
|
3269
|
+
if with_config not in {"no", "first", "last"}:
|
|
3270
|
+
raise ValueError(f"Invalid with_config option '{with_config}'.")
|
|
3271
|
+
if position != with_config:
|
|
3272
|
+
return
|
|
3273
|
+
|
|
3274
|
+
from io import StringIO
|
|
3275
|
+
|
|
3276
|
+
config_buffer = StringIO()
|
|
3277
|
+
config.saveConfig(self, config_buffer, self.mylog)
|
|
3278
|
+
config_buffer.seek(0)
|
|
3279
|
+
|
|
3280
|
+
ws_config = wb.create_sheet(title="Config (.toml)", index=0 if position == "first" else None)
|
|
3281
|
+
for row_idx, line in enumerate(config_buffer.getvalue().splitlines(), start=1):
|
|
3282
|
+
ws_config.cell(row=row_idx, column=1, value=line)
|
|
2774
3283
|
|
|
2775
3284
|
def fillsheet(sheet, dic, datatype, op=lambda x: x):
|
|
2776
3285
|
rawData = {}
|
|
@@ -2790,6 +3299,7 @@ class Plan:
|
|
|
2790
3299
|
_formatSpreadsheet(ws, datatype)
|
|
2791
3300
|
|
|
2792
3301
|
wb = Workbook()
|
|
3302
|
+
add_config_sheet("first")
|
|
2793
3303
|
|
|
2794
3304
|
# Income.
|
|
2795
3305
|
ws = wb.active
|
|
@@ -2811,6 +3321,10 @@ class Plan:
|
|
|
2811
3321
|
"all pensions": np.sum(self.piBar_in, axis=0),
|
|
2812
3322
|
"all soc sec": np.sum(self.zetaBar_in, axis=0),
|
|
2813
3323
|
"all BTI's": np.sum(self.Lambda_in, axis=0),
|
|
3324
|
+
"FA ord inc": self.fixed_assets_ordinary_income_n,
|
|
3325
|
+
"FA cap gains": self.fixed_assets_capital_gains_n,
|
|
3326
|
+
"FA tax-free": self.fixed_assets_tax_free_n,
|
|
3327
|
+
"debt pmts": -self.debt_payments_n,
|
|
2814
3328
|
"all wdrwls": np.sum(self.w_ijn, axis=(0, 1)),
|
|
2815
3329
|
"all deposits": -np.sum(self.d_in, axis=0),
|
|
2816
3330
|
"ord taxes": -self.T_n - self.J_n,
|
|
@@ -2839,6 +3353,16 @@ class Plan:
|
|
|
2839
3353
|
ws = wb.create_sheet(sname)
|
|
2840
3354
|
fillsheet(ws, srcDic, "currency", op=lambda x: x[i]) # noqa: B023
|
|
2841
3355
|
|
|
3356
|
+
# Household sources (debts and fixed assets)
|
|
3357
|
+
householdSrcDic = {
|
|
3358
|
+
"FA ord inc": self.sources_in["FA ord inc"],
|
|
3359
|
+
"FA cap gains": self.sources_in["FA cap gains"],
|
|
3360
|
+
"FA tax-free": self.sources_in["FA tax-free"],
|
|
3361
|
+
"debt pmts": self.sources_in["debt pmts"],
|
|
3362
|
+
}
|
|
3363
|
+
ws = wb.create_sheet("Household Sources")
|
|
3364
|
+
fillsheet(ws, householdSrcDic, "currency", op=lambda x: x[0])
|
|
3365
|
+
|
|
2842
3366
|
# Account balances except final year.
|
|
2843
3367
|
accDic = {
|
|
2844
3368
|
"taxable bal": self.b_ijn[:, 0, :-1],
|
|
@@ -2933,6 +3457,7 @@ class Plan:
|
|
|
2933
3457
|
ws.append(row)
|
|
2934
3458
|
|
|
2935
3459
|
_formatSpreadsheet(ws, "summary")
|
|
3460
|
+
add_config_sheet("last")
|
|
2936
3461
|
|
|
2937
3462
|
if saveToFile:
|
|
2938
3463
|
if basename is None:
|
|
@@ -2952,11 +3477,29 @@ class Plan:
|
|
|
2952
3477
|
|
|
2953
3478
|
planData = {}
|
|
2954
3479
|
planData["year"] = self.year_n
|
|
3480
|
+
|
|
3481
|
+
# Income data
|
|
2955
3482
|
planData["net spending"] = self.g_n
|
|
2956
3483
|
planData["taxable ord. income"] = self.G_n
|
|
2957
3484
|
planData["taxable gains/divs"] = self.Q_n
|
|
2958
|
-
planData["
|
|
2959
|
-
|
|
3485
|
+
planData["Tax bills + Med."] = self.T_n + self.U_n + self.m_n + self.M_n + self.J_n
|
|
3486
|
+
|
|
3487
|
+
# Cash flow data (matching Cash Flow worksheet)
|
|
3488
|
+
planData["all wages"] = np.sum(self.omega_in, axis=0)
|
|
3489
|
+
planData["all pensions"] = np.sum(self.piBar_in, axis=0)
|
|
3490
|
+
planData["all soc sec"] = np.sum(self.zetaBar_in, axis=0)
|
|
3491
|
+
planData["all BTI's"] = np.sum(self.Lambda_in, axis=0)
|
|
3492
|
+
planData["FA ord inc"] = self.fixed_assets_ordinary_income_n
|
|
3493
|
+
planData["FA cap gains"] = self.fixed_assets_capital_gains_n
|
|
3494
|
+
planData["FA tax-free"] = self.fixed_assets_tax_free_n
|
|
3495
|
+
planData["debt pmts"] = -self.debt_payments_n
|
|
3496
|
+
planData["all wdrwls"] = np.sum(self.w_ijn, axis=(0, 1))
|
|
3497
|
+
planData["all deposits"] = -np.sum(self.d_in, axis=0)
|
|
3498
|
+
planData["ord taxes"] = -self.T_n - self.J_n
|
|
3499
|
+
planData["div taxes"] = -self.U_n
|
|
3500
|
+
planData["Medicare"] = -self.m_n - self.M_n
|
|
3501
|
+
|
|
3502
|
+
# Individual account data
|
|
2960
3503
|
for i in range(self.N_i):
|
|
2961
3504
|
planData[self.inames[i] + " txbl bal"] = self.b_ijn[i, 0, :-1]
|
|
2962
3505
|
planData[self.inames[i] + " txbl dep"] = self.d_in[i, :]
|
|
@@ -2971,6 +3514,7 @@ class Plan:
|
|
|
2971
3514
|
planData[self.inames[i] + " tax-free wdrwl"] = self.w_ijn[i, 2, :]
|
|
2972
3515
|
planData[self.inames[i] + " big-ticket items"] = self.Lambda_in[i, :]
|
|
2973
3516
|
|
|
3517
|
+
# Rates
|
|
2974
3518
|
ratesDic = {"S&P 500": 0, "Corporate Baa": 1, "T Bonds": 2, "inflation": 3}
|
|
2975
3519
|
for key in ratesDic:
|
|
2976
3520
|
planData[key] = self.tau_kn[ratesDic[key]]
|
|
@@ -3020,7 +3564,7 @@ def _saveWorkbook(wb, basename, overwrite, mylog):
|
|
|
3020
3564
|
mylog.print(f'File "{fname}" already exists.')
|
|
3021
3565
|
key = input("Overwrite? [Ny] ")
|
|
3022
3566
|
if key != "y":
|
|
3023
|
-
mylog.
|
|
3567
|
+
mylog.print("Skipping save and returning.")
|
|
3024
3568
|
return None
|
|
3025
3569
|
|
|
3026
3570
|
for _ in range(3):
|
|
@@ -3067,7 +3611,11 @@ def _formatSpreadsheet(ws, ftype):
|
|
|
3067
3611
|
# col[0].style = 'Title'
|
|
3068
3612
|
width = max(len(str(col[0].value)) + 4, 10)
|
|
3069
3613
|
ws.column_dimensions[column].width = width
|
|
3070
|
-
if column
|
|
3614
|
+
if column == "A":
|
|
3615
|
+
# Format year column as integer without commas
|
|
3616
|
+
for cell in col:
|
|
3617
|
+
cell.number_format = "0"
|
|
3618
|
+
else:
|
|
3071
3619
|
for cell in col:
|
|
3072
3620
|
cell.number_format = fstring
|
|
3073
3621
|
|
|
@@ -3098,8 +3646,8 @@ def _formatDebtsSheet(ws):
|
|
|
3098
3646
|
# Apply formatting based on column name
|
|
3099
3647
|
for col_letter, col_name in col_map.items():
|
|
3100
3648
|
if col_name in ["year", "term"]:
|
|
3101
|
-
# Integer format
|
|
3102
|
-
fstring = "
|
|
3649
|
+
# Integer format without commas
|
|
3650
|
+
fstring = "0"
|
|
3103
3651
|
elif col_name in ["rate"]:
|
|
3104
3652
|
# Number format (2 decimal places for percentages stored as numbers)
|
|
3105
3653
|
fstring = "#,##0.00"
|
|
@@ -3143,8 +3691,8 @@ def _formatFixedAssetsSheet(ws):
|
|
|
3143
3691
|
# Apply formatting based on column name
|
|
3144
3692
|
for col_letter, col_name in col_map.items():
|
|
3145
3693
|
if col_name in ["yod"]:
|
|
3146
|
-
# Integer format
|
|
3147
|
-
fstring = "
|
|
3694
|
+
# Integer format without commas
|
|
3695
|
+
fstring = "0"
|
|
3148
3696
|
elif col_name in ["rate", "commission"]:
|
|
3149
3697
|
# Number format (1 decimal place for percentages stored as numbers)
|
|
3150
3698
|
fstring = "#,##0.00"
|