1#[derive(Debug, Clone, PartialEq)]
12pub struct OrderBookLevel {
13 pub price: f64,
15 pub quantity: f64,
17}
18
19impl OrderBookLevel {
20 #[inline]
22 pub fn value(&self) -> f64 {
23 self.price * self.quantity
24 }
25}
26
27#[derive(Debug, Clone)]
29pub struct OrderBook {
30 pub pair: String,
32 pub bids: Vec<OrderBookLevel>,
34 pub asks: Vec<OrderBookLevel>,
36}
37
38impl OrderBook {
39 pub fn best_bid(&self) -> Option<f64> {
41 self.bids.first().map(|l| l.price)
42 }
43
44 pub fn best_ask(&self) -> Option<f64> {
46 self.asks.first().map(|l| l.price)
47 }
48
49 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 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 pub fn bid_depth(&self) -> f64 {
67 self.bids.iter().map(OrderBookLevel::value).sum()
68 }
69
70 pub fn ask_depth(&self) -> f64 {
72 self.asks.iter().map(OrderBookLevel::value).sum()
73 }
74
75 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 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#[derive(Debug, Clone, Copy, PartialEq)]
159pub enum ExecutionSide {
160 Buy,
161 Sell,
162}
163
164#[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#[derive(Debug, Clone, PartialEq)]
176pub enum HealthCheck {
177 Pass(String),
178 Fail(String),
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum TradeSide {
188 Buy,
189 Sell,
190}
191
192#[derive(Debug, Clone)]
194pub struct Trade {
195 pub price: f64,
197 pub quantity: f64,
199 pub quote_quantity: Option<f64>,
201 pub timestamp_ms: u64,
203 pub side: TradeSide,
205 pub id: Option<String>,
207}
208
209#[derive(Debug, Clone)]
211pub struct Ticker {
212 pub pair: String,
214 pub last_price: Option<f64>,
216 pub high_24h: Option<f64>,
218 pub low_24h: Option<f64>,
220 pub volume_24h: Option<f64>,
222 pub quote_volume_24h: Option<f64>,
224 pub best_bid: Option<f64>,
226 pub best_ask: Option<f64>,
228}
229
230#[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#[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}