Files
ferro-ta/benchmarks/test_benchmark_suite.py
2026-03-24 02:02:10 +05:30

371 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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
from collections.abc import Callable
from typing import Any
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")
def ohlcv() -> dict[str, np.ndarray]:
"""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.
INDICATOR_SUITE: list[dict[str, Any]] = [
{
"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},
},
{
"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},
},
{
"name": "VAR_20",
"inputs": "close",
"fn": None,
"fn_name": "VAR",
"kwargs": {"timeperiod": 20},
},
{
"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},
},
{
"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)
def _run_indicator(entry: dict[str, Any], data: dict[str, np.ndarray]) -> np.ndarray:
fn = _load_fn(entry["fn_name"])
if entry["inputs"] == "close":
result = fn(data["close"], **entry["kwargs"])
elif entry["inputs"] == "hlc":
result = fn(data["high"], data["low"], data["close"], **entry["kwargs"])
else: # pair_hl
result = fn(data["high"], data["low"], **entry["kwargs"])
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(
self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray]
) -> 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(
self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray]
) -> 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]
)
def test_no_inf(self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray]) -> None:
"""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(
self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray]
) -> 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(
self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray]
) -> 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,
entry: dict[str, Any],
ohlcv: dict[str, np.ndarray],
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
# ---------------------------------------------------------------------------
def update_baselines(ohlcv_data: dict[str, np.ndarray]) -> None:
"""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)
"
"""
store: dict[str, np.ndarray] = {}
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)