SPB Git forge
1commits 1branches 0releases
189.0 KBsize
maindefault branch
5 h agolast push
Python 54.7% TypeScript 42.5% SQL 1.9%
6.5 KB · 144 lines python
Raw Blame History
1"""Event-driven bar backtester.23Timeline per bar i (closed bars only):4  • At close(i) the strategy's target exposure τ(i) is known (features use rows ≤ i).5  • The resulting order is *submitted* after close(i) and executed at open(i+1+latency) — never on bar i.6  • Fill price = open ± half-spread ± slippage ± impact ; fee = taker/maker × notional ; partial fills capped at7    max_participation × volume(i+1) ; qty rounded DOWN to lot_decimals ; orders below ordermin/costmin are dropped.8  • Every order passes the independent RiskGateway (same class used by paper/live engines).9  • Equity is marked at close(i+1). Fees, slippage and rejected/partial orders are all accounted in the ledger.10"""1112from __future__ import annotations1314from dataclasses import dataclass, field15from typing import Any, Literal1617import numpy as np18import pandas as pd1920from ktrade.backtest.costs import CostModel, fee_rate, fill_price, round_lot21from ktrade.risk.gateway import MarketState, OrderIntent, PortfolioSnapshot, RiskGateway, RiskLimits22from ktrade.strategy.base import Strategy232425@dataclass26class TradeRecord:27    ts: int28    side: str29    qty: float30    price: float31    ref_price: float32    fee: float33    slippage_cost: float34    notional: float35    reason: str36    strategy_target: float37    partial: bool = False383940@dataclass41class BacktestResult:42    equity: pd.DataFrame  # ts, equity, cash, position, exposure, price, target43    trades: list[TradeRecord]44    rejected: list[dict[str, Any]]45    fees_paid: float46    slippage_paid: float47    turnover: float48    bars: int49    diagnostics: dict[str, Any] = field(default_factory=dict)505152def run_backtest(53    strategy: Strategy,54    df: pd.DataFrame,55    *,56    interval_min: int,57    initial_cash: float = 10_000.0,58    cost_model: CostModel | None = None,59    risk_limits: RiskLimits | None = None,60    lot_decimals: int = 8,61    ordermin: float = 0.0,62    costmin: float = 0.0,63    rebalance_threshold: float = 0.05,64) -> BacktestResult:65    cm = cost_model or CostModel()66    limits = risk_limits or RiskLimits(max_order_notional=1e12, max_position_notional=1e12, max_gross_exposure=1.0, max_concentration=1.0, daily_loss_limit=1.0, max_drawdown_limit=1.0, stale_data_seconds=1e12, max_open_orders=10, max_participation=cm.max_participation)67    gateway = RiskGateway(limits)68    df = df.sort_values("ts").reset_index(drop=True).copy()69    df.attrs["interval_min"] = interval_min70    res = strategy.run(df)71    target = res.target.to_numpy(dtype=float)72    ts = df["ts"].to_numpy(dtype=np.int64)73    o = df["open"].to_numpy(dtype=float)74    c = df["close"].to_numpy(dtype=float)75    v = df["volume"].to_numpy(dtype=float)76    n = len(df)77    cash = initial_cash78    pos = 0.079    fees = 0.080    slip = 0.081    traded_notional = 0.082    peak = initial_cash83    trades: list[TradeRecord] = []84    rejected: list[dict[str, Any]] = []85    rows = []86    fr = fee_rate(cm)87    pending: list[tuple[int, float]] = []  # (execute_at_index, target)88    for i in range(n):89        # 1) execute orders scheduled for this bar's open (decided at an earlier close)90        due = [t for (idx, t) in pending if idx == i]91        pending = [(idx, t) for (idx, t) in pending if idx != i]92        if due:93            tgt = due[-1]94            ref = o[i]95            equity_open = cash + pos * ref96            desired_qty = tgt * equity_open / ref97            delta = desired_qty - pos98            # avoid churning: skip if the change in exposure is below the threshold99            if abs(delta * ref) >= rebalance_threshold * equity_open and abs(delta) > 0:100                side: Literal["buy", "sell"] = "buy" if delta > 0 else "sell"101                qty = round_lot(abs(delta), lot_decimals)102                if side == "sell":103                    qty = min(qty, pos)104                max_fill = cm.max_participation * v[i] if v[i] > 0 else qty105                partial = qty > max_fill106                qty = round_lot(min(qty, max_fill), lot_decimals)107                px = fill_price(side, ref, qty, v[i], cm) if qty > 0 else ref108                if side == "buy":  # cap by cash incl. fee109                    qty = round_lot(min(qty, cash / (px * (1 + fr))), lot_decimals)110                intent = OrderIntent(symbol="BT", side=side, qty=qty, strategy=strategy.meta.name)111                pf = PortfolioSnapshot(equity=equity_open, cash=cash, positions={"BT": pos}, marks={"BT": ref}, peak_equity=peak)112                mkt = MarketState(last_price=ref, recent_bar_volume=v[i] if v[i] > 0 else None, ordermin=ordermin, costmin=costmin)113                dec = gateway.evaluate(intent, pf, mkt)114                if qty > 0 and dec.allowed:115                    notional = qty * px116                    fee = notional * fr117                    if side == "buy":118                        cash -= notional + fee119                        pos += qty120                    else:121                        cash += notional - fee122                        pos -= qty123                    fees += fee124                    sc = abs(px - ref) * qty125                    slip += sc126                    traded_notional += notional127                    trades.append(TradeRecord(ts=int(ts[i]), side=side, qty=qty, price=px, ref_price=ref, fee=fee, slippage_cost=sc, notional=notional, reason="rebalance", strategy_target=tgt, partial=partial))128                    if partial:129                        pending.append((i + 1, tgt))  # keep working the remainder next bar130                elif qty > 0:131                    rejected.append({"ts": int(ts[i]), "side": side, "qty": qty, "reasons": dec.reasons})132        # 2) mark at close133        equity = cash + pos * c[i]134        peak = max(peak, equity)135        rows.append((int(ts[i]), equity, cash, pos, (pos * c[i]) / equity if equity > 0 else 0.0, c[i], float(target[i]) if not np.isnan(target[i]) else 0.0))136        # 3) decision at close(i) → execution at open(i+1+latency)137        if i + 1 + cm.latency_bars < n and not np.isnan(target[i]):138            pending.append((i + 1 + cm.latency_bars, float(target[i])))139    eq = pd.DataFrame(rows, columns=["ts", "equity", "cash", "position", "exposure", "price", "target"])140    avg_equity = float(eq["equity"].mean()) if n else initial_cash141    bars_per_year = 365 * 24 * 60 / interval_min142    turnover = (traded_notional / avg_equity) / max(n / bars_per_year, 1e-9) if avg_equity > 0 else 0.0143    return BacktestResult(equity=eq, trades=trades, rejected=rejected, fees_paid=fees, slippage_paid=slip, turnover=turnover, bars=n, diagnostics={"strategy": res.diagnostics, "n_partial": sum(t.partial for t in trades)})144