use serde::Deserialize;
use serde_json::json;
use tracing::info;
use uuid::Uuid;
use crate::client::KuCoinClient;
use crate::error::{ExchangeError, Result};
use crate::types::{OrderType, STP, Side, TimeInForce};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OrderResponse {
pub order_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OrderDetail {
pub id: String,
pub symbol: String,
pub side: String,
#[serde(rename = "type")]
pub order_type: String,
pub status: String,
pub price: Option<f64>,
pub size: u32,
pub filled_size: Option<u32>,
pub remaining_size: Option<u32>,
pub leverage: Option<String>,
pub reduce_only: Option<bool>,
pub time_in_force: Option<String>,
pub created_at: Option<i64>,
pub updated_at: Option<i64>,
}
impl OrderDetail {
pub fn is_active(&self) -> bool {
self.status == "active"
}
pub fn is_filled(&self) -> bool {
self.filled_size.map_or(false, |f| f >= self.size)
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Fill {
pub symbol: String,
pub order_id: String,
pub side: String,
pub price: f64,
pub size: u32,
pub fee: f64,
pub fee_currency: Option<String>,
pub liquidity: Option<String>,
pub trade_id: Option<String>,
pub created_at: Option<i64>,
}
impl KuCoinClient {
pub async fn calc_contracts(
&self,
symbol: &str,
price: f64,
balance: f64,
leverage: u32,
risk_fraction: f64,
max_contracts: u32,
) -> Result<u32> {
if price <= 0.0 {
tracing::warn!(price, "calc_contracts: invalid price — defaulting to 1");
return Ok(1);
}
if leverage == 0 {
tracing::warn!("calc_contracts: leverage is 0 — defaulting to 1");
return Ok(1);
}
let contract = self.get_contract(symbol).await?;
let cv = contract.multiplier.ok_or_else(|| {
ExchangeError::Order(format!(
"calc_contracts: contract {symbol} returned no multiplier — cannot size position"
))
})?;
let notional_per_ct = price * cv;
let margin_per_ct = notional_per_ct / f64::from(leverage);
let raw = (balance * risk_fraction / margin_per_ct) as u32;
Ok(raw.max(1).min(max_contracts))
}
}
impl KuCoinClient {
#[allow(clippy::similar_names, clippy::too_many_arguments)] pub async fn place_order(
&self,
symbol: &str,
side: Side,
size: u32,
leverage: u32,
order_type: OrderType,
price: Option<f64>,
time_in_force: Option<TimeInForce>,
reduce_only: bool,
stp: Option<STP>,
) -> Result<OrderResponse> {
if order_type == OrderType::Limit && price.is_none() {
return Err(ExchangeError::Order(
"place_order: price is required for limit orders".into(),
));
}
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,
"leverage": leverage.to_string(),
"timeInForce": tif,
"reduceOnly": reduce_only,
});
if let Some(p) = price {
body["price"] = json!(p.to_string());
}
if let Some(s) = stp {
body["stp"] = json!(s.as_str());
}
info!(
symbol, side = ?side, size, leverage,
order_type = order_type.as_str(), tif, reduce_only,
price = ?price,
"placing order"
);
self.post("/api/v1/orders", &body).await
}
pub async fn close_position(
&self,
symbol: &str,
qty: i32,
leverage: u32,
) -> Result<OrderResponse> {
if qty == 0 {
return Err(ExchangeError::Order("qty is 0 — nothing to close".into()));
}
let side = if qty > 0 { Side::Sell } else { Side::Buy };
let abs_qty = qty.unsigned_abs();
let body = json!({
"clientOid": Uuid::new_v4().to_string(),
"side": side.as_str(),
"symbol": symbol,
"type": "market",
"size": abs_qty,
"leverage": leverage.to_string(),
"closeOrder": true,
"timeInForce": "GTC",
});
info!(symbol, qty, side = ?side, "closing position");
self.post("/api/v1/orders", &body).await
}
pub async fn cancel_order(&self, order_id: &str) -> Result<serde_json::Value> {
info!(order_id, "cancelling order");
self.delete(&format!("/api/v1/orders/{order_id}")).await
}
pub async fn cancel_all_orders(&self, symbol: &str) -> Result<serde_json::Value> {
info!(symbol, "cancelling all open orders");
self.delete(&format!("/api/v1/orders?symbol={symbol}"))
.await
}
pub async fn get_open_orders(&self, symbol: &str) -> Result<Vec<OrderDetail>> {
#[derive(Deserialize)]
struct Page {
items: Vec<OrderDetail>,
}
let page: Page = self
.get(
"/api/v1/orders",
&[("status", "active"), ("symbol", symbol)],
)
.await?;
Ok(page.items)
}
pub async fn get_order(&self, order_id: &str) -> Result<OrderDetail> {
self.get(&format!("/api/v1/orders/{order_id}"), &[]).await
}
pub async fn get_recent_fills(&self, symbol: &str) -> Result<Vec<Fill>> {
self.get("/api/v1/recentFills", &[("symbol", symbol)]).await
}
#[allow(clippy::similar_names)] pub async fn place_stop_order(
&self,
symbol: &str,
side: Side,
size: u32,
leverage: u32,
stop_price: f64,
stop_type: &str,
price: Option<f64>,
reduce_only: bool,
) -> Result<OrderResponse> {
let order_type = if price.is_some() { "limit" } else { "market" };
let mut body = json!({
"clientOid": Uuid::new_v4().to_string(),
"side": side.as_str(),
"symbol": symbol,
"type": order_type,
"size": size,
"leverage": leverage.to_string(),
"stop": stop_type,
"stopPrice": stop_price.to_string(),
"reduceOnly": reduce_only,
});
if let Some(lp) = price {
body["price"] = json!(lp.to_string());
}
info!(symbol, side = ?side, size, stop_price, stop_type, "placing stop order");
self.post("/api/v1/stopOrders", &body).await
}
pub async fn cancel_stop_order(&self, order_id: &str) -> Result<serde_json::Value> {
info!(order_id, "cancelling stop order");
self.delete(&format!("/api/v1/stopOrders/{order_id}")).await
}
pub async fn cancel_all_stop_orders(&self, symbol: &str) -> Result<serde_json::Value> {
info!(symbol, "cancelling all stop orders");
self.delete(&format!("/api/v1/stopOrders?symbol={symbol}"))
.await
}
pub async fn get_open_stop_orders(&self, symbol: &str) -> Result<Vec<StopOrderDetail>> {
#[derive(Deserialize)]
struct Page {
items: Vec<StopOrderDetail>,
}
let page: Page = self
.get("/api/v1/stopOrders", &[("symbol", symbol)])
.await?;
Ok(page.items)
}
pub async fn get_done_orders(&self, symbol: &str, max_count: u32) -> Result<Vec<OrderDetail>> {
#[derive(Deserialize)]
struct Page {
items: Vec<OrderDetail>,
}
let limit = max_count.min(100).to_string();
let page: Page = self
.get(
"/api/v1/orders",
&[("status", "done"), ("symbol", symbol), ("pageSize", &limit)],
)
.await?;
Ok(page.items)
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StopOrderDetail {
pub id: String,
pub symbol: String,
pub side: String,
#[serde(rename = "type")]
pub order_type: String,
pub stop: Option<String>,
pub stop_price: Option<f64>,
pub price: Option<f64>,
pub size: u32,
pub leverage: Option<String>,
pub reduce_only: Option<bool>,
pub created_at: Option<i64>,
}