squeeze-kernel 0.2.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.
@@ -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
+ [![CI](https://github.com/r0k3/squeeze-kernel/actions/workflows/ci.yml/badge.svg)](https://github.com/r0k3/squeeze-kernel/actions/workflows/ci.yml)
34
+ [![PyPI](https://img.shields.io/pypi/v/squeeze-kernel.svg)](https://pypi.org/project/squeeze-kernel/)
35
+ [![Python](https://img.shields.io/pypi/pyversions/squeeze-kernel.svg)](https://pypi.org/project/squeeze-kernel/)
36
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](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,134 @@
1
+ # Squeeze Kernel Covariance Estimator
2
+
3
+ [![CI](https://github.com/r0k3/squeeze-kernel/actions/workflows/ci.yml/badge.svg)](https://github.com/r0k3/squeeze-kernel/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/squeeze-kernel.svg)](https://pypi.org/project/squeeze-kernel/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/squeeze-kernel.svg)](https://pypi.org/project/squeeze-kernel/)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7
+
8
+ 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.
9
+
10
+ Reference: *"The Squeeze Kernel Covariance Estimator: Dual-Timescale Tracking with Adaptive Shrinkage"* (Kende, 2026).
11
+
12
+ ## Why
13
+
14
+ 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:
15
+
16
+ - **is PSD at every step, structurally** — never needs eigenvalue clipping, nearest-PSD projection, or a solver;
17
+ - **adapts fastest exactly when it matters** — a Fisher-information kernel up-weights high-dispersion (stress) days, when correlation regimes actually move;
18
+ - **regularises itself** — an adaptive equicorrelation shrinkage activates automatically as the asset count approaches the effective sample size, with a provable condition-number bound;
19
+ - **ingests missing values natively** — listings, delistings, and halts enter as `NaN`; no imputation or complete-case subsetting;
20
+ - **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.
21
+
22
+ 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.
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ pip install squeeze-kernel # NumPy only
28
+ pip install "squeeze-kernel[full]" # + SciPy (kappa calibration, chi² kernel)
29
+ ```
30
+
31
+ ## Quickstart
32
+
33
+ ```python
34
+ import numpy as np
35
+ from squeeze_kernel import SqueezeKernelEstimator
36
+
37
+ # daily_returns: array of shape (T, n) — may contain NaN for missing assets
38
+ est = SqueezeKernelEstimator(n_assets=daily_returns.shape[1])
39
+
40
+ for r_t in daily_returns: # stream one day at a time
41
+ est.update(r_t)
42
+
43
+ cov = est.get_cov() # (n, n) covariance, PSD by construction
44
+ corr = est.get_corr() # (n, n) correlation
45
+ ```
46
+
47
+ 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.
48
+
49
+ Batch mode, if you prefer the full path in one call:
50
+
51
+ ```python
52
+ from squeeze_kernel import estimate_squeeze_cov
53
+
54
+ cov_path, corr_path, weights = estimate_squeeze_cov(daily_returns, with_weights=True)
55
+ # cov_path: (T, n, n) — the estimate after each day
56
+ ```
57
+
58
+ A complete runnable walkthrough (streaming, missing data, batch) is in [`examples/quickstart.py`](examples/quickstart.py).
59
+
60
+ ## Missing values
61
+
62
+ Pass `NaN` for any asset not observed on a given day — nothing else to do:
63
+
64
+ ```python
65
+ r_t = np.array([0.004, np.nan, -0.011]) # asset 2 not trading today
66
+ est.update(r_t) # PSD preserved, no imputation
67
+ ```
68
+
69
+ ## Parameters
70
+
71
+ | Parameter | Default | Meaning |
72
+ |---|---|---|
73
+ | `lambda_vol` | `0.98` | volatility EWMA decay (half-life ≈ 34 days) |
74
+ | `lambda_corr` | `0.996` | correlation EWMA decay (half-life ≈ 173 days, T_eff ≈ 250) |
75
+ | `kappa` | `0.25` | Fisher kernel saturation; higher = stronger calm-day filtering |
76
+ | `shrinkage` | `"auto"` | adaptive equicorrelation shrinkage (`"none"` or a float to override) |
77
+ | `shrinkage_delta` | `0.10` | concentration threshold at which shrinkage activates |
78
+
79
+ Useful read-only state after each `update()`: `est.weight` (last kernel weight), `est.effective_sample_size` (kernel-weighted T_eff), `est.shrinkage_intensity` (current α).
80
+
81
+ To recalibrate `kappa` for a different asset class (requires the `full` extra):
82
+
83
+ ```python
84
+ kappa = SqueezeKernelEstimator.calibrate_kappa(burn_in_returns, target_weight=0.6)
85
+ ```
86
+
87
+ ## Advanced options
88
+
89
+ **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.
90
+
91
+ ```python
92
+ est = SqueezeKernelEstimator(n_assets=100, kappa=1.0, weight_statistic="mahalanobis")
93
+ ```
94
+
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
+
97
+ **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
+
99
+ ## How it works
100
+
101
+ Three mechanisms in one recursion:
102
+
103
+ 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.
104
+ 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.
105
+ 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.
106
+
107
+ 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).
108
+
109
+ ## Development
110
+
111
+ ```bash
112
+ uv sync --extra full --extra dev
113
+ uv run python -m pytest # test suite
114
+ uv run python -m ruff check . # lint
115
+ uv build # build sdist + wheel
116
+ ```
117
+
118
+ 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.
119
+
120
+ ## Citation
121
+
122
+ ```bibtex
123
+ @article{kende2026squeeze,
124
+ title = {The Squeeze Kernel Covariance Estimator: Dual-Timescale Tracking with Adaptive Shrinkage},
125
+ author = {Kende, Robert},
126
+ year = {2026}
127
+ }
128
+ ```
129
+
130
+ See also [`CITATION.cff`](CITATION.cff).
131
+
132
+ ## License
133
+
134
+ MIT
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.10.6,<0.11.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "squeeze-kernel"
7
+ version = "0.2.0"
8
+ description = "Streaming, PSD-by-construction covariance estimator with Fisher-kernel weighting and adaptive shrinkage"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Robert Kende" }]
13
+ keywords = ["covariance", "correlation", "ewma", "kernel", "risk", "streaming"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Science/Research",
17
+ "Topic :: Scientific/Engineering :: Mathematics",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Operating System :: OS Independent",
25
+ ]
26
+ dependencies = ["numpy>=1.24"]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/r0k3/squeeze-kernel"
30
+ Repository = "https://github.com/r0k3/squeeze-kernel"
31
+ Issues = "https://github.com/r0k3/squeeze-kernel/issues"
32
+
33
+ [project.optional-dependencies]
34
+ full = ["scipy>=1.11"]
35
+ dev = ["pytest>=7.0", "ruff>=0.7", "scipy>=1.11"]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
@@ -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"
@@ -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