owlplanner 2025.5.3__py3-none-any.whl → 2025.5.5__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/__init__.py +6 -6
- owlplanner/abcapi.py +14 -7
- owlplanner/config.py +5 -5
- owlplanner/plan.py +327 -586
- owlplanner/plots.py +296 -0
- owlplanner/rates.py +42 -59
- owlplanner/tax2025.py +2 -1
- owlplanner/timelists.py +1 -1
- owlplanner/version.py +1 -1
- {owlplanner-2025.5.3.dist-info → owlplanner-2025.5.5.dist-info}/METADATA +1 -1
- owlplanner-2025.5.5.dist-info/RECORD +18 -0
- owlplanner-2025.5.3.dist-info/RECORD +0 -17
- {owlplanner-2025.5.3.dist-info → owlplanner-2025.5.5.dist-info}/WHEEL +0 -0
- {owlplanner-2025.5.3.dist-info → owlplanner-2025.5.5.dist-info}/licenses/LICENSE +0 -0
owlplanner/plan.py
CHANGED
|
@@ -22,7 +22,6 @@ from functools import wraps
|
|
|
22
22
|
from openpyxl import Workbook
|
|
23
23
|
from openpyxl.utils.dataframe import dataframe_to_rows
|
|
24
24
|
import time
|
|
25
|
-
import io
|
|
26
25
|
|
|
27
26
|
from owlplanner import utils as u
|
|
28
27
|
from owlplanner import tax2025 as tx
|
|
@@ -32,6 +31,7 @@ from owlplanner import config
|
|
|
32
31
|
from owlplanner import timelists
|
|
33
32
|
from owlplanner import logging
|
|
34
33
|
from owlplanner import progress
|
|
34
|
+
from owlplanner import plots
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
# This makes all graphs to have the same height.
|
|
@@ -248,10 +248,14 @@ class Plan(object):
|
|
|
248
248
|
self.defaultSolver = "HiGHS"
|
|
249
249
|
|
|
250
250
|
self.N_i = len(yobs)
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
251
|
+
if not (0 <= self.N_i <= 2):
|
|
252
|
+
raise ValueError(f"Cannot support {self.N_i} individuals.")
|
|
253
|
+
if self.N_i != len(expectancy):
|
|
254
|
+
raise ValueError(f"Expectancy must have {self.N_i} entries.")
|
|
255
|
+
if self.N_i != len(inames):
|
|
256
|
+
raise ValueError(f"Names for individuals must have {self.N_i} entries.")
|
|
257
|
+
if inames[0] == "" or (self.N_i == 2 and inames[1] == ""):
|
|
258
|
+
raise ValueError("Name for each individual must be provided.")
|
|
255
259
|
|
|
256
260
|
self.filingStatus = ["single", "married"][self.N_i - 1]
|
|
257
261
|
# Default year TCJA is speculated to expire.
|
|
@@ -263,10 +267,9 @@ class Plan(object):
|
|
|
263
267
|
# Reference time is starting date in the current year and all passings are assumed at the end.
|
|
264
268
|
thisyear = date.today().year
|
|
265
269
|
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
270
|
self.N_n = np.max(self.horizons)
|
|
268
271
|
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.
|
|
272
|
+
# Year index in the plan (if any) where individuals turn 59. For 10% withdrawal penalty.
|
|
270
273
|
self.n59 = 59 - thisyear + self.yobs
|
|
271
274
|
self.n59[self.n59 < 0] = 0
|
|
272
275
|
# Handle passing of one spouse before the other.
|
|
@@ -433,7 +436,8 @@ class Plan(object):
|
|
|
433
436
|
where s_n is the surplus amount. Here d_0n is the taxable account
|
|
434
437
|
deposit for the first spouse while d_1n is for the second spouse.
|
|
435
438
|
"""
|
|
436
|
-
|
|
439
|
+
if not (0 <= eta <= 1):
|
|
440
|
+
raise ValueError("Fraction must be between 0 and 1.")
|
|
437
441
|
if self.N_i != 2:
|
|
438
442
|
self.mylog.vprint("Deposit fraction can only be 0 for single individuals.")
|
|
439
443
|
eta = 0
|
|
@@ -452,11 +456,12 @@ class Plan(object):
|
|
|
452
456
|
|
|
453
457
|
def setDividendRate(self, mu):
|
|
454
458
|
"""
|
|
455
|
-
Set dividend rate
|
|
459
|
+
Set dividend tax rate. Rate is in percent. Default 2%.
|
|
456
460
|
"""
|
|
457
|
-
|
|
461
|
+
if not (0 <= mu <= 100):
|
|
462
|
+
raise ValueError("Rate must be between 0 and 100.")
|
|
458
463
|
mu /= 100
|
|
459
|
-
self.mylog.vprint(f"Dividend
|
|
464
|
+
self.mylog.vprint(f"Dividend tax rate set to {u.pc(mu, f=0)}.")
|
|
460
465
|
self.mu = mu
|
|
461
466
|
self.caseStatus = "modified"
|
|
462
467
|
|
|
@@ -473,7 +478,8 @@ class Plan(object):
|
|
|
473
478
|
"""
|
|
474
479
|
Set long-term income tax rate. Rate is in percent. Default 15%.
|
|
475
480
|
"""
|
|
476
|
-
|
|
481
|
+
if not (0 <= psi <= 100):
|
|
482
|
+
raise ValueError("Rate must be between 0 and 100.")
|
|
477
483
|
psi /= 100
|
|
478
484
|
self.mylog.vprint(f"Long-term capital gain income tax set to {u.pc(psi, f=0)}.")
|
|
479
485
|
self.psi = psi
|
|
@@ -484,10 +490,11 @@ class Plan(object):
|
|
|
484
490
|
Set fractions of savings accounts that is left to surviving spouse.
|
|
485
491
|
Default is [1, 1, 1] for taxable, tax-deferred, adn tax-exempt accounts.
|
|
486
492
|
"""
|
|
487
|
-
|
|
493
|
+
if len(phi) != self.N_j:
|
|
494
|
+
raise ValueError(f"Fractions must have {self.N_j} entries.")
|
|
488
495
|
for j in range(self.N_j):
|
|
489
|
-
|
|
490
|
-
|
|
496
|
+
if not (0 <= phi[j] <= 1):
|
|
497
|
+
raise ValueError("Fractions must be between 0 and 1.")
|
|
491
498
|
self.phi_j = np.array(phi, dtype=np.float32)
|
|
492
499
|
self.mylog.vprint("Spousal beneficiary fractions set to",
|
|
493
500
|
["{:.2f}".format(self.phi_j[j]) for j in range(self.N_j)])
|
|
@@ -502,20 +509,24 @@ class Plan(object):
|
|
|
502
509
|
Set the heirs tax rate on the tax-deferred portion of the estate.
|
|
503
510
|
Rate is in percent. Default is 30%.
|
|
504
511
|
"""
|
|
505
|
-
|
|
512
|
+
if not (0 <= nu <= 100):
|
|
513
|
+
raise ValueError("Rate must be between 0 and 100.")
|
|
506
514
|
nu /= 100
|
|
507
515
|
self.mylog.vprint(f"Heirs tax rate on tax-deferred portion of estate set to {u.pc(nu, f=0)}.")
|
|
508
516
|
self.nu = nu
|
|
509
517
|
self.caseStatus = "modified"
|
|
510
518
|
|
|
511
|
-
def setPension(self, amounts, ages, indexed=
|
|
519
|
+
def setPension(self, amounts, ages, indexed=(False, False), units="k"):
|
|
512
520
|
"""
|
|
513
521
|
Set value of pension for each individual and commencement age.
|
|
514
522
|
Units are in $k, unless specified otherwise: 'k', 'M', or '1'.
|
|
515
523
|
"""
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
524
|
+
if len(amounts) != self.N_i:
|
|
525
|
+
raise ValueError(f"Amounts must have {self.N_i} entries.")
|
|
526
|
+
if len(ages) != self.N_i:
|
|
527
|
+
raise ValueError(f"Ages must have {self.N_i} entries.")
|
|
528
|
+
if len(indexed) < self.N_i:
|
|
529
|
+
raise ValueError(f"Indexed list must have at least {self.N_i} entries.")
|
|
519
530
|
|
|
520
531
|
fac = u.getUnits(units)
|
|
521
532
|
amounts = u.rescale(amounts, fac)
|
|
@@ -546,8 +557,10 @@ class Plan(object):
|
|
|
546
557
|
Set value of social security for each individual and commencement age.
|
|
547
558
|
Units are in $k, unless specified otherwise: 'k', 'M', or '1'.
|
|
548
559
|
"""
|
|
549
|
-
|
|
550
|
-
|
|
560
|
+
if len(amounts) != self.N_i:
|
|
561
|
+
raise ValueError(f"Amounts must have {self.N_i} entries.")
|
|
562
|
+
if len(ages) != self.N_i:
|
|
563
|
+
raise ValueError(f"Ages must have {self.N_i} entries.")
|
|
551
564
|
|
|
552
565
|
fac = u.getUnits(units)
|
|
553
566
|
amounts = u.rescale(amounts, fac)
|
|
@@ -582,10 +595,14 @@ class Plan(object):
|
|
|
582
595
|
as a second argument. Default value is 60%.
|
|
583
596
|
Dip and increase are percent changes in the smile profile.
|
|
584
597
|
"""
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
598
|
+
if not (0 <= percent <= 100):
|
|
599
|
+
raise ValueError(f"Survivor value {percent} outside range.")
|
|
600
|
+
if not (0 <= dip <= 100):
|
|
601
|
+
raise ValueError(f"Dip value {dip} outside range.")
|
|
602
|
+
if not (-100 <= increase <= 100):
|
|
603
|
+
raise ValueError(f"Increase value {increase} outside range.")
|
|
604
|
+
if not (0 <= delay <= self.N_n - 2):
|
|
605
|
+
raise ValueError(f"Delay value {delay} outside year range.")
|
|
589
606
|
|
|
590
607
|
self.chi = percent / 100
|
|
591
608
|
|
|
@@ -676,7 +693,8 @@ class Plan(object):
|
|
|
676
693
|
raise RuntimeError("A rate method needs to be first selected using setRates(...).")
|
|
677
694
|
|
|
678
695
|
thisyear = date.today().year
|
|
679
|
-
|
|
696
|
+
if year <= thisyear:
|
|
697
|
+
raise RuntimeError("Internal error in forwardValue().")
|
|
680
698
|
span = year - thisyear
|
|
681
699
|
|
|
682
700
|
return amount * self.gamma_n[span]
|
|
@@ -688,9 +706,12 @@ class Plan(object):
|
|
|
688
706
|
one entry. Units are in $k, unless specified otherwise: 'k', 'M', or '1'.
|
|
689
707
|
"""
|
|
690
708
|
plurals = ["", "y", "ies"][self.N_i]
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
709
|
+
if len(taxable) != self.N_i:
|
|
710
|
+
raise ValueError(f"taxable must have {self.N_i} entr{plurals}.")
|
|
711
|
+
if len(taxDeferred) != self.N_i:
|
|
712
|
+
raise ValueError(f"taxDeferred must have {self.N_i} entr{plurals}.")
|
|
713
|
+
if len(taxFree) != self.N_i:
|
|
714
|
+
raise ValueError(f"taxFree must have {self.N_i} entr{plurals}.")
|
|
694
715
|
|
|
695
716
|
fac = u.getUnits(units)
|
|
696
717
|
taxable = u.rescale(taxable, fac)
|
|
@@ -766,13 +787,17 @@ class Plan(object):
|
|
|
766
787
|
if allocType == "account":
|
|
767
788
|
# Make sure we have proper input.
|
|
768
789
|
for item in [taxable, taxDeferred, taxFree]:
|
|
769
|
-
|
|
790
|
+
if len(item) != self.N_i:
|
|
791
|
+
raise ValueError(f"{item} must have one entry per individual.")
|
|
770
792
|
for i in range(self.N_i):
|
|
771
793
|
# Initial and final.
|
|
772
|
-
|
|
794
|
+
if len(item[i]) != 2:
|
|
795
|
+
raise ValueError(f"{item}[{i}] must have 2 lists (initial and final).")
|
|
773
796
|
for z in range(2):
|
|
774
|
-
|
|
775
|
-
|
|
797
|
+
if len(item[i][z]) != self.N_k:
|
|
798
|
+
raise ValueError(f"{item}[{i}][{z}] must have {self.N_k} entries.")
|
|
799
|
+
if abs(sum(item[i][z]) - 100) > 0.01:
|
|
800
|
+
raise ValueError("Sum of percentages must add to 100.")
|
|
776
801
|
|
|
777
802
|
for i in range(self.N_i):
|
|
778
803
|
self.mylog.vprint(f"{self.inames[i]}: Setting gliding allocation ratios (%) to {allocType}.")
|
|
@@ -799,13 +824,17 @@ class Plan(object):
|
|
|
799
824
|
self.boundsAR["tax-free"] = taxFree
|
|
800
825
|
|
|
801
826
|
elif allocType == "individual":
|
|
802
|
-
|
|
827
|
+
if len(generic) != self.N_i:
|
|
828
|
+
raise ValueError("generic must have one list per individual.")
|
|
803
829
|
for i in range(self.N_i):
|
|
804
830
|
# Initial and final.
|
|
805
|
-
|
|
831
|
+
if len(generic[i]) != 2:
|
|
832
|
+
raise ValueError(f"generic[{i}] must have 2 lists (initial and final).")
|
|
806
833
|
for z in range(2):
|
|
807
|
-
|
|
808
|
-
|
|
834
|
+
if len(generic[i][z]) != self.N_k:
|
|
835
|
+
raise ValueError(f"generic[{i}][{z}] must have {self.N_k} entries.")
|
|
836
|
+
if abs(sum(generic[i][z]) - 100) > 0.01:
|
|
837
|
+
raise ValueError("Sum of percentages must add to 100.")
|
|
809
838
|
|
|
810
839
|
for i in range(self.N_i):
|
|
811
840
|
self.mylog.vprint(f"{self.inames[i]}: Setting gliding allocation ratios (%) to {allocType}.")
|
|
@@ -817,30 +846,26 @@ class Plan(object):
|
|
|
817
846
|
start = generic[i][0][k] / 100
|
|
818
847
|
end = generic[i][1][k] / 100
|
|
819
848
|
dat = self._interpolator(start, end, Nin)
|
|
820
|
-
|
|
821
|
-
self.alpha_ijkn[i, j, k, :Nin] = dat[:]
|
|
849
|
+
self.alpha_ijkn[i, :, k, :Nin] = dat[:]
|
|
822
850
|
|
|
823
851
|
self.boundsAR["generic"] = generic
|
|
824
852
|
|
|
825
853
|
elif allocType == "spouses":
|
|
826
|
-
|
|
854
|
+
if len(generic) != 2:
|
|
855
|
+
raise ValueError("generic must have 2 entries (initial and final).")
|
|
827
856
|
for z in range(2):
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
|
857
|
+
if len(generic[z]) != self.N_k:
|
|
858
|
+
raise ValueError(f"generic[{z}] must have {self.N_k} entries.")
|
|
859
|
+
if abs(sum(generic[z]) - 100) > 0.01:
|
|
860
|
+
raise ValueError("Sum of percentages must add to 100.")
|
|
836
861
|
|
|
837
|
-
for
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
862
|
+
for i in range(self.N_i):
|
|
863
|
+
Nin = self.horizons[i] + 1
|
|
864
|
+
for k in range(self.N_k):
|
|
865
|
+
start = generic[0][k] / 100
|
|
866
|
+
end = generic[1][k] / 100
|
|
867
|
+
dat = self._interpolator(start, end, Nin)
|
|
868
|
+
self.alpha_ijkn[i, :, k, :Nin] = dat[:]
|
|
844
869
|
|
|
845
870
|
self.boundsAR["generic"] = generic
|
|
846
871
|
|
|
@@ -873,8 +898,7 @@ class Plan(object):
|
|
|
873
898
|
try:
|
|
874
899
|
filename, self.timeLists = timelists.read(filename, self.inames, self.horizons, self.mylog)
|
|
875
900
|
except Exception as e:
|
|
876
|
-
raise Exception(f"Unsuccessful read of contributions: {e}")
|
|
877
|
-
return False
|
|
901
|
+
raise Exception(f"Unsuccessful read of contributions: {e}") from e
|
|
878
902
|
|
|
879
903
|
self.timeListsFileName = filename
|
|
880
904
|
self.setContributions()
|
|
@@ -971,9 +995,7 @@ class Plan(object):
|
|
|
971
995
|
a linear interpolation.
|
|
972
996
|
"""
|
|
973
997
|
# num goes one more year as endpoint=True.
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
return dat
|
|
998
|
+
return np.linspace(a, b, numPoints)
|
|
977
999
|
|
|
978
1000
|
def _tanhInterp(self, a, b, numPoints):
|
|
979
1001
|
"""
|
|
@@ -1051,259 +1073,217 @@ class Plan(object):
|
|
|
1051
1073
|
def _buildConstraints(self, objective, options):
|
|
1052
1074
|
"""
|
|
1053
1075
|
Utility function that builds constraint matrix and vectors.
|
|
1054
|
-
|
|
1055
|
-
"""
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1076
|
+
Refactored for clarity and maintainability.
|
|
1077
|
+
"""
|
|
1078
|
+
self._setup_constraint_shortcuts(options)
|
|
1079
|
+
|
|
1080
|
+
self.A = abc.ConstraintMatrix(self.nvars)
|
|
1081
|
+
self.B = abc.Bounds(self.nvars, self.nbins)
|
|
1082
|
+
|
|
1083
|
+
self._add_rmd_inequalities()
|
|
1084
|
+
self._add_tax_bracket_bounds()
|
|
1085
|
+
self._add_standard_exemption_bounds()
|
|
1086
|
+
self._add_defunct_constraints()
|
|
1087
|
+
self._add_roth_conversion_constraints(options)
|
|
1088
|
+
self._add_withdrawal_limits()
|
|
1089
|
+
self._add_conversion_limits()
|
|
1090
|
+
self._add_objective_constraints(objective, options)
|
|
1091
|
+
self._add_initial_balances()
|
|
1092
|
+
self._add_surplus_deposit_linking()
|
|
1093
|
+
self._add_account_balance_carryover()
|
|
1094
|
+
self._add_net_cash_flow()
|
|
1095
|
+
self._add_income_profile()
|
|
1096
|
+
self._add_taxable_income()
|
|
1097
|
+
self._configure_binary_variables(options)
|
|
1098
|
+
self._build_objective_vector(objective)
|
|
1059
1099
|
|
|
1060
|
-
|
|
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
|
|
1100
|
+
return None
|
|
1083
1101
|
|
|
1102
|
+
def _setup_constraint_shortcuts(self, options):
|
|
1103
|
+
# Set up all the local variables as attributes for use in helpers
|
|
1084
1104
|
oppCostX = options.get("oppCostX", 0.)
|
|
1085
|
-
xnet = 1 - oppCostX/100.
|
|
1086
|
-
|
|
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
|
|
1105
|
+
self.xnet = 1 - oppCostX / 100.
|
|
1106
|
+
self.optionsUnits = u.getUnits(options.get("units", "k"))
|
|
1096
1107
|
|
|
1097
|
-
|
|
1098
|
-
|
|
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):
|
|
1108
|
+
def _add_rmd_inequalities(self):
|
|
1109
|
+
for i in range(self.N_i):
|
|
1109
1110
|
if self.beta_ij[i, 1] > 0:
|
|
1110
1111
|
for n in range(self.horizons[i]):
|
|
1111
1112
|
rowDic = {
|
|
1112
|
-
_q3(
|
|
1113
|
-
_q3(
|
|
1113
|
+
_q3(self.C["w"], i, 1, n, self.N_i, self.N_j, self.N_n): 1,
|
|
1114
|
+
_q3(self.C["b"], i, 1, n, self.N_i, self.N_j, self.N_n + 1): -self.rho_in[i, n],
|
|
1114
1115
|
}
|
|
1115
|
-
A.addNewRow(rowDic,
|
|
1116
|
+
self.A.addNewRow(rowDic, 0, np.inf)
|
|
1116
1117
|
|
|
1117
|
-
|
|
1118
|
-
for t in range(
|
|
1119
|
-
for n in range(
|
|
1120
|
-
B.setRange(_q2(
|
|
1118
|
+
def _add_tax_bracket_bounds(self):
|
|
1119
|
+
for t in range(self.N_t):
|
|
1120
|
+
for n in range(self.N_n):
|
|
1121
|
+
self.B.setRange(_q2(self.C["F"], t, n, self.N_t, self.N_n), 0, self.DeltaBar_tn[t, n])
|
|
1121
1122
|
|
|
1122
|
-
|
|
1123
|
-
for n in range(
|
|
1124
|
-
B.setRange(_q1(
|
|
1123
|
+
def _add_standard_exemption_bounds(self):
|
|
1124
|
+
for n in range(self.N_n):
|
|
1125
|
+
self.B.setRange(_q1(self.C["e"], n, self.N_n), 0, self.sigmaBar_n[n])
|
|
1125
1126
|
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
for n in range(self.
|
|
1129
|
-
B.setRange(_q2(
|
|
1130
|
-
B.setRange(_q2(
|
|
1131
|
-
for j in range(
|
|
1132
|
-
B.setRange(_q3(
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
# This condition supercedes everything else.
|
|
1127
|
+
def _add_defunct_constraints(self):
|
|
1128
|
+
if self.N_i == 2:
|
|
1129
|
+
for n in range(self.n_d, self.N_n):
|
|
1130
|
+
self.B.setRange(_q2(self.C["d"], self.i_d, n, self.N_i, self.N_n), 0, 0)
|
|
1131
|
+
self.B.setRange(_q2(self.C["x"], self.i_d, n, self.N_i, self.N_n), 0, 0)
|
|
1132
|
+
for j in range(self.N_j):
|
|
1133
|
+
self.B.setRange(_q3(self.C["w"], self.i_d, j, n, self.N_i, self.N_j, self.N_n), 0, 0)
|
|
1134
|
+
|
|
1135
|
+
def _add_roth_conversion_constraints(self, options):
|
|
1136
1136
|
if "maxRothConversion" in options and options["maxRothConversion"] == "file":
|
|
1137
|
-
|
|
1138
|
-
for i in range(Ni):
|
|
1137
|
+
for i in range(self.N_i):
|
|
1139
1138
|
for n in range(self.horizons[i]):
|
|
1140
1139
|
rhs = self.myRothX_in[i][n]
|
|
1141
|
-
B.setRange(_q2(
|
|
1140
|
+
self.B.setRange(_q2(self.C["x"], i, n, self.N_i, self.N_n), rhs, rhs)
|
|
1142
1141
|
else:
|
|
1143
1142
|
if "maxRothConversion" in options:
|
|
1144
1143
|
rhsopt = options["maxRothConversion"]
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
# self.mylog.vprint('Limiting Roth conversions to:', u.d(rhsopt))
|
|
1152
|
-
for i in range(Ni):
|
|
1144
|
+
if not isinstance(rhsopt, (int, float)):
|
|
1145
|
+
raise ValueError(f"Specified maxRothConversion {rhsopt} is not a number.")
|
|
1146
|
+
|
|
1147
|
+
if rhsopt >= 0:
|
|
1148
|
+
rhsopt *= self.optionsUnits
|
|
1149
|
+
for i in range(self.N_i):
|
|
1153
1150
|
for n in range(self.horizons[i]):
|
|
1154
|
-
|
|
1155
|
-
# Should we adjust Roth conversion cap with inflation?
|
|
1156
|
-
B.setRange(_q2(Cx, i, n, Ni, Nn), zero, rhsopt + 0.01)
|
|
1151
|
+
self.B.setRange(_q2(self.C["x"], i, n, self.N_i, self.N_n), 0, rhsopt + 0.01)
|
|
1157
1152
|
|
|
1158
|
-
# Process startRothConversions option.
|
|
1159
1153
|
if "startRothConversions" in options:
|
|
1160
1154
|
rhsopt = options["startRothConversions"]
|
|
1161
|
-
|
|
1155
|
+
if not isinstance(rhsopt, (int, float)):
|
|
1156
|
+
raise ValueError(f"Specified startRothConversions {rhsopt} is not a number.")
|
|
1162
1157
|
thisyear = date.today().year
|
|
1163
1158
|
yearn = max(rhsopt - thisyear, 0)
|
|
1164
|
-
|
|
1165
|
-
for i in range(Ni):
|
|
1159
|
+
for i in range(self.N_i):
|
|
1166
1160
|
nstart = min(yearn, self.horizons[i])
|
|
1167
1161
|
for n in range(0, nstart):
|
|
1168
|
-
B.setRange(_q2(
|
|
1162
|
+
self.B.setRange(_q2(self.C["x"], i, n, self.N_i, self.N_n), 0, 0)
|
|
1169
1163
|
|
|
1170
|
-
# Process noRothConversions option. Also valid when N_i == 1, why not?
|
|
1171
1164
|
if "noRothConversions" in options and options["noRothConversions"] != "None":
|
|
1172
1165
|
rhsopt = options["noRothConversions"]
|
|
1173
1166
|
try:
|
|
1174
1167
|
i_x = self.inames.index(rhsopt)
|
|
1175
|
-
except ValueError:
|
|
1176
|
-
raise ValueError(f"Unknown individual {rhsopt} for noRothConversions:")
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
B.setRange(_q2(Cx, i_x, n, Ni, Nn), zero, zero)
|
|
1168
|
+
except ValueError as e:
|
|
1169
|
+
raise ValueError(f"Unknown individual {rhsopt} for noRothConversions:") from e
|
|
1170
|
+
for n in range(self.N_n):
|
|
1171
|
+
self.B.setRange(_q2(self.C["x"], i_x, n, self.N_i, self.N_n), 0, 0)
|
|
1180
1172
|
|
|
1181
|
-
|
|
1182
|
-
for i in range(
|
|
1173
|
+
def _add_withdrawal_limits(self):
|
|
1174
|
+
for i in range(self.N_i):
|
|
1183
1175
|
for j in [0, 2]:
|
|
1184
|
-
for n in range(
|
|
1185
|
-
rowDic = {_q3(
|
|
1186
|
-
|
|
1176
|
+
for n in range(self.N_n):
|
|
1177
|
+
rowDic = {_q3(self.C["w"], i, j, n, self.N_i, self.N_j, self.N_n): -1,
|
|
1178
|
+
_q3(self.C["b"], i, j, n, self.N_i, self.N_j, self.N_n + 1): 1}
|
|
1179
|
+
self.A.addNewRow(rowDic, 0, np.inf)
|
|
1187
1180
|
|
|
1188
|
-
|
|
1189
|
-
for i in range(
|
|
1190
|
-
for n in range(
|
|
1181
|
+
def _add_conversion_limits(self):
|
|
1182
|
+
for i in range(self.N_i):
|
|
1183
|
+
for n in range(self.N_n):
|
|
1191
1184
|
rowDic = {
|
|
1192
|
-
_q2(
|
|
1193
|
-
_q3(
|
|
1194
|
-
_q3(
|
|
1185
|
+
_q2(self.C["x"], i, n, self.N_i, self.N_n): -1,
|
|
1186
|
+
_q3(self.C["w"], i, 1, n, self.N_i, self.N_j, self.N_n): -1,
|
|
1187
|
+
_q3(self.C["b"], i, 1, n, self.N_i, self.N_j, self.N_n + 1): 1,
|
|
1195
1188
|
}
|
|
1196
|
-
A.addNewRow(rowDic,
|
|
1189
|
+
self.A.addNewRow(rowDic, 0, np.inf)
|
|
1197
1190
|
|
|
1198
|
-
|
|
1191
|
+
def _add_objective_constraints(self, objective, options):
|
|
1199
1192
|
if objective == "maxSpending":
|
|
1200
|
-
# Impose optional constraint on final bequest requested in today's $.
|
|
1201
1193
|
if "bequest" in options:
|
|
1202
1194
|
bequest = options["bequest"]
|
|
1203
|
-
|
|
1204
|
-
|
|
1195
|
+
if not isinstance(bequest, (int, float)):
|
|
1196
|
+
raise ValueError(f"Desired bequest {bequest} is not a number.")
|
|
1197
|
+
bequest *= self.optionsUnits * self.gamma_n[-1]
|
|
1205
1198
|
else:
|
|
1206
|
-
# If not specified, defaults to $1 (nominal $).
|
|
1207
1199
|
bequest = 1
|
|
1208
1200
|
|
|
1209
|
-
row = A.newRow()
|
|
1210
|
-
for i in range(
|
|
1211
|
-
row.addElem(_q3(
|
|
1212
|
-
row.addElem(_q3(
|
|
1213
|
-
|
|
1214
|
-
|
|
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))
|
|
1201
|
+
row = self.A.newRow()
|
|
1202
|
+
for i in range(self.N_i):
|
|
1203
|
+
row.addElem(_q3(self.C["b"], i, 0, self.N_n, self.N_i, self.N_j, self.N_n + 1), 1)
|
|
1204
|
+
row.addElem(_q3(self.C["b"], i, 1, self.N_n, self.N_i, self.N_j, self.N_n + 1), 1 - self.nu)
|
|
1205
|
+
row.addElem(_q3(self.C["b"], i, 2, self.N_n, self.N_i, self.N_j, self.N_n + 1), 1)
|
|
1206
|
+
self.A.addRow(row, bequest, bequest)
|
|
1218
1207
|
elif objective == "maxBequest":
|
|
1219
1208
|
spending = options["netSpending"]
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
spending *=
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
# Set initial balances through bounds or constraints.
|
|
1229
|
-
for i in range(Ni):
|
|
1230
|
-
for j in range(Nj):
|
|
1209
|
+
if not isinstance(spending, (int, float)):
|
|
1210
|
+
raise ValueError(f"Desired spending provided {spending} is not a number.")
|
|
1211
|
+
spending *= self.optionsUnits * self.yearFracLeft
|
|
1212
|
+
self.B.setRange(_q1(self.C["g"], 0, self.N_n), spending, spending)
|
|
1213
|
+
|
|
1214
|
+
def _add_initial_balances(self):
|
|
1215
|
+
for i in range(self.N_i):
|
|
1216
|
+
for j in range(self.N_j):
|
|
1231
1217
|
rhs = self.beta_ij[i, j]
|
|
1232
|
-
|
|
1233
|
-
B.setRange(_q3(Cb, i, j, 0, Ni, Nj, Nn + 1), rhs, rhs)
|
|
1218
|
+
self.B.setRange(_q3(self.C["b"], i, j, 0, self.N_i, self.N_j, self.N_n + 1), rhs, rhs)
|
|
1234
1219
|
|
|
1235
|
-
|
|
1236
|
-
for i in range(
|
|
1220
|
+
def _add_surplus_deposit_linking(self):
|
|
1221
|
+
for i in range(self.N_i):
|
|
1237
1222
|
fac1 = u.krond(i, 0) * (1 - self.eta) + u.krond(i, 1) * self.eta
|
|
1238
|
-
for n in range(n_d):
|
|
1239
|
-
rowDic = {_q2(
|
|
1240
|
-
A.addNewRow(rowDic,
|
|
1223
|
+
for n in range(self.n_d):
|
|
1224
|
+
rowDic = {_q2(self.C["d"], i, n, self.N_i, self.N_n): 1, _q1(self.C["s"], n, self.N_n): -fac1}
|
|
1225
|
+
self.A.addNewRow(rowDic, 0, 0)
|
|
1241
1226
|
fac2 = u.krond(self.i_s, i)
|
|
1242
|
-
for n in range(n_d,
|
|
1243
|
-
rowDic = {_q2(
|
|
1244
|
-
A.addNewRow(rowDic,
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
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))
|
|
1227
|
+
for n in range(self.n_d, self.N_n):
|
|
1228
|
+
rowDic = {_q2(self.C["d"], i, n, self.N_i, self.N_n): 1, _q1(self.C["s"], n, self.N_n): -fac2}
|
|
1229
|
+
self.A.addNewRow(rowDic, 0, 0)
|
|
1230
|
+
# Prevent surplus on last year.
|
|
1231
|
+
self.B.setRange(_q1(self.C["s"], self.N_n - 1, self.N_n), 0, 0)
|
|
1232
|
+
|
|
1233
|
+
def _add_account_balance_carryover(self):
|
|
1234
|
+
tau_ijn = np.zeros((self.N_i, self.N_j, self.N_n))
|
|
1235
|
+
for i in range(self.N_i):
|
|
1236
|
+
for j in range(self.N_j):
|
|
1237
|
+
for n in range(self.N_n):
|
|
1238
|
+
tau_ijn[i, j, n] = np.sum(self.alpha_ijkn[i, j, :, n] * self.tau_kn[:, n], axis=0)
|
|
1239
|
+
|
|
1240
|
+
# Weights are normalized on k: sum_k[alpha*(1 + tau)] = 1 + sum_k[alpha*tau]
|
|
1241
|
+
Tau1_ijn = 1 + tau_ijn
|
|
1242
|
+
Tauh_ijn = 1 + tau_ijn / 2
|
|
1243
|
+
|
|
1244
|
+
for i in range(self.N_i):
|
|
1245
|
+
for j in range(self.N_j):
|
|
1246
|
+
for n in range(self.N_n):
|
|
1247
|
+
if self.N_i == 2 and self.n_d < self.N_n and i == self.i_d and n == self.n_d - 1:
|
|
1270
1248
|
fac1 = 0
|
|
1271
1249
|
else:
|
|
1272
1250
|
fac1 = 1
|
|
1273
1251
|
|
|
1274
1252
|
rhs = fac1 * self.kappa_ijn[i, j, n] * Tauh_ijn[i, j, n]
|
|
1275
1253
|
|
|
1276
|
-
row = A.newRow()
|
|
1277
|
-
row.addElem(_q3(
|
|
1278
|
-
row.addElem(_q3(
|
|
1279
|
-
row.addElem(_q3(
|
|
1280
|
-
row.addElem(_q2(
|
|
1254
|
+
row = self.A.newRow()
|
|
1255
|
+
row.addElem(_q3(self.C["b"], i, j, n + 1, self.N_i, self.N_j, self.N_n + 1), 1)
|
|
1256
|
+
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])
|
|
1257
|
+
row.addElem(_q3(self.C["w"], i, j, n, self.N_i, self.N_j, self.N_n), fac1 * Tau1_ijn[i, j, n])
|
|
1258
|
+
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
1259
|
row.addElem(
|
|
1282
|
-
_q2(
|
|
1283
|
-
-fac1 * (xnet*u.krond(j, 2) - u.krond(j, 1)) * Tau1_ijn[i, j, n],
|
|
1260
|
+
_q2(self.C["x"], i, n, self.N_i, self.N_n),
|
|
1261
|
+
-fac1 * (self.xnet * u.krond(j, 2) - u.krond(j, 1)) * Tau1_ijn[i, j, n],
|
|
1284
1262
|
)
|
|
1285
1263
|
|
|
1286
|
-
if
|
|
1264
|
+
if self.N_i == 2 and self.n_d < self.N_n and i == self.i_s and n == self.n_d - 1:
|
|
1287
1265
|
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(
|
|
1290
|
-
|
|
1291
|
-
row.addElem(
|
|
1266
|
+
rhs += fac2 * self.kappa_ijn[self.i_d, j, n] * Tauh_ijn[self.i_d, j, n]
|
|
1267
|
+
row.addElem(_q3(self.C["b"], self.i_d, j, n, self.N_i, self.N_j, self.N_n + 1),
|
|
1268
|
+
-fac2 * Tau1_ijn[self.i_d, j, n])
|
|
1269
|
+
row.addElem(_q3(self.C["w"], self.i_d, j, n, self.N_i, self.N_j, self.N_n),
|
|
1270
|
+
fac2 * Tau1_ijn[self.i_d, j, n])
|
|
1271
|
+
row.addElem(_q2(self.C["d"], self.i_d, n, self.N_i, self.N_n),
|
|
1272
|
+
-fac2 * u.krond(j, 0) * Tau1_ijn[self.i_d, 0, n])
|
|
1292
1273
|
row.addElem(
|
|
1293
|
-
_q2(
|
|
1294
|
-
-fac2 * (xnet*u.krond(j, 2) - u.krond(j, 1)) * Tau1_ijn[i_d, j, n],
|
|
1274
|
+
_q2(self.C["x"], self.i_d, n, self.N_i, self.N_n),
|
|
1275
|
+
-fac2 * (self.xnet * u.krond(j, 2) - u.krond(j, 1)) * Tau1_ijn[self.i_d, j, n],
|
|
1295
1276
|
)
|
|
1296
|
-
A.addRow(row, rhs, rhs)
|
|
1277
|
+
self.A.addRow(row, rhs, rhs)
|
|
1297
1278
|
|
|
1279
|
+
def _add_net_cash_flow(self):
|
|
1298
1280
|
tau_0prev = np.roll(self.tau_kn[0, :], 1)
|
|
1299
1281
|
tau_0prev[tau_0prev < 0] = 0
|
|
1300
|
-
|
|
1301
|
-
# Net cash flow.
|
|
1302
|
-
for n in range(Nn):
|
|
1282
|
+
for n in range(self.N_n):
|
|
1303
1283
|
rhs = -self.M_n[n]
|
|
1304
|
-
row = A.newRow({_q1(
|
|
1305
|
-
row.addElem(_q1(
|
|
1306
|
-
for i in range(
|
|
1284
|
+
row = self.A.newRow({_q1(self.C["g"], n, self.N_n): 1})
|
|
1285
|
+
row.addElem(_q1(self.C["s"], n, self.N_n), 1)
|
|
1286
|
+
for i in range(self.N_i):
|
|
1307
1287
|
fac = self.psi * self.alpha_ijkn[i, 0, 0, n]
|
|
1308
1288
|
rhs += (
|
|
1309
1289
|
self.omega_in[i, n]
|
|
@@ -1312,112 +1292,99 @@ class Plan(object):
|
|
|
1312
1292
|
+ self.Lambda_in[i, n]
|
|
1313
1293
|
- 0.5 * fac * self.mu * self.kappa_ijn[i, 0, n]
|
|
1314
1294
|
)
|
|
1315
|
-
|
|
1316
|
-
row.addElem(_q3(
|
|
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)
|
|
1295
|
+
row.addElem(_q3(self.C["b"], i, 0, n, self.N_i, self.N_j, self.N_n + 1), fac * self.mu)
|
|
1296
|
+
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
1297
|
penalty = 0.1 if n < self.n59[i] else 0
|
|
1321
|
-
row.addElem(_q3(
|
|
1322
|
-
row.addElem(_q3(
|
|
1323
|
-
row.addElem(_q2(
|
|
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])
|
|
1298
|
+
row.addElem(_q3(self.C["w"], i, 1, n, self.N_i, self.N_j, self.N_n), -1 + penalty)
|
|
1299
|
+
row.addElem(_q3(self.C["w"], i, 2, n, self.N_i, self.N_j, self.N_n), -1 + penalty)
|
|
1300
|
+
row.addElem(_q2(self.C["d"], i, n, self.N_i, self.N_n), fac * self.mu)
|
|
1328
1301
|
|
|
1329
|
-
|
|
1302
|
+
for t in range(self.N_t):
|
|
1303
|
+
row.addElem(_q2(self.C["F"], t, n, self.N_t, self.N_n), self.theta_tn[t, n])
|
|
1330
1304
|
|
|
1331
|
-
|
|
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)
|
|
1305
|
+
self.A.addRow(row, rhs, rhs)
|
|
1337
1306
|
|
|
1338
|
-
|
|
1339
|
-
|
|
1307
|
+
def _add_income_profile(self):
|
|
1308
|
+
spLo = 1 - self.lambdha
|
|
1309
|
+
spHi = 1 + self.lambdha
|
|
1310
|
+
for n in range(1, self.N_n):
|
|
1311
|
+
rowDic = {_q1(self.C["g"], 0, self.N_n): spLo * self.xiBar_n[n],
|
|
1312
|
+
_q1(self.C["g"], n, self.N_n): -self.xiBar_n[0]}
|
|
1313
|
+
self.A.addNewRow(rowDic, -np.inf, 0)
|
|
1314
|
+
rowDic = {_q1(self.C["g"], 0, self.N_n): spHi * self.xiBar_n[n],
|
|
1315
|
+
_q1(self.C["g"], n, self.N_n): -self.xiBar_n[0]}
|
|
1316
|
+
self.A.addNewRow(rowDic, 0, np.inf)
|
|
1317
|
+
|
|
1318
|
+
def _add_taxable_income(self):
|
|
1319
|
+
for n in range(self.N_n):
|
|
1340
1320
|
rhs = 0
|
|
1341
|
-
row = A.newRow()
|
|
1342
|
-
row.addElem(_q1(
|
|
1343
|
-
for i in range(
|
|
1321
|
+
row = self.A.newRow()
|
|
1322
|
+
row.addElem(_q1(self.C["e"], n, self.N_n), 1)
|
|
1323
|
+
for i in range(self.N_i):
|
|
1344
1324
|
rhs += self.omega_in[i, n] + 0.85 * self.zetaBar_in[i, n] + self.piBar_in[i, n]
|
|
1345
|
-
|
|
1346
|
-
row.addElem(
|
|
1347
|
-
|
|
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)
|
|
1325
|
+
row.addElem(_q3(self.C["w"], i, 1, n, self.N_i, self.N_j, self.N_n), -1)
|
|
1326
|
+
row.addElem(_q2(self.C["x"], i, n, self.N_i, self.N_n), -1)
|
|
1327
|
+
fak = np.sum(self.tau_kn[1:self.N_k, n] * self.alpha_ijkn[i, 0, 1:self.N_k, n], axis=0)
|
|
1351
1328
|
rhs += 0.5 * fak * self.kappa_ijn[i, 0, n]
|
|
1352
|
-
row.addElem(_q3(
|
|
1353
|
-
row.addElem(_q3(
|
|
1354
|
-
row.addElem(_q2(
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1329
|
+
row.addElem(_q3(self.C["b"], i, 0, n, self.N_i, self.N_j, self.N_n + 1), -fak)
|
|
1330
|
+
row.addElem(_q3(self.C["w"], i, 0, n, self.N_i, self.N_j, self.N_n), fak)
|
|
1331
|
+
row.addElem(_q2(self.C["d"], i, n, self.N_i, self.N_n), -fak)
|
|
1332
|
+
for t in range(self.N_t):
|
|
1333
|
+
row.addElem(_q2(self.C["F"], t, n, self.N_t, self.N_n), 1)
|
|
1334
|
+
self.A.addRow(row, rhs, rhs)
|
|
1335
|
+
|
|
1336
|
+
def _configure_binary_variables(self, options):
|
|
1337
|
+
bigM = options.get("bigM", 5e6)
|
|
1338
|
+
if not isinstance(bigM, (int, float)):
|
|
1339
|
+
raise ValueError(f"bigM {bigM} is not a number.")
|
|
1360
1340
|
|
|
1361
|
-
|
|
1362
|
-
for i in range(Ni):
|
|
1341
|
+
for i in range(self.N_i):
|
|
1363
1342
|
for n in range(self.horizons[i]):
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
A.addNewRow(
|
|
1369
|
-
{_q3(Cz, i, n, 0, Ni, Nn, Nz): bigM, _q1(Cs, n, Nn): -1},
|
|
1370
|
-
zero,
|
|
1343
|
+
self.A.addNewRow(
|
|
1344
|
+
{_q3(self.C["z"], i, n, 0, self.N_i, self.N_n, self.N_z): bigM,
|
|
1345
|
+
_q1(self.C["s"], n, self.N_n): -1},
|
|
1346
|
+
0,
|
|
1371
1347
|
bigM,
|
|
1372
1348
|
)
|
|
1373
|
-
|
|
1374
|
-
A.addNewRow(
|
|
1349
|
+
self.A.addNewRow(
|
|
1375
1350
|
{
|
|
1376
|
-
_q3(
|
|
1377
|
-
_q3(
|
|
1378
|
-
_q3(
|
|
1351
|
+
_q3(self.C["z"], i, n, 0, self.N_i, self.N_n, self.N_z): bigM,
|
|
1352
|
+
_q3(self.C["w"], i, 0, n, self.N_i, self.N_j, self.N_n): 1,
|
|
1353
|
+
_q3(self.C["w"], i, 2, n, self.N_i, self.N_j, self.N_n): 1,
|
|
1379
1354
|
},
|
|
1380
|
-
|
|
1355
|
+
0,
|
|
1381
1356
|
bigM,
|
|
1382
1357
|
)
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
zero,
|
|
1358
|
+
self.A.addNewRow(
|
|
1359
|
+
{_q3(self.C["z"], i, n, 1, self.N_i, self.N_n, self.N_z): bigM,
|
|
1360
|
+
_q2(self.C["x"], i, n, self.N_i, self.N_n): -1},
|
|
1361
|
+
0,
|
|
1388
1362
|
bigM,
|
|
1389
1363
|
)
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1364
|
+
self.A.addNewRow(
|
|
1365
|
+
{_q3(self.C["z"], i, n, 1, self.N_i, self.N_n, self.N_z): bigM,
|
|
1366
|
+
_q3(self.C["w"], i, 2, n, self.N_i, self.N_j, self.N_n): 1},
|
|
1367
|
+
0,
|
|
1394
1368
|
bigM,
|
|
1395
1369
|
)
|
|
1370
|
+
for n in range(self.horizons[i], self.N_n):
|
|
1371
|
+
self.B.setRange(_q3(self.C["z"], i, n, 0, self.N_i, self.N_n, self.N_z), 0, 0)
|
|
1372
|
+
self.B.setRange(_q3(self.C["z"], i, n, 1, self.N_i, self.N_n, self.N_z), 0, 0)
|
|
1396
1373
|
|
|
1397
|
-
|
|
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.
|
|
1374
|
+
def _build_objective_vector(self, objective):
|
|
1402
1375
|
c = abc.Objective(self.nvars)
|
|
1403
1376
|
if objective == "maxSpending":
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
c.setElem(_q1(Cg, n, Nn), -1/self.gamma_n[n])
|
|
1377
|
+
for n in range(self.N_n):
|
|
1378
|
+
c.setElem(_q1(self.C["g"], n, self.N_n), -1/self.gamma_n[n])
|
|
1407
1379
|
elif objective == "maxBequest":
|
|
1408
|
-
for i in range(
|
|
1409
|
-
c.setElem(_q3(
|
|
1410
|
-
c.setElem(_q3(
|
|
1411
|
-
c.setElem(_q3(
|
|
1380
|
+
for i in range(self.N_i):
|
|
1381
|
+
c.setElem(_q3(self.C["b"], i, 0, self.N_n, self.N_i, self.N_j, self.N_n + 1), -1)
|
|
1382
|
+
c.setElem(_q3(self.C["b"], i, 1, self.N_n, self.N_i, self.N_j, self.N_n + 1), -(1 - self.nu))
|
|
1383
|
+
c.setElem(_q3(self.C["b"], i, 2, self.N_n, self.N_i, self.N_j, self.N_n + 1), -1)
|
|
1412
1384
|
else:
|
|
1413
1385
|
raise RuntimeError("Internal error in objective function.")
|
|
1414
|
-
|
|
1415
|
-
self.A = A
|
|
1416
|
-
self.B = B
|
|
1417
1386
|
self.c = c
|
|
1418
1387
|
|
|
1419
|
-
return None
|
|
1420
|
-
|
|
1421
1388
|
@_timer
|
|
1422
1389
|
def runHistoricalRange(self, objective, options, ystart, yend, *, verbose=False, figure=False, progcall=None):
|
|
1423
1390
|
"""
|
|
@@ -1461,10 +1428,11 @@ class Plan(object):
|
|
|
1461
1428
|
|
|
1462
1429
|
progcall.finish()
|
|
1463
1430
|
self.mylog.resetVerbose()
|
|
1464
|
-
fig, description = self._showResults(objective, df, N, figure)
|
|
1465
|
-
self.mylog.print(description.getvalue())
|
|
1466
1431
|
|
|
1467
1432
|
if figure:
|
|
1433
|
+
fig, description = plots.show_histogram_results(objective, df, N, self.year_n,
|
|
1434
|
+
self.n_d, self.N_i, self.phi_j)
|
|
1435
|
+
self.mylog.print(description.getvalue())
|
|
1468
1436
|
return fig, description.getvalue()
|
|
1469
1437
|
|
|
1470
1438
|
return N, df
|
|
@@ -1517,98 +1485,15 @@ class Plan(object):
|
|
|
1517
1485
|
|
|
1518
1486
|
progcall.finish()
|
|
1519
1487
|
self.mylog.resetVerbose()
|
|
1520
|
-
fig, description = self._showResults(objective, df, N, figure)
|
|
1521
|
-
self.mylog.print(description.getvalue())
|
|
1522
1488
|
|
|
1523
1489
|
if figure:
|
|
1490
|
+
fig, description = plots.show_histogram_results(objective, df, N, self.year_n,
|
|
1491
|
+
self.n_d, self.N_i, self.phi_j)
|
|
1492
|
+
self.mylog.print(description.getvalue())
|
|
1524
1493
|
return fig, description.getvalue()
|
|
1525
1494
|
|
|
1526
1495
|
return N, df
|
|
1527
1496
|
|
|
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
1497
|
def resolve(self):
|
|
1613
1498
|
"""
|
|
1614
1499
|
Solve a plan using saved options.
|
|
@@ -1619,7 +1504,7 @@ class Plan(object):
|
|
|
1619
1504
|
|
|
1620
1505
|
@_checkConfiguration
|
|
1621
1506
|
@_timer
|
|
1622
|
-
def solve(self, objective, options=
|
|
1507
|
+
def solve(self, objective, options=None):
|
|
1623
1508
|
"""
|
|
1624
1509
|
This function builds the necessary constaints and
|
|
1625
1510
|
runs the optimizer.
|
|
@@ -1661,6 +1546,7 @@ class Plan(object):
|
|
|
1661
1546
|
"oppCostX",
|
|
1662
1547
|
]
|
|
1663
1548
|
# We might modify options if required.
|
|
1549
|
+
options = {} if options is None else options
|
|
1664
1550
|
myoptions = dict(options)
|
|
1665
1551
|
|
|
1666
1552
|
for opt in myoptions:
|
|
@@ -2260,56 +2146,15 @@ class Plan(object):
|
|
|
2260
2146
|
|
|
2261
2147
|
A tag string can be set to add information to the title of the plot.
|
|
2262
2148
|
"""
|
|
2263
|
-
import seaborn as sbn
|
|
2264
|
-
|
|
2265
2149
|
if self.rateMethod in [None, "user", "historical average", "conservative"]:
|
|
2266
2150
|
self.mylog.vprint(f"Warning: Cannot plot correlations for {self.rateMethod} rate method.")
|
|
2267
2151
|
return None
|
|
2268
2152
|
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
"Baa Corp. Bonds",
|
|
2272
|
-
"10-y T-Notes",
|
|
2273
|
-
"Inflation",
|
|
2274
|
-
]
|
|
2275
|
-
|
|
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
|
|
2308
|
-
|
|
2309
|
-
g.fig.suptitle(title, y=1.08)
|
|
2153
|
+
fig = plots.show_rates_correlations(self._name, self.tau_kn, self.N_n, self.rateMethod,
|
|
2154
|
+
self.rateFrm, self.rateTo, tag, shareRange)
|
|
2310
2155
|
|
|
2311
2156
|
if figure:
|
|
2312
|
-
return
|
|
2157
|
+
return fig
|
|
2313
2158
|
|
|
2314
2159
|
plt.show()
|
|
2315
2160
|
return None
|
|
@@ -2320,49 +2165,12 @@ class Plan(object):
|
|
|
2320
2165
|
|
|
2321
2166
|
A tag string can be set to add information to the title of the plot.
|
|
2322
2167
|
"""
|
|
2323
|
-
import matplotlib.ticker as tk
|
|
2324
|
-
|
|
2325
2168
|
if self.rateMethod is None:
|
|
2326
2169
|
self.mylog.vprint("Warning: Rate method must be selected before plotting.")
|
|
2327
2170
|
return None
|
|
2328
2171
|
|
|
2329
|
-
fig
|
|
2330
|
-
|
|
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("%")
|
|
2172
|
+
fig = plots.show_rates(self._name, self.tau_kn, self.year_n, self.yearFracLeft,
|
|
2173
|
+
self.N_k, self.rateMethod, self.rateFrm, self.rateTo, tag)
|
|
2366
2174
|
|
|
2367
2175
|
if figure:
|
|
2368
2176
|
return fig
|
|
@@ -2384,10 +2192,9 @@ class Plan(object):
|
|
|
2384
2192
|
if tag != "":
|
|
2385
2193
|
title += " - " + tag
|
|
2386
2194
|
|
|
2387
|
-
# style = {'net': '-', 'target': ':'}
|
|
2388
2195
|
style = {"profile": "-"}
|
|
2389
2196
|
series = {"profile": self.xi_n}
|
|
2390
|
-
fig, ax =
|
|
2197
|
+
fig, ax = plots.line_income_plot(self.year_n, series, style, title, yformat="$\\xi$")
|
|
2391
2198
|
|
|
2392
2199
|
if figure:
|
|
2393
2200
|
return fig
|
|
@@ -2422,7 +2229,7 @@ class Plan(object):
|
|
|
2422
2229
|
}
|
|
2423
2230
|
yformat = "\\$k (" + str(self.year_n[0]) + "\\$)"
|
|
2424
2231
|
|
|
2425
|
-
fig, ax =
|
|
2232
|
+
fig, ax = plots.line_income_plot(self.year_n, series, style, title, yformat)
|
|
2426
2233
|
|
|
2427
2234
|
if figure:
|
|
2428
2235
|
return fig
|
|
@@ -2471,9 +2278,8 @@ class Plan(object):
|
|
|
2471
2278
|
if tag != "":
|
|
2472
2279
|
title += " - " + tag
|
|
2473
2280
|
|
|
2474
|
-
fig, ax =
|
|
2475
|
-
|
|
2476
|
-
)
|
|
2281
|
+
fig, ax = plots.stack_plot(years_n, self.inames, title, range(self.N_i),
|
|
2282
|
+
y2stack, stackNames, "upper left", yformat)
|
|
2477
2283
|
figures.append(fig)
|
|
2478
2284
|
|
|
2479
2285
|
if figure:
|
|
@@ -2522,7 +2328,8 @@ class Plan(object):
|
|
|
2522
2328
|
if tag != "":
|
|
2523
2329
|
title += " - " + tag
|
|
2524
2330
|
|
|
2525
|
-
fig, ax =
|
|
2331
|
+
fig, ax = plots.stack_plot(self.year_n, self.inames, title, [i],
|
|
2332
|
+
y2stack, stackNames, "upper left", "percent")
|
|
2526
2333
|
figures.append(fig)
|
|
2527
2334
|
|
|
2528
2335
|
if figure:
|
|
@@ -2560,7 +2367,8 @@ class Plan(object):
|
|
|
2560
2367
|
for key in self.savings_in:
|
|
2561
2368
|
savings_in[key] = self.savings_in[key] / self.gamma_n
|
|
2562
2369
|
|
|
2563
|
-
fig, ax =
|
|
2370
|
+
fig, ax = plots.stack_plot(year_n, self.inames, title, range(self.N_i),
|
|
2371
|
+
savings_in, stypes, "upper left", yformat)
|
|
2564
2372
|
|
|
2565
2373
|
if figure:
|
|
2566
2374
|
return fig
|
|
@@ -2582,7 +2390,6 @@ class Plan(object):
|
|
|
2582
2390
|
|
|
2583
2391
|
title = self._name + "\nRaw Income Sources"
|
|
2584
2392
|
stypes = self.sources_in.keys()
|
|
2585
|
-
# stypes = [item for item in stypes if "RothX" not in item]
|
|
2586
2393
|
|
|
2587
2394
|
if tag != "":
|
|
2588
2395
|
title += " - " + tag
|
|
@@ -2596,9 +2403,8 @@ class Plan(object):
|
|
|
2596
2403
|
for key in stypes:
|
|
2597
2404
|
sources_in[key] = self.sources_in[key] / self.gamma_n[:-1]
|
|
2598
2405
|
|
|
2599
|
-
fig, ax =
|
|
2600
|
-
|
|
2601
|
-
)
|
|
2406
|
+
fig, ax = plots.stack_plot(self.year_n, self.inames, title, range(self.N_i),
|
|
2407
|
+
sources_in, stypes, "upper left", yformat)
|
|
2602
2408
|
|
|
2603
2409
|
if figure:
|
|
2604
2410
|
return fig
|
|
@@ -2624,11 +2430,10 @@ class Plan(object):
|
|
|
2624
2430
|
for t in range(self.N_t):
|
|
2625
2431
|
key = "f " + str(t)
|
|
2626
2432
|
series[key] = self.F_tn[t] / self.DeltaBar_tn[t]
|
|
2627
|
-
# print(key, series[key])
|
|
2628
2433
|
style[key] = various[q % len(various)]
|
|
2629
2434
|
q += 1
|
|
2630
2435
|
|
|
2631
|
-
fig, ax =
|
|
2436
|
+
fig, ax = plots.line_income_plot(self.year_n, series, style, title, yformat="")
|
|
2632
2437
|
|
|
2633
2438
|
plt.show()
|
|
2634
2439
|
return None
|
|
@@ -2661,7 +2466,7 @@ class Plan(object):
|
|
|
2661
2466
|
if tag != "":
|
|
2662
2467
|
title += " - " + tag
|
|
2663
2468
|
|
|
2664
|
-
fig, ax =
|
|
2469
|
+
fig, ax = plots.line_income_plot(self.year_n, series, style, title, yformat)
|
|
2665
2470
|
|
|
2666
2471
|
if figure:
|
|
2667
2472
|
return fig
|
|
@@ -2696,7 +2501,7 @@ class Plan(object):
|
|
|
2696
2501
|
if tag != "":
|
|
2697
2502
|
title += " - " + tag
|
|
2698
2503
|
|
|
2699
|
-
fig, ax =
|
|
2504
|
+
fig, ax = plots.line_income_plot(self.year_n, series, style, title, yformat)
|
|
2700
2505
|
|
|
2701
2506
|
data = tx.taxBrackets(self.N_i, self.n_d, self.N_n, self.yTCJA)
|
|
2702
2507
|
for key in data:
|
|
@@ -2826,7 +2631,7 @@ class Plan(object):
|
|
|
2826
2631
|
for i in range(self.N_i):
|
|
2827
2632
|
sname = self.inames[i] + "'s Sources"
|
|
2828
2633
|
ws = wb.create_sheet(sname)
|
|
2829
|
-
fillsheet(ws, srcDic, "currency", op=lambda x: x[i])
|
|
2634
|
+
fillsheet(ws, srcDic, "currency", op=lambda x: x[i]) # noqa: B023
|
|
2830
2635
|
|
|
2831
2636
|
# Account balances except final year.
|
|
2832
2637
|
accDic = {
|
|
@@ -2846,7 +2651,7 @@ class Plan(object):
|
|
|
2846
2651
|
for i in range(self.N_i):
|
|
2847
2652
|
sname = self.inames[i] + "'s Accounts"
|
|
2848
2653
|
ws = wb.create_sheet(sname)
|
|
2849
|
-
fillsheet(ws, accDic, "currency", op=lambda x: x[i])
|
|
2654
|
+
fillsheet(ws, accDic, "currency", op=lambda x: x[i]) # noqa: B023
|
|
2850
2655
|
# Add final balances.
|
|
2851
2656
|
lastRow = [
|
|
2852
2657
|
self.year_n[-1] + 1,
|
|
@@ -2963,75 +2768,11 @@ class Plan(object):
|
|
|
2963
2768
|
if key == "n":
|
|
2964
2769
|
break
|
|
2965
2770
|
except Exception as e:
|
|
2966
|
-
raise Exception(f"Unanticipated exception: {e}.")
|
|
2771
|
+
raise Exception(f"Unanticipated exception: {e}.") from e
|
|
2967
2772
|
|
|
2968
2773
|
return None
|
|
2969
2774
|
|
|
2970
2775
|
|
|
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}.")
|
|
3031
|
-
|
|
3032
|
-
return fig, ax
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
2776
|
def _saveWorkbook(wb, basename, overwrite, mylog):
|
|
3036
2777
|
"""
|
|
3037
2778
|
Utility function to save XL workbook.
|
|
@@ -3051,7 +2792,7 @@ def _saveWorkbook(wb, basename, overwrite, mylog):
|
|
|
3051
2792
|
mylog.vprint("Skipping save and returning.")
|
|
3052
2793
|
return None
|
|
3053
2794
|
|
|
3054
|
-
|
|
2795
|
+
for _ in range(3):
|
|
3055
2796
|
try:
|
|
3056
2797
|
mylog.vprint(f'Saving plan as "{fname}".')
|
|
3057
2798
|
wb.save(fname)
|
|
@@ -3062,7 +2803,7 @@ def _saveWorkbook(wb, basename, overwrite, mylog):
|
|
|
3062
2803
|
if key == "n":
|
|
3063
2804
|
break
|
|
3064
2805
|
except Exception as e:
|
|
3065
|
-
raise Exception(f"Unanticipated exception {e}.")
|
|
2806
|
+
raise Exception(f"Unanticipated exception {e}.") from e
|
|
3066
2807
|
|
|
3067
2808
|
return None
|
|
3068
2809
|
|