1use std::{fmt::Debug, sync::Arc};
19
20use ahash::AHashMap;
21use nautilus_core::{
22 AtomicMap, UnixNanos,
23 time::{AtomicTime, get_atomic_clock_realtime},
24};
25use nautilus_model::{
26 data::{Bar, BarType, InstrumentStatus, OrderBookDeltas, QuoteTick, TradeTick},
27 identifiers::{AccountId, InstrumentId, Symbol},
28 instruments::{Instrument, InstrumentAny},
29 reports::OrderStatusReport,
30};
31use nautilus_network::{RECONNECTED, websocket::WebSocketClient};
32use tokio_tungstenite::tungstenite::Message;
33use ustr::Ustr;
34
35use crate::{
36 common::consts::COINBASE_VENUE,
37 websocket::{
38 client::COINBASE_WS_SUBSCRIPTION_KEYS,
39 messages::{CoinbaseWsMessage, CoinbaseWsSubscription, WsEventType, WsOrderUpdate},
40 parse::{
41 parse_ws_candle, parse_ws_l2_snapshot, parse_ws_l2_update, parse_ws_status_product,
42 parse_ws_ticker, parse_ws_trade, parse_ws_user_event_to_order_status_report,
43 },
44 },
45};
46
47fn instrument_id_from_product(product_id: &Ustr) -> InstrumentId {
48 InstrumentId::new(Symbol::new(*product_id), *COINBASE_VENUE)
49}
50
51fn resolve_instrument_id_from_aliases(
52 aliases: &AHashMap<Ustr, Ustr>,
53 product_id: &Ustr,
54) -> InstrumentId {
55 let resolved = aliases.get(product_id).copied().unwrap_or(*product_id);
56 instrument_id_from_product(&resolved)
57}
58
59pub enum HandlerCommand {
61 SetClient(WebSocketClient),
63 Subscribe(CoinbaseWsSubscription),
65 Unsubscribe(CoinbaseWsSubscription),
67 Disconnect,
69 InitializeInstruments(Vec<InstrumentAny>),
71 UpdateInstrument(Box<InstrumentAny>),
73 AddBarType { key: String, bar_type: BarType },
75 RemoveBarType { key: String },
77 SetAccountId(AccountId),
79}
80
81impl Debug for HandlerCommand {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 match self {
84 Self::SetClient(_) => f.write_str("SetClient"),
85 Self::Subscribe(s) => write!(f, "Subscribe({:?})", s.channel),
86 Self::Unsubscribe(s) => write!(f, "Unsubscribe({:?})", s.channel),
87 Self::Disconnect => f.write_str("Disconnect"),
88 Self::InitializeInstruments(v) => write!(f, "InitializeInstruments({})", v.len()),
89 Self::UpdateInstrument(i) => write!(f, "UpdateInstrument({})", i.id()),
90 Self::AddBarType { key, .. } => write!(f, "AddBarType({key})"),
91 Self::RemoveBarType { key } => write!(f, "RemoveBarType({key})"),
92 Self::SetAccountId(id) => write!(f, "SetAccountId({id})"),
93 }
94 }
95}
96
97#[derive(Debug, Clone)]
109pub struct UserOrderUpdate {
110 pub report: Box<OrderStatusReport>,
111 pub update: Box<WsOrderUpdate>,
112 pub instrument: InstrumentAny,
113 pub is_snapshot: bool,
114 pub ts_event: UnixNanos,
115 pub ts_init: UnixNanos,
116}
117
118#[derive(Debug, Clone)]
120pub enum NautilusWsMessage {
121 Trade(TradeTick),
123 Quote(QuoteTick),
125 Deltas(OrderBookDeltas),
127 Bar(Bar),
129 UserOrder(Box<UserOrderUpdate>),
131 FuturesBalanceSummary(Box<crate::websocket::messages::WsFcmBalanceSummary>),
134 InstrumentStatus(Box<InstrumentStatus>),
137 Reconnected,
139 Error(String),
141}
142
143#[derive(Debug)]
145pub struct FeedHandler {
146 clock: &'static AtomicTime,
147 signal: Arc<std::sync::atomic::AtomicBool>,
148 client: Option<WebSocketClient>,
149 cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
150 raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
151 instruments: AHashMap<InstrumentId, InstrumentAny>,
152 subscription_aliases: Arc<AtomicMap<Ustr, Ustr>>,
156 bar_types: AHashMap<String, BarType>,
157 account_id: Option<AccountId>,
158 buffer: Vec<NautilusWsMessage>,
159 last_heartbeat_counter: Option<u64>,
160}
161
162impl FeedHandler {
163 pub fn new(
165 signal: Arc<std::sync::atomic::AtomicBool>,
166 cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
167 raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
168 subscription_aliases: Arc<AtomicMap<Ustr, Ustr>>,
169 ) -> Self {
170 Self {
171 clock: get_atomic_clock_realtime(),
172 signal,
173 client: None,
174 cmd_rx,
175 raw_rx,
176 instruments: AHashMap::new(),
177 subscription_aliases,
178 bar_types: AHashMap::new(),
179 account_id: None,
180 buffer: Vec::new(),
181 last_heartbeat_counter: None,
182 }
183 }
184
185 pub fn set_account_id(&mut self, account_id: AccountId) {
187 self.account_id = Some(account_id);
188 }
189
190 pub async fn next(&mut self) -> Option<NautilusWsMessage> {
194 if self.signal.load(std::sync::atomic::Ordering::Acquire) {
197 self.buffer.clear();
198 return None;
199 }
200
201 if let Some(msg) = self.buffer.pop() {
202 return Some(msg);
203 }
204
205 loop {
206 if self.signal.load(std::sync::atomic::Ordering::Acquire) {
207 return None;
208 }
209
210 tokio::select! {
211 Some(cmd) = self.cmd_rx.recv() => {
212 match cmd {
213 HandlerCommand::SetClient(client) => {
214 self.client = Some(client);
215 }
216 HandlerCommand::Subscribe(sub) => {
217 self.send_subscription(&sub).await;
218 }
219 HandlerCommand::Unsubscribe(sub) => {
220 self.send_subscription(&sub).await;
221 }
222 HandlerCommand::Disconnect => {
223 if let Some(client) = self.client.take() {
224 client.notify_closed();
227 }
228 return None;
229 }
230 HandlerCommand::InitializeInstruments(instruments) => {
231 for inst in instruments {
232 self.instruments.insert(inst.id(), inst);
233 }
234 }
235 HandlerCommand::UpdateInstrument(inst) => {
236 self.instruments.insert(inst.id(), *inst);
237 }
238 HandlerCommand::AddBarType { key, bar_type } => {
239 self.bar_types.insert(key, bar_type);
240 }
241 HandlerCommand::RemoveBarType { key } => {
242 self.bar_types.remove(&key);
243 }
244 HandlerCommand::SetAccountId(account_id) => {
245 self.account_id = Some(account_id);
246 }
247 }
248 }
249 Some(raw) = self.raw_rx.recv() => {
250 match raw {
251 Message::Text(text) => {
252 if let Some(msg) = self.handle_text(&text) {
253 return Some(msg);
254 }
255 }
256 Message::Ping(data) => {
257 if let Some(client) = &self.client
258 && let Err(e) = client.send_pong(data.to_vec()).await
259 {
260 log::error!("Failed to send pong: {e}");
261 }
262 }
263 Message::Close(_) => return None,
264 _ => {}
265 }
266 }
267 else => return None,
268 }
269 }
270 }
271
272 async fn send_subscription(&self, sub: &CoinbaseWsSubscription) {
273 let Some(client) = &self.client else {
274 log::warn!("Cannot send subscription, no WebSocket client set");
275 return;
276 };
277
278 match serde_json::to_string(sub) {
279 Ok(json) => {
280 if let Err(e) = client
281 .send_text(json, Some(COINBASE_WS_SUBSCRIPTION_KEYS.as_slice()))
282 .await
283 {
284 log::error!("Failed to send subscription: {e}");
285 }
286 }
287 Err(e) => log::error!("Failed to serialize subscription: {e}"),
288 }
289 }
290
291 fn handle_text(&mut self, text: &str) -> Option<NautilusWsMessage> {
292 if text == RECONNECTED {
293 self.last_heartbeat_counter = None;
294 return Some(NautilusWsMessage::Reconnected);
295 }
296
297 let ts_init = self.clock.get_time_ns();
298
299 let msg: CoinbaseWsMessage = match serde_json::from_str(text) {
300 Ok(m) => m,
301 Err(e) => {
302 log::warn!("Failed to parse WS message: {e}");
303 return None;
304 }
305 };
306
307 match msg {
308 CoinbaseWsMessage::L2Data {
309 timestamp, events, ..
310 } => self.handle_l2_events(&events, ×tamp, ts_init),
311 CoinbaseWsMessage::MarketTrades { events, .. } => {
312 self.handle_market_trades(&events, ts_init)
313 }
314 CoinbaseWsMessage::Ticker {
315 timestamp, events, ..
316 }
317 | CoinbaseWsMessage::TickerBatch {
318 timestamp, events, ..
319 } => self.handle_ticker(&events, ×tamp, ts_init),
320 CoinbaseWsMessage::Candles { events, .. } => self.handle_candles(&events, ts_init),
321 CoinbaseWsMessage::Heartbeats { events, .. } => {
322 for event in events {
323 if let Some((expected, actual)) =
324 self.note_heartbeat_counter(event.heartbeat_counter)
325 {
326 log::warn!("Heartbeat counter gap: expected {expected}, was {actual}");
327 }
328 }
329 None
330 }
331 CoinbaseWsMessage::Subscriptions { events, .. } => {
332 log::debug!("Subscription state: {events:?}");
336 None
337 }
338 CoinbaseWsMessage::User {
339 timestamp, events, ..
340 } => self.handle_user_events(&events, ×tamp, ts_init),
341 CoinbaseWsMessage::FuturesBalanceSummary { events, .. } => {
342 self.handle_futures_balance_summary(events)
343 }
344 CoinbaseWsMessage::Status {
345 timestamp, events, ..
346 } => self.handle_status_events(&events, ×tamp, ts_init),
347 }
348 }
349
350 fn note_heartbeat_counter(&mut self, counter: u64) -> Option<(u64, u64)> {
351 let gap = self.last_heartbeat_counter.and_then(|last| {
352 let expected = last.saturating_add(1);
353 (counter != expected).then_some((expected, counter))
354 });
355 self.last_heartbeat_counter = Some(counter);
356 gap
357 }
358
359 fn handle_l2_events(
360 &mut self,
361 events: &[crate::websocket::messages::WsL2DataEvent],
362 timestamp: &str,
363 ts_init: UnixNanos,
364 ) -> Option<NautilusWsMessage> {
365 let ts_event = match crate::http::parse::parse_rfc3339_timestamp(timestamp) {
366 Ok(ts) => ts,
367 Err(e) => {
368 log::warn!("Failed to parse L2 message timestamp {timestamp}: {e}");
369 ts_init
370 }
371 };
372
373 let mut first: Option<NautilusWsMessage> = None;
374 let aliases = self.subscription_aliases.load();
375
376 for event in events {
377 let instrument_id = resolve_instrument_id_from_aliases(&aliases, &event.product_id);
378
379 let instrument = match self.instruments.get(&instrument_id) {
380 Some(inst) => inst,
381 None => {
382 log::warn!("No instrument cached for {instrument_id}");
383 continue;
384 }
385 };
386
387 let result = match event.event_type {
388 WsEventType::Snapshot => parse_ws_l2_snapshot(event, instrument, ts_event, ts_init),
389 WsEventType::Update => parse_ws_l2_update(event, instrument, ts_event, ts_init),
390 };
391
392 match result {
393 Ok(deltas) => {
394 let msg = NautilusWsMessage::Deltas(deltas);
395
396 if first.is_none() {
397 first = Some(msg);
398 } else {
399 self.buffer.push(msg);
400 }
401 }
402 Err(e) => log::warn!("Failed to parse L2 event: {e}"),
403 }
404 }
405
406 if first.is_some() {
407 self.buffer.reverse();
408 }
409 first
410 }
411
412 fn handle_market_trades(
413 &mut self,
414 events: &[crate::websocket::messages::WsMarketTradesEvent],
415 ts_init: UnixNanos,
416 ) -> Option<NautilusWsMessage> {
417 let aliases = self.subscription_aliases.load();
418
419 for event in events {
420 for trade in &event.trades {
421 let instrument_id = resolve_instrument_id_from_aliases(&aliases, &trade.product_id);
422
423 let instrument = match self.instruments.get(&instrument_id) {
424 Some(inst) => inst,
425 None => {
426 log::warn!("No instrument cached for {instrument_id}");
427 continue;
428 }
429 };
430
431 match parse_ws_trade(trade, instrument, ts_init) {
432 Ok(tick) => {
433 self.buffer_remaining_trades(events, event, trade, ts_init);
434 self.buffer.reverse();
436 return Some(NautilusWsMessage::Trade(tick));
437 }
438 Err(e) => log::warn!("Failed to parse trade: {e}"),
439 }
440 }
441 }
442 None
443 }
444
445 fn buffer_remaining_trades(
446 &mut self,
447 events: &[crate::websocket::messages::WsMarketTradesEvent],
448 current_event: &crate::websocket::messages::WsMarketTradesEvent,
449 current_trade: &crate::websocket::messages::WsTrade,
450 ts_init: UnixNanos,
451 ) {
452 let mut found_current = false;
453 let aliases = self.subscription_aliases.load();
454
455 for event in events {
456 let is_current_event = std::ptr::eq(event, current_event);
457
458 for trade in &event.trades {
459 if !found_current {
460 if is_current_event && std::ptr::eq(trade, current_trade) {
461 found_current = true;
462 }
463 continue;
464 }
465
466 let instrument_id = resolve_instrument_id_from_aliases(&aliases, &trade.product_id);
467
468 if let Some(instrument) = self.instruments.get(&instrument_id)
469 && let Ok(tick) = parse_ws_trade(trade, instrument, ts_init)
470 {
471 self.buffer.push(NautilusWsMessage::Trade(tick));
472 }
473 }
474 }
475 }
476
477 fn handle_ticker(
478 &mut self,
479 events: &[crate::websocket::messages::WsTickerEvent],
480 timestamp: &str,
481 ts_init: UnixNanos,
482 ) -> Option<NautilusWsMessage> {
483 let ts_event = crate::http::parse::parse_rfc3339_timestamp(timestamp).unwrap_or(ts_init);
484
485 let mut first: Option<NautilusWsMessage> = None;
486 let aliases = self.subscription_aliases.load();
487
488 for event in events {
489 for ticker in &event.tickers {
490 let instrument_id =
491 resolve_instrument_id_from_aliases(&aliases, &ticker.product_id);
492
493 let instrument = match self.instruments.get(&instrument_id) {
494 Some(inst) => inst,
495 None => {
496 log::warn!("No instrument cached for {instrument_id}");
497 continue;
498 }
499 };
500
501 match parse_ws_ticker(ticker, instrument, ts_event, ts_init) {
502 Ok(quote) => {
503 let msg = NautilusWsMessage::Quote(quote);
504
505 if first.is_none() {
506 first = Some(msg);
507 } else {
508 self.buffer.push(msg);
509 }
510 }
511 Err(e) => log::warn!("Failed to parse ticker: {e}"),
512 }
513 }
514 }
515
516 if first.is_some() {
517 self.buffer.reverse();
518 }
519 first
520 }
521
522 fn handle_user_events(
523 &mut self,
524 events: &[crate::websocket::messages::WsUserEvent],
525 timestamp: &str,
526 ts_init: UnixNanos,
527 ) -> Option<NautilusWsMessage> {
528 let Some(account_id) = self.account_id else {
529 log::debug!(
530 "Dropping user event: account_id not set (call SetAccountId after connect)"
531 );
532 return None;
533 };
534
535 let ts_event = match crate::http::parse::parse_rfc3339_timestamp(timestamp) {
536 Ok(ts) => ts,
537 Err(e) => {
538 log::warn!("Failed to parse user message timestamp {timestamp}: {e}");
539 ts_init
540 }
541 };
542
543 let mut first: Option<NautilusWsMessage> = None;
544 let aliases = self.subscription_aliases.load();
545
546 for event in events {
547 let is_snapshot = matches!(event.event_type, WsEventType::Snapshot);
548
549 for order in &event.orders {
550 let instrument_id = resolve_instrument_id_from_aliases(&aliases, &order.product_id);
551 let instrument = match self.instruments.get(&instrument_id).cloned() {
552 Some(inst) => inst,
553 None => {
554 log::warn!("No instrument cached for {instrument_id}");
555 continue;
556 }
557 };
558
559 self.emit_user_event_messages(
560 order,
561 &instrument,
562 account_id,
563 is_snapshot,
564 ts_event,
565 ts_init,
566 &mut first,
567 );
568 }
569 }
570
571 if first.is_some() {
572 self.buffer.reverse();
573 }
574 first
575 }
576
577 #[allow(clippy::too_many_arguments)]
578 fn emit_user_event_messages(
579 &mut self,
580 order: &WsOrderUpdate,
581 instrument: &InstrumentAny,
582 account_id: AccountId,
583 is_snapshot: bool,
584 ts_event: UnixNanos,
585 ts_init: UnixNanos,
586 first: &mut Option<NautilusWsMessage>,
587 ) {
588 let report = match parse_ws_user_event_to_order_status_report(
589 order, instrument, account_id, ts_event, ts_init,
590 ) {
591 Ok(r) => r,
592 Err(e) => {
593 log::warn!("Failed to parse user order update: {e}");
594 return;
595 }
596 };
597
598 let msg = NautilusWsMessage::UserOrder(Box::new(UserOrderUpdate {
599 report: Box::new(report),
600 update: Box::new(order.clone()),
601 instrument: instrument.clone(),
602 is_snapshot,
603 ts_event,
604 ts_init,
605 }));
606
607 if first.is_none() {
608 *first = Some(msg);
609 } else {
610 self.buffer.push(msg);
611 }
612 }
613
614 fn handle_status_events(
615 &mut self,
616 events: &[crate::websocket::messages::WsStatusEvent],
617 timestamp: &str,
618 ts_init: UnixNanos,
619 ) -> Option<NautilusWsMessage> {
620 let ts_event = crate::http::parse::parse_rfc3339_timestamp(timestamp).unwrap_or(ts_init);
621
622 let mut first: Option<NautilusWsMessage> = None;
623 let aliases = self.subscription_aliases.load();
624
625 for event in events {
626 for product in &event.products {
627 let canonical = product.id;
628 let resolved = resolve_instrument_id_from_aliases(&aliases, &canonical);
629 let Some(status) = parse_ws_status_product(product, resolved, ts_event, ts_init)
630 else {
631 continue;
632 };
633 let msg = NautilusWsMessage::InstrumentStatus(Box::new(status));
634
635 if first.is_none() {
636 first = Some(msg);
637 } else {
638 self.buffer.push(msg);
639 }
640 }
641 }
642
643 if first.is_some() {
644 self.buffer.reverse();
645 }
646 first
647 }
648
649 fn handle_futures_balance_summary(
650 &mut self,
651 events: Vec<crate::websocket::messages::WsFuturesBalanceSummaryEvent>,
652 ) -> Option<NautilusWsMessage> {
653 let mut first: Option<NautilusWsMessage> = None;
654
655 for event in events {
656 let msg = NautilusWsMessage::FuturesBalanceSummary(Box::new(event.fcm_balance_summary));
657
658 if first.is_none() {
659 first = Some(msg);
660 } else {
661 self.buffer.push(msg);
662 }
663 }
664
665 if first.is_some() {
666 self.buffer.reverse();
667 }
668 first
669 }
670
671 fn handle_candles(
672 &mut self,
673 events: &[crate::websocket::messages::WsCandlesEvent],
674 ts_init: UnixNanos,
675 ) -> Option<NautilusWsMessage> {
676 let mut first: Option<NautilusWsMessage> = None;
677 let aliases = self.subscription_aliases.load();
678
679 for event in events {
680 for candle in &event.candles {
681 let key = candle.product_id.as_str();
682
683 let bar_type = match self.bar_types.get(key) {
684 Some(bt) => *bt,
685 None => {
686 log::debug!("No bar type registered for {key}");
687 continue;
688 }
689 };
690
691 let instrument_id =
692 resolve_instrument_id_from_aliases(&aliases, &candle.product_id);
693
694 let instrument = match self.instruments.get(&instrument_id) {
695 Some(inst) => inst,
696 None => {
697 log::warn!("No instrument cached for {instrument_id}");
698 continue;
699 }
700 };
701
702 match parse_ws_candle(candle, bar_type, instrument, ts_init) {
703 Ok(bar) => {
704 let msg = NautilusWsMessage::Bar(bar);
705
706 if first.is_none() {
707 first = Some(msg);
708 } else {
709 self.buffer.push(msg);
710 }
711 }
712 Err(e) => log::warn!("Failed to parse candle: {e}"),
713 }
714 }
715 }
716
717 if first.is_some() {
718 self.buffer.reverse();
719 }
720 first
721 }
722}
723
724#[cfg(test)]
725mod tests {
726 use std::sync::{Arc, atomic::AtomicBool};
727
728 use nautilus_model::{
729 identifiers::Symbol,
730 instruments::CurrencyPair,
731 types::{Currency, Price, Quantity},
732 };
733 use rstest::rstest;
734
735 use super::*;
736 use crate::common::{consts::COINBASE_VENUE, testing::load_test_fixture};
737
738 fn test_handler() -> FeedHandler {
739 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
740 let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
741 FeedHandler::new(
742 Arc::new(AtomicBool::new(false)),
743 cmd_rx,
744 raw_rx,
745 Arc::new(AtomicMap::new()),
746 )
747 }
748
749 fn btc_usd_instrument() -> InstrumentAny {
750 let instrument_id = InstrumentId::new(Symbol::new("BTC-USD"), *COINBASE_VENUE);
751 InstrumentAny::CurrencyPair(
752 CurrencyPair::builder()
753 .instrument_id(instrument_id)
754 .raw_symbol(Symbol::new("BTC-USD"))
755 .base_currency(Currency::get_or_create_crypto("BTC"))
756 .quote_currency(Currency::get_or_create_crypto("USD"))
757 .price_precision(2)
758 .size_precision(8)
759 .price_increment(Price::from("0.01"))
760 .size_increment(Quantity::from("0.00000001"))
761 .min_quantity(Quantity::from("0.00000001"))
762 .ts_event(UnixNanos::default())
763 .ts_init(UnixNanos::default())
764 .build()
765 .unwrap(),
766 )
767 }
768
769 #[rstest]
770 fn test_handle_text_drops_user_channel_when_account_id_unset() {
771 let json = load_test_fixture("ws_user.json");
772 let mut handler = test_handler();
773
774 assert!(handler.handle_text(&json).is_none());
776 assert!(handler.buffer.is_empty());
777 }
778
779 #[rstest]
780 fn test_handle_user_event_emits_user_order_update() {
781 use nautilus_model::{
782 enums::{OrderSide, OrderStatus},
783 identifiers::AccountId,
784 types::Quantity,
785 };
786
787 use crate::common::enums::CoinbaseProductType;
788
789 let json = load_test_fixture("ws_user.json");
790 let mut handler = test_handler();
791 handler.set_account_id(AccountId::new("COINBASE-001"));
792 handler
793 .instruments
794 .insert(btc_usd_instrument().id(), btc_usd_instrument());
795
796 let msg = handler
797 .handle_text(&json)
798 .expect("handler should emit a user-channel update");
799
800 match msg {
801 NautilusWsMessage::UserOrder(carrier) => {
802 assert_eq!(carrier.report.account_id.as_str(), "COINBASE-001");
804 assert_eq!(carrier.report.instrument_id, btc_usd_instrument().id());
805 assert_eq!(
806 carrier.report.venue_order_id.as_str(),
807 "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
808 );
809 assert_eq!(
810 carrier.report.client_order_id.unwrap().as_str(),
811 "11111-000000-000001"
812 );
813 assert_eq!(carrier.report.order_side, OrderSide::Buy.into());
814 assert_eq!(carrier.report.order_status, OrderStatus::Accepted);
815 assert_eq!(carrier.report.filled_qty, Quantity::from("0.00000000"));
816 assert_eq!(carrier.report.quantity, Quantity::from("0.00100000"));
817
818 assert_eq!(carrier.update.product_id, "BTC-USD");
820 assert_eq!(carrier.update.product_type, CoinbaseProductType::Spot);
821 assert_eq!(carrier.update.cumulative_quantity, "0");
822 assert_eq!(carrier.update.leaves_quantity, "0.001");
823
824 assert_eq!(carrier.instrument.id(), btc_usd_instrument().id());
826 assert!(carrier.ts_event.as_u64() > 0);
827 }
828 other => panic!("expected UserOrder, was {other:?}"),
829 }
830 }
831
832 #[rstest]
833 fn test_handle_text_emits_instrument_status_from_status_channel() {
834 use nautilus_model::enums::MarketStatusAction;
835
836 let json = r#"{
837 "channel": "status",
838 "client_id": "",
839 "timestamp": "2023-02-09T20:29:49.753424311Z",
840 "sequence_num": 0,
841 "events": [
842 {
843 "type": "snapshot",
844 "products": [
845 {
846 "product_type": "SPOT",
847 "id": "BTC-USD",
848 "base_currency": "BTC",
849 "quote_currency": "USD",
850 "base_increment": "0.00000001",
851 "quote_increment": "0.01",
852 "display_name": "BTC/USD",
853 "status": "online",
854 "status_message": "",
855 "min_market_funds": "1"
856 },
857 {
858 "product_type": "SPOT",
859 "id": "ETH-USD",
860 "base_currency": "ETH",
861 "quote_currency": "USD",
862 "base_increment": "0.00000001",
863 "quote_increment": "0.01",
864 "display_name": "ETH/USD",
865 "status": "offline",
866 "status_message": "maintenance",
867 "min_market_funds": "1"
868 }
869 ]
870 }
871 ]
872 }"#;
873 let mut handler = test_handler();
874
875 let first = handler
876 .handle_text(json)
877 .expect("status channel must emit InstrumentStatus");
878 let NautilusWsMessage::InstrumentStatus(status) = first else {
879 panic!("expected InstrumentStatus, was {first:?}");
880 };
881 assert_eq!(status.instrument_id, btc_usd_instrument().id());
882 assert_eq!(status.action, MarketStatusAction::Trading);
883 assert_eq!(status.is_trading, Some(true));
884 assert!(status.reason.is_none());
885
886 assert_eq!(handler.buffer.len(), 1);
888 let NautilusWsMessage::InstrumentStatus(status) = handler.buffer.pop().unwrap() else {
889 panic!("expected buffered InstrumentStatus");
890 };
891 assert_eq!(status.action, MarketStatusAction::Halt);
892 assert_eq!(status.is_trading, Some(false));
893 assert_eq!(
894 status.reason.map(|s| s.to_string()),
895 Some("maintenance".to_string())
896 );
897 }
898
899 #[rstest]
900 fn test_handle_l2_update_uses_batch_timestamp_for_all_deltas() {
901 let json = load_test_fixture("ws_l2_data_update.json");
902 let mut handler = test_handler();
903 handler
904 .instruments
905 .insert(btc_usd_instrument().id(), btc_usd_instrument());
906
907 let msg = handler
908 .handle_text(&json)
909 .expect("handler should emit deltas for a valid L2 update");
910
911 let deltas = match msg {
912 NautilusWsMessage::Deltas(d) => d,
913 other => panic!("expected Deltas, was {other:?}"),
914 };
915
916 assert!(!deltas.deltas.is_empty());
917 let expected_ts = deltas.deltas[0].ts_event;
918 for delta in &deltas.deltas {
919 assert_eq!(
920 delta.ts_event, expected_ts,
921 "all deltas in a batch must share ts_event"
922 );
923 }
924 }
925
926 #[rstest]
927 fn test_handle_l2_update_malformed_timestamp_falls_back_to_ts_init() {
928 let json = load_test_fixture("ws_l2_data_update.json")
929 .replace("2026-04-07T14:30:01.456789Z", "not-a-valid-timestamp");
930 let mut handler = test_handler();
931 handler
932 .instruments
933 .insert(btc_usd_instrument().id(), btc_usd_instrument());
934
935 let msg = handler
936 .handle_text(&json)
937 .expect("handler should still emit deltas when timestamp is malformed");
938
939 let deltas = match msg {
940 NautilusWsMessage::Deltas(d) => d,
941 other => panic!("expected Deltas, was {other:?}"),
942 };
943
944 assert!(!deltas.deltas.is_empty());
945 for delta in &deltas.deltas {
946 assert_eq!(
947 delta.ts_event, delta.ts_init,
948 "malformed timestamp must fall back to ts_init"
949 );
950 }
951 }
952
953 #[rstest]
954 fn test_handle_text_emits_futures_balance_summary_snapshot() {
955 use rust_decimal::Decimal;
956
957 let json = r#"{
958 "channel": "futures_balance_summary",
959 "client_id": "",
960 "timestamp": "2023-02-09T20:33:57.609931463Z",
961 "sequence_num": 0,
962 "events": [
963 {
964 "type": "snapshot",
965 "fcm_balance_summary": {
966 "futures_buying_power": "100.00",
967 "total_usd_balance": "200.00",
968 "cbi_usd_balance": "300.00",
969 "cfm_usd_balance": "400.00",
970 "total_open_orders_hold_amount": "500.00",
971 "unrealized_pnl": "600.00",
972 "daily_realized_pnl": "0",
973 "initial_margin": "700.00",
974 "available_margin": "800.00",
975 "liquidation_threshold": "900.00",
976 "liquidation_buffer_amount": "1000.00",
977 "liquidation_buffer_percentage": "1000",
978 "intraday_margin_window_measure": {
979 "margin_window_type": "FCM_MARGIN_WINDOW_TYPE_INTRADAY",
980 "margin_level": "MARGIN_LEVEL_TYPE_BASE",
981 "initial_margin": "100.00",
982 "maintenance_margin": "200.00",
983 "liquidation_buffer_percentage": "1000",
984 "total_hold": "100.00",
985 "futures_buying_power": "400.00"
986 },
987 "overnight_margin_window_measure": {
988 "margin_window_type": "FCM_MARGIN_WINDOW_TYPE_OVERNIGHT",
989 "margin_level": "MARGIN_LEVEL_TYPE_BASE",
990 "initial_margin": "300.00",
991 "maintenance_margin": "200.00",
992 "liquidation_buffer_percentage": "1000",
993 "total_hold": "-30.00",
994 "futures_buying_power": "2000.00"
995 }
996 }
997 }
998 ]
999 }"#;
1000 let mut handler = test_handler();
1001
1002 let msg = handler
1003 .handle_text(json)
1004 .expect("handler should emit a futures balance summary");
1005 match msg {
1006 NautilusWsMessage::FuturesBalanceSummary(summary) => {
1007 assert_eq!(summary.futures_buying_power, Decimal::from(100));
1008 assert_eq!(summary.total_usd_balance, Decimal::from(200));
1009 assert_eq!(summary.total_open_orders_hold_amount, Decimal::from(500));
1010 assert_eq!(summary.available_margin, Decimal::from(800));
1011 let intraday = &summary.intraday_margin_window_measure;
1012 assert_eq!(intraday.initial_margin, Decimal::from(100));
1013 assert_eq!(intraday.maintenance_margin, Decimal::from(200));
1014 let overnight = &summary.overnight_margin_window_measure;
1015 assert_eq!(overnight.initial_margin, Decimal::from(300));
1016 assert_eq!(overnight.maintenance_margin, Decimal::from(200));
1017 assert_eq!(overnight.total_hold, "-30".parse::<Decimal>().unwrap());
1020 }
1021 other => panic!("expected FuturesBalanceSummary, was {other:?}"),
1022 }
1023 }
1024
1025 #[rstest]
1026 fn test_handle_text_routes_reconnected_sentinel() {
1027 let mut handler = test_handler();
1028 let result = handler.handle_text(RECONNECTED);
1029 assert!(matches!(result, Some(NautilusWsMessage::Reconnected)));
1030 }
1031
1032 #[rstest]
1033 fn test_heartbeat_counter_tracks_sequence_and_detects_gaps() {
1034 let mut handler = test_handler();
1035
1036 assert_eq!(handler.note_heartbeat_counter(42), None);
1037 assert_eq!(handler.last_heartbeat_counter, Some(42));
1038 assert_eq!(handler.note_heartbeat_counter(43), None);
1039 assert_eq!(handler.last_heartbeat_counter, Some(43));
1040 assert_eq!(handler.note_heartbeat_counter(45), Some((44, 45)));
1041 assert_eq!(handler.last_heartbeat_counter, Some(45));
1042 assert_eq!(handler.note_heartbeat_counter(40), Some((46, 40)));
1043 assert_eq!(handler.last_heartbeat_counter, Some(40));
1044 }
1045
1046 #[rstest]
1047 fn test_handle_text_heartbeats_updates_counter_and_reconnect_resets_it() {
1048 let json = load_test_fixture("ws_heartbeats.json");
1049 let mut handler = test_handler();
1050
1051 assert!(handler.handle_text(&json).is_none());
1052 assert_eq!(handler.last_heartbeat_counter, Some(42));
1053
1054 let next = json.replace("\"heartbeat_counter\": 42", "\"heartbeat_counter\": 43");
1055 assert!(handler.handle_text(&next).is_none());
1056 assert_eq!(handler.last_heartbeat_counter, Some(43));
1057
1058 let result = handler.handle_text(RECONNECTED);
1059 assert!(matches!(result, Some(NautilusWsMessage::Reconnected)));
1060 assert_eq!(handler.last_heartbeat_counter, None);
1061 }
1062
1063 #[rstest]
1064 fn test_signal_release_acquire_exits_handler_loop() {
1065 use std::sync::atomic::Ordering;
1066
1067 let signal = Arc::new(AtomicBool::new(false));
1068 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1069 let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
1070 let mut handler =
1071 FeedHandler::new(signal.clone(), cmd_rx, raw_rx, Arc::new(AtomicMap::new()));
1072
1073 signal.store(true, Ordering::Release);
1074
1075 let runtime = tokio::runtime::Builder::new_current_thread()
1076 .enable_all()
1077 .build()
1078 .unwrap();
1079 let result = runtime.block_on(async { handler.next().await });
1080 assert!(result.is_none(), "{result:?}");
1081 }
1082}