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