Skip to main content

use_tick/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7use use_market_price::MarketPrice;
8
9/// Common tick primitives.
10pub mod prelude {
11    pub use crate::{QuoteTick, Tick, TickError, TickKind, TickKindParseError, TradeTick};
12}
13
14/// Descriptive tick kind vocabulary.
15#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
16pub enum TickKind {
17    /// Trade tick.
18    Trade,
19    /// Bid tick.
20    Bid,
21    /// Ask tick.
22    Ask,
23    /// Quote tick.
24    Quote,
25    /// Unknown tick kind.
26    Unknown,
27    /// Caller-defined tick kind.
28    Custom(String),
29}
30
31impl fmt::Display for TickKind {
32    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
33        formatter.write_str(match self {
34            Self::Trade => "trade",
35            Self::Bid => "bid",
36            Self::Ask => "ask",
37            Self::Quote => "quote",
38            Self::Unknown => "unknown",
39            Self::Custom(value) => value.as_str(),
40        })
41    }
42}
43
44impl FromStr for TickKind {
45    type Err = TickKindParseError;
46
47    fn from_str(value: &str) -> Result<Self, Self::Err> {
48        let trimmed = value.trim();
49        if trimmed.is_empty() {
50            return Err(TickKindParseError::Empty);
51        }
52
53        match normalized_token(trimmed).as_str() {
54            "trade" => Ok(Self::Trade),
55            "bid" => Ok(Self::Bid),
56            "ask" => Ok(Self::Ask),
57            "quote" => Ok(Self::Quote),
58            "unknown" => Ok(Self::Unknown),
59            _ => Ok(Self::Custom(trimmed.to_string())),
60        }
61    }
62}
63
64/// Errors returned while parsing tick kinds.
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66pub enum TickKindParseError {
67    /// The input was empty after trimming whitespace.
68    Empty,
69}
70
71impl fmt::Display for TickKindParseError {
72    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            Self::Empty => formatter.write_str("tick kind cannot be empty"),
75        }
76    }
77}
78
79impl Error for TickKindParseError {}
80
81/// A single price tick with optional timestamp label and size.
82#[derive(Clone, Debug, PartialEq)]
83pub struct Tick {
84    kind: TickKind,
85    timestamp: Option<String>,
86    price: MarketPrice,
87    size: Option<f64>,
88}
89
90impl Tick {
91    /// Creates a tick from a kind and price.
92    #[must_use]
93    pub const fn new(kind: TickKind, price: MarketPrice) -> Self {
94        Self {
95            kind,
96            timestamp: None,
97            price,
98            size: None,
99        }
100    }
101
102    /// Attaches a non-empty timestamp label.
103    ///
104    /// # Errors
105    ///
106    /// Returns [`TickError::EmptyTimestamp`] when the trimmed label is empty.
107    pub fn with_timestamp(mut self, timestamp: impl AsRef<str>) -> Result<Self, TickError> {
108        let trimmed = timestamp.as_ref().trim();
109        if trimmed.is_empty() {
110            return Err(TickError::EmptyTimestamp);
111        }
112
113        self.timestamp = Some(trimmed.to_string());
114        Ok(self)
115    }
116
117    /// Attaches a finite non-negative size.
118    ///
119    /// # Errors
120    ///
121    /// Returns [`TickError::NonFiniteSize`] or [`TickError::NegativeSize`] when `size` is invalid.
122    pub fn with_size(mut self, size: f64) -> Result<Self, TickError> {
123        validate_size(size)?;
124        self.size = Some(size);
125        Ok(self)
126    }
127
128    /// Returns the tick kind.
129    #[must_use]
130    pub const fn kind(&self) -> &TickKind {
131        &self.kind
132    }
133
134    /// Returns the optional timestamp label.
135    #[must_use]
136    pub fn timestamp(&self) -> Option<&str> {
137        self.timestamp.as_deref()
138    }
139
140    /// Returns the tick price.
141    #[must_use]
142    pub const fn price(&self) -> MarketPrice {
143        self.price
144    }
145
146    /// Returns the optional size.
147    #[must_use]
148    pub const fn size(&self) -> Option<f64> {
149        self.size
150    }
151}
152
153/// A trade tick wrapper.
154#[derive(Clone, Debug, PartialEq)]
155pub struct TradeTick {
156    tick: Tick,
157}
158
159impl TradeTick {
160    /// Creates a trade tick.
161    #[must_use]
162    pub const fn new(price: MarketPrice) -> Self {
163        Self {
164            tick: Tick::new(TickKind::Trade, price),
165        }
166    }
167
168    /// Attaches a timestamp label.
169    ///
170    /// # Errors
171    ///
172    /// Returns [`TickError::EmptyTimestamp`] when the trimmed label is empty.
173    pub fn with_timestamp(mut self, timestamp: impl AsRef<str>) -> Result<Self, TickError> {
174        self.tick = self.tick.with_timestamp(timestamp)?;
175        Ok(self)
176    }
177
178    /// Attaches a finite non-negative size.
179    ///
180    /// # Errors
181    ///
182    /// Returns [`TickError::NonFiniteSize`] or [`TickError::NegativeSize`] when `size` is invalid.
183    pub fn with_size(mut self, size: f64) -> Result<Self, TickError> {
184        self.tick = self.tick.with_size(size)?;
185        Ok(self)
186    }
187
188    /// Returns the underlying tick.
189    #[must_use]
190    pub const fn tick(&self) -> &Tick {
191        &self.tick
192    }
193}
194
195/// A quote tick with optional bid and ask prices.
196#[derive(Clone, Debug, PartialEq)]
197pub struct QuoteTick {
198    timestamp: Option<String>,
199    bid: Option<MarketPrice>,
200    ask: Option<MarketPrice>,
201}
202
203impl QuoteTick {
204    /// Creates a quote tick and rejects crossed bid/ask values when both sides are present.
205    ///
206    /// # Errors
207    ///
208    /// Returns [`TickError::CrossedQuote`] when `ask < bid`.
209    pub fn new(bid: Option<MarketPrice>, ask: Option<MarketPrice>) -> Result<Self, TickError> {
210        validate_quote(bid, ask)?;
211
212        Ok(Self {
213            timestamp: None,
214            bid,
215            ask,
216        })
217    }
218
219    /// Attaches a non-empty timestamp label.
220    ///
221    /// # Errors
222    ///
223    /// Returns [`TickError::EmptyTimestamp`] when the trimmed label is empty.
224    pub fn with_timestamp(mut self, timestamp: impl AsRef<str>) -> Result<Self, TickError> {
225        let trimmed = timestamp.as_ref().trim();
226        if trimmed.is_empty() {
227            return Err(TickError::EmptyTimestamp);
228        }
229
230        self.timestamp = Some(trimmed.to_string());
231        Ok(self)
232    }
233
234    /// Returns the optional timestamp label.
235    #[must_use]
236    pub fn timestamp(&self) -> Option<&str> {
237        self.timestamp.as_deref()
238    }
239
240    /// Returns the optional bid price.
241    #[must_use]
242    pub const fn bid(&self) -> Option<MarketPrice> {
243        self.bid
244    }
245
246    /// Returns the optional ask price.
247    #[must_use]
248    pub const fn ask(&self) -> Option<MarketPrice> {
249        self.ask
250    }
251
252    /// Returns the ask-bid spread when both sides are present.
253    #[must_use]
254    pub fn spread(&self) -> Option<f64> {
255        Some(self.ask?.value() - self.bid?.value())
256    }
257}
258
259/// Errors returned by tick construction.
260#[derive(Clone, Copy, Debug, Eq, PartialEq)]
261pub enum TickError {
262    /// Timestamp labels must be non-empty after trimming whitespace.
263    EmptyTimestamp,
264    /// Size values must be finite.
265    NonFiniteSize,
266    /// Size values must not be negative.
267    NegativeSize,
268    /// Quote ask must be greater than or equal to bid.
269    CrossedQuote,
270}
271
272impl fmt::Display for TickError {
273    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
274        match self {
275            Self::EmptyTimestamp => formatter.write_str("tick timestamp cannot be empty"),
276            Self::NonFiniteSize => formatter.write_str("tick size must be finite"),
277            Self::NegativeSize => formatter.write_str("tick size cannot be negative"),
278            Self::CrossedQuote => {
279                formatter.write_str("quote ask must be greater than or equal to bid")
280            },
281        }
282    }
283}
284
285impl Error for TickError {}
286
287fn validate_size(size: f64) -> Result<(), TickError> {
288    if !size.is_finite() {
289        return Err(TickError::NonFiniteSize);
290    }
291
292    if size < 0.0 {
293        return Err(TickError::NegativeSize);
294    }
295
296    Ok(())
297}
298
299fn validate_quote(bid: Option<MarketPrice>, ask: Option<MarketPrice>) -> Result<(), TickError> {
300    if let (Some(bid), Some(ask)) = (bid, ask)
301        && ask.value() < bid.value()
302    {
303        return Err(TickError::CrossedQuote);
304    }
305
306    Ok(())
307}
308
309fn normalized_token(value: &str) -> String {
310    value
311        .trim()
312        .chars()
313        .map(|character| match character {
314            '_' | ' ' => '-',
315            other => other.to_ascii_lowercase(),
316        })
317        .collect()
318}
319
320#[cfg(test)]
321mod tests {
322    use super::{QuoteTick, TickError, TickKind, TradeTick};
323    use use_market_price::MarketPrice;
324
325    #[test]
326    fn constructs_valid_trade_tick() {
327        let tick = TradeTick::new(MarketPrice::new(101.25).expect("price should be valid"))
328            .with_timestamp("2026-05-17T10:00:00Z")
329            .expect("timestamp should be valid")
330            .with_size(100.0)
331            .expect("size should be valid");
332
333        assert!((tick.tick().price().value() - 101.25).abs() < f64::EPSILON);
334        assert_eq!(tick.tick().size(), Some(100.0));
335    }
336
337    #[test]
338    fn constructs_valid_quote_tick() {
339        let quote = QuoteTick::new(
340            Some(MarketPrice::new(101.20).expect("price should be valid")),
341            Some(MarketPrice::new(101.30).expect("price should be valid")),
342        )
343        .expect("quote should be valid");
344
345        assert!((quote.spread().expect("spread should exist") - 0.10).abs() < 1.0e-12);
346    }
347
348    #[test]
349    fn rejects_crossed_quote() {
350        assert_eq!(
351            QuoteTick::new(
352                Some(MarketPrice::new(101.30).expect("price should be valid")),
353                Some(MarketPrice::new(101.20).expect("price should be valid")),
354            ),
355            Err(TickError::CrossedQuote)
356        );
357    }
358
359    #[test]
360    fn displays_and_parses_tick_kind() {
361        let kind: TickKind = "trade".parse().expect("kind should parse");
362
363        assert_eq!(kind, TickKind::Trade);
364        assert_eq!(kind.to_string(), "trade");
365    }
366
367    #[test]
368    fn supports_custom_tick_kind() {
369        let kind: TickKind = "auction".parse().expect("kind should parse");
370
371        assert_eq!(kind, TickKind::Custom("auction".to_string()));
372    }
373}