use std::collections::HashMap;
use serde::Deserialize;
use serde_json::Value;
use crate::error::{ExchangeError, Result};
use crate::http::PublicRestClient;
const BASE_URL: &str = "https://api.kraken.com";
pub fn unwrap_kraken_envelope<T: serde::de::DeserializeOwned>(raw: Value) -> Result<T> {
let errors: Vec<String> = raw
.get("error")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(Value::as_str)
.map(String::from)
.collect()
})
.unwrap_or_default();
if !errors.is_empty() {
return Err(ExchangeError::Api {
code: "kraken_error".into(),
message: errors.join("; "),
});
}
let result = raw.get("result").cloned().unwrap_or(Value::Null);
serde_json::from_value(result).map_err(ExchangeError::Json)
}
#[derive(Debug, Clone, Deserialize)]
pub struct KrakenSystemStatus {
pub status: String,
pub timestamp: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KrakenAsset {
pub aclass: String,
pub altname: String,
pub decimals: u32,
pub display_decimals: u32,
#[serde(default)]
pub collateral_value: Option<f64>,
#[serde(default)]
pub status: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KrakenAssetPair {
pub altname: String,
#[serde(default)]
pub wsname: Option<String>,
pub base: String,
pub quote: String,
pub pair_decimals: u32,
pub lot_decimals: u32,
pub lot_multiplier: u32,
#[serde(default)]
pub status: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KrakenTicker {
pub a: [String; 3],
pub b: [String; 3],
pub c: [String; 2],
pub v: [String; 2],
pub p: [String; 2],
pub t: [u64; 2],
pub l: [String; 2],
pub h: [String; 2],
pub o: String,
}
impl KrakenTicker {
#[must_use]
pub fn ask_price(&self) -> f64 {
self.a[0].parse().unwrap_or(0.0)
}
#[must_use]
pub fn bid_price(&self) -> f64 {
self.b[0].parse().unwrap_or(0.0)
}
#[must_use]
pub fn last_price(&self) -> f64 {
self.c[0].parse().unwrap_or(0.0)
}
#[must_use]
pub fn volume_24h(&self) -> f64 {
self.v[1].parse().unwrap_or(0.0)
}
#[must_use]
pub fn high_24h(&self) -> f64 {
self.h[1].parse().unwrap_or(0.0)
}
#[must_use]
pub fn low_24h(&self) -> f64 {
self.l[1].parse().unwrap_or(0.0)
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct KrakenOrderBook {
pub asks: Vec<(String, String, f64)>,
pub bids: Vec<(String, String, f64)>,
}
impl KrakenOrderBook {
#[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, String, f64)]) -> Vec<[f64; 2]> {
rows.iter()
.filter_map(|(p, v, _ts)| Some([p.parse().ok()?, v.parse().ok()?]))
.collect()
}
}
#[derive(Clone)]
pub struct KrakenRestClient {
http: PublicRestClient,
}
impl KrakenRestClient {
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_system_status(&self) -> Result<KrakenSystemStatus> {
let raw: Value = self.http.get("/0/public/SystemStatus", &[]).await?;
unwrap_kraken_envelope(raw)
}
pub async fn get_assets(&self) -> Result<HashMap<String, KrakenAsset>> {
let raw: Value = self.http.get("/0/public/Assets", &[]).await?;
unwrap_kraken_envelope(raw)
}
pub async fn get_asset_pairs(
&self,
pair: Option<&str>,
) -> Result<HashMap<String, KrakenAssetPair>> {
let raw: Value = if let Some(p) = pair {
self.http
.get("/0/public/AssetPairs", &[("pair", p)])
.await?
} else {
self.http.get("/0/public/AssetPairs", &[]).await?
};
unwrap_kraken_envelope(raw)
}
pub async fn get_ticker(&self, pair: &str) -> Result<HashMap<String, KrakenTicker>> {
let raw: Value = self.http.get("/0/public/Ticker", &[("pair", pair)]).await?;
unwrap_kraken_envelope(raw)
}
pub async fn get_orderbook(
&self,
pair: &str,
count: u32,
) -> Result<HashMap<String, KrakenOrderBook>> {
let c = count.to_string();
let raw: Value = self
.http
.get("/0/public/Depth", &[("pair", pair), ("count", &c)])
.await?;
unwrap_kraken_envelope(raw)
}
pub async fn get_ohlc(&self, pair: &str, interval_mins: u32) -> Result<Value> {
let i = interval_mins.to_string();
let raw: Value = self
.http
.get("/0/public/OHLC", &[("pair", pair), ("interval", &i)])
.await?;
unwrap_kraken_envelope(raw)
}
pub async fn get_recent_trades(&self, pair: &str) -> Result<Value> {
let raw: Value = self.http.get("/0/public/Trades", &[("pair", pair)]).await?;
unwrap_kraken_envelope(raw)
}
pub async fn get_spread(&self, pair: &str) -> Result<Value> {
let raw: Value = self.http.get("/0/public/Spread", &[("pair", pair)]).await?;
unwrap_kraken_envelope(raw)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn envelope_unwraps_success() {
let raw = serde_json::json!({"result": {"x": 1}, "error": []});
let v: Value = unwrap_kraken_envelope(raw).expect("unwrap");
assert_eq!(v["x"], 1);
}
#[test]
fn envelope_surfaces_error_array_as_api_error() {
let raw = serde_json::json!({
"result": {},
"error": ["EAPI:Invalid key", "EGeneral:Permission denied"]
});
let r: Result<Value> = unwrap_kraken_envelope(raw);
match r {
Err(ExchangeError::Api { code, message }) => {
assert_eq!(code, "kraken_error");
assert!(message.contains("Invalid key"));
assert!(message.contains("Permission denied"));
}
other => panic!("expected Api error, got {other:?}"),
}
}
#[test]
fn ticker_helpers_parse_tuple_fields() {
let raw = r#"{
"a": ["96001.0", "1", "1.000"],
"b": ["95999.0", "1", "1.000"],
"c": ["96000.0", "0.01"],
"v": ["10.5", "100.5"],
"p": ["95950.0", "95800.0"],
"t": [100, 1000],
"l": ["95500.0", "95000.0"],
"h": ["96500.0", "97000.0"],
"o": "95750.0"
}"#;
let t: KrakenTicker = serde_json::from_str(raw).expect("deserialize");
assert!((t.ask_price() - 96_001.0).abs() < 1e-9);
assert!((t.bid_price() - 95_999.0).abs() < 1e-9);
assert!((t.last_price() - 96_000.0).abs() < 1e-9);
assert!((t.volume_24h() - 100.5).abs() < 1e-9);
assert!((t.high_24h() - 97_000.0).abs() < 1e-9);
assert!((t.low_24h() - 95_000.0).abs() < 1e-9);
}
#[test]
fn orderbook_helpers_drop_timestamp() {
let raw = r#"{
"asks": [["96000.0", "1.5", 1700000000]],
"bids": [["95999.0", "2.0", 1700000000]]
}"#;
let book: KrakenOrderBook = serde_json::from_str(raw).expect("deserialize");
assert_eq!(book.asks_f64().len(), 1);
assert!((book.asks_f64()[0][0] - 96_000.0).abs() < 1e-9);
assert!((book.bids_f64()[0][1] - 2.0).abs() < 1e-9);
}
#[test]
fn asset_deserialize_handles_missing_optionals() {
let raw = r#"{
"aclass": "currency",
"altname": "XBT",
"decimals": 10,
"display_decimals": 5
}"#;
let a: KrakenAsset = serde_json::from_str(raw).expect("deserialize");
assert_eq!(a.altname, "XBT");
assert_eq!(a.decimals, 10);
assert!(a.collateral_value.is_none());
assert!(a.status.is_none());
}
#[test]
fn asset_pair_handles_missing_wsname_and_status() {
let raw = r#"{
"altname": "XBTUSD",
"base": "XXBT",
"quote": "ZUSD",
"pair_decimals": 1,
"lot_decimals": 8,
"lot_multiplier": 1
}"#;
let p: KrakenAssetPair = serde_json::from_str(raw).expect("deserialize");
assert_eq!(p.altname, "XBTUSD");
assert!(p.wsname.is_none());
assert!(p.status.is_none());
}
}