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}