Skip to main content

exchange_apiws/cryptocom/
rest.rs

1//! Crypto.com public REST endpoints (`https://api.crypto.com/exchange/v1`).
2//!
3//! All endpoints exposed here are **unauthenticated**. Crypto.com wraps
4//! every response in a `{"id": N, "method": "...", "code": N, "result": {...}}`
5//! envelope; non-zero `code` surfaces as [`ExchangeError::Api`] via
6//! [`unwrap_cryptocom_envelope`].
7//!
8//! # Endpoint coverage
9//!
10//! | Method | Endpoint | Returns |
11//! |---|---|---|
12//! | `get_instruments()` | `/public/get-instruments` | `Vec<CryptocomInstrument>` |
13//! | `get_orderbook(instrument, depth)` | `/public/get-book` | `CryptocomOrderBook` |
14//! | `get_candlestick(instrument, timeframe)` | `/public/get-candlestick` | `Vec<CryptocomCandle>` |
15//! | `get_ticker(instrument)` | `/public/get-ticker` | `Vec<CryptocomTicker>` |
16//! | `get_recent_trades(instrument)` | `/public/get-trades` | `Vec<CryptocomTrade>` |
17//! | `get_valuations(instrument, valuation_type)` | `/public/get-valuations` | `Vec<CryptocomValuation>` |
18//!
19//! Crypto.com's wire format uses heavily abbreviated single-letter field
20//! names (`i`, `b`, `k`, `vv`, …); the typed structs in this module
21//! `#[serde(rename)]` them to readable names. Numeric fields stay as
22//! `String` to preserve Crypto.com's wire precision; convert with
23//! `.parse::<f64>()` where needed.
24
25use serde::Deserialize;
26use serde_json::Value;
27
28use crate::error::{ExchangeError, Result};
29use crate::http::PublicRestClient;
30
31const BASE_URL: &str = "https://api.crypto.com/exchange/v1";
32
33// ── Envelope unwrap ─────────────────────────────────────────────────────────
34
35/// Unwrap the standard Crypto.com `{"code": N, "result": {...}}` envelope.
36///
37/// Non-zero `code` surfaces as [`ExchangeError::Api`] with the code as a
38/// decimal string and the `message` field preserved when present.
39///
40/// # Errors
41///
42/// Returns [`ExchangeError::Api`] when Crypto.com reports an error code,
43/// or [`ExchangeError::Json`] when the `result` field can't be decoded
44/// into `T`.
45pub fn unwrap_cryptocom_envelope<T: serde::de::DeserializeOwned>(raw: Value) -> Result<T> {
46    let code = raw.get("code").and_then(Value::as_i64).unwrap_or(-1);
47    if code != 0 {
48        let message = raw
49            .get("message")
50            .and_then(Value::as_str)
51            .unwrap_or("no message")
52            .to_string();
53        return Err(ExchangeError::Api {
54            code: code.to_string(),
55            message,
56        });
57    }
58    let result = raw.get("result").cloned().unwrap_or(Value::Null);
59    serde_json::from_value(result).map_err(ExchangeError::Json)
60}
61
62/// Inner `{"data": [...]}` wrapper that most Crypto.com endpoints use.
63/// The list-of-T pattern is so consistent we unwrap it generically.
64#[derive(Debug, Clone, Deserialize)]
65struct DataList<T> {
66    data: Vec<T>,
67}
68
69// ── Response types ──────────────────────────────────────────────────────────
70
71/// Instrument metadata from `/public/get-instruments`.
72#[derive(Debug, Clone, Deserialize)]
73pub struct CryptocomInstrument {
74    /// Trading-pair symbol (e.g. `"BTC_USDT"`).
75    pub symbol: String,
76    /// `"CCY_PAIR"` (spot) or `"PERPETUAL_SWAP"`.
77    #[serde(default)]
78    pub inst_type: Option<String>,
79    /// Human-friendly name (e.g. `"BTC/USDT"`).
80    #[serde(default)]
81    pub display_name: Option<String>,
82    /// Base currency code.
83    #[serde(default)]
84    pub base_ccy: Option<String>,
85    /// Quote currency code.
86    #[serde(default)]
87    pub quote_ccy: Option<String>,
88    /// Decimal precision for quote-asset prices.
89    #[serde(default)]
90    pub quote_decimals: Option<u32>,
91    /// Decimal precision for base-asset quantities.
92    #[serde(default)]
93    pub quantity_decimals: Option<u32>,
94    /// Minimum price increment (string form).
95    #[serde(default)]
96    pub price_tick_size: Option<String>,
97    /// Minimum size increment (string form).
98    #[serde(default)]
99    pub qty_tick_size: Option<String>,
100    /// Maximum leverage for perpetuals; `"1"` for spot.
101    #[serde(default)]
102    pub max_leverage: Option<String>,
103    /// `true` when the instrument is currently tradable.
104    #[serde(default)]
105    pub tradable: bool,
106}
107
108/// Order book snapshot from `/public/get-book`.
109///
110/// Crypto.com's response wraps the book in an array of one entry; this
111/// struct flattens it for ergonomics.
112#[derive(Debug, Clone, Deserialize)]
113pub struct CryptocomOrderBook {
114    /// Symbol the book is for.
115    pub instrument_name: String,
116    /// Returned depth (matches request).
117    #[serde(default)]
118    pub depth: Option<u32>,
119    /// Bid levels — `[price_str, qty_str, num_orders_str]` per row.
120    #[serde(default)]
121    pub bids: Vec<[String; 3]>,
122    /// Ask levels — same shape as bids.
123    #[serde(default)]
124    pub asks: Vec<[String; 3]>,
125    /// Snapshot timestamp (ms since epoch).
126    #[serde(default)]
127    pub timestamp: i64,
128    /// Sequence number for deltas.
129    #[serde(default)]
130    pub sequence: i64,
131}
132
133impl CryptocomOrderBook {
134    /// Parse `bids` to `[price, qty]` `f64` pairs, dropping the
135    /// num-orders column and skipping malformed entries.
136    #[must_use]
137    pub fn bids_f64(&self) -> Vec<[f64; 2]> {
138        Self::parse_levels(&self.bids)
139    }
140    /// Parse `asks` to `[price, qty]` `f64` pairs.
141    #[must_use]
142    pub fn asks_f64(&self) -> Vec<[f64; 2]> {
143        Self::parse_levels(&self.asks)
144    }
145    fn parse_levels(rows: &[[String; 3]]) -> Vec<[f64; 2]> {
146        rows.iter()
147            .filter_map(|[p, q, _n]| Some([p.parse().ok()?, q.parse().ok()?]))
148            .collect()
149    }
150}
151
152/// Single candlestick bar from `/public/get-candlestick`.
153///
154/// Crypto.com uses single-letter field names on the wire; renamed here
155/// to readable names. Numeric fields stay as `String` to preserve
156/// precision.
157#[derive(Debug, Clone, Deserialize)]
158pub struct CryptocomCandle {
159    /// Open price.
160    #[serde(rename = "o")]
161    pub open: String,
162    /// High price.
163    #[serde(rename = "h")]
164    pub high: String,
165    /// Low price.
166    #[serde(rename = "l")]
167    pub low: String,
168    /// Close price.
169    #[serde(rename = "c")]
170    pub close: String,
171    /// Volume in base asset.
172    #[serde(rename = "v")]
173    pub volume: String,
174    /// Bar open timestamp (ms since epoch).
175    #[serde(rename = "t")]
176    pub open_ts: i64,
177}
178
179impl CryptocomCandle {
180    /// Parse `open` as `f64`. Returns `0.0` on a malformed string.
181    #[must_use]
182    pub fn open_f64(&self) -> f64 {
183        self.open.parse().unwrap_or(0.0)
184    }
185    /// Parse `close` as `f64`.
186    #[must_use]
187    pub fn close_f64(&self) -> f64 {
188        self.close.parse().unwrap_or(0.0)
189    }
190    /// Parse `high` as `f64`.
191    #[must_use]
192    pub fn high_f64(&self) -> f64 {
193        self.high.parse().unwrap_or(0.0)
194    }
195    /// Parse `low` as `f64`.
196    #[must_use]
197    pub fn low_f64(&self) -> f64 {
198        self.low.parse().unwrap_or(0.0)
199    }
200    /// Parse `volume` as `f64`.
201    #[must_use]
202    pub fn volume_f64(&self) -> f64 {
203        self.volume.parse().unwrap_or(0.0)
204    }
205}
206
207/// 24-hour ticker snapshot from `/public/get-ticker`.
208///
209/// Crypto.com's wire shape uses single-letter field names that don't
210/// follow any obvious convention — they're renamed here per the
211/// official docs.
212#[derive(Debug, Clone, Deserialize)]
213pub struct CryptocomTicker {
214    /// Instrument symbol.
215    #[serde(rename = "i")]
216    pub instrument: String,
217    /// Latest trade price.
218    #[serde(rename = "a", default)]
219    pub last_price: Option<String>,
220    /// 24-hour high.
221    #[serde(rename = "h", default)]
222    pub high_24h: Option<String>,
223    /// 24-hour low.
224    #[serde(rename = "l", default)]
225    pub low_24h: Option<String>,
226    /// 24-hour base-asset volume.
227    #[serde(rename = "v", default)]
228    pub volume_24h: Option<String>,
229    /// 24-hour quote-asset turnover.
230    #[serde(rename = "vv", default)]
231    pub value_24h: Option<String>,
232    /// 24-hour price change percent (as a decimal — `0.05` = 5 %).
233    #[serde(rename = "c", default)]
234    pub change_pct_24h: Option<String>,
235    /// Best bid price.
236    #[serde(rename = "b", default)]
237    pub best_bid: Option<String>,
238    /// Best ask price. (Crypto.com uses `"k"` for ask, unintuitively.)
239    #[serde(rename = "k", default)]
240    pub best_ask: Option<String>,
241    /// Snapshot timestamp (ms since epoch).
242    #[serde(rename = "t", default)]
243    pub timestamp: i64,
244}
245
246/// Single recent trade from `/public/get-trades`.
247#[derive(Debug, Clone, Deserialize)]
248pub struct CryptocomTrade {
249    /// `"buy"` or `"sell"` (direction of the taker).
250    #[serde(rename = "s")]
251    pub side: String,
252    /// Trade price.
253    #[serde(rename = "p")]
254    pub price: String,
255    /// Trade quantity in base asset.
256    #[serde(rename = "q")]
257    pub qty: String,
258    /// Trade timestamp (ms since epoch).
259    #[serde(rename = "t")]
260    pub timestamp: i64,
261    /// Trade ID.
262    #[serde(rename = "d", default)]
263    pub trade_id: Option<String>,
264    /// Instrument symbol.
265    #[serde(rename = "i", default)]
266    pub instrument: Option<String>,
267}
268
269/// Single valuation entry from `/public/get-valuations`.
270///
271/// `valuation_type` selects what `value` carries: `"mark_price"`,
272/// `"index_price"`, `"funding_rate"`, `"estimated_funding_rate"`.
273#[derive(Debug, Clone, Deserialize)]
274pub struct CryptocomValuation {
275    /// Numeric value of the requested metric.
276    #[serde(rename = "v")]
277    pub value: String,
278    /// Sample timestamp (ms since epoch).
279    #[serde(rename = "t")]
280    pub timestamp: i64,
281}
282
283// ── Client ──────────────────────────────────────────────────────────────────
284
285/// Crypto.com public REST client.
286///
287/// Construct once and clone cheaply — the underlying HTTP client pools
288/// connections. All methods are `&self` and async.
289#[derive(Clone)]
290pub struct CryptocomRestClient {
291    http: PublicRestClient,
292}
293
294impl CryptocomRestClient {
295    /// Build a client pointed at Crypto.com's live exchange API.
296    pub fn new() -> Result<Self> {
297        Self::with_base_url(BASE_URL)
298    }
299
300    /// Build a client with a caller-supplied base URL (tests, proxies).
301    pub fn with_base_url(base_url: impl Into<String>) -> Result<Self> {
302        Ok(Self {
303            http: PublicRestClient::new(base_url)?,
304        })
305    }
306
307    /// `GET /public/get-instruments` — every tradable instrument.
308    pub async fn get_instruments(&self) -> Result<Vec<CryptocomInstrument>> {
309        let raw: Value = self.http.get("/public/get-instruments", &[]).await?;
310        let list: DataList<CryptocomInstrument> = unwrap_cryptocom_envelope(raw)?;
311        Ok(list.data)
312    }
313
314    /// `GET /public/get-book` — order book snapshot for `instrument`.
315    ///
316    /// `depth` is clamped server-side to a supported value (e.g. 10, 50).
317    pub async fn get_orderbook(&self, instrument: &str, depth: u32) -> Result<CryptocomOrderBook> {
318        let d = depth.to_string();
319        let raw: Value = self
320            .http
321            .get(
322                "/public/get-book",
323                &[("instrument_name", instrument), ("depth", &d)],
324            )
325            .await?;
326        // The response wraps the book in result.data[0]; unwrap to a single struct.
327        let list: DataList<CryptocomOrderBook> = unwrap_cryptocom_envelope(raw)?;
328        list.data
329            .into_iter()
330            .next()
331            .ok_or_else(|| ExchangeError::Api {
332                code: "empty_data".into(),
333                message: "Crypto.com returned an empty book array".into(),
334            })
335    }
336
337    /// `GET /public/get-candlestick` — OHLC bars for `instrument`.
338    ///
339    /// `timeframe` follows Crypto.com's wire values — `"1m"`, `"5m"`,
340    /// `"15m"`, `"30m"`, `"1h"`, `"4h"`, `"6h"`, `"12h"`, `"1D"`,
341    /// `"7D"`, `"14D"`, `"1M"`.
342    pub async fn get_candlestick(
343        &self,
344        instrument: &str,
345        timeframe: &str,
346    ) -> Result<Vec<CryptocomCandle>> {
347        let raw: Value = self
348            .http
349            .get(
350                "/public/get-candlestick",
351                &[("instrument_name", instrument), ("timeframe", timeframe)],
352            )
353            .await?;
354        let list: DataList<CryptocomCandle> = unwrap_cryptocom_envelope(raw)?;
355        Ok(list.data)
356    }
357
358    /// `GET /public/get-tickers` — 24-hour rolling ticker for one or all
359    /// instruments. Pass `None` to fetch every instrument.
360    pub async fn get_ticker(&self, instrument: Option<&str>) -> Result<Vec<CryptocomTicker>> {
361        let mut params: Vec<(&str, &str)> = Vec::new();
362        if let Some(i) = instrument {
363            params.push(("instrument_name", i));
364        }
365        let raw: Value = self.http.get("/public/get-tickers", &params).await?;
366        let list: DataList<CryptocomTicker> = unwrap_cryptocom_envelope(raw)?;
367        Ok(list.data)
368    }
369
370    /// `GET /public/get-trades` — recent trades for `instrument`.
371    pub async fn get_recent_trades(&self, instrument: &str) -> Result<Vec<CryptocomTrade>> {
372        let raw: Value = self
373            .http
374            .get("/public/get-trades", &[("instrument_name", instrument)])
375            .await?;
376        let list: DataList<CryptocomTrade> = unwrap_cryptocom_envelope(raw)?;
377        Ok(list.data)
378    }
379
380    /// `GET /public/get-valuations` — mark/index price or funding rate
381    /// time series for a perpetual.
382    ///
383    /// `valuation_type` is one of `"index_price"`, `"mark_price"`,
384    /// `"funding_rate"`, `"estimated_funding_rate"`. Returns a time
385    /// series; the latest value is the last element.
386    pub async fn get_valuations(
387        &self,
388        instrument: &str,
389        valuation_type: &str,
390    ) -> Result<Vec<CryptocomValuation>> {
391        let raw: Value = self
392            .http
393            .get(
394                "/public/get-valuations",
395                &[
396                    ("instrument_name", instrument),
397                    ("valuation_type", valuation_type),
398                ],
399            )
400            .await?;
401        let list: DataList<CryptocomValuation> = unwrap_cryptocom_envelope(raw)?;
402        Ok(list.data)
403    }
404}
405
406// ── Unit tests ──────────────────────────────────────────────────────────────
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn envelope_unwraps_success() {
414        let raw = serde_json::json!({"code": 0, "result": {"x": 1}});
415        let v: Value = unwrap_cryptocom_envelope(raw).expect("unwrap");
416        assert_eq!(v["x"], 1);
417    }
418
419    #[test]
420    fn envelope_surfaces_nonzero_code_as_api_error() {
421        let raw = serde_json::json!({
422            "code": 30009,
423            "message": "Invalid instrument_name",
424            "result": {}
425        });
426        let r: Result<Value> = unwrap_cryptocom_envelope(raw);
427        match r {
428            Err(ExchangeError::Api { code, message }) => {
429                assert_eq!(code, "30009");
430                assert!(message.contains("Invalid instrument_name"));
431            }
432            other => panic!("expected Api error, got {other:?}"),
433        }
434    }
435
436    #[test]
437    fn orderbook_helpers_drop_num_orders_column() {
438        let raw = r#"{
439            "instrument_name": "BTC_USDT",
440            "depth": 5,
441            "bids": [["96000.0", "1.5", "2"], ["95999.0", "2.0", "3"]],
442            "asks": [["96001.0", "0.5", "1"]],
443            "timestamp": 1700000000000,
444            "sequence": 42
445        }"#;
446        let book: CryptocomOrderBook = serde_json::from_str(raw).expect("deserialize");
447        assert_eq!(book.instrument_name, "BTC_USDT");
448        assert_eq!(book.sequence, 42);
449        let bids = book.bids_f64();
450        let asks = book.asks_f64();
451        assert!((bids[0][0] - 96_000.0).abs() < 1e-9);
452        assert!((bids[1][1] - 2.0).abs() < 1e-9);
453        assert!((asks[0][0] - 96_001.0).abs() < 1e-9);
454    }
455
456    #[test]
457    fn candle_renames_letters_and_helpers_parse() {
458        let raw = r#"{"o":"96000.0","h":"96100.0","l":"95900.0","c":"96050.0","v":"10.5","t":1700000000000}"#;
459        let c: CryptocomCandle = serde_json::from_str(raw).expect("deserialize");
460        assert!((c.open_f64() - 96_000.0).abs() < 1e-9);
461        assert!((c.close_f64() - 96_050.0).abs() < 1e-9);
462        assert!((c.high_f64() - 96_100.0).abs() < 1e-9);
463        assert!((c.low_f64() - 95_900.0).abs() < 1e-9);
464        assert!((c.volume_f64() - 10.5).abs() < 1e-9);
465        assert_eq!(c.open_ts, 1_700_000_000_000);
466    }
467
468    #[test]
469    fn ticker_renames_cryptic_letters() {
470        let raw = r#"{
471            "i":"BTC_USDT","a":"96000.0","h":"96500.0","l":"95500.0",
472            "v":"100.5","vv":"9650000","c":"0.005",
473            "b":"95999.0","k":"96001.0","t":1700000000000
474        }"#;
475        let t: CryptocomTicker = serde_json::from_str(raw).expect("deserialize");
476        assert_eq!(t.instrument, "BTC_USDT");
477        assert_eq!(t.last_price.as_deref(), Some("96000.0"));
478        assert_eq!(t.best_bid.as_deref(), Some("95999.0"));
479        assert_eq!(t.best_ask.as_deref(), Some("96001.0"));
480        assert_eq!(t.value_24h.as_deref(), Some("9650000"));
481        assert_eq!(t.timestamp, 1_700_000_000_000);
482    }
483
484    #[test]
485    fn trade_renames_letters() {
486        let raw =
487            r#"{"s":"buy","p":"96000.0","q":"0.05","t":1700000000000,"d":"abc","i":"BTC_USDT"}"#;
488        let t: CryptocomTrade = serde_json::from_str(raw).expect("deserialize");
489        assert_eq!(t.side, "buy");
490        assert_eq!(t.price, "96000.0");
491        assert_eq!(t.trade_id.as_deref(), Some("abc"));
492    }
493
494    #[test]
495    fn valuation_renames_letters() {
496        let raw = r#"{"v":"96010.5","t":1700000000000}"#;
497        let val: CryptocomValuation = serde_json::from_str(raw).expect("deserialize");
498        assert_eq!(val.value, "96010.5");
499        assert_eq!(val.timestamp, 1_700_000_000_000);
500    }
501
502    #[test]
503    fn instrument_handles_missing_optional_fields() {
504        // A minimal instrument response — only the required `symbol` field.
505        let raw = r#"{"symbol":"BTC_USDT","tradable":true}"#;
506        let i: CryptocomInstrument = serde_json::from_str(raw).expect("deserialize");
507        assert_eq!(i.symbol, "BTC_USDT");
508        assert!(i.tradable);
509        assert!(i.inst_type.is_none());
510        assert!(i.max_leverage.is_none());
511    }
512}