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,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)