Skip to main content

wickra_core/
microstructure.rs

1//! Microstructure value types: order-book snapshots and trades.
2//!
3//! These are the non-OHLCV inputs consumed by the order-book / trade-flow
4//! indicator family. An [`OrderBook`] is a depth snapshot (sorted bid and ask
5//! levels); a [`Trade`] is a single executed trade with an aggressor [`Side`];
6//! a [`TradeQuote`] pairs a trade with the mid-price prevailing at execution,
7//! the input for spread- and price-impact measures.
8
9use crate::error::{Error, Result};
10
11/// A single order-book price level: a resting quantity at a price.
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct Level {
14    /// Price of the level (strictly positive).
15    pub price: f64,
16    /// Resting size / quantity at this price (non-negative).
17    pub size: f64,
18}
19
20impl Level {
21    /// Construct a level, validating that `price` is finite and strictly
22    /// positive and `size` is finite and non-negative.
23    ///
24    /// # Errors
25    ///
26    /// Returns [`Error::InvalidOrderBook`] if the price is not a finite
27    /// positive number, or the size is not a finite non-negative number.
28    pub fn new(price: f64, size: f64) -> Result<Self> {
29        if !price.is_finite() || price <= 0.0 {
30            return Err(Error::InvalidOrderBook {
31                message: "level price must be finite and positive",
32            });
33        }
34        if !size.is_finite() || size < 0.0 {
35            return Err(Error::InvalidOrderBook {
36                message: "level size must be finite and non-negative",
37            });
38        }
39        Ok(Self { price, size })
40    }
41
42    /// Construct a level without validation. The caller asserts that `price`
43    /// is finite and positive and `size` is finite and non-negative.
44    pub const fn new_unchecked(price: f64, size: f64) -> Self {
45        Self { price, size }
46    }
47}
48
49/// An order-book depth snapshot.
50///
51/// Bids are stored best-first (strictly descending price); asks are stored
52/// best-first (strictly ascending price). A valid book is non-empty on both
53/// sides and uncrossed (`best_bid < best_ask`).
54#[derive(Debug, Clone, PartialEq)]
55pub struct OrderBook {
56    /// Bid levels, best (highest price) first.
57    pub bids: Vec<Level>,
58    /// Ask levels, best (lowest price) first.
59    pub asks: Vec<Level>,
60}
61
62impl OrderBook {
63    /// Construct an order book, validating the level and ordering invariants.
64    ///
65    /// # Errors
66    ///
67    /// Returns [`Error::InvalidOrderBook`] if either side is empty, any level
68    /// has a non-finite/non-positive price or non-finite/negative size, the
69    /// bids are not strictly descending in price, the asks are not strictly
70    /// ascending in price, or the book is crossed/locked (`best_bid >=
71    /// best_ask`).
72    pub fn new(bids: Vec<Level>, asks: Vec<Level>) -> Result<Self> {
73        if bids.is_empty() || asks.is_empty() {
74            return Err(Error::InvalidOrderBook {
75                message: "order book must have at least one bid and one ask",
76            });
77        }
78        for level in bids.iter().chain(asks.iter()) {
79            if !level.price.is_finite() || level.price <= 0.0 {
80                return Err(Error::InvalidOrderBook {
81                    message: "level price must be finite and positive",
82                });
83            }
84            if !level.size.is_finite() || level.size < 0.0 {
85                return Err(Error::InvalidOrderBook {
86                    message: "level size must be finite and non-negative",
87                });
88            }
89        }
90        for pair in bids.windows(2) {
91            if pair[0].price <= pair[1].price {
92                return Err(Error::InvalidOrderBook {
93                    message: "bids must be strictly descending in price",
94                });
95            }
96        }
97        for pair in asks.windows(2) {
98            if pair[0].price >= pair[1].price {
99                return Err(Error::InvalidOrderBook {
100                    message: "asks must be strictly ascending in price",
101                });
102            }
103        }
104        if bids[0].price >= asks[0].price {
105            return Err(Error::InvalidOrderBook {
106                message: "order book must be uncrossed (best_bid < best_ask)",
107            });
108        }
109        Ok(Self { bids, asks })
110    }
111
112    /// Construct an order book without validation. The caller asserts that all
113    /// level and ordering invariants hold.
114    pub const fn new_unchecked(bids: Vec<Level>, asks: Vec<Level>) -> Self {
115        Self { bids, asks }
116    }
117
118    /// The best (highest-price) bid level, or `None` if the bid side is empty.
119    pub fn best_bid(&self) -> Option<Level> {
120        self.bids.first().copied()
121    }
122
123    /// The best (lowest-price) ask level, or `None` if the ask side is empty.
124    pub fn best_ask(&self) -> Option<Level> {
125        self.asks.first().copied()
126    }
127
128    /// The mid price `(best_bid + best_ask) / 2`, or `None` if either side is
129    /// empty.
130    pub fn mid(&self) -> Option<f64> {
131        match (self.best_bid(), self.best_ask()) {
132            (Some(bid), Some(ask)) => Some(f64::midpoint(bid.price, ask.price)),
133            _ => None,
134        }
135    }
136}
137
138/// The aggressor side of a trade: the side that crossed the spread.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum Side {
141    /// A buyer-initiated (aggressive buy) trade.
142    Buy,
143    /// A seller-initiated (aggressive sell) trade.
144    Sell,
145}
146
147impl Side {
148    /// The signed multiplier for this side: `+1.0` for a buy, `−1.0` for a
149    /// sell.
150    pub const fn sign(self) -> f64 {
151        match self {
152            Side::Buy => 1.0,
153            Side::Sell => -1.0,
154        }
155    }
156}
157
158/// A single executed trade with an aggressor side.
159#[derive(Debug, Clone, Copy, PartialEq)]
160pub struct Trade {
161    /// Execution price (strictly positive).
162    pub price: f64,
163    /// Executed size / quantity (non-negative).
164    pub size: f64,
165    /// Aggressor side.
166    pub side: Side,
167    /// Trade timestamp (caller-defined epoch / resolution).
168    pub timestamp: i64,
169}
170
171impl Trade {
172    /// Construct a trade, validating that `price` is finite and strictly
173    /// positive and `size` is finite and non-negative.
174    ///
175    /// # Errors
176    ///
177    /// Returns [`Error::InvalidTrade`] if the price is not a finite positive
178    /// number, or the size is not a finite non-negative number.
179    pub fn new(price: f64, size: f64, side: Side, timestamp: i64) -> Result<Self> {
180        if !price.is_finite() || price <= 0.0 {
181            return Err(Error::InvalidTrade {
182                message: "trade price must be finite and positive",
183            });
184        }
185        if !size.is_finite() || size < 0.0 {
186            return Err(Error::InvalidTrade {
187                message: "trade size must be finite and non-negative",
188            });
189        }
190        Ok(Self {
191            price,
192            size,
193            side,
194            timestamp,
195        })
196    }
197
198    /// Construct a trade without validation. The caller asserts that `price`
199    /// is finite and positive and `size` is finite and non-negative.
200    pub const fn new_unchecked(price: f64, size: f64, side: Side, timestamp: i64) -> Self {
201        Self {
202            price,
203            size,
204            side,
205            timestamp,
206        }
207    }
208}
209
210/// A trade paired with the mid-price prevailing at execution.
211///
212/// This is the input for spread- and price-impact measures (effective spread,
213/// realized spread, Kyle's lambda), which relate an executed trade to the
214/// quote it traded against.
215#[derive(Debug, Clone, Copy, PartialEq)]
216pub struct TradeQuote {
217    /// The executed trade.
218    pub trade: Trade,
219    /// The mid-price prevailing at execution (strictly positive).
220    pub mid: f64,
221}
222
223impl TradeQuote {
224    /// Construct a trade-quote, validating that `mid` is finite and strictly
225    /// positive. The `trade` is assumed already valid.
226    ///
227    /// # Errors
228    ///
229    /// Returns [`Error::InvalidTrade`] if `mid` is not a finite positive
230    /// number.
231    pub fn new(trade: Trade, mid: f64) -> Result<Self> {
232        if !mid.is_finite() || mid <= 0.0 {
233            return Err(Error::InvalidTrade {
234                message: "trade-quote mid must be finite and positive",
235            });
236        }
237        Ok(Self { trade, mid })
238    }
239
240    /// Construct a trade-quote without validation. The caller asserts that
241    /// `mid` is finite and positive.
242    pub const fn new_unchecked(trade: Trade, mid: f64) -> Self {
243        Self { trade, mid }
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn level_new_accepts_valid() {
253        let level = Level::new(100.5, 2.0).unwrap();
254        assert_eq!(level.price, 100.5);
255        assert_eq!(level.size, 2.0);
256    }
257
258    #[test]
259    fn level_new_accepts_zero_size() {
260        assert!(Level::new(100.0, 0.0).is_ok());
261    }
262
263    #[test]
264    fn level_new_rejects_non_finite_price() {
265        assert!(matches!(
266            Level::new(f64::NAN, 1.0),
267            Err(Error::InvalidOrderBook { .. })
268        ));
269        assert!(matches!(
270            Level::new(f64::INFINITY, 1.0),
271            Err(Error::InvalidOrderBook { .. })
272        ));
273    }
274
275    #[test]
276    fn level_new_rejects_non_positive_price() {
277        assert!(matches!(
278            Level::new(0.0, 1.0),
279            Err(Error::InvalidOrderBook { .. })
280        ));
281        assert!(matches!(
282            Level::new(-1.0, 1.0),
283            Err(Error::InvalidOrderBook { .. })
284        ));
285    }
286
287    #[test]
288    fn level_new_rejects_bad_size() {
289        assert!(matches!(
290            Level::new(100.0, -1.0),
291            Err(Error::InvalidOrderBook { .. })
292        ));
293        assert!(matches!(
294            Level::new(100.0, f64::NAN),
295            Err(Error::InvalidOrderBook { .. })
296        ));
297    }
298
299    #[test]
300    fn level_new_unchecked_preserves_fields() {
301        let level = Level::new_unchecked(-5.0, -2.0);
302        assert_eq!(level.price, -5.0);
303        assert_eq!(level.size, -2.0);
304    }
305
306    fn lvl(price: f64, size: f64) -> Level {
307        Level::new(price, size).unwrap()
308    }
309
310    #[test]
311    fn order_book_new_accepts_valid() {
312        let book = OrderBook::new(
313            vec![lvl(100.0, 2.0), lvl(99.0, 3.0)],
314            vec![lvl(101.0, 1.0), lvl(102.0, 4.0)],
315        )
316        .unwrap();
317        assert_eq!(book.best_bid(), Some(lvl(100.0, 2.0)));
318        assert_eq!(book.best_ask(), Some(lvl(101.0, 1.0)));
319        assert_eq!(book.mid(), Some(100.5));
320    }
321
322    #[test]
323    fn order_book_new_rejects_empty_side() {
324        assert!(matches!(
325            OrderBook::new(vec![], vec![lvl(101.0, 1.0)]),
326            Err(Error::InvalidOrderBook { .. })
327        ));
328        assert!(matches!(
329            OrderBook::new(vec![lvl(100.0, 1.0)], vec![]),
330            Err(Error::InvalidOrderBook { .. })
331        ));
332    }
333
334    #[test]
335    fn order_book_new_rejects_bad_level() {
336        assert!(matches!(
337            OrderBook::new(
338                vec![Level::new_unchecked(100.0, -1.0)],
339                vec![lvl(101.0, 1.0)]
340            ),
341            Err(Error::InvalidOrderBook { .. })
342        ));
343        assert!(matches!(
344            OrderBook::new(
345                vec![lvl(100.0, 1.0)],
346                vec![Level::new_unchecked(f64::NAN, 1.0)]
347            ),
348            Err(Error::InvalidOrderBook { .. })
349        ));
350    }
351
352    #[test]
353    fn order_book_new_rejects_misordered_bids() {
354        assert!(matches!(
355            OrderBook::new(vec![lvl(99.0, 1.0), lvl(100.0, 1.0)], vec![lvl(101.0, 1.0)]),
356            Err(Error::InvalidOrderBook { .. })
357        ));
358    }
359
360    #[test]
361    fn order_book_new_rejects_misordered_asks() {
362        assert!(matches!(
363            OrderBook::new(
364                vec![lvl(100.0, 1.0)],
365                vec![lvl(102.0, 1.0), lvl(101.0, 1.0)]
366            ),
367            Err(Error::InvalidOrderBook { .. })
368        ));
369    }
370
371    #[test]
372    fn order_book_new_rejects_crossed() {
373        assert!(matches!(
374            OrderBook::new(vec![lvl(101.0, 1.0)], vec![lvl(101.0, 1.0)]),
375            Err(Error::InvalidOrderBook { .. })
376        ));
377        assert!(matches!(
378            OrderBook::new(vec![lvl(102.0, 1.0)], vec![lvl(101.0, 1.0)]),
379            Err(Error::InvalidOrderBook { .. })
380        ));
381    }
382
383    #[test]
384    fn order_book_new_unchecked_allows_empty() {
385        let book = OrderBook::new_unchecked(vec![], vec![]);
386        assert_eq!(book.best_bid(), None);
387        assert_eq!(book.best_ask(), None);
388        assert_eq!(book.mid(), None);
389    }
390
391    #[test]
392    fn side_sign() {
393        assert_eq!(Side::Buy.sign(), 1.0);
394        assert_eq!(Side::Sell.sign(), -1.0);
395    }
396
397    #[test]
398    fn trade_new_accepts_valid() {
399        let trade = Trade::new(100.0, 1.5, Side::Buy, 42).unwrap();
400        assert_eq!(trade.price, 100.0);
401        assert_eq!(trade.size, 1.5);
402        assert_eq!(trade.side, Side::Buy);
403        assert_eq!(trade.timestamp, 42);
404    }
405
406    #[test]
407    fn trade_new_rejects_bad_price() {
408        assert!(matches!(
409            Trade::new(0.0, 1.0, Side::Buy, 0),
410            Err(Error::InvalidTrade { .. })
411        ));
412        assert!(matches!(
413            Trade::new(f64::NAN, 1.0, Side::Sell, 0),
414            Err(Error::InvalidTrade { .. })
415        ));
416    }
417
418    #[test]
419    fn trade_new_rejects_bad_size() {
420        assert!(matches!(
421            Trade::new(100.0, -1.0, Side::Buy, 0),
422            Err(Error::InvalidTrade { .. })
423        ));
424        assert!(matches!(
425            Trade::new(100.0, f64::INFINITY, Side::Buy, 0),
426            Err(Error::InvalidTrade { .. })
427        ));
428    }
429
430    #[test]
431    fn trade_new_unchecked_preserves_fields() {
432        let trade = Trade::new_unchecked(-1.0, -2.0, Side::Sell, 7);
433        assert_eq!(trade.price, -1.0);
434        assert_eq!(trade.size, -2.0);
435        assert_eq!(trade.side, Side::Sell);
436        assert_eq!(trade.timestamp, 7);
437    }
438
439    #[test]
440    fn trade_quote_new_accepts_valid() {
441        let trade = Trade::new(100.0, 1.0, Side::Buy, 0).unwrap();
442        let tq = TradeQuote::new(trade, 99.5).unwrap();
443        assert_eq!(tq.trade, trade);
444        assert_eq!(tq.mid, 99.5);
445    }
446
447    #[test]
448    fn trade_quote_new_rejects_bad_mid() {
449        let trade = Trade::new(100.0, 1.0, Side::Buy, 0).unwrap();
450        assert!(matches!(
451            TradeQuote::new(trade, 0.0),
452            Err(Error::InvalidTrade { .. })
453        ));
454        assert!(matches!(
455            TradeQuote::new(trade, f64::NAN),
456            Err(Error::InvalidTrade { .. })
457        ));
458    }
459
460    #[test]
461    fn trade_quote_new_unchecked_preserves_fields() {
462        let trade = Trade::new_unchecked(100.0, 1.0, Side::Buy, 0);
463        let tq = TradeQuote::new_unchecked(trade, -1.0);
464        assert_eq!(tq.mid, -1.0);
465        assert_eq!(tq.trade, trade);
466    }
467}