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
9pub mod prelude {
11 pub use crate::{QuoteTick, Tick, TickError, TickKind, TickKindParseError, TradeTick};
12}
13
14#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
16pub enum TickKind {
17 Trade,
19 Bid,
21 Ask,
23 Quote,
25 Unknown,
27 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66pub enum TickKindParseError {
67 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#[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 #[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 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 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 #[must_use]
130 pub const fn kind(&self) -> &TickKind {
131 &self.kind
132 }
133
134 #[must_use]
136 pub fn timestamp(&self) -> Option<&str> {
137 self.timestamp.as_deref()
138 }
139
140 #[must_use]
142 pub const fn price(&self) -> MarketPrice {
143 self.price
144 }
145
146 #[must_use]
148 pub const fn size(&self) -> Option<f64> {
149 self.size
150 }
151}
152
153#[derive(Clone, Debug, PartialEq)]
155pub struct TradeTick {
156 tick: Tick,
157}
158
159impl TradeTick {
160 #[must_use]
162 pub const fn new(price: MarketPrice) -> Self {
163 Self {
164 tick: Tick::new(TickKind::Trade, price),
165 }
166 }
167
168 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 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 #[must_use]
190 pub const fn tick(&self) -> &Tick {
191 &self.tick
192 }
193}
194
195#[derive(Clone, Debug, PartialEq)]
197pub struct QuoteTick {
198 timestamp: Option<String>,
199 bid: Option<MarketPrice>,
200 ask: Option<MarketPrice>,
201}
202
203impl QuoteTick {
204 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 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 #[must_use]
236 pub fn timestamp(&self) -> Option<&str> {
237 self.timestamp.as_deref()
238 }
239
240 #[must_use]
242 pub const fn bid(&self) -> Option<MarketPrice> {
243 self.bid
244 }
245
246 #[must_use]
248 pub const fn ask(&self) -> Option<MarketPrice> {
249 self.ask
250 }
251
252 #[must_use]
254 pub fn spread(&self) -> Option<f64> {
255 Some(self.ask?.value() - self.bid?.value())
256 }
257}
258
259#[derive(Clone, Copy, Debug, Eq, PartialEq)]
261pub enum TickError {
262 EmptyTimestamp,
264 NonFiniteSize,
266 NegativeSize,
268 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}