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        if !a.is_finite() || !b.is_finite() {
120            return None;
121        }
122        // Predicted state covariance: add the transition noise to the diagonal
123        // (the very first observation starts from a zero prior).
124        let mut cov_pred = self.cov;
125        if self.count > 0 {
126            cov_pred[0][0] += self.transition_var;
127            cov_pred[1][1] += self.transition_var;
128        }
129        // Observation row is F = [b, 1].
130        let predicted = self.beta * b + self.alpha;
131        let innovation = a - predicted;
132        // F·cov_pred  (a 1×2 row).
133        let fr0 = b * cov_pred[0][0] + cov_pred[1][0];
134        let fr1 = b * cov_pred[0][1] + cov_pred[1][1];
135        // Innovation variance S = F·cov_pred·Fᵀ + observation_var ≥ observation_var > 0.
136        let innovation_var = fr0 * b + fr1 + self.observation_var;
137        // Kalman gain = cov_pred·Fᵀ / S.
138        let rft0 = cov_pred[0][0] * b + cov_pred[0][1];
139        let rft1 = cov_pred[1][0] * b + cov_pred[1][1];
140        let gain0 = rft0 / innovation_var;
141        let gain1 = rft1 / innovation_var;
142        // State update.
143        self.beta += gain0 * innovation;
144        self.alpha += gain1 * innovation;
145        // Covariance update P = cov_pred − gain·(F·cov_pred).
146        self.cov[0][0] = cov_pred[0][0] - gain0 * fr0;
147        self.cov[0][1] = cov_pred[0][1] - gain0 * fr1;
148        self.cov[1][0] = cov_pred[1][0] - gain1 * fr0;
149        self.cov[1][1] = cov_pred[1][1] - gain1 * fr1;
150        self.count += 1;
151        Some(KalmanHedgeRatioOutput {
152            hedge_ratio: self.beta,
153            intercept: self.alpha,
154            spread: innovation,
155        })
156    }
157
158    fn reset(&mut self) {
159        self.beta = 0.0;
160        self.alpha = 0.0;
161        self.cov = [[0.0; 2]; 2];
162        self.count = 0;
163    }
164
165    fn warmup_period(&self) -> usize {
166        1
167    }
168
169    fn is_ready(&self) -> bool {
170        self.count >= 1
171    }
172
173    fn name(&self) -> &'static str {
174        "KalmanHedgeRatio"
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::traits::BatchExt;
182
183    #[test]
184    fn rejects_bad_parameters() {
185        assert!(KalmanHedgeRatio::new(0.0, 1.0).is_err());
186        assert!(KalmanHedgeRatio::new(1.0, 1.0).is_err());
187        assert!(KalmanHedgeRatio::new(-0.1, 1.0).is_err());
188        assert!(KalmanHedgeRatio::new(f64::NAN, 1.0).is_err());
189        assert!(KalmanHedgeRatio::new(0.001, 0.0).is_err());
190        assert!(KalmanHedgeRatio::new(0.001, -1.0).is_err());
191        assert!(KalmanHedgeRatio::new(0.001, f64::INFINITY).is_err());
192        assert!(KalmanHedgeRatio::new(0.001, 0.001).is_ok());
193    }
194
195    #[test]
196    fn accessors_and_metadata() {
197        let k = KalmanHedgeRatio::new(0.001, 0.01).unwrap();
198        assert_eq!(k.delta(), 0.001);
199        assert_eq!(k.observation_var(), 0.01);
200        assert_eq!(k.warmup_period(), 1);
201        assert_eq!(k.name(), "KalmanHedgeRatio");
202        assert!(!k.is_ready());
203    }
204
205    #[test]
206    fn emits_from_first_update() {
207        let mut k = KalmanHedgeRatio::new(0.001, 0.001).unwrap();
208        let first = k.update((10.0, 5.0)).unwrap();
209        // The diffuse prior leaves the first state at the origin.
210        assert_eq!(first.hedge_ratio, 0.0);
211        assert_eq!(first.intercept, 0.0);
212        assert_eq!(first.spread, 10.0);
213        assert!(k.is_ready());
214    }
215
216    #[test]
217    fn converges_to_static_relationship() {
218        // a = 2·b + 5 ⇒ the filter should recover β ≈ 2, α ≈ 5, spread ≈ 0.
219        // `b` sweeps a wide range so β and α are jointly identifiable.
220        let pairs: Vec<(f64, f64)> = (0..500)
221            .map(|t| {
222                let b = 100.0 + (f64::from(t) * 0.5).sin() * 95.0;
223                (2.0 * b + 5.0, b)
224            })
225            .collect();
226        let out = KalmanHedgeRatio::new(1e-2, 1e-3)
227            .unwrap()
228            .batch(&pairs)
229            .into_iter()
230            .flatten()
231            .last()
232            .unwrap();
233        assert!(
234            (out.hedge_ratio - 2.0).abs() < 0.05,
235            "beta {}",
236            out.hedge_ratio
237        );
238        assert!((out.intercept - 5.0).abs() < 1.0, "alpha {}", out.intercept);
239        assert!(out.spread.abs() < 0.05, "spread {}", out.spread);
240    }
241
242    #[test]
243    fn tracks_a_changing_hedge_ratio() {
244        // Hedge ratio steps from 2 to 3 partway through; the filter should move
245        // toward the new ratio.
246        let mut pairs: Vec<(f64, f64)> = (0..300)
247            .map(|t| {
248                let b = 100.0 + (f64::from(t) * 0.5).sin() * 95.0;
249                (2.0 * b + 5.0, b)
250            })
251            .collect();
252        pairs.extend((0..300).map(|t| {
253            let b = 100.0 + (f64::from(t) * 0.5).cos() * 95.0;
254            (3.0 * b + 5.0, b)
255        }));
256        let out = KalmanHedgeRatio::new(1e-2, 1e-3)
257            .unwrap()
258            .batch(&pairs)
259            .into_iter()
260            .flatten()
261            .last()
262            .unwrap();
263        assert!(out.hedge_ratio > 2.5, "beta {}", out.hedge_ratio);
264    }
265
266    #[test]
267    fn reset_clears_state() {
268        let mut k = KalmanHedgeRatio::new(0.001, 0.001).unwrap();
269        for t in 0..50 {
270            let b = 100.0 + f64::from(t);
271            k.update((2.0 * b, b));
272        }
273        assert!(k.is_ready());
274        k.reset();
275        assert!(!k.is_ready());
276        let first = k.update((10.0, 5.0)).unwrap();
277        assert_eq!(first.hedge_ratio, 0.0);
278    }
279
280    #[test]
281    fn batch_equals_streaming() {
282        let pairs: Vec<(f64, f64)> = (0..120)
283            .map(|t| {
284                let b = 30.0 + 0.7 * f64::from(t);
285                (1.8 * b + 2.0 + (f64::from(t) * 0.4).sin(), b)
286            })
287            .collect();
288        let batch = KalmanHedgeRatio::new(1e-3, 1e-2).unwrap().batch(&pairs);
289        let mut k = KalmanHedgeRatio::new(1e-3, 1e-2).unwrap();
290        let streamed: Vec<_> = pairs.iter().map(|p| k.update(*p)).collect();
291        assert_eq!(batch, streamed);
292    }
293}