owlplanner 2025.5.3__py3-none-any.whl → 2025.5.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
owlplanner/plots.py ADDED
@@ -0,0 +1,296 @@
1
+ """
2
+
3
+ Owl/plots
4
+ ---------
5
+
6
+ A retirement planner using linear programming optimization.
7
+
8
+ This module contains all plotting functions used by the Owl project.
9
+
10
+ Copyright (C) 2025 -- Martin-D. Lacasse
11
+
12
+ Disclaimer: This program comes with no guarantee. Use at your own risk.
13
+ """
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+ import matplotlib.pyplot as plt
18
+ import matplotlib.ticker as tk
19
+ import io
20
+ import os
21
+
22
+ os.environ["JUPYTER_PLATFORM_DIRS"] = "1"
23
+ import seaborn as sbn
24
+
25
+ from owlplanner import utils as u
26
+
27
+
28
+ def line_income_plot(x, series, style, title, yformat="\\$k"):
29
+ """
30
+ Core line plotter function.
31
+ """
32
+ fig, ax = plt.subplots(figsize=(6, 4))
33
+ plt.grid(visible="both")
34
+
35
+ for sname in series:
36
+ ax.plot(x, series[sname], label=sname, ls=style[sname])
37
+
38
+ ax.legend(loc="upper left", reverse=True, fontsize=8, framealpha=0.3)
39
+ ax.set_title(title)
40
+ ax.set_xlabel("year")
41
+ ax.set_ylabel(yformat)
42
+ ax.xaxis.set_major_locator(tk.MaxNLocator(integer=True))
43
+ if "k" in yformat:
44
+ ax.get_yaxis().set_major_formatter(tk.FuncFormatter(lambda x, p: format(int(x / 1000), ",")))
45
+ # Give range to y values in unindexed flat profiles.
46
+ ymin, ymax = ax.get_ylim()
47
+ if ymax - ymin < 5000:
48
+ ax.set_ylim((ymin * 0.95, ymax * 1.05))
49
+
50
+ return fig, ax
51
+
52
+
53
+ def stack_plot(x, inames, title, irange, series, snames, location, yformat="\\$k"):
54
+ """
55
+ Core function for stacked plots.
56
+ """
57
+ nonzeroSeries = {}
58
+ for sname in snames:
59
+ for i in irange:
60
+ tmp = series[sname][i]
61
+ if sum(tmp) > 1.0:
62
+ nonzeroSeries[sname + " " + inames[i]] = tmp
63
+
64
+ if len(nonzeroSeries) == 0:
65
+ return None, None
66
+
67
+ fig, ax = plt.subplots(figsize=(6, 4))
68
+ plt.grid(visible="both")
69
+
70
+ ax.stackplot(x, nonzeroSeries.values(), labels=nonzeroSeries.keys(), alpha=0.6)
71
+ ax.legend(loc=location, reverse=True, fontsize=8, ncol=2, framealpha=0.5)
72
+ ax.set_title(title)
73
+ ax.set_xlabel("year")
74
+ ax.xaxis.set_major_locator(tk.MaxNLocator(integer=True))
75
+ if "k" in yformat:
76
+ ax.set_ylabel(yformat)
77
+ ax.get_yaxis().set_major_formatter(tk.FuncFormatter(lambda x, p: format(int(x / 1000), ",")))
78
+ elif yformat == "percent":
79
+ ax.set_ylabel("%")
80
+ ax.get_yaxis().set_major_formatter(tk.FuncFormatter(lambda x, p: format(int(100 * x), ",")))
81
+ else:
82
+ raise RuntimeError(f"Unknown yformat: {yformat}.")
83
+
84
+ return fig, ax
85
+
86
+
87
+ def show_histogram_results(objective, df, N, year_n, n_d=None, N_i=1, phi_j=None):
88
+ """
89
+ Show a histogram of values from historical data or Monte Carlo simulations.
90
+ """
91
+ description = io.StringIO()
92
+
93
+ pSuccess = u.pc(len(df) / N)
94
+ print(f"Success rate: {pSuccess} on {N} samples.", file=description)
95
+ title = f"$N$ = {N}, $P$ = {pSuccess}"
96
+ means = df.mean(axis=0, numeric_only=True)
97
+ medians = df.median(axis=0, numeric_only=True)
98
+
99
+ my = 2 * [year_n[-1]]
100
+ if N_i == 2 and n_d is not None and n_d < len(year_n):
101
+ my[0] = year_n[n_d - 1]
102
+
103
+ # Don't show partial bequest of zero if spouse is full beneficiary,
104
+ # or if solution led to empty accounts at the end of first spouse's life.
105
+ if (phi_j is not None and np.all(phi_j == 1)) or medians.iloc[0] < 1:
106
+ if medians.iloc[0] < 1:
107
+ print(f"Optimized solutions all have null partial bequest in year {my[0]}.", file=description)
108
+ df.drop("partial", axis=1, inplace=True)
109
+ means = df.mean(axis=0, numeric_only=True)
110
+ medians = df.median(axis=0, numeric_only=True)
111
+
112
+ df /= 1000
113
+ if len(df) > 0:
114
+ thisyear = year_n[0]
115
+ if objective == "maxBequest":
116
+ fig, axes = plt.subplots()
117
+ # Show both partial and final bequests in the same histogram.
118
+ sbn.histplot(df, multiple="dodge", kde=True, ax=axes)
119
+ legend = []
120
+ # Don't know why but legend is reversed from df.
121
+ for q in range(len(means) - 1, -1, -1):
122
+ dmedian = u.d(medians.iloc[q], latex=True)
123
+ dmean = u.d(means.iloc[q], latex=True)
124
+ legend.append(f"{my[q]}: $M$: {dmedian}, $\\bar{{x}}$: {dmean}")
125
+ plt.legend(legend, shadow=True)
126
+ plt.xlabel(f"{thisyear} $k")
127
+ plt.title(objective)
128
+ leads = [f"partial {my[0]}", f" final {my[1]}"]
129
+ elif len(means) == 2:
130
+ # Show partial bequest and net spending as two separate histograms.
131
+ fig, axes = plt.subplots(1, 2, figsize=(10, 5))
132
+ cols = ["partial", objective]
133
+ leads = [f"partial {my[0]}", objective]
134
+ for q in range(2):
135
+ sbn.histplot(df[cols[q]], kde=True, ax=axes[q])
136
+ dmedian = u.d(medians.iloc[q], latex=True)
137
+ dmean = u.d(means.iloc[q], latex=True)
138
+ legend = [f"$M$: {dmedian}, $\\bar{{x}}$: {dmean}"]
139
+ axes[q].set_label(legend)
140
+ axes[q].legend(labels=legend)
141
+ axes[q].set_title(leads[q])
142
+ axes[q].set_xlabel(f"{thisyear} $k")
143
+ else:
144
+ # Show net spending as single histogram.
145
+ fig, axes = plt.subplots()
146
+ sbn.histplot(df[objective], kde=True, ax=axes)
147
+ dmedian = u.d(medians.iloc[0], latex=True)
148
+ dmean = u.d(means.iloc[0], latex=True)
149
+ legend = [f"$M$: {dmedian}, $\\bar{{x}}$: {dmean}"]
150
+ plt.legend(legend, shadow=True)
151
+ plt.xlabel(f"{thisyear} $k")
152
+ plt.title(objective)
153
+ leads = [objective]
154
+
155
+ plt.suptitle(title)
156
+
157
+ for q in range(len(means)):
158
+ print(f"{leads[q]:>12}: Median ({thisyear} $): {u.d(medians.iloc[q])}", file=description)
159
+ print(f"{leads[q]:>12}: Mean ({thisyear} $): {u.d(means.iloc[q])}", file=description)
160
+ mmin = 1000 * df.iloc[:, q].min()
161
+ mmax = 1000 * df.iloc[:, q].max()
162
+ print(f"{leads[q]:>12}: Range: {u.d(mmin)} - {u.d(mmax)}", file=description)
163
+ nzeros = len(df.iloc[:, q][df.iloc[:, q] < 0.001])
164
+ print(f"{leads[q]:>12}: N zero solns: {nzeros}", file=description)
165
+
166
+ return fig, description
167
+
168
+ return None, description
169
+
170
+
171
+ def show_rates_correlations(name, tau_kn, N_n, rate_method, rate_frm=None, rate_to=None, tag="", share_range=False):
172
+ """
173
+ Plot correlations between various rates.
174
+ """
175
+ rate_names = [
176
+ "S&P500 (incl. div.)",
177
+ "Baa Corp. Bonds",
178
+ "10-y T-Notes",
179
+ "Inflation",
180
+ ]
181
+
182
+ df = pd.DataFrame()
183
+ for k, name in enumerate(rate_names):
184
+ data = 100 * tau_kn[k]
185
+ df[name] = data
186
+
187
+ g = sbn.PairGrid(df, diag_sharey=False, height=1.8, aspect=1)
188
+ if share_range:
189
+ minval = df.min().min() - 5
190
+ maxval = df.max().max() + 5
191
+ g.set(xlim=(minval, maxval), ylim=(minval, maxval))
192
+ g.map_upper(sbn.scatterplot)
193
+ g.map_lower(sbn.kdeplot)
194
+ g.map_diag(sbn.histplot, color="orange")
195
+
196
+ # Put zero axes on off-diagonal plots.
197
+ imod = len(rate_names) + 1
198
+ for i, ax in enumerate(g.axes.flat):
199
+ ax.axvline(x=0, color="grey", linewidth=1, linestyle=":")
200
+ if i % imod != 0:
201
+ ax.axhline(y=0, color="grey", linewidth=1, linestyle=":")
202
+
203
+ title = name + "\n"
204
+ title += f"Rates Correlations (N={N_n}) {rate_method}"
205
+ if rate_method in ["historical", "histochastic"]:
206
+ title += f" ({rate_frm}-{rate_to})"
207
+
208
+ if tag != "":
209
+ title += " - " + tag
210
+
211
+ g.fig.suptitle(title, y=1.08)
212
+ return g.fig
213
+
214
+
215
+ def show_rates(name, tau_kn, year_n, year_frac_left, N_k, rate_method, rate_frm=None, rate_to=None, tag=""):
216
+ """
217
+ Plot rate values used over the time horizon.
218
+ """
219
+ fig, ax = plt.subplots(figsize=(6, 4))
220
+ plt.grid(visible="both")
221
+ title = name + "\nReturn & Inflation Rates (" + str(rate_method)
222
+ if rate_method in ["historical", "histochastic", "historical average"]:
223
+ title += f" {rate_frm}-{rate_to}"
224
+ title += ")"
225
+
226
+ if tag != "":
227
+ title += " - " + tag
228
+
229
+ rate_name = [
230
+ "S&P500 (incl. div.)",
231
+ "Baa Corp. Bonds",
232
+ "10-y T-Notes",
233
+ "Inflation",
234
+ ]
235
+ ltype = ["-", "-.", ":", "--"]
236
+ for k in range(N_k):
237
+ if year_frac_left == 1:
238
+ data = 100 * tau_kn[k]
239
+ years = year_n
240
+ else:
241
+ data = 100 * tau_kn[k, 1:]
242
+ years = year_n[1:]
243
+
244
+ # Use ddof=1 to match pandas.
245
+ label = (
246
+ rate_name[k] + " <" + "{:.1f}".format(np.mean(data)) + " +/- {:.1f}".format(np.std(data, ddof=1)) + "%>"
247
+ )
248
+ ax.plot(years, data, label=label, ls=ltype[k % N_k])
249
+
250
+ ax.xaxis.set_major_locator(tk.MaxNLocator(integer=True))
251
+ ax.legend(loc="best", reverse=False, fontsize=8, framealpha=0.7)
252
+ ax.set_title(title)
253
+ ax.set_xlabel("year")
254
+ ax.set_ylabel("%")
255
+ return fig
256
+
257
+
258
+ def show_rates_distributions(frm, to, SP500, BondsBaa, TNotes, Inflation, FROM):
259
+ """
260
+ Plot histograms of the rates distributions.
261
+ """
262
+ title = f"Rates from {frm} to {to}"
263
+ # Bring year values to indices.
264
+ frm -= FROM
265
+ to -= FROM
266
+
267
+ nbins = int((to - frm) / 4)
268
+ fig, ax = plt.subplots(1, 4, sharey=True, sharex=True, tight_layout=True)
269
+
270
+ dat0 = np.array(SP500[frm:to])
271
+ dat1 = np.array(BondsBaa[frm:to])
272
+ dat2 = np.array(TNotes[frm:to])
273
+ dat3 = np.array(Inflation[frm:to])
274
+
275
+ fig.suptitle(title)
276
+ ax[0].set_title("S&P500")
277
+ label = "<>: " + u.pc(np.mean(dat0), 2, 1)
278
+ ax[0].hist(dat0, bins=nbins, label=label)
279
+ ax[0].legend(loc="upper left", fontsize=8, framealpha=0.7)
280
+
281
+ ax[1].set_title("BondsBaa")
282
+ label = "<>: " + u.pc(np.mean(dat1), 2, 1)
283
+ ax[1].hist(dat1, bins=nbins, label=label)
284
+ ax[1].legend(loc="upper left", fontsize=8, framealpha=0.7)
285
+
286
+ ax[2].set_title("TNotes")
287
+ label = "<>: " + u.pc(np.mean(dat2), 2, 1)
288
+ ax[2].hist(dat2, bins=nbins, label=label)
289
+ ax[2].legend(loc="upper left", fontsize=8, framealpha=0.7)
290
+
291
+ ax[3].set_title("Inflation")
292
+ label = "<>: " + u.pc(np.mean(dat3), 2, 1)
293
+ ax[3].hist(dat3, bins=nbins, label=label)
294
+ ax[3].legend(loc="upper left", fontsize=8, framealpha=0.7)
295
+
296
+ return fig
owlplanner/rates.py CHANGED
@@ -37,6 +37,7 @@ from datetime import date
37
37
 
38
38
  from owlplanner import logging
39
39
  from owlplanner import utils as u
40
+ from owlplanner import plots
40
41
 
41
42
  # All data goes from 1928 to 2024. Update the TO value when data
42
43
  # becomes available for subsequent years.
@@ -48,7 +49,7 @@ file = os.path.join(where, "data/rates.csv")
48
49
  try:
49
50
  df = pd.read_csv(file)
50
51
  except Exception as e:
51
- raise RuntimeError(f"Could not find rates data file: {e}")
52
+ raise RuntimeError(f"Could not find rates data file: {e}") from e
52
53
 
53
54
 
54
55
  # Annual rate of return (%) of S&P 500 since 1928, including dividends.
@@ -82,9 +83,12 @@ def getRatesDistributions(frm, to, mylog=None):
82
83
  # Convert years to index and check range.
83
84
  frm -= FROM
84
85
  to -= FROM
85
- assert 0 <= frm and frm <= len(SP500), 'Range "from" out of bounds.'
86
- assert 0 <= to and to <= len(SP500), 'Range "to" out of bounds.'
87
- assert frm <= to, '"from" must be smaller than "to".'
86
+ if not (0 <= frm and frm <= len(SP500)):
87
+ raise ValueError(f"Range 'from' {frm} out of bounds.")
88
+ if not (0 <= to and to <= len(SP500)):
89
+ raise ValueError(f"Range 'to' {to} out of bounds.")
90
+ if frm >= to:
91
+ raise ValueError(f'"from" {frm} must be smaller than "to" {to}.')
88
92
 
89
93
  series = {
90
94
  "SP500": SP500,
@@ -124,9 +128,12 @@ def historicalValue(amount, year):
124
128
  valued at the beginning of the year specified.
125
129
  """
126
130
  thisyear = date.today().year
127
- assert TO == thisyear - 1, f"Rates file needs to be updated to be current to {thisyear}."
128
- assert year >= FROM, f"Only data from {FROM} is available."
129
- assert year <= thisyear, f"Year must be < {thisyear} for historical data."
131
+ if TO != thisyear - 1:
132
+ raise RuntimeError(f"Rates file needs to be updated to be current to {thisyear}.")
133
+ if year < FROM:
134
+ raise ValueError(f"Only data from {FROM} is available.")
135
+ if year > thisyear:
136
+ raise ValueError(f"Year must be < {thisyear} for historical data.")
130
137
 
131
138
  span = thisyear - year
132
139
  ub = len(Inflation)
@@ -223,18 +230,24 @@ class Rates(object):
223
230
  self.mylog.vprint("Using conservative fixed rates values:", *[u.pc(k) for k in self.means])
224
231
  self._setFixedRates(self._conservRates)
225
232
  elif method == "user":
226
- assert values is not None, "Fixed values must be provided with the user option."
227
- assert len(values) == Nk, f"Values must have {Nk} items."
233
+ if values is None:
234
+ raise ValueError("Fixed values must be provided with the user option.")
235
+ if len(values) != Nk:
236
+ raise ValueError(f"Values must have {Nk} items.")
228
237
  self.means = np.array(values, dtype=float)
229
238
  # Convert percent to decimal for storing.
230
239
  self.means /= 100.0
231
240
  self.mylog.vprint("Setting rates using fixed user values:", *[u.pc(k) for k in self.means])
232
241
  self._setFixedRates(self.means)
233
242
  elif method == "stochastic":
234
- assert values is not None, "Mean values must be provided with the stochastic option."
235
- assert stdev is not None, "Standard deviations must be provided with the stochastic option."
236
- assert len(values) == Nk, f"Values must have {Nk} items."
237
- assert len(stdev) == Nk, f"stdev must have {Nk} items."
243
+ if values is None:
244
+ raise ValueError("Mean values must be provided with the stochastic option.")
245
+ if stdev is None:
246
+ raise ValueError("Standard deviations must be provided with the stochastic option.")
247
+ if len(values) != Nk:
248
+ raise ValueError(f"Values must have {Nk} items.")
249
+ if len(stdev) != Nk:
250
+ raise ValueError(f"stdev must have {Nk} items.")
238
251
  self.means = np.array(values, dtype=float)
239
252
  self.stdev = np.array(stdev, dtype=float)
240
253
  # Convert percent to decimal for storing.
@@ -262,7 +275,8 @@ class Rates(object):
262
275
  raise RuntimeError(f"Unable to process correlation shape of {corrarr.shape}.")
263
276
 
264
277
  self.corr = corrarr
265
- assert np.array_equal(self.corr, self.corr.T), "Correlation matrix must be symmetric."
278
+ if not np.array_equal(self.corr, self.corr.T):
279
+ raise ValueError("Correlation matrix must be symmetric.")
266
280
  # Now build covariance matrix from stdev and correlation matrix.
267
281
  # Multiply each row by a vector element-wise. Then columns.
268
282
  covar = self.corr * self.stdev
@@ -273,10 +287,14 @@ class Rates(object):
273
287
  self.mylog.vprint("\t and correlation matrix:\n\t\t", str(self.corr).replace("\n", "\n\t\t"))
274
288
  else:
275
289
  # Then methods relying on historical data range.
276
- assert frm is not None, "From year must be provided with this option."
277
- assert FROM <= frm and frm <= TO, f"Lower range 'frm={frm}' out of bounds."
278
- assert FROM <= to and to <= TO, f"Upper range 'to={to}' out of bounds."
279
- assert frm < to, "Unacceptable range."
290
+ if frm is None:
291
+ raise ValueError("From year must be provided with this option.")
292
+ if not (FROM <= frm <= TO):
293
+ raise ValueError(f"Lower range 'frm={frm}' out of bounds.")
294
+ if not (FROM <= to <= TO):
295
+ raise ValueError(f"Upper range 'to={to}' out of bounds.")
296
+ if not (frm < to):
297
+ raise ValueError("Unacceptable range.")
280
298
  self.frm = frm
281
299
  self.to = to
282
300
 
@@ -300,7 +318,8 @@ class Rates(object):
300
318
 
301
319
  def _setFixedRates(self, rates):
302
320
  Nk = len(self._defRates)
303
- assert len(rates) == Nk, f"Rate list provided must have {Nk} entries."
321
+ if len(rates) != Nk:
322
+ raise ValueError(f"Rate list provided must have {Nk} entries.")
304
323
  self._myRates = np.array(rates)
305
324
  self._rateMethod = self._fixedRates
306
325
 
@@ -370,43 +389,7 @@ def showRatesDistributions(frm=FROM, to=TO):
370
389
  """
371
390
  Plot histograms of the rates distributions.
372
391
  """
373
- import matplotlib.pyplot as plt
374
-
375
- title = "Rates from " + str(frm) + " to " + str(to)
376
- # Bring year values to indices.
377
- frm -= FROM
378
- to -= FROM
379
-
380
- nbins = int((to - frm) / 4)
381
- fig, ax = plt.subplots(1, 4, sharey=True, sharex=True, tight_layout=True)
382
-
383
- dat0 = np.array(SP500[frm:to])
384
- dat1 = np.array(BondsBaa[frm:to])
385
- dat2 = np.array(TNotes[frm:to])
386
- dat3 = np.array(Inflation[frm:to])
387
-
388
- fig.suptitle(title)
389
- ax[0].set_title("S&P500")
390
- label = "<>: " + u.pc(np.mean(dat0), 2, 1)
391
- ax[0].hist(dat0, bins=nbins, label=label)
392
- ax[0].legend(loc="upper left", fontsize=8, framealpha=0.7)
393
-
394
- ax[1].set_title("BondsBaa")
395
- label = "<>: " + u.pc(np.mean(dat1), 2, 1)
396
- ax[1].hist(dat1, bins=nbins, label=label)
397
- ax[1].legend(loc="upper left", fontsize=8, framealpha=0.7)
398
-
399
- ax[2].set_title("TNotes")
400
- label = "<>: " + u.pc(np.mean(dat2), 2, 1)
401
- ax[2].hist(dat1, bins=nbins, label=label)
402
- ax[2].legend(loc="upper left", fontsize=8, framealpha=0.7)
403
-
404
- ax[3].set_title("Inflation")
405
- label = "<>: " + u.pc(np.mean(dat3), 2, 1)
406
- ax[3].hist(dat3, bins=nbins, label=label)
407
- ax[3].legend(loc="upper left", fontsize=8, framealpha=0.7)
408
-
409
- plt.show()
410
-
411
- # return fig, ax
412
- return None
392
+ fig = plots.show_rates_distributions(frm, to, SP500, BondsBaa, TNotes, Inflation, FROM)
393
+ return fig
394
+ # plt.show()
395
+ # return None
owlplanner/tax2025.py CHANGED
@@ -172,7 +172,8 @@ def taxBrackets(N_i, n_d, N_n, y_TCJA):
172
172
  Return dictionary containing future tax brackets
173
173
  unadjusted for inflation for plotting.
174
174
  """
175
- assert 0 < N_i and N_i <= 2, f"Cannot process {N_i} individuals."
175
+ if not (0 < N_i <= 2):
176
+ raise ValueError(f"Cannot process {N_i} individuals.")
176
177
  n_d = min(n_d, N_n)
177
178
  status = N_i - 1
178
179
 
owlplanner/timelists.py CHANGED
@@ -55,7 +55,7 @@ def read(finput, inames, horizons, mylog):
55
55
  try:
56
56
  dfDict = pd.read_excel(finput, sheet_name=None)
57
57
  except Exception as e:
58
- raise Exception(f"Could not read file {finput}: {e}.")
58
+ raise Exception(f"Could not read file {finput}: {e}.") from e
59
59
  streamName = f"file '{finput}'"
60
60
 
61
61
  timeLists = condition(dfDict, inames, horizons, mylog)
owlplanner/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "2025.05.03"
1
+ __version__ = "2025.05.05"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: owlplanner
3
- Version: 2025.5.3
3
+ Version: 2025.5.5
4
4
  Summary: Owl: 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
@@ -0,0 +1,18 @@
1
+ owlplanner/__init__.py,sha256=QJgqS0MrRlBUFuI3PY2LbtD-Xxk1keNnbbPmSsdyZL0,552
2
+ owlplanner/abcapi.py,sha256=m0vtoEzz9HJV7fOK_d7OnK7ha2Qbf7wLLPCJ9YZzR1k,6851
3
+ owlplanner/config.py,sha256=DP-E8EPhkWvMmgPKp26rIrcppkITQyedgLfEyFrySpg,12554
4
+ owlplanner/logging.py,sha256=tYMw04O-XYSzjTj36fmKJGLcE1VkK6k6oJNeqtKXzuc,2530
5
+ owlplanner/plan.py,sha256=9mWj7AXXSTTfaa9bqqUqjPFuiPYf3HtCH31c0Y7Sryg,110615
6
+ owlplanner/plots.py,sha256=0bvKzvPFfUSymWvMV2gGXnQy3-LpjcywYrfJ2-W_8M0,10476
7
+ owlplanner/progress.py,sha256=8jlCvvtgDI89zXVNMBg1-lnEyhpPvKQS2X5oAIpoOVQ,384
8
+ owlplanner/rates.py,sha256=ct-CmRiuxUxslkoBy14j5p19_FesldQiPvMgw470FKE,15108
9
+ owlplanner/tax2025.py,sha256=wmlZpYeeGNrbyn5g7wOFqhWbggppodtHqc-ex5XRooI,7850
10
+ owlplanner/timelists.py,sha256=6eqdnpwmNje9sNw1Hy9Gd-2Wcpgjor1TH4sVnFrpLo4,4033
11
+ owlplanner/utils.py,sha256=WpJgn79YZfH8UCkcmhd-AZlxlGuz1i1-UDBRXImsY6I,2485
12
+ owlplanner/version.py,sha256=suJjWyGl2pv1HKBSdqeb6B361ikXPlXHjEEfihKlKrk,28
13
+ owlplanner/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ owlplanner/data/rates.csv,sha256=6fxg56BVVORrj9wJlUGFdGXKvOX5r7CSca8uhUbbuIU,3734
15
+ owlplanner-2025.5.5.dist-info/METADATA,sha256=0QKQkzgOLIwhD32o7WFrIACVzPf15TXHke0uEU--dn0,53926
16
+ owlplanner-2025.5.5.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
17
+ owlplanner-2025.5.5.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
18
+ owlplanner-2025.5.5.dist-info/RECORD,,
@@ -1,17 +0,0 @@
1
- owlplanner/__init__.py,sha256=QqrdT0Qks20osBTg7h0vJHAxpP9lL7DA99xb0nYbtw4,254
2
- owlplanner/abcapi.py,sha256=Lt8OUgbrfOPzAw0HyxyT2wT-IXI3d9Zo26MwyqdX56Y,6617
3
- owlplanner/config.py,sha256=F6GS3n02VeFX0GCVeM4J7Ra0in4N632W6TZIXk7Yj2w,12519
4
- owlplanner/logging.py,sha256=tYMw04O-XYSzjTj36fmKJGLcE1VkK6k6oJNeqtKXzuc,2530
5
- owlplanner/plan.py,sha256=mKSfHqFdG8P9Dlse-VbTnWSEgO2ZDcqAfK3gHYNEIho,119112
6
- owlplanner/progress.py,sha256=8jlCvvtgDI89zXVNMBg1-lnEyhpPvKQS2X5oAIpoOVQ,384
7
- owlplanner/rates.py,sha256=gJaoe-gJqWCQV5qVLlHp-Yn9TSJs-PJzeTbOwMCbqWs,15682
8
- owlplanner/tax2025.py,sha256=JDBtFFAf2bWtKUMuE3W5F0nBhYaKBjmdJj0iayM2iGA,7829
9
- owlplanner/timelists.py,sha256=tYieZU67FT6TCcQQis36JaXGI7dT6NqD7RvdEjgJL4M,4026
10
- owlplanner/utils.py,sha256=WpJgn79YZfH8UCkcmhd-AZlxlGuz1i1-UDBRXImsY6I,2485
11
- owlplanner/version.py,sha256=NZ889ANsXH5BzjEmfYGvqRdWan3eQSUgEGwPnMds764,28
12
- owlplanner/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- owlplanner/data/rates.csv,sha256=6fxg56BVVORrj9wJlUGFdGXKvOX5r7CSca8uhUbbuIU,3734
14
- owlplanner-2025.5.3.dist-info/METADATA,sha256=eGnBmMVTpNCvwzG153HRjN1rzzrD0zu1hQn1tV0SroA,53926
15
- owlplanner-2025.5.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
16
- owlplanner-2025.5.3.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
17
- owlplanner-2025.5.3.dist-info/RECORD,,