SPB Git

Repository Public

perp_backtest.py 7.8 KB 125 lines python

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
Files main
Menu

Theme