quantred 2.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantred/__init__.py +24 -0
- quantred/alphas/__init__.py +53 -0
- quantred/alphas/base.py +221 -0
- quantred/alphas/combine.py +260 -0
- quantred/alphas/horizon.py +153 -0
- quantred/alphas/refine.py +300 -0
- quantred/alphas/scorers.py +87 -0
- quantred/analytics/attribution.py +354 -0
- quantred/analytics/bootstrap.py +353 -0
- quantred/analytics/charts.py +393 -0
- quantred/analytics/excursion.py +185 -0
- quantred/analytics/execution_quality.py +180 -0
- quantred/analytics/exposure.py +167 -0
- quantred/analytics/htmlreport.py +1208 -0
- quantred/analytics/information.py +197 -0
- quantred/analytics/metrics.py +502 -0
- quantred/analytics/performance.py +639 -0
- quantred/analytics/reporting.py +1672 -0
- quantred/analytics/screening.py +190 -0
- quantred/analytics/series_comparison.py +303 -0
- quantred/analytics/trade_analytics.py +353 -0
- quantred/brokers/alpaca/broker.py +415 -0
- quantred/brokers/alpaca/factory.py +33 -0
- quantred/brokers/alpaca/market_data.py +166 -0
- quantred/brokers/base.py +220 -0
- quantred/brokers/dryrun.py +164 -0
- quantred/brokers/errors.py +70 -0
- quantred/cli.py +8619 -0
- quantred/costs/__init__.py +11 -0
- quantred/costs/base.py +126 -0
- quantred/costs/parametric.py +131 -0
- quantred/data/__init__.py +29 -0
- quantred/data/compute.py +332 -0
- quantred/data/edges.py +81 -0
- quantred/data/features.py +120 -0
- quantred/data/panel.py +61 -0
- quantred/data/scan.py +85 -0
- quantred/data/store.py +269 -0
- quantred/demo/__init__.py +22 -0
- quantred/demo/scanners.py +104 -0
- quantred/demo/strategies.py +125 -0
- quantred/engine/backtest.py +1206 -0
- quantred/engine/barcheck.py +228 -0
- quantred/engine/live.py +539 -0
- quantred/execution/decision.py +192 -0
- quantred/execution/flatten.py +302 -0
- quantred/execution/halt.py +165 -0
- quantred/execution/ledger.py +758 -0
- quantred/execution/live_trader.py +587 -0
- quantred/execution/order_id.py +83 -0
- quantred/execution/sizing.py +114 -0
- quantred/indicators/indicators.py +158 -0
- quantred/marketdata/base.py +62 -0
- quantred/marketdata/client.py +59 -0
- quantred/marketdata/session.py +112 -0
- quantred/marketdata/synthetic.py +74 -0
- quantred/marketdata/timeframe.py +125 -0
- quantred/mcp/__init__.py +6 -0
- quantred/mcp/selftest.py +139 -0
- quantred/mcp/server.py +2419 -0
- quantred/optimization/causality.py +607 -0
- quantred/optimization/config_store.py +300 -0
- quantred/optimization/optimizer.py +431 -0
- quantred/optimization/parallel.py +345 -0
- quantred/optimization/param_space.py +309 -0
- quantred/optimization/walk_forward.py +1254 -0
- quantred/portfolio/allocator.py +135 -0
- quantred/portfolio/benchmark.py +109 -0
- quantred/portfolio/optimizer.py +786 -0
- quantred/portfolio/policy.py +335 -0
- quantred/portfolio/posterior.py +208 -0
- quantred/research/__init__.py +30 -0
- quantred/research/agent.py +868 -0
- quantred/research/demo_proposals.py +222 -0
- quantred/research/llm.py +311 -0
- quantred/research/proposer.py +234 -0
- quantred/research/provider_errors.py +242 -0
- quantred/research/sandbox.py +340 -0
- quantred/risk/__init__.py +60 -0
- quantred/risk/base.py +218 -0
- quantred/risk/conditional.py +474 -0
- quantred/risk/exposures.py +88 -0
- quantred/risk/factor.py +119 -0
- quantred/risk/sample.py +89 -0
- quantred/risk/streaming.py +214 -0
- quantred/scanners/base.py +141 -0
- quantred/scanners/symbol_scanner.py +148 -0
- quantred/services/__init__.py +51 -0
- quantred/services/analysis.py +6082 -0
- quantred/services/audit.py +574 -0
- quantred/services/campaign.py +381 -0
- quantred/services/configs.py +282 -0
- quantred/services/data.py +172 -0
- quantred/services/dryrun.py +206 -0
- quantred/services/glossary.py +130 -0
- quantred/services/maintenance.py +314 -0
- quantred/services/registry.py +351 -0
- quantred/services/setup.py +1023 -0
- quantred/services/sizing.py +150 -0
- quantred/services/smallreal.py +450 -0
- quantred/settings.py +571 -0
- quantred/store/__init__.py +0 -0
- quantred/store/bars.py +660 -0
- quantred/store/trials.py +1804 -0
- quantred/strategies/base.py +643 -0
- quantred/strategies/signals.py +28 -0
- quantred/utils/errors.py +48 -0
- quantred/utils/extras.py +330 -0
- quantred/utils/logging_config.py +33 -0
- quantred/utils/numeric.py +46 -0
- quantred/utils/streaming.py +474 -0
- quantred/utils/timeutils.py +148 -0
- quantred-2.4.0.dist-info/METADATA +632 -0
- quantred-2.4.0.dist-info/RECORD +117 -0
- quantred-2.4.0.dist-info/WHEEL +4 -0
- quantred-2.4.0.dist-info/entry_points.txt +8 -0
- quantred-2.4.0.dist-info/licenses/LICENSE +21 -0
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
|