owlplanner 2025.12.5__py3-none-any.whl → 2026.1.26__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- owlplanner/In Discussion #58, the case of Kim and Sam.md +307 -0
- owlplanner/__init__.py +20 -1
- owlplanner/abcapi.py +24 -23
- 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 -136
- owlplanner/data/__init__.py +21 -0
- owlplanner/data/awi.csv +75 -0
- owlplanner/data/bendpoints.csv +49 -0
- owlplanner/data/newawi.csv +75 -0
- owlplanner/data/rates.csv +99 -98
- owlplanner/debts.py +315 -0
- owlplanner/fixedassets.py +288 -0
- owlplanner/mylogging.py +157 -25
- owlplanner/plan.py +1044 -332
- 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 +33 -10
- owlplanner/progress.py +66 -9
- owlplanner/rates.py +366 -361
- owlplanner/socialsecurity.py +142 -22
- owlplanner/tax2026.py +170 -57
- owlplanner/timelists.py +316 -32
- owlplanner/utils.py +204 -5
- owlplanner/version.py +20 -1
- {owlplanner-2025.12.5.dist-info → owlplanner-2026.1.26.dist-info}/METADATA +50 -158
- owlplanner-2026.1.26.dist-info/RECORD +36 -0
- owlplanner-2026.1.26.dist-info/entry_points.txt +2 -0
- owlplanner-2026.1.26.dist-info/licenses/AUTHORS +15 -0
- owlplanner/tax2025.py +0 -339
- owlplanner-2025.12.5.dist-info/RECORD +0 -24
- {owlplanner-2025.12.5.dist-info → owlplanner-2026.1.26.dist-info}/WHEEL +0 -0
- {owlplanner-2025.12.5.dist-info → owlplanner-2026.1.26.dist-info}/licenses/LICENSE +0 -0
owlplanner/plan.py
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
"""
|
|
2
|
+
Core retirement planning module using linear programming optimization.
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
A retirement planner using linear programming optimization.
|
|
7
|
-
|
|
8
|
-
See companion PDF document for an explanation of the underlying
|
|
4
|
+
This module implements the main Plan class and optimization logic for retirement
|
|
5
|
+
financial planning. See companion PDF document for an explanation of the underlying
|
|
9
6
|
mathematical model and a description of all variables and parameters.
|
|
10
7
|
|
|
11
|
-
Copyright
|
|
8
|
+
Copyright (C) 2025-2026 The Owlplanner Authors
|
|
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,19 +29,33 @@ 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
|
|
31
39
|
from . import timelists
|
|
32
40
|
from . import socialsecurity as socsec
|
|
41
|
+
from . import debts as debts
|
|
42
|
+
from . import fixedassets as fxasst
|
|
33
43
|
from . import mylogging as log
|
|
34
44
|
from . import progress
|
|
35
45
|
from .plotting.factory import PlotFactory
|
|
36
46
|
|
|
37
47
|
|
|
48
|
+
# Default values
|
|
49
|
+
BIGM_XOR = 5e7 # 100 times large withdrawals or conversions
|
|
50
|
+
BIGM_IRMAA = 5e7 # 100 times large MAGI
|
|
51
|
+
GAP = 1e-4
|
|
52
|
+
MILP_GAP = 10 * GAP
|
|
53
|
+
MAX_ITERATIONS = 29
|
|
54
|
+
ABS_TOL = 20
|
|
55
|
+
REL_TOL = 1e-6
|
|
56
|
+
TIME_LIMIT = 900
|
|
57
|
+
|
|
58
|
+
|
|
38
59
|
def _genGamma_n(tau):
|
|
39
60
|
"""
|
|
40
61
|
Utility function to generate a cumulative inflation multiplier
|
|
@@ -83,7 +104,7 @@ def _genXi_n(profile, fraction, n_d, N_n, a, b, c):
|
|
|
83
104
|
xi[n_d:] *= fraction
|
|
84
105
|
xi *= neutralSum / xi.sum()
|
|
85
106
|
else:
|
|
86
|
-
raise ValueError(f"Unknown profile type {profile}.")
|
|
107
|
+
raise ValueError(f"Unknown profile type '{profile}'.")
|
|
87
108
|
|
|
88
109
|
return xi
|
|
89
110
|
|
|
@@ -125,20 +146,18 @@ def _q4(C, l1, l2, l3, l4, N1, N2, N3, N4):
|
|
|
125
146
|
|
|
126
147
|
def clone(plan, newname=None, *, verbose=True, logstreams=None):
|
|
127
148
|
"""
|
|
128
|
-
Return an almost identical copy of plan: only the name of the
|
|
149
|
+
Return an almost identical copy of plan: only the name of the case
|
|
129
150
|
has been modified and appended the string '(copy)',
|
|
130
151
|
unless a new name is provided as an argument.
|
|
131
152
|
"""
|
|
132
153
|
import copy
|
|
133
154
|
|
|
134
|
-
#
|
|
135
|
-
mylogger = plan.logger()
|
|
136
|
-
plan.setLogger(None)
|
|
155
|
+
# logger __deepcopy__ sets the logstreams of new logger to None
|
|
137
156
|
newplan = copy.deepcopy(plan)
|
|
138
|
-
plan.setLogger(mylogger)
|
|
139
157
|
|
|
140
158
|
if logstreams is None:
|
|
141
|
-
|
|
159
|
+
original_logger = plan.logger()
|
|
160
|
+
newplan.setLogger(original_logger)
|
|
142
161
|
else:
|
|
143
162
|
newplan.setLogstreams(verbose, logstreams)
|
|
144
163
|
|
|
@@ -162,7 +181,7 @@ def _checkCaseStatus(func):
|
|
|
162
181
|
@wraps(func)
|
|
163
182
|
def wrapper(self, *args, **kwargs):
|
|
164
183
|
if self.caseStatus != "solved":
|
|
165
|
-
self.mylog.
|
|
184
|
+
self.mylog.print(f"Preventing to run method {func.__name__}() while case is {self.caseStatus}.")
|
|
166
185
|
return None
|
|
167
186
|
return func(self, *args, **kwargs)
|
|
168
187
|
|
|
@@ -179,11 +198,11 @@ def _checkConfiguration(func):
|
|
|
179
198
|
def wrapper(self, *args, **kwargs):
|
|
180
199
|
if self.xi_n is None:
|
|
181
200
|
msg = f"You must define a spending profile before calling {func.__name__}()."
|
|
182
|
-
self.mylog.
|
|
201
|
+
self.mylog.print(msg)
|
|
183
202
|
raise RuntimeError(msg)
|
|
184
203
|
if self.alpha_ijkn is None:
|
|
185
204
|
msg = f"You must define an allocation profile before calling {func.__name__}()."
|
|
186
|
-
self.mylog.
|
|
205
|
+
self.mylog.print(msg)
|
|
187
206
|
raise RuntimeError(msg)
|
|
188
207
|
return func(self, *args, **kwargs)
|
|
189
208
|
|
|
@@ -202,28 +221,43 @@ def _timer(func):
|
|
|
202
221
|
result = func(self, *args, **kwargs)
|
|
203
222
|
pt = time.process_time() - pt0
|
|
204
223
|
rt = time.time() - rt0
|
|
205
|
-
self.mylog.vprint(f"CPU time used: {int(pt / 60)}m{pt % 60:.1f}s, Wall time: {int(rt / 60)}m{rt % 60:.1f}s."
|
|
224
|
+
self.mylog.vprint(f"CPU time used: {int(pt / 60)}m{pt % 60:.1f}s, Wall time: {int(rt / 60)}m{rt % 60:.1f}s.",
|
|
225
|
+
tag="INFO")
|
|
206
226
|
return result
|
|
207
227
|
|
|
208
228
|
return wrapper
|
|
209
229
|
|
|
210
230
|
|
|
211
|
-
class Plan
|
|
231
|
+
class Plan:
|
|
212
232
|
"""
|
|
213
233
|
This is the main class of the Owl Project.
|
|
214
234
|
"""
|
|
235
|
+
# Class-level counter for unique Plan IDs
|
|
236
|
+
_id_counter = 0
|
|
237
|
+
|
|
238
|
+
@classmethod
|
|
239
|
+
def get_next_id(cls):
|
|
240
|
+
cls._id_counter += 1
|
|
241
|
+
return cls._id_counter
|
|
215
242
|
|
|
216
|
-
|
|
243
|
+
@classmethod
|
|
244
|
+
def get_current_id(cls):
|
|
245
|
+
return cls._id_counter
|
|
246
|
+
|
|
247
|
+
def __init__(self, inames, dobs, expectancy, name, *, verbose=False, logstreams=None):
|
|
217
248
|
"""
|
|
218
249
|
Constructor requires three lists: the first
|
|
219
250
|
one contains the name(s) of the individual(s),
|
|
220
251
|
the second one is the year of birth of each individual,
|
|
221
252
|
and the third the life expectancy. Last argument is a name for
|
|
222
|
-
the
|
|
253
|
+
the case.
|
|
223
254
|
"""
|
|
224
255
|
if name == "":
|
|
225
256
|
raise ValueError("Plan must have a name")
|
|
226
257
|
|
|
258
|
+
# Generate unique ID for this Plan instance using the class method
|
|
259
|
+
self._id = Plan.get_next_id()
|
|
260
|
+
|
|
227
261
|
self._name = name
|
|
228
262
|
self.setLogstreams(verbose, logstreams)
|
|
229
263
|
|
|
@@ -232,8 +266,8 @@ class Plan(object):
|
|
|
232
266
|
self.N_q = 6
|
|
233
267
|
self.N_j = 3
|
|
234
268
|
self.N_k = 4
|
|
235
|
-
#
|
|
236
|
-
self.N_zx =
|
|
269
|
+
# 4 binary variables for exclusions.
|
|
270
|
+
self.N_zx = 4
|
|
237
271
|
|
|
238
272
|
# Default interpolation parameters for allocation ratios.
|
|
239
273
|
self.interpMethod = "linear"
|
|
@@ -249,13 +283,9 @@ class Plan(object):
|
|
|
249
283
|
# self.setPlotBackend("matplotlib")
|
|
250
284
|
self.setPlotBackend("plotly")
|
|
251
285
|
|
|
252
|
-
self.N_i = len(
|
|
286
|
+
self.N_i = len(dobs)
|
|
253
287
|
if not (0 <= self.N_i <= 2):
|
|
254
288
|
raise ValueError(f"Cannot support {self.N_i} individuals.")
|
|
255
|
-
if len(mobs) != len(yobs):
|
|
256
|
-
raise ValueError("Months and years arrays should have same length.")
|
|
257
|
-
if min(mobs) < 1 or max(mobs) > 12:
|
|
258
|
-
raise ValueError("Months must be between 1 and 12.")
|
|
259
289
|
if self.N_i != len(expectancy):
|
|
260
290
|
raise ValueError(f"Expectancy must have {self.N_i} entries.")
|
|
261
291
|
if self.N_i != len(inames):
|
|
@@ -268,16 +298,19 @@ class Plan(object):
|
|
|
268
298
|
# Default year OBBBA speculated to be expired and replaced by pre-TCJA rates.
|
|
269
299
|
self.yOBBBA = 2032
|
|
270
300
|
self.inames = inames
|
|
271
|
-
self.yobs
|
|
272
|
-
self.
|
|
301
|
+
self.yobs, self.mobs, self.tobs = u.parseDobs(dobs)
|
|
302
|
+
self.dobs = dobs
|
|
273
303
|
self.expectancy = np.array(expectancy, dtype=np.int32)
|
|
274
304
|
|
|
275
305
|
# Reference time is starting date in the current year and all passings are assumed at the end.
|
|
276
306
|
thisyear = date.today().year
|
|
277
307
|
self.horizons = self.yobs + self.expectancy - thisyear + 1
|
|
278
308
|
self.N_n = np.max(self.horizons)
|
|
309
|
+
if self.N_n <= 2:
|
|
310
|
+
raise ValueError(f"Plan needs more than {self.N_n} years.")
|
|
311
|
+
|
|
279
312
|
self.year_n = np.linspace(thisyear, thisyear + self.N_n - 1, self.N_n, dtype=np.int32)
|
|
280
|
-
# Year index in the
|
|
313
|
+
# Year index in the case (if any) where individuals turn 59. For 10% withdrawal penalty.
|
|
281
314
|
self.n59 = 59 - thisyear + self.yobs
|
|
282
315
|
self.n59[self.n59 < 0] = 0
|
|
283
316
|
# Handle passing of one spouse before the other.
|
|
@@ -292,9 +325,11 @@ class Plan(object):
|
|
|
292
325
|
|
|
293
326
|
# Default parameters:
|
|
294
327
|
self.psi_n = np.zeros(self.N_n) # Long-term income tax rate on capital gains (decimal)
|
|
295
|
-
|
|
296
|
-
self.
|
|
297
|
-
self.
|
|
328
|
+
# Fraction of social security benefits that is taxed (fixed at 85% for now).
|
|
329
|
+
self.Psi_n = np.ones(self.N_n) * 0.85
|
|
330
|
+
self.chi = 0.60 # Survivor fraction
|
|
331
|
+
self.mu = 0.0172 # Dividend rate (decimal)
|
|
332
|
+
self.nu = 0.300 # Heirs tax rate (decimal)
|
|
298
333
|
self.eta = (self.N_i - 1) / 2 # Spousal deposit ratio (0 or .5)
|
|
299
334
|
self.phi_j = np.array([1, 1, 1]) # Fractions left to other spouse at death
|
|
300
335
|
self.smileDip = 15 # Percent to reduce smile profile
|
|
@@ -316,9 +351,23 @@ class Plan(object):
|
|
|
316
351
|
self.myRothX_in = np.zeros((self.N_i, self.N_n + 5))
|
|
317
352
|
self.kappa_ijn = np.zeros((self.N_i, self.N_j, self.N_n + 5))
|
|
318
353
|
|
|
354
|
+
# Debt payments array (length N_n)
|
|
355
|
+
self.debt_payments_n = np.zeros(self.N_n)
|
|
356
|
+
|
|
357
|
+
# Fixed assets arrays (length N_n)
|
|
358
|
+
self.fixed_assets_tax_free_n = np.zeros(self.N_n)
|
|
359
|
+
self.fixed_assets_ordinary_income_n = np.zeros(self.N_n)
|
|
360
|
+
self.fixed_assets_capital_gains_n = np.zeros(self.N_n)
|
|
361
|
+
# Fixed assets bequest value (assets with yod past plan end)
|
|
362
|
+
self.fixed_assets_bequest_value = 0.0
|
|
363
|
+
|
|
364
|
+
# Remaining debt balance at end of plan
|
|
365
|
+
self.remaining_debt_balance = 0.0
|
|
366
|
+
|
|
319
367
|
# Previous 2 years of MAGI needed for Medicare.
|
|
320
368
|
self.prevMAGI = np.zeros((2))
|
|
321
369
|
self.MAGI_n = np.zeros(self.N_n)
|
|
370
|
+
self.solverOptions = {}
|
|
322
371
|
|
|
323
372
|
# Init current balances to none.
|
|
324
373
|
self.beta_ij = None
|
|
@@ -329,21 +378,26 @@ class Plan(object):
|
|
|
329
378
|
|
|
330
379
|
# Scenario starts at the beginning of this year and ends at the end of the last year.
|
|
331
380
|
s = ("", "s")[self.N_i - 1]
|
|
332
|
-
self.mylog.vprint(f"Preparing scenario of {self.N_n} years for {self.N_i} individual{s}.")
|
|
381
|
+
self.mylog.vprint(f"Preparing scenario '{self._id}' of {self.N_n} years for {self.N_i} individual{s}.")
|
|
333
382
|
for i in range(self.N_i):
|
|
334
383
|
endyear = thisyear + self.horizons[i] - 1
|
|
335
384
|
self.mylog.vprint(f"{self.inames[i]:>14}: life horizon from {thisyear} -> {endyear}.")
|
|
336
385
|
|
|
337
386
|
# Prepare RMD time series.
|
|
338
|
-
self.rho_in = tx.rho_in(self.yobs, self.N_n)
|
|
387
|
+
self.rho_in = tx.rho_in(self.yobs, self.expectancy, self.N_n)
|
|
339
388
|
|
|
340
389
|
# Initialize guardrails to ensure proper configuration.
|
|
341
390
|
self._adjustedParameters = False
|
|
342
391
|
self.timeListsFileName = "None"
|
|
343
392
|
self.timeLists = {}
|
|
393
|
+
self.houseLists = {}
|
|
344
394
|
self.zeroContributions()
|
|
345
395
|
self.caseStatus = "unsolved"
|
|
396
|
+
# "monotonic", "oscillatory", "max iteration", or "undefined" - how solution was obtained
|
|
397
|
+
self.convergenceType = "undefined"
|
|
346
398
|
self.rateMethod = None
|
|
399
|
+
self.reproducibleRates = False
|
|
400
|
+
self.rateSeed = None
|
|
347
401
|
|
|
348
402
|
self.ARCoord = None
|
|
349
403
|
self.objective = "unknown"
|
|
@@ -359,7 +413,7 @@ class Plan(object):
|
|
|
359
413
|
|
|
360
414
|
def setLogstreams(self, verbose, logstreams):
|
|
361
415
|
self.mylog = log.Logger(verbose, logstreams)
|
|
362
|
-
|
|
416
|
+
self.mylog.vprint(f"Setting logger with logstreams {logstreams}.")
|
|
363
417
|
|
|
364
418
|
def logger(self):
|
|
365
419
|
return self.mylog
|
|
@@ -374,7 +428,7 @@ class Plan(object):
|
|
|
374
428
|
|
|
375
429
|
def _setStartingDate(self, mydate):
|
|
376
430
|
"""
|
|
377
|
-
Set the date when the
|
|
431
|
+
Set the date when the case starts in the current year.
|
|
378
432
|
This is mostly for reproducibility purposes and back projecting known balances to Jan 1st.
|
|
379
433
|
String format of mydate is 'MM/DD', 'MM-DD', 'YYYY-MM-DD', or 'YYYY/MM/DD'. Year is ignored.
|
|
380
434
|
"""
|
|
@@ -420,16 +474,16 @@ class Plan(object):
|
|
|
420
474
|
|
|
421
475
|
def rename(self, newname):
|
|
422
476
|
"""
|
|
423
|
-
Override name of the
|
|
477
|
+
Override name of the case. Case name is used
|
|
424
478
|
to distinguish graph outputs and as base name for
|
|
425
479
|
saving configurations and workbooks.
|
|
426
480
|
"""
|
|
427
|
-
self.mylog.vprint(f"Renaming
|
|
481
|
+
self.mylog.vprint(f"Renaming case '{self._name}' -> '{newname}'.")
|
|
428
482
|
self._name = newname
|
|
429
483
|
|
|
430
484
|
def setDescription(self, description):
|
|
431
485
|
"""
|
|
432
|
-
Set a text description of the
|
|
486
|
+
Set a text description of the case.
|
|
433
487
|
"""
|
|
434
488
|
self._description = description
|
|
435
489
|
|
|
@@ -446,7 +500,7 @@ class Plan(object):
|
|
|
446
500
|
if not (0 <= eta <= 1):
|
|
447
501
|
raise ValueError("Fraction must be between 0 and 1.")
|
|
448
502
|
if self.N_i != 2:
|
|
449
|
-
self.mylog.
|
|
503
|
+
self.mylog.print("Deposit fraction can only be 0 for single individuals.")
|
|
450
504
|
eta = 0
|
|
451
505
|
else:
|
|
452
506
|
self.mylog.vprint(f"Setting spousal surplus deposit fraction to {eta:.1f}.")
|
|
@@ -459,7 +513,7 @@ class Plan(object):
|
|
|
459
513
|
"""
|
|
460
514
|
|
|
461
515
|
self.defaultPlots = self._checkValueType(value)
|
|
462
|
-
self.mylog.vprint(f"Setting plots default value to {value}.")
|
|
516
|
+
self.mylog.vprint(f"Setting plots default value to '{value}'.")
|
|
463
517
|
|
|
464
518
|
def setPlotBackend(self, backend: str):
|
|
465
519
|
"""
|
|
@@ -467,12 +521,12 @@ class Plan(object):
|
|
|
467
521
|
"""
|
|
468
522
|
|
|
469
523
|
if backend not in ("matplotlib", "plotly"):
|
|
470
|
-
raise ValueError(f"Backend {backend} not a valid option.")
|
|
524
|
+
raise ValueError(f"Backend '{backend}' not a valid option.")
|
|
471
525
|
|
|
472
526
|
if backend != self._plotterName:
|
|
473
527
|
self._plotter = PlotFactory.createBackend(backend)
|
|
474
528
|
self._plotterName = backend
|
|
475
|
-
self.mylog.vprint(f"Setting plotting backend to {backend}.")
|
|
529
|
+
self.mylog.vprint(f"Setting plotting backend to '{backend}'.")
|
|
476
530
|
|
|
477
531
|
def setDividendRate(self, mu):
|
|
478
532
|
"""
|
|
@@ -510,8 +564,8 @@ class Plan(object):
|
|
|
510
564
|
self.caseStatus = "modified"
|
|
511
565
|
|
|
512
566
|
if np.any(self.phi_j != 1):
|
|
513
|
-
self.mylog.
|
|
514
|
-
self.mylog.
|
|
567
|
+
self.mylog.print("Consider changing spousal deposit fraction for better convergence.")
|
|
568
|
+
self.mylog.print(f"\tRecommended: setSpousalDepositFraction({self.i_d}.)")
|
|
515
569
|
|
|
516
570
|
def setHeirsTaxRate(self, nu):
|
|
517
571
|
"""
|
|
@@ -569,6 +623,9 @@ class Plan(object):
|
|
|
569
623
|
def setSocialSecurity(self, pias, ages):
|
|
570
624
|
"""
|
|
571
625
|
Set value of social security for each individual and claiming age.
|
|
626
|
+
|
|
627
|
+
Note: Social Security benefits are paid in arrears (one month after eligibility).
|
|
628
|
+
The zeta_in array represents when checks actually arrive, not when eligibility starts.
|
|
572
629
|
"""
|
|
573
630
|
if len(pias) != self.N_i:
|
|
574
631
|
raise ValueError(f"Principal Insurance Amount must have {self.N_i} entries.")
|
|
@@ -582,40 +639,52 @@ class Plan(object):
|
|
|
582
639
|
fras = socsec.getFRAs(self.yobs)
|
|
583
640
|
spousalBenefits = socsec.getSpousalBenefits(pias)
|
|
584
641
|
|
|
585
|
-
self.mylog.vprint(
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
)
|
|
589
|
-
self.mylog.vprint(
|
|
590
|
-
"Benefits requested to start at age(s)", [ages[i] for i in range(self.N_i)],
|
|
591
|
-
)
|
|
642
|
+
self.mylog.vprint("SS monthly PIAs set to", [u.d(pias[i]) for i in range(self.N_i)])
|
|
643
|
+
self.mylog.vprint("SS FRAs(s)", [fras[i] for i in range(self.N_i)])
|
|
644
|
+
self.mylog.vprint("SS benefits claimed at age(s)", [ages[i] for i in range(self.N_i)])
|
|
592
645
|
|
|
593
646
|
thisyear = date.today().year
|
|
594
647
|
self.zeta_in = np.zeros((self.N_i, self.N_n))
|
|
595
648
|
for i in range(self.N_i):
|
|
649
|
+
# Check if age is in bound.
|
|
650
|
+
bornOnFirstDays = (self.tobs[i] <= 2)
|
|
651
|
+
|
|
652
|
+
eligible = 62 if bornOnFirstDays else 62 + 1/12
|
|
653
|
+
if ages[i] < eligible:
|
|
654
|
+
self.mylog.print(f"Resetting SS claiming age of {self.inames[i]} to {eligible}.")
|
|
655
|
+
ages[i] = eligible
|
|
656
|
+
|
|
596
657
|
# Check if claim age added to birth month falls next year.
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
658
|
+
# janage is age with reference to Jan 1 of yob when eligibility starts.
|
|
659
|
+
janage = ages[i] + (self.mobs[i] - 1)/12
|
|
660
|
+
|
|
661
|
+
# Social Security benefits are paid in arrears (one month after eligibility).
|
|
662
|
+
# Calculate when payments actually start (checks arrive).
|
|
663
|
+
paymentJanage = janage + 1/12
|
|
664
|
+
paymentIage = int(paymentJanage)
|
|
665
|
+
paymentRealns = self.yobs[i] + paymentIage - thisyear
|
|
666
|
+
ns = max(0, paymentRealns)
|
|
601
667
|
nd = self.horizons[i]
|
|
602
668
|
self.zeta_in[i, ns:nd] = pias[i]
|
|
603
|
-
# Reduce starting year due to month offset. If
|
|
604
|
-
if
|
|
605
|
-
self.zeta_in[i, ns] *= 1 - (
|
|
669
|
+
# Reduce starting year due to month offset. If paymentRealns < 0, this has happened already.
|
|
670
|
+
if paymentRealns >= 0:
|
|
671
|
+
self.zeta_in[i, ns] *= 1 - (paymentJanage % 1.)
|
|
672
|
+
|
|
606
673
|
# Increase/decrease PIA due to claiming age.
|
|
607
|
-
self.zeta_in[i, :] *= socsec.getSelfFactor(fras[i], ages[i])
|
|
674
|
+
self.zeta_in[i, :] *= socsec.getSelfFactor(fras[i], ages[i], bornOnFirstDays)
|
|
608
675
|
|
|
609
676
|
# Add spousal benefits if applicable.
|
|
610
677
|
if self.N_i == 2 and spousalBenefits[i] > 0:
|
|
611
|
-
# The latest of the two spouses to claim.
|
|
678
|
+
# The latest of the two spouses to claim (eligibility start).
|
|
612
679
|
claimYear = max(self.yobs + (self.mobs - 1)/12 + ages)
|
|
613
680
|
claimAge = claimYear - self.yobs[i] - (self.mobs[i] - 1)/12
|
|
614
|
-
|
|
615
|
-
|
|
681
|
+
# Spousal benefits are also paid in arrears (one month after eligibility).
|
|
682
|
+
paymentClaimYear = claimYear + 1/12
|
|
683
|
+
ns2 = max(0, int(paymentClaimYear) - thisyear)
|
|
684
|
+
spousalFactor = socsec.getSpousalFactor(fras[i], claimAge, bornOnFirstDays)
|
|
616
685
|
self.zeta_in[i, ns2:nd] += spousalBenefits[i] * spousalFactor
|
|
617
686
|
# Reduce first year of benefit by month offset.
|
|
618
|
-
self.zeta_in[i, ns2] -= spousalBenefits[i] * spousalFactor * (
|
|
687
|
+
self.zeta_in[i, ns2] -= spousalBenefits[i] * spousalFactor * (paymentClaimYear % 1.)
|
|
619
688
|
|
|
620
689
|
# Switch survivor to spousal survivor benefits.
|
|
621
690
|
# Assumes both deceased and survivor already have claimed last year before passing (at n_d - 1).
|
|
@@ -659,7 +728,36 @@ class Plan(object):
|
|
|
659
728
|
self.smileDelay = delay
|
|
660
729
|
self.caseStatus = "modified"
|
|
661
730
|
|
|
662
|
-
def
|
|
731
|
+
def setReproducible(self, reproducible, seed=None):
|
|
732
|
+
"""
|
|
733
|
+
Set whether rates should be reproducible for stochastic methods.
|
|
734
|
+
This should be called before setting rates. It only sets configuration
|
|
735
|
+
and does not regenerate existing rates.
|
|
736
|
+
|
|
737
|
+
Args:
|
|
738
|
+
reproducible: Boolean indicating if rates should be reproducible.
|
|
739
|
+
seed: Optional seed value. If None and reproducible is True,
|
|
740
|
+
generates a new seed from current time. If None and
|
|
741
|
+
reproducible is False, generates a seed but won't reuse it.
|
|
742
|
+
"""
|
|
743
|
+
self.reproducibleRates = bool(reproducible)
|
|
744
|
+
if reproducible:
|
|
745
|
+
if seed is None:
|
|
746
|
+
if self.rateSeed is not None:
|
|
747
|
+
# Reuse existing seed if available
|
|
748
|
+
seed = self.rateSeed
|
|
749
|
+
else:
|
|
750
|
+
# Generate new seed from current time
|
|
751
|
+
seed = int(time.time() * 1000000) # Use microseconds
|
|
752
|
+
else:
|
|
753
|
+
seed = int(seed)
|
|
754
|
+
self.rateSeed = seed
|
|
755
|
+
else:
|
|
756
|
+
# For non-reproducible rates, clear the seed
|
|
757
|
+
# setRates() will generate a new seed each time it's called
|
|
758
|
+
self.rateSeed = None
|
|
759
|
+
|
|
760
|
+
def setRates(self, method, frm=None, to=None, values=None, stdev=None, corr=None, override_reproducible=False):
|
|
663
761
|
"""
|
|
664
762
|
Generate rates for return and inflation based on the method and
|
|
665
763
|
years selected. Note that last bound is included.
|
|
@@ -674,28 +772,77 @@ class Plan(object):
|
|
|
674
772
|
must be provided, and optionally an ending year.
|
|
675
773
|
|
|
676
774
|
Valid year range is from 1928 to last year.
|
|
775
|
+
|
|
776
|
+
Note: For stochastic methods, setReproducible() should be called before
|
|
777
|
+
setRates() to set the reproducibility flag and seed. If not called,
|
|
778
|
+
defaults to non-reproducible behavior.
|
|
779
|
+
|
|
780
|
+
Args:
|
|
781
|
+
override_reproducible: If True, override reproducibility setting and always generate new rates.
|
|
782
|
+
Used by Monte-Carlo runs to ensure different rates each time.
|
|
677
783
|
"""
|
|
678
784
|
if frm is not None and to is None:
|
|
679
785
|
to = frm + self.N_n - 1 # 'to' is inclusive.
|
|
680
786
|
|
|
681
|
-
|
|
787
|
+
# Handle seed for stochastic methods
|
|
788
|
+
if method in ["stochastic", "histochastic"]:
|
|
789
|
+
if self.reproducibleRates and not override_reproducible:
|
|
790
|
+
if self.rateSeed is None:
|
|
791
|
+
raise RuntimeError("Config error: reproducibleRates is True but rateSeed is None.")
|
|
792
|
+
|
|
793
|
+
seed = self.rateSeed
|
|
794
|
+
else:
|
|
795
|
+
# For non-reproducible rates or when overriding reproducibility, generate a new seed from time.
|
|
796
|
+
# This ensures we always have a seed stored in config, but it won't be reused.
|
|
797
|
+
seed = int(time.time() * 1000000)
|
|
798
|
+
if not override_reproducible:
|
|
799
|
+
self.rateSeed = seed
|
|
800
|
+
else:
|
|
801
|
+
# For non-stochastic methods, seed is not used but we preserve it
|
|
802
|
+
# so that if user switches back to stochastic, their reproducibility settings are maintained.
|
|
803
|
+
seed = None
|
|
804
|
+
|
|
805
|
+
dr = rates.Rates(self.mylog, seed=seed)
|
|
682
806
|
self.rateValues, self.rateStdev, self.rateCorr = dr.setMethod(method, frm, to, values, stdev, corr)
|
|
683
807
|
self.rateMethod = method
|
|
684
808
|
self.rateFrm = frm
|
|
685
809
|
self.rateTo = to
|
|
686
810
|
self.tau_kn = dr.genSeries(self.N_n).transpose()
|
|
687
|
-
self.mylog.vprint(f"Generating rate series of {len(self.tau_kn[0])} years using {method} method.")
|
|
811
|
+
self.mylog.vprint(f"Generating rate series of {len(self.tau_kn[0])} years using '{method}' method.")
|
|
812
|
+
if method in ["stochastic", "histochastic"]:
|
|
813
|
+
repro_status = "reproducible" if self.reproducibleRates else "non-reproducible"
|
|
814
|
+
self.mylog.print(f"Using seed {seed} for {repro_status} rates.")
|
|
688
815
|
|
|
689
816
|
# Once rates are selected, (re)build cumulative inflation multipliers.
|
|
690
817
|
self.gamma_n = _genGamma_n(self.tau_kn)
|
|
691
818
|
self._adjustedParameters = False
|
|
692
819
|
self.caseStatus = "modified"
|
|
693
820
|
|
|
694
|
-
def regenRates(self):
|
|
821
|
+
def regenRates(self, override_reproducible=False):
|
|
695
822
|
"""
|
|
696
823
|
Regenerate the rates using the arguments specified during last setRates() call.
|
|
697
824
|
This method is used to regenerate stochastic time series.
|
|
698
|
-
|
|
825
|
+
Only stochastic and histochastic methods need regeneration.
|
|
826
|
+
All fixed rate methods (default, optimistic, conservative, user, historical average,
|
|
827
|
+
historical) don't need regeneration as they produce the same values.
|
|
828
|
+
|
|
829
|
+
Args:
|
|
830
|
+
override_reproducible: If True, override reproducibility setting and always generate new rates.
|
|
831
|
+
Used by Monte-Carlo runs to ensure each run gets different rates.
|
|
832
|
+
"""
|
|
833
|
+
# Fixed rate methods don't need regeneration - they produce the same values
|
|
834
|
+
fixed_methods = ["default", "optimistic", "conservative", "user",
|
|
835
|
+
"historical average", "historical"]
|
|
836
|
+
if self.rateMethod in fixed_methods:
|
|
837
|
+
return
|
|
838
|
+
|
|
839
|
+
# Only stochastic methods reach here
|
|
840
|
+
# If reproducibility is enabled and we're not overriding it, don't regenerate
|
|
841
|
+
# (rates should stay the same for reproducibility)
|
|
842
|
+
if self.reproducibleRates and not override_reproducible:
|
|
843
|
+
return
|
|
844
|
+
|
|
845
|
+
# Regenerate with new random values
|
|
699
846
|
self.setRates(
|
|
700
847
|
self.rateMethod,
|
|
701
848
|
frm=self.rateFrm,
|
|
@@ -703,6 +850,7 @@ class Plan(object):
|
|
|
703
850
|
values=100 * self.rateValues,
|
|
704
851
|
stdev=100 * self.rateStdev,
|
|
705
852
|
corr=self.rateCorr,
|
|
853
|
+
override_reproducible=override_reproducible,
|
|
706
854
|
)
|
|
707
855
|
|
|
708
856
|
def setAccountBalances(self, *, taxable, taxDeferred, taxFree, startDate=None, units="k"):
|
|
@@ -763,12 +911,12 @@ class Plan(object):
|
|
|
763
911
|
self.interpCenter = center
|
|
764
912
|
self.interpWidth = width
|
|
765
913
|
else:
|
|
766
|
-
raise ValueError(f"Method {method} not supported.")
|
|
914
|
+
raise ValueError(f"Method '{method}' not supported.")
|
|
767
915
|
|
|
768
916
|
self.interpMethod = method
|
|
769
917
|
self.caseStatus = "modified"
|
|
770
918
|
|
|
771
|
-
self.mylog.vprint(f"Asset allocation interpolation method set to {method}.")
|
|
919
|
+
self.mylog.vprint(f"Asset allocation interpolation method set to '{method}'.")
|
|
772
920
|
|
|
773
921
|
def setAllocationRatios(self, allocType, taxable=None, taxDeferred=None, taxFree=None, generic=None):
|
|
774
922
|
"""
|
|
@@ -792,6 +940,11 @@ class Plan(object):
|
|
|
792
940
|
generic = [[ko00, ko01, ko02, ko03], [kf00, kf01, kf02, kf02]]
|
|
793
941
|
as assets are coordinated between accounts and spouses.
|
|
794
942
|
"""
|
|
943
|
+
# Validate allocType parameter
|
|
944
|
+
validTypes = ["account", "individual", "spouses"]
|
|
945
|
+
if allocType not in validTypes:
|
|
946
|
+
raise ValueError(f"allocType must be one of {validTypes}, got '{allocType}'.")
|
|
947
|
+
|
|
795
948
|
self.boundsAR = {}
|
|
796
949
|
self.alpha_ijkn = np.zeros((self.N_i, self.N_j, self.N_k, self.N_n + 1))
|
|
797
950
|
if allocType == "account":
|
|
@@ -810,7 +963,7 @@ class Plan(object):
|
|
|
810
963
|
raise ValueError("Sum of percentages must add to 100.")
|
|
811
964
|
|
|
812
965
|
for i in range(self.N_i):
|
|
813
|
-
self.mylog.vprint(f"{self.inames[i]}: Setting gliding allocation ratios (%) to {allocType}.")
|
|
966
|
+
self.mylog.vprint(f"{self.inames[i]}: Setting gliding allocation ratios (%) to '{allocType}'.")
|
|
814
967
|
self.mylog.vprint(f" taxable: {taxable[i][0]} -> {taxable[i][1]}")
|
|
815
968
|
self.mylog.vprint(f" taxDeferred: {taxDeferred[i][0]} -> {taxDeferred[i][1]}")
|
|
816
969
|
self.mylog.vprint(f" taxFree: {taxFree[i][0]} -> {taxFree[i][1]}")
|
|
@@ -847,7 +1000,7 @@ class Plan(object):
|
|
|
847
1000
|
raise ValueError("Sum of percentages must add to 100.")
|
|
848
1001
|
|
|
849
1002
|
for i in range(self.N_i):
|
|
850
|
-
self.mylog.vprint(f"{self.inames[i]}: Setting gliding allocation ratios (%) to {allocType}.")
|
|
1003
|
+
self.mylog.vprint(f"{self.inames[i]}: Setting gliding allocation ratios (%) to '{allocType}'.")
|
|
851
1004
|
self.mylog.vprint(f"\t{generic[i][0]} -> {generic[i][1]}")
|
|
852
1005
|
|
|
853
1006
|
for i in range(self.N_i):
|
|
@@ -882,9 +1035,9 @@ class Plan(object):
|
|
|
882
1035
|
self.ARCoord = allocType
|
|
883
1036
|
self.caseStatus = "modified"
|
|
884
1037
|
|
|
885
|
-
self.mylog.vprint(f"Interpolating assets allocation ratios using {self.interpMethod} method.")
|
|
1038
|
+
self.mylog.vprint(f"Interpolating assets allocation ratios using '{self.interpMethod}' method.")
|
|
886
1039
|
|
|
887
|
-
def readContributions(self, filename):
|
|
1040
|
+
def readContributions(self, filename, filename_for_logging=None):
|
|
888
1041
|
"""
|
|
889
1042
|
Provide the name of the file containing the financial events
|
|
890
1043
|
over the anticipated life span determined by the
|
|
@@ -904,13 +1057,24 @@ class Plan(object):
|
|
|
904
1057
|
|
|
905
1058
|
in any order. A template is provided as an example.
|
|
906
1059
|
Missing rows (years) are populated with zero values.
|
|
1060
|
+
|
|
1061
|
+
Parameters
|
|
1062
|
+
----------
|
|
1063
|
+
filename : file-like object, str, or dict
|
|
1064
|
+
Input file or dictionary of DataFrames
|
|
1065
|
+
filename_for_logging : str, optional
|
|
1066
|
+
Explicit filename for logging purposes. If provided, this will be used
|
|
1067
|
+
in log messages instead of trying to extract it from filename.
|
|
907
1068
|
"""
|
|
908
1069
|
try:
|
|
909
|
-
|
|
1070
|
+
returned_filename, self.timeLists, self.houseLists = timelists.read(
|
|
1071
|
+
filename, self.inames, self.horizons, self.mylog, filename=filename_for_logging
|
|
1072
|
+
)
|
|
910
1073
|
except Exception as e:
|
|
911
|
-
raise Exception(f"Unsuccessful read of
|
|
1074
|
+
raise Exception(f"Unsuccessful read of Household Financial Profile: {e}") from e
|
|
912
1075
|
|
|
913
|
-
|
|
1076
|
+
# Use filename_for_logging if provided, otherwise use returned filename
|
|
1077
|
+
self.timeListsFileName = filename_for_logging if filename_for_logging is not None else returned_filename
|
|
914
1078
|
self.setContributions()
|
|
915
1079
|
|
|
916
1080
|
return True
|
|
@@ -931,20 +1095,91 @@ class Plan(object):
|
|
|
931
1095
|
|
|
932
1096
|
# Values for last 5 years of Roth conversion and contributions stored at the end
|
|
933
1097
|
# of array and accessed with negative index.
|
|
934
|
-
self.kappa_ijn[i, 0, :h
|
|
935
|
-
self.kappa_ijn[i, 1, :h
|
|
936
|
-
self.kappa_ijn[i, 1, :h
|
|
937
|
-
self.kappa_ijn[i, 2, :h
|
|
938
|
-
self.kappa_ijn[i, 2, :h
|
|
939
|
-
self.myRothX_in[i, :h
|
|
1098
|
+
self.kappa_ijn[i, 0, :h] = self.timeLists[iname]["taxable ctrb"][5:h+5]
|
|
1099
|
+
self.kappa_ijn[i, 1, :h] = self.timeLists[iname]["401k ctrb"][5:h+5]
|
|
1100
|
+
self.kappa_ijn[i, 1, :h] += self.timeLists[iname]["IRA ctrb"][5:h+5]
|
|
1101
|
+
self.kappa_ijn[i, 2, :h] = self.timeLists[iname]["Roth 401k ctrb"][5:h+5]
|
|
1102
|
+
self.kappa_ijn[i, 2, :h] += self.timeLists[iname]["Roth IRA ctrb"][5:h+5]
|
|
1103
|
+
self.myRothX_in[i, :h] = self.timeLists[iname]["Roth conv"][5:h+5]
|
|
1104
|
+
|
|
1105
|
+
# Last 5 years are at the end of the N_n array.
|
|
1106
|
+
self.kappa_ijn[i, 0, -5:] = self.timeLists[iname]["taxable ctrb"][:5]
|
|
1107
|
+
self.kappa_ijn[i, 1, -5:] = self.timeLists[iname]["401k ctrb"][:5]
|
|
1108
|
+
self.kappa_ijn[i, 1, -5:] += self.timeLists[iname]["IRA ctrb"][:5]
|
|
1109
|
+
self.kappa_ijn[i, 2, -5:] = self.timeLists[iname]["Roth 401k ctrb"][:5]
|
|
1110
|
+
self.kappa_ijn[i, 2, -5:] += self.timeLists[iname]["Roth IRA ctrb"][:5]
|
|
1111
|
+
self.myRothX_in[i, -5:] = self.timeLists[iname]["Roth conv"][:5]
|
|
940
1112
|
|
|
941
1113
|
self.caseStatus = "modified"
|
|
942
1114
|
|
|
943
1115
|
return self.timeLists
|
|
944
1116
|
|
|
1117
|
+
def processDebtsAndFixedAssets(self):
|
|
1118
|
+
"""
|
|
1119
|
+
Process debts and fixed assets from houseLists and populate arrays.
|
|
1120
|
+
Should be called after setContributions() and before solve().
|
|
1121
|
+
"""
|
|
1122
|
+
thisyear = date.today().year
|
|
1123
|
+
|
|
1124
|
+
# Process debts
|
|
1125
|
+
if "Debts" in self.houseLists and not self.houseLists["Debts"].empty:
|
|
1126
|
+
self.debt_payments_n = debts.get_debt_payments_array(
|
|
1127
|
+
self.houseLists["Debts"], self.N_n, thisyear
|
|
1128
|
+
)
|
|
1129
|
+
self.remaining_debt_balance = debts.get_remaining_debt_balance(
|
|
1130
|
+
self.houseLists["Debts"], self.N_n, thisyear
|
|
1131
|
+
)
|
|
1132
|
+
else:
|
|
1133
|
+
self.debt_payments_n = np.zeros(self.N_n)
|
|
1134
|
+
self.remaining_debt_balance = 0.0
|
|
1135
|
+
|
|
1136
|
+
# Process fixed assets
|
|
1137
|
+
if "Fixed Assets" in self.houseLists and not self.houseLists["Fixed Assets"].empty:
|
|
1138
|
+
filing_status = "married" if self.N_i == 2 else "single"
|
|
1139
|
+
(self.fixed_assets_tax_free_n,
|
|
1140
|
+
self.fixed_assets_ordinary_income_n,
|
|
1141
|
+
self.fixed_assets_capital_gains_n) = fxasst.get_fixed_assets_arrays(
|
|
1142
|
+
self.houseLists["Fixed Assets"], self.N_n, thisyear, filing_status
|
|
1143
|
+
)
|
|
1144
|
+
# Calculate bequest value for assets with yod past plan end
|
|
1145
|
+
self.fixed_assets_bequest_value = fxasst.get_fixed_assets_bequest_value(
|
|
1146
|
+
self.houseLists["Fixed Assets"], self.N_n, thisyear
|
|
1147
|
+
)
|
|
1148
|
+
else:
|
|
1149
|
+
self.fixed_assets_tax_free_n = np.zeros(self.N_n)
|
|
1150
|
+
self.fixed_assets_ordinary_income_n = np.zeros(self.N_n)
|
|
1151
|
+
self.fixed_assets_capital_gains_n = np.zeros(self.N_n)
|
|
1152
|
+
self.fixed_assets_bequest_value = 0.0
|
|
1153
|
+
|
|
1154
|
+
def getFixedAssetsBequestValueInTodaysDollars(self):
|
|
1155
|
+
"""
|
|
1156
|
+
Return the fixed assets bequest value in today's dollars.
|
|
1157
|
+
This requires rates to be set to calculate gamma_n (inflation factor).
|
|
1158
|
+
|
|
1159
|
+
Returns:
|
|
1160
|
+
--------
|
|
1161
|
+
float
|
|
1162
|
+
Fixed assets bequest value in today's dollars.
|
|
1163
|
+
Returns 0.0 if rates not set, gamma_n not calculated, or no fixed assets.
|
|
1164
|
+
"""
|
|
1165
|
+
if self.fixed_assets_bequest_value == 0.0:
|
|
1166
|
+
return 0.0
|
|
1167
|
+
|
|
1168
|
+
# Check if we can calculate gamma_n
|
|
1169
|
+
if self.rateMethod is None or not hasattr(self, 'tau_kn'):
|
|
1170
|
+
# Rates not set yet - return 0
|
|
1171
|
+
return 0.0
|
|
1172
|
+
|
|
1173
|
+
# Calculate gamma_n if not already calculated
|
|
1174
|
+
if not hasattr(self, 'gamma_n') or self.gamma_n is None:
|
|
1175
|
+
self.gamma_n = _genGamma_n(self.tau_kn)
|
|
1176
|
+
|
|
1177
|
+
# Convert: today's dollars = nominal dollars / inflation_factor
|
|
1178
|
+
return self.fixed_assets_bequest_value / self.gamma_n[-1]
|
|
1179
|
+
|
|
945
1180
|
def saveContributions(self):
|
|
946
1181
|
"""
|
|
947
|
-
Return workbook on wages and contributions.
|
|
1182
|
+
Return workbook on wages and contributions, including Debts and Fixed Assets.
|
|
948
1183
|
"""
|
|
949
1184
|
if self.timeLists is None:
|
|
950
1185
|
return None
|
|
@@ -966,6 +1201,36 @@ class Plan(object):
|
|
|
966
1201
|
ws = wb.create_sheet(self.inames[1])
|
|
967
1202
|
fillsheet(ws, 1)
|
|
968
1203
|
|
|
1204
|
+
# Add Debts sheet if available
|
|
1205
|
+
if "Debts" in self.houseLists and not self.houseLists["Debts"].empty:
|
|
1206
|
+
ws = wb.create_sheet("Debts")
|
|
1207
|
+
df = self.houseLists["Debts"]
|
|
1208
|
+
for row in dataframe_to_rows(df, index=False, header=True):
|
|
1209
|
+
ws.append(row)
|
|
1210
|
+
_formatDebtsSheet(ws)
|
|
1211
|
+
else:
|
|
1212
|
+
# Create empty Debts sheet with proper columns
|
|
1213
|
+
ws = wb.create_sheet("Debts")
|
|
1214
|
+
df = pd.DataFrame(columns=timelists._debtItems)
|
|
1215
|
+
for row in dataframe_to_rows(df, index=False, header=True):
|
|
1216
|
+
ws.append(row)
|
|
1217
|
+
_formatDebtsSheet(ws)
|
|
1218
|
+
|
|
1219
|
+
# Add Fixed Assets sheet if available
|
|
1220
|
+
if "Fixed Assets" in self.houseLists and not self.houseLists["Fixed Assets"].empty:
|
|
1221
|
+
ws = wb.create_sheet("Fixed Assets")
|
|
1222
|
+
df = self.houseLists["Fixed Assets"]
|
|
1223
|
+
for row in dataframe_to_rows(df, index=False, header=True):
|
|
1224
|
+
ws.append(row)
|
|
1225
|
+
_formatFixedAssetsSheet(ws)
|
|
1226
|
+
else:
|
|
1227
|
+
# Create empty Fixed Assets sheet with proper columns
|
|
1228
|
+
ws = wb.create_sheet("Fixed Assets")
|
|
1229
|
+
df = pd.DataFrame(columns=timelists._fixedAssetItems)
|
|
1230
|
+
for row in dataframe_to_rows(df, index=False, header=True):
|
|
1231
|
+
ws.append(row)
|
|
1232
|
+
_formatFixedAssetsSheet(ws)
|
|
1233
|
+
|
|
969
1234
|
return wb
|
|
970
1235
|
|
|
971
1236
|
def zeroContributions(self):
|
|
@@ -1079,9 +1344,11 @@ class Plan(object):
|
|
|
1079
1344
|
C["w"] = _qC(C["s"], self.N_n)
|
|
1080
1345
|
C["x"] = _qC(C["w"], self.N_i, self.N_j, self.N_n)
|
|
1081
1346
|
C["zx"] = _qC(C["x"], self.N_i, self.N_n)
|
|
1082
|
-
C["zm"] = _qC(C["zx"], self.
|
|
1347
|
+
C["zm"] = _qC(C["zx"], self.N_n, self.N_zx)
|
|
1083
1348
|
self.nvars = _qC(C["zm"], self.N_n - self.nm, self.N_q - 1) if medi else C["zm"]
|
|
1084
1349
|
self.nbins = self.nvars - C["zx"]
|
|
1350
|
+
self.nconts = C["zx"]
|
|
1351
|
+
self.nbals = C["d"]
|
|
1085
1352
|
|
|
1086
1353
|
self.C = C
|
|
1087
1354
|
self.mylog.vprint(
|
|
@@ -1108,7 +1375,7 @@ class Plan(object):
|
|
|
1108
1375
|
self._add_conversion_limits()
|
|
1109
1376
|
self._add_objective_constraints(objective, options)
|
|
1110
1377
|
self._add_initial_balances()
|
|
1111
|
-
self._add_surplus_deposit_linking()
|
|
1378
|
+
self._add_surplus_deposit_linking(options)
|
|
1112
1379
|
self._add_account_balance_carryover()
|
|
1113
1380
|
self._add_net_cash_flow()
|
|
1114
1381
|
self._add_income_profile()
|
|
@@ -1178,48 +1445,76 @@ class Plan(object):
|
|
|
1178
1445
|
cgains *= oldTau1
|
|
1179
1446
|
# Past years are stored at the end of contributions and conversions arrays.
|
|
1180
1447
|
# Use negative index to access tail of array.
|
|
1448
|
+
# Past years are stored at the end of arrays, accessed via negative indexing
|
|
1181
1449
|
rhs += (cgains - 1) * self.kappa_ijn[i, 2, nn] + cgains * self.myRothX_in[i, nn]
|
|
1182
|
-
# rhs += cgains * self.kappa_ijn[i, 2, nn] + cgains * self.myRothX_in[i, nn]
|
|
1183
1450
|
|
|
1184
1451
|
self.A.addRow(row, rhs, np.inf)
|
|
1185
1452
|
|
|
1186
1453
|
def _add_roth_conversion_constraints(self, options):
|
|
1454
|
+
# Values in file supercedes everything.
|
|
1187
1455
|
if "maxRothConversion" in options and options["maxRothConversion"] == "file":
|
|
1188
1456
|
for i in range(self.N_i):
|
|
1189
1457
|
for n in range(self.horizons[i]):
|
|
1190
1458
|
rhs = self.myRothX_in[i][n]
|
|
1191
1459
|
self.B.setRange(_q2(self.C["x"], i, n, self.N_i, self.N_n), rhs, rhs)
|
|
1192
1460
|
else:
|
|
1461
|
+
# Don't exclude anyone by default.
|
|
1462
|
+
i_xcluded = -1
|
|
1463
|
+
if "noRothConversions" in options and options["noRothConversions"] != "None":
|
|
1464
|
+
rhsopt = options["noRothConversions"]
|
|
1465
|
+
try:
|
|
1466
|
+
i_xcluded = self.inames.index(rhsopt)
|
|
1467
|
+
except ValueError as e:
|
|
1468
|
+
raise ValueError(f"Unknown individual '{rhsopt}' for noRothConversions:") from e
|
|
1469
|
+
for n in range(self.horizons[i_xcluded]):
|
|
1470
|
+
self.B.setRange(_q2(self.C["x"], i_xcluded, n, self.N_i, self.N_n), 0, 0)
|
|
1471
|
+
|
|
1193
1472
|
if "maxRothConversion" in options:
|
|
1194
|
-
rhsopt = options
|
|
1195
|
-
if not isinstance(rhsopt, (int, float)):
|
|
1196
|
-
raise ValueError(f"Specified maxRothConversion {rhsopt} is not a number.")
|
|
1473
|
+
rhsopt = u.get_numeric_option(options, "maxRothConversion", 0)
|
|
1197
1474
|
|
|
1198
1475
|
if rhsopt >= 0:
|
|
1199
1476
|
rhsopt *= self.optionsUnits
|
|
1200
1477
|
for i in range(self.N_i):
|
|
1478
|
+
if i == i_xcluded:
|
|
1479
|
+
continue
|
|
1201
1480
|
for n in range(self.horizons[i]):
|
|
1202
|
-
|
|
1481
|
+
# Apply the cap per individual (legacy behavior).
|
|
1482
|
+
self.B.setRange(_q2(self.C["x"], i, n, self.N_i, self.N_n), 0, rhsopt)
|
|
1203
1483
|
|
|
1204
1484
|
if "startRothConversions" in options:
|
|
1205
|
-
rhsopt = options
|
|
1206
|
-
if not isinstance(rhsopt, (int, float)):
|
|
1207
|
-
raise ValueError(f"Specified startRothConversions {rhsopt} is not a number.")
|
|
1485
|
+
rhsopt = int(u.get_numeric_option(options, "startRothConversions", 0))
|
|
1208
1486
|
thisyear = date.today().year
|
|
1209
1487
|
yearn = max(rhsopt - thisyear, 0)
|
|
1210
1488
|
for i in range(self.N_i):
|
|
1489
|
+
if i == i_xcluded:
|
|
1490
|
+
continue
|
|
1211
1491
|
nstart = min(yearn, self.horizons[i])
|
|
1212
1492
|
for n in range(0, nstart):
|
|
1213
1493
|
self.B.setRange(_q2(self.C["x"], i, n, self.N_i, self.N_n), 0, 0)
|
|
1214
1494
|
|
|
1215
|
-
if "
|
|
1216
|
-
rhsopt = options
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1495
|
+
if "swapRothConverters" in options and i_xcluded == -1:
|
|
1496
|
+
rhsopt = int(u.get_numeric_option(options, "swapRothConverters", 0))
|
|
1497
|
+
if self.N_i == 2 and rhsopt != 0:
|
|
1498
|
+
thisyear = date.today().year
|
|
1499
|
+
absrhsopt = abs(rhsopt)
|
|
1500
|
+
yearn = max(absrhsopt - thisyear, 0)
|
|
1501
|
+
i_x = 0 if rhsopt > 0 else 1
|
|
1502
|
+
i_y = (i_x + 1) % 2
|
|
1503
|
+
|
|
1504
|
+
transy = min(yearn, self.horizons[i_y])
|
|
1505
|
+
for n in range(0, transy):
|
|
1506
|
+
self.B.setRange(_q2(self.C["x"], i_y, n, self.N_i, self.N_n), 0, 0)
|
|
1507
|
+
|
|
1508
|
+
transx = min(yearn, self.horizons[i_x])
|
|
1509
|
+
for n in range(transx, self.horizons[i_x]):
|
|
1510
|
+
self.B.setRange(_q2(self.C["x"], i_x, n, self.N_i, self.N_n), 0, 0)
|
|
1511
|
+
|
|
1512
|
+
# Disallow Roth conversions in last two years alive. Plan has at least 2 years.
|
|
1513
|
+
for i in range(self.N_i):
|
|
1514
|
+
if i == i_xcluded:
|
|
1515
|
+
continue
|
|
1516
|
+
self.B.setRange(_q2(self.C["x"], i, self.horizons[i] - 2, self.N_i, self.N_n), 0, 0)
|
|
1517
|
+
self.B.setRange(_q2(self.C["x"], i, self.horizons[i] - 1, self.N_i, self.N_n), 0, 0)
|
|
1223
1518
|
|
|
1224
1519
|
def _add_withdrawal_limits(self):
|
|
1225
1520
|
for i in range(self.N_i):
|
|
@@ -1242,23 +1537,26 @@ class Plan(object):
|
|
|
1242
1537
|
def _add_objective_constraints(self, objective, options):
|
|
1243
1538
|
if objective == "maxSpending":
|
|
1244
1539
|
if "bequest" in options:
|
|
1245
|
-
bequest = options
|
|
1246
|
-
if not isinstance(bequest, (int, float)):
|
|
1247
|
-
raise ValueError(f"Desired bequest {bequest} is not a number.")
|
|
1540
|
+
bequest = u.get_numeric_option(options, "bequest", 1)
|
|
1248
1541
|
bequest *= self.optionsUnits * self.gamma_n[-1]
|
|
1249
1542
|
else:
|
|
1250
1543
|
bequest = 1
|
|
1251
1544
|
|
|
1545
|
+
# Bequest constraint now refers only to savings accounts
|
|
1546
|
+
# User specifies desired bequest from accounts (fixed assets are separate)
|
|
1547
|
+
# Total bequest = accounts - debts + fixed_assets
|
|
1548
|
+
# So: accounts >= desired_bequest_from_accounts + debts
|
|
1549
|
+
# (fixed_assets are added separately in the total bequest calculation)
|
|
1550
|
+
total_bequest_value = bequest + self.remaining_debt_balance
|
|
1551
|
+
|
|
1252
1552
|
row = self.A.newRow()
|
|
1253
1553
|
for i in range(self.N_i):
|
|
1254
1554
|
row.addElem(_q3(self.C["b"], i, 0, self.N_n, self.N_i, self.N_j, self.N_n + 1), 1)
|
|
1255
1555
|
row.addElem(_q3(self.C["b"], i, 1, self.N_n, self.N_i, self.N_j, self.N_n + 1), 1 - self.nu)
|
|
1256
1556
|
row.addElem(_q3(self.C["b"], i, 2, self.N_n, self.N_i, self.N_j, self.N_n + 1), 1)
|
|
1257
|
-
self.A.addRow(row,
|
|
1557
|
+
self.A.addRow(row, total_bequest_value, total_bequest_value)
|
|
1258
1558
|
elif objective == "maxBequest":
|
|
1259
|
-
spending = options
|
|
1260
|
-
if not isinstance(spending, (int, float)):
|
|
1261
|
-
raise ValueError(f"Desired spending provided {spending} is not a number.")
|
|
1559
|
+
spending = u.get_numeric_option(options, "netSpending", 1)
|
|
1262
1560
|
spending *= self.optionsUnits
|
|
1263
1561
|
self.B.setRange(_q1(self.C["g"], 0, self.N_n), spending, spending)
|
|
1264
1562
|
|
|
@@ -1272,7 +1570,7 @@ class Plan(object):
|
|
|
1272
1570
|
rhs = self.beta_ij[i, j] / backTau
|
|
1273
1571
|
self.B.setRange(_q3(self.C["b"], i, j, 0, self.N_i, self.N_j, self.N_n + 1), rhs, rhs)
|
|
1274
1572
|
|
|
1275
|
-
def _add_surplus_deposit_linking(self):
|
|
1573
|
+
def _add_surplus_deposit_linking(self, options):
|
|
1276
1574
|
for i in range(self.N_i):
|
|
1277
1575
|
fac1 = u.krond(i, 0) * (1 - self.eta) + u.krond(i, 1) * self.eta
|
|
1278
1576
|
for n in range(self.n_d):
|
|
@@ -1282,8 +1580,12 @@ class Plan(object):
|
|
|
1282
1580
|
for n in range(self.n_d, self.N_n):
|
|
1283
1581
|
rowDic = {_q2(self.C["d"], i, n, self.N_i, self.N_n): 1, _q1(self.C["s"], n, self.N_n): -fac2}
|
|
1284
1582
|
self.A.addNewRow(rowDic, 0, 0)
|
|
1285
|
-
|
|
1286
|
-
|
|
1583
|
+
|
|
1584
|
+
# Prevent surplus on two last year as they have little tax and/or growth consequence.
|
|
1585
|
+
disallow = options.get("noLateSurplus", False)
|
|
1586
|
+
if disallow:
|
|
1587
|
+
self.B.setRange(_q1(self.C["s"], self.N_n - 2, self.N_n), 0, 0)
|
|
1588
|
+
self.B.setRange(_q1(self.C["s"], self.N_n - 1, self.N_n), 0, 0)
|
|
1287
1589
|
|
|
1288
1590
|
def _add_account_balance_carryover(self):
|
|
1289
1591
|
tau_ijn = np.zeros((self.N_i, self.N_j, self.N_n))
|
|
@@ -1336,6 +1638,12 @@ class Plan(object):
|
|
|
1336
1638
|
tau_0prev[tau_0prev < 0] = 0
|
|
1337
1639
|
for n in range(self.N_n):
|
|
1338
1640
|
rhs = -self.M_n[n] - self.J_n[n]
|
|
1641
|
+
# Add fixed assets proceeds (positive cash flow)
|
|
1642
|
+
rhs += (self.fixed_assets_tax_free_n[n]
|
|
1643
|
+
+ self.fixed_assets_ordinary_income_n[n]
|
|
1644
|
+
+ self.fixed_assets_capital_gains_n[n])
|
|
1645
|
+
# Subtract debt payments (negative cash flow)
|
|
1646
|
+
rhs -= self.debt_payments_n[n]
|
|
1339
1647
|
row = self.A.newRow({_q1(self.C["g"], n, self.N_n): 1})
|
|
1340
1648
|
row.addElem(_q1(self.C["s"], n, self.N_n), 1)
|
|
1341
1649
|
row.addElem(_q1(self.C["m"], n, self.N_n), 1)
|
|
@@ -1373,11 +1681,12 @@ class Plan(object):
|
|
|
1373
1681
|
|
|
1374
1682
|
def _add_taxable_income(self):
|
|
1375
1683
|
for n in range(self.N_n):
|
|
1376
|
-
|
|
1684
|
+
# Add fixed assets ordinary income
|
|
1685
|
+
rhs = self.fixed_assets_ordinary_income_n[n]
|
|
1377
1686
|
row = self.A.newRow()
|
|
1378
1687
|
row.addElem(_q1(self.C["e"], n, self.N_n), 1)
|
|
1379
1688
|
for i in range(self.N_i):
|
|
1380
|
-
rhs += self.omega_in[i, n] +
|
|
1689
|
+
rhs += self.omega_in[i, n] + self.Psi_n[n] * self.zetaBar_in[i, n] + self.piBar_in[i, n]
|
|
1381
1690
|
row.addElem(_q3(self.C["w"], i, 1, n, self.N_i, self.N_j, self.N_n), -1)
|
|
1382
1691
|
row.addElem(_q2(self.C["x"], i, n, self.N_i, self.N_n), -1)
|
|
1383
1692
|
fak = np.sum(self.tau_kn[1:self.N_k, n] * self.alpha_ijkn[i, 0, 1:self.N_k, n], axis=0)
|
|
@@ -1390,53 +1699,73 @@ class Plan(object):
|
|
|
1390
1699
|
self.A.addRow(row, rhs, rhs)
|
|
1391
1700
|
|
|
1392
1701
|
def _configure_exclusion_binary_variables(self, options):
|
|
1393
|
-
if not options.get("
|
|
1702
|
+
if not options.get("amoConstraints", True):
|
|
1394
1703
|
return
|
|
1395
1704
|
|
|
1396
|
-
bigM =
|
|
1397
|
-
|
|
1398
|
-
|
|
1705
|
+
bigM = u.get_numeric_option(options, "bigMamo", BIGM_XOR, min_value=0)
|
|
1706
|
+
|
|
1707
|
+
if options.get("amoSurplus", True):
|
|
1708
|
+
for n in range(self.N_n):
|
|
1709
|
+
# Make z_0 and z_1 exclusive binary variables.
|
|
1710
|
+
dic0 = {_q2(self.C["zx"], n, 0, self.N_n, self.N_zx): bigM*self.gamma_n[n],
|
|
1711
|
+
_q3(self.C["w"], 0, 0, n, self.N_i, self.N_j, self.N_n): -1,
|
|
1712
|
+
_q3(self.C["w"], 0, 2, n, self.N_i, self.N_j, self.N_n): -1}
|
|
1713
|
+
if self.N_i == 2:
|
|
1714
|
+
dic1 = {_q3(self.C["w"], 1, 0, n, self.N_i, self.N_j, self.N_n): -1,
|
|
1715
|
+
_q3(self.C["w"], 1, 2, n, self.N_i, self.N_j, self.N_n): -1}
|
|
1716
|
+
dic0.update(dic1)
|
|
1717
|
+
|
|
1718
|
+
self.A.addNewRow(dic0, 0, np.inf)
|
|
1399
1719
|
|
|
1400
|
-
for i in range(self.N_i):
|
|
1401
|
-
for n in range(self.horizons[i]):
|
|
1402
1720
|
self.A.addNewRow(
|
|
1403
|
-
{
|
|
1721
|
+
{_q2(self.C["zx"], n, 1, self.N_n, self.N_zx): bigM*self.gamma_n[n],
|
|
1404
1722
|
_q1(self.C["s"], n, self.N_n): -1},
|
|
1405
|
-
0,
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
self.A.addNewRow(
|
|
1409
|
-
{
|
|
1410
|
-
_q3(self.C["zx"], i, n, 0, self.N_i, self.N_n, self.N_zx): bigM,
|
|
1411
|
-
_q3(self.C["w"], i, 0, n, self.N_i, self.N_j, self.N_n): 1,
|
|
1412
|
-
_q3(self.C["w"], i, 2, n, self.N_i, self.N_j, self.N_n): 1,
|
|
1413
|
-
},
|
|
1414
|
-
0,
|
|
1415
|
-
bigM,
|
|
1416
|
-
)
|
|
1723
|
+
0, np.inf)
|
|
1724
|
+
|
|
1725
|
+
# As both can be zero, bound as z_0 + z_1 <= 1
|
|
1417
1726
|
self.A.addNewRow(
|
|
1418
|
-
{
|
|
1419
|
-
_q2(self.C["
|
|
1420
|
-
0,
|
|
1421
|
-
bigM,
|
|
1727
|
+
{_q2(self.C["zx"], n, 0, self.N_n, self.N_zx): +1,
|
|
1728
|
+
_q2(self.C["zx"], n, 1, self.N_n, self.N_zx): +1},
|
|
1729
|
+
0, 1
|
|
1422
1730
|
)
|
|
1731
|
+
|
|
1732
|
+
if "maxRothConversion" in options:
|
|
1733
|
+
rhsopt = u.get_numeric_option(options, "maxRothConversion", 0)
|
|
1734
|
+
if rhsopt < -1:
|
|
1735
|
+
return
|
|
1736
|
+
|
|
1737
|
+
# Turning off this constraint for maxRothConversions = 0 makes solution infeasible.
|
|
1738
|
+
if options.get("amoRoth", True):
|
|
1739
|
+
for n in range(self.N_n):
|
|
1740
|
+
# Make z_2 and z_3 exclusive binary variables.
|
|
1741
|
+
dic0 = {_q2(self.C["zx"], n, 2, self.N_n, self.N_zx): bigM*self.gamma_n[n],
|
|
1742
|
+
_q2(self.C["x"], 0, n, self.N_i, self.N_n): -1}
|
|
1743
|
+
if self.N_i == 2:
|
|
1744
|
+
dic1 = {_q2(self.C["x"], 1, n, self.N_i, self.N_n): -1}
|
|
1745
|
+
dic0.update(dic1)
|
|
1746
|
+
|
|
1747
|
+
self.A.addNewRow(dic0, 0, np.inf)
|
|
1748
|
+
|
|
1749
|
+
dic0 = {_q2(self.C["zx"], n, 3, self.N_n, self.N_zx): bigM*self.gamma_n[n],
|
|
1750
|
+
_q3(self.C["w"], 0, 2, n, self.N_i, self.N_j, self.N_n): -1}
|
|
1751
|
+
if self.N_i == 2:
|
|
1752
|
+
dic1 = {_q3(self.C["w"], 1, 2, n, self.N_i, self.N_j, self.N_n): -1}
|
|
1753
|
+
dic0.update(dic1)
|
|
1754
|
+
|
|
1755
|
+
self.A.addNewRow(dic0, 0, np.inf)
|
|
1756
|
+
|
|
1423
1757
|
self.A.addNewRow(
|
|
1424
|
-
{
|
|
1425
|
-
|
|
1426
|
-
0,
|
|
1427
|
-
bigM,
|
|
1758
|
+
{_q2(self.C["zx"], n, 2, self.N_n, self.N_zx): +1,
|
|
1759
|
+
_q2(self.C["zx"], n, 3, self.N_n, self.N_zx): +1},
|
|
1760
|
+
0, 1
|
|
1428
1761
|
)
|
|
1429
|
-
for n in range(self.horizons[i], self.N_n):
|
|
1430
|
-
self.B.setRange(_q3(self.C["zx"], i, n, 0, self.N_i, self.N_n, self.N_zx), 0, 0)
|
|
1431
|
-
self.B.setRange(_q3(self.C["zx"], i, n, 1, self.N_i, self.N_n, self.N_zx), 0, 0)
|
|
1432
1762
|
|
|
1433
1763
|
def _configure_Medicare_binary_variables(self, options):
|
|
1434
1764
|
if options.get("withMedicare", "loop") != "optimize":
|
|
1435
1765
|
return
|
|
1436
1766
|
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
raise ValueError(f"bigM {bigM} is not a number.")
|
|
1767
|
+
# Default: 5e7 (50 million) - bounds aggregate MAGI, typically larger than bigMamo
|
|
1768
|
+
bigM = u.get_numeric_option(options, "bigMirmaa", BIGM_IRMAA, min_value=0)
|
|
1440
1769
|
|
|
1441
1770
|
Nmed = self.N_n - self.nm
|
|
1442
1771
|
offset = 0
|
|
@@ -1446,47 +1775,47 @@ class Plan(object):
|
|
|
1446
1775
|
n = self.nm + nn
|
|
1447
1776
|
for q in range(self.N_q - 1):
|
|
1448
1777
|
self.A.addNewRow({_q2(self.C["zm"], nn, q, Nmed, self.N_q - 1): bigM},
|
|
1449
|
-
|
|
1450
|
-
self.A.addNewRow({_q2(self.C["zm"], nn, q, Nmed, self.N_q - 1): -bigM},
|
|
1451
|
-
-np.inf, self.L_nq[nn, q] - self.prevMAGI[n])
|
|
1778
|
+
self.prevMAGI[n] - self.L_nq[nn, q], np.inf)
|
|
1452
1779
|
|
|
1453
1780
|
for nn in range(offset, Nmed):
|
|
1454
1781
|
n2 = self.nm + nn - 2 # n - 2
|
|
1455
1782
|
for q in range(self.N_q - 1):
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1783
|
+
rhs = self.L_nq[nn, q]
|
|
1784
|
+
rhs -= (self.fixed_assets_ordinary_income_n[n2]
|
|
1785
|
+
+ self.fixed_assets_capital_gains_n[n2])
|
|
1786
|
+
row = self.A.newRow()
|
|
1460
1787
|
|
|
1461
|
-
|
|
1462
|
-
|
|
1788
|
+
row.addElem(_q2(self.C["zm"], nn, q, Nmed, self.N_q - 1), -bigM*self.gamma_n[nn])
|
|
1789
|
+
# Using e_n slows convergence like crazy.
|
|
1790
|
+
# Maybe replace with full exemption at the risk of creating negative income?
|
|
1791
|
+
# rhs -= self.sigmaBar_n[n2]
|
|
1792
|
+
row.addElem(_q1(self.C["e"], n2, self.N_n), +1)
|
|
1463
1793
|
for i in range(self.N_i):
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
row1.addElem(_q2(self.C["x"], i, n2, self.N_i, self.N_n), -1)
|
|
1468
|
-
row2.addElem(_q2(self.C["x"], i, n2, self.N_i, self.N_n), +1)
|
|
1794
|
+
row.addElem(_q3(self.C["w"], i, 1, n2, self.N_i, self.N_j, self.N_n), +1)
|
|
1795
|
+
row.addElem(_q2(self.C["x"], i, n2, self.N_i, self.N_n), +1)
|
|
1469
1796
|
|
|
1797
|
+
# Dividends and interest gains for year n2.
|
|
1470
1798
|
afac = (self.mu*self.alpha_ijkn[i, 0, 0, n2]
|
|
1471
1799
|
+ np.sum(self.alpha_ijkn[i, 0, 1:, n2]*self.tau_kn[1:, n2]))
|
|
1472
|
-
afac = 0
|
|
1473
|
-
row1.addElem(_q3(self.C["b"], i, 0, n2, self.N_i, self.N_j, self.N_n + 1), -afac)
|
|
1474
|
-
row2.addElem(_q3(self.C["b"], i, 0, n2, self.N_i, self.N_j, self.N_n + 1), +afac)
|
|
1475
1800
|
|
|
1476
|
-
|
|
1477
|
-
|
|
1801
|
+
row.addElem(_q3(self.C["b"], i, 0, n2, self.N_i, self.N_j, self.N_n + 1), +afac)
|
|
1802
|
+
row.addElem(_q2(self.C["d"], i, n2, self.N_i, self.N_n), +afac)
|
|
1478
1803
|
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1804
|
+
# Capital gains on stocks sold from taxable account accrued in year n2 - 1.
|
|
1805
|
+
# Capital gains = price appreciation only (total return - dividend rate)
|
|
1806
|
+
# to avoid double taxation of dividends.
|
|
1807
|
+
tau_prev = self.tau_kn[0, max(0, n2-1)]
|
|
1808
|
+
bfac = self.alpha_ijkn[i, 0, 0, n2] * max(0, tau_prev - self.mu)
|
|
1809
|
+
row.addElem(_q3(self.C["w"], i, 0, n2, self.N_i, self.N_j, self.N_n), -afac + bfac)
|
|
1482
1810
|
|
|
1483
|
-
|
|
1811
|
+
# MAGI includes total Social Security (taxable + non-taxable) for IRMAA.
|
|
1812
|
+
sumoni = (self.omega_in[i, n2]
|
|
1813
|
+
+ self.zetaBar_in[i, n2]
|
|
1814
|
+
+ self.piBar_in[i, n2]
|
|
1484
1815
|
+ 0.5 * self.kappa_ijn[i, 0, n2] * afac)
|
|
1485
|
-
|
|
1486
|
-
rhs2 -= sumoni
|
|
1816
|
+
rhs -= sumoni
|
|
1487
1817
|
|
|
1488
|
-
self.A.addRow(
|
|
1489
|
-
self.A.addRow(row2, -np.inf, rhs2)
|
|
1818
|
+
self.A.addRow(row, -np.inf, rhs)
|
|
1490
1819
|
|
|
1491
1820
|
def _add_Medicare_costs(self, options):
|
|
1492
1821
|
if options.get("withMedicare", "loop") != "optimize":
|
|
@@ -1526,7 +1855,7 @@ class Plan(object):
|
|
|
1526
1855
|
|
|
1527
1856
|
if yend + self.N_n > self.year_n[0]:
|
|
1528
1857
|
yend = self.year_n[0] - self.N_n - 1
|
|
1529
|
-
self.mylog.
|
|
1858
|
+
self.mylog.print(f"Warning: Upper bound for year range re-adjusted to {yend}.")
|
|
1530
1859
|
|
|
1531
1860
|
if yend < ystart:
|
|
1532
1861
|
raise ValueError(f"Starting year is too large to support a lifespan of {self.N_n} years.")
|
|
@@ -1541,8 +1870,8 @@ class Plan(object):
|
|
|
1541
1870
|
elif objective == "maxBequest":
|
|
1542
1871
|
columns = ["partial", "final"]
|
|
1543
1872
|
else:
|
|
1544
|
-
self.mylog.print(f"Invalid objective {objective}.")
|
|
1545
|
-
raise ValueError(f"Invalid objective {objective}.")
|
|
1873
|
+
self.mylog.print(f"Invalid objective '{objective}'.")
|
|
1874
|
+
raise ValueError(f"Invalid objective '{objective}'.")
|
|
1546
1875
|
|
|
1547
1876
|
df = pd.DataFrame(columns=columns)
|
|
1548
1877
|
|
|
@@ -1594,7 +1923,7 @@ class Plan(object):
|
|
|
1594
1923
|
elif objective == "maxBequest":
|
|
1595
1924
|
columns = ["partial", "final"]
|
|
1596
1925
|
else:
|
|
1597
|
-
self.mylog.print(f"Invalid objective {objective}.")
|
|
1926
|
+
self.mylog.print(f"Invalid objective '{objective}'.")
|
|
1598
1927
|
return None
|
|
1599
1928
|
|
|
1600
1929
|
df = pd.DataFrame(columns=columns)
|
|
@@ -1606,7 +1935,7 @@ class Plan(object):
|
|
|
1606
1935
|
progcall.start()
|
|
1607
1936
|
|
|
1608
1937
|
for n in range(N):
|
|
1609
|
-
self.regenRates()
|
|
1938
|
+
self.regenRates(override_reproducible=True)
|
|
1610
1939
|
self.solve(objective, myoptions)
|
|
1611
1940
|
if not verbose:
|
|
1612
1941
|
progcall.show((n + 1) / N)
|
|
@@ -1655,52 +1984,68 @@ class Plan(object):
|
|
|
1655
1984
|
|
|
1656
1985
|
Refer to companion document for implementation details.
|
|
1657
1986
|
"""
|
|
1987
|
+
|
|
1658
1988
|
if self.rateMethod is None:
|
|
1659
1989
|
raise RuntimeError("Rate method must be selected before solving.")
|
|
1660
1990
|
|
|
1661
1991
|
# Assume unsuccessful until problem solved.
|
|
1662
1992
|
self.caseStatus = "unsuccessful"
|
|
1993
|
+
self.convergenceType = "undefined"
|
|
1663
1994
|
|
|
1664
1995
|
# Check objective and required options.
|
|
1665
1996
|
knownObjectives = ["maxBequest", "maxSpending"]
|
|
1666
1997
|
knownSolvers = ["HiGHS", "PuLP/CBC", "PuLP/HiGHS", "MOSEK"]
|
|
1667
1998
|
|
|
1668
1999
|
knownOptions = [
|
|
2000
|
+
"absTol",
|
|
2001
|
+
"amoConstraints",
|
|
2002
|
+
"amoRoth",
|
|
2003
|
+
"amoSurplus",
|
|
1669
2004
|
"bequest",
|
|
1670
|
-
"
|
|
2005
|
+
"bigMirmaa", # Big-M value for Medicare IRMAA constraints (default: 5e7)
|
|
2006
|
+
"bigMamo", # Big-M value for XOR constraints (default: 5e7)
|
|
2007
|
+
"gap",
|
|
2008
|
+
"maxIter",
|
|
1671
2009
|
"maxRothConversion",
|
|
1672
2010
|
"netSpending",
|
|
2011
|
+
"noLateSurplus",
|
|
1673
2012
|
"noRothConversions",
|
|
1674
2013
|
"oppCostX",
|
|
1675
|
-
"withMedicare",
|
|
1676
2014
|
"previousMAGIs",
|
|
2015
|
+
"relTol",
|
|
1677
2016
|
"solver",
|
|
1678
2017
|
"spendingSlack",
|
|
1679
2018
|
"startRothConversions",
|
|
2019
|
+
"swapRothConverters",
|
|
2020
|
+
"maxTime",
|
|
1680
2021
|
"units",
|
|
1681
|
-
"
|
|
2022
|
+
"verbose",
|
|
2023
|
+
"withMedicare",
|
|
1682
2024
|
"withSCLoop",
|
|
1683
2025
|
]
|
|
1684
|
-
# We might modify options if required.
|
|
1685
2026
|
options = {} if options is None else options
|
|
2027
|
+
|
|
2028
|
+
# We might modify options if required.
|
|
1686
2029
|
myoptions = dict(options)
|
|
1687
2030
|
|
|
1688
|
-
for opt in myoptions:
|
|
2031
|
+
for opt in list(myoptions.keys()):
|
|
1689
2032
|
if opt not in knownOptions:
|
|
1690
|
-
raise ValueError(f"Option {opt} is not one of {knownOptions}.")
|
|
2033
|
+
# raise ValueError(f"Option '{opt}' is not one of {knownOptions}.")
|
|
2034
|
+
self.mylog.print(f"Ignoring unknown solver option '{opt}'.")
|
|
2035
|
+
myoptions.pop(opt)
|
|
1691
2036
|
|
|
1692
2037
|
if objective not in knownObjectives:
|
|
1693
|
-
raise ValueError(f"Objective {objective} is not one of {knownObjectives}.")
|
|
2038
|
+
raise ValueError(f"Objective '{objective}' is not one of {knownObjectives}.")
|
|
1694
2039
|
|
|
1695
2040
|
if objective == "maxBequest" and "netSpending" not in myoptions:
|
|
1696
|
-
raise RuntimeError(f"Objective {objective} needs netSpending option.")
|
|
2041
|
+
raise RuntimeError(f"Objective '{objective}' needs netSpending option.")
|
|
1697
2042
|
|
|
1698
2043
|
if objective == "maxBequest" and "bequest" in myoptions:
|
|
1699
|
-
self.mylog.
|
|
2044
|
+
self.mylog.print("Ignoring bequest option provided.")
|
|
1700
2045
|
myoptions.pop("bequest")
|
|
1701
2046
|
|
|
1702
2047
|
if objective == "maxSpending" and "netSpending" in myoptions:
|
|
1703
|
-
self.mylog.
|
|
2048
|
+
self.mylog.print("Ignoring netSpending option provided.")
|
|
1704
2049
|
myoptions.pop("netSpending")
|
|
1705
2050
|
|
|
1706
2051
|
if objective == "maxSpending" and "bequest" not in myoptions:
|
|
@@ -1708,9 +2053,24 @@ class Plan(object):
|
|
|
1708
2053
|
|
|
1709
2054
|
self.optionsUnits = u.getUnits(myoptions.get("units", "k"))
|
|
1710
2055
|
|
|
1711
|
-
oppCostX =
|
|
2056
|
+
oppCostX = myoptions.get("oppCostX", 0.)
|
|
1712
2057
|
self.xnet = 1 - oppCostX / 100.
|
|
1713
2058
|
|
|
2059
|
+
if "swapRothConverters" in myoptions and "noRothConversions" in myoptions:
|
|
2060
|
+
self.mylog.print("Ignoring 'noRothConversions' as 'swapRothConverters' option present.")
|
|
2061
|
+
myoptions.pop("noRothConversions")
|
|
2062
|
+
|
|
2063
|
+
# Go easy on MILP - auto gap somehow.
|
|
2064
|
+
if "gap" not in myoptions and myoptions.get("withMedicare", "loop") == "optimize":
|
|
2065
|
+
fac = 1
|
|
2066
|
+
maxRoth = myoptions.get("maxRothConversion", 100)
|
|
2067
|
+
if maxRoth <= 15:
|
|
2068
|
+
fac = 10
|
|
2069
|
+
# Loosen default MIP gap when Medicare is optimized. Even more if rothX == 0
|
|
2070
|
+
gap = fac * MILP_GAP
|
|
2071
|
+
myoptions["gap"] = gap
|
|
2072
|
+
self.mylog.vprint(f"Using restricted gap of {gap:.1e}.")
|
|
2073
|
+
|
|
1714
2074
|
self.prevMAGI = np.zeros(2)
|
|
1715
2075
|
if "previousMAGIs" in myoptions:
|
|
1716
2076
|
magi = myoptions["previousMAGIs"]
|
|
@@ -1721,7 +2081,7 @@ class Plan(object):
|
|
|
1721
2081
|
|
|
1722
2082
|
lambdha = myoptions.get("spendingSlack", 0)
|
|
1723
2083
|
if not (0 <= lambdha <= 50):
|
|
1724
|
-
raise ValueError(f"Slack value out of range
|
|
2084
|
+
raise ValueError(f"Slack value {lambdha} out of range.")
|
|
1725
2085
|
self.lambdha = lambdha / 100
|
|
1726
2086
|
|
|
1727
2087
|
# Reset long-term capital gain tax rate and MAGI to zero.
|
|
@@ -1731,11 +2091,14 @@ class Plan(object):
|
|
|
1731
2091
|
self.M_n = np.zeros(self.N_n)
|
|
1732
2092
|
|
|
1733
2093
|
self._adjustParameters(self.gamma_n, self.MAGI_n)
|
|
1734
|
-
self._buildOffsetMap(
|
|
2094
|
+
self._buildOffsetMap(myoptions)
|
|
2095
|
+
|
|
2096
|
+
# Process debts and fixed assets
|
|
2097
|
+
self.processDebtsAndFixedAssets()
|
|
1735
2098
|
|
|
1736
2099
|
solver = myoptions.get("solver", self.defaultSolver)
|
|
1737
2100
|
if solver not in knownSolvers:
|
|
1738
|
-
raise ValueError(f"Unknown solver {solver}.")
|
|
2101
|
+
raise ValueError(f"Unknown solver '{solver}'.")
|
|
1739
2102
|
|
|
1740
2103
|
if solver == "HiGHS":
|
|
1741
2104
|
solverMethod = self._milpSolve
|
|
@@ -1746,7 +2109,10 @@ class Plan(object):
|
|
|
1746
2109
|
else:
|
|
1747
2110
|
raise RuntimeError("Internal error in defining solverMethod.")
|
|
1748
2111
|
|
|
1749
|
-
self.
|
|
2112
|
+
self.mylog.vprint(f"Using '{solver}' solver.")
|
|
2113
|
+
myoptions_txt = textwrap.fill(f"{myoptions}", initial_indent="\t", subsequent_indent="\t", width=100)
|
|
2114
|
+
self.mylog.vprint(f"Solver options:\n{myoptions_txt}.")
|
|
2115
|
+
self._scSolve(objective, myoptions, solverMethod)
|
|
1750
2116
|
|
|
1751
2117
|
self.objective = objective
|
|
1752
2118
|
self.solverOptions = myoptions
|
|
@@ -1760,6 +2126,20 @@ class Plan(object):
|
|
|
1760
2126
|
includeMedicare = options.get("withMedicare", "loop") == "loop"
|
|
1761
2127
|
withSCLoop = options.get("withSCLoop", True)
|
|
1762
2128
|
|
|
2129
|
+
# Convergence uses a relative tolerance tied to MILP gap,
|
|
2130
|
+
# with an absolute floor to avoid zero/near-zero objectives.
|
|
2131
|
+
gap = u.get_numeric_option(options, "gap", GAP, min_value=0)
|
|
2132
|
+
abs_tol = u.get_numeric_option(options, "absTol", ABS_TOL, min_value=0)
|
|
2133
|
+
rel_tol = options.get("relTol")
|
|
2134
|
+
if rel_tol is None:
|
|
2135
|
+
# Keep rel_tol aligned with solver gap to avoid SC loop chasing noise.
|
|
2136
|
+
rel_tol = max(REL_TOL, gap / 300)
|
|
2137
|
+
# rel_tol = u.get_numeric_option({"relTol": rel_tol}, "relTol", REL_TOL, min_value=0)
|
|
2138
|
+
self.mylog.print(f"Using relTol={rel_tol:.1e}, absTol={abs_tol:.1e}, and gap={gap:.1e}.")
|
|
2139
|
+
|
|
2140
|
+
max_iterations = int(u.get_numeric_option(options, "maxIter", MAX_ITERATIONS, min_value=1))
|
|
2141
|
+
self.mylog.print(f"Using maxIter={max_iterations}.")
|
|
2142
|
+
|
|
1763
2143
|
if objective == "maxSpending":
|
|
1764
2144
|
objFac = -1 / self.xi_n[0]
|
|
1765
2145
|
else:
|
|
@@ -1768,12 +2148,15 @@ class Plan(object):
|
|
|
1768
2148
|
it = 0
|
|
1769
2149
|
old_x = np.zeros(self.nvars)
|
|
1770
2150
|
old_objfns = [np.inf]
|
|
2151
|
+
scaled_obj_history = [] # Track scaled objective values for oscillation detection
|
|
2152
|
+
sol_history = [] # Track solutions aligned with scaled_obj_history
|
|
2153
|
+
obj_history = [] # Track raw objective values aligned with scaled_obj_history
|
|
1771
2154
|
self._computeNLstuff(None, includeMedicare)
|
|
1772
2155
|
while True:
|
|
1773
|
-
objfn, xx, solverSuccess, solverMsg = solverMethod(objective, options)
|
|
2156
|
+
objfn, xx, solverSuccess, solverMsg, solgap = solverMethod(objective, options)
|
|
1774
2157
|
|
|
1775
2158
|
if not solverSuccess or objfn is None:
|
|
1776
|
-
self.mylog.
|
|
2159
|
+
self.mylog.print("Solver failed:", solverMsg, solverSuccess)
|
|
1777
2160
|
break
|
|
1778
2161
|
|
|
1779
2162
|
if not withSCLoop:
|
|
@@ -1782,24 +2165,64 @@ class Plan(object):
|
|
|
1782
2165
|
self._computeNLstuff(xx, includeMedicare)
|
|
1783
2166
|
|
|
1784
2167
|
delta = xx - old_x
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
2168
|
+
# Only consider account balances in dX.
|
|
2169
|
+
absSolDiff = np.sum(np.abs(delta[:self.nbals]), axis=0)/self.nbals
|
|
2170
|
+
absObjDiff = abs(objFac*(objfn + old_objfns[-1]))
|
|
2171
|
+
scaled_obj = objfn * objFac
|
|
2172
|
+
scaled_obj_history.append(scaled_obj)
|
|
2173
|
+
sol_history.append(xx)
|
|
2174
|
+
obj_history.append(objfn)
|
|
2175
|
+
self.mylog.vprint(f"Iter: {it:02}; f: {u.d(scaled_obj, f=0)}; gap: {solgap:.1e};"
|
|
2176
|
+
f" |dX|: {absSolDiff:.0f}; |df|: {u.d(absObjDiff, f=0)}")
|
|
2177
|
+
|
|
2178
|
+
# Solution difference is calculated and reported but not used for convergence
|
|
2179
|
+
# since it scales with problem size and can prevent convergence for large cases.
|
|
2180
|
+
prev_scaled_obj = scaled_obj
|
|
2181
|
+
if np.isfinite(old_objfns[-1]):
|
|
2182
|
+
prev_scaled_obj = (-old_objfns[-1]) * objFac
|
|
2183
|
+
scale = max(1.0, abs(scaled_obj), abs(prev_scaled_obj))
|
|
2184
|
+
tol = max(abs_tol, rel_tol * scale)
|
|
2185
|
+
if absObjDiff <= tol:
|
|
2186
|
+
# Check if convergence was monotonic or oscillatory
|
|
2187
|
+
# old_objfns stores -objfn values, so we need to scale them to match displayed values
|
|
2188
|
+
# For monotonic convergence, the scaled objective (objfn * objFac) should be non-increasing
|
|
2189
|
+
# Include current iteration's scaled objfn value
|
|
2190
|
+
scaled_objfns = [(-val) * objFac for val in old_objfns[1:]] + [scaled_obj]
|
|
2191
|
+
# Check if scaled objective function is non-increasing (monotonic convergence)
|
|
2192
|
+
is_monotonic = all(scaled_objfns[i] <= scaled_objfns[i-1] + tol
|
|
2193
|
+
for i in range(1, len(scaled_objfns)))
|
|
2194
|
+
if is_monotonic:
|
|
2195
|
+
self.convergenceType = "monotonic"
|
|
2196
|
+
else:
|
|
2197
|
+
self.convergenceType = "oscillatory"
|
|
2198
|
+
self.mylog.print(f"Converged on full solution with {self.convergenceType} behavior.")
|
|
1793
2199
|
break
|
|
1794
2200
|
|
|
1795
|
-
#
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
2201
|
+
# Check for oscillation (need at least 4 iterations to detect a 2-cycle)
|
|
2202
|
+
if it >= 3:
|
|
2203
|
+
cycle_len = self._detectOscillation(scaled_obj_history, tol)
|
|
2204
|
+
if cycle_len is not None:
|
|
2205
|
+
# Find the best (maximum) objective in the cycle
|
|
2206
|
+
cycle_values = scaled_obj_history[-cycle_len:]
|
|
2207
|
+
best_idx = np.argmax(cycle_values)
|
|
2208
|
+
best_obj = cycle_values[best_idx]
|
|
2209
|
+
self.convergenceType = f"oscillatory (cycle length {cycle_len})"
|
|
2210
|
+
self.mylog.print(f"Oscillation detected: {cycle_len}-cycle pattern identified.")
|
|
2211
|
+
self.mylog.print(f"Best objective in cycle: {u.d(best_obj, f=2)}")
|
|
2212
|
+
|
|
2213
|
+
# Select the solution corresponding to the best objective in the detected cycle.
|
|
2214
|
+
cycle_solutions = sol_history[-cycle_len:]
|
|
2215
|
+
cycle_objfns = obj_history[-cycle_len:]
|
|
2216
|
+
xx = cycle_solutions[best_idx]
|
|
2217
|
+
objfn = cycle_objfns[best_idx]
|
|
2218
|
+
self.mylog.print("Using best solution from detected cycle.")
|
|
2219
|
+
|
|
2220
|
+
self.mylog.print("Accepting solution from cycle and terminating.")
|
|
2221
|
+
break
|
|
1800
2222
|
|
|
1801
|
-
if it
|
|
1802
|
-
self.
|
|
2223
|
+
if it >= max_iterations:
|
|
2224
|
+
self.convergenceType = "max iteration"
|
|
2225
|
+
self.mylog.print("Warning: Exiting loop on maximum iterations.")
|
|
1803
2226
|
break
|
|
1804
2227
|
|
|
1805
2228
|
it += 1
|
|
@@ -1807,15 +2230,15 @@ class Plan(object):
|
|
|
1807
2230
|
old_x = xx
|
|
1808
2231
|
|
|
1809
2232
|
if solverSuccess:
|
|
1810
|
-
self.mylog.
|
|
1811
|
-
self.mylog.
|
|
1812
|
-
self.mylog.
|
|
2233
|
+
self.mylog.print(f"Self-consistent loop returned after {it+1} iterations.")
|
|
2234
|
+
self.mylog.print(solverMsg)
|
|
2235
|
+
self.mylog.print(f"Objective: {u.d(objfn * objFac)}")
|
|
1813
2236
|
# self.mylog.vprint('Upper bound:', u.d(-solution.mip_dual_bound))
|
|
1814
2237
|
self._aggregateResults(xx)
|
|
1815
2238
|
self._timestamp = datetime.now().strftime("%Y-%m-%d at %H:%M:%S")
|
|
1816
2239
|
self.caseStatus = "solved"
|
|
1817
2240
|
else:
|
|
1818
|
-
self.mylog.
|
|
2241
|
+
self.mylog.print("Warning: Optimization failed:", solverMsg, solverSuccess)
|
|
1819
2242
|
self.caseStatus = "unsuccessful"
|
|
1820
2243
|
|
|
1821
2244
|
return None
|
|
@@ -1826,12 +2249,17 @@ class Plan(object):
|
|
|
1826
2249
|
"""
|
|
1827
2250
|
from scipy import optimize
|
|
1828
2251
|
|
|
2252
|
+
time_limit = u.get_numeric_option(options, "maxTime", TIME_LIMIT, min_value=0) # seconds
|
|
2253
|
+
mygap = u.get_numeric_option(options, "gap", GAP, min_value=0)
|
|
2254
|
+
verbose = options.get("verbose", False)
|
|
2255
|
+
|
|
1829
2256
|
# Optimize solver parameters
|
|
1830
2257
|
milpOptions = {
|
|
1831
|
-
"disp":
|
|
1832
|
-
"mip_rel_gap": 1e-
|
|
2258
|
+
"disp": bool(verbose),
|
|
2259
|
+
"mip_rel_gap": mygap, # Internal default in milp is 1e-4
|
|
1833
2260
|
"presolve": True,
|
|
1834
|
-
"
|
|
2261
|
+
"time_limit": time_limit,
|
|
2262
|
+
"node_limit": 1000000 # Limit search nodes for faster solutions
|
|
1835
2263
|
}
|
|
1836
2264
|
|
|
1837
2265
|
self._buildConstraints(objective, options)
|
|
@@ -1850,7 +2278,7 @@ class Plan(object):
|
|
|
1850
2278
|
options=milpOptions,
|
|
1851
2279
|
)
|
|
1852
2280
|
|
|
1853
|
-
return solution.fun, solution.x, solution.success, solution.message
|
|
2281
|
+
return solution.fun, solution.x, solution.success, solution.message, solution.mip_gap
|
|
1854
2282
|
|
|
1855
2283
|
def _pulpSolve(self, objective, options):
|
|
1856
2284
|
"""
|
|
@@ -1881,7 +2309,7 @@ class Plan(object):
|
|
|
1881
2309
|
elif vkeys[i] == "fx":
|
|
1882
2310
|
x += [pulp.LpVariable(f"x_{i}", cat="Continuous", lowBound=Lb[i], upBound=Ub[i])]
|
|
1883
2311
|
else:
|
|
1884
|
-
raise RuntimeError(f"Internal error: Variable with
|
|
2312
|
+
raise RuntimeError(f"Internal error: Variable with weird bound {vkeys[i]}.")
|
|
1885
2313
|
|
|
1886
2314
|
x.extend([pulp.LpVariable(f"z_{i}", cat="Binary") for i in range(self.nbins)])
|
|
1887
2315
|
|
|
@@ -1915,7 +2343,7 @@ class Plan(object):
|
|
|
1915
2343
|
solution = np.dot(c, xx)
|
|
1916
2344
|
success = (pulp.LpStatus[prob.status] == "Optimal")
|
|
1917
2345
|
|
|
1918
|
-
return solution, xx, success, pulp.LpStatus[prob.status]
|
|
2346
|
+
return solution, xx, success, pulp.LpStatus[prob.status], -1
|
|
1919
2347
|
|
|
1920
2348
|
def _mosekSolve(self, objective, options):
|
|
1921
2349
|
"""
|
|
@@ -1934,6 +2362,7 @@ class Plan(object):
|
|
|
1934
2362
|
solverMsg = str()
|
|
1935
2363
|
|
|
1936
2364
|
def _streamPrinter(text, msg=solverMsg):
|
|
2365
|
+
self.mylog.vprint(text.strip())
|
|
1937
2366
|
msg += text
|
|
1938
2367
|
|
|
1939
2368
|
self._buildConstraints(objective, options)
|
|
@@ -1944,10 +2373,24 @@ class Plan(object):
|
|
|
1944
2373
|
vkeys = self.B.keys()
|
|
1945
2374
|
cind, cval = self.c.lists()
|
|
1946
2375
|
|
|
2376
|
+
time_limit = u.get_numeric_option(options, "maxTime", TIME_LIMIT, min_value=0)
|
|
2377
|
+
mygap = u.get_numeric_option(options, "gap", GAP, min_value=0)
|
|
2378
|
+
|
|
2379
|
+
verbose = options.get("verbose", False)
|
|
2380
|
+
|
|
1947
2381
|
task = mosek.Task()
|
|
1948
|
-
|
|
1949
|
-
# task.putdouparam(mosek.dparam.
|
|
1950
|
-
|
|
2382
|
+
task.putdouparam(mosek.dparam.mio_max_time, time_limit) # Default -1
|
|
2383
|
+
# task.putdouparam(mosek.dparam.mio_rel_gap_const, 1e-6) # Default 1e-10
|
|
2384
|
+
task.putdouparam(mosek.dparam.mio_tol_rel_gap, mygap) # Default 1e-4
|
|
2385
|
+
# task.putdouparam(mosek.dparam.mio_tol_abs_relax_int, 2e-5) # Default 1e-5
|
|
2386
|
+
# task.putdouparam(mosek.iparam.mio_heuristic_level, 3) # Default -1
|
|
2387
|
+
|
|
2388
|
+
# task.set_Stream(mosek.streamtype.wrn, _streamPrinter)
|
|
2389
|
+
task.set_Stream(mosek.streamtype.err, _streamPrinter)
|
|
2390
|
+
if verbose:
|
|
2391
|
+
# task.set_Stream(mosek.streamtype.log, _streamPrinter)
|
|
2392
|
+
task.set_Stream(mosek.streamtype.msg, _streamPrinter)
|
|
2393
|
+
|
|
1951
2394
|
task.appendcons(self.A.ncons)
|
|
1952
2395
|
task.appendvars(self.A.nvars)
|
|
1953
2396
|
|
|
@@ -1970,34 +2413,67 @@ class Plan(object):
|
|
|
1970
2413
|
# Problem MUST contain binary variables to make these calls.
|
|
1971
2414
|
solsta = task.getsolsta(mosek.soltype.itg)
|
|
1972
2415
|
solverSuccess = (solsta == mosek.solsta.integer_optimal)
|
|
2416
|
+
rel_gap = task.getdouinf(mosek.dinfitem.mio_obj_rel_gap) if solverSuccess else -1
|
|
1973
2417
|
|
|
1974
2418
|
xx = np.array(task.getxx(mosek.soltype.itg))
|
|
1975
2419
|
solution = task.getprimalobj(mosek.soltype.itg)
|
|
1976
|
-
task.set_Stream(mosek.streamtype.wrn, _streamPrinter)
|
|
1977
2420
|
task.solutionsummary(mosek.streamtype.msg)
|
|
1978
2421
|
# task.writedata(self._name+'.ptf')
|
|
1979
2422
|
|
|
1980
|
-
return solution, xx, solverSuccess, solverMsg
|
|
2423
|
+
return solution, xx, solverSuccess, solverMsg, rel_gap
|
|
1981
2424
|
|
|
1982
|
-
def
|
|
1983
|
-
"""
|
|
1984
|
-
Compute ACA tax on Dividends (Q) and Interests (I).
|
|
1985
|
-
Pass arguments to better understand dependencies.
|
|
1986
|
-
For accounting for rent and/or trust income, one can easily add a column
|
|
1987
|
-
to the Wages and Contributions file and add yearly amount to Q_n + I_n below.
|
|
2425
|
+
def _detectOscillation(self, obj_history, tolerance, max_cycle_length=15):
|
|
1988
2426
|
"""
|
|
1989
|
-
|
|
1990
|
-
status = len(self.yobs) - 1
|
|
2427
|
+
Detect if the objective function is oscillating in a repeating cycle.
|
|
1991
2428
|
|
|
1992
|
-
for
|
|
1993
|
-
|
|
1994
|
-
|
|
2429
|
+
This function checks for repeating patterns of any length (2, 3, 4, etc.)
|
|
2430
|
+
in the recent objective function history. It handles numerical precision
|
|
2431
|
+
by using a tolerance for "close enough" matching.
|
|
2432
|
+
|
|
2433
|
+
Parameters
|
|
2434
|
+
----------
|
|
2435
|
+
obj_history : list
|
|
2436
|
+
List of recent objective function values (most recent last)
|
|
2437
|
+
tolerance : float
|
|
2438
|
+
Tolerance for considering two values "equal" (same as convergence tolerance)
|
|
2439
|
+
max_cycle_length : int
|
|
2440
|
+
Maximum cycle length to check for (default 15)
|
|
2441
|
+
|
|
2442
|
+
Returns
|
|
2443
|
+
-------
|
|
2444
|
+
int or None
|
|
2445
|
+
Cycle length if oscillation detected, None otherwise
|
|
2446
|
+
"""
|
|
2447
|
+
if len(obj_history) < 4: # Need at least 4 values to detect a 2-cycle
|
|
2448
|
+
return None
|
|
1995
2449
|
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
2450
|
+
# Check for cycles of length 2, 3, 4, ... up to max_cycle_length
|
|
2451
|
+
# We need at least 2*cycle_length values to confirm a cycle
|
|
2452
|
+
for cycle_len in range(2, min(max_cycle_length + 1, len(obj_history) // 2 + 1)):
|
|
2453
|
+
# Check if the last cycle_len values match the previous cycle_len values
|
|
2454
|
+
if len(obj_history) < 2 * cycle_len:
|
|
2455
|
+
continue
|
|
2456
|
+
|
|
2457
|
+
recent = obj_history[-cycle_len:]
|
|
2458
|
+
previous = obj_history[-2*cycle_len:-cycle_len]
|
|
2459
|
+
|
|
2460
|
+
# Check if all pairs match within tolerance
|
|
2461
|
+
matches = all(abs(recent[i] - previous[i]) <= tolerance
|
|
2462
|
+
for i in range(cycle_len))
|
|
2463
|
+
|
|
2464
|
+
if matches:
|
|
2465
|
+
# Verify it's a true cycle by checking one more period back if available
|
|
2466
|
+
if len(obj_history) >= 3 * cycle_len:
|
|
2467
|
+
earlier = obj_history[-3*cycle_len:-2*cycle_len]
|
|
2468
|
+
if all(abs(recent[i] - earlier[i]) <= tolerance
|
|
2469
|
+
for i in range(cycle_len)):
|
|
2470
|
+
return cycle_len
|
|
2471
|
+
else:
|
|
2472
|
+
# If we don't have enough history, still report the cycle
|
|
2473
|
+
# but it's less certain
|
|
2474
|
+
return cycle_len
|
|
1999
2475
|
|
|
2000
|
-
return
|
|
2476
|
+
return None
|
|
2001
2477
|
|
|
2002
2478
|
def _computeNLstuff(self, x, includeMedicare):
|
|
2003
2479
|
"""
|
|
@@ -2013,8 +2489,14 @@ class Plan(object):
|
|
|
2013
2489
|
|
|
2014
2490
|
self._aggregateResults(x, short=True)
|
|
2015
2491
|
|
|
2016
|
-
self.J_n =
|
|
2017
|
-
|
|
2492
|
+
self.J_n = tx.computeNIIT(self.N_i, self.MAGI_n, self.I_n, self.Q_n, self.n_d, self.N_n)
|
|
2493
|
+
ltcg_n = np.maximum(self.Q_n, 0)
|
|
2494
|
+
tx_income_n = self.e_n + ltcg_n
|
|
2495
|
+
cg_tax_n = tx.capitalGainTax(self.N_i, tx_income_n, ltcg_n, self.gamma_n[:-1], self.n_d, self.N_n)
|
|
2496
|
+
self.psi_n = np.zeros(self.N_n)
|
|
2497
|
+
has_ltcg = ltcg_n > 0
|
|
2498
|
+
self.psi_n[has_ltcg] = cg_tax_n[has_ltcg] / ltcg_n[has_ltcg]
|
|
2499
|
+
self.U_n = cg_tax_n
|
|
2018
2500
|
# Compute Medicare through self-consistent loop.
|
|
2019
2501
|
if includeMedicare:
|
|
2020
2502
|
self.M_n = tx.mediCosts(self.yobs, self.horizons, self.MAGI_n, self.prevMAGI, self.gamma_n[:-1], self.N_n)
|
|
@@ -2082,27 +2564,34 @@ class Plan(object):
|
|
|
2082
2564
|
self.G_n = np.sum(self.f_tn, axis=0)
|
|
2083
2565
|
|
|
2084
2566
|
tau_0 = np.array(self.tau_kn[0, :])
|
|
2085
|
-
tau_0[tau_0 < 0] = 0
|
|
2086
2567
|
# Last year's rates.
|
|
2087
2568
|
tau_0prev = np.roll(tau_0, 1)
|
|
2569
|
+
# Capital gains = price appreciation only (total return - dividend rate)
|
|
2570
|
+
# to avoid double taxation of dividends. No tax harvesting here.
|
|
2571
|
+
capital_gains_rate = np.maximum(0, tau_0prev - self.mu)
|
|
2088
2572
|
self.Q_n = np.sum(
|
|
2089
2573
|
(
|
|
2090
2574
|
self.mu
|
|
2091
2575
|
* (self.b_ijn[:, 0, :Nn] - self.w_ijn[:, 0, :] + self.d_in[:, :] + 0.5 * self.kappa_ijn[:, 0, :Nn])
|
|
2092
|
-
+
|
|
2576
|
+
+ capital_gains_rate * self.w_ijn[:, 0, :]
|
|
2093
2577
|
)
|
|
2094
2578
|
* self.alpha_ijkn[:, 0, 0, :Nn],
|
|
2095
2579
|
axis=0,
|
|
2096
2580
|
)
|
|
2581
|
+
# Add fixed assets capital gains.
|
|
2582
|
+
self.Q_n += self.fixed_assets_capital_gains_n
|
|
2097
2583
|
self.U_n = self.psi_n * self.Q_n
|
|
2098
2584
|
|
|
2099
|
-
|
|
2585
|
+
# Also add back non-taxable part of SS.
|
|
2586
|
+
self.MAGI_n = (self.G_n + self.e_n + self.Q_n
|
|
2587
|
+
+ np.sum((1 - self.Psi_n) * self.zetaBar_in, axis=0))
|
|
2100
2588
|
|
|
2101
2589
|
I_in = ((self.b_ijn[:, 0, :-1] + self.d_in - self.w_ijn[:, 0, :])
|
|
2102
2590
|
* np.sum(self.alpha_ijkn[:, 0, 1:, :Nn] * self.tau_kn[1:, :], axis=1))
|
|
2103
|
-
|
|
2591
|
+
# Clamp interest/dividend income to non-negative. Sum over individuals to share losses across spouses.
|
|
2592
|
+
self.I_n = np.maximum(0, np.sum(I_in, axis=0))
|
|
2104
2593
|
|
|
2105
|
-
# Stop after building
|
|
2594
|
+
# Stop after building minimum required for self-consistent loop.
|
|
2106
2595
|
if short:
|
|
2107
2596
|
return
|
|
2108
2597
|
|
|
@@ -2165,6 +2654,13 @@ class Plan(object):
|
|
|
2165
2654
|
sources["RothX"] = self.x_in
|
|
2166
2655
|
sources["tax-free wdrwl"] = self.w_ijn[:, 2, :]
|
|
2167
2656
|
sources["BTI"] = self.Lambda_in
|
|
2657
|
+
# Debts and fixed assets (debts are negative as expenses)
|
|
2658
|
+
# Show as household totals, not split between individuals
|
|
2659
|
+
# Reshape to (1, N_n) to indicate household-level source
|
|
2660
|
+
sources["FA ord inc"] = self.fixed_assets_ordinary_income_n.reshape(1, -1)
|
|
2661
|
+
sources["FA cap gains"] = self.fixed_assets_capital_gains_n.reshape(1, -1)
|
|
2662
|
+
sources["FA tax-free"] = self.fixed_assets_tax_free_n.reshape(1, -1)
|
|
2663
|
+
sources["debt pmts"] = -self.debt_payments_n.reshape(1, -1)
|
|
2168
2664
|
|
|
2169
2665
|
savings = {}
|
|
2170
2666
|
savings["taxable"] = self.b_ijn[:, 0, :]
|
|
@@ -2176,7 +2672,9 @@ class Plan(object):
|
|
|
2176
2672
|
|
|
2177
2673
|
estate_j = np.sum(self.b_ijn[:, :, self.N_n], axis=0)
|
|
2178
2674
|
estate_j[1] *= 1 - self.nu
|
|
2179
|
-
|
|
2675
|
+
# Subtract remaining debt balance from estate
|
|
2676
|
+
total_estate = np.sum(estate_j) - self.remaining_debt_balance
|
|
2677
|
+
self.bequest = max(0.0, total_estate) / self.gamma_n[-1]
|
|
2180
2678
|
|
|
2181
2679
|
self.basis = self.g_n[0] / self.xi_n[0]
|
|
2182
2680
|
|
|
@@ -2194,100 +2692,114 @@ class Plan(object):
|
|
|
2194
2692
|
return None
|
|
2195
2693
|
|
|
2196
2694
|
@_checkCaseStatus
|
|
2197
|
-
def summary(self):
|
|
2695
|
+
def summary(self, N=None):
|
|
2198
2696
|
"""
|
|
2199
2697
|
Print summary in logs.
|
|
2200
2698
|
"""
|
|
2201
2699
|
self.mylog.print("SUMMARY ================================================================")
|
|
2202
|
-
dic = self.summaryDic()
|
|
2700
|
+
dic = self.summaryDic(N)
|
|
2203
2701
|
for key, value in dic.items():
|
|
2204
2702
|
self.mylog.print(f"{key}: {value}")
|
|
2205
2703
|
self.mylog.print("------------------------------------------------------------------------")
|
|
2206
2704
|
|
|
2207
2705
|
return None
|
|
2208
2706
|
|
|
2209
|
-
def summaryList(self):
|
|
2707
|
+
def summaryList(self, N=None):
|
|
2210
2708
|
"""
|
|
2211
2709
|
Return summary as a list.
|
|
2212
2710
|
"""
|
|
2213
2711
|
mylist = []
|
|
2214
|
-
dic = self.summaryDic()
|
|
2712
|
+
dic = self.summaryDic(N)
|
|
2215
2713
|
for key, value in dic.items():
|
|
2216
2714
|
mylist.append(f"{key}: {value}")
|
|
2217
2715
|
|
|
2218
2716
|
return mylist
|
|
2219
2717
|
|
|
2220
|
-
def summaryDf(self):
|
|
2718
|
+
def summaryDf(self, N=None):
|
|
2221
2719
|
"""
|
|
2222
2720
|
Return summary as a dataframe.
|
|
2223
2721
|
"""
|
|
2224
|
-
return pd.DataFrame(self.summaryDic(), index=[self._name])
|
|
2722
|
+
return pd.DataFrame(self.summaryDic(N), index=[self._name])
|
|
2225
2723
|
|
|
2226
|
-
def summaryString(self):
|
|
2724
|
+
def summaryString(self, N=None):
|
|
2227
2725
|
"""
|
|
2228
2726
|
Return summary as a string.
|
|
2229
2727
|
"""
|
|
2230
2728
|
string = "Synopsis\n"
|
|
2231
|
-
dic = self.summaryDic()
|
|
2729
|
+
dic = self.summaryDic(N)
|
|
2232
2730
|
for key, value in dic.items():
|
|
2233
|
-
string += f"{key:>
|
|
2731
|
+
string += f"{key:>77}: {value}\n"
|
|
2234
2732
|
|
|
2235
2733
|
return string
|
|
2236
2734
|
|
|
2237
|
-
def summaryDic(self):
|
|
2735
|
+
def summaryDic(self, N=None):
|
|
2238
2736
|
"""
|
|
2239
2737
|
Return dictionary containing summary of values.
|
|
2240
2738
|
"""
|
|
2739
|
+
if N is None:
|
|
2740
|
+
N = self.N_n
|
|
2741
|
+
if not (0 < N <= self.N_n):
|
|
2742
|
+
raise ValueError(f"Value N={N} is out of reange")
|
|
2743
|
+
|
|
2241
2744
|
now = self.year_n[0]
|
|
2242
2745
|
dic = {}
|
|
2243
2746
|
# Results
|
|
2244
|
-
dic["
|
|
2747
|
+
dic["Case name"] = self._name
|
|
2245
2748
|
dic["Net yearly spending basis" + 26*" ."] = u.d(self.g_n[0] / self.xi_n[0])
|
|
2246
2749
|
dic[f"Net spending for year {now}"] = u.d(self.g_n[0])
|
|
2247
2750
|
dic[f"Net spending remaining in year {now}"] = u.d(self.g_n[0] * self.yearFracLeft)
|
|
2248
2751
|
|
|
2249
|
-
totSpending = np.sum(self.g_n, axis=0)
|
|
2250
|
-
totSpendingNow = np.sum(self.g_n / self.gamma_n[
|
|
2752
|
+
totSpending = np.sum(self.g_n[:N], axis=0)
|
|
2753
|
+
totSpendingNow = np.sum(self.g_n[:N] / self.gamma_n[:N], axis=0)
|
|
2251
2754
|
dic[" Total net spending"] = f"{u.d(totSpendingNow)}"
|
|
2252
2755
|
dic["[Total net spending]"] = f"{u.d(totSpending)}"
|
|
2253
2756
|
|
|
2254
|
-
totRoth = np.sum(self.x_in, axis=(0, 1))
|
|
2255
|
-
totRothNow = np.sum(np.sum(self.x_in, axis=0) / self.gamma_n[
|
|
2757
|
+
totRoth = np.sum(self.x_in[:, :N], axis=(0, 1))
|
|
2758
|
+
totRothNow = np.sum(np.sum(self.x_in[:, :N], axis=0) / self.gamma_n[:N], axis=0)
|
|
2256
2759
|
dic[" Total Roth conversions"] = f"{u.d(totRothNow)}"
|
|
2257
2760
|
dic["[Total Roth conversions]"] = f"{u.d(totRoth)}"
|
|
2258
2761
|
|
|
2259
|
-
taxPaid = np.sum(self.T_n, axis=0)
|
|
2260
|
-
taxPaidNow = np.sum(self.T_n / self.gamma_n[
|
|
2762
|
+
taxPaid = np.sum(self.T_n[:N], axis=0)
|
|
2763
|
+
taxPaidNow = np.sum(self.T_n[:N] / self.gamma_n[:N], axis=0)
|
|
2261
2764
|
dic[" Total tax paid on ordinary income"] = f"{u.d(taxPaidNow)}"
|
|
2262
2765
|
dic["[Total tax paid on ordinary income]"] = f"{u.d(taxPaid)}"
|
|
2263
2766
|
for t in range(self.N_t):
|
|
2264
|
-
taxPaid = np.sum(self.T_tn[t], axis=0)
|
|
2265
|
-
taxPaidNow = np.sum(self.T_tn[t] / self.gamma_n[
|
|
2266
|
-
|
|
2767
|
+
taxPaid = np.sum(self.T_tn[t, :N], axis=0)
|
|
2768
|
+
taxPaidNow = np.sum(self.T_tn[t, :N] / self.gamma_n[:N], axis=0)
|
|
2769
|
+
if t >= len(tx.taxBracketNames):
|
|
2770
|
+
tname = f"Bracket {t}"
|
|
2771
|
+
else:
|
|
2772
|
+
tname = tx.taxBracketNames[t]
|
|
2267
2773
|
dic[f"» Subtotal in tax bracket {tname}"] = f"{u.d(taxPaidNow)}"
|
|
2268
2774
|
dic[f"» [Subtotal in tax bracket {tname}]"] = f"{u.d(taxPaid)}"
|
|
2269
2775
|
|
|
2270
|
-
penaltyPaid = np.sum(self.P_n, axis=0)
|
|
2271
|
-
penaltyPaidNow = np.sum(self.P_n / self.gamma_n[
|
|
2776
|
+
penaltyPaid = np.sum(self.P_n[:N], axis=0)
|
|
2777
|
+
penaltyPaidNow = np.sum(self.P_n[:N] / self.gamma_n[:N], axis=0)
|
|
2272
2778
|
dic["» Subtotal in early withdrawal penalty"] = f"{u.d(penaltyPaidNow)}"
|
|
2273
2779
|
dic["» [Subtotal in early withdrawal penalty]"] = f"{u.d(penaltyPaid)}"
|
|
2274
2780
|
|
|
2275
|
-
taxPaid = np.sum(self.U_n, axis=0)
|
|
2276
|
-
taxPaidNow = np.sum(self.U_n / self.gamma_n[
|
|
2781
|
+
taxPaid = np.sum(self.U_n[:N], axis=0)
|
|
2782
|
+
taxPaidNow = np.sum(self.U_n[:N] / self.gamma_n[:N], axis=0)
|
|
2277
2783
|
dic[" Total tax paid on gains and dividends"] = f"{u.d(taxPaidNow)}"
|
|
2278
2784
|
dic["[Total tax paid on gains and dividends]"] = f"{u.d(taxPaid)}"
|
|
2279
2785
|
|
|
2280
|
-
taxPaid = np.sum(self.J_n, axis=0)
|
|
2281
|
-
taxPaidNow = np.sum(self.J_n / self.gamma_n[
|
|
2786
|
+
taxPaid = np.sum(self.J_n[:N], axis=0)
|
|
2787
|
+
taxPaidNow = np.sum(self.J_n[:N] / self.gamma_n[:N], axis=0)
|
|
2282
2788
|
dic[" Total net investment income tax paid"] = f"{u.d(taxPaidNow)}"
|
|
2283
2789
|
dic["[Total net investment income tax paid]"] = f"{u.d(taxPaid)}"
|
|
2284
2790
|
|
|
2285
|
-
taxPaid = np.sum(self.m_n + self.M_n, axis=0)
|
|
2286
|
-
taxPaidNow = np.sum((self.m_n + self.M_n) / self.gamma_n[
|
|
2791
|
+
taxPaid = np.sum(self.m_n[:N] + self.M_n[:N], axis=0)
|
|
2792
|
+
taxPaidNow = np.sum((self.m_n[:N] + self.M_n[:N]) / self.gamma_n[:N], axis=0)
|
|
2287
2793
|
dic[" Total Medicare premiums paid"] = f"{u.d(taxPaidNow)}"
|
|
2288
2794
|
dic["[Total Medicare premiums paid]"] = f"{u.d(taxPaid)}"
|
|
2289
2795
|
|
|
2290
|
-
|
|
2796
|
+
totDebtPayments = np.sum(self.debt_payments_n[:N], axis=0)
|
|
2797
|
+
if totDebtPayments > 0:
|
|
2798
|
+
totDebtPaymentsNow = np.sum(self.debt_payments_n[:N] / self.gamma_n[:N], axis=0)
|
|
2799
|
+
dic[" Total debt payments"] = f"{u.d(totDebtPaymentsNow)}"
|
|
2800
|
+
dic["[Total debt payments]"] = f"{u.d(totDebtPayments)}"
|
|
2801
|
+
|
|
2802
|
+
if self.N_i == 2 and self.n_d < self.N_n and N == self.N_n:
|
|
2291
2803
|
p_j = self.partialEstate_j * (1 - self.phi_j)
|
|
2292
2804
|
p_j[1] *= 1 - self.nu
|
|
2293
2805
|
nx = self.n_d - 1
|
|
@@ -2298,49 +2810,66 @@ class Plan(object):
|
|
|
2298
2810
|
totSpousal = np.sum(q_j)
|
|
2299
2811
|
iname_s = self.inames[self.i_s]
|
|
2300
2812
|
iname_d = self.inames[self.i_d]
|
|
2301
|
-
dic["Year of partial bequest"] =
|
|
2302
|
-
dic[f" Sum of spousal transfer to {iname_s}"] =
|
|
2303
|
-
dic[f"[Sum of spousal transfer to {iname_s}]"] =
|
|
2304
|
-
dic[f"» Spousal transfer to {iname_s} - taxable"] =
|
|
2305
|
-
dic[f"» [Spousal transfer to {iname_s} - taxable]"] =
|
|
2306
|
-
dic[f"» Spousal transfer to {iname_s} - tax-def"] =
|
|
2307
|
-
dic[f"» [Spousal transfer to {iname_s} - tax-def]"] =
|
|
2308
|
-
dic[f"» Spousal transfer to {iname_s} - tax-free"] =
|
|
2309
|
-
dic[f"» [Spousal transfer to {iname_s} - tax-free]"] =
|
|
2310
|
-
|
|
2311
|
-
dic[f" Sum of post-tax non-spousal bequest from {iname_d}"] =
|
|
2312
|
-
dic[f"[Sum of post-tax non-spousal bequest from {iname_d}]"] =
|
|
2313
|
-
dic[f"» Post-tax non-spousal bequest from {iname_d} - taxable"] =
|
|
2314
|
-
dic[f"» [Post-tax non-spousal bequest from {iname_d} - taxable]"] =
|
|
2315
|
-
dic[f"» Post-tax non-spousal bequest from {iname_d} - tax-def"] =
|
|
2316
|
-
dic[f"» [Post-tax non-spousal bequest from {iname_d} - tax-def]"] =
|
|
2317
|
-
dic[f"» Post-tax non-spousal bequest from {iname_d} - tax-free"] =
|
|
2318
|
-
dic[f"» [Post-tax non-spousal bequest from {iname_d} - tax-free]"] =
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2813
|
+
dic["Year of partial bequest"] = f"{ynx}"
|
|
2814
|
+
dic[f" Sum of spousal transfer to {iname_s}"] = f"{u.d(ynxNow*totSpousal)}"
|
|
2815
|
+
dic[f"[Sum of spousal transfer to {iname_s}]"] = f"{u.d(totSpousal)}"
|
|
2816
|
+
dic[f"» Spousal transfer to {iname_s} - taxable"] = f"{u.d(ynxNow*q_j[0])}"
|
|
2817
|
+
dic[f"» [Spousal transfer to {iname_s} - taxable]"] = f"{u.d(q_j[0])}"
|
|
2818
|
+
dic[f"» Spousal transfer to {iname_s} - tax-def"] = f"{u.d(ynxNow*q_j[1])}"
|
|
2819
|
+
dic[f"» [Spousal transfer to {iname_s} - tax-def]"] = f"{u.d(q_j[1])}"
|
|
2820
|
+
dic[f"» Spousal transfer to {iname_s} - tax-free"] = f"{u.d(ynxNow*q_j[2])}"
|
|
2821
|
+
dic[f"» [Spousal transfer to {iname_s} - tax-free]"] = f"{u.d(q_j[2])}"
|
|
2822
|
+
|
|
2823
|
+
dic[f" Sum of post-tax non-spousal bequest from {iname_d}"] = f"{u.d(ynxNow*totOthers)}"
|
|
2824
|
+
dic[f"[Sum of post-tax non-spousal bequest from {iname_d}]"] = f"{u.d(totOthers)}"
|
|
2825
|
+
dic[f"» Post-tax non-spousal bequest from {iname_d} - taxable"] = f"{u.d(ynxNow*p_j[0])}"
|
|
2826
|
+
dic[f"» [Post-tax non-spousal bequest from {iname_d} - taxable]"] = f"{u.d(p_j[0])}"
|
|
2827
|
+
dic[f"» Post-tax non-spousal bequest from {iname_d} - tax-def"] = f"{u.d(ynxNow*p_j[1])}"
|
|
2828
|
+
dic[f"» [Post-tax non-spousal bequest from {iname_d} - tax-def]"] = f"{u.d(p_j[1])}"
|
|
2829
|
+
dic[f"» Post-tax non-spousal bequest from {iname_d} - tax-free"] = f"{u.d(ynxNow*p_j[2])}"
|
|
2830
|
+
dic[f"» [Post-tax non-spousal bequest from {iname_d} - tax-free]"] = f"{u.d(p_j[2])}"
|
|
2831
|
+
|
|
2832
|
+
if N == self.N_n:
|
|
2833
|
+
estate = np.sum(self.b_ijn[:, :, self.N_n], axis=0)
|
|
2834
|
+
heirsTaxLiability = estate[1] * self.nu
|
|
2835
|
+
estate[1] *= 1 - self.nu
|
|
2836
|
+
endyear = self.year_n[-1]
|
|
2837
|
+
lyNow = 1./self.gamma_n[-1]
|
|
2838
|
+
# Add fixed assets bequest value (assets with yod past plan end)
|
|
2839
|
+
debts = self.remaining_debt_balance
|
|
2840
|
+
savingsEstate = np.sum(estate)
|
|
2841
|
+
totEstate = savingsEstate - debts + self.fixed_assets_bequest_value
|
|
2842
|
+
|
|
2843
|
+
dic["Year of final bequest"] = f"{endyear}"
|
|
2844
|
+
dic[" Total after-tax value of final bequest"] = f"{u.d(lyNow*totEstate)}"
|
|
2845
|
+
dic["» After-tax value of savings assets"] = f"{u.d(lyNow*savingsEstate)}"
|
|
2846
|
+
dic["» Fixed assets liquidated at end of plan"] = f"{u.d(lyNow*self.fixed_assets_bequest_value)}"
|
|
2847
|
+
dic["» With heirs assuming tax liability of"] = f"{u.d(lyNow*heirsTaxLiability)}"
|
|
2848
|
+
dic["» After paying remaining debts of"] = f"{u.d(lyNow*debts)}"
|
|
2849
|
+
|
|
2850
|
+
dic["[Total after-tax value of final bequest]"] = f"{u.d(totEstate)}"
|
|
2851
|
+
dic["[» After-tax value of savings assets]"] = f"{u.d(savingsEstate)}"
|
|
2852
|
+
dic["[» Fixed assets liquidated at end of plan]"] = f"{u.d(self.fixed_assets_bequest_value)}"
|
|
2853
|
+
dic["[» With heirs assuming tax liability of"] = f"{u.d(heirsTaxLiability)}"
|
|
2854
|
+
dic["[» After paying remaining debts of]"] = f"{u.d(debts)}"
|
|
2855
|
+
|
|
2856
|
+
dic["» Post-tax final bequest account value - taxable"] = f"{u.d(lyNow*estate[0])}"
|
|
2857
|
+
dic["» [Post-tax final bequest account value - taxable]"] = f"{u.d(estate[0])}"
|
|
2858
|
+
dic["» Post-tax final bequest account value - tax-def"] = f"{u.d(lyNow*estate[1])}"
|
|
2859
|
+
dic["» [Post-tax final bequest account value - tax-def]"] = f"{u.d(estate[1])}"
|
|
2860
|
+
dic["» Post-tax final bequest account value - tax-free"] = f"{u.d(lyNow*estate[2])}"
|
|
2861
|
+
dic["» [Post-tax final bequest account value - tax-free]"] = f"{u.d(estate[2])}"
|
|
2862
|
+
|
|
2863
|
+
dic["Case starting date"] = str(self.startDate)
|
|
2864
|
+
dic["Cumulative inflation factor at end of final year"] = f"{self.gamma_n[N]:.2f}"
|
|
2337
2865
|
for i in range(self.N_i):
|
|
2338
|
-
dic[f"{self.inames[i]:>14}'s life horizon"] =
|
|
2339
|
-
dic[f"{self.inames[i]:>14}'s years planned"] =
|
|
2866
|
+
dic[f"{self.inames[i]:>14}'s life horizon"] = f"{now} -> {now + self.horizons[i] - 1}"
|
|
2867
|
+
dic[f"{self.inames[i]:>14}'s years planned"] = f"{self.horizons[i]}"
|
|
2340
2868
|
|
|
2341
|
-
dic["
|
|
2869
|
+
dic["Case name"] = self._name
|
|
2342
2870
|
dic["Number of decision variables"] = str(self.A.nvars)
|
|
2343
2871
|
dic["Number of constraints"] = str(self.A.ncons)
|
|
2872
|
+
dic["Convergence"] = self.convergenceType
|
|
2344
2873
|
dic["Case executed on"] = str(self._timestamp)
|
|
2345
2874
|
|
|
2346
2875
|
return dic
|
|
@@ -2352,9 +2881,26 @@ class Plan(object):
|
|
|
2352
2881
|
A tag string can be set to add information to the title of the plot.
|
|
2353
2882
|
"""
|
|
2354
2883
|
if self.rateMethod in [None, "user", "historical average", "conservative"]:
|
|
2355
|
-
self.mylog.
|
|
2884
|
+
self.mylog.print(f"Warning: Cannot plot correlations for {self.rateMethod} rate method.")
|
|
2356
2885
|
return None
|
|
2357
2886
|
|
|
2887
|
+
# Check if rates are constant (all values are the same for each rate type)
|
|
2888
|
+
# This can happen with fixed rates
|
|
2889
|
+
if self.tau_kn is not None:
|
|
2890
|
+
# Check if all rates are constant (no variation)
|
|
2891
|
+
rates_are_constant = True
|
|
2892
|
+
for k in range(self.N_k):
|
|
2893
|
+
# Check if all values in this rate series are (approximately) the same
|
|
2894
|
+
rate_std = np.std(self.tau_kn[k])
|
|
2895
|
+
# Use a small threshold to account for floating point precision
|
|
2896
|
+
if rate_std > 1e-10: # If standard deviation is non-zero, rates vary
|
|
2897
|
+
rates_are_constant = False
|
|
2898
|
+
break
|
|
2899
|
+
|
|
2900
|
+
if rates_are_constant:
|
|
2901
|
+
self.mylog.print("Warning: Cannot plot correlations for constant rates (no variation in rate values).")
|
|
2902
|
+
return None
|
|
2903
|
+
|
|
2358
2904
|
fig = self._plotter.plot_rates_correlations(self._name, self.tau_kn, self.N_n, self.rateMethod,
|
|
2359
2905
|
self.rateFrm, self.rateTo, tag, shareRange)
|
|
2360
2906
|
|
|
@@ -2383,7 +2929,7 @@ class Plan(object):
|
|
|
2383
2929
|
A tag string can be set to add information to the title of the plot.
|
|
2384
2930
|
"""
|
|
2385
2931
|
if self.rateMethod is None:
|
|
2386
|
-
self.mylog.
|
|
2932
|
+
self.mylog.print("Warning: Rate method must be selected before plotting.")
|
|
2387
2933
|
return None
|
|
2388
2934
|
|
|
2389
2935
|
fig = self._plotter.plot_rates(self._name, self.tau_kn, self.year_n,
|
|
@@ -2402,7 +2948,7 @@ class Plan(object):
|
|
|
2402
2948
|
A tag string can be set to add information to the title of the plot.
|
|
2403
2949
|
"""
|
|
2404
2950
|
if self.xi_n is None:
|
|
2405
|
-
self.mylog.
|
|
2951
|
+
self.mylog.print("Warning: Profile must be selected before plotting.")
|
|
2406
2952
|
return None
|
|
2407
2953
|
title = self._name + "\nSpending Profile"
|
|
2408
2954
|
if tag:
|
|
@@ -2572,7 +3118,7 @@ class Plan(object):
|
|
|
2572
3118
|
self._plotter.jupyter_renderer(fig)
|
|
2573
3119
|
return None
|
|
2574
3120
|
|
|
2575
|
-
def saveWorkbook(self, overwrite=False, *, basename=None, saveToFile=True):
|
|
3121
|
+
def saveWorkbook(self, overwrite=False, *, basename=None, saveToFile=True, with_config="no"):
|
|
2576
3122
|
"""
|
|
2577
3123
|
Save instance in an Excel spreadsheet.
|
|
2578
3124
|
The first worksheet will contain income in the following
|
|
@@ -2581,10 +3127,10 @@ class Plan(object):
|
|
|
2581
3127
|
- taxable ordinary income
|
|
2582
3128
|
- taxable dividends
|
|
2583
3129
|
- tax bills (federal only, including IRMAA)
|
|
2584
|
-
for all the years for the time span of the
|
|
3130
|
+
for all the years for the time span of the case.
|
|
2585
3131
|
|
|
2586
3132
|
The second worksheet contains the rates
|
|
2587
|
-
used for the
|
|
3133
|
+
used for the case as follows:
|
|
2588
3134
|
- S&P 500
|
|
2589
3135
|
- Corporate Baa bonds
|
|
2590
3136
|
- Treasury notes (10y)
|
|
@@ -2606,7 +3152,30 @@ class Plan(object):
|
|
|
2606
3152
|
- tax-free account.
|
|
2607
3153
|
|
|
2608
3154
|
Last worksheet contains summary.
|
|
3155
|
+
|
|
3156
|
+
with_config controls whether to insert the current case configuration
|
|
3157
|
+
as a TOML sheet. Valid values are:
|
|
3158
|
+
- "no": do not include config
|
|
3159
|
+
- "first": insert config as the first sheet
|
|
3160
|
+
- "last": insert config as the last sheet
|
|
2609
3161
|
"""
|
|
3162
|
+
def add_config_sheet(position):
|
|
3163
|
+
if with_config == "no":
|
|
3164
|
+
return
|
|
3165
|
+
if with_config not in {"no", "first", "last"}:
|
|
3166
|
+
raise ValueError(f"Invalid with_config option '{with_config}'.")
|
|
3167
|
+
if position != with_config:
|
|
3168
|
+
return
|
|
3169
|
+
|
|
3170
|
+
from io import StringIO
|
|
3171
|
+
|
|
3172
|
+
config_buffer = StringIO()
|
|
3173
|
+
config.saveConfig(self, config_buffer, self.mylog)
|
|
3174
|
+
config_buffer.seek(0)
|
|
3175
|
+
|
|
3176
|
+
ws_config = wb.create_sheet(title="Config (.toml)", index=0 if position == "first" else None)
|
|
3177
|
+
for row_idx, line in enumerate(config_buffer.getvalue().splitlines(), start=1):
|
|
3178
|
+
ws_config.cell(row=row_idx, column=1, value=line)
|
|
2610
3179
|
|
|
2611
3180
|
def fillsheet(sheet, dic, datatype, op=lambda x: x):
|
|
2612
3181
|
rawData = {}
|
|
@@ -2626,6 +3195,7 @@ class Plan(object):
|
|
|
2626
3195
|
_formatSpreadsheet(ws, datatype)
|
|
2627
3196
|
|
|
2628
3197
|
wb = Workbook()
|
|
3198
|
+
add_config_sheet("first")
|
|
2629
3199
|
|
|
2630
3200
|
# Income.
|
|
2631
3201
|
ws = wb.active
|
|
@@ -2647,6 +3217,10 @@ class Plan(object):
|
|
|
2647
3217
|
"all pensions": np.sum(self.piBar_in, axis=0),
|
|
2648
3218
|
"all soc sec": np.sum(self.zetaBar_in, axis=0),
|
|
2649
3219
|
"all BTI's": np.sum(self.Lambda_in, axis=0),
|
|
3220
|
+
"FA ord inc": self.fixed_assets_ordinary_income_n,
|
|
3221
|
+
"FA cap gains": self.fixed_assets_capital_gains_n,
|
|
3222
|
+
"FA tax-free": self.fixed_assets_tax_free_n,
|
|
3223
|
+
"debt pmts": -self.debt_payments_n,
|
|
2650
3224
|
"all wdrwls": np.sum(self.w_ijn, axis=(0, 1)),
|
|
2651
3225
|
"all deposits": -np.sum(self.d_in, axis=0),
|
|
2652
3226
|
"ord taxes": -self.T_n - self.J_n,
|
|
@@ -2675,6 +3249,16 @@ class Plan(object):
|
|
|
2675
3249
|
ws = wb.create_sheet(sname)
|
|
2676
3250
|
fillsheet(ws, srcDic, "currency", op=lambda x: x[i]) # noqa: B023
|
|
2677
3251
|
|
|
3252
|
+
# Household sources (debts and fixed assets)
|
|
3253
|
+
householdSrcDic = {
|
|
3254
|
+
"FA ord inc": self.sources_in["FA ord inc"],
|
|
3255
|
+
"FA cap gains": self.sources_in["FA cap gains"],
|
|
3256
|
+
"FA tax-free": self.sources_in["FA tax-free"],
|
|
3257
|
+
"debt pmts": self.sources_in["debt pmts"],
|
|
3258
|
+
}
|
|
3259
|
+
ws = wb.create_sheet("Household Sources")
|
|
3260
|
+
fillsheet(ws, householdSrcDic, "currency", op=lambda x: x[0])
|
|
3261
|
+
|
|
2678
3262
|
# Account balances except final year.
|
|
2679
3263
|
accDic = {
|
|
2680
3264
|
"taxable bal": self.b_ijn[:, 0, :-1],
|
|
@@ -2713,6 +3297,20 @@ class Plan(object):
|
|
|
2713
3297
|
ws.append(lastRow)
|
|
2714
3298
|
_formatSpreadsheet(ws, "currency")
|
|
2715
3299
|
|
|
3300
|
+
# Federal income tax brackets.
|
|
3301
|
+
TxDic = {}
|
|
3302
|
+
for t in range(self.N_t):
|
|
3303
|
+
TxDic[tx.taxBracketNames[t]] = self.T_tn[t, :]
|
|
3304
|
+
|
|
3305
|
+
TxDic["total"] = self.T_n
|
|
3306
|
+
TxDic["NIIT"] = self.J_n
|
|
3307
|
+
TxDic["LTCG"] = self.U_n
|
|
3308
|
+
TxDic["10% penalty"] = self.P_n
|
|
3309
|
+
|
|
3310
|
+
sname = "Federal Income Tax"
|
|
3311
|
+
ws = wb.create_sheet(sname)
|
|
3312
|
+
fillsheet(ws, TxDic, "currency")
|
|
3313
|
+
|
|
2716
3314
|
# Allocations.
|
|
2717
3315
|
jDic = {"taxable": 0, "tax-deferred": 1, "tax-free": 2}
|
|
2718
3316
|
kDic = {"stocks": 0, "C bonds": 1, "T notes": 2, "common": 3}
|
|
@@ -2755,6 +3353,7 @@ class Plan(object):
|
|
|
2755
3353
|
ws.append(row)
|
|
2756
3354
|
|
|
2757
3355
|
_formatSpreadsheet(ws, "summary")
|
|
3356
|
+
add_config_sheet("last")
|
|
2758
3357
|
|
|
2759
3358
|
if saveToFile:
|
|
2760
3359
|
if basename is None:
|
|
@@ -2774,11 +3373,29 @@ class Plan(object):
|
|
|
2774
3373
|
|
|
2775
3374
|
planData = {}
|
|
2776
3375
|
planData["year"] = self.year_n
|
|
3376
|
+
|
|
3377
|
+
# Income data
|
|
2777
3378
|
planData["net spending"] = self.g_n
|
|
2778
3379
|
planData["taxable ord. income"] = self.G_n
|
|
2779
3380
|
planData["taxable gains/divs"] = self.Q_n
|
|
2780
|
-
planData["
|
|
2781
|
-
|
|
3381
|
+
planData["Tax bills + Med."] = self.T_n + self.U_n + self.m_n + self.M_n + self.J_n
|
|
3382
|
+
|
|
3383
|
+
# Cash flow data (matching Cash Flow worksheet)
|
|
3384
|
+
planData["all wages"] = np.sum(self.omega_in, axis=0)
|
|
3385
|
+
planData["all pensions"] = np.sum(self.piBar_in, axis=0)
|
|
3386
|
+
planData["all soc sec"] = np.sum(self.zetaBar_in, axis=0)
|
|
3387
|
+
planData["all BTI's"] = np.sum(self.Lambda_in, axis=0)
|
|
3388
|
+
planData["FA ord inc"] = self.fixed_assets_ordinary_income_n
|
|
3389
|
+
planData["FA cap gains"] = self.fixed_assets_capital_gains_n
|
|
3390
|
+
planData["FA tax-free"] = self.fixed_assets_tax_free_n
|
|
3391
|
+
planData["debt pmts"] = -self.debt_payments_n
|
|
3392
|
+
planData["all wdrwls"] = np.sum(self.w_ijn, axis=(0, 1))
|
|
3393
|
+
planData["all deposits"] = -np.sum(self.d_in, axis=0)
|
|
3394
|
+
planData["ord taxes"] = -self.T_n - self.J_n
|
|
3395
|
+
planData["div taxes"] = -self.U_n
|
|
3396
|
+
planData["Medicare"] = -self.m_n - self.M_n
|
|
3397
|
+
|
|
3398
|
+
# Individual account data
|
|
2782
3399
|
for i in range(self.N_i):
|
|
2783
3400
|
planData[self.inames[i] + " txbl bal"] = self.b_ijn[i, 0, :-1]
|
|
2784
3401
|
planData[self.inames[i] + " txbl dep"] = self.d_in[i, :]
|
|
@@ -2793,6 +3410,7 @@ class Plan(object):
|
|
|
2793
3410
|
planData[self.inames[i] + " tax-free wdrwl"] = self.w_ijn[i, 2, :]
|
|
2794
3411
|
planData[self.inames[i] + " big-ticket items"] = self.Lambda_in[i, :]
|
|
2795
3412
|
|
|
3413
|
+
# Rates
|
|
2796
3414
|
ratesDic = {"S&P 500": 0, "Corporate Baa": 1, "T Bonds": 2, "inflation": 3}
|
|
2797
3415
|
for key in ratesDic:
|
|
2798
3416
|
planData[key] = self.tau_kn[ratesDic[key]]
|
|
@@ -2842,7 +3460,7 @@ def _saveWorkbook(wb, basename, overwrite, mylog):
|
|
|
2842
3460
|
mylog.print(f'File "{fname}" already exists.')
|
|
2843
3461
|
key = input("Overwrite? [Ny] ")
|
|
2844
3462
|
if key != "y":
|
|
2845
|
-
mylog.
|
|
3463
|
+
mylog.print("Skipping save and returning.")
|
|
2846
3464
|
return None
|
|
2847
3465
|
|
|
2848
3466
|
for _ in range(3):
|
|
@@ -2889,8 +3507,102 @@ def _formatSpreadsheet(ws, ftype):
|
|
|
2889
3507
|
# col[0].style = 'Title'
|
|
2890
3508
|
width = max(len(str(col[0].value)) + 4, 10)
|
|
2891
3509
|
ws.column_dimensions[column].width = width
|
|
2892
|
-
if column
|
|
3510
|
+
if column == "A":
|
|
3511
|
+
# Format year column as integer without commas
|
|
3512
|
+
for cell in col:
|
|
3513
|
+
cell.number_format = "0"
|
|
3514
|
+
else:
|
|
2893
3515
|
for cell in col:
|
|
2894
3516
|
cell.number_format = fstring
|
|
2895
3517
|
|
|
2896
3518
|
return None
|
|
3519
|
+
|
|
3520
|
+
|
|
3521
|
+
def _formatDebtsSheet(ws):
|
|
3522
|
+
"""
|
|
3523
|
+
Format Debts sheet with appropriate column formatting.
|
|
3524
|
+
"""
|
|
3525
|
+
from openpyxl.utils import get_column_letter
|
|
3526
|
+
|
|
3527
|
+
# Format header row
|
|
3528
|
+
for cell in ws[1]:
|
|
3529
|
+
cell.style = "Pandas"
|
|
3530
|
+
|
|
3531
|
+
# Get column mapping from header
|
|
3532
|
+
header_row = ws[1]
|
|
3533
|
+
col_map = {}
|
|
3534
|
+
for idx, cell in enumerate(header_row, start=1):
|
|
3535
|
+
col_letter = get_column_letter(idx)
|
|
3536
|
+
col_name = str(cell.value).lower() if cell.value else ""
|
|
3537
|
+
col_map[col_letter] = col_name
|
|
3538
|
+
# Set column width
|
|
3539
|
+
width = max(len(str(cell.value)) + 4, 10)
|
|
3540
|
+
ws.column_dimensions[col_letter].width = width
|
|
3541
|
+
|
|
3542
|
+
# Apply formatting based on column name
|
|
3543
|
+
for col_letter, col_name in col_map.items():
|
|
3544
|
+
if col_name in ["year", "term"]:
|
|
3545
|
+
# Integer format without commas
|
|
3546
|
+
fstring = "0"
|
|
3547
|
+
elif col_name in ["rate"]:
|
|
3548
|
+
# Number format (2 decimal places for percentages stored as numbers)
|
|
3549
|
+
fstring = "#,##0.00"
|
|
3550
|
+
elif col_name in ["amount"]:
|
|
3551
|
+
# Currency format
|
|
3552
|
+
fstring = "$#,##0_);[Red]($#,##0)"
|
|
3553
|
+
else:
|
|
3554
|
+
# Text columns (name, type) - no number formatting
|
|
3555
|
+
continue
|
|
3556
|
+
|
|
3557
|
+
# Apply formatting to all data rows (skip header row 1)
|
|
3558
|
+
for row in ws.iter_rows(min_row=2):
|
|
3559
|
+
for cell in row:
|
|
3560
|
+
if cell.column_letter == col_letter:
|
|
3561
|
+
cell.number_format = fstring
|
|
3562
|
+
|
|
3563
|
+
return None
|
|
3564
|
+
|
|
3565
|
+
|
|
3566
|
+
def _formatFixedAssetsSheet(ws):
|
|
3567
|
+
"""
|
|
3568
|
+
Format Fixed Assets sheet with appropriate column formatting.
|
|
3569
|
+
"""
|
|
3570
|
+
from openpyxl.utils import get_column_letter
|
|
3571
|
+
|
|
3572
|
+
# Format header row
|
|
3573
|
+
for cell in ws[1]:
|
|
3574
|
+
cell.style = "Pandas"
|
|
3575
|
+
|
|
3576
|
+
# Get column mapping from header
|
|
3577
|
+
header_row = ws[1]
|
|
3578
|
+
col_map = {}
|
|
3579
|
+
for idx, cell in enumerate(header_row, start=1):
|
|
3580
|
+
col_letter = get_column_letter(idx)
|
|
3581
|
+
col_name = str(cell.value).lower() if cell.value else ""
|
|
3582
|
+
col_map[col_letter] = col_name
|
|
3583
|
+
# Set column width
|
|
3584
|
+
width = max(len(str(cell.value)) + 4, 10)
|
|
3585
|
+
ws.column_dimensions[col_letter].width = width
|
|
3586
|
+
|
|
3587
|
+
# Apply formatting based on column name
|
|
3588
|
+
for col_letter, col_name in col_map.items():
|
|
3589
|
+
if col_name in ["yod"]:
|
|
3590
|
+
# Integer format without commas
|
|
3591
|
+
fstring = "0"
|
|
3592
|
+
elif col_name in ["rate", "commission"]:
|
|
3593
|
+
# Number format (1 decimal place for percentages stored as numbers)
|
|
3594
|
+
fstring = "#,##0.00"
|
|
3595
|
+
elif col_name in ["basis", "value"]:
|
|
3596
|
+
# Currency format
|
|
3597
|
+
fstring = "$#,##0_);[Red]($#,##0)"
|
|
3598
|
+
else:
|
|
3599
|
+
# Text columns (name, type) - no number formatting
|
|
3600
|
+
continue
|
|
3601
|
+
|
|
3602
|
+
# Apply formatting to all data rows (skip header row 1)
|
|
3603
|
+
for row in ws.iter_rows(min_row=2):
|
|
3604
|
+
for cell in row:
|
|
3605
|
+
if cell.column_letter == col_letter:
|
|
3606
|
+
cell.number_format = fstring
|
|
3607
|
+
|
|
3608
|
+
return None
|