Skip to main content

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