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,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
|
quantred/risk/factor.py
ADDED
|
@@ -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
|
+
)
|