Skip to main content

nautilus_coinbase/http/
client.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//! Provides the HTTP client for the Coinbase Advanced Trade REST API.
17//!
18//! Two-layer architecture:
19//! - [`CoinbaseRawHttpClient`]: low-level endpoint methods, JWT auth, rate limiting.
20//! - [`CoinbaseHttpClient`]: domain wrapper with instrument caching and Nautilus type conversions.
21
22use std::{
23    collections::HashMap,
24    num::NonZeroU32,
25    sync::{Arc, LazyLock},
26};
27
28use anyhow::Context;
29use arc_swap::ArcSwap;
30use chrono::{DateTime, Utc};
31use nautilus_core::{
32    AtomicMap, UnixNanos,
33    consts::NAUTILUS_USER_AGENT,
34    time::{AtomicTime, get_atomic_clock_realtime},
35};
36use nautilus_model::{
37    enums::{OrderSide, OrderType, TimeInForce},
38    events::AccountState,
39    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
40    instruments::{Instrument, InstrumentAny},
41    reports::{FillReport, OrderStatusReport, PositionStatusReport},
42    types::{MarginBalance, Price, Quantity},
43};
44use nautilus_network::{
45    http::{HttpClient, HttpClientError, HttpResponse, Method, USER_AGENT},
46    ratelimiter::quota::Quota,
47    retry::{RetryConfig, RetryManager},
48};
49use rust_decimal::Decimal;
50use serde_json::Value;
51use tokio_util::sync::CancellationToken;
52use url::form_urlencoded;
53use ustr::Ustr;
54
55use crate::{
56    common::{
57        consts::{
58            ACCOUNTS_PAGE_LIMIT, ORDER_STATUS_OPEN, QUERY_KEY_CURSOR, QUERY_KEY_END_DATE,
59            QUERY_KEY_END_SEQUENCE_TIMESTAMP, QUERY_KEY_LIMIT, QUERY_KEY_ORDER_IDS,
60            QUERY_KEY_ORDER_STATUS, QUERY_KEY_PRODUCT_IDS, QUERY_KEY_START_DATE,
61            QUERY_KEY_START_SEQUENCE_TIMESTAMP, REST_API_PATH,
62        },
63        credential::CoinbaseCredential,
64        enums::{
65            CoinbaseEnvironment, CoinbaseMarginType, CoinbaseOrderSide, CoinbaseProductType,
66            CoinbaseStopDirection,
67        },
68        parse::format_rfc3339_from_nanos,
69        urls,
70    },
71    http::{
72        error::{Error, Result},
73        models::{
74            Account, AccountsResponse, CancelOrdersResponse, CfmBalanceSummary,
75            CfmBalanceSummaryResponse, CfmPositionResponse, CfmPositionsResponse,
76            CreateOrderResponse, EditOrderResponse, Fill, FillsResponse, Order, OrderResponse,
77            OrdersListResponse, ProductsResponse,
78        },
79        parse::{
80            parse_account_state, parse_cfm_account_state, parse_cfm_margin_balances,
81            parse_cfm_position_status_report, parse_fill_report, parse_instrument,
82            parse_order_status_report,
83        },
84        query::{
85            CancelOrdersRequest, CreateOrderRequest, EditOrderRequest, FillListQuery, LimitFok,
86            LimitFokParams, LimitGtc, LimitGtcParams, LimitGtd, LimitGtdParams, MarketFok,
87            MarketIoc, MarketParams, OrderConfiguration, OrderListQuery, StopLimitGtc,
88            StopLimitGtcParams, StopLimitGtd, StopLimitGtdParams,
89        },
90    },
91};
92
93/// Default Coinbase Advanced Trade REST rate limit (30 requests per second).
94pub static COINBASE_REST_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
95    Quota::per_second(NonZeroU32::new(30).expect("non-zero")).expect("valid constant")
96});
97
98/// Returns the default retry configuration for the Coinbase HTTP client.
99#[must_use]
100pub fn default_retry_config() -> RetryConfig {
101    RetryConfig {
102        max_retries: 3,
103        initial_delay_ms: 100,
104        max_delay_ms: 5_000,
105        backoff_factor: 2.0,
106        jitter_ms: 250,
107        operation_timeout_ms: Some(60_000),
108        immediate_first: false,
109        max_elapsed_ms: Some(180_000),
110    }
111}
112
113/// Returns the retry configuration for the Coinbase data client.
114///
115/// Historical requests spawn detached tasks outside the client's
116/// cancellation token; `max_retries = 0` keeps them bounded by a single
117/// HTTP timeout so a shut-down client cannot keep emitting `DataResponse`s.
118#[must_use]
119pub fn data_client_retry_config() -> RetryConfig {
120    RetryConfig {
121        max_retries: 0,
122        initial_delay_ms: 100,
123        max_delay_ms: 100,
124        backoff_factor: 1.0,
125        jitter_ms: 0,
126        operation_timeout_ms: None,
127        immediate_first: false,
128        max_elapsed_ms: None,
129    }
130}
131
132// Builds a query string from `(key, value)` pairs, percent-encoding both
133// halves. Coinbase cursors and RFC 3339 timestamps (`+00:00`) contain
134// reserved characters that must be encoded to avoid the server reading
135// them as a different query.
136fn encode_query(params: &[(&str, &str)]) -> String {
137    let mut serializer = form_urlencoded::Serializer::new(String::new());
138    for (k, v) in params {
139        serializer.append_pair(k, v);
140    }
141    serializer.finish()
142}
143
144/// Provides a raw HTTP client for low-level Coinbase Advanced Trade REST API operations.
145///
146/// Handles JWT authentication, request construction, and response parsing.
147/// Each request generates a fresh ES256 JWT for authentication.
148#[derive(Debug)]
149pub struct CoinbaseRawHttpClient {
150    client: HttpClient,
151    credential: Option<CoinbaseCredential>,
152    base_url: ArcSwap<String>,
153    environment: CoinbaseEnvironment,
154    retry_manager: RetryManager<Error>,
155    cancellation_token: CancellationToken,
156}
157
158impl CoinbaseRawHttpClient {
159    /// Creates a new [`CoinbaseRawHttpClient`] for public endpoints only.
160    ///
161    /// # Errors
162    ///
163    /// Returns an error if the HTTP client cannot be created.
164    pub fn new(
165        environment: CoinbaseEnvironment,
166        timeout_secs: u64,
167        proxy_url: Option<String>,
168        retry_config: Option<RetryConfig>,
169    ) -> std::result::Result<Self, HttpClientError> {
170        Ok(Self {
171            client: HttpClient::new(
172                Self::default_headers(),
173                vec![],
174                vec![],
175                Some(*COINBASE_REST_QUOTA),
176                Some(timeout_secs),
177                proxy_url,
178            )?,
179            credential: None,
180            base_url: ArcSwap::from_pointee(urls::rest_url(environment).to_string()),
181            environment,
182            retry_manager: RetryManager::new(retry_config.unwrap_or_else(default_retry_config)),
183            cancellation_token: CancellationToken::new(),
184        })
185    }
186
187    /// Creates a new [`CoinbaseRawHttpClient`] with credentials for authenticated requests.
188    ///
189    /// # Errors
190    ///
191    /// Returns an error if the HTTP client cannot be created.
192    pub fn with_credentials(
193        credential: CoinbaseCredential,
194        environment: CoinbaseEnvironment,
195        timeout_secs: u64,
196        proxy_url: Option<String>,
197        retry_config: Option<RetryConfig>,
198    ) -> std::result::Result<Self, HttpClientError> {
199        Ok(Self {
200            client: HttpClient::new(
201                Self::default_headers(),
202                vec![],
203                vec![],
204                Some(*COINBASE_REST_QUOTA),
205                Some(timeout_secs),
206                proxy_url,
207            )?,
208            credential: Some(credential),
209            base_url: ArcSwap::from_pointee(urls::rest_url(environment).to_string()),
210            environment,
211            retry_manager: RetryManager::new(retry_config.unwrap_or_else(default_retry_config)),
212            cancellation_token: CancellationToken::new(),
213        })
214    }
215
216    /// Creates an authenticated client from environment variables.
217    ///
218    /// # Errors
219    ///
220    /// Returns [`Error::Auth`] if required environment variables are not set.
221    pub fn from_env(environment: CoinbaseEnvironment) -> Result<Self> {
222        let credential = CoinbaseCredential::from_env()
223            .map_err(|e| Error::auth(format!("Missing credentials in environment: {e}")))?;
224        Self::with_credentials(credential, environment, 10, None, None)
225            .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
226    }
227
228    /// Creates a new [`CoinbaseRawHttpClient`] with explicit credentials.
229    ///
230    /// # Errors
231    ///
232    /// Returns [`Error::Auth`] if credentials are invalid.
233    pub fn from_credentials(
234        api_key: &str,
235        api_secret: &str,
236        environment: CoinbaseEnvironment,
237        timeout_secs: u64,
238        proxy_url: Option<String>,
239        retry_config: Option<RetryConfig>,
240    ) -> Result<Self> {
241        let credential = CoinbaseCredential::new(api_key.to_string(), api_secret.to_string());
242        Self::with_credentials(
243            credential,
244            environment,
245            timeout_secs,
246            proxy_url,
247            retry_config,
248        )
249        .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
250    }
251
252    /// Returns the cancellation token shared by in-flight requests.
253    #[must_use]
254    pub fn cancellation_token(&self) -> &CancellationToken {
255        &self.cancellation_token
256    }
257
258    /// Overrides the base REST URL (for testing with mock servers).
259    ///
260    /// Lock-free; safe to call after the client has been cloned.
261    pub fn set_base_url(&self, url: String) {
262        self.base_url.store(Arc::new(url));
263    }
264
265    /// Returns the configured environment.
266    #[must_use]
267    pub fn environment(&self) -> CoinbaseEnvironment {
268        self.environment
269    }
270
271    /// Returns true if this client has credentials for authenticated requests.
272    #[must_use]
273    pub fn is_authenticated(&self) -> bool {
274        self.credential.is_some()
275    }
276
277    fn default_headers() -> HashMap<String, String> {
278        HashMap::from([
279            (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
280            ("Content-Type".to_string(), "application/json".to_string()),
281        ])
282    }
283
284    fn build_url(&self, path: &str) -> String {
285        format!("{}{REST_API_PATH}{path}", self.base_url.load())
286    }
287
288    // JWT uri claim must match the actual request host
289    fn build_jwt_uri(&self, method: &str, path: &str) -> String {
290        let base = self.base_url.load();
291        let host = base
292            .strip_prefix("https://")
293            .or_else(|| base.strip_prefix("http://"))
294            .unwrap_or(base.as_str());
295        format!("{method} {host}{REST_API_PATH}{path}")
296    }
297
298    fn auth_headers(&self, method: &str, path: &str) -> Result<HashMap<String, String>> {
299        let credential = self
300            .credential
301            .as_ref()
302            .ok_or_else(|| Error::auth("No credentials configured"))?;
303
304        let uri = self.build_jwt_uri(method, path);
305        let jwt = credential.build_rest_jwt(&uri)?;
306
307        Ok(HashMap::from([(
308            "Authorization".to_string(),
309            format!("Bearer {jwt}"),
310        )]))
311    }
312
313    fn parse_response(&self, response: &HttpResponse) -> Result<Value> {
314        if !response.status.is_success() {
315            return Err(Error::from_http_status(
316                response.status.as_u16(),
317                &response.body,
318            ));
319        }
320
321        if response.body.is_empty() {
322            return Ok(Value::Null);
323        }
324
325        serde_json::from_slice(&response.body).map_err(Error::Serde)
326    }
327
328    // Retries are gated to GET/DELETE because Coinbase POST endpoints
329    // (`/orders`, `/orders/edit`, `/orders/batch_cancel`) mutate live state
330    // and a replay could submit, edit, or cancel twice. JWT headers are
331    // rebuilt on each attempt because Coinbase JWTs expire after 120s.
332    async fn send_request(
333        &self,
334        method: Method,
335        url: String,
336        sign_method: Option<&'static str>,
337        sign_path: Option<&str>,
338        body: Option<Vec<u8>>,
339    ) -> Result<Value> {
340        let sign_path_owned = sign_path.map(ToOwned::to_owned);
341        let operation_name = sign_path_owned
342            .as_deref()
343            .unwrap_or(url.as_str())
344            .to_string();
345
346        let is_idempotent = matches!(method, Method::GET | Method::DELETE);
347
348        let operation = || {
349            let method = method.clone();
350            let url = url.clone();
351            let body = body.clone();
352            let sign_path = sign_path_owned.clone();
353
354            async move {
355                let headers = match (sign_method, sign_path.as_deref()) {
356                    (Some(m), Some(p)) => Some(self.auth_headers(m, p)?),
357                    _ => None,
358                };
359
360                let response = self
361                    .client
362                    .request(method, url, None, headers, body, None, None)
363                    .await
364                    .map_err(Error::from_http_client)?;
365
366                self.parse_response(&response)
367            }
368        };
369
370        let should_retry = move |err: &Error| is_idempotent && err.is_retryable();
371
372        self.retry_manager
373            .execute_with_retry_with_cancel(
374                &operation_name,
375                operation,
376                should_retry,
377                Error::transport,
378                &self.cancellation_token,
379            )
380            .await
381    }
382
383    /// Sends a GET request to a public endpoint (no auth required).
384    pub async fn get_public(&self, path: &str) -> Result<Value> {
385        let url = self.build_url(path);
386        self.send_request(Method::GET, url, None, None, None).await
387    }
388
389    /// Sends a GET request with query parameters to a public endpoint.
390    pub async fn get_public_with_query(&self, path: &str, query: &str) -> Result<Value> {
391        let full_path = if query.is_empty() {
392            path.to_string()
393        } else {
394            format!("{path}?{query}")
395        };
396        let url = self.build_url(&full_path);
397        self.send_request(Method::GET, url, None, None, None).await
398    }
399
400    /// Sends an authenticated GET request.
401    pub async fn get(&self, path: &str) -> Result<Value> {
402        let url = self.build_url(path);
403        self.send_request(Method::GET, url, Some("GET"), Some(path), None)
404            .await
405    }
406
407    /// Sends an authenticated GET request with query parameters appended to the path.
408    ///
409    /// The JWT URI claim covers only `{METHOD} {host}{path}` without the
410    /// query string, matching the Coinbase SDK convention. Query parameters
411    /// are appended to the URL but excluded from the signing input.
412    pub async fn get_with_query(&self, path: &str, query: &str) -> Result<Value> {
413        let full_url_path = if query.is_empty() {
414            path.to_string()
415        } else {
416            format!("{path}?{query}")
417        };
418        let url = self.build_url(&full_url_path);
419        // Sign with the bare path only (no query string).
420        self.send_request(Method::GET, url, Some("GET"), Some(path), None)
421            .await
422    }
423
424    /// Sends an authenticated POST request with a JSON body.
425    pub async fn post(&self, path: &str, body: &Value) -> Result<Value> {
426        let url = self.build_url(path);
427        let body_bytes = serde_json::to_vec(body).map_err(Error::Serde)?;
428        self.send_request(
429            Method::POST,
430            url,
431            Some("POST"),
432            Some(path),
433            Some(body_bytes),
434        )
435        .await
436    }
437
438    /// Sends an authenticated DELETE request.
439    pub async fn delete(&self, path: &str) -> Result<Value> {
440        let url = self.build_url(path);
441        self.send_request(Method::DELETE, url, Some("DELETE"), Some(path), None)
442            .await
443    }
444
445    /// Gets all available products via the public `/market/products` endpoint.
446    pub async fn get_products(&self) -> Result<Value> {
447        self.get_public("/market/products").await
448    }
449
450    /// Gets a specific product by ID via the public endpoint.
451    pub async fn get_product(&self, product_id: &str) -> Result<Value> {
452        self.get_public(&format!("/market/products/{product_id}"))
453            .await
454    }
455
456    /// Gets candles for a product via the public endpoint.
457    pub async fn get_candles(
458        &self,
459        product_id: &str,
460        start: &str,
461        end: &str,
462        granularity: &str,
463    ) -> Result<Value> {
464        let query = format!("start={start}&end={end}&granularity={granularity}");
465        self.get_public_with_query(&format!("/market/products/{product_id}/candles"), &query)
466            .await
467    }
468
469    /// Gets market trades for a product via the public endpoint.
470    pub async fn get_market_trades(&self, product_id: &str, limit: u32) -> Result<Value> {
471        let query = format!("limit={limit}");
472        self.get_public_with_query(&format!("/market/products/{product_id}/ticker"), &query)
473            .await
474    }
475
476    /// Gets best bid/ask for one or more products.
477    ///
478    /// No public `/market/` equivalent exists for this endpoint; requires
479    /// authentication.
480    pub async fn get_best_bid_ask(&self, product_ids: &[&str]) -> Result<Value> {
481        let query = product_ids
482            .iter()
483            .map(|id| format!("product_ids={id}"))
484            .collect::<Vec<_>>()
485            .join("&");
486        self.get_with_query("/best_bid_ask", &query).await
487    }
488
489    /// Gets the product order book via the public endpoint.
490    pub async fn get_product_book(&self, product_id: &str, limit: Option<u32>) -> Result<Value> {
491        let mut query = format!("product_id={product_id}");
492
493        if let Some(limit) = limit {
494            query.push_str(&format!("&limit={limit}"));
495        }
496        self.get_public_with_query("/market/product_book", &query)
497            .await
498    }
499
500    /// Gets all accounts.
501    pub async fn get_accounts(&self) -> Result<Value> {
502        self.get("/accounts").await
503    }
504
505    /// Gets accounts with a query string (for pagination via `cursor` / `limit`).
506    pub async fn get_accounts_with_query(&self, query: &str) -> Result<Value> {
507        if query.is_empty() {
508            self.get("/accounts").await
509        } else {
510            self.get_with_query("/accounts", query).await
511        }
512    }
513
514    /// Gets a specific account by UUID.
515    pub async fn get_account(&self, account_id: &str) -> Result<Value> {
516        self.get(&format!("/accounts/{account_id}")).await
517    }
518
519    /// Lists all portfolios visible to the authenticated key.
520    pub async fn get_portfolios(&self) -> Result<Value> {
521        self.get("/portfolios").await
522    }
523
524    /// Gets historical orders.
525    pub async fn get_orders(&self, query: &str) -> Result<Value> {
526        self.get_with_query("/orders/historical/batch", query).await
527    }
528
529    /// Gets a specific order by ID.
530    pub async fn get_order(&self, order_id: &str) -> Result<Value> {
531        self.get(&format!("/orders/historical/{order_id}")).await
532    }
533
534    /// Gets fills (trade executions).
535    pub async fn get_fills(&self, query: &str) -> Result<Value> {
536        self.get_with_query("/orders/historical/fills", query).await
537    }
538
539    /// Gets fee transaction summary.
540    pub async fn get_transaction_summary(&self) -> Result<Value> {
541        self.get("/transaction_summary").await
542    }
543
544    /// Gets the CFM (Coinbase Financial Markets) futures balance summary.
545    ///
546    /// # References
547    ///
548    /// - <https://docs.cdp.coinbase.com/api-reference/advanced-trade-api/rest-api/perpetuals/get-fcm-balance-summary>
549    pub async fn get_cfm_balance_summary(&self) -> Result<CfmBalanceSummaryResponse> {
550        let json = self.get("/cfm/balance_summary").await?;
551        serde_json::from_value(json).map_err(Error::Serde)
552    }
553
554    /// Gets all CFM futures positions for the account.
555    ///
556    /// # References
557    ///
558    /// - <https://docs.cdp.coinbase.com/api-reference/advanced-trade-api/rest-api/perpetuals/get-fcm-positions>
559    pub async fn get_cfm_positions(&self) -> Result<CfmPositionsResponse> {
560        let json = self.get("/cfm/positions").await?;
561        serde_json::from_value(json).map_err(Error::Serde)
562    }
563
564    /// Gets a single CFM futures position by product ID.
565    ///
566    /// # References
567    ///
568    /// - <https://docs.cdp.coinbase.com/api-reference/advanced-trade-api/rest-api/perpetuals/get-fcm-position>
569    pub async fn get_cfm_position(&self, product_id: &str) -> Result<CfmPositionResponse> {
570        let json = self.get(&format!("/cfm/positions/{product_id}")).await?;
571        serde_json::from_value(json).map_err(Error::Serde)
572    }
573
574    /// Fetches every account, following Coinbase's cursor pagination.
575    ///
576    /// Returns the deserialized [`Account`] vector. Domain callers compose
577    /// this with [`parse_account_state`] to build a Nautilus [`AccountState`].
578    pub async fn fetch_all_accounts(&self) -> Result<Vec<Account>> {
579        let mut all = Vec::new();
580        let mut cursor: Option<String> = None;
581
582        loop {
583            let mut pairs: Vec<(&str, &str)> = vec![(QUERY_KEY_LIMIT, ACCOUNTS_PAGE_LIMIT)];
584            if let Some(c) = cursor.as_deref().filter(|s| !s.is_empty()) {
585                pairs.push((QUERY_KEY_CURSOR, c));
586            }
587            let query_str = encode_query(&pairs);
588
589            let json = self.get_accounts_with_query(&query_str).await?;
590            let response: AccountsResponse = serde_json::from_value(json).map_err(Error::Serde)?;
591
592            all.extend(response.accounts);
593
594            if !response.has_next || response.cursor.is_empty() {
595                break;
596            }
597            cursor = Some(response.cursor);
598        }
599
600        Ok(all)
601    }
602
603    /// Fetches every order matching the query, following cursor pagination.
604    ///
605    /// Honors `OrderListQuery::client_order_id_filter` as a client-side
606    /// filter applied to each page (the venue endpoint does not accept that
607    /// parameter directly). Stops once the configured `limit` is reached.
608    pub async fn fetch_all_orders(&self, query: &OrderListQuery) -> Result<Vec<Order>> {
609        let mut collected: Vec<Order> = Vec::new();
610        let mut cursor: Option<String> = None;
611
612        loop {
613            let start_str = query.start.map(|s| s.to_rfc3339());
614            let end_str = query.end.map(|e| e.to_rfc3339());
615            let limit_str = query.limit.map(|l| l.to_string());
616
617            let mut pairs: Vec<(&str, &str)> = Vec::new();
618
619            // Coinbase accepts `product_ids` as a repeated array parameter on
620            // `/orders/historical/batch`; the singular form is silently ignored.
621            if let Some(pid) = query.product_id.as_deref() {
622                pairs.push((QUERY_KEY_PRODUCT_IDS, pid));
623            }
624
625            if query.open_only {
626                pairs.push((QUERY_KEY_ORDER_STATUS, ORDER_STATUS_OPEN));
627            }
628
629            if let Some(s) = start_str.as_deref() {
630                pairs.push((QUERY_KEY_START_DATE, s));
631            }
632
633            if let Some(e) = end_str.as_deref() {
634                pairs.push((QUERY_KEY_END_DATE, e));
635            }
636
637            if let Some(l) = limit_str.as_deref() {
638                pairs.push((QUERY_KEY_LIMIT, l));
639            }
640
641            if let Some(c) = cursor.as_deref().filter(|s| !s.is_empty()) {
642                pairs.push((QUERY_KEY_CURSOR, c));
643            }
644
645            let query_str = encode_query(&pairs);
646            let json = self.get_orders(&query_str).await?;
647            let response: OrdersListResponse =
648                serde_json::from_value(json).map_err(Error::Serde)?;
649
650            for order in response.orders {
651                if let Some(cid) = query.client_order_id_filter.as_deref()
652                    && order.client_order_id != cid
653                {
654                    continue;
655                }
656                collected.push(order);
657            }
658
659            if let Some(limit) = query.limit
660                && collected.len() >= limit as usize
661            {
662                collected.truncate(limit as usize);
663                break;
664            }
665
666            if !response.has_next || response.cursor.is_empty() {
667                break;
668            }
669            cursor = Some(response.cursor);
670        }
671
672        Ok(collected)
673    }
674
675    /// Fetches every fill matching the query, following cursor pagination.
676    pub async fn fetch_all_fills(&self, query: &FillListQuery) -> Result<Vec<Fill>> {
677        let mut collected: Vec<Fill> = Vec::new();
678        let mut cursor: Option<String> = None;
679
680        loop {
681            let start_str = query.start.map(|s| s.to_rfc3339());
682            let end_str = query.end.map(|e| e.to_rfc3339());
683            let limit_str = query.limit.map(|l| l.to_string());
684
685            let mut pairs: Vec<(&str, &str)> = Vec::new();
686
687            // `/orders/historical/fills` takes repeated array filters for
688            // product and order IDs. Singular keys are accepted by the server
689            // but silently ignored, which would scan the full fill history.
690            if let Some(pid) = query.product_id.as_deref() {
691                pairs.push((QUERY_KEY_PRODUCT_IDS, pid));
692            }
693
694            if let Some(vid) = query.venue_order_id.as_deref() {
695                pairs.push((QUERY_KEY_ORDER_IDS, vid));
696            }
697
698            if let Some(s) = start_str.as_deref() {
699                pairs.push((QUERY_KEY_START_SEQUENCE_TIMESTAMP, s));
700            }
701
702            if let Some(e) = end_str.as_deref() {
703                pairs.push((QUERY_KEY_END_SEQUENCE_TIMESTAMP, e));
704            }
705
706            if let Some(l) = limit_str.as_deref() {
707                pairs.push((QUERY_KEY_LIMIT, l));
708            }
709
710            if let Some(c) = cursor.as_deref().filter(|s| !s.is_empty()) {
711                pairs.push((QUERY_KEY_CURSOR, c));
712            }
713
714            let query_str = encode_query(&pairs);
715            let json = self.get_fills(&query_str).await?;
716            let response: FillsResponse = serde_json::from_value(json).map_err(Error::Serde)?;
717
718            collected.extend(response.fills);
719
720            if let Some(limit) = query.limit
721                && collected.len() >= limit as usize
722            {
723                collected.truncate(limit as usize);
724                break;
725            }
726
727            if response.cursor.is_empty() {
728                break;
729            }
730            cursor = Some(response.cursor);
731        }
732
733        Ok(collected)
734    }
735
736    /// Creates a new order via `POST /orders`.
737    ///
738    /// # References
739    ///
740    /// - <https://docs.cdp.coinbase.com/api-reference/advanced-trade-api/rest-api/orders/create-order>
741    pub async fn create_order(&self, request: &CreateOrderRequest) -> Result<CreateOrderResponse> {
742        let body = serde_json::to_value(request).map_err(Error::Serde)?;
743        let json = self.post("/orders", &body).await?;
744        serde_json::from_value(json).map_err(Error::Serde)
745    }
746
747    /// Cancels one or more orders via `POST /orders/batch_cancel`.
748    ///
749    /// # References
750    ///
751    /// - <https://docs.cdp.coinbase.com/api-reference/advanced-trade-api/rest-api/orders/cancel-order>
752    pub async fn cancel_orders(
753        &self,
754        request: &CancelOrdersRequest,
755    ) -> Result<CancelOrdersResponse> {
756        let body = serde_json::to_value(request).map_err(Error::Serde)?;
757        let json = self.post("/orders/batch_cancel", &body).await?;
758        serde_json::from_value(json).map_err(Error::Serde)
759    }
760
761    /// Edits an existing order via `POST /orders/edit`.
762    ///
763    /// Coinbase restricts edits to GTC orders (LIMIT, STOP_LIMIT, Bracket);
764    /// other order types require cancel-and-replace.
765    ///
766    /// # References
767    ///
768    /// - <https://docs.cdp.coinbase.com/api-reference/advanced-trade-api/rest-api/orders/edit-order>
769    pub async fn edit_order(&self, request: &EditOrderRequest) -> Result<EditOrderResponse> {
770        let body = serde_json::to_value(request).map_err(Error::Serde)?;
771        let json = self.post("/orders/edit", &body).await?;
772        serde_json::from_value(json).map_err(Error::Serde)
773    }
774}
775
776/// Provides a domain-level HTTP client for the Coinbase Advanced Trade API.
777///
778/// Wraps [`CoinbaseRawHttpClient`] in an `Arc` and adds instrument caching
779/// and Nautilus type conversions. This is the primary HTTP interface for the
780/// data and execution clients.
781#[derive(Debug, Clone)]
782#[cfg_attr(
783    feature = "python",
784    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.coinbase", from_py_object)
785)]
786pub struct CoinbaseHttpClient {
787    pub(crate) inner: Arc<CoinbaseRawHttpClient>,
788    clock: &'static AtomicTime,
789    instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
790    /// Maps a product ID to its Coinbase-canonical alias (e.g. `BTC-USDC -> BTC-USD`).
791    /// Coinbase consolidates aliased pairs into a single book server-side, so the
792    /// WebSocket feed and user-channel echo the canonical id even when callers
793    /// subscribed or submitted with the alias.
794    product_aliases: Arc<AtomicMap<Ustr, Ustr>>,
795}
796
797impl Default for CoinbaseHttpClient {
798    fn default() -> Self {
799        Self::new(CoinbaseEnvironment::Live, 10, None, None)
800            .expect("Failed to create default Coinbase HTTP client")
801    }
802}
803
804impl CoinbaseHttpClient {
805    /// Creates a new [`CoinbaseHttpClient`] for public endpoints only.
806    ///
807    /// # Errors
808    ///
809    /// Returns an error if the HTTP client cannot be created.
810    pub fn new(
811        environment: CoinbaseEnvironment,
812        timeout_secs: u64,
813        proxy_url: Option<String>,
814        retry_config: Option<RetryConfig>,
815    ) -> std::result::Result<Self, HttpClientError> {
816        let raw = CoinbaseRawHttpClient::new(environment, timeout_secs, proxy_url, retry_config)?;
817        Ok(Self::from_raw(raw))
818    }
819
820    /// Creates a new [`CoinbaseHttpClient`] with credentials for authenticated requests.
821    ///
822    /// # Errors
823    ///
824    /// Returns an error if the HTTP client cannot be created.
825    pub fn with_credentials(
826        credential: CoinbaseCredential,
827        environment: CoinbaseEnvironment,
828        timeout_secs: u64,
829        proxy_url: Option<String>,
830        retry_config: Option<RetryConfig>,
831    ) -> std::result::Result<Self, HttpClientError> {
832        let raw = CoinbaseRawHttpClient::with_credentials(
833            credential,
834            environment,
835            timeout_secs,
836            proxy_url,
837            retry_config,
838        )?;
839        Ok(Self::from_raw(raw))
840    }
841
842    /// Creates an authenticated client from environment variables.
843    ///
844    /// # Errors
845    ///
846    /// Returns [`Error::Auth`] if required environment variables are not set.
847    pub fn from_env(environment: CoinbaseEnvironment) -> Result<Self> {
848        let raw = CoinbaseRawHttpClient::from_env(environment)?;
849        Ok(Self::from_raw(raw))
850    }
851
852    /// Creates a new [`CoinbaseHttpClient`] with explicit credentials.
853    ///
854    /// # Errors
855    ///
856    /// Returns [`Error::Auth`] if credentials are invalid.
857    pub fn from_credentials(
858        api_key: &str,
859        api_secret: &str,
860        environment: CoinbaseEnvironment,
861        timeout_secs: u64,
862        proxy_url: Option<String>,
863        retry_config: Option<RetryConfig>,
864    ) -> Result<Self> {
865        let raw = CoinbaseRawHttpClient::from_credentials(
866            api_key,
867            api_secret,
868            environment,
869            timeout_secs,
870            proxy_url,
871            retry_config,
872        )?;
873        Ok(Self::from_raw(raw))
874    }
875
876    /// Returns the cancellation token shared by in-flight requests.
877    #[must_use]
878    pub fn cancellation_token(&self) -> &CancellationToken {
879        self.inner.cancellation_token()
880    }
881
882    fn from_raw(raw: CoinbaseRawHttpClient) -> Self {
883        Self {
884            inner: Arc::new(raw),
885            clock: get_atomic_clock_realtime(),
886            instruments: Arc::new(AtomicMap::new()),
887            product_aliases: Arc::new(AtomicMap::new()),
888        }
889    }
890
891    /// Overrides the base REST URL (for testing with mock servers).
892    ///
893    /// Safe to call regardless of how many clones share the inner client.
894    pub fn set_base_url(&self, url: String) {
895        self.inner.set_base_url(url);
896    }
897
898    /// Returns the configured environment.
899    #[must_use]
900    pub fn environment(&self) -> CoinbaseEnvironment {
901        self.inner.environment()
902    }
903
904    /// Returns true if this client has credentials for authenticated requests.
905    #[must_use]
906    pub fn is_authenticated(&self) -> bool {
907        self.inner.is_authenticated()
908    }
909
910    /// Returns a reference to the instrument cache.
911    #[must_use]
912    pub fn instruments(&self) -> &Arc<AtomicMap<InstrumentId, InstrumentAny>> {
913        &self.instruments
914    }
915
916    /// Returns a reference to the product alias map (`product_id -> canonical product_id`).
917    #[must_use]
918    pub fn product_aliases(&self) -> &Arc<AtomicMap<Ustr, Ustr>> {
919        &self.product_aliases
920    }
921
922    /// Returns the current timestamp from the atomic clock.
923    #[must_use]
924    pub fn ts_now(&self) -> UnixNanos {
925        self.clock.get_time_ns()
926    }
927
928    /// Gets all available products.
929    pub async fn get_products(&self) -> Result<Value> {
930        self.inner.get_products().await
931    }
932
933    /// Gets a specific product by ID.
934    pub async fn get_product(&self, product_id: &str) -> Result<Value> {
935        self.inner.get_product(product_id).await
936    }
937
938    /// Gets candles for a product.
939    pub async fn get_candles(
940        &self,
941        product_id: &str,
942        start: &str,
943        end: &str,
944        granularity: &str,
945    ) -> Result<Value> {
946        self.inner
947            .get_candles(product_id, start, end, granularity)
948            .await
949    }
950
951    /// Gets market trades for a product.
952    pub async fn get_market_trades(&self, product_id: &str, limit: u32) -> Result<Value> {
953        self.inner.get_market_trades(product_id, limit).await
954    }
955
956    /// Gets best bid/ask for one or more products.
957    pub async fn get_best_bid_ask(&self, product_ids: &[&str]) -> Result<Value> {
958        self.inner.get_best_bid_ask(product_ids).await
959    }
960
961    /// Gets the product order book.
962    pub async fn get_product_book(&self, product_id: &str, limit: Option<u32>) -> Result<Value> {
963        self.inner.get_product_book(product_id, limit).await
964    }
965
966    /// Gets all accounts.
967    pub async fn get_accounts(&self) -> Result<Value> {
968        self.inner.get_accounts().await
969    }
970
971    /// Gets a specific account by UUID.
972    pub async fn get_account(&self, account_id: &str) -> Result<Value> {
973        self.inner.get_account(account_id).await
974    }
975
976    /// Lists all portfolios visible to the authenticated key.
977    pub async fn get_portfolios(&self) -> Result<Value> {
978        self.inner.get_portfolios().await
979    }
980
981    /// Validates an order payload against the venue without submitting it.
982    ///
983    /// Useful for diagnosing `account is not available` and similar errors
984    /// because it returns the same error envelope as `POST /orders`.
985    pub async fn preview_order(&self, body: &Value) -> Result<Value> {
986        self.inner.post("/orders/preview", body).await
987    }
988
989    /// Gets historical orders.
990    pub async fn get_orders(&self, query: &str) -> Result<Value> {
991        self.inner.get_orders(query).await
992    }
993
994    /// Gets a specific order by ID.
995    pub async fn get_order(&self, order_id: &str) -> Result<Value> {
996        self.inner.get_order(order_id).await
997    }
998
999    /// Gets fills (trade executions).
1000    pub async fn get_fills(&self, query: &str) -> Result<Value> {
1001        self.inner.get_fills(query).await
1002    }
1003
1004    /// Gets fee transaction summary.
1005    pub async fn get_transaction_summary(&self) -> Result<Value> {
1006        self.inner.get_transaction_summary().await
1007    }
1008
1009    /// Requests all instruments from Coinbase, optionally filtered by product type.
1010    ///
1011    /// Parses each supported product into a Nautilus [`InstrumentAny`] and caches
1012    /// the results in the shared instrument map. Unsupported products (non-crypto
1013    /// futures, `UNKNOWN` product types) are skipped with a debug log.
1014    ///
1015    /// # Errors
1016    ///
1017    /// Returns an error when the HTTP request fails or the response cannot be
1018    /// deserialized.
1019    pub async fn request_instruments(
1020        &self,
1021        product_type: Option<CoinbaseProductType>,
1022    ) -> anyhow::Result<Vec<InstrumentAny>> {
1023        let json = self
1024            .inner
1025            .get_products()
1026            .await
1027            .map_err(|e| anyhow::anyhow!("Failed to fetch products: {e}"))?;
1028        let response: ProductsResponse =
1029            serde_json::from_value(json).map_err(|e| anyhow::anyhow!(e))?;
1030
1031        let ts_init = self.ts_now();
1032        let mut instruments = Vec::with_capacity(response.products.len());
1033
1034        for product in &response.products {
1035            if let Some(filter) = product_type
1036                && product.product_type != filter
1037            {
1038                continue;
1039            }
1040
1041            match parse_instrument(product, ts_init) {
1042                Ok(instrument) => instruments.push(instrument),
1043                Err(e) => {
1044                    log::debug!(
1045                        "Skipping product '{}' during parse: {e}",
1046                        product.product_id
1047                    );
1048                }
1049            }
1050        }
1051
1052        self.cache_instruments(&instruments);
1053        self.record_product_aliases(&response.products);
1054        Ok(instruments)
1055    }
1056
1057    /// Requests a single instrument by product ID.
1058    ///
1059    /// Caches the result on success.
1060    ///
1061    /// # Errors
1062    ///
1063    /// Returns an error when the HTTP request fails, deserialization fails,
1064    /// or the product cannot be parsed into a supported instrument.
1065    pub async fn request_instrument(&self, product_id: &str) -> anyhow::Result<InstrumentAny> {
1066        let json = self
1067            .inner
1068            .get_product(product_id)
1069            .await
1070            .map_err(|e| anyhow::anyhow!("Failed to fetch product '{product_id}': {e}"))?;
1071        let product: crate::http::models::Product =
1072            serde_json::from_value(json).map_err(|e| anyhow::anyhow!(e))?;
1073        let ts_init = self.ts_now();
1074        let instrument = parse_instrument(&product, ts_init)?;
1075        self.cache_instrument(&instrument);
1076        self.record_product_aliases(std::slice::from_ref(&product));
1077        Ok(instrument)
1078    }
1079
1080    /// Requests the raw product payload for a product ID.
1081    ///
1082    /// Returns the full [`crate::http::models::Product`] so callers can read
1083    /// derivatives-specific fields (`future_product_details.index_price`,
1084    /// `funding_rate`, `funding_time`) that are stripped when parsing to a
1085    /// Nautilus instrument.
1086    ///
1087    /// # Errors
1088    ///
1089    /// Returns an error when the HTTP request fails or the response cannot
1090    /// be deserialized.
1091    pub async fn request_raw_product(
1092        &self,
1093        product_id: &str,
1094    ) -> anyhow::Result<crate::http::models::Product> {
1095        let json = self
1096            .inner
1097            .get_product(product_id)
1098            .await
1099            .map_err(|e| anyhow::anyhow!("Failed to fetch product '{product_id}': {e}"))?;
1100        serde_json::from_value(json).map_err(|e| anyhow::anyhow!(e))
1101    }
1102
1103    /// Requests the current account state.
1104    ///
1105    /// Builds a cash-type [`AccountState`] from `/accounts` with one balance
1106    /// per currency. Follows Coinbase's cursor pagination so multi-wallet
1107    /// accounts are reported in full. `reported` is set to `true` since the
1108    /// values come from the venue.
1109    ///
1110    /// # Errors
1111    ///
1112    /// Returns an error when the HTTP request fails or the response cannot
1113    /// be parsed.
1114    pub async fn request_account_state(
1115        &self,
1116        account_id: AccountId,
1117    ) -> anyhow::Result<AccountState> {
1118        let accounts = self
1119            .inner
1120            .fetch_all_accounts()
1121            .await
1122            .map_err(|e| anyhow::anyhow!("Failed to fetch accounts: {e}"))?;
1123        let ts_event = self.ts_now();
1124        parse_account_state(&accounts, account_id, true, ts_event, ts_event)
1125    }
1126
1127    /// Requests a single order status report by venue or client order ID.
1128    ///
1129    /// Resolves venue order IDs first via `/orders/historical/{id}`. When only a
1130    /// `client_order_id` is provided, paginates the order history filtered to
1131    /// that client ID.
1132    ///
1133    /// # Errors
1134    ///
1135    /// Returns an error when the HTTP request fails, the order cannot be found,
1136    /// or the response cannot be parsed.
1137    pub async fn request_order_status_report(
1138        &self,
1139        account_id: AccountId,
1140        client_order_id: Option<ClientOrderId>,
1141        venue_order_id: Option<VenueOrderId>,
1142    ) -> anyhow::Result<OrderStatusReport> {
1143        let venue_order_id = match (venue_order_id, client_order_id) {
1144            (Some(vid), _) => vid,
1145            (None, Some(cid)) => {
1146                // Fall back to batched query when only the client order ID is known
1147                let query = OrderListQuery {
1148                    client_order_id_filter: Some(cid.as_str().to_string()),
1149                    ..Default::default()
1150                };
1151                let orders = self
1152                    .inner
1153                    .fetch_all_orders(&query)
1154                    .await
1155                    .map_err(|e| anyhow::anyhow!("Failed to fetch orders: {e}"))?;
1156                let order = orders
1157                    .into_iter()
1158                    .next()
1159                    .ok_or_else(|| anyhow::anyhow!("No order found for client_order_id={cid}"))?;
1160                let instrument = self.get_or_fetch_instrument(order.product_id).await?;
1161                let ts_init = self.ts_now();
1162                return parse_order_status_report(&order, &instrument, account_id, ts_init);
1163            }
1164            (None, None) => {
1165                anyhow::bail!("Either client_order_id or venue_order_id is required")
1166            }
1167        };
1168
1169        let json = self
1170            .inner
1171            .get_order(venue_order_id.as_str())
1172            .await
1173            .map_err(|e| anyhow::anyhow!("Failed to fetch order: {e}"))?;
1174        let response: OrderResponse =
1175            serde_json::from_value(json).map_err(|e| anyhow::anyhow!(e))?;
1176        let instrument = self
1177            .get_or_fetch_instrument(response.order.product_id)
1178            .await?;
1179        let ts_init = self.ts_now();
1180        parse_order_status_report(&response.order, &instrument, account_id, ts_init)
1181    }
1182
1183    /// Requests order status reports, optionally filtered by instrument, open
1184    /// status, and time window.
1185    ///
1186    /// # Errors
1187    ///
1188    /// Returns an error when the HTTP request fails or when any response cannot
1189    /// be deserialized.
1190    pub async fn request_order_status_reports(
1191        &self,
1192        account_id: AccountId,
1193        instrument_id: Option<InstrumentId>,
1194        open_only: bool,
1195        start: Option<DateTime<Utc>>,
1196        end: Option<DateTime<Utc>>,
1197        limit: Option<u32>,
1198    ) -> anyhow::Result<Vec<OrderStatusReport>> {
1199        let query = OrderListQuery {
1200            product_id: instrument_id.map(|id| id.symbol.as_str().to_string()),
1201            open_only,
1202            start,
1203            end,
1204            limit,
1205            client_order_id_filter: None,
1206        };
1207
1208        let orders = self
1209            .inner
1210            .fetch_all_orders(&query)
1211            .await
1212            .map_err(|e| anyhow::anyhow!("Failed to fetch orders: {e}"))?;
1213
1214        let ts_init = self.ts_now();
1215        let mut reports = Vec::with_capacity(orders.len());
1216
1217        for order in &orders {
1218            let instrument = match self.get_or_fetch_instrument(order.product_id).await {
1219                Ok(inst) => inst,
1220                Err(e) => {
1221                    log::debug!("Skipping order {}: {e}", order.order_id);
1222                    continue;
1223                }
1224            };
1225
1226            match parse_order_status_report(order, &instrument, account_id, ts_init) {
1227                Ok(report) => reports.push(report),
1228                Err(e) => log::warn!("Failed to parse order {}: {e}", order.order_id),
1229            }
1230        }
1231
1232        Ok(reports)
1233    }
1234
1235    /// Requests fill reports, optionally filtered by instrument, venue order ID,
1236    /// and time window.
1237    ///
1238    /// # Errors
1239    ///
1240    /// Returns an error when the HTTP request fails or the response cannot be
1241    /// deserialized.
1242    pub async fn request_fill_reports(
1243        &self,
1244        account_id: AccountId,
1245        instrument_id: Option<InstrumentId>,
1246        venue_order_id: Option<VenueOrderId>,
1247        start: Option<DateTime<Utc>>,
1248        end: Option<DateTime<Utc>>,
1249        limit: Option<u32>,
1250    ) -> anyhow::Result<Vec<FillReport>> {
1251        let query = FillListQuery {
1252            product_id: instrument_id.map(|id| id.symbol.as_str().to_string()),
1253            venue_order_id: venue_order_id.map(|id| id.as_str().to_string()),
1254            start,
1255            end,
1256            limit,
1257        };
1258
1259        let fills = self
1260            .inner
1261            .fetch_all_fills(&query)
1262            .await
1263            .map_err(|e| anyhow::anyhow!("Failed to fetch fills: {e}"))?;
1264
1265        let ts_init = self.ts_now();
1266        let mut reports = Vec::with_capacity(fills.len());
1267
1268        for fill in &fills {
1269            let instrument = match self.get_or_fetch_instrument(fill.product_id).await {
1270                Ok(inst) => inst,
1271                Err(e) => {
1272                    log::debug!("Skipping fill {}: {e}", fill.trade_id);
1273                    continue;
1274                }
1275            };
1276
1277            match parse_fill_report(fill, &instrument, account_id, ts_init) {
1278                Ok(report) => reports.push(report),
1279                Err(e) => log::warn!("Failed to parse fill {}: {e}", fill.trade_id),
1280            }
1281        }
1282
1283        Ok(reports)
1284    }
1285
1286    /// Caches an instrument in the shared instrument map.
1287    pub fn cache_instrument(&self, instrument: &InstrumentAny) {
1288        self.instruments.rcu(|m| {
1289            m.insert(instrument.id(), instrument.clone());
1290        });
1291    }
1292
1293    /// Caches a batch of instruments in the shared instrument map.
1294    pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
1295        self.instruments.rcu(|m| {
1296            for instrument in instruments {
1297                m.insert(instrument.id(), instrument.clone());
1298            }
1299        });
1300    }
1301
1302    /// Records `product_id -> alias` entries for any product whose `alias`
1303    /// field is non-empty. Coinbase aliases pairs to a canonical id (e.g.
1304    /// `BTC-USDC -> BTC-USD`) that the WebSocket and user channel use on the
1305    /// wire even when callers operate on the alias side.
1306    pub fn record_product_aliases(&self, products: &[crate::http::models::Product]) {
1307        let aliased: Vec<(Ustr, Ustr)> = products
1308            .iter()
1309            .filter(|p| !p.alias.is_empty())
1310            .map(|p| (p.product_id, p.alias))
1311            .collect();
1312
1313        if aliased.is_empty() {
1314            return;
1315        }
1316
1317        self.product_aliases.rcu(|m| {
1318            for (product_id, alias) in &aliased {
1319                m.insert(*product_id, *alias);
1320            }
1321        });
1322    }
1323
1324    // Returns the cached instrument for a product ID, fetching it on miss.
1325    // Order and fill reconciliation calls parse hundreds of historical
1326    // records and each one needs precision metadata. Rather than forcing
1327    // callers to bootstrap the full instrument universe first, this lazy
1328    // path fetches any missing product via `/products/{id}` and caches it.
1329    async fn get_or_fetch_instrument(&self, product_id: Ustr) -> anyhow::Result<InstrumentAny> {
1330        let instrument_id = InstrumentId::new(
1331            Symbol::new(product_id),
1332            *crate::common::consts::COINBASE_VENUE,
1333        );
1334
1335        if let Some(instrument) = self.instruments.get_cloned(&instrument_id) {
1336            return Ok(instrument);
1337        }
1338        // Cache miss: fetch and cache the single product. Any parse error
1339        // (unsupported product type, missing fields) surfaces to the caller so
1340        // the offending record can be skipped with a log.
1341        self.request_instrument(product_id.as_str()).await
1342    }
1343
1344    /// Submits a new order built from Nautilus domain types.
1345    ///
1346    /// Maps the order side, order type, and time-in-force to Coinbase's
1347    /// `order_configuration` shape and posts to `/orders`. Returns the
1348    /// venue's create-order response; callers inspect `success` and the
1349    /// success/error response variants.
1350    ///
1351    /// # Errors
1352    ///
1353    /// Returns an error when the order parameters cannot be mapped to a
1354    /// supported Coinbase configuration, when the HTTP request fails, or
1355    /// when the response cannot be parsed.
1356    #[allow(clippy::too_many_arguments)]
1357    pub async fn submit_order(
1358        &self,
1359        client_order_id: ClientOrderId,
1360        instrument_id: InstrumentId,
1361        side: OrderSide,
1362        order_type: OrderType,
1363        quantity: Quantity,
1364        time_in_force: TimeInForce,
1365        price: Option<Price>,
1366        trigger_price: Option<Price>,
1367        expire_time: Option<UnixNanos>,
1368        post_only: bool,
1369        is_quote_quantity: bool,
1370        leverage: Option<Decimal>,
1371        margin_type: Option<CoinbaseMarginType>,
1372        reduce_only: bool,
1373        retail_portfolio_id: Option<String>,
1374    ) -> anyhow::Result<CreateOrderResponse> {
1375        let coinbase_side = map_order_side(side)?;
1376        let order_config = build_order_configuration(
1377            order_type,
1378            side,
1379            quantity,
1380            price,
1381            trigger_price,
1382            time_in_force,
1383            expire_time,
1384            post_only,
1385            is_quote_quantity,
1386            reduce_only,
1387        )?;
1388
1389        let request = CreateOrderRequest {
1390            client_order_id: client_order_id.to_string(),
1391            product_id: instrument_id.symbol.inner(),
1392            side: coinbase_side,
1393            order_configuration: order_config,
1394            self_trade_prevention_id: None,
1395            leverage: leverage.map(|d| d.normalize().to_string()),
1396            margin_type,
1397            retail_portfolio_id,
1398            reduce_only,
1399        };
1400
1401        self.inner
1402            .create_order(&request)
1403            .await
1404            .context("failed to submit order")
1405    }
1406
1407    /// Cancels one or more orders by venue order ID via batch_cancel.
1408    ///
1409    /// # Errors
1410    ///
1411    /// Returns an error when the HTTP request fails or the response cannot
1412    /// be parsed.
1413    pub async fn cancel_orders(
1414        &self,
1415        venue_order_ids: &[VenueOrderId],
1416    ) -> anyhow::Result<CancelOrdersResponse> {
1417        let request = CancelOrdersRequest {
1418            order_ids: venue_order_ids
1419                .iter()
1420                .map(|id| id.as_str().to_string())
1421                .collect(),
1422        };
1423        self.inner
1424            .cancel_orders(&request)
1425            .await
1426            .context("failed to cancel orders")
1427    }
1428
1429    /// Fetches the CFM (futures) balance summary.
1430    ///
1431    /// # Errors
1432    ///
1433    /// Returns an error when the HTTP request fails or the response cannot be
1434    /// deserialized.
1435    pub async fn request_cfm_balance_summary(&self) -> anyhow::Result<CfmBalanceSummary> {
1436        let response = self
1437            .inner
1438            .get_cfm_balance_summary()
1439            .await
1440            .map_err(|e| anyhow::anyhow!("Failed to fetch CFM balance summary: {e}"))?;
1441        Ok(response.balance_summary)
1442    }
1443
1444    /// Fetches margin balances derived from the CFM balance summary.
1445    ///
1446    /// # Errors
1447    ///
1448    /// Returns an error when the summary cannot be fetched or when a balance
1449    /// cannot be constructed.
1450    pub async fn request_cfm_margin_balances(&self) -> anyhow::Result<Vec<MarginBalance>> {
1451        let summary = self.request_cfm_balance_summary().await?;
1452        parse_cfm_margin_balances(&summary)
1453    }
1454
1455    /// Fetches a margin [`AccountState`] derived from the CFM balance summary.
1456    ///
1457    /// # Errors
1458    ///
1459    /// Returns an error when the summary cannot be fetched or when balances
1460    /// cannot be constructed.
1461    pub async fn request_cfm_account_state(
1462        &self,
1463        account_id: AccountId,
1464    ) -> anyhow::Result<AccountState> {
1465        let summary = self.request_cfm_balance_summary().await?;
1466        let ts_event = self.ts_now();
1467        parse_cfm_account_state(&summary, account_id, true, ts_event, ts_event)
1468    }
1469
1470    /// Fetches all CFM futures positions and returns Nautilus position reports.
1471    ///
1472    /// # Errors
1473    ///
1474    /// Returns an error when the HTTP request fails or a position cannot be
1475    /// parsed.
1476    pub async fn request_position_status_reports(
1477        &self,
1478        account_id: AccountId,
1479    ) -> anyhow::Result<Vec<PositionStatusReport>> {
1480        let response = self
1481            .inner
1482            .get_cfm_positions()
1483            .await
1484            .map_err(|e| anyhow::anyhow!("Failed to fetch CFM positions: {e}"))?;
1485
1486        let ts_init = self.ts_now();
1487        let mut reports = Vec::with_capacity(response.positions.len());
1488
1489        for position in &response.positions {
1490            let instrument = match self.get_or_fetch_instrument(position.product_id).await {
1491                Ok(inst) => inst,
1492                Err(e) => {
1493                    log::debug!("Skipping CFM position {}: {e}", position.product_id);
1494                    continue;
1495                }
1496            };
1497
1498            match parse_cfm_position_status_report(position, &instrument, account_id, ts_init) {
1499                Ok(report) => reports.push(report),
1500                Err(e) => log::warn!("Failed to parse CFM position {}: {e}", position.product_id),
1501            }
1502        }
1503
1504        Ok(reports)
1505    }
1506
1507    /// Fetches a single CFM futures position and returns a position status
1508    /// report when the venue reports a non-flat position.
1509    ///
1510    /// # Errors
1511    ///
1512    /// Returns an error when the HTTP request fails or the position cannot be
1513    /// parsed.
1514    pub async fn request_position_status_report(
1515        &self,
1516        account_id: AccountId,
1517        instrument_id: InstrumentId,
1518    ) -> anyhow::Result<Option<PositionStatusReport>> {
1519        let product_id = instrument_id.symbol.as_str();
1520        let response = self
1521            .inner
1522            .get_cfm_position(product_id)
1523            .await
1524            .map_err(|e| anyhow::anyhow!("Failed to fetch CFM position '{product_id}': {e}"))?;
1525
1526        let instrument = self
1527            .get_or_fetch_instrument(response.position.product_id)
1528            .await?;
1529        let ts_init = self.ts_now();
1530        let report =
1531            parse_cfm_position_status_report(&response.position, &instrument, account_id, ts_init)?;
1532        Ok(Some(report))
1533    }
1534
1535    /// Modifies an existing GTC order's price, size, or stop price.
1536    ///
1537    /// Coinbase's `/orders/edit` endpoint is documented to accept edits on
1538    /// these fields for supported order configurations (primarily LIMIT
1539    /// GTC). At least one of `price`, `quantity`, or `trigger_price` must
1540    /// be supplied.
1541    ///
1542    /// # Errors
1543    ///
1544    /// Returns an error when the HTTP request fails or the response cannot
1545    /// be deserialized.
1546    pub async fn modify_order(
1547        &self,
1548        venue_order_id: VenueOrderId,
1549        price: Option<Price>,
1550        quantity: Option<Quantity>,
1551        trigger_price: Option<Price>,
1552    ) -> anyhow::Result<EditOrderResponse> {
1553        let request = EditOrderRequest {
1554            order_id: venue_order_id.as_str().to_string(),
1555            price: price.map(|p| p.to_string()),
1556            size: quantity.map(|q| q.to_string()),
1557            stop_price: trigger_price.map(|p| p.to_string()),
1558        };
1559        self.inner
1560            .edit_order(&request)
1561            .await
1562            .context("failed to edit order")
1563    }
1564}
1565
1566/// Maps a Nautilus [`OrderSide`] to Coinbase's wire enum.
1567///
1568/// # Errors
1569///
1570/// Returns an error when the side is [`OrderSide::NoOrderSide`].
1571pub fn map_order_side(side: OrderSide) -> anyhow::Result<CoinbaseOrderSide> {
1572    match side {
1573        OrderSide::Buy => Ok(CoinbaseOrderSide::Buy),
1574        OrderSide::Sell => Ok(CoinbaseOrderSide::Sell),
1575        OrderSide::NoOrderSide => anyhow::bail!("NoOrderSide is not a valid Coinbase side"),
1576    }
1577}
1578
1579/// Builds the Coinbase [`OrderConfiguration`] payload from Nautilus order
1580/// parameters.
1581///
1582/// Caller supplies the order type, side, quantity, optional price/trigger,
1583/// time-in-force, optional expire time (required for GTD), `post_only`
1584/// flag, and whether the quantity is denominated in the quote currency
1585/// (only meaningful for MARKET orders).
1586///
1587/// # Errors
1588///
1589/// Returns an error when the requested combination is not supported by
1590/// Coinbase (e.g. STOP_MARKET, IOC LIMIT, missing required field).
1591#[allow(clippy::too_many_arguments)]
1592pub fn build_order_configuration(
1593    order_type: OrderType,
1594    side: OrderSide,
1595    quantity: Quantity,
1596    price: Option<Price>,
1597    trigger_price: Option<Price>,
1598    time_in_force: TimeInForce,
1599    expire_time: Option<UnixNanos>,
1600    post_only: bool,
1601    is_quote_quantity: bool,
1602    reduce_only: bool,
1603) -> anyhow::Result<OrderConfiguration> {
1604    let qty = quantity.as_decimal();
1605    let price = price.map(|p| p.as_decimal());
1606    let trigger = trigger_price.map(|p| p.as_decimal());
1607
1608    if reduce_only && matches!(order_type, OrderType::Market) {
1609        log::debug!("Coinbase MARKET orders do not accept reduce_only; ignoring flag");
1610    }
1611
1612    match order_type {
1613        OrderType::Market => {
1614            // Coinbase exposes `market_market_ioc` and `market_market_fok` for
1615            // MARKET orders. Nautilus' default GTC is mapped to IOC (mirroring
1616            // the Bybit adapter pattern); explicit IOC and FOK are honoured;
1617            // DAY / GTD are rejected.
1618            //
1619            // Note: a MARKET order built with TIF=GTC will execute as IOC at
1620            // Coinbase. Backtest replays of the same order through the
1621            // matching engine treat it differently. Strategies that need
1622            // strict backtest/live parity should construct MarketOrders with
1623            // TIF=IOC or TIF=FOK explicitly.
1624            let params = if is_quote_quantity {
1625                MarketParams {
1626                    quote_size: Some(qty),
1627                    base_size: None,
1628                }
1629            } else {
1630                MarketParams {
1631                    quote_size: None,
1632                    base_size: Some(qty),
1633                }
1634            };
1635
1636            match time_in_force {
1637                TimeInForce::Ioc | TimeInForce::Gtc => {
1638                    Ok(OrderConfiguration::MarketIoc(MarketIoc {
1639                        market_market_ioc: params,
1640                    }))
1641                }
1642                TimeInForce::Fok => Ok(OrderConfiguration::MarketFok(MarketFok {
1643                    market_market_fok: params,
1644                })),
1645                _ => {
1646                    anyhow::bail!(
1647                        "Unsupported TIF {time_in_force} for MARKET on Coinbase (use IOC or FOK)"
1648                    )
1649                }
1650            }
1651        }
1652        OrderType::Limit => {
1653            let limit_price =
1654                price.ok_or_else(|| anyhow::anyhow!("LIMIT order requires a price"))?;
1655
1656            match time_in_force {
1657                TimeInForce::Gtc => Ok(OrderConfiguration::LimitGtc(LimitGtc {
1658                    limit_limit_gtc: LimitGtcParams {
1659                        base_size: qty,
1660                        limit_price,
1661                        post_only,
1662                    },
1663                })),
1664                TimeInForce::Gtd => {
1665                    let expire = expire_time
1666                        .ok_or_else(|| anyhow::anyhow!("GTD LIMIT requires expire_time"))?;
1667                    Ok(OrderConfiguration::LimitGtd(LimitGtd {
1668                        limit_limit_gtd: LimitGtdParams {
1669                            base_size: qty,
1670                            limit_price,
1671                            end_time: format_rfc3339_from_nanos(expire)?,
1672                            post_only,
1673                        },
1674                    }))
1675                }
1676                TimeInForce::Fok => Ok(OrderConfiguration::LimitFok(LimitFok {
1677                    limit_limit_fok: LimitFokParams {
1678                        base_size: qty,
1679                        limit_price,
1680                    },
1681                })),
1682                _ => anyhow::bail!("Unsupported TIF {time_in_force} for LIMIT on Coinbase"),
1683            }
1684        }
1685        OrderType::StopLimit => {
1686            let limit_price =
1687                price.ok_or_else(|| anyhow::anyhow!("STOP_LIMIT order requires a price"))?;
1688            let stop_price = trigger
1689                .ok_or_else(|| anyhow::anyhow!("STOP_LIMIT order requires trigger_price"))?;
1690            let direction = match side {
1691                OrderSide::Buy => CoinbaseStopDirection::StopUp,
1692                OrderSide::Sell => CoinbaseStopDirection::StopDown,
1693                OrderSide::NoOrderSide => {
1694                    anyhow::bail!("STOP_LIMIT requires a defined side")
1695                }
1696            };
1697
1698            match time_in_force {
1699                TimeInForce::Gtc => Ok(OrderConfiguration::StopLimitGtc(StopLimitGtc {
1700                    stop_limit_stop_limit_gtc: StopLimitGtcParams {
1701                        base_size: qty,
1702                        limit_price,
1703                        stop_price,
1704                        stop_direction: direction,
1705                    },
1706                })),
1707                TimeInForce::Gtd => {
1708                    let expire = expire_time
1709                        .ok_or_else(|| anyhow::anyhow!("GTD STOP_LIMIT requires expire_time"))?;
1710                    Ok(OrderConfiguration::StopLimitGtd(StopLimitGtd {
1711                        stop_limit_stop_limit_gtd: StopLimitGtdParams {
1712                            base_size: qty,
1713                            limit_price,
1714                            stop_price,
1715                            stop_direction: direction,
1716                            end_time: format_rfc3339_from_nanos(expire)?,
1717                        },
1718                    }))
1719                }
1720                _ => anyhow::bail!("Unsupported TIF {time_in_force} for STOP_LIMIT on Coinbase"),
1721            }
1722        }
1723        other => anyhow::bail!("Unsupported order type for Coinbase: {other}"),
1724    }
1725}
1726
1727#[cfg(test)]
1728mod tests {
1729    use rstest::rstest;
1730
1731    use super::*;
1732
1733    #[rstest]
1734    fn test_raw_client_construction_live() {
1735        let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1736        assert_eq!(client.environment(), CoinbaseEnvironment::Live);
1737        assert!(!client.is_authenticated());
1738    }
1739
1740    #[rstest]
1741    fn test_raw_client_construction_sandbox() {
1742        let client =
1743            CoinbaseRawHttpClient::new(CoinbaseEnvironment::Sandbox, 10, None, None).unwrap();
1744        assert_eq!(client.environment(), CoinbaseEnvironment::Sandbox);
1745    }
1746
1747    #[rstest]
1748    fn test_raw_build_url() {
1749        let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1750        let url = client.build_url("/products");
1751        assert_eq!(url, "https://api.coinbase.com/api/v3/brokerage/products");
1752    }
1753
1754    #[rstest]
1755    fn test_raw_build_jwt_uri_live() {
1756        let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1757        let uri = client.build_jwt_uri("GET", "/accounts");
1758        assert_eq!(uri, "GET api.coinbase.com/api/v3/brokerage/accounts");
1759    }
1760
1761    #[rstest]
1762    fn test_raw_build_jwt_uri_sandbox() {
1763        let client =
1764            CoinbaseRawHttpClient::new(CoinbaseEnvironment::Sandbox, 10, None, None).unwrap();
1765        let uri = client.build_jwt_uri("GET", "/accounts");
1766        assert_eq!(
1767            uri,
1768            "GET api-sandbox.coinbase.com/api/v3/brokerage/accounts"
1769        );
1770    }
1771
1772    #[rstest]
1773    fn test_raw_build_jwt_uri_custom_base_url() {
1774        let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1775        client.set_base_url("http://localhost:8080".to_string());
1776        let uri = client.build_jwt_uri("POST", "/orders");
1777        assert_eq!(uri, "POST localhost:8080/api/v3/brokerage/orders");
1778    }
1779
1780    #[rstest]
1781    fn test_raw_set_base_url_safe_after_clone_via_arc() {
1782        let raw = Arc::new(
1783            CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap(),
1784        );
1785        let other = Arc::clone(&raw);
1786        // Mutating after a clone must not panic; readers see the new value
1787        raw.set_base_url("http://localhost:1234".to_string());
1788        assert!(other.build_url("/foo").starts_with("http://localhost:1234"));
1789    }
1790
1791    #[rstest]
1792    fn test_raw_auth_headers_without_credentials() {
1793        let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1794        let result = client.auth_headers("GET", "/accounts");
1795        assert!(result.is_err());
1796        assert!(result.unwrap_err().is_auth_error());
1797    }
1798
1799    #[rstest]
1800    fn test_domain_client_construction() {
1801        let client = CoinbaseHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1802        assert_eq!(client.environment(), CoinbaseEnvironment::Live);
1803        assert!(!client.is_authenticated());
1804    }
1805
1806    #[rstest]
1807    fn test_domain_client_default() {
1808        let client = CoinbaseHttpClient::default();
1809        assert_eq!(client.environment(), CoinbaseEnvironment::Live);
1810    }
1811
1812    #[rstest]
1813    fn test_domain_client_instruments_cache_empty() {
1814        let client = CoinbaseHttpClient::default();
1815        assert!(client.instruments().is_empty());
1816    }
1817
1818    #[rstest]
1819    fn test_domain_client_set_base_url() {
1820        let client = CoinbaseHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1821        let cloned = client.clone();
1822        // Mutating after a clone must not panic; both clones observe the change
1823        client.set_base_url("http://localhost:9090".to_string());
1824        let url = cloned.inner.build_url("/test");
1825        assert!(url.starts_with("http://localhost:9090"));
1826    }
1827
1828    #[rstest]
1829    fn test_encode_query_escapes_rfc3339_timestamps() {
1830        let query = encode_query(&[("start_date", "2024-01-15T10:00:00+00:00")]);
1831        // `+` must be escaped so the server does not read it as a space.
1832        assert_eq!(query, "start_date=2024-01-15T10%3A00%3A00%2B00%3A00");
1833    }
1834
1835    #[rstest]
1836    fn test_encode_query_escapes_opaque_cursor() {
1837        let query = encode_query(&[("cursor", "a/b+c=?&x")]);
1838        // Reserved characters in an opaque cursor must not leak into the query structure.
1839        assert!(!query.contains("a/b+c=?&x"));
1840        assert!(query.starts_with("cursor="));
1841    }
1842
1843    #[rstest]
1844    fn test_encode_query_joins_pairs_with_ampersand() {
1845        let query = encode_query(&[("product_id", "BTC-USD"), ("limit", "50")]);
1846        assert_eq!(query, "product_id=BTC-USD&limit=50");
1847    }
1848
1849    #[rstest]
1850    fn test_map_order_side_rejects_no_side() {
1851        assert!(matches!(
1852            map_order_side(OrderSide::Buy).unwrap(),
1853            CoinbaseOrderSide::Buy
1854        ));
1855        assert!(matches!(
1856            map_order_side(OrderSide::Sell).unwrap(),
1857            CoinbaseOrderSide::Sell
1858        ));
1859        assert!(map_order_side(OrderSide::NoOrderSide).is_err());
1860    }
1861
1862    #[rstest]
1863    fn test_build_order_configuration_market_base_size() {
1864        let cfg = build_order_configuration(
1865            OrderType::Market,
1866            OrderSide::Buy,
1867            Quantity::from("1.5"),
1868            None,
1869            None,
1870            TimeInForce::Ioc,
1871            None,
1872            false,
1873            false,
1874            false,
1875        )
1876        .unwrap();
1877
1878        match cfg {
1879            OrderConfiguration::MarketIoc(m) => {
1880                assert!(m.market_market_ioc.base_size.is_some());
1881                assert!(m.market_market_ioc.quote_size.is_none());
1882            }
1883            other => panic!("expected MarketIoc, was {other:?}"),
1884        }
1885    }
1886
1887    #[rstest]
1888    fn test_build_order_configuration_market_quote_size() {
1889        let cfg = build_order_configuration(
1890            OrderType::Market,
1891            OrderSide::Buy,
1892            Quantity::from("100"),
1893            None,
1894            None,
1895            TimeInForce::Ioc,
1896            None,
1897            false,
1898            true, // is_quote_quantity
1899            false,
1900        )
1901        .unwrap();
1902
1903        match cfg {
1904            OrderConfiguration::MarketIoc(m) => {
1905                assert!(m.market_market_ioc.quote_size.is_some());
1906                assert!(m.market_market_ioc.base_size.is_none());
1907            }
1908            other => panic!("expected MarketIoc, was {other:?}"),
1909        }
1910    }
1911
1912    #[rstest]
1913    fn test_build_order_configuration_market_fok() {
1914        let cfg = build_order_configuration(
1915            OrderType::Market,
1916            OrderSide::Buy,
1917            Quantity::from("0.5"),
1918            None,
1919            None,
1920            TimeInForce::Fok,
1921            None,
1922            false,
1923            false,
1924            false,
1925        )
1926        .unwrap();
1927
1928        match cfg {
1929            OrderConfiguration::MarketFok(m) => {
1930                assert!(m.market_market_fok.base_size.is_some());
1931                assert!(m.market_market_fok.quote_size.is_none());
1932            }
1933            other => panic!("expected MarketFok, was {other:?}"),
1934        }
1935    }
1936
1937    #[rstest]
1938    #[case(TimeInForce::Day)]
1939    #[case(TimeInForce::Gtd)]
1940    fn test_build_order_configuration_market_rejects_unsupported_tif(#[case] tif: TimeInForce) {
1941        let result = build_order_configuration(
1942            OrderType::Market,
1943            OrderSide::Buy,
1944            Quantity::from("1"),
1945            None,
1946            None,
1947            tif,
1948            None,
1949            false,
1950            false,
1951            false,
1952        );
1953        assert!(result.is_err());
1954    }
1955
1956    #[rstest]
1957    fn test_build_order_configuration_limit_gtc_post_only() {
1958        let cfg = build_order_configuration(
1959            OrderType::Limit,
1960            OrderSide::Sell,
1961            Quantity::from("0.5"),
1962            Some(Price::from("50000.00")),
1963            None,
1964            TimeInForce::Gtc,
1965            None,
1966            true,
1967            false,
1968            false,
1969        )
1970        .unwrap();
1971
1972        match cfg {
1973            OrderConfiguration::LimitGtc(l) => assert!(l.limit_limit_gtc.post_only),
1974            other => panic!("expected LimitGtc, was {other:?}"),
1975        }
1976    }
1977
1978    #[rstest]
1979    fn test_build_order_configuration_limit_gtd_requires_expire_time() {
1980        let result = build_order_configuration(
1981            OrderType::Limit,
1982            OrderSide::Buy,
1983            Quantity::from("1"),
1984            Some(Price::from("100.00")),
1985            None,
1986            TimeInForce::Gtd,
1987            None,
1988            false,
1989            false,
1990            false,
1991        );
1992        assert!(result.is_err());
1993    }
1994
1995    #[rstest]
1996    fn test_build_order_configuration_stop_limit_uses_correct_direction() {
1997        let buy_cfg = build_order_configuration(
1998            OrderType::StopLimit,
1999            OrderSide::Buy,
2000            Quantity::from("1"),
2001            Some(Price::from("100.00")),
2002            Some(Price::from("99.00")),
2003            TimeInForce::Gtc,
2004            None,
2005            false,
2006            false,
2007            false,
2008        )
2009        .unwrap();
2010
2011        match buy_cfg {
2012            OrderConfiguration::StopLimitGtc(s) => assert_eq!(
2013                s.stop_limit_stop_limit_gtc.stop_direction,
2014                CoinbaseStopDirection::StopUp
2015            ),
2016            other => panic!("expected StopLimitGtc, was {other:?}"),
2017        }
2018
2019        let sell_cfg = build_order_configuration(
2020            OrderType::StopLimit,
2021            OrderSide::Sell,
2022            Quantity::from("1"),
2023            Some(Price::from("100.00")),
2024            Some(Price::from("99.00")),
2025            TimeInForce::Gtc,
2026            None,
2027            false,
2028            false,
2029            false,
2030        )
2031        .unwrap();
2032
2033        match sell_cfg {
2034            OrderConfiguration::StopLimitGtc(s) => assert_eq!(
2035                s.stop_limit_stop_limit_gtc.stop_direction,
2036                CoinbaseStopDirection::StopDown
2037            ),
2038            other => panic!("expected StopLimitGtc, was {other:?}"),
2039        }
2040    }
2041
2042    #[rstest]
2043    fn test_build_order_configuration_market_accepts_default_gtc() {
2044        // Nautilus orders default to GTC; coerce to MARKET IOC silently for
2045        // the default case but not for any explicit non-IOC TIF.
2046        let cfg = build_order_configuration(
2047            OrderType::Market,
2048            OrderSide::Buy,
2049            Quantity::from("1"),
2050            None,
2051            None,
2052            TimeInForce::Gtc,
2053            None,
2054            false,
2055            false,
2056            false,
2057        )
2058        .unwrap();
2059        assert!(matches!(cfg, OrderConfiguration::MarketIoc(_)));
2060    }
2061
2062    #[rstest]
2063    fn test_build_order_configuration_rejects_stop_market() {
2064        let result = build_order_configuration(
2065            OrderType::StopMarket,
2066            OrderSide::Buy,
2067            Quantity::from("1"),
2068            None,
2069            Some(Price::from("100.00")),
2070            TimeInForce::Gtc,
2071            None,
2072            false,
2073            false,
2074            false,
2075        );
2076        assert!(result.is_err());
2077    }
2078
2079    #[rstest]
2080    fn test_rest_quota_matches_documented_limit() {
2081        assert_eq!(COINBASE_REST_QUOTA.burst_size().get(), 30);
2082    }
2083
2084    #[rstest]
2085    fn test_default_retry_config_values() {
2086        let config = default_retry_config();
2087        assert_eq!(config.max_retries, 3);
2088        assert_eq!(config.initial_delay_ms, 100);
2089        assert_eq!(config.max_delay_ms, 5_000);
2090        assert_eq!(config.max_elapsed_ms, Some(180_000));
2091    }
2092}