owlplanner 2025.12.20__py3-none-any.whl → 2026.2.2__py3-none-any.whl

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