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,414 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import os
|
|
3
|
+
import math
|
|
4
|
+
from typing import Generator
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from numba import jit
|
|
9
|
+
import multiprocess
|
|
10
|
+
|
|
11
|
+
import rangekeeper as rk
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Enumerate:
|
|
15
|
+
@staticmethod
|
|
16
|
+
# @jit(nopython=True)
|
|
17
|
+
def sine(
|
|
18
|
+
period: float,
|
|
19
|
+
phase: float,
|
|
20
|
+
amplitude: float,
|
|
21
|
+
num_periods: int):
|
|
22
|
+
"""
|
|
23
|
+
Generate a sine wave from the parameters.
|
|
24
|
+
The conventional, symmetric cycle is a simple sine function, parameterized:
|
|
25
|
+
y = amplitude * sin((t - phase) * (2 * pi / period))
|
|
26
|
+
"""
|
|
27
|
+
data = []
|
|
28
|
+
for i in range(num_periods):
|
|
29
|
+
data.append(amplitude *
|
|
30
|
+
np.sin(
|
|
31
|
+
(i - phase) *
|
|
32
|
+
(2 * np.pi / period)
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
return data
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def asymmetric_sine(
|
|
39
|
+
period: float,
|
|
40
|
+
phase: float,
|
|
41
|
+
amplitude: float,
|
|
42
|
+
parameter: float,
|
|
43
|
+
num_periods: int,
|
|
44
|
+
precision: float,
|
|
45
|
+
bound: float):
|
|
46
|
+
"""
|
|
47
|
+
Generate a smoothed sinusoid asymmetric (sawtooth) wave.
|
|
48
|
+
Based on the solution to the expression: f(x) = sin(x - f(x)),
|
|
49
|
+
with additional parameterization of period, phase, amplitude, and shear,
|
|
50
|
+
where shear is defined as a parameter between 0 and 1 (1 being most asymmetric)
|
|
51
|
+
|
|
52
|
+
Because the formula is recursive, we approximate the solution by
|
|
53
|
+
solving sin(x + c) - x = 0, for x, as in this answer https://math.stackexchange.com/a/2645080
|
|
54
|
+
to https://math.stackexchange.com/q/2644982/999815
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
if parameter == 0:
|
|
58
|
+
return Enumerate.sine(
|
|
59
|
+
period=period,
|
|
60
|
+
phase=phase,
|
|
61
|
+
amplitude=amplitude,
|
|
62
|
+
num_periods=num_periods)
|
|
63
|
+
|
|
64
|
+
@jit(nopython=True)
|
|
65
|
+
def f(x):
|
|
66
|
+
upper = 1 + bound
|
|
67
|
+
lower = -upper
|
|
68
|
+
while upper - lower > precision:
|
|
69
|
+
mid = (lower + upper) / 2
|
|
70
|
+
if -math.sin((x - phase) * (2 * np.pi / period) + mid) - ((1 / parameter) * mid) > 0:
|
|
71
|
+
lower = mid
|
|
72
|
+
else:
|
|
73
|
+
upper = mid
|
|
74
|
+
return (lower + upper) / 2 * (amplitude * (1 / -parameter))
|
|
75
|
+
|
|
76
|
+
return list(map(f, range(num_periods)))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Cycle:
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
period: float,
|
|
83
|
+
phase: float,
|
|
84
|
+
amplitude: float):
|
|
85
|
+
"""
|
|
86
|
+
Parameters that define a cyclical time series
|
|
87
|
+
"""
|
|
88
|
+
self.period = period
|
|
89
|
+
self.phase = phase
|
|
90
|
+
self.amplitude = amplitude
|
|
91
|
+
|
|
92
|
+
def __str__(self):
|
|
93
|
+
string = ""
|
|
94
|
+
string += "Period: " + str(self.period) + "\n"
|
|
95
|
+
string += "Phase: " + str(self.phase) + "\n"
|
|
96
|
+
string += "Amplitude: " + str(self.amplitude) + "\n"
|
|
97
|
+
return string
|
|
98
|
+
|
|
99
|
+
def sine(
|
|
100
|
+
self,
|
|
101
|
+
index: pd.PeriodIndex,
|
|
102
|
+
name: str = "sine_cycle"):
|
|
103
|
+
data = Enumerate.sine(
|
|
104
|
+
period=self.period,
|
|
105
|
+
phase=self.phase,
|
|
106
|
+
amplitude=self.amplitude,
|
|
107
|
+
num_periods=index.size)
|
|
108
|
+
return rk.flux.Flow(
|
|
109
|
+
movements=pd.Series(
|
|
110
|
+
data=data,
|
|
111
|
+
index=rk.periodicity.to_datestamps(period_index=index)),
|
|
112
|
+
name=name)
|
|
113
|
+
|
|
114
|
+
def asymmetric_sine(
|
|
115
|
+
self,
|
|
116
|
+
parameter: float,
|
|
117
|
+
index: pd.PeriodIndex,
|
|
118
|
+
precision: float = 1e-8,
|
|
119
|
+
bound: float = 1e-1,
|
|
120
|
+
name: str = "asymmetric_sine_cycle") -> rk.flux.Flow:
|
|
121
|
+
data = Enumerate.asymmetric_sine(
|
|
122
|
+
period=self.period,
|
|
123
|
+
phase=self.phase,
|
|
124
|
+
amplitude=self.amplitude,
|
|
125
|
+
parameter=parameter,
|
|
126
|
+
num_periods=index.size,
|
|
127
|
+
precision=precision,
|
|
128
|
+
bound=bound)
|
|
129
|
+
return rk.flux.Flow(
|
|
130
|
+
movements=pd.Series(
|
|
131
|
+
data=data,
|
|
132
|
+
index=rk.periodicity.to_datestamps(period_index=index)),
|
|
133
|
+
# units=measure.scalar,
|
|
134
|
+
name=name)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class Cyclicality:
|
|
138
|
+
space_waveform: rk.flux.Flow
|
|
139
|
+
asset_waveform: rk.flux.Flow
|
|
140
|
+
|
|
141
|
+
def __init__(
|
|
142
|
+
self,
|
|
143
|
+
sequence: pd.PeriodIndex,
|
|
144
|
+
space_cycle: Cycle,
|
|
145
|
+
asset_cycle: Cycle,
|
|
146
|
+
space_cycle_asymmetric_parameter: float = None,
|
|
147
|
+
asset_cycle_asymmetric_parameter: float = None):
|
|
148
|
+
"""
|
|
149
|
+
This models a (possibly somewhat) predictable long-term cycle in the
|
|
150
|
+
pricing. In fact, there are two cycles, not necessarily in sync,
|
|
151
|
+
one for the space market (rents) and another separate cycle for the
|
|
152
|
+
asset market (capital flows), the latter reflected by the cap rate.
|
|
153
|
+
|
|
154
|
+
Cycles are modeled by generalized asymmetric sine functions.
|
|
155
|
+
"""
|
|
156
|
+
self.space_cycle = space_cycle
|
|
157
|
+
self.asset_cycle = asset_cycle
|
|
158
|
+
|
|
159
|
+
if space_cycle_asymmetric_parameter == 0:
|
|
160
|
+
space_waveform = self.space_cycle.sine(
|
|
161
|
+
index=sequence,
|
|
162
|
+
name='Space Cycle Waveform')
|
|
163
|
+
else:
|
|
164
|
+
space_waveform = self.space_cycle.asymmetric_sine(
|
|
165
|
+
index=sequence,
|
|
166
|
+
name='Space Cycle Waveform',
|
|
167
|
+
parameter=space_cycle_asymmetric_parameter)
|
|
168
|
+
self.space_waveform = rk.flux.Flow(
|
|
169
|
+
name=space_waveform.name,
|
|
170
|
+
movements=(1 + space_waveform.movements),
|
|
171
|
+
units=space_waveform.units)
|
|
172
|
+
|
|
173
|
+
if asset_cycle_asymmetric_parameter == 0:
|
|
174
|
+
self.asset_waveform = self.asset_cycle.sine(
|
|
175
|
+
index=sequence,
|
|
176
|
+
name='Asset Cycle Waveform')
|
|
177
|
+
else:
|
|
178
|
+
self.asset_waveform = self.asset_cycle.asymmetric_sine(
|
|
179
|
+
index=sequence,
|
|
180
|
+
name='Asset Cycle Waveform',
|
|
181
|
+
parameter=asset_cycle_asymmetric_parameter)
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def from_params(
|
|
185
|
+
cls,
|
|
186
|
+
space_cycle_period: float,
|
|
187
|
+
space_cycle_phase: float,
|
|
188
|
+
space_cycle_amplitude: float,
|
|
189
|
+
asset_cycle_period: float,
|
|
190
|
+
asset_cycle_phase: float,
|
|
191
|
+
asset_cycle_amplitude: float,
|
|
192
|
+
space_cycle_asymmetric_parameter: float,
|
|
193
|
+
asset_cycle_asymmetric_parameter: float,
|
|
194
|
+
sequence: pd.PeriodIndex) -> Cyclicality:
|
|
195
|
+
"""
|
|
196
|
+
This models a (possibly somewhat) predictable long-term cycle in the
|
|
197
|
+
pricing. In fact, there are two cycles, not necessarily in sync,
|
|
198
|
+
one for the space market (rents) and another separate cycle for the
|
|
199
|
+
asset market (capital flows), the latter reflected by the cap rate.
|
|
200
|
+
|
|
201
|
+
Cycles are modeled by generalized sine functions governed by the given
|
|
202
|
+
input period, amplitude, and span.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
space_cycle = Cycle(
|
|
206
|
+
period=space_cycle_period,
|
|
207
|
+
phase=space_cycle_phase,
|
|
208
|
+
amplitude=space_cycle_amplitude)
|
|
209
|
+
"""
|
|
210
|
+
In the U.S. the real estate market cycle period seems to be in the range of 10
|
|
211
|
+
to 20 years.
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
The space cycle amplitude is modelled as a fraction of the mid-cycle
|
|
215
|
+
level. Historically in the U.S., such cycles in investment property have
|
|
216
|
+
been as much as 50% or more in some locations in the rental market
|
|
217
|
+
(including both rent prices & occupancy effect and considering the
|
|
218
|
+
leverage that fixed operating expenses have on the bottom-line net cash
|
|
219
|
+
flow). The Pricing Factors in this model apply to net cash flows, not
|
|
220
|
+
just top-line potential gross rental revenue.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
asset_cycle = Cycle(
|
|
224
|
+
period=asset_cycle_period,
|
|
225
|
+
phase=asset_cycle_phase,
|
|
226
|
+
amplitude=asset_cycle_amplitude)
|
|
227
|
+
"""
|
|
228
|
+
Negative of actual cap rate cycle. This makes this cycle directly
|
|
229
|
+
reflect the asset pricing, as prices are an inverse function of the
|
|
230
|
+
cap rate. By taking the negative of the actual cap rate, we therefore
|
|
231
|
+
make it easier to envision the effect on prices.
|
|
232
|
+
|
|
233
|
+
Since we input this cycle as the negative of the actual cap rate cycle,
|
|
234
|
+
you can think of the phase in the same way as the space market phase.
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
return cls(
|
|
238
|
+
sequence=sequence,
|
|
239
|
+
space_cycle=space_cycle,
|
|
240
|
+
asset_cycle=asset_cycle,
|
|
241
|
+
space_cycle_asymmetric_parameter=space_cycle_asymmetric_parameter,
|
|
242
|
+
asset_cycle_asymmetric_parameter=asset_cycle_asymmetric_parameter)
|
|
243
|
+
|
|
244
|
+
@classmethod
|
|
245
|
+
def from_estimates(
|
|
246
|
+
cls,
|
|
247
|
+
space_cycle_phase_prop: float,
|
|
248
|
+
space_cycle_period: float,
|
|
249
|
+
space_cycle_height: float,
|
|
250
|
+
asset_cycle_period_diff: float,
|
|
251
|
+
asset_cycle_phase_diff_prop: float,
|
|
252
|
+
asset_cycle_amplitude: float,
|
|
253
|
+
space_cycle_asymmetric_parameter: float,
|
|
254
|
+
asset_cycle_asymmetric_parameter: float,
|
|
255
|
+
sequence: pd.PeriodIndex) -> Cyclicality:
|
|
256
|
+
|
|
257
|
+
space_cycle_phase = space_cycle_phase_prop * space_cycle_period
|
|
258
|
+
"""
|
|
259
|
+
If you are agnostic about where the market currently is in the cycle,
|
|
260
|
+
and you want to simulate all possitilities, then make the space cycle
|
|
261
|
+
phase proportion a uniform random variable across the entire length of
|
|
262
|
+
the cycle period defined in the space_cycle_period input.
|
|
263
|
+
If you think you know where the market currently is in the cycle, then
|
|
264
|
+
specify the proportion of the cycle period to shift the cycle by (note,
|
|
265
|
+
since this is generated from a sine curve, the base (0) is at mid-cycle,
|
|
266
|
+
heading up. See https://en.wikipedia.org/wiki/Phase_(waves)
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
space_cycle_amplitude = space_cycle_height / 2
|
|
270
|
+
"""
|
|
271
|
+
The space cycle height is the peak-to-trough full cycle amplitude as a
|
|
272
|
+
fraction of the mid-cycle level. Historically in the U.S. such cycles in
|
|
273
|
+
investment property have been as much as 50% or more in some markets in
|
|
274
|
+
the rental market (including both rent prices & occupancy effect and
|
|
275
|
+
considering the leverage that fixed operating expenses have on the
|
|
276
|
+
bottom-line net cash flow). The Pricing Factors in this model apply to
|
|
277
|
+
net cash flows, not just top-line potential gross rental revenue.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
asset_cycle_period = asset_cycle_period_diff + space_cycle_period
|
|
281
|
+
"""
|
|
282
|
+
This can be randomly different from rent cycle period, but probably not
|
|
283
|
+
too different, maybe +/- 1 year.
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
asset_cycle_phase = space_cycle_phase + (asset_cycle_phase_diff_prop * asset_cycle_period)
|
|
287
|
+
"""
|
|
288
|
+
These two cycles are not generally exactly in sync, but they usually are
|
|
289
|
+
not too far off from each other.
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
return cls.from_params(
|
|
293
|
+
space_cycle_period=space_cycle_period,
|
|
294
|
+
space_cycle_phase=space_cycle_phase,
|
|
295
|
+
space_cycle_amplitude=space_cycle_amplitude,
|
|
296
|
+
space_cycle_asymmetric_parameter=space_cycle_asymmetric_parameter,
|
|
297
|
+
asset_cycle_period=asset_cycle_period,
|
|
298
|
+
asset_cycle_phase=asset_cycle_phase,
|
|
299
|
+
asset_cycle_amplitude=asset_cycle_amplitude,
|
|
300
|
+
asset_cycle_asymmetric_parameter=asset_cycle_asymmetric_parameter,
|
|
301
|
+
sequence=sequence
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
@classmethod
|
|
305
|
+
def _from_args(
|
|
306
|
+
cls,
|
|
307
|
+
args: tuple) -> Cyclicality:
|
|
308
|
+
space_cycle_phase_prop, \
|
|
309
|
+
space_cycle_period, \
|
|
310
|
+
space_cycle_height, \
|
|
311
|
+
asset_cycle_period_diff, \
|
|
312
|
+
asset_cycle_phase_diff_prop, \
|
|
313
|
+
asset_cycle_amplitude, \
|
|
314
|
+
space_cycle_asymmetric_param, \
|
|
315
|
+
asset_cycle_asymmetric_param, \
|
|
316
|
+
sequence = args
|
|
317
|
+
|
|
318
|
+
return cls.from_estimates(
|
|
319
|
+
space_cycle_phase_prop=space_cycle_phase_prop,
|
|
320
|
+
space_cycle_period=space_cycle_period,
|
|
321
|
+
space_cycle_height=space_cycle_height,
|
|
322
|
+
asset_cycle_period_diff=asset_cycle_period_diff,
|
|
323
|
+
asset_cycle_phase_diff_prop=asset_cycle_phase_diff_prop,
|
|
324
|
+
asset_cycle_amplitude=asset_cycle_amplitude,
|
|
325
|
+
space_cycle_asymmetric_parameter=space_cycle_asymmetric_param,
|
|
326
|
+
asset_cycle_asymmetric_parameter=asset_cycle_asymmetric_param,
|
|
327
|
+
sequence=sequence)
|
|
328
|
+
|
|
329
|
+
@classmethod
|
|
330
|
+
def from_likelihoods(
|
|
331
|
+
cls,
|
|
332
|
+
space_cycle_phase_prop_dist: rk.distribution.Form,
|
|
333
|
+
space_cycle_period_dist: rk.distribution.Form,
|
|
334
|
+
space_cycle_height_dist: rk.distribution.Form,
|
|
335
|
+
asset_cycle_phase_diff_prop_dist: rk.distribution.Form,
|
|
336
|
+
asset_cycle_period_diff_dist: rk.distribution.Form,
|
|
337
|
+
asset_cycle_amplitude_dist: rk.distribution.Form,
|
|
338
|
+
space_cycle_asymmetric_parameter_dist: rk.distribution.Form,
|
|
339
|
+
asset_cycle_asymmetric_parameter_dist: rk.distribution.Form,
|
|
340
|
+
sequence: pd.PeriodIndex,
|
|
341
|
+
iterations: int = 1) -> [Cyclicality]:
|
|
342
|
+
|
|
343
|
+
space_cycle_phase_props = space_cycle_phase_prop_dist.sample(iterations)
|
|
344
|
+
"""
|
|
345
|
+
This distribution should generate the proportion of a full period at
|
|
346
|
+
which the space cycle starts. If you are unsure completely, then use a
|
|
347
|
+
uniform distribution from 0 to 1; otherwise use a distribution that
|
|
348
|
+
reflects your confidence of where the market is in the cycle.
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
space_cycle_periods = space_cycle_period_dist.sample(iterations)
|
|
352
|
+
"""
|
|
353
|
+
This distribution should generate the cycle period governing
|
|
354
|
+
each market simulation (realistically between 10 and 20 years)
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
space_cycle_heights = space_cycle_height_dist.sample(iterations)
|
|
358
|
+
|
|
359
|
+
asset_cycle_period_diffs = asset_cycle_period_diff_dist.sample(iterations)
|
|
360
|
+
"""
|
|
361
|
+
Since the asset cycle period tracks the space cycle period, this
|
|
362
|
+
distribution should generate reasonable (+/- 1 year) differences
|
|
363
|
+
"""
|
|
364
|
+
|
|
365
|
+
asset_cycle_phase_props = asset_cycle_phase_diff_prop_dist.sample(iterations)
|
|
366
|
+
"""
|
|
367
|
+
The asset market phase is equal to the space market phase +/- some
|
|
368
|
+
random difference that is a pretty small fraction of the cycle period.
|
|
369
|
+
Reasonably a quarter period. Remember that peak-to-trough is half
|
|
370
|
+
period, LR mean to either peak or trough is quarter period.
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
asset_cycle_amplitudes = asset_cycle_amplitude_dist.sample(iterations)
|
|
374
|
+
"""
|
|
375
|
+
The amplitude of the asset cycle specifies cap rates, which may cycle
|
|
376
|
+
+/- 100 to 200 basis-points.
|
|
377
|
+
"""
|
|
378
|
+
|
|
379
|
+
space_cycle_asymmetric_params = space_cycle_asymmetric_parameter_dist.sample(iterations)
|
|
380
|
+
asset_cycle_asymmetric_params = asset_cycle_asymmetric_parameter_dist.sample(iterations)
|
|
381
|
+
|
|
382
|
+
args = [
|
|
383
|
+
(space_cycle_phase_prop,
|
|
384
|
+
space_cycle_period,
|
|
385
|
+
space_cycle_height,
|
|
386
|
+
asset_cycle_period_diff,
|
|
387
|
+
asset_cycle_phase_prop,
|
|
388
|
+
asset_cycle_amplitude,
|
|
389
|
+
space_cycle_asymmetric_param,
|
|
390
|
+
asset_cycle_asymmetric_param,
|
|
391
|
+
sequence)
|
|
392
|
+
for (space_cycle_phase_prop,
|
|
393
|
+
space_cycle_period,
|
|
394
|
+
space_cycle_height,
|
|
395
|
+
asset_cycle_period_diff,
|
|
396
|
+
asset_cycle_phase_prop,
|
|
397
|
+
asset_cycle_amplitude,
|
|
398
|
+
space_cycle_asymmetric_param,
|
|
399
|
+
asset_cycle_asymmetric_param)
|
|
400
|
+
in zip(space_cycle_phase_props,
|
|
401
|
+
space_cycle_periods,
|
|
402
|
+
space_cycle_heights,
|
|
403
|
+
asset_cycle_period_diffs,
|
|
404
|
+
asset_cycle_phase_props,
|
|
405
|
+
asset_cycle_amplitudes,
|
|
406
|
+
space_cycle_asymmetric_params,
|
|
407
|
+
asset_cycle_asymmetric_params)
|
|
408
|
+
]
|
|
409
|
+
|
|
410
|
+
if iterations == 1:
|
|
411
|
+
return cls._from_args(args[0])
|
|
412
|
+
else:
|
|
413
|
+
pool = multiprocess.Pool(os.cpu_count())
|
|
414
|
+
return pool.map(cls._from_args, args)
|