Spaces:
Running on Zero
Running on Zero
| """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 | |
| # -------------------------------------------------------------------------- | |
| 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 | |
| def commission_rate(self) -> float: | |
| return (self.commission_bps / 10_000.0) if self.enabled else 0.0 | |
| def slippage_rate(self) -> float: | |
| return (self.slippage_bps / 10_000.0) if self.enabled else 0.0 | |
| 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 | |
| class Stops: | |
| sl_pct: float | None = None | |
| tp_pct: float | None = None | |
| trail_pct: float | None = None | |
| def any_set(self) -> bool: | |
| return any(v is not None for v in (self.sl_pct, self.tp_pct, self.trail_pct)) | |
| 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 | |
| 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 | |
| # -------------------------------------------------------------------------- | |
| 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 | |
| # -------------------------------------------------------------------------- | |
| 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" | |
| ) | |
| 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) | |
| 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 | |
| # -------------------------------------------------------------------------- | |
| class WindowResult: | |
| window: Window | |
| metrics: Metrics | |
| equity: pd.Series | |
| 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 | |
| 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, | |
| ) | |