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, prelude::ToPrimitive};
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_status, parse_order_type, parse_price, parse_quantity, parse_rfc3339_timestamp,
41 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(&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.to_f64().unwrap_or_default())?;
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) -> 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 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(CurrencyPair::new(
436 instrument_id,
437 raw_symbol,
438 base_currency,
439 quote_currency,
440 2,
441 8,
442 Price::from("0.01"),
443 Quantity::from("0.00000001"),
444 None,
445 None,
446 None,
447 Some(Quantity::from("0.00000001")),
448 None,
449 None,
450 None,
451 None,
452 None,
453 None,
454 None,
455 None,
456 None,
457 None,
458 UnixNanos::default(),
459 UnixNanos::default(),
460 ))
461 }
462
463 #[rstest]
464 fn test_parse_ws_trade() {
465 let json = load_test_fixture("ws_market_trades.json");
466 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
467 let instrument = test_instrument();
468 let ts_init = UnixNanos::default();
469
470 match msg {
471 CoinbaseWsMessage::MarketTrades { events, .. } => {
472 let trade_data = &events[0].trades[0];
473 let tick = parse_ws_trade(trade_data, &instrument, ts_init).unwrap();
474
475 assert_eq!(tick.instrument_id, instrument.id());
476 assert_eq!(tick.price, Price::from("68900.50"));
477 assert_eq!(tick.size, Quantity::from("0.00150000"));
478 assert_eq!(tick.aggressor_side, AggressorSide::Buyer);
479 assert_eq!(tick.trade_id.as_str(), "995098700");
480 assert!(tick.ts_event.as_u64() > 0);
481 }
482 _ => panic!("Expected MarketTrades"),
483 }
484 }
485
486 #[rstest]
487 fn test_parse_ws_trade_sell_side() {
488 let json = load_test_fixture("ws_market_trades.json");
489 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
490 let instrument = test_instrument();
491 let ts_init = UnixNanos::default();
492
493 match msg {
494 CoinbaseWsMessage::MarketTrades { events, .. } => {
495 let trade_data = &events[0].trades[1];
496 let tick = parse_ws_trade(trade_data, &instrument, ts_init).unwrap();
497
498 assert_eq!(tick.aggressor_side, AggressorSide::Seller);
499 assert_eq!(tick.price, Price::from("68900.00"));
500 assert_eq!(tick.size, Quantity::from("0.05000000"));
501 }
502 _ => panic!("Expected MarketTrades"),
503 }
504 }
505
506 #[rstest]
507 fn test_parse_ws_ticker() {
508 let json = load_test_fixture("ws_ticker.json");
509 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
510 let instrument = test_instrument();
511 let ts_init = UnixNanos::default();
512
513 match msg {
514 CoinbaseWsMessage::Ticker {
515 timestamp, events, ..
516 } => {
517 let ticker_data = &events[0].tickers[0];
518 let ts_event = parse_rfc3339_timestamp(×tamp).unwrap();
519 let quote = parse_ws_ticker(ticker_data, &instrument, ts_event, ts_init).unwrap();
520
521 assert_eq!(quote.instrument_id, instrument.id());
522 assert_eq!(quote.bid_price, Price::from("68900.00"));
523 assert_eq!(quote.ask_price, Price::from("68901.00"));
524 assert_eq!(quote.bid_size, Quantity::from("1.50000000"));
525 assert_eq!(quote.ask_size, Quantity::from("0.50000000"));
526 }
527 _ => panic!("Expected Ticker"),
528 }
529 }
530
531 #[rstest]
532 fn test_parse_ws_candle() {
533 let json = load_test_fixture("ws_candles.json");
534 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
535 let instrument = test_instrument();
536 let ts_init = UnixNanos::default();
537
538 let bar_spec = BarSpecification::new(5, BarAggregation::Minute, PriceType::Last);
539 let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::External);
540
541 match msg {
542 CoinbaseWsMessage::Candles { events, .. } => {
543 let candle_data = &events[0].candles[0];
544 let bar = parse_ws_candle(candle_data, bar_type, &instrument, ts_init).unwrap();
545
546 assert_eq!(bar.bar_type, bar_type);
547 assert_eq!(bar.open, Price::from("68900.00"));
548 assert_eq!(bar.high, Price::from("68950.00"));
549 assert_eq!(bar.low, Price::from("68850.00"));
550 assert_eq!(bar.close, Price::from("68920.50"));
551 assert_eq!(bar.volume, Quantity::from("42.15000000"));
552 assert_eq!(bar.ts_event.as_u64(), 1_775_521_800_000_000_000);
553 }
554 _ => panic!("Expected Candles"),
555 }
556 }
557
558 #[rstest]
559 fn test_parse_ws_l2_snapshot() {
560 let json = load_test_fixture("ws_l2_data_snapshot.json");
561 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
562 let instrument = test_instrument();
563 let ts_init = UnixNanos::default();
564
565 match msg {
566 CoinbaseWsMessage::L2Data {
567 timestamp, events, ..
568 } => {
569 let event = &events[0];
570 assert_eq!(event.event_type, WsEventType::Snapshot);
571 let ts_event = parse_rfc3339_timestamp(×tamp).unwrap();
572
573 let deltas = parse_ws_l2_snapshot(event, &instrument, ts_event, ts_init).unwrap();
574 assert_eq!(deltas.instrument_id, instrument.id());
575 for delta in &deltas.deltas {
576 assert_eq!(delta.ts_event, ts_event);
577 }
578
579 assert_eq!(deltas.deltas.len(), 7);
581
582 assert_eq!(deltas.deltas[0].action, BookAction::Clear);
584
585 assert_eq!(deltas.deltas[1].order.side, OrderSide::Buy);
587 assert_eq!(deltas.deltas[1].order.price, Price::from("68900.00"));
588 assert_eq!(deltas.deltas[1].order.size, Quantity::from("1.50000000"));
589
590 assert_eq!(deltas.deltas[4].order.side, OrderSide::Sell);
592 assert_eq!(deltas.deltas[4].order.price, Price::from("68901.00"));
593
594 let last = deltas.deltas.last().unwrap();
596 assert_ne!(last.flags & RecordFlag::F_LAST as u8, 0);
597
598 for delta in &deltas.deltas {
600 assert_ne!(
601 delta.flags & RecordFlag::F_SNAPSHOT as u8,
602 0,
603 "snapshot delta missing F_SNAPSHOT: {delta:?}",
604 );
605 }
606 }
607 _ => panic!("Expected L2Data"),
608 }
609 }
610
611 #[rstest]
615 fn test_parse_ws_l2_snapshot_empty_book_clear_carries_snapshot_and_last() {
616 let event = WsL2DataEvent {
617 event_type: WsEventType::Snapshot,
618 product_id: Ustr::from("BTC-USD"),
619 updates: Vec::new(),
620 };
621 let instrument = test_instrument();
622 let ts_event = UnixNanos::from(1);
623 let ts_init = UnixNanos::from(2);
624
625 let deltas = parse_ws_l2_snapshot(&event, &instrument, ts_event, ts_init).unwrap();
626 assert_eq!(deltas.deltas.len(), 1);
627 let clear = &deltas.deltas[0];
628 assert_eq!(clear.action, BookAction::Clear);
629 assert_ne!(clear.flags & RecordFlag::F_SNAPSHOT as u8, 0);
630 assert_ne!(clear.flags & RecordFlag::F_LAST as u8, 0);
631 }
632
633 #[rstest]
637 fn test_parse_ws_l2_update_omits_snapshot_flag() {
638 let json = load_test_fixture("ws_l2_data_update.json");
639 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
640 let instrument = test_instrument();
641 let ts_init = UnixNanos::default();
642
643 match msg {
644 CoinbaseWsMessage::L2Data {
645 timestamp, events, ..
646 } => {
647 let event = &events[0];
648 let ts_event = parse_rfc3339_timestamp(×tamp).unwrap();
649 let deltas = parse_ws_l2_update(event, &instrument, ts_event, ts_init).unwrap();
650
651 for delta in &deltas.deltas {
652 assert_eq!(
653 delta.flags & RecordFlag::F_SNAPSHOT as u8,
654 0,
655 "update delta must not carry F_SNAPSHOT: {delta:?}",
656 );
657 }
658 }
659 _ => panic!("Expected L2Data"),
660 }
661 }
662
663 #[rstest]
664 fn test_parse_ws_l2_update() {
665 let json = load_test_fixture("ws_l2_data_update.json");
666 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
667 let instrument = test_instrument();
668 let ts_init = UnixNanos::default();
669
670 match msg {
671 CoinbaseWsMessage::L2Data {
672 timestamp, events, ..
673 } => {
674 let event = &events[0];
675 assert_eq!(event.event_type, WsEventType::Update);
676 let ts_event = parse_rfc3339_timestamp(×tamp).unwrap();
677
678 let deltas = parse_ws_l2_update(event, &instrument, ts_event, ts_init).unwrap();
679 assert_eq!(deltas.deltas.len(), 2);
680 for delta in &deltas.deltas {
681 assert_eq!(delta.ts_event, ts_event);
682 }
683
684 assert_eq!(deltas.deltas[0].order.side, OrderSide::Buy);
686 assert_eq!(deltas.deltas[0].order.price, Price::from("68900.00"));
687 assert_eq!(deltas.deltas[0].order.size, Quantity::from("2.00000000"));
688 assert_eq!(deltas.deltas[0].action, BookAction::Update);
689
690 assert_eq!(deltas.deltas[1].order.side, OrderSide::Sell);
692 assert_eq!(deltas.deltas[1].action, BookAction::Delete);
693 assert_eq!(deltas.deltas[1].order.size, Quantity::from("0.00000000"));
694
695 assert_ne!(deltas.deltas[1].flags & RecordFlag::F_LAST as u8, 0);
697 }
698 _ => panic!("Expected L2Data"),
699 }
700 }
701
702 #[rstest]
703 fn test_parse_ws_l2_update_zero_quantity_is_delete() {
704 let json = load_test_fixture("ws_l2_data_update.json");
705 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
706 let instrument = test_instrument();
707 let ts_init = UnixNanos::default();
708
709 match msg {
710 CoinbaseWsMessage::L2Data {
711 timestamp, events, ..
712 } => {
713 let event = &events[0];
714 let ts_event = parse_rfc3339_timestamp(×tamp).unwrap();
715 let deltas = parse_ws_l2_update(event, &instrument, ts_event, ts_init).unwrap();
716
717 let delete_delta = deltas
719 .deltas
720 .iter()
721 .find(|d| d.action == BookAction::Delete)
722 .expect("should have a delete action for zero quantity");
723 assert_eq!(delete_delta.order.side, OrderSide::Sell);
724 assert_eq!(delete_delta.ts_event, ts_event);
725 }
726 _ => panic!("Expected L2Data"),
727 }
728 }
729
730 #[rstest]
731 fn test_ws_book_side_conversion() {
732 assert_eq!(ws_book_side_to_order_side(WsBookSide::Bid), OrderSide::Buy);
733 assert_eq!(
734 ws_book_side_to_order_side(WsBookSide::Offer),
735 OrderSide::Sell
736 );
737 }
738
739 #[rstest]
740 fn test_parse_ws_user_event_to_order_status_report_open() {
741 let json = load_test_fixture("ws_user.json");
742 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
743 let instrument = test_instrument();
744 let account_id = AccountId::new("COINBASE-001");
745 let ts_event = UnixNanos::from(1_705_314_600_000_000_000u64);
746 let ts_init = UnixNanos::from(1_705_314_700_000_000_000u64);
747
748 let order = match msg {
749 CoinbaseWsMessage::User { events, .. } => events[0].orders[0].clone(),
750 other => panic!("expected User, was {other:?}"),
751 };
752
753 let report = parse_ws_user_event_to_order_status_report(
754 &order,
755 &instrument,
756 account_id,
757 ts_event,
758 ts_init,
759 )
760 .unwrap();
761
762 assert_eq!(report.account_id, account_id);
763 assert_eq!(report.instrument_id, instrument.id());
764 assert_eq!(
765 report.venue_order_id.as_str(),
766 "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
767 );
768 assert_eq!(
769 report.client_order_id.unwrap().as_str(),
770 "11111-000000-000001"
771 );
772 assert_eq!(report.order_side, OrderSide::Buy);
773 assert_eq!(report.order_status, OrderStatus::Accepted);
774 assert_eq!(report.filled_qty, Quantity::from("0.00000000"));
775 assert_eq!(report.quantity, Quantity::from("0.00100000"));
776 assert_eq!(report.ts_init, ts_init);
777 }
778
779 #[rstest]
780 fn test_parse_ws_user_event_to_order_status_report_promotes_partial_fill() {
781 let mut update = WsOrderUpdate {
782 order_id: "venue-1".to_string(),
783 client_order_id: "client-1".to_string(),
784 contract_expiry_type: crate::common::enums::CoinbaseContractExpiryType::Unknown,
785 cumulative_quantity: "0.5".to_string(),
786 leaves_quantity: "0.5".to_string(),
787 avg_price: "100.00".to_string(),
788 total_fees: "0.05".to_string(),
789 status: crate::common::enums::CoinbaseOrderStatus::Open,
790 product_id: ustr::Ustr::from("BTC-USD"),
791 product_type: crate::common::enums::CoinbaseProductType::Spot,
792 creation_time: String::new(),
793 order_side: crate::common::enums::CoinbaseOrderSide::Buy,
794 order_type: crate::common::enums::CoinbaseOrderType::Limit,
795 risk_managed_by: crate::common::enums::CoinbaseRiskManagedBy::Unknown,
796 time_in_force: crate::common::enums::CoinbaseTimeInForce::GoodUntilCancelled,
797 trigger_status: crate::common::enums::CoinbaseTriggerStatus::InvalidOrderType,
798 cancel_reason: String::new(),
799 reject_reason: String::new(),
800 total_value_after_fees: String::new(),
801 };
802 update.creation_time = String::new();
803
804 let instrument = test_instrument();
805 let report = parse_ws_user_event_to_order_status_report(
806 &update,
807 &instrument,
808 AccountId::new("COINBASE-001"),
809 UnixNanos::default(),
810 UnixNanos::default(),
811 )
812 .unwrap();
813
814 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
816 assert_eq!(report.filled_qty, Quantity::from("0.50000000"));
817 assert_eq!(report.quantity, Quantity::from("1.00000000"));
818 }
819
820 #[rstest]
821 fn test_parse_ws_user_event_to_fill_report_uses_supplied_last_px_and_commission() {
822 let update = WsOrderUpdate {
823 order_id: "venue-1".to_string(),
824 client_order_id: "client-1".to_string(),
825 contract_expiry_type: crate::common::enums::CoinbaseContractExpiryType::Unknown,
826 cumulative_quantity: "0.5".to_string(),
827 leaves_quantity: "0.5".to_string(),
828 avg_price: "100.00".to_string(),
829 total_fees: "0.05".to_string(),
830 status: crate::common::enums::CoinbaseOrderStatus::Open,
831 product_id: ustr::Ustr::from("BTC-USD"),
832 product_type: crate::common::enums::CoinbaseProductType::Spot,
833 creation_time: String::new(),
834 order_side: crate::common::enums::CoinbaseOrderSide::Sell,
835 order_type: crate::common::enums::CoinbaseOrderType::Limit,
836 risk_managed_by: crate::common::enums::CoinbaseRiskManagedBy::Unknown,
837 time_in_force: crate::common::enums::CoinbaseTimeInForce::GoodUntilCancelled,
838 trigger_status: crate::common::enums::CoinbaseTriggerStatus::InvalidOrderType,
839 cancel_reason: String::new(),
840 reject_reason: String::new(),
841 total_value_after_fees: String::new(),
842 };
843
844 let instrument = test_instrument();
845 let usd = Currency::USD();
846 let last_px = Price::from("120.00");
847 let commission =
848 Money::from_decimal(rust_decimal::Decimal::from_str("0.10").unwrap(), usd).unwrap();
849 let trade_id = TradeId::new("venue-1-0.5");
850
851 let report = parse_ws_user_event_to_fill_report(
852 &update,
853 Quantity::from("0.50000000"),
854 last_px,
855 commission,
856 trade_id,
857 &instrument,
858 AccountId::new("COINBASE-001"),
859 LiquiditySide::Maker,
860 UnixNanos::default(),
861 UnixNanos::default(),
862 );
863
864 assert_eq!(report.venue_order_id.as_str(), "venue-1");
865 assert_eq!(report.client_order_id.unwrap().as_str(), "client-1");
866 assert_eq!(report.order_side, OrderSide::Sell);
867 assert_eq!(report.last_qty, Quantity::from("0.50000000"));
868 assert_eq!(report.last_px, Price::from("120.00"));
869 assert_eq!(report.commission, commission);
870 assert_eq!(report.liquidity_side, LiquiditySide::Maker);
871 assert_eq!(report.trade_id, trade_id);
872 }
873
874 fn make_status_product(status: CoinbaseProductStatus, message: &str) -> WsStatusProduct {
875 WsStatusProduct {
876 product_type: crate::common::enums::CoinbaseProductType::Spot,
877 id: Ustr::from("BTC-USD"),
878 base_currency: Ustr::from("BTC"),
879 quote_currency: Ustr::from("USD"),
880 base_increment: "0.00000001".to_string(),
881 quote_increment: "0.01".to_string(),
882 display_name: "BTC/USD".to_string(),
883 status,
884 status_message: message.to_string(),
885 min_market_funds: Decimal::ONE,
886 }
887 }
888
889 #[rstest]
890 #[case::online(
891 CoinbaseProductStatus::Online,
892 "",
893 Some(MarketStatusAction::Trading),
894 Some(true),
895 None
896 )]
897 #[case::offline_with_reason(
898 CoinbaseProductStatus::Offline,
899 "maintenance",
900 Some(MarketStatusAction::Halt),
901 Some(false),
902 Some("maintenance")
903 )]
904 #[case::delisted(
905 CoinbaseProductStatus::Delisted,
906 "",
907 Some(MarketStatusAction::Close),
908 Some(false),
909 None
910 )]
911 #[case::unset_skipped(CoinbaseProductStatus::Unset, "", None, None, None)]
912 fn test_parse_ws_status_product(
913 #[case] status: CoinbaseProductStatus,
914 #[case] message: &str,
915 #[case] expected_action: Option<MarketStatusAction>,
916 #[case] expected_is_trading: Option<bool>,
917 #[case] expected_reason: Option<&str>,
918 ) {
919 let product = make_status_product(status, message);
920 let instrument_id = InstrumentId::new(Symbol::new("BTC-USD"), *COINBASE_VENUE);
921 let result = parse_ws_status_product(
922 &product,
923 instrument_id,
924 UnixNanos::from(1),
925 UnixNanos::from(2),
926 );
927
928 match expected_action {
929 Some(action) => {
930 let status = result.expect("expected InstrumentStatus");
931 assert_eq!(status.instrument_id, instrument_id);
932 assert_eq!(status.action, action);
933 assert_eq!(status.is_trading, expected_is_trading);
934 assert_eq!(
935 status.reason.map(|s| s.to_string()),
936 expected_reason.map(|s| s.to_string()),
937 );
938 assert_eq!(status.ts_event, UnixNanos::from(1));
939 assert_eq!(status.ts_init, UnixNanos::from(2));
940 }
941 None => assert!(result.is_none(), "expected None for unset status"),
942 }
943 }
944}