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., DAI)
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., "DAI/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// OHLC / Candlestick
240// =============================================================================
241
242/// A single OHLC candlestick bar.
243#[derive(Debug, Clone, PartialEq)]
244pub struct Candle {
245    /// Candle open time in milliseconds since epoch.
246    pub open_time: u64,
247    /// Opening price.
248    pub open: f64,
249    /// Highest price during the interval.
250    pub high: f64,
251    /// Lowest price during the interval.
252    pub low: f64,
253    /// Closing price.
254    pub close: f64,
255    /// Base-asset volume during the interval.
256    pub volume: f64,
257    /// Candle close time in milliseconds since epoch.
258    pub close_time: u64,
259}
260
261// =============================================================================
262// Tests
263// =============================================================================
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn test_order_book_level_value() {
271        let level = OrderBookLevel {
272            price: 1.0002,
273            quantity: 100.0,
274        };
275        assert!((level.value() - 100.02).abs() < 1e-6);
276    }
277
278    #[test]
279    fn test_order_book_empty() {
280        let book = OrderBook {
281            pair: "DAI/USDT".to_string(),
282            bids: vec![],
283            asks: vec![],
284        };
285        assert!(book.best_bid().is_none());
286        assert!(book.best_ask().is_none());
287        assert!(book.mid_price().is_none());
288        assert_eq!(book.bid_depth(), 0.0);
289        assert_eq!(book.ask_depth(), 0.0);
290    }
291
292    #[test]
293    fn test_order_book_with_levels() {
294        let book = OrderBook {
295            pair: "DAI/USDT".to_string(),
296            bids: vec![
297                OrderBookLevel {
298                    price: 0.9998,
299                    quantity: 100.0,
300                },
301                OrderBookLevel {
302                    price: 0.9997,
303                    quantity: 50.0,
304                },
305            ],
306            asks: vec![
307                OrderBookLevel {
308                    price: 1.0001,
309                    quantity: 200.0,
310                },
311                OrderBookLevel {
312                    price: 1.0002,
313                    quantity: 150.0,
314                },
315            ],
316        };
317        assert_eq!(book.best_bid(), Some(0.9998));
318        assert_eq!(book.best_ask(), Some(1.0001));
319        assert_eq!(book.mid_price(), Some(0.99995));
320        assert!((book.spread().unwrap() - 0.0003).abs() < 1e-10);
321        assert!((book.bid_depth() - 99.98 - 49.985).abs() < 0.01);
322        assert!((book.ask_depth() - 200.02 - 150.03).abs() < 0.01);
323    }
324
325    #[test]
326    fn test_candle_construction() {
327        let candle = Candle {
328            open_time: 1700000000000,
329            open: 100.0,
330            high: 105.0,
331            low: 95.0,
332            close: 102.0,
333            volume: 1_000_000.0,
334            close_time: 1700003600000,
335        };
336        assert_eq!(candle.open_time, 1700000000000);
337        assert_eq!(candle.open, 100.0);
338        assert_eq!(candle.high, 105.0);
339        assert_eq!(candle.low, 95.0);
340        assert_eq!(candle.close, 102.0);
341        assert_eq!(candle.volume, 1_000_000.0);
342        assert_eq!(candle.close_time, 1700003600000);
343    }
344
345    #[test]
346    fn test_candle_partial_eq() {
347        let c1 = Candle {
348            open_time: 1000,
349            open: 1.0,
350            high: 1.1,
351            low: 0.9,
352            close: 1.0,
353            volume: 100.0,
354            close_time: 2000,
355        };
356        let c2 = c1.clone();
357        assert_eq!(c1, c2);
358    }
359
360    #[test]
361    fn test_trade_side_equality() {
362        assert_eq!(TradeSide::Buy, TradeSide::Buy);
363        assert_ne!(TradeSide::Buy, TradeSide::Sell);
364    }
365
366    #[test]
367    fn test_trade_construction() {
368        let trade = Trade {
369            price: 42_000.0,
370            quantity: 1.5,
371            quote_quantity: Some(63_000.0),
372            timestamp_ms: 1700000000000,
373            side: TradeSide::Buy,
374            id: Some("12345".to_string()),
375        };
376        assert_eq!(trade.price, 42_000.0);
377        assert_eq!(trade.quantity, 1.5);
378        assert_eq!(trade.quote_quantity, Some(63_000.0));
379        assert_eq!(trade.side, TradeSide::Buy);
380        assert_eq!(trade.id, Some("12345".to_string()));
381    }
382
383    #[test]
384    fn test_trade_optional_fields() {
385        let trade = Trade {
386            price: 1.0001,
387            quantity: 100.0,
388            quote_quantity: None,
389            timestamp_ms: 1700000000000,
390            side: TradeSide::Sell,
391            id: None,
392        };
393        assert!(trade.quote_quantity.is_none());
394        assert!(trade.id.is_none());
395    }
396
397    #[test]
398    fn test_ticker_construction() {
399        let ticker = Ticker {
400            pair: "BTC/USDT".to_string(),
401            last_price: Some(42_000.0),
402            high_24h: Some(43_000.0),
403            low_24h: Some(41_000.0),
404            volume_24h: Some(50_000.0),
405            quote_volume_24h: Some(2_100_000_000.0),
406            best_bid: Some(41_999.0),
407            best_ask: Some(42_001.0),
408        };
409        assert_eq!(ticker.pair, "BTC/USDT");
410        assert_eq!(ticker.last_price, Some(42_000.0));
411        assert_eq!(ticker.high_24h, Some(43_000.0));
412    }
413
414    #[test]
415    fn test_ticker_all_none() {
416        let ticker = Ticker {
417            pair: "UNKNOWN/USD".to_string(),
418            last_price: None,
419            high_24h: None,
420            low_24h: None,
421            volume_24h: None,
422            quote_volume_24h: None,
423            best_bid: None,
424            best_ask: None,
425        };
426        assert!(ticker.last_price.is_none());
427        assert!(ticker.volume_24h.is_none());
428    }
429
430    #[test]
431    fn test_market_snapshot_full() {
432        let snapshot = MarketSnapshot {
433            order_book: Some(OrderBook {
434                pair: "BTC/USDT".to_string(),
435                bids: vec![OrderBookLevel {
436                    price: 42_000.0,
437                    quantity: 1.0,
438                }],
439                asks: vec![OrderBookLevel {
440                    price: 42_001.0,
441                    quantity: 1.0,
442                }],
443            }),
444            ticker: Some(Ticker {
445                pair: "BTC/USDT".to_string(),
446                last_price: Some(42_000.0),
447                high_24h: None,
448                low_24h: None,
449                volume_24h: None,
450                quote_volume_24h: None,
451                best_bid: None,
452                best_ask: None,
453            }),
454            recent_trades: Some(vec![Trade {
455                price: 42_000.0,
456                quantity: 0.5,
457                quote_quantity: None,
458                timestamp_ms: 1700000000000,
459                side: TradeSide::Buy,
460                id: None,
461            }]),
462        };
463        assert!(snapshot.order_book.is_some());
464        assert!(snapshot.ticker.is_some());
465        assert_eq!(snapshot.recent_trades.as_ref().unwrap().len(), 1);
466    }
467
468    #[test]
469    fn test_market_snapshot_empty() {
470        let snapshot = MarketSnapshot {
471            order_book: None,
472            ticker: None,
473            recent_trades: None,
474        };
475        assert!(snapshot.order_book.is_none());
476        assert!(snapshot.ticker.is_none());
477        assert!(snapshot.recent_trades.is_none());
478    }
479
480    // ================================================================
481    // Execution estimate edge-case tests (zero-price branches)
482    // ================================================================
483
484    #[test]
485    fn test_estimate_buy_zero_mid_price() {
486        // Bids and asks both at price 0 -> mid_price = 0 -> returns None
487        let book = OrderBook {
488            pair: "X/Y".to_string(),
489            bids: vec![OrderBookLevel {
490                price: 0.0,
491                quantity: 100.0,
492            }],
493            asks: vec![OrderBookLevel {
494                price: 0.0,
495                quantity: 100.0,
496            }],
497        };
498        assert!(book.estimate_buy_execution(1000.0).is_none());
499    }
500
501    #[test]
502    fn test_estimate_sell_zero_mid_price() {
503        let book = OrderBook {
504            pair: "X/Y".to_string(),
505            bids: vec![OrderBookLevel {
506                price: 0.0,
507                quantity: 100.0,
508            }],
509            asks: vec![OrderBookLevel {
510                price: 0.0,
511                quantity: 100.0,
512            }],
513        };
514        assert!(book.estimate_sell_execution(1000.0).is_none());
515    }
516
517    #[test]
518    fn test_estimate_buy_zero_price_level() {
519        // Valid mid price but one ask level has price 0 -> take_qty branch = 0.0
520        let book = OrderBook {
521            pair: "X/Y".to_string(),
522            bids: vec![OrderBookLevel {
523                price: 1.0,
524                quantity: 100.0,
525            }],
526            asks: vec![
527                OrderBookLevel {
528                    price: 0.0,
529                    quantity: 100.0,
530                },
531                OrderBookLevel {
532                    price: 1.001,
533                    quantity: 10000.0,
534                },
535            ],
536        };
537        let est = book.estimate_buy_execution(50.0).unwrap();
538        assert!(est.fillable);
539    }
540
541    #[test]
542    fn test_estimate_sell_zero_price_level() {
543        // Valid mid price but one bid level has price 0
544        let book = OrderBook {
545            pair: "X/Y".to_string(),
546            bids: vec![
547                OrderBookLevel {
548                    price: 0.0,
549                    quantity: 100.0,
550                },
551                OrderBookLevel {
552                    price: 0.999,
553                    quantity: 10000.0,
554                },
555            ],
556            asks: vec![OrderBookLevel {
557                price: 1.0,
558                quantity: 100.0,
559            }],
560        };
561        let est = book.estimate_sell_execution(50.0).unwrap();
562        assert!(est.fillable);
563    }
564
565    #[test]
566    fn test_estimate_buy_zero_filled_qty() {
567        // All ask levels have price 0 -> filled_qty stays 0 -> vwap = mid
568        let book = OrderBook {
569            pair: "X/Y".to_string(),
570            bids: vec![OrderBookLevel {
571                price: 1.0,
572                quantity: 100.0,
573            }],
574            asks: vec![OrderBookLevel {
575                price: 0.0,
576                quantity: 0.0,
577            }],
578        };
579        let est = book.estimate_buy_execution(50.0);
580        // Can still return Some if mid exists
581        assert!(est.is_some());
582        let est = est.unwrap();
583        assert!(!est.fillable);
584    }
585
586    #[test]
587    fn test_estimate_sell_zero_filled_qty() {
588        // All bid levels have price 0 and value 0
589        let book = OrderBook {
590            pair: "X/Y".to_string(),
591            bids: vec![OrderBookLevel {
592                price: 0.0,
593                quantity: 0.0,
594            }],
595            asks: vec![OrderBookLevel {
596                price: 1.0,
597                quantity: 100.0,
598            }],
599        };
600        let est = book.estimate_sell_execution(50.0);
601        assert!(est.is_some());
602        let est = est.unwrap();
603        assert!(!est.fillable);
604    }
605}