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)]
786#[cfg_attr(
787    feature = "python",
788    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.coinbase")
789)]
790pub struct CoinbaseHttpClient {
791    pub(crate) inner: Arc<CoinbaseRawHttpClient>,
792    clock: &'static AtomicTime,
793    instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
794    /// Maps a product ID to its Coinbase-canonical alias (e.g. `BTC-USDC -> BTC-USD`).
795    /// Coinbase consolidates aliased pairs into a single book server-side, so the
796    /// WebSocket feed and user-channel echo the canonical id even when callers
797    /// subscribed or submitted with the alias.
798    product_aliases: Arc<AtomicMap<Ustr, Ustr>>,
799}
800
801impl Default for CoinbaseHttpClient {
802    fn default() -> Self {
803        Self::new(CoinbaseEnvironment::Live, 10, None, None)
804            .expect("Failed to create default Coinbase HTTP client")
805    }
806}
807
808impl CoinbaseHttpClient {
809    /// Creates a new [`CoinbaseHttpClient`] for public endpoints only.
810    ///
811    /// # Errors
812    ///
813    /// Returns an error if the HTTP client cannot be created.
814    pub fn new(
815        environment: CoinbaseEnvironment,
816        timeout_secs: u64,
817        proxy_url: Option<String>,
818        retry_config: Option<RetryConfig>,
819    ) -> std::result::Result<Self, HttpClientError> {
820        let raw = CoinbaseRawHttpClient::new(environment, timeout_secs, proxy_url, retry_config)?;
821        Ok(Self::from_raw(raw))
822    }
823
824    /// Creates a new [`CoinbaseHttpClient`] with credentials for authenticated requests.
825    ///
826    /// # Errors
827    ///
828    /// Returns an error if the HTTP client cannot be created.
829    pub fn with_credentials(
830        credential: CoinbaseCredential,
831        environment: CoinbaseEnvironment,
832        timeout_secs: u64,
833        proxy_url: Option<String>,
834        retry_config: Option<RetryConfig>,
835    ) -> std::result::Result<Self, HttpClientError> {
836        let raw = CoinbaseRawHttpClient::with_credentials(
837            credential,
838            environment,
839            timeout_secs,
840            proxy_url,
841            retry_config,
842        )?;
843        Ok(Self::from_raw(raw))
844    }
845
846    /// Creates an authenticated client from environment variables.
847    ///
848    /// # Errors
849    ///
850    /// Returns [`Error::Auth`] if required environment variables are not set.
851    pub fn from_env(environment: CoinbaseEnvironment) -> Result<Self> {
852        let raw = CoinbaseRawHttpClient::from_env(environment)?;
853        Ok(Self::from_raw(raw))
854    }
855
856    /// Creates a new [`CoinbaseHttpClient`] with explicit credentials.
857    ///
858    /// # Errors
859    ///
860    /// Returns [`Error::Auth`] if credentials are invalid.
861    pub fn from_credentials(
862        api_key: &str,
863        api_secret: &str,
864        environment: CoinbaseEnvironment,
865        timeout_secs: u64,
866        proxy_url: Option<String>,
867        retry_config: Option<RetryConfig>,
868    ) -> Result<Self> {
869        let raw = CoinbaseRawHttpClient::from_credentials(
870            api_key,
871            api_secret,
872            environment,
873            timeout_secs,
874            proxy_url,
875            retry_config,
876        )?;
877        Ok(Self::from_raw(raw))
878    }
879
880    /// Returns the cancellation token shared by in-flight requests.
881    #[must_use]
882    pub fn cancellation_token(&self) -> &CancellationToken {
883        self.inner.cancellation_token()
884    }
885
886    fn from_raw(raw: CoinbaseRawHttpClient) -> Self {
887        Self {
888            inner: Arc::new(raw),
889            clock: get_atomic_clock_realtime(),
890            instruments: Arc::new(AtomicMap::new()),
891            product_aliases: Arc::new(AtomicMap::new()),
892        }
893    }
894
895    /// Overrides the base REST URL (for testing with mock servers).
896    ///
897    /// Safe to call regardless of how many clones share the inner client.
898    pub fn set_base_url(&self, url: String) {
899        self.inner.set_base_url(url);
900    }
901
902    /// Returns the configured environment.
903    #[must_use]
904    pub fn environment(&self) -> CoinbaseEnvironment {
905        self.inner.environment()
906    }
907
908    /// Returns true if this client has credentials for authenticated requests.
909    #[must_use]
910    pub fn is_authenticated(&self) -> bool {
911        self.inner.is_authenticated()
912    }
913
914    /// Returns a reference to the instrument cache.
915    #[must_use]
916    pub fn instruments(&self) -> &Arc<AtomicMap<InstrumentId, InstrumentAny>> {
917        &self.instruments
918    }
919
920    /// Returns a reference to the product alias map (`product_id -> canonical product_id`).
921    #[must_use]
922    pub fn product_aliases(&self) -> &Arc<AtomicMap<Ustr, Ustr>> {
923        &self.product_aliases
924    }
925
926    /// Returns the current timestamp from the atomic clock.
927    #[must_use]
928    pub fn ts_now(&self) -> UnixNanos {
929        self.clock.get_time_ns()
930    }
931
932    /// Gets all available products.
933    pub async fn get_products(&self) -> Result<Value> {
934        self.inner.get_products().await
935    }
936
937    /// Gets a specific product by ID.
938    pub async fn get_product(&self, product_id: &str) -> Result<Value> {
939        self.inner.get_product(product_id).await
940    }
941
942    /// Gets candles for a product.
943    pub async fn get_candles(
944        &self,
945        product_id: &str,
946        start: &str,
947        end: &str,
948        granularity: &str,
949    ) -> Result<Value> {
950        self.inner
951            .get_candles(product_id, start, end, granularity)
952            .await
953    }
954
955    /// Gets market trades for a product.
956    pub async fn get_market_trades(&self, product_id: &str, limit: u32) -> Result<Value> {
957        self.inner.get_market_trades(product_id, limit).await
958    }
959
960    /// Gets best bid/ask for one or more products.
961    pub async fn get_best_bid_ask(&self, product_ids: &[&str]) -> Result<Value> {
962        self.inner.get_best_bid_ask(product_ids).await
963    }
964
965    /// Gets the product order book.
966    pub async fn get_product_book(&self, product_id: &str, limit: Option<u32>) -> Result<Value> {
967        self.inner.get_product_book(product_id, limit).await
968    }
969
970    /// Gets all accounts.
971    pub async fn get_accounts(&self) -> Result<Value> {
972        self.inner.get_accounts().await
973    }
974
975    /// Gets a specific account by UUID.
976    pub async fn get_account(&self, account_id: &str) -> Result<Value> {
977        self.inner.get_account(account_id).await
978    }
979
980    /// Lists all portfolios visible to the authenticated key.
981    pub async fn get_portfolios(&self) -> Result<Value> {
982        self.inner.get_portfolios().await
983    }
984
985    /// Validates an order payload against the venue without submitting it.
986    ///
987    /// Useful for diagnosing `account is not available` and similar errors
988    /// because it returns the same error envelope as `POST /orders`.
989    pub async fn preview_order(&self, body: &Value) -> Result<Value> {
990        self.inner.post("/orders/preview", body).await
991    }
992
993    /// Gets historical orders.
994    pub async fn get_orders(&self, query: &str) -> Result<Value> {
995        self.inner.get_orders(query).await
996    }
997
998    /// Gets a specific order by ID.
999    pub async fn get_order(&self, order_id: &str) -> Result<Value> {
1000        self.inner.get_order(order_id).await
1001    }
1002
1003    /// Gets fills (trade executions).
1004    pub async fn get_fills(&self, query: &str) -> Result<Value> {
1005        self.inner.get_fills(query).await
1006    }
1007
1008    /// Gets fee transaction summary.
1009    pub async fn get_transaction_summary(&self) -> Result<Value> {
1010        self.inner.get_transaction_summary().await
1011    }
1012
1013    /// Requests all instruments from Coinbase, optionally filtered by product type.
1014    ///
1015    /// Parses each supported product into a Nautilus [`InstrumentAny`] and caches
1016    /// the results in the shared instrument map. Unsupported products (non-crypto
1017    /// futures, `UNKNOWN` product types) are skipped with a debug log.
1018    ///
1019    /// # Errors
1020    ///
1021    /// Returns an error when the HTTP request fails or the response cannot be
1022    /// deserialized.
1023    pub async fn request_instruments(
1024        &self,
1025        product_type: Option<CoinbaseProductType>,
1026    ) -> anyhow::Result<Vec<InstrumentAny>> {
1027        let json = self
1028            .inner
1029            .get_products()
1030            .await
1031            .map_err(|e| anyhow::anyhow!("Failed to fetch products: {e}"))?;
1032        let response: ProductsResponse =
1033            serde_json::from_value(json).map_err(|e| anyhow::anyhow!(e))?;
1034
1035        let ts_init = self.ts_now();
1036        let mut instruments = Vec::with_capacity(response.products.len());
1037
1038        for product in &response.products {
1039            if let Some(filter) = product_type
1040                && product.product_type != filter
1041            {
1042                continue;
1043            }
1044
1045            match parse_instrument(product, ts_init) {
1046                Ok(instrument) => instruments.push(instrument),
1047                Err(e) => {
1048                    log::debug!(
1049                        "Skipping product '{}' during parse: {e}",
1050                        product.product_id
1051                    );
1052                }
1053            }
1054        }
1055
1056        self.cache_instruments(&instruments);
1057        self.record_product_aliases(&response.products);
1058        Ok(instruments)
1059    }
1060
1061    /// Requests a single instrument by product ID.
1062    ///
1063    /// Caches the result on success.
1064    ///
1065    /// # Errors
1066    ///
1067    /// Returns an error when the HTTP request fails, deserialization fails,
1068    /// or the product cannot be parsed into a supported instrument.
1069    pub async fn request_instrument(&self, product_id: &str) -> anyhow::Result<InstrumentAny> {
1070        let json = self
1071            .inner
1072            .get_product(product_id)
1073            .await
1074            .map_err(|e| anyhow::anyhow!("Failed to fetch product '{product_id}': {e}"))?;
1075        let product: crate::http::models::Product =
1076            serde_json::from_value(json).map_err(|e| anyhow::anyhow!(e))?;
1077        let ts_init = self.ts_now();
1078        let instrument = parse_instrument(&product, ts_init)?;
1079        self.cache_instrument(&instrument);
1080        self.record_product_aliases(std::slice::from_ref(&product));
1081        Ok(instrument)
1082    }
1083
1084    /// Requests the raw product payload for a product ID.
1085    ///
1086    /// Returns the full [`crate::http::models::Product`] so callers can read
1087    /// derivatives-specific fields (`future_product_details.index_price`,
1088    /// `funding_rate`, `funding_time`) that are stripped when parsing to a
1089    /// Nautilus instrument.
1090    ///
1091    /// # Errors
1092    ///
1093    /// Returns an error when the HTTP request fails or the response cannot
1094    /// be deserialized.
1095    pub async fn request_raw_product(
1096        &self,
1097        product_id: &str,
1098    ) -> anyhow::Result<crate::http::models::Product> {
1099        let json = self
1100            .inner
1101            .get_product(product_id)
1102            .await
1103            .map_err(|e| anyhow::anyhow!("Failed to fetch product '{product_id}': {e}"))?;
1104        serde_json::from_value(json).map_err(|e| anyhow::anyhow!(e))
1105    }
1106
1107    /// Requests the current account state.
1108    ///
1109    /// Builds a cash-type [`AccountState`] from `/accounts` with one balance
1110    /// per currency. Follows Coinbase's cursor pagination so multi-wallet
1111    /// accounts are reported in full. `reported` is set to `true` since the
1112    /// values come from the venue.
1113    ///
1114    /// # Errors
1115    ///
1116    /// Returns an error when the HTTP request fails or the response cannot
1117    /// be parsed.
1118    pub async fn request_account_state(
1119        &self,
1120        account_id: AccountId,
1121    ) -> anyhow::Result<AccountState> {
1122        let accounts = self
1123            .inner
1124            .fetch_all_accounts()
1125            .await
1126            .map_err(|e| anyhow::anyhow!("Failed to fetch accounts: {e}"))?;
1127        let ts_event = self.ts_now();
1128        parse_account_state(&accounts, account_id, true, ts_event, ts_event)
1129    }
1130
1131    /// Requests a single order status report by venue or client order ID.
1132    ///
1133    /// Resolves venue order IDs first via `/orders/historical/{id}`. When only a
1134    /// `client_order_id` is provided, paginates the order history filtered to
1135    /// that client ID.
1136    ///
1137    /// # Errors
1138    ///
1139    /// Returns an error when the HTTP request fails, the order cannot be found,
1140    /// or the response cannot be parsed.
1141    pub async fn request_order_status_report(
1142        &self,
1143        account_id: AccountId,
1144        client_order_id: Option<ClientOrderId>,
1145        venue_order_id: Option<VenueOrderId>,
1146    ) -> anyhow::Result<OrderStatusReport> {
1147        let venue_order_id = match (venue_order_id, client_order_id) {
1148            (Some(vid), _) => vid,
1149            (None, Some(cid)) => {
1150                // Fall back to batched query when only the client order ID is known
1151                let query = OrderListQuery {
1152                    client_order_id_filter: Some(cid.as_str().to_string()),
1153                    ..Default::default()
1154                };
1155                let orders = self
1156                    .inner
1157                    .fetch_all_orders(&query)
1158                    .await
1159                    .map_err(|e| anyhow::anyhow!("Failed to fetch orders: {e}"))?;
1160                let order = orders
1161                    .into_iter()
1162                    .next()
1163                    .ok_or_else(|| anyhow::anyhow!("No order found for client_order_id={cid}"))?;
1164                let instrument = self.get_or_fetch_instrument(order.product_id).await?;
1165                let ts_init = self.ts_now();
1166                return parse_order_status_report(&order, &instrument, account_id, ts_init);
1167            }
1168            (None, None) => {
1169                anyhow::bail!("Either client_order_id or venue_order_id is required")
1170            }
1171        };
1172
1173        let json = self
1174            .inner
1175            .get_order(venue_order_id.as_str())
1176            .await
1177            .map_err(|e| anyhow::anyhow!("Failed to fetch order: {e}"))?;
1178        let response: OrderResponse =
1179            serde_json::from_value(json).map_err(|e| anyhow::anyhow!(e))?;
1180        let instrument = self
1181            .get_or_fetch_instrument(response.order.product_id)
1182            .await?;
1183        let ts_init = self.ts_now();
1184        parse_order_status_report(&response.order, &instrument, account_id, ts_init)
1185    }
1186
1187    /// Requests order status reports, optionally filtered by instrument, open
1188    /// status, and time window.
1189    ///
1190    /// # Errors
1191    ///
1192    /// Returns an error when the HTTP request fails or when any response cannot
1193    /// be deserialized.
1194    pub async fn request_order_status_reports(
1195        &self,
1196        account_id: AccountId,
1197        instrument_id: Option<InstrumentId>,
1198        open_only: bool,
1199        start: Option<DateTime<Utc>>,
1200        end: Option<DateTime<Utc>>,
1201        limit: Option<u32>,
1202    ) -> anyhow::Result<Vec<OrderStatusReport>> {
1203        let query = OrderListQuery {
1204            product_id: instrument_id.map(|id| id.symbol.as_str().to_string()),
1205            open_only,
1206            start,
1207            end,
1208            limit,
1209            client_order_id_filter: None,
1210        };
1211
1212        let orders = self
1213            .inner
1214            .fetch_all_orders(&query)
1215            .await
1216            .map_err(|e| anyhow::anyhow!("Failed to fetch orders: {e}"))?;
1217
1218        let ts_init = self.ts_now();
1219        let mut reports = Vec::with_capacity(orders.len());
1220
1221        for order in &orders {
1222            let instrument = match self.get_or_fetch_instrument(order.product_id).await {
1223                Ok(inst) => inst,
1224                Err(e) => {
1225                    log::debug!("Skipping order {}: {e}", order.order_id);
1226                    continue;
1227                }
1228            };
1229
1230            match parse_order_status_report(order, &instrument, account_id, ts_init) {
1231                Ok(report) => reports.push(report),
1232                Err(e) => log::warn!("Failed to parse order {}: {e}", order.order_id),
1233            }
1234        }
1235
1236        Ok(reports)
1237    }
1238
1239    /// Requests fill reports, optionally filtered by instrument, venue order ID,
1240    /// and time window.
1241    ///
1242    /// # Errors
1243    ///
1244    /// Returns an error when the HTTP request fails or the response cannot be
1245    /// deserialized.
1246    pub async fn request_fill_reports(
1247        &self,
1248        account_id: AccountId,
1249        instrument_id: Option<InstrumentId>,
1250        venue_order_id: Option<VenueOrderId>,
1251        start: Option<DateTime<Utc>>,
1252        end: Option<DateTime<Utc>>,
1253        limit: Option<u32>,
1254    ) -> anyhow::Result<Vec<FillReport>> {
1255        let query = FillListQuery {
1256            product_id: instrument_id.map(|id| id.symbol.as_str().to_string()),
1257            venue_order_id: venue_order_id.map(|id| id.as_str().to_string()),
1258            start,
1259            end,
1260            limit,
1261        };
1262
1263        let fills = self
1264            .inner
1265            .fetch_all_fills(&query)
1266            .await
1267            .map_err(|e| anyhow::anyhow!("Failed to fetch fills: {e}"))?;
1268
1269        let ts_init = self.ts_now();
1270        let mut reports = Vec::with_capacity(fills.len());
1271
1272        for fill in &fills {
1273            let instrument = match self.get_or_fetch_instrument(fill.product_id).await {
1274                Ok(inst) => inst,
1275                Err(e) => {
1276                    log::debug!("Skipping fill {}: {e}", fill.trade_id);
1277                    continue;
1278                }
1279            };
1280
1281            match parse_fill_report(fill, &instrument, account_id, ts_init) {
1282                Ok(report) => reports.push(report),
1283                Err(e) => log::warn!("Failed to parse fill {}: {e}", fill.trade_id),
1284            }
1285        }
1286
1287        Ok(reports)
1288    }
1289
1290    /// Caches an instrument in the shared instrument map.
1291    pub fn cache_instrument(&self, instrument: &InstrumentAny) {
1292        self.instruments.rcu(|m| {
1293            m.insert(instrument.id(), instrument.clone());
1294        });
1295    }
1296
1297    /// Caches a batch of instruments in the shared instrument map.
1298    pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
1299        self.instruments.rcu(|m| {
1300            for instrument in instruments {
1301                m.insert(instrument.id(), instrument.clone());
1302            }
1303        });
1304    }
1305
1306    /// Records `product_id -> alias` entries for any product whose `alias`
1307    /// field is non-empty. Coinbase aliases pairs to a canonical id (e.g.
1308    /// `BTC-USDC -> BTC-USD`) that the WebSocket and user channel use on the
1309    /// wire even when callers operate on the alias side.
1310    pub fn record_product_aliases(&self, products: &[crate::http::models::Product]) {
1311        let aliased: Vec<(Ustr, Ustr)> = products
1312            .iter()
1313            .filter(|p| !p.alias.is_empty())
1314            .map(|p| (p.product_id, p.alias))
1315            .collect();
1316
1317        if aliased.is_empty() {
1318            return;
1319        }
1320
1321        self.product_aliases.rcu(|m| {
1322            for (product_id, alias) in &aliased {
1323                m.insert(*product_id, *alias);
1324            }
1325        });
1326    }
1327
1328    // Returns the cached instrument for a product ID, fetching it on miss.
1329    // Order and fill reconciliation calls parse hundreds of historical
1330    // records and each one needs precision metadata. Rather than forcing
1331    // callers to bootstrap the full instrument universe first, this lazy
1332    // path fetches any missing product via `/products/{id}` and caches it.
1333    async fn get_or_fetch_instrument(&self, product_id: Ustr) -> anyhow::Result<InstrumentAny> {
1334        let instrument_id = InstrumentId::new(
1335            Symbol::new(product_id),
1336            *crate::common::consts::COINBASE_VENUE,
1337        );
1338
1339        if let Some(instrument) = self.instruments.get_cloned(&instrument_id) {
1340            return Ok(instrument);
1341        }
1342        // Cache miss: fetch and cache the single product. Any parse error
1343        // (unsupported product type, missing fields) surfaces to the caller so
1344        // the offending record can be skipped with a log.
1345        self.request_instrument(product_id.as_str()).await
1346    }
1347
1348    /// Submits a new order built from Nautilus domain types.
1349    ///
1350    /// Maps the order side, order type, and time-in-force to Coinbase's
1351    /// `order_configuration` shape and posts to `/orders`. Returns the
1352    /// venue's create-order response; callers inspect `success` and the
1353    /// success/error response variants.
1354    ///
1355    /// # Errors
1356    ///
1357    /// Returns an error when the order parameters cannot be mapped to a
1358    /// supported Coinbase configuration, when the HTTP request fails, or
1359    /// when the response cannot be parsed.
1360    #[allow(clippy::too_many_arguments)]
1361    pub async fn submit_order(
1362        &self,
1363        client_order_id: ClientOrderId,
1364        instrument_id: InstrumentId,
1365        side: OrderSide,
1366        order_type: OrderType,
1367        quantity: Quantity,
1368        time_in_force: TimeInForce,
1369        price: Option<Price>,
1370        trigger_price: Option<Price>,
1371        expire_time: Option<UnixNanos>,
1372        post_only: bool,
1373        is_quote_quantity: bool,
1374        leverage: Option<Decimal>,
1375        margin_type: Option<CoinbaseMarginType>,
1376        reduce_only: bool,
1377        retail_portfolio_id: Option<String>,
1378    ) -> anyhow::Result<CreateOrderResponse> {
1379        let coinbase_side = map_order_side(side)?;
1380        let order_config = build_order_configuration(
1381            order_type,
1382            side,
1383            quantity,
1384            price,
1385            trigger_price,
1386            time_in_force,
1387            expire_time,
1388            post_only,
1389            is_quote_quantity,
1390            reduce_only,
1391        )?;
1392
1393        let request = CreateOrderRequest {
1394            client_order_id: client_order_id.to_string(),
1395            product_id: instrument_id.symbol.inner(),
1396            side: coinbase_side,
1397            order_configuration: order_config,
1398            self_trade_prevention_id: None,
1399            leverage: leverage.map(|d| d.normalize().to_string()),
1400            margin_type,
1401            retail_portfolio_id,
1402            reduce_only,
1403        };
1404
1405        self.inner
1406            .create_order(&request)
1407            .await
1408            .context("failed to submit order")
1409    }
1410
1411    /// Cancels one or more orders by venue order ID via batch_cancel.
1412    ///
1413    /// # Errors
1414    ///
1415    /// Returns an error when the HTTP request fails or the response cannot
1416    /// be parsed.
1417    pub async fn cancel_orders(
1418        &self,
1419        venue_order_ids: &[VenueOrderId],
1420    ) -> anyhow::Result<CancelOrdersResponse> {
1421        let request = CancelOrdersRequest {
1422            order_ids: venue_order_ids
1423                .iter()
1424                .map(|id| id.as_str().to_string())
1425                .collect(),
1426        };
1427        self.inner
1428            .cancel_orders(&request)
1429            .await
1430            .context("failed to cancel orders")
1431    }
1432
1433    /// Fetches the CFM (futures) balance summary.
1434    ///
1435    /// # Errors
1436    ///
1437    /// Returns an error when the HTTP request fails or the response cannot be
1438    /// deserialized.
1439    pub async fn request_cfm_balance_summary(&self) -> anyhow::Result<CfmBalanceSummary> {
1440        let response = self
1441            .inner
1442            .get_cfm_balance_summary()
1443            .await
1444            .map_err(|e| anyhow::anyhow!("Failed to fetch CFM balance summary: {e}"))?;
1445        Ok(response.balance_summary)
1446    }
1447
1448    /// Fetches margin balances derived from the CFM balance summary.
1449    ///
1450    /// # Errors
1451    ///
1452    /// Returns an error when the summary cannot be fetched or when a balance
1453    /// cannot be constructed.
1454    pub async fn request_cfm_margin_balances(&self) -> anyhow::Result<Vec<MarginBalance>> {
1455        let summary = self.request_cfm_balance_summary().await?;
1456        parse_cfm_margin_balances(&summary)
1457    }
1458
1459    /// Fetches a margin [`AccountState`] derived from the CFM balance summary.
1460    ///
1461    /// # Errors
1462    ///
1463    /// Returns an error when the summary cannot be fetched or when balances
1464    /// cannot be constructed.
1465    pub async fn request_cfm_account_state(
1466        &self,
1467        account_id: AccountId,
1468    ) -> anyhow::Result<AccountState> {
1469        let summary = self.request_cfm_balance_summary().await?;
1470        let ts_event = self.ts_now();
1471        parse_cfm_account_state(&summary, account_id, true, ts_event, ts_event)
1472    }
1473
1474    /// Fetches all CFM futures positions and returns Nautilus position reports.
1475    ///
1476    /// # Errors
1477    ///
1478    /// Returns an error when the HTTP request fails or a position cannot be
1479    /// parsed.
1480    pub async fn request_position_status_reports(
1481        &self,
1482        account_id: AccountId,
1483    ) -> anyhow::Result<Vec<PositionStatusReport>> {
1484        let response = self
1485            .inner
1486            .get_cfm_positions()
1487            .await
1488            .map_err(|e| anyhow::anyhow!("Failed to fetch CFM positions: {e}"))?;
1489
1490        let ts_init = self.ts_now();
1491        let mut reports = Vec::with_capacity(response.positions.len());
1492
1493        for position in &response.positions {
1494            let instrument = match self.get_or_fetch_instrument(position.product_id).await {
1495                Ok(inst) => inst,
1496                Err(e) => {
1497                    log::debug!("Skipping CFM position {}: {e}", position.product_id);
1498                    continue;
1499                }
1500            };
1501
1502            match parse_cfm_position_status_report(position, &instrument, account_id, ts_init) {
1503                Ok(report) => reports.push(report),
1504                Err(e) => log::warn!("Failed to parse CFM position {}: {e}", position.product_id),
1505            }
1506        }
1507
1508        Ok(reports)
1509    }
1510
1511    /// Fetches a single CFM futures position and returns a position status
1512    /// report when the venue reports a non-flat position.
1513    ///
1514    /// # Errors
1515    ///
1516    /// Returns an error when the HTTP request fails or the position cannot be
1517    /// parsed.
1518    pub async fn request_position_status_report(
1519        &self,
1520        account_id: AccountId,
1521        instrument_id: InstrumentId,
1522    ) -> anyhow::Result<Option<PositionStatusReport>> {
1523        let product_id = instrument_id.symbol.as_str();
1524        let response = self
1525            .inner
1526            .get_cfm_position(product_id)
1527            .await
1528            .map_err(|e| anyhow::anyhow!("Failed to fetch CFM position '{product_id}': {e}"))?;
1529
1530        let instrument = self
1531            .get_or_fetch_instrument(response.position.product_id)
1532            .await?;
1533        let ts_init = self.ts_now();
1534        let report =
1535            parse_cfm_position_status_report(&response.position, &instrument, account_id, ts_init)?;
1536        Ok(Some(report))
1537    }
1538
1539    /// Modifies an existing GTC order's price, size, or stop price.
1540    ///
1541    /// Coinbase's `/orders/edit` endpoint is documented to accept edits on
1542    /// these fields for supported order configurations (primarily LIMIT
1543    /// GTC). At least one of `price`, `quantity`, or `trigger_price` must
1544    /// be supplied.
1545    ///
1546    /// # Errors
1547    ///
1548    /// Returns an error when the HTTP request fails or the response cannot
1549    /// be deserialized.
1550    pub async fn modify_order(
1551        &self,
1552        venue_order_id: VenueOrderId,
1553        price: Option<Price>,
1554        quantity: Option<Quantity>,
1555        trigger_price: Option<Price>,
1556    ) -> anyhow::Result<EditOrderResponse> {
1557        let request = EditOrderRequest {
1558            order_id: venue_order_id.as_str().to_string(),
1559            price: price.map(|p| p.to_string()),
1560            size: quantity.map(|q| q.to_string()),
1561            stop_price: trigger_price.map(|p| p.to_string()),
1562        };
1563        self.inner
1564            .edit_order(&request)
1565            .await
1566            .context("failed to edit order")
1567    }
1568}
1569
1570/// Maps a Nautilus [`OrderSide`] to Coinbase's wire enum.
1571///
1572/// # Errors
1573///
1574/// Returns an error when the side is [`OrderSide::NoOrderSide`].
1575pub fn map_order_side(side: OrderSide) -> anyhow::Result<CoinbaseOrderSide> {
1576    match side {
1577        OrderSide::Buy => Ok(CoinbaseOrderSide::Buy),
1578        OrderSide::Sell => Ok(CoinbaseOrderSide::Sell),
1579        OrderSide::NoOrderSide => anyhow::bail!("NoOrderSide is not a valid Coinbase side"),
1580    }
1581}
1582
1583/// Builds the Coinbase [`OrderConfiguration`] payload from Nautilus order
1584/// parameters.
1585///
1586/// Caller supplies the order type, side, quantity, optional price/trigger,
1587/// time-in-force, optional expire time (required for GTD), `post_only`
1588/// flag, and whether the quantity is denominated in the quote currency
1589/// (only meaningful for MARKET orders).
1590///
1591/// # Errors
1592///
1593/// Returns an error when the requested combination is not supported by
1594/// Coinbase (e.g. STOP_MARKET, IOC LIMIT, missing required field).
1595#[allow(clippy::too_many_arguments)]
1596pub fn build_order_configuration(
1597    order_type: OrderType,
1598    side: OrderSide,
1599    quantity: Quantity,
1600    price: Option<Price>,
1601    trigger_price: Option<Price>,
1602    time_in_force: TimeInForce,
1603    expire_time: Option<UnixNanos>,
1604    post_only: bool,
1605    is_quote_quantity: bool,
1606    reduce_only: bool,
1607) -> anyhow::Result<OrderConfiguration> {
1608    let qty = quantity.as_decimal();
1609    let price = price.map(|p| p.as_decimal());
1610    let trigger = trigger_price.map(|p| p.as_decimal());
1611
1612    if reduce_only && matches!(order_type, OrderType::Market) {
1613        log::debug!("Coinbase MARKET orders do not accept reduce_only; ignoring flag");
1614    }
1615
1616    match order_type {
1617        OrderType::Market => {
1618            // Coinbase exposes `market_market_ioc` and `market_market_fok` for
1619            // MARKET orders. Nautilus' default GTC is mapped to IOC (mirroring
1620            // the Bybit adapter pattern); explicit IOC and FOK are honoured;
1621            // DAY / GTD are rejected.
1622            //
1623            // Note: a MARKET order built with TIF=GTC will execute as IOC at
1624            // Coinbase. Backtest replays of the same order through the
1625            // matching engine treat it differently. Strategies that need
1626            // strict backtest/live parity should construct MarketOrders with
1627            // TIF=IOC or TIF=FOK explicitly.
1628            let params = if is_quote_quantity {
1629                MarketParams {
1630                    quote_size: Some(qty),
1631                    base_size: None,
1632                }
1633            } else {
1634                MarketParams {
1635                    quote_size: None,
1636                    base_size: Some(qty),
1637                }
1638            };
1639
1640            match time_in_force {
1641                TimeInForce::Ioc | TimeInForce::Gtc => {
1642                    Ok(OrderConfiguration::MarketIoc(MarketIoc {
1643                        market_market_ioc: params,
1644                    }))
1645                }
1646                TimeInForce::Fok => Ok(OrderConfiguration::MarketFok(MarketFok {
1647                    market_market_fok: params,
1648                })),
1649                _ => {
1650                    anyhow::bail!(
1651                        "Unsupported TIF {time_in_force} for MARKET on Coinbase (use IOC or FOK)"
1652                    )
1653                }
1654            }
1655        }
1656        OrderType::Limit => {
1657            let limit_price =
1658                price.ok_or_else(|| anyhow::anyhow!("LIMIT order requires a price"))?;
1659
1660            match time_in_force {
1661                TimeInForce::Gtc => Ok(OrderConfiguration::LimitGtc(LimitGtc {
1662                    limit_limit_gtc: LimitGtcParams {
1663                        base_size: qty,
1664                        limit_price,
1665                        post_only,
1666                    },
1667                })),
1668                TimeInForce::Gtd => {
1669                    let expire = expire_time
1670                        .ok_or_else(|| anyhow::anyhow!("GTD LIMIT requires expire_time"))?;
1671                    Ok(OrderConfiguration::LimitGtd(LimitGtd {
1672                        limit_limit_gtd: LimitGtdParams {
1673                            base_size: qty,
1674                            limit_price,
1675                            end_time: format_rfc3339_from_nanos(expire)?,
1676                            post_only,
1677                        },
1678                    }))
1679                }
1680                TimeInForce::Fok => Ok(OrderConfiguration::LimitFok(LimitFok {
1681                    limit_limit_fok: LimitFokParams {
1682                        base_size: qty,
1683                        limit_price,
1684                    },
1685                })),
1686                _ => anyhow::bail!("Unsupported TIF {time_in_force} for LIMIT on Coinbase"),
1687            }
1688        }
1689        OrderType::StopLimit => {
1690            let limit_price =
1691                price.ok_or_else(|| anyhow::anyhow!("STOP_LIMIT order requires a price"))?;
1692            let stop_price = trigger
1693                .ok_or_else(|| anyhow::anyhow!("STOP_LIMIT order requires trigger_price"))?;
1694            let direction = match side {
1695                OrderSide::Buy => CoinbaseStopDirection::StopUp,
1696                OrderSide::Sell => CoinbaseStopDirection::StopDown,
1697                OrderSide::NoOrderSide => {
1698                    anyhow::bail!("STOP_LIMIT requires a defined side")
1699                }
1700            };
1701
1702            match time_in_force {
1703                TimeInForce::Gtc => Ok(OrderConfiguration::StopLimitGtc(StopLimitGtc {
1704                    stop_limit_stop_limit_gtc: StopLimitGtcParams {
1705                        base_size: qty,
1706                        limit_price,
1707                        stop_price,
1708                        stop_direction: direction,
1709                    },
1710                })),
1711                TimeInForce::Gtd => {
1712                    let expire = expire_time
1713                        .ok_or_else(|| anyhow::anyhow!("GTD STOP_LIMIT requires expire_time"))?;
1714                    Ok(OrderConfiguration::StopLimitGtd(StopLimitGtd {
1715                        stop_limit_stop_limit_gtd: StopLimitGtdParams {
1716                            base_size: qty,
1717                            limit_price,
1718                            stop_price,
1719                            stop_direction: direction,
1720                            end_time: format_rfc3339_from_nanos(expire)?,
1721                        },
1722                    }))
1723                }
1724                _ => anyhow::bail!("Unsupported TIF {time_in_force} for STOP_LIMIT on Coinbase"),
1725            }
1726        }
1727        other => anyhow::bail!("Unsupported order type for Coinbase: {other}"),
1728    }
1729}
1730
1731#[cfg(test)]
1732mod tests {
1733    use rstest::rstest;
1734
1735    use super::*;
1736
1737    #[rstest]
1738    fn test_raw_client_construction_live() {
1739        let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1740        assert_eq!(client.environment(), CoinbaseEnvironment::Live);
1741        assert!(!client.is_authenticated());
1742    }
1743
1744    #[rstest]
1745    fn test_raw_client_construction_sandbox() {
1746        let client =
1747            CoinbaseRawHttpClient::new(CoinbaseEnvironment::Sandbox, 10, None, None).unwrap();
1748        assert_eq!(client.environment(), CoinbaseEnvironment::Sandbox);
1749    }
1750
1751    #[rstest]
1752    fn test_raw_build_url() {
1753        let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1754        let url = client.build_url("/products");
1755        assert_eq!(url, "https://api.coinbase.com/api/v3/brokerage/products");
1756    }
1757
1758    #[rstest]
1759    fn test_raw_build_jwt_uri_live() {
1760        let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1761        let uri = client.build_jwt_uri("GET", "/accounts");
1762        assert_eq!(uri, "GET api.coinbase.com/api/v3/brokerage/accounts");
1763    }
1764
1765    #[rstest]
1766    fn test_raw_build_jwt_uri_sandbox() {
1767        let client =
1768            CoinbaseRawHttpClient::new(CoinbaseEnvironment::Sandbox, 10, None, None).unwrap();
1769        let uri = client.build_jwt_uri("GET", "/accounts");
1770        assert_eq!(
1771            uri,
1772            "GET api-sandbox.coinbase.com/api/v3/brokerage/accounts"
1773        );
1774    }
1775
1776    #[rstest]
1777    fn test_raw_build_jwt_uri_custom_base_url() {
1778        let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1779        client.set_base_url("http://localhost:8080".to_string());
1780        let uri = client.build_jwt_uri("POST", "/orders");
1781        assert_eq!(uri, "POST localhost:8080/api/v3/brokerage/orders");
1782    }
1783
1784    #[rstest]
1785    fn test_raw_set_base_url_safe_after_clone_via_arc() {
1786        let raw = Arc::new(
1787            CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap(),
1788        );
1789        let other = Arc::clone(&raw);
1790        // Mutating after a clone must not panic; readers see the new value
1791        raw.set_base_url("http://localhost:1234".to_string());
1792        assert!(other.build_url("/foo").starts_with("http://localhost:1234"));
1793    }
1794
1795    #[rstest]
1796    fn test_raw_auth_headers_without_credentials() {
1797        let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1798        let result = client.auth_headers("GET", "/accounts");
1799        assert!(result.is_err());
1800        assert!(result.unwrap_err().is_auth_error());
1801    }
1802
1803    #[rstest]
1804    fn test_domain_client_construction() {
1805        let client = CoinbaseHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1806        assert_eq!(client.environment(), CoinbaseEnvironment::Live);
1807        assert!(!client.is_authenticated());
1808    }
1809
1810    #[rstest]
1811    fn test_domain_client_default() {
1812        let client = CoinbaseHttpClient::default();
1813        assert_eq!(client.environment(), CoinbaseEnvironment::Live);
1814    }
1815
1816    #[rstest]
1817    fn test_domain_client_instruments_cache_empty() {
1818        let client = CoinbaseHttpClient::default();
1819        assert!(client.instruments().is_empty());
1820    }
1821
1822    #[rstest]
1823    fn test_domain_client_set_base_url() {
1824        let client = CoinbaseHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1825        let cloned = client.clone();
1826        // Mutating after a clone must not panic; both clones observe the change
1827        client.set_base_url("http://localhost:9090".to_string());
1828        let url = cloned.inner.build_url("/test");
1829        assert!(url.starts_with("http://localhost:9090"));
1830    }
1831
1832    #[rstest]
1833    fn test_encode_query_escapes_rfc3339_timestamps() {
1834        let query = encode_query(&[("start_date", "2024-01-15T10:00:00+00:00")]);
1835        // `+` must be escaped so the server does not read it as a space.
1836        assert_eq!(query, "start_date=2024-01-15T10%3A00%3A00%2B00%3A00");
1837    }
1838
1839    #[rstest]
1840    fn test_encode_query_escapes_opaque_cursor() {
1841        let query = encode_query(&[("cursor", "a/b+c=?&x")]);
1842        // Reserved characters in an opaque cursor must not leak into the query structure.
1843        assert!(!query.contains("a/b+c=?&x"));
1844        assert!(query.starts_with("cursor="));
1845    }
1846
1847    #[rstest]
1848    fn test_encode_query_joins_pairs_with_ampersand() {
1849        let query = encode_query(&[("product_id", "BTC-USD"), ("limit", "50")]);
1850        assert_eq!(query, "product_id=BTC-USD&limit=50");
1851    }
1852
1853    #[rstest]
1854    fn test_map_order_side_rejects_no_side() {
1855        assert!(matches!(
1856            map_order_side(OrderSide::Buy).unwrap(),
1857            CoinbaseOrderSide::Buy
1858        ));
1859        assert!(matches!(
1860            map_order_side(OrderSide::Sell).unwrap(),
1861            CoinbaseOrderSide::Sell
1862        ));
1863        assert!(map_order_side(OrderSide::NoOrderSide).is_err());
1864    }
1865
1866    #[rstest]
1867    fn test_build_order_configuration_market_base_size() {
1868        let cfg = build_order_configuration(
1869            OrderType::Market,
1870            OrderSide::Buy,
1871            Quantity::from("1.5"),
1872            None,
1873            None,
1874            TimeInForce::Ioc,
1875            None,
1876            false,
1877            false,
1878            false,
1879        )
1880        .unwrap();
1881
1882        match cfg {
1883            OrderConfiguration::MarketIoc(m) => {
1884                assert!(m.market_market_ioc.base_size.is_some());
1885                assert!(m.market_market_ioc.quote_size.is_none());
1886            }
1887            other => panic!("expected MarketIoc, was {other:?}"),
1888        }
1889    }
1890
1891    #[rstest]
1892    fn test_build_order_configuration_market_quote_size() {
1893        let cfg = build_order_configuration(
1894            OrderType::Market,
1895            OrderSide::Buy,
1896            Quantity::from("100"),
1897            None,
1898            None,
1899            TimeInForce::Ioc,
1900            None,
1901            false,
1902            true, // is_quote_quantity
1903            false,
1904        )
1905        .unwrap();
1906
1907        match cfg {
1908            OrderConfiguration::MarketIoc(m) => {
1909                assert!(m.market_market_ioc.quote_size.is_some());
1910                assert!(m.market_market_ioc.base_size.is_none());
1911            }
1912            other => panic!("expected MarketIoc, was {other:?}"),
1913        }
1914    }
1915
1916    #[rstest]
1917    fn test_build_order_configuration_market_fok() {
1918        let cfg = build_order_configuration(
1919            OrderType::Market,
1920            OrderSide::Buy,
1921            Quantity::from("0.5"),
1922            None,
1923            None,
1924            TimeInForce::Fok,
1925            None,
1926            false,
1927            false,
1928            false,
1929        )
1930        .unwrap();
1931
1932        match cfg {
1933            OrderConfiguration::MarketFok(m) => {
1934                assert!(m.market_market_fok.base_size.is_some());
1935                assert!(m.market_market_fok.quote_size.is_none());
1936            }
1937            other => panic!("expected MarketFok, was {other:?}"),
1938        }
1939    }
1940
1941    #[rstest]
1942    #[case(TimeInForce::Day)]
1943    #[case(TimeInForce::Gtd)]
1944    fn test_build_order_configuration_market_rejects_unsupported_tif(#[case] tif: TimeInForce) {
1945        let result = build_order_configuration(
1946            OrderType::Market,
1947            OrderSide::Buy,
1948            Quantity::from("1"),
1949            None,
1950            None,
1951            tif,
1952            None,
1953            false,
1954            false,
1955            false,
1956        );
1957        assert!(result.is_err());
1958    }
1959
1960    #[rstest]
1961    fn test_build_order_configuration_limit_gtc_post_only() {
1962        let cfg = build_order_configuration(
1963            OrderType::Limit,
1964            OrderSide::Sell,
1965            Quantity::from("0.5"),
1966            Some(Price::from("50000.00")),
1967            None,
1968            TimeInForce::Gtc,
1969            None,
1970            true,
1971            false,
1972            false,
1973        )
1974        .unwrap();
1975
1976        match cfg {
1977            OrderConfiguration::LimitGtc(l) => assert!(l.limit_limit_gtc.post_only),
1978            other => panic!("expected LimitGtc, was {other:?}"),
1979        }
1980    }
1981
1982    #[rstest]
1983    fn test_build_order_configuration_limit_gtd_requires_expire_time() {
1984        let result = build_order_configuration(
1985            OrderType::Limit,
1986            OrderSide::Buy,
1987            Quantity::from("1"),
1988            Some(Price::from("100.00")),
1989            None,
1990            TimeInForce::Gtd,
1991            None,
1992            false,
1993            false,
1994            false,
1995        );
1996        assert!(result.is_err());
1997    }
1998
1999    #[rstest]
2000    fn test_build_order_configuration_stop_limit_uses_correct_direction() {
2001        let buy_cfg = build_order_configuration(
2002            OrderType::StopLimit,
2003            OrderSide::Buy,
2004            Quantity::from("1"),
2005            Some(Price::from("100.00")),
2006            Some(Price::from("99.00")),
2007            TimeInForce::Gtc,
2008            None,
2009            false,
2010            false,
2011            false,
2012        )
2013        .unwrap();
2014
2015        match buy_cfg {
2016            OrderConfiguration::StopLimitGtc(s) => assert_eq!(
2017                s.stop_limit_stop_limit_gtc.stop_direction,
2018                CoinbaseStopDirection::StopUp
2019            ),
2020            other => panic!("expected StopLimitGtc, was {other:?}"),
2021        }
2022
2023        let sell_cfg = build_order_configuration(
2024            OrderType::StopLimit,
2025            OrderSide::Sell,
2026            Quantity::from("1"),
2027            Some(Price::from("100.00")),
2028            Some(Price::from("99.00")),
2029            TimeInForce::Gtc,
2030            None,
2031            false,
2032            false,
2033            false,
2034        )
2035        .unwrap();
2036
2037        match sell_cfg {
2038            OrderConfiguration::StopLimitGtc(s) => assert_eq!(
2039                s.stop_limit_stop_limit_gtc.stop_direction,
2040                CoinbaseStopDirection::StopDown
2041            ),
2042            other => panic!("expected StopLimitGtc, was {other:?}"),
2043        }
2044    }
2045
2046    #[rstest]
2047    fn test_build_order_configuration_market_accepts_default_gtc() {
2048        // Nautilus orders default to GTC; coerce to MARKET IOC silently for
2049        // the default case but not for any explicit non-IOC TIF.
2050        let cfg = build_order_configuration(
2051            OrderType::Market,
2052            OrderSide::Buy,
2053            Quantity::from("1"),
2054            None,
2055            None,
2056            TimeInForce::Gtc,
2057            None,
2058            false,
2059            false,
2060            false,
2061        )
2062        .unwrap();
2063        assert!(matches!(cfg, OrderConfiguration::MarketIoc(_)));
2064    }
2065
2066    #[rstest]
2067    fn test_build_order_configuration_rejects_stop_market() {
2068        let result = build_order_configuration(
2069            OrderType::StopMarket,
2070            OrderSide::Buy,
2071            Quantity::from("1"),
2072            None,
2073            Some(Price::from("100.00")),
2074            TimeInForce::Gtc,
2075            None,
2076            false,
2077            false,
2078            false,
2079        );
2080        assert!(result.is_err());
2081    }
2082
2083    #[rstest]
2084    fn test_rest_quota_matches_documented_limit() {
2085        assert_eq!(COINBASE_REST_QUOTA.burst_size().get(), 30);
2086    }
2087
2088    #[rstest]
2089    fn test_default_retry_config_values() {
2090        let config = default_retry_config();
2091        assert_eq!(config.max_retries, 3);
2092        assert_eq!(config.initial_delay_ms, 100);
2093        assert_eq!(config.max_delay_ms, 5_000);
2094        assert_eq!(config.max_elapsed_ms, Some(180_000));
2095    }
2096}