quantred 2.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantred/__init__.py +24 -0
- quantred/alphas/__init__.py +53 -0
- quantred/alphas/base.py +221 -0
- quantred/alphas/combine.py +260 -0
- quantred/alphas/horizon.py +153 -0
- quantred/alphas/refine.py +300 -0
- quantred/alphas/scorers.py +87 -0
- quantred/analytics/attribution.py +354 -0
- quantred/analytics/bootstrap.py +353 -0
- quantred/analytics/charts.py +393 -0
- quantred/analytics/excursion.py +185 -0
- quantred/analytics/execution_quality.py +180 -0
- quantred/analytics/exposure.py +167 -0
- quantred/analytics/htmlreport.py +1208 -0
- quantred/analytics/information.py +197 -0
- quantred/analytics/metrics.py +502 -0
- quantred/analytics/performance.py +639 -0
- quantred/analytics/reporting.py +1672 -0
- quantred/analytics/screening.py +190 -0
- quantred/analytics/series_comparison.py +303 -0
- quantred/analytics/trade_analytics.py +353 -0
- quantred/brokers/alpaca/broker.py +415 -0
- quantred/brokers/alpaca/factory.py +33 -0
- quantred/brokers/alpaca/market_data.py +166 -0
- quantred/brokers/base.py +220 -0
- quantred/brokers/dryrun.py +164 -0
- quantred/brokers/errors.py +70 -0
- quantred/cli.py +8619 -0
- quantred/costs/__init__.py +11 -0
- quantred/costs/base.py +126 -0
- quantred/costs/parametric.py +131 -0
- quantred/data/__init__.py +29 -0
- quantred/data/compute.py +332 -0
- quantred/data/edges.py +81 -0
- quantred/data/features.py +120 -0
- quantred/data/panel.py +61 -0
- quantred/data/scan.py +85 -0
- quantred/data/store.py +269 -0
- quantred/demo/__init__.py +22 -0
- quantred/demo/scanners.py +104 -0
- quantred/demo/strategies.py +125 -0
- quantred/engine/backtest.py +1206 -0
- quantred/engine/barcheck.py +228 -0
- quantred/engine/live.py +539 -0
- quantred/execution/decision.py +192 -0
- quantred/execution/flatten.py +302 -0
- quantred/execution/halt.py +165 -0
- quantred/execution/ledger.py +758 -0
- quantred/execution/live_trader.py +587 -0
- quantred/execution/order_id.py +83 -0
- quantred/execution/sizing.py +114 -0
- quantred/indicators/indicators.py +158 -0
- quantred/marketdata/base.py +62 -0
- quantred/marketdata/client.py +59 -0
- quantred/marketdata/session.py +112 -0
- quantred/marketdata/synthetic.py +74 -0
- quantred/marketdata/timeframe.py +125 -0
- quantred/mcp/__init__.py +6 -0
- quantred/mcp/selftest.py +139 -0
- quantred/mcp/server.py +2419 -0
- quantred/optimization/causality.py +607 -0
- quantred/optimization/config_store.py +300 -0
- quantred/optimization/optimizer.py +431 -0
- quantred/optimization/parallel.py +345 -0
- quantred/optimization/param_space.py +309 -0
- quantred/optimization/walk_forward.py +1254 -0
- quantred/portfolio/allocator.py +135 -0
- quantred/portfolio/benchmark.py +109 -0
- quantred/portfolio/optimizer.py +786 -0
- quantred/portfolio/policy.py +335 -0
- quantred/portfolio/posterior.py +208 -0
- quantred/research/__init__.py +30 -0
- quantred/research/agent.py +868 -0
- quantred/research/demo_proposals.py +222 -0
- quantred/research/llm.py +311 -0
- quantred/research/proposer.py +234 -0
- quantred/research/provider_errors.py +242 -0
- quantred/research/sandbox.py +340 -0
- quantred/risk/__init__.py +60 -0
- quantred/risk/base.py +218 -0
- quantred/risk/conditional.py +474 -0
- quantred/risk/exposures.py +88 -0
- quantred/risk/factor.py +119 -0
- quantred/risk/sample.py +89 -0
- quantred/risk/streaming.py +214 -0
- quantred/scanners/base.py +141 -0
- quantred/scanners/symbol_scanner.py +148 -0
- quantred/services/__init__.py +51 -0
- quantred/services/analysis.py +6082 -0
- quantred/services/audit.py +574 -0
- quantred/services/campaign.py +381 -0
- quantred/services/configs.py +282 -0
- quantred/services/data.py +172 -0
- quantred/services/dryrun.py +206 -0
- quantred/services/glossary.py +130 -0
- quantred/services/maintenance.py +314 -0
- quantred/services/registry.py +351 -0
- quantred/services/setup.py +1023 -0
- quantred/services/sizing.py +150 -0
- quantred/services/smallreal.py +450 -0
- quantred/settings.py +571 -0
- quantred/store/__init__.py +0 -0
- quantred/store/bars.py +660 -0
- quantred/store/trials.py +1804 -0
- quantred/strategies/base.py +643 -0
- quantred/strategies/signals.py +28 -0
- quantred/utils/errors.py +48 -0
- quantred/utils/extras.py +330 -0
- quantred/utils/logging_config.py +33 -0
- quantred/utils/numeric.py +46 -0
- quantred/utils/streaming.py +474 -0
- quantred/utils/timeutils.py +148 -0
- quantred-2.4.0.dist-info/METADATA +632 -0
- quantred-2.4.0.dist-info/RECORD +117 -0
- quantred-2.4.0.dist-info/WHEEL +4 -0
- quantred-2.4.0.dist-info/entry_points.txt +8 -0
- quantred-2.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Trial-store maintenance: quarantining a subset, and retiring an era.
|
|
2
|
+
|
|
3
|
+
Two operations, deliberately not one command. They answer different questions, and
|
|
4
|
+
they differ in how reversible they are:
|
|
5
|
+
|
|
6
|
+
**Quarantine** (:func:`mark_contaminated`) is for a *suspect subset*. Something was
|
|
7
|
+
learned about some trials after they were recorded — a bug in a scanner, a data vendor
|
|
8
|
+
correction, a config that turned out not to be the one you thought. History stays
|
|
9
|
+
exactly as written; an event is appended saying what was learned, and readers honour it.
|
|
10
|
+
|
|
11
|
+
**Archive** (:func:`archive`) is for a *whole era*. An accounting bump changes what the
|
|
12
|
+
engine computes, so every recorded metric becomes incommensurable with anything produced
|
|
13
|
+
afterwards — not suspect, but measured with a different instrument. That is not a subset
|
|
14
|
+
and no annotation fixes it: the journal and its index are moved aside together and a
|
|
15
|
+
fresh pair started.
|
|
16
|
+
|
|
17
|
+
Collapsing them would be a mistake in both directions. Quarantining an era leaves
|
|
18
|
+
thousands of rows carrying a caveat nobody can act on; archiving a subset throws away
|
|
19
|
+
the evidence around it.
|
|
20
|
+
|
|
21
|
+
**Nothing here deletes anything.** There is no `reset`, because the destructive version
|
|
22
|
+
of archive is exactly what the append-only rule exists to forbid: the journal has nothing
|
|
23
|
+
behind it to rebuild from, so a record removed is a record gone. Archive moves; the files
|
|
24
|
+
are still on disk under a name that says when and why.
|
|
25
|
+
|
|
26
|
+
The two files move **together**, always. The store is derived from the journal, so moving
|
|
27
|
+
one without the other leaves a store indexing a file that no longer exists — or worse, a
|
|
28
|
+
store full of an old era's rows sitting beside a fresh journal, silently disagreeing with
|
|
29
|
+
its own source and reporting a multiple-testing count for evidence that is no longer
|
|
30
|
+
there. That is precisely the hand-rolled state this exists to replace.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import logging
|
|
35
|
+
import shutil
|
|
36
|
+
from datetime import datetime, timezone
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
39
|
+
|
|
40
|
+
from quantred.engine.backtest import ACCOUNTING_VERSION
|
|
41
|
+
from quantred.services import audit
|
|
42
|
+
from quantred.settings import state_root
|
|
43
|
+
from quantred.store.trials import CONTAMINATION_TOOL, TrialStore, db_path_for_journal
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
#: Where retired eras go. Under the state root, beside the journal they came from, so
|
|
48
|
+
#: an archive travels with the campaign rather than wherever the command was run.
|
|
49
|
+
ARCHIVE_DIRNAME = "archive"
|
|
50
|
+
|
|
51
|
+
#: Written into each archive directory. The files alone do not say why they were
|
|
52
|
+
#: retired, and "why" is the part nobody reconstructs a year later.
|
|
53
|
+
MANIFEST_NAME = "manifest.json"
|
|
54
|
+
|
|
55
|
+
#: Appended to the *new* journal as its first line after an archive. A fresh journal that
|
|
56
|
+
#: simply starts empty loses the fact that an era existed, and the emptiness then reads
|
|
57
|
+
#: as "nothing has ever been run here" — which is how a campaign's evidence quietly
|
|
58
|
+
#: appears to have never happened.
|
|
59
|
+
ARCHIVE_TOOL = "trials:archived"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolve_journal(journal_path: Optional[Any]) -> Path:
|
|
63
|
+
return Path(journal_path) if journal_path else audit.default_trial_journal()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def resolve_trial_ids(typed: Iterable[str], journal_path: Optional[Any] = None) -> Dict[str, Optional[str]]:
|
|
67
|
+
"""Each typed id (or unique prefix) to the full id it names, ``None`` where it names none.
|
|
68
|
+
|
|
69
|
+
Raises ``AmbiguousTrialId`` (a ``ValueError``) for a prefix more than one trial carries.
|
|
70
|
+
"""
|
|
71
|
+
journal = _resolve_journal(journal_path)
|
|
72
|
+
with TrialStore(db_path_for_journal(journal), journal_path=journal) as store:
|
|
73
|
+
return {t: store.resolve_id(t) for t in typed}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def select_trials(
|
|
77
|
+
*,
|
|
78
|
+
journal_path: Optional[Any] = None,
|
|
79
|
+
trial_ids: Optional[Iterable[str]] = None,
|
|
80
|
+
strategy: Optional[str] = None,
|
|
81
|
+
kind: Optional[str] = None,
|
|
82
|
+
accounting: Optional[int] = None,
|
|
83
|
+
before: Optional[Any] = None,
|
|
84
|
+
) -> List[Dict[str, Any]]:
|
|
85
|
+
"""The trials a quarantine would name, resolved to explicit rows.
|
|
86
|
+
|
|
87
|
+
Filters are resolved to ids **now**, and the event records those ids rather than the
|
|
88
|
+
filter. A stored filter would re-evaluate on every rebuild and could silently pull in
|
|
89
|
+
trials recorded after the decision was made — a quarantine that grows on its own is
|
|
90
|
+
not a record of what somebody decided.
|
|
91
|
+
"""
|
|
92
|
+
if trial_ids and any(f is not None for f in (strategy, kind, accounting, before)):
|
|
93
|
+
# Ids name trials outright, and the filters used to be ignored beside them, so
|
|
94
|
+
# `--id X --strategy Y` quarantined X whatever its strategy, saying nothing.
|
|
95
|
+
raise ValueError("ids name trials outright; give either ids or filters, not both")
|
|
96
|
+
journal = _resolve_journal(journal_path)
|
|
97
|
+
with TrialStore(db_path_for_journal(journal), journal_path=journal) as store:
|
|
98
|
+
if trial_ids:
|
|
99
|
+
rows = [store.get_trial(str(t)) for t in trial_ids]
|
|
100
|
+
return [r for r in rows if r]
|
|
101
|
+
# The store's own `before` rather than a second date comparison here: the store
|
|
102
|
+
# owns one definition of what a filter means, and a listing that disagreed with
|
|
103
|
+
# the store's own count of the same filter is the bug that definition prevents.
|
|
104
|
+
# Not `until`, which includes the whole day it names.
|
|
105
|
+
return store.list_trials(
|
|
106
|
+
strategy=strategy,
|
|
107
|
+
kind=kind,
|
|
108
|
+
accounting=accounting,
|
|
109
|
+
all_accounting=accounting is None,
|
|
110
|
+
before=before,
|
|
111
|
+
limit=1_000_000,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def mark_contaminated(
|
|
116
|
+
*,
|
|
117
|
+
reason: str,
|
|
118
|
+
journal_path: Optional[Any] = None,
|
|
119
|
+
trial_ids: Optional[Iterable[str]] = None,
|
|
120
|
+
strategy: Optional[str] = None,
|
|
121
|
+
kind: Optional[str] = None,
|
|
122
|
+
accounting: Optional[int] = None,
|
|
123
|
+
before: Optional[Any] = None,
|
|
124
|
+
dry_run: bool = False,
|
|
125
|
+
) -> Dict[str, Any]:
|
|
126
|
+
"""Quarantine a suspect subset of trials, reason on the record.
|
|
127
|
+
|
|
128
|
+
Appends one event naming every affected trial, then applies it to the derived index.
|
|
129
|
+
A rebuild replays the event and reaches the same rows, so the quarantine survives the
|
|
130
|
+
store being thrown away — which it is, routinely, whenever the schema moves.
|
|
131
|
+
|
|
132
|
+
``reason`` is required and not defaulted. An unexplained quarantine is worse than
|
|
133
|
+
none: a later reader sees rows excluded from every leaderboard with nothing saying
|
|
134
|
+
what was wrong with them, and has no way to judge whether it still applies.
|
|
135
|
+
"""
|
|
136
|
+
if not (reason or "").strip():
|
|
137
|
+
raise ValueError("A quarantine needs a reason: an unexplained one cannot be judged later")
|
|
138
|
+
|
|
139
|
+
journal = _resolve_journal(journal_path)
|
|
140
|
+
typed = [str(t) for t in (trial_ids or [])]
|
|
141
|
+
if typed and any(f is not None for f in (strategy, kind, accounting, before)):
|
|
142
|
+
raise ValueError("ids name trials outright; give either ids or filters, not both")
|
|
143
|
+
resolved = resolve_trial_ids(typed, journal) if typed else {}
|
|
144
|
+
found = [full for full in resolved.values() if full]
|
|
145
|
+
if typed and not found:
|
|
146
|
+
# Every id was a typo. An empty id list is falsy, and handing it on read as "no
|
|
147
|
+
# ids, no filters" - so the report selected every trial in the store.
|
|
148
|
+
selected: List[Dict[str, Any]] = []
|
|
149
|
+
else:
|
|
150
|
+
selected = select_trials(
|
|
151
|
+
journal_path=journal,
|
|
152
|
+
trial_ids=found if typed else None,
|
|
153
|
+
strategy=strategy,
|
|
154
|
+
kind=kind,
|
|
155
|
+
accounting=accounting,
|
|
156
|
+
before=before,
|
|
157
|
+
)
|
|
158
|
+
# An id somebody typed is a claim that the trial exists; a filter matching nothing is
|
|
159
|
+
# an answer. Only the first is a typo signal, so only it is reported back.
|
|
160
|
+
payload_unmatched = [t for t in typed if not resolved.get(t)]
|
|
161
|
+
already = [r for r in selected if r.get("contaminated_at")]
|
|
162
|
+
fresh = [r for r in selected if not r.get("contaminated_at")]
|
|
163
|
+
ids = [r["id"] for r in fresh]
|
|
164
|
+
|
|
165
|
+
payload = {
|
|
166
|
+
"selected": len(selected),
|
|
167
|
+
"already_contaminated": len(already),
|
|
168
|
+
"to_mark": len(ids),
|
|
169
|
+
"trial_ids": ids,
|
|
170
|
+
"reason": reason,
|
|
171
|
+
"journal_path": str(journal),
|
|
172
|
+
"dry_run": dry_run,
|
|
173
|
+
"unmatched_ids": payload_unmatched,
|
|
174
|
+
}
|
|
175
|
+
if dry_run or not ids or payload_unmatched:
|
|
176
|
+
payload["applied"] = 0
|
|
177
|
+
if payload_unmatched:
|
|
178
|
+
first = "An id matched no trial, so nothing was written."
|
|
179
|
+
else:
|
|
180
|
+
first = "Nothing selected." if not selected else "Dry run: nothing was written."
|
|
181
|
+
payload["note"] = (
|
|
182
|
+
first + " A quarantined trial still counts toward its family's multiple-testing total."
|
|
183
|
+
)
|
|
184
|
+
return payload
|
|
185
|
+
|
|
186
|
+
run_id = audit.audit_log(
|
|
187
|
+
CONTAMINATION_TOOL,
|
|
188
|
+
{"trial_ids": ids, "reason": reason, "selected": len(selected)},
|
|
189
|
+
path=journal,
|
|
190
|
+
)
|
|
191
|
+
with TrialStore(db_path_for_journal(journal), journal_path=journal) as store:
|
|
192
|
+
applied = store.mark_contaminated(ids, reason=reason)
|
|
193
|
+
payload["applied"] = applied
|
|
194
|
+
payload["event_run_id"] = run_id
|
|
195
|
+
payload["note"] = (
|
|
196
|
+
"Quarantined trials are never served as a memo and never ranked, and they still "
|
|
197
|
+
"count toward their family's multiple-testing total — the search happened, and "
|
|
198
|
+
"removing it would lower the deflation bar rather than raise it."
|
|
199
|
+
)
|
|
200
|
+
return payload
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def archive(
|
|
204
|
+
*,
|
|
205
|
+
reason: str,
|
|
206
|
+
journal_path: Optional[Any] = None,
|
|
207
|
+
label: Optional[str] = None,
|
|
208
|
+
stamp: Optional[str] = None,
|
|
209
|
+
dry_run: bool = False,
|
|
210
|
+
) -> Dict[str, Any]:
|
|
211
|
+
"""Retire a whole era: move the journal and its index aside, together.
|
|
212
|
+
|
|
213
|
+
Together is the requirement, not a convenience. The store is an index over the
|
|
214
|
+
journal, so moving one alone leaves the other describing something that is not there
|
|
215
|
+
— a store still reporting an era's multiple-testing count beside a journal that no
|
|
216
|
+
longer holds it, with nothing erroring, because both files are individually valid.
|
|
217
|
+
|
|
218
|
+
The archive keeps a manifest saying what was retired, when, why, and under which
|
|
219
|
+
accounting version and commit. And the *new* journal opens with a record of the
|
|
220
|
+
archive, so its emptiness reads as "an era was retired here" rather than as "nothing
|
|
221
|
+
has ever been run".
|
|
222
|
+
"""
|
|
223
|
+
if not (reason or "").strip():
|
|
224
|
+
raise ValueError(
|
|
225
|
+
"An archive needs a reason: a retired era with no explanation cannot be judged later"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
journal = _resolve_journal(journal_path)
|
|
229
|
+
db = db_path_for_journal(journal)
|
|
230
|
+
when = stamp or datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
231
|
+
name = f"{when}-{label}" if label else when
|
|
232
|
+
destination = state_root() / ARCHIVE_DIRNAME / name
|
|
233
|
+
|
|
234
|
+
summary: Dict[str, Any] = {"rows": 0, "journal_lines": 0}
|
|
235
|
+
if journal.exists() or db.exists():
|
|
236
|
+
try:
|
|
237
|
+
with TrialStore(db, journal_path=journal) as store:
|
|
238
|
+
status = store.status(journal)
|
|
239
|
+
summary = {
|
|
240
|
+
"rows": status["rows"],
|
|
241
|
+
"journal_lines": status["journal_lines"],
|
|
242
|
+
"journal_trial_lines": status["journal_trial_lines"],
|
|
243
|
+
"contaminated_rows": status.get("contaminated_rows", 0),
|
|
244
|
+
}
|
|
245
|
+
except Exception: # noqa: BLE001 - a broken store must not block retiring it
|
|
246
|
+
logger.warning("Could not summarize the store before archiving it", exc_info=True)
|
|
247
|
+
|
|
248
|
+
manifest = {
|
|
249
|
+
"archived_at": datetime.now(timezone.utc).isoformat(),
|
|
250
|
+
"reason": reason,
|
|
251
|
+
"label": label,
|
|
252
|
+
"accounting_version_at_archive": ACCOUNTING_VERSION,
|
|
253
|
+
"git_sha": audit.current_git_sha(),
|
|
254
|
+
"journal_path": str(journal),
|
|
255
|
+
"db_path": str(db),
|
|
256
|
+
"destination": str(destination),
|
|
257
|
+
**summary,
|
|
258
|
+
}
|
|
259
|
+
existing = [path for path in (journal, db) if path.exists()]
|
|
260
|
+
if dry_run:
|
|
261
|
+
return {
|
|
262
|
+
**manifest,
|
|
263
|
+
"moved": [],
|
|
264
|
+
"would_move": [path.name for path in existing],
|
|
265
|
+
"dry_run": True,
|
|
266
|
+
"note": "Dry run: nothing was moved.",
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if not existing:
|
|
270
|
+
return {
|
|
271
|
+
**manifest,
|
|
272
|
+
"moved": [],
|
|
273
|
+
"dry_run": False,
|
|
274
|
+
"note": f"Nothing to archive: neither {journal.name} nor {db.name} exists.",
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
278
|
+
moved = []
|
|
279
|
+
for path in existing:
|
|
280
|
+
shutil.move(str(path), str(destination / path.name))
|
|
281
|
+
moved.append(path.name)
|
|
282
|
+
(destination / MANIFEST_NAME).write_text(json.dumps({**manifest, "moved": moved}, indent=2) + "\n")
|
|
283
|
+
|
|
284
|
+
# The new journal's first line, so an empty campaign says why it is empty.
|
|
285
|
+
audit.audit_log(
|
|
286
|
+
ARCHIVE_TOOL,
|
|
287
|
+
{"reason": reason, "archived_to": str(destination), "label": label, **summary},
|
|
288
|
+
path=journal,
|
|
289
|
+
)
|
|
290
|
+
return {**manifest, "moved": moved, "dry_run": False, "note": "Both files moved together."}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def list_archives() -> List[Dict[str, Any]]:
|
|
294
|
+
"""Every retired era, newest first, with what its manifest recorded."""
|
|
295
|
+
root = state_root() / ARCHIVE_DIRNAME
|
|
296
|
+
if not root.is_dir():
|
|
297
|
+
return []
|
|
298
|
+
out = []
|
|
299
|
+
for directory in sorted(root.iterdir(), reverse=True):
|
|
300
|
+
if not directory.is_dir():
|
|
301
|
+
continue
|
|
302
|
+
entry: Dict[str, Any] = {"name": directory.name, "path": str(directory)}
|
|
303
|
+
manifest = directory / MANIFEST_NAME
|
|
304
|
+
if manifest.exists():
|
|
305
|
+
try:
|
|
306
|
+
entry.update(json.loads(manifest.read_text()))
|
|
307
|
+
except json.JSONDecodeError:
|
|
308
|
+
entry["manifest_error"] = "unreadable"
|
|
309
|
+
else:
|
|
310
|
+
# Reported, never inferred: a directory somebody created by hand is not
|
|
311
|
+
# evidence about what it holds.
|
|
312
|
+
entry["manifest_error"] = "missing"
|
|
313
|
+
out.append(entry)
|
|
314
|
+
return out
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""Discovery: the registries of strategies and scanners, and their tunables.
|
|
2
|
+
|
|
3
|
+
Single source of truth for "what can the engine run?" - imported by the CLI, the
|
|
4
|
+
MCP server, and the research agent so they all see the same menu.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from importlib import metadata
|
|
12
|
+
from typing import Any, Dict, List, Optional, Type
|
|
13
|
+
|
|
14
|
+
from quantred.demo.scanners import DemoVolumeScanner
|
|
15
|
+
from quantred.demo.strategies import DemoTrendStrategy
|
|
16
|
+
from quantred.scanners.base import ScannerStrategy
|
|
17
|
+
from quantred.scanners.symbol_scanner import BUILTIN_SCANNERS as _BUILTIN_SCANNERS
|
|
18
|
+
from quantred.scanners.symbol_scanner import SymbolScanner
|
|
19
|
+
from quantred.settings import (
|
|
20
|
+
LEGACY_DISTRIBUTION_NAMES,
|
|
21
|
+
SCANNER_ENTRY_POINT_GROUP,
|
|
22
|
+
STRATEGY_ENTRY_POINT_GROUP,
|
|
23
|
+
entry_point_groups,
|
|
24
|
+
)
|
|
25
|
+
from quantred.strategies.base import Strategy
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$")
|
|
30
|
+
|
|
31
|
+
#: Names the engine itself owns, and the classes behind them. This is the reserved
|
|
32
|
+
#: set: an installed pack cannot take one of these names.
|
|
33
|
+
#:
|
|
34
|
+
#: The demo pair is *also* declared as an entry point in the engine's own
|
|
35
|
+
#: ``pyproject.toml``, so the shipped wheel exercises the same discovery path a
|
|
36
|
+
#: pack's does rather than leaving it to CI fixtures. Seeded here as well because
|
|
37
|
+
#: enumeration order across distributions is not defined: a pack registering
|
|
38
|
+
#: ``demo_trend`` would otherwise win or lose by accident, and reserving a name is
|
|
39
|
+
#: worth nothing if which class answers to it is a coin flip.
|
|
40
|
+
BUILTIN_STRATEGIES: Dict[str, Type[Strategy]] = {"demo_trend": DemoTrendStrategy}
|
|
41
|
+
|
|
42
|
+
#: Built-in scanners. Taken from the scanner package's own literal rather than from
|
|
43
|
+
#: ``SymbolScanner.SCANNERS``, which discovery overwrites with the merged set -
|
|
44
|
+
#: re-deriving from that on a module reload quietly promoted installed contributions
|
|
45
|
+
#: into the reserved built-in names.
|
|
46
|
+
BUILTIN_SCANNERS: Dict[str, Type[ScannerStrategy]] = {
|
|
47
|
+
**_BUILTIN_SCANNERS,
|
|
48
|
+
"demo_volume": DemoVolumeScanner,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#: Trading strategies exposed everywhere (CLI, MCP, agent).
|
|
52
|
+
#:
|
|
53
|
+
#: Seeded with the built-ins rather than left empty, so these are never *less* than
|
|
54
|
+
#: the public engine at any instant. Discovery imports arbitrary third-party modules,
|
|
55
|
+
#: and a pack that reads the registry while importing used to observe the empty dict
|
|
56
|
+
#: this was populated from - raising, and being dropped as a broken contribution.
|
|
57
|
+
STRATEGIES: Dict[str, Type[Strategy]] = dict(BUILTIN_STRATEGIES)
|
|
58
|
+
|
|
59
|
+
#: Universe scanners exposed everywhere (CLI, MCP, agent).
|
|
60
|
+
SCANNERS: Dict[str, Type[ScannerStrategy]] = dict(BUILTIN_SCANNERS)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def refresh_registries() -> None:
|
|
64
|
+
"""Reload built-ins plus installed strategy/scanner entry points.
|
|
65
|
+
|
|
66
|
+
Import-time discovery is enough for normal CLI/MCP use, but tests and notebooks
|
|
67
|
+
can call this after changing the environment. Built-in names are reserved:
|
|
68
|
+
an installed private package cannot silently replace the public examples.
|
|
69
|
+
|
|
70
|
+
Both registries are built in full before either is swapped in. Clearing first
|
|
71
|
+
published an empty registry for the whole of discovery - which is exactly when
|
|
72
|
+
third-party code is being imported and may read it - and left the registry wiped
|
|
73
|
+
rather than merely stale if anything raised on the way.
|
|
74
|
+
"""
|
|
75
|
+
strategies = _merged_registry(BUILTIN_STRATEGIES, STRATEGY_ENTRY_POINT_GROUP, Strategy)
|
|
76
|
+
scanners = _merged_registry(BUILTIN_SCANNERS, SCANNER_ENTRY_POINT_GROUP, ScannerStrategy)
|
|
77
|
+
STRATEGIES.clear()
|
|
78
|
+
STRATEGIES.update(strategies)
|
|
79
|
+
SCANNERS.clear()
|
|
80
|
+
SCANNERS.update(scanners)
|
|
81
|
+
# SymbolScanner is still used directly in a few paths; keep its legacy class
|
|
82
|
+
# attribute in sync with the single service registry.
|
|
83
|
+
SymbolScanner.SCANNERS = dict(SCANNERS)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def list_strategies() -> List[Dict[str, Any]]:
|
|
87
|
+
"""Names, one-line descriptions, and whether each is shipped demo scaffolding.
|
|
88
|
+
|
|
89
|
+
``demo`` is read from the class, not from registry membership. The shipped
|
|
90
|
+
example arrives by entry point exactly as a private pack's does, so *how* something
|
|
91
|
+
was discovered stopped being able to say what it is for. Carried rather than
|
|
92
|
+
implied, because without a label the reasonable read of a registry is that the
|
|
93
|
+
platform *is* whatever is in it.
|
|
94
|
+
"""
|
|
95
|
+
return [
|
|
96
|
+
{
|
|
97
|
+
"name": name,
|
|
98
|
+
"description": _first_line(cls.__doc__),
|
|
99
|
+
"timeframe": getattr(cls, "TIMEFRAME", None) or None,
|
|
100
|
+
"demo": is_demo(cls),
|
|
101
|
+
}
|
|
102
|
+
for name, cls in STRATEGIES.items()
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def list_scanners() -> List[Dict[str, Any]]:
|
|
107
|
+
"""Names, one-line descriptions, and whether each is shipped demo scaffolding."""
|
|
108
|
+
return [
|
|
109
|
+
{
|
|
110
|
+
"name": name,
|
|
111
|
+
"description": _first_line(cls.__doc__),
|
|
112
|
+
"demo": is_demo(cls),
|
|
113
|
+
}
|
|
114
|
+
for name, cls in SCANNERS.items()
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def scanner_refusal(value: str) -> Optional[str]:
|
|
119
|
+
"""Why ``value`` is not a usable scanner name, or ``None`` when it is.
|
|
120
|
+
|
|
121
|
+
``"none"`` means no scan and is always accepted. One definition for the CLI (at parse
|
|
122
|
+
time) and the services (before any step runs), so an unknown name is refused the same
|
|
123
|
+
way on every surface rather than failing inside a scan and being reported as a
|
|
124
|
+
different problem further down the report.
|
|
125
|
+
"""
|
|
126
|
+
import difflib
|
|
127
|
+
|
|
128
|
+
if value == "none" or value in SCANNERS:
|
|
129
|
+
return None
|
|
130
|
+
close = difflib.get_close_matches(value, list(SCANNERS), n=1)
|
|
131
|
+
hint = f" Did you mean {close[0]!r}?" if close else ""
|
|
132
|
+
installed = ", ".join(sorted(SCANNERS)) or "none installed"
|
|
133
|
+
return f"unknown scanner {value!r}.{hint} Available: {installed}; or 'none' to skip the scan."
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def is_demo(cls: Type) -> bool:
|
|
137
|
+
"""Whether ``cls`` is shipped demonstration scaffolding, rather than inheriting it.
|
|
138
|
+
|
|
139
|
+
Read from the class's own ``__dict__``, not through the MRO. ``docs/content/usage/
|
|
140
|
+
private-strategies.md`` invites a pack to start by subclassing ``DemoTrendStrategy``,
|
|
141
|
+
and an inherited flag reported that pack's real strategy to the CLI, the MCP server
|
|
142
|
+
and the research agent as shipped scaffolding - the same "the platform *is* whatever
|
|
143
|
+
is in it" misread the label exists to prevent, pointing the other way. A subclass
|
|
144
|
+
that genuinely is more scaffolding can say so by setting ``DEMO`` itself.
|
|
145
|
+
"""
|
|
146
|
+
return bool(cls.__dict__.get("DEMO", False))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_param_ranges(kind: str, name: str) -> Dict[str, Any]:
|
|
150
|
+
"""The ``PARAM_RANGES`` map for a strategy/scanner - what is tunable and its bounds.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
kind: ``"strategy"`` or ``"scanner"``.
|
|
154
|
+
name: A registered name.
|
|
155
|
+
"""
|
|
156
|
+
registry = STRATEGIES if kind == "strategy" else SCANNERS if kind == "scanner" else None
|
|
157
|
+
if registry is None:
|
|
158
|
+
raise ValueError(f"kind must be 'strategy' or 'scanner', got {kind!r}")
|
|
159
|
+
if name not in registry:
|
|
160
|
+
raise ValueError(f"Unknown {kind} '{name}'. Available: {list(registry)}")
|
|
161
|
+
return {"kind": kind, "name": name, "param_ranges": registry[name].PARAM_RANGES}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
#: Identity prefix of a strategy the research agent or a draft walk-forward wrote. It
|
|
165
|
+
#: names a mechanism for the trial history; it is never a registered, runnable name.
|
|
166
|
+
DRAFT_PREFIX = "draft:"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def resolve_strategy_class(name: str) -> Type[Strategy]:
|
|
170
|
+
if name.startswith(DRAFT_PREFIX):
|
|
171
|
+
from quantred.utils.errors import GeneratedCodeRefused
|
|
172
|
+
|
|
173
|
+
raise GeneratedCodeRefused(name)
|
|
174
|
+
if name not in STRATEGIES:
|
|
175
|
+
raise ValueError(f"Unknown strategy '{name}'. Available: {list(STRATEGIES)}")
|
|
176
|
+
return STRATEGIES[name]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _merged_registry(builtins: Dict[str, Type], group: str, base_cls: Type) -> Dict[str, Type]:
|
|
180
|
+
registry = dict(builtins)
|
|
181
|
+
for name, cls in _entry_point_classes(group, base_cls).items():
|
|
182
|
+
if name in registry:
|
|
183
|
+
if registry[name] is cls:
|
|
184
|
+
# The engine's own demo, arriving by the entry point it declares.
|
|
185
|
+
# Same name, same class, nothing overridden - warning here would fire
|
|
186
|
+
# on every single run and train the reader to ignore the real one.
|
|
187
|
+
continue
|
|
188
|
+
logger.warning("Ignoring %s entry point %r: built-in names cannot be overridden", group, name)
|
|
189
|
+
continue
|
|
190
|
+
registry[name] = cls
|
|
191
|
+
return registry
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _entry_point_classes(group: str, base_cls: Type) -> Dict[str, Type]:
|
|
195
|
+
"""Load extension classes from an entry-point group.
|
|
196
|
+
|
|
197
|
+
A contribution may be either a class, a mapping of name -> class, or a no-arg
|
|
198
|
+
function returning that mapping. Invalid contributions are ignored with a warning
|
|
199
|
+
so a broken private package cannot make the public engine unusable.
|
|
200
|
+
"""
|
|
201
|
+
found: Dict[str, Type] = {}
|
|
202
|
+
for entry_point in _select_entry_points(group):
|
|
203
|
+
declared = getattr(entry_point, "group", None) or group
|
|
204
|
+
if _from_legacy_distribution(entry_point):
|
|
205
|
+
# The previous distribution (`tradeflow-engine` 2.3.0) can still be installed
|
|
206
|
+
# beside this one and declares the demo pair under the old groups. Those
|
|
207
|
+
# names are built in here already; loading them would import the old
|
|
208
|
+
# package's copy of the same classes and trip the override warning on every
|
|
209
|
+
# command. This distribution's own declarations are *not* skipped: the demo
|
|
210
|
+
# pair goes through this path on every install, which is the point of
|
|
211
|
+
# declaring it rather than only seeding it.
|
|
212
|
+
logger.debug(
|
|
213
|
+
"Skipping %s entry point %r: declared by the previous distribution",
|
|
214
|
+
declared,
|
|
215
|
+
entry_point.name,
|
|
216
|
+
)
|
|
217
|
+
continue
|
|
218
|
+
# Loading is all-or-nothing for one entry point: if the module will not
|
|
219
|
+
# import, or hands back something that is not a set of contributions, there
|
|
220
|
+
# is nothing of it to keep.
|
|
221
|
+
try:
|
|
222
|
+
contributions = _coerce_contributions(entry_point.name, entry_point.load())
|
|
223
|
+
except Exception as exc: # noqa: BLE001 - discovery should degrade, not brick the CLI
|
|
224
|
+
logger.warning("Ignoring %s entry point %r: %s", declared, entry_point.name, exc)
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
# Validation is not. One unusable class in a pack of five used to abort the
|
|
228
|
+
# loop, keeping whatever had already been added and silently dropping the
|
|
229
|
+
# siblings it had not reached yet - so which of a pack's contributions
|
|
230
|
+
# survived came down to dict ordering, and the warning named neither the
|
|
231
|
+
# offending class nor the ones lost behind it.
|
|
232
|
+
for name, cls in contributions.items():
|
|
233
|
+
try:
|
|
234
|
+
_validate_extension(name, cls, base_cls, declared)
|
|
235
|
+
except Exception as exc: # noqa: BLE001 - one bad class, not one bad pack
|
|
236
|
+
logger.warning(
|
|
237
|
+
"Ignoring %s contribution %r from entry point %r: %s",
|
|
238
|
+
declared,
|
|
239
|
+
name,
|
|
240
|
+
entry_point.name,
|
|
241
|
+
exc,
|
|
242
|
+
)
|
|
243
|
+
continue
|
|
244
|
+
if name in found:
|
|
245
|
+
logger.warning(
|
|
246
|
+
"Ignoring duplicate %s contribution %r from entry point %r",
|
|
247
|
+
declared,
|
|
248
|
+
name,
|
|
249
|
+
entry_point.name,
|
|
250
|
+
)
|
|
251
|
+
continue
|
|
252
|
+
found[name] = cls
|
|
253
|
+
_warn_if_a_pack_imports_the_old_name()
|
|
254
|
+
return found
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _from_legacy_distribution(entry_point: Any) -> bool:
|
|
258
|
+
"""Whether ``entry_point`` is declared by the engine's *previous* distribution.
|
|
259
|
+
|
|
260
|
+
Only the previous one: its declarations are a second copy of classes that are
|
|
261
|
+
built in here already. The current distribution's own entry points are loaded like
|
|
262
|
+
anyone's.
|
|
263
|
+
"""
|
|
264
|
+
dist = getattr(entry_point, "dist", None)
|
|
265
|
+
name = getattr(dist, "name", None) or (getattr(dist, "metadata", None) or {}).get("Name")
|
|
266
|
+
return bool(name) and str(name).lower().replace("_", "-") in LEGACY_DISTRIBUTION_NAMES
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
_WARNED_OLD_IMPORT = False
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _warn_if_a_pack_imports_the_old_name() -> None:
|
|
273
|
+
"""Say once that something loaded through the ``tradeflow`` import alias.
|
|
274
|
+
|
|
275
|
+
The alias's ``DeprecationWarning`` is hidden by Python's default filters unless it
|
|
276
|
+
is raised from ``__main__``, so a pack loaded by entry point would use it silently
|
|
277
|
+
while the docs say it warns. Detected from the alias itself (its module and its
|
|
278
|
+
import finder are present) rather than from any one pack.
|
|
279
|
+
"""
|
|
280
|
+
global _WARNED_OLD_IMPORT
|
|
281
|
+
if _WARNED_OLD_IMPORT or "tradeflow" not in sys.modules:
|
|
282
|
+
return
|
|
283
|
+
if not any(type(finder).__name__ == "_AliasFinder" for finder in sys.meta_path):
|
|
284
|
+
return
|
|
285
|
+
_WARNED_OLD_IMPORT = True
|
|
286
|
+
logger.warning(
|
|
287
|
+
"A pack imports the old `tradeflow` name; it works through tradeflow-engine 2.3.1 - "
|
|
288
|
+
"change its imports to `quantred`."
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _select_entry_points(group: str) -> List[Any]:
|
|
293
|
+
"""Installed entry points in ``group``, or none if they cannot be enumerated.
|
|
294
|
+
|
|
295
|
+
Enumeration was the one step of discovery outside a ``try``, and it is the step
|
|
296
|
+
that reads installed distribution metadata - which can be malformed by a package
|
|
297
|
+
this project has never heard of. Because discovery runs at import, that took down
|
|
298
|
+
every command importing this module, which is every command: precisely the
|
|
299
|
+
"a broken private distribution must not make the open engine unusable" case the
|
|
300
|
+
rest of this file is written around.
|
|
301
|
+
"""
|
|
302
|
+
try:
|
|
303
|
+
entry_points = metadata.entry_points()
|
|
304
|
+
found: List[Any] = []
|
|
305
|
+
for name in entry_point_groups(group):
|
|
306
|
+
if hasattr(entry_points, "select"):
|
|
307
|
+
found.extend(entry_points.select(group=name))
|
|
308
|
+
else: # pragma: no cover - the dict-shaped API of older Pythons
|
|
309
|
+
found.extend(entry_points.get(name, ()))
|
|
310
|
+
return found
|
|
311
|
+
except Exception as exc: # noqa: BLE001 - built-ins only beats no engine at all
|
|
312
|
+
logger.warning("Cannot enumerate %s entry points (%s); built-in names only", group, exc)
|
|
313
|
+
return []
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _coerce_contributions(default_name: str, loaded: Any) -> Dict[str, Type]:
|
|
317
|
+
if isinstance(loaded, type):
|
|
318
|
+
return {default_name: loaded}
|
|
319
|
+
if isinstance(loaded, Mapping):
|
|
320
|
+
return dict(loaded)
|
|
321
|
+
if callable(loaded):
|
|
322
|
+
contributed = loaded()
|
|
323
|
+
if not isinstance(contributed, Mapping):
|
|
324
|
+
raise ValueError("factory entry point must return a mapping of name -> class")
|
|
325
|
+
return dict(contributed)
|
|
326
|
+
raise ValueError("entry point must load a class, mapping, or mapping factory")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _validate_extension(name: str, cls: Type, base_cls: Type, group: str) -> None:
|
|
330
|
+
if not isinstance(name, str) or not _NAME_RE.match(name):
|
|
331
|
+
raise ValueError(f"invalid extension name {name!r}")
|
|
332
|
+
if not isinstance(cls, type) or not issubclass(cls, base_cls) or cls is base_cls:
|
|
333
|
+
raise ValueError(f"{name!r} is not a concrete {base_cls.__name__} subclass")
|
|
334
|
+
if getattr(cls, "__abstractmethods__", None):
|
|
335
|
+
raise ValueError(f"{name!r} leaves abstract methods unimplemented")
|
|
336
|
+
if not isinstance(getattr(cls, "PARAM_RANGES", None), dict):
|
|
337
|
+
raise ValueError(f"{name!r} must declare PARAM_RANGES")
|
|
338
|
+
logger.info("Loaded %s extension %r from %s", group, name, cls.__module__)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _first_line(doc: str) -> str:
|
|
342
|
+
return (doc or "").strip().split("\n", 1)[0]
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# Discovery at import, guarded at import. A degraded engine that still runs the
|
|
346
|
+
# built-ins is worth more than an ImportError on every command, and the traceback is
|
|
347
|
+
# logged rather than swallowed so the cause stays recoverable.
|
|
348
|
+
try:
|
|
349
|
+
refresh_registries()
|
|
350
|
+
except Exception: # noqa: BLE001
|
|
351
|
+
logger.warning("Extension discovery failed; built-in strategies and scanners only", exc_info=True)
|