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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user