coinbase_pro_api/
lib.rs

1/// Coinbase Pro REST API public client
2
3// std
4use std::num::NonZeroU32;
5use std::fmt::Debug;
6use std::time::Duration;
7// external
8use reqwest::{Method, Url};
9use reqwest;
10use governor::{
11    Quota,
12    RateLimiter,
13    clock::DefaultClock,
14    state::{InMemoryState, NotKeyed}
15};
16use anyhow;
17use anyhow::Context;
18use chrono::{DateTime, Utc};
19
20/// Default Constants
21pub(crate) const COINBASE_API_URL: &str = "https://api.pro.coinbase.com";
22pub(crate) const DEFAULT_REQUEST_TIMEOUT: u8 = 30;
23pub(crate) const DEFAULT_RATE_LIMIT: u8 = 3;
24pub(crate) const DEFAULT_BURST_SIZE: u8 = 6;
25pub(crate) const APP_USER_AGENT: &str = concat!(
26    env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")
27);
28
29/// Coinbase Pro public API client. Use build() method to instantiate.
30#[derive(Debug)]
31pub struct CoinbasePublicClient {
32    api_url: &'static str,
33    http_client: reqwest::Client,
34    request_timeout: u8,
35    rate_limiter: Option<RateLimiter<NotKeyed, InMemoryState, DefaultClock>>,
36}
37
38/// Enum representing Coinbase's orderbook options.
39#[derive(Debug, Clone)]
40pub enum OBLevel {
41    Level1 = 1,
42    Level2 = 2,
43    Level3 = 3,
44}
45
46/// Returns a tuple of length 2 that can be placed into a vector and included
47/// as a request parameter.
48impl OBLevel {
49    fn param_tuple(&self) -> (String, String) {
50        ("level".to_owned(), (self.to_owned() as u8).to_string())
51    }
52}
53
54/// Enum representing Coinbase's accepted candle granularities, in seconds.
55#[derive(Debug, Clone)]
56pub enum Granularity {
57    Minute1 = 60,
58    Minute5 = 300,
59    Minute15 = 900,
60    Hour1 = 3600,
61    Hour6 = 21600,
62    Hour24 = 86400,
63}
64
65impl Granularity {
66    fn param_tuple(&self) -> (String, String) {
67        ("granularity".to_owned(), (self.to_owned() as u32).to_string())
68    }
69}
70
71type Params = Vec<(String, String)>;
72
73impl CoinbasePublicClient {
74    /// Instantiate a new Coinbase public client using default parameters.
75    pub fn new() -> Self {
76        Self::builder().build()
77    }
78
79    /// Builder to construct CoinbasePublicClient instances. Parameters not passed to the builder
80    /// default to the constants defined under 'Default Constants'.
81    ///
82    /// # Arguments
83    ///
84    /// * 'api_url' - API URL . Defaults to const COINBASE_API_URL (https://api.pro.coinbase.com)
85    /// * 'request_timeout' - HTTP request timeout (in seconds). Defaults to const DEFAULT_REQUEST_TIMEOUT (30).
86    /// * 'rate_limit' - Number of requests per second allowed. Set to zero to disable rate-limiting.
87    /// Defaults to const DEFAULT_RATE_LIMIT (3).
88    /// * 'burst_size' - Number of requests that can be burst when rate-limiting is enabled.
89    /// Defaults to const DEFAULT_BURST_SIZE (6).
90    ///
91    ///  # Example
92    ///             use coinbase_pro_api::CoinbasePublicClient;
93    ///
94    ///             let client = CoinbasePublicClient::builder()
95    ///                .request_timeout(30)
96    ///                .rate_limit(3)
97    ///                .burst_size(6)
98    ///                .api_url("https://api.pro.coinbase.com")
99    ///                .build();
100    pub fn builder() -> CoinbaseClientBuilder<'static> {
101        CoinbaseClientBuilder::new()
102    }
103
104    /// Get list of available markets to trade.
105    pub async fn get_products(&self) -> Result<String, anyhow::Error> {
106        let endpoint = "/products";
107        Ok(self.get_json(endpoint, None).await?)
108    }
109
110    /// Returns information about a single market
111    ///
112    /// # Arguments
113    ///
114    /// * 'product_id' - market identifier formatted as 'BASE-QUOTE', such as 'ETH-USD'.
115    /// String can be lowercase or uppercase.
116    pub async fn get_product(&self, product_id: &str) -> Result<String, anyhow::Error> {
117        let endpoint = "/products/".to_owned() + product_id;
118        Ok(self.get_json(&endpoint, None).await?)
119    }
120
121    /// Returns up to a full (level 3) orderbook from a single market.
122    ///
123    /// # Arguments
124    ///
125    /// * 'product_id' - market identifier formatted as 'BASE-QUOTE', such as 'ETH-USD'.
126    /// String can be lowercase or uppercase.
127    ///
128    /// * 'level' - Level 1 will return the best bid and best ask.
129    /// Level 2 will return the 50 best bid and ask levels, aggregated.
130    /// Level 3 will return the full orderbook, unaggregated.
131    pub async fn get_product_orderbook(&self, product_id: &str, level: OBLevel) -> Result<String, anyhow::Error> {
132        let params: Params = vec![level.param_tuple()];
133        let endpoint = format!("/products/{}/book", product_id);
134        Ok(self.get_json(&endpoint, Some(params)).await?)
135    }
136
137    /// Returns snapshot about the last trade, best bid/ask and 24h volume.
138    ///
139    /// # Arguments
140    ///
141    /// * 'product_id' - market identifier formatted as 'BASE-QUOTE', such as 'ETH-USD'.
142    /// String can be lowercase or uppercase.
143    pub async fn get_product_ticker(&self, product_id: &str) -> Result<String, anyhow::Error> {
144        let endpoint = format!("/products/{}/ticker", product_id);
145        Ok(self.get_json(&endpoint, None).await?)
146    }
147
148    /// Returns a product's latest trades.
149    ///
150    /// # Arguments
151    ///
152    /// * 'product_id' - market identifier formatted as 'BASE-QUOTE', such as 'ETH-USD'.
153    /// String can be lowercase or uppercase.
154    /// * 'after' - optional parameter: pass in a 'Some(u64)' to parameterize a lower bound for
155    /// recent trades, and exclude trades from the response that have a lower sequence.
156    pub async fn get_product_trades(&self, product_id: &str, after: Option<u64>) -> Result<String, anyhow::Error> {
157        let endpoint = format!("/products/{}/trades", product_id);
158
159        let maybe_params: Option<Params> = after
160            .map(|after| vec![("after".to_owned(), (after + 1).to_string())]);
161
162        Ok(self.get_json(&endpoint, maybe_params).await?)
163    }
164
165    /// Return's a product's historic rates.
166    ///
167    /// # Arguments
168    /// * 'product_id' - market identifier formatted as 'BASE-QUOTE', such as 'ETH-USD'.
169    /// String can be lowercase or uppercase.
170    /// * 'start' - optional parameter: Start DateTime<UTC>
171    /// * 'end' - optional parameter: End DateTime<UTC>
172    /// * 'granularity' - optional parameter: candle size in seconds
173    ///
174    /// The chrono crate's ['to_rfc3339'](https://docs.rs/chrono/0.4.0/chrono/struct.DateTime.html#method.to_rfc3339)
175    /// method can generate the correct datetime strings.
176    ///
177    /// Candle schema is [timestamp, low, high, open, close, volume].
178    ///
179    /// If start, end, and granularity parameters are left None, Coinbase will return
180    /// 300 1-minute candles. Coinbase does not publish data for periods where no trades
181    /// occur. Coinbase will reject requests for more than 300 candles of any size.
182    pub async fn get_product_historic_rates(
183        &self,
184        product_id: &str,
185        start_opt: Option<DateTime<Utc>>,
186        end_opt: Option<DateTime<Utc>>,
187        granularity_opt: Option<Granularity>
188    ) -> Result<String, anyhow::Error> {
189        let endpoint = format!("/products/{}/candles", product_id);
190
191        let mut params: Params = Vec::new();
192        if start_opt.is_some() {
193            params.push(("start".to_owned(), start_opt.unwrap().to_owned().to_rfc3339()))
194        }
195        if end_opt.is_some() {
196            params.push(("end".to_owned(), end_opt.unwrap().to_owned().to_rfc3339()))
197        }
198        if granularity_opt.is_some() { params.push(granularity_opt.unwrap().param_tuple()); }
199
200        let maybe_params: Option<Params> = match params.is_empty() {
201            true => None,
202            false => Some(params)
203        };
204
205        Ok(self.get_json(&endpoint, maybe_params).await?)
206    }
207
208    /// Returns a product's 24h stats.
209    /// # Arguments
210    /// * 'product_id' - market identifier formatted as 'BASE-QUOTE', such as 'ETH-USD'.
211    /// String can be lowercase or uppercase.
212    pub async fn get_product_24h_stats(&self, product_id: &str) -> Result<String, anyhow::Error> {
213        let endpoint = format!("/products/{}/stats", product_id);
214        Ok(self.get_json(&endpoint, None).await?)
215    }
216
217    /// Returns currencies supported by Coinbase.
218    pub async fn get_currencies(&self) -> Result<String, anyhow::Error> {
219        let endpoint = "/currencies";
220        Ok(self.get_json(endpoint, None).await?)
221    }
222
223
224    /// Returns Coinbase's server time in both epoch and ISO format.
225    pub async fn get_time(&self) -> Result<String, anyhow::Error> {
226        let endpoint = "/time";
227        Ok(self.get_json(endpoint, None).await?)
228    }
229
230    /// Sends get message and attempts to return json string.
231    async fn get_json(&self, endpoint: &str, params: Option<Params>) -> Result<String, anyhow::Error> {
232        let url_str = self.api_url.to_owned() + endpoint;
233
234        let url = match params {
235            Some(params) => {
236                Url::parse_with_params(&url_str, &params)
237                    .context("failed to parse url string with params")?
238            },
239            None => {
240                Url::parse(&url_str).context("failed to parse url string")?
241            }
242        };
243
244        if self.rate_limiter.is_some() {
245            self.rate_limiter.as_ref().unwrap().until_ready().await;
246        }
247
248        let result= self.http_client
249            .request(Method::GET, url)
250            .timeout(Duration::from_secs(self.request_timeout as u64))
251            .send().await.context("failure while sending request")?
252            .text().await.context("failure while decoding response to text")?;
253
254        Ok(result)
255    }
256}
257
258/// Builder to construct Coinbase client instances
259pub struct CoinbaseClientBuilder<'a> {
260    api_url: Option<&'a str>,
261    request_timeout: Option<u8>,
262    rate_limit: Option<u8>,
263    burst_size: Option<u8>,
264}
265
266impl CoinbaseClientBuilder<'static> {
267    pub fn new() -> Self {
268        Self {
269            api_url: None,
270            request_timeout: None,
271            rate_limit: None,
272            burst_size: None,
273        }
274    }
275
276    pub fn api_url(self, value: &'static str) -> Self {
277        Self {
278            api_url: Some(value),
279            ..self
280        }
281    }
282
283    pub fn request_timeout(self, value: u8) -> Self {
284        Self {
285            request_timeout: Some(value),
286            ..self
287        }
288    }
289
290    pub fn rate_limit(self, value: u8) -> Self {
291        Self {
292            rate_limit: Some(value),
293            ..self
294        }
295    }
296
297    pub fn burst_size(self, value: u8) -> Self {
298        Self {
299            burst_size: Some(value),
300            ..self
301        }
302    }
303
304    pub fn build(self) -> CoinbasePublicClient {
305        let rate_limit = self.rate_limit.unwrap_or(DEFAULT_RATE_LIMIT);
306        let burst_size = self.burst_size.unwrap_or(DEFAULT_BURST_SIZE);
307
308        CoinbasePublicClient {
309            api_url: self.api_url.unwrap_or(COINBASE_API_URL),
310            http_client: reqwest::Client::builder()
311                .user_agent(APP_USER_AGENT)
312                .build()
313                .unwrap_or_else(|_| reqwest::Client::new()),
314            request_timeout: self.request_timeout.unwrap_or(DEFAULT_REQUEST_TIMEOUT),
315            rate_limiter: {
316                if rate_limit > 0 {
317                    let mut quota = Quota::per_second(NonZeroU32::new(rate_limit as u32).unwrap());
318                    if burst_size > 0 {
319                        quota = quota.allow_burst(NonZeroU32::new(burst_size as u32).unwrap())
320                    };
321                    Some(RateLimiter::direct(quota))
322                } else { None }
323            },
324        }
325    }
326}
327
328impl Default for CoinbasePublicClient {
329    fn default() -> Self {
330        CoinbasePublicClient::new()
331    }
332}
333
334impl Default for CoinbaseClientBuilder<'_> {
335    fn default() -> Self {
336        CoinbaseClientBuilder::new()
337    }
338}
339
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use chrono::prelude::*;
345    use std::time::SystemTime;
346    use lazy_static::lazy_static;
347
348    fn print_type_of<T>(_: &T) {
349        println!("{}", std::any::type_name::<T>());
350    }
351
352    fn now_minus_x_sec(x: u64) -> DateTime<Utc> {
353        let now: DateTime<Utc> = SystemTime::now().into();
354        let x = chrono::Duration::seconds(x as i64);
355        now.checked_sub_signed(x).unwrap()
356    }
357
358    lazy_static! {
359        static ref client: CoinbasePublicClient = CoinbasePublicClient::builder()
360            .rate_limit(1)
361            .burst_size(1)
362            .build()
363        ;
364    }
365
366    #[tokio::test]
367    async fn test_time() {
368        let response = client.get_time().await;
369        assert!(response.is_ok());
370    }
371
372    #[tokio::test]
373    async fn test_currencies() {
374        let response = client.get_currencies().await;
375        assert!(response.is_ok());
376    }
377
378    #[tokio::test]
379    async fn test_24h_stats() {
380        let response = client.get_product_24h_stats("ETH-USD").await;
381        assert!(response.is_ok());
382    }
383
384    #[tokio::test]
385    async fn test_candles() {
386        let candles = client.get_product_historic_rates(
387            "eth-usd", None, None, None
388            ).await;
389        assert!(candles.is_ok())
390    }
391
392    #[tokio::test]
393    async fn test_trades() {
394        // let client = CoinbasePublicClient::builder().build();
395        let trades = client.get_product_trades("eth-usd", None).await;
396        assert!(trades.is_ok())
397    }
398
399    #[tokio::test]
400    async fn test_ticker() {
401        let ticker = client.get_product_ticker("eth-usd").await;
402        assert!(ticker.is_ok())
403    }
404
405    #[tokio::test]
406    async fn test_orderbook() {
407        let eth_usd_str = "eth-usd";
408        let orderbook_lvl1 = client
409            .get_product_orderbook(eth_usd_str, OBLevel::Level1).await;
410        assert!(orderbook_lvl1.is_ok());
411        let orderbook_lvl2 = client
412            .get_product_orderbook(eth_usd_str, OBLevel::Level2).await;
413        assert!(orderbook_lvl2.is_ok());
414        let orderbook_lvl3 = client
415            .get_product_orderbook(eth_usd_str, OBLevel::Level3).await;
416        assert!(orderbook_lvl3.is_ok());
417    }
418
419    #[tokio::test]
420    async fn get_products() {
421        let products = client.get_products().await;
422        assert!(products.is_ok());
423    }
424
425    #[tokio::test]
426    async fn get_product() {
427        let product_ids: Vec<&str> = vec![
428            "ETH-USD", "btc-usd", "sol-usd"
429        ];
430        for product_str in product_ids {
431            let result = client.get_product(product_str).await;
432            assert!(result.is_ok());
433        }
434    }
435}