Skip to main content

nautilus_coinbase/http/
models.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! HTTP response model types for the Coinbase Advanced Trade REST API.
17
18use rust_decimal::Decimal;
19use serde::Deserialize;
20use ustr::Ustr;
21
22use crate::common::{
23    enums::{
24        CoinbaseAccountType, CoinbaseContractExpiryType, CoinbaseFcmTradingSessionClosedReason,
25        CoinbaseFcmTradingSessionState, CoinbaseFillTradeType, CoinbaseFuturesAssetType,
26        CoinbaseLiquidityIndicator, CoinbaseMarginType, CoinbaseOrderPlacementSource,
27        CoinbaseOrderSide, CoinbaseOrderStatus, CoinbaseOrderType, CoinbaseProductStatus,
28        CoinbaseProductType, CoinbaseProductVenue, CoinbaseRiskManagedBy, CoinbaseTimeInForce,
29        CoinbaseTriggerStatus,
30    },
31    parse::{
32        deserialize_decimal_from_str, deserialize_decimal_or_zero, deserialize_margin_type_or_none,
33        deserialize_product_type_or_unknown, deserialize_string_to_u64,
34    },
35};
36
37/// Response wrapper for listing products.
38#[derive(Debug, Clone, Deserialize)]
39pub struct ProductsResponse {
40    pub products: Vec<Product>,
41    pub num_products: Option<i64>,
42}
43
44/// Coinbase product (trading pair).
45#[derive(Debug, Clone, Deserialize)]
46pub struct Product {
47    pub product_id: Ustr,
48    pub price: String,
49    pub price_percentage_change_24h: String,
50    pub volume_24h: String,
51    pub volume_percentage_change_24h: String,
52    pub base_increment: String,
53    pub quote_increment: String,
54    pub quote_min_size: String,
55    pub quote_max_size: String,
56    pub base_min_size: String,
57    pub base_max_size: String,
58    pub base_name: String,
59    pub quote_name: String,
60    pub watched: bool,
61    pub is_disabled: bool,
62    pub new: bool,
63    pub status: CoinbaseProductStatus,
64    pub cancel_only: bool,
65    pub limit_only: bool,
66    pub post_only: bool,
67    pub trading_disabled: bool,
68    pub auction_mode: bool,
69    #[serde(deserialize_with = "deserialize_product_type_or_unknown")]
70    pub product_type: CoinbaseProductType,
71    pub quote_currency_id: Ustr,
72    pub base_currency_id: Ustr,
73    #[serde(default)]
74    pub fcm_trading_session_details: Option<FcmTradingSessionDetails>,
75    #[serde(default)]
76    pub mid_market_price: String,
77    #[serde(default)]
78    pub alias: Ustr,
79    #[serde(default)]
80    pub alias_to: Vec<Ustr>,
81    #[serde(default)]
82    pub base_display_symbol: Ustr,
83    #[serde(default)]
84    pub quote_display_symbol: Ustr,
85    #[serde(default)]
86    pub view_only: bool,
87    pub price_increment: String,
88    #[serde(default)]
89    pub display_name: String,
90    #[serde(default)]
91    pub product_venue: Option<CoinbaseProductVenue>,
92    #[serde(default)]
93    pub approximate_quote_24h_volume: String,
94    #[serde(default)]
95    pub future_product_details: Option<FutureProductDetails>,
96}
97
98/// FCM trading session details for futures products.
99#[derive(Debug, Clone, Deserialize)]
100pub struct FcmTradingSessionDetails {
101    pub is_session_open: bool,
102    pub open_time: String,
103    pub close_time: String,
104    pub session_state: CoinbaseFcmTradingSessionState,
105    #[serde(default)]
106    pub after_hours_order_entry_disabled: bool,
107    pub closed_reason: CoinbaseFcmTradingSessionClosedReason,
108    #[serde(default)]
109    pub maintenance: Option<MaintenanceWindow>,
110}
111
112/// Maintenance window for FCM sessions.
113#[derive(Debug, Clone, Deserialize)]
114pub struct MaintenanceWindow {
115    pub start_time: String,
116    pub end_time: String,
117}
118
119/// Future product details.
120#[derive(Debug, Clone, Deserialize)]
121pub struct FutureProductDetails {
122    pub venue: Ustr,
123    pub contract_code: Ustr,
124    pub contract_expiry: String,
125    pub contract_size: String,
126    pub contract_root_unit: Ustr,
127    pub group_description: String,
128    pub contract_expiry_timezone: String,
129    pub group_short_description: String,
130    pub risk_managed_by: CoinbaseRiskManagedBy,
131    pub contract_expiry_type: CoinbaseContractExpiryType,
132    #[serde(default)]
133    pub perpetual_details: Option<PerpetualDetails>,
134    pub contract_display_name: String,
135    #[serde(default)]
136    pub time_to_expiry_ms: String,
137    #[serde(default)]
138    pub non_crypto: bool,
139    #[serde(default)]
140    pub contract_expiry_name: String,
141    #[serde(default)]
142    pub twenty_four_by_seven: bool,
143    #[serde(default)]
144    pub open_interest: String,
145    #[serde(default)]
146    pub funding_rate: String,
147    #[serde(default)]
148    pub funding_time: Option<String>,
149    #[serde(default)]
150    pub funding_interval: Option<String>,
151    #[serde(default)]
152    pub index_price: Option<String>,
153    #[serde(default)]
154    pub display_name: String,
155    #[serde(default)]
156    pub futures_asset_type: Option<CoinbaseFuturesAssetType>,
157}
158
159/// Perpetual contract details.
160#[derive(Debug, Clone, Deserialize)]
161pub struct PerpetualDetails {
162    #[serde(default)]
163    pub open_interest: String,
164    #[serde(default)]
165    pub funding_rate: String,
166    #[serde(default)]
167    pub funding_time: Option<String>,
168}
169
170/// Response wrapper for candles.
171#[derive(Debug, Clone, Deserialize)]
172pub struct CandlesResponse {
173    pub candles: Vec<Candle>,
174}
175
176/// OHLCV candle data.
177#[derive(Debug, Clone, Deserialize)]
178pub struct Candle {
179    pub start: String,
180    pub low: String,
181    pub high: String,
182    pub open: String,
183    pub close: String,
184    pub volume: String,
185}
186
187/// Response wrapper for ticker/market trades.
188#[derive(Debug, Clone, Deserialize)]
189pub struct TickerResponse {
190    pub trades: Vec<Trade>,
191    pub best_bid: String,
192    pub best_ask: String,
193}
194
195/// A single trade execution.
196#[derive(Debug, Clone, Deserialize)]
197pub struct Trade {
198    pub trade_id: String,
199    pub product_id: Ustr,
200    pub price: String,
201    pub size: String,
202    pub time: String,
203    pub side: CoinbaseOrderSide,
204    #[serde(default)]
205    pub bid: String,
206    #[serde(default)]
207    pub ask: String,
208    #[serde(default)]
209    pub exchange: String,
210}
211
212/// Response wrapper for the product order book.
213#[derive(Debug, Clone, Deserialize)]
214pub struct ProductBookResponse {
215    pub pricebook: PriceBook,
216    #[serde(default)]
217    pub last: String,
218    #[serde(default)]
219    pub mid_market: String,
220    #[serde(default)]
221    pub spread_bps: String,
222    #[serde(default)]
223    pub spread_absolute: String,
224}
225
226/// Order book price levels.
227#[derive(Debug, Clone, Deserialize)]
228pub struct PriceBook {
229    pub product_id: Ustr,
230    pub bids: Vec<BookLevel>,
231    pub asks: Vec<BookLevel>,
232    pub time: String,
233}
234
235/// A single price level in the order book.
236#[derive(Debug, Clone, Deserialize)]
237pub struct BookLevel {
238    pub price: String,
239    pub size: String,
240}
241
242/// Response wrapper for best bid/ask.
243#[derive(Debug, Clone, Deserialize)]
244pub struct BestBidAskResponse {
245    pub pricebooks: Vec<BestBidAsk>,
246}
247
248/// Best bid/ask for a single product.
249#[derive(Debug, Clone, Deserialize)]
250pub struct BestBidAsk {
251    pub product_id: Ustr,
252    pub bids: Vec<BookLevel>,
253    pub asks: Vec<BookLevel>,
254    #[serde(default)]
255    pub time: String,
256}
257
258/// Response wrapper for listing accounts.
259#[derive(Debug, Clone, Deserialize)]
260pub struct AccountsResponse {
261    pub accounts: Vec<Account>,
262    #[serde(default)]
263    pub has_next: bool,
264    #[serde(default)]
265    pub cursor: String,
266    #[serde(default)]
267    pub size: Option<i64>,
268}
269
270/// Coinbase account.
271#[derive(Debug, Clone, Deserialize)]
272pub struct Account {
273    pub uuid: String,
274    pub name: String,
275    pub currency: Ustr,
276    pub available_balance: Balance,
277    #[serde(default)]
278    pub default: bool,
279    #[serde(default)]
280    pub active: bool,
281    #[serde(default)]
282    pub created_at: String,
283    #[serde(default)]
284    pub updated_at: String,
285    #[serde(default)]
286    pub deleted_at: Option<String>,
287    #[serde(rename = "type")]
288    pub account_type: CoinbaseAccountType,
289    #[serde(default)]
290    pub ready: bool,
291    #[serde(default)]
292    pub hold: Option<Balance>,
293    #[serde(default)]
294    pub retail_portfolio_id: String,
295}
296
297/// Balance amount.
298#[derive(Debug, Clone, Deserialize)]
299pub struct Balance {
300    #[serde(deserialize_with = "deserialize_decimal_from_str")]
301    pub value: Decimal,
302    pub currency: Ustr,
303}
304
305/// Response for creating an order.
306#[derive(Debug, Clone, Deserialize)]
307pub struct CreateOrderResponse {
308    pub success: bool,
309    #[serde(default)]
310    pub failure_reason: String,
311    #[serde(default)]
312    pub order_id: String,
313    #[serde(default)]
314    pub success_response: Option<OrderSuccessResponse>,
315    #[serde(default)]
316    pub error_response: Option<OrderErrorResponse>,
317}
318
319/// Successful order creation details.
320#[derive(Debug, Clone, Deserialize)]
321pub struct OrderSuccessResponse {
322    pub order_id: String,
323    pub product_id: Ustr,
324    pub side: CoinbaseOrderSide,
325    pub client_order_id: String,
326}
327
328/// Order creation error details.
329#[derive(Debug, Clone, Deserialize)]
330pub struct OrderErrorResponse {
331    pub error: String,
332    pub message: String,
333    pub error_details: String,
334    #[serde(default)]
335    pub preview_failure_reason: String,
336    #[serde(default)]
337    pub new_order_failure_reason: String,
338}
339
340/// Response for batch cancel.
341#[derive(Debug, Clone, Deserialize)]
342pub struct CancelOrdersResponse {
343    pub results: Vec<CancelResult>,
344}
345
346/// Response for editing an order via `/orders/edit`.
347#[derive(Debug, Clone, Deserialize)]
348pub struct EditOrderResponse {
349    pub success: bool,
350    #[serde(default)]
351    pub errors: Vec<EditOrderError>,
352}
353
354/// A single edit error entry returned by `/orders/edit`.
355#[derive(Debug, Clone, Deserialize)]
356pub struct EditOrderError {
357    #[serde(default)]
358    pub edit_failure_reason: String,
359    #[serde(default)]
360    pub preview_failure_reason: String,
361}
362
363/// Result for a single order cancellation.
364#[derive(Debug, Clone, Deserialize)]
365pub struct CancelResult {
366    pub success: bool,
367    #[serde(default)]
368    pub failure_reason: String,
369    pub order_id: String,
370}
371
372/// Response wrapper for a single order lookup.
373#[derive(Debug, Clone, Deserialize)]
374pub struct OrderResponse {
375    pub order: Order,
376}
377
378/// Response wrapper for an orders list query.
379#[derive(Debug, Clone, Deserialize)]
380pub struct OrdersListResponse {
381    pub orders: Vec<Order>,
382    #[serde(default)]
383    pub sequence: Option<String>,
384    #[serde(default)]
385    pub has_next: bool,
386    #[serde(default)]
387    pub cursor: String,
388}
389
390/// A historical or open order as returned by `/orders/historical/*`.
391///
392/// `order_configuration` is kept as a raw JSON value because Coinbase returns
393/// a wider set of config shapes on history responses than on submit (bracket
394/// orders, TWAP, trigger variants, and new shapes Coinbase may ship without
395/// bumping the API version). Consumers that need typed access can try to
396/// deserialize the inner value into
397/// [`crate::http::query::OrderConfiguration`] and tolerate failures. Keeping
398/// the wire shape permissive prevents a single unknown variant from failing
399/// the entire batch response.
400#[derive(Debug, Clone, Deserialize)]
401pub struct Order {
402    pub order_id: String,
403    pub product_id: Ustr,
404    #[serde(default)]
405    pub user_id: String,
406    #[serde(default)]
407    pub order_configuration: Option<serde_json::Value>,
408    pub side: CoinbaseOrderSide,
409    #[serde(default)]
410    pub client_order_id: String,
411    pub status: CoinbaseOrderStatus,
412    #[serde(default)]
413    pub time_in_force: Option<CoinbaseTimeInForce>,
414    #[serde(default)]
415    pub created_time: String,
416    #[serde(default)]
417    pub completion_percentage: String,
418    #[serde(default)]
419    pub filled_size: String,
420    #[serde(default)]
421    pub average_filled_price: String,
422    // Coinbase returns these as empty strings on terminal or unfilled orders
423    // (e.g. cancelled before any partial fill). `deserialize_decimal_or_zero`
424    // accepts `""` and `"0"` as `Decimal::ZERO` so a single empty field does
425    // not fail the entire historical-order batch.
426    #[serde(default, deserialize_with = "deserialize_decimal_or_zero")]
427    pub fee: Decimal,
428    #[serde(default, deserialize_with = "deserialize_string_to_u64")]
429    pub number_of_fills: u64,
430    #[serde(default, deserialize_with = "deserialize_decimal_or_zero")]
431    pub filled_value: Decimal,
432    #[serde(default)]
433    pub pending_cancel: bool,
434    #[serde(default)]
435    pub size_in_quote: bool,
436    #[serde(default, deserialize_with = "deserialize_decimal_or_zero")]
437    pub total_fees: Decimal,
438    #[serde(default)]
439    pub size_inclusive_of_fees: bool,
440    #[serde(default, deserialize_with = "deserialize_decimal_or_zero")]
441    pub total_value_after_fees: Decimal,
442    pub trigger_status: CoinbaseTriggerStatus,
443    pub order_type: CoinbaseOrderType,
444    #[serde(default)]
445    pub reject_reason: String,
446    #[serde(default)]
447    pub settled: bool,
448    #[serde(deserialize_with = "deserialize_product_type_or_unknown")]
449    pub product_type: CoinbaseProductType,
450    #[serde(default)]
451    pub reject_message: String,
452    #[serde(default)]
453    pub cancel_message: String,
454    pub order_placement_source: CoinbaseOrderPlacementSource,
455    #[serde(default, deserialize_with = "deserialize_decimal_or_zero")]
456    pub outstanding_hold_amount: Decimal,
457    #[serde(default)]
458    pub is_liquidation: bool,
459    #[serde(default)]
460    pub last_fill_time: Option<String>,
461    #[serde(default)]
462    pub leverage: String,
463    #[serde(default, deserialize_with = "deserialize_margin_type_or_none")]
464    pub margin_type: Option<CoinbaseMarginType>,
465    #[serde(default)]
466    pub retail_portfolio_id: String,
467    #[serde(default)]
468    pub originating_order_id: String,
469    #[serde(default)]
470    pub attached_order_id: String,
471}
472
473/// Response for `GET /api/v3/brokerage/cfm/balance_summary`.
474#[derive(Debug, Clone, Deserialize)]
475pub struct CfmBalanceSummaryResponse {
476    pub balance_summary: CfmBalanceSummary,
477}
478
479/// Coinbase FCM (futures) balance summary.
480#[derive(Debug, Clone, Deserialize)]
481pub struct CfmBalanceSummary {
482    pub futures_buying_power: CfmAmount,
483    pub total_usd_balance: CfmAmount,
484    pub cbi_usd_balance: CfmAmount,
485    pub cfm_usd_balance: CfmAmount,
486    pub total_open_orders_hold_amount: CfmAmount,
487    pub unrealized_pnl: CfmAmount,
488    pub daily_realized_pnl: CfmAmount,
489    pub initial_margin: CfmAmount,
490    pub available_margin: CfmAmount,
491    pub liquidation_threshold: CfmAmount,
492    pub liquidation_buffer_amount: CfmAmount,
493    #[serde(default)]
494    pub liquidation_buffer_percentage: String,
495    #[serde(default)]
496    pub intraday_margin_window_measure: Option<CfmMarginWindowMeasure>,
497    #[serde(default)]
498    pub overnight_margin_window_measure: Option<CfmMarginWindowMeasure>,
499}
500
501/// Monetary value with an explicit currency code.
502///
503/// REST returns FCM money fields as `{value: "...", currency: "USD"}`; the
504/// scalar-only WebSocket form is covered by
505/// [`crate::websocket::messages::WsFcmBalanceSummary`].
506#[derive(Debug, Clone, Deserialize)]
507pub struct CfmAmount {
508    #[serde(deserialize_with = "deserialize_decimal_from_str")]
509    pub value: Decimal,
510    pub currency: Ustr,
511}
512
513/// Margin window breakdown inside an FCM balance summary.
514#[derive(Debug, Clone, Deserialize)]
515pub struct CfmMarginWindowMeasure {
516    pub margin_window_type: crate::common::enums::CoinbaseMarginWindowType,
517    pub margin_level: crate::common::enums::CoinbaseMarginLevel,
518    pub initial_margin: CfmAmount,
519    pub maintenance_margin: CfmAmount,
520    #[serde(default)]
521    pub liquidation_buffer_percentage: String,
522    pub total_hold: CfmAmount,
523    pub futures_buying_power: CfmAmount,
524}
525
526/// Response for `GET /api/v3/brokerage/cfm/positions`.
527#[derive(Debug, Clone, Deserialize)]
528pub struct CfmPositionsResponse {
529    pub positions: Vec<CfmPosition>,
530}
531
532/// Response for `GET /api/v3/brokerage/cfm/positions/{product_id}`.
533#[derive(Debug, Clone, Deserialize)]
534pub struct CfmPositionResponse {
535    pub position: CfmPosition,
536}
537
538/// Position on the Coinbase FCM venue.
539#[derive(Debug, Clone, Deserialize)]
540pub struct CfmPosition {
541    pub product_id: Ustr,
542    #[serde(default)]
543    pub expiration_time: String,
544    pub side: crate::common::enums::CoinbaseFcmPositionSide,
545    #[serde(deserialize_with = "deserialize_decimal_from_str")]
546    pub number_of_contracts: Decimal,
547    pub current_price: CfmAmount,
548    pub avg_entry_price: CfmAmount,
549    pub unrealized_pnl: CfmAmount,
550    pub daily_realized_pnl: CfmAmount,
551    #[serde(default)]
552    pub total_fees: Option<CfmAmount>,
553    #[serde(default)]
554    pub contract_size: String,
555    #[serde(default)]
556    pub entry_vwap: Option<CfmAmount>,
557    #[serde(default)]
558    pub liquidation_price: Option<CfmAmount>,
559    #[serde(default)]
560    pub leverage: String,
561    #[serde(default)]
562    pub im_contribution: Option<CfmAmount>,
563    #[serde(default)]
564    pub mm_contribution: Option<CfmAmount>,
565    #[serde(default)]
566    pub position_notional: Option<CfmAmount>,
567}
568
569/// Response for listing fills.
570#[derive(Debug, Clone, Deserialize)]
571pub struct FillsResponse {
572    pub fills: Vec<Fill>,
573    #[serde(default)]
574    pub cursor: String,
575}
576
577/// A single fill (trade execution).
578#[derive(Debug, Clone, Deserialize)]
579pub struct Fill {
580    pub entry_id: String,
581    pub trade_id: String,
582    pub order_id: String,
583    pub trade_time: String,
584    pub trade_type: CoinbaseFillTradeType,
585    pub price: String,
586    pub size: String,
587    #[serde(default, deserialize_with = "deserialize_decimal_or_zero")]
588    pub commission: Decimal,
589    pub product_id: Ustr,
590    pub sequence_timestamp: String,
591    pub liquidity_indicator: CoinbaseLiquidityIndicator,
592    pub size_in_quote: bool,
593    pub user_id: String,
594    pub side: CoinbaseOrderSide,
595    #[serde(default)]
596    pub retail_portfolio_id: String,
597}
598
599#[cfg(test)]
600mod tests {
601    use std::str::FromStr;
602
603    use rstest::rstest;
604    use rust_decimal::Decimal;
605
606    use super::*;
607    use crate::common::{consts::ORDER_CONFIG_LIMIT_GTC, testing::load_test_fixture};
608
609    #[rstest]
610    fn test_deserialize_product() {
611        let json = load_test_fixture("http_product.json");
612        let product: Product = serde_json::from_str(&json).unwrap();
613        assert_eq!(product.product_id, "BTC-USD");
614        assert_eq!(product.product_type, CoinbaseProductType::Spot);
615        assert_eq!(product.base_currency_id, "BTC");
616        assert_eq!(product.quote_currency_id, "USD");
617        assert_eq!(product.base_increment, "0.00000001");
618        assert_eq!(product.quote_increment, "0.01");
619        assert_eq!(product.price_increment, "0.01");
620        assert!(!product.is_disabled);
621        assert!(!product.trading_disabled);
622    }
623
624    #[rstest]
625    fn test_deserialize_products_list() {
626        let json = load_test_fixture("http_products.json");
627        let response: ProductsResponse = serde_json::from_str(&json).unwrap();
628        assert_eq!(response.products.len(), 2);
629        assert_eq!(response.products[0].product_id, "BTC-USD");
630        assert_eq!(response.products[1].product_id, "BTC-USDC");
631    }
632
633    #[rstest]
634    fn test_deserialize_products_future() {
635        let json = load_test_fixture("http_products_future.json");
636        let response: ProductsResponse = serde_json::from_str(&json).unwrap();
637        assert!(!response.products.is_empty());
638        assert_eq!(
639            response.products[0].product_type,
640            CoinbaseProductType::Future
641        );
642        assert!(response.products[0].fcm_trading_session_details.is_some());
643    }
644
645    #[rstest]
646    fn test_deserialize_candles() {
647        let json = load_test_fixture("http_candles.json");
648        let response: CandlesResponse = serde_json::from_str(&json).unwrap();
649        assert_eq!(response.candles.len(), 2);
650
651        let candle = &response.candles[0];
652        assert!(!candle.start.is_empty());
653        assert!(!candle.open.is_empty());
654        assert!(!candle.high.is_empty());
655        assert!(!candle.low.is_empty());
656        assert!(!candle.close.is_empty());
657        assert!(!candle.volume.is_empty());
658    }
659
660    #[rstest]
661    fn test_deserialize_ticker() {
662        let json = load_test_fixture("http_ticker.json");
663        let response: TickerResponse = serde_json::from_str(&json).unwrap();
664        assert_eq!(response.trades.len(), 3);
665        assert!(!response.best_bid.is_empty());
666        assert!(!response.best_ask.is_empty());
667
668        let trade = &response.trades[0];
669        assert_eq!(trade.product_id, "BTC-USD");
670        assert!(!trade.price.is_empty());
671        assert!(!trade.size.is_empty());
672        assert!(!trade.time.is_empty());
673        assert!(trade.side == CoinbaseOrderSide::Buy || trade.side == CoinbaseOrderSide::Sell);
674    }
675
676    #[rstest]
677    fn test_deserialize_product_book() {
678        let json = load_test_fixture("http_product_book.json");
679        let response: ProductBookResponse = serde_json::from_str(&json).unwrap();
680        assert_eq!(response.pricebook.product_id, "BTC-USD");
681        assert!(!response.pricebook.bids.is_empty());
682        assert!(!response.pricebook.asks.is_empty());
683        assert!(!response.pricebook.time.is_empty());
684        assert!(!response.spread_absolute.is_empty());
685
686        let bid = &response.pricebook.bids[0];
687        assert!(!bid.price.is_empty());
688        assert!(!bid.size.is_empty());
689    }
690
691    #[rstest]
692    fn test_product_spot_fields() {
693        let json = load_test_fixture("http_product.json");
694        let product: Product = serde_json::from_str(&json).unwrap();
695
696        // Verify precision-relevant fields
697        assert_eq!(product.base_min_size, "0.00000001");
698        assert_eq!(product.base_max_size, "3400");
699        assert_eq!(product.quote_min_size, "1");
700        assert_eq!(product.quote_max_size, "150000000");
701        assert_eq!(product.product_venue, Some(CoinbaseProductVenue::Cbe));
702    }
703
704    #[rstest]
705    fn test_deserialize_order() {
706        let json = load_test_fixture("http_order.json");
707        let response: OrderResponse = serde_json::from_str(&json).unwrap();
708        let order = response.order;
709
710        assert_eq!(order.order_id, "0000-000000-000000");
711        assert_eq!(order.product_id, "BTC-USD");
712        assert_eq!(order.side, CoinbaseOrderSide::Buy);
713        assert_eq!(order.status, CoinbaseOrderStatus::Open);
714        assert_eq!(order.client_order_id, "11111-000000-000000");
715        assert_eq!(
716            order.time_in_force,
717            Some(CoinbaseTimeInForce::GoodUntilCancelled)
718        );
719        assert_eq!(order.order_type, CoinbaseOrderType::Limit);
720        assert_eq!(
721            order.trigger_status,
722            CoinbaseTriggerStatus::InvalidOrderType
723        );
724        assert_eq!(
725            order.order_placement_source,
726            CoinbaseOrderPlacementSource::RetailAdvanced
727        );
728        assert_eq!(order.margin_type, Some(CoinbaseMarginType::Cross));
729        assert_eq!(order.filled_size, "0.001");
730        assert_eq!(order.average_filled_price, "50");
731        assert_eq!(order.fee, Decimal::ZERO);
732        assert_eq!(order.number_of_fills, 2);
733        assert_eq!(order.filled_value, Decimal::from_str("10000").unwrap());
734        assert_eq!(order.total_fees, Decimal::from_str("5.00").unwrap());
735        assert_eq!(
736            order.total_value_after_fees,
737            Decimal::from_str("10000").unwrap()
738        );
739        assert_eq!(
740            order.outstanding_hold_amount,
741            Decimal::from_str("1.00").unwrap()
742        );
743        assert_eq!(
744            order.last_fill_time.as_deref(),
745            Some("2021-05-31T10:30:00Z")
746        );
747        // History configs are kept as raw JSON so unknown Coinbase variants
748        // don't fail the whole batch; verify the shape by key lookup.
749        let config = order
750            .order_configuration
751            .as_ref()
752            .and_then(|v| v.as_object())
753            .expect("order_configuration should be a JSON object");
754        assert!(config.contains_key(ORDER_CONFIG_LIMIT_GTC));
755    }
756
757    #[rstest]
758    fn test_deserialize_orders_list() {
759        let json = load_test_fixture("http_orders_list.json");
760        let response: OrdersListResponse = serde_json::from_str(&json).unwrap();
761
762        assert_eq!(response.orders.len(), 2);
763        assert!(!response.has_next);
764
765        let open_order = &response.orders[0];
766        assert_eq!(open_order.status, CoinbaseOrderStatus::Open);
767        assert_eq!(open_order.side, CoinbaseOrderSide::Buy);
768        assert_eq!(open_order.order_type, CoinbaseOrderType::Limit);
769        assert_eq!(
770            open_order.trigger_status,
771            CoinbaseTriggerStatus::InvalidOrderType
772        );
773        assert_eq!(open_order.margin_type, None);
774        assert_eq!(open_order.number_of_fills, 0);
775        assert_eq!(open_order.total_fees, Decimal::ZERO);
776
777        let filled_order = &response.orders[1];
778        assert_eq!(filled_order.status, CoinbaseOrderStatus::Filled);
779        assert_eq!(filled_order.side, CoinbaseOrderSide::Sell);
780        assert_eq!(filled_order.order_type, CoinbaseOrderType::Market);
781        assert_eq!(filled_order.margin_type, None);
782        assert!(filled_order.size_in_quote);
783        assert_eq!(
784            filled_order.time_in_force,
785            Some(CoinbaseTimeInForce::ImmediateOrCancel)
786        );
787    }
788
789    #[rstest]
790    fn test_deserialize_fills() {
791        let json = load_test_fixture("http_fills.json");
792        let response: FillsResponse = serde_json::from_str(&json).unwrap();
793
794        assert_eq!(response.fills.len(), 2);
795
796        let maker_fill = &response.fills[0];
797        assert_eq!(maker_fill.trade_id, "1111-11111-111111");
798        assert_eq!(maker_fill.order_id, "0000-000000-000000");
799        assert_eq!(maker_fill.product_id, "BTC-USD");
800        assert_eq!(maker_fill.price, "45123.45");
801        assert_eq!(maker_fill.size, "0.005");
802        assert_eq!(maker_fill.trade_type, CoinbaseFillTradeType::Fill);
803        assert_eq!(maker_fill.commission, Decimal::from_str("1.14").unwrap());
804        assert_eq!(maker_fill.side, CoinbaseOrderSide::Buy);
805        assert_eq!(
806            maker_fill.liquidity_indicator,
807            CoinbaseLiquidityIndicator::Maker
808        );
809
810        let taker_fill = &response.fills[1];
811        assert_eq!(
812            taker_fill.liquidity_indicator,
813            CoinbaseLiquidityIndicator::Taker
814        );
815    }
816
817    #[rstest]
818    fn test_deserialize_accounts() {
819        let json = load_test_fixture("http_accounts.json");
820        let response: AccountsResponse = serde_json::from_str(&json).unwrap();
821
822        assert_eq!(response.accounts.len(), 2);
823        assert!(!response.has_next);
824
825        let btc_account = &response.accounts[0];
826        assert_eq!(btc_account.currency, "BTC");
827        assert_eq!(
828            btc_account.available_balance.value,
829            Decimal::from_str("1.23456789").unwrap()
830        );
831        assert_eq!(btc_account.available_balance.currency, "BTC");
832        assert_eq!(btc_account.account_type, CoinbaseAccountType::Crypto);
833        assert!(btc_account.default);
834        assert_eq!(
835            btc_account.hold.as_ref().map(|b| b.value),
836            Some(Decimal::from_str("0.00500000").unwrap())
837        );
838
839        let usd_account = &response.accounts[1];
840        assert_eq!(usd_account.currency, "USD");
841        assert_eq!(
842            usd_account.available_balance.value,
843            Decimal::from_str("10000.50").unwrap()
844        );
845        assert_eq!(usd_account.account_type, CoinbaseAccountType::Fiat);
846    }
847
848    #[rstest]
849    fn test_future_product_fields() {
850        let json = load_test_fixture("http_products_future.json");
851        let response: ProductsResponse = serde_json::from_str(&json).unwrap();
852        let product = &response.products[0];
853
854        assert_eq!(product.product_type, CoinbaseProductType::Future);
855        assert_eq!(product.product_venue, Some(CoinbaseProductVenue::Fcm));
856        assert!(product.future_product_details.is_some());
857
858        let session = product.fcm_trading_session_details.as_ref().unwrap();
859        assert_eq!(session.session_state, CoinbaseFcmTradingSessionState::Open);
860        assert_eq!(
861            session.closed_reason,
862            CoinbaseFcmTradingSessionClosedReason::ExchangeMaintenance
863        );
864
865        let details = product.future_product_details.as_ref().unwrap();
866        assert!(!details.contract_code.is_empty());
867        assert!(!details.contract_size.is_empty());
868        assert_eq!(details.risk_managed_by, CoinbaseRiskManagedBy::ManagedByFcm);
869    }
870
871    #[rstest]
872    fn test_deserialize_edit_order_response_success() {
873        let json = r#"{"success": true, "errors": []}"#;
874        let resp: EditOrderResponse = serde_json::from_str(json).unwrap();
875        assert!(resp.success);
876        assert!(resp.errors.is_empty());
877    }
878
879    #[rstest]
880    fn test_deserialize_edit_order_response_failure() {
881        let json = r#"{
882            "success": false,
883            "errors": [
884                {
885                    "edit_failure_reason": "ORDER_NOT_FOUND",
886                    "preview_failure_reason": ""
887                }
888            ]
889        }"#;
890        let resp: EditOrderResponse = serde_json::from_str(json).unwrap();
891        assert!(!resp.success);
892        assert_eq!(resp.errors.len(), 1);
893        assert_eq!(resp.errors[0].edit_failure_reason, "ORDER_NOT_FOUND");
894        assert_eq!(resp.errors[0].preview_failure_reason, "");
895    }
896
897    #[rstest]
898    fn test_deserialize_edit_order_response_preview_failure() {
899        let json = r#"{
900            "success": false,
901            "errors": [
902                {
903                    "edit_failure_reason": "",
904                    "preview_failure_reason": "PREVIEW_INSUFFICIENT_FUNDS"
905                }
906            ]
907        }"#;
908        let resp: EditOrderResponse = serde_json::from_str(json).unwrap();
909        assert!(!resp.success);
910        assert_eq!(
911            resp.errors[0].preview_failure_reason,
912            "PREVIEW_INSUFFICIENT_FUNDS"
913        );
914    }
915
916    #[rstest]
917    fn test_deserialize_edit_order_response_omitted_errors_defaults_empty() {
918        let json = r#"{"success": true}"#;
919        let resp: EditOrderResponse = serde_json::from_str(json).unwrap();
920        assert!(resp.success);
921        assert!(resp.errors.is_empty());
922    }
923}