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