wickra_core/indicators/
kalman_hedge_ratio.rs1use crate::error::{Error, Result};
4use crate::traits::Indicator;
5
6#[derive(Debug, Clone, Copy, PartialEq)]
8pub struct KalmanHedgeRatioOutput {
9 pub hedge_ratio: f64,
11 pub intercept: f64,
13 pub spread: f64,
16}
17
18#[derive(Debug, Clone)]
60pub struct KalmanHedgeRatio {
61 delta: f64,
62 transition_var: f64,
63 observation_var: f64,
64 beta: f64,
65 alpha: f64,
66 cov: [[f64; 2]; 2],
68 count: usize,
69}
70
71impl KalmanHedgeRatio {
72 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 pub const fn delta(&self) -> f64 {
104 self.delta
105 }
106
107 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 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 let predicted = self.beta * b + self.alpha;
131 let innovation = a - predicted;
132 let fr0 = b * cov_pred[0][0] + cov_pred[1][0];
134 let fr1 = b * cov_pred[0][1] + cov_pred[1][1];
135 let innovation_var = fr0 * b + fr1 + self.observation_var;
137 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 self.beta += gain0 * innovation;
144 self.alpha += gain1 * innovation;
145 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 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 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 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}