Files
ferro-ta/benchmarks/test_benchmark_suite.py
T

371 lines
11 KiB
Python
Raw Normal View History

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)