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    fn update(&mut self, quote: TradeQuote) -> Option<f64> {
61        self.has_emitted = true;
62        let sign = quote.trade.side.sign();
63        Some(2.0 * sign * (quote.trade.price - quote.mid) / quote.mid * 10_000.0)
64    }
65
66    fn reset(&mut self) {
67        self.has_emitted = false;
68    }
69
70    fn warmup_period(&self) -> usize {
71        1
72    }
73
74    fn is_ready(&self) -> bool {
75        self.has_emitted
76    }
77
78    fn name(&self) -> &'static str {
79        "EffectiveSpread"
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use crate::microstructure::{Side, Trade};
87    use crate::traits::BatchExt;
88
89    fn quote(price: f64, side: Side, mid: f64) -> TradeQuote {
90        TradeQuote::new(Trade::new(price, 1.0, side, 0).unwrap(), mid).unwrap()
91    }
92
93    #[test]
94    fn accessors_and_metadata() {
95        let es = EffectiveSpread::new();
96        assert_eq!(es.name(), "EffectiveSpread");
97        assert_eq!(es.warmup_period(), 1);
98        assert!(!es.is_ready());
99    }
100
101    #[test]
102    fn buy_above_mid_is_positive() {
103        let mut es = EffectiveSpread::new();
104        // 2 · (+1) · (100.05 − 100.0) / 100.0 · 10_000 = 10 bps.
105        let out = es.update(quote(100.05, Side::Buy, 100.0)).unwrap();
106        assert!((out - 10.0).abs() < 1e-9);
107        assert!(es.is_ready());
108    }
109
110    #[test]
111    fn sell_below_mid_is_positive() {
112        let mut es = EffectiveSpread::new();
113        // 2 · (−1) · (99.95 − 100.0) / 100.0 · 10_000 = 10 bps.
114        let out = es.update(quote(99.95, Side::Sell, 100.0)).unwrap();
115        assert!((out - 10.0).abs() < 1e-9);
116    }
117
118    #[test]
119    fn price_improvement_reads_negative() {
120        let mut es = EffectiveSpread::new();
121        // A buy filled below the mid: price improvement -> negative.
122        let out = es.update(quote(99.95, Side::Buy, 100.0)).unwrap();
123        assert!(out < 0.0);
124    }
125
126    #[test]
127    fn trade_at_mid_is_zero() {
128        let mut es = EffectiveSpread::new();
129        assert_eq!(es.update(quote(100.0, Side::Buy, 100.0)), Some(0.0));
130    }
131
132    #[test]
133    fn batch_equals_streaming() {
134        let quotes: Vec<TradeQuote> = (0..20)
135            .map(|i| {
136                let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
137                let price = 100.0 + f64::from(i % 4) * 0.01;
138                quote(price, side, 100.0)
139            })
140            .collect();
141        let mut a = EffectiveSpread::new();
142        let mut b = EffectiveSpread::new();
143        assert_eq!(
144            a.batch(&quotes),
145            quotes.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
146        );
147    }
148
149    #[test]
150    fn reset_clears_state() {
151        let mut es = EffectiveSpread::new();
152        es.update(quote(100.05, Side::Buy, 100.0));
153        assert!(es.is_ready());
154        es.reset();
155        assert!(!es.is_ready());
156    }
157}