squeeze-kernel 2.0.0__tar.gz → 3.1.1__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 → squeeze_kernel-3.1.1}/PKG-INFO +14 -12
- {squeeze_kernel-2.0.0 → squeeze_kernel-3.1.1}/README.md +13 -11
- {squeeze_kernel-2.0.0 → squeeze_kernel-3.1.1}/pyproject.toml +1 -1
- {squeeze_kernel-2.0.0 → squeeze_kernel-3.1.1}/pyproject.toml.orig +1 -1
- {squeeze_kernel-2.0.0 → squeeze_kernel-3.1.1}/src/squeeze_kernel/__init__.py +1 -1
- {squeeze_kernel-2.0.0 → squeeze_kernel-3.1.1}/src/squeeze_kernel/core.py +21 -5
- {squeeze_kernel-2.0.0 → squeeze_kernel-3.1.1}/src/squeeze_kernel/estimator.py +392 -48
- {squeeze_kernel-2.0.0 → squeeze_kernel-3.1.1}/src/squeeze_kernel/batch.py +0 -0
- {squeeze_kernel-2.0.0 → squeeze_kernel-3.1.1}/src/squeeze_kernel/kernels.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: squeeze-kernel
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.1.1
|
|
4
4
|
Summary: Streaming, PSD-by-construction covariance estimator with Fisher-kernel weighting and adaptive shrinkage
|
|
5
5
|
Keywords: covariance,correlation,ewma,kernel,risk,streaming
|
|
6
6
|
Author: Robert Kende
|
|
@@ -35,8 +35,9 @@ Description-Content-Type: text/markdown
|
|
|
35
35
|
[](https://pypi.org/project/squeeze-kernel/)
|
|
36
36
|
[](https://pypi.org/project/squeeze-kernel/)
|
|
37
37
|
[](LICENSE)
|
|
38
|
+
[](https://doi.org/10.5281/zenodo.19170801)
|
|
38
39
|
|
|
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.
|
|
40
|
+
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. An `O(Kn²)` state update per day, positive semi-definite **by construction**, missing values handled **natively**, no tuning, no refits. Only dependency: NumPy.
|
|
40
41
|
|
|
41
42
|
```python
|
|
42
43
|
from squeeze_kernel import SqueezeKernel
|
|
@@ -53,12 +54,12 @@ Reference: *"The Squeeze Kernel Covariance Estimator: Dual-Timescale Tracking wi
|
|
|
53
54
|
|
|
54
55
|
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
|
|
|
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
|
|
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** —
|
|
57
|
+
- **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, and no factorisation anywhere in the state update. (The adaptive timescale weights are the one exception: they read each timescale's predictive likelihood, which costs one Cholesky per timescale per day. Turn them off and the estimator is pure `O(Kn²)`.)
|
|
58
|
+
- **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^¼)`. Pairs accrue covariance at the geometric mean of their two clock increments, which is the most positive semi-definiteness allows and exactly the Cauchy–Schwarz bound on how far two assets' clocks can overlap;
|
|
59
|
+
- **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, which is exactly the correlation matrix of the *squared* returns (cluster-respecting, PSD by the Schur product theorem, and sign-blind by construction — the signs are carried by the unshrunk term);
|
|
60
|
+
- **adapts its memory to regime breaks, in both stages** — the timescale mix moves by an exponentiated-gradient step on the *blend's* own log score (not on any single timescale's, which would select rather than blend), at a temperature calibrated so that uninformative evidence leaves the mix within a factor e of its prior; the marginal variance is tracked on its own three-rung ladder, two octaves below the correlation ladder, and pooled panel-wide by the same rule on saturated evidence, so one spike day cannot hand a stale rung weeks of weight. Nothing in either mixture is fitted: the ladders are derived from `lam`, the evidence memory is the fastest rung's, and the temperature is the null's;
|
|
60
61
|
- **ingests missing values natively** — listings, delistings, halts enter as `NaN`;
|
|
61
|
-
- **is fast** — a thirty-year daily pass at n=300
|
|
62
|
+
- **is fast** — one Cholesky and one triangular solve per day; a thirty-year daily pass at n=300 runs in about two minutes single-threaded, well under daily rolling-window refits.
|
|
62
63
|
|
|
63
64
|
**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
|
|
|
@@ -102,7 +103,7 @@ sk = SqueezeKernel() # lam=0.996 (anchor half-life ~173 days)
|
|
|
102
103
|
for r_t in returns:
|
|
103
104
|
w = sk.update(r_t) # returns the day's kernel weight
|
|
104
105
|
cov, corr = sk.covariance(), sk.correlation()
|
|
105
|
-
sk.state() # kernel scale, per-timescale effective sizes,
|
|
106
|
+
sk.state() # kernel scale, per-timescale effective sizes, mixture tilt
|
|
106
107
|
|
|
107
108
|
# batch mode: full panel in, covariance path out
|
|
108
109
|
cov_path, corr_path, weights = estimate_squeeze_cov(returns, with_weights=True)
|
|
@@ -117,15 +118,16 @@ Missing values: pass `NaN` (or `mask=` on `update`). Newly listed, delisted or h
|
|
|
117
118
|
| timescale ladder | decays `(lam⁴, lam, lam^¼)` — half-lives `(h/4, h, 4h)`, `h = -1/log2(lam)` |
|
|
118
119
|
| kernel scale | state: `κ_t = ⅓ · EWMA(activity)` at the anchor rate |
|
|
119
120
|
| 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,
|
|
121
|
-
|
|
|
122
|
-
|
|
|
121
|
+
| timescale weights | prior ∝ √h, moved by exponentiated gradient on the blend log score at the null temperature, fast-rung memory |
|
|
122
|
+
| volatility memory | ladder `(h/16, h/4, h)`, pooled panel-wide by the same rule on tanh-saturated evidence, uniform prior |
|
|
123
|
+
| structural constants | K=3, b=4, θ=½, κ-scale ⅓, Schur power 2 — each bracketed by ablation in the paper |
|
|
124
|
+
| fitted constants | none (the 2.x volatility clock `λ_v = 0.98` is replaced by the ladder above) |
|
|
123
125
|
|
|
124
126
|
`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
127
|
|
|
126
128
|
## How it works
|
|
127
129
|
|
|
128
|
-
One daily update: variance
|
|
130
|
+
One daily update: variance on a three-rung ladder per asset, pooled by panel-wide self-adapting weights → 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 shrinkage as a learned pool of the raw correlation, its equicorrelation level and its Hadamard square, with the self-tuning intensity rule as the prior and the blend gradient as the correction → blend across timescales, moved by the gradient of the blend's own log score → covariance. The paper gives the derivations, guarantees (PSD, conditioning floor, exact reductions to the published special cases), and the full evaluation.
|
|
129
131
|
|
|
130
132
|
## Development
|
|
131
133
|
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
[](https://pypi.org/project/squeeze-kernel/)
|
|
5
5
|
[](https://pypi.org/project/squeeze-kernel/)
|
|
6
6
|
[](LICENSE)
|
|
7
|
+
[](https://doi.org/10.5281/zenodo.19170801)
|
|
7
8
|
|
|
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.
|
|
9
|
+
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. An `O(Kn²)` state update per day, positive semi-definite **by construction**, missing values handled **natively**, no tuning, no refits. Only dependency: NumPy.
|
|
9
10
|
|
|
10
11
|
```python
|
|
11
12
|
from squeeze_kernel import SqueezeKernel
|
|
@@ -22,12 +23,12 @@ Reference: *"The Squeeze Kernel Covariance Estimator: Dual-Timescale Tracking wi
|
|
|
22
23
|
|
|
23
24
|
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
|
|
|
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
|
|
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** —
|
|
26
|
+
- **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, and no factorisation anywhere in the state update. (The adaptive timescale weights are the one exception: they read each timescale's predictive likelihood, which costs one Cholesky per timescale per day. Turn them off and the estimator is pure `O(Kn²)`.)
|
|
27
|
+
- **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^¼)`. Pairs accrue covariance at the geometric mean of their two clock increments, which is the most positive semi-definiteness allows and exactly the Cauchy–Schwarz bound on how far two assets' clocks can overlap;
|
|
28
|
+
- **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, which is exactly the correlation matrix of the *squared* returns (cluster-respecting, PSD by the Schur product theorem, and sign-blind by construction — the signs are carried by the unshrunk term);
|
|
29
|
+
- **adapts its memory to regime breaks, in both stages** — the timescale mix moves by an exponentiated-gradient step on the *blend's* own log score (not on any single timescale's, which would select rather than blend), at a temperature calibrated so that uninformative evidence leaves the mix within a factor e of its prior; the marginal variance is tracked on its own three-rung ladder, two octaves below the correlation ladder, and pooled panel-wide by the same rule on saturated evidence, so one spike day cannot hand a stale rung weeks of weight. Nothing in either mixture is fitted: the ladders are derived from `lam`, the evidence memory is the fastest rung's, and the temperature is the null's;
|
|
29
30
|
- **ingests missing values natively** — listings, delistings, halts enter as `NaN`;
|
|
30
|
-
- **is fast** — a thirty-year daily pass at n=300
|
|
31
|
+
- **is fast** — one Cholesky and one triangular solve per day; a thirty-year daily pass at n=300 runs in about two minutes single-threaded, well under daily rolling-window refits.
|
|
31
32
|
|
|
32
33
|
**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
|
|
|
@@ -71,7 +72,7 @@ sk = SqueezeKernel() # lam=0.996 (anchor half-life ~173 days)
|
|
|
71
72
|
for r_t in returns:
|
|
72
73
|
w = sk.update(r_t) # returns the day's kernel weight
|
|
73
74
|
cov, corr = sk.covariance(), sk.correlation()
|
|
74
|
-
sk.state() # kernel scale, per-timescale effective sizes,
|
|
75
|
+
sk.state() # kernel scale, per-timescale effective sizes, mixture tilt
|
|
75
76
|
|
|
76
77
|
# batch mode: full panel in, covariance path out
|
|
77
78
|
cov_path, corr_path, weights = estimate_squeeze_cov(returns, with_weights=True)
|
|
@@ -86,15 +87,16 @@ Missing values: pass `NaN` (or `mask=` on `update`). Newly listed, delisted or h
|
|
|
86
87
|
| timescale ladder | decays `(lam⁴, lam, lam^¼)` — half-lives `(h/4, h, 4h)`, `h = -1/log2(lam)` |
|
|
87
88
|
| kernel scale | state: `κ_t = ⅓ · EWMA(activity)` at the anchor rate |
|
|
88
89
|
| 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,
|
|
90
|
-
|
|
|
91
|
-
|
|
|
90
|
+
| timescale weights | prior ∝ √h, moved by exponentiated gradient on the blend log score at the null temperature, fast-rung memory |
|
|
91
|
+
| volatility memory | ladder `(h/16, h/4, h)`, pooled panel-wide by the same rule on tanh-saturated evidence, uniform prior |
|
|
92
|
+
| structural constants | K=3, b=4, θ=½, κ-scale ⅓, Schur power 2 — each bracketed by ablation in the paper |
|
|
93
|
+
| fitted constants | none (the 2.x volatility clock `λ_v = 0.98` is replaced by the ladder above) |
|
|
92
94
|
|
|
93
95
|
`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
96
|
|
|
95
97
|
## How it works
|
|
96
98
|
|
|
97
|
-
One daily update: variance
|
|
99
|
+
One daily update: variance on a three-rung ladder per asset, pooled by panel-wide self-adapting weights → 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 shrinkage as a learned pool of the raw correlation, its equicorrelation level and its Hadamard square, with the self-tuning intensity rule as the prior and the blend gradient as the correction → blend across timescales, moved by the gradient of the blend's own log score → covariance. The paper gives the derivations, guarantees (PSD, conditioning floor, exact reductions to the published special cases), and the full evaluation.
|
|
98
100
|
|
|
99
101
|
## Development
|
|
100
102
|
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "squeeze-kernel"
|
|
7
|
-
version = "
|
|
7
|
+
version = "3.1.1"
|
|
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 = "
|
|
7
|
+
version = "3.1.1"
|
|
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"
|
|
@@ -11,9 +11,21 @@ Configuration (research record: squeeze_cov V2_STATUS.md, branch v2):
|
|
|
11
11
|
- correlation ladder at ``half_life * (43/173, 1, 693/173)`` — the
|
|
12
12
|
canonical rungs at the default half-life; rung weights ``pi ~ sqrt(h)``
|
|
13
13
|
(theta = 1/2, structural),
|
|
14
|
-
- volatility
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
- volatility on a ladder at ``half_life * (1/16, 1/4, 1)``, pooled
|
|
15
|
+
panel-wide by Bayes at the null-calibrated temperature on saturated
|
|
16
|
+
evidence with the fastest rung's memory and a uniform prior; this
|
|
17
|
+
replaced the 2.x fitted constant ``lambda_vol = 0.98`` (still accepted
|
|
18
|
+
by ``SqueezeKernelEstimator`` and ignored when ``vol_ladder=True``),
|
|
19
|
+
- timescale weights moved by exponentiated gradient on the blend's log
|
|
20
|
+
score at the null temperature (fast-rung memory) from the prior
|
|
21
|
+
``pi ~ sqrt(h)``; replaced the 2.x CUSUM detector,
|
|
22
|
+
- (3.1) each timescale's shrunk correlation is a learned convex
|
|
23
|
+
combination of three PSD unit-diagonal experts -- its raw
|
|
24
|
+
correlation, the equicorrelation matrix at its mean level, and its
|
|
25
|
+
Schur square -- whose prior is the intensity rule's morph
|
|
26
|
+
``(1-a, a(1-a), a^2)`` and whose weights the same blend gradient
|
|
27
|
+
corrects online (studentised within the timescale, same temperature
|
|
28
|
+
and memory). At the prior the estimator is the 3.0 one,
|
|
17
29
|
- kernel scale as state, not parameter: ``kappa_t = (1/3) EWMA_h(d^2)``
|
|
18
30
|
(chi-squared-null constant),
|
|
19
31
|
- self-tuning shrinkage intensity per rung from the online concentration
|
|
@@ -31,7 +43,7 @@ from dataclasses import dataclass
|
|
|
31
43
|
import numpy as np
|
|
32
44
|
from numpy.typing import ArrayLike
|
|
33
45
|
|
|
34
|
-
from .estimator import SqueezeKernelEstimator
|
|
46
|
+
from .estimator import SqueezeKernelEstimator, _BlendGradient
|
|
35
47
|
|
|
36
48
|
__all__ = ["SqueezeKernel", "StructuralConstants", "CONSTANTS"]
|
|
37
49
|
|
|
@@ -45,7 +57,7 @@ class StructuralConstants:
|
|
|
45
57
|
|
|
46
58
|
b: float = 4.0 # ladder spacing: rungs (h/b, h, h*b)
|
|
47
59
|
theta: float = 0.5 # rung weights ~ h^theta
|
|
48
|
-
lambda_vol: float = 0.98 #
|
|
60
|
+
lambda_vol: float = 0.98 # 2.x constant; unused by the 3.0 front-end (vol ladder)
|
|
49
61
|
kappa_c: float = 1.0 / 3.0 # kernel scale: kappa = kappa_c * EWMA(activity)
|
|
50
62
|
schur_p: int = 2 # Hadamard power of the cluster target
|
|
51
63
|
epsilon: float = 1e-8
|
|
@@ -106,6 +118,9 @@ class SqueezeKernel:
|
|
|
106
118
|
level_match=False,
|
|
107
119
|
detector=True,
|
|
108
120
|
clock="asset",
|
|
121
|
+
vol_ladder=True,
|
|
122
|
+
weights="eg_blend",
|
|
123
|
+
split_learn=True,
|
|
109
124
|
epsilon=c.epsilon,
|
|
110
125
|
)
|
|
111
126
|
|
|
@@ -161,6 +176,7 @@ class SqueezeKernel:
|
|
|
161
176
|
"rung_S": S,
|
|
162
177
|
"rung_nu": nu,
|
|
163
178
|
"detector_tilt": 0.0 if det is None else det.tilt,
|
|
179
|
+
"split": det.v.copy() if isinstance(det, _BlendGradient) and det.split else None,
|
|
164
180
|
}
|
|
165
181
|
|
|
166
182
|
# ── v1 escape hatch ──────────────────────────────────────────────────
|
|
@@ -128,6 +128,226 @@ class _SurpriseDetector:
|
|
|
128
128
|
return np.asarray((1.0 + t) * prior + (-t) * self.pi_slow)
|
|
129
129
|
|
|
130
130
|
|
|
131
|
+
class _BlendGradient:
|
|
132
|
+
"""Exponentiated gradient on the BLEND's log score, at the
|
|
133
|
+
null-calibrated temperature.
|
|
134
|
+
|
|
135
|
+
The emitted covariance is the linear pool Sigma(w) = sum_k w_k Sigma_k
|
|
136
|
+
of the rung covariances. Its Gaussian log score has gradient
|
|
137
|
+
|
|
138
|
+
d ell / d w_k = -1/2 tr(Sigma^-1 Sigma_k) + 1/2 u' Sigma_k u,
|
|
139
|
+
u = Sigma^-1 r,
|
|
140
|
+
|
|
141
|
+
so the update moves the blend rather than selecting a rung (Bayesian
|
|
142
|
+
model averaging over rung likelihoods selects, and collapses). The
|
|
143
|
+
gradient is studentised across rungs by its running rms and scaled by
|
|
144
|
+
sqrt(2 eps), eps the fixed-share rate: the accumulated log-odds then
|
|
145
|
+
have unit variance under uninformative evidence, so the mixture stays
|
|
146
|
+
within a factor e of its prior when there is nothing to learn and
|
|
147
|
+
moves linearly in a persistent advantage. Evidence memory: the
|
|
148
|
+
fastest rung (a regime can change as fast as the fastest rung can
|
|
149
|
+
follow). State: the weights, one scalar scale, and the cached rung
|
|
150
|
+
covariances of the previous step. Causal: ``score`` reads the
|
|
151
|
+
previous step's covariances, ``advance`` updates the weights used for
|
|
152
|
+
the next blend.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
_JITTER = 1e-12
|
|
156
|
+
_FLOOR = 1e-8 # spectral floor of the scored blend
|
|
157
|
+
_CERT = 10.0 # certify lambda_min >= _CERT * _FLOOR before skipping the floor
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _solve(sig: np.ndarray, r: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
161
|
+
"""(sig^-1 r, sig^-1) from one Cholesky factorisation."""
|
|
162
|
+
if _cho_factor is not None:
|
|
163
|
+
cf = _cho_factor(sig, lower=True, check_finite=False)
|
|
164
|
+
return (_cho_solve(cf, r, check_finite=False),
|
|
165
|
+
_cho_solve(cf, np.eye(sig.shape[0]), check_finite=False))
|
|
166
|
+
sinv = np.linalg.inv(sig)
|
|
167
|
+
return sinv @ r, sinv
|
|
168
|
+
|
|
169
|
+
def __init__(self, half_lives: np.ndarray, prior: np.ndarray,
|
|
170
|
+
split: bool = False, n_experts: int = 3) -> None:
|
|
171
|
+
hl = np.asarray(half_lives, dtype=np.float64)
|
|
172
|
+
self.prior = np.asarray(prior, dtype=np.float64)
|
|
173
|
+
self.w = self.prior.copy()
|
|
174
|
+
self.eps = float(1.0 - 2.0 ** (-1.0 / float(hl.min())))
|
|
175
|
+
self._gamma_scale = 2.0 ** (-1.0 / float(np.median(hl)))
|
|
176
|
+
self.scale = 1.0
|
|
177
|
+
self.prev_sig: list[np.ndarray] | None = None
|
|
178
|
+
self.prev_w: np.ndarray | None = None
|
|
179
|
+
# Learned shrinkage split (3.1): each timescale's correlation is a
|
|
180
|
+
# convex combination of three PSD unit-diagonal experts -- raw,
|
|
181
|
+
# equicorrelation at its mean level, Schur square -- whose prior
|
|
182
|
+
# is the intensity rule's morph (1-a, a(1-a), a^2) and whose
|
|
183
|
+
# weights are moved by the same gradient step as the timescale
|
|
184
|
+
# weights, studentised within the timescale. Starts uniform and
|
|
185
|
+
# forgets toward the prior at the fixed-share rate.
|
|
186
|
+
self.split = bool(split)
|
|
187
|
+
self.n_experts = int(n_experts)
|
|
188
|
+
self.v = np.full((hl.size, self.n_experts), 1.0 / self.n_experts)
|
|
189
|
+
self.scale_v = np.ones(hl.size)
|
|
190
|
+
self.prev_comps: list[tuple[np.ndarray, ...]] | None = None
|
|
191
|
+
self.prev_prior: np.ndarray | None = None
|
|
192
|
+
self.prev_cov: np.ndarray | None = None
|
|
193
|
+
self.prev_vol: np.ndarray | None = None
|
|
194
|
+
self.prev_v: np.ndarray | None = None
|
|
195
|
+
self._gc: np.ndarray | None = None
|
|
196
|
+
|
|
197
|
+
def score(self, r_t: np.ndarray, finite: np.ndarray) -> np.ndarray | None:
|
|
198
|
+
"""Gradient of the previous blend's log score at ``r_t`` (observed
|
|
199
|
+
subvector), one entry per rung; ``None`` on a degenerate day."""
|
|
200
|
+
if self.prev_sig is None or self.prev_w is None:
|
|
201
|
+
return None
|
|
202
|
+
idx = np.flatnonzero(finite)
|
|
203
|
+
if idx.size < 2:
|
|
204
|
+
return None
|
|
205
|
+
K = len(self.prev_sig)
|
|
206
|
+
full = idx.size == self.prev_sig[0].shape[0]
|
|
207
|
+
# Gate exactly as the research engine does: the day counts only if
|
|
208
|
+
# every rung's observed sub-block is positive definite (a newly
|
|
209
|
+
# listed asset enters with a zero row and fails this); otherwise
|
|
210
|
+
# the mixture keeps its weights for the day. With the split the
|
|
211
|
+
# rung matrices held here are correlations (vol > 0 on observed
|
|
212
|
+
# assets, so PD of the correlation block <=> PD of the covariance).
|
|
213
|
+
for k in range(K):
|
|
214
|
+
sub = self.prev_sig[k] if full else self.prev_sig[k][np.ix_(idx, idx)]
|
|
215
|
+
if not self.split:
|
|
216
|
+
sub = 0.5 * (sub + sub.T)
|
|
217
|
+
try:
|
|
218
|
+
if _cho_factor is not None:
|
|
219
|
+
_cho_factor(sub, lower=True, check_finite=False)
|
|
220
|
+
else:
|
|
221
|
+
np.linalg.cholesky(sub)
|
|
222
|
+
except (np.linalg.LinAlgError, ValueError):
|
|
223
|
+
return None
|
|
224
|
+
if self.split:
|
|
225
|
+
prev_vol, prev_cov = self.prev_vol, self.prev_cov
|
|
226
|
+
assert prev_vol is not None and prev_cov is not None
|
|
227
|
+
# an asset observed today but without a variance state at the
|
|
228
|
+
# forecast has a zero covariance row: abstain, as the covariance
|
|
229
|
+
# factorisation did
|
|
230
|
+
if not full and np.any(prev_vol[idx] <= 0.0):
|
|
231
|
+
return None
|
|
232
|
+
sig = prev_cov if full else prev_cov[np.ix_(idx, idx)]
|
|
233
|
+
sig = 0.5 * (sig + sig.T)
|
|
234
|
+
else:
|
|
235
|
+
sig = np.zeros((idx.size, idx.size))
|
|
236
|
+
for k in range(K):
|
|
237
|
+
sig += self.prev_w[k] * self.prev_sig[k][np.ix_(idx, idx)]
|
|
238
|
+
sig = 0.5 * (sig + sig.T)
|
|
239
|
+
# The spectrum is floored at _FLOOR before factorising, exactly as
|
|
240
|
+
# the research engine's scoring path does: a near-singular early
|
|
241
|
+
# blend would otherwise hand the running scale one enormous
|
|
242
|
+
# gradient and silence the mixer for years. The floor acts only
|
|
243
|
+
# when lambda_min < _FLOOR, so factorise first and certify: the
|
|
244
|
+
# inverse gives lambda_min >= 1/||sig^-1||_F, and a certified day
|
|
245
|
+
# is bit-identical to the floored path (no floor, same factor).
|
|
246
|
+
# Uncertified or failed days take the eigenvalue floor as before.
|
|
247
|
+
sol: tuple[np.ndarray, np.ndarray] | None = None
|
|
248
|
+
try:
|
|
249
|
+
sol = self._solve(sig, r_t[idx])
|
|
250
|
+
fro = float(np.linalg.norm(sol[1]))
|
|
251
|
+
if not (np.isfinite(fro) and fro * self._CERT * self._FLOOR <= 1.0):
|
|
252
|
+
sol = None
|
|
253
|
+
except (np.linalg.LinAlgError, ValueError):
|
|
254
|
+
sol = None
|
|
255
|
+
if sol is None:
|
|
256
|
+
min_eig = float(np.linalg.eigvalsh(sig).min())
|
|
257
|
+
if min_eig < self._FLOOR:
|
|
258
|
+
sig = sig + np.eye(idx.size) * (self._FLOOR - min_eig)
|
|
259
|
+
try:
|
|
260
|
+
sol = self._solve(sig, r_t[idx])
|
|
261
|
+
except (np.linalg.LinAlgError, ValueError):
|
|
262
|
+
return None
|
|
263
|
+
u, sinv = sol
|
|
264
|
+
g = np.empty(K)
|
|
265
|
+
self._gc = None
|
|
266
|
+
if self.split and self.prev_comps is not None:
|
|
267
|
+
prev_comps, prev_vol, prev_v = self.prev_comps, self.prev_vol, self.prev_v
|
|
268
|
+
assert prev_vol is not None and prev_v is not None
|
|
269
|
+
# Gradients on the expert CORRELATIONS:
|
|
270
|
+
# tr(Sinv (T o vv)) = sum((Sinv o vv) * T),
|
|
271
|
+
# u'(T o vv) u = (u o s)' T (u o s);
|
|
272
|
+
# the rung gradient follows by linearity of the pool.
|
|
273
|
+
vo = prev_vol if full else prev_vol[idx]
|
|
274
|
+
mw = sinv * np.multiply.outer(vo, vo)
|
|
275
|
+
ut = u * vo
|
|
276
|
+
J = self.n_experts
|
|
277
|
+
gc = np.empty((K, J))
|
|
278
|
+
for k in range(K):
|
|
279
|
+
for j in range(J):
|
|
280
|
+
t_ = prev_comps[k][j]
|
|
281
|
+
ts = t_ if full else t_[np.ix_(idx, idx)]
|
|
282
|
+
gc[k, j] = -0.5 * float((mw * ts).sum()) + 0.5 * float(ut @ ts @ ut)
|
|
283
|
+
g[k] = float(prev_v[k] @ gc[k])
|
|
284
|
+
if not np.all(np.isfinite(g)):
|
|
285
|
+
return None
|
|
286
|
+
self._gc = gc
|
|
287
|
+
return g
|
|
288
|
+
for k in range(K):
|
|
289
|
+
sk = self.prev_sig[k][np.ix_(idx, idx)]
|
|
290
|
+
g[k] = -0.5 * float((sinv * sk).sum()) + 0.5 * float(u @ sk @ u)
|
|
291
|
+
if not np.all(np.isfinite(g)):
|
|
292
|
+
return None
|
|
293
|
+
return g
|
|
294
|
+
|
|
295
|
+
def advance(self, g: np.ndarray | None) -> None:
|
|
296
|
+
if g is not None:
|
|
297
|
+
dd = g - g.mean()
|
|
298
|
+
rms = float(np.sqrt((dd @ dd) / dd.size))
|
|
299
|
+
self.scale = (self._gamma_scale * self.scale
|
|
300
|
+
+ (1.0 - self._gamma_scale) * rms)
|
|
301
|
+
ell = (dd / (self.scale + self._JITTER)) * np.sqrt(2.0 * self.eps)
|
|
302
|
+
lw = np.log(np.maximum(self.w, 1e-300)) + ell
|
|
303
|
+
lw -= lw.max()
|
|
304
|
+
w = np.exp(lw)
|
|
305
|
+
self.w = w / w.sum()
|
|
306
|
+
if self.split and self._gc is not None and self.prev_prior is not None:
|
|
307
|
+
for k in range(self.v.shape[0]):
|
|
308
|
+
gc = self._gc[k]
|
|
309
|
+
if not np.all(np.isfinite(gc)):
|
|
310
|
+
continue
|
|
311
|
+
ddv = gc - gc.mean()
|
|
312
|
+
rmsv = float(np.sqrt((ddv @ ddv) / float(self.n_experts)))
|
|
313
|
+
self.scale_v[k] = (self._gamma_scale * self.scale_v[k]
|
|
314
|
+
+ (1.0 - self._gamma_scale) * rmsv)
|
|
315
|
+
lv = (np.log(np.maximum(self.v[k], 1e-300))
|
|
316
|
+
+ (ddv / (self.scale_v[k] + self._JITTER)) * np.sqrt(2.0 * self.eps))
|
|
317
|
+
lv -= lv.max()
|
|
318
|
+
vk = np.exp(lv)
|
|
319
|
+
vk /= vk.sum()
|
|
320
|
+
self.v[k] = (1.0 - self.eps) * vk + self.eps * self.prev_prior[k]
|
|
321
|
+
self.w = (1.0 - self.eps) * self.w + self.eps * self.prior
|
|
322
|
+
|
|
323
|
+
def record(self, rung_covs: list[np.ndarray],
|
|
324
|
+
comps: list[tuple[np.ndarray, ...]] | None = None,
|
|
325
|
+
prior: np.ndarray | None = None,
|
|
326
|
+
cov: np.ndarray | None = None,
|
|
327
|
+
vol: np.ndarray | None = None) -> None:
|
|
328
|
+
"""Cache this step's forecast for next step's ``score``. Without
|
|
329
|
+
the split: the rung covariances and the weights that blend them.
|
|
330
|
+
With the split: the rung CORRELATIONS (mixed), the expert
|
|
331
|
+
correlations, the rule's prior split, the emitted covariance and
|
|
332
|
+
the volatility vector; nothing per expert is materialised."""
|
|
333
|
+
self.prev_sig = rung_covs
|
|
334
|
+
self.prev_w = self.w.copy()
|
|
335
|
+
self.prev_comps = comps
|
|
336
|
+
self.prev_prior = None if prior is None else prior.copy()
|
|
337
|
+
self.prev_cov = cov
|
|
338
|
+
self.prev_vol = vol
|
|
339
|
+
self.prev_v = self.v.copy()
|
|
340
|
+
|
|
341
|
+
def blend(self, prior: np.ndarray) -> np.ndarray:
|
|
342
|
+
return np.asarray(self.w)
|
|
343
|
+
|
|
344
|
+
@property
|
|
345
|
+
def tilt(self) -> float:
|
|
346
|
+
"""Diagnostic analogue of the CUSUM tilt: signed departure of the
|
|
347
|
+
blend from its prior toward the fast (+) or slow (-) rung."""
|
|
348
|
+
return float(self.w[0] - self.prior[0] - (self.w[-1] - self.prior[-1]))
|
|
349
|
+
|
|
350
|
+
|
|
131
351
|
class SqueezeKernelEstimator:
|
|
132
352
|
"""The Squeeze Kernel estimator with its full switch surface.
|
|
133
353
|
|
|
@@ -219,8 +439,18 @@ class SqueezeKernelEstimator:
|
|
|
219
439
|
level_match: bool = True,
|
|
220
440
|
detector: bool = True,
|
|
221
441
|
clock: str = "global",
|
|
442
|
+
vol_ladder: bool = False,
|
|
443
|
+
weights: str = "cusum",
|
|
444
|
+
split_learn: bool = False,
|
|
222
445
|
):
|
|
223
446
|
self.n_assets = n_assets
|
|
447
|
+
if weights not in ("cusum", "eg_blend"):
|
|
448
|
+
raise ValueError("weights must be 'cusum' or 'eg_blend'.")
|
|
449
|
+
self.weights = weights
|
|
450
|
+
if split_learn and weights != "eg_blend":
|
|
451
|
+
raise ValueError("split_learn requires weights='eg_blend'.")
|
|
452
|
+
self.split_learn = bool(split_learn)
|
|
453
|
+
self.vol_ladder = bool(vol_ladder)
|
|
224
454
|
self.lambda_vol = lambda_vol
|
|
225
455
|
self.lambda_corr = lambda_corr
|
|
226
456
|
self.epsilon = epsilon
|
|
@@ -288,7 +518,7 @@ class SqueezeKernelEstimator:
|
|
|
288
518
|
self.corr_theta = corr_theta
|
|
289
519
|
self._corr_lam: np.ndarray | None = None
|
|
290
520
|
self._corr_w: np.ndarray | None = None
|
|
291
|
-
self._detector: _SurpriseDetector | None = None
|
|
521
|
+
self._detector: _SurpriseDetector | _BlendGradient | None = None
|
|
292
522
|
self._Q_list: list[np.ndarray] | None = None
|
|
293
523
|
self._S_list: list[float] | None = None
|
|
294
524
|
self._adaptive = False
|
|
@@ -311,7 +541,10 @@ class SqueezeKernelEstimator:
|
|
|
311
541
|
# weights; the v2 ablation switch).
|
|
312
542
|
self._adaptive = detector and hl.size >= 2
|
|
313
543
|
if self._adaptive:
|
|
314
|
-
self._detector =
|
|
544
|
+
self._detector = (_BlendGradient(hl, self._corr_w, split=self.split_learn,
|
|
545
|
+
n_experts=2 if shrinkage_target == "equicorrelation" else 3)
|
|
546
|
+
if weights == "eg_blend"
|
|
547
|
+
else _SurpriseDetector(hl))
|
|
315
548
|
|
|
316
549
|
# Resolve shrinkage
|
|
317
550
|
if isinstance(shrinkage, str):
|
|
@@ -328,6 +561,24 @@ class SqueezeKernelEstimator:
|
|
|
328
561
|
self._var_t: np.ndarray | None = None
|
|
329
562
|
self._var_init: np.ndarray | None = None
|
|
330
563
|
self._vol_t: np.ndarray | None = None
|
|
564
|
+
# Self-adapting volatility memory: a ladder of decays two octaves
|
|
565
|
+
# below the correlation ladder, pooled with panel-wide weights that
|
|
566
|
+
# are Bayes at the null-calibrated temperature on SATURATED
|
|
567
|
+
# (tanh) evidence -- one day cannot hand a stale rung weeks of
|
|
568
|
+
# weight -- with the fastest rung's memory and a uniform prior.
|
|
569
|
+
# Replaces the fitted constant ``lambda_vol``.
|
|
570
|
+
self._var_l: np.ndarray | None = None
|
|
571
|
+
if self.vol_ladder:
|
|
572
|
+
if self.corr_half_lives is None:
|
|
573
|
+
raise ValueError("vol_ladder requires corr_half_lives.")
|
|
574
|
+
h = float(np.median(self.corr_half_lives))
|
|
575
|
+
self._hl_v = np.array([h / 16.0, h / 4.0, h])
|
|
576
|
+
self._lv_l = 2.0 ** (-1.0 / self._hl_v)
|
|
577
|
+
self._eps_v = float(1.0 - self._lv_l[0])
|
|
578
|
+
self._pi_v = np.full(3, 1.0 / 3.0)
|
|
579
|
+
self._gamma_v = 2.0 ** (-1.0 / h)
|
|
580
|
+
self._w_p = self._pi_v.copy()
|
|
581
|
+
self._scale_p = 1.0
|
|
331
582
|
# No single-scale state is allocated in ladder mode.
|
|
332
583
|
self._Q_t = (np.eye(n_assets, dtype=np.float64)
|
|
333
584
|
if self._corr_lam is None else None)
|
|
@@ -397,11 +648,36 @@ class SqueezeKernelEstimator:
|
|
|
397
648
|
if np.any(first):
|
|
398
649
|
var_t[first] = r_t[first] ** 2 + eps
|
|
399
650
|
var_init[first] = True
|
|
651
|
+
if self.vol_ladder:
|
|
652
|
+
if self._var_l is None:
|
|
653
|
+
self._var_l = np.full((3, n), eps)
|
|
654
|
+
self._var_l[:, first] = r_t[first] ** 2 + eps
|
|
400
655
|
if np.any(repeat):
|
|
401
|
-
|
|
402
|
-
self.
|
|
403
|
-
|
|
404
|
-
|
|
656
|
+
if self.vol_ladder:
|
|
657
|
+
assert self._var_l is not None
|
|
658
|
+
r2 = r_t[repeat] ** 2
|
|
659
|
+
vr = self._var_l[:, repeat]
|
|
660
|
+
vr_safe = np.maximum(vr, eps)
|
|
661
|
+
ell_v = -0.5 * (np.log(vr_safe) + r2 / vr_safe)
|
|
662
|
+
ep = ell_v.sum(axis=1)
|
|
663
|
+
dp = ep - ep.mean()
|
|
664
|
+
rp = float(np.sqrt((dp @ dp) / 3.0))
|
|
665
|
+
self._scale_p = (self._gamma_v * self._scale_p
|
|
666
|
+
+ (1.0 - self._gamma_v) * rp)
|
|
667
|
+
zp = np.tanh(dp / (self._scale_p + 1e-12))
|
|
668
|
+
lw = np.log(np.maximum(self._w_p, 1e-300)) + zp * np.sqrt(2.0 * self._eps_v)
|
|
669
|
+
lw -= lw.max()
|
|
670
|
+
w_p = np.exp(lw)
|
|
671
|
+
w_p /= w_p.sum()
|
|
672
|
+
self._w_p = (1.0 - self._eps_v) * w_p + self._eps_v * self._pi_v
|
|
673
|
+
lv = self._lv_l[:, None]
|
|
674
|
+
self._var_l[:, repeat] = lv * vr + (1.0 - lv) * r2
|
|
675
|
+
var_t[repeat] = (self._w_p[:, None] * self._var_l[:, repeat]).sum(axis=0)
|
|
676
|
+
else:
|
|
677
|
+
var_t[repeat] = (
|
|
678
|
+
self.lambda_vol * var_t[repeat]
|
|
679
|
+
+ (1.0 - self.lambda_vol) * r_t[repeat] ** 2
|
|
680
|
+
)
|
|
405
681
|
|
|
406
682
|
vol_t = np.zeros(n, dtype=np.float64)
|
|
407
683
|
vol_t[var_init] = np.sqrt(var_t[var_init])
|
|
@@ -577,6 +853,47 @@ class SqueezeKernelEstimator:
|
|
|
577
853
|
# T = (1 - rho_bar) I + rho_bar 11'. We avoid materialising T by
|
|
578
854
|
# blending the off-diagonal toward rho_bar in place and resetting
|
|
579
855
|
# the diagonal to 1.
|
|
856
|
+
alpha = self._intensity(corr, S_t, J_t)
|
|
857
|
+
if alpha > 0.0 and n > 1:
|
|
858
|
+
# Off-diagonal mean: O(n^2) sum, no mask allocation.
|
|
859
|
+
rho_bar = (corr.sum() - corr.trace()) / self._n_off
|
|
860
|
+
if self.shrinkage_target == "equicorrelation" or rho_bar <= 0.0:
|
|
861
|
+
# rho_bar <= 0 also covers the q = meanoff(C o C) = 0 corner:
|
|
862
|
+
# C o C has nonnegative entries, so q = 0 forces C = I and
|
|
863
|
+
# hence rho_bar = 0 — the equicorrelation fallback applies.
|
|
864
|
+
corr *= (1.0 - alpha)
|
|
865
|
+
corr += alpha * rho_bar
|
|
866
|
+
np.fill_diagonal(corr, 1.0)
|
|
867
|
+
else:
|
|
868
|
+
# Cluster (concentration-morphing) target:
|
|
869
|
+
# T = (1-alpha) T_equi + alpha [(1-gamma) I + gamma (C o C)]
|
|
870
|
+
# C o C is the Hadamard square of the raw correlation (PSD by
|
|
871
|
+
# the Schur product theorem, unit diagonal for free); gamma is
|
|
872
|
+
# level-matched so the target carries the same average
|
|
873
|
+
# correlation mass as the equicorrelation target. As
|
|
874
|
+
# alpha -> 0 this reduces exactly to the published estimator.
|
|
875
|
+
had = self._scratch_had
|
|
876
|
+
np.multiply(corr, corr, out=had) # Hadamard square, O(n^2)
|
|
877
|
+
if self.level_match:
|
|
878
|
+
mean_off = (had.sum() - np.trace(had)) / self._n_off
|
|
879
|
+
gamma = min(1.0, rho_bar / max(mean_off, eps))
|
|
880
|
+
else:
|
|
881
|
+
gamma = 1.0 # v2: pure Schur-square target
|
|
882
|
+
corr *= (1.0 - alpha)
|
|
883
|
+
corr += (alpha * (1.0 - alpha)) * rho_bar
|
|
884
|
+
had *= alpha * alpha * gamma
|
|
885
|
+
corr += had
|
|
886
|
+
np.fill_diagonal(corr, 1.0)
|
|
887
|
+
return corr
|
|
888
|
+
|
|
889
|
+
def _intensity(self, corr: np.ndarray, S_t: float,
|
|
890
|
+
J_t: float | None = None) -> float:
|
|
891
|
+
"""Shrinkage intensity of one timescale from its normalised raw
|
|
892
|
+
correlation ``corr`` and its information masses (published rule or
|
|
893
|
+
the self-tuning rule); ``corr`` is read, not modified."""
|
|
894
|
+
eps = self.epsilon
|
|
895
|
+
n = self.n_assets
|
|
896
|
+
S_t = max(S_t, eps)
|
|
580
897
|
alpha = self._shrinkage_alpha
|
|
581
898
|
if alpha < 0:
|
|
582
899
|
if self.alpha_rule != "published" and J_t is not None:
|
|
@@ -615,37 +932,29 @@ class SqueezeKernelEstimator:
|
|
|
615
932
|
alpha = min(1.0, c_k) / (1.0 + r_ * r_ * s_)
|
|
616
933
|
else:
|
|
617
934
|
alpha = max(0.0, min(1.0, n / (2.0 * S_t) - self.shrinkage_delta))
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
else:
|
|
642
|
-
gamma = 1.0 # v2: pure Schur-square target
|
|
643
|
-
corr *= (1.0 - alpha)
|
|
644
|
-
corr += (alpha * (1.0 - alpha)) * rho_bar
|
|
645
|
-
had *= alpha * alpha * gamma
|
|
646
|
-
corr += had
|
|
647
|
-
np.fill_diagonal(corr, 1.0)
|
|
648
|
-
return corr
|
|
935
|
+
return alpha
|
|
936
|
+
|
|
937
|
+
def _rung_experts(self, Q: np.ndarray, S_t: float, J_t: float
|
|
938
|
+
) -> tuple[tuple[np.ndarray, ...], np.ndarray]:
|
|
939
|
+
"""The three PSD unit-diagonal experts of one timescale -- raw
|
|
940
|
+
correlation, equicorrelation at its mean level, Schur square --
|
|
941
|
+
and the rule's prior split (1-a, a(1-a), a^2)."""
|
|
942
|
+
eps = self.epsilon
|
|
943
|
+
n = self.n_assets
|
|
944
|
+
diag_z = np.diagonal(Q).copy()
|
|
945
|
+
inv_diag = 1.0 / np.sqrt(np.maximum(diag_z, eps))
|
|
946
|
+
corr = np.multiply.outer(inv_diag, inv_diag)
|
|
947
|
+
corr *= Q
|
|
948
|
+
np.fill_diagonal(corr, np.where(diag_z > eps, 1.0, 0.0))
|
|
949
|
+
a = self._intensity(corr, S_t, J_t)
|
|
950
|
+
rho = (corr.sum() - n) / self._n_off
|
|
951
|
+
rho = float(min(max(rho, -0.99 / max(n - 1, 1)), 0.999))
|
|
952
|
+
E = np.full((n, n), rho)
|
|
953
|
+
np.fill_diagonal(E, 1.0)
|
|
954
|
+
if self.shrinkage_target == "equicorrelation":
|
|
955
|
+
# target off: the pool is the equicorrelation shrinkage alone
|
|
956
|
+
return (corr, E), np.array([1.0 - a, a])
|
|
957
|
+
return (corr, E, corr * corr), np.array([1.0 - a, a * (1.0 - a), a * a])
|
|
649
958
|
|
|
650
959
|
def _materialize_adaptive(self) -> None:
|
|
651
960
|
"""Adaptive-weight extraction: build per-rung shrunk covariances
|
|
@@ -671,21 +980,56 @@ class SqueezeKernelEstimator:
|
|
|
671
980
|
if self.clock == "asset" and buf is None:
|
|
672
981
|
buf = np.empty((K, self.n_assets, self.n_assets))
|
|
673
982
|
self._corr_blend_buf = buf
|
|
983
|
+
comps: list[tuple[np.ndarray, ...]] | None = None
|
|
984
|
+
prior: np.ndarray | None = None
|
|
985
|
+
v: np.ndarray | None = None
|
|
986
|
+
if self.split_learn:
|
|
987
|
+
assert isinstance(detector, _BlendGradient) # the split rides on the blend gradient
|
|
988
|
+
comps = []
|
|
989
|
+
v = detector.v
|
|
990
|
+
n_exp = 2 if self.shrinkage_target == "equicorrelation" else 3
|
|
991
|
+
if v.shape[1] != n_exp:
|
|
992
|
+
# the target was switched after construction; the split has
|
|
993
|
+
# not learned anything yet, so re-shape its state
|
|
994
|
+
assert detector.prev_comps is None, "cannot switch the target mid-stream"
|
|
995
|
+
detector.n_experts = n_exp
|
|
996
|
+
detector.v = np.full((K, n_exp), 1.0 / n_exp)
|
|
997
|
+
v = detector.v
|
|
998
|
+
prior = np.empty(v.shape)
|
|
674
999
|
for k in range(K):
|
|
675
|
-
|
|
1000
|
+
if self.split_learn:
|
|
1001
|
+
assert comps is not None and prior is not None and v is not None
|
|
1002
|
+
experts, prior[k] = self._rung_experts(Q_list[k], S_list[k], J_list[k])
|
|
1003
|
+
corr_k = v[k, 0] * experts[0]
|
|
1004
|
+
for j in range(1, len(experts)):
|
|
1005
|
+
corr_k = corr_k + v[k, j] * experts[j]
|
|
1006
|
+
comps.append(experts)
|
|
1007
|
+
sig_k.append(corr_k) # mixed correlation; vv applied once below
|
|
1008
|
+
else:
|
|
1009
|
+
corr_k = self._shrunk_corr_from_Q(Q_list[k], S_list[k], J_list[k])
|
|
1010
|
+
sig_k.append(corr_k * vv)
|
|
676
1011
|
if buf is not None:
|
|
677
1012
|
buf[k][:] = corr_k
|
|
678
|
-
sig_k.append(corr_k * vv)
|
|
679
|
-
detector.record(sig_k)
|
|
680
1013
|
w = detector.blend(corr_w)
|
|
681
|
-
if
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
cov =
|
|
688
|
-
|
|
1014
|
+
if self.split_learn:
|
|
1015
|
+
cb = w[0] * sig_k[0]
|
|
1016
|
+
for k in range(1, K):
|
|
1017
|
+
cb = cb + w[k] * sig_k[k]
|
|
1018
|
+
if buf is not None:
|
|
1019
|
+
self._C_prev = cb
|
|
1020
|
+
cov = cb * vv
|
|
1021
|
+
cov = (cov + cov.T) * 0.5
|
|
1022
|
+
detector.record(sig_k, comps, prior, cov, vol_t) # type: ignore[call-arg]
|
|
1023
|
+
else:
|
|
1024
|
+
detector.record(sig_k)
|
|
1025
|
+
if buf is not None:
|
|
1026
|
+
# next day's neighborhood weighting: the emitted blend of the
|
|
1027
|
+
# shrunk rung correlations
|
|
1028
|
+
self._C_prev = np.tensordot(w, buf, axes=1)
|
|
1029
|
+
cov = w[0] * sig_k[0]
|
|
1030
|
+
for k in range(1, len(sig_k)):
|
|
1031
|
+
cov = cov + w[k] * sig_k[k]
|
|
1032
|
+
cov = (cov + cov.T) * 0.5
|
|
689
1033
|
self._cov = cov
|
|
690
1034
|
d = np.sqrt(np.maximum(np.diagonal(cov), eps))
|
|
691
1035
|
self._corr = cov / np.outer(d, d)
|
|
File without changes
|
|
File without changes
|