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}