squeeze-kernel 0.2.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.
- squeeze_kernel/__init__.py +37 -0
- squeeze_kernel/batch.py +81 -0
- squeeze_kernel/estimator.py +380 -0
- squeeze_kernel/kernels.py +139 -0
- squeeze_kernel-0.2.0.dist-info/METADATA +164 -0
- squeeze_kernel-0.2.0.dist-info/RECORD +7 -0
- squeeze_kernel-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Squeeze Kernel Covariance Estimator
|
|
3
|
+
====================================
|
|
4
|
+
|
|
5
|
+
Streaming, PSD-by-construction covariance estimator with adaptive
|
|
6
|
+
equicorrelation shrinkage and pluggable kernel weighting.
|
|
7
|
+
|
|
8
|
+
Quick start::
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from squeeze_kernel import SqueezeKernelEstimator
|
|
12
|
+
|
|
13
|
+
returns = np.random.default_rng(42).normal(0.0, 0.01, size=(250, 3))
|
|
14
|
+
|
|
15
|
+
# Defaults (lambda_vol=0.98, lambda_corr=0.996, kappa=0.25) are the
|
|
16
|
+
# paper-recommended settings for panels of daily financial returns.
|
|
17
|
+
est = SqueezeKernelEstimator(n_assets=3)
|
|
18
|
+
for r_t in returns:
|
|
19
|
+
est.update(r_t)
|
|
20
|
+
|
|
21
|
+
cov = est.get_cov()
|
|
22
|
+
corr = est.get_corr()
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from squeeze_kernel.estimator import SqueezeKernelEstimator
|
|
26
|
+
from squeeze_kernel.kernels import kernel_fisher, kernel_exponential, kernel_chi2_cdf
|
|
27
|
+
from squeeze_kernel.batch import estimate_squeeze_cov
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"SqueezeKernelEstimator",
|
|
31
|
+
"estimate_squeeze_cov",
|
|
32
|
+
"kernel_fisher",
|
|
33
|
+
"kernel_exponential",
|
|
34
|
+
"kernel_chi2_cdf",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
__version__ = "0.2.0"
|
squeeze_kernel/batch.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Batch estimation over an entire returns panel."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from squeeze_kernel.estimator import SqueezeKernelEstimator
|
|
8
|
+
from squeeze_kernel.kernels import KernelFn
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def estimate_squeeze_cov(
|
|
12
|
+
returns,
|
|
13
|
+
*,
|
|
14
|
+
lambda_vol: float = 0.98,
|
|
15
|
+
lambda_corr: float = 0.996,
|
|
16
|
+
kappa: float | None = None,
|
|
17
|
+
kernel_fn: KernelFn | None = None,
|
|
18
|
+
kernel_kwargs: dict[str, object] | None = None,
|
|
19
|
+
epsilon: float = 1e-8,
|
|
20
|
+
shrinkage: str | float = "auto",
|
|
21
|
+
shrinkage_delta: float = 0.10,
|
|
22
|
+
impute_missing: bool = False,
|
|
23
|
+
impute_threshold: float = 0.6,
|
|
24
|
+
with_corr: bool = True,
|
|
25
|
+
with_weights: bool = False,
|
|
26
|
+
) -> tuple[np.ndarray, np.ndarray | None, np.ndarray | None]:
|
|
27
|
+
"""Estimate streaming covariance over an entire returns panel.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
returns : array-like, shape (T, n)
|
|
32
|
+
2D return matrix. May contain NaN for missing observations.
|
|
33
|
+
kappa : float, optional
|
|
34
|
+
Saturation parameter for the default Fisher kernel.
|
|
35
|
+
kernel_fn : callable, optional
|
|
36
|
+
Custom kernel ``(d2, *, n_observed, **kw) -> float``.
|
|
37
|
+
kernel_kwargs : dict, optional
|
|
38
|
+
Extra keyword arguments forwarded to ``kernel_fn``.
|
|
39
|
+
with_corr : bool
|
|
40
|
+
If True, also return the correlation tensor.
|
|
41
|
+
with_weights : bool
|
|
42
|
+
If True, also return per-timestamp kernel weights.
|
|
43
|
+
|
|
44
|
+
Returns
|
|
45
|
+
-------
|
|
46
|
+
cov : ndarray, shape (T, n, n)
|
|
47
|
+
corr : ndarray or None, shape (T, n, n)
|
|
48
|
+
weights : ndarray or None, shape (T,)
|
|
49
|
+
"""
|
|
50
|
+
values = np.asarray(returns, dtype=np.float64)
|
|
51
|
+
if values.ndim != 2:
|
|
52
|
+
raise ValueError(f"Expected 2D returns, got shape {values.shape}.")
|
|
53
|
+
t_total, n_assets = values.shape
|
|
54
|
+
|
|
55
|
+
est = SqueezeKernelEstimator(
|
|
56
|
+
n_assets,
|
|
57
|
+
lambda_vol=lambda_vol,
|
|
58
|
+
lambda_corr=lambda_corr,
|
|
59
|
+
kappa=kappa,
|
|
60
|
+
kernel_fn=kernel_fn,
|
|
61
|
+
kernel_kwargs=kernel_kwargs,
|
|
62
|
+
epsilon=epsilon,
|
|
63
|
+
shrinkage=shrinkage,
|
|
64
|
+
shrinkage_delta=shrinkage_delta,
|
|
65
|
+
impute_missing=impute_missing,
|
|
66
|
+
impute_threshold=impute_threshold,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
cov = np.empty((t_total, n_assets, n_assets), dtype=np.float64)
|
|
70
|
+
corr = np.empty_like(cov) if with_corr else None
|
|
71
|
+
weights = np.empty(t_total, dtype=np.float64) if with_weights else None
|
|
72
|
+
|
|
73
|
+
for t in range(t_total):
|
|
74
|
+
w_t = est.update(values[t])
|
|
75
|
+
cov[t] = est.get_cov()
|
|
76
|
+
if corr is not None:
|
|
77
|
+
corr[t] = est.get_corr()
|
|
78
|
+
if weights is not None:
|
|
79
|
+
weights[t] = w_t
|
|
80
|
+
|
|
81
|
+
return cov, corr, weights
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Core streaming Squeeze Kernel covariance estimator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from squeeze_kernel.kernels import (
|
|
8
|
+
KernelFn, kernel_fisher, calibrate_kappa, extract_d2_series,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SqueezeKernelEstimator:
|
|
13
|
+
"""Streaming robust covariance estimator with pluggable kernel weighting.
|
|
14
|
+
|
|
15
|
+
The estimator is positive semi-definite by construction at every time step,
|
|
16
|
+
handles missing observations natively, and separates volatility and
|
|
17
|
+
correlation dynamics through dual-timescale EWMAs. An adaptive
|
|
18
|
+
equicorrelation shrinkage rule automatically calibrates regularisation
|
|
19
|
+
to the concentration ratio n / T_eff.
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
n_assets : int
|
|
24
|
+
Number of assets.
|
|
25
|
+
lambda_vol : float
|
|
26
|
+
Decay factor for per-asset volatility EWMA (default 0.98,
|
|
27
|
+
half-life ≈ 34 trading days).
|
|
28
|
+
lambda_corr : float
|
|
29
|
+
Decay factor for correlation EWMA (default 0.996, half-life
|
|
30
|
+
≈ 173 trading days, effective sample size ≈ 250).
|
|
31
|
+
kappa : float, optional
|
|
32
|
+
Saturation parameter for the default Fisher kernel (default 0.25).
|
|
33
|
+
The defaults for ``lambda_vol``, ``lambda_corr`` and ``kappa`` are
|
|
34
|
+
the values recommended in the paper for panels of daily financial
|
|
35
|
+
returns; they were selected by time-series cross-validation and are
|
|
36
|
+
insensitive to moderate perturbation.
|
|
37
|
+
kernel_fn : callable, optional
|
|
38
|
+
Custom kernel ``(d2, *, n_observed, **kw) -> float``.
|
|
39
|
+
If omitted, the estimator uses ``kernel_fisher``.
|
|
40
|
+
kernel_kwargs : dict, optional
|
|
41
|
+
Extra keyword arguments forwarded to ``kernel_fn``.
|
|
42
|
+
Use this to configure alternative kernels such as
|
|
43
|
+
``kernel_exponential(gamma=...)``.
|
|
44
|
+
epsilon : float
|
|
45
|
+
Numerical floor (default 1e-8).
|
|
46
|
+
shrinkage : str or float
|
|
47
|
+
``'auto'`` (default) for adaptive shrinkage,
|
|
48
|
+
``0`` or ``'none'`` to disable, or a float in [0, 1] for fixed intensity.
|
|
49
|
+
shrinkage_delta : float
|
|
50
|
+
Threshold for adaptive shrinkage (default 0.10).
|
|
51
|
+
impute_missing : bool
|
|
52
|
+
If True, impute missing standardized returns from correlated assets.
|
|
53
|
+
impute_threshold : float
|
|
54
|
+
Minimum |correlation| for imputation donors (default 0.6).
|
|
55
|
+
weight_statistic : str
|
|
56
|
+
Statistic fed to the kernel. ``'marginal'`` (default) uses the mean
|
|
57
|
+
squared standardized return d² = z'z/N — the published estimator.
|
|
58
|
+
``'mahalanobis'`` uses the score-exact surprise z'C⁻¹z/N measured
|
|
59
|
+
against the estimator's own previous correlation matrix (one linear
|
|
60
|
+
solve per update). With κ ≈ 1 (the parameter-free default, since
|
|
61
|
+
E[z'C⁻¹z/N] = 1 under a correct C) this improved one-step NLL by
|
|
62
|
+
≈2.5 points on the S&P-500 n=100 benchmark, holdout-confirmed.
|
|
63
|
+
IMPORTANT — regime-dependent: the advantage inverts in the
|
|
64
|
+
high-concentration regime (≈12 NLL worse at n=200 and ≈61 worse at
|
|
65
|
+
n=300 with T_eff = 250), because the estimated inverse inflates the
|
|
66
|
+
statistic as n approaches T_eff, saturating the kernel and weakening
|
|
67
|
+
the adaptive shrinkage. Use only when n/T_eff ≲ 0.5; the marginal
|
|
68
|
+
default is the concentration-robust choice at every dimension.
|
|
69
|
+
lambda_corr_fast : float or None
|
|
70
|
+
If set, enables score-driven memory: the correlation decay becomes
|
|
71
|
+
λ_t = lambda_corr + (lambda_corr_fast − lambda_corr)·w_t, shortening
|
|
72
|
+
the memory on high-weight (stress) days. PSD is preserved. Pass
|
|
73
|
+
``None`` (default) for the published constant-λ behaviour.
|
|
74
|
+
Do not combine with ``weight_statistic='mahalanobis'`` — the two
|
|
75
|
+
mechanisms act on the same reactivity channel and their combination
|
|
76
|
+
degraded out-of-sample accuracy in testing.
|
|
77
|
+
|
|
78
|
+
Examples
|
|
79
|
+
--------
|
|
80
|
+
>>> import numpy as np
|
|
81
|
+
>>> returns = np.random.default_rng(42).normal(0.0, 0.01, size=(250, 3))
|
|
82
|
+
>>> est = SqueezeKernelEstimator(n_assets=3)
|
|
83
|
+
>>> for r_t in returns:
|
|
84
|
+
... est.update(r_t)
|
|
85
|
+
>>> cov = est.get_cov()
|
|
86
|
+
>>> corr = est.get_corr()
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
n_assets: int,
|
|
92
|
+
*,
|
|
93
|
+
lambda_vol: float = 0.98,
|
|
94
|
+
lambda_corr: float = 0.996,
|
|
95
|
+
kappa: float | None = None,
|
|
96
|
+
kernel_fn: KernelFn | None = None,
|
|
97
|
+
kernel_kwargs: dict[str, object] | None = None,
|
|
98
|
+
epsilon: float = 1e-8,
|
|
99
|
+
shrinkage: str | float = "auto",
|
|
100
|
+
shrinkage_delta: float = 0.10,
|
|
101
|
+
impute_missing: bool = False,
|
|
102
|
+
impute_threshold: float = 0.6,
|
|
103
|
+
weight_statistic: str = "marginal",
|
|
104
|
+
lambda_corr_fast: float | None = None,
|
|
105
|
+
):
|
|
106
|
+
self.n_assets = n_assets
|
|
107
|
+
self.lambda_vol = lambda_vol
|
|
108
|
+
self.lambda_corr = lambda_corr
|
|
109
|
+
self.epsilon = epsilon
|
|
110
|
+
self.impute_missing = impute_missing
|
|
111
|
+
self.impute_threshold = impute_threshold
|
|
112
|
+
self.shrinkage_delta = shrinkage_delta
|
|
113
|
+
|
|
114
|
+
# Opt-in extensions (defaults preserve the published estimator exactly)
|
|
115
|
+
if weight_statistic not in ("marginal", "mahalanobis"):
|
|
116
|
+
raise ValueError("weight_statistic must be 'marginal' or 'mahalanobis'.")
|
|
117
|
+
self.weight_statistic = weight_statistic
|
|
118
|
+
if lambda_corr_fast is not None and not (0.0 < lambda_corr_fast < 1.0):
|
|
119
|
+
raise ValueError("lambda_corr_fast must be in (0, 1).")
|
|
120
|
+
self.lambda_corr_fast = lambda_corr_fast
|
|
121
|
+
|
|
122
|
+
# Resolve shrinkage
|
|
123
|
+
if isinstance(shrinkage, str):
|
|
124
|
+
self._shrinkage_alpha = -1.0 if shrinkage == "auto" else 0.0
|
|
125
|
+
else:
|
|
126
|
+
self._shrinkage_alpha = float(shrinkage)
|
|
127
|
+
|
|
128
|
+
# Resolve kernel
|
|
129
|
+
self._kernel_fn, self._kernel_kwargs = _resolve_kernel(kappa, kernel_fn, kernel_kwargs)
|
|
130
|
+
self.kappa = self._kernel_kwargs.get("kappa") if self._kernel_fn is kernel_fisher else None
|
|
131
|
+
|
|
132
|
+
# State
|
|
133
|
+
self._var_t: np.ndarray | None = None
|
|
134
|
+
self._var_init: np.ndarray | None = None
|
|
135
|
+
self._M_t = np.eye(n_assets, dtype=np.float64) * epsilon
|
|
136
|
+
self._S_t = float(epsilon)
|
|
137
|
+
self._cov: np.ndarray | None = None
|
|
138
|
+
self._corr: np.ndarray | None = None
|
|
139
|
+
self._last_weight: float = 0.0
|
|
140
|
+
|
|
141
|
+
# Cached scratch buffers reused per ``update()`` to avoid per-step
|
|
142
|
+
# allocator churn. These are intentionally module-private and
|
|
143
|
+
# never escape the estimator.
|
|
144
|
+
self._scratch_outer = np.empty((n_assets, n_assets), dtype=np.float64)
|
|
145
|
+
self._scratch_corr = np.empty((n_assets, n_assets), dtype=np.float64)
|
|
146
|
+
self._n_off = float(n_assets * (n_assets - 1)) if n_assets > 1 else 1.0
|
|
147
|
+
|
|
148
|
+
# ── Public API ────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
def update(self, r_t) -> float:
|
|
151
|
+
"""Process one return vector and update the covariance estimate.
|
|
152
|
+
|
|
153
|
+
Parameters
|
|
154
|
+
----------
|
|
155
|
+
r_t : array-like, shape (n_assets,)
|
|
156
|
+
Return vector. May contain NaN for missing assets.
|
|
157
|
+
|
|
158
|
+
Returns
|
|
159
|
+
-------
|
|
160
|
+
float
|
|
161
|
+
Kernel weight w_t assigned to this observation.
|
|
162
|
+
"""
|
|
163
|
+
r_t = np.asarray(r_t, dtype=np.float64)
|
|
164
|
+
n = self.n_assets
|
|
165
|
+
eps = self.epsilon
|
|
166
|
+
if r_t.shape != (n,):
|
|
167
|
+
raise ValueError(f"Expected shape ({n},), got {r_t.shape}.")
|
|
168
|
+
|
|
169
|
+
finite = np.isfinite(r_t)
|
|
170
|
+
|
|
171
|
+
# ── Volatility update ──
|
|
172
|
+
if self._var_t is None:
|
|
173
|
+
self._var_t = np.zeros(n, dtype=np.float64)
|
|
174
|
+
self._var_init = np.zeros(n, dtype=bool)
|
|
175
|
+
|
|
176
|
+
first = finite & ~self._var_init
|
|
177
|
+
repeat = finite & self._var_init
|
|
178
|
+
if np.any(first):
|
|
179
|
+
self._var_t[first] = r_t[first] ** 2 + eps
|
|
180
|
+
self._var_init[first] = True
|
|
181
|
+
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
|
+
)
|
|
186
|
+
|
|
187
|
+
vol_t = np.zeros(n, dtype=np.float64)
|
|
188
|
+
vol_t[self._var_init] = np.sqrt(self._var_t[self._var_init])
|
|
189
|
+
|
|
190
|
+
# ── Standardized returns ──
|
|
191
|
+
z_t = np.zeros(n, dtype=np.float64)
|
|
192
|
+
n_obs = int(finite.sum())
|
|
193
|
+
if n_obs > 0:
|
|
194
|
+
z_t[finite] = r_t[finite] / (vol_t[finite] + eps)
|
|
195
|
+
d2 = float(z_t[finite] @ z_t[finite]) / n_obs
|
|
196
|
+
if self.weight_statistic == "mahalanobis" and self._corr is not None:
|
|
197
|
+
# Score-exact surprise against the estimator's own previous
|
|
198
|
+
# correlation; falls back to the marginal d² on the first
|
|
199
|
+
# step or a (rare) singular observed submatrix.
|
|
200
|
+
try:
|
|
201
|
+
c_sub = self._corr[np.ix_(finite, finite)]
|
|
202
|
+
d2 = float(z_t[finite] @ np.linalg.solve(c_sub, z_t[finite])) / n_obs
|
|
203
|
+
except np.linalg.LinAlgError:
|
|
204
|
+
pass
|
|
205
|
+
w_t = self._kernel_fn(d2, n_observed=n_obs, **self._kernel_kwargs)
|
|
206
|
+
else:
|
|
207
|
+
w_t = 0.0
|
|
208
|
+
|
|
209
|
+
# ── Imputation ──
|
|
210
|
+
if self.impute_missing and 0 < n_obs < n:
|
|
211
|
+
self._impute(z_t, finite)
|
|
212
|
+
|
|
213
|
+
# ── Correlation EWMA (in-place to avoid per-step allocations) ──
|
|
214
|
+
lam_c = self.lambda_corr
|
|
215
|
+
if self.lambda_corr_fast is not None:
|
|
216
|
+
# Score-driven memory: stress days (w_t → 1) shorten the memory
|
|
217
|
+
# toward lambda_corr_fast; calm days keep the slow decay.
|
|
218
|
+
lam_c = self.lambda_corr + (self.lambda_corr_fast - self.lambda_corr) * w_t
|
|
219
|
+
self._S_t = lam_c * self._S_t + w_t
|
|
220
|
+
self._M_t *= lam_c
|
|
221
|
+
if w_t > 0.0 and n_obs > 0:
|
|
222
|
+
# np.multiply.outer with out= avoids the temporary that
|
|
223
|
+
# np.outer otherwise allocates each step.
|
|
224
|
+
np.multiply.outer(z_t, z_t, out=self._scratch_outer)
|
|
225
|
+
self._M_t += w_t * self._scratch_outer
|
|
226
|
+
|
|
227
|
+
# ── Extract covariance ──
|
|
228
|
+
self._cov, self._corr = self._extract(vol_t)
|
|
229
|
+
self._last_weight = w_t
|
|
230
|
+
return w_t
|
|
231
|
+
|
|
232
|
+
def get_cov(self) -> np.ndarray:
|
|
233
|
+
"""Return the current covariance matrix estimate (n x n)."""
|
|
234
|
+
if self._cov is None:
|
|
235
|
+
raise RuntimeError("Call update() at least once before get_cov().")
|
|
236
|
+
return self._cov.copy()
|
|
237
|
+
|
|
238
|
+
def get_corr(self) -> np.ndarray:
|
|
239
|
+
"""Return the current correlation matrix estimate (n x n)."""
|
|
240
|
+
if self._corr is None:
|
|
241
|
+
raise RuntimeError("Call update() at least once before get_corr().")
|
|
242
|
+
return self._corr.copy()
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def weight(self) -> float:
|
|
246
|
+
"""Kernel weight assigned to the most recent observation."""
|
|
247
|
+
return self._last_weight
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def effective_sample_size(self) -> float:
|
|
251
|
+
"""Kernel-weighted effective sample size S_t."""
|
|
252
|
+
return self._S_t
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def shrinkage_intensity(self) -> float:
|
|
256
|
+
"""Current adaptive shrinkage intensity alpha_t."""
|
|
257
|
+
if self._shrinkage_alpha >= 0:
|
|
258
|
+
return self._shrinkage_alpha
|
|
259
|
+
n = self.n_assets
|
|
260
|
+
return max(0.0, min(1.0, n / (2.0 * max(self._S_t, self.epsilon)) - self.shrinkage_delta))
|
|
261
|
+
|
|
262
|
+
@staticmethod
|
|
263
|
+
def calibrate_kappa(
|
|
264
|
+
returns, target_weight: float = 0.5, lambda_vol: float = 0.98,
|
|
265
|
+
) -> float:
|
|
266
|
+
"""Calibrate κ from burn-in data so E[w_t] ≈ target_weight.
|
|
267
|
+
|
|
268
|
+
Parameters
|
|
269
|
+
----------
|
|
270
|
+
returns : array-like, shape (T, n)
|
|
271
|
+
Burn-in return data.
|
|
272
|
+
target_weight : float
|
|
273
|
+
Target average kernel weight in (0, 1).
|
|
274
|
+
lambda_vol : float
|
|
275
|
+
Volatility decay factor used for standardization.
|
|
276
|
+
|
|
277
|
+
Returns
|
|
278
|
+
-------
|
|
279
|
+
float
|
|
280
|
+
Calibrated κ value.
|
|
281
|
+
"""
|
|
282
|
+
d2 = extract_d2_series(returns, lambda_vol=lambda_vol)
|
|
283
|
+
return calibrate_kappa(d2, target_weight)
|
|
284
|
+
|
|
285
|
+
# ── Private helpers ───────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
def _impute(self, z_t: np.ndarray, finite: np.ndarray) -> None:
|
|
288
|
+
eps = self.epsilon
|
|
289
|
+
denom = max(self._S_t, eps)
|
|
290
|
+
sigma_z = self._M_t / denom
|
|
291
|
+
diag_z = np.diag(sigma_z)
|
|
292
|
+
inv_diag = 1.0 / np.sqrt(np.maximum(diag_z, eps))
|
|
293
|
+
missing = ~finite
|
|
294
|
+
for i in range(self.n_assets):
|
|
295
|
+
if not missing[i]:
|
|
296
|
+
continue
|
|
297
|
+
num = den = 0.0
|
|
298
|
+
for j in range(self.n_assets):
|
|
299
|
+
if not finite[j]:
|
|
300
|
+
continue
|
|
301
|
+
c_ij = sigma_z[i, j] * inv_diag[i] * inv_diag[j]
|
|
302
|
+
if abs(c_ij) < self.impute_threshold:
|
|
303
|
+
continue
|
|
304
|
+
num += c_ij * z_t[j]
|
|
305
|
+
den += abs(c_ij)
|
|
306
|
+
if den > 0.0:
|
|
307
|
+
z_t[i] = num / den
|
|
308
|
+
|
|
309
|
+
def _extract(self, vol_t: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
310
|
+
eps = self.epsilon
|
|
311
|
+
S_t = max(self._S_t, eps)
|
|
312
|
+
n = self.n_assets
|
|
313
|
+
|
|
314
|
+
# Normalised standardised covariance matrix sigma_z = M_t / S_t.
|
|
315
|
+
# Compute correlations directly into the cached scratch buffer to
|
|
316
|
+
# avoid two intermediate allocations (sigma_z and corr).
|
|
317
|
+
diag_z = np.diagonal(self._M_t).copy()
|
|
318
|
+
diag_z /= S_t # in-place
|
|
319
|
+
inv_diag = 1.0 / np.sqrt(np.maximum(diag_z, eps))
|
|
320
|
+
# corr_ij = (M_ij / S_t) * inv_diag_i * inv_diag_j; this writes
|
|
321
|
+
# the rescaled outer-product into _scratch_corr in one pass.
|
|
322
|
+
np.multiply.outer(inv_diag, inv_diag, out=self._scratch_corr)
|
|
323
|
+
corr = self._scratch_corr
|
|
324
|
+
corr *= self._M_t # in-place; corr = sigma_z * outer(inv_diag,inv_diag)
|
|
325
|
+
corr *= (1.0 / S_t) # absorb the M_t / S_t scale
|
|
326
|
+
np.fill_diagonal(corr, np.where(diag_z > eps, 1.0, 0.0))
|
|
327
|
+
|
|
328
|
+
# Adaptive shrinkage: blend toward the equicorrelation target
|
|
329
|
+
# T = (1 - rho_bar) I + rho_bar 11'. We avoid materialising T by
|
|
330
|
+
# blending the off-diagonal toward rho_bar in place and resetting
|
|
331
|
+
# the diagonal to 1.
|
|
332
|
+
alpha = self._shrinkage_alpha
|
|
333
|
+
if alpha < 0:
|
|
334
|
+
alpha = max(0.0, min(1.0, n / (2.0 * S_t) - self.shrinkage_delta))
|
|
335
|
+
if alpha > 0.0 and n > 1:
|
|
336
|
+
# Off-diagonal mean: O(n^2) sum, no mask allocation.
|
|
337
|
+
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)
|
|
341
|
+
|
|
342
|
+
# Build covariance from corr and vol_t. We need an output array
|
|
343
|
+
# that the caller can keep, so allocate one cov here (cannot reuse
|
|
344
|
+
# corr buffer because both are returned).
|
|
345
|
+
cov = corr * np.outer(vol_t, vol_t)
|
|
346
|
+
# Defensive symmetrisation against floating-point asymmetry.
|
|
347
|
+
cov += cov.T
|
|
348
|
+
cov *= 0.5
|
|
349
|
+
# Return a copy of corr so callers see a stable snapshot even if
|
|
350
|
+
# the next update() overwrites the scratch buffer.
|
|
351
|
+
corr_out = corr.copy()
|
|
352
|
+
corr_out += corr_out.T
|
|
353
|
+
corr_out *= 0.5
|
|
354
|
+
return cov, corr_out
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# ── Kernel resolution ─────────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
def _resolve_kernel(
|
|
360
|
+
kappa: float | None,
|
|
361
|
+
kernel_fn: KernelFn | None,
|
|
362
|
+
kernel_kwargs: dict[str, object] | None,
|
|
363
|
+
) -> tuple[KernelFn, dict[str, object]]:
|
|
364
|
+
kw = dict(kernel_kwargs) if kernel_kwargs else {}
|
|
365
|
+
|
|
366
|
+
if kernel_fn is not None:
|
|
367
|
+
if kappa is not None:
|
|
368
|
+
raise ValueError(
|
|
369
|
+
"Pass kernel-specific parameters via kernel_kwargs when kernel_fn is set."
|
|
370
|
+
)
|
|
371
|
+
return kernel_fn, kw
|
|
372
|
+
|
|
373
|
+
if "kappa" in kw and kappa is not None:
|
|
374
|
+
raise ValueError("Pass kappa either as a top-level argument or in kernel_kwargs, not both.")
|
|
375
|
+
|
|
376
|
+
resolved_kappa = float(kw.get("kappa", 0.25 if kappa is None else kappa))
|
|
377
|
+
if resolved_kappa <= 0.0:
|
|
378
|
+
raise ValueError("kappa must be > 0.")
|
|
379
|
+
kw["kappa"] = resolved_kappa
|
|
380
|
+
return kernel_fisher, kw
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Built-in kernel weight functions for the Squeeze Kernel estimator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
KernelFn = Callable[..., float]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def kernel_fisher(d2: float, /, *, kappa: float, **kw) -> float:
|
|
12
|
+
"""Fisher information saturation kernel: w = d² / (d² + κ).
|
|
13
|
+
|
|
14
|
+
Motivated by the signal-to-noise structure of the Gaussian score.
|
|
15
|
+
Default kernel for the Squeeze Kernel estimator.
|
|
16
|
+
"""
|
|
17
|
+
if kappa <= 0.0:
|
|
18
|
+
raise ValueError("kappa must be > 0.")
|
|
19
|
+
return d2 / (d2 + kappa)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def kernel_exponential(d2: float, /, *, gamma: float, **kw) -> float:
|
|
23
|
+
"""Rayleigh survival (exponential) kernel: w = 1 − exp(−d²/(2γ²))."""
|
|
24
|
+
if gamma <= 0.0:
|
|
25
|
+
raise ValueError("gamma must be > 0.")
|
|
26
|
+
return 1.0 - math.exp(-d2 / (2.0 * gamma * gamma))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def kernel_chi2_cdf(d2: float, /, *, n_observed: int, **kw) -> float:
|
|
30
|
+
"""Chi-squared CDF kernel: w = F_χ²_N(N·d²). Requires scipy."""
|
|
31
|
+
try:
|
|
32
|
+
from scipy.stats import chi2
|
|
33
|
+
except ImportError as exc:
|
|
34
|
+
raise ImportError(
|
|
35
|
+
"kernel_chi2_cdf() requires SciPy. Install the optional "
|
|
36
|
+
"'squeeze-kernel[full]' dependencies."
|
|
37
|
+
) from exc
|
|
38
|
+
return float(chi2.cdf(n_observed * d2, df=n_observed))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def calibrate_kappa(d2_samples, target_mean_weight: float = 0.5) -> float:
|
|
42
|
+
"""Find κ such that E[d²/(d²+κ)] ≈ target_mean_weight on burn-in data.
|
|
43
|
+
|
|
44
|
+
This implements the closed-form initializer from Proposition 1 of the paper,
|
|
45
|
+
refined by one-dimensional root finding.
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
d2_samples : array-like
|
|
50
|
+
Observed d̄²_t values from a burn-in window (use ``extract_d2_series``).
|
|
51
|
+
target_mean_weight : float
|
|
52
|
+
Target average kernel weight in (0, 1).
|
|
53
|
+
|
|
54
|
+
Returns
|
|
55
|
+
-------
|
|
56
|
+
float
|
|
57
|
+
Calibrated κ value.
|
|
58
|
+
"""
|
|
59
|
+
import numpy as np
|
|
60
|
+
try:
|
|
61
|
+
from scipy.optimize import brentq
|
|
62
|
+
except ImportError as exc:
|
|
63
|
+
raise ImportError(
|
|
64
|
+
"calibrate_kappa() requires SciPy. Install the optional "
|
|
65
|
+
"'squeeze-kernel[full]' dependencies."
|
|
66
|
+
) from exc
|
|
67
|
+
|
|
68
|
+
d2 = np.asarray(d2_samples, dtype=np.float64)
|
|
69
|
+
d2 = d2[np.isfinite(d2) & (d2 > 0)]
|
|
70
|
+
if len(d2) < 10:
|
|
71
|
+
raise ValueError("Need at least 10 finite positive d² samples for calibration.")
|
|
72
|
+
|
|
73
|
+
mu = float(d2.mean())
|
|
74
|
+
# Closed-form initializer (Proposition 1): κ₀ = μ·(1/w₀ − 1)
|
|
75
|
+
kappa_init = mu * (1.0 / target_mean_weight - 1.0)
|
|
76
|
+
|
|
77
|
+
# Refine via root finding
|
|
78
|
+
def residual(kappa):
|
|
79
|
+
return float(np.mean(d2 / (d2 + kappa))) - target_mean_weight
|
|
80
|
+
|
|
81
|
+
lo = max(kappa_init * 0.01, 1e-6)
|
|
82
|
+
hi = kappa_init * 100.0
|
|
83
|
+
# Ensure bracket
|
|
84
|
+
while residual(lo) < 0:
|
|
85
|
+
lo *= 0.1
|
|
86
|
+
while residual(hi) > 0:
|
|
87
|
+
hi *= 10.0
|
|
88
|
+
|
|
89
|
+
return float(brentq(residual, lo, hi, xtol=1e-8))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def extract_d2_series(
|
|
93
|
+
returns, lambda_vol: float = 0.98, epsilon: float = 1e-8,
|
|
94
|
+
):
|
|
95
|
+
"""Extract the d̄²_t series from a returns panel for κ calibration.
|
|
96
|
+
|
|
97
|
+
Parameters
|
|
98
|
+
----------
|
|
99
|
+
returns : array-like, shape (T, n)
|
|
100
|
+
Daily return matrix.
|
|
101
|
+
lambda_vol : float
|
|
102
|
+
Volatility decay factor.
|
|
103
|
+
|
|
104
|
+
Returns
|
|
105
|
+
-------
|
|
106
|
+
ndarray, shape (T,)
|
|
107
|
+
Average squared standardized return at each timestep.
|
|
108
|
+
"""
|
|
109
|
+
import numpy as np
|
|
110
|
+
|
|
111
|
+
x = np.asarray(returns, dtype=np.float64)
|
|
112
|
+
t_total, n = x.shape
|
|
113
|
+
var_t = np.zeros(n, dtype=np.float64)
|
|
114
|
+
var_init = np.zeros(n, dtype=bool)
|
|
115
|
+
d2_out = np.empty(t_total, dtype=np.float64)
|
|
116
|
+
one_minus_lv = 1.0 - lambda_vol
|
|
117
|
+
|
|
118
|
+
for t in range(t_total):
|
|
119
|
+
rt = x[t]
|
|
120
|
+
finite = np.isfinite(rt)
|
|
121
|
+
first = finite & ~var_init
|
|
122
|
+
repeat = finite & var_init
|
|
123
|
+
|
|
124
|
+
if np.any(first):
|
|
125
|
+
var_t[first] = rt[first] ** 2 + epsilon
|
|
126
|
+
var_init[first] = True
|
|
127
|
+
if np.any(repeat):
|
|
128
|
+
var_t[repeat] = lambda_vol * var_t[repeat] + one_minus_lv * rt[repeat] ** 2
|
|
129
|
+
|
|
130
|
+
vol = np.sqrt(var_t)
|
|
131
|
+
obs = finite & var_init
|
|
132
|
+
n_obs = int(obs.sum())
|
|
133
|
+
if n_obs > 0:
|
|
134
|
+
z = rt[obs] / (vol[obs] + epsilon)
|
|
135
|
+
d2_out[t] = float(z @ z) / n_obs
|
|
136
|
+
else:
|
|
137
|
+
d2_out[t] = float("nan")
|
|
138
|
+
|
|
139
|
+
return d2_out
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: squeeze-kernel
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Streaming, PSD-by-construction covariance estimator with Fisher-kernel weighting and adaptive shrinkage
|
|
5
|
+
Keywords: covariance,correlation,ewma,kernel,risk,streaming
|
|
6
|
+
Author: Robert Kende
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Science/Research
|
|
10
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Requires-Dist: numpy>=1.24
|
|
19
|
+
Requires-Dist: pytest>=7.0 ; extra == 'dev'
|
|
20
|
+
Requires-Dist: ruff>=0.7 ; extra == 'dev'
|
|
21
|
+
Requires-Dist: scipy>=1.11 ; extra == 'dev'
|
|
22
|
+
Requires-Dist: scipy>=1.11 ; extra == 'full'
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Project-URL: Homepage, https://github.com/r0k3/squeeze-kernel
|
|
25
|
+
Project-URL: Repository, https://github.com/r0k3/squeeze-kernel
|
|
26
|
+
Project-URL: Issues, https://github.com/r0k3/squeeze-kernel/issues
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Provides-Extra: full
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# Squeeze Kernel Covariance Estimator
|
|
32
|
+
|
|
33
|
+
[](https://github.com/r0k3/squeeze-kernel/actions/workflows/ci.yml)
|
|
34
|
+
[](https://pypi.org/project/squeeze-kernel/)
|
|
35
|
+
[](https://pypi.org/project/squeeze-kernel/)
|
|
36
|
+
[](LICENSE)
|
|
37
|
+
|
|
38
|
+
A **streaming covariance estimator for panels of daily financial returns**. One `O(n²)` update per day, positive semi-definite **by construction** at every step, missing values handled **natively**, and defaults that require no tuning. Only dependency: NumPy.
|
|
39
|
+
|
|
40
|
+
Reference: *"The Squeeze Kernel Covariance Estimator: Dual-Timescale Tracking with Adaptive Shrinkage"* (Kende, 2026).
|
|
41
|
+
|
|
42
|
+
## Why
|
|
43
|
+
|
|
44
|
+
Rolling-window estimators (Ledoit–Wolf, nonlinear shrinkage, RMT denoising) refit over a fixed window each day and cannot adapt within it; multivariate GARCH (DCC) adapts but needs multi-stage estimation and a fragile news coefficient. The Squeeze Kernel is a single streaming recursion that:
|
|
45
|
+
|
|
46
|
+
- **is PSD at every step, structurally** — never needs eigenvalue clipping, nearest-PSD projection, or a solver;
|
|
47
|
+
- **adapts fastest exactly when it matters** — a Fisher-information kernel up-weights high-dispersion (stress) days, when correlation regimes actually move;
|
|
48
|
+
- **regularises itself** — an adaptive equicorrelation shrinkage activates automatically as the asset count approaches the effective sample size, with a provable condition-number bound;
|
|
49
|
+
- **ingests missing values natively** — listings, delistings, and halts enter as `NaN`; no imputation or complete-case subsetting;
|
|
50
|
+
- **is fast** — a full 30-year daily pass takes ~0.75 s at n=100 and ~3.4 s at n=300 (single-threaded), 30–40× faster than rolling-window baselines at scale.
|
|
51
|
+
|
|
52
|
+
On a 30-year S&P 500 panel (n=100, ~7,600 out-of-sample days) it statistically ties DCC on one-step density forecasts and beats EWMA, Ledoit–Wolf, OAS, nonlinear shrinkage, RMT denoising, and the Gerber statistic — and it is the only method in the 90% model confidence set together with DCC. At n=300 it leads every competitor that remains statistically viable.
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install squeeze-kernel # NumPy only
|
|
58
|
+
pip install "squeeze-kernel[full]" # + SciPy (kappa calibration, chi² kernel)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Quickstart
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import numpy as np
|
|
65
|
+
from squeeze_kernel import SqueezeKernelEstimator
|
|
66
|
+
|
|
67
|
+
# daily_returns: array of shape (T, n) — may contain NaN for missing assets
|
|
68
|
+
est = SqueezeKernelEstimator(n_assets=daily_returns.shape[1])
|
|
69
|
+
|
|
70
|
+
for r_t in daily_returns: # stream one day at a time
|
|
71
|
+
est.update(r_t)
|
|
72
|
+
|
|
73
|
+
cov = est.get_cov() # (n, n) covariance, PSD by construction
|
|
74
|
+
corr = est.get_corr() # (n, n) correlation
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
That is the whole API for most uses. The defaults (`lambda_vol=0.98`, `lambda_corr=0.996`, `kappa=0.25`) are the paper-recommended settings for daily returns, selected by time-series cross-validation and robust across a 50× parameter sweep — deploy them as-is.
|
|
78
|
+
|
|
79
|
+
Batch mode, if you prefer the full path in one call:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from squeeze_kernel import estimate_squeeze_cov
|
|
83
|
+
|
|
84
|
+
cov_path, corr_path, weights = estimate_squeeze_cov(daily_returns, with_weights=True)
|
|
85
|
+
# cov_path: (T, n, n) — the estimate after each day
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
A complete runnable walkthrough (streaming, missing data, batch) is in [`examples/quickstart.py`](examples/quickstart.py).
|
|
89
|
+
|
|
90
|
+
## Missing values
|
|
91
|
+
|
|
92
|
+
Pass `NaN` for any asset not observed on a given day — nothing else to do:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
r_t = np.array([0.004, np.nan, -0.011]) # asset 2 not trading today
|
|
96
|
+
est.update(r_t) # PSD preserved, no imputation
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Parameters
|
|
100
|
+
|
|
101
|
+
| Parameter | Default | Meaning |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| `lambda_vol` | `0.98` | volatility EWMA decay (half-life ≈ 34 days) |
|
|
104
|
+
| `lambda_corr` | `0.996` | correlation EWMA decay (half-life ≈ 173 days, T_eff ≈ 250) |
|
|
105
|
+
| `kappa` | `0.25` | Fisher kernel saturation; higher = stronger calm-day filtering |
|
|
106
|
+
| `shrinkage` | `"auto"` | adaptive equicorrelation shrinkage (`"none"` or a float to override) |
|
|
107
|
+
| `shrinkage_delta` | `0.10` | concentration threshold at which shrinkage activates |
|
|
108
|
+
|
|
109
|
+
Useful read-only state after each `update()`: `est.weight` (last kernel weight), `est.effective_sample_size` (kernel-weighted T_eff), `est.shrinkage_intensity` (current α).
|
|
110
|
+
|
|
111
|
+
To recalibrate `kappa` for a different asset class (requires the `full` extra):
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
kappa = SqueezeKernelEstimator.calibrate_kappa(burn_in_returns, target_weight=0.6)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Advanced options
|
|
118
|
+
|
|
119
|
+
**Score-exact weighting** (`weight_statistic="mahalanobis"`, use with `kappa=1.0`): drives the kernel with the Mahalanobis surprise `z'C⁻¹z/N` against the estimator's own correlation instead of the marginal dispersion. Improves accuracy in the moderate-concentration regime — use only when `n / T_eff ≲ 0.5` (e.g. n ≤ 100 at the default `lambda_corr`); at higher concentration the estimated inverse degrades it and the default is strictly better.
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
est = SqueezeKernelEstimator(n_assets=100, kappa=1.0, weight_statistic="mahalanobis")
|
|
123
|
+
```
|
|
124
|
+
|
|
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
|
+
|
|
127
|
+
**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
|
+
|
|
129
|
+
## How it works
|
|
130
|
+
|
|
131
|
+
Three mechanisms in one recursion:
|
|
132
|
+
|
|
133
|
+
1. **Dual-timescale EWMA** — fast per-asset volatility (`lambda_vol`) is separated from slow correlation dynamics (`lambda_corr`), so variance shocks don't contaminate the correlation estimate.
|
|
134
|
+
2. **Fisher kernel weighting** — each day's standardized outer product enters with weight `w = d²/(d² + kappa)`, where `d²` is the mean squared standardized return: calm days contribute little, dispersion shocks contribute fully.
|
|
135
|
+
3. **Adaptive equicorrelation shrinkage** — `alpha = min(1, max(0, n/(2·S) − delta))` blends toward an equicorrelation target using the estimator's own kernel-weighted sample size `S`; it is a no-op at low dimension and provides provably bounded conditioning at high dimension.
|
|
136
|
+
|
|
137
|
+
The complete update is a natural-gradient step on the Gaussian log-likelihood, with the kernel weight acting as an adaptive Riemannian learning rate (paper, Appendix B).
|
|
138
|
+
|
|
139
|
+
## Development
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
uv sync --extra full --extra dev
|
|
143
|
+
uv run python -m pytest # test suite
|
|
144
|
+
uv run python -m ruff check . # lint
|
|
145
|
+
uv build # build sdist + wheel
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Releases: publishing a GitHub release from a `v*` tag triggers the [publish workflow](.github/workflows/publish.yml), which builds and uploads to PyPI via trusted publishing.
|
|
149
|
+
|
|
150
|
+
## Citation
|
|
151
|
+
|
|
152
|
+
```bibtex
|
|
153
|
+
@article{kende2026squeeze,
|
|
154
|
+
title = {The Squeeze Kernel Covariance Estimator: Dual-Timescale Tracking with Adaptive Shrinkage},
|
|
155
|
+
author = {Kende, Robert},
|
|
156
|
+
year = {2026}
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
See also [`CITATION.cff`](CITATION.cff).
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
squeeze_kernel/__init__.py,sha256=P9W7ElU6aaYXOeVUwuON8UqImub2Qp1B1ckc9NFjagk,1029
|
|
2
|
+
squeeze_kernel/batch.py,sha256=os5SQAlghvZjPfRwELuKRqJbL6mVweJb6Z5A8QKhxDc,2563
|
|
3
|
+
squeeze_kernel/estimator.py,sha256=HwnTPKtQx8NneTFeD51ZpY8vygk1Y36hQnIuNRkojcU,16161
|
|
4
|
+
squeeze_kernel/kernels.py,sha256=O1hMFhGdXxWVA0KrSa5j_wwLYp2B_hMNJeSqcdfFOM0,4197
|
|
5
|
+
squeeze_kernel-0.2.0.dist-info/WHEEL,sha256=uOqnPWqgFlbov4NeTCercq7cBQ2UN7xh5fiW55lOnAg,81
|
|
6
|
+
squeeze_kernel-0.2.0.dist-info/METADATA,sha256=m2FGPjFxsflfH8mngaR9TGYygbuBn_f7nYd5p5af4to,8656
|
|
7
|
+
squeeze_kernel-0.2.0.dist-info/RECORD,,
|