1use 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
33pub 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#[derive(Debug, Clone, Deserialize)]
65struct DataList<T> {
66 data: Vec<T>,
67}
68
69#[derive(Debug, Clone, Deserialize)]
73pub struct CryptocomInstrument {
74 pub symbol: String,
76 #[serde(default)]
78 pub inst_type: Option<String>,
79 #[serde(default)]
81 pub display_name: Option<String>,
82 #[serde(default)]
84 pub base_ccy: Option<String>,
85 #[serde(default)]
87 pub quote_ccy: Option<String>,
88 #[serde(default)]
90 pub quote_decimals: Option<u32>,
91 #[serde(default)]
93 pub quantity_decimals: Option<u32>,
94 #[serde(default)]
96 pub price_tick_size: Option<String>,
97 #[serde(default)]
99 pub qty_tick_size: Option<String>,
100 #[serde(default)]
102 pub max_leverage: Option<String>,
103 #[serde(default)]
105 pub tradable: bool,
106}
107
108#[derive(Debug, Clone, Deserialize)]
113pub struct CryptocomOrderBook {
114 pub instrument_name: String,
116 #[serde(default)]
118 pub depth: Option<u32>,
119 #[serde(default)]
121 pub bids: Vec<[String; 3]>,
122 #[serde(default)]
124 pub asks: Vec<[String; 3]>,
125 #[serde(default)]
127 pub timestamp: i64,
128 #[serde(default)]
130 pub sequence: i64,
131}
132
133impl CryptocomOrderBook {
134 #[must_use]
137 pub fn bids_f64(&self) -> Vec<[f64; 2]> {
138 Self::parse_levels(&self.bids)
139 }
140 #[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#[derive(Debug, Clone, Deserialize)]
158pub struct CryptocomCandle {
159 #[serde(rename = "o")]
161 pub open: String,
162 #[serde(rename = "h")]
164 pub high: String,
165 #[serde(rename = "l")]
167 pub low: String,
168 #[serde(rename = "c")]
170 pub close: String,
171 #[serde(rename = "v")]
173 pub volume: String,
174 #[serde(rename = "t")]
176 pub open_ts: i64,
177}
178
179impl CryptocomCandle {
180 #[must_use]
182 pub fn open_f64(&self) -> f64 {
183 self.open.parse().unwrap_or(0.0)
184 }
185 #[must_use]
187 pub fn close_f64(&self) -> f64 {
188 self.close.parse().unwrap_or(0.0)
189 }
190 #[must_use]
192 pub fn high_f64(&self) -> f64 {
193 self.high.parse().unwrap_or(0.0)
194 }
195 #[must_use]
197 pub fn low_f64(&self) -> f64 {
198 self.low.parse().unwrap_or(0.0)
199 }
200 #[must_use]
202 pub fn volume_f64(&self) -> f64 {
203 self.volume.parse().unwrap_or(0.0)
204 }
205}
206
207#[derive(Debug, Clone, Deserialize)]
213pub struct CryptocomTicker {
214 #[serde(rename = "i")]
216 pub instrument: String,
217 #[serde(rename = "a", default)]
219 pub last_price: Option<String>,
220 #[serde(rename = "h", default)]
222 pub high_24h: Option<String>,
223 #[serde(rename = "l", default)]
225 pub low_24h: Option<String>,
226 #[serde(rename = "v", default)]
228 pub volume_24h: Option<String>,
229 #[serde(rename = "vv", default)]
231 pub value_24h: Option<String>,
232 #[serde(rename = "c", default)]
234 pub change_pct_24h: Option<String>,
235 #[serde(rename = "b", default)]
237 pub best_bid: Option<String>,
238 #[serde(rename = "k", default)]
240 pub best_ask: Option<String>,
241 #[serde(rename = "t", default)]
243 pub timestamp: i64,
244}
245
246#[derive(Debug, Clone, Deserialize)]
248pub struct CryptocomTrade {
249 #[serde(rename = "s")]
251 pub side: String,
252 #[serde(rename = "p")]
254 pub price: String,
255 #[serde(rename = "q")]
257 pub qty: String,
258 #[serde(rename = "t")]
260 pub timestamp: i64,
261 #[serde(rename = "d", default)]
263 pub trade_id: Option<String>,
264 #[serde(rename = "i", default)]
266 pub instrument: Option<String>,
267}
268
269#[derive(Debug, Clone, Deserialize)]
274pub struct CryptocomValuation {
275 #[serde(rename = "v")]
277 pub value: String,
278 #[serde(rename = "t")]
280 pub timestamp: i64,
281}
282
283#[derive(Clone)]
290pub struct CryptocomRestClient {
291 http: PublicRestClient,
292}
293
294impl CryptocomRestClient {
295 pub fn new() -> Result<Self> {
297 Self::with_base_url(BASE_URL)
298 }
299
300 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 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 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 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 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 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", ¶ms).await?;
366 let list: DataList<CryptocomTicker> = unwrap_cryptocom_envelope(raw)?;
367 Ok(list.data)
368 }
369
370 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 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#[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 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}