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