rangekeeper 0.1.0__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.
@@ -0,0 +1,328 @@
1
+ from __future__ import annotations
2
+ import os
3
+
4
+ from typing import Generator
5
+ import copy
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ from numba import jit
10
+ import multiprocess
11
+
12
+ import rangekeeper as rk
13
+
14
+
15
+ class Market:
16
+ def __init__(
17
+ self,
18
+ sequence: pd.PeriodIndex,
19
+ trend: rk.dynamics.trend.Trend,
20
+ volatility: rk.dynamics.volatility.Volatility,
21
+ cyclicality: rk.dynamics.cyclicality.Cyclicality,
22
+ noise: rk.dynamics.noise.Noise,
23
+ black_swan: rk.dynamics.black_swan.BlackSwan):
24
+ """
25
+
26
+ """
27
+ self.sequence = sequence
28
+ self._sequence = rk.periodicity.offset_periodindex(
29
+ index=self.sequence,
30
+ offset_end=-1)
31
+
32
+ self.trend = trend
33
+ self.volatility = volatility
34
+ self.cyclicality = cyclicality
35
+
36
+ self.space_market = rk.flux.Flow(
37
+ movements=(
38
+ self.cyclicality.space_waveform.movements * self.volatility.movements),
39
+ name='Space Market')
40
+
41
+ self.asset_market = rk.flux.Flow(
42
+ movements=self.trend.cap_rate - self.cyclicality.asset_waveform.movements,
43
+ name='Asset Market')
44
+ """
45
+ Negative of actual cap rate cycle.
46
+ This makes this cycle directly reflect the asset pricing, as prices
47
+ are an inverse function of the cap rate. By taking the negative of the
48
+ actual cap rate, we therefore make it easier to envision the effect
49
+ on prices.
50
+
51
+ We're subtracting the cap rate cycle from the long-term mean cap rate,
52
+ rather than adding it, because we're entering the cap rate cycle
53
+ negative to the actual cap rate cycle, so that the cycle is up when
54
+ prices are up and down when prices are down. Recall that the actual
55
+ cap rate is inversely related to prices
56
+ (high cap rates ==> low prices & vice versa).
57
+
58
+ It's easier to think about cycles positively.
59
+ """
60
+
61
+ self.asset_true_value = rk.flux.Flow(
62
+ movements=self.space_market.movements / self.asset_market.movements,
63
+ name='Asset True Value')
64
+ """
65
+ This applies the capital market cycle to the rent history to obtain
66
+ the history of "true values," that is, the asset values (PVs) that
67
+ would prevail if there were no "noise" (transaction or observation
68
+ "error"), and at this point also ignoring "black swan" events.
69
+ """
70
+
71
+ self.space_market_price_factors = rk.flux.Flow(
72
+ movements=self.space_market.movements / self.trend.initial_value,
73
+ name='Space Market Price Factors')
74
+ """
75
+ This is the "true value" pricing factor for just the space market,
76
+ not reflecting the asset market cycle (cap rate). We have actually
77
+ already computed this, and we're here just making it into a ratio
78
+ of the initial rent. This series of Pricing factors will apply to
79
+ operating cash flows.
80
+ """
81
+
82
+ self.noise = noise
83
+ self.noisy_value = rk.flux.Flow(
84
+ movements=(1 + self.noise.generate().movements) * self.asset_true_value.movements,
85
+ name='Noisy Value')
86
+
87
+ self.black_swan = black_swan
88
+ self.historical_value = rk.flux.Flow(
89
+ movements=self.noisy_value.movements * (1 + self.black_swan.impact * self.black_swan.generate().movements),
90
+ name='Historical Value')
91
+
92
+ implied_cap_rate_data = (self.space_market.movements[1:].reset_index(drop=True) /
93
+ self.historical_value.movements[:-1].reset_index(drop=True))
94
+ # implied_cap_rate_data = pd.concat([implied_cap_rate_data, pd.Series(np.nan)], axis=0, join='outer')
95
+ self.implied_rev_cap_rate = rk.flux.Flow(
96
+ movements=pd.Series(
97
+ data=implied_cap_rate_data.values,
98
+ index=rk.periodicity.to_datestamps(period_index=self._sequence)),
99
+ name='Implied Cap Rate')
100
+ """
101
+ These are the forward-looking cap rates implied for each year of the
102
+ scenario. These will govern the reversion (resale) cash flows in the
103
+ DCF model of PV.
104
+ """
105
+
106
+ returns_data = (self.historical_value.movements[1:].reset_index(drop=True) /
107
+ self.historical_value.movements[:-1].reset_index(drop=True)) - 1
108
+ # returns_data = pd.concat([returns_data, pd.Series(np.nan)], axis=0, join='outer')
109
+ self.returns = rk.flux.Flow(
110
+ movements=pd.Series(
111
+ data=returns_data.values,
112
+ index=rk.periodicity.to_datestamps(period_index=self._sequence)),
113
+ name='Returns')
114
+
115
+ """
116
+ This is the implied capital returns series for the scenario. It reflects
117
+ the overall pricing dynamics and uncertainty in the property value over
118
+ time.
119
+ """
120
+
121
+ @classmethod
122
+ def _from_args(
123
+ cls,
124
+ args: tuple) -> Market:
125
+ sequence, trend, volatility, cyclicality, noise, black_swan = args
126
+ return cls(
127
+ sequence=sequence,
128
+ trend=trend,
129
+ volatility=volatility,
130
+ cyclicality=cyclicality,
131
+ noise=noise,
132
+ black_swan=black_swan)
133
+
134
+ @classmethod
135
+ def from_likelihoods(
136
+ cls,
137
+ sequence: pd.PeriodIndex,
138
+ trends: [rk.dynamics.trend.Trend],
139
+ volatilities: [rk.dynamics.volatility.Volatility],
140
+ cyclicalities: [rk.dynamics.cyclicality.Cyclicality],
141
+ noise: rk.dynamics.noise.Noise,
142
+ black_swan: rk.dynamics.black_swan.BlackSwan) -> [Market]:
143
+
144
+ pool = multiprocess.Pool(os.cpu_count())
145
+
146
+ args = [(sequence, trend, volatility, cyclicality, noise, black_swan)
147
+ for (trend, volatility, cyclicality)
148
+ in zip(trends, volatilities, cyclicalities)]
149
+
150
+ return pool.map(cls._from_args, args)
151
+
152
+ # def __init__(
153
+ # self,
154
+ # sequence: pd.PeriodIndex,
155
+ # market_params: dict,
156
+ # volatility_params: dict,
157
+ # cyclicality_params: dict):
158
+ # """
159
+ #
160
+ # """
161
+ # self.span = rk.periodicity.to_span(period_index=sequence)
162
+ # self._sequence = rk.periodicity.offset_periodindex(
163
+ # index=sequence,
164
+ # offset_end=-1)
165
+ #
166
+ # self.trend = market_params['trend']
167
+ # self.initial_rent = market_params['initial_rent']
168
+ # self.market_trend = rk.flux.Flow.from_projection(
169
+ # name='Market Trend',
170
+ # value=self.initial_rent,
171
+ # proj=rk.projection.Extrapolation(
172
+ # form=self.trend,
173
+ # sequence=sequence))
174
+ #
175
+ # self.market_volatility = rk.dynamics.volatility.Volatility(
176
+ # trend=self.trend,
177
+ # initial_rent=self.initial_rent,
178
+ # volatility_per_period=volatility_params['volatility_per_period'],
179
+ # mean_reversion_param=volatility_params['mean_reversion_param'],
180
+ # autoregression_param=volatility_params['autoregression_param'],
181
+ # sequence=sequence)
182
+ #
183
+ # self.market_cyclicality = rk.dynamics.cyclicality.Cyclicality(
184
+ # space_cycle_period=cyclicality_params['space_cycle_period'],
185
+ # space_cycle_phase=cyclicality_params['space_cycle_phase'],
186
+ # space_cycle_amplitude=cyclicality_params['space_cycle_amplitude'],
187
+ # space_cycle_asymmetric_parameter=cyclicality_params['space_cycle_asymmetric_parameter'],
188
+ # asset_cycle_period=cyclicality_params['asset_cycle_period'],
189
+ # asset_cycle_phase=cyclicality_params['asset_cycle_phase'],
190
+ # asset_cycle_amplitude=cyclicality_params['asset_cycle_amplitude'],
191
+ # asset_cycle_asymmetric_parameter=cyclicality_params['asset_cycle_asymmetric_parameter'],
192
+ # sequence=sequence)
193
+ #
194
+ # self.space_market = rk.flux.Flow(
195
+ # movements=(
196
+ # self.market_cyclicality.space_waveform.movements * self.market_volatility.cumulative_volatility.movements),
197
+ # name='Space Market')
198
+ #
199
+ # self.asset_market = rk.flux.Flow(
200
+ # movements=market_params['cap_rate'] - self.market_cyclicality.asset_waveform.movements,
201
+ # name='Asset Market')
202
+ # """
203
+ # Negative of actual cap rate cycle.
204
+ # This makes this cycle directly reflect the asset pricing, as prices
205
+ # are an inverse function of the cap rate. By taking the negative of the
206
+ # actual cap rate, we therefore make it easier to envision the effect
207
+ # on prices.
208
+ #
209
+ # We're subtracting the cap rate cycle from the long-term mean cap rate,
210
+ # rather than adding it, because we're entering the cap rate cycle
211
+ # negative to the actual cap rate cycle, so that the cycle is up when
212
+ # prices are up and down when prices are down. Recall that the actual
213
+ # cap rate is inversely related to prices
214
+ # (high cap rates ==> low prices & vice versa).
215
+ #
216
+ # It's easier to think about cycles positively.
217
+ # """
218
+ #
219
+ # self.asset_true_value = rk.flux.Flow(
220
+ # movements=self.space_market.movements / self.asset_market.movements,
221
+ # name='Asset True Value')
222
+ # """
223
+ # This applies the capital market cycle to the rent history to obtain
224
+ # the history of "true values," that is, the asset values (PVs) that
225
+ # would prevail if there were no "noise" (transaction or observation
226
+ # "error"), and at this point also ignoring "black swan" events.
227
+ # """
228
+ #
229
+ # self.space_market_price_factors = rk.flux.Flow(
230
+ # movements=self.space_market.movements / self.initial_rent,
231
+ # name='Space Market Price Factors')
232
+ # """
233
+ # This is the "true value" pricing factor for just the space market,
234
+ # not reflecting the asset market cycle (cap rate). We have actually
235
+ # already computed this, and we're here just making it into a ratio
236
+ # of the initial rent. This series of Pricing factors will apply to
237
+ # operating cash flows.
238
+ # """
239
+ #
240
+ # self.noise_values = [rk.distribution.Symmetric(
241
+ # distribution_type=rk.distribution.Type.PERT,
242
+ # mean=0.,
243
+ # residual=market_params['noise_residual']
244
+ # ).distribution().sample() for _ in range(sequence.size)]
245
+ # self.noise = rk.flux.Flow(
246
+ # movements=pd.Series(
247
+ # data=self.noise_values,
248
+ # index=rk.periodicity.to_datestamps(period_index=sequence)),
249
+ # name='Noise')
250
+ # """
251
+ # This is a symmetric PERT distribution. Note that a random realization
252
+ # of noise is generated each period, but it is applied to the value
253
+ # LEVELs (not to the returns or increments), hence, noise does not
254
+ # accumulate over time in the levels (unlike volatility). In actuality
255
+ # noise would be "realized" only when/if the asset is sold, or its value
256
+ # is formally estimated. But of course, in principle, an asset sale
257
+ # (or formal value estimation) could take place at any time.
258
+ # """
259
+ #
260
+ # self.noisy_value = rk.flux.Flow(
261
+ # movements=(1 + self.noise.movements) * self.asset_true_value.movements,
262
+ # name='Noisy Value')
263
+ # """
264
+ # In this column the noise we generated in the previous column is
265
+ # applied to the noise-free value history in the "TrueValue" column,
266
+ # to generate the actually-observable value each year (excluding any
267
+ # "black swan" impact).
268
+ # """
269
+ #
270
+ # black_swan_effect_data = pd.Series(
271
+ # data=self.calculate_black_swan_effects(
272
+ # likelihood=market_params['black_swan_likelihood'],
273
+ # dissipation_rate=market_params['black_swan_dissipation_rate'],
274
+ # events=market_params['black_swan_prob_dist'].sample(sequence.size)),
275
+ # index=rk.periodicity.to_datestamps(period_index=sequence))
276
+ # self.black_swan_effect = rk.flux.Flow(
277
+ # movements=black_swan_effect_data,
278
+ # name='Black Swan Effect')
279
+ # """
280
+ # This random variable will determine whether a "black swan" event
281
+ # occurs in any given year. We ensure that no more than one black
282
+ # swan will occur in the 24-yr history, as black swans are by definition
283
+ # rare events.
284
+ #
285
+ # This column causes the effect of the Black Swan event to dissipate
286
+ # over time, geometrically, at the same mean reversion rate as is
287
+ # applied in general to the rents (entered in co.F).
288
+ # """
289
+ #
290
+ # self.historical_value = rk.flux.Flow(
291
+ # movements=self.noisy_value.movements * (
292
+ # 1 + market_params['black_swan_impact'] * self.black_swan_effect.movements),
293
+ # name='Historical Value')
294
+ # """
295
+ # This is another source or type of uncertainty in real estate pricing.
296
+ # This column will apply the given "black swan" result, but then reduces
297
+ # the subsequent impact of the event gradually as mean-reversion
298
+ # takes effect.
299
+ # """
300
+ #
301
+ # implied_cap_rate_data = (self.space_market.movements[1:].reset_index(drop=True) /
302
+ # self.historical_value.movements[:-1].reset_index(drop=True))
303
+ # # implied_cap_rate_data = pd.concat([implied_cap_rate_data, pd.Series(np.nan)], axis=0, join='outer')
304
+ # self.implied_rev_cap_rate = rk.flux.Flow(
305
+ # movements=pd.Series(
306
+ # data=implied_cap_rate_data.values,
307
+ # index=rk.periodicity.to_datestamps(period_index=self._sequence)),
308
+ # name='Implied Cap Rate')
309
+ # """
310
+ # These are the forward-looking cap rates implied for each year of the
311
+ # scenario. These will govern the reversion (resale) cash flows in the
312
+ # DCF model of PV.
313
+ # """
314
+ #
315
+ # returns_data = (self.historical_value.movements[1:].reset_index(drop=True) /
316
+ # self.historical_value.movements[:-1].reset_index(drop=True)) - 1
317
+ # # returns_data = pd.concat([returns_data, pd.Series(np.nan)], axis=0, join='outer')
318
+ # self.returns = rk.flux.Flow(
319
+ # movements=pd.Series(
320
+ # data=returns_data.values,
321
+ # index=rk.periodicity.to_datestamps(period_index=self._sequence)),
322
+ # name='Returns')
323
+ #
324
+ # """
325
+ # This is the implied capital returns series for the scenario. It reflects
326
+ # the overall pricing dynamics and uncertainty in the property value over
327
+ # time.
328
+ # """
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import pandas as pd
4
+
5
+ import rangekeeper as rk
6
+
7
+
8
+ class Noise:
9
+ def __init__(
10
+ self,
11
+ sequence: pd.PeriodIndex,
12
+ noise_dist: rk.distribution.Form):
13
+ """
14
+ Note that a random realization of noise is generated each period, but it is applied to the value
15
+ LEVELs (not to the returns or increments), hence, noise does not
16
+ accumulate over time in the levels (unlike volatility). In actuality
17
+ noise would be "realized" only when/if the asset is sold, or its value
18
+ is formally estimated. But of course, in principle, an asset sale
19
+ (or formal value estimation) could take place at any time.
20
+
21
+ In this column the noise we generated in the previous column is
22
+ applied to the noise-free value history in the "TrueValue" column,
23
+ to generate the actually-observable value each year (excluding any
24
+ "black swan" impact).
25
+ """
26
+ self.sequence = sequence
27
+ self.noise_dist = noise_dist
28
+
29
+ def generate(self) -> rk.flux.Flow:
30
+ return rk.flux.Flow(
31
+ movements=pd.Series(
32
+ data=self.noise_dist.sample(size=self.sequence.size),
33
+ index=rk.periodicity.to_datestamps(period_index=self.sequence)),
34
+ name='Noise')
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+ import os
3
+
4
+ from typing import Optional, Generator
5
+
6
+ import pandas as pd
7
+ import multiprocess
8
+
9
+ import rangekeeper as rk
10
+
11
+
12
+ class Trend(rk.flux.Flow):
13
+ growth_rate: float
14
+ cap_rate: float
15
+ initial_value: float
16
+ initial_price_factor: float
17
+
18
+ def __init__(
19
+ self,
20
+ sequence: pd.PeriodIndex,
21
+ growth_rate: float,
22
+ cap_rate: float,
23
+ initial_value: Optional[float] = None,
24
+ initial_price_factor: float = 1.):
25
+ self.sequence = sequence
26
+ self.cap_rate = cap_rate
27
+ self.growth_rate = growth_rate
28
+ self.initial_value = initial_price_factor * self.cap_rate if initial_value is None else initial_value
29
+ self.initial_price_factor = initial_price_factor
30
+
31
+ self.proj = rk.projection.Extrapolation(
32
+ form=rk.extrapolation.Compounding(rate=self.growth_rate),
33
+ sequence=self.sequence)
34
+
35
+ super().__init__(
36
+ name='Market Trend',
37
+ movements=self.proj.terms() * self.initial_value)
38
+
39
+ @classmethod
40
+ def _from_args(
41
+ cls,
42
+ args: tuple) -> Trend:
43
+ sequence, cap_rate, growth_rate, initial_value, initial_price_factor = args
44
+ return cls(
45
+ sequence=sequence,
46
+ cap_rate=cap_rate,
47
+ growth_rate=growth_rate,
48
+ initial_value=initial_value,
49
+ initial_price_factor=initial_price_factor)
50
+ @classmethod
51
+ def from_likelihoods(
52
+ cls,
53
+ sequence: pd.PeriodIndex,
54
+ cap_rate: float,
55
+ growth_rate_dist: rk.distribution,
56
+ initial_value_dist: rk.distribution,
57
+ initial_price_factor: float = 1.,
58
+ iterations: int = 1) -> [Trend]:
59
+ growth_rates = growth_rate_dist.sample(size=iterations)
60
+ initial_values = initial_value_dist.sample(size=iterations)
61
+
62
+ pool = multiprocess.Pool(os.cpu_count())
63
+
64
+ args = [
65
+ (sequence,
66
+ cap_rate,
67
+ growth_rate,
68
+ initial_value,
69
+ initial_price_factor)
70
+ for (growth_rate, initial_value)
71
+ in zip(growth_rates, initial_values)
72
+ ]
73
+
74
+ results = pool.map(cls._from_args, args)
75
+ if iterations == 1:
76
+ return results[0]
77
+ else:
78
+ return results
79
+
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+ import os
3
+
4
+ from typing import Generator
5
+ import pandas as pd
6
+ import scipy as sp
7
+ from numba import jit
8
+ import numba
9
+ import multiprocess
10
+
11
+ import rangekeeper as rk
12
+
13
+
14
+ class Volatility(rk.flux.Flow):
15
+ def __init__(
16
+ self,
17
+ trend: rk.dynamics.trend.Trend,
18
+ volatility_per_period: float,
19
+ autoregression_param: float,
20
+ mean_reversion_param: float,
21
+ sequence: pd.PeriodIndex):
22
+ """
23
+ This is a normal (Gaussian) distribution.
24
+ Note that volatility is realized (new random increment is generated) in EACH period,
25
+ so that this "risk" outcome accumulates in the history of rent levels.
26
+ But this is just the volatility in the innovations; if there is autoregression then that will also affect the annual volatility.
27
+ Cycles will also affect the average volatility observed empirically across the scenario.
28
+ volatility_per_period = .08
29
+ """
30
+
31
+ volatilities = pd.Series(
32
+ data=[sp.special.ndtri(rk.distribution.Uniform(lower=0, range=1).sample()[0]) * volatility_per_period
33
+ for x in range(sequence.size)],
34
+ # the ndtri() function replicates excel's NORMSINV().
35
+ # See https://stackoverflow.com/questions/20626994/how-to-calculate-the-inverse-of-the-normal-cumulative-distribution-function-in-p/20627638
36
+ index=rk.periodicity.to_datestamps(period_index=sequence))
37
+
38
+ self.volatility = rk.flux.Flow(
39
+ movements=volatilities,
40
+ name='Volatility')
41
+
42
+ volatility = numba.typed.List()
43
+ [volatility.append(x) for x in self.volatility.movements.to_list()]
44
+
45
+ autoregression_return_data = self.calculate_autoregression(
46
+ parameter=autoregression_param,
47
+ volatility=volatility)
48
+ self.autoregressive_returns = rk.flux.Flow(
49
+ movements=pd.Series(
50
+ data=autoregression_return_data,
51
+ index=self.volatility.movements.index),
52
+ name='Autoregressive Returns')
53
+
54
+ trend_values = numba.typed.List()
55
+ [trend_values.append(x) for x in trend.movements.to_list()]
56
+
57
+ ar_returns = numba.typed.List()
58
+ [ar_returns.append(x) for x in self.autoregressive_returns.movements.to_list()]
59
+
60
+ cumulative_volatility_data = self.calculate_volatility_accumulation(
61
+ trend_rate=trend.growth_rate,
62
+ trend_values=trend_values,
63
+ mr_parameter=mean_reversion_param,
64
+ ar_returns=ar_returns)
65
+
66
+ movements = pd.Series(
67
+ data=cumulative_volatility_data,
68
+ index=rk.periodicity.to_datestamps(period_index=sequence))
69
+
70
+ super().__init__(
71
+ name='Cumulative Volatility',
72
+ movements=movements)
73
+
74
+ @classmethod
75
+ def _from_args(
76
+ cls,
77
+ args: tuple) -> Volatility:
78
+ trend, volatility_per_period, autoregression_param, mean_reversion_param, sequence = args
79
+ return cls(
80
+ trend=trend,
81
+ volatility_per_period=volatility_per_period,
82
+ autoregression_param=autoregression_param,
83
+ mean_reversion_param=mean_reversion_param,
84
+ sequence=sequence)
85
+
86
+ @classmethod
87
+ def from_trends(
88
+ cls,
89
+ trends: [rk.dynamics.trend.Trend],
90
+ volatility_per_period: float,
91
+ autoregression_param: float,
92
+ mean_reversion_param: float,
93
+ sequence: pd.PeriodIndex) -> [Volatility]:
94
+
95
+ pool = multiprocess.Pool(os.cpu_count())
96
+
97
+ args = [
98
+ (trend,
99
+ volatility_per_period,
100
+ autoregression_param,
101
+ mean_reversion_param,
102
+ sequence)
103
+ for trend in trends]
104
+
105
+ return pool.map(cls._from_args, args)
106
+
107
+ @staticmethod
108
+ @jit(nopython=True)
109
+ def calculate_autoregression(
110
+ parameter: float,
111
+ volatility: numba.typed.List):
112
+ ar_returns = numba.typed.List()
113
+ for i in range(len(volatility)):
114
+ if i == 0:
115
+ ar_returns.append(volatility[0])
116
+ else:
117
+ ar_returns.append(volatility[i] + (parameter * ar_returns[i - 1]))
118
+ return ar_returns
119
+
120
+ @staticmethod
121
+ @jit(nopython=True)
122
+ def calculate_volatility_accumulation(
123
+ trend_rate: float,
124
+ trend_values: numba.typed.List,
125
+ mr_parameter: float,
126
+ ar_returns: numba.typed.List):
127
+ """
128
+ Cumulative Volatility:
129
+ Accumulate the volatility generated in the previous column, also reflecting the mean reversion tendency.
130
+ """
131
+ accumulated_volatility = numba.typed.List()
132
+ for i in range(len(trend_values)):
133
+ if i == 0:
134
+ accumulated_volatility.append(trend_values[0])
135
+ else:
136
+ accumulated_volatility.append(
137
+ (accumulated_volatility[i - 1] * (1 + trend_rate + ar_returns[i])) +
138
+ (mr_parameter * (trend_values[i - 1] - accumulated_volatility[i - 1]))
139
+ )
140
+ return accumulated_volatility
@@ -0,0 +1,100 @@
1
+ from typing import Optional
2
+
3
+ import enum
4
+ import numpy as np
5
+ import pandas as pd
6
+ import scipy.stats as ss
7
+ from abc import abstractmethod
8
+
9
+
10
+ class Type(enum.Enum):
11
+ STRAIGHT_LINE = 'Straight Line' # A constant-change (linearly growing (or decaying)) projection form, originating from an initial value.
12
+ COMPOUNDING = 'Compounding' # An exponentially growing (compounding) or decaying projection at a specified rate per period.
13
+ RECURRING = 'Recurring' # A projection form that repeats a specified sequence of values.
14
+ DYNAMIC = 'Dynamic' # A projection form generated by a stochastic process.
15
+
16
+
17
+ class Form:
18
+ type: Type
19
+ """
20
+ A specified methodology, logic, or algorithm for extrapolating a value to a sequence.
21
+ """
22
+
23
+ @abstractmethod
24
+ def terms(
25
+ self,
26
+ sequence: pd.RangeIndex) -> [float]:
27
+ """
28
+ Returns the set of terms that define the projection's form at each value in the sequence.
29
+ """
30
+ pass
31
+
32
+
33
+ class StraightLine(Form):
34
+ slope: float
35
+ """
36
+ A constant-change (linearly growing (or decaying)) projection form, originating from an initial value.
37
+ To calculate the factor, the projection is modelled as a linear function of (slope * period) + 1
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ slope: float):
43
+ self.type = Type.STRAIGHT_LINE
44
+ self.slope = slope
45
+
46
+ def terms(
47
+ self,
48
+ sequence: pd.RangeIndex) -> [float]:
49
+ """
50
+ Returns the additive terms to the projection at each value in the sequence.
51
+ """
52
+ return [(self.slope * index) for index in sequence]
53
+
54
+
55
+ class Recurring(StraightLine):
56
+ def __init__(self):
57
+ super().__init__(slope=0)
58
+ self.type = Type.RECURRING
59
+
60
+
61
+ class Compounding(Form):
62
+ rate: float
63
+ """
64
+ An exponentially growing (compounding) or decaying projection at a specified rate per period.
65
+ To calculate the factor, the projection is modelled as an exponential function of (1 + rate) ** period
66
+ """
67
+
68
+ def __init__(
69
+ self,
70
+ rate: float):
71
+ self.type = Type.COMPOUNDING
72
+ self.rate = rate
73
+
74
+ def terms(
75
+ self,
76
+ sequence: pd.RangeIndex) -> [float]:
77
+ """
78
+ Returns the multiplicative factors of the projection's form at each value in the sequence.
79
+ """
80
+ return [np.power((1 + self.rate), index) for index in sequence]
81
+
82
+
83
+ class Dynamic(Form):
84
+ series: pd.Series
85
+
86
+ def __init__(
87
+ self,
88
+ series: pd.Series):
89
+ self.type = Type.DYNAMIC
90
+ self.series = series
91
+
92
+ def terms(
93
+ self,
94
+ sequence: pd.RangeIndex) -> [float]:
95
+ """
96
+ Returns the multiplicative factors of the projection's form at each value in the sequence.
97
+ """
98
+ if self.series.index.size != sequence.size:
99
+ raise ValueError("Error: series and sequence must match in size")
100
+ return list(self.series.values)