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.
- srfm_core-0.1.0/.gitignore +11 -0
- srfm_core-0.1.0/LICENSE +21 -0
- srfm_core-0.1.0/NOTICE +16 -0
- srfm_core-0.1.0/PKG-INFO +72 -0
- srfm_core-0.1.0/PYPI.md +33 -0
- srfm_core-0.1.0/pyproject.toml +53 -0
- srfm_core-0.1.0/srfm_core/__init__.py +46 -0
- srfm_core-0.1.0/srfm_core/frames.py +111 -0
- srfm_core-0.1.0/srfm_core/kernels.py +256 -0
- srfm_core-0.1.0/srfm_core/py.typed +0 -0
- srfm_core-0.1.0/srfm_core/streaming.py +376 -0
- srfm_core-0.1.0/srfm_core/vbt.py +95 -0
- srfm_core-0.1.0/tests/conftest.py +45 -0
- srfm_core-0.1.0/tests/test_api.py +105 -0
- srfm_core-0.1.0/tests/test_freqtrade_example.py +77 -0
- srfm_core-0.1.0/tests/test_golden.py +64 -0
- srfm_core-0.1.0/tests/test_streaming.py +110 -0
- srfm_core-0.1.0/tests/test_vbt.py +42 -0
srfm_core-0.1.0/LICENSE
ADDED
|
@@ -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.
|
srfm_core-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
srfm_core-0.1.0/PYPI.md
ADDED
|
@@ -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
|