Skip to main content

nautilus_coinbase/http/
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 API responses to Nautilus domain types.
17
18use std::str::FromStr;
19
20use anyhow::Context;
21use jiff::Timestamp;
22use nautilus_core::{UUID4, UnixNanos};
23use nautilus_model::{
24    data::{Bar, BarType, BookOrder, OrderBookDelta, OrderBookDeltas, TradeTick},
25    enums::{
26        AccountType, AggressorSide, BookAction, LiquiditySide, OrderSide, OrderStatus, OrderType,
27        PositionSide, RecordFlag, TimeInForce, TriggerType,
28    },
29    events::AccountState,
30    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, TradeId, VenueOrderId},
31    instruments::{CryptoFuture, CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
32    reports::{FillReport, OrderStatusReport, PositionStatusReport},
33    types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
34};
35use rust_decimal::Decimal;
36
37use crate::{
38    common::{
39        consts::{
40            COINBASE_VENUE, ORDER_CONFIG_BASE_SIZE, ORDER_CONFIG_END_TIME,
41            ORDER_CONFIG_LIMIT_PRICE, ORDER_CONFIG_POST_ONLY, ORDER_CONFIG_STOP_PRICE,
42        },
43        enums::{
44            CoinbaseContractExpiryType, CoinbaseFcmPositionSide, CoinbaseLiquidityIndicator,
45            CoinbaseOrderSide, CoinbaseOrderStatus, CoinbaseOrderType, CoinbaseProductType,
46            CoinbaseTimeInForce,
47        },
48    },
49    http::models::{
50        Account, BookLevel, Candle, CfmBalanceSummary, CfmPosition, Fill, Order, PriceBook,
51        Product, Trade,
52    },
53    websocket::messages::WsFcmBalanceSummary,
54};
55
56/// Parses an RFC 3339 timestamp string to `UnixNanos`.
57pub fn parse_rfc3339_timestamp(timestamp: &str) -> anyhow::Result<UnixNanos> {
58    let dt = timestamp
59        .parse::<Timestamp>()
60        .context(format!("Failed to parse timestamp '{timestamp}'"))?;
61    let nanos = u64::try_from(dt.as_nanosecond())
62        .context(format!("Timestamp out of range: '{timestamp}'"))?;
63    Ok(UnixNanos::from(nanos))
64}
65
66/// Parses a Unix epoch seconds string to `UnixNanos`.
67pub fn parse_epoch_secs_timestamp(epoch_secs: &str) -> anyhow::Result<UnixNanos> {
68    let secs: u64 = epoch_secs
69        .parse()
70        .context(format!("Failed to parse epoch seconds '{epoch_secs}'"))?;
71    Ok(UnixNanos::from(secs * 1_000_000_000))
72}
73
74/// Parses a price string with the given precision.
75pub fn parse_price(value: &str, precision: u8) -> anyhow::Result<Price> {
76    let decimal = Decimal::from_str(value).context(format!("Failed to parse price '{value}'"))?;
77    Price::from_decimal_dp(decimal, precision).context(format!(
78        "Failed to create Price from '{value}' with precision {precision}"
79    ))
80}
81
82/// Parses a quantity string with the given precision.
83pub fn parse_quantity(value: &str, precision: u8) -> anyhow::Result<Quantity> {
84    let decimal =
85        Decimal::from_str(value).context(format!("Failed to parse quantity '{value}'"))?;
86    Quantity::from_decimal_dp(decimal, precision).context(format!(
87        "Failed to create Quantity from '{value}' with precision {precision}"
88    ))
89}
90
91/// Derives precision (number of decimal places) from an increment string.
92///
93/// For example, `"0.01"` returns 2, `"0.00000001"` returns 8, `"1"` returns 0.
94pub fn precision_from_increment(increment: &str) -> u8 {
95    match increment.find('.') {
96        Some(pos) => {
97            let decimals = &increment[pos + 1..];
98            let trimmed_len = decimals.trim_end_matches('0').len();
99            let min = usize::from(!decimals.chars().all(|c| c == '0'));
100            trimmed_len.max(min) as u8
101        }
102        None => 0,
103    }
104}
105
106/// Converts a Coinbase market trade side to a Nautilus aggressor side.
107///
108/// Coinbase reports the maker side on public market trades, so the aggressor is the
109/// opposite side.
110pub fn coinbase_side_to_aggressor(side: &CoinbaseOrderSide) -> AggressorSide {
111    match side {
112        CoinbaseOrderSide::Buy => AggressorSide::Sell,
113        CoinbaseOrderSide::Sell => AggressorSide::Buy,
114        CoinbaseOrderSide::Unknown => AggressorSide::NoAggressor,
115    }
116}
117
118/// Parses an optional quantity from a string, returning `None` for empty,
119/// zero, or values that exceed Nautilus's `QUANTITY_RAW_MAX`.
120fn parse_optional_quantity(value: &str) -> Option<Quantity> {
121    if value.is_empty() || value == "0" {
122        None
123    } else {
124        Quantity::from_str(value).ok()
125    }
126}
127
128/// Derives the base currency from the product, falling back to the first word
129/// in `display_name` when `base_currency_id` is empty (Coinbase futures).
130fn derive_base_currency(product: &Product) -> Currency {
131    if product.base_currency_id.is_empty() {
132        let base_str = product
133            .display_name
134            .split_whitespace()
135            .next()
136            .unwrap_or("UNKNOWN");
137        Currency::get_or_create_crypto(base_str)
138    } else {
139        Currency::get_or_create_crypto(product.base_currency_id)
140    }
141}
142
143/// Extracts the contract size as a multiplier from future product details.
144fn contract_size_multiplier(product: &Product) -> Option<Quantity> {
145    product.future_product_details.as_ref().and_then(|d| {
146        if d.contract_size.is_empty() || d.contract_size == "0" {
147            None
148        } else {
149            Some(Quantity::from(d.contract_size.as_str()))
150        }
151    })
152}
153
154/// Parses a Coinbase spot product into a `CurrencyPair`.
155///
156/// # Panics
157///
158/// Panics if the constructed instrument fails validation.
159pub fn parse_spot_instrument(
160    product: &Product,
161    ts_init: UnixNanos,
162) -> anyhow::Result<InstrumentAny> {
163    let instrument_id = InstrumentId::new(Symbol::new(product.product_id), *COINBASE_VENUE);
164    let raw_symbol = Symbol::new(product.product_id);
165
166    let base_currency = Currency::get_or_create_crypto(product.base_currency_id);
167    let quote_currency = Currency::get_or_create_crypto(product.quote_currency_id);
168
169    let price_precision = precision_from_increment(&product.price_increment);
170    let size_precision = precision_from_increment(&product.base_increment);
171
172    let price_increment = parse_price(&product.price_increment, price_precision)?;
173    let size_increment = parse_quantity(&product.base_increment, size_precision)?;
174
175    let min_quantity = parse_optional_quantity(&product.base_min_size);
176    let max_quantity = parse_optional_quantity(&product.base_max_size);
177
178    let instrument = CurrencyPair::builder()
179        .instrument_id(instrument_id)
180        .raw_symbol(raw_symbol)
181        .base_currency(base_currency)
182        .quote_currency(quote_currency)
183        .price_precision(price_precision)
184        .size_precision(size_precision)
185        .price_increment(price_increment)
186        .size_increment(size_increment)
187        .maybe_max_quantity(max_quantity)
188        .maybe_min_quantity(min_quantity)
189        // maker_fee (loaded separately via transaction_summary)
190        .ts_event(ts_init)
191        .ts_init(ts_init)
192        .build()
193        .unwrap();
194
195    Ok(InstrumentAny::CurrencyPair(instrument))
196}
197
198/// Parses a Coinbase perpetual futures product into a `CryptoPerpetual`.
199///
200/// # Panics
201///
202/// Panics if the constructed instrument fails validation.
203pub fn parse_perpetual_instrument(
204    product: &Product,
205    ts_init: UnixNanos,
206) -> anyhow::Result<InstrumentAny> {
207    let instrument_id = InstrumentId::new(Symbol::new(product.product_id), *COINBASE_VENUE);
208    let raw_symbol = Symbol::new(product.product_id);
209
210    let base_currency = derive_base_currency(product);
211    let quote_currency = Currency::get_or_create_crypto(product.quote_currency_id);
212    let settlement_currency = quote_currency;
213
214    let price_precision = precision_from_increment(&product.price_increment);
215    let size_precision = precision_from_increment(&product.base_increment);
216
217    let price_increment = parse_price(&product.price_increment, price_precision)?;
218    let size_increment = parse_quantity(&product.base_increment, size_precision)?;
219
220    let min_quantity = parse_optional_quantity(&product.base_min_size);
221    let max_quantity = parse_optional_quantity(&product.base_max_size);
222
223    let multiplier = contract_size_multiplier(product);
224
225    let instrument = CryptoPerpetual::builder()
226        .instrument_id(instrument_id)
227        .raw_symbol(raw_symbol)
228        .base_currency(base_currency)
229        .quote_currency(quote_currency)
230        .settlement_currency(settlement_currency)
231        .is_inverse(false)
232        .price_precision(price_precision)
233        .size_precision(size_precision)
234        .price_increment(price_increment)
235        .size_increment(size_increment)
236        .maybe_multiplier(multiplier)
237        .maybe_max_quantity(max_quantity)
238        .maybe_min_quantity(min_quantity)
239        .ts_event(ts_init)
240        .ts_init(ts_init)
241        .build()
242        .unwrap();
243
244    Ok(InstrumentAny::CryptoPerpetual(instrument))
245}
246
247/// Parses a Coinbase dated future into a `CryptoFuture`.
248///
249/// # Panics
250///
251/// Panics if the constructed instrument fails validation.
252pub fn parse_future_instrument(
253    product: &Product,
254    ts_init: UnixNanos,
255) -> anyhow::Result<InstrumentAny> {
256    let instrument_id = InstrumentId::new(Symbol::new(product.product_id), *COINBASE_VENUE);
257    let raw_symbol = Symbol::new(product.product_id);
258
259    let underlying = derive_base_currency(product);
260    let quote_currency = Currency::get_or_create_crypto(product.quote_currency_id);
261    let settlement_currency = quote_currency;
262
263    let price_precision = precision_from_increment(&product.price_increment);
264    let size_precision = precision_from_increment(&product.base_increment);
265
266    let price_increment = parse_price(&product.price_increment, price_precision)?;
267    let size_increment = parse_quantity(&product.base_increment, size_precision)?;
268
269    let min_quantity = parse_optional_quantity(&product.base_min_size);
270    let max_quantity = parse_optional_quantity(&product.base_max_size);
271
272    let expiry_str = product
273        .future_product_details
274        .as_ref()
275        .map_or("", |d| d.contract_expiry.as_str());
276
277    anyhow::ensure!(
278        !expiry_str.is_empty(),
279        "Missing contract_expiry for dated future '{}'",
280        product.product_id
281    );
282
283    let expiration_ns = parse_rfc3339_timestamp(expiry_str).context(format!(
284        "Failed to parse contract_expiry for '{}'",
285        product.product_id
286    ))?;
287
288    let multiplier = contract_size_multiplier(product);
289
290    let instrument = CryptoFuture::builder()
291        .instrument_id(instrument_id)
292        .raw_symbol(raw_symbol)
293        .underlying(underlying)
294        .quote_currency(quote_currency)
295        .settlement_currency(settlement_currency)
296        .is_inverse(false)
297        .activation_ns(ts_init)
298        .expiration_ns(expiration_ns)
299        .price_precision(price_precision)
300        .size_precision(size_precision)
301        .price_increment(price_increment)
302        .size_increment(size_increment)
303        .maybe_multiplier(multiplier)
304        .maybe_max_quantity(max_quantity)
305        .maybe_min_quantity(min_quantity)
306        .ts_event(ts_init)
307        .ts_init(ts_init)
308        .build()
309        .unwrap();
310
311    Ok(InstrumentAny::CryptoFuture(instrument))
312}
313
314/// Parses a Coinbase product into the appropriate Nautilus instrument type.
315pub fn parse_instrument(product: &Product, ts_init: UnixNanos) -> anyhow::Result<InstrumentAny> {
316    match product.product_type {
317        CoinbaseProductType::Spot => parse_spot_instrument(product, ts_init),
318        CoinbaseProductType::Future => {
319            if is_perpetual_product(product) {
320                parse_perpetual_instrument(product, ts_init)
321            } else {
322                parse_future_instrument(product, ts_init)
323            }
324        }
325        CoinbaseProductType::Unknown => {
326            anyhow::bail!("Unknown product type for '{}'", product.product_id)
327        }
328    }
329}
330
331/// Determines whether a futures product is a perpetual contract.
332///
333/// Coinbase returns `contract_expiry_type: "EXPIRING"` for both perpetuals
334/// and dated futures, so the `CoinbaseContractExpiryType::Perpetual` variant
335/// alone is not sufficient. We check three signals in order:
336///
337/// 1. `contract_expiry_type == Perpetual` (forward compat if Coinbase fixes the API)
338/// 2. Non-empty `funding_rate` in `future_product_details` (structural signal:
339///    only perpetuals have ongoing funding)
340/// 3. `display_name` contains "PERP" or "Perpetual" (heuristic fallback)
341pub(crate) fn is_perpetual_product(product: &Product) -> bool {
342    if let Some(details) = &product.future_product_details {
343        if details.contract_expiry_type == CoinbaseContractExpiryType::Perpetual {
344            return true;
345        }
346
347        if !details.funding_rate.is_empty() {
348            return true;
349        }
350    }
351    product.display_name.contains("PERP") || product.display_name.contains("Perpetual")
352}
353
354/// Parses a Coinbase trade into a `TradeTick`.
355pub fn parse_trade_tick(
356    trade: &Trade,
357    instrument_id: InstrumentId,
358    price_precision: u8,
359    size_precision: u8,
360    ts_init: UnixNanos,
361) -> anyhow::Result<TradeTick> {
362    let price = parse_price(&trade.price, price_precision)?;
363    let size = parse_quantity(&trade.size, size_precision)?;
364    let aggressor_side = coinbase_side_to_aggressor(&trade.side);
365    let trade_id = TradeId::new(&trade.trade_id);
366    let ts_event = parse_rfc3339_timestamp(&trade.time)?;
367
368    TradeTick::new_checked(
369        instrument_id,
370        price,
371        size,
372        aggressor_side,
373        trade_id,
374        ts_event,
375        ts_init,
376    )
377}
378
379/// Parses a Coinbase candle into a `Bar`.
380pub fn parse_bar(
381    candle: &Candle,
382    bar_type: BarType,
383    price_precision: u8,
384    size_precision: u8,
385    ts_init: UnixNanos,
386) -> anyhow::Result<Bar> {
387    let open = parse_price(&candle.open, price_precision)?;
388    let high = parse_price(&candle.high, price_precision)?;
389    let low = parse_price(&candle.low, price_precision)?;
390    let close = parse_price(&candle.close, price_precision)?;
391    let volume = parse_quantity(&candle.volume, size_precision)?;
392
393    // Coinbase candle "start" is epoch seconds for the candle open time
394    let ts_event = parse_epoch_secs_timestamp(&candle.start)?;
395
396    Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
397}
398
399/// Parses a Coinbase order book snapshot into `OrderBookDeltas`.
400pub fn parse_product_book_snapshot(
401    book: &PriceBook,
402    instrument_id: InstrumentId,
403    price_precision: u8,
404    size_precision: u8,
405    ts_init: UnixNanos,
406) -> anyhow::Result<OrderBookDeltas> {
407    let ts_event = parse_rfc3339_timestamp(&book.time)?;
408    let total_levels = book.bids.len() + book.asks.len();
409    let mut deltas = Vec::with_capacity(total_levels + 1);
410
411    let mut clear = OrderBookDelta::clear(instrument_id, 0, ts_event, ts_init);
412    clear.flags |= RecordFlag::F_SNAPSHOT as u8;
413
414    if total_levels == 0 {
415        clear.flags |= RecordFlag::F_LAST as u8;
416    }
417    deltas.push(clear);
418
419    let mut processed = 0usize;
420
421    for level in &book.bids {
422        processed += 1;
423        let delta = parse_book_delta(
424            level,
425            OrderSide::Buy,
426            instrument_id,
427            price_precision,
428            size_precision,
429            processed == total_levels,
430            ts_event,
431            ts_init,
432        )?;
433        deltas.push(delta);
434    }
435
436    for level in &book.asks {
437        processed += 1;
438        let delta = parse_book_delta(
439            level,
440            OrderSide::Sell,
441            instrument_id,
442            price_precision,
443            size_precision,
444            processed == total_levels,
445            ts_event,
446            ts_init,
447        )?;
448        deltas.push(delta);
449    }
450
451    OrderBookDeltas::new_checked(instrument_id, deltas)
452}
453
454#[expect(clippy::too_many_arguments)]
455fn parse_book_delta(
456    level: &BookLevel,
457    side: OrderSide,
458    instrument_id: InstrumentId,
459    price_precision: u8,
460    size_precision: u8,
461    is_last: bool,
462    ts_event: UnixNanos,
463    ts_init: UnixNanos,
464) -> anyhow::Result<OrderBookDelta> {
465    let price = parse_price(&level.price, price_precision)?;
466    let size = parse_quantity(&level.size, size_precision)?;
467
468    let mut flags = RecordFlag::F_MBP as u8 | RecordFlag::F_SNAPSHOT as u8;
469
470    if is_last {
471        flags |= RecordFlag::F_LAST as u8;
472    }
473
474    let order = BookOrder::new(side, price, size, 0);
475    OrderBookDelta::new_checked(
476        instrument_id,
477        BookAction::Add,
478        order,
479        flags,
480        0,
481        ts_event,
482        ts_init,
483    )
484}
485
486/// Converts a Coinbase order side to the Nautilus [`Option<OrderSide>`].
487pub fn parse_order_side_optional(side: &CoinbaseOrderSide) -> Option<OrderSide> {
488    match side {
489        CoinbaseOrderSide::Buy => Some(OrderSide::Buy),
490        CoinbaseOrderSide::Sell => Some(OrderSide::Sell),
491        CoinbaseOrderSide::Unknown => None,
492    }
493}
494
495/// Converts a Coinbase order side to a Nautilus [`OrderSide`].
496///
497/// # Errors
498///
499/// Returns an error if Coinbase supplies an unknown side.
500pub fn parse_order_side(side: &CoinbaseOrderSide) -> anyhow::Result<OrderSide> {
501    match side {
502        CoinbaseOrderSide::Buy => Ok(OrderSide::Buy),
503        CoinbaseOrderSide::Sell => Ok(OrderSide::Sell),
504        CoinbaseOrderSide::Unknown => anyhow::bail!("Coinbase fill has unknown order side"),
505    }
506}
507
508/// Converts a Coinbase order status to the Nautilus [`OrderStatus`].
509///
510/// `Pending` and `Queued` are transient pre-`Open` states the venue passes
511/// through after acknowledging the order. They are mapped to `Accepted`
512/// (rather than `Submitted`) so user-channel updates that race the REST
513/// `OrderAccepted` event do not appear as a backwards transition to the
514/// reconciler. `Open` also maps to `Accepted` because Nautilus differentiates
515/// the initial accept event from later partial-fill states; callers should
516/// promote the status to `PartiallyFilled` / `Filled` based on `filled_qty`.
517pub fn parse_order_status(status: CoinbaseOrderStatus) -> OrderStatus {
518    match status {
519        CoinbaseOrderStatus::Pending | CoinbaseOrderStatus::Queued | CoinbaseOrderStatus::Open => {
520            OrderStatus::Accepted
521        }
522        CoinbaseOrderStatus::Filled => OrderStatus::Filled,
523        CoinbaseOrderStatus::Cancelled => OrderStatus::Canceled,
524        CoinbaseOrderStatus::CancelQueued => OrderStatus::PendingCancel,
525        CoinbaseOrderStatus::EditQueued => OrderStatus::PendingUpdate,
526        CoinbaseOrderStatus::Expired => OrderStatus::Expired,
527        CoinbaseOrderStatus::Failed => OrderStatus::Rejected,
528        CoinbaseOrderStatus::Unknown => OrderStatus::Rejected,
529    }
530}
531
532/// Converts a Coinbase time-in-force to the Nautilus [`TimeInForce`].
533pub fn parse_time_in_force(tif: Option<CoinbaseTimeInForce>) -> TimeInForce {
534    match tif {
535        Some(CoinbaseTimeInForce::GoodUntilCancelled) => TimeInForce::Gtc,
536        Some(CoinbaseTimeInForce::GoodUntilDateTime) => TimeInForce::Gtd,
537        Some(CoinbaseTimeInForce::ImmediateOrCancel) => TimeInForce::Ioc,
538        Some(CoinbaseTimeInForce::FillOrKill) => TimeInForce::Fok,
539        Some(CoinbaseTimeInForce::Unknown) | None => TimeInForce::Gtc,
540    }
541}
542
543/// Converts a Coinbase liquidity indicator to the Nautilus [`LiquiditySide`].
544pub fn parse_liquidity_side(indicator: &CoinbaseLiquidityIndicator) -> LiquiditySide {
545    match indicator {
546        CoinbaseLiquidityIndicator::Maker => LiquiditySide::Maker,
547        CoinbaseLiquidityIndicator::Taker => LiquiditySide::Taker,
548        CoinbaseLiquidityIndicator::Unknown => LiquiditySide::NoLiquiditySide,
549    }
550}
551
552/// Converts a Coinbase order type to the Nautilus [`OrderType`].
553///
554/// Coinbase uses `BRACKET` on history endpoints for multi-leg orders. Nautilus
555/// has no bracket order type, so the parser falls back to [`OrderType::Limit`].
556pub fn parse_order_type(order_type: CoinbaseOrderType) -> OrderType {
557    match order_type {
558        CoinbaseOrderType::Market => OrderType::Market,
559        CoinbaseOrderType::Limit => OrderType::Limit,
560        CoinbaseOrderType::Stop => OrderType::StopMarket,
561        CoinbaseOrderType::StopLimit => OrderType::StopLimit,
562        CoinbaseOrderType::Liquidation => OrderType::Market,
563        CoinbaseOrderType::Bracket
564        | CoinbaseOrderType::Twap
565        | CoinbaseOrderType::RollOpen
566        | CoinbaseOrderType::RollClose
567        | CoinbaseOrderType::Scaled
568        | CoinbaseOrderType::Unknown => OrderType::Limit,
569    }
570}
571
572/// Parses a Coinbase [`Order`] into an [`OrderStatusReport`].
573///
574/// Uses the given instrument's price and size precision to build quantities
575/// and prices, and derives the limit price from the order configuration when
576/// present. Timestamps default to `ts_init` when Coinbase omits them.
577///
578/// # Errors
579///
580/// Returns an error if any numeric field cannot be parsed against the instrument precision.
581pub fn parse_order_status_report(
582    order: &Order,
583    instrument: &InstrumentAny,
584    account_id: AccountId,
585    ts_init: UnixNanos,
586) -> anyhow::Result<OrderStatusReport> {
587    let instrument_id = instrument.id();
588    let price_precision = instrument.price_precision();
589    let size_precision = instrument.size_precision();
590
591    let order_side = parse_order_side_optional(&order.side);
592    let order_type = parse_order_type(order.order_type);
593    let time_in_force = parse_time_in_force(order.time_in_force);
594    let mut order_status = parse_order_status(order.status);
595
596    let venue_order_id = VenueOrderId::new(&order.order_id);
597    let client_order_id = if order.client_order_id.is_empty() {
598        None
599    } else {
600        Some(ClientOrderId::new(&order.client_order_id))
601    };
602
603    let filled_qty = if order.filled_size.is_empty() {
604        Quantity::zero(size_precision)
605    } else {
606        parse_quantity(&order.filled_size, size_precision).context("failed to parse filled_size")?
607    };
608
609    // API has no separate ADL flag, so liquidation and ADL share this branch
610    if order.order_type == CoinbaseOrderType::Liquidation || order.is_liquidation {
611        let order_side = order_side.as_ref().map_or("NO_ORDER_SIDE", AsRef::as_ref);
612        log::warn!(
613            "Forced-close (liquidation/ADL) order: {instrument_id} venue_order_id={venue_order_id} side={order_side} filled={filled_qty}",
614        );
615    }
616
617    // Derive the ordered quantity from the order_configuration. For quote-sized
618    // market orders the base quantity is not reported pre-fill; fall back to
619    // filled_qty when the order is terminal.
620    let quantity = base_quantity_from_configuration(order, size_precision).unwrap_or(filled_qty);
621
622    // Promote Accepted to PartiallyFilled when some fill has landed but the
623    // order is still open, matching Nautilus' lifecycle.
624    if order_status == OrderStatus::Accepted && filled_qty.is_positive() && filled_qty < quantity {
625        order_status = OrderStatus::PartiallyFilled;
626    }
627
628    let ts_accepted = if order.created_time.is_empty() {
629        ts_init
630    } else {
631        parse_rfc3339_timestamp(&order.created_time).unwrap_or(ts_init)
632    };
633    let ts_last = order
634        .last_fill_time
635        .as_deref()
636        .filter(|s| !s.is_empty())
637        .and_then(|s| parse_rfc3339_timestamp(s).ok())
638        .unwrap_or(ts_accepted);
639
640    let mut report = OrderStatusReport::new(
641        account_id,
642        instrument_id,
643        client_order_id,
644        venue_order_id,
645        order_side,
646        order_type,
647        time_in_force,
648        order_status,
649        quantity,
650        filled_qty,
651        ts_accepted,
652        ts_last,
653        ts_init,
654        None,
655    );
656
657    if let Some(price) = limit_price_from_configuration(order, price_precision) {
658        report = report.with_price(price);
659    }
660
661    if let Some(trigger_price) = stop_price_from_configuration(order, price_precision) {
662        report = report
663            .with_trigger_price(trigger_price)
664            .with_trigger_type(TriggerType::LastPrice);
665    }
666
667    if !order.average_filled_price.is_empty()
668        && let Ok(avg_decimal) = Decimal::from_str(&order.average_filled_price)
669        && avg_decimal.is_sign_positive()
670        && !avg_decimal.is_zero()
671    {
672        report = report.with_avg_px(avg_decimal);
673    }
674
675    if post_only_from_configuration(order) {
676        report = report.with_post_only(true);
677    }
678
679    if let Some(expire_time) = end_time_from_configuration(order) {
680        report = report.with_expire_time(expire_time);
681    }
682
683    Ok(report)
684}
685
686/// Parses a Coinbase [`Fill`] into a [`FillReport`].
687///
688/// Commission currency defaults to the instrument's quote currency, which
689/// matches how Coinbase reports fees for spot products. Negates the fee sign
690/// to follow the Nautilus convention where commissions are positive when
691/// paid by the taker.
692///
693/// # Errors
694///
695/// Returns an error if the price or size cannot be parsed, or the commission cannot be converted to
696/// `Money`.
697pub fn parse_fill_report(
698    fill: &Fill,
699    instrument: &InstrumentAny,
700    account_id: AccountId,
701    ts_init: UnixNanos,
702) -> anyhow::Result<FillReport> {
703    let instrument_id = instrument.id();
704    let price_precision = instrument.price_precision();
705    let size_precision = instrument.size_precision();
706
707    let venue_order_id = VenueOrderId::new(&fill.order_id);
708    let trade_id = TradeId::new(&fill.trade_id);
709    let order_side = parse_order_side(&fill.side)?;
710    let last_px = parse_price(&fill.price, price_precision)?;
711    let last_qty = parse_quantity(&fill.size, size_precision)?;
712
713    let commission_currency = instrument.quote_currency();
714    let commission = Money::from_decimal(fill.commission, commission_currency)
715        .context("failed to build commission Money")?;
716
717    let liquidity_side = parse_liquidity_side(&fill.liquidity_indicator);
718    let ts_event = parse_rfc3339_timestamp(&fill.trade_time)?;
719
720    Ok(FillReport::new(
721        account_id,
722        instrument_id,
723        venue_order_id,
724        trade_id,
725        order_side,
726        last_qty,
727        last_px,
728        commission,
729        liquidity_side,
730        None, // client_order_id not carried on Coinbase fill records
731        None, // venue_position_id not provided
732        ts_event,
733        ts_init,
734        None,
735    ))
736}
737
738/// Parses a list of Coinbase [`Account`] entries into a Nautilus [`AccountState`].
739///
740/// Builds one [`AccountBalance`] per currency where
741/// `total = available_balance + hold`, `free = available_balance`, and
742/// `locked = hold`. Accounts with invalid balances are skipped with a debug
743/// log. Always emits at least one balance so the resulting
744/// [`AccountState`] is valid.
745///
746/// # Errors
747///
748/// Returns an error if building a balance fails after all accounts have been exhausted (i.e. every
749/// entry was malformed).
750pub fn parse_account_state(
751    accounts: &[Account],
752    account_id: AccountId,
753    is_reported: bool,
754    ts_event: UnixNanos,
755    ts_init: UnixNanos,
756) -> anyhow::Result<AccountState> {
757    // Coinbase returns one row per wallet, so the same currency may appear
758    // multiple times (per retail portfolio or sub-account). Aggregate by
759    // currency before emitting balances: Nautilus stores balances keyed by
760    // `Currency`, so emitting duplicates would drop funds via last-write-wins.
761    let mut aggregated: ahash::AHashMap<Currency, (Money, Money)> = ahash::AHashMap::new();
762
763    for account in accounts {
764        let currency_code = account.currency.trim();
765        if currency_code.is_empty() {
766            log::debug!(
767                "Skipping account with empty currency code: uuid={}",
768                account.uuid
769            );
770            continue;
771        }
772
773        let currency =
774            Currency::get_or_create_crypto_with_context(currency_code, Some("coinbase account"));
775
776        let Some(free) = parse_money_field(
777            account.available_balance.value,
778            "available_balance",
779            currency,
780        ) else {
781            continue;
782        };
783
784        let locked = match account.hold.as_ref() {
785            Some(hold) => {
786                parse_money_field(hold.value, "hold", currency).unwrap_or(Money::zero(currency))
787            }
788            None => Money::zero(currency),
789        };
790
791        aggregated
792            .entry(currency)
793            .and_modify(|(acc_free, acc_locked)| {
794                *acc_free = *acc_free + free;
795                *acc_locked = *acc_locked + locked;
796            })
797            .or_insert((free, locked));
798    }
799
800    let mut balances: Vec<AccountBalance> = aggregated
801        .into_iter()
802        .map(|(currency, (free, locked))| {
803            let total = free + locked;
804            AccountBalance::from_total_and_locked(total.as_decimal(), locked.as_decimal(), currency)
805                .map_err(anyhow::Error::from)
806        })
807        .collect::<anyhow::Result<Vec<_>>>()?;
808
809    if balances.is_empty() {
810        let fallback_currency = Currency::USD();
811        let zero = Money::zero(fallback_currency);
812        balances.push(AccountBalance::new(zero, zero, zero));
813    }
814
815    Ok(AccountState::new(
816        account_id,
817        AccountType::Cash,
818        balances,
819        Vec::new(),
820        is_reported,
821        UUID4::new(),
822        ts_event,
823        ts_init,
824        None,
825    ))
826}
827
828fn parse_money_field(value: Decimal, field: &str, currency: Currency) -> Option<Money> {
829    match Money::from_decimal(value, currency) {
830        Ok(money) => Some(money),
831        Err(e) => {
832            log::debug!(
833                "Skipping {field}='{value}' for currency {}: {e}",
834                currency.code
835            );
836            None
837        }
838    }
839}
840
841// Coinbase reports the CFM buffer as a percentage of `liquidation_threshold`
842// (e.g. "100" = 1x cushion). Warn at <20% so operators can react before the
843// liquidation engine (or, on perps, the deleveraging waterfall) fires.
844const CFM_LIQUIDATION_BUFFER_WARN_PCT: Decimal = Decimal::from_parts(20, 0, 0, false, 0);
845
846fn liquidation_buffer_in_warn_band(buffer_percentage: Decimal) -> bool {
847    buffer_percentage < CFM_LIQUIDATION_BUFFER_WARN_PCT
848}
849
850fn warn_if_liquidation_buffer_low(account_id: AccountId, buffer_percentage: Decimal) {
851    if liquidation_buffer_in_warn_band(buffer_percentage) {
852        log::warn!(
853            "Elevated CFM liquidation risk: {account_id} liquidation_buffer_percentage={buffer_percentage}% (warn threshold {CFM_LIQUIDATION_BUFFER_WARN_PCT}%)",
854        );
855    }
856}
857
858/// Parses a CFM balance summary into a single consolidated [`MarginBalance`].
859///
860/// Coinbase reports two windows (intraday and overnight) with identical
861/// currency, but `MarginAccount::split_event_margins` keys account-level
862/// margins by currency only, so emitting both would have one overwrite the
863/// other. Selecting per-field maxima could synthesize a pair that matches
864/// neither window, so we pick the whole window with the larger
865/// `initial_margin` (ties broken by `maintenance_margin`) and emit its pair
866/// verbatim; the stricter capital requirement governs risk.
867///
868/// # Errors
869///
870/// Returns an error if any balance cannot be built as [`Money`].
871pub fn parse_cfm_margin_balances(
872    summary: &CfmBalanceSummary,
873) -> anyhow::Result<Vec<MarginBalance>> {
874    let Some(window) = [
875        summary.intraday_margin_window_measure.as_ref(),
876        summary.overnight_margin_window_measure.as_ref(),
877    ]
878    .into_iter()
879    .flatten()
880    .max_by(|a, b| {
881        a.initial_margin
882            .value
883            .cmp(&b.initial_margin.value)
884            .then(a.maintenance_margin.value.cmp(&b.maintenance_margin.value))
885    }) else {
886        return Ok(Vec::new());
887    };
888
889    let currency = Currency::get_or_create_crypto(window.initial_margin.currency.as_str());
890    let initial = Money::from_decimal(window.initial_margin.value, currency)
891        .context("failed to build initial margin")?;
892    let maintenance = Money::from_decimal(window.maintenance_margin.value, currency)
893        .context("failed to build maintenance margin")?;
894
895    Ok(vec![MarginBalance::new(initial, maintenance, None)])
896}
897
898/// Builds a margin [`AccountState`] from the CFM balance summary and the
899/// current CBI / CFM USD balances.
900///
901/// # Errors
902///
903/// Returns an error if balances cannot be built from the summary values.
904pub fn parse_cfm_account_state(
905    summary: &CfmBalanceSummary,
906    account_id: AccountId,
907    is_reported: bool,
908    ts_event: UnixNanos,
909    ts_init: UnixNanos,
910) -> anyhow::Result<AccountState> {
911    if let Ok(buffer_pct) = Decimal::from_str(&summary.liquidation_buffer_percentage) {
912        warn_if_liquidation_buffer_low(account_id, buffer_pct);
913    }
914
915    let usd_currency = Currency::get_or_create_crypto(summary.total_usd_balance.currency.as_str());
916
917    // `total_usd_balance` is the venue's equity figure and includes collateral
918    // already consumed by open positions; using it as total (with
919    // `available_margin` as free) preserves equity so `Portfolio::equity`
920    // matches the venue. `from_total_and_free` derives locked as total - free
921    // so the `total == free + locked` invariant holds by construction.
922    let balance = AccountBalance::from_total_and_free(
923        summary.total_usd_balance.value,
924        summary.available_margin.value,
925        usd_currency,
926    )
927    .context("failed to build CFM account balance")?;
928
929    let margins = parse_cfm_margin_balances(summary)?;
930
931    Ok(AccountState::new(
932        account_id,
933        AccountType::Margin,
934        vec![balance],
935        margins,
936        is_reported,
937        UUID4::new(),
938        ts_event,
939        ts_init,
940        None,
941    ))
942}
943
944/// Builds a margin [`AccountState`] from a WebSocket-delivered FCM balance
945/// summary.
946///
947/// The WebSocket payload does not carry explicit currency codes, so the
948/// balance is reported in USD (the only CFM settlement currency).
949///
950/// # Errors
951///
952/// Returns an error if any component balance cannot be constructed.
953pub fn parse_ws_cfm_account_state(
954    summary: &WsFcmBalanceSummary,
955    account_id: AccountId,
956    ts_event: UnixNanos,
957    ts_init: UnixNanos,
958) -> anyhow::Result<AccountState> {
959    warn_if_liquidation_buffer_low(account_id, summary.liquidation_buffer_percentage);
960
961    let usd = Currency::USD();
962
963    // See `parse_cfm_account_state`: `total_usd_balance` is the venue's
964    // equity and must be kept so cached balance and `Portfolio::equity` align
965    // with the venue.
966    let balance = AccountBalance::from_total_and_free(
967        summary.total_usd_balance,
968        summary.available_margin,
969        usd,
970    )
971    .context("failed to build WS CFM account balance")?;
972
973    // Pick the window with the larger `initial_margin` (ties by maintenance)
974    // and emit its pair verbatim so the emitted MarginBalance matches a real
975    // venue window. See `parse_cfm_margin_balances` for why.
976    let window = if summary
977        .intraday_margin_window_measure
978        .initial_margin
979        .cmp(&summary.overnight_margin_window_measure.initial_margin)
980        .then(
981            summary
982                .intraday_margin_window_measure
983                .maintenance_margin
984                .cmp(&summary.overnight_margin_window_measure.maintenance_margin),
985        )
986        .is_ge()
987    {
988        &summary.intraday_margin_window_measure
989    } else {
990        &summary.overnight_margin_window_measure
991    };
992
993    let initial = Money::from_decimal(window.initial_margin, usd)
994        .context("failed to build initial margin")?;
995    let maintenance = Money::from_decimal(window.maintenance_margin, usd)
996        .context("failed to build maintenance margin")?;
997
998    Ok(AccountState::new(
999        account_id,
1000        AccountType::Margin,
1001        vec![balance],
1002        vec![MarginBalance::new(initial, maintenance, None)],
1003        true,
1004        UUID4::new(),
1005        ts_event,
1006        ts_init,
1007        None,
1008    ))
1009}
1010
1011/// Parses a single CFM position into a Nautilus [`PositionStatusReport`].
1012///
1013/// The position's quantity is scaled by `contract_size` (expressed in the
1014/// instrument's size precision). Callers are expected to supply the
1015/// matching instrument so precision lines up with the venue's reported
1016/// number of contracts.
1017///
1018/// # Errors
1019///
1020/// Returns an error if the quantity or average entry price cannot be represented with the
1021/// instrument's precision.
1022pub fn parse_cfm_position_status_report(
1023    position: &CfmPosition,
1024    instrument: &InstrumentAny,
1025    account_id: AccountId,
1026    ts_init: UnixNanos,
1027) -> anyhow::Result<PositionStatusReport> {
1028    let instrument_id = instrument.id();
1029    let size_precision = instrument.size_precision();
1030
1031    let position_side = match position.side {
1032        CoinbaseFcmPositionSide::Long => PositionSide::Long,
1033        CoinbaseFcmPositionSide::Short => PositionSide::Short,
1034        CoinbaseFcmPositionSide::Unspecified => PositionSide::Flat,
1035    };
1036
1037    let quantity = Quantity::from_decimal_dp(position.number_of_contracts, size_precision)
1038        .context("failed to build CFM position quantity")?;
1039
1040    let avg_px_open = if position.avg_entry_price.value.is_zero() {
1041        None
1042    } else {
1043        Some(position.avg_entry_price.value)
1044    };
1045
1046    Ok(PositionStatusReport::new(
1047        account_id,
1048        instrument_id,
1049        position_side,
1050        quantity,
1051        ts_init,
1052        ts_init,
1053        None,
1054        None,
1055        avg_px_open,
1056    ))
1057}
1058
1059// Coinbase history endpoints return a wider set of configuration shapes than
1060// `OrderConfiguration` covers (bracket, TWAP, trigger variants). History
1061// `Order.order_configuration` is kept as a raw `serde_json::Value`; these
1062// accessors read the value by key so unknown shapes simply return `None`
1063// instead of failing the whole batch.
1064fn base_quantity_from_configuration(order: &Order, size_precision: u8) -> Option<Quantity> {
1065    let config = order.order_configuration.as_ref()?.as_object()?;
1066
1067    for (_key, inner) in config {
1068        let Some(inner_obj) = inner.as_object() else {
1069            continue;
1070        };
1071
1072        if let Some(size) = inner_obj
1073            .get(ORDER_CONFIG_BASE_SIZE)
1074            .and_then(|v| v.as_str())
1075            && !size.is_empty()
1076            && let Ok(qty) = parse_quantity(size, size_precision)
1077        {
1078            return Some(qty);
1079        }
1080    }
1081
1082    None
1083}
1084
1085fn limit_price_from_configuration(order: &Order, price_precision: u8) -> Option<Price> {
1086    let config = order.order_configuration.as_ref()?.as_object()?;
1087
1088    for (_key, inner) in config {
1089        let Some(inner_obj) = inner.as_object() else {
1090            continue;
1091        };
1092
1093        if let Some(price) = inner_obj
1094            .get(ORDER_CONFIG_LIMIT_PRICE)
1095            .and_then(|v| v.as_str())
1096            && !price.is_empty()
1097            && let Ok(parsed) = parse_price(price, price_precision)
1098        {
1099            return Some(parsed);
1100        }
1101    }
1102
1103    None
1104}
1105
1106fn stop_price_from_configuration(order: &Order, price_precision: u8) -> Option<Price> {
1107    let config = order.order_configuration.as_ref()?.as_object()?;
1108
1109    for (_key, inner) in config {
1110        let Some(inner_obj) = inner.as_object() else {
1111            continue;
1112        };
1113
1114        if let Some(stop) = inner_obj
1115            .get(ORDER_CONFIG_STOP_PRICE)
1116            .and_then(|v| v.as_str())
1117            && !stop.is_empty()
1118            && let Ok(parsed) = parse_price(stop, price_precision)
1119        {
1120            return Some(parsed);
1121        }
1122    }
1123
1124    None
1125}
1126
1127fn post_only_from_configuration(order: &Order) -> bool {
1128    let Some(config) = order
1129        .order_configuration
1130        .as_ref()
1131        .and_then(|v| v.as_object())
1132    else {
1133        return false;
1134    };
1135
1136    for (_key, inner) in config {
1137        if let Some(inner_obj) = inner.as_object()
1138            && let Some(post_only) = inner_obj
1139                .get(ORDER_CONFIG_POST_ONLY)
1140                .and_then(|v| v.as_bool())
1141        {
1142            return post_only;
1143        }
1144    }
1145    false
1146}
1147
1148fn end_time_from_configuration(order: &Order) -> Option<UnixNanos> {
1149    let config = order.order_configuration.as_ref()?.as_object()?;
1150
1151    for (_key, inner) in config {
1152        if let Some(inner_obj) = inner.as_object()
1153            && let Some(end_time) = inner_obj
1154                .get(ORDER_CONFIG_END_TIME)
1155                .and_then(|v| v.as_str())
1156            && !end_time.is_empty()
1157            && let Ok(ts) = parse_rfc3339_timestamp(end_time)
1158        {
1159            return Some(ts);
1160        }
1161    }
1162
1163    None
1164}
1165
1166#[cfg(test)]
1167mod tests {
1168    use nautilus_model::{
1169        data::bar::{BarSpecification, BarType},
1170        enums::{AggregationSource, BarAggregation, PriceType},
1171        identifiers::Venue,
1172        instruments::Instrument,
1173    };
1174    use rstest::rstest;
1175    use ustr::Ustr;
1176
1177    use super::*;
1178    use crate::{
1179        common::{
1180            consts::COINBASE_VENUE,
1181            enums::{CoinbaseMarginLevel, CoinbaseMarginWindowType},
1182            testing::load_test_fixture,
1183        },
1184        http::models::{Account, Balance},
1185    };
1186
1187    fn coinbase_venue() -> Venue {
1188        *COINBASE_VENUE
1189    }
1190
1191    #[rstest]
1192    #[case("0.01", 2)]
1193    #[case("0.00000001", 8)]
1194    #[case("1", 0)]
1195    #[case("5", 0)]
1196    #[case("0.1", 1)]
1197    #[case("0.001", 3)]
1198    #[case("25.000", 0)]
1199    fn test_precision_from_increment(#[case] increment: &str, #[case] expected: u8) {
1200        assert_eq!(precision_from_increment(increment), expected);
1201    }
1202
1203    #[rstest]
1204    fn test_parse_rfc3339_timestamp() {
1205        let ts = parse_rfc3339_timestamp("2026-04-07T00:28:32.643779Z").unwrap();
1206        assert_eq!(ts.as_u64(), 1_775_521_712_643_779_000);
1207    }
1208
1209    #[rstest]
1210    #[case("")]
1211    #[case("not-a-date")]
1212    #[case("2026-13-01T00:00:00Z")]
1213    fn test_parse_rfc3339_timestamp_rejects_invalid(#[case] input: &str) {
1214        assert!(parse_rfc3339_timestamp(input).is_err());
1215    }
1216
1217    #[rstest]
1218    fn test_parse_epoch_secs_timestamp() {
1219        let ts = parse_epoch_secs_timestamp("1712192400").unwrap();
1220        assert_eq!(ts.as_u64(), 1_712_192_400_000_000_000);
1221    }
1222
1223    #[rstest]
1224    #[case("")]
1225    #[case("abc")]
1226    fn test_parse_epoch_secs_timestamp_rejects_invalid(#[case] input: &str) {
1227        assert!(parse_epoch_secs_timestamp(input).is_err());
1228    }
1229
1230    #[rstest]
1231    fn test_parse_price_valid() {
1232        let price = parse_price("68913.87", 2).unwrap();
1233        assert_eq!(price, Price::from("68913.87"));
1234    }
1235
1236    #[rstest]
1237    #[case("")]
1238    #[case("abc")]
1239    fn test_parse_price_rejects_invalid(#[case] input: &str) {
1240        assert!(parse_price(input, 2).is_err());
1241    }
1242
1243    #[rstest]
1244    fn test_parse_quantity_valid() {
1245        let qty = parse_quantity("0.00014004", 8).unwrap();
1246        assert_eq!(qty, Quantity::from("0.00014004"));
1247    }
1248
1249    #[rstest]
1250    #[case("")]
1251    #[case("abc")]
1252    fn test_parse_quantity_rejects_invalid(#[case] input: &str) {
1253        assert!(parse_quantity(input, 8).is_err());
1254    }
1255
1256    #[rstest]
1257    fn test_parse_spot_instrument() {
1258        let json = load_test_fixture("http_product.json");
1259        let product: crate::http::models::Product = serde_json::from_str(&json).unwrap();
1260        let ts = UnixNanos::default();
1261
1262        let instrument = parse_spot_instrument(&product, ts).unwrap();
1263        let pair = match &instrument {
1264            InstrumentAny::CurrencyPair(p) => p,
1265            other => panic!("Expected CurrencyPair, was{other:?}"),
1266        };
1267
1268        assert_eq!(pair.id().symbol.as_str(), "BTC-USD");
1269        assert_eq!(pair.id().venue, coinbase_venue());
1270        assert_eq!(pair.base_currency().unwrap().code, "BTC");
1271        assert_eq!(pair.quote_currency().code, "USD");
1272        assert_eq!(pair.price_precision(), 2);
1273        assert_eq!(pair.size_precision(), 8);
1274        assert_eq!(pair.price_increment(), Price::from("0.01"));
1275        assert_eq!(pair.size_increment(), Quantity::from("0.00000001"));
1276        assert_eq!(pair.min_quantity(), Some(Quantity::from("0.00000001")));
1277        assert_eq!(pair.max_quantity(), Some(Quantity::from("3400")));
1278    }
1279
1280    #[rstest]
1281    fn test_parse_spot_instrument_normalizes_padded_increments() {
1282        let json = load_test_fixture("http_product.json");
1283        let mut product: crate::http::models::Product = serde_json::from_str(&json).unwrap();
1284        product.price_increment = "25.000".to_string();
1285        product.base_increment = "1.2300".to_string();
1286
1287        let instrument = parse_spot_instrument(&product, UnixNanos::default()).unwrap();
1288        let InstrumentAny::CurrencyPair(pair) = instrument else {
1289            panic!("Expected CurrencyPair");
1290        };
1291
1292        assert_eq!(pair.price_precision(), 0);
1293        assert_eq!(pair.price_increment(), Price::from("25"));
1294        assert_eq!(pair.price_increment().precision, 0);
1295        assert_eq!(pair.size_precision(), 2);
1296        assert_eq!(pair.size_increment(), Quantity::from("1.23"));
1297        assert_eq!(pair.size_increment().precision, 2);
1298    }
1299
1300    #[rstest]
1301    fn test_parse_derivative_instruments_normalize_padded_increments() {
1302        let json = load_test_fixture("http_products_future.json");
1303        let response: crate::http::models::ProductsResponse = serde_json::from_str(&json).unwrap();
1304        let mut perp_product = response
1305            .products
1306            .iter()
1307            .find(|product| product.display_name.contains("PERP"))
1308            .unwrap()
1309            .clone();
1310        let mut future_product = response
1311            .products
1312            .iter()
1313            .find(|product| !product.display_name.contains("PERP"))
1314            .unwrap()
1315            .clone();
1316
1317        for product in [&mut perp_product, &mut future_product] {
1318            product.price_increment = "25.000".to_string();
1319            product.base_increment = "1.2300".to_string();
1320        }
1321
1322        let perp = parse_perpetual_instrument(&perp_product, UnixNanos::default()).unwrap();
1323        let future = parse_future_instrument(&future_product, UnixNanos::default()).unwrap();
1324
1325        for instrument in [&perp, &future] {
1326            assert_eq!(instrument.price_precision(), 0);
1327            assert_eq!(instrument.price_increment(), Price::from("25"));
1328            assert_eq!(instrument.price_increment().precision, 0);
1329            assert_eq!(instrument.size_precision(), 2);
1330            assert_eq!(instrument.size_increment(), Quantity::from("1.23"));
1331            assert_eq!(instrument.size_increment().precision, 2);
1332        }
1333    }
1334
1335    #[rstest]
1336    fn test_parse_spot_instruments_from_list() {
1337        let json = load_test_fixture("http_products.json");
1338        let response: crate::http::models::ProductsResponse = serde_json::from_str(&json).unwrap();
1339        let ts = UnixNanos::default();
1340
1341        let instruments: Vec<InstrumentAny> = response
1342            .products
1343            .iter()
1344            .map(|p| parse_instrument(p, ts).unwrap())
1345            .collect();
1346
1347        assert_eq!(instruments.len(), 2);
1348        for inst in &instruments {
1349            assert!(matches!(inst, InstrumentAny::CurrencyPair(_)));
1350        }
1351    }
1352
1353    #[rstest]
1354    fn test_parse_future_instruments_distinguishes_perp_and_dated() {
1355        let json = load_test_fixture("http_products_future.json");
1356        let response: crate::http::models::ProductsResponse = serde_json::from_str(&json).unwrap();
1357        let ts = UnixNanos::default();
1358
1359        let instruments: Vec<InstrumentAny> = response
1360            .products
1361            .iter()
1362            .map(|p| parse_instrument(p, ts).unwrap())
1363            .collect();
1364
1365        assert_eq!(instruments.len(), 2);
1366
1367        // First product is "BTC PERP" -> CryptoPerpetual
1368        assert!(
1369            matches!(&instruments[0], InstrumentAny::CryptoPerpetual(_)),
1370            "Expected CryptoPerpetual for BTC PERP, was{:?}",
1371            instruments[0]
1372        );
1373
1374        // Second product is "BTC 24 APR 26" -> CryptoFuture
1375        assert!(
1376            matches!(&instruments[1], InstrumentAny::CryptoFuture(_)),
1377            "Expected CryptoFuture for dated future, was{:?}",
1378            instruments[1]
1379        );
1380    }
1381
1382    #[rstest]
1383    fn test_parse_perpetual_instrument_derives_base_from_display_name() {
1384        let json = load_test_fixture("http_products_future.json");
1385        let response: crate::http::models::ProductsResponse = serde_json::from_str(&json).unwrap();
1386        let ts = UnixNanos::default();
1387
1388        // The first future product has empty base_currency_id and display_name "BTC PERP"
1389        let perp_product = response
1390            .products
1391            .iter()
1392            .find(|p| p.display_name.contains("PERP"))
1393            .expect("should have a PERP product");
1394
1395        let instrument = parse_perpetual_instrument(perp_product, ts).unwrap();
1396        let perp = match &instrument {
1397            InstrumentAny::CryptoPerpetual(p) => p,
1398            other => panic!("Expected CryptoPerpetual, was{other:?}"),
1399        };
1400
1401        assert_eq!(perp.base_currency().unwrap().code, "BTC");
1402        assert_eq!(perp.quote_currency().code, "USD");
1403    }
1404
1405    #[rstest]
1406    fn test_parse_perpetual_instrument_has_contract_size_multiplier() {
1407        let json = load_test_fixture("http_products_future.json");
1408        let response: crate::http::models::ProductsResponse = serde_json::from_str(&json).unwrap();
1409        let ts = UnixNanos::default();
1410
1411        let perp_product = response
1412            .products
1413            .iter()
1414            .find(|p| p.display_name.contains("PERP"))
1415            .expect("should have a PERP product");
1416
1417        let instrument = parse_perpetual_instrument(perp_product, ts).unwrap();
1418        let perp = match &instrument {
1419            InstrumentAny::CryptoPerpetual(p) => p,
1420            other => panic!("Expected CryptoPerpetual, was {other:?}"),
1421        };
1422
1423        assert_eq!(perp.multiplier, Quantity::from("0.01"));
1424    }
1425
1426    #[rstest]
1427    fn test_parse_future_instrument_has_expiry_and_multiplier() {
1428        let json = load_test_fixture("http_products_future.json");
1429        let response: crate::http::models::ProductsResponse = serde_json::from_str(&json).unwrap();
1430        let ts = UnixNanos::default();
1431
1432        let future_product = response
1433            .products
1434            .iter()
1435            .find(|p| !p.display_name.contains("PERP") && !p.display_name.contains("Perpetual"))
1436            .expect("should have a dated future product");
1437
1438        let instrument = parse_future_instrument(future_product, ts).unwrap();
1439        let future = match &instrument {
1440            InstrumentAny::CryptoFuture(f) => f,
1441            other => panic!("Expected CryptoFuture, was {other:?}"),
1442        };
1443
1444        // Verify contract_expiry "2026-04-24T15:00:00Z" parsed correctly
1445        let expected_expiry = parse_rfc3339_timestamp("2026-04-24T15:00:00Z").unwrap();
1446        assert_eq!(future.expiration_ns, expected_expiry);
1447        assert_eq!(future.multiplier, Quantity::from("0.01"));
1448        assert_eq!(future.base_currency().unwrap().code, "BTC");
1449        assert_eq!(future.quote_currency().code, "USD");
1450    }
1451
1452    #[rstest]
1453    fn test_parse_trade_tick() {
1454        let json = load_test_fixture("http_ticker.json");
1455        let response: crate::http::models::TickerResponse = serde_json::from_str(&json).unwrap();
1456        let instrument_id = InstrumentId::new(Symbol::new("BTC-USD"), coinbase_venue());
1457        let ts_init = UnixNanos::default();
1458
1459        let trades: Vec<TradeTick> = response
1460            .trades
1461            .iter()
1462            .map(|t| parse_trade_tick(t, instrument_id, 2, 8, ts_init).unwrap())
1463            .collect();
1464
1465        assert_eq!(trades.len(), 3);
1466
1467        // Verify exact values from first fixture trade
1468        assert_eq!(trades[0].instrument_id, instrument_id);
1469        assert_eq!(trades[0].price, Price::from("68923.67"));
1470        assert_eq!(trades[0].size, Quantity::from("0.00064000"));
1471        assert_eq!(trades[0].trade_id.as_str(), "995098663");
1472        assert!(trades[0].ts_event.as_u64() > 0);
1473    }
1474
1475    #[rstest]
1476    fn test_parse_trade_tick_aggressor_side() {
1477        let json = load_test_fixture("http_ticker.json");
1478        let response: crate::http::models::TickerResponse = serde_json::from_str(&json).unwrap();
1479        let instrument_id = InstrumentId::new(Symbol::new("BTC-USD"), coinbase_venue());
1480        let ts_init = UnixNanos::default();
1481
1482        let aggressor_sides: Vec<AggressorSide> = response
1483            .trades
1484            .iter()
1485            .map(|t| {
1486                parse_trade_tick(t, instrument_id, 2, 8, ts_init)
1487                    .unwrap()
1488                    .aggressor_side
1489            })
1490            .collect();
1491
1492        // Fixture sides are SELL, SELL, BUY (maker sides)
1493        assert_eq!(
1494            aggressor_sides,
1495            vec![AggressorSide::Buy, AggressorSide::Buy, AggressorSide::Sell],
1496        );
1497    }
1498
1499    #[rstest]
1500    fn test_parse_bar() {
1501        let json = load_test_fixture("http_candles.json");
1502        let response: crate::http::models::CandlesResponse = serde_json::from_str(&json).unwrap();
1503
1504        let instrument_id = InstrumentId::new(Symbol::new("BTC-USD"), coinbase_venue());
1505        let bar_spec = BarSpecification::new(1, BarAggregation::Hour, PriceType::Last);
1506        let bar_type = BarType::new(instrument_id, bar_spec, AggregationSource::External);
1507        let ts_init = UnixNanos::default();
1508
1509        let bars: Vec<Bar> = response
1510            .candles
1511            .iter()
1512            .map(|c| parse_bar(c, bar_type, 2, 8, ts_init).unwrap())
1513            .collect();
1514
1515        assert_eq!(bars.len(), 2);
1516
1517        // Verify exact OHLCV from first fixture candle (start=1712192400)
1518        let bar = &bars[0];
1519        assert_eq!(bar.bar_type, bar_type);
1520        assert_eq!(bar.open, Price::from("66312.40"));
1521        assert_eq!(bar.high, Price::from("66331.99"));
1522        assert_eq!(bar.low, Price::from("66055.14"));
1523        assert_eq!(bar.close, Price::from("66181.60"));
1524        assert_eq!(bar.volume, Quantity::from("355.82896243"));
1525        assert_eq!(bar.ts_event.as_u64(), 1_712_192_400_000_000_000);
1526    }
1527
1528    #[rstest]
1529    fn test_parse_product_book_snapshot() {
1530        let json = load_test_fixture("http_product_book.json");
1531        let response: crate::http::models::ProductBookResponse =
1532            serde_json::from_str(&json).unwrap();
1533
1534        let instrument_id = InstrumentId::new(Symbol::new("BTC-USD"), coinbase_venue());
1535        let ts_init = UnixNanos::default();
1536
1537        let deltas =
1538            parse_product_book_snapshot(&response.pricebook, instrument_id, 2, 8, ts_init).unwrap();
1539
1540        assert_eq!(deltas.instrument_id, instrument_id);
1541        let total_levels = response.pricebook.bids.len() + response.pricebook.asks.len();
1542        assert_eq!(deltas.deltas.len(), total_levels + 1);
1543
1544        // First delta is a clear
1545        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1546
1547        // Verify first bid side and price
1548        let first_bid = &deltas.deltas[1];
1549        assert_eq!(first_bid.order.side, OrderSide::Buy.into());
1550        assert_eq!(first_bid.action, BookAction::Add);
1551        assert!(first_bid.order.price.as_f64() > 0.0);
1552
1553        // Verify first ask comes after bids
1554        let first_ask_idx = response.pricebook.bids.len() + 1;
1555        let first_ask = &deltas.deltas[first_ask_idx];
1556        assert_eq!(first_ask.order.side, OrderSide::Sell.into());
1557        assert_eq!(first_ask.action, BookAction::Add);
1558
1559        // Last delta has F_LAST flag
1560        let last = deltas.deltas.last().unwrap();
1561        assert_ne!(last.flags & RecordFlag::F_LAST as u8, 0);
1562
1563        // Every delta in a snapshot sequence carries F_SNAPSHOT.
1564        for delta in &deltas.deltas {
1565            assert_ne!(
1566                delta.flags & RecordFlag::F_SNAPSHOT as u8,
1567                0,
1568                "snapshot delta missing F_SNAPSHOT: {delta:?}",
1569            );
1570        }
1571    }
1572
1573    // Empty-book snapshots must carry F_SNAPSHOT | F_LAST on the lone Clear
1574    // delta so buffered consumers receive the clear event; without F_LAST the
1575    // DataEngine never flushes and downstream subscribers see nothing.
1576    #[rstest]
1577    fn test_parse_product_book_snapshot_empty_book_clear_carries_snapshot_and_last() {
1578        let pricebook = crate::http::models::PriceBook {
1579            product_id: Ustr::from("BTC-USD"),
1580            bids: Vec::new(),
1581            asks: Vec::new(),
1582            time: "2024-01-15T10:30:00Z".to_string(),
1583        };
1584        let instrument_id = InstrumentId::new(Symbol::new("BTC-USD"), coinbase_venue());
1585
1586        let deltas =
1587            parse_product_book_snapshot(&pricebook, instrument_id, 2, 8, UnixNanos::default())
1588                .unwrap();
1589        assert_eq!(deltas.deltas.len(), 1);
1590        let clear = &deltas.deltas[0];
1591        assert_eq!(clear.action, BookAction::Clear);
1592        assert_ne!(clear.flags & RecordFlag::F_SNAPSHOT as u8, 0);
1593        assert_ne!(clear.flags & RecordFlag::F_LAST as u8, 0);
1594    }
1595
1596    fn btc_usd_instrument() -> InstrumentAny {
1597        let json = load_test_fixture("http_product.json");
1598        let product: crate::http::models::Product = serde_json::from_str(&json).unwrap();
1599        parse_spot_instrument(&product, UnixNanos::default()).unwrap()
1600    }
1601
1602    #[rstest]
1603    fn test_parse_order_status_report_fully_filled_limit_gtc() {
1604        let json = load_test_fixture("http_order.json");
1605        let response: crate::http::models::OrderResponse = serde_json::from_str(&json).unwrap();
1606        let instrument = btc_usd_instrument();
1607        let account_id = AccountId::new("COINBASE-001");
1608        let ts_init = UnixNanos::from(1);
1609
1610        let report =
1611            parse_order_status_report(&response.order, &instrument, account_id, ts_init).unwrap();
1612
1613        assert_eq!(report.account_id, account_id);
1614        assert_eq!(report.instrument_id.symbol.as_str(), "BTC-USD");
1615        assert_eq!(report.venue_order_id.as_str(), "0000-000000-000000");
1616        assert_eq!(
1617            report.client_order_id.unwrap().as_str(),
1618            "11111-000000-000000"
1619        );
1620        assert_eq!(report.order_side, OrderSide::Buy.into());
1621        assert_eq!(report.order_type, OrderType::Limit);
1622        assert_eq!(report.time_in_force, TimeInForce::Gtc);
1623        // filled_size (0.001) == base_size (0.001), so status stays Accepted
1624        // rather than promoting to PartiallyFilled.
1625        assert_eq!(report.order_status, OrderStatus::Accepted);
1626        assert_eq!(report.quantity, Quantity::from("0.001"));
1627        assert_eq!(report.filled_qty, Quantity::from("0.001"));
1628        assert_eq!(report.price, Some(Price::from("10000.00")));
1629        assert_eq!(report.avg_px, Some(Decimal::from(50)));
1630    }
1631
1632    #[rstest]
1633    fn test_parse_order_status_report_filled_market_order() {
1634        let json = load_test_fixture("http_orders_list.json");
1635        let response: crate::http::models::OrdersListResponse =
1636            serde_json::from_str(&json).unwrap();
1637        let instrument = btc_usd_instrument();
1638        let account_id = AccountId::new("COINBASE-001");
1639        let ts_init = UnixNanos::from(1);
1640
1641        // Second order in the list is a filled MARKET order
1642        let filled_order = &response.orders[1];
1643        let report =
1644            parse_order_status_report(filled_order, &instrument, account_id, ts_init).unwrap();
1645
1646        assert_eq!(report.order_status, OrderStatus::Filled);
1647        assert_eq!(report.order_type, OrderType::Market);
1648        assert_eq!(report.order_side, OrderSide::Sell.into());
1649        assert_eq!(report.time_in_force, TimeInForce::Ioc);
1650        // Market quote-size orders fall back to filled_qty for total quantity
1651        assert_eq!(report.filled_qty, Quantity::from("0.0325"));
1652        assert_eq!(report.quantity, report.filled_qty);
1653        assert!(report.price.is_none());
1654    }
1655
1656    #[rstest]
1657    fn test_parse_fill_report_maker() {
1658        let json = load_test_fixture("http_fills.json");
1659        let response: crate::http::models::FillsResponse = serde_json::from_str(&json).unwrap();
1660        let instrument = btc_usd_instrument();
1661        let account_id = AccountId::new("COINBASE-001");
1662        let ts_init = UnixNanos::from(1);
1663
1664        let maker_fill = &response.fills[0];
1665        let report = parse_fill_report(maker_fill, &instrument, account_id, ts_init).unwrap();
1666
1667        assert_eq!(report.account_id, account_id);
1668        assert_eq!(report.trade_id.as_str(), "1111-11111-111111");
1669        assert_eq!(report.venue_order_id.as_str(), "0000-000000-000000");
1670        assert_eq!(report.order_side, OrderSide::Buy);
1671        assert_eq!(report.liquidity_side, LiquiditySide::Maker);
1672        assert_eq!(report.last_px, Price::from("45123.45"));
1673        assert_eq!(report.last_qty, Quantity::from("0.00500000"));
1674        assert_eq!(
1675            report.commission.as_decimal(),
1676            Decimal::from_str("1.14").unwrap()
1677        );
1678        assert_eq!(report.commission.currency.code, "USD");
1679    }
1680
1681    #[rstest]
1682    fn test_parse_account_state_spot_cash() {
1683        let json = load_test_fixture("http_accounts.json");
1684        let response: crate::http::models::AccountsResponse = serde_json::from_str(&json).unwrap();
1685        let account_id = AccountId::new("COINBASE-001");
1686        let ts_event = UnixNanos::from(1);
1687        let ts_init = UnixNanos::from(2);
1688
1689        let state =
1690            parse_account_state(&response.accounts, account_id, true, ts_event, ts_init).unwrap();
1691
1692        assert_eq!(state.account_id, account_id);
1693        assert_eq!(state.account_type, AccountType::Cash);
1694        assert!(state.is_reported);
1695        assert_eq!(state.margins.len(), 0);
1696        assert_eq!(state.balances.len(), 2);
1697
1698        let btc_balance = state
1699            .balances
1700            .iter()
1701            .find(|b| b.currency.code == "BTC")
1702            .expect("BTC balance present");
1703        assert_eq!(
1704            btc_balance.free.as_decimal(),
1705            Decimal::from_str("1.23456789").unwrap()
1706        );
1707        assert_eq!(
1708            btc_balance.locked.as_decimal(),
1709            Decimal::from_str("0.00500000").unwrap()
1710        );
1711        assert_eq!(
1712            btc_balance.total.as_decimal(),
1713            btc_balance.free.as_decimal() + btc_balance.locked.as_decimal()
1714        );
1715
1716        let usd_balance = state
1717            .balances
1718            .iter()
1719            .find(|b| b.currency.code == "USD")
1720            .expect("USD balance present");
1721        assert_eq!(
1722            usd_balance.free.as_decimal(),
1723            Decimal::from_str("10000.50").unwrap()
1724        );
1725        assert_eq!(
1726            usd_balance.locked.as_decimal(),
1727            Decimal::from_str("450.00").unwrap()
1728        );
1729    }
1730
1731    #[rstest]
1732    fn test_parse_account_state_aggregates_same_currency() {
1733        fn make_account(
1734            currency: &str,
1735            available: &str,
1736            hold: &str,
1737            uuid: &str,
1738            portfolio: &str,
1739        ) -> Account {
1740            Account {
1741                uuid: uuid.to_string(),
1742                name: "wallet".to_string(),
1743                currency: Ustr::from(currency),
1744                available_balance: Balance {
1745                    value: Decimal::from_str(available).unwrap(),
1746                    currency: Ustr::from(currency),
1747                },
1748                default: false,
1749                active: true,
1750                created_at: String::new(),
1751                updated_at: String::new(),
1752                deleted_at: None,
1753                account_type: crate::common::enums::CoinbaseAccountType::Fiat,
1754                ready: true,
1755                hold: Some(Balance {
1756                    value: Decimal::from_str(hold).unwrap(),
1757                    currency: Ustr::from(currency),
1758                }),
1759                retail_portfolio_id: portfolio.to_string(),
1760            }
1761        }
1762
1763        let accounts = vec![
1764            make_account("USD", "1000.00", "50.00", "uuid-1", "portfolio-a"),
1765            make_account("USD", "2500.00", "25.00", "uuid-2", "portfolio-b"),
1766            make_account("BTC", "0.5", "0.1", "uuid-3", "portfolio-a"),
1767        ];
1768
1769        let account_id = AccountId::new("COINBASE-001");
1770        let state = parse_account_state(
1771            &accounts,
1772            account_id,
1773            true,
1774            UnixNanos::from(1),
1775            UnixNanos::from(2),
1776        )
1777        .unwrap();
1778
1779        assert_eq!(state.balances.len(), 2);
1780
1781        let usd = state
1782            .balances
1783            .iter()
1784            .find(|b| b.currency.code == "USD")
1785            .expect("USD balance aggregated");
1786        assert_eq!(usd.free.as_decimal(), Decimal::from_str("3500.00").unwrap());
1787        assert_eq!(usd.locked.as_decimal(), Decimal::from_str("75.00").unwrap());
1788        assert_eq!(
1789            usd.total.as_decimal(),
1790            Decimal::from_str("3575.00").unwrap()
1791        );
1792
1793        let btc = state
1794            .balances
1795            .iter()
1796            .find(|b| b.currency.code == "BTC")
1797            .expect("BTC balance present");
1798        assert_eq!(btc.free.as_decimal(), Decimal::from_str("0.5").unwrap());
1799        assert_eq!(btc.locked.as_decimal(), Decimal::from_str("0.1").unwrap());
1800    }
1801
1802    #[rstest]
1803    fn test_parse_account_state_empty_falls_back_to_zero_usd() {
1804        let account_id = AccountId::new("COINBASE-001");
1805        let state = parse_account_state(
1806            &[],
1807            account_id,
1808            true,
1809            UnixNanos::from(1),
1810            UnixNanos::from(2),
1811        )
1812        .unwrap();
1813
1814        assert_eq!(state.balances.len(), 1);
1815        let balance = &state.balances[0];
1816        assert_eq!(balance.currency.code, "USD");
1817        assert_eq!(balance.total.as_decimal(), Decimal::ZERO);
1818    }
1819
1820    #[rstest]
1821    #[case(CoinbaseOrderType::Market, OrderType::Market)]
1822    #[case(CoinbaseOrderType::Limit, OrderType::Limit)]
1823    #[case(CoinbaseOrderType::Stop, OrderType::StopMarket)]
1824    #[case(CoinbaseOrderType::StopLimit, OrderType::StopLimit)]
1825    #[case(CoinbaseOrderType::Bracket, OrderType::Limit)]
1826    #[case(CoinbaseOrderType::Twap, OrderType::Limit)]
1827    #[case(CoinbaseOrderType::RollOpen, OrderType::Limit)]
1828    #[case(CoinbaseOrderType::RollClose, OrderType::Limit)]
1829    #[case(CoinbaseOrderType::Liquidation, OrderType::Market)]
1830    #[case(CoinbaseOrderType::Scaled, OrderType::Limit)]
1831    #[case(CoinbaseOrderType::Unknown, OrderType::Limit)]
1832    fn test_parse_order_type(#[case] input: CoinbaseOrderType, #[case] expected: OrderType) {
1833        assert_eq!(parse_order_type(input), expected);
1834    }
1835
1836    #[rstest]
1837    #[case(CoinbaseOrderStatus::Open, OrderStatus::Accepted)]
1838    #[case(CoinbaseOrderStatus::Filled, OrderStatus::Filled)]
1839    #[case(CoinbaseOrderStatus::Cancelled, OrderStatus::Canceled)]
1840    #[case(CoinbaseOrderStatus::CancelQueued, OrderStatus::PendingCancel)]
1841    #[case(CoinbaseOrderStatus::EditQueued, OrderStatus::PendingUpdate)]
1842    #[case(CoinbaseOrderStatus::Expired, OrderStatus::Expired)]
1843    #[case(CoinbaseOrderStatus::Failed, OrderStatus::Rejected)]
1844    #[case(CoinbaseOrderStatus::Pending, OrderStatus::Accepted)]
1845    #[case(CoinbaseOrderStatus::Queued, OrderStatus::Accepted)]
1846    fn test_parse_order_status(#[case] input: CoinbaseOrderStatus, #[case] expected: OrderStatus) {
1847        assert_eq!(parse_order_status(input), expected);
1848    }
1849
1850    // Builds a minimal limit-GTC order with overridable size fields so tests
1851    // can exercise partial-fill, error, and boundary paths without adding a
1852    // fixture per permutation.
1853    fn make_limit_gtc_order(
1854        base_size: &str,
1855        limit_price: &str,
1856        filled_size: &str,
1857        status: CoinbaseOrderStatus,
1858    ) -> crate::http::models::Order {
1859        crate::http::models::Order {
1860            order_id: "venue-abc".to_string(),
1861            product_id: Ustr::from("BTC-USD"),
1862            user_id: "user-1".to_string(),
1863            order_configuration: Some(serde_json::json!({
1864                "limit_limit_gtc": {
1865                    "base_size": base_size,
1866                    "limit_price": limit_price,
1867                    "post_only": false,
1868                }
1869            })),
1870            side: CoinbaseOrderSide::Buy,
1871            client_order_id: "client-abc".to_string(),
1872            status,
1873            time_in_force: Some(CoinbaseTimeInForce::GoodUntilCancelled),
1874            created_time: "2024-01-15T10:00:00Z".to_string(),
1875            completion_percentage: String::new(),
1876            filled_size: filled_size.to_string(),
1877            average_filled_price: String::new(),
1878            fee: Decimal::ZERO,
1879            number_of_fills: 0,
1880            filled_value: Decimal::ZERO,
1881            pending_cancel: false,
1882            size_in_quote: false,
1883            total_fees: Decimal::ZERO,
1884            size_inclusive_of_fees: false,
1885            total_value_after_fees: Decimal::ZERO,
1886            trigger_status: crate::common::enums::CoinbaseTriggerStatus::Unknown,
1887            order_type: CoinbaseOrderType::Limit,
1888            reject_reason: String::new(),
1889            settled: false,
1890            product_type: CoinbaseProductType::Spot,
1891            reject_message: String::new(),
1892            cancel_message: String::new(),
1893            order_placement_source:
1894                crate::common::enums::CoinbaseOrderPlacementSource::RetailAdvanced,
1895            outstanding_hold_amount: Decimal::ZERO,
1896            is_liquidation: false,
1897            last_fill_time: None,
1898            leverage: String::new(),
1899            margin_type: None,
1900            retail_portfolio_id: String::new(),
1901            originating_order_id: String::new(),
1902            attached_order_id: String::new(),
1903        }
1904    }
1905
1906    #[rstest]
1907    #[case::partially_filled("0.001", "0.0005", OrderStatus::PartiallyFilled)]
1908    #[case::fully_equals_boundary("0.001", "0.001", OrderStatus::Accepted)]
1909    #[case::zero_filled("0.001", "0", OrderStatus::Accepted)]
1910    fn test_parse_order_status_report_promotes_to_partially_filled(
1911        #[case] base_size: &str,
1912        #[case] filled_size: &str,
1913        #[case] expected_status: OrderStatus,
1914    ) {
1915        let order = make_limit_gtc_order(
1916            base_size,
1917            "50000.00",
1918            filled_size,
1919            CoinbaseOrderStatus::Open,
1920        );
1921        let instrument = btc_usd_instrument();
1922        let account_id = AccountId::new("COINBASE-001");
1923
1924        let report =
1925            parse_order_status_report(&order, &instrument, account_id, UnixNanos::from(1)).unwrap();
1926
1927        assert_eq!(report.order_status, expected_status);
1928        assert_eq!(report.quantity, Quantity::from(base_size));
1929    }
1930
1931    #[rstest]
1932    fn test_parse_order_status_report_handles_liquidation_order_type() {
1933        let mut order =
1934            make_limit_gtc_order("0.001", "50000.00", "0.001", CoinbaseOrderStatus::Filled);
1935        order.order_type = CoinbaseOrderType::Liquidation;
1936        let instrument = btc_usd_instrument();
1937
1938        let report = parse_order_status_report(
1939            &order,
1940            &instrument,
1941            AccountId::new("COINBASE-001"),
1942            UnixNanos::from(1),
1943        )
1944        .unwrap();
1945
1946        assert_eq!(report.order_type, OrderType::Market);
1947        assert_eq!(report.order_status, OrderStatus::Filled);
1948    }
1949
1950    #[rstest]
1951    fn test_parse_order_status_report_handles_is_liquidation_flag() {
1952        let mut order =
1953            make_limit_gtc_order("0.001", "50000.00", "0.001", CoinbaseOrderStatus::Filled);
1954        order.is_liquidation = true;
1955        let instrument = btc_usd_instrument();
1956
1957        let report = parse_order_status_report(
1958            &order,
1959            &instrument,
1960            AccountId::new("COINBASE-001"),
1961            UnixNanos::from(1),
1962        )
1963        .unwrap();
1964
1965        // Pin the fields the warn branch reads so a future refactor that drops
1966        // the `is_liquidation` arm cannot accept this fixture by accident.
1967        assert_eq!(report.order_status, OrderStatus::Filled);
1968        assert_eq!(report.order_side, OrderSide::Buy.into());
1969        assert_eq!(report.filled_qty, Quantity::from("0.001"));
1970        assert_eq!(report.instrument_id, instrument.id());
1971    }
1972
1973    #[rstest]
1974    fn test_parse_order_status_report_rejects_malformed_filled_size() {
1975        let mut order = make_limit_gtc_order("0.001", "50000.00", "0", CoinbaseOrderStatus::Open);
1976        order.filled_size = "not-a-number".to_string();
1977        let instrument = btc_usd_instrument();
1978
1979        let err = parse_order_status_report(
1980            &order,
1981            &instrument,
1982            AccountId::new("COINBASE-001"),
1983            UnixNanos::from(1),
1984        )
1985        .unwrap_err();
1986
1987        let chain = format!("{err:#}");
1988        assert!(
1989            chain.contains("failed to parse filled_size"),
1990            "expected failed to parse filled_size in error chain, was: {chain}"
1991        );
1992    }
1993
1994    fn make_fill(commission: &str, price: &str, size: &str, trade_time: &str) -> Fill {
1995        Fill {
1996            entry_id: "entry-1".to_string(),
1997            trade_id: "trade-1".to_string(),
1998            order_id: "venue-1".to_string(),
1999            trade_time: trade_time.to_string(),
2000            trade_type: crate::common::enums::CoinbaseFillTradeType::Fill,
2001            price: price.to_string(),
2002            size: size.to_string(),
2003            commission: Decimal::from_str(commission).unwrap(),
2004            product_id: Ustr::from("BTC-USD"),
2005            sequence_timestamp: "2024-01-15T10:30:00.000Z".to_string(),
2006            liquidity_indicator: CoinbaseLiquidityIndicator::Maker,
2007            size_in_quote: false,
2008            user_id: "user-1".to_string(),
2009            side: CoinbaseOrderSide::Buy,
2010            retail_portfolio_id: String::new(),
2011        }
2012    }
2013
2014    #[rstest]
2015    fn test_parse_fill_report_rejects_out_of_range_commission() {
2016        let fill = make_fill(
2017            "9999999999999999999999999999",
2018            "45000.00",
2019            "0.001",
2020            "2024-01-15T10:30:00Z",
2021        );
2022        let instrument = btc_usd_instrument();
2023
2024        let err = parse_fill_report(
2025            &fill,
2026            &instrument,
2027            AccountId::new("COINBASE-001"),
2028            UnixNanos::from(1),
2029        )
2030        .unwrap_err();
2031
2032        let chain = format!("{err:#}");
2033        assert!(
2034            chain.contains("failed to build commission Money"),
2035            "expected failed to build commission Money in error chain, was: {chain}"
2036        );
2037    }
2038
2039    #[rstest]
2040    fn test_parse_fill_report_rejects_non_rfc3339_trade_time() {
2041        let fill = make_fill("0.50", "45000.00", "0.001", "not-a-timestamp");
2042        let instrument = btc_usd_instrument();
2043
2044        let result = parse_fill_report(
2045            &fill,
2046            &instrument,
2047            AccountId::new("COINBASE-001"),
2048            UnixNanos::from(1),
2049        );
2050        assert!(result.is_err(), "expected parse failure on bad trade_time");
2051    }
2052
2053    #[rstest]
2054    fn test_parse_account_state_skips_entry_with_out_of_range_money() {
2055        let valid = Account {
2056            uuid: "uuid-valid".to_string(),
2057            name: "USD Wallet".to_string(),
2058            currency: Ustr::from("USD"),
2059            available_balance: Balance {
2060                value: Decimal::from_str("1000.00").unwrap(),
2061                currency: Ustr::from("USD"),
2062            },
2063            default: false,
2064            active: true,
2065            created_at: String::new(),
2066            updated_at: String::new(),
2067            deleted_at: None,
2068            account_type: crate::common::enums::CoinbaseAccountType::Fiat,
2069            ready: true,
2070            hold: Some(Balance {
2071                value: Decimal::from_str("50.00").unwrap(),
2072                currency: Ustr::from("USD"),
2073            }),
2074            retail_portfolio_id: String::new(),
2075        };
2076
2077        let over_precision = Account {
2078            available_balance: Balance {
2079                value: Decimal::from_str("9999999999999999999999999999").unwrap(),
2080                currency: Ustr::from("USD"),
2081            },
2082            hold: Some(Balance {
2083                value: Decimal::ZERO,
2084                currency: Ustr::from("USD"),
2085            }),
2086            currency: Ustr::from("USD"),
2087            uuid: "uuid-over-precision".to_string(),
2088            ..valid.clone()
2089        };
2090
2091        let state = parse_account_state(
2092            &[over_precision, valid],
2093            AccountId::new("COINBASE-001"),
2094            true,
2095            UnixNanos::from(1),
2096            UnixNanos::from(2),
2097        )
2098        .unwrap();
2099
2100        // Out-of-range entry was skipped; only the valid USD row survives.
2101        assert_eq!(state.balances.len(), 1);
2102        assert_eq!(state.balances[0].currency.code, "USD");
2103        assert_eq!(
2104            state.balances[0].free.as_decimal(),
2105            Decimal::from_str("1000.00").unwrap()
2106        );
2107    }
2108
2109    #[rstest]
2110    fn test_parse_order_status_report_extracts_stop_limit_trigger_price() {
2111        let order = crate::http::models::Order {
2112            order_configuration: Some(serde_json::json!({
2113                "stop_limit_stop_limit_gtc": {
2114                    "base_size": "0.001",
2115                    "limit_price": "49500.00",
2116                    "stop_price": "49000.00",
2117                    "stop_direction": "STOP_DIRECTION_STOP_DOWN"
2118                }
2119            })),
2120            order_type: CoinbaseOrderType::StopLimit,
2121            ..make_limit_gtc_order("0.001", "0", "0", CoinbaseOrderStatus::Open)
2122        };
2123        let instrument = btc_usd_instrument();
2124
2125        let report = parse_order_status_report(
2126            &order,
2127            &instrument,
2128            AccountId::new("COINBASE-001"),
2129            UnixNanos::from(1),
2130        )
2131        .unwrap();
2132
2133        assert_eq!(report.order_type, OrderType::StopLimit);
2134        assert_eq!(report.price, Some(Price::from("49500.00")));
2135        assert_eq!(report.trigger_price, Some(Price::from("49000.00")));
2136        assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
2137    }
2138
2139    #[rstest]
2140    #[case::limit_gtc_post_only_true("limit_limit_gtc", true)]
2141    #[case::limit_gtc_post_only_false("limit_limit_gtc", false)]
2142    fn test_parse_order_status_report_propagates_post_only(
2143        #[case] config_key: &str,
2144        #[case] post_only: bool,
2145    ) {
2146        let config = serde_json::json!({
2147            config_key: {
2148                "base_size": "0.001",
2149                "limit_price": "50000.00",
2150                "post_only": post_only,
2151            }
2152        });
2153        let order = crate::http::models::Order {
2154            order_configuration: Some(config),
2155            ..make_limit_gtc_order("0.001", "50000.00", "0", CoinbaseOrderStatus::Open)
2156        };
2157
2158        let report = parse_order_status_report(
2159            &order,
2160            &btc_usd_instrument(),
2161            AccountId::new("COINBASE-001"),
2162            UnixNanos::from(1),
2163        )
2164        .unwrap();
2165
2166        assert_eq!(report.post_only, post_only);
2167    }
2168
2169    #[rstest]
2170    fn test_parse_order_with_unknown_configuration_does_not_fail() {
2171        // Coinbase history may return bracket, TWAP, or trigger configs that
2172        // the submit-side OrderConfiguration enum does not model. The raw
2173        // JSON field must tolerate these without failing deserialization.
2174        let json_str = r#"{
2175            "order": {
2176                "order_id": "venue-bracket-1",
2177                "product_id": "BTC-USD",
2178                "user_id": "user-1",
2179                "order_configuration": {
2180                    "trigger_bracket_gtd": {
2181                        "limit_price": "55000.00",
2182                        "stop_trigger_price": "45000.00",
2183                        "end_time": "2024-12-31T23:59:59Z"
2184                    }
2185                },
2186                "side": "BUY",
2187                "client_order_id": "client-bracket-1",
2188                "status": "OPEN",
2189                "time_in_force": "GOOD_UNTIL_DATE_TIME",
2190                "created_time": "2024-01-15T10:00:00Z",
2191                "completion_percentage": "0",
2192                "filled_size": "0",
2193                "average_filled_price": "0",
2194                "fee": "0",
2195                "number_of_fills": "0",
2196                "filled_value": "0",
2197                "pending_cancel": false,
2198                "size_in_quote": false,
2199                "total_fees": "0",
2200                "size_inclusive_of_fees": false,
2201                "total_value_after_fees": "0",
2202                "trigger_status": "INVALID_ORDER_TYPE",
2203                "order_type": "BRACKET",
2204                "reject_reason": "",
2205                "settled": false,
2206                "product_type": "SPOT",
2207                "reject_message": "",
2208                "cancel_message": "",
2209                "order_placement_source": "RETAIL_ADVANCED",
2210                "outstanding_hold_amount": "0",
2211                "is_liquidation": false,
2212                "last_fill_time": null,
2213                "leverage": "",
2214                "margin_type": "",
2215                "retail_portfolio_id": "",
2216                "originating_order_id": "",
2217                "attached_order_id": ""
2218            }
2219        }"#;
2220
2221        let response: crate::http::models::OrderResponse =
2222            serde_json::from_str(json_str).expect("unknown config must deserialize");
2223
2224        let report = parse_order_status_report(
2225            &response.order,
2226            &btc_usd_instrument(),
2227            AccountId::new("COINBASE-001"),
2228            UnixNanos::from(1),
2229        )
2230        .unwrap();
2231
2232        assert_eq!(report.venue_order_id.as_str(), "venue-bracket-1");
2233        // The bracket config has no `base_size`, so quantity falls back to
2234        // filled_qty (zero). The `limit_price` key still matches the
2235        // permissive walker and is extracted opportunistically; this is the
2236        // right tolerant default for unknown shapes.
2237        assert_eq!(report.filled_qty, Quantity::zero(8));
2238        assert_eq!(report.price, Some(Price::from("55000.00")));
2239    }
2240
2241    #[rstest]
2242    fn test_parse_order_status_report_gtd_carries_expire_time() {
2243        let order = crate::http::models::Order {
2244            order_configuration: Some(serde_json::json!({
2245                "limit_limit_gtd": {
2246                    "base_size": "0.001",
2247                    "limit_price": "50000.00",
2248                    "end_time": "2024-12-31T23:59:59Z",
2249                    "post_only": false
2250                }
2251            })),
2252            time_in_force: Some(CoinbaseTimeInForce::GoodUntilDateTime),
2253            order_type: CoinbaseOrderType::Limit,
2254            ..make_limit_gtc_order("0.001", "50000.00", "0", CoinbaseOrderStatus::Open)
2255        };
2256
2257        let report = parse_order_status_report(
2258            &order,
2259            &btc_usd_instrument(),
2260            AccountId::new("COINBASE-001"),
2261            UnixNanos::from(1),
2262        )
2263        .unwrap();
2264
2265        assert_eq!(report.time_in_force, TimeInForce::Gtd);
2266
2267        let expected_expire = parse_rfc3339_timestamp("2024-12-31T23:59:59Z").unwrap();
2268        assert_eq!(report.expire_time, Some(expected_expire));
2269    }
2270
2271    #[rstest]
2272    fn test_parse_optional_quantity_returns_none_on_overflow() {
2273        // Values exceeding QUANTITY_RAW_MAX must return None instead of panicking
2274        let result = parse_optional_quantity("99999999999999999999999999999999");
2275        assert!(result.is_none());
2276    }
2277
2278    // Confirms the "pick one whole window" invariant: when intraday has the
2279    // larger initial_margin but overnight has the larger maintenance_margin,
2280    // the emitted MarginBalance must match one of the venue windows verbatim
2281    // rather than mixing fields across windows.
2282    #[rstest]
2283    fn test_parse_cfm_margin_balances_picks_whole_window_not_per_field_max() {
2284        let summary = cfm_summary_with_windows(
2285            Some(cfm_window(
2286                CoinbaseMarginWindowType::Intraday,
2287                "800.00",
2288                "100.00",
2289            )),
2290            Some(cfm_window(
2291                CoinbaseMarginWindowType::Overnight,
2292                "500.00",
2293                "400.00",
2294            )),
2295        );
2296
2297        let margins = parse_cfm_margin_balances(&summary).unwrap();
2298        assert_eq!(margins.len(), 1);
2299        let m = &margins[0];
2300        // Intraday wins on initial (800 > 500); its maintenance (100) must
2301        // come along, not the overnight 400 that would dominate a per-field
2302        // max strategy.
2303        assert_eq!(m.initial.as_decimal(), Decimal::from_str("800.00").unwrap());
2304        assert_eq!(
2305            m.maintenance.as_decimal(),
2306            Decimal::from_str("100.00").unwrap()
2307        );
2308    }
2309
2310    #[rstest]
2311    fn test_parse_cfm_margin_balances_returns_empty_when_no_windows() {
2312        let summary = cfm_summary_with_windows(None, None);
2313        assert!(parse_cfm_margin_balances(&summary).unwrap().is_empty());
2314    }
2315
2316    #[rstest]
2317    fn test_parse_cfm_margin_balances_uses_sole_intraday_window_verbatim() {
2318        let summary = cfm_summary_with_windows(
2319            Some(cfm_window(
2320                CoinbaseMarginWindowType::Intraday,
2321                "250.00",
2322                "125.00",
2323            )),
2324            None,
2325        );
2326        let margins = parse_cfm_margin_balances(&summary).unwrap();
2327        assert_eq!(margins.len(), 1);
2328        assert_eq!(
2329            margins[0].initial.as_decimal(),
2330            Decimal::from_str("250.00").unwrap()
2331        );
2332        assert_eq!(
2333            margins[0].maintenance.as_decimal(),
2334            Decimal::from_str("125.00").unwrap()
2335        );
2336    }
2337
2338    #[rstest]
2339    fn test_parse_cfm_margin_balances_uses_sole_overnight_window_verbatim() {
2340        let summary = cfm_summary_with_windows(
2341            None,
2342            Some(cfm_window(
2343                CoinbaseMarginWindowType::Overnight,
2344                "900.00",
2345                "450.00",
2346            )),
2347        );
2348        let margins = parse_cfm_margin_balances(&summary).unwrap();
2349        assert_eq!(margins.len(), 1);
2350        assert_eq!(
2351            margins[0].initial.as_decimal(),
2352            Decimal::from_str("900.00").unwrap()
2353        );
2354        assert_eq!(
2355            margins[0].maintenance.as_decimal(),
2356            Decimal::from_str("450.00").unwrap()
2357        );
2358    }
2359
2360    // Mirrors `parse_cfm_margin_balances` selector tests for the WS variant
2361    // so a future drift between the two selectors is caught before it ships.
2362    #[rstest]
2363    fn test_parse_ws_cfm_account_state_picks_whole_window_not_per_field_max() {
2364        use nautilus_model::enums::AccountType;
2365
2366        use crate::websocket::messages::{WsFcmBalanceSummary, WsMarginWindowMeasure};
2367
2368        fn ws_window(
2369            kind: CoinbaseMarginWindowType,
2370            initial: &str,
2371            maintenance: &str,
2372        ) -> WsMarginWindowMeasure {
2373            WsMarginWindowMeasure {
2374                margin_window_type: kind,
2375                margin_level: CoinbaseMarginLevel::Base,
2376                initial_margin: Decimal::from_str(initial).unwrap(),
2377                maintenance_margin: Decimal::from_str(maintenance).unwrap(),
2378                liquidation_buffer_percentage: Decimal::ZERO,
2379                total_hold: Decimal::ZERO,
2380                futures_buying_power: Decimal::ZERO,
2381            }
2382        }
2383
2384        let summary = WsFcmBalanceSummary {
2385            futures_buying_power: Decimal::from_str("100.00").unwrap(),
2386            total_usd_balance: Decimal::from_str("500.00").unwrap(),
2387            cbi_usd_balance: Decimal::ZERO,
2388            cfm_usd_balance: Decimal::ZERO,
2389            total_open_orders_hold_amount: Decimal::from_str("25.00").unwrap(),
2390            unrealized_pnl: Decimal::ZERO,
2391            daily_realized_pnl: Decimal::ZERO,
2392            initial_margin: Decimal::ZERO,
2393            available_margin: Decimal::from_str("350.00").unwrap(),
2394            liquidation_threshold: Decimal::ZERO,
2395            liquidation_buffer_amount: Decimal::ZERO,
2396            liquidation_buffer_percentage: Decimal::ZERO,
2397            intraday_margin_window_measure: ws_window(
2398                CoinbaseMarginWindowType::Intraday,
2399                "800.00",
2400                "100.00",
2401            ),
2402            overnight_margin_window_measure: ws_window(
2403                CoinbaseMarginWindowType::Overnight,
2404                "500.00",
2405                "400.00",
2406            ),
2407        };
2408
2409        let state = parse_ws_cfm_account_state(
2410            &summary,
2411            AccountId::new("COINBASE-001"),
2412            UnixNanos::default(),
2413            UnixNanos::default(),
2414        )
2415        .unwrap();
2416
2417        assert_eq!(state.account_type, AccountType::Margin);
2418        // Balance invariant: total == venue total_usd_balance; free == available_margin.
2419        assert_eq!(
2420            state.balances[0].total.as_decimal(),
2421            Decimal::from_str("500.00").unwrap()
2422        );
2423        assert_eq!(
2424            state.balances[0].free.as_decimal(),
2425            Decimal::from_str("350.00").unwrap()
2426        );
2427        // Intraday wins on initial (800 > 500); its maintenance comes along.
2428        assert_eq!(state.margins.len(), 1);
2429        assert_eq!(
2430            state.margins[0].initial.as_decimal(),
2431            Decimal::from_str("800.00").unwrap()
2432        );
2433        assert_eq!(
2434            state.margins[0].maintenance.as_decimal(),
2435            Decimal::from_str("100.00").unwrap()
2436        );
2437    }
2438
2439    #[rstest]
2440    #[case(CoinbaseFcmPositionSide::Long, PositionSide::Long)]
2441    #[case(CoinbaseFcmPositionSide::Short, PositionSide::Short)]
2442    #[case(CoinbaseFcmPositionSide::Unspecified, PositionSide::Flat)]
2443    fn test_parse_cfm_position_side_maps_all_variants(
2444        #[case] venue_side: CoinbaseFcmPositionSide,
2445        #[case] expected: PositionSide,
2446    ) {
2447        let report = parse_cfm_position_status_report(
2448            &cfm_position(venue_side, "1", "49000.00"),
2449            &btc_perp_instrument(),
2450            AccountId::new("COINBASE-001"),
2451            UnixNanos::default(),
2452        )
2453        .unwrap();
2454        assert_eq!(report.position_side, expected);
2455    }
2456
2457    #[rstest]
2458    fn test_parse_cfm_position_drops_avg_px_when_entry_zero() {
2459        // Coinbase reports `avg_entry_price=0` on freshly-opened positions
2460        // before a fill lands; Nautilus represents "no open price" as None.
2461        let report = parse_cfm_position_status_report(
2462            &cfm_position(CoinbaseFcmPositionSide::Long, "1", "0"),
2463            &btc_perp_instrument(),
2464            AccountId::new("COINBASE-001"),
2465            UnixNanos::default(),
2466        )
2467        .unwrap();
2468        assert!(report.avg_px_open.is_none());
2469    }
2470
2471    fn cfm_amount(value: &str) -> crate::http::models::CfmAmount {
2472        crate::http::models::CfmAmount {
2473            value: Decimal::from_str(value).unwrap(),
2474            currency: Ustr::from("USD"),
2475        }
2476    }
2477
2478    fn cfm_window(
2479        kind: CoinbaseMarginWindowType,
2480        initial: &str,
2481        maintenance: &str,
2482    ) -> crate::http::models::CfmMarginWindowMeasure {
2483        crate::http::models::CfmMarginWindowMeasure {
2484            margin_window_type: kind,
2485            margin_level: CoinbaseMarginLevel::Base,
2486            initial_margin: cfm_amount(initial),
2487            maintenance_margin: cfm_amount(maintenance),
2488            liquidation_buffer_percentage: String::new(),
2489            total_hold: cfm_amount("0"),
2490            futures_buying_power: cfm_amount("0"),
2491        }
2492    }
2493
2494    fn cfm_summary_with_windows(
2495        intraday: Option<crate::http::models::CfmMarginWindowMeasure>,
2496        overnight: Option<crate::http::models::CfmMarginWindowMeasure>,
2497    ) -> CfmBalanceSummary {
2498        CfmBalanceSummary {
2499            futures_buying_power: cfm_amount("0"),
2500            total_usd_balance: cfm_amount("0"),
2501            cbi_usd_balance: cfm_amount("0"),
2502            cfm_usd_balance: cfm_amount("0"),
2503            total_open_orders_hold_amount: cfm_amount("0"),
2504            unrealized_pnl: cfm_amount("0"),
2505            daily_realized_pnl: cfm_amount("0"),
2506            initial_margin: cfm_amount("0"),
2507            available_margin: cfm_amount("0"),
2508            liquidation_threshold: cfm_amount("0"),
2509            liquidation_buffer_amount: cfm_amount("0"),
2510            liquidation_buffer_percentage: String::new(),
2511            intraday_margin_window_measure: intraday,
2512            overnight_margin_window_measure: overnight,
2513        }
2514    }
2515
2516    #[rstest]
2517    #[case::below_threshold("15", true)]
2518    #[case::at_threshold("20", false)]
2519    #[case::just_above("21", false)]
2520    #[case::well_above("100", false)]
2521    #[case::zero("0", true)]
2522    fn test_liquidation_buffer_in_warn_band(#[case] value: &str, #[case] expected_in_band: bool) {
2523        let pct = Decimal::from_str(value).unwrap();
2524        assert_eq!(liquidation_buffer_in_warn_band(pct), expected_in_band);
2525    }
2526
2527    #[rstest]
2528    #[case::below_threshold("5")]
2529    #[case::above_threshold("100")]
2530    #[case::empty_string_silently_skips_warn("")]
2531    fn test_parse_cfm_account_state_buffer_threshold_paths(#[case] buffer_pct: &str) {
2532        use nautilus_model::enums::AccountType;
2533
2534        let mut summary = cfm_summary_with_windows(
2535            Some(cfm_window(
2536                CoinbaseMarginWindowType::Intraday,
2537                "100.00",
2538                "50.00",
2539            )),
2540            None,
2541        );
2542        summary.total_usd_balance = cfm_amount("100.00");
2543        summary.available_margin = cfm_amount("50.00");
2544        summary.liquidation_buffer_percentage = buffer_pct.to_string();
2545
2546        let state = parse_cfm_account_state(
2547            &summary,
2548            AccountId::new("COINBASE-001"),
2549            true,
2550            UnixNanos::default(),
2551            UnixNanos::default(),
2552        )
2553        .unwrap();
2554
2555        assert_eq!(state.account_type, AccountType::Margin);
2556        assert!(!state.balances.is_empty());
2557    }
2558
2559    #[rstest]
2560    #[case::below_threshold("5")]
2561    #[case::above_threshold("100")]
2562    fn test_parse_ws_cfm_account_state_buffer_threshold_paths(#[case] buffer_pct: &str) {
2563        use nautilus_model::enums::AccountType;
2564
2565        use crate::websocket::messages::{WsFcmBalanceSummary, WsMarginWindowMeasure};
2566
2567        fn ws_window(kind: CoinbaseMarginWindowType) -> WsMarginWindowMeasure {
2568            WsMarginWindowMeasure {
2569                margin_window_type: kind,
2570                margin_level: CoinbaseMarginLevel::Base,
2571                initial_margin: Decimal::from_str("100.00").unwrap(),
2572                maintenance_margin: Decimal::from_str("50.00").unwrap(),
2573                liquidation_buffer_percentage: Decimal::ZERO,
2574                total_hold: Decimal::ZERO,
2575                futures_buying_power: Decimal::ZERO,
2576            }
2577        }
2578
2579        let summary = WsFcmBalanceSummary {
2580            futures_buying_power: Decimal::ZERO,
2581            total_usd_balance: Decimal::from_str("100.00").unwrap(),
2582            cbi_usd_balance: Decimal::ZERO,
2583            cfm_usd_balance: Decimal::ZERO,
2584            total_open_orders_hold_amount: Decimal::ZERO,
2585            unrealized_pnl: Decimal::ZERO,
2586            daily_realized_pnl: Decimal::ZERO,
2587            initial_margin: Decimal::ZERO,
2588            available_margin: Decimal::from_str("50.00").unwrap(),
2589            liquidation_threshold: Decimal::ZERO,
2590            liquidation_buffer_amount: Decimal::ZERO,
2591            liquidation_buffer_percentage: Decimal::from_str(buffer_pct).unwrap(),
2592            intraday_margin_window_measure: ws_window(CoinbaseMarginWindowType::Intraday),
2593            overnight_margin_window_measure: ws_window(CoinbaseMarginWindowType::Overnight),
2594        };
2595
2596        let state = parse_ws_cfm_account_state(
2597            &summary,
2598            AccountId::new("COINBASE-001"),
2599            UnixNanos::default(),
2600            UnixNanos::default(),
2601        )
2602        .unwrap();
2603
2604        assert_eq!(state.account_type, AccountType::Margin);
2605        assert!(!state.balances.is_empty());
2606    }
2607
2608    fn cfm_position(
2609        side: CoinbaseFcmPositionSide,
2610        contracts: &str,
2611        avg_entry: &str,
2612    ) -> CfmPosition {
2613        CfmPosition {
2614            product_id: Ustr::from("BIP-20DEC30-CDE"),
2615            expiration_time: String::new(),
2616            side,
2617            number_of_contracts: Decimal::from_str(contracts).unwrap(),
2618            current_price: cfm_amount("50000.00"),
2619            avg_entry_price: cfm_amount(avg_entry),
2620            unrealized_pnl: cfm_amount("0"),
2621            daily_realized_pnl: cfm_amount("0"),
2622            total_fees: None,
2623            contract_size: "0.01".to_string(),
2624            entry_vwap: None,
2625            liquidation_price: None,
2626            leverage: String::new(),
2627            im_contribution: None,
2628            mm_contribution: None,
2629            position_notional: None,
2630        }
2631    }
2632
2633    fn btc_perp_instrument() -> InstrumentAny {
2634        let json = load_test_fixture("http_products_future.json");
2635        let response: crate::http::models::ProductsResponse = serde_json::from_str(&json).unwrap();
2636        parse_instrument(&response.products[0], UnixNanos::default()).unwrap()
2637    }
2638}