owlplanner 2025.11.5__py3-none-any.whl → 2025.12.3__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 +77 -33
- owlplanner/socialsecurity.py +89 -0
- owlplanner/version.py +1 -1
- {owlplanner-2025.11.5.dist-info → owlplanner-2025.12.3.dist-info}/METADATA +23 -15
- {owlplanner-2025.11.5.dist-info → owlplanner-2025.12.3.dist-info}/RECORD +8 -7
- {owlplanner-2025.11.5.dist-info → owlplanner-2025.12.3.dist-info}/WHEEL +1 -1
- {owlplanner-2025.11.5.dist-info → owlplanner-2025.12.3.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
|
|
|
@@ -1669,13 +1713,13 @@ class Plan(object):
|
|
|
1669
1713
|
self.prevMAGI = np.zeros(2)
|
|
1670
1714
|
if "previousMAGIs" in myoptions:
|
|
1671
1715
|
magi = myoptions["previousMAGIs"]
|
|
1672
|
-
if
|
|
1716
|
+
if len(magi) != 2:
|
|
1673
1717
|
raise ValueError("previousMAGIs must have 2 values.")
|
|
1674
1718
|
|
|
1675
1719
|
self.prevMAGI = self.optionsUnits * np.array(magi)
|
|
1676
1720
|
|
|
1677
1721
|
lambdha = myoptions.get("spendingSlack", 0)
|
|
1678
|
-
if
|
|
1722
|
+
if not (0 <= lambdha <= 50):
|
|
1679
1723
|
raise ValueError(f"Slack value out of range {lambdha}.")
|
|
1680
1724
|
self.lambdha = lambdha / 100
|
|
1681
1725
|
|
|
@@ -1786,7 +1830,7 @@ class Plan(object):
|
|
|
1786
1830
|
"disp": False,
|
|
1787
1831
|
"mip_rel_gap": 1e-7,
|
|
1788
1832
|
"presolve": True,
|
|
1789
|
-
|
|
1833
|
+
"node_limit": 1000000 # Limit search nodes for faster solutions
|
|
1790
1834
|
}
|
|
1791
1835
|
|
|
1792
1836
|
self._buildConstraints(objective, options)
|
|
@@ -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.03"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: owlplanner
|
|
3
|
-
Version: 2025.
|
|
4
|
-
Summary: Owl: Retirement planner with great wisdom
|
|
3
|
+
Version: 2025.12.3
|
|
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
|
|
7
7
|
Project-URL: Issues, https://github.com/mdlacasse/owl/issues
|
|
@@ -684,19 +684,20 @@ License: GNU GENERAL PUBLIC LICENSE
|
|
|
684
684
|
Public License instead of this License. But first, please read
|
|
685
685
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
|
686
686
|
License-File: LICENSE
|
|
687
|
-
Classifier: Development Status ::
|
|
687
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
688
688
|
Classifier: Intended Audience :: End Users/Desktop
|
|
689
689
|
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
|
690
690
|
Classifier: Operating System :: OS Independent
|
|
691
691
|
Classifier: Programming Language :: Python :: 3
|
|
692
692
|
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
693
|
-
Requires-Python: >=3.
|
|
693
|
+
Requires-Python: >=3.10
|
|
694
|
+
Requires-Dist: highspy
|
|
694
695
|
Requires-Dist: matplotlib
|
|
695
696
|
Requires-Dist: numpy
|
|
696
697
|
Requires-Dist: odfpy
|
|
697
698
|
Requires-Dist: openpyxl
|
|
698
699
|
Requires-Dist: pandas
|
|
699
|
-
Requires-Dist: plotly
|
|
700
|
+
Requires-Dist: plotly>=6.3
|
|
700
701
|
Requires-Dist: pulp
|
|
701
702
|
Requires-Dist: scipy
|
|
702
703
|
Requires-Dist: seaborn
|
|
@@ -721,16 +722,16 @@ Users can select varying return rates to perform historical back testing,
|
|
|
721
722
|
stochastic rates for performing Monte Carlo analyses,
|
|
722
723
|
or fixed rates either derived from historical averages, or set by the user.
|
|
723
724
|
|
|
724
|
-
There are
|
|
725
|
+
There are three ways to run Owl:
|
|
725
726
|
|
|
726
|
-
- Run Owl
|
|
727
|
+
- **Streamlit Hub:** Run Owl remotely as hosted on the Streamlit Community Server at
|
|
727
728
|
[owlplanner.streamlit.app](https://owlplanner.streamlit.app).
|
|
728
729
|
|
|
729
|
-
- Run locally on your computer using a Docker image.
|
|
730
|
-
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.
|
|
731
732
|
|
|
732
|
-
- Run locally on your computer using Python code and libraries.
|
|
733
|
-
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.
|
|
734
735
|
|
|
735
736
|
-------------------------------------------------------------------------------------
|
|
736
737
|
## Overview
|
|
@@ -750,13 +751,17 @@ your strategy under different market assumptions, helping to better understand y
|
|
|
750
751
|
|
|
751
752
|
-------------------------------------------------------------------------------------
|
|
752
753
|
## Purpose and vision
|
|
753
|
-
|
|
754
|
+
One goal of Owl is to provide a free and open-source ecosystem that has cutting-edge optimization capabilities,
|
|
754
755
|
allowing for the next generation of Python-literate retirees to experiment with their own financial future
|
|
755
|
-
while providing a codebase where they can learn and contribute.
|
|
756
|
+
while providing a codebase where they can learn and contribute. At the same time, an intuitive and easy-to-use
|
|
757
|
+
user interface based on Streamlit allows a broad set of users to benefit from the application as it only requires basic financial knowledge.
|
|
758
|
+
|
|
759
|
+
There are and were
|
|
756
760
|
good retirement optimizers in the recent past, but the vast majority of them are either proprietary platforms
|
|
757
761
|
collecting your data, or academic papers that share the results without really sharing the details of
|
|
758
762
|
the underlying mathematical models.
|
|
759
|
-
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
|
+
other platforms such as Mosek and COIN-OR. The complete formulation and
|
|
760
765
|
detailed description of the underlying
|
|
761
766
|
mathematical model can be found [here](https://github.com/mdlacasse/Owl/blob/main/docs/owl.pdf).
|
|
762
767
|
|
|
@@ -844,13 +849,16 @@ They can also be optimized explicitly as an option, but this choice can lead to
|
|
|
844
849
|
due to the use of the many additional binary variables required by the formulation.
|
|
845
850
|
Future Medicare and IRMAA values are simple projections of current values with the assumed inflation rates.
|
|
846
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
|
+
|
|
847
856
|
### Limitations
|
|
848
857
|
Owl is work in progress. At the current time:
|
|
849
858
|
- Only the US federal income tax is considered (and minimized through the optimization algorithm).
|
|
850
859
|
Head of household filing status has not been added but can easily be.
|
|
851
860
|
- Required minimum distributions are calculated, but tables for spouses more than 10 years apart are not included.
|
|
852
861
|
These cases are detected and will generate an error message.
|
|
853
|
-
- Social security rule for surviving spouse assumes that benefits were taken at full retirement age.
|
|
854
862
|
- Current version has no optimization of asset allocations between individuals and/or types of savings accounts.
|
|
855
863
|
If there is interest, that could be added in the future.
|
|
856
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=NgrzZPWvxV9lMWfLLilVdxY4QdswZjuOjxgPR8CgSHA,117522
|
|
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=4vemu4v7aVVvv0jXrGPQlk_6PPXwT_p-wdqdrbPl7QI,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.3.dist-info/METADATA,sha256=V7_0-bsuSMQqs1wsbMAVxMcY3bg2gAwjs3z9Fm139Mk,55234
|
|
22
|
+
owlplanner-2025.12.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
23
|
+
owlplanner-2025.12.3.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
|
|
24
|
+
owlplanner-2025.12.3.dist-info/RECORD,,
|
|
File without changes
|