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