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