1use chrono::{DateTime, Utc};
17use serde::{Deserialize, Deserializer, Serialize, de::Error};
18use ustr::Ustr;
19
20use crate::common::{enums::TardisExchange, parse::deserialize_uppercase};
21
22#[derive(Debug, Clone, Deserialize, Serialize)]
24pub struct BookLevel {
25 pub price: f64,
27 pub amount: f64,
29}
30
31#[derive(Debug, Clone, Deserialize, Serialize)]
33#[serde(rename_all = "camelCase")]
34pub struct BookChangeMsg {
35 #[serde(deserialize_with = "deserialize_uppercase")]
37 pub symbol: Ustr,
38 pub exchange: TardisExchange,
40 pub is_snapshot: bool,
42 #[serde(deserialize_with = "deserialize_book_levels")]
44 pub bids: Vec<BookLevel>,
45 #[serde(deserialize_with = "deserialize_book_levels")]
47 pub asks: Vec<BookLevel>,
48 pub timestamp: DateTime<Utc>,
50 pub local_timestamp: DateTime<Utc>,
52}
53
54#[derive(Debug, Clone, Deserialize, Serialize)]
56#[serde(rename_all = "camelCase")]
57pub struct BookSnapshotMsg {
58 #[serde(deserialize_with = "deserialize_uppercase")]
60 pub symbol: Ustr,
61 pub exchange: TardisExchange,
63 pub name: String,
65 pub depth: u32,
67 pub interval: u32,
69 #[serde(deserialize_with = "deserialize_book_levels")]
71 pub bids: Vec<BookLevel>,
72 #[serde(deserialize_with = "deserialize_book_levels")]
74 pub asks: Vec<BookLevel>,
75 pub timestamp: DateTime<Utc>,
77 pub local_timestamp: DateTime<Utc>,
79}
80
81#[derive(Debug, Clone, Deserialize, Serialize)]
83#[serde(tag = "type")]
84#[serde(rename_all = "camelCase")]
85pub struct TradeMsg {
86 #[serde(deserialize_with = "deserialize_uppercase")]
88 pub symbol: Ustr,
89 pub exchange: TardisExchange,
91 pub id: Option<String>,
93 pub price: f64,
95 pub amount: f64,
97 pub side: String,
99 pub timestamp: DateTime<Utc>,
101 pub local_timestamp: DateTime<Utc>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107#[serde(rename_all = "camelCase")]
108pub struct DerivativeTickerMsg {
109 #[serde(deserialize_with = "deserialize_uppercase")]
111 pub symbol: Ustr,
112 pub exchange: TardisExchange,
114 pub last_price: Option<f64>,
116 pub open_interest: Option<f64>,
118 pub funding_rate: Option<f64>,
120 pub index_price: Option<f64>,
122 pub mark_price: Option<f64>,
124 pub timestamp: DateTime<Utc>,
126 pub local_timestamp: DateTime<Utc>,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase")]
134pub struct OptionSummaryMsg {
135 #[serde(deserialize_with = "deserialize_uppercase")]
137 pub symbol: Ustr,
138 pub exchange: TardisExchange,
140 pub option_type: String,
142 pub strike_price: f64,
144 pub expiration_date: DateTime<Utc>,
146 pub best_bid_price: Option<f64>,
148 pub best_bid_amount: Option<f64>,
150 #[serde(rename = "bestBidIV")]
152 pub best_bid_iv: Option<f64>,
153 pub best_ask_price: Option<f64>,
155 pub best_ask_amount: Option<f64>,
157 #[serde(rename = "bestAskIV")]
159 pub best_ask_iv: Option<f64>,
160 pub last_price: Option<f64>,
162 pub open_interest: Option<f64>,
164 pub mark_price: Option<f64>,
166 #[serde(rename = "markIV")]
168 pub mark_iv: Option<f64>,
169 pub delta: Option<f64>,
171 pub gamma: Option<f64>,
173 pub vega: Option<f64>,
175 pub theta: Option<f64>,
177 pub rho: Option<f64>,
179 pub underlying_price: Option<f64>,
181 pub underlying_index: String,
183 pub timestamp: DateTime<Utc>,
185 pub local_timestamp: DateTime<Utc>,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
193#[serde(rename_all = "camelCase")]
194pub struct BarMsg {
195 #[serde(deserialize_with = "deserialize_uppercase")]
197 pub symbol: Ustr,
198 pub exchange: TardisExchange,
200 pub name: String,
202 pub interval: u64,
204 pub open: f64,
206 pub high: f64,
208 pub low: f64,
210 pub close: f64,
212 pub volume: f64,
214 pub buy_volume: f64,
216 pub sell_volume: f64,
218 pub trades: u64,
220 pub vwap: f64,
222 pub open_timestamp: DateTime<Utc>,
224 pub close_timestamp: DateTime<Utc>,
226 pub timestamp: DateTime<Utc>,
228 pub local_timestamp: DateTime<Utc>,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize)]
235#[serde(rename_all = "camelCase")]
236pub struct DisconnectMsg {
237 pub exchange: TardisExchange,
239 pub local_timestamp: DateTime<Utc>,
241}
242
243#[allow(missing_docs)]
245#[derive(Debug, Clone, Serialize, Deserialize)]
246#[serde(rename_all = "snake_case", tag = "type")]
247pub enum WsMessage {
248 BookChange(BookChangeMsg),
249 BookSnapshot(BookSnapshotMsg),
250 Trade(TradeMsg),
251 TradeBar(BarMsg),
252 DerivativeTicker(DerivativeTickerMsg),
253 OptionSummary(OptionSummaryMsg),
254 Disconnect(DisconnectMsg),
255}
256
257#[derive(Debug, Deserialize)]
258struct RawBookLevel {
259 price: Option<f64>,
260 amount: Option<f64>,
261}
262
263fn deserialize_book_levels<'de, D>(deserializer: D) -> Result<Vec<BookLevel>, D::Error>
264where
265 D: Deserializer<'de>,
266{
267 Vec::<RawBookLevel>::deserialize(deserializer)?
268 .into_iter()
269 .filter_map(|level| match (level.price, level.amount) {
270 (Some(price), Some(amount)) => Some(Ok(BookLevel { price, amount })),
271 (None, None) => None,
272 (None, Some(_)) => Some(Err(D::Error::custom("book level missing price"))),
273 (Some(_), None) => Some(Err(D::Error::custom("book level missing amount"))),
274 })
275 .collect()
276}
277
278#[cfg(test)]
279mod tests {
280 use rstest::rstest;
281
282 use super::*;
283 use crate::common::testing::load_test_json;
284
285 #[rstest]
286 fn test_parse_book_change_message() {
287 let json_data = load_test_json("book_change.json");
288 let message: BookChangeMsg = serde_json::from_str(&json_data).unwrap();
289
290 assert_eq!(message.symbol, "XBTUSD");
291 assert_eq!(message.exchange, TardisExchange::Bitmex);
292 assert!(!message.is_snapshot);
293 assert!(message.bids.is_empty());
294 assert_eq!(message.asks.len(), 1);
295 assert_eq!(message.asks[0].price, 7_985.0);
296 assert_eq!(message.asks[0].amount, 283_318.0);
297 assert_eq!(
298 message.timestamp,
299 DateTime::parse_from_rfc3339("2019-10-23T11:29:53.469Z").unwrap()
300 );
301 assert_eq!(
302 message.local_timestamp,
303 DateTime::parse_from_rfc3339("2019-10-23T11:29:53.469Z").unwrap()
304 );
305 }
306
307 #[rstest]
308 fn test_parse_book_change_message_skips_empty_book_levels() {
309 let json_data = r#"{
310 "type": "book_change",
311 "symbol": "XBTUSD",
312 "exchange": "bitmex",
313 "isSnapshot": false,
314 "bids": [{"price": 7984, "amount": 100}, {}],
315 "asks": [{}],
316 "timestamp": "2019-10-23T11:29:53.469Z",
317 "localTimestamp": "2019-10-23T11:29:53.469Z"
318 }"#;
319
320 let message: BookChangeMsg = serde_json::from_str(json_data).unwrap();
321
322 assert_eq!(message.bids.len(), 1);
323 assert!(message.asks.is_empty());
324 assert_eq!(message.bids[0].price, 7_984.0);
325 assert_eq!(message.bids[0].amount, 100.0);
326 }
327
328 #[rstest]
329 #[case(r#"[{"price": 7984}]"#, "book level missing amount")]
330 #[case(r#"[{"amount": 100}]"#, "book level missing price")]
331 fn test_parse_book_change_message_rejects_partial_book_level(
332 #[case] bids: &str,
333 #[case] error_message: &str,
334 ) {
335 let json_data = format!(
336 r#"{{
337 "type": "book_change",
338 "symbol": "XBTUSD",
339 "exchange": "bitmex",
340 "isSnapshot": false,
341 "bids": {bids},
342 "asks": [],
343 "timestamp": "2019-10-23T11:29:53.469Z",
344 "localTimestamp": "2019-10-23T11:29:53.469Z"
345 }}"#
346 );
347
348 let error = serde_json::from_str::<BookChangeMsg>(&json_data).unwrap_err();
349
350 assert!(error.to_string().contains(error_message));
351 }
352
353 #[rstest]
354 fn test_parse_book_snapshot_message() {
355 let json_data = load_test_json("book_snapshot.json");
356 let message: BookSnapshotMsg = serde_json::from_str(&json_data).unwrap();
357
358 assert_eq!(message.symbol, "XBTUSD");
359 assert_eq!(message.exchange, TardisExchange::Bitmex);
360 assert_eq!(message.name, "book_snapshot_2_50ms");
361 assert_eq!(message.depth, 2);
362 assert_eq!(message.interval, 50);
363 assert_eq!(message.bids.len(), 2);
364 assert_eq!(message.asks.len(), 2);
365 assert_eq!(message.bids[0].price, 7_633.5);
366 assert_eq!(message.bids[0].amount, 1_906_067.0);
367 assert_eq!(message.asks[0].price, 7_634.0);
368 assert_eq!(message.asks[0].amount, 1_467_849.0);
369 assert_eq!(
370 message.timestamp,
371 DateTime::parse_from_rfc3339("2019-10-25T13:39:46.950Z").unwrap(),
372 );
373 assert_eq!(
374 message.local_timestamp,
375 DateTime::parse_from_rfc3339("2019-10-25T13:39:46.961Z").unwrap()
376 );
377 }
378
379 #[rstest]
380 fn test_parse_book_snapshot_message_skips_empty_book_levels() {
381 let json_data = r#"{
382 "type": "book_snapshot",
383 "symbol": "ETC",
384 "exchange": "hyperliquid",
385 "name": "book_snapshot_20_10s",
386 "depth": 20,
387 "interval": 10000,
388 "bids": [{"price": 20.002, "amount": 5.81}],
389 "asks": [{"price": 20.003, "amount": 162.45}, {}],
390 "timestamp": "2025-03-03T10:48:10.000Z",
391 "localTimestamp": "2025-03-03T10:48:10.596818Z"
392 }"#;
393
394 let message: BookSnapshotMsg = serde_json::from_str(json_data).unwrap();
395
396 assert_eq!(message.symbol, "ETC");
397 assert_eq!(message.exchange, TardisExchange::Hyperliquid);
398 assert_eq!(message.bids.len(), 1);
399 assert_eq!(message.asks.len(), 1);
400 assert_eq!(message.asks[0].price, 20.003);
401 assert_eq!(message.asks[0].amount, 162.45);
402 }
403
404 #[rstest]
405 fn test_parse_book_snapshot_message_rejects_partial_book_level() {
406 let json_data = r#"{
407 "type": "book_snapshot",
408 "symbol": "ETC",
409 "exchange": "hyperliquid",
410 "name": "book_snapshot_20_10s",
411 "depth": 20,
412 "interval": 10000,
413 "bids": [{"price": 20.002}],
414 "asks": [],
415 "timestamp": "2025-03-03T10:48:10.000Z",
416 "localTimestamp": "2025-03-03T10:48:10.596818Z"
417 }"#;
418
419 let error = serde_json::from_str::<BookSnapshotMsg>(json_data).unwrap_err();
420
421 assert!(error.to_string().contains("book level missing amount"));
422 }
423
424 #[rstest]
425 fn test_parse_trade_message() {
426 let json_data = load_test_json("trade.json");
427 let message: TradeMsg = serde_json::from_str(&json_data).unwrap();
428
429 assert_eq!(message.symbol, "XBTUSD");
430 assert_eq!(message.exchange, TardisExchange::Bitmex);
431 assert_eq!(
432 message.id,
433 Some("282a0445-0e3a-abeb-f403-11003204ea1b".to_string())
434 );
435 assert_eq!(message.price, 7_996.0);
436 assert_eq!(message.amount, 50.0);
437 assert_eq!(message.side, "sell");
438 assert_eq!(
439 message.timestamp,
440 DateTime::parse_from_rfc3339("2019-10-23T10:32:49.669Z").unwrap()
441 );
442 assert_eq!(
443 message.local_timestamp,
444 DateTime::parse_from_rfc3339("2019-10-23T10:32:49.740Z").unwrap()
445 );
446 }
447
448 #[rstest]
449 fn test_parse_derivative_ticker_message() {
450 let json_data = load_test_json("derivative_ticker.json");
451 let message: DerivativeTickerMsg = serde_json::from_str(&json_data).unwrap();
452
453 assert_eq!(message.symbol, "BTC-PERPETUAL");
454 assert_eq!(message.exchange, TardisExchange::Deribit);
455 assert_eq!(message.last_price, Some(7_987.5));
456 assert_eq!(message.open_interest, Some(84_129_491.0));
457 assert_eq!(message.funding_rate, Some(-0.00001568));
458 assert_eq!(message.index_price, Some(7_989.28));
459 assert_eq!(message.mark_price, Some(7_987.56));
460 assert_eq!(
461 message.timestamp,
462 DateTime::parse_from_rfc3339("2019-10-23T11:34:29.302Z").unwrap()
463 );
464 assert_eq!(
465 message.local_timestamp,
466 DateTime::parse_from_rfc3339("2019-10-23T11:34:29.416Z").unwrap()
467 );
468 }
469
470 #[rstest]
471 fn test_parse_option_summary_message() {
472 let json_data = load_test_json("option_summary.json");
473 let message: OptionSummaryMsg = serde_json::from_str(&json_data).unwrap();
474
475 assert_eq!(message.symbol, "BTC-28JUN24-70000-C");
476 assert_eq!(message.exchange, TardisExchange::Deribit);
477 assert_eq!(message.option_type, "call");
478 assert_eq!(message.strike_price, 70_000.0);
479 assert_eq!(message.best_bid_iv, Some(0.55));
480 assert_eq!(message.best_ask_iv, Some(0.58));
481 assert_eq!(message.mark_iv, Some(0.565));
482 assert_eq!(message.delta, Some(0.25));
483 assert_eq!(message.gamma, Some(0.000_02));
484 assert_eq!(message.vega, Some(45.5));
485 assert_eq!(message.theta, Some(-15.2));
486 assert_eq!(message.rho, Some(0.05));
487 assert_eq!(message.underlying_price, Some(63_500.0));
488 assert_eq!(message.underlying_index, "BTC-USD");
489 assert_eq!(message.open_interest, Some(150.0));
490 assert_eq!(
491 message.timestamp,
492 DateTime::parse_from_rfc3339("2024-01-15T10:30:00.123Z").unwrap()
493 );
494 assert_eq!(
495 message.local_timestamp,
496 DateTime::parse_from_rfc3339("2024-01-15T10:30:00.234Z").unwrap()
497 );
498 }
499
500 #[rstest]
501 fn test_parse_bar_message() {
502 let json_data = load_test_json("bar.json");
503 let message: BarMsg = serde_json::from_str(&json_data).unwrap();
504
505 assert_eq!(message.symbol, "XBTUSD");
506 assert_eq!(message.exchange, TardisExchange::Bitmex);
507 assert_eq!(message.name, "trade_bar_10000ms");
508 assert_eq!(message.interval, 10_000);
509 assert_eq!(message.open, 7_623.5);
510 assert_eq!(message.high, 7_623.5);
511 assert_eq!(message.low, 7_623.0);
512 assert_eq!(message.close, 7_623.5);
513 assert_eq!(message.volume, 37_034.0);
514 assert_eq!(message.buy_volume, 24_244.0);
515 assert_eq!(message.sell_volume, 12_790.0);
516 assert_eq!(message.trades, 9);
517 assert_eq!(message.vwap, 7_623.327320840309);
518 assert_eq!(
519 message.open_timestamp,
520 DateTime::parse_from_rfc3339("2019-10-25T13:11:31.574Z").unwrap()
521 );
522 assert_eq!(
523 message.close_timestamp,
524 DateTime::parse_from_rfc3339("2019-10-25T13:11:39.212Z").unwrap()
525 );
526 assert_eq!(
527 message.local_timestamp,
528 DateTime::parse_from_rfc3339("2019-10-25T13:11:40.369Z").unwrap()
529 );
530 assert_eq!(
531 message.timestamp,
532 DateTime::parse_from_rfc3339("2019-10-25T13:11:40.000Z").unwrap()
533 );
534 }
535
536 #[rstest]
537 fn test_parse_disconnect_message() {
538 let json_data = load_test_json("disconnect.json");
539 let message: DisconnectMsg = serde_json::from_str(&json_data).unwrap();
540
541 assert_eq!(message.exchange, TardisExchange::Deribit);
542 assert_eq!(
543 message.local_timestamp,
544 DateTime::parse_from_rfc3339("2019-10-23T11:34:29.416Z").unwrap()
545 );
546 }
547}