SPB Git forge

spb/hfmarketdata

Public

Open high-frequency market data platform — FirstRate full-history downloader, DuckDB/Parquet lake, open REST API and React docs platform (www.hfmarketdata.io)

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%
8.9 KB · 183 lines python
Raw Blame History
1#!/usr/bin/env python32"""Walk-forward moving-average crossover backtest on HF Market Data bars.34  ma_crossover.py --asset etf --ticker SPY --start 2010-01-01 [--grid 10,20,50 --grid-slow 100,150,200]5                  [--train-years 3 --test-years 1] [--cost-bps 5] [--allow-short] [--fixed 50,200] [--plot out.png]67The reported metrics are OUT-OF-SAMPLE: for each test block the (fast, slow) pair is chosen on the8preceding training window only. Positions apply to the next bar (no look-ahead).9"""10from __future__ import annotations1112import argparse13import itertools14import sys15from pathlib import Path1617import numpy as np18import pandas as pd1920sys.path.insert(0, str(Path(__file__).parent))21import hfmd  # noqa: E402222324def signal_ma_cross(close: pd.Series, fast: int, slow: int, allow_short: bool) -> pd.Series:25    """Position in {0,1} (or {-1,0,1}) from past data only. Replace to test another idea."""26    f, s = close.rolling(fast).mean(), close.rolling(slow).mean()27    pos = (f > s).astype(float)28    if allow_short:29        pos = pos - (f < s).astype(float)30    return pos313233def run(close: pd.Series, pos: pd.Series, cost_bps: float) -> pd.DataFrame:34    ret = close.pct_change().fillna(0.0)35    p = pos.shift(1).fillna(0.0)  # decide at t, hold from t+136    trades = p.diff().abs().fillna(0.0)37    strat = p * ret - trades * cost_bps / 1e438    return pd.DataFrame({"ret": ret, "pos": p, "strat": strat, "trades": trades})394041def periods_per_year(dt: pd.Series) -> int:42    step = dt.diff().dropna().median()43    if step >= pd.Timedelta(days=1):44        return 252 if dt.dt.dayofweek.max() <= 4 else 36545    per_day = int(pd.Timedelta(hours=6.5) / step) if step < pd.Timedelta(hours=1) else int(pd.Timedelta(hours=24) / step)46    return max(per_day, 1) * 252474849def metrics(r: pd.Series, ppy: int) -> dict:50    if len(r) < 2:51        return {"cagr": np.nan, "vol": np.nan, "sharpe": np.nan, "max_dd": np.nan}52    eq = (1 + r).cumprod()53    years = len(r) / ppy54    return {55        "cagr": eq.iloc[-1] ** (1 / years) - 1 if years > 0 else np.nan,56        "vol": r.std() * np.sqrt(ppy),57        "sharpe": r.mean() / r.std() * np.sqrt(ppy) if r.std() > 0 else np.nan,58        "max_dd": (eq / eq.cummax() - 1).min(),59    }606162def main() -> int:63    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)64    ap.add_argument("--asset", required=True, choices=["stock", "etf", "crypto", "index", "fx", "futures"])65    ap.add_argument("--ticker", required=True)66    ap.add_argument("--timeframe", default="1day")67    ap.add_argument("--start", default="2010-01-01")68    ap.add_argument("--end")69    ap.add_argument("--adjustment", help="futures: prefer contin_adj_ratio")70    ap.add_argument("--grid", default="10,20,50", help="fast MA candidates")71    ap.add_argument("--grid-slow", default="100,150,200", help="slow MA candidates")72    ap.add_argument("--fixed", help="fast,slow — skip optimisation, evaluate one pair on the whole history")73    ap.add_argument("--train-years", type=float, default=3.0)74    ap.add_argument("--test-years", type=float, default=1.0)75    ap.add_argument("--cost-bps", type=float, default=5.0, help="cost per side per unit of position change")76    ap.add_argument("--allow-short", action="store_true")77    ap.add_argument("--show-insample", action="store_true", help="also print the best pair on the full history (for contrast)")78    ap.add_argument("--plot")79    ap.add_argument("--out-csv", help="write the out-of-sample equity curve")80    a = ap.parse_args()8182    adj = a.adjustment or ("contin_adj_ratio" if a.asset == "futures" else None)83    df = hfmd.bars(a.asset, a.ticker.upper(), a.timeframe, a.start, a.end, adj)84    if len(df) < 300:85        print(f"only {len(df)} bars — too short for a walk-forward", file=sys.stderr)86        return 187    df = df.set_index("datetime")88    close = df["close"].astype(float)89    ppy = periods_per_year(pd.Series(df.index))90    print(f"{a.ticker.upper()} {a.timeframe} {adj or ''}: {len(close):,} bars {close.index[0].date()} → {close.index[-1].date()} · periods/year={ppy} · cost={a.cost_bps} bps/side")9192    fasts = [int(x) for x in a.grid.split(",")]93    slows = [int(x) for x in a.grid_slow.split(",")]94    pairs = [(f, s) for f, s in itertools.product(fasts, slows) if f < s]9596    if a.fixed:97        f, s = (int(x) for x in a.fixed.split(","))98        res = run(close, signal_ma_cross(close, f, s, a.allow_short), a.cost_bps).iloc[s:]99        blocks = pd.DataFrame([{"test_start": res.index[0].date(), "test_end": res.index[-1].date(), "fast": f, "slow": s, **{f"oos_{k}": v for k, v in metrics(res["strat"], ppy).items()}}])100    else:101        train_n, test_n = int(a.train_years * ppy), int(a.test_years * ppy)102        if train_n < max(slows) + 20:103            print(f"train window ({train_n} bars) too short for slow MA {max(slows)}", file=sys.stderr)104            return 1105        pieces, rows = [], []106        start = train_n107        while start + 20 < len(close):108            tr = close.iloc[start - train_n:start]109            best, best_sh = None, -np.inf110            for f, s in pairs:111                sh = metrics(run(tr, signal_ma_cross(tr, f, s, a.allow_short), a.cost_bps)["strat"].iloc[s:], ppy)["sharpe"]112                if np.isfinite(sh) and sh > best_sh:113                    best, best_sh = (f, s), sh114            f, s = best or pairs[0]115            # compute the signal on history + test block so the MAs are warm at the block start116            seg = close.iloc[max(0, start - s - 5):start + test_n]117            res = run(seg, signal_ma_cross(seg, f, s, a.allow_short), a.cost_bps).loc[close.index[start]:]118            pieces.append(res)119            m = metrics(res["strat"], ppy)120            rows.append({"test_start": res.index[0].date(), "test_end": res.index[-1].date(), "fast": f, "slow": s, "train_sharpe": round(best_sh, 2), **{f"oos_{k}": v for k, v in m.items()}})121            start += test_n122        if not pieces:123            print("not enough data for one test block", file=sys.stderr)124            return 1125        res = pd.concat(pieces)126        blocks = pd.DataFrame(rows)127128    strat, bench = metrics(res["strat"], ppy), metrics(res["ret"], ppy)129    n_trades = int((res["trades"] > 0).sum())130    print(f"\nOUT-OF-SAMPLE {res.index[0].date()} → {res.index[-1].date()} ({len(res):,} bars, {len(blocks)} block(s))")131    print(f"{'':14}{'strategy':>12}{'buy&hold':>12}")132    for k in ("cagr", "vol", "sharpe", "max_dd"):133        print(f"{k:<14}{strat[k]:>12.3f}{bench[k]:>12.3f}")134    print(f"{'exposure':<14}{res['pos'].abs().mean():>12.2f}")135    print(f"{'trades':<14}{n_trades:>12d}   turnover/yr={res['trades'].sum() / (len(res) / ppy):.1f}")136    print("\nper block (parameters chosen on the preceding training window):")137    with pd.option_context("display.width", 160, "display.float_format", "{:.3f}".format):138        print(blocks.to_string(index=False))139140    if a.show_insample and not a.fixed:141        best = max(pairs, key=lambda p: metrics(run(close, signal_ma_cross(close, *p, a.allow_short), a.cost_bps)["strat"].iloc[p[1]:], ppy)["sharpe"] or -np.inf)142        m = metrics(run(close, signal_ma_cross(close, *best, a.allow_short), a.cost_bps)["strat"].iloc[best[1]:], ppy)143        print(f"\nIN-SAMPLE best pair on full history (optimistic, for contrast): {best} sharpe={m['sharpe']:.2f} cagr={m['cagr']:.3f} max_dd={m['max_dd']:.3f}")144145    if a.out_csv:146        out = res.copy()147        out["equity"] = (1 + out["strat"]).cumprod()148        out["benchmark"] = (1 + out["ret"]).cumprod()149        out.to_csv(a.out_csv)150        print(f"wrote {a.out_csv}")151152    if a.plot:153        try:154            import matplotlib155            matplotlib.use("Agg")156            import matplotlib.pyplot as plt157        except ImportError:158            print("matplotlib not installed", file=sys.stderr)159            return 0160        eq, bm = (1 + res["strat"]).cumprod(), (1 + res["ret"]).cumprod()161        fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(11, 6.5), sharex=True, gridspec_kw={"height_ratios": [3, 1]})162        ax1.plot(eq.index, eq, lw=1.3, label=f"MA cross walk-forward (Sharpe {strat['sharpe']:.2f})")163        ax1.plot(bm.index, bm, lw=1.0, alpha=0.7, label=f"buy & hold (Sharpe {bench['sharpe']:.2f})")164        if not a.fixed:165            for _, b in blocks.iterrows():166                ax1.axvline(pd.Timestamp(b["test_start"]), color="grey", alpha=0.25, lw=0.8)167                ax1.text(pd.Timestamp(b["test_start"]), ax1.get_ylim()[0], f"{b['fast']}/{b['slow']}", fontsize=7, rotation=90, va="bottom", alpha=0.7)168        ax1.set_yscale("log")169        ax1.grid(alpha=0.25)170        ax1.legend(loc="upper left")171        ax1.set_title(f"{a.ticker.upper()} — out-of-sample equity (log), cost {a.cost_bps} bps/side")172        ax2.fill_between(eq.index, (eq / eq.cummax() - 1) * 100, 0, alpha=0.4)173        ax2.set_ylabel("drawdown %")174        ax2.grid(alpha=0.25)175        fig.tight_layout()176        fig.savefig(a.plot, dpi=130)177        print(f"wrote {a.plot}")178    return 0179180181if __name__ == "__main__":182    sys.exit(main())183