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 an error 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 an error 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 an error 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 an error 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 if the HTTP request fails or the response cannot be 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 if the HTTP request fails, deserialization fails, or the product cannot be
1068    /// 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 if the HTTP request fails or the response cannot be deserialized.
1094    pub async fn request_raw_product(
1095        &self,
1096        product_id: &str,
1097    ) -> anyhow::Result<crate::http::models::Product> {
1098        let json = self
1099            .inner
1100            .get_product(product_id)
1101            .await
1102            .map_err(|e| anyhow::anyhow!("Failed to fetch product '{product_id}': {e}"))?;
1103        serde_json::from_value(json).map_err(|e| anyhow::anyhow!(e))
1104    }
1105
1106    /// Requests the current account state.
1107    ///
1108    /// Builds a cash-type [`AccountState`] from `/accounts` with one balance
1109    /// per currency. Follows Coinbase's cursor pagination so multi-wallet
1110    /// accounts are reported in full. `reported` is set to `true` since the
1111    /// values come from the venue.
1112    ///
1113    /// # Errors
1114    ///
1115    /// Returns an error if the HTTP request fails or the response cannot be parsed.
1116    pub async fn request_account_state(
1117        &self,
1118        account_id: AccountId,
1119    ) -> anyhow::Result<AccountState> {
1120        let accounts = self
1121            .inner
1122            .fetch_all_accounts()
1123            .await
1124            .map_err(|e| anyhow::anyhow!("Failed to fetch accounts: {e}"))?;
1125        let ts_event = self.ts_now();
1126        parse_account_state(&accounts, account_id, true, ts_event, ts_event)
1127    }
1128
1129    /// Requests a single order status report by venue or client order ID.
1130    ///
1131    /// Resolves venue order IDs first via `/orders/historical/{id}`. When only a
1132    /// `client_order_id` is provided, paginates the order history filtered to
1133    /// that client ID.
1134    ///
1135    /// # Errors
1136    ///
1137    /// Returns an error if the HTTP request fails, the order cannot be found, or the response
1138    /// cannot be parsed.
1139    pub async fn request_order_status_report(
1140        &self,
1141        account_id: AccountId,
1142        client_order_id: Option<ClientOrderId>,
1143        venue_order_id: Option<VenueOrderId>,
1144    ) -> anyhow::Result<OrderStatusReport> {
1145        let venue_order_id = match (venue_order_id, client_order_id) {
1146            (Some(vid), _) => vid,
1147            (None, Some(cid)) => {
1148                // Fall back to batched query when only the client order ID is known
1149                let query = OrderListQuery {
1150                    client_order_id_filter: Some(cid.as_str().to_string()),
1151                    ..Default::default()
1152                };
1153                let orders = self
1154                    .inner
1155                    .fetch_all_orders(&query)
1156                    .await
1157                    .map_err(|e| anyhow::anyhow!("Failed to fetch orders: {e}"))?;
1158                let order = orders
1159                    .into_iter()
1160                    .next()
1161                    .ok_or_else(|| anyhow::anyhow!("No order found for client_order_id={cid}"))?;
1162                let instrument = self.get_or_fetch_instrument(order.product_id).await?;
1163                let ts_init = self.ts_now();
1164                return parse_order_status_report(&order, &instrument, account_id, ts_init);
1165            }
1166            (None, None) => {
1167                anyhow::bail!("Either client_order_id or venue_order_id is required")
1168            }
1169        };
1170
1171        let json = self
1172            .inner
1173            .get_order(venue_order_id.as_str())
1174            .await
1175            .map_err(|e| anyhow::anyhow!("Failed to fetch order: {e}"))?;
1176        let response: OrderResponse =
1177            serde_json::from_value(json).map_err(|e| anyhow::anyhow!(e))?;
1178        let instrument = self
1179            .get_or_fetch_instrument(response.order.product_id)
1180            .await?;
1181        let ts_init = self.ts_now();
1182        parse_order_status_report(&response.order, &instrument, account_id, ts_init)
1183    }
1184
1185    /// Requests order status reports, optionally filtered by instrument, open
1186    /// status, and time window.
1187    ///
1188    /// # Errors
1189    ///
1190    /// Returns an error if the HTTP request fails or when any response cannot be deserialized.
1191    pub async fn request_order_status_reports(
1192        &self,
1193        account_id: AccountId,
1194        instrument_id: Option<InstrumentId>,
1195        open_only: bool,
1196        start: Option<Timestamp>,
1197        end: Option<Timestamp>,
1198        limit: Option<u32>,
1199    ) -> anyhow::Result<Vec<OrderStatusReport>> {
1200        let query = OrderListQuery {
1201            product_id: instrument_id.map(|id| id.symbol.as_str().to_string()),
1202            open_only,
1203            start,
1204            end,
1205            limit,
1206            client_order_id_filter: None,
1207        };
1208
1209        let orders = self
1210            .inner
1211            .fetch_all_orders(&query)
1212            .await
1213            .map_err(|e| anyhow::anyhow!("Failed to fetch orders: {e}"))?;
1214
1215        let ts_init = self.ts_now();
1216        let mut reports = Vec::with_capacity(orders.len());
1217
1218        for order in &orders {
1219            let instrument = match self.get_or_fetch_instrument(order.product_id).await {
1220                Ok(inst) => inst,
1221                Err(e) => {
1222                    log::debug!("Skipping order {}: {e}", order.order_id);
1223                    continue;
1224                }
1225            };
1226
1227            match parse_order_status_report(order, &instrument, account_id, ts_init) {
1228                Ok(report) => reports.push(report),
1229                Err(e) => log::warn!("Failed to parse order {}: {e}", order.order_id),
1230            }
1231        }
1232
1233        Ok(reports)
1234    }
1235
1236    /// Requests fill reports, optionally filtered by instrument, venue order ID,
1237    /// and time window.
1238    ///
1239    /// # Errors
1240    ///
1241    /// Returns an error if the HTTP request fails or the response cannot be deserialized.
1242    pub async fn request_fill_reports(
1243        &self,
1244        account_id: AccountId,
1245        instrument_id: Option<InstrumentId>,
1246        venue_order_id: Option<VenueOrderId>,
1247        start: Option<Timestamp>,
1248        end: Option<Timestamp>,
1249        limit: Option<u32>,
1250    ) -> anyhow::Result<Vec<FillReport>> {
1251        let query = FillListQuery {
1252            product_id: instrument_id.map(|id| id.symbol.as_str().to_string()),
1253            venue_order_id: venue_order_id.map(|id| id.as_str().to_string()),
1254            start,
1255            end,
1256            limit,
1257        };
1258
1259        let fills = self
1260            .inner
1261            .fetch_all_fills(&query)
1262            .await
1263            .map_err(|e| anyhow::anyhow!("Failed to fetch fills: {e}"))?;
1264
1265        let ts_init = self.ts_now();
1266        let mut reports = Vec::with_capacity(fills.len());
1267
1268        for fill in &fills {
1269            let instrument = match self.get_or_fetch_instrument(fill.product_id).await {
1270                Ok(inst) => inst,
1271                Err(e) => {
1272                    log::debug!("Skipping fill {}: {e}", fill.trade_id);
1273                    continue;
1274                }
1275            };
1276
1277            match parse_fill_report(fill, &instrument, account_id, ts_init) {
1278                Ok(report) => reports.push(report),
1279                Err(e) => log::warn!("Failed to parse fill {}: {e}", fill.trade_id),
1280            }
1281        }
1282
1283        Ok(reports)
1284    }
1285
1286    /// Caches an instrument in the shared instrument map.
1287    pub fn cache_instrument(&self, instrument: &InstrumentAny) {
1288        self.instruments.rcu(|m| {
1289            m.insert(instrument.id(), instrument.clone());
1290        });
1291    }
1292
1293    /// Caches a batch of instruments in the shared instrument map.
1294    pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
1295        self.instruments.rcu(|m| {
1296            for instrument in instruments {
1297                m.insert(instrument.id(), instrument.clone());
1298            }
1299        });
1300    }
1301
1302    /// Records `product_id -> alias` entries for any product whose `alias`
1303    /// field is non-empty. Coinbase aliases pairs to a canonical id (e.g.
1304    /// `BTC-USDC -> BTC-USD`) that the WebSocket and user channel use on the
1305    /// wire even when callers operate on the alias side.
1306    pub fn record_product_aliases(&self, products: &[crate::http::models::Product]) {
1307        let aliased: Vec<(Ustr, Ustr)> = products
1308            .iter()
1309            .filter(|p| !p.alias.is_empty())
1310            .map(|p| (p.product_id, p.alias))
1311            .collect();
1312
1313        if aliased.is_empty() {
1314            return;
1315        }
1316
1317        self.product_aliases.rcu(|m| {
1318            for (product_id, alias) in &aliased {
1319                m.insert(*product_id, *alias);
1320            }
1321        });
1322    }
1323
1324    // Returns the cached instrument for a product ID, fetching it on miss.
1325    // Order and fill reconciliation calls parse hundreds of historical
1326    // records and each one needs precision metadata. Rather than forcing
1327    // callers to bootstrap the full instrument universe first, this lazy
1328    // path fetches any missing product via `/products/{id}` and caches it.
1329    async fn get_or_fetch_instrument(&self, product_id: Ustr) -> anyhow::Result<InstrumentAny> {
1330        let instrument_id = InstrumentId::new(
1331            Symbol::new(product_id),
1332            *crate::common::consts::COINBASE_VENUE,
1333        );
1334
1335        if let Some(instrument) = self.instruments.get_cloned(&instrument_id) {
1336            return Ok(instrument);
1337        }
1338        // Cache miss: fetch and cache the single product. Any parse error
1339        // (unsupported product type, missing fields) surfaces to the caller so
1340        // the offending record can be skipped with a log.
1341        self.request_instrument(product_id.as_str()).await
1342    }
1343
1344    /// Submits a new order built from Nautilus domain types.
1345    ///
1346    /// Maps the order side, order type, and time-in-force to Coinbase's
1347    /// `order_configuration` shape and posts to `/orders`. Returns the
1348    /// venue's create-order response; callers inspect `success` and the
1349    /// success/error response variants.
1350    ///
1351    /// # Errors
1352    ///
1353    /// Returns an error if the order parameters cannot be mapped to a supported Coinbase
1354    /// configuration, when the HTTP request fails, or when the response cannot be parsed.
1355    #[allow(clippy::too_many_arguments)]
1356    pub async fn submit_order(
1357        &self,
1358        client_order_id: ClientOrderId,
1359        instrument_id: InstrumentId,
1360        side: OrderSide,
1361        order_type: OrderType,
1362        quantity: Quantity,
1363        time_in_force: TimeInForce,
1364        price: Option<Price>,
1365        trigger_price: Option<Price>,
1366        expire_time: Option<UnixNanos>,
1367        post_only: bool,
1368        is_quote_quantity: bool,
1369        leverage: Option<Decimal>,
1370        margin_type: Option<CoinbaseMarginType>,
1371        reduce_only: bool,
1372        retail_portfolio_id: Option<String>,
1373    ) -> anyhow::Result<CreateOrderResponse> {
1374        let coinbase_side = map_order_side(side);
1375        let order_config = build_order_configuration(
1376            order_type,
1377            side,
1378            quantity,
1379            price,
1380            trigger_price,
1381            time_in_force,
1382            expire_time,
1383            post_only,
1384            is_quote_quantity,
1385            reduce_only,
1386        )?;
1387
1388        let request = CreateOrderRequest {
1389            client_order_id: client_order_id.to_string(),
1390            product_id: instrument_id.symbol.inner(),
1391            side: coinbase_side,
1392            order_configuration: order_config,
1393            self_trade_prevention_id: None,
1394            leverage: leverage.map(|d| d.normalize().to_string()),
1395            margin_type,
1396            retail_portfolio_id,
1397        };
1398
1399        self.inner
1400            .create_order(&request)
1401            .await
1402            .context("failed to submit order")
1403    }
1404
1405    /// Cancels one or more orders by venue order ID via batch_cancel.
1406    ///
1407    /// # Errors
1408    ///
1409    /// Returns an error if the HTTP request fails or the response cannot be parsed.
1410    pub async fn cancel_orders(
1411        &self,
1412        venue_order_ids: &[VenueOrderId],
1413    ) -> anyhow::Result<CancelOrdersResponse> {
1414        let request = CancelOrdersRequest {
1415            order_ids: venue_order_ids
1416                .iter()
1417                .map(|id| id.as_str().to_string())
1418                .collect(),
1419        };
1420        self.inner
1421            .cancel_orders(&request)
1422            .await
1423            .context("failed to cancel orders")
1424    }
1425
1426    /// Fetches the CFM (futures) balance summary.
1427    ///
1428    /// # Errors
1429    ///
1430    /// Returns an error if the HTTP request fails or the response cannot be deserialized.
1431    pub async fn request_cfm_balance_summary(&self) -> anyhow::Result<CfmBalanceSummary> {
1432        let response = self
1433            .inner
1434            .get_cfm_balance_summary()
1435            .await
1436            .map_err(|e| anyhow::anyhow!("Failed to fetch CFM balance summary: {e}"))?;
1437        Ok(response.balance_summary)
1438    }
1439
1440    /// Fetches margin balances derived from the CFM balance summary.
1441    ///
1442    /// # Errors
1443    ///
1444    /// Returns an error if the summary cannot be fetched or when a balance cannot be constructed.
1445    pub async fn request_cfm_margin_balances(&self) -> anyhow::Result<Vec<MarginBalance>> {
1446        let summary = self.request_cfm_balance_summary().await?;
1447        parse_cfm_margin_balances(&summary)
1448    }
1449
1450    /// Fetches a margin [`AccountState`] derived from the CFM balance summary.
1451    ///
1452    /// # Errors
1453    ///
1454    /// Returns an error if the summary cannot be fetched or when balances cannot be constructed.
1455    pub async fn request_cfm_account_state(
1456        &self,
1457        account_id: AccountId,
1458    ) -> anyhow::Result<AccountState> {
1459        let summary = self.request_cfm_balance_summary().await?;
1460        let ts_event = self.ts_now();
1461        parse_cfm_account_state(&summary, account_id, true, ts_event, ts_event)
1462    }
1463
1464    /// Fetches all CFM futures positions and returns Nautilus position reports.
1465    ///
1466    /// # Errors
1467    ///
1468    /// Returns an error if the HTTP request fails or a position cannot be parsed.
1469    pub async fn request_position_status_reports(
1470        &self,
1471        account_id: AccountId,
1472    ) -> anyhow::Result<Vec<PositionStatusReport>> {
1473        let response = self
1474            .inner
1475            .get_cfm_positions()
1476            .await
1477            .map_err(|e| anyhow::anyhow!("Failed to fetch CFM positions: {e}"))?;
1478
1479        let ts_init = self.ts_now();
1480        let mut reports = Vec::with_capacity(response.positions.len());
1481
1482        for position in &response.positions {
1483            let instrument = match self.get_or_fetch_instrument(position.product_id).await {
1484                Ok(inst) => inst,
1485                Err(e) => {
1486                    log::debug!("Skipping CFM position {}: {e}", position.product_id);
1487                    continue;
1488                }
1489            };
1490
1491            match parse_cfm_position_status_report(position, &instrument, account_id, ts_init) {
1492                Ok(report) => reports.push(report),
1493                Err(e) => log::warn!("Failed to parse CFM position {}: {e}", position.product_id),
1494            }
1495        }
1496
1497        Ok(reports)
1498    }
1499
1500    /// Fetches a single CFM futures position and returns a position status
1501    /// report when the venue reports a non-flat position.
1502    ///
1503    /// # Errors
1504    ///
1505    /// Returns an error if the HTTP request fails or the position cannot be parsed.
1506    pub async fn request_position_status_report(
1507        &self,
1508        account_id: AccountId,
1509        instrument_id: InstrumentId,
1510    ) -> anyhow::Result<Option<PositionStatusReport>> {
1511        let product_id = instrument_id.symbol.as_str();
1512        let response = self
1513            .inner
1514            .get_cfm_position(product_id)
1515            .await
1516            .map_err(|e| anyhow::anyhow!("Failed to fetch CFM position '{product_id}': {e}"))?;
1517
1518        let instrument = self
1519            .get_or_fetch_instrument(response.position.product_id)
1520            .await?;
1521        let ts_init = self.ts_now();
1522        let report =
1523            parse_cfm_position_status_report(&response.position, &instrument, account_id, ts_init)?;
1524        Ok(Some(report))
1525    }
1526
1527    /// Modifies an existing GTC order's price, size, or stop price.
1528    ///
1529    /// Coinbase's `/orders/edit` endpoint is documented to accept edits on
1530    /// these fields for supported order configurations (primarily LIMIT
1531    /// GTC). At least one of `price`, `quantity`, or `trigger_price` must
1532    /// be supplied.
1533    ///
1534    /// # Errors
1535    ///
1536    /// Returns an error if the HTTP request fails or the response cannot be deserialized.
1537    pub async fn modify_order(
1538        &self,
1539        venue_order_id: VenueOrderId,
1540        price: Option<Price>,
1541        quantity: Option<Quantity>,
1542        trigger_price: Option<Price>,
1543    ) -> anyhow::Result<EditOrderResponse> {
1544        let request = EditOrderRequest {
1545            order_id: venue_order_id.as_str().to_string(),
1546            price: price.map(|p| p.to_string()),
1547            size: quantity.map(|q| q.to_string()),
1548            stop_price: trigger_price.map(|p| p.to_string()),
1549        };
1550        self.inner
1551            .edit_order(&request)
1552            .await
1553            .context("failed to edit order")
1554    }
1555}
1556
1557/// Maps a Nautilus [`OrderSide`] to Coinbase's wire enum.
1558pub fn map_order_side(side: OrderSide) -> CoinbaseOrderSide {
1559    match side {
1560        OrderSide::Buy => CoinbaseOrderSide::Buy,
1561        OrderSide::Sell => CoinbaseOrderSide::Sell,
1562    }
1563}
1564
1565/// Builds the Coinbase [`OrderConfiguration`] payload from Nautilus order
1566/// parameters.
1567///
1568/// Caller supplies the order type, side, quantity, optional price/trigger,
1569/// time-in-force, optional expire time (required for GTD), `post_only`
1570/// flag, and whether the quantity is denominated in the quote currency
1571/// (only meaningful for MARKET orders).
1572///
1573/// # Errors
1574///
1575/// Returns an error if the requested combination is not supported by Coinbase (e.g. STOP_MARKET,
1576/// IOC LIMIT, missing required field).
1577#[allow(clippy::too_many_arguments)]
1578pub fn build_order_configuration(
1579    order_type: OrderType,
1580    side: OrderSide,
1581    quantity: Quantity,
1582    price: Option<Price>,
1583    trigger_price: Option<Price>,
1584    time_in_force: TimeInForce,
1585    expire_time: Option<UnixNanos>,
1586    post_only: bool,
1587    is_quote_quantity: bool,
1588    reduce_only: bool,
1589) -> anyhow::Result<OrderConfiguration> {
1590    let qty = quantity.as_decimal();
1591    let price = price.map(|p| p.as_decimal());
1592    let trigger = trigger_price.map(|p| p.as_decimal());
1593
1594    anyhow::ensure!(
1595        !reduce_only,
1596        "Reduce-only orders are not supported by Coinbase Advanced Trade"
1597    );
1598
1599    match order_type {
1600        OrderType::Market => {
1601            // Coinbase exposes `market_market_ioc` and `market_market_fok` for
1602            // MARKET orders. Nautilus' default GTC is mapped to IOC (mirroring
1603            // the Bybit adapter pattern); explicit IOC and FOK are honored;
1604            // DAY / GTD are rejected.
1605            //
1606            // Note: a MARKET order built with TIF=GTC will execute as IOC at
1607            // Coinbase. Backtest replays of the same order through the
1608            // matching engine treat it differently. Strategies that need
1609            // strict backtest/live parity should construct MarketOrders with
1610            // TIF=IOC or TIF=FOK explicitly.
1611            let params = if is_quote_quantity {
1612                MarketParams {
1613                    quote_size: Some(qty),
1614                    base_size: None,
1615                }
1616            } else {
1617                MarketParams {
1618                    quote_size: None,
1619                    base_size: Some(qty),
1620                }
1621            };
1622
1623            match time_in_force {
1624                TimeInForce::Ioc | TimeInForce::Gtc => {
1625                    Ok(OrderConfiguration::MarketIoc(MarketIoc {
1626                        market_market_ioc: params,
1627                    }))
1628                }
1629                TimeInForce::Fok => Ok(OrderConfiguration::MarketFok(MarketFok {
1630                    market_market_fok: params,
1631                })),
1632                _ => {
1633                    anyhow::bail!(
1634                        "Unsupported TIF {time_in_force} for MARKET on Coinbase (use IOC or FOK)"
1635                    )
1636                }
1637            }
1638        }
1639        OrderType::Limit => {
1640            let limit_price =
1641                price.ok_or_else(|| anyhow::anyhow!("LIMIT order requires a price"))?;
1642
1643            match time_in_force {
1644                TimeInForce::Gtc => Ok(OrderConfiguration::LimitGtc(LimitGtc {
1645                    limit_limit_gtc: LimitGtcParams {
1646                        base_size: qty,
1647                        limit_price,
1648                        post_only,
1649                    },
1650                })),
1651                TimeInForce::Gtd => {
1652                    let expire = expire_time
1653                        .ok_or_else(|| anyhow::anyhow!("GTD LIMIT requires expire_time"))?;
1654                    Ok(OrderConfiguration::LimitGtd(LimitGtd {
1655                        limit_limit_gtd: LimitGtdParams {
1656                            base_size: qty,
1657                            limit_price,
1658                            end_time: format_rfc3339_from_nanos(expire)?,
1659                            post_only,
1660                        },
1661                    }))
1662                }
1663                TimeInForce::Fok => Ok(OrderConfiguration::LimitFok(LimitFok {
1664                    limit_limit_fok: LimitFokParams {
1665                        base_size: qty,
1666                        limit_price,
1667                    },
1668                })),
1669                _ => anyhow::bail!("Unsupported TIF {time_in_force} for LIMIT on Coinbase"),
1670            }
1671        }
1672        OrderType::StopLimit => {
1673            let limit_price =
1674                price.ok_or_else(|| anyhow::anyhow!("STOP_LIMIT order requires a price"))?;
1675            let stop_price = trigger
1676                .ok_or_else(|| anyhow::anyhow!("STOP_LIMIT order requires trigger_price"))?;
1677            let direction = match side {
1678                OrderSide::Buy => CoinbaseStopDirection::StopUp,
1679                OrderSide::Sell => CoinbaseStopDirection::StopDown,
1680            };
1681
1682            match time_in_force {
1683                TimeInForce::Gtc => Ok(OrderConfiguration::StopLimitGtc(StopLimitGtc {
1684                    stop_limit_stop_limit_gtc: StopLimitGtcParams {
1685                        base_size: qty,
1686                        limit_price,
1687                        stop_price,
1688                        stop_direction: direction,
1689                    },
1690                })),
1691                TimeInForce::Gtd => {
1692                    let expire = expire_time
1693                        .ok_or_else(|| anyhow::anyhow!("GTD STOP_LIMIT requires expire_time"))?;
1694                    Ok(OrderConfiguration::StopLimitGtd(StopLimitGtd {
1695                        stop_limit_stop_limit_gtd: StopLimitGtdParams {
1696                            base_size: qty,
1697                            limit_price,
1698                            stop_price,
1699                            stop_direction: direction,
1700                            end_time: format_rfc3339_from_nanos(expire)?,
1701                        },
1702                    }))
1703                }
1704                _ => anyhow::bail!("Unsupported TIF {time_in_force} for STOP_LIMIT on Coinbase"),
1705            }
1706        }
1707        other => anyhow::bail!("Unsupported order type for Coinbase: {other}"),
1708    }
1709}
1710
1711#[cfg(test)]
1712mod tests {
1713    use nautilus_testkit::http::assert_http_redirect_rejected;
1714    use rstest::rstest;
1715
1716    use super::*;
1717
1718    #[tokio::test]
1719    async fn test_authenticated_client_rejects_redirects() {
1720        let client = CoinbaseRawHttpClient::with_credentials(
1721            CoinbaseCredential::new("key".into(), "secret".into()),
1722            CoinbaseEnvironment::Sandbox,
1723            3,
1724            None,
1725            None,
1726        )
1727        .unwrap()
1728        .client;
1729        assert_http_redirect_rejected(|url| async move {
1730            client
1731                .get(url, None, None, Some(3), None)
1732                .await
1733                .unwrap()
1734                .status
1735                .as_u16()
1736        })
1737        .await;
1738    }
1739
1740    #[rstest]
1741    fn test_raw_client_construction_live() {
1742        let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1743        assert_eq!(client.environment(), CoinbaseEnvironment::Live);
1744        assert!(!client.is_authenticated());
1745    }
1746
1747    #[rstest]
1748    fn test_raw_client_construction_sandbox() {
1749        let client =
1750            CoinbaseRawHttpClient::new(CoinbaseEnvironment::Sandbox, 10, None, None).unwrap();
1751        assert_eq!(client.environment(), CoinbaseEnvironment::Sandbox);
1752    }
1753
1754    #[rstest]
1755    fn test_raw_build_url() {
1756        let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1757        let url = client.build_url("/products");
1758        assert_eq!(url, "https://api.coinbase.com/api/v3/brokerage/products");
1759    }
1760
1761    #[rstest]
1762    fn test_raw_build_jwt_uri_live() {
1763        let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1764        let uri = client.build_jwt_uri("GET", "/accounts");
1765        assert_eq!(uri, "GET api.coinbase.com/api/v3/brokerage/accounts");
1766    }
1767
1768    #[rstest]
1769    fn test_raw_build_jwt_uri_sandbox() {
1770        let client =
1771            CoinbaseRawHttpClient::new(CoinbaseEnvironment::Sandbox, 10, None, None).unwrap();
1772        let uri = client.build_jwt_uri("GET", "/accounts");
1773        assert_eq!(
1774            uri,
1775            "GET api-sandbox.coinbase.com/api/v3/brokerage/accounts"
1776        );
1777    }
1778
1779    #[rstest]
1780    fn test_raw_build_jwt_uri_custom_base_url() {
1781        let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1782        client.set_base_url("http://localhost:8080".to_string());
1783        let uri = client.build_jwt_uri("POST", "/orders");
1784        assert_eq!(uri, "POST localhost:8080/api/v3/brokerage/orders");
1785    }
1786
1787    #[rstest]
1788    fn test_raw_set_base_url_safe_after_clone_via_arc() {
1789        let raw = Arc::new(
1790            CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap(),
1791        );
1792        let other = Arc::clone(&raw);
1793        // Mutating after a clone must not panic; readers see the new value
1794        raw.set_base_url("http://localhost:1234".to_string());
1795        assert!(other.build_url("/foo").starts_with("http://localhost:1234"));
1796    }
1797
1798    #[rstest]
1799    fn test_raw_auth_headers_without_credentials() {
1800        let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1801        let result = client.auth_headers("GET", "/accounts");
1802        assert!(result.is_err());
1803        assert!(result.unwrap_err().is_auth_error());
1804    }
1805
1806    #[rstest]
1807    fn test_domain_client_construction() {
1808        let client = CoinbaseHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1809        assert_eq!(client.environment(), CoinbaseEnvironment::Live);
1810        assert!(!client.is_authenticated());
1811    }
1812
1813    #[rstest]
1814    fn test_domain_client_default() {
1815        let client = CoinbaseHttpClient::default();
1816        assert_eq!(client.environment(), CoinbaseEnvironment::Live);
1817    }
1818
1819    #[rstest]
1820    fn test_domain_client_instruments_cache_empty() {
1821        let client = CoinbaseHttpClient::default();
1822        assert!(client.instruments().is_empty());
1823    }
1824
1825    #[rstest]
1826    fn test_domain_client_set_base_url() {
1827        let client = CoinbaseHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1828        let cloned = client.clone();
1829        // Mutating after a clone must not panic; both clones observe the change
1830        client.set_base_url("http://localhost:9090".to_string());
1831        let url = cloned.inner.build_url("/test");
1832        assert!(url.starts_with("http://localhost:9090"));
1833    }
1834
1835    #[rstest]
1836    fn test_encode_query_escapes_rfc3339_timestamps() {
1837        let query = encode_query(&[("start_date", "2024-01-15T10:00:00+00:00")]);
1838        // `+` must be escaped so the server does not read it as a space.
1839        assert_eq!(query, "start_date=2024-01-15T10%3A00%3A00%2B00%3A00");
1840    }
1841
1842    #[rstest]
1843    fn test_encode_query_escapes_opaque_cursor() {
1844        let query = encode_query(&[("cursor", "a/b+c=?&x")]);
1845        // Reserved characters in an opaque cursor must not leak into the query structure.
1846        assert!(!query.contains("a/b+c=?&x"));
1847        assert!(query.starts_with("cursor="));
1848    }
1849
1850    #[rstest]
1851    fn test_encode_query_joins_pairs_with_ampersand() {
1852        let query = encode_query(&[("product_id", "BTC-USD"), ("limit", "50")]);
1853        assert_eq!(query, "product_id=BTC-USD&limit=50");
1854    }
1855
1856    #[rstest]
1857    fn test_map_order_side() {
1858        assert!(matches!(
1859            map_order_side(OrderSide::Buy),
1860            CoinbaseOrderSide::Buy
1861        ));
1862        assert!(matches!(
1863            map_order_side(OrderSide::Sell),
1864            CoinbaseOrderSide::Sell
1865        ));
1866    }
1867
1868    #[rstest]
1869    fn test_build_order_configuration_market_base_size() {
1870        let cfg = build_order_configuration(
1871            OrderType::Market,
1872            OrderSide::Buy,
1873            Quantity::from("1.5"),
1874            None,
1875            None,
1876            TimeInForce::Ioc,
1877            None,
1878            false,
1879            false,
1880            false,
1881        )
1882        .unwrap();
1883
1884        match cfg {
1885            OrderConfiguration::MarketIoc(m) => {
1886                assert!(m.market_market_ioc.base_size.is_some());
1887                assert!(m.market_market_ioc.quote_size.is_none());
1888            }
1889            other => panic!("expected MarketIoc, was {other:?}"),
1890        }
1891    }
1892
1893    #[rstest]
1894    fn test_build_order_configuration_market_quote_size() {
1895        let cfg = build_order_configuration(
1896            OrderType::Market,
1897            OrderSide::Buy,
1898            Quantity::from("100"),
1899            None,
1900            None,
1901            TimeInForce::Ioc,
1902            None,
1903            false,
1904            true, // is_quote_quantity
1905            false,
1906        )
1907        .unwrap();
1908
1909        match cfg {
1910            OrderConfiguration::MarketIoc(m) => {
1911                assert!(m.market_market_ioc.quote_size.is_some());
1912                assert!(m.market_market_ioc.base_size.is_none());
1913            }
1914            other => panic!("expected MarketIoc, was {other:?}"),
1915        }
1916    }
1917
1918    #[rstest]
1919    fn test_build_order_configuration_market_fok() {
1920        let cfg = build_order_configuration(
1921            OrderType::Market,
1922            OrderSide::Buy,
1923            Quantity::from("0.5"),
1924            None,
1925            None,
1926            TimeInForce::Fok,
1927            None,
1928            false,
1929            false,
1930            false,
1931        )
1932        .unwrap();
1933
1934        match cfg {
1935            OrderConfiguration::MarketFok(m) => {
1936                assert!(m.market_market_fok.base_size.is_some());
1937                assert!(m.market_market_fok.quote_size.is_none());
1938            }
1939            other => panic!("expected MarketFok, was {other:?}"),
1940        }
1941    }
1942
1943    #[rstest]
1944    #[case(TimeInForce::Day)]
1945    #[case(TimeInForce::Gtd)]
1946    fn test_build_order_configuration_market_rejects_unsupported_tif(#[case] tif: TimeInForce) {
1947        let result = build_order_configuration(
1948            OrderType::Market,
1949            OrderSide::Buy,
1950            Quantity::from("1"),
1951            None,
1952            None,
1953            tif,
1954            None,
1955            false,
1956            false,
1957            false,
1958        );
1959        assert!(result.is_err());
1960    }
1961
1962    #[rstest]
1963    fn test_build_order_configuration_limit_gtc_post_only() {
1964        let cfg = build_order_configuration(
1965            OrderType::Limit,
1966            OrderSide::Sell,
1967            Quantity::from("0.5"),
1968            Some(Price::from("50000.00")),
1969            None,
1970            TimeInForce::Gtc,
1971            None,
1972            true,
1973            false,
1974            false,
1975        )
1976        .unwrap();
1977
1978        match cfg {
1979            OrderConfiguration::LimitGtc(l) => assert!(l.limit_limit_gtc.post_only),
1980            other => panic!("expected LimitGtc, was {other:?}"),
1981        }
1982    }
1983
1984    #[rstest]
1985    fn test_build_order_configuration_limit_gtd_requires_expire_time() {
1986        let result = build_order_configuration(
1987            OrderType::Limit,
1988            OrderSide::Buy,
1989            Quantity::from("1"),
1990            Some(Price::from("100.00")),
1991            None,
1992            TimeInForce::Gtd,
1993            None,
1994            false,
1995            false,
1996            false,
1997        );
1998        assert!(result.is_err());
1999    }
2000
2001    #[rstest]
2002    fn test_build_order_configuration_stop_limit_uses_correct_direction() {
2003        let buy_cfg = build_order_configuration(
2004            OrderType::StopLimit,
2005            OrderSide::Buy,
2006            Quantity::from("1"),
2007            Some(Price::from("100.00")),
2008            Some(Price::from("99.00")),
2009            TimeInForce::Gtc,
2010            None,
2011            false,
2012            false,
2013            false,
2014        )
2015        .unwrap();
2016
2017        match buy_cfg {
2018            OrderConfiguration::StopLimitGtc(s) => assert_eq!(
2019                s.stop_limit_stop_limit_gtc.stop_direction,
2020                CoinbaseStopDirection::StopUp
2021            ),
2022            other => panic!("expected StopLimitGtc, was {other:?}"),
2023        }
2024
2025        let sell_cfg = build_order_configuration(
2026            OrderType::StopLimit,
2027            OrderSide::Sell,
2028            Quantity::from("1"),
2029            Some(Price::from("100.00")),
2030            Some(Price::from("99.00")),
2031            TimeInForce::Gtc,
2032            None,
2033            false,
2034            false,
2035            false,
2036        )
2037        .unwrap();
2038
2039        match sell_cfg {
2040            OrderConfiguration::StopLimitGtc(s) => assert_eq!(
2041                s.stop_limit_stop_limit_gtc.stop_direction,
2042                CoinbaseStopDirection::StopDown
2043            ),
2044            other => panic!("expected StopLimitGtc, was {other:?}"),
2045        }
2046    }
2047
2048    #[rstest]
2049    fn test_build_order_configuration_market_accepts_default_gtc() {
2050        // Nautilus orders default to GTC; coerce to MARKET IOC silently for
2051        // the default case but not for any explicit non-IOC TIF.
2052        let cfg = build_order_configuration(
2053            OrderType::Market,
2054            OrderSide::Buy,
2055            Quantity::from("1"),
2056            None,
2057            None,
2058            TimeInForce::Gtc,
2059            None,
2060            false,
2061            false,
2062            false,
2063        )
2064        .unwrap();
2065        assert!(matches!(cfg, OrderConfiguration::MarketIoc(_)));
2066    }
2067
2068    #[rstest]
2069    fn test_build_order_configuration_rejects_stop_market() {
2070        let result = build_order_configuration(
2071            OrderType::StopMarket,
2072            OrderSide::Buy,
2073            Quantity::from("1"),
2074            None,
2075            Some(Price::from("100.00")),
2076            TimeInForce::Gtc,
2077            None,
2078            false,
2079            false,
2080            false,
2081        );
2082        assert!(result.is_err());
2083    }
2084
2085    #[rstest]
2086    fn test_rest_quota_matches_documented_limit() {
2087        assert_eq!(COINBASE_REST_QUOTA.burst_size().get(), 30);
2088    }
2089
2090    #[rstest]
2091    fn test_default_retry_config_values() {
2092        let config = default_retry_config();
2093        assert_eq!(config.max_retries, 3);
2094        assert_eq!(config.initial_delay_ms, 100);
2095        assert_eq!(config.max_delay_ms, 5_000);
2096        assert_eq!(config.max_elapsed_ms, Some(180_000));
2097    }
2098}