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.
- qubx/__init__.py +207 -0
- qubx/_nb_magic.py +100 -0
- qubx/backtester/__init__.py +5 -0
- qubx/backtester/account.py +145 -0
- qubx/backtester/broker.py +87 -0
- qubx/backtester/data.py +296 -0
- qubx/backtester/management.py +378 -0
- qubx/backtester/ome.py +296 -0
- qubx/backtester/optimization.py +201 -0
- qubx/backtester/simulated_data.py +558 -0
- qubx/backtester/simulator.py +362 -0
- qubx/backtester/utils.py +780 -0
- qubx/cli/__init__.py +0 -0
- qubx/cli/commands.py +67 -0
- qubx/connectors/ccxt/__init__.py +0 -0
- qubx/connectors/ccxt/account.py +495 -0
- qubx/connectors/ccxt/broker.py +132 -0
- qubx/connectors/ccxt/customizations.py +193 -0
- qubx/connectors/ccxt/data.py +612 -0
- qubx/connectors/ccxt/exceptions.py +17 -0
- qubx/connectors/ccxt/factory.py +93 -0
- qubx/connectors/ccxt/utils.py +307 -0
- qubx/core/__init__.py +0 -0
- qubx/core/account.py +251 -0
- qubx/core/basics.py +850 -0
- qubx/core/context.py +420 -0
- qubx/core/exceptions.py +38 -0
- qubx/core/helpers.py +480 -0
- qubx/core/interfaces.py +1150 -0
- qubx/core/loggers.py +514 -0
- qubx/core/lookups.py +475 -0
- qubx/core/metrics.py +1512 -0
- qubx/core/mixins/__init__.py +13 -0
- qubx/core/mixins/market.py +94 -0
- qubx/core/mixins/processing.py +428 -0
- qubx/core/mixins/subscription.py +203 -0
- qubx/core/mixins/trading.py +88 -0
- qubx/core/mixins/universe.py +270 -0
- qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/core/series.pxd +125 -0
- qubx/core/series.pyi +118 -0
- qubx/core/series.pyx +988 -0
- qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/core/utils.pyi +6 -0
- qubx/core/utils.pyx +62 -0
- qubx/data/__init__.py +25 -0
- qubx/data/helpers.py +416 -0
- qubx/data/readers.py +1562 -0
- qubx/data/tardis.py +100 -0
- qubx/gathering/simplest.py +88 -0
- qubx/math/__init__.py +3 -0
- qubx/math/stats.py +129 -0
- qubx/pandaz/__init__.py +23 -0
- qubx/pandaz/ta.py +2757 -0
- qubx/pandaz/utils.py +638 -0
- qubx/resources/instruments/symbols-binance.cm.json +1 -0
- qubx/resources/instruments/symbols-binance.json +1 -0
- qubx/resources/instruments/symbols-binance.um.json +1 -0
- qubx/resources/instruments/symbols-bitfinex.f.json +1 -0
- qubx/resources/instruments/symbols-bitfinex.json +1 -0
- qubx/resources/instruments/symbols-kraken.f.json +1 -0
- qubx/resources/instruments/symbols-kraken.json +1 -0
- qubx/ta/__init__.py +0 -0
- qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/ta/indicators.pxd +149 -0
- qubx/ta/indicators.pyi +41 -0
- qubx/ta/indicators.pyx +787 -0
- qubx/trackers/__init__.py +3 -0
- qubx/trackers/abvanced.py +236 -0
- qubx/trackers/composite.py +146 -0
- qubx/trackers/rebalancers.py +129 -0
- qubx/trackers/riskctrl.py +641 -0
- qubx/trackers/sizers.py +235 -0
- qubx/utils/__init__.py +5 -0
- qubx/utils/_pyxreloader.py +281 -0
- qubx/utils/charting/lookinglass.py +1057 -0
- qubx/utils/charting/mpl_helpers.py +1183 -0
- qubx/utils/marketdata/binance.py +284 -0
- qubx/utils/marketdata/ccxt.py +90 -0
- qubx/utils/marketdata/dukas.py +130 -0
- qubx/utils/misc.py +541 -0
- qubx/utils/ntp.py +63 -0
- qubx/utils/numbers_utils.py +7 -0
- qubx/utils/orderbook.py +491 -0
- qubx/utils/plotting/__init__.py +0 -0
- qubx/utils/plotting/dashboard.py +150 -0
- qubx/utils/plotting/data.py +137 -0
- qubx/utils/plotting/interfaces.py +25 -0
- qubx/utils/plotting/renderers/__init__.py +0 -0
- qubx/utils/plotting/renderers/plotly.py +0 -0
- qubx/utils/runner/__init__.py +1 -0
- qubx/utils/runner/_jupyter_runner.pyt +60 -0
- qubx/utils/runner/accounts.py +88 -0
- qubx/utils/runner/configs.py +65 -0
- qubx/utils/runner/runner.py +470 -0
- qubx/utils/time.py +312 -0
- qubx-0.5.7.dist-info/METADATA +105 -0
- qubx-0.5.7.dist-info/RECORD +100 -0
- qubx-0.5.7.dist-info/WHEEL +4 -0
- 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
|