1use std::{
23 collections::HashMap,
24 num::NonZeroU32,
25 sync::{Arc, LazyLock},
26};
27
28use anyhow::Context;
29use arc_swap::ArcSwap;
30use jiff::{Timestamp, tz::Offset};
31use nautilus_core::{
32 AtomicMap, UnixNanos,
33 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::builder()
172 .headers(Self::default_headers())
173 .default_quota(*COINBASE_REST_QUOTA)
174 .timeout_secs(timeout_secs)
175 .maybe_proxy_url(proxy_url)
176 .build()?,
177 credential: None,
178 base_url: ArcSwap::from_pointee(urls::rest_url(environment).to_string()),
179 environment,
180 retry_manager: RetryManager::new(retry_config.unwrap_or_else(default_retry_config)),
181 cancellation_token: CancellationToken::new(),
182 })
183 }
184
185 pub fn with_credentials(
191 credential: CoinbaseCredential,
192 environment: CoinbaseEnvironment,
193 timeout_secs: u64,
194 proxy_url: Option<String>,
195 retry_config: Option<RetryConfig>,
196 ) -> std::result::Result<Self, HttpClientError> {
197 Ok(Self {
198 client: HttpClient::builder()
199 .headers(Self::default_headers())
200 .default_quota(*COINBASE_REST_QUOTA)
201 .timeout_secs(timeout_secs)
202 .maybe_proxy_url(proxy_url)
203 .build()?,
204 credential: Some(credential),
205 base_url: ArcSwap::from_pointee(urls::rest_url(environment).to_string()),
206 environment,
207 retry_manager: RetryManager::new(retry_config.unwrap_or_else(default_retry_config)),
208 cancellation_token: CancellationToken::new(),
209 })
210 }
211
212 pub fn from_env(environment: CoinbaseEnvironment) -> Result<Self> {
218 let credential = CoinbaseCredential::from_env()
219 .map_err(|e| Error::auth(format!("Missing credentials in environment: {e}")))?;
220 Self::with_credentials(credential, environment, 10, None, None)
221 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
222 }
223
224 pub fn from_credentials(
230 api_key: &str,
231 api_secret: &str,
232 environment: CoinbaseEnvironment,
233 timeout_secs: u64,
234 proxy_url: Option<String>,
235 retry_config: Option<RetryConfig>,
236 ) -> Result<Self> {
237 let credential = CoinbaseCredential::new(api_key.to_string(), api_secret.to_string());
238 Self::with_credentials(
239 credential,
240 environment,
241 timeout_secs,
242 proxy_url,
243 retry_config,
244 )
245 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
246 }
247
248 #[must_use]
250 pub fn cancellation_token(&self) -> &CancellationToken {
251 &self.cancellation_token
252 }
253
254 pub fn set_base_url(&self, url: String) {
258 self.base_url.store(Arc::new(url));
259 }
260
261 #[must_use]
263 pub fn environment(&self) -> CoinbaseEnvironment {
264 self.environment
265 }
266
267 #[must_use]
269 pub fn is_authenticated(&self) -> bool {
270 self.credential.is_some()
271 }
272
273 fn default_headers() -> HashMap<String, String> {
274 HashMap::from([
275 (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
276 ("Content-Type".to_string(), "application/json".to_string()),
277 ])
278 }
279
280 fn build_url(&self, path: &str) -> String {
281 format!("{}{REST_API_PATH}{path}", self.base_url.load())
282 }
283
284 fn build_jwt_uri(&self, method: &str, path: &str) -> String {
286 let base = self.base_url.load();
287 let host = base
288 .strip_prefix("https://")
289 .or_else(|| base.strip_prefix("http://"))
290 .unwrap_or(base.as_str());
291 format!("{method} {host}{REST_API_PATH}{path}")
292 }
293
294 fn auth_headers(&self, method: &str, path: &str) -> Result<HashMap<String, String>> {
295 let credential = self
296 .credential
297 .as_ref()
298 .ok_or_else(|| Error::auth("No credentials configured"))?;
299
300 let uri = self.build_jwt_uri(method, path);
301 let jwt = credential.build_rest_jwt(&uri)?;
302
303 Ok(HashMap::from([(
304 "Authorization".to_string(),
305 format!("Bearer {jwt}"),
306 )]))
307 }
308
309 fn parse_response(&self, response: &HttpResponse) -> Result<Value> {
310 if !response.status.is_success() {
311 return Err(Error::from_http_status(
312 response.status.as_u16(),
313 &response.body,
314 ));
315 }
316
317 if response.body.is_empty() {
318 return Ok(Value::Null);
319 }
320
321 serde_json::from_slice(&response.body).map_err(Error::Serde)
322 }
323
324 async fn send_request(
329 &self,
330 method: Method,
331 url: String,
332 sign_method: Option<&'static str>,
333 sign_path: Option<&str>,
334 body: Option<Vec<u8>>,
335 ) -> Result<Value> {
336 let sign_path_owned = sign_path.map(ToOwned::to_owned);
337 let operation_name = sign_path_owned
338 .as_deref()
339 .unwrap_or(url.as_str())
340 .to_string();
341
342 let is_idempotent = matches!(method, Method::GET | Method::DELETE);
343
344 let operation = || {
345 let method = method.clone();
346 let url = url.clone();
347 let body = body.clone();
348 let sign_path = sign_path_owned.clone();
349
350 async move {
351 let headers = match (sign_method, sign_path.as_deref()) {
352 (Some(m), Some(p)) => Some(self.auth_headers(m, p)?),
353 _ => None,
354 };
355
356 let response = self
357 .client
358 .request(method, url, None, headers, body, None, None)
359 .await
360 .map_err(Error::from_http_client)?;
361
362 self.parse_response(&response)
363 }
364 };
365
366 let should_retry = move |err: &Error| is_idempotent && err.is_retryable();
367
368 self.retry_manager
369 .execute_with_retry_with_cancel(
370 &operation_name,
371 operation,
372 should_retry,
373 |e| Error::transport(e.to_string()),
374 &self.cancellation_token,
375 )
376 .await
377 }
378
379 pub async fn get_public(&self, path: &str) -> Result<Value> {
381 let url = self.build_url(path);
382 self.send_request(Method::GET, url, None, None, None).await
383 }
384
385 pub async fn get_public_with_query(&self, path: &str, query: &str) -> Result<Value> {
387 let full_path = if query.is_empty() {
388 path.to_string()
389 } else {
390 format!("{path}?{query}")
391 };
392 let url = self.build_url(&full_path);
393 self.send_request(Method::GET, url, None, None, None).await
394 }
395
396 pub async fn get(&self, path: &str) -> Result<Value> {
398 let url = self.build_url(path);
399 self.send_request(Method::GET, url, Some("GET"), Some(path), None)
400 .await
401 }
402
403 pub async fn get_with_query(&self, path: &str, query: &str) -> Result<Value> {
409 let full_url_path = if query.is_empty() {
410 path.to_string()
411 } else {
412 format!("{path}?{query}")
413 };
414 let url = self.build_url(&full_url_path);
415 self.send_request(Method::GET, url, Some("GET"), Some(path), None)
417 .await
418 }
419
420 pub async fn post(&self, path: &str, body: &Value) -> Result<Value> {
422 let url = self.build_url(path);
423 let body_bytes = serde_json::to_vec(body).map_err(Error::Serde)?;
424 self.send_request(
425 Method::POST,
426 url,
427 Some("POST"),
428 Some(path),
429 Some(body_bytes),
430 )
431 .await
432 }
433
434 pub async fn delete(&self, path: &str) -> Result<Value> {
436 let url = self.build_url(path);
437 self.send_request(Method::DELETE, url, Some("DELETE"), Some(path), None)
438 .await
439 }
440
441 pub async fn get_products(&self) -> Result<Value> {
443 self.get_public("/market/products").await
444 }
445
446 pub async fn get_product(&self, product_id: &str) -> Result<Value> {
448 self.get_public(&format!("/market/products/{product_id}"))
449 .await
450 }
451
452 pub async fn get_candles(
454 &self,
455 product_id: &str,
456 start: &str,
457 end: &str,
458 granularity: &str,
459 ) -> Result<Value> {
460 let query = format!("start={start}&end={end}&granularity={granularity}");
461 self.get_public_with_query(&format!("/market/products/{product_id}/candles"), &query)
462 .await
463 }
464
465 pub async fn get_market_trades(&self, product_id: &str, limit: u32) -> Result<Value> {
467 let query = format!("limit={limit}");
468 self.get_public_with_query(&format!("/market/products/{product_id}/ticker"), &query)
469 .await
470 }
471
472 pub async fn get_best_bid_ask(&self, product_ids: &[&str]) -> Result<Value> {
477 let query = product_ids
478 .iter()
479 .map(|id| format!("product_ids={id}"))
480 .collect::<Vec<_>>()
481 .join("&");
482 self.get_with_query("/best_bid_ask", &query).await
483 }
484
485 pub async fn get_product_book(&self, product_id: &str, limit: Option<u32>) -> Result<Value> {
487 let mut query = format!("product_id={product_id}");
488
489 if let Some(limit) = limit {
490 query.push_str(&format!("&limit={limit}"));
491 }
492 self.get_public_with_query("/market/product_book", &query)
493 .await
494 }
495
496 pub async fn get_accounts(&self) -> Result<Value> {
498 self.get("/accounts").await
499 }
500
501 pub async fn get_accounts_with_query(&self, query: &str) -> Result<Value> {
503 if query.is_empty() {
504 self.get("/accounts").await
505 } else {
506 self.get_with_query("/accounts", query).await
507 }
508 }
509
510 pub async fn get_account(&self, account_id: &str) -> Result<Value> {
512 self.get(&format!("/accounts/{account_id}")).await
513 }
514
515 pub async fn get_portfolios(&self) -> Result<Value> {
517 self.get("/portfolios").await
518 }
519
520 pub async fn get_orders(&self, query: &str) -> Result<Value> {
522 self.get_with_query("/orders/historical/batch", query).await
523 }
524
525 pub async fn get_order(&self, order_id: &str) -> Result<Value> {
527 self.get(&format!("/orders/historical/{order_id}")).await
528 }
529
530 pub async fn get_fills(&self, query: &str) -> Result<Value> {
532 self.get_with_query("/orders/historical/fills", query).await
533 }
534
535 pub async fn get_transaction_summary(&self) -> Result<Value> {
537 self.get("/transaction_summary").await
538 }
539
540 pub async fn get_cfm_balance_summary(&self) -> Result<CfmBalanceSummaryResponse> {
546 let json = self.get("/cfm/balance_summary").await?;
547 serde_json::from_value(json).map_err(Error::Serde)
548 }
549
550 pub async fn get_cfm_positions(&self) -> Result<CfmPositionsResponse> {
556 let json = self.get("/cfm/positions").await?;
557 serde_json::from_value(json).map_err(Error::Serde)
558 }
559
560 pub async fn get_cfm_position(&self, product_id: &str) -> Result<CfmPositionResponse> {
566 let json = self.get(&format!("/cfm/positions/{product_id}")).await?;
567 serde_json::from_value(json).map_err(Error::Serde)
568 }
569
570 pub async fn fetch_all_accounts(&self) -> Result<Vec<Account>> {
575 let mut all = Vec::new();
576 let mut cursor: Option<String> = None;
577
578 loop {
579 let mut pairs: Vec<(&str, &str)> = vec![(QUERY_KEY_LIMIT, ACCOUNTS_PAGE_LIMIT)];
580 if let Some(c) = cursor.as_deref().filter(|s| !s.is_empty()) {
581 pairs.push((QUERY_KEY_CURSOR, c));
582 }
583 let query_str = encode_query(&pairs);
584
585 let json = self.get_accounts_with_query(&query_str).await?;
586 let response: AccountsResponse = serde_json::from_value(json).map_err(Error::Serde)?;
587
588 all.extend(response.accounts);
589
590 if !response.has_next || response.cursor.is_empty() {
591 break;
592 }
593 cursor = Some(response.cursor);
594 }
595
596 Ok(all)
597 }
598
599 pub async fn fetch_all_orders(&self, query: &OrderListQuery) -> Result<Vec<Order>> {
605 let mut collected: Vec<Order> = Vec::new();
606 let mut cursor: Option<String> = None;
607
608 loop {
609 let start_str = query
610 .start
611 .map(|s| s.display_with_offset(Offset::UTC).to_string());
612 let end_str = query
613 .end
614 .map(|e| e.display_with_offset(Offset::UTC).to_string());
615 let limit_str = query.limit.map(|l| l.to_string());
616
617 let mut pairs: Vec<(&str, &str)> = Vec::new();
618
619 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
682 .start
683 .map(|s| s.display_with_offset(Offset::UTC).to_string());
684 let end_str = query
685 .end
686 .map(|e| e.display_with_offset(Offset::UTC).to_string());
687 let limit_str = query.limit.map(|l| l.to_string());
688
689 let mut pairs: Vec<(&str, &str)> = Vec::new();
690
691 if let Some(pid) = query.product_id.as_deref() {
695 pairs.push((QUERY_KEY_PRODUCT_IDS, pid));
696 }
697
698 if let Some(vid) = query.venue_order_id.as_deref() {
699 pairs.push((QUERY_KEY_ORDER_IDS, vid));
700 }
701
702 if let Some(s) = start_str.as_deref() {
703 pairs.push((QUERY_KEY_START_SEQUENCE_TIMESTAMP, s));
704 }
705
706 if let Some(e) = end_str.as_deref() {
707 pairs.push((QUERY_KEY_END_SEQUENCE_TIMESTAMP, e));
708 }
709
710 if let Some(l) = limit_str.as_deref() {
711 pairs.push((QUERY_KEY_LIMIT, l));
712 }
713
714 if let Some(c) = cursor.as_deref().filter(|s| !s.is_empty()) {
715 pairs.push((QUERY_KEY_CURSOR, c));
716 }
717
718 let query_str = encode_query(&pairs);
719 let json = self.get_fills(&query_str).await?;
720 let response: FillsResponse = serde_json::from_value(json).map_err(Error::Serde)?;
721
722 collected.extend(response.fills);
723
724 if let Some(limit) = query.limit
725 && collected.len() >= limit as usize
726 {
727 collected.truncate(limit as usize);
728 break;
729 }
730
731 if response.cursor.is_empty() {
732 break;
733 }
734 cursor = Some(response.cursor);
735 }
736
737 Ok(collected)
738 }
739
740 pub async fn create_order(&self, request: &CreateOrderRequest) -> Result<CreateOrderResponse> {
746 let body = serde_json::to_value(request).map_err(Error::Serde)?;
747 let json = self.post("/orders", &body).await?;
748 serde_json::from_value(json).map_err(Error::Serde)
749 }
750
751 pub async fn cancel_orders(
757 &self,
758 request: &CancelOrdersRequest,
759 ) -> Result<CancelOrdersResponse> {
760 let body = serde_json::to_value(request).map_err(Error::Serde)?;
761 let json = self.post("/orders/batch_cancel", &body).await?;
762 serde_json::from_value(json).map_err(Error::Serde)
763 }
764
765 pub async fn edit_order(&self, request: &EditOrderRequest) -> Result<EditOrderResponse> {
774 let body = serde_json::to_value(request).map_err(Error::Serde)?;
775 let json = self.post("/orders/edit", &body).await?;
776 serde_json::from_value(json).map_err(Error::Serde)
777 }
778}
779
780#[derive(Debug, Clone)]
786#[cfg_attr(
787 feature = "python",
788 pyo3::pyclass(module = "nautilus_trader.adapters.coinbase", from_py_object)
789)]
790pub struct CoinbaseHttpClient {
791 pub(crate) inner: Arc<CoinbaseRawHttpClient>,
792 clock: &'static AtomicTime,
793 instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
794 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<Timestamp>,
1200 end: Option<Timestamp>,
1201 limit: Option<u32>,
1202 ) -> anyhow::Result<Vec<OrderStatusReport>> {
1203 let query = OrderListQuery {
1204 product_id: instrument_id.map(|id| id.symbol.as_str().to_string()),
1205 open_only,
1206 start,
1207 end,
1208 limit,
1209 client_order_id_filter: None,
1210 };
1211
1212 let orders = self
1213 .inner
1214 .fetch_all_orders(&query)
1215 .await
1216 .map_err(|e| anyhow::anyhow!("Failed to fetch orders: {e}"))?;
1217
1218 let ts_init = self.ts_now();
1219 let mut reports = Vec::with_capacity(orders.len());
1220
1221 for order in &orders {
1222 let instrument = match self.get_or_fetch_instrument(order.product_id).await {
1223 Ok(inst) => inst,
1224 Err(e) => {
1225 log::debug!("Skipping order {}: {e}", order.order_id);
1226 continue;
1227 }
1228 };
1229
1230 match parse_order_status_report(order, &instrument, account_id, ts_init) {
1231 Ok(report) => reports.push(report),
1232 Err(e) => log::warn!("Failed to parse order {}: {e}", order.order_id),
1233 }
1234 }
1235
1236 Ok(reports)
1237 }
1238
1239 pub async fn request_fill_reports(
1247 &self,
1248 account_id: AccountId,
1249 instrument_id: Option<InstrumentId>,
1250 venue_order_id: Option<VenueOrderId>,
1251 start: Option<Timestamp>,
1252 end: Option<Timestamp>,
1253 limit: Option<u32>,
1254 ) -> anyhow::Result<Vec<FillReport>> {
1255 let query = FillListQuery {
1256 product_id: instrument_id.map(|id| id.symbol.as_str().to_string()),
1257 venue_order_id: venue_order_id.map(|id| id.as_str().to_string()),
1258 start,
1259 end,
1260 limit,
1261 };
1262
1263 let fills = self
1264 .inner
1265 .fetch_all_fills(&query)
1266 .await
1267 .map_err(|e| anyhow::anyhow!("Failed to fetch fills: {e}"))?;
1268
1269 let ts_init = self.ts_now();
1270 let mut reports = Vec::with_capacity(fills.len());
1271
1272 for fill in &fills {
1273 let instrument = match self.get_or_fetch_instrument(fill.product_id).await {
1274 Ok(inst) => inst,
1275 Err(e) => {
1276 log::debug!("Skipping fill {}: {e}", fill.trade_id);
1277 continue;
1278 }
1279 };
1280
1281 match parse_fill_report(fill, &instrument, account_id, ts_init) {
1282 Ok(report) => reports.push(report),
1283 Err(e) => log::warn!("Failed to parse fill {}: {e}", fill.trade_id),
1284 }
1285 }
1286
1287 Ok(reports)
1288 }
1289
1290 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) -> CoinbaseOrderSide {
1572 match side {
1573 OrderSide::Buy => CoinbaseOrderSide::Buy,
1574 OrderSide::Sell => CoinbaseOrderSide::Sell,
1575 }
1576}
1577
1578#[allow(clippy::too_many_arguments)]
1591pub fn build_order_configuration(
1592 order_type: OrderType,
1593 side: OrderSide,
1594 quantity: Quantity,
1595 price: Option<Price>,
1596 trigger_price: Option<Price>,
1597 time_in_force: TimeInForce,
1598 expire_time: Option<UnixNanos>,
1599 post_only: bool,
1600 is_quote_quantity: bool,
1601 reduce_only: bool,
1602) -> anyhow::Result<OrderConfiguration> {
1603 let qty = quantity.as_decimal();
1604 let price = price.map(|p| p.as_decimal());
1605 let trigger = trigger_price.map(|p| p.as_decimal());
1606
1607 if reduce_only && matches!(order_type, OrderType::Market) {
1608 log::debug!("Coinbase MARKET orders do not accept reduce_only; ignoring flag");
1609 }
1610
1611 match order_type {
1612 OrderType::Market => {
1613 let params = if is_quote_quantity {
1624 MarketParams {
1625 quote_size: Some(qty),
1626 base_size: None,
1627 }
1628 } else {
1629 MarketParams {
1630 quote_size: None,
1631 base_size: Some(qty),
1632 }
1633 };
1634
1635 match time_in_force {
1636 TimeInForce::Ioc | TimeInForce::Gtc => {
1637 Ok(OrderConfiguration::MarketIoc(MarketIoc {
1638 market_market_ioc: params,
1639 }))
1640 }
1641 TimeInForce::Fok => Ok(OrderConfiguration::MarketFok(MarketFok {
1642 market_market_fok: params,
1643 })),
1644 _ => {
1645 anyhow::bail!(
1646 "Unsupported TIF {time_in_force} for MARKET on Coinbase (use IOC or FOK)"
1647 )
1648 }
1649 }
1650 }
1651 OrderType::Limit => {
1652 let limit_price =
1653 price.ok_or_else(|| anyhow::anyhow!("LIMIT order requires a price"))?;
1654
1655 match time_in_force {
1656 TimeInForce::Gtc => Ok(OrderConfiguration::LimitGtc(LimitGtc {
1657 limit_limit_gtc: LimitGtcParams {
1658 base_size: qty,
1659 limit_price,
1660 post_only,
1661 },
1662 })),
1663 TimeInForce::Gtd => {
1664 let expire = expire_time
1665 .ok_or_else(|| anyhow::anyhow!("GTD LIMIT requires expire_time"))?;
1666 Ok(OrderConfiguration::LimitGtd(LimitGtd {
1667 limit_limit_gtd: LimitGtdParams {
1668 base_size: qty,
1669 limit_price,
1670 end_time: format_rfc3339_from_nanos(expire)?,
1671 post_only,
1672 },
1673 }))
1674 }
1675 TimeInForce::Fok => Ok(OrderConfiguration::LimitFok(LimitFok {
1676 limit_limit_fok: LimitFokParams {
1677 base_size: qty,
1678 limit_price,
1679 },
1680 })),
1681 _ => anyhow::bail!("Unsupported TIF {time_in_force} for LIMIT on Coinbase"),
1682 }
1683 }
1684 OrderType::StopLimit => {
1685 let limit_price =
1686 price.ok_or_else(|| anyhow::anyhow!("STOP_LIMIT order requires a price"))?;
1687 let stop_price = trigger
1688 .ok_or_else(|| anyhow::anyhow!("STOP_LIMIT order requires trigger_price"))?;
1689 let direction = match side {
1690 OrderSide::Buy => CoinbaseStopDirection::StopUp,
1691 OrderSide::Sell => CoinbaseStopDirection::StopDown,
1692 };
1693
1694 match time_in_force {
1695 TimeInForce::Gtc => Ok(OrderConfiguration::StopLimitGtc(StopLimitGtc {
1696 stop_limit_stop_limit_gtc: StopLimitGtcParams {
1697 base_size: qty,
1698 limit_price,
1699 stop_price,
1700 stop_direction: direction,
1701 },
1702 })),
1703 TimeInForce::Gtd => {
1704 let expire = expire_time
1705 .ok_or_else(|| anyhow::anyhow!("GTD STOP_LIMIT requires expire_time"))?;
1706 Ok(OrderConfiguration::StopLimitGtd(StopLimitGtd {
1707 stop_limit_stop_limit_gtd: StopLimitGtdParams {
1708 base_size: qty,
1709 limit_price,
1710 stop_price,
1711 stop_direction: direction,
1712 end_time: format_rfc3339_from_nanos(expire)?,
1713 },
1714 }))
1715 }
1716 _ => anyhow::bail!("Unsupported TIF {time_in_force} for STOP_LIMIT on Coinbase"),
1717 }
1718 }
1719 other => anyhow::bail!("Unsupported order type for Coinbase: {other}"),
1720 }
1721}
1722
1723#[cfg(test)]
1724mod tests {
1725 use rstest::rstest;
1726
1727 use super::*;
1728
1729 #[rstest]
1730 fn test_raw_client_construction_live() {
1731 let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1732 assert_eq!(client.environment(), CoinbaseEnvironment::Live);
1733 assert!(!client.is_authenticated());
1734 }
1735
1736 #[rstest]
1737 fn test_raw_client_construction_sandbox() {
1738 let client =
1739 CoinbaseRawHttpClient::new(CoinbaseEnvironment::Sandbox, 10, None, None).unwrap();
1740 assert_eq!(client.environment(), CoinbaseEnvironment::Sandbox);
1741 }
1742
1743 #[rstest]
1744 fn test_raw_build_url() {
1745 let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1746 let url = client.build_url("/products");
1747 assert_eq!(url, "https://api.coinbase.com/api/v3/brokerage/products");
1748 }
1749
1750 #[rstest]
1751 fn test_raw_build_jwt_uri_live() {
1752 let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1753 let uri = client.build_jwt_uri("GET", "/accounts");
1754 assert_eq!(uri, "GET api.coinbase.com/api/v3/brokerage/accounts");
1755 }
1756
1757 #[rstest]
1758 fn test_raw_build_jwt_uri_sandbox() {
1759 let client =
1760 CoinbaseRawHttpClient::new(CoinbaseEnvironment::Sandbox, 10, None, None).unwrap();
1761 let uri = client.build_jwt_uri("GET", "/accounts");
1762 assert_eq!(
1763 uri,
1764 "GET api-sandbox.coinbase.com/api/v3/brokerage/accounts"
1765 );
1766 }
1767
1768 #[rstest]
1769 fn test_raw_build_jwt_uri_custom_base_url() {
1770 let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1771 client.set_base_url("http://localhost:8080".to_string());
1772 let uri = client.build_jwt_uri("POST", "/orders");
1773 assert_eq!(uri, "POST localhost:8080/api/v3/brokerage/orders");
1774 }
1775
1776 #[rstest]
1777 fn test_raw_set_base_url_safe_after_clone_via_arc() {
1778 let raw = Arc::new(
1779 CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap(),
1780 );
1781 let other = Arc::clone(&raw);
1782 raw.set_base_url("http://localhost:1234".to_string());
1784 assert!(other.build_url("/foo").starts_with("http://localhost:1234"));
1785 }
1786
1787 #[rstest]
1788 fn test_raw_auth_headers_without_credentials() {
1789 let client = CoinbaseRawHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1790 let result = client.auth_headers("GET", "/accounts");
1791 assert!(result.is_err());
1792 assert!(result.unwrap_err().is_auth_error());
1793 }
1794
1795 #[rstest]
1796 fn test_domain_client_construction() {
1797 let client = CoinbaseHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1798 assert_eq!(client.environment(), CoinbaseEnvironment::Live);
1799 assert!(!client.is_authenticated());
1800 }
1801
1802 #[rstest]
1803 fn test_domain_client_default() {
1804 let client = CoinbaseHttpClient::default();
1805 assert_eq!(client.environment(), CoinbaseEnvironment::Live);
1806 }
1807
1808 #[rstest]
1809 fn test_domain_client_instruments_cache_empty() {
1810 let client = CoinbaseHttpClient::default();
1811 assert!(client.instruments().is_empty());
1812 }
1813
1814 #[rstest]
1815 fn test_domain_client_set_base_url() {
1816 let client = CoinbaseHttpClient::new(CoinbaseEnvironment::Live, 10, None, None).unwrap();
1817 let cloned = client.clone();
1818 client.set_base_url("http://localhost:9090".to_string());
1820 let url = cloned.inner.build_url("/test");
1821 assert!(url.starts_with("http://localhost:9090"));
1822 }
1823
1824 #[rstest]
1825 fn test_encode_query_escapes_rfc3339_timestamps() {
1826 let query = encode_query(&[("start_date", "2024-01-15T10:00:00+00:00")]);
1827 assert_eq!(query, "start_date=2024-01-15T10%3A00%3A00%2B00%3A00");
1829 }
1830
1831 #[rstest]
1832 fn test_encode_query_escapes_opaque_cursor() {
1833 let query = encode_query(&[("cursor", "a/b+c=?&x")]);
1834 assert!(!query.contains("a/b+c=?&x"));
1836 assert!(query.starts_with("cursor="));
1837 }
1838
1839 #[rstest]
1840 fn test_encode_query_joins_pairs_with_ampersand() {
1841 let query = encode_query(&[("product_id", "BTC-USD"), ("limit", "50")]);
1842 assert_eq!(query, "product_id=BTC-USD&limit=50");
1843 }
1844
1845 #[rstest]
1846 fn test_map_order_side() {
1847 assert!(matches!(
1848 map_order_side(OrderSide::Buy),
1849 CoinbaseOrderSide::Buy
1850 ));
1851 assert!(matches!(
1852 map_order_side(OrderSide::Sell),
1853 CoinbaseOrderSide::Sell
1854 ));
1855 }
1856
1857 #[rstest]
1858 fn test_build_order_configuration_market_base_size() {
1859 let cfg = build_order_configuration(
1860 OrderType::Market,
1861 OrderSide::Buy,
1862 Quantity::from("1.5"),
1863 None,
1864 None,
1865 TimeInForce::Ioc,
1866 None,
1867 false,
1868 false,
1869 false,
1870 )
1871 .unwrap();
1872
1873 match cfg {
1874 OrderConfiguration::MarketIoc(m) => {
1875 assert!(m.market_market_ioc.base_size.is_some());
1876 assert!(m.market_market_ioc.quote_size.is_none());
1877 }
1878 other => panic!("expected MarketIoc, was {other:?}"),
1879 }
1880 }
1881
1882 #[rstest]
1883 fn test_build_order_configuration_market_quote_size() {
1884 let cfg = build_order_configuration(
1885 OrderType::Market,
1886 OrderSide::Buy,
1887 Quantity::from("100"),
1888 None,
1889 None,
1890 TimeInForce::Ioc,
1891 None,
1892 false,
1893 true, false,
1895 )
1896 .unwrap();
1897
1898 match cfg {
1899 OrderConfiguration::MarketIoc(m) => {
1900 assert!(m.market_market_ioc.quote_size.is_some());
1901 assert!(m.market_market_ioc.base_size.is_none());
1902 }
1903 other => panic!("expected MarketIoc, was {other:?}"),
1904 }
1905 }
1906
1907 #[rstest]
1908 fn test_build_order_configuration_market_fok() {
1909 let cfg = build_order_configuration(
1910 OrderType::Market,
1911 OrderSide::Buy,
1912 Quantity::from("0.5"),
1913 None,
1914 None,
1915 TimeInForce::Fok,
1916 None,
1917 false,
1918 false,
1919 false,
1920 )
1921 .unwrap();
1922
1923 match cfg {
1924 OrderConfiguration::MarketFok(m) => {
1925 assert!(m.market_market_fok.base_size.is_some());
1926 assert!(m.market_market_fok.quote_size.is_none());
1927 }
1928 other => panic!("expected MarketFok, was {other:?}"),
1929 }
1930 }
1931
1932 #[rstest]
1933 #[case(TimeInForce::Day)]
1934 #[case(TimeInForce::Gtd)]
1935 fn test_build_order_configuration_market_rejects_unsupported_tif(#[case] tif: TimeInForce) {
1936 let result = build_order_configuration(
1937 OrderType::Market,
1938 OrderSide::Buy,
1939 Quantity::from("1"),
1940 None,
1941 None,
1942 tif,
1943 None,
1944 false,
1945 false,
1946 false,
1947 );
1948 assert!(result.is_err());
1949 }
1950
1951 #[rstest]
1952 fn test_build_order_configuration_limit_gtc_post_only() {
1953 let cfg = build_order_configuration(
1954 OrderType::Limit,
1955 OrderSide::Sell,
1956 Quantity::from("0.5"),
1957 Some(Price::from("50000.00")),
1958 None,
1959 TimeInForce::Gtc,
1960 None,
1961 true,
1962 false,
1963 false,
1964 )
1965 .unwrap();
1966
1967 match cfg {
1968 OrderConfiguration::LimitGtc(l) => assert!(l.limit_limit_gtc.post_only),
1969 other => panic!("expected LimitGtc, was {other:?}"),
1970 }
1971 }
1972
1973 #[rstest]
1974 fn test_build_order_configuration_limit_gtd_requires_expire_time() {
1975 let result = build_order_configuration(
1976 OrderType::Limit,
1977 OrderSide::Buy,
1978 Quantity::from("1"),
1979 Some(Price::from("100.00")),
1980 None,
1981 TimeInForce::Gtd,
1982 None,
1983 false,
1984 false,
1985 false,
1986 );
1987 assert!(result.is_err());
1988 }
1989
1990 #[rstest]
1991 fn test_build_order_configuration_stop_limit_uses_correct_direction() {
1992 let buy_cfg = build_order_configuration(
1993 OrderType::StopLimit,
1994 OrderSide::Buy,
1995 Quantity::from("1"),
1996 Some(Price::from("100.00")),
1997 Some(Price::from("99.00")),
1998 TimeInForce::Gtc,
1999 None,
2000 false,
2001 false,
2002 false,
2003 )
2004 .unwrap();
2005
2006 match buy_cfg {
2007 OrderConfiguration::StopLimitGtc(s) => assert_eq!(
2008 s.stop_limit_stop_limit_gtc.stop_direction,
2009 CoinbaseStopDirection::StopUp
2010 ),
2011 other => panic!("expected StopLimitGtc, was {other:?}"),
2012 }
2013
2014 let sell_cfg = build_order_configuration(
2015 OrderType::StopLimit,
2016 OrderSide::Sell,
2017 Quantity::from("1"),
2018 Some(Price::from("100.00")),
2019 Some(Price::from("99.00")),
2020 TimeInForce::Gtc,
2021 None,
2022 false,
2023 false,
2024 false,
2025 )
2026 .unwrap();
2027
2028 match sell_cfg {
2029 OrderConfiguration::StopLimitGtc(s) => assert_eq!(
2030 s.stop_limit_stop_limit_gtc.stop_direction,
2031 CoinbaseStopDirection::StopDown
2032 ),
2033 other => panic!("expected StopLimitGtc, was {other:?}"),
2034 }
2035 }
2036
2037 #[rstest]
2038 fn test_build_order_configuration_market_accepts_default_gtc() {
2039 let cfg = build_order_configuration(
2042 OrderType::Market,
2043 OrderSide::Buy,
2044 Quantity::from("1"),
2045 None,
2046 None,
2047 TimeInForce::Gtc,
2048 None,
2049 false,
2050 false,
2051 false,
2052 )
2053 .unwrap();
2054 assert!(matches!(cfg, OrderConfiguration::MarketIoc(_)));
2055 }
2056
2057 #[rstest]
2058 fn test_build_order_configuration_rejects_stop_market() {
2059 let result = build_order_configuration(
2060 OrderType::StopMarket,
2061 OrderSide::Buy,
2062 Quantity::from("1"),
2063 None,
2064 Some(Price::from("100.00")),
2065 TimeInForce::Gtc,
2066 None,
2067 false,
2068 false,
2069 false,
2070 );
2071 assert!(result.is_err());
2072 }
2073
2074 #[rstest]
2075 fn test_rest_quota_matches_documented_limit() {
2076 assert_eq!(COINBASE_REST_QUOTA.burst_size().get(), 30);
2077 }
2078
2079 #[rstest]
2080 fn test_default_retry_config_values() {
2081 let config = default_retry_config();
2082 assert_eq!(config.max_retries, 3);
2083 assert_eq!(config.initial_delay_ms, 100);
2084 assert_eq!(config.max_delay_ms, 5_000);
2085 assert_eq!(config.max_elapsed_ms, Some(180_000));
2086 }
2087}