Files
fxhnt/tests/integration/test_equity_backtest_runner.py
2026-06-17 12:42:50 +02:00

115 lines
4.7 KiB
Python

import datetime as dt
import numpy as np
from fxhnt.adapters.warehouse.duckdb_feature_store import DuckDbFeatureStore
from fxhnt.application.equity_backtest_runner import (
EquityBacktestRunner,
evaluate_constructions,
)
from fxhnt.domain.equity_backtest import month_end_rebalance_dates
_SPD = 86_400
def _ts(iso: str) -> int:
d = dt.date.fromisoformat(iso)
return (d - dt.date(1970, 1, 1)).days * _SPD
def _build_warehouse(path: str) -> DuckDbFeatureStore:
"""3 names over ~2 years of daily bars. AAA strong uptrend, BBB mild,
CCC downtrend — gives a non-degenerate cross-section for long/ls/tilt."""
store = DuckDbFeatureStore(path)
start = dt.date(2022, 1, 3)
n = 520 # ~2 trading years
drifts = {"AAA": 0.0015, "BBB": 0.0003, "CCC": -0.0010}
items = []
members = []
for sym, dr in drifts.items():
rows = []
px = 100.0
for i in range(n):
day = start + dt.timedelta(days=i)
px *= (1.0 + dr)
ts = (day - dt.date(1970, 1, 1)).days * _SPD
rows.append((ts, {"adjclose": px, "close": px, "volume": 1_000_000.0}))
items.append((sym, rows))
members.append((sym, "NYSE", "2000-01-01", "2026-06-01"))
store.write_features_bulk(items)
store.upsert_membership(members)
return store
def test_runner_produces_three_construction_series(tmp_path):
store = _build_warehouse(str(tmp_path / "wh.duckdb"))
runner = EquityBacktestRunner(store, n=10, cost_bps_per_turnover=15.0,
borrow_annual=0.0, momentum_min_history=252)
result = runner.run()
store.close()
assert set(result.returns_by_construction) == {"long", "ls", "tilt"}
for series in result.returns_by_construction.values():
assert len(series) > 50
assert np.isfinite(series).all()
assert float(np.prod(1.0 + result.returns_by_construction["long"]) - 1.0) > 0.0
# rebalance_dates lists only ACTUALLY-TRADED periods (len(kept) >= 2),
# never the 252d-warmup months where no book could be formed.
month_ends = month_end_rebalance_dates(
sorted({
(dt.date(2022, 1, 3) + dt.timedelta(days=i)).isoformat()
for i in range(520)
})
)
assert len(result.rebalance_dates) >= 1
assert all(d in month_ends for d in result.rebalance_dates)
# Warmup (252 trading-day history requirement) means fewer traded periods
# than the total monthly rebalance count.
assert len(result.rebalance_dates) < len(month_ends)
# All 3 synthetic names qualify once warmed up, so the average over TRADED
# periods is ~3.0 (not ~1.67, which would average in the empty warmup months).
assert abs(result.n_names_avg - 3.0) < 1e-9
def test_runner_is_deterministic(tmp_path):
store = _build_warehouse(str(tmp_path / "wh.duckdb"))
runner = EquityBacktestRunner(store, n=10, cost_bps_per_turnover=15.0,
borrow_annual=0.0, momentum_min_history=252)
a = runner.run().returns_by_construction["ls"]
b = runner.run().returns_by_construction["ls"]
store.close()
assert np.array_equal(a, b)
def test_momentum_min_history_gates_inclusion(tmp_path):
# With an absurdly high min-history requirement, NO name ever has enough
# history, so every construction series is empty and n_names_avg is 0.
store = _build_warehouse(str(tmp_path / "wh.duckdb"))
runner = EquityBacktestRunner(store, n=10, cost_bps_per_turnover=15.0,
borrow_annual=0.0, momentum_min_history=100_000)
result = runner.run()
store.close()
assert result.n_names_avg == 0.0
for series in result.returns_by_construction.values():
assert len(series) == 0
def test_evaluate_constructions_emits_stats_and_verdict_per_construction(tmp_path):
store = _build_warehouse(str(tmp_path / "wh.duckdb"))
runner = EquityBacktestRunner(store, n=10, cost_bps_per_turnover=15.0,
borrow_annual=0.0, momentum_min_history=252)
result = runner.run()
store.close()
report = evaluate_constructions(result, oos_fraction=0.40, dsr_min=0.95,
oos_min_sharpe=0.0, max_is_oos_decay=0.50)
assert set(report["constructions"]) == {"long", "ls", "tilt"}
for c, block in report["constructions"].items():
assert {"stats", "verdict"} <= set(block)
assert {"sharpe", "cagr", "max_drawdown", "n_obs"} <= set(block["stats"])
assert {"passed", "dsr", "is_sharpe", "oos_sharpe", "n_trials", "reasons"} <= set(block["verdict"])
assert block["verdict"]["n_trials"] == 3
assert report["n_names_avg"] >= 0.0
assert isinstance(report["rebalance_dates"], list)
import json
json.dumps(report) # must not raise