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