quantred 2.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. quantred/__init__.py +24 -0
  2. quantred/alphas/__init__.py +53 -0
  3. quantred/alphas/base.py +221 -0
  4. quantred/alphas/combine.py +260 -0
  5. quantred/alphas/horizon.py +153 -0
  6. quantred/alphas/refine.py +300 -0
  7. quantred/alphas/scorers.py +87 -0
  8. quantred/analytics/attribution.py +354 -0
  9. quantred/analytics/bootstrap.py +353 -0
  10. quantred/analytics/charts.py +393 -0
  11. quantred/analytics/excursion.py +185 -0
  12. quantred/analytics/execution_quality.py +180 -0
  13. quantred/analytics/exposure.py +167 -0
  14. quantred/analytics/htmlreport.py +1208 -0
  15. quantred/analytics/information.py +197 -0
  16. quantred/analytics/metrics.py +502 -0
  17. quantred/analytics/performance.py +639 -0
  18. quantred/analytics/reporting.py +1672 -0
  19. quantred/analytics/screening.py +190 -0
  20. quantred/analytics/series_comparison.py +303 -0
  21. quantred/analytics/trade_analytics.py +353 -0
  22. quantred/brokers/alpaca/broker.py +415 -0
  23. quantred/brokers/alpaca/factory.py +33 -0
  24. quantred/brokers/alpaca/market_data.py +166 -0
  25. quantred/brokers/base.py +220 -0
  26. quantred/brokers/dryrun.py +164 -0
  27. quantred/brokers/errors.py +70 -0
  28. quantred/cli.py +8619 -0
  29. quantred/costs/__init__.py +11 -0
  30. quantred/costs/base.py +126 -0
  31. quantred/costs/parametric.py +131 -0
  32. quantred/data/__init__.py +29 -0
  33. quantred/data/compute.py +332 -0
  34. quantred/data/edges.py +81 -0
  35. quantred/data/features.py +120 -0
  36. quantred/data/panel.py +61 -0
  37. quantred/data/scan.py +85 -0
  38. quantred/data/store.py +269 -0
  39. quantred/demo/__init__.py +22 -0
  40. quantred/demo/scanners.py +104 -0
  41. quantred/demo/strategies.py +125 -0
  42. quantred/engine/backtest.py +1206 -0
  43. quantred/engine/barcheck.py +228 -0
  44. quantred/engine/live.py +539 -0
  45. quantred/execution/decision.py +192 -0
  46. quantred/execution/flatten.py +302 -0
  47. quantred/execution/halt.py +165 -0
  48. quantred/execution/ledger.py +758 -0
  49. quantred/execution/live_trader.py +587 -0
  50. quantred/execution/order_id.py +83 -0
  51. quantred/execution/sizing.py +114 -0
  52. quantred/indicators/indicators.py +158 -0
  53. quantred/marketdata/base.py +62 -0
  54. quantred/marketdata/client.py +59 -0
  55. quantred/marketdata/session.py +112 -0
  56. quantred/marketdata/synthetic.py +74 -0
  57. quantred/marketdata/timeframe.py +125 -0
  58. quantred/mcp/__init__.py +6 -0
  59. quantred/mcp/selftest.py +139 -0
  60. quantred/mcp/server.py +2419 -0
  61. quantred/optimization/causality.py +607 -0
  62. quantred/optimization/config_store.py +300 -0
  63. quantred/optimization/optimizer.py +431 -0
  64. quantred/optimization/parallel.py +345 -0
  65. quantred/optimization/param_space.py +309 -0
  66. quantred/optimization/walk_forward.py +1254 -0
  67. quantred/portfolio/allocator.py +135 -0
  68. quantred/portfolio/benchmark.py +109 -0
  69. quantred/portfolio/optimizer.py +786 -0
  70. quantred/portfolio/policy.py +335 -0
  71. quantred/portfolio/posterior.py +208 -0
  72. quantred/research/__init__.py +30 -0
  73. quantred/research/agent.py +868 -0
  74. quantred/research/demo_proposals.py +222 -0
  75. quantred/research/llm.py +311 -0
  76. quantred/research/proposer.py +234 -0
  77. quantred/research/provider_errors.py +242 -0
  78. quantred/research/sandbox.py +340 -0
  79. quantred/risk/__init__.py +60 -0
  80. quantred/risk/base.py +218 -0
  81. quantred/risk/conditional.py +474 -0
  82. quantred/risk/exposures.py +88 -0
  83. quantred/risk/factor.py +119 -0
  84. quantred/risk/sample.py +89 -0
  85. quantred/risk/streaming.py +214 -0
  86. quantred/scanners/base.py +141 -0
  87. quantred/scanners/symbol_scanner.py +148 -0
  88. quantred/services/__init__.py +51 -0
  89. quantred/services/analysis.py +6082 -0
  90. quantred/services/audit.py +574 -0
  91. quantred/services/campaign.py +381 -0
  92. quantred/services/configs.py +282 -0
  93. quantred/services/data.py +172 -0
  94. quantred/services/dryrun.py +206 -0
  95. quantred/services/glossary.py +130 -0
  96. quantred/services/maintenance.py +314 -0
  97. quantred/services/registry.py +351 -0
  98. quantred/services/setup.py +1023 -0
  99. quantred/services/sizing.py +150 -0
  100. quantred/services/smallreal.py +450 -0
  101. quantred/settings.py +571 -0
  102. quantred/store/__init__.py +0 -0
  103. quantred/store/bars.py +660 -0
  104. quantred/store/trials.py +1804 -0
  105. quantred/strategies/base.py +643 -0
  106. quantred/strategies/signals.py +28 -0
  107. quantred/utils/errors.py +48 -0
  108. quantred/utils/extras.py +330 -0
  109. quantred/utils/logging_config.py +33 -0
  110. quantred/utils/numeric.py +46 -0
  111. quantred/utils/streaming.py +474 -0
  112. quantred/utils/timeutils.py +148 -0
  113. quantred-2.4.0.dist-info/METADATA +632 -0
  114. quantred-2.4.0.dist-info/RECORD +117 -0
  115. quantred-2.4.0.dist-info/WHEEL +4 -0
  116. quantred-2.4.0.dist-info/entry_points.txt +8 -0
  117. quantred-2.4.0.dist-info/licenses/LICENSE +21 -0
@@ -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)