Skip to main content

wickra_exchange_core/
events.rs

1//! Market-data and account streaming types.
2//!
3//! These are the items a pull-based stream yields from [`poll_events`]. Prices
4//! and quantities are exact [`Decimal`]s; the order-book types carry a sequence
5//! id so the local builder can apply diffs and detect gaps. They are all
6//! `serde`-serializable, which is what the replay-parity corpus pins across the
7//! language bindings.
8//!
9//! [`poll_events`]: crate::MarketData::poll_events
10
11use crate::symbol::Symbol;
12use crate::types::{Balance, Order, OrderSide, Ticker};
13use rust_decimal::Decimal;
14use serde::{Deserialize, Serialize};
15
16/// A single resting price level (exchange-precision `Decimal`).
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct BookLevel {
19    /// Price of the level.
20    pub price: Decimal,
21    /// Resting quantity at this price. A quantity of zero in a [`BookDelta`]
22    /// means the level was removed.
23    pub quantity: Decimal,
24}
25
26impl BookLevel {
27    /// Construct a level.
28    #[must_use]
29    pub fn new(price: Decimal, quantity: Decimal) -> Self {
30        Self { price, quantity }
31    }
32}
33
34/// A depth snapshot with exact levels, best-first on each side.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct OrderBookSnapshot {
37    /// The market.
38    pub symbol: Symbol,
39    /// The venue's sequence id for this snapshot (for diff alignment).
40    pub last_update_id: u64,
41    /// Bid levels, best (highest price) first.
42    pub bids: Vec<BookLevel>,
43    /// Ask levels, best (lowest price) first.
44    pub asks: Vec<BookLevel>,
45    /// Venue timestamp (milliseconds since the Unix epoch), or `0` when the
46    /// venue did not supply one.
47    ///
48    /// Without this a consumer cannot tell how old a quote is, which is the
49    /// difference between acting on the market and acting on a memory of it. A
50    /// venue that publishes no timestamp reports `0` rather than the local
51    /// clock: a locally-stamped quote looks fresh by construction, which is the
52    /// one thing a staleness check must never be told.
53    pub timestamp: i64,
54}
55
56impl OrderBookSnapshot {
57    /// The best (highest-price) bid, or `None` if the bid side is empty.
58    #[must_use]
59    pub fn best_bid(&self) -> Option<&BookLevel> {
60        self.bids.first()
61    }
62
63    /// The best (lowest-price) ask, or `None` if the ask side is empty.
64    #[must_use]
65    pub fn best_ask(&self) -> Option<&BookLevel> {
66        self.asks.first()
67    }
68
69    /// The mid price `(best_bid + best_ask) / 2`, or `None` if either side is empty.
70    #[must_use]
71    pub fn mid_price(&self) -> Option<Decimal> {
72        match (self.best_bid(), self.best_ask()) {
73            (Some(bid), Some(ask)) => Some((bid.price + ask.price) / Decimal::from(2)),
74            _ => None,
75        }
76    }
77
78    /// The bid/ask spread `best_ask - best_bid`, or `None` if either side is empty.
79    #[must_use]
80    pub fn spread(&self) -> Option<Decimal> {
81        match (self.best_bid(), self.best_ask()) {
82            (Some(bid), Some(ask)) => Some(ask.price - bid.price),
83            _ => None,
84        }
85    }
86}
87
88/// A depth diff to apply to a locally maintained order book.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct BookDelta {
91    /// The market.
92    pub symbol: Symbol,
93    /// The first sequence id covered by this diff.
94    pub first_update_id: u64,
95    /// The last sequence id covered by this diff.
96    pub final_update_id: u64,
97    /// Changed bid levels (quantity zero = remove).
98    pub bids: Vec<BookLevel>,
99    /// Changed ask levels (quantity zero = remove).
100    pub asks: Vec<BookLevel>,
101    /// Venue timestamp (milliseconds since the Unix epoch), or `0` when the
102    /// venue did not supply one.
103    ///
104    /// Without this a consumer cannot tell how old a quote is, which is the
105    /// difference between acting on the market and acting on a memory of it. A
106    /// venue that publishes no timestamp reports `0` rather than the local
107    /// clock: a locally-stamped quote looks fresh by construction, which is the
108    /// one thing a staleness check must never be told.
109    pub timestamp: i64,
110}
111
112/// A single executed public trade.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct TradePrint {
115    /// The market.
116    pub symbol: Symbol,
117    /// Execution price.
118    pub price: Decimal,
119    /// Executed quantity.
120    pub quantity: Decimal,
121    /// The taker (aggressor) side.
122    pub aggressor: OrderSide,
123    /// Venue timestamp (milliseconds since the Unix epoch).
124    pub timestamp: i64,
125}
126
127/// An item yielded by a pull-based stream.
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(tag = "type", rename_all = "snake_case")]
130pub enum Event {
131    /// A public trade print.
132    Trade(TradePrint),
133    /// A ticker update.
134    Ticker(Ticker),
135    /// A full order-book snapshot (e.g. after subscribe or resync).
136    BookSnapshot(OrderBookSnapshot),
137    /// An incremental order-book diff.
138    BookDelta(BookDelta),
139    /// An update to one of the account's orders (user-data stream).
140    OrderUpdate(Order),
141    /// An account balance update (user-data stream).
142    BalanceUpdate(Vec<Balance>),
143    /// A subscription was acknowledged by the venue.
144    Subscribed {
145        /// The channel that was subscribed.
146        channel: String,
147    },
148    /// The stream disconnected; the client will attempt to reconnect.
149    Disconnected,
150    /// The stream reconnected and resubscribed.
151    Reconnected,
152    /// A derivatives microstructure print: funding, mark/index, or a forced
153    /// liquidation. Arrives only on a futures client that subscribed to the
154    /// channel through
155    /// [`DerivativesStream`](crate::traits::DerivativesStream).
156    Derivatives(crate::feeds::DerivativesFeed),
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use rust_decimal_macros::dec;
163
164    fn book() -> OrderBookSnapshot {
165        OrderBookSnapshot {
166            symbol: Symbol::new("BTC", "USDT"),
167            last_update_id: 42,
168            bids: vec![
169                BookLevel::new(dec!(100), dec!(1)),
170                BookLevel::new(dec!(99), dec!(2)),
171            ],
172            asks: vec![
173                BookLevel::new(dec!(101), dec!(1)),
174                BookLevel::new(dec!(102), dec!(3)),
175            ],
176            timestamp: 0,
177        }
178    }
179
180    #[test]
181    fn book_best_levels_mid_and_spread() {
182        let b = book();
183        assert_eq!(b.best_bid(), Some(&BookLevel::new(dec!(100), dec!(1))));
184        assert_eq!(b.best_ask(), Some(&BookLevel::new(dec!(101), dec!(1))));
185        assert_eq!(b.mid_price(), Some(dec!(100.5)));
186        assert_eq!(b.spread(), Some(dec!(1)));
187    }
188
189    #[test]
190    fn empty_sides_yield_no_mid_or_spread() {
191        let empty = OrderBookSnapshot {
192            symbol: Symbol::new("BTC", "USDT"),
193            last_update_id: 0,
194            bids: vec![],
195            asks: vec![],
196            timestamp: 0,
197        };
198        assert!(empty.best_bid().is_none());
199        assert!(empty.best_ask().is_none());
200        assert!(empty.mid_price().is_none());
201        assert!(empty.spread().is_none());
202
203        let bid_only = OrderBookSnapshot {
204            asks: vec![],
205            ..book()
206        };
207        assert!(bid_only.mid_price().is_none());
208        assert!(bid_only.spread().is_none());
209    }
210
211    #[test]
212    fn event_is_tagged_in_json() {
213        let event = Event::Trade(TradePrint {
214            symbol: Symbol::new("BTC", "USDT"),
215            price: dec!(20000),
216            quantity: dec!(0.5),
217            aggressor: OrderSide::Buy,
218            timestamp: 1_700_000_000_000,
219        });
220        let json = serde_json::to_string(&event).unwrap();
221        assert!(json.contains("\"type\":\"trade\""));
222        assert_eq!(serde_json::from_str::<Event>(&json).unwrap(), event);
223    }
224
225    #[test]
226    fn lifecycle_events_round_trip() {
227        for event in [Event::Disconnected, Event::Reconnected] {
228            let json = serde_json::to_string(&event).unwrap();
229            assert_eq!(serde_json::from_str::<Event>(&json).unwrap(), event);
230        }
231        let sub = Event::Subscribed {
232            channel: "btcusdt@trade".into(),
233        };
234        let json = serde_json::to_string(&sub).unwrap();
235        assert_eq!(serde_json::from_str::<Event>(&json).unwrap(), sub);
236    }
237}