Skip to main content

wickra_core/indicators/
kalman_hedge_ratio.rs

1//! Kalman-filter dynamic hedge ratio between two series.
2
3use crate::error::{Error, Result};
4use crate::traits::Indicator;
5
6/// Output of [`KalmanHedgeRatio`].
7#[derive(Debug, Clone, Copy, PartialEq)]
8pub struct KalmanHedgeRatioOutput {
9    /// Current hedge ratio `β` — the filtered slope of `a` on `b`.
10    pub hedge_ratio: f64,
11    /// Current intercept `α` — the filtered level offset.
12    pub intercept: f64,
13    /// Forecast error `a − (α + β·b)`: how far the latest `a` sits from the
14    /// Kalman-predicted relationship. This is the tradeable spread signal.
15    pub spread: f64,
16}
17
18/// Dynamic hedge ratio between two series, estimated online with a Kalman filter.
19///
20/// Each `update` takes one `(a, b)` price pair and treats the linear relation
21/// `aₜ = αₜ + βₜ·bₜ + noise` as a state-space model whose hidden state
22/// `[βₜ, αₜ]` follows a random walk. The filter updates the state from every
23/// observation, so the hedge ratio **adapts continuously** instead of being a
24/// flat OLS slope over a fixed window:
25///
26/// ```text
27/// state    xₜ = [βₜ, αₜ],   drifts as a random walk with covariance Vw·I
28/// observe  aₜ = [bₜ, 1]·xₜ + εₜ,   Var(εₜ) = observation_var
29/// Vw = delta / (1 − delta)
30/// ```
31///
32/// `delta` controls how fast the hedge ratio is allowed to move: a larger
33/// `delta` tracks regime changes faster but is noisier; a smaller `delta` is
34/// smoother but slower. `observation_var` is the measurement-noise variance.
35/// The reported `spread` (the filter's forecast error) is the mean-reverting
36/// signal a pairs trade fades — the Kalman analogue of the
37/// [`crate::Cointegration`] residual, but with a hedge ratio that breathes.
38///
39/// The filter emits an estimate from the **first** update (warmup of one bar);
40/// early estimates are diffuse and settle as observations accumulate. Each
41/// `update` is `O(1)` over the fixed 2×2 covariance.
42///
43/// # Example
44///
45/// ```
46/// use wickra_core::{Indicator, KalmanHedgeRatio};
47///
48/// let mut k = KalmanHedgeRatio::new(1e-2, 1e-3).unwrap();
49/// let mut last = None;
50/// for t in 0..400 {
51///     // `b` sweeps a wide range so the slope and intercept are identifiable.
52///     let b = 100.0 + (f64::from(t) * 0.5).sin() * 95.0;
53///     last = k.update((2.0 * b + 5.0, b)); // a = 2·b + 5
54/// }
55/// let out = last.unwrap();
56/// assert!((out.hedge_ratio - 2.0).abs() < 0.05);
57/// assert!(out.spread.abs() < 0.05);
58/// ```
59#[derive(Debug, Clone)]
60pub struct KalmanHedgeRatio {
61    delta: f64,
62    transition_var: f64,
63    observation_var: f64,
64    beta: f64,
65    alpha: f64,
66    // State covariance, row-major 2×2: [[p00, p01], [p10, p11]].
67    cov: [[f64; 2]; 2],
68    count: usize,
69}
70
71impl KalmanHedgeRatio {
72    /// Construct a new Kalman hedge-ratio filter.
73    ///
74    /// `delta` is the state-drift ratio in `(0, 1)`; `observation_var` is the
75    /// measurement-noise variance (`> 0`).
76    ///
77    /// # Errors
78    /// Returns [`Error::InvalidParameter`] if `delta` is not in `(0, 1)` or if
79    /// `observation_var` is not strictly positive (both must also be finite).
80    pub fn new(delta: f64, observation_var: f64) -> Result<Self> {
81        if !delta.is_finite() || delta <= 0.0 || delta >= 1.0 {
82            return Err(Error::InvalidParameter {
83                message: "kalman hedge ratio needs delta in (0, 1)",
84            });
85        }
86        if !observation_var.is_finite() || observation_var <= 0.0 {
87            return Err(Error::InvalidParameter {
88                message: "kalman hedge ratio needs observation_var > 0",
89            });
90        }
91        Ok(Self {
92            delta,
93            transition_var: delta / (1.0 - delta),
94            observation_var,
95            beta: 0.0,
96            alpha: 0.0,
97            cov: [[0.0; 2]; 2],
98            count: 0,
99        })
100    }
101
102    /// Configured state-drift ratio `delta`.
103    pub const fn delta(&self) -> f64 {
104        self.delta
105    }
106
107    /// Configured measurement-noise variance.
108    pub const fn observation_var(&self) -> f64 {
109        self.observation_var
110    }
111}
112
113impl Indicator for KalmanHedgeRatio {
114    type Input = (f64, f64);
115    type Output = KalmanHedgeRatioOutput;
116
117    fn update(&mut self, input: (f64, f64)) -> Option<KalmanHedgeRatioOutput> {
118        let (a, b) = input;
119        // Predicted state covariance: add the transition noise to the diagonal
120        // (the very first observation starts from a zero prior).
121        let mut cov_pred = self.cov;
122        if self.count > 0 {
123            cov_pred[0][0] += self.transition_var;
124            cov_pred[1][1] += self.transition_var;
125        }
126        // Observation row is F = [b, 1].
127        let predicted = self.beta * b + self.alpha;
128        let innovation = a - predicted;
129        // F·cov_pred  (a 1×2 row).
130        let fr0 = b * cov_pred[0][0] + cov_pred[1][0];
131        let fr1 = b * cov_pred[0][1] + cov_pred[1][1];
132        // Innovation variance S = F·cov_pred·Fᵀ + observation_var ≥ observation_var > 0.
133        let innovation_var = fr0 * b + fr1 + self.observation_var;
134        // Kalman gain = cov_pred·Fᵀ / S.
135        let rft0 = cov_pred[0][0] * b + cov_pred[0][1];
136        let rft1 = cov_pred[1][0] * b + cov_pred[1][1];
137        let gain0 = rft0 / innovation_var;
138        let gain1 = rft1 / innovation_var;
139        // State update.
140        self.beta += gain0 * innovation;
141        self.alpha += gain1 * innovation;
142        // Covariance update P = cov_pred − gain·(F·cov_pred).
143        self.cov[0][0] = cov_pred[0][0] - gain0 * fr0;
144        self.cov[0][1] = cov_pred[0][1] - gain0 * fr1;
145        self.cov[1][0] = cov_pred[1][0] - gain1 * fr0;
146        self.cov[1][1] = cov_pred[1][1] - gain1 * fr1;
147        self.count += 1;
148        Some(KalmanHedgeRatioOutput {
149            hedge_ratio: self.beta,
150            intercept: self.alpha,
151            spread: innovation,
152        })
153    }
154
155    fn reset(&mut self) {
156        self.beta = 0.0;
157        self.alpha = 0.0;
158        self.cov = [[0.0; 2]; 2];
159        self.count = 0;
160    }
161
162    fn warmup_period(&self) -> usize {
163        1
164    }
165
166    fn is_ready(&self) -> bool {
167        self.count >= 1
168    }
169
170    fn name(&self) -> &'static str {
171        "KalmanHedgeRatio"
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::traits::BatchExt;
179
180    #[test]
181    fn rejects_bad_parameters() {
182        assert!(KalmanHedgeRatio::new(0.0, 1.0).is_err());
183        assert!(KalmanHedgeRatio::new(1.0, 1.0).is_err());
184        assert!(KalmanHedgeRatio::new(-0.1, 1.0).is_err());
185        assert!(KalmanHedgeRatio::new(f64::NAN, 1.0).is_err());
186        assert!(KalmanHedgeRatio::new(0.001, 0.0).is_err());
187        assert!(KalmanHedgeRatio::new(0.001, -1.0).is_err());
188        assert!(KalmanHedgeRatio::new(0.001, f64::INFINITY).is_err());
189        assert!(KalmanHedgeRatio::new(0.001, 0.001).is_ok());
190    }
191
192    #[test]
193    fn accessors_and_metadata() {
194        let k = KalmanHedgeRatio::new(0.001, 0.01).unwrap();
195        assert_eq!(k.delta(), 0.001);
196        assert_eq!(k.observation_var(), 0.01);
197        assert_eq!(k.warmup_period(), 1);
198        assert_eq!(k.name(), "KalmanHedgeRatio");
199        assert!(!k.is_ready());
200    }
201
202    #[test]
203    fn emits_from_first_update() {
204        let mut k = KalmanHedgeRatio::new(0.001, 0.001).unwrap();
205        let first = k.update((10.0, 5.0)).unwrap();
206        // The diffuse prior leaves the first state at the origin.
207        assert_eq!(first.hedge_ratio, 0.0);
208        assert_eq!(first.intercept, 0.0);
209        assert_eq!(first.spread, 10.0);
210        assert!(k.is_ready());
211    }
212
213    #[test]
214    fn converges_to_static_relationship() {
215        // a = 2·b + 5 ⇒ the filter should recover β ≈ 2, α ≈ 5, spread ≈ 0.
216        // `b` sweeps a wide range so β and α are jointly identifiable.
217        let pairs: Vec<(f64, f64)> = (0..500)
218            .map(|t| {
219                let b = 100.0 + (f64::from(t) * 0.5).sin() * 95.0;
220                (2.0 * b + 5.0, b)
221            })
222            .collect();
223        let out = KalmanHedgeRatio::new(1e-2, 1e-3)
224            .unwrap()
225            .batch(&pairs)
226            .into_iter()
227            .flatten()
228            .last()
229            .unwrap();
230        assert!(
231            (out.hedge_ratio - 2.0).abs() < 0.05,
232            "beta {}",
233            out.hedge_ratio
234        );
235        assert!((out.intercept - 5.0).abs() < 1.0, "alpha {}", out.intercept);
236        assert!(out.spread.abs() < 0.05, "spread {}", out.spread);
237    }
238
239    #[test]
240    fn tracks_a_changing_hedge_ratio() {
241        // Hedge ratio steps from 2 to 3 partway through; the filter should move
242        // toward the new ratio.
243        let mut pairs: Vec<(f64, f64)> = (0..300)
244            .map(|t| {
245                let b = 100.0 + (f64::from(t) * 0.5).sin() * 95.0;
246                (2.0 * b + 5.0, b)
247            })
248            .collect();
249        pairs.extend((0..300).map(|t| {
250            let b = 100.0 + (f64::from(t) * 0.5).cos() * 95.0;
251            (3.0 * b + 5.0, b)
252        }));
253        let out = KalmanHedgeRatio::new(1e-2, 1e-3)
254            .unwrap()
255            .batch(&pairs)
256            .into_iter()
257            .flatten()
258            .last()
259            .unwrap();
260        assert!(out.hedge_ratio > 2.5, "beta {}", out.hedge_ratio);
261    }
262
263    #[test]
264    fn reset_clears_state() {
265        let mut k = KalmanHedgeRatio::new(0.001, 0.001).unwrap();
266        for t in 0..50 {
267            let b = 100.0 + f64::from(t);
268            k.update((2.0 * b, b));
269        }
270        assert!(k.is_ready());
271        k.reset();
272        assert!(!k.is_ready());
273        let first = k.update((10.0, 5.0)).unwrap();
274        assert_eq!(first.hedge_ratio, 0.0);
275    }
276
277    #[test]
278    fn batch_equals_streaming() {
279        let pairs: Vec<(f64, f64)> = (0..120)
280            .map(|t| {
281                let b = 30.0 + 0.7 * f64::from(t);
282                (1.8 * b + 2.0 + (f64::from(t) * 0.4).sin(), b)
283            })
284            .collect();
285        let batch = KalmanHedgeRatio::new(1e-3, 1e-2).unwrap().batch(&pairs);
286        let mut k = KalmanHedgeRatio::new(1e-3, 1e-2).unwrap();
287        let streamed: Vec<_> = pairs.iter().map(|p| k.update(*p)).collect();
288        assert_eq!(batch, streamed);
289    }
290}