SPB Git

Repository Public

test_perp_lookahead.py 10.7 KB 206 lines python

1"""Funding, premium, mark and index are inputs like prices: a strategy must never see a value from after its2decision bar, and the engine must never charge a settlement the strategy could have read."""3from __future__ import annotations45import numpy as np6import pandas as pd7import pytest89from perp_helpers import bars, cfg, perp_data, perp_meta, settlements10from spq.data.hyperliquid import attach_funding, check_funding_alignment11from spq.metrics import sharpe_ratio12from spq.perp_engine import PerpConfig, run_perp_backtest13from spq.perp_research import PerpExperiment14from spq.research import TestSplitError15from spq.robustness import lookahead_check16from spq.strategy import from_function1718FREE = PerpConfig("1h", taker_fee_bps=0, slippage_bps=0, min_order=0)192021def funding_sign(d, p):22    """Short when the newest funding rate it can see is positive, long when negative."""23    f = d["X"]["funding"].iloc[-1]24    return {"X": -1.0 if f > 0 else 1.0}252627def one(n=600, seed=1, **kw):28    data = perp_data(n, ("X",), seed=seed, rate=0.0, rate_sigma=5e-4, **kw)29    data["X"]["mark"] = data["X"]["close"] * 1.000130    data["X"]["index"] = data["X"]["close"] * 0.999931    return data323334def test_every_input_column_ends_at_the_decision_bar():35    data = one()36    seen = []3738    def spy(d, p):39        df = d["X"]40        seen.append((df.index[-1], len(df), tuple(df.columns), df["funding"].iloc[-1], df["mark"].iloc[-1],41                     df["index"].iloc[-1], df["premium"].iloc[-1]))42        return {"X": 0.5}4344    res = run_perp_backtest(from_function(spy, perp_meta(("X",), lookback=30)), data, FREE)45    full = data["X"]46    assert len(seen) == len(res.decisions) == len(full) - 2947    for (last, n, cols, f, mark, index, prem), decided in zip(seen, res.decisions.index):48        assert last == decided and n == 3049        assert {"funding", "premium", "mark", "index"} <= set(cols)50        row = full.loc[decided]                       # the newest values are those of the decision bar itself51        assert (f, mark, index, prem) == (row["funding"], row["mark"], row["index"], row["premium"])52    # the settlement charged at the fill bar is the NEXT row's rate: it was not in the inputs53    pos = {ts: i for i, ts in enumerate(full.index)}54    first = res.funding.iloc[0]55    assert pos[first["ts"]] == pos[res.trades.iloc[0]["ts"]] + 156    assert float(first["rate"]) == full["funding"].iloc[pos[first["ts"]]]575859def test_a_strategy_that_peeks_at_the_next_funding_rate_is_caught():60    """Honest inputs: the newest rate seen is the one settled at the decision bar's open. If the engine handed61    even one more row, the truncation check must fail. A position decided on bar i is filled at the open of bar62    i+1 AFTER that hour's settlement, so the first payment it is exposed to is the one of bar i+2: a rule that63    could read that row would be on the receiving side almost every hour."""64    data = one(900, seed=5)65    s = from_function(funding_sign, perp_meta(("X",), lookback=10))66    ok = lookahead_check(s, data, FREE, runner=run_perp_backtest)67    assert ok["status"] == "pass", ok6869    def leaky_runner(strategy, d, config, ahead=1, **kw):70        df = d["X"]71        position = {ts: i for i, ts in enumerate(df.index)}7273        def decide(view):74            end = position[view["X"].index[-1]]75            return strategy({"X": df.iloc[max(0, end - 8): end + 1 + ahead]})   # rows from the future when they exist7677        return run_perp_backtest(decide, d, config, start=kw.get("start"), end=kw.get("end"), lookback=10)7879    # a rule whose weight moves with the LEVEL of the newest rate: any extra row changes the decision at the cut80    level = from_function(lambda d, p: {"X": float(np.clip(-1000 * d["X"]["funding"].iloc[-1], -1, 1))},81                          perp_meta(("X",), lookback=10))82    assert lookahead_check(level, data, FREE, runner=run_perp_backtest)["status"] == "pass"83    bad = lookahead_check(level, data, FREE, runner=leaky_runner)               # one leaked row is enough to fail84    assert bad["status"] == "fail" and "decisions" in bad["detail"]8586    honest = run_perp_backtest(s, data, FREE)87    leaked = leaky_runner(s, data, FREE, ahead=2)88    # funding is autocorrelated in this fixture, so even the honest rule collects some; the peeker collects more,89    # because it is on the receiving side of (nearly) every settlement it is charged90    paid_honest, paid_leaked = float(honest.ledger["funding"]), float(leaked.ledger["funding"])91    received = lambda r: float(np.mean([float(a) > 0 for a in r.funding["amount"]]))  # noqa: E73192    assert received(leaked) > 0.97 and received(leaked) > received(honest) + 0.0593    assert paid_leaked > paid_honest > 0949596def test_peeking_at_independent_funding_earns_nothing_without_a_leak():97    """With i.i.d. funding the newest visible rate says nothing about the next settlement: no funding edge.98    The same rule with one leaked row receives every payment."""99    n = 4000100    data = perp_data(n, ("X",), seed=9, rate=0.0, rate_sigma=0.0, sigma=1e-9)101    rng = np.random.default_rng(3)102    data["X"]["funding"] = rng.normal(0, 1e-4, n)103    s = from_function(funding_sign, perp_meta(("X",), lookback=5))104    honest = run_perp_backtest(s, data, FREE)105    amounts = np.array([float(a) for a in honest.funding["amount"]])106    assert abs(amounts.mean()) / amounts.std() < 0.05                  # standard error 1/sqrt(4000) = 0.016107    df = data["X"]108    position = {ts: i for i, ts in enumerate(df.index)}109110    def leaky(view):                                  # sees the settlement its new position will be charged111        end = position[view["X"].index[-1]]112        return funding_sign({"X": df.iloc[max(0, end - 3): end + 3]}, {})113114    leaked = run_perp_backtest(leaky, data, FREE, lookback=5)115    amounts = np.array([float(a) for a in leaked.funding["amount"]])116    assert (amounts[:-2] > 0).mean() > 0.99 and float(leaked.ledger["funding"]) > 50 * abs(float(honest.ledger["funding"]))117    assert sharpe_ratio(leaked.returns.to_numpy()) > 0.5118    # one leaked row (the settlement of the fill bar itself) changes the decisions but earns nothing: that119    # settlement is charged to the PREVIOUS position, before the fill120    def one_row(view):121        end = position[view["X"].index[-1]]122        return funding_sign({"X": df.iloc[max(0, end - 3): end + 2]}, {})123124    amounts = np.array([float(a) for a in run_perp_backtest(one_row, data, FREE, lookback=5).funding["amount"]])125    assert abs(amounts.mean()) / amounts.std() < 0.05126127128def test_changing_future_funding_mark_or_index_does_not_change_the_past():129    data = one(500, seed=13)130131    def uses_everything(d, p):132        df = d["X"]133        basis = df["mark"].iloc[-1] / df["index"].iloc[-1] - 1.0134        return {"X": float(np.clip(-2000 * df["funding"].iloc[-5:].mean() + 50 * basis, -1, 1))}135136    s = from_function(uses_everything, perp_meta(("X",), lookback=20))137    base = run_perp_backtest(s, data, FREE)138    t = 300139    idx = data["X"].index140    for col in ("funding", "mark", "index", "premium"):141        altered = {"X": data["X"].copy()}142        altered["X"].loc[idx[t + 1]:, col] = altered["X"].loc[idx[t + 1]:, col] * 1.37 + (1e-4 if col == "funding" else 0)143        other = run_perp_backtest(s, altered, FREE)144        np.testing.assert_array_equal(other.equity.iloc[:t + 1].to_numpy(), base.equity.iloc[:t + 1].to_numpy())145        pd.testing.assert_frame_equal(other.decisions.loc[:idx[t]], base.decisions.loc[:idx[t]])146    # ... and the check used in every report agrees147    assert lookahead_check(s, data, FREE, runner=run_perp_backtest)["status"] == "pass"148149150def test_funding_alignment_check_detects_a_column_shifted_by_one_bar():151    data = perp_data(300, ("BTC", "ETH"), seed=2)152    raw = {s: settlements(df) for s, df in data.items()}153    assert check_funding_alignment(data, raw)["status"] == "pass"154    # the classic mistake: row i carries the rate settled at the END of bar i (the next settlement)155    shifted = {s: df.assign(funding=df["funding"].shift(-1)) for s, df in data.items()}156    bad = check_funding_alignment(shifted, raw)157    assert bad["status"] == "fail" and bad["detail"]["BTC"]["column_matches_settlements"] is False158    assert check_funding_alignment({s: df.drop(columns=["funding"]) for s, df in data.items()}, raw)["status"] == "fail"159    assert check_funding_alignment(data, {})["status"] == "fail"160    # settlement instants far from the hour cannot be attributed to a bar161    late = {s: f.assign(time_ms=f["time_ms"] + 5 * 60_000) for s, f in raw.items()}162    assert check_funding_alignment(data, late)["status"] == "fail"163164165def test_attach_funding_never_puts_a_later_settlement_on_a_bar():166    candles = bars([100] * 6, [100] * 6, start="2024-01-01 00:00")167    hours = pd.date_range("2023-12-31 23:00", periods=9, freq="1h", tz="UTC")168    f = pd.DataFrame({"rate": np.arange(1, 10) * 1e-5, "premium": np.arange(1, 10) * 1e-4}, index=hours)169    out = attach_funding(candles, f)170    # the bar opening at 00:00 gets the settlement of 00:00 (the 2nd row), ... ; 23:00 belongs to no bar here,171    # and the settlements of 06:00 and 07:00 are after the last bar: dropped172    np.testing.assert_allclose(out["funding"].to_numpy(), np.arange(2, 8) * 1e-5)173    np.testing.assert_allclose(out["premium"].to_numpy(), np.arange(2, 8) * 1e-4)174    # 4-hour bars: the sum of the settlements in (previous open, open]175    four = bars([100] * 3, [100] * 3, start="2024-01-01 00:00", freq="4h")176    f = pd.DataFrame({"rate": 1e-5, "premium": 0.0}, index=pd.date_range("2024-01-01", periods=12, freq="1h", tz="UTC"))177    np.testing.assert_allclose(attach_funding(four, f)["funding"].to_numpy(), [1e-5, 4e-5, 4e-5])178    # a missing settlement is NaN, never filled179    gap = attach_funding(candles, pd.DataFrame({"rate": [1e-5, 3e-5], "premium": [0.0, 0.0]},180                                               index=pd.DatetimeIndex(["2024-01-01 00:00", "2024-01-01 02:00"], tz="UTC")))181    assert np.isnan(gap["funding"].to_numpy()).tolist() == [False, True, False, True, True, True]182183184def test_selection_code_only_sees_funding_from_before_the_test_split():185    data = perp_data(600, ("BTC", "ETH"), seed=4)186    exp = PerpExperiment(data, cfg())187    test_start = exp.splits.test[0]188    for df in exp.dev_data().values():189        assert df.index[-1] < test_start and "funding" in df.columns and df["funding"].index[-1] < test_start190    seen = []191192    def spy(d, p):193        seen.append(d["BTC"].index[-1])194        return {"BTC": 0.2, "ETH": -0.2}195196    s = from_function(spy, perp_meta(("BTC", "ETH"), lookback=10))197    exp.trial(s)198    exp.run(s, "validation")199    assert max(seen) < test_start200    with pytest.raises(TestSplitError):201        exp.run(s, "test")202    exp.final_test(s)203    assert max(seen) >= test_start204    with pytest.raises(TestSplitError):205        exp.trial(s)                                  # sealed: no tuning after the look206
Files main
Menu

Theme