feat(b3b): fxhnt backtest-equity CLI — run walk-forward verdict + write report JSON

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

View File

@@ -508,6 +508,42 @@ def ingest_historical(
)
@app.command("backtest-equity")
def backtest_equity(
n: int = typer.Option(150, "--n", help="PIT universe size (top-N by trailing dollar-volume)."),
cost_bps: float = typer.Option(15.0, "--cost-bps", help="Cost in bps per unit (two-way) turnover."),
borrow_annual: float = typer.Option(0.0, "--borrow-annual", help="Annual borrow cost on short notional."),
out: str = typer.Option("/backtest-data/equity_backtest_report.json", "--out", help="Report JSON path."),
) -> None:
"""B3b: walk-forward backtest of the 3 price-only equity-factor constructions against the DEDICATED
survivorship-free backtest warehouse (settings.backtest_warehouse_path) -> gauntlet verdict + JSON report."""
import json
from fxhnt.adapters.warehouse.duckdb_feature_store import DuckDbFeatureStore
from fxhnt.application.equity_backtest_runner import EquityBacktestRunner, evaluate_constructions
s = get_settings()
store = DuckDbFeatureStore(s.backtest_warehouse_path)
try:
result = EquityBacktestRunner(
store, n=n, cost_bps_per_turnover=cost_bps, borrow_annual=borrow_annual,
).run()
report = evaluate_constructions(
result,
oos_fraction=s.gauntlet.oos_fraction, dsr_min=s.gauntlet.dsr_min,
oos_min_sharpe=s.gauntlet.oos_min_sharpe, max_is_oos_decay=s.gauntlet.max_is_oos_decay,
)
finally:
store.close()
with open(out, "w") as fh:
json.dump(report, fh, indent=2)
for c, block in report["constructions"].items():
v = block["verdict"]
typer.echo(f"backtest-equity {c}: passed={v['passed']} dsr={v['dsr']:.3f} "
f"is_sharpe={v['is_sharpe']:.2f} oos_sharpe={v['oos_sharpe']:.2f} "
f"sharpe={block['stats']['sharpe']:.2f} -> {out}")
@app.command("warehouse-catalog")
def warehouse_catalog() -> None:
"""Show the warehouse SSOT inventory: per-symbol bar count + date range (freshness)."""

View File

@@ -0,0 +1,59 @@
"""B3b: the `backtest-equity` CLI entrypoint runs the walk-forward equity backtest against the
DEDICATED backtest warehouse (settings.backtest_warehouse_path), evaluates the 3 price-only
constructions through the gauntlet, and writes a JSON report. No network: a synthetic warehouse
is built and the command is pointed at it via FXHNT_BACKTEST_WAREHOUSE_PATH."""
from __future__ import annotations
import datetime as dt
import json
from typer.testing import CliRunner
from fxhnt.adapters.warehouse.duckdb_feature_store import DuckDbFeatureStore
_SPD = 86_400
def _build_warehouse(path: str) -> DuckDbFeatureStore:
"""3 names over ~2 years of daily bars (mirrors the runner integration test): AAA strong uptrend,
BBB mild, CCC downtrend — 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_cli_backtest_equity_writes_report(tmp_path, monkeypatch) -> None:
import fxhnt.cli as cli
wh = str(tmp_path / "wh.duckdb")
_build_warehouse(wh).close()
out = tmp_path / "report.json"
monkeypatch.setenv("FXHNT_BACKTEST_WAREHOUSE_PATH", wh)
cli.get_settings.cache_clear() # settings are lru_cached — pick up the env override
res = CliRunner().invoke(cli.app, ["backtest-equity", "--n", "10", "--out", str(out)])
cli.get_settings.cache_clear()
assert res.exit_code == 0, res.output
assert out.exists(), "backtest-equity must write the report JSON at --out"
report = json.loads(out.read_text())
assert set(report["constructions"]) == {"long", "ls", "tilt"}
for block in report["constructions"].values():
assert {"stats", "verdict"} <= set(block)
assert {"passed", "dsr", "is_sharpe", "oos_sharpe"} <= set(block["verdict"])