Repository Public
1"""Event-driven, long-only spot backtester.23Timeline for a window of bars a .. b-1 (bar i opens at ts[i]):45 close of bar i : the strategy sees bars <= i (at most `lookback` of them) and returns target weights6 open of bar i+1 : the engine trades towards those weights at open[i+1], paying fee and slippage7 close of bar i+1 : the portfolio is marked to market -> equity[i+1]89The strategy is handed `df.iloc[i + 1 - lookback : i + 1]` and nothing else: the bar it is filled on does not exist10in its inputs. Order sizes are computed at execution time from the fill bar's open (as a live engine would size an11order from a fresh quote); the decision itself (the weights) only depends on closed bars.1213Costs, on traded notional: a buy pays open*(1+slippage) per unit plus fee on the amount paid; a sell receives14open*(1-slippage) per unit minus fee on the amount received. Cash is USD (USDC) and earns nothing.15"""16from __future__ import annotations1718from dataclasses import asdict, dataclass19from typing import Callable2021import numpy as np22import pandas as pd2324from .data.core import DataError, interval_seconds, to_utc25from .strategy import Strategy, validate_weights2627if int(pd.__version__.split(".")[0]) < 3: # pragma: no cover - copy-on-write is the only mode from pandas 328 # slices handed to a strategy must be copy-on-write so that it cannot write into the engine's data29 pd.options.mode.copy_on_write = True303132@dataclass(frozen=True)33class BacktestConfig:34 interval: str35 fee_bps: float = 10.036 slippage_bps: float = 10.037 initial_cash: float = 1000.038 min_order: float = 0.0 # USD; smaller orders are not sent39 rebalance_threshold: float = 0.0 # do not trade a symbol whose weight is within this distance of its target40 decision_every: int = 1 # ask the strategy every N bars (anchored on the UTC epoch)4142 def __post_init__(self):43 interval_seconds(self.interval)44 if self.fee_bps < 0 or self.slippage_bps < 0 or self.slippage_bps >= 10_000:45 raise ValueError("fee_bps and slippage_bps must be >= 0 (slippage < 10000)")46 if self.initial_cash <= 0 or self.min_order < 0 or not 0 <= self.rebalance_threshold <= 1:47 raise ValueError("initial_cash > 0, min_order >= 0 and 0 <= rebalance_threshold <= 1 are required")48 if int(self.decision_every) != self.decision_every or self.decision_every < 1:49 raise ValueError("decision_every must be a positive integer")5051 def to_dict(self) -> dict:52 return asdict(self)535455@dataclass56class BacktestResult:57 """equity/cash/exposure are indexed by bar-open time and valued at that bar's CLOSE."""5859 symbols: list[str]60 config: BacktestConfig61 equity: pd.Series62 cash: pd.Series63 exposure: pd.Series # invested fraction of equity at each close64 decisions: pd.DataFrame # target weights, indexed by the decision bar65 trades: pd.DataFrame # one row per fill66 open_positions: list[dict]6768 @property69 def returns(self) -> pd.Series:70 return self.equity.pct_change().iloc[1:]717273TRADE_COLUMNS = ["ts", "decided_at", "symbol", "side", "units", "price", "notional", "fee", "slippage", "realised_pnl"]747576def check_aligned(data: dict[str, pd.DataFrame]) -> pd.DatetimeIndex:77 if not data:78 raise DataError("no data")79 index = None80 for s, df in data.items():81 if index is None:82 index = df.index83 elif not df.index.equals(index):84 raise DataError(f"{s}: index differs from the other symbols (use spq.data.align first)")85 if not isinstance(index, pd.DatetimeIndex) or index.tz is None:86 raise DataError("data must be indexed by timezone-aware UTC bar-open times")87 if not index.is_monotonic_increasing or not index.is_unique:88 raise DataError("data index must be strictly increasing")89 return index909192def run_backtest(93 strategy: Strategy | Callable,94 data: dict[str, pd.DataFrame],95 config: BacktestConfig,96 *,97 start=None,98 end=None,99 params: dict | None = None,100 lookback: int | None = None,101) -> BacktestResult:102 """Simulate `strategy` on the bars with start <= ts < end.103104 Bars before `start` are only used as history for the strategy's lookback (they are past data).105 `strategy` is a loaded Strategy, or (for benchmarks) a callable view -> weights | None where None means106 "leave the portfolio as it is".107 """108 index = check_aligned(data)109 symbols = list(data.keys())110 k = len(symbols)111 step = interval_seconds(config.interval)112 a = 0 if start is None else int(index.searchsorted(to_utc(start), side="left"))113 b = len(index) if end is None else int(index.searchsorted(to_utc(end), side="left"))114 if b - a < 2:115 raise DataError("the backtest window needs at least two bars")116117 if isinstance(strategy, Strategy):118 if strategy.kind != "spot":119 raise DataError(f"strategy {strategy.name!r} is a {strategy.kind} strategy: the spot backtester cannot "120 "run it (use spq.perp_engine.run_perp_backtest / python -m spq.perp_backtest)")121 lb = strategy.lookback if lookback is None else lookback122123 def decide(view):124 return strategy(view, params)125 else:126 lb = lookback or 1127128 def decide(view):129 w = strategy(view)130 return None if w is None else validate_weights(w, symbols)131132 frames = [data[s] for s in symbols]133 opens = np.column_stack([df["open"].to_numpy(dtype="float64") for df in frames])134 closes = np.column_stack([df["close"].to_numpy(dtype="float64") for df in frames])135 if not (np.isfinite(opens[a:b]).all() and np.isfinite(closes[a:b]).all() and (opens[a:b] > 0).all()):136 raise DataError("non-finite or non-positive prices inside the backtest window")137 close_epoch = index.as_unit("s").asi8 + step138 due = (close_epoch % (step * int(config.decision_every))) == 0139140 fee = config.fee_bps / 1e4141 slip = config.slippage_bps / 1e4142 thr = config.rebalance_threshold143144 cash = float(config.initial_cash)145 units = np.zeros(k)146 cost = np.zeros(k) # cost basis (USD paid, fees and slippage included) of the units held147 n = b - a148 equity = np.empty(n)149 cash_curve = np.empty(n)150 exposure = np.empty(n)151 trades: list[tuple] = []152 decision_ts: list = []153 decision_w: list = []154 pending = None155 pending_at = None156157 for j in range(n):158 i = a + j159 if pending is not None:160 px = opens[i]161 value = units * px162 total = cash + value.sum()163 target = np.array([pending[s] for s in symbols]) * total164 delta = target - value165 w_gap = np.abs(delta) / total if total > 0 else np.zeros(k)166 act = (w_gap > 1e-12) & (w_gap >= thr)167 act |= (target == 0) & (units > 0) # a full exit is never blocked by the threshold168 act &= np.abs(delta) >= config.min_order169 act &= np.abs(delta) > 0170 ts = index[i]171 for m in np.nonzero(act & (delta < 0))[0]: # sells first: they fund the buys172 q = units[m] if target[m] == 0 else min(units[m], -delta[m] / px[m])173 gross = q * px[m] * (1 - slip)174 f = gross * fee175 basis = cost[m] * (q / units[m])176 cash += gross - f177 units[m] -= q178 cost[m] -= basis179 if target[m] == 0:180 units[m], cost[m] = 0.0, 0.0181 trades.append((ts, pending_at, symbols[m], "sell", q, px[m] * (1 - slip), gross, f,182 q * px[m] * slip, gross - f - basis))183 buys = np.nonzero(act & (delta > 0))[0]184 if len(buys):185 want = delta[buys] * (1 + slip) * (1 + fee) # cash needed for each buy186 scale = min(1.0, cash / want.sum()) if want.sum() > 0 else 0.0187 for m, spend in zip(buys, want * scale):188 if spend < config.min_order or spend <= 0:189 continue190 gross = spend / (1 + fee)191 q = gross / (px[m] * (1 + slip))192 cash -= spend193 units[m] += q194 cost[m] += spend195 trades.append((ts, pending_at, symbols[m], "buy", q, px[m] * (1 + slip), gross, gross * fee,196 q * px[m] * slip, 0.0))197 cash = max(cash, 0.0)198 pending = None199200 held = units * closes[i]201 eq = cash + held.sum()202 equity[j], cash_curve[j] = eq, cash203 exposure[j] = held.sum() / eq if eq > 0 else 0.0204205 if due[i] and i + 1 >= lb: # also on the last bar: recorded as the current signal, never filled206 lo = i + 1 - lb207 view = {s: df.iloc[lo:i + 1] for s, df in zip(symbols, frames)}208 w = decide(view)209 if w is not None:210 pending, pending_at = w, index[i]211 decision_ts.append(index[i])212 decision_w.append([w[s] for s in symbols])213214 win = index[a:b]215 open_positions = [216 {"symbol": s, "units": float(units[m]), "cost": float(cost[m]), "value": float(units[m] * closes[b - 1, m])}217 for m, s in enumerate(symbols) if units[m] > 0218 ]219 return BacktestResult(220 symbols=symbols,221 config=config,222 equity=pd.Series(equity, index=win, name="equity"),223 cash=pd.Series(cash_curve, index=win, name="cash"),224 exposure=pd.Series(exposure, index=win, name="exposure"),225 decisions=pd.DataFrame(decision_w, index=pd.DatetimeIndex(decision_ts, name="ts"), columns=symbols),226 trades=pd.DataFrame(trades, columns=TRADE_COLUMNS),227 open_positions=open_positions,228 )229230231# ---------------------------------------------------------------------------------------------------- benchmarks232233def buy_and_hold(data, config: BacktestConfig, *, start=None, end=None, weights: dict | None = None) -> BacktestResult:234 """Buy once at the first possible fill (equal weights by default), with the same costs, then never trade."""235 symbols = list(data.keys())236 w = weights or {s: 1.0 / len(symbols) for s in symbols}237 state = {"done": False}238239 def decide(_view):240 if state["done"]:241 return None242 state["done"] = True243 return w244245 cfg = BacktestConfig(**{**config.to_dict(), "decision_every": 1, "rebalance_threshold": 0.0, "min_order": 0.0})246 return run_backtest(decide, data, cfg, start=start, end=end)247248249def equal_weight(data, config: BacktestConfig, *, start=None, end=None) -> BacktestResult:250 """Equal weights, rebalanced under the same costs, threshold and minimum order as the strategy."""251 symbols = list(data.keys())252 w = {s: 1.0 / len(symbols) for s in symbols}253 return run_backtest(lambda _view: w, data, config, start=start, end=end)254