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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: squeeze-kernel
3
- Version: 2.0.0
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
  [![PyPI](https://img.shields.io/pypi/v/squeeze-kernel.svg)](https://pypi.org/project/squeeze-kernel/)
36
36
  [![Python](https://img.shields.io/pypi/pyversions/squeeze-kernel.svg)](https://pypi.org/project/squeeze-kernel/)
37
37
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
38
+ [![DOI](https://zenodo.org/badge/1188987927.svg)](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. One `O(n²)` update per day, positive semi-definite **by construction**, missing values handled **natively**, no tuning, no refits. Only dependency: NumPy.
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 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;
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 takes ~40 s single-threaded, two orders of magnitude under daily rolling-window refits.
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, detector tilt
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, 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 |
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 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.
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
  [![PyPI](https://img.shields.io/pypi/v/squeeze-kernel.svg)](https://pypi.org/project/squeeze-kernel/)
5
5
  [![Python](https://img.shields.io/pypi/pyversions/squeeze-kernel.svg)](https://pypi.org/project/squeeze-kernel/)
6
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7
+ [![DOI](https://zenodo.org/badge/1188987927.svg)](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. One `O(n²)` update per day, positive semi-definite **by construction**, missing values handled **natively**, no tuning, no refits. Only dependency: NumPy.
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 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;
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 takes ~40 s single-threaded, two orders of magnitude under daily rolling-window refits.
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, detector tilt
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, 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 |
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 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.
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 = "2.0.0"
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 = "2.0.0"
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"
@@ -39,4 +39,4 @@ __all__ = [
39
39
  "kernel_fisher",
40
40
  ]
41
41
 
42
- __version__ = "2.0.0"
42
+ __version__ = "3.1.1"
@@ -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 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),
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 # frozen empirical (not derived from h)
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 = _SurpriseDetector(hl)
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
- var_t[repeat] = (
402
- self.lambda_vol * var_t[repeat]
403
- + (1.0 - self.lambda_vol) * r_t[repeat] ** 2
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
- if alpha > 0.0 and n > 1:
619
- # Off-diagonal mean: O(n^2) sum, no mask allocation.
620
- rho_bar = (corr.sum() - corr.trace()) / self._n_off
621
- if self.shrinkage_target == "equicorrelation" or rho_bar <= 0.0:
622
- # rho_bar <= 0 also covers the q = meanoff(C o C) = 0 corner:
623
- # C o C has nonnegative entries, so q = 0 forces C = I and
624
- # hence rho_bar = 0 — the equicorrelation fallback applies.
625
- corr *= (1.0 - alpha)
626
- corr += alpha * rho_bar
627
- np.fill_diagonal(corr, 1.0)
628
- else:
629
- # Cluster (concentration-morphing) target:
630
- # T = (1-alpha) T_equi + alpha [(1-gamma) I + gamma (C o C)]
631
- # C o C is the Hadamard square of the raw correlation (PSD by
632
- # the Schur product theorem, unit diagonal for free); gamma is
633
- # level-matched so the target carries the same average
634
- # correlation mass as the equicorrelation target. As
635
- # alpha -> 0 this reduces exactly to the published estimator.
636
- had = self._scratch_had
637
- np.multiply(corr, corr, out=had) # Hadamard square, O(n^2)
638
- if self.level_match:
639
- mean_off = (had.sum() - np.trace(had)) / self._n_off
640
- gamma = min(1.0, rho_bar / max(mean_off, eps))
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
- corr_k = self._shrunk_corr_from_Q(Q_list[k], S_list[k], J_list[k])
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 buf is not None:
682
- # next day's neighborhood weighting: the emitted blend of the
683
- # shrunk rung correlations
684
- self._C_prev = np.tensordot(w, buf, axes=1)
685
- cov = w[0] * sig_k[0]
686
- for k in range(1, len(sig_k)):
687
- cov = cov + w[k] * sig_k[k]
688
- cov = (cov + cov.T) * 0.5
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)