wickra_core/indicators/
kyles_lambda.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::microstructure::TradeQuote;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone)]
61pub struct KylesLambda {
62 window: usize,
63 prev_mid: Option<f64>,
64 pairs: VecDeque<(f64, f64)>,
65 sum_q: f64,
66 sum_dm: f64,
67 sum_qq: f64,
68 sum_qdm: f64,
69}
70
71impl KylesLambda {
72 pub fn new(window: usize) -> Result<Self> {
79 if window < 2 {
80 return Err(Error::InvalidPeriod {
81 message: "kyle's lambda needs window >= 2",
82 });
83 }
84 Ok(Self {
85 window,
86 prev_mid: None,
87 pairs: VecDeque::with_capacity(window),
88 sum_q: 0.0,
89 sum_dm: 0.0,
90 sum_qq: 0.0,
91 sum_qdm: 0.0,
92 })
93 }
94
95 pub const fn window(&self) -> usize {
97 self.window
98 }
99
100 fn push_pair(&mut self, signed_vol: f64, delta_mid: f64) -> Option<f64> {
101 if self.pairs.len() == self.window {
102 let (old_q, old_dm) = self.pairs.pop_front().expect("non-empty");
103 self.sum_q -= old_q;
104 self.sum_dm -= old_dm;
105 self.sum_qq -= old_q * old_q;
106 self.sum_qdm -= old_q * old_dm;
107 }
108 self.pairs.push_back((signed_vol, delta_mid));
109 self.sum_q += signed_vol;
110 self.sum_dm += delta_mid;
111 self.sum_qq += signed_vol * signed_vol;
112 self.sum_qdm += signed_vol * delta_mid;
113 if self.pairs.len() < self.window {
114 return None;
115 }
116 let n = self.window as f64;
117 let mean_q = self.sum_q / n;
118 let mean_dm = self.sum_dm / n;
119 let var_q = (self.sum_qq / n - mean_q * mean_q).max(0.0);
120 let cov = self.sum_qdm / n - mean_q * mean_dm;
121 if var_q == 0.0 {
122 return Some(0.0);
124 }
125 Some(cov / var_q)
126 }
127}
128
129impl Indicator for KylesLambda {
130 type Input = TradeQuote;
131 type Output = f64;
132
133 fn update(&mut self, quote: TradeQuote) -> Option<f64> {
134 let mid = quote.mid;
135 let signed_vol = quote.trade.size * quote.trade.side.sign();
136 let Some(prev) = self.prev_mid else {
137 self.prev_mid = Some(mid);
138 return None;
139 };
140 self.prev_mid = Some(mid);
141 self.push_pair(signed_vol, mid - prev)
142 }
143
144 fn reset(&mut self) {
145 self.prev_mid = None;
146 self.pairs.clear();
147 self.sum_q = 0.0;
148 self.sum_dm = 0.0;
149 self.sum_qq = 0.0;
150 self.sum_qdm = 0.0;
151 }
152
153 fn warmup_period(&self) -> usize {
154 self.window + 1
155 }
156
157 fn is_ready(&self) -> bool {
158 self.pairs.len() == self.window
159 }
160
161 fn name(&self) -> &'static str {
162 "KylesLambda"
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use crate::microstructure::{Side, Trade};
170 use crate::traits::BatchExt;
171 use approx::assert_relative_eq;
172
173 fn quotes_with_impact(n: usize, impact: f64) -> Vec<TradeQuote> {
174 let mut mid = 100.0;
175 (0..n)
176 .map(|i| {
177 let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
178 let size = 1.0 + (i % 3) as f64;
179 let signed = size * side.sign();
180 mid += impact * signed;
181 let trade = Trade::new(mid, size, side, 0).unwrap();
182 TradeQuote::new(trade, mid).unwrap()
183 })
184 .collect()
185 }
186
187 #[test]
188 fn rejects_window_below_two() {
189 assert!(KylesLambda::new(0).is_err());
190 assert!(KylesLambda::new(1).is_err());
191 assert!(KylesLambda::new(2).is_ok());
192 }
193
194 #[test]
195 fn accessors_and_metadata() {
196 let kl = KylesLambda::new(14).unwrap();
197 assert_eq!(kl.name(), "KylesLambda");
198 assert_eq!(kl.window(), 14);
199 assert_eq!(kl.warmup_period(), 15);
200 assert!(!kl.is_ready());
201 }
202
203 #[test]
204 fn recovers_constant_impact_slope() {
205 let last = KylesLambda::new(6)
207 .unwrap()
208 .batch("es_with_impact(20, 0.5))
209 .into_iter()
210 .flatten()
211 .last()
212 .unwrap();
213 assert_relative_eq!(last, 0.5, epsilon = 1e-9);
214 }
215
216 #[test]
217 fn negative_impact_reads_negative() {
218 let last = KylesLambda::new(6)
219 .unwrap()
220 .batch("es_with_impact(20, -0.3))
221 .into_iter()
222 .flatten()
223 .last()
224 .unwrap();
225 assert_relative_eq!(last, -0.3, epsilon = 1e-9);
226 }
227
228 #[test]
229 fn constant_signed_volume_is_zero() {
230 let mut mid = 100.0;
232 let quotes: Vec<TradeQuote> = (0..10)
233 .map(|_| {
234 mid += 0.01;
235 let trade = Trade::new(mid, 1.0, Side::Buy, 0).unwrap();
236 TradeQuote::new(trade, mid).unwrap()
237 })
238 .collect();
239 let last = KylesLambda::new(5)
240 .unwrap()
241 .batch("es)
242 .into_iter()
243 .flatten()
244 .last()
245 .unwrap();
246 assert_relative_eq!(last, 0.0, epsilon = 1e-12);
247 }
248
249 #[test]
250 fn warms_up_after_window_plus_one() {
251 let mut kl = KylesLambda::new(3).unwrap();
252 let quotes = quotes_with_impact(4, 0.2);
253 assert_eq!(kl.update(quotes[0]), None); assert_eq!(kl.update(quotes[1]), None);
255 assert_eq!(kl.update(quotes[2]), None);
256 assert!(!kl.is_ready());
257 assert!(kl.update(quotes[3]).is_some());
258 assert!(kl.is_ready());
259 }
260
261 #[test]
262 fn batch_equals_streaming() {
263 let quotes = quotes_with_impact(40, 0.15);
264 let batch = KylesLambda::new(10).unwrap().batch("es);
265 let mut kl = KylesLambda::new(10).unwrap();
266 let streamed: Vec<_> = quotes.iter().map(|q| kl.update(*q)).collect();
267 assert_eq!(batch, streamed);
268 }
269
270 #[test]
271 fn reset_clears_state() {
272 let mut kl = KylesLambda::new(3).unwrap();
273 for q in quotes_with_impact(6, 0.2) {
274 kl.update(q);
275 }
276 assert!(kl.is_ready());
277 kl.reset();
278 assert!(!kl.is_ready());
279 assert_eq!(kl.update(quotes_with_impact(1, 0.2)[0]), None);
280 }
281}