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,393 @@
1
+ """Reusable chart rendering for backtest and walk-forward results.
2
+
3
+ These functions operate on the engine's own result objects — a
4
+ :class:`~quantred.engine.backtest.BacktestResult` or a
5
+ :class:`~quantred.optimization.walk_forward.WalkForwardResult` — so *any* strategy or
6
+ run can be rendered, not just the demo. The CLI exposes them via a ``--chart``
7
+ argument on the ``backtest`` and ``walkforward`` commands; the demo composes the
8
+ same panels into one summary image.
9
+
10
+ Matplotlib is an optional dependency (the ``viz`` extra), imported lazily so the
11
+ base install and the text-only commands never pay for it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ from quantred.settings import BRAND
19
+
20
+ # Palette — kept here so the look is consistent and easy to retune.
21
+ _INK = "#1b1f24"
22
+ _MUTED = "#6b7280"
23
+ _ACCENT = "#2563eb"
24
+ _PASS = "#16a34a"
25
+ _FAIL = "#dc2626"
26
+ _GRID = "#e5e7eb"
27
+
28
+
29
+ def _mpl():
30
+ """Import matplotlib lazily; raise an actionable error if it's missing."""
31
+ try:
32
+ import matplotlib
33
+
34
+ matplotlib.use("Agg") # headless: no display needed
35
+ import matplotlib.pyplot as plt
36
+ from matplotlib.gridspec import GridSpec
37
+
38
+ plt.rcParams.update({"font.size": 11, "axes.edgecolor": _GRID, "text.color": _INK})
39
+ return plt, GridSpec
40
+ except ModuleNotFoundError as exc: # pragma: no cover - depends on optional extra
41
+ from quantred.utils.extras import missing_extra_message
42
+
43
+ raise RuntimeError(missing_extra_message("Charting", "viz")) from exc
44
+
45
+
46
+ def _fmt(value: Any) -> str:
47
+ """Compact, readable formatting for values of unknown scale."""
48
+ if isinstance(value, bool):
49
+ return "yes" if value else "no"
50
+ try:
51
+ v = float(value)
52
+ except (TypeError, ValueError):
53
+ return str(value)
54
+ if v == 0:
55
+ return "0"
56
+ if abs(v) < 0.01 or abs(v) >= 1e4:
57
+ return f"{v:.1e}"
58
+ return f"{v:.2f}"
59
+
60
+
61
+ def _gate(name: str, value: Any) -> str:
62
+ """A gate value in its own unit, exactly as the terminal prints it.
63
+
64
+ The chart used to apply :func:`_fmt` to every gate, so a deflated Sharpe of 0.0004
65
+ was ``4.4e-04`` here and ``0.00`` in the terminal beside it.
66
+ """
67
+ from quantred.optimization.walk_forward import format_gate_value
68
+
69
+ return format_gate_value(name, value)
70
+
71
+
72
+ def _metrics_subtitle(metrics: Dict[str, float]) -> str:
73
+ return (
74
+ f"Sharpe {metrics.get('sharpe_ratio', 0):.2f} · "
75
+ f"return {metrics.get('total_return', 0):.1f}% · "
76
+ f"{int(metrics.get('total_trades', 0))} trades · "
77
+ f"max DD {metrics.get('max_drawdown', 0):.1f}%"
78
+ )
79
+
80
+
81
+ # --------------------------------------------------------------------------- #
82
+ # Composable panels (draw onto a provided Axes)
83
+ # --------------------------------------------------------------------------- #
84
+ def _equity_panel(ax, equity_curve: List[float], *, title: str, subtitle: Optional[str]) -> None:
85
+ n = len(equity_curve)
86
+ x = list(range(n))
87
+ ax.set_facecolor("white")
88
+ ax.plot(x, equity_curve, color=_ACCENT, linewidth=2.0)
89
+ if n:
90
+ ax.fill_between(x, equity_curve, min(equity_curve), color=_ACCENT, alpha=0.08)
91
+ ax.margins(x=0.01)
92
+ ax.grid(True, axis="y", color=_GRID, linewidth=0.8)
93
+ for side in ("top", "right"):
94
+ ax.spines[side].set_visible(False)
95
+ ax.set_title(title, fontsize=13, fontweight="bold", loc="left", pad=10)
96
+ ax.set_xlabel("Backtest timeline (days)", color=_MUTED)
97
+ ax.set_ylabel("Account equity ($)", color=_MUTED)
98
+ ax.yaxis.set_major_formatter(lambda v, _pos: f"${v:,.0f}")
99
+ ax.tick_params(colors=_MUTED)
100
+ if subtitle:
101
+ ax.annotate(
102
+ subtitle,
103
+ xy=(0.015, 0.04),
104
+ xycoords="axes fraction",
105
+ fontsize=10.5,
106
+ color=_MUTED,
107
+ style="italic",
108
+ )
109
+
110
+
111
+ def _verdict_panel(
112
+ ax,
113
+ *,
114
+ promotable: bool,
115
+ checks: Dict[str, Dict[str, Any]],
116
+ oos_sharpe: float,
117
+ efficiency: float,
118
+ oos_trades: int,
119
+ title: str = "WALK-FORWARD VERDICT",
120
+ ) -> None:
121
+ ax.axis("off")
122
+
123
+ def stat_row(y, label, value):
124
+ ax.text(0.0, y, label, transform=ax.transAxes, va="top", fontsize=10, color=_INK)
125
+ ax.text(
126
+ 1.0,
127
+ y,
128
+ value,
129
+ transform=ax.transAxes,
130
+ va="top",
131
+ ha="right",
132
+ fontsize=10,
133
+ family="monospace",
134
+ color=_INK,
135
+ )
136
+
137
+ ax.text(0.0, 0.99, title, transform=ax.transAxes, va="top", fontsize=10, color=_MUTED, fontweight="bold")
138
+ ax.text(
139
+ 0.0,
140
+ 0.88,
141
+ "PROMOTABLE" if promotable else "NOT PROMOTABLE",
142
+ transform=ax.transAxes,
143
+ va="top",
144
+ fontsize=22,
145
+ fontweight="bold",
146
+ color=_PASS if promotable else _FAIL,
147
+ )
148
+ stat_row(0.74, "OOS Sharpe (median)", _fmt(oos_sharpe))
149
+ stat_row(0.67, "Efficiency (OOS / IS)", _fmt(efficiency))
150
+ stat_row(0.60, "OOS trades", str(oos_trades))
151
+ ax.text(
152
+ 0.0,
153
+ 0.49,
154
+ "Promotion gates",
155
+ transform=ax.transAxes,
156
+ va="top",
157
+ fontsize=10.5,
158
+ color=_MUTED,
159
+ fontweight="bold",
160
+ )
161
+
162
+ top, bottom = 0.41, 0.02
163
+ rows = list(checks.items())
164
+ step = (top - bottom) / max(len(rows), 1)
165
+ for i, (name, check) in enumerate(rows):
166
+ y = top - i * step
167
+ passed = bool(check.get("passed"))
168
+ ax.text(
169
+ 0.0,
170
+ y,
171
+ "PASS" if passed else "FAIL",
172
+ transform=ax.transAxes,
173
+ va="top",
174
+ fontsize=9.5,
175
+ fontweight="bold",
176
+ family="monospace",
177
+ color=_PASS if passed else _FAIL,
178
+ )
179
+ ax.text(0.15, y, name, transform=ax.transAxes, va="top", fontsize=9.5, color=_INK)
180
+ ax.text(
181
+ 1.0,
182
+ y,
183
+ f"{_gate(name, check.get('value'))} / {_gate(name, check.get('threshold'))}",
184
+ transform=ax.transAxes,
185
+ va="top",
186
+ ha="right",
187
+ fontsize=9,
188
+ family="monospace",
189
+ color=_MUTED,
190
+ )
191
+
192
+
193
+ def _save(fig, out_path: str) -> str:
194
+ fig.savefig(out_path, facecolor="white")
195
+ import matplotlib.pyplot as plt
196
+
197
+ plt.close(fig)
198
+ return out_path
199
+
200
+
201
+ def _to_png(fig) -> bytes:
202
+ """Render a figure to PNG bytes and close it.
203
+
204
+ The in-memory sibling of :func:`_save`, for callers embedding a chart rather
205
+ than writing a file. Closing is not optional: a library-mode renderer that
206
+ leaks figures will exhaust matplotlib's warning threshold and then memory,
207
+ across a test suite as easily as across a long session.
208
+ """
209
+ import io
210
+
211
+ import matplotlib.pyplot as plt
212
+
213
+ buffer = io.BytesIO()
214
+ try:
215
+ fig.savefig(buffer, format="png", facecolor="white")
216
+ finally:
217
+ plt.close(fig)
218
+ return buffer.getvalue()
219
+
220
+
221
+ # --------------------------------------------------------------------------- #
222
+ # In-memory renderers (same panels, PNG bytes instead of a file)
223
+ # --------------------------------------------------------------------------- #
224
+ #: Deliberately modest: an embedded chart is base64'd into a single-file report,
225
+ #: where every byte costs ~4/3 bytes of document. These keep one chart around
226
+ #: 60-120 KB rather than the 300 KB a print-resolution figure would cost.
227
+ EMBED_DPI = 96
228
+ EMBED_SIZE = (9.0, 4.2)
229
+
230
+
231
+ def render_gate_png(
232
+ checks: Dict[str, Dict[str, Any]],
233
+ *,
234
+ promotable: bool,
235
+ title: str = "VERDICT",
236
+ stats: Optional[List[tuple]] = None,
237
+ ) -> bytes:
238
+ """Render a pass/fail gate scorecard from plain check data, as PNG bytes.
239
+
240
+ Takes the ``{name: {value, threshold, passed}}`` shape every gate report in the
241
+ project already produces, so a report renders the same scorecard whether the
242
+ checks came from a walk-forward's promotion gates or a composite verdict's.
243
+ """
244
+ plt, _ = _mpl()
245
+ fig = plt.figure(figsize=(7.5, 4.8), dpi=EMBED_DPI, facecolor="white")
246
+ ax = fig.add_subplot(1, 1, 1)
247
+ rows = stats or []
248
+ _verdict_panel(
249
+ ax,
250
+ promotable=promotable,
251
+ checks=checks,
252
+ oos_sharpe=rows[0][1] if len(rows) > 0 else 0.0,
253
+ efficiency=rows[1][1] if len(rows) > 1 else 0.0,
254
+ oos_trades=rows[2][1] if len(rows) > 2 else 0,
255
+ title=title,
256
+ )
257
+ fig.subplots_adjust(top=0.95, bottom=0.05, left=0.05, right=0.95)
258
+ return _to_png(fig)
259
+
260
+
261
+ def render_equity_png(
262
+ equity_curve: List[float], *, title: str = "Equity", subtitle: Optional[str] = None
263
+ ) -> bytes:
264
+ """Render an equity curve from a plain list of values, as PNG bytes."""
265
+ plt, _ = _mpl()
266
+ fig = plt.figure(figsize=EMBED_SIZE, dpi=EMBED_DPI, facecolor="white")
267
+ _equity_panel(fig.add_subplot(1, 1, 1), list(equity_curve), title=title, subtitle=subtitle)
268
+ fig.subplots_adjust(top=0.9, bottom=0.13, left=0.1, right=0.97)
269
+ return _to_png(fig)
270
+
271
+
272
+ def render_bars_png(labels: List[str], values: List[float], *, title: str, value_label: str = "") -> bytes:
273
+ """Render a labeled horizontal bar chart (alphas, weights) as PNG bytes.
274
+
275
+ Presentation of values a step already computed - it ranks and draws, it does
276
+ not derive.
277
+ """
278
+ plt, _ = _mpl()
279
+ height = max(2.2, 0.34 * len(labels) + 1.2)
280
+ fig = plt.figure(figsize=(EMBED_SIZE[0], height), dpi=EMBED_DPI, facecolor="white")
281
+ ax = fig.add_subplot(1, 1, 1)
282
+ ax.set_facecolor("white")
283
+ positions = list(range(len(labels)))[::-1]
284
+ colors = [_ACCENT if v >= 0 else _FAIL for v in values]
285
+ ax.barh(positions, values, color=colors, height=0.62)
286
+ ax.set_yticks(positions)
287
+ ax.set_yticklabels(labels, fontsize=10)
288
+ ax.axvline(0.0, color=_MUTED, linewidth=0.8)
289
+ ax.grid(True, axis="x", color=_GRID, linewidth=0.8)
290
+ for side in ("top", "right", "left"):
291
+ ax.spines[side].set_visible(False)
292
+ ax.set_title(title, fontsize=13, fontweight="bold", loc="left", pad=10)
293
+ if value_label:
294
+ ax.set_xlabel(value_label, color=_MUTED)
295
+ ax.tick_params(colors=_MUTED)
296
+ fig.subplots_adjust(top=0.88, bottom=0.16, left=0.14, right=0.97)
297
+ return _to_png(fig)
298
+
299
+
300
+ # --------------------------------------------------------------------------- #
301
+ # Public renderers (operate on engine result objects — reusable for any run)
302
+ # --------------------------------------------------------------------------- #
303
+ def render_backtest_chart(
304
+ result, out_path: str, *, title: Optional[str] = None, subtitle: Optional[str] = None
305
+ ) -> str:
306
+ """Render any ``BacktestResult`` as an equity curve + headline metrics."""
307
+ plt, _ = _mpl()
308
+ fig = plt.figure(figsize=(10, 4.8), dpi=120, facecolor="white")
309
+ ax = fig.add_subplot(1, 1, 1)
310
+ _equity_panel(
311
+ ax,
312
+ result.equity_curve,
313
+ title=title or "Backtest equity",
314
+ subtitle=subtitle or _metrics_subtitle(result.metrics),
315
+ )
316
+ fig.subplots_adjust(top=0.9, bottom=0.13, left=0.1, right=0.97)
317
+ return _save(fig, out_path)
318
+
319
+
320
+ def render_walkforward_chart(
321
+ result, out_path: str, *, title: Optional[str] = None, subtitle: Optional[str] = None
322
+ ) -> str:
323
+ """Render any ``WalkForwardResult`` as the verdict + promotion-gate scorecard."""
324
+ plt, _ = _mpl()
325
+ report = result.gate_report()
326
+ fig = plt.figure(figsize=(7.5, 4.8), dpi=120, facecolor="white")
327
+ ax = fig.add_subplot(1, 1, 1)
328
+ _verdict_panel(
329
+ ax,
330
+ promotable=report["promotable"],
331
+ checks=report["checks"],
332
+ oos_sharpe=result.median_oos("sharpe_ratio"),
333
+ efficiency=result.median_efficiency(),
334
+ oos_trades=result.total_oos_trades(),
335
+ )
336
+ if title:
337
+ fig.suptitle(title, fontsize=14, fontweight="bold", x=0.05, ha="left")
338
+ if subtitle:
339
+ fig.text(0.05, 0.9, subtitle, fontsize=10.5, color=_MUTED, ha="left")
340
+ fig.subplots_adjust(top=0.82, bottom=0.05, left=0.05, right=0.95)
341
+ return _save(fig, out_path)
342
+
343
+
344
+ def render_demo_summary(
345
+ backtest_result,
346
+ wf_result,
347
+ out_path: str,
348
+ *,
349
+ strategy: str = "strategy",
350
+ seed: Optional[int] = None,
351
+ ) -> str:
352
+ """Compose the in-sample equity curve and the walk-forward verdict in one image.
353
+
354
+ Reuses the same panels as the standalone renderers; the demo is just one more
355
+ caller, not special-cased plotting code.
356
+ """
357
+ plt, GridSpec = _mpl()
358
+ report = wf_result.gate_report()
359
+ equity = backtest_result.equity_curve
360
+ ret_pct = (equity[-1] / equity[0] - 1.0) * 100.0 if equity and equity[0] else 0.0
361
+
362
+ fig = plt.figure(figsize=(12, 5.2), dpi=120, facecolor="white")
363
+ gs = GridSpec(1, 2, width_ratios=[1.55, 1.0], wspace=0.18, figure=fig)
364
+
365
+ _equity_panel(
366
+ fig.add_subplot(gs[0, 0]),
367
+ equity,
368
+ title=f"In-sample equity — {strategy} on synthetic data",
369
+ subtitle=f"in-sample return {ret_pct:+.1f}% · looks tradeable",
370
+ )
371
+ _verdict_panel(
372
+ fig.add_subplot(gs[0, 1]),
373
+ promotable=report["promotable"],
374
+ checks=report["checks"],
375
+ oos_sharpe=wf_result.median_oos("sharpe_ratio"),
376
+ efficiency=wf_result.median_efficiency(),
377
+ oos_trades=wf_result.total_oos_trades(),
378
+ )
379
+
380
+ sub = "synthetic random walk — no real edge, so the gates refuse to promote it"
381
+ if seed is not None:
382
+ sub += f" (seed {seed})"
383
+ fig.suptitle(
384
+ f"{BRAND} demo: honest out-of-sample evaluation",
385
+ fontsize=15,
386
+ fontweight="bold",
387
+ x=0.07,
388
+ ha="left",
389
+ y=0.99,
390
+ )
391
+ fig.text(0.07, 0.925, sub, fontsize=10.5, color=_MUTED, ha="left")
392
+ fig.subplots_adjust(top=0.84, bottom=0.11, left=0.07, right=0.975)
393
+ return _save(fig, out_path)
@@ -0,0 +1,185 @@
1
+ """How bad did the *book* get while it was open?
2
+
3
+ Per-trade MAE answers a narrower question than it looks like it answers. A position
4
+ 30% underwater is alarming until you notice it was a small fraction of the book, at
5
+ which point the same number says almost nothing about what the portfolio went through.
6
+ Establishing that meant pulling a trial's trade table and comparing per-trade
7
+ excursions against the equity drawdown by hand — and the hand comparison is the whole
8
+ diagnostic, so it belongs in the tool.
9
+
10
+ The equity curve is marked at each bar's **close**. Everything that happened inside the
11
+ bar — the tick where three positions were simultaneously at their worst — is invisible
12
+ to it. So the curve can show a shallow drawdown over a period the book actually spent
13
+ in considerably more trouble, and nothing in the summary metrics says which of those
14
+ two stories is true.
15
+
16
+ This reports both ends of that, and is careful about what each one is:
17
+
18
+ * **The closing mark** is what the equity curve saw. It is a real sequence of prices
19
+ the book actually printed at, and it is a *lower* bound on the pain.
20
+ * **The simultaneous-extremes bound** marks every open position at its own worst tick
21
+ inside the same bar. That assumes they all got there at once, which they did not.
22
+ It is an *upper* bound and is never a measurement.
23
+
24
+ The realized worst lies between them, and neither number is the answer on its own.
25
+ Reporting only the second would be the pessimistic reading the per-trade figures
26
+ already invite; reporting only the first is the complacent one the closing mark already
27
+ gives. Diagnostic only — nothing here gates anything, and the point of the pair is that
28
+ a reader can see how far apart they are.
29
+ """
30
+
31
+ from typing import Any, Dict, List, Optional, Sequence
32
+
33
+ #: Below this, the gap between the two bounds is not worth a reader's attention: a book
34
+ #: whose intra-bar worst is within a fraction of a percent of its closing marks was
35
+ #: sampled honestly by its own equity curve. Stated as a display threshold, not a gate —
36
+ #: nothing changes when it is crossed except that the report says so.
37
+ MATERIAL_GAP_PCT = 1.0
38
+
39
+
40
+ def _instant(sample: Dict[str, Any], equity_key: str, peak: float) -> Dict[str, Any]:
41
+ """One sample rendered as the moment it describes, with the book's shape at it."""
42
+ equity = float(sample.get(equity_key, 0.0))
43
+ close = float(sample.get("equity_close", 0.0))
44
+ gross, net = float(sample.get("gross", 0.0)), float(sample.get("net", 0.0))
45
+ return {
46
+ "time": sample.get("time"),
47
+ "equity": equity,
48
+ "equity_at_close": close,
49
+ "peak_equity": peak,
50
+ "open_positions": int(sample.get("open_positions", 0)),
51
+ "gross_exposure": gross,
52
+ "net_exposure": net,
53
+ # As fractions of the book at that instant, because the dollar figure alone
54
+ # cannot say whether it was a large position or a large book.
55
+ "gross_exposure_pct": (gross / close * 100.0) if close else None,
56
+ "net_exposure_pct": (net / close * 100.0) if close else None,
57
+ # What the closing mark showed at the same instant. The gap between this and
58
+ # the excursion is the whole finding.
59
+ "drawdown_at_close_pct": ((peak - close) / peak * 100.0) if peak else None,
60
+ }
61
+
62
+
63
+ def portfolio_excursion(samples: Optional[Sequence[Dict[str, Any]]]) -> Dict[str, Any]:
64
+ """The book's aggregate adverse and favourable excursion through time.
65
+
66
+ ``samples`` are the engine's per-step records: the book marked at the bar's close,
67
+ at every open position's worst tick within the bar, and at every one's best, plus
68
+ the exposure and position count carried at that step.
69
+
70
+ Both excursions are measured against the running peak of the **closing** curve — a
71
+ level the book actually printed, rather than an intra-bar extreme it only touched.
72
+ Measuring a drawdown from a peak that never closed would inflate it with the same
73
+ intra-bar noise the diagnostic exists to isolate.
74
+ """
75
+ report: Dict[str, Any] = {
76
+ "available": False,
77
+ "reason": "",
78
+ "n_steps": 0,
79
+ "basis": (
80
+ "every open position marked at its own worst (and best) tick within the same "
81
+ "bar, which assumes they all got there at once — an upper bound on the pain, "
82
+ "never a measurement of it"
83
+ ),
84
+ }
85
+ if not samples:
86
+ # Distinct from a book that never went underwater. No samples means no run to
87
+ # look at: a strategy that opened nothing, or a result from before this was
88
+ # recorded.
89
+ report["reason"] = (
90
+ "no excursion samples were recorded for this run — it opened no positions, "
91
+ "or predates this diagnostic"
92
+ )
93
+ return report
94
+
95
+ peak = float(samples[0].get("equity_close", 0.0))
96
+ worst: Optional[Dict[str, Any]] = None
97
+ best: Optional[Dict[str, Any]] = None
98
+ worst_pct = 0.0
99
+ best_pct = 0.0
100
+ close_worst: Optional[Dict[str, Any]] = None
101
+ close_worst_pct = 0.0
102
+
103
+ for sample in samples:
104
+ close = float(sample.get("equity_close", 0.0))
105
+ peak = max(peak, close)
106
+ if peak <= 0:
107
+ # A book with no equity has no meaningful fraction of one.
108
+ continue
109
+ adverse_pct = (peak - float(sample.get("equity_adverse", close))) / peak * 100.0
110
+ favourable_pct = (float(sample.get("equity_favourable", close)) - peak) / peak * 100.0
111
+ closing_pct = (peak - close) / peak * 100.0
112
+ if adverse_pct > worst_pct or worst is None:
113
+ worst_pct, worst = adverse_pct, _instant(sample, "equity_adverse", peak)
114
+ if favourable_pct > best_pct or best is None:
115
+ best_pct, best = favourable_pct, _instant(sample, "equity_favourable", peak)
116
+ if closing_pct > close_worst_pct or close_worst is None:
117
+ close_worst_pct, close_worst = closing_pct, _instant(sample, "equity_close", peak)
118
+
119
+ report.update(
120
+ {
121
+ "available": True,
122
+ "n_steps": len(samples),
123
+ "max_adverse_excursion_pct": worst_pct,
124
+ "adverse_at": worst,
125
+ "max_favourable_excursion_pct": best_pct,
126
+ "favourable_at": best,
127
+ # The same book measured the way the equity curve measures it. The pair is
128
+ # the finding; either alone is half of it.
129
+ "closing_mark": {
130
+ "max_drawdown_pct": close_worst_pct,
131
+ "at": close_worst,
132
+ },
133
+ }
134
+ )
135
+ report["understatement_pct"] = worst_pct - close_worst_pct
136
+ report["sampled_the_same_pain"] = report["understatement_pct"] < MATERIAL_GAP_PCT
137
+ return report
138
+
139
+
140
+ def excursion_lines(report: Dict[str, Any]) -> List[str]:
141
+ """The diagnostic as terminal lines. Reports the pair; grades neither."""
142
+ if not report.get("available"):
143
+ return ["", "=== Portfolio excursion ===", f" {report.get('reason', 'not available')}"]
144
+
145
+ worst, close = report.get("adverse_at") or {}, (report.get("closing_mark") or {}).get("at") or {}
146
+ lines = [
147
+ "",
148
+ "=== Portfolio excursion ===",
149
+ f" Worst the book ever looked : -{report['max_adverse_excursion_pct']:.2f}% from its peak",
150
+ f" at {str(worst.get('time'))[:19]} — {worst.get('open_positions')} open, "
151
+ f"gross {_pct(worst.get('gross_exposure_pct'))}, net {_pct(worst.get('net_exposure_pct'))} of equity",
152
+ f" the equity curve showed -{_num(worst.get('drawdown_at_close_pct'))}% at that same instant",
153
+ f" Deepest closing drawdown : -{report['closing_mark']['max_drawdown_pct']:.2f}% "
154
+ f"at {str(close.get('time'))[:19]}",
155
+ f" Best the book ever looked : +{report['max_favourable_excursion_pct']:.2f}% above its peak",
156
+ ]
157
+ gap = report.get("understatement_pct", 0.0)
158
+ if report.get("sampled_the_same_pain"):
159
+ lines.append(
160
+ f" The curve sampled the same pain: intra-bar worst is {gap:.2f}pp below the "
161
+ "closing marks,\n so the drawdown you can see is the drawdown there was."
162
+ )
163
+ else:
164
+ lines.append(
165
+ f" The curve did NOT sample the same pain: {gap:.2f}pp deeper intra-bar than the\n"
166
+ " closing marks ever showed."
167
+ )
168
+ # Wrapped rather than one long line: this is the sentence that stops the upper
169
+ # bound being quoted as a measurement, and it has to be readable to do that.
170
+ lines.append(" Basis: every open position marked at its own worst (and best) tick within")
171
+ lines.append(" the same bar, which assumes they all got there at once — an upper bound on")
172
+ lines.append(" the pain, never a measurement of it.")
173
+ lines.append(
174
+ " The realized worst lies between the two: the closing mark is a lower bound and\n"
175
+ " the simultaneous-extremes figure an upper one. Neither is the answer alone."
176
+ )
177
+ return lines
178
+
179
+
180
+ def _pct(value: Optional[float]) -> str:
181
+ return "—" if value is None else f"{value:.1f}%"
182
+
183
+
184
+ def _num(value: Optional[float]) -> str:
185
+ return "—" if value is None else f"{value:.2f}"