owlplanner 2025.5.2__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 +329 -587
- 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.2.dist-info → owlplanner-2025.5.5.dist-info}/METADATA +1 -1
- owlplanner-2025.5.5.dist-info/RECORD +18 -0
- owlplanner-2025.5.2.dist-info/RECORD +0 -17
- {owlplanner-2025.5.2.dist-info → owlplanner-2025.5.5.dist-info}/WHEEL +0 -0
- {owlplanner-2025.5.2.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.
|
|
@@ -377,7 +380,7 @@ class Plan(object):
|
|
|
377
380
|
refdate = date.today()
|
|
378
381
|
self.startDate = refdate.strftime("%Y-%m-%d")
|
|
379
382
|
else:
|
|
380
|
-
mydatelist = mydate.split("-")
|
|
383
|
+
mydatelist = mydate.replace("/", "-").split("-")
|
|
381
384
|
if len(mydatelist) == 2 or len(mydatelist) == 3:
|
|
382
385
|
self.startDate = mydate
|
|
383
386
|
# Ignore the year provided.
|
|
@@ -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:
|
|
@@ -1765,6 +1651,7 @@ class Plan(object):
|
|
|
1765
1651
|
self.mylog.vprint("WARNING: Exiting loop on maximum iterations.")
|
|
1766
1652
|
break
|
|
1767
1653
|
|
|
1654
|
+
it += 1
|
|
1768
1655
|
old_solutions.append(-solution)
|
|
1769
1656
|
old_x = xx
|
|
1770
1657
|
|
|
@@ -2259,56 +2146,15 @@ class Plan(object):
|
|
|
2259
2146
|
|
|
2260
2147
|
A tag string can be set to add information to the title of the plot.
|
|
2261
2148
|
"""
|
|
2262
|
-
import seaborn as sbn
|
|
2263
|
-
|
|
2264
2149
|
if self.rateMethod in [None, "user", "historical average", "conservative"]:
|
|
2265
2150
|
self.mylog.vprint(f"Warning: Cannot plot correlations for {self.rateMethod} rate method.")
|
|
2266
2151
|
return None
|
|
2267
2152
|
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
"Baa Corp. Bonds",
|
|
2271
|
-
"10-y T-Notes",
|
|
2272
|
-
"Inflation",
|
|
2273
|
-
]
|
|
2274
|
-
|
|
2275
|
-
df = pd.DataFrame()
|
|
2276
|
-
for k, name in enumerate(rateNames):
|
|
2277
|
-
data = 100 * self.tau_kn[k]
|
|
2278
|
-
df[name] = data
|
|
2279
|
-
|
|
2280
|
-
g = sbn.PairGrid(df, diag_sharey=False, height=1.8, aspect=1)
|
|
2281
|
-
if shareRange:
|
|
2282
|
-
minval = df.min().min() - 5
|
|
2283
|
-
maxval = df.max().max() + 5
|
|
2284
|
-
g.set(xlim=(minval, maxval), ylim=(minval, maxval))
|
|
2285
|
-
g.map_upper(sbn.scatterplot)
|
|
2286
|
-
g.map_lower(sbn.kdeplot)
|
|
2287
|
-
# g.map_diag(sbn.kdeplot)
|
|
2288
|
-
g.map_diag(sbn.histplot, color="orange")
|
|
2289
|
-
|
|
2290
|
-
# Put zero axes on off-diagonal plots.
|
|
2291
|
-
imod = len(rateNames) + 1
|
|
2292
|
-
for i, ax in enumerate(g.axes.flat):
|
|
2293
|
-
ax.axvline(x=0, color="grey", linewidth=1, linestyle=":")
|
|
2294
|
-
if i % imod != 0:
|
|
2295
|
-
ax.axhline(y=0, color="grey", linewidth=1, linestyle=":")
|
|
2296
|
-
# ax.tick_params(axis='both', labelleft=True, labelbottom=True)
|
|
2297
|
-
|
|
2298
|
-
# plt.subplots_adjust(wspace=0.3, hspace=0.3)
|
|
2299
|
-
|
|
2300
|
-
title = self._name + "\n"
|
|
2301
|
-
title += f"Rates Correlations (N={self.N_n}) {self.rateMethod}"
|
|
2302
|
-
if self.rateMethod in ["historical", "histochastic"]:
|
|
2303
|
-
title += " (" + str(self.rateFrm) + "-" + str(self.rateTo) + ")"
|
|
2304
|
-
|
|
2305
|
-
if tag != "":
|
|
2306
|
-
title += " - " + tag
|
|
2307
|
-
|
|
2308
|
-
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)
|
|
2309
2155
|
|
|
2310
2156
|
if figure:
|
|
2311
|
-
return
|
|
2157
|
+
return fig
|
|
2312
2158
|
|
|
2313
2159
|
plt.show()
|
|
2314
2160
|
return None
|
|
@@ -2319,49 +2165,12 @@ class Plan(object):
|
|
|
2319
2165
|
|
|
2320
2166
|
A tag string can be set to add information to the title of the plot.
|
|
2321
2167
|
"""
|
|
2322
|
-
import matplotlib.ticker as tk
|
|
2323
|
-
|
|
2324
2168
|
if self.rateMethod is None:
|
|
2325
2169
|
self.mylog.vprint("Warning: Rate method must be selected before plotting.")
|
|
2326
2170
|
return None
|
|
2327
2171
|
|
|
2328
|
-
fig
|
|
2329
|
-
|
|
2330
|
-
title = self._name + "\nReturn & Inflation Rates (" + str(self.rateMethod)
|
|
2331
|
-
if self.rateMethod in ["historical", "histochastic", "historical average"]:
|
|
2332
|
-
title += " " + str(self.rateFrm) + "-" + str(self.rateTo)
|
|
2333
|
-
title += ")"
|
|
2334
|
-
|
|
2335
|
-
if tag != "":
|
|
2336
|
-
title += " - " + tag
|
|
2337
|
-
|
|
2338
|
-
rateName = [
|
|
2339
|
-
"S&P500 (incl. div.)",
|
|
2340
|
-
"Baa Corp. Bonds",
|
|
2341
|
-
"10-y T-Notes",
|
|
2342
|
-
"Inflation",
|
|
2343
|
-
]
|
|
2344
|
-
ltype = ["-", "-.", ":", "--"]
|
|
2345
|
-
for k in range(self.N_k):
|
|
2346
|
-
if self.yearFracLeft == 1:
|
|
2347
|
-
data = 100 * self.tau_kn[k]
|
|
2348
|
-
years = self.year_n
|
|
2349
|
-
else:
|
|
2350
|
-
data = 100 * self.tau_kn[k, 1:]
|
|
2351
|
-
years = self.year_n[1:]
|
|
2352
|
-
|
|
2353
|
-
# Use ddof=1 to match pandas.
|
|
2354
|
-
label = (
|
|
2355
|
-
rateName[k] + " <" + "{:.1f}".format(np.mean(data)) + " +/- {:.1f}".format(np.std(data, ddof=1)) + "%>"
|
|
2356
|
-
)
|
|
2357
|
-
ax.plot(years, data, label=label, ls=ltype[k % self.N_k])
|
|
2358
|
-
|
|
2359
|
-
ax.xaxis.set_major_locator(tk.MaxNLocator(integer=True))
|
|
2360
|
-
ax.legend(loc="best", reverse=False, fontsize=8, framealpha=0.7)
|
|
2361
|
-
# ax.legend(loc='upper left')
|
|
2362
|
-
ax.set_title(title)
|
|
2363
|
-
ax.set_xlabel("year")
|
|
2364
|
-
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)
|
|
2365
2174
|
|
|
2366
2175
|
if figure:
|
|
2367
2176
|
return fig
|
|
@@ -2383,10 +2192,9 @@ class Plan(object):
|
|
|
2383
2192
|
if tag != "":
|
|
2384
2193
|
title += " - " + tag
|
|
2385
2194
|
|
|
2386
|
-
# style = {'net': '-', 'target': ':'}
|
|
2387
2195
|
style = {"profile": "-"}
|
|
2388
2196
|
series = {"profile": self.xi_n}
|
|
2389
|
-
fig, ax =
|
|
2197
|
+
fig, ax = plots.line_income_plot(self.year_n, series, style, title, yformat="$\\xi$")
|
|
2390
2198
|
|
|
2391
2199
|
if figure:
|
|
2392
2200
|
return fig
|
|
@@ -2421,7 +2229,7 @@ class Plan(object):
|
|
|
2421
2229
|
}
|
|
2422
2230
|
yformat = "\\$k (" + str(self.year_n[0]) + "\\$)"
|
|
2423
2231
|
|
|
2424
|
-
fig, ax =
|
|
2232
|
+
fig, ax = plots.line_income_plot(self.year_n, series, style, title, yformat)
|
|
2425
2233
|
|
|
2426
2234
|
if figure:
|
|
2427
2235
|
return fig
|
|
@@ -2470,9 +2278,8 @@ class Plan(object):
|
|
|
2470
2278
|
if tag != "":
|
|
2471
2279
|
title += " - " + tag
|
|
2472
2280
|
|
|
2473
|
-
fig, ax =
|
|
2474
|
-
|
|
2475
|
-
)
|
|
2281
|
+
fig, ax = plots.stack_plot(years_n, self.inames, title, range(self.N_i),
|
|
2282
|
+
y2stack, stackNames, "upper left", yformat)
|
|
2476
2283
|
figures.append(fig)
|
|
2477
2284
|
|
|
2478
2285
|
if figure:
|
|
@@ -2521,7 +2328,8 @@ class Plan(object):
|
|
|
2521
2328
|
if tag != "":
|
|
2522
2329
|
title += " - " + tag
|
|
2523
2330
|
|
|
2524
|
-
fig, ax =
|
|
2331
|
+
fig, ax = plots.stack_plot(self.year_n, self.inames, title, [i],
|
|
2332
|
+
y2stack, stackNames, "upper left", "percent")
|
|
2525
2333
|
figures.append(fig)
|
|
2526
2334
|
|
|
2527
2335
|
if figure:
|
|
@@ -2559,7 +2367,8 @@ class Plan(object):
|
|
|
2559
2367
|
for key in self.savings_in:
|
|
2560
2368
|
savings_in[key] = self.savings_in[key] / self.gamma_n
|
|
2561
2369
|
|
|
2562
|
-
fig, ax =
|
|
2370
|
+
fig, ax = plots.stack_plot(year_n, self.inames, title, range(self.N_i),
|
|
2371
|
+
savings_in, stypes, "upper left", yformat)
|
|
2563
2372
|
|
|
2564
2373
|
if figure:
|
|
2565
2374
|
return fig
|
|
@@ -2581,7 +2390,6 @@ class Plan(object):
|
|
|
2581
2390
|
|
|
2582
2391
|
title = self._name + "\nRaw Income Sources"
|
|
2583
2392
|
stypes = self.sources_in.keys()
|
|
2584
|
-
# stypes = [item for item in stypes if "RothX" not in item]
|
|
2585
2393
|
|
|
2586
2394
|
if tag != "":
|
|
2587
2395
|
title += " - " + tag
|
|
@@ -2595,9 +2403,8 @@ class Plan(object):
|
|
|
2595
2403
|
for key in stypes:
|
|
2596
2404
|
sources_in[key] = self.sources_in[key] / self.gamma_n[:-1]
|
|
2597
2405
|
|
|
2598
|
-
fig, ax =
|
|
2599
|
-
|
|
2600
|
-
)
|
|
2406
|
+
fig, ax = plots.stack_plot(self.year_n, self.inames, title, range(self.N_i),
|
|
2407
|
+
sources_in, stypes, "upper left", yformat)
|
|
2601
2408
|
|
|
2602
2409
|
if figure:
|
|
2603
2410
|
return fig
|
|
@@ -2623,11 +2430,10 @@ class Plan(object):
|
|
|
2623
2430
|
for t in range(self.N_t):
|
|
2624
2431
|
key = "f " + str(t)
|
|
2625
2432
|
series[key] = self.F_tn[t] / self.DeltaBar_tn[t]
|
|
2626
|
-
# print(key, series[key])
|
|
2627
2433
|
style[key] = various[q % len(various)]
|
|
2628
2434
|
q += 1
|
|
2629
2435
|
|
|
2630
|
-
fig, ax =
|
|
2436
|
+
fig, ax = plots.line_income_plot(self.year_n, series, style, title, yformat="")
|
|
2631
2437
|
|
|
2632
2438
|
plt.show()
|
|
2633
2439
|
return None
|
|
@@ -2660,7 +2466,7 @@ class Plan(object):
|
|
|
2660
2466
|
if tag != "":
|
|
2661
2467
|
title += " - " + tag
|
|
2662
2468
|
|
|
2663
|
-
fig, ax =
|
|
2469
|
+
fig, ax = plots.line_income_plot(self.year_n, series, style, title, yformat)
|
|
2664
2470
|
|
|
2665
2471
|
if figure:
|
|
2666
2472
|
return fig
|
|
@@ -2695,7 +2501,7 @@ class Plan(object):
|
|
|
2695
2501
|
if tag != "":
|
|
2696
2502
|
title += " - " + tag
|
|
2697
2503
|
|
|
2698
|
-
fig, ax =
|
|
2504
|
+
fig, ax = plots.line_income_plot(self.year_n, series, style, title, yformat)
|
|
2699
2505
|
|
|
2700
2506
|
data = tx.taxBrackets(self.N_i, self.n_d, self.N_n, self.yTCJA)
|
|
2701
2507
|
for key in data:
|
|
@@ -2825,7 +2631,7 @@ class Plan(object):
|
|
|
2825
2631
|
for i in range(self.N_i):
|
|
2826
2632
|
sname = self.inames[i] + "'s Sources"
|
|
2827
2633
|
ws = wb.create_sheet(sname)
|
|
2828
|
-
fillsheet(ws, srcDic, "currency", op=lambda x: x[i])
|
|
2634
|
+
fillsheet(ws, srcDic, "currency", op=lambda x: x[i]) # noqa: B023
|
|
2829
2635
|
|
|
2830
2636
|
# Account balances except final year.
|
|
2831
2637
|
accDic = {
|
|
@@ -2845,7 +2651,7 @@ class Plan(object):
|
|
|
2845
2651
|
for i in range(self.N_i):
|
|
2846
2652
|
sname = self.inames[i] + "'s Accounts"
|
|
2847
2653
|
ws = wb.create_sheet(sname)
|
|
2848
|
-
fillsheet(ws, accDic, "currency", op=lambda x: x[i])
|
|
2654
|
+
fillsheet(ws, accDic, "currency", op=lambda x: x[i]) # noqa: B023
|
|
2849
2655
|
# Add final balances.
|
|
2850
2656
|
lastRow = [
|
|
2851
2657
|
self.year_n[-1] + 1,
|
|
@@ -2962,75 +2768,11 @@ class Plan(object):
|
|
|
2962
2768
|
if key == "n":
|
|
2963
2769
|
break
|
|
2964
2770
|
except Exception as e:
|
|
2965
|
-
raise Exception(f"Unanticipated exception: {e}.")
|
|
2771
|
+
raise Exception(f"Unanticipated exception: {e}.") from e
|
|
2966
2772
|
|
|
2967
2773
|
return None
|
|
2968
2774
|
|
|
2969
2775
|
|
|
2970
|
-
def _lineIncomePlot(x, series, style, title, yformat="\\$k"):
|
|
2971
|
-
"""
|
|
2972
|
-
Core line plotter function.
|
|
2973
|
-
"""
|
|
2974
|
-
import matplotlib.ticker as tk
|
|
2975
|
-
|
|
2976
|
-
fig, ax = plt.subplots(figsize=(6, 4))
|
|
2977
|
-
plt.grid(visible="both")
|
|
2978
|
-
|
|
2979
|
-
for sname in series:
|
|
2980
|
-
ax.plot(x, series[sname], label=sname, ls=style[sname])
|
|
2981
|
-
|
|
2982
|
-
ax.legend(loc="upper left", reverse=True, fontsize=8, framealpha=0.3)
|
|
2983
|
-
ax.set_title(title)
|
|
2984
|
-
ax.set_xlabel("year")
|
|
2985
|
-
ax.set_ylabel(yformat)
|
|
2986
|
-
ax.xaxis.set_major_locator(tk.MaxNLocator(integer=True))
|
|
2987
|
-
if "k" in yformat:
|
|
2988
|
-
ax.get_yaxis().set_major_formatter(tk.FuncFormatter(lambda x, p: format(int(x / 1000), ",")))
|
|
2989
|
-
# Give range to y values in unindexed flat profiles.
|
|
2990
|
-
ymin, ymax = ax.get_ylim()
|
|
2991
|
-
if ymax - ymin < 5000:
|
|
2992
|
-
ax.set_ylim((ymin * 0.95, ymax * 1.05))
|
|
2993
|
-
|
|
2994
|
-
return fig, ax
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
def _stackPlot(x, inames, title, irange, series, snames, location, yformat="\\$k"):
|
|
2998
|
-
"""
|
|
2999
|
-
Core function for stacked plots.
|
|
3000
|
-
"""
|
|
3001
|
-
import matplotlib.ticker as tk
|
|
3002
|
-
|
|
3003
|
-
nonzeroSeries = {}
|
|
3004
|
-
for sname in snames:
|
|
3005
|
-
for i in irange:
|
|
3006
|
-
tmp = series[sname][i]
|
|
3007
|
-
if sum(tmp) > 1.0:
|
|
3008
|
-
nonzeroSeries[sname + " " + inames[i]] = tmp
|
|
3009
|
-
|
|
3010
|
-
if len(nonzeroSeries) == 0:
|
|
3011
|
-
# print('Nothing to plot for', title)
|
|
3012
|
-
return None, None
|
|
3013
|
-
|
|
3014
|
-
fig, ax = plt.subplots(figsize=(6, 4))
|
|
3015
|
-
plt.grid(visible="both")
|
|
3016
|
-
|
|
3017
|
-
ax.stackplot(x, nonzeroSeries.values(), labels=nonzeroSeries.keys(), alpha=0.6)
|
|
3018
|
-
ax.legend(loc=location, reverse=True, fontsize=8, ncol=2, framealpha=0.5)
|
|
3019
|
-
ax.set_title(title)
|
|
3020
|
-
ax.set_xlabel("year")
|
|
3021
|
-
ax.xaxis.set_major_locator(tk.MaxNLocator(integer=True))
|
|
3022
|
-
if "k" in yformat:
|
|
3023
|
-
ax.set_ylabel(yformat)
|
|
3024
|
-
ax.get_yaxis().set_major_formatter(tk.FuncFormatter(lambda x, p: format(int(x / 1000), ",")))
|
|
3025
|
-
elif yformat == "percent":
|
|
3026
|
-
ax.set_ylabel("%")
|
|
3027
|
-
ax.get_yaxis().set_major_formatter(tk.FuncFormatter(lambda x, p: format(int(100 * x), ",")))
|
|
3028
|
-
else:
|
|
3029
|
-
raise RuntimeError(f"Unknown yformat: {yformat}.")
|
|
3030
|
-
|
|
3031
|
-
return fig, ax
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
2776
|
def _saveWorkbook(wb, basename, overwrite, mylog):
|
|
3035
2777
|
"""
|
|
3036
2778
|
Utility function to save XL workbook.
|
|
@@ -3050,7 +2792,7 @@ def _saveWorkbook(wb, basename, overwrite, mylog):
|
|
|
3050
2792
|
mylog.vprint("Skipping save and returning.")
|
|
3051
2793
|
return None
|
|
3052
2794
|
|
|
3053
|
-
|
|
2795
|
+
for _ in range(3):
|
|
3054
2796
|
try:
|
|
3055
2797
|
mylog.vprint(f'Saving plan as "{fname}".')
|
|
3056
2798
|
wb.save(fname)
|
|
@@ -3061,7 +2803,7 @@ def _saveWorkbook(wb, basename, overwrite, mylog):
|
|
|
3061
2803
|
if key == "n":
|
|
3062
2804
|
break
|
|
3063
2805
|
except Exception as e:
|
|
3064
|
-
raise Exception(f"Unanticipated exception {e}.")
|
|
2806
|
+
raise Exception(f"Unanticipated exception {e}.") from e
|
|
3065
2807
|
|
|
3066
2808
|
return None
|
|
3067
2809
|
|