Repository Public
1"""Perp backtester: hand-computed examples. Every expected number is derived in the comments.23Round parameters (perp_helpers.cfg): taker fee 10 bps, slippage 10 bps, 10x, maintenance 5 %, no penalty."""4from __future__ import annotations56from decimal import Decimal7from fractions import Fraction89import numpy as np10import pandas as pd11import pytest1213from perp_helpers import D, bars, cfg, perp_data, perp_meta, script, scripted_strategy14from spq.data.core import DataError15from spq.engine import BacktestConfig, run_backtest16from spq.margin import dstr, exact, liquidation_price_cross17from spq.perp_engine import PerpConfig, perp_hold, run_perp_backtest18from spq.perp_metrics import compute_perp_metrics19from spq.strategy import WeightsError, from_function202122def check_ledger(res):23 """Rebuild the final equity from the logs alone, to the last digit."""24 led = res.ledger25 t, f = res.trades, res.funding26 with exact(): # sums that raise instead of rounding27 rebuilt = (led["initial"] + sum(t["realised_pnl"], D(0)) + sum(f["amount"], D(0)) - sum(t["fee"], D(0))28 - sum(t["slippage"], D(0)) + led["unrealised_pnl"] + led["bad_debt_written_off"])29 assert rebuilt == led["final_equity"] and led["reconciles"] is True30 assert led["final_equity"] == led["collateral"] + led["unrealised_pnl"]31 assert float(led["final_equity"]) == pytest.approx(res.equity.iloc[-1], rel=1e-12, abs=1e-12)32 return led333435def test_long_round_trip_by_hand():36 data = {"X": bars([100, 100, 110, 121], [100, 110, 121, 121])}37 res = run_perp_backtest(script({"X": 1.0}, None, {"X": 0.0}), data, cfg())38 # bar 1, open 100: equity 1000 -> notional 1000 -> buy 10. slippage 10*100*0.001 = 1;39 # fill price 100.1; fee 10*100.1*0.001 = 1.001; collateral 1000 - 1 - 1.001 = 997.99940 # close 110: equity 997.999 + 10*(110-100) = 1097.999 ; close 121 (bar 2): 997.999 + 210 = 1207.99941 # bar 3, open 121: sell 10. realised 210; slippage 10*121*0.001 = 1.21; fill price 120.879; fee 1.2087942 # collateral 997.999 + 210 - 1.21 - 1.20879 = 1205.5802143 assert res.equity.tolist() == pytest.approx([1000.0, 1097.999, 1207.999, 1205.58021], abs=1e-12)44 t = res.trades45 assert list(t["side"]) == ["buy", "sell"] and list(t["kind"]) == ["open", "close"]46 assert [dstr(x) for x in t["size"]] == ["10", "10"]47 assert [dstr(x) for x in t["price"]] == ["100", "121"] and [dstr(x) for x in t["fill_price"]] == ["100.1", "120.879"]48 assert [dstr(x) for x in t["fee"]] == ["1.001", "1.20879"] and [dstr(x) for x in t["slippage"]] == ["1", "1.21"]49 assert [dstr(x) for x in t["realised_pnl"]] == ["0", "210"]50 assert list(t["decided_at"]) == [data["X"].index[0], data["X"].index[2]] and list(t["ts"]) == list(data["X"].index[[1, 3]])51 led = check_ledger(res)52 assert dstr(led["final_equity"]) == "1205.58021" and dstr(led["fees"]) == "2.20979" and dstr(led["slippage"]) == "2.21"53 assert res.exposure.tolist() == pytest.approx([0, 1100 / 1097.999, 1210 / 1207.999, 0])54 assert res.open_positions == [] and not res.wiped_out and res.liquidations == []555657def test_short_round_trip_by_hand():58 data = {"X": bars([100, 100, 90, 80], [100, 90, 80, 80])}59 res = run_perp_backtest(script({"X": -1.0}, None, {"X": 0.0}), data, cfg())60 # sell 10 @ 100: slippage 1; fill price 99.9; fee 0.999; collateral 998.00161 # close 90: 998.001 + 10*(100-90) = 1098.001 ; close 80: 1198.00162 # buy 10 @ 80: realised 200; slippage 0.8; fill price 80.08; fee 0.8008 -> 998.001 + 200 - 0.8 - 0.8008 = 1196.400263 assert res.equity.tolist() == pytest.approx([1000.0, 1098.001, 1198.001, 1196.4002], abs=1e-12)64 assert list(res.trades["side"]) == ["sell", "buy"]65 assert [dstr(x) for x in res.trades["position_after"]] == ["-10", "0"]66 assert res.short_exposure.iloc[1] == pytest.approx(900 / 1098.001) and res.long_exposure.sum() == 067 assert res.weights["X"].iloc[1] == pytest.approx(-900 / 1098.001)68 assert dstr(check_ledger(res)["final_equity"]) == "1196.4002"697071def test_flip_from_long_to_short_by_hand():72 data = {"X": bars([100, 100, 110, 110], [100, 110, 110, 99])}73 res = run_perp_backtest(script({"X": 1.0}, {"X": -1.0}), data, cfg())74 # long 10 @ 100 as above: collateral 997.999. Bar 2, open 110: equity 997.999 + 100 = 1097.99975 # target -1097.999 / 110 = -9.98180909... -> -9.981809 (truncated to the 1e-6 lot); order: sell 19.98180976 # realised 10*(110-100) = 100; slippage 19.981809*110*0.001 = 2.19799899; fill price 109.89;77 # fee 19.981809*109.89*0.001 = 2.19580099101; collateral 997.999 + 100 - 2.19799899 - 2.1958009910178 flip = res.trades.iloc[1]79 assert (flip["side"], flip["kind"], dstr(flip["size"]), dstr(flip["position_after"])) == \80 ("sell", "flip", "19.981809", "-9.981809")81 assert dstr(flip["realised_pnl"]) == "100" and dstr(flip["slippage"]) == "2.19799899"82 assert dstr(flip["fee"]) == "2.19580099101"83 collateral = D("997.999") + 100 - D("2.19799899") - D("2.19580099101")84 assert res.ledger["collateral"] == collateral == D("1093.60520001899")85 # bar 3 closes at 99: the short 9.981809 @ 110 gains 9.981809 * 11 = 109.79989986 assert res.ledger["unrealised_pnl"] == D("109.799899")87 assert res.equity.iloc[-1] == pytest.approx(float(collateral + D("109.799899")), abs=1e-12)88 assert res.open_positions[0]["side"] == "short" and dstr(res.open_positions[0]["entry_price"]) == "110"89 check_ledger(res)909192def test_funding_sign_for_a_long_and_for_a_short():93 """Numbers of packages/derivatives/test/paper.test.ts: long 2, price 110, rate 0.0005 -> -0.11 (the long94 pays); rate -0.0002 -> +0.044 (the long receives); short 2, price 100, rate 0.0005 -> +0.1."""95 free = cfg(taker_fee_bps=0, slippage_bps=0, initial_cash=10_000)96 # the rate in row i is settled at the open of bar i, on the position held coming into it97 long_ = {"X": bars([100, 100, 110, 110, 110], [100, 110, 110, 110, 110], funding=[0.01, 0.02, 0.0005, -0.0002, 0.0])}98 res = run_perp_backtest(script({"X": 0.02}), long_, free) # 0.02 * 10 000 / 100 = 2 units99 f = res.funding100 # nothing at bar 0 (no position) nor at bar 1 (the position is opened AFTER that settlement)101 assert list(f["ts"]) == list(long_["X"].index[[2, 3]])102 assert [dstr(x) for x in f["amount"]] == ["-0.11", "0.044"] and [dstr(x) for x in f["size"]] == ["2", "2"]103 assert [dstr(x) for x in f["price"]] == ["110", "110"]104 assert dstr(res.ledger["funding"]) == "-0.066" and dstr(res.ledger["collateral"]) == "9999.934"105 assert res.equity.tolist() == pytest.approx([10000, 10020, 10019.89, 10019.934, 10019.934])106 check_ledger(res)107108 short = {"X": bars([100, 100, 100, 100], [100, 100, 100, 100], funding=[0.0, 0.0, 0.0005, -0.0005])}109 res = run_perp_backtest(script({"X": -0.02}), short, free)110 assert [dstr(x) for x in res.funding["amount"]] == ["0.1", "-0.1"] # receives a positive rate, pays a negative one111 assert [dstr(x) for x in res.funding["size"]] == ["-2", "-2"]112 assert compute_perp_metrics(res)["funding_pnl"] == 0.0 and res.counters["funding_payments"] == 2113114115def test_funding_missing_is_counted_and_charged_as_zero_and_index_is_the_reference_price():116 free = cfg(taker_fee_bps=0, slippage_bps=0, initial_cash=10_000)117 data = {"X": bars([100, 100, 110, 110], [100, 110, 110, 110], funding=[0, 0, np.nan, 0.001])}118 res = run_perp_backtest(script({"X": 0.02}), data, free)119 assert res.counters["funding_missing"] == 1 and [dstr(x) for x in res.funding["amount"]] == ["-0.22"]120 # with an index column the notional is size x index known BEFORE the bar (its previous value): 2 * 108 * 0.001121 data = {"X": bars([100, 100, 110, 110], [100, 110, 110, 110], funding=[0, 0, 0, 0.001], index=[99, 109, 108, 111])}122 res = run_perp_backtest(script({"X": 0.02}), data, free)123 assert [dstr(x) for x in res.funding["amount"]] == ["-0.216"] and dstr(res.funding["price"].iloc[0]) == "108"124125126def test_weights_beyond_the_leverage_caps_are_refused():127 data = {"X": bars([100] * 6, [100] * 6), "Y": bars([50] * 6, [50] * 6)}128 meta = perp_meta(("X", "Y"), lookback=1, max_leverage=2.0, max_gross=3.0)129 for bad in ({"X": 2.5}, {"X": -2.5}, {"X": 2.0, "Y": -1.5}, {"X": float("nan")}, {"Z": 0.1}, [1, 2]):130 with pytest.raises(WeightsError):131 run_perp_backtest(from_function(lambda d, p, bad=bad: bad, meta), data, cfg())132 ok = run_perp_backtest(from_function(lambda d, p: {"X": 2.0, "Y": -1.0}, meta), data,133 cfg(leverage=None, taker_fee_bps=0, slippage_bps=0))134 # leverage defaults to META['max_leverage'] = 2: initial margin 2000/2 + 1000/2 = 1500 > 1000 of equity ->135 # both orders are cut by the same factor 1000/1500 (rule: cut pro rata to the free collateral)136 assert [dstr(x) for x in ok.trades["size"]] == ["13.333333", "13.333333"] and ok.trades["cut"].all()137 assert ok.counters["orders_cut"] == 2 and ok.exposure.iloc[1] == pytest.approx(2.0, abs=1e-6)138 assert ok.margin_utilisation.iloc[1] == pytest.approx(1.0, abs=1e-6)139140141def test_initial_margin_check_cuts_or_refuses_orders_that_exceed_leverage():142 """paper.test.ts rejects 11 @ 100 at 10x with 100 of collateral (needs 110 + 1.1 of fee). Stated rule here:143 the order is CUT to what the free collateral allows, and refused only if what is left is below the minimum."""144 data = {"X": bars([100] * 5, [100] * 5)}145 c = cfg(initial_cash=100, slippage_bps=0)146 res = run_perp_backtest(scripted_strategy({"X": 11.0}), data, c)147 # per unit: margin 100/10 = 10 + fee 0.1 = 10.1 ; 100 / 10.1 = 9.90099009... -> 9.90099 units148 t = res.trades.iloc[0]149 assert dstr(t["size"]) == "9.90099" and bool(t["cut"]) and len(res.trades) == 1150 # the strategy asks for 11x again at each of the 3 later bars: nothing more fits, those orders are refused151 assert res.counters["orders_cut"] == 1 and res.counters["orders_refused"] == 3152 assert D("9.90099") * 100 / 10 + t["fee"] <= 100 # it fits: 99.0099 + 0.990099153 # 9 @ 100 needs 90 + 0.9: accepted in full (same case as the TypeScript test)154 res = run_perp_backtest(script({"X": 9.0}), data, c)155 assert dstr(res.trades.iloc[0]["size"]) == "9" and not res.trades.iloc[0]["cut"]156 assert dstr(res.ledger["collateral"]) == "99.1"157 # with a 200 USD minimum order the cut of an order that only fits for 50 USD is refused158 res = run_perp_backtest(script({"X": 9.0}, {"X": 10.0}), data, cfg(initial_cash=100, slippage_bps=0, min_order=0))159 assert len(res.trades) == 2 # 9, then +0.89... (cut)160 res = run_perp_backtest(script({"X": 9.0}, {"X": 10.0}), data, cfg(initial_cash=100, slippage_bps=0, min_order=95))161 assert len(res.trades) == 1 and res.counters["orders_refused"] + res.counters["orders_skipped"] == 1162 # reducing is always allowed, even with no free collateral left163 res = run_perp_backtest(script({"X": 9.0}, {"X": 4.0}), data, c)164 # (target 4 x equity 99.1 / 100 = 3.964 units)165 assert list(res.trades["kind"]) == ["open", "reduce"] and dstr(res.trades.iloc[1]["position_after"]) == "3.964"166167168def test_venue_maximum_leverage_caps_the_account_leverage():169 data = {"X": bars([100] * 4, [100] * 4)}170 meta = perp_meta(("X",), lookback=1, max_leverage=5.0)171 c = PerpConfig("1h", taker_fee_bps=0, slippage_bps=0, min_order=0, venue_max_leverage={"X": 2.0})172 res = run_perp_backtest(from_function(lambda d, p: {"X": 5.0}, meta), data, c)173 assert res.exposure.iloc[1] == pytest.approx(2.0, abs=1e-6) and res.counters["orders_cut"] == 1174 assert c.maintenance("X") == 0.25 and c.maintenance("OTHER") == 0.05 # half of the initial margin at max leverage175176177def test_liquidation_of_a_long_at_the_expected_price():178 """Same account as packages/derivatives/test/paper.test.ts: long 9 @ 100 at 10x with 100 of collateral,179 fee 0.1 % -> collateral 99.1, maintenance 5 %. The TypeScript estimate of the liquidation price is180 (9*100 - 99.1) / (9 * 0.95) = 93.672514619883040936 (rounded up at 18 decimals)."""181 lp = D("93.672514619883040936")182 assert liquidation_price_cross("long", D(9), D(100), D("99.1"), D("0.05")) == lp183 data = {"X": bars([100, 100, 100, 94, 95], [100, 100, 94, 95, 95], lows=[100, 100, 94, 93, 95])}184 res = run_perp_backtest(script({"X": 9.0}), data, cfg(initial_cash=100, slippage_bps=0))185 # bar 2 falls to 94: equity 99.1 + 9*(94-100) = 45.1 >= maintenance 9*94*0.05 = 42.3 -> alive (as in the TS test)186 assert res.equity.iloc[2] == pytest.approx(45.1) and res.worst_equity.iloc[2] == pytest.approx(45.1)187 # bar 3: open 94, low 93 crosses the level -> closed AT the level, taker fee 0.1 % of 9 * level188 liq = res.trades.iloc[1]189 assert bool(liq["liquidation"]) and liq["side"] == "sell" and liq["price"] == lp and liq["ts"] == data["X"].index[3]190 realised = 9 * (lp - 100)191 fee = 9 * lp * D("0.001")192 assert liq["realised_pnl"] == realised == D("-56.947368421052631576") and liq["fee"] == fee193 assert res.ledger["collateral"] == D("99.1") + realised - fee194 assert res.equity.iloc[3] == pytest.approx(float(D("99.1") + realised - fee)) == pytest.approx(41.3095789, abs=1e-6)195 assert len(res.liquidations) == 1 and res.liquidations[0]["reason"].startswith("intrabar")196 assert not res.wiped_out and res.open_positions == []197 # at the level, equity before the fee equals the maintenance margin (up to the 1e-18 rounding of the level)198 assert abs((D("99.1") + realised) - 9 * lp * D("0.05")) < D("1e-15")199 m = compute_perp_metrics(res)200 assert m["liquidations"] == 1 and m["wiped_out"] is False and m["worst_intrabar_drawdown"] > m["max_drawdown"] - 1e-12201 check_ledger(res)202203 # a low that stops just above the level does not liquidate204 safe = {"X": bars([100, 100, 100, 94, 95], [100, 100, 94, 95, 95], lows=[100, 100, 94, 93.68, 95])}205 assert run_perp_backtest(script({"X": 9.0}), safe, cfg(initial_cash=100, slippage_bps=0)).liquidations == []206207208def test_liquidation_of_a_short_at_the_expected_price_with_a_penalty():209 # short 9 @ 100, collateral 99.1: level (900 + 99.1) / (9 * 1.05) = 105.724867724867724867 (rounded DOWN)210 lp = liquidation_price_cross("short", D(9), D(100), D("99.1"), D("0.05"))211 assert dstr(lp) == "105.724867724867724867"212 assert abs(Fraction(lp) - Fraction(9991, 10) / Fraction(945, 100)) < Fraction(1, 10**18)213 data = {"X": bars([100, 100, 100, 104], [100, 100, 104, 104], highs=[100, 100, 104, 107])}214 res = run_perp_backtest(script({"X": -9.0}), data, cfg(initial_cash=100, slippage_bps=0, liquidation_penalty_bps=90))215 liq = res.trades.iloc[1]216 assert bool(liq["liquidation"]) and liq["side"] == "buy" and liq["price"] == lp217 assert liq["realised_pnl"] == 9 * (100 - lp)218 # stated penalty: 90 bps of the liquidated notional on top of the 10 bps taker fee = 1 % in total219 assert liq["penalty"] == 9 * lp * D("0.009") and liq["fee"] == 9 * lp * D("0.01")220 assert res.ledger["liquidation_penalties"] == liq["penalty"]221 check_ledger(res)222223224def test_gap_through_the_level_liquidates_at_the_open():225 """paper.test.ts, second liquidation case: short 9 @ 100 (collateral 99.1), mark 107, liquidation fee 1 %:226 closed PnL -63, fee 9*107*0.01 = 9.63, collateral 99.1 - 63 - 9.63 = 26.47."""227 data = {"X": bars([100, 100, 107, 107], [100, 100, 107, 107])}228 res = run_perp_backtest(script({"X": -9.0}), data, cfg(initial_cash=100, slippage_bps=0, liquidation_penalty_bps=90))229 liq = res.trades.iloc[1]230 assert (dstr(liq["price"]), dstr(liq["realised_pnl"]), dstr(liq["fee"]), bool(liq["liquidation"])) == \231 ("107", "-63", "9.63", True)232 assert dstr(res.ledger["collateral"]) == "26.47" and res.liquidations[0]["reason"] == "gap at the open"233 # long side, first TS case: mark 93 -> closed PnL -63; here the taker fee 9*93*0.001 = 0.837 is also charged234 data = {"X": bars([100, 100, 93, 93], [100, 100, 93, 93])}235 res = run_perp_backtest(script({"X": 9.0}), data, cfg(initial_cash=100, slippage_bps=0))236 assert dstr(res.trades.iloc[1]["realised_pnl"]) == "-63" and dstr(res.ledger["collateral"]) == "35.263"237238239def test_wipe_out_stops_the_run_at_zero_and_reports_the_debt():240 data = {"X": bars([100, 100, 85, 90, 120, 130], [100, 100, 90, 120, 130, 140])}241 res = run_perp_backtest(script({"X": 9.0}, {"X": 5.0}, {"X": 5.0}, {"X": 5.0}), data,242 cfg(initial_cash=100, slippage_bps=0, liquidation_penalty_bps=100))243 # gap to 85: realised 9*(85-100) = -135; fee 9*85*(0.001+0.01) = 8.415 -> collateral 99.1 - 135 - 8.415 = -44.315244 assert res.wiped_out and res.wiped_out_at == data["X"].index[2]245 assert res.equity.tolist() == pytest.approx([100, 99.1, 0, 0, 0, 0])246 assert dstr(res.ledger["bad_debt_written_off"]) == "44.315" and res.ledger["final_equity"] == 0247 assert len(res.trades) == 2 and (res.trades["ts"] <= data["X"].index[2]).all() # nothing trades afterwards248 assert len(res.decisions) == 2 # and nothing is decided249 check_ledger(res)250 m = compute_perp_metrics(res)251 assert m["wiped_out"] is True and m["total_return"] == -1.0 and m["max_drawdown"] == 1.0252 assert m["worst_intrabar_drawdown"] == 1.0 and m["liquidations"] == 1 and m["cagr"] == -1.0253 assert np.isfinite(res.returns.to_numpy()).all()254255256def test_cross_margin_liquidation_with_two_positions_happens_where_equity_meets_maintenance():257 data = {"X": bars([100, 100, 100], [100, 100, 97], lows=[100, 100, 90]),258 "Y": bars([50, 50, 50], [50, 50, 51], highs=[50, 50, 56])}259 res = run_perp_backtest(script({"X": 5.0, "Y": -4.0}), data, cfg(initial_cash=100, taker_fee_bps=0, slippage_bps=0))260 # long 5 X @ 100, short 8 Y @ 50, collateral 100. Along the path open -> (X low 90, Y high 56), a fraction u:261 # equity(u) = 100 - 5*10u - 8*6u = 100 - 98u ; maintenance(u) = 0.05*(5*(100-10u) + 8*(50+6u)) = 45 - 0.1u262 # equal at u = 55 / 97.9 -> X = 100 - 10u = 94.38202247..., Y = 50 + 6u = 53.37078651...263 u = Fraction(55) / Fraction(979, 10)264 x, y = res.trades.iloc[2]["price"], res.trades.iloc[3]["price"]265 assert res.trades["liquidation"].tolist() == [False, False, True, True] and len(res.liquidations) == 1266 assert abs(Fraction(x) - (100 - 10 * u)) < Fraction(1, 10**17) and abs(Fraction(y) - (50 + 6 * u)) < Fraction(1, 10**17)267 equity = 100 + 5 * (x - 100) + 8 * (50 - y)268 assert abs(equity - D("0.05") * (5 * x + 8 * y)) < D("1e-15")269 check_ledger(res)270271272def test_ledger_reconciles_exactly_on_random_runs_including_liquidations():273 rng = np.random.default_rng(7)274 saw_liquidation = saw_wipe = False275 for seed in range(6):276 data = perp_data(700, ("BTC", "ETH"), seed=seed, sigma=0.02, rate=5e-5, rate_sigma=3e-4)277 lev = [2.0, 6.0, 12.0][seed % 3]278 meta = perp_meta(("BTC", "ETH"), lookback=5, max_leverage=lev, max_gross=2 * lev)279 w = rng.uniform(-lev, lev, size=(700, 2))280281 def random_weights(d, p, w=w, t0=data["BTC"].index[0]):282 i = int((d["BTC"].index[-1] - t0) / pd.Timedelta(hours=1))283 return {"BTC": float(w[i, 0]), "ETH": float(w[i, 1])}284285 c = PerpConfig("1h", decision_every=3, venue_max_leverage={"BTC": 40, "ETH": 25}, leverage=lev,286 size_decimals={"BTC": 5, "ETH": 4},287 # the last runs use a penalty larger than the maintenance margin: a liquidation wipes out288 liquidation_penalty_bps=100 if seed < 4 else 400)289 res = run_perp_backtest(from_function(random_weights, meta), data, c)290 led = check_ledger(res)291 assert len(res.trades) > 50 and len(res.funding) > 100292 assert (res.equity >= 0).all() and (res.worst_equity >= 0).all()293 assert res.wiped_out == (res.equity.iloc[-1] == 0) and (led["bad_debt_written_off"] > 0) == res.wiped_out294 saw_liquidation |= len(res.liquidations) > 0295 saw_wipe |= res.wiped_out296 # every money value of the logs is an exact Decimal, never a float297 assert all(isinstance(x, Decimal) for x in res.trades["fee"]) and all(isinstance(x, Decimal) for x in res.funding["amount"])298 assert saw_liquidation and saw_wipe, "the random runs must exercise liquidation and wipe-out"299300301def test_spot_leg_is_paid_in_full_is_not_margin_and_hedges_the_short():302 px = [100, 100, 120, 150, 150]303 data = {"X": bars(px, px[1:] + [150], funding=[0, 0, 0.001, 0.001, 0.001]), "X.SPOT": bars(px, px[1:] + [150])}304 c = cfg(taker_fee_bps=0, slippage_bps=0, spot_fee_bps=0, spot_slippage_bps=0, leverage=1.0)305 res = run_perp_backtest(script({"X": -0.4, "X.SPOT": 0.4}), data, c)306 # 4 units long on spot (400 USDC paid), 4 units short on the perpetual: the price move cancels out and the307 # short collects funding at the opens 120, 150, 150: 4*120*0.001 + 4*150*0.001 + 4*150*0.001 = 1.68308 assert [dstr(x) for x in res.funding["amount"]] == ["0.48", "0.6", "0.6"]309 assert res.equity.tolist() == pytest.approx([1000, 1000, 1000.48, 1001.08, 1001.68])310 assert res.long_exposure.iloc[1] == pytest.approx(0.48) and res.short_exposure.iloc[1] == pytest.approx(0.48)311 assert (res.net_exposure.abs() < 1e-12).all()312 # margin equity = 1000 - 400 (spot is not collateral) - 200 (short's loss at 150) + funding; never liquidated313 assert res.liquidations == [] and res.margin_utilisation.iloc[3] == pytest.approx(600 / (1000 - 400 - 200 + 1.08), rel=1e-9)314 check_ledger(res)315 # a spot leg cannot be shorted nor levered, and a universe of spot legs only is refused316 with pytest.raises(WeightsError):317 run_perp_backtest(script({"X.SPOT": -0.1}), data, c)318 with pytest.raises(WeightsError):319 run_perp_backtest(script({"X.SPOT": 1.5}), data, c)320 with pytest.raises(DataError):321 run_perp_backtest(script({"X.SPOT": 0.5}), {"X.SPOT": data["X.SPOT"]}, c)322323324def test_rebalance_threshold_minimum_order_and_full_exit():325 px = [100, 100, 101, 102, 103, 104]326 data = {"X": bars(px, px[1:] + [104])}327 c = cfg(taker_fee_bps=0, slippage_bps=0, rebalance_threshold=0.05)328 res = run_perp_backtest(script({"X": 1.0}, {"X": 1.02}, {"X": 1.0}, {"X": 0.0}), data, c)329 assert list(res.trades["kind"]) == ["open", "close"] and res.counters["orders_skipped"] == 1330 res = run_perp_backtest(script({"X": 0.001}, {"X": 0.0}), data, cfg(min_order=10.0)) # 1 USD < 10 USD331 assert len(res.trades) == 0332 res = run_perp_backtest(script({"X": 0.02}, {"X": 0.0}), data, cfg(min_order=15.0)) # 20 USD in, exit allowed333 assert list(res.trades["kind"]) == ["open", "close"]334335336def test_benchmark_perp_long_1x_includes_funding_and_spot_engine_refuses_a_perp_strategy():337 data = perp_data(400, ("BTC", "ETH"), seed=3, rate=1e-4, rate_sigma=0)338 c = PerpConfig("1h", taker_fee_bps=0, slippage_bps=0)339 held = perp_hold(data, c)340 no_funding = perp_hold({s: df.drop(columns=["funding", "premium"]) for s, df in data.items()}, c)341 assert len(held.trades) == 2 and held.exposure.iloc[5] == pytest.approx(1.0, abs=0.05)342 paid = float(held.ledger["funding"])343 assert paid < 0 and held.equity.iloc[-1] == pytest.approx(no_funding.equity.iloc[-1] + paid, rel=1e-9)344 assert held.counters["funding_payments"] == 2 * 398 and no_funding.counters["funding_payments"] == 0345 check_ledger(held)346 strategy = from_function(lambda d, p: {"BTC": -0.5}, perp_meta(("BTC", "ETH"), lookback=5))347 with pytest.raises(DataError, match="perp strategy"):348 run_backtest(strategy, data, BacktestConfig("1h"))349350351def test_config_validation_and_engine_guards():352 for bad in (dict(taker_fee_bps=-1), dict(slippage_bps=10_000), dict(liquidity="x"), dict(initial_cash=0),353 dict(decision_every=0), dict(leverage=0), dict(maintenance_fraction=1.0), dict(venue_max_leverage={"X": 0})):354 with pytest.raises(ValueError):355 PerpConfig("1h", **bad)356 assert PerpConfig("1h").fee_bps == 4.5 and PerpConfig("1h", liquidity="maker").fee_bps == 1.5357 data = {"X": bars([100, 100], [100, 100])}358 with pytest.raises(TypeError):359 run_perp_backtest(script(), data, BacktestConfig("1h"))360 with pytest.raises(DataError):361 run_perp_backtest(script(), {"X": data["X"].iloc[:1]}, cfg())362 with pytest.raises(DataError):363 run_perp_backtest(script(), {"X": bars([100, -1], [100, 100])}, cfg())364