use serde::Deserialize;
use serde_json::json;
use tracing::info;
use uuid::Uuid;
use crate::client::KuCoinClient;
use crate::error::Result;
use crate::types::{OrderType, Side, TimeInForce};
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MarginOrderResponse {
pub order_id: String,
#[serde(default)]
pub borrow_size: Option<String>,
#[serde(default)]
pub loan_apply_id: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MarginOrderDetail {
pub id: String,
pub symbol: String,
pub side: String,
#[serde(rename = "type")]
pub order_type: String,
pub size: String,
pub price: String,
#[serde(default)]
pub funds: Option<String>,
#[serde(default)]
pub deal_size: Option<String>,
#[serde(default)]
pub deal_funds: Option<String>,
#[serde(default)]
pub margin_model: Option<String>,
#[serde(default)]
pub time_in_force: Option<String>,
#[serde(default)]
pub is_active: bool,
#[serde(default)]
pub cancel_exist: bool,
#[serde(default)]
pub created_at: Option<i64>,
}
impl MarginOrderDetail {
#[must_use]
pub fn size_f64(&self) -> f64 {
self.size.parse().unwrap_or(0.0)
}
#[must_use]
pub fn price_f64(&self) -> f64 {
self.price.parse().unwrap_or(0.0)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CancelMarginOrderResponse {
#[serde(default)]
pub cancelled_order_ids: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MarginFill {
pub symbol: String,
pub order_id: String,
pub side: String,
pub price: String,
pub size: String,
#[serde(default)]
pub funds: Option<String>,
pub fee: String,
#[serde(default)]
pub fee_currency: Option<String>,
#[serde(default)]
pub liquidity: Option<String>,
#[serde(default)]
pub trade_id: Option<String>,
#[serde(default)]
pub created_at: Option<i64>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MarginAccountV1 {
#[serde(default)]
pub debt_ratio: Option<String>,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub accounts: Vec<MarginAccountAssetV1>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MarginAccountAssetV1 {
pub currency: String,
#[serde(default)]
pub total_balance: Option<String>,
#[serde(default)]
pub available_balance: Option<String>,
#[serde(default)]
pub hold_balance: Option<String>,
#[serde(default)]
pub liability: Option<String>,
#[serde(default)]
pub max_borrow_size: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MarginModel {
Cross,
Isolated,
}
impl MarginModel {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Cross => "cross",
Self::Isolated => "isolated",
}
}
}
impl KuCoinClient {
#[allow(clippy::too_many_arguments, clippy::similar_names)]
pub async fn place_margin_order(
&self,
symbol: &str,
side: Side,
order_type: OrderType,
size: f64,
price: Option<f64>,
margin_model: MarginModel,
auto_borrow: bool,
time_in_force: Option<TimeInForce>,
) -> Result<MarginOrderResponse> {
let tif = time_in_force.unwrap_or_default().as_str();
let mut body = json!({
"clientOid": Uuid::new_v4().to_string(),
"side": side.as_str(),
"symbol": symbol,
"type": order_type.as_str(),
"size": size.to_string(),
"marginModel": margin_model.as_str(),
"autoBorrow": auto_borrow,
"timeInForce": tif,
});
if let Some(p) = price {
body["price"] = json!(p.to_string());
}
info!(
symbol, side = ?side, size, auto_borrow,
margin_model = margin_model.as_str(),
order_type = order_type.as_str(),
price = ?price,
"placing margin order"
);
self.post("/api/v1/margin/order", &body).await
}
pub async fn get_margin_order(&self, order_id: &str) -> Result<MarginOrderDetail> {
self.get(&format!("/api/v1/margin/orders/{order_id}"), &[])
.await
}
pub async fn cancel_margin_order(&self, order_id: &str) -> Result<CancelMarginOrderResponse> {
info!(order_id, "cancelling margin order");
self.delete(&format!("/api/v1/margin/orders/{order_id}"))
.await
}
pub async fn get_margin_fills(&self, symbol: &str) -> Result<Vec<MarginFill>> {
#[derive(Deserialize)]
struct Page {
items: Vec<MarginFill>,
}
let page: Page = self
.get("/api/v1/margin/fills", &[("symbol", symbol)])
.await?;
Ok(page.items)
}
pub async fn get_margin_balance(&self) -> Result<MarginAccountV1> {
self.get("/api/v1/margin/account", &[]).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn margin_order_response_with_borrow() {
let raw = r#"{
"orderId": "abc123",
"borrowSize": "0.05",
"loanApplyId": "loan-1"
}"#;
let r: MarginOrderResponse = serde_json::from_str(raw).expect("deserialize");
assert_eq!(r.order_id, "abc123");
assert_eq!(r.borrow_size.as_deref(), Some("0.05"));
assert_eq!(r.loan_apply_id.as_deref(), Some("loan-1"));
}
#[test]
fn margin_order_response_without_borrow() {
let raw = r#"{"orderId": "abc", "borrowSize": "0", "loanApplyId": null}"#;
let r: MarginOrderResponse = serde_json::from_str(raw).expect("deserialize");
assert_eq!(r.borrow_size.as_deref(), Some("0"));
assert!(r.loan_apply_id.is_none());
}
#[test]
fn margin_order_detail_parses_strings_and_helpers() {
let raw = r#"{
"id":"o-1","symbol":"BTC-USDT","side":"buy","type":"limit",
"size":"0.01","price":"30000","dealSize":"0.005","dealFunds":"150",
"marginModel":"cross","timeInForce":"GTC","isActive":true,
"cancelExist":false,"createdAt":1700000000000
}"#;
let d: MarginOrderDetail = serde_json::from_str(raw).expect("deserialize");
assert_eq!(d.symbol, "BTC-USDT");
assert!((d.size_f64() - 0.01).abs() < 1e-9);
assert!((d.price_f64() - 30_000.0).abs() < 1e-9);
assert!(d.is_active);
assert!(!d.cancel_exist);
assert_eq!(d.margin_model.as_deref(), Some("cross"));
}
#[test]
fn cancel_response_extracts_ids() {
let raw = r#"{"cancelledOrderIds":["o-1","o-2"]}"#;
let r: CancelMarginOrderResponse = serde_json::from_str(raw).expect("deserialize");
assert_eq!(r.cancelled_order_ids, vec!["o-1", "o-2"]);
}
#[test]
fn cancel_response_empty_when_already_done() {
let raw = r#"{"cancelledOrderIds":[]}"#;
let r: CancelMarginOrderResponse = serde_json::from_str(raw).expect("deserialize");
assert!(r.cancelled_order_ids.is_empty());
}
#[test]
fn margin_account_v1_with_assets() {
let raw = r#"{
"debtRatio":"0",
"status":"EFFECTIVE",
"accounts":[{
"currency":"USDT",
"totalBalance":"100",
"availableBalance":"100",
"holdBalance":"0",
"liability":"0",
"maxBorrowSize":"50"
}]
}"#;
let a: MarginAccountV1 = serde_json::from_str(raw).expect("deserialize");
assert_eq!(a.status.as_deref(), Some("EFFECTIVE"));
assert_eq!(a.accounts.len(), 1);
assert_eq!(a.accounts[0].currency, "USDT");
assert_eq!(a.accounts[0].available_balance.as_deref(), Some("100"));
}
#[test]
fn margin_model_wire_format() {
assert_eq!(MarginModel::Cross.as_str(), "cross");
assert_eq!(MarginModel::Isolated.as_str(), "isolated");
}
}