feat(b3b): gauntlet verdict + JSON report per construction (DSR/OOS, n_trials=3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-06-17 12:42:50 +02:00
parent 8447245603
commit 714a4963a7
2 changed files with 66 additions and 1 deletions

View File

@@ -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

View File

@@ -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