owlplanner 2025.5.3__py3-none-any.whl → 2025.5.12__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
@@ -16,26 +16,21 @@ Disclaimer: This program comes with no guarantee. Use at your own risk.
16
16
  ###########################################################################
17
17
  import numpy as np
18
18
  import pandas as pd
19
- import matplotlib.pyplot as plt
20
19
  from datetime import date, datetime
21
20
  from functools import wraps
22
21
  from openpyxl import Workbook
23
22
  from openpyxl.utils.dataframe import dataframe_to_rows
24
23
  import time
25
- import io
26
24
 
27
- from owlplanner import utils as u
28
- from owlplanner import tax2025 as tx
29
- from owlplanner import abcapi as abc
30
- from owlplanner import rates
31
- from owlplanner import config
32
- from owlplanner import timelists
33
- from owlplanner import logging
34
- from owlplanner import progress
35
-
36
-
37
- # This makes all graphs to have the same height.
38
- plt.rcParams.update({'figure.autolayout': True})
25
+ from . import utils as u
26
+ from . import tax2025 as tx
27
+ from . import abcapi as abc
28
+ from . import rates
29
+ from . import config
30
+ from . import timelists
31
+ from . import mylogging as log
32
+ from . import progress
33
+ from .plotting.factory import PlotFactory
39
34
 
40
35
 
41
36
  def _genGamma_n(tau):
@@ -246,14 +241,20 @@ class Plan(object):
246
241
  self._description = ''
247
242
  self.defaultPlots = "nominal"
248
243
  self.defaultSolver = "HiGHS"
244
+ self._plotterName = None
245
+ self.setPlotBackend("matplotlib")
249
246
 
250
247
  self.N_i = len(yobs)
251
- assert 0 < self.N_i and self.N_i <= 2, f"Cannot support {self.N_i} individuals."
252
- assert self.N_i == len(expectancy), f"Expectancy must have {self.N_i} entries."
253
- assert self.N_i == len(inames), f"Names for individuals must have {self.N_i} entries."
254
- assert inames[0] != "" or (self.N_i == 2 and inames[1] == ""), "Name for each individual must be provided."
255
-
256
- self.filingStatus = ["single", "married"][self.N_i - 1]
248
+ if not (0 <= self.N_i <= 2):
249
+ raise ValueError(f"Cannot support {self.N_i} individuals.")
250
+ if self.N_i != len(expectancy):
251
+ raise ValueError(f"Expectancy must have {self.N_i} entries.")
252
+ if self.N_i != len(inames):
253
+ raise ValueError(f"Names for individuals must have {self.N_i} entries.")
254
+ if inames[0] == "" or (self.N_i == 2 and inames[1] == ""):
255
+ raise ValueError("Name for each individual must be provided.")
256
+
257
+ self.filingStatus = ("single", "married")[self.N_i - 1]
257
258
  # Default year TCJA is speculated to expire.
258
259
  self.yTCJA = 2026
259
260
  self.inames = inames
@@ -263,10 +264,9 @@ class Plan(object):
263
264
  # Reference time is starting date in the current year and all passings are assumed at the end.
264
265
  thisyear = date.today().year
265
266
  self.horizons = self.yobs + self.expectancy - thisyear + 1
266
- # self.horizons = [yobs[i] + expectancy[i] - thisyear + 1 for i in range(self.N_i)]
267
267
  self.N_n = np.max(self.horizons)
268
268
  self.year_n = np.linspace(thisyear, thisyear + self.N_n - 1, self.N_n, dtype=np.int32)
269
- # Year in the plan (if any) where individuals turn 59. For 10% withdrawal penalty.
269
+ # Year index in the plan (if any) where individuals turn 59. For 10% withdrawal penalty.
270
270
  self.n59 = 59 - thisyear + self.yobs
271
271
  self.n59[self.n59 < 0] = 0
272
272
  # Handle passing of one spouse before the other.
@@ -311,7 +311,7 @@ class Plan(object):
311
311
  self.lambdha = 0
312
312
 
313
313
  # Scenario starts at the beginning of this year and ends at the end of the last year.
314
- s = ["", "s"][self.N_i - 1]
314
+ s = ("", "s")[self.N_i - 1]
315
315
  self.mylog.vprint(f"Preparing scenario of {self.N_n} years for {self.N_i} individual{s}.")
316
316
  for i in range(self.N_i):
317
317
  endyear = thisyear + self.horizons[i] - 1
@@ -346,7 +346,7 @@ class Plan(object):
346
346
  self.mylog = logger
347
347
 
348
348
  def setLogstreams(self, verbose, logstreams):
349
- self.mylog = logging.Logger(verbose, logstreams)
349
+ self.mylog = log.Logger(verbose, logstreams)
350
350
  # self.mylog.vprint(f"Setting logstreams to {logstreams}.")
351
351
 
352
352
  def logger(self):
@@ -400,13 +400,11 @@ class Plan(object):
400
400
  if value is None:
401
401
  return self.defaultPlots
402
402
 
403
- opts = ["nominal", "today"]
404
- if value in opts:
405
- return value
403
+ opts = ("nominal", "today")
404
+ if value not in opts:
405
+ raise ValueError(f"Value type must be one of: {opts}")
406
406
 
407
- raise ValueError(f"Value type must be one of: {opts}")
408
-
409
- return None
407
+ return value
410
408
 
411
409
  def rename(self, newname):
412
410
  """
@@ -433,7 +431,8 @@ class Plan(object):
433
431
  where s_n is the surplus amount. Here d_0n is the taxable account
434
432
  deposit for the first spouse while d_1n is for the second spouse.
435
433
  """
436
- assert 0 <= eta and eta <= 1, "Fraction must be between 0 and 1."
434
+ if not (0 <= eta <= 1):
435
+ raise ValueError("Fraction must be between 0 and 1.")
437
436
  if self.N_i != 2:
438
437
  self.mylog.vprint("Deposit fraction can only be 0 for single individuals.")
439
438
  eta = 0
@@ -450,13 +449,26 @@ class Plan(object):
450
449
  self.defaultPlots = self._checkValue(value)
451
450
  self.mylog.vprint(f"Setting plots default value to {value}.")
452
451
 
452
+ def setPlotBackend(self, backend: str):
453
+ """
454
+ Set plotting backend.
455
+ """
456
+
457
+ if backend not in ("matplotlib", "plotly"):
458
+ raise ValueError(f"Backend {backend} not a valid option.")
459
+
460
+ if backend != self._plotterName:
461
+ self._plotter = PlotFactory.createBackend(backend)
462
+ self.mylog.vprint(f"Setting plotting backend to {backend}.")
463
+
453
464
  def setDividendRate(self, mu):
454
465
  """
455
- Set dividend rate on equities. Rate is in percent. Default 2%.
466
+ Set dividend tax rate. Rate is in percent. Default 2%.
456
467
  """
457
- assert 0 <= mu and mu <= 100, "Rate must be between 0 and 100."
468
+ if not (0 <= mu <= 100):
469
+ raise ValueError("Rate must be between 0 and 100.")
458
470
  mu /= 100
459
- self.mylog.vprint(f"Dividend return rate on equities set to {u.pc(mu, f=1)}.")
471
+ self.mylog.vprint(f"Dividend tax rate set to {u.pc(mu, f=0)}.")
460
472
  self.mu = mu
461
473
  self.caseStatus = "modified"
462
474
 
@@ -473,7 +485,8 @@ class Plan(object):
473
485
  """
474
486
  Set long-term income tax rate. Rate is in percent. Default 15%.
475
487
  """
476
- assert 0 <= psi and psi <= 100, "Rate must be between 0 and 100."
488
+ if not (0 <= psi <= 100):
489
+ raise ValueError("Rate must be between 0 and 100.")
477
490
  psi /= 100
478
491
  self.mylog.vprint(f"Long-term capital gain income tax set to {u.pc(psi, f=0)}.")
479
492
  self.psi = psi
@@ -484,10 +497,11 @@ class Plan(object):
484
497
  Set fractions of savings accounts that is left to surviving spouse.
485
498
  Default is [1, 1, 1] for taxable, tax-deferred, adn tax-exempt accounts.
486
499
  """
487
- assert len(phi) == self.N_j, f"Fractions must have {self.N_j} entries."
500
+ if len(phi) != self.N_j:
501
+ raise ValueError(f"Fractions must have {self.N_j} entries.")
488
502
  for j in range(self.N_j):
489
- assert 0 <= phi[j] <= 1, "Fractions must be between 0 and 1."
490
-
503
+ if not (0 <= phi[j] <= 1):
504
+ raise ValueError("Fractions must be between 0 and 1.")
491
505
  self.phi_j = np.array(phi, dtype=np.float32)
492
506
  self.mylog.vprint("Spousal beneficiary fractions set to",
493
507
  ["{:.2f}".format(self.phi_j[j]) for j in range(self.N_j)])
@@ -502,20 +516,24 @@ class Plan(object):
502
516
  Set the heirs tax rate on the tax-deferred portion of the estate.
503
517
  Rate is in percent. Default is 30%.
504
518
  """
505
- assert 0 <= nu and nu <= 100, "Rate must be between 0 and 100."
519
+ if not (0 <= nu <= 100):
520
+ raise ValueError("Rate must be between 0 and 100.")
506
521
  nu /= 100
507
522
  self.mylog.vprint(f"Heirs tax rate on tax-deferred portion of estate set to {u.pc(nu, f=0)}.")
508
523
  self.nu = nu
509
524
  self.caseStatus = "modified"
510
525
 
511
- def setPension(self, amounts, ages, indexed=[False, False], units="k"):
526
+ def setPension(self, amounts, ages, indexed=(False, False), units="k"):
512
527
  """
513
528
  Set value of pension for each individual and commencement age.
514
529
  Units are in $k, unless specified otherwise: 'k', 'M', or '1'.
515
530
  """
516
- assert len(amounts) == self.N_i, f"Amounts must have {self.N_i} entries."
517
- assert len(ages) == self.N_i, f"Ages must have {self.N_i} entries."
518
- assert len(indexed) >= self.N_i, f"Indexed list must have at least {self.N_i} entries."
531
+ if len(amounts) != self.N_i:
532
+ raise ValueError(f"Amounts must have {self.N_i} entries.")
533
+ if len(ages) != self.N_i:
534
+ raise ValueError(f"Ages must have {self.N_i} entries.")
535
+ if len(indexed) < self.N_i:
536
+ raise ValueError(f"Indexed list must have at least {self.N_i} entries.")
519
537
 
520
538
  fac = u.getUnits(units)
521
539
  amounts = u.rescale(amounts, fac)
@@ -546,8 +564,10 @@ class Plan(object):
546
564
  Set value of social security for each individual and commencement age.
547
565
  Units are in $k, unless specified otherwise: 'k', 'M', or '1'.
548
566
  """
549
- assert len(amounts) == self.N_i, f"Amounts must have {self.N_i} entries."
550
- assert len(ages) == self.N_i, f"Ages must have {self.N_i} entries."
567
+ if len(amounts) != self.N_i:
568
+ raise ValueError(f"Amounts must have {self.N_i} entries.")
569
+ if len(ages) != self.N_i:
570
+ raise ValueError(f"Ages must have {self.N_i} entries.")
551
571
 
552
572
  fac = u.getUnits(units)
553
573
  amounts = u.rescale(amounts, fac)
@@ -582,10 +602,14 @@ class Plan(object):
582
602
  as a second argument. Default value is 60%.
583
603
  Dip and increase are percent changes in the smile profile.
584
604
  """
585
- assert 0 <= percent and percent <= 100, f"Survivor value {percent} outside range."
586
- assert 0 <= dip and dip <= 100, f"Dip value {dip} outside range."
587
- assert -100 <= increase and increase <= 100, f"Increase value {increase} outside range."
588
- assert 0 <= delay and delay <= self.N_n - 2, f"Delay value {delay} outside year range."
605
+ if not (0 <= percent <= 100):
606
+ raise ValueError(f"Survivor value {percent} outside range.")
607
+ if not (0 <= dip <= 100):
608
+ raise ValueError(f"Dip value {dip} outside range.")
609
+ if not (-100 <= increase <= 100):
610
+ raise ValueError(f"Increase value {increase} outside range.")
611
+ if not (0 <= delay <= self.N_n - 2):
612
+ raise ValueError(f"Delay value {delay} outside year range.")
589
613
 
590
614
  self.chi = percent / 100
591
615
 
@@ -676,7 +700,8 @@ class Plan(object):
676
700
  raise RuntimeError("A rate method needs to be first selected using setRates(...).")
677
701
 
678
702
  thisyear = date.today().year
679
- assert year > thisyear, "Internal error in forwardValue()."
703
+ if year <= thisyear:
704
+ raise RuntimeError("Internal error in forwardValue().")
680
705
  span = year - thisyear
681
706
 
682
707
  return amount * self.gamma_n[span]
@@ -688,9 +713,12 @@ class Plan(object):
688
713
  one entry. Units are in $k, unless specified otherwise: 'k', 'M', or '1'.
689
714
  """
690
715
  plurals = ["", "y", "ies"][self.N_i]
691
- assert len(taxable) == self.N_i, f"taxable must have {self.N_i} entr{plurals}."
692
- assert len(taxDeferred) == self.N_i, f"taxDeferred must have {self.N_i} entr{plurals}."
693
- assert len(taxFree) == self.N_i, f"taxFree must have {self.N_i} entr{plurals}."
716
+ if len(taxable) != self.N_i:
717
+ raise ValueError(f"taxable must have {self.N_i} entr{plurals}.")
718
+ if len(taxDeferred) != self.N_i:
719
+ raise ValueError(f"taxDeferred must have {self.N_i} entr{plurals}.")
720
+ if len(taxFree) != self.N_i:
721
+ raise ValueError(f"taxFree must have {self.N_i} entr{plurals}.")
694
722
 
695
723
  fac = u.getUnits(units)
696
724
  taxable = u.rescale(taxable, fac)
@@ -766,13 +794,17 @@ class Plan(object):
766
794
  if allocType == "account":
767
795
  # Make sure we have proper input.
768
796
  for item in [taxable, taxDeferred, taxFree]:
769
- assert len(item) == self.N_i, f"{item} must have one entry per individual."
797
+ if len(item) != self.N_i:
798
+ raise ValueError(f"{item} must have one entry per individual.")
770
799
  for i in range(self.N_i):
771
800
  # Initial and final.
772
- assert len(item[i]) == 2, f"{item}[{i}] must have 2 lists (initial and final)."
801
+ if len(item[i]) != 2:
802
+ raise ValueError(f"{item}[{i}] must have 2 lists (initial and final).")
773
803
  for z in range(2):
774
- assert len(item[i][z]) == self.N_k, f"{item}[{i}][{z}] must have {self.N_k} entries."
775
- assert abs(sum(item[i][z]) - 100) < 0.01, "Sum of percentages must add to 100."
804
+ if len(item[i][z]) != self.N_k:
805
+ raise ValueError(f"{item}[{i}][{z}] must have {self.N_k} entries.")
806
+ if abs(sum(item[i][z]) - 100) > 0.01:
807
+ raise ValueError("Sum of percentages must add to 100.")
776
808
 
777
809
  for i in range(self.N_i):
778
810
  self.mylog.vprint(f"{self.inames[i]}: Setting gliding allocation ratios (%) to {allocType}.")
@@ -799,13 +831,17 @@ class Plan(object):
799
831
  self.boundsAR["tax-free"] = taxFree
800
832
 
801
833
  elif allocType == "individual":
802
- assert len(generic) == self.N_i, "generic must have one list per individual."
834
+ if len(generic) != self.N_i:
835
+ raise ValueError("generic must have one list per individual.")
803
836
  for i in range(self.N_i):
804
837
  # Initial and final.
805
- assert len(generic[i]) == 2, f"generic[{i}] must have 2 lists (initial and final)."
838
+ if len(generic[i]) != 2:
839
+ raise ValueError(f"generic[{i}] must have 2 lists (initial and final).")
806
840
  for z in range(2):
807
- assert len(generic[i][z]) == self.N_k, f"generic[{i}][{z}] must have {self.N_k} entries."
808
- assert abs(sum(generic[i][z]) - 100) < 0.01, "Sum of percentages must add to 100."
841
+ if len(generic[i][z]) != self.N_k:
842
+ raise ValueError(f"generic[{i}][{z}] must have {self.N_k} entries.")
843
+ if abs(sum(generic[i][z]) - 100) > 0.01:
844
+ raise ValueError("Sum of percentages must add to 100.")
809
845
 
810
846
  for i in range(self.N_i):
811
847
  self.mylog.vprint(f"{self.inames[i]}: Setting gliding allocation ratios (%) to {allocType}.")
@@ -817,30 +853,26 @@ class Plan(object):
817
853
  start = generic[i][0][k] / 100
818
854
  end = generic[i][1][k] / 100
819
855
  dat = self._interpolator(start, end, Nin)
820
- for j in range(self.N_j):
821
- self.alpha_ijkn[i, j, k, :Nin] = dat[:]
856
+ self.alpha_ijkn[i, :, k, :Nin] = dat[:]
822
857
 
823
858
  self.boundsAR["generic"] = generic
824
859
 
825
860
  elif allocType == "spouses":
826
- assert len(generic) == 2, "generic must have 2 entries (initial and final)."
861
+ if len(generic) != 2:
862
+ raise ValueError("generic must have 2 entries (initial and final).")
827
863
  for z in range(2):
828
- assert len(generic[z]) == self.N_k, f"generic[{z}] must have {self.N_k} entries."
829
- assert abs(sum(generic[z]) - 100) < 0.01, "Sum of percentages must add to 100."
864
+ if len(generic[z]) != self.N_k:
865
+ raise ValueError(f"generic[{z}] must have {self.N_k} entries.")
866
+ if abs(sum(generic[z]) - 100) > 0.01:
867
+ raise ValueError("Sum of percentages must add to 100.")
830
868
 
831
- self.mylog.vprint(f"Setting gliding allocation ratios (%) to {allocType}.")
832
- self.mylog.vprint(f"\t{generic[0]} -> {generic[1]}")
833
-
834
- # Use longest-lived spouse for both time scales.
835
- Nxn = max(self.horizons) + 1
836
-
837
- for k in range(self.N_k):
838
- start = generic[0][k] / 100
839
- end = generic[1][k] / 100
840
- dat = self._interpolator(start, end, Nxn)
841
- for i in range(self.N_i):
842
- for j in range(self.N_j):
843
- self.alpha_ijkn[i, j, k, :Nxn] = dat[:]
869
+ for i in range(self.N_i):
870
+ Nin = self.horizons[i] + 1
871
+ for k in range(self.N_k):
872
+ start = generic[0][k] / 100
873
+ end = generic[1][k] / 100
874
+ dat = self._interpolator(start, end, Nin)
875
+ self.alpha_ijkn[i, :, k, :Nin] = dat[:]
844
876
 
845
877
  self.boundsAR["generic"] = generic
846
878
 
@@ -873,8 +905,7 @@ class Plan(object):
873
905
  try:
874
906
  filename, self.timeLists = timelists.read(filename, self.inames, self.horizons, self.mylog)
875
907
  except Exception as e:
876
- raise Exception(f"Unsuccessful read of contributions: {e}")
877
- return False
908
+ raise Exception(f"Unsuccessful read of contributions: {e}") from e
878
909
 
879
910
  self.timeListsFileName = filename
880
911
  self.setContributions()
@@ -971,9 +1002,7 @@ class Plan(object):
971
1002
  a linear interpolation.
972
1003
  """
973
1004
  # num goes one more year as endpoint=True.
974
- dat = np.linspace(a, b, numPoints)
975
-
976
- return dat
1005
+ return np.linspace(a, b, numPoints)
977
1006
 
978
1007
  def _tanhInterp(self, a, b, numPoints):
979
1008
  """
@@ -1051,259 +1080,217 @@ class Plan(object):
1051
1080
  def _buildConstraints(self, objective, options):
1052
1081
  """
1053
1082
  Utility function that builds constraint matrix and vectors.
1054
- Refer to companion document for notation and detailed explanations.
1055
- """
1056
- # Bounds values.
1057
- zero = 0
1058
- inf = np.inf
1083
+ Refactored for clarity and maintainability.
1084
+ """
1085
+ self._setup_constraint_shortcuts(options)
1086
+
1087
+ self.A = abc.ConstraintMatrix(self.nvars)
1088
+ self.B = abc.Bounds(self.nvars, self.nbins)
1089
+
1090
+ self._add_rmd_inequalities()
1091
+ self._add_tax_bracket_bounds()
1092
+ self._add_standard_exemption_bounds()
1093
+ self._add_defunct_constraints()
1094
+ self._add_roth_conversion_constraints(options)
1095
+ self._add_withdrawal_limits()
1096
+ self._add_conversion_limits()
1097
+ self._add_objective_constraints(objective, options)
1098
+ self._add_initial_balances()
1099
+ self._add_surplus_deposit_linking()
1100
+ self._add_account_balance_carryover()
1101
+ self._add_net_cash_flow()
1102
+ self._add_income_profile()
1103
+ self._add_taxable_income()
1104
+ self._configure_binary_variables(options)
1105
+ self._build_objective_vector(objective)
1059
1106
 
1060
- # Simplified notation.
1061
- Ni = self.N_i
1062
- Nj = self.N_j
1063
- Nk = self.N_k
1064
- Nn = self.N_n
1065
- Nt = self.N_t
1066
- Nz = self.N_z
1067
- i_d = self.i_d
1068
- i_s = self.i_s
1069
- n_d = self.n_d
1070
-
1071
- Cb = self.C["b"]
1072
- Cd = self.C["d"]
1073
- Ce = self.C["e"]
1074
- CF = self.C["F"]
1075
- Cg = self.C["g"]
1076
- Cs = self.C["s"]
1077
- Cw = self.C["w"]
1078
- Cx = self.C["x"]
1079
- Cz = self.C["z"]
1080
-
1081
- spLo = 1 - self.lambdha
1082
- spHi = 1 + self.lambdha
1107
+ return None
1083
1108
 
1109
+ def _setup_constraint_shortcuts(self, options):
1110
+ # Set up all the local variables as attributes for use in helpers.
1084
1111
  oppCostX = options.get("oppCostX", 0.)
1085
- xnet = 1 - oppCostX/100.
1112
+ self.xnet = 1 - oppCostX / 100.
1113
+ self.optionsUnits = u.getUnits(options.get("units", "k"))
1086
1114
 
1087
- tau_ijn = np.zeros((Ni, Nj, Nn))
1088
- for i in range(Ni):
1089
- for j in range(Nj):
1090
- for n in range(Nn):
1091
- tau_ijn[i, j, n] = np.sum(self.alpha_ijkn[i, j, :, n] * self.tau_kn[:, n], axis=0)
1092
-
1093
- # Weights are normalized on k: sum_k[alpha*(1 + tau)] = 1 + sum_k(alpha*tau).
1094
- Tau1_ijn = 1 + tau_ijn
1095
- Tauh_ijn = 1 + tau_ijn / 2
1096
-
1097
- units = u.getUnits(options.get("units", "k"))
1098
- # No units for bigM.
1099
- bigM = options.get("bigM", 5e6)
1100
- assert isinstance(bigM, (int, float)), f"bigM {bigM} is not a number."
1101
-
1102
- ###################################################################
1103
- # Inequality constraint matrix with upper and lower bound vectors.
1104
- A = abc.ConstraintMatrix(self.nvars)
1105
- B = abc.Bounds(self.nvars, self.nbins)
1106
-
1107
- # RMDs inequalities, only if there is an initial balance in tax-deferred account.
1108
- for i in range(Ni):
1115
+ def _add_rmd_inequalities(self):
1116
+ for i in range(self.N_i):
1109
1117
  if self.beta_ij[i, 1] > 0:
1110
1118
  for n in range(self.horizons[i]):
1111
1119
  rowDic = {
1112
- _q3(Cw, i, 1, n, Ni, Nj, Nn): 1,
1113
- _q3(Cb, i, 1, n, Ni, Nj, Nn + 1): -self.rho_in[i, n],
1120
+ _q3(self.C["w"], i, 1, n, self.N_i, self.N_j, self.N_n): 1,
1121
+ _q3(self.C["b"], i, 1, n, self.N_i, self.N_j, self.N_n + 1): -self.rho_in[i, n],
1114
1122
  }
1115
- A.addNewRow(rowDic, zero, inf)
1123
+ self.A.addNewRow(rowDic, 0, np.inf)
1116
1124
 
1117
- # Income tax bracket range inequalities.
1118
- for t in range(Nt):
1119
- for n in range(Nn):
1120
- B.setRange(_q2(CF, t, n, Nt, Nn), zero, self.DeltaBar_tn[t, n])
1125
+ def _add_tax_bracket_bounds(self):
1126
+ for t in range(self.N_t):
1127
+ for n in range(self.N_n):
1128
+ self.B.setRange(_q2(self.C["F"], t, n, self.N_t, self.N_n), 0, self.DeltaBar_tn[t, n])
1121
1129
 
1122
- # Standard exemption range inequalities.
1123
- for n in range(Nn):
1124
- B.setRange(_q1(Ce, n, Nn), zero, self.sigmaBar_n[n])
1130
+ def _add_standard_exemption_bounds(self):
1131
+ for n in range(self.N_n):
1132
+ self.B.setRange(_q1(self.C["e"], n, self.N_n), 0, self.sigmaBar_n[n])
1125
1133
 
1126
- # Start with no activities after passing.
1127
- for i in range(Ni):
1128
- for n in range(self.horizons[i], Nn):
1129
- B.setRange(_q2(Cd, i, n, Ni, Nn), zero, zero)
1130
- B.setRange(_q2(Cx, i, n, Ni, Nn), zero, zero)
1131
- for j in range(Nj):
1132
- B.setRange(_q3(Cw, i, j, n, Ni, Nj, Nn), zero, zero)
1133
-
1134
- # Roth conversions equalities/inequalities.
1135
- # This condition supercedes everything else.
1134
+ def _add_defunct_constraints(self):
1135
+ if self.N_i == 2:
1136
+ for n in range(self.n_d, self.N_n):
1137
+ self.B.setRange(_q2(self.C["d"], self.i_d, n, self.N_i, self.N_n), 0, 0)
1138
+ self.B.setRange(_q2(self.C["x"], self.i_d, n, self.N_i, self.N_n), 0, 0)
1139
+ for j in range(self.N_j):
1140
+ self.B.setRange(_q3(self.C["w"], self.i_d, j, n, self.N_i, self.N_j, self.N_n), 0, 0)
1141
+
1142
+ def _add_roth_conversion_constraints(self, options):
1136
1143
  if "maxRothConversion" in options and options["maxRothConversion"] == "file":
1137
- # self.mylog.vprint(f"Fixing Roth conversions to those from file {self.timeListsFileName}.")
1138
- for i in range(Ni):
1144
+ for i in range(self.N_i):
1139
1145
  for n in range(self.horizons[i]):
1140
1146
  rhs = self.myRothX_in[i][n]
1141
- B.setRange(_q2(Cx, i, n, Ni, Nn), rhs, rhs)
1147
+ self.B.setRange(_q2(self.C["x"], i, n, self.N_i, self.N_n), rhs, rhs)
1142
1148
  else:
1143
1149
  if "maxRothConversion" in options:
1144
1150
  rhsopt = options["maxRothConversion"]
1145
- assert isinstance(rhsopt, (int, float)), "Specified maxRothConversion is not a number."
1146
- rhsopt *= units
1147
- if rhsopt < 0:
1148
- # self.mylog.vprint('Unlimited Roth conversions (<0)')
1149
- pass
1150
- else:
1151
- # self.mylog.vprint('Limiting Roth conversions to:', u.d(rhsopt))
1152
- for i in range(Ni):
1151
+ if not isinstance(rhsopt, (int, float)):
1152
+ raise ValueError(f"Specified maxRothConversion {rhsopt} is not a number.")
1153
+
1154
+ if rhsopt >= 0:
1155
+ rhsopt *= self.optionsUnits
1156
+ for i in range(self.N_i):
1153
1157
  for n in range(self.horizons[i]):
1154
- # MOSEK chokes if completely zero. Add a 1 cent slack.
1155
- # Should we adjust Roth conversion cap with inflation?
1156
- B.setRange(_q2(Cx, i, n, Ni, Nn), zero, rhsopt + 0.01)
1158
+ self.B.setRange(_q2(self.C["x"], i, n, self.N_i, self.N_n), 0, rhsopt + 0.01)
1157
1159
 
1158
- # Process startRothConversions option.
1159
1160
  if "startRothConversions" in options:
1160
1161
  rhsopt = options["startRothConversions"]
1161
- assert isinstance(rhsopt, (int, float)), "Specified startRothConversions is not a number."
1162
+ if not isinstance(rhsopt, (int, float)):
1163
+ raise ValueError(f"Specified startRothConversions {rhsopt} is not a number.")
1162
1164
  thisyear = date.today().year
1163
1165
  yearn = max(rhsopt - thisyear, 0)
1164
-
1165
- for i in range(Ni):
1166
+ for i in range(self.N_i):
1166
1167
  nstart = min(yearn, self.horizons[i])
1167
1168
  for n in range(0, nstart):
1168
- B.setRange(_q2(Cx, i, n, Ni, Nn), zero, zero)
1169
+ self.B.setRange(_q2(self.C["x"], i, n, self.N_i, self.N_n), 0, 0)
1169
1170
 
1170
- # Process noRothConversions option. Also valid when N_i == 1, why not?
1171
1171
  if "noRothConversions" in options and options["noRothConversions"] != "None":
1172
1172
  rhsopt = options["noRothConversions"]
1173
1173
  try:
1174
1174
  i_x = self.inames.index(rhsopt)
1175
- except ValueError:
1176
- raise ValueError(f"Unknown individual {rhsopt} for noRothConversions:")
1177
-
1178
- for n in range(Nn):
1179
- B.setRange(_q2(Cx, i_x, n, Ni, Nn), zero, zero)
1175
+ except ValueError as e:
1176
+ raise ValueError(f"Unknown individual {rhsopt} for noRothConversions:") from e
1177
+ for n in range(self.N_n):
1178
+ self.B.setRange(_q2(self.C["x"], i_x, n, self.N_i, self.N_n), 0, 0)
1180
1179
 
1181
- # Impose withdrawal limits on taxable and tax-exempt accounts.
1182
- for i in range(Ni):
1180
+ def _add_withdrawal_limits(self):
1181
+ for i in range(self.N_i):
1183
1182
  for j in [0, 2]:
1184
- for n in range(Nn):
1185
- rowDic = {_q3(Cw, i, j, n, Ni, Nj, Nn): -1, _q3(Cb, i, j, n, Ni, Nj, Nn + 1): 1}
1186
- A.addNewRow(rowDic, zero, inf)
1183
+ for n in range(self.N_n):
1184
+ rowDic = {_q3(self.C["w"], i, j, n, self.N_i, self.N_j, self.N_n): -1,
1185
+ _q3(self.C["b"], i, j, n, self.N_i, self.N_j, self.N_n + 1): 1}
1186
+ self.A.addNewRow(rowDic, 0, np.inf)
1187
1187
 
1188
- # Impose withdrawals and conversion limits on tax-deferred account.
1189
- for i in range(Ni):
1190
- for n in range(Nn):
1188
+ def _add_conversion_limits(self):
1189
+ for i in range(self.N_i):
1190
+ for n in range(self.N_n):
1191
1191
  rowDic = {
1192
- _q2(Cx, i, n, Ni, Nn): -1,
1193
- _q3(Cw, i, 1, n, Ni, Nj, Nn): -1,
1194
- _q3(Cb, i, 1, n, Ni, Nj, Nn + 1): 1,
1192
+ _q2(self.C["x"], i, n, self.N_i, self.N_n): -1,
1193
+ _q3(self.C["w"], i, 1, n, self.N_i, self.N_j, self.N_n): -1,
1194
+ _q3(self.C["b"], i, 1, n, self.N_i, self.N_j, self.N_n + 1): 1,
1195
1195
  }
1196
- A.addNewRow(rowDic, zero, inf)
1196
+ self.A.addNewRow(rowDic, 0, np.inf)
1197
1197
 
1198
- # Constraints depending on objective function.
1198
+ def _add_objective_constraints(self, objective, options):
1199
1199
  if objective == "maxSpending":
1200
- # Impose optional constraint on final bequest requested in today's $.
1201
1200
  if "bequest" in options:
1202
1201
  bequest = options["bequest"]
1203
- assert isinstance(bequest, (int, float)), "Desired bequest is not a number."
1204
- bequest *= units * self.gamma_n[-1]
1202
+ if not isinstance(bequest, (int, float)):
1203
+ raise ValueError(f"Desired bequest {bequest} is not a number.")
1204
+ bequest *= self.optionsUnits * self.gamma_n[-1]
1205
1205
  else:
1206
- # If not specified, defaults to $1 (nominal $).
1207
1206
  bequest = 1
1208
1207
 
1209
- row = A.newRow()
1210
- for i in range(Ni):
1211
- row.addElem(_q3(Cb, i, 0, Nn, Ni, Nj, Nn + 1), 1)
1212
- row.addElem(_q3(Cb, i, 1, Nn, Ni, Nj, Nn + 1), 1 - self.nu)
1213
- # Nudge could be added (e.g. 1.02) to artificially favor tax-exempt account
1214
- # as heirs's benefits of 10y tax-free is not weighted in?
1215
- row.addElem(_q3(Cb, i, 2, Nn, Ni, Nj, Nn + 1), 1)
1216
- A.addRow(row, bequest, bequest)
1217
- # self.mylog.vprint('Adding bequest constraint of:', u.d(bequest))
1208
+ row = self.A.newRow()
1209
+ for i in range(self.N_i):
1210
+ row.addElem(_q3(self.C["b"], i, 0, self.N_n, self.N_i, self.N_j, self.N_n + 1), 1)
1211
+ row.addElem(_q3(self.C["b"], i, 1, self.N_n, self.N_i, self.N_j, self.N_n + 1), 1 - self.nu)
1212
+ row.addElem(_q3(self.C["b"], i, 2, self.N_n, self.N_i, self.N_j, self.N_n + 1), 1)
1213
+ self.A.addRow(row, bequest, bequest)
1218
1214
  elif objective == "maxBequest":
1219
1215
  spending = options["netSpending"]
1220
- assert isinstance(spending, (int, float)), "Desired spending provided is not a number."
1221
- # Account for time elapsed in the current year.
1222
- spending *= units * self.yearFracLeft
1223
- # self.mylog.vprint('Maximizing bequest with desired net spending of:', u.d(spending))
1224
- # To allow slack in first year, Cg can be made Nn+1 and store basis in g[Nn].
1225
- # A.addNewRow({_q1(Cg, 0, Nn): 1}, spending, spending)
1226
- B.setRange(_q1(Cg, 0, Nn), spending, spending)
1227
-
1228
- # Set initial balances through bounds or constraints.
1229
- for i in range(Ni):
1230
- for j in range(Nj):
1216
+ if not isinstance(spending, (int, float)):
1217
+ raise ValueError(f"Desired spending provided {spending} is not a number.")
1218
+ spending *= self.optionsUnits * self.yearFracLeft
1219
+ self.B.setRange(_q1(self.C["g"], 0, self.N_n), spending, spending)
1220
+
1221
+ def _add_initial_balances(self):
1222
+ for i in range(self.N_i):
1223
+ for j in range(self.N_j):
1231
1224
  rhs = self.beta_ij[i, j]
1232
- # A.addNewRow({_q3(Cb, i, j, 0, Ni, Nj, Nn + 1): 1}, rhs, rhs)
1233
- B.setRange(_q3(Cb, i, j, 0, Ni, Nj, Nn + 1), rhs, rhs)
1225
+ self.B.setRange(_q3(self.C["b"], i, j, 0, self.N_i, self.N_j, self.N_n + 1), rhs, rhs)
1234
1226
 
1235
- # Link surplus and taxable account deposits regardless of Ni.
1236
- for i in range(Ni):
1227
+ def _add_surplus_deposit_linking(self):
1228
+ for i in range(self.N_i):
1237
1229
  fac1 = u.krond(i, 0) * (1 - self.eta) + u.krond(i, 1) * self.eta
1238
- for n in range(n_d):
1239
- rowDic = {_q2(Cd, i, n, Ni, Nn): 1, _q1(Cs, n, Nn): -fac1}
1240
- A.addNewRow(rowDic, zero, zero)
1230
+ for n in range(self.n_d):
1231
+ rowDic = {_q2(self.C["d"], i, n, self.N_i, self.N_n): 1, _q1(self.C["s"], n, self.N_n): -fac1}
1232
+ self.A.addNewRow(rowDic, 0, 0)
1241
1233
  fac2 = u.krond(self.i_s, i)
1242
- for n in range(n_d, Nn):
1243
- rowDic = {_q2(Cd, i, n, Ni, Nn): 1, _q1(Cs, n, Nn): -fac2}
1244
- A.addNewRow(rowDic, zero, zero)
1245
-
1246
- # No surplus allowed during the last year to be used as a tax loophole.
1247
- B.setRange(_q1(Cs, Nn - 1, Nn), zero, zero)
1248
-
1249
- if Ni == 2:
1250
- # No conversion during last year.
1251
- # B.setRange(_q2(Cx, i_d, nd-1, Ni, Nn), zero, zero)
1252
- # B.setRange(_q2(Cx, i_s, Nn-1, Ni, Nn), zero, zero)
1253
-
1254
- # No withdrawals or deposits for any i_d-owned accounts after year of passing.
1255
- # Implicit n_d < Nn imposed by for loop.
1256
- for n in range(n_d, Nn):
1257
- B.setRange(_q2(Cd, i_d, n, Ni, Nn), zero, zero)
1258
- B.setRange(_q2(Cx, i_d, n, Ni, Nn), zero, zero)
1259
- for j in range(Nj):
1260
- B.setRange(_q3(Cw, i_d, j, n, Ni, Nj, Nn), zero, zero)
1261
-
1262
- # Account balances carried from year to year.
1263
- # Considering spousal asset transfer at passing of a spouse.
1264
- # Using hybrid approach with 'if' statement and Kronecker deltas.
1265
- for i in range(Ni):
1266
- for j in range(Nj):
1267
- for n in range(Nn):
1268
- if Ni == 2 and n_d < Nn and i == i_d and n == n_d - 1:
1269
- # fac1 = 1 - (u.krond(n, n_d - 1) * u.krond(i, i_d))
1234
+ for n in range(self.n_d, self.N_n):
1235
+ rowDic = {_q2(self.C["d"], i, n, self.N_i, self.N_n): 1, _q1(self.C["s"], n, self.N_n): -fac2}
1236
+ self.A.addNewRow(rowDic, 0, 0)
1237
+ # Prevent surplus on last year.
1238
+ self.B.setRange(_q1(self.C["s"], self.N_n - 1, self.N_n), 0, 0)
1239
+
1240
+ def _add_account_balance_carryover(self):
1241
+ tau_ijn = np.zeros((self.N_i, self.N_j, self.N_n))
1242
+ for i in range(self.N_i):
1243
+ for j in range(self.N_j):
1244
+ for n in range(self.N_n):
1245
+ tau_ijn[i, j, n] = np.sum(self.alpha_ijkn[i, j, :, n] * self.tau_kn[:, n], axis=0)
1246
+
1247
+ # Weights are normalized on k: sum_k[alpha*(1 + tau)] = 1 + sum_k[alpha*tau]
1248
+ Tau1_ijn = 1 + tau_ijn
1249
+ Tauh_ijn = 1 + tau_ijn / 2
1250
+
1251
+ for i in range(self.N_i):
1252
+ for j in range(self.N_j):
1253
+ for n in range(self.N_n):
1254
+ if self.N_i == 2 and self.n_d < self.N_n and i == self.i_d and n == self.n_d - 1:
1270
1255
  fac1 = 0
1271
1256
  else:
1272
1257
  fac1 = 1
1273
1258
 
1274
1259
  rhs = fac1 * self.kappa_ijn[i, j, n] * Tauh_ijn[i, j, n]
1275
1260
 
1276
- row = A.newRow()
1277
- row.addElem(_q3(Cb, i, j, n + 1, Ni, Nj, Nn + 1), 1)
1278
- row.addElem(_q3(Cb, i, j, n, Ni, Nj, Nn + 1), -fac1 * Tau1_ijn[i, j, n])
1279
- row.addElem(_q3(Cw, i, j, n, Ni, Nj, Nn), fac1 * Tau1_ijn[i, j, n])
1280
- row.addElem(_q2(Cd, i, n, Ni, Nn), -fac1 * u.krond(j, 0) * Tau1_ijn[i, 0, n])
1261
+ row = self.A.newRow()
1262
+ row.addElem(_q3(self.C["b"], i, j, n + 1, self.N_i, self.N_j, self.N_n + 1), 1)
1263
+ row.addElem(_q3(self.C["b"], i, j, n, self.N_i, self.N_j, self.N_n + 1), -fac1 * Tau1_ijn[i, j, n])
1264
+ row.addElem(_q3(self.C["w"], i, j, n, self.N_i, self.N_j, self.N_n), fac1 * Tau1_ijn[i, j, n])
1265
+ row.addElem(_q2(self.C["d"], i, n, self.N_i, self.N_n), -fac1 * u.krond(j, 0) * Tau1_ijn[i, 0, n])
1281
1266
  row.addElem(
1282
- _q2(Cx, i, n, Ni, Nn),
1283
- -fac1 * (xnet*u.krond(j, 2) - u.krond(j, 1)) * Tau1_ijn[i, j, n],
1267
+ _q2(self.C["x"], i, n, self.N_i, self.N_n),
1268
+ -fac1 * (self.xnet * u.krond(j, 2) - u.krond(j, 1)) * Tau1_ijn[i, j, n],
1284
1269
  )
1285
1270
 
1286
- if Ni == 2 and n_d < Nn and i == i_s and n == n_d - 1:
1271
+ if self.N_i == 2 and self.n_d < self.N_n and i == self.i_s and n == self.n_d - 1:
1287
1272
  fac2 = self.phi_j[j]
1288
- rhs += fac2 * self.kappa_ijn[i_d, j, n] * Tauh_ijn[i_d, j, n]
1289
- row.addElem(_q3(Cb, i_d, j, n, Ni, Nj, Nn + 1), -fac2 * Tau1_ijn[i_d, j, n])
1290
- row.addElem(_q3(Cw, i_d, j, n, Ni, Nj, Nn), fac2 * Tau1_ijn[i_d, j, n])
1291
- row.addElem(_q2(Cd, i_d, n, Ni, Nn), -fac2 * u.krond(j, 0) * Tau1_ijn[i_d, 0, n])
1273
+ rhs += fac2 * self.kappa_ijn[self.i_d, j, n] * Tauh_ijn[self.i_d, j, n]
1274
+ row.addElem(_q3(self.C["b"], self.i_d, j, n, self.N_i, self.N_j, self.N_n + 1),
1275
+ -fac2 * Tau1_ijn[self.i_d, j, n])
1276
+ row.addElem(_q3(self.C["w"], self.i_d, j, n, self.N_i, self.N_j, self.N_n),
1277
+ fac2 * Tau1_ijn[self.i_d, j, n])
1278
+ row.addElem(_q2(self.C["d"], self.i_d, n, self.N_i, self.N_n),
1279
+ -fac2 * u.krond(j, 0) * Tau1_ijn[self.i_d, 0, n])
1292
1280
  row.addElem(
1293
- _q2(Cx, i_d, n, Ni, Nn),
1294
- -fac2 * (xnet*u.krond(j, 2) - u.krond(j, 1)) * Tau1_ijn[i_d, j, n],
1281
+ _q2(self.C["x"], self.i_d, n, self.N_i, self.N_n),
1282
+ -fac2 * (self.xnet * u.krond(j, 2) - u.krond(j, 1)) * Tau1_ijn[self.i_d, j, n],
1295
1283
  )
1296
- A.addRow(row, rhs, rhs)
1284
+ self.A.addRow(row, rhs, rhs)
1297
1285
 
1286
+ def _add_net_cash_flow(self):
1298
1287
  tau_0prev = np.roll(self.tau_kn[0, :], 1)
1299
1288
  tau_0prev[tau_0prev < 0] = 0
1300
-
1301
- # Net cash flow.
1302
- for n in range(Nn):
1289
+ for n in range(self.N_n):
1303
1290
  rhs = -self.M_n[n]
1304
- row = A.newRow({_q1(Cg, n, Nn): 1})
1305
- row.addElem(_q1(Cs, n, Nn), 1)
1306
- for i in range(Ni):
1291
+ row = self.A.newRow({_q1(self.C["g"], n, self.N_n): 1})
1292
+ row.addElem(_q1(self.C["s"], n, self.N_n), 1)
1293
+ for i in range(self.N_i):
1307
1294
  fac = self.psi * self.alpha_ijkn[i, 0, 0, n]
1308
1295
  rhs += (
1309
1296
  self.omega_in[i, n]
@@ -1312,122 +1299,113 @@ class Plan(object):
1312
1299
  + self.Lambda_in[i, n]
1313
1300
  - 0.5 * fac * self.mu * self.kappa_ijn[i, 0, n]
1314
1301
  )
1315
-
1316
- row.addElem(_q3(Cb, i, 0, n, Ni, Nj, Nn + 1), fac * self.mu)
1317
- # Minus capital gains on taxable withdrawals using last year's rate if >=0.
1318
- # Plus taxable account withdrawals, and all other withdrawals.
1319
- row.addElem(_q3(Cw, i, 0, n, Ni, Nj, Nn), fac * (tau_0prev[n] - self.mu) - 1)
1302
+ row.addElem(_q3(self.C["b"], i, 0, n, self.N_i, self.N_j, self.N_n + 1), fac * self.mu)
1303
+ row.addElem(_q3(self.C["w"], i, 0, n, self.N_i, self.N_j, self.N_n), fac * (tau_0prev[n] - self.mu) - 1)
1320
1304
  penalty = 0.1 if n < self.n59[i] else 0
1321
- row.addElem(_q3(Cw, i, 1, n, Ni, Nj, Nn), -1 + penalty)
1322
- row.addElem(_q3(Cw, i, 2, n, Ni, Nj, Nn), -1 + penalty)
1323
- row.addElem(_q2(Cd, i, n, Ni, Nn), fac * self.mu)
1324
-
1325
- # Minus tax on ordinary income, T_n.
1326
- for t in range(Nt):
1327
- row.addElem(_q2(CF, t, n, Nt, Nn), self.theta_tn[t, n])
1305
+ row.addElem(_q3(self.C["w"], i, 1, n, self.N_i, self.N_j, self.N_n), -1 + penalty)
1306
+ row.addElem(_q3(self.C["w"], i, 2, n, self.N_i, self.N_j, self.N_n), -1 + penalty)
1307
+ row.addElem(_q2(self.C["d"], i, n, self.N_i, self.N_n), fac * self.mu)
1328
1308
 
1329
- A.addRow(row, rhs, rhs)
1309
+ for t in range(self.N_t):
1310
+ row.addElem(_q2(self.C["F"], t, n, self.N_t, self.N_n), self.theta_tn[t, n])
1330
1311
 
1331
- # Impose income profile.
1332
- for n in range(1, Nn):
1333
- rowDic = {_q1(Cg, 0, Nn): spLo * self.xiBar_n[n], _q1(Cg, n, Nn): -self.xiBar_n[0]}
1334
- A.addNewRow(rowDic, -inf, zero)
1335
- rowDic = {_q1(Cg, 0, Nn): spHi * self.xiBar_n[n], _q1(Cg, n, Nn): -self.xiBar_n[0]}
1336
- A.addNewRow(rowDic, zero, inf)
1312
+ self.A.addRow(row, rhs, rhs)
1337
1313
 
1338
- # Taxable ordinary income.
1339
- for n in range(Nn):
1314
+ def _add_income_profile(self):
1315
+ spLo = 1 - self.lambdha
1316
+ spHi = 1 + self.lambdha
1317
+ for n in range(1, self.N_n):
1318
+ rowDic = {_q1(self.C["g"], 0, self.N_n): spLo * self.xiBar_n[n],
1319
+ _q1(self.C["g"], n, self.N_n): -self.xiBar_n[0]}
1320
+ self.A.addNewRow(rowDic, -np.inf, 0)
1321
+ rowDic = {_q1(self.C["g"], 0, self.N_n): spHi * self.xiBar_n[n],
1322
+ _q1(self.C["g"], n, self.N_n): -self.xiBar_n[0]}
1323
+ self.A.addNewRow(rowDic, 0, np.inf)
1324
+
1325
+ def _add_taxable_income(self):
1326
+ for n in range(self.N_n):
1340
1327
  rhs = 0
1341
- row = A.newRow()
1342
- row.addElem(_q1(Ce, n, Nn), 1)
1343
- for i in range(Ni):
1328
+ row = self.A.newRow()
1329
+ row.addElem(_q1(self.C["e"], n, self.N_n), 1)
1330
+ for i in range(self.N_i):
1344
1331
  rhs += self.omega_in[i, n] + 0.85 * self.zetaBar_in[i, n] + self.piBar_in[i, n]
1345
- # Taxable income from tax-deferred withdrawals.
1346
- row.addElem(_q3(Cw, i, 1, n, Ni, Nj, Nn), -1)
1347
- row.addElem(_q2(Cx, i, n, Ni, Nn), -1)
1348
-
1349
- # Taxable returns on securities in taxable account.
1350
- fak = np.sum(self.tau_kn[1:Nk, n] * self.alpha_ijkn[i, 0, 1:Nk, n], axis=0)
1332
+ row.addElem(_q3(self.C["w"], i, 1, n, self.N_i, self.N_j, self.N_n), -1)
1333
+ row.addElem(_q2(self.C["x"], i, n, self.N_i, self.N_n), -1)
1334
+ fak = np.sum(self.tau_kn[1:self.N_k, n] * self.alpha_ijkn[i, 0, 1:self.N_k, n], axis=0)
1351
1335
  rhs += 0.5 * fak * self.kappa_ijn[i, 0, n]
1352
- row.addElem(_q3(Cb, i, 0, n, Ni, Nj, Nn + 1), -fak)
1353
- row.addElem(_q3(Cw, i, 0, n, Ni, Nj, Nn), fak)
1354
- row.addElem(_q2(Cd, i, n, Ni, Nn), -fak)
1355
-
1356
- for t in range(Nt):
1357
- row.addElem(_q2(CF, t, n, Nt, Nn), 1)
1358
-
1359
- A.addRow(row, rhs, rhs)
1336
+ row.addElem(_q3(self.C["b"], i, 0, n, self.N_i, self.N_j, self.N_n + 1), -fak)
1337
+ row.addElem(_q3(self.C["w"], i, 0, n, self.N_i, self.N_j, self.N_n), fak)
1338
+ row.addElem(_q2(self.C["d"], i, n, self.N_i, self.N_n), -fak)
1339
+ for t in range(self.N_t):
1340
+ row.addElem(_q2(self.C["F"], t, n, self.N_t, self.N_n), 1)
1341
+ self.A.addRow(row, rhs, rhs)
1342
+
1343
+ def _configure_binary_variables(self, options):
1344
+ bigM = options.get("bigM", 5e6)
1345
+ if not isinstance(bigM, (int, float)):
1346
+ raise ValueError(f"bigM {bigM} is not a number.")
1360
1347
 
1361
- # Configure binary variables.
1362
- for i in range(Ni):
1348
+ for i in range(self.N_i):
1363
1349
  for n in range(self.horizons[i]):
1364
- # for z in range(Nz):
1365
- # B.setBinary(_q3(Cz, i, n, z, Ni, Nn, Nz))
1366
-
1367
- # Exclude simultaneous deposits and withdrawals from taxable or tax-free accounts.
1368
- A.addNewRow(
1369
- {_q3(Cz, i, n, 0, Ni, Nn, Nz): bigM, _q1(Cs, n, Nn): -1},
1370
- zero,
1350
+ self.A.addNewRow(
1351
+ {_q3(self.C["z"], i, n, 0, self.N_i, self.N_n, self.N_z): bigM,
1352
+ _q1(self.C["s"], n, self.N_n): -1},
1353
+ 0,
1371
1354
  bigM,
1372
1355
  )
1373
-
1374
- A.addNewRow(
1356
+ self.A.addNewRow(
1375
1357
  {
1376
- _q3(Cz, i, n, 0, Ni, Nn, Nz): bigM,
1377
- _q3(Cw, i, 0, n, Ni, Nj, Nn): 1,
1378
- _q3(Cw, i, 2, n, Ni, Nj, Nn): 1,
1358
+ _q3(self.C["z"], i, n, 0, self.N_i, self.N_n, self.N_z): bigM,
1359
+ _q3(self.C["w"], i, 0, n, self.N_i, self.N_j, self.N_n): 1,
1360
+ _q3(self.C["w"], i, 2, n, self.N_i, self.N_j, self.N_n): 1,
1379
1361
  },
1380
- zero,
1362
+ 0,
1381
1363
  bigM,
1382
1364
  )
1383
-
1384
- # Exclude simultaneous Roth conversions and tax-exempt withdrawals.
1385
- A.addNewRow(
1386
- {_q3(Cz, i, n, 1, Ni, Nn, Nz): bigM, _q2(Cx, i, n, Ni, Nn): -1},
1387
- zero,
1365
+ self.A.addNewRow(
1366
+ {_q3(self.C["z"], i, n, 1, self.N_i, self.N_n, self.N_z): bigM,
1367
+ _q2(self.C["x"], i, n, self.N_i, self.N_n): -1},
1368
+ 0,
1388
1369
  bigM,
1389
1370
  )
1390
-
1391
- A.addNewRow(
1392
- {_q3(Cz, i, n, 1, Ni, Nn, Nz): bigM, _q3(Cw, i, 2, n, Ni, Nj, Nn): 1},
1393
- zero,
1371
+ self.A.addNewRow(
1372
+ {_q3(self.C["z"], i, n, 1, self.N_i, self.N_n, self.N_z): bigM,
1373
+ _q3(self.C["w"], i, 2, n, self.N_i, self.N_j, self.N_n): 1},
1374
+ 0,
1394
1375
  bigM,
1395
1376
  )
1377
+ for n in range(self.horizons[i], self.N_n):
1378
+ self.B.setRange(_q3(self.C["z"], i, n, 0, self.N_i, self.N_n, self.N_z), 0, 0)
1379
+ self.B.setRange(_q3(self.C["z"], i, n, 1, self.N_i, self.N_n, self.N_z), 0, 0)
1396
1380
 
1397
- for n in range(self.horizons[i], Nn):
1398
- B.setRange(_q3(Cz, i, n, 0, Ni, Nn, Nz), zero, zero)
1399
- B.setRange(_q3(Cz, i, n, 1, Ni, Nn, Nz), zero, zero)
1400
-
1401
- # Now build a solver-neutral objective vector.
1381
+ def _build_objective_vector(self, objective):
1402
1382
  c = abc.Objective(self.nvars)
1403
1383
  if objective == "maxSpending":
1404
- # c.setElem(_q1(Cg, 0, Nn), -1) # Only OK in implemention without slack.
1405
- for n in range(Nn):
1406
- c.setElem(_q1(Cg, n, Nn), -1/self.gamma_n[n])
1384
+ for n in range(self.N_n):
1385
+ c.setElem(_q1(self.C["g"], n, self.N_n), -1/self.gamma_n[n])
1407
1386
  elif objective == "maxBequest":
1408
- for i in range(Ni):
1409
- c.setElem(_q3(Cb, i, 0, Nn, Ni, Nj, Nn + 1), -1)
1410
- c.setElem(_q3(Cb, i, 1, Nn, Ni, Nj, Nn + 1), -(1 - self.nu))
1411
- c.setElem(_q3(Cb, i, 2, Nn, Ni, Nj, Nn + 1), -1)
1387
+ for i in range(self.N_i):
1388
+ c.setElem(_q3(self.C["b"], i, 0, self.N_n, self.N_i, self.N_j, self.N_n + 1), -1)
1389
+ c.setElem(_q3(self.C["b"], i, 1, self.N_n, self.N_i, self.N_j, self.N_n + 1), -(1 - self.nu))
1390
+ c.setElem(_q3(self.C["b"], i, 2, self.N_n, self.N_i, self.N_j, self.N_n + 1), -1)
1412
1391
  else:
1413
1392
  raise RuntimeError("Internal error in objective function.")
1414
-
1415
- self.A = A
1416
- self.B = B
1417
1393
  self.c = c
1418
1394
 
1419
- return None
1420
-
1421
1395
  @_timer
1422
1396
  def runHistoricalRange(self, objective, options, ystart, yend, *, verbose=False, figure=False, progcall=None):
1423
1397
  """
1424
1398
  Run historical scenarios on plan over a range of years.
1425
1399
  """
1400
+
1426
1401
  if yend + self.N_n > self.year_n[0]:
1427
1402
  yend = self.year_n[0] - self.N_n - 1
1428
1403
  self.mylog.vprint(f"Warning: Upper bound for year range re-adjusted to {yend}.")
1429
- N = yend - ystart + 1
1430
1404
 
1405
+ if yend < ystart:
1406
+ raise ValueError(f"Starting year is too large to support a lifespan of {self.N_n} years.")
1407
+
1408
+ N = yend - ystart + 1
1431
1409
  self.mylog.vprint(f"Running historical range from {ystart} to {yend}.")
1432
1410
 
1433
1411
  self.mylog.setVerbose(verbose)
@@ -1438,7 +1416,7 @@ class Plan(object):
1438
1416
  columns = ["partial", "final"]
1439
1417
  else:
1440
1418
  self.mylog.print(f"Invalid objective {objective}.")
1441
- return None
1419
+ raise ValueError(f"Invalid objective {objective}.")
1442
1420
 
1443
1421
  df = pd.DataFrame(columns=columns)
1444
1422
 
@@ -1461,7 +1439,9 @@ class Plan(object):
1461
1439
 
1462
1440
  progcall.finish()
1463
1441
  self.mylog.resetVerbose()
1464
- fig, description = self._showResults(objective, df, N, figure)
1442
+
1443
+ fig, description = self._plotter.plot_histogram_results(objective, df, N, self.year_n,
1444
+ self.n_d, self.N_i, self.phi_j)
1465
1445
  self.mylog.print(description.getvalue())
1466
1446
 
1467
1447
  if figure:
@@ -1474,7 +1454,7 @@ class Plan(object):
1474
1454
  """
1475
1455
  Run Monte Carlo simulations on plan.
1476
1456
  """
1477
- if self.rateMethod not in ["stochastic", "histochastic"]:
1457
+ if self.rateMethod not in ("stochastic", "histochastic"):
1478
1458
  self.mylog.print("It is pointless to run Monte Carlo simulations with fixed rates.")
1479
1459
  return
1480
1460
 
@@ -1517,7 +1497,9 @@ class Plan(object):
1517
1497
 
1518
1498
  progcall.finish()
1519
1499
  self.mylog.resetVerbose()
1520
- fig, description = self._showResults(objective, df, N, figure)
1500
+
1501
+ fig, description = self._plotter.plot_histogram_results(objective, df, N, self.year_n,
1502
+ self.n_d, self.N_i, self.phi_j)
1521
1503
  self.mylog.print(description.getvalue())
1522
1504
 
1523
1505
  if figure:
@@ -1525,90 +1507,6 @@ class Plan(object):
1525
1507
 
1526
1508
  return N, df
1527
1509
 
1528
- def _showResults(self, objective, df, N, figure):
1529
- """
1530
- Show a histogram of values from runMC() and runHistoricalRange().
1531
- """
1532
- import seaborn as sbn
1533
-
1534
- description = io.StringIO()
1535
-
1536
- pSuccess = u.pc(len(df) / N)
1537
- print(f"Success rate: {pSuccess} on {N} samples.", file=description)
1538
- title = f"$N$ = {N}, $P$ = {pSuccess}"
1539
- means = df.mean(axis=0, numeric_only=True)
1540
- medians = df.median(axis=0, numeric_only=True)
1541
-
1542
- my = 2 * [self.year_n[-1]]
1543
- if self.N_i == 2 and self.n_d < self.N_n:
1544
- my[0] = self.year_n[self.n_d - 1]
1545
-
1546
- # Don't show partial bequest of zero if spouse is full beneficiary,
1547
- # or if solution led to empty accounts at the end of first spouse's life.
1548
- if np.all(self.phi_j == 1) or medians.iloc[0] < 1:
1549
- if medians.iloc[0] < 1:
1550
- print(f"Optimized solutions all have null partial bequest in year {my[0]}.", file=description)
1551
- df.drop("partial", axis=1, inplace=True)
1552
- means = df.mean(axis=0, numeric_only=True)
1553
- medians = df.median(axis=0, numeric_only=True)
1554
-
1555
- df /= 1000
1556
- if len(df) > 0:
1557
- thisyear = self.year_n[0]
1558
- if objective == "maxBequest":
1559
- fig, axes = plt.subplots()
1560
- # Show both partial and final bequests in the same histogram.
1561
- sbn.histplot(df, multiple="dodge", kde=True, ax=axes)
1562
- legend = []
1563
- # Don't know why but legend is reversed from df.
1564
- for q in range(len(means) - 1, -1, -1):
1565
- dmedian = u.d(medians.iloc[q], latex=True)
1566
- dmean = u.d(means.iloc[q], latex=True)
1567
- legend.append(f"{my[q]}: $M$: {dmedian}, $\\bar{{x}}$: {dmean}")
1568
- plt.legend(legend, shadow=True)
1569
- plt.xlabel(f"{thisyear} $k")
1570
- plt.title(objective)
1571
- leads = [f"partial {my[0]}", f" final {my[1]}"]
1572
- elif len(means) == 2:
1573
- # Show partial bequest and net spending as two separate histograms.
1574
- fig, axes = plt.subplots(1, 2, figsize=(10, 5))
1575
- cols = ["partial", objective]
1576
- leads = [f"partial {my[0]}", objective]
1577
- for q in range(2):
1578
- sbn.histplot(df[cols[q]], kde=True, ax=axes[q])
1579
- dmedian = u.d(medians.iloc[q], latex=True)
1580
- dmean = u.d(means.iloc[q], latex=True)
1581
- legend = [f"$M$: {dmedian}, $\\bar{{x}}$: {dmean}"]
1582
- axes[q].set_label(legend)
1583
- axes[q].legend(labels=legend)
1584
- axes[q].set_title(leads[q])
1585
- axes[q].set_xlabel(f"{thisyear} $k")
1586
- else:
1587
- # Show net spending as single histogram.
1588
- fig, axes = plt.subplots()
1589
- sbn.histplot(df[objective], kde=True, ax=axes)
1590
- dmedian = u.d(medians.iloc[0], latex=True)
1591
- dmean = u.d(means.iloc[0], latex=True)
1592
- legend = [f"$M$: {dmedian}, $\\bar{{x}}$: {dmean}"]
1593
- plt.legend(legend, shadow=True)
1594
- plt.xlabel(f"{thisyear} $k")
1595
- plt.title(objective)
1596
- leads = [objective]
1597
-
1598
- plt.suptitle(title)
1599
- # plt.show()
1600
-
1601
- for q in range(len(means)):
1602
- print(f"{leads[q]:>12}: Median ({thisyear} $): {u.d(medians.iloc[q])}", file=description)
1603
- print(f"{leads[q]:>12}: Mean ({thisyear} $): {u.d(means.iloc[q])}", file=description)
1604
- mmin = 1000 * df.iloc[:, q].min()
1605
- mmax = 1000 * df.iloc[:, q].max()
1606
- print(f"{leads[q]:>12}: Range: {u.d(mmin)} - {u.d(mmax)}", file=description)
1607
- nzeros = len(df.iloc[:, q][df.iloc[:, q] < 0.001])
1608
- print(f"{leads[q]:>12}: N zero solns: {nzeros}", file=description)
1609
-
1610
- return fig, description
1611
-
1612
1510
  def resolve(self):
1613
1511
  """
1614
1512
  Solve a plan using saved options.
@@ -1619,7 +1517,7 @@ class Plan(object):
1619
1517
 
1620
1518
  @_checkConfiguration
1621
1519
  @_timer
1622
- def solve(self, objective, options={}):
1520
+ def solve(self, objective, options=None):
1623
1521
  """
1624
1522
  This function builds the necessary constaints and
1625
1523
  runs the optimizer.
@@ -1661,6 +1559,7 @@ class Plan(object):
1661
1559
  "oppCostX",
1662
1560
  ]
1663
1561
  # We might modify options if required.
1562
+ options = {} if options is None else options
1664
1563
  myoptions = dict(options)
1665
1564
 
1666
1565
  for opt in myoptions:
@@ -1739,7 +1638,8 @@ class Plan(object):
1739
1638
  while True:
1740
1639
  solution, xx, solverSuccess, solverMsg = solverMethod(objective, options)
1741
1640
 
1742
- if not solverSuccess:
1641
+ if not solverSuccess or solution is None:
1642
+ self.mylog.vprint("Solver failed:", solverMsg, solverSuccess)
1743
1643
  break
1744
1644
 
1745
1645
  if not withMedicare:
@@ -1789,8 +1689,13 @@ class Plan(object):
1789
1689
  """
1790
1690
  from scipy import optimize
1791
1691
 
1792
- # mip_rel_gap smaller than 1e-6 can lead to oscillatory solutions.
1793
- milpOptions = {"disp": False, "mip_rel_gap": 1e-7}
1692
+ # Optimize solver parameters
1693
+ milpOptions = {
1694
+ "disp": False,
1695
+ "mip_rel_gap": 1e-7,
1696
+ "presolve": True,
1697
+ "node_limit": 10000 # Limit search nodes for faster solutions
1698
+ }
1794
1699
 
1795
1700
  self._buildConstraints(objective, options)
1796
1701
  Alu, lbvec, ubvec = self.A.arrays()
@@ -2161,10 +2066,10 @@ class Plan(object):
2161
2066
  dic[f"Net spending for year {now}"] = u.d(self.g_n[0] / self.yearFracLeft)
2162
2067
  dic[f"Net spending remaining in year {now}"] = u.d(self.g_n[0])
2163
2068
 
2164
- totIncome = np.sum(self.g_n, axis=0)
2165
- totIncomeNow = np.sum(self.g_n / self.gamma_n[:-1], axis=0)
2166
- dic["Total net spending"] = f"{u.d(totIncomeNow)}"
2167
- dic["[Total net spending]"] = f"{u.d(totIncome)}"
2069
+ totSpending = np.sum(self.g_n, axis=0)
2070
+ totSpendingNow = np.sum(self.g_n / self.gamma_n[:-1], axis=0)
2071
+ dic["Total net spending"] = f"{u.d(totSpendingNow)}"
2072
+ dic["[Total net spending]"] = f"{u.d(totSpending)}"
2168
2073
 
2169
2074
  totRoth = np.sum(self.x_in, axis=(0, 1))
2170
2075
  totRothNow = np.sum(np.sum(self.x_in, axis=0) / self.gamma_n[:-1], axis=0)
@@ -2260,58 +2165,29 @@ class Plan(object):
2260
2165
 
2261
2166
  A tag string can be set to add information to the title of the plot.
2262
2167
  """
2263
- import seaborn as sbn
2264
-
2265
2168
  if self.rateMethod in [None, "user", "historical average", "conservative"]:
2266
2169
  self.mylog.vprint(f"Warning: Cannot plot correlations for {self.rateMethod} rate method.")
2267
2170
  return None
2268
2171
 
2269
- rateNames = [
2270
- "S&P500 (incl. div.)",
2271
- "Baa Corp. Bonds",
2272
- "10-y T-Notes",
2273
- "Inflation",
2274
- ]
2172
+ fig = self._plotter.plot_rates_correlations(self._name, self.tau_kn, self.N_n, self.rateMethod,
2173
+ self.rateFrm, self.rateTo, tag, shareRange)
2275
2174
 
2276
- df = pd.DataFrame()
2277
- for k, name in enumerate(rateNames):
2278
- data = 100 * self.tau_kn[k]
2279
- df[name] = data
2280
-
2281
- g = sbn.PairGrid(df, diag_sharey=False, height=1.8, aspect=1)
2282
- if shareRange:
2283
- minval = df.min().min() - 5
2284
- maxval = df.max().max() + 5
2285
- g.set(xlim=(minval, maxval), ylim=(minval, maxval))
2286
- g.map_upper(sbn.scatterplot)
2287
- g.map_lower(sbn.kdeplot)
2288
- # g.map_diag(sbn.kdeplot)
2289
- g.map_diag(sbn.histplot, color="orange")
2290
-
2291
- # Put zero axes on off-diagonal plots.
2292
- imod = len(rateNames) + 1
2293
- for i, ax in enumerate(g.axes.flat):
2294
- ax.axvline(x=0, color="grey", linewidth=1, linestyle=":")
2295
- if i % imod != 0:
2296
- ax.axhline(y=0, color="grey", linewidth=1, linestyle=":")
2297
- # ax.tick_params(axis='both', labelleft=True, labelbottom=True)
2298
-
2299
- # plt.subplots_adjust(wspace=0.3, hspace=0.3)
2300
-
2301
- title = self._name + "\n"
2302
- title += f"Rates Correlations (N={self.N_n}) {self.rateMethod}"
2303
- if self.rateMethod in ["historical", "histochastic"]:
2304
- title += " (" + str(self.rateFrm) + "-" + str(self.rateTo) + ")"
2305
-
2306
- if tag != "":
2307
- title += " - " + tag
2175
+ if figure:
2176
+ return fig
2308
2177
 
2309
- g.fig.suptitle(title, y=1.08)
2178
+ self._plotter.jupyter_renderer(fig)
2179
+ return None
2310
2180
 
2181
+ def showRatesDistributions(self, frm=rates.FROM, to=rates.TO, figure=False):
2182
+ """
2183
+ Plot histograms of the rates distributions.
2184
+ """
2185
+ fig = self._plotter.plot_rates_distributions(frm, to, rates.SP500, rates.BondsBaa,
2186
+ rates.TNotes, rates.Inflation, rates.FROM)
2311
2187
  if figure:
2312
- return g.fig
2188
+ return fig
2313
2189
 
2314
- plt.show()
2190
+ self._plotter.jupyter_renderer(fig)
2315
2191
  return None
2316
2192
 
2317
2193
  def showRates(self, tag="", figure=False):
@@ -2320,54 +2196,17 @@ class Plan(object):
2320
2196
 
2321
2197
  A tag string can be set to add information to the title of the plot.
2322
2198
  """
2323
- import matplotlib.ticker as tk
2324
-
2325
2199
  if self.rateMethod is None:
2326
2200
  self.mylog.vprint("Warning: Rate method must be selected before plotting.")
2327
2201
  return None
2328
2202
 
2329
- fig, ax = plt.subplots(figsize=(6, 4))
2330
- plt.grid(visible="both")
2331
- title = self._name + "\nReturn & Inflation Rates (" + str(self.rateMethod)
2332
- if self.rateMethod in ["historical", "histochastic", "historical average"]:
2333
- title += " " + str(self.rateFrm) + "-" + str(self.rateTo)
2334
- title += ")"
2335
-
2336
- if tag != "":
2337
- title += " - " + tag
2338
-
2339
- rateName = [
2340
- "S&P500 (incl. div.)",
2341
- "Baa Corp. Bonds",
2342
- "10-y T-Notes",
2343
- "Inflation",
2344
- ]
2345
- ltype = ["-", "-.", ":", "--"]
2346
- for k in range(self.N_k):
2347
- if self.yearFracLeft == 1:
2348
- data = 100 * self.tau_kn[k]
2349
- years = self.year_n
2350
- else:
2351
- data = 100 * self.tau_kn[k, 1:]
2352
- years = self.year_n[1:]
2353
-
2354
- # Use ddof=1 to match pandas.
2355
- label = (
2356
- rateName[k] + " <" + "{:.1f}".format(np.mean(data)) + " +/- {:.1f}".format(np.std(data, ddof=1)) + "%>"
2357
- )
2358
- ax.plot(years, data, label=label, ls=ltype[k % self.N_k])
2359
-
2360
- ax.xaxis.set_major_locator(tk.MaxNLocator(integer=True))
2361
- ax.legend(loc="best", reverse=False, fontsize=8, framealpha=0.7)
2362
- # ax.legend(loc='upper left')
2363
- ax.set_title(title)
2364
- ax.set_xlabel("year")
2365
- ax.set_ylabel("%")
2203
+ fig = self._plotter.plot_rates(self._name, self.tau_kn, self.year_n, self.yearFracLeft,
2204
+ self.N_k, self.rateMethod, self.rateFrm, self.rateTo, tag)
2366
2205
 
2367
2206
  if figure:
2368
2207
  return fig
2369
2208
 
2370
- plt.show()
2209
+ self._plotter.jupyter_renderer(fig)
2371
2210
  return None
2372
2211
 
2373
2212
  def showProfile(self, tag="", figure=False):
@@ -2379,20 +2218,15 @@ class Plan(object):
2379
2218
  if self.xi_n is None:
2380
2219
  self.mylog.vprint("Warning: Profile must be selected before plotting.")
2381
2220
  return None
2382
-
2383
2221
  title = self._name + "\nSpending Profile"
2384
- if tag != "":
2222
+ if tag:
2385
2223
  title += " - " + tag
2386
-
2387
- # style = {'net': '-', 'target': ':'}
2388
- style = {"profile": "-"}
2389
- series = {"profile": self.xi_n}
2390
- fig, ax = _lineIncomePlot(self.year_n, series, style, title, yformat="$\\xi$")
2224
+ fig = self._plotter.plot_profile(self.year_n, self.xi_n, title, self.inames)
2391
2225
 
2392
2226
  if figure:
2393
2227
  return fig
2394
2228
 
2395
- plt.show()
2229
+ self._plotter.jupyter_renderer(fig)
2396
2230
  return None
2397
2231
 
2398
2232
  @_checkCaseStatus
@@ -2406,28 +2240,15 @@ class Plan(object):
2406
2240
  the default behavior of setDefaultPlots().
2407
2241
  """
2408
2242
  value = self._checkValue(value)
2409
-
2410
2243
  title = self._name + "\nNet Available Spending"
2411
- if tag != "":
2244
+ if tag:
2412
2245
  title += " - " + tag
2413
-
2414
- style = {"net": "-", "target": ":"}
2415
- if value == "nominal":
2416
- series = {"net": self.g_n, "target": (self.g_n[0] / self.xi_n[0]) * self.xiBar_n}
2417
- yformat = "\\$k (nominal)"
2418
- else:
2419
- series = {
2420
- "net": self.g_n / self.gamma_n[:-1],
2421
- "target": (self.g_n[0] / self.xi_n[0]) * self.xi_n,
2422
- }
2423
- yformat = "\\$k (" + str(self.year_n[0]) + "\\$)"
2424
-
2425
- fig, ax = _lineIncomePlot(self.year_n, series, style, title, yformat)
2426
-
2246
+ fig = self._plotter.plot_net_spending(self.year_n, self.g_n, self.xi_n, self.xiBar_n,
2247
+ self.gamma_n, value, title, self.inames)
2427
2248
  if figure:
2428
2249
  return fig
2429
2250
 
2430
- plt.show()
2251
+ self._plotter.jupyter_renderer(fig)
2431
2252
  return None
2432
2253
 
2433
2254
  @_checkCaseStatus
@@ -2444,42 +2265,37 @@ class Plan(object):
2444
2265
  the default behavior of setDefaultPlots().
2445
2266
  """
2446
2267
  value = self._checkValue(value)
2268
+ figures = self._plotter.plot_asset_distribution(self.year_n, self.inames, self.b_ijkn,
2269
+ self.gamma_n, value, self._name, tag)
2270
+ if figure:
2271
+ return figures
2447
2272
 
2448
- if value == "nominal":
2449
- yformat = "\\$k (nominal)"
2450
- infladjust = 1
2451
- else:
2452
- yformat = "\\$k (" + str(self.year_n[0]) + "\\$)"
2453
- infladjust = self.gamma_n
2454
-
2455
- years_n = np.array(self.year_n)
2456
- years_n = np.append(years_n, [years_n[-1] + 1])
2457
- y2stack = {}
2458
- jDic = {"taxable": 0, "tax-deferred": 1, "tax-free": 2}
2459
- kDic = {"stocks": 0, "C bonds": 1, "T notes": 2, "common": 3}
2460
- figures = []
2461
- for jkey in jDic:
2462
- stackNames = []
2463
- for kkey in kDic:
2464
- name = kkey + " / " + jkey
2465
- stackNames.append(name)
2466
- y2stack[name] = np.zeros((self.N_i, self.N_n + 1))
2467
- for i in range(self.N_i):
2468
- y2stack[name][i][:] = self.b_ijkn[i][jDic[jkey]][kDic[kkey]][:] / infladjust
2273
+ for fig in figures:
2274
+ self._plotter.jupyter_renderer(fig)
2275
+ return None
2469
2276
 
2470
- title = self._name + "\nAssets Distribution - " + jkey
2471
- if tag != "":
2472
- title += " - " + tag
2277
+ @_checkCaseStatus
2278
+ def showGrossIncome(self, tag="", value=None, figure=False):
2279
+ """
2280
+ Plot income tax and taxable income over time horizon.
2473
2281
 
2474
- fig, ax = _stackPlot(
2475
- years_n, self.inames, title, range(self.N_i), y2stack, stackNames, "upper left", yformat
2476
- )
2477
- figures.append(fig)
2282
+ A tag string can be set to add information to the title of the plot.
2478
2283
 
2284
+ The value parameter can be set to *nominal* or *today*, overriding
2285
+ the default behavior of setDefaultPlots().
2286
+ """
2287
+ value = self._checkValue(value)
2288
+ tax_brackets = tx.taxBrackets(self.N_i, self.n_d, self.N_n, self.yTCJA)
2289
+ title = self._name + "\nTaxable Ordinary Income vs. Tax Brackets"
2290
+ if tag:
2291
+ title += " - " + tag
2292
+ fig = self._plotter.plot_gross_income(
2293
+ self.year_n, self.G_n, self.gamma_n, value, title, tax_brackets
2294
+ )
2479
2295
  if figure:
2480
- return figures
2296
+ return fig
2481
2297
 
2482
- plt.show()
2298
+ self._plotter.jupyter_renderer(fig)
2483
2299
  return None
2484
2300
 
2485
2301
  def showAllocations(self, tag="", figure=False):
@@ -2490,45 +2306,16 @@ class Plan(object):
2490
2306
 
2491
2307
  A tag string can be set to add information to the title of the plot.
2492
2308
  """
2493
- count = self.N_i
2494
- if self.ARCoord == "spouses":
2495
- acList = [self.ARCoord]
2496
- count = 1
2497
- elif self.ARCoord == "individual":
2498
- acList = [self.ARCoord]
2499
- elif self.ARCoord == "account":
2500
- acList = ["taxable", "tax-deferred", "tax-free"]
2501
- else:
2502
- raise ValueError(f"Unknown coordination {self.ARCoord}.")
2503
-
2504
- figures = []
2505
- assetDic = {"stocks": 0, "C bonds": 1, "T notes": 2, "common": 3}
2506
- for i in range(count):
2507
- y2stack = {}
2508
- for acType in acList:
2509
- stackNames = []
2510
- for key in assetDic:
2511
- aname = key + " / " + acType
2512
- stackNames.append(aname)
2513
- y2stack[aname] = np.zeros((count, self.N_n))
2514
- y2stack[aname][i][:] = self.alpha_ijkn[i, acList.index(acType), assetDic[key], : self.N_n]
2515
-
2516
- title = self._name + "\nAsset Allocation (%) - " + acType
2517
- if self.ARCoord == "spouses":
2518
- title += " spouses"
2519
- else:
2520
- title += " " + self.inames[i]
2521
-
2522
- if tag != "":
2523
- title += " - " + tag
2524
-
2525
- fig, ax = _stackPlot(self.year_n, self.inames, title, [i], y2stack, stackNames, "upper left", "percent")
2526
- figures.append(fig)
2527
-
2309
+ title = self._name + "\nAsset Allocation"
2310
+ if tag:
2311
+ title += " - " + tag
2312
+ figures = self._plotter.plot_allocations(self.year_n, self.inames, self.alpha_ijkn,
2313
+ self.ARCoord, title)
2528
2314
  if figure:
2529
2315
  return figures
2530
2316
 
2531
- plt.show()
2317
+ for fig in figures:
2318
+ self._plotter.jupyter_renderer(fig)
2532
2319
  return None
2533
2320
 
2534
2321
  @_checkCaseStatus
@@ -2542,30 +2329,15 @@ class Plan(object):
2542
2329
  the default behavior of setDefaultPlots().
2543
2330
  """
2544
2331
  value = self._checkValue(value)
2545
-
2546
2332
  title = self._name + "\nSavings Balance"
2547
- if tag != "":
2333
+ if tag:
2548
2334
  title += " - " + tag
2549
-
2550
- stypes = self.savings_in.keys()
2551
- # Add one year for estate.
2552
- year_n = np.append(self.year_n, [self.year_n[-1] + 1])
2553
-
2554
- if value == "nominal":
2555
- yformat = "\\$k (nominal)"
2556
- savings_in = self.savings_in
2557
- else:
2558
- yformat = "\\$k (" + str(self.year_n[0]) + "\\$)"
2559
- savings_in = {}
2560
- for key in self.savings_in:
2561
- savings_in[key] = self.savings_in[key] / self.gamma_n
2562
-
2563
- fig, ax = _stackPlot(year_n, self.inames, title, range(self.N_i), savings_in, stypes, "upper left", yformat)
2564
-
2335
+ fig = self._plotter.plot_accounts(self.year_n, self.savings_in, self.gamma_n,
2336
+ value, title, self.inames)
2565
2337
  if figure:
2566
2338
  return fig
2567
2339
 
2568
- plt.show()
2340
+ self._plotter.jupyter_renderer(fig)
2569
2341
  return None
2570
2342
 
2571
2343
  @_checkCaseStatus
@@ -2579,58 +2351,15 @@ class Plan(object):
2579
2351
  the default behavior of setDefaultPlots().
2580
2352
  """
2581
2353
  value = self._checkValue(value)
2582
-
2583
2354
  title = self._name + "\nRaw Income Sources"
2584
- stypes = self.sources_in.keys()
2585
- # stypes = [item for item in stypes if "RothX" not in item]
2586
-
2587
- if tag != "":
2355
+ if tag:
2588
2356
  title += " - " + tag
2589
-
2590
- if value == "nominal":
2591
- yformat = "\\$k (nominal)"
2592
- sources_in = self.sources_in
2593
- else:
2594
- yformat = "\\$k (" + str(self.year_n[0]) + "\\$)"
2595
- sources_in = {}
2596
- for key in stypes:
2597
- sources_in[key] = self.sources_in[key] / self.gamma_n[:-1]
2598
-
2599
- fig, ax = _stackPlot(
2600
- self.year_n, self.inames, title, range(self.N_i), sources_in, stypes, "upper left", yformat
2601
- )
2602
-
2357
+ fig = self._plotter.plot_sources(self.year_n, self.sources_in, self.gamma_n,
2358
+ value, title, self.inames)
2603
2359
  if figure:
2604
2360
  return fig
2605
2361
 
2606
- plt.show()
2607
- return None
2608
-
2609
- @_checkCaseStatus
2610
- def _showFeff(self, tag=""):
2611
- """
2612
- Plot income tax paid over time.
2613
-
2614
- A tag string can be set to add information to the title of the plot.
2615
- """
2616
- title = self._name + "\nEff f "
2617
- if tag != "":
2618
- title += " - " + tag
2619
-
2620
- various = ["-", "--", "-.", ":"]
2621
- style = {}
2622
- series = {}
2623
- q = 0
2624
- for t in range(self.N_t):
2625
- key = "f " + str(t)
2626
- series[key] = self.F_tn[t] / self.DeltaBar_tn[t]
2627
- # print(key, series[key])
2628
- style[key] = various[q % len(various)]
2629
- q += 1
2630
-
2631
- fig, ax = _lineIncomePlot(self.year_n, series, style, title, yformat="")
2632
-
2633
- plt.show()
2362
+ self._plotter.jupyter_renderer(fig)
2634
2363
  return None
2635
2364
 
2636
2365
  @_checkCaseStatus
@@ -2644,87 +2373,17 @@ class Plan(object):
2644
2373
  the default behavior of setDefaultPlots().
2645
2374
  """
2646
2375
  value = self._checkValue(value)
2647
-
2648
- style = {"income taxes": "-", "Medicare": "-."}
2649
-
2650
- if value == "nominal":
2651
- series = {"income taxes": self.T_n, "Medicare": self.M_n}
2652
- yformat = "\\$k (nominal)"
2653
- else:
2654
- series = {
2655
- "income taxes": self.T_n / self.gamma_n[:-1],
2656
- "Medicare": self.M_n / self.gamma_n[:-1],
2657
- }
2658
- yformat = "\\$k (" + str(self.year_n[0]) + "\\$)"
2659
-
2660
2376
  title = self._name + "\nIncome Tax"
2661
- if tag != "":
2377
+ if tag:
2662
2378
  title += " - " + tag
2663
-
2664
- fig, ax = _lineIncomePlot(self.year_n, series, style, title, yformat)
2665
-
2379
+ fig = self._plotter.plot_taxes(self.year_n, self.T_n, self.M_n, self.gamma_n,
2380
+ value, title, self.inames)
2666
2381
  if figure:
2667
2382
  return fig
2668
2383
 
2669
- plt.show()
2384
+ self._plotter.jupyter_renderer(fig)
2670
2385
  return None
2671
2386
 
2672
- @_checkCaseStatus
2673
- def showGrossIncome(self, tag="", value=None, figure=False):
2674
- """
2675
- Plot income tax and taxable income over time horizon.
2676
-
2677
- A tag string can be set to add information to the title of the plot.
2678
-
2679
- The value parameter can be set to *nominal* or *today*, overriding
2680
- the default behavior of setDefaultPlots().
2681
- """
2682
- value = self._checkValue(value)
2683
-
2684
- style = {"taxable income": "-"}
2685
-
2686
- if value == "nominal":
2687
- series = {"taxable income": self.G_n}
2688
- yformat = "\\$k (nominal)"
2689
- infladjust = self.gamma_n[:-1]
2690
- else:
2691
- series = {"taxable income": self.G_n / self.gamma_n[:-1]}
2692
- yformat = "\\$k (" + str(self.year_n[0]) + "\\$)"
2693
- infladjust = 1
2694
-
2695
- title = self._name + "\nTaxable Ordinary Income vs. Tax Brackets"
2696
- if tag != "":
2697
- title += " - " + tag
2698
-
2699
- fig, ax = _lineIncomePlot(self.year_n, series, style, title, yformat)
2700
-
2701
- data = tx.taxBrackets(self.N_i, self.n_d, self.N_n, self.yTCJA)
2702
- for key in data:
2703
- data_adj = data[key] * infladjust
2704
- ax.plot(self.year_n, data_adj, label=key, ls=":")
2705
-
2706
- plt.grid(visible="both")
2707
- ax.legend(loc="upper left", reverse=True, fontsize=8, framealpha=0.3)
2708
-
2709
- if figure:
2710
- return fig
2711
-
2712
- plt.show()
2713
- return None
2714
-
2715
- # @_checkCaseStatus
2716
- def saveConfig(self, basename=None):
2717
- """
2718
- Save parameters in a configuration file.
2719
- """
2720
- if basename is None:
2721
- basename = "case_" + self._name
2722
-
2723
- config.saveConfig(self, basename, self.mylog)
2724
-
2725
- return None
2726
-
2727
- @_checkCaseStatus
2728
2387
  def saveWorkbook(self, overwrite=False, *, basename=None, saveToFile=True):
2729
2388
  """
2730
2389
  Save instance in an Excel spreadsheet.
@@ -2826,7 +2485,7 @@ class Plan(object):
2826
2485
  for i in range(self.N_i):
2827
2486
  sname = self.inames[i] + "'s Sources"
2828
2487
  ws = wb.create_sheet(sname)
2829
- fillsheet(ws, srcDic, "currency", op=lambda x: x[i])
2488
+ fillsheet(ws, srcDic, "currency", op=lambda x: x[i]) # noqa: B023
2830
2489
 
2831
2490
  # Account balances except final year.
2832
2491
  accDic = {
@@ -2846,7 +2505,7 @@ class Plan(object):
2846
2505
  for i in range(self.N_i):
2847
2506
  sname = self.inames[i] + "'s Accounts"
2848
2507
  ws = wb.create_sheet(sname)
2849
- fillsheet(ws, accDic, "currency", op=lambda x: x[i])
2508
+ fillsheet(ws, accDic, "currency", op=lambda x: x[i]) # noqa: B023
2850
2509
  # Add final balances.
2851
2510
  lastRow = [
2852
2511
  self.year_n[-1] + 1,
@@ -2963,73 +2622,20 @@ class Plan(object):
2963
2622
  if key == "n":
2964
2623
  break
2965
2624
  except Exception as e:
2966
- raise Exception(f"Unanticipated exception: {e}.")
2625
+ raise Exception(f"Unanticipated exception: {e}.") from e
2967
2626
 
2968
2627
  return None
2969
2628
 
2629
+ def saveConfig(self, basename=None):
2630
+ """
2631
+ Save parameters in a configuration file.
2632
+ """
2633
+ if basename is None:
2634
+ basename = "case_" + self._name
2970
2635
 
2971
- def _lineIncomePlot(x, series, style, title, yformat="\\$k"):
2972
- """
2973
- Core line plotter function.
2974
- """
2975
- import matplotlib.ticker as tk
2976
-
2977
- fig, ax = plt.subplots(figsize=(6, 4))
2978
- plt.grid(visible="both")
2979
-
2980
- for sname in series:
2981
- ax.plot(x, series[sname], label=sname, ls=style[sname])
2982
-
2983
- ax.legend(loc="upper left", reverse=True, fontsize=8, framealpha=0.3)
2984
- ax.set_title(title)
2985
- ax.set_xlabel("year")
2986
- ax.set_ylabel(yformat)
2987
- ax.xaxis.set_major_locator(tk.MaxNLocator(integer=True))
2988
- if "k" in yformat:
2989
- ax.get_yaxis().set_major_formatter(tk.FuncFormatter(lambda x, p: format(int(x / 1000), ",")))
2990
- # Give range to y values in unindexed flat profiles.
2991
- ymin, ymax = ax.get_ylim()
2992
- if ymax - ymin < 5000:
2993
- ax.set_ylim((ymin * 0.95, ymax * 1.05))
2994
-
2995
- return fig, ax
2996
-
2997
-
2998
- def _stackPlot(x, inames, title, irange, series, snames, location, yformat="\\$k"):
2999
- """
3000
- Core function for stacked plots.
3001
- """
3002
- import matplotlib.ticker as tk
3003
-
3004
- nonzeroSeries = {}
3005
- for sname in snames:
3006
- for i in irange:
3007
- tmp = series[sname][i]
3008
- if sum(tmp) > 1.0:
3009
- nonzeroSeries[sname + " " + inames[i]] = tmp
3010
-
3011
- if len(nonzeroSeries) == 0:
3012
- # print('Nothing to plot for', title)
3013
- return None, None
3014
-
3015
- fig, ax = plt.subplots(figsize=(6, 4))
3016
- plt.grid(visible="both")
3017
-
3018
- ax.stackplot(x, nonzeroSeries.values(), labels=nonzeroSeries.keys(), alpha=0.6)
3019
- ax.legend(loc=location, reverse=True, fontsize=8, ncol=2, framealpha=0.5)
3020
- ax.set_title(title)
3021
- ax.set_xlabel("year")
3022
- ax.xaxis.set_major_locator(tk.MaxNLocator(integer=True))
3023
- if "k" in yformat:
3024
- ax.set_ylabel(yformat)
3025
- ax.get_yaxis().set_major_formatter(tk.FuncFormatter(lambda x, p: format(int(x / 1000), ",")))
3026
- elif yformat == "percent":
3027
- ax.set_ylabel("%")
3028
- ax.get_yaxis().set_major_formatter(tk.FuncFormatter(lambda x, p: format(int(100 * x), ",")))
3029
- else:
3030
- raise RuntimeError(f"Unknown yformat: {yformat}.")
2636
+ config.saveConfig(self, basename, self.mylog)
3031
2637
 
3032
- return fig, ax
2638
+ return None
3033
2639
 
3034
2640
 
3035
2641
  def _saveWorkbook(wb, basename, overwrite, mylog):
@@ -3051,7 +2657,7 @@ def _saveWorkbook(wb, basename, overwrite, mylog):
3051
2657
  mylog.vprint("Skipping save and returning.")
3052
2658
  return None
3053
2659
 
3054
- while True:
2660
+ for _ in range(3):
3055
2661
  try:
3056
2662
  mylog.vprint(f'Saving plan as "{fname}".')
3057
2663
  wb.save(fname)
@@ -3062,7 +2668,7 @@ def _saveWorkbook(wb, basename, overwrite, mylog):
3062
2668
  if key == "n":
3063
2669
  break
3064
2670
  except Exception as e:
3065
- raise Exception(f"Unanticipated exception {e}.")
2671
+ raise Exception(f"Unanticipated exception {e}.") from e
3066
2672
 
3067
2673
  return None
3068
2674