owlplanner 2026.1.26__py3-none-any.whl → 2026.2.2__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/rates.py CHANGED
@@ -1,366 +1,377 @@
1
- """
2
- Historical and statistical rate of return data for asset classes.
3
-
4
- This module provides historical annual rates of return for different asset
5
- classes: S&P500, Baa corporate bonds, real estate, 3-mo T-Bills, 10-year Treasury
6
- notes, and inflation as measured by CPI from 1928 to present. Values were
7
- extracted from NYU's Stern School of business historical returns data.
8
-
9
- Copyright (C) 2025-2026 The Owlplanner Authors
10
-
11
- This program is free software: you can redistribute it and/or modify
12
- it under the terms of the GNU General Public License as published by
13
- the Free Software Foundation, either version 3 of the License, or
14
- (at your option) any later version.
15
-
16
- This program is distributed in the hope that it will be useful,
17
- but WITHOUT ANY WARRANTY; without even the implied warranty of
18
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
- GNU General Public License for more details.
20
-
21
- You should have received a copy of the GNU General Public License
22
- along with this program. If not, see <https://www.gnu.org/licenses/>.
23
- """
24
-
25
- ###################################################################
26
- import numpy as np
27
- import pandas as pd
28
- import os
29
- import sys
30
-
31
- from owlplanner import mylogging as log
32
- from owlplanner import utils as u
33
-
34
- # All data goes from 1928 to 2025. Update the TO value when data
35
- # becomes available for subsequent years.
36
- FROM = 1928
37
- TO = 2025
38
-
39
- where = os.path.dirname(sys.modules["owlplanner"].__file__)
40
- file = os.path.join(where, "data/rates.csv")
41
- try:
42
- df = pd.read_csv(file)
43
- except Exception as e:
44
- raise RuntimeError(f"Could not find rates data file: {e}") from e
45
-
46
-
47
- # Annual rate of return (%) of S&P 500 since 1928, including dividends.
48
- SP500 = df["S&P 500"]
49
-
50
- # Annual rate of return (%) of Baa Corporate Bonds since 1928.
51
- BondsBaa = df["Bonds Baa"]
52
-
53
- # Annual rate of return (%) of Real Estate since 1928.
54
- RealEstate = df["real estate"]
55
-
56
- # Annual rate of return (%) for 10-y Treasury notes since 1928.
57
- TNotes = df["TNotes"]
58
-
59
- # Annual rates of return for 3-month Treasury bills since 1928.
60
- TBills = df["TBills"]
61
-
62
- # Inflation rate as U.S. CPI index (%) since 1928.
63
- Inflation = df["Inflation"]
64
-
65
-
66
- def getRatesDistributions(frm, to, mylog=None):
67
- """
68
- Pre-compute normal distribution parameters for the series above.
69
- This calculation takes into account the correlations between
70
- the different rates. Function returns means and covariance matrix.
71
- """
72
- if mylog is None:
73
- mylog = log.Logger()
74
-
75
- # Convert years to index and check range.
76
- frm -= FROM
77
- to -= FROM
78
- if not (0 <= frm and frm <= len(SP500)):
79
- raise ValueError(f"Range 'from' {frm} out of bounds.")
80
- if not (0 <= to and to <= len(SP500)):
81
- raise ValueError(f"Range 'to' {to} out of bounds.")
82
- if frm >= to:
83
- raise ValueError(f'"from" {frm} must be smaller than "to" {to}.')
84
-
85
- series = {
86
- "SP500": SP500,
87
- "BondsBaa": BondsBaa,
88
- "T. Notes": TNotes,
89
- "Inflation": Inflation,
90
- }
91
-
92
- df = pd.DataFrame(series)
93
- df = df.truncate(before=frm, after=to)
94
-
95
- means = df.mean()
96
- stdev = df.std()
97
- covar = df.cov()
98
-
99
- mylog.vprint("means: (%)\n", means)
100
- mylog.vprint("standard deviation: (%)\n", stdev)
101
-
102
- # Convert to NumPy array and from percent to decimal.
103
- means = np.array(means) / 100.0
104
- stdev = np.array(stdev) / 100.0
105
- covar = np.array(covar) / 10000.0
106
- # Build correlation matrix by dividing by the stdev for each column and row.
107
- corr = covar / stdev[:, None]
108
- corr = corr.T / stdev[:, None]
109
- # Fold round-off errors in proper bounds.
110
- corr[corr > 1] = 1
111
- corr[corr < -1] = -1
112
- mylog.vprint("correlation matrix: \n\t\t%s" % str(corr).replace("\n", "\n\t\t"))
113
-
114
- return means, stdev, corr, covar
115
-
116
-
117
- class Rates(object):
118
- """
119
- Rates are stored in a 4-array in the following order:
120
- Stocks, Bonds, Fixed assets, and Inflation.
121
- Rate are stored in decimal, but the API is in percent.
122
-
123
- To use this class first build an object:
124
- ``r = Rates()``
125
- then ``r.setMethod(...)``
126
- then ``mySeries = r.genSeries()``
127
- """
128
-
129
- def __init__(self, mylog=None, seed=None):
130
- """
131
- Default constructor.
132
-
133
- Args:
134
- mylog: Logger instance (optional)
135
- seed: Random seed for reproducible stochastic rates (optional)
136
- """
137
- if mylog is None:
138
- self.mylog = log.Logger()
139
- else:
140
- self.mylog = mylog
141
-
142
- # Store seed for stochastic rate generation
143
- # Always use a Generator instance for thread safety and modern API
144
- # If seed is None, default_rng() will use entropy/current time
145
- self._seed = seed
146
- self._rng = np.random.default_rng(seed)
147
-
148
- # Default rates are average over last 30 years.
149
- self._defRates = np.array([0.1101, 0.0736, 0.0503, 0.0251])
150
-
151
- # Realistic rates are average predictions of major firms
152
- # as reported by MorningStar in 2023.
153
- self._optimisticRates = np.array([0.086, 0.049, 0.033, 0.025])
154
-
155
- # Conservative rates.
156
- self._conservRates = np.array([0.06, 0.04, 0.033, 0.028])
157
-
158
- self.means = np.zeros((4))
159
- self.stdev = np.zeros((4))
160
- self.corr = np.zeros((4, 4))
161
- self.covar = np.zeros((4, 4))
162
-
163
- self.frm = FROM
164
- self.to = TO
165
-
166
- # Default values for rates.
167
- self.setMethod("default")
168
-
169
- def setMethod(self, method, frm=None, to=TO, values=None, stdev=None, corr=None):
170
- """
171
- Select the method to generate the annual rates of return
172
- for the different classes of assets. Different methods include:
173
- - default: average over last 30 years.
174
- - optimistic: predictions from various firms reported by MorningStar.
175
- - conservative: conservative values.
176
- - user: user-selected fixed rates.
177
- - historical: historical rates from 1928 to last year.
178
- - historical average or means: average over historical data.
179
- - histochastic: randomly generated from the statistical properties of a historical range.
180
- - stochastic: randomly generated from means, standard deviation and optionally a correlation matrix.
181
- The correlation matrix can be provided as a full matrix or
182
- by only specifying the off-diagonal elements as a simple list
183
- of (Nk*Nk - Nk)/2 values for Nk assets.
184
- For 4 assets, this represents a list of 6 off-diagonal values.
185
- """
186
- if method not in [
187
- "default",
188
- "optimistic",
189
- "conservative",
190
- "user",
191
- "historical",
192
- "historical average",
193
- "mean",
194
- "stochastic",
195
- "histochastic",
196
- ]:
197
- raise ValueError(f"Unknown rate selection method {method}.")
198
-
199
- Nk = len(self._defRates)
200
- # First process fixed methods relying on values.
201
- if method == "default":
202
- self.means = self._defRates
203
- # self.mylog.vprint('Using default fixed rates values:', *[u.pc(k) for k in values])
204
- self._setFixedRates(self._defRates)
205
- elif method == "optimistic":
206
- self.means = self._defRates
207
- self.mylog.vprint("Using optimistic fixed rates values:", *[u.pc(k) for k in self.means])
208
- self._setFixedRates(self._optimisticRates)
209
- elif method == "conservative":
210
- self.means = self._conservRates
211
- self.mylog.vprint("Using conservative fixed rates values:", *[u.pc(k) for k in self.means])
212
- self._setFixedRates(self._conservRates)
213
- elif method == "user":
214
- if values is None:
215
- raise ValueError("Fixed values must be provided with the user option.")
216
- if len(values) != Nk:
217
- raise ValueError(f"Values must have {Nk} items.")
218
- self.means = np.array(values, dtype=float)
219
- # Convert percent to decimal for storing.
220
- self.means /= 100.0
221
- self.mylog.vprint("Setting rates using fixed user values:", *[u.pc(k) for k in self.means])
222
- self._setFixedRates(self.means)
223
- elif method == "stochastic":
224
- if values is None:
225
- raise ValueError("Mean values must be provided with the stochastic option.")
226
- if stdev is None:
227
- raise ValueError("Standard deviations must be provided with the stochastic option.")
228
- if len(values) != Nk:
229
- raise ValueError(f"Values must have {Nk} items.")
230
- if len(stdev) != Nk:
231
- raise ValueError(f"stdev must have {Nk} items.")
232
- self.means = np.array(values, dtype=float)
233
- self.stdev = np.array(stdev, dtype=float)
234
- # Convert percent to decimal for storing.
235
- self.means /= 100.0
236
- self.stdev /= 100.0
237
- # Build covariance matrix from standard deviation and correlation matrix.
238
- if corr is None:
239
- corrarr = np.identity(Nk)
240
- else:
241
- corrarr = np.array(corr)
242
- # Full correlation matrix was provided.
243
- if corrarr.shape == (Nk, Nk):
244
- pass
245
- # Only off-diagonal elements were provided: build full matrix.
246
- elif corrarr.shape == ((Nk * (Nk - 1)) // 2,):
247
- newcorr = np.identity(Nk)
248
- x = 0
249
- for i in range(Nk):
250
- for j in range(i + 1, Nk):
251
- newcorr[i, j] = corrarr[x]
252
- newcorr[j, i] = corrarr[x]
253
- x += 1
254
- corrarr = newcorr
255
- else:
256
- raise RuntimeError(f"Unable to process correlation shape of {corrarr.shape}.")
257
-
258
- self.corr = corrarr
259
- if not np.array_equal(self.corr, self.corr.T):
260
- raise ValueError("Correlation matrix must be symmetric.")
261
- # Now build covariance matrix from stdev and correlation matrix.
262
- # Multiply each row by a vector element-wise. Then columns.
263
- covar = self.corr * self.stdev
264
- self.covar = covar.T * self.stdev
265
- self._rateMethod = self._stochRates
266
- self.mylog.vprint("Setting rates using stochastic method with means:", *[u.pc(k) for k in self.means])
267
- self.mylog.vprint("\t standard deviations:", *[u.pc(k) for k in self.stdev])
268
- self.mylog.vprint("\t and correlation matrix:\n\t\t", str(self.corr).replace("\n", "\n\t\t"))
269
- else:
270
- # Then methods relying on historical data range.
271
- if frm is None:
272
- raise ValueError("From year must be provided with this option.")
273
- if not (FROM <= frm <= TO):
274
- raise ValueError(f"Lower range 'frm={frm}' out of bounds.")
275
- if not (FROM <= to <= TO):
276
- raise ValueError(f"Upper range 'to={to}' out of bounds.")
277
- if not (frm < to):
278
- raise ValueError("Unacceptable range.")
279
- self.frm = frm
280
- self.to = to
281
-
282
- if method == "historical":
283
- self.mylog.vprint(f"Using historical rates representing data from {frm} to {to}.")
284
- self._rateMethod = self._histRates
285
- elif method == "historical average" or method == "means":
286
- self.mylog.vprint(f"Using average of rates from {frm} to {to}.")
287
- self.means, self.stdev, self.corr, self.covar = getRatesDistributions(frm, to, self.mylog)
288
- self._setFixedRates(self.means)
289
- elif method == "histochastic":
290
- self.mylog.vprint(f"Using histochastic rates derived from years {frm} to {to}.")
291
- self._rateMethod = self._stochRates
292
- self.means, self.stdev, self.corr, self.covar = getRatesDistributions(frm, to, self.mylog)
293
- else:
294
- raise ValueError(f"Method {method} not supported.")
295
-
296
- self.method = method
297
-
298
- return self.means, self.stdev, self.corr
299
-
300
- def _setFixedRates(self, rates):
301
- Nk = len(self._defRates)
302
- if len(rates) != Nk:
303
- raise ValueError(f"Rate list provided must have {Nk} entries.")
304
- self._myRates = np.array(rates)
305
- self._rateMethod = self._fixedRates
306
-
307
- return
308
-
309
- def genSeries(self, N):
310
- """
311
- Generate a series of Nx4 entries of rates representing S&P500,
312
- corporate Baa bonds, 10-y treasury notes, and inflation,
313
- respectively. If there are less than 'N' entries
314
- in sub-series selected by 'setMethod()', values will be repeated
315
- modulo the length of the sub-series.
316
- """
317
- rateSeries = np.zeros((N, 4))
318
-
319
- # Convert years to indices.
320
- ifrm = self.frm - FROM
321
- ito = self.to - FROM
322
-
323
- # Add one since bounds are inclusive.
324
- span = ito - ifrm + 1
325
-
326
- # Assign 4 values at the time.
327
- for n in range(N):
328
- rateSeries[n][:] = self._rateMethod((ifrm + (n % span)))[:]
329
-
330
- return rateSeries
331
-
332
- def _fixedRates(self, n):
333
- """
334
- Return rates provided.
335
- For fixed rates, values are time-independent, and therefore
336
- the 'n' argument is ignored.
337
- """
338
- # Fixed rates are stored in decimal.
339
- return self._myRates
340
-
341
- def _histRates(self, n):
342
- """
343
- Return an array of 4 values representing the historical rates
344
- of stock, Corporate Baa bonds, Treasury notes, and inflation,
345
- respectively.
346
- """
347
- hrates = np.array([SP500[n], BondsBaa[n], TNotes[n], Inflation[n]])
348
-
349
- # Historical rates are stored in percent. Convert from percent to decimal.
350
- return hrates / 100
351
-
352
- def _stochRates(self, n):
353
- """
354
- Return an array of 4 values representing the historical rates
355
- of stock, Corporate Baa bonds, Treasury notes, and inflation,
356
- respectively. Values are pulled from normal distributions
357
- having the same characteristics as the historical data for
358
- the range of years selected. Argument 'n' is ignored.
359
-
360
- But these variables need to be looked at together
361
- through multivariate analysis. Code below accounts for
362
- covariance between stocks, corp bonds, t-notes, and inflation.
363
- """
364
- srates = self._rng.multivariate_normal(self.means, self.covar)
365
-
366
- return srates
1
+ """
2
+ Historical and statistical rate of return data for asset classes.
3
+
4
+ This module provides historical annual rates of return for different asset
5
+ classes: S&P500, Baa corporate bonds, real estate, 3-mo T-Bills, 10-year Treasury
6
+ notes, and inflation as measured by CPI from 1928 to present. Values were
7
+ extracted from NYU's Stern School of business historical returns data.
8
+
9
+ Copyright (C) 2025-2026 The Owlplanner Authors
10
+
11
+ This program is free software: you can redistribute it and/or modify
12
+ it under the terms of the GNU General Public License as published by
13
+ the Free Software Foundation, either version 3 of the License, or
14
+ (at your option) any later version.
15
+
16
+ This program is distributed in the hope that it will be useful,
17
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ GNU General Public License for more details.
20
+
21
+ You should have received a copy of the GNU General Public License
22
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+ """
24
+
25
+ ###################################################################
26
+ import numpy as np
27
+ import pandas as pd
28
+ import os
29
+ import sys
30
+
31
+ from owlplanner import mylogging as log
32
+ from owlplanner import utils as u
33
+
34
+ # All data goes from 1928 to 2025. Update the TO value when data
35
+ # becomes available for subsequent years.
36
+ FROM = 1928
37
+ TO = 2025
38
+
39
+ # Rate methods that use the same rate every year (reverse/roll are no-ops).
40
+ CONSTANT_RATE_METHODS = (
41
+ "default", "optimistic", "conservative", "user",
42
+ "historical average", "mean",
43
+ )
44
+ # Rate methods that produce deterministic series (no regeneration needed).
45
+ RATE_METHODS_NO_REGEN = (
46
+ "default", "optimistic", "conservative", "user",
47
+ "historical average", "historical",
48
+ )
49
+
50
+ where = os.path.dirname(sys.modules["owlplanner"].__file__)
51
+ file = os.path.join(where, "data/rates.csv")
52
+ try:
53
+ df = pd.read_csv(file)
54
+ except Exception as e:
55
+ raise RuntimeError(f"Could not find rates data file: {e}") from e
56
+
57
+
58
+ # Annual rate of return (%) of S&P 500 since 1928, including dividends.
59
+ SP500 = df["S&P 500"]
60
+
61
+ # Annual rate of return (%) of Baa Corporate Bonds since 1928.
62
+ BondsBaa = df["Bonds Baa"]
63
+
64
+ # Annual rate of return (%) of Real Estate since 1928.
65
+ RealEstate = df["real estate"]
66
+
67
+ # Annual rate of return (%) for 10-y Treasury notes since 1928.
68
+ TNotes = df["TNotes"]
69
+
70
+ # Annual rates of return for 3-month Treasury bills since 1928.
71
+ TBills = df["TBills"]
72
+
73
+ # Inflation rate as U.S. CPI index (%) since 1928.
74
+ Inflation = df["Inflation"]
75
+
76
+
77
+ def getRatesDistributions(frm, to, mylog=None):
78
+ """
79
+ Pre-compute normal distribution parameters for the series above.
80
+ This calculation takes into account the correlations between
81
+ the different rates. Function returns means and covariance matrix.
82
+ """
83
+ if mylog is None:
84
+ mylog = log.Logger()
85
+
86
+ # Convert years to index and check range.
87
+ frm -= FROM
88
+ to -= FROM
89
+ if not (0 <= frm and frm <= len(SP500)):
90
+ raise ValueError(f"Range 'from' {frm} out of bounds.")
91
+ if not (0 <= to and to <= len(SP500)):
92
+ raise ValueError(f"Range 'to' {to} out of bounds.")
93
+ if frm >= to:
94
+ raise ValueError(f'"from" {frm} must be smaller than "to" {to}.')
95
+
96
+ series = {
97
+ "SP500": SP500,
98
+ "BondsBaa": BondsBaa,
99
+ "T. Notes": TNotes,
100
+ "Inflation": Inflation,
101
+ }
102
+
103
+ df = pd.DataFrame(series)
104
+ df = df.truncate(before=frm, after=to)
105
+
106
+ means = df.mean()
107
+ stdev = df.std()
108
+ covar = df.cov()
109
+
110
+ mylog.vprint("means: (%)\n", means)
111
+ mylog.vprint("standard deviation: (%)\n", stdev)
112
+
113
+ # Convert to NumPy array and from percent to decimal.
114
+ means = np.array(means) / 100.0
115
+ stdev = np.array(stdev) / 100.0
116
+ covar = np.array(covar) / 10000.0
117
+ # Build correlation matrix by dividing by the stdev for each column and row.
118
+ corr = covar / stdev[:, None]
119
+ corr = corr.T / stdev[:, None]
120
+ # Fold round-off errors in proper bounds.
121
+ corr[corr > 1] = 1
122
+ corr[corr < -1] = -1
123
+ mylog.vprint("correlation matrix: \n\t\t%s" % str(corr).replace("\n", "\n\t\t"))
124
+
125
+ return means, stdev, corr, covar
126
+
127
+
128
+ class Rates(object):
129
+ """
130
+ Rates are stored in a 4-array in the following order:
131
+ Stocks, Bonds, Fixed assets, and Inflation.
132
+ Rate are stored in decimal, but the API is in percent.
133
+
134
+ To use this class first build an object:
135
+ ``r = Rates()``
136
+ then ``r.setMethod(...)``
137
+ then ``mySeries = r.genSeries()``
138
+ """
139
+
140
+ def __init__(self, mylog=None, seed=None):
141
+ """
142
+ Default constructor.
143
+
144
+ Args:
145
+ mylog: Logger instance (optional)
146
+ seed: Random seed for reproducible stochastic rates (optional)
147
+ """
148
+ if mylog is None:
149
+ self.mylog = log.Logger()
150
+ else:
151
+ self.mylog = mylog
152
+
153
+ # Store seed for stochastic rate generation
154
+ # Always use a Generator instance for thread safety and modern API
155
+ # If seed is None, default_rng() will use entropy/current time
156
+ self._seed = seed
157
+ self._rng = np.random.default_rng(seed)
158
+
159
+ # Default rates are average over last 30 years.
160
+ self._defRates = np.array([0.1101, 0.0736, 0.0503, 0.0251])
161
+
162
+ # Realistic rates are average predictions of major firms
163
+ # as reported by MorningStar in 2023.
164
+ self._optimisticRates = np.array([0.086, 0.049, 0.033, 0.025])
165
+
166
+ # Conservative rates.
167
+ self._conservRates = np.array([0.06, 0.04, 0.033, 0.028])
168
+
169
+ self.means = np.zeros((4))
170
+ self.stdev = np.zeros((4))
171
+ self.corr = np.zeros((4, 4))
172
+ self.covar = np.zeros((4, 4))
173
+
174
+ self.frm = FROM
175
+ self.to = TO
176
+
177
+ # Default values for rates.
178
+ self.setMethod("default")
179
+
180
+ def setMethod(self, method, frm=None, to=TO, values=None, stdev=None, corr=None):
181
+ """
182
+ Select the method to generate the annual rates of return
183
+ for the different classes of assets. Different methods include:
184
+ - default: average over last 30 years.
185
+ - optimistic: predictions from various firms reported by MorningStar.
186
+ - conservative: conservative values.
187
+ - user: user-selected fixed rates.
188
+ - historical: historical rates from 1928 to last year.
189
+ - historical average or means: average over historical data.
190
+ - histochastic: randomly generated from the statistical properties of a historical range.
191
+ - stochastic: randomly generated from means, standard deviation and optionally a correlation matrix.
192
+ The correlation matrix can be provided as a full matrix or
193
+ by only specifying the off-diagonal elements as a simple list
194
+ of (Nk*Nk - Nk)/2 values for Nk assets.
195
+ For 4 assets, this represents a list of 6 off-diagonal values.
196
+ """
197
+ if method not in [
198
+ "default",
199
+ "optimistic",
200
+ "conservative",
201
+ "user",
202
+ "historical",
203
+ "historical average",
204
+ "mean",
205
+ "stochastic",
206
+ "histochastic",
207
+ ]:
208
+ raise ValueError(f"Unknown rate selection method {method}.")
209
+
210
+ Nk = len(self._defRates)
211
+ # First process fixed methods relying on values.
212
+ if method == "default":
213
+ self.means = self._defRates
214
+ # self.mylog.vprint('Using default fixed rates values:', *[u.pc(k) for k in values])
215
+ self._setFixedRates(self._defRates)
216
+ elif method == "optimistic":
217
+ self.means = self._defRates
218
+ self.mylog.vprint("Using optimistic fixed rates values:", *[u.pc(k) for k in self.means])
219
+ self._setFixedRates(self._optimisticRates)
220
+ elif method == "conservative":
221
+ self.means = self._conservRates
222
+ self.mylog.vprint("Using conservative fixed rates values:", *[u.pc(k) for k in self.means])
223
+ self._setFixedRates(self._conservRates)
224
+ elif method == "user":
225
+ if values is None:
226
+ raise ValueError("Fixed values must be provided with the user option.")
227
+ if len(values) != Nk:
228
+ raise ValueError(f"Values must have {Nk} items.")
229
+ self.means = np.array(values, dtype=float)
230
+ # Convert percent to decimal for storing.
231
+ self.means /= 100.0
232
+ self.mylog.vprint("Setting rates using fixed user values:", *[u.pc(k) for k in self.means])
233
+ self._setFixedRates(self.means)
234
+ elif method == "stochastic":
235
+ if values is None:
236
+ raise ValueError("Mean values must be provided with the stochastic option.")
237
+ if stdev is None:
238
+ raise ValueError("Standard deviations must be provided with the stochastic option.")
239
+ if len(values) != Nk:
240
+ raise ValueError(f"Values must have {Nk} items.")
241
+ if len(stdev) != Nk:
242
+ raise ValueError(f"stdev must have {Nk} items.")
243
+ self.means = np.array(values, dtype=float)
244
+ self.stdev = np.array(stdev, dtype=float)
245
+ # Convert percent to decimal for storing.
246
+ self.means /= 100.0
247
+ self.stdev /= 100.0
248
+ # Build covariance matrix from standard deviation and correlation matrix.
249
+ if corr is None:
250
+ corrarr = np.identity(Nk)
251
+ else:
252
+ corrarr = np.array(corr)
253
+ # Full correlation matrix was provided.
254
+ if corrarr.shape == (Nk, Nk):
255
+ pass
256
+ # Only off-diagonal elements were provided: build full matrix.
257
+ elif corrarr.shape == ((Nk * (Nk - 1)) // 2,):
258
+ newcorr = np.identity(Nk)
259
+ x = 0
260
+ for i in range(Nk):
261
+ for j in range(i + 1, Nk):
262
+ newcorr[i, j] = corrarr[x]
263
+ newcorr[j, i] = corrarr[x]
264
+ x += 1
265
+ corrarr = newcorr
266
+ else:
267
+ raise RuntimeError(f"Unable to process correlation shape of {corrarr.shape}.")
268
+
269
+ self.corr = corrarr
270
+ if not np.array_equal(self.corr, self.corr.T):
271
+ raise ValueError("Correlation matrix must be symmetric.")
272
+ # Now build covariance matrix from stdev and correlation matrix.
273
+ # Multiply each row by a vector element-wise. Then columns.
274
+ covar = self.corr * self.stdev
275
+ self.covar = covar.T * self.stdev
276
+ self._rateMethod = self._stochRates
277
+ self.mylog.vprint("Setting rates using stochastic method with means:", *[u.pc(k) for k in self.means])
278
+ self.mylog.vprint("\t standard deviations:", *[u.pc(k) for k in self.stdev])
279
+ self.mylog.vprint("\t and correlation matrix:\n\t\t", str(self.corr).replace("\n", "\n\t\t"))
280
+ else:
281
+ # Then methods relying on historical data range.
282
+ if frm is None:
283
+ raise ValueError("From year must be provided with this option.")
284
+ if not (FROM <= frm <= TO):
285
+ raise ValueError(f"Lower range 'frm={frm}' out of bounds.")
286
+ if not (FROM <= to <= TO):
287
+ raise ValueError(f"Upper range 'to={to}' out of bounds.")
288
+ if not (frm < to):
289
+ raise ValueError("Unacceptable range.")
290
+ self.frm = frm
291
+ self.to = to
292
+
293
+ if method == "historical":
294
+ self.mylog.vprint(f"Using historical rates representing data from {frm} to {to}.")
295
+ self._rateMethod = self._histRates
296
+ elif method == "historical average" or method == "means":
297
+ self.mylog.vprint(f"Using average of rates from {frm} to {to}.")
298
+ self.means, self.stdev, self.corr, self.covar = getRatesDistributions(frm, to, self.mylog)
299
+ self._setFixedRates(self.means)
300
+ elif method == "histochastic":
301
+ self.mylog.vprint(f"Using histochastic rates derived from years {frm} to {to}.")
302
+ self._rateMethod = self._stochRates
303
+ self.means, self.stdev, self.corr, self.covar = getRatesDistributions(frm, to, self.mylog)
304
+ else:
305
+ raise ValueError(f"Method {method} not supported.")
306
+
307
+ self.method = method
308
+
309
+ return self.means, self.stdev, self.corr
310
+
311
+ def _setFixedRates(self, rates):
312
+ Nk = len(self._defRates)
313
+ if len(rates) != Nk:
314
+ raise ValueError(f"Rate list provided must have {Nk} entries.")
315
+ self._myRates = np.array(rates)
316
+ self._rateMethod = self._fixedRates
317
+
318
+ return
319
+
320
+ def genSeries(self, N):
321
+ """
322
+ Generate a series of Nx4 entries of rates representing S&P500,
323
+ corporate Baa bonds, 10-y treasury notes, and inflation,
324
+ respectively. If there are less than 'N' entries
325
+ in sub-series selected by 'setMethod()', values will be repeated
326
+ modulo the length of the sub-series.
327
+ """
328
+ rateSeries = np.zeros((N, 4))
329
+
330
+ # Convert years to indices.
331
+ ifrm = self.frm - FROM
332
+ ito = self.to - FROM
333
+
334
+ # Add one since bounds are inclusive.
335
+ span = ito - ifrm + 1
336
+
337
+ # Assign 4 values at the time.
338
+ for n in range(N):
339
+ rateSeries[n][:] = self._rateMethod((ifrm + (n % span)))[:]
340
+
341
+ return rateSeries
342
+
343
+ def _fixedRates(self, n):
344
+ """
345
+ Return rates provided.
346
+ For fixed rates, values are time-independent, and therefore
347
+ the 'n' argument is ignored.
348
+ """
349
+ # Fixed rates are stored in decimal.
350
+ return self._myRates
351
+
352
+ def _histRates(self, n):
353
+ """
354
+ Return an array of 4 values representing the historical rates
355
+ of stock, Corporate Baa bonds, Treasury notes, and inflation,
356
+ respectively.
357
+ """
358
+ hrates = np.array([SP500[n], BondsBaa[n], TNotes[n], Inflation[n]])
359
+
360
+ # Historical rates are stored in percent. Convert from percent to decimal.
361
+ return hrates / 100
362
+
363
+ def _stochRates(self, n):
364
+ """
365
+ Return an array of 4 values representing the historical rates
366
+ of stock, Corporate Baa bonds, Treasury notes, and inflation,
367
+ respectively. Values are pulled from normal distributions
368
+ having the same characteristics as the historical data for
369
+ the range of years selected. Argument 'n' is ignored.
370
+
371
+ But these variables need to be looked at together
372
+ through multivariate analysis. Code below accounts for
373
+ covariance between stocks, corp bonds, t-notes, and inflation.
374
+ """
375
+ srates = self._rng.multivariate_normal(self.means, self.covar)
376
+
377
+ return srates