Skip to main content

rustyqlib/core/
quotes.rs

1//! Price observations: the scalar market observable pricing consumes.
2//!
3//! A [`Quote`] is what the pricing layer reads — a mark. It is
4//! deliberately **not** an order book: trading systems keep the book
5//! (ladders, order-by-order churn) in the execution layer and hand
6//! pricing a derived observable. The book-shaped sibling is
7//! [`MarketDepth`](crate::core::depth::MarketDepth), which lives under
8//! its own market key and *produces* a `Quote` at the snapshot boundary.
9//!
10//! Derived values (`mid`, `spread`, `microprice`) are **functions, never
11//! fields** — a stored mid can disagree with the bid/ask it came from;
12//! a computed one cannot. Representation is private by construction
13//! (enum variants are matched, not assigned), so richer quote shapes can
14//! be added without touching pricing call sites: engines only call
15//! [`mid`](Quote::mid).
16
17use crate::core::errors::{Result, RustyQLibError};
18
19/// One price observation for one instrument, in increasing order of
20/// structure. Pricing reads [`mid`](Quote::mid) regardless of variant;
21/// the richer variants exist so marking policies (bid/ask-side marks,
22/// microprice) have honest inputs when a trading system supplies them.
23#[derive(Debug, Clone, Copy, PartialEq)]
24pub enum Quote {
25    /// A single price with no book behind it: a mark, settle, close or
26    /// model price.
27    Mid(f64),
28    /// Best bid and offer. `mid` and `spread` are derived on demand.
29    TopOfBook { bid: f64, ask: f64 },
30    /// Best bid and offer with displayed sizes; enables the size-weighted
31    /// [`microprice`](Quote::microprice).
32    Sized { bid: f64, bid_size: f64, ask: f64, ask_size: f64 },
33}
34
35fn require_uncrossed(bid: f64, ask: f64) -> Result<()> {
36    if !bid.is_finite() || !ask.is_finite() {
37        return Err(RustyQLibError::invalid_input(
38            "quote",
39            format!("bid/ask must be finite, got bid={bid}, ask={ask}"),
40        ));
41    }
42    if bid > ask {
43        return Err(RustyQLibError::invalid_input(
44            "quote",
45            format!("crossed quote: bid {bid} > ask {ask}"),
46        ));
47    }
48    Ok(())
49}
50
51impl Quote {
52    /// A bare mid — the compatibility constructor (marks, settles, model
53    /// prices, placeholders). Performs no validation; use
54    /// [`from_bid_ask`](Self::from_bid_ask) for feed data.
55    pub fn new(mid: f64) -> Self {
56        Quote::Mid(mid)
57    }
58
59    /// Top of book. Rejects non-finite and crossed (`bid > ask`) input —
60    /// crossed books are a feed problem to resolve upstream, not a state
61    /// to price off.
62    pub fn from_bid_ask(bid: f64, ask: f64) -> Result<Self> {
63        require_uncrossed(bid, ask)?;
64        Ok(Quote::TopOfBook { bid, ask })
65    }
66
67    /// Top of book with displayed sizes (both strictly positive and
68    /// finite), enabling [`microprice`](Self::microprice).
69    pub fn from_bid_ask_sized(bid: f64, bid_size: f64, ask: f64, ask_size: f64) -> Result<Self> {
70        require_uncrossed(bid, ask)?;
71        if !(bid_size.is_finite() && ask_size.is_finite() && bid_size > 0.0 && ask_size > 0.0) {
72            return Err(RustyQLibError::invalid_input(
73                "quote",
74                format!("sizes must be finite and positive, got bid_size={bid_size}, ask_size={ask_size}"),
75            ));
76        }
77        Ok(Quote::Sized { bid, bid_size, ask, ask_size })
78    }
79
80    /// The mark pricing uses: the price itself for [`Mid`](Quote::Mid),
81    /// the arithmetic bid/ask midpoint otherwise.
82    pub fn mid(&self) -> f64 {
83        match *self {
84            Quote::Mid(value) => value,
85            Quote::TopOfBook { bid, ask } | Quote::Sized { bid, ask, .. } => 0.5 * (bid + ask),
86        }
87    }
88
89    /// Alias for [`mid`](Self::mid), kept for source compatibility with
90    /// the field-based `Quote`.
91    pub fn value(&self) -> f64 {
92        self.mid()
93    }
94
95    /// Best bid, when a book side was observed.
96    pub fn bid(&self) -> Option<f64> {
97        match *self {
98            Quote::Mid(_) => None,
99            Quote::TopOfBook { bid, .. } | Quote::Sized { bid, .. } => Some(bid),
100        }
101    }
102
103    /// Best ask, when a book side was observed.
104    pub fn ask(&self) -> Option<f64> {
105        match *self {
106            Quote::Mid(_) => None,
107            Quote::TopOfBook { ask, .. } | Quote::Sized { ask, .. } => Some(ask),
108        }
109    }
110
111    /// `ask - bid`, when both sides were observed.
112    pub fn spread(&self) -> Option<f64> {
113        match *self {
114            Quote::Mid(_) => None,
115            Quote::TopOfBook { bid, ask } | Quote::Sized { bid, ask, .. } => Some(ask - bid),
116        }
117    }
118
119    /// The size-weighted mid `(bid * ask_size + ask * bid_size) /
120    /// (bid_size + ask_size)` — the standard short-horizon fair-value
121    /// estimator (leans toward the side with less displayed size). Falls
122    /// back to [`mid`](Self::mid) when sizes are not available.
123    pub fn microprice(&self) -> f64 {
124        match *self {
125            Quote::Sized { bid, bid_size, ask, ask_size } => {
126                (bid * ask_size + ask * bid_size) / (bid_size + ask_size)
127            }
128            _ => self.mid(),
129        }
130    }
131
132    /// Whether the mark is a usable positive price.
133    pub fn valid_value(&self) -> bool {
134        self.mid() > 0.0
135    }
136
137    /// Every price level scaled by `factor` (a relative bump: spread and
138    /// levels scale together, sizes are untouched). The quote shape is
139    /// preserved — bumping a scenario must not silently discard book
140    /// information.
141    pub fn scaled(&self, factor: f64) -> Quote {
142        match *self {
143            Quote::Mid(value) => Quote::Mid(value * factor),
144            Quote::TopOfBook { bid, ask } => {
145                Quote::TopOfBook { bid: bid * factor, ask: ask * factor }
146            }
147            Quote::Sized { bid, bid_size, ask, ask_size } => {
148                Quote::Sized { bid: bid * factor, bid_size, ask: ask * factor, ask_size }
149            }
150        }
151    }
152
153    /// Every price level shifted by `delta` (an absolute bump: the spread
154    /// is preserved exactly, sizes are untouched).
155    pub fn shifted(&self, delta: f64) -> Quote {
156        match *self {
157            Quote::Mid(value) => Quote::Mid(value + delta),
158            Quote::TopOfBook { bid, ask } => {
159                Quote::TopOfBook { bid: bid + delta, ask: ask + delta }
160            }
161            Quote::Sized { bid, bid_size, ask, ask_size } => {
162                Quote::Sized { bid: bid + delta, bid_size, ask: ask + delta, ask_size }
163            }
164        }
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn mid_is_derived_never_stored() {
174        assert_eq!(Quote::new(100.0).mid(), 100.0);
175        let top = Quote::from_bid_ask(99.0, 101.0).unwrap();
176        assert_eq!(top.mid(), 100.0);
177        assert_eq!(top.spread(), Some(2.0));
178        assert_eq!(top.bid(), Some(99.0));
179        assert_eq!(top.ask(), Some(101.0));
180        // the compatibility alias agrees
181        assert_eq!(top.value(), top.mid());
182        // a bare mid has no book
183        let mark = Quote::new(100.0);
184        assert_eq!(mark.bid(), None);
185        assert_eq!(mark.spread(), None);
186    }
187
188    #[test]
189    fn constructors_reject_crossed_and_non_finite_input() {
190        assert!(Quote::from_bid_ask(101.0, 99.0).is_err(), "crossed");
191        assert!(Quote::from_bid_ask(f64::NAN, 100.0).is_err());
192        assert!(Quote::from_bid_ask(99.0, f64::INFINITY).is_err());
193        // touched (bid == ask) is legal: a locked book still has a mid
194        assert!(Quote::from_bid_ask(100.0, 100.0).is_ok());
195        assert!(Quote::from_bid_ask_sized(99.0, 0.0, 101.0, 5.0).is_err(), "zero size");
196        assert!(Quote::from_bid_ask_sized(99.0, 5.0, 101.0, -1.0).is_err());
197    }
198
199    #[test]
200    fn microprice_weights_toward_the_thin_side() {
201        // 4x the size on the bid: fair value leans toward the ask
202        let quote = Quote::from_bid_ask_sized(99.0, 400.0, 101.0, 100.0).unwrap();
203        let micro = quote.microprice();
204        assert!((micro - (99.0 * 100.0 + 101.0 * 400.0) / 500.0).abs() < 1e-12);
205        assert!(micro > quote.mid(), "heavy bid pushes fair value up");
206        // without sizes it degrades to the mid
207        assert_eq!(Quote::from_bid_ask(99.0, 101.0).unwrap().microprice(), 100.0);
208        assert_eq!(Quote::new(100.0).microprice(), 100.0);
209    }
210
211    #[test]
212    fn bumps_preserve_shape_spread_and_sizes() {
213        let quote = Quote::from_bid_ask_sized(99.0, 400.0, 101.0, 100.0).unwrap();
214        let scaled = quote.scaled(0.8);
215        assert!((scaled.mid() - 80.0).abs() < 1e-12);
216        assert!((scaled.spread().unwrap() - 1.6).abs() < 1e-12, "relative bump scales the spread");
217        let shifted = quote.shifted(-20.0);
218        assert!((shifted.mid() - 80.0).abs() < 1e-12);
219        assert!((shifted.spread().unwrap() - 2.0).abs() < 1e-12, "absolute bump preserves the spread");
220        // sizes ride through both, and the variant is unchanged
221        match (scaled, shifted) {
222            (
223                Quote::Sized { bid_size: s1, ask_size: a1, .. },
224                Quote::Sized { bid_size: s2, ask_size: a2, .. },
225            ) => {
226                assert_eq!((s1, a1), (400.0, 100.0));
227                assert_eq!((s2, a2), (400.0, 100.0));
228            }
229            other => panic!("bump must preserve the quote shape, got {other:?}"),
230        }
231        // a bare mid bumps as a scalar
232        assert!((Quote::new(100.0).scaled(1.1).mid() - 110.0).abs() < 1e-12);
233        assert!((Quote::new(100.0).shifted(-1.0).mid() - 99.0).abs() < 1e-12);
234    }
235}