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)]
786pub struct CoinbaseHttpClient {
787 pub(crate) inner: Arc<CoinbaseRawHttpClient>,
788 clock: &'static AtomicTime,
789 instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
790 product_aliases: Arc<AtomicMap<Ustr, Ustr>>,
795}
796
797impl Default for CoinbaseHttpClient {
798 fn default() -> Self {
799 Self::new(CoinbaseEnvironment::Live, 10, None, None)
800 .expect("Failed to create default Coinbase HTTP client")
801 }
802}
803
804impl CoinbaseHttpClient {
805 pub fn new(
811 environment: CoinbaseEnvironment,
812 timeout_secs: u64,
813 proxy_url: Option<String>,
814 retry_config: Option<RetryConfig>,
815 ) -> std::result::Result<Self, HttpClientError> {
816 let raw = CoinbaseRawHttpClient::new(environment, timeout_secs, proxy_url, retry_config)?;
817 Ok(Self::from_raw(raw))
818 }
819
820 pub fn with_credentials(
826 credential: CoinbaseCredential,
827 environment: CoinbaseEnvironment,
828 timeout_secs: u64,
829 proxy_url: Option<String>,
830 retry_config: Option<RetryConfig>,
831 ) -> std::result::Result<Self, HttpClientError> {
832 let raw = CoinbaseRawHttpClient::with_credentials(
833 credential,
834 environment,
835 timeout_secs,
836 proxy_url,
837 retry_config,
838 )?;
839 Ok(Self::from_raw(raw))
840 }
841
842 pub fn from_env(environment: CoinbaseEnvironment) -> Result<Self> {
848 let raw = CoinbaseRawHttpClient::from_env(environment)?;
849 Ok(Self::from_raw(raw))
850 }
851
852 pub fn from_credentials(
858 api_key: &str,
859 api_secret: &str,
860 environment: CoinbaseEnvironment,
861 timeout_secs: u64,
862 proxy_url: Option<String>,
863 retry_config: Option<RetryConfig>,
864 ) -> Result<Self> {
865 let raw = CoinbaseRawHttpClient::from_credentials(
866 api_key,
867 api_secret,
868 environment,
869 timeout_secs,
870 proxy_url,
871 retry_config,
872 )?;
873 Ok(Self::from_raw(raw))
874 }
875
876 #[must_use]
878 pub fn cancellation_token(&self) -> &CancellationToken {
879 self.inner.cancellation_token()
880 }
881
882 fn from_raw(raw: CoinbaseRawHttpClient) -> Self {
883 Self {
884 inner: Arc::new(raw),
885 clock: get_atomic_clock_realtime(),
886 instruments: Arc::new(AtomicMap::new()),
887 product_aliases: Arc::new(AtomicMap::new()),
888 }
889 }
890
891 pub fn set_base_url(&self, url: String) {
895 self.inner.set_base_url(url);
896 }
897
898 #[must_use]
900 pub fn environment(&self) -> CoinbaseEnvironment {
901 self.inner.environment()
902 }
903
904 #[must_use]
906 pub fn is_authenticated(&self) -> bool {
907 self.inner.is_authenticated()
908 }
909
910 #[must_use]
912 pub fn instruments(&self) -> &Arc<AtomicMap<InstrumentId, InstrumentAny>> {
913 &self.instruments
914 }
915
916 #[must_use]
918 pub fn product_aliases(&self) -> &Arc<AtomicMap<Ustr, Ustr>> {
919 &self.product_aliases
920 }
921
922 #[must_use]
924 pub fn ts_now(&self) -> UnixNanos {
925 self.clock.get_time_ns()
926 }
927
928 pub async fn get_products(&self) -> Result<Value> {
930 self.inner.get_products().await
931 }
932
933 pub async fn get_product(&self, product_id: &str) -> Result<Value> {
935 self.inner.get_product(product_id).await
936 }
937
938 pub async fn get_candles(
940 &self,
941 product_id: &str,
942 start: &str,
943 end: &str,
944 granularity: &str,
945 ) -> Result<Value> {
946 self.inner
947 .get_candles(product_id, start, end, granularity)
948 .await
949 }
950
951 pub async fn get_market_trades(&self, product_id: &str, limit: u32) -> Result<Value> {
953 self.inner.get_market_trades(product_id, limit).await
954 }
955
956 pub async fn get_best_bid_ask(&self, product_ids: &[&str]) -> Result<Value> {
958 self.inner.get_best_bid_ask(product_ids).await
959 }
960
961 pub async fn get_product_book(&self, product_id: &str, limit: Option<u32>) -> Result<Value> {
963 self.inner.get_product_book(product_id, limit).await
964 }
965
966 pub async fn get_accounts(&self) -> Result<Value> {
968 self.inner.get_accounts().await
969 }
970
971 pub async fn get_account(&self, account_id: &str) -> Result<Value> {
973 self.inner.get_account(account_id).await
974 }
975
976 pub async fn get_portfolios(&self) -> Result<Value> {
978 self.inner.get_portfolios().await
979 }
980
981 pub async fn preview_order(&self, body: &Value) -> Result<Value> {
986 self.inner.post("/orders/preview", body).await
987 }
988
989 pub async fn get_orders(&self, query: &str) -> Result<Value> {
991 self.inner.get_orders(query).await
992 }
993
994 pub async fn get_order(&self, order_id: &str) -> Result<Value> {
996 self.inner.get_order(order_id).await
997 }
998
999 pub async fn get_fills(&self, query: &str) -> Result<Value> {
1001 self.inner.get_fills(query).await
1002 }
1003
1004 pub async fn get_transaction_summary(&self) -> Result<Value> {
1006 self.inner.get_transaction_summary().await
1007 }
1008
1009 pub async fn request_instruments(
1020 &self,
1021 product_type: Option<CoinbaseProductType>,
1022 ) -> anyhow::Result<Vec<InstrumentAny>> {
1023 let json = self
1024 .inner
1025 .get_products()
1026 .await
1027 .map_err(|e| anyhow::anyhow!("Failed to fetch products: {e}"))?;
1028 let response: ProductsResponse =
1029 serde_json::from_value(json).map_err(|e| anyhow::anyhow!(e))?;
1030
1031 let ts_init = self.ts_now();
1032 let mut instruments = Vec::with_capacity(response.products.len());
1033
1034 for product in &response.products {
1035 if let Some(filter) = product_type
1036 && product.product_type != filter
1037 {
1038 continue;
1039 }
1040
1041 match parse_instrument(product, ts_init) {
1042 Ok(instrument) => instruments.push(instrument),
1043 Err(e) => {
1044 log::debug!(
1045 "Skipping product '{}' during parse: {e}",
1046 product.product_id
1047 );
1048 }
1049 }
1050 }
1051
1052 self.cache_instruments(&instruments);
1053 self.record_product_aliases(&response.products);
1054 Ok(instruments)
1055 }
1056
1057 pub async fn request_instrument(&self, product_id: &str) -> anyhow::Result<InstrumentAny> {
1066 let json = self
1067 .inner
1068 .get_product(product_id)
1069 .await
1070 .map_err(|e| anyhow::anyhow!("Failed to fetch product '{product_id}': {e}"))?;
1071 let product: crate::http::models::Product =
1072 serde_json::from_value(json).map_err(|e| anyhow::anyhow!(e))?;
1073 let ts_init = self.ts_now();
1074 let instrument = parse_instrument(&product, ts_init)?;
1075 self.cache_instrument(&instrument);
1076 self.record_product_aliases(std::slice::from_ref(&product));
1077 Ok(instrument)
1078 }
1079
1080 pub async fn request_raw_product(
1092 &self,
1093 product_id: &str,
1094 ) -> anyhow::Result<crate::http::models::Product> {
1095 let json = self
1096 .inner
1097 .get_product(product_id)
1098 .await
1099 .map_err(|e| anyhow::anyhow!("Failed to fetch product '{product_id}': {e}"))?;
1100 serde_json::from_value(json).map_err(|e| anyhow::anyhow!(e))
1101 }
1102
1103 pub async fn request_account_state(
1115 &self,
1116 account_id: AccountId,
1117 ) -> anyhow::Result<AccountState> {
1118 let accounts = self
1119 .inner
1120 .fetch_all_accounts()
1121 .await
1122 .map_err(|e| anyhow::anyhow!("Failed to fetch accounts: {e}"))?;
1123 let ts_event = self.ts_now();
1124 parse_account_state(&accounts, account_id, true, ts_event, ts_event)
1125 }
1126
1127 pub async fn request_order_status_report(
1138 &self,
1139 account_id: AccountId,
1140 client_order_id: Option<ClientOrderId>,
1141 venue_order_id: Option<VenueOrderId>,
1142 ) -> anyhow::Result<OrderStatusReport> {
1143 let venue_order_id = match (venue_order_id, client_order_id) {
1144 (Some(vid), _) => vid,
1145 (None, Some(cid)) => {
1146 let query = OrderListQuery {
1148 client_order_id_filter: Some(cid.as_str().to_string()),
1149 ..Default::default()
1150 };
1151 let orders = self
1152 .inner
1153 .fetch_all_orders(&query)
1154 .await
1155 .map_err(|e| anyhow::anyhow!("Failed to fetch orders: {e}"))?;
1156 let order = orders
1157 .into_iter()
1158 .next()
1159 .ok_or_else(|| anyhow::anyhow!("No order found for client_order_id={cid}"))?;
1160 let instrument = self.get_or_fetch_instrument(order.product_id).await?;
1161 let ts_init = self.ts_now();
1162 return parse_order_status_report(&order, &instrument, account_id, ts_init);
1163 }
1164 (None, None) => {
1165 anyhow::bail!("Either client_order_id or venue_order_id is required")
1166 }
1167 };
1168
1169 let json = self
1170 .inner
1171 .get_order(venue_order_id.as_str())
1172 .await
1173 .map_err(|e| anyhow::anyhow!("Failed to fetch order: {e}"))?;
1174 let response: OrderResponse =
1175 serde_json::from_value(json).map_err(|e| anyhow::anyhow!(e))?;
1176 let instrument = self
1177 .get_or_fetch_instrument(response.order.product_id)
1178 .await?;
1179 let ts_init = self.ts_now();
1180 parse_order_status_report(&response.order, &instrument, account_id, ts_init)
1181 }
1182
1183 pub async fn request_order_status_reports(
1191 &self,
1192 account_id: AccountId,
1193 instrument_id: Option<InstrumentId>,
1194 open_only: bool,
1195 start: Option<DateTime<Utc>>,
1196 end: Option<DateTime<Utc>>,
1197 limit: Option<u32>,
1198 ) -> anyhow::Result<Vec<OrderStatusReport>> {
1199 let query = OrderListQuery {
1200 product_id: instrument_id.map(|id| id.symbol.as_str().to_string()),
1201 open_only,
1202 start,
1203 end,
1204 limit,
1205 client_order_id_filter: None,
1206 };
1207
1208 let orders = self
1209 .inner
1210 .fetch_all_orders(&query)
1211 .await
1212 .map_err(|e| anyhow::anyhow!("Failed to fetch orders: {e}"))?;
1213
1214 let ts_init = self.ts_now();
1215 let mut reports = Vec::with_capacity(orders.len());
1216
1217 for order in &orders {
1218 let instrument = match self.get_or_fetch_instrument(order.product_id).await {
1219 Ok(inst) => inst,
1220 Err(e) => {
1221 log::debug!("Skipping order {}: {e}", order.order_id);
1222 continue;
1223 }
1224 };
1225
1226 match parse_order_status_report(order, &instrument, account_id, ts_init) {
1227 Ok(report) => reports.push(report),
1228 Err(e) => log::warn!("Failed to parse order {}: {e}", order.order_id),
1229 }
1230 }
1231
1232 Ok(reports)
1233 }
1234
1235 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<DateTime<Utc>>,
1248 end: Option<DateTime<Utc>>,
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)]
1357 pub async fn submit_order(
1358 &self,
1359 client_order_id: ClientOrderId,
1360 instrument_id: InstrumentId,
1361 side: OrderSide,
1362 order_type: OrderType,
1363 quantity: Quantity,
1364 time_in_force: TimeInForce,
1365 price: Option<Price>,
1366 trigger_price: Option<Price>,
1367 expire_time: Option<UnixNanos>,
1368 post_only: bool,
1369 is_quote_quantity: bool,
1370 leverage: Option<Decimal>,
1371 margin_type: Option<CoinbaseMarginType>,
1372 reduce_only: bool,
1373 retail_portfolio_id: Option<String>,
1374 ) -> anyhow::Result<CreateOrderResponse> {
1375 let coinbase_side = map_order_side(side)?;
1376 let order_config = build_order_configuration(
1377 order_type,
1378 side,
1379 quantity,
1380 price,
1381 trigger_price,
1382 time_in_force,
1383 expire_time,
1384 post_only,
1385 is_quote_quantity,
1386 reduce_only,
1387 )?;
1388
1389 let request = CreateOrderRequest {
1390 client_order_id: client_order_id.to_string(),
1391 product_id: instrument_id.symbol.inner(),
1392 side: coinbase_side,
1393 order_configuration: order_config,
1394 self_trade_prevention_id: None,
1395 leverage: leverage.map(|d| d.normalize().to_string()),
1396 margin_type,
1397 retail_portfolio_id,
1398 reduce_only,
1399 };
1400
1401 self.inner
1402 .create_order(&request)
1403 .await
1404 .context("failed to submit order")
1405 }
1406
1407 pub async fn cancel_orders(
1414 &self,
1415 venue_order_ids: &[VenueOrderId],
1416 ) -> anyhow::Result<CancelOrdersResponse> {
1417 let request = CancelOrdersRequest {
1418 order_ids: venue_order_ids
1419 .iter()
1420 .map(|id| id.as_str().to_string())
1421 .collect(),
1422 };
1423 self.inner
1424 .cancel_orders(&request)
1425 .await
1426 .context("failed to cancel orders")
1427 }
1428
1429 pub async fn request_cfm_balance_summary(&self) -> anyhow::Result<CfmBalanceSummary> {
1436 let response = self
1437 .inner
1438 .get_cfm_balance_summary()
1439 .await
1440 .map_err(|e| anyhow::anyhow!("Failed to fetch CFM balance summary: {e}"))?;
1441 Ok(response.balance_summary)
1442 }
1443
1444 pub async fn request_cfm_margin_balances(&self) -> anyhow::Result<Vec<MarginBalance>> {
1451 let summary = self.request_cfm_balance_summary().await?;
1452 parse_cfm_margin_balances(&summary)
1453 }
1454
1455 pub async fn request_cfm_account_state(
1462 &self,
1463 account_id: AccountId,
1464 ) -> anyhow::Result<AccountState> {
1465 let summary = self.request_cfm_balance_summary().await?;
1466 let ts_event = self.ts_now();
1467 parse_cfm_account_state(&summary, account_id, true, ts_event, ts_event)
1468 }
1469
1470 pub async fn request_position_status_reports(
1477 &self,
1478 account_id: AccountId,
1479 ) -> anyhow::Result<Vec<PositionStatusReport>> {
1480 let response = self
1481 .inner
1482 .get_cfm_positions()
1483 .await
1484 .map_err(|e| anyhow::anyhow!("Failed to fetch CFM positions: {e}"))?;
1485
1486 let ts_init = self.ts_now();
1487 let mut reports = Vec::with_capacity(response.positions.len());
1488
1489 for position in &response.positions {
1490 let instrument = match self.get_or_fetch_instrument(position.product_id).await {
1491 Ok(inst) => inst,
1492 Err(e) => {
1493 log::debug!("Skipping CFM position {}: {e}", position.product_id);
1494 continue;
1495 }
1496 };
1497
1498 match parse_cfm_position_status_report(position, &instrument, account_id, ts_init) {
1499 Ok(report) => reports.push(report),
1500 Err(e) => log::warn!("Failed to parse CFM position {}: {e}", position.product_id),
1501 }
1502 }
1503
1504 Ok(reports)
1505 }
1506
1507 pub async fn request_position_status_report(
1515 &self,
1516 account_id: AccountId,
1517 instrument_id: InstrumentId,
1518 ) -> anyhow::Result<Option<PositionStatusReport>> {
1519 let product_id = instrument_id.symbol.as_str();
1520 let response = self
1521 .inner
1522 .get_cfm_position(product_id)
1523 .await
1524 .map_err(|e| anyhow::anyhow!("Failed to fetch CFM position '{product_id}': {e}"))?;
1525
1526 let instrument = self
1527 .get_or_fetch_instrument(response.position.product_id)
1528 .await?;
1529 let ts_init = self.ts_now();
1530 let report =
1531 parse_cfm_position_status_report(&response.position, &instrument, account_id, ts_init)?;
1532 Ok(Some(report))
1533 }
1534
1535 pub async fn modify_order(
1547 &self,
1548 venue_order_id: VenueOrderId,
1549 price: Option<Price>,
1550 quantity: Option<Quantity>,
1551 trigger_price: Option<Price>,
1552 ) -> anyhow::Result<EditOrderResponse> {
1553 let request = EditOrderRequest {
1554 order_id: venue_order_id.as_str().to_string(),
1555 price: price.map(|p| p.to_string()),
1556 size: quantity.map(|q| q.to_string()),
1557 stop_price: trigger_price.map(|p| p.to_string()),
1558 };
1559 self.inner
1560 .edit_order(&request)
1561 .await
1562 .context("failed to edit order")
1563 }
1564}
1565
1566pub fn map_order_side(side: OrderSide) -> anyhow::Result<CoinbaseOrderSide> {
1572 match side {
1573 OrderSide::Buy => Ok(CoinbaseOrderSide::Buy),
1574 OrderSide::Sell => Ok(CoinbaseOrderSide::Sell),
1575 OrderSide::NoOrderSide => anyhow::bail!("NoOrderSide is not a valid Coinbase side"),
1576 }
1577}
1578
1579#[allow(clippy::too_many_arguments)]
1592pub fn build_order_configuration(
1593 order_type: OrderType,
1594 side: OrderSide,
1595 quantity: Quantity,
1596 price: Option<Price>,
1597 trigger_price: Option<Price>,
1598 time_in_force: TimeInForce,
1599 expire_time: Option<UnixNanos>,
1600 post_only: bool,
1601 is_quote_quantity: bool,
1602 reduce_only: bool,
1603) -> anyhow::Result<OrderConfiguration> {
1604 let qty = quantity.as_decimal();
1605 let price = price.map(|p| p.as_decimal());
1606 let trigger = trigger_price.map(|p| p.as_decimal());
1607
1608 if reduce_only && matches!(order_type, OrderType::Market) {
1609 log::debug!("Coinbase MARKET orders do not accept reduce_only; ignoring flag");
1610 }
1611
1612 match order_type {
1613 OrderType::Market => {
1614 let params = if is_quote_quantity {
1625 MarketParams {
1626 quote_size: Some(qty),
1627 base_size: None,
1628 }
1629 } else {
1630 MarketParams {
1631 quote_size: None,
1632 base_size: Some(qty),
1633 }
1634 };
1635
1636 match time_in_force {
1637 TimeInForce::Ioc | TimeInForce::Gtc => {
1638 Ok(OrderConfiguration::MarketIoc(MarketIoc {
1639 market_market_ioc: params,
1640 }))
1641 }
1642 TimeInForce::Fok => Ok(OrderConfiguration::MarketFok(MarketFok {
1643 market_market_fok: params,
1644 })),
1645 _ => {
1646 anyhow::bail!(
1647 "Unsupported TIF {time_in_force} for MARKET on Coinbase (use IOC or FOK)"
1648 )
1649 }
1650 }
1651 }
1652 OrderType::Limit => {
1653 let limit_price =
1654 price.ok_or_else(|| anyhow::anyhow!("LIMIT order requires a price"))?;
1655
1656 match time_in_force {
1657 TimeInForce::Gtc => Ok(OrderConfiguration::LimitGtc(LimitGtc {
1658 limit_limit_gtc: LimitGtcParams {
1659 base_size: qty,
1660 limit_price,
1661 post_only,
1662 },
1663 })),
1664 TimeInForce::Gtd => {
1665 let expire = expire_time
1666 .ok_or_else(|| anyhow::anyhow!("GTD LIMIT requires expire_time"))?;
1667 Ok(OrderConfiguration::LimitGtd(LimitGtd {
1668 limit_limit_gtd: LimitGtdParams {
1669 base_size: qty,
1670 limit_price,
1671 end_time: format_rfc3339_from_nanos(expire)?,
1672 post_only,
1673 },
1674 }))
1675 }
1676 TimeInForce::Fok => Ok(OrderConfiguration::LimitFok(LimitFok {
1677 limit_limit_fok: LimitFokParams {
1678 base_size: qty,
1679 limit_price,
1680 },
1681 })),
1682 _ => anyhow::bail!("Unsupported TIF {time_in_force} for LIMIT on Coinbase"),
1683 }
1684 }
1685 OrderType::StopLimit => {
1686 let limit_price =
1687 price.ok_or_else(|| anyhow::anyhow!("STOP_LIMIT order requires a price"))?;
1688 let stop_price = trigger
1689 .ok_or_else(|| anyhow::anyhow!("STOP_LIMIT order requires trigger_price"))?;
1690 let direction = match side {
1691 OrderSide::Buy => CoinbaseStopDirection::StopUp,
1692 OrderSide::Sell => CoinbaseStopDirection::StopDown,
1693 OrderSide::NoOrderSide => {
1694 anyhow::bail!("STOP_LIMIT requires a defined side")
1695 }
1696 };
1697
1698 match time_in_force {
1699 TimeInForce::Gtc => Ok(OrderConfiguration::StopLimitGtc(StopLimitGtc {
1700 stop_limit_stop_limit_gtc: StopLimitGtcParams {
1701 base_size: qty,
1702 limit_price,
1703 stop_price,
1704 stop_direction: direction,
1705 },
1706 })),
1707 TimeInForce::Gtd => {
1708 let expire = expire_time
1709 .ok_or_else(|| anyhow::anyhow!("GTD STOP_LIMIT requires expire_time"))?;
1710 Ok(OrderConfiguration::StopLimitGtd(StopLimitGtd {
1711 stop_limit_stop_limit_gtd: StopLimitGtdParams {
1712 base_size: qty,
1713 limit_price,
1714 stop_price,
1715 stop_direction: direction,
1716 end_time: format_rfc3339_from_nanos(expire)?,
1717 },
1718 }))
1719 }
1720 _ => anyhow::bail!("Unsupported TIF {time_in_force} for STOP_LIMIT on Coinbase"),
1721 }
1722 }
1723 other => anyhow::bail!("Unsupported order type for Coinbase: {other}"),
1724 }
1725}
1726
1727#[cfg(test)]
1728mod tests {
1729 use rstest::rstest;
1730
1731 use super::*;
1732
1733 #[rstest]
1734 fn test_raw_client_construction_live() {
1735 let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1736 assert_eq!(client.environment(), CoinbaseEnvironment::Live);
1737 assert!(!client.is_authenticated());
1738 }
1739
1740 #[rstest]
1741 fn test_raw_client_construction_sandbox() {
1742 let client =
1743 CoinbaseRawHttpClient::new(CoinbaseEnvironment::Sandbox, 10, None, None).unwrap();
1744 assert_eq!(client.environment(), CoinbaseEnvironment::Sandbox);
1745 }
1746
1747 #[rstest]
1748 fn test_raw_build_url() {
1749 let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1750 let url = client.build_url("/products");
1751 assert_eq!(url, "https://api.coinbase.com/api/v3/brokerage/products");
1752 }
1753
1754 #[rstest]
1755 fn test_raw_build_jwt_uri_live() {
1756 let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1757 let uri = client.build_jwt_uri("GET", "/accounts");
1758 assert_eq!(uri, "GET api.coinbase.com/api/v3/brokerage/accounts");
1759 }
1760
1761 #[rstest]
1762 fn test_raw_build_jwt_uri_sandbox() {
1763 let client =
1764 CoinbaseRawHttpClient::new(CoinbaseEnvironment::Sandbox, 10, None, None).unwrap();
1765 let uri = client.build_jwt_uri("GET", "/accounts");
1766 assert_eq!(
1767 uri,
1768 "GET api-sandbox.coinbase.com/api/v3/brokerage/accounts"
1769 );
1770 }
1771
1772 #[rstest]
1773 fn test_raw_build_jwt_uri_custom_base_url() {
1774 let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1775 client.set_base_url("http://localhost:8080".to_string());
1776 let uri = client.build_jwt_uri("POST", "/orders");
1777 assert_eq!(uri, "POST localhost:8080/api/v3/brokerage/orders");
1778 }
1779
1780 #[rstest]
1781 fn test_raw_set_base_url_safe_after_clone_via_arc() {
1782 let raw = Arc::new(
1783 CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap(),
1784 );
1785 let other = Arc::clone(&raw);
1786 raw.set_base_url("http://localhost:1234".to_string());
1788 assert!(other.build_url("/foo").starts_with("http://localhost:1234"));
1789 }
1790
1791 #[rstest]
1792 fn test_raw_auth_headers_without_credentials() {
1793 let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1794 let result = client.auth_headers("GET", "/accounts");
1795 assert!(result.is_err());
1796 assert!(result.unwrap_err().is_auth_error());
1797 }
1798
1799 #[rstest]
1800 fn test_domain_client_construction() {
1801 let client = CoinbaseHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1802 assert_eq!(client.environment(), CoinbaseEnvironment::Live);
1803 assert!(!client.is_authenticated());
1804 }
1805
1806 #[rstest]
1807 fn test_domain_client_default() {
1808 let client = CoinbaseHttpClient::default();
1809 assert_eq!(client.environment(), CoinbaseEnvironment::Live);
1810 }
1811
1812 #[rstest]
1813 fn test_domain_client_instruments_cache_empty() {
1814 let client = CoinbaseHttpClient::default();
1815 assert!(client.instruments().is_empty());
1816 }
1817
1818 #[rstest]
1819 fn test_domain_client_set_base_url() {
1820 let client = CoinbaseHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1821 let cloned = client.clone();
1822 client.set_base_url("http://localhost:9090".to_string());
1824 let url = cloned.inner.build_url("/test");
1825 assert!(url.starts_with("http://localhost:9090"));
1826 }
1827
1828 #[rstest]
1829 fn test_encode_query_escapes_rfc3339_timestamps() {
1830 let query = encode_query(&[("start_date", "2024-01-15T10:00:00+00:00")]);
1831 assert_eq!(query, "start_date=2024-01-15T10%3A00%3A00%2B00%3A00");
1833 }
1834
1835 #[rstest]
1836 fn test_encode_query_escapes_opaque_cursor() {
1837 let query = encode_query(&[("cursor", "a/b+c=?&x")]);
1838 assert!(!query.contains("a/b+c=?&x"));
1840 assert!(query.starts_with("cursor="));
1841 }
1842
1843 #[rstest]
1844 fn test_encode_query_joins_pairs_with_ampersand() {
1845 let query = encode_query(&[("product_id", "BTC-USD"), ("limit", "50")]);
1846 assert_eq!(query, "product_id=BTC-USD&limit=50");
1847 }
1848
1849 #[rstest]
1850 fn test_map_order_side_rejects_no_side() {
1851 assert!(matches!(
1852 map_order_side(OrderSide::Buy).unwrap(),
1853 CoinbaseOrderSide::Buy
1854 ));
1855 assert!(matches!(
1856 map_order_side(OrderSide::Sell).unwrap(),
1857 CoinbaseOrderSide::Sell
1858 ));
1859 assert!(map_order_side(OrderSide::NoOrderSide).is_err());
1860 }
1861
1862 #[rstest]
1863 fn test_build_order_configuration_market_base_size() {
1864 let cfg = build_order_configuration(
1865 OrderType::Market,
1866 OrderSide::Buy,
1867 Quantity::from("1.5"),
1868 None,
1869 None,
1870 TimeInForce::Ioc,
1871 None,
1872 false,
1873 false,
1874 false,
1875 )
1876 .unwrap();
1877
1878 match cfg {
1879 OrderConfiguration::MarketIoc(m) => {
1880 assert!(m.market_market_ioc.base_size.is_some());
1881 assert!(m.market_market_ioc.quote_size.is_none());
1882 }
1883 other => panic!("expected MarketIoc, was {other:?}"),
1884 }
1885 }
1886
1887 #[rstest]
1888 fn test_build_order_configuration_market_quote_size() {
1889 let cfg = build_order_configuration(
1890 OrderType::Market,
1891 OrderSide::Buy,
1892 Quantity::from("100"),
1893 None,
1894 None,
1895 TimeInForce::Ioc,
1896 None,
1897 false,
1898 true, false,
1900 )
1901 .unwrap();
1902
1903 match cfg {
1904 OrderConfiguration::MarketIoc(m) => {
1905 assert!(m.market_market_ioc.quote_size.is_some());
1906 assert!(m.market_market_ioc.base_size.is_none());
1907 }
1908 other => panic!("expected MarketIoc, was {other:?}"),
1909 }
1910 }
1911
1912 #[rstest]
1913 fn test_build_order_configuration_market_fok() {
1914 let cfg = build_order_configuration(
1915 OrderType::Market,
1916 OrderSide::Buy,
1917 Quantity::from("0.5"),
1918 None,
1919 None,
1920 TimeInForce::Fok,
1921 None,
1922 false,
1923 false,
1924 false,
1925 )
1926 .unwrap();
1927
1928 match cfg {
1929 OrderConfiguration::MarketFok(m) => {
1930 assert!(m.market_market_fok.base_size.is_some());
1931 assert!(m.market_market_fok.quote_size.is_none());
1932 }
1933 other => panic!("expected MarketFok, was {other:?}"),
1934 }
1935 }
1936
1937 #[rstest]
1938 #[case(TimeInForce::Day)]
1939 #[case(TimeInForce::Gtd)]
1940 fn test_build_order_configuration_market_rejects_unsupported_tif(#[case] tif: TimeInForce) {
1941 let result = build_order_configuration(
1942 OrderType::Market,
1943 OrderSide::Buy,
1944 Quantity::from("1"),
1945 None,
1946 None,
1947 tif,
1948 None,
1949 false,
1950 false,
1951 false,
1952 );
1953 assert!(result.is_err());
1954 }
1955
1956 #[rstest]
1957 fn test_build_order_configuration_limit_gtc_post_only() {
1958 let cfg = build_order_configuration(
1959 OrderType::Limit,
1960 OrderSide::Sell,
1961 Quantity::from("0.5"),
1962 Some(Price::from("50000.00")),
1963 None,
1964 TimeInForce::Gtc,
1965 None,
1966 true,
1967 false,
1968 false,
1969 )
1970 .unwrap();
1971
1972 match cfg {
1973 OrderConfiguration::LimitGtc(l) => assert!(l.limit_limit_gtc.post_only),
1974 other => panic!("expected LimitGtc, was {other:?}"),
1975 }
1976 }
1977
1978 #[rstest]
1979 fn test_build_order_configuration_limit_gtd_requires_expire_time() {
1980 let result = build_order_configuration(
1981 OrderType::Limit,
1982 OrderSide::Buy,
1983 Quantity::from("1"),
1984 Some(Price::from("100.00")),
1985 None,
1986 TimeInForce::Gtd,
1987 None,
1988 false,
1989 false,
1990 false,
1991 );
1992 assert!(result.is_err());
1993 }
1994
1995 #[rstest]
1996 fn test_build_order_configuration_stop_limit_uses_correct_direction() {
1997 let buy_cfg = build_order_configuration(
1998 OrderType::StopLimit,
1999 OrderSide::Buy,
2000 Quantity::from("1"),
2001 Some(Price::from("100.00")),
2002 Some(Price::from("99.00")),
2003 TimeInForce::Gtc,
2004 None,
2005 false,
2006 false,
2007 false,
2008 )
2009 .unwrap();
2010
2011 match buy_cfg {
2012 OrderConfiguration::StopLimitGtc(s) => assert_eq!(
2013 s.stop_limit_stop_limit_gtc.stop_direction,
2014 CoinbaseStopDirection::StopUp
2015 ),
2016 other => panic!("expected StopLimitGtc, was {other:?}"),
2017 }
2018
2019 let sell_cfg = build_order_configuration(
2020 OrderType::StopLimit,
2021 OrderSide::Sell,
2022 Quantity::from("1"),
2023 Some(Price::from("100.00")),
2024 Some(Price::from("99.00")),
2025 TimeInForce::Gtc,
2026 None,
2027 false,
2028 false,
2029 false,
2030 )
2031 .unwrap();
2032
2033 match sell_cfg {
2034 OrderConfiguration::StopLimitGtc(s) => assert_eq!(
2035 s.stop_limit_stop_limit_gtc.stop_direction,
2036 CoinbaseStopDirection::StopDown
2037 ),
2038 other => panic!("expected StopLimitGtc, was {other:?}"),
2039 }
2040 }
2041
2042 #[rstest]
2043 fn test_build_order_configuration_market_accepts_default_gtc() {
2044 let cfg = build_order_configuration(
2047 OrderType::Market,
2048 OrderSide::Buy,
2049 Quantity::from("1"),
2050 None,
2051 None,
2052 TimeInForce::Gtc,
2053 None,
2054 false,
2055 false,
2056 false,
2057 )
2058 .unwrap();
2059 assert!(matches!(cfg, OrderConfiguration::MarketIoc(_)));
2060 }
2061
2062 #[rstest]
2063 fn test_build_order_configuration_rejects_stop_market() {
2064 let result = build_order_configuration(
2065 OrderType::StopMarket,
2066 OrderSide::Buy,
2067 Quantity::from("1"),
2068 None,
2069 Some(Price::from("100.00")),
2070 TimeInForce::Gtc,
2071 None,
2072 false,
2073 false,
2074 false,
2075 );
2076 assert!(result.is_err());
2077 }
2078
2079 #[rstest]
2080 fn test_rest_quota_matches_documented_limit() {
2081 assert_eq!(COINBASE_REST_QUOTA.burst_size().get(), 30);
2082 }
2083
2084 #[rstest]
2085 fn test_default_retry_config_values() {
2086 let config = default_retry_config();
2087 assert_eq!(config.max_retries, 3);
2088 assert_eq!(config.initial_delay_ms, 100);
2089 assert_eq!(config.max_delay_ms, 5_000);
2090 assert_eq!(config.max_elapsed_ms, Some(180_000));
2091 }
2092}