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,639 @@
|
|
|
1
|
+
"""Backtest performance accounting - i.e. the part that delivers the bad news.
|
|
2
|
+
|
|
3
|
+
Turns a table of completed trades plus an equity curve into a metrics dict. This
|
|
4
|
+
logic lives here - not inside ``Strategy`` or the engine - so strategies stay
|
|
5
|
+
about signals and the engine stays about orchestration (separation of concerns).
|
|
6
|
+
|
|
7
|
+
The metric *formulas* all live in :mod:`quantred.analytics.metrics`; this module only composes them over the trade table
|
|
8
|
+
and equity curve.
|
|
9
|
+
|
|
10
|
+
Caveat: the metrics are only as good as the curve handed in. The engine's own curve
|
|
11
|
+
is marked to market at each bar's close, which a backtest and each walk-forward fold
|
|
12
|
+
use. ``build_equity_curve`` instead accumulates *closed-trade* P&L resampled to
|
|
13
|
+
calendar-daily - the fallback when no single simulation produced the trades, such as
|
|
14
|
+
a walk-forward's aggregate across folds - and there intra-trade drawdown is not
|
|
15
|
+
captured, so ``max_drawdown``/``ulcer_index``/volatility understate risk during long
|
|
16
|
+
holds. The metrics glossary states which basis applies where.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from typing import Any, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
import pandas as pd
|
|
24
|
+
|
|
25
|
+
from quantred.analytics import metrics as m
|
|
26
|
+
|
|
27
|
+
#: Below this many trades a result is statistically low-power.
|
|
28
|
+
LOW_SAMPLE_TRADES = 30
|
|
29
|
+
|
|
30
|
+
#: The metric keys always present in a results dict (handy for optimizers/reports).
|
|
31
|
+
#: Existing keys keep their name, meaning and order (backward compatibility);
|
|
32
|
+
#: new keys are appended by tier.
|
|
33
|
+
METRIC_KEYS = (
|
|
34
|
+
# --- original headline set ---
|
|
35
|
+
"total_return",
|
|
36
|
+
"buy_hold_return",
|
|
37
|
+
"sharpe_ratio",
|
|
38
|
+
"sortino_ratio",
|
|
39
|
+
"calmar_ratio",
|
|
40
|
+
"max_drawdown",
|
|
41
|
+
"total_trades",
|
|
42
|
+
"win_rate",
|
|
43
|
+
"profit_factor",
|
|
44
|
+
"avg_win",
|
|
45
|
+
"avg_loss",
|
|
46
|
+
"largest_win",
|
|
47
|
+
"largest_loss",
|
|
48
|
+
# --- Tier 1: required for honest evaluation ---
|
|
49
|
+
"cagr",
|
|
50
|
+
"annualized_volatility",
|
|
51
|
+
"max_drawdown_duration",
|
|
52
|
+
"exposure",
|
|
53
|
+
"probabilistic_sharpe_ratio",
|
|
54
|
+
"deflated_sharpe_ratio",
|
|
55
|
+
# --- Tier 2: valuable ---
|
|
56
|
+
"recovery_factor",
|
|
57
|
+
"ulcer_index",
|
|
58
|
+
"martin_ratio",
|
|
59
|
+
"sterling_ratio",
|
|
60
|
+
"var_95",
|
|
61
|
+
"var_99",
|
|
62
|
+
"cvar_95",
|
|
63
|
+
"expectancy",
|
|
64
|
+
"payoff_ratio",
|
|
65
|
+
"gain_to_pain_ratio",
|
|
66
|
+
"kelly_criterion",
|
|
67
|
+
"sqn",
|
|
68
|
+
"max_consecutive_wins",
|
|
69
|
+
"max_consecutive_losses",
|
|
70
|
+
"avg_trade_duration",
|
|
71
|
+
"mae_pct",
|
|
72
|
+
"mfe_pct",
|
|
73
|
+
"alpha",
|
|
74
|
+
"beta",
|
|
75
|
+
"r_squared",
|
|
76
|
+
"treynor_ratio",
|
|
77
|
+
"information_ratio",
|
|
78
|
+
"benchmark_buy_hold_return",
|
|
79
|
+
# --- Tier 3: nice-to-have ---
|
|
80
|
+
"skew",
|
|
81
|
+
"kurtosis",
|
|
82
|
+
"downside_deviation",
|
|
83
|
+
"tail_ratio",
|
|
84
|
+
"omega_ratio",
|
|
85
|
+
"best_period",
|
|
86
|
+
"worst_period",
|
|
87
|
+
"turnover",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
#: Metrics a parameter search may maximise: higher is better and the number measures
|
|
92
|
+
#: the strategy. The search always maximises, so a lower-is-better metric
|
|
93
|
+
#: (``max_drawdown``, ``annualized_volatility``, ``var_95``) as the objective picked
|
|
94
|
+
#: the *worst* candidate, and one that does not depend on the strategy
|
|
95
|
+
#: (``buy_hold_return``) picked arbitrarily. ``total_trades`` is excluded because
|
|
96
|
+
#: maximising it selects for turnover, not edge. ``calmar_ratio`` and
|
|
97
|
+
#: ``sterling_ratio`` were held out while stored results could carry their old
|
|
98
|
+
#: (wrong-signed) values; the accounting v6 bump retired every such result, since a
|
|
99
|
+
#: search reuses only same-version candidates.
|
|
100
|
+
#:
|
|
101
|
+
#: A new metric becomes an objective by being added here as well as to the metrics
|
|
102
|
+
#: dict - deliberately, so its direction is a decision rather than an accident.
|
|
103
|
+
OBJECTIVE_KEYS = (
|
|
104
|
+
"sharpe_ratio",
|
|
105
|
+
"sortino_ratio",
|
|
106
|
+
"calmar_ratio",
|
|
107
|
+
"sterling_ratio",
|
|
108
|
+
"martin_ratio",
|
|
109
|
+
"omega_ratio",
|
|
110
|
+
"gain_to_pain_ratio",
|
|
111
|
+
"information_ratio",
|
|
112
|
+
"treynor_ratio",
|
|
113
|
+
"probabilistic_sharpe_ratio",
|
|
114
|
+
"deflated_sharpe_ratio",
|
|
115
|
+
"total_return",
|
|
116
|
+
"cagr",
|
|
117
|
+
"profit_factor",
|
|
118
|
+
"recovery_factor",
|
|
119
|
+
"expectancy",
|
|
120
|
+
"payoff_ratio",
|
|
121
|
+
"sqn",
|
|
122
|
+
"alpha",
|
|
123
|
+
"win_rate",
|
|
124
|
+
"kelly_criterion",
|
|
125
|
+
"tail_ratio",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
#: Non-numeric flags also always present in a results dict.
|
|
129
|
+
#:
|
|
130
|
+
#: ``treynor_available`` is separate from ``benchmark_available`` because the two fail
|
|
131
|
+
#: independently: a benchmark can be present and Treynor still meaningless, when the
|
|
132
|
+
#: book's beta is too near zero to divide by. Without the flag a suppressed ratio and a
|
|
133
|
+
#: genuine zero are the same number.
|
|
134
|
+
FLAG_KEYS = ("benchmark_available", "low_sample", "treynor_available", "deflation_applied")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
#: Metrics that are regressions against a benchmark. Without one they are stored as 0.0,
|
|
138
|
+
#: which is a placeholder and not a measurement.
|
|
139
|
+
BENCHMARK_DEPENDENT_METRICS = ("alpha", "beta", "r_squared", "information_ratio", "benchmark_buy_hold_return")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def unmeasured_metrics(metrics: Dict[str, Any]) -> Dict[str, str]:
|
|
143
|
+
"""Metric name -> why it was not measured, for the ones stored as a placeholder zero.
|
|
144
|
+
|
|
145
|
+
``treynor_available`` and ``benchmark_available`` exist because a suppressed ratio and
|
|
146
|
+
a genuine zero are the same number; this is the reader of those flags, so a renderer
|
|
147
|
+
that shows a stored value cannot show a zero beside a flag saying it never existed.
|
|
148
|
+
"""
|
|
149
|
+
out: Dict[str, str] = {}
|
|
150
|
+
if not metrics.get("benchmark_available", True):
|
|
151
|
+
for name in (*BENCHMARK_DEPENDENT_METRICS, "treynor_ratio"):
|
|
152
|
+
if name in metrics:
|
|
153
|
+
out[name] = "no benchmark"
|
|
154
|
+
elif not metrics.get("treynor_available", True) and "treynor_ratio" in metrics:
|
|
155
|
+
out["treynor_ratio"] = "beta too close to zero"
|
|
156
|
+
return out
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
#: Thresholds for the executability verdict. Judgment calls, not evidence-derived:
|
|
160
|
+
#: they mark where an executed book stops resembling the one that was validated, and
|
|
161
|
+
#: they are exposed so a deployment can set its own. Deliberately **separate** from the
|
|
162
|
+
#: promotion gates - those ask whether the edge was real, this asks whether the book
|
|
163
|
+
#: can be traded at this capital, and collapsing the two would make one number mean two
|
|
164
|
+
#: things and silently redefine `promotable` for every trial already recorded.
|
|
165
|
+
DEFAULT_EXECUTION_LIMITS: Dict[str, float] = {
|
|
166
|
+
"max_rounding_drag_pct": 10.0, # executed notional this far below intended
|
|
167
|
+
"max_unfillable_pct": 5.0, # this share of intended entries never opened
|
|
168
|
+
"max_cost_share_of_gross_pct": 40.0, # transaction cost as a share of gross profit
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#: What each execution check's numbers *are*, declared beside the checks that produce
|
|
172
|
+
#: them rather than in whichever surface renders them. The renderer assumed percent for
|
|
173
|
+
#: all of them, so ``book_breadth`` - a position *count* - printed as "a maximum of
|
|
174
|
+
#: 1.00% positions". Same defect, same fix as ``walk_forward.format_gate_value``: the
|
|
175
|
+
#: unit belongs to the check, not to the formatter. Every check must appear here;
|
|
176
|
+
#: ``test_diagnostic_surfaces`` walks a real verdict and fails on any that does not,
|
|
177
|
+
#: so a new check cannot reach a surface without saying what its number means.
|
|
178
|
+
EXECUTION_VALUE_KINDS: Dict[str, str] = {
|
|
179
|
+
"rounding_drag": "percent",
|
|
180
|
+
"unfillable_entries": "percent",
|
|
181
|
+
"book_breadth": "count",
|
|
182
|
+
"cost_share_of_gross": "percent",
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def format_execution_value(check_name: str, value: Any) -> str:
|
|
187
|
+
"""One execution-check number as a string, in the unit that check is measured in."""
|
|
188
|
+
if value is None:
|
|
189
|
+
return "n/a"
|
|
190
|
+
if isinstance(value, bool):
|
|
191
|
+
return "yes" if value else "no"
|
|
192
|
+
try:
|
|
193
|
+
number = float(value)
|
|
194
|
+
except (TypeError, ValueError):
|
|
195
|
+
return str(value)
|
|
196
|
+
kind = EXECUTION_VALUE_KINDS.get(check_name, "percent")
|
|
197
|
+
if kind == "count":
|
|
198
|
+
return f"{int(round(number))}"
|
|
199
|
+
return f"{number:.2f}%"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def execution_verdict(
|
|
203
|
+
execution: Optional[Dict[str, Any]], limits: Optional[Dict[str, float]] = None
|
|
204
|
+
) -> Dict[str, Any]:
|
|
205
|
+
"""Whether the book the engine actually traded resembles the one it was asked for.
|
|
206
|
+
|
|
207
|
+
Whole-share rounding is invisible and scales with how small the account is: the
|
|
208
|
+
same config that drags 0.3% at $100,000 drags 5% at $4,000 and 22% at $500, and
|
|
209
|
+
nothing in the result said so. This turns that into a check with its numbers shown,
|
|
210
|
+
in the house style - every threshold beside its value, no single reassuring summary.
|
|
211
|
+
|
|
212
|
+
Returns ``executable: None`` when there is nothing to judge (no entries attempted),
|
|
213
|
+
which is not the same as passing and must not be rendered as one.
|
|
214
|
+
"""
|
|
215
|
+
checks: Dict[str, Any] = {}
|
|
216
|
+
if not execution or not (
|
|
217
|
+
execution.get("positions_filled", 0)
|
|
218
|
+
or execution.get("positions_rounded_to_zero", 0)
|
|
219
|
+
or execution.get("positions_below_min_notional", 0)
|
|
220
|
+
):
|
|
221
|
+
return {"executable": None, "checks": checks, "reason": "no entries were attempted"}
|
|
222
|
+
|
|
223
|
+
limit = {**DEFAULT_EXECUTION_LIMITS, **(limits or {})}
|
|
224
|
+
drag = float(execution.get("rounding_drag_pct", 0.0))
|
|
225
|
+
unfillable = float(execution.get("unfillable_pct", 0.0))
|
|
226
|
+
checks["rounding_drag"] = {
|
|
227
|
+
"value": drag,
|
|
228
|
+
"threshold": limit["max_rounding_drag_pct"],
|
|
229
|
+
"passed": drag <= limit["max_rounding_drag_pct"],
|
|
230
|
+
"note": "share rounding removed this much of the intended notional at this capital",
|
|
231
|
+
}
|
|
232
|
+
checks["unfillable_entries"] = {
|
|
233
|
+
"value": unfillable,
|
|
234
|
+
"threshold": limit["max_unfillable_pct"],
|
|
235
|
+
"passed": unfillable <= limit["max_unfillable_pct"],
|
|
236
|
+
"note": "this share of intended entries could not be opened at all",
|
|
237
|
+
}
|
|
238
|
+
# Against *gross profit*, not capital: the same cost is unremarkable against a
|
|
239
|
+
# large gross return and fatal against a small one, and the two denominators
|
|
240
|
+
# disagree most exactly when the answer matters. Skipped rather than guessed when
|
|
241
|
+
# the strategy did not make money gross - there is no edge for cost to eat, and a
|
|
242
|
+
# ratio against a non-positive denominator would be arithmetic rather than a fact.
|
|
243
|
+
# Breadth: not "is the cap small" - a strategy that concentrates in the best five
|
|
244
|
+
# of sixty is a legitimate design - but "was the cap ever chosen". A one-position
|
|
245
|
+
# book selected from many candidates is a different strategy from a many-position
|
|
246
|
+
# one, and 1 is what every shipped strategy declares by default, so this is
|
|
247
|
+
# overwhelmingly a setting nobody changed rather than a decision anybody made.
|
|
248
|
+
max_positions = execution.get("max_positions")
|
|
249
|
+
universe_size = int(execution.get("universe_size") or 0)
|
|
250
|
+
if max_positions is not None and universe_size > 1:
|
|
251
|
+
checks["book_breadth"] = {
|
|
252
|
+
"value": float(max_positions),
|
|
253
|
+
"threshold": 2.0,
|
|
254
|
+
"passed": float(max_positions) > 1,
|
|
255
|
+
"note": (
|
|
256
|
+
f"the book holds at most {max_positions:g} of {universe_size} candidates"
|
|
257
|
+
+ (
|
|
258
|
+
" - 1 is the shipped default; set it to the book you intend"
|
|
259
|
+
if float(max_positions) <= 1
|
|
260
|
+
else ""
|
|
261
|
+
)
|
|
262
|
+
),
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
gross_profit = float(execution.get("gross_profit", 0.0))
|
|
266
|
+
if gross_profit > 0:
|
|
267
|
+
cost_share = float(execution.get("total_cost", 0.0)) / gross_profit * 100.0
|
|
268
|
+
checks["cost_share_of_gross"] = {
|
|
269
|
+
"value": cost_share,
|
|
270
|
+
"threshold": limit["max_cost_share_of_gross_pct"],
|
|
271
|
+
"passed": cost_share <= limit["max_cost_share_of_gross_pct"],
|
|
272
|
+
"note": "transaction cost ate this share of the gross profit",
|
|
273
|
+
}
|
|
274
|
+
failed = [name for name, check in checks.items() if not check["passed"]]
|
|
275
|
+
return {
|
|
276
|
+
"executable": not failed,
|
|
277
|
+
"checks": checks,
|
|
278
|
+
"reason": "" if not failed else f"{', '.join(failed)} beyond limit at this capital",
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
#: Prerequisites checked *before* promotion, beside `promotable` rather than inside it.
|
|
283
|
+
#:
|
|
284
|
+
#: `promotable` stays statistical - median OOS Sharpe, efficiency, drawdown ratio,
|
|
285
|
+
#: deflated Sharpe - and keeps meaning for every trial already recorded. These are the
|
|
286
|
+
#: questions asked of a candidate that has already cleared those: does the edge survive
|
|
287
|
+
#: worse cost assumptions, and does it still look like skill once the whole family of
|
|
288
|
+
#: trials is priced in?
|
|
289
|
+
#:
|
|
290
|
+
#: `min_family_trials` is the interesting one. A family test over two series is
|
|
291
|
+
#: arithmetic rather than evidence, so the check does not run at all below the floor -
|
|
292
|
+
#: and reports that it did not, rather than passing by default. That is also what makes
|
|
293
|
+
#: this safe to add: a campaign with a thin family is told the gate is unevaluated, not
|
|
294
|
+
#: told it passed.
|
|
295
|
+
DEFAULT_PREREQUISITES: Dict[str, float] = {
|
|
296
|
+
"min_cost_stress_multiple": 3.0,
|
|
297
|
+
"max_family_p": 0.05,
|
|
298
|
+
"min_family_trials": 10,
|
|
299
|
+
"min_benchmark_ir": 0.0, # median per-fold OOS information ratio
|
|
300
|
+
"min_benchmark_excess_pct": 0.0, # median per-fold OOS return less the benchmark's
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def promotion_prerequisites(
|
|
305
|
+
*,
|
|
306
|
+
cost_stress: Optional[Dict[str, Any]] = None,
|
|
307
|
+
bootstrap: Optional[Dict[str, Any]] = None,
|
|
308
|
+
benchmark_ir: Optional[float] = None,
|
|
309
|
+
benchmark_excess_pct: Optional[float] = None,
|
|
310
|
+
limits: Optional[Dict[str, float]] = None,
|
|
311
|
+
bootstrap_unavailable: Optional[str] = None,
|
|
312
|
+
) -> Dict[str, Any]:
|
|
313
|
+
"""Checks a candidate should clear before paper, reported beside ``promotable``.
|
|
314
|
+
|
|
315
|
+
Every check carries ``evaluated``. A check whose input is absent is *not* a pass:
|
|
316
|
+
a cost curve nobody ran and a family too thin to test are both "unknown", and
|
|
317
|
+
rendering unknown as green is the failure this whole section exists to avoid.
|
|
318
|
+
|
|
319
|
+
``ready`` is ``None`` when nothing could be evaluated, ``False`` when an evaluated
|
|
320
|
+
check failed, ``True`` only when every evaluated check passed - and it never
|
|
321
|
+
implies the unevaluated ones would have.
|
|
322
|
+
|
|
323
|
+
``bootstrap_unavailable`` is why a surface supplies no bootstrap at all. Without it
|
|
324
|
+
the family check says "0 available", which reads as a campaign too thin to test
|
|
325
|
+
when the truth is that this surface never asked.
|
|
326
|
+
"""
|
|
327
|
+
limit = {**DEFAULT_PREREQUISITES, **(limits or {})}
|
|
328
|
+
checks: Dict[str, Any] = {}
|
|
329
|
+
|
|
330
|
+
survives = (cost_stress or {}).get("survives_to_multiple")
|
|
331
|
+
checks["cost_stress"] = {
|
|
332
|
+
"evaluated": survives is not None,
|
|
333
|
+
"value": float(survives) if survives is not None else None,
|
|
334
|
+
"threshold": limit["min_cost_stress_multiple"],
|
|
335
|
+
"passed": survives is not None and float(survives) >= limit["min_cost_stress_multiple"],
|
|
336
|
+
"note": "the edge survives this multiple of its own assumed cost",
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
family = (bootstrap or {}).get("family") or {}
|
|
340
|
+
n_used = int(family.get("n_used") or 0)
|
|
341
|
+
family_p = family.get("family_p")
|
|
342
|
+
enough_family = bool(family.get("available")) and n_used >= limit["min_family_trials"]
|
|
343
|
+
checks["family_bootstrap"] = {
|
|
344
|
+
"evaluated": enough_family and family_p is not None,
|
|
345
|
+
"value": float(family_p) if family_p is not None else None,
|
|
346
|
+
"threshold": limit["max_family_p"],
|
|
347
|
+
"passed": enough_family and family_p is not None and float(family_p) <= limit["max_family_p"],
|
|
348
|
+
"n_used": n_used,
|
|
349
|
+
"note": (
|
|
350
|
+
bootstrap_unavailable
|
|
351
|
+
if bootstrap is None and bootstrap_unavailable
|
|
352
|
+
else f"needs {int(limit['min_family_trials'])} usable return-series trials to mean "
|
|
353
|
+
f"anything; {n_used} available"
|
|
354
|
+
if not enough_family
|
|
355
|
+
else "still notable once every trial the campaign tried is priced in"
|
|
356
|
+
),
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
# Median across folds, not a figure over the stitched OOS curve. Every other fold
|
|
360
|
+
# statistic here is a median (`min_oos_sharpe`, `min_wfe`), and a second aggregation
|
|
361
|
+
# convention in one report would differ from its neighbours most exactly when the
|
|
362
|
+
# folds disagree - which is when a reader can least afford the ambiguity.
|
|
363
|
+
checks["benchmark_relative"] = {
|
|
364
|
+
"evaluated": benchmark_ir is not None,
|
|
365
|
+
"value": float(benchmark_ir) if benchmark_ir is not None else None,
|
|
366
|
+
"threshold": limit["min_benchmark_ir"],
|
|
367
|
+
"passed": benchmark_ir is not None and float(benchmark_ir) > limit["min_benchmark_ir"],
|
|
368
|
+
"note": "median per-fold information ratio against the benchmark",
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
# A different question from the ratio above, and a strategy can pass one while
|
|
372
|
+
# failing the other: a good risk-adjusted number is compatible with losing to the
|
|
373
|
+
# benchmark outright, which is not something to promote on.
|
|
374
|
+
checks["benchmark_excess"] = {
|
|
375
|
+
"evaluated": benchmark_excess_pct is not None,
|
|
376
|
+
"value": float(benchmark_excess_pct) if benchmark_excess_pct is not None else None,
|
|
377
|
+
"threshold": limit["min_benchmark_excess_pct"],
|
|
378
|
+
"passed": benchmark_excess_pct is not None
|
|
379
|
+
and float(benchmark_excess_pct) > limit["min_benchmark_excess_pct"],
|
|
380
|
+
"note": "median per-fold return less the benchmark's over the same steps",
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
evaluated = [check for check in checks.values() if check["evaluated"]]
|
|
384
|
+
ready = None if not evaluated else all(check["passed"] for check in evaluated)
|
|
385
|
+
return {"ready": ready, "checks": checks, "evaluated": len(evaluated), "total": len(checks)}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def empty_metrics() -> Dict[str, float]:
|
|
389
|
+
"""A zeroed metrics dict, returned when no trades occurred."""
|
|
390
|
+
base = {key: 0.0 for key in METRIC_KEYS}
|
|
391
|
+
base["benchmark_available"] = False
|
|
392
|
+
base["low_sample"] = True
|
|
393
|
+
base["treynor_available"] = False
|
|
394
|
+
base["deflation_applied"] = False
|
|
395
|
+
return base
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def compute_backtest_metrics(
|
|
399
|
+
trades_df: pd.DataFrame,
|
|
400
|
+
equity_curve: List[float],
|
|
401
|
+
initial_capital: float,
|
|
402
|
+
final_capital: float,
|
|
403
|
+
market_data: Dict[str, Dict[str, float]],
|
|
404
|
+
*,
|
|
405
|
+
start: Optional[datetime] = None,
|
|
406
|
+
end: Optional[datetime] = None,
|
|
407
|
+
periods_per_year: int = m.TRADING_DAYS_PER_YEAR,
|
|
408
|
+
benchmark_returns: Optional[pd.Series] = None,
|
|
409
|
+
n_trials: int = 1,
|
|
410
|
+
var_of_trial_sr: Optional[float] = None,
|
|
411
|
+
) -> Dict[str, float]:
|
|
412
|
+
"""Compute headline performance metrics for a completed backtest.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
trades_df: One row per closed trade; must include a ``pnl`` column (and,
|
|
416
|
+
for excursion metrics, ``mae_pct``/``mfe_pct``/``entry_time``/``exit_time``).
|
|
417
|
+
equity_curve: Account equity sampled over the backtest (daily-resampled).
|
|
418
|
+
initial_capital / final_capital: Capital at the start / end.
|
|
419
|
+
market_data: ``{symbol: {"first_open", "last_close"}}`` for buy & hold.
|
|
420
|
+
start / end: Backtest window, used for CAGR and exposure. When absent,
|
|
421
|
+
elapsed time is inferred from the equity-curve length.
|
|
422
|
+
periods_per_year: Annualization factor for the equity-curve returns
|
|
423
|
+
(the curve is daily, so the default 252 is correct; pass a different
|
|
424
|
+
value only for a non-daily series).
|
|
425
|
+
benchmark_returns: Optional return series for alpha/beta/IR. When ``None``
|
|
426
|
+
those keys are ``0.0`` and ``benchmark_available`` is ``False``.
|
|
427
|
+
n_trials: Number of configs evaluated to produce this result (drives the
|
|
428
|
+
Deflated Sharpe). ``1`` for a standalone backtest.
|
|
429
|
+
var_of_trial_sr: Variance of per-period Sharpe across those trials, if
|
|
430
|
+
known (passed by the optimizer/walk-forward); otherwise estimated.
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
A flat ``{metric_name: value}`` dict (see :data:`METRIC_KEYS`/:data:`FLAG_KEYS`).
|
|
434
|
+
"""
|
|
435
|
+
if trades_df.empty:
|
|
436
|
+
return empty_metrics()
|
|
437
|
+
|
|
438
|
+
pnl = trades_df["pnl"]
|
|
439
|
+
returns = m.returns_from_equity(equity_curve)
|
|
440
|
+
|
|
441
|
+
winning = trades_df[pnl > 0]["pnl"]
|
|
442
|
+
losing = trades_df[pnl < 0]["pnl"]
|
|
443
|
+
|
|
444
|
+
total_return_pct = (final_capital / initial_capital - 1) * 100 if initial_capital else 0.0
|
|
445
|
+
net_profit = final_capital - initial_capital
|
|
446
|
+
drawdown_frac = m.max_drawdown(equity_curve)
|
|
447
|
+
drawdown_pct = drawdown_frac * 100
|
|
448
|
+
drawdown_dollars = _max_drawdown_dollars(equity_curve)
|
|
449
|
+
years = _elapsed_years(start, end, equity_curve)
|
|
450
|
+
cagr_frac = m.cagr(equity_curve, years)
|
|
451
|
+
|
|
452
|
+
has_benchmark = benchmark_returns is not None and len(benchmark_returns) > 0
|
|
453
|
+
benchmark_buy_hold = 0.0
|
|
454
|
+
if has_benchmark:
|
|
455
|
+
alpha, beta, r_squared = m.alpha_beta(returns, benchmark_returns)
|
|
456
|
+
info_ratio = m.information_ratio(returns, benchmark_returns, periods_per_year)
|
|
457
|
+
treynor = m.treynor_ratio(returns, beta, periods_per_year)
|
|
458
|
+
# Compounded over exactly the steps the strategy was measured on, rather than
|
|
459
|
+
# the benchmark's own first/last close: the two differ whenever the curve does
|
|
460
|
+
# not start on the benchmark's first bar, and the comparison is only fair over
|
|
461
|
+
# one window.
|
|
462
|
+
benchmark_buy_hold = float(((1.0 + pd.Series(benchmark_returns).dropna()).prod() - 1.0) * 100)
|
|
463
|
+
else:
|
|
464
|
+
alpha = beta = r_squared = info_ratio = treynor = 0.0
|
|
465
|
+
|
|
466
|
+
result = {
|
|
467
|
+
# --- original headline set ---
|
|
468
|
+
"total_return": total_return_pct,
|
|
469
|
+
"buy_hold_return": _buy_hold_return(market_data),
|
|
470
|
+
"benchmark_buy_hold_return": benchmark_buy_hold,
|
|
471
|
+
"sharpe_ratio": m.sharpe_ratio(returns, periods_per_year),
|
|
472
|
+
"sortino_ratio": m.sortino_ratio(returns, periods_per_year),
|
|
473
|
+
"calmar_ratio": m.calmar_ratio(cagr_frac, drawdown_frac),
|
|
474
|
+
"max_drawdown": drawdown_pct,
|
|
475
|
+
"total_trades": int(len(trades_df)),
|
|
476
|
+
"win_rate": m.win_rate(pnl) * 100,
|
|
477
|
+
"profit_factor": m.profit_factor(pnl),
|
|
478
|
+
"avg_win": float(winning.mean()) if not winning.empty else 0.0,
|
|
479
|
+
"avg_loss": float(abs(losing.mean())) if not losing.empty else 0.0,
|
|
480
|
+
"largest_win": float(winning.max()) if not winning.empty else 0.0,
|
|
481
|
+
"largest_loss": float(abs(losing.min())) if not losing.empty else 0.0,
|
|
482
|
+
# --- Tier 1 ---
|
|
483
|
+
"cagr": cagr_frac * 100,
|
|
484
|
+
"annualized_volatility": m.annualized_volatility(returns, periods_per_year) * 100,
|
|
485
|
+
"max_drawdown_duration": m.max_drawdown_duration(equity_curve),
|
|
486
|
+
"exposure": _exposure(trades_df, start, end) * 100,
|
|
487
|
+
"probabilistic_sharpe_ratio": m.probabilistic_sharpe_ratio(returns),
|
|
488
|
+
"deflated_sharpe_ratio": m.deflated_sharpe_ratio(returns, n_trials, var_of_trial_sr),
|
|
489
|
+
# --- Tier 2 ---
|
|
490
|
+
"recovery_factor": m.recovery_factor(net_profit, drawdown_dollars),
|
|
491
|
+
"ulcer_index": m.ulcer_index(equity_curve),
|
|
492
|
+
"martin_ratio": m.martin_ratio(cagr_frac, m.ulcer_index(equity_curve)),
|
|
493
|
+
"sterling_ratio": m.sterling_ratio(cagr_frac, _average_drawdown(equity_curve)),
|
|
494
|
+
"var_95": m.value_at_risk(returns, 0.95) * 100,
|
|
495
|
+
"var_99": m.value_at_risk(returns, 0.99) * 100,
|
|
496
|
+
"cvar_95": m.conditional_var(returns, 0.95) * 100,
|
|
497
|
+
"expectancy": m.expectancy(pnl),
|
|
498
|
+
"payoff_ratio": m.payoff_ratio(pnl),
|
|
499
|
+
"gain_to_pain_ratio": m.gain_to_pain_ratio(pnl),
|
|
500
|
+
"kelly_criterion": m.kelly_criterion(pnl),
|
|
501
|
+
"sqn": m.system_quality_number(pnl),
|
|
502
|
+
"max_consecutive_wins": m.consecutive(pnl, winning=True),
|
|
503
|
+
"max_consecutive_losses": m.consecutive(pnl, winning=False),
|
|
504
|
+
"avg_trade_duration": _avg_trade_duration(trades_df),
|
|
505
|
+
"mae_pct": float(trades_df["mae_pct"].mean()) if "mae_pct" in trades_df else 0.0,
|
|
506
|
+
"mfe_pct": float(trades_df["mfe_pct"].mean()) if "mfe_pct" in trades_df else 0.0,
|
|
507
|
+
"alpha": alpha,
|
|
508
|
+
"beta": beta,
|
|
509
|
+
"r_squared": r_squared,
|
|
510
|
+
"treynor_ratio": treynor,
|
|
511
|
+
"information_ratio": info_ratio,
|
|
512
|
+
# --- Tier 3 ---
|
|
513
|
+
"skew": m.skewness(returns),
|
|
514
|
+
"kurtosis": m.kurtosis(returns),
|
|
515
|
+
"downside_deviation": m.downside_deviation(returns, periods_per_year) * 100,
|
|
516
|
+
"tail_ratio": m.tail_ratio(returns),
|
|
517
|
+
"omega_ratio": m.omega_ratio(returns),
|
|
518
|
+
"best_period": float(returns.max() * 100) if not returns.empty else 0.0,
|
|
519
|
+
"worst_period": float(returns.min() * 100) if not returns.empty else 0.0,
|
|
520
|
+
"turnover": _turnover(trades_df, initial_capital),
|
|
521
|
+
# --- flags ---
|
|
522
|
+
"benchmark_available": bool(has_benchmark),
|
|
523
|
+
"treynor_available": bool(has_benchmark and abs(beta) >= m.MIN_ABS_BETA_FOR_TREYNOR),
|
|
524
|
+
# At one trial there is nothing to deflate against: the "deflated" Sharpe is
|
|
525
|
+
# identically the probabilistic one, so the name promises a multiple-testing
|
|
526
|
+
# correction that was not applied. The number is right; the label was not.
|
|
527
|
+
"deflation_applied": bool(n_trials > 1),
|
|
528
|
+
"low_sample": bool(len(trades_df) < LOW_SAMPLE_TRADES),
|
|
529
|
+
}
|
|
530
|
+
return result
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def build_equity_curve(trades_df: pd.DataFrame, initial_capital: float) -> List[float]:
|
|
534
|
+
"""Build a daily equity curve by accumulating trade P&L at exit time."""
|
|
535
|
+
dated = build_dated_equity_curve(trades_df, initial_capital)
|
|
536
|
+
return dated.tolist() if not dated.empty else [initial_capital]
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def build_dated_equity_curve(trades_df: pd.DataFrame, initial_capital: float) -> pd.Series:
|
|
540
|
+
"""Like :func:`build_equity_curve`, but keeps the ``DatetimeIndex``.
|
|
541
|
+
|
|
542
|
+
``build_equity_curve`` returns a plain ``List[float]`` because nothing on its
|
|
543
|
+
existing call paths ever needed the dates back. The per-trial OOS
|
|
544
|
+
return series does: the Reality Check inner-joins trials on a common calendar
|
|
545
|
+
(`src.store.trials.TrialStore.returns_panel`), which needs real dates, not
|
|
546
|
+
just an ordered sequence.
|
|
547
|
+
"""
|
|
548
|
+
if trades_df.empty or "exit_time" not in trades_df:
|
|
549
|
+
return pd.Series(dtype="float64")
|
|
550
|
+
daily_pnl = trades_df.set_index("exit_time")["pnl"].resample("D").sum().fillna(0)
|
|
551
|
+
if daily_pnl.empty:
|
|
552
|
+
return pd.Series(dtype="float64")
|
|
553
|
+
equity = initial_capital + daily_pnl.cumsum()
|
|
554
|
+
first_day = equity.index[0] - pd.Timedelta(days=1)
|
|
555
|
+
return pd.concat([pd.Series([initial_capital], index=[first_day]), equity])
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
# --------------------------------------------------------------------------- #
|
|
559
|
+
# Helpers
|
|
560
|
+
# --------------------------------------------------------------------------- #
|
|
561
|
+
def _elapsed_years(start: Optional[datetime], end: Optional[datetime], equity_curve: List[float]) -> float:
|
|
562
|
+
"""Elapsed calendar years for CAGR - from the window if known, else the curve."""
|
|
563
|
+
if start is not None and end is not None:
|
|
564
|
+
days = (end - start).days
|
|
565
|
+
return days / 365.25 if days > 0 else 0.0
|
|
566
|
+
# Fall back to the daily-resampled curve length (one point per calendar day).
|
|
567
|
+
return max(len(equity_curve) - 1, 0) / 365.25
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _exposure(trades_df: pd.DataFrame, start: Optional[datetime], end: Optional[datetime]) -> float:
|
|
571
|
+
"""Fraction of the window with at least one open position (union of holds)."""
|
|
572
|
+
if trades_df.empty or "entry_time" not in trades_df or "exit_time" not in trades_df:
|
|
573
|
+
return 0.0
|
|
574
|
+
if start is None or end is None:
|
|
575
|
+
return 0.0
|
|
576
|
+
total = (pd.Timestamp(end) - pd.Timestamp(start)).total_seconds()
|
|
577
|
+
if total <= 0:
|
|
578
|
+
return 0.0
|
|
579
|
+
|
|
580
|
+
intervals = sorted(
|
|
581
|
+
(pd.Timestamp(a), pd.Timestamp(b)) for a, b in zip(trades_df["entry_time"], trades_df["exit_time"])
|
|
582
|
+
)
|
|
583
|
+
# Normalize timezone so subtraction against tz-naive start/end is valid.
|
|
584
|
+
covered = 0.0
|
|
585
|
+
cur_start, cur_end = None, None
|
|
586
|
+
for a, b in intervals:
|
|
587
|
+
a, b = a.tz_localize(None) if a.tzinfo else a, b.tz_localize(None) if b.tzinfo else b
|
|
588
|
+
if cur_end is None:
|
|
589
|
+
cur_start, cur_end = a, b
|
|
590
|
+
elif a <= cur_end:
|
|
591
|
+
cur_end = max(cur_end, b)
|
|
592
|
+
else:
|
|
593
|
+
covered += (cur_end - cur_start).total_seconds()
|
|
594
|
+
cur_start, cur_end = a, b
|
|
595
|
+
if cur_end is not None:
|
|
596
|
+
covered += (cur_end - cur_start).total_seconds()
|
|
597
|
+
return min(covered / total, 1.0)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _max_drawdown_dollars(equity_curve: List[float]) -> float:
|
|
601
|
+
"""Largest peak-to-trough decline of the equity curve in account currency."""
|
|
602
|
+
equity = pd.Series(equity_curve, dtype="float64")
|
|
603
|
+
if equity.empty:
|
|
604
|
+
return 0.0
|
|
605
|
+
return float((equity.cummax() - equity).max())
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _average_drawdown(equity_curve: List[float]) -> float:
|
|
609
|
+
"""Mean depth of the underwater periods (fraction), for the Sterling ratio."""
|
|
610
|
+
dd = m.drawdown_series(equity_curve)
|
|
611
|
+
underwater = dd[dd < 0]
|
|
612
|
+
return float(abs(underwater.mean())) if not underwater.empty else 0.0
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _avg_trade_duration(trades_df: pd.DataFrame) -> float:
|
|
616
|
+
"""Mean holding period in hours."""
|
|
617
|
+
if "entry_time" not in trades_df or "exit_time" not in trades_df:
|
|
618
|
+
return 0.0
|
|
619
|
+
durations = pd.to_datetime(trades_df["exit_time"]) - pd.to_datetime(trades_df["entry_time"])
|
|
620
|
+
seconds = durations.dt.total_seconds()
|
|
621
|
+
return float(seconds.mean() / 3600.0) if not seconds.empty else 0.0
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _turnover(trades_df: pd.DataFrame, initial_capital: float) -> float:
|
|
625
|
+
"""Traded notional / capital - a proxy for fee/slippage drag."""
|
|
626
|
+
if "entry_price" not in trades_df or "size" not in trades_df or not initial_capital:
|
|
627
|
+
return 0.0
|
|
628
|
+
notional = (trades_df["entry_price"] * trades_df["size"]).sum()
|
|
629
|
+
return float(notional / initial_capital)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _buy_hold_return(market_data: Dict[str, Dict[str, float]]) -> float:
|
|
633
|
+
"""Average buy & hold return (%) across the traded symbols."""
|
|
634
|
+
per_symbol = [
|
|
635
|
+
(data["last_close"] / data["first_open"] - 1) * 100
|
|
636
|
+
for data in market_data.values()
|
|
637
|
+
if data.get("first_open")
|
|
638
|
+
]
|
|
639
|
+
return float(np.mean(per_symbol)) if per_symbol else 0.0
|