quantred 2.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. quantred/__init__.py +24 -0
  2. quantred/alphas/__init__.py +53 -0
  3. quantred/alphas/base.py +221 -0
  4. quantred/alphas/combine.py +260 -0
  5. quantred/alphas/horizon.py +153 -0
  6. quantred/alphas/refine.py +300 -0
  7. quantred/alphas/scorers.py +87 -0
  8. quantred/analytics/attribution.py +354 -0
  9. quantred/analytics/bootstrap.py +353 -0
  10. quantred/analytics/charts.py +393 -0
  11. quantred/analytics/excursion.py +185 -0
  12. quantred/analytics/execution_quality.py +180 -0
  13. quantred/analytics/exposure.py +167 -0
  14. quantred/analytics/htmlreport.py +1208 -0
  15. quantred/analytics/information.py +197 -0
  16. quantred/analytics/metrics.py +502 -0
  17. quantred/analytics/performance.py +639 -0
  18. quantred/analytics/reporting.py +1672 -0
  19. quantred/analytics/screening.py +190 -0
  20. quantred/analytics/series_comparison.py +303 -0
  21. quantred/analytics/trade_analytics.py +353 -0
  22. quantred/brokers/alpaca/broker.py +415 -0
  23. quantred/brokers/alpaca/factory.py +33 -0
  24. quantred/brokers/alpaca/market_data.py +166 -0
  25. quantred/brokers/base.py +220 -0
  26. quantred/brokers/dryrun.py +164 -0
  27. quantred/brokers/errors.py +70 -0
  28. quantred/cli.py +8619 -0
  29. quantred/costs/__init__.py +11 -0
  30. quantred/costs/base.py +126 -0
  31. quantred/costs/parametric.py +131 -0
  32. quantred/data/__init__.py +29 -0
  33. quantred/data/compute.py +332 -0
  34. quantred/data/edges.py +81 -0
  35. quantred/data/features.py +120 -0
  36. quantred/data/panel.py +61 -0
  37. quantred/data/scan.py +85 -0
  38. quantred/data/store.py +269 -0
  39. quantred/demo/__init__.py +22 -0
  40. quantred/demo/scanners.py +104 -0
  41. quantred/demo/strategies.py +125 -0
  42. quantred/engine/backtest.py +1206 -0
  43. quantred/engine/barcheck.py +228 -0
  44. quantred/engine/live.py +539 -0
  45. quantred/execution/decision.py +192 -0
  46. quantred/execution/flatten.py +302 -0
  47. quantred/execution/halt.py +165 -0
  48. quantred/execution/ledger.py +758 -0
  49. quantred/execution/live_trader.py +587 -0
  50. quantred/execution/order_id.py +83 -0
  51. quantred/execution/sizing.py +114 -0
  52. quantred/indicators/indicators.py +158 -0
  53. quantred/marketdata/base.py +62 -0
  54. quantred/marketdata/client.py +59 -0
  55. quantred/marketdata/session.py +112 -0
  56. quantred/marketdata/synthetic.py +74 -0
  57. quantred/marketdata/timeframe.py +125 -0
  58. quantred/mcp/__init__.py +6 -0
  59. quantred/mcp/selftest.py +139 -0
  60. quantred/mcp/server.py +2419 -0
  61. quantred/optimization/causality.py +607 -0
  62. quantred/optimization/config_store.py +300 -0
  63. quantred/optimization/optimizer.py +431 -0
  64. quantred/optimization/parallel.py +345 -0
  65. quantred/optimization/param_space.py +309 -0
  66. quantred/optimization/walk_forward.py +1254 -0
  67. quantred/portfolio/allocator.py +135 -0
  68. quantred/portfolio/benchmark.py +109 -0
  69. quantred/portfolio/optimizer.py +786 -0
  70. quantred/portfolio/policy.py +335 -0
  71. quantred/portfolio/posterior.py +208 -0
  72. quantred/research/__init__.py +30 -0
  73. quantred/research/agent.py +868 -0
  74. quantred/research/demo_proposals.py +222 -0
  75. quantred/research/llm.py +311 -0
  76. quantred/research/proposer.py +234 -0
  77. quantred/research/provider_errors.py +242 -0
  78. quantred/research/sandbox.py +340 -0
  79. quantred/risk/__init__.py +60 -0
  80. quantred/risk/base.py +218 -0
  81. quantred/risk/conditional.py +474 -0
  82. quantred/risk/exposures.py +88 -0
  83. quantred/risk/factor.py +119 -0
  84. quantred/risk/sample.py +89 -0
  85. quantred/risk/streaming.py +214 -0
  86. quantred/scanners/base.py +141 -0
  87. quantred/scanners/symbol_scanner.py +148 -0
  88. quantred/services/__init__.py +51 -0
  89. quantred/services/analysis.py +6082 -0
  90. quantred/services/audit.py +574 -0
  91. quantred/services/campaign.py +381 -0
  92. quantred/services/configs.py +282 -0
  93. quantred/services/data.py +172 -0
  94. quantred/services/dryrun.py +206 -0
  95. quantred/services/glossary.py +130 -0
  96. quantred/services/maintenance.py +314 -0
  97. quantred/services/registry.py +351 -0
  98. quantred/services/setup.py +1023 -0
  99. quantred/services/sizing.py +150 -0
  100. quantred/services/smallreal.py +450 -0
  101. quantred/settings.py +571 -0
  102. quantred/store/__init__.py +0 -0
  103. quantred/store/bars.py +660 -0
  104. quantred/store/trials.py +1804 -0
  105. quantred/strategies/base.py +643 -0
  106. quantred/strategies/signals.py +28 -0
  107. quantred/utils/errors.py +48 -0
  108. quantred/utils/extras.py +330 -0
  109. quantred/utils/logging_config.py +33 -0
  110. quantred/utils/numeric.py +46 -0
  111. quantred/utils/streaming.py +474 -0
  112. quantred/utils/timeutils.py +148 -0
  113. quantred-2.4.0.dist-info/METADATA +632 -0
  114. quantred-2.4.0.dist-info/RECORD +117 -0
  115. quantred-2.4.0.dist-info/WHEEL +4 -0
  116. quantred-2.4.0.dist-info/entry_points.txt +8 -0
  117. quantred-2.4.0.dist-info/licenses/LICENSE +21 -0
quantred/mcp/server.py ADDED
@@ -0,0 +1,2419 @@
1
+ """MCP server: a thin adapter exposing QuantRed's deterministic core as tools, so
2
+ an agent can do the research and the engine can stay boringly deterministic.
3
+
4
+ No business logic lives here - every tool calls a function in :mod:`quantred.services`
5
+ and logs the call to the audit trail. The server constructs **only** a data
6
+ client (never a broker/trading client), so it is structurally incapable of
7
+ placing an order. The hard wall is the *absence* of any
8
+ order/live/account-mutation tool; it cannot be prompt-injected around because the
9
+ capability is not wired in.
10
+
11
+ Run with ``quantred mcp`` when installed, or ``python main.py mcp`` from a checkout
12
+ (needs the ``mcp`` extra either way).
13
+ """
14
+
15
+ import contextlib
16
+ import logging
17
+ import math
18
+ import sys
19
+ from datetime import datetime
20
+ from typing import Any, Dict, List, Literal, Optional
21
+
22
+ try:
23
+ # pydantic (hence every install with the 'mcp' extra) needs this TypedDict on
24
+ # Python < 3.12; the module must still import without the extra.
25
+ from typing_extensions import TypedDict
26
+ except ImportError: # pragma: no cover - only without the extra
27
+ from typing import TypedDict
28
+
29
+ from quantred.analytics.series_comparison import MIN_OVERLAP as SERIES_MIN_OVERLAP
30
+ from quantred.services import analysis, campaign, configs, glossary, registry
31
+ from quantred.services.audit import audit_log, new_run_id
32
+ from quantred.settings import BRAND, MCP_SERVER_NAME
33
+ from quantred.utils.timeutils import parse_market_date
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ SERVER_NAME = MCP_SERVER_NAME
38
+
39
+ #: The complete, intended tool surface (used by tests to assert the wall).
40
+ EXPOSED_TOOLS = (
41
+ "list_strategies",
42
+ "list_scanners",
43
+ "get_param_ranges",
44
+ "run_scan",
45
+ "run_backtest",
46
+ "run_screen",
47
+ "confirm_screen_point",
48
+ "run_causality_probes",
49
+ "run_optimization",
50
+ "run_walk_forward",
51
+ "validate_draft_strategy_code",
52
+ "validate_draft_scanner_code",
53
+ "run_draft_walk_forward",
54
+ "run_verdict",
55
+ "compute_alphas",
56
+ "combine_alphas",
57
+ "compute_risk",
58
+ "construct_portfolio",
59
+ "compute_information",
60
+ "compute_attribution",
61
+ "compute_horizon",
62
+ "render_report",
63
+ "list_trials",
64
+ "get_trial",
65
+ "analyze_trial",
66
+ "compare_trials",
67
+ "get_campaign_material",
68
+ "best_trials",
69
+ "get_metrics_glossary",
70
+ "summarize_bars",
71
+ "save_config",
72
+ "load_config",
73
+ "list_configs",
74
+ )
75
+
76
+ #: Tools that record a trial. An agent that does not know a call costs a trial will
77
+ #: burn a campaign's multiple-testing budget at machine speed, so every one of these
78
+ #: says so in its description - asserted by a test, not left to good intentions.
79
+ JOURNALING_TOOLS = frozenset(
80
+ {
81
+ "run_backtest",
82
+ "confirm_screen_point",
83
+ "run_optimization",
84
+ "run_walk_forward",
85
+ "run_draft_walk_forward",
86
+ "run_verdict",
87
+ }
88
+ )
89
+
90
+ #: The inverse, and it has to be said just as explicitly. A tool that spends no
91
+ #: statistical budget is useless to an agent that assumes every call costs a trial:
92
+ #: it will ration the cheap question exactly as hard as the expensive one, which is
93
+ #: the behaviour a screen exists to remove.
94
+ NON_JOURNALING_NOTE = (
95
+ "Journals NOTHING. This is reconnaissance, not evidence: no trial is recorded, "
96
+ "the campaign's multiple-testing total is unchanged, and nothing here is "
97
+ "promotable or citable as validation. Ask freely — that is the point. To turn one "
98
+ "screened point into recorded evidence, call `confirm_screen_point` with exactly "
99
+ "that point's parameters."
100
+ )
101
+
102
+ #: Evidence-gated features that ship **off**. Where a tool exposes one, its
103
+ #: description must name the gate and its current verdict rather than presenting the
104
+ #: flag as a neutral option - an agent reads a description as fact and acts on it.
105
+ EVIDENCE_GATED = ("conditional", "policy", "posterior")
106
+
107
+ #: Service parameters this surface deliberately does **not** expose, and why.
108
+ #:
109
+ #: MCP parity says anything a run can be configured with should be reachable from both
110
+ #: surfaces. Evidence gating is the stronger rule, and where the two disagree this is the
111
+ #: list that records which won. A feature whose own adoption gate does not clear must not
112
+ #: find in the agent surface an easier way to be switched on than the one a human reads a
113
+ #: warning before using — an agent acts on a description at machine speed, and being
114
+ #: reachable is not the same as being validated.
115
+ #:
116
+ #: Keyed by service parameter, because that is what the parity test enumerates: a
117
+ #: parameter absent from a tool must appear here with a reason, or the test fails. That
118
+ #: makes "not wired yet" impossible to confuse with "deliberately withheld".
119
+ DEFERRED_PARAMS = {
120
+ "conditional": "evidence-gated: the conditional-risk adoption gate does not clear",
121
+ "conditional_lambda": "evidence-gated: configures conditional risk",
122
+ "conditional_method": "evidence-gated: configures conditional risk",
123
+ "posterior": "evidence-gated: the Black-Litterman posterior gate does not clear",
124
+ "posterior_ic": "evidence-gated: configures the posterior",
125
+ "posterior_t_eff": "evidence-gated: configures the posterior",
126
+ "posterior_tau": "evidence-gated: configures the posterior",
127
+ "policy": "evidence-gated: the multi-period aim policy gate does not clear",
128
+ # Inert rather than dangerous, and withheld for that reason: the service passes it
129
+ # only when `policy == "aim"`, so exposing it without the gated policy would be a
130
+ # knob that silently reaches nothing — the defect `_refuse_inert_flags` exists to
131
+ # stop on the CLI.
132
+ "trade_rate": "evidence-gated: reaches nothing unless `policy` is 'aim', which is gated",
133
+ "decay_lookback_days": "evidence-gated: only feeds the aim policy's decay estimate",
134
+ }
135
+
136
+ #: Service parameters withheld for a reason that is not an evidence gate, kept apart so
137
+ #: ``DEFERRED_PARAMS`` stays exactly the evidence-gated list its own test pins.
138
+ #:
139
+ #: Keyed by tool, because the answer is per tool: ``run_verdict``'s ``journal`` is the
140
+ #: CLI's ``--no-journal``, and it is withheld there because the multiple-testing
141
+ #: correction counts attempts - an agent able to run unrecorded verdicts could try as
142
+ #: many variants as it liked without the campaign's total ever noticing. (The draft
143
+ #: walk-forward does expose its own ``journal``, for smoke tests that must not consume a
144
+ #: test; that is a decision about that tool, not a precedent for this one.)
145
+ WITHHELD_PARAMS = {
146
+ "run_verdict": {
147
+ "journal": "every verdict run over this surface is recorded, so the campaign's "
148
+ "multiple-testing count stays honest",
149
+ },
150
+ }
151
+
152
+ #: Deliberately CLI-only, and not an oversight. Quarantining evidence and retiring an
153
+ #: era are operator decisions about a campaign's record, not run configuration: one
154
+ #: changes what every later leaderboard and memo reports, the other moves a user's files.
155
+ #: Both are cheap to ask a human for and expensive to have done on your behalf by
156
+ #: something that cannot be asked why afterwards. An agent that believes a trial is
157
+ #: contaminated should say so and let a person act.
158
+ #: ``small-real`` is here for a different and stronger reason than the other two, and
159
+ #: it is also covered by ``FORBIDDEN_TOOLS`` below: it is the one mode that places real
160
+ #: orders. The read-only wall means an agent physically cannot reach it, and this entry
161
+ #: records that the absence is a decision. Deciding to spend real money on a book is not
162
+ #: a research step an agent takes on somebody's behalf; an agent that thinks execution
163
+ #: telemetry is worth gathering should say so and let a person start the run.
164
+ OPERATOR_ONLY = ("trials archive", "trials mark-contaminated", "small-real")
165
+
166
+ #: Capabilities that must NEVER be exposed over MCP (the safety model).
167
+ FORBIDDEN_TOOLS = frozenset(
168
+ {
169
+ "place_order",
170
+ "submit_order",
171
+ "submit_market_order",
172
+ "submit_bracket_order",
173
+ "start_live",
174
+ "run_live",
175
+ # Every spelling of the mode that can place real orders. Named rather than left
176
+ # to the general wall, because a list of forbidden capabilities that omits the
177
+ # newest one reads as a list somebody checked.
178
+ "small_real",
179
+ "run_small_real",
180
+ "start_small_real",
181
+ "cancel_order",
182
+ "cancel_all_orders",
183
+ "close_position",
184
+ "close_all_positions",
185
+ "set_paper_trade",
186
+ "set_config",
187
+ "get_account",
188
+ "list_positions",
189
+ }
190
+ )
191
+
192
+
193
+ def _parse_date(value: str) -> datetime:
194
+ """Parse an ISO date/datetime string from a tool argument.
195
+
196
+ The CLI's own reading (``utils.timeutils.parse_market_date``): an exchange-zone
197
+ instant, so a request means the same window, and the same recorded trial, over
198
+ either surface.
199
+ """
200
+ return parse_market_date(value)
201
+
202
+
203
+ #: Appended to `run_optimization` only: it is the one tool that journals one trial per
204
+ #: evaluated config. Every other journaling tool states its own count in its own words,
205
+ #: because a shared sentence was false under three of them. An agent that misreads its
206
+ #: own experiment history misreads every result after it.
207
+ _OPTIMIZATION_JOURNALING_NOTE = (
208
+ "Journals one trial per evaluated config, so this call counts toward the "
209
+ "campaign's multiple-testing total (what the Deflated Sharpe deflates against). "
210
+ "An identical prior run is returned from the trial store labeled `memoized`, with "
211
+ "the original run's timestamp, instead of being re-run; pass `force=true` to "
212
+ "re-verify and append a new trial."
213
+ )
214
+
215
+ #: The glossary's deflated-Sharpe definition is appended to every tool that reports the
216
+ #: metric, and it describes the deflated figure. A single backtest or an optimization row
217
+ #: is computed for one trial, so the definition would be read as a claim about a number
218
+ #: that was never deflated.
219
+ _NOT_DEFLATED_NOTE = (
220
+ "Caution on `deflated_sharpe_ratio` in THIS tool's results: it is computed for a single "
221
+ "trial (`deflation_applied: false` where reported), so it is NOT deflated for the number "
222
+ "of configs the campaign has tried, whatever the definition above says. For the "
223
+ "deflated figure use `run_walk_forward`, `list_trials` or `best_trials`."
224
+ )
225
+
226
+ #: `confirm_screen_point` journals exactly what `run_backtest` does: one trial.
227
+ _CONFIRM_JOURNALING_NOTE = (
228
+ "Journals ONE trial (the same record `run_backtest` writes), so this call counts "
229
+ "once toward the campaign's multiple-testing total. An identical prior run is "
230
+ "returned from the trial store labeled `memoized: true` with `journaled: false` - "
231
+ "nothing new was recorded - unless `force=true`, which re-runs and appends a new trial."
232
+ )
233
+
234
+ #: Appended where a tool exposes an evidence-gated feature.
235
+ _GATED_NOTE = (
236
+ "Evidence-gated features ship OFF and are not neutral options: conditional risk, "
237
+ "the aim-in-front-of-the-target trading policy, and the Black-Litterman posterior "
238
+ "each have an adoption gate (a predictive-accuracy test or a net-of-cost A/B), and "
239
+ "none of them clears on this repository's own demo data. Enabling one is a "
240
+ "deliberate departure from the validated default, not a tuning choice."
241
+ )
242
+
243
+
244
+ #: Returned instead of rows when the store cannot be opened. The store is derived and
245
+ #: passive by contract: a broken one degrades a read, it never fails a session.
246
+ _NO_STORE = "The trial store could not be opened; no campaign history is available for this call."
247
+
248
+
249
+ @contextlib.contextmanager
250
+ def _trial_store():
251
+ """A read-only handle on the trial store, or ``None`` if it cannot be opened.
252
+
253
+ Delegates. This was a third copy of the CLI's and the service's opener - three
254
+ places deciding which journal to index, which is the one decision they must never
255
+ disagree about.
256
+ """
257
+ from quantred.services.audit import open_trial_store
258
+
259
+ with open_trial_store() as store:
260
+ yield store
261
+
262
+
263
+ #: Said after every refusal of an unknown trial id, so the agent has a next step.
264
+ _USE_LIST_TRIALS = "Use list_trials to see which trial ids are recorded."
265
+
266
+
267
+ def _trade_recovery_reason(report: Dict[str, Any]) -> Optional[str]:
268
+ """What an agent can actually do to get a trade table for this kind of trial.
269
+
270
+ The shared reason says to re-run "with trades recorded", which only two tools can
271
+ do: `run_backtest` and `run_walk_forward` take `record_trades`. `run_verdict`,
272
+ `run_optimization` and `run_draft_walk_forward` do not, so advice naming the
273
+ parameter sent an agent to a tool that rejects it - and it acts on the advice.
274
+ """
275
+ kind = report.get("kind")
276
+ if kind == "verdict":
277
+ return (
278
+ "a verdict trial carries no trade table - it scores a cross-sectional book, not "
279
+ "a sequence of trades, and `run_verdict` cannot record one. To see trades, run "
280
+ "`run_backtest` with `record_trades=true` over the same window and universe "
281
+ "and analyze that trial instead."
282
+ )
283
+ if kind == "optimize":
284
+ return (
285
+ "an optimization trial carries no trade table - `run_optimization` evaluates many "
286
+ "configs and cannot record any of their trades. To see one config's trades, run "
287
+ "`run_backtest` with `record_trades=true` on its parameters over the same window "
288
+ "and universe, and analyze that trial instead."
289
+ )
290
+ if kind == "walkforward" and str(report.get("strategy") or "").startswith("draft:"):
291
+ return (
292
+ "a draft walk-forward trial carries no trade table - `run_draft_walk_forward` "
293
+ "cannot record one. Run `run_backtest` with `record_trades=true` on an installed "
294
+ "strategy, or install the draft and run `run_walk_forward` with `record_trades=true`."
295
+ )
296
+ return None
297
+
298
+
299
+ def _trade_recovery_note(report: Dict[str, Any]) -> Dict[str, Any]:
300
+ """Replace the shared "re-run with trades recorded" reason with one the trial's own
301
+ kind can act on. Only an unavailable table is touched, and only when the kind has a
302
+ tailored answer; a capped or unrecorded-completeness table keeps its own reason."""
303
+ if report.get("status") != "unavailable":
304
+ return report
305
+ reason = _trade_recovery_reason(report)
306
+ if reason is None:
307
+ return report
308
+ if report.get("kind") != "verdict" and not str(report.get("reason", "")).startswith(
309
+ "no trade table was recorded"
310
+ ):
311
+ return report
312
+ return {**report, "reason": reason}
313
+
314
+
315
+ #: Appended where a tool accepts `workers`. Parallelism is a throughput choice, and
316
+ #: an agent should know it changes nothing else about the answer.
317
+ _WORKERS_NOTE = (
318
+ "`workers` (default 1) evaluates candidates across that many processes. It changes "
319
+ "wall-clock only: the same seed produces the same trials, the same winner, and the "
320
+ "same campaign trial count as a sequential run, because workers only execute — this "
321
+ "server still does every journal write itself. Memory scales with workers x the "
322
+ "per-worker bar footprint, and parallel runs read from the local bar cache."
323
+ )
324
+
325
+
326
+ def _describe(doc: Optional[str], *metric_keys: str, notes: Optional[List[str]] = None) -> str:
327
+ """A tool's description: its docstring, plus the glossary's own words for any
328
+ metric it reports.
329
+
330
+ Metric definitions are *pulled* from :mod:`quantred.services.glossary` rather than
331
+ restated here, so a description can never drift from what the metric actually
332
+ means — the same one-source-of-truth rule the layer table applies to logic.
333
+ Descriptions matter more than docs do: an agent cannot notice that a
334
+ description is stale, it can only act on it, and every action costs a trial.
335
+ """
336
+ parts = [(doc or "").strip()]
337
+ if metric_keys:
338
+ definitions = glossary.definitions_for(metric_keys)
339
+ if definitions:
340
+ parts.append(
341
+ "Metric definitions (from the shared glossary — call get_metrics_glossary "
342
+ "for the full set and its pitfalls):\n"
343
+ + "\n".join(f"- {name}: {text}" for name, text in definitions.items())
344
+ )
345
+ parts.extend(notes or [])
346
+ return "\n\n".join(p for p in parts if p)
347
+
348
+
349
+ class StrategyEntry(TypedDict):
350
+ """One row of ``list_strategies``; declared so a client can validate it."""
351
+
352
+ name: str
353
+ description: str
354
+ #: None means the class declares no timeframe - absent, not an empty string.
355
+ timeframe: Optional[str]
356
+ #: Shipped demonstration scaffolding, not a validated strategy.
357
+ demo: bool
358
+
359
+
360
+ class ScannerEntry(TypedDict):
361
+ """One row of ``list_scanners``."""
362
+
363
+ name: str
364
+ description: str
365
+ demo: bool
366
+
367
+
368
+ def _keys_missing_message() -> str:
369
+ """What a tool that needs bars says when the server has no keys to fetch them with.
370
+
371
+ Spelled for the copy that is running, and aimed at a person at a terminal: the agent
372
+ cannot fix this itself, and a client that only shows "Connection closed" (which is
373
+ what a server dying at startup used to look like) says nothing at all.
374
+ """
375
+ from quantred.settings import invocation
376
+
377
+ return (
378
+ "This tool needs market data and the server has no broker keys, so it cannot fetch "
379
+ f"bars. Add them from a terminal with `{invocation('init')}` and restart the server, "
380
+ f"or start it with `{invocation('mcp --offline')}` to read only the local bar cache, "
381
+ "which needs no keys. Catalogue and trial-history tools work without keys."
382
+ )
383
+
384
+
385
+ def build_server(data_client=None, *, offline: bool = False, cache_dir=None):
386
+ """Construct the FastMCP server with all read/analyze/propose tools registered.
387
+
388
+ ``data_client`` may be injected (offline tests); otherwise a data-only client is
389
+ built **lazily**, on the first tool that needs bars. Building it here made a server
390
+ with no keys exit at startup, which a client reports only as a closed connection - and
391
+ took the catalogue and trial-history tools, which need no market data, down with it.
392
+ ``offline`` builds the keyless, cache-only client instead. Either way the server holds
393
+ only a data client, never a broker. Importing ``mcp`` is deferred to here so the
394
+ package and the rest of the CLI never require the extra.
395
+ """
396
+ from mcp.server.fastmcp import FastMCP
397
+
398
+ if data_client is not None:
399
+ _assert_no_trading_client(data_client)
400
+ held: List[Any] = [data_client]
401
+
402
+ def _data():
403
+ """The data client, built on first use. A failed build is retried, not cached."""
404
+ if held[0] is None:
405
+ from quantred.services.data import build_data_client
406
+ from quantred.settings import SettingsError
407
+
408
+ try:
409
+ client = build_data_client(offline=offline, cache_dir=cache_dir)
410
+ except SettingsError as exc:
411
+ raise RuntimeError(_keys_missing_message()) from exc
412
+ _assert_no_trading_client(client)
413
+ held[0] = client
414
+ return held[0]
415
+
416
+ server = FastMCP(SERVER_NAME)
417
+ # FastMCP takes no version, so a client would display this app as the SDK's own
418
+ # ("quantred 1.30.0"). The low-level server's `version` is what `initialize` reports.
419
+ import quantred
420
+
421
+ server._mcp_server.version = quantred.__version__
422
+
423
+ def _logged(tool: str, inputs: Dict[str, Any], result: Any) -> Any:
424
+ run_id = result.get("run_id") if isinstance(result, dict) else None
425
+ _SESSION["tool_calls"] += 1
426
+ audit_log(tool, inputs, run_id=run_id or new_run_id(), result_summary=_summary(result))
427
+ return _json_safe(result)
428
+
429
+ def tool(*metric_keys: str, notes: Optional[List[str]] = None):
430
+ """Register a tool whose description is *composed*, not hand-written twice.
431
+
432
+ The docstring stays next to the code, and the glossary's own definitions for
433
+ the metrics this tool reports are appended mechanically — so the description
434
+ an agent reads cannot drift from what the metric means, and shared caveats
435
+ (journaling, evidence-gated defaults) are stated identically everywhere.
436
+ """
437
+
438
+ def register(fn):
439
+ server.tool(description=_describe(fn.__doc__, *metric_keys, notes=notes))(fn)
440
+ return fn
441
+
442
+ return register
443
+
444
+ # ---------------- Discovery (safe) ---------------- #
445
+ @tool()
446
+ def list_strategies() -> List[StrategyEntry]:
447
+ """List the trading strategies this engine can run. Each row has `name`, a
448
+ one-line `description`, `timeframe` (the bar timeframe it is designed for, or null
449
+ when the class declares none) and `demo` (true for shipped demonstration
450
+ scaffolding, which shows how the pieces fit and is not a validated strategy).
451
+
452
+ Start here. A strategy is a `bar -> score` policy: each defines one continuous
453
+ conviction score, and both the discrete BUY/SELL/HOLD signal and the continuous
454
+ alpha forecast are derived from that one score. The `name` values are what every
455
+ other tool's `strategy` argument accepts. Read-only.
456
+ """
457
+ return _logged("list_strategies", {}, registry.list_strategies())
458
+
459
+ @tool()
460
+ def list_scanners() -> List[ScannerEntry]:
461
+ """List the universe scanners. Each row has `name`, a one-line `description` of
462
+ what it selects for, and `demo` (true for shipped demonstration scaffolding, not
463
+ a validated scanner).
464
+
465
+ A scanner narrows a candidate symbol list to the names worth analyzing on a
466
+ given day; the `name` values are what every other tool's `scanner` argument
467
+ accepts, and `"none"` skips scanning and uses the candidates as given. Choosing
468
+ a universe by looking at outcomes is look-ahead — treat universe selection as a
469
+ research decision, not something to tune. Read-only.
470
+ """
471
+ return _logged("list_scanners", {}, registry.list_scanners())
472
+
473
+ @tool()
474
+ def get_param_ranges(kind: str, name: str) -> Dict[str, Any]:
475
+ """The tunable parameters of one strategy or scanner: bounds and defaults.
476
+
477
+ `kind` is "strategy" or "scanner". This is the map of what `run_optimization`
478
+ and `run_walk_forward` are allowed to search over, and what a `config` override
479
+ on any other tool may contain — a parameter outside its declared range is
480
+ rejected rather than silently clamped. Fewer parameters searched means fewer
481
+ trials burned and a less deflated Sharpe to clear. Read-only.
482
+ """
483
+ return _logged(
484
+ "get_param_ranges", {"kind": kind, "name": name}, registry.get_param_ranges(kind, name)
485
+ )
486
+
487
+ # ---------------- Read / analyze (safe) ---------------- #
488
+ @tool()
489
+ def run_scan(
490
+ scanner: str,
491
+ symbols: List[str],
492
+ config: Optional[Dict[str, Any]] = None,
493
+ as_of: Optional[str] = None,
494
+ ) -> Dict[str, Any]:
495
+ """Run a universe scanner over `symbols` and return the names it flags.
496
+
497
+ Narrows a candidate list to the symbols worth analyzing, with each one's
498
+ scanner signal strength. This is the same universe resolution every other tool
499
+ performs internally — call it when you want to see or reuse the selection,
500
+ not as a prerequisite.
501
+
502
+ `as_of` can pin the scan to a historical ISO date/datetime. Omit it only when
503
+ you genuinely want "now"; historical backtests and verdicts resolve scanner
504
+ state at their own window end to avoid mixing today's universe with an older
505
+ evaluation window.
506
+
507
+ Scanning is a research decision, not a tuning knob: picking a universe by
508
+ which one produces the best backtest is look-ahead, and it will not survive
509
+ out-of-sample. Read-only, journals nothing.
510
+ """
511
+ inputs = {"scanner": scanner, "symbols": symbols, "config": config, "as_of": as_of}
512
+ return _logged(
513
+ "run_scan",
514
+ inputs,
515
+ analysis.run_scan(_data(), scanner, symbols, config, as_of=_parse_date(as_of) if as_of else None),
516
+ )
517
+
518
+ @tool(notes=[NON_JOURNALING_NOTE])
519
+ def run_causality_probes(
520
+ strategy: str,
521
+ symbols: List[str],
522
+ start: str,
523
+ end: str,
524
+ capital: float = 100_000.0,
525
+ gross: bool = False,
526
+ commission_bps: float = 1.0,
527
+ impact_eta: float = 0.3,
528
+ borrow_bps: float = 50.0,
529
+ benchmark: Optional[str] = None,
530
+ position_limits: Optional[Dict[str, Any]] = None,
531
+ scanner: Optional[str] = None,
532
+ scan_as_of: Optional[str] = None,
533
+ sample: int = 3,
534
+ ) -> Dict[str, Any]:
535
+ """Check whether this strategy's decisions could have been made when they were.
536
+
537
+ Four probes, all by perturbation: withhold something that only becomes knowable
538
+ after a fill, re-run, and require the decision at that instant to be unchanged.
539
+ `execution_clock` (was every input available strictly before the fill priced),
540
+ `same_bar_ranking` (entry ordering must not consult the bar it transacts on),
541
+ `benchmark_alignment` (the benchmark must not be paired out of step with the
542
+ strategy), and `as_of_scanner` (universe selection must not read past its clock).
543
+
544
+ **This is NOT the leakage probe and does not replace it.** That one shifts the
545
+ feed forward to test for *future data*. It cannot detect a one-bar look-ahead at
546
+ all, because a shift moves signal and price together and leaves the relationship
547
+ intact — it cleared a candidate whose every signal was executed one bar early.
548
+ A passing leakage probe says nothing about anything checked here, and vice versa.
549
+
550
+ Read `verdict` carefully. `incomplete` means at least one probe had nothing to
551
+ examine — no trades, no contention for slots, no benchmark — and that is not a
552
+ pass: a run that never traded has not been cleared by a probe about trading.
553
+ `not_exercised` names which ones. Each probe's `passed` is true, false, or null
554
+ for the same reason.
555
+ """
556
+ inputs = {
557
+ "strategy": strategy,
558
+ "symbols": symbols,
559
+ "start": start,
560
+ "end": end,
561
+ "benchmark": benchmark,
562
+ "position_limits": position_limits,
563
+ "scanner": scanner,
564
+ "sample": sample,
565
+ }
566
+ result = analysis.run_causality_probes(
567
+ _data(),
568
+ strategy,
569
+ symbols,
570
+ _parse_date(start),
571
+ _parse_date(end),
572
+ capital=capital,
573
+ gross=gross,
574
+ commission_bps=commission_bps,
575
+ impact_eta=impact_eta,
576
+ borrow_bps=borrow_bps,
577
+ benchmark=benchmark,
578
+ position_limits=position_limits,
579
+ scanner=scanner,
580
+ scan_as_of=_parse_date(scan_as_of) if scan_as_of else None,
581
+ sample=sample,
582
+ )
583
+ return _logged("run_causality_probes", inputs, result)
584
+
585
+ @tool("sharpe_ratio", "sortino_ratio", "profit_factor", notes=[NON_JOURNALING_NOTE])
586
+ def run_screen(
587
+ strategy: str,
588
+ symbols: List[str],
589
+ start: str,
590
+ end: str,
591
+ method: str = "grid",
592
+ objective: str = "sharpe_ratio",
593
+ max_evals: int = 50,
594
+ seed: int = 42,
595
+ capital: float = 100_000.0,
596
+ gross: bool = False,
597
+ commission_bps: float = 1.0,
598
+ impact_eta: float = 0.3,
599
+ borrow_bps: float = 50.0,
600
+ position_limits: Optional[Dict[str, Any]] = None,
601
+ param_ranges: Optional[Dict[str, Dict[str, Any]]] = None,
602
+ workers: int = 1,
603
+ ) -> Dict[str, Any]:
604
+ """Sweep a parameter space cheaply to ask whether a family holds anything at all.
605
+
606
+ The reconnaissance step *before* optimization. One process, one data fetch, N
607
+ evaluations, and nothing recorded — so "does any parameterisation of this idea
608
+ survive?" is a question you can afford to ask.
609
+
610
+ Read the `distribution` block, not `best_point`. `best_point` is the winning
611
+ row flattened with its metrics; its `params` field is just the parameter values,
612
+ and is what `confirm_screen_point` takes. The best of N points is the
613
+ maximum of N draws, which is a positive number even when nothing searched has
614
+ any edge, and it grows with N. `noise_baseline.expected_best_under_null` is
615
+ what the best of that many draws is worth if none of them had any edge: a
616
+ `best_point` below it is indistinguishable from noise no matter how good the
617
+ number looks. That baseline assumes the points are independent, which
618
+ neighbouring grid points are not, so it sits high; it is a reference for
619
+ reading the table, not a test. It is omitted, with a reason, for any objective
620
+ whose null is not zero.
621
+
622
+ `gradients` is the other half. A positive rate that moves coherently across a
623
+ parameter is different evidence from the same count of positive points
624
+ scattered at random — and it can point off the edge of the range you searched,
625
+ which a best-point report structurally cannot show.
626
+
627
+ `param_ranges` narrows the search per parameter, e.g.
628
+ {"lookback": {"min": 10, "max": 30, "step": 5}}; each supplies any of
629
+ min/max/step and inherits the rest. It can only narrow: a bound outside the
630
+ strategy's declared range is refused. `position_limits` is the book each point is
631
+ evaluated against (e.g. {"max_positions": 8}); without it every point runs at
632
+ whatever the strategy class declares, which is not what would be deployed.
633
+ Parameter combinations a strategy declares invalid are never drawn.
634
+ """
635
+ inputs = {
636
+ "strategy": strategy,
637
+ "symbols": symbols,
638
+ "start": start,
639
+ "end": end,
640
+ "method": method,
641
+ "objective": objective,
642
+ "max_evals": max_evals,
643
+ "seed": seed,
644
+ "gross": gross,
645
+ "position_limits": position_limits,
646
+ "param_ranges": param_ranges,
647
+ }
648
+ result = analysis.run_screen(
649
+ _data(),
650
+ strategy,
651
+ symbols,
652
+ _parse_date(start),
653
+ _parse_date(end),
654
+ method=method,
655
+ objective=objective,
656
+ max_evals=max_evals,
657
+ seed=seed,
658
+ capital=capital,
659
+ gross=gross,
660
+ commission_bps=commission_bps,
661
+ impact_eta=impact_eta,
662
+ borrow_bps=borrow_bps,
663
+ position_limits=position_limits,
664
+ param_ranges=param_ranges,
665
+ workers=workers,
666
+ )
667
+ return _logged("run_screen", inputs, result)
668
+
669
+ @tool("sharpe_ratio", "deflated_sharpe_ratio", notes=[_CONFIRM_JOURNALING_NOTE, _NOT_DEFLATED_NOTE])
670
+ def confirm_screen_point(
671
+ strategy: str,
672
+ symbols: List[str],
673
+ start: str,
674
+ end: str,
675
+ params: Dict[str, Any],
676
+ capital: float = 100_000.0,
677
+ gross: bool = False,
678
+ commission_bps: float = 1.0,
679
+ impact_eta: float = 0.3,
680
+ borrow_bps: float = 50.0,
681
+ position_limits: Optional[Dict[str, Any]] = None,
682
+ force: bool = False,
683
+ ) -> Dict[str, Any]:
684
+ """Re-run EXACTLY ONE screened point as a proper, journaled trial.
685
+
686
+ `params` is one parameter set — one point: only parameters the strategy declares
687
+ (see `get_param_ranges`), such as a screen's `best_point.params`. Any other key,
688
+ including the metrics beside `best_point`'s parameters, is refused rather than
689
+ journaled into the trial's config. This is the only way a screen's
690
+ finding becomes evidence, and it is deliberately singular: confirming a set
691
+ would make the screen itself journal, which is the statistical-budget problem
692
+ the screen exists to avoid. It would also record the best of N, which is the
693
+ one selection a sweep cannot support.
694
+
695
+ Identical to running `run_backtest` with those parameters when the extra
696
+ cost and book knobs here (`commission_bps`, `impact_eta`, `borrow_bps`,
697
+ `position_limits`) are left at their defaults — same dedup identity, same
698
+ memoization, same journal record — so a confirmed point is indistinguishable
699
+ from a backtest anyone else ran, and counts once. Set one and the run is a
700
+ different config from a plain backtest of those parameters.
701
+ """
702
+ inputs = {
703
+ "strategy": strategy,
704
+ "symbols": symbols,
705
+ "start": start,
706
+ "end": end,
707
+ "params": params,
708
+ "gross": gross,
709
+ "position_limits": position_limits,
710
+ "force": force,
711
+ }
712
+ result = analysis.confirm_screen_point(
713
+ _data(),
714
+ strategy,
715
+ symbols,
716
+ _parse_date(start),
717
+ _parse_date(end),
718
+ params,
719
+ capital=capital,
720
+ gross=gross,
721
+ commission_bps=commission_bps,
722
+ impact_eta=impact_eta,
723
+ borrow_bps=borrow_bps,
724
+ position_limits=position_limits,
725
+ force=force,
726
+ )
727
+ return _logged("confirm_screen_point", inputs, result)
728
+
729
+ @tool(
730
+ "sharpe_ratio",
731
+ "deflated_sharpe_ratio",
732
+ "max_drawdown",
733
+ "profit_factor",
734
+ "low_sample",
735
+ notes=[_NOT_DEFLATED_NOTE],
736
+ )
737
+ def run_backtest(
738
+ strategy: str,
739
+ symbols: List[str],
740
+ start: str,
741
+ end: str,
742
+ capital: float = 100_000.0,
743
+ config: Optional[Dict[str, Any]] = None,
744
+ beta_sizing: bool = False,
745
+ benchmark: str = "SPY",
746
+ gross: bool = False,
747
+ take_profit_margin_bps: float = 0.0,
748
+ force: bool = False,
749
+ record_trades: bool = False,
750
+ ) -> Dict[str, Any]:
751
+ """Backtest `strategy` on `symbols` over [start, end] (YYYY-MM-DD).
752
+
753
+ Returns the full metrics dict (NET of transaction cost by default — commission
754
+ + half-spread + square-root impact; pass gross=True to disable), the trade
755
+ count, total cost, and a path to the trades CSV (trades are not inlined).
756
+ `config` overrides default params.
757
+
758
+ `take_profit_margin_bps` requires the price to trade that far *through* a
759
+ take-profit before it counts as filled. Zero — the default — fills a target the
760
+ moment a bar touches it, which models a resting limit always first in the queue.
761
+ For a strategy whose gain concentrates in target exits that assumption is the
762
+ result rather than a detail, so raising it is how you find out which you have.
763
+
764
+ The result carries `excursion`: how bad the book got *inside* a bar against how
765
+ bad its closing marks ever showed. The equity curve is marked at each bar's
766
+ close, so a shallow `max_drawdown` can sit over a period the book spent in more
767
+ trouble — and per-trade MAE cannot tell you, because a position deep underwater
768
+ may be a small part of the book. Read both bounds and quote the pair: the
769
+ closing mark is a lower bound on the pain, and the intra-bar figure marks every
770
+ position at its own worst tick and so assumes they all got there at once, which
771
+ makes it an upper bound and never a measurement. The realized worst lies
772
+ between them. Nothing is gated on either.
773
+
774
+ Journals this as a trial (the same research journal and trial store the
775
+ `backtest` command uses) so it counts toward the campaign's multiple-testing
776
+ total. An exact prior trial is served instead (result has `memoized: true`)
777
+ unless `force=True`, which re-runs and appends a new trial rather than
778
+ overwriting the memoized one. A memoized result says `journaled: false`: nothing
779
+ new was recorded, and `trial_id` is the original's. Memoization is scoped to the engine's accounting
780
+ version, so results from an older engine are never served to a newer one.
781
+
782
+ `record_trades=true` stores the full trade table with the journaled trial, which
783
+ `analyze_trial` reads; without it the trial keeps no table and it reports it as
784
+ not recorded. `compare_trials` does not read that table: it reads the dated
785
+ return series every journaled backtest stores either way. A prior identical trial that kept no table
786
+ does not answer a `record_trades=true` request: the backtest is re-run and a new
787
+ trial (with the table) is journaled, counting toward the multiple-testing total.
788
+
789
+ A memoized result is NOT the full result: the trial store keeps the headline
790
+ metrics, not total cost, execution checks, excursion, the trades CSV or the
791
+ resolved config. It carries `not_recorded` naming every field a fresh run has
792
+ that this one lacks (absent, not zero), `from_request` for values filled from
793
+ your request, and `derived` for values computed from stored ones. Pass
794
+ `force=True` to recompute the missing fields.
795
+ """
796
+ inputs = {
797
+ "strategy": strategy,
798
+ "symbols": symbols,
799
+ "start": start,
800
+ "end": end,
801
+ "capital": capital,
802
+ "config": config,
803
+ "beta_sizing": beta_sizing,
804
+ "gross": gross,
805
+ "take_profit_margin_bps": take_profit_margin_bps,
806
+ "force": force,
807
+ **({"record_trades": True} if record_trades else {}),
808
+ }
809
+ result = analysis.run_backtest(
810
+ _data(),
811
+ strategy,
812
+ symbols,
813
+ _parse_date(start),
814
+ _parse_date(end),
815
+ capital,
816
+ config,
817
+ beta_sizing,
818
+ benchmark,
819
+ gross=gross,
820
+ take_profit_margin_bps=take_profit_margin_bps,
821
+ force=force,
822
+ **({"record_trades": True} if record_trades else {}),
823
+ )
824
+ return _logged("run_backtest", inputs, result)
825
+
826
+ @tool(
827
+ "sharpe_ratio",
828
+ "deflated_sharpe_ratio",
829
+ notes=[_OPTIMIZATION_JOURNALING_NOTE, _NOT_DEFLATED_NOTE, _WORKERS_NOTE],
830
+ )
831
+ def run_optimization(
832
+ strategy: str,
833
+ symbols: List[str],
834
+ start: str,
835
+ end: str,
836
+ method: str = "grid",
837
+ objective: str = "sharpe_ratio",
838
+ max_evals: int = 50,
839
+ seed: int = 42,
840
+ capital: float = 100_000.0,
841
+ gross: bool = False,
842
+ force: bool = False,
843
+ workers: int = 1,
844
+ ) -> Dict[str, Any]:
845
+ """Search a strategy's parameters IN-SAMPLE (grid|random|bayesian).
846
+
847
+ Returns best_params, best_score, and the top-10 configs (full grid -> CSV).
848
+ NET of transaction cost by default (commission + half-spread + square-root
849
+ impact; pass gross=True to disable) — gross search reliably favors the
850
+ highest-turnover config. IMPORTANT: in-sample results from picking the
851
+ best of many configs are inflated and are NOT evidence of edge. Always
852
+ validate with run_walk_forward before trusting them.
853
+
854
+ Each evaluated config is journaled as its own trial (same journal/trial
855
+ store the CLI uses). A candidate identical to one already scored this
856
+ campaign is served from the trial store instead of re-run (`n_memoized`
857
+ in the result) unless `force=True`.
858
+ """
859
+ inputs = {
860
+ "strategy": strategy,
861
+ "symbols": symbols,
862
+ "start": start,
863
+ "end": end,
864
+ "method": method,
865
+ "objective": objective,
866
+ "max_evals": max_evals,
867
+ "seed": seed,
868
+ "gross": gross,
869
+ "force": force,
870
+ }
871
+ result = analysis.run_optimization(
872
+ _data(),
873
+ strategy,
874
+ symbols,
875
+ _parse_date(start),
876
+ _parse_date(end),
877
+ method,
878
+ objective,
879
+ max_evals,
880
+ seed,
881
+ capital,
882
+ gross=gross,
883
+ force=force,
884
+ workers=workers,
885
+ )
886
+ return _logged("run_optimization", inputs, result)
887
+
888
+ @tool(
889
+ "sharpe_ratio",
890
+ "deflated_sharpe_ratio",
891
+ "profit_factor",
892
+ "max_drawdown",
893
+ notes=[_WORKERS_NOTE],
894
+ )
895
+ def run_walk_forward(
896
+ strategy: str,
897
+ symbols: List[str],
898
+ start: str,
899
+ end: str,
900
+ mode: str = "anchored",
901
+ n_folds: Optional[int] = None,
902
+ embargo_days: Optional[int] = None,
903
+ holdout_days: int = 0,
904
+ method: str = "grid",
905
+ objective: str = "sharpe_ratio",
906
+ max_evals: int = 50,
907
+ seed: int = 42,
908
+ capital: float = 100_000.0,
909
+ include_pbo: bool = False,
910
+ parameter_sensitivity: bool = False,
911
+ leakage_probe: bool = False,
912
+ gross: bool = False,
913
+ force: bool = False,
914
+ workers: int = 1,
915
+ benchmark: Optional[str] = None,
916
+ position_limits: Optional[Dict[str, Any]] = None,
917
+ record_trades: bool = False,
918
+ cost_stress: Optional[Literal["all", "turnover", "borrow"]] = "all",
919
+ ) -> Dict[str, Any]:
920
+ """Honest out-of-sample evaluation across folds - your advancement criterion.
921
+
922
+ Optimizes in-sample per fold, scores out-of-sample, and returns the OOS
923
+ aggregate, walk-forward efficiency, IS->OOS degradation, per-fold summary,
924
+ the holdout score (if holdout_days>0), the Deflated Sharpe (corrected for
925
+ all configs tried), and the PROMOTION-GATE verdict (pass/fail per gate +
926
+ overall "promotable"). A config advances only if it is promotable - never
927
+ on in-sample Sharpe. include_pbo is expensive; leave it off unless needed.
928
+ NET of transaction cost by default, in-sample and out (pass gross=True to
929
+ disable) — gross validation systematically promotes turnover the
930
+ strategy could not afford live.
931
+
932
+ `promotable` is the statistical gates ONLY and does not mean clear for paper.
933
+ The `evidence` block beside it is what the CLI prints as "Promotion
934
+ prerequisites" and "Evidence completeness": cost stress, family bootstrap
935
+ (not run over this tool, so it stays unevaluated), and benchmark-relative
936
+ information ratio and excess return, each pass/fail/unevaluated, plus excess
937
+ return per fold and `evidence.clear`. Read `evidence.evidence_completeness`
938
+ before acting on `promotable`; an unevaluated check is unknown, not passed.
939
+ `holdout` is the holdout's metrics on a fresh run (null when none was requested); a
940
+ memoized answer carries `holdout_record` instead ({state, days, metrics, params}:
941
+ recorded, none requested, or requested before the journal kept it).
942
+ When `evidence` is null, `evidence_unavailable` says why (a memoized answer keeps no
943
+ per-fold detail). An unknown `cost_stress` is refused rather than stressing nothing.
944
+ `cost_stress` ("all"/"turnover"/"borrow", null to skip) re-runs the chosen
945
+ config at worse costs and at the same `position_limits` book, unjournaled, and is on by default as in the CLI.
946
+
947
+ `benchmark` is a symbol to measure against (e.g. "SPY"). Without it the
948
+ benchmark-relative prerequisites are left unevaluated rather than failed.
949
+ It is scored per fold and aggregated as a median: `oos_aggregate`
950
+ (concatenated trades) deliberately carries no benchmark figures, so its
951
+ `benchmark_available: false` is not a missing benchmark. `position_limits` is the book the config says it will trade
952
+ (e.g. {"max_positions": 8}); without it the validation runs at whatever the
953
+ strategy class declares, which is not what would be deployed.
954
+
955
+ Journals the OOS aggregate as one validated trial. An identical prior
956
+ validation (same recipe: mode/folds/method/objective/max_evals/seed/cost/
957
+ book over the same window) is served instead (result has `memoized: true` and
958
+ `journaled: false`: nothing new was recorded) unless `force=True`. The trial
959
+ counts toward the campaign's multiple-testing total once.
960
+
961
+ `record_trades=true` stores the out-of-sample trade table with the validated
962
+ trial, which `analyze_trial` reads; without it the trial keeps no table.
963
+ `compare_trials` reads the stitched out-of-sample return series instead, which
964
+ every journaled validation stores either way. A prior identical validation that kept no table does not
965
+ answer a `record_trades=true` request: the validation is re-run and a new trial
966
+ (with the table) is journaled, adding every configuration its inner search
967
+ evaluates to the family's multiple-testing total - as large a cost as the first
968
+ validation. Ask for the table on the first run you mean to analyze.
969
+ """
970
+ inputs = {
971
+ "strategy": strategy,
972
+ "symbols": symbols,
973
+ "start": start,
974
+ "end": end,
975
+ "mode": mode,
976
+ "n_folds": n_folds,
977
+ "embargo_days": embargo_days,
978
+ "holdout_days": holdout_days,
979
+ "method": method,
980
+ "objective": objective,
981
+ "max_evals": max_evals,
982
+ "seed": seed,
983
+ "include_pbo": include_pbo,
984
+ "gross": gross,
985
+ "force": force,
986
+ "benchmark": benchmark,
987
+ "position_limits": position_limits,
988
+ **({"record_trades": True} if record_trades else {}),
989
+ **({"cost_stress": cost_stress} if cost_stress != "all" else {}),
990
+ }
991
+ result = analysis.run_walk_forward(
992
+ _data(),
993
+ strategy,
994
+ symbols,
995
+ _parse_date(start),
996
+ _parse_date(end),
997
+ mode=mode,
998
+ n_folds=n_folds,
999
+ embargo_days=embargo_days,
1000
+ holdout_days=holdout_days,
1001
+ method=method,
1002
+ objective=objective,
1003
+ max_evals=max_evals,
1004
+ seed=seed,
1005
+ capital=capital,
1006
+ include_pbo=include_pbo,
1007
+ parameter_sensitivity=parameter_sensitivity,
1008
+ leakage_probe=leakage_probe,
1009
+ gross=gross,
1010
+ force=force,
1011
+ workers=workers,
1012
+ benchmark=benchmark,
1013
+ position_limits=position_limits,
1014
+ **({"record_trades": True} if record_trades else {}),
1015
+ **({"cost_stress": cost_stress} if cost_stress != "all" else {}),
1016
+ )
1017
+ return _logged("run_walk_forward", inputs, result)
1018
+
1019
+ @tool()
1020
+ def validate_draft_strategy_code(code: str, class_name: Optional[str] = None) -> Dict[str, Any]:
1021
+ """Validate draft Strategy source without registering or running it.
1022
+
1023
+ Use this for agent-authored or private-package-bound strategy code before
1024
+ spending market-data calls on it. The code is compiled in the research
1025
+ sandbox with restricted imports, then checked for the Strategy contract:
1026
+ concrete subclass, docstring, valid PARAM_RANGES with no more than five
1027
+ searchable params, default construction, required sizing/risk config, and
1028
+ one continuous score source. It writes no files and journals no trials.
1029
+
1030
+ Always answers, never raises: a rejection comes back as `valid: false` with
1031
+ `error_kind` either `invalid_draft` (rewrite the source - `error` names what
1032
+ to fix) or `validator_error` (the validator itself failed on this input; the
1033
+ draft may be fine, so rewriting it is the wrong response).
1034
+
1035
+ This is a drafting aid, not promotion. To make validated code available by
1036
+ name, ship it in a package exposing the `quantred.strategies` entry-point
1037
+ group, or keep using `run_draft_walk_forward` with source attached.
1038
+ """
1039
+ inputs = {"class_name": class_name, "code_hash": analysis.draft_code_hash(code)}
1040
+ return _logged(
1041
+ "validate_draft_strategy_code",
1042
+ inputs,
1043
+ analysis.validate_draft_strategy_code(code, class_name=class_name),
1044
+ )
1045
+
1046
+ @tool()
1047
+ def validate_draft_scanner_code(code: str, class_name: Optional[str] = None) -> Dict[str, Any]:
1048
+ """Validate draft ScannerStrategy source without registering or running it.
1049
+
1050
+ Use this for agent-authored or private-package-bound scanner code before it
1051
+ becomes part of a universe-selection workflow. The code is compiled in the
1052
+ research sandbox with restricted imports, then checked for the scanner
1053
+ contract: concrete subclass, docstring, valid PARAM_RANGES with no more than
1054
+ five searchable params, default construction, and a generated signal frame
1055
+ containing `signal` plus numeric `signal_strength` using the scanner signal
1056
+ vocabulary. It writes no files and journals no trials. It answers with the
1057
+ same verdict shape as `validate_draft_strategy_code`, including on rejection.
1058
+
1059
+ To make validated scanner code available by name, ship it in a package
1060
+ exposing the `quantred.scanners` entry-point group.
1061
+ """
1062
+ inputs = {"class_name": class_name, "code_hash": analysis.draft_code_hash(code)}
1063
+ return _logged(
1064
+ "validate_draft_scanner_code",
1065
+ inputs,
1066
+ analysis.validate_draft_scanner_code(code, class_name=class_name),
1067
+ )
1068
+
1069
+ @tool("sharpe_ratio", "deflated_sharpe_ratio", "profit_factor", "max_drawdown")
1070
+ def run_draft_walk_forward(
1071
+ code: str,
1072
+ symbols: List[str],
1073
+ start: str,
1074
+ end: str,
1075
+ class_name: Optional[str] = None,
1076
+ mode: str = "anchored",
1077
+ n_folds: Optional[int] = None,
1078
+ embargo_days: Optional[int] = None,
1079
+ holdout_days: int = 0,
1080
+ method: str = "grid",
1081
+ objective: str = "sharpe_ratio",
1082
+ max_evals: int = 50,
1083
+ seed: int = 42,
1084
+ capital: float = 100_000.0,
1085
+ include_pbo: bool = False,
1086
+ include_monte_carlo: bool = False,
1087
+ parameter_sensitivity: bool = False,
1088
+ leakage_probe: bool = False,
1089
+ gross: bool = False,
1090
+ journal: bool = True,
1091
+ force: bool = False,
1092
+ ) -> Dict[str, Any]:
1093
+ """Validate draft Strategy source, then run normal walk-forward gates.
1094
+
1095
+ This is the bridge from "agent can propose or modify strategy code" to
1096
+ "QuantRed can validate it" without putting proprietary code in this repo.
1097
+ The source is compiled in-memory through the same sandbox as
1098
+ `validate_draft_strategy_code`, never registered globally, and never made
1099
+ live. Results are regular walk-forward results with a `draft` block carrying
1100
+ class name, source hash, and whether the run was journaled.
1101
+
1102
+ Source that does not validate comes back as the same `valid: false` verdict
1103
+ the two validators return, rather than as an error: nothing ran and no trial
1104
+ was spent, so a rejected draft costs nothing against the campaign's budget.
1105
+ Validating first with `validate_draft_strategy_code` is still cheaper.
1106
+
1107
+ By default this journals one validated trial under
1108
+ `draft:<ClassName>:<code_hash>` so campaign history still reflects that the
1109
+ source consumed a test. Set `journal=false` only for smoke tests that should
1110
+ not enter the campaign record (`draft.journaled` is then false). One
1111
+ journaled run counts once toward the campaign's multiple-testing total. An
1112
+ identical prior draft validation is served from the trial store, labeled
1113
+ `memoized: true` with `draft.journaled: false` (nothing new was recorded),
1114
+ unless `force=true`.
1115
+ """
1116
+ inputs = {
1117
+ "code_hash": analysis.draft_code_hash(code),
1118
+ "symbols": symbols,
1119
+ "start": start,
1120
+ "end": end,
1121
+ "class_name": class_name,
1122
+ "mode": mode,
1123
+ "n_folds": n_folds,
1124
+ "holdout_days": holdout_days,
1125
+ "method": method,
1126
+ "objective": objective,
1127
+ "max_evals": max_evals,
1128
+ "seed": seed,
1129
+ "gross": gross,
1130
+ "journal": journal,
1131
+ "force": force,
1132
+ }
1133
+ result = analysis.run_draft_walk_forward(
1134
+ _data(),
1135
+ code,
1136
+ symbols,
1137
+ _parse_date(start),
1138
+ _parse_date(end),
1139
+ class_name=class_name,
1140
+ mode=mode,
1141
+ n_folds=n_folds,
1142
+ embargo_days=embargo_days,
1143
+ holdout_days=holdout_days,
1144
+ method=method,
1145
+ objective=objective,
1146
+ max_evals=max_evals,
1147
+ seed=seed,
1148
+ capital=capital,
1149
+ include_pbo=include_pbo,
1150
+ include_monte_carlo=include_monte_carlo,
1151
+ parameter_sensitivity=parameter_sensitivity,
1152
+ leakage_probe=leakage_probe,
1153
+ gross=gross,
1154
+ journal=journal,
1155
+ force=force,
1156
+ )
1157
+ return _logged("run_draft_walk_forward", inputs, result)
1158
+
1159
+ @tool()
1160
+ def compute_alphas(
1161
+ strategy: str,
1162
+ symbols: List[str],
1163
+ as_of: str,
1164
+ source: str = "strategy",
1165
+ scanner: str = "demo_volume",
1166
+ ic: float = 0.03,
1167
+ benchmark: str = "SPY",
1168
+ neutralize: bool = False,
1169
+ lookback_days: int = 180,
1170
+ scaling: str = "case1",
1171
+ neutralize_factors: Optional[List[str]] = None,
1172
+ price_derived: Optional[bool] = None,
1173
+ timeframe: Optional[str] = None,
1174
+ ) -> Dict[str, Any]:
1175
+ """Rank `symbols` by continuous alpha (residual-return forecast) as of a date.
1176
+
1177
+ Turns each name's view into a comparable, annualized residual-return
1178
+ forecast. `scaling` picks the per-name scaling: "case1" =
1179
+ sigma*IC*z (the default; correct when per-name signal vol is constant across
1180
+ names), "case2" = IC*c_g*z (no per-name vol multiply, for signals whose vol is
1181
+ proportional to the name's vol — most price signals), or "auto" to let a
1182
+ Std_TS-vs-omega regression decide (echoed under `case`). source: "strategy"
1183
+ uses the strategy's continuous conviction; "signal" uses its BUY/SELL/HOLD as
1184
+ +1/-1/0; "scanner" uses the scanner's continuous strength. Read-only. The
1185
+ absolute scale is only as good as the assumed IC; relative ranking is not.
1186
+
1187
+ `neutralize_factors` names risk-model factors to regress out of the alpha, and
1188
+ the factors actually removed come back in `neutralized_against`. Without it that
1189
+ field is empty — which is what this surface reported for every call before the
1190
+ parameter existed, so a caller could ask for neutralization and be told, in a
1191
+ field it echoed back, that none had happened.
1192
+
1193
+ `scaling` is one of case1/case2/auto; anything else is refused. On a thin
1194
+ universe (fewer than ten scored names) neutralization and the case choice are
1195
+ both skipped - `neutralized_against` stays empty and case2 gives case1's alphas -
1196
+ and `low_confidence_reason` says which of your requests were skipped.
1197
+ """
1198
+ inputs = {
1199
+ "strategy": strategy,
1200
+ "symbols": symbols,
1201
+ "as_of": as_of,
1202
+ "source": source,
1203
+ "scanner": scanner,
1204
+ "ic": ic,
1205
+ "benchmark": benchmark,
1206
+ "neutralize": neutralize,
1207
+ "scaling": scaling,
1208
+ }
1209
+ result = analysis.compute_alphas(
1210
+ _data(),
1211
+ strategy,
1212
+ symbols,
1213
+ _parse_date(as_of),
1214
+ source=source,
1215
+ scanner=scanner,
1216
+ ic=ic,
1217
+ benchmark=benchmark,
1218
+ neutralize=neutralize,
1219
+ lookback_days=lookback_days,
1220
+ scaling=scaling,
1221
+ **{
1222
+ k: v
1223
+ for k, v in (
1224
+ ("neutralize_factors", neutralize_factors),
1225
+ ("price_derived", price_derived),
1226
+ ("timeframe", timeframe),
1227
+ )
1228
+ if v is not None
1229
+ },
1230
+ )
1231
+ return _logged("compute_alphas", inputs, result)
1232
+
1233
+ @tool()
1234
+ def combine_alphas(
1235
+ signals: List[str],
1236
+ symbols: List[str],
1237
+ as_of: str,
1238
+ benchmark: str = "SPY",
1239
+ neutralize: bool = False,
1240
+ lookback_days: int = 365,
1241
+ horizon: int = 5,
1242
+ n_points: int = 12,
1243
+ ) -> Dict[str, Any]:
1244
+ """Combine several strategies' signals into one alpha by IC + correlation.
1245
+
1246
+ `signals` is a list of strategy names. Measures each signal's IC and the
1247
+ signal correlation matrix over a trailing window of realized residual returns,
1248
+ shrinks the ICs by estimation confidence, and combines them with GLS weights
1249
+ (Ω⁻¹·IC) so redundant signals split a weight instead of double-counting.
1250
+ Returns the ranked combined alphas plus measured ICs, shrunk ICs, weights, and
1251
+ the correlation matrix. Read-only; measure on out-of-sample data for honesty.
1252
+ """
1253
+ inputs = {"signals": signals, "symbols": symbols, "as_of": as_of, "neutralize": neutralize}
1254
+ result = analysis.compute_combined_alphas(
1255
+ _data(),
1256
+ signals,
1257
+ symbols,
1258
+ _parse_date(as_of),
1259
+ benchmark=benchmark,
1260
+ neutralize=neutralize,
1261
+ lookback_days=lookback_days,
1262
+ horizon=horizon,
1263
+ n_points=n_points,
1264
+ )
1265
+ return _logged("combine_alphas", inputs, result)
1266
+
1267
+ @tool()
1268
+ def compute_horizon(
1269
+ strategy: str,
1270
+ symbols: List[str],
1271
+ start: str,
1272
+ end: str,
1273
+ source: str = "strategy",
1274
+ benchmark: str = "SPY",
1275
+ max_lag: int = 10,
1276
+ ) -> Dict[str, Any]:
1277
+ """Measure an alpha's decay/half-life and recommend cadence + lagged blend.
1278
+
1279
+ Measures the IC-vs-lag profile (alpha at t vs residual return n periods later),
1280
+ fits the per-period decay δ and half-life, derives the rebalance cadence that
1281
+ maximizes IC·√(1/Δt), and computes the IR-maximizing current/lagged blend from
1282
+ δ and the signal autocorrelation (diversify if δ>ρ, hedge if δ<ρ). The
1283
+ half-life is the holding period to amortize cost over. Read-only.
1284
+
1285
+ The report carries no recommendation to switch trading policy: the policy that
1286
+ would replace the lagged blend is evidence-gated and not offered on this surface.
1287
+ """
1288
+ inputs = {"strategy": strategy, "symbols": symbols, "start": start, "end": end}
1289
+ result = analysis.compute_horizon(
1290
+ _data(),
1291
+ strategy,
1292
+ symbols,
1293
+ _parse_date(start),
1294
+ _parse_date(end),
1295
+ source=source,
1296
+ benchmark=benchmark,
1297
+ max_lag=max_lag,
1298
+ )
1299
+ # The CLI's report points at its `--policy aim` flag, which this server does not
1300
+ # expose (evidence-gated) and which is no tuning suggestion for an agent to act
1301
+ # on, so the pointer is left off this payload rather than reworded.
1302
+ result = {k: v for k, v in result.items() if k != "blend_superseded_by"}
1303
+ return _logged("compute_horizon", inputs, result)
1304
+
1305
+ @tool(notes=[_GATED_NOTE])
1306
+ def compute_risk(
1307
+ symbols: List[str],
1308
+ as_of: str,
1309
+ model: str = "shrinkage",
1310
+ timeframe: str = "1Day",
1311
+ lookback_days: int = 365,
1312
+ ) -> Dict[str, Any]:
1313
+ """Estimate the universe's covariance Σ and summarize its risk structure.
1314
+
1315
+ Returns the shrinkage intensity δ, condition number, mean correlation, the
1316
+ equal-weight portfolio volatility, and the top risk contributors as of a date.
1317
+ model: "shrinkage" = Ledoit–Wolf (default), "sample" = raw, "factor" =
1318
+ structural XFXᵀ+Δ (adds the factor-vs-specific risk split). Read-only: Σ sizes
1319
+ conviction, it never places an order. Risk is not additive — correlated names
1320
+ are one bet, which is what this matrix captures.
1321
+ """
1322
+ inputs = {
1323
+ "symbols": symbols,
1324
+ "as_of": as_of,
1325
+ "model": model,
1326
+ "timeframe": timeframe,
1327
+ "lookback_days": lookback_days,
1328
+ }
1329
+ result = analysis.compute_risk(
1330
+ _data(),
1331
+ symbols,
1332
+ _parse_date(as_of),
1333
+ model=model,
1334
+ timeframe=timeframe,
1335
+ lookback_days=lookback_days,
1336
+ )
1337
+ return _logged("compute_risk", inputs, result)
1338
+
1339
+ @tool("information_ratio", "turnover", notes=[_GATED_NOTE])
1340
+ def construct_portfolio(
1341
+ strategy: str,
1342
+ symbols: List[str],
1343
+ as_of: str,
1344
+ source: str = "strategy",
1345
+ target_te: float = 0.04,
1346
+ max_weight: float = 0.25,
1347
+ max_names: Optional[int] = None,
1348
+ min_weight: Optional[float] = None,
1349
+ benchmark: str = "SPY",
1350
+ capital: Optional[float] = None,
1351
+ scanner: Optional[str] = None,
1352
+ neutralize: Optional[bool] = None,
1353
+ neutralize_factors: Optional[List[str]] = None,
1354
+ book: Optional[str] = None,
1355
+ gross_leverage: Optional[float] = None,
1356
+ short_max_weight: Optional[float] = None,
1357
+ benchmark_holdings: Optional[Dict[str, float]] = None,
1358
+ benchmark_premium: Optional[float] = None,
1359
+ current_weights: Optional[Dict[str, float]] = None,
1360
+ holding_period_years: Optional[float] = None,
1361
+ lookback_days: Optional[int] = None,
1362
+ timeframe: Optional[str] = None,
1363
+ risk_model: Optional[str] = None,
1364
+ cost_aware: Optional[bool] = None,
1365
+ commission_bps: Optional[float] = None,
1366
+ impact_eta: Optional[float] = None,
1367
+ participation_cap: Optional[float] = None,
1368
+ borrow_bps: Optional[float] = None,
1369
+ ) -> Dict[str, Any]:
1370
+ """Construct the mean-variance optimal portfolio from alphas and Σ.
1371
+
1372
+ Maximizes αᵀw − λ·wᵀΣw over long-only, box-bounded, budgeted (optionally
1373
+ cardinality-capped) weights, calibrating λ to `target_te`. Cost-aware by
1374
+ default: the objective carries name-specific turnover and square-root impact,
1375
+ so a no-trade band emerges from the cost itself rather than being imposed.
1376
+
1377
+ Returns the proposed weights, the active weights and factor exposures of the
1378
+ resulting book, and the Fundamental-Law report: IR* (the best achievable
1379
+ information ratio), predicted tracking error and IR, the transfer coefficient
1380
+ (how much of IR* survives the constraints), turnover, expected active return
1381
+ gross and net of cost, and the capacity at which impact erases the alpha.
1382
+
1383
+ A PROPOSAL, never an order. This is research-clock only: it cannot trade, and
1384
+ a human promotes any config that comes out of it.
1385
+
1386
+ `book="market_neutral"` builds a long/short book and REQUIRES `gross_leverage` (the
1387
+ cap is never defaulted); `short_max_weight` sizes the short side.
1388
+ `gross_leverage` with the default long-only book is refused rather than ignored; `benchmark_holdings` (plus `benchmark_premium`) makes the solve
1389
+ benchmark-relative; `neutralize_factors` regresses named risk-model factors out
1390
+ of the alpha first; `current_weights` makes turnover measured from a real book
1391
+ rather than from cash. The cost knobs set the assumptions the objective prices
1392
+ turnover with.
1393
+
1394
+ Scope note, so you do not assume more than this exposes: the evidence-gated
1395
+ features — conditional risk, the Black-Litterman posterior, and the multi-period
1396
+ aim policy — are **not** arguments here and will not become so while their own
1397
+ adoption gates do not clear. That is deliberate, not an omission: being reachable
1398
+ from an agent is not the same as being validated, and this surface must not be
1399
+ the easier way to switch on a feature the command line warns about.
1400
+ """
1401
+ # Only what the caller actually set is forwarded, so an omitted knob keeps the
1402
+ # service's own default rather than this surface restating it — a second copy of
1403
+ # a default is a second thing to keep in step.
1404
+ optional = {
1405
+ "min_weight": min_weight,
1406
+ "scanner": scanner,
1407
+ "neutralize": neutralize,
1408
+ "neutralize_factors": neutralize_factors,
1409
+ "book": book,
1410
+ "gross_leverage": gross_leverage,
1411
+ "short_max_weight": short_max_weight,
1412
+ "benchmark_holdings": benchmark_holdings,
1413
+ "benchmark_premium": benchmark_premium,
1414
+ "current_weights": current_weights,
1415
+ "holding_period_years": holding_period_years,
1416
+ "lookback_days": lookback_days,
1417
+ "timeframe": timeframe,
1418
+ "risk_model": risk_model,
1419
+ "cost_aware": cost_aware,
1420
+ "commission_bps": commission_bps,
1421
+ "impact_eta": impact_eta,
1422
+ "participation_cap": participation_cap,
1423
+ "borrow_bps": borrow_bps,
1424
+ }
1425
+ # Every knob the caller set, not just the four this used to record. The audit
1426
+ # log exists so a human can replay what an agent did, and the tool grew from
1427
+ # nine parameters to twenty-seven while the record stayed at four — a
1428
+ # market-neutral, leveraged proposal audited identically to a default one.
1429
+ inputs = {
1430
+ "strategy": strategy,
1431
+ "symbols": symbols,
1432
+ "as_of": as_of,
1433
+ "source": source,
1434
+ "target_te": target_te,
1435
+ "max_weight": max_weight,
1436
+ "max_names": max_names,
1437
+ "benchmark": benchmark,
1438
+ "capital": capital,
1439
+ **{k: v for k, v in optional.items() if v is not None},
1440
+ }
1441
+ result = analysis.construct_portfolio(
1442
+ _data(),
1443
+ strategy,
1444
+ symbols,
1445
+ _parse_date(as_of),
1446
+ source=source,
1447
+ target_te=target_te,
1448
+ max_weight=max_weight,
1449
+ max_names=max_names,
1450
+ benchmark=benchmark,
1451
+ capital=capital,
1452
+ **{k: v for k, v in optional.items() if v is not None},
1453
+ )
1454
+ return _logged("construct_portfolio", inputs, result)
1455
+
1456
+ @tool("information_ratio", "deflated_sharpe_ratio")
1457
+ def compute_information(
1458
+ strategy: str,
1459
+ symbols: List[str],
1460
+ start: str,
1461
+ end: str,
1462
+ source: str = "strategy",
1463
+ benchmark: str = "SPY",
1464
+ horizon: int = 5,
1465
+ n_trials: int = 1,
1466
+ ) -> Dict[str, Any]:
1467
+ """Measure a strategy's IC, breadth, and predicted-vs-realized IR.
1468
+
1469
+ Pairs the alpha known at each rebalance with the subsequent realized residual
1470
+ return (strict forward alignment) to measure the information coefficient
1471
+ (Pearson + rank) and its t-stat, the effective breadth (deflated by ρ̄), and the
1472
+ predicted vs realized information ratio — with guardrails (IR standard-error
1473
+ band, multiple-testing inflation for `n_trials`, sanity ceiling). An IC t-stat
1474
+ below 2 means the skill is not distinguishable from luck. Read-only.
1475
+ """
1476
+ inputs = {"strategy": strategy, "symbols": symbols, "start": start, "end": end, "n_trials": n_trials}
1477
+ result = analysis.compute_information(
1478
+ _data(),
1479
+ strategy,
1480
+ symbols,
1481
+ _parse_date(start),
1482
+ _parse_date(end),
1483
+ source=source,
1484
+ benchmark=benchmark,
1485
+ horizon=horizon,
1486
+ n_trials=n_trials,
1487
+ )
1488
+ return _logged("compute_information", inputs, result)
1489
+
1490
+ @tool("information_ratio")
1491
+ def compute_attribution(
1492
+ strategy: str,
1493
+ symbols: List[str],
1494
+ start: str,
1495
+ end: str,
1496
+ source: str = "strategy",
1497
+ scanner: str = "demo_volume",
1498
+ benchmark: str = "SPY",
1499
+ neutralize_factors: Optional[List[str]] = None,
1500
+ signals: Optional[List[str]] = None,
1501
+ horizon: int = 5,
1502
+ n_points: int = 24,
1503
+ n_trials: int = 1,
1504
+ timeframe: Optional[str] = None,
1505
+ benchmark_holdings: Optional[Dict[str, float]] = None,
1506
+ benchmark_premium: Optional[float] = None,
1507
+ detail: Optional[bool] = None,
1508
+ min_obs: Optional[int] = None,
1509
+ risk_model: Optional[str] = None,
1510
+ bootstrap_skill: Optional[bool] = None,
1511
+ bootstrap_b: Optional[int] = None,
1512
+ bootstrap_block_length: Optional[float] = None,
1513
+ bootstrap_seed: Optional[int] = None,
1514
+ ) -> Dict[str, Any]:
1515
+ """Attribute realized active return to timing, risk factors, signals and picking.
1516
+
1517
+ Splits each sampled rebalance's active return by an exact regression identity
1518
+ into the systematic benchmark-timing bucket, each risk factor
1519
+ (market/momentum/volatility/size), the strategy's own alpha as a signal column
1520
+ (plus any additional `signals`), and a stock-picking remainder — then confronts
1521
+ every attributed t-stat with the same research-integrity guardrails
1522
+ `compute_information` applies to ICs, so a bucket that looks significant on a
1523
+ short sample is not read as skill.
1524
+
1525
+ Answers the question `compute_information` cannot: not "does the signal rank
1526
+ names" but "where did the realized return actually come from". A book whose
1527
+ return is mostly the factor bucket is a factor bet wearing a strategy's name.
1528
+
1529
+ Read-only, research-clock, leakage-safe by construction: each cross-section is
1530
+ rebuilt from bars strictly at or before its own instant. Journals nothing.
1531
+
1532
+ `n_trials` deflates for the search that produced this candidate — pass the
1533
+ number of configurations tried, not 1, or the t-stats flatter themselves.
1534
+ """
1535
+ optional = {
1536
+ "neutralize_factors": neutralize_factors,
1537
+ "signals": signals,
1538
+ "timeframe": timeframe,
1539
+ "benchmark_holdings": benchmark_holdings,
1540
+ "benchmark_premium": benchmark_premium,
1541
+ "detail": detail,
1542
+ "min_obs": min_obs,
1543
+ "risk_model": risk_model,
1544
+ "bootstrap_skill": bootstrap_skill,
1545
+ "bootstrap_b": bootstrap_b,
1546
+ "bootstrap_block_length": bootstrap_block_length,
1547
+ "bootstrap_seed": bootstrap_seed,
1548
+ }
1549
+ inputs = {
1550
+ "strategy": strategy,
1551
+ "symbols": symbols,
1552
+ "start": start,
1553
+ "end": end,
1554
+ "source": source,
1555
+ "scanner": scanner,
1556
+ "benchmark": benchmark,
1557
+ "horizon": horizon,
1558
+ "n_points": n_points,
1559
+ "n_trials": n_trials,
1560
+ **{k: v for k, v in optional.items() if v is not None},
1561
+ }
1562
+ result = analysis.compute_attribution(
1563
+ _data(),
1564
+ strategy,
1565
+ symbols,
1566
+ _parse_date(start),
1567
+ _parse_date(end),
1568
+ source=source,
1569
+ scanner=scanner,
1570
+ benchmark=benchmark,
1571
+ horizon=horizon,
1572
+ n_points=n_points,
1573
+ n_trials=n_trials,
1574
+ **{k: v for k, v in optional.items() if v is not None},
1575
+ )
1576
+ return _logged("compute_attribution", inputs, result)
1577
+
1578
+ # Not the shared journaling note: "one trial per evaluated config" is wrong here,
1579
+ # where an incomplete run can journal none. The docstring states the whole rule.
1580
+ @tool("deflated_sharpe_ratio", "information_ratio", notes=[_GATED_NOTE])
1581
+ def run_verdict(
1582
+ strategy: str,
1583
+ symbols: List[str],
1584
+ start: str,
1585
+ end: str,
1586
+ scanner: str = "demo_volume",
1587
+ signals: Optional[List[str]] = None,
1588
+ source: str = "strategy",
1589
+ benchmark: str = "SPY",
1590
+ timeframe: str = "1Day",
1591
+ capital: Optional[float] = 100_000.0,
1592
+ horizon: int = 5,
1593
+ target_te: float = 0.04,
1594
+ risk_model: str = "shrinkage",
1595
+ gross: bool = False,
1596
+ commission_bps: float = 1.0,
1597
+ impact_eta: float = 0.3,
1598
+ borrow_bps: float = 50.0,
1599
+ force: bool = False,
1600
+ max_weight: Optional[float] = None,
1601
+ max_names: Optional[int] = None,
1602
+ lookback_days: Optional[int] = None,
1603
+ neutralize_factors: Optional[List[str]] = None,
1604
+ participation_cap: Optional[float] = None,
1605
+ n_points: Optional[int] = None,
1606
+ ) -> Dict[str, Any]:
1607
+ """Run the whole cross-sectional pipeline once and return one composite answer.
1608
+
1609
+ Scan the universe, refine the signal into alphas (combining several when
1610
+ `signals` names more than one), construct the cost-aware portfolio, and measure
1611
+ the information content - all against ONE resolved universe, ONE window, and
1612
+ ONE cost model. Running the four steps as separate tool calls gives no such
1613
+ guarantee: each re-resolves its own universe and applies its own defaults, and
1614
+ the joined-up story can silently be four different stories. Prefer this when
1615
+ you want the answer rather than one stage's detail.
1616
+
1617
+ Returns `{schema, inputs, steps, scan, alphas, combination, portfolio,
1618
+ information, verdict, provenance}`. `verdict.verdict` is one of promotable /
1619
+ not promotable / needs more data / mixed / infeasible / incomplete, assembled from the gates
1620
+ the steps themselves computed (IC t-stat vs 2, realized IR vs its own standard
1621
+ error, the sanity ceiling, expected active return net of cost) - every check is
1622
+ in `verdict.checks` with its value and threshold. A run where any step failed
1623
+ is `incomplete` and carries NO verdict, whatever the completed sections say:
1624
+ do not act on a partial run's weights.
1625
+
1626
+ `infeasible` means the caps cannot fund a fully invested long-only book from
1627
+ this universe (for example three names under the default `max_weight`): the
1628
+ binding constraint is in `verdict.summary` and in `portfolio.binding_constraint`,
1629
+ the later steps are reported as skipped, and more dates will not help. Nothing
1630
+ is relaxed for you. Recover by passing a wider `symbols` list, a larger
1631
+ `max_weight`, or a larger `max_names` explicitly, and read the result's
1632
+ `inputs` to confirm which caps were applied. An unknown `risk_model` is refused
1633
+ up front, listing the valid models. A history too short to estimate a
1634
+ book at all names no constraint and is `needs more data`, not `infeasible`.
1635
+
1636
+ The allocation knobs (`max_weight`, `max_names`, `lookback_days`,
1637
+ `neutralize_factors`, `participation_cap`, `n_points`) are the CLI's own; leave
1638
+ one out and the service's default applies; the result's `inputs` block reports
1639
+ the value each one took. The evidence-gated construction knobs are deliberately not offered here.
1640
+
1641
+ This answers "what does the pipeline say about this universe as of `end`" -
1642
+ a forecast and a proposed book. The scanner is resolved at `end`, not at
1643
+ wall-clock now, so a historical verdict does not mix today's universe into an
1644
+ older evaluation window. For "did this ever work", use `run_backtest` or
1645
+ `run_walk_forward`.
1646
+
1647
+ Read-only research clock: proposes a portfolio, never places an order. Journals
1648
+ exactly ONE trial (kind `verdict`), so it counts once toward the campaign's
1649
+ multiple-testing total - except that an `incomplete` run in which no step measured an
1650
+ IC journals none, and so does an `infeasible` run refused by the allocation preflight
1651
+ (before any step read the data): `journaled: false`, with
1652
+ `not_journaled_reason`.
1653
+ An `infeasible` run found only after the alphas read the data does journal, since
1654
+ it was a look at the data. A non-journaled run also reports `provenance.n_trials`
1655
+ without itself (`null` when the store could not say). Candidates with no bars are
1656
+ dropped by the alphas step, after the preflight counted them; they are listed in
1657
+ `dropped_symbols` (empty when none). A step that failed for lack of cached bars says so, and `cache_miss` holds
1658
+ one warm command covering every such step. An identical prior run is returned
1659
+ from the trial store labeled `memoized: true`, with the original run's timestamp,
1660
+ rather than re-run; pass `force=true` to re-verify and append a new trial.
1661
+ """
1662
+ # Only what the caller set is forwarded, so an omitted knob keeps the service's
1663
+ # own default instead of this surface restating it.
1664
+ optional = {
1665
+ "max_weight": max_weight,
1666
+ "max_names": max_names,
1667
+ "lookback_days": lookback_days,
1668
+ "neutralize_factors": neutralize_factors,
1669
+ "participation_cap": participation_cap,
1670
+ "n_points": n_points,
1671
+ }
1672
+ optional = {k: v for k, v in optional.items() if v is not None}
1673
+ inputs = {"strategy": strategy, "symbols": symbols, "start": start, "end": end, **optional}
1674
+ result = analysis.run_verdict(
1675
+ _data(),
1676
+ strategy,
1677
+ symbols,
1678
+ _parse_date(start),
1679
+ _parse_date(end),
1680
+ scanner=scanner,
1681
+ signals=signals,
1682
+ source=source,
1683
+ benchmark=benchmark,
1684
+ timeframe=timeframe,
1685
+ capital=capital,
1686
+ horizon=horizon,
1687
+ target_te=target_te,
1688
+ risk_model=risk_model,
1689
+ gross=gross,
1690
+ commission_bps=commission_bps,
1691
+ impact_eta=impact_eta,
1692
+ borrow_bps=borrow_bps,
1693
+ force=force,
1694
+ **optional,
1695
+ )
1696
+ return _logged("run_verdict", inputs, result)
1697
+
1698
+ @tool()
1699
+ def render_report(kind: str, result: Dict[str, Any]) -> Dict[str, Any]:
1700
+ """Render a result you already have as one self-contained HTML document.
1701
+
1702
+ `kind` is one of verdict/backtest/walkforward/info, and `result` is the dict
1703
+ that kind's run tool returned, **unmodified** — pass it through rather than
1704
+ rebuilding it, or the report will describe something the run did not produce.
1705
+ A payload that does not match its kind is rejected with a clear error rather
1706
+ than half-rendered.
1707
+
1708
+ Returns `{kind, html, bytes}`. The document embeds its CSS and charts, so
1709
+ opening it issues no network requests at all — it is safe to save, attach, or
1710
+ forward. Provenance (window, universe, cost model, git SHA, campaign
1711
+ n_trials) is a mandatory header, and memoized results, gate failures, a backtest
1712
+ that is not executable at its capital, a failed leakage probe, and any enabled
1713
+ evidence-gated feature render as warnings above the sections rather than as
1714
+ footnotes.
1715
+
1716
+ This computes nothing: it is the same renderer the CLI's `--html` flag uses,
1717
+ over the same object. Charts need the plotting extra on the server; without it
1718
+ the tables still render and the chart slots say so.
1719
+
1720
+ Note that a backtest result carries no equity curve (it is omitted from the
1721
+ payload deliberately), so a backtest report rendered here shows the metrics
1722
+ table without the equity chart.
1723
+ """
1724
+ from quantred.analytics.htmlreport import render_html
1725
+
1726
+ document = render_html(result, kind)
1727
+ return _logged(
1728
+ "render_report",
1729
+ {"kind": kind, "run_id": result.get("run_id") if isinstance(result, dict) else None},
1730
+ {"kind": kind, "html": document, "bytes": len(document.encode("utf-8"))},
1731
+ )
1732
+
1733
+ # ---------------- Campaign memory (read-only) ---------------- #
1734
+ @tool("sharpe_ratio", "deflated_sharpe_ratio")
1735
+ def list_trials(
1736
+ strategy: Optional[str] = None,
1737
+ kind: Optional[str] = None,
1738
+ symbols: Optional[List[str]] = None,
1739
+ since: Optional[str] = None,
1740
+ until: Optional[str] = None,
1741
+ min_sharpe: Optional[float] = None,
1742
+ gates_passed: Optional[bool] = None,
1743
+ sort: str = "date",
1744
+ limit: int = 20,
1745
+ offset: int = 0,
1746
+ include_in_sample: bool = False,
1747
+ all_accounting: bool = False,
1748
+ ) -> Dict[str, Any]:
1749
+ """What has this campaign already tried? Filtered rows from the trial store.
1750
+
1751
+ Ask this BEFORE running anything expensive. Every backtest, optimization,
1752
+ walk-forward, and verdict ever run — over the CLI or over this server — is
1753
+ recorded here, and re-running something the campaign already answered costs a
1754
+ trial without buying information.
1755
+
1756
+ `symbols` matches on the normalized universe, so order and case never change
1757
+ what is found. `since`/`until` take a date (a New York market date) or an ISO
1758
+ datetime; both ends include the dates they name, so `until="2026-11-06"` includes
1759
+ that evening. Each row's `ts` is the UTC instant it was recorded. `sort` is
1760
+ date/sharpe/dsr; a trial with no recorded metric
1761
+ sorts last rather than as a zero. Returns `{rows, total, in_sample_excluded, in_sample_included, undated_excluded}` —
1762
+ `total` is how many matched, which is usually more than were returned.
1763
+
1764
+ **Ranking leaves in-sample kinds out.** Sorting by `sharpe`/`dsr`, or setting
1765
+ `min_sharpe`, makes this a leaderboard, and `optimize` (best of N candidates),
1766
+ `backtest` (one pass over its window) and `alpha` rows are not validated out of
1767
+ sample, so they are excluded unless `include_in_sample=true` — as `best_trials`
1768
+ does. `in_sample_excluded` says how many that hid. Every row carries
1769
+ `sharpe_basis` (what its Sharpe measures: out-of-sample aggregate, median fold,
1770
+ full window, or best of N) and `in_sample`; read them before comparing two Sharpes.
1771
+
1772
+ Absent fields are null, and null means *not recorded*: a trial predating a
1773
+ field did not fail to record it, and a forecast kind did not score zero.
1774
+ An unknown `kind` is refused with the valid ones rather than listing nothing.
1775
+ Read-only: this cannot modify or delete anything.
1776
+ """
1777
+ inputs = {"strategy": strategy, "kind": kind, "symbols": symbols, "limit": limit}
1778
+ with _trial_store() as store:
1779
+ if store is None:
1780
+ return _logged("list_trials", inputs, {"rows": [], "total": 0, "error": _NO_STORE})
1781
+ filters = {
1782
+ "strategy": strategy,
1783
+ "kind": kind,
1784
+ "symbols": symbols,
1785
+ "since": _parse_date(since) if since else None,
1786
+ "until": _parse_date(until) if until else None,
1787
+ "min_sharpe": min_sharpe,
1788
+ "promotable": gates_passed,
1789
+ "all_accounting": all_accounting,
1790
+ }
1791
+ listing = store.listing(
1792
+ sort=sort, limit=limit, offset=offset, include_in_sample=include_in_sample, **filters
1793
+ )
1794
+ listing.pop("exclude_kinds", None)
1795
+ return _logged("list_trials", inputs, listing)
1796
+
1797
+ @tool()
1798
+ def get_trial(trial_id: str) -> Dict[str, Any]:
1799
+ """Everything the trial store knows about one trial.
1800
+
1801
+ Its full params (the run's dedup identity, including the folded cost and
1802
+ data-vintage keys), provenance, headline metrics, and what was stored
1803
+ alongside it: whether its out-of-sample return series was persisted (and over
1804
+ what window), the book it proposed, its trade table if the run opted into
1805
+ keeping one, and which later trials were served from this one.
1806
+
1807
+ `null` for a companion means *not recorded* — the run predates that storage,
1808
+ or did not opt in. It never means the run produced nothing. A walk-forward also
1809
+ carries `holdout_record`: `state` is `recorded` (with `metrics`), `none_requested`,
1810
+ `not_retained` (asked for, but the trial predates the journal keeping the result)
1811
+ or `unknown`. Read-only.
1812
+ """
1813
+ with _trial_store() as store:
1814
+ if store is None:
1815
+ return _logged("get_trial", {"trial_id": trial_id}, {"error": _NO_STORE})
1816
+ from quantred.store.trials import AmbiguousTrialId
1817
+
1818
+ try:
1819
+ resolved = store.resolve_id(trial_id)
1820
+ except AmbiguousTrialId as exc:
1821
+ raise ValueError(f"{exc} {_USE_LIST_TRIALS}") from exc
1822
+ trial = store.get_trial(resolved) if resolved else None
1823
+ if trial is None:
1824
+ raise ValueError(f"No trial with id {trial_id!r}. {_USE_LIST_TRIALS}")
1825
+ if trial.get("kind") == "walkforward":
1826
+ # The index never kept the holdout; the journal line is the only place it
1827
+ # is, and `state` says whether it is there, was never asked for, or
1828
+ # predates the journal keeping it.
1829
+ from quantred.services.audit import holdout_for_trial
1830
+
1831
+ trial["holdout_record"] = holdout_for_trial(trial["id"])
1832
+ return _logged("get_trial", {"trial_id": trial_id}, trial)
1833
+
1834
+ @tool()
1835
+ def analyze_trial(trial_id: str, allow_partial: bool = False) -> Dict[str, Any]:
1836
+ """What one recorded run's trades actually did: exit-reason P&L, win and loss
1837
+ by reason, holding period, and per-trade excursion.
1838
+
1839
+ Read `status` before any number. `complete` means the figures cover every trade
1840
+ the run made. `unavailable` means there are none and `reason` says why — most
1841
+ often that the run did not record trades (`record_trades=true` on `run_backtest` or
1842
+ `run_walk_forward`; no other tool takes it), which is NOT the same as a run that made
1843
+ no trades. Verdict, optimization and draft walk-forward trials never carry a trade
1844
+ table; `reason` says which tool to run instead for the trial's own kind. `truncated`
1845
+ appears only with `allow_partial=true`,
1846
+ and then every count and total is a partial and must be reported as one.
1847
+
1848
+ A trade table capped at the storage ceiling is refused by default rather than
1849
+ summed: a total over a prefix of a run's trades is not a smaller number than
1850
+ the truth, it is a wrong one. So is a table whose completeness was never
1851
+ recorded.
1852
+
1853
+ The excursion figures are **per trade** — how far one position went against its
1854
+ own entry — and are not the book's aggregate open drawdown. A position deep
1855
+ underwater that is a small fraction of the book did not put the book that far
1856
+ underwater. Journals nothing; read-only.
1857
+ """
1858
+ inputs = {"trial_id": trial_id, "allow_partial": allow_partial}
1859
+ with _trial_store() as store:
1860
+ if store is None:
1861
+ return _logged("analyze_trial", inputs, {"error": _NO_STORE})
1862
+ report = analysis.analyze_trial(store, trial_id, allow_partial=allow_partial)
1863
+ if report.get("error"):
1864
+ raise ValueError(f"{report['error']} {_USE_LIST_TRIALS}")
1865
+ return _logged("analyze_trial", inputs, _trade_recovery_note(report))
1866
+
1867
+ @tool()
1868
+ def compare_trials(
1869
+ trial_ids: List[str],
1870
+ min_overlap: int = SERIES_MIN_OVERLAP,
1871
+ across_accounting: bool = False,
1872
+ ) -> Dict[str, Any]:
1873
+ """Correlate the recorded return series of two or more trials — are they in
1874
+ fact one result?
1875
+
1876
+ Ask this before reporting two candidates as separate evidence. Results
1877
+ correlating near 1.0 are one bet held twice however differently they were
1878
+ parameterised, and counting both inflates what a campaign appears to have
1879
+ found.
1880
+
1881
+ Pairs are **refused**, not caveated, when they share fewer than `min_overlap`
1882
+ dates or were recorded under different accounting versions — a correlation over
1883
+ a handful of dates is an error bar, and two engines that compute different
1884
+ things produce series whose correlation is partly about the engines.
1885
+ `across_accounting=true` computes those anyway and marks them
1886
+ `comparable: false`.
1887
+
1888
+ Read `pairs` alongside `matrix`. The matrix holds `null` where nothing was
1889
+ computed, because in a correlation matrix a zero is the strong claim that two
1890
+ results move independently — exactly what a refusal cannot say. Every
1891
+ correlation carries a Fisher-z `interval`; quote it, since one resting on a
1892
+ short overlap is wide and a bare coefficient hides that.
1893
+
1894
+ Only trials that store a dated return series can be compared: walk-forwards (the
1895
+ stitched out-of-sample folds, drafts included) and backtests (daily returns from
1896
+ realized trade P&L over the window, in-sample). Optimizations, verdicts and other
1897
+ kinds store none, and neither do backtests recorded over MCP before this server
1898
+ began storing one; those are reported as having no series. Each series carries its
1899
+ trial `kind`, and `kinds_differ` / a pair's `unlike_basis` flag a comparison of
1900
+ unlike things. Journals nothing; read-only.
1901
+ """
1902
+ # `across_accounting` decides whether cross-era pairs were computed or refused,
1903
+ # so an audit trail without it cannot tell a default comparison from one that
1904
+ # deliberately crossed an accounting boundary.
1905
+ inputs = {
1906
+ "trial_ids": trial_ids,
1907
+ "min_overlap": min_overlap,
1908
+ "across_accounting": across_accounting,
1909
+ }
1910
+ with _trial_store() as store:
1911
+ if store is None:
1912
+ return _logged("compare_trials", inputs, {"error": _NO_STORE})
1913
+ report = analysis.compare_trials(
1914
+ store,
1915
+ trial_ids,
1916
+ min_overlap=min_overlap,
1917
+ across_accounting=across_accounting,
1918
+ )
1919
+ if report.get("error"):
1920
+ raise ValueError(f"{report['error'].rstrip('.')}. {_USE_LIST_TRIALS}")
1921
+ unknown = report.get("unknown_trial_ids") or []
1922
+ if len(trial_ids) - len(unknown) < 2:
1923
+ raise ValueError(
1924
+ f"Fewer than two of these trials are recorded (unknown: {', '.join(map(repr, unknown))}). "
1925
+ f"{_USE_LIST_TRIALS}"
1926
+ )
1927
+ return _logged("compare_trials", inputs, report)
1928
+
1929
+ @tool()
1930
+ def get_campaign_material(trial_id: str) -> Dict[str, Any]:
1931
+ """What validated a trial: its recipe, its evidence, and what is recoverable.
1932
+
1933
+ Read this before proposing a config from a trial, and pass it through as
1934
+ `provenance.campaign` when you call `save_config` — that is the same block
1935
+ `trials promote` writes, and a second shape invented here would be a second
1936
+ provenance format, which is the thing this project has been bitten by more
1937
+ than once.
1938
+
1939
+ Three sections, labelled because they age differently. `recipe` is how it was
1940
+ validated — the folds, the embargo, the objective, the search method, and the
1941
+ cost model and book limits folded into its identity. It stays true. `evidence`
1942
+ is what was measured under one accounting version and is worthless outside it;
1943
+ check `comparable_with_current_engine` before quoting a single number from it.
1944
+ `metadata` is about the record: when, which git sha, and which stored artifacts
1945
+ still exist.
1946
+
1947
+ A section that could not be assembled says so with a reason rather than coming
1948
+ back empty. Most trial kinds have no separate recipe — a backtest's identity
1949
+ *is* its parameters — and the recipe section says that instead of implying the
1950
+ run was validated with no settings. Journals nothing; read-only.
1951
+ """
1952
+ with _trial_store() as store:
1953
+ if store is None:
1954
+ return _logged("get_campaign_material", {"trial_id": trial_id}, {"error": _NO_STORE})
1955
+ material = campaign.campaign_material(store, trial_id)
1956
+ if material.get("not_indexed"):
1957
+ # The service's reason names the CLI's reindex command, which an agent
1958
+ # cannot run; say what it can do instead.
1959
+ raise ValueError(
1960
+ f"No trial with id {trial_id!r} is indexed. Its journal line may still exist, but "
1961
+ "reindexing the store from the journal is an operator action this server does not "
1962
+ f"offer. {_USE_LIST_TRIALS}"
1963
+ )
1964
+ if not material.get("available"):
1965
+ raise ValueError(f"{material.get('reason') or 'No such trial.'} {_USE_LIST_TRIALS}")
1966
+ return _logged("get_campaign_material", {"trial_id": trial_id}, material)
1967
+
1968
+ @tool("deflated_sharpe_ratio", "sharpe_ratio")
1969
+ def best_trials(
1970
+ strategy: Optional[str] = None,
1971
+ kind: Optional[str] = None,
1972
+ symbols: Optional[List[str]] = None,
1973
+ rank_by: str = "dsr",
1974
+ limit: int = 10,
1975
+ include_in_sample: bool = False,
1976
+ all_accounting: bool = False,
1977
+ ) -> Dict[str, Any]:
1978
+ """The campaign's leaderboard, ranked by DEFLATED Sharpe by default.
1979
+
1980
+ Read the caveat this returns before reporting a winner. Ranking a research
1981
+ campaign's trials and presenting the top row is selection bias by
1982
+ construction: the best of N tried configs is a biased estimate of its own
1983
+ future performance. That is why the default ranking is the deflated Sharpe
1984
+ (which already discounts for N), why every row carries its family's
1985
+ `n_trials`, and why `rank_by="sharpe"` returns a caveat saying in as many
1986
+ words that the ordering does not correct for how many configs were tried.
1987
+
1988
+ In-sample kinds (`optimize`, `alpha`, `backtest`) are excluded by default: an
1989
+ `optimize` row is the winner of a search, best-of-N by construction, so ranking
1990
+ one ranks the selection bias rather than any skill, and a plain `backtest` is one
1991
+ pass over its window, never validated out of sample (its `oos_sharpe` is the
1992
+ full-window Sharpe). `include_in_sample=true`
1993
+ opts back in, and `in_sample_excluded` reports how many were dropped.
1994
+
1995
+ A row with no value for the ranking metric (a verdict has neither a Sharpe nor a
1996
+ DSR) is not ranked and not listed; `unranked_excluded` counts them. A row's DSR
1997
+ was deflated by the trials of its own run, while `family_n_trials` is the
1998
+ strategy-and-universe count now, so DSRs from runs of different size are not
1999
+ comparable.
2000
+
2001
+ Returns `{rank_by, rows, max_family_n_trials, in_sample_included,
2002
+ in_sample_excluded, unranked_excluded, caveat}`. The caveat and the per-row family counts are
2003
+ part of the payload, not decoration — quote them when you report a result.
2004
+ Read-only.
2005
+ """
2006
+ inputs = {"strategy": strategy, "symbols": symbols, "rank_by": rank_by, "limit": limit}
2007
+ with _trial_store() as store:
2008
+ if store is None:
2009
+ return _logged("best_trials", inputs, {"rows": [], "error": _NO_STORE})
2010
+ board = store.best(
2011
+ rank_by=rank_by,
2012
+ limit=limit,
2013
+ include_in_sample=include_in_sample,
2014
+ strategy=strategy,
2015
+ kind=kind,
2016
+ symbols=symbols,
2017
+ all_accounting=all_accounting,
2018
+ )
2019
+ return _logged("best_trials", inputs, board)
2020
+
2021
+ @tool()
2022
+ def get_metrics_glossary() -> Dict[str, Any]:
2023
+ """Definitions + pitfalls of every reported metric, plus global caveats.
2024
+
2025
+ Read this before interpreting results - it spells out the Deflated Sharpe /
2026
+ multiple-testing trap and the closed-trade equity-curve caveat so you don't
2027
+ over-trust in-sample Sharpe.
2028
+ """
2029
+ return _logged("get_metrics_glossary", {}, glossary.metrics_glossary())
2030
+
2031
+ @tool()
2032
+ def summarize_bars(
2033
+ symbols: List[str], timeframe: str = "1Day", lookback_days: int = 90
2034
+ ) -> Dict[str, Any]:
2035
+ """Compact OHLCV stats per symbol (return, vol, trend, volume) - no raw bars.
2036
+
2037
+ Descriptive only. Picking symbols by these stats then backtesting them is
2038
+ look-ahead; treat universe choice as a research decision, not an input to
2039
+ optimize.
2040
+ """
2041
+ inputs = {"symbols": symbols, "timeframe": timeframe, "lookback_days": lookback_days}
2042
+ return _logged(
2043
+ "summarize_bars", inputs, analysis.summarize_bars(_data(), symbols, timeframe, lookback_days)
2044
+ )
2045
+
2046
+ # ---------------- Propose (writes a file, never live state) ---------------- #
2047
+ @tool()
2048
+ def save_config(
2049
+ name: str,
2050
+ strategy: str,
2051
+ params: Dict[str, Any],
2052
+ scanner: Optional[str] = None,
2053
+ symbols: Optional[List[str]] = None,
2054
+ capital: Optional[float] = None,
2055
+ position_limits: Optional[Dict[str, Any]] = None,
2056
+ provenance: Optional[Dict[str, Any]] = None,
2057
+ overwrite: bool = False,
2058
+ ) -> Dict[str, Any]:
2059
+ """Propose a candidate config by writing configs/<name>.json, with provenance.
2060
+
2061
+ This is the end of what automation may do. It writes a file for a HUMAN to
2062
+ read, judge, and promote; it does not affect any running process, does not
2063
+ change any default, and cannot enable live trading. Promotion is a manual
2064
+ step by design — the research clock proposes, a person disposes.
2065
+
2066
+ `name` is a plain file name (no directories, no `..`); the file is always written
2067
+ under the configs directory. A name already in use is refused unless
2068
+ `overwrite=true`: a saved config may be one a person has promoted. The config is
2069
+ checked before anything is written: the strategy and scanner must exist, `params`
2070
+ must construct the strategy (a key that is a near-miss of a declared parameter is
2071
+ refused as a typo), and `position_limits` may only name limits that are enforced.
2072
+ A config that could not run is refused, not saved.
2073
+
2074
+ Include the evidence in `provenance` (the walk-forward or verdict run behind
2075
+ the proposal, its window and universe), because a config with no recorded
2076
+ reason for existing is a config nobody can safely promote. It accepts only these
2077
+ fields: `method`, `objective`, `windows`, `oos_metrics`, `n_trials`, `seed`,
2078
+ `git_sha`, `timestamp`, `notes`, `accounting`, `campaign`; any other key is
2079
+ refused. There is no trial-id field: put the trial id in `notes`, or pass the
2080
+ `get_campaign_material` block as `campaign`, which carries it.
2081
+
2082
+ For a config proposed from a recorded trial, put `get_campaign_material`'s
2083
+ block in `provenance.campaign` verbatim rather than summarising it. It carries
2084
+ the validation recipe the parameters alone cannot express, and which accounting
2085
+ era its numbers belong to. One format, so a config written here and one written
2086
+ by `trials promote` read identically.
2087
+
2088
+ **Pass `position_limits`, and pass the book that was actually validated** —
2089
+ `recipe.folded_into_identity._limits` in that same block. Omitting it does not
2090
+ leave the book unspecified: a run from the config inherits the strategy class's
2091
+ default of one position, so a config whose provenance says eight were validated
2092
+ silently trades one. `symbols` and `capital` the same way, when you know them.
2093
+ The validated cost model is taken from that same campaign block when it records
2094
+ one, so the config trades at the costs it was validated under.
2095
+ """
2096
+ inputs = {
2097
+ "name": name,
2098
+ "strategy": strategy,
2099
+ "params": params,
2100
+ "scanner": scanner,
2101
+ "position_limits": position_limits,
2102
+ "overwrite": overwrite,
2103
+ }
2104
+ result = configs.save_config(
2105
+ name,
2106
+ strategy=strategy,
2107
+ params=params,
2108
+ scanner=scanner,
2109
+ symbols=symbols,
2110
+ capital=capital,
2111
+ position_limits=position_limits,
2112
+ provenance=provenance,
2113
+ overwrite=overwrite,
2114
+ )
2115
+ return _logged("save_config", inputs, result)
2116
+
2117
+ @tool()
2118
+ def load_config(name: str) -> Dict[str, Any]:
2119
+ """Read back one saved candidate config: its strategy, params, symbols, capital,
2120
+ position_limits, and provenance.
2121
+
2122
+ `symbols`, `capital` and `position_limits` are absent when the config never
2123
+ recorded them (absent, not zero or a default); a config with no
2124
+ `position_limits` runs at the strategy class's own book.
2125
+
2126
+ Reading a config does not activate it — nothing here is running the config,
2127
+ and loading it changes no behavior anywhere. Use it to check what a past
2128
+ proposal actually contained before re-testing or superseding it. Read-only.
2129
+ """
2130
+ try:
2131
+ loaded = configs.load_config(name)
2132
+ except FileNotFoundError:
2133
+ raise ValueError(
2134
+ f"No saved config named {name!r}. Use list_configs to see what exists."
2135
+ ) from None
2136
+ return _logged("load_config", {"name": name}, loaded)
2137
+
2138
+ @tool()
2139
+ def list_configs() -> List[Dict[str, Any]]:
2140
+ """List every saved candidate config with a compact summary of each.
2141
+
2142
+ These are proposals awaiting human review, not active settings: none of them
2143
+ is in use by anything until a person promotes it. Read-only.
2144
+ """
2145
+ return _logged("list_configs", {}, configs.list_configs())
2146
+
2147
+ _refuse_unknown_arguments(server)
2148
+ return server
2149
+
2150
+
2151
+ def _refuse_unknown_arguments(server) -> None:
2152
+ """Make an argument this surface does not accept an error rather than a silence.
2153
+
2154
+ The framework validates a call against the tool's schema and then **drops** every
2155
+ key the schema does not declare. Nothing raises, nothing is logged, and the tool
2156
+ returns a perfectly good result computed without the argument — so an agent told to
2157
+ "turn on conditional risk" passes `conditional="ewma"`, gets a portfolio back, and
2158
+ reports that it did. The wall held; its account of what it did did not.
2159
+
2160
+ That is the failure this whole surface is designed against: an agent reads a
2161
+ description as fact and acts on it, and every action costs a journaled trial. A
2162
+ dropped argument is the same class of lie as a stale description, arriving at call
2163
+ time instead of read time.
2164
+
2165
+ A plain typo has the identical shape — `targt_te` silently leaves the default in
2166
+ place — so both are refused. Naming the near-miss matters more here than for a
2167
+ human, who would see the default in the output and wonder; an agent has nothing to
2168
+ wonder at.
2169
+
2170
+ Wrapped at dispatch rather than declared per tool: `**kwargs` cannot express this,
2171
+ because the framework turns it into a *required* schema property called `extra`,
2172
+ which changes every tool's contract to fix a problem in none of them.
2173
+
2174
+ **Wrapped on the tool manager, not on the server.** The obvious place —
2175
+ reassigning ``server.call_tool`` after the tools are registered — does not work, and
2176
+ fails in the worst possible way: the framework registers its low-level protocol
2177
+ handler during ``FastMCP.__init__``, capturing the *bound method* as it was then, so
2178
+ a later reassignment is invisible to every real client while a test that calls
2179
+ ``server.call_tool`` directly still sees it. That is a guard which passes its own
2180
+ suite and protects nobody, and it is what the first version of this did.
2181
+ ``FastMCP.call_tool`` looks its manager up as ``self._tool_manager`` at call time, so
2182
+ wrapping the manager is on both paths.
2183
+ """
2184
+ manager = server._tool_manager
2185
+ original = manager.call_tool
2186
+
2187
+ async def call_tool(name, arguments, *args, **kwargs):
2188
+ declared = _declared_arguments(server, name)
2189
+ # An unknown tool, or a schema we could not read, is the framework's business:
2190
+ # pass it through rather than inventing a second way for a call to fail.
2191
+ if declared is not None and isinstance(arguments, dict):
2192
+ unknown = sorted(set(arguments) - declared)
2193
+ if unknown:
2194
+ raise ValueError(_unknown_argument_message(name, unknown, declared))
2195
+ return await original(name, arguments, *args, **kwargs)
2196
+
2197
+ manager.call_tool = call_tool
2198
+
2199
+
2200
+ def _declared_arguments(server, name: str):
2201
+ """The argument names one tool accepts, or ``None`` if that cannot be established.
2202
+
2203
+ Read from the registered tool's own schema rather than from a signature, so it is
2204
+ the same thing the client was shown. ``None`` on any doubt: refusing a call on a
2205
+ guess about what a tool accepts would be worse than the silence this replaces.
2206
+ """
2207
+ try:
2208
+ tool = server._tool_manager.get_tool(name)
2209
+ schema = getattr(tool, "parameters", None) or {}
2210
+ properties = schema.get("properties")
2211
+ except Exception: # noqa: BLE001 - never turn a lookup problem into a call failure
2212
+ return None
2213
+ return set(properties) if isinstance(properties, dict) else None
2214
+
2215
+
2216
+ def _unknown_argument_message(name: str, unknown, declared) -> str:
2217
+ """Say what was ignored, and — where it can be known — why.
2218
+
2219
+ A withheld knob and a mistyped one need different actions, so they get different
2220
+ sentences. `DEFERRED_PARAMS` already records why each gated family is absent; this
2221
+ is the moment an agent actually needs that reason, so it is quoted rather than
2222
+ referred to.
2223
+ """
2224
+ import difflib
2225
+
2226
+ lines = [f"{name} does not accept: {', '.join(unknown)}."]
2227
+ for argument in unknown:
2228
+ reason = DEFERRED_PARAMS.get(argument) or WITHHELD_PARAMS.get(name, {}).get(argument)
2229
+ if reason:
2230
+ lines.append(f" {argument}: withheld from this surface — {reason}.")
2231
+ continue
2232
+ close = difflib.get_close_matches(argument, sorted(declared), n=1)
2233
+ if close:
2234
+ lines.append(f" {argument}: not a parameter of this tool. Did you mean {close[0]}?")
2235
+ else:
2236
+ lines.append(f" {argument}: not a parameter of this tool.")
2237
+ lines.append(
2238
+ "Refused rather than ignored: an argument dropped in silence leaves you believing it was applied."
2239
+ )
2240
+ return "\n".join(lines)
2241
+
2242
+
2243
+ #: What this process has done, for the line it prints when it stops. A plain dict
2244
+ #: mutated in place: the count is read from a signal handler, which must not take a lock.
2245
+ _SESSION: Dict[str, int] = {"tool_calls": 0}
2246
+
2247
+
2248
+ def _client_gone(exc: BaseException) -> bool:
2249
+ """True when every leaf of ``exc`` is the client's pipe having closed under us.
2250
+
2251
+ A client that exits without closing stdin first surfaces as a ``BrokenPipeError``
2252
+ inside an exception group from the SDK's task group. That is a disconnect, not a
2253
+ crash, and it should not print a traceback to someone who just closed a chat.
2254
+ """
2255
+ leaves = getattr(exc, "exceptions", None)
2256
+ if leaves is not None:
2257
+ return bool(leaves) and all(_client_gone(leaf) for leaf in leaves)
2258
+ return isinstance(exc, (BrokenPipeError, ConnectionResetError))
2259
+
2260
+
2261
+ def _stop_line(why: str) -> str:
2262
+ """The one line printed when the server stops, saying what that did and did not do.
2263
+
2264
+ Not the CLI's generic interrupt text, which says no trial was recorded - true of a
2265
+ command interrupted part-way, false of a server that may have journaled trials all
2266
+ session. Every tool call here appends to the journal before it returns, so work
2267
+ already done is on disk and only a request in progress is lost.
2268
+ """
2269
+ from quantred.services.audit import default_trial_journal
2270
+
2271
+ n = _SESSION["tool_calls"]
2272
+ return (
2273
+ f"MCP server stopped ({why}). It handled {n} tool call(s) this session; trials "
2274
+ f"already journaled are kept in {default_trial_journal()}. A request in progress, "
2275
+ "if any, was not completed."
2276
+ )
2277
+
2278
+
2279
+ def stop_now(why: str, code: int) -> None:
2280
+ """Say what stopping did, then leave without interpreter finalization.
2281
+
2282
+ ``os._exit`` is deliberate, not tidy-minded: the SDK reads stdin on a worker thread
2283
+ blocked in ``read(2)`` that nothing can cancel or wake, so ``anyio.run`` never
2284
+ returns after an interrupt and, once it is forced out, finalization joins that
2285
+ thread - hanging, or aborting the interpreter if interrupted again. Leaving
2286
+ directly is the only exit that is bounded. Repeats are ignored first so a second
2287
+ Ctrl-C cannot re-enter this.
2288
+ """
2289
+ import signal
2290
+
2291
+ from quantred.utils import streaming
2292
+
2293
+ for sig in streaming.STOP_SIGNALS:
2294
+ try:
2295
+ signal.signal(sig, signal.SIG_IGN)
2296
+ except (ValueError, OSError):
2297
+ pass
2298
+ try:
2299
+ sys.stderr.write(_stop_line(why) + "\n")
2300
+ except Exception: # noqa: BLE001 - a closed stderr must not stop the exit
2301
+ pass
2302
+ streaming.exit_now(code)
2303
+
2304
+
2305
+ def _install_stop_handlers() -> None:
2306
+ """Route SIGINT/SIGTERM to :func:`stop_now` instead of the loop.
2307
+
2308
+ ``signal.signal``, not an event-loop handler: tool functions here are synchronous
2309
+ and run on the loop's own thread, so a loop callback would not fire until the call
2310
+ in progress returned. A no-op off the main thread, where signals cannot be set.
2311
+ """
2312
+ import signal
2313
+ import threading
2314
+
2315
+ if threading.current_thread() is not threading.main_thread():
2316
+ return
2317
+ from quantred.utils import streaming
2318
+
2319
+ for sig in streaming.STOP_SIGNALS:
2320
+ code = 128 + int(sig)
2321
+ name = "interrupted" if sig == signal.SIGINT else "terminated"
2322
+ signal.signal(sig, lambda _n, _f, _name=name, _code=code: stop_now(_name, _code))
2323
+
2324
+
2325
+ def serve(*, offline: bool = False, cache_dir=None) -> None:
2326
+ """Build the server and run it over stdio (what Claude Desktop/Code launch).
2327
+
2328
+ Starts without broker keys: bars are fetched lazily, so a keyless server still
2329
+ answers the catalogue and trial-history tools. ``offline`` reads only the bar cache.
2330
+ """
2331
+ # Said at startup, not on request: an agent driving these tools cannot see
2332
+ # where its evidence is landing, and every journaled trial is permanent.
2333
+ from quantred.services.setup import invocation
2334
+ from quantred.settings import git_worktree_containing, state_root
2335
+
2336
+ root = state_root()
2337
+ logger.info("MCP state root: %s", root)
2338
+ worktree = git_worktree_containing(root)
2339
+ if worktree is not None:
2340
+ logger.warning(
2341
+ "State root %s is inside the git working tree at %s - trials, configs and "
2342
+ "any private strategy's evidence are being written into a repository.",
2343
+ root,
2344
+ worktree,
2345
+ )
2346
+
2347
+ server = build_server(offline=offline, cache_dir=cache_dir)
2348
+ # After the build, not before: a missing extra stops the server there, and "waiting
2349
+ # for a client" from a process about to exit is false.
2350
+ logger.info(
2351
+ "%s MCP server (stdio) is waiting for an MCP client on stdin/stdout. Live "
2352
+ "trading is NOT exposed. Started by hand in a terminal it is attached to no chat: "
2353
+ "a client launches its own copy (check the command with `%s`). Ctrl-C stops it.",
2354
+ BRAND,
2355
+ invocation("mcp --self-test"),
2356
+ )
2357
+ _install_stop_handlers()
2358
+ try:
2359
+ server.run()
2360
+ except KeyboardInterrupt:
2361
+ # Only reachable where the handlers could not be installed (not the main thread).
2362
+ stop_now("interrupted", 130)
2363
+ except BaseException as exc: # noqa: BLE001 - classified, and re-raised unless a disconnect
2364
+ if not _client_gone(exc):
2365
+ raise
2366
+ stop_now("client disconnected", 0)
2367
+
2368
+
2369
+ def _json_safe(value: Any) -> Any:
2370
+ """``value`` with every non-finite float spelled ``"nan"``, ``"inf"`` or ``"-inf"``.
2371
+
2372
+ JSON has no NaN or infinity. Some services already spelled them as strings and the
2373
+ rest reached the wire as bare ``NaN`` / ``Infinity``, which a strict client refuses to
2374
+ parse, so one payload could carry ``"nan"`` in one field and an unparseable ``NaN``
2375
+ in the next. One convention, applied once at the boundary every tool returns through.
2376
+ """
2377
+ if isinstance(value, dict):
2378
+ return {key: _json_safe(item) for key, item in value.items()}
2379
+ if isinstance(value, (list, tuple)):
2380
+ return [_json_safe(item) for item in value]
2381
+ if isinstance(value, float) and not math.isfinite(value):
2382
+ return "nan" if math.isnan(value) else ("inf" if value > 0 else "-inf")
2383
+ if type(value).__module__ == "numpy":
2384
+ if getattr(value, "ndim", 0):
2385
+ return _json_safe(value.tolist())
2386
+ if hasattr(value, "item"):
2387
+ return _json_safe(value.item())
2388
+ return value
2389
+
2390
+
2391
+ def _assert_no_trading_client(data_client) -> None:
2392
+ """Guardrail: the MCP process must hold only a data client."""
2393
+ from quantred.marketdata.client import MarketDataClient
2394
+
2395
+ if not isinstance(data_client, MarketDataClient):
2396
+ raise RuntimeError("MCP server requires a MarketDataClient (data only); refusing to start.")
2397
+ for attr in ("broker", "trading_client", "_broker"):
2398
+ if getattr(data_client, attr, None) is not None:
2399
+ raise RuntimeError(f"MCP data client unexpectedly exposes {attr!r}; refusing to start.")
2400
+
2401
+
2402
+ def _summary(result: Any) -> Dict[str, Any]:
2403
+ """A tiny, log-friendly digest of a tool result (no large arrays)."""
2404
+ if not isinstance(result, dict):
2405
+ return {"type": type(result).__name__, "len": len(result) if hasattr(result, "__len__") else None}
2406
+ keep = (
2407
+ "run_id",
2408
+ "best_score",
2409
+ "total_trades",
2410
+ "n_trials",
2411
+ "n_trials_total",
2412
+ "flagged_count",
2413
+ "memoized",
2414
+ "trial_id",
2415
+ )
2416
+ digest = {k: result[k] for k in keep if k in result}
2417
+ if "gate_report" in result and isinstance(result["gate_report"], dict):
2418
+ digest["promotable"] = result["gate_report"].get("promotable")
2419
+ return digest