2026-03-23 23:34:28 +05:30
|
|
|
|
"""
|
|
|
|
|
|
Benchmark suite
|
|
|
|
|
|
===========================
|
|
|
|
|
|
|
|
|
|
|
|
Numerical-regression and performance benchmarks that run against the canonical
|
|
|
|
|
|
OHLCV fixture in ``benchmarks/fixtures/canonical_ohlcv.npz``.
|
|
|
|
|
|
|
|
|
|
|
|
Numerical regression checks
|
|
|
|
|
|
----------------------------
|
|
|
|
|
|
For each (indicator, params) pair in ``INDICATOR_SUITE``, the test:
|
|
|
|
|
|
1. Loads the canonical dataset.
|
|
|
|
|
|
2. Runs the indicator.
|
|
|
|
|
|
3. Compares the last N non-NaN values to stored baselines (or tolerance-based).
|
|
|
|
|
|
|
|
|
|
|
|
To regenerate baselines after an intentional indicator change::
|
|
|
|
|
|
|
|
|
|
|
|
pytest benchmarks/test_benchmark_suite.py --update-baselines
|
|
|
|
|
|
|
|
|
|
|
|
Performance checks
|
|
|
|
|
|
------------------
|
|
|
|
|
|
Each indicator is timed over the canonical dataset. If a ``baselines.npz``
|
|
|
|
|
|
file exists in this directory, the run compares to that; otherwise timing is
|
|
|
|
|
|
reported only.
|
|
|
|
|
|
|
|
|
|
|
|
Run locally::
|
|
|
|
|
|
|
|
|
|
|
|
pytest benchmarks/test_benchmark_suite.py -v
|
|
|
|
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import pathlib
|
|
|
|
|
|
import time
|
2026-03-24 02:02:10 +05:30
|
|
|
|
from collections.abc import Callable
|
|
|
|
|
|
from typing import Any
|
2026-03-23 23:34:28 +05:30
|
|
|
|
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
FIXTURE_PATH = pathlib.Path(__file__).parent / "fixtures" / "canonical_ohlcv.npz"
|
|
|
|
|
|
BASELINE_PATH = pathlib.Path(__file__).parent / "baselines.npz"
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Load fixture
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
2026-03-24 02:02:10 +05:30
|
|
|
|
def ohlcv() -> dict[str, np.ndarray]:
|
2026-03-23 23:34:28 +05:30
|
|
|
|
"""Load canonical OHLCV fixture."""
|
|
|
|
|
|
if not FIXTURE_PATH.exists():
|
|
|
|
|
|
pytest.skip(f"Canonical fixture not found: {FIXTURE_PATH}")
|
|
|
|
|
|
data = np.load(FIXTURE_PATH)
|
|
|
|
|
|
return {k: data[k] for k in data.files}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Indicator suite definition
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
# Each entry: (name, callable, kwargs)
|
|
|
|
|
|
# The callable receives (close,) or (high, low, close,) based on 'inputs' key.
|
2026-03-24 02:02:10 +05:30
|
|
|
|
INDICATOR_SUITE: list[dict[str, Any]] = [
|
2026-03-23 23:34:28 +05:30
|
|
|
|
{
|
|
|
|
|
|
"name": "SMA_20",
|
|
|
|
|
|
"inputs": "close",
|
|
|
|
|
|
"fn": None,
|
|
|
|
|
|
"fn_name": "SMA",
|
|
|
|
|
|
"kwargs": {"timeperiod": 20},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "EMA_20",
|
|
|
|
|
|
"inputs": "close",
|
|
|
|
|
|
"fn": None,
|
|
|
|
|
|
"fn_name": "EMA",
|
|
|
|
|
|
"kwargs": {"timeperiod": 20},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "RSI_14",
|
|
|
|
|
|
"inputs": "close",
|
|
|
|
|
|
"fn": None,
|
|
|
|
|
|
"fn_name": "RSI",
|
|
|
|
|
|
"kwargs": {"timeperiod": 14},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "ATR_14",
|
|
|
|
|
|
"inputs": "hlc",
|
|
|
|
|
|
"fn": None,
|
|
|
|
|
|
"fn_name": "ATR",
|
|
|
|
|
|
"kwargs": {"timeperiod": 14},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "ADX_14",
|
|
|
|
|
|
"inputs": "hlc",
|
|
|
|
|
|
"fn": None,
|
|
|
|
|
|
"fn_name": "ADX",
|
|
|
|
|
|
"kwargs": {"timeperiod": 14},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "STDDEV_20",
|
|
|
|
|
|
"inputs": "close",
|
|
|
|
|
|
"fn": None,
|
|
|
|
|
|
"fn_name": "STDDEV",
|
|
|
|
|
|
"kwargs": {"timeperiod": 20},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "MACD",
|
|
|
|
|
|
"inputs": "close",
|
|
|
|
|
|
"fn": None,
|
|
|
|
|
|
"fn_name": "MACD",
|
|
|
|
|
|
"kwargs": {},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "BBANDS_20",
|
|
|
|
|
|
"inputs": "close",
|
|
|
|
|
|
"fn": None,
|
|
|
|
|
|
"fn_name": "BBANDS",
|
|
|
|
|
|
"kwargs": {"timeperiod": 20},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "STOCH",
|
|
|
|
|
|
"inputs": "hlc",
|
|
|
|
|
|
"fn": None,
|
|
|
|
|
|
"fn_name": "STOCH",
|
|
|
|
|
|
"kwargs": {},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "LINEARREG_14",
|
|
|
|
|
|
"inputs": "close",
|
|
|
|
|
|
"fn": None,
|
|
|
|
|
|
"fn_name": "LINEARREG",
|
|
|
|
|
|
"kwargs": {"timeperiod": 14},
|
|
|
|
|
|
},
|
2026-03-24 02:02:10 +05:30
|
|
|
|
{
|
|
|
|
|
|
"name": "LINEARREG_SLOPE_14",
|
|
|
|
|
|
"inputs": "close",
|
|
|
|
|
|
"fn": None,
|
|
|
|
|
|
"fn_name": "LINEARREG_SLOPE",
|
|
|
|
|
|
"kwargs": {"timeperiod": 14},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "TSF_14",
|
|
|
|
|
|
"inputs": "close",
|
|
|
|
|
|
"fn": None,
|
|
|
|
|
|
"fn_name": "TSF",
|
|
|
|
|
|
"kwargs": {"timeperiod": 14},
|
|
|
|
|
|
},
|
2026-03-23 23:34:28 +05:30
|
|
|
|
{
|
|
|
|
|
|
"name": "VAR_20",
|
|
|
|
|
|
"inputs": "close",
|
|
|
|
|
|
"fn": None,
|
|
|
|
|
|
"fn_name": "VAR",
|
|
|
|
|
|
"kwargs": {"timeperiod": 20},
|
|
|
|
|
|
},
|
2026-03-24 02:02:10 +05:30
|
|
|
|
{
|
|
|
|
|
|
"name": "CORREL_30",
|
|
|
|
|
|
"inputs": "pair_hl",
|
|
|
|
|
|
"fn": None,
|
|
|
|
|
|
"fn_name": "CORREL",
|
|
|
|
|
|
"kwargs": {"timeperiod": 30},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "BETA_5",
|
|
|
|
|
|
"inputs": "pair_hl",
|
|
|
|
|
|
"fn": None,
|
|
|
|
|
|
"fn_name": "BETA",
|
|
|
|
|
|
"kwargs": {"timeperiod": 5},
|
|
|
|
|
|
},
|
2026-03-23 23:34:28 +05:30
|
|
|
|
{
|
|
|
|
|
|
"name": "CCI_14",
|
|
|
|
|
|
"inputs": "hlc",
|
|
|
|
|
|
"fn": None,
|
|
|
|
|
|
"fn_name": "CCI",
|
|
|
|
|
|
"kwargs": {"timeperiod": 14},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "WILLR_14",
|
|
|
|
|
|
"inputs": "hlc",
|
|
|
|
|
|
"fn": None,
|
|
|
|
|
|
"fn_name": "WILLR",
|
|
|
|
|
|
"kwargs": {"timeperiod": 14},
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_fn(fn_name: str) -> Callable[..., Any]:
|
|
|
|
|
|
import ferro_ta as ft
|
|
|
|
|
|
|
|
|
|
|
|
return getattr(ft, fn_name)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-24 02:02:10 +05:30
|
|
|
|
def _run_indicator(entry: dict[str, Any], data: dict[str, np.ndarray]) -> np.ndarray:
|
2026-03-23 23:34:28 +05:30
|
|
|
|
fn = _load_fn(entry["fn_name"])
|
|
|
|
|
|
if entry["inputs"] == "close":
|
|
|
|
|
|
result = fn(data["close"], **entry["kwargs"])
|
2026-03-24 02:02:10 +05:30
|
|
|
|
elif entry["inputs"] == "hlc":
|
2026-03-23 23:34:28 +05:30
|
|
|
|
result = fn(data["high"], data["low"], data["close"], **entry["kwargs"])
|
2026-03-24 02:02:10 +05:30
|
|
|
|
else: # pair_hl
|
|
|
|
|
|
result = fn(data["high"], data["low"], **entry["kwargs"])
|
2026-03-23 23:34:28 +05:30
|
|
|
|
if isinstance(result, tuple):
|
|
|
|
|
|
result = result[0]
|
|
|
|
|
|
return np.asarray(result, dtype=np.float64)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Numerical regression tests
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestNumericalRegression:
|
|
|
|
|
|
"""Verify indicator outputs match stored baselines (or tolerance)."""
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
"entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE]
|
|
|
|
|
|
)
|
|
|
|
|
|
def test_output_shape(
|
2026-03-24 02:02:10 +05:30
|
|
|
|
self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray]
|
2026-03-23 23:34:28 +05:30
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Indicator output length must equal input length."""
|
|
|
|
|
|
out = _run_indicator(entry, ohlcv)
|
|
|
|
|
|
assert len(out) == len(ohlcv["close"]), (
|
|
|
|
|
|
f"{entry['name']}: expected len {len(ohlcv['close'])}, got {len(out)}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
"entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE]
|
|
|
|
|
|
)
|
|
|
|
|
|
def test_warmup_is_nan(
|
2026-03-24 02:02:10 +05:30
|
|
|
|
self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray]
|
2026-03-23 23:34:28 +05:30
|
|
|
|
) -> None:
|
|
|
|
|
|
"""First bar must be NaN (warm-up)."""
|
|
|
|
|
|
out = _run_indicator(entry, ohlcv)
|
|
|
|
|
|
assert np.isnan(out[0]), f"{entry['name']}: expected NaN at bar 0, got {out[0]}"
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
"entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE]
|
|
|
|
|
|
)
|
2026-03-24 02:02:10 +05:30
|
|
|
|
def test_no_inf(self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray]) -> None:
|
2026-03-23 23:34:28 +05:30
|
|
|
|
"""Output must not contain infinities."""
|
|
|
|
|
|
out = _run_indicator(entry, ohlcv)
|
|
|
|
|
|
assert not np.any(np.isinf(out)), f"{entry['name']}: output contains Inf"
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
"entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE]
|
|
|
|
|
|
)
|
|
|
|
|
|
def test_last_values_stable(
|
2026-03-24 02:02:10 +05:30
|
|
|
|
self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray]
|
2026-03-23 23:34:28 +05:30
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Last 10 non-NaN values must be finite and stable (no sudden jumps)."""
|
|
|
|
|
|
out = _run_indicator(entry, ohlcv)
|
|
|
|
|
|
valid = out[~np.isnan(out)]
|
|
|
|
|
|
assert len(valid) >= 10, f"{entry['name']}: fewer than 10 valid output values"
|
|
|
|
|
|
last10 = valid[-10:]
|
|
|
|
|
|
assert np.all(np.isfinite(last10)), (
|
|
|
|
|
|
f"{entry['name']}: non-finite in last 10 values"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.skipif(not BASELINE_PATH.exists(), reason="No baselines.npz found")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
"entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE]
|
|
|
|
|
|
)
|
|
|
|
|
|
def test_regression_vs_baseline(
|
2026-03-24 02:02:10 +05:30
|
|
|
|
self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray]
|
2026-03-23 23:34:28 +05:30
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Compare last 10 values to stored baselines."""
|
|
|
|
|
|
baselines = np.load(BASELINE_PATH)
|
|
|
|
|
|
key = entry["name"]
|
|
|
|
|
|
if key not in baselines:
|
|
|
|
|
|
pytest.skip(f"No baseline stored for {key}")
|
|
|
|
|
|
out = _run_indicator(entry, ohlcv)
|
|
|
|
|
|
valid = out[~np.isnan(out)]
|
|
|
|
|
|
last10 = valid[-10:]
|
|
|
|
|
|
stored = baselines[key]
|
|
|
|
|
|
np.testing.assert_allclose(
|
|
|
|
|
|
last10,
|
|
|
|
|
|
stored,
|
|
|
|
|
|
rtol=1e-5,
|
|
|
|
|
|
atol=1e-8,
|
|
|
|
|
|
err_msg=f"Numerical regression for {key}",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Performance benchmarks
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestPerformance:
|
|
|
|
|
|
"""Timing benchmarks — record wall time and compare to baselines if present."""
|
|
|
|
|
|
|
|
|
|
|
|
PERF_THRESHOLD_FACTOR = 2.0 # fail if run is > 2× slower than baseline
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
"entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE]
|
|
|
|
|
|
)
|
|
|
|
|
|
def test_timing(
|
|
|
|
|
|
self,
|
2026-03-24 02:02:10 +05:30
|
|
|
|
entry: dict[str, Any],
|
|
|
|
|
|
ohlcv: dict[str, np.ndarray],
|
2026-03-23 23:34:28 +05:30
|
|
|
|
request: pytest.FixtureRequest,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Time the indicator on the canonical dataset."""
|
|
|
|
|
|
# Warm-up run
|
|
|
|
|
|
_run_indicator(entry, ohlcv)
|
|
|
|
|
|
|
|
|
|
|
|
# Timed run
|
|
|
|
|
|
t0 = time.perf_counter()
|
|
|
|
|
|
for _ in range(5):
|
|
|
|
|
|
_run_indicator(entry, ohlcv)
|
|
|
|
|
|
elapsed = (time.perf_counter() - t0) / 5.0 # average over 5 runs
|
|
|
|
|
|
|
|
|
|
|
|
# Store timing in request node for reporting
|
|
|
|
|
|
request.node._ferro_ta_timing = elapsed # type: ignore[attr-defined]
|
|
|
|
|
|
|
|
|
|
|
|
# Compare to baseline if available
|
|
|
|
|
|
if BASELINE_PATH.exists():
|
|
|
|
|
|
baselines = np.load(BASELINE_PATH, allow_pickle=True)
|
|
|
|
|
|
key = f"timing_{entry['name']}"
|
|
|
|
|
|
if key in baselines:
|
|
|
|
|
|
baseline_time = float(baselines[key])
|
|
|
|
|
|
if elapsed > baseline_time * self.PERF_THRESHOLD_FACTOR:
|
|
|
|
|
|
pytest.fail(
|
|
|
|
|
|
f"{entry['name']}: timing regression — "
|
|
|
|
|
|
f"current {elapsed * 1000:.2f}ms vs "
|
|
|
|
|
|
f"baseline {baseline_time * 1000:.2f}ms "
|
|
|
|
|
|
f"(>{self.PERF_THRESHOLD_FACTOR}×)"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Baseline update helper
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-24 02:02:10 +05:30
|
|
|
|
def update_baselines(ohlcv_data: dict[str, np.ndarray]) -> None:
|
2026-03-23 23:34:28 +05:30
|
|
|
|
"""Write current indicator outputs and timings to baselines.npz.
|
|
|
|
|
|
|
|
|
|
|
|
Call this after intentional changes to update the stored baselines::
|
|
|
|
|
|
|
|
|
|
|
|
python -c "
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
|
from benchmarks.test_benchmark_suite import update_baselines, FIXTURE_PATH
|
|
|
|
|
|
data = {k: v for k, v in np.load(FIXTURE_PATH).items()}
|
|
|
|
|
|
update_baselines(data)
|
|
|
|
|
|
"
|
|
|
|
|
|
"""
|
2026-03-24 02:02:10 +05:30
|
|
|
|
store: dict[str, np.ndarray] = {}
|
2026-03-23 23:34:28 +05:30
|
|
|
|
for entry in INDICATOR_SUITE:
|
|
|
|
|
|
out = _run_indicator(entry, ohlcv_data)
|
|
|
|
|
|
valid = out[~np.isnan(out)]
|
|
|
|
|
|
store[entry["name"]] = valid[-10:]
|
|
|
|
|
|
|
|
|
|
|
|
# Timing
|
|
|
|
|
|
t0 = time.perf_counter()
|
|
|
|
|
|
for _ in range(5):
|
|
|
|
|
|
_run_indicator(entry, ohlcv_data)
|
|
|
|
|
|
store[f"timing_{entry['name']}"] = np.array([(time.perf_counter() - t0) / 5.0])
|
|
|
|
|
|
|
|
|
|
|
|
np.savez_compressed(BASELINE_PATH, **store)
|
|
|
|
|
|
print(f"Baselines written to {BASELINE_PATH}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
if not FIXTURE_PATH.exists():
|
|
|
|
|
|
print(f"Fixture not found: {FIXTURE_PATH}")
|
|
|
|
|
|
print("Run: python benchmarks/fixtures/generate_canonical.py")
|
|
|
|
|
|
else:
|
|
|
|
|
|
data = {k: v for k, v in np.load(FIXTURE_PATH).items()}
|
|
|
|
|
|
update_baselines(data)
|