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}