quantred 2.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. quantred/__init__.py +24 -0
  2. quantred/alphas/__init__.py +53 -0
  3. quantred/alphas/base.py +221 -0
  4. quantred/alphas/combine.py +260 -0
  5. quantred/alphas/horizon.py +153 -0
  6. quantred/alphas/refine.py +300 -0
  7. quantred/alphas/scorers.py +87 -0
  8. quantred/analytics/attribution.py +354 -0
  9. quantred/analytics/bootstrap.py +353 -0
  10. quantred/analytics/charts.py +393 -0
  11. quantred/analytics/excursion.py +185 -0
  12. quantred/analytics/execution_quality.py +180 -0
  13. quantred/analytics/exposure.py +167 -0
  14. quantred/analytics/htmlreport.py +1208 -0
  15. quantred/analytics/information.py +197 -0
  16. quantred/analytics/metrics.py +502 -0
  17. quantred/analytics/performance.py +639 -0
  18. quantred/analytics/reporting.py +1672 -0
  19. quantred/analytics/screening.py +190 -0
  20. quantred/analytics/series_comparison.py +303 -0
  21. quantred/analytics/trade_analytics.py +353 -0
  22. quantred/brokers/alpaca/broker.py +415 -0
  23. quantred/brokers/alpaca/factory.py +33 -0
  24. quantred/brokers/alpaca/market_data.py +166 -0
  25. quantred/brokers/base.py +220 -0
  26. quantred/brokers/dryrun.py +164 -0
  27. quantred/brokers/errors.py +70 -0
  28. quantred/cli.py +8619 -0
  29. quantred/costs/__init__.py +11 -0
  30. quantred/costs/base.py +126 -0
  31. quantred/costs/parametric.py +131 -0
  32. quantred/data/__init__.py +29 -0
  33. quantred/data/compute.py +332 -0
  34. quantred/data/edges.py +81 -0
  35. quantred/data/features.py +120 -0
  36. quantred/data/panel.py +61 -0
  37. quantred/data/scan.py +85 -0
  38. quantred/data/store.py +269 -0
  39. quantred/demo/__init__.py +22 -0
  40. quantred/demo/scanners.py +104 -0
  41. quantred/demo/strategies.py +125 -0
  42. quantred/engine/backtest.py +1206 -0
  43. quantred/engine/barcheck.py +228 -0
  44. quantred/engine/live.py +539 -0
  45. quantred/execution/decision.py +192 -0
  46. quantred/execution/flatten.py +302 -0
  47. quantred/execution/halt.py +165 -0
  48. quantred/execution/ledger.py +758 -0
  49. quantred/execution/live_trader.py +587 -0
  50. quantred/execution/order_id.py +83 -0
  51. quantred/execution/sizing.py +114 -0
  52. quantred/indicators/indicators.py +158 -0
  53. quantred/marketdata/base.py +62 -0
  54. quantred/marketdata/client.py +59 -0
  55. quantred/marketdata/session.py +112 -0
  56. quantred/marketdata/synthetic.py +74 -0
  57. quantred/marketdata/timeframe.py +125 -0
  58. quantred/mcp/__init__.py +6 -0
  59. quantred/mcp/selftest.py +139 -0
  60. quantred/mcp/server.py +2419 -0
  61. quantred/optimization/causality.py +607 -0
  62. quantred/optimization/config_store.py +300 -0
  63. quantred/optimization/optimizer.py +431 -0
  64. quantred/optimization/parallel.py +345 -0
  65. quantred/optimization/param_space.py +309 -0
  66. quantred/optimization/walk_forward.py +1254 -0
  67. quantred/portfolio/allocator.py +135 -0
  68. quantred/portfolio/benchmark.py +109 -0
  69. quantred/portfolio/optimizer.py +786 -0
  70. quantred/portfolio/policy.py +335 -0
  71. quantred/portfolio/posterior.py +208 -0
  72. quantred/research/__init__.py +30 -0
  73. quantred/research/agent.py +868 -0
  74. quantred/research/demo_proposals.py +222 -0
  75. quantred/research/llm.py +311 -0
  76. quantred/research/proposer.py +234 -0
  77. quantred/research/provider_errors.py +242 -0
  78. quantred/research/sandbox.py +340 -0
  79. quantred/risk/__init__.py +60 -0
  80. quantred/risk/base.py +218 -0
  81. quantred/risk/conditional.py +474 -0
  82. quantred/risk/exposures.py +88 -0
  83. quantred/risk/factor.py +119 -0
  84. quantred/risk/sample.py +89 -0
  85. quantred/risk/streaming.py +214 -0
  86. quantred/scanners/base.py +141 -0
  87. quantred/scanners/symbol_scanner.py +148 -0
  88. quantred/services/__init__.py +51 -0
  89. quantred/services/analysis.py +6082 -0
  90. quantred/services/audit.py +574 -0
  91. quantred/services/campaign.py +381 -0
  92. quantred/services/configs.py +282 -0
  93. quantred/services/data.py +172 -0
  94. quantred/services/dryrun.py +206 -0
  95. quantred/services/glossary.py +130 -0
  96. quantred/services/maintenance.py +314 -0
  97. quantred/services/registry.py +351 -0
  98. quantred/services/setup.py +1023 -0
  99. quantred/services/sizing.py +150 -0
  100. quantred/services/smallreal.py +450 -0
  101. quantred/settings.py +571 -0
  102. quantred/store/__init__.py +0 -0
  103. quantred/store/bars.py +660 -0
  104. quantred/store/trials.py +1804 -0
  105. quantred/strategies/base.py +643 -0
  106. quantred/strategies/signals.py +28 -0
  107. quantred/utils/errors.py +48 -0
  108. quantred/utils/extras.py +330 -0
  109. quantred/utils/logging_config.py +33 -0
  110. quantred/utils/numeric.py +46 -0
  111. quantred/utils/streaming.py +474 -0
  112. quantred/utils/timeutils.py +148 -0
  113. quantred-2.4.0.dist-info/METADATA +632 -0
  114. quantred-2.4.0.dist-info/RECORD +117 -0
  115. quantred-2.4.0.dist-info/WHEEL +4 -0
  116. quantred-2.4.0.dist-info/entry_points.txt +8 -0
  117. quantred-2.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1254 @@
1
+ """Walk-forward / out-of-sample validation.
2
+
3
+ The honest fitness function for the whole automation effort. The optimizer
4
+ (:class:`~quantred.optimization.optimizer.ParameterOptimizer`) tunes parameters on an
5
+ in-sample (IS) window; this module makes the chosen config prove itself on an
6
+ out-of-sample (OOS) window the optimizer never saw, across rolling/anchored
7
+ folds, with a holdout slice scored exactly once at the end.
8
+
9
+ Why it matters: the moment an agent can run hundreds of optimizations and read
10
+ the resulting Sharpe, it becomes a machine for *discovering noise*. Optimizing on
11
+ one slice and measuring on another is the structural defense (see the
12
+ walk-forward page in the engineering docs).
13
+
14
+ Key correctness properties:
15
+ * **No fold-boundary leakage.** Each OOS backtest fetches ``warmup`` bars before
16
+ ``oos_start`` so indicators are valid, but only trades entered at/after
17
+ ``oos_start`` are counted. The embargo (>= lookback) separates IS from OOS.
18
+ * **Variable-length folds ⇒ CAGR/annualized metrics**, never raw
19
+ total return.
20
+ * **The holdout is sacred** - computed first, subtracted from the fold region,
21
+ never passed to any optimizer call.
22
+ * **Determinism** - the optimizer seed is threaded so a run is reproducible.
23
+ * **Prefetch once, slice per fold** - the full window is fetched a single
24
+ time and sliced in memory for every fold/OOS run.
25
+ """
26
+
27
+ import logging
28
+ import math
29
+ from dataclasses import dataclass, field
30
+ from datetime import datetime, timedelta
31
+ from typing import Any, Dict, List, Optional, Tuple, Type
32
+
33
+ import numpy as np
34
+ import pandas as pd
35
+
36
+ from quantred.analytics import performance
37
+ from quantred.analytics.metrics import TRADING_DAYS_PER_YEAR, returns_from_equity
38
+ from quantred.costs.base import CostModel
39
+ from quantred.engine.backtest import BacktestEngine
40
+ from quantred.marketdata.base import BarHandler, MarketDataProvider
41
+ from quantred.marketdata.client import MarketDataClient
42
+ from quantred.marketdata.timeframe import Timeframe
43
+ from quantred.optimization.optimizer import ParameterOptimizer
44
+ from quantred.strategies.base import Strategy, build_with_limits
45
+ from quantred.utils.timeutils import shift_days
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+ #: Promotion gates. Config-driven so thresholds are tunable
50
+ #: without code changes; uses *median* (not mean) for WFE/Sharpe so one lucky
51
+ #: fold can't inflate the verdict.
52
+ #:
53
+ #: Calibration note. Portfolio accounting changed how two of these are
54
+ #: *measured*, so some thresholds had to move just to keep meaning what they meant:
55
+ #:
56
+ #: * **Sharpe scale shifted ~1.19x.** Measured over 12 runs (2 strategies x 3
57
+ #: universes x 2 windows), holding trades fixed and varying only the curve
58
+ #: construction: mark-to-market Sharpe ran 1.04-1.27x the old realized-P&L Sharpe
59
+ #: (median 1.19). The old curve booked P&L as a spike at exit, so its volatility
60
+ #: was overstated. ``min_oos_sharpe`` is rescaled 1.0 -> 1.2 to preserve the
61
+ #: original bar; leaving it at 1.0 would have quietly made the gate ~16% easier.
62
+ #: * **Drawdown scale held** (median 1.04x over the same runs), and the gate is a
63
+ #: ratio of two same-construction numbers, so ``max_dd_ratio`` is unchanged.
64
+ #:
65
+ #: What deliberately did NOT move: ``min_oos_trades``. Portfolio accounting reduced
66
+ #: trade counts sharply (one book, ``max_positions`` slots, instead of every symbol
67
+ #: trading its own full capital), so strategies now clear it far less often. That is
68
+ #: a genuine loss of evidence, not a measurement artifact - the sample really is
69
+ #: smaller. Lowering a statistical-power floor because results got worse is exactly
70
+ #: the gate-fitting this engine exists to prevent. Note the shipped strategies set
71
+ #: ``max_positions: 1``, which caps concurrency and makes 100 OOS trades hard to
72
+ #: reach; that is a strategy-config question, not a threshold question.
73
+ DEFAULT_GATES: Dict[str, float] = {
74
+ "min_oos_sharpe": 1.2, # median OOS Sharpe across folds (rescaled, see above)
75
+ "min_oos_profit_factor": 1.3, # median OOS profit factor (trade-level, unaffected)
76
+ "min_wfe": 0.4, # median walk-forward efficiency ...
77
+ "wfe_relaxed": 0.3, # ... or this if OOS Sharpe also clears target
78
+ "max_dd_ratio": 1.5, # OOS max drawdown <= this x IS max drawdown
79
+ "min_oos_trades": 100, # statistical-power floor (total OOS trades)
80
+ "max_param_sensitivity_loss": 0.25, # <=25% Sharpe loss under +-10% perturbation
81
+ "min_deflated_sharpe": 0.5, # DSR with n_trials_total
82
+ }
83
+
84
+
85
+ # --------------------------------------------------------------------------- #
86
+ # Result containers
87
+ # --------------------------------------------------------------------------- #
88
+ @dataclass
89
+ class Fold:
90
+ index: int
91
+ is_start: datetime
92
+ is_end: datetime
93
+ oos_start: datetime
94
+ oos_end: datetime
95
+
96
+
97
+ @dataclass
98
+ class FoldResult:
99
+ fold: Fold
100
+ is_best_params: Dict[str, Any]
101
+ is_metrics: Dict[str, float]
102
+ oos_metrics: Dict[str, float]
103
+ oos_trades: int
104
+ n_trials: int
105
+ #: Per-side realized performance for this fold's OOS window. Empty for a long-only
106
+ #: run, and betas are ``None`` unless the validator was given a benchmark.
107
+ oos_legs: Dict[str, Any] = field(default_factory=dict)
108
+
109
+
110
+ def _concat_trades(frames: List[pd.DataFrame]) -> Optional[pd.DataFrame]:
111
+ """Every fold's OOS trades as one frame, or ``None`` when there were none.
112
+
113
+ ``None`` rather than an empty frame: "this validation produced no trades" and
114
+ "no trade detail was kept" are different facts about a run.
115
+ """
116
+ usable = [f for f in frames if f is not None and not f.empty]
117
+ if not usable:
118
+ return None
119
+ return pd.concat(usable, ignore_index=True)
120
+
121
+
122
+ @dataclass
123
+ class WalkForwardResult:
124
+ folds: List[FoldResult]
125
+ oos_aggregate: Dict[str, float]
126
+ holdout: Optional[Dict[str, float]]
127
+ holdout_params: Optional[Dict[str, Any]]
128
+ efficiency: float
129
+ degradation: Dict[str, float]
130
+ n_trials_total: int
131
+ objective: str
132
+ pbo: Optional[float] = None
133
+ monte_carlo: Optional[Dict[str, float]] = None
134
+ diagnostics: Dict[str, Any] = field(default_factory=dict)
135
+ #: The concatenated OOS per-period return series (dated, daily-resampled -
136
+ #: same construction as the aggregate's realized-P&L fallback), when any OOS
137
+ #: trades exist. This is the "realized active-return series" the
138
+ #: bootstrap skill test resamples, and what gets persisted to the trial store
139
+ #: so a Reality Check has a real trial to join against later.
140
+ oos_returns: Optional[pd.Series] = None
141
+ #: Every out-of-sample trade across all folds, concatenated - the per-trade
142
+ #: detail behind the aggregate. Held only so a caller that asked to keep it
143
+ #: (trade-table persistence is opt-in) has something to keep; nothing in the
144
+ #: gates or the metrics reads it.
145
+ oos_trade_table: Optional[pd.DataFrame] = None
146
+
147
+ # --- derived summaries used by the gates ---
148
+ def median_oos(self, key: str) -> float:
149
+ values = [fr.oos_metrics.get(key, 0.0) for fr in self.folds]
150
+ return float(np.median(values)) if values else 0.0
151
+
152
+ def excess_return_by_fold(self) -> List[float]:
153
+ """Each fold's OOS return less the benchmark's over the *same* steps.
154
+
155
+ A different question from the information ratio: a strategy can have a good
156
+ risk-adjusted ratio while losing to the benchmark outright, and a promotion
157
+ decision cares about both. Empty when no benchmark was supplied, which callers
158
+ must treat as unevaluated rather than as zero excess.
159
+ """
160
+ out: List[float] = []
161
+ for fold_result in self.folds:
162
+ metrics = fold_result.oos_metrics
163
+ if not metrics.get("benchmark_available"):
164
+ continue
165
+ out.append(
166
+ float(metrics.get("total_return", 0.0)) - float(metrics.get("benchmark_buy_hold_return", 0.0))
167
+ )
168
+ return out
169
+
170
+ def median_oos_excess_return(self) -> Optional[float]:
171
+ """Median per-fold excess return, or ``None`` when no fold could measure one."""
172
+ excess = self.excess_return_by_fold()
173
+ return float(np.median(excess)) if excess else None
174
+
175
+ def leg_beta_by_fold(self) -> Dict[str, List[Optional[float]]]:
176
+ """Each leg's beta, fold by fold.
177
+
178
+ A book that is neutral *on average* and directional *within* folds is a
179
+ different proposition from one that is neutral throughout, and an aggregate
180
+ cannot distinguish them - which is the same reason the benchmark prerequisite
181
+ takes a median across folds rather than a figure over the stitched curve.
182
+
183
+ Lists are **fold-aligned**: entry ``i`` is fold ``i`` for every leg, and a fold
184
+ where a leg did not trade - or traded too little to regress - carries ``None``
185
+ rather than being skipped. Skipping shortened one list and not the other, so the
186
+ two stopped describing the same folds; the report prints them as aligned columns,
187
+ which then showed one fold's short beta under another fold's heading. A
188
+ per-fold diagnostic that cannot say which fold answers nothing.
189
+ """
190
+ # A leg that traded in no fold at all is not a leg this book has: a long-only
191
+ # book still reports a zero-trade ``short`` entry, and carrying it through as a
192
+ # column of ``None`` invites the reader to wonder which folds went short.
193
+ names = sorted(
194
+ {
195
+ name
196
+ for fold in self.folds
197
+ for name, leg in (fold.oos_legs or {}).items()
198
+ if (leg or {}).get("trades")
199
+ }
200
+ )
201
+ out: Dict[str, List[Optional[float]]] = {name: [] for name in names}
202
+ for fold_result in self.folds:
203
+ legs = fold_result.oos_legs or {}
204
+ for name in names:
205
+ leg = legs.get(name) or {}
206
+ # Absent is not zero, and here it is not "omit" either: no trades and no
207
+ # computable beta are both genuinely unknown for that fold.
208
+ out[name].append(leg.get("beta") if leg.get("trades") else None)
209
+ return out
210
+
211
+ def median_efficiency(self) -> float:
212
+ ratios = []
213
+ for fr in self.folds:
214
+ is_obj = fr.is_metrics.get(self.objective, 0.0)
215
+ oos_obj = fr.oos_metrics.get(self.objective, 0.0)
216
+ if is_obj not in (0.0, float("inf"), float("-inf")):
217
+ ratios.append(oos_obj / is_obj)
218
+ return float(np.median(ratios)) if ratios else 0.0
219
+
220
+ def total_oos_trades(self) -> int:
221
+ return int(sum(fr.oos_trades for fr in self.folds))
222
+
223
+ def gate_report(self, gates: Optional[Dict[str, float]] = None) -> Dict[str, Any]:
224
+ """Evaluate the promotion gates; a config is promotable only if all pass."""
225
+ g = {**DEFAULT_GATES, **(gates or {})}
226
+ median_sharpe = self.median_oos("sharpe_ratio")
227
+ median_pf = self.median_oos("profit_factor")
228
+ median_wfe = self.median_efficiency()
229
+ total_trades = self.total_oos_trades()
230
+ # Both sides of this ratio must come from the same curve construction, or
231
+ # the ratio measures the construction gap, not real IS/OOS divergence.
232
+ # Per-fold IS/OOS metrics are always mark-to-market (each fold is one
233
+ # continuous BacktestEngine.run()); only the *aggregate* across folds
234
+ # falls back to a realized-P&L reconstruction (no single simulation spans
235
+ # multiple folds) - so median per-fold OOS drawdown, not the aggregate's,
236
+ # is what's actually comparable to median per-fold IS drawdown.
237
+ oos_dd = float(np.median([fr.oos_metrics.get("max_drawdown", 0.0) for fr in self.folds] or [0.0]))
238
+ is_dd = float(np.median([fr.is_metrics.get("max_drawdown", 0.0) for fr in self.folds] or [0.0]))
239
+ dsr = self.oos_aggregate.get("deflated_sharpe_ratio", 0.0)
240
+
241
+ checks: Dict[str, Dict[str, Any]] = {
242
+ "oos_sharpe": _check(median_sharpe, ">=", g["min_oos_sharpe"]),
243
+ "oos_profit_factor": _check(median_pf, ">=", g["min_oos_profit_factor"]),
244
+ "walk_forward_efficiency": {
245
+ "value": median_wfe,
246
+ "threshold": g["min_wfe"],
247
+ "passed": median_wfe >= g["min_wfe"]
248
+ or (median_sharpe >= g["min_oos_sharpe"] and median_wfe >= g["wfe_relaxed"]),
249
+ },
250
+ "oos_drawdown_vs_is": {
251
+ "value": oos_dd,
252
+ "threshold": g["max_dd_ratio"] * is_dd if is_dd else float("inf"),
253
+ # With no OOS trades the drawdown is 0.0 because nothing traded, which
254
+ # would pass any threshold: a gate that passes only for lack of data.
255
+ "passed": total_trades > 0 and (is_dd == 0.0 or oos_dd <= g["max_dd_ratio"] * is_dd),
256
+ **(
257
+ {"note": "not evaluated - no OOS trades, so there is no drawdown to compare"}
258
+ if total_trades == 0
259
+ else {}
260
+ ),
261
+ },
262
+ "min_oos_trades": _check(total_trades, ">=", g["min_oos_trades"]),
263
+ "deflated_sharpe": _check(dsr, ">", g["min_deflated_sharpe"]),
264
+ }
265
+
266
+ # What each gate's number is computed over. The fold-median gates sit beside a
267
+ # stitched out-of-sample aggregate carrying the same metric names, and the two
268
+ # differ most when the folds disagree; unlabelled, a gate value of 0.21 next to
269
+ # an aggregate Sharpe of 0.20 read as a rounding error or a bug.
270
+ for name, basis in _GATE_BASIS.items():
271
+ if name in checks:
272
+ checks[name]["basis"] = basis
273
+
274
+ sensitivity = self.diagnostics.get("parameter_sensitivity")
275
+ if sensitivity is not None:
276
+ loss = sensitivity.get("max_sharpe_loss", 0.0)
277
+ checks["parameter_sensitivity"] = _check(loss, "<=", g["max_param_sensitivity_loss"])
278
+ leakage = self.diagnostics.get("leakage_probe")
279
+ if leakage is not None:
280
+ checks["leakage_probe"] = {
281
+ "value": leakage.get("passed"),
282
+ "threshold": True,
283
+ "passed": bool(leakage.get("passed")),
284
+ }
285
+
286
+ promotable = all(c["passed"] for c in checks.values())
287
+ return {"promotable": promotable, "checks": checks}
288
+
289
+
290
+ #: What a gate's value is computed over, carried on the check for every renderer.
291
+ _GATE_BASIS = {
292
+ "oos_sharpe": "median across folds",
293
+ "oos_profit_factor": "median across folds",
294
+ "walk_forward_efficiency": "median across folds",
295
+ "oos_drawdown_vs_is": "median across folds",
296
+ "min_oos_trades": "total across folds",
297
+ "deflated_sharpe": "stitched out-of-sample aggregate",
298
+ }
299
+
300
+
301
+ #: How far before a boundary a half-open window ends - below any bar's resolution.
302
+ _JUST_BEFORE = timedelta(microseconds=1)
303
+
304
+
305
+ def _check(value: float, op: str, threshold: float) -> Dict[str, Any]:
306
+ passed = value >= threshold if op == ">=" else value > threshold if op == ">" else value <= threshold
307
+ return {"value": float(value), "threshold": float(threshold), "passed": bool(passed)}
308
+
309
+
310
+ #: What each gate's numbers *are*, for anything that renders them. Declared here, beside
311
+ #: the checks that produce them, so a new gate states its unit where it is defined
312
+ #: rather than in whichever surface happens to print it. Unlisted gates are ratios.
313
+ #: The unit each named value is measured in, declared beside the things that produce
314
+ #: them rather than in whichever surface happens to render one. Gate names and metric
315
+ #: names share the table because they share the defect: a renderer that applies one
316
+ #: format to every number prints a trade count as ``25.00`` and a boolean as ``1.00``.
317
+ #:
318
+ #: Anything absent is a ratio, which is the safe default — being wrong about a ratio
319
+ #: costs two decimal places, and being wrong about a count costs credibility.
320
+ _GATE_VALUE_KINDS = {
321
+ # Promotion prerequisites: a cost multiple, a p-value and an excess return in percent.
322
+ "cost_stress": "multiple",
323
+ "family_bootstrap": "probability",
324
+ "benchmark_excess": "percent",
325
+ "min_oos_trades": "count",
326
+ # A drawdown in percent against a threshold in percent; printed bare, "10.59 vs
327
+ # 15.41" read as a ratio.
328
+ "oos_drawdown_vs_is": "percent",
329
+ "leakage_probe": "flag",
330
+ "total_trades": "count",
331
+ "oos_trades": "count",
332
+ "n_trials": "count",
333
+ "universe_size": "count",
334
+ "symbols_traded": "count",
335
+ "max_positions": "count",
336
+ "low_sample": "flag",
337
+ "benchmark_available": "flag",
338
+ "treynor_available": "flag",
339
+ "promotable": "flag",
340
+ "deflated_sharpe": "probability",
341
+ "deflated_sharpe_ratio": "probability",
342
+ "probabilistic_sharpe_ratio": "probability",
343
+ "pbo": "probability",
344
+ # Verdict gates the chart renders beside the terminal's `.4g`: an expected active
345
+ # return of 0.0035 is a pass that two decimals print as 0.00.
346
+ "net_of_cost_alpha": "fine",
347
+ "sample_size": "count",
348
+ "portfolio_feasible": "flag",
349
+ # Backtest metrics stored in percent units (the terminal prints each with a "%").
350
+ "total_return": "percent",
351
+ "buy_hold_return": "percent",
352
+ "benchmark_buy_hold_return": "percent",
353
+ "cagr": "percent",
354
+ "annualized_volatility": "percent",
355
+ "max_drawdown": "percent",
356
+ "var_95": "percent",
357
+ "var_99": "percent",
358
+ "cvar_95": "percent",
359
+ "exposure": "percent",
360
+ "win_rate": "percent",
361
+ "mae_pct": "percent",
362
+ "mfe_pct": "percent",
363
+ "downside_deviation": "percent",
364
+ "best_period": "percent",
365
+ "worst_period": "percent",
366
+ "avg_win": "money",
367
+ "avg_loss": "money",
368
+ "largest_win": "money",
369
+ "largest_loss": "money",
370
+ "expectancy": "money",
371
+ "max_drawdown_duration": "periods",
372
+ "avg_trade_duration": "hours",
373
+ "max_consecutive_wins": "count",
374
+ "max_consecutive_losses": "count",
375
+ "deflation_applied": "flag",
376
+ }
377
+
378
+ #: Every name with a declared unit, for renderers that walk a whole metrics dict.
379
+ UNIT_DECLARED = frozenset(_GATE_VALUE_KINDS)
380
+
381
+ #: Every name measured as a probability, for renderers that walk a whole metrics dict.
382
+ PROBABILITY_METRICS = frozenset(k for k, v in _GATE_VALUE_KINDS.items() if v == "probability")
383
+
384
+
385
+ def format_gate_value(gate_name: str, value: Any) -> str:
386
+ """One number as a string, in the unit that name is actually measured in.
387
+
388
+ Every renderer used to apply one format to all of them. `:.2f` is right for a
389
+ Sharpe and wrong for a trade count, which printed as `25.00`; `leakage_probe`
390
+ carries a *boolean*, which rendered as `1.00`, and its value is `None` when the
391
+ probe recorded no verdict, which raised `TypeError` inside a demo block written
392
+ never to hard-crash.
393
+
394
+ Named for gates because that is where the defect was first found; it takes any
395
+ name in :data:`_GATE_VALUE_KINDS`, and a second formatter for metrics would be the
396
+ same defect with a different spelling.
397
+ """
398
+ if value is None:
399
+ return "n/a"
400
+ kind = _GATE_VALUE_KINDS.get(gate_name, "ratio")
401
+ if kind == "flag" or isinstance(value, bool):
402
+ return "yes" if value else "no"
403
+ try:
404
+ number = float(value)
405
+ except (TypeError, ValueError):
406
+ return str(value)
407
+ if not math.isfinite(number):
408
+ return "unbounded" if number > 0 else str(number)
409
+ if kind == "count":
410
+ return f"{int(round(number))}"
411
+ if kind == "fine":
412
+ return f"{number:.4g}"
413
+ if kind == "percent":
414
+ return f"{number:.2f}%"
415
+ if kind == "money":
416
+ return f"-${abs(number):,.2f}" if number < 0 else f"${number:,.2f}"
417
+ if kind == "days":
418
+ return f"{number:.0f} days"
419
+ if kind == "multiple":
420
+ return f"{number:.1f}x"
421
+ if kind == "periods":
422
+ # Bars of the run's own timeframe: a drawdown lasting 40 periods is 40 days on
423
+ # daily bars and 40 minutes on minute bars, so it is not labelled as either.
424
+ return f"{number:.0f} bars"
425
+ if kind == "hours":
426
+ return f"{number:.2f} h"
427
+ if kind == "probability":
428
+ # Three decimals so a failing 0.496 does not print as the 0.5 gate it missed.
429
+ # Below that, "0.000" reads as "certainly zero" and the chart once printed the
430
+ # same value as 4.4e-04 — one number, two answers. Say it is small.
431
+ if 0.0 < number < 0.0005:
432
+ return "<0.001"
433
+ if 0.9995 <= number < 1.0:
434
+ return ">0.999"
435
+ return f"{number:.3f}"
436
+ return f"{number:.2f}"
437
+
438
+
439
+ # --------------------------------------------------------------------------- #
440
+ # Prefetch+slice data provider
441
+ # --------------------------------------------------------------------------- #
442
+ class PrefetchedProvider(MarketDataProvider):
443
+ """Serves slices of an in-memory ``{symbol: DataFrame}`` prefetched once.
444
+
445
+ Public because it is the mechanism behind "one process, one data fetch, N
446
+ evaluations" — every sweep that evaluates many configs over one window wants it,
447
+ not just this module.
448
+ """
449
+
450
+ def __init__(self, frames: Dict[str, pd.DataFrame]):
451
+ self._frames = frames
452
+
453
+ def get_bars(self, symbols, timeframe: Timeframe, start, end) -> Dict[str, pd.DataFrame]:
454
+ out: Dict[str, pd.DataFrame] = {}
455
+ for symbol in symbols:
456
+ df = self._frames.get(symbol)
457
+ if df is None or df.empty:
458
+ continue
459
+ lo, hi = _coerce_tz(start, df.index), _coerce_tz(end, df.index)
460
+ out[symbol] = df.loc[(df.index >= lo) & (df.index <= hi)].copy()
461
+ return out
462
+
463
+ async def stream_bars(self, symbols, handler: BarHandler) -> None: # pragma: no cover
464
+ raise NotImplementedError("prefetched provider does not stream")
465
+
466
+ def supports_streaming(self) -> bool:
467
+ return False
468
+
469
+
470
+ def _coerce_tz(when: datetime, index: pd.DatetimeIndex) -> pd.Timestamp:
471
+ ts = pd.Timestamp(when)
472
+ if index.tz is not None:
473
+ ts = ts.tz_localize(index.tz) if ts.tzinfo is None else ts.tz_convert(index.tz)
474
+ elif ts.tzinfo is not None:
475
+ ts = ts.tz_localize(None)
476
+ return ts
477
+
478
+
479
+ # --------------------------------------------------------------------------- #
480
+ # Validator
481
+ # --------------------------------------------------------------------------- #
482
+ class WalkForwardValidator:
483
+ """Orchestrates per-fold IS optimization + leakage-safe OOS evaluation."""
484
+
485
+ def __init__(
486
+ self,
487
+ strategy_class: Type[Strategy],
488
+ data_client: MarketDataClient,
489
+ initial_capital: float = 100_000.0,
490
+ seed: int = 42,
491
+ gates: Optional[Dict[str, float]] = None,
492
+ cost_model: Optional[CostModel] = None,
493
+ *,
494
+ trial_store: Optional[Any] = None,
495
+ strategy_name: Optional[str] = None,
496
+ cost_key: Optional[Dict[str, Any]] = None,
497
+ accounting: Optional[int] = None,
498
+ force: bool = False,
499
+ workers: Optional[int] = None,
500
+ data_spec: Optional[Any] = None,
501
+ benchmark: Optional[str] = None,
502
+ position_limits: Optional[Dict[str, Any]] = None,
503
+ ):
504
+ self.strategy_class = strategy_class
505
+ #: The book a config says it will trade. `position_limits` is not a tunable
506
+ #: param, so constructing a candidate from its params alone silently drops it
507
+ #: and validates against the strategy class's declared defaults instead - a
508
+ #: config asking for eight positions was validated at one. Validation and
509
+ #: deployment then describe different books, which is the one thing a
510
+ #: walk-forward exists to rule out.
511
+ self.position_limits = position_limits
512
+ self.data_client = data_client
513
+ self.initial_capital = initial_capital
514
+ self.seed = seed
515
+ self.gates = gates
516
+ #: Scored per fold, then aggregated by median - the same shape as every other
517
+ #: fold statistic here. A benchmark figure over the stitched OOS curve would be
518
+ #: a second aggregation convention in one report, differing from its neighbours
519
+ #: most exactly when the folds disagree, which is when a reader needs it least
520
+ #: ambiguous.
521
+ self.benchmark = benchmark
522
+ #: Charged on every simulated fill, in-sample and out. Gross-return validation
523
+ #: systematically promotes turnover the strategy could not afford live.
524
+ self.cost_model = cost_model
525
+ #: Forwarded to every internal `ParameterOptimizer` (per-fold IS search) so
526
+ #: a repeated candidate is served from the trial store instead of
527
+ #: re-simulated - same mechanism `optimize` uses standalone.
528
+ self.trial_store = trial_store
529
+ self.strategy_name = strategy_name
530
+ self.cost_key = cost_key
531
+ self.accounting = accounting
532
+ self.force = force
533
+ #: Forwarded to each fold's IS search. Parallelism goes *inside* a fold, not
534
+ #: across folds: the candidates are where the work is (one fold evaluates
535
+ #: `max_evals` of them), and nesting a pool inside a pool risks deadlock for
536
+ #: no additional throughput. Folds stay sequential, so the per-fold progress
537
+ #: a reader relies on stays in order.
538
+ self.workers = workers
539
+ self.data_spec = data_spec
540
+
541
+ # Read timeframe + lookback from a default instance (drives warmup/embargo).
542
+ defaults = {
543
+ name: spec["default"] for name, spec in strategy_class.PARAM_RANGES.items() if "default" in spec
544
+ }
545
+ probe = strategy_class(dict(defaults))
546
+ self.timeframe = Timeframe.parse(probe.config["timeframe"])
547
+ self.lookback_bars = int(probe.config.get("required_lookback_periods", 20))
548
+
549
+ # ------------------------------------------------------------------ #
550
+ # Fold construction
551
+ # ------------------------------------------------------------------ #
552
+ def build_folds(
553
+ self,
554
+ start: datetime,
555
+ end: datetime,
556
+ *,
557
+ mode: str = "anchored",
558
+ n_folds: Optional[int] = None,
559
+ train_days: Optional[int] = None,
560
+ test_days: Optional[int] = None,
561
+ embargo_days: Optional[int] = None,
562
+ holdout_days: int = 0,
563
+ ) -> "tuple[List[Fold], Optional[tuple[datetime, datetime]]]":
564
+ """Construct folds and (optionally) a disjoint holdout window.
565
+
566
+ The holdout is carved off the *end* first and never re-enters the fold
567
+ region, so it is provably disjoint from every IS/OOS window.
568
+ """
569
+ embargo = embargo_days if embargo_days is not None else self.default_embargo_days()
570
+ holdout = None
571
+ region_end = end
572
+ if holdout_days > 0:
573
+ holdout_start = shift_days(end, -holdout_days)
574
+ holdout = (holdout_start, end)
575
+ region_end = holdout_start
576
+
577
+ region_days = (region_end - start).days
578
+ if region_days <= embargo:
579
+ raise ValueError("Walk-forward region is too short for the requested embargo/holdout")
580
+
581
+ if train_days is None or test_days is None:
582
+ folds_n = n_folds or 4
583
+ # train + folds_n * test + embargo == region_days with train == test.
584
+ test_days = max(int((region_days - embargo) / (folds_n + 1)), 1)
585
+ train_days = test_days
586
+
587
+ folds: List[Fold] = []
588
+ index = 0
589
+ is_end = shift_days(start, train_days)
590
+ while True:
591
+ is_start = start if mode == "anchored" else shift_days(is_end, -train_days)
592
+ oos_start = shift_days(is_end, embargo)
593
+ oos_end = shift_days(oos_start, test_days)
594
+ if oos_end > region_end:
595
+ break
596
+ # Half-open: the next fold's out-of-sample window starts exactly at this
597
+ # boundary, and windows are sliced inclusive at both ends, so ending *at* it
598
+ # put the boundary bar in two folds - its return counted twice across the
599
+ # fold statistics. Ending just before it gives each bar to one fold.
600
+ folds.append(Fold(index, is_start, is_end, oos_start, oos_end - _JUST_BEFORE))
601
+ index += 1
602
+ is_end = shift_days(is_end, test_days)
603
+
604
+ if not folds:
605
+ raise ValueError("No folds generated; widen the window or reduce train/test/embargo")
606
+ return folds, holdout
607
+
608
+ def default_embargo_days(self) -> int:
609
+ """Lookback expressed in calendar days."""
610
+ bars_per_day = self._bars_per_day()
611
+ return max(math.ceil(self.lookback_bars / bars_per_day), 1)
612
+
613
+ def _make_optimizer(self, data_client: MarketDataClient) -> ParameterOptimizer:
614
+ """A `ParameterOptimizer` for one fold/holdout's IS search, carrying this
615
+ validator's memoization context - so a repeated IS candidate (across
616
+ folds, or a re-run of this validation) is served from the trial store."""
617
+ return ParameterOptimizer(
618
+ self.strategy_class,
619
+ data_client,
620
+ self.initial_capital,
621
+ self.seed,
622
+ self.cost_model,
623
+ trial_store=self.trial_store,
624
+ strategy_name=self.strategy_name,
625
+ cost_key=self.cost_key,
626
+ accounting=self.accounting,
627
+ force=self.force,
628
+ workers=self.workers,
629
+ data_spec=self.data_spec,
630
+ # The book the out-of-sample folds run at. Without it the search chose
631
+ # parameters for the class default and the folds scored them at another.
632
+ position_limits=self.position_limits,
633
+ )
634
+
635
+ def _bars_per_day(self) -> float:
636
+ tf = self.timeframe
637
+ if tf.unit == "day":
638
+ return 1.0 / tf.amount
639
+ if tf.unit == "week":
640
+ return 1.0 / (5.0 * tf.amount)
641
+ if tf.unit == "hour":
642
+ return 6.5 / tf.amount
643
+ return 6.5 * 60.0 / tf.amount # minutes
644
+
645
+ # ------------------------------------------------------------------ #
646
+ # Run
647
+ # ------------------------------------------------------------------ #
648
+ def _make(self, params: Dict[str, Any], strategy_class=None):
649
+ """Construct a candidate and give it the book the config asked for.
650
+
651
+ `position_limits` is not a tunable param, so building from params alone drops
652
+ it. Applied after construction for the same reason `_strategy_from` does it in
653
+ the CLI: it does not go through PARAM_RANGES validation, and a strategy built
654
+ from defaults would otherwise keep the defaults the config exists to override.
655
+ """
656
+ return build_with_limits(strategy_class or self.strategy_class, params, self.position_limits)
657
+
658
+ def run(
659
+ self,
660
+ symbols: List[str],
661
+ start: datetime,
662
+ end: datetime,
663
+ *,
664
+ mode: str = "anchored",
665
+ n_folds: Optional[int] = None,
666
+ train_days: Optional[int] = None,
667
+ test_days: Optional[int] = None,
668
+ embargo_days: Optional[int] = None,
669
+ holdout_days: int = 0,
670
+ method: str = "grid",
671
+ objective: str = "sharpe_ratio",
672
+ max_evals: int = 50,
673
+ pbo: bool = False,
674
+ monte_carlo: bool = False,
675
+ parameter_sensitivity: bool = False,
676
+ leakage_probe: bool = False,
677
+ n_trials_offset: int = 0,
678
+ ) -> WalkForwardResult:
679
+ embargo = embargo_days if embargo_days is not None else self.default_embargo_days()
680
+ folds, holdout = self.build_folds(
681
+ start,
682
+ end,
683
+ mode=mode,
684
+ n_folds=n_folds,
685
+ train_days=train_days,
686
+ test_days=test_days,
687
+ embargo_days=embargo,
688
+ holdout_days=holdout_days,
689
+ )
690
+ warmup_days = embargo # warmup buffer == embargo, by construction >= lookback
691
+
692
+ # Prefetch the whole window once (plus warmup) and slice per fold.
693
+ frames = self._fetch(symbols, start, end, embargo)
694
+ sliced = MarketDataClient(PrefetchedProvider(frames))
695
+
696
+ all_trial_sharpes: List[float] = []
697
+ fold_results: List[FoldResult] = []
698
+ oos_trade_frames: List[pd.DataFrame] = []
699
+
700
+ for fold in folds:
701
+ opt = self._make_optimizer(sliced)
702
+ is_result = self._optimize(opt, symbols, fold.is_start, fold.is_end, method, objective, max_evals)
703
+ if not is_result.best_params:
704
+ logger.warning("Fold %d produced no valid IS config; skipping", fold.index)
705
+ continue
706
+
707
+ is_metrics = self._row_metrics(is_result)
708
+ all_trial_sharpes.extend(self._trial_sharpes(is_result))
709
+
710
+ strategy = self._make(is_result.best_params)
711
+ oos_metrics, oos_trades, oos_legs = self._oos_backtest(
712
+ strategy,
713
+ sliced,
714
+ symbols,
715
+ fold.oos_start,
716
+ fold.oos_end,
717
+ warmup_days,
718
+ n_trials=len(is_result.results),
719
+ )
720
+ oos_trade_frames.append(oos_trades)
721
+ fold_results.append(
722
+ FoldResult(
723
+ fold=fold,
724
+ is_best_params=dict(is_result.best_params),
725
+ is_metrics=is_metrics,
726
+ oos_metrics=oos_metrics,
727
+ oos_trades=int(len(oos_trades)),
728
+ n_trials=len(is_result.results),
729
+ oos_legs=oos_legs,
730
+ )
731
+ )
732
+
733
+ # ``n_trials_offset`` lets a research session accumulate the
734
+ # multiple-testing count across many walk-forward runs, so the Deflated
735
+ # Sharpe reflects every config tried this session, not just this run.
736
+ n_trials_total = sum(fr.n_trials for fr in fold_results) + n_trials_offset
737
+ var_trial_sr = self._per_period_sr_variance(all_trial_sharpes)
738
+
739
+ oos_aggregate = self._aggregate_oos(oos_trade_frames, folds, n_trials_total, var_trial_sr)
740
+ efficiency = self._efficiency(fold_results, objective)
741
+ degradation = self._degradation(fold_results)
742
+
743
+ # Choose the production candidate by optimizing over the entire non-holdout
744
+ # region, then score the holdout exactly once.
745
+ holdout_metrics, holdout_params = None, None
746
+ if holdout is not None and fold_results:
747
+ holdout_params, holdout_metrics = self._holdout(
748
+ sliced, symbols, start, holdout, warmup_days, method, objective, max_evals
749
+ )
750
+
751
+ result = WalkForwardResult(
752
+ folds=fold_results,
753
+ oos_aggregate=oos_aggregate,
754
+ holdout=holdout_metrics,
755
+ holdout_params=holdout_params,
756
+ efficiency=efficiency,
757
+ degradation=degradation,
758
+ n_trials_total=n_trials_total,
759
+ objective=objective,
760
+ oos_returns=self._oos_return_series(oos_trade_frames),
761
+ oos_trade_table=_concat_trades(oos_trade_frames),
762
+ )
763
+
764
+ # Optional, costlier diagnostics.
765
+ if fold_results:
766
+ best = fold_results[-1].is_best_params
767
+ if parameter_sensitivity:
768
+ result.diagnostics["parameter_sensitivity"] = self.parameter_sensitivity(
769
+ sliced, symbols, best, folds[-1], warmup_days
770
+ )
771
+ if leakage_probe:
772
+ result.diagnostics["leakage_probe"] = self.leakage_probe(
773
+ sliced, frames, symbols, best, folds[-1], warmup_days
774
+ )
775
+ if monte_carlo:
776
+ result.monte_carlo = self.monte_carlo(oos_trade_frames)
777
+ if pbo:
778
+ result.pbo = self.estimate_pbo(oos_trade_frames, fold_results, objective)
779
+ return result
780
+
781
+ def evaluate_config(
782
+ self,
783
+ symbols: List[str],
784
+ start: datetime,
785
+ end: datetime,
786
+ params: Dict[str, Any],
787
+ *,
788
+ mode: str = "anchored",
789
+ n_folds: Optional[int] = None,
790
+ train_days: Optional[int] = None,
791
+ test_days: Optional[int] = None,
792
+ embargo_days: Optional[int] = None,
793
+ objective: str = "sharpe_ratio",
794
+ n_trials_offset: int = 0,
795
+ strategy_class: Optional[Type[Strategy]] = None,
796
+ ) -> WalkForwardResult:
797
+ """Validate a *fixed* config out-of-sample across folds (no per-fold search).
798
+
799
+ Used by the research agent: the agent proposes a specific
800
+ config, and this scores that exact config OOS, fold by fold, leakage-safe.
801
+ It counts as **one** trial for the multiple-testing correction; pass the
802
+ running session count via ``n_trials_offset`` so the Deflated Sharpe
803
+ reflects every config tried so far.
804
+ """
805
+ cls = strategy_class or self.strategy_class
806
+ embargo = embargo_days if embargo_days is not None else self.default_embargo_days()
807
+ folds, _ = self.build_folds(
808
+ start,
809
+ end,
810
+ mode=mode,
811
+ n_folds=n_folds,
812
+ train_days=train_days,
813
+ test_days=test_days,
814
+ embargo_days=embargo,
815
+ holdout_days=0,
816
+ )
817
+ warmup_days = embargo
818
+ frames = self._fetch(symbols, start, end, embargo)
819
+ sliced = MarketDataClient(PrefetchedProvider(frames))
820
+
821
+ fold_results: List[FoldResult] = []
822
+ oos_trade_frames: List[pd.DataFrame] = []
823
+ for fold in folds:
824
+ is_result = BacktestEngine(self._make(params, cls), sliced, cost_model=self.cost_model).run(
825
+ symbols, fold.is_start, fold.is_end, self.initial_capital
826
+ )
827
+ oos_metrics, oos_trades, oos_legs = self._oos_backtest(
828
+ self._make(params, cls),
829
+ sliced,
830
+ symbols,
831
+ fold.oos_start,
832
+ fold.oos_end,
833
+ warmup_days,
834
+ n_trials=1,
835
+ )
836
+ oos_trade_frames.append(oos_trades)
837
+ fold_results.append(
838
+ FoldResult(
839
+ fold=fold,
840
+ is_best_params=dict(params),
841
+ is_metrics=is_result.metrics,
842
+ oos_metrics=oos_metrics,
843
+ oos_trades=int(len(oos_trades)),
844
+ n_trials=1,
845
+ oos_legs=oos_legs,
846
+ )
847
+ )
848
+
849
+ n_trials_total = 1 + n_trials_offset # one distinct config evaluated this call
850
+ oos_aggregate = self._aggregate_oos(oos_trade_frames, folds, n_trials_total, None)
851
+ return WalkForwardResult(
852
+ folds=fold_results,
853
+ oos_aggregate=oos_aggregate,
854
+ holdout=None,
855
+ holdout_params=None,
856
+ efficiency=self._efficiency(fold_results, objective),
857
+ degradation=self._degradation(fold_results),
858
+ n_trials_total=n_trials_total,
859
+ objective=objective,
860
+ oos_returns=self._oos_return_series(oos_trade_frames),
861
+ oos_trade_table=_concat_trades(oos_trade_frames),
862
+ )
863
+
864
+ def score_window(
865
+ self,
866
+ symbols: List[str],
867
+ window_start: datetime,
868
+ window_end: datetime,
869
+ params: Dict[str, Any],
870
+ *,
871
+ embargo_days: Optional[int] = None,
872
+ n_trials: int = 1,
873
+ var_of_trial_sr: Optional[float] = None,
874
+ strategy_class: Optional[Type[Strategy]] = None,
875
+ ) -> Dict[str, float]:
876
+ """Leakage-safe metrics for a fixed config over a single window (e.g. holdout)."""
877
+ cls = strategy_class or self.strategy_class
878
+ warmup_days = embargo_days if embargo_days is not None else self.default_embargo_days()
879
+ frames = self._fetch(symbols, window_start, window_end, warmup_days)
880
+ sliced = MarketDataClient(PrefetchedProvider(frames))
881
+ metrics, _, _ = self._oos_backtest(
882
+ self._make(params, cls),
883
+ sliced,
884
+ symbols,
885
+ window_start,
886
+ window_end,
887
+ warmup_days,
888
+ n_trials=n_trials,
889
+ var_of_trial_sr=var_of_trial_sr,
890
+ )
891
+ return metrics
892
+
893
+ # ------------------------------------------------------------------ #
894
+ # Per-fold helpers
895
+ # ------------------------------------------------------------------ #
896
+ def _optimize(self, opt, symbols, is_start, is_end, method, objective, max_evals):
897
+ if method == "grid":
898
+ return opt.grid_search(symbols, is_start, is_end, objective, max_evals=max_evals)
899
+ if method == "random":
900
+ return opt.random_search(symbols, is_start, is_end, objective, n_samples=max_evals)
901
+ return opt.optimize_bayesian(symbols, is_start, is_end, objective)
902
+
903
+ def _oos_backtest(
904
+ self, strategy, client, symbols, oos_start, oos_end, warmup_days, n_trials, var_of_trial_sr=None
905
+ ):
906
+ """Backtest with warmup, trading only from ``oos_start``.
907
+
908
+ ``trade_from`` keeps the warmup bars out of the book, so the engine's own
909
+ portfolio equity curve *is* the OOS curve — mark-to-market, open positions
910
+ included. Reconstructing one from a filtered trade list (the fallback in
911
+ :meth:`_metrics_for_trades`) can only see realized P&L at exit.
912
+ """
913
+ fetch_start = shift_days(oos_start, -warmup_days)
914
+ result = BacktestEngine(strategy, client, cost_model=self.cost_model).run(
915
+ symbols,
916
+ fetch_start,
917
+ oos_end,
918
+ self.initial_capital,
919
+ trade_from=oos_start,
920
+ benchmark=self.benchmark,
921
+ )
922
+ oos_trades = _filter_trades_from(result.trades, oos_start) # belt and braces
923
+ metrics = self._metrics_for_trades(
924
+ oos_trades,
925
+ oos_start,
926
+ oos_end,
927
+ n_trials,
928
+ var_of_trial_sr,
929
+ equity=result.equity_curve,
930
+ # The engine's own alignment, against the very curve being re-measured.
931
+ # Deriving a second one here would be a second chance to disagree.
932
+ benchmark_returns=result.benchmark_returns,
933
+ )
934
+ return metrics, oos_trades, result.legs
935
+
936
+ def _fetch(self, symbols, start, end, warmup_days):
937
+ """The one fetch, refused when no symbol of the universe returned a bar.
938
+
939
+ Every fold then scored nothing, and the result reported a Sharpe of 0.0 and was
940
+ journaled as a trial - a measurement of a run that never saw the data. The
941
+ benchmark does not count: bars for it alone are still nothing to trade.
942
+ """
943
+ from quantred.utils.errors import no_bars_refusal
944
+
945
+ frames = self.data_client.get_bars(*self.prefetch_read(symbols, start, end, warmup_days))
946
+ if not any(frames.get(s) is not None and not frames[s].empty for s in symbols):
947
+ raise no_bars_refusal(symbols, start, end)
948
+ return frames
949
+
950
+ def prefetch_read(
951
+ self, symbols: List[str], start: datetime, end: datetime, embargo_days: Optional[int] = None
952
+ ) -> Tuple[List[str], Timeframe, datetime, datetime]:
953
+ """The one fetch a validation over ``[start, end]`` makes, as ``get_bars`` arguments.
954
+
955
+ The universe and the benchmark, from a warmup before ``start`` equal to the
956
+ embargo (the strategy's lookback by default). Public so an offline preflight
957
+ can ask the cache about exactly the read the run will make.
958
+ """
959
+ warmup = embargo_days if embargo_days is not None else self.default_embargo_days()
960
+ return self._with_benchmark(symbols), self.timeframe, shift_days(start, -warmup), end
961
+
962
+ def _with_benchmark(self, symbols):
963
+ """The universe plus the benchmark, which the folds fetch through a *prefetched*
964
+ provider and would otherwise have no bars for.
965
+
966
+ Missing it does not error - the engine warns and scores without a benchmark -
967
+ so the failure mode is a fold quietly reporting an information ratio of zero.
968
+ """
969
+ if not self.benchmark or self.benchmark in symbols:
970
+ return list(symbols)
971
+ return [*symbols, self.benchmark]
972
+
973
+ def _metrics_for_trades(
974
+ self, trades, start, end, n_trials, var_of_trial_sr, equity=None, benchmark_returns=None
975
+ ):
976
+ """Metrics for a trade set, preferring a real portfolio curve when available.
977
+
978
+ ``equity=None`` falls back to accumulating realized P&L, which is all that is
979
+ possible when the trades come from *several* folds and no single simulation
980
+ produced them (see :meth:`_aggregate_oos`).
981
+ """
982
+ # The two curve sources run on different clocks: the engine samples per bar,
983
+ # the realized-P&L fallback resamples to calendar days. Annualize accordingly.
984
+ if equity is None:
985
+ equity = performance.build_equity_curve(trades, self.initial_capital)
986
+ periods_per_year = TRADING_DAYS_PER_YEAR
987
+ # A rebuilt curve runs on a different clock (calendar days, not bars), so a
988
+ # series aligned to the engine's curve no longer lines up with it. Dropped
989
+ # rather than misaligned: a positional pairing that silently slips is worse
990
+ # than an absent benchmark, because it still produces a number.
991
+ benchmark_returns = None
992
+ else:
993
+ periods_per_year = self.timeframe.periods_per_year()
994
+ final = self.initial_capital + (trades["pnl"].sum() if not trades.empty else 0.0)
995
+ return performance.compute_backtest_metrics(
996
+ trades,
997
+ equity,
998
+ self.initial_capital,
999
+ final,
1000
+ {},
1001
+ start=start,
1002
+ end=end,
1003
+ periods_per_year=periods_per_year,
1004
+ n_trials=n_trials,
1005
+ var_of_trial_sr=var_of_trial_sr,
1006
+ benchmark_returns=benchmark_returns,
1007
+ )
1008
+
1009
+ def _aggregate_oos(self, oos_trade_frames, folds, n_trials_total, var_trial_sr):
1010
+ trades = _concat_trades(oos_trade_frames)
1011
+ span_start = min((f.oos_start for f in folds), default=None)
1012
+ span_end = max((f.oos_end for f in folds), default=None)
1013
+ return self._metrics_for_trades(trades, span_start, span_end, n_trials_total, var_trial_sr)
1014
+
1015
+ def _oos_return_series(self, oos_trade_frames) -> Optional[pd.Series]:
1016
+ """The OOS aggregate's per-period return series, dated - the same
1017
+ construction ``_aggregate_oos`` falls back to internally (concatenated
1018
+ multi-fold trades have no single continuous simulation curve), kept here
1019
+ with its ``DatetimeIndex`` rather than reduced to a metrics dict."""
1020
+ trades = _concat_trades(oos_trade_frames)
1021
+ equity = performance.build_dated_equity_curve(trades, self.initial_capital)
1022
+ if equity.empty:
1023
+ return None
1024
+ returns = returns_from_equity(equity)
1025
+ return returns if not returns.empty else None
1026
+
1027
+ def _holdout(self, client, symbols, region_start, holdout, warmup_days, method, objective, max_evals):
1028
+ holdout_start, holdout_end = holdout
1029
+ # Optimize over everything before the holdout (the production training set).
1030
+ opt = self._make_optimizer(client)
1031
+ final = self._optimize(opt, symbols, region_start, holdout_start, method, objective, max_evals)
1032
+ if not final.best_params:
1033
+ return None, None
1034
+ strategy = self._make(final.best_params)
1035
+ metrics, _, _ = self._oos_backtest(
1036
+ strategy, client, symbols, holdout_start, holdout_end, warmup_days, n_trials=len(final.results)
1037
+ )
1038
+ return dict(final.best_params), metrics
1039
+
1040
+ # ------------------------------------------------------------------ #
1041
+ # Aggregate diagnostics
1042
+ # ------------------------------------------------------------------ #
1043
+ @staticmethod
1044
+ def _row_metrics(is_result) -> Dict[str, float]:
1045
+ if is_result.results.empty:
1046
+ return {}
1047
+ row = is_result.results.iloc[0]
1048
+ return {
1049
+ k: (float(v) if isinstance(v, (int, float, np.floating, np.integer)) else v)
1050
+ for k, v in row.items()
1051
+ if k in performance.METRIC_KEYS
1052
+ }
1053
+
1054
+ @staticmethod
1055
+ def _trial_sharpes(is_result) -> List[float]:
1056
+ if "sharpe_ratio" in is_result.results.columns:
1057
+ return [float(v) for v in is_result.results["sharpe_ratio"].to_numpy() if np.isfinite(v)]
1058
+ return []
1059
+
1060
+ @staticmethod
1061
+ def _per_period_sr_variance(annualized_sharpes: List[float]) -> Optional[float]:
1062
+ """Variance of *per-period* trial Sharpes (the DSR needs per-period units)."""
1063
+ if len(annualized_sharpes) < 2:
1064
+ return None
1065
+ per_period = np.array(annualized_sharpes) / math.sqrt(TRADING_DAYS_PER_YEAR)
1066
+ return float(np.var(per_period, ddof=1))
1067
+
1068
+ def _efficiency(self, fold_results, objective) -> float:
1069
+ is_objs = [fr.is_metrics.get(objective, 0.0) for fr in fold_results]
1070
+ oos_objs = [fr.oos_metrics.get(objective, 0.0) for fr in fold_results]
1071
+ is_objs = [v for v in is_objs if v not in (float("inf"), float("-inf"))]
1072
+ oos_objs = [v for v in oos_objs if v not in (float("inf"), float("-inf"))]
1073
+ mean_is = np.mean(is_objs) if is_objs else 0.0
1074
+ if mean_is == 0:
1075
+ return 0.0
1076
+ return float(np.mean(oos_objs) / mean_is)
1077
+
1078
+ @staticmethod
1079
+ def _degradation(fold_results) -> Dict[str, float]:
1080
+ keys = ("sharpe_ratio", "sortino_ratio", "profit_factor", "max_drawdown", "cagr")
1081
+ out: Dict[str, float] = {}
1082
+ for key in keys:
1083
+ is_vals = [fr.is_metrics.get(key, 0.0) for fr in fold_results]
1084
+ oos_vals = [fr.oos_metrics.get(key, 0.0) for fr in fold_results]
1085
+ is_vals = [v for v in is_vals if v not in (float("inf"), float("-inf"))]
1086
+ oos_vals = [v for v in oos_vals if v not in (float("inf"), float("-inf"))]
1087
+ if is_vals and oos_vals:
1088
+ out[key] = float(np.mean(is_vals) - np.mean(oos_vals))
1089
+ return out
1090
+
1091
+ # ------------------------------------------------------------------ #
1092
+ # Optional robustness diagnostics
1093
+ # ------------------------------------------------------------------ #
1094
+ def parameter_sensitivity(
1095
+ self, client, symbols, best_params, fold, warmup_days, perturbation: float = 0.10
1096
+ ) -> Dict[str, float]:
1097
+ """Perturb each searched param +-``perturbation`` and re-backtest the OOS.
1098
+
1099
+ A robust optimum loses little Sharpe; a config perched on a spike is
1100
+ overfit. Returns the worst observed Sharpe loss fraction.
1101
+ """
1102
+ space = ParameterOptimizer(
1103
+ self.strategy_class, client, self.initial_capital, self.seed, self.cost_model
1104
+ ).space
1105
+ base = self._oos_sharpe(client, symbols, best_params, fold, warmup_days)
1106
+ worst_loss = 0.0
1107
+ details: Dict[str, float] = {}
1108
+ for name in space.searchable:
1109
+ spec = space.param_ranges[name]
1110
+ for direction in (1 - perturbation, 1 + perturbation):
1111
+ candidate = dict(best_params)
1112
+ value = best_params[name] * direction
1113
+ value = min(spec["max"], max(spec["min"], value))
1114
+ candidate[name] = int(round(value)) if spec["type"] == "int" else value
1115
+ sharpe = self._oos_sharpe(client, symbols, candidate, fold, warmup_days)
1116
+ loss = (base - sharpe) / abs(base) if base else 0.0
1117
+ details[f"{name}:{'+' if direction > 1 else '-'}"] = sharpe
1118
+ worst_loss = max(worst_loss, loss)
1119
+ return {"base_sharpe": base, "max_sharpe_loss": float(worst_loss), "perturbed": details}
1120
+
1121
+ def _oos_sharpe(self, client, symbols, params, fold, warmup_days) -> float:
1122
+ strategy = self._make(params)
1123
+ metrics, _, _ = self._oos_backtest(
1124
+ strategy, client, symbols, fold.oos_start, fold.oos_end, warmup_days, n_trials=1
1125
+ )
1126
+ return metrics.get("sharpe_ratio", 0.0)
1127
+
1128
+ def leakage_probe(self, client, frames, symbols, best_params, fold, warmup_days) -> Dict[str, Any]:
1129
+ """Re-run the OOS with bars shifted forward; identical results => leakage.
1130
+
1131
+ Operationalizes the fold-boundary leakage check: if the strategy reads future data, shifting
1132
+ the feed forward leaves results unchanged. A *clean* strategy's results
1133
+ change materially, so we **fail** when they don't.
1134
+ """
1135
+ strategy = self._make(best_params)
1136
+ _, base_trades, _ = self._oos_backtest(
1137
+ strategy, client, symbols, fold.oos_start, fold.oos_end, warmup_days, n_trials=1
1138
+ )
1139
+
1140
+ shifted = {s: df.copy() for s, df in frames.items()}
1141
+ for df in shifted.values():
1142
+ # Shift OHLCV forward by 5 bars: bar t now carries data from t+5.
1143
+ for col in ("open", "high", "low", "close", "volume"):
1144
+ if col in df:
1145
+ df[col] = df[col].shift(-5)
1146
+ df.dropna(inplace=True)
1147
+ shifted_client = MarketDataClient(PrefetchedProvider(shifted))
1148
+ strategy2 = self._make(best_params)
1149
+ _, shifted_trades, _ = self._oos_backtest(
1150
+ strategy2, shifted_client, symbols, fold.oos_start, fold.oos_end, warmup_days, n_trials=1
1151
+ )
1152
+
1153
+ same_count = len(base_trades) == len(shifted_trades)
1154
+ # If both empty we can't conclude; treat as pass (nothing traded).
1155
+ if base_trades.empty and shifted_trades.empty:
1156
+ return {"passed": True, "reason": "no OOS trades to probe"}
1157
+ identical = same_count and _pnl_close(base_trades, shifted_trades)
1158
+ return {
1159
+ "passed": not identical,
1160
+ "base_trades": int(len(base_trades)),
1161
+ "shifted_trades": int(len(shifted_trades)),
1162
+ }
1163
+
1164
+ def monte_carlo(
1165
+ self, oos_trade_frames, n_resamples: int = 1000, block: int = 20, seed: int = 0
1166
+ ) -> Dict[str, float]:
1167
+ """Block-bootstrap the OOS trade sequence -> distribution of outcomes.
1168
+
1169
+ Reports the 5th-percentile Sharpe ("how bad is a plausibly unlucky run").
1170
+ """
1171
+ trades = _concat_trades(oos_trade_frames)
1172
+ pnl = trades["pnl"].to_numpy() if not trades.empty else np.array([])
1173
+ if len(pnl) < block:
1174
+ return {"samples": 0}
1175
+ rng = np.random.default_rng(seed)
1176
+ sharpes, returns = [], []
1177
+ n_blocks = math.ceil(len(pnl) / block)
1178
+ for _ in range(n_resamples):
1179
+ starts = rng.integers(0, len(pnl) - block + 1, size=n_blocks)
1180
+ sample = np.concatenate([pnl[s : s + block] for s in starts])[: len(pnl)]
1181
+ std = sample.std()
1182
+ sharpes.append(math.sqrt(len(sample)) * sample.mean() / std if std > 0 else 0.0)
1183
+ returns.append(sample.sum())
1184
+ return {
1185
+ "samples": n_resamples,
1186
+ "sharpe_p05": float(np.percentile(sharpes, 5)),
1187
+ "sharpe_p50": float(np.percentile(sharpes, 50)),
1188
+ "return_p05": float(np.percentile(returns, 5)),
1189
+ }
1190
+
1191
+ def estimate_pbo(self, oos_trade_frames, fold_results, objective) -> Optional[float]:
1192
+ """Probability of backtest overfitting via a simple CSCV-style estimate.
1193
+
1194
+ Splits folds into IS/OOS halves over all combinations; PBO is the fraction
1195
+ of splits where the IS-best fold ranks below the OOS median. Needs >= 4
1196
+ folds to be meaningful.
1197
+ """
1198
+ from itertools import combinations
1199
+
1200
+ n = len(fold_results)
1201
+ if n < 4:
1202
+ return None
1203
+ is_scores = np.array([fr.is_metrics.get(objective, 0.0) for fr in fold_results])
1204
+ oos_scores = np.array([fr.oos_metrics.get(objective, 0.0) for fr in fold_results])
1205
+ is_scores = np.nan_to_num(is_scores, posinf=1e6, neginf=-1e6)
1206
+ oos_scores = np.nan_to_num(oos_scores, posinf=1e6, neginf=-1e6)
1207
+
1208
+ indices = range(n)
1209
+ below = total = 0
1210
+ for combo in combinations(indices, n // 2):
1211
+ train = list(combo)
1212
+ test = [i for i in indices if i not in combo]
1213
+ if not train or not test:
1214
+ continue
1215
+ best_train = train[int(np.argmax(is_scores[train]))]
1216
+ median_test = np.median(oos_scores[test])
1217
+ below += oos_scores[best_train] < median_test
1218
+ total += 1
1219
+ return float(below / total) if total else None
1220
+
1221
+
1222
+ # --------------------------------------------------------------------------- #
1223
+ # Trade-frame utilities
1224
+ # --------------------------------------------------------------------------- #
1225
+ def _filter_trades_from(trades: pd.DataFrame, oos_start: datetime) -> pd.DataFrame:
1226
+ """Keep trades whose entry is at/after ``oos_start`` (tz-aware safe)."""
1227
+ if trades.empty or "entry_time" not in trades:
1228
+ return trades
1229
+ entry = pd.to_datetime(trades["entry_time"])
1230
+ if getattr(entry.dt, "tz", None) is not None:
1231
+ entry = entry.dt.tz_localize(None)
1232
+ mask = (
1233
+ entry >= pd.Timestamp(oos_start).tz_localize(None)
1234
+ if pd.Timestamp(oos_start).tzinfo
1235
+ else entry >= pd.Timestamp(oos_start)
1236
+ )
1237
+ return trades[mask.to_numpy()].reset_index(drop=True)
1238
+
1239
+
1240
+ def _concat_trades(frames: List[pd.DataFrame]) -> pd.DataFrame:
1241
+ non_empty = [f for f in frames if not f.empty]
1242
+ if not non_empty:
1243
+ return pd.DataFrame()
1244
+ combined = pd.concat(non_empty, ignore_index=True)
1245
+ if "exit_time" in combined:
1246
+ combined = combined.sort_values("exit_time").reset_index(drop=True)
1247
+ return combined
1248
+
1249
+
1250
+ def _pnl_close(a: pd.DataFrame, b: pd.DataFrame, tol: float = 1e-6) -> bool:
1251
+ if len(a) != len(b):
1252
+ return False
1253
+ pa, pb = a["pnl"].to_numpy(), b["pnl"].to_numpy()
1254
+ return bool(np.allclose(np.sort(pa), np.sort(pb), atol=tol))