Files
fxhnt/tests/integration/test_crypto_factor_runner.py
2026-06-19 19:38:19 +02:00

123 lines
4.8 KiB
Python

"""Integration tests for the cross-sectional crypto PRICE-factor walk-forward runner +
gauntlet matrix. Synthetic panels with a built-in edge; no I/O, no live network."""
import json
from functools import partial
import numpy as np
from fxhnt.application.crypto_factor_runner import (
CryptoFactorRunner,
FactorRunResult,
evaluate_factor_matrix,
)
from fxhnt.domain.crypto_factors import (
xs_illiquidity_score,
xs_lowvol_score,
xs_reversal_score,
)
Panel = dict[str, dict[int, tuple[float, float, float]]]
def _lowvol_edge_panel(n_days: int = 120) -> Panel:
"""Low-vol coins drift UP smoothly; high-vol coins oscillate around flat with no drift.
The low-vol factor's long_tilt should therefore make money."""
panel: Panel = {}
# calm uptrenders: tiny noise, positive drift
for sym, drift in {"CALM1": 0.004, "CALM2": 0.0035, "CALM3": 0.003}.items():
closes = []
p = 100.0
for i in range(n_days):
p *= (1.0 + drift + 0.0003 * ((-1) ** i))
closes.append(p)
panel[sym] = {1000 + i: (closes[i], 5e6, 0.0) for i in range(n_days)}
# wild flat coins: big oscillation, no net drift
for sym, amp in {"WILD1": 8.0, "WILD2": 10.0, "WILD3": 12.0}.items():
panel[sym] = {1000 + i: (100.0 + amp * ((-1) ** i), 5e6, 0.0) for i in range(n_days)}
return panel
def _generic_panel(n_days: int = 120) -> Panel:
panel: Panel = {}
rng = np.random.default_rng(7)
for k, sym in enumerate(["A", "B", "C", "D", "E", "F"]):
p = 100.0
closes = []
for _ in range(n_days):
p *= (1.0 + float(rng.normal(0.0005 * k, 0.01)))
closes.append(max(1.0, p))
qv = 1e5 * (k + 1) ** 2
panel[sym] = {1000 + i: (closes[i], qv, 0.0) for i in range(n_days)}
return panel
def test_lowvol_factor_long_tilt_has_positive_edge():
runner = CryptoFactorRunner(
_lowvol_edge_panel(),
partial(xs_lowvol_score, window=30),
min_qvol=1e6, min_history=20, quantile=0.5, cost_bps=0.0, slip_coef=0.0,
)
res = runner.run()
assert isinstance(res, FactorRunResult)
assert set(res.returns_by_mode) == {"long_tilt", "market_neutral"}
for series in res.returns_by_mode.values():
assert len(series) > 30 and np.isfinite(series).all()
# the built-in low-vol edge => the long_tilt series is net positive
assert float(np.sum(res.returns_by_mode["long_tilt"])) > 0.0
assert res.n_rebalances == len(res.returns_by_mode["long_tilt"])
assert res.n_names_avg > 0.0
def test_runner_books_price_returns_not_funding():
# funding is nonzero but the runner must book PRICE returns; a flat-price panel with
# nonzero funding must produce ~zero return (proving it is NOT reading funding).
panel: Panel = {}
for sym in ["A", "B", "C", "D"]:
panel[sym] = {1000 + i: (100.0, 5e6, 0.05) for i in range(60)} # huge funding, flat price
res = CryptoFactorRunner(
panel, partial(xs_lowvol_score, window=30),
min_qvol=1e6, min_history=20, quantile=0.5, cost_bps=0.0, slip_coef=0.0,
).run()
assert abs(float(np.sum(res.returns_by_mode["long_tilt"]))) < 1e-9
def test_runner_deterministic():
mk = lambda: CryptoFactorRunner(
_lowvol_edge_panel(), partial(xs_lowvol_score, window=30),
min_qvol=1e6, min_history=20, quantile=0.5, cost_bps=8.0, slip_coef=0.0005,
).run().returns_by_mode["long_tilt"]
assert np.array_equal(mk(), mk())
def test_evaluate_factor_matrix_runs_all_cells():
factors = {
"reversal": partial(xs_reversal_score, lookback=3),
"lowvol": partial(xs_lowvol_score, window=30),
"illiquidity": partial(xs_illiquidity_score, window=30),
}
report = evaluate_factor_matrix(
_generic_panel(), factors, floors=[1e4, 1e6], quantile=0.5,
cost_bps=8.0, slip_coef=0.0, oos_fraction=0.4,
)
# 3 factors x 2 floors x 2 modes = 12 cells
assert len(report["cells"]) == 12
assert report["n_trials"] == 12
for cell in report["cells"].values():
assert {"factor", "mode", "floor", "stats", "verdict"} <= set(cell)
assert {"sharpe", "cagr", "max_drawdown", "n_obs"} <= set(cell["stats"])
assert {"passed", "dsr", "is_sharpe", "oos_sharpe", "n_trials"} <= set(cell["verdict"])
assert cell["verdict"]["n_trials"] == 12
json.dumps(report) # must be JSON-serializable (no NaN/np types)
def test_evaluate_factor_matrix_n_trials_equals_total_cells():
factors = {"lowvol": partial(xs_lowvol_score, window=30)}
report = evaluate_factor_matrix(
_generic_panel(), factors, floors=[1e4], quantile=0.5,
cost_bps=8.0, slip_coef=0.0, oos_fraction=0.4,
)
# 1 factor x 1 floor x 2 modes = 2 cells
assert report["n_trials"] == 2
assert len(report["cells"]) == 2
json.dumps(report)