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