1use std::str::FromStr;
19
20use anyhow::Context;
21use nautilus_core::UnixNanos;
22use nautilus_model::{
23 data::{
24 Bar, BarType, BookOrder, InstrumentStatus, OrderBookDelta, OrderBookDeltas, QuoteTick,
25 TradeTick,
26 },
27 enums::{BookAction, LiquiditySide, MarketStatusAction, OrderSide, OrderStatus, RecordFlag},
28 identifiers::{AccountId, ClientOrderId, InstrumentId, TradeId, VenueOrderId},
29 instruments::{Instrument, InstrumentAny},
30 reports::{FillReport, OrderStatusReport},
31 types::{Money, Price, Quantity},
32};
33use rust_decimal::Decimal;
34use ustr::Ustr;
35
36use crate::{
37 common::enums::CoinbaseProductStatus,
38 http::parse::{
39 coinbase_side_to_aggressor, parse_epoch_secs_timestamp, parse_order_side,
40 parse_order_side_optional, parse_order_status, parse_order_type, parse_price,
41 parse_quantity, parse_rfc3339_timestamp, parse_time_in_force,
42 },
43 websocket::messages::{
44 WsBookSide, WsCandle, WsL2DataEvent, WsL2Update, WsOrderUpdate, WsStatusProduct, WsTicker,
45 WsTrade,
46 },
47};
48
49pub fn parse_ws_trade(
51 trade: &WsTrade,
52 instrument: &InstrumentAny,
53 ts_init: UnixNanos,
54) -> anyhow::Result<TradeTick> {
55 let price = parse_price(&trade.price, instrument.price_precision())?;
56 let size = parse_quantity(&trade.size, instrument.size_precision())?;
57 let aggressor_side = coinbase_side_to_aggressor(&trade.side);
58 let trade_id = TradeId::new(&trade.trade_id);
59 let ts_event = parse_rfc3339_timestamp(&trade.time)?;
60
61 TradeTick::new_checked(
62 instrument.id(),
63 price,
64 size,
65 aggressor_side,
66 trade_id,
67 ts_event,
68 ts_init,
69 )
70}
71
72pub fn parse_ws_ticker(
74 ticker: &WsTicker,
75 instrument: &InstrumentAny,
76 ts_event: UnixNanos,
77 ts_init: UnixNanos,
78) -> anyhow::Result<QuoteTick> {
79 let bid_price = parse_price(&ticker.best_bid, instrument.price_precision())?;
80 let ask_price = parse_price(&ticker.best_ask, instrument.price_precision())?;
81 let bid_size = parse_quantity(&ticker.best_bid_quantity, instrument.size_precision())?;
82 let ask_size = parse_quantity(&ticker.best_ask_quantity, instrument.size_precision())?;
83
84 QuoteTick::new_checked(
85 instrument.id(),
86 bid_price,
87 ask_price,
88 bid_size,
89 ask_size,
90 ts_event,
91 ts_init,
92 )
93}
94
95pub fn parse_ws_candle(
97 candle: &WsCandle,
98 bar_type: BarType,
99 instrument: &InstrumentAny,
100 ts_init: UnixNanos,
101) -> anyhow::Result<Bar> {
102 let open = parse_price(&candle.open, instrument.price_precision())?;
103 let high = parse_price(&candle.high, instrument.price_precision())?;
104 let low = parse_price(&candle.low, instrument.price_precision())?;
105 let close = parse_price(&candle.close, instrument.price_precision())?;
106 let volume = parse_quantity(&candle.volume, instrument.size_precision())?;
107 let ts_event = parse_epoch_secs_timestamp(&candle.start)?;
108
109 Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
110}
111
112pub fn parse_ws_l2_snapshot(
118 event: &WsL2DataEvent,
119 instrument: &InstrumentAny,
120 ts_event: UnixNanos,
121 ts_init: UnixNanos,
122) -> anyhow::Result<OrderBookDeltas> {
123 let instrument_id = instrument.id();
124
125 let total = event.updates.len();
126 let mut deltas = Vec::with_capacity(total + 1);
127
128 let mut clear = OrderBookDelta::clear(instrument_id, 0, ts_event, ts_init);
129 clear.flags |= RecordFlag::F_SNAPSHOT as u8;
130
131 if total == 0 {
132 clear.flags |= RecordFlag::F_LAST as u8;
133 }
134 deltas.push(clear);
135
136 for (i, update) in event.updates.iter().enumerate() {
137 let is_last = i == total - 1;
138 let delta = parse_l2_delta(
139 update,
140 instrument_id,
141 instrument.price_precision(),
142 instrument.size_precision(),
143 is_last,
144 ts_event,
145 ts_init,
146 )?;
147 deltas.push(delta);
148 }
149
150 OrderBookDeltas::new_checked(instrument_id, deltas)
151}
152
153pub fn parse_ws_l2_update(
159 event: &WsL2DataEvent,
160 instrument: &InstrumentAny,
161 ts_event: UnixNanos,
162 ts_init: UnixNanos,
163) -> anyhow::Result<OrderBookDeltas> {
164 let instrument_id = instrument.id();
165 let total = event.updates.len();
166 let mut deltas = Vec::with_capacity(total);
167
168 for (i, update) in event.updates.iter().enumerate() {
169 let is_last = i == total - 1;
170 let price = parse_price(&update.price_level, instrument.price_precision())?;
171 let size = parse_quantity(&update.new_quantity, instrument.size_precision())?;
172 let side = ws_book_side_to_order_side(update.side);
173
174 let action = if size == Quantity::zero(instrument.size_precision()) {
175 BookAction::Delete
176 } else {
177 BookAction::Update
178 };
179
180 let mut flags = RecordFlag::F_MBP as u8;
181
182 if is_last {
183 flags |= RecordFlag::F_LAST as u8;
184 }
185
186 let order = BookOrder::new(side, price, size, 0);
187 let delta =
188 OrderBookDelta::new_checked(instrument_id, action, order, flags, 0, ts_event, ts_init)?;
189 deltas.push(delta);
190 }
191
192 OrderBookDeltas::new_checked(instrument_id, deltas)
193}
194
195fn parse_l2_delta(
197 update: &WsL2Update,
198 instrument_id: InstrumentId,
199 price_precision: u8,
200 size_precision: u8,
201 is_last: bool,
202 ts_event: UnixNanos,
203 ts_init: UnixNanos,
204) -> anyhow::Result<OrderBookDelta> {
205 let price = parse_price(&update.price_level, price_precision)?;
206 let size = parse_quantity(&update.new_quantity, size_precision)?;
207 let side = ws_book_side_to_order_side(update.side);
208
209 let mut flags = RecordFlag::F_MBP as u8 | RecordFlag::F_SNAPSHOT as u8;
210
211 if is_last {
212 flags |= RecordFlag::F_LAST as u8;
213 }
214
215 let order = BookOrder::new(side, price, size, 0);
216 OrderBookDelta::new_checked(
217 instrument_id,
218 BookAction::Add,
219 order,
220 flags,
221 0,
222 ts_event,
223 ts_init,
224 )
225}
226
227fn ws_book_side_to_order_side(side: WsBookSide) -> OrderSide {
229 match side {
230 WsBookSide::Bid => OrderSide::Buy,
231 WsBookSide::Offer => OrderSide::Sell,
232 }
233}
234
235pub fn parse_ws_user_event_to_order_status_report(
246 update: &WsOrderUpdate,
247 instrument: &InstrumentAny,
248 account_id: AccountId,
249 ts_event: UnixNanos,
250 ts_init: UnixNanos,
251) -> anyhow::Result<OrderStatusReport> {
252 let instrument_id = instrument.id();
253 let size_precision = instrument.size_precision();
254
255 let order_side = parse_order_side_optional(&update.order_side);
256 let order_type = parse_order_type(update.order_type);
257 let time_in_force = parse_time_in_force(Some(update.time_in_force));
258 let mut order_status = parse_order_status(update.status);
259
260 let venue_order_id = VenueOrderId::new(&update.order_id);
261 let client_order_id = if update.client_order_id.is_empty() {
262 None
263 } else {
264 Some(ClientOrderId::new(&update.client_order_id))
265 };
266
267 let filled_qty = if update.cumulative_quantity.is_empty() {
268 Quantity::zero(size_precision)
269 } else {
270 parse_quantity(&update.cumulative_quantity, size_precision)
271 .context("failed to parse cumulative_quantity")?
272 };
273 let leaves_qty = if update.leaves_quantity.is_empty() {
274 Quantity::zero(size_precision)
275 } else {
276 parse_quantity(&update.leaves_quantity, size_precision)
277 .context("failed to parse leaves_quantity")?
278 };
279
280 let quantity = filled_qty + leaves_qty;
281
282 if order_status == OrderStatus::Accepted && filled_qty.is_positive() && filled_qty < quantity {
283 order_status = OrderStatus::PartiallyFilled;
284 }
285
286 let ts_accepted = if update.creation_time.is_empty() {
287 ts_event
288 } else {
289 parse_rfc3339_timestamp(&update.creation_time).unwrap_or(ts_event)
290 };
291
292 let mut report = OrderStatusReport::new(
293 account_id,
294 instrument_id,
295 client_order_id,
296 venue_order_id,
297 order_side,
298 order_type,
299 time_in_force,
300 order_status,
301 quantity,
302 filled_qty,
303 ts_accepted,
304 ts_event,
305 ts_init,
306 None,
307 );
308
309 if !update.avg_price.is_empty()
310 && let Ok(avg_decimal) = Decimal::from_str(&update.avg_price)
311 && avg_decimal.is_sign_positive()
312 && !avg_decimal.is_zero()
313 {
314 report = report.with_avg_px(avg_decimal);
315 }
316
317 Ok(report)
318}
319
320#[allow(clippy::too_many_arguments)]
332pub fn parse_ws_user_event_to_fill_report(
333 update: &WsOrderUpdate,
334 last_qty: Quantity,
335 last_px: Price,
336 commission: Money,
337 trade_id: TradeId,
338 instrument: &InstrumentAny,
339 account_id: AccountId,
340 liquidity_side: LiquiditySide,
341 ts_event: UnixNanos,
342 ts_init: UnixNanos,
343) -> anyhow::Result<FillReport> {
344 let instrument_id = instrument.id();
345
346 let venue_order_id = VenueOrderId::new(&update.order_id);
347 let client_order_id = if update.client_order_id.is_empty() {
348 None
349 } else {
350 Some(ClientOrderId::new(&update.client_order_id))
351 };
352 let order_side = parse_order_side(&update.order_side)?;
353
354 Ok(FillReport::new(
355 account_id,
356 instrument_id,
357 venue_order_id,
358 trade_id,
359 order_side,
360 last_qty,
361 last_px,
362 commission,
363 liquidity_side,
364 client_order_id,
365 None,
366 ts_event,
367 ts_init,
368 None,
369 ))
370}
371
372pub fn parse_ws_status_product(
378 product: &WsStatusProduct,
379 instrument_id: InstrumentId,
380 ts_event: UnixNanos,
381 ts_init: UnixNanos,
382) -> Option<InstrumentStatus> {
383 let action = match product.status {
384 CoinbaseProductStatus::Online => MarketStatusAction::Trading,
385 CoinbaseProductStatus::Offline => MarketStatusAction::Halt,
386 CoinbaseProductStatus::Delisted => MarketStatusAction::Close,
387 CoinbaseProductStatus::Unset | CoinbaseProductStatus::Unknown => return None,
390 };
391 let reason = if product.status_message.is_empty() {
392 None
393 } else {
394 Some(Ustr::from(&product.status_message))
395 };
396 let is_trading = Some(matches!(action, MarketStatusAction::Trading));
397 Some(InstrumentStatus::new(
398 instrument_id,
399 action,
400 ts_event,
401 ts_init,
402 reason,
403 None,
404 is_trading,
405 None,
406 None,
407 ))
408}
409
410#[cfg(test)]
411mod tests {
412 use std::str::FromStr;
413
414 use nautilus_model::{
415 data::bar::BarSpecification,
416 enums::{AggregationSource, AggressorSide, BarAggregation, PriceType},
417 identifiers::Symbol,
418 instruments::CurrencyPair,
419 types::{Currency, Price},
420 };
421 use rstest::rstest;
422
423 use super::*;
424 use crate::{
425 common::{consts::COINBASE_VENUE, testing::load_test_fixture},
426 websocket::messages::{CoinbaseWsMessage, WsEventType},
427 };
428
429 fn test_instrument() -> InstrumentAny {
430 let instrument_id = InstrumentId::new(Symbol::new("BTC-USD"), *COINBASE_VENUE);
431 let raw_symbol = Symbol::new("BTC-USD");
432 let base_currency = Currency::get_or_create_crypto("BTC");
433 let quote_currency = Currency::get_or_create_crypto("USD");
434
435 InstrumentAny::CurrencyPair(
436 CurrencyPair::builder()
437 .instrument_id(instrument_id)
438 .raw_symbol(raw_symbol)
439 .base_currency(base_currency)
440 .quote_currency(quote_currency)
441 .price_precision(2)
442 .size_precision(8)
443 .price_increment(Price::from("0.01"))
444 .size_increment(Quantity::from("0.00000001"))
445 .min_quantity(Quantity::from("0.00000001"))
446 .ts_event(UnixNanos::default())
447 .ts_init(UnixNanos::default())
448 .build()
449 .unwrap(),
450 )
451 }
452
453 #[rstest]
454 fn test_parse_ws_trade() {
455 let json = load_test_fixture("ws_market_trades.json");
456 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
457 let instrument = test_instrument();
458 let ts_init = UnixNanos::default();
459
460 match msg {
461 CoinbaseWsMessage::MarketTrades { events, .. } => {
462 let trade_data = &events[0].trades[0];
463 let tick = parse_ws_trade(trade_data, &instrument, ts_init).unwrap();
464
465 assert_eq!(tick.instrument_id, instrument.id());
466 assert_eq!(tick.price, Price::from("68900.50"));
467 assert_eq!(tick.size, Quantity::from("0.00150000"));
468 assert_eq!(tick.aggressor_side, AggressorSide::Buy);
469 assert_eq!(tick.trade_id.as_str(), "995098700");
470 assert!(tick.ts_event.as_u64() > 0);
471 }
472 _ => panic!("Expected MarketTrades"),
473 }
474 }
475
476 #[rstest]
477 fn test_parse_ws_trade_sell_side() {
478 let json = load_test_fixture("ws_market_trades.json");
479 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
480 let instrument = test_instrument();
481 let ts_init = UnixNanos::default();
482
483 match msg {
484 CoinbaseWsMessage::MarketTrades { events, .. } => {
485 let trade_data = &events[0].trades[1];
486 let tick = parse_ws_trade(trade_data, &instrument, ts_init).unwrap();
487
488 assert_eq!(tick.aggressor_side, AggressorSide::Sell);
489 assert_eq!(tick.price, Price::from("68900.00"));
490 assert_eq!(tick.size, Quantity::from("0.05000000"));
491 }
492 _ => panic!("Expected MarketTrades"),
493 }
494 }
495
496 #[rstest]
497 fn test_parse_ws_ticker() {
498 let json = load_test_fixture("ws_ticker.json");
499 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
500 let instrument = test_instrument();
501 let ts_init = UnixNanos::default();
502
503 match msg {
504 CoinbaseWsMessage::Ticker {
505 timestamp, events, ..
506 } => {
507 let ticker_data = &events[0].tickers[0];
508 let ts_event = parse_rfc3339_timestamp(×tamp).unwrap();
509 let quote = parse_ws_ticker(ticker_data, &instrument, ts_event, ts_init).unwrap();
510
511 assert_eq!(quote.instrument_id, instrument.id());
512 assert_eq!(quote.bid_price, Price::from("68900.00"));
513 assert_eq!(quote.ask_price, Price::from("68901.00"));
514 assert_eq!(quote.bid_size, Quantity::from("1.50000000"));
515 assert_eq!(quote.ask_size, Quantity::from("0.50000000"));
516 }
517 _ => panic!("Expected Ticker"),
518 }
519 }
520
521 #[rstest]
522 fn test_parse_ws_candle() {
523 let json = load_test_fixture("ws_candles.json");
524 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
525 let instrument = test_instrument();
526 let ts_init = UnixNanos::default();
527
528 let bar_spec = BarSpecification::new(5, BarAggregation::Minute, PriceType::Last);
529 let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::External);
530
531 match msg {
532 CoinbaseWsMessage::Candles { events, .. } => {
533 let candle_data = &events[0].candles[0];
534 let bar = parse_ws_candle(candle_data, bar_type, &instrument, ts_init).unwrap();
535
536 assert_eq!(bar.bar_type, bar_type);
537 assert_eq!(bar.open, Price::from("68900.00"));
538 assert_eq!(bar.high, Price::from("68950.00"));
539 assert_eq!(bar.low, Price::from("68850.00"));
540 assert_eq!(bar.close, Price::from("68920.50"));
541 assert_eq!(bar.volume, Quantity::from("42.15000000"));
542 assert_eq!(bar.ts_event.as_u64(), 1_775_521_800_000_000_000);
543 }
544 _ => panic!("Expected Candles"),
545 }
546 }
547
548 #[rstest]
549 fn test_parse_ws_l2_snapshot() {
550 let json = load_test_fixture("ws_l2_data_snapshot.json");
551 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
552 let instrument = test_instrument();
553 let ts_init = UnixNanos::default();
554
555 match msg {
556 CoinbaseWsMessage::L2Data {
557 timestamp, events, ..
558 } => {
559 let event = &events[0];
560 assert_eq!(event.event_type, WsEventType::Snapshot);
561 let ts_event = parse_rfc3339_timestamp(×tamp).unwrap();
562
563 let deltas = parse_ws_l2_snapshot(event, &instrument, ts_event, ts_init).unwrap();
564 assert_eq!(deltas.instrument_id, instrument.id());
565 for delta in &deltas.deltas {
566 assert_eq!(delta.ts_event, ts_event);
567 }
568
569 assert_eq!(deltas.deltas.len(), 7);
571
572 assert_eq!(deltas.deltas[0].action, BookAction::Clear);
574
575 assert_eq!(deltas.deltas[1].order.side, OrderSide::Buy.into());
577 assert_eq!(deltas.deltas[1].order.price, Price::from("68900.00"));
578 assert_eq!(deltas.deltas[1].order.size, Quantity::from("1.50000000"));
579
580 assert_eq!(deltas.deltas[4].order.side, OrderSide::Sell.into());
582 assert_eq!(deltas.deltas[4].order.price, Price::from("68901.00"));
583
584 let last = deltas.deltas.last().unwrap();
586 assert_ne!(last.flags & RecordFlag::F_LAST as u8, 0);
587
588 for delta in &deltas.deltas {
590 assert_ne!(
591 delta.flags & RecordFlag::F_SNAPSHOT as u8,
592 0,
593 "snapshot delta missing F_SNAPSHOT: {delta:?}",
594 );
595 }
596 }
597 _ => panic!("Expected L2Data"),
598 }
599 }
600
601 #[rstest]
605 fn test_parse_ws_l2_snapshot_empty_book_clear_carries_snapshot_and_last() {
606 let event = WsL2DataEvent {
607 event_type: WsEventType::Snapshot,
608 product_id: Ustr::from("BTC-USD"),
609 updates: Vec::new(),
610 };
611 let instrument = test_instrument();
612 let ts_event = UnixNanos::from(1);
613 let ts_init = UnixNanos::from(2);
614
615 let deltas = parse_ws_l2_snapshot(&event, &instrument, ts_event, ts_init).unwrap();
616 assert_eq!(deltas.deltas.len(), 1);
617 let clear = &deltas.deltas[0];
618 assert_eq!(clear.action, BookAction::Clear);
619 assert_ne!(clear.flags & RecordFlag::F_SNAPSHOT as u8, 0);
620 assert_ne!(clear.flags & RecordFlag::F_LAST as u8, 0);
621 }
622
623 #[rstest]
627 fn test_parse_ws_l2_update_omits_snapshot_flag() {
628 let json = load_test_fixture("ws_l2_data_update.json");
629 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
630 let instrument = test_instrument();
631 let ts_init = UnixNanos::default();
632
633 match msg {
634 CoinbaseWsMessage::L2Data {
635 timestamp, events, ..
636 } => {
637 let event = &events[0];
638 let ts_event = parse_rfc3339_timestamp(×tamp).unwrap();
639 let deltas = parse_ws_l2_update(event, &instrument, ts_event, ts_init).unwrap();
640
641 for delta in &deltas.deltas {
642 assert_eq!(
643 delta.flags & RecordFlag::F_SNAPSHOT as u8,
644 0,
645 "update delta must not carry F_SNAPSHOT: {delta:?}",
646 );
647 }
648 }
649 _ => panic!("Expected L2Data"),
650 }
651 }
652
653 #[rstest]
654 fn test_parse_ws_l2_update() {
655 let json = load_test_fixture("ws_l2_data_update.json");
656 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
657 let instrument = test_instrument();
658 let ts_init = UnixNanos::default();
659
660 match msg {
661 CoinbaseWsMessage::L2Data {
662 timestamp, events, ..
663 } => {
664 let event = &events[0];
665 assert_eq!(event.event_type, WsEventType::Update);
666 let ts_event = parse_rfc3339_timestamp(×tamp).unwrap();
667
668 let deltas = parse_ws_l2_update(event, &instrument, ts_event, ts_init).unwrap();
669 assert_eq!(deltas.deltas.len(), 2);
670 for delta in &deltas.deltas {
671 assert_eq!(delta.ts_event, ts_event);
672 }
673
674 assert_eq!(deltas.deltas[0].order.side, OrderSide::Buy.into());
676 assert_eq!(deltas.deltas[0].order.price, Price::from("68900.00"));
677 assert_eq!(deltas.deltas[0].order.size, Quantity::from("2.00000000"));
678 assert_eq!(deltas.deltas[0].action, BookAction::Update);
679
680 assert_eq!(deltas.deltas[1].order.side, OrderSide::Sell.into());
682 assert_eq!(deltas.deltas[1].action, BookAction::Delete);
683 assert_eq!(deltas.deltas[1].order.size, Quantity::from("0.00000000"));
684
685 assert_ne!(deltas.deltas[1].flags & RecordFlag::F_LAST as u8, 0);
687 }
688 _ => panic!("Expected L2Data"),
689 }
690 }
691
692 #[rstest]
693 fn test_parse_ws_l2_update_zero_quantity_is_delete() {
694 let json = load_test_fixture("ws_l2_data_update.json");
695 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
696 let instrument = test_instrument();
697 let ts_init = UnixNanos::default();
698
699 match msg {
700 CoinbaseWsMessage::L2Data {
701 timestamp, events, ..
702 } => {
703 let event = &events[0];
704 let ts_event = parse_rfc3339_timestamp(×tamp).unwrap();
705 let deltas = parse_ws_l2_update(event, &instrument, ts_event, ts_init).unwrap();
706
707 let delete_delta = deltas
709 .deltas
710 .iter()
711 .find(|d| d.action == BookAction::Delete)
712 .expect("should have a delete action for zero quantity");
713 assert_eq!(delete_delta.order.side, OrderSide::Sell.into());
714 assert_eq!(delete_delta.ts_event, ts_event);
715 }
716 _ => panic!("Expected L2Data"),
717 }
718 }
719
720 #[rstest]
721 fn test_ws_book_side_conversion() {
722 assert_eq!(ws_book_side_to_order_side(WsBookSide::Bid), OrderSide::Buy,);
723 assert_eq!(
724 ws_book_side_to_order_side(WsBookSide::Offer),
725 OrderSide::Sell
726 );
727 }
728
729 #[rstest]
730 fn test_parse_ws_user_event_to_order_status_report_open() {
731 let json = load_test_fixture("ws_user.json");
732 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
733 let instrument = test_instrument();
734 let account_id = AccountId::new("COINBASE-001");
735 let ts_event = UnixNanos::from(1_705_314_600_000_000_000u64);
736 let ts_init = UnixNanos::from(1_705_314_700_000_000_000u64);
737
738 let order = match msg {
739 CoinbaseWsMessage::User { events, .. } => events[0].orders[0].clone(),
740 other => panic!("expected User, was {other:?}"),
741 };
742
743 let report = parse_ws_user_event_to_order_status_report(
744 &order,
745 &instrument,
746 account_id,
747 ts_event,
748 ts_init,
749 )
750 .unwrap();
751
752 assert_eq!(report.account_id, account_id);
753 assert_eq!(report.instrument_id, instrument.id());
754 assert_eq!(
755 report.venue_order_id.as_str(),
756 "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
757 );
758 assert_eq!(
759 report.client_order_id.unwrap().as_str(),
760 "11111-000000-000001"
761 );
762 assert_eq!(report.order_side, OrderSide::Buy.into());
763 assert_eq!(report.order_status, OrderStatus::Accepted);
764 assert_eq!(report.filled_qty, Quantity::from("0.00000000"));
765 assert_eq!(report.quantity, Quantity::from("0.00100000"));
766 assert_eq!(report.ts_init, ts_init);
767 }
768
769 #[rstest]
770 fn test_parse_ws_user_event_to_order_status_report_promotes_partial_fill() {
771 let mut update = WsOrderUpdate {
772 order_id: "venue-1".to_string(),
773 client_order_id: "client-1".to_string(),
774 contract_expiry_type: crate::common::enums::CoinbaseContractExpiryType::Unknown,
775 cumulative_quantity: "0.5".to_string(),
776 leaves_quantity: "0.5".to_string(),
777 avg_price: "100.00".to_string(),
778 total_fees: "0.05".to_string(),
779 status: crate::common::enums::CoinbaseOrderStatus::Open,
780 product_id: ustr::Ustr::from("BTC-USD"),
781 product_type: crate::common::enums::CoinbaseProductType::Spot,
782 creation_time: String::new(),
783 order_side: crate::common::enums::CoinbaseOrderSide::Buy,
784 order_type: crate::common::enums::CoinbaseOrderType::Limit,
785 risk_managed_by: crate::common::enums::CoinbaseRiskManagedBy::Unknown,
786 time_in_force: crate::common::enums::CoinbaseTimeInForce::GoodUntilCancelled,
787 trigger_status: crate::common::enums::CoinbaseTriggerStatus::InvalidOrderType,
788 cancel_reason: String::new(),
789 reject_reason: String::new(),
790 total_value_after_fees: String::new(),
791 };
792 update.creation_time = String::new();
793
794 let instrument = test_instrument();
795 let report = parse_ws_user_event_to_order_status_report(
796 &update,
797 &instrument,
798 AccountId::new("COINBASE-001"),
799 UnixNanos::default(),
800 UnixNanos::default(),
801 )
802 .unwrap();
803
804 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
806 assert_eq!(report.filled_qty, Quantity::from("0.50000000"));
807 assert_eq!(report.quantity, Quantity::from("1.00000000"));
808 }
809
810 #[rstest]
811 fn test_parse_ws_user_event_to_fill_report_uses_supplied_last_px_and_commission() {
812 let update = WsOrderUpdate {
813 order_id: "venue-1".to_string(),
814 client_order_id: "client-1".to_string(),
815 contract_expiry_type: crate::common::enums::CoinbaseContractExpiryType::Unknown,
816 cumulative_quantity: "0.5".to_string(),
817 leaves_quantity: "0.5".to_string(),
818 avg_price: "100.00".to_string(),
819 total_fees: "0.05".to_string(),
820 status: crate::common::enums::CoinbaseOrderStatus::Open,
821 product_id: ustr::Ustr::from("BTC-USD"),
822 product_type: crate::common::enums::CoinbaseProductType::Spot,
823 creation_time: String::new(),
824 order_side: crate::common::enums::CoinbaseOrderSide::Sell,
825 order_type: crate::common::enums::CoinbaseOrderType::Limit,
826 risk_managed_by: crate::common::enums::CoinbaseRiskManagedBy::Unknown,
827 time_in_force: crate::common::enums::CoinbaseTimeInForce::GoodUntilCancelled,
828 trigger_status: crate::common::enums::CoinbaseTriggerStatus::InvalidOrderType,
829 cancel_reason: String::new(),
830 reject_reason: String::new(),
831 total_value_after_fees: String::new(),
832 };
833
834 let instrument = test_instrument();
835 let usd = Currency::USD();
836 let last_px = Price::from("120.00");
837 let commission =
838 Money::from_decimal(rust_decimal::Decimal::from_str("0.10").unwrap(), usd).unwrap();
839 let trade_id = TradeId::new("venue-1-0.5");
840
841 let report = parse_ws_user_event_to_fill_report(
842 &update,
843 Quantity::from("0.50000000"),
844 last_px,
845 commission,
846 trade_id,
847 &instrument,
848 AccountId::new("COINBASE-001"),
849 LiquiditySide::Maker,
850 UnixNanos::default(),
851 UnixNanos::default(),
852 )
853 .unwrap();
854
855 assert_eq!(report.venue_order_id.as_str(), "venue-1");
856 assert_eq!(report.client_order_id.unwrap().as_str(), "client-1");
857 assert_eq!(report.order_side, OrderSide::Sell);
858 assert_eq!(report.last_qty, Quantity::from("0.50000000"));
859 assert_eq!(report.last_px, Price::from("120.00"));
860 assert_eq!(report.commission, commission);
861 assert_eq!(report.liquidity_side, LiquiditySide::Maker);
862 assert_eq!(report.trade_id, trade_id);
863 }
864
865 fn make_status_product(status: CoinbaseProductStatus, message: &str) -> WsStatusProduct {
866 WsStatusProduct {
867 product_type: crate::common::enums::CoinbaseProductType::Spot,
868 id: Ustr::from("BTC-USD"),
869 base_currency: Ustr::from("BTC"),
870 quote_currency: Ustr::from("USD"),
871 base_increment: "0.00000001".to_string(),
872 quote_increment: "0.01".to_string(),
873 display_name: "BTC/USD".to_string(),
874 status,
875 status_message: message.to_string(),
876 min_market_funds: Decimal::ONE,
877 }
878 }
879
880 #[rstest]
881 #[case::online(
882 CoinbaseProductStatus::Online,
883 "",
884 Some(MarketStatusAction::Trading),
885 Some(true),
886 None
887 )]
888 #[case::offline_with_reason(
889 CoinbaseProductStatus::Offline,
890 "maintenance",
891 Some(MarketStatusAction::Halt),
892 Some(false),
893 Some("maintenance")
894 )]
895 #[case::delisted(
896 CoinbaseProductStatus::Delisted,
897 "",
898 Some(MarketStatusAction::Close),
899 Some(false),
900 None
901 )]
902 #[case::unset_skipped(CoinbaseProductStatus::Unset, "", None, None, None)]
903 fn test_parse_ws_status_product(
904 #[case] status: CoinbaseProductStatus,
905 #[case] message: &str,
906 #[case] expected_action: Option<MarketStatusAction>,
907 #[case] expected_is_trading: Option<bool>,
908 #[case] expected_reason: Option<&str>,
909 ) {
910 let product = make_status_product(status, message);
911 let instrument_id = InstrumentId::new(Symbol::new("BTC-USD"), *COINBASE_VENUE);
912 let result = parse_ws_status_product(
913 &product,
914 instrument_id,
915 UnixNanos::from(1),
916 UnixNanos::from(2),
917 );
918
919 match expected_action {
920 Some(action) => {
921 let status = result.expect("expected InstrumentStatus");
922 assert_eq!(status.instrument_id, instrument_id);
923 assert_eq!(status.action, action);
924 assert_eq!(status.is_trading, expected_is_trading);
925 assert_eq!(
926 status.reason.map(|s| s.to_string()),
927 expected_reason.map(|s| s.to_string()),
928 );
929 assert_eq!(status.ts_event, UnixNanos::from(1));
930 assert_eq!(status.ts_init, UnixNanos::from(2));
931 }
932 None => assert!(result.is_none(), "expected None for unset status"),
933 }
934 }
935}