Skip to main content

wickra_core/indicators/
realized_spread.rs

1//! Realized Spread — the post-trade liquidity revenue of a trade in basis
2//! points.
3
4use std::collections::VecDeque;
5
6use crate::error::{Error, Result};
7use crate::microstructure::TradeQuote;
8use crate::traits::Indicator;
9
10/// Realized Spread — twice the signed deviation of a trade price from the mid
11/// that prevails `horizon` trades *later*, expressed in basis points of the
12/// trade's contemporaneous mid.
13///
14/// ```text
15/// realizedSpread = 2 · D · (tradePrice − mid_{t+horizon}) / mid_t · 10_000   (bps)
16/// ```
17///
18/// where `D` is the aggressor sign (`+1` for a buy, `−1` for a sell), `mid_t`
19/// is the mid at the time of the trade, and `mid_{t+horizon}` is the mid
20/// `horizon` trade-quotes later. Where the [effective spread] measures the full
21/// cost paid by the aggressor against the contemporaneous mid, the realized
22/// spread measures the share of that cost a liquidity provider *keeps* after
23/// the mid has moved: it is the effective spread net of the price impact
24/// (`effective = realized + 2 · priceImpact`). A high realized spread means
25/// the quote was not picked off; a low or negative one is the signature of
26/// adverse selection, the trade preceding a move in its own direction.
27///
28/// The indicator buffers each incoming trade-quote and emits the realized
29/// spread for the trade made `horizon` updates ago, once that future mid is
30/// known. It warms up for `horizon + 1` trade-quotes — `update` returns `None`
31/// until the first trade can be resolved — and then emits one value per update
32/// in O(1).
33///
34/// `Input = TradeQuote`, `Output = f64`.
35///
36/// [effective spread]: crate::EffectiveSpread
37///
38/// # Example
39///
40/// ```
41/// use wickra_core::{Indicator, RealizedSpread, Side, Trade, TradeQuote};
42///
43/// let mut rs = RealizedSpread::new(1).unwrap();
44/// let tq = |price: f64, side, mid| TradeQuote::new(Trade::new(price, 1.0, side, 0).unwrap(), mid).unwrap();
45/// // First trade buffered; nothing to resolve yet.
46/// assert_eq!(rs.update(tq(100.10, Side::Buy, 100.0)), None);
47/// // One trade later the mid is 100.20, resolving the first buy:
48/// // 2 · (+1) · (100.10 − 100.20) / 100.0 · 10_000 = −20 bps (adverse selection).
49/// let out = rs.update(tq(99.90, Side::Sell, 100.20)).unwrap();
50/// assert!((out - (-20.0)).abs() < 1e-9);
51/// ```
52#[derive(Debug, Clone)]
53pub struct RealizedSpread {
54    horizon: usize,
55    // Each pending entry is (aggressor sign, trade price, contemporaneous mid).
56    pending: VecDeque<(f64, f64, f64)>,
57    has_emitted: bool,
58}
59
60impl RealizedSpread {
61    /// Construct a realized-spread indicator that resolves each trade against
62    /// the mid `horizon` trade-quotes later.
63    ///
64    /// # Errors
65    ///
66    /// Returns [`Error::PeriodZero`] if `horizon` is zero (the realized spread
67    /// is defined against a strictly future mid).
68    pub fn new(horizon: usize) -> Result<Self> {
69        if horizon == 0 {
70            return Err(Error::PeriodZero);
71        }
72        if horizon > crate::error::MAX_PERIOD {
73            return Err(Error::InvalidPeriod {
74                message: crate::error::PERIOD_ABOVE_MAX,
75            });
76        }
77        Ok(Self {
78            horizon,
79            pending: VecDeque::with_capacity(horizon + 1),
80            has_emitted: false,
81        })
82    }
83
84    /// The configured horizon, in trade-quotes.
85    pub const fn horizon(&self) -> usize {
86        self.horizon
87    }
88}
89
90impl Indicator for RealizedSpread {
91    type Input = TradeQuote;
92    type Output = f64;
93
94    #[inline]
95    fn update(&mut self, quote: TradeQuote) -> Option<f64> {
96        let sign = quote.trade.side.sign();
97        self.pending.push_back((sign, quote.trade.price, quote.mid));
98        if self.pending.len() <= self.horizon {
99            return None;
100        }
101        let (old_sign, old_price, old_mid) = self.pending.pop_front().expect("len > horizon >= 1");
102        self.has_emitted = true;
103        // `quote.mid` is the mid prevailing `horizon` trades after the resolved
104        // trade; normalise by that trade's own contemporaneous mid.
105        Some(2.0 * old_sign * (old_price - quote.mid) / old_mid * 10_000.0)
106    }
107
108    fn reset(&mut self) {
109        self.pending.clear();
110        self.has_emitted = false;
111    }
112
113    #[inline]
114    fn warmup_period(&self) -> usize {
115        self.horizon + 1
116    }
117
118    #[inline]
119    fn is_ready(&self) -> bool {
120        self.has_emitted
121    }
122
123    #[inline]
124    fn name(&self) -> &'static str {
125        "RealizedSpread"
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::microstructure::{Side, Trade};
133    use crate::traits::BatchExt;
134
135    fn tq(price: f64, side: Side, mid: f64) -> TradeQuote {
136        TradeQuote::new(Trade::new(price, 1.0, side, 0).unwrap(), mid).unwrap()
137    }
138
139    #[test]
140    fn rejects_zero_horizon() {
141        assert!(matches!(RealizedSpread::new(0), Err(Error::PeriodZero)));
142        assert!(RealizedSpread::new(1).is_ok());
143    }
144
145    #[test]
146    fn accessors_and_metadata() {
147        let rs = RealizedSpread::new(3).unwrap();
148        assert_eq!(rs.name(), "RealizedSpread");
149        assert_eq!(rs.horizon(), 3);
150        assert_eq!(rs.warmup_period(), 4);
151        assert!(!rs.is_ready());
152    }
153
154    #[test]
155    fn resolves_against_future_mid() {
156        let mut rs = RealizedSpread::new(1).unwrap();
157        assert_eq!(rs.update(tq(100.10, Side::Buy, 100.0)), None);
158        assert!(!rs.is_ready());
159        // 2 · (+1) · (100.10 − 100.20) / 100.0 · 10_000 = −20 bps.
160        let out = rs.update(tq(99.90, Side::Sell, 100.20)).unwrap();
161        assert!((out - (-20.0)).abs() < 1e-9);
162        assert!(rs.is_ready());
163    }
164
165    #[test]
166    fn no_adverse_move_equals_effective_spread() {
167        // If the mid does not move over the horizon, realized == effective.
168        let mut rs = RealizedSpread::new(1).unwrap();
169        rs.update(tq(100.05, Side::Buy, 100.0));
170        // mid stays at 100.0 -> 2 · (100.05 − 100.0) / 100.0 · 10_000 = 10 bps.
171        let out = rs.update(tq(100.0, Side::Buy, 100.0)).unwrap();
172        assert!((out - 10.0).abs() < 1e-9);
173    }
174
175    #[test]
176    fn longer_horizon_warms_up() {
177        let mut rs = RealizedSpread::new(3).unwrap();
178        for _ in 0..3 {
179            assert_eq!(rs.update(tq(100.0, Side::Buy, 100.0)), None);
180        }
181        assert!(!rs.is_ready());
182        assert!(rs.update(tq(100.0, Side::Buy, 100.0)).is_some());
183        assert!(rs.is_ready());
184    }
185
186    #[test]
187    fn batch_equals_streaming() {
188        let quotes: Vec<TradeQuote> = (0..30)
189            .map(|i| {
190                let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
191                let mid = 100.0 + f64::from(i % 5) * 0.05;
192                tq(mid + 0.02, side, mid)
193            })
194            .collect();
195        let mut a = RealizedSpread::new(4).unwrap();
196        let mut b = RealizedSpread::new(4).unwrap();
197        assert_eq!(
198            a.batch(&quotes),
199            quotes.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
200        );
201    }
202
203    #[test]
204    fn reset_clears_state() {
205        let mut rs = RealizedSpread::new(1).unwrap();
206        rs.update(tq(100.05, Side::Buy, 100.0));
207        rs.update(tq(100.0, Side::Buy, 100.0));
208        assert!(rs.is_ready());
209        rs.reset();
210        assert!(!rs.is_ready());
211        assert_eq!(rs.update(tq(100.05, Side::Buy, 100.0)), None);
212    }
213}