Spaces:
Running on Zero
Running on Zero
File size: 26,045 Bytes
46f1a78 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 | """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,
)
|