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