owlplanner 2025.11.9__py3-none-any.whl → 2025.12.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/config.py +11 -8
- owlplanner/plan.py +84 -39
- owlplanner/socialsecurity.py +89 -0
- owlplanner/version.py +1 -1
- {owlplanner-2025.11.9.dist-info → owlplanner-2025.12.5.dist-info}/METADATA +12 -9
- {owlplanner-2025.11.9.dist-info → owlplanner-2025.12.5.dist-info}/RECORD +8 -7
- {owlplanner-2025.11.9.dist-info → owlplanner-2025.12.5.dist-info}/WHEEL +1 -1
- {owlplanner-2025.11.9.dist-info → owlplanner-2025.12.5.dist-info}/licenses/LICENSE +0 -0
owlplanner/config.py
CHANGED
|
@@ -37,6 +37,7 @@ def saveConfig(myplan, file, mylog):
|
|
|
37
37
|
"Status": ["unknown", "single", "married"][myplan.N_i],
|
|
38
38
|
"Names": myplan.inames,
|
|
39
39
|
"Birth year": myplan.yobs.tolist(),
|
|
40
|
+
"Birth month": myplan.mobs.tolist(),
|
|
40
41
|
"Life expectancy": myplan.expectancy.tolist(),
|
|
41
42
|
"Start date": myplan.startDate,
|
|
42
43
|
}
|
|
@@ -55,10 +56,10 @@ def saveConfig(myplan, file, mylog):
|
|
|
55
56
|
|
|
56
57
|
# Fixed Income.
|
|
57
58
|
diconf["Fixed Income"] = {
|
|
58
|
-
"Pension amounts": (myplan.pensionAmounts
|
|
59
|
+
"Pension monthly amounts": (myplan.pensionAmounts).tolist(),
|
|
59
60
|
"Pension ages": myplan.pensionAges.tolist(),
|
|
60
61
|
"Pension indexed": myplan.pensionIsIndexed,
|
|
61
|
-
"Social security amounts": (myplan.ssecAmounts
|
|
62
|
+
"Social security PIA amounts": (myplan.ssecAmounts).tolist(),
|
|
62
63
|
"Social security ages": myplan.ssecAges.tolist(),
|
|
63
64
|
}
|
|
64
65
|
|
|
@@ -181,11 +182,13 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
|
|
|
181
182
|
inames = diconf["Basic Info"]["Names"]
|
|
182
183
|
# status = diconf['Basic Info']['Status']
|
|
183
184
|
yobs = diconf["Basic Info"]["Birth year"]
|
|
184
|
-
expectancy = diconf["Basic Info"]["Life expectancy"]
|
|
185
185
|
icount = len(yobs)
|
|
186
|
+
# Default to January if no month entry found.
|
|
187
|
+
mobs = diconf["Basic Info"].get("Birth month", [1]*icount)
|
|
188
|
+
expectancy = diconf["Basic Info"]["Life expectancy"]
|
|
186
189
|
s = ["", "s"][icount - 1]
|
|
187
190
|
mylog.vprint(f"Plan for {icount} individual{s}: {inames}.")
|
|
188
|
-
p = plan.Plan(inames, yobs, expectancy, name, verbose=True, logstreams=logstreams)
|
|
191
|
+
p = plan.Plan(inames, yobs, mobs, expectancy, name, verbose=True, logstreams=logstreams)
|
|
189
192
|
p._description = diconf.get("Description", "")
|
|
190
193
|
|
|
191
194
|
# Assets.
|
|
@@ -217,11 +220,11 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
|
|
|
217
220
|
mylog.vprint(f"Ignoring to read contributions file {timeListsFileName}.")
|
|
218
221
|
|
|
219
222
|
# Fixed Income.
|
|
220
|
-
ssecAmounts = np.array(diconf["Fixed Income"]
|
|
221
|
-
ssecAges = np.array(diconf["Fixed Income"]["Social security ages"]
|
|
223
|
+
ssecAmounts = np.array(diconf["Fixed Income"].get("Social security PIA amounts", [0]*icount), dtype=np.int32)
|
|
224
|
+
ssecAges = np.array(diconf["Fixed Income"]["Social security ages"])
|
|
222
225
|
p.setSocialSecurity(ssecAmounts, ssecAges)
|
|
223
|
-
pensionAmounts = np.array(diconf["Fixed Income"]
|
|
224
|
-
pensionAges = np.array(diconf["Fixed Income"]["Pension ages"]
|
|
226
|
+
pensionAmounts = np.array(diconf["Fixed Income"].get("Pension monthly amounts", [0]*icount), dtype=np.float32)
|
|
227
|
+
pensionAges = np.array(diconf["Fixed Income"]["Pension ages"])
|
|
225
228
|
pensionIsIndexed = diconf["Fixed Income"]["Pension indexed"]
|
|
226
229
|
p.setPension(pensionAmounts, pensionAges, pensionIsIndexed)
|
|
227
230
|
|
owlplanner/plan.py
CHANGED
|
@@ -29,6 +29,7 @@ from . import abcapi as abc
|
|
|
29
29
|
from . import rates
|
|
30
30
|
from . import config
|
|
31
31
|
from . import timelists
|
|
32
|
+
from . import socialsecurity as socsec
|
|
32
33
|
from . import mylogging as log
|
|
33
34
|
from . import progress
|
|
34
35
|
from .plotting.factory import PlotFactory
|
|
@@ -212,7 +213,7 @@ class Plan(object):
|
|
|
212
213
|
This is the main class of the Owl Project.
|
|
213
214
|
"""
|
|
214
215
|
|
|
215
|
-
def __init__(self, inames, yobs, expectancy, name, *, verbose=False, logstreams=None):
|
|
216
|
+
def __init__(self, inames, yobs, mobs, expectancy, name, *, verbose=False, logstreams=None):
|
|
216
217
|
"""
|
|
217
218
|
Constructor requires three lists: the first
|
|
218
219
|
one contains the name(s) of the individual(s),
|
|
@@ -251,6 +252,10 @@ class Plan(object):
|
|
|
251
252
|
self.N_i = len(yobs)
|
|
252
253
|
if not (0 <= self.N_i <= 2):
|
|
253
254
|
raise ValueError(f"Cannot support {self.N_i} individuals.")
|
|
255
|
+
if len(mobs) != len(yobs):
|
|
256
|
+
raise ValueError("Months and years arrays should have same length.")
|
|
257
|
+
if min(mobs) < 1 or max(mobs) > 12:
|
|
258
|
+
raise ValueError("Months must be between 1 and 12.")
|
|
254
259
|
if self.N_i != len(expectancy):
|
|
255
260
|
raise ValueError(f"Expectancy must have {self.N_i} entries.")
|
|
256
261
|
if self.N_i != len(inames):
|
|
@@ -264,6 +269,7 @@ class Plan(object):
|
|
|
264
269
|
self.yOBBBA = 2032
|
|
265
270
|
self.inames = inames
|
|
266
271
|
self.yobs = np.array(yobs, dtype=np.int32)
|
|
272
|
+
self.mobs = np.array(mobs, dtype=np.int32)
|
|
267
273
|
self.expectancy = np.array(expectancy, dtype=np.int32)
|
|
268
274
|
|
|
269
275
|
# Reference time is starting date in the current year and all passings are assumed at the end.
|
|
@@ -297,10 +303,10 @@ class Plan(object):
|
|
|
297
303
|
# Default to zero pension and social security.
|
|
298
304
|
self.pi_in = np.zeros((self.N_i, self.N_n))
|
|
299
305
|
self.zeta_in = np.zeros((self.N_i, self.N_n))
|
|
300
|
-
self.pensionAmounts = np.zeros(self.N_i)
|
|
306
|
+
self.pensionAmounts = np.zeros(self.N_i, dtype=np.int32)
|
|
301
307
|
self.pensionAges = 65 * np.ones(self.N_i, dtype=np.int32)
|
|
302
308
|
self.pensionIsIndexed = [False] * self.N_i
|
|
303
|
-
self.ssecAmounts = np.zeros(self.N_i)
|
|
309
|
+
self.ssecAmounts = np.zeros(self.N_i, dtype=np.int32)
|
|
304
310
|
self.ssecAges = 67 * np.ones(self.N_i, dtype=np.int32)
|
|
305
311
|
|
|
306
312
|
# Parameters from timeLists initialized to zero.
|
|
@@ -519,10 +525,10 @@ class Plan(object):
|
|
|
519
525
|
self.nu = nu
|
|
520
526
|
self.caseStatus = "modified"
|
|
521
527
|
|
|
522
|
-
def setPension(self, amounts, ages, indexed=None
|
|
528
|
+
def setPension(self, amounts, ages, indexed=None):
|
|
523
529
|
"""
|
|
524
530
|
Set value of pension for each individual and commencement age.
|
|
525
|
-
Units are in
|
|
531
|
+
Units are in $.
|
|
526
532
|
"""
|
|
527
533
|
if len(amounts) != self.N_i:
|
|
528
534
|
raise ValueError(f"Amounts must have {self.N_i} entries.")
|
|
@@ -531,10 +537,7 @@ class Plan(object):
|
|
|
531
537
|
if indexed is None:
|
|
532
538
|
indexed = [False] * self.N_i
|
|
533
539
|
|
|
534
|
-
|
|
535
|
-
amounts = u.rescale(amounts, fac)
|
|
536
|
-
|
|
537
|
-
self.mylog.vprint("Setting pension of", [u.d(amounts[i]) for i in range(self.N_i)],
|
|
540
|
+
self.mylog.vprint("Setting monthly pension of", [u.d(amounts[i]) for i in range(self.N_i)],
|
|
538
541
|
"at age(s)", [int(ages[i]) for i in range(self.N_i)])
|
|
539
542
|
|
|
540
543
|
thisyear = date.today().year
|
|
@@ -542,47 +545,88 @@ class Plan(object):
|
|
|
542
545
|
self.pi_in = np.zeros((self.N_i, self.N_n))
|
|
543
546
|
for i in range(self.N_i):
|
|
544
547
|
if amounts[i] != 0:
|
|
545
|
-
|
|
548
|
+
# Check if claim age added to birth month falls next year.
|
|
549
|
+
realage = ages[i] + (self.mobs[i] - 1)/12
|
|
550
|
+
iage = int(realage)
|
|
551
|
+
fraction = 1 - (realage % 1.)
|
|
552
|
+
realns = iage - thisyear + self.yobs[i]
|
|
553
|
+
ns = max(0, realns)
|
|
546
554
|
nd = self.horizons[i]
|
|
547
555
|
self.pi_in[i, ns:nd] = amounts[i]
|
|
556
|
+
# Reduce starting year due to birth month. If realns < 0, this has happened already.
|
|
557
|
+
if realns >= 0:
|
|
558
|
+
self.pi_in[i, ns] *= fraction
|
|
559
|
+
|
|
560
|
+
# Convert all to annual numbers.
|
|
561
|
+
self.pi_in *= 12
|
|
548
562
|
|
|
549
|
-
self.pensionAmounts = np.array(amounts)
|
|
550
|
-
self.pensionAges = np.array(ages
|
|
563
|
+
self.pensionAmounts = np.array(amounts, dtype=np.int32)
|
|
564
|
+
self.pensionAges = np.array(ages)
|
|
551
565
|
self.pensionIsIndexed = indexed
|
|
552
566
|
self.caseStatus = "modified"
|
|
553
567
|
self._adjustedParameters = False
|
|
554
568
|
|
|
555
|
-
def setSocialSecurity(self,
|
|
569
|
+
def setSocialSecurity(self, pias, ages):
|
|
556
570
|
"""
|
|
557
|
-
Set value of social security for each individual and
|
|
558
|
-
Units are in $k, unless specified otherwise: 'k', 'M', or '1'.
|
|
571
|
+
Set value of social security for each individual and claiming age.
|
|
559
572
|
"""
|
|
560
|
-
if len(
|
|
561
|
-
raise ValueError(f"
|
|
573
|
+
if len(pias) != self.N_i:
|
|
574
|
+
raise ValueError(f"Principal Insurance Amount must have {self.N_i} entries.")
|
|
562
575
|
if len(ages) != self.N_i:
|
|
563
576
|
raise ValueError(f"Ages must have {self.N_i} entries.")
|
|
564
577
|
|
|
565
|
-
|
|
566
|
-
|
|
578
|
+
# Just make sure we are dealing with arrays if lists were passed.
|
|
579
|
+
pias = np.array(pias, dtype=np.int32)
|
|
580
|
+
ages = np.array(ages)
|
|
567
581
|
|
|
582
|
+
fras = socsec.getFRAs(self.yobs)
|
|
583
|
+
spousalBenefits = socsec.getSpousalBenefits(pias)
|
|
584
|
+
|
|
585
|
+
self.mylog.vprint(
|
|
586
|
+
"Social security monthly benefits set to", [u.d(pias[i]) for i in range(self.N_i)],
|
|
587
|
+
"at FRAs(s)", [fras[i] for i in range(self.N_i)],
|
|
588
|
+
)
|
|
568
589
|
self.mylog.vprint(
|
|
569
|
-
"
|
|
570
|
-
"at age(s)", [int(ages[i]) for i in range(self.N_i)],
|
|
590
|
+
"Benefits requested to start at age(s)", [ages[i] for i in range(self.N_i)],
|
|
571
591
|
)
|
|
572
592
|
|
|
573
593
|
thisyear = date.today().year
|
|
574
594
|
self.zeta_in = np.zeros((self.N_i, self.N_n))
|
|
575
595
|
for i in range(self.N_i):
|
|
576
|
-
|
|
596
|
+
# Check if claim age added to birth month falls next year.
|
|
597
|
+
realage = ages[i] + (self.mobs[i] - 1)/12
|
|
598
|
+
iage = int(realage)
|
|
599
|
+
realns = iage - thisyear + self.yobs[i]
|
|
600
|
+
ns = max(0, realns)
|
|
577
601
|
nd = self.horizons[i]
|
|
578
|
-
self.zeta_in[i, ns:nd] =
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
602
|
+
self.zeta_in[i, ns:nd] = pias[i]
|
|
603
|
+
# Reduce starting year due to month offset. If realns < 0, this has happened already.
|
|
604
|
+
if realns >= 0:
|
|
605
|
+
self.zeta_in[i, ns] *= 1 - (realage % 1.)
|
|
606
|
+
# Increase/decrease PIA due to claiming age.
|
|
607
|
+
self.zeta_in[i, :] *= socsec.getSelfFactor(fras[i], ages[i])
|
|
608
|
+
|
|
609
|
+
# Add spousal benefits if applicable.
|
|
610
|
+
if self.N_i == 2 and spousalBenefits[i] > 0:
|
|
611
|
+
# The latest of the two spouses to claim.
|
|
612
|
+
claimYear = max(self.yobs + (self.mobs - 1)/12 + ages)
|
|
613
|
+
claimAge = claimYear - self.yobs[i] - (self.mobs[i] - 1)/12
|
|
614
|
+
ns2 = max(0, int(claimYear) - thisyear)
|
|
615
|
+
spousalFactor = socsec.getSpousalFactor(fras[i], claimAge)
|
|
616
|
+
self.zeta_in[i, ns2:nd] += spousalBenefits[i] * spousalFactor
|
|
617
|
+
# Reduce first year of benefit by month offset.
|
|
618
|
+
self.zeta_in[i, ns2] -= spousalBenefits[i] * spousalFactor * (claimYear % 1.)
|
|
619
|
+
|
|
620
|
+
# Switch survivor to spousal survivor benefits.
|
|
621
|
+
# Assumes both deceased and survivor already have claimed last year before passing (at n_d - 1).
|
|
622
|
+
if self.N_i == 2 and self.zeta_in[self.i_d, self.n_d - 1] > self.zeta_in[self.i_s, self.n_d - 1]:
|
|
623
|
+
self.zeta_in[self.i_s, self.n_d : self.horizons[self.i_s]] = self.zeta_in[self.i_d, self.n_d - 1]
|
|
624
|
+
|
|
625
|
+
# Convert all to annual numbers.
|
|
626
|
+
self.zeta_in *= 12
|
|
627
|
+
|
|
628
|
+
self.ssecAmounts = pias
|
|
629
|
+
self.ssecAges = ages
|
|
586
630
|
self.caseStatus = "modified"
|
|
587
631
|
self._adjustedParameters = False
|
|
588
632
|
|
|
@@ -1116,25 +1160,26 @@ class Plan(object):
|
|
|
1116
1160
|
h = self.horizons[i]
|
|
1117
1161
|
for n in range(h):
|
|
1118
1162
|
rhs = 0
|
|
1119
|
-
# To add compounded gains to
|
|
1163
|
+
# To add compounded gains to cumulative amounts. Always keep cgains >= 1.
|
|
1120
1164
|
cgains = 1
|
|
1121
1165
|
row = self.A.newRow()
|
|
1122
1166
|
row.addElem(_q3(self.C["b"], i, 2, n, self.N_i, self.N_j, self.N_n + 1), 1)
|
|
1123
1167
|
row.addElem(_q3(self.C["w"], i, 2, n, self.N_i, self.N_j, self.N_n), -1)
|
|
1124
1168
|
for dn in range(1, 6):
|
|
1125
1169
|
nn = n - dn
|
|
1126
|
-
if nn >= 0:
|
|
1170
|
+
if nn >= 0: # Past of future is now or in the future: use variables or parameters.
|
|
1127
1171
|
Tau1 = 1 + np.sum(self.alpha_ijkn[i, 2, :, nn] * self.tau_kn[:, nn], axis=0)
|
|
1128
|
-
|
|
1172
|
+
# Ignore market downs.
|
|
1173
|
+
cgains *= max(1, Tau1)
|
|
1129
1174
|
row.addElem(_q2(self.C["x"], i, nn, self.N_i, self.N_n), -cgains)
|
|
1130
|
-
# If a contribution
|
|
1175
|
+
# If a contribution, it has only penalty on gains, not on deposited amount.
|
|
1131
1176
|
rhs += (cgains - 1) * self.kappa_ijn[i, 2, nn]
|
|
1132
1177
|
else: # Past of future is in the past:
|
|
1133
|
-
# Parameters are stored at the end of contributions and conversions arrays.
|
|
1134
1178
|
cgains *= oldTau1
|
|
1135
|
-
#
|
|
1136
|
-
#
|
|
1137
|
-
rhs += cgains * self.kappa_ijn[i, 2, nn] + cgains * self.myRothX_in[i, nn]
|
|
1179
|
+
# Past years are stored at the end of contributions and conversions arrays.
|
|
1180
|
+
# Use negative index to access tail of array.
|
|
1181
|
+
rhs += (cgains - 1) * self.kappa_ijn[i, 2, nn] + cgains * self.myRothX_in[i, nn]
|
|
1182
|
+
# rhs += cgains * self.kappa_ijn[i, 2, nn] + cgains * self.myRothX_in[i, nn]
|
|
1138
1183
|
|
|
1139
1184
|
self.A.addRow(row, rhs, np.inf)
|
|
1140
1185
|
|
|
@@ -1675,7 +1720,7 @@ class Plan(object):
|
|
|
1675
1720
|
self.prevMAGI = self.optionsUnits * np.array(magi)
|
|
1676
1721
|
|
|
1677
1722
|
lambdha = myoptions.get("spendingSlack", 0)
|
|
1678
|
-
if
|
|
1723
|
+
if not (0 <= lambdha <= 50):
|
|
1679
1724
|
raise ValueError(f"Slack value out of range {lambdha}.")
|
|
1680
1725
|
self.lambdha = lambdha / 100
|
|
1681
1726
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
|
|
3
|
+
Owl/socialsecurity
|
|
4
|
+
--------
|
|
5
|
+
|
|
6
|
+
A retirement planner using linear programming optimization.
|
|
7
|
+
|
|
8
|
+
This file contains the rules related to social security.
|
|
9
|
+
|
|
10
|
+
Copyright © 2025 - Martin-D. Lacasse
|
|
11
|
+
|
|
12
|
+
Disclaimers: This code is for educational purposes only and does not constitute financial advice.
|
|
13
|
+
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def getFRAs(yobs):
|
|
20
|
+
"""
|
|
21
|
+
Return full retirement age based on birth year.
|
|
22
|
+
Returns an array of fractional age.
|
|
23
|
+
"""
|
|
24
|
+
fras = np.zeros(len(yobs))
|
|
25
|
+
|
|
26
|
+
for i in range(len(yobs)):
|
|
27
|
+
if yobs[i] >= 1960:
|
|
28
|
+
fras[i] = 67
|
|
29
|
+
else:
|
|
30
|
+
mo = max(0, 2*(yobs[i] - 1954))
|
|
31
|
+
fras[i] = 66 + mo/12
|
|
32
|
+
|
|
33
|
+
return fras
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def getSpousalBenefits(pias):
|
|
37
|
+
"""
|
|
38
|
+
Compute spousal benefit. Returns an array.
|
|
39
|
+
"""
|
|
40
|
+
icount = len(pias)
|
|
41
|
+
benefits = np.zeros(icount)
|
|
42
|
+
if icount == 1:
|
|
43
|
+
return benefits
|
|
44
|
+
elif icount == 2:
|
|
45
|
+
for i in range(2):
|
|
46
|
+
j = (i+1) % 2
|
|
47
|
+
benefits[i] = max(0, 0.5*pias[j] - pias[i])
|
|
48
|
+
else:
|
|
49
|
+
raise ValueError(f"PIAs array cannot have {icount} entries.")
|
|
50
|
+
|
|
51
|
+
return benefits
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def getSelfFactor(fra, age):
|
|
55
|
+
"""
|
|
56
|
+
Return factor to multiply PIA given the age when SS starts.
|
|
57
|
+
Year of FRA and age can be fractional.
|
|
58
|
+
"""
|
|
59
|
+
if age < 62 or age > 70:
|
|
60
|
+
raise ValueError(f"Age {age} out of range.")
|
|
61
|
+
|
|
62
|
+
diff = fra - age
|
|
63
|
+
if diff <= 0:
|
|
64
|
+
return 1. - .08 * diff
|
|
65
|
+
elif diff <= 3:
|
|
66
|
+
# Reduction of 20% over first 36 months.
|
|
67
|
+
return 1. - 0.06666667 * diff
|
|
68
|
+
else:
|
|
69
|
+
# Then 5% per tranche of 12 months.
|
|
70
|
+
return .8 - 0.05 * (diff - 3)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def getSpousalFactor(fra, age):
|
|
74
|
+
"""
|
|
75
|
+
Return factor to multiply spousal benefit given the age when benefit starts.
|
|
76
|
+
Year of FRA and age can be fractional.
|
|
77
|
+
"""
|
|
78
|
+
if age < 62:
|
|
79
|
+
raise ValueError(f"Age {age} out of range.")
|
|
80
|
+
|
|
81
|
+
diff = fra - age
|
|
82
|
+
if diff <= 0:
|
|
83
|
+
return 1.
|
|
84
|
+
elif diff <= 3:
|
|
85
|
+
# Reduction of 25% over first 36 months.
|
|
86
|
+
return 1. - 0.08333333 * diff
|
|
87
|
+
else:
|
|
88
|
+
# Then 5% per tranche of 12 months.
|
|
89
|
+
return .75 - 0.05 * (diff - 3)
|
owlplanner/version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "2025.
|
|
1
|
+
__version__ = "2025.12.05"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: owlplanner
|
|
3
|
-
Version: 2025.
|
|
3
|
+
Version: 2025.12.5
|
|
4
4
|
Summary: Owl - Optimal Wealth Lab: Retirement planner with great wisdom
|
|
5
5
|
Project-URL: HomePage, https://github.com/mdlacasse/owl
|
|
6
6
|
Project-URL: Repository, https://github.com/mdlacasse/owl
|
|
@@ -722,16 +722,16 @@ Users can select varying return rates to perform historical back testing,
|
|
|
722
722
|
stochastic rates for performing Monte Carlo analyses,
|
|
723
723
|
or fixed rates either derived from historical averages, or set by the user.
|
|
724
724
|
|
|
725
|
-
There are
|
|
725
|
+
There are three ways to run Owl:
|
|
726
726
|
|
|
727
|
-
- Run Owl
|
|
727
|
+
- **Streamlit Hub:** Run Owl remotely as hosted on the Streamlit Community Server at
|
|
728
728
|
[owlplanner.streamlit.app](https://owlplanner.streamlit.app).
|
|
729
729
|
|
|
730
|
-
- Run locally on your computer using a Docker image.
|
|
731
|
-
Follow these [instructions](docker/README.md) for this option.
|
|
730
|
+
- **Docker Container:** Run Owl locally on your computer using a Docker image.
|
|
731
|
+
Follow these [instructions](docker/README.md) for using this option.
|
|
732
732
|
|
|
733
|
-
- Run locally on your computer using Python code and libraries.
|
|
734
|
-
Follow these [instructions](INSTALL.md) to install
|
|
733
|
+
- **Self-hosting:** Run Owl locally on your computer using Python code and libraries.
|
|
734
|
+
Follow these [instructions](INSTALL.md) to install from the source code and self-host on your own computer.
|
|
735
735
|
|
|
736
736
|
-------------------------------------------------------------------------------------
|
|
737
737
|
## Overview
|
|
@@ -760,7 +760,7 @@ There are and were
|
|
|
760
760
|
good retirement optimizers in the recent past, but the vast majority of them are either proprietary platforms
|
|
761
761
|
collecting your data, or academic papers that share the results without really sharing the details of
|
|
762
762
|
the underlying mathematical models.
|
|
763
|
-
The algorithms in Owl rely on the open-source HiGHS linear programming solver
|
|
763
|
+
The algorithms in Owl rely on the open-source HiGHS linear programming solver but they have also been ported and tested on
|
|
764
764
|
other platforms such as Mosek and COIN-OR. The complete formulation and
|
|
765
765
|
detailed description of the underlying
|
|
766
766
|
mathematical model can be found [here](https://github.com/mdlacasse/Owl/blob/main/docs/owl.pdf).
|
|
@@ -849,13 +849,16 @@ They can also be optimized explicitly as an option, but this choice can lead to
|
|
|
849
849
|
due to the use of the many additional binary variables required by the formulation.
|
|
850
850
|
Future Medicare and IRMAA values are simple projections of current values with the assumed inflation rates.
|
|
851
851
|
|
|
852
|
+
Owl has a basic social security calculator that determines the actual benefits based on the individual's
|
|
853
|
+
primary insurance amount (PIA), full retirement age (FRA), and claiming age. Both
|
|
854
|
+
spousal's benefits and survivor's benefits are calculated for non-complex cases.
|
|
855
|
+
|
|
852
856
|
### Limitations
|
|
853
857
|
Owl is work in progress. At the current time:
|
|
854
858
|
- Only the US federal income tax is considered (and minimized through the optimization algorithm).
|
|
855
859
|
Head of household filing status has not been added but can easily be.
|
|
856
860
|
- Required minimum distributions are calculated, but tables for spouses more than 10 years apart are not included.
|
|
857
861
|
These cases are detected and will generate an error message.
|
|
858
|
-
- Social security rule for surviving spouse assumes that benefits were taken at full retirement age.
|
|
859
862
|
- Current version has no optimization of asset allocations between individuals and/or types of savings accounts.
|
|
860
863
|
If there is interest, that could be added in the future.
|
|
861
864
|
- In the current implementation, social securiy is always taxed at 85%, assuming that your taxable income will be larger than 34 k$ (single) or 44 k$ (married filing jointly).
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
owlplanner/__init__.py,sha256=hJ2i4m2JpHPAKyQLjYOXpJzeEsgcTcKD-Vhm0AIjjWg,592
|
|
2
2
|
owlplanner/abcapi.py,sha256=rtg7d0UbftinokR9VlB49VUjDjzUq3ONnJbhMXVIrgo,6879
|
|
3
|
-
owlplanner/config.py,sha256=
|
|
3
|
+
owlplanner/config.py,sha256=bEO5-QAy_rXjzTIJFEIFSfFSdELbJUDp4mRyU2wRBvA,12790
|
|
4
4
|
owlplanner/mylogging.py,sha256=OVGeDFO7LIZG91R6HMpZBzjno-B8PH8Fo00Jw2Pdgqw,2558
|
|
5
|
-
owlplanner/plan.py,sha256=
|
|
5
|
+
owlplanner/plan.py,sha256=FCtiG60AfdvgcUQgnQ4N2KlFXl6T0xC1pdyj9-GGwgY,117597
|
|
6
6
|
owlplanner/progress.py,sha256=dUUlFmSAKUei36rUj2BINRY10f_YEUo_e23d0es6nrc,530
|
|
7
7
|
owlplanner/rates.py,sha256=9Nmo8AKsyi5PoCUrzhr06phkSlNTv-TXzj5iYFU76AY,14113
|
|
8
|
+
owlplanner/socialsecurity.py,sha256=ODxJ7QqHomzKVVlCPMNa-H8ywOcEk8ktyyDJ2MDrMxo,2195
|
|
8
9
|
owlplanner/tax2025.py,sha256=4KYaT6TO6WU7wDjgdRW48lqfwvVCtaXs9tcw1nleKhg,10834
|
|
9
10
|
owlplanner/tax2026.py,sha256=hgCiCJWVzJITk0cA8W-zxl-a0kObijPZ1yXc0F6MAwk,10848
|
|
10
11
|
owlplanner/timelists.py,sha256=UdzH6A_-w4REn4A1po7yndSiy1R8_R-i_C-94re4JYY,4093
|
|
11
12
|
owlplanner/utils.py,sha256=afAjeO6Msf6Rn4jwz_7Ody9rHGWlBR7iQFqe1xzLNQc,2513
|
|
12
|
-
owlplanner/version.py,sha256=
|
|
13
|
+
owlplanner/version.py,sha256=tIESH6G3DgWFtRScfgtDIL48ULuRpQ0KcMjAFDHJ1D8,28
|
|
13
14
|
owlplanner/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
15
|
owlplanner/data/rates.csv,sha256=6fxg56BVVORrj9wJlUGFdGXKvOX5r7CSca8uhUbbuIU,3734
|
|
15
16
|
owlplanner/plotting/__init__.py,sha256=uhxqtUi0OI-QWNOO2LkXgQViW_9yM3rYb-204Wit974,250
|
|
@@ -17,7 +18,7 @@ owlplanner/plotting/base.py,sha256=UimGKpMTV-dVm3BX5Apr_Ltorc7dlDLCRPRQ3RF_v7c,2
|
|
|
17
18
|
owlplanner/plotting/factory.py,sha256=EDopIAPQr9zHRgemObko18FlCeRNhNCoLNNFAOq-X6s,1030
|
|
18
19
|
owlplanner/plotting/matplotlib_backend.py,sha256=AOEkapD94U5hGNoS0EdbRoe8mgdMHH4oOvkXADZS914,17957
|
|
19
20
|
owlplanner/plotting/plotly_backend.py,sha256=AO33GxBHGYG5osir_H1iRRtGxdhs4AjfLV2d_xm35nY,33138
|
|
20
|
-
owlplanner-2025.
|
|
21
|
-
owlplanner-2025.
|
|
22
|
-
owlplanner-2025.
|
|
23
|
-
owlplanner-2025.
|
|
21
|
+
owlplanner-2025.12.5.dist-info/METADATA,sha256=S8mJrqS-nw6cS91G6jOtK6kkN-xGvDKnMEwpBRv1LPs,55234
|
|
22
|
+
owlplanner-2025.12.5.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
23
|
+
owlplanner-2025.12.5.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
|
|
24
|
+
owlplanner-2025.12.5.dist-info/RECORD,,
|
|
File without changes
|