quantred 2.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. quantred/__init__.py +24 -0
  2. quantred/alphas/__init__.py +53 -0
  3. quantred/alphas/base.py +221 -0
  4. quantred/alphas/combine.py +260 -0
  5. quantred/alphas/horizon.py +153 -0
  6. quantred/alphas/refine.py +300 -0
  7. quantred/alphas/scorers.py +87 -0
  8. quantred/analytics/attribution.py +354 -0
  9. quantred/analytics/bootstrap.py +353 -0
  10. quantred/analytics/charts.py +393 -0
  11. quantred/analytics/excursion.py +185 -0
  12. quantred/analytics/execution_quality.py +180 -0
  13. quantred/analytics/exposure.py +167 -0
  14. quantred/analytics/htmlreport.py +1208 -0
  15. quantred/analytics/information.py +197 -0
  16. quantred/analytics/metrics.py +502 -0
  17. quantred/analytics/performance.py +639 -0
  18. quantred/analytics/reporting.py +1672 -0
  19. quantred/analytics/screening.py +190 -0
  20. quantred/analytics/series_comparison.py +303 -0
  21. quantred/analytics/trade_analytics.py +353 -0
  22. quantred/brokers/alpaca/broker.py +415 -0
  23. quantred/brokers/alpaca/factory.py +33 -0
  24. quantred/brokers/alpaca/market_data.py +166 -0
  25. quantred/brokers/base.py +220 -0
  26. quantred/brokers/dryrun.py +164 -0
  27. quantred/brokers/errors.py +70 -0
  28. quantred/cli.py +8619 -0
  29. quantred/costs/__init__.py +11 -0
  30. quantred/costs/base.py +126 -0
  31. quantred/costs/parametric.py +131 -0
  32. quantred/data/__init__.py +29 -0
  33. quantred/data/compute.py +332 -0
  34. quantred/data/edges.py +81 -0
  35. quantred/data/features.py +120 -0
  36. quantred/data/panel.py +61 -0
  37. quantred/data/scan.py +85 -0
  38. quantred/data/store.py +269 -0
  39. quantred/demo/__init__.py +22 -0
  40. quantred/demo/scanners.py +104 -0
  41. quantred/demo/strategies.py +125 -0
  42. quantred/engine/backtest.py +1206 -0
  43. quantred/engine/barcheck.py +228 -0
  44. quantred/engine/live.py +539 -0
  45. quantred/execution/decision.py +192 -0
  46. quantred/execution/flatten.py +302 -0
  47. quantred/execution/halt.py +165 -0
  48. quantred/execution/ledger.py +758 -0
  49. quantred/execution/live_trader.py +587 -0
  50. quantred/execution/order_id.py +83 -0
  51. quantred/execution/sizing.py +114 -0
  52. quantred/indicators/indicators.py +158 -0
  53. quantred/marketdata/base.py +62 -0
  54. quantred/marketdata/client.py +59 -0
  55. quantred/marketdata/session.py +112 -0
  56. quantred/marketdata/synthetic.py +74 -0
  57. quantred/marketdata/timeframe.py +125 -0
  58. quantred/mcp/__init__.py +6 -0
  59. quantred/mcp/selftest.py +139 -0
  60. quantred/mcp/server.py +2419 -0
  61. quantred/optimization/causality.py +607 -0
  62. quantred/optimization/config_store.py +300 -0
  63. quantred/optimization/optimizer.py +431 -0
  64. quantred/optimization/parallel.py +345 -0
  65. quantred/optimization/param_space.py +309 -0
  66. quantred/optimization/walk_forward.py +1254 -0
  67. quantred/portfolio/allocator.py +135 -0
  68. quantred/portfolio/benchmark.py +109 -0
  69. quantred/portfolio/optimizer.py +786 -0
  70. quantred/portfolio/policy.py +335 -0
  71. quantred/portfolio/posterior.py +208 -0
  72. quantred/research/__init__.py +30 -0
  73. quantred/research/agent.py +868 -0
  74. quantred/research/demo_proposals.py +222 -0
  75. quantred/research/llm.py +311 -0
  76. quantred/research/proposer.py +234 -0
  77. quantred/research/provider_errors.py +242 -0
  78. quantred/research/sandbox.py +340 -0
  79. quantred/risk/__init__.py +60 -0
  80. quantred/risk/base.py +218 -0
  81. quantred/risk/conditional.py +474 -0
  82. quantred/risk/exposures.py +88 -0
  83. quantred/risk/factor.py +119 -0
  84. quantred/risk/sample.py +89 -0
  85. quantred/risk/streaming.py +214 -0
  86. quantred/scanners/base.py +141 -0
  87. quantred/scanners/symbol_scanner.py +148 -0
  88. quantred/services/__init__.py +51 -0
  89. quantred/services/analysis.py +6082 -0
  90. quantred/services/audit.py +574 -0
  91. quantred/services/campaign.py +381 -0
  92. quantred/services/configs.py +282 -0
  93. quantred/services/data.py +172 -0
  94. quantred/services/dryrun.py +206 -0
  95. quantred/services/glossary.py +130 -0
  96. quantred/services/maintenance.py +314 -0
  97. quantred/services/registry.py +351 -0
  98. quantred/services/setup.py +1023 -0
  99. quantred/services/sizing.py +150 -0
  100. quantred/services/smallreal.py +450 -0
  101. quantred/settings.py +571 -0
  102. quantred/store/__init__.py +0 -0
  103. quantred/store/bars.py +660 -0
  104. quantred/store/trials.py +1804 -0
  105. quantred/strategies/base.py +643 -0
  106. quantred/strategies/signals.py +28 -0
  107. quantred/utils/errors.py +48 -0
  108. quantred/utils/extras.py +330 -0
  109. quantred/utils/logging_config.py +33 -0
  110. quantred/utils/numeric.py +46 -0
  111. quantred/utils/streaming.py +474 -0
  112. quantred/utils/timeutils.py +148 -0
  113. quantred-2.4.0.dist-info/METADATA +632 -0
  114. quantred-2.4.0.dist-info/RECORD +117 -0
  115. quantred-2.4.0.dist-info/WHEEL +4 -0
  116. quantred-2.4.0.dist-info/entry_points.txt +8 -0
  117. quantred-2.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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