Skip to main content

wickra_core/indicators/
roll_measure.rs

1//! Roll Measure — effective spread implied by serial covariance of price changes.
2
3use std::collections::VecDeque;
4
5use crate::microstructure::Trade;
6use crate::traits::Indicator;
7use crate::{Error, Result};
8
9/// Roll Measure — the effective bid-ask spread implied by the negative
10/// first-order serial covariance of trade-price changes (Roll, 1984).
11///
12/// ```text
13/// Δpₜ  = priceₜ − priceₜ₋₁
14/// γ    = sample lag-1 autocovariance of Δp over the last `period` changes
15/// spread = 2 · √(−γ)   if γ < 0,   else 0
16/// ```
17///
18/// Roll's insight: in a frictionless market price changes are serially
19/// uncorrelated, but the *bid-ask bounce* — trades alternating between buying at
20/// the ask and selling at the bid — induces a **negative** autocovariance whose
21/// magnitude pins the spread. The measure recovers an effective spread from
22/// trade prices alone, with no quote data. When the serial covariance is
23/// non-negative (a trending or frictionless tape) the model implies no spread
24/// and the indicator returns `0`.
25///
26/// `Input = Trade` (only the price is used). Each `update` is `O(period)`: the
27/// autocovariance is recomputed from the window of price changes.
28///
29/// # Example
30///
31/// ```
32/// use wickra_core::{Indicator, Side, Trade, RollMeasure};
33///
34/// let mut roll = RollMeasure::new(20).unwrap();
35/// let mut last = None;
36/// // A clean bid-ask bounce of ±0.5 around 100 implies a spread near 1.0.
37/// for i in 0..40 {
38///     let price = if i % 2 == 0 { 100.0 } else { 101.0 };
39///     last = roll.update(Trade::new(price, 1.0, Side::Buy, 0).unwrap());
40/// }
41/// assert!(last.unwrap() > 0.0);
42/// ```
43#[derive(Debug, Clone)]
44pub struct RollMeasure {
45    period: usize,
46    prev_price: Option<f64>,
47    window: VecDeque<f64>,
48    /// Reusable scratch buffer to avoid allocating per `update`.
49    scratch: Vec<f64>,
50}
51
52impl RollMeasure {
53    /// Construct a new Roll Measure over the given window of price changes.
54    ///
55    /// # Errors
56    /// Returns [`Error::InvalidPeriod`] if `period < 3` — the lag-1
57    /// autocovariance needs at least two consecutive change pairs.
58    pub fn new(period: usize) -> Result<Self> {
59        if period < 3 {
60            return Err(Error::InvalidPeriod {
61                message: "Roll measure needs period >= 3",
62            });
63        }
64        if period > crate::error::MAX_PERIOD {
65            return Err(Error::InvalidPeriod {
66                message: crate::error::PERIOD_ABOVE_MAX,
67            });
68        }
69        Ok(Self {
70            period,
71            prev_price: None,
72            window: VecDeque::with_capacity(period),
73            scratch: Vec::with_capacity(period),
74        })
75    }
76
77    /// Configured period.
78    pub const fn period(&self) -> usize {
79        self.period
80    }
81}
82
83impl Indicator for RollMeasure {
84    type Input = Trade;
85    type Output = f64;
86
87    #[inline]
88    fn update(&mut self, trade: Trade) -> Option<f64> {
89        let Some(prev) = self.prev_price else {
90            self.prev_price = Some(trade.price);
91            return None;
92        };
93        let change = trade.price - prev;
94        self.prev_price = Some(trade.price);
95        if self.window.len() == self.period {
96            self.window.pop_front();
97        }
98        self.window.push_back(change);
99        if self.window.len() < self.period {
100            return None;
101        }
102        // Sample lag-1 autocovariance of the price changes over the window.
103        self.scratch.clear();
104        self.scratch.extend(self.window.iter().copied());
105        let changes = &self.scratch;
106        let count = changes.len() as f64;
107        let mean = changes.iter().sum::<f64>() / count;
108        let pairs = (changes.len() - 1) as f64;
109        let mut cov = 0.0;
110        for pair in changes.windows(2) {
111            cov += (pair[0] - mean) * (pair[1] - mean);
112        }
113        cov /= pairs;
114        let spread = if cov < 0.0 { 2.0 * (-cov).sqrt() } else { 0.0 };
115        Some(spread)
116    }
117
118    fn reset(&mut self) {
119        self.prev_price = None;
120        self.window.clear();
121        self.scratch.clear();
122    }
123
124    #[inline]
125    fn warmup_period(&self) -> usize {
126        self.period + 1
127    }
128
129    #[inline]
130    fn is_ready(&self) -> bool {
131        self.window.len() == self.period
132    }
133
134    #[inline]
135    fn name(&self) -> &'static str {
136        "RollMeasure"
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::microstructure::Side;
144    use crate::traits::BatchExt;
145    use approx::assert_relative_eq;
146
147    fn trade(price: f64) -> Trade {
148        Trade::new(price, 1.0, Side::Buy, 0).unwrap()
149    }
150
151    #[test]
152    fn rejects_period_below_three() {
153        assert!(matches!(
154            RollMeasure::new(2),
155            Err(Error::InvalidPeriod { .. })
156        ));
157        assert!(RollMeasure::new(3).is_ok());
158    }
159
160    #[test]
161    fn accessors_and_metadata() {
162        let roll = RollMeasure::new(20).unwrap();
163        assert_eq!(roll.period(), 20);
164        assert_eq!(roll.warmup_period(), 21);
165        assert_eq!(roll.name(), "RollMeasure");
166        assert!(!roll.is_ready());
167    }
168
169    #[test]
170    fn bid_ask_bounce_implies_spread() {
171        // Prices bounce 100/101 => Δp alternates +1/-1 => mean 0, lag-1
172        // autocov = -5/(6-1) = -1 over a 6-change window => spread = 2.
173        let mut roll = RollMeasure::new(6).unwrap();
174        let prices: Vec<Trade> = (0..20)
175            .map(|i| trade(if i % 2 == 0 { 100.0 } else { 101.0 }))
176            .collect();
177        let last = roll.batch(&prices).into_iter().flatten().last().unwrap();
178        assert_relative_eq!(last, 2.0, epsilon = 1e-12);
179    }
180
181    #[test]
182    fn trending_prices_imply_no_spread() {
183        // Monotone prices => constant Δp => zero-centred deviations => cov 0
184        // => spread 0.
185        let mut roll = RollMeasure::new(6).unwrap();
186        let prices: Vec<Trade> = (0..20).map(|i| trade(100.0 + f64::from(i))).collect();
187        for v in roll.batch(&prices).into_iter().flatten() {
188            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
189        }
190    }
191
192    #[test]
193    fn output_is_non_negative() {
194        let mut roll = RollMeasure::new(20).unwrap();
195        let prices: Vec<Trade> = (0..200)
196            .map(|i| trade(100.0 + (f64::from(i) * 0.7).sin() * 2.0))
197            .collect();
198        for v in roll.batch(&prices).into_iter().flatten() {
199            assert!(v >= 0.0, "spread must be non-negative, got {v}");
200        }
201    }
202
203    #[test]
204    fn reset_clears_state() {
205        let mut roll = RollMeasure::new(5).unwrap();
206        for i in 0..20 {
207            roll.update(trade(100.0 + f64::from(i % 2)));
208        }
209        assert!(roll.is_ready());
210        roll.reset();
211        assert!(!roll.is_ready());
212        assert_eq!(roll.update(trade(100.0)), None);
213    }
214
215    #[test]
216    fn batch_equals_streaming() {
217        let prices: Vec<Trade> = (0..80)
218            .map(|i| trade(100.0 + (f64::from(i) * 0.6).sin() * 3.0))
219            .collect();
220        let batch = RollMeasure::new(14).unwrap().batch(&prices);
221        let mut b = RollMeasure::new(14).unwrap();
222        let streamed: Vec<_> = prices.iter().map(|t| b.update(*t)).collect();
223        assert_eq!(batch, streamed);
224    }
225}