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,340 @@
|
|
|
1
|
+
"""Validation sandbox for proposals - the rules that keep an eager AI from
|
|
2
|
+
manufacturing overfit garbage at scale (its words would be more enthusiastic).
|
|
3
|
+
|
|
4
|
+
Two jobs:
|
|
5
|
+
|
|
6
|
+
1. **Research-hygiene gates** on every proposal: a rationale must be present, and
|
|
7
|
+
no more than :data:`MAX_TUNABLE_PARAMS` parameters may be varied. These are the
|
|
8
|
+
cheap, load-bearing rules that stop the loop manufacturing overfit garbage at
|
|
9
|
+
scale.
|
|
10
|
+
2. **Contract validation** of agent-authored strategy/scanner *code*: a strategy
|
|
11
|
+
must define a concrete :class:`~quantred.strategies.base.Strategy` subclass that
|
|
12
|
+
implements the abstract hooks, declares a valid ``PARAM_RANGES`` (<= 5
|
|
13
|
+
searchable), carries a docstring (the hypothesis), and actually constructs. A
|
|
14
|
+
scanner must define a concrete :class:`~quantred.scanners.base.ScannerStrategy`
|
|
15
|
+
subclass that emits the scanner signal vocabulary and a numeric
|
|
16
|
+
``signal_strength``.
|
|
17
|
+
|
|
18
|
+
Isolation note: generated code is validated and executed with a restricted global
|
|
19
|
+
namespace (no ``os``/``sys``/network imports, limited builtins). This is a
|
|
20
|
+
*proposal artifact*, never auto-merged. True OS-level isolation (separate process,
|
|
21
|
+
no network, resource limits) is the production hardening called for in and is
|
|
22
|
+
left as a deliberate follow-up; :func:`load_strategy_from_code` and
|
|
23
|
+
:func:`load_scanner_from_code` are the choke points where that would be enforced.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import builtins as _builtins
|
|
27
|
+
from collections.abc import Mapping
|
|
28
|
+
from typing import Optional, Tuple, Type
|
|
29
|
+
|
|
30
|
+
from quantred.optimization.param_space import ParameterSpace
|
|
31
|
+
from quantred.scanners.base import SCANNER_BUY, SCANNER_HOLD, SCANNER_SELL, ScannerStrategy
|
|
32
|
+
from quantred.strategies.base import Strategy
|
|
33
|
+
|
|
34
|
+
#: Hard cap on tunable parameters per strategy (more knobs = more overfit surface).
|
|
35
|
+
MAX_TUNABLE_PARAMS = 5
|
|
36
|
+
|
|
37
|
+
#: Config keys the execution path reads off every strategy, regardless of mechanism:
|
|
38
|
+
#: ``calculate_position_size`` needs the first two, and the backtest engine and live
|
|
39
|
+
#: trader both read ``take_profit`` when opening a position. A generated strategy that
|
|
40
|
+
#: omits them passes every other check and then raises on the first bar - producing a
|
|
41
|
+
#: zero-trade run that is indistinguishable from "no edge" unless we catch it here.
|
|
42
|
+
REQUIRED_CONFIG_KEYS = ("risk_per_trade", "stop_loss", "take_profit")
|
|
43
|
+
|
|
44
|
+
#: Import names generated strategy/scanner code is allowed to use.
|
|
45
|
+
#:
|
|
46
|
+
#: The list exists to keep generated code away from the filesystem, the process, the
|
|
47
|
+
#: network and the broker. ``typing`` and ``collections.abc`` reach none of those -
|
|
48
|
+
#: they are annotation vocabulary and grant no capability - and leaving them off
|
|
49
|
+
#: rejected every strategy and scanner this package ships, since all of them annotate
|
|
50
|
+
#: their ``PARAM_RANGES``. A validator that refuses the repository's own examples is
|
|
51
|
+
#: measuring its allowlist, not the draft.
|
|
52
|
+
_ALLOWED_IMPORTS = {
|
|
53
|
+
"pandas",
|
|
54
|
+
"numpy",
|
|
55
|
+
"math",
|
|
56
|
+
"typing",
|
|
57
|
+
"collections.abc",
|
|
58
|
+
"quantred.indicators.indicators",
|
|
59
|
+
"quantred.indicators",
|
|
60
|
+
"quantred.scanners.base",
|
|
61
|
+
"quantred.scanners",
|
|
62
|
+
"quantred.strategies.base",
|
|
63
|
+
"quantred.strategies.signals",
|
|
64
|
+
"quantred.strategies",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#: Builtins denied to generated code (no filesystem / process / eval surface).
|
|
68
|
+
_DENIED_BUILTINS = {
|
|
69
|
+
"open",
|
|
70
|
+
"exec",
|
|
71
|
+
"eval",
|
|
72
|
+
"compile",
|
|
73
|
+
"__import__",
|
|
74
|
+
"input",
|
|
75
|
+
"globals",
|
|
76
|
+
"locals",
|
|
77
|
+
"vars",
|
|
78
|
+
"memoryview",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class HygieneError(ValueError):
|
|
83
|
+
"""A proposal violated a research-hygiene rule."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def validate_hygiene(proposal, strategy_cls: Optional[Type[Strategy]] = None) -> Tuple[bool, str]:
|
|
87
|
+
"""Check a proposal against the hygiene rules; return ``(ok, reason)``.
|
|
88
|
+
|
|
89
|
+
Never raises - the caller journals the reason and skips the proposal.
|
|
90
|
+
"""
|
|
91
|
+
if not (proposal.hypothesis or "").strip():
|
|
92
|
+
return False, "missing hypothesis (rationale required before evaluation)"
|
|
93
|
+
|
|
94
|
+
if proposal.kind == "tune":
|
|
95
|
+
if strategy_cls is None:
|
|
96
|
+
return False, "tune proposal without a known strategy class"
|
|
97
|
+
if not isinstance(proposal.params, Mapping):
|
|
98
|
+
return False, f"params must be an object of name: value, got {type(proposal.params).__name__}"
|
|
99
|
+
tuned = proposal.tuned_params or list(proposal.params)
|
|
100
|
+
if len(tuned) > MAX_TUNABLE_PARAMS:
|
|
101
|
+
return False, f"{len(tuned)} tuned params exceeds the cap of {MAX_TUNABLE_PARAMS}"
|
|
102
|
+
ranges = strategy_cls.PARAM_RANGES
|
|
103
|
+
for name, value in proposal.params.items():
|
|
104
|
+
if name not in ranges:
|
|
105
|
+
return False, f"unknown parameter {name!r}"
|
|
106
|
+
reason = _value_problem(name, value, ranges[name])
|
|
107
|
+
if reason:
|
|
108
|
+
return False, reason
|
|
109
|
+
return True, "ok"
|
|
110
|
+
|
|
111
|
+
if proposal.kind == "code":
|
|
112
|
+
if not isinstance(proposal.code, str) or not proposal.code.strip():
|
|
113
|
+
return False, "code proposal without source"
|
|
114
|
+
return True, "ok"
|
|
115
|
+
|
|
116
|
+
return False, f"unknown proposal kind {proposal.kind!r}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _value_problem(name: str, value, spec) -> Optional[str]:
|
|
120
|
+
"""Why ``value`` cannot be this parameter's value, or ``None`` when it can.
|
|
121
|
+
|
|
122
|
+
The declared type is checked before the range: ``10.5`` for an integer parameter
|
|
123
|
+
used to be evaluated and saved as ``10.5``, while ``True`` (a bool is an ``int`` to
|
|
124
|
+
Python) was reported as "outside the range" - a wrong reason that hid the real one.
|
|
125
|
+
"""
|
|
126
|
+
if not isinstance(spec, Mapping):
|
|
127
|
+
return None
|
|
128
|
+
declared = spec.get("type")
|
|
129
|
+
if not (declared in ("int", "float") or ("min" in spec and "max" in spec)):
|
|
130
|
+
return None
|
|
131
|
+
expected = declared or "a number"
|
|
132
|
+
if isinstance(value, bool):
|
|
133
|
+
return f"{name}={value} is a boolean; expected {expected}"
|
|
134
|
+
if not isinstance(value, (int, float)):
|
|
135
|
+
return f"{name}={value!r} is not a number; expected {expected}"
|
|
136
|
+
if declared == "int" and isinstance(value, float) and not value.is_integer():
|
|
137
|
+
return f"{name}={value} is not an integer; {name} is declared int"
|
|
138
|
+
if "min" in spec and "max" in spec and not (spec["min"] <= value <= spec["max"]):
|
|
139
|
+
return f"{name}={value} outside [{spec['min']}, {spec['max']}]"
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def load_strategy_from_code(code: str, *, class_name: Optional[str] = None) -> Type[Strategy]:
|
|
144
|
+
"""Compile generated source and return its validated ``Strategy`` subclass.
|
|
145
|
+
|
|
146
|
+
Raises :class:`HygieneError` if the code imports disallowed modules, fails the
|
|
147
|
+
abstract-method contract, declares > 5 searchable params, lacks a docstring,
|
|
148
|
+
or does not construct from its declared defaults.
|
|
149
|
+
"""
|
|
150
|
+
cls = _load_class_from_code(code, Strategy, "strategy", class_name)
|
|
151
|
+
_validate_strategy_contract(cls)
|
|
152
|
+
return cls
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def load_scanner_from_code(code: str, *, class_name: Optional[str] = None) -> Type[ScannerStrategy]:
|
|
156
|
+
"""Compile generated source and return its validated ``ScannerStrategy`` subclass.
|
|
157
|
+
|
|
158
|
+
Raises :class:`HygieneError` if the code imports disallowed modules, fails the
|
|
159
|
+
abstract-method contract, declares too many searchable params, lacks a docstring,
|
|
160
|
+
or does not emit a scanner signal frame on sample OHLCV data.
|
|
161
|
+
"""
|
|
162
|
+
cls = _load_class_from_code(code, ScannerStrategy, "scanner", class_name)
|
|
163
|
+
_validate_scanner_contract(cls)
|
|
164
|
+
return cls
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _load_class_from_code(code: str, base_cls: Type, label: str, class_name: Optional[str] = None) -> Type:
|
|
168
|
+
namespace: dict = {"__builtins__": _restricted_builtins()}
|
|
169
|
+
namespace["__import__"] = _guarded_import
|
|
170
|
+
try:
|
|
171
|
+
compiled = compile(code, f"<generated-{label}>", "exec")
|
|
172
|
+
exec(compiled, namespace) # noqa: S102 - restricted namespace, validated below
|
|
173
|
+
except Exception as exc: # noqa: BLE001 - any failure is a rejection, not a crash
|
|
174
|
+
raise HygieneError(f"generated code failed to import: {exc}") from exc
|
|
175
|
+
|
|
176
|
+
candidates = [
|
|
177
|
+
obj
|
|
178
|
+
for obj in namespace.values()
|
|
179
|
+
if isinstance(obj, type) and issubclass(obj, base_cls) and obj is not base_cls
|
|
180
|
+
]
|
|
181
|
+
if class_name:
|
|
182
|
+
candidates = [c for c in candidates if c.__name__ == class_name]
|
|
183
|
+
if not candidates:
|
|
184
|
+
raise HygieneError(f"no concrete {base_cls.__name__} subclass defined")
|
|
185
|
+
return candidates[-1]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _validate_common_contract(cls: Type, base_name: str) -> None:
|
|
189
|
+
if getattr(cls, "__abstractmethods__", None):
|
|
190
|
+
raise HygieneError(
|
|
191
|
+
f"{cls.__name__} leaves abstract methods unimplemented: {sorted(cls.__abstractmethods__)}"
|
|
192
|
+
)
|
|
193
|
+
if not (cls.__doc__ or "").strip():
|
|
194
|
+
raise HygieneError(f"{cls.__name__} has no docstring (the hypothesis is required)")
|
|
195
|
+
if not isinstance(getattr(cls, "PARAM_RANGES", None), dict):
|
|
196
|
+
raise HygieneError(f"{cls.__name__} must declare a PARAM_RANGES dict")
|
|
197
|
+
# Every rejection leaves here as a HygieneError, including a malformed spec.
|
|
198
|
+
# `PARAM_RANGES = {"lookback": 20}` is what generated code actually writes, and
|
|
199
|
+
# reading it raised a bare TypeError out of ParameterSpace - through validators
|
|
200
|
+
# whose entire contract is to answer "is this valid?" with a verdict.
|
|
201
|
+
for param, spec in cls.PARAM_RANGES.items():
|
|
202
|
+
if not isinstance(spec, Mapping):
|
|
203
|
+
raise HygieneError(
|
|
204
|
+
f"{cls.__name__} parameter {param!r} must be a spec mapping with "
|
|
205
|
+
f"min/max/step/default, not a bare {type(spec).__name__}"
|
|
206
|
+
)
|
|
207
|
+
try:
|
|
208
|
+
space = ParameterSpace.for_class(cls)
|
|
209
|
+
except ValueError as exc:
|
|
210
|
+
# Same contract as the malformed-spec rejection above: a validator whose whole
|
|
211
|
+
# job is to answer "is this valid?" must answer it, not raise something else.
|
|
212
|
+
# A draft can declare constraints, and a constraint naming a parameter that
|
|
213
|
+
# does not exist is exactly the kind of thing generated code writes.
|
|
214
|
+
raise HygieneError(f"{cls.__name__} declares an unusable PARAM_CONSTRAINTS: {exc}") from exc
|
|
215
|
+
if len(space.searchable) > MAX_TUNABLE_PARAMS:
|
|
216
|
+
# Deliberately stricter than the curated strategies this package ships: a
|
|
217
|
+
# draft is an unreviewed proposal, and knobs are overfit surface. Two shipped
|
|
218
|
+
# strategies exceed it, which is why the shipped-artifact test below admits
|
|
219
|
+
# this one rejection and no other.
|
|
220
|
+
raise HygieneError(
|
|
221
|
+
f"{cls.__name__} has {len(space.searchable)} searchable params "
|
|
222
|
+
f"(draft {base_name} cap is {MAX_TUNABLE_PARAMS})"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _defaults(cls: Type) -> dict:
|
|
227
|
+
return {name: spec["default"] for name, spec in cls.PARAM_RANGES.items() if "default" in spec}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _validate_strategy_contract(cls: Type[Strategy]) -> None:
|
|
231
|
+
_validate_common_contract(cls, "strategy")
|
|
232
|
+
# Must construct from its own defaults.
|
|
233
|
+
defaults = _defaults(cls)
|
|
234
|
+
defaults.setdefault("timeframe", getattr(cls, "TIMEFRAME", "1Day"))
|
|
235
|
+
try:
|
|
236
|
+
instance = cls(dict(defaults))
|
|
237
|
+
except Exception as exc: # noqa: BLE001
|
|
238
|
+
raise HygieneError(f"{cls.__name__} does not construct from defaults: {exc}") from exc
|
|
239
|
+
|
|
240
|
+
# Sizing and exit handling read these off the config on every bar. Missing keys
|
|
241
|
+
# would surface as a silent zero-trade run, not an error, so reject here instead.
|
|
242
|
+
missing = [key for key in REQUIRED_CONFIG_KEYS if key not in instance.config]
|
|
243
|
+
if missing:
|
|
244
|
+
raise HygieneError(
|
|
245
|
+
f"{cls.__name__} does not declare required config {missing} "
|
|
246
|
+
f"(the execution path reads {list(REQUIRED_CONFIG_KEYS)} on every bar)"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _validate_scanner_contract(cls: Type[ScannerStrategy]) -> None:
|
|
251
|
+
_validate_common_contract(cls, "scanner")
|
|
252
|
+
try:
|
|
253
|
+
instance = cls(_defaults(cls))
|
|
254
|
+
instance.initialize()
|
|
255
|
+
except Exception as exc: # noqa: BLE001
|
|
256
|
+
raise HygieneError(f"{cls.__name__} does not construct from defaults: {exc}") from exc
|
|
257
|
+
|
|
258
|
+
import pandas as pd
|
|
259
|
+
|
|
260
|
+
sample = _scanner_sample(cls, instance)
|
|
261
|
+
try:
|
|
262
|
+
signals = instance.generate_signals_df(instance.process_data(sample))
|
|
263
|
+
except Exception as exc: # noqa: BLE001
|
|
264
|
+
raise HygieneError(f"{cls.__name__} cannot produce scanner signals on sample data: {exc}") from exc
|
|
265
|
+
required = {"signal", "signal_strength"}
|
|
266
|
+
if not required <= set(signals.columns):
|
|
267
|
+
raise HygieneError(f"{cls.__name__} scanner signals must include {sorted(required)}")
|
|
268
|
+
valid_signals = {SCANNER_BUY, SCANNER_SELL, SCANNER_HOLD}
|
|
269
|
+
emitted = set(signals["signal"].dropna())
|
|
270
|
+
if not emitted <= valid_signals:
|
|
271
|
+
raise HygieneError(f"{cls.__name__} emits unknown scanner signals: {sorted(emitted - valid_signals)}")
|
|
272
|
+
try:
|
|
273
|
+
pd.to_numeric(signals["signal_strength"])
|
|
274
|
+
except Exception as exc: # noqa: BLE001
|
|
275
|
+
raise HygieneError(f"{cls.__name__} signal_strength must be numeric") from exc
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _scanner_sample(cls: Type, instance):
|
|
279
|
+
"""OHLCV long enough for a scanner's own warm-up, and eventful enough to move it.
|
|
280
|
+
|
|
281
|
+
A fixed five-bar frame was neither, and every scanner needs more than five bars.
|
|
282
|
+
One that guards on ``required_data_points()`` was rejected for a sample too short
|
|
283
|
+
to scan; one that does not returned an all-NaN, all-HOLD frame - which passes the
|
|
284
|
+
signal-vocabulary check trivially, because ``{HOLD}`` is a subset of the
|
|
285
|
+
vocabulary. So the check could not fail, for any scanner, in either direction.
|
|
286
|
+
|
|
287
|
+
The tail carries a volume spike on a decisive up-bar because a scanner that never
|
|
288
|
+
emits an actionable label is a scanner whose actionable labels went unchecked.
|
|
289
|
+
"""
|
|
290
|
+
import pandas as pd
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
required = int(instance.required_data_points())
|
|
294
|
+
except Exception as exc: # noqa: BLE001 - a scanner that cannot say is not valid
|
|
295
|
+
raise HygieneError(f"{cls.__name__} cannot report required_data_points: {exc}") from exc
|
|
296
|
+
if required < 1:
|
|
297
|
+
raise HygieneError(f"{cls.__name__} reports required_data_points={required}")
|
|
298
|
+
|
|
299
|
+
n = max(required + 5, 30)
|
|
300
|
+
opens = [100.0 + i * 0.25 for i in range(n)]
|
|
301
|
+
closes = [price + 0.1 for price in opens]
|
|
302
|
+
volumes = [300_000.0] * n
|
|
303
|
+
# The event: a decisive up-bar on heavy volume, so thresholds on ratio, move and
|
|
304
|
+
# liquidity are all cleared at once and an actionable label is actually reachable.
|
|
305
|
+
opens[-1], closes[-1] = 100.0, 103.0
|
|
306
|
+
volumes[-1] = 2_000_000.0
|
|
307
|
+
return pd.DataFrame(
|
|
308
|
+
{
|
|
309
|
+
"open": opens,
|
|
310
|
+
"high": [max(o, c) + 1.0 for o, c in zip(opens, closes)],
|
|
311
|
+
"low": [min(o, c) - 1.0 for o, c in zip(opens, closes)],
|
|
312
|
+
"close": closes,
|
|
313
|
+
"volume": volumes,
|
|
314
|
+
},
|
|
315
|
+
index=pd.date_range("2024-01-02", periods=n, freq="D"),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _guarded_import(name, *args, **kwargs):
|
|
320
|
+
root = name.split(".")[0]
|
|
321
|
+
if root == "tradeflow":
|
|
322
|
+
# Older prompts and drafts name the package this one replaced; say where the
|
|
323
|
+
# module went instead of reporting a permission problem that does not exist.
|
|
324
|
+
raise HygieneError(
|
|
325
|
+
f"generated code may not import {name!r}: the package is now `quantred`, "
|
|
326
|
+
f"so import from `quantred` ({name.replace('tradeflow', 'quantred', 1)!r})"
|
|
327
|
+
)
|
|
328
|
+
if name in _ALLOWED_IMPORTS or root in {"pandas", "numpy", "math"} or name.startswith("quantred."):
|
|
329
|
+
if name.startswith("quantred.") and name not in _ALLOWED_IMPORTS and root == "quantred":
|
|
330
|
+
# Only allow the explicitly whitelisted quantred.* modules.
|
|
331
|
+
if not any(name == allowed or name.startswith(allowed + ".") for allowed in _ALLOWED_IMPORTS):
|
|
332
|
+
raise HygieneError(f"generated code may not import {name!r}")
|
|
333
|
+
return _builtins.__import__(name, *args, **kwargs)
|
|
334
|
+
raise HygieneError(f"generated code may not import {name!r}")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _restricted_builtins() -> dict:
|
|
338
|
+
allowed = {k: getattr(_builtins, k) for k in dir(_builtins) if k not in _DENIED_BUILTINS}
|
|
339
|
+
allowed["__import__"] = _guarded_import
|
|
340
|
+
return allowed
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Risk model: the covariance matrix Σ and the quantities built on it.
|
|
2
|
+
|
|
3
|
+
Active management needs a structural answer to "how risky is this portfolio?" that
|
|
4
|
+
respects the fact that risk is not additive. This module estimates an annualized,
|
|
5
|
+
well-conditioned, invertible Σ over a universe (Ledoit–Wolf shrinkage in v1) and
|
|
6
|
+
exposes portfolio variance, tracking error, and marginal contribution to risk.
|
|
7
|
+
Research-clock only: Σ sizes conviction, it never reaches the order path.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from quantred.risk.base import RiskMatrix, RiskModel, build_return_panel, build_risk_matrix
|
|
11
|
+
from quantred.risk.conditional import (
|
|
12
|
+
condition_risk_matrix,
|
|
13
|
+
default_lambda,
|
|
14
|
+
evaluate_vol_forecasts,
|
|
15
|
+
mincer_zarnowitz,
|
|
16
|
+
qlike_loss,
|
|
17
|
+
turnover_risk_share,
|
|
18
|
+
)
|
|
19
|
+
from quantred.risk.exposures import FACTOR_NAMES, build_factor_exposures
|
|
20
|
+
from quantred.risk.factor import FactorRiskMatrix, build_factor_risk_matrix, estimate_factor_model
|
|
21
|
+
from quantred.risk.sample import LedoitWolfCovariance, SampleCovariance
|
|
22
|
+
from quantred.risk.streaming import streaming_factor_risk_matrix, streaming_sample_covariance
|
|
23
|
+
|
|
24
|
+
#: Conditional-vol backends accepted by ``build_risk_matrix``/``build_factor_risk_matrix``.
|
|
25
|
+
CONDITIONAL_VOL_MODELS = ("ewma", "har")
|
|
26
|
+
|
|
27
|
+
#: Statistical estimators by name (the `RiskModel.estimate` interface).
|
|
28
|
+
RISK_MODELS = {
|
|
29
|
+
"shrinkage": LedoitWolfCovariance,
|
|
30
|
+
"sample": SampleCovariance,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#: Every covariance model name the surfaces accept ("factor" is structural, built
|
|
34
|
+
#: from exposures rather than a plain estimator).
|
|
35
|
+
COVARIANCE_MODELS = (*RISK_MODELS, "factor")
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"RiskMatrix",
|
|
39
|
+
"RiskModel",
|
|
40
|
+
"build_return_panel",
|
|
41
|
+
"build_risk_matrix",
|
|
42
|
+
"LedoitWolfCovariance",
|
|
43
|
+
"SampleCovariance",
|
|
44
|
+
"RISK_MODELS",
|
|
45
|
+
"COVARIANCE_MODELS",
|
|
46
|
+
"FACTOR_NAMES",
|
|
47
|
+
"build_factor_exposures",
|
|
48
|
+
"FactorRiskMatrix",
|
|
49
|
+
"estimate_factor_model",
|
|
50
|
+
"build_factor_risk_matrix",
|
|
51
|
+
"streaming_sample_covariance",
|
|
52
|
+
"streaming_factor_risk_matrix",
|
|
53
|
+
"condition_risk_matrix",
|
|
54
|
+
"default_lambda",
|
|
55
|
+
"evaluate_vol_forecasts",
|
|
56
|
+
"mincer_zarnowitz",
|
|
57
|
+
"qlike_loss",
|
|
58
|
+
"turnover_risk_share",
|
|
59
|
+
"CONDITIONAL_VOL_MODELS",
|
|
60
|
+
]
|
quantred/risk/base.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Risk model core - the covariance matrix Σ and the quantities built on it.
|
|
2
|
+
|
|
3
|
+
A portfolio's risk is **not** additive: two names that move together are one bet,
|
|
4
|
+
two that move oppositely are a hedge. The covariance matrix Σ is what lets the
|
|
5
|
+
optimizer (and the diagnostics) tell the difference - compute portfolio variance,
|
|
6
|
+
tracking error, and each name's marginal contribution to risk.
|
|
7
|
+
|
|
8
|
+
This module owns the shared types: :class:`RiskMatrix` (an annualized Σ plus the
|
|
9
|
+
derived quantities) and :class:`RiskModel` (the estimator interface). Concrete
|
|
10
|
+
estimators live in :mod:`quantred.risk.sample`. Everything is research-clock: Σ is a tool
|
|
11
|
+
for sizing conviction, never consulted to place an order.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any, Dict, List, Mapping, Optional, Tuple, Union
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
import pandas as pd
|
|
20
|
+
|
|
21
|
+
Weights = Union[Mapping[str, float], np.ndarray]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class RiskMatrix:
|
|
26
|
+
"""An annualized covariance matrix Σ over a universe, plus risk math on it."""
|
|
27
|
+
|
|
28
|
+
symbols: List[str]
|
|
29
|
+
sigma: np.ndarray # N×N annualized covariance, positive-definite
|
|
30
|
+
shrinkage: Optional[float] = None # δ used (Ledoit–Wolf), for audit
|
|
31
|
+
#: Set when Σ was conditioned: method, λ, per-name D_t vs the
|
|
32
|
+
#: unconditional diagonal (the "sigma_regime" diagnostic). ``None`` unconditional.
|
|
33
|
+
conditional_diagnostics: Optional[Dict[str, Any]] = None
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
self._index = {sym: i for i, sym in enumerate(self.symbols)}
|
|
37
|
+
|
|
38
|
+
# ------------------------------------------------------------------ #
|
|
39
|
+
# Weight handling
|
|
40
|
+
# ------------------------------------------------------------------ #
|
|
41
|
+
def _vector(self, weights: Weights) -> np.ndarray:
|
|
42
|
+
"""Align a weight mapping to Σ's symbol order (missing names → 0)."""
|
|
43
|
+
if isinstance(weights, np.ndarray):
|
|
44
|
+
if weights.shape != (len(self.symbols),):
|
|
45
|
+
raise ValueError(f"weight vector must have length {len(self.symbols)}")
|
|
46
|
+
return weights.astype(float)
|
|
47
|
+
vec = np.zeros(len(self.symbols))
|
|
48
|
+
for sym, w in weights.items():
|
|
49
|
+
idx = self._index.get(sym)
|
|
50
|
+
if idx is not None:
|
|
51
|
+
vec[idx] = w
|
|
52
|
+
return vec
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------------ #
|
|
55
|
+
# Risk quantities (Σ is annualized, so these are annualized too)
|
|
56
|
+
# ------------------------------------------------------------------ #
|
|
57
|
+
def variance(self, weights: Weights) -> float:
|
|
58
|
+
"""Portfolio variance ``wᵀ Σ w``."""
|
|
59
|
+
w = self._vector(weights)
|
|
60
|
+
return float(w @ self.sigma @ w)
|
|
61
|
+
|
|
62
|
+
def volatility(self, weights: Weights) -> float:
|
|
63
|
+
"""Portfolio volatility ``√(wᵀ Σ w)`` (annualized)."""
|
|
64
|
+
return float(np.sqrt(max(self.variance(weights), 0.0)))
|
|
65
|
+
|
|
66
|
+
def tracking_error(self, weights: Weights, benchmark: Weights) -> float:
|
|
67
|
+
"""Tracking error ``√(w_aᵀ Σ w_a)`` for active weights ``w_a = w − w_B``."""
|
|
68
|
+
active = self._vector(weights) - self._vector(benchmark)
|
|
69
|
+
return float(np.sqrt(max(active @ self.sigma @ active, 0.0)))
|
|
70
|
+
|
|
71
|
+
def implied_beta(self, benchmark: Weights) -> pd.Series:
|
|
72
|
+
"""The Σ-implied benchmark beta per name: ``β = Σw_B / (w_Bᵀ Σ w_B)``.
|
|
73
|
+
|
|
74
|
+
The one canonical beta for anything benchmark-portfolio-relative (reverse
|
|
75
|
+
optimization, active-beta diagnostics, alpha neutralization) - this is
|
|
76
|
+
"one β, everywhere": per-name regression betas (the alpha
|
|
77
|
+
pipeline's ``beta`` feature) are a different, complementary quantity and
|
|
78
|
+
must not be mixed with this one. Zero vector when the benchmark carries no
|
|
79
|
+
risk (``w_B = 0`` or degenerate Σ), so callers reduce to "no benchmark"
|
|
80
|
+
rather than dividing by zero.
|
|
81
|
+
"""
|
|
82
|
+
wb = self._vector(benchmark)
|
|
83
|
+
denom = float(wb @ self.sigma @ wb)
|
|
84
|
+
if denom <= 0:
|
|
85
|
+
return pd.Series(0.0, index=self.symbols)
|
|
86
|
+
return pd.Series((self.sigma @ wb) / denom, index=self.symbols)
|
|
87
|
+
|
|
88
|
+
def marginal_contribution_to_risk(
|
|
89
|
+
self, weights: Weights, benchmark: Optional[Weights] = None
|
|
90
|
+
) -> pd.Series:
|
|
91
|
+
"""Per-name MCR ``(Σ w_a) / ψ`` - ``∂ψ/∂w_a``, the risk gradient."""
|
|
92
|
+
active = self._vector(weights)
|
|
93
|
+
if benchmark is not None:
|
|
94
|
+
active = active - self._vector(benchmark)
|
|
95
|
+
te = float(np.sqrt(max(active @ self.sigma @ active, 0.0)))
|
|
96
|
+
if te == 0:
|
|
97
|
+
return pd.Series(0.0, index=self.symbols)
|
|
98
|
+
return pd.Series((self.sigma @ active) / te, index=self.symbols)
|
|
99
|
+
|
|
100
|
+
def correlation(self) -> pd.DataFrame:
|
|
101
|
+
"""The implied correlation matrix (Σ scaled by its diagonal std)."""
|
|
102
|
+
std = np.sqrt(np.diag(self.sigma))
|
|
103
|
+
denom = np.outer(std, std)
|
|
104
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
105
|
+
corr = np.where(denom > 0, self.sigma / denom, 0.0)
|
|
106
|
+
return pd.DataFrame(corr, index=self.symbols, columns=self.symbols)
|
|
107
|
+
|
|
108
|
+
def volatilities(self) -> pd.Series:
|
|
109
|
+
"""Annualized volatility per name (√diagonal of Σ)."""
|
|
110
|
+
return pd.Series(np.sqrt(np.diag(self.sigma)), index=self.symbols)
|
|
111
|
+
|
|
112
|
+
def condition_number(self) -> float:
|
|
113
|
+
"""Condition number of Σ - how close to singular (the invertibility guard)."""
|
|
114
|
+
return float(np.linalg.cond(self.sigma))
|
|
115
|
+
|
|
116
|
+
def is_positive_definite(self) -> bool:
|
|
117
|
+
"""Whether Σ is positive-definite (so ``Σ⁻¹`` exists for the optimizer)."""
|
|
118
|
+
try:
|
|
119
|
+
np.linalg.cholesky(self.sigma)
|
|
120
|
+
return True
|
|
121
|
+
except np.linalg.LinAlgError:
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class RiskModel(ABC):
|
|
126
|
+
"""Estimates a per-bar covariance over a returns panel (columns = symbols)."""
|
|
127
|
+
|
|
128
|
+
@abstractmethod
|
|
129
|
+
def estimate(self, returns: pd.DataFrame) -> Tuple[np.ndarray, Optional[float]]:
|
|
130
|
+
"""Return ``(Σ_per_bar, shrinkage)`` over complete-case ``returns`` columns."""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def build_return_panel(bars: Dict[str, pd.DataFrame], min_obs: int = 60) -> Tuple[pd.DataFrame, List[str]]:
|
|
134
|
+
"""Build an aligned (dates × symbols) return panel and the under-sampled names.
|
|
135
|
+
|
|
136
|
+
Names enter and leave the universe, so the raw panel is ragged. We keep names
|
|
137
|
+
with at least ``min_obs`` returns and align them on their common (complete-case)
|
|
138
|
+
dates; names below the floor are returned separately so the caller can apply the
|
|
139
|
+
documented fallback (a cross-sectional median variance) rather than drop them.
|
|
140
|
+
"""
|
|
141
|
+
returns = pd.DataFrame(
|
|
142
|
+
{sym: frame["close"].pct_change() for sym, frame in bars.items() if not frame.empty}
|
|
143
|
+
)
|
|
144
|
+
if returns.empty:
|
|
145
|
+
return returns, []
|
|
146
|
+
counts = returns.notna().sum()
|
|
147
|
+
kept = [sym for sym in returns.columns if counts[sym] >= min_obs]
|
|
148
|
+
under_sampled = [sym for sym in returns.columns if sym not in kept]
|
|
149
|
+
panel = returns[kept].dropna() if kept else returns.iloc[0:0]
|
|
150
|
+
return panel, under_sampled
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def build_risk_matrix(
|
|
154
|
+
model: RiskModel,
|
|
155
|
+
bars: Dict[str, pd.DataFrame],
|
|
156
|
+
periods_per_year: float,
|
|
157
|
+
min_obs: int = 60,
|
|
158
|
+
conditional: Optional[str] = None,
|
|
159
|
+
conditional_lambda: Optional[float] = None,
|
|
160
|
+
conditional_horizon: int = 1,
|
|
161
|
+
) -> Optional[RiskMatrix]:
|
|
162
|
+
"""Estimate an annualized :class:`RiskMatrix` over a universe's scanned bars.
|
|
163
|
+
|
|
164
|
+
Builds the aligned return panel, estimates Σ on the well-sampled names, annualizes
|
|
165
|
+
it, and splices in under-sampled names with the documented fallback - a
|
|
166
|
+
cross-sectional **median variance** and zero correlation - so the matrix spans the
|
|
167
|
+
full universe and stays positive-definite rather than dropping names silently.
|
|
168
|
+
Returns ``None`` if no name has enough history.
|
|
169
|
+
|
|
170
|
+
``conditional`` (default ``None`` / off) conditions the well-sampled
|
|
171
|
+
block's diagonal via an EWMA (``"ewma"``) or HAR-lite (``"har"``) per-name
|
|
172
|
+
volatility forecast, keeping the correlation structure fixed
|
|
173
|
+
(``Σ_t = D_t·R·D_t`` - see :mod:`quantred.risk.conditional`), *before* the
|
|
174
|
+
under-sampled splice below - so thin-history names keep the same
|
|
175
|
+
unconditional-fallback treatment regardless of conditioning.
|
|
176
|
+
"""
|
|
177
|
+
panel, under_sampled = build_return_panel(bars, min_obs=min_obs)
|
|
178
|
+
universe = [sym for sym in bars if not bars[sym].empty]
|
|
179
|
+
if panel.empty or panel.shape[0] < 2:
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
sigma_bar, shrinkage = model.estimate(panel)
|
|
183
|
+
sigma = sigma_bar * periods_per_year
|
|
184
|
+
kept = list(panel.columns)
|
|
185
|
+
matrix = RiskMatrix(symbols=kept, sigma=sigma, shrinkage=shrinkage)
|
|
186
|
+
if conditional:
|
|
187
|
+
from quantred.risk.conditional import condition_risk_matrix
|
|
188
|
+
|
|
189
|
+
matrix = condition_risk_matrix(
|
|
190
|
+
matrix,
|
|
191
|
+
panel,
|
|
192
|
+
conditional,
|
|
193
|
+
periods_per_year,
|
|
194
|
+
min_obs=min_obs,
|
|
195
|
+
lambda_=conditional_lambda,
|
|
196
|
+
horizon=conditional_horizon,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if not under_sampled:
|
|
200
|
+
return matrix
|
|
201
|
+
|
|
202
|
+
# Fallback for thin-history names: independent, at the median estimated variance.
|
|
203
|
+
median_var = float(np.median(np.diag(matrix.sigma))) if matrix.sigma.size else 0.0
|
|
204
|
+
order = [sym for sym in universe if sym in kept or sym in under_sampled]
|
|
205
|
+
idx = {sym: i for i, sym in enumerate(kept)}
|
|
206
|
+
full = np.zeros((len(order), len(order)))
|
|
207
|
+
for a, sa in enumerate(order):
|
|
208
|
+
for b, sb in enumerate(order):
|
|
209
|
+
if sa in idx and sb in idx:
|
|
210
|
+
full[a, b] = matrix.sigma[idx[sa], idx[sb]]
|
|
211
|
+
if sa in under_sampled:
|
|
212
|
+
full[a, a] = median_var
|
|
213
|
+
return RiskMatrix(
|
|
214
|
+
symbols=order,
|
|
215
|
+
sigma=full,
|
|
216
|
+
shrinkage=matrix.shrinkage,
|
|
217
|
+
conditional_diagnostics=matrix.conditional_diagnostics,
|
|
218
|
+
)
|