bit-backtest-lab / src /engine.py
Bit-Trading-Company's picture
Backtest Lab v1.0.0
46f1a78 verified
Raw
History Blame Contribute Delete
26 kB
"""Backtest engine.
Two properties are enforced structurally rather than by convention:
**Fills happen at the next bar's open.** A strategy's decision at bar `t` is
shifted forward one bar by `_shift_decisions`, which `run_backtest` always
applies and a strategy cannot bypass, and the fill price handed to vectorbt is
`open`. There is no code path that enters on the signal bar.
**Strategies cannot see the future.** `assert_causal` perturbs the tail of a
price series, re-runs the indicator, and requires that every output before the
perturbation is bit-identical. A strategy that reads bar `t+1` changes its
earlier outputs when the future changes, and is caught. This is a property
test on the function, not a promise in a docstring.
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field, asdict, replace
import numpy as np
import pandas as pd
import vectorbt as vbt
from . import config
from .metrics import Metrics, compute_metrics
# --------------------------------------------------------------------------
# Errors
# --------------------------------------------------------------------------
class EngineError(RuntimeError):
pass
class LookaheadError(AssertionError):
"""A strategy's output at bar t depended on data after bar t."""
# --------------------------------------------------------------------------
# Configuration
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class Costs:
"""Trading frictions. On by default -- turning them off is how a backtest lies."""
enabled: bool = True
commission_bps: float = config.DEFAULT_COMMISSION_BPS # per side
slippage_bps: float = config.DEFAULT_SLIPPAGE_BPS
slippage_model: str = "fixed" # "fixed" | "volume_scaled"
volume_scale_k: float = 0.5
@property
def commission_rate(self) -> float:
return (self.commission_bps / 10_000.0) if self.enabled else 0.0
@property
def slippage_rate(self) -> float:
return (self.slippage_bps / 10_000.0) if self.enabled else 0.0
@dataclass(frozen=True)
class Sizing:
mode: str = "fixed_pct" # "fixed_pct" | "vol_target" | "fixed_units"
pct: float = 1.0 # fraction of equity when fixed_pct
units: float = 1.0 # units when fixed_units
vol_target_ann: float = 0.15
vol_lookback: int = 20
leverage: float = 1.0
max_position: float = 1.0 # cap as fraction of equity
@dataclass(frozen=True)
class Stops:
sl_pct: float | None = None
tp_pct: float | None = None
trail_pct: float | None = None
@property
def any_set(self) -> bool:
return any(v is not None for v in (self.sl_pct, self.tp_pct, self.trail_pct))
@dataclass(frozen=True)
class Validation:
"""How the period is carved into in-sample and out-of-sample."""
mode: str = "holdout" # "none" | "split" | "walk_forward" | "holdout"
split_frac: float = 0.7
train_months: int = 12
test_months: int = 3
roll_months: int = 3
holdout_months: int = config.DEFAULT_HOLDOUT_MONTHS
@dataclass(frozen=True)
class BacktestConfig:
asset: str = "BTC-USD"
timeframe: str = "1d"
strategy: str = "Buy & Hold"
params: dict = field(default_factory=dict)
costs: Costs = field(default_factory=Costs)
sizing: Sizing = field(default_factory=Sizing)
stops: Stops = field(default_factory=Stops)
validation: Validation = field(default_factory=Validation)
init_cash: float = config.DEFAULT_INIT_CASH
fill: str = "next_open" # the only supported value; see module docstring
direction: str = "longonly" # "longonly" | "both"
# Used for R-multiple when no stop is configured. Documented, not hidden.
risk_per_trade_pct: float = 0.02
def fingerprint(self) -> str:
"""Stable hash of everything that can change results -- determinism key."""
payload = json.dumps(asdict(self), sort_keys=True, default=str)
return hashlib.sha256(payload.encode()).hexdigest()[:16]
# --------------------------------------------------------------------------
# Strategy output
# --------------------------------------------------------------------------
@dataclass
class StrategyOutput:
"""Decisions aligned to bar *close*. The engine shifts them, not you."""
entries: pd.Series
exits: pd.Series
triggers: pd.Series | None = None
short_entries: pd.Series | None = None
short_exits: pd.Series | None = None
shifted: bool = False
def validate(self, index: pd.Index) -> None:
for name in ("entries", "exits", "short_entries", "short_exits"):
s = getattr(self, name)
if s is None:
continue
if not s.index.equals(index):
raise EngineError(f"{name} index does not match the price index")
def _shift_decisions(out: StrategyOutput) -> StrategyOutput:
"""Move every decision one bar forward, so it can only act at the next open."""
if out.shifted:
raise EngineError("decisions were already shifted; shift exactly once")
def sh(s):
if s is None:
return None
# Cast before filling: filling an object-dtype series with False and
# then downcasting is deprecated in pandas and would change behaviour.
return s.astype("boolean").shift(1).fillna(False).astype(bool)
def sh_obj(s):
if s is None:
return None
return s.shift(1)
return StrategyOutput(
entries=sh(out.entries),
exits=sh(out.exits),
triggers=sh_obj(out.triggers),
short_entries=sh(out.short_entries),
short_exits=sh(out.short_exits),
shifted=True,
)
# --------------------------------------------------------------------------
# Structural causality check
# --------------------------------------------------------------------------
def assert_causal(
indicator_fn,
prices: pd.DataFrame,
*,
probe_fracs: tuple[float, ...] = (0.4, 0.55, 0.7, 0.85),
bumps: tuple[float, ...] = (1.35, 0.65),
) -> None:
"""Fail if `indicator_fn`'s output before bar k depends on data after bar k.
`indicator_fn(prices) -> StrategyOutput | Series | DataFrame`. For each
probe point the tail of the price frame is scaled, and every output value
strictly before the probe must be unchanged.
The tail is scaled both up and down. One direction alone is not enough: a
boolean comparison that is already `True` can survive an upward bump
unchanged, so a peeking strategy would slip through. Perturbing both ways
forces any future-dependent comparison to flip in at least one of them.
"""
baseline = _as_frame(indicator_fn(prices))
n = len(prices)
for frac in probe_fracs:
k = int(n * frac)
if k < 2 or k >= n:
continue
for bump in bumps:
perturbed = prices.copy()
tail = perturbed.index[k:]
for col in ("open", "high", "low", "close"):
if col in perturbed.columns:
perturbed.loc[tail, col] = perturbed.loc[tail, col] * bump
if "volume" in perturbed.columns:
perturbed.loc[tail, "volume"] = perturbed.loc[tail, "volume"] * bump
probed = _as_frame(indicator_fn(perturbed))
a, b = baseline.iloc[:k], probed.iloc[:k]
if not _frames_equal(a, b):
where = _first_difference(a, b)
raise LookaheadError(
f"output changed before the perturbation point (bar {k}, "
f"{prices.index[k]}, tail scaled by {bump}): first divergence "
f"at {where}. The strategy is using information from after "
f"the bar it acts on."
)
def _as_frame(obj) -> pd.DataFrame:
if isinstance(obj, StrategyOutput):
cols = {"entries": obj.entries, "exits": obj.exits}
if obj.short_entries is not None:
cols["short_entries"] = obj.short_entries
if obj.short_exits is not None:
cols["short_exits"] = obj.short_exits
return pd.DataFrame(cols)
if isinstance(obj, pd.Series):
return obj.to_frame("value")
if isinstance(obj, pd.DataFrame):
return obj
raise EngineError(f"cannot interpret indicator output of type {type(obj)}")
def _frames_equal(a: pd.DataFrame, b: pd.DataFrame) -> bool:
if list(a.columns) != list(b.columns) or len(a) != len(b):
return False
for c in a.columns:
x, y = a[c], b[c]
if x.dtype == bool or y.dtype == bool:
xa = x.astype('boolean').fillna(False).astype(bool).to_numpy()
ya = y.astype('boolean').fillna(False).astype(bool).to_numpy()
if not (xa == ya).all():
return False
elif np.issubdtype(x.dtype, np.number):
if not np.allclose(x.fillna(0).to_numpy(), y.fillna(0).to_numpy(),
rtol=1e-12, atol=1e-12):
return False
else:
if not (x.fillna("").astype(str).to_numpy()
== y.fillna("").astype(str).to_numpy()).all():
return False
return True
def _first_difference(a: pd.DataFrame, b: pd.DataFrame) -> str:
for c in a.columns:
x = a[c].fillna(0) if np.issubdtype(a[c].dtype, np.number) else a[c].fillna("")
y = b[c].fillna(0) if np.issubdtype(b[c].dtype, np.number) else b[c].fillna("")
diff = np.where(x.to_numpy() != y.to_numpy())[0]
if len(diff):
return f"column {c!r}, bar {int(diff[0])} ({a.index[int(diff[0])]})"
return "unknown"
# --------------------------------------------------------------------------
# Validation plans
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class Window:
idx: int
train_start: pd.Timestamp
train_end: pd.Timestamp
test_start: pd.Timestamp
test_end: pd.Timestamp
def validate(self) -> None:
if self.train_start > self.train_end:
raise EngineError(f"window {self.idx}: inverted train range")
if self.test_start > self.test_end:
raise EngineError(f"window {self.idx}: inverted test range")
if self.test_start <= self.train_end:
raise EngineError(
f"window {self.idx}: test starts {self.test_start} which is not "
f"after train end {self.train_end} -- train/test would overlap"
)
@dataclass
class ValidationPlan:
mode: str
index: pd.DatetimeIndex
holdout_start: pd.Timestamp | None = None
is_end: pd.Timestamp | None = None
windows: list[Window] = field(default_factory=list)
# Honest notes about what this plan could not do, surfaced in the UI.
notes: list[str] = field(default_factory=list)
@property
def holdout_mask(self) -> pd.Series:
if self.holdout_start is None:
return pd.Series(False, index=self.index)
return pd.Series(self.index >= self.holdout_start, index=self.index)
def selectable_index(self) -> pd.DatetimeIndex:
"""Bars any parameter-selection path is permitted to touch.
The holdout is excluded here and nowhere else, so a caller cannot fit
on it by accident.
"""
if self.holdout_start is None:
return self.index
return self.index[self.index < self.holdout_start]
def segment_of(self, ts: pd.Timestamp) -> str:
if self.holdout_start is not None and ts >= self.holdout_start:
return "holdout"
if self.is_end is not None:
return "IS" if ts <= self.is_end else "OOS"
for w in self.windows:
if w.test_start <= ts <= w.test_end:
return "OOS"
return "IS"
def build_validation_plan(index: pd.DatetimeIndex, v: Validation) -> ValidationPlan:
if len(index) == 0:
return ValidationPlan(mode=v.mode, index=index)
start, end = index[0], index[-1]
# A locked holdout applies to every mode except "none". Walk-forward in
# particular must not roll its windows into reserved data -- the design
# shows rolling windows and an "OOS holdout LAST 6MO" side by side.
holdout_start = None
if v.mode != "none" and v.holdout_months > 0:
holdout_start = end - pd.DateOffset(months=v.holdout_months)
if holdout_start <= start:
holdout_start = None
plan = ValidationPlan(mode=v.mode, index=index, holdout_start=holdout_start)
if v.mode == "none":
plan.is_end = end
return plan
if v.mode == "split":
cut = int(len(index) * v.split_frac)
cut = min(max(cut, 1), len(index) - 1)
plan.is_end = index[cut - 1]
return plan
if v.mode == "holdout":
plan.is_end = (holdout_start - pd.Timedelta(seconds=1)) if holdout_start else end
return plan
if v.mode == "walk_forward":
usable = plan.selectable_index() if holdout_start is not None else index
if len(usable) == 0:
return plan
w_start = usable[0]
i = 0
last = usable[-1]
while True:
train_start = w_start
train_end = train_start + pd.DateOffset(months=v.train_months)
test_start = train_end
test_end = test_start + pd.DateOffset(months=v.test_months)
if test_start >= last:
break
# train_end is exclusive of the test side: nudge back one instant so
# the two never share a boundary bar.
win = Window(
idx=i,
train_start=train_start,
train_end=train_end - pd.Timedelta(seconds=1),
test_start=test_start,
test_end=min(test_end - pd.Timedelta(seconds=1), last),
)
win.validate()
plan.windows.append(win)
i += 1
w_start = w_start + pd.DateOffset(months=v.roll_months)
if i > 200:
break
if not plan.windows:
span_days = (usable[-1] - usable[0]).days
plan.notes.append(
f"Walk-forward produced no out-of-sample windows: the selectable "
f"period is {span_days} days, which is shorter than one "
f"{v.train_months}-month train plus {v.test_months}-month test "
f"window. There is no OOS result to report — widen the date range "
f"or shorten the training window."
)
return plan
raise EngineError(f"unknown validation mode {v.mode!r}")
# --------------------------------------------------------------------------
# Results
# --------------------------------------------------------------------------
@dataclass
class WindowResult:
window: Window
metrics: Metrics
equity: pd.Series
@dataclass
class BacktestResult:
config: BacktestConfig
equity: pd.Series
benchmark_equity: pd.Series
position: pd.Series
trades: pd.DataFrame
metrics_all: Metrics
metrics_is: Metrics
metrics_oos: Metrics
metrics_holdout: Metrics | None
plan: ValidationPlan
windows: list[WindowResult] = field(default_factory=list)
prices: pd.DataFrame | None = None
fingerprint: str = ""
elapsed_s: float = 0.0
@property
def costs_paid(self) -> float:
return float(self.trades["costs"].sum()) if len(self.trades) else 0.0
def summary(self) -> dict:
return {
"fingerprint": self.fingerprint,
"trades": int(len(self.trades)),
"total_return": self.metrics_all.total_return,
"oos_sharpe": self.metrics_oos.sharpe,
"max_drawdown": self.metrics_all.max_drawdown,
"costs_paid": self.costs_paid,
}
# --------------------------------------------------------------------------
# Sizing / slippage helpers
# --------------------------------------------------------------------------
def _size_args(prices: pd.DataFrame, sizing: Sizing) -> tuple:
"""Return (size, size_type) for vectorbt."""
if sizing.mode == "fixed_units":
return sizing.units, "amount"
if sizing.mode == "vol_target":
rets = prices["close"].pct_change()
realised = rets.rolling(sizing.vol_lookback).std()
bpy = config.bars_per_year("BTC-USD", "1d") # scale set by caller's tf
ann = realised * np.sqrt(bpy)
target = (sizing.vol_target_ann / ann.replace(0.0, np.nan))
target = target.clip(upper=sizing.max_position * sizing.leverage)
target = target.fillna(sizing.pct).clip(lower=0.0)
return target.to_numpy(), "percent"
frac = min(sizing.pct * sizing.leverage, sizing.max_position * sizing.leverage)
return float(frac), "percent"
def _slippage_arg(prices: pd.DataFrame, costs: Costs):
base = costs.slippage_rate
if base == 0.0:
return 0.0
if costs.slippage_model != "volume_scaled":
return base
vol = prices["volume"].astype("float64")
med = float(vol.replace(0.0, np.nan).median())
if not np.isfinite(med) or med <= 0:
return base
# Thin bars cost more to trade than typical ones.
scale = 1.0 + costs.volume_scale_k * ((med / vol.replace(0.0, np.nan)) - 1.0)
return (base * scale.clip(lower=0.5, upper=5.0).fillna(1.0)).to_numpy()
# --------------------------------------------------------------------------
# Trade list construction
# --------------------------------------------------------------------------
def _build_trades(
pf, prices: pd.DataFrame, triggers: pd.Series | None,
cfg: BacktestConfig, plan: ValidationPlan,
) -> pd.DataFrame:
"""Turn vectorbt's records into the trade list the UI and metrics use."""
cols = ["id", "entry_ts", "exit_ts", "side", "entry_px", "exit_px", "size",
"gross_pnl", "costs", "net_pnl", "r_multiple", "mae", "mfe",
"duration_bars", "trigger", "segment", "status"]
rec = pf.trades.records_readable
if rec is None or len(rec) == 0:
return pd.DataFrame(columns=cols)
idx = prices.index
pos_of = {ts: i for i, ts in enumerate(idx)}
slip = cfg.costs.slippage_rate
rows = []
# to_dict("records") preserves the spaced column names; itertuples would
# rename "Entry Timestamp" to a positional field.
for n, d in enumerate(rec.to_dict("records")):
entry_ts = pd.Timestamp(d["Entry Timestamp"])
exit_ts = pd.Timestamp(d["Exit Timestamp"])
size = float(d["Size"])
entry_px = float(d["Avg Entry Price"])
exit_px = float(d["Avg Exit Price"])
fees = float(d["Entry Fees"]) + float(d["Exit Fees"])
net = float(d["PnL"])
side = str(d["Direction"]).lower()
status = str(d.get("Status", "Closed"))
# Slippage is embedded in the fill price rather than booked as a fee, so
# it is reconstructed from the unslipped reference price. Slippage
# always works against the trade: a buy fills high, a sell fills low.
if slip:
if side == "long":
raw_entry, raw_exit = entry_px / (1 + slip), exit_px / (1 - slip)
else:
raw_entry, raw_exit = entry_px / (1 - slip), exit_px / (1 + slip)
slip_cost = slip * size * (raw_entry + raw_exit)
else:
slip_cost = 0.0
costs_total = fees + slip_cost
gross = net + costs_total
i0, i1 = pos_of.get(entry_ts), pos_of.get(exit_ts)
mae = mfe = float("nan")
if i0 is not None and i1 is not None and i1 >= i0:
window = prices.iloc[i0:i1 + 1]
lo, hi = float(window["low"].min()), float(window["high"].max())
if side == "long":
mae = lo / entry_px - 1.0
mfe = hi / entry_px - 1.0
else:
mae = 1.0 - hi / entry_px
mfe = 1.0 - lo / entry_px
if cfg.stops.sl_pct:
risk = cfg.stops.sl_pct * entry_px * size
else:
risk = cfg.risk_per_trade_pct * entry_px * size
r_mult = (net / risk) if risk else float("nan")
trig = ""
if triggers is not None and entry_ts in triggers.index:
v = triggers.loc[entry_ts]
trig = "" if (v is None or (isinstance(v, float) and np.isnan(v))) else str(v)
rows.append({
"id": n + 1,
"entry_ts": entry_ts, "exit_ts": exit_ts, "side": side,
"entry_px": entry_px, "exit_px": exit_px, "size": size,
"gross_pnl": gross, "costs": costs_total, "net_pnl": net,
"r_multiple": r_mult, "mae": mae, "mfe": mfe,
"duration_bars": (i1 - i0) if (i0 is not None and i1 is not None) else np.nan,
"trigger": trig,
"segment": plan.segment_of(entry_ts),
"status": status,
})
return pd.DataFrame(rows, columns=cols)
# --------------------------------------------------------------------------
# The run
# --------------------------------------------------------------------------
def run_backtest(
prices: pd.DataFrame,
output: StrategyOutput,
cfg: BacktestConfig,
*,
bars_per_year: float | None = None,
) -> BacktestResult:
"""Simulate `output` on `prices` under `cfg`.
`output` must carry decisions aligned to bar close; the shift to next-bar
execution is applied here and only here.
"""
import time
t0 = time.perf_counter()
if prices.empty:
raise EngineError("no price data for the requested range")
for c in ("open", "high", "low", "close"):
if c not in prices.columns:
raise EngineError(f"price frame missing {c!r}")
if cfg.fill != "next_open":
raise EngineError(
f"fill={cfg.fill!r} is not supported; next-bar-open execution is "
"non-negotiable in this engine"
)
prices = prices.sort_index()
output.validate(prices.index)
shifted = _shift_decisions(output)
if not shifted.shifted:
raise EngineError("internal: decisions were not shifted")
bpy = bars_per_year if bars_per_year is not None else config.bars_per_year(
cfg.asset, cfg.timeframe
)
plan = build_validation_plan(prices.index, cfg.validation)
size, size_type = _size_args(prices, cfg.sizing)
slippage = _slippage_arg(prices, cfg.costs)
kwargs = dict(
close=prices["close"],
entries=shifted.entries,
exits=shifted.exits,
price=prices["open"], # next-bar-open execution
open=prices["open"], high=prices["high"], low=prices["low"],
fees=cfg.costs.commission_rate,
slippage=slippage,
init_cash=cfg.init_cash,
size=size, size_type=size_type,
freq=pd.infer_freq(prices.index) or f"{config.TIMEFRAMES[cfg.timeframe].minutes}min",
direction=cfg.direction,
seed=0, # determinism
)
if shifted.short_entries is not None:
kwargs["short_entries"] = shifted.short_entries
kwargs["short_exits"] = shifted.short_exits
kwargs["direction"] = "both"
if cfg.stops.sl_pct is not None:
kwargs["sl_stop"] = cfg.stops.sl_pct
if cfg.stops.trail_pct is not None:
kwargs["sl_stop"] = cfg.stops.trail_pct
kwargs["sl_trail"] = True
if cfg.stops.tp_pct is not None:
kwargs["tp_stop"] = cfg.stops.tp_pct
pf = vbt.Portfolio.from_signals(**kwargs)
equity = pf.value()
try:
position = pf.asset_flow().cumsum()
except Exception:
position = pd.Series(0.0, index=prices.index)
trades = _build_trades(pf, prices, shifted.triggers, cfg, plan)
# Benchmark: buy and hold the same asset, same costs-free basis, so the
# comparison isolates the strategy rather than the fee schedule.
bench = prices["close"] / prices["close"].iloc[0] * cfg.init_cash
def slice_metrics(mask: pd.Series, seg: str) -> Metrics:
eq = equity[mask]
if len(eq) < 2:
return Metrics(segment=seg)
tr = trades[trades["segment"] == seg] if len(trades) else trades
return compute_metrics(eq, tr, bpy, segment=seg,
position=position[mask] if position is not None else None)
holdout_mask = plan.holdout_mask
in_holdout = holdout_mask.to_numpy()
if plan.is_end is not None:
is_mask = pd.Series((prices.index <= plan.is_end) & ~in_holdout, index=prices.index)
oos_mask = pd.Series((prices.index > plan.is_end) & ~in_holdout, index=prices.index)
else:
seg = pd.Series([plan.segment_of(t) for t in prices.index], index=prices.index)
is_mask = (seg == "IS") & ~in_holdout
oos_mask = (seg == "OOS") & ~in_holdout
metrics_all = compute_metrics(equity, trades, bpy, segment="all", position=position)
metrics_is = slice_metrics(is_mask, "IS")
metrics_oos = slice_metrics(oos_mask, "OOS")
metrics_holdout = slice_metrics(holdout_mask, "holdout") if in_holdout.any() else None
windows: list[WindowResult] = []
for w in plan.windows:
m = (prices.index >= w.test_start) & (prices.index <= w.test_end)
eq = equity[m]
if len(eq) < 2:
continue
tr = trades[(trades["entry_ts"] >= w.test_start)
& (trades["entry_ts"] <= w.test_end)] if len(trades) else trades
windows.append(WindowResult(
window=w,
metrics=compute_metrics(eq, tr, bpy, segment=f"W{w.idx + 1}"),
equity=eq,
))
return BacktestResult(
config=cfg, equity=equity, benchmark_equity=bench, position=position,
trades=trades, metrics_all=metrics_all, metrics_is=metrics_is,
metrics_oos=metrics_oos, metrics_holdout=metrics_holdout, plan=plan,
windows=windows, prices=prices, fingerprint=cfg.fingerprint(),
elapsed_s=time.perf_counter() - t0,
)