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