SPB Git

Repository Public

engine.py 10.7 KB 254 lines python

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
Files main
Menu

Theme