srfm-core 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- srfm_core/__init__.py +46 -0
- srfm_core/frames.py +111 -0
- srfm_core/kernels.py +256 -0
- srfm_core/py.typed +0 -0
- srfm_core/streaming.py +376 -0
- srfm_core/vbt.py +95 -0
- srfm_core-0.1.0.dist-info/METADATA +72 -0
- srfm_core-0.1.0.dist-info/RECORD +11 -0
- srfm_core-0.1.0.dist-info/WHEEL +4 -0
- srfm_core-0.1.0.dist-info/licenses/LICENSE +21 -0
- srfm_core-0.1.0.dist-info/licenses/NOTICE +16 -0
srfm_core/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""srfm-core: the SRFM bar transforms from srfm-lab.
|
|
2
|
+
|
|
3
|
+
Beta (Minkowski TIMELIKE/SPACELIKE), black-hole mass, the rolling geodesic
|
|
4
|
+
and the Hawking z proxy, as streaming classes and as numpy/pandas batch
|
|
5
|
+
functions. The same math ships as the Rust crate ``srfm-core``; both are
|
|
6
|
+
checked against one set of golden vectors generated from srfm-lab's
|
|
7
|
+
``lib/srfm_core.py``.
|
|
8
|
+
|
|
9
|
+
These are feature transforms. They are not a trading edge on their own.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .frames import add_srfm_columns, beta, bh_mass, geodesic, hawking_z, wilder_atr
|
|
13
|
+
from .kernels import BH_DEFAULTS
|
|
14
|
+
from .streaming import (
|
|
15
|
+
SPACELIKE,
|
|
16
|
+
TIMELIKE,
|
|
17
|
+
UNKNOWN,
|
|
18
|
+
BlackHoleDetector,
|
|
19
|
+
GeodesicAnalyzer,
|
|
20
|
+
GravitationalLens,
|
|
21
|
+
HawkingMonitor,
|
|
22
|
+
MinkowskiClassifier,
|
|
23
|
+
WilderATR,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"beta",
|
|
30
|
+
"bh_mass",
|
|
31
|
+
"geodesic",
|
|
32
|
+
"hawking_z",
|
|
33
|
+
"wilder_atr",
|
|
34
|
+
"add_srfm_columns",
|
|
35
|
+
"BH_DEFAULTS",
|
|
36
|
+
"MinkowskiClassifier",
|
|
37
|
+
"BlackHoleDetector",
|
|
38
|
+
"GeodesicAnalyzer",
|
|
39
|
+
"GravitationalLens",
|
|
40
|
+
"HawkingMonitor",
|
|
41
|
+
"WilderATR",
|
|
42
|
+
"TIMELIKE",
|
|
43
|
+
"SPACELIKE",
|
|
44
|
+
"UNKNOWN",
|
|
45
|
+
"__version__",
|
|
46
|
+
]
|
srfm_core/frames.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""pandas front end over :mod:`srfm_core.kernels`.
|
|
2
|
+
|
|
3
|
+
Each function accepts a list, ndarray or pandas Series. A Series keeps its
|
|
4
|
+
index; anything else gets a RangeIndex.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import pandas as pd
|
|
13
|
+
|
|
14
|
+
from . import kernels as _k
|
|
15
|
+
|
|
16
|
+
__all__ = ["beta", "bh_mass", "geodesic", "hawking_z", "wilder_atr", "add_srfm_columns"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _index(x) -> Optional[pd.Index]:
|
|
20
|
+
return x.index if isinstance(x, (pd.Series, pd.DataFrame)) else None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def beta(close, cf: float = 0.001) -> pd.Series:
|
|
24
|
+
"""Per-bar beta (``|dC| / C_prev / cf``). ``< 1`` is TIMELIKE."""
|
|
25
|
+
return pd.Series(_k.beta(close, cf), index=_index(close), name="beta")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def bh_mass(
|
|
29
|
+
close,
|
|
30
|
+
cf: float = 0.001,
|
|
31
|
+
bh_form: float = 1.5,
|
|
32
|
+
bh_collapse: float = 1.0,
|
|
33
|
+
timelike_decay: float = 0.95,
|
|
34
|
+
spacelike_decay: float = 0.7,
|
|
35
|
+
ctl_min: int = 5,
|
|
36
|
+
**advanced,
|
|
37
|
+
) -> pd.DataFrame:
|
|
38
|
+
"""Black-hole mass. Returns a DataFrame with columns
|
|
39
|
+
``bh_mass, bh_active, bh_dir, ctl``.
|
|
40
|
+
|
|
41
|
+
``advanced`` accepts ``gain``, ``boost_step``, ``boost_cap``,
|
|
42
|
+
``reform_window`` and ``reform_frac`` (see docs/PARAMETERS.md).
|
|
43
|
+
"""
|
|
44
|
+
unknown = set(advanced) - {"gain", "boost_step", "boost_cap", "reform_window", "reform_frac"}
|
|
45
|
+
if unknown:
|
|
46
|
+
raise TypeError(f"unexpected keyword arguments: {sorted(unknown)}")
|
|
47
|
+
out = _k.bh_mass(close, cf=cf, bh_form=bh_form, bh_collapse=bh_collapse,
|
|
48
|
+
timelike_decay=timelike_decay, spacelike_decay=spacelike_decay,
|
|
49
|
+
ctl_min=ctl_min, **advanced)
|
|
50
|
+
return pd.DataFrame(out, index=_index(close))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def geodesic(close, atr, window: int = 20, cf: float = 0.001) -> pd.DataFrame:
|
|
54
|
+
"""Rolling geodesic. Columns ``geo_dev, geo_slope, causal_frac``."""
|
|
55
|
+
a = atr.to_numpy(dtype=float) if isinstance(atr, pd.Series) else atr
|
|
56
|
+
return pd.DataFrame(_k.geodesic(close, a, window=window, cf=cf), index=_index(close))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def hawking_z(close, period: int = 20) -> pd.DataFrame:
|
|
60
|
+
"""Bollinger z and Hawking proxy. Columns ``hawking_z, hawking_t``."""
|
|
61
|
+
return pd.DataFrame(_k.hawking_z(close, period=period), index=_index(close))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def wilder_atr(high, low, close, period: int = 14) -> pd.Series:
|
|
65
|
+
"""Wilder ATR with srfm-lab's warm-up (NaN on the first bar)."""
|
|
66
|
+
def arr(x):
|
|
67
|
+
return x.to_numpy(dtype=float) if isinstance(x, pd.Series) else x
|
|
68
|
+
return pd.Series(_k.wilder_atr(arr(high), arr(low), arr(close), period),
|
|
69
|
+
index=_index(close), name="atr")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def add_srfm_columns(
|
|
73
|
+
df: pd.DataFrame,
|
|
74
|
+
cf: float = 0.001,
|
|
75
|
+
window: int = 20,
|
|
76
|
+
period: int = 20,
|
|
77
|
+
atr_period: int = 14,
|
|
78
|
+
prefix: str = "srfm_",
|
|
79
|
+
close_col: str = "close",
|
|
80
|
+
**bh_params,
|
|
81
|
+
) -> pd.DataFrame:
|
|
82
|
+
"""Add every SRFM column to ``df`` in place and return it.
|
|
83
|
+
|
|
84
|
+
Made for a freqtrade ``populate_indicators``::
|
|
85
|
+
|
|
86
|
+
from srfm_core import add_srfm_columns
|
|
87
|
+
|
|
88
|
+
def populate_indicators(self, dataframe, metadata):
|
|
89
|
+
return add_srfm_columns(dataframe, cf=0.001)
|
|
90
|
+
|
|
91
|
+
Adds ``beta, bh_mass, bh_active, bh_dir, ctl, geo_dev, geo_slope,
|
|
92
|
+
causal_frac, hawking_z, hawking_t, atr``, each named with ``prefix``
|
|
93
|
+
(default ``srfm_``, so ``srfm_bh_mass``; pass ``prefix=""`` for bare
|
|
94
|
+
names). ATR is Wilder ATR from ``high``/``low``/``close``; on a close-only
|
|
95
|
+
frame the true range falls back to ``|dC|``.
|
|
96
|
+
``bh_params`` go to :func:`bh_mass`.
|
|
97
|
+
"""
|
|
98
|
+
close = df[close_col].to_numpy(dtype=float)
|
|
99
|
+
if "high" in df.columns and "low" in df.columns:
|
|
100
|
+
atr = _k.wilder_atr(df["high"].to_numpy(dtype=float), df["low"].to_numpy(dtype=float),
|
|
101
|
+
close, atr_period)
|
|
102
|
+
else:
|
|
103
|
+
atr = _k.wilder_atr(close, close, close, atr_period)
|
|
104
|
+
cols = {"beta": _k.beta(close, cf)}
|
|
105
|
+
cols.update(_k.bh_mass(close, cf=cf, **bh_params))
|
|
106
|
+
cols.update(_k.geodesic(close, atr, window=window, cf=cf))
|
|
107
|
+
cols.update(_k.hawking_z(close, period=period))
|
|
108
|
+
cols["atr"] = atr
|
|
109
|
+
for name, values in cols.items():
|
|
110
|
+
df[prefix + name] = values
|
|
111
|
+
return df
|
srfm_core/kernels.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""NumPy-only batch kernels. No pandas import.
|
|
2
|
+
|
|
3
|
+
Every function takes 1-D float arrays and returns ndarrays (or a dict of
|
|
4
|
+
ndarrays). Shared conventions, identical in the Rust crate and checked by
|
|
5
|
+
``golden/golden_vectors.json``:
|
|
6
|
+
|
|
7
|
+
* Non-finite closes (NaN, inf) are gaps. A gap bar outputs NaN (``bh_active``
|
|
8
|
+
False, ``bh_dir`` 0, ``ctl`` 0), does not touch the state, and the next
|
|
9
|
+
finite bar is compared with the last finite close.
|
|
10
|
+
* Finite closes must be > 0, otherwise ``ValueError``.
|
|
11
|
+
* Warm-up outputs are NaN, never a fake 0.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
from typing import Dict, Tuple
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
__all__ = ["beta", "bh_mass", "geodesic", "hawking_z", "wilder_atr", "BH_DEFAULTS"]
|
|
22
|
+
|
|
23
|
+
_EPS = 1e-9
|
|
24
|
+
|
|
25
|
+
#: LARSA's BH constants, now parameters. See docs/PARAMETERS.md.
|
|
26
|
+
BH_DEFAULTS = dict(
|
|
27
|
+
cf=0.001,
|
|
28
|
+
bh_form=1.5,
|
|
29
|
+
bh_collapse=1.0,
|
|
30
|
+
timelike_decay=0.95,
|
|
31
|
+
spacelike_decay=0.7,
|
|
32
|
+
ctl_min=5,
|
|
33
|
+
gain=100.0,
|
|
34
|
+
boost_step=0.1,
|
|
35
|
+
boost_cap=2.0,
|
|
36
|
+
reform_window=15,
|
|
37
|
+
reform_frac=0.5,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _as_close(close) -> Tuple[np.ndarray, np.ndarray]:
|
|
42
|
+
c = np.asarray(close, dtype=float)
|
|
43
|
+
if c.ndim != 1:
|
|
44
|
+
raise ValueError("close must be 1-D")
|
|
45
|
+
finite = np.isfinite(c)
|
|
46
|
+
if np.any(c[finite] <= 0):
|
|
47
|
+
raise ValueError("close prices must be > 0 (non-finite values are treated as gaps)")
|
|
48
|
+
return c, finite
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _check_cf(cf: float) -> None:
|
|
52
|
+
if not (cf > 0 and math.isfinite(cf)):
|
|
53
|
+
raise ValueError("cf must be a finite number > 0")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def beta(close, cf: float = 0.001) -> np.ndarray:
|
|
57
|
+
"""``|C_t - C_{t-1}| / C_{t-1} / cf`` per bar. NaN on the first finite bar and on gaps.
|
|
58
|
+
|
|
59
|
+
``beta < 1`` is a TIMELIKE bar, ``beta >= 1`` SPACELIKE.
|
|
60
|
+
"""
|
|
61
|
+
_check_cf(cf)
|
|
62
|
+
c, finite = _as_close(close)
|
|
63
|
+
out = np.full(c.shape, np.nan)
|
|
64
|
+
idx = np.flatnonzero(finite)
|
|
65
|
+
if idx.size >= 2:
|
|
66
|
+
v = c[idx]
|
|
67
|
+
out[idx[1:]] = np.abs(v[1:] - v[:-1]) / (v[:-1] + _EPS) / cf
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def bh_mass(
|
|
72
|
+
close,
|
|
73
|
+
cf: float = 0.001,
|
|
74
|
+
bh_form: float = 1.5,
|
|
75
|
+
bh_collapse: float = 1.0,
|
|
76
|
+
timelike_decay: float = 0.95,
|
|
77
|
+
spacelike_decay: float = 0.7,
|
|
78
|
+
ctl_min: int = 5,
|
|
79
|
+
gain: float = 100.0,
|
|
80
|
+
boost_step: float = 0.1,
|
|
81
|
+
boost_cap: float = 2.0,
|
|
82
|
+
reform_window: int = 15,
|
|
83
|
+
reform_frac: float = 0.5,
|
|
84
|
+
) -> Dict[str, np.ndarray]:
|
|
85
|
+
"""Black-hole mass per bar.
|
|
86
|
+
|
|
87
|
+
Returns a dict with ``bh_mass`` (float), ``bh_active`` (bool), ``bh_dir``
|
|
88
|
+
(int8, +1/-1/0) and ``ctl`` (int64, consecutive TIMELIKE bars). See
|
|
89
|
+
:class:`srfm_core.BlackHoleDetector` for the recurrence.
|
|
90
|
+
"""
|
|
91
|
+
_check_cf(cf)
|
|
92
|
+
c, finite = _as_close(close)
|
|
93
|
+
n = c.shape[0]
|
|
94
|
+
mass_out = np.full(n, np.nan)
|
|
95
|
+
active_out = np.zeros(n, dtype=bool)
|
|
96
|
+
dir_out = np.zeros(n, dtype=np.int8)
|
|
97
|
+
ctl_out = np.zeros(n, dtype=np.int64)
|
|
98
|
+
|
|
99
|
+
mass = 0.0
|
|
100
|
+
active = False
|
|
101
|
+
bdir = 0
|
|
102
|
+
ctl = 0
|
|
103
|
+
cum = 0.0
|
|
104
|
+
prev_mass = 0.0
|
|
105
|
+
reform = 0
|
|
106
|
+
prev = None
|
|
107
|
+
for i in np.flatnonzero(finite).tolist():
|
|
108
|
+
x = float(c[i])
|
|
109
|
+
if prev is not None:
|
|
110
|
+
diff = x - prev
|
|
111
|
+
timelike = abs(diff) / (prev + _EPS) / cf < 1.0
|
|
112
|
+
br = diff / (prev + _EPS)
|
|
113
|
+
if timelike:
|
|
114
|
+
ctl += 1
|
|
115
|
+
cum += br
|
|
116
|
+
sb = min(boost_cap, 1.0 + ctl * boost_step)
|
|
117
|
+
mass = mass * timelike_decay + abs(br) * gain * sb
|
|
118
|
+
else:
|
|
119
|
+
ctl = 0
|
|
120
|
+
mass *= spacelike_decay
|
|
121
|
+
if cum > 0:
|
|
122
|
+
bdir = 1
|
|
123
|
+
elif cum < 0:
|
|
124
|
+
bdir = -1
|
|
125
|
+
was = active
|
|
126
|
+
if not was:
|
|
127
|
+
if 0 < reform < reform_window:
|
|
128
|
+
mass += prev_mass * reform_frac
|
|
129
|
+
prev_mass = 0.0
|
|
130
|
+
active = mass > bh_form and ctl >= ctl_min
|
|
131
|
+
else:
|
|
132
|
+
active = mass > bh_collapse and ctl >= ctl_min
|
|
133
|
+
if active and not was:
|
|
134
|
+
reform = 0
|
|
135
|
+
elif was and not active:
|
|
136
|
+
prev_mass = mass
|
|
137
|
+
reform = 1
|
|
138
|
+
if not active:
|
|
139
|
+
if reform > 0:
|
|
140
|
+
reform += 1
|
|
141
|
+
if ctl == 0:
|
|
142
|
+
cum = 0.0
|
|
143
|
+
mass_out[i] = mass
|
|
144
|
+
active_out[i] = active
|
|
145
|
+
dir_out[i] = bdir
|
|
146
|
+
ctl_out[i] = ctl
|
|
147
|
+
prev = x
|
|
148
|
+
return {"bh_mass": mass_out, "bh_active": active_out, "bh_dir": dir_out, "ctl": ctl_out}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def geodesic(close, atr, window: int = 20, cf: float = 0.001) -> Dict[str, np.ndarray]:
|
|
152
|
+
"""Rolling log-linear "geodesic" over ``window`` finite closes.
|
|
153
|
+
|
|
154
|
+
Returns ``geo_dev`` = tanh((C - fitted C) / atr), ``geo_slope`` =
|
|
155
|
+
100 * slope of log price per bar, and ``causal_frac`` = share of lags
|
|
156
|
+
1..window-1 with a TIMELIKE interval. NaN until ``window`` finite closes.
|
|
157
|
+
``atr`` is aligned with ``close``; NaN ATR gives NaN ``geo_dev`` only.
|
|
158
|
+
"""
|
|
159
|
+
if window < 2:
|
|
160
|
+
raise ValueError("window must be >= 2")
|
|
161
|
+
_check_cf(cf)
|
|
162
|
+
c, finite = _as_close(close)
|
|
163
|
+
a = np.asarray(atr, dtype=float)
|
|
164
|
+
if a.shape != c.shape:
|
|
165
|
+
raise ValueError("atr must have the same length as close")
|
|
166
|
+
n = c.shape[0]
|
|
167
|
+
dev = np.full(n, np.nan)
|
|
168
|
+
slope_out = np.full(n, np.nan)
|
|
169
|
+
causal = np.full(n, np.nan)
|
|
170
|
+
idx = np.flatnonzero(finite)
|
|
171
|
+
m = idx.size
|
|
172
|
+
if m >= window:
|
|
173
|
+
v = c[idx]
|
|
174
|
+
w = np.lib.stride_tricks.sliding_window_view(v, window) # oldest -> newest
|
|
175
|
+
lp = np.log(w + _EPS)
|
|
176
|
+
x = np.arange(window, dtype=float)
|
|
177
|
+
nn = float(window)
|
|
178
|
+
sx = x.sum()
|
|
179
|
+
sxx = float(np.dot(x, x))
|
|
180
|
+
slp = lp.sum(axis=1)
|
|
181
|
+
sxlp = lp @ x
|
|
182
|
+
slope = (nn * sxlp - sx * slp) / (nn * sxx - sx * sx + _EPS)
|
|
183
|
+
intercept = (slp - slope * sx) / nn
|
|
184
|
+
geo_p = np.exp(slope * (window - 1) + intercept)
|
|
185
|
+
last = w[:, -1]
|
|
186
|
+
tgt = idx[window - 1:]
|
|
187
|
+
dev[tgt] = np.tanh((last - geo_p) / (a[tgt] + _EPS))
|
|
188
|
+
slope_out[tgt] = slope * 100
|
|
189
|
+
k = np.arange(1, window, dtype=float)
|
|
190
|
+
past = w[:, :-1][:, ::-1] # column j = lag j+1
|
|
191
|
+
dp = np.abs(last[:, None] - past) / (past + _EPS)
|
|
192
|
+
ds2 = -(cf * k) ** 2 + dp * dp
|
|
193
|
+
causal[tgt] = (ds2 < 0).sum(axis=1) / (window - 1)
|
|
194
|
+
return {"geo_dev": dev, "geo_slope": slope_out, "causal_frac": causal}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def hawking_z(close, period: int = 20) -> Dict[str, np.ndarray]:
|
|
198
|
+
"""Bollinger z-score and the LARSA Hawking-temperature proxy.
|
|
199
|
+
|
|
200
|
+
``hawking_z = (C - mean) / std`` over the last ``period`` finite closes
|
|
201
|
+
(population std, current bar included) and
|
|
202
|
+
``hawking_t = z * (z - z_prev)``, where ``z_prev`` is the previous defined
|
|
203
|
+
z (0 before the first). NaN in warm-up and where std == 0.
|
|
204
|
+
"""
|
|
205
|
+
if period < 2:
|
|
206
|
+
raise ValueError("period must be >= 2")
|
|
207
|
+
c, finite = _as_close(close)
|
|
208
|
+
n = c.shape[0]
|
|
209
|
+
z_out = np.full(n, np.nan)
|
|
210
|
+
t_out = np.full(n, np.nan)
|
|
211
|
+
idx = np.flatnonzero(finite)
|
|
212
|
+
if idx.size >= period:
|
|
213
|
+
v = c[idx]
|
|
214
|
+
w = np.lib.stride_tricks.sliding_window_view(v, period)
|
|
215
|
+
mid = w.sum(axis=1) / period
|
|
216
|
+
std = w.std(axis=1)
|
|
217
|
+
last = w[:, -1]
|
|
218
|
+
tgt = idx[period - 1:]
|
|
219
|
+
ok = std > 0
|
|
220
|
+
z = np.full(last.shape, np.nan)
|
|
221
|
+
z[ok] = (last[ok] - mid[ok]) / std[ok]
|
|
222
|
+
zv = z[ok]
|
|
223
|
+
pz = np.concatenate(([0.0], zv[:-1]))
|
|
224
|
+
t = np.full(last.shape, np.nan)
|
|
225
|
+
t[ok] = zv * (zv - pz)
|
|
226
|
+
z_out[tgt] = z
|
|
227
|
+
t_out[tgt] = t
|
|
228
|
+
return {"hawking_z": z_out, "hawking_t": t_out}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def wilder_atr(high, low, close, period: int = 14) -> np.ndarray:
|
|
232
|
+
"""Wilder ATR (mean of true ranges for the first ``period`` bars, then
|
|
233
|
+
Wilder smoothing). NaN on the first finite close and on gaps."""
|
|
234
|
+
if period < 1:
|
|
235
|
+
raise ValueError("period must be >= 1")
|
|
236
|
+
c, finite = _as_close(close)
|
|
237
|
+
h = np.asarray(high, dtype=float)
|
|
238
|
+
lo = np.asarray(low, dtype=float)
|
|
239
|
+
if h.shape != c.shape or lo.shape != c.shape:
|
|
240
|
+
raise ValueError("high, low and close must have the same length")
|
|
241
|
+
out = np.full(c.shape, np.nan)
|
|
242
|
+
prev = None
|
|
243
|
+
val = 0.0
|
|
244
|
+
k = 0
|
|
245
|
+
for i in np.flatnonzero(finite).tolist():
|
|
246
|
+
x = float(c[i])
|
|
247
|
+
if prev is not None:
|
|
248
|
+
tr = max(float(h[i]) - float(lo[i]), abs(float(h[i]) - prev), abs(float(lo[i]) - prev))
|
|
249
|
+
if k < period:
|
|
250
|
+
val = (val * k + tr) / (k + 1)
|
|
251
|
+
else:
|
|
252
|
+
val = (val * (period - 1) + tr) / period
|
|
253
|
+
k += 1
|
|
254
|
+
out[i] = val
|
|
255
|
+
prev = x
|
|
256
|
+
return out
|
srfm_core/py.typed
ADDED
|
File without changes
|
srfm_core/streaming.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Streaming (bar-by-bar) SRFM classes.
|
|
2
|
+
|
|
3
|
+
These are the classes from srfm-lab ``lib/srfm_core.py`` (commit 8cbfcb0) with
|
|
4
|
+
two changes and no change to the math at default settings:
|
|
5
|
+
|
|
6
|
+
* every LARSA constant is now a documented constructor parameter whose
|
|
7
|
+
default is the value LARSA shipped with;
|
|
8
|
+
* lab-only batch wrappers (``detect``, ``analyze``, ``compute_mu``,
|
|
9
|
+
``compute_temperature``, ``classify``) are gone; use the functions in
|
|
10
|
+
:mod:`srfm_core` instead, which do not have the one-bar lag of ``detect``.
|
|
11
|
+
|
|
12
|
+
The golden vectors in ``golden/golden_vectors.json`` are produced from the
|
|
13
|
+
untouched srfm-lab file and these classes are tested against them.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import math
|
|
19
|
+
from collections import deque
|
|
20
|
+
from typing import Deque, List, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"MinkowskiClassifier",
|
|
26
|
+
"BlackHoleDetector",
|
|
27
|
+
"GeodesicAnalyzer",
|
|
28
|
+
"GravitationalLens",
|
|
29
|
+
"HawkingMonitor",
|
|
30
|
+
"WilderATR",
|
|
31
|
+
"TIMELIKE",
|
|
32
|
+
"SPACELIKE",
|
|
33
|
+
"UNKNOWN",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
TIMELIKE = "TIMELIKE"
|
|
37
|
+
SPACELIKE = "SPACELIKE"
|
|
38
|
+
UNKNOWN = "UNKNOWN"
|
|
39
|
+
|
|
40
|
+
_EPS = 1e-9 # denominator guard used throughout LARSA; kept for bit-exactness
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MinkowskiClassifier:
|
|
44
|
+
"""Classify each bar as TIMELIKE or SPACELIKE.
|
|
45
|
+
|
|
46
|
+
``beta = |close_t - close_{t-1}| / close_{t-1} / cf``.
|
|
47
|
+
``beta < 1`` is TIMELIKE (an ordered move smaller than the "speed of
|
|
48
|
+
light" ``cf``), ``beta >= 1`` is SPACELIKE.
|
|
49
|
+
|
|
50
|
+
Also tracks ``tl_confirm`` (consecutive TIMELIKE bars, capped at
|
|
51
|
+
``tl_cap``) and ``proper_time`` (sum of 1/gamma with
|
|
52
|
+
``v = min(0.99, |dC/C| / max_vol)``).
|
|
53
|
+
|
|
54
|
+
Parameters
|
|
55
|
+
----------
|
|
56
|
+
cf : float, default 0.001
|
|
57
|
+
Speed-of-light constant as a fraction of price per bar. A bar whose
|
|
58
|
+
absolute return is below ``cf`` is TIMELIKE. Tune per instrument and
|
|
59
|
+
bar size; LARSA used 0.001 on hourly index futures.
|
|
60
|
+
max_vol : float, default 0.01
|
|
61
|
+
Return at which proper-time velocity saturates (v capped at 0.99).
|
|
62
|
+
tl_cap : int, default 3
|
|
63
|
+
Cap on the ``tl_confirm`` counter.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, cf: float = 0.001, max_vol: float = 0.01, tl_cap: int = 3):
|
|
67
|
+
if cf <= 0:
|
|
68
|
+
raise ValueError("cf must be > 0")
|
|
69
|
+
self.cf = cf
|
|
70
|
+
self.max_vol = max_vol
|
|
71
|
+
self.tl_cap = tl_cap
|
|
72
|
+
self.beta: float = 0.0
|
|
73
|
+
self.bit: str = UNKNOWN
|
|
74
|
+
self.tl_confirm: int = 0
|
|
75
|
+
self.proper_time: float = 0.0
|
|
76
|
+
self._prev_close: Optional[float] = None
|
|
77
|
+
|
|
78
|
+
def update(self, close: float) -> str:
|
|
79
|
+
"""Feed one close. Returns ``"TIMELIKE"``, ``"SPACELIKE"`` or ``"UNKNOWN"`` (first bar)."""
|
|
80
|
+
if self._prev_close is None or self._prev_close <= 0:
|
|
81
|
+
self._prev_close = close
|
|
82
|
+
return self.bit
|
|
83
|
+
price_diff = abs(close - self._prev_close)
|
|
84
|
+
self.beta = price_diff / (self._prev_close + _EPS) / self.cf
|
|
85
|
+
self.bit = TIMELIKE if self.beta < 1.0 else SPACELIKE
|
|
86
|
+
if self.bit == TIMELIKE:
|
|
87
|
+
self.tl_confirm = min(self.tl_confirm + 1, self.tl_cap)
|
|
88
|
+
else:
|
|
89
|
+
self.tl_confirm = 0
|
|
90
|
+
hv = price_diff / (self._prev_close + _EPS)
|
|
91
|
+
v = min(0.99, hv / self.max_vol)
|
|
92
|
+
gamma = 1.0 / math.sqrt(max(1e-9, 1.0 - v * v))
|
|
93
|
+
self.proper_time += 1.0 / gamma
|
|
94
|
+
self._prev_close = close
|
|
95
|
+
return self.bit
|
|
96
|
+
|
|
97
|
+
def reset_proper_time(self) -> None:
|
|
98
|
+
self.proper_time = 0.0
|
|
99
|
+
|
|
100
|
+
def reset(self) -> None:
|
|
101
|
+
self.__init__(self.cf, self.max_vol, self.tl_cap)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def is_timelike(self) -> bool:
|
|
105
|
+
return self.bit == TIMELIKE
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class BlackHoleDetector:
|
|
109
|
+
"""Black-hole (BH) mass: a decaying accumulator of ordered momentum.
|
|
110
|
+
|
|
111
|
+
Per bar, with ``br = (close - prev_close) / prev_close``:
|
|
112
|
+
|
|
113
|
+
* TIMELIKE: ``ctl += 1``; ``sb = min(boost_cap, 1 + ctl * boost_step)``;
|
|
114
|
+
``mass = mass * timelike_decay + |br| * gain * sb``
|
|
115
|
+
* otherwise: ``ctl = 0``; ``mass *= spacelike_decay``
|
|
116
|
+
|
|
117
|
+
A well *forms* when ``mass > bh_form`` and ``ctl >= ctl_min``; it stays
|
|
118
|
+
active while ``mass > bh_collapse`` and ``ctl >= ctl_min``. ``bh_dir`` is
|
|
119
|
+
the sign of the return accumulated over the current TIMELIKE run.
|
|
120
|
+
Within ``reform_window`` bars of a collapse, the next formation attempt
|
|
121
|
+
adds ``reform_frac`` of the mass at collapse (once).
|
|
122
|
+
|
|
123
|
+
All defaults are the values LARSA shipped with.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
bh_form: float = 1.5,
|
|
129
|
+
bh_collapse: float = 1.0,
|
|
130
|
+
bh_decay: float = 0.95,
|
|
131
|
+
*,
|
|
132
|
+
spacelike_decay: float = 0.7,
|
|
133
|
+
ctl_min: int = 5,
|
|
134
|
+
gain: float = 100.0,
|
|
135
|
+
boost_step: float = 0.1,
|
|
136
|
+
boost_cap: float = 2.0,
|
|
137
|
+
reform_window: int = 15,
|
|
138
|
+
reform_frac: float = 0.5,
|
|
139
|
+
):
|
|
140
|
+
self.bh_form = bh_form
|
|
141
|
+
self.bh_collapse = bh_collapse
|
|
142
|
+
self.bh_decay = bh_decay # TIMELIKE decay; name kept from srfm-lab
|
|
143
|
+
self.spacelike_decay = spacelike_decay
|
|
144
|
+
self.ctl_min = ctl_min
|
|
145
|
+
self.gain = gain
|
|
146
|
+
self.boost_step = boost_step
|
|
147
|
+
self.boost_cap = boost_cap
|
|
148
|
+
self.reform_window = reform_window
|
|
149
|
+
self.reform_frac = reform_frac
|
|
150
|
+
self.reset()
|
|
151
|
+
self.well_events: List[dict] = []
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def timelike_decay(self) -> float:
|
|
155
|
+
return self.bh_decay
|
|
156
|
+
|
|
157
|
+
def update(self, bit: str, close: float, prev_close: float) -> bool:
|
|
158
|
+
"""Feed one bar (its classification and the two closes). Returns ``bh_active``."""
|
|
159
|
+
br = (close - prev_close) / (prev_close + _EPS)
|
|
160
|
+
if bit == TIMELIKE:
|
|
161
|
+
self.ctl += 1
|
|
162
|
+
self.cum_disp += br
|
|
163
|
+
sb = min(self.boost_cap, 1.0 + self.ctl * self.boost_step)
|
|
164
|
+
self.bh_mass = self.bh_mass * self.bh_decay + abs(br) * self.gain * sb
|
|
165
|
+
else:
|
|
166
|
+
self.ctl = 0
|
|
167
|
+
self.bh_mass *= self.spacelike_decay
|
|
168
|
+
|
|
169
|
+
if self.cum_disp > 0:
|
|
170
|
+
self.bh_dir = 1
|
|
171
|
+
elif self.cum_disp < 0:
|
|
172
|
+
self.bh_dir = -1
|
|
173
|
+
|
|
174
|
+
prev_active = self.bh_active
|
|
175
|
+
if not prev_active:
|
|
176
|
+
if 0 < self.reform_bars < self.reform_window:
|
|
177
|
+
self.bh_mass += self.prev_bh_mass * self.reform_frac
|
|
178
|
+
self.prev_bh_mass = 0.0
|
|
179
|
+
self.bh_active = self.bh_mass > self.bh_form and self.ctl >= self.ctl_min
|
|
180
|
+
else:
|
|
181
|
+
self.bh_active = self.bh_mass > self.bh_collapse and self.ctl >= self.ctl_min
|
|
182
|
+
|
|
183
|
+
if self.bh_active and not prev_active:
|
|
184
|
+
self.reform_bars = 0
|
|
185
|
+
self.well_events.append(
|
|
186
|
+
{"event": "formed", "mass": self.bh_mass, "dir": self.bh_dir, "ctl": self.ctl})
|
|
187
|
+
elif not self.bh_active and prev_active:
|
|
188
|
+
self.prev_bh_mass = self.bh_mass
|
|
189
|
+
self.reform_bars = 1
|
|
190
|
+
self.well_events.append({"event": "collapsed", "mass": self.bh_mass})
|
|
191
|
+
|
|
192
|
+
if not self.bh_active:
|
|
193
|
+
if self.reform_bars > 0:
|
|
194
|
+
self.reform_bars += 1
|
|
195
|
+
if self.ctl == 0:
|
|
196
|
+
self.cum_disp = 0.0
|
|
197
|
+
return self.bh_active
|
|
198
|
+
|
|
199
|
+
def reset(self) -> None:
|
|
200
|
+
self.bh_mass = 0.0
|
|
201
|
+
self.bh_active = False
|
|
202
|
+
self.bh_dir = 0
|
|
203
|
+
self.ctl = 0
|
|
204
|
+
self.cum_disp = 0.0
|
|
205
|
+
self.prev_bh_mass = 0.0
|
|
206
|
+
self.reform_bars = 0
|
|
207
|
+
self.well_events = []
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class GeodesicAnalyzer:
|
|
211
|
+
"""Rolling log-linear regression ("geodesic") and Minkowski causal fraction.
|
|
212
|
+
|
|
213
|
+
Over the last ``window`` closes:
|
|
214
|
+
|
|
215
|
+
* ``geo_slope = 100 * slope`` of ``log(close)`` on bar index;
|
|
216
|
+
* ``geo_dev = tanh((close - fitted_close) / atr)``;
|
|
217
|
+
* ``causal_frac`` = share of lags ``k = 1..window-1`` whose interval
|
|
218
|
+
``ds2 = -(cf*k)^2 + (|C_t - C_{t-k}| / C_{t-k})^2`` is negative.
|
|
219
|
+
|
|
220
|
+
``update`` returns ``(geo_dev, geo_slope, causal_frac, rapidity)`` and
|
|
221
|
+
``(0, 0, 1, 0)`` during warm-up, exactly as in srfm-lab. ``rapidity`` is
|
|
222
|
+
the LARSA production formula, which is identically 0 (its denominator
|
|
223
|
+
guard never passes); it is kept only for drop-in compatibility.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
def __init__(self, cf: float = 0.001, window: int = 20):
|
|
227
|
+
if window < 2:
|
|
228
|
+
raise ValueError("window must be >= 2")
|
|
229
|
+
self.cf = cf
|
|
230
|
+
self.window = window
|
|
231
|
+
self._closes: Deque[float] = deque(maxlen=window)
|
|
232
|
+
|
|
233
|
+
def update(self, close: float, atr: float) -> Tuple[float, float, float, float]:
|
|
234
|
+
self._closes.appendleft(close)
|
|
235
|
+
if len(self._closes) < self.window:
|
|
236
|
+
return 0.0, 0.0, 1.0, 0.0
|
|
237
|
+
prices = list(reversed(list(self._closes)))
|
|
238
|
+
lp = np.log(np.array(prices) + _EPS)
|
|
239
|
+
x = np.arange(self.window, dtype=float)
|
|
240
|
+
n = float(self.window)
|
|
241
|
+
sx = x.sum()
|
|
242
|
+
slp = lp.sum()
|
|
243
|
+
sxx = np.dot(x, x)
|
|
244
|
+
sxlp = np.dot(x, lp)
|
|
245
|
+
slope = (n * sxlp - sx * slp) / (n * sxx - sx * sx + _EPS)
|
|
246
|
+
intercept = (slp - slope * sx) / n
|
|
247
|
+
geo_p = float(np.exp(slope * (self.window - 1) + intercept))
|
|
248
|
+
geo_dev = float(np.tanh((close - geo_p) / (atr + _EPS)))
|
|
249
|
+
geo_slope = float(slope * 100)
|
|
250
|
+
|
|
251
|
+
closes_list = list(self._closes)
|
|
252
|
+
cc = 0
|
|
253
|
+
for k in range(1, self.window):
|
|
254
|
+
pp = closes_list[k]
|
|
255
|
+
dp = abs(close - pp) / (pp + _EPS)
|
|
256
|
+
if -(self.cf * k) ** 2 + dp * dp < 0:
|
|
257
|
+
cc += 1
|
|
258
|
+
causal_frac = cc / (self.window - 1)
|
|
259
|
+
|
|
260
|
+
e = (close - closes_list[self.window - 1]) / (closes_list[self.window - 1] + _EPS)
|
|
261
|
+
denom = e - e + _EPS
|
|
262
|
+
num = e + e
|
|
263
|
+
if abs(denom) > _EPS and num / denom > 0:
|
|
264
|
+
rapidity = float(np.tanh(0.5 * np.log(num / denom + _EPS)))
|
|
265
|
+
else:
|
|
266
|
+
rapidity = 0.0
|
|
267
|
+
return geo_dev, geo_slope, causal_frac, rapidity
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def ready(self) -> bool:
|
|
271
|
+
return len(self._closes) >= self.window
|
|
272
|
+
|
|
273
|
+
def reset(self) -> None:
|
|
274
|
+
self._closes.clear()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class GravitationalLens:
|
|
278
|
+
"""Lensing amplification ``mu`` from the distance to the TIMELIKE-bar VWAP.
|
|
279
|
+
|
|
280
|
+
``M = ctl + (1 if TIMELIKE)``, ``R_E = sqrt(M)``,
|
|
281
|
+
``r = |close - VWAP_TL| / atr``, ``mu = 1 + R_E / (r + R_E)``;
|
|
282
|
+
``mu = max(0.3, M / 3)`` when ``M < 2``.
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
def __init__(self):
|
|
286
|
+
self._closes_tl: List[float] = []
|
|
287
|
+
self._vols_tl: List[float] = []
|
|
288
|
+
self.mu: float = 1.0
|
|
289
|
+
|
|
290
|
+
def update(self, close: float, volume: float, bit: str, ctl: int, atr: float) -> float:
|
|
291
|
+
if bit == TIMELIKE:
|
|
292
|
+
self._closes_tl.append(close)
|
|
293
|
+
self._vols_tl.append(volume)
|
|
294
|
+
else:
|
|
295
|
+
self._closes_tl = []
|
|
296
|
+
self._vols_tl = []
|
|
297
|
+
m = float(ctl + (1 if bit == TIMELIKE else 0))
|
|
298
|
+
r_e = math.sqrt(m + _EPS)
|
|
299
|
+
if m < 2.0:
|
|
300
|
+
self.mu = max(0.3, m / 3.0)
|
|
301
|
+
elif self._closes_tl:
|
|
302
|
+
sv = sum(self._vols_tl)
|
|
303
|
+
if sv > 0:
|
|
304
|
+
vwap = sum(p * v for p, v in zip(self._closes_tl, self._vols_tl)) / sv
|
|
305
|
+
else:
|
|
306
|
+
vwap = sum(self._closes_tl) / len(self._closes_tl)
|
|
307
|
+
r = abs(close - vwap) / (atr + _EPS)
|
|
308
|
+
self.mu = float(1.0 + r_e / (r + r_e))
|
|
309
|
+
else:
|
|
310
|
+
self.mu = 1.0
|
|
311
|
+
return self.mu
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class HawkingMonitor:
|
|
315
|
+
"""Hawking-temperature proxy: ``ht = z * (z - z_prev)`` on a Bollinger z-score.
|
|
316
|
+
|
|
317
|
+
``z = (close - bb_middle) / std``. LARSA treated ``ht > hot`` (1.8) as a
|
|
318
|
+
hot well (avoid long entry) and ``ht < inverted`` (-1.5) as a boost.
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
def __init__(self, hot: float = 1.8, inverted: float = -1.5):
|
|
322
|
+
self.hot = hot
|
|
323
|
+
self.inverted = inverted
|
|
324
|
+
self.ht: float = 0.0
|
|
325
|
+
self.z: float = 0.0
|
|
326
|
+
self._pz: float = 0.0
|
|
327
|
+
|
|
328
|
+
def update(self, close: float, bb_middle: float, std: float) -> float:
|
|
329
|
+
if std <= 0:
|
|
330
|
+
return self.ht
|
|
331
|
+
z = (close - bb_middle) / std
|
|
332
|
+
self.ht = z * (z - self._pz)
|
|
333
|
+
self._pz = z
|
|
334
|
+
self.z = z
|
|
335
|
+
return self.ht
|
|
336
|
+
|
|
337
|
+
@property
|
|
338
|
+
def is_hot(self) -> bool:
|
|
339
|
+
return self.ht > self.hot
|
|
340
|
+
|
|
341
|
+
@property
|
|
342
|
+
def is_inverted(self) -> bool:
|
|
343
|
+
return self.ht < self.inverted
|
|
344
|
+
|
|
345
|
+
def reset(self) -> None:
|
|
346
|
+
self.ht = 0.0
|
|
347
|
+
self.z = 0.0
|
|
348
|
+
self._pz = 0.0
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class WilderATR:
|
|
352
|
+
"""Wilder ATR as used by srfm-lab: simple mean of true ranges for the first
|
|
353
|
+
``period`` bars, Wilder smoothing after. The first bar has no true range
|
|
354
|
+
and returns ``nan`` (srfm-lab returned 0.0)."""
|
|
355
|
+
|
|
356
|
+
def __init__(self, period: int = 14):
|
|
357
|
+
self.period = period
|
|
358
|
+
self.reset()
|
|
359
|
+
|
|
360
|
+
def update(self, high: float, low: float, close: float) -> float:
|
|
361
|
+
if self._prev_c is None:
|
|
362
|
+
self._prev_c = close
|
|
363
|
+
return float("nan")
|
|
364
|
+
tr = max(high - low, abs(high - self._prev_c), abs(low - self._prev_c))
|
|
365
|
+
if self._n < self.period:
|
|
366
|
+
self.val = (self.val * self._n + tr) / (self._n + 1)
|
|
367
|
+
else:
|
|
368
|
+
self.val = (self.val * (self.period - 1) + tr) / self.period
|
|
369
|
+
self._n += 1
|
|
370
|
+
self._prev_c = close
|
|
371
|
+
return self.val
|
|
372
|
+
|
|
373
|
+
def reset(self) -> None:
|
|
374
|
+
self._prev_c: Optional[float] = None
|
|
375
|
+
self.val = 0.0
|
|
376
|
+
self._n = 0
|
srfm_core/vbt.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""vectorbt indicators (optional extra: ``pip install "srfm-core[vbt]"``).
|
|
2
|
+
|
|
3
|
+
vectorbt is imported the first time an indicator is used, not when
|
|
4
|
+
``srfm_core`` is imported::
|
|
5
|
+
|
|
6
|
+
import vectorbt as vbt
|
|
7
|
+
from srfm_core.vbt import BHMass
|
|
8
|
+
|
|
9
|
+
close = vbt.YFData.download("BTC-USD").get("Close")
|
|
10
|
+
bh = BHMass.run(close, cf=[0.001, 0.002]) # parameter grid works
|
|
11
|
+
entries = bh.active & ~bh.active.shift(1, fill_value=False)
|
|
12
|
+
pf = vbt.Portfolio.from_signals(close, entries, ~bh.active)
|
|
13
|
+
|
|
14
|
+
Indicators: ``BHMass`` (outputs mass, active, dir, ctl), ``SRFMBeta``
|
|
15
|
+
(beta) and ``HawkingZ`` (z, t). Each column of a 2-D input is run
|
|
16
|
+
independently; NaN padding between columns is treated as gaps.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
from . import kernels as _k
|
|
24
|
+
|
|
25
|
+
__all__ = ["BHMass", "SRFMBeta", "HawkingZ"]
|
|
26
|
+
|
|
27
|
+
_cache: dict = {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _per_column(fn, close2d, keys, dtypes):
|
|
31
|
+
close2d = np.asarray(close2d, dtype=float)
|
|
32
|
+
if close2d.ndim == 1:
|
|
33
|
+
close2d = close2d[:, None]
|
|
34
|
+
outs = [np.empty(close2d.shape, dtype=d) for d in dtypes]
|
|
35
|
+
for j in range(close2d.shape[1]):
|
|
36
|
+
res = fn(close2d[:, j])
|
|
37
|
+
for o, k in zip(outs, keys):
|
|
38
|
+
o[:, j] = res[k]
|
|
39
|
+
return tuple(outs) if len(outs) > 1 else outs[0]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _bh_apply(close, cf, bh_form, bh_collapse, timelike_decay, spacelike_decay, ctl_min):
|
|
43
|
+
return _per_column(
|
|
44
|
+
lambda c: _k.bh_mass(c, cf=cf, bh_form=bh_form, bh_collapse=bh_collapse,
|
|
45
|
+
timelike_decay=timelike_decay, spacelike_decay=spacelike_decay,
|
|
46
|
+
ctl_min=int(ctl_min)),
|
|
47
|
+
close, ("bh_mass", "bh_active", "bh_dir", "ctl"),
|
|
48
|
+
(float, bool, np.int8, np.int64))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _beta_apply(close, cf):
|
|
52
|
+
return _per_column(lambda c: {"beta": _k.beta(c, cf)}, close, ("beta",), (float,))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _hz_apply(close, period):
|
|
56
|
+
return _per_column(lambda c: _k.hawking_z(c, int(period)), close,
|
|
57
|
+
("hawking_z", "hawking_t"), (float, float))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build(name: str):
|
|
61
|
+
try:
|
|
62
|
+
import vectorbt as vbt
|
|
63
|
+
except ImportError as exc: # pragma: no cover - exercised only without vectorbt
|
|
64
|
+
raise ImportError(
|
|
65
|
+
"srfm_core.vbt needs vectorbt: pip install \"srfm-core[vbt]\"") from exc
|
|
66
|
+
if name == "BHMass":
|
|
67
|
+
d = _k.BH_DEFAULTS
|
|
68
|
+
return vbt.IndicatorFactory(
|
|
69
|
+
class_name="BHMass", short_name="bh", input_names=["close"],
|
|
70
|
+
param_names=["cf", "bh_form", "bh_collapse", "timelike_decay",
|
|
71
|
+
"spacelike_decay", "ctl_min"],
|
|
72
|
+
output_names=["mass", "active", "dir", "ctl"],
|
|
73
|
+
).from_apply_func(
|
|
74
|
+
_bh_apply, cf=d["cf"], bh_form=d["bh_form"], bh_collapse=d["bh_collapse"],
|
|
75
|
+
timelike_decay=d["timelike_decay"], spacelike_decay=d["spacelike_decay"],
|
|
76
|
+
ctl_min=d["ctl_min"])
|
|
77
|
+
if name == "SRFMBeta":
|
|
78
|
+
return vbt.IndicatorFactory(
|
|
79
|
+
class_name="SRFMBeta", short_name="srfm_beta", input_names=["close"],
|
|
80
|
+
param_names=["cf"], output_names=["beta"],
|
|
81
|
+
).from_apply_func(_beta_apply, cf=0.001)
|
|
82
|
+
if name == "HawkingZ":
|
|
83
|
+
return vbt.IndicatorFactory(
|
|
84
|
+
class_name="HawkingZ", short_name="hawking", input_names=["close"],
|
|
85
|
+
param_names=["period"], output_names=["z", "t"],
|
|
86
|
+
).from_apply_func(_hz_apply, period=20)
|
|
87
|
+
raise AttributeError(name)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def __getattr__(name: str):
|
|
91
|
+
if name in __all__:
|
|
92
|
+
if name not in _cache:
|
|
93
|
+
_cache[name] = _build(name)
|
|
94
|
+
return _cache[name]
|
|
95
|
+
raise AttributeError(f"module 'srfm_core.vbt' has no attribute {name!r}")
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: srfm-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SRFM bar transforms (Minkowski beta, black-hole mass, geodesic, Hawking z) as numpy/pandas functions, streaming classes, a vectorbt indicator and a freqtrade helper. Carved out of srfm-lab, with golden vectors shared with the Rust crate.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Mattbusel/srfm-core
|
|
6
|
+
Project-URL: Source, https://github.com/Mattbusel/srfm-core
|
|
7
|
+
Project-URL: Documentation, https://github.com/Mattbusel/srfm-core/tree/main/docs
|
|
8
|
+
Project-URL: Changelog, https://github.com/Mattbusel/srfm-core/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/Mattbusel/srfm-core/issues
|
|
10
|
+
Project-URL: Upstream (srfm-lab), https://github.com/Mattbusel/srfm-lab
|
|
11
|
+
Author: Matthew Busel
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
License-File: NOTICE
|
|
15
|
+
Keywords: finance,freqtrade,indicators,quant,srfm,technical-analysis,trading,vectorbt
|
|
16
|
+
Classifier: Development Status :: 4 - Beta
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
19
|
+
Classifier: Intended Audience :: Science/Research
|
|
20
|
+
Classifier: Operating System :: OS Independent
|
|
21
|
+
Classifier: Programming Language :: Python :: 3
|
|
22
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
27
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
28
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
29
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
30
|
+
Classifier: Typing :: Typed
|
|
31
|
+
Requires-Python: >=3.10
|
|
32
|
+
Requires-Dist: numpy>=1.22
|
|
33
|
+
Requires-Dist: pandas>=1.4
|
|
34
|
+
Provides-Extra: test
|
|
35
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
36
|
+
Provides-Extra: vbt
|
|
37
|
+
Requires-Dist: vectorbt>=0.25; extra == 'vbt'
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
|
|
40
|
+
# srfm-core
|
|
41
|
+
|
|
42
|
+
The SRFM bar transforms from [srfm-lab](https://github.com/Mattbusel/srfm-lab), as a small package you can drop into a pandas, vectorbt or freqtrade workflow. NumPy and pandas only.
|
|
43
|
+
|
|
44
|
+
| Function | What it gives you per bar |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `beta(close, cf=0.001)` | `abs(dC) / C_prev / cf`. Below 1 is a TIMELIKE (ordered) bar, 1 or more is SPACELIKE. |
|
|
47
|
+
| `bh_mass(close, cf, bh_form=1.5, bh_collapse=1.0, timelike_decay=0.95, spacelike_decay=0.7, ctl_min=5)` | DataFrame `bh_mass, bh_active, bh_dir, ctl`: a decaying accumulator of ordered momentum and whether a "well" is active. |
|
|
48
|
+
| `geodesic(close, atr, window=20)` | DataFrame `geo_dev, geo_slope, causal_frac`: rolling log-linear fit and the share of lags with a TIMELIKE interval. |
|
|
49
|
+
| `hawking_z(close, period=20)` | DataFrame `hawking_z, hawking_t`: Bollinger z and LARSA's `z * (z - z_prev)` proxy. |
|
|
50
|
+
| `add_srfm_columns(df)` | All of the above added to an OHLCV frame as `srfm_*` columns (made for freqtrade `populate_indicators`). |
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import srfm_core as sc
|
|
54
|
+
|
|
55
|
+
df = sc.add_srfm_columns(ohlcv) # srfm_bh_mass, srfm_bh_active, ...
|
|
56
|
+
bh = sc.bh_mass(ohlcv["close"], cf=0.002) # tune cf to your bar size
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
vectorbt (optional, `pip install "srfm-core[vbt]"`):
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from srfm_core.vbt import BHMass
|
|
63
|
+
bh = BHMass.run(close, cf=[0.001, 0.002]) # parameter grids work
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Streaming classes (`MinkowskiClassifier`, `BlackHoleDetector`, `GeodesicAnalyzer`, `GravitationalLens`, `HawkingMonitor`) keep the `update()` bar-by-bar API from srfm-lab.
|
|
67
|
+
|
|
68
|
+
Every LARSA constant is a documented parameter with LARSA's value as the default. The batch functions are checked against `golden/golden_vectors.json`, generated from srfm-lab's untouched `lib/srfm_core.py`; the Rust crate [`srfm-core`](https://crates.io/crates/srfm-core) is checked against the same file.
|
|
69
|
+
|
|
70
|
+
These are feature transforms, not a strategy. Nothing here claims a trading edge.
|
|
71
|
+
|
|
72
|
+
Docs: https://github.com/Mattbusel/srfm-core/tree/main/docs. License: MIT.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
srfm_core/__init__.py,sha256=E5uaQ5krMNjxnSsdaY9pAk4G-zC4IizD6Yyg-7-rdYE,1123
|
|
2
|
+
srfm_core/frames.py,sha256=ZvQIBQDFCraVXwzM7o0btYZ04xnuqNcRJoM4ekwO2Y4,4054
|
|
3
|
+
srfm_core/kernels.py,sha256=Zz8FJ_ZAWCouHJdXAvcKL1ul5wWu1cEx4Z9xERUd4Ek,8465
|
|
4
|
+
srfm_core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
srfm_core/streaming.py,sha256=I19J2k5Cr7Y1DqKlT5ehwU_q8nadk0BM2hV9kf2PmV0,12979
|
|
6
|
+
srfm_core/vbt.py,sha256=y0nxHR7Q5yF2FrBE5Di-WVLV5AJspA1rbgkr9GDORMs,3595
|
|
7
|
+
srfm_core-0.1.0.dist-info/METADATA,sha256=x38LHiLAESQ8mYB46WDQQ494d1iiw-x01Rr7gKMYOqo,3911
|
|
8
|
+
srfm_core-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
9
|
+
srfm_core-0.1.0.dist-info/licenses/LICENSE,sha256=TpCqwl9Iq4EFUnYGbR2dGUSmF5tDy-hCISSPJasaP10,1070
|
|
10
|
+
srfm_core-0.1.0.dist-info/licenses/NOTICE,sha256=MaygiZYB0xhiYYke5xCDKXepkxiuHzfzQ5qFIq-0qjM,899
|
|
11
|
+
srfm_core-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matthew Busel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
srfm-core
|
|
2
|
+
Copyright (c) 2026 Matthew Busel. Licensed under the MIT License (see LICENSE).
|
|
3
|
+
|
|
4
|
+
This package is carved out of srfm-lab (https://github.com/Mattbusel/srfm-lab),
|
|
5
|
+
also MIT licensed, copyright (c) 2026 Matthew Busel, at commit
|
|
6
|
+
8cbfcb0551d44e3ed1c20bdae9dc805d61521c62:
|
|
7
|
+
|
|
8
|
+
* lib/srfm_core.py -> python/srfm_core/streaming.py,
|
|
9
|
+
golden/reference/srfm_core_lab.py (verbatim)
|
|
10
|
+
* crates/tick-backtest/src/bh_physics.rs -> src/lib.rs (structure; math re-ported
|
|
11
|
+
from lib/srfm_core.py)
|
|
12
|
+
* crates/larsa-core -> bindings/python-rs (pyo3 layer only)
|
|
13
|
+
* tests/test_srfm_core.py, tests/test_bh_physics.py -> python/tests, tests
|
|
14
|
+
|
|
15
|
+
The SRFM transforms (Minkowski beta, black-hole mass, geodesic, Hawking proxy)
|
|
16
|
+
were defined in the LARSA strategy code inside srfm-lab. See docs/PROVENANCE.md.
|