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,474 @@
1
+ """Conditional risk: a Σ that knows what month it is.
2
+
3
+ The base risk model's Σ is **unconditional over its trailing window** — a flat,
4
+ equal-weighted average since the start of the estimation window. The fix here is
5
+ old and cheap: **condition the volatilities, keep the correlation structure slow.**
6
+
7
+ Σ_t = D_t · R · D_t
8
+
9
+ ``R`` is the base model's own (shrunk/factor) correlation structure on the long
10
+ window; ``D_t`` is a diagonal of *conditional* per-name variances from an EWMA
11
+ (RiskMetrics λ≈0.94 daily / 0.97 weekly) or HAR-lite forecast. This is **not** a
12
+ multivariate GARCH — correlations stay slow on purpose (conditioning them buys
13
+ little at this horizon and costs stability).
14
+
15
+ Two backends compose with the base model's two estimators:
16
+
17
+ - **Shrinkage/sample** (:class:`~quantred.risk.base.RiskMatrix`): condition the diagonal,
18
+ keep the LW-shrunk (or raw) correlation matrix fixed — :func:`condition_risk_matrix`.
19
+ - **Factor** (:class:`~quantred.risk.factor.FactorRiskMatrix`): condition *both*
20
+ ``factor_cov`` (via an EWMA covariance on factor returns — small, ``K×K``, PSD by
21
+ construction) and ``specific_var`` (via the same per-name EWMA/HAR family used by
22
+ the shrinkage path) — never one without the other (a partial conditioning would
23
+ mis-split the factor/specific attribution).
24
+
25
+ Everything here is an **as-of forward pass**: state at bar ``t`` is seeded from the
26
+ first ``min_obs`` rows of the panel handed in (the base model's window) and then
27
+ updated row by row through the rest of the panel — so the result depends only on
28
+ rows at or before the panel's last timestamp, never on what comes after (verified
29
+ by a property test in ``tests/test_conditional_risk.py``, not just a code
30
+ review). This mirrors the streaming accumulator shape of :mod:`quantred.risk.streaming`
31
+ — a small O(N) / O(K²) state updated one row at a time — though, like
32
+ Ledoit–Wolf itself, the estimator here runs eagerly over a materialized panel; wiring
33
+ it to stream out of the Parquet store is future work, not done here.
34
+
35
+ **The alpha/risk vol seam.** :func:`quantred.data.features.add_risk_features`
36
+ computes a *different*, deliberately unconditional ``residual_vol`` that scales alphas
37
+ (``α = ω·IC·z``, Case 1) and the refinement's Case logic — that vol must stay slow. Only the
38
+ *risk*-model Σ used for portfolio construction / tracking-error conditions here. Do
39
+ not let the two merge into one "vol" concept.
40
+ """
41
+
42
+ from dataclasses import dataclass
43
+ from typing import Any, Dict, List, Optional
44
+
45
+ import numpy as np
46
+ import pandas as pd
47
+
48
+ from quantred.risk.base import RiskMatrix
49
+ from quantred.risk.factor import FactorRiskMatrix
50
+
51
+ #: RiskMetrics defaults, by cadence. Global, not per-name (cross-sectional λ
52
+ #: fitting overfits small histories).
53
+ EWMA_LAMBDA_DAILY = 0.94
54
+ EWMA_LAMBDA_WEEKLY = 0.97
55
+
56
+ #: A cadence below this many periods/year is "weekly or slower" for the λ default.
57
+ _WEEKLY_PPY_CEILING = 100.0
58
+
59
+
60
+ def default_lambda(periods_per_year: float) -> float:
61
+ """RiskMetrics' λ default for the bar cadence implied by ``periods_per_year``."""
62
+ return EWMA_LAMBDA_DAILY if periods_per_year > _WEEKLY_PPY_CEILING else EWMA_LAMBDA_WEEKLY
63
+
64
+
65
+ # --------------------------------------------------------------------------- #
66
+ # The vol layer: EWMA and HAR-lite per-name variance forecasts
67
+ # --------------------------------------------------------------------------- #
68
+ def ewma_variance_series(x: np.ndarray, lambda_: float, min_obs: int) -> np.ndarray:
69
+ """Per-column EWMA variance at every row ``t >= min_obs`` (rows ``< min_obs`` are
70
+ NaN — no seed yet). ``x`` is raw returns, ``(T, N)`` (mean-zero assumed, the
71
+ RiskMetrics convention). Row ``t``'s value depends only on ``x[:t+1]`` — the
72
+ as-of property.
73
+
74
+ Seeded from the raw (mean-zero-assumed) population variance of the first
75
+ ``min_obs`` rows (the base model's own window), then folded forward one row at a time via
76
+ the RiskMetrics recursion ``var_t = λ·var_{t-1} + (1-λ)·r_t²``. With ``λ = 1``
77
+ the recursion never updates past the seed, so ``D_t`` stays exactly the seed
78
+ window's variance no matter how much more data follows — the reduction test.
79
+ """
80
+ t, n = x.shape
81
+ out = np.full((t, n), np.nan)
82
+ if t < min_obs or min_obs < 1:
83
+ return out
84
+ var = np.mean(x[:min_obs] ** 2, axis=0)
85
+ out[min_obs - 1] = var
86
+ for i in range(min_obs, t):
87
+ var = lambda_ * var + (1.0 - lambda_) * x[i] ** 2
88
+ out[i] = var
89
+ return out
90
+
91
+
92
+ def ewma_variance_path(returns: pd.DataFrame, lambda_: float, min_obs: int) -> Optional[pd.Series]:
93
+ """Final per-name EWMA variance (per-bar, not annualized) as of ``returns``'
94
+ last row. Returns are used **raw, not demeaned** — the RiskMetrics convention
95
+ (daily/weekly return means are indistinguishable from zero at this horizon), and
96
+ deliberately so: demeaning by a window's own mean would make the seed depend on
97
+ every row in whatever window happened to be passed in, which breaks the as-of
98
+ forward-pass property once more rows are appended (see the λ=1 reduction test).
99
+ ``None`` if the panel is shorter than ``min_obs``.
100
+ """
101
+ x = returns.to_numpy(dtype=float)
102
+ series = ewma_variance_series(x, lambda_, min_obs)
103
+ if len(series) < min_obs:
104
+ return None
105
+ last = series[-1]
106
+ if np.isnan(last).any():
107
+ return None
108
+ return pd.Series(last, index=returns.columns)
109
+
110
+
111
+ def ewma_covariance_path(returns: pd.DataFrame, lambda_: float, min_obs: int) -> Optional[np.ndarray]:
112
+ """Final ``K×K`` EWMA covariance (per-bar) as of ``returns``' last row — the
113
+ small-matrix analogue of :func:`ewma_variance_path`, used to condition a factor
114
+ model's ``factor_cov`` (PSD by construction, a convex
115
+ combination of PSD matrices, never eigenvalue surgery). Raw (not demeaned)
116
+ returns, same convention as :func:`ewma_variance_path`.
117
+ """
118
+ x = returns.to_numpy(dtype=float)
119
+ t, k = x.shape
120
+ if t < min_obs or min_obs < 1:
121
+ return None
122
+ seed = x[:min_obs]
123
+ cov = (seed.T @ seed) / min_obs
124
+ for i in range(min_obs, t):
125
+ r = x[i]
126
+ cov = lambda_ * cov + (1.0 - lambda_) * np.outer(r, r)
127
+ return cov
128
+
129
+
130
+ def har_variance_forecast(returns: pd.Series, min_obs: int = 60) -> Optional[float]:
131
+ """HAR-lite one-step-ahead variance forecast (Corsi's Heterogeneous Autoregressive
132
+ model, daily/weekly(5)/monthly(22) realized-variance regressors) — the
133
+ term-structure-aware alternative to the EWMA's flat term structure.
134
+ OLS-fit on the trailing window handed in, forecasting the *next* bar's
135
+ ``r²`` from the current (day, week, month) realized-variance triple. ``None``
136
+ below ``min_obs`` usable rows.
137
+ """
138
+ r2 = returns.to_numpy(dtype=float) ** 2
139
+ t = len(r2)
140
+ if t <= min_obs:
141
+ return None
142
+ day = r2
143
+ week = pd.Series(r2).rolling(5, min_periods=5).mean().to_numpy()
144
+ month = pd.Series(r2).rolling(22, min_periods=22).mean().to_numpy()
145
+
146
+ x_day, x_week, x_month = day[:-1], week[:-1], month[:-1]
147
+ y = day[1:] # next bar's realized variance
148
+ mask = ~(np.isnan(x_week) | np.isnan(x_month))
149
+ if int(mask.sum()) < min_obs:
150
+ return None
151
+ design = np.column_stack([np.ones(int(mask.sum())), x_day[mask], x_week[mask], x_month[mask]])
152
+ coef, *_ = np.linalg.lstsq(design, y[mask], rcond=None)
153
+
154
+ x_last = np.array([1.0, day[-1], week[-1], month[-1]])
155
+ if np.isnan(x_last).any():
156
+ return None
157
+ return float(max(coef @ x_last, 0.0))
158
+
159
+
160
+ def _forecast_variance(panel: pd.DataFrame, method: str, min_obs: int, lambda_: float) -> pd.Series:
161
+ """Per-name variance forecast (per-bar), dispatched by ``method`` ('ewma'/'har')."""
162
+ if method == "ewma":
163
+ var = ewma_variance_path(panel, lambda_, min_obs)
164
+ return var if var is not None else pd.Series(dtype=float)
165
+ if method == "har":
166
+ out: Dict[str, float] = {}
167
+ for col in panel.columns:
168
+ forecast = har_variance_forecast(panel[col], min_obs)
169
+ if forecast is not None:
170
+ out[col] = forecast
171
+ return pd.Series(out, dtype=float)
172
+ raise ValueError(f"conditional method must be 'ewma' or 'har', got {method!r}")
173
+
174
+
175
+ # --------------------------------------------------------------------------- #
176
+ # Σ_t = D_t R D_t assembly
177
+ # --------------------------------------------------------------------------- #
178
+ def _assemble_d_r_d(
179
+ sigma: np.ndarray, unconditional_var: np.ndarray, conditional_var: np.ndarray
180
+ ) -> np.ndarray:
181
+ """Rescale ``sigma`` to carry ``conditional_var`` on the diagonal, holding its
182
+ implied correlation fixed. ``D R D`` with ``R`` PSD is PSD —
183
+ a diagonal congruence transform preserves the sign of every eigenvalue.
184
+ Zero-variance (dead-flat) names get zero correlation with everything else
185
+ rather than a NaN — the same "independent" treatment the ragged-panel fallback
186
+ (:func:`quantred.risk.base.build_risk_matrix`) already gives thin-history names.
187
+ """
188
+ std_old = np.sqrt(np.maximum(unconditional_var, 0.0))
189
+ std_new = np.sqrt(np.maximum(conditional_var, 0.0))
190
+ denom = np.outer(std_old, std_old)
191
+ with np.errstate(divide="ignore", invalid="ignore"):
192
+ corr = np.where(denom > 0, sigma / denom, 0.0)
193
+ np.fill_diagonal(corr, 1.0)
194
+ return corr * np.outer(std_new, std_new)
195
+
196
+
197
+ def _sigma_regime(
198
+ symbols: List[str],
199
+ unconditional_var: np.ndarray,
200
+ conditional_var: np.ndarray,
201
+ method: str,
202
+ lambda_: float,
203
+ ) -> Dict[str, Any]:
204
+ """The reported diagnostic: current D_t vs the unconditional diagonal, as a ratio."""
205
+ ratio = np.ones_like(conditional_var)
206
+ positive = unconditional_var > 0
207
+ ratio[positive] = conditional_var[positive] / unconditional_var[positive]
208
+ return {
209
+ "method": method,
210
+ "lambda": lambda_,
211
+ "unconditional_vol": {s: float(np.sqrt(max(v, 0.0))) for s, v in zip(symbols, unconditional_var)},
212
+ "conditional_vol": {s: float(np.sqrt(max(v, 0.0))) for s, v in zip(symbols, conditional_var)},
213
+ "sigma_regime": {s: float(r) for s, r in zip(symbols, ratio)},
214
+ "mean_sigma_regime": float(np.mean(ratio)) if len(ratio) else 1.0,
215
+ }
216
+
217
+
218
+ def condition_risk_matrix(
219
+ matrix: RiskMatrix,
220
+ panel: pd.DataFrame,
221
+ method: str,
222
+ periods_per_year: float,
223
+ min_obs: int = 60,
224
+ lambda_: Optional[float] = None,
225
+ horizon: int = 1,
226
+ ) -> RiskMatrix:
227
+ """Return a Σ_t conditioned on ``method`` ('ewma'/'har'), or ``matrix`` unchanged
228
+ if ``method`` is falsy or the panel is too short to seed the recursion.
229
+
230
+ ``panel`` must be the SAME (as-of, ``<= t``) return panel used to estimate
231
+ ``matrix`` — this function only rescales the diagonal (shrinkage/sample) or
232
+ reassembles ``X F_t Xᵀ + Δ_t`` (factor); it never re-estimates the correlation
233
+ structure or the exposures. Dispatches on ``matrix``'s type: a
234
+ :class:`~quantred.risk.factor.FactorRiskMatrix` conditions ``factor_cov``/``specific_var``
235
+ coherently; any other :class:`RiskMatrix` conditions the plain
236
+ diagonal, keeping the correlation matrix fixed (``Σ_t = D_t R D_t``).
237
+ """
238
+ if not method:
239
+ return matrix
240
+ lam = lambda_ if lambda_ is not None else default_lambda(periods_per_year)
241
+ if isinstance(matrix, FactorRiskMatrix):
242
+ return _condition_factor(matrix, panel, method, periods_per_year, min_obs, lam, horizon)
243
+ return _condition_shrinkage(matrix, panel, method, periods_per_year, min_obs, lam, horizon)
244
+
245
+
246
+ def _condition_shrinkage(
247
+ matrix: RiskMatrix,
248
+ panel: pd.DataFrame,
249
+ method: str,
250
+ periods_per_year: float,
251
+ min_obs: int,
252
+ lam: float,
253
+ horizon: int,
254
+ ) -> RiskMatrix:
255
+ cols = [s for s in matrix.symbols if s in panel.columns]
256
+ if len(cols) < 2 or len(panel) < min_obs:
257
+ return matrix
258
+ forecast = _forecast_variance(panel[cols], method, min_obs, lam)
259
+ if forecast.empty:
260
+ return matrix
261
+
262
+ unconditional = np.diag(matrix.sigma).astype(float).copy()
263
+ conditional = unconditional.copy()
264
+ for i, sym in enumerate(matrix.symbols):
265
+ if sym in forecast.index and np.isfinite(forecast[sym]):
266
+ conditional[i] = max(float(forecast[sym]), 0.0) * horizon * periods_per_year
267
+
268
+ sigma_t = _assemble_d_r_d(matrix.sigma, unconditional, conditional)
269
+ diag = _sigma_regime(matrix.symbols, unconditional, conditional, method, lam)
270
+ return RiskMatrix(
271
+ symbols=list(matrix.symbols), sigma=sigma_t, shrinkage=matrix.shrinkage, conditional_diagnostics=diag
272
+ )
273
+
274
+
275
+ def _condition_factor(
276
+ matrix: FactorRiskMatrix,
277
+ panel: pd.DataFrame,
278
+ method: str,
279
+ periods_per_year: float,
280
+ min_obs: int,
281
+ lam: float,
282
+ horizon: int,
283
+ ) -> FactorRiskMatrix:
284
+ names = list(matrix.symbols)
285
+ if not all(n in panel.columns for n in names) or len(panel) < min_obs:
286
+ return matrix
287
+
288
+ x = matrix.exposures.loc[names].to_numpy(dtype=float) # N×K
289
+ r = panel[names].to_numpy(dtype=float) # T×N, the SAME panel the factor model was estimated on
290
+ proj = x @ np.linalg.pinv(x.T @ x) # N×K
291
+ factor_returns = r @ proj # T×K
292
+ residuals = r - factor_returns @ x.T # T×N
293
+
294
+ factor_returns_df = pd.DataFrame(factor_returns, columns=matrix.factor_names)
295
+ factor_cov_bar = ewma_covariance_path(factor_returns_df, lam, min_obs)
296
+ residuals_df = pd.DataFrame(residuals, columns=names)
297
+ specific_forecast = _forecast_variance(residuals_df, method, min_obs, lam)
298
+
299
+ if factor_cov_bar is None or specific_forecast.empty:
300
+ return matrix
301
+
302
+ unconditional_specific = matrix.specific_var.astype(float).copy()
303
+ conditional_specific = unconditional_specific.copy()
304
+ for i, sym in enumerate(names):
305
+ if sym in specific_forecast.index and np.isfinite(specific_forecast[sym]):
306
+ conditional_specific[i] = max(float(specific_forecast[sym]), 0.0) * horizon * periods_per_year
307
+
308
+ factor_cov_t = np.atleast_2d(factor_cov_bar) * horizon * periods_per_year
309
+ sigma_t = x @ factor_cov_t @ x.T + np.diag(conditional_specific)
310
+
311
+ diag = _sigma_regime(names, unconditional_specific, conditional_specific, method, lam)
312
+ diag["factor_cov_conditioning"] = "ewma" # F_t always conditions via EWMA ("same family" as specific_var)
313
+
314
+ return FactorRiskMatrix(
315
+ symbols=names,
316
+ sigma=sigma_t,
317
+ shrinkage=matrix.shrinkage,
318
+ conditional_diagnostics=diag,
319
+ exposures=matrix.exposures,
320
+ factor_cov=factor_cov_t,
321
+ specific_var=conditional_specific,
322
+ factor_names=list(matrix.factor_names),
323
+ )
324
+
325
+
326
+ # --------------------------------------------------------------------------- #
327
+ # Evidence gate: Mincer-Zarnowitz and QLIKE
328
+ # --------------------------------------------------------------------------- #
329
+ def mincer_zarnowitz(realized_var: np.ndarray, forecast_var: np.ndarray) -> Dict[str, float]:
330
+ """OLS of ``realized_var = a + b*forecast_var``. A well-calibrated forecast has
331
+ ``a ≈ 0``, ``b ≈ 1``. Needs >= 3 finite pairs; returns NaNs otherwise.
332
+ """
333
+ realized_var = np.asarray(realized_var, dtype=float)
334
+ forecast_var = np.asarray(forecast_var, dtype=float)
335
+ mask = np.isfinite(realized_var) & np.isfinite(forecast_var)
336
+ if int(mask.sum()) < 3:
337
+ return {"a": float("nan"), "b": float("nan"), "r2": float("nan"), "n": int(mask.sum())}
338
+ y, f = realized_var[mask], forecast_var[mask]
339
+ design = np.column_stack([np.ones(len(f)), f])
340
+ coef, *_ = np.linalg.lstsq(design, y, rcond=None)
341
+ fitted = design @ coef
342
+ ss_res = float(np.sum((y - fitted) ** 2))
343
+ ss_tot = float(np.sum((y - y.mean()) ** 2))
344
+ r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0
345
+ return {"a": float(coef[0]), "b": float(coef[1]), "r2": float(r2), "n": int(mask.sum())}
346
+
347
+
348
+ def qlike_loss(realized_var: np.ndarray, forecast_var: np.ndarray) -> float:
349
+ """QLIKE loss (Patton 2011): ``mean(realized/forecast - log(realized/forecast) - 1)``.
350
+ A robust variance-forecast loss (lower is better; 0 for a perfect forecast).
351
+ Non-positive forecasts/realized values are excluded (undefined ratio).
352
+ """
353
+ realized_var = np.asarray(realized_var, dtype=float)
354
+ forecast_var = np.asarray(forecast_var, dtype=float)
355
+ mask = np.isfinite(realized_var) & np.isfinite(forecast_var) & (forecast_var > 0) & (realized_var > 0)
356
+ if not mask.any():
357
+ return float("nan")
358
+ ratio = realized_var[mask] / forecast_var[mask]
359
+ return float(np.mean(ratio - np.log(ratio) - 1.0))
360
+
361
+
362
+ @dataclass
363
+ class VolForecastEvaluation:
364
+ """Per-method forecast-quality evaluation: MZ + QLIKE, pooled and by
365
+ ex-post realized-vol tercile — the gate that decides whether conditioning is
366
+ adopted, never a preference. ``realized``/``forecasts``
367
+ are the raw per-point arrays (one entry per sampled rebalance), kept so a
368
+ caller can pool several names' points into one cross-sectional evaluation
369
+ (:func:`quantred.services.analysis.evaluate_conditional_risk`)."""
370
+
371
+ n_points: int
372
+ by_method: Dict[str, Dict[str, Any]]
373
+ realized: np.ndarray = None # type: ignore[assignment]
374
+ forecasts: Dict[str, np.ndarray] = None # type: ignore[assignment]
375
+
376
+
377
+ def evaluate_vol_forecasts(
378
+ returns: pd.Series,
379
+ min_obs: int = 60,
380
+ n_points: int = 60,
381
+ lambda_: Optional[float] = None,
382
+ periods_per_year: float = 252.0,
383
+ ) -> VolForecastEvaluation:
384
+ """Walk one name's return series forward, comparing EWMA / HAR / unconditional
385
+ (expanding trailing) one-bar-ahead variance forecasts against the realized
386
+ ``r_{t+1}²`` — Mincer–Zarnowitz and QLIKE, pooled and split by the realized-vol
387
+ tercile the forecast landed in (ex-post labels only — never fed back into
388
+ the model). All forecasts at ``t`` use only ``returns[:t+1]``.
389
+ """
390
+ x = returns.to_numpy(dtype=float)
391
+ t_total = len(x)
392
+ lam = lambda_ if lambda_ is not None else default_lambda(periods_per_year)
393
+ last = t_total - 2 # need a realized r_{t+1}
394
+ if last <= min_obs:
395
+ return VolForecastEvaluation(n_points=0, by_method={})
396
+ points = np.linspace(min_obs, last, num=min(n_points, last - min_obs + 1), dtype=int)
397
+ points = sorted(set(int(p) for p in points))
398
+
399
+ ewma_full = ewma_variance_series(x.reshape(-1, 1), lam, min_obs)[:, 0]
400
+ cumsum2 = np.cumsum(x**2)
401
+
402
+ realized, forecasts = [], {"ewma": [], "har": [], "unconditional": []}
403
+ for t in points:
404
+ realized.append(x[t + 1] ** 2)
405
+ forecasts["ewma"].append(ewma_full[t] if t < len(ewma_full) else np.nan)
406
+ forecasts["unconditional"].append(cumsum2[t] / (t + 1))
407
+ har = har_variance_forecast(returns.iloc[: t + 1], min_obs)
408
+ forecasts["har"].append(har if har is not None else np.nan)
409
+
410
+ realized_arr = np.array(realized)
411
+ by_method: Dict[str, Dict[str, Any]] = {}
412
+ terciles = _tercile_labels(realized_arr)
413
+ for name, series in forecasts.items():
414
+ arr = np.array(series)
415
+ entry: Dict[str, Any] = {
416
+ "mincer_zarnowitz": mincer_zarnowitz(realized_arr, arr),
417
+ "qlike": qlike_loss(realized_arr, arr),
418
+ "by_regime": {},
419
+ }
420
+ for label in ("low", "mid", "high"):
421
+ m = terciles == label
422
+ entry["by_regime"][label] = {
423
+ "mincer_zarnowitz": mincer_zarnowitz(realized_arr[m], arr[m]),
424
+ "qlike": qlike_loss(realized_arr[m], arr[m]),
425
+ "n": int(m.sum()),
426
+ }
427
+ by_method[name] = entry
428
+
429
+ forecast_arrays = {name: np.array(series) for name, series in forecasts.items()}
430
+ return VolForecastEvaluation(
431
+ n_points=len(points), by_method=by_method, realized=realized_arr, forecasts=forecast_arrays
432
+ )
433
+
434
+
435
+ def _tercile_labels(values: np.ndarray) -> np.ndarray:
436
+ """Ex-post terciles ('low'/'mid'/'high') of ``values`` — report-time labels only."""
437
+ finite = values[np.isfinite(values)]
438
+ if len(finite) < 3:
439
+ return np.array(["mid"] * len(values))
440
+ q1, q2 = np.quantile(finite, [1 / 3, 2 / 3])
441
+ labels = np.where(values <= q1, "low", np.where(values <= q2, "mid", "high"))
442
+ return labels
443
+
444
+
445
+ # --------------------------------------------------------------------------- #
446
+ # Churn guard: risk-driven vs alpha-driven turnover
447
+ # --------------------------------------------------------------------------- #
448
+ def turnover_risk_share(
449
+ optimizer,
450
+ alphas,
451
+ current_weights: Dict[str, float],
452
+ matrix_t: RiskMatrix,
453
+ matrix_prev: RiskMatrix,
454
+ **optimize_kwargs,
455
+ ) -> Dict[str, float]:
456
+ """Decompose realized turnover into alpha-driven vs Σ-driven:
457
+ rerun the same solve with ``matrix_prev`` (Σ frozen at the prior rebalance) in
458
+ place of ``matrix_t`` (today's conditioned Σ), holding alphas/cost/current_weights
459
+ fixed. The gap between the two turnovers is turnover the frozen-Σ solve would NOT
460
+ have traded — attributable to Σ_t having moved, not to the alpha view changing.
461
+ If risk-driven turnover dominates in a *calm* regime, λ is too hot (over-reactive).
462
+ """
463
+ actual = optimizer.optimize(alphas, matrix_t, current_weights=current_weights, **optimize_kwargs)
464
+ frozen = optimizer.optimize(alphas, matrix_prev, current_weights=current_weights, **optimize_kwargs)
465
+ turnover_actual = float(actual.diagnostics.get("turnover", 0.0)) if actual.feasible else 0.0
466
+ turnover_frozen = float(frozen.diagnostics.get("turnover", 0.0)) if frozen.feasible else 0.0
467
+ risk_driven = max(turnover_actual - turnover_frozen, 0.0)
468
+ share = risk_driven / turnover_actual if turnover_actual > 0 else 0.0
469
+ return {
470
+ "turnover_actual": turnover_actual,
471
+ "turnover_frozen_sigma": turnover_frozen,
472
+ "risk_driven_turnover": risk_driven,
473
+ "risk_driven_share": share,
474
+ }
@@ -0,0 +1,88 @@
1
+ """Factor exposures - the matrix X of each name's loading on each factor.
2
+
3
+ A structural risk model decomposes returns as ``r = X f + u``: common factor
4
+ exposures ``X`` times factor returns ``f``, plus idiosyncratic ``u``. The starter
5
+ factor set is computable from price/volume alone (no fundamentals feed):
6
+
7
+ - **market** — beta to the benchmark.
8
+ - **momentum** — trailing return, skipping the most recent month (the classic 12-1).
9
+ - **volatility** — trailing realized volatility.
10
+ - **size** — a liquidity proxy, ``log(price · ADV)`` (dollar volume).
11
+
12
+ Exposures are **cross-sectionally standardized** (z-scored across names) so the
13
+ factors are on a comparable scale and the factor-return regression is well-posed.
14
+ """
15
+
16
+ from datetime import datetime
17
+ from typing import Dict, List, Optional
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+
22
+ from quantred.indicators import indicators
23
+
24
+ FACTOR_NAMES = ["market", "momentum", "volatility", "size"]
25
+
26
+
27
+ def build_factor_exposures(
28
+ bars: Dict[str, pd.DataFrame],
29
+ benchmark_bars: Optional[pd.DataFrame],
30
+ *,
31
+ momentum_window: int = 126,
32
+ momentum_skip: int = 21,
33
+ vol_window: int = 60,
34
+ as_of: Optional[datetime] = None,
35
+ factors: Optional[List[str]] = None,
36
+ betas: Optional[pd.Series] = None,
37
+ ) -> pd.DataFrame:
38
+ """Build the cross-sectionally standardized exposure matrix ``X`` (symbols × factors).
39
+
40
+ Names without enough history for every requested factor are dropped (the
41
+ cross-section just has fewer names). Returns an empty frame if fewer than two
42
+ names qualify. ``factors`` selects a subset of :data:`FACTOR_NAMES` (default:
43
+ all); the history requirement adapts — momentum needs the longest window, so a
44
+ subset without it keeps names a full build would drop. ``betas`` supplies
45
+ precomputed per-name betas for the market factor (a Series by symbol), skipping
46
+ the per-name regression when the caller already ran it.
47
+ """
48
+ wanted = list(FACTOR_NAMES) if factors is None else list(factors)
49
+ unknown = [f for f in wanted if f not in FACTOR_NAMES]
50
+ if unknown:
51
+ raise ValueError(f"unknown factors {unknown}; available: {FACTOR_NAMES}")
52
+ if not wanted:
53
+ return pd.DataFrame()
54
+
55
+ min_bars = momentum_window + momentum_skip + 1 if "momentum" in wanted else vol_window + 1
56
+ bench_close = benchmark_bars["close"] if benchmark_bars is not None and not benchmark_bars.empty else None
57
+ rows: Dict[str, Dict[str, float]] = {}
58
+ for symbol, frame in bars.items():
59
+ if frame is None or len(frame) < min_bars:
60
+ continue
61
+ close = frame["close"]
62
+ row: Dict[str, float] = {}
63
+ if "market" in wanted:
64
+ known = betas.get(symbol) if betas is not None else None
65
+ if known is not None and known == known: # not None, not NaN
66
+ row["market"] = float(known)
67
+ elif bench_close is not None:
68
+ row["market"] = indicators.calculate_beta(close, bench_close)
69
+ else:
70
+ row["market"] = 1.0
71
+ if "momentum" in wanted:
72
+ row["momentum"] = (
73
+ close.iloc[-1 - momentum_skip] / close.iloc[-1 - momentum_skip - momentum_window] - 1.0
74
+ )
75
+ if "volatility" in wanted:
76
+ returns = close.tail(vol_window + 1).pct_change().dropna()
77
+ row["volatility"] = float(returns.std())
78
+ if "size" in wanted:
79
+ dollar_volume = float(close.iloc[-1] * frame["volume"].tail(vol_window).mean())
80
+ row["size"] = np.log(dollar_volume) if dollar_volume > 0 else np.nan
81
+ rows[symbol] = row
82
+
83
+ frame = pd.DataFrame.from_dict(rows, orient="index").reindex(columns=wanted).dropna()
84
+ if len(frame) < 2:
85
+ return frame.iloc[0:0]
86
+ # Cross-sectional z-score per factor (unit dispersion, mean 0).
87
+ std = frame.std(ddof=0).replace(0.0, 1.0)
88
+ return (frame - frame.mean()) / std
@@ -0,0 +1,119 @@
1
+ """Structural factor risk model: Σ = X F Xᵀ + Δ.
2
+
3
+ A factor model is the preferred form for active risk: the parameter count drops from
4
+ ``O(N²)`` to ``O(N·K)``, Σ is invertible and stable by construction (``K`` small), and
5
+ — crucially — risk becomes **attributable**, splitting into *factor* risk
6
+ (``w_aᵀ X F Xᵀ w_a``) and *specific* risk (``w_aᵀ Δ w_a``). That decomposition is what
7
+ the information-analysis attribution and the alpha factor-neutralization both consume.
8
+
9
+ Factor returns ``f_t`` are recovered each period by a cross-sectional regression of
10
+ that period's returns on the (slowly-varying) exposures ``X``; ``F`` is their
11
+ covariance and ``Δ`` the diagonal variance of the residuals. Everything is annualized.
12
+ """
13
+
14
+ from dataclasses import dataclass, field
15
+ from typing import List, Optional
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+
20
+ from quantred.risk.base import RiskMatrix, build_return_panel
21
+ from quantred.risk.exposures import build_factor_exposures
22
+
23
+
24
+ @dataclass
25
+ class FactorRiskMatrix(RiskMatrix):
26
+ """A :class:`RiskMatrix` whose Σ is ``X F Xᵀ + Δ``, with the pieces kept for attribution."""
27
+
28
+ exposures: Optional[pd.DataFrame] = None # N×K (standardized)
29
+ factor_cov: Optional[np.ndarray] = None # K×K, annualized
30
+ specific_var: Optional[np.ndarray] = None # N, annualized
31
+ factor_names: List[str] = field(default_factory=list)
32
+
33
+ def factor_variance(self, weights) -> float:
34
+ """Active variance explained by the common factors: ``w_aᵀ X F Xᵀ w_a``."""
35
+ w = self._vector(weights)
36
+ x = self.exposures.loc[self.symbols].to_numpy()
37
+ fx = x.T @ w
38
+ return float(fx @ self.factor_cov @ fx)
39
+
40
+ def specific_variance(self, weights) -> float:
41
+ """Idiosyncratic active variance: ``w_aᵀ Δ w_a``."""
42
+ w = self._vector(weights)
43
+ return float(np.sum(self.specific_var * w * w))
44
+
45
+
46
+ def estimate_factor_model(
47
+ returns: pd.DataFrame, exposures: pd.DataFrame, periods_per_year: float
48
+ ) -> Optional[FactorRiskMatrix]:
49
+ """Estimate ``Σ = X F Xᵀ + Δ`` from a returns panel and an exposure matrix.
50
+
51
+ ``returns`` is (T × names), ``exposures`` (names × K). Factor returns are recovered
52
+ per period by cross-sectional OLS ``f_t = (XᵀX)⁻¹ Xᵀ r_t``; ``F`` is their
53
+ covariance and ``Δ`` the residual variance per name (both annualized). Returns
54
+ ``None`` if too few names/periods overlap.
55
+ """
56
+ names = [c for c in returns.columns if c in exposures.index]
57
+ if len(names) < 2 or len(returns) < 2:
58
+ return None
59
+
60
+ r = returns[names].to_numpy() # T×N
61
+ x = exposures.loc[names].to_numpy() # N×K
62
+ xtx_inv = np.linalg.pinv(x.T @ x) # K×K (pinv guards a thin/collinear X)
63
+
64
+ factor_returns = r @ x @ xtx_inv # T×K: each row f_t = (XᵀX)⁻¹Xᵀ r_t
65
+ residuals = r - factor_returns @ x.T # T×N
66
+
67
+ factor_cov = np.cov(factor_returns, rowvar=False) * periods_per_year
68
+ factor_cov = np.atleast_2d(factor_cov)
69
+ specific_var = residuals.var(axis=0, ddof=1) * periods_per_year
70
+ sigma = x @ factor_cov @ x.T + np.diag(specific_var)
71
+
72
+ return FactorRiskMatrix(
73
+ symbols=names,
74
+ sigma=sigma,
75
+ shrinkage=None,
76
+ exposures=exposures.loc[names],
77
+ factor_cov=factor_cov,
78
+ specific_var=specific_var,
79
+ factor_names=list(exposures.columns),
80
+ )
81
+
82
+
83
+ def build_factor_risk_matrix(
84
+ bars,
85
+ benchmark_bars,
86
+ periods_per_year: float,
87
+ min_obs: int = 60,
88
+ conditional: Optional[str] = None,
89
+ conditional_lambda: Optional[float] = None,
90
+ conditional_horizon: int = 1,
91
+ ) -> Optional[FactorRiskMatrix]:
92
+ """Build factor exposures from ``bars`` then estimate the factor covariance Σ.
93
+
94
+ ``conditional`` (default ``None`` / off) conditions ``factor_cov`` AND
95
+ ``specific_var`` together via the same EWMA/HAR family (never one without the
96
+ other — a partial conditioning mis-splits the factor/specific attribution); see
97
+ :func:`quantred.risk.conditional.condition_risk_matrix`. Loadings ``X`` stay slow.
98
+ """
99
+ exposures = build_factor_exposures(bars, benchmark_bars)
100
+ if exposures.empty:
101
+ return None
102
+ panel, _ = build_return_panel(bars, min_obs=min_obs)
103
+ if panel.empty:
104
+ return None
105
+ matrix = estimate_factor_model(panel, exposures, periods_per_year)
106
+ if matrix is None or not conditional:
107
+ return matrix
108
+
109
+ from quantred.risk.conditional import condition_risk_matrix
110
+
111
+ return condition_risk_matrix(
112
+ matrix,
113
+ panel,
114
+ conditional,
115
+ periods_per_year,
116
+ min_obs=min_obs,
117
+ lambda_=conditional_lambda,
118
+ horizon=conditional_horizon,
119
+ )