Skip to main content

wickra_core/indicators/
kyles_lambda.rs

1//! Kyle's Lambda — rolling price impact per unit of signed order flow.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::microstructure::TradeQuote;
7use crate::traits::Indicator;
8
9/// Kyle's Lambda — the rolling ordinary-least-squares slope of mid-price changes
10/// on signed trade volume, the canonical measure of market depth / price
11/// impact.
12///
13/// Each `update` receives a [`TradeQuote`] — a trade plus the mid prevailing at
14/// execution. Internally the indicator forms, per trade, the mid change since
15/// the previous trade (`Δmid = midₜ − midₜ₋₁`) and the signed volume
16/// (`q = size · D`, with `D` the aggressor sign), then runs a rolling OLS
17/// regression of `Δmid` on `q` over the trailing window of `window` trades:
18///
19/// ```text
20/// cov = (1/n) · Σ q·Δmid − q̄·Δ̄mid
21/// var = (1/n) · Σ q²      − q̄²
22/// λ   = cov / var
23/// ```
24///
25/// `λ` is the estimated price move per unit of signed volume: a deep, liquid
26/// book absorbs flow with little movement and reads a small `λ`; a thin book
27/// moves sharply per unit traded and reads a large `λ`. It is a direct,
28/// model-light proxy for the slope of the demand curve in Kyle's microstructure
29/// model.
30///
31/// Each `update` is O(1): four running sums (`Σq`, `ΣΔmid`, `Σq²`, `Σq·Δmid`)
32/// are maintained as the window slides. A window of constant signed volume has
33/// zero variance and `λ` is undefined; the indicator returns `0` in that case
34/// rather than producing `NaN`.
35///
36/// `Input = TradeQuote`, `Output = f64`. It warms up for `window + 1`
37/// trade-quotes: one to seed the previous mid, then `window` paired
38/// observations.
39///
40/// # Example
41///
42/// ```
43/// use wickra_core::{Indicator, KylesLambda, Side, Trade, TradeQuote};
44///
45/// // A book where each trade moves the mid by exactly 0.5 per unit of signed
46/// // volume gives λ = 0.5.
47/// let mut lambda = KylesLambda::new(8).unwrap();
48/// let mut mid = 100.0;
49/// let mut last = None;
50/// for i in 0..20 {
51///     let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
52///     let size = 1.0 + f64::from(i % 3);
53///     let signed = size * side.sign();
54///     mid += 0.5 * signed;
55///     let trade = Trade::new(mid, size, side, 0).unwrap();
56///     last = lambda.update(TradeQuote::new(trade, mid).unwrap());
57/// }
58/// assert!((last.unwrap() - 0.5).abs() < 1e-9);
59/// ```
60#[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    /// Construct a rolling Kyle's lambda over `window` paired observations.
73    ///
74    /// # Errors
75    ///
76    /// Returns [`Error::InvalidPeriod`] if `window < 2` (the regression
77    /// variance needs at least two observations).
78    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    /// The configured window length, in paired observations.
96    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            // Constant signed-volume window has no defined slope.
123            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        // mid moves exactly 0.5 per unit signed volume -> lambda = 0.5.
206        let last = KylesLambda::new(6)
207            .unwrap()
208            .batch(&quotes_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(&quotes_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        // Every trade is a buy of size 1: signed volume is constant -> var 0 -> 0.
231        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(&quotes)
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); // seeds prev mid
254        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(&quotes);
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}