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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.