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:
@@ -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)."""
|
||||
|
||||
59
tests/integration/test_backtest_equity_cli.py
Normal file
59
tests/integration/test_backtest_equity_cli.py
Normal 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"])
|
||||
Reference in New Issue
Block a user