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