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