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,
    )