Repository Public
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