Skip to main content

nautilus_coinbase/websocket/
handler.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Feed handler for parsing Coinbase WebSocket messages into Nautilus types.
17
18use 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
60/// Commands sent from [`super::client::CoinbaseWebSocketClient`] to the feed handler.
61pub enum HandlerCommand {
62    /// Provides the network-level WebSocket client.
63    SetClient(WebSocketClient),
64    /// Subscribes with a serialized payload that is zeroized on drop.
65    Subscribe {
66        channel: crate::common::enums::CoinbaseWsChannel,
67        product_ids: Vec<Ustr>,
68        payload: SecretString,
69    },
70    /// Unsubscribes with a serialized payload that is zeroized on drop.
71    Unsubscribe {
72        channel: crate::common::enums::CoinbaseWsChannel,
73        product_ids: Vec<Ustr>,
74        payload: SecretString,
75    },
76    /// Disconnects the WebSocket.
77    Disconnect,
78    /// Caches instruments for precision lookups during parsing.
79    InitializeInstruments(Vec<InstrumentAny>),
80    /// Updates a single instrument in the cache.
81    UpdateInstrument(Box<InstrumentAny>),
82    /// Registers a bar type for candle parsing.
83    AddBarType { key: String, bar_type: BarType },
84    /// Removes a bar type registration.
85    RemoveBarType { key: String },
86    /// Sets the account ID used when emitting user-channel execution reports.
87    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/// Carrier for a single user-channel order update.
107///
108/// Pairs the parsed [`OrderStatusReport`] with the resolved instrument and
109/// the raw venue payload so downstream consumers (e.g. the execution client)
110/// can diff cumulative quantity and fees against their own tracked state.
111///
112/// `is_snapshot` is true when the wrapping `WsUserEvent` was a `snapshot`
113/// type. Snapshots restate the current cumulative state of every open order
114/// and must NOT be interpreted as fresh fills, otherwise a cold start (or
115/// any state-clearing reconnect) would synthesize phantom fills covering the
116/// entire pre-existing cumulative quantity.
117#[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/// Nautilus-typed messages produced by the feed handler.
128#[derive(Debug, Clone)]
129pub enum NautilusWsMessage {
130    /// Trade tick from market_trades channel.
131    Trade(TradeTick),
132    /// Quote tick from ticker channel.
133    Quote(QuoteTick),
134    /// Order book deltas from l2_data channel.
135    Deltas(OrderBookDeltas),
136    /// Bar from candles channel.
137    Bar(Bar),
138    /// Order status update from the user channel.
139    UserOrder(Box<UserOrderUpdate>),
140    /// Futures balance summary snapshot from the
141    /// `futures_balance_summary` channel.
142    FuturesBalanceSummary(Box<crate::websocket::messages::WsFcmBalanceSummary>),
143    /// Instrument status update from the `status` channel. Emitted for every
144    /// product the venue reports; the data client filters by subscription.
145    InstrumentStatus(Box<InstrumentStatus>),
146    /// The connection was re-established after a drop.
147    Reconnected,
148    /// An error occurred during message processing.
149    Error(String),
150}
151
152/// Processes raw WebSocket messages into Nautilus domain types.
153#[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    /// Shared with [`super::client::CoinbaseWebSocketClient`]; consulted in
162    /// `resolve_instrument_id_from_aliases` to re-key inbound messages whose wire
163    /// `product_id` is the canonical alias of a subscribed/submitted product.
164    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    /// Creates a new [`FeedHandler`] instance.
173    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    /// Sets the account ID used to stamp user-channel execution reports.
195    pub fn set_account_id(&mut self, account_id: AccountId) {
196        self.account_id = Some(account_id);
197    }
198
199    /// Polls for the next output message, processing commands and raw messages.
200    ///
201    /// Returns `None` when the handler should shut down.
202    pub async fn next(&mut self) -> Option<NautilusWsMessage> {
203        // Check signal before draining buffer so disconnect takes
204        // priority over pending buffered messages
205        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                                // Transition to CLOSED immediately without waiting
232                                // for ACTIVE (avoids blocking during reconnect)
233                                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, &timestamp, 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, &timestamp, 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                // Coinbase emits this after every subscribe and unsubscribe
338                // with the full current subscription set, so it's noisy at
339                // INFO and not strictly a "confirmation" of the latest action.
340                log::debug!("Subscription state: {events:?}");
341                None
342            }
343            CoinbaseWsMessage::User {
344                timestamp, events, ..
345            } => self.handle_user_events(&events, &timestamp, 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, &timestamp, 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                        // Reverse so pop() drains in exchange order
440                        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        // account_id is intentionally left unset; events should be dropped
780        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                // Status report fields.
808                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                // Raw venue update fields.
824                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                // Carrier metadata.
830                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        // The second product (ETH-USD offline) is buffered behind the BTC one.
892        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                // `total_hold` carries negative values on the wire; ensure
1023                // the signed decimal survives the round trip.
1024                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}