spb/crypto-trade-bot
Public
Python 54.7%
TypeScript 42.5%
SQL 1.9%
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