Skip to main content

scope/market/
types.rs

1//! Core market data types.
2//!
3//! Defines order book levels, full order books, execution estimates, and
4//! the newer trade/ticker/snapshot types used by the exchange system.
5
6// =============================================================================
7// Order Book
8// =============================================================================
9
10/// A single price level in the order book.
11#[derive(Debug, Clone, PartialEq)]
12pub struct OrderBookLevel {
13    /// Price (e.g., 1.0001)
14    pub price: f64,
15    /// Quantity in base asset (e.g., PUSD)
16    pub quantity: f64,
17}
18
19impl OrderBookLevel {
20    /// Value in quote asset (price × quantity, e.g., USDT).
21    #[inline]
22    pub fn value(&self) -> f64 {
23        self.price * self.quantity
24    }
25}
26
27/// Full order book snapshot with bids and asks.
28#[derive(Debug, Clone)]
29pub struct OrderBook {
30    /// Trading pair label (e.g., "PUSD/USDT").
31    pub pair: String,
32    /// Bids sorted by price descending (best bid first).
33    pub bids: Vec<OrderBookLevel>,
34    /// Asks sorted by price ascending (best ask first).
35    pub asks: Vec<OrderBookLevel>,
36}
37
38impl OrderBook {
39    /// Best bid price, or None if empty.
40    pub fn best_bid(&self) -> Option<f64> {
41        self.bids.first().map(|l| l.price)
42    }
43
44    /// Best ask price, or None if empty.
45    pub fn best_ask(&self) -> Option<f64> {
46        self.asks.first().map(|l| l.price)
47    }
48
49    /// Mid price between best bid and ask.
50    pub fn mid_price(&self) -> Option<f64> {
51        match (self.best_bid(), self.best_ask()) {
52            (Some(bid), Some(ask)) => Some((bid + ask) / 2.0),
53            _ => None,
54        }
55    }
56
57    /// Spread (ask - bid).
58    pub fn spread(&self) -> Option<f64> {
59        match (self.best_bid(), self.best_ask()) {
60            (Some(bid), Some(ask)) => Some(ask - bid),
61            _ => None,
62        }
63    }
64
65    /// Total bid depth in quote terms (sum of price × quantity).
66    pub fn bid_depth(&self) -> f64 {
67        self.bids.iter().map(OrderBookLevel::value).sum()
68    }
69
70    /// Total ask depth in quote terms.
71    pub fn ask_depth(&self) -> f64 {
72        self.asks.iter().map(OrderBookLevel::value).sum()
73    }
74
75    /// Estimate slippage for buying a given USDT notional by walking the ask side.
76    /// Returns (vwap, slippage_bps) if fillable, or None if insufficient liquidity.
77    pub fn estimate_buy_execution(&self, notional_usdt: f64) -> Option<ExecutionEstimate> {
78        let mid = self.mid_price()?;
79        if mid <= 0.0 {
80            return None;
81        }
82        let mut remaining = notional_usdt;
83        let mut filled_value = 0.0;
84        let mut filled_qty = 0.0;
85        for level in &self.asks {
86            let level_value = level.value();
87            if remaining <= 0.0 {
88                break;
89            }
90            let take_value = level_value.min(remaining);
91            let take_qty = if level.price > 0.0 {
92                take_value / level.price
93            } else {
94                0.0
95            };
96            filled_value += take_value;
97            filled_qty += take_qty;
98            remaining -= take_value;
99        }
100        let fillable = remaining <= 0.01;
101        let vwap = if filled_qty > 0.0 {
102            filled_value / filled_qty
103        } else {
104            mid
105        };
106        let slippage_bps = (vwap - mid) / mid * 10_000.0;
107        Some(ExecutionEstimate {
108            notional_usdt,
109            side: ExecutionSide::Buy,
110            vwap,
111            slippage_bps,
112            fillable,
113        })
114    }
115
116    /// Estimate slippage for selling (hitting bids) a given USDT notional.
117    pub fn estimate_sell_execution(&self, notional_usdt: f64) -> Option<ExecutionEstimate> {
118        let mid = self.mid_price()?;
119        if mid <= 0.0 {
120            return None;
121        }
122        let mut remaining = notional_usdt;
123        let mut filled_value = 0.0;
124        let mut filled_qty = 0.0;
125        for level in &self.bids {
126            if remaining <= 0.0 {
127                break;
128            }
129            let level_value = level.value();
130            let take_value = level_value.min(remaining);
131            let take_qty = if level.price > 0.0 {
132                take_value / level.price
133            } else {
134                0.0
135            };
136            filled_value += take_value;
137            filled_qty += take_qty;
138            remaining -= take_value;
139        }
140        let fillable = remaining <= 0.01;
141        let vwap = if filled_qty > 0.0 {
142            filled_value / filled_qty
143        } else {
144            mid
145        };
146        let slippage_bps = (mid - vwap) / mid * 10_000.0;
147        Some(ExecutionEstimate {
148            notional_usdt,
149            side: ExecutionSide::Sell,
150            vwap,
151            slippage_bps,
152            fillable,
153        })
154    }
155}
156
157/// Side of execution (buy = hit asks, sell = hit bids).
158#[derive(Debug, Clone, Copy, PartialEq)]
159pub enum ExecutionSide {
160    Buy,
161    Sell,
162}
163
164/// Result of execution simulation for a given notional size.
165#[derive(Debug, Clone)]
166pub struct ExecutionEstimate {
167    pub notional_usdt: f64,
168    pub side: ExecutionSide,
169    pub vwap: f64,
170    pub slippage_bps: f64,
171    pub fillable: bool,
172}
173
174/// Outcome of a single health check.
175#[derive(Debug, Clone, PartialEq)]
176pub enum HealthCheck {
177    Pass(String),
178    Fail(String),
179}
180
181// =============================================================================
182// Trade & Ticker Types
183// =============================================================================
184
185/// Side of a trade.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum TradeSide {
188    Buy,
189    Sell,
190}
191
192/// A single trade from the recent trades endpoint.
193#[derive(Debug, Clone)]
194pub struct Trade {
195    /// Trade price in quote currency.
196    pub price: f64,
197    /// Quantity in base currency.
198    pub quantity: f64,
199    /// Quote quantity (price × quantity), if provided.
200    pub quote_quantity: Option<f64>,
201    /// Timestamp in milliseconds since epoch.
202    pub timestamp_ms: u64,
203    /// Whether this was a buy or sell from the taker's perspective.
204    pub side: TradeSide,
205    /// Trade ID, if available.
206    pub id: Option<String>,
207}
208
209/// 24-hour ticker / market stats.
210#[derive(Debug, Clone)]
211pub struct Ticker {
212    /// Pair label (e.g., "BTC/USDT").
213    pub pair: String,
214    /// Last traded price.
215    pub last_price: Option<f64>,
216    /// 24h high.
217    pub high_24h: Option<f64>,
218    /// 24h low.
219    pub low_24h: Option<f64>,
220    /// 24h base volume.
221    pub volume_24h: Option<f64>,
222    /// 24h quote volume.
223    pub quote_volume_24h: Option<f64>,
224    /// Best bid (if included in ticker).
225    pub best_bid: Option<f64>,
226    /// Best ask (if included in ticker).
227    pub best_ask: Option<f64>,
228}
229
230/// Aggregated market snapshot combining all available data for a pair.
231#[derive(Debug, Clone)]
232pub struct MarketSnapshot {
233    pub order_book: Option<OrderBook>,
234    pub ticker: Option<Ticker>,
235    pub recent_trades: Option<Vec<Trade>>,
236}
237
238// =============================================================================
239// Tests
240// =============================================================================
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn test_order_book_level_value() {
248        let level = OrderBookLevel {
249            price: 1.0002,
250            quantity: 100.0,
251        };
252        assert!((level.value() - 100.02).abs() < 1e-6);
253    }
254
255    #[test]
256    fn test_order_book_empty() {
257        let book = OrderBook {
258            pair: "PUSD/USDT".to_string(),
259            bids: vec![],
260            asks: vec![],
261        };
262        assert!(book.best_bid().is_none());
263        assert!(book.best_ask().is_none());
264        assert!(book.mid_price().is_none());
265        assert_eq!(book.bid_depth(), 0.0);
266        assert_eq!(book.ask_depth(), 0.0);
267    }
268
269    #[test]
270    fn test_order_book_with_levels() {
271        let book = OrderBook {
272            pair: "PUSD/USDT".to_string(),
273            bids: vec![
274                OrderBookLevel {
275                    price: 0.9998,
276                    quantity: 100.0,
277                },
278                OrderBookLevel {
279                    price: 0.9997,
280                    quantity: 50.0,
281                },
282            ],
283            asks: vec![
284                OrderBookLevel {
285                    price: 1.0001,
286                    quantity: 200.0,
287                },
288                OrderBookLevel {
289                    price: 1.0002,
290                    quantity: 150.0,
291                },
292            ],
293        };
294        assert_eq!(book.best_bid(), Some(0.9998));
295        assert_eq!(book.best_ask(), Some(1.0001));
296        assert_eq!(book.mid_price(), Some(0.99995));
297        assert!((book.spread().unwrap() - 0.0003).abs() < 1e-10);
298        assert!((book.bid_depth() - 99.98 - 49.985).abs() < 0.01);
299        assert!((book.ask_depth() - 200.02 - 150.03).abs() < 0.01);
300    }
301
302    #[test]
303    fn test_trade_side_equality() {
304        assert_eq!(TradeSide::Buy, TradeSide::Buy);
305        assert_ne!(TradeSide::Buy, TradeSide::Sell);
306    }
307
308    #[test]
309    fn test_trade_construction() {
310        let trade = Trade {
311            price: 42_000.0,
312            quantity: 1.5,
313            quote_quantity: Some(63_000.0),
314            timestamp_ms: 1700000000000,
315            side: TradeSide::Buy,
316            id: Some("12345".to_string()),
317        };
318        assert_eq!(trade.price, 42_000.0);
319        assert_eq!(trade.quantity, 1.5);
320        assert_eq!(trade.quote_quantity, Some(63_000.0));
321        assert_eq!(trade.side, TradeSide::Buy);
322        assert_eq!(trade.id, Some("12345".to_string()));
323    }
324
325    #[test]
326    fn test_trade_optional_fields() {
327        let trade = Trade {
328            price: 1.0001,
329            quantity: 100.0,
330            quote_quantity: None,
331            timestamp_ms: 1700000000000,
332            side: TradeSide::Sell,
333            id: None,
334        };
335        assert!(trade.quote_quantity.is_none());
336        assert!(trade.id.is_none());
337    }
338
339    #[test]
340    fn test_ticker_construction() {
341        let ticker = Ticker {
342            pair: "BTC/USDT".to_string(),
343            last_price: Some(42_000.0),
344            high_24h: Some(43_000.0),
345            low_24h: Some(41_000.0),
346            volume_24h: Some(50_000.0),
347            quote_volume_24h: Some(2_100_000_000.0),
348            best_bid: Some(41_999.0),
349            best_ask: Some(42_001.0),
350        };
351        assert_eq!(ticker.pair, "BTC/USDT");
352        assert_eq!(ticker.last_price, Some(42_000.0));
353        assert_eq!(ticker.high_24h, Some(43_000.0));
354    }
355
356    #[test]
357    fn test_ticker_all_none() {
358        let ticker = Ticker {
359            pair: "UNKNOWN/USD".to_string(),
360            last_price: None,
361            high_24h: None,
362            low_24h: None,
363            volume_24h: None,
364            quote_volume_24h: None,
365            best_bid: None,
366            best_ask: None,
367        };
368        assert!(ticker.last_price.is_none());
369        assert!(ticker.volume_24h.is_none());
370    }
371
372    #[test]
373    fn test_market_snapshot_full() {
374        let snapshot = MarketSnapshot {
375            order_book: Some(OrderBook {
376                pair: "BTC/USDT".to_string(),
377                bids: vec![OrderBookLevel {
378                    price: 42_000.0,
379                    quantity: 1.0,
380                }],
381                asks: vec![OrderBookLevel {
382                    price: 42_001.0,
383                    quantity: 1.0,
384                }],
385            }),
386            ticker: Some(Ticker {
387                pair: "BTC/USDT".to_string(),
388                last_price: Some(42_000.0),
389                high_24h: None,
390                low_24h: None,
391                volume_24h: None,
392                quote_volume_24h: None,
393                best_bid: None,
394                best_ask: None,
395            }),
396            recent_trades: Some(vec![Trade {
397                price: 42_000.0,
398                quantity: 0.5,
399                quote_quantity: None,
400                timestamp_ms: 1700000000000,
401                side: TradeSide::Buy,
402                id: None,
403            }]),
404        };
405        assert!(snapshot.order_book.is_some());
406        assert!(snapshot.ticker.is_some());
407        assert_eq!(snapshot.recent_trades.as_ref().unwrap().len(), 1);
408    }
409
410    #[test]
411    fn test_market_snapshot_empty() {
412        let snapshot = MarketSnapshot {
413            order_book: None,
414            ticker: None,
415            recent_trades: None,
416        };
417        assert!(snapshot.order_book.is_none());
418        assert!(snapshot.ticker.is_none());
419        assert!(snapshot.recent_trades.is_none());
420    }
421}