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 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 let predicted = self.beta * b + self.alpha;
128 let innovation = a - predicted;
129 let fr0 = b * cov_pred[0][0] + cov_pred[1][0];
131 let fr1 = b * cov_pred[0][1] + cov_pred[1][1];
132 let innovation_var = fr0 * b + fr1 + self.observation_var;
134 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 self.beta += gain0 * innovation;
141 self.alpha += gain1 * innovation;
142 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 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 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 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}