Repository Public
1"""Backtest one perpetual-futures strategy version and write the version-2 JSON report.23 uv run python -m spq.perp_backtest --strategy strategies/ts_momentum_perp/1 --data-dir data \4 --rebalance-threshold 0.05 --out report.json56Data come from --data-dir: Hyperliquid candles and funding written by `python -m spq.data.hyperliquid`, and (for7the spot benchmark and for spot legs) spot candles written by `python -m spq.data.fetch`. Venue parameters8(maximum leverage, lot) are read from <data-dir>/hyperliquid/meta.json when it exists. The report is validated9against schema/backtest-report-v2.schema.json before it is written. Exit code 0 means a report was written; it10says nothing about whether the strategy is any good.11"""12from __future__ import annotations1314import argparse15import json16import sys1718from .data import DataError19from .data.hyperliquid import SOURCE, load_meta, load_perp_universe, load_spot_reference20from .perp_engine import HL_MAKER_BPS, HL_MIN_ORDER, HL_TAKER_BPS, PerpConfig21from .perp_report import build_perp_report, write_perp_report22from .research import cartesian_grid23from .strategy import StrategyError, is_spot_leg, load_strategy242526def add_cost_arguments(p: argparse.ArgumentParser) -> None:27 p.add_argument("--taker-fee-bps", type=float, default=HL_TAKER_BPS, help="Hyperliquid base tier: 4.5")28 p.add_argument("--maker-fee-bps", type=float, default=HL_MAKER_BPS, help="Hyperliquid base tier: 1.5")29 p.add_argument("--liquidity", choices=("taker", "maker"), default="taker",30 help="fee paid by every fill (default taker: fills at the open are market orders)")31 p.add_argument("--slippage-bps", type=float, default=2.0)32 p.add_argument("--liquidation-penalty-bps", type=float, default=100.0,33 help="charged on the liquidated notional, on top of the taker fee")34 p.add_argument("--maintenance-fraction", type=float, default=None,35 help="flat maintenance margin fraction; default 1 / (2 x venue max leverage) per symbol")36 p.add_argument("--leverage", type=float, default=None, help="account leverage; default META['max_leverage']")37 p.add_argument("--spot-fee-bps", type=float, default=10.0)38 p.add_argument("--spot-slippage-bps", type=float, default=10.0)39 p.add_argument("--initial-cash", type=float, default=1000.0)40 p.add_argument("--min-order", type=float, default=HL_MIN_ORDER, help="USD; Hyperliquid minimum: 10")41 p.add_argument("--rebalance-threshold", type=float, default=0.05)424344def make_config(a, interval: str, venue_meta: dict, symbols, decision_every: int = 1) -> PerpConfig:45 uni = venue_meta.get("universe", {})46 perps = [s for s in symbols if not is_spot_leg(s)]47 return PerpConfig(48 interval=interval, taker_fee_bps=a.taker_fee_bps, maker_fee_bps=a.maker_fee_bps, liquidity=a.liquidity,49 slippage_bps=a.slippage_bps, initial_cash=a.initial_cash, min_order=a.min_order,50 rebalance_threshold=a.rebalance_threshold, decision_every=decision_every, leverage=a.leverage,51 venue_max_leverage={s: uni[s]["max_leverage"] for s in perps if s in uni},52 maintenance_fraction=a.maintenance_fraction, liquidation_penalty_bps=a.liquidation_penalty_bps,53 size_decimals={s: uni[s]["size_decimals"] for s in perps if s in uni},54 spot_fee_bps=a.spot_fee_bps, spot_slippage_bps=a.spot_slippage_bps)555657def parser() -> argparse.ArgumentParser:58 p = argparse.ArgumentParser(prog="python -m spq.perp_backtest", description=__doc__.split("\n")[0])59 p.add_argument("--strategy", required=True, help="strategy version directory (contains strategy.py)")60 p.add_argument("--sha256", default=None, help="refuse to run if strategy.py has another hash")61 p.add_argument("--symbols", default=None, help="comma-separated; default: META['universe']")62 p.add_argument("--interval", default=None, help="default: META['interval']")63 p.add_argument("--start", default=None, help="UTC, inclusive")64 p.add_argument("--end", default=None, help="UTC, exclusive")65 add_cost_arguments(p)66 p.add_argument("--decision-every", type=int, default=1, help="ask the strategy every N bars")67 p.add_argument("--split", default="0.6,0.2,0.2", help="train,validation,test fractions (chronological)")68 p.add_argument("--grid", default=None, help="JSON object parameter -> list of values for the sensitivity grid")69 p.add_argument("--folds", type=int, default=4)70 p.add_argument("--resamples", type=int, default=2000)71 p.add_argument("--seed", type=int, default=0)72 p.add_argument("--ledger", default=None, help="JSONL file recording every trial and test evaluation")73 p.add_argument("--job-ledger", default=None,74 help="v7: the trial ledger of the research job that produced this strategy (the runner's copy); "75 "its trials are added to the trial count of the deflated Sharpe ratio")76 p.add_argument("--data-dir", required=True, help="root of the local store (hyperliquid/ and the spot source)")77 p.add_argument("--spot-source", default="coinbase", help="spot candles for the benchmark and for spot legs")78 p.add_argument("--out", required=True, help="report path (JSON)")79 return p808182def main(argv=None) -> int:83 a = parser().parse_args(argv)84 try:85 strategy = load_strategy(a.strategy, a.sha256)86 if strategy.kind != "perp":87 raise StrategyError(f"{strategy.name} is a {strategy.kind} strategy: use python -m spq.backtest")88 symbols = [s.strip() for s in a.symbols.split(",") if s.strip()] if a.symbols else strategy.universe89 interval = a.interval or strategy.meta["interval"]90 config = make_config(a, interval, load_meta(a.data_dir), symbols, a.decision_every)91 fractions = tuple(float(x) for x in a.split.split(","))92 grid = cartesian_grid(strategy.params, json.loads(a.grid)) if a.grid else None93 data, quality, fq, raw = load_perp_universe(symbols, interval, a.start, a.end, root=a.data_dir,94 spot_source=a.spot_source)95 index = next(iter(data.values())).index96 perps = [s for s in symbols if not is_spot_leg(s)]97 spot, spot_q = load_spot_reference(perps, interval, index, root=a.data_dir, source=a.spot_source)98 notes = [f"spot benchmark from {a.spot_source}: {s} {q.n_bars} bars, {q.missing_bars} missing"99 for s, q in spot_q.items()]100 report, _ = build_perp_report(strategy, data, config, quality=quality, funding_quality=fq, funding_raw=raw,101 spot_data=spot, fractions=fractions, grid=grid, ledger=a.ledger, job_ledger=a.job_ledger,102 n_folds=a.folds, n_resamples=a.resamples, seed=a.seed, source=SOURCE,103 notes=notes)104 write_perp_report(report, a.out)105 except (StrategyError, DataError, ValueError) as e:106 print(f"spq.perp_backtest: {type(e).__name__}: {e}", file=sys.stderr)107 return 2108 t = report["metrics"]["test"]109 print(json.dumps({110 "report": a.out, "strategy_sha256": report["strategy_sha256"],111 "test": {k: t[k] for k in ("total_return", "sharpe", "max_drawdown", "worst_intrabar_drawdown", "trades",112 "fees_paid", "funding_pnl", "gross_leverage_mean", "liquidations", "wiped_out")},113 "benchmarks_test": {n: {k: b[k] for k in ("total_return", "sharpe", "max_drawdown")}114 for n, b in report["benchmarks"].items() if n in ("buy_and_hold", "perp_long_1x")},115 "sharpe_ci": report["robustness"]["bootstrap_sharpe_ci"],116 "trials_tested": report["robustness"]["trials_tested"],117 "deflated_sharpe": report["robustness"]["deflated_sharpe"],118 "checks": {k: report["checks"][k] for k in ("lookahead", "data_quality", "ledger", "funding_alignment")},119 }))120 return 0121122123if __name__ == "__main__":124 sys.exit(main())125