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.
Files changed (38) hide show
  1. owlplanner/In Discussion #58, the case of Kim and Sam.md +307 -0
  2. owlplanner/__init__.py +20 -1
  3. owlplanner/abcapi.py +24 -23
  4. owlplanner/cli/README.md +50 -0
  5. owlplanner/cli/_main.py +52 -0
  6. owlplanner/cli/cli_logging.py +56 -0
  7. owlplanner/cli/cmd_list.py +83 -0
  8. owlplanner/cli/cmd_run.py +86 -0
  9. owlplanner/config.py +315 -136
  10. owlplanner/data/__init__.py +21 -0
  11. owlplanner/data/awi.csv +75 -0
  12. owlplanner/data/bendpoints.csv +49 -0
  13. owlplanner/data/newawi.csv +75 -0
  14. owlplanner/data/rates.csv +99 -98
  15. owlplanner/debts.py +315 -0
  16. owlplanner/fixedassets.py +288 -0
  17. owlplanner/mylogging.py +157 -25
  18. owlplanner/plan.py +1044 -332
  19. owlplanner/plotting/__init__.py +16 -3
  20. owlplanner/plotting/base.py +17 -3
  21. owlplanner/plotting/factory.py +16 -3
  22. owlplanner/plotting/matplotlib_backend.py +30 -7
  23. owlplanner/plotting/plotly_backend.py +33 -10
  24. owlplanner/progress.py +66 -9
  25. owlplanner/rates.py +366 -361
  26. owlplanner/socialsecurity.py +142 -22
  27. owlplanner/tax2026.py +170 -57
  28. owlplanner/timelists.py +316 -32
  29. owlplanner/utils.py +204 -5
  30. owlplanner/version.py +20 -1
  31. {owlplanner-2025.12.5.dist-info → owlplanner-2026.1.26.dist-info}/METADATA +50 -158
  32. owlplanner-2026.1.26.dist-info/RECORD +36 -0
  33. owlplanner-2026.1.26.dist-info/entry_points.txt +2 -0
  34. owlplanner-2026.1.26.dist-info/licenses/AUTHORS +15 -0
  35. owlplanner/tax2025.py +0 -339
  36. owlplanner-2025.12.5.dist-info/RECORD +0 -24
  37. {owlplanner-2025.12.5.dist-info → owlplanner-2026.1.26.dist-info}/WHEEL +0 -0
  38. {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
- Owl/plan
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 © 2024 - Martin-D. Lacasse
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
- Disclaimers: This code is for educational purposes only and does not constitute financial advice.
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 tax2025 as tx
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 plan
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
- # Can't deepcopy variables containing file descriptors.
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
- newplan.setLogger(mylogger)
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.vprint(f"Preventing to run method {func.__name__}() while case is {self.caseStatus}.")
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.vprint(msg)
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.vprint(msg)
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(object):
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
- def __init__(self, inames, yobs, mobs, expectancy, name, *, verbose=False, logstreams=None):
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 plan.
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
- # 2 binary variables.
236
- self.N_zx = 2
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(yobs)
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 = np.array(yobs, dtype=np.int32)
272
- self.mobs = np.array(mobs, dtype=np.int32)
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 plan (if any) where individuals turn 59. For 10% withdrawal penalty.
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
- self.chi = 0.6 # Survivor fraction
296
- self.mu = 0.018 # Dividend rate (decimal)
297
- self.nu = 0.30 # Heirs tax rate (decimal)
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
- # self.mylog.vprint(f"Setting logstreams to {logstreams}.")
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 plan starts in the current year.
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 plan. Plan name is used
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 plan {self._name} -> {newname}.")
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 plan.
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.vprint("Deposit fraction can only be 0 for single individuals.")
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.vprint("Consider changing spousal deposit fraction for better convergence.")
514
- self.mylog.vprint(f"\tRecommended: setSpousalDepositFraction({self.i_d}.)")
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
- "Social security monthly benefits set to", [u.d(pias[i]) for i in range(self.N_i)],
587
- "at FRAs(s)", [fras[i] for i in range(self.N_i)],
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
- realage = ages[i] + (self.mobs[i] - 1)/12
598
- iage = int(realage)
599
- realns = iage - thisyear + self.yobs[i]
600
- ns = max(0, realns)
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 realns < 0, this has happened already.
604
- if realns >= 0:
605
- self.zeta_in[i, ns] *= 1 - (realage % 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
- ns2 = max(0, int(claimYear) - thisyear)
615
- spousalFactor = socsec.getSpousalFactor(fras[i], claimAge)
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 * (claimYear % 1.)
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 setRates(self, method, frm=None, to=None, values=None, stdev=None, corr=None):
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
- dr = rates.Rates(self.mylog)
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
- filename, self.timeLists = timelists.read(filename, self.inames, self.horizons, self.mylog)
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 Wages and Contributions: {e}") from e
1074
+ raise Exception(f"Unsuccessful read of Household Financial Profile: {e}") from e
912
1075
 
913
- self.timeListsFileName = filename
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+5] = np.roll(self.timeLists[iname]["taxable ctrb"], -5)
935
- self.kappa_ijn[i, 1, :h+5] = np.roll(self.timeLists[iname]["401k ctrb"], -5)
936
- self.kappa_ijn[i, 1, :h+5] += np.roll(self.timeLists[iname]["IRA ctrb"], -5)
937
- self.kappa_ijn[i, 2, :h+5] = np.roll(self.timeLists[iname]["Roth 401k ctrb"], -5)
938
- self.kappa_ijn[i, 2, :h+5] += np.roll(self.timeLists[iname]["Roth IRA ctrb"], -5)
939
- self.myRothX_in[i, :h+5] = np.roll(self.timeLists[iname]["Roth conv"], -5)
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.N_i, self.N_n, self.N_zx)
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["maxRothConversion"]
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
- self.B.setRange(_q2(self.C["x"], i, n, self.N_i, self.N_n), 0, rhsopt + 0.01)
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["startRothConversions"]
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 "noRothConversions" in options and options["noRothConversions"] != "None":
1216
- rhsopt = options["noRothConversions"]
1217
- try:
1218
- i_x = self.inames.index(rhsopt)
1219
- except ValueError as e:
1220
- raise ValueError(f"Unknown individual {rhsopt} for noRothConversions:") from e
1221
- for n in range(self.N_n):
1222
- self.B.setRange(_q2(self.C["x"], i_x, n, self.N_i, self.N_n), 0, 0)
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["bequest"]
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, bequest, bequest)
1557
+ self.A.addRow(row, total_bequest_value, total_bequest_value)
1258
1558
  elif objective == "maxBequest":
1259
- spending = options["netSpending"]
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
- # Prevent surplus on last year.
1286
- self.B.setRange(_q1(self.C["s"], self.N_n - 1, self.N_n), 0, 0)
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
- rhs = 0
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] + 0.85 * self.zetaBar_in[i, n] + self.piBar_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("xorConstraints", True):
1702
+ if not options.get("amoConstraints", True):
1394
1703
  return
1395
1704
 
1396
- bigM = options.get("bigM", 5e6)
1397
- if not isinstance(bigM, (int, float)):
1398
- raise ValueError(f"bigM {bigM} is not a number.")
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
- {_q3(self.C["zx"], i, n, 0, self.N_i, self.N_n, self.N_zx): bigM,
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
- bigM,
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
- {_q3(self.C["zx"], i, n, 1, self.N_i, self.N_n, self.N_zx): bigM,
1419
- _q2(self.C["x"], i, n, self.N_i, self.N_n): -1},
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
- {_q3(self.C["zx"], i, n, 1, self.N_i, self.N_n, self.N_zx): bigM,
1425
- _q3(self.C["w"], i, 2, n, self.N_i, self.N_j, self.N_n): 1},
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
- bigM = options.get("bigM", 5e6)
1438
- if not isinstance(bigM, (int, float)):
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
- -np.inf, bigM - self.L_nq[nn, q] + self.prevMAGI[n])
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
- rhs1 = bigM - self.L_nq[nn, q]
1457
- rhs2 = self.L_nq[nn, q]
1458
- row1 = self.A.newRow()
1459
- row2 = self.A.newRow()
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
- row1.addElem(_q2(self.C["zm"], nn, q, Nmed, self.N_q - 1), +bigM)
1462
- row2.addElem(_q2(self.C["zm"], nn, q, Nmed, self.N_q - 1), -bigM)
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
- row1.addElem(_q3(self.C["w"], i, 1, n2, self.N_i, self.N_j, self.N_n), -1)
1465
- row2.addElem(_q3(self.C["w"], i, 1, n2, self.N_i, self.N_j, self.N_n), +1)
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
- row1.addElem(_q2(self.C["d"], i, n2, self.N_i, self.N_n), -afac)
1477
- row2.addElem(_q2(self.C["d"], i, n2, self.N_i, self.N_n), +afac)
1801
+ row.addElem(_q3(self.C["b"], i, 0, n2, self.N_i, self.N_j, self.N_n + 1), +afac)
1802
+ row.addElem(_q2(self.C["d"], i, n2, self.N_i, self.N_n), +afac)
1478
1803
 
1479
- bfac = self.alpha_ijkn[i, 0, 0, n2] * max(0, self.tau_kn[0, max(0, n2-1)])
1480
- row1.addElem(_q3(self.C["w"], i, 0, n2, self.N_i, self.N_j, self.N_n), +afac - bfac)
1481
- row2.addElem(_q3(self.C["w"], i, 0, n2, self.N_i, self.N_j, self.N_n), -afac + bfac)
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
- sumoni = (self.omega_in[i, n2] + self.psi_n[n2] * self.zetaBar_in[i, n2] + self.piBar_in[i, n2]
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
- rhs1 += sumoni
1486
- rhs2 -= sumoni
1816
+ rhs -= sumoni
1487
1817
 
1488
- self.A.addRow(row1, -np.inf, rhs1)
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.vprint(f"Warning: Upper bound for year range re-adjusted to {yend}.")
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
- "bigM",
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
- "xorConstraints",
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.vprint("Ignoring bequest option provided.")
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.vprint("Ignoring netSpending option provided.")
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 = options.get("oppCostX", 0.)
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 {lambdha}.")
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(options)
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._scSolve(objective, options, solverMethod)
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.vprint("Solver failed:", solverMsg, solverSuccess)
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
- absSolDiff = np.sum(np.abs(delta), axis=0)/100
1786
- absObjDiff = abs(objFac*(objfn + old_objfns[-1]))/100
1787
- self.mylog.vprint(f"Iteration: {it} objective: {u.d(objfn * objFac, f=2)},"
1788
- f" |dX|: {absSolDiff:.2f}, |df|: {u.d(absObjDiff, f=2)}")
1789
-
1790
- # 50 cents accuracy.
1791
- if absSolDiff < .5 and absObjDiff < .5:
1792
- self.mylog.vprint("Converged on full solution.")
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
- # Avoid oscillatory solutions. Look only at most recent solutions. Within $10.
1796
- isclosenough = abs(-objfn - min(old_objfns[int(it / 2) :])) < 10 * self.xi_n[0]
1797
- if isclosenough:
1798
- self.mylog.vprint("Converged through selecting minimum oscillating objective.")
1799
- break
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 > 59:
1802
- self.mylog.vprint("WARNING: Exiting loop on maximum iterations.")
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.vprint(f"Self-consistent loop returned after {it+1} iterations.")
1811
- self.mylog.vprint(solverMsg)
1812
- self.mylog.vprint(f"Objective: {u.d(objfn * objFac)}")
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.vprint("WARNING: Optimization failed:", solverMsg, solverSuccess)
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": False,
1832
- "mip_rel_gap": 1e-7,
2258
+ "disp": bool(verbose),
2259
+ "mip_rel_gap": mygap, # Internal default in milp is 1e-4
1833
2260
  "presolve": True,
1834
- "node_limit": 1000000 # Limit search nodes for faster solutions
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 wierd bound f{vkeys[i]}.")
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
- # task.putdouparam(mosek.dparam.mio_rel_gap_const, 1e-6)
1949
- # task.putdouparam(mosek.dparam.mio_tol_abs_relax_int, 1e-4)
1950
- # task.set_Stream(mosek.streamtype.msg, _streamPrinter)
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 _computeNIIT(self, MAGI_n, I_n, Q_n):
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
- J_n = np.zeros(self.N_n)
1990
- status = len(self.yobs) - 1
2427
+ Detect if the objective function is oscillating in a repeating cycle.
1991
2428
 
1992
- for n in range(self.N_n):
1993
- if status and n == self.n_d:
1994
- status -= 1
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
- Gmax = tx.niitThreshold[status]
1997
- if MAGI_n[n] > Gmax:
1998
- J_n[n] = tx.niitRate * min(MAGI_n[n] - Gmax, I_n[n] + Q_n[n])
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 J_n
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 = self._computeNIIT(self.MAGI_n, self.I_n, self.Q_n)
2017
- self.psi_n = tx.capitalGainTaxRate(self.N_i, self.MAGI_n, self.gamma_n[:-1], self.n_d, self.N_n)
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
- + tau_0prev * self.w_ijn[:, 0, :]
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
- self.MAGI_n = self.G_n + self.e_n + self.Q_n
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
- self.I_n = np.sum(I_in, axis=0)
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 minimu required for self-consistent loop.
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
- self.bequest = np.sum(estate_j) / self.gamma_n[-1]
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:>70}: {value}\n"
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["Plan name"] = self._name
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[:-1], axis=0)
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[:-1], axis=0)
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[:-1], axis=0)
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[:-1], axis=0)
2266
- tname = tx.taxBracketNames[t]
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[:-1], axis=0)
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[:-1], axis=0)
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[:-1], axis=0)
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[:-1], axis=0)
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
- if self.N_i == 2 and self.n_d < self.N_n:
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"] = (f"{ynx}")
2302
- dic[f" Sum of spousal transfer to {iname_s}"] = (f"{u.d(ynxNow*totSpousal)}")
2303
- dic[f"[Sum of spousal transfer to {iname_s}]"] = (f"{u.d(totSpousal)}")
2304
- dic[f"» Spousal transfer to {iname_s} - taxable"] = (f"{u.d(ynxNow*q_j[0])}")
2305
- dic[f"» [Spousal transfer to {iname_s} - taxable]"] = (f"{u.d(q_j[0])}")
2306
- dic[f"» Spousal transfer to {iname_s} - tax-def"] = (f"{u.d(ynxNow*q_j[1])}")
2307
- dic[f"» [Spousal transfer to {iname_s} - tax-def]"] = (f"{u.d(q_j[1])}")
2308
- dic[f"» Spousal transfer to {iname_s} - tax-free"] = (f"{u.d(ynxNow*q_j[2])}")
2309
- dic[f"» [Spousal transfer to {iname_s} - tax-free]"] = (f"{u.d(q_j[2])}")
2310
-
2311
- dic[f" Sum of post-tax non-spousal bequest from {iname_d}"] = (f"{u.d(ynxNow*totOthers)}")
2312
- dic[f"[Sum of post-tax non-spousal bequest from {iname_d}]"] = (f"{u.d(totOthers)}")
2313
- dic[f"» Post-tax non-spousal bequest from {iname_d} - taxable"] = (f"{u.d(ynxNow*p_j[0])}")
2314
- dic[f"» [Post-tax non-spousal bequest from {iname_d} - taxable]"] = (f"{u.d(p_j[0])}")
2315
- dic[f"» Post-tax non-spousal bequest from {iname_d} - tax-def"] = (f"{u.d(ynxNow*p_j[1])}")
2316
- dic[f"» [Post-tax non-spousal bequest from {iname_d} - tax-def]"] = (f"{u.d(p_j[1])}")
2317
- dic[f"» Post-tax non-spousal bequest from {iname_d} - tax-free"] = (f"{u.d(ynxNow*p_j[2])}")
2318
- dic[f"» [Post-tax non-spousal bequest from {iname_d} - tax-free]"] = (f"{u.d(p_j[2])}")
2319
-
2320
- estate = np.sum(self.b_ijn[:, :, self.N_n], axis=0)
2321
- estate[1] *= 1 - self.nu
2322
- endyear = self.year_n[-1]
2323
- lyNow = 1./self.gamma_n[-1]
2324
- totEstate = np.sum(estate)
2325
- dic["Year of final bequest"] = (f"{endyear}")
2326
- dic[" Total value of final bequest"] = (f"{u.d(lyNow*totEstate)}")
2327
- dic["[Total value of final bequest]"] = (f"{u.d(totEstate)}")
2328
- dic["» Post-tax final bequest account value - taxable"] = (f"{u.d(lyNow*estate[0])}")
2329
- dic["» [Post-tax final bequest account value - taxable]"] = (f"{u.d(estate[0])}")
2330
- dic["» Post-tax final bequest account value - tax-def"] = (f"{u.d(lyNow*estate[1])}")
2331
- dic["» [Post-tax final bequest account value - tax-def]"] = (f"{u.d(estate[1])}")
2332
- dic["» Post-tax final bequest account value - tax-free"] = (f"{u.d(lyNow*estate[2])}")
2333
- dic["» [Post-tax final bequest account value - tax-free]"] = (f"{u.d(estate[2])}")
2334
-
2335
- dic["Plan starting date"] = str(self.startDate)
2336
- dic["Cumulative inflation factor at end of final year"] = (f"{self.gamma_n[-1]:.2f}")
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"] = (f"{now} -> {now + self.horizons[i] - 1}")
2339
- dic[f"{self.inames[i]:>14}'s years planned"] = (f"{self.horizons[i]}")
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["Plan name"] = self._name
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.vprint(f"Warning: Cannot plot correlations for {self.rateMethod} rate method.")
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.vprint("Warning: Rate method must be selected before plotting.")
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.vprint("Warning: Profile must be selected before plotting.")
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 plan.
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 plan as follows:
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["tax bill"] = self.T_n
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.vprint("Skipping save and returning.")
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 != "A":
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