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