diff --git a/src/fxhnt/application/equity_backtest_runner.py b/src/fxhnt/application/equity_backtest_runner.py index 00b4212..f2aa194 100644 --- a/src/fxhnt/application/equity_backtest_runner.py +++ b/src/fxhnt/application/equity_backtest_runner.py @@ -7,10 +7,13 @@ from __future__ import annotations import datetime as dt from dataclasses import dataclass +from typing import Any import numpy as np from fxhnt.adapters.warehouse.duckdb_feature_store import DuckDbFeatureStore +from fxhnt.domain.backtest import compute_stats +from fxhnt.domain.gauntlet.core import evaluate from fxhnt.domain.equity_backtest import ( _epoch_day, daily_returns_for_weights, @@ -128,6 +131,45 @@ class EquityBacktestRunner: ) +def evaluate_constructions( + result: BacktestRunResult, + *, + oos_fraction: float = 0.40, + dsr_min: float = 0.95, + oos_min_sharpe: float = 0.0, + max_is_oos_decay: float = 0.50, +) -> dict[str, Any]: + """Stack each construction's daily returns, split IS/OOS, run the gauntlet. + n_trials = the number of constructions tested (multiple-testing correction).""" + n_trials = len(result.returns_by_construction) + out: dict[str, Any] = { + "constructions": {}, + "rebalance_dates": result.rebalance_dates, + "n_names_avg": result.n_names_avg, + "settings": { + "oos_fraction": oos_fraction, "dsr_min": dsr_min, + "oos_min_sharpe": oos_min_sharpe, "max_is_oos_decay": max_is_oos_decay, + "n_trials": n_trials, + }, + } + for c, r in result.returns_by_construction.items(): + stats = compute_stats(r) + split = int((1.0 - oos_fraction) * len(r)) + is_r, oos_r = r[:split], r[split:] + sr_variance = (1.0 / len(r)) if len(r) > 1 else 0.0 + verdict = evaluate( + is_r, oos_r, n_trials=n_trials, sr_variance=sr_variance, + dsr_min=dsr_min, oos_min_sharpe=oos_min_sharpe, + max_is_oos_decay=max_is_oos_decay, + has_economic_rationale=True, + ) + out["constructions"][c] = { + "stats": stats.model_dump(), + "verdict": verdict.model_dump(), + } + return out + + def _turnover(prev: dict[str, float], cur: dict[str, float]) -> float: """TWO-WAY (round-trip) turnover: the full sum of absolute weight changes across all names. A complete A->B switch (sell 1.0 of A, buy 1.0 of B) scores diff --git a/tests/integration/test_equity_backtest_runner.py b/tests/integration/test_equity_backtest_runner.py index 4d96dd9..43a0c7b 100644 --- a/tests/integration/test_equity_backtest_runner.py +++ b/tests/integration/test_equity_backtest_runner.py @@ -3,7 +3,10 @@ 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 +from fxhnt.application.equity_backtest_runner import ( + EquityBacktestRunner, + evaluate_constructions, +) from fxhnt.domain.equity_backtest import month_end_rebalance_dates _SPD = 86_400 @@ -89,3 +92,23 @@ def test_momentum_min_history_gates_inclusion(tmp_path): 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