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,1208 @@
|
|
|
1
|
+
"""Self-contained HTML rendering of a research result.
|
|
2
|
+
|
|
3
|
+
Every result in this project otherwise dies in a terminal scrollback. One HTML
|
|
4
|
+
file per run fixes that: openable anywhere, attachable, and honest — because it
|
|
5
|
+
renders the **same** result dict the CLI printed rather than recomputing anything.
|
|
6
|
+
Nothing in this module derives a number; a renderer that computes is a renderer
|
|
7
|
+
that can disagree with the report it claims to be showing.
|
|
8
|
+
|
|
9
|
+
**Self-contained is a hard requirement, not an aspiration.** No web fonts, no CDN
|
|
10
|
+
stylesheets, no remote images, no analytics: opening a report must issue zero
|
|
11
|
+
network requests. That is an offline guarantee and a privacy one at once — a
|
|
12
|
+
report forwarded to someone else must not phone home on their machine. The test
|
|
13
|
+
suite asserts it rather than trusting it.
|
|
14
|
+
|
|
15
|
+
**The medium flatters; the report must not.** Gate failures, memoized results, and
|
|
16
|
+
enabled evidence-gated features render as prominent warnings at the top, mirroring
|
|
17
|
+
the severity the terminal gives them. A pretty report that buries its own verdict
|
|
18
|
+
is worse than the plain-text one it replaced.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import base64
|
|
22
|
+
import html
|
|
23
|
+
import json
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
26
|
+
|
|
27
|
+
from quantred.analytics.reporting import alphas_heading, capacity_text
|
|
28
|
+
|
|
29
|
+
#: Result kinds this module knows how to render.
|
|
30
|
+
KINDS = ("verdict", "backtest", "walkforward", "info")
|
|
31
|
+
|
|
32
|
+
#: The evidence-gated features that default off. A report where one is enabled says
|
|
33
|
+
#: so loudly: the reader has to know they are looking at a non-default configuration
|
|
34
|
+
#: whose own adoption gate has not cleared on this repo's data.
|
|
35
|
+
_DEFAULT_OFF = {
|
|
36
|
+
"conditional": "conditional risk (Σ_t)",
|
|
37
|
+
"policy": "the aim-in-front-of-the-target trading policy",
|
|
38
|
+
"posterior": "the Black–Litterman posterior",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ReportKindError(ValueError):
|
|
43
|
+
"""The result dict does not match the kind it was asked to render as.
|
|
44
|
+
|
|
45
|
+
Raised loudly and early rather than producing a half-rendered report from a
|
|
46
|
+
payload whose shape was guessed at - the failure mode a result-dict passthrough
|
|
47
|
+
contract invites once two surfaces exchange these objects.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def render_html(result: Dict[str, Any], kind: str, *, extras: Optional[Dict[str, Any]] = None) -> str:
|
|
52
|
+
"""Render ``result`` as one self-contained HTML document.
|
|
53
|
+
|
|
54
|
+
``kind`` is one of :data:`KINDS` and must match the payload: a composite result
|
|
55
|
+
carries its own schema stamp, and a mismatch raises :class:`ReportKindError`
|
|
56
|
+
rather than rendering something misleading.
|
|
57
|
+
|
|
58
|
+
``extras`` carries display-only material a caller already has in hand but the
|
|
59
|
+
result dict does not (today: an ``equity_curve`` for a backtest, which the
|
|
60
|
+
result dict deliberately omits because it can run to tens of thousands of
|
|
61
|
+
points). It is never required — a report rendered without it degrades to the
|
|
62
|
+
tables, which is exactly what a caller reading the dict alone gets.
|
|
63
|
+
"""
|
|
64
|
+
if kind not in KINDS:
|
|
65
|
+
raise ReportKindError(f"Unknown report kind {kind!r}; expected one of {', '.join(KINDS)}")
|
|
66
|
+
_assert_shape(result, kind)
|
|
67
|
+
extras = extras or {}
|
|
68
|
+
|
|
69
|
+
sections = {
|
|
70
|
+
"verdict": _verdict_sections,
|
|
71
|
+
"backtest": _backtest_sections,
|
|
72
|
+
"walkforward": _walkforward_sections,
|
|
73
|
+
"info": _info_sections,
|
|
74
|
+
}[kind](result, extras)
|
|
75
|
+
|
|
76
|
+
return "\n".join(
|
|
77
|
+
[
|
|
78
|
+
"<!doctype html>",
|
|
79
|
+
'<html lang="en"><head>',
|
|
80
|
+
'<meta charset="utf-8">',
|
|
81
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
82
|
+
f"<title>{esc(_title(result, kind))}</title>",
|
|
83
|
+
f"<style>{_CSS}</style>",
|
|
84
|
+
"</head><body>",
|
|
85
|
+
f"<main><h1>{esc(_title(result, kind))}</h1>",
|
|
86
|
+
_provenance_block(result, kind),
|
|
87
|
+
_warnings_block(result, kind),
|
|
88
|
+
*sections,
|
|
89
|
+
_footer(),
|
|
90
|
+
"</main></body></html>",
|
|
91
|
+
]
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _assert_shape(result: Dict[str, Any], kind: str) -> None:
|
|
96
|
+
"""Fail fast when the payload is not what ``kind`` promises.
|
|
97
|
+
|
|
98
|
+
A composite carries an explicit schema stamp, so it is checked exactly. The
|
|
99
|
+
other kinds predate any stamp, so they are checked on a field only that kind
|
|
100
|
+
produces - crude, but it catches the mistake that matters (a result handed to
|
|
101
|
+
the wrong renderer) without inventing a version scheme for payloads that have
|
|
102
|
+
none.
|
|
103
|
+
"""
|
|
104
|
+
if not isinstance(result, dict):
|
|
105
|
+
raise ReportKindError(f"Expected a result dict, got {type(result).__name__}")
|
|
106
|
+
if kind == "verdict":
|
|
107
|
+
from quantred.services.analysis import VERDICT_SCHEMA
|
|
108
|
+
|
|
109
|
+
schema = result.get("schema")
|
|
110
|
+
if schema != VERDICT_SCHEMA:
|
|
111
|
+
raise ReportKindError(
|
|
112
|
+
f"Expected a {VERDICT_SCHEMA} result, got schema={schema!r}. "
|
|
113
|
+
"Pass the object a verdict run returned, unmodified."
|
|
114
|
+
)
|
|
115
|
+
return
|
|
116
|
+
required = {"backtest": "metrics", "walkforward": "folds", "info": "mean_ic"}[kind]
|
|
117
|
+
# A reused walk-forward keeps the OOS aggregate and not the folds; it is a legitimate
|
|
118
|
+
# (reduced) result of this kind, not a mismatched payload.
|
|
119
|
+
if kind == "walkforward" and result.get("memoized") and "oos_aggregate" in result:
|
|
120
|
+
return
|
|
121
|
+
if required not in result:
|
|
122
|
+
raise ReportKindError(
|
|
123
|
+
f"This does not look like a {kind} result: no {required!r} field. "
|
|
124
|
+
"Pass the object the matching run returned, unmodified."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# --------------------------------------------------------------------------- #
|
|
129
|
+
# Escaping and small formatters
|
|
130
|
+
# --------------------------------------------------------------------------- #
|
|
131
|
+
def esc(value: Any) -> str:
|
|
132
|
+
"""Escape anything on its way into the document.
|
|
133
|
+
|
|
134
|
+
Symbols, strategy names, config params, and error strings all flow into this
|
|
135
|
+
report, and every one of them is ultimately user input. Everything
|
|
136
|
+
interpolated goes through here - there is no "obviously safe" interpolation.
|
|
137
|
+
"""
|
|
138
|
+
return html.escape("" if value is None else str(value), quote=True)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _num(value: Any, spec: str = "{:.4g}") -> str:
|
|
142
|
+
"""Format a number, leaving non-numbers (and absent values) legible as-is."""
|
|
143
|
+
if value is None:
|
|
144
|
+
return "—"
|
|
145
|
+
if isinstance(value, bool):
|
|
146
|
+
return "yes" if value else "no"
|
|
147
|
+
try:
|
|
148
|
+
return spec.format(float(value))
|
|
149
|
+
except (TypeError, ValueError):
|
|
150
|
+
# Plain text, deliberately unescaped: formatters produce values, and
|
|
151
|
+
# escaping happens once, at the point of interpolation. Escaping here too
|
|
152
|
+
# would double-escape every non-numeric cell.
|
|
153
|
+
return str(value)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _metric_cell(name: str, value: Any) -> str:
|
|
157
|
+
"""One metric by name, through the shared unit table so a percent shows its ``%``,
|
|
158
|
+
a probability its three decimals and a flag yes/no - the same text the terminal
|
|
159
|
+
prints. A metric with no declared unit is a ratio and keeps the generic format."""
|
|
160
|
+
from quantred.optimization.walk_forward import UNIT_DECLARED, format_gate_value
|
|
161
|
+
|
|
162
|
+
if value is not None and name in UNIT_DECLARED:
|
|
163
|
+
return format_gate_value(name, value)
|
|
164
|
+
return _num(value)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _metric_rows(metrics: Dict[str, Any]) -> List[List[str]]:
|
|
168
|
+
"""Name/value rows for a metrics dict, with a placeholder zero shown as not measured.
|
|
169
|
+
|
|
170
|
+
A ratio stored as 0.0 beside a flag saying it was unavailable is not a result of
|
|
171
|
+
zero; it is a number that never existed.
|
|
172
|
+
"""
|
|
173
|
+
from quantred.analytics.performance import unmeasured_metrics
|
|
174
|
+
|
|
175
|
+
unmeasured = unmeasured_metrics(metrics)
|
|
176
|
+
return [
|
|
177
|
+
[
|
|
178
|
+
_metric_label(k, metrics),
|
|
179
|
+
f"not measured ({unmeasured[k]})" if k in unmeasured else _metric_cell(k, v),
|
|
180
|
+
]
|
|
181
|
+
for k, v in sorted(metrics.items())
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _metric_label(name: str, metrics: Dict[str, Any]) -> str:
|
|
186
|
+
"""The row label for a metric: an undeflated 'deflated' Sharpe says what it is.
|
|
187
|
+
|
|
188
|
+
``deflation_applied`` is false for a single trial, where the value is the
|
|
189
|
+
probabilistic Sharpe under another name. The stored key is unchanged; only the
|
|
190
|
+
label is.
|
|
191
|
+
"""
|
|
192
|
+
if name == "deflated_sharpe_ratio" and metrics.get("deflation_applied") is False:
|
|
193
|
+
return "PSR, undeflated (1 trial) [deflated_sharpe_ratio]"
|
|
194
|
+
return name
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _pct(value: Any, spec: str = "{:+.2%}") -> str:
|
|
198
|
+
return _num(value, spec)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _short_date(value: Optional[str]) -> str:
|
|
202
|
+
return (value or "—")[:10]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# --------------------------------------------------------------------------- #
|
|
206
|
+
# Building blocks
|
|
207
|
+
# --------------------------------------------------------------------------- #
|
|
208
|
+
def _card(title: str, body: str, *, tone: str = "") -> str:
|
|
209
|
+
klass = f"card {tone}".strip()
|
|
210
|
+
return f'<section class="{klass}"><h2>{esc(title)}</h2>{body}</section>'
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _table(headers: Sequence[str], rows: Sequence[Sequence[Any]], *, raw: bool = False) -> str:
|
|
214
|
+
"""A table. ``raw`` marks cells already escaped by the caller (formatters that
|
|
215
|
+
emit markup, like a pass/fail pill); everything else is escaped here."""
|
|
216
|
+
if not rows:
|
|
217
|
+
return '<p class="empty">Not recorded.</p>'
|
|
218
|
+
head = "".join(f"<th>{esc(h)}</th>" for h in headers)
|
|
219
|
+
body = []
|
|
220
|
+
for row in rows:
|
|
221
|
+
cells = "".join(f"<td>{c if raw else esc(c)}</td>" for c in row)
|
|
222
|
+
body.append(f"<tr>{cells}</tr>")
|
|
223
|
+
return f'<div class="scroll"><table><thead><tr>{head}</tr></thead><tbody>{"".join(body)}</tbody></table></div>'
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _kv(pairs: Sequence[Tuple[str, Any]]) -> str:
|
|
227
|
+
"""A definition list. Every key and value is escaped, with no exceptions.
|
|
228
|
+
|
|
229
|
+
There is no markup passthrough here on purpose: a "this string already looks
|
|
230
|
+
like markup" heuristic is indistinguishable from "this user-supplied symbol
|
|
231
|
+
contains a `<`", which is precisely how a symbol list becomes script injection.
|
|
232
|
+
Callers needing markup compose it themselves, outside this helper.
|
|
233
|
+
"""
|
|
234
|
+
items = "".join(f"<div><dt>{esc(k)}</dt><dd>{esc(v)}</dd></div>" for k, v in pairs)
|
|
235
|
+
return f"<dl class='kv'>{items}</dl>"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _pill(passed: Optional[bool]) -> str:
|
|
239
|
+
if passed is None:
|
|
240
|
+
return '<span class="pill neutral">—</span>'
|
|
241
|
+
return '<span class="pill pass">PASS</span>' if passed else '<span class="pill fail">FAIL</span>'
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _pre(lines: Sequence[str]) -> str:
|
|
245
|
+
"""Terminal text, verbatim, in a block: the same words the terminal prints, so a
|
|
246
|
+
caveat is one definition shown twice rather than two phrasings that can drift."""
|
|
247
|
+
return f"<pre>{esc(chr(10).join(line.rstrip() for line in lines).strip(chr(10)))}</pre>"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _warning(text: str, *, level: str = "warn") -> str:
|
|
251
|
+
return f'<div class="banner {esc(level)}">{esc(text)}</div>'
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _chart(png: Optional[bytes], alt: str) -> str:
|
|
255
|
+
"""Embed a PNG as a data URI, or explain its absence.
|
|
256
|
+
|
|
257
|
+
A missing chart is a slot with an explanation, never a broken run: the plotting
|
|
258
|
+
dependency is an optional extra, and a text-complete report without pictures is
|
|
259
|
+
strictly better than no report.
|
|
260
|
+
"""
|
|
261
|
+
if png is None:
|
|
262
|
+
# Deliberately not the install command: a report is attached and forwarded, and
|
|
263
|
+
# the command names this machine's packs and installer. `init --check` prints it
|
|
264
|
+
# for the reader's own terminal.
|
|
265
|
+
from quantred.settings import DOCS_URL
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
'<p class="empty">Chart not rendered — the viz extra is not installed. '
|
|
269
|
+
f"See {esc(DOCS_URL)}/usage/extras for how to add it.</p>"
|
|
270
|
+
)
|
|
271
|
+
encoded = base64.b64encode(png).decode("ascii")
|
|
272
|
+
return f'<img alt="{esc(alt)}" src="data:image/png;base64,{encoded}">'
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _try_chart(fn) -> Optional[bytes]:
|
|
276
|
+
"""Render a chart, or ``None`` when plotting is unavailable.
|
|
277
|
+
|
|
278
|
+
Only the missing-dependency case degrades silently; a genuine rendering bug
|
|
279
|
+
should not be disguised as "matplotlib isn't installed".
|
|
280
|
+
"""
|
|
281
|
+
try:
|
|
282
|
+
return fn()
|
|
283
|
+
except RuntimeError: # the viz extra's own actionable "not installed" error
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# --------------------------------------------------------------------------- #
|
|
288
|
+
# Header blocks (identical for every kind)
|
|
289
|
+
# --------------------------------------------------------------------------- #
|
|
290
|
+
def _title(result: Dict[str, Any], kind: str) -> str:
|
|
291
|
+
strategy = result.get("strategy") or (result.get("inputs") or {}).get("strategy") or "result"
|
|
292
|
+
from quantred.settings import BRAND
|
|
293
|
+
|
|
294
|
+
return f"{BRAND} {kind} — {strategy}"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _window(result: Dict[str, Any]) -> Dict[str, Any]:
|
|
298
|
+
return (result.get("inputs") or {}).get("window") or result.get("window") or {}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _provenance_block(result: Dict[str, Any], kind: str) -> str:
|
|
302
|
+
"""What a reader needs to not misread this file a month from now.
|
|
303
|
+
|
|
304
|
+
Mandatory, at the top, never a footnote: a report that outlives the context
|
|
305
|
+
that produced it carries its window, universe, cost assumptions, code version,
|
|
306
|
+
and campaign trial count, or it is a number without a claim attached.
|
|
307
|
+
"""
|
|
308
|
+
inputs = result.get("inputs") or {}
|
|
309
|
+
provenance = result.get("provenance") or {}
|
|
310
|
+
window = _window(result)
|
|
311
|
+
universe = inputs.get("universe") or result.get("symbols") or inputs.get("candidates") or []
|
|
312
|
+
pairs: List[Tuple[str, Any]] = [
|
|
313
|
+
("Kind", kind),
|
|
314
|
+
("Window", f"{_short_date(window.get('start'))} → {_short_date(window.get('end'))}"),
|
|
315
|
+
("Universe", f"{len(universe)} names: {', '.join(str(s) for s in universe) or '—'}"),
|
|
316
|
+
("Timeframe", _requested(inputs.get("timeframe") or result.get("timeframe"), result)),
|
|
317
|
+
(
|
|
318
|
+
"Benchmark",
|
|
319
|
+
_requested(
|
|
320
|
+
inputs.get("benchmark") or result.get("benchmark") or result.get("benchmark_requested"),
|
|
321
|
+
result,
|
|
322
|
+
),
|
|
323
|
+
),
|
|
324
|
+
("Cost model", _cost_text(inputs.get("cost"), result)),
|
|
325
|
+
# "Version" rather than "Git SHA": an installed copy has no repository, and
|
|
326
|
+
# the packaged version is the honest answer to "what made this".
|
|
327
|
+
("Code version", provenance.get("git_sha") or _code_version()),
|
|
328
|
+
("Generated", provenance.get("generated_at") or datetime.now(timezone.utc).isoformat()),
|
|
329
|
+
("Campaign trials", _campaign_trials(provenance, result)),
|
|
330
|
+
]
|
|
331
|
+
if result.get("run_id"):
|
|
332
|
+
pairs.append(("Run id", result["run_id"]))
|
|
333
|
+
if result.get("trial_id"):
|
|
334
|
+
pairs.append(("Trial id", result["trial_id"]))
|
|
335
|
+
return _card("Provenance", _kv(pairs))
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _requested(value: Any, result: Dict[str, Any]) -> str:
|
|
339
|
+
"""A header fact, marked "as requested" on a reused result: the trial store kept the
|
|
340
|
+
metrics, not what the run was scored against, so the request is all that is known."""
|
|
341
|
+
if not value:
|
|
342
|
+
return "—"
|
|
343
|
+
return f"{value} (as requested)" if result.get("memoized") else str(value)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _campaign_trials(provenance: Dict[str, Any], result: Dict[str, Any]) -> str:
|
|
347
|
+
"""The terminal's rendering, so a real zero is not "—" and an unrecorded run says so."""
|
|
348
|
+
from quantred.analytics.reporting import campaign_trials_text
|
|
349
|
+
|
|
350
|
+
if "n_trials" in provenance:
|
|
351
|
+
return campaign_trials_text(provenance)
|
|
352
|
+
return "—" if result.get("n_trials") is None else str(result["n_trials"])
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _code_version() -> str:
|
|
356
|
+
"""The running code's identifier, for a result dict that carries none."""
|
|
357
|
+
from quantred.services.analysis import code_version
|
|
358
|
+
|
|
359
|
+
return code_version()
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _cost_text(cost: Optional[Dict[str, Any]], result: Dict[str, Any]) -> str:
|
|
363
|
+
if cost is None:
|
|
364
|
+
return "GROSS (no transaction cost charged)" if result.get("gross") else "net of cost (defaults)"
|
|
365
|
+
if cost.get("gross"):
|
|
366
|
+
return "GROSS (no transaction cost charged)"
|
|
367
|
+
return (
|
|
368
|
+
f"{cost.get('commission_bps')}bps commission · impact η={cost.get('impact_eta')} "
|
|
369
|
+
f"· borrow {cost.get('borrow_bps')}bps"
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _warnings_block(result: Dict[str, Any], kind: str) -> str:
|
|
374
|
+
"""Everything that should stop a reader, before anything that might please one."""
|
|
375
|
+
banners: List[str] = []
|
|
376
|
+
|
|
377
|
+
if result.get("memoized"):
|
|
378
|
+
banners.append(
|
|
379
|
+
_warning(
|
|
380
|
+
f"REUSED — not re-run. Served from trial {result.get('trial_id', '?')} "
|
|
381
|
+
f"recorded {result.get('trial_ts') or 'at an unknown time'} "
|
|
382
|
+
f"({_age(result.get('trial_ts'))} old). Re-run with --force to re-verify.",
|
|
383
|
+
level="reuse",
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
verdict = result.get("verdict") or {}
|
|
388
|
+
if verdict.get("verdict") == "incomplete":
|
|
389
|
+
banners.append(
|
|
390
|
+
_warning(
|
|
391
|
+
"INCOMPLETE — no verdict. A step failed"
|
|
392
|
+
+ (
|
|
393
|
+
f" ({', '.join(verdict.get('failed_steps') or [])})"
|
|
394
|
+
if verdict.get("failed_steps")
|
|
395
|
+
else ""
|
|
396
|
+
)
|
|
397
|
+
+ ". Do not act on the sections below.",
|
|
398
|
+
level="fail",
|
|
399
|
+
)
|
|
400
|
+
)
|
|
401
|
+
if verdict.get("verdict") == "infeasible":
|
|
402
|
+
banners.append(
|
|
403
|
+
_warning(
|
|
404
|
+
"INFEASIBLE — no book satisfies the constraints. More dates will not change this; "
|
|
405
|
+
"widen the universe or change the caps explicitly.",
|
|
406
|
+
level="fail",
|
|
407
|
+
)
|
|
408
|
+
)
|
|
409
|
+
failed = [name for name, c in (verdict.get("checks") or {}).items() if not c.get("passed")]
|
|
410
|
+
if failed:
|
|
411
|
+
banners.append(_warning("Gate failures: " + ", ".join(sorted(failed)), level="fail"))
|
|
412
|
+
|
|
413
|
+
gate_report = result.get("gate_report") or {}
|
|
414
|
+
if gate_report and not gate_report.get("promotable"):
|
|
415
|
+
failed_gates = [n for n, c in (gate_report.get("checks") or {}).items() if not c.get("passed")]
|
|
416
|
+
banners.append(
|
|
417
|
+
_warning("NOT PROMOTABLE — failed gates: " + ", ".join(sorted(failed_gates)), level="fail")
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
if kind == "walkforward":
|
|
421
|
+
evidence = result.get("evidence")
|
|
422
|
+
if evidence is None:
|
|
423
|
+
banners.append(
|
|
424
|
+
_warning(
|
|
425
|
+
"Evidence completeness not computed — promotion prerequisites were not assessed "
|
|
426
|
+
f"for this result ({result.get('evidence_unavailable') or 'not computed for this run'}). "
|
|
427
|
+
"`promotable` is the statistical gates only.",
|
|
428
|
+
)
|
|
429
|
+
)
|
|
430
|
+
elif gate_report.get("promotable") and not evidence.get("clear"):
|
|
431
|
+
banners.append(
|
|
432
|
+
_warning(
|
|
433
|
+
"PROMOTABLE is the statistical gates only — evidence completeness: "
|
|
434
|
+
f"{evidence.get('evidence_completeness')}. Not clear for paper.",
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
executability = result.get("executability") or {}
|
|
439
|
+
if executability.get("executable") is False:
|
|
440
|
+
failing = [n for n, c in (executability.get("checks") or {}).items() if not c.get("passed")]
|
|
441
|
+
banners.append(
|
|
442
|
+
_warning(
|
|
443
|
+
"NOT EXECUTABLE at this capital — failed checks: " + ", ".join(sorted(failing)),
|
|
444
|
+
level="fail",
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
if result.get("low_sample"):
|
|
449
|
+
banners.append(_warning("Low sample — too few rebalances to measure an IC with confidence."))
|
|
450
|
+
if result.get("sanity_ceiling_breached"):
|
|
451
|
+
banners.append(
|
|
452
|
+
_warning("Realized IR above 2 on public data — suspect a bug or a leak, not skill.", level="fail")
|
|
453
|
+
)
|
|
454
|
+
leakage = (result.get("diagnostics") or {}).get("leakage_probe")
|
|
455
|
+
if leakage is not None and not leakage.get("passed"):
|
|
456
|
+
banners.append(_warning("Leakage probe FAILED — the result is not trustworthy.", level="fail"))
|
|
457
|
+
|
|
458
|
+
banners.extend(_default_off_banners(result))
|
|
459
|
+
return "".join(banners)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _default_off_banners(result: Dict[str, Any]) -> List[str]:
|
|
463
|
+
"""Name every evidence-gated, default-off feature this run turned on."""
|
|
464
|
+
portfolio = result.get("portfolio") if isinstance(result.get("portfolio"), dict) else result
|
|
465
|
+
banners = []
|
|
466
|
+
for key, description in _DEFAULT_OFF.items():
|
|
467
|
+
value = portfolio.get(key) if isinstance(portfolio, dict) else None
|
|
468
|
+
if value:
|
|
469
|
+
banners.append(
|
|
470
|
+
_warning(
|
|
471
|
+
f"Non-default configuration: {description} is ENABLED ({value}). "
|
|
472
|
+
"It ships off because its own adoption gate does not clear on this "
|
|
473
|
+
"repo's data — this run is not the default path.",
|
|
474
|
+
level="note",
|
|
475
|
+
)
|
|
476
|
+
)
|
|
477
|
+
return banners
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _age(ts: Optional[str]) -> str:
|
|
481
|
+
if not ts:
|
|
482
|
+
return "unknown age"
|
|
483
|
+
try:
|
|
484
|
+
when = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
|
|
485
|
+
if when.tzinfo is None:
|
|
486
|
+
when = when.replace(tzinfo=timezone.utc)
|
|
487
|
+
seconds = (datetime.now(timezone.utc) - when).total_seconds()
|
|
488
|
+
except ValueError:
|
|
489
|
+
return "unknown age"
|
|
490
|
+
if seconds < 3600:
|
|
491
|
+
return f"{int(max(seconds, 0) // 60)} minutes"
|
|
492
|
+
if seconds < 86400:
|
|
493
|
+
return f"{seconds / 3600:.1f} hours"
|
|
494
|
+
return f"{seconds / 86400:.1f} days"
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# --------------------------------------------------------------------------- #
|
|
498
|
+
# Per-kind sections
|
|
499
|
+
# --------------------------------------------------------------------------- #
|
|
500
|
+
def _verdict_sections(result: Dict[str, Any], extras: Dict[str, Any]) -> List[str]:
|
|
501
|
+
from quantred.analytics import charts
|
|
502
|
+
from quantred.optimization.walk_forward import format_gate_value
|
|
503
|
+
|
|
504
|
+
verdict = result.get("verdict") or {}
|
|
505
|
+
checks = verdict.get("checks") or {}
|
|
506
|
+
out = []
|
|
507
|
+
|
|
508
|
+
banner_class = {
|
|
509
|
+
"promotable": "pass",
|
|
510
|
+
"not promotable": "fail",
|
|
511
|
+
"needs more data": "warn",
|
|
512
|
+
"mixed": "warn",
|
|
513
|
+
"incomplete": "fail",
|
|
514
|
+
"infeasible": "fail",
|
|
515
|
+
}.get(verdict.get("verdict"), "warn")
|
|
516
|
+
out.append(
|
|
517
|
+
_card(
|
|
518
|
+
"Verdict",
|
|
519
|
+
f'<p class="verdict {banner_class}">{esc(verdict.get("summary", "unknown"))}</p>'
|
|
520
|
+
+ _table(
|
|
521
|
+
["", "Check", "Value", "Threshold", "Why it is there"],
|
|
522
|
+
[
|
|
523
|
+
[
|
|
524
|
+
_pill(c.get("passed")),
|
|
525
|
+
esc(name),
|
|
526
|
+
esc(format_gate_value(name, c.get("value"))),
|
|
527
|
+
esc(format_gate_value(name, c.get("threshold"))),
|
|
528
|
+
esc(c.get("note", "")),
|
|
529
|
+
]
|
|
530
|
+
for name, c in sorted(checks.items())
|
|
531
|
+
],
|
|
532
|
+
raw=True,
|
|
533
|
+
)
|
|
534
|
+
+ (
|
|
535
|
+
_chart(
|
|
536
|
+
_try_chart(
|
|
537
|
+
lambda: charts.render_gate_png(
|
|
538
|
+
checks, promotable=bool(verdict.get("promotable")), title="RESEARCH VERDICT"
|
|
539
|
+
)
|
|
540
|
+
),
|
|
541
|
+
"verdict gate scorecard",
|
|
542
|
+
)
|
|
543
|
+
if checks
|
|
544
|
+
else ""
|
|
545
|
+
),
|
|
546
|
+
tone=banner_class,
|
|
547
|
+
)
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
steps = result.get("steps") or {}
|
|
551
|
+
notable = [(n, s) for n, s in steps.items() if s.get("status") != "ok"]
|
|
552
|
+
if notable:
|
|
553
|
+
out.append(
|
|
554
|
+
_card(
|
|
555
|
+
"Steps",
|
|
556
|
+
_table(
|
|
557
|
+
["Step", "Status", "Detail"],
|
|
558
|
+
[[n, s.get("status"), s.get("error") or s.get("reason") or ""] for n, s in notable],
|
|
559
|
+
),
|
|
560
|
+
)
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
scan = result.get("scan")
|
|
564
|
+
if scan:
|
|
565
|
+
note = (
|
|
566
|
+
"<p>Nothing flagged — the full candidate list was analyzed instead.</p>"
|
|
567
|
+
if scan.get("fell_back_to_candidates")
|
|
568
|
+
else ""
|
|
569
|
+
)
|
|
570
|
+
out.append(
|
|
571
|
+
_card(
|
|
572
|
+
"Scan",
|
|
573
|
+
f"<p>{esc(scan.get('scanner'))}: {esc(scan.get('flagged_count', 0))} of "
|
|
574
|
+
f"{esc(len(scan.get('candidates') or []))} candidates flagged.</p>" + note,
|
|
575
|
+
)
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
source = result.get("combination") or result.get("alphas")
|
|
579
|
+
if source:
|
|
580
|
+
rows = source.get("alphas") or []
|
|
581
|
+
chart = _chart(
|
|
582
|
+
_try_chart(
|
|
583
|
+
lambda: charts.render_bars_png(
|
|
584
|
+
[str(r.get("symbol")) for r in rows[:20]],
|
|
585
|
+
[float(r.get("alpha", 0.0)) for r in rows[:20]],
|
|
586
|
+
title="Alpha forecast by name",
|
|
587
|
+
value_label="annualized residual return",
|
|
588
|
+
)
|
|
589
|
+
),
|
|
590
|
+
"alphas by name",
|
|
591
|
+
)
|
|
592
|
+
out.append(
|
|
593
|
+
_card(
|
|
594
|
+
"Alphas",
|
|
595
|
+
f"<p>{esc(alphas_heading(result))}</p>"
|
|
596
|
+
+ (
|
|
597
|
+
f"<p>{esc(source['low_confidence_reason'])}</p>"
|
|
598
|
+
if source.get("low_confidence") and source.get("low_confidence_reason")
|
|
599
|
+
else ""
|
|
600
|
+
)
|
|
601
|
+
+ chart
|
|
602
|
+
+ _table(
|
|
603
|
+
["Symbol", "Alpha", "z", "Residual vol"],
|
|
604
|
+
[
|
|
605
|
+
[r.get("symbol"), _num(r.get("alpha")), _num(r.get("z")), _num(r.get("residual_vol"))]
|
|
606
|
+
for r in rows
|
|
607
|
+
],
|
|
608
|
+
),
|
|
609
|
+
)
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
out.append(_portfolio_card(result.get("portfolio")))
|
|
613
|
+
out.append(_information_card(result.get("information")))
|
|
614
|
+
return [s for s in out if s]
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _portfolio_card(portfolio: Optional[Dict[str, Any]]) -> str:
|
|
618
|
+
from quantred.analytics import charts
|
|
619
|
+
|
|
620
|
+
if not portfolio:
|
|
621
|
+
return ""
|
|
622
|
+
if not portfolio.get("feasible"):
|
|
623
|
+
return _card(
|
|
624
|
+
"Portfolio",
|
|
625
|
+
f"<p>NOT FEASIBLE — {esc(portfolio.get('binding_constraint') or portfolio.get('note') or 'unknown')}</p>",
|
|
626
|
+
tone="fail",
|
|
627
|
+
)
|
|
628
|
+
d = portfolio.get("diagnostics") or {}
|
|
629
|
+
weights = portfolio.get("weights") or {}
|
|
630
|
+
chart = _chart(
|
|
631
|
+
_try_chart(
|
|
632
|
+
lambda: charts.render_bars_png(
|
|
633
|
+
list(weights)[:20],
|
|
634
|
+
[float(v) for v in list(weights.values())[:20]],
|
|
635
|
+
title="Proposed weights",
|
|
636
|
+
value_label="portfolio weight",
|
|
637
|
+
)
|
|
638
|
+
),
|
|
639
|
+
"proposed portfolio weights",
|
|
640
|
+
)
|
|
641
|
+
exposures = portfolio.get("exposures")
|
|
642
|
+
body = (
|
|
643
|
+
"<p class='note'>A proposal, not an order.</p>"
|
|
644
|
+
+ _kv(
|
|
645
|
+
[
|
|
646
|
+
("Names", len(weights)),
|
|
647
|
+
("Predicted tracking error", _pct(d.get("predicted_tracking_error"), "{:.2%}")),
|
|
648
|
+
("Target tracking error", _pct(portfolio.get("target_te"), "{:.2%}")),
|
|
649
|
+
("Expected active return (gross)", _pct(d.get("expected_active_return"))),
|
|
650
|
+
("Expected active return (net)", _pct(d.get("expected_active_return_net"))),
|
|
651
|
+
("Predicted IR", _num(d.get("predicted_ir"), "{:+.2f}")),
|
|
652
|
+
("Transfer coefficient", _num(d.get("transfer_coefficient"), "{:+.2f}")),
|
|
653
|
+
("Capacity", capacity_text(d) or "n/a"),
|
|
654
|
+
("Risk model", portfolio.get("risk_model")),
|
|
655
|
+
]
|
|
656
|
+
)
|
|
657
|
+
+ chart
|
|
658
|
+
+ _table(["Symbol", "Weight"], [[sym, _pct(w, "{:.2%}")] for sym, w in weights.items()])
|
|
659
|
+
)
|
|
660
|
+
if exposures:
|
|
661
|
+
body += "<h3>Factor exposures</h3>" + _table(
|
|
662
|
+
["Factor", "Exposure"], [[k, _num(v, "{:+.3f}")] for k, v in sorted(exposures.items())]
|
|
663
|
+
)
|
|
664
|
+
return _card("Portfolio", body)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _information_card(information: Optional[Dict[str, Any]]) -> str:
|
|
668
|
+
if not information or not information.get("periods"):
|
|
669
|
+
return ""
|
|
670
|
+
r = information
|
|
671
|
+
return _card(
|
|
672
|
+
"Information",
|
|
673
|
+
_kv(
|
|
674
|
+
[
|
|
675
|
+
("Rebalances measured", r.get("periods")),
|
|
676
|
+
("Horizon (bars)", r.get("horizon_bars")),
|
|
677
|
+
("IC (mean)", _num(r.get("mean_ic"), "{:+.4f}")),
|
|
678
|
+
("IC t-stat", _num(r.get("ic_tstat"), "{:+.2f}")),
|
|
679
|
+
("Rank IC", _num(r.get("rank_ic"), "{:+.4f}")),
|
|
680
|
+
("Effective breadth", _num(r.get("breadth_effective"), "{:.0f}")),
|
|
681
|
+
("Mean correlation ρ̄", _num(r.get("rho_bar"), "{:.2f}")),
|
|
682
|
+
("Predicted IR", _num(r.get("predicted_ir"), "{:+.2f}")),
|
|
683
|
+
(
|
|
684
|
+
"Realized IR",
|
|
685
|
+
f"{_num(r.get('realized_ir'), '{:+.2f}')} ± {_num(r.get('ir_standard_error'), '{:.2f}')}",
|
|
686
|
+
),
|
|
687
|
+
(
|
|
688
|
+
f"P(any |t|>2 across {r.get('n_trials', 1)} trials)",
|
|
689
|
+
_num(r.get("multiple_testing_inflation"), "{:.2f}"),
|
|
690
|
+
),
|
|
691
|
+
]
|
|
692
|
+
),
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
NOT_RECORDED = "not recorded"
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _recorded(value: Any, spec: str = "{:.4g}", suffix: str = "") -> str:
|
|
700
|
+
"""A number, or the words ``not recorded`` - never a blank, a dash or a zero.
|
|
701
|
+
|
|
702
|
+
``_num`` renders None as a dash, which is fine in a dense table but reads as "no
|
|
703
|
+
value" beside a suffix (``—%``) and says nothing about *why*. A run card is the
|
|
704
|
+
place a reader checks capital and cost, so an absent value says so.
|
|
705
|
+
"""
|
|
706
|
+
if value is None or value == "":
|
|
707
|
+
return NOT_RECORDED
|
|
708
|
+
return _num(value, spec) + suffix
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _backtest_run_rows(result: Dict[str, Any]) -> List[Tuple[str, str]]:
|
|
712
|
+
"""The Run card rows. A reused result lists what it never kept in ``not_recorded``;
|
|
713
|
+
a field named there is shown as such even if a stray value sits under its key."""
|
|
714
|
+
missing = set(result.get("not_recorded") or [])
|
|
715
|
+
|
|
716
|
+
def get(key: str) -> Any:
|
|
717
|
+
return None if key in missing else result.get(key)
|
|
718
|
+
|
|
719
|
+
reused = bool(result.get("memoized"))
|
|
720
|
+
final_label = "Final capital (derived from total return)" if reused else "Final capital"
|
|
721
|
+
final = result.get("final_capital_derived") if reused else get("final_capital")
|
|
722
|
+
request = " (as requested)" if reused else ""
|
|
723
|
+
rows = [
|
|
724
|
+
("Initial capital" + request, _recorded(result.get("initial_capital"), "{:,.2f}")),
|
|
725
|
+
(final_label, _recorded(final, "{:,.2f}")),
|
|
726
|
+
("Transaction cost", _recorded(get("total_cost"), "{:,.2f}")),
|
|
727
|
+
("Cost drag", _recorded(get("cost_drag_pct"), "{:.2f}", "%")),
|
|
728
|
+
("Trades", _recorded(get("total_trades"), "{:,.0f}")),
|
|
729
|
+
]
|
|
730
|
+
if reused:
|
|
731
|
+
# Part of the run's identity, so the reused run had the same one; None is a run
|
|
732
|
+
# with no benchmark, not a benchmark that went unrecorded.
|
|
733
|
+
rows.append(("Benchmark" + request, result.get("benchmark_requested") or "none"))
|
|
734
|
+
rows.append(("Not recorded", ", ".join(sorted(missing)) or "nothing"))
|
|
735
|
+
return rows
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _backtest_sections(result: Dict[str, Any], extras: Dict[str, Any]) -> List[str]:
|
|
739
|
+
from quantred.analytics import charts
|
|
740
|
+
|
|
741
|
+
metrics = result.get("metrics") or {}
|
|
742
|
+
equity = extras.get("equity_curve")
|
|
743
|
+
out = []
|
|
744
|
+
if equity:
|
|
745
|
+
out.append(
|
|
746
|
+
_card(
|
|
747
|
+
"Equity",
|
|
748
|
+
_chart(
|
|
749
|
+
_try_chart(lambda: charts.render_equity_png(equity, title="Backtest equity")),
|
|
750
|
+
"backtest equity curve",
|
|
751
|
+
),
|
|
752
|
+
)
|
|
753
|
+
)
|
|
754
|
+
out.append(_execution_card(result))
|
|
755
|
+
out.append(
|
|
756
|
+
_card(
|
|
757
|
+
"Metrics",
|
|
758
|
+
_table(
|
|
759
|
+
["Metric", "Value"],
|
|
760
|
+
_metric_rows(metrics),
|
|
761
|
+
),
|
|
762
|
+
)
|
|
763
|
+
)
|
|
764
|
+
out.append(_card("Run", _kv(_backtest_run_rows(result))))
|
|
765
|
+
out.append(_pnl_by_exit_card(result))
|
|
766
|
+
out.append(_backtest_verdicts_card(result))
|
|
767
|
+
out.append(_universe_card(result, extras))
|
|
768
|
+
return [s for s in out if s]
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _pnl_by_exit_card(result: Dict[str, Any]) -> str:
|
|
772
|
+
""" "Where the P&L came from", as the terminal prints it, including the warning when
|
|
773
|
+
nearly all of the gain is one exit path. A reused result never kept its trades, so the
|
|
774
|
+
card says that instead of vanishing."""
|
|
775
|
+
from quantred.analytics.reporting import format_exit_concentration
|
|
776
|
+
|
|
777
|
+
if result.get("memoized"):
|
|
778
|
+
return _card(
|
|
779
|
+
"Where the P&L came from",
|
|
780
|
+
'<p class="empty">Not recorded for a reused result - its trades were not kept. '
|
|
781
|
+
"Re-run with force to see whether one exit path carries the gain.</p>",
|
|
782
|
+
)
|
|
783
|
+
report = result.get("pnl_by_exit")
|
|
784
|
+
if not report:
|
|
785
|
+
return ""
|
|
786
|
+
block = format_exit_concentration(report)
|
|
787
|
+
return _card("Where the P&L came from", _pre(block.splitlines())) if block else ""
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _backtest_verdicts_card(result: Dict[str, Any]) -> str:
|
|
791
|
+
"""The three verdicts, two of them "not assessed here": a backtest is an in-sample
|
|
792
|
+
replay and cannot say the edge was real."""
|
|
793
|
+
from quantred.analytics.reporting import format_backtest_verdicts
|
|
794
|
+
|
|
795
|
+
verdict = result.get("executability")
|
|
796
|
+
if verdict is None:
|
|
797
|
+
return _card(
|
|
798
|
+
"Verdicts",
|
|
799
|
+
_pre(
|
|
800
|
+
[
|
|
801
|
+
"Statistical validation not assessed here - was the edge real, and not overfit (`walkforward`)",
|
|
802
|
+
"Execution viability not recorded for this result",
|
|
803
|
+
"Evidence completeness not assessed here (`walkforward --bootstrap-skill`)",
|
|
804
|
+
]
|
|
805
|
+
)
|
|
806
|
+
+ "<p>A backtest replays one history, in-sample; it is not a validation.</p>",
|
|
807
|
+
)
|
|
808
|
+
return _card(
|
|
809
|
+
"Verdicts",
|
|
810
|
+
_pre(format_backtest_verdicts(verdict))
|
|
811
|
+
+ "<p>A backtest replays one history, in-sample; it is not a validation.</p>",
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _universe_card(result: Dict[str, Any], extras: Dict[str, Any]) -> str:
|
|
816
|
+
"""Where the universe came from and what that does not guarantee (survivorship).
|
|
817
|
+
|
|
818
|
+
The CLI hands in the terminal's own block (it knows the scanner and the source); a
|
|
819
|
+
payload alone knows only the names, so the fallback is the same block for a supplied
|
|
820
|
+
list - the one case an MCP caller can be in."""
|
|
821
|
+
from quantred.analytics.reporting import format_universe_provenance
|
|
822
|
+
|
|
823
|
+
lines = extras.get("universe_provenance")
|
|
824
|
+
if not lines:
|
|
825
|
+
names = result.get("symbols") or []
|
|
826
|
+
lines = format_universe_provenance(
|
|
827
|
+
candidates=names,
|
|
828
|
+
resolved=names,
|
|
829
|
+
scanner=None,
|
|
830
|
+
scan_clock=None,
|
|
831
|
+
source="the supplied list",
|
|
832
|
+
replayed=False,
|
|
833
|
+
)
|
|
834
|
+
return _card("Universe provenance", _pre([line for line in lines if not line.startswith("===")]))
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _execution_card(result: Dict[str, Any]) -> str:
|
|
838
|
+
"""Whether the book that traded resembles the one asked for, check by check.
|
|
839
|
+
|
|
840
|
+
The terminal printed this and the report did not, so a forwarded report of a book
|
|
841
|
+
that could not be traded at its capital read as clean. A result that does not carry
|
|
842
|
+
the check (a reused one) says so rather than leaving the card out.
|
|
843
|
+
"""
|
|
844
|
+
from quantred.analytics.performance import format_execution_value
|
|
845
|
+
|
|
846
|
+
verdict = result.get("executability")
|
|
847
|
+
if verdict is None:
|
|
848
|
+
return _card(
|
|
849
|
+
"Execution viability",
|
|
850
|
+
'<p class="empty">Not recorded for this result. Re-run it with force (the '
|
|
851
|
+
"<code>--force</code> flag, or <code>force=true</code> over MCP) to assess whether "
|
|
852
|
+
"this book can be traded at this capital.</p>",
|
|
853
|
+
)
|
|
854
|
+
if verdict.get("executable") is None:
|
|
855
|
+
return _card(
|
|
856
|
+
"Execution viability", _kv([("Not assessed", verdict.get("reason") or "nothing to judge")])
|
|
857
|
+
)
|
|
858
|
+
rows = [
|
|
859
|
+
[
|
|
860
|
+
esc(name),
|
|
861
|
+
esc(format_execution_value(name, check.get("value"))),
|
|
862
|
+
esc(format_execution_value(name, check.get("threshold"))),
|
|
863
|
+
_pill(check.get("passed")),
|
|
864
|
+
esc(check.get("note") or ""),
|
|
865
|
+
]
|
|
866
|
+
for name, check in (verdict.get("checks") or {}).items()
|
|
867
|
+
]
|
|
868
|
+
body = _table(["Check", "Value", "Threshold", "Result", "What it means"], rows, raw=True)
|
|
869
|
+
if not verdict.get("executable"):
|
|
870
|
+
body = _warning("Not the book that was validated — see the failing check.", level="fail") + body
|
|
871
|
+
return _card("Execution viability", body, tone="" if verdict.get("executable") else "fail")
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _evidence_card(evidence: Optional[Dict[str, Any]]) -> str:
|
|
875
|
+
"""Promotion prerequisites and evidence completeness, beside the statistical verdict.
|
|
876
|
+
|
|
877
|
+
The same block the terminal prints, from the same service output. Absent evidence is
|
|
878
|
+
said to be absent: a card that silently vanished would read as "nothing to check".
|
|
879
|
+
"""
|
|
880
|
+
if not evidence:
|
|
881
|
+
return _card(
|
|
882
|
+
"Promotion prerequisites",
|
|
883
|
+
"<p>Not assessed for this result. An unassessed prerequisite is unknown, not passed.</p>",
|
|
884
|
+
tone="warn",
|
|
885
|
+
)
|
|
886
|
+
prereq = evidence.get("prerequisites") or {}
|
|
887
|
+
from quantred.optimization.walk_forward import format_gate_value
|
|
888
|
+
|
|
889
|
+
rows = []
|
|
890
|
+
for name, check in (prereq.get("checks") or {}).items():
|
|
891
|
+
if not check.get("evaluated"):
|
|
892
|
+
rows.append(
|
|
893
|
+
[
|
|
894
|
+
_pill(None),
|
|
895
|
+
esc(name),
|
|
896
|
+
"not evaluated",
|
|
897
|
+
esc(format_gate_value(name, check.get("threshold"))),
|
|
898
|
+
esc(check.get("note")),
|
|
899
|
+
]
|
|
900
|
+
)
|
|
901
|
+
continue
|
|
902
|
+
rows.append(
|
|
903
|
+
[
|
|
904
|
+
_pill(check.get("passed")),
|
|
905
|
+
esc(name),
|
|
906
|
+
esc(
|
|
907
|
+
format_gate_value(name, check.get("value"))
|
|
908
|
+
if check.get("value") is not None
|
|
909
|
+
else "not evaluated"
|
|
910
|
+
),
|
|
911
|
+
esc(format_gate_value(name, check.get("threshold"))),
|
|
912
|
+
esc(check.get("note", "")),
|
|
913
|
+
]
|
|
914
|
+
)
|
|
915
|
+
table = _table(["", "Check", "Value", "Threshold", "Meaning"], rows, raw=True)
|
|
916
|
+
excess = evidence.get("excess_return_by_fold") or {}
|
|
917
|
+
values = excess.get("values_pct") or []
|
|
918
|
+
excess_html = ""
|
|
919
|
+
if values:
|
|
920
|
+
excess_html = (
|
|
921
|
+
"<h3>Excess return by fold (diagnostic)</h3>"
|
|
922
|
+
+ _table(
|
|
923
|
+
["Fold", "Excess return"],
|
|
924
|
+
[[i, f"{v:+.2f}%"] for i, v in enumerate(values)],
|
|
925
|
+
)
|
|
926
|
+
+ f"<p>Median {excess.get('median_pct'):+.2f}%, spread {excess.get('spread_pp'):.2f}pp; "
|
|
927
|
+
f"{excess.get('folds_lost')} of {len(values)} folds lost to the benchmark.</p>"
|
|
928
|
+
)
|
|
929
|
+
clear = bool(evidence.get("clear"))
|
|
930
|
+
return _card(
|
|
931
|
+
"Promotion prerequisites",
|
|
932
|
+
_kv(
|
|
933
|
+
[
|
|
934
|
+
("Statistical validation", evidence.get("statistical_verdict")),
|
|
935
|
+
("Evidence completeness", evidence.get("evidence_completeness")),
|
|
936
|
+
("Benchmark", evidence.get("benchmark") or "none"),
|
|
937
|
+
]
|
|
938
|
+
)
|
|
939
|
+
+ table
|
|
940
|
+
+ excess_html
|
|
941
|
+
+ f"<p>{esc(evidence.get('note', ''))}</p>",
|
|
942
|
+
tone="pass" if clear else "warn",
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _reused_walkforward_sections(result: Dict[str, Any]) -> List[str]:
|
|
947
|
+
"""The reduced report for a reused walk-forward: what the trial store kept, and a
|
|
948
|
+
plain list of what it did not. The REUSED banner is the header's; this is the body."""
|
|
949
|
+
promotable = result.get("promotable")
|
|
950
|
+
if promotable is None:
|
|
951
|
+
text, tone = "Promotability was not recorded for the original run", "warn"
|
|
952
|
+
elif promotable:
|
|
953
|
+
text, tone = "Recorded as PROMOTABLE (statistical gates only) by the original run", "warn"
|
|
954
|
+
else:
|
|
955
|
+
text, tone = "Recorded as NOT PROMOTABLE by the original run", "fail"
|
|
956
|
+
not_retained = result.get("not_retained") or [
|
|
957
|
+
"per-fold detail",
|
|
958
|
+
"promotion prerequisites (evidence)",
|
|
959
|
+
"holdout",
|
|
960
|
+
"gate checks",
|
|
961
|
+
]
|
|
962
|
+
facts = [
|
|
963
|
+
("Efficiency (OOS / IS)", _recorded(result.get("efficiency"), "{:.3f}")),
|
|
964
|
+
("Trials total", _recorded(result.get("n_trials_total"), "{:,.0f}")),
|
|
965
|
+
("Original trial", result.get("trial_id") or NOT_RECORDED),
|
|
966
|
+
("Original recorded at", result.get("trial_ts") or NOT_RECORDED),
|
|
967
|
+
]
|
|
968
|
+
return [
|
|
969
|
+
_card(
|
|
970
|
+
"Verdict (from the original run)",
|
|
971
|
+
f'<p class="verdict {tone}">{esc(text)}</p>'
|
|
972
|
+
+ _kv(facts)
|
|
973
|
+
+ "<p>The individual gate checks were not retained, so no pass/fail table is shown. "
|
|
974
|
+
"Re-run with force to re-verify.</p>",
|
|
975
|
+
tone=tone,
|
|
976
|
+
),
|
|
977
|
+
_card(
|
|
978
|
+
"Not retained in a reused result",
|
|
979
|
+
"<ul>"
|
|
980
|
+
+ "".join(f"<li>{esc(item)}</li>" for item in not_retained)
|
|
981
|
+
+ "</ul>"
|
|
982
|
+
+ f"<p>{esc(result.get('evidence_unavailable') or '')}</p>",
|
|
983
|
+
tone="warn",
|
|
984
|
+
),
|
|
985
|
+
_card(
|
|
986
|
+
"Out-of-sample aggregate",
|
|
987
|
+
# The fresh report's rows, so a placeholder zero beside an unavailable flag
|
|
988
|
+
# reads as not measured here too.
|
|
989
|
+
_table(["Metric", "Value"], _metric_rows(result.get("oos_aggregate") or {})),
|
|
990
|
+
),
|
|
991
|
+
*_reused_holdout_card(result.get("holdout_record")),
|
|
992
|
+
]
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _reused_holdout_card(holdout: Any) -> List[str]:
|
|
996
|
+
"""What the original run recorded of its holdout, in the terminal's words.
|
|
997
|
+
|
|
998
|
+
Found in review: the reused payload carried a recorded holdout and took it off the
|
|
999
|
+
not-retained list, while this report showed it nowhere - neither present nor missing.
|
|
1000
|
+
"""
|
|
1001
|
+
if not isinstance(holdout, dict):
|
|
1002
|
+
return []
|
|
1003
|
+
from quantred.analytics.reporting import format_holdout
|
|
1004
|
+
|
|
1005
|
+
return [_card("Holdout (from the original run)", _pre(format_holdout(holdout)))]
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _walkforward_verdicts_card(gate_report: Dict[str, Any], evidence: Optional[Dict[str, Any]]) -> str:
|
|
1009
|
+
"""The terminal's "Verdicts" block: statistical validation and evidence completeness,
|
|
1010
|
+
with execution viability named as not assessed here (a walk-forward has no capital)."""
|
|
1011
|
+
from quantred.analytics.reporting import format_verdicts
|
|
1012
|
+
from quantred.services.analysis import evidence_verdicts
|
|
1013
|
+
|
|
1014
|
+
if not gate_report.get("checks"):
|
|
1015
|
+
return ""
|
|
1016
|
+
prereq = (evidence or {}).get("prerequisites")
|
|
1017
|
+
if prereq:
|
|
1018
|
+
statistical, completeness = evidence_verdicts(gate_report, prereq)
|
|
1019
|
+
else:
|
|
1020
|
+
failed = [n for n, c in gate_report["checks"].items() if not c.get("passed")]
|
|
1021
|
+
statistical = "PASS" if gate_report.get("promotable") else f"FAIL - {', '.join(failed)}"
|
|
1022
|
+
completeness = None
|
|
1023
|
+
return _card("Verdicts", _pre(format_verdicts(statistical=statistical, evidence=completeness)))
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _walkforward_sections(result: Dict[str, Any], extras: Dict[str, Any]) -> List[str]:
|
|
1027
|
+
from quantred.analytics import charts
|
|
1028
|
+
|
|
1029
|
+
if result.get("memoized") and "folds" not in result:
|
|
1030
|
+
return _reused_walkforward_sections(result)
|
|
1031
|
+
from quantred.optimization.walk_forward import format_gate_value
|
|
1032
|
+
|
|
1033
|
+
gate_report = result.get("gate_report") or {}
|
|
1034
|
+
checks = gate_report.get("checks") or {}
|
|
1035
|
+
evidence = result.get("evidence")
|
|
1036
|
+
# Passing the six gates is not a clearance while a prerequisite is unmet or unknown:
|
|
1037
|
+
# the colour and the words both say so rather than leaving it to a later section.
|
|
1038
|
+
if not gate_report.get("promotable"):
|
|
1039
|
+
verdict_class, verdict_text = "fail", "NOT PROMOTABLE"
|
|
1040
|
+
elif evidence and evidence.get("clear"):
|
|
1041
|
+
verdict_class, verdict_text = "pass", "PROMOTABLE — evidence clear"
|
|
1042
|
+
else:
|
|
1043
|
+
verdict_class, verdict_text = "warn", "PROMOTABLE (statistical gates only) — evidence NOT clear"
|
|
1044
|
+
out = [
|
|
1045
|
+
_card(
|
|
1046
|
+
"Verdict",
|
|
1047
|
+
f'<p class="verdict {verdict_class}">{esc(verdict_text)}</p>'
|
|
1048
|
+
+ _table(
|
|
1049
|
+
["", "Gate", "Value", "Threshold", "Computed over"],
|
|
1050
|
+
[
|
|
1051
|
+
[
|
|
1052
|
+
_pill(c.get("passed")),
|
|
1053
|
+
esc(n),
|
|
1054
|
+
esc(format_gate_value(n, c.get("value"))),
|
|
1055
|
+
esc(format_gate_value(n, c.get("threshold"))),
|
|
1056
|
+
esc(c.get("basis", "")),
|
|
1057
|
+
]
|
|
1058
|
+
for n, c in sorted(checks.items())
|
|
1059
|
+
],
|
|
1060
|
+
raw=True,
|
|
1061
|
+
)
|
|
1062
|
+
+ (
|
|
1063
|
+
_chart(
|
|
1064
|
+
_try_chart(
|
|
1065
|
+
lambda: charts.render_gate_png(
|
|
1066
|
+
checks,
|
|
1067
|
+
promotable=bool(gate_report.get("promotable")),
|
|
1068
|
+
title="WALK-FORWARD VERDICT",
|
|
1069
|
+
stats=[
|
|
1070
|
+
("OOS Sharpe (median)", result.get("median_oos_sharpe", 0.0)),
|
|
1071
|
+
("Efficiency (OOS / IS)", result.get("median_efficiency", 0.0)),
|
|
1072
|
+
("OOS trades", result.get("total_oos_trades", 0)),
|
|
1073
|
+
],
|
|
1074
|
+
)
|
|
1075
|
+
),
|
|
1076
|
+
"walk-forward gate scorecard",
|
|
1077
|
+
)
|
|
1078
|
+
if checks
|
|
1079
|
+
else ""
|
|
1080
|
+
),
|
|
1081
|
+
tone=verdict_class,
|
|
1082
|
+
),
|
|
1083
|
+
_evidence_card(evidence),
|
|
1084
|
+
_walkforward_verdicts_card(gate_report, evidence),
|
|
1085
|
+
_card(
|
|
1086
|
+
"Folds",
|
|
1087
|
+
_table(
|
|
1088
|
+
["#", "IS window", "OOS window", "IS Sharpe", "OOS Sharpe", "OOS trades", "Trials"],
|
|
1089
|
+
[
|
|
1090
|
+
[
|
|
1091
|
+
f.get("index"),
|
|
1092
|
+
f"{_short_date((f.get('is_window') or {}).get('start'))}→"
|
|
1093
|
+
f"{_short_date((f.get('is_window') or {}).get('end'))}",
|
|
1094
|
+
f"{_short_date((f.get('oos_window') or {}).get('start'))}→"
|
|
1095
|
+
f"{_short_date((f.get('oos_window') or {}).get('end'))}",
|
|
1096
|
+
_num(f.get("is_sharpe"), "{:+.2f}"),
|
|
1097
|
+
_num(f.get("oos_sharpe"), "{:+.2f}"),
|
|
1098
|
+
f.get("oos_trades"),
|
|
1099
|
+
f.get("n_trials"),
|
|
1100
|
+
]
|
|
1101
|
+
for f in result.get("folds") or []
|
|
1102
|
+
],
|
|
1103
|
+
),
|
|
1104
|
+
),
|
|
1105
|
+
_card(
|
|
1106
|
+
"Out-of-sample aggregate",
|
|
1107
|
+
_table(
|
|
1108
|
+
["Metric", "Value"],
|
|
1109
|
+
_metric_rows(result.get("oos_aggregate") or {}),
|
|
1110
|
+
),
|
|
1111
|
+
),
|
|
1112
|
+
]
|
|
1113
|
+
if result.get("holdout"):
|
|
1114
|
+
out.append(
|
|
1115
|
+
_card(
|
|
1116
|
+
"Holdout",
|
|
1117
|
+
_table(
|
|
1118
|
+
["Metric", "Value"],
|
|
1119
|
+
[[k, _metric_cell(k, v)] for k, v in sorted((result["holdout"]).items())],
|
|
1120
|
+
),
|
|
1121
|
+
)
|
|
1122
|
+
)
|
|
1123
|
+
return out
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def _info_sections(result: Dict[str, Any], extras: Dict[str, Any]) -> List[str]:
|
|
1127
|
+
card = _information_card(result)
|
|
1128
|
+
body = [card] if card else []
|
|
1129
|
+
body.append(
|
|
1130
|
+
_card(
|
|
1131
|
+
"How to read this",
|
|
1132
|
+
f"<p>{esc(result.get('note', ''))}</p>",
|
|
1133
|
+
)
|
|
1134
|
+
)
|
|
1135
|
+
return body
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def _footer() -> str:
|
|
1139
|
+
from quantred.settings import BRAND
|
|
1140
|
+
|
|
1141
|
+
return (
|
|
1142
|
+
f"<footer><p>Generated by {BRAND}. This file is self-contained: opening it "
|
|
1143
|
+
"issues no network requests. It is a research artifact, not investment advice.</p></footer>"
|
|
1144
|
+
)
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def write_html(result: Dict[str, Any], kind: str, path: str, **kwargs: Any) -> str:
|
|
1148
|
+
"""Render and write the report; return the path written."""
|
|
1149
|
+
document = render_html(result, kind, **kwargs)
|
|
1150
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
1151
|
+
fh.write(document)
|
|
1152
|
+
return path
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
def _json_block(payload: Any) -> str: # pragma: no cover - reserved for a detail view
|
|
1156
|
+
return f"<pre>{esc(json.dumps(payload, indent=2, sort_keys=True))}</pre>"
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
#: Inline, dependency-free, and light/dark aware. System font stacks only - a web
|
|
1160
|
+
#: font would be an external request, which this document does not make.
|
|
1161
|
+
_CSS = """
|
|
1162
|
+
:root { color-scheme: light dark;
|
|
1163
|
+
--bg:#ffffff; --fg:#1b1f24; --muted:#6b7280; --line:#e5e7eb; --card:#ffffff;
|
|
1164
|
+
--pass:#16a34a; --fail:#dc2626; --warn:#b45309; --accent:#2563eb; }
|
|
1165
|
+
@media (prefers-color-scheme: dark) { :root {
|
|
1166
|
+
--bg:#0f1216; --fg:#e6e8eb; --muted:#9aa3ad; --line:#2a2f36; --card:#161a20;
|
|
1167
|
+
--pass:#4ade80; --fail:#f87171; --warn:#fbbf24; --accent:#60a5fa; } }
|
|
1168
|
+
* { box-sizing: border-box; }
|
|
1169
|
+
body { margin:0; background:var(--bg); color:var(--fg); font:15px/1.55 -apple-system,
|
|
1170
|
+
BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
|
|
1171
|
+
main { max-width: 60rem; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
|
1172
|
+
h1 { font-size:1.6rem; margin:0 0 1.25rem; }
|
|
1173
|
+
h2 { font-size:1.05rem; margin:0 0 .75rem; letter-spacing:.02em; text-transform:uppercase;
|
|
1174
|
+
color:var(--muted); }
|
|
1175
|
+
h3 { font-size:.95rem; margin:1.25rem 0 .5rem; }
|
|
1176
|
+
.card { background:var(--card); border:1px solid var(--line); border-radius:10px;
|
|
1177
|
+
padding:1.1rem 1.25rem; margin:0 0 1rem; }
|
|
1178
|
+
.card.pass { border-left:4px solid var(--pass); }
|
|
1179
|
+
.card.fail { border-left:4px solid var(--fail); }
|
|
1180
|
+
.card.warn { border-left:4px solid var(--warn); }
|
|
1181
|
+
.banner { border-radius:8px; padding:.8rem 1rem; margin:0 0 .75rem; font-weight:600;
|
|
1182
|
+
border:1px solid var(--line); }
|
|
1183
|
+
.banner.fail { background:color-mix(in srgb, var(--fail) 12%, transparent); border-color:var(--fail); }
|
|
1184
|
+
.banner.warn, .banner.reuse { background:color-mix(in srgb, var(--warn) 14%, transparent);
|
|
1185
|
+
border-color:var(--warn); }
|
|
1186
|
+
.banner.note { background:color-mix(in srgb, var(--accent) 10%, transparent); border-color:var(--accent); }
|
|
1187
|
+
.verdict { font-size:1.2rem; font-weight:700; margin:.25rem 0 1rem; }
|
|
1188
|
+
.verdict.pass { color:var(--pass); } .verdict.fail { color:var(--fail); }
|
|
1189
|
+
.verdict.warn { color:var(--warn); }
|
|
1190
|
+
.scroll { overflow-x:auto; }
|
|
1191
|
+
table { border-collapse:collapse; width:100%; font-size:.9rem; }
|
|
1192
|
+
th, td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--line); white-space:nowrap; }
|
|
1193
|
+
th { color:var(--muted); font-weight:600; }
|
|
1194
|
+
td:last-child, th:last-child { white-space:normal; }
|
|
1195
|
+
dl.kv { display:grid; grid-template-columns:repeat(auto-fit,minmax(15rem,1fr)); gap:.4rem 1.5rem; margin:0; }
|
|
1196
|
+
dl.kv div { display:flex; justify-content:space-between; gap:1rem; border-bottom:1px solid var(--line);
|
|
1197
|
+
padding:.3rem 0; }
|
|
1198
|
+
dt { color:var(--muted); } dd { margin:0; font-variant-numeric:tabular-nums; text-align:right; }
|
|
1199
|
+
.pill { font-size:.72rem; font-weight:700; padding:.12rem .45rem; border-radius:999px; }
|
|
1200
|
+
.pill.pass { background:var(--pass); color:#fff; } .pill.fail { background:var(--fail); color:#fff; }
|
|
1201
|
+
.pill.neutral { background:var(--muted); color:#fff; }
|
|
1202
|
+
img { max-width:100%; height:auto; display:block; margin:1rem 0; border-radius:6px; }
|
|
1203
|
+
.empty, .note { color:var(--muted); font-style:italic; }
|
|
1204
|
+
code { font-family:ui-monospace, SFMono-Regular, Menlo, monospace; font-size:.85em; }
|
|
1205
|
+
pre { overflow-x:auto; background:var(--card); padding:.75rem; border-radius:6px; }
|
|
1206
|
+
footer { color:var(--muted); font-size:.82rem; margin-top:2rem; border-top:1px solid var(--line);
|
|
1207
|
+
padding-top:1rem; }
|
|
1208
|
+
"""
|