rusty_ems/exchanges/coinbase/
rest_client.rs

1//! Coinbase Advanced Trade REST API Client
2//!
3//! A comprehensive implementation of the Coinbase Advanced Trade REST API with support for:
4//! - ECDSA (ES256) authentication for Advanced Trade
5//! - HMAC-SHA256 authentication for legacy API
6//! - Order management (place, cancel, modify, get status)
7//! - Account management (balances, fees, portfolios)
8//! - Market data (products, order book, candles, trades)
9//! - Portfolio management (create, list, move funds)
10//! - Transaction history and fills
11//!
12//! # Authentication
13//!
14//! The client supports both authentication methods:
15//! - **ECDSA (Recommended)**: For Coinbase Advanced Trade API using ES256 JWT tokens
16//! - **HMAC-SHA256**: For legacy Coinbase Pro API
17//!
18//! # Usage
19//!
20//! ```rust,no_run
21//! use rusty_ems::exchanges::coinbase::CoinbaseRestClient;
22//! use rusty_common::auth::exchanges::coinbase::CoinbaseAuth;
23//! use std::sync::Arc;
24//!
25//! #[tokio::main]
26//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
27//!     // ECDSA authentication (Advanced Trade)
28//!     let auth = Arc::new(CoinbaseAuth::new_ecdsa(
29//!         "your_api_key".into(),
30//!         "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----".into()
31//!     )?);
32//!
33//!     let client = CoinbaseRestClient::new(auth, false); // false = production
34//!
35//!     // Get account balances
36//!     let accounts = client.get_accounts().await?;
37//!     println!("Accounts: {:#?}", accounts);
38//!
39//!     Ok(())
40//! }
41//! ```
42
43use anyhow::{Result, bail};
44use log::{trace, warn};
45use reqwest::{Method, Response};
46use rusty_common::{SmartString, auth::exchanges::coinbase::CoinbaseAuth};
47use rusty_model::enums::{OrderSide, OrderStatus};
48use serde::{Deserialize, Serialize};
49use simd_json::value::owned::Value as JsonValue;
50use std::sync::Arc;
51use std::time::Duration;
52
53use crate::error::exchange_errors::extract_rate_limit_info_detailed;
54
55/// Coinbase API URLs
56const COINBASE_API_URL: &str = "https://api.coinbase.com";
57const COINBASE_SANDBOX_URL: &str = "https://api-public.sandbox.exchange.coinbase.com";
58const COINBASE_ADVANCED_API_URL: &str = "https://api.coinbase.com/api/v3/brokerage";
59const COINBASE_ADVANCED_SANDBOX_URL: &str =
60    "https://api-public.sandbox.exchange.coinbase.com/api/v3/brokerage";
61
62/// Request timeout configuration
63const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
64const MARKET_DATA_TIMEOUT: Duration = Duration::from_secs(10);
65
66/// Account information
67#[derive(Debug, Clone, Deserialize, Serialize)]
68pub struct CoinbaseAccount {
69    /// Unique identifier for the account
70    pub uuid: SmartString,
71    /// Human-readable name for the account
72    pub name: SmartString,
73    /// Currency code for the account (e.g., "BTC", "USD")
74    pub currency: SmartString,
75    /// Available balance that can be used for trading
76    pub available_balance: CoinbaseAccountBalance,
77    /// Whether this is the default account for the currency
78    pub default: bool,
79    /// Whether the account is currently active
80    pub active: bool,
81    /// ISO 8601 timestamp when the account was created
82    pub created_at: SmartString,
83    /// ISO 8601 timestamp when the account was last updated
84    pub updated_at: SmartString,
85    /// ISO 8601 timestamp when the account was deleted (if applicable)
86    pub deleted_at: Option<SmartString>,
87    /// Type of account (e.g., "wallet", "fiat")
88    #[serde(rename = "type")]
89    pub account_type: SmartString,
90    /// Whether the account is ready for trading
91    pub ready: bool,
92    /// Amount held in pending transactions or orders
93    pub hold: CoinbaseAccountBalance,
94}
95
96/// Account balance information
97#[derive(Debug, Clone, Deserialize, Serialize)]
98pub struct CoinbaseAccountBalance {
99    /// The balance amount as a string representation
100    pub value: SmartString,
101    /// Currency code for the balance (e.g., "BTC", "USD")
102    pub currency: SmartString,
103}
104
105/// Product information
106#[derive(Debug, Clone, Deserialize, Serialize)]
107pub struct CoinbaseProduct {
108    /// Unique identifier for the trading product (e.g., "BTC-USD")
109    pub product_id: SmartString,
110    /// Current price of the product
111    pub price: SmartString,
112    /// Price percentage change in the last 24 hours
113    pub price_percentage_change_24h: SmartString,
114    /// Total volume traded in the last 24 hours
115    pub volume_24h: SmartString,
116    /// Volume percentage change in the last 24 hours
117    pub volume_percentage_change_24h: SmartString,
118    /// Minimum increment for base currency orders
119    pub base_increment: SmartString,
120    /// Minimum increment for quote currency orders
121    pub quote_increment: SmartString,
122    /// Minimum order size in quote currency
123    pub quote_min_size: SmartString,
124    /// Maximum order size in quote currency
125    pub quote_max_size: SmartString,
126    /// Minimum order size in base currency
127    pub base_min_size: SmartString,
128    /// Maximum order size in base currency
129    pub base_max_size: SmartString,
130    /// Display name for the base currency
131    pub base_name: SmartString,
132    /// Display name for the quote currency
133    pub quote_name: SmartString,
134    /// Whether the product is being watched
135    pub watched: bool,
136    /// Whether trading is disabled for this product
137    pub is_disabled: bool,
138    /// Whether this is a new product
139    pub new: bool,
140    /// Current status of the product
141    pub status: SmartString,
142    /// Whether only cancel operations are allowed
143    pub cancel_only: bool,
144    /// Whether only limit orders are allowed
145    pub limit_only: bool,
146    /// Whether only post-only orders are allowed
147    pub post_only: bool,
148    /// Whether trading is disabled
149    pub trading_disabled: bool,
150    /// Whether the product is in auction mode
151    pub auction_mode: bool,
152    /// Type of the product (e.g., "SPOT")
153    pub product_type: SmartString,
154    /// Unique identifier for the quote currency
155    pub quote_currency_id: SmartString,
156    /// Unique identifier for the base currency
157    pub base_currency_id: SmartString,
158    /// Mid-market price between best bid and ask
159    pub mid_market_price: SmartString,
160}
161
162/// Order information from Coinbase
163#[derive(Debug, Clone, Deserialize, Serialize)]
164pub struct CoinbaseOrder {
165    /// Unique identifier for the order
166    pub order_id: SmartString,
167    /// Product identifier for the trading pair (e.g., "BTC-USD")
168    pub product_id: SmartString,
169    /// User identifier who placed the order
170    pub user_id: SmartString,
171    /// Order configuration containing type-specific details
172    pub order_configuration: CoinbaseOrderConfiguration,
173    /// Order side: "BUY" or "SELL"
174    pub side: SmartString, // "BUY" or "SELL"
175    /// Client-provided order identifier
176    pub client_order_id: SmartString,
177    /// Current status of the order
178    pub status: SmartString,
179    /// Time in force for the order (e.g., "GTC", "IOC")
180    pub time_in_force: SmartString,
181    /// ISO 8601 timestamp when the order was created
182    pub created_time: SmartString,
183    /// Percentage of the order that has been filled
184    pub completion_percentage: SmartString,
185    /// Amount of the order that has been filled
186    pub filled_size: SmartString,
187    /// Average price at which the order was filled
188    pub average_filled_price: SmartString,
189    /// Fee charged for the order
190    pub fee: SmartString,
191    /// Number of individual fills for this order
192    pub number_of_fills: SmartString,
193    /// Total value of filled portion
194    pub filled_value: SmartString,
195    /// Whether the order has a pending cancel request
196    pub pending_cancel: bool,
197    /// Whether the order size is specified in quote currency
198    pub size_in_quote: bool,
199    /// Total fees charged for the order
200    pub total_fees: SmartString,
201    /// Whether the order size includes fees
202    pub size_inclusive_of_fees: bool,
203    /// Total value after fees have been deducted
204    pub total_value_after_fees: SmartString,
205    /// Status of trigger conditions for conditional orders
206    pub trigger_status: SmartString,
207    /// Type of the order (e.g., "market", "limit")
208    pub order_type: SmartString,
209    /// Reason for order rejection (if applicable)
210    pub reject_reason: SmartString,
211    /// Whether the order has been settled
212    pub settled: bool,
213    /// Type of the product being traded
214    pub product_type: SmartString,
215    /// Detailed rejection message (if applicable)
216    pub reject_message: SmartString,
217    /// Message explaining order cancellation (if applicable)
218    pub cancel_message: SmartString,
219}
220
221/// Order configuration for different order types
222#[derive(Debug, Clone, Deserialize, Serialize)]
223#[serde(tag = "order_type")]
224pub enum CoinbaseOrderConfiguration {
225    /// Market order with Immediate-Or-Cancel (IOC) time in force
226    #[serde(rename = "market_market_ioc")]
227    MarketIoc {
228        /// Market order configuration
229        market_market_ioc: CoinbaseMarketOrder,
230    },
231    /// Limit order with Good-Till-Cancelled (GTC) time in force
232    #[serde(rename = "limit_limit_gtc")]
233    LimitGtc {
234        /// Limit order configuration
235        limit_limit_gtc: CoinbaseLimitOrder,
236    },
237    /// Limit order with Good-Till-Date (GTD) time in force
238    #[serde(rename = "limit_limit_gtd")]
239    LimitGtd {
240        /// Limit order with expiration date configuration
241        limit_limit_gtd: CoinbaseLimitOrderGtd,
242    },
243    /// Stop limit order with Good-Till-Cancelled (GTC) time in force
244    #[serde(rename = "stop_limit_stop_limit_gtc")]
245    StopLimitGtc {
246        /// Stop limit order configuration
247        stop_limit_stop_limit_gtc: CoinbaseStopLimitOrder,
248    },
249    /// Stop limit order with Good-Till-Date (GTD) time in force
250    #[serde(rename = "stop_limit_stop_limit_gtd")]
251    StopLimitGtd {
252        /// Stop limit order with expiration date configuration
253        stop_limit_stop_limit_gtd: CoinbaseStopLimitOrderGtd,
254    },
255}
256
257/// Market order configuration
258#[derive(Debug, Clone, Deserialize, Serialize)]
259pub struct CoinbaseMarketOrder {
260    /// Size of the order in quote currency (e.g., USD amount to spend)
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub quote_size: Option<SmartString>,
263    /// Size of the order in base currency (e.g., BTC amount to buy/sell)
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub base_size: Option<SmartString>,
266}
267
268/// Limit order configuration
269#[derive(Debug, Clone, Deserialize, Serialize)]
270pub struct CoinbaseLimitOrder {
271    /// Size of the order in base currency
272    pub base_size: SmartString,
273    /// Price at which the order should be executed
274    pub limit_price: SmartString,
275    /// Whether the order should only be posted (not immediately filled)
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub post_only: Option<bool>,
278}
279
280/// Limit order with Good Till Date
281#[derive(Debug, Clone, Deserialize, Serialize)]
282pub struct CoinbaseLimitOrderGtd {
283    /// Size of the order in base currency
284    pub base_size: SmartString,
285    /// Price at which the order should be executed
286    pub limit_price: SmartString,
287    /// ISO 8601 timestamp when the order expires
288    pub end_time: SmartString,
289    /// Whether the order should only be posted (not immediately filled)
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub post_only: Option<bool>,
292}
293
294/// Stop limit order configuration
295#[derive(Debug, Clone, Deserialize, Serialize)]
296pub struct CoinbaseStopLimitOrder {
297    /// Size of the order in base currency
298    pub base_size: SmartString,
299    /// Price at which the order should be executed when triggered
300    pub limit_price: SmartString,
301    /// Price at which the stop order is triggered
302    pub stop_price: SmartString,
303    /// Direction of the stop: "STOP_DIRECTION_STOP_UP" or "STOP_DIRECTION_STOP_DOWN"
304    pub stop_direction: SmartString, // "STOP_DIRECTION_STOP_UP" or "STOP_DIRECTION_STOP_DOWN"
305}
306
307/// Stop limit order with Good Till Date
308#[derive(Debug, Clone, Deserialize, Serialize)]
309pub struct CoinbaseStopLimitOrderGtd {
310    /// Size of the order in base currency
311    pub base_size: SmartString,
312    /// Price at which the order should be executed when triggered
313    pub limit_price: SmartString,
314    /// Price at which the stop order is triggered
315    pub stop_price: SmartString,
316    /// ISO 8601 timestamp when the order expires
317    pub end_time: SmartString,
318    /// Direction of the stop: "STOP_DIRECTION_STOP_UP" or "STOP_DIRECTION_STOP_DOWN"
319    pub stop_direction: SmartString,
320}
321
322/// Order request for creating new orders
323#[derive(Debug, Clone, Serialize)]
324pub struct CoinbaseOrderRequest {
325    /// Client-provided unique identifier for the order
326    pub client_order_id: SmartString,
327    /// Product identifier for the trading pair (e.g., "BTC-USD")
328    pub product_id: SmartString,
329    /// Order side: "BUY" or "SELL"
330    pub side: SmartString,
331    /// Order configuration containing type-specific parameters
332    pub order_configuration: CoinbaseOrderConfiguration,
333}
334
335/// Response from order operations
336#[derive(Debug, Clone, Deserialize)]
337pub struct CoinbaseOrderResponse {
338    /// Whether the order operation was successful
339    pub success: bool,
340    /// Reason for failure if the operation was not successful
341    pub failure_reason: SmartString,
342    /// Unique identifier for the order
343    pub order_id: SmartString,
344    /// Success response details if the operation succeeded
345    pub success_response: Option<CoinbaseOrderSuccessResponse>,
346    /// Error response details if the operation failed
347    pub error_response: Option<CoinbaseOrderErrorResponse>,
348}
349
350/// Successful order response
351#[derive(Debug, Clone, Deserialize)]
352pub struct CoinbaseOrderSuccessResponse {
353    /// Unique identifier for the order
354    pub order_id: SmartString,
355    /// Product identifier for the trading pair
356    pub product_id: SmartString,
357    /// Order side: "BUY" or "SELL"
358    pub side: SmartString,
359    /// Client-provided order identifier
360    pub client_order_id: SmartString,
361}
362
363/// Error response from order operations
364#[derive(Debug, Clone, Deserialize)]
365pub struct CoinbaseOrderErrorResponse {
366    /// Error code or type
367    pub error: SmartString,
368    /// Human-readable error message
369    pub message: SmartString,
370    /// Detailed error information
371    pub error_details: SmartString,
372    /// Reason for preview failure (if applicable)
373    pub preview_failure_reason: SmartString,
374    /// Reason for new order failure (if applicable)
375    pub new_order_failure_reason: SmartString,
376}
377
378/// Order book data
379#[derive(Debug, Clone, Deserialize)]
380pub struct CoinbaseOrderBook {
381    /// Price book containing bids and asks
382    pub pricebook: CoinbasePriceBook,
383}
384
385/// Price book with bids and asks
386#[derive(Debug, Clone, Deserialize)]
387pub struct CoinbasePriceBook {
388    /// Product identifier for the trading pair
389    pub product_id: SmartString,
390    /// List of bid price levels (buyers)
391    pub bids: Vec<CoinbasePriceLevel>,
392    /// List of ask price levels (sellers)
393    pub asks: Vec<CoinbasePriceLevel>,
394    /// ISO 8601 timestamp of the price book snapshot
395    pub time: SmartString,
396}
397
398/// Price level in order book
399#[derive(Debug, Clone, Deserialize)]
400pub struct CoinbasePriceLevel {
401    /// Price level
402    pub price: SmartString,
403    /// Total size available at this price level
404    pub size: SmartString,
405}
406
407/// Candles data for market analysis
408#[derive(Debug, Clone, Deserialize)]
409pub struct CoinbaseCandle {
410    /// Start time of the candle period (ISO 8601 timestamp)
411    pub start: SmartString,
412    /// Lowest price during the candle period
413    pub low: SmartString,
414    /// Highest price during the candle period
415    pub high: SmartString,
416    /// Opening price of the candle period
417    pub open: SmartString,
418    /// Closing price of the candle period
419    pub close: SmartString,
420    /// Total volume traded during the candle period
421    pub volume: SmartString,
422}
423
424/// Trade data
425#[derive(Debug, Clone, Deserialize)]
426pub struct CoinbaseTrade {
427    /// Unique identifier for the trade
428    pub trade_id: SmartString,
429    /// Product identifier for the trading pair
430    pub product_id: SmartString,
431    /// Price at which the trade was executed
432    pub price: SmartString,
433    /// Size of the trade
434    pub size: SmartString,
435    /// ISO 8601 timestamp when the trade occurred
436    pub time: SmartString,
437    /// Trade side: "BUY" or "SELL"
438    pub side: SmartString,
439    /// Best bid price at the time of the trade
440    pub bid: SmartString,
441    /// Best ask price at the time of the trade
442    pub ask: SmartString,
443}
444
445/// Fill information
446#[derive(Debug, Clone, Deserialize)]
447pub struct CoinbaseFill {
448    /// Unique identifier for the fill entry
449    pub entry_id: SmartString,
450    /// Unique identifier for the trade
451    pub trade_id: SmartString,
452    /// Unique identifier for the order
453    pub order_id: SmartString,
454    /// ISO 8601 timestamp when the trade occurred
455    pub trade_time: SmartString,
456    /// Type of trade (e.g., "FILL")
457    pub trade_type: SmartString,
458    /// Price at which the fill was executed
459    pub price: SmartString,
460    /// Size of the fill
461    pub size: SmartString,
462    /// Commission charged for the fill
463    pub commission: SmartString,
464    /// Product identifier for the trading pair
465    pub product_id: SmartString,
466    /// Sequence timestamp for ordering fills
467    pub sequence_timestamp: SmartString,
468    /// Liquidity indicator: "MAKER" or "TAKER"
469    pub liquidity_indicator: SmartString,
470    /// Whether the size is specified in quote currency
471    pub size_in_quote: bool,
472    /// User identifier who owns the order
473    pub user_id: SmartString,
474    /// Order side: "BUY" or "SELL"
475    pub side: SmartString,
476}
477
478/// Portfolio information
479#[derive(Debug, Clone, Deserialize)]
480pub struct CoinbasePortfolio {
481    /// Human-readable name of the portfolio
482    pub name: SmartString,
483    /// Unique identifier for the portfolio
484    pub uuid: SmartString,
485    /// Type of portfolio (e.g., "CONSUMER", "INTX")
486    #[serde(rename = "type")]
487    pub portfolio_type: SmartString,
488    /// Whether the portfolio has been deleted
489    pub deleted: bool,
490}
491
492/// Fee structure
493#[derive(Debug, Clone, Deserialize)]
494pub struct CoinbaseFeeStructure {
495    /// Fee rate for maker orders (adding liquidity)
496    pub maker_fee_rate: SmartString,
497    /// Fee rate for taker orders (removing liquidity)
498    pub taker_fee_rate: SmartString,
499    /// USD volume used to determine fee tier
500    pub usd_volume: SmartString,
501}
502
503/// Transaction summary
504#[derive(Debug, Clone, Deserialize)]
505pub struct CoinbaseTransaction {
506    /// Unique identifier for the transaction
507    pub id: SmartString,
508    /// Type of transaction (e.g., "send", "receive", "buy", "sell")
509    #[serde(rename = "type")]
510    pub transaction_type: SmartString,
511    /// Current status of the transaction
512    pub status: SmartString,
513    /// Amount of the transaction
514    pub amount: CoinbaseAccountBalance,
515    /// Native amount (in user's native currency)
516    pub native_amount: CoinbaseAccountBalance,
517    /// Human-readable description of the transaction
518    pub description: SmartString,
519    /// ISO 8601 timestamp when the transaction was created
520    pub created_at: SmartString,
521    /// ISO 8601 timestamp when the transaction was last updated
522    pub updated_at: SmartString,
523    /// Resource type (e.g., "transaction")
524    pub resource: SmartString,
525    /// API path to the resource
526    pub resource_path: SmartString,
527    /// Whether this was an instant exchange transaction
528    pub instant_exchange: bool,
529    /// Additional transaction details
530    pub details: JsonValue,
531}
532
533/// Coinbase REST API client
534pub struct CoinbaseRestClient {
535    /// Authentication handler for API requests
536    auth: Arc<CoinbaseAuth>,
537    /// HTTP client for making requests
538    http_client: reqwest::Client,
539    /// Base URL for the Coinbase API
540    base_url: SmartString,
541    /// Advanced API URL for the Coinbase Advanced Trade API
542    advanced_api_url: SmartString,
543    /// Whether the client is using sandbox mode
544    sandbox: bool,
545}
546
547impl CoinbaseRestClient {
548    /// Create a new Coinbase REST client
549    pub fn new(auth: Arc<CoinbaseAuth>, sandbox: bool) -> Result<Self, Box<dyn std::error::Error>> {
550        let base_url = if sandbox {
551            COINBASE_SANDBOX_URL.into()
552        } else {
553            COINBASE_API_URL.into()
554        };
555
556        let advanced_api_url = if sandbox {
557            COINBASE_ADVANCED_SANDBOX_URL.into()
558        } else {
559            COINBASE_ADVANCED_API_URL.into()
560        };
561
562        let http_client = rusty_common::http::create_http_client_with_timeout(DEFAULT_TIMEOUT)?;
563
564        Ok(Self {
565            auth,
566            http_client,
567            base_url,
568            advanced_api_url,
569            sandbox,
570        })
571    }
572
573    /// Make an authenticated HTTP request
574    async fn make_request(
575        &self,
576        method: Method,
577        endpoint: &str,
578        body: Option<&str>,
579        use_advanced_api: bool,
580    ) -> Result<Response> {
581        let base_url = if use_advanced_api {
582            &self.advanced_api_url
583        } else {
584            &self.base_url
585        };
586
587        let url = format!("{base_url}{endpoint}");
588
589        let headers = self
590            .auth
591            .generate_headers(method.as_str(), endpoint, body)?;
592
593        let mut request = self.http_client.request(method, &url);
594
595        // Add authentication headers
596        for (key, value) in headers {
597            request = request.header(key.as_str(), value.as_str());
598        }
599
600        // Add body if provided
601        if let Some(body_str) = body {
602            request = request
603                .header("Content-Type", "application/json")
604                .body(body_str.to_string());
605        }
606
607        let response = request.send().await?;
608        let status = response.status();
609
610        // Parse and log rate limit information for all responses
611        let rate_limit_info = extract_rate_limit_info_detailed(response.headers());
612        let summary = rate_limit_info.summary();
613        if summary != "no_rate_limit_info" {
614            trace!("[Coinbase] Rate limits: {summary}");
615
616            // Warn if approaching rate limits
617            if rate_limit_info.is_approaching_limit() {
618                warn!("[Coinbase] Rate limit approaching: {summary}");
619            }
620        }
621
622        if !status.is_success() {
623            // Handle rate limit exceeded with detailed information
624            if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
625                let error_text = response.text().await.unwrap_or_default();
626                let retry_info = if let Some(retry_ms) = rate_limit_info.get_retry_after_ms() {
627                    format!(" (retry after {retry_ms}ms)")
628                } else {
629                    String::new()
630                };
631
632                warn!("[Coinbase] Rate limit exceeded{retry_info}. Details: {summary}");
633                bail!("Rate limit exceeded: {}{}", error_text, retry_info);
634            }
635
636            let error_text = response.text().await.unwrap_or_default();
637            bail!("HTTP error {}: {}", status, error_text);
638        }
639
640        Ok(response)
641    }
642
643    /// Parse JSON response
644    async fn parse_response<T: for<'de> serde::Deserialize<'de>>(response: Response) -> Result<T> {
645        let text = response.text().await?;
646        let mut json_bytes = text.into_bytes();
647
648        simd_json::serde::from_slice(&mut json_bytes)
649            .map_err(|e| anyhow::anyhow!("JSON parse error: {}", e))
650    }
651
652    // =================
653    // Account Operations
654    // =================
655
656    /// Get all accounts
657    pub async fn get_accounts(&self) -> Result<Vec<CoinbaseAccount>> {
658        let response = self
659            .make_request(
660                Method::GET,
661                "/accounts",
662                None,
663                true, // Use Advanced API
664            )
665            .await?;
666
667        #[derive(Deserialize)]
668        struct AccountsResponse {
669            accounts: Vec<CoinbaseAccount>,
670        }
671
672        let accounts_response: AccountsResponse = Self::parse_response(response).await?;
673        Ok(accounts_response.accounts)
674    }
675
676    /// Get account by UUID
677    pub async fn get_account(&self, account_uuid: &str) -> Result<CoinbaseAccount> {
678        let endpoint = format!("/accounts/{account_uuid}");
679        let response = self
680            .make_request(Method::GET, &endpoint, None, true)
681            .await?;
682
683        #[derive(Deserialize)]
684        struct AccountResponse {
685            account: CoinbaseAccount,
686        }
687
688        let account_response: AccountResponse = Self::parse_response(response).await?;
689        Ok(account_response.account)
690    }
691
692    // =================
693    // Order Operations
694    // =================
695
696    /// Place a new order
697    pub async fn place_order(
698        &self,
699        order_request: &CoinbaseOrderRequest,
700    ) -> Result<CoinbaseOrderResponse> {
701        let body = simd_json::serde::to_string(order_request)?;
702
703        let response = self
704            .make_request(Method::POST, "/orders", Some(&body), true)
705            .await?;
706
707        Self::parse_response(response).await
708    }
709
710    /// Cancel an order
711    pub async fn cancel_order(&self, order_id: &str) -> Result<CoinbaseOrderResponse> {
712        let endpoint = "/orders/batch_cancel".to_string();
713
714        #[derive(Serialize)]
715        struct CancelRequest<'a> {
716            order_ids: [&'a str; 1],
717        }
718
719        let request_body = CancelRequest {
720            order_ids: [order_id],
721        };
722        let body = simd_json::serde::to_string(&request_body)?;
723
724        let response = self
725            .make_request(Method::POST, &endpoint, Some(&body), true)
726            .await?;
727
728        #[derive(Deserialize)]
729        struct CancelResponse {
730            results: Vec<CoinbaseOrderResponse>,
731        }
732
733        let cancel_response: CancelResponse = Self::parse_response(response).await?;
734
735        cancel_response
736            .results
737            .into_iter()
738            .next()
739            .ok_or_else(|| anyhow::anyhow!("No cancel result returned"))
740    }
741
742    /// Get order by ID
743    pub async fn get_order(&self, order_id: &str) -> Result<CoinbaseOrder> {
744        let endpoint = format!("/orders/historical/{order_id}");
745        let response = self
746            .make_request(Method::GET, &endpoint, None, true)
747            .await?;
748
749        #[derive(Deserialize)]
750        struct OrderResponse {
751            order: CoinbaseOrder,
752        }
753
754        let order_response: OrderResponse = Self::parse_response(response).await?;
755        Ok(order_response.order)
756    }
757
758    /// List orders with optional filters
759    pub async fn list_orders(
760        &self,
761        product_id: Option<&str>,
762        order_status: Option<&str>,
763        limit: Option<u32>,
764        start_date: Option<&str>,
765        end_date: Option<&str>,
766    ) -> Result<Vec<CoinbaseOrder>> {
767        let mut endpoint = "/orders/historical/batch".to_string();
768        let mut params = Vec::new();
769
770        if let Some(pid) = product_id {
771            params.push(format!("product_id={pid}"));
772        }
773        if let Some(status) = order_status {
774            params.push(format!("order_status={status}"));
775        }
776        if let Some(lim) = limit {
777            params.push(format!("limit={lim}"));
778        }
779        if let Some(start) = start_date {
780            params.push(format!("start_date={start}"));
781        }
782        if let Some(end) = end_date {
783            params.push(format!("end_date={end}"));
784        }
785
786        if !params.is_empty() {
787            endpoint.push('?');
788            endpoint.push_str(&params.join("&"));
789        }
790
791        let response = self
792            .make_request(Method::GET, &endpoint, None, true)
793            .await?;
794
795        #[derive(Deserialize)]
796        struct OrdersResponse {
797            orders: Vec<CoinbaseOrder>,
798        }
799
800        let orders_response: OrdersResponse = Self::parse_response(response).await?;
801        Ok(orders_response.orders)
802    }
803
804    /// Get fills for an order
805    pub async fn get_fills(
806        &self,
807        order_id: Option<&str>,
808        product_id: Option<&str>,
809    ) -> Result<Vec<CoinbaseFill>> {
810        let mut endpoint = "/orders/historical/fills".to_string();
811        let mut params = Vec::new();
812
813        if let Some(oid) = order_id {
814            params.push(format!("order_id={oid}"));
815        }
816        if let Some(pid) = product_id {
817            params.push(format!("product_id={pid}"));
818        }
819
820        if !params.is_empty() {
821            endpoint.push('?');
822            endpoint.push_str(&params.join("&"));
823        }
824
825        let response = self
826            .make_request(Method::GET, &endpoint, None, true)
827            .await?;
828
829        #[derive(Deserialize)]
830        struct FillsResponse {
831            fills: Vec<CoinbaseFill>,
832        }
833
834        let fills_response: FillsResponse = Self::parse_response(response).await?;
835        Ok(fills_response.fills)
836    }
837
838    // =================
839    // Market Data Operations
840    // =================
841
842    /// Get all products
843    pub async fn get_products(&self) -> Result<Vec<CoinbaseProduct>> {
844        let response = self
845            .make_request(Method::GET, "/products", None, true)
846            .await?;
847
848        #[derive(Deserialize)]
849        struct ProductsResponse {
850            products: Vec<CoinbaseProduct>,
851        }
852
853        let products_response: ProductsResponse = Self::parse_response(response).await?;
854        Ok(products_response.products)
855    }
856
857    /// Get product by ID
858    pub async fn get_product(&self, product_id: &str) -> Result<CoinbaseProduct> {
859        let endpoint = format!("/products/{product_id}");
860        let response = self
861            .make_request(Method::GET, &endpoint, None, true)
862            .await?;
863
864        Self::parse_response(response).await
865    }
866
867    /// Get order book for a product
868    pub async fn get_order_book(
869        &self,
870        product_id: &str,
871        limit: Option<u32>,
872    ) -> Result<CoinbaseOrderBook> {
873        let mut endpoint = format!("/products/{product_id}/book");
874
875        if let Some(lim) = limit {
876            endpoint.push_str(&format!("?limit={lim}"));
877        }
878
879        let response = self
880            .make_request(Method::GET, &endpoint, None, true)
881            .await?;
882
883        Self::parse_response(response).await
884    }
885
886    /// Get candles for a product
887    pub async fn get_candles(
888        &self,
889        product_id: &str,
890        start: &str,
891        end: &str,
892        granularity: &str,
893    ) -> Result<Vec<CoinbaseCandle>> {
894        let endpoint = format!(
895            "/products/{product_id}/candles?start={start}&end={end}&granularity={granularity}"
896        );
897
898        let response = self
899            .make_request(Method::GET, &endpoint, None, true)
900            .await?;
901
902        #[derive(Deserialize)]
903        struct CandlesResponse {
904            candles: Vec<CoinbaseCandle>,
905        }
906
907        let candles_response: CandlesResponse = Self::parse_response(response).await?;
908        Ok(candles_response.candles)
909    }
910
911    /// Get recent trades for a product
912    pub async fn get_trades(
913        &self,
914        product_id: &str,
915        limit: Option<u32>,
916    ) -> Result<Vec<CoinbaseTrade>> {
917        let mut endpoint = format!("/products/{product_id}/trades");
918
919        if let Some(lim) = limit {
920            endpoint.push_str(&format!("?limit={lim}"));
921        }
922
923        let response = self
924            .make_request(Method::GET, &endpoint, None, true)
925            .await?;
926
927        #[derive(Deserialize)]
928        struct TradesResponse {
929            trades: Vec<CoinbaseTrade>,
930        }
931
932        let trades_response: TradesResponse = Self::parse_response(response).await?;
933        Ok(trades_response.trades)
934    }
935
936    // =================
937    // Portfolio Operations
938    // =================
939
940    /// Get portfolios
941    pub async fn get_portfolios(&self) -> Result<Vec<CoinbasePortfolio>> {
942        let response = self
943            .make_request(Method::GET, "/portfolios", None, true)
944            .await?;
945
946        #[derive(Deserialize)]
947        struct PortfoliosResponse {
948            portfolios: Vec<CoinbasePortfolio>,
949        }
950
951        let portfolios_response: PortfoliosResponse = Self::parse_response(response).await?;
952        Ok(portfolios_response.portfolios)
953    }
954
955    /// Create a new portfolio
956    pub async fn create_portfolio(&self, name: &str) -> Result<CoinbasePortfolio> {
957        #[derive(Serialize)]
958        struct CreatePortfolioRequest<'a> {
959            name: &'a str,
960        }
961
962        let request_body = CreatePortfolioRequest { name };
963        let body = simd_json::serde::to_string(&request_body)?;
964
965        let response = self
966            .make_request(Method::POST, "/portfolios", Some(&body), true)
967            .await?;
968
969        #[derive(Deserialize)]
970        struct PortfolioResponse {
971            portfolio: CoinbasePortfolio,
972        }
973
974        let portfolio_response: PortfolioResponse = Self::parse_response(response).await?;
975        Ok(portfolio_response.portfolio)
976    }
977
978    // =================
979    // Fee Operations
980    // =================
981
982    /// Get transaction summary including fees
983    pub async fn get_transaction_summary(
984        &self,
985        product_id: Option<&str>,
986        product_type: Option<&str>,
987    ) -> Result<CoinbaseFeeStructure> {
988        let mut endpoint = "/transaction_summary".to_string();
989        let mut params = Vec::new();
990
991        if let Some(pid) = product_id {
992            params.push(format!("product_id={pid}"));
993        }
994        if let Some(ptype) = product_type {
995            params.push(format!("product_type={ptype}"));
996        }
997
998        if !params.is_empty() {
999            endpoint.push('?');
1000            endpoint.push_str(&params.join("&"));
1001        }
1002
1003        let response = self
1004            .make_request(Method::GET, &endpoint, None, true)
1005            .await?;
1006
1007        #[derive(Deserialize)]
1008        struct FeeSummaryResponse {
1009            fee_tier: CoinbaseFeeStructure,
1010        }
1011
1012        let fee_response: FeeSummaryResponse = Self::parse_response(response).await?;
1013        Ok(fee_response.fee_tier)
1014    }
1015
1016    // =================
1017    // Helper Methods
1018    // =================
1019
1020    /// Convert `OrderSide` to Coinbase side string
1021    #[must_use]
1022    pub fn order_side_to_string(side: OrderSide) -> SmartString {
1023        match side {
1024            OrderSide::Buy => "BUY".into(),
1025            OrderSide::Sell => "SELL".into(),
1026        }
1027    }
1028
1029    /// Convert Coinbase side string to `OrderSide`
1030    pub fn string_to_order_side(side: &str) -> Result<OrderSide> {
1031        match side.to_uppercase().as_str() {
1032            "BUY" => Ok(OrderSide::Buy),
1033            "SELL" => Ok(OrderSide::Sell),
1034            _ => bail!("Invalid order side: {}", side),
1035        }
1036    }
1037
1038    /// Convert Coinbase order status to `OrderStatus`
1039    #[must_use]
1040    pub fn string_to_order_status(status: &str) -> OrderStatus {
1041        match status.to_uppercase().as_str() {
1042            "PENDING" | "OPEN" | "QUEUED" => OrderStatus::New,
1043            "PARTIALLY_FILLED" => OrderStatus::PartiallyFilled,
1044            "FILLED" => OrderStatus::Filled,
1045            "CANCELLED" | "CANCELED" => OrderStatus::Cancelled,
1046            "REJECTED" | "FAILED" | "EXPIRED" => OrderStatus::Rejected,
1047            _ => {
1048                log::warn!("Unknown Coinbase order status: {status}");
1049                OrderStatus::Unknown
1050            }
1051        }
1052    }
1053
1054    /// Create a market order request
1055    #[must_use]
1056    pub fn create_market_order_request(
1057        client_order_id: SmartString,
1058        product_id: SmartString,
1059        side: OrderSide,
1060        quote_size: Option<SmartString>,
1061        base_size: Option<SmartString>,
1062    ) -> CoinbaseOrderRequest {
1063        CoinbaseOrderRequest {
1064            client_order_id,
1065            product_id,
1066            side: Self::order_side_to_string(side),
1067            order_configuration: CoinbaseOrderConfiguration::MarketIoc {
1068                market_market_ioc: CoinbaseMarketOrder {
1069                    quote_size,
1070                    base_size,
1071                },
1072            },
1073        }
1074    }
1075
1076    /// Create a limit order request
1077    #[must_use]
1078    pub fn create_limit_order_request(
1079        client_order_id: SmartString,
1080        product_id: SmartString,
1081        side: OrderSide,
1082        base_size: SmartString,
1083        limit_price: SmartString,
1084        post_only: Option<bool>,
1085    ) -> CoinbaseOrderRequest {
1086        CoinbaseOrderRequest {
1087            client_order_id,
1088            product_id,
1089            side: Self::order_side_to_string(side),
1090            order_configuration: CoinbaseOrderConfiguration::LimitGtc {
1091                limit_limit_gtc: CoinbaseLimitOrder {
1092                    base_size,
1093                    limit_price,
1094                    post_only,
1095                },
1096            },
1097        }
1098    }
1099
1100    /// Create a stop limit order request
1101    #[must_use]
1102    pub fn create_stop_limit_order_request(
1103        client_order_id: SmartString,
1104        product_id: SmartString,
1105        side: OrderSide,
1106        base_size: SmartString,
1107        limit_price: SmartString,
1108        stop_price: SmartString,
1109        stop_direction: SmartString,
1110    ) -> CoinbaseOrderRequest {
1111        CoinbaseOrderRequest {
1112            client_order_id,
1113            product_id,
1114            side: Self::order_side_to_string(side),
1115            order_configuration: CoinbaseOrderConfiguration::StopLimitGtc {
1116                stop_limit_stop_limit_gtc: CoinbaseStopLimitOrder {
1117                    base_size,
1118                    limit_price,
1119                    stop_price,
1120                    stop_direction,
1121                },
1122            },
1123        }
1124    }
1125
1126    /// Check if the client is using sandbox mode
1127    #[must_use]
1128    pub const fn is_sandbox(&self) -> bool {
1129        self.sandbox
1130    }
1131
1132    /// Get the base URL being used
1133    #[must_use]
1134    pub fn get_base_url(&self) -> &str {
1135        &self.base_url
1136    }
1137
1138    /// Get the advanced API URL being used
1139    #[must_use]
1140    pub fn get_advanced_api_url(&self) -> &str {
1141        &self.advanced_api_url
1142    }
1143}
1144
1145#[cfg(test)]
1146mod tests {
1147    use super::*;
1148    #[test]
1149    fn test_order_side_conversion() {
1150        assert_eq!(
1151            CoinbaseRestClient::order_side_to_string(OrderSide::Buy),
1152            "BUY"
1153        );
1154        assert_eq!(
1155            CoinbaseRestClient::order_side_to_string(OrderSide::Sell),
1156            "SELL"
1157        );
1158
1159        assert_eq!(
1160            CoinbaseRestClient::string_to_order_side("BUY").unwrap(),
1161            OrderSide::Buy
1162        );
1163        assert_eq!(
1164            CoinbaseRestClient::string_to_order_side("SELL").unwrap(),
1165            OrderSide::Sell
1166        );
1167        assert_eq!(
1168            CoinbaseRestClient::string_to_order_side("buy").unwrap(),
1169            OrderSide::Buy
1170        );
1171        assert_eq!(
1172            CoinbaseRestClient::string_to_order_side("sell").unwrap(),
1173            OrderSide::Sell
1174        );
1175    }
1176
1177    #[test]
1178    fn test_order_status_conversion() {
1179        assert_eq!(
1180            CoinbaseRestClient::string_to_order_status("PENDING"),
1181            OrderStatus::New
1182        );
1183        assert_eq!(
1184            CoinbaseRestClient::string_to_order_status("OPEN"),
1185            OrderStatus::New
1186        );
1187        assert_eq!(
1188            CoinbaseRestClient::string_to_order_status("PARTIALLY_FILLED"),
1189            OrderStatus::PartiallyFilled
1190        );
1191        assert_eq!(
1192            CoinbaseRestClient::string_to_order_status("FILLED"),
1193            OrderStatus::Filled
1194        );
1195        assert_eq!(
1196            CoinbaseRestClient::string_to_order_status("CANCELLED"),
1197            OrderStatus::Cancelled
1198        );
1199        assert_eq!(
1200            CoinbaseRestClient::string_to_order_status("REJECTED"),
1201            OrderStatus::Rejected
1202        );
1203    }
1204
1205    #[test]
1206    fn test_market_order_creation() {
1207        let order = CoinbaseRestClient::create_market_order_request(
1208            "test_123".into(),
1209            "BTC-USD".into(),
1210            OrderSide::Buy,
1211            Some("1000.00".into()),
1212            None,
1213        );
1214
1215        assert_eq!(order.client_order_id, "test_123");
1216        assert_eq!(order.product_id, "BTC-USD");
1217        assert_eq!(order.side, "BUY");
1218
1219        match order.order_configuration {
1220            CoinbaseOrderConfiguration::MarketIoc { market_market_ioc } => {
1221                assert_eq!(market_market_ioc.quote_size, Some("1000.00".into()));
1222                assert_eq!(market_market_ioc.base_size, None);
1223            }
1224            _ => panic!("Expected market order configuration"),
1225        }
1226    }
1227
1228    #[test]
1229    fn test_limit_order_creation() {
1230        let order = CoinbaseRestClient::create_limit_order_request(
1231            "test_456".into(),
1232            "ETH-USD".into(),
1233            OrderSide::Sell,
1234            "0.5".into(),
1235            "3000.00".into(),
1236            Some(true),
1237        );
1238
1239        assert_eq!(order.client_order_id, "test_456");
1240        assert_eq!(order.product_id, "ETH-USD");
1241        assert_eq!(order.side, "SELL");
1242
1243        match order.order_configuration {
1244            CoinbaseOrderConfiguration::LimitGtc { limit_limit_gtc } => {
1245                assert_eq!(limit_limit_gtc.base_size, "0.5");
1246                assert_eq!(limit_limit_gtc.limit_price, "3000.00");
1247                assert_eq!(limit_limit_gtc.post_only, Some(true));
1248            }
1249            _ => panic!("Expected limit order configuration"),
1250        }
1251    }
1252}