Skip to main content

egui_charts/model/
quote.rs

1//! Market quotes and order-book data structures.
2//!
3//! Market microstructure data at three granularity levels:
4//!
5//! | Level | Type | Data |
6//! |-------|------|------|
7//! | Tick | [`Tick`] | Individual trades (time & sales) |
8//! | L1 | [`Level1Quote`] | Best bid/ask (top-of-book) |
9//! | L2 | [`OrderBook`] | Full depth-of-market order book |
10//!
11//! A feed pushes trades, the order book and Level 1 quotes into the chart through
12//! [`Sink`](crate::data::Sink). A [`Quote`] summarises a symbol for watchlists, the
13//! symbol's panels and the order ticket; the host streams it through the
14//! [`Quotes`](crate::services::Quotes) service.
15
16use crate::model::OrderSide;
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19
20/// A single trade execution (time & sales entry).
21///
22/// Each tick represents one matched trade on the exchange, recording the
23/// price, size, and aggressor side. Ticks are the finest-grained market
24/// data and can be aggregated into bars, footprint charts, or volume
25/// profiles. Build it with [`Tick::new`]. A trade names no symbol: the
26/// [`Sink`](crate::data::Sink) it is pushed into stands for one instrument, so a
27/// trade holds no heap data.
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29#[non_exhaustive]
30pub struct Tick {
31    /// Ts of the trade
32    pub ts: DateTime<Utc>,
33    /// Trade price
34    pub price: f64,
35    /// Trade size/volume
36    pub size: f64,
37    /// Aggressor side, `None` when the feed does not classify the trade.
38    pub side: Option<OrderSide>,
39    /// Exchange trade id or sequence number, unique per symbol and never reused: a number that
40    /// restarts each session, such as a daily sequence number, is not an id unless combined with
41    /// the session date. With it, history and live trades join without losing or repeating a
42    /// trade, and a live trade whose id was already taken is dropped.
43    #[serde(default)]
44    pub id: Option<u64>,
45    /// Sale conditions the exchange reported, so studies and overlays can leave out prints such
46    /// as off-exchange or auction trades.
47    #[serde(default)]
48    pub conditions: TradeConditions,
49    /// Exchange aggressor order id, unique per symbol. Only explicit matching ids combine fills.
50    #[serde(default)]
51    pub aggressor_order_id: Option<u64>,
52    /// Resting order filled by this execution, when supplied by an order-level feed.
53    #[serde(default)]
54    pub resting_order_id: Option<u64>,
55}
56
57impl Tick {
58    /// A trade without an id.
59    pub fn new(ts: DateTime<Utc>, price: f64, size: f64, side: Option<OrderSide>) -> Self {
60        Self {
61            ts,
62            price,
63            size,
64            side,
65            id: None,
66            conditions: TradeConditions::NONE,
67            aggressor_order_id: None,
68            resting_order_id: None,
69        }
70    }
71
72    /// Sets [`id`](Self::id).
73    pub fn with_id(mut self, id: u64) -> Self {
74        self.id = Some(id);
75        self
76    }
77
78    /// Sets [`resting_order_id`](Self::resting_order_id).
79    pub fn with_resting_order_id(mut self, id: u64) -> Self {
80        self.resting_order_id = Some(id);
81        self
82    }
83
84    /// Sets [`aggressor_order_id`](Self::aggressor_order_id).
85    pub fn with_aggressor_order_id(mut self, id: u64) -> Self {
86        self.aggressor_order_id = Some(id);
87        self
88    }
89
90    /// Sets [`conditions`](Self::conditions).
91    pub fn with_conditions(mut self, conditions: TradeConditions) -> Self {
92        self.conditions = conditions;
93        self
94    }
95}
96
97/// Sale conditions of a trade, as a set of flags. Combine them with `|` and test them with
98/// [`intersects`](Self::intersects):
99///
100/// ```
101/// use egui_charts::model::{Tick, TradeConditions};
102///
103/// let trade = Tick::new(chrono::Utc::now(), 100.0, 5.0, None)
104///     .with_conditions(TradeConditions::AUCTION);
105/// let leave_out = TradeConditions::OFF_EXCHANGE | TradeConditions::AUCTION;
106/// assert!(trade.conditions.intersects(leave_out));
107/// ```
108#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
109pub struct TradeConditions(u16);
110
111impl TradeConditions {
112    /// A regular trade of continuous trading on the exchange.
113    pub const NONE: Self = Self(0);
114    /// Reported away from the exchange's book, such as by a dark pool or an over-the-counter
115    /// dealer.
116    pub const OFF_EXCHANGE: Self = Self(1);
117    /// Printed by an opening, closing or reopening auction rather than by continuous trading.
118    pub const AUCTION: Self = Self(1 << 1);
119    /// Smaller than the instrument's round lot.
120    pub const ODD_LOT: Self = Self(1 << 2);
121    /// Reported late or out of sequence, after trades executed later than it.
122    pub const OUT_OF_SEQUENCE: Self = Self(1 << 3);
123    /// Executed outside the regular trading session.
124    pub const EXTENDED_HOURS: Self = Self(1 << 4);
125
126    /// Raw condition bits, including flags a newer feed may define.
127    pub(crate) fn bits(self) -> u16 {
128        self.0
129    }
130
131    /// Restores condition bits without discarding unknown flags in a recording.
132    pub(crate) fn from_bits(bits: u16) -> Self {
133        Self(bits)
134    }
135
136    /// Whether any condition of `other` is set.
137    pub fn intersects(self, other: Self) -> bool {
138        self.0 & other.0 != 0
139    }
140}
141
142impl std::ops::BitOr for TradeConditions {
143    type Output = Self;
144
145    fn bitor(self, other: Self) -> Self {
146        Self(self.0 | other.0)
147    }
148}
149
150/// A host-supplied session price, independent of the regular close and hovered history.
151#[derive(Debug, Clone, PartialEq)]
152#[non_exhaustive]
153pub struct SessionQuote {
154    /// Session price. Renderers ignore non-finite values.
155    pub price: f64,
156    /// Short host-supplied qualifier shown beside the price.
157    pub qualifier: String,
158}
159
160impl SessionQuote {
161    /// Creates a session quote without deriving it from historical bars.
162    pub fn new(price: f64, qualifier: impl Into<String>) -> Self {
163        Self {
164            price,
165            qualifier: qualifier.into(),
166        }
167    }
168}
169
170/// The quote of one symbol: its last price and change from the previous close, the session's
171/// volume, the best bid and ask, and the latest extended-hours trade. Fields the host does not
172/// know stay `None`.
173///
174/// ```
175/// use egui_charts::model::Quote;
176///
177/// let quote = Quote::new(105.0, 100.0, chrono::Utc::now()).with_bid_ask(104.99, 105.01);
178/// assert_eq!((quote.change(), quote.change_percent()), (5.0, 5.0));
179/// ```
180#[derive(Clone, Debug, PartialEq)]
181#[non_exhaustive]
182pub struct Quote {
183    /// Last traded price of the regular session.
184    pub last: f64,
185    /// Close of the previous regular session.
186    pub prev_close: f64,
187    /// When the quote last changed.
188    pub time: DateTime<Utc>,
189    /// Volume traded in the current session.
190    pub volume: Option<f64>,
191    /// Best bid.
192    pub bid: Option<f64>,
193    /// Best ask.
194    pub ask: Option<f64>,
195    /// The latest trade before or after the regular session.
196    pub extended: Option<ExtendedHoursQuote>,
197}
198
199impl Quote {
200    /// A quote of `last`, changed from `prev_close`, at `time`.
201    pub fn new(last: f64, prev_close: f64, time: DateTime<Utc>) -> Self {
202        Self {
203            last,
204            prev_close,
205            time,
206            volume: None,
207            bid: None,
208            ask: None,
209            extended: None,
210        }
211    }
212
213    /// Sets [`volume`](Self::volume).
214    pub fn with_volume(mut self, volume: f64) -> Self {
215        self.volume = Some(volume);
216        self
217    }
218
219    /// Sets [`bid`](Self::bid) and [`ask`](Self::ask).
220    pub fn with_bid_ask(mut self, bid: f64, ask: f64) -> Self {
221        (self.bid, self.ask) = (Some(bid), Some(ask));
222        self
223    }
224
225    /// Sets [`extended`](Self::extended).
226    pub fn with_extended(mut self, extended: ExtendedHoursQuote) -> Self {
227        self.extended = Some(extended);
228        self
229    }
230
231    /// `last` minus `prev_close`.
232    pub fn change(&self) -> f64 {
233        self.last - self.prev_close
234    }
235
236    /// The change divided by the absolute previous close, times 100; NaN when the previous close
237    /// is 0.
238    pub fn change_percent(&self) -> f64 {
239        if self.prev_close == 0.0 {
240            f64::NAN
241        } else {
242            self.change() / self.prev_close.abs() * 100.0
243        }
244    }
245}
246
247/// The session of an extended-hours trade.
248#[derive(Clone, Copy, Debug, PartialEq, Eq)]
249pub enum ExtendedSession {
250    /// Before the regular session opens.
251    PreMarket,
252    /// After the regular session closes.
253    PostMarket,
254}
255
256/// The latest trade of a symbol outside its regular session.
257#[derive(Clone, Copy, Debug, PartialEq)]
258pub struct ExtendedHoursQuote {
259    /// Whether the trade was before or after the regular session.
260    pub session: ExtendedSession,
261    /// Last traded price. Its change is measured from the regular session's last price,
262    /// [`Quote::last`].
263    pub last: f64,
264    /// When `last` traded.
265    pub time: DateTime<Utc>,
266}
267
268/// Level 1 quote -- best bid and ask (top-of-book).
269///
270/// A Level 1 quote captures the current best bid and ask prices and
271/// their sizes. It is the most common form of real-time market data,
272/// sufficient for spread calculations and basic order-book imbalance
273/// metrics.
274///
275/// ```
276/// use egui_charts::model::Level1Quote;
277/// use chrono::Utc;
278///
279/// let quote = Level1Quote {
280///     ts: Utc::now(),
281///     symbol: "BTCUSDT".into(),
282///     bid: 50_000.0, bid_size: 1.5,
283///     ask: 50_010.0, ask_size: 2.0,
284/// };
285/// assert_eq!(quote.spread(), 10.0);
286/// ```
287#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
288pub struct Level1Quote {
289    /// Ts of the quote
290    pub ts: DateTime<Utc>,
291    /// Symbol
292    pub symbol: String,
293    /// Best bid price
294    pub bid: f64,
295    /// Best bid size
296    pub bid_size: f64,
297    /// Best ask price
298    pub ask: f64,
299    /// Best ask size
300    pub ask_size: f64,
301}
302
303impl Level1Quote {
304    /// Calculate bid-ask spread in price
305    pub fn spread(&self) -> f64 {
306        self.ask - self.bid
307    }
308
309    /// Calculate the bid-ask spread in basis points (1 bp = 0.01%).
310    pub fn spread_bps(&self) -> f64 {
311        if self.bid == 0.0 {
312            return 0.0;
313        }
314        (self.spread() / self.bid) * 10000.0
315    }
316
317    /// Calculate mid price
318    pub fn mid_price(&self) -> f64 {
319        (self.bid + self.ask) / 2.0
320    }
321
322    /// Total quoted volume (bid + ask)
323    pub fn total_size(&self) -> f64 {
324        self.bid_size + self.ask_size
325    }
326}
327
328/// A single price level in a Level 2 order book.
329///
330/// Represents the aggregate size (and optionally order count) resting
331/// at one price level on either the bid or ask side.
332#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
333pub struct OrderBookLevel {
334    /// Price level
335    pub price: f64,
336    /// Size at this level
337    pub size: f64,
338    /// Number of orders at this level (if available)
339    pub order_cnt: Option<u32>,
340}
341
342/// Full depth-of-market order book snapshot (Level 2).
343///
344/// Contains all visible bid and ask price levels at a single point in
345/// time. Bids are sorted descending (best bid first), asks ascending
346/// (best ask first). Use [`imbalance_ratio`](Self::imbalance_ratio) to
347/// gauge buying vs. selling pressure.
348#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
349pub struct OrderBook {
350    /// Ts of the snapshot
351    pub ts: DateTime<Utc>,
352    /// Symbol
353    pub symbol: String,
354    /// Bid levels (sorted descending by price)
355    pub bids: Vec<OrderBookLevel>,
356    /// Ask levels (sorted ascending by price)
357    pub asks: Vec<OrderBookLevel>,
358}
359
360impl OrderBook {
361    /// Get best bid (highest bid price)
362    pub fn best_bid(&self) -> Option<&OrderBookLevel> {
363        self.bids.first()
364    }
365
366    /// Get best ask (lowest ask price)
367    pub fn best_ask(&self) -> Option<&OrderBookLevel> {
368        self.asks.first()
369    }
370
371    /// Convert to Level 1 quote
372    pub fn to_level1(&self) -> Option<Level1Quote> {
373        let bid = self.best_bid()?;
374        let ask = self.best_ask()?;
375
376        Some(Level1Quote {
377            ts: self.ts,
378            symbol: self.symbol.clone(),
379            bid: bid.price,
380            bid_size: bid.size,
381            ask: ask.price,
382            ask_size: ask.size,
383        })
384    }
385
386    /// Calculate total bid volume
387    pub fn total_bid_volume(&self) -> f64 {
388        self.bids.iter().map(|level| level.size).sum()
389    }
390
391    /// Calculate total ask volume
392    pub fn total_ask_volume(&self) -> f64 {
393        self.asks.iter().map(|level| level.size).sum()
394    }
395
396    /// Calculate the order-book imbalance ratio (`bid_volume / total_volume`).
397    ///
398    /// Returns a value in `[0, 1]`. Values above 0.5 indicate more resting
399    /// bid volume (buying interest); values below 0.5 indicate more ask
400    /// volume (selling interest). Returns 0.5 when the book is empty.
401    pub fn imbalance_ratio(&self) -> f64 {
402        let bid_vol = self.total_bid_volume();
403        let ask_vol = self.total_ask_volume();
404        let total = bid_vol + ask_vol;
405
406        if total == 0.0 {
407            return 0.5;
408        }
409
410        bid_vol / total
411    }
412
413    /// Whether the best bid is above the best ask. Bids and asks overlap during pre-open and
414    /// auction phases, while a venue publishes an indicative opening price, and in books
415    /// consolidated from several venues, so a crossed book is valid data: the chart keeps it as
416    /// the feed sends it. A best bid equal to the best ask is locked, not crossed.
417    pub fn is_crossed(&self) -> bool {
418        self.best_bid()
419            .zip(self.best_ask())
420            .is_some_and(|(bid, ask)| bid.price > ask.price)
421    }
422
423    /// Get mid price from best bid/ask
424    pub fn mid_price(&self) -> Option<f64> {
425        let bid = self.best_bid()?.price;
426        let ask = self.best_ask()?.price;
427        Some((bid + ask) / 2.0)
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn test_level1_quote_calculations() {
437        let quote = Level1Quote {
438            ts: Utc::now(),
439            symbol: "BTCUSDT".to_string(),
440            bid: 50000.0,
441            bid_size: 1.5,
442            ask: 50010.0,
443            ask_size: 2.0,
444        };
445
446        assert_eq!(quote.spread(), 10.0);
447        assert_eq!(quote.mid_price(), 50005.0);
448        assert_eq!(quote.total_size(), 3.5);
449
450        // Spread in basis points: (10 / 50000) * 10000 = 2 bps
451        assert!((quote.spread_bps() - 2.0).abs() < 0.01);
452    }
453
454    #[test]
455    fn test_order_book_imbalance() {
456        let order_book = OrderBook {
457            ts: Utc::now(),
458            symbol: "BTCUSDT".to_string(),
459            bids: vec![
460                OrderBookLevel {
461                    price: 50000.0,
462                    size: 3.0,
463                    order_cnt: Some(5),
464                },
465                OrderBookLevel {
466                    price: 49990.0,
467                    size: 2.0,
468                    order_cnt: Some(3),
469                },
470            ],
471            asks: vec![
472                OrderBookLevel {
473                    price: 50010.0,
474                    size: 1.0,
475                    order_cnt: Some(2),
476                },
477                OrderBookLevel {
478                    price: 50020.0,
479                    size: 1.0,
480                    order_cnt: Some(1),
481                },
482            ],
483        };
484
485        // Total bid: 5.0, total ask: 2.0, imbalance: 5/7 ≈ 0.714
486        let imbalance = order_book.imbalance_ratio();
487        assert!((imbalance - 0.714).abs() < 0.01);
488
489        assert_eq!(order_book.total_bid_volume(), 5.0);
490        assert_eq!(order_book.total_ask_volume(), 2.0);
491    }
492
493    #[test]
494    fn test_order_book_to_level1() {
495        let order_book = OrderBook {
496            ts: Utc::now(),
497            symbol: "BTCUSDT".to_string(),
498            bids: vec![OrderBookLevel {
499                price: 50000.0,
500                size: 1.5,
501                order_cnt: None,
502            }],
503            asks: vec![OrderBookLevel {
504                price: 50010.0,
505                size: 2.0,
506                order_cnt: None,
507            }],
508        };
509
510        let level1 = order_book.to_level1().unwrap();
511        assert_eq!(level1.bid, 50000.0);
512        assert_eq!(level1.ask, 50010.0);
513        assert_eq!(level1.bid_size, 1.5);
514        assert_eq!(level1.ask_size, 2.0);
515    }
516}