use serde::Deserialize;
use serde_json::Value;
use crate::error::{ExchangeError, Result};
use crate::http::PublicRestClient;
const BASE_URL: &str = "https://api.crypto.com/exchange/v1";
pub fn unwrap_cryptocom_envelope<T: serde::de::DeserializeOwned>(raw: Value) -> Result<T> {
let code = raw.get("code").and_then(Value::as_i64).unwrap_or(-1);
if code != 0 {
let message = raw
.get("message")
.and_then(Value::as_str)
.unwrap_or("no message")
.to_string();
return Err(ExchangeError::Api {
code: code.to_string(),
message,
});
}
let result = raw.get("result").cloned().unwrap_or(Value::Null);
serde_json::from_value(result).map_err(ExchangeError::Json)
}
#[derive(Debug, Clone, Deserialize)]
struct DataList<T> {
data: Vec<T>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CryptocomInstrument {
pub symbol: String,
#[serde(default)]
pub inst_type: Option<String>,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub base_ccy: Option<String>,
#[serde(default)]
pub quote_ccy: Option<String>,
#[serde(default)]
pub quote_decimals: Option<u32>,
#[serde(default)]
pub quantity_decimals: Option<u32>,
#[serde(default)]
pub price_tick_size: Option<String>,
#[serde(default)]
pub qty_tick_size: Option<String>,
#[serde(default)]
pub max_leverage: Option<String>,
#[serde(default)]
pub tradable: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CryptocomOrderBook {
pub instrument_name: String,
#[serde(default)]
pub depth: Option<u32>,
#[serde(default)]
pub bids: Vec<[String; 3]>,
#[serde(default)]
pub asks: Vec<[String; 3]>,
#[serde(default)]
pub timestamp: i64,
#[serde(default)]
pub sequence: i64,
}
impl CryptocomOrderBook {
#[must_use]
pub fn bids_f64(&self) -> Vec<[f64; 2]> {
Self::parse_levels(&self.bids)
}
#[must_use]
pub fn asks_f64(&self) -> Vec<[f64; 2]> {
Self::parse_levels(&self.asks)
}
fn parse_levels(rows: &[[String; 3]]) -> Vec<[f64; 2]> {
rows.iter()
.filter_map(|[p, q, _n]| Some([p.parse().ok()?, q.parse().ok()?]))
.collect()
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct CryptocomCandle {
#[serde(rename = "o")]
pub open: String,
#[serde(rename = "h")]
pub high: String,
#[serde(rename = "l")]
pub low: String,
#[serde(rename = "c")]
pub close: String,
#[serde(rename = "v")]
pub volume: String,
#[serde(rename = "t")]
pub open_ts: i64,
}
impl CryptocomCandle {
#[must_use]
pub fn open_f64(&self) -> f64 {
self.open.parse().unwrap_or(0.0)
}
#[must_use]
pub fn close_f64(&self) -> f64 {
self.close.parse().unwrap_or(0.0)
}
#[must_use]
pub fn high_f64(&self) -> f64 {
self.high.parse().unwrap_or(0.0)
}
#[must_use]
pub fn low_f64(&self) -> f64 {
self.low.parse().unwrap_or(0.0)
}
#[must_use]
pub fn volume_f64(&self) -> f64 {
self.volume.parse().unwrap_or(0.0)
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct CryptocomTicker {
#[serde(rename = "i")]
pub instrument: String,
#[serde(rename = "a", default)]
pub last_price: Option<String>,
#[serde(rename = "h", default)]
pub high_24h: Option<String>,
#[serde(rename = "l", default)]
pub low_24h: Option<String>,
#[serde(rename = "v", default)]
pub volume_24h: Option<String>,
#[serde(rename = "vv", default)]
pub value_24h: Option<String>,
#[serde(rename = "c", default)]
pub change_pct_24h: Option<String>,
#[serde(rename = "b", default)]
pub best_bid: Option<String>,
#[serde(rename = "k", default)]
pub best_ask: Option<String>,
#[serde(rename = "t", default)]
pub timestamp: i64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CryptocomTrade {
#[serde(rename = "s")]
pub side: String,
#[serde(rename = "p")]
pub price: String,
#[serde(rename = "q")]
pub qty: String,
#[serde(rename = "t")]
pub timestamp: i64,
#[serde(rename = "d", default)]
pub trade_id: Option<String>,
#[serde(rename = "i", default)]
pub instrument: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CryptocomValuation {
#[serde(rename = "v")]
pub value: String,
#[serde(rename = "t")]
pub timestamp: i64,
}
#[derive(Clone)]
pub struct CryptocomRestClient {
http: PublicRestClient,
}
impl CryptocomRestClient {
pub fn new() -> Result<Self> {
Self::with_base_url(BASE_URL)
}
pub fn with_base_url(base_url: impl Into<String>) -> Result<Self> {
Ok(Self {
http: PublicRestClient::new(base_url)?,
})
}
pub async fn get_instruments(&self) -> Result<Vec<CryptocomInstrument>> {
let raw: Value = self.http.get("/public/get-instruments", &[]).await?;
let list: DataList<CryptocomInstrument> = unwrap_cryptocom_envelope(raw)?;
Ok(list.data)
}
pub async fn get_orderbook(&self, instrument: &str, depth: u32) -> Result<CryptocomOrderBook> {
let d = depth.to_string();
let raw: Value = self
.http
.get(
"/public/get-book",
&[("instrument_name", instrument), ("depth", &d)],
)
.await?;
let list: DataList<CryptocomOrderBook> = unwrap_cryptocom_envelope(raw)?;
list.data
.into_iter()
.next()
.ok_or_else(|| ExchangeError::Api {
code: "empty_data".into(),
message: "Crypto.com returned an empty book array".into(),
})
}
pub async fn get_candlestick(
&self,
instrument: &str,
timeframe: &str,
) -> Result<Vec<CryptocomCandle>> {
let raw: Value = self
.http
.get(
"/public/get-candlestick",
&[("instrument_name", instrument), ("timeframe", timeframe)],
)
.await?;
let list: DataList<CryptocomCandle> = unwrap_cryptocom_envelope(raw)?;
Ok(list.data)
}
pub async fn get_ticker(&self, instrument: Option<&str>) -> Result<Vec<CryptocomTicker>> {
let mut params: Vec<(&str, &str)> = Vec::new();
if let Some(i) = instrument {
params.push(("instrument_name", i));
}
let raw: Value = self.http.get("/public/get-ticker", ¶ms).await?;
let list: DataList<CryptocomTicker> = unwrap_cryptocom_envelope(raw)?;
Ok(list.data)
}
pub async fn get_recent_trades(&self, instrument: &str) -> Result<Vec<CryptocomTrade>> {
let raw: Value = self
.http
.get("/public/get-trades", &[("instrument_name", instrument)])
.await?;
let list: DataList<CryptocomTrade> = unwrap_cryptocom_envelope(raw)?;
Ok(list.data)
}
pub async fn get_valuations(
&self,
instrument: &str,
valuation_type: &str,
) -> Result<Vec<CryptocomValuation>> {
let raw: Value = self
.http
.get(
"/public/get-valuations",
&[
("instrument_name", instrument),
("valuation_type", valuation_type),
],
)
.await?;
let list: DataList<CryptocomValuation> = unwrap_cryptocom_envelope(raw)?;
Ok(list.data)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn envelope_unwraps_success() {
let raw = serde_json::json!({"code": 0, "result": {"x": 1}});
let v: Value = unwrap_cryptocom_envelope(raw).expect("unwrap");
assert_eq!(v["x"], 1);
}
#[test]
fn envelope_surfaces_nonzero_code_as_api_error() {
let raw = serde_json::json!({
"code": 30009,
"message": "Invalid instrument_name",
"result": {}
});
let r: Result<Value> = unwrap_cryptocom_envelope(raw);
match r {
Err(ExchangeError::Api { code, message }) => {
assert_eq!(code, "30009");
assert!(message.contains("Invalid instrument_name"));
}
other => panic!("expected Api error, got {other:?}"),
}
}
#[test]
fn orderbook_helpers_drop_num_orders_column() {
let raw = r#"{
"instrument_name": "BTC_USDT",
"depth": 5,
"bids": [["96000.0", "1.5", "2"], ["95999.0", "2.0", "3"]],
"asks": [["96001.0", "0.5", "1"]],
"timestamp": 1700000000000,
"sequence": 42
}"#;
let book: CryptocomOrderBook = serde_json::from_str(raw).expect("deserialize");
assert_eq!(book.instrument_name, "BTC_USDT");
assert_eq!(book.sequence, 42);
let bids = book.bids_f64();
let asks = book.asks_f64();
assert!((bids[0][0] - 96_000.0).abs() < 1e-9);
assert!((bids[1][1] - 2.0).abs() < 1e-9);
assert!((asks[0][0] - 96_001.0).abs() < 1e-9);
}
#[test]
fn candle_renames_letters_and_helpers_parse() {
let raw = r#"{"o":"96000.0","h":"96100.0","l":"95900.0","c":"96050.0","v":"10.5","t":1700000000000}"#;
let c: CryptocomCandle = serde_json::from_str(raw).expect("deserialize");
assert!((c.open_f64() - 96_000.0).abs() < 1e-9);
assert!((c.close_f64() - 96_050.0).abs() < 1e-9);
assert!((c.high_f64() - 96_100.0).abs() < 1e-9);
assert!((c.low_f64() - 95_900.0).abs() < 1e-9);
assert!((c.volume_f64() - 10.5).abs() < 1e-9);
assert_eq!(c.open_ts, 1_700_000_000_000);
}
#[test]
fn ticker_renames_cryptic_letters() {
let raw = r#"{
"i":"BTC_USDT","a":"96000.0","h":"96500.0","l":"95500.0",
"v":"100.5","vv":"9650000","c":"0.005",
"b":"95999.0","k":"96001.0","t":1700000000000
}"#;
let t: CryptocomTicker = serde_json::from_str(raw).expect("deserialize");
assert_eq!(t.instrument, "BTC_USDT");
assert_eq!(t.last_price.as_deref(), Some("96000.0"));
assert_eq!(t.best_bid.as_deref(), Some("95999.0"));
assert_eq!(t.best_ask.as_deref(), Some("96001.0"));
assert_eq!(t.value_24h.as_deref(), Some("9650000"));
assert_eq!(t.timestamp, 1_700_000_000_000);
}
#[test]
fn trade_renames_letters() {
let raw =
r#"{"s":"buy","p":"96000.0","q":"0.05","t":1700000000000,"d":"abc","i":"BTC_USDT"}"#;
let t: CryptocomTrade = serde_json::from_str(raw).expect("deserialize");
assert_eq!(t.side, "buy");
assert_eq!(t.price, "96000.0");
assert_eq!(t.trade_id.as_deref(), Some("abc"));
}
#[test]
fn valuation_renames_letters() {
let raw = r#"{"v":"96010.5","t":1700000000000}"#;
let val: CryptocomValuation = serde_json::from_str(raw).expect("deserialize");
assert_eq!(val.value, "96010.5");
assert_eq!(val.timestamp, 1_700_000_000_000);
}
#[test]
fn instrument_handles_missing_optional_fields() {
let raw = r#"{"symbol":"BTC_USDT","tradable":true}"#;
let i: CryptocomInstrument = serde_json::from_str(raw).expect("deserialize");
assert_eq!(i.symbol, "BTC_USDT");
assert!(i.tradable);
assert!(i.inst_type.is_none());
assert!(i.max_leverage.is_none());
}
}