Qubx 0.5.7__cp312-cp312-manylinux_2_39_x86_64.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.

Potentially problematic release.


This version of Qubx might be problematic. Click here for more details.

Files changed (100) hide show
  1. qubx/__init__.py +207 -0
  2. qubx/_nb_magic.py +100 -0
  3. qubx/backtester/__init__.py +5 -0
  4. qubx/backtester/account.py +145 -0
  5. qubx/backtester/broker.py +87 -0
  6. qubx/backtester/data.py +296 -0
  7. qubx/backtester/management.py +378 -0
  8. qubx/backtester/ome.py +296 -0
  9. qubx/backtester/optimization.py +201 -0
  10. qubx/backtester/simulated_data.py +558 -0
  11. qubx/backtester/simulator.py +362 -0
  12. qubx/backtester/utils.py +780 -0
  13. qubx/cli/__init__.py +0 -0
  14. qubx/cli/commands.py +67 -0
  15. qubx/connectors/ccxt/__init__.py +0 -0
  16. qubx/connectors/ccxt/account.py +495 -0
  17. qubx/connectors/ccxt/broker.py +132 -0
  18. qubx/connectors/ccxt/customizations.py +193 -0
  19. qubx/connectors/ccxt/data.py +612 -0
  20. qubx/connectors/ccxt/exceptions.py +17 -0
  21. qubx/connectors/ccxt/factory.py +93 -0
  22. qubx/connectors/ccxt/utils.py +307 -0
  23. qubx/core/__init__.py +0 -0
  24. qubx/core/account.py +251 -0
  25. qubx/core/basics.py +850 -0
  26. qubx/core/context.py +420 -0
  27. qubx/core/exceptions.py +38 -0
  28. qubx/core/helpers.py +480 -0
  29. qubx/core/interfaces.py +1150 -0
  30. qubx/core/loggers.py +514 -0
  31. qubx/core/lookups.py +475 -0
  32. qubx/core/metrics.py +1512 -0
  33. qubx/core/mixins/__init__.py +13 -0
  34. qubx/core/mixins/market.py +94 -0
  35. qubx/core/mixins/processing.py +428 -0
  36. qubx/core/mixins/subscription.py +203 -0
  37. qubx/core/mixins/trading.py +88 -0
  38. qubx/core/mixins/universe.py +270 -0
  39. qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
  40. qubx/core/series.pxd +125 -0
  41. qubx/core/series.pyi +118 -0
  42. qubx/core/series.pyx +988 -0
  43. qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
  44. qubx/core/utils.pyi +6 -0
  45. qubx/core/utils.pyx +62 -0
  46. qubx/data/__init__.py +25 -0
  47. qubx/data/helpers.py +416 -0
  48. qubx/data/readers.py +1562 -0
  49. qubx/data/tardis.py +100 -0
  50. qubx/gathering/simplest.py +88 -0
  51. qubx/math/__init__.py +3 -0
  52. qubx/math/stats.py +129 -0
  53. qubx/pandaz/__init__.py +23 -0
  54. qubx/pandaz/ta.py +2757 -0
  55. qubx/pandaz/utils.py +638 -0
  56. qubx/resources/instruments/symbols-binance.cm.json +1 -0
  57. qubx/resources/instruments/symbols-binance.json +1 -0
  58. qubx/resources/instruments/symbols-binance.um.json +1 -0
  59. qubx/resources/instruments/symbols-bitfinex.f.json +1 -0
  60. qubx/resources/instruments/symbols-bitfinex.json +1 -0
  61. qubx/resources/instruments/symbols-kraken.f.json +1 -0
  62. qubx/resources/instruments/symbols-kraken.json +1 -0
  63. qubx/ta/__init__.py +0 -0
  64. qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
  65. qubx/ta/indicators.pxd +149 -0
  66. qubx/ta/indicators.pyi +41 -0
  67. qubx/ta/indicators.pyx +787 -0
  68. qubx/trackers/__init__.py +3 -0
  69. qubx/trackers/abvanced.py +236 -0
  70. qubx/trackers/composite.py +146 -0
  71. qubx/trackers/rebalancers.py +129 -0
  72. qubx/trackers/riskctrl.py +641 -0
  73. qubx/trackers/sizers.py +235 -0
  74. qubx/utils/__init__.py +5 -0
  75. qubx/utils/_pyxreloader.py +281 -0
  76. qubx/utils/charting/lookinglass.py +1057 -0
  77. qubx/utils/charting/mpl_helpers.py +1183 -0
  78. qubx/utils/marketdata/binance.py +284 -0
  79. qubx/utils/marketdata/ccxt.py +90 -0
  80. qubx/utils/marketdata/dukas.py +130 -0
  81. qubx/utils/misc.py +541 -0
  82. qubx/utils/ntp.py +63 -0
  83. qubx/utils/numbers_utils.py +7 -0
  84. qubx/utils/orderbook.py +491 -0
  85. qubx/utils/plotting/__init__.py +0 -0
  86. qubx/utils/plotting/dashboard.py +150 -0
  87. qubx/utils/plotting/data.py +137 -0
  88. qubx/utils/plotting/interfaces.py +25 -0
  89. qubx/utils/plotting/renderers/__init__.py +0 -0
  90. qubx/utils/plotting/renderers/plotly.py +0 -0
  91. qubx/utils/runner/__init__.py +1 -0
  92. qubx/utils/runner/_jupyter_runner.pyt +60 -0
  93. qubx/utils/runner/accounts.py +88 -0
  94. qubx/utils/runner/configs.py +65 -0
  95. qubx/utils/runner/runner.py +470 -0
  96. qubx/utils/time.py +312 -0
  97. qubx-0.5.7.dist-info/METADATA +105 -0
  98. qubx-0.5.7.dist-info/RECORD +100 -0
  99. qubx-0.5.7.dist-info/WHEEL +4 -0
  100. qubx-0.5.7.dist-info/entry_points.txt +3 -0
qubx/pandaz/ta.py ADDED
@@ -0,0 +1,2757 @@
1
+ import types
2
+ from collections import OrderedDict
3
+ from typing import Any, Callable, List, Tuple, Union
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ import statsmodels.api as sm
8
+ from numba import njit
9
+ from statsmodels.regression.linear_model import OLS
10
+
11
+ from qubx.pandaz.utils import check_frame_columns, continuous_periods, has_columns, ohlc_resample, scols, srows
12
+ from qubx.utils.misc import Struct
13
+ from qubx.utils.time import infer_series_frequency
14
+
15
+
16
+ def __apply_to_frame(func: types.FunctionType, x: pd.Series | pd.DataFrame, *args, **kwargs):
17
+ """
18
+ Utility applies given function to x and converts result to incoming type
19
+
20
+ >>> from qubx.ta.pandaz import ema
21
+ >>> apply_to_frame(ema, data['EURUSD'], 50)
22
+ >>> apply_to_frame(lambda x, p1: x + p1, data['EURUSD'], 1)
23
+
24
+ :param func: function to map
25
+ :param x: input data
26
+ :param args: arguments of func
27
+ :param kwargs: named arguments of func (if it contains keep_names=True it won't change source columns names)
28
+ :return: result of function's application
29
+ """
30
+ _keep_names = False
31
+ if "keep_names" in kwargs:
32
+ _keep_names = kwargs.pop("keep_names")
33
+
34
+ if func is None or not isinstance(func, types.FunctionType):
35
+ raise ValueError(str(func) + " must be callable object")
36
+
37
+ xp = column_vector(func(x, *args, **kwargs))
38
+ _name = None
39
+ if not _keep_names:
40
+ _name = func.__name__ + "_" + "_".join([str(i) for i in args])
41
+
42
+ if isinstance(x, pd.DataFrame):
43
+ c_names = x.columns if _keep_names else ["%s_%s" % (c, _name) for c in x.columns]
44
+ return pd.DataFrame(xp, index=x.index, columns=c_names)
45
+
46
+ elif isinstance(x, pd.Series):
47
+ return pd.Series(xp.flatten(), index=x.index, name=_name)
48
+
49
+ return xp
50
+
51
+
52
+ def column_vector(x) -> np.ndarray:
53
+ """
54
+ Convert any vector to column vector. Matrices remain unchanged.
55
+
56
+ :param x: vector
57
+ :return: column vector
58
+ """
59
+ if isinstance(x, (pd.DataFrame, pd.Series)):
60
+ x = x.values
61
+ return np.reshape(x, (x.shape[0], -1))
62
+
63
+
64
+ @njit
65
+ def shift(xs: np.ndarray, n: int, fill=np.nan) -> np.ndarray:
66
+ """
67
+ Shift data in numpy array (aka lag function):
68
+
69
+ shift(np.array([[1.,2.],
70
+ [11.,22.],
71
+ [33.,44.]]), 1)
72
+
73
+ >> array([[ nan, nan],
74
+ [ 1., 2.],
75
+ [ 11., 22.]])
76
+
77
+ :param xs:
78
+ :param n:
79
+ :param fill: value to use for
80
+ :return:
81
+ """
82
+ e = np.empty_like(xs)
83
+ if n >= 0:
84
+ e[:n] = fill
85
+ e[n:] = xs[:-n]
86
+ else:
87
+ e[n:] = fill
88
+ e[:n] = xs[-n:]
89
+ return e
90
+
91
+
92
+ def sink_nans_down(x_in, copy=False) -> Tuple[np.ndarray, np.ndarray]:
93
+ """
94
+ Move all starting nans 'down to the bottom' in every column.
95
+
96
+ NaN = np.nan
97
+ x = np.array([[NaN, 1, NaN],
98
+ [NaN, 2, NaN],
99
+ [NaN, 3, NaN],
100
+ [10, 4, NaN],
101
+ [20, 5, NaN],
102
+ [30, 6, 100],
103
+ [40, 7, 200]])
104
+
105
+ x1, nx = sink_nans_down(x)
106
+ print(x1)
107
+
108
+ >> [[ 10. 1. 100.]
109
+ [ 20. 2. 200.]
110
+ [ 30. 3. nan]
111
+ [ 40. 4. nan]
112
+ [ nan 5. nan]
113
+ [ nan 6. nan]
114
+ [ nan 7. nan]]
115
+
116
+ :param x_in: numpy 1D/2D array
117
+ :param copy: set if need to make copy input to prevent being modified [False by default]
118
+ :return: modified x_in and indexes
119
+ """
120
+ x = np.copy(x_in) if copy else x_in
121
+ n_ix = np.zeros(x.shape[1])
122
+ for i in range(0, x.shape[1]):
123
+ f_n = np.where(~np.isnan(x[:, i]))[0]
124
+ if len(f_n) > 0:
125
+ if f_n[0] != 0:
126
+ x[:, i] = np.concatenate((x[f_n[0] :, i], x[: f_n[0], i]))
127
+ n_ix[i] = f_n[0]
128
+ return x, n_ix
129
+
130
+
131
+ def lift_nans_up(x_in, n_ix, copy=False) -> np.ndarray:
132
+ """
133
+ Move all ending nans 'up to top' of every column.
134
+
135
+ NaN = np.nan
136
+ x = np.array([[NaN, 1, NaN],
137
+ [NaN, 2, NaN],
138
+ [NaN, 3, NaN],
139
+ [10, 4, NaN],
140
+ [20, 5, NaN],
141
+ [30, 6, 100],
142
+ [40, 7, 200]])
143
+
144
+ x1, nx = sink_nans_down(x)
145
+ print(x1)
146
+
147
+ >> [[ 10. 1. 100.]
148
+ [ 20. 2. 200.]
149
+ [ 30. 3. nan]
150
+ [ 40. 4. nan]
151
+ [ nan 5. nan]
152
+ [ nan 6. nan]
153
+ [ nan 7. nan]]
154
+
155
+ x2 = lift_nans_up(x1, nx)
156
+ print(x2)
157
+
158
+ >> [[ nan 1. nan]
159
+ [ nan 2. nan]
160
+ [ nan 3. nan]
161
+ [ 10. 4. nan]
162
+ [ 20. 5. nan]
163
+ [ 30. 6. 100.]
164
+ [ 40. 7. 200.]]
165
+
166
+ :param x_in: numpy 1D/2D array
167
+ :param n_ix: indexes for every column
168
+ :param copy: set if need to make copy input to prevent being modified [False by default]
169
+ :return: modified x_in
170
+ """
171
+ x = np.copy(x_in) if copy else x_in
172
+ for i in range(0, x.shape[1]):
173
+ f_n = int(n_ix[i])
174
+ if f_n != 0:
175
+ x[:, i] = np.concatenate((nans(f_n), x[:-f_n, i]))
176
+ return x
177
+
178
+
179
+ @njit
180
+ def nans(dims):
181
+ """
182
+ nans((M,N,P,...)) is an M-by-N-by-P-by-... array of NaNs.
183
+
184
+ :param dims: dimensions tuple
185
+ :return: nans matrix
186
+ """
187
+ return np.nan * np.ones(dims)
188
+
189
+
190
+ @njit
191
+ def rolling_sum(x: np.ndarray, n: int) -> np.ndarray:
192
+ """
193
+ Fast running sum for numpy array (matrix) along columns.
194
+
195
+ Example:
196
+ >>> rolling_sum(column_vector(np.array([[1,2,3,4,5,6,7,8,9], [11,22,33,44,55,66,77,88,99]]).T), n=5)
197
+
198
+ array([[ nan, nan],
199
+ [ nan, nan],
200
+ [ nan, nan],
201
+ [ nan, nan],
202
+ [ 15., 165.],
203
+ [ 20., 220.],
204
+ [ 25., 275.],
205
+ [ 30., 330.],
206
+ [ 35., 385.]])
207
+
208
+ :param x: input data
209
+ :param n: rolling window size
210
+ :return: rolling sum for every column preceded by nans
211
+ """
212
+ xs = nans(x.shape)
213
+ for i in range(0, x.shape[1]):
214
+ ret = np.nancumsum(x[:, i])
215
+ ret[n:] = ret[n:] - ret[:-n]
216
+ xs[(n - 1) :, i] = ret[n - 1 :]
217
+ return xs
218
+
219
+
220
+ def __wrap_dataframe_decorator(func):
221
+ def wrapper(*args, **kwargs):
222
+ if isinstance(args[0], (pd.Series, pd.DataFrame)):
223
+ return __apply_to_frame(func, *args, **kwargs)
224
+ else:
225
+ return func(*args)
226
+
227
+ return wrapper
228
+
229
+
230
+ def __empty_smoother(x, *args, **kwargs):
231
+ return column_vector(x)
232
+
233
+
234
+ def running_view(arr, window, axis=-1):
235
+ """
236
+ Produces running view (lagged matrix) from given array.
237
+
238
+ Example:
239
+
240
+ > running_view(np.array([1,2,3,4,5,6]), 3)
241
+
242
+ array([[1, 2, 3],
243
+ [2, 3, 4],
244
+ [3, 4, 5],
245
+ [4, 5, 6]])
246
+
247
+ :param arr: array of numbers
248
+ :param window: window length
249
+ :param axis:
250
+ :return: lagged matrix
251
+ """
252
+ shape = list(arr.shape)
253
+ shape[axis] -= window - 1
254
+ return np.lib.index_tricks.as_strided(arr, shape + [window], arr.strides + (arr.strides[axis],))
255
+
256
+
257
+ def detrend(y, order):
258
+ """
259
+ Removes linear trend from the series y.
260
+ detrend computes the least-squares fit of a straight line to the data
261
+ and subtracts the resulting function from the data.
262
+
263
+ :param y:
264
+ :param order:
265
+ :return:
266
+ """
267
+ if order == -1:
268
+ return y
269
+ return OLS(y, np.vander(np.linspace(-1, 1, len(y)), order + 1)).fit().resid
270
+
271
+
272
+ def moving_detrend(y: pd.Series, order: int, window: int) -> pd.DataFrame:
273
+ """
274
+ Removes linear trend from the series y by using sliding window.
275
+ :param y: series (ndarray or pd.DataFrame/Series)
276
+ :param order: trend's polinome order
277
+ :param window: sliding window size
278
+ :return: (residual, rsquatred, betas)
279
+ """
280
+ yy = running_view(column_vector(y).T[0], window=window)
281
+ n_pts = len(y)
282
+ resid = nans((n_pts))
283
+ r_sqr = nans((n_pts))
284
+ betas = nans((n_pts, order + 1))
285
+ for i, p in enumerate(yy):
286
+ n = len(p)
287
+ lr = OLS(p, np.vander(np.linspace(-1, 1, n), order + 1)).fit()
288
+ r_sqr[n - 1 + i] = lr.rsquared
289
+ resid[n - 1 + i] = lr.resid[-1]
290
+ betas[n - 1 + i, :] = lr.params
291
+
292
+ # return pandas frame if input is series/frame
293
+ r = pd.DataFrame({"resid": resid, "r2": r_sqr}, index=y.index, columns=["resid", "r2"])
294
+ betas_fr = pd.DataFrame(betas, index=y.index, columns=["b%d" % i for i in range(order + 1)])
295
+ return pd.concat((r, betas_fr), axis=1)
296
+
297
+
298
+ def moving_ols(y, x, window):
299
+ """
300
+ Function for calculating moving linear regression model using sliding window
301
+ y = B*x + err
302
+ returns array of betas, residuals and standard deviation for residuals
303
+ residuals = y - yhat, where yhat = betas * x
304
+
305
+ Example:
306
+
307
+ x = pd.DataFrame(np.random.randn(100, 5))
308
+ y = pd.Series(np.random.randn(100).cumsum())
309
+ m = moving_ols(y, x, 5)
310
+ lr_line = (x * m).sum(axis=1)
311
+
312
+ :param y: dependent variable (vector)
313
+ :param x: exogenous variables (vector or matrix)
314
+ :param window: sliding windowsize
315
+ :return: array of betas, residuals and standard deviation for residuals
316
+ """
317
+ # if we have any indexes
318
+ idx_line = y.index if isinstance(y, (pd.Series, pd.DataFrame)) else None
319
+ x_col_names = x.columns if isinstance(y, (pd.Series, pd.DataFrame)) else None
320
+
321
+ x = column_vector(x)
322
+ y = column_vector(y)
323
+ nx = len(x)
324
+ if nx != len(y):
325
+ raise ValueError("Series must contain equal number of points")
326
+
327
+ if y.shape[1] != 1:
328
+ raise ValueError("Response variable y must be column array or series object")
329
+
330
+ if window > nx:
331
+ raise ValueError("Window size must be less than number of observations")
332
+
333
+ betas = nans(x.shape)
334
+ err = nans((nx))
335
+ sd = nans((nx))
336
+
337
+ for i in range(window, nx + 1):
338
+ ys = y[(i - window) : i]
339
+ xs = x[(i - window) : i, :]
340
+ lr = OLS(ys, xs).fit()
341
+ betas[i - 1, :] = lr.params
342
+ err[i - 1] = y[i - 1] - (x[i - 1, :] * lr.params).sum()
343
+ sd[i - 1] = lr.resid.std()
344
+
345
+ # convert to dataframe if need
346
+ if x_col_names is not None and idx_line is not None:
347
+ _non_empy = lambda c, idx: c if c else idx
348
+ _bts = pd.DataFrame({_non_empy(c, i): betas[:, i] for i, c in enumerate(x_col_names)}, index=idx_line)
349
+ return pd.concat((_bts, pd.DataFrame({"error": err, "stdev": sd}, index=idx_line)), axis=1)
350
+ else:
351
+ return betas, err, sd
352
+
353
+
354
+ def holt_winters_second_order_ewma(x: pd.Series, span: int, beta: float) -> pd.DataFrame:
355
+ """
356
+ The Holt-Winters second order method (aka double exponential smoothing) attempts to incorporate the estimated
357
+ trend into the smoothed data, using a {b_{t}} term that keeps track of the slope of the original signal.
358
+ The smoothed signal is written to the s_{t} term.
359
+
360
+ :param x: series values (DataFrame, Series or numpy array)
361
+ :param span: number of data points taken for calculation
362
+ :param beta: trend smoothing factor, 0 < beta < 1
363
+ :return: tuple of smoothed series and smoothed trend
364
+ """
365
+ if span < 0:
366
+ raise ValueError("Span value must be positive")
367
+
368
+ if isinstance(x, (pd.DataFrame, pd.Series)):
369
+ x = x.values
370
+
371
+ x = np.reshape(x, (x.shape[0], -1))
372
+ alpha = 2.0 / (1 + span)
373
+ r_alpha = 1 - alpha
374
+ r_beta = 1 - beta
375
+ s = np.zeros(x.shape)
376
+ b = np.zeros(x.shape)
377
+ s[0, :] = x[0, :]
378
+ for i in range(1, x.shape[0]):
379
+ s[i, :] = alpha * x[i, :] + r_alpha * (s[i - 1, :] + b[i - 1, :])
380
+ b[i, :] = beta * (s[i, :] - s[i - 1, :]) + r_beta * b[i - 1, :]
381
+ return pd.DataFrame({"smoothed": s, "trend": b}, index=x.index)
382
+
383
+
384
+ @__wrap_dataframe_decorator
385
+ def sma(x, period):
386
+ """
387
+ Classical simple moving average
388
+
389
+ :param x: input data (as np.array or pd.DataFrame/Series)
390
+ :param period: period of smoothing
391
+ :return: smoothed values
392
+ """
393
+ if period <= 0:
394
+ raise ValueError("Period must be positive and greater than zero !!!")
395
+
396
+ x = column_vector(x)
397
+ x, ix = sink_nans_down(x, copy=True)
398
+ s = rolling_sum(x, period) / period
399
+ return lift_nans_up(s, ix)
400
+
401
+
402
+ @njit
403
+ def _calc_kama(x, period, fast_span, slow_span):
404
+ x = x.astype(np.float64)
405
+ for i in range(0, x.shape[1]):
406
+ nan_start = np.where(~np.isnan(x[:, i]))[0][0]
407
+ x_s = x[:, i][nan_start:]
408
+ if period >= len(x_s):
409
+ raise ValueError("Wrong value for period. period parameter must be less than number of input observations")
410
+ abs_diff = np.abs(x_s - shift(x_s, 1))
411
+ er = np.abs(x_s - shift(x_s, period)) / rolling_sum(np.reshape(abs_diff, (len(abs_diff), -1)), period)[:, 0]
412
+ sc = np.square((er * (2.0 / (fast_span + 1) - 2.0 / (slow_span + 1.0)) + 2 / (slow_span + 1.0)))
413
+ ama = nans(sc.shape)
414
+
415
+ # here ama_0 = x_0
416
+ ama[period - 1] = x_s[period - 1]
417
+ for n in range(period, len(ama)):
418
+ ama[n] = ama[n - 1] + sc[n] * (x_s[n] - ama[n - 1])
419
+
420
+ # drop 1-st kama value (just for compatibility with ta-lib)
421
+ ama[period - 1] = np.nan
422
+
423
+ x[:, i] = np.concatenate((nans(nan_start), ama))
424
+
425
+ return x
426
+
427
+
428
+ @__wrap_dataframe_decorator
429
+ def kama(x, period, fast_span=2, slow_span=30):
430
+ """
431
+ Kaufman Adaptive Moving Average
432
+
433
+ :param x: input data (as np.array or pd.DataFrame/Series)
434
+ :param period: period of smoothing
435
+ :param fast_span: fast period (default is 2 as in canonical impl)
436
+ :param slow_span: slow period (default is 30 as in canonical impl)
437
+ :return: smoothed values
438
+ """
439
+ x = column_vector(x)
440
+ return _calc_kama(x, period, fast_span, slow_span)
441
+
442
+
443
+ @njit
444
+ def _calc_ema(x, span, init_mean=True, min_periods=0):
445
+ alpha = 2.0 / (1 + span)
446
+ x = x.astype(np.float64)
447
+ for i in range(0, x.shape[1]):
448
+ nan_start = np.where(~np.isnan(x[:, i]))[0][0]
449
+ x_s = x[:, i][nan_start:]
450
+ a_1 = 1 - alpha
451
+ s = np.zeros(x_s.shape)
452
+
453
+ start_i = 1
454
+ if init_mean:
455
+ s += np.nan
456
+ if span - 1 >= len(s):
457
+ x[:, :] = np.nan
458
+ continue
459
+ s[span - 1] = np.mean(x_s[:span])
460
+ start_i = span
461
+ else:
462
+ s[0] = x_s[0]
463
+
464
+ for n in range(start_i, x_s.shape[0]):
465
+ s[n] = alpha * x_s[n] + a_1 * s[n - 1]
466
+
467
+ if min_periods > 0:
468
+ s[: min_periods - 1] = np.nan
469
+
470
+ x[:, i] = np.concatenate((nans(nan_start), s))
471
+
472
+ return x
473
+
474
+
475
+ @__wrap_dataframe_decorator
476
+ def ema(x, span, init_mean=True, min_periods=0) -> np.ndarray:
477
+ """
478
+ Exponential moving average
479
+
480
+ :param x: data to be smoothed
481
+ :param span: number of data points for smooth
482
+ :param init_mean: use average of first span points as starting ema value (default is true)
483
+ :param min_periods: minimum number of observations in window required to have a value (0)
484
+ :return:
485
+ """
486
+ x = column_vector(x)
487
+ return _calc_ema(x, span, init_mean, min_periods)
488
+
489
+
490
+ @__wrap_dataframe_decorator
491
+ def zlema(x: np.ndarray, n: int, init_mean=True):
492
+ """
493
+ 'Zero lag' moving average
494
+ :type x: np.array
495
+ :param x:
496
+ :param n:
497
+ :param init_mean: True if initial ema value is average of first n points
498
+ :return:
499
+ """
500
+ x = column_vector(x)
501
+ return ema(2 * x - shift(x, n), n, init_mean=init_mean)
502
+
503
+
504
+ @__wrap_dataframe_decorator
505
+ def dema(x, n: int, init_mean=True):
506
+ """
507
+ Double EMA
508
+
509
+ :param x:
510
+ :param n:
511
+ :param init_mean: True if initial ema value is average of first n points
512
+ :return:
513
+ """
514
+ e1 = ema(x, n, init_mean=init_mean)
515
+ return 2 * e1 - ema(e1, n, init_mean=init_mean)
516
+
517
+
518
+ @__wrap_dataframe_decorator
519
+ def tema(x, n: int, init_mean=True):
520
+ """
521
+ Triple EMA
522
+
523
+ :param x:
524
+ :param n:
525
+ :param init_mean: True if initial ema value is average of first n points
526
+ :return:
527
+ """
528
+ e1 = ema(x, n, init_mean=init_mean)
529
+ e2 = ema(e1, n, init_mean=init_mean)
530
+ return 3 * e1 - 3 * e2 + ema(e2, n, init_mean=init_mean)
531
+
532
+
533
+ @__wrap_dataframe_decorator
534
+ def wma(x, period: int, weights=None):
535
+ """
536
+ Weighted moving average
537
+
538
+ :param x: values to be averaged
539
+ :param period: period (used for standard WMA (weights = [1,2,3,4, ... period]))
540
+ :param weights: custom weights array
541
+ :return: weighted values
542
+ """
543
+ x = column_vector(x)
544
+
545
+ # if weights are set up
546
+ if weights is None or not weights:
547
+ w = np.arange(1, period + 1)
548
+ else:
549
+ w = np.array(weights)
550
+ period = len(w)
551
+
552
+ if period > len(x):
553
+ raise ValueError(f"Period for wma must be less than number of rows. {period}, {len(x)}")
554
+
555
+ w = (w / np.sum(w))[::-1] # order fixed !
556
+ y = x.astype(np.float64).copy()
557
+ for i in range(0, x.shape[1]):
558
+ nan_start = np.where(~np.isnan(x[:, i]))[0][0]
559
+ y_s = y[:, i][nan_start:]
560
+ wm = np.concatenate((nans(period - 1), np.convolve(y_s, w, "valid")))
561
+ y[:, i] = np.concatenate((nans(nan_start), wm))
562
+
563
+ return y
564
+
565
+
566
+ @__wrap_dataframe_decorator
567
+ def hma(x, period: int):
568
+ """
569
+ Hull moving average
570
+
571
+ :param x: values to be averaged
572
+ :param period: period
573
+ :return: weighted values
574
+ """
575
+ return wma(2 * wma(x, period // 2) - wma(x, period), int(np.sqrt(period)))
576
+
577
+
578
+ @__wrap_dataframe_decorator
579
+ def bidirectional_ema(x: pd.Series, span: int, smoother="ema") -> np.ndarray:
580
+ """
581
+ EMA function is really appropriate for stationary data, i.e., data without trends or seasonality.
582
+ In particular, the EMA function resists trends away from the current mean that it’s already “seen”.
583
+ So, if you have a noisy hat function that goes from 0, to 1, and then back to 0, then the EMA function will return
584
+ low values on the up-hill side, and high values on the down-hill side.
585
+ One way to circumvent this is to smooth the signal in both directions, marching forward,
586
+ and then marching backward, and then average the two.
587
+
588
+ :param x: data
589
+ :param span: span for smoothing
590
+ :param smoother: smoothing function (default 'ema' or 'tema')
591
+ :return: smoohted data
592
+ """
593
+ if smoother == "tema":
594
+ fwd = tema(x, span, init_mean=False) # take TEMA in forward direction
595
+ bwd = tema(x[::-1], span, init_mean=False) # take TEMA in backward direction
596
+ else:
597
+ fwd = ema(x, span=span, init_mean=False) # take EMA in forward direction
598
+ bwd = ema(x[::-1], span=span, init_mean=False) # take EMA in backward direction
599
+ return (fwd + bwd[::-1]) / 2.0
600
+
601
+
602
+ def series_halflife(series: pd.Series | np.ndarray) -> float:
603
+ """
604
+ Tries to find half-life time for this series.
605
+
606
+ Example:
607
+ >>> series_halflife(np.array([1,0,2,3,2,1,-1,-2,0,1]))
608
+ >>> 2.0
609
+
610
+ :param series: series data (np.array or pd.Series)
611
+ :return: half-life value rounded to integer
612
+ """
613
+ ser = column_vector(series)
614
+ if ser.shape[1] > 1:
615
+ raise ValueError("Nultimple series is not supported")
616
+
617
+ lag = ser[1:]
618
+ dY = -np.diff(ser, axis=0)
619
+ m = OLS(dY, sm.add_constant(lag, prepend=False))
620
+ reg = m.fit()
621
+
622
+ return np.ceil(-np.log(2) / reg.params[0])
623
+
624
+
625
+ def rolling_std_with_mean(x: pd.Series, mean: float | pd.Series, window: int):
626
+ """
627
+ Calculates rolling standard deviation for data from x and already calculated mean series
628
+ :param x: series data
629
+ :param mean: calculated mean
630
+ :param window: window
631
+ :return: rolling standard deviation
632
+ """
633
+ return np.sqrt((((x - mean) ** 2).rolling(window=window).sum() / (window - 1)))
634
+
635
+
636
+ def bollinger(x: pd.Series, window=14, nstd=2, mean="sma") -> pd.DataFrame:
637
+ """
638
+ Bollinger Bands indicator
639
+
640
+ :param x: input data
641
+ :param window: lookback window
642
+ :param nstd: number of standard devialtions for bands
643
+ :param mean: method for calculating mean: sma, ema, tema, dema, zlema, kama
644
+ :param as_frame: if true result is returned as DataFrame
645
+ :return: mean, upper and lower bands
646
+ """
647
+ rolling_mean = smooth(x, mean, window)
648
+ rolling_std = rolling_std_with_mean(x, rolling_mean, window)
649
+
650
+ upper_band = rolling_mean + (rolling_std * nstd)
651
+ lower_band = rolling_mean - (rolling_std * nstd)
652
+
653
+ _bb = rolling_mean, upper_band, lower_band
654
+ return pd.concat(_bb, axis=1, keys=["Median", "Upper", "Lower"])
655
+
656
+
657
+ def bollinger_atr(x: pd.DataFrame, window=14, atr_window=14, natr=2, mean="sma", atr_mean="ema") -> pd.DataFrame:
658
+ """
659
+ Bollinger Bands indicator where ATR is used for bands range estimating
660
+ :param x: input data
661
+ :param window: window size for averaged price
662
+ :param atr_window: atr window size
663
+ :param natr: number of ATRs for bands
664
+ :param mean: method for calculating mean: sma, ema, tema, dema, zlema, kama
665
+ :param atr_mean: method for calculating mean for atr: sma, ema, tema, dema, zlema, kama
666
+ :param as_frame: if true result is returned as DataFrame
667
+ :return: mean, upper and lower bands
668
+ """
669
+ check_frame_columns(x, "open", "high", "low", "close")
670
+
671
+ b = bollinger(x["close"], window, 0, mean)
672
+ a = natr * atr(x, atr_window, atr_mean)
673
+ m = b["Median"]
674
+ return scols(*(m, m + a, m - a), names=["Median", "Upper", "Lower"])
675
+
676
+
677
+ def macd(x: pd.Series, fast=12, slow=26, signal=9, method="ema", signal_method="ema") -> pd.Series:
678
+ """
679
+ Moving average convergence divergence (MACD) is a trend-following momentum indicator that shows the relationship
680
+ between two moving averages of prices. The MACD is calculated by subtracting the 26-day slow moving average from the
681
+ 12-day fast MA. A nine-day MA of the MACD, called the "signal line", is then plotted on top of the MACD,
682
+ functioning as a trigger for buy and sell signals.
683
+
684
+ :param x: input data
685
+ :param fast: fast MA period
686
+ :param slow: slow MA period
687
+ :param signal: signal MA period
688
+ :param method: used moving averaging method (sma, ema, tema, dema, zlema, kama)
689
+ :param signal_method: used method for averaging signal (sma, ema, tema, dema, zlema, kama)
690
+ :return: macd signal
691
+ """
692
+ x_diff = smooth(x, method, fast) - smooth(x, method, slow)
693
+
694
+ # averaging signal
695
+ return smooth(x_diff, signal_method, signal).rename("macd")
696
+
697
+
698
+ def atr(x: pd.DataFrame, window=14, smoother="sma", percentage=False) -> pd.Series:
699
+ """
700
+ Average True Range indicator
701
+
702
+ :param x: input series
703
+ :param window: smoothing window size
704
+ :param smoother: smooting method: sma, ema, zlema, tema, dema, kama
705
+ :param percentage: if True ATR presented as percentage to close price: 100*ATR[i]/close[i]
706
+ :return: average true range
707
+ """
708
+ check_frame_columns(x, "open", "high", "low", "close")
709
+
710
+ close = x["close"].shift(1)
711
+ h_l = abs(x["high"] - x["low"])
712
+ h_pc = abs(x["high"] - close)
713
+ l_pc = abs(x["low"] - close)
714
+ tr = pd.concat((h_l, h_pc, l_pc), axis=1).max(axis=1)
715
+
716
+ # smoothing
717
+ a = smooth(tr, smoother, window).rename("atr")
718
+ return (100 * a / close) if percentage else a
719
+
720
+
721
+ def rolling_atr(x, window, periods, smoother="sma") -> pd.Series:
722
+ """
723
+ Average True Range indicator calculated on rolling window
724
+
725
+ :param x:
726
+ :param window: windiw size as Timedelta or string
727
+ :param periods: number periods for smoothing (applied if > 1)
728
+ :param smoother: smoother (sma is default)
729
+ :return: ATR
730
+ """
731
+ check_frame_columns(x, "open", "high", "low", "close")
732
+
733
+ window = pd.Timedelta(window) if isinstance(window, str) else window
734
+ tf_orig = pd.Timedelta(infer_series_frequency(x))
735
+
736
+ if window < tf_orig:
737
+ raise ValueError("window size must be great or equal to OHLC series timeframe !!!")
738
+
739
+ wind_delta = window + tf_orig
740
+ n_min_periods = wind_delta // tf_orig
741
+ _c_1 = x.rolling(wind_delta, min_periods=n_min_periods).close.apply(lambda y: y[0])
742
+ _l = x.rolling(window, min_periods=n_min_periods - 1).low.apply(lambda y: np.nanmin(y))
743
+ _h = x.rolling(window, min_periods=n_min_periods - 1).high.apply(lambda y: np.nanmax(y))
744
+
745
+ # calculate TR
746
+ _tr = pd.concat((abs(_h - _l), abs(_h - _c_1), abs(_l - _c_1)), axis=1).max(axis=1)
747
+
748
+ if smoother and periods > 1:
749
+ _tr = smooth(_tr.ffill(), smoother, periods * max(1, (n_min_periods - 1)))
750
+
751
+ return _tr
752
+
753
+
754
+ def trend_detector(
755
+ data, period, nstd, method="bb", exit_on_mid=False, avg="sma", atr_period=12, atr_avg="kama"
756
+ ) -> pd.DataFrame:
757
+ """
758
+ Trend detector method
759
+
760
+ :param data: input series/frame
761
+ :param period: bb period
762
+ :param method: how to calculate range: bb (bollinger), bbatr (bollinger_atr),
763
+ hilo (rolling highest high ~ lower low -> SuperTrend regime)
764
+ :param nstd: bb num of stds
765
+ :param avg: averaging ma type
766
+ :param exit_on_mid: trend is over when x crosses middle of bb
767
+ :param atr_period: ATR period (used only when use_atr is True)
768
+ :param atr_avg: ATR smoother (used only when use_atr is True)
769
+ :return: frame
770
+ """
771
+ # flatten list lambda
772
+ flatten = lambda l: [item for sublist in l for item in sublist]
773
+
774
+ # just taking close prices
775
+ x = data.close if isinstance(data, pd.DataFrame) else data
776
+
777
+ if method in ["bb", "bb_atr", "bbatr", "bollinger", "bollinger_atr"]:
778
+ if "atr" in method:
779
+ _bb = bollinger_atr(data, period, atr_period, nstd, avg, atr_avg)
780
+ else:
781
+ _bb = bollinger(x, period, nstd, avg)
782
+
783
+ midle, smax, smin = _bb["Median"], _bb["Upper"], _bb["Lower"]
784
+ elif method in ["hl", "hilo", "hhll"]:
785
+ check_frame_columns(data, ["high", "low", "close"])
786
+
787
+ midle = (data["high"].rolling(period).max() + data["low"].rolling(period).min()) / 2
788
+ smax = midle + nstd * atr(data, period)
789
+ smin = midle - nstd * atr(data, period)
790
+ else:
791
+ raise ValueError(f"Unsupported method {method}")
792
+
793
+ trend = (((x > smax.shift(1)) + 0.0) - ((x < smin.shift(1)) + 0.0)).replace(0, np.nan)
794
+
795
+ # some special case if we want to exit when close is on the opposite side of median price
796
+ if exit_on_mid:
797
+ lom, him = ((x < midle).values, (x > midle).values)
798
+ t = 0
799
+ _t = trend.values.tolist()
800
+ for i in range(len(trend)):
801
+ t0 = _t[i]
802
+ t = t0 if np.abs(t0) == 1 else t
803
+ if (t > 0 and lom[i]) or (t < 0 and him[i]):
804
+ t = 0
805
+ _t[i] = t
806
+ trend = pd.Series(_t, trend.index)
807
+ else:
808
+ trend = trend.ffill(axis=0).fillna(0.0)
809
+
810
+ # making resulting frame
811
+ m = x.to_frame().copy()
812
+ m["trend"] = trend
813
+ m["blk"] = (m.trend.shift(1) != m.trend).astype(int).cumsum()
814
+ m["x"] = abs(m.trend) * (smax * (-m.trend + 1) - smin * (1 + m.trend)) / 2
815
+ _g0 = m.reset_index().groupby(["blk", "trend"])
816
+ m["x"] = flatten(abs(_g0["x"].apply(np.array).transform(np.minimum.accumulate).values))
817
+ m["utl"] = m.x.where(m.trend > 0)
818
+ m["dtl"] = m.x.where(m.trend < 0)
819
+
820
+ # signals
821
+ tsi = pd.DatetimeIndex(_g0["time"].apply(lambda x: x.values[0]).values)
822
+ m["uts"] = m.loc[tsi].utl
823
+ m["dts"] = m.loc[tsi].dtl
824
+
825
+ return m.filter(items=["uts", "dts", "trend", "utl", "dtl"])
826
+
827
+
828
+ def denoised_trend(x: pd.DataFrame, period: int, window=0, mean: str = "kama", bar_returns: bool = True) -> pd.Series:
829
+ """
830
+ Returns denoised trend (T_i).
831
+
832
+ ----
833
+
834
+ R_i = C_i - O_i
835
+
836
+ D_i = R_i - R_{i - period}
837
+
838
+ P_i = sum_{k=i-period-1}^{i} abs(R_k)
839
+
840
+ T_i = D_i * abs(D_i) / P_i
841
+
842
+ ----
843
+
844
+ :param x: OHLC dataset (must contain .open and .close columns)
845
+ :param period: period of filtering
846
+ :param window: smothing window size (default 0)
847
+ :param mean: smoother
848
+ :param bar_returns: if True use R_i = close_i - open_i
849
+ :return: trend with removed noise
850
+ """
851
+ check_frame_columns(x, "open", "close")
852
+
853
+ if bar_returns:
854
+ ri = x.close - x.open
855
+ di = x.close - x.open.shift(period)
856
+ else:
857
+ ri = x.close - x.close.shift(1)
858
+ di = x.close - x.close.shift(period)
859
+ period -= 1
860
+
861
+ abs_di = abs(di)
862
+ si = abs(ri).rolling(window=period + 1).sum()
863
+ # for open - close there may be gaps
864
+ if bar_returns:
865
+ si = np.max(np.concatenate((abs_di.values[:, np.newaxis], si.values[:, np.newaxis]), axis=1), axis=1)
866
+ filtered_trend = abs_di * (di / si)
867
+ filtered_trend = filtered_trend.replace([np.inf, -np.inf], 0.0)
868
+
869
+ if window > 0 and mean is not None:
870
+ filtered_trend = smooth(filtered_trend, mean, window)
871
+
872
+ return filtered_trend
873
+
874
+
875
+ def rolling_percentiles(
876
+ x, window, pctls=(0, 1, 2, 3, 5, 10, 15, 25, 45, 50, 55, 75, 85, 90, 95, 97, 98, 99, 100)
877
+ ) -> pd.DataFrame:
878
+ """
879
+ Calculates percentiles from x on rolling window basis
880
+
881
+ :param x: series data
882
+ :param window: window size
883
+ :param pctls: percentiles
884
+ :return: calculated percentiles as DataFrame indexed by time.
885
+ Every pctl. is denoted as Qd (where d is taken from pctls)
886
+ """
887
+ r = nans((len(x), len(pctls)))
888
+ i = window - 1
889
+
890
+ for v in running_view(column_vector(x).flatten(), window):
891
+ r[i, :] = np.percentile(v, pctls)
892
+ i += 1
893
+
894
+ return pd.DataFrame(r, index=x.index, columns=["Q%d" % q for q in pctls])
895
+
896
+
897
+ def trend_locker(y: pd.Series, order, window, lock_forward_window=1, use_projections=False) -> pd.DataFrame:
898
+ """
899
+ Trend locker indicator based on OLS.
900
+
901
+ :param y: series data
902
+ :param order: OLS order (1 - linear, 2 - squared etc)
903
+ :param window: rolling window for regression
904
+ :param lock_forward_window: how many forward points to lock (default is 1)
905
+ :param use_projections: if need to get regression projections as well (False)
906
+ :param as_frame: true if need to force converting result to DataFrame (True)
907
+ :return: (residuals, projections, r2, betas)
908
+ """
909
+
910
+ if lock_forward_window < 1:
911
+ raise ValueError("lock_forward_window must be positive non zero integer")
912
+
913
+ n = window + lock_forward_window
914
+ yy = running_view(column_vector(y).T[0], window=n)
915
+ n_pts = len(y)
916
+ resid = nans((n_pts, lock_forward_window))
917
+ proj = nans((n_pts, lock_forward_window)) if use_projections else None
918
+ r_sqr = nans(n_pts)
919
+ betas = nans((n_pts, order + 1))
920
+
921
+ for i, p in enumerate(yy):
922
+ x = np.vander(np.linspace(-1, 1, n), order + 1)
923
+ lr = OLS(p[:window], x[:window, :]).fit()
924
+
925
+ r_sqr[window - 1 + i] = lr.rsquared
926
+ betas[window - 1 + i, :] = lr.params
927
+
928
+ pl = p[-lock_forward_window:]
929
+ xl = x[-lock_forward_window:, :]
930
+ fwd_prj = np.sum(lr.params * xl, axis=1)
931
+ fwd_data = pl - fwd_prj
932
+
933
+ # store forward data
934
+ np.fill_diagonal(resid[window + i : n + i + 1, :], fwd_data)
935
+
936
+ # if we asked for projections
937
+ if use_projections:
938
+ np.fill_diagonal(proj[window + i : n + i + 1, :], fwd_prj)
939
+
940
+ # return pandas frame if input is series/frame
941
+ y = pd.Series(y, name="X")
942
+ y_idx = y.index
943
+ f_res = pd.DataFrame(data=resid, index=y_idx, columns=["R%d" % i for i in range(1, lock_forward_window + 1)])
944
+ f_prj = None
945
+ if use_projections:
946
+ f_prj = pd.DataFrame(data=proj, index=y_idx, columns=["L%d" % i for i in range(1, lock_forward_window + 1)])
947
+ r = pd.DataFrame({"r2": r_sqr}, index=y_idx, columns=["r2"])
948
+ betas_fr = pd.DataFrame(betas, index=y_idx, columns=["b%d" % i for i in range(order + 1)])
949
+ return pd.concat((y, f_res, f_prj, r, betas_fr), axis=1)
950
+
951
+
952
+ def __slope_ols(x):
953
+ x = x[~np.isnan(x)]
954
+ xs = 2 * (x - min(x)) / (max(x) - min(x)) - 1
955
+ m = OLS(xs, np.vander(np.linspace(-1, 1, len(xs)), 2)).fit()
956
+ return m.params[0]
957
+
958
+
959
+ def __slope_angle(p, t):
960
+ return 180 * np.arctan(p / t) / np.pi
961
+
962
+
963
+ def rolling_series_slope(x: pd.Series, period: int, method="ols", scaling="transform", n_bins=5) -> pd.Series:
964
+ """
965
+ Rolling slope indicator. May be used as trend indicator
966
+
967
+ :param x: time series
968
+ :param period: period for OLS window
969
+ :param n_bins: number of bins used for scaling
970
+ :param method: method used for metric of regression line slope: ('ols' or 'angle')
971
+ :param scaling: how to scale slope 'transform' / 'binarize' / nothing
972
+ :return: series slope metric
973
+ """
974
+
975
+ def __binarize(_x: pd.Series, n, limits=(None, None), center=False):
976
+ n0 = n // 2 if center else 0
977
+ _min = np.min(_x) if limits[0] is None else limits[0]
978
+ _max = np.max(_x) if limits[1] is None else limits[1]
979
+ return pd.Series(np.floor(n * (_x - _min) / (_max - _min)) - n0, index=_x.index)
980
+
981
+ def __scaling_transform(x: pd.Series, n=5, need_round=True, limits=None):
982
+ if limits is None:
983
+ _lmax = max(abs(x))
984
+ _lmin = -_lmax
985
+ else:
986
+ _lmax = max(limits)
987
+ _lmin = min(limits)
988
+
989
+ if need_round:
990
+ ni = np.round(np.interp(x, (_lmin, _lmax), (-2 * n, +2 * n))) / 2
991
+ else:
992
+ ni = np.interp(x, (_lmin, _lmax), (-n, +n))
993
+ return pd.Series(ni, index=x.index)
994
+
995
+ if method == "ols":
996
+ slp_meth = lambda z: __slope_ols(z)
997
+ _lmts = (-1, 1)
998
+ elif method == "angle":
999
+ slp_meth = lambda z: __slope_angle(z[-1] - z[0], len(z))
1000
+ _lmts = (-90, 90)
1001
+ else:
1002
+ raise ValueError("Unknown Method %s" % method)
1003
+
1004
+ _min_p = period
1005
+ if isinstance(period, str):
1006
+ _min_p = pd.Timedelta(period).days
1007
+
1008
+ roll_slope = x.rolling(period, min_periods=_min_p).apply(slp_meth)
1009
+
1010
+ if scaling == "transform":
1011
+ return __scaling_transform(roll_slope, n=n_bins, limits=_lmts)
1012
+ elif scaling == "binarize":
1013
+ return __binarize(roll_slope, n=(n_bins - 1) * 4, limits=_lmts, center=True) / 2
1014
+
1015
+ return roll_slope
1016
+
1017
+
1018
+ def adx(ohlc: pd.DataFrame, period: int, smoother="kama") -> pd.DataFrame:
1019
+ """
1020
+ Average Directional Index.
1021
+
1022
+ ADX = 100 * MA(abs((+DI - -DI) / (+DI + -DI)))
1023
+
1024
+ Where:
1025
+ -DI = 100 * MA(-DM) / ATR
1026
+ +DI = 100 * MA(+DM) / ATR
1027
+
1028
+ +DM: if UPMOVE > DWNMOVE and UPMOVE > 0 then +DM = UPMOVE else +DM = 0
1029
+ -DM: if DWNMOVE > UPMOVE and DWNMOVE > 0 then -DM = DWNMOVE else -DM = 0
1030
+
1031
+ DWNMOVE = L_{t-1} - L_t
1032
+ UPMOVE = H_t - H_{t-1}
1033
+
1034
+ :param ohlc: DataFrame with ohlc data
1035
+ :param period: indicator period
1036
+ :param smoother: smoothing function (kama is default)
1037
+ :param as_frame: set to True if DataFrame needed as result (default false)
1038
+ :return: adx, DIp, DIm or DataFrame
1039
+ """
1040
+ check_frame_columns(ohlc, "open", "high", "low", "close")
1041
+
1042
+ h, l = ohlc["high"], ohlc["low"]
1043
+ _atr = atr(ohlc, period, smoother=smoother)
1044
+
1045
+ Mu, Md = h.diff(), -l.diff()
1046
+ DMp = Mu * (((Mu > 0) & (Mu > Md)) + 0)
1047
+ DMm = Md * (((Md > 0) & (Md > Mu)) + 0)
1048
+ DIp = 100 * smooth(DMp, smoother, period) / _atr
1049
+ DIm = 100 * smooth(DMm, smoother, period) / _atr
1050
+ _adx = 100 * smooth(abs((DIp - DIm) / (DIp + DIm)), smoother, period)
1051
+
1052
+ return pd.concat((_adx.rename("ADX"), DIp.rename("DIp"), DIm.rename("DIm")), axis=1)
1053
+
1054
+
1055
+ def rsi(x, periods, smoother=sma) -> pd.Series:
1056
+ """
1057
+ U = X_t - X_{t-1}, D = 0 when X_t > X_{t-1}
1058
+ D = X_{t-1} - X_t, U = 0 when X_t < X_{t-1}
1059
+ U = 0, D = 0, when X_t = X_{t-1}
1060
+
1061
+ RSI = 100 * E[U, n] / (E[U, n] + E[D, n])
1062
+
1063
+ """
1064
+ xx = pd.concat((x, x.shift(1)), axis=1, keys=["c", "p"])
1065
+ df = xx.c - xx.p
1066
+ mu = smooth(df.where(df > 0, 0), smoother, periods)
1067
+ md = smooth(abs(df.where(df < 0, 0)), smoother, periods)
1068
+
1069
+ return 100 * mu / (mu + md)
1070
+
1071
+
1072
+ def pivot_point(data: pd.DataFrame, method="classic", timeframe="D", timezone=None) -> pd.DataFrame:
1073
+ """
1074
+ Pivot points indicator based for daily/weekly/monthly levels.
1075
+ It supports 'classic', 'woodie' and 'camarilla' species.
1076
+ """
1077
+ if timeframe not in ["D", "W", "M"]:
1078
+ raise ValueError(f"Unsupported pivots timeframe {timeframe}: only 'D', 'W', 'M' allowed !")
1079
+
1080
+ tf_resample = f"1{timeframe}"
1081
+ x: pd.DataFrame | dict = ohlc_resample(data, tf_resample, resample_tz=timezone)
1082
+
1083
+ pp = pd.DataFrame()
1084
+ if method == "classic":
1085
+ pvt = (x["high"] + x["low"] + x["close"]) / 3
1086
+ _range = x["high"] - x["low"]
1087
+
1088
+ pp["R4"] = pvt + 3 * _range
1089
+ pp["R3"] = pvt + 2 * _range
1090
+ pp["R2"] = pvt + _range
1091
+ pp["R1"] = pvt * 2 - x["low"]
1092
+ pp["P"] = pvt
1093
+ pp["S1"] = pvt * 2 - x["high"]
1094
+ pp["S2"] = pvt - _range
1095
+ pp["S3"] = pvt - 2 * _range
1096
+ pp["S4"] = pvt - 3 * _range
1097
+
1098
+ # rearrange
1099
+ pp = pp[["R4", "R3", "R2", "R1", "P", "S1", "S2", "S3", "S4"]]
1100
+
1101
+ elif method == "woodie":
1102
+ pvt = (x.high + x.low + x.open + x.open) / 4
1103
+ _range = x.high - x.low
1104
+
1105
+ pp["R3"] = x.high + 2 * (pvt - x.low)
1106
+ pp["R2"] = pvt + _range
1107
+ pp["R1"] = pvt * 2 - x.low
1108
+ pp["P"] = pvt
1109
+ pp["S1"] = pvt * 2 - x.high
1110
+ pp["S2"] = pvt - _range
1111
+ pp["S3"] = x.low + 2 * (x.high - pvt)
1112
+ pp = pp[["R3", "R2", "R1", "P", "S1", "S2", "S3"]]
1113
+
1114
+ elif method == "camarilla":
1115
+ """
1116
+ R4 = C + RANGE * 1.1/2
1117
+ R3 = C + RANGE * 1.1/4
1118
+ R2 = C + RANGE * 1.1/6
1119
+ R1 = C + RANGE * 1.1/12
1120
+ PP = (HIGH + LOW + CLOSE) / 3
1121
+ S1 = C - RANGE * 1.1/12
1122
+ S2 = C - RANGE * 1.1/6
1123
+ S3 = C - RANGE * 1.1/4
1124
+ S4 = C - RANGE * 1.1/2
1125
+ """
1126
+ pvt = (x.high + x.low + x.close) / 3
1127
+ _range = x.high - x.low
1128
+
1129
+ pp["R4"] = x.close + _range * 1.1 / 2
1130
+ pp["R3"] = x.close + _range * 1.1 / 4
1131
+ pp["R2"] = x.close + _range * 1.1 / 6
1132
+ pp["R1"] = x.close + _range * 1.1 / 12
1133
+ pp["P"] = pvt
1134
+ pp["S1"] = x.close - _range * 1.1 / 12
1135
+ pp["S2"] = x.close - _range * 1.1 / 6
1136
+ pp["S3"] = x.close - _range * 1.1 / 4
1137
+ pp["S4"] = x.close - _range * 1.1 / 2
1138
+ pp = pp[["R4", "R3", "R2", "R1", "P", "S1", "S2", "S3", "S4"]]
1139
+ else:
1140
+ raise ValueError("Unknown method %s. Available methods are: 'classic', 'woodie', 'camarilla'" % method)
1141
+
1142
+ pp.index = pp.index + pd.Timedelta("1D")
1143
+ return data.combine_first(pp).ffill(axis=0)[pp.columns]
1144
+
1145
+
1146
+ def intraday_min_max(data: pd.DataFrame, timezone="UTC") -> pd.DataFrame:
1147
+ """
1148
+ Intradeay min and max values
1149
+ :param data: ohlcv series
1150
+ :param timezone: timezone (default EET) used for find day's start/end (EET for forex data)
1151
+ :return: series with min and max values intraday
1152
+ """
1153
+ check_frame_columns(data, "open", "high", "low", "close")
1154
+
1155
+ def _day_min_max(d):
1156
+ _d_min = np.minimum.accumulate(d.low)
1157
+ _d_max = np.maximum.accumulate(d.high)
1158
+ return scols(_d_min, _d_max, keys=["Min", "Max"])
1159
+
1160
+ source_tz = data.index.tz
1161
+ if not source_tz:
1162
+ x = data.tz_localize("GMT")
1163
+ else:
1164
+ x = data
1165
+
1166
+ x = x.tz_convert(timezone)
1167
+ return x.groupby(x.index.date).apply(_day_min_max).tz_convert(source_tz)
1168
+
1169
+
1170
+ def stochastic(x: pd.Series | pd.DataFrame, period: int, smooth_period: int, smoother="sma") -> pd.DataFrame:
1171
+ """
1172
+ Classical stochastic oscillator indicator
1173
+ :param x: series or OHLC dataframe
1174
+ :param period: indicator's period
1175
+ :param smooth_period: period of smoothing
1176
+ :param smoother: smoothing method (sma by default)
1177
+ :return: K and D series as DataFrame
1178
+ """
1179
+ # in case we received HLC data
1180
+ if has_columns(x, "close", "high", "low"):
1181
+ hi, li, xi = x["high"], x["low"], x["close"]
1182
+ else:
1183
+ hi, li, xi = x, x, x
1184
+
1185
+ hh = hi.rolling(period).max()
1186
+ ll = li.rolling(period).min()
1187
+ k = 100 * (xi - ll) / (hh - ll)
1188
+ d = smooth(k, smoother, smooth_period)
1189
+ return scols(k, d, names=["K", "D"])
1190
+
1191
+
1192
+ @njit
1193
+ def _laguerre_calc(xx, g):
1194
+ l0, l1, l2, l3, f = np.zeros(len(xx)), np.zeros(len(xx)), np.zeros(len(xx)), np.zeros(len(xx)), np.zeros(len(xx))
1195
+ for i in range(1, len(xx)):
1196
+ l0[i] = (1 - g) * xx[i] + g * l0[i - 1]
1197
+ l1[i] = -g * l0[i] + l0[i - 1] + g * l1[i - 1]
1198
+ l2[i] = -g * l1[i] + l1[i - 1] + g * l2[i - 1]
1199
+ l3[i] = -g * l2[i] + l2[i - 1] + g * l3[i - 1]
1200
+ f[i] = (l0[i] + 2 * l1[i] + 2 * l2[i] + l3[i]) / 6
1201
+ return f
1202
+
1203
+
1204
+ def laguerre_filter(x, gamma=0.8) -> pd.Series:
1205
+ """
1206
+ Laguerre 4 pole IIR filter
1207
+ """
1208
+ return pd.Series(_laguerre_calc(x.values.flatten(), gamma), x.index)
1209
+
1210
+
1211
+ @njit
1212
+ def _lrsi_calc(xx, g):
1213
+ l0, l1, l2, l3, f = np.zeros(len(xx)), np.zeros(len(xx)), np.zeros(len(xx)), np.zeros(len(xx)), np.zeros(len(xx))
1214
+ for i in range(1, len(xx)):
1215
+ l0[i] = (1 - g) * xx[i] + g * l0[i - 1]
1216
+ l1[i] = -g * l0[i] + l0[i - 1] + g * l1[i - 1]
1217
+ l2[i] = -g * l1[i] + l1[i - 1] + g * l2[i - 1]
1218
+ l3[i] = -g * l2[i] + l2[i - 1] + g * l3[i - 1]
1219
+
1220
+ _cu, _cd = 0, 0
1221
+ _d0 = l0[i] - l1[i]
1222
+ _d1 = l1[i] - l2[i]
1223
+ _d2 = l2[i] - l3[i]
1224
+
1225
+ if _d0 >= 0:
1226
+ _cu = _d0
1227
+ else:
1228
+ _cd = np.abs(_d0)
1229
+
1230
+ if _d1 >= 0:
1231
+ _cu += _d1
1232
+ else:
1233
+ _cd += np.abs(_d1)
1234
+
1235
+ if _d2 >= 0:
1236
+ _cu += _d2
1237
+ else:
1238
+ _cd += np.abs(_d2)
1239
+
1240
+ f[i] = 100 * _cu / (_cu + _cd) if (_cu + _cd) != 0 else 0
1241
+
1242
+ return f
1243
+
1244
+
1245
+ def lrsi(x, gamma=0.5) -> pd.Series:
1246
+ """
1247
+ Laguerre RSI
1248
+ """
1249
+ return pd.Series(_lrsi_calc(x.values.flatten(), gamma), x.index)
1250
+
1251
+
1252
+ @njit
1253
+ def calc_ema_time(t, vv, period, min_time_quant, with_correction=True):
1254
+ index = np.empty(len(vv) - 1, dtype=np.int64)
1255
+ values = np.empty(len(vv) - 1, dtype=np.float64)
1256
+ dt = np.diff(t)
1257
+ dt[dt == 0] = min_time_quant
1258
+ a = dt / period
1259
+ u = np.exp(-a)
1260
+ _ep = vv[0]
1261
+
1262
+ if with_correction:
1263
+ v = (1 - u) / a
1264
+ c1 = v - u
1265
+ c2 = 1 - v
1266
+ for i in range(0, len(vv) - 1):
1267
+ _ep = u[i] * _ep + c1[i] * vv[i] + c2[i] * vv[i + 1]
1268
+ index[i] = t[i + 1]
1269
+ values[i] = _ep
1270
+ else:
1271
+ v = 1 - u
1272
+ for i in range(0, len(vv) - 1):
1273
+ _ep = _ep + v[i] * (vv[i + 1] - _ep)
1274
+ index[i] = t[i + 1]
1275
+ values[i] = _ep
1276
+ return index, values
1277
+
1278
+
1279
+ def ema_time(
1280
+ x: pd.Series, period: str | pd.Timedelta, min_time_quant=pd.Timedelta("1ms"), with_correction=True
1281
+ ) -> pd.Series:
1282
+ """
1283
+ EMA on non consistent time series
1284
+
1285
+ https://stackoverflow.com/questions/1023860/exponential-moving-average-sampled-at-varying-times
1286
+ """
1287
+ if not isinstance(x, pd.Series):
1288
+ raise ValueError("Input series must be instance of pandas Series class")
1289
+
1290
+ t = x.index.values
1291
+ vv = x.values
1292
+
1293
+ if isinstance(period, str):
1294
+ period = pd.Timedelta(period)
1295
+
1296
+ index, values = calc_ema_time(t.astype("int64"), vv, period.value, min_time_quant.value, with_correction)
1297
+
1298
+ old_ser_name = "UnknownSeries" if x.name is None else x.name
1299
+ res = pd.Series(values, pd.to_datetime(index), name="EMAT_%d_sec_%s" % (period.seconds, old_ser_name))
1300
+ res = res.loc[~res.index.duplicated(keep="first")]
1301
+ return res
1302
+
1303
+
1304
+ @njit
1305
+ def _rolling_rank(x, period, pctls):
1306
+ x = np.reshape(x, (x.shape[0], -1)).flatten()
1307
+ r = nans((len(x)))
1308
+ for i in range(period, len(x)):
1309
+ v = x[i - period : i]
1310
+ r[i] = np.argmax(np.sign(np.append(np.percentile(v, pctls), np.inf) - x[i]))
1311
+ return r
1312
+
1313
+
1314
+ def rolling_rank(x, period, pctls=(25, 50, 75)):
1315
+ """
1316
+ Calculates percentile rank (number of percentile's range) on rolling window basis from series of data
1317
+
1318
+ :param x: series or frame of data
1319
+ :param period: window size
1320
+ :param pctls: percentiles (25,50,75) are default.
1321
+ Function returns 0 for values hit 0...25, 1 for 25...50, 2 for 50...75 and 3 for 75...100
1322
+ on rolling window basis
1323
+ :return: series/frame of ranks
1324
+ """
1325
+ if period > len(x):
1326
+ raise ValueError(f"Period {period} exceeds number of data records {len(x)} ")
1327
+
1328
+ if isinstance(x, pd.DataFrame):
1329
+ z = pd.DataFrame.from_dict({c: rolling_rank(s, period, pctls) for c, s in x.iteritems()})
1330
+ elif isinstance(x, pd.Series):
1331
+ z = pd.Series(_rolling_rank(x.values, period, pctls), x.index, name=x.name)
1332
+ else:
1333
+ z = _rolling_rank(x.values, period, pctls)
1334
+ return z
1335
+
1336
+
1337
+ def rolling_vwap(ohlc, period):
1338
+ """
1339
+ Calculate rolling volume weighted average price using specified rolling period
1340
+ """
1341
+ check_frame_columns(ohlc, "close", "volume")
1342
+
1343
+ if period > len(ohlc):
1344
+ raise ValueError(f"Period {period} exceeds number of data records {len(ohlc)} ")
1345
+
1346
+ def __rollsum(x, window):
1347
+ rs = pd.DataFrame(rolling_sum(column_vector(x.values.copy()), window), index=x.index)
1348
+ rs[rs < 0] = np.nan
1349
+ return rs
1350
+
1351
+ return __rollsum((ohlc.volume * ohlc.close), period) / __rollsum(ohlc.volume, period)
1352
+
1353
+
1354
+ def fractals(data, nf, actual_time=True, align_with_index=False) -> pd.DataFrame:
1355
+ """
1356
+ Calculates fractals indicator
1357
+
1358
+ :param data: OHLC bars series
1359
+ :param nf: fractals lookback/foreahed parameter
1360
+ :param actual_time: if true fractals timestamps at bars where they would be observed
1361
+ :param align_with_index: if true result will be reindexed as input ohlc data
1362
+ :return: pd.DataFrame with U (upper) and L (lower) fractals columns
1363
+ """
1364
+ check_frame_columns(data, "high", "low")
1365
+
1366
+ ohlc = scols(data.close.ffill(), data, keys=["A", "ohlc"]).ffill(axis=1)["ohlc"]
1367
+ ru, rd = None, None
1368
+ for i in range(1, nf + 1):
1369
+ ru = scols(
1370
+ ru, (ohlc.high - ohlc.high.shift(i)).rename(f"p{i}"), (ohlc.high - ohlc.high.shift(-i)).rename(f"p_{i}")
1371
+ )
1372
+ rd = scols(rd, (ohlc.low.shift(i) - ohlc.low).rename(f"p{i}"), (ohlc.low.shift(-i) - ohlc.low).rename(f"p_{i}"))
1373
+
1374
+ ru, rd = ru.dropna(), rd.dropna()
1375
+
1376
+ upF = pd.Series(+1, ru[((ru > 0).all(axis=1))].index)
1377
+ dwF = pd.Series(-1, rd[((rd > 0).all(axis=1))].index)
1378
+
1379
+ shift_forward = nf if actual_time else 0
1380
+ ht = ohlc.loc[upF.index].reindex(ohlc.index).shift(shift_forward).high
1381
+ lt = ohlc.loc[dwF.index].reindex(ohlc.index).shift(shift_forward).low
1382
+
1383
+ if not align_with_index:
1384
+ ht = ht.dropna()
1385
+ lt = lt.dropna()
1386
+
1387
+ return scols(ht, lt, names=["U", "L"])
1388
+
1389
+
1390
+ @njit
1391
+ def _jma(x, period, phase, power):
1392
+ x = x.astype(np.float64)
1393
+
1394
+ beta = 0.45 * (period - 1) / (0.45 * (period - 1) + 2)
1395
+ alpha = np.power(beta, power)
1396
+ phase = 0.5 if phase < -100 else 2.5 if phase > 100 else phase / 100 + 1.5
1397
+
1398
+ for i in range(0, x.shape[1]):
1399
+ xs = x[:, i]
1400
+ nan_start = np.where(~np.isnan(xs))[0][0]
1401
+ r = np.zeros(xs.shape[0])
1402
+ det0 = det1 = 0
1403
+ ma1 = ma2 = jm = xs[0]
1404
+
1405
+ for k, xi in enumerate(xs):
1406
+ ma1 = (1 - alpha) * xi + alpha * ma1
1407
+ det0 = (xi - ma1) * (1 - beta) + beta * det0
1408
+ ma2 = ma1 + phase * det0
1409
+ det1 = (ma2 - jm) * np.power(1 - alpha, 2) + np.power(alpha, 2) * det1
1410
+ jm = jm + det1
1411
+ r[k] = jm
1412
+ x[:, i] = np.concatenate((nans(nan_start), r))
1413
+ return x
1414
+
1415
+
1416
+ @__wrap_dataframe_decorator
1417
+ def jma(x, period, phase=0, power=2):
1418
+ """
1419
+ Jurik MA (code from https://www.tradingview.com/script/nZuBWW9j-Jurik-Moving-Average/)
1420
+ :param x: data
1421
+ :param period: period
1422
+ :param phase: phase
1423
+ :param power: power
1424
+ :return: jma
1425
+ """
1426
+ x = column_vector(x)
1427
+ if len(x) < period:
1428
+ raise ValueError("Not enough data for calculate jma !")
1429
+
1430
+ return _jma(x, period, phase, power)
1431
+
1432
+
1433
+ def super_trend(
1434
+ data, length: int = 22, mult: float = 3, src: str = "hl2", wicks: bool = True, atr_smoother="sma"
1435
+ ) -> pd.DataFrame:
1436
+ """
1437
+ SuperTrend indicator (implementation from https://www.tradingview.com/script/VLWVV7tH-SuperTrend/)
1438
+
1439
+ :param data: OHLC data
1440
+ :param length: ATR Period
1441
+ :param mult: ATR Multiplier
1442
+ :param src: Source: close, hl2, hlc3, ohlc4. For example hl2 = (high + low) / 2, etc
1443
+ :param wicks: Take Wicks
1444
+ :param atr_smoother: ATR smoothing function (default sma)
1445
+ :return:
1446
+ """
1447
+ check_frame_columns(data, "open", "high", "low", "close")
1448
+
1449
+ def calc_src(data, src):
1450
+ if src == "close":
1451
+ return data["close"]
1452
+ elif src == "hl2":
1453
+ return (data["high"] + data["low"]) / 2
1454
+ elif src == "hlc3":
1455
+ return (data["high"] + data["low"] + data["close"]) / 3
1456
+ elif src == "ohlc4":
1457
+ return (data["open"] + data["high"] + data["low"] + data["close"]) / 4
1458
+ else:
1459
+ raise ValueError("unsupported src: %s" % src)
1460
+
1461
+ atr_data = abs(mult) * atr(data, length, smoother=atr_smoother)
1462
+ src_data = calc_src(data, src)
1463
+ high_price = data["high"] if wicks else data["close"]
1464
+ low_price = data["low"] if wicks else data["close"]
1465
+ doji4price = (data["open"] == data["close"]) & (data["open"] == data["low"]) & (data["open"] == data["high"])
1466
+
1467
+ p_high_price = high_price.shift(1)
1468
+ p_low_price = low_price.shift(1)
1469
+
1470
+ longstop = src_data - atr_data
1471
+ shortstop = src_data + atr_data
1472
+
1473
+ prev_longstop = np.nan
1474
+ prev_shortstop = np.nan
1475
+
1476
+ longstop_d = {}
1477
+ shortstop_d = {}
1478
+ for i, ls, ss, lp, hp, d4 in zip(
1479
+ src_data.index, longstop.values, shortstop.values, p_low_price.values, p_high_price.values, doji4price.values
1480
+ ):
1481
+ # longs
1482
+ if np.isnan(prev_longstop):
1483
+ prev_longstop = ls
1484
+
1485
+ if ls > 0:
1486
+ if d4:
1487
+ longstop_d[i] = prev_longstop
1488
+ else:
1489
+ longstop_d[i] = max(ls, prev_longstop) if lp > prev_longstop else ls
1490
+ else:
1491
+ longstop_d[i] = prev_longstop
1492
+
1493
+ prev_longstop = longstop_d[i]
1494
+
1495
+ # shorts
1496
+ if np.isnan(prev_shortstop):
1497
+ prev_shortstop = ss
1498
+
1499
+ if ss > 0:
1500
+ if d4:
1501
+ shortstop_d[i] = prev_shortstop
1502
+ else:
1503
+ shortstop_d[i] = min(ss, prev_shortstop) if hp < prev_shortstop else ss
1504
+ else:
1505
+ shortstop_d[i] = prev_shortstop
1506
+
1507
+ prev_shortstop = shortstop_d[i]
1508
+
1509
+ longstop = pd.Series(longstop_d)
1510
+ shortstop = pd.Series(shortstop_d)
1511
+
1512
+ direction = pd.Series(np.nan, src_data.index)
1513
+ direction.iloc[(low_price < longstop.shift(1))] = -1
1514
+ direction.iloc[(high_price > shortstop.shift(1))] = 1
1515
+ # direction.fillna(method='ffill', inplace=True) # deprecated
1516
+ direction.ffill(inplace=True)
1517
+
1518
+ longstop_res = pd.Series(np.nan, src_data.index)
1519
+ shortstop_res = pd.Series(np.nan, src_data.index)
1520
+
1521
+ shortstop_res[direction == -1] = shortstop[direction == -1]
1522
+ longstop_res[direction == 1] = longstop[direction == 1]
1523
+
1524
+ return scols(longstop_res, shortstop_res, direction, names=["utl", "dtl", "trend"])
1525
+
1526
+
1527
+ def choppiness(
1528
+ data,
1529
+ period,
1530
+ upper=61.8,
1531
+ lower=38.2,
1532
+ volatility_estimator="t",
1533
+ volume_adjusting=False,
1534
+ identification="strong",
1535
+ ) -> pd.Series:
1536
+ """
1537
+ Calculate market choppiness index using volatility-based formula.
1538
+
1539
+ Volatile market leads to false breakouts and does not respect support/resistance levels (being choppy).
1540
+ We cannot know whether we are in a trend or in a range.
1541
+
1542
+ Values above 61.8% indicate a choppy market that is bound to breakout. We should be ready for some directional movement.
1543
+ Values below 38.2% indicate a strong trending market that is bound to stabilize.
1544
+
1545
+ When volatility_estimator="t" (true range), this gives the classical Dreiss choppiness formula.
1546
+
1547
+ Parameters
1548
+ ----------
1549
+ data : pd.DataFrame
1550
+ OHLCV data frame containing 'open', 'high', 'low', 'close', 'volume' columns
1551
+ period : int
1552
+ Lookback period for calculations
1553
+ upper : float, default 61.8
1554
+ Upper threshold - values above indicate choppy market
1555
+ lower : float, default 38.2
1556
+ Lower threshold - values below indicate trending market
1557
+ volatility_estimator : str, default 't'
1558
+ Method used for volatility calculation (see volatility() function)
1559
+ volume_adjusting : bool, default False
1560
+ If True, adjusts volatility by relative volume changes
1561
+ identification : str, default 'strong'
1562
+ Identification method:
1563
+ - 'mid': Identifies three states: choppy (1) when crossing upper threshold, trending (-1) when crossing lower threshold,
1564
+ and neutral (0) when between thresholds
1565
+ - 'strong': Binary classification focused on trending - returns 1 when entering trending regime (crossing below lower threshold),
1566
+ 0 when exiting trending regime (crossing above lower threshold)
1567
+ - 'weak': Binary classification focused on choppiness - returns 1 when entering choppy regime (crossing above upper threshold),
1568
+ 0 when entering trending regime (crossing below lower threshold)
1569
+
1570
+ Returns
1571
+ -------
1572
+ pd.Series
1573
+ Boolean series where True indicates choppy market and False indicates trending market
1574
+ """
1575
+
1576
+ if volume_adjusting:
1577
+ check_frame_columns(data, "open", "high", "low", "close", "volume")
1578
+ xr = data[["open", "high", "low", "close", "volume"]]
1579
+ else:
1580
+ check_frame_columns(data, "open", "high", "low", "close")
1581
+ xr = data[["open", "high", "low", "close"]]
1582
+
1583
+ rng = (
1584
+ xr["high"].rolling(window=period, min_periods=period).max()
1585
+ - xr["low"].rolling(window=period, min_periods=period).min()
1586
+ )
1587
+
1588
+ if volatility_estimator == "atr":
1589
+ a = atr(xr, period, smoother="ema")
1590
+ vol = pd.Series(rolling_sum(column_vector(a.copy()), period).flatten(), a.index).replace(0, np.nan)
1591
+ else:
1592
+ vol = period * volatility(
1593
+ xr, period, method=volatility_estimator, volume_adjusting=volume_adjusting, percentage=False
1594
+ )
1595
+ ci = 100 * (1 / np.log(period)) * np.log(vol / rng)
1596
+
1597
+ f0 = pd.Series(np.nan, ci.index, dtype=int)
1598
+ match identification:
1599
+ case "mid":
1600
+ f0[(ci > lower) & (ci < upper)] = 0
1601
+ f0[(ci > upper) & (ci.shift(1) <= upper)] = 1
1602
+ f0[(ci < lower) & (ci.shift(1) >= lower)] = -1
1603
+ case "strong":
1604
+ f0[(ci > lower) & (ci.shift(1) <= lower)] = 1
1605
+ f0[(ci < lower) & (ci.shift(1) >= lower)] = 0
1606
+ case "weak":
1607
+ f0[(ci > upper) & (ci.shift(1) <= upper)] = 1
1608
+ f0[(ci < lower) & (ci.shift(1) >= lower)] = 0
1609
+ case _:
1610
+ raise ValueError(f"Invalid identification: {identification}")
1611
+
1612
+ # f0 = pd.Series(np.nan, ci.index, dtype=bool)
1613
+ # f0[ci >= upper] = True
1614
+ # f0[ci <= lower] = False
1615
+ # return f0.ffill().fillna(False)
1616
+
1617
+ # f0 = pd.Series(np.nan, ci.index, dtype=int)
1618
+ # f0[(ci > upper) & (ci.shift(1) <= upper)] = +1
1619
+ # f0[(ci < lower) & (ci.shift(1) >= lower)] = 0
1620
+ # return f0.ffill().fillna(0)
1621
+
1622
+ return f0.ffill().fillna(0)
1623
+
1624
+
1625
+ @njit
1626
+ def __psar(close, high, low, iaf, maxaf):
1627
+ """
1628
+ PSAR loop in numba
1629
+ """
1630
+ length = len(close)
1631
+ psar = close[0 : len(close)].copy()
1632
+
1633
+ psarbull, psarbear = np.ones(length) * np.nan, np.ones(length) * np.nan
1634
+
1635
+ af, ep, hp, lp, bull = iaf, low[0], high[0], low[0], True
1636
+
1637
+ for i in range(2, length):
1638
+ if bull:
1639
+ psar[i] = psar[i - 1] + af * (hp - psar[i - 1])
1640
+ else:
1641
+ psar[i] = psar[i - 1] + af * (lp - psar[i - 1])
1642
+
1643
+ reverse = False
1644
+ if bull:
1645
+ if low[i] < psar[i]:
1646
+ bull = False
1647
+ reverse = True
1648
+ psar[i] = hp
1649
+ lp = low[i]
1650
+ af = iaf
1651
+ else:
1652
+ if high[i] > psar[i]:
1653
+ bull = True
1654
+ reverse = True
1655
+ psar[i] = lp
1656
+ hp = high[i]
1657
+ af = iaf
1658
+
1659
+ if not reverse:
1660
+ if bull:
1661
+ if high[i] > hp:
1662
+ hp = high[i]
1663
+ af = min(af + iaf, maxaf)
1664
+ if low[i - 1] < psar[i]:
1665
+ psar[i] = low[i - 1]
1666
+ if low[i - 2] < psar[i]:
1667
+ psar[i] = low[i - 2]
1668
+ else:
1669
+ if low[i] < lp:
1670
+ lp = low[i]
1671
+ af = min(af + iaf, maxaf)
1672
+ if high[i - 1] > psar[i]:
1673
+ psar[i] = high[i - 1]
1674
+ if high[i - 2] > psar[i]:
1675
+ psar[i] = high[i - 2]
1676
+
1677
+ if bull:
1678
+ psarbull[i] = psar[i]
1679
+ else:
1680
+ psarbear[i] = psar[i]
1681
+
1682
+ return psar, psarbear, psarbull
1683
+
1684
+
1685
+ def psar(ohlc, iaf=0.02, maxaf=0.2) -> pd.DataFrame:
1686
+ """
1687
+ Parabolic SAR indicator
1688
+ """
1689
+ check_frame_columns(ohlc, "high", "low", "close")
1690
+
1691
+ # do cycle in numba
1692
+ psar_i, psarbear, psarbull = __psar(ohlc["close"].values, ohlc["high"].values, ohlc["low"].values, iaf, maxaf)
1693
+
1694
+ return pd.DataFrame({"psar": psar_i, "up": psarbear, "down": psarbull}, index=ohlc.index)
1695
+
1696
+
1697
+ @__wrap_dataframe_decorator
1698
+ def fdi(x: pd.Series | pd.DataFrame, e_period=30) -> np.ndarray:
1699
+ """
1700
+ The Fractal Dimension Index determines the amount of market volatility. Value of 1.5 suggests the market is
1701
+ acting in a completely random fashion. As the indicator deviates from 1.5, the opportunity for earning profits
1702
+ is increased in proportion to the amount of deviation.
1703
+
1704
+ The indicator < 1.5 when the market is in a trend. And it > 1.5 when there is a high volatility.
1705
+ When the FDI crosses 1.5 upward it means that a trend is finishing, the market becomes erratic and
1706
+ a high volatility is present.
1707
+
1708
+ For more information, see
1709
+ http://www.forex-tsd.com/suggestions-trading-systems/6119-tasc-03-07-fractal-dimension-index.html
1710
+
1711
+ :param x: input series (pd.Series)
1712
+ :param e_period: period of indicator (30 is default)
1713
+ """
1714
+ len_shape = 2
1715
+ data = x.copy()
1716
+ if isinstance(data, (pd.DataFrame, pd.Series)):
1717
+ data = data.values
1718
+ if len(data.shape) == 1:
1719
+ len_shape = 1
1720
+ data = data.reshape(data.shape[0], 1)
1721
+ fdi_result: np.ndarray | None = None
1722
+ for work_data in running_view(data, e_period, 0):
1723
+ if fdi_result is None:
1724
+ fdi_result = _fdi(work_data, e_period, len_shape)
1725
+ else:
1726
+ fdi_result = np.vstack([fdi_result, _fdi(work_data, e_period, len_shape)])
1727
+ fdi_result[np.isinf(fdi_result)] = 0
1728
+ fdi_result = np.vstack((np.full([e_period, x.shape[-1] if len(x.shape) == 2 else 1], np.nan), fdi_result[1:]))
1729
+ return fdi_result
1730
+
1731
+
1732
+ @njit
1733
+ def _fdi(work_data, e_period=30, shape_len=1) -> np.ndarray:
1734
+ idx = np.argmax(work_data, -1)
1735
+ flat_idx = np.arange(work_data.size, step=work_data.shape[-1]) + idx.ravel()
1736
+ price_max = work_data.ravel()[flat_idx].reshape(*work_data.shape[:-1])
1737
+ idx = np.argmin(work_data, -1)
1738
+ flat_idx = np.arange(work_data.size, step=work_data.shape[-1]) + idx.ravel()
1739
+ price_min = work_data.ravel()[flat_idx].reshape(*work_data.shape[:-1])
1740
+
1741
+ length = 0
1742
+
1743
+ if shape_len == 1:
1744
+ diffs = (work_data - price_min) / (price_max - price_min)
1745
+ length = np.power(np.power(np.diff(diffs).T, 2.0) + (1.0 / np.power(e_period, 2.0)), 0.5)
1746
+ else:
1747
+ diffs = (work_data.T - price_min) / (price_max - price_min)
1748
+ length = np.power(np.power(np.diff(diffs.T).T, 2.0) + (1.0 / np.power(e_period, 2.0)), 0.5)
1749
+ length = np.sum(length[1:], 0)
1750
+
1751
+ fdi_vs = 1.0 + (np.log(length) + np.log(2.0)) / np.log(2 * e_period)
1752
+
1753
+ return fdi_vs
1754
+
1755
+
1756
+ @njit
1757
+ def __mcginley(xs, es, period):
1758
+ g = 0.0
1759
+ gs = []
1760
+ for x, e in zip(xs, es):
1761
+ if g == 0.0:
1762
+ g = e
1763
+ g = g + (x - g) / (period * np.power(x / g, 4))
1764
+ gs.append(g)
1765
+ return gs
1766
+
1767
+
1768
+ def mcginley(xs: pd.Series, period: int) -> pd.Series:
1769
+ """
1770
+ McGinley dynamic moving average
1771
+ """
1772
+ x = column_vector(xs)
1773
+ es0 = ema(xs, period, init_mean=False)
1774
+ return pd.Series(__mcginley(x.reshape(1, -1)[0], es0.reshape(1, -1)[0], period), index=xs.index)
1775
+
1776
+
1777
+ def stdev(x, window: int) -> pd.Series:
1778
+ """
1779
+ Standard deviation of x on rolling basis period (as in tranding view)
1780
+ """
1781
+ x = x.copy()
1782
+ a = smooth(np.power(x, 2), "sma", window)
1783
+ sm = pd.Series(rolling_sum(column_vector(x), window).reshape(1, -1)[0], x.index)
1784
+ b = np.power(sm, 2) / np.power(window, 2)
1785
+ return np.sqrt(a - b)
1786
+
1787
+
1788
+ def waexplosion(
1789
+ data: pd.DataFrame,
1790
+ fastLength=20,
1791
+ slowLength=40,
1792
+ channelLength=20,
1793
+ sensitivity=150,
1794
+ mult=2.0,
1795
+ source="close",
1796
+ tuning_F1=3.7,
1797
+ tuning_atr_period=100,
1798
+ tuning_macd_smoother="ema",
1799
+ ) -> pd.DataFrame:
1800
+ """
1801
+ Waddah Attar Explosion indicator (version from TradingView)
1802
+
1803
+ Example:
1804
+ - - - -
1805
+
1806
+ wae = waexplosion(ohlc)
1807
+ LookingGlass(ohlc, {
1808
+ 'WAE': [
1809
+ 'dots', wae.dead_zone, 'line', wae.explosion, 'area', 'green', wae.trend_up, 'orange', 'area', wae.trend_down
1810
+ ],
1811
+ }).look('2023-Feb-01', '2023-Feb-17').hover()
1812
+
1813
+ """
1814
+ check_frame_columns(data, "open", "high", "low", "close")
1815
+ x = data[source]
1816
+
1817
+ dead_zone = tuning_F1 * atr(data, 2 * tuning_atr_period + 1, "ema")
1818
+ macd1 = smooth(x, tuning_macd_smoother, fastLength) - smooth(x, tuning_macd_smoother, slowLength)
1819
+ t1 = sensitivity * macd1.diff()
1820
+
1821
+ e1 = 2 * mult * stdev(x, channelLength)
1822
+ trend_up = t1.where(t1 > 0, 0)
1823
+ trend_dw = -t1.where(t1 < 0, 0)
1824
+
1825
+ return scols(dead_zone, e1, trend_up, trend_dw, names=["dead_zone", "explosion", "trend_up", "trend_down"])
1826
+
1827
+
1828
+ def rad_indicator(x: pd.DataFrame, period: int, mult: float = 2, smoother="sma") -> pd.DataFrame:
1829
+ """
1830
+ RAD chandelier indicator
1831
+ """
1832
+ check_frame_columns(x, "open", "high", "low", "close")
1833
+
1834
+ a = atr(x, period, smoother=smoother)
1835
+
1836
+ hh = x.high.rolling(window=period).max()
1837
+ ll = x.low.rolling(window=period).min()
1838
+
1839
+ rad_long = hh - a * mult
1840
+ rad_short = ll + a * mult
1841
+
1842
+ brk_d = x[(x.close.shift(1) > rad_long.shift(1)) & (x.close < rad_long)].index
1843
+ brk_u = x[(x.close.shift(1) < rad_short.shift(1)) & (x.close > rad_short)].index
1844
+
1845
+ sw = pd.Series(np.nan, x.index)
1846
+ sw.loc[brk_d] = +1
1847
+ sw.loc[brk_u] = -1
1848
+ sw = sw.ffill()
1849
+
1850
+ radU = rad_short[sw[sw > 0].index]
1851
+ radD = rad_long[sw[sw < 0].index]
1852
+ # rad = srows(radU, radD)
1853
+
1854
+ # stop level
1855
+ mu, md = -np.inf, np.inf
1856
+ rs = {}
1857
+ for t, s in zip(sw.index, sw.values):
1858
+ if s < 0:
1859
+ mu = max(mu, rad_long.loc[t])
1860
+ rs[t] = mu
1861
+ md = np.inf
1862
+ if s > 0:
1863
+ md = min(md, rad_short.loc[t])
1864
+ rs[t] = md
1865
+ mu = -np.inf
1866
+
1867
+ return scols(pd.Series(rs), rad_long, rad_short, radU, radD, names=["rad", "long", "short", "U", "D"])
1868
+
1869
+
1870
+ @njit
1871
+ def __calc_qqe_mod_core_fast(rs_index_values, newlongband_values, newshortband_values) -> list:
1872
+ ri1, lb1, sb1, tr1, lb2, sb2 = np.nan, 0, 0, np.nan, np.nan, np.nan
1873
+ c1 = 0
1874
+ fast_atr_rsi_tl = []
1875
+
1876
+ for ri, nl, ns in zip(rs_index_values, newlongband_values, newshortband_values):
1877
+ if not np.isnan(ri1):
1878
+ c1 = ((ri1 <= lb2) & (ri > lb1)) | ((ri1 >= lb2) & (ri < lb1))
1879
+ tr = ((ri1 <= sb2) & (ri > sb1)) | ((ri1 >= sb2) & (ri < sb1))
1880
+ tr1 = 1 if tr else -1 if c1 else tr1
1881
+
1882
+ lb2, sb2 = lb1, sb1
1883
+ lb1 = max(lb1, nl) if (ri1 > lb1) and ri > lb1 else nl
1884
+ sb1 = min(sb1, ns) if (ri1 < sb1) and ri < sb1 else ns
1885
+
1886
+ fast_atr_rsi_tl.append(lb1 if tr1 == 1 else sb1)
1887
+ else:
1888
+ fast_atr_rsi_tl.append(np.nan)
1889
+
1890
+ ri1 = ri
1891
+
1892
+ return fast_atr_rsi_tl
1893
+
1894
+
1895
+ def _calc_qqe_mod_core(src: pd.Series, rsi_period, sf, qqe) -> Tuple[pd.Series, pd.Series]:
1896
+ to_ser = lambda xs, name=None: pd.Series(xs, name=name)
1897
+ wilders_period = rsi_period * 2 - 1
1898
+
1899
+ rsi_i = rsi(src, wilders_period, smoother=ema)
1900
+ rsi_ma = smooth(rsi_i, "ema", sf)
1901
+
1902
+ atr_rsi = abs(rsi_ma.diff())
1903
+ ma_atr_rsi = smooth(atr_rsi, "ema", wilders_period)
1904
+ dar = smooth(ma_atr_rsi, "ema", wilders_period) * qqe
1905
+
1906
+ delta_fast_atr_rsi = dar
1907
+ rs_index = rsi_ma
1908
+
1909
+ newshortband = rs_index + delta_fast_atr_rsi
1910
+ newlongband = rs_index - delta_fast_atr_rsi
1911
+
1912
+ fast_atr_rsi_tl = __calc_qqe_mod_core_fast(rs_index.values, newlongband.values, newshortband.values)
1913
+ fast_atr_rsi_tl = pd.Series(fast_atr_rsi_tl, index=rs_index.index, name="fast_atr_rsi_tl")
1914
+
1915
+ # - old approach - 10 times slower
1916
+ # ri1, lb1, sb1, tr1, lb2, sb2 = np.nan, 0, 0, np.nan, np.nan, np.nan
1917
+ # c1 = 0
1918
+ # fast_atr_rsi_tl = {}
1919
+
1920
+ # for t, ri, nl, ns in zip(rs_index.index,
1921
+ # rs_index.values, newlongband.values, newshortband.values):
1922
+ # if not np.isnan(ri1):
1923
+ # c1 = ((ri1 <= lb2) & (ri > lb1)) | ((ri1 >= lb2) & (ri < lb1))
1924
+ # tr = ((ri1 <= sb2) & (ri > sb1)) | ((ri1 >= sb2) & (ri < sb1))
1925
+ # tr1 = 1 if tr else -1 if c1 else tr1
1926
+
1927
+ # lb2, sb2 = lb1, sb1
1928
+ # lb1 = max(lb1, nl) if (ri1 > lb1) and ri > lb1 else nl
1929
+ # sb1 = min(sb1, ns) if (ri1 < sb1) and ri < sb1 else ns
1930
+
1931
+ # fast_atr_rsi_tl[t] = lb1 if tr1 == 1 else sb1
1932
+
1933
+ # # ri1, nl1, ns1 = ri, nl, ns
1934
+ # ri1 = ri
1935
+ # fast_atr_rsi_tl = to_ser(fast_atr_rsi_tl, name='fast_atr_rsi_tl')
1936
+
1937
+ return rsi_ma, fast_atr_rsi_tl
1938
+
1939
+
1940
+ def qqe_mod(
1941
+ data: pd.DataFrame,
1942
+ rsi_period=6,
1943
+ sf=5,
1944
+ qqe=3,
1945
+ source="close",
1946
+ length=50,
1947
+ mult=0.35,
1948
+ source2="close",
1949
+ rsi_period2=6,
1950
+ sf2=5,
1951
+ qqe2=1.61,
1952
+ threshhold2=3,
1953
+ ) -> pd.DataFrame:
1954
+ """
1955
+ QQE_MOD indicator
1956
+ """
1957
+ check_frame_columns(data, "open", "high", "low", "close")
1958
+
1959
+ src = data[source]
1960
+ rsi_ma, fast_atr_rsi_tl = _calc_qqe_mod_core(src, rsi_period, sf, qqe)
1961
+
1962
+ basis = smooth(fast_atr_rsi_tl - 50, "sma", length)
1963
+ dev = mult * stdev(fast_atr_rsi_tl - 50, length)
1964
+ upper = basis + dev
1965
+ lower = basis - dev
1966
+
1967
+ src2 = data[source2]
1968
+ rsi_ma2, fast_atr_rsi_tl2 = _calc_qqe_mod_core(src2, rsi_period2, sf2, qqe2)
1969
+
1970
+ qqe_line = fast_atr_rsi_tl2 - 50
1971
+ histo1 = rsi_ma - 50
1972
+ histo2 = rsi_ma2 - 50
1973
+
1974
+ m = scols(histo1, histo2, upper, lower, names=["H1", "H2", "U", "L"])
1975
+ _gb_cond = (m.H2 > threshhold2) & (m.H1 > m.U)
1976
+ _rb_cond = (m.H2 < -threshhold2) & (m.H1 < m.L)
1977
+ _sb_cond = ((histo2 > threshhold2) | (histo2 < -threshhold2)) & ~_gb_cond & ~_rb_cond
1978
+ green_bars = m[_gb_cond].H2
1979
+ red_bars = m[_rb_cond].H2
1980
+ silver_bars = m[_sb_cond].H2
1981
+
1982
+ res = scols(qqe_line, green_bars, red_bars, silver_bars, names=["qqe", "green", "red", "silver"])
1983
+ res = res.assign(
1984
+ # here we code hist bars:
1985
+ # -1 -> silver < 0, +1 -> silver > 0, +2 -> green, -2 -> red
1986
+ code=-1 * (res.silver < 0) + 1 * (res.silver > 0) + 2 * (res.green > 0) - 2 * (res.red < 0)
1987
+ )
1988
+
1989
+ return res
1990
+
1991
+
1992
+ def ssl_exits(
1993
+ data: pd.DataFrame,
1994
+ baseline_type="hma",
1995
+ baseline_period=60,
1996
+ exit_type="hma",
1997
+ exit_period=15,
1998
+ atr_type="ema",
1999
+ atr_period=14,
2000
+ multy=0.2,
2001
+ ) -> pd.DataFrame:
2002
+ """
2003
+ Exits generator based on momentum reversal (from SSL Hybrid in TV)
2004
+ """
2005
+ check_frame_columns(data, "high", "low", "close")
2006
+ close = data.close
2007
+ lows = data.low
2008
+ highs = data.high
2009
+
2010
+ base_line = smooth(close, baseline_type, baseline_period)
2011
+ exit_hi = smooth(highs, exit_type, exit_period)
2012
+ exit_lo = smooth(lows, exit_type, exit_period)
2013
+
2014
+ tr = atr(data, atr_period, atr_type)
2015
+ upperk = base_line + multy * tr
2016
+ lowerk = base_line - multy * tr
2017
+
2018
+ hlv3 = pd.Series(np.nan, close.index)
2019
+ hlv3.loc[close > exit_hi] = +1
2020
+ hlv3.loc[close < exit_lo] = -1
2021
+ hlv3 = hlv3.ffill()
2022
+
2023
+ ssl_exit = srows(exit_hi[hlv3 < 0], exit_lo[hlv3 > 0])
2024
+ m = scols(
2025
+ ssl_exit,
2026
+ close,
2027
+ lows,
2028
+ highs,
2029
+ base_line,
2030
+ upperk,
2031
+ lowerk,
2032
+ names=["exit", "close", "low", "high", "base_line", "upperk", "lowerk"],
2033
+ )
2034
+
2035
+ exit_short = m[(m.close.shift(1) <= m.exit.shift(1)) & (m.close > m.exit)].low
2036
+ exit_long = m[(m.close.shift(1) >= m.exit.shift(1)) & (m.close < m.exit)].high
2037
+
2038
+ grow_line = pd.Series(np.nan, index=m.index)
2039
+ decl_line = pd.Series(np.nan, index=m.index)
2040
+
2041
+ g = m[m.close > m.upperk].base_line
2042
+ d = m[m.close < m.lowerk].base_line
2043
+ grow_line[g.index] = g
2044
+ decl_line[d.index] = d
2045
+
2046
+ return scols(
2047
+ exit_long,
2048
+ exit_short,
2049
+ grow_line,
2050
+ decl_line,
2051
+ m.base_line,
2052
+ names=["exit_long", "exit_short", "grow", "decline", "base"],
2053
+ )
2054
+
2055
+
2056
+ def streaks(xs: pd.Series):
2057
+ """
2058
+ Count consequently rising values in input series, actually it measures the duration of the trend.
2059
+ It is the number of points in a row with value has been higher (up) or lower (down) than the previous one.
2060
+
2061
+ streaks(pd.Series([1,2,1,2,3,4,5,1]))
2062
+
2063
+ 0 0.0
2064
+ 1 1.0
2065
+ 2 -1.0
2066
+ 3 1.0
2067
+ 4 2.0
2068
+ 5 3.0
2069
+ 6 4.0
2070
+ 7 -1.0
2071
+
2072
+ """
2073
+
2074
+ def consecutive_count(b):
2075
+ cs = b.astype(int).cumsum()
2076
+ return cs.sub(cs.mask(b).ffill().fillna(0))
2077
+
2078
+ prev = xs.shift(1)
2079
+ return consecutive_count(xs > prev) - consecutive_count(xs < prev)
2080
+
2081
+
2082
+ def percentrank(xs: pd.Series, period: int) -> pd.Series:
2083
+ """
2084
+ Percent rank is the percents of how many previous values was less than or equal to the current value of given series.
2085
+ """
2086
+ r = {}
2087
+ for t, x in zip(running_view(xs.index.values, period), running_view(xs.values, period)):
2088
+ r[t[-1]] = 100 * sum(x[-1] > x[:-1]) / period
2089
+ return pd.Series(r).reindex(xs.index)
2090
+
2091
+
2092
+ def connors_rsi(close, rsi_period=3, streaks_period=2, percent_rank_period=100) -> pd.Series:
2093
+ """
2094
+ Connors RSI indicator (https://www.quantifiedstrategies.com/connors-rsi/)
2095
+
2096
+ """
2097
+ return (rsi(close, rsi_period) + rsi(streaks(close), streaks_period) + percentrank(close, percent_rank_period)) / 3
2098
+
2099
+
2100
+ def swings(ohlc: pd.DataFrame, trend_indicator: Callable = psar, **indicator_args) -> Struct:
2101
+ """
2102
+ Swing detector based on trend indicator
2103
+ """
2104
+ check_frame_columns(ohlc, "high", "low", "close")
2105
+
2106
+ def _find_reversal_pts(highs, lows, indicator, is_lows):
2107
+ pts = {}
2108
+ cdp = continuous_periods(indicator, ~np.isnan(indicator))
2109
+ for b in cdp.blocks:
2110
+ ex_t = highs[b].idxmax() if is_lows else lows[b].idxmin()
2111
+ pts[ex_t] = highs.loc[ex_t] if is_lows else lows.loc[ex_t]
2112
+ return pts
2113
+
2114
+ trend_detector = trend_indicator(ohlc, **indicator_args)
2115
+ down, up = None, None
2116
+ if trend_detector is not None and isinstance(trend_detector, pd.DataFrame):
2117
+ _d = "down" if "down" in trend_detector.columns else "utl"
2118
+ _u = "up" if "up" in trend_detector.columns else "dtl"
2119
+ down = trend_detector[_d]
2120
+ up = trend_detector[_u]
2121
+
2122
+ hp = pd.Series(_find_reversal_pts(ohlc.high, ohlc.low, down, True), name="H")
2123
+ lp = pd.Series(_find_reversal_pts(ohlc.high, ohlc.low, up, False), name="L")
2124
+
2125
+ u_tr, d_tr = {}, {}
2126
+ prev_t, prev_pt = None, None
2127
+ swings = {}
2128
+ for t, (h, l) in scols(hp, lp).iterrows():
2129
+ if np.isnan(h):
2130
+ if prev_pt:
2131
+ length = abs(prev_pt - l)
2132
+ u_tr[prev_t] = {"start_price": prev_pt, "end_price": l, "delta": length, "end": t}
2133
+ swings[prev_t] = {"p0": prev_pt, "p1": l, "direction": -1, "duration": t - prev_t, "length": length}
2134
+ prev_pt = l
2135
+ prev_t = t
2136
+
2137
+ elif np.isnan(l):
2138
+ if prev_pt:
2139
+ length = abs(prev_pt - h)
2140
+ d_tr[prev_t] = {"start_price": prev_pt, "end_price": h, "delta": length, "end": t}
2141
+ swings[prev_t] = {"p0": prev_pt, "p1": h, "direction": +1, "duration": t - prev_t, "length": length}
2142
+ prev_pt = h
2143
+ prev_t = t
2144
+
2145
+ trends_splits = scols(
2146
+ pd.DataFrame.from_dict(u_tr, orient="index"),
2147
+ pd.DataFrame.from_dict(d_tr, orient="index"),
2148
+ keys=["DownTrends", "UpTrends"],
2149
+ )
2150
+
2151
+ swings = pd.DataFrame.from_dict(swings, orient="index")
2152
+
2153
+ return Struct(swings=swings, trends=trends_splits, tops=hp, bottoms=lp)
2154
+
2155
+
2156
+ @njit
2157
+ def norm_pdf(x):
2158
+ return np.exp(-(x**2) / 2) / np.sqrt(2 * np.pi)
2159
+
2160
+
2161
+ @njit
2162
+ def lognorm_pdf(x, s):
2163
+ return np.exp(-(np.log(x) ** 2) / (2 * s**2)) / (x * s * np.sqrt(2 * np.pi))
2164
+
2165
+
2166
+ @njit
2167
+ def _pwma(_x, a, beta, T):
2168
+ _mean, _std, _var = np.zeros(_x.shape), np.zeros(_x.shape), np.zeros(_x.shape)
2169
+ _mean[0] = _x[0]
2170
+
2171
+ for i in range(1, len(_x)):
2172
+ i_1 = i - 1
2173
+ diff = _x[i] - _mean[i_1]
2174
+ p = norm_pdf(diff / _std[i_1]) if _std[i_1] != 0 else 0 # Prob of observing diff
2175
+ a_t = a * (1 - beta * p) if i_1 > T else 1 - 1 / i # weight to give to this point
2176
+ incr = (1 - a_t) * diff
2177
+
2178
+ # Update Mean, Var, Std
2179
+ v = a_t * (_var[i_1] + diff * incr)
2180
+ _mean[i] = _mean[i_1] + incr
2181
+ _var[i] = v
2182
+ _std[i] = np.sqrt(v)
2183
+ return _mean, _var, _std
2184
+
2185
+
2186
+ def pwma(x: pd.Series, alpha: float, beta: float, T: int) -> pd.DataFrame:
2187
+ """
2188
+ Implementation of probabilistic exponential weighted ma (https://sci-hub.shop/10.1109/SSP.2012.6319708)
2189
+ """
2190
+ m, v, s = _pwma(x.values, alpha, beta, T)
2191
+ return pd.DataFrame({"Mean": m, "Var": v, "Std": s}, index=x.index)
2192
+
2193
+
2194
+ @njit
2195
+ def _pwma_outliers_detector(x, a, beta, T, z_th, dist):
2196
+ x0 = 0 if np.isnan(x[0]) else x[0]
2197
+ s1, s2, s1_n, std_n = x0, x0**2, x0, 0
2198
+
2199
+ s1a, stda, za, probs = np.zeros(x.shape), np.zeros(x.shape), np.zeros(x.shape), np.zeros(x.shape)
2200
+ uba, lba = np.zeros(x.shape), np.zeros(x.shape)
2201
+ outliers = []
2202
+
2203
+ for i in range(0, len(x)):
2204
+ s1 = s1_n
2205
+ std = std_n
2206
+ xi = x[i]
2207
+
2208
+ z_t = ((xi - s1) / std) if (std != 0 and not np.isnan(xi)) else 0
2209
+ ub, lb = (z_t + z_th) * std + s1, (z_t - z_th) * std + s1
2210
+
2211
+ # find probability
2212
+ p = norm_pdf(z_t)
2213
+ a_t = a * (1 - beta * p) if i + 1 >= T else 1 - 1 / (i + 1)
2214
+
2215
+ # Update Mean, Var, Std
2216
+ if not np.isnan(xi):
2217
+ s1 = a_t * s1 + (1 - a_t) * xi
2218
+ s2 = a_t * s2 + (1 - a_t) * xi**2
2219
+ s1_n = s1
2220
+ std_n = np.sqrt(abs(s2 - np.square(s1)))
2221
+
2222
+ # detects outlier
2223
+ if abs(z_t) >= z_th:
2224
+ outliers.append(i)
2225
+
2226
+ s1a[i] = s1_n
2227
+ stda[i] = std_n
2228
+ probs[i] = p
2229
+ za[i] = z_t
2230
+
2231
+ # upper and lower boundaries
2232
+ ub, lb = s1_n + z_th * std_n, s1_n - z_th * std_n
2233
+ uba[i] = ub
2234
+ lba[i] = lb
2235
+ # print('[%d] %.3f -> s1_n: %.3f s1: %.3f Z: %.3f s: %.3f s_n: %.3f' % (i, x[i], s1_n, s1, z_t, std, std_n))
2236
+ return s1a, stda, probs, za, uba, lba, outliers
2237
+
2238
+
2239
+ def pwma_outliers_detector(x: pd.Series, alpha: float, beta: float, T=30, threshold=0.05, dist="norm") -> Struct:
2240
+ """
2241
+ Outliers detector based on pwma
2242
+ """
2243
+ import scipy.stats
2244
+
2245
+ z_thr = scipy.stats.norm.ppf(1 - threshold / 2)
2246
+ m, s, p, z, u, l, oi = _pwma_outliers_detector(x.values, alpha, beta, T, z_thr, dist)
2247
+ res = pd.DataFrame({"Mean": m, "Std": s, "Za": z, "Uba": u, "Lba": l, "Prob": p}, index=x.index)
2248
+
2249
+ return Struct(
2250
+ m=res["Mean"],
2251
+ s=res["Std"],
2252
+ z=res["Za"],
2253
+ u=res["Uba"],
2254
+ l=res["Lba"],
2255
+ p=res["Prob"],
2256
+ outliers=x.iloc[oi] if len(oi) else None,
2257
+ z_bounds=(z_thr, -z_thr),
2258
+ )
2259
+
2260
+
2261
+ @njit
2262
+ def __fast_ols(x, y):
2263
+ n = len(x)
2264
+ p, _, _, _ = np.linalg.lstsq(x, y, rcond=-1)
2265
+ r2 = (n * np.sum(x * y) - np.sum(x) * np.sum(y)) ** 2 / (
2266
+ (n * np.sum(x**2) - np.sum(x) ** 2) * (n * np.sum(y**2) - np.sum(y) ** 2)
2267
+ )
2268
+ return p[0][0], p[1][0], r2
2269
+
2270
+
2271
+ def fast_ols(x, y) -> Struct:
2272
+ b, c, r2 = __fast_ols(column_vector(x), column_vector(y))
2273
+ return Struct(const=c, beta=b, r2=r2)
2274
+
2275
+
2276
+ @njit
2277
+ def fast_alpha(x, order=1, factor=10, min_threshold=1e-10):
2278
+ """
2279
+ Returns alpha based on following calculations:
2280
+
2281
+ alpha = exp(-F*(1 - R2))
2282
+
2283
+ where R2 - r squared metric from regression of x data against straight line y = x
2284
+ """
2285
+ x = x[~np.isnan(x)]
2286
+ x_max, x_min = np.max(x), np.min(x)
2287
+
2288
+ if x_max - x_min > min_threshold:
2289
+ yy = 2 * (x - x_min) / (x_max - x_min) - 1
2290
+ xx = np.vander(np.linspace(-1, 1, len(yy)), order + 1)
2291
+ slope, intercept, r2 = __fast_ols(xx, yy.reshape(-1, 1))
2292
+ else:
2293
+ slope, intercept, r2 = np.inf, 0, 0
2294
+
2295
+ return np.exp(-factor * (1 - r2)), r2, slope, intercept
2296
+
2297
+
2298
+ @njit
2299
+ def __rolling_slope(x, period, alpha_factor):
2300
+ ri = nans((len(x), 2))
2301
+
2302
+ for i in range(period, x.shape[0]):
2303
+ a, r2, s, _ = fast_alpha(x[i - period : i], factor=alpha_factor)
2304
+ ri[i, :] = [r2, s]
2305
+
2306
+ return ri
2307
+
2308
+
2309
+ def rolling_slope(x: pd.Series, period: int, alpha_factor=10) -> pd.DataFrame:
2310
+ """
2311
+ Calculates slope/R2 on rolling basis for series from x
2312
+ returns DataFrame with 2 columns: R2, Slope
2313
+ """
2314
+ return pd.DataFrame(__rolling_slope(column_vector(x), period, alpha_factor), index=x.index, columns=["R2", "Slope"])
2315
+
2316
+
2317
+ def find_movements_hilo(
2318
+ x: pd.DataFrame,
2319
+ threshold: float = -np.inf,
2320
+ pcntg=0.75,
2321
+ t_window: List | Tuple | int = 10,
2322
+ use_prev_movement_size_for_percentage=True,
2323
+ result_as_frame=False,
2324
+ collect_log=False,
2325
+ init_direction=0,
2326
+ silent=False,
2327
+ ):
2328
+ """
2329
+ Finds all movements in DataFrame x (should be pandas Dataframe object with low & high columns) which have absolute magnitude >= threshold
2330
+ and lasts not more than t_window bars.
2331
+
2332
+ # Example:
2333
+ # -----------------
2334
+
2335
+ import pandas as pd
2336
+ import numpy as np
2337
+ import matplotlib
2338
+ import matplotlib.pyplot as plt
2339
+ from pylab import *
2340
+
2341
+ z = 50 + np.random.normal(0, 0.2, 1000).cumsum()
2342
+ x = pd.Series(z, index=pd.date_range('1/1/2000 16:00:00', periods=len(z), freq='30s'))
2343
+
2344
+ i_drops, i_grows, _, _ = find_movements(x, threshold=1, t_window=120, pcntg=.75)
2345
+
2346
+ plt.figure(figsize=(15,10))
2347
+
2348
+ # plot series
2349
+ plt.plot(x)
2350
+
2351
+ # plot movements
2352
+ plt.plot(x.index[i_drops].T, x[i_drops].T, 'r--', lw=1.2);
2353
+ plt.plot(x.index[i_grows].T, x[i_grows].T, 'w--', lw=1.2);
2354
+
2355
+ # or new version (after 2018-08-31)
2356
+ trends = find_movements(x, threshold=1, t_window=120, pcntg=.75, result_as_indexes=False)
2357
+ u, d = trends.UpTrends.dropna(), trends.DownTrends.dropna()
2358
+ plt.plot([u.index, u.end], [u.start_price, u.end_price], 'w--', lw=0.7, marker='.', markersize=5);
2359
+ plt.plot([d.index, d.end], [d.start_price, d.end_price], 'r--', lw=0.7);
2360
+
2361
+ plt.draw()
2362
+ plt.show()
2363
+
2364
+ # -----------------
2365
+
2366
+ :param x: pandas DataFrame object
2367
+ :param threshold: movement minimal magnitude threshold
2368
+ :param pcntg: percentage of previous movement (if use_prev_movement_size_for_percentage is True) that considered as start of new movement (1 == 100%)
2369
+ :param use_prev_movement_size_for_percentage: False if use percentage from previous price extremum (otherwise it uses prev. movement) [True]
2370
+ :param t_window: movement's length filter in bars or range: 120 or (0, 100) or (100, np.inf) etc
2371
+ :param drop_out_of_market: True if need to drop movements between sessions
2372
+ :param drop_weekends_crossings: True if need to drop movemets crossing weekends (for intraday data)
2373
+ :param silent: if True it doesn't show progress bar [False by default]
2374
+ :param result_as_frame: if False (default) result returned as tuple of indexes otherwise as DataFrame
2375
+ :param collect_log: True if need to collect track of tops/bottoms at times when they appeared
2376
+ :param init_direction: initial direction, can be 0, 1, -1
2377
+ :return: tuple with indexes of (droping movements, growing movements, droping magnitudes, growing magnitudes)
2378
+ """
2379
+
2380
+ # check input arguments
2381
+ check_frame_columns(x, "high", "low")
2382
+
2383
+ direction = init_direction
2384
+ mi, mx = 0, 0
2385
+ i_drops, i_grows = [], []
2386
+ log_rec = OrderedDict()
2387
+ timeline = x.index
2388
+
2389
+ # check filter values
2390
+ if isinstance(t_window, int):
2391
+ t_window = [0, t_window]
2392
+ elif len(t_window) != 2 or t_window[0] >= t_window[1]:
2393
+ raise ValueError("t_window must have 2 ascending elements")
2394
+
2395
+ if not silent:
2396
+ print(" -[", end="")
2397
+ n_p_len = max(int(len(x) / 100), 1)
2398
+
2399
+ prev_vL = 0
2400
+ prev_vH = 0
2401
+ prev_mx = 0
2402
+ prev_mi = 0
2403
+ last_drop = None
2404
+ last_grow = None
2405
+
2406
+ LO = x["low"].values
2407
+ HI = x["high"].values
2408
+
2409
+ xL_mi = LO[mi]
2410
+ xH_mx = HI[mx]
2411
+
2412
+ # for i in range(1, len(x)):
2413
+ i = 1
2414
+ x_len = len(x)
2415
+ while i < x_len:
2416
+ vL, vH = LO[i], HI[i]
2417
+
2418
+ if direction <= 0:
2419
+ if direction < 0 and vH > prev_vH and last_grow is not None:
2420
+ # extend to previous grow start
2421
+ last_grow[1] = i
2422
+
2423
+ # extend to current point
2424
+ mx = i
2425
+ xH_mx = HI[mx]
2426
+ prev_mx = mx
2427
+ prev_vH = xH_mx
2428
+ last_drop = None # already added, reset to avoid duplicates
2429
+
2430
+ mi = i
2431
+ xL_mi = LO[mi]
2432
+
2433
+ elif vL < xL_mi:
2434
+ mi = i
2435
+ xL_mi = LO[mi]
2436
+ direction = -1
2437
+
2438
+ else:
2439
+ # floating up
2440
+ if use_prev_movement_size_for_percentage:
2441
+ l_mv = pcntg * (xH_mx - xL_mi)
2442
+ else:
2443
+ l_mv = pcntg * xL_mi
2444
+
2445
+ # check condition
2446
+ if (vL - xL_mi >= threshold) or (l_mv < vL - xL_mi):
2447
+ # case when HighLow of a one bar are extreme points, to avoid infinite loop
2448
+ if mx == mi:
2449
+ mi += 1
2450
+ # xL_mi = x.low.values[mi]
2451
+
2452
+ last_drop = [mx, mi]
2453
+ # i_drops.append([mx, mi])
2454
+ if last_grow:
2455
+ # check if not violate the previous drop
2456
+ min_idx = np.argmin(LO[last_grow[0] : last_grow[1] + 1])
2457
+ if last_grow[1] > (last_grow[0] + 1) and min_idx > 0 and len(i_drops) > 0:
2458
+ # we have low, which is lower than start of uptrend,
2459
+ # remove the previous drop and replace it with the new one
2460
+ new_drop = [i_drops[-1][0], last_grow[0] + min_idx]
2461
+ i_drops[-1] = new_drop
2462
+ last_grow[0] = last_grow[0] + min_idx
2463
+
2464
+ i_grows.append(last_grow)
2465
+ last_grow = None
2466
+
2467
+ prev_vL = xL_mi
2468
+ prev_mi = mi
2469
+
2470
+ if collect_log:
2471
+ log_rec[timeline[i]] = {"Type": "-", "Time": timeline[mi], "Price": xL_mi}
2472
+
2473
+ # need to move back to the end of last drop
2474
+ i = mi
2475
+ mx = i
2476
+ direction = 1
2477
+ xH_mx = x.high.values[mx]
2478
+ xL_mi = x.low.values[mi]
2479
+
2480
+ if direction >= 0:
2481
+ if direction > 0 and vL < prev_vL and last_drop is not None:
2482
+ # extend to previous drop start
2483
+ last_drop[1] = i
2484
+
2485
+ # extend to current point
2486
+ mi = i
2487
+ xL_mi = LO[mi]
2488
+ prev_mi = mi
2489
+ prev_vL = xL_mi
2490
+ last_grow = None # already added, reset to avoid duplicates
2491
+
2492
+ mx = i
2493
+ xH_mx = HI[mx]
2494
+
2495
+ elif vH > xH_mx:
2496
+ mx = i
2497
+ xH_mx = HI[mx]
2498
+ direction = +1
2499
+ else:
2500
+ if use_prev_movement_size_for_percentage:
2501
+ l_mv = pcntg * (xH_mx - xL_mi)
2502
+ else:
2503
+ l_mv = pcntg * xH_mx
2504
+
2505
+ if (xH_mx - vH >= threshold) or (l_mv < xH_mx - vH):
2506
+ # i_grows.append([mi, mx])
2507
+
2508
+ # case when HighLow of a one bar are extreme points, to avoid infinite loop
2509
+ if mx == mi:
2510
+ mx += 1
2511
+ # xH_mx = x.high.values[mx]
2512
+
2513
+ last_grow = [mi, mx]
2514
+ if last_drop:
2515
+ # check if not violate the previous drop
2516
+ max_idx = np.argmax(HI[last_drop[0] : last_drop[1] + 1])
2517
+ if last_drop[1] > (last_drop[0] + 1) and max_idx > 0 and len(i_grows) > 0:
2518
+ # more than 1 bar between points
2519
+ # we have low, which is lower than start of uptrend,
2520
+ # remove the previous drop and replace it with the new one
2521
+ new_grow = [i_grows[-1][0], last_drop[0] + max_idx]
2522
+ i_grows[-1] = new_grow
2523
+ last_drop[0] = last_drop[0] + max_idx
2524
+
2525
+ i_drops.append(last_drop)
2526
+ last_drop = None
2527
+
2528
+ prev_vH = xH_mx
2529
+ prev_mx = mx
2530
+
2531
+ if collect_log:
2532
+ log_rec[timeline[i]] = {"Type": "+", "Time": timeline[mx], "Price": xH_mx}
2533
+
2534
+ # need to move back to the end of last grow
2535
+ i = mx
2536
+ mi = i
2537
+ xL_mi = LO[mi]
2538
+ xH_mx = HI[mx]
2539
+ direction = -1
2540
+
2541
+ i += 1
2542
+ if not silent and not (i % n_p_len):
2543
+ print(":", end="")
2544
+
2545
+ if last_grow:
2546
+ i_grows.append(last_grow)
2547
+ last_grow = None
2548
+
2549
+ if last_drop:
2550
+ i_drops.append(last_drop)
2551
+ last_drop = None
2552
+
2553
+ if not silent:
2554
+ print("]-")
2555
+ i_drops = np.array(i_drops)
2556
+ i_grows = np.array(i_grows)
2557
+
2558
+ # Nothing is found
2559
+ if len(i_drops) == 0 or len(i_grows) == 0:
2560
+ if not silent:
2561
+ print("\n\t[WARNING] find_movements_hilo: No trends found for given conditions !")
2562
+ return pd.DataFrame({"UpTrends": [], "DownTrends": []}) if result_as_frame else ([], [], [], [])
2563
+
2564
+ # retain only movements equal or exceed specified threshold
2565
+ if not np.isinf(threshold):
2566
+ if i_drops.size:
2567
+ i_drops = i_drops[abs(x["low"][i_drops[:, 1]].values - x["high"][i_drops[:, 0]].values) >= threshold, :]
2568
+ if i_grows.size:
2569
+ i_grows = i_grows[abs(x["high"][i_grows[:, 1]].values - x["low"][i_grows[:, 0]].values) >= threshold, :]
2570
+
2571
+ # retain only movements which shorter than specified window
2572
+ __drops_len = abs(i_drops[:, 1] - i_drops[:, 0])
2573
+ __grows_len = abs(i_grows[:, 1] - i_grows[:, 0])
2574
+ if i_drops.size:
2575
+ i_drops = i_drops[(__drops_len >= t_window[0]) & (__drops_len <= t_window[1]), :]
2576
+ if i_grows.size:
2577
+ i_grows = i_grows[(__grows_len >= t_window[0]) & (__grows_len <= t_window[1]), :]
2578
+
2579
+ # Removed - filter out all movements which cover period from 16:00 till 9:30 next day
2580
+
2581
+ # Removed - drop crossed weekend if required (we would not want to drop them when use daily prices)
2582
+
2583
+ # drops and grows magnitudes
2584
+ v_drops = []
2585
+ if i_drops.size:
2586
+ v_drops = abs(x["low"][i_drops[:, 1]].values - x["high"][i_drops[:, 0]].values)
2587
+
2588
+ v_grows = []
2589
+ if i_grows.size:
2590
+ v_grows = abs(x["high"][i_grows[:, 1]].values - x["low"][i_grows[:, 0]].values)
2591
+
2592
+ # - return results
2593
+ indexes = np.array(x.index)
2594
+ i_d, i_g = indexes[i_drops], indexes[i_grows]
2595
+ x_d = np.array([x.high[i_d[:, 0]].values, x.low[i_d[:, 1]].values]).transpose()
2596
+ x_g = np.array([x.low[i_g[:, 0]].values, x.high[i_g[:, 1]].values]).transpose()
2597
+
2598
+ d = pd.DataFrame(
2599
+ OrderedDict({"start_price": x_d[:, 0], "end_price": x_d[:, 1], "delta": v_drops, "end": i_d[:, 1]}),
2600
+ index=i_d[:, 0],
2601
+ )
2602
+
2603
+ g = pd.DataFrame(
2604
+ OrderedDict({"start_price": x_g[:, 0], "end_price": x_g[:, 1], "delta": v_grows, "end": i_g[:, 1]}),
2605
+ index=i_g[:, 0],
2606
+ )
2607
+
2608
+ trends = pd.concat((g, d), axis=1, keys=["UpTrends", "DownTrends"])
2609
+ if collect_log:
2610
+ return trends, pd.DataFrame.from_dict(log_rec, orient="index")
2611
+
2612
+ return trends
2613
+
2614
+
2615
+ _SMOOTHERS = {
2616
+ "sma": sma,
2617
+ "ema": ema,
2618
+ "tema": tema,
2619
+ "dema": dema,
2620
+ "zlema": zlema,
2621
+ "kama": kama,
2622
+ "jma": jma,
2623
+ "wma": wma,
2624
+ "mcginley": mcginley,
2625
+ "hma": hma,
2626
+ "ema_time": ema_time,
2627
+ }
2628
+
2629
+
2630
+ def smooth(x: pd.Series, smoother: str | Callable[[pd.Series, Any], pd.Series], *args, **kwargs) -> pd.Series:
2631
+ """
2632
+ Smooth series using either given function or find it by name from registered smoothers
2633
+ """
2634
+
2635
+ f_sm = __empty_smoother
2636
+ if isinstance(smoother, str):
2637
+ if smoother in _SMOOTHERS:
2638
+ f_sm = _SMOOTHERS.get(smoother)
2639
+ else:
2640
+ raise ValueError(
2641
+ f"Smoothing method '{smoother}' is not supported, supported methods: {list(_SMOOTHERS.keys())}"
2642
+ )
2643
+
2644
+ if isinstance(smoother, types.FunctionType):
2645
+ f_sm = smoother
2646
+
2647
+ # smoothing
2648
+ x_sm = f_sm(x, *args, **kwargs)
2649
+
2650
+ return x_sm if isinstance(x_sm, pd.Series) else pd.Series(x_sm.flatten(), index=x.index)
2651
+
2652
+
2653
+ def volatility(
2654
+ data: pd.DataFrame,
2655
+ period: int,
2656
+ method: str = "c",
2657
+ volume_adjusting: bool = False,
2658
+ volume_adjustment_method: str = "normal",
2659
+ percentage: bool = True,
2660
+ ) -> pd.Series:
2661
+ """
2662
+ Calculate historical volatility (as standard deviation) using various estimation methods.
2663
+
2664
+ Parameters
2665
+ ----------
2666
+ data : pd.DataFrame
2667
+ OHLCV data frame containing 'open', 'high', 'low', 'close', 'volume' columns
2668
+ period : int
2669
+ Rolling window size for volatility calculation
2670
+ method : str, default 'c'
2671
+ Volatility estimation method:
2672
+ - 't': True range volatility
2673
+ - 'te': True range with ema smoothing
2674
+ - 'c': Classical close-to-close volatility
2675
+ - 'p': Parkinson high-low volatility
2676
+ - 'rs': Rogers-Satchell volatility
2677
+ - 'gk': Garman-Klass volatility
2678
+ - 'yz': Yang-Zhang volatility (combines overnight and trading volatility)
2679
+ volume_adjusting : bool, default False
2680
+ If True, adjusts volatility by relative volume changes
2681
+ volume_adjustment_method : str, default 'normal'
2682
+ Volume adjustment method:
2683
+ - 'normal': Normal volume adjustment: f_t = v_t / v_{t-1}
2684
+ - 'entropy': Entropy volume adjustment: f_t = v_t / sma(v_t, period)
2685
+ percentage : bool, default True
2686
+ If True, returns volatility in percentage terms, otherwise in price terms
2687
+
2688
+ Returns
2689
+ -------
2690
+ pd.Series
2691
+ Volatility estimates for each period
2692
+ """
2693
+ if volume_adjusting:
2694
+ check_frame_columns(data, "open", "high", "low", "close", "volume")
2695
+ xr = data[["open", "high", "low", "close", "volume"]]
2696
+ o, h, l, c, v = xr["open"], xr["high"], xr["low"], xr["close"], xr["volume"]
2697
+
2698
+ match volume_adjustment_method:
2699
+ case "normal":
2700
+ vi = v / v.shift(1)
2701
+ case "entropy":
2702
+ vi = v / sma(v, period)
2703
+ case _:
2704
+ raise ValueError(f"Invalid volume adjustment method: {volume_adjustment_method}")
2705
+
2706
+ else:
2707
+ check_frame_columns(data, "open", "high", "low", "close")
2708
+ xr = data[["open", "high", "low", "close"]]
2709
+ o, h, l, c = xr["open"], xr["high"], xr["low"], xr["close"]
2710
+ vi = 1
2711
+
2712
+ c1 = c.shift(1)
2713
+ oi = np.log(o) - np.log(c1)
2714
+ ci = np.log(c) - np.log(o)
2715
+ di = np.log(l) - np.log(o)
2716
+ ui = np.log(h) - np.log(o)
2717
+
2718
+ match method:
2719
+ case "t":
2720
+ # - true range
2721
+ vol = sma(vi * pd.concat((abs(h - l), abs(h - c1), abs(l - c1)), axis=1).max(axis=1), period)
2722
+ return (100 * vol / c) if percentage else vol
2723
+
2724
+ case "te":
2725
+ # - true range with ema smoothing
2726
+ vol = ema(vi * pd.concat((abs(h - l), abs(h - c1), abs(l - c1)), axis=1).max(axis=1), period)
2727
+ return (100 * vol / c) if percentage else vol
2728
+
2729
+ case "c":
2730
+ # - classical
2731
+ m = sma(oi + ci, period)
2732
+ vol = sma(vi * ((oi + ci) - m) ** 2, period)
2733
+
2734
+ case "p":
2735
+ # - parkinson
2736
+ vol = sma(vi * (ui - di) ** 2, period) / (4 * np.log(2))
2737
+
2738
+ case "rs":
2739
+ # - rogers-satchell
2740
+ vol = sma(vi * (ui * (ui - ci) + di * (di - ci)), period)
2741
+
2742
+ case "gk":
2743
+ # - garman-klass
2744
+ vol = sma(vi * ((ui - di) ** 2 - (4 * np.log(2) - 2) * ci**2), period)
2745
+
2746
+ case "yz":
2747
+ # - yang-zhang
2748
+ k = 0.34 / (1.34 + (period + 1) / (period - 1))
2749
+ vol = sma(
2750
+ vi * (ci - sma(ci, period)) ** 2
2751
+ + k * (oi - sma(oi, period)) ** 2
2752
+ + (1 - k) * (ui * (ui - ci) + di * (di - ci)),
2753
+ period,
2754
+ )
2755
+ case _:
2756
+ raise ValueError(f"Invalid method: {method} only t, te, c, p, rs, gk, yz are supported")
2757
+ return 100 * vol**0.5 if percentage else vol**0.5 * c