1use 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
95pub static COINBASE_REST_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
97 Quota::per_second(NonZeroU32::new(30).expect("non-zero")).expect("valid constant")
98});
99
100#[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#[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
134fn 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#[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 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 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 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 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 #[must_use]
253 pub fn cancellation_token(&self) -> &CancellationToken {
254 &self.cancellation_token
255 }
256
257 pub fn set_base_url(&self, url: String) {
261 self.base_url.store(Arc::new(url));
262 }
263
264 #[must_use]
266 pub fn environment(&self) -> CoinbaseEnvironment {
267 self.environment
268 }
269
270 #[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 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 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 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 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 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 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 self.send_request(Method::GET, url, Some("GET"), Some(path), None)
418 .await
419 }
420
421 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 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 pub async fn get_products(&self) -> Result<Value> {
444 self.get_public("/market/products").await
445 }
446
447 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 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 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 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 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 pub async fn get_accounts(&self) -> Result<Value> {
499 self.get("/accounts").await
500 }
501
502 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 pub async fn get_account(&self, account_id: &str) -> Result<Value> {
513 self.get(&format!("/accounts/{account_id}")).await
514 }
515
516 pub async fn get_portfolios(&self) -> Result<Value> {
518 self.get("/portfolios").await
519 }
520
521 pub async fn get_orders(&self, query: &str) -> Result<Value> {
523 self.get_with_query("/orders/historical/batch", query).await
524 }
525
526 pub async fn get_order(&self, order_id: &str) -> Result<Value> {
528 self.get(&format!("/orders/historical/{order_id}")).await
529 }
530
531 pub async fn get_fills(&self, query: &str) -> Result<Value> {
533 self.get_with_query("/orders/historical/fills", query).await
534 }
535
536 pub async fn get_transaction_summary(&self) -> Result<Value> {
538 self.get("/transaction_summary").await
539 }
540
541 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 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 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 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 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 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 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 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 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 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 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#[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 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 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 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 pub fn from_env(environment: CoinbaseEnvironment) -> Result<Self> {
853 let raw = CoinbaseRawHttpClient::from_env(environment)?;
854 Ok(Self::from_raw(raw))
855 }
856
857 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 #[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 pub fn set_base_url(&self, url: String) {
900 self.inner.set_base_url(url);
901 }
902
903 #[must_use]
905 pub fn environment(&self) -> CoinbaseEnvironment {
906 self.inner.environment()
907 }
908
909 #[must_use]
911 pub fn is_authenticated(&self) -> bool {
912 self.inner.is_authenticated()
913 }
914
915 #[must_use]
917 pub fn instruments(&self) -> &Arc<AtomicMap<InstrumentId, InstrumentAny>> {
918 &self.instruments
919 }
920
921 #[must_use]
923 pub fn product_aliases(&self) -> &Arc<AtomicMap<Ustr, Ustr>> {
924 &self.product_aliases
925 }
926
927 #[must_use]
929 pub fn ts_now(&self) -> UnixNanos {
930 self.clock.get_time_ns()
931 }
932
933 pub async fn get_products(&self) -> Result<Value> {
935 self.inner.get_products().await
936 }
937
938 pub async fn get_product(&self, product_id: &str) -> Result<Value> {
940 self.inner.get_product(product_id).await
941 }
942
943 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 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 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 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 pub async fn get_accounts(&self) -> Result<Value> {
973 self.inner.get_accounts().await
974 }
975
976 pub async fn get_account(&self, account_id: &str) -> Result<Value> {
978 self.inner.get_account(account_id).await
979 }
980
981 pub async fn get_portfolios(&self) -> Result<Value> {
983 self.inner.get_portfolios().await
984 }
985
986 pub async fn preview_order(&self, body: &Value) -> Result<Value> {
991 self.inner.post("/orders/preview", body).await
992 }
993
994 pub async fn get_orders(&self, query: &str) -> Result<Value> {
996 self.inner.get_orders(query).await
997 }
998
999 pub async fn get_order(&self, order_id: &str) -> Result<Value> {
1001 self.inner.get_order(order_id).await
1002 }
1003
1004 pub async fn get_fills(&self, query: &str) -> Result<Value> {
1006 self.inner.get_fills(query).await
1007 }
1008
1009 pub async fn get_transaction_summary(&self) -> Result<Value> {
1011 self.inner.get_transaction_summary().await
1012 }
1013
1014 pub async fn request_instruments(
1025 &self,
1026 product_type: Option<CoinbaseProductType>,
1027 ) -> anyhow::Result<Vec<InstrumentAny>> {
1028 let json = self
1029 .inner
1030 .get_products()
1031 .await
1032 .map_err(|e| anyhow::anyhow!("Failed to fetch products: {e}"))?;
1033 let response: ProductsResponse =
1034 serde_json::from_value(json).map_err(|e| anyhow::anyhow!(e))?;
1035
1036 let ts_init = self.ts_now();
1037 let mut instruments = Vec::with_capacity(response.products.len());
1038
1039 for product in &response.products {
1040 if let Some(filter) = product_type
1041 && product.product_type != filter
1042 {
1043 continue;
1044 }
1045
1046 match parse_instrument(product, ts_init) {
1047 Ok(instrument) => instruments.push(instrument),
1048 Err(e) => {
1049 log::debug!(
1050 "Skipping product '{}' during parse: {e}",
1051 product.product_id
1052 );
1053 }
1054 }
1055 }
1056
1057 self.cache_instruments(&instruments);
1058 self.record_product_aliases(&response.products);
1059 Ok(instruments)
1060 }
1061
1062 pub async fn request_instrument(&self, product_id: &str) -> anyhow::Result<InstrumentAny> {
1071 let json = self
1072 .inner
1073 .get_product(product_id)
1074 .await
1075 .map_err(|e| anyhow::anyhow!("Failed to fetch product '{product_id}': {e}"))?;
1076 let product: crate::http::models::Product =
1077 serde_json::from_value(json).map_err(|e| anyhow::anyhow!(e))?;
1078 let ts_init = self.ts_now();
1079 let instrument = parse_instrument(&product, ts_init)?;
1080 self.cache_instrument(&instrument);
1081 self.record_product_aliases(std::slice::from_ref(&product));
1082 Ok(instrument)
1083 }
1084
1085 pub async fn request_raw_product(
1097 &self,
1098 product_id: &str,
1099 ) -> anyhow::Result<crate::http::models::Product> {
1100 let json = self
1101 .inner
1102 .get_product(product_id)
1103 .await
1104 .map_err(|e| anyhow::anyhow!("Failed to fetch product '{product_id}': {e}"))?;
1105 serde_json::from_value(json).map_err(|e| anyhow::anyhow!(e))
1106 }
1107
1108 pub async fn request_account_state(
1120 &self,
1121 account_id: AccountId,
1122 ) -> anyhow::Result<AccountState> {
1123 let accounts = self
1124 .inner
1125 .fetch_all_accounts()
1126 .await
1127 .map_err(|e| anyhow::anyhow!("Failed to fetch accounts: {e}"))?;
1128 let ts_event = self.ts_now();
1129 parse_account_state(&accounts, account_id, true, ts_event, ts_event)
1130 }
1131
1132 pub async fn request_order_status_report(
1143 &self,
1144 account_id: AccountId,
1145 client_order_id: Option<ClientOrderId>,
1146 venue_order_id: Option<VenueOrderId>,
1147 ) -> anyhow::Result<OrderStatusReport> {
1148 let venue_order_id = match (venue_order_id, client_order_id) {
1149 (Some(vid), _) => vid,
1150 (None, Some(cid)) => {
1151 let query = OrderListQuery {
1153 client_order_id_filter: Some(cid.as_str().to_string()),
1154 ..Default::default()
1155 };
1156 let orders = self
1157 .inner
1158 .fetch_all_orders(&query)
1159 .await
1160 .map_err(|e| anyhow::anyhow!("Failed to fetch orders: {e}"))?;
1161 let order = orders
1162 .into_iter()
1163 .next()
1164 .ok_or_else(|| anyhow::anyhow!("No order found for client_order_id={cid}"))?;
1165 let instrument = self.get_or_fetch_instrument(order.product_id).await?;
1166 let ts_init = self.ts_now();
1167 return parse_order_status_report(&order, &instrument, account_id, ts_init);
1168 }
1169 (None, None) => {
1170 anyhow::bail!("Either client_order_id or venue_order_id is required")
1171 }
1172 };
1173
1174 let json = self
1175 .inner
1176 .get_order(venue_order_id.as_str())
1177 .await
1178 .map_err(|e| anyhow::anyhow!("Failed to fetch order: {e}"))?;
1179 let response: OrderResponse =
1180 serde_json::from_value(json).map_err(|e| anyhow::anyhow!(e))?;
1181 let instrument = self
1182 .get_or_fetch_instrument(response.order.product_id)
1183 .await?;
1184 let ts_init = self.ts_now();
1185 parse_order_status_report(&response.order, &instrument, account_id, ts_init)
1186 }
1187
1188 pub async fn request_order_status_reports(
1196 &self,
1197 account_id: AccountId,
1198 instrument_id: Option<InstrumentId>,
1199 open_only: bool,
1200 start: Option<Timestamp>,
1201 end: Option<Timestamp>,
1202 limit: Option<u32>,
1203 ) -> anyhow::Result<Vec<OrderStatusReport>> {
1204 let query = OrderListQuery {
1205 product_id: instrument_id.map(|id| id.symbol.as_str().to_string()),
1206 open_only,
1207 start,
1208 end,
1209 limit,
1210 client_order_id_filter: None,
1211 };
1212
1213 let orders = self
1214 .inner
1215 .fetch_all_orders(&query)
1216 .await
1217 .map_err(|e| anyhow::anyhow!("Failed to fetch orders: {e}"))?;
1218
1219 let ts_init = self.ts_now();
1220 let mut reports = Vec::with_capacity(orders.len());
1221
1222 for order in &orders {
1223 let instrument = match self.get_or_fetch_instrument(order.product_id).await {
1224 Ok(inst) => inst,
1225 Err(e) => {
1226 log::debug!("Skipping order {}: {e}", order.order_id);
1227 continue;
1228 }
1229 };
1230
1231 match parse_order_status_report(order, &instrument, account_id, ts_init) {
1232 Ok(report) => reports.push(report),
1233 Err(e) => log::warn!("Failed to parse order {}: {e}", order.order_id),
1234 }
1235 }
1236
1237 Ok(reports)
1238 }
1239
1240 pub async fn request_fill_reports(
1248 &self,
1249 account_id: AccountId,
1250 instrument_id: Option<InstrumentId>,
1251 venue_order_id: Option<VenueOrderId>,
1252 start: Option<Timestamp>,
1253 end: Option<Timestamp>,
1254 limit: Option<u32>,
1255 ) -> anyhow::Result<Vec<FillReport>> {
1256 let query = FillListQuery {
1257 product_id: instrument_id.map(|id| id.symbol.as_str().to_string()),
1258 venue_order_id: venue_order_id.map(|id| id.as_str().to_string()),
1259 start,
1260 end,
1261 limit,
1262 };
1263
1264 let fills = self
1265 .inner
1266 .fetch_all_fills(&query)
1267 .await
1268 .map_err(|e| anyhow::anyhow!("Failed to fetch fills: {e}"))?;
1269
1270 let ts_init = self.ts_now();
1271 let mut reports = Vec::with_capacity(fills.len());
1272
1273 for fill in &fills {
1274 let instrument = match self.get_or_fetch_instrument(fill.product_id).await {
1275 Ok(inst) => inst,
1276 Err(e) => {
1277 log::debug!("Skipping fill {}: {e}", fill.trade_id);
1278 continue;
1279 }
1280 };
1281
1282 match parse_fill_report(fill, &instrument, account_id, ts_init) {
1283 Ok(report) => reports.push(report),
1284 Err(e) => log::warn!("Failed to parse fill {}: {e}", fill.trade_id),
1285 }
1286 }
1287
1288 Ok(reports)
1289 }
1290
1291 pub fn cache_instrument(&self, instrument: &InstrumentAny) {
1293 self.instruments.rcu(|m| {
1294 m.insert(instrument.id(), instrument.clone());
1295 });
1296 }
1297
1298 pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
1300 self.instruments.rcu(|m| {
1301 for instrument in instruments {
1302 m.insert(instrument.id(), instrument.clone());
1303 }
1304 });
1305 }
1306
1307 pub fn record_product_aliases(&self, products: &[crate::http::models::Product]) {
1312 let aliased: Vec<(Ustr, Ustr)> = products
1313 .iter()
1314 .filter(|p| !p.alias.is_empty())
1315 .map(|p| (p.product_id, p.alias))
1316 .collect();
1317
1318 if aliased.is_empty() {
1319 return;
1320 }
1321
1322 self.product_aliases.rcu(|m| {
1323 for (product_id, alias) in &aliased {
1324 m.insert(*product_id, *alias);
1325 }
1326 });
1327 }
1328
1329 async fn get_or_fetch_instrument(&self, product_id: Ustr) -> anyhow::Result<InstrumentAny> {
1335 let instrument_id = InstrumentId::new(
1336 Symbol::new(product_id),
1337 *crate::common::consts::COINBASE_VENUE,
1338 );
1339
1340 if let Some(instrument) = self.instruments.get_cloned(&instrument_id) {
1341 return Ok(instrument);
1342 }
1343 self.request_instrument(product_id.as_str()).await
1347 }
1348
1349 #[allow(clippy::too_many_arguments)]
1362 pub async fn submit_order(
1363 &self,
1364 client_order_id: ClientOrderId,
1365 instrument_id: InstrumentId,
1366 side: OrderSide,
1367 order_type: OrderType,
1368 quantity: Quantity,
1369 time_in_force: TimeInForce,
1370 price: Option<Price>,
1371 trigger_price: Option<Price>,
1372 expire_time: Option<UnixNanos>,
1373 post_only: bool,
1374 is_quote_quantity: bool,
1375 leverage: Option<Decimal>,
1376 margin_type: Option<CoinbaseMarginType>,
1377 reduce_only: bool,
1378 retail_portfolio_id: Option<String>,
1379 ) -> anyhow::Result<CreateOrderResponse> {
1380 let coinbase_side = map_order_side(side);
1381 let order_config = build_order_configuration(
1382 order_type,
1383 side,
1384 quantity,
1385 price,
1386 trigger_price,
1387 time_in_force,
1388 expire_time,
1389 post_only,
1390 is_quote_quantity,
1391 reduce_only,
1392 )?;
1393
1394 let request = CreateOrderRequest {
1395 client_order_id: client_order_id.to_string(),
1396 product_id: instrument_id.symbol.inner(),
1397 side: coinbase_side,
1398 order_configuration: order_config,
1399 self_trade_prevention_id: None,
1400 leverage: leverage.map(|d| d.normalize().to_string()),
1401 margin_type,
1402 retail_portfolio_id,
1403 };
1404
1405 self.inner
1406 .create_order(&request)
1407 .await
1408 .context("failed to submit order")
1409 }
1410
1411 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 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 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 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 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 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 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
1570pub fn map_order_side(side: OrderSide) -> CoinbaseOrderSide {
1572 match side {
1573 OrderSide::Buy => CoinbaseOrderSide::Buy,
1574 OrderSide::Sell => CoinbaseOrderSide::Sell,
1575 }
1576}
1577
1578#[allow(clippy::too_many_arguments)]
1591pub fn build_order_configuration(
1592 order_type: OrderType,
1593 side: OrderSide,
1594 quantity: Quantity,
1595 price: Option<Price>,
1596 trigger_price: Option<Price>,
1597 time_in_force: TimeInForce,
1598 expire_time: Option<UnixNanos>,
1599 post_only: bool,
1600 is_quote_quantity: bool,
1601 reduce_only: bool,
1602) -> anyhow::Result<OrderConfiguration> {
1603 let qty = quantity.as_decimal();
1604 let price = price.map(|p| p.as_decimal());
1605 let trigger = trigger_price.map(|p| p.as_decimal());
1606
1607 anyhow::ensure!(
1608 !reduce_only,
1609 "Reduce-only orders are not supported by Coinbase Advanced Trade"
1610 );
1611
1612 match order_type {
1613 OrderType::Market => {
1614 let params = if is_quote_quantity {
1625 MarketParams {
1626 quote_size: Some(qty),
1627 base_size: None,
1628 }
1629 } else {
1630 MarketParams {
1631 quote_size: None,
1632 base_size: Some(qty),
1633 }
1634 };
1635
1636 match time_in_force {
1637 TimeInForce::Ioc | TimeInForce::Gtc => {
1638 Ok(OrderConfiguration::MarketIoc(MarketIoc {
1639 market_market_ioc: params,
1640 }))
1641 }
1642 TimeInForce::Fok => Ok(OrderConfiguration::MarketFok(MarketFok {
1643 market_market_fok: params,
1644 })),
1645 _ => {
1646 anyhow::bail!(
1647 "Unsupported TIF {time_in_force} for MARKET on Coinbase (use IOC or FOK)"
1648 )
1649 }
1650 }
1651 }
1652 OrderType::Limit => {
1653 let limit_price =
1654 price.ok_or_else(|| anyhow::anyhow!("LIMIT order requires a price"))?;
1655
1656 match time_in_force {
1657 TimeInForce::Gtc => Ok(OrderConfiguration::LimitGtc(LimitGtc {
1658 limit_limit_gtc: LimitGtcParams {
1659 base_size: qty,
1660 limit_price,
1661 post_only,
1662 },
1663 })),
1664 TimeInForce::Gtd => {
1665 let expire = expire_time
1666 .ok_or_else(|| anyhow::anyhow!("GTD LIMIT requires expire_time"))?;
1667 Ok(OrderConfiguration::LimitGtd(LimitGtd {
1668 limit_limit_gtd: LimitGtdParams {
1669 base_size: qty,
1670 limit_price,
1671 end_time: format_rfc3339_from_nanos(expire)?,
1672 post_only,
1673 },
1674 }))
1675 }
1676 TimeInForce::Fok => Ok(OrderConfiguration::LimitFok(LimitFok {
1677 limit_limit_fok: LimitFokParams {
1678 base_size: qty,
1679 limit_price,
1680 },
1681 })),
1682 _ => anyhow::bail!("Unsupported TIF {time_in_force} for LIMIT on Coinbase"),
1683 }
1684 }
1685 OrderType::StopLimit => {
1686 let limit_price =
1687 price.ok_or_else(|| anyhow::anyhow!("STOP_LIMIT order requires a price"))?;
1688 let stop_price = trigger
1689 .ok_or_else(|| anyhow::anyhow!("STOP_LIMIT order requires trigger_price"))?;
1690 let direction = match side {
1691 OrderSide::Buy => CoinbaseStopDirection::StopUp,
1692 OrderSide::Sell => CoinbaseStopDirection::StopDown,
1693 };
1694
1695 match time_in_force {
1696 TimeInForce::Gtc => Ok(OrderConfiguration::StopLimitGtc(StopLimitGtc {
1697 stop_limit_stop_limit_gtc: StopLimitGtcParams {
1698 base_size: qty,
1699 limit_price,
1700 stop_price,
1701 stop_direction: direction,
1702 },
1703 })),
1704 TimeInForce::Gtd => {
1705 let expire = expire_time
1706 .ok_or_else(|| anyhow::anyhow!("GTD STOP_LIMIT requires expire_time"))?;
1707 Ok(OrderConfiguration::StopLimitGtd(StopLimitGtd {
1708 stop_limit_stop_limit_gtd: StopLimitGtdParams {
1709 base_size: qty,
1710 limit_price,
1711 stop_price,
1712 stop_direction: direction,
1713 end_time: format_rfc3339_from_nanos(expire)?,
1714 },
1715 }))
1716 }
1717 _ => anyhow::bail!("Unsupported TIF {time_in_force} for STOP_LIMIT on Coinbase"),
1718 }
1719 }
1720 other => anyhow::bail!("Unsupported order type for Coinbase: {other}"),
1721 }
1722}
1723
1724#[cfg(test)]
1725mod tests {
1726 use nautilus_testkit::http::assert_http_redirect_rejected;
1727 use rstest::rstest;
1728
1729 use super::*;
1730
1731 #[tokio::test]
1732 async fn test_authenticated_client_rejects_redirects() {
1733 let client = CoinbaseRawHttpClient::with_credentials(
1734 CoinbaseCredential::new("key".into(), "secret".into()),
1735 CoinbaseEnvironment::Sandbox,
1736 3,
1737 None,
1738 None,
1739 )
1740 .unwrap()
1741 .client;
1742 assert_http_redirect_rejected(|url| async move {
1743 client
1744 .get(url, None, None, Some(3), None)
1745 .await
1746 .unwrap()
1747 .status
1748 .as_u16()
1749 })
1750 .await;
1751 }
1752
1753 #[rstest]
1754 fn test_raw_client_construction_live() {
1755 let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1756 assert_eq!(client.environment(), CoinbaseEnvironment::Live);
1757 assert!(!client.is_authenticated());
1758 }
1759
1760 #[rstest]
1761 fn test_raw_client_construction_sandbox() {
1762 let client =
1763 CoinbaseRawHttpClient::new(CoinbaseEnvironment::Sandbox, 10, None, None).unwrap();
1764 assert_eq!(client.environment(), CoinbaseEnvironment::Sandbox);
1765 }
1766
1767 #[rstest]
1768 fn test_raw_build_url() {
1769 let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1770 let url = client.build_url("/products");
1771 assert_eq!(url, "https://api.coinbase.com/api/v3/brokerage/products");
1772 }
1773
1774 #[rstest]
1775 fn test_raw_build_jwt_uri_live() {
1776 let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1777 let uri = client.build_jwt_uri("GET", "/accounts");
1778 assert_eq!(uri, "GET api.coinbase.com/api/v3/brokerage/accounts");
1779 }
1780
1781 #[rstest]
1782 fn test_raw_build_jwt_uri_sandbox() {
1783 let client =
1784 CoinbaseRawHttpClient::new(CoinbaseEnvironment::Sandbox, 10, None, None).unwrap();
1785 let uri = client.build_jwt_uri("GET", "/accounts");
1786 assert_eq!(
1787 uri,
1788 "GET api-sandbox.coinbase.com/api/v3/brokerage/accounts"
1789 );
1790 }
1791
1792 #[rstest]
1793 fn test_raw_build_jwt_uri_custom_base_url() {
1794 let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1795 client.set_base_url("http://localhost:8080".to_string());
1796 let uri = client.build_jwt_uri("POST", "/orders");
1797 assert_eq!(uri, "POST localhost:8080/api/v3/brokerage/orders");
1798 }
1799
1800 #[rstest]
1801 fn test_raw_set_base_url_safe_after_clone_via_arc() {
1802 let raw = Arc::new(
1803 CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap(),
1804 );
1805 let other = Arc::clone(&raw);
1806 raw.set_base_url("http://localhost:1234".to_string());
1808 assert!(other.build_url("/foo").starts_with("http://localhost:1234"));
1809 }
1810
1811 #[rstest]
1812 fn test_raw_auth_headers_without_credentials() {
1813 let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1814 let result = client.auth_headers("GET", "/accounts");
1815 assert!(result.is_err());
1816 assert!(result.unwrap_err().is_auth_error());
1817 }
1818
1819 #[rstest]
1820 fn test_domain_client_construction() {
1821 let client = CoinbaseHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1822 assert_eq!(client.environment(), CoinbaseEnvironment::Live);
1823 assert!(!client.is_authenticated());
1824 }
1825
1826 #[rstest]
1827 fn test_domain_client_default() {
1828 let client = CoinbaseHttpClient::default();
1829 assert_eq!(client.environment(), CoinbaseEnvironment::Live);
1830 }
1831
1832 #[rstest]
1833 fn test_domain_client_instruments_cache_empty() {
1834 let client = CoinbaseHttpClient::default();
1835 assert!(client.instruments().is_empty());
1836 }
1837
1838 #[rstest]
1839 fn test_domain_client_set_base_url() {
1840 let client = CoinbaseHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1841 let cloned = client.clone();
1842 client.set_base_url("http://localhost:9090".to_string());
1844 let url = cloned.inner.build_url("/test");
1845 assert!(url.starts_with("http://localhost:9090"));
1846 }
1847
1848 #[rstest]
1849 fn test_encode_query_escapes_rfc3339_timestamps() {
1850 let query = encode_query(&[("start_date", "2024-01-15T10:00:00+00:00")]);
1851 assert_eq!(query, "start_date=2024-01-15T10%3A00%3A00%2B00%3A00");
1853 }
1854
1855 #[rstest]
1856 fn test_encode_query_escapes_opaque_cursor() {
1857 let query = encode_query(&[("cursor", "a/b+c=?&x")]);
1858 assert!(!query.contains("a/b+c=?&x"));
1860 assert!(query.starts_with("cursor="));
1861 }
1862
1863 #[rstest]
1864 fn test_encode_query_joins_pairs_with_ampersand() {
1865 let query = encode_query(&[("product_id", "BTC-USD"), ("limit", "50")]);
1866 assert_eq!(query, "product_id=BTC-USD&limit=50");
1867 }
1868
1869 #[rstest]
1870 fn test_map_order_side() {
1871 assert!(matches!(
1872 map_order_side(OrderSide::Buy),
1873 CoinbaseOrderSide::Buy
1874 ));
1875 assert!(matches!(
1876 map_order_side(OrderSide::Sell),
1877 CoinbaseOrderSide::Sell
1878 ));
1879 }
1880
1881 #[rstest]
1882 fn test_build_order_configuration_market_base_size() {
1883 let cfg = build_order_configuration(
1884 OrderType::Market,
1885 OrderSide::Buy,
1886 Quantity::from("1.5"),
1887 None,
1888 None,
1889 TimeInForce::Ioc,
1890 None,
1891 false,
1892 false,
1893 false,
1894 )
1895 .unwrap();
1896
1897 match cfg {
1898 OrderConfiguration::MarketIoc(m) => {
1899 assert!(m.market_market_ioc.base_size.is_some());
1900 assert!(m.market_market_ioc.quote_size.is_none());
1901 }
1902 other => panic!("expected MarketIoc, was {other:?}"),
1903 }
1904 }
1905
1906 #[rstest]
1907 fn test_build_order_configuration_market_quote_size() {
1908 let cfg = build_order_configuration(
1909 OrderType::Market,
1910 OrderSide::Buy,
1911 Quantity::from("100"),
1912 None,
1913 None,
1914 TimeInForce::Ioc,
1915 None,
1916 false,
1917 true, false,
1919 )
1920 .unwrap();
1921
1922 match cfg {
1923 OrderConfiguration::MarketIoc(m) => {
1924 assert!(m.market_market_ioc.quote_size.is_some());
1925 assert!(m.market_market_ioc.base_size.is_none());
1926 }
1927 other => panic!("expected MarketIoc, was {other:?}"),
1928 }
1929 }
1930
1931 #[rstest]
1932 fn test_build_order_configuration_market_fok() {
1933 let cfg = build_order_configuration(
1934 OrderType::Market,
1935 OrderSide::Buy,
1936 Quantity::from("0.5"),
1937 None,
1938 None,
1939 TimeInForce::Fok,
1940 None,
1941 false,
1942 false,
1943 false,
1944 )
1945 .unwrap();
1946
1947 match cfg {
1948 OrderConfiguration::MarketFok(m) => {
1949 assert!(m.market_market_fok.base_size.is_some());
1950 assert!(m.market_market_fok.quote_size.is_none());
1951 }
1952 other => panic!("expected MarketFok, was {other:?}"),
1953 }
1954 }
1955
1956 #[rstest]
1957 #[case(TimeInForce::Day)]
1958 #[case(TimeInForce::Gtd)]
1959 fn test_build_order_configuration_market_rejects_unsupported_tif(#[case] tif: TimeInForce) {
1960 let result = build_order_configuration(
1961 OrderType::Market,
1962 OrderSide::Buy,
1963 Quantity::from("1"),
1964 None,
1965 None,
1966 tif,
1967 None,
1968 false,
1969 false,
1970 false,
1971 );
1972 assert!(result.is_err());
1973 }
1974
1975 #[rstest]
1976 fn test_build_order_configuration_limit_gtc_post_only() {
1977 let cfg = build_order_configuration(
1978 OrderType::Limit,
1979 OrderSide::Sell,
1980 Quantity::from("0.5"),
1981 Some(Price::from("50000.00")),
1982 None,
1983 TimeInForce::Gtc,
1984 None,
1985 true,
1986 false,
1987 false,
1988 )
1989 .unwrap();
1990
1991 match cfg {
1992 OrderConfiguration::LimitGtc(l) => assert!(l.limit_limit_gtc.post_only),
1993 other => panic!("expected LimitGtc, was {other:?}"),
1994 }
1995 }
1996
1997 #[rstest]
1998 fn test_build_order_configuration_limit_gtd_requires_expire_time() {
1999 let result = build_order_configuration(
2000 OrderType::Limit,
2001 OrderSide::Buy,
2002 Quantity::from("1"),
2003 Some(Price::from("100.00")),
2004 None,
2005 TimeInForce::Gtd,
2006 None,
2007 false,
2008 false,
2009 false,
2010 );
2011 assert!(result.is_err());
2012 }
2013
2014 #[rstest]
2015 fn test_build_order_configuration_stop_limit_uses_correct_direction() {
2016 let buy_cfg = build_order_configuration(
2017 OrderType::StopLimit,
2018 OrderSide::Buy,
2019 Quantity::from("1"),
2020 Some(Price::from("100.00")),
2021 Some(Price::from("99.00")),
2022 TimeInForce::Gtc,
2023 None,
2024 false,
2025 false,
2026 false,
2027 )
2028 .unwrap();
2029
2030 match buy_cfg {
2031 OrderConfiguration::StopLimitGtc(s) => assert_eq!(
2032 s.stop_limit_stop_limit_gtc.stop_direction,
2033 CoinbaseStopDirection::StopUp
2034 ),
2035 other => panic!("expected StopLimitGtc, was {other:?}"),
2036 }
2037
2038 let sell_cfg = build_order_configuration(
2039 OrderType::StopLimit,
2040 OrderSide::Sell,
2041 Quantity::from("1"),
2042 Some(Price::from("100.00")),
2043 Some(Price::from("99.00")),
2044 TimeInForce::Gtc,
2045 None,
2046 false,
2047 false,
2048 false,
2049 )
2050 .unwrap();
2051
2052 match sell_cfg {
2053 OrderConfiguration::StopLimitGtc(s) => assert_eq!(
2054 s.stop_limit_stop_limit_gtc.stop_direction,
2055 CoinbaseStopDirection::StopDown
2056 ),
2057 other => panic!("expected StopLimitGtc, was {other:?}"),
2058 }
2059 }
2060
2061 #[rstest]
2062 fn test_build_order_configuration_market_accepts_default_gtc() {
2063 let cfg = build_order_configuration(
2066 OrderType::Market,
2067 OrderSide::Buy,
2068 Quantity::from("1"),
2069 None,
2070 None,
2071 TimeInForce::Gtc,
2072 None,
2073 false,
2074 false,
2075 false,
2076 )
2077 .unwrap();
2078 assert!(matches!(cfg, OrderConfiguration::MarketIoc(_)));
2079 }
2080
2081 #[rstest]
2082 fn test_build_order_configuration_rejects_stop_market() {
2083 let result = build_order_configuration(
2084 OrderType::StopMarket,
2085 OrderSide::Buy,
2086 Quantity::from("1"),
2087 None,
2088 Some(Price::from("100.00")),
2089 TimeInForce::Gtc,
2090 None,
2091 false,
2092 false,
2093 false,
2094 );
2095 assert!(result.is_err());
2096 }
2097
2098 #[rstest]
2099 fn test_rest_quota_matches_documented_limit() {
2100 assert_eq!(COINBASE_REST_QUOTA.burst_size().get(), 30);
2101 }
2102
2103 #[rstest]
2104 fn test_default_retry_config_values() {
2105 let config = default_retry_config();
2106 assert_eq!(config.max_retries, 3);
2107 assert_eq!(config.initial_delay_ms, 100);
2108 assert_eq!(config.max_delay_ms, 5_000);
2109 assert_eq!(config.max_elapsed_ms, Some(180_000));
2110 }
2111}