Skip to main content

wickra_core/indicators/
effective_spread.rs

1//! Effective Spread — the realised cost of a single trade in basis points.
2
3use crate::microstructure::TradeQuote;
4use crate::traits::Indicator;
5
6/// Effective Spread — twice the signed deviation of an executed trade price
7/// from the prevailing mid, expressed in basis points of the mid.
8///
9/// ```text
10/// effectiveSpread = 2 · D · (tradePrice − mid) / mid · 10_000   (bps)
11/// ```
12///
13/// where `D` is the aggressor sign (`+1` for a buy, `−1` for a sell). The
14/// factor of two scales the one-sided deviation up to a full round-trip cost so
15/// it is directly comparable to the [quoted spread]: a marketable order that
16/// fills exactly at the touch of an otherwise quoted-spread book pays an
17/// effective spread equal to the quoted spread. Trades that fill *inside* the
18/// spread (price improvement) read below the quoted spread; trades that walk
19/// the book read above it.
20///
21/// A buy printed above the mid (`tradePrice > mid`) and a sell printed below it
22/// both yield a positive effective spread — the conventional sign, since the
23/// aggressor pays in both cases. A trade printed on the wrong side of the mid
24/// for its aggressor flag (a buy below the mid) reads negative, the signature of
25/// price improvement or a stale/mislabelled quote.
26///
27/// `Input = TradeQuote`, `Output = f64`. Stateless; ready after the first
28/// trade-quote.
29///
30/// [quoted spread]: crate::QuotedSpread
31///
32/// # Example
33///
34/// ```
35/// use wickra_core::{EffectiveSpread, Indicator, Side, Trade, TradeQuote};
36///
37/// let mut es = EffectiveSpread::new();
38/// // Buy filled at 100.05 against a mid of 100.0:
39/// // 2 · (+1) · (100.05 − 100.0) / 100.0 · 10_000 = 10 bps.
40/// let trade = Trade::new(100.05, 1.0, Side::Buy, 0).unwrap();
41/// let quote = TradeQuote::new(trade, 100.0).unwrap();
42/// assert!((es.update(quote).unwrap() - 10.0).abs() < 1e-9);
43/// ```
44#[derive(Debug, Clone, Default)]
45pub struct EffectiveSpread {
46    has_emitted: bool,
47}
48
49impl EffectiveSpread {
50    /// Construct a new effective-spread indicator.
51    pub const fn new() -> Self {
52        Self { has_emitted: false }
53    }
54}
55
56impl Indicator for EffectiveSpread {
57    type Input = TradeQuote;
58    type Output = f64;
59
60    #[inline]
61    fn update(&mut self, quote: TradeQuote) -> Option<f64> {
62        self.has_emitted = true;
63        let sign = quote.trade.side.sign();
64        Some(2.0 * sign * (quote.trade.price - quote.mid) / quote.mid * 10_000.0)
65    }
66
67    fn reset(&mut self) {
68        self.has_emitted = false;
69    }
70
71    #[inline]
72    fn warmup_period(&self) -> usize {
73        1
74    }
75
76    #[inline]
77    fn is_ready(&self) -> bool {
78        self.has_emitted
79    }
80
81    #[inline]
82    fn name(&self) -> &'static str {
83        "EffectiveSpread"
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use crate::microstructure::{Side, Trade};
91    use crate::traits::BatchExt;
92
93    fn quote(price: f64, side: Side, mid: f64) -> TradeQuote {
94        TradeQuote::new(Trade::new(price, 1.0, side, 0).unwrap(), mid).unwrap()
95    }
96
97    #[test]
98    fn accessors_and_metadata() {
99        let es = EffectiveSpread::new();
100        assert_eq!(es.name(), "EffectiveSpread");
101        assert_eq!(es.warmup_period(), 1);
102        assert!(!es.is_ready());
103    }
104
105    #[test]
106    fn buy_above_mid_is_positive() {
107        let mut es = EffectiveSpread::new();
108        // 2 · (+1) · (100.05 − 100.0) / 100.0 · 10_000 = 10 bps.
109        let out = es.update(quote(100.05, Side::Buy, 100.0)).unwrap();
110        assert!((out - 10.0).abs() < 1e-9);
111        assert!(es.is_ready());
112    }
113
114    #[test]
115    fn sell_below_mid_is_positive() {
116        let mut es = EffectiveSpread::new();
117        // 2 · (−1) · (99.95 − 100.0) / 100.0 · 10_000 = 10 bps.
118        let out = es.update(quote(99.95, Side::Sell, 100.0)).unwrap();
119        assert!((out - 10.0).abs() < 1e-9);
120    }
121
122    #[test]
123    fn price_improvement_reads_negative() {
124        let mut es = EffectiveSpread::new();
125        // A buy filled below the mid: price improvement -> negative.
126        let out = es.update(quote(99.95, Side::Buy, 100.0)).unwrap();
127        assert!(out < 0.0);
128    }
129
130    #[test]
131    fn trade_at_mid_is_zero() {
132        let mut es = EffectiveSpread::new();
133        assert_eq!(es.update(quote(100.0, Side::Buy, 100.0)), Some(0.0));
134    }
135
136    #[test]
137    fn batch_equals_streaming() {
138        let quotes: Vec<TradeQuote> = (0..20)
139            .map(|i| {
140                let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
141                let price = 100.0 + f64::from(i % 4) * 0.01;
142                quote(price, side, 100.0)
143            })
144            .collect();
145        let mut a = EffectiveSpread::new();
146        let mut b = EffectiveSpread::new();
147        assert_eq!(
148            a.batch(&quotes),
149            quotes.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
150        );
151    }
152
153    #[test]
154    fn reset_clears_state() {
155        let mut es = EffectiveSpread::new();
156        es.update(quote(100.05, Side::Buy, 100.0));
157        assert!(es.is_ready());
158        es.reset();
159        assert!(!es.is_ready());
160    }
161}