srfm-core 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ /target
2
+ Cargo.lock.bak
3
+ __pycache__/
4
+ *.py[cod]
5
+ *.egg-info/
6
+ .pytest_cache/
7
+ dist/
8
+ build/
9
+ .venv/
10
+ *.pyd
11
+ *.so
@@ -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.
srfm_core-0.1.0/NOTICE ADDED
@@ -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.
@@ -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,33 @@
1
+ # srfm-core
2
+
3
+ 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.
4
+
5
+ | Function | What it gives you per bar |
6
+ |---|---|
7
+ | `beta(close, cf=0.001)` | `abs(dC) / C_prev / cf`. Below 1 is a TIMELIKE (ordered) bar, 1 or more is SPACELIKE. |
8
+ | `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. |
9
+ | `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. |
10
+ | `hawking_z(close, period=20)` | DataFrame `hawking_z, hawking_t`: Bollinger z and LARSA's `z * (z - z_prev)` proxy. |
11
+ | `add_srfm_columns(df)` | All of the above added to an OHLCV frame as `srfm_*` columns (made for freqtrade `populate_indicators`). |
12
+
13
+ ```python
14
+ import srfm_core as sc
15
+
16
+ df = sc.add_srfm_columns(ohlcv) # srfm_bh_mass, srfm_bh_active, ...
17
+ bh = sc.bh_mass(ohlcv["close"], cf=0.002) # tune cf to your bar size
18
+ ```
19
+
20
+ vectorbt (optional, `pip install "srfm-core[vbt]"`):
21
+
22
+ ```python
23
+ from srfm_core.vbt import BHMass
24
+ bh = BHMass.run(close, cf=[0.001, 0.002]) # parameter grids work
25
+ ```
26
+
27
+ Streaming classes (`MinkowskiClassifier`, `BlackHoleDetector`, `GeodesicAnalyzer`, `GravitationalLens`, `HawkingMonitor`) keep the `update()` bar-by-bar API from srfm-lab.
28
+
29
+ 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.
30
+
31
+ These are feature transforms, not a strategy. Nothing here claims a trading edge.
32
+
33
+ Docs: https://github.com/Mattbusel/srfm-core/tree/main/docs. License: MIT.
@@ -0,0 +1,53 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.21"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "srfm-core"
7
+ version = "0.1.0"
8
+ description = "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."
9
+ readme = "PYPI.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE", "NOTICE"]
12
+ authors = [{ name = "Matthew Busel" }]
13
+ requires-python = ">=3.10"
14
+ dependencies = ["numpy>=1.22", "pandas>=1.4"]
15
+ keywords = ["trading", "technical-analysis", "indicators", "quant", "finance", "vectorbt", "freqtrade", "srfm"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Intended Audience :: Financial and Insurance Industry",
20
+ "Intended Audience :: Science/Research",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3 :: Only",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Programming Language :: Python :: 3.14",
29
+ "Topic :: Office/Business :: Financial :: Investment",
30
+ "Topic :: Scientific/Engineering :: Mathematics",
31
+ "Typing :: Typed",
32
+ ]
33
+
34
+ [project.optional-dependencies]
35
+ vbt = ["vectorbt>=0.25"]
36
+ test = ["pytest>=7"]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/Mattbusel/srfm-core"
40
+ Source = "https://github.com/Mattbusel/srfm-core"
41
+ Documentation = "https://github.com/Mattbusel/srfm-core/tree/main/docs"
42
+ Changelog = "https://github.com/Mattbusel/srfm-core/blob/main/CHANGELOG.md"
43
+ Issues = "https://github.com/Mattbusel/srfm-core/issues"
44
+ "Upstream (srfm-lab)" = "https://github.com/Mattbusel/srfm-lab"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["srfm_core"]
48
+
49
+ [tool.hatch.build.targets.sdist]
50
+ include = ["srfm_core", "tests", "PYPI.md", "LICENSE", "NOTICE", "pyproject.toml"]
51
+
52
+ [tool.pytest.ini_options]
53
+ testpaths = ["tests"]
@@ -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
+ ]
@@ -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
@@ -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
File without changes