squeeze-kernel 0.2.0__tar.gz → 0.4.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: squeeze-kernel
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Streaming, PSD-by-construction covariance estimator with Fisher-kernel weighting and adaptive shrinkage
5
5
  Keywords: covariance,correlation,ewma,kernel,risk,streaming
6
6
  Author: Robert Kende
@@ -124,6 +124,18 @@ est = SqueezeKernelEstimator(n_assets=100, kappa=1.0, weight_statistic="mahalano
124
124
 
125
125
  **Score-driven memory** (`lambda_corr_fast=0.99`): lets stress days also *shorten* the correlation memory (decay slides from `lambda_corr` toward `lambda_corr_fast` as the kernel weight rises). Do **not** combine with the Mahalanobis option — they act on the same channel and the combination degrades accuracy.
126
126
 
127
+ **OU volatility anchor** (`vol_anchor_phi=0.995`): mean-reverts each asset's variance prediction toward a slow per-asset anchor (a ~1000-day EWMA of squared returns) before the daily update — a two-timescale, component-style volatility structure. One global parameter with a clean interpretation (deviation half-life ≈ ln 2/(1−φ) days; φ=0.995 ≈ 139 d). On the S&P 500 n=100 benchmark this improved held-out one-step NLL by 3.3 points (4.3 at φ=0.99) and five-step NLL by 3.9 (5.0), with no degradation at n=300. `None` (default) or φ=1 reproduces the published estimator exactly.
128
+
129
+ ```python
130
+ est = SqueezeKernelEstimator(n_assets=100, vol_anchor_phi=0.995)
131
+ ```
132
+
133
+ **Cluster shrinkage target** (`shrinkage_target="cluster"`): generalizes the equicorrelation shrinkage target to respect the correlation matrix's own block/cluster structure — with **no clustering algorithm**. The target morphs with the shrinkage intensity, T = (1−α)·T_equi + α·[(1−γ)I + γ·(C∘C)], where C∘C is the Hadamard square of the current correlation (positive semi-definite by the Schur product theorem; entries are pairwise shared-variance fractions) and γ is level-matched automatically. Zero added parameters, still O(n²), and as α→0 it reduces exactly to the default estimator. Held-out one-step NLL on the S&P 500 benchmark: ±0.1 at n=100, **−4.2 at n=200, −25.0 at n=300** — recommended whenever the universe size approaches the effective sample size.
134
+
135
+ ```python
136
+ est = SqueezeKernelEstimator(n_assets=300, shrinkage_target="cluster")
137
+ ```
138
+
127
139
  **Alternative kernels**: pass `kernel_fn=kernel_exponential` (with `kernel_kwargs={"gamma": ...}`) or `kernel_chi2_cdf`, or any callable `(d2, *, n_observed, **kw) -> float` mapping to `[0, 1)`. The PSD guarantee holds for any such kernel.
128
140
 
129
141
  ## How it works
@@ -94,6 +94,18 @@ est = SqueezeKernelEstimator(n_assets=100, kappa=1.0, weight_statistic="mahalano
94
94
 
95
95
  **Score-driven memory** (`lambda_corr_fast=0.99`): lets stress days also *shorten* the correlation memory (decay slides from `lambda_corr` toward `lambda_corr_fast` as the kernel weight rises). Do **not** combine with the Mahalanobis option — they act on the same channel and the combination degrades accuracy.
96
96
 
97
+ **OU volatility anchor** (`vol_anchor_phi=0.995`): mean-reverts each asset's variance prediction toward a slow per-asset anchor (a ~1000-day EWMA of squared returns) before the daily update — a two-timescale, component-style volatility structure. One global parameter with a clean interpretation (deviation half-life ≈ ln 2/(1−φ) days; φ=0.995 ≈ 139 d). On the S&P 500 n=100 benchmark this improved held-out one-step NLL by 3.3 points (4.3 at φ=0.99) and five-step NLL by 3.9 (5.0), with no degradation at n=300. `None` (default) or φ=1 reproduces the published estimator exactly.
98
+
99
+ ```python
100
+ est = SqueezeKernelEstimator(n_assets=100, vol_anchor_phi=0.995)
101
+ ```
102
+
103
+ **Cluster shrinkage target** (`shrinkage_target="cluster"`): generalizes the equicorrelation shrinkage target to respect the correlation matrix's own block/cluster structure — with **no clustering algorithm**. The target morphs with the shrinkage intensity, T = (1−α)·T_equi + α·[(1−γ)I + γ·(C∘C)], where C∘C is the Hadamard square of the current correlation (positive semi-definite by the Schur product theorem; entries are pairwise shared-variance fractions) and γ is level-matched automatically. Zero added parameters, still O(n²), and as α→0 it reduces exactly to the default estimator. Held-out one-step NLL on the S&P 500 benchmark: ±0.1 at n=100, **−4.2 at n=200, −25.0 at n=300** — recommended whenever the universe size approaches the effective sample size.
104
+
105
+ ```python
106
+ est = SqueezeKernelEstimator(n_assets=300, shrinkage_target="cluster")
107
+ ```
108
+
97
109
  **Alternative kernels**: pass `kernel_fn=kernel_exponential` (with `kernel_kwargs={"gamma": ...}`) or `kernel_chi2_cdf`, or any callable `(d2, *, n_observed, **kw) -> float` mapping to `[0, 1)`. The PSD guarantee holds for any such kernel.
98
110
 
99
111
  ## How it works
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "squeeze-kernel"
7
- version = "0.2.0"
7
+ version = "0.4.0"
8
8
  description = "Streaming, PSD-by-construction covariance estimator with Fisher-kernel weighting and adaptive shrinkage"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -34,4 +34,4 @@ __all__ = [
34
34
  "kernel_chi2_cdf",
35
35
  ]
36
36
 
37
- __version__ = "0.2.0"
37
+ __version__ = "0.4.0"
@@ -74,6 +74,39 @@ class SqueezeKernelEstimator:
74
74
  Do not combine with ``weight_statistic='mahalanobis'`` — the two
75
75
  mechanisms act on the same reactivity channel and their combination
76
76
  degraded out-of-sample accuracy in testing.
77
+ vol_anchor_phi : float or None
78
+ If set, enables the OU volatility anchor: each asset's variance
79
+ prediction mean-reverts toward a slow per-asset anchor before the
80
+ measurement update, v_pred = v̄ + φ·(v − v̄), with the anchor v̄ a
81
+ slow EWMA of squared returns (see ``vol_anchor_decay``). φ is the
82
+ per-step retention of deviations from the anchor (deviation
83
+ half-life ≈ ln 2 / (1 − φ) days); φ = 1 or ``None`` (default)
84
+ reproduces the published estimator exactly. Recommended φ = 0.995
85
+ (conservative; the range [0.99, 0.995] is robust). On the S&P-500
86
+ n=100 benchmark this improved held-out one-step NLL by 3.3 points
87
+ (φ=0.995; 4.3 at φ=0.99) and five-step NLL by 3.9 (5.0), with no
88
+ degradation at n=300. Mechanism: a two-timescale (component-style)
89
+ volatility structure — it changes persistence, not shock response.
90
+ Validated with the default marginal kernel; interaction with the
91
+ correlation-side extensions above is untested.
92
+ vol_anchor_decay : float
93
+ Decay of the slow per-asset variance anchor (default 0.999,
94
+ effective memory ≈ 1000 trading days). Only used when
95
+ ``vol_anchor_phi`` is set.
96
+ shrinkage_target : str
97
+ Geometry of the adaptive shrinkage target. ``'equicorrelation'``
98
+ (default) is the published single-factor target. ``'cluster'``
99
+ uses the concentration-morphing cluster target
100
+ T = (1−α)·T_equi + α·[(1−γ)I + γ·(C∘C)], where C∘C is the Hadamard
101
+ square of the current correlation (PSD by the Schur product
102
+ theorem) and γ = min(1, ρ̄/mean-offdiag(C∘C)) level-matches the
103
+ target to the equicorrelation mass. Respects the correlation
104
+ matrix's own block/cluster structure without any clustering
105
+ algorithm; adds no parameters and stays O(n²). As α → 0 it
106
+ reduces exactly to the published estimator, so behaviour at low
107
+ concentration is unchanged. Held-out one-step NLL on the S&P-500
108
+ benchmark: +0.14 (negligible) at n=100, −4.2 at n=200, −25.0 at
109
+ n=300. Recommended when n approaches the effective sample size.
77
110
 
78
111
  Examples
79
112
  --------
@@ -102,6 +135,9 @@ class SqueezeKernelEstimator:
102
135
  impute_threshold: float = 0.6,
103
136
  weight_statistic: str = "marginal",
104
137
  lambda_corr_fast: float | None = None,
138
+ vol_anchor_phi: float | None = None,
139
+ vol_anchor_decay: float = 0.999,
140
+ shrinkage_target: str = "equicorrelation",
105
141
  ):
106
142
  self.n_assets = n_assets
107
143
  self.lambda_vol = lambda_vol
@@ -118,6 +154,15 @@ class SqueezeKernelEstimator:
118
154
  if lambda_corr_fast is not None and not (0.0 < lambda_corr_fast < 1.0):
119
155
  raise ValueError("lambda_corr_fast must be in (0, 1).")
120
156
  self.lambda_corr_fast = lambda_corr_fast
157
+ if vol_anchor_phi is not None and not (0.0 < vol_anchor_phi <= 1.0):
158
+ raise ValueError("vol_anchor_phi must be in (0, 1].")
159
+ if not (0.0 < vol_anchor_decay < 1.0):
160
+ raise ValueError("vol_anchor_decay must be in (0, 1).")
161
+ self.vol_anchor_phi = vol_anchor_phi
162
+ self.vol_anchor_decay = vol_anchor_decay
163
+ if shrinkage_target not in ("equicorrelation", "cluster"):
164
+ raise ValueError("shrinkage_target must be 'equicorrelation' or 'cluster'.")
165
+ self.shrinkage_target = shrinkage_target
121
166
 
122
167
  # Resolve shrinkage
123
168
  if isinstance(shrinkage, str):
@@ -132,6 +177,7 @@ class SqueezeKernelEstimator:
132
177
  # State
133
178
  self._var_t: np.ndarray | None = None
134
179
  self._var_init: np.ndarray | None = None
180
+ self._var_anchor: np.ndarray | None = None
135
181
  self._M_t = np.eye(n_assets, dtype=np.float64) * epsilon
136
182
  self._S_t = float(epsilon)
137
183
  self._cov: np.ndarray | None = None
@@ -172,17 +218,38 @@ class SqueezeKernelEstimator:
172
218
  if self._var_t is None:
173
219
  self._var_t = np.zeros(n, dtype=np.float64)
174
220
  self._var_init = np.zeros(n, dtype=bool)
221
+ if self.vol_anchor_phi is not None:
222
+ self._var_anchor = np.zeros(n, dtype=np.float64)
175
223
 
176
224
  first = finite & ~self._var_init
177
225
  repeat = finite & self._var_init
178
226
  if np.any(first):
179
227
  self._var_t[first] = r_t[first] ** 2 + eps
180
228
  self._var_init[first] = True
229
+ if self._var_anchor is not None:
230
+ self._var_anchor[first] = self._var_t[first]
181
231
  if np.any(repeat):
182
- self._var_t[repeat] = (
183
- self.lambda_vol * self._var_t[repeat]
184
- + (1.0 - self.lambda_vol) * r_t[repeat] ** 2
185
- )
232
+ if self.vol_anchor_phi is None:
233
+ self._var_t[repeat] = (
234
+ self.lambda_vol * self._var_t[repeat]
235
+ + (1.0 - self.lambda_vol) * r_t[repeat] ** 2
236
+ )
237
+ else:
238
+ # OU anchor: mean-revert the variance prediction toward a slow
239
+ # per-asset anchor before the measurement update, then update
240
+ # the anchor itself (order matters and matches the validated
241
+ # experiment: prediction uses the *old* anchor).
242
+ phi = self.vol_anchor_phi
243
+ lam_bar = self.vol_anchor_decay
244
+ anchor = self._var_anchor[repeat]
245
+ v_pred = anchor + phi * (self._var_t[repeat] - anchor)
246
+ self._var_t[repeat] = (
247
+ self.lambda_vol * v_pred
248
+ + (1.0 - self.lambda_vol) * r_t[repeat] ** 2
249
+ )
250
+ self._var_anchor[repeat] = (
251
+ lam_bar * anchor + (1.0 - lam_bar) * r_t[repeat] ** 2
252
+ )
186
253
 
187
254
  vol_t = np.zeros(n, dtype=np.float64)
188
255
  vol_t[self._var_init] = np.sqrt(self._var_t[self._var_init])
@@ -335,9 +402,25 @@ class SqueezeKernelEstimator:
335
402
  if alpha > 0.0 and n > 1:
336
403
  # Off-diagonal mean: O(n^2) sum, no mask allocation.
337
404
  rho_bar = (corr.sum() - corr.trace()) / self._n_off
338
- corr *= (1.0 - alpha)
339
- corr += alpha * rho_bar
340
- np.fill_diagonal(corr, 1.0)
405
+ if self.shrinkage_target == "equicorrelation" or rho_bar <= 0.0:
406
+ corr *= (1.0 - alpha)
407
+ corr += alpha * rho_bar
408
+ np.fill_diagonal(corr, 1.0)
409
+ else:
410
+ # Cluster (concentration-morphing) target:
411
+ # T = (1-alpha) T_equi + alpha [(1-gamma) I + gamma (C o C)]
412
+ # C o C is the Hadamard square of the raw correlation (PSD by
413
+ # the Schur product theorem, unit diagonal for free); gamma is
414
+ # level-matched so the target carries the same average
415
+ # correlation mass as the equicorrelation target. As
416
+ # alpha -> 0 this reduces exactly to the published estimator.
417
+ had = corr * corr # Hadamard square, O(n^2)
418
+ mean_off = (had.sum() - np.trace(had)) / self._n_off
419
+ gamma = min(1.0, rho_bar / max(mean_off, eps))
420
+ corr *= (1.0 - alpha)
421
+ corr += (alpha * (1.0 - alpha)) * rho_bar
422
+ corr += (alpha * alpha * gamma) * had
423
+ np.fill_diagonal(corr, 1.0)
341
424
 
342
425
  # Build covariance from corr and vol_t. We need an output array
343
426
  # that the caller can keep, so allocate one cov here (cannot reuse