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::indicators::rolling_moments::ShiftedPairMoments;
7use crate::microstructure::TradeQuote;
8use crate::traits::Indicator;
9
10/// Kyle's Lambda — the rolling ordinary-least-squares slope of mid-price changes
11/// on signed trade volume, the canonical measure of market depth / price
12/// impact.
13///
14/// Each `update` receives a [`TradeQuote`] — a trade plus the mid prevailing at
15/// execution. Internally the indicator forms, per trade, the mid change since
16/// the previous trade (`Δmid = midₜ − midₜ₋₁`) and the signed volume
17/// (`q = size · D`, with `D` the aggressor sign), then runs a rolling OLS
18/// regression of `Δmid` on `q` over the trailing window of `window` trades:
19///
20/// ```text
21/// cov = (1/n) · Σ q·Δmid − q̄·Δ̄mid
22/// var = (1/n) · Σ q²      − q̄²
23/// λ   = cov / var
24/// ```
25///
26/// `λ` is the estimated price move per unit of signed volume: a deep, liquid
27/// book absorbs flow with little movement and reads a small `λ`; a thin book
28/// moves sharply per unit traded and reads a large `λ`. It is a direct,
29/// model-light proxy for the slope of the demand curve in Kyle's microstructure
30/// model.
31///
32/// Each `update` is O(1): four running sums (`Σq`, `ΣΔmid`, `Σq²`, `Σq·Δmid`)
33/// are maintained as the window slides. A window of constant signed volume has
34/// zero variance and `λ` is undefined; the indicator returns `0` in that case
35/// rather than producing `NaN`.
36///
37/// `Input = TradeQuote`, `Output = f64`. It warms up for `window + 1`
38/// trade-quotes: one to seed the previous mid, then `window` paired
39/// observations.
40///
41/// # Example
42///
43/// ```
44/// use wickra_core::{Indicator, KylesLambda, Side, Trade, TradeQuote};
45///
46/// // A book where each trade moves the mid by exactly 0.5 per unit of signed
47/// // volume gives λ = 0.5.
48/// let mut lambda = KylesLambda::new(8).unwrap();
49/// let mut mid = 100.0;
50/// let mut last = None;
51/// for i in 0..20 {
52///     let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
53///     let size = 1.0 + f64::from(i % 3);
54///     let signed = size * side.sign();
55///     mid += 0.5 * signed;
56///     let trade = Trade::new(mid, size, side, 0).unwrap();
57///     last = lambda.update(TradeQuote::new(trade, mid).unwrap());
58/// }
59/// assert!((last.unwrap() - 0.5).abs() < 1e-9);
60/// ```
61#[derive(Debug, Clone)]
62pub struct KylesLambda {
63    window: usize,
64    prev_mid: Option<f64>,
65    pairs: VecDeque<(f64, f64)>,
66    moments: ShiftedPairMoments,
67}
68
69impl KylesLambda {
70    /// Construct a rolling Kyle's lambda over `window` paired observations.
71    ///
72    /// # Errors
73    ///
74    /// Returns [`Error::InvalidPeriod`] if `window < 2` (the regression
75    /// variance needs at least two observations).
76    pub fn new(window: usize) -> Result<Self> {
77        if window < 2 {
78            return Err(Error::InvalidPeriod {
79                message: "kyle's lambda needs window >= 2",
80            });
81        }
82        if window > crate::error::MAX_PERIOD {
83            return Err(Error::InvalidPeriod {
84                message: crate::error::PERIOD_ABOVE_MAX,
85            });
86        }
87        Ok(Self {
88            window,
89            prev_mid: None,
90            pairs: VecDeque::with_capacity(window),
91            moments: ShiftedPairMoments::new(),
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.moments.evict(old_q, old_dm);
104        }
105        self.pairs.push_back((signed_vol, delta_mid));
106        self.moments.push(signed_vol, delta_mid);
107        if self.moments.needs_reseed(self.window) {
108            self.moments.reseed(self.pairs.iter().copied());
109        }
110        if self.pairs.len() < self.window {
111            return None;
112        }
113        let var_q = self.moments.var_a(self.window);
114        if var_q == 0.0 {
115            // Constant signed-volume window has no defined slope.
116            return Some(0.0);
117        }
118        Some(self.moments.cov(self.window) / var_q)
119    }
120}
121
122impl Indicator for KylesLambda {
123    type Input = TradeQuote;
124    type Output = f64;
125
126    #[inline]
127    fn update(&mut self, quote: TradeQuote) -> Option<f64> {
128        let mid = quote.mid;
129        let signed_vol = quote.trade.size * quote.trade.side.sign();
130        let Some(prev) = self.prev_mid else {
131            self.prev_mid = Some(mid);
132            return None;
133        };
134        self.prev_mid = Some(mid);
135        self.push_pair(signed_vol, mid - prev)
136    }
137
138    fn reset(&mut self) {
139        self.prev_mid = None;
140        self.pairs.clear();
141        self.moments.reset();
142    }
143
144    #[inline]
145    fn warmup_period(&self) -> usize {
146        self.window + 1
147    }
148
149    #[inline]
150    fn is_ready(&self) -> bool {
151        self.pairs.len() == self.window
152    }
153
154    #[inline]
155    fn name(&self) -> &'static str {
156        "KylesLambda"
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::microstructure::{Side, Trade};
164    use crate::traits::BatchExt;
165    use approx::assert_relative_eq;
166
167    fn quotes_with_impact(n: usize, impact: f64) -> Vec<TradeQuote> {
168        let mut mid = 100.0;
169        (0..n)
170            .map(|i| {
171                let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
172                let size = 1.0 + (i % 3) as f64;
173                let signed = size * side.sign();
174                mid += impact * signed;
175                let trade = Trade::new(mid, size, side, 0).unwrap();
176                TradeQuote::new(trade, mid).unwrap()
177            })
178            .collect()
179    }
180
181    #[test]
182    fn rejects_window_below_two() {
183        assert!(KylesLambda::new(0).is_err());
184        assert!(KylesLambda::new(1).is_err());
185        assert!(KylesLambda::new(2).is_ok());
186    }
187
188    #[test]
189    fn accessors_and_metadata() {
190        let kl = KylesLambda::new(14).unwrap();
191        assert_eq!(kl.name(), "KylesLambda");
192        assert_eq!(kl.window(), 14);
193        assert_eq!(kl.warmup_period(), 15);
194        assert!(!kl.is_ready());
195    }
196
197    #[test]
198    fn recovers_constant_impact_slope() {
199        // mid moves exactly 0.5 per unit signed volume -> lambda = 0.5.
200        let last = KylesLambda::new(6)
201            .unwrap()
202            .batch(&quotes_with_impact(20, 0.5))
203            .into_iter()
204            .flatten()
205            .last()
206            .unwrap();
207        assert_relative_eq!(last, 0.5, epsilon = 1e-9);
208    }
209
210    #[test]
211    fn negative_impact_reads_negative() {
212        let last = KylesLambda::new(6)
213            .unwrap()
214            .batch(&quotes_with_impact(20, -0.3))
215            .into_iter()
216            .flatten()
217            .last()
218            .unwrap();
219        assert_relative_eq!(last, -0.3, epsilon = 1e-9);
220    }
221
222    #[test]
223    fn constant_signed_volume_is_zero() {
224        // Every trade is a buy of size 1: signed volume is constant -> var 0 -> 0.
225        let mut mid = 100.0;
226        let quotes: Vec<TradeQuote> = (0..10)
227            .map(|_| {
228                mid += 0.01;
229                let trade = Trade::new(mid, 1.0, Side::Buy, 0).unwrap();
230                TradeQuote::new(trade, mid).unwrap()
231            })
232            .collect();
233        let last = KylesLambda::new(5)
234            .unwrap()
235            .batch(&quotes)
236            .into_iter()
237            .flatten()
238            .last()
239            .unwrap();
240        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
241    }
242
243    #[test]
244    fn warms_up_after_window_plus_one() {
245        let mut kl = KylesLambda::new(3).unwrap();
246        let quotes = quotes_with_impact(4, 0.2);
247        assert_eq!(kl.update(quotes[0]), None); // seeds prev mid
248        assert_eq!(kl.update(quotes[1]), None);
249        assert_eq!(kl.update(quotes[2]), None);
250        assert!(!kl.is_ready());
251        assert!(kl.update(quotes[3]).is_some());
252        assert!(kl.is_ready());
253    }
254
255    #[test]
256    fn batch_equals_streaming() {
257        let quotes = quotes_with_impact(40, 0.15);
258        let batch = KylesLambda::new(10).unwrap().batch(&quotes);
259        let mut kl = KylesLambda::new(10).unwrap();
260        let streamed: Vec<_> = quotes.iter().map(|q| kl.update(*q)).collect();
261        assert_eq!(batch, streamed);
262    }
263
264    #[test]
265    fn reset_clears_state() {
266        let mut kl = KylesLambda::new(3).unwrap();
267        for q in quotes_with_impact(6, 0.2) {
268            kl.update(q);
269        }
270        assert!(kl.is_ready());
271        kl.reset();
272        assert!(!kl.is_ready());
273        assert_eq!(kl.update(quotes_with_impact(1, 0.2)[0]), None);
274    }
275
276    /// Signed volume is only centred on zero while order flow is balanced. A
277    /// persistent one-sided imbalance moves its mean far from zero, and the
278    /// variance was then computed as `E[q^2] - E[q]^2` over running sums. At a
279    /// trade size around 1e8 with a 99% buy imbalance that measured 2.9e-09
280    /// against a two-pass reference; a centred accumulator gives 2.1e-12.
281    #[test]
282    fn one_sided_flow_at_size_matches_a_two_pass_reference() {
283        const WINDOW: usize = 20;
284        const BARS: i64 = 600;
285
286        let mut ind = KylesLambda::new(WINDOW).unwrap();
287        let (mut qs, mut deltas): (Vec<f64>, Vec<f64>) = (Vec::new(), Vec::new());
288        let mut prev_mid = 100.0;
289        let mut compared = 0_usize;
290        for i in 0..BARS {
291            let t = i as f64;
292            // Sells only where the sine dips below -0.99: a 99% buy imbalance.
293            let side = if (t * 0.37).sin() > -0.99 {
294                Side::Buy
295            } else {
296                Side::Sell
297            };
298            let size = 1e8 * (1.0 + 0.02 * (t * 0.19).cos());
299            let mid = 100.0 + 0.5 * (t * 0.05).sin();
300            let quote = TradeQuote::new(Trade::new(mid, size, side, i).unwrap(), mid).unwrap();
301            let got = ind.update(quote);
302            qs.push(if matches!(side, Side::Buy) {
303                size
304            } else {
305                -size
306            });
307            deltas.push(mid - prev_mid);
308            prev_mid = mid;
309            let Some(lambda) = got else { continue };
310            let k = qs.len();
311            let (xs, ys) = (&qs[k - WINDOW..], &deltas[k - WINDOW..]);
312            let n = WINDOW as f64;
313            let mean_q = xs.iter().sum::<f64>() / n;
314            let mean_d = ys.iter().sum::<f64>() / n;
315            let var_q = xs.iter().map(|v| (v - mean_q) * (v - mean_q)).sum::<f64>() / n;
316            let cov = xs
317                .iter()
318                .zip(ys)
319                .map(|(u, v)| (u - mean_q) * (v - mean_d))
320                .sum::<f64>()
321                / n;
322            compared += 1;
323            assert_relative_eq!(lambda, cov / var_q, max_relative = 1e-10);
324        }
325        assert_eq!(compared, BARS as usize - ind.warmup_period() + 1);
326    }
327}