use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use reqwest::Client;
use serde::Deserialize;
use serde_json::Value;
use tracing::{debug, info};
use crate::error::{ExchangeError, Result};
use crate::kraken::auth::{KrakenCredentials, form_encode, sign_kraken_request};
use crate::kraken::rest::unwrap_kraken_envelope;
const BASE_URL: &str = "https://api.kraken.com";
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 10;
#[derive(Debug, Clone, Deserialize)]
pub struct KrakenOrder {
pub status: String,
#[serde(default)]
pub opentm: Option<f64>,
pub vol: String,
pub vol_exec: String,
pub cost: String,
pub fee: String,
#[serde(default)]
pub descr: Option<KrakenOrderDescr>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KrakenOrderDescr {
pub pair: String,
#[serde(rename = "type")]
pub side: String,
pub ordertype: String,
#[serde(default)]
pub price: String,
#[serde(default)]
pub price2: String,
#[serde(default)]
pub leverage: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KrakenOpenOrders {
#[serde(default)]
pub open: HashMap<String, KrakenOrder>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KrakenClosedOrders {
#[serde(default)]
pub closed: HashMap<String, KrakenOrder>,
#[serde(default)]
pub count: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KrakenAddOrderResponse {
#[serde(default)]
pub descr: Option<KrakenAddOrderDescr>,
#[serde(default)]
pub txid: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KrakenAddOrderDescr {
pub order: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KrakenCancelResponse {
#[serde(default)]
pub count: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KrakenWithdrawResponse {
pub refid: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KrakenTradeHistoryEntry {
pub ordertxid: String,
#[serde(default)]
pub postxid: String,
pub pair: String,
pub time: f64,
#[serde(rename = "type")]
pub side: String,
pub ordertype: String,
pub price: String,
pub cost: String,
pub fee: String,
pub vol: String,
#[serde(default)]
pub margin: String,
#[serde(default)]
pub misc: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KrakenTradesHistory {
#[serde(default)]
pub trades: HashMap<String, KrakenTradeHistoryEntry>,
#[serde(default)]
pub count: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KrakenLedgerEntry {
pub refid: String,
pub time: f64,
#[serde(rename = "type")]
pub entry_type: String,
#[serde(default)]
pub subtype: String,
pub aclass: String,
pub asset: String,
pub amount: String,
pub fee: String,
pub balance: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KrakenLedgers {
#[serde(default)]
pub ledger: HashMap<String, KrakenLedgerEntry>,
#[serde(default)]
pub count: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KrakenWithdrawalRecord {
#[serde(default)]
pub method: String,
#[serde(default)]
pub aclass: String,
pub asset: String,
pub refid: String,
#[serde(default)]
pub txid: Option<String>,
#[serde(default)]
pub info: String,
pub amount: String,
#[serde(default)]
pub fee: String,
pub time: f64,
pub status: String,
}
#[derive(Clone)]
pub struct KrakenPrivateClient {
http: Client,
base_url: String,
credentials: KrakenCredentials,
nonce_state: Arc<AtomicU64>,
}
impl KrakenPrivateClient {
pub fn new(credentials: KrakenCredentials) -> Result<Self> {
Self::with_base_url(credentials, BASE_URL)
}
pub fn with_base_url(
credentials: KrakenCredentials,
base_url: impl Into<String>,
) -> Result<Self> {
let http = Client::builder()
.timeout(Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS))
.build()
.map_err(|e| ExchangeError::Config(format!("failed to build HTTP client: {e}")))?;
Ok(Self {
http,
base_url: base_url.into(),
credentials,
nonce_state: Arc::new(AtomicU64::new(0)),
})
}
fn next_nonce(&self) -> u64 {
let now_ms = chrono::Utc::now().timestamp_millis().max(0) as u64;
self.nonce_state.fetch_max(now_ms, Ordering::SeqCst);
self.nonce_state.fetch_add(1, Ordering::SeqCst)
}
async fn post<T: serde::de::DeserializeOwned>(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<T> {
let nonce = self.next_nonce();
let nonce_str = nonce.to_string();
let mut all_params: Vec<(&str, &str)> = Vec::with_capacity(params.len() + 1);
all_params.push(("nonce", &nonce_str));
all_params.extend_from_slice(params);
let body = form_encode(&all_params);
let sig = sign_kraken_request(path, nonce, &body, &self.credentials.api_secret_b64)?;
debug!(path, "Kraken private POST");
let url = format!("{}{path}", self.base_url);
let resp = self
.http
.post(&url)
.header("API-Key", &self.credentials.api_key)
.header("API-Sign", &sig)
.header("Content-Type", "application/x-www-form-urlencoded")
.body(body)
.send()
.await?;
if !resp.status().is_success() {
let code = resp.status().as_u16().to_string();
let message = resp
.text()
.await
.unwrap_or_else(|_| String::from("no body"));
return Err(ExchangeError::Api { code, message });
}
let raw: Value = resp.json().await?;
unwrap_kraken_envelope(raw)
}
pub async fn get_balance(&self) -> Result<HashMap<String, String>> {
info!("Fetching Kraken balance");
self.post("/0/private/Balance", &[]).await
}
pub async fn get_open_orders(&self) -> Result<KrakenOpenOrders> {
self.post("/0/private/OpenOrders", &[]).await
}
pub async fn get_closed_orders(&self) -> Result<KrakenClosedOrders> {
self.post("/0/private/ClosedOrders", &[]).await
}
#[allow(clippy::too_many_arguments)]
pub async fn place_order(
&self,
pair: &str,
side: &str, order_type: &str, volume: &str,
price: Option<&str>,
) -> Result<KrakenAddOrderResponse> {
info!(pair, side, order_type, volume, ?price, "Kraken place order");
let mut params: Vec<(&str, &str)> = vec![
("pair", pair),
("type", side),
("ordertype", order_type),
("volume", volume),
];
if let Some(p) = price {
params.push(("price", p));
}
self.post("/0/private/AddOrder", ¶ms).await
}
pub async fn cancel_order(&self, txid: &str) -> Result<KrakenCancelResponse> {
info!(txid, "Kraken cancel order");
self.post("/0/private/CancelOrder", &[("txid", txid)]).await
}
pub async fn cancel_all_orders(&self) -> Result<KrakenCancelResponse> {
info!("Kraken cancel ALL open orders");
self.post("/0/private/CancelAll", &[]).await
}
pub async fn get_trades_history(&self) -> Result<KrakenTradesHistory> {
self.post("/0/private/TradesHistory", &[]).await
}
pub async fn get_ledger(&self, asset: &str) -> Result<KrakenLedgers> {
self.post("/0/private/Ledgers", &[("asset", asset)]).await
}
pub async fn withdraw(
&self,
asset: &str,
key: &str,
amount: &str,
) -> Result<KrakenWithdrawResponse> {
info!(asset, key, amount, "Kraken withdraw");
self.post(
"/0/private/Withdraw",
&[("asset", asset), ("key", key), ("amount", amount)],
)
.await
}
pub async fn get_withdrawal_status(&self, asset: &str) -> Result<Vec<KrakenWithdrawalRecord>> {
self.post("/0/private/WithdrawStatus", &[("asset", asset)])
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sim_client(base_url: &str) -> KrakenPrivateClient {
use base64::Engine;
let secret = base64::engine::general_purpose::STANDARD.encode(b"sim-secret");
KrakenPrivateClient::with_base_url(KrakenCredentials::new("sim-key", secret), base_url)
.expect("client build")
}
#[test]
fn nonce_is_strictly_increasing_across_calls() {
let c = sim_client("http://example.invalid");
let mut prev = 0_u64;
for _ in 0..1000 {
let n = c.next_nonce();
assert!(n > prev, "nonce did not increase: {prev} -> {n}");
prev = n;
}
}
#[test]
fn order_deserializes_minimum_fields() {
let raw = r#"{
"status": "open",
"opentm": 1700000000.5,
"vol": "1.00000000",
"vol_exec": "0.50000000",
"cost": "30000.0",
"fee": "5.0",
"descr": {
"pair": "XBTUSD",
"type": "buy",
"ordertype": "limit",
"price": "30000",
"price2": "0",
"leverage": "none"
}
}"#;
let o: KrakenOrder = serde_json::from_str(raw).expect("deserialize");
assert_eq!(o.status, "open");
assert_eq!(o.opentm, Some(1_700_000_000.5));
assert_eq!(o.descr.unwrap().pair, "XBTUSD");
}
#[test]
fn add_order_response_with_txid() {
let raw = r#"{
"descr": {"order": "buy 1.00 XBTUSD @ limit 30000"},
"txid": ["OQCLML-BW3P3-BUCMWZ"]
}"#;
let r: KrakenAddOrderResponse = serde_json::from_str(raw).expect("deserialize");
assert_eq!(r.txid.len(), 1);
assert!(r.descr.unwrap().order.contains("buy 1.00"));
}
#[test]
fn cancel_response_count() {
let raw = r#"{"count": 3}"#;
let r: KrakenCancelResponse = serde_json::from_str(raw).expect("deserialize");
assert_eq!(r.count, 3);
}
#[test]
fn trades_history_deserializes_keyed_map() {
let raw = r#"{
"trades": {
"TZ5X4A-ABCDE-FGHIJK": {
"ordertxid": "OQCLML-BW3P3-BUCMWZ",
"postxid": "",
"pair": "XXBTZUSD",
"time": 1700000000.1234,
"type": "buy",
"ordertype": "limit",
"price": "30000.0",
"cost": "30000.0",
"fee": "48.0",
"vol": "1.0",
"margin": "0.0",
"misc": ""
}
},
"count": 1
}"#;
let h: KrakenTradesHistory = serde_json::from_str(raw).expect("deserialize");
assert_eq!(h.count, 1);
let t = &h.trades["TZ5X4A-ABCDE-FGHIJK"];
assert_eq!(t.pair, "XXBTZUSD");
assert_eq!(t.side, "buy");
assert_eq!(t.ordertxid, "OQCLML-BW3P3-BUCMWZ");
assert!((t.time - 1_700_000_000.123_4).abs() < 1e-6);
}
#[test]
fn ledgers_deserialize_keyed_map() {
let raw = r#"{
"ledger": {
"L4UESK-KG3EQ-UFO4T5": {
"refid": "TY5BYV-WLD5M-ABCDEF",
"time": 1700000000.0,
"type": "trade",
"subtype": "",
"aclass": "currency",
"asset": "ZUSD",
"amount": "-30000.0",
"fee": "48.0",
"balance": "12345.6"
}
},
"count": 1
}"#;
let l: KrakenLedgers = serde_json::from_str(raw).expect("deserialize");
assert_eq!(l.count, 1);
let e = &l.ledger["L4UESK-KG3EQ-UFO4T5"];
assert_eq!(e.entry_type, "trade");
assert_eq!(e.asset, "ZUSD");
assert_eq!(e.amount, "-30000.0");
}
#[test]
fn withdrawal_record_handles_pending_without_txid() {
let raw = r#"{
"method": "Bitcoin",
"aclass": "currency",
"asset": "XXBT",
"refid": "FTQcuak-V6Za8qrPnhsw47JfVff",
"info": "bc1qexample",
"amount": "0.05",
"fee": "0.00015",
"time": 1700000000.0,
"status": "Pending"
}"#;
let w: KrakenWithdrawalRecord = serde_json::from_str(raw).expect("deserialize");
assert_eq!(w.asset, "XXBT");
assert_eq!(w.status, "Pending");
assert!(w.txid.is_none());
assert_eq!(w.amount, "0.05");
}
#[test]
fn withdrawal_record_with_settled_txid() {
let raw = r#"{
"method": "Bitcoin",
"aclass": "currency",
"asset": "XXBT",
"refid": "FTQcuak-V6Za8qrPnhsw47JfVff",
"txid": "deadbeef...",
"info": "bc1qexample",
"amount": "0.05",
"fee": "0.00015",
"time": 1700000000.0,
"status": "Success"
}"#;
let w: KrakenWithdrawalRecord = serde_json::from_str(raw).expect("deserialize");
assert_eq!(w.txid.as_deref(), Some("deadbeef..."));
assert_eq!(w.status, "Success");
}
}