diff --git a/src/fxhnt/cli.py b/src/fxhnt/cli.py index f72df0f..0f1a8ff 100644 --- a/src/fxhnt/cli.py +++ b/src/fxhnt/cli.py @@ -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).""" diff --git a/tests/integration/test_backtest_equity_cli.py b/tests/integration/test_backtest_equity_cli.py new file mode 100644 index 0000000..9750153 --- /dev/null +++ b/tests/integration/test_backtest_equity_cli.py @@ -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"])