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