squeeze-kernel 0.7.0__tar.gz → 2.0.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.
- squeeze_kernel-2.0.0/PKG-INFO +155 -0
- squeeze_kernel-2.0.0/README.md +124 -0
- {squeeze_kernel-0.7.0 → squeeze_kernel-2.0.0}/pyproject.toml +1 -1
- {squeeze_kernel-0.7.0 → squeeze_kernel-2.0.0}/pyproject.toml.orig +1 -1
- squeeze_kernel-2.0.0/src/squeeze_kernel/__init__.py +42 -0
- squeeze_kernel-2.0.0/src/squeeze_kernel/batch.py +52 -0
- squeeze_kernel-2.0.0/src/squeeze_kernel/core.py +171 -0
- {squeeze_kernel-0.7.0 → squeeze_kernel-2.0.0}/src/squeeze_kernel/estimator.py +214 -319
- squeeze_kernel-2.0.0/src/squeeze_kernel/kernels.py +10 -0
- squeeze_kernel-0.7.0/PKG-INFO +0 -196
- squeeze_kernel-0.7.0/README.md +0 -165
- squeeze_kernel-0.7.0/src/squeeze_kernel/__init__.py +0 -37
- squeeze_kernel-0.7.0/src/squeeze_kernel/batch.py +0 -71
- squeeze_kernel-0.7.0/src/squeeze_kernel/kernels.py +0 -140
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: squeeze-kernel
|
|
3
|
+
Version: 2.0.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: mypy>=1.10 ; extra == 'dev'
|
|
23
|
+
Requires-Dist: scipy>=1.11 ; extra == 'full'
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Project-URL: Homepage, https://github.com/r0k3/squeeze-kernel
|
|
26
|
+
Project-URL: Repository, https://github.com/r0k3/squeeze-kernel
|
|
27
|
+
Project-URL: Issues, https://github.com/r0k3/squeeze-kernel/issues
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Provides-Extra: full
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# Squeeze Kernel Covariance Estimator
|
|
33
|
+
|
|
34
|
+
[](https://github.com/r0k3/squeeze-kernel/actions/workflows/ci.yml)
|
|
35
|
+
[](https://pypi.org/project/squeeze-kernel/)
|
|
36
|
+
[](https://pypi.org/project/squeeze-kernel/)
|
|
37
|
+
[](LICENSE)
|
|
38
|
+
|
|
39
|
+
A **streaming covariance estimator for panels of financial returns** whose entire public surface is **one number** — the decay `lam` of the anchor correlation timescale. Every other quantity is derived from it, fixed by a structural argument, or computed online from the estimator's own state. One `O(n²)` update per day, positive semi-definite **by construction**, missing values handled **natively**, no tuning, no refits. Only dependency: NumPy.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from squeeze_kernel import SqueezeKernel
|
|
43
|
+
|
|
44
|
+
sk = SqueezeKernel(lam=0.996) # the entire public surface
|
|
45
|
+
for r_t in returns: # NaN marks missing assets
|
|
46
|
+
sk.update(r_t)
|
|
47
|
+
cov = sk.covariance()
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Reference: *"The Squeeze Kernel Covariance Estimator: Dual-Timescale Tracking with Adaptive Shrinkage"* (Kende, 2026) — [SSRN abstract 6455918](https://ssrn.com/abstract=6455918); the 2.0 estimator is described in the paper's current revision.
|
|
51
|
+
|
|
52
|
+
## Why
|
|
53
|
+
|
|
54
|
+
Markets do not keep calendar time. Following Mandelbrot, the estimator treats a panel as a collection of partially coupled markets, **each advancing on its own activity-driven clock** — and reads those clocks from the panel's own correlation structure, so a hot cluster (say precious metals and FX) advances its correlation state while an idle one (agriculture) does not, without anyone identifying a cluster. On those clocks it runs a single recursion that:
|
|
55
|
+
|
|
56
|
+
- **is PSD at every step, structurally** — the correlation state evolves by a diagonal-congruence flow (a congruence plus a rank-one term); no eigenvalue clipping, no nearest-PSD repair, no solver on the online path;
|
|
57
|
+
- **learns in market time and forgets in calendar time** — observations enter with a saturating, self-studentising weight (no day counts more than one unit of trading time); memory decays at fixed per-day rates on a geometric ladder of three timescales `(lam⁴, lam, lam^¼)`;
|
|
58
|
+
- **regularises itself** — each timescale's shrinkage intensity is computed from two online statistics, the concentration `n/ν` (dimension per unit trading time) and the de-noised fraction of correlation dispersion the target explains; the target is the Hadamard square of the running correlation (cluster-respecting, PSD by the Schur product theorem);
|
|
59
|
+
- **adapts its memory to regime breaks** — a Page-CUSUM detector on the inter-timescale score drift, under an explicit two-year false-alarm budget, reallocates weight across timescales and self-silences where no break signatures exist;
|
|
60
|
+
- **ingests missing values natively** — listings, delistings, halts enter as `NaN`;
|
|
61
|
+
- **is fast** — a thirty-year daily pass at n=300 takes ~40 s single-threaded, two orders of magnitude under daily rolling-window refits.
|
|
62
|
+
|
|
63
|
+
**Evidence.** On thirty years of S&P 500 constituents against an eleven-method field (EWMA, DCC, Ledoit–Wolf, OAS, nonlinear shrinkage, RMT filtering, Gerber, IEWMA, CM-IEWMA, and the published v1 estimator) it leads at every universe size from 50 to 300 and is the **sole member of the 90% model confidence set at every size**. Carried **zero-shot** to a diversified panel of 121 futures across eight asset classes it beats the same field *calibrated on that panel's own history* — matched-backbone IEWMA by 6.9 NLL/day (p = 4·10⁻⁴), calibrated DCC by 17.9 — out-of-time.
|
|
64
|
+
|
|
65
|
+
## See the difference
|
|
66
|
+
|
|
67
|
+
A passive strategy any allocator would recognize: long-only minimum-variance over 300 liquid US stocks, scaled to a 15% volatility target, rebalanced monthly, 5 bps costs. Two runs on identical data; the only difference is the covariance matrix. The Squeeze Kernel arm runs `SqueezeKernel(lam=0.996)` — **nothing tuned on this panel**.
|
|
68
|
+
|
|
69
|
+
<picture>
|
|
70
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/r0k3/squeeze-kernel/main/examples/figures/vol_targeted_portfolio_dark.png">
|
|
71
|
+
<img alt="Vol-targeted long-only minimum-variance portfolio on 300 US equities: Squeeze Kernel vs Ledoit-Wolf equity curves, drawdown, realized volatility, and risk/return profile" src="https://raw.githubusercontent.com/r0k3/squeeze-kernel/main/examples/figures/vol_targeted_portfolio.png">
|
|
72
|
+
</picture>
|
|
73
|
+
|
|
74
|
+
| Method | CAGR | Vol | Sharpe | MaxDD | Calmar | Vol-target RMSE |
|
|
75
|
+
|---|---|---|---|---|---|---|
|
|
76
|
+
| **Squeeze Kernel (default)** | **12.4%** | **13.5%** | **0.91** | **-34.6%** | **0.36** | **7.10%** |
|
|
77
|
+
| Ledoit-Wolf (252d) | 11.7% | 14.6% | 0.80 | -38.7% | 0.30 | 7.51% |
|
|
78
|
+
|
|
79
|
+
Reproduce from the repo alone (the 300-stock panel ships as a parquet; survivorship and provenance are documented in the script):
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pip install squeeze-kernel pandas pyarrow scikit-learn matplotlib
|
|
83
|
+
python examples/vol_targeted_portfolio.py # ~2 minutes
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Installation
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pip install squeeze-kernel # NumPy only
|
|
90
|
+
pip install "squeeze-kernel[full]" # + SciPy (faster detector factorisations)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Quickstart
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
import numpy as np
|
|
97
|
+
from squeeze_kernel import SqueezeKernel, estimate_squeeze_cov
|
|
98
|
+
|
|
99
|
+
returns = np.random.default_rng(42).normal(0.0, 0.01, size=(500, 30))
|
|
100
|
+
|
|
101
|
+
sk = SqueezeKernel() # lam=0.996 (anchor half-life ~173 days)
|
|
102
|
+
for r_t in returns:
|
|
103
|
+
w = sk.update(r_t) # returns the day's kernel weight
|
|
104
|
+
cov, corr = sk.covariance(), sk.correlation()
|
|
105
|
+
sk.state() # kernel scale, per-timescale effective sizes, detector tilt
|
|
106
|
+
|
|
107
|
+
# batch mode: full panel in, covariance path out
|
|
108
|
+
cov_path, corr_path, weights = estimate_squeeze_cov(returns, with_weights=True)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Missing values: pass `NaN` (or `mask=` on `update`). Newly listed, delisted or halted assets need no imputation and no complete-case subsetting.
|
|
112
|
+
|
|
113
|
+
## What derives from `lam`
|
|
114
|
+
|
|
115
|
+
| quantity | value |
|
|
116
|
+
|---|---|
|
|
117
|
+
| timescale ladder | decays `(lam⁴, lam, lam^¼)` — half-lives `(h/4, h, 4h)`, `h = -1/log2(lam)` |
|
|
118
|
+
| kernel scale | state: `κ_t = ⅓ · EWMA(activity)` at the anchor rate |
|
|
119
|
+
| shrinkage intensity | per timescale, `α = min(1,c) · g̃²/(g̃² + (1−g̃)²·max(0, 1/c − 1))` from the online concentration `c = n/ν` and target-fit `g̃` |
|
|
120
|
+
| timescale weights | prior ∝ √h, tilted by the surprise detector |
|
|
121
|
+
| structural constants | K=3, b=4, θ=½, κ-scale ⅓, Schur power 2, detector budget — each bracketed by ablation in the paper |
|
|
122
|
+
| the one empirical constant | volatility clock `λ_v = 0.98`, disclosed |
|
|
123
|
+
|
|
124
|
+
`from squeeze_kernel import CONSTANTS` exposes the structural constants for research. The published v1 estimator (all its knobs) remains available as `SqueezeKernelEstimator` / `SqueezeKernel.v1(...)`; every 2.0 mechanism is also an estimator-level switch for ablation. See [MIGRATION.md](MIGRATION.md).
|
|
125
|
+
|
|
126
|
+
## How it works
|
|
127
|
+
|
|
128
|
+
One daily update: variance EWMA per asset → standardised surprise → per-asset clock increments from the Schur-square-weighted neighbourhood mean of squared surprises → diagonal-congruence update of each timescale's correlation state on those clocks → per-timescale self-tuning shrinkage toward the Hadamard-square target → surprise-gated blend across timescales → covariance. The paper gives the derivations, guarantees (PSD, conditioning floor, exact reductions to the published special cases), and the full evaluation.
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
uv sync --extra full --extra dev
|
|
134
|
+
uv run python -m pytest # test suite
|
|
135
|
+
uv run python -m ruff check . # lint
|
|
136
|
+
uv run mypy # strict type check (src/squeeze_kernel)
|
|
137
|
+
uv build # build sdist + wheel
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Citation
|
|
141
|
+
|
|
142
|
+
```bibtex
|
|
143
|
+
@article{kende2026squeeze,
|
|
144
|
+
title = {The Squeeze Kernel Covariance Estimator: Dual-Timescale Tracking with Adaptive Shrinkage},
|
|
145
|
+
author = {Kende, Robert},
|
|
146
|
+
year = {2026},
|
|
147
|
+
note = {Available at SSRN: \url{https://ssrn.com/abstract=6455918}}
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
See also [`CITATION.cff`](CITATION.cff).
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Squeeze Kernel Covariance Estimator
|
|
2
|
+
|
|
3
|
+
[](https://github.com/r0k3/squeeze-kernel/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/squeeze-kernel/)
|
|
5
|
+
[](https://pypi.org/project/squeeze-kernel/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
A **streaming covariance estimator for panels of financial returns** whose entire public surface is **one number** — the decay `lam` of the anchor correlation timescale. Every other quantity is derived from it, fixed by a structural argument, or computed online from the estimator's own state. One `O(n²)` update per day, positive semi-definite **by construction**, missing values handled **natively**, no tuning, no refits. Only dependency: NumPy.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from squeeze_kernel import SqueezeKernel
|
|
12
|
+
|
|
13
|
+
sk = SqueezeKernel(lam=0.996) # the entire public surface
|
|
14
|
+
for r_t in returns: # NaN marks missing assets
|
|
15
|
+
sk.update(r_t)
|
|
16
|
+
cov = sk.covariance()
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Reference: *"The Squeeze Kernel Covariance Estimator: Dual-Timescale Tracking with Adaptive Shrinkage"* (Kende, 2026) — [SSRN abstract 6455918](https://ssrn.com/abstract=6455918); the 2.0 estimator is described in the paper's current revision.
|
|
20
|
+
|
|
21
|
+
## Why
|
|
22
|
+
|
|
23
|
+
Markets do not keep calendar time. Following Mandelbrot, the estimator treats a panel as a collection of partially coupled markets, **each advancing on its own activity-driven clock** — and reads those clocks from the panel's own correlation structure, so a hot cluster (say precious metals and FX) advances its correlation state while an idle one (agriculture) does not, without anyone identifying a cluster. On those clocks it runs a single recursion that:
|
|
24
|
+
|
|
25
|
+
- **is PSD at every step, structurally** — the correlation state evolves by a diagonal-congruence flow (a congruence plus a rank-one term); no eigenvalue clipping, no nearest-PSD repair, no solver on the online path;
|
|
26
|
+
- **learns in market time and forgets in calendar time** — observations enter with a saturating, self-studentising weight (no day counts more than one unit of trading time); memory decays at fixed per-day rates on a geometric ladder of three timescales `(lam⁴, lam, lam^¼)`;
|
|
27
|
+
- **regularises itself** — each timescale's shrinkage intensity is computed from two online statistics, the concentration `n/ν` (dimension per unit trading time) and the de-noised fraction of correlation dispersion the target explains; the target is the Hadamard square of the running correlation (cluster-respecting, PSD by the Schur product theorem);
|
|
28
|
+
- **adapts its memory to regime breaks** — a Page-CUSUM detector on the inter-timescale score drift, under an explicit two-year false-alarm budget, reallocates weight across timescales and self-silences where no break signatures exist;
|
|
29
|
+
- **ingests missing values natively** — listings, delistings, halts enter as `NaN`;
|
|
30
|
+
- **is fast** — a thirty-year daily pass at n=300 takes ~40 s single-threaded, two orders of magnitude under daily rolling-window refits.
|
|
31
|
+
|
|
32
|
+
**Evidence.** On thirty years of S&P 500 constituents against an eleven-method field (EWMA, DCC, Ledoit–Wolf, OAS, nonlinear shrinkage, RMT filtering, Gerber, IEWMA, CM-IEWMA, and the published v1 estimator) it leads at every universe size from 50 to 300 and is the **sole member of the 90% model confidence set at every size**. Carried **zero-shot** to a diversified panel of 121 futures across eight asset classes it beats the same field *calibrated on that panel's own history* — matched-backbone IEWMA by 6.9 NLL/day (p = 4·10⁻⁴), calibrated DCC by 17.9 — out-of-time.
|
|
33
|
+
|
|
34
|
+
## See the difference
|
|
35
|
+
|
|
36
|
+
A passive strategy any allocator would recognize: long-only minimum-variance over 300 liquid US stocks, scaled to a 15% volatility target, rebalanced monthly, 5 bps costs. Two runs on identical data; the only difference is the covariance matrix. The Squeeze Kernel arm runs `SqueezeKernel(lam=0.996)` — **nothing tuned on this panel**.
|
|
37
|
+
|
|
38
|
+
<picture>
|
|
39
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/r0k3/squeeze-kernel/main/examples/figures/vol_targeted_portfolio_dark.png">
|
|
40
|
+
<img alt="Vol-targeted long-only minimum-variance portfolio on 300 US equities: Squeeze Kernel vs Ledoit-Wolf equity curves, drawdown, realized volatility, and risk/return profile" src="https://raw.githubusercontent.com/r0k3/squeeze-kernel/main/examples/figures/vol_targeted_portfolio.png">
|
|
41
|
+
</picture>
|
|
42
|
+
|
|
43
|
+
| Method | CAGR | Vol | Sharpe | MaxDD | Calmar | Vol-target RMSE |
|
|
44
|
+
|---|---|---|---|---|---|---|
|
|
45
|
+
| **Squeeze Kernel (default)** | **12.4%** | **13.5%** | **0.91** | **-34.6%** | **0.36** | **7.10%** |
|
|
46
|
+
| Ledoit-Wolf (252d) | 11.7% | 14.6% | 0.80 | -38.7% | 0.30 | 7.51% |
|
|
47
|
+
|
|
48
|
+
Reproduce from the repo alone (the 300-stock panel ships as a parquet; survivorship and provenance are documented in the script):
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install squeeze-kernel pandas pyarrow scikit-learn matplotlib
|
|
52
|
+
python examples/vol_targeted_portfolio.py # ~2 minutes
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Installation
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install squeeze-kernel # NumPy only
|
|
59
|
+
pip install "squeeze-kernel[full]" # + SciPy (faster detector factorisations)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Quickstart
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import numpy as np
|
|
66
|
+
from squeeze_kernel import SqueezeKernel, estimate_squeeze_cov
|
|
67
|
+
|
|
68
|
+
returns = np.random.default_rng(42).normal(0.0, 0.01, size=(500, 30))
|
|
69
|
+
|
|
70
|
+
sk = SqueezeKernel() # lam=0.996 (anchor half-life ~173 days)
|
|
71
|
+
for r_t in returns:
|
|
72
|
+
w = sk.update(r_t) # returns the day's kernel weight
|
|
73
|
+
cov, corr = sk.covariance(), sk.correlation()
|
|
74
|
+
sk.state() # kernel scale, per-timescale effective sizes, detector tilt
|
|
75
|
+
|
|
76
|
+
# batch mode: full panel in, covariance path out
|
|
77
|
+
cov_path, corr_path, weights = estimate_squeeze_cov(returns, with_weights=True)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Missing values: pass `NaN` (or `mask=` on `update`). Newly listed, delisted or halted assets need no imputation and no complete-case subsetting.
|
|
81
|
+
|
|
82
|
+
## What derives from `lam`
|
|
83
|
+
|
|
84
|
+
| quantity | value |
|
|
85
|
+
|---|---|
|
|
86
|
+
| timescale ladder | decays `(lam⁴, lam, lam^¼)` — half-lives `(h/4, h, 4h)`, `h = -1/log2(lam)` |
|
|
87
|
+
| kernel scale | state: `κ_t = ⅓ · EWMA(activity)` at the anchor rate |
|
|
88
|
+
| shrinkage intensity | per timescale, `α = min(1,c) · g̃²/(g̃² + (1−g̃)²·max(0, 1/c − 1))` from the online concentration `c = n/ν` and target-fit `g̃` |
|
|
89
|
+
| timescale weights | prior ∝ √h, tilted by the surprise detector |
|
|
90
|
+
| structural constants | K=3, b=4, θ=½, κ-scale ⅓, Schur power 2, detector budget — each bracketed by ablation in the paper |
|
|
91
|
+
| the one empirical constant | volatility clock `λ_v = 0.98`, disclosed |
|
|
92
|
+
|
|
93
|
+
`from squeeze_kernel import CONSTANTS` exposes the structural constants for research. The published v1 estimator (all its knobs) remains available as `SqueezeKernelEstimator` / `SqueezeKernel.v1(...)`; every 2.0 mechanism is also an estimator-level switch for ablation. See [MIGRATION.md](MIGRATION.md).
|
|
94
|
+
|
|
95
|
+
## How it works
|
|
96
|
+
|
|
97
|
+
One daily update: variance EWMA per asset → standardised surprise → per-asset clock increments from the Schur-square-weighted neighbourhood mean of squared surprises → diagonal-congruence update of each timescale's correlation state on those clocks → per-timescale self-tuning shrinkage toward the Hadamard-square target → surprise-gated blend across timescales → covariance. The paper gives the derivations, guarantees (PSD, conditioning floor, exact reductions to the published special cases), and the full evaluation.
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
uv sync --extra full --extra dev
|
|
103
|
+
uv run python -m pytest # test suite
|
|
104
|
+
uv run python -m ruff check . # lint
|
|
105
|
+
uv run mypy # strict type check (src/squeeze_kernel)
|
|
106
|
+
uv build # build sdist + wheel
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Citation
|
|
110
|
+
|
|
111
|
+
```bibtex
|
|
112
|
+
@article{kende2026squeeze,
|
|
113
|
+
title = {The Squeeze Kernel Covariance Estimator: Dual-Timescale Tracking with Adaptive Shrinkage},
|
|
114
|
+
author = {Kende, Robert},
|
|
115
|
+
year = {2026},
|
|
116
|
+
note = {Available at SSRN: \url{https://ssrn.com/abstract=6455918}}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
See also [`CITATION.cff`](CITATION.cff).
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "squeeze-kernel"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "2.0.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"
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "squeeze-kernel"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "2.0.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"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Squeeze Kernel Covariance Estimator
|
|
3
|
+
====================================
|
|
4
|
+
|
|
5
|
+
Streaming, PSD-by-construction covariance estimator. The 2.0 API is one
|
|
6
|
+
number; everything else derives from that decay, is a frozen
|
|
7
|
+
structural constant, or is self-tuning state — including per-asset
|
|
8
|
+
market clocks read from the correlation structure itself.
|
|
9
|
+
|
|
10
|
+
Quick start::
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from squeeze_kernel import SqueezeKernel
|
|
14
|
+
|
|
15
|
+
returns = np.random.default_rng(42).normal(0.0, 0.01, size=(250, 30))
|
|
16
|
+
|
|
17
|
+
sk = SqueezeKernel(lam=0.996) # the entire public surface
|
|
18
|
+
for r_t in returns:
|
|
19
|
+
sk.update(r_t) # NaN marks missing assets
|
|
20
|
+
|
|
21
|
+
cov = sk.covariance()
|
|
22
|
+
corr = sk.correlation()
|
|
23
|
+
|
|
24
|
+
The published v1 estimator (all legacy knobs) remains available as
|
|
25
|
+
``SqueezeKernelEstimator`` or ``SqueezeKernel.v1(...)``; see MIGRATION.md.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from squeeze_kernel.core import CONSTANTS, SqueezeKernel, StructuralConstants
|
|
29
|
+
from squeeze_kernel.estimator import SqueezeKernelEstimator
|
|
30
|
+
from squeeze_kernel.kernels import kernel_fisher
|
|
31
|
+
from squeeze_kernel.batch import estimate_squeeze_cov
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"SqueezeKernel",
|
|
35
|
+
"StructuralConstants",
|
|
36
|
+
"CONSTANTS",
|
|
37
|
+
"SqueezeKernelEstimator",
|
|
38
|
+
"estimate_squeeze_cov",
|
|
39
|
+
"kernel_fisher",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
__version__ = "2.0.0"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Batch estimation over an entire returns panel."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from numpy.typing import ArrayLike
|
|
7
|
+
|
|
8
|
+
from squeeze_kernel.core import SqueezeKernel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def estimate_squeeze_cov(
|
|
12
|
+
returns: ArrayLike,
|
|
13
|
+
*,
|
|
14
|
+
lam: float = 0.996,
|
|
15
|
+
with_corr: bool = True,
|
|
16
|
+
with_weights: bool = False,
|
|
17
|
+
) -> tuple[np.ndarray, np.ndarray | None, np.ndarray | None]:
|
|
18
|
+
"""Run :class:`SqueezeKernel` over a panel and collect the estimate path.
|
|
19
|
+
|
|
20
|
+
Parameters
|
|
21
|
+
----------
|
|
22
|
+
returns : array-like, shape (T, n)
|
|
23
|
+
Daily returns; NaN marks missing observations.
|
|
24
|
+
lam : float
|
|
25
|
+
The estimator's single parameter (default 0.996).
|
|
26
|
+
with_corr : bool
|
|
27
|
+
Also return the correlation path.
|
|
28
|
+
with_weights : bool
|
|
29
|
+
Also return the per-day kernel weight.
|
|
30
|
+
|
|
31
|
+
Returns
|
|
32
|
+
-------
|
|
33
|
+
cov : ndarray, shape (T, n, n)
|
|
34
|
+
corr : ndarray or None, shape (T, n, n)
|
|
35
|
+
weights : ndarray or None, shape (T,)
|
|
36
|
+
"""
|
|
37
|
+
values = np.asarray(returns, dtype=np.float64)
|
|
38
|
+
if values.ndim != 2:
|
|
39
|
+
raise ValueError(f"Expected 2D returns, got shape {values.shape}.")
|
|
40
|
+
t_total, n_assets = values.shape
|
|
41
|
+
sk = SqueezeKernel(lam=lam)
|
|
42
|
+
cov = np.empty((t_total, n_assets, n_assets), dtype=np.float64)
|
|
43
|
+
corr = np.empty_like(cov) if with_corr else None
|
|
44
|
+
weights = np.empty(t_total, dtype=np.float64) if with_weights else None
|
|
45
|
+
for t in range(t_total):
|
|
46
|
+
w_t = sk.update(values[t])
|
|
47
|
+
cov[t] = sk.covariance()
|
|
48
|
+
if corr is not None:
|
|
49
|
+
corr[t] = sk.correlation()
|
|
50
|
+
if weights is not None:
|
|
51
|
+
weights[t] = w_t
|
|
52
|
+
return cov, corr, weights
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""squeeze-kernel 2.0 public API: the one-number estimator.
|
|
2
|
+
|
|
3
|
+
``SqueezeKernel`` exposes the v2 configuration of the estimator — every
|
|
4
|
+
constant either derives from ``half_life`` or is a frozen structural
|
|
5
|
+
constant (``CONSTANTS``) — with a public surface of one number and two
|
|
6
|
+
booleans. The v1 estimator remains available unchanged as
|
|
7
|
+
``SqueezeKernelEstimator`` (or via :meth:`SqueezeKernel.v1`).
|
|
8
|
+
|
|
9
|
+
Configuration (research record: squeeze_cov V2_STATUS.md, branch v2):
|
|
10
|
+
|
|
11
|
+
- correlation ladder at ``half_life * (43/173, 1, 693/173)`` — the
|
|
12
|
+
canonical rungs at the default half-life; rung weights ``pi ~ sqrt(h)``
|
|
13
|
+
(theta = 1/2, structural),
|
|
14
|
+
- volatility EWMA at ``lambda_vol = 0.98`` — the single remaining
|
|
15
|
+
empirically frozen constant (the ``h/b`` derivation is falsified by
|
|
16
|
+
crisis sub-periods; see the paper's intensity section),
|
|
17
|
+
- kernel scale as state, not parameter: ``kappa_t = (1/3) EWMA_h(d^2)``
|
|
18
|
+
(chi-squared-null constant),
|
|
19
|
+
- self-tuning shrinkage intensity per rung from the online concentration
|
|
20
|
+
``c = n / nu`` and the de-noised equicorrelation-explained fraction
|
|
21
|
+
``g``: ``alpha = min(1, c) g^2 / (g^2 + (1-g)^2 max(0, 1/c - 1))``,
|
|
22
|
+
- shrinkage target: gamma = 1 Schur-square cluster target (PSD by the
|
|
23
|
+
Schur product theorem) or equicorrelation,
|
|
24
|
+
- sequential surprise-gated rung weights (Page CUSUM on the studentised
|
|
25
|
+
inter-rung predictive-score drift), switchable via ``detector``.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
from numpy.typing import ArrayLike
|
|
33
|
+
|
|
34
|
+
from .estimator import SqueezeKernelEstimator
|
|
35
|
+
|
|
36
|
+
__all__ = ["SqueezeKernel", "StructuralConstants", "CONSTANTS"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class StructuralConstants:
|
|
41
|
+
"""Frozen structural constants of the v2 estimator.
|
|
42
|
+
|
|
43
|
+
Importable for research; not constructor arguments.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
b: float = 4.0 # ladder spacing: rungs (h/b, h, h*b)
|
|
47
|
+
theta: float = 0.5 # rung weights ~ h^theta
|
|
48
|
+
lambda_vol: float = 0.98 # frozen empirical (not derived from h)
|
|
49
|
+
kappa_c: float = 1.0 / 3.0 # kernel scale: kappa = kappa_c * EWMA(activity)
|
|
50
|
+
schur_p: int = 2 # Hadamard power of the cluster target
|
|
51
|
+
epsilon: float = 1e-8
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
CONSTANTS = StructuralConstants()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SqueezeKernel:
|
|
58
|
+
"""Streaming covariance estimator whose public surface is one number.
|
|
59
|
+
|
|
60
|
+
Parameters
|
|
61
|
+
----------
|
|
62
|
+
lam : float
|
|
63
|
+
The single tunable: the exponential decay of the anchor
|
|
64
|
+
correlation timescale per trading day, in the classical EWMA
|
|
65
|
+
convention (default 0.996, a half-life of about 173 days). The
|
|
66
|
+
timescale ladder ``(lam**b, lam, lam**(1/b))`` with ``b = 4``, the
|
|
67
|
+
kernel-scale clock, and the rung weights all derive from it.
|
|
68
|
+
|
|
69
|
+
Everything else is structural or self-tuning state: per-asset market
|
|
70
|
+
clocks read from the correlation neighborhood (row-normalized Schur-
|
|
71
|
+
square weighting of squared surprises), a per-timescale shrinkage
|
|
72
|
+
intensity from the online concentration and target-fit, the Schur-
|
|
73
|
+
square cluster target, and the surprise-gated timescale weights.
|
|
74
|
+
The number of assets is inferred from the first ``update`` call.
|
|
75
|
+
The published v1 estimator and every ablation switch remain available
|
|
76
|
+
on ``SqueezeKernelEstimator``.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, lam: float = 0.996) -> None:
|
|
80
|
+
if not (0.0 < lam < 1.0):
|
|
81
|
+
raise ValueError("lam must be in (0, 1).")
|
|
82
|
+
self.lam = float(lam)
|
|
83
|
+
self._est: SqueezeKernelEstimator | None = None
|
|
84
|
+
self._t = 0
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def half_life(self) -> float:
|
|
88
|
+
"""Anchor half-life in trading days implied by ``lam``."""
|
|
89
|
+
return float(-1.0 / np.log2(self.lam))
|
|
90
|
+
|
|
91
|
+
# ── lifecycle ────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
def _build(self, n_assets: int) -> SqueezeKernelEstimator:
|
|
94
|
+
c = CONSTANTS
|
|
95
|
+
h = self.half_life # ladder = (lam**b, lam, lam**(1/b))
|
|
96
|
+
ladder = (h / c.b, h, h * c.b)
|
|
97
|
+
return SqueezeKernelEstimator(
|
|
98
|
+
n_assets,
|
|
99
|
+
lambda_vol=c.lambda_vol,
|
|
100
|
+
shrinkage="auto",
|
|
101
|
+
shrinkage_target="cluster",
|
|
102
|
+
corr_half_lives=ladder,
|
|
103
|
+
corr_theta=c.theta,
|
|
104
|
+
kappa_mode="adaptive",
|
|
105
|
+
alpha_rule="selftuning",
|
|
106
|
+
level_match=False,
|
|
107
|
+
detector=True,
|
|
108
|
+
clock="asset",
|
|
109
|
+
epsilon=c.epsilon,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# ── public API ───────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
def update(self, r_t: ArrayLike, mask: ArrayLike | None = None) -> float:
|
|
115
|
+
"""Process one return vector; returns the kernel weight w_t.
|
|
116
|
+
|
|
117
|
+
``r_t`` may contain NaN for missing assets; ``mask`` (optional
|
|
118
|
+
boolean, True = observed) is an alternative way to mark them.
|
|
119
|
+
"""
|
|
120
|
+
r = np.asarray(r_t, dtype=np.float64)
|
|
121
|
+
if r.ndim != 1:
|
|
122
|
+
raise ValueError("r_t must be one-dimensional.")
|
|
123
|
+
if mask is not None:
|
|
124
|
+
m = np.asarray(mask, dtype=bool)
|
|
125
|
+
if m.shape != r.shape:
|
|
126
|
+
raise ValueError("mask must match r_t's shape.")
|
|
127
|
+
r = np.where(m, r, np.nan)
|
|
128
|
+
if self._est is None:
|
|
129
|
+
self._est = self._build(r.size)
|
|
130
|
+
self._t += 1
|
|
131
|
+
return self._est.update(r)
|
|
132
|
+
|
|
133
|
+
def covariance(self) -> np.ndarray:
|
|
134
|
+
"""Current covariance estimate (n x n)."""
|
|
135
|
+
if self._est is None:
|
|
136
|
+
raise RuntimeError("Call update() at least once first.")
|
|
137
|
+
return self._est.get_cov()
|
|
138
|
+
|
|
139
|
+
def correlation(self) -> np.ndarray:
|
|
140
|
+
"""Current correlation estimate (n x n)."""
|
|
141
|
+
if self._est is None:
|
|
142
|
+
raise RuntimeError("Call update() at least once first.")
|
|
143
|
+
return self._est.get_corr()
|
|
144
|
+
|
|
145
|
+
def state(self) -> dict[str, object]:
|
|
146
|
+
"""Diagnostics: update count, last weight, kernel scale, per-rung
|
|
147
|
+
effective sizes, detector tilt."""
|
|
148
|
+
if self._est is None:
|
|
149
|
+
return {"n_assets": None, "t": 0}
|
|
150
|
+
est = self._est
|
|
151
|
+
det = est._detector
|
|
152
|
+
S = list(est._S_list or [])
|
|
153
|
+
J = list(est._J_list or [])
|
|
154
|
+
nu = [s * s / max(j, est.epsilon) for s, j in zip(S, J)]
|
|
155
|
+
return {
|
|
156
|
+
"n_assets": est.n_assets,
|
|
157
|
+
"t": self._t,
|
|
158
|
+
"last_weight": est.weight,
|
|
159
|
+
"kappa_t": getattr(est, "_last_kappa",
|
|
160
|
+
CONSTANTS.kappa_c * est._kap_state),
|
|
161
|
+
"rung_S": S,
|
|
162
|
+
"rung_nu": nu,
|
|
163
|
+
"detector_tilt": 0.0 if det is None else det.tilt,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
# ── v1 escape hatch ──────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
def v1(cls, n_assets: int, **kwargs: object) -> SqueezeKernelEstimator:
|
|
170
|
+
"""The published v1 estimator, unchanged (all legacy knobs)."""
|
|
171
|
+
return SqueezeKernelEstimator(n_assets, **kwargs) # type: ignore[arg-type]
|