1use std::{
23 collections::HashMap,
24 num::NonZeroU32,
25 sync::{Arc, LazyLock},
26};
27
28use anyhow::Context;
29use arc_swap::ArcSwap;
30use chrono::{DateTime, Utc};
31use nautilus_core::{
32 AtomicMap, UnixNanos,
33 consts::NAUTILUS_USER_AGENT,
34 time::{AtomicTime, get_atomic_clock_realtime},
35};
36use nautilus_model::{
37 enums::{OrderSide, OrderType, TimeInForce},
38 events::AccountState,
39 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
40 instruments::{Instrument, InstrumentAny},
41 reports::{FillReport, OrderStatusReport, PositionStatusReport},
42 types::{MarginBalance, Price, Quantity},
43};
44use nautilus_network::{
45 http::{HttpClient, HttpClientError, HttpResponse, Method, USER_AGENT},
46 ratelimiter::quota::Quota,
47 retry::{RetryConfig, RetryManager},
48};
49use rust_decimal::Decimal;
50use serde_json::Value;
51use tokio_util::sync::CancellationToken;
52use url::form_urlencoded;
53use ustr::Ustr;
54
55use crate::{
56 common::{
57 consts::{
58 ACCOUNTS_PAGE_LIMIT, ORDER_STATUS_OPEN, QUERY_KEY_CURSOR, QUERY_KEY_END_DATE,
59 QUERY_KEY_END_SEQUENCE_TIMESTAMP, QUERY_KEY_LIMIT, QUERY_KEY_ORDER_IDS,
60 QUERY_KEY_ORDER_STATUS, QUERY_KEY_PRODUCT_IDS, QUERY_KEY_START_DATE,
61 QUERY_KEY_START_SEQUENCE_TIMESTAMP, REST_API_PATH,
62 },
63 credential::CoinbaseCredential,
64 enums::{
65 CoinbaseEnvironment, CoinbaseMarginType, CoinbaseOrderSide, CoinbaseProductType,
66 CoinbaseStopDirection,
67 },
68 parse::format_rfc3339_from_nanos,
69 urls,
70 },
71 http::{
72 error::{Error, Result},
73 models::{
74 Account, AccountsResponse, CancelOrdersResponse, CfmBalanceSummary,
75 CfmBalanceSummaryResponse, CfmPositionResponse, CfmPositionsResponse,
76 CreateOrderResponse, EditOrderResponse, Fill, FillsResponse, Order, OrderResponse,
77 OrdersListResponse, ProductsResponse,
78 },
79 parse::{
80 parse_account_state, parse_cfm_account_state, parse_cfm_margin_balances,
81 parse_cfm_position_status_report, parse_fill_report, parse_instrument,
82 parse_order_status_report,
83 },
84 query::{
85 CancelOrdersRequest, CreateOrderRequest, EditOrderRequest, FillListQuery, LimitFok,
86 LimitFokParams, LimitGtc, LimitGtcParams, LimitGtd, LimitGtdParams, MarketFok,
87 MarketIoc, MarketParams, OrderConfiguration, OrderListQuery, StopLimitGtc,
88 StopLimitGtcParams, StopLimitGtd, StopLimitGtdParams,
89 },
90 },
91};
92
93pub static COINBASE_REST_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
95 Quota::per_second(NonZeroU32::new(30).expect("non-zero")).expect("valid constant")
96});
97
98#[must_use]
100pub fn default_retry_config() -> RetryConfig {
101 RetryConfig {
102 max_retries: 3,
103 initial_delay_ms: 100,
104 max_delay_ms: 5_000,
105 backoff_factor: 2.0,
106 jitter_ms: 250,
107 operation_timeout_ms: Some(60_000),
108 immediate_first: false,
109 max_elapsed_ms: Some(180_000),
110 }
111}
112
113#[must_use]
119pub fn data_client_retry_config() -> RetryConfig {
120 RetryConfig {
121 max_retries: 0,
122 initial_delay_ms: 100,
123 max_delay_ms: 100,
124 backoff_factor: 1.0,
125 jitter_ms: 0,
126 operation_timeout_ms: None,
127 immediate_first: false,
128 max_elapsed_ms: None,
129 }
130}
131
132fn encode_query(params: &[(&str, &str)]) -> String {
137 let mut serializer = form_urlencoded::Serializer::new(String::new());
138 for (k, v) in params {
139 serializer.append_pair(k, v);
140 }
141 serializer.finish()
142}
143
144#[derive(Debug)]
149pub struct CoinbaseRawHttpClient {
150 client: HttpClient,
151 credential: Option<CoinbaseCredential>,
152 base_url: ArcSwap<String>,
153 environment: CoinbaseEnvironment,
154 retry_manager: RetryManager<Error>,
155 cancellation_token: CancellationToken,
156}
157
158impl CoinbaseRawHttpClient {
159 pub fn new(
165 environment: CoinbaseEnvironment,
166 timeout_secs: u64,
167 proxy_url: Option<String>,
168 retry_config: Option<RetryConfig>,
169 ) -> std::result::Result<Self, HttpClientError> {
170 Ok(Self {
171 client: HttpClient::new(
172 Self::default_headers(),
173 vec![],
174 vec![],
175 Some(*COINBASE_REST_QUOTA),
176 Some(timeout_secs),
177 proxy_url,
178 )?,
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::new(
201 Self::default_headers(),
202 vec![],
203 vec![],
204 Some(*COINBASE_REST_QUOTA),
205 Some(timeout_secs),
206 proxy_url,
207 )?,
208 credential: Some(credential),
209 base_url: ArcSwap::from_pointee(urls::rest_url(environment).to_string()),
210 environment,
211 retry_manager: RetryManager::new(retry_config.unwrap_or_else(default_retry_config)),
212 cancellation_token: CancellationToken::new(),
213 })
214 }
215
216 pub fn from_env(environment: CoinbaseEnvironment) -> Result<Self> {
222 let credential = CoinbaseCredential::from_env()
223 .map_err(|e| Error::auth(format!("Missing credentials in environment: {e}")))?;
224 Self::with_credentials(credential, environment, 10, None, None)
225 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
226 }
227
228 pub fn from_credentials(
234 api_key: &str,
235 api_secret: &str,
236 environment: CoinbaseEnvironment,
237 timeout_secs: u64,
238 proxy_url: Option<String>,
239 retry_config: Option<RetryConfig>,
240 ) -> Result<Self> {
241 let credential = CoinbaseCredential::new(api_key.to_string(), api_secret.to_string());
242 Self::with_credentials(
243 credential,
244 environment,
245 timeout_secs,
246 proxy_url,
247 retry_config,
248 )
249 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
250 }
251
252 #[must_use]
254 pub fn cancellation_token(&self) -> &CancellationToken {
255 &self.cancellation_token
256 }
257
258 pub fn set_base_url(&self, url: String) {
262 self.base_url.store(Arc::new(url));
263 }
264
265 #[must_use]
267 pub fn environment(&self) -> CoinbaseEnvironment {
268 self.environment
269 }
270
271 #[must_use]
273 pub fn is_authenticated(&self) -> bool {
274 self.credential.is_some()
275 }
276
277 fn default_headers() -> HashMap<String, String> {
278 HashMap::from([
279 (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
280 ("Content-Type".to_string(), "application/json".to_string()),
281 ])
282 }
283
284 fn build_url(&self, path: &str) -> String {
285 format!("{}{REST_API_PATH}{path}", self.base_url.load())
286 }
287
288 fn build_jwt_uri(&self, method: &str, path: &str) -> String {
290 let base = self.base_url.load();
291 let host = base
292 .strip_prefix("https://")
293 .or_else(|| base.strip_prefix("http://"))
294 .unwrap_or(base.as_str());
295 format!("{method} {host}{REST_API_PATH}{path}")
296 }
297
298 fn auth_headers(&self, method: &str, path: &str) -> Result<HashMap<String, String>> {
299 let credential = self
300 .credential
301 .as_ref()
302 .ok_or_else(|| Error::auth("No credentials configured"))?;
303
304 let uri = self.build_jwt_uri(method, path);
305 let jwt = credential.build_rest_jwt(&uri)?;
306
307 Ok(HashMap::from([(
308 "Authorization".to_string(),
309 format!("Bearer {jwt}"),
310 )]))
311 }
312
313 fn parse_response(&self, response: &HttpResponse) -> Result<Value> {
314 if !response.status.is_success() {
315 return Err(Error::from_http_status(
316 response.status.as_u16(),
317 &response.body,
318 ));
319 }
320
321 if response.body.is_empty() {
322 return Ok(Value::Null);
323 }
324
325 serde_json::from_slice(&response.body).map_err(Error::Serde)
326 }
327
328 async fn send_request(
333 &self,
334 method: Method,
335 url: String,
336 sign_method: Option<&'static str>,
337 sign_path: Option<&str>,
338 body: Option<Vec<u8>>,
339 ) -> Result<Value> {
340 let sign_path_owned = sign_path.map(ToOwned::to_owned);
341 let operation_name = sign_path_owned
342 .as_deref()
343 .unwrap_or(url.as_str())
344 .to_string();
345
346 let is_idempotent = matches!(method, Method::GET | Method::DELETE);
347
348 let operation = || {
349 let method = method.clone();
350 let url = url.clone();
351 let body = body.clone();
352 let sign_path = sign_path_owned.clone();
353
354 async move {
355 let headers = match (sign_method, sign_path.as_deref()) {
356 (Some(m), Some(p)) => Some(self.auth_headers(m, p)?),
357 _ => None,
358 };
359
360 let response = self
361 .client
362 .request(method, url, None, headers, body, None, None)
363 .await
364 .map_err(Error::from_http_client)?;
365
366 self.parse_response(&response)
367 }
368 };
369
370 let should_retry = move |err: &Error| is_idempotent && err.is_retryable();
371
372 self.retry_manager
373 .execute_with_retry_with_cancel(
374 &operation_name,
375 operation,
376 should_retry,
377 Error::transport,
378 &self.cancellation_token,
379 )
380 .await
381 }
382
383 pub async fn get_public(&self, path: &str) -> Result<Value> {
385 let url = self.build_url(path);
386 self.send_request(Method::GET, url, None, None, None).await
387 }
388
389 pub async fn get_public_with_query(&self, path: &str, query: &str) -> Result<Value> {
391 let full_path = if query.is_empty() {
392 path.to_string()
393 } else {
394 format!("{path}?{query}")
395 };
396 let url = self.build_url(&full_path);
397 self.send_request(Method::GET, url, None, None, None).await
398 }
399
400 pub async fn get(&self, path: &str) -> Result<Value> {
402 let url = self.build_url(path);
403 self.send_request(Method::GET, url, Some("GET"), Some(path), None)
404 .await
405 }
406
407 pub async fn get_with_query(&self, path: &str, query: &str) -> Result<Value> {
413 let full_url_path = if query.is_empty() {
414 path.to_string()
415 } else {
416 format!("{path}?{query}")
417 };
418 let url = self.build_url(&full_url_path);
419 self.send_request(Method::GET, url, Some("GET"), Some(path), None)
421 .await
422 }
423
424 pub async fn post(&self, path: &str, body: &Value) -> Result<Value> {
426 let url = self.build_url(path);
427 let body_bytes = serde_json::to_vec(body).map_err(Error::Serde)?;
428 self.send_request(
429 Method::POST,
430 url,
431 Some("POST"),
432 Some(path),
433 Some(body_bytes),
434 )
435 .await
436 }
437
438 pub async fn delete(&self, path: &str) -> Result<Value> {
440 let url = self.build_url(path);
441 self.send_request(Method::DELETE, url, Some("DELETE"), Some(path), None)
442 .await
443 }
444
445 pub async fn get_products(&self) -> Result<Value> {
447 self.get_public("/market/products").await
448 }
449
450 pub async fn get_product(&self, product_id: &str) -> Result<Value> {
452 self.get_public(&format!("/market/products/{product_id}"))
453 .await
454 }
455
456 pub async fn get_candles(
458 &self,
459 product_id: &str,
460 start: &str,
461 end: &str,
462 granularity: &str,
463 ) -> Result<Value> {
464 let query = format!("start={start}&end={end}&granularity={granularity}");
465 self.get_public_with_query(&format!("/market/products/{product_id}/candles"), &query)
466 .await
467 }
468
469 pub async fn get_market_trades(&self, product_id: &str, limit: u32) -> Result<Value> {
471 let query = format!("limit={limit}");
472 self.get_public_with_query(&format!("/market/products/{product_id}/ticker"), &query)
473 .await
474 }
475
476 pub async fn get_best_bid_ask(&self, product_ids: &[&str]) -> Result<Value> {
481 let query = product_ids
482 .iter()
483 .map(|id| format!("product_ids={id}"))
484 .collect::<Vec<_>>()
485 .join("&");
486 self.get_with_query("/best_bid_ask", &query).await
487 }
488
489 pub async fn get_product_book(&self, product_id: &str, limit: Option<u32>) -> Result<Value> {
491 let mut query = format!("product_id={product_id}");
492
493 if let Some(limit) = limit {
494 query.push_str(&format!("&limit={limit}"));
495 }
496 self.get_public_with_query("/market/product_book", &query)
497 .await
498 }
499
500 pub async fn get_accounts(&self) -> Result<Value> {
502 self.get("/accounts").await
503 }
504
505 pub async fn get_accounts_with_query(&self, query: &str) -> Result<Value> {
507 if query.is_empty() {
508 self.get("/accounts").await
509 } else {
510 self.get_with_query("/accounts", query).await
511 }
512 }
513
514 pub async fn get_account(&self, account_id: &str) -> Result<Value> {
516 self.get(&format!("/accounts/{account_id}")).await
517 }
518
519 pub async fn get_portfolios(&self) -> Result<Value> {
521 self.get("/portfolios").await
522 }
523
524 pub async fn get_orders(&self, query: &str) -> Result<Value> {
526 self.get_with_query("/orders/historical/batch", query).await
527 }
528
529 pub async fn get_order(&self, order_id: &str) -> Result<Value> {
531 self.get(&format!("/orders/historical/{order_id}")).await
532 }
533
534 pub async fn get_fills(&self, query: &str) -> Result<Value> {
536 self.get_with_query("/orders/historical/fills", query).await
537 }
538
539 pub async fn get_transaction_summary(&self) -> Result<Value> {
541 self.get("/transaction_summary").await
542 }
543
544 pub async fn get_cfm_balance_summary(&self) -> Result<CfmBalanceSummaryResponse> {
550 let json = self.get("/cfm/balance_summary").await?;
551 serde_json::from_value(json).map_err(Error::Serde)
552 }
553
554 pub async fn get_cfm_positions(&self) -> Result<CfmPositionsResponse> {
560 let json = self.get("/cfm/positions").await?;
561 serde_json::from_value(json).map_err(Error::Serde)
562 }
563
564 pub async fn get_cfm_position(&self, product_id: &str) -> Result<CfmPositionResponse> {
570 let json = self.get(&format!("/cfm/positions/{product_id}")).await?;
571 serde_json::from_value(json).map_err(Error::Serde)
572 }
573
574 pub async fn fetch_all_accounts(&self) -> Result<Vec<Account>> {
579 let mut all = Vec::new();
580 let mut cursor: Option<String> = None;
581
582 loop {
583 let mut pairs: Vec<(&str, &str)> = vec![(QUERY_KEY_LIMIT, ACCOUNTS_PAGE_LIMIT)];
584 if let Some(c) = cursor.as_deref().filter(|s| !s.is_empty()) {
585 pairs.push((QUERY_KEY_CURSOR, c));
586 }
587 let query_str = encode_query(&pairs);
588
589 let json = self.get_accounts_with_query(&query_str).await?;
590 let response: AccountsResponse = serde_json::from_value(json).map_err(Error::Serde)?;
591
592 all.extend(response.accounts);
593
594 if !response.has_next || response.cursor.is_empty() {
595 break;
596 }
597 cursor = Some(response.cursor);
598 }
599
600 Ok(all)
601 }
602
603 pub async fn fetch_all_orders(&self, query: &OrderListQuery) -> Result<Vec<Order>> {
609 let mut collected: Vec<Order> = Vec::new();
610 let mut cursor: Option<String> = None;
611
612 loop {
613 let start_str = query.start.map(|s| s.to_rfc3339());
614 let end_str = query.end.map(|e| e.to_rfc3339());
615 let limit_str = query.limit.map(|l| l.to_string());
616
617 let mut pairs: Vec<(&str, &str)> = Vec::new();
618
619 if let Some(pid) = query.product_id.as_deref() {
622 pairs.push((QUERY_KEY_PRODUCT_IDS, pid));
623 }
624
625 if query.open_only {
626 pairs.push((QUERY_KEY_ORDER_STATUS, ORDER_STATUS_OPEN));
627 }
628
629 if let Some(s) = start_str.as_deref() {
630 pairs.push((QUERY_KEY_START_DATE, s));
631 }
632
633 if let Some(e) = end_str.as_deref() {
634 pairs.push((QUERY_KEY_END_DATE, e));
635 }
636
637 if let Some(l) = limit_str.as_deref() {
638 pairs.push((QUERY_KEY_LIMIT, l));
639 }
640
641 if let Some(c) = cursor.as_deref().filter(|s| !s.is_empty()) {
642 pairs.push((QUERY_KEY_CURSOR, c));
643 }
644
645 let query_str = encode_query(&pairs);
646 let json = self.get_orders(&query_str).await?;
647 let response: OrdersListResponse =
648 serde_json::from_value(json).map_err(Error::Serde)?;
649
650 for order in response.orders {
651 if let Some(cid) = query.client_order_id_filter.as_deref()
652 && order.client_order_id != cid
653 {
654 continue;
655 }
656 collected.push(order);
657 }
658
659 if let Some(limit) = query.limit
660 && collected.len() >= limit as usize
661 {
662 collected.truncate(limit as usize);
663 break;
664 }
665
666 if !response.has_next || response.cursor.is_empty() {
667 break;
668 }
669 cursor = Some(response.cursor);
670 }
671
672 Ok(collected)
673 }
674
675 pub async fn fetch_all_fills(&self, query: &FillListQuery) -> Result<Vec<Fill>> {
677 let mut collected: Vec<Fill> = Vec::new();
678 let mut cursor: Option<String> = None;
679
680 loop {
681 let start_str = query.start.map(|s| s.to_rfc3339());
682 let end_str = query.end.map(|e| e.to_rfc3339());
683 let limit_str = query.limit.map(|l| l.to_string());
684
685 let mut pairs: Vec<(&str, &str)> = Vec::new();
686
687 if let Some(pid) = query.product_id.as_deref() {
691 pairs.push((QUERY_KEY_PRODUCT_IDS, pid));
692 }
693
694 if let Some(vid) = query.venue_order_id.as_deref() {
695 pairs.push((QUERY_KEY_ORDER_IDS, vid));
696 }
697
698 if let Some(s) = start_str.as_deref() {
699 pairs.push((QUERY_KEY_START_SEQUENCE_TIMESTAMP, s));
700 }
701
702 if let Some(e) = end_str.as_deref() {
703 pairs.push((QUERY_KEY_END_SEQUENCE_TIMESTAMP, e));
704 }
705
706 if let Some(l) = limit_str.as_deref() {
707 pairs.push((QUERY_KEY_LIMIT, l));
708 }
709
710 if let Some(c) = cursor.as_deref().filter(|s| !s.is_empty()) {
711 pairs.push((QUERY_KEY_CURSOR, c));
712 }
713
714 let query_str = encode_query(&pairs);
715 let json = self.get_fills(&query_str).await?;
716 let response: FillsResponse = serde_json::from_value(json).map_err(Error::Serde)?;
717
718 collected.extend(response.fills);
719
720 if let Some(limit) = query.limit
721 && collected.len() >= limit as usize
722 {
723 collected.truncate(limit as usize);
724 break;
725 }
726
727 if response.cursor.is_empty() {
728 break;
729 }
730 cursor = Some(response.cursor);
731 }
732
733 Ok(collected)
734 }
735
736 pub async fn create_order(&self, request: &CreateOrderRequest) -> Result<CreateOrderResponse> {
742 let body = serde_json::to_value(request).map_err(Error::Serde)?;
743 let json = self.post("/orders", &body).await?;
744 serde_json::from_value(json).map_err(Error::Serde)
745 }
746
747 pub async fn cancel_orders(
753 &self,
754 request: &CancelOrdersRequest,
755 ) -> Result<CancelOrdersResponse> {
756 let body = serde_json::to_value(request).map_err(Error::Serde)?;
757 let json = self.post("/orders/batch_cancel", &body).await?;
758 serde_json::from_value(json).map_err(Error::Serde)
759 }
760
761 pub async fn edit_order(&self, request: &EditOrderRequest) -> Result<EditOrderResponse> {
770 let body = serde_json::to_value(request).map_err(Error::Serde)?;
771 let json = self.post("/orders/edit", &body).await?;
772 serde_json::from_value(json).map_err(Error::Serde)
773 }
774}
775
776#[derive(Debug, Clone)]
782#[cfg_attr(
783 feature = "python",
784 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.coinbase", from_py_object)
785)]
786#[cfg_attr(
787 feature = "python",
788 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.coinbase")
789)]
790pub struct CoinbaseHttpClient {
791 pub(crate) inner: Arc<CoinbaseRawHttpClient>,
792 clock: &'static AtomicTime,
793 instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
794 product_aliases: Arc<AtomicMap<Ustr, Ustr>>,
799}
800
801impl Default for CoinbaseHttpClient {
802 fn default() -> Self {
803 Self::new(CoinbaseEnvironment::Live, 10, None, None)
804 .expect("Failed to create default Coinbase HTTP client")
805 }
806}
807
808impl CoinbaseHttpClient {
809 pub fn new(
815 environment: CoinbaseEnvironment,
816 timeout_secs: u64,
817 proxy_url: Option<String>,
818 retry_config: Option<RetryConfig>,
819 ) -> std::result::Result<Self, HttpClientError> {
820 let raw = CoinbaseRawHttpClient::new(environment, timeout_secs, proxy_url, retry_config)?;
821 Ok(Self::from_raw(raw))
822 }
823
824 pub fn with_credentials(
830 credential: CoinbaseCredential,
831 environment: CoinbaseEnvironment,
832 timeout_secs: u64,
833 proxy_url: Option<String>,
834 retry_config: Option<RetryConfig>,
835 ) -> std::result::Result<Self, HttpClientError> {
836 let raw = CoinbaseRawHttpClient::with_credentials(
837 credential,
838 environment,
839 timeout_secs,
840 proxy_url,
841 retry_config,
842 )?;
843 Ok(Self::from_raw(raw))
844 }
845
846 pub fn from_env(environment: CoinbaseEnvironment) -> Result<Self> {
852 let raw = CoinbaseRawHttpClient::from_env(environment)?;
853 Ok(Self::from_raw(raw))
854 }
855
856 pub fn from_credentials(
862 api_key: &str,
863 api_secret: &str,
864 environment: CoinbaseEnvironment,
865 timeout_secs: u64,
866 proxy_url: Option<String>,
867 retry_config: Option<RetryConfig>,
868 ) -> Result<Self> {
869 let raw = CoinbaseRawHttpClient::from_credentials(
870 api_key,
871 api_secret,
872 environment,
873 timeout_secs,
874 proxy_url,
875 retry_config,
876 )?;
877 Ok(Self::from_raw(raw))
878 }
879
880 #[must_use]
882 pub fn cancellation_token(&self) -> &CancellationToken {
883 self.inner.cancellation_token()
884 }
885
886 fn from_raw(raw: CoinbaseRawHttpClient) -> Self {
887 Self {
888 inner: Arc::new(raw),
889 clock: get_atomic_clock_realtime(),
890 instruments: Arc::new(AtomicMap::new()),
891 product_aliases: Arc::new(AtomicMap::new()),
892 }
893 }
894
895 pub fn set_base_url(&self, url: String) {
899 self.inner.set_base_url(url);
900 }
901
902 #[must_use]
904 pub fn environment(&self) -> CoinbaseEnvironment {
905 self.inner.environment()
906 }
907
908 #[must_use]
910 pub fn is_authenticated(&self) -> bool {
911 self.inner.is_authenticated()
912 }
913
914 #[must_use]
916 pub fn instruments(&self) -> &Arc<AtomicMap<InstrumentId, InstrumentAny>> {
917 &self.instruments
918 }
919
920 #[must_use]
922 pub fn product_aliases(&self) -> &Arc<AtomicMap<Ustr, Ustr>> {
923 &self.product_aliases
924 }
925
926 #[must_use]
928 pub fn ts_now(&self) -> UnixNanos {
929 self.clock.get_time_ns()
930 }
931
932 pub async fn get_products(&self) -> Result<Value> {
934 self.inner.get_products().await
935 }
936
937 pub async fn get_product(&self, product_id: &str) -> Result<Value> {
939 self.inner.get_product(product_id).await
940 }
941
942 pub async fn get_candles(
944 &self,
945 product_id: &str,
946 start: &str,
947 end: &str,
948 granularity: &str,
949 ) -> Result<Value> {
950 self.inner
951 .get_candles(product_id, start, end, granularity)
952 .await
953 }
954
955 pub async fn get_market_trades(&self, product_id: &str, limit: u32) -> Result<Value> {
957 self.inner.get_market_trades(product_id, limit).await
958 }
959
960 pub async fn get_best_bid_ask(&self, product_ids: &[&str]) -> Result<Value> {
962 self.inner.get_best_bid_ask(product_ids).await
963 }
964
965 pub async fn get_product_book(&self, product_id: &str, limit: Option<u32>) -> Result<Value> {
967 self.inner.get_product_book(product_id, limit).await
968 }
969
970 pub async fn get_accounts(&self) -> Result<Value> {
972 self.inner.get_accounts().await
973 }
974
975 pub async fn get_account(&self, account_id: &str) -> Result<Value> {
977 self.inner.get_account(account_id).await
978 }
979
980 pub async fn get_portfolios(&self) -> Result<Value> {
982 self.inner.get_portfolios().await
983 }
984
985 pub async fn preview_order(&self, body: &Value) -> Result<Value> {
990 self.inner.post("/orders/preview", body).await
991 }
992
993 pub async fn get_orders(&self, query: &str) -> Result<Value> {
995 self.inner.get_orders(query).await
996 }
997
998 pub async fn get_order(&self, order_id: &str) -> Result<Value> {
1000 self.inner.get_order(order_id).await
1001 }
1002
1003 pub async fn get_fills(&self, query: &str) -> Result<Value> {
1005 self.inner.get_fills(query).await
1006 }
1007
1008 pub async fn get_transaction_summary(&self) -> Result<Value> {
1010 self.inner.get_transaction_summary().await
1011 }
1012
1013 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(
1096 &self,
1097 product_id: &str,
1098 ) -> anyhow::Result<crate::http::models::Product> {
1099 let json = self
1100 .inner
1101 .get_product(product_id)
1102 .await
1103 .map_err(|e| anyhow::anyhow!("Failed to fetch product '{product_id}': {e}"))?;
1104 serde_json::from_value(json).map_err(|e| anyhow::anyhow!(e))
1105 }
1106
1107 pub async fn request_account_state(
1119 &self,
1120 account_id: AccountId,
1121 ) -> anyhow::Result<AccountState> {
1122 let accounts = self
1123 .inner
1124 .fetch_all_accounts()
1125 .await
1126 .map_err(|e| anyhow::anyhow!("Failed to fetch accounts: {e}"))?;
1127 let ts_event = self.ts_now();
1128 parse_account_state(&accounts, account_id, true, ts_event, ts_event)
1129 }
1130
1131 pub async fn request_order_status_report(
1142 &self,
1143 account_id: AccountId,
1144 client_order_id: Option<ClientOrderId>,
1145 venue_order_id: Option<VenueOrderId>,
1146 ) -> anyhow::Result<OrderStatusReport> {
1147 let venue_order_id = match (venue_order_id, client_order_id) {
1148 (Some(vid), _) => vid,
1149 (None, Some(cid)) => {
1150 let query = OrderListQuery {
1152 client_order_id_filter: Some(cid.as_str().to_string()),
1153 ..Default::default()
1154 };
1155 let orders = self
1156 .inner
1157 .fetch_all_orders(&query)
1158 .await
1159 .map_err(|e| anyhow::anyhow!("Failed to fetch orders: {e}"))?;
1160 let order = orders
1161 .into_iter()
1162 .next()
1163 .ok_or_else(|| anyhow::anyhow!("No order found for client_order_id={cid}"))?;
1164 let instrument = self.get_or_fetch_instrument(order.product_id).await?;
1165 let ts_init = self.ts_now();
1166 return parse_order_status_report(&order, &instrument, account_id, ts_init);
1167 }
1168 (None, None) => {
1169 anyhow::bail!("Either client_order_id or venue_order_id is required")
1170 }
1171 };
1172
1173 let json = self
1174 .inner
1175 .get_order(venue_order_id.as_str())
1176 .await
1177 .map_err(|e| anyhow::anyhow!("Failed to fetch order: {e}"))?;
1178 let response: OrderResponse =
1179 serde_json::from_value(json).map_err(|e| anyhow::anyhow!(e))?;
1180 let instrument = self
1181 .get_or_fetch_instrument(response.order.product_id)
1182 .await?;
1183 let ts_init = self.ts_now();
1184 parse_order_status_report(&response.order, &instrument, account_id, ts_init)
1185 }
1186
1187 pub async fn request_order_status_reports(
1195 &self,
1196 account_id: AccountId,
1197 instrument_id: Option<InstrumentId>,
1198 open_only: bool,
1199 start: Option<DateTime<Utc>>,
1200 end: Option<DateTime<Utc>>,
1201 limit: Option<u32>,
1202 ) -> anyhow::Result<Vec<OrderStatusReport>> {
1203 let query = OrderListQuery {
1204 product_id: instrument_id.map(|id| id.symbol.as_str().to_string()),
1205 open_only,
1206 start,
1207 end,
1208 limit,
1209 client_order_id_filter: None,
1210 };
1211
1212 let orders = self
1213 .inner
1214 .fetch_all_orders(&query)
1215 .await
1216 .map_err(|e| anyhow::anyhow!("Failed to fetch orders: {e}"))?;
1217
1218 let ts_init = self.ts_now();
1219 let mut reports = Vec::with_capacity(orders.len());
1220
1221 for order in &orders {
1222 let instrument = match self.get_or_fetch_instrument(order.product_id).await {
1223 Ok(inst) => inst,
1224 Err(e) => {
1225 log::debug!("Skipping order {}: {e}", order.order_id);
1226 continue;
1227 }
1228 };
1229
1230 match parse_order_status_report(order, &instrument, account_id, ts_init) {
1231 Ok(report) => reports.push(report),
1232 Err(e) => log::warn!("Failed to parse order {}: {e}", order.order_id),
1233 }
1234 }
1235
1236 Ok(reports)
1237 }
1238
1239 pub async fn request_fill_reports(
1247 &self,
1248 account_id: AccountId,
1249 instrument_id: Option<InstrumentId>,
1250 venue_order_id: Option<VenueOrderId>,
1251 start: Option<DateTime<Utc>>,
1252 end: Option<DateTime<Utc>>,
1253 limit: Option<u32>,
1254 ) -> anyhow::Result<Vec<FillReport>> {
1255 let query = FillListQuery {
1256 product_id: instrument_id.map(|id| id.symbol.as_str().to_string()),
1257 venue_order_id: venue_order_id.map(|id| id.as_str().to_string()),
1258 start,
1259 end,
1260 limit,
1261 };
1262
1263 let fills = self
1264 .inner
1265 .fetch_all_fills(&query)
1266 .await
1267 .map_err(|e| anyhow::anyhow!("Failed to fetch fills: {e}"))?;
1268
1269 let ts_init = self.ts_now();
1270 let mut reports = Vec::with_capacity(fills.len());
1271
1272 for fill in &fills {
1273 let instrument = match self.get_or_fetch_instrument(fill.product_id).await {
1274 Ok(inst) => inst,
1275 Err(e) => {
1276 log::debug!("Skipping fill {}: {e}", fill.trade_id);
1277 continue;
1278 }
1279 };
1280
1281 match parse_fill_report(fill, &instrument, account_id, ts_init) {
1282 Ok(report) => reports.push(report),
1283 Err(e) => log::warn!("Failed to parse fill {}: {e}", fill.trade_id),
1284 }
1285 }
1286
1287 Ok(reports)
1288 }
1289
1290 pub fn cache_instrument(&self, instrument: &InstrumentAny) {
1292 self.instruments.rcu(|m| {
1293 m.insert(instrument.id(), instrument.clone());
1294 });
1295 }
1296
1297 pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
1299 self.instruments.rcu(|m| {
1300 for instrument in instruments {
1301 m.insert(instrument.id(), instrument.clone());
1302 }
1303 });
1304 }
1305
1306 pub fn record_product_aliases(&self, products: &[crate::http::models::Product]) {
1311 let aliased: Vec<(Ustr, Ustr)> = products
1312 .iter()
1313 .filter(|p| !p.alias.is_empty())
1314 .map(|p| (p.product_id, p.alias))
1315 .collect();
1316
1317 if aliased.is_empty() {
1318 return;
1319 }
1320
1321 self.product_aliases.rcu(|m| {
1322 for (product_id, alias) in &aliased {
1323 m.insert(*product_id, *alias);
1324 }
1325 });
1326 }
1327
1328 async fn get_or_fetch_instrument(&self, product_id: Ustr) -> anyhow::Result<InstrumentAny> {
1334 let instrument_id = InstrumentId::new(
1335 Symbol::new(product_id),
1336 *crate::common::consts::COINBASE_VENUE,
1337 );
1338
1339 if let Some(instrument) = self.instruments.get_cloned(&instrument_id) {
1340 return Ok(instrument);
1341 }
1342 self.request_instrument(product_id.as_str()).await
1346 }
1347
1348 #[allow(clippy::too_many_arguments)]
1361 pub async fn submit_order(
1362 &self,
1363 client_order_id: ClientOrderId,
1364 instrument_id: InstrumentId,
1365 side: OrderSide,
1366 order_type: OrderType,
1367 quantity: Quantity,
1368 time_in_force: TimeInForce,
1369 price: Option<Price>,
1370 trigger_price: Option<Price>,
1371 expire_time: Option<UnixNanos>,
1372 post_only: bool,
1373 is_quote_quantity: bool,
1374 leverage: Option<Decimal>,
1375 margin_type: Option<CoinbaseMarginType>,
1376 reduce_only: bool,
1377 retail_portfolio_id: Option<String>,
1378 ) -> anyhow::Result<CreateOrderResponse> {
1379 let coinbase_side = map_order_side(side)?;
1380 let order_config = build_order_configuration(
1381 order_type,
1382 side,
1383 quantity,
1384 price,
1385 trigger_price,
1386 time_in_force,
1387 expire_time,
1388 post_only,
1389 is_quote_quantity,
1390 reduce_only,
1391 )?;
1392
1393 let request = CreateOrderRequest {
1394 client_order_id: client_order_id.to_string(),
1395 product_id: instrument_id.symbol.inner(),
1396 side: coinbase_side,
1397 order_configuration: order_config,
1398 self_trade_prevention_id: None,
1399 leverage: leverage.map(|d| d.normalize().to_string()),
1400 margin_type,
1401 retail_portfolio_id,
1402 reduce_only,
1403 };
1404
1405 self.inner
1406 .create_order(&request)
1407 .await
1408 .context("failed to submit order")
1409 }
1410
1411 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) -> anyhow::Result<CoinbaseOrderSide> {
1576 match side {
1577 OrderSide::Buy => Ok(CoinbaseOrderSide::Buy),
1578 OrderSide::Sell => Ok(CoinbaseOrderSide::Sell),
1579 OrderSide::NoOrderSide => anyhow::bail!("NoOrderSide is not a valid Coinbase side"),
1580 }
1581}
1582
1583#[allow(clippy::too_many_arguments)]
1596pub fn build_order_configuration(
1597 order_type: OrderType,
1598 side: OrderSide,
1599 quantity: Quantity,
1600 price: Option<Price>,
1601 trigger_price: Option<Price>,
1602 time_in_force: TimeInForce,
1603 expire_time: Option<UnixNanos>,
1604 post_only: bool,
1605 is_quote_quantity: bool,
1606 reduce_only: bool,
1607) -> anyhow::Result<OrderConfiguration> {
1608 let qty = quantity.as_decimal();
1609 let price = price.map(|p| p.as_decimal());
1610 let trigger = trigger_price.map(|p| p.as_decimal());
1611
1612 if reduce_only && matches!(order_type, OrderType::Market) {
1613 log::debug!("Coinbase MARKET orders do not accept reduce_only; ignoring flag");
1614 }
1615
1616 match order_type {
1617 OrderType::Market => {
1618 let params = if is_quote_quantity {
1629 MarketParams {
1630 quote_size: Some(qty),
1631 base_size: None,
1632 }
1633 } else {
1634 MarketParams {
1635 quote_size: None,
1636 base_size: Some(qty),
1637 }
1638 };
1639
1640 match time_in_force {
1641 TimeInForce::Ioc | TimeInForce::Gtc => {
1642 Ok(OrderConfiguration::MarketIoc(MarketIoc {
1643 market_market_ioc: params,
1644 }))
1645 }
1646 TimeInForce::Fok => Ok(OrderConfiguration::MarketFok(MarketFok {
1647 market_market_fok: params,
1648 })),
1649 _ => {
1650 anyhow::bail!(
1651 "Unsupported TIF {time_in_force} for MARKET on Coinbase (use IOC or FOK)"
1652 )
1653 }
1654 }
1655 }
1656 OrderType::Limit => {
1657 let limit_price =
1658 price.ok_or_else(|| anyhow::anyhow!("LIMIT order requires a price"))?;
1659
1660 match time_in_force {
1661 TimeInForce::Gtc => Ok(OrderConfiguration::LimitGtc(LimitGtc {
1662 limit_limit_gtc: LimitGtcParams {
1663 base_size: qty,
1664 limit_price,
1665 post_only,
1666 },
1667 })),
1668 TimeInForce::Gtd => {
1669 let expire = expire_time
1670 .ok_or_else(|| anyhow::anyhow!("GTD LIMIT requires expire_time"))?;
1671 Ok(OrderConfiguration::LimitGtd(LimitGtd {
1672 limit_limit_gtd: LimitGtdParams {
1673 base_size: qty,
1674 limit_price,
1675 end_time: format_rfc3339_from_nanos(expire)?,
1676 post_only,
1677 },
1678 }))
1679 }
1680 TimeInForce::Fok => Ok(OrderConfiguration::LimitFok(LimitFok {
1681 limit_limit_fok: LimitFokParams {
1682 base_size: qty,
1683 limit_price,
1684 },
1685 })),
1686 _ => anyhow::bail!("Unsupported TIF {time_in_force} for LIMIT on Coinbase"),
1687 }
1688 }
1689 OrderType::StopLimit => {
1690 let limit_price =
1691 price.ok_or_else(|| anyhow::anyhow!("STOP_LIMIT order requires a price"))?;
1692 let stop_price = trigger
1693 .ok_or_else(|| anyhow::anyhow!("STOP_LIMIT order requires trigger_price"))?;
1694 let direction = match side {
1695 OrderSide::Buy => CoinbaseStopDirection::StopUp,
1696 OrderSide::Sell => CoinbaseStopDirection::StopDown,
1697 OrderSide::NoOrderSide => {
1698 anyhow::bail!("STOP_LIMIT requires a defined side")
1699 }
1700 };
1701
1702 match time_in_force {
1703 TimeInForce::Gtc => Ok(OrderConfiguration::StopLimitGtc(StopLimitGtc {
1704 stop_limit_stop_limit_gtc: StopLimitGtcParams {
1705 base_size: qty,
1706 limit_price,
1707 stop_price,
1708 stop_direction: direction,
1709 },
1710 })),
1711 TimeInForce::Gtd => {
1712 let expire = expire_time
1713 .ok_or_else(|| anyhow::anyhow!("GTD STOP_LIMIT requires expire_time"))?;
1714 Ok(OrderConfiguration::StopLimitGtd(StopLimitGtd {
1715 stop_limit_stop_limit_gtd: StopLimitGtdParams {
1716 base_size: qty,
1717 limit_price,
1718 stop_price,
1719 stop_direction: direction,
1720 end_time: format_rfc3339_from_nanos(expire)?,
1721 },
1722 }))
1723 }
1724 _ => anyhow::bail!("Unsupported TIF {time_in_force} for STOP_LIMIT on Coinbase"),
1725 }
1726 }
1727 other => anyhow::bail!("Unsupported order type for Coinbase: {other}"),
1728 }
1729}
1730
1731#[cfg(test)]
1732mod tests {
1733 use rstest::rstest;
1734
1735 use super::*;
1736
1737 #[rstest]
1738 fn test_raw_client_construction_live() {
1739 let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1740 assert_eq!(client.environment(), CoinbaseEnvironment::Live);
1741 assert!(!client.is_authenticated());
1742 }
1743
1744 #[rstest]
1745 fn test_raw_client_construction_sandbox() {
1746 let client =
1747 CoinbaseRawHttpClient::new(CoinbaseEnvironment::Sandbox, 10, None, None).unwrap();
1748 assert_eq!(client.environment(), CoinbaseEnvironment::Sandbox);
1749 }
1750
1751 #[rstest]
1752 fn test_raw_build_url() {
1753 let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1754 let url = client.build_url("/products");
1755 assert_eq!(url, "https://api.coinbase.com/api/v3/brokerage/products");
1756 }
1757
1758 #[rstest]
1759 fn test_raw_build_jwt_uri_live() {
1760 let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1761 let uri = client.build_jwt_uri("GET", "/accounts");
1762 assert_eq!(uri, "GET api.coinbase.com/api/v3/brokerage/accounts");
1763 }
1764
1765 #[rstest]
1766 fn test_raw_build_jwt_uri_sandbox() {
1767 let client =
1768 CoinbaseRawHttpClient::new(CoinbaseEnvironment::Sandbox, 10, None, None).unwrap();
1769 let uri = client.build_jwt_uri("GET", "/accounts");
1770 assert_eq!(
1771 uri,
1772 "GET api-sandbox.coinbase.com/api/v3/brokerage/accounts"
1773 );
1774 }
1775
1776 #[rstest]
1777 fn test_raw_build_jwt_uri_custom_base_url() {
1778 let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1779 client.set_base_url("http://localhost:8080".to_string());
1780 let uri = client.build_jwt_uri("POST", "/orders");
1781 assert_eq!(uri, "POST localhost:8080/api/v3/brokerage/orders");
1782 }
1783
1784 #[rstest]
1785 fn test_raw_set_base_url_safe_after_clone_via_arc() {
1786 let raw = Arc::new(
1787 CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap(),
1788 );
1789 let other = Arc::clone(&raw);
1790 raw.set_base_url("http://localhost:1234".to_string());
1792 assert!(other.build_url("/foo").starts_with("http://localhost:1234"));
1793 }
1794
1795 #[rstest]
1796 fn test_raw_auth_headers_without_credentials() {
1797 let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1798 let result = client.auth_headers("GET", "/accounts");
1799 assert!(result.is_err());
1800 assert!(result.unwrap_err().is_auth_error());
1801 }
1802
1803 #[rstest]
1804 fn test_domain_client_construction() {
1805 let client = CoinbaseHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1806 assert_eq!(client.environment(), CoinbaseEnvironment::Live);
1807 assert!(!client.is_authenticated());
1808 }
1809
1810 #[rstest]
1811 fn test_domain_client_default() {
1812 let client = CoinbaseHttpClient::default();
1813 assert_eq!(client.environment(), CoinbaseEnvironment::Live);
1814 }
1815
1816 #[rstest]
1817 fn test_domain_client_instruments_cache_empty() {
1818 let client = CoinbaseHttpClient::default();
1819 assert!(client.instruments().is_empty());
1820 }
1821
1822 #[rstest]
1823 fn test_domain_client_set_base_url() {
1824 let client = CoinbaseHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1825 let cloned = client.clone();
1826 client.set_base_url("http://localhost:9090".to_string());
1828 let url = cloned.inner.build_url("/test");
1829 assert!(url.starts_with("http://localhost:9090"));
1830 }
1831
1832 #[rstest]
1833 fn test_encode_query_escapes_rfc3339_timestamps() {
1834 let query = encode_query(&[("start_date", "2024-01-15T10:00:00+00:00")]);
1835 assert_eq!(query, "start_date=2024-01-15T10%3A00%3A00%2B00%3A00");
1837 }
1838
1839 #[rstest]
1840 fn test_encode_query_escapes_opaque_cursor() {
1841 let query = encode_query(&[("cursor", "a/b+c=?&x")]);
1842 assert!(!query.contains("a/b+c=?&x"));
1844 assert!(query.starts_with("cursor="));
1845 }
1846
1847 #[rstest]
1848 fn test_encode_query_joins_pairs_with_ampersand() {
1849 let query = encode_query(&[("product_id", "BTC-USD"), ("limit", "50")]);
1850 assert_eq!(query, "product_id=BTC-USD&limit=50");
1851 }
1852
1853 #[rstest]
1854 fn test_map_order_side_rejects_no_side() {
1855 assert!(matches!(
1856 map_order_side(OrderSide::Buy).unwrap(),
1857 CoinbaseOrderSide::Buy
1858 ));
1859 assert!(matches!(
1860 map_order_side(OrderSide::Sell).unwrap(),
1861 CoinbaseOrderSide::Sell
1862 ));
1863 assert!(map_order_side(OrderSide::NoOrderSide).is_err());
1864 }
1865
1866 #[rstest]
1867 fn test_build_order_configuration_market_base_size() {
1868 let cfg = build_order_configuration(
1869 OrderType::Market,
1870 OrderSide::Buy,
1871 Quantity::from("1.5"),
1872 None,
1873 None,
1874 TimeInForce::Ioc,
1875 None,
1876 false,
1877 false,
1878 false,
1879 )
1880 .unwrap();
1881
1882 match cfg {
1883 OrderConfiguration::MarketIoc(m) => {
1884 assert!(m.market_market_ioc.base_size.is_some());
1885 assert!(m.market_market_ioc.quote_size.is_none());
1886 }
1887 other => panic!("expected MarketIoc, was {other:?}"),
1888 }
1889 }
1890
1891 #[rstest]
1892 fn test_build_order_configuration_market_quote_size() {
1893 let cfg = build_order_configuration(
1894 OrderType::Market,
1895 OrderSide::Buy,
1896 Quantity::from("100"),
1897 None,
1898 None,
1899 TimeInForce::Ioc,
1900 None,
1901 false,
1902 true, false,
1904 )
1905 .unwrap();
1906
1907 match cfg {
1908 OrderConfiguration::MarketIoc(m) => {
1909 assert!(m.market_market_ioc.quote_size.is_some());
1910 assert!(m.market_market_ioc.base_size.is_none());
1911 }
1912 other => panic!("expected MarketIoc, was {other:?}"),
1913 }
1914 }
1915
1916 #[rstest]
1917 fn test_build_order_configuration_market_fok() {
1918 let cfg = build_order_configuration(
1919 OrderType::Market,
1920 OrderSide::Buy,
1921 Quantity::from("0.5"),
1922 None,
1923 None,
1924 TimeInForce::Fok,
1925 None,
1926 false,
1927 false,
1928 false,
1929 )
1930 .unwrap();
1931
1932 match cfg {
1933 OrderConfiguration::MarketFok(m) => {
1934 assert!(m.market_market_fok.base_size.is_some());
1935 assert!(m.market_market_fok.quote_size.is_none());
1936 }
1937 other => panic!("expected MarketFok, was {other:?}"),
1938 }
1939 }
1940
1941 #[rstest]
1942 #[case(TimeInForce::Day)]
1943 #[case(TimeInForce::Gtd)]
1944 fn test_build_order_configuration_market_rejects_unsupported_tif(#[case] tif: TimeInForce) {
1945 let result = build_order_configuration(
1946 OrderType::Market,
1947 OrderSide::Buy,
1948 Quantity::from("1"),
1949 None,
1950 None,
1951 tif,
1952 None,
1953 false,
1954 false,
1955 false,
1956 );
1957 assert!(result.is_err());
1958 }
1959
1960 #[rstest]
1961 fn test_build_order_configuration_limit_gtc_post_only() {
1962 let cfg = build_order_configuration(
1963 OrderType::Limit,
1964 OrderSide::Sell,
1965 Quantity::from("0.5"),
1966 Some(Price::from("50000.00")),
1967 None,
1968 TimeInForce::Gtc,
1969 None,
1970 true,
1971 false,
1972 false,
1973 )
1974 .unwrap();
1975
1976 match cfg {
1977 OrderConfiguration::LimitGtc(l) => assert!(l.limit_limit_gtc.post_only),
1978 other => panic!("expected LimitGtc, was {other:?}"),
1979 }
1980 }
1981
1982 #[rstest]
1983 fn test_build_order_configuration_limit_gtd_requires_expire_time() {
1984 let result = build_order_configuration(
1985 OrderType::Limit,
1986 OrderSide::Buy,
1987 Quantity::from("1"),
1988 Some(Price::from("100.00")),
1989 None,
1990 TimeInForce::Gtd,
1991 None,
1992 false,
1993 false,
1994 false,
1995 );
1996 assert!(result.is_err());
1997 }
1998
1999 #[rstest]
2000 fn test_build_order_configuration_stop_limit_uses_correct_direction() {
2001 let buy_cfg = build_order_configuration(
2002 OrderType::StopLimit,
2003 OrderSide::Buy,
2004 Quantity::from("1"),
2005 Some(Price::from("100.00")),
2006 Some(Price::from("99.00")),
2007 TimeInForce::Gtc,
2008 None,
2009 false,
2010 false,
2011 false,
2012 )
2013 .unwrap();
2014
2015 match buy_cfg {
2016 OrderConfiguration::StopLimitGtc(s) => assert_eq!(
2017 s.stop_limit_stop_limit_gtc.stop_direction,
2018 CoinbaseStopDirection::StopUp
2019 ),
2020 other => panic!("expected StopLimitGtc, was {other:?}"),
2021 }
2022
2023 let sell_cfg = build_order_configuration(
2024 OrderType::StopLimit,
2025 OrderSide::Sell,
2026 Quantity::from("1"),
2027 Some(Price::from("100.00")),
2028 Some(Price::from("99.00")),
2029 TimeInForce::Gtc,
2030 None,
2031 false,
2032 false,
2033 false,
2034 )
2035 .unwrap();
2036
2037 match sell_cfg {
2038 OrderConfiguration::StopLimitGtc(s) => assert_eq!(
2039 s.stop_limit_stop_limit_gtc.stop_direction,
2040 CoinbaseStopDirection::StopDown
2041 ),
2042 other => panic!("expected StopLimitGtc, was {other:?}"),
2043 }
2044 }
2045
2046 #[rstest]
2047 fn test_build_order_configuration_market_accepts_default_gtc() {
2048 let cfg = build_order_configuration(
2051 OrderType::Market,
2052 OrderSide::Buy,
2053 Quantity::from("1"),
2054 None,
2055 None,
2056 TimeInForce::Gtc,
2057 None,
2058 false,
2059 false,
2060 false,
2061 )
2062 .unwrap();
2063 assert!(matches!(cfg, OrderConfiguration::MarketIoc(_)));
2064 }
2065
2066 #[rstest]
2067 fn test_build_order_configuration_rejects_stop_market() {
2068 let result = build_order_configuration(
2069 OrderType::StopMarket,
2070 OrderSide::Buy,
2071 Quantity::from("1"),
2072 None,
2073 Some(Price::from("100.00")),
2074 TimeInForce::Gtc,
2075 None,
2076 false,
2077 false,
2078 false,
2079 );
2080 assert!(result.is_err());
2081 }
2082
2083 #[rstest]
2084 fn test_rest_quota_matches_documented_limit() {
2085 assert_eq!(COINBASE_REST_QUOTA.burst_size().get(), 30);
2086 }
2087
2088 #[rstest]
2089 fn test_default_retry_config_values() {
2090 let config = default_retry_config();
2091 assert_eq!(config.max_retries, 3);
2092 assert_eq!(config.initial_delay_ms, 100);
2093 assert_eq!(config.max_delay_ms, 5_000);
2094 assert_eq!(config.max_elapsed_ms, Some(180_000));
2095 }
2096}