use crate::core::errors::{Result, RustyQLibError};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Quote {
Mid(f64),
TopOfBook { bid: f64, ask: f64 },
Sized { bid: f64, bid_size: f64, ask: f64, ask_size: f64 },
}
fn require_uncrossed(bid: f64, ask: f64) -> Result<()> {
if !bid.is_finite() || !ask.is_finite() {
return Err(RustyQLibError::invalid_input(
"quote",
format!("bid/ask must be finite, got bid={bid}, ask={ask}"),
));
}
if bid > ask {
return Err(RustyQLibError::invalid_input(
"quote",
format!("crossed quote: bid {bid} > ask {ask}"),
));
}
Ok(())
}
impl Quote {
pub fn new(mid: f64) -> Self {
Quote::Mid(mid)
}
pub fn from_bid_ask(bid: f64, ask: f64) -> Result<Self> {
require_uncrossed(bid, ask)?;
Ok(Quote::TopOfBook { bid, ask })
}
pub fn from_bid_ask_sized(bid: f64, bid_size: f64, ask: f64, ask_size: f64) -> Result<Self> {
require_uncrossed(bid, ask)?;
if !(bid_size.is_finite() && ask_size.is_finite() && bid_size > 0.0 && ask_size > 0.0) {
return Err(RustyQLibError::invalid_input(
"quote",
format!("sizes must be finite and positive, got bid_size={bid_size}, ask_size={ask_size}"),
));
}
Ok(Quote::Sized { bid, bid_size, ask, ask_size })
}
pub fn mid(&self) -> f64 {
match *self {
Quote::Mid(value) => value,
Quote::TopOfBook { bid, ask } | Quote::Sized { bid, ask, .. } => 0.5 * (bid + ask),
}
}
pub fn value(&self) -> f64 {
self.mid()
}
pub fn bid(&self) -> Option<f64> {
match *self {
Quote::Mid(_) => None,
Quote::TopOfBook { bid, .. } | Quote::Sized { bid, .. } => Some(bid),
}
}
pub fn ask(&self) -> Option<f64> {
match *self {
Quote::Mid(_) => None,
Quote::TopOfBook { ask, .. } | Quote::Sized { ask, .. } => Some(ask),
}
}
pub fn spread(&self) -> Option<f64> {
match *self {
Quote::Mid(_) => None,
Quote::TopOfBook { bid, ask } | Quote::Sized { bid, ask, .. } => Some(ask - bid),
}
}
pub fn microprice(&self) -> f64 {
match *self {
Quote::Sized { bid, bid_size, ask, ask_size } => {
(bid * ask_size + ask * bid_size) / (bid_size + ask_size)
}
_ => self.mid(),
}
}
pub fn valid_value(&self) -> bool {
self.mid() > 0.0
}
pub fn scaled(&self, factor: f64) -> Quote {
match *self {
Quote::Mid(value) => Quote::Mid(value * factor),
Quote::TopOfBook { bid, ask } => {
Quote::TopOfBook { bid: bid * factor, ask: ask * factor }
}
Quote::Sized { bid, bid_size, ask, ask_size } => {
Quote::Sized { bid: bid * factor, bid_size, ask: ask * factor, ask_size }
}
}
}
pub fn shifted(&self, delta: f64) -> Quote {
match *self {
Quote::Mid(value) => Quote::Mid(value + delta),
Quote::TopOfBook { bid, ask } => {
Quote::TopOfBook { bid: bid + delta, ask: ask + delta }
}
Quote::Sized { bid, bid_size, ask, ask_size } => {
Quote::Sized { bid: bid + delta, bid_size, ask: ask + delta, ask_size }
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mid_is_derived_never_stored() {
assert_eq!(Quote::new(100.0).mid(), 100.0);
let top = Quote::from_bid_ask(99.0, 101.0).unwrap();
assert_eq!(top.mid(), 100.0);
assert_eq!(top.spread(), Some(2.0));
assert_eq!(top.bid(), Some(99.0));
assert_eq!(top.ask(), Some(101.0));
assert_eq!(top.value(), top.mid());
let mark = Quote::new(100.0);
assert_eq!(mark.bid(), None);
assert_eq!(mark.spread(), None);
}
#[test]
fn constructors_reject_crossed_and_non_finite_input() {
assert!(Quote::from_bid_ask(101.0, 99.0).is_err(), "crossed");
assert!(Quote::from_bid_ask(f64::NAN, 100.0).is_err());
assert!(Quote::from_bid_ask(99.0, f64::INFINITY).is_err());
assert!(Quote::from_bid_ask(100.0, 100.0).is_ok());
assert!(Quote::from_bid_ask_sized(99.0, 0.0, 101.0, 5.0).is_err(), "zero size");
assert!(Quote::from_bid_ask_sized(99.0, 5.0, 101.0, -1.0).is_err());
}
#[test]
fn microprice_weights_toward_the_thin_side() {
let quote = Quote::from_bid_ask_sized(99.0, 400.0, 101.0, 100.0).unwrap();
let micro = quote.microprice();
assert!((micro - (99.0 * 100.0 + 101.0 * 400.0) / 500.0).abs() < 1e-12);
assert!(micro > quote.mid(), "heavy bid pushes fair value up");
assert_eq!(Quote::from_bid_ask(99.0, 101.0).unwrap().microprice(), 100.0);
assert_eq!(Quote::new(100.0).microprice(), 100.0);
}
#[test]
fn bumps_preserve_shape_spread_and_sizes() {
let quote = Quote::from_bid_ask_sized(99.0, 400.0, 101.0, 100.0).unwrap();
let scaled = quote.scaled(0.8);
assert!((scaled.mid() - 80.0).abs() < 1e-12);
assert!((scaled.spread().unwrap() - 1.6).abs() < 1e-12, "relative bump scales the spread");
let shifted = quote.shifted(-20.0);
assert!((shifted.mid() - 80.0).abs() < 1e-12);
assert!((shifted.spread().unwrap() - 2.0).abs() < 1e-12, "absolute bump preserves the spread");
match (scaled, shifted) {
(
Quote::Sized { bid_size: s1, ask_size: a1, .. },
Quote::Sized { bid_size: s2, ask_size: a2, .. },
) => {
assert_eq!((s1, a1), (400.0, 100.0));
assert_eq!((s2, a2), (400.0, 100.0));
}
other => panic!("bump must preserve the quote shape, got {other:?}"),
}
assert!((Quote::new(100.0).scaled(1.1).mid() - 110.0).abs() < 1e-12);
assert!((Quote::new(100.0).shifted(-1.0).mid() - 99.0).abs() < 1e-12);
}
}