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,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}"
|