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        Ok(Self {
73            horizon,
74            pending: VecDeque::with_capacity(horizon + 1),
75            has_emitted: false,
76        })
77    }
78
79    /// The configured horizon, in trade-quotes.
80    pub const fn horizon(&self) -> usize {
81        self.horizon
82    }
83}
84
85impl Indicator for RealizedSpread {
86    type Input = TradeQuote;
87    type Output = f64;
88
89    fn update(&mut self, quote: TradeQuote) -> Option<f64> {
90        let sign = quote.trade.side.sign();
91        self.pending.push_back((sign, quote.trade.price, quote.mid));
92        if self.pending.len() <= self.horizon {
93            return None;
94        }
95        let (old_sign, old_price, old_mid) = self.pending.pop_front().expect("len > horizon >= 1");
96        self.has_emitted = true;
97        // `quote.mid` is the mid prevailing `horizon` trades after the resolved
98        // trade; normalise by that trade's own contemporaneous mid.
99        Some(2.0 * old_sign * (old_price - quote.mid) / old_mid * 10_000.0)
100    }
101
102    fn reset(&mut self) {
103        self.pending.clear();
104        self.has_emitted = false;
105    }
106
107    fn warmup_period(&self) -> usize {
108        self.horizon + 1
109    }
110
111    fn is_ready(&self) -> bool {
112        self.has_emitted
113    }
114
115    fn name(&self) -> &'static str {
116        "RealizedSpread"
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::microstructure::{Side, Trade};
124    use crate::traits::BatchExt;
125
126    fn tq(price: f64, side: Side, mid: f64) -> TradeQuote {
127        TradeQuote::new(Trade::new(price, 1.0, side, 0).unwrap(), mid).unwrap()
128    }
129
130    #[test]
131    fn rejects_zero_horizon() {
132        assert!(matches!(RealizedSpread::new(0), Err(Error::PeriodZero)));
133        assert!(RealizedSpread::new(1).is_ok());
134    }
135
136    #[test]
137    fn accessors_and_metadata() {
138        let rs = RealizedSpread::new(3).unwrap();
139        assert_eq!(rs.name(), "RealizedSpread");
140        assert_eq!(rs.horizon(), 3);
141        assert_eq!(rs.warmup_period(), 4);
142        assert!(!rs.is_ready());
143    }
144
145    #[test]
146    fn resolves_against_future_mid() {
147        let mut rs = RealizedSpread::new(1).unwrap();
148        assert_eq!(rs.update(tq(100.10, Side::Buy, 100.0)), None);
149        assert!(!rs.is_ready());
150        // 2 · (+1) · (100.10 − 100.20) / 100.0 · 10_000 = −20 bps.
151        let out = rs.update(tq(99.90, Side::Sell, 100.20)).unwrap();
152        assert!((out - (-20.0)).abs() < 1e-9);
153        assert!(rs.is_ready());
154    }
155
156    #[test]
157    fn no_adverse_move_equals_effective_spread() {
158        // If the mid does not move over the horizon, realized == effective.
159        let mut rs = RealizedSpread::new(1).unwrap();
160        rs.update(tq(100.05, Side::Buy, 100.0));
161        // mid stays at 100.0 -> 2 · (100.05 − 100.0) / 100.0 · 10_000 = 10 bps.
162        let out = rs.update(tq(100.0, Side::Buy, 100.0)).unwrap();
163        assert!((out - 10.0).abs() < 1e-9);
164    }
165
166    #[test]
167    fn longer_horizon_warms_up() {
168        let mut rs = RealizedSpread::new(3).unwrap();
169        for _ in 0..3 {
170            assert_eq!(rs.update(tq(100.0, Side::Buy, 100.0)), None);
171        }
172        assert!(!rs.is_ready());
173        assert!(rs.update(tq(100.0, Side::Buy, 100.0)).is_some());
174        assert!(rs.is_ready());
175    }
176
177    #[test]
178    fn batch_equals_streaming() {
179        let quotes: Vec<TradeQuote> = (0..30)
180            .map(|i| {
181                let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
182                let mid = 100.0 + f64::from(i % 5) * 0.05;
183                tq(mid + 0.02, side, mid)
184            })
185            .collect();
186        let mut a = RealizedSpread::new(4).unwrap();
187        let mut b = RealizedSpread::new(4).unwrap();
188        assert_eq!(
189            a.batch(&quotes),
190            quotes.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
191        );
192    }
193
194    #[test]
195    fn reset_clears_state() {
196        let mut rs = RealizedSpread::new(1).unwrap();
197        rs.update(tq(100.05, Side::Buy, 100.0));
198        rs.update(tq(100.0, Side::Buy, 100.0));
199        assert!(rs.is_ready());
200        rs.reset();
201        assert!(!rs.is_ready());
202        assert_eq!(rs.update(tq(100.05, Side::Buy, 100.0)), None);
203    }
204}