#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::error::Error;
use use_market_price::MarketPrice;
pub mod prelude {
pub use crate::{QuoteTick, Tick, TickError, TickKind, TickKindParseError, TradeTick};
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum TickKind {
Trade,
Bid,
Ask,
Quote,
Unknown,
Custom(String),
}
impl fmt::Display for TickKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Trade => "trade",
Self::Bid => "bid",
Self::Ask => "ask",
Self::Quote => "quote",
Self::Unknown => "unknown",
Self::Custom(value) => value.as_str(),
})
}
}
impl FromStr for TickKind {
type Err = TickKindParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(TickKindParseError::Empty);
}
match normalized_token(trimmed).as_str() {
"trade" => Ok(Self::Trade),
"bid" => Ok(Self::Bid),
"ask" => Ok(Self::Ask),
"quote" => Ok(Self::Quote),
"unknown" => Ok(Self::Unknown),
_ => Ok(Self::Custom(trimmed.to_string())),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TickKindParseError {
Empty,
}
impl fmt::Display for TickKindParseError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("tick kind cannot be empty"),
}
}
}
impl Error for TickKindParseError {}
#[derive(Clone, Debug, PartialEq)]
pub struct Tick {
kind: TickKind,
timestamp: Option<String>,
price: MarketPrice,
size: Option<f64>,
}
impl Tick {
#[must_use]
pub const fn new(kind: TickKind, price: MarketPrice) -> Self {
Self {
kind,
timestamp: None,
price,
size: None,
}
}
pub fn with_timestamp(mut self, timestamp: impl AsRef<str>) -> Result<Self, TickError> {
let trimmed = timestamp.as_ref().trim();
if trimmed.is_empty() {
return Err(TickError::EmptyTimestamp);
}
self.timestamp = Some(trimmed.to_string());
Ok(self)
}
pub fn with_size(mut self, size: f64) -> Result<Self, TickError> {
validate_size(size)?;
self.size = Some(size);
Ok(self)
}
#[must_use]
pub const fn kind(&self) -> &TickKind {
&self.kind
}
#[must_use]
pub fn timestamp(&self) -> Option<&str> {
self.timestamp.as_deref()
}
#[must_use]
pub const fn price(&self) -> MarketPrice {
self.price
}
#[must_use]
pub const fn size(&self) -> Option<f64> {
self.size
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct TradeTick {
tick: Tick,
}
impl TradeTick {
#[must_use]
pub const fn new(price: MarketPrice) -> Self {
Self {
tick: Tick::new(TickKind::Trade, price),
}
}
pub fn with_timestamp(mut self, timestamp: impl AsRef<str>) -> Result<Self, TickError> {
self.tick = self.tick.with_timestamp(timestamp)?;
Ok(self)
}
pub fn with_size(mut self, size: f64) -> Result<Self, TickError> {
self.tick = self.tick.with_size(size)?;
Ok(self)
}
#[must_use]
pub const fn tick(&self) -> &Tick {
&self.tick
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct QuoteTick {
timestamp: Option<String>,
bid: Option<MarketPrice>,
ask: Option<MarketPrice>,
}
impl QuoteTick {
pub fn new(bid: Option<MarketPrice>, ask: Option<MarketPrice>) -> Result<Self, TickError> {
validate_quote(bid, ask)?;
Ok(Self {
timestamp: None,
bid,
ask,
})
}
pub fn with_timestamp(mut self, timestamp: impl AsRef<str>) -> Result<Self, TickError> {
let trimmed = timestamp.as_ref().trim();
if trimmed.is_empty() {
return Err(TickError::EmptyTimestamp);
}
self.timestamp = Some(trimmed.to_string());
Ok(self)
}
#[must_use]
pub fn timestamp(&self) -> Option<&str> {
self.timestamp.as_deref()
}
#[must_use]
pub const fn bid(&self) -> Option<MarketPrice> {
self.bid
}
#[must_use]
pub const fn ask(&self) -> Option<MarketPrice> {
self.ask
}
#[must_use]
pub fn spread(&self) -> Option<f64> {
Some(self.ask?.value() - self.bid?.value())
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TickError {
EmptyTimestamp,
NonFiniteSize,
NegativeSize,
CrossedQuote,
}
impl fmt::Display for TickError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyTimestamp => formatter.write_str("tick timestamp cannot be empty"),
Self::NonFiniteSize => formatter.write_str("tick size must be finite"),
Self::NegativeSize => formatter.write_str("tick size cannot be negative"),
Self::CrossedQuote => {
formatter.write_str("quote ask must be greater than or equal to bid")
},
}
}
}
impl Error for TickError {}
fn validate_size(size: f64) -> Result<(), TickError> {
if !size.is_finite() {
return Err(TickError::NonFiniteSize);
}
if size < 0.0 {
return Err(TickError::NegativeSize);
}
Ok(())
}
fn validate_quote(bid: Option<MarketPrice>, ask: Option<MarketPrice>) -> Result<(), TickError> {
if let (Some(bid), Some(ask)) = (bid, ask)
&& ask.value() < bid.value()
{
return Err(TickError::CrossedQuote);
}
Ok(())
}
fn normalized_token(value: &str) -> String {
value
.trim()
.chars()
.map(|character| match character {
'_' | ' ' => '-',
other => other.to_ascii_lowercase(),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::{QuoteTick, TickError, TickKind, TradeTick};
use use_market_price::MarketPrice;
#[test]
fn constructs_valid_trade_tick() {
let tick = TradeTick::new(MarketPrice::new(101.25).expect("price should be valid"))
.with_timestamp("2026-05-17T10:00:00Z")
.expect("timestamp should be valid")
.with_size(100.0)
.expect("size should be valid");
assert!((tick.tick().price().value() - 101.25).abs() < f64::EPSILON);
assert_eq!(tick.tick().size(), Some(100.0));
}
#[test]
fn constructs_valid_quote_tick() {
let quote = QuoteTick::new(
Some(MarketPrice::new(101.20).expect("price should be valid")),
Some(MarketPrice::new(101.30).expect("price should be valid")),
)
.expect("quote should be valid");
assert!((quote.spread().expect("spread should exist") - 0.10).abs() < 1.0e-12);
}
#[test]
fn rejects_crossed_quote() {
assert_eq!(
QuoteTick::new(
Some(MarketPrice::new(101.30).expect("price should be valid")),
Some(MarketPrice::new(101.20).expect("price should be valid")),
),
Err(TickError::CrossedQuote)
);
}
#[test]
fn displays_and_parses_tick_kind() {
let kind: TickKind = "trade".parse().expect("kind should parse");
assert_eq!(kind, TickKind::Trade);
assert_eq!(kind.to_string(), "trade");
}
#[test]
fn supports_custom_tick_kind() {
let kind: TickKind = "auction".parse().expect("kind should parse");
assert_eq!(kind, TickKind::Custom("auction".to_string()));
}
}