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