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

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