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.
- quantred/__init__.py +24 -0
- quantred/alphas/__init__.py +53 -0
- quantred/alphas/base.py +221 -0
- quantred/alphas/combine.py +260 -0
- quantred/alphas/horizon.py +153 -0
- quantred/alphas/refine.py +300 -0
- quantred/alphas/scorers.py +87 -0
- quantred/analytics/attribution.py +354 -0
- quantred/analytics/bootstrap.py +353 -0
- quantred/analytics/charts.py +393 -0
- quantred/analytics/excursion.py +185 -0
- quantred/analytics/execution_quality.py +180 -0
- quantred/analytics/exposure.py +167 -0
- quantred/analytics/htmlreport.py +1208 -0
- quantred/analytics/information.py +197 -0
- quantred/analytics/metrics.py +502 -0
- quantred/analytics/performance.py +639 -0
- quantred/analytics/reporting.py +1672 -0
- quantred/analytics/screening.py +190 -0
- quantred/analytics/series_comparison.py +303 -0
- quantred/analytics/trade_analytics.py +353 -0
- quantred/brokers/alpaca/broker.py +415 -0
- quantred/brokers/alpaca/factory.py +33 -0
- quantred/brokers/alpaca/market_data.py +166 -0
- quantred/brokers/base.py +220 -0
- quantred/brokers/dryrun.py +164 -0
- quantred/brokers/errors.py +70 -0
- quantred/cli.py +8619 -0
- quantred/costs/__init__.py +11 -0
- quantred/costs/base.py +126 -0
- quantred/costs/parametric.py +131 -0
- quantred/data/__init__.py +29 -0
- quantred/data/compute.py +332 -0
- quantred/data/edges.py +81 -0
- quantred/data/features.py +120 -0
- quantred/data/panel.py +61 -0
- quantred/data/scan.py +85 -0
- quantred/data/store.py +269 -0
- quantred/demo/__init__.py +22 -0
- quantred/demo/scanners.py +104 -0
- quantred/demo/strategies.py +125 -0
- quantred/engine/backtest.py +1206 -0
- quantred/engine/barcheck.py +228 -0
- quantred/engine/live.py +539 -0
- quantred/execution/decision.py +192 -0
- quantred/execution/flatten.py +302 -0
- quantred/execution/halt.py +165 -0
- quantred/execution/ledger.py +758 -0
- quantred/execution/live_trader.py +587 -0
- quantred/execution/order_id.py +83 -0
- quantred/execution/sizing.py +114 -0
- quantred/indicators/indicators.py +158 -0
- quantred/marketdata/base.py +62 -0
- quantred/marketdata/client.py +59 -0
- quantred/marketdata/session.py +112 -0
- quantred/marketdata/synthetic.py +74 -0
- quantred/marketdata/timeframe.py +125 -0
- quantred/mcp/__init__.py +6 -0
- quantred/mcp/selftest.py +139 -0
- quantred/mcp/server.py +2419 -0
- quantred/optimization/causality.py +607 -0
- quantred/optimization/config_store.py +300 -0
- quantred/optimization/optimizer.py +431 -0
- quantred/optimization/parallel.py +345 -0
- quantred/optimization/param_space.py +309 -0
- quantred/optimization/walk_forward.py +1254 -0
- quantred/portfolio/allocator.py +135 -0
- quantred/portfolio/benchmark.py +109 -0
- quantred/portfolio/optimizer.py +786 -0
- quantred/portfolio/policy.py +335 -0
- quantred/portfolio/posterior.py +208 -0
- quantred/research/__init__.py +30 -0
- quantred/research/agent.py +868 -0
- quantred/research/demo_proposals.py +222 -0
- quantred/research/llm.py +311 -0
- quantred/research/proposer.py +234 -0
- quantred/research/provider_errors.py +242 -0
- quantred/research/sandbox.py +340 -0
- quantred/risk/__init__.py +60 -0
- quantred/risk/base.py +218 -0
- quantred/risk/conditional.py +474 -0
- quantred/risk/exposures.py +88 -0
- quantred/risk/factor.py +119 -0
- quantred/risk/sample.py +89 -0
- quantred/risk/streaming.py +214 -0
- quantred/scanners/base.py +141 -0
- quantred/scanners/symbol_scanner.py +148 -0
- quantred/services/__init__.py +51 -0
- quantred/services/analysis.py +6082 -0
- quantred/services/audit.py +574 -0
- quantred/services/campaign.py +381 -0
- quantred/services/configs.py +282 -0
- quantred/services/data.py +172 -0
- quantred/services/dryrun.py +206 -0
- quantred/services/glossary.py +130 -0
- quantred/services/maintenance.py +314 -0
- quantred/services/registry.py +351 -0
- quantred/services/setup.py +1023 -0
- quantred/services/sizing.py +150 -0
- quantred/services/smallreal.py +450 -0
- quantred/settings.py +571 -0
- quantred/store/__init__.py +0 -0
- quantred/store/bars.py +660 -0
- quantred/store/trials.py +1804 -0
- quantred/strategies/base.py +643 -0
- quantred/strategies/signals.py +28 -0
- quantred/utils/errors.py +48 -0
- quantred/utils/extras.py +330 -0
- quantred/utils/logging_config.py +33 -0
- quantred/utils/numeric.py +46 -0
- quantred/utils/streaming.py +474 -0
- quantred/utils/timeutils.py +148 -0
- quantred-2.4.0.dist-info/METADATA +632 -0
- quantred-2.4.0.dist-info/RECORD +117 -0
- quantred-2.4.0.dist-info/WHEEL +4 -0
- quantred-2.4.0.dist-info/entry_points.txt +8 -0
- 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))
|