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.
- rangekeeper/__init__.py +40 -0
- rangekeeper/api.py +88 -0
- rangekeeper/distribution.py +269 -0
- rangekeeper/dynamics/__init__.py +6 -0
- rangekeeper/dynamics/black_swan.py +77 -0
- rangekeeper/dynamics/cyclicality.py +414 -0
- rangekeeper/dynamics/market.py +328 -0
- rangekeeper/dynamics/noise.py +34 -0
- rangekeeper/dynamics/trend.py +79 -0
- rangekeeper/dynamics/volatility.py +140 -0
- rangekeeper/extrapolation.py +100 -0
- rangekeeper/flux.py +733 -0
- rangekeeper/graph.py +144 -0
- rangekeeper/measure.py +138 -0
- rangekeeper/models/__init__.py +5 -0
- rangekeeper/models/deterministic.py +143 -0
- rangekeeper/models/flexible.py +177 -0
- rangekeeper/models/linear.py +144 -0
- rangekeeper/models/linear_graph.py +171 -0
- rangekeeper/models/probabilistic.py +157 -0
- rangekeeper/periodicity.py +236 -0
- rangekeeper/policy.py +20 -0
- rangekeeper/projection.py +248 -0
- rangekeeper/segmentation.py +220 -0
- rangekeeper/setup.py +14 -0
- rangekeeper/space.py +42 -0
- rangekeeper/span.py +226 -0
- rangekeeper-0.1.0.dist-info/LICENSE +373 -0
- rangekeeper-0.1.0.dist-info/METADATA +32 -0
- rangekeeper-0.1.0.dist-info/RECORD +31 -0
- rangekeeper-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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)
|