use serde::Deserialize;
use tracing::info;
use crate::client::KuCoinClient;
use crate::error::Result;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UtaAccountSummary {
#[serde(with = "str_f64")]
pub account_equity_total: f64,
#[serde(rename = "unrealisedPNLTotal", with = "str_f64")]
pub unrealised_pnl_total: f64,
#[serde(with = "str_f64")]
pub margin_balance_total: f64,
#[serde(with = "str_f64")]
pub position_margin_total: f64,
#[serde(with = "str_f64")]
pub order_margin_total: f64,
#[serde(with = "str_f64")]
pub frozen_funds_total: f64,
#[serde(with = "str_f64")]
pub available_balance_total: f64,
pub total_currency: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CrossMarginAccount {
#[serde(with = "str_f64")]
pub total_asset_of_quote_currency: f64,
#[serde(with = "str_f64")]
pub total_liability_of_quote_currency: f64,
#[serde(with = "str_f64")]
pub debt_ratio: f64,
pub status: String,
pub assets: Vec<CrossMarginAsset>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CrossMarginAsset {
pub currency: String,
#[serde(default)]
pub borrow_enabled: bool,
#[serde(default)]
pub repay_enabled: bool,
#[serde(default)]
pub transfer_enabled: bool,
#[serde(with = "str_f64")]
pub borrowed: f64,
#[serde(with = "str_f64")]
pub total_asset: f64,
#[serde(with = "str_f64")]
pub available: f64,
#[serde(with = "str_f64")]
pub hold: f64,
#[serde(default, with = "opt_str_f64")]
pub max_borrow_size: Option<f64>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IsolatedMarginAccount {
#[serde(default, with = "opt_str_f64")]
pub total_conversion_balance: Option<f64>,
#[serde(default, with = "opt_str_f64")]
pub liability_conversion_balance: Option<f64>,
pub assets: Vec<IsolatedMarginPair>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IsolatedMarginPair {
pub symbol: String,
#[serde(default)]
pub status: String,
#[serde(default, with = "opt_str_f64")]
pub debt_ratio: Option<f64>,
pub base_asset: IsolatedMarginAsset,
pub quote_asset: IsolatedMarginAsset,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IsolatedMarginAsset {
pub currency: String,
#[serde(default, with = "opt_str_f64")]
pub total_balance: Option<f64>,
#[serde(default, with = "opt_str_f64")]
pub hold_balance: Option<f64>,
#[serde(default, with = "opt_str_f64")]
pub available_balance: Option<f64>,
#[serde(default, with = "opt_str_f64")]
pub liability: Option<f64>,
#[serde(default, with = "opt_str_f64")]
pub interest: Option<f64>,
#[serde(default, with = "opt_str_f64")]
pub borrowable_amount: Option<f64>,
#[serde(default)]
pub borrow_enabled: bool,
#[serde(default)]
pub transfer_in_enabled: bool,
#[serde(default)]
pub repay_enabled: bool,
}
mod str_f64 {
use serde::{Deserialize, Deserializer};
pub(super) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum SF {
S(String),
F(f64),
}
match SF::deserialize(d)? {
SF::S(s) if s.is_empty() => Ok(0.0),
SF::S(s) => s.parse().map_err(serde::de::Error::custom),
SF::F(f) => Ok(f),
}
}
}
mod opt_str_f64 {
use serde::{Deserialize, Deserializer};
pub(super) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum W {
None,
S(String),
F(f64),
}
match Option::<W>::deserialize(d)? {
None | Some(W::None) => Ok(None),
Some(W::S(s)) if s.is_empty() => Ok(None),
Some(W::S(s)) => s.parse().map(Some).map_err(serde::de::Error::custom),
Some(W::F(f)) => Ok(Some(f)),
}
}
}
impl KuCoinClient {
pub async fn get_uta_account_summary(&self) -> Result<UtaAccountSummary> {
info!("Fetching UTA account summary");
self.get("/api/v3/account/summary", &[]).await
}
pub async fn get_cross_margin_accounts(&self) -> Result<CrossMarginAccount> {
info!("Fetching cross-margin accounts");
self.get("/api/v3/margin/accounts", &[]).await
}
pub async fn get_isolated_margin_accounts(&self) -> Result<IsolatedMarginAccount> {
info!("Fetching isolated-margin accounts");
self.get("/api/v1/isolated/accounts", &[]).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn account_summary_deserializes_string_fields() {
let raw = r#"{
"accountEquityTotal": "10000.00",
"unrealisedPNLTotal": "12.50",
"marginBalanceTotal": "9987.50",
"positionMarginTotal": "100.00",
"orderMarginTotal": "50.00",
"frozenFundsTotal": "0.00",
"availableBalanceTotal": "9837.50",
"totalCurrency": "USDT"
}"#;
let s: UtaAccountSummary = serde_json::from_str(raw).expect("deserialize");
assert!((s.account_equity_total - 10_000.0).abs() < 1e-6);
assert!((s.unrealised_pnl_total - 12.5).abs() < 1e-9);
assert_eq!(s.total_currency, "USDT");
}
#[test]
fn cross_margin_with_assets() {
let raw = r#"{
"totalAssetOfQuoteCurrency": "10000.0",
"totalLiabilityOfQuoteCurrency": "0.0",
"debtRatio": "0.0",
"status": "EFFECTIVE",
"assets": [
{
"currency": "USDT",
"borrowEnabled": true,
"repayEnabled": true,
"transferEnabled": true,
"borrowed": "0.0",
"totalAsset": "10000.0",
"available": "10000.0",
"hold": "0.0",
"maxBorrowSize": "5000.0"
}
]
}"#;
let m: CrossMarginAccount = serde_json::from_str(raw).expect("deserialize");
assert_eq!(m.status, "EFFECTIVE");
assert!((m.debt_ratio - 0.0).abs() < 1e-12);
assert_eq!(m.assets.len(), 1);
let a = &m.assets[0];
assert_eq!(a.currency, "USDT");
assert!(a.borrow_enabled);
assert_eq!(a.max_borrow_size, Some(5000.0));
}
#[test]
fn cross_margin_handles_missing_max_borrow() {
let raw = r#"{
"totalAssetOfQuoteCurrency": "0.0",
"totalLiabilityOfQuoteCurrency": "0.0",
"debtRatio": "0.0",
"status": "EFFECTIVE",
"assets": [{
"currency": "USDT",
"borrowed": "0.0",
"totalAsset": "0.0",
"available": "0.0",
"hold": "0.0"
}]
}"#;
let m: CrossMarginAccount = serde_json::from_str(raw).expect("deserialize");
assert_eq!(m.assets[0].max_borrow_size, None);
assert!(!m.assets[0].borrow_enabled);
}
#[test]
fn isolated_margin_with_pair() {
let raw = r#"{
"totalConversionBalance": "1000.0",
"liabilityConversionBalance": "0.0",
"assets": [{
"symbol": "BTC-USDT",
"status": "EFFECTIVE",
"debtRatio": "0.0",
"baseAsset": {
"currency": "BTC",
"totalBalance": "0.01",
"holdBalance": "0.0",
"availableBalance": "0.01",
"liability": "0.0",
"interest": "0.0",
"borrowableAmount": "0.005",
"borrowEnabled": true,
"transferInEnabled": true,
"repayEnabled": true
},
"quoteAsset": {
"currency": "USDT",
"totalBalance": "500.0",
"holdBalance": "0.0",
"availableBalance": "500.0",
"liability": "0.0",
"interest": "0.0",
"borrowableAmount": "250.0",
"borrowEnabled": true,
"transferInEnabled": true,
"repayEnabled": true
}
}]
}"#;
let m: IsolatedMarginAccount = serde_json::from_str(raw).expect("deserialize");
assert_eq!(m.total_conversion_balance, Some(1000.0));
assert_eq!(m.assets.len(), 1);
let p = &m.assets[0];
assert_eq!(p.symbol, "BTC-USDT");
assert_eq!(p.base_asset.currency, "BTC");
assert_eq!(p.base_asset.available_balance, Some(0.01));
assert_eq!(p.quote_asset.borrowable_amount, Some(250.0));
}
}