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,300 @@
1
+ """Shared config persistence with provenance - so when a config makes (or loses)
2
+ money, you can reconstruct exactly which decisions to blame.
3
+
4
+ Walk-forward (and, later, the MCP server) produces a *chosen config*
5
+ worth saving. This is the one serialization layer both use: plain JSON, with a
6
+ ``provenance`` block recording exactly how the config was produced so a human can
7
+ audit it before promoting it to live trading.
8
+
9
+ Saving a config **never** alters live behavior - it writes a file a human
10
+ chooses to use. Configs land in a gitignored ``configs/`` directory by default.
11
+ """
12
+
13
+ import json
14
+ import logging
15
+ import math
16
+ import subprocess
17
+ from dataclasses import asdict, dataclass, field
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import Any, Dict, List, Optional
21
+
22
+ from quantred.engine.backtest import ACCOUNTING_VERSION
23
+ from quantred.settings import state_root
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ #: Default directory for saved configs (gitignored).
28
+ DEFAULT_CONFIG_DIR = state_root() / "configs"
29
+
30
+
31
+ @dataclass
32
+ class Provenance:
33
+ """How a config was produced - the audit trail."""
34
+
35
+ method: str = "" # grid | random | bayesian
36
+ objective: str = ""
37
+ windows: Dict[str, Any] = field(default_factory=dict) # start/end/folds/holdout
38
+ oos_metrics: Dict[str, float] = field(default_factory=dict)
39
+ n_trials: int = 1
40
+ seed: Optional[int] = None
41
+ git_sha: Optional[str] = None
42
+ timestamp: Optional[str] = None
43
+ notes: str = ""
44
+ #: Engine accounting model behind ``oos_metrics``. Defaults to 1 so a config
45
+ #: written before this field existed loads as the original accounting model —
46
+ #: which is exactly what it is. :func:`build_provenance` always stamps the
47
+ #: current version.
48
+ accounting: int = 1
49
+ #: What validated this config, when it was materialised from a recorded trial:
50
+ #: the validation recipe, the evidence measured under one accounting era, and
51
+ #: metadata about the record. Assembled by
52
+ #: :func:`quantred.services.campaign.campaign_material`.
53
+ #:
54
+ #: A *field of this format*, deliberately, rather than a second artifact beside
55
+ #: it. Two provenance formats is the parity hazard this project has been bitten
56
+ #: by more than once, and a campaign export living somewhere else would be one
57
+ #: — the thing you carry forward has to be one file. Empty by default, so a
58
+ #: config written before this existed loads unchanged and reads as *not
59
+ #: materialised from a campaign*, which is what it is.
60
+ campaign: Dict[str, Any] = field(default_factory=dict)
61
+
62
+
63
+ def current_git_sha() -> Optional[str]:
64
+ """Best-effort identifier for the code that produced a result; ``None`` if unavailable.
65
+
66
+ This is a *working-tree* identifier, not just ``HEAD``. Callers use it to decide
67
+ whether a stored trial may be served instead of re-run, and HEAD alone cannot
68
+ answer that: uncommitted edits leave the SHA untouched, so a strategy edited but
69
+ not committed would match its own pre-edit result and be served as though the
70
+ change had been evaluated. That is the one thing the guard exists to prevent, and
71
+ it would fire exactly when iterating, which is when the tree is dirty.
72
+
73
+ So a dirty tree gets a ``-dirty`` suffix, which matches no stored row and forces a
74
+ fresh run. Failing toward a redundant run is always safe; silently skipping one
75
+ never is.
76
+ """
77
+ try:
78
+ out = subprocess.run(
79
+ ["git", "rev-parse", "--short", "HEAD"],
80
+ capture_output=True,
81
+ text=True,
82
+ timeout=5,
83
+ check=True,
84
+ )
85
+ sha = out.stdout.strip()
86
+ if not sha:
87
+ return None
88
+ status = subprocess.run(
89
+ ["git", "status", "--porcelain"],
90
+ capture_output=True,
91
+ text=True,
92
+ timeout=5,
93
+ check=True,
94
+ )
95
+ return f"{sha}-dirty" if status.stdout.strip() else sha
96
+ except (subprocess.SubprocessError, OSError):
97
+ return None
98
+
99
+
100
+ def build_provenance(
101
+ *,
102
+ method: str,
103
+ objective: str,
104
+ windows: Dict[str, Any],
105
+ oos_metrics: Dict[str, float],
106
+ n_trials: int = 1,
107
+ seed: Optional[int] = None,
108
+ timestamp: Optional[datetime] = None,
109
+ notes: str = "",
110
+ ) -> Provenance:
111
+ """Assemble a :class:`Provenance` record, stamping git SHA and time."""
112
+ stamp = (timestamp or datetime.now(timezone.utc)).isoformat()
113
+ return Provenance(
114
+ method=method,
115
+ objective=objective,
116
+ windows=_jsonable(windows),
117
+ oos_metrics=_jsonable(oos_metrics),
118
+ n_trials=int(n_trials),
119
+ seed=seed,
120
+ git_sha=current_git_sha(),
121
+ timestamp=stamp,
122
+ notes=notes,
123
+ accounting=ACCOUNTING_VERSION,
124
+ )
125
+
126
+
127
+ def save_config(
128
+ path,
129
+ *,
130
+ strategy: str,
131
+ params: Dict[str, Any],
132
+ scanner: Optional[str] = None,
133
+ symbols: Optional[Any] = None,
134
+ candidate_symbols: Optional[Any] = None,
135
+ capital: Optional[float] = None,
136
+ position_limits: Optional[Dict[str, Any]] = None,
137
+ cost: Optional[Dict[str, Any]] = None,
138
+ provenance: Optional[Provenance] = None,
139
+ generated_code: Optional[Dict[str, Any]] = None,
140
+ ) -> Path:
141
+ """Write the run configuration as JSON; return the path.
142
+
143
+ ``{strategy, scanner, symbols, candidate_symbols, capital, cost, params,
144
+ provenance}``. All but ``provenance`` are *inputs* - what to run - so one file can
145
+ configure a run whatever its type,
146
+ which is the point of a config a private repository versions alongside its
147
+ strategies. ``provenance`` is the opposite: a record of how the params were
148
+ arrived at, never read back as input.
149
+
150
+ ``position_limits`` is written out in full rather than left to the strategy's own
151
+ defaults. A config is the thing a paper or live run is frozen from, and the shipped
152
+ default of ``max_positions: 1`` is exactly the kind of inheritance nobody chose - a
153
+ 61-name config that quietly holds one position is a defect already found in
154
+ backtests, and a live run would repeat it with money. What a run risks should be in
155
+ the file, not resolved from somewhere else at start-up.
156
+
157
+ ``symbols`` is the universe the scanner **resolved**: the book that was validated,
158
+ and what a replay trades. ``candidate_symbols`` is the list it was resolved *from*,
159
+ kept because they are different decisions - re-running a scanner over the resolved
160
+ 61 names is a second filter over an already-filtered set, not the original
161
+ 85-candidate scan. Only the candidates make a genuine re-resolution possible.
162
+
163
+ The window is deliberately absent. A config carrying its own tuning dates would
164
+ make every later run re-evaluate that period by default, which is the one thing a
165
+ saved config must not quietly do; ``provenance.windows`` records what it was tuned
166
+ on, for reading rather than for replaying.
167
+
168
+ Keys whose value is ``None`` are omitted rather than written as null, so a config
169
+ that never had a universe is distinguishable from one that was saved without one.
170
+ A relative ``path`` with no directory is placed under :data:`DEFAULT_CONFIG_DIR`.
171
+
172
+ ``generated_code`` is the source of a strategy the research agent wrote
173
+ (``{"code", "code_hash", "class_name"}``), for a config whose ``strategy`` is a
174
+ ``draft:<Class>:<hash>`` identity. It is written under ``provenance`` and only
175
+ there: the identity is a hash, and the source must survive somewhere a human can
176
+ review it. Loading the config never executes it - the identity resolves to nothing.
177
+ Absent for every config that names a registered strategy.
178
+ """
179
+ path = Path(path)
180
+ if not path.is_absolute() and path.parent == Path("."):
181
+ path = DEFAULT_CONFIG_DIR / path
182
+ path.parent.mkdir(parents=True, exist_ok=True)
183
+
184
+ payload: Dict[str, Any] = {
185
+ "strategy": strategy,
186
+ "scanner": scanner,
187
+ "params": _jsonable(params),
188
+ "provenance": _nonfinite_as_text(asdict(provenance)) if provenance else {},
189
+ }
190
+ if generated_code:
191
+ payload["provenance"] = {**payload["provenance"], "generated_code": _jsonable(generated_code)}
192
+ for key, value in (
193
+ ("symbols", symbols),
194
+ ("candidate_symbols", candidate_symbols),
195
+ ("capital", capital),
196
+ ("position_limits", position_limits),
197
+ ("cost", cost),
198
+ ):
199
+ if value is not None:
200
+ payload[key] = _jsonable(value)
201
+ path.write_text(json.dumps(payload, indent=2, sort_keys=False))
202
+ logger.info("Saved config to %s", path)
203
+ return path
204
+
205
+
206
+ #: Every top-level key :func:`save_config` writes.
207
+ CONFIG_KEYS = (
208
+ "strategy",
209
+ "scanner",
210
+ "params",
211
+ "provenance",
212
+ "symbols",
213
+ "candidate_symbols",
214
+ "capital",
215
+ "position_limits",
216
+ "cost",
217
+ )
218
+
219
+
220
+ def unrecognised_keys(payload: Dict[str, Any]) -> List[str]:
221
+ """Top-level keys no writer of this format produces - usually a hand-edit's typo.
222
+
223
+ Reported, not refused: a key the loader does not read harms nothing on its own, and
224
+ an older file with an extra field should still load. It matters when something the
225
+ run needs is missing, because then the typo is the likeliest reason.
226
+ """
227
+ return [key for key in payload if key not in CONFIG_KEYS]
228
+
229
+
230
+ def load_config(path) -> Dict[str, Any]:
231
+ """Load a config JSON written by :func:`save_config`.
232
+
233
+ The returned ``params`` flow straight into ``strategy_class(params)``.
234
+
235
+ Warns when the recorded metrics predate the current accounting model: the
236
+ params are still valid, but the ``oos_metrics`` beside them were measured a
237
+ different way and must not be compared against a fresh run. Silent when there are
238
+ no recorded metrics - a hand-written config has nothing that could be incomparable.
239
+ """
240
+ payload = json.loads(Path(path).read_text())
241
+ provenance = payload.get("provenance") or {}
242
+ stored = provenance.get("accounting", 1)
243
+ # Only when there are metrics to be incomparable. A hand-written config - which is
244
+ # what everyone's first one is - carries no oos_metrics, and warning that *its*
245
+ # metrics predate the model claims something untrue. A warning that fires on a file
246
+ # with nothing wrong with it is how people learn to skip reading them.
247
+ if stored != ACCOUNTING_VERSION and provenance.get("oos_metrics"):
248
+ logger.warning(
249
+ "%s carries accounting v%s metrics but the engine is v%s — its recorded "
250
+ "oos_metrics are NOT comparable with a current run. Re-run the config to "
251
+ "get metrics on the current model.",
252
+ path,
253
+ stored,
254
+ ACCOUNTING_VERSION,
255
+ )
256
+ return payload
257
+
258
+
259
+ def is_current_accounting(payload: Dict[str, Any]) -> bool:
260
+ """Whether a loaded config's metrics were produced by the current engine.
261
+
262
+ Callers that rank or compare stored results should check this rather than
263
+ assume every record on disk measured the same thing.
264
+ """
265
+ return (payload.get("provenance") or {}).get("accounting", 1) == ACCOUNTING_VERSION
266
+
267
+
268
+ def _nonfinite_as_text(value: Any) -> Any:
269
+ """Non-finite floats as ``"inf"`` / ``"-inf"`` / ``"nan"``, the MCP surface's spelling.
270
+
271
+ ``json.dumps`` writes them as bare ``Infinity`` / ``NaN``, which is not JSON: a
272
+ strict reader refuses the whole file. A profit factor or tail ratio over a run with
273
+ no losing trade is genuinely infinite, so the value is kept as text rather than
274
+ dropped or clamped.
275
+ """
276
+ if isinstance(value, dict):
277
+ return {k: _nonfinite_as_text(v) for k, v in value.items()}
278
+ if isinstance(value, (list, tuple)):
279
+ return [_nonfinite_as_text(v) for v in value]
280
+ if isinstance(value, float) and not math.isfinite(value):
281
+ return "nan" if math.isnan(value) else ("inf" if value > 0 else "-inf")
282
+ return value
283
+
284
+
285
+ def _jsonable(value: Any) -> Any:
286
+ """Coerce numpy / datetime / nested values into JSON-native types."""
287
+ if isinstance(value, dict):
288
+ return {str(k): _jsonable(v) for k, v in value.items()}
289
+ if isinstance(value, (list, tuple)):
290
+ return [_jsonable(v) for v in value]
291
+ if isinstance(value, datetime):
292
+ return value.isoformat()
293
+ # numpy scalars expose .item(); fall back to the value itself.
294
+ item = getattr(value, "item", None)
295
+ if callable(item):
296
+ try:
297
+ return item()
298
+ except (ValueError, TypeError):
299
+ return value
300
+ return value