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,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
|