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,1672 @@
|
|
|
1
|
+
"""Human-readable rendering of research results.
|
|
2
|
+
|
|
3
|
+
Kept separate from metric *computation* so the numbers can be consumed
|
|
4
|
+
programmatically (e.g. by the optimizer, or rendered to another medium) without
|
|
5
|
+
any formatting concerns. Nothing here computes: a renderer that derives a number
|
|
6
|
+
is a renderer that can disagree with the report it is summarizing.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
12
|
+
|
|
13
|
+
from quantred.analytics import metrics as m
|
|
14
|
+
from quantred.analytics import performance
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
#: The capital range the capacity search bisects over. Here, below the service that
|
|
20
|
+
#: searches it, so the words describing a bound cannot drift from the bound.
|
|
21
|
+
CAPACITY_SEARCH_FLOOR = 1e3
|
|
22
|
+
CAPACITY_SEARCH_CEILING = 1e12
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def capacity_text(diagnostics: Dict[str, Any]) -> Optional[str]:
|
|
26
|
+
"""Words for a portfolio's capacity result, or ``None`` when none was computed.
|
|
27
|
+
|
|
28
|
+
A number only when one was solved for; otherwise which bound the search hit, so a
|
|
29
|
+
search sentinel never reads as a measured capital.
|
|
30
|
+
"""
|
|
31
|
+
if "capacity_status" not in diagnostics:
|
|
32
|
+
return None
|
|
33
|
+
status = diagnostics["capacity_status"]
|
|
34
|
+
capital = diagnostics.get("capacity_capital")
|
|
35
|
+
if status == "measured" and capital is not None:
|
|
36
|
+
return f"≈ ${capital:,.0f} (where √-impact erases the alpha)"
|
|
37
|
+
return {
|
|
38
|
+
"no_edge": "not applicable - expected gross alpha is not positive, so there is no capital at which "
|
|
39
|
+
"it survives cost",
|
|
40
|
+
"no_priced_holdings": "not measured - no holding had the price and volume history the cost model needs",
|
|
41
|
+
"below_search_floor": "not measured - the alpha does not cover √-impact cost even at the smallest "
|
|
42
|
+
f"capital searched (${CAPACITY_SEARCH_FLOOR:,.0f})",
|
|
43
|
+
"above_search_ceiling": "not measured - the alpha still covers √-impact cost at the largest capital "
|
|
44
|
+
f"searched (${CAPACITY_SEARCH_CEILING:,.0f})",
|
|
45
|
+
}.get(status, f"unknown ({status})")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def small_aware(value: Any, spec: str) -> str:
|
|
49
|
+
"""``format(value, spec)``, except a nonzero value that the spec would print as zero.
|
|
50
|
+
|
|
51
|
+
``+0.0000`` and ``-0.00`` read as "exactly nothing" for a value that is merely
|
|
52
|
+
small; those are shown in scientific notation instead (a percent spec shows the
|
|
53
|
+
percent).
|
|
54
|
+
"""
|
|
55
|
+
if value is None:
|
|
56
|
+
return "n/a"
|
|
57
|
+
text = format(value, spec)
|
|
58
|
+
if value != 0 and not any(ch in "123456789" for ch in text):
|
|
59
|
+
scale = 100.0 if spec.endswith("%") else 1.0
|
|
60
|
+
return f"{value * scale:.1e}" + ("%" if scale == 100.0 else "")
|
|
61
|
+
return text
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# (metric key, label, format spec), grouped into report sections.
|
|
65
|
+
_SECTIONS = [
|
|
66
|
+
(
|
|
67
|
+
"Returns",
|
|
68
|
+
[
|
|
69
|
+
("total_return", "Total Return", "{:.2f}%"),
|
|
70
|
+
("cagr", "CAGR", "{:.2f}%"),
|
|
71
|
+
("buy_hold_return", "Buy & Hold (universe)", "{:.2f}%"),
|
|
72
|
+
("benchmark_buy_hold_return", "Buy & Hold (benchmark)", "{:.2f}%"),
|
|
73
|
+
("annualized_volatility", "Annualized Volatility", "{:.2f}%"),
|
|
74
|
+
],
|
|
75
|
+
),
|
|
76
|
+
(
|
|
77
|
+
"Risk-adjusted",
|
|
78
|
+
[
|
|
79
|
+
("sharpe_ratio", "Sharpe Ratio", "{:.2f}"),
|
|
80
|
+
("probabilistic_sharpe_ratio", " Probabilistic Sharpe", "{:.2f}"),
|
|
81
|
+
("deflated_sharpe_ratio", " Deflated Sharpe", "{:.2f}"),
|
|
82
|
+
("sortino_ratio", "Sortino Ratio", "{:.2f}"),
|
|
83
|
+
("calmar_ratio", "Calmar Ratio", "{:.2f}"),
|
|
84
|
+
("martin_ratio", "Martin Ratio (UPI)", "{:.2f}"),
|
|
85
|
+
("information_ratio", "Information Ratio", "{:.2f}"),
|
|
86
|
+
("treynor_ratio", "Treynor Ratio", "{:.2f}"),
|
|
87
|
+
],
|
|
88
|
+
),
|
|
89
|
+
(
|
|
90
|
+
"Drawdown & tail",
|
|
91
|
+
[
|
|
92
|
+
("max_drawdown", "Max Drawdown", "{:.2f}%"),
|
|
93
|
+
("max_drawdown_duration", "Max DD Duration (days)", "{:.0f}"),
|
|
94
|
+
("ulcer_index", "Ulcer Index", "{:.2f}"),
|
|
95
|
+
("recovery_factor", "Recovery Factor", "{:.2f}"),
|
|
96
|
+
("var_95", "VaR (95%)", "{:.2f}%"),
|
|
97
|
+
("cvar_95", "CVaR (95%)", "{:.2f}%"),
|
|
98
|
+
("var_99", "VaR (99%)", "{:.2f}%"),
|
|
99
|
+
("exposure", "Time in Market", "{:.1f}%"),
|
|
100
|
+
],
|
|
101
|
+
),
|
|
102
|
+
(
|
|
103
|
+
"Trades",
|
|
104
|
+
[
|
|
105
|
+
("total_trades", "Total Trades", "{:.0f}"),
|
|
106
|
+
("win_rate", "Win Rate", "{:.2f}%"),
|
|
107
|
+
("profit_factor", "Profit Factor", "{:.2f}"),
|
|
108
|
+
("payoff_ratio", "Payoff Ratio", "{:.2f}"),
|
|
109
|
+
("expectancy", "Expectancy", "${:.2f}"),
|
|
110
|
+
("sqn", "System Quality (SQN)", "{:.2f}"),
|
|
111
|
+
("kelly_criterion", "Kelly Fraction", "{:.2f}"),
|
|
112
|
+
("max_consecutive_wins", "Max Consecutive Wins", "{:.0f}"),
|
|
113
|
+
("max_consecutive_losses", "Max Consecutive Losses", "{:.0f}"),
|
|
114
|
+
("avg_trade_duration", "Avg Trade Duration (h)", "{:.2f}"),
|
|
115
|
+
("mae_pct", "Avg MAE", "{:.2f}%"),
|
|
116
|
+
("mfe_pct", "Avg MFE", "{:.2f}%"),
|
|
117
|
+
("avg_win", "Average Win", "${:.2f}"),
|
|
118
|
+
("avg_loss", "Average Loss", "${:.2f}"),
|
|
119
|
+
("largest_win", "Largest Win", "${:.2f}"),
|
|
120
|
+
("largest_loss", "Largest Loss", "${:.2f}"),
|
|
121
|
+
],
|
|
122
|
+
),
|
|
123
|
+
(
|
|
124
|
+
"Benchmark-relative",
|
|
125
|
+
[
|
|
126
|
+
("alpha", "Alpha (per period)", "{:.5f}"),
|
|
127
|
+
("beta", "Beta", "{:.2f}"),
|
|
128
|
+
("r_squared", "R-squared", "{:.2f}"),
|
|
129
|
+
],
|
|
130
|
+
),
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# Flat view for backward compatibility / simple consumers.
|
|
135
|
+
_ROWS = [row for _, rows in _SECTIONS for row in rows]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _execution_lines(execution: Optional[Dict[str, Any]]) -> List[str]:
|
|
139
|
+
"""The gap between the book the sizer asked for and the one that could be traded.
|
|
140
|
+
|
|
141
|
+
Silent by construction until now: whole-share rounding just happens, and it scales
|
|
142
|
+
with how small the account is. Shown whenever anything was lost, and shown with
|
|
143
|
+
both numbers beside their thresholds rather than as a verdict on its own - the same
|
|
144
|
+
rule the promotion gates follow.
|
|
145
|
+
"""
|
|
146
|
+
if not execution:
|
|
147
|
+
return []
|
|
148
|
+
verdict = performance.execution_verdict(execution)
|
|
149
|
+
if verdict["executable"] is None or (verdict["executable"] and not execution.get("rounding_drag_pct")):
|
|
150
|
+
return []
|
|
151
|
+
|
|
152
|
+
mark = "(!)" if not verdict["executable"] else "(i)"
|
|
153
|
+
lines = [
|
|
154
|
+
"",
|
|
155
|
+
f"--- Execution & cost {mark} ---",
|
|
156
|
+
f"{'Intended notional':28}${execution.get('requested_notional', 0.0):,.2f}",
|
|
157
|
+
f"{'Filled notional':28}${execution.get('filled_notional', 0.0):,.2f}",
|
|
158
|
+
]
|
|
159
|
+
for name, check in verdict["checks"].items():
|
|
160
|
+
flag = "PASS" if check["passed"] else "FAIL"
|
|
161
|
+
# Unit per check, from the module that declares the check - not one format
|
|
162
|
+
# applied to all of them. `book_breadth` carries a position count and rendered
|
|
163
|
+
# as "1.00%", which is a different claim from the one the check makes.
|
|
164
|
+
value = performance.format_execution_value(name, check["value"])
|
|
165
|
+
threshold = performance.format_execution_value(name, check["threshold"])
|
|
166
|
+
lines.append(f" [{flag}] {name:22}{value} vs {threshold}")
|
|
167
|
+
lost = execution.get("positions_rounded_to_zero", 0) + execution.get("positions_below_min_notional", 0)
|
|
168
|
+
if lost:
|
|
169
|
+
lines.append(
|
|
170
|
+
f"{' entries never opened':28}{lost} "
|
|
171
|
+
f"({execution.get('positions_rounded_to_zero', 0)} rounded to zero, "
|
|
172
|
+
f"{execution.get('positions_below_min_notional', 0)} below min notional)"
|
|
173
|
+
)
|
|
174
|
+
if not verdict["executable"]:
|
|
175
|
+
lines.append(" Not the book that was validated - see the failing check above.")
|
|
176
|
+
return lines
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _leg_lines(legs: Optional[Dict[str, Any]]) -> List[str]:
|
|
180
|
+
"""Each side of a long/short book, separately.
|
|
181
|
+
|
|
182
|
+
Shown only when both sides actually traded: a long-only run has nothing to
|
|
183
|
+
decompose, and a table with an empty short row is noise that teaches readers to
|
|
184
|
+
skip the block.
|
|
185
|
+
|
|
186
|
+
The question it exists to answer is what a net figure structurally cannot: a
|
|
187
|
+
near-zero net beta is either genuinely small exposure on both sides, or a large
|
|
188
|
+
long beta cancelling a large short one. Same number, opposite risk.
|
|
189
|
+
"""
|
|
190
|
+
if not legs or not all(legs.get(side, {}).get("trades") for side in ("long", "short")):
|
|
191
|
+
return []
|
|
192
|
+
lines = [
|
|
193
|
+
"",
|
|
194
|
+
"--- Legs (diagnostic; no thresholds) ---",
|
|
195
|
+
f" {'leg':7}{'return':>9}{'vol':>8}{'max DD':>9}{'beta':>8}{'corr':>7}{'trades':>8}{'cost':>11}",
|
|
196
|
+
]
|
|
197
|
+
for name in ("long", "short"):
|
|
198
|
+
leg = legs[name]
|
|
199
|
+
beta, corr = leg.get("beta"), leg.get("benchmark_correlation")
|
|
200
|
+
lines.append(
|
|
201
|
+
f" {name:7}{leg['return_pct']:>+8.2f}%{leg['volatility_pct']:>7.3f}%"
|
|
202
|
+
f"{leg['max_drawdown_pct']:>8.2f}%"
|
|
203
|
+
f"{'n/a' if beta is None else format(beta, '.3f'):>8}"
|
|
204
|
+
f"{'n/a' if corr is None else format(corr, '.2f'):>7}"
|
|
205
|
+
f"{leg['trades']:>8}{leg['cost']:>11,.0f}"
|
|
206
|
+
)
|
|
207
|
+
betas = [legs[name].get("beta") for name in ("long", "short")]
|
|
208
|
+
if all(b is not None for b in betas) and min(abs(b) for b in betas) > 0.3:
|
|
209
|
+
lines.append(
|
|
210
|
+
" Both legs carry real market exposure - a small net beta here is two "
|
|
211
|
+
"exposures cancelling, not an absence of them."
|
|
212
|
+
)
|
|
213
|
+
return lines
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def format_universe_provenance(
|
|
217
|
+
*,
|
|
218
|
+
candidates: Sequence[str],
|
|
219
|
+
resolved: Sequence[str],
|
|
220
|
+
scanner: Optional[str],
|
|
221
|
+
scan_clock: Optional[str],
|
|
222
|
+
source: str,
|
|
223
|
+
replayed: bool,
|
|
224
|
+
) -> List[str]:
|
|
225
|
+
"""Where this run's universe came from, and what that does and does not guarantee.
|
|
226
|
+
|
|
227
|
+
A 61-name large-cap list is not "the market", and a report that leaves the universe
|
|
228
|
+
in the background invites it to be read as one. The awkward line is the last: a
|
|
229
|
+
hand-supplied candidate list is *today's* names applied to history, so anything that
|
|
230
|
+
left the list - delisted, acquired, collapsed - is already absent from every
|
|
231
|
+
backtest run over it.
|
|
232
|
+
|
|
233
|
+
That is stated rather than measured, because measuring it needs point-in-time
|
|
234
|
+
membership data this project does not ingest. Saying "this is survivorship-prone by
|
|
235
|
+
construction" is honest; computing a survivorship number from a list that has none
|
|
236
|
+
would not be.
|
|
237
|
+
"""
|
|
238
|
+
lines = [
|
|
239
|
+
"",
|
|
240
|
+
"=== Universe provenance ===",
|
|
241
|
+
f" {'candidates':16}{len(candidates)} names from {source}",
|
|
242
|
+
]
|
|
243
|
+
if scanner and scanner != "none":
|
|
244
|
+
clock = f" as of {scan_clock}" if scan_clock else ""
|
|
245
|
+
lines.append(f" {'scanner':16}{scanner}{clock}")
|
|
246
|
+
lines.append(f" {'resolved':16}{len(resolved)} of {len(candidates)} names")
|
|
247
|
+
else:
|
|
248
|
+
lines.append(f" {'scanner':16}none - candidates traded as-is")
|
|
249
|
+
lines.append(f" {'universe':16}{'replayed from config' if replayed else 'resolved this run'}")
|
|
250
|
+
lines.append(
|
|
251
|
+
f" {'survivorship':16}a hand-supplied list is today's names applied to history; "
|
|
252
|
+
"membership was not point-in-time"
|
|
253
|
+
)
|
|
254
|
+
return lines
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def format_verdicts(
|
|
258
|
+
*,
|
|
259
|
+
statistical: Optional[Dict[str, Any]] = None,
|
|
260
|
+
execution: Optional[Dict[str, Any]] = None,
|
|
261
|
+
evidence: Optional[Dict[str, Any]] = None,
|
|
262
|
+
) -> List[str]:
|
|
263
|
+
"""The three facts about a candidate, side by side and separately labelled.
|
|
264
|
+
|
|
265
|
+
They were already three verdicts that never collapse into one another, but each was
|
|
266
|
+
printed by a different command at a different moment, so nothing ever showed a
|
|
267
|
+
reader all three. That is how a backtest replay reads as "approved" when it only
|
|
268
|
+
means "this saved config runs and its history looks good".
|
|
269
|
+
|
|
270
|
+
A verdict this command could not assess is printed as **not assessed here**, with
|
|
271
|
+
the command that would assess it - the same rule the prerequisites follow, because
|
|
272
|
+
an unknown rendered as a blank is an unknown a reader fills in optimistically.
|
|
273
|
+
"""
|
|
274
|
+
rows = [
|
|
275
|
+
("Statistical validation", statistical, "was the edge real, and not overfit", "walkforward"),
|
|
276
|
+
("Execution viability", execution, "can this book be traded at this capital", "backtest"),
|
|
277
|
+
(
|
|
278
|
+
"Evidence completeness",
|
|
279
|
+
evidence,
|
|
280
|
+
"what has actually been checked",
|
|
281
|
+
"walkforward --bootstrap-skill",
|
|
282
|
+
),
|
|
283
|
+
]
|
|
284
|
+
lines = ["", "=== Verdicts ==="]
|
|
285
|
+
for label, verdict, question, command in rows:
|
|
286
|
+
if verdict is None:
|
|
287
|
+
lines.append(f" {label:24}not assessed here - {question} (`{command}`)")
|
|
288
|
+
continue
|
|
289
|
+
lines.append(f" {label:24}{verdict}")
|
|
290
|
+
lines.append(" Three separate facts. Clearing one says nothing about the others.")
|
|
291
|
+
return lines
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def format_backtest_verdicts(executability: Dict[str, Any]) -> List[str]:
|
|
295
|
+
"""A backtest can speak to execution and to nothing else; this says so.
|
|
296
|
+
|
|
297
|
+
``executability`` is :func:`performance.execution_verdict`'s answer. One definition
|
|
298
|
+
for the terminal and the HTML report, so the "not assessed here" lines that stop a
|
|
299
|
+
replay reading as approval cannot be missing from either.
|
|
300
|
+
"""
|
|
301
|
+
executable = executability.get("executable")
|
|
302
|
+
summary = {
|
|
303
|
+
None: "UNKNOWN - nothing was attempted",
|
|
304
|
+
True: "PASS",
|
|
305
|
+
False: f"FAIL - {executability.get('reason')}",
|
|
306
|
+
}[executable]
|
|
307
|
+
return format_verdicts(execution=summary)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def format_backtest_report(
|
|
311
|
+
metrics: Dict[str, float],
|
|
312
|
+
initial_capital: float,
|
|
313
|
+
final_capital: Optional[float],
|
|
314
|
+
title: str = "Backtest Results",
|
|
315
|
+
execution: Optional[Dict[str, Any]] = None,
|
|
316
|
+
legs: Optional[Dict[str, Any]] = None,
|
|
317
|
+
) -> str:
|
|
318
|
+
"""Render metrics as an aligned, fixed-width text block, grouped by section."""
|
|
319
|
+
# A reused result may carry no final capital at all; absent is not zero.
|
|
320
|
+
final = f"${final_capital:,.2f}" if final_capital is not None else "not recorded"
|
|
321
|
+
lines = [f"=== {title} ===", f"{'Capital':28}${initial_capital:,.2f} -> {final}"]
|
|
322
|
+
if metrics.get("low_sample"):
|
|
323
|
+
lines.append(
|
|
324
|
+
f"{'(!) low sample':28}fewer than {performance.LOW_SAMPLE_TRADES} trades - treat ratios with caution"
|
|
325
|
+
)
|
|
326
|
+
if not metrics.get("benchmark_available", True):
|
|
327
|
+
lines.append(f"{'(i) no benchmark':28}alpha/beta/information-ratio unavailable")
|
|
328
|
+
elif not metrics.get("treynor_available", True):
|
|
329
|
+
lines.append(
|
|
330
|
+
f"{'(i) beta near zero':28}Treynor unavailable (|beta| < "
|
|
331
|
+
f"{m.MIN_ABS_BETA_FOR_TREYNOR:g}; excess return per unit of beta is not "
|
|
332
|
+
f"meaningful for a book with no market exposure)"
|
|
333
|
+
)
|
|
334
|
+
lines.extend(_execution_lines(execution))
|
|
335
|
+
lines.extend(_leg_lines(legs))
|
|
336
|
+
undeflated = not metrics.get("deflation_applied", True)
|
|
337
|
+
for section, rows in _SECTIONS:
|
|
338
|
+
section_lines = []
|
|
339
|
+
for key, label, fmt in rows:
|
|
340
|
+
if key == "deflated_sharpe_ratio" and undeflated:
|
|
341
|
+
# One trial deflates against nothing, so the row says what it is rather
|
|
342
|
+
# than borrowing the name of a correction that was not applied.
|
|
343
|
+
# Named for what the number is - a probability, not a Sharpe ratio, which
|
|
344
|
+
# the earlier "Sharpe (undeflated)" read as.
|
|
345
|
+
label = " PSR, undeflated (1 trial)"
|
|
346
|
+
if key in metrics and _is_probability(key):
|
|
347
|
+
section_lines.append(f"{label:28}{_prob_cell(metrics[key], key)}")
|
|
348
|
+
elif key in metrics:
|
|
349
|
+
value = fmt.format(metrics[key]) if metrics[key] != float("inf") else "inf"
|
|
350
|
+
section_lines.append(f"{label:28}{value}")
|
|
351
|
+
if section_lines:
|
|
352
|
+
lines.append(f"--- {section} ---")
|
|
353
|
+
lines.extend(section_lines)
|
|
354
|
+
lines.append("=" * (len(title) + 8))
|
|
355
|
+
return "\n".join(lines)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
#: How to get back what a reused backtest never kept, in both spellings: a reader of the
|
|
359
|
+
#: payload may be on either surface. Here rather than in the service that fills the
|
|
360
|
+
#: payload, because this layer renders it and must not import upward.
|
|
361
|
+
BACKTEST_RECOVERY_NOTE = "Re-run with --force (CLI) or force=true (MCP) to recompute them."
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def format_not_recorded(payload: Dict[str, Any]) -> str:
|
|
365
|
+
"""One line naming what a reused result never kept, and how to get it back.
|
|
366
|
+
|
|
367
|
+
Empty for a fresh result, so a caller can print it unconditionally.
|
|
368
|
+
"""
|
|
369
|
+
missing = payload.get("not_recorded") or []
|
|
370
|
+
if not missing:
|
|
371
|
+
return ""
|
|
372
|
+
return f"Not recorded for this reused result: {', '.join(missing)}. {BACKTEST_RECOVERY_NOTE}"
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def log_backtest_report(
|
|
376
|
+
metrics: Dict[str, float],
|
|
377
|
+
initial_capital: float,
|
|
378
|
+
final_capital: float,
|
|
379
|
+
execution: Optional[Dict[str, Any]] = None,
|
|
380
|
+
legs: Optional[Dict[str, Any]] = None,
|
|
381
|
+
) -> None:
|
|
382
|
+
"""Log the rendered report at INFO.
|
|
383
|
+
|
|
384
|
+
``legs`` is forwarded. It was accepted here and dropped on the way to the
|
|
385
|
+
formatter, so the long/short decomposition - the block that distinguishes "no
|
|
386
|
+
exposure" from "two exposures cancelling" - was unreachable from ``backtest``
|
|
387
|
+
while being fully covered by tests that called the formatter directly. A test
|
|
388
|
+
that renders *through* this function is what catches that.
|
|
389
|
+
"""
|
|
390
|
+
logger.info(
|
|
391
|
+
"\n%s",
|
|
392
|
+
format_backtest_report(metrics, initial_capital, final_capital, execution=execution, legs=legs),
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _parse_iso(text: str) -> datetime:
|
|
397
|
+
"""An ISO timestamp, on every supported Python.
|
|
398
|
+
|
|
399
|
+
``datetime.fromisoformat`` before 3.11 refuses a ``Z`` suffix and fractional seconds
|
|
400
|
+
of other than three or six digits, so a stamp such as ``03:00:00.5+00:00`` printed
|
|
401
|
+
as raw UTC text on 3.10 and as New York time on 3.11. pandas reads both.
|
|
402
|
+
"""
|
|
403
|
+
import pandas as pd
|
|
404
|
+
|
|
405
|
+
try:
|
|
406
|
+
return datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
407
|
+
except ValueError:
|
|
408
|
+
try:
|
|
409
|
+
return pd.Timestamp(text).to_pydatetime()
|
|
410
|
+
except (ValueError, TypeError) as exc:
|
|
411
|
+
raise ValueError(str(exc)) from exc
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _age_str(ts: Optional[str]) -> str:
|
|
415
|
+
"""A human ``"N days"``/``"N hours"`` age for an ISO timestamp; ``"unknown"``
|
|
416
|
+
when it can't be parsed - never let a formatting failure block the notice."""
|
|
417
|
+
if not ts:
|
|
418
|
+
return "unknown age"
|
|
419
|
+
try:
|
|
420
|
+
when = _parse_iso(ts)
|
|
421
|
+
if when.tzinfo is None:
|
|
422
|
+
when = when.replace(tzinfo=timezone.utc)
|
|
423
|
+
delta = datetime.now(timezone.utc) - when
|
|
424
|
+
except ValueError:
|
|
425
|
+
return "unknown age"
|
|
426
|
+
seconds = delta.total_seconds()
|
|
427
|
+
if seconds < 0:
|
|
428
|
+
return "0 days"
|
|
429
|
+
if seconds < 3600:
|
|
430
|
+
return f"{int(seconds // 60)} minutes"
|
|
431
|
+
if seconds < 86400:
|
|
432
|
+
return f"{seconds / 3600:.1f} hours"
|
|
433
|
+
return f"{seconds / 86400:.1f} days"
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def format_cached_notice(
|
|
437
|
+
row: Dict[str, Any], *, current_accounting: Optional[int] = None, vintage_available: bool = False
|
|
438
|
+
) -> str:
|
|
439
|
+
"""A prominent, unmistakable "this is a memo, not a fresh verification" banner.
|
|
440
|
+
|
|
441
|
+
Every command that can serve a memoized trial (``backtest``/``optimize``/
|
|
442
|
+
``walkforward``, CLI and MCP alike) renders this immediately above its report
|
|
443
|
+
so the reused case looks visually distinct everywhere, not just wherever it
|
|
444
|
+
was first implemented. Always names the original run's id and timestamp/age -
|
|
445
|
+
never let a stale number pass as freshly checked.
|
|
446
|
+
|
|
447
|
+
``vintage_available`` is ``True`` only when *this* lookup was itself keyed on
|
|
448
|
+
a bar-cache data-vintage stamp and still matched - which, by construction,
|
|
449
|
+
means the match's underlying data vintage is identical to the stored trial's
|
|
450
|
+
(that's what made the dedup hash match). In that case the data caveat below is
|
|
451
|
+
replaced with a positive statement rather than dropped silently; a run without
|
|
452
|
+
the bar cache (``--cache``/``--offline``) still gets the original caveat, since
|
|
453
|
+
for it nothing has actually changed.
|
|
454
|
+
"""
|
|
455
|
+
ts = row.get("ts")
|
|
456
|
+
lines = [
|
|
457
|
+
f"=== REUSED — trial {row.get('id', '?')} from {ts or 'unknown time'} ({_age_str(ts)} old) ===",
|
|
458
|
+
"Not re-run: an identical trial already exists in the trial store. Pass --force/--rerun to re-verify.",
|
|
459
|
+
]
|
|
460
|
+
row_accounting = row.get("accounting")
|
|
461
|
+
if current_accounting is not None and row_accounting is not None and row_accounting != current_accounting:
|
|
462
|
+
lines.append(
|
|
463
|
+
f"(!) stored under accounting v{row_accounting}, engine is v{current_accounting} — "
|
|
464
|
+
"its metrics are NOT comparable to a fresh run"
|
|
465
|
+
)
|
|
466
|
+
if vintage_available:
|
|
467
|
+
lines.append(
|
|
468
|
+
"(i) data vintage confirmed: the bar cache's fetch stamp for this exact window matches the "
|
|
469
|
+
"original run's — this reuse is vintage-safe, not just same-inputs"
|
|
470
|
+
)
|
|
471
|
+
else:
|
|
472
|
+
lines.append(
|
|
473
|
+
"(i) reused on 'same requested inputs' only — not yet guaranteed identical underlying data "
|
|
474
|
+
"(no data-vintage stamp; re-run with --cache/--offline for a vintage-safe reuse); a rare vendor "
|
|
475
|
+
"correction/backfill since the original run could differ"
|
|
476
|
+
)
|
|
477
|
+
return "\n".join(lines)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def format_verdict_report(result: Dict[str, Any]) -> str:
|
|
481
|
+
"""Render a composite research result as one consolidated text report.
|
|
482
|
+
|
|
483
|
+
Pure formatting over the composite object: every number here was produced by
|
|
484
|
+
the step that owns it. The verdict banner is printed **last and loudest** -
|
|
485
|
+
the sections above it are evidence, and a report that buries its own conclusion
|
|
486
|
+
under a pretty table is worse than no report.
|
|
487
|
+
"""
|
|
488
|
+
inputs = result.get("inputs") or {}
|
|
489
|
+
window = inputs.get("window") or {}
|
|
490
|
+
universe = inputs.get("universe") or []
|
|
491
|
+
provenance = result.get("provenance") or {}
|
|
492
|
+
lines = [
|
|
493
|
+
"",
|
|
494
|
+
f"=== Research verdict: '{inputs.get('strategy', '?')}' "
|
|
495
|
+
f"{_short_date(window.get('start'))}..{_short_date(window.get('end'))} ===",
|
|
496
|
+
f" universe: {len(universe)} names ({_symbol_summary(universe)})",
|
|
497
|
+
f" timeframe {inputs.get('timeframe', '?')} | benchmark {inputs.get('benchmark', '?')} | "
|
|
498
|
+
f"cost {_cost_summary(inputs.get('cost'))}",
|
|
499
|
+
]
|
|
500
|
+
if provenance:
|
|
501
|
+
lines.append(
|
|
502
|
+
f" provenance: git {provenance.get('git_sha') or 'unknown'} | "
|
|
503
|
+
f"campaign trials {campaign_trials_text(provenance)} | "
|
|
504
|
+
f"bar requests: {_fetch_summary(provenance.get('bar_requests'))}"
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
lines.extend(_verdict_step_lines(result))
|
|
508
|
+
lines.extend(_verdict_scan_lines(result))
|
|
509
|
+
lines.extend(_verdict_alpha_lines(result))
|
|
510
|
+
lines.extend(_verdict_portfolio_lines(result))
|
|
511
|
+
lines.extend(_verdict_information_lines(result))
|
|
512
|
+
lines.extend(_verdict_banner_lines(result))
|
|
513
|
+
return "\n".join(lines)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def campaign_trials_text(provenance: Dict[str, Any]) -> str:
|
|
517
|
+
"""A verdict's campaign trial count as printed, on every surface that prints it.
|
|
518
|
+
|
|
519
|
+
A run that was not recorded reports the family without itself, and says so; a count
|
|
520
|
+
the store could not supply reads as unavailable rather than as zero.
|
|
521
|
+
"""
|
|
522
|
+
if "n_trials" not in provenance:
|
|
523
|
+
return "?"
|
|
524
|
+
count = provenance["n_trials"]
|
|
525
|
+
text = "unavailable" if count is None else str(count)
|
|
526
|
+
return text + (" (this run not counted)" if provenance.get("this_run_counted") is False else "")
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _short_date(value: Optional[str]) -> str:
|
|
530
|
+
return (value or "?")[:10]
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _symbol_summary(symbols, limit: int = 8) -> str:
|
|
534
|
+
listed = list(symbols)[:limit]
|
|
535
|
+
suffix = f", +{len(symbols) - limit} more" if len(symbols) > limit else ""
|
|
536
|
+
return ", ".join(str(s) for s in listed) + suffix if listed else "none"
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _cost_summary(cost: Optional[Dict[str, Any]]) -> str:
|
|
540
|
+
if not cost:
|
|
541
|
+
return "unknown"
|
|
542
|
+
if cost.get("gross"):
|
|
543
|
+
return "GROSS (no transaction cost charged)"
|
|
544
|
+
return (
|
|
545
|
+
f"{cost.get('commission_bps')}bps commission, impact η={cost.get('impact_eta')}, "
|
|
546
|
+
f"borrow {cost.get('borrow_bps')}bps"
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def format_offline_scan_notice(as_of) -> str:
|
|
551
|
+
"""Say plainly that an offline scan saw only what the cache already holds.
|
|
552
|
+
|
|
553
|
+
A scan picks a universe at a clock. Offline it reads local Parquet and nothing
|
|
554
|
+
else, so the selection is exactly as current as the cache and no more - and
|
|
555
|
+
nothing errors when coverage ends before that clock: the newest cached bar simply
|
|
556
|
+
becomes "the latest", and a universe chosen from stale bars is indistinguishable
|
|
557
|
+
from one chosen from fresh ones. That is precisely the case that has to announce
|
|
558
|
+
itself.
|
|
559
|
+
"""
|
|
560
|
+
return (
|
|
561
|
+
f"OFFLINE: universe resolved at {as_of.isoformat()} from cached bars only - "
|
|
562
|
+
"as current as the cache and no more. `cache status` shows the coverage."
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _fetch_summary(stats: Optional[Dict[str, Any]]) -> str:
|
|
567
|
+
"""How much of the "one shared fetch" claim actually held, as measured.
|
|
568
|
+
|
|
569
|
+
This counts *in-run sharing*, not provider access. ``fetches`` is the number of
|
|
570
|
+
requests that missed this run's own memo and reached the data client underneath -
|
|
571
|
+
and on an ``--offline`` run that client is the local bar cache, so the old wording
|
|
572
|
+
("hit the provider") reported a network round trip on a run that made none. A
|
|
573
|
+
provenance line that overstates where data came from is worse than no line, since
|
|
574
|
+
provenance is the one thing a reader cannot check for themselves.
|
|
575
|
+
"""
|
|
576
|
+
if not stats:
|
|
577
|
+
return "not measured"
|
|
578
|
+
fetches, requests = stats.get("fetches", "?"), stats.get("requests", "?")
|
|
579
|
+
return f"{fetches} of {requests} reached the data client, the rest shared within this run"
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _verdict_step_lines(result: Dict[str, Any]) -> list:
|
|
583
|
+
"""One line per step that did not simply succeed - silence means everything ran."""
|
|
584
|
+
steps = result.get("steps") or {}
|
|
585
|
+
notable = [(name, s) for name, s in steps.items() if s.get("status") != "ok"]
|
|
586
|
+
if not notable:
|
|
587
|
+
return []
|
|
588
|
+
lines = ["", " Steps:"]
|
|
589
|
+
for name, state in notable:
|
|
590
|
+
detail = state.get("error") or state.get("reason") or ""
|
|
591
|
+
marker = "(!)" if state.get("status") == "failed" else " -"
|
|
592
|
+
lines.append(f" {marker} {name}: {state.get('status')}{f' — {detail}' if detail else ''}")
|
|
593
|
+
for miss in result.get("cache_miss") or []:
|
|
594
|
+
lines.append(f" Fix the cache first: {miss}")
|
|
595
|
+
return lines
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _verdict_scan_lines(result: Dict[str, Any]) -> list:
|
|
599
|
+
scan = result.get("scan")
|
|
600
|
+
dropped = result.get("dropped_symbols") or []
|
|
601
|
+
tail = (
|
|
602
|
+
[f" (!) dropped for having no bars: {', '.join(dropped)} — the book was judged without them"]
|
|
603
|
+
if dropped
|
|
604
|
+
else []
|
|
605
|
+
)
|
|
606
|
+
if not scan:
|
|
607
|
+
return tail
|
|
608
|
+
lines = [
|
|
609
|
+
"",
|
|
610
|
+
f" Scan ({scan.get('scanner')}): {scan.get('flagged_count', 0)} of "
|
|
611
|
+
f"{len(scan.get('candidates') or [])} candidates flagged",
|
|
612
|
+
]
|
|
613
|
+
if scan.get("fell_back_to_candidates"):
|
|
614
|
+
lines.append(" (i) nothing flagged — the full candidate list was analyzed instead")
|
|
615
|
+
return lines + tail
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def alphas_heading(result: Dict[str, Any]) -> str:
|
|
619
|
+
"""The one-line statement of what the alphas are: how scaled, on which IC, how many
|
|
620
|
+
names, and whether the universe is too thin to trust. One definition for the terminal
|
|
621
|
+
and the HTML report, so a caveat printed in one cannot be missing from the other."""
|
|
622
|
+
combination = result.get("combination")
|
|
623
|
+
if combination:
|
|
624
|
+
weights = combination.get("signal_weights") or {}
|
|
625
|
+
return f"Alphas (combined, measured IC {combination.get('combined_ic', 0.0):+.4f}): " + ", ".join(
|
|
626
|
+
f"{k} {v:+.2f}" for k, v in weights.items()
|
|
627
|
+
)
|
|
628
|
+
alphas = result.get("alphas") or {}
|
|
629
|
+
flag = " (low confidence - thin universe, demean-only)" if alphas.get("low_confidence") else ""
|
|
630
|
+
return (
|
|
631
|
+
f"Alphas ({alphas.get('scaling', '?')} scaling, assumed IC "
|
|
632
|
+
f"{alphas.get('ic', 0.0):+.4f}): {alphas.get('universe_size', 0)} names{flag}"
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _more_line(shown: int, total: int) -> list:
|
|
637
|
+
"""A truncated list says how many names it left out; silence reads as 'all of them'."""
|
|
638
|
+
return [f" ... +{total - shown} more (not shown)"] if total > shown else []
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _verdict_alpha_lines(result: Dict[str, Any], top: int = 5) -> list:
|
|
642
|
+
combination = result.get("combination")
|
|
643
|
+
alphas = result.get("alphas")
|
|
644
|
+
source = combination or alphas
|
|
645
|
+
if not source:
|
|
646
|
+
return []
|
|
647
|
+
lines = ["", " " + alphas_heading(result)]
|
|
648
|
+
full = source.get("alphas") or []
|
|
649
|
+
for row in full[:top]:
|
|
650
|
+
lines.append(
|
|
651
|
+
f" {row.get('symbol', '?'):<8} alpha {small_aware(float(row.get('alpha', 0.0)), '+.4f')}"
|
|
652
|
+
)
|
|
653
|
+
lines.extend(_more_line(min(top, len(full)), len(full)))
|
|
654
|
+
return lines
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _verdict_portfolio_lines(result: Dict[str, Any], top: int = 5) -> list:
|
|
658
|
+
pf = result.get("portfolio")
|
|
659
|
+
if not pf:
|
|
660
|
+
return []
|
|
661
|
+
if not pf.get("feasible"):
|
|
662
|
+
return [
|
|
663
|
+
"",
|
|
664
|
+
f" Portfolio: NOT FEASIBLE — {pf.get('binding_constraint') or pf.get('note') or 'unknown'}",
|
|
665
|
+
]
|
|
666
|
+
d = pf.get("diagnostics") or {}
|
|
667
|
+
lines = [
|
|
668
|
+
"",
|
|
669
|
+
f" Portfolio (proposal, not an order): {len(pf.get('weights') or {})} names, "
|
|
670
|
+
f"TE {float(d.get('predicted_tracking_error', 0.0)):.2%} (target {pf.get('target_te', 0):.2%})",
|
|
671
|
+
f" expected active return {float(d.get('expected_active_return', 0.0)):+.2%} gross"
|
|
672
|
+
f" / {float(d.get('expected_active_return_net', d.get('expected_active_return', 0.0))):+.2%} net",
|
|
673
|
+
f" predicted IR {float(d.get('predicted_ir', 0.0)):+.2f} | "
|
|
674
|
+
f"transfer coefficient {float(d.get('transfer_coefficient', 0.0)):+.2f}",
|
|
675
|
+
]
|
|
676
|
+
held = list((pf.get("weights") or {}).items())
|
|
677
|
+
for symbol, weight in held[:top]:
|
|
678
|
+
lines.append(f" {symbol:<8} {float(weight):>7.2%}")
|
|
679
|
+
lines.extend(_more_line(min(top, len(held)), len(held)))
|
|
680
|
+
exposures = pf.get("exposures")
|
|
681
|
+
if exposures:
|
|
682
|
+
lines.append(
|
|
683
|
+
" exposures: " + ", ".join(f"{k} {float(v):+.2f}" for k, v in sorted(exposures.items()))
|
|
684
|
+
)
|
|
685
|
+
return lines
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _verdict_information_lines(result: Dict[str, Any]) -> list:
|
|
689
|
+
inf = result.get("information")
|
|
690
|
+
if not inf or not inf.get("periods"):
|
|
691
|
+
return []
|
|
692
|
+
flag = " (!) low sample" if inf.get("low_sample") else ""
|
|
693
|
+
return [
|
|
694
|
+
"",
|
|
695
|
+
f" Information ({inf.get('periods')} rebalances, horizon {inf.get('horizon_bars')} bars):{flag}",
|
|
696
|
+
f" IC {float(inf.get('mean_ic', 0.0)):+.4f} t-stat {float(inf.get('ic_tstat', 0.0)):+.2f} "
|
|
697
|
+
f"rank-IC {float(inf.get('rank_ic', 0.0)):+.4f}",
|
|
698
|
+
f" breadth {float(inf.get('breadth_effective', 0.0)):.0f} effective "
|
|
699
|
+
f"(ρ̄ {float(inf.get('rho_bar', 0.0)):.2f}, {inf.get('n_names', 0)} names)",
|
|
700
|
+
f" IR predicted {float(inf.get('predicted_ir', 0.0)):+.2f} vs realized "
|
|
701
|
+
f"{float(inf.get('realized_ir', 0.0)):+.2f} ± {float(inf.get('ir_standard_error', 0.0)):.2f}",
|
|
702
|
+
f" P(any |t|>2 across {inf.get('n_trials', 1)} campaign trials) = "
|
|
703
|
+
f"{float(inf.get('multiple_testing_inflation', 0.0)):.2f}",
|
|
704
|
+
]
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _verdict_banner_lines(result: Dict[str, Any]) -> list:
|
|
708
|
+
"""The conclusion, with every gate that produced it shown underneath.
|
|
709
|
+
|
|
710
|
+
Checks are listed whatever the verdict says, passes included: a verdict whose
|
|
711
|
+
supporting numbers are only visible when it is bad teaches the reader to trust
|
|
712
|
+
the good ones unexamined.
|
|
713
|
+
"""
|
|
714
|
+
verdict = result.get("verdict") or {}
|
|
715
|
+
lines = ["", f" VERDICT: {verdict.get('summary', 'unknown')}"]
|
|
716
|
+
for name, check in sorted((verdict.get("checks") or {}).items()):
|
|
717
|
+
passed = bool(check.get("passed"))
|
|
718
|
+
value, threshold = check.get("value"), check.get("threshold")
|
|
719
|
+
detail = f"{_num(value)} vs {_num(threshold)}"
|
|
720
|
+
# Every note states the *failing* condition ("...is indistinguishable from
|
|
721
|
+
# zero"), so printing it beside a PASS produced a line that contradicted its
|
|
722
|
+
# own verdict. On a pass the value and its threshold already say everything.
|
|
723
|
+
note = f" — {check['note']}" if not passed and check.get("note") else ""
|
|
724
|
+
lines.append(f" [{'PASS' if passed else 'FAIL'}] {name}: {detail}{note}")
|
|
725
|
+
if verdict.get("verdict") == "incomplete":
|
|
726
|
+
lines.append(" No verdict is offered for a partial run — do not act on the sections above.")
|
|
727
|
+
if result.get("journaled") is False and result.get("not_journaled_reason"):
|
|
728
|
+
lines.append(f" Not recorded as a trial: {result['not_journaled_reason']}.")
|
|
729
|
+
if verdict.get("verdict") == "infeasible":
|
|
730
|
+
lines.append(
|
|
731
|
+
" The constraints, not the data, are what failed: change the universe or the caps "
|
|
732
|
+
"(--max-weight, --max-names) yourself - nothing was relaxed for you."
|
|
733
|
+
)
|
|
734
|
+
lines.append("")
|
|
735
|
+
return lines
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _num(value: Any) -> str:
|
|
739
|
+
"""Format a gate's value/threshold without pretending a bool or a None is a float."""
|
|
740
|
+
if isinstance(value, bool) or value is None:
|
|
741
|
+
return str(value)
|
|
742
|
+
if isinstance(value, (int, float)):
|
|
743
|
+
return f"{value:.4g}"
|
|
744
|
+
return str(value)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
# --------------------------------------------------------------------------- #
|
|
748
|
+
# Trial-store browsing
|
|
749
|
+
# --------------------------------------------------------------------------- #
|
|
750
|
+
#: Rendered wherever a stored field is absent. A trial recorded before a field
|
|
751
|
+
#: existed, or of a kind that never produces one, did not score zero and did not
|
|
752
|
+
#: fail — it has nothing recorded, and the two must never look alike.
|
|
753
|
+
NOT_RECORDED = "—"
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _is_probability(key: str) -> bool:
|
|
757
|
+
from quantred.optimization.walk_forward import PROBABILITY_METRICS
|
|
758
|
+
|
|
759
|
+
return key in PROBABILITY_METRICS
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _prob_cell(value: Any, key: str = "deflated_sharpe") -> str:
|
|
763
|
+
"""A probability cell through the shared unit table, so the trial browser says what the
|
|
764
|
+
walk-forward and its chart say about the same trial — never ``0.000`` for a small one."""
|
|
765
|
+
if value is None:
|
|
766
|
+
return NOT_RECORDED
|
|
767
|
+
from quantred.optimization.walk_forward import format_gate_value
|
|
768
|
+
|
|
769
|
+
return format_gate_value(key, value)
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _local_ts(value: Any) -> str:
|
|
773
|
+
"""A recorded UTC instant as New York wall time, the zone the date filters read.
|
|
774
|
+
|
|
775
|
+
Shown as the stored UTC text, a trial at 22:00 New York on the 6th read as the 7th
|
|
776
|
+
and looked like a row ``--until <the 6th>`` should not have returned.
|
|
777
|
+
"""
|
|
778
|
+
if not value:
|
|
779
|
+
return NOT_RECORDED
|
|
780
|
+
from quantred.utils.timeutils import NEW_YORK
|
|
781
|
+
|
|
782
|
+
try:
|
|
783
|
+
parsed = _parse_iso(str(value))
|
|
784
|
+
except ValueError:
|
|
785
|
+
return str(value)[:19]
|
|
786
|
+
if parsed.tzinfo is None:
|
|
787
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
788
|
+
return parsed.astimezone(NEW_YORK).strftime("%Y-%m-%d %H:%M:%S %Z")
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _cell(value: Any, spec: str = "{:.3f}") -> str:
|
|
792
|
+
"""A metric cell, or :data:`NOT_RECORDED` when there is genuinely nothing stored."""
|
|
793
|
+
if value is None:
|
|
794
|
+
return NOT_RECORDED
|
|
795
|
+
if isinstance(value, bool):
|
|
796
|
+
return "yes" if value else "no"
|
|
797
|
+
try:
|
|
798
|
+
return spec.format(float(value))
|
|
799
|
+
except (TypeError, ValueError):
|
|
800
|
+
return str(value)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _plural(count: int, noun: str) -> str:
|
|
804
|
+
"""``1 name`` / ``2 names`` — a detail view is read closely enough for this to
|
|
805
|
+
grate."""
|
|
806
|
+
return f"{count} {noun}" if count == 1 else f"{count} {noun}s"
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _basis_cell(row: Dict[str, Any]) -> str:
|
|
810
|
+
"""What a row's Sharpe measures, in the one short cell a table has room for."""
|
|
811
|
+
from quantred.store.trials import SHARPE_BASIS_SHORT
|
|
812
|
+
|
|
813
|
+
return SHARPE_BASIS_SHORT.get(row.get("kind") or "", NOT_RECORDED)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def format_trials_table(
|
|
817
|
+
rows: List[Dict[str, Any]],
|
|
818
|
+
*,
|
|
819
|
+
total: Optional[int] = None,
|
|
820
|
+
in_sample_excluded: int = 0,
|
|
821
|
+
undated_excluded: int = 0,
|
|
822
|
+
) -> str:
|
|
823
|
+
"""The trial listing: one row per trial, absences rendered as absences.
|
|
824
|
+
|
|
825
|
+
Always states how many rows matched versus how many are shown — a listing that
|
|
826
|
+
silently truncates reads as "this is everything" when it is not. Every row says
|
|
827
|
+
what its Sharpe *is* (BASIS): the same number is an out-of-sample aggregate on a
|
|
828
|
+
walk-forward and a search's winning candidate on an optimize row, and a table that
|
|
829
|
+
prints both under one heading invites ranking them together.
|
|
830
|
+
"""
|
|
831
|
+
notices = []
|
|
832
|
+
if in_sample_excluded:
|
|
833
|
+
notices.append(
|
|
834
|
+
f"{in_sample_excluded} in-sample row(s) (optimize/backtest/alpha) left out of this "
|
|
835
|
+
"ranking — none was validated out of sample. Pass --include-in-sample to see them."
|
|
836
|
+
)
|
|
837
|
+
if undated_excluded:
|
|
838
|
+
notices.append(
|
|
839
|
+
f"{undated_excluded} row(s) with no recorded timestamp are not shown: a date bound "
|
|
840
|
+
"cannot place them, and no date is guessed for them. Drop --since/--until to see them."
|
|
841
|
+
)
|
|
842
|
+
if not rows:
|
|
843
|
+
return "\n".join(["No trials matched.", *notices])
|
|
844
|
+
lines = [
|
|
845
|
+
f"{'ID':14}{'KIND':12}{'STRATEGY':16}{'SHARPE':>9} {'BASIS':<11}{'DSR':>7}{'PROMO':>7}{'ACCT':>6} TS",
|
|
846
|
+
]
|
|
847
|
+
for r in rows:
|
|
848
|
+
promo = NOT_RECORDED if r.get("promotable") is None else ("yes" if r["promotable"] else "no")
|
|
849
|
+
lines.append(
|
|
850
|
+
f"{str(r.get('id', ''))[:14]:14}{str(r.get('kind', ''))[:12]:12}"
|
|
851
|
+
f"{str(r.get('strategy') or '')[:16]:16}"
|
|
852
|
+
f"{_cell(r.get('oos_sharpe')):>9} {_basis_cell(r):<11}{_prob_cell(r.get('deflated_sharpe')):>7}"
|
|
853
|
+
f"{promo:>7}{r.get('accounting', ''):>6} {_local_ts(r.get('ts'))}"
|
|
854
|
+
)
|
|
855
|
+
if total is not None and total > len(rows):
|
|
856
|
+
lines.append(f"\nShowing {len(rows)} of {total} matching trials (use --limit/--offset for more).")
|
|
857
|
+
elif total is not None:
|
|
858
|
+
lines.append(f"\n{total} matching trial(s).")
|
|
859
|
+
if any(r.get("kind") in ("optimize", "backtest") for r in rows):
|
|
860
|
+
lines.append(
|
|
861
|
+
"IN-SAMPLE rows: the Sharpe is a search's winning candidate (optimize) or one pass "
|
|
862
|
+
"over the whole window (backtest) — not out-of-sample evidence."
|
|
863
|
+
)
|
|
864
|
+
lines.extend(notices)
|
|
865
|
+
return "\n".join(lines)
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def format_exit_concentration(report: Dict[str, Any]) -> str:
|
|
869
|
+
"""The "Where the P&L came from" block, over a trade-analytics result.
|
|
870
|
+
|
|
871
|
+
Returns ``""`` when there is nothing to say — no table, no exit-reason column, or
|
|
872
|
+
a split that carries no P&L either way. A run that produced no trades gets no
|
|
873
|
+
block rather than a table of zeros.
|
|
874
|
+
"""
|
|
875
|
+
section = report.get("exit_reasons") or {}
|
|
876
|
+
rows = section.get("rows") or []
|
|
877
|
+
if not section.get("available") or not rows:
|
|
878
|
+
return ""
|
|
879
|
+
if not any(r.get("net_pnl") for r in rows):
|
|
880
|
+
return ""
|
|
881
|
+
|
|
882
|
+
lines = ["", "=== Where the P&L came from ==="]
|
|
883
|
+
if report.get("status") == "truncated":
|
|
884
|
+
# Partial totals are printed only under a label saying so. The alternative is
|
|
885
|
+
# a table of sums that reads exactly like a complete one.
|
|
886
|
+
lines.append(f" SHOWN ROWS ONLY — {report.get('reason', '')}")
|
|
887
|
+
lines.append(f" {'exit':16}{'trades':>8}{'share':>8}{'net P&L':>14}{'win rate':>10}")
|
|
888
|
+
for row in rows:
|
|
889
|
+
share = row.get("share_of_trades")
|
|
890
|
+
win_rate = row.get("win_rate")
|
|
891
|
+
net = row.get("net_pnl")
|
|
892
|
+
share_cell = NOT_RECORDED if share is None else format(share, ".1%")
|
|
893
|
+
net_cell = _money(net)
|
|
894
|
+
rate_cell = NOT_RECORDED if win_rate is None else format(win_rate, ".1%")
|
|
895
|
+
lines.append(
|
|
896
|
+
f" {str(row['exit_reason']):16}{row['trades']:>8}{share_cell:>8}{net_cell:>14}{rate_cell:>10}"
|
|
897
|
+
)
|
|
898
|
+
if row.get("unmeasured"):
|
|
899
|
+
# Otherwise the trades column silently fails to sum to the total while the
|
|
900
|
+
# share column sums to 100%, and nothing on screen says why.
|
|
901
|
+
lines.append(f" {'':32}(+{row['unmeasured']} with no P&L recorded, counted in the share)")
|
|
902
|
+
concentration = section.get("concentration") or {}
|
|
903
|
+
share_of_gain = concentration.get("share_of_gain")
|
|
904
|
+
if report.get("status") == "truncated":
|
|
905
|
+
# The numbers above are labelled as partial and can be read as such. This line
|
|
906
|
+
# is not a number, it is a claim about the run — and a prefix of the trades
|
|
907
|
+
# cannot support it. A partial gets no verdict.
|
|
908
|
+
lines.append(
|
|
909
|
+
" No concentration verdict from a partial table: which exit carried the gain "
|
|
910
|
+
"is\n a statement about all of the trades, and these are some of them."
|
|
911
|
+
)
|
|
912
|
+
elif share_of_gain is not None and share_of_gain > 0.9:
|
|
913
|
+
lines.append(
|
|
914
|
+
f" Nearly all of the gain comes from {concentration['exit_reason']}. The result "
|
|
915
|
+
f"is a bet on that\n exit's fill assumption - stress it before believing the headline."
|
|
916
|
+
)
|
|
917
|
+
return "\n".join(lines)
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def describe_trade_table(trades: Optional[Dict[str, Any]]) -> str:
|
|
921
|
+
"""One phrase for what a stored trade table is, used everywhere one is described.
|
|
922
|
+
|
|
923
|
+
Four distinct states, and collapsing any two of them is a lie somebody will act
|
|
924
|
+
on: never recorded, recorded and complete, recorded and *capped* at the storage
|
|
925
|
+
ceiling, and recorded before the store kept the count — which is not evidence of
|
|
926
|
+
either completeness or truncation and must not be rendered as though it were.
|
|
927
|
+
|
|
928
|
+
Goes through ``trade_analytics.describe_source`` rather than reading the payload's
|
|
929
|
+
keys again, so what counts as "capped" has one definition and this cannot come to
|
|
930
|
+
disagree with the module that refuses to aggregate one.
|
|
931
|
+
"""
|
|
932
|
+
from quantred.analytics.trade_analytics import describe_source
|
|
933
|
+
|
|
934
|
+
return describe_trade_table_source(describe_source(trades))
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
#: Names listed under a stored book before the rest are summarised. A verdict over a wide
|
|
938
|
+
#: universe proposes dozens of names; the full book is always in `--json`.
|
|
939
|
+
_BOOK_LINES = 25
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _book_lines(weights: Optional[Dict[str, Any]]) -> List[str]:
|
|
943
|
+
"""The proposed book itself - symbols and weights, largest first.
|
|
944
|
+
|
|
945
|
+
`show` is where the verdict's next step sends a reader asking what the proposed book
|
|
946
|
+
*is*, and a count ("8 names") answers a different question. Truncated loudly, with the
|
|
947
|
+
remainder and where to get all of it, never quietly.
|
|
948
|
+
"""
|
|
949
|
+
book = (weights or {}).get("weights") or {}
|
|
950
|
+
if not book:
|
|
951
|
+
return []
|
|
952
|
+
active = (weights or {}).get("active_weights") or {}
|
|
953
|
+
ranked = sorted(book.items(), key=lambda item: (-abs(float(item[1])), item[0]))
|
|
954
|
+
as_of = (weights or {}).get("as_of")
|
|
955
|
+
lines = ["", f" Proposed book{f' (as of {str(as_of)[:10]})' if as_of else ''}:"]
|
|
956
|
+
header = f" {'symbol':<10}{'weight':>9}" + (f"{'active':>9}" if active else "")
|
|
957
|
+
lines.append(header)
|
|
958
|
+
for symbol, weight in ranked[:_BOOK_LINES]:
|
|
959
|
+
row = f" {symbol:<10}{float(weight):>9.2%}"
|
|
960
|
+
if active:
|
|
961
|
+
row += f"{_cell(active.get(symbol), '{:+.2%}'):>9}"
|
|
962
|
+
lines.append(row)
|
|
963
|
+
if len(ranked) > _BOOK_LINES:
|
|
964
|
+
rest = len(ranked) - _BOOK_LINES
|
|
965
|
+
lines.append(f" … {rest} more name(s) not shown (`trials show --json` carries the whole book).")
|
|
966
|
+
lines.append(f" {'total':<10}{sum(float(w) for w in book.values()):>9.2%}")
|
|
967
|
+
return lines
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def format_trial_detail(trial: Dict[str, Any]) -> str:
|
|
971
|
+
"""Everything the store knows about one trial.
|
|
972
|
+
|
|
973
|
+
Companion records report their *presence and shape* rather than their contents:
|
|
974
|
+
a detail view exists to tell you what is recoverable, not to dump a return
|
|
975
|
+
series into a terminal.
|
|
976
|
+
"""
|
|
977
|
+
lines = [
|
|
978
|
+
"",
|
|
979
|
+
f"=== Trial {trial.get('id')} ({trial.get('kind')}) ===",
|
|
980
|
+
f" recorded : {_local_ts(trial.get('ts'))}",
|
|
981
|
+
f" strategy : {trial.get('strategy') or NOT_RECORDED}",
|
|
982
|
+
f" window : {(trial.get('window_start') or NOT_RECORDED)[:19]} → "
|
|
983
|
+
f"{(trial.get('window_end') or NOT_RECORDED)[:19]}",
|
|
984
|
+
f" universe : {trial.get('universe_hash', NOT_RECORDED)[:16]} (hash)",
|
|
985
|
+
f" accounting v{trial.get('accounting')} | git {trial.get('git_sha') or NOT_RECORDED}",
|
|
986
|
+
"",
|
|
987
|
+
" Params (the run's identity, including the folded cost and vintage keys):",
|
|
988
|
+
]
|
|
989
|
+
params = trial.get("params") or {}
|
|
990
|
+
for key in sorted(params):
|
|
991
|
+
lines.append(f" {key} = {params[key]}")
|
|
992
|
+
if not params:
|
|
993
|
+
lines.append(f" {NOT_RECORDED}")
|
|
994
|
+
|
|
995
|
+
lines.append("")
|
|
996
|
+
lines.append(" Headline metrics:")
|
|
997
|
+
from quantred.optimization.walk_forward import format_gate_value
|
|
998
|
+
from quantred.store.trials import IN_SAMPLE_KINDS, sharpe_basis
|
|
999
|
+
|
|
1000
|
+
# Named by what it is for this kind, not by the column it happens to be stored in:
|
|
1001
|
+
# `oos_sharpe` holds a full-window figure on a backtest and a search's winner on an
|
|
1002
|
+
# optimize row, and printing either as "OOS Sharpe" is the label doing the flattering.
|
|
1003
|
+
basis = sharpe_basis(trial.get("kind"))
|
|
1004
|
+
sharpe_label = "Sharpe"
|
|
1005
|
+
# (label, stored column, the name the shared unit table knows that quantity by)
|
|
1006
|
+
for label, key, unit_name in (
|
|
1007
|
+
(sharpe_label, "oos_sharpe", None),
|
|
1008
|
+
("Deflated Sharpe", "deflated_sharpe", "deflated_sharpe"),
|
|
1009
|
+
("Profit factor", "oos_profit_factor", None),
|
|
1010
|
+
("Max drawdown", "oos_max_dd", "max_drawdown"),
|
|
1011
|
+
("Efficiency", "efficiency", None),
|
|
1012
|
+
("Trades", "oos_trades", "total_trades"),
|
|
1013
|
+
):
|
|
1014
|
+
raw = trial.get(key)
|
|
1015
|
+
if unit_name is None:
|
|
1016
|
+
value = _cell(raw)
|
|
1017
|
+
else:
|
|
1018
|
+
value = NOT_RECORDED if raw is None else format_gate_value(unit_name, raw)
|
|
1019
|
+
if key == "oos_sharpe" and basis and raw is not None:
|
|
1020
|
+
value = f"{value} [{basis}]"
|
|
1021
|
+
lines.append(f" {label:<18}{value}")
|
|
1022
|
+
if trial.get("kind") in IN_SAMPLE_KINDS:
|
|
1023
|
+
lines.append(" (in-sample: describes the data it was fitted or run on, not out-of-sample skill)")
|
|
1024
|
+
promo = trial.get("promotable")
|
|
1025
|
+
lines.append(f" {'Promotable':<18}{NOT_RECORDED if promo is None else ('yes' if promo else 'no')}")
|
|
1026
|
+
|
|
1027
|
+
lines.append("")
|
|
1028
|
+
lines.append(" Stored alongside this trial:")
|
|
1029
|
+
returns = trial.get("returns")
|
|
1030
|
+
lines.append(
|
|
1031
|
+
f" return series : {NOT_RECORDED} (not recorded)"
|
|
1032
|
+
if not returns
|
|
1033
|
+
else f" return series : {returns['periods']} periods, "
|
|
1034
|
+
f"{str(returns['start'])[:10]} → {str(returns['end'])[:10]}"
|
|
1035
|
+
)
|
|
1036
|
+
weights = trial.get("weights")
|
|
1037
|
+
lines.append(
|
|
1038
|
+
f" proposed book : {NOT_RECORDED} (not recorded)"
|
|
1039
|
+
if not weights
|
|
1040
|
+
else f" proposed book : {_plural(len(weights.get('weights') or {}), 'name')}"
|
|
1041
|
+
+ (", with factor exposures" if weights.get("exposures") else "")
|
|
1042
|
+
+ (", with active weights" if weights.get("active_weights") else "")
|
|
1043
|
+
)
|
|
1044
|
+
lines.append(f" trade table : {describe_trade_table(trial.get('trades'))}")
|
|
1045
|
+
lines.extend(_book_lines(weights))
|
|
1046
|
+
|
|
1047
|
+
reused = trial.get("reused_by") or []
|
|
1048
|
+
lines.append("")
|
|
1049
|
+
if reused:
|
|
1050
|
+
lines.append(f" Reused by {len(reused)} later trial(s) with the same identity:")
|
|
1051
|
+
for row in reused:
|
|
1052
|
+
lines.append(f" {row['id']} {_local_ts(row.get('ts'))} ({row.get('kind')})")
|
|
1053
|
+
else:
|
|
1054
|
+
lines.append(" Not reused by any later trial.")
|
|
1055
|
+
lines.append("")
|
|
1056
|
+
return "\n".join(lines)
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def format_trial_trades(trades: Optional[Dict[str, Any]], limit: int = 25) -> str:
|
|
1060
|
+
"""The stored trade table, truncated loudly rather than quietly.
|
|
1061
|
+
|
|
1062
|
+
Two different truncations meet here and are named separately: this view showing
|
|
1063
|
+
the first ``limit`` of what is stored, and the *store* holding only the first N of
|
|
1064
|
+
what the run produced. The first is undone by a flag; the second is not undoable
|
|
1065
|
+
at all, and reading one as the other is how a partial table gets totalled.
|
|
1066
|
+
"""
|
|
1067
|
+
if not trades:
|
|
1068
|
+
return f" trade table: {NOT_RECORDED} (not recorded)"
|
|
1069
|
+
columns = trades.get("columns") or []
|
|
1070
|
+
rows = trades.get("rows") or []
|
|
1071
|
+
lines = ["", f" Trades — {describe_trade_table(trades)}:"]
|
|
1072
|
+
lines.append(" " + " ".join(f"{str(c)[:12]:>12}" for c in columns))
|
|
1073
|
+
for row in rows[:limit]:
|
|
1074
|
+
lines.append(" " + " ".join(f"{str(v)[:12]:>12}" for v in row))
|
|
1075
|
+
if len(rows) > limit:
|
|
1076
|
+
lines.append(f" … {len(rows) - limit} more stored trade(s) not shown (--trades-limit to raise).")
|
|
1077
|
+
return "\n".join(lines)
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def format_trial_analysis(report: Dict[str, Any]) -> str:
|
|
1081
|
+
"""The trade-table report for one recorded trial. Grades nothing."""
|
|
1082
|
+
lines = ["", f"=== Trades of {report.get('trial_id')} ({report.get('kind')}) ==="]
|
|
1083
|
+
lines.append(
|
|
1084
|
+
f" strategy {report.get('strategy') or NOT_RECORDED} | accounting "
|
|
1085
|
+
f"v{report.get('accounting')} | recorded {_local_ts(report.get('recorded_at'))}"
|
|
1086
|
+
)
|
|
1087
|
+
if report.get("contaminated_at"):
|
|
1088
|
+
lines.append(
|
|
1089
|
+
f" QUARANTINED {_local_ts(report['contaminated_at'])} — "
|
|
1090
|
+
f"{report.get('contamination_reason') or 'no reason recorded'}"
|
|
1091
|
+
)
|
|
1092
|
+
source = report.get("source") or {}
|
|
1093
|
+
lines.append(f" table : {describe_trade_table_source(source)}")
|
|
1094
|
+
|
|
1095
|
+
if report.get("status") == "unavailable":
|
|
1096
|
+
# Only here. A truncated report *does* have totals — partial ones, labelled by
|
|
1097
|
+
# the line below — and printing "No totals:" directly above a table of them
|
|
1098
|
+
# contradicts both the table and its own reason string.
|
|
1099
|
+
lines.append("")
|
|
1100
|
+
lines.append(f" No totals: {report.get('reason', '')}")
|
|
1101
|
+
return "\n".join(lines + [""])
|
|
1102
|
+
if report.get("status") == "truncated":
|
|
1103
|
+
lines.append("")
|
|
1104
|
+
lines.append(f" SHOWN ROWS ONLY — {report.get('reason', '')}")
|
|
1105
|
+
|
|
1106
|
+
overall = report.get("overall") or {}
|
|
1107
|
+
if overall.get("available") is False:
|
|
1108
|
+
lines.append(f" overall : {overall.get('note')}")
|
|
1109
|
+
else:
|
|
1110
|
+
lines.append("")
|
|
1111
|
+
lines.append(" Overall:")
|
|
1112
|
+
lines.append(f" {'Trades':<16}{overall.get('trades'):,}")
|
|
1113
|
+
lines.append(f" {'Wins / losses':<16}{overall.get('wins')} / {overall.get('losses')}")
|
|
1114
|
+
lines.append(f" {'Win rate':<16}{_cell(overall.get('win_rate'), '{:.1%}')}")
|
|
1115
|
+
# Money goes through one formatter so the sign sits outside the symbol
|
|
1116
|
+
# everywhere in the report, rather than in whichever block was written first.
|
|
1117
|
+
for label, key in (
|
|
1118
|
+
("Net P&L", "net_pnl"),
|
|
1119
|
+
("Average win", "avg_win"),
|
|
1120
|
+
("Average loss", "avg_loss"),
|
|
1121
|
+
("Expectancy", "expectancy"),
|
|
1122
|
+
):
|
|
1123
|
+
lines.append(f" {label:<16}{_money(overall.get(key))}")
|
|
1124
|
+
lines.append(f" {'Profit factor':<16}{_cell(overall.get('profit_factor'), '{:.2f}')}")
|
|
1125
|
+
if overall.get("unmeasured"):
|
|
1126
|
+
lines.append(f" {'Unmeasured':<16}{overall['unmeasured']} row(s) carried no P&L")
|
|
1127
|
+
|
|
1128
|
+
exits = report.get("exit_reasons") or {}
|
|
1129
|
+
if exits.get("available"):
|
|
1130
|
+
lines.append("")
|
|
1131
|
+
lines.append(" By exit reason:")
|
|
1132
|
+
lines.append(
|
|
1133
|
+
f" {'exit':16}{'trades':>8}{'share':>8}{'net P&L':>14}{'win rate':>10}"
|
|
1134
|
+
f"{'avg win':>12}{'avg loss':>12}"
|
|
1135
|
+
)
|
|
1136
|
+
for row in exits["rows"]:
|
|
1137
|
+
lines.append(
|
|
1138
|
+
f" {str(row['exit_reason']):16}{row['trades']:>8}"
|
|
1139
|
+
f"{_cell(row.get('share_of_trades'), '{:.1%}'):>8}"
|
|
1140
|
+
f"{_money(row.get('net_pnl')):>14}{_cell(row.get('win_rate'), '{:.1%}'):>10}"
|
|
1141
|
+
f"{_money(row.get('avg_win')):>12}{_money(row.get('avg_loss')):>12}"
|
|
1142
|
+
)
|
|
1143
|
+
if row.get("unmeasured"):
|
|
1144
|
+
lines.append(f" {'':24}(+{row['unmeasured']} with no P&L recorded, counted in the share)")
|
|
1145
|
+
elif exits:
|
|
1146
|
+
lines.append(f" by exit : {exits.get('note')}")
|
|
1147
|
+
|
|
1148
|
+
duration = report.get("duration") or {}
|
|
1149
|
+
lines.append("")
|
|
1150
|
+
if duration.get("available"):
|
|
1151
|
+
lines.append(
|
|
1152
|
+
f" Held : median {_cell(duration.get('median'), '{:.1f}')} days, "
|
|
1153
|
+
f"p25 {_cell(duration.get('p25'), '{:.1f}')}, p75 {_cell(duration.get('p75'), '{:.1f}')}, "
|
|
1154
|
+
f"max {_cell(duration.get('max'), '{:.1f}')} "
|
|
1155
|
+
f"({duration.get('n_measured')} measured, {duration.get('n_unmeasured')} not)"
|
|
1156
|
+
)
|
|
1157
|
+
else:
|
|
1158
|
+
lines.append(f" Held : {duration.get('note')}")
|
|
1159
|
+
|
|
1160
|
+
excursion = report.get("excursion") or {}
|
|
1161
|
+
if excursion.get("available"):
|
|
1162
|
+
lines.append(f" Excursion: {excursion.get('note')}")
|
|
1163
|
+
for name, label in (("mae_pct", "adverse"), ("mfe_pct", "favourable")):
|
|
1164
|
+
block = excursion.get(name) or {}
|
|
1165
|
+
if block.get("available") is False:
|
|
1166
|
+
lines.append(f" {label:<12}{block.get('note')}")
|
|
1167
|
+
continue
|
|
1168
|
+
lines.append(
|
|
1169
|
+
f" {label:<12}median {_cell(block.get('median'), '{:.2f}')}%, "
|
|
1170
|
+
f"p90 {_cell(block.get('p90'), '{:.2f}')}%, max {_cell(block.get('max'), '{:.2f}')}% "
|
|
1171
|
+
f"({block.get('n_measured')} measured, {block.get('n_unmeasured')} not)"
|
|
1172
|
+
)
|
|
1173
|
+
else:
|
|
1174
|
+
lines.append(f" Excursion: {excursion.get('note')}")
|
|
1175
|
+
lines.append("")
|
|
1176
|
+
return "\n".join(lines)
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def describe_trade_table_source(source: Dict[str, Any]) -> str:
|
|
1180
|
+
"""The stored table described from a trade-analytics ``source`` block.
|
|
1181
|
+
|
|
1182
|
+
The one place any surface turns a table's completeness into words.
|
|
1183
|
+
"""
|
|
1184
|
+
completeness = source.get("completeness")
|
|
1185
|
+
stored, total = source.get("rows_stored"), source.get("total_rows")
|
|
1186
|
+
if not source.get("recorded"):
|
|
1187
|
+
return f"{NOT_RECORDED} (not recorded — re-run with --record-trades, or record_trades=true over MCP)"
|
|
1188
|
+
if completeness == "capped":
|
|
1189
|
+
return (
|
|
1190
|
+
f"{stored:,} of {total:,} trades — TRUNCATED at the storage cap, so any "
|
|
1191
|
+
"total over these rows is short by the rest"
|
|
1192
|
+
)
|
|
1193
|
+
if completeness == "unknown":
|
|
1194
|
+
return f"{_plural(stored or 0, 'trade')} stored; whether that was all of them was not recorded"
|
|
1195
|
+
return _plural(stored or 0, "trade")
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def _money(value: Any) -> str:
|
|
1199
|
+
"""Currency with the sign outside the symbol; ``$-780`` reads as a price."""
|
|
1200
|
+
if value is None:
|
|
1201
|
+
return NOT_RECORDED
|
|
1202
|
+
return f"{'-' if value < 0 else ''}${abs(value):,.0f}"
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
#: The correlation at or above which two recorded results stop being independent
|
|
1206
|
+
#: evidence for each other. Deliberately high: the claim it licenses is that a campaign
|
|
1207
|
+
#: has one finding where it believed it had two, which is worth being sure of.
|
|
1208
|
+
NEAR_DUPLICATE = 0.80
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def _series_kind(kind: Optional[str]) -> str:
|
|
1212
|
+
"""``walkforward: stitched out-of-sample folds``, or just the kind when unlisted."""
|
|
1213
|
+
from quantred.analytics.series_comparison import series_basis
|
|
1214
|
+
|
|
1215
|
+
basis = series_basis(kind)
|
|
1216
|
+
return f"{kind or NOT_RECORDED}: {basis}" if basis else str(kind or NOT_RECORDED)
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
def format_series_comparison(report: Dict[str, Any]) -> str:
|
|
1220
|
+
"""Pairwise correlation of recorded return series, refusals included.
|
|
1221
|
+
|
|
1222
|
+
The refusals are printed with the correlations rather than under them. A listing
|
|
1223
|
+
that shows three coefficients and quietly drops the fourth pair reads as a complete
|
|
1224
|
+
comparison of everything that was asked about.
|
|
1225
|
+
"""
|
|
1226
|
+
lines = ["", "=== Return-series comparison ==="]
|
|
1227
|
+
for summary in report.get("series") or []:
|
|
1228
|
+
if summary.get("available"):
|
|
1229
|
+
lines.append(
|
|
1230
|
+
f" {str(summary['trial_id'])[:14]:14} {summary['periods']:>5} periods, "
|
|
1231
|
+
f"{str(summary['start'])[:10]} → {str(summary['end'])[:10]} "
|
|
1232
|
+
f"(v{summary.get('accounting')}, {summary.get('strategy') or NOT_RECORDED}, "
|
|
1233
|
+
f"{_series_kind(summary.get('kind'))})"
|
|
1234
|
+
)
|
|
1235
|
+
else:
|
|
1236
|
+
lines.append(f" {str(summary['trial_id'])[:14]:14} {NOT_RECORDED} {summary['reason']}")
|
|
1237
|
+
|
|
1238
|
+
for trial_id in report.get("unknown_trial_ids") or []:
|
|
1239
|
+
lines.append(f" {str(trial_id)[:14]:14} {NOT_RECORDED} no trial with this id is recorded")
|
|
1240
|
+
|
|
1241
|
+
lines.append("")
|
|
1242
|
+
for pair in report.get("pairs") or []:
|
|
1243
|
+
head = f" {str(pair['a'])[:12]:12} vs {str(pair['b'])[:12]:12}"
|
|
1244
|
+
span = (
|
|
1245
|
+
f"overlap {pair['overlap']} periods, {str(pair['start'])[:10]}..{str(pair['end'])[:10]}"
|
|
1246
|
+
if pair["overlap"]
|
|
1247
|
+
else "overlap 0 periods"
|
|
1248
|
+
)
|
|
1249
|
+
if pair["status"] != "compared":
|
|
1250
|
+
lines.append(f"{head} {span}\n REFUSED — {pair['reason']}")
|
|
1251
|
+
continue
|
|
1252
|
+
interval = pair.get("interval")
|
|
1253
|
+
band = (
|
|
1254
|
+
f" [{interval['low']:+.2f}, {interval['high']:+.2f}]"
|
|
1255
|
+
if interval
|
|
1256
|
+
else f" {pair.get('interval_note') or 'no interval available'}"
|
|
1257
|
+
)
|
|
1258
|
+
lines.append(f"{head} {span}\n correlation {pair['correlation']:+.2f}{band}")
|
|
1259
|
+
if pair.get("comparable") is False:
|
|
1260
|
+
lines.append(f" {pair['reason']}")
|
|
1261
|
+
if pair.get("unlike_basis"):
|
|
1262
|
+
lines.append(
|
|
1263
|
+
f" UNLIKE SERIES — {_series_kind(pair.get('kind_a'))} against "
|
|
1264
|
+
f"{_series_kind(pair.get('kind_b'))}: the overlap is arithmetic over two "
|
|
1265
|
+
"different things, not two estimates of one."
|
|
1266
|
+
)
|
|
1267
|
+
|
|
1268
|
+
if report.get("kinds_differ"):
|
|
1269
|
+
lines.append(
|
|
1270
|
+
" The trials compared are not all the same kind of result: a walk-forward or research "
|
|
1271
|
+
"series is stitched out-of-sample\n folds, a backtest or optimize series is one "
|
|
1272
|
+
"in-sample pass over its window."
|
|
1273
|
+
)
|
|
1274
|
+
lines.append("")
|
|
1275
|
+
lines.append(
|
|
1276
|
+
f" {report.get('n_compared', 0)} of {report.get('n_pairs', 0)} pair(s) compared; "
|
|
1277
|
+
f"{report.get('n_refused', 0)} refused. Minimum overlap {report.get('min_overlap')} periods."
|
|
1278
|
+
)
|
|
1279
|
+
highest = report.get("highest")
|
|
1280
|
+
if highest:
|
|
1281
|
+
lines.append(f" Highest: {highest['a']} vs {highest['b']} at {highest['correlation']:+.2f}.")
|
|
1282
|
+
# The number is always reported; the sentence about what it means is not. A
|
|
1283
|
+
# coefficient near zero says these results are independent, and printing "one
|
|
1284
|
+
# bet held twice" beside it would be a verdict the number contradicts.
|
|
1285
|
+
if highest["correlation"] >= NEAR_DUPLICATE:
|
|
1286
|
+
lines.append(
|
|
1287
|
+
" At that level they are one bet held twice, however differently they were\n"
|
|
1288
|
+
" parameterised — promoting both counts a single result as two."
|
|
1289
|
+
)
|
|
1290
|
+
lines.append("")
|
|
1291
|
+
return "\n".join(lines)
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
def _recorded_cell(fact: Optional[Dict[str, Any]]) -> str:
|
|
1295
|
+
"""One context fact, rendering *not recorded* distinctly from a recorded value.
|
|
1296
|
+
|
|
1297
|
+
The reason each fact carries ``recorded`` at all is that "this run used no scanner"
|
|
1298
|
+
and "nobody wrote down whether it did" are different facts. A renderer printing the
|
|
1299
|
+
value alone collapses them back into one blank — which is what this one did, while
|
|
1300
|
+
the docs showed an example of output it never produced.
|
|
1301
|
+
"""
|
|
1302
|
+
if not fact or not fact.get("recorded"):
|
|
1303
|
+
return f"{NOT_RECORDED} not recorded"
|
|
1304
|
+
value = fact.get("value")
|
|
1305
|
+
if isinstance(value, dict):
|
|
1306
|
+
return ", ".join(f"{k}={v}" for k, v in sorted(value.items())) or "recorded, empty"
|
|
1307
|
+
if isinstance(value, float):
|
|
1308
|
+
return f"{value:,.2f}"
|
|
1309
|
+
return str(value)
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
def _starting_book(source: Any) -> str:
|
|
1313
|
+
"""How the starting book reads. A dry run cannot see the account, so a flat book is
|
|
1314
|
+
an assumption it made, not a fact it observed."""
|
|
1315
|
+
if source == "flat":
|
|
1316
|
+
return "assumed flat (a dry run cannot see the account)"
|
|
1317
|
+
return str(source)
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def format_dry_run(report: Dict[str, Any], book_lines: Optional[Sequence[str]] = None) -> str:
|
|
1321
|
+
"""A dry run's decision path, in language that cannot be read as execution.
|
|
1322
|
+
|
|
1323
|
+
Every heading is conditional — *would* submit, *would* bind, *would* skip — and the
|
|
1324
|
+
banner leads, because the single worst outcome for this mode is a reader taking an
|
|
1325
|
+
intention for a fill. The capital carries its source: a reader judging whether these
|
|
1326
|
+
caps are the right ones needs to know whether the number came from the validated
|
|
1327
|
+
config or was typed at the prompt.
|
|
1328
|
+
"""
|
|
1329
|
+
counts = report.get("counts") or {}
|
|
1330
|
+
lines = [
|
|
1331
|
+
"",
|
|
1332
|
+
f"=== {report.get('banner')} ===",
|
|
1333
|
+
"",
|
|
1334
|
+
f" {'capital':<20}${report.get('capital', 0):,.2f} ({report.get('capital_source')})",
|
|
1335
|
+
f" {'starting book':<20}{_starting_book(report.get('starting_positions'))}",
|
|
1336
|
+
f" {'universe':<20}{len(report.get('universe') or [])} symbol(s)",
|
|
1337
|
+
# Both numbers, always. "12 symbols, 12 evaluated" and "12 symbols, 7 evaluated"
|
|
1338
|
+
# look identical if only the first is printed, and the second is a different run.
|
|
1339
|
+
f" {'evaluated':<20}{report.get('n_evaluated', 0)}"
|
|
1340
|
+
+ (
|
|
1341
|
+
f" ({report.get('n_unable_to_evaluate')} could not be evaluated)"
|
|
1342
|
+
if report.get("n_unable_to_evaluate")
|
|
1343
|
+
else ""
|
|
1344
|
+
),
|
|
1345
|
+
"",
|
|
1346
|
+
]
|
|
1347
|
+
if book_lines:
|
|
1348
|
+
# The caps these buckets were decided against; without them "WOULD BIND: 0" is
|
|
1349
|
+
# unreadable, since nothing says what there was to bind.
|
|
1350
|
+
lines.extend([" book checked against:", *book_lines, ""])
|
|
1351
|
+
|
|
1352
|
+
for bucket, heading in (
|
|
1353
|
+
("would_submit", "WOULD SUBMIT — orders this contract would have sent"),
|
|
1354
|
+
("would_bind", "WOULD BIND — a configured cap refusing an order"),
|
|
1355
|
+
("would_skip", "WOULD SKIP — no order, for a reason other than a cap"),
|
|
1356
|
+
("unable_to_evaluate", "UNABLE TO EVALUATE — no decision was possible"),
|
|
1357
|
+
):
|
|
1358
|
+
rows = report.get(bucket) or []
|
|
1359
|
+
# Printed even when empty: "no cap bound" and "the caps were never reached" are
|
|
1360
|
+
# different findings, and an omitted section renders them identically.
|
|
1361
|
+
lines.append(f" {heading}: {counts.get(bucket, 0)}")
|
|
1362
|
+
for row in rows:
|
|
1363
|
+
plan = row.get("plan") or {}
|
|
1364
|
+
detail = (
|
|
1365
|
+
f"{plan.get('side')} {plan.get('qty'):g} @ {plan.get('reference_price'):,.2f}"
|
|
1366
|
+
f" (stop {plan.get('stop_loss'):,.2f} / target {plan.get('take_profit'):,.2f})"
|
|
1367
|
+
if plan
|
|
1368
|
+
else row.get("reason")
|
|
1369
|
+
)
|
|
1370
|
+
lines.append(f" {str(row.get('symbol')):<10}{detail}")
|
|
1371
|
+
lines.append("")
|
|
1372
|
+
|
|
1373
|
+
lines.append(f" not covered {report.get('not_covered')}")
|
|
1374
|
+
lines.append(
|
|
1375
|
+
" Nothing here was journaled: a dry run measures nothing, so recording it would\n"
|
|
1376
|
+
" spend the family's statistical budget on a rehearsal."
|
|
1377
|
+
)
|
|
1378
|
+
lines.append("")
|
|
1379
|
+
return "\n".join(lines)
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
#: How many symbol names to print before summarising. A campaign universe runs to
|
|
1383
|
+
#: hundreds, and a wall of tickers is not more auditable than a count plus a sample —
|
|
1384
|
+
#: but a *silent* truncation would be, so the elision says how many it hid and the JSON
|
|
1385
|
+
#: keeps the full list either way.
|
|
1386
|
+
_UNIVERSE_SAMPLE = 12
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
def _seed_cell(metadata: Dict[str, Any]) -> str:
|
|
1390
|
+
"""The seed, however this record happens to spell it.
|
|
1391
|
+
|
|
1392
|
+
Four shapes exist in the wild and all four have to read correctly, because this
|
|
1393
|
+
block is embedded in saved config files and a config written last month is not going
|
|
1394
|
+
to be rewritten: the key **absent**, an explicit **null**, a bare **scalar** (what
|
|
1395
|
+
every config written before the field carried its own provenance holds), and the
|
|
1396
|
+
current ``{recorded, value, from}`` object.
|
|
1397
|
+
|
|
1398
|
+
A scalar is a *recorded* seed. Dropping it silently — which an `isinstance(dict)`
|
|
1399
|
+
check does — turns a value the record holds into no line at all, which is the same
|
|
1400
|
+
false absence this field was changed to fix, arriving from the other direction.
|
|
1401
|
+
"""
|
|
1402
|
+
seed = metadata.get("seed")
|
|
1403
|
+
if isinstance(seed, dict):
|
|
1404
|
+
rendered = _recorded_cell(seed)
|
|
1405
|
+
if seed.get("recorded") and seed.get("from"):
|
|
1406
|
+
rendered += f" (from the {seed['from']})"
|
|
1407
|
+
clash = seed.get("disagrees_with")
|
|
1408
|
+
if clash:
|
|
1409
|
+
rendered += (
|
|
1410
|
+
f" — DISAGREES with {clash.get('value')} in the {clash.get('from')}; "
|
|
1411
|
+
"the recipe defines the run, but this record is inconsistent"
|
|
1412
|
+
)
|
|
1413
|
+
return rendered
|
|
1414
|
+
if seed is None:
|
|
1415
|
+
# Covers both the absent key and an explicit null: nobody wrote one down.
|
|
1416
|
+
return f"{NOT_RECORDED} not recorded"
|
|
1417
|
+
return f"{seed} (recorded before this block named its source)"
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
def _universe_lines(universe: Dict[str, Any], readable: Optional[bool] = None) -> List[str]:
|
|
1421
|
+
"""The names the evidence covers, and how many were considered to reach them.
|
|
1422
|
+
|
|
1423
|
+
Absent is not empty: a trial that recorded no universe says so, rather than
|
|
1424
|
+
rendering as a run over nothing.
|
|
1425
|
+
|
|
1426
|
+
And *unreadable* is not absent. When the journal line could not be read at all —
|
|
1427
|
+
an archived store queried without its journal, or a research-agent trial whose
|
|
1428
|
+
record this scanner does not match — nothing here knows whether a universe was
|
|
1429
|
+
recorded, and saying "not recorded for this trial" asserts more than the code can
|
|
1430
|
+
see. The recipe reason one line above already draws that distinction; this used to
|
|
1431
|
+
contradict it.
|
|
1432
|
+
"""
|
|
1433
|
+
symbols = universe.get("symbols")
|
|
1434
|
+
candidates = universe.get("candidate_symbols")
|
|
1435
|
+
unknown = (
|
|
1436
|
+
f"{NOT_RECORDED} the journal line for this trial could not be read"
|
|
1437
|
+
if readable is False
|
|
1438
|
+
else f"{NOT_RECORDED} not recorded for this trial"
|
|
1439
|
+
)
|
|
1440
|
+
if symbols is None and candidates is None:
|
|
1441
|
+
return [f" {'universe':<16}{unknown}"]
|
|
1442
|
+
|
|
1443
|
+
lines = []
|
|
1444
|
+
if symbols is None:
|
|
1445
|
+
lines.append(f" {'universe':<16}{unknown}")
|
|
1446
|
+
else:
|
|
1447
|
+
shown = ", ".join(symbols[:_UNIVERSE_SAMPLE])
|
|
1448
|
+
hidden = len(symbols) - _UNIVERSE_SAMPLE
|
|
1449
|
+
if hidden > 0:
|
|
1450
|
+
shown += f", … (+{hidden} more)"
|
|
1451
|
+
lines.append(f" {'universe':<16}{len(symbols)} symbol(s): {shown}")
|
|
1452
|
+
if candidates is not None:
|
|
1453
|
+
# The pre-scan set. Its *size* is the interesting part — it says how much the
|
|
1454
|
+
# scanner rejected, which is the "how it was resolved" half of the question.
|
|
1455
|
+
lines.append(f" {'considered':<16}{len(candidates)} candidate(s) before the scan")
|
|
1456
|
+
return lines
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
def format_holdout(holdout: Dict[str, Any]) -> List[str]:
|
|
1460
|
+
"""A walk-forward's holdout as lines, in each of the states a journal can leave it.
|
|
1461
|
+
|
|
1462
|
+
The four states are not interchangeable and none is rendered as another: a holdout
|
|
1463
|
+
that was never asked for, one whose result is in the journal, one that was asked
|
|
1464
|
+
for and produced nothing, and one asked for on a line written before the journal
|
|
1465
|
+
kept the result - that last is *absent*, and says so rather than printing nothing.
|
|
1466
|
+
"""
|
|
1467
|
+
from quantred.optimization.walk_forward import format_gate_value
|
|
1468
|
+
|
|
1469
|
+
state, days = holdout.get("state"), holdout.get("days")
|
|
1470
|
+
if state == "none_requested":
|
|
1471
|
+
return [" Holdout : none requested (--holdout-days 0)"]
|
|
1472
|
+
if state == "not_retained":
|
|
1473
|
+
return [
|
|
1474
|
+
f" Holdout : requested ({days} days) but its result was not retained — this trial "
|
|
1475
|
+
"was recorded before the journal kept holdout results"
|
|
1476
|
+
]
|
|
1477
|
+
if state == "recorded":
|
|
1478
|
+
metrics = holdout.get("metrics")
|
|
1479
|
+
if not metrics:
|
|
1480
|
+
return [f" Holdout : requested ({days} days) but it produced no result"]
|
|
1481
|
+
cells = " ".join(
|
|
1482
|
+
f"{label} {format_gate_value(key, metrics.get(key))}"
|
|
1483
|
+
for label, key in (("Sharpe", "sharpe_ratio"), ("CAGR", "cagr"), ("trades", "total_trades"))
|
|
1484
|
+
if key in metrics
|
|
1485
|
+
)
|
|
1486
|
+
return [f" Holdout : {days} days, scored once — {cells}"]
|
|
1487
|
+
return [" Holdout : unknown — the journal line for this trial is not readable"]
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
def _campaign_heading(material: Dict[str, Any]) -> str:
|
|
1491
|
+
"""The block's title, by whether the trial was validated at all.
|
|
1492
|
+
|
|
1493
|
+
"What validated" over a backtest or an optimize row claims a validation that never
|
|
1494
|
+
happened: those kinds were not split into a part fitted and a part judged. The
|
|
1495
|
+
block is still worth printing - it is the run's recipe and record - but it is
|
|
1496
|
+
headed as what it is.
|
|
1497
|
+
"""
|
|
1498
|
+
from quantred.store.trials import IN_SAMPLE_KINDS
|
|
1499
|
+
|
|
1500
|
+
ident = f"{material.get('trial_id')} ({material.get('kind')})"
|
|
1501
|
+
if material.get("kind") in IN_SAMPLE_KINDS:
|
|
1502
|
+
return f"=== What produced {ident} — NOT validated out of sample ==="
|
|
1503
|
+
return f"=== What validated {ident} ==="
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
def format_campaign_material(material: Dict[str, Any]) -> str:
|
|
1507
|
+
"""What validated a trial, with each section labelled by what kind of thing it is.
|
|
1508
|
+
|
|
1509
|
+
The labels are the point. A recipe survives an accounting bump and a measurement
|
|
1510
|
+
does not, and a reader who cannot tell them apart will carry a stale number forward
|
|
1511
|
+
beside a recipe that is still perfectly good.
|
|
1512
|
+
"""
|
|
1513
|
+
if not material.get("available"):
|
|
1514
|
+
return f"\n=== Campaign ===\n {NOT_RECORDED} {material.get('reason')}\n"
|
|
1515
|
+
|
|
1516
|
+
recipe = material.get("recipe") or {}
|
|
1517
|
+
evidence = material.get("evidence") or {}
|
|
1518
|
+
metadata = material.get("metadata") or {}
|
|
1519
|
+
lines = [
|
|
1520
|
+
"",
|
|
1521
|
+
_campaign_heading(material),
|
|
1522
|
+
"",
|
|
1523
|
+
" RECIPE — how it was validated. Survives an accounting bump.",
|
|
1524
|
+
]
|
|
1525
|
+
window = recipe.get("window") or {}
|
|
1526
|
+
lines.append(f" window {str(window.get('start'))[:10]} → {str(window.get('end'))[:10]}")
|
|
1527
|
+
if recipe.get("available"):
|
|
1528
|
+
for key, value in sorted((recipe.get("validation") or {}).items()):
|
|
1529
|
+
lines.append(f" {key:<16}{NOT_RECORDED if value is None else value}")
|
|
1530
|
+
for key, value in sorted((recipe.get("folded_into_identity") or {}).items()):
|
|
1531
|
+
lines.append(f" {key:<16}{value} (folded into its identity)")
|
|
1532
|
+
else:
|
|
1533
|
+
lines.append(f" {NOT_RECORDED} {recipe.get('reason')}")
|
|
1534
|
+
|
|
1535
|
+
# The universe is part of what validated this, and the block carried it in JSON while
|
|
1536
|
+
# this renderer printed none of it — the same "the payload has it, the text a reader
|
|
1537
|
+
# sees does not" defect already found once in this function. Rendered under the
|
|
1538
|
+
# recipe because that is what it is: which names the evidence covers, and how many
|
|
1539
|
+
# were considered to get there. Outside the `available` branch on purpose: a
|
|
1540
|
+
# backtest has no validation recipe and still has a universe.
|
|
1541
|
+
lines.extend(_universe_lines(recipe.get("universe") or {}, readable=metadata.get("journal_line_found")))
|
|
1542
|
+
|
|
1543
|
+
lines.append("")
|
|
1544
|
+
lines.append(" SET UP WITH — how the run was configured. Not part of its identity.")
|
|
1545
|
+
for fact, value in (recipe.get("context") or {}).items():
|
|
1546
|
+
lines.append(f" {fact:<16}{_recorded_cell(value)}")
|
|
1547
|
+
|
|
1548
|
+
lines.append("")
|
|
1549
|
+
lines.append(
|
|
1550
|
+
f" EVIDENCE — what was measured, under accounting v{evidence.get('accounting')}. Valid only there."
|
|
1551
|
+
)
|
|
1552
|
+
metrics = evidence.get("metrics")
|
|
1553
|
+
if metrics:
|
|
1554
|
+
# Through the shared unit table, not a flat float format. A trade count
|
|
1555
|
+
# rendered as `88.000` is the defect this project has now found in three
|
|
1556
|
+
# separate renderers, and a fourth private formatter here would be the fourth.
|
|
1557
|
+
# Without a benchmark these are stored as 0.0, which beside
|
|
1558
|
+
# `benchmark_available no` reads as a measured zero alpha. They were not measured.
|
|
1559
|
+
# The one reader of those flags (the HTML report uses it too), so a Treynor ratio
|
|
1560
|
+
# suppressed for a near-zero beta is not called "no benchmark" here.
|
|
1561
|
+
from quantred.analytics.performance import unmeasured_metrics
|
|
1562
|
+
from quantred.optimization.walk_forward import format_gate_value
|
|
1563
|
+
|
|
1564
|
+
unmeasured = unmeasured_metrics(metrics)
|
|
1565
|
+
for key, value in sorted(metrics.items()):
|
|
1566
|
+
gap = " " if len(key) >= 16 else "" # a long name must not run into its value
|
|
1567
|
+
shown = (
|
|
1568
|
+
f"not measured ({unmeasured[key]})" if key in unmeasured else format_gate_value(key, value)
|
|
1569
|
+
)
|
|
1570
|
+
lines.append(f" {key:<16}{gap}{shown}")
|
|
1571
|
+
if material.get("kind") == "research" and "sharpe_ratio" in metrics:
|
|
1572
|
+
lines.append(
|
|
1573
|
+
" (research round: sharpe_ratio here is the aggregate over all folds; the "
|
|
1574
|
+
"Sharpe in `trials list`/`show` is the median fold)"
|
|
1575
|
+
)
|
|
1576
|
+
else:
|
|
1577
|
+
lines.append(f" {NOT_RECORDED} no metrics recorded")
|
|
1578
|
+
if evidence.get("holdout") is not None:
|
|
1579
|
+
lines.extend(" " + line for line in format_holdout(evidence["holdout"]))
|
|
1580
|
+
promo = evidence.get("promotable")
|
|
1581
|
+
lines.append(f" {'promotable':<16}{NOT_RECORDED if promo is None else ('yes' if promo else 'no')}")
|
|
1582
|
+
lines.append(f" {'family n_trials':<16}{_cell(evidence.get('family_n_trials'), '{:.0f}')}")
|
|
1583
|
+
lines.append(f" {'trial ids':<16}{', '.join(evidence.get('trial_ids') or []) or NOT_RECORDED}")
|
|
1584
|
+
lines.append(f" {'probes':<16}{_recorded_cell(evidence.get('probes'))}")
|
|
1585
|
+
if evidence.get("staleness"):
|
|
1586
|
+
lines.append(f" (!) {evidence['staleness']}")
|
|
1587
|
+
if evidence.get("quarantined"):
|
|
1588
|
+
lines.append(f" (!) QUARANTINED — {evidence.get('quarantine_reason')}")
|
|
1589
|
+
|
|
1590
|
+
lines.append("")
|
|
1591
|
+
lines.append(" METADATA — about the record, not about the strategy.")
|
|
1592
|
+
lines.append(f" {'recorded':<16}{_local_ts(metadata.get('recorded_at'))}")
|
|
1593
|
+
lines.append(f" {'git':<16}{metadata.get('git_sha') or NOT_RECORDED}")
|
|
1594
|
+
# Named alongside the sha because both answer "what would it take to reproduce
|
|
1595
|
+
# this", and because an omitted line reads as nothing to say rather than as nothing
|
|
1596
|
+
# recorded. The source is printed with it: the seed's real home is the validation
|
|
1597
|
+
# recipe, and a reader checking provenance should know which record it came off.
|
|
1598
|
+
lines.append(f" {'seed':<16}{_seed_cell(metadata)}")
|
|
1599
|
+
lines.append(f" {'notes':<16}{_recorded_cell(metadata.get('notes'))}")
|
|
1600
|
+
for artifact in metadata.get("artifacts") or []:
|
|
1601
|
+
state = artifact["read_with"] if artifact["recorded"] else f"{NOT_RECORDED} not recorded"
|
|
1602
|
+
lines.append(f" {artifact['artifact']:<16}{state}")
|
|
1603
|
+
lines.append("")
|
|
1604
|
+
return "\n".join(lines)
|
|
1605
|
+
|
|
1606
|
+
|
|
1607
|
+
def format_leaderboard(board: Dict[str, Any]) -> str:
|
|
1608
|
+
"""The leaderboard, and the multiple-testing context that makes it honest.
|
|
1609
|
+
|
|
1610
|
+
The family's trial count sits on every row and the caveat is always printed —
|
|
1611
|
+
a ranking without its selection context is the exact trap this project's
|
|
1612
|
+
evaluation machinery exists to close, and it would be worse coming from our own
|
|
1613
|
+
tooling, which lends it authority.
|
|
1614
|
+
"""
|
|
1615
|
+
rows = board.get("rows") or []
|
|
1616
|
+
if not rows:
|
|
1617
|
+
return "No trials matched."
|
|
1618
|
+
ranked_by = "deflated Sharpe" if board.get("rank_by") == "dsr" else "RAW Sharpe"
|
|
1619
|
+
lines = [
|
|
1620
|
+
"",
|
|
1621
|
+
# Name the population, not just the sort key. "Top 3 by deflated Sharpe"
|
|
1622
|
+
# still reads as "the best of everything I have run" unless it says what it
|
|
1623
|
+
# ranked over.
|
|
1624
|
+
f" Top {len(rows)} by {ranked_by}"
|
|
1625
|
+
+ ("" if board.get("in_sample_included") else " (validated runs only)")
|
|
1626
|
+
+ ":",
|
|
1627
|
+
# KIND is not decoration. Without it a reader cannot tell a validated
|
|
1628
|
+
# walk-forward from a search's winning candidate, and those mean opposite
|
|
1629
|
+
# things about whether a number is evidence.
|
|
1630
|
+
f" {'#':<3}{'ID':14}{'KIND':12}{'STRATEGY':16}{'DSR':>8}{'SHARPE':>9} {'BASIS':<11}{'FAMILY n_trials':>16}",
|
|
1631
|
+
]
|
|
1632
|
+
for i, r in enumerate(rows, 1):
|
|
1633
|
+
family = r.get("family_n_trials")
|
|
1634
|
+
lines.append(
|
|
1635
|
+
f" {i:<3}{str(r.get('id', ''))[:14]:14}{str(r.get('kind') or '')[:12]:12}"
|
|
1636
|
+
f"{str(r.get('strategy') or '')[:16]:16}"
|
|
1637
|
+
f"{_prob_cell(r.get('deflated_sharpe')):>8}{_cell(r.get('oos_sharpe')):>9} "
|
|
1638
|
+
f"{_basis_cell(r):<11}{(NOT_RECORDED if family is None else family):>16}"
|
|
1639
|
+
)
|
|
1640
|
+
lines.append("")
|
|
1641
|
+
if board.get("unranked_excluded"):
|
|
1642
|
+
lines.append(
|
|
1643
|
+
f" {board['unranked_excluded']} row(s) have no {ranked_by} recorded and cannot be "
|
|
1644
|
+
"ranked by it (a verdict records neither a Sharpe nor a DSR); they are not listed."
|
|
1645
|
+
)
|
|
1646
|
+
# Two different counts sit on a row and they are easy to read as one. The DSR was
|
|
1647
|
+
# fixed when the row was recorded, deflated by the configs tried inside that one run;
|
|
1648
|
+
# the family count is read now, across every run of the strategy over the universe.
|
|
1649
|
+
lines.append(
|
|
1650
|
+
" FAMILY n_trials = configs tried so far on this row's strategy over its universe "
|
|
1651
|
+
"(this accounting version), counted now. A row's DSR was deflated by the trials of "
|
|
1652
|
+
"its own run, so two rows' DSRs are not comparable when their runs searched different amounts."
|
|
1653
|
+
)
|
|
1654
|
+
if board.get("in_sample_included"):
|
|
1655
|
+
lines.append(
|
|
1656
|
+
" (!) IN-SAMPLE rows included. An 'optimize' row is the winner of a search — "
|
|
1657
|
+
"best-of-N by construction — so its rank measures selection, not skill; a "
|
|
1658
|
+
"'backtest' row is one pass over its window, never validated out of sample."
|
|
1659
|
+
)
|
|
1660
|
+
elif board.get("in_sample_excluded"):
|
|
1661
|
+
lines.append(
|
|
1662
|
+
f" {board['in_sample_excluded']} in-sample row(s) excluded (search candidates and "
|
|
1663
|
+
"plain backtests, not validated out of sample). Pass --include-in-sample to see them."
|
|
1664
|
+
)
|
|
1665
|
+
lines.append(f" {board.get('caveat', '')}")
|
|
1666
|
+
if board.get("max_family_n_trials", 0) >= 50:
|
|
1667
|
+
lines.append(
|
|
1668
|
+
f" This family has tried {board['max_family_n_trials']} configs. At that count "
|
|
1669
|
+
"the best raw Sharpe is largely selection — read the deflated column, not the rank."
|
|
1670
|
+
)
|
|
1671
|
+
lines.append("")
|
|
1672
|
+
return "\n".join(lines)
|