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 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 / 1000).tolist(),
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 / 1000).tolist(),
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"]["Social security amounts"], dtype=np.float32)
221
- ssecAges = np.array(diconf["Fixed Income"]["Social security ages"], dtype=np.int32)
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"]["Pension amounts"], dtype=np.float32)
224
- pensionAges = np.array(diconf["Fixed Income"]["Pension ages"], dtype=np.int32)
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, units="k"):
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 $k, unless specified otherwise: 'k', 'M', or '1'.
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
- fac = u.getUnits(units)
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
- ns = max(0, ages[i] - thisyear + self.yobs[i])
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, dtype=np.int32)
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, amounts, ages, units="k"):
569
+ def setSocialSecurity(self, pias, ages):
556
570
  """
557
- Set value of social security for each individual and commencement age.
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(amounts) != self.N_i:
561
- raise ValueError(f"Amounts must have {self.N_i} entries.")
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
- fac = u.getUnits(units)
566
- amounts = u.rescale(amounts, fac)
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
- "Setting social security benefits of", [u.d(amounts[i]) for i in range(self.N_i)],
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
- ns = max(0, ages[i] - thisyear + self.yobs[i])
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] = amounts[i]
579
-
580
- if self.N_i == 2:
581
- # Approximate calculation for spousal benefit (only valid at FRA, and if of similar ages).
582
- self.zeta_in[self.i_s, self.n_d :] = max(amounts[self.i_s], 0.5*amounts[self.i_d])
583
-
584
- self.ssecAmounts = np.array(amounts)
585
- self.ssecAges = np.array(ages, dtype=np.int32)
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 3 < len(magi) < 2:
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 lambdha < 0 or lambdha > 50:
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
- # "node_limit": 10000 # Limit search nodes for faster solutions
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 &copy; 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.11.05"
1
+ __version__ = "2025.12.03"
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: owlplanner
3
- Version: 2025.11.5
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 :: 4 - Beta
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.8
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 a few ways to run Owl:
725
+ There are three ways to run Owl:
725
726
 
726
- - Run Owl directly on the Streamlit Community Server at
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 Owl from the source code and run it on your computer.
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
- The goal of Owl is to create a free and open-source ecosystem that has cutting-edge optimization capabilities,
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. There are and were
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. The complete formulation and
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=UF2Dy6E9PiX6Ua8B1R0aYCNUoIYmY46up8awf_36B_Q,12615
3
+ owlplanner/config.py,sha256=bEO5-QAy_rXjzTIJFEIFSfFSdELbJUDp4mRyU2wRBvA,12790
4
4
  owlplanner/mylogging.py,sha256=OVGeDFO7LIZG91R6HMpZBzjno-B8PH8Fo00Jw2Pdgqw,2558
5
- owlplanner/plan.py,sha256=NDdV0Eri76JMpiNPeuMzLYGX-lDDBEbjXd6Qg6k_CIw,115180
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=spkGv6ZbCI0lRKprFpKz-y1z1na-qJ_499Tc7QZZukA,28
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.11.5.dist-info/METADATA,sha256=5uKbrq453U55Q_aDQZowTLzz8k8Zlrry1Rwnz5S1gno,54640
21
- owlplanner-2025.11.5.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
22
- owlplanner-2025.11.5.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
23
- owlplanner-2025.11.5.dist-info/RECORD,,
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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.27.0
2
+ Generator: hatchling 1.28.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any