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(
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 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 pub async fn request_raw_product(
1095 &self,
1096 product_id: &str,
1097 ) -> anyhow::Result<crate::http::models::Product> {
1098 let json = self
1099 .inner
1100 .get_product(product_id)
1101 .await
1102 .map_err(|e| anyhow::anyhow!("Failed to fetch product '{product_id}': {e}"))?;
1103 serde_json::from_value(json).map_err(|e| anyhow::anyhow!(e))
1104 }
1105
1106 pub async fn request_account_state(
1117 &self,
1118 account_id: AccountId,
1119 ) -> anyhow::Result<AccountState> {
1120 let accounts = self
1121 .inner
1122 .fetch_all_accounts()
1123 .await
1124 .map_err(|e| anyhow::anyhow!("Failed to fetch accounts: {e}"))?;
1125 let ts_event = self.ts_now();
1126 parse_account_state(&accounts, account_id, true, ts_event, ts_event)
1127 }
1128
1129 pub async fn request_order_status_report(
1140 &self,
1141 account_id: AccountId,
1142 client_order_id: Option<ClientOrderId>,
1143 venue_order_id: Option<VenueOrderId>,
1144 ) -> anyhow::Result<OrderStatusReport> {
1145 let venue_order_id = match (venue_order_id, client_order_id) {
1146 (Some(vid), _) => vid,
1147 (None, Some(cid)) => {
1148 let query = OrderListQuery {
1150 client_order_id_filter: Some(cid.as_str().to_string()),
1151 ..Default::default()
1152 };
1153 let orders = self
1154 .inner
1155 .fetch_all_orders(&query)
1156 .await
1157 .map_err(|e| anyhow::anyhow!("Failed to fetch orders: {e}"))?;
1158 let order = orders
1159 .into_iter()
1160 .next()
1161 .ok_or_else(|| anyhow::anyhow!("No order found for client_order_id={cid}"))?;
1162 let instrument = self.get_or_fetch_instrument(order.product_id).await?;
1163 let ts_init = self.ts_now();
1164 return parse_order_status_report(&order, &instrument, account_id, ts_init);
1165 }
1166 (None, None) => {
1167 anyhow::bail!("Either client_order_id or venue_order_id is required")
1168 }
1169 };
1170
1171 let json = self
1172 .inner
1173 .get_order(venue_order_id.as_str())
1174 .await
1175 .map_err(|e| anyhow::anyhow!("Failed to fetch order: {e}"))?;
1176 let response: OrderResponse =
1177 serde_json::from_value(json).map_err(|e| anyhow::anyhow!(e))?;
1178 let instrument = self
1179 .get_or_fetch_instrument(response.order.product_id)
1180 .await?;
1181 let ts_init = self.ts_now();
1182 parse_order_status_report(&response.order, &instrument, account_id, ts_init)
1183 }
1184
1185 pub async fn request_order_status_reports(
1192 &self,
1193 account_id: AccountId,
1194 instrument_id: Option<InstrumentId>,
1195 open_only: bool,
1196 start: Option<Timestamp>,
1197 end: Option<Timestamp>,
1198 limit: Option<u32>,
1199 ) -> anyhow::Result<Vec<OrderStatusReport>> {
1200 let query = OrderListQuery {
1201 product_id: instrument_id.map(|id| id.symbol.as_str().to_string()),
1202 open_only,
1203 start,
1204 end,
1205 limit,
1206 client_order_id_filter: None,
1207 };
1208
1209 let orders = self
1210 .inner
1211 .fetch_all_orders(&query)
1212 .await
1213 .map_err(|e| anyhow::anyhow!("Failed to fetch orders: {e}"))?;
1214
1215 let ts_init = self.ts_now();
1216 let mut reports = Vec::with_capacity(orders.len());
1217
1218 for order in &orders {
1219 let instrument = match self.get_or_fetch_instrument(order.product_id).await {
1220 Ok(inst) => inst,
1221 Err(e) => {
1222 log::debug!("Skipping order {}: {e}", order.order_id);
1223 continue;
1224 }
1225 };
1226
1227 match parse_order_status_report(order, &instrument, account_id, ts_init) {
1228 Ok(report) => reports.push(report),
1229 Err(e) => log::warn!("Failed to parse order {}: {e}", order.order_id),
1230 }
1231 }
1232
1233 Ok(reports)
1234 }
1235
1236 pub async fn request_fill_reports(
1243 &self,
1244 account_id: AccountId,
1245 instrument_id: Option<InstrumentId>,
1246 venue_order_id: Option<VenueOrderId>,
1247 start: Option<Timestamp>,
1248 end: Option<Timestamp>,
1249 limit: Option<u32>,
1250 ) -> anyhow::Result<Vec<FillReport>> {
1251 let query = FillListQuery {
1252 product_id: instrument_id.map(|id| id.symbol.as_str().to_string()),
1253 venue_order_id: venue_order_id.map(|id| id.as_str().to_string()),
1254 start,
1255 end,
1256 limit,
1257 };
1258
1259 let fills = self
1260 .inner
1261 .fetch_all_fills(&query)
1262 .await
1263 .map_err(|e| anyhow::anyhow!("Failed to fetch fills: {e}"))?;
1264
1265 let ts_init = self.ts_now();
1266 let mut reports = Vec::with_capacity(fills.len());
1267
1268 for fill in &fills {
1269 let instrument = match self.get_or_fetch_instrument(fill.product_id).await {
1270 Ok(inst) => inst,
1271 Err(e) => {
1272 log::debug!("Skipping fill {}: {e}", fill.trade_id);
1273 continue;
1274 }
1275 };
1276
1277 match parse_fill_report(fill, &instrument, account_id, ts_init) {
1278 Ok(report) => reports.push(report),
1279 Err(e) => log::warn!("Failed to parse fill {}: {e}", fill.trade_id),
1280 }
1281 }
1282
1283 Ok(reports)
1284 }
1285
1286 pub fn cache_instrument(&self, instrument: &InstrumentAny) {
1288 self.instruments.rcu(|m| {
1289 m.insert(instrument.id(), instrument.clone());
1290 });
1291 }
1292
1293 pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
1295 self.instruments.rcu(|m| {
1296 for instrument in instruments {
1297 m.insert(instrument.id(), instrument.clone());
1298 }
1299 });
1300 }
1301
1302 pub fn record_product_aliases(&self, products: &[crate::http::models::Product]) {
1307 let aliased: Vec<(Ustr, Ustr)> = products
1308 .iter()
1309 .filter(|p| !p.alias.is_empty())
1310 .map(|p| (p.product_id, p.alias))
1311 .collect();
1312
1313 if aliased.is_empty() {
1314 return;
1315 }
1316
1317 self.product_aliases.rcu(|m| {
1318 for (product_id, alias) in &aliased {
1319 m.insert(*product_id, *alias);
1320 }
1321 });
1322 }
1323
1324 async fn get_or_fetch_instrument(&self, product_id: Ustr) -> anyhow::Result<InstrumentAny> {
1330 let instrument_id = InstrumentId::new(
1331 Symbol::new(product_id),
1332 *crate::common::consts::COINBASE_VENUE,
1333 );
1334
1335 if let Some(instrument) = self.instruments.get_cloned(&instrument_id) {
1336 return Ok(instrument);
1337 }
1338 self.request_instrument(product_id.as_str()).await
1342 }
1343
1344 #[allow(clippy::too_many_arguments)]
1356 pub async fn submit_order(
1357 &self,
1358 client_order_id: ClientOrderId,
1359 instrument_id: InstrumentId,
1360 side: OrderSide,
1361 order_type: OrderType,
1362 quantity: Quantity,
1363 time_in_force: TimeInForce,
1364 price: Option<Price>,
1365 trigger_price: Option<Price>,
1366 expire_time: Option<UnixNanos>,
1367 post_only: bool,
1368 is_quote_quantity: bool,
1369 leverage: Option<Decimal>,
1370 margin_type: Option<CoinbaseMarginType>,
1371 reduce_only: bool,
1372 retail_portfolio_id: Option<String>,
1373 ) -> anyhow::Result<CreateOrderResponse> {
1374 let coinbase_side = map_order_side(side);
1375 let order_config = build_order_configuration(
1376 order_type,
1377 side,
1378 quantity,
1379 price,
1380 trigger_price,
1381 time_in_force,
1382 expire_time,
1383 post_only,
1384 is_quote_quantity,
1385 reduce_only,
1386 )?;
1387
1388 let request = CreateOrderRequest {
1389 client_order_id: client_order_id.to_string(),
1390 product_id: instrument_id.symbol.inner(),
1391 side: coinbase_side,
1392 order_configuration: order_config,
1393 self_trade_prevention_id: None,
1394 leverage: leverage.map(|d| d.normalize().to_string()),
1395 margin_type,
1396 retail_portfolio_id,
1397 };
1398
1399 self.inner
1400 .create_order(&request)
1401 .await
1402 .context("failed to submit order")
1403 }
1404
1405 pub async fn cancel_orders(
1411 &self,
1412 venue_order_ids: &[VenueOrderId],
1413 ) -> anyhow::Result<CancelOrdersResponse> {
1414 let request = CancelOrdersRequest {
1415 order_ids: venue_order_ids
1416 .iter()
1417 .map(|id| id.as_str().to_string())
1418 .collect(),
1419 };
1420 self.inner
1421 .cancel_orders(&request)
1422 .await
1423 .context("failed to cancel orders")
1424 }
1425
1426 pub async fn request_cfm_balance_summary(&self) -> anyhow::Result<CfmBalanceSummary> {
1432 let response = self
1433 .inner
1434 .get_cfm_balance_summary()
1435 .await
1436 .map_err(|e| anyhow::anyhow!("Failed to fetch CFM balance summary: {e}"))?;
1437 Ok(response.balance_summary)
1438 }
1439
1440 pub async fn request_cfm_margin_balances(&self) -> anyhow::Result<Vec<MarginBalance>> {
1446 let summary = self.request_cfm_balance_summary().await?;
1447 parse_cfm_margin_balances(&summary)
1448 }
1449
1450 pub async fn request_cfm_account_state(
1456 &self,
1457 account_id: AccountId,
1458 ) -> anyhow::Result<AccountState> {
1459 let summary = self.request_cfm_balance_summary().await?;
1460 let ts_event = self.ts_now();
1461 parse_cfm_account_state(&summary, account_id, true, ts_event, ts_event)
1462 }
1463
1464 pub async fn request_position_status_reports(
1470 &self,
1471 account_id: AccountId,
1472 ) -> anyhow::Result<Vec<PositionStatusReport>> {
1473 let response = self
1474 .inner
1475 .get_cfm_positions()
1476 .await
1477 .map_err(|e| anyhow::anyhow!("Failed to fetch CFM positions: {e}"))?;
1478
1479 let ts_init = self.ts_now();
1480 let mut reports = Vec::with_capacity(response.positions.len());
1481
1482 for position in &response.positions {
1483 let instrument = match self.get_or_fetch_instrument(position.product_id).await {
1484 Ok(inst) => inst,
1485 Err(e) => {
1486 log::debug!("Skipping CFM position {}: {e}", position.product_id);
1487 continue;
1488 }
1489 };
1490
1491 match parse_cfm_position_status_report(position, &instrument, account_id, ts_init) {
1492 Ok(report) => reports.push(report),
1493 Err(e) => log::warn!("Failed to parse CFM position {}: {e}", position.product_id),
1494 }
1495 }
1496
1497 Ok(reports)
1498 }
1499
1500 pub async fn request_position_status_report(
1507 &self,
1508 account_id: AccountId,
1509 instrument_id: InstrumentId,
1510 ) -> anyhow::Result<Option<PositionStatusReport>> {
1511 let product_id = instrument_id.symbol.as_str();
1512 let response = self
1513 .inner
1514 .get_cfm_position(product_id)
1515 .await
1516 .map_err(|e| anyhow::anyhow!("Failed to fetch CFM position '{product_id}': {e}"))?;
1517
1518 let instrument = self
1519 .get_or_fetch_instrument(response.position.product_id)
1520 .await?;
1521 let ts_init = self.ts_now();
1522 let report =
1523 parse_cfm_position_status_report(&response.position, &instrument, account_id, ts_init)?;
1524 Ok(Some(report))
1525 }
1526
1527 pub async fn modify_order(
1538 &self,
1539 venue_order_id: VenueOrderId,
1540 price: Option<Price>,
1541 quantity: Option<Quantity>,
1542 trigger_price: Option<Price>,
1543 ) -> anyhow::Result<EditOrderResponse> {
1544 let request = EditOrderRequest {
1545 order_id: venue_order_id.as_str().to_string(),
1546 price: price.map(|p| p.to_string()),
1547 size: quantity.map(|q| q.to_string()),
1548 stop_price: trigger_price.map(|p| p.to_string()),
1549 };
1550 self.inner
1551 .edit_order(&request)
1552 .await
1553 .context("failed to edit order")
1554 }
1555}
1556
1557pub fn map_order_side(side: OrderSide) -> CoinbaseOrderSide {
1559 match side {
1560 OrderSide::Buy => CoinbaseOrderSide::Buy,
1561 OrderSide::Sell => CoinbaseOrderSide::Sell,
1562 }
1563}
1564
1565#[allow(clippy::too_many_arguments)]
1578pub fn build_order_configuration(
1579 order_type: OrderType,
1580 side: OrderSide,
1581 quantity: Quantity,
1582 price: Option<Price>,
1583 trigger_price: Option<Price>,
1584 time_in_force: TimeInForce,
1585 expire_time: Option<UnixNanos>,
1586 post_only: bool,
1587 is_quote_quantity: bool,
1588 reduce_only: bool,
1589) -> anyhow::Result<OrderConfiguration> {
1590 let qty = quantity.as_decimal();
1591 let price = price.map(|p| p.as_decimal());
1592 let trigger = trigger_price.map(|p| p.as_decimal());
1593
1594 anyhow::ensure!(
1595 !reduce_only,
1596 "Reduce-only orders are not supported by Coinbase Advanced Trade"
1597 );
1598
1599 match order_type {
1600 OrderType::Market => {
1601 let params = if is_quote_quantity {
1612 MarketParams {
1613 quote_size: Some(qty),
1614 base_size: None,
1615 }
1616 } else {
1617 MarketParams {
1618 quote_size: None,
1619 base_size: Some(qty),
1620 }
1621 };
1622
1623 match time_in_force {
1624 TimeInForce::Ioc | TimeInForce::Gtc => {
1625 Ok(OrderConfiguration::MarketIoc(MarketIoc {
1626 market_market_ioc: params,
1627 }))
1628 }
1629 TimeInForce::Fok => Ok(OrderConfiguration::MarketFok(MarketFok {
1630 market_market_fok: params,
1631 })),
1632 _ => {
1633 anyhow::bail!(
1634 "Unsupported TIF {time_in_force} for MARKET on Coinbase (use IOC or FOK)"
1635 )
1636 }
1637 }
1638 }
1639 OrderType::Limit => {
1640 let limit_price =
1641 price.ok_or_else(|| anyhow::anyhow!("LIMIT order requires a price"))?;
1642
1643 match time_in_force {
1644 TimeInForce::Gtc => Ok(OrderConfiguration::LimitGtc(LimitGtc {
1645 limit_limit_gtc: LimitGtcParams {
1646 base_size: qty,
1647 limit_price,
1648 post_only,
1649 },
1650 })),
1651 TimeInForce::Gtd => {
1652 let expire = expire_time
1653 .ok_or_else(|| anyhow::anyhow!("GTD LIMIT requires expire_time"))?;
1654 Ok(OrderConfiguration::LimitGtd(LimitGtd {
1655 limit_limit_gtd: LimitGtdParams {
1656 base_size: qty,
1657 limit_price,
1658 end_time: format_rfc3339_from_nanos(expire)?,
1659 post_only,
1660 },
1661 }))
1662 }
1663 TimeInForce::Fok => Ok(OrderConfiguration::LimitFok(LimitFok {
1664 limit_limit_fok: LimitFokParams {
1665 base_size: qty,
1666 limit_price,
1667 },
1668 })),
1669 _ => anyhow::bail!("Unsupported TIF {time_in_force} for LIMIT on Coinbase"),
1670 }
1671 }
1672 OrderType::StopLimit => {
1673 let limit_price =
1674 price.ok_or_else(|| anyhow::anyhow!("STOP_LIMIT order requires a price"))?;
1675 let stop_price = trigger
1676 .ok_or_else(|| anyhow::anyhow!("STOP_LIMIT order requires trigger_price"))?;
1677 let direction = match side {
1678 OrderSide::Buy => CoinbaseStopDirection::StopUp,
1679 OrderSide::Sell => CoinbaseStopDirection::StopDown,
1680 };
1681
1682 match time_in_force {
1683 TimeInForce::Gtc => Ok(OrderConfiguration::StopLimitGtc(StopLimitGtc {
1684 stop_limit_stop_limit_gtc: StopLimitGtcParams {
1685 base_size: qty,
1686 limit_price,
1687 stop_price,
1688 stop_direction: direction,
1689 },
1690 })),
1691 TimeInForce::Gtd => {
1692 let expire = expire_time
1693 .ok_or_else(|| anyhow::anyhow!("GTD STOP_LIMIT requires expire_time"))?;
1694 Ok(OrderConfiguration::StopLimitGtd(StopLimitGtd {
1695 stop_limit_stop_limit_gtd: StopLimitGtdParams {
1696 base_size: qty,
1697 limit_price,
1698 stop_price,
1699 stop_direction: direction,
1700 end_time: format_rfc3339_from_nanos(expire)?,
1701 },
1702 }))
1703 }
1704 _ => anyhow::bail!("Unsupported TIF {time_in_force} for STOP_LIMIT on Coinbase"),
1705 }
1706 }
1707 other => anyhow::bail!("Unsupported order type for Coinbase: {other}"),
1708 }
1709}
1710
1711#[cfg(test)]
1712mod tests {
1713 use nautilus_testkit::http::assert_http_redirect_rejected;
1714 use rstest::rstest;
1715
1716 use super::*;
1717
1718 #[tokio::test]
1719 async fn test_authenticated_client_rejects_redirects() {
1720 let client = CoinbaseRawHttpClient::with_credentials(
1721 CoinbaseCredential::new("key".into(), "secret".into()),
1722 CoinbaseEnvironment::Sandbox,
1723 3,
1724 None,
1725 None,
1726 )
1727 .unwrap()
1728 .client;
1729 assert_http_redirect_rejected(|url| async move {
1730 client
1731 .get(url, None, None, Some(3), None)
1732 .await
1733 .unwrap()
1734 .status
1735 .as_u16()
1736 })
1737 .await;
1738 }
1739
1740 #[rstest]
1741 fn test_raw_client_construction_live() {
1742 let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1743 assert_eq!(client.environment(), CoinbaseEnvironment::Live);
1744 assert!(!client.is_authenticated());
1745 }
1746
1747 #[rstest]
1748 fn test_raw_client_construction_sandbox() {
1749 let client =
1750 CoinbaseRawHttpClient::new(CoinbaseEnvironment::Sandbox, 10, None, None).unwrap();
1751 assert_eq!(client.environment(), CoinbaseEnvironment::Sandbox);
1752 }
1753
1754 #[rstest]
1755 fn test_raw_build_url() {
1756 let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1757 let url = client.build_url("/products");
1758 assert_eq!(url, "https://api.coinbase.com/api/v3/brokerage/products");
1759 }
1760
1761 #[rstest]
1762 fn test_raw_build_jwt_uri_live() {
1763 let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1764 let uri = client.build_jwt_uri("GET", "/accounts");
1765 assert_eq!(uri, "GET api.coinbase.com/api/v3/brokerage/accounts");
1766 }
1767
1768 #[rstest]
1769 fn test_raw_build_jwt_uri_sandbox() {
1770 let client =
1771 CoinbaseRawHttpClient::new(CoinbaseEnvironment::Sandbox, 10, None, None).unwrap();
1772 let uri = client.build_jwt_uri("GET", "/accounts");
1773 assert_eq!(
1774 uri,
1775 "GET api-sandbox.coinbase.com/api/v3/brokerage/accounts"
1776 );
1777 }
1778
1779 #[rstest]
1780 fn test_raw_build_jwt_uri_custom_base_url() {
1781 let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1782 client.set_base_url("http://localhost:8080".to_string());
1783 let uri = client.build_jwt_uri("POST", "/orders");
1784 assert_eq!(uri, "POST localhost:8080/api/v3/brokerage/orders");
1785 }
1786
1787 #[rstest]
1788 fn test_raw_set_base_url_safe_after_clone_via_arc() {
1789 let raw = Arc::new(
1790 CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap(),
1791 );
1792 let other = Arc::clone(&raw);
1793 raw.set_base_url("http://localhost:1234".to_string());
1795 assert!(other.build_url("/foo").starts_with("http://localhost:1234"));
1796 }
1797
1798 #[rstest]
1799 fn test_raw_auth_headers_without_credentials() {
1800 let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1801 let result = client.auth_headers("GET", "/accounts");
1802 assert!(result.is_err());
1803 assert!(result.unwrap_err().is_auth_error());
1804 }
1805
1806 #[rstest]
1807 fn test_domain_client_construction() {
1808 let client = CoinbaseHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1809 assert_eq!(client.environment(), CoinbaseEnvironment::Live);
1810 assert!(!client.is_authenticated());
1811 }
1812
1813 #[rstest]
1814 fn test_domain_client_default() {
1815 let client = CoinbaseHttpClient::default();
1816 assert_eq!(client.environment(), CoinbaseEnvironment::Live);
1817 }
1818
1819 #[rstest]
1820 fn test_domain_client_instruments_cache_empty() {
1821 let client = CoinbaseHttpClient::default();
1822 assert!(client.instruments().is_empty());
1823 }
1824
1825 #[rstest]
1826 fn test_domain_client_set_base_url() {
1827 let client = CoinbaseHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1828 let cloned = client.clone();
1829 client.set_base_url("http://localhost:9090".to_string());
1831 let url = cloned.inner.build_url("/test");
1832 assert!(url.starts_with("http://localhost:9090"));
1833 }
1834
1835 #[rstest]
1836 fn test_encode_query_escapes_rfc3339_timestamps() {
1837 let query = encode_query(&[("start_date", "2024-01-15T10:00:00+00:00")]);
1838 assert_eq!(query, "start_date=2024-01-15T10%3A00%3A00%2B00%3A00");
1840 }
1841
1842 #[rstest]
1843 fn test_encode_query_escapes_opaque_cursor() {
1844 let query = encode_query(&[("cursor", "a/b+c=?&x")]);
1845 assert!(!query.contains("a/b+c=?&x"));
1847 assert!(query.starts_with("cursor="));
1848 }
1849
1850 #[rstest]
1851 fn test_encode_query_joins_pairs_with_ampersand() {
1852 let query = encode_query(&[("product_id", "BTC-USD"), ("limit", "50")]);
1853 assert_eq!(query, "product_id=BTC-USD&limit=50");
1854 }
1855
1856 #[rstest]
1857 fn test_map_order_side() {
1858 assert!(matches!(
1859 map_order_side(OrderSide::Buy),
1860 CoinbaseOrderSide::Buy
1861 ));
1862 assert!(matches!(
1863 map_order_side(OrderSide::Sell),
1864 CoinbaseOrderSide::Sell
1865 ));
1866 }
1867
1868 #[rstest]
1869 fn test_build_order_configuration_market_base_size() {
1870 let cfg = build_order_configuration(
1871 OrderType::Market,
1872 OrderSide::Buy,
1873 Quantity::from("1.5"),
1874 None,
1875 None,
1876 TimeInForce::Ioc,
1877 None,
1878 false,
1879 false,
1880 false,
1881 )
1882 .unwrap();
1883
1884 match cfg {
1885 OrderConfiguration::MarketIoc(m) => {
1886 assert!(m.market_market_ioc.base_size.is_some());
1887 assert!(m.market_market_ioc.quote_size.is_none());
1888 }
1889 other => panic!("expected MarketIoc, was {other:?}"),
1890 }
1891 }
1892
1893 #[rstest]
1894 fn test_build_order_configuration_market_quote_size() {
1895 let cfg = build_order_configuration(
1896 OrderType::Market,
1897 OrderSide::Buy,
1898 Quantity::from("100"),
1899 None,
1900 None,
1901 TimeInForce::Ioc,
1902 None,
1903 false,
1904 true, false,
1906 )
1907 .unwrap();
1908
1909 match cfg {
1910 OrderConfiguration::MarketIoc(m) => {
1911 assert!(m.market_market_ioc.quote_size.is_some());
1912 assert!(m.market_market_ioc.base_size.is_none());
1913 }
1914 other => panic!("expected MarketIoc, was {other:?}"),
1915 }
1916 }
1917
1918 #[rstest]
1919 fn test_build_order_configuration_market_fok() {
1920 let cfg = build_order_configuration(
1921 OrderType::Market,
1922 OrderSide::Buy,
1923 Quantity::from("0.5"),
1924 None,
1925 None,
1926 TimeInForce::Fok,
1927 None,
1928 false,
1929 false,
1930 false,
1931 )
1932 .unwrap();
1933
1934 match cfg {
1935 OrderConfiguration::MarketFok(m) => {
1936 assert!(m.market_market_fok.base_size.is_some());
1937 assert!(m.market_market_fok.quote_size.is_none());
1938 }
1939 other => panic!("expected MarketFok, was {other:?}"),
1940 }
1941 }
1942
1943 #[rstest]
1944 #[case(TimeInForce::Day)]
1945 #[case(TimeInForce::Gtd)]
1946 fn test_build_order_configuration_market_rejects_unsupported_tif(#[case] tif: TimeInForce) {
1947 let result = build_order_configuration(
1948 OrderType::Market,
1949 OrderSide::Buy,
1950 Quantity::from("1"),
1951 None,
1952 None,
1953 tif,
1954 None,
1955 false,
1956 false,
1957 false,
1958 );
1959 assert!(result.is_err());
1960 }
1961
1962 #[rstest]
1963 fn test_build_order_configuration_limit_gtc_post_only() {
1964 let cfg = build_order_configuration(
1965 OrderType::Limit,
1966 OrderSide::Sell,
1967 Quantity::from("0.5"),
1968 Some(Price::from("50000.00")),
1969 None,
1970 TimeInForce::Gtc,
1971 None,
1972 true,
1973 false,
1974 false,
1975 )
1976 .unwrap();
1977
1978 match cfg {
1979 OrderConfiguration::LimitGtc(l) => assert!(l.limit_limit_gtc.post_only),
1980 other => panic!("expected LimitGtc, was {other:?}"),
1981 }
1982 }
1983
1984 #[rstest]
1985 fn test_build_order_configuration_limit_gtd_requires_expire_time() {
1986 let result = build_order_configuration(
1987 OrderType::Limit,
1988 OrderSide::Buy,
1989 Quantity::from("1"),
1990 Some(Price::from("100.00")),
1991 None,
1992 TimeInForce::Gtd,
1993 None,
1994 false,
1995 false,
1996 false,
1997 );
1998 assert!(result.is_err());
1999 }
2000
2001 #[rstest]
2002 fn test_build_order_configuration_stop_limit_uses_correct_direction() {
2003 let buy_cfg = build_order_configuration(
2004 OrderType::StopLimit,
2005 OrderSide::Buy,
2006 Quantity::from("1"),
2007 Some(Price::from("100.00")),
2008 Some(Price::from("99.00")),
2009 TimeInForce::Gtc,
2010 None,
2011 false,
2012 false,
2013 false,
2014 )
2015 .unwrap();
2016
2017 match buy_cfg {
2018 OrderConfiguration::StopLimitGtc(s) => assert_eq!(
2019 s.stop_limit_stop_limit_gtc.stop_direction,
2020 CoinbaseStopDirection::StopUp
2021 ),
2022 other => panic!("expected StopLimitGtc, was {other:?}"),
2023 }
2024
2025 let sell_cfg = build_order_configuration(
2026 OrderType::StopLimit,
2027 OrderSide::Sell,
2028 Quantity::from("1"),
2029 Some(Price::from("100.00")),
2030 Some(Price::from("99.00")),
2031 TimeInForce::Gtc,
2032 None,
2033 false,
2034 false,
2035 false,
2036 )
2037 .unwrap();
2038
2039 match sell_cfg {
2040 OrderConfiguration::StopLimitGtc(s) => assert_eq!(
2041 s.stop_limit_stop_limit_gtc.stop_direction,
2042 CoinbaseStopDirection::StopDown
2043 ),
2044 other => panic!("expected StopLimitGtc, was {other:?}"),
2045 }
2046 }
2047
2048 #[rstest]
2049 fn test_build_order_configuration_market_accepts_default_gtc() {
2050 let cfg = build_order_configuration(
2053 OrderType::Market,
2054 OrderSide::Buy,
2055 Quantity::from("1"),
2056 None,
2057 None,
2058 TimeInForce::Gtc,
2059 None,
2060 false,
2061 false,
2062 false,
2063 )
2064 .unwrap();
2065 assert!(matches!(cfg, OrderConfiguration::MarketIoc(_)));
2066 }
2067
2068 #[rstest]
2069 fn test_build_order_configuration_rejects_stop_market() {
2070 let result = build_order_configuration(
2071 OrderType::StopMarket,
2072 OrderSide::Buy,
2073 Quantity::from("1"),
2074 None,
2075 Some(Price::from("100.00")),
2076 TimeInForce::Gtc,
2077 None,
2078 false,
2079 false,
2080 false,
2081 );
2082 assert!(result.is_err());
2083 }
2084
2085 #[rstest]
2086 fn test_rest_quota_matches_documented_limit() {
2087 assert_eq!(COINBASE_REST_QUOTA.burst_size().get(), 30);
2088 }
2089
2090 #[rstest]
2091 fn test_default_retry_config_values() {
2092 let config = default_retry_config();
2093 assert_eq!(config.max_retries, 3);
2094 assert_eq!(config.initial_delay_ms, 100);
2095 assert_eq!(config.max_delay_ms, 5_000);
2096 assert_eq!(config.max_elapsed_ms, Some(180_000));
2097 }
2098}