1use crate::clock::ServerClock;
17use crate::credentials::Credentials;
18use crate::error::{Error, Result};
19use crate::events::{BookDelta, BookLevel, Event, OrderBookSnapshot, TradePrint};
20use crate::feeds::{
21 DerivativesChannel, DerivativesFeed, FundingRate, LongShortRatio, MarkIndex, OpenInterest,
22};
23use crate::idempotency::ClientIdGenerator;
24use crate::normalize::{format_decimal, parse_decimal};
25use crate::options::{ExchangeOptions, MarginMode, MarketType, PositionMode, SelfTradePrevention};
26use crate::positions::{Position, PositionSide};
27use crate::signing::hmac_sha256_base64;
28use crate::symbol::Symbol;
29use crate::traits::{
30 AdvancedOrders, Derivatives, DerivativesStream, Exchange, Execution, MarketData, WsExecution,
31 WsUserData,
32};
33use crate::transport::{HttpMethod, HttpRequest, HttpTransport, WsConnection, WsTransport};
34use crate::types::{
35 Balance, OcoRequest, Order, OrderRequest, OrderSide, OrderStatus, OrderType, Ticker,
36 TimeInForce,
37};
38use rust_decimal::prelude::FromPrimitive;
39use rust_decimal::Decimal;
40use serde::Deserialize;
41use std::collections::HashMap;
42use std::fmt;
43use wickra_core::Candle;
44
45fn system_now_ms() -> i64 {
46 use std::time::{SystemTime, UNIX_EPOCH};
47 SystemTime::now()
48 .duration_since(UNIX_EPOCH)
49 .expect("system clock is before the Unix epoch")
50 .as_millis() as i64
51}
52
53pub struct KuCoin {
55 http: Box<dyn HttpTransport>,
56 ws: Option<Box<dyn WsTransport>>,
57 rest_base: String,
58 market_type: MarketType,
59 position_mode: PositionMode,
62 credentials: Option<Credentials>,
63 now_ms: Box<dyn Fn() -> i64 + Send + Sync>,
64 client_ids: ClientIdGenerator,
73 clock: ServerClock,
76 connection: Option<Box<dyn WsConnection>>,
77 sub_messages: Vec<String>,
78 subscriptions: Vec<(String, Symbol)>,
79 derivatives_channels: Vec<(String, DerivativesChannel)>,
84 leverage: u32,
87 private_connection: Option<Box<dyn WsConnection>>,
91 user_data_active: bool,
94}
95
96impl fmt::Debug for KuCoin {
102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 f.debug_struct("KuCoin")
104 .field("ws", &self.ws.is_some())
105 .field("rest_base", &self.rest_base)
106 .field("market_type", &self.market_type)
107 .field("authenticated", &self.credentials.is_some())
108 .field("clock_offset_ms", &self.clock.offset_ms())
109 .field("connection", &self.connection.is_some())
110 .field("sub_messages", &self.sub_messages.len())
111 .field("subscriptions", &self.subscriptions.len())
112 .field("leverage", &self.leverage)
113 .field("private_connection", &self.private_connection.is_some())
114 .field("user_data_active", &self.user_data_active)
115 .finish_non_exhaustive()
116 }
117}
118
119impl KuCoin {
120 fn ensure_market_is_routed(&self) -> Result<()> {
128 super::ensure_market_is_routed("KuCoin", self.market_type, super::SPOT_AND_LINEAR)
129 }
130
131 fn build(
132 http: Box<dyn HttpTransport>,
133 options: &ExchangeOptions,
134 credentials: Option<Credentials>,
135 ) -> Self {
136 let futures = options.market_type.is_derivatives();
137 Self {
138 http,
139 ws: None,
140 rest_base: if futures {
141 "https://api-futures.kucoin.com".to_string()
142 } else {
143 "https://api.kucoin.com".to_string()
144 },
145 market_type: options.market_type,
146 position_mode: options.position_mode,
147 credentials,
148 now_ms: Box::new(system_now_ms),
149 client_ids: ClientIdGenerator::with_seed(
150 "wkex",
151 u64::try_from(system_now_ms()).unwrap_or(0),
152 ),
153 clock: ServerClock::new(),
154 connection: None,
155 sub_messages: Vec::new(),
156 subscriptions: Vec::new(),
157 derivatives_channels: Vec::new(),
158 leverage: 1,
159 private_connection: None,
160 user_data_active: false,
161 }
162 }
163
164 fn signed_now_ms(&self) -> i64 {
173 self.clock.server_time_ms((self.now_ms)())
174 }
175
176 pub fn sync_time(&mut self) -> Result<i64> {
188 let local_before = (self.now_ms)();
191 let value = self.get("/api/v1/timestamp", "")?;
192 let server_ms = value
193 .as_i64()
194 .ok_or_else(|| Error::Deserialization("timestamp: data not an integer".into()))?;
195 self.clock.sync(local_before, server_ms);
196 Ok(self.clock.offset_ms())
197 }
198
199 fn is_futures(&self) -> bool {
202 self.market_type.is_derivatives()
203 }
204
205 fn ensure_one_way(&self) -> Result<()> {
218 if self.is_futures() && self.position_mode == PositionMode::Hedge {
219 return Err(Error::Exchange {
220 code: "unsupported".to_string(),
221 message: "KuCoin Futures is one-way only; PositionMode::Hedge cannot be expressed on its orders"
222 .to_string(),
223 });
224 }
225 Ok(())
226 }
227
228 fn futures_symbol(symbol: &Symbol) -> String {
232 let base = if symbol.base() == "BTC" {
233 "XBT"
234 } else {
235 symbol.base()
236 };
237 format!("{base}{}M", symbol.quote())
238 }
239
240 #[must_use]
242 pub fn with_http(http: Box<dyn HttpTransport>, options: &ExchangeOptions) -> Self {
243 Self::build(http, options, None)
244 }
245
246 #[must_use]
248 pub fn with_credentials(
249 http: Box<dyn HttpTransport>,
250 options: &ExchangeOptions,
251 credentials: Credentials,
252 ) -> Self {
253 Self::build(http, options, Some(credentials))
254 }
255
256 #[must_use]
258 pub fn with_clock(mut self, now_ms: Box<dyn Fn() -> i64 + Send + Sync>) -> Self {
259 self.now_ms = now_ms;
260 self
261 }
262
263 #[must_use]
265 pub fn with_ws(mut self, ws: Box<dyn WsTransport>) -> Self {
266 self.ws = Some(ws);
267 self
268 }
269
270 #[must_use]
272 pub fn wire_symbol(symbol: &Symbol) -> String {
273 format!("{}-{}", symbol.base(), symbol.quote())
274 }
275
276 pub fn ticker(&self, symbol: &Symbol) -> Result<Ticker> {
281 let query = format!("symbol={}", Self::wire_symbol(symbol));
282 let data = self.get("/api/v1/market/stats", &query)?;
283 let raw: RawStats = parse_json(data)?;
284 Ok(Ticker {
285 symbol: symbol.clone(),
286 last: parse_decimal(&raw.last)?,
287 bid: parse_decimal(&raw.buy)?,
288 ask: parse_decimal(&raw.sell)?,
289 volume: parse_decimal(&raw.vol)?,
290 timestamp: raw.time,
291 })
292 }
293
294 pub fn klines(&self, symbol: &Symbol, interval: &str, _limit: u32) -> Result<Vec<Candle>> {
300 let query = format!(
301 "symbol={}&type={}",
302 Self::wire_symbol(symbol),
303 map_type(interval),
304 );
305 let data = self.get("/api/v1/market/candles", &query)?;
306 let rows: Vec<Vec<String>> = parse_json(data)?;
307 let mut candles = rows
308 .iter()
309 .map(|row| parse_kline_row(row))
310 .collect::<Result<Vec<_>>>()?;
311 candles.reverse();
312 Ok(candles)
313 }
314
315 pub fn order_book(&self, symbol: &Symbol, _depth: u32) -> Result<OrderBookSnapshot> {
320 let query = format!("symbol={}", Self::wire_symbol(symbol));
321 let data = self.get("/api/v1/market/orderbook/level2_20", &query)?;
322 let raw: RawDepth = parse_json(data)?;
323 Ok(OrderBookSnapshot {
324 symbol: symbol.clone(),
325 last_update_id: raw.sequence.parse().unwrap_or(0),
326 bids: parse_levels(&raw.bids)?,
327 asks: parse_levels(&raw.asks)?,
328 timestamp: raw.time,
329 })
330 }
331
332 pub fn subscribe_derivatives(
346 &mut self,
347 symbol: &Symbol,
348 channel: DerivativesChannel,
349 ) -> Result<()> {
350 if !self.is_futures() {
351 return Err(Error::unsupported_field(
352 "KuCoin",
353 "a derivatives channel on a spot client",
354 "funding and mark/index exist only on KuCoin Futures",
355 ));
356 }
357 if channel == DerivativesChannel::Liquidations {
358 return Err(Error::unsupported_field(
359 "KuCoin",
360 "a liquidations channel",
361 "KuCoin publishes no public stream of forced orders",
362 ));
363 }
364 let contract = Self::futures_symbol(symbol);
365 if !self
366 .derivatives_channels
367 .iter()
368 .any(|(c, k)| c == &contract && *k == channel)
369 {
370 self.derivatives_channels.push((contract, channel));
371 }
372 self.subscribe_instrument(symbol)
373 }
374
375 fn subscribe_instrument(&mut self, symbol: &Symbol) -> Result<()> {
378 self.ensure_market_is_routed()?;
379 let wire = Self::futures_symbol(symbol);
380 if self.connection.is_none() {
381 let ws = self.ws.as_ref().ok_or(Error::NotConnected)?;
382 let connection = ws.connect(FUTURES_WS_URL)?;
383 self.connection = Some(connection);
384 }
385 let id = (self.now_ms)();
386 let message = format!(
387 r#"{{"id":"{id}","type":"subscribe","topic":"/contract/instrument:{wire}","response":true}}"#
388 );
389 self.connection
390 .as_mut()
391 .expect("connection just ensured")
392 .send(&message)?;
393 if !self.sub_messages.contains(&message) {
394 self.sub_messages.push(message);
395 }
396 if !self.subscriptions.iter().any(|(w, _)| w == &wire) {
397 self.subscriptions.push((wire, symbol.clone()));
398 }
399 Ok(())
400 }
401
402 pub fn open_interest(&self, symbol: &Symbol) -> Result<OpenInterest> {
412 if !self.is_futures() {
413 return Err(Error::unsupported_field(
414 "KuCoin",
415 "open interest on a spot client",
416 "open interest is a futures figure",
417 ));
418 }
419 let path = format!("/api/v1/contracts/{}", Self::futures_symbol(symbol));
420 let data = self.get(&path, "")?;
421 let contracts = parse_decimal(field_str(&data, "openInterest")?)?;
422 let multiplier = data
423 .get("multiplier")
424 .and_then(serde_json::Value::as_f64)
425 .and_then(|m| parse_decimal(&m.to_string()).ok())
426 .ok_or_else(|| Error::Deserialization("no contract multiplier".to_string()))?;
427 Ok(OpenInterest {
428 symbol: symbol.clone(),
429 open_interest: contracts * multiplier,
430 timestamp: 0,
431 })
432 }
433
434 pub fn long_short_ratio(&self, _symbol: &Symbol) -> Result<LongShortRatio> {
439 Err(Error::unsupported_field(
440 "KuCoin",
441 "long/short positioning",
442 "KuCoin publishes no public account-positioning figure",
443 ))
444 }
445
446 pub fn subscribe_trades(&mut self, symbol: &Symbol) -> Result<()> {
451 self.subscribe(symbol, "/market/match")
452 }
453
454 pub fn subscribe_book(&mut self, symbol: &Symbol) -> Result<()> {
459 self.subscribe(symbol, "/market/level2")
460 }
461
462 pub fn subscribe_ticker(&mut self, symbol: &Symbol) -> Result<()> {
467 self.subscribe(symbol, "/market/ticker")
468 }
469
470 fn subscribe(&mut self, symbol: &Symbol, topic_prefix: &str) -> Result<()> {
471 self.ensure_market_is_routed()?;
472 let futures = self.is_futures();
476 let (topic_prefix, wire) = if futures {
477 (futures_topic(topic_prefix), Self::futures_symbol(symbol))
478 } else {
479 (topic_prefix, Self::wire_symbol(symbol))
480 };
481 if self.connection.is_none() {
482 let ws = self.ws.as_ref().ok_or(Error::NotConnected)?;
485 let url = if futures {
486 FUTURES_WS_URL
487 } else {
488 "wss://ws-api-spot.kucoin.com/"
489 };
490 let connection = ws.connect(url)?;
491 self.connection = Some(connection);
492 }
493 let id = (self.now_ms)();
494 let message = format!(
495 r#"{{"id":"{id}","type":"subscribe","topic":"{topic_prefix}:{wire}","response":true}}"#
496 );
497 self.connection
498 .as_mut()
499 .expect("connection just ensured")
500 .send(&message)?;
501 if !self.sub_messages.contains(&message) {
502 self.sub_messages.push(message.clone());
503 }
504 if !self.subscriptions.iter().any(|(w, _)| w == &wire) {
505 self.subscriptions.push((wire, symbol.clone()));
506 }
507 Ok(())
508 }
509
510 pub fn poll_events(&mut self) -> Vec<Event> {
512 let subscriptions: HashMap<String, Symbol> = self.subscriptions.iter().cloned().collect();
513 let resolve = |wire: &str| {
514 subscriptions
515 .get(wire)
516 .cloned()
517 .unwrap_or_else(|| wire.parse().unwrap_or_else(|_| Symbol::new(wire, "")))
518 };
519 let channels = self.derivatives_channels.clone();
520 let subscribed = |wire: &str, channel: DerivativesChannel| {
521 channels.iter().any(|(c, k)| c == wire && *k == channel)
522 };
523 let mut events = Vec::new();
524 if let Some(connection) = self.connection.as_mut() {
525 while let Ok(Some(frame)) = connection.recv() {
526 if let Ok(Some(event)) = parse_ws_message(&frame, &resolve, &subscribed) {
527 events.push(event);
528 }
529 }
530 }
531 if let Some(connection) = self.private_connection.as_mut() {
533 while let Ok(Some(frame)) = connection.recv() {
534 if let Ok(Some(event)) = parse_ws_message(&frame, &resolve, &subscribed) {
535 events.push(event);
536 }
537 }
538 }
539 if self.user_data_active
543 && self
544 .private_connection
545 .as_ref()
546 .is_some_and(|c| !c.is_connected())
547 {
548 events.push(Event::Disconnected);
549 self.private_connection = None;
550 if self.subscribe_user_data().is_ok() {
551 events.push(Event::Reconnected);
552 }
553 }
554 let url = "wss://ws-api-spot.kucoin.com/";
555 crate::wsutil::reconnect_if_dropped(
556 self.ws.as_deref(),
557 url,
558 &mut self.connection,
559 &self.sub_messages,
560 &mut events,
561 );
562 events
563 }
564
565 pub fn subscribe_user_data(&mut self) -> Result<()> {
585 self.ensure_market_is_routed()?;
586 let (orders_topic, account_topic) = if self.is_futures() {
591 ("/contractMarket/tradeOrders", "/contractAccount/wallet")
592 } else {
593 ("/spotMarket/tradeOrders", "/account/balance")
594 };
595 let data = self.signed_request(HttpMethod::Post, "/api/v1/bullet-private", "", "")?;
596 let bullet: BulletToken = parse_json(data)?;
597 let server = bullet.instance_servers.into_iter().next().ok_or_else(|| {
598 Error::NotFound("bullet-private returned no instance server".to_string())
599 })?;
600 let connect_id = (self.now_ms)();
601 let url = format!(
602 "{}?token={}&connectId={connect_id}",
603 server.endpoint, bullet.token
604 );
605 let orders = format!(
606 r#"{{"id":"{connect_id}","type":"subscribe","topic":"{orders_topic}","privateChannel":true,"response":true}}"#
607 );
608 let account = format!(
609 r#"{{"id":"{}","type":"subscribe","topic":"{account_topic}","privateChannel":true,"response":true}}"#,
610 connect_id + 1
611 );
612 let ws = self.ws.as_ref().ok_or(Error::NotConnected)?;
613 let mut connection = ws.connect(&url)?;
614 connection.send(&orders)?;
615 connection.send(&account)?;
616 self.private_connection = Some(connection);
617 self.user_data_active = true;
618 Ok(())
619 }
620
621 pub fn keepalive_user_data(&mut self) -> Result<()> {
628 let ping = format!(r#"{{"id":"{}","type":"ping"}}"#, (self.now_ms)());
629 if let Some(connection) = self.private_connection.as_mut() {
630 connection.send(&ping)?;
631 }
632 Ok(())
633 }
634
635 fn ensure_reduce_only_is_reducible(&self, request: &OrderRequest) -> Result<()> {
648 if request.reduce_only && !self.is_futures() {
649 return Err(Error::unsupported_field(
650 "KuCoin",
651 "reduce_only on a spot order",
652 "spot holds balances, not positions, and has none to reduce",
653 ));
654 }
655 Ok(())
656 }
657
658 fn add_trigger(&self, body: &mut serde_json::Value, request: &OrderRequest) -> Result<()> {
670 let stop = request
671 .stop_price
672 .ok_or(Error::InvalidOrder("a trigger order requires a stop price"))?;
673 body["stop"] = serde_json::json!(match request.side {
674 OrderSide::Sell => "loss",
675 OrderSide::Buy => "entry",
676 });
677 body["stopPrice"] = serde_json::json!(format_decimal(stop));
678 if self.is_futures() {
679 body["stopPriceType"] = serde_json::json!("MP");
680 }
681 Ok(())
682 }
683
684 pub fn place_order(&self, request: &OrderRequest) -> Result<Order> {
685 self.ensure_reduce_only_is_reducible(request)?;
686 self.ensure_one_way()?;
687 request.validate()?;
688 let client_oid = request
689 .client_order_id
690 .clone()
691 .unwrap_or_else(|| self.client_ids.next_id());
692 let symbol_wire = if self.is_futures() {
693 Self::futures_symbol(&request.symbol)
694 } else {
695 Self::wire_symbol(&request.symbol)
696 };
697 let mut body = serde_json::json!({
698 "clientOid": client_oid,
699 "side": side_str(request.side),
700 "symbol": symbol_wire,
701 "type": order_type_str(request.order_type),
702 "size": format_decimal(request.quantity),
703 });
704 if let Some(price) = request.price {
705 body["price"] = serde_json::json!(format_decimal(price));
706 }
707 if request.post_only {
708 body["postOnly"] = serde_json::json!(true);
709 }
710 if let Some(tif) = tif_str(request.time_in_force) {
711 body["timeInForce"] = serde_json::json!(tif);
712 }
713 if let Some(stp) = stp_flag(request.stp) {
714 body["stp"] = serde_json::json!(stp);
715 }
716 if self.is_futures() {
717 body["leverage"] = serde_json::json!(self.leverage.to_string());
719 if request.reduce_only {
720 body["reduceOnly"] = serde_json::json!(true);
721 }
722 }
723 let path = if request.order_type.is_trigger() {
727 self.add_trigger(&mut body, request)?;
728 if self.is_futures() {
729 "/api/v1/orders"
730 } else {
731 "/api/v1/stop-order"
732 }
733 } else {
734 "/api/v1/orders"
735 };
736 let data = self.signed_request(HttpMethod::Post, path, "", &body.to_string())?;
737 let placed: PlaceResult = parse_json(data)?;
738 Ok(Order {
739 id: placed.order_id,
740 client_order_id: Some(client_oid),
741 symbol: request.symbol.clone(),
742 side: request.side,
743 order_type: request.order_type,
744 status: OrderStatus::New,
745 quantity: request.quantity,
746 filled_quantity: Decimal::ZERO,
747 price: request.price,
748 average_price: None,
749 })
750 }
751
752 pub fn cancel_order(&self, _symbol: &Symbol, order_id: &str) -> Result<()> {
757 let path = format!("/api/v1/orders/{order_id}");
758 self.signed_request(HttpMethod::Delete, &path, "", "")?;
759 Ok(())
760 }
761
762 pub fn query_order(&self, symbol: &Symbol, order_id: &str) -> Result<Order> {
767 let path = format!("/api/v1/orders/{order_id}");
768 let data = self.signed_request(HttpMethod::Get, &path, "", "")?;
769 let raw: RawOrder = parse_json(data)?;
770 order_from_raw(symbol.clone(), &raw)
771 }
772
773 pub fn open_orders(&self, symbol: Option<&Symbol>) -> Result<Vec<Order>> {
778 let mut query = "status=active".to_string();
779 if let Some(s) = symbol {
780 query.push_str("&symbol=");
781 query.push_str(&Self::wire_symbol(s));
782 }
783 let data = self.signed_request(HttpMethod::Get, "/api/v1/orders", &query, "")?;
784 let page: OrderPage = parse_json(data)?;
785 page.items
786 .iter()
787 .map(|raw| {
788 let sym = symbol.cloned().unwrap_or_else(|| {
789 raw.symbol
790 .parse()
791 .unwrap_or_else(|_| Symbol::new(&raw.symbol, ""))
792 });
793 order_from_raw(sym, raw)
794 })
795 .collect()
796 }
797
798 pub fn balances(&self) -> Result<Vec<Balance>> {
803 let data = self.signed_request(HttpMethod::Get, "/api/v1/accounts", "type=trade", "")?;
804 let list: Vec<RawAccount> = parse_json(data)?;
805 Ok(list
806 .iter()
807 .map(|a| Balance {
808 asset: a.currency.clone(),
809 free: dec_or_zero(&a.available),
810 locked: dec_or_zero(&a.holds),
811 })
812 .collect())
813 }
814
815 fn get(&self, path: &str, query: &str) -> Result<serde_json::Value> {
816 self.ensure_market_is_routed()?;
817 let url = format!("{}{path}?{query}", self.rest_base);
818 let response = self.http.execute(&HttpRequest::get(url))?;
819 unwrap_envelope(&response.body).map_err(|e| e.with_retry_after(response.retry_after()))
820 }
821
822 fn signed_request(
824 &self,
825 method: HttpMethod,
826 path: &str,
827 query: &str,
828 body: &str,
829 ) -> Result<serde_json::Value> {
830 self.ensure_market_is_routed()?;
831 let creds = self.credentials.as_ref().ok_or(Error::InvalidCredentials(
832 "signed endpoint requires credentials",
833 ))?;
834 let passphrase = creds
835 .passphrase
836 .as_deref()
837 .ok_or(Error::InvalidCredentials("KuCoin requires a passphrase"))?;
838 let timestamp = self.signed_now_ms().to_string();
839 let endpoint = if query.is_empty() {
840 path.to_string()
841 } else {
842 format!("{path}?{query}")
843 };
844 let prehash = format!("{timestamp}{}{endpoint}{body}", method.as_str());
845 let signature = hmac_sha256_base64(creds.api_secret.as_bytes(), prehash.as_bytes());
846 let signed_passphrase =
847 hmac_sha256_base64(creds.api_secret.as_bytes(), passphrase.as_bytes());
848 let url = format!("{}{endpoint}", self.rest_base);
849 let mut request = HttpRequest::new(method, url)
850 .with_header("KC-API-KEY", creds.api_key.clone())
851 .with_header("KC-API-SIGN", signature)
852 .with_header("KC-API-TIMESTAMP", timestamp)
853 .with_header("KC-API-PASSPHRASE", signed_passphrase)
854 .with_header("KC-API-KEY-VERSION", "2");
855 if !body.is_empty() {
856 request = request
857 .with_header("Content-Type", "application/json")
858 .with_body(body.to_string());
859 }
860 let response = self.http.execute(&request)?;
861 unwrap_envelope(&response.body).map_err(|e| e.with_retry_after(response.retry_after()))
862 }
863}
864
865fn map_type(interval: &str) -> String {
866 match interval {
867 "1m" => "1min",
868 "3m" => "3min",
869 "5m" => "5min",
870 "15m" => "15min",
871 "30m" => "30min",
872 "1h" => "1hour",
873 "2h" => "2hour",
874 "4h" => "4hour",
875 "6h" => "6hour",
876 "12h" => "12hour",
877 "1d" => "1day",
878 "1w" => "1week",
879 other => other,
880 }
881 .to_string()
882}
883
884fn unwrap_envelope(body: &str) -> Result<serde_json::Value> {
885 let envelope: Envelope =
886 serde_json::from_str(body).map_err(|e| Error::Deserialization(e.to_string()))?;
887 if envelope.code != "200000" {
888 return Err(map_error(&envelope.code, &envelope.msg));
889 }
890 Ok(envelope.data)
891}
892
893fn parse_json<T: for<'de> Deserialize<'de>>(value: serde_json::Value) -> Result<T> {
894 serde_json::from_value(value).map_err(|e| Error::Deserialization(e.to_string()))
895}
896
897fn map_error(code: &str, msg: &str) -> Error {
898 match code {
899 "429000" => Error::RateLimited { retry_after: None },
900 "400003" | "400004" | "400005" | "400006" | "400007" => Error::Auth(msg.to_string()),
901 "200004" | "400100" => Error::InsufficientBalance,
902 "400600" | "404000" => Error::NotFound(msg.to_string()),
903 other => Error::Exchange {
904 code: other.to_string(),
905 message: msg.to_string(),
906 },
907 }
908}
909
910fn side_str(side: OrderSide) -> &'static str {
911 match side {
912 OrderSide::Buy => "buy",
913 OrderSide::Sell => "sell",
914 }
915}
916
917fn order_type_str(order_type: OrderType) -> &'static str {
918 match order_type {
919 OrderType::Market | OrderType::StopMarket => "market",
920 OrderType::Limit | OrderType::StopLimit => "limit",
921 }
922}
923
924fn tif_str(tif: TimeInForce) -> Option<&'static str> {
930 match tif {
931 TimeInForce::Gtc => None,
932 TimeInForce::Ioc => Some("IOC"),
933 TimeInForce::Fok => Some("FOK"),
934 }
935}
936
937fn stp_flag(stp: SelfTradePrevention) -> Option<&'static str> {
938 match stp {
939 SelfTradePrevention::None => None,
940 SelfTradePrevention::ExpireMaker => Some("CO"),
941 SelfTradePrevention::ExpireTaker => Some("CN"),
942 SelfTradePrevention::ExpireBoth => Some("CB"),
943 }
944}
945
946fn parse_side(raw: &str) -> Result<OrderSide> {
947 match raw {
948 "buy" => Ok(OrderSide::Buy),
949 "sell" => Ok(OrderSide::Sell),
950 other => Err(Error::Deserialization(format!("unknown side {other:?}"))),
951 }
952}
953
954fn parse_order_type(raw: &str) -> Result<OrderType> {
955 match raw {
956 "market" => Ok(OrderType::Market),
957 "limit" => Ok(OrderType::Limit),
958 other => Err(Error::Deserialization(format!(
959 "unknown order type {other:?}"
960 ))),
961 }
962}
963
964fn dec_or_zero(raw: &str) -> Decimal {
965 crate::normalize::parse_opt_decimal(Some(raw))
966 .ok()
967 .flatten()
968 .unwrap_or(Decimal::ZERO)
969}
970
971fn nonzero_decimal(raw: &str) -> Option<Decimal> {
972 crate::normalize::parse_opt_decimal(Some(raw))
973 .ok()
974 .flatten()
975 .filter(|d| *d > Decimal::ZERO)
976}
977
978fn parse_levels(levels: &[[String; 2]]) -> Result<Vec<BookLevel>> {
979 levels
980 .iter()
981 .map(|[price, qty]| {
982 Ok(BookLevel {
983 price: parse_decimal(price)?,
984 quantity: parse_decimal(qty)?,
985 })
986 })
987 .collect()
988}
989
990fn parse_kline_row(row: &[String]) -> Result<Candle> {
991 if row.len() < 6 {
993 return Err(Error::Deserialization("kline row too short".to_string()));
994 }
995 let ts = row[0]
996 .parse::<i64>()
997 .map_err(|e| Error::Deserialization(format!("kline time not an integer: {e}")))?;
998 let f = |i: usize| -> Result<f64> {
999 row[i]
1000 .parse::<f64>()
1001 .map_err(|e| Error::Deserialization(format!("kline field not a number: {e}")))
1002 };
1003 Candle::new(f(1)?, f(3)?, f(4)?, f(2)?, f(5)?, ts)
1005 .map_err(|e| Error::Deserialization(e.to_string()))
1006}
1007
1008fn order_from_raw(symbol: Symbol, raw: &RawOrder) -> Result<Order> {
1009 let status = if raw.is_active {
1010 if dec_or_zero(&raw.deal_size) > Decimal::ZERO {
1011 OrderStatus::PartiallyFilled
1012 } else {
1013 OrderStatus::New
1014 }
1015 } else if raw.cancel_exist {
1016 OrderStatus::Canceled
1017 } else {
1018 OrderStatus::Filled
1019 };
1020 Ok(Order {
1021 id: raw.id.clone(),
1022 client_order_id: (!raw.client_oid.is_empty()).then(|| raw.client_oid.clone()),
1023 symbol,
1024 side: parse_side(&raw.side)?,
1025 order_type: parse_order_type(&raw.order_type)?,
1026 status,
1027 quantity: parse_decimal(&raw.size)?,
1028 filled_quantity: dec_or_zero(&raw.deal_size),
1029 price: nonzero_decimal(&raw.price),
1030 average_price: None,
1031 })
1032}
1033
1034fn field_str<'a>(value: &'a serde_json::Value, key: &str) -> Result<&'a str> {
1035 value
1036 .get(key)
1037 .and_then(serde_json::Value::as_str)
1038 .ok_or_else(|| Error::Deserialization(format!("missing string field {key:?}")))
1039}
1040
1041fn opt_str<'a>(value: &'a serde_json::Value, key: &str) -> &'a str {
1042 value
1043 .get(key)
1044 .and_then(serde_json::Value::as_str)
1045 .unwrap_or("")
1046}
1047
1048fn opt_u64(value: &serde_json::Value, key: &str) -> u64 {
1050 value
1051 .get(key)
1052 .and_then(|v| {
1053 v.as_u64()
1054 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
1055 })
1056 .unwrap_or(0)
1057}
1058
1059fn parse_ws_levels(value: Option<&serde_json::Value>) -> Result<Vec<BookLevel>> {
1060 let array = value
1061 .and_then(serde_json::Value::as_array)
1062 .ok_or_else(|| Error::Deserialization("missing depth levels".to_string()))?;
1063 array
1064 .iter()
1065 .map(|level| {
1066 let pair = level
1067 .as_array()
1068 .ok_or_else(|| Error::Deserialization("depth level not an array".to_string()))?;
1069 let price = parse_decimal(
1070 pair.first()
1071 .and_then(serde_json::Value::as_str)
1072 .ok_or_else(|| Error::Deserialization("depth price missing".to_string()))?,
1073 )?;
1074 let quantity =
1075 parse_decimal(pair.get(1).and_then(serde_json::Value::as_str).ok_or_else(
1076 || Error::Deserialization("depth quantity missing".to_string()),
1077 )?)?;
1078 Ok(BookLevel { price, quantity })
1079 })
1080 .collect()
1081}
1082
1083fn opt_i64(value: &serde_json::Value, key: &str) -> i64 {
1086 value
1087 .get(key)
1088 .and_then(serde_json::Value::as_i64)
1089 .unwrap_or(0)
1090}
1091
1092const FUTURES_WS_URL: &str = "wss://ws-api-futures.kucoin.com/";
1094
1095fn futures_topic(spot_topic: &str) -> &'static str {
1101 match spot_topic {
1102 "/market/match" => "/contractMarket/execution",
1103 "/market/level2" => "/contractMarket/level2",
1104 _ => "/contractMarket/ticker",
1105 }
1106}
1107
1108fn futures_millis(raw: i64) -> i64 {
1112 raw / 1_000_000
1113}
1114
1115fn futures_change(change: &str) -> Option<(BookLevel, OrderSide)> {
1119 let mut parts = change.split(',');
1120 let price = parse_decimal(parts.next()?.trim()).ok()?;
1121 let side = match parts.next()?.trim() {
1122 "buy" => OrderSide::Buy,
1123 "sell" => OrderSide::Sell,
1124 _ => return None,
1125 };
1126 let quantity = parse_decimal(parts.next()?.trim()).ok()?;
1127 Some((BookLevel { price, quantity }, side))
1128}
1129
1130fn parse_ws_message(
1131 text: &str,
1132 resolve: &impl Fn(&str) -> Symbol,
1133 subscribed: &impl Fn(&str, DerivativesChannel) -> bool,
1134) -> Result<Option<Event>> {
1135 let value: serde_json::Value =
1136 serde_json::from_str(text).map_err(|e| Error::Deserialization(e.to_string()))?;
1137 if value.get("type").and_then(serde_json::Value::as_str) != Some("message") {
1138 return Ok(None); }
1140 let Some(topic) = value.get("topic").and_then(serde_json::Value::as_str) else {
1141 return Ok(None);
1142 };
1143 let wire = topic.rsplit(':').next().unwrap_or("");
1144 let symbol = resolve(wire);
1145 let null = serde_json::Value::Null;
1146 let data = value.get("data").unwrap_or(&null);
1147
1148 if topic.starts_with("/contract/instrument:") {
1150 return Ok(
1154 match value.get("subject").and_then(serde_json::Value::as_str) {
1155 Some("funding.rate") if subscribed(wire, DerivativesChannel::Funding) => {
1156 let rate = data
1157 .get("fundingRate")
1158 .and_then(serde_json::Value::as_f64)
1159 .and_then(|r| parse_decimal(&r.to_string()).ok());
1160 rate.map(|rate| {
1161 Event::Derivatives(DerivativesFeed::Funding(FundingRate {
1162 symbol,
1163 rate,
1164 mark_price: Decimal::ZERO,
1165 timestamp: opt_i64(data, "timestamp"),
1166 }))
1167 })
1168 }
1169 Some("mark.index.price") if subscribed(wire, DerivativesChannel::MarkIndex) => {
1170 let mark = data
1171 .get("markPrice")
1172 .and_then(serde_json::Value::as_f64)
1173 .and_then(|p| parse_decimal(&p.to_string()).ok());
1174 let index = data
1175 .get("indexPrice")
1176 .and_then(serde_json::Value::as_f64)
1177 .and_then(|p| parse_decimal(&p.to_string()).ok());
1178 match (mark, index) {
1179 (Some(mark_price), Some(index_price)) => {
1180 Some(Event::Derivatives(DerivativesFeed::MarkIndex(MarkIndex {
1181 symbol,
1182 mark_price,
1183 index_price,
1184 timestamp: opt_i64(data, "timestamp"),
1185 })))
1186 }
1187 _ => None,
1188 }
1189 }
1190 _ => None,
1191 },
1192 );
1193 } else if topic.starts_with("/contractMarket/execution:") {
1194 return Ok(Some(Event::Trade(TradePrint {
1196 symbol,
1197 price: parse_decimal(field_str(data, "price")?)?,
1198 quantity: parse_decimal(&opt_i64(data, "size").to_string())?,
1199 aggressor: parse_side(field_str(data, "side")?)?,
1200 timestamp: futures_millis(opt_i64(data, "ts")),
1201 })));
1202 } else if topic.starts_with("/contractMarket/ticker:") {
1203 return Ok(Some(Event::Ticker(Ticker {
1204 symbol,
1205 last: parse_decimal(field_str(data, "price")?)?,
1206 bid: dec_or_zero(opt_str(data, "bestBidPrice")),
1207 ask: dec_or_zero(opt_str(data, "bestAskPrice")),
1208 volume: Decimal::ZERO,
1211 timestamp: futures_millis(opt_i64(data, "ts")),
1212 })));
1213 } else if topic.starts_with("/contractMarket/level2:") {
1214 let Some((level, side)) = futures_change(opt_str(data, "change")) else {
1216 return Ok(None);
1217 };
1218 let sequence = opt_i64(data, "sequence");
1219 let (bids, asks) = match side {
1220 OrderSide::Buy => (vec![level], Vec::new()),
1221 OrderSide::Sell => (Vec::new(), vec![level]),
1222 };
1223 return Ok(Some(Event::BookDelta(BookDelta {
1224 symbol,
1225 first_update_id: sequence.unsigned_abs(),
1226 final_update_id: sequence.unsigned_abs(),
1227 bids,
1228 asks,
1229 timestamp: opt_i64(data, "timestamp"),
1230 })));
1231 }
1232
1233 if topic.starts_with("/market/match:") {
1234 Ok(Some(Event::Trade(TradePrint {
1235 symbol,
1236 price: parse_decimal(field_str(data, "price")?)?,
1237 quantity: parse_decimal(field_str(data, "size")?)?,
1238 aggressor: parse_side(field_str(data, "side")?)?,
1239 timestamp: opt_str(data, "time").parse().unwrap_or(0),
1240 })))
1241 } else if topic.starts_with("/market/ticker:") {
1242 Ok(Some(Event::Ticker(Ticker {
1243 symbol,
1244 last: parse_decimal(field_str(data, "price")?)?,
1245 bid: dec_or_zero(opt_str(data, "bestBid")),
1246 ask: dec_or_zero(opt_str(data, "bestAsk")),
1247 volume: dec_or_zero(opt_str(data, "size")),
1248 timestamp: data
1249 .get("time")
1250 .and_then(serde_json::Value::as_i64)
1251 .unwrap_or(0),
1252 })))
1253 } else if topic.starts_with("/market/level2:") {
1254 let changes = data.get("changes");
1255 let bids = parse_ws_levels(changes.and_then(|c| c.get("bids")))?;
1256 let asks = parse_ws_levels(changes.and_then(|c| c.get("asks")))?;
1257 let update_id = opt_u64(data, "sequenceEnd");
1258 Ok(Some(Event::BookDelta(BookDelta {
1259 symbol,
1260 first_update_id: update_id,
1261 final_update_id: update_id,
1262 bids,
1263 asks,
1264 timestamp: data
1265 .get("time")
1266 .and_then(serde_json::Value::as_i64)
1267 .unwrap_or(0),
1268 })))
1269 } else if topic == "/spotMarket/tradeOrders" {
1270 Ok(Some(Event::OrderUpdate(ws_order_from_data(data)?)))
1271 } else if topic == "/contractMarket/tradeOrders" {
1272 Ok(Some(Event::OrderUpdate(futures_order_from_data(data)?)))
1273 } else if topic == "/contractAccount/wallet" {
1274 Ok(Some(Event::BalanceUpdate(vec![Balance {
1278 asset: field_str(data, "currency")?.to_string(),
1279 free: dec_or_zero(opt_str(data, "availableBalance")),
1280 locked: dec_or_zero(opt_str(data, "holdBalance")),
1281 }])))
1282 } else if topic == "/account/balance" {
1283 Ok(Some(Event::BalanceUpdate(vec![Balance {
1284 asset: field_str(data, "currency")?.to_string(),
1285 free: dec_or_zero(opt_str(data, "available")),
1286 locked: dec_or_zero(opt_str(data, "hold")),
1287 }])))
1288 } else {
1289 Ok(None)
1290 }
1291}
1292
1293fn futures_symbol_from_wire(wire: &str) -> Symbol {
1300 let stem = wire.strip_suffix('M').unwrap_or(wire);
1301 let quote = ["USDT", "USDC", "USD"]
1303 .into_iter()
1304 .find(|q| stem.len() > q.len() && stem.ends_with(q));
1305 let Some(quote) = quote else {
1306 return Symbol::new(stem, "");
1307 };
1308 let base = &stem[..stem.len() - quote.len()];
1309 let base = if base == "XBT" { "BTC" } else { base };
1310 Symbol::new(base, quote)
1311}
1312
1313fn futures_order_status(status: &str, event_type: &str) -> OrderStatus {
1319 match (status, event_type) {
1320 (_, "canceled") => OrderStatus::Canceled,
1321 ("match", _) => OrderStatus::PartiallyFilled,
1322 ("done", _) | (_, "filled") => OrderStatus::Filled,
1323 _ => OrderStatus::New,
1324 }
1325}
1326
1327fn futures_order_from_data(data: &serde_json::Value) -> Result<Order> {
1329 let client_oid = opt_str(data, "clientOid");
1330 Ok(Order {
1331 id: field_str(data, "orderId")?.to_string(),
1332 client_order_id: (!client_oid.is_empty()).then(|| client_oid.to_string()),
1333 symbol: futures_symbol_from_wire(field_str(data, "symbol")?),
1334 side: parse_side(field_str(data, "side")?)?,
1335 order_type: parse_order_type(field_str(data, "orderType")?)?,
1336 status: futures_order_status(opt_str(data, "status"), opt_str(data, "type")),
1337 quantity: number_or_string(data, "size"),
1339 filled_quantity: number_or_string(data, "filledSize"),
1340 price: nonzero_decimal(opt_str(data, "price")),
1341 average_price: None,
1342 })
1343}
1344
1345fn number_or_string(data: &serde_json::Value, key: &str) -> Decimal {
1347 data.get(key).map_or(Decimal::ZERO, |value| {
1348 value.as_str().map_or_else(
1349 || {
1350 value
1351 .as_i64()
1352 .and_then(|n| parse_decimal(&n.to_string()).ok())
1353 .unwrap_or(Decimal::ZERO)
1354 },
1355 |text| parse_decimal(text).unwrap_or(Decimal::ZERO),
1356 )
1357 })
1358}
1359
1360fn ws_order_status(status: &str, event_type: &str) -> OrderStatus {
1363 match status {
1364 "match" => OrderStatus::PartiallyFilled,
1365 "done" if event_type == "canceled" => OrderStatus::Canceled,
1366 "done" => OrderStatus::Filled,
1367 _ => OrderStatus::New,
1368 }
1369}
1370
1371fn ws_order_from_data(data: &serde_json::Value) -> Result<Order> {
1373 let wire = field_str(data, "symbol")?;
1374 let symbol = wire.parse().unwrap_or_else(|_| Symbol::new(wire, ""));
1375 let client_oid = opt_str(data, "clientOid");
1376 Ok(Order {
1377 id: field_str(data, "orderId")?.to_string(),
1378 client_order_id: (!client_oid.is_empty()).then(|| client_oid.to_string()),
1379 symbol,
1380 side: parse_side(field_str(data, "side")?)?,
1381 order_type: parse_order_type(field_str(data, "orderType")?)?,
1382 status: ws_order_status(opt_str(data, "status"), opt_str(data, "type")),
1383 quantity: parse_decimal(field_str(data, "size")?)?,
1384 filled_quantity: dec_or_zero(opt_str(data, "filledSize")),
1385 price: nonzero_decimal(opt_str(data, "price")),
1386 average_price: None,
1387 })
1388}
1389
1390#[derive(Deserialize)]
1392struct BulletToken {
1393 token: String,
1394 #[serde(rename = "instanceServers", default)]
1395 instance_servers: Vec<BulletServer>,
1396}
1397
1398#[derive(Deserialize)]
1399struct BulletServer {
1400 endpoint: String,
1401}
1402
1403#[derive(Deserialize)]
1404struct Envelope {
1405 code: String,
1406 #[serde(default)]
1407 msg: String,
1408 #[serde(default)]
1409 data: serde_json::Value,
1410}
1411
1412#[derive(Deserialize)]
1413struct RawStats {
1414 #[serde(default)]
1416 time: i64,
1417 last: String,
1418 buy: String,
1419 sell: String,
1420 vol: String,
1421}
1422
1423#[derive(Deserialize)]
1424struct RawDepth {
1425 #[serde(default)]
1427 time: i64,
1428 #[serde(default)]
1429 sequence: String,
1430 bids: Vec<[String; 2]>,
1431 asks: Vec<[String; 2]>,
1432}
1433
1434#[derive(Deserialize)]
1435struct PlaceResult {
1436 #[serde(rename = "orderId", default)]
1437 order_id: String,
1438}
1439
1440#[derive(Deserialize)]
1441struct OrderPage {
1442 items: Vec<RawOrder>,
1443}
1444
1445#[derive(Deserialize)]
1446struct RawOrder {
1447 id: String,
1448 #[serde(rename = "clientOid", default)]
1449 client_oid: String,
1450 #[serde(default)]
1451 symbol: String,
1452 side: String,
1453 #[serde(rename = "type")]
1454 order_type: String,
1455 size: String,
1456 #[serde(rename = "dealSize", default)]
1457 deal_size: String,
1458 #[serde(default)]
1459 price: String,
1460 #[serde(rename = "isActive", default)]
1461 is_active: bool,
1462 #[serde(rename = "cancelExist", default)]
1463 cancel_exist: bool,
1464}
1465
1466#[derive(Deserialize)]
1467struct RawAccount {
1468 currency: String,
1469 #[serde(default)]
1470 available: String,
1471 #[serde(default)]
1472 holds: String,
1473}
1474
1475impl MarketData for KuCoin {
1476 fn ticker(&mut self, symbol: &Symbol) -> Result<Ticker> {
1477 KuCoin::ticker(self, symbol)
1478 }
1479 fn klines(&mut self, symbol: &Symbol, interval: &str, limit: u32) -> Result<Vec<Candle>> {
1480 KuCoin::klines(self, symbol, interval, limit)
1481 }
1482 fn order_book(&mut self, symbol: &Symbol, depth: u32) -> Result<OrderBookSnapshot> {
1483 KuCoin::order_book(self, symbol, depth)
1484 }
1485 fn subscribe_trades(&mut self, symbol: &Symbol) -> Result<()> {
1486 KuCoin::subscribe_trades(self, symbol)
1487 }
1488 fn subscribe_book(&mut self, symbol: &Symbol) -> Result<()> {
1489 KuCoin::subscribe_book(self, symbol)
1490 }
1491 fn subscribe_ticker(&mut self, symbol: &Symbol) -> Result<()> {
1492 KuCoin::subscribe_ticker(self, symbol)
1493 }
1494 fn poll_events(&mut self) -> Vec<Event> {
1495 KuCoin::poll_events(self)
1496 }
1497}
1498
1499impl Execution for KuCoin {
1500 fn place_order(&mut self, request: &OrderRequest) -> Result<Order> {
1501 KuCoin::place_order(self, request)
1502 }
1503 fn cancel_order(&mut self, symbol: &Symbol, order_id: &str) -> Result<()> {
1504 KuCoin::cancel_order(self, symbol, order_id)
1505 }
1506 fn query_order(&mut self, symbol: &Symbol, order_id: &str) -> Result<Order> {
1507 KuCoin::query_order(self, symbol, order_id)
1508 }
1509 fn open_orders(&mut self, symbol: Option<&Symbol>) -> Result<Vec<Order>> {
1510 KuCoin::open_orders(self, symbol)
1511 }
1512 fn balances(&mut self) -> Result<Vec<Balance>> {
1513 KuCoin::balances(self)
1514 }
1515}
1516
1517impl KuCoin {
1518 pub fn positions(&self, symbol: Option<&Symbol>) -> Result<Vec<Position>> {
1525 let data = self.signed_request(HttpMethod::Get, "/api/v1/positions", "", "")?;
1526 let list: Vec<RawKuPosition> = parse_json(data)?;
1527 list.iter()
1528 .filter(|p| symbol.is_none_or(|s| p.symbol == Self::futures_symbol(s)))
1529 .filter_map(parse_ku_position)
1530 .collect()
1531 }
1532
1533 pub fn set_leverage(&mut self, _symbol: &Symbol, leverage: u32) -> Result<()> {
1540 self.leverage = leverage.max(1);
1541 Ok(())
1542 }
1543
1544 pub fn set_margin_mode(&self, symbol: &Symbol, mode: MarginMode) -> Result<()> {
1549 let margin = match mode {
1550 MarginMode::Cross => "CROSS",
1551 MarginMode::Isolated => "ISOLATED",
1552 };
1553 let body = serde_json::json!({
1554 "symbol": Self::futures_symbol(symbol),
1555 "marginMode": margin,
1556 });
1557 self.signed_request(
1558 HttpMethod::Post,
1559 "/api/v1/position/changeMarginMode",
1560 "",
1561 &body.to_string(),
1562 )?;
1563 Ok(())
1564 }
1565
1566 pub fn close_position(&self, symbol: &Symbol) -> Result<Order> {
1573 self.ensure_one_way()?;
1574 let position = self
1575 .positions(Some(symbol))?
1576 .into_iter()
1577 .find(|p| &p.symbol == symbol)
1578 .ok_or_else(|| Error::NotFound(format!("no open position for {symbol}")))?;
1579 let request = match position.side {
1580 PositionSide::Long => OrderRequest::market_sell(symbol.clone(), position.quantity),
1581 PositionSide::Short => OrderRequest::market_buy(symbol.clone(), position.quantity),
1582 }
1583 .reduce_only();
1584 self.place_order(&request)
1585 }
1586
1587 pub fn place_batch(&self, requests: &[OrderRequest]) -> Result<Vec<Result<Order>>> {
1595 if requests.iter().any(|r| r.order_type.is_trigger()) {
1596 return Err(Error::unsupported_trigger("KuCoin"));
1597 }
1598 for request in requests {
1599 self.ensure_reduce_only_is_reducible(request)?;
1600 }
1601 self.ensure_one_way()?;
1602 if requests.is_empty() {
1603 return Ok(Vec::new());
1604 }
1605 if self.is_futures() {
1606 return Err(Error::Exchange {
1607 code: "unsupported".to_string(),
1608 message: "KuCoin multi-order is a spot endpoint".to_string(),
1609 });
1610 }
1611 let wire = Self::wire_symbol(&requests[0].symbol);
1612 let order_list: Vec<serde_json::Value> = requests
1613 .iter()
1614 .map(|r| {
1615 let coid = r
1616 .client_order_id
1617 .clone()
1618 .unwrap_or_else(|| self.client_ids.next_id());
1619 let mut o = serde_json::json!({
1620 "clientOid": coid,
1621 "side": side_str(r.side),
1622 "type": order_type_str(r.order_type),
1623 "size": format_decimal(r.quantity),
1624 });
1625 if let Some(price) = r.price {
1626 o["price"] = serde_json::json!(format_decimal(price));
1627 }
1628 if r.post_only {
1629 o["postOnly"] = serde_json::json!(true);
1630 }
1631 if let Some(tif) = tif_str(r.time_in_force) {
1632 o["timeInForce"] = serde_json::json!(tif);
1633 }
1634 if let Some(stp) = stp_flag(r.stp) {
1635 o["stp"] = serde_json::json!(stp);
1636 }
1637 o
1638 })
1639 .collect();
1640 let body = serde_json::json!({ "symbol": wire, "orderList": order_list });
1641 let data = self.signed_request(
1642 HttpMethod::Post,
1643 "/api/v1/orders/multi",
1644 "",
1645 &body.to_string(),
1646 )?;
1647 let batch: MultiBatch = parse_json(data)?;
1648 Ok(requests
1649 .iter()
1650 .zip(batch.data)
1651 .map(|(req, res)| {
1652 if res.order_id.is_empty() || res.status == "fail" {
1653 return Err(Error::OrderRejected {
1654 code: "batch".to_string(),
1655 message: if res.fail_msg.is_empty() {
1656 "order rejected in batch".to_string()
1657 } else {
1658 res.fail_msg
1659 },
1660 });
1661 }
1662 Ok(Order {
1663 id: res.order_id,
1664 client_order_id: req.client_order_id.clone(),
1665 symbol: req.symbol.clone(),
1666 side: req.side,
1667 order_type: req.order_type,
1668 status: OrderStatus::New,
1669 quantity: req.quantity,
1670 filled_quantity: Decimal::ZERO,
1671 price: req.price,
1672 average_price: None,
1673 })
1674 })
1675 .collect())
1676 }
1677
1678 pub fn cancel_batch(&self, symbol: &Symbol, order_ids: &[String]) -> Result<()> {
1684 for id in order_ids {
1685 self.cancel_order(symbol, id)?;
1686 }
1687 Ok(())
1688 }
1689
1690 pub fn place_oco(&self, request: &OcoRequest) -> Result<Vec<Order>> {
1697 self.ensure_one_way()?;
1698 request.validate()?;
1699 let client_oid = request
1700 .client_order_id
1701 .clone()
1702 .unwrap_or_else(|| self.client_ids.next_id());
1703 let stop_limit = request.stop_limit_price.unwrap_or(request.stop_price);
1704 let body = serde_json::json!({
1705 "symbol": Self::wire_symbol(&request.symbol),
1706 "side": side_str(request.side),
1707 "size": format_decimal(request.quantity),
1708 "price": format_decimal(request.price),
1709 "stopPrice": format_decimal(request.stop_price),
1710 "limitPrice": format_decimal(stop_limit),
1711 "clientOid": client_oid,
1712 });
1713 let data =
1714 self.signed_request(HttpMethod::Post, "/api/v3/oco/order", "", &body.to_string())?;
1715 let placed: PlaceResult = parse_json(data)?;
1716 Ok(vec![Order {
1717 id: placed.order_id,
1718 client_order_id: Some(client_oid),
1719 symbol: request.symbol.clone(),
1720 side: request.side,
1721 order_type: OrderType::StopLimit,
1722 status: OrderStatus::New,
1723 quantity: request.quantity,
1724 filled_quantity: Decimal::ZERO,
1725 price: Some(request.price),
1726 average_price: None,
1727 }])
1728 }
1729}
1730
1731impl AdvancedOrders for KuCoin {
1732 fn amend_order(
1735 &mut self,
1736 _symbol: &Symbol,
1737 _order_id: &str,
1738 _new_price: Option<Decimal>,
1739 _new_quantity: Option<Decimal>,
1740 ) -> Result<Order> {
1741 Err(Error::Exchange {
1742 code: "unsupported".to_string(),
1743 message: "KuCoin has no in-place amend; cancel and re-place the order".to_string(),
1744 })
1745 }
1746 fn place_batch(&mut self, requests: &[OrderRequest]) -> Result<Vec<Result<Order>>> {
1747 KuCoin::place_batch(self, requests)
1748 }
1749 fn cancel_batch(&mut self, symbol: &Symbol, order_ids: &[String]) -> Result<()> {
1750 KuCoin::cancel_batch(self, symbol, order_ids)
1751 }
1752 fn place_oco(&mut self, request: &OcoRequest) -> Result<Vec<Order>> {
1753 KuCoin::place_oco(self, request)
1754 }
1755}
1756
1757#[derive(Deserialize)]
1758struct MultiBatch {
1759 #[serde(default)]
1760 data: Vec<MultiResult>,
1761}
1762
1763#[derive(Deserialize)]
1764struct MultiResult {
1765 #[serde(rename = "orderId", default)]
1766 order_id: String,
1767 #[serde(default)]
1768 status: String,
1769 #[serde(rename = "failMsg", default)]
1770 fail_msg: String,
1771}
1772
1773impl Exchange for KuCoin {
1774 fn name(&self) -> &'static str {
1775 "kucoin"
1776 }
1777}
1778
1779impl WsUserData for KuCoin {
1780 fn subscribe_user_data(&mut self) -> Result<()> {
1781 KuCoin::subscribe_user_data(self)
1782 }
1783 fn keepalive_user_data(&mut self) -> Result<()> {
1784 KuCoin::keepalive_user_data(self)
1785 }
1786}
1787
1788impl WsExecution for KuCoin {
1789 fn place_order_ws(&mut self, _request: &OrderRequest) -> Result<Order> {
1794 Err(Error::Exchange {
1795 code: "unsupported".to_string(),
1796 message: "KuCoin has no WebSocket order-entry API; place orders over \
1797 REST (POST /api/v1/orders)"
1798 .to_string(),
1799 })
1800 }
1801
1802 fn cancel_order_ws(&mut self, _symbol: &Symbol, _order_id: &str) -> Result<()> {
1803 Err(Error::Exchange {
1804 code: "unsupported".to_string(),
1805 message: "KuCoin has no WebSocket order-entry API; cancel orders over \
1806 REST (DELETE /api/v1/orders/{orderId})"
1807 .to_string(),
1808 })
1809 }
1810}
1811
1812impl Derivatives for KuCoin {
1813 fn positions(&mut self, symbol: Option<&Symbol>) -> Result<Vec<Position>> {
1814 KuCoin::positions(self, symbol)
1815 }
1816 fn set_leverage(&mut self, symbol: &Symbol, leverage: u32) -> Result<()> {
1817 KuCoin::set_leverage(self, symbol, leverage)
1818 }
1819 fn set_margin_mode(&mut self, symbol: &Symbol, mode: MarginMode) -> Result<()> {
1820 KuCoin::set_margin_mode(self, symbol, mode)
1821 }
1822 fn close_position(&mut self, symbol: &Symbol) -> Result<Order> {
1823 KuCoin::close_position(self, symbol)
1824 }
1825}
1826
1827impl DerivativesStream for KuCoin {
1828 fn subscribe_derivatives(
1829 &mut self,
1830 symbol: &Symbol,
1831 channel: DerivativesChannel,
1832 ) -> Result<()> {
1833 KuCoin::subscribe_derivatives(self, symbol, channel)
1834 }
1835 fn open_interest(&mut self, symbol: &Symbol) -> Result<OpenInterest> {
1836 KuCoin::open_interest(self, symbol)
1837 }
1838 fn long_short_ratio(&mut self, symbol: &Symbol) -> Result<LongShortRatio> {
1839 KuCoin::long_short_ratio(self, symbol)
1840 }
1841}
1842
1843fn symbol_from_futures(contract: &str) -> Symbol {
1847 const QUOTES: &[&str] = &["USDT", "USDC", "USD"];
1848 let stripped = contract.strip_suffix('M').unwrap_or(contract);
1849 for quote in QUOTES {
1850 if let Some(base) = stripped.strip_suffix(quote) {
1851 if !base.is_empty() {
1852 let base = if base == "XBT" { "BTC" } else { base };
1853 return Symbol::new(base, *quote);
1854 }
1855 }
1856 }
1857 Symbol::new(stripped, "")
1858}
1859
1860#[derive(Deserialize)]
1861struct RawKuPosition {
1862 symbol: String,
1863 #[serde(rename = "currentQty")]
1864 current_qty: f64,
1865 #[serde(rename = "avgEntryPrice", default)]
1866 avg_entry_price: f64,
1867 #[serde(rename = "markPrice", default)]
1868 mark_price: f64,
1869 #[serde(rename = "realLeverage", default)]
1870 real_leverage: f64,
1871 #[serde(rename = "unrealisedPnl", default)]
1872 unrealised_pnl: f64,
1873 #[serde(rename = "crossMode", default)]
1874 cross_mode: bool,
1875}
1876
1877fn parse_ku_position(raw: &RawKuPosition) -> Option<Result<Position>> {
1878 if raw.current_qty == 0.0 {
1879 return None; }
1881 let side = if raw.current_qty < 0.0 {
1882 PositionSide::Short
1883 } else {
1884 PositionSide::Long
1885 };
1886 let dec = |value: f64| Decimal::from_f64(value).unwrap_or_default();
1887 Some(Ok(Position {
1888 symbol: symbol_from_futures(&raw.symbol),
1889 side,
1890 quantity: dec(raw.current_qty.abs()),
1891 entry_price: dec(raw.avg_entry_price),
1892 mark_price: dec(raw.mark_price),
1893 leverage: dec(raw.real_leverage),
1894 unrealized_pnl: dec(raw.unrealised_pnl),
1895 margin_mode: if raw.cross_mode {
1896 MarginMode::Cross
1897 } else {
1898 MarginMode::Isolated
1899 },
1900 }))
1901}
1902
1903#[cfg(test)]
1904mod tests {
1905 use super::*;
1906 use crate::transport::{HttpResponse, MockHttpTransport, MockWsTransport};
1907 use rust_decimal_macros::dec;
1908 use std::sync::Arc;
1909
1910 struct ArcTransport(Arc<MockHttpTransport>);
1911 impl HttpTransport for ArcTransport {
1912 fn execute(&self, request: &HttpRequest) -> Result<HttpResponse> {
1913 self.0.execute(request)
1914 }
1915 }
1916 struct ArcWs(Arc<MockWsTransport>);
1917 impl WsTransport for ArcWs {
1918 fn connect(&self, url: &str) -> Result<Box<dyn WsConnection>> {
1919 self.0.connect(url)
1920 }
1921 }
1922
1923 fn symbol() -> Symbol {
1924 Symbol::new("BTC", "USDT")
1925 }
1926
1927 fn client() -> (KuCoin, Arc<MockHttpTransport>) {
1928 let mock = Arc::new(MockHttpTransport::new());
1929 let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
1930 (
1931 KuCoin::with_http(Box::new(ArcTransport(Arc::clone(&mock))), &opts),
1932 mock,
1933 )
1934 }
1935
1936 fn futures_ws_client() -> (KuCoin, Arc<MockWsTransport>) {
1937 let http = Arc::new(MockHttpTransport::new());
1938 let ws = Arc::new(MockWsTransport::new());
1939 let opts = ExchangeOptions::mainnet(crate::MarketType::UsdMFutures);
1940 let kucoin = KuCoin::with_http(Box::new(ArcTransport(http)), &opts)
1941 .with_ws(Box::new(ArcWs(Arc::clone(&ws))));
1942 (kucoin, ws)
1943 }
1944
1945 #[test]
1947 fn kucoin_open_interest_reports_what_went_wrong() {
1948 let http = Arc::new(MockHttpTransport::new());
1949 let opts = ExchangeOptions::mainnet(crate::MarketType::UsdMFutures);
1950 let kucoin = KuCoin::with_http(Box::new(ArcTransport(Arc::clone(&http))), &opts);
1951
1952 http.push_json(
1955 200,
1956 r#"{"code":"200000","data":{"symbol":"XBTUSDTM","openInterest":"100"}}"#,
1957 );
1958 assert!(matches!(
1959 KuCoin::open_interest(&kucoin, &symbol()),
1960 Err(Error::Deserialization(_))
1961 ));
1962 }
1963
1964 #[test]
1966 fn kucoin_drops_futures_frames_it_cannot_read() {
1967 let (mut kucoin, ws) = futures_ws_client();
1968 ws.push_connection(vec![
1969 Ok(Some(
1971 r#"{"topic":"/contractMarket/level2:XBTUSDTM","type":"message",
1972 "subject":"level2","data":{"sequence":1,"change":"nonsense"}}"#
1973 .to_string(),
1974 )),
1975 Ok(Some(
1977 r#"{"topic":"/contractMarket/level2:XBTUSDTM","type":"message",
1978 "subject":"level2","data":{"sequence":1,"change":"1.0,sideways,2"}}"#
1979 .to_string(),
1980 )),
1981 Ok(Some(
1983 r#"{"topic":"/contract/instrument:XBTUSDTM","type":"message",
1984 "subject":"something.else","data":{}}"#
1985 .to_string(),
1986 )),
1987 ]);
1988 KuCoin::subscribe_book(&mut kucoin, &symbol()).unwrap();
1989 KuCoin::subscribe_derivatives(&mut kucoin, &symbol(), DerivativesChannel::MarkIndex)
1990 .unwrap();
1991
1992 assert_eq!(KuCoin::poll_events(&mut kucoin), Vec::new());
1993 }
1994
1995 #[test]
1997 fn kucoin_drops_an_instrument_frame_without_prices() {
1998 let (mut kucoin, ws) = futures_ws_client();
1999 ws.push_connection(vec![
2000 Ok(Some(
2001 r#"{"topic":"/contract/instrument:XBTUSDTM","type":"message",
2002 "subject":"mark.index.price","data":{"markPrice":1.0}}"#
2003 .to_string(),
2004 )),
2005 Ok(Some(
2006 r#"{"topic":"/contract/instrument:XBTUSDTM","type":"message",
2007 "subject":"funding.rate","data":{"timestamp":1}}"#
2008 .to_string(),
2009 )),
2010 ]);
2011 KuCoin::subscribe_derivatives(&mut kucoin, &symbol(), DerivativesChannel::MarkIndex)
2012 .unwrap();
2013 KuCoin::subscribe_derivatives(&mut kucoin, &symbol(), DerivativesChannel::Funding).unwrap();
2014
2015 assert_eq!(KuCoin::poll_events(&mut kucoin), Vec::new());
2016 }
2017
2018 #[test]
2020 fn the_instrument_topic_answers_funding_and_mark_index() {
2021 let (mut kucoin, ws) = futures_ws_client();
2022 ws.push_connection(vec![
2023 Ok(Some(
2024 r#"{"topic":"/contract/instrument:XBTUSDTM","type":"message",
2025 "subject":"mark.index.price","data":{"markPrice":81316.40,
2026 "indexPrice":81334.48,"granularity":1000,"timestamp":1788462351000}}"#
2027 .to_string(),
2028 )),
2029 Ok(Some(
2030 r#"{"topic":"/contract/instrument:XBTUSDTM","type":"message",
2031 "subject":"funding.rate","data":{"period":1,"granularity":60000,
2032 "fundingRate":0.000021,"timestamp":1788464640000}}"#
2033 .to_string(),
2034 )),
2035 ]);
2036 KuCoin::subscribe_derivatives(&mut kucoin, &symbol(), DerivativesChannel::MarkIndex)
2037 .unwrap();
2038 KuCoin::subscribe_derivatives(&mut kucoin, &symbol(), DerivativesChannel::Funding).unwrap();
2039 assert!(ws.sent()[0].contains("/contract/instrument:XBTUSDTM"));
2041
2042 let events = KuCoin::poll_events(&mut kucoin);
2043 let Event::Derivatives(DerivativesFeed::MarkIndex(mark)) = &events[0] else {
2044 panic!("expected mark/index, got {:?}", events[0]);
2045 };
2046 assert_eq!(mark.mark_price, dec!(81316.40));
2047 assert_eq!(mark.index_price, dec!(81334.48));
2048 assert_eq!(mark.timestamp, 1_788_462_351_000);
2049
2050 let Event::Derivatives(DerivativesFeed::Funding(funding)) = &events[1] else {
2051 panic!("expected funding, got {:?}", events[1]);
2052 };
2053 assert_eq!(funding.rate, dec!(0.000021));
2054 assert_eq!(funding.mark_price, Decimal::ZERO);
2057 }
2058
2059 #[test]
2061 fn an_unsubscribed_instrument_subject_is_dropped() {
2062 let (mut kucoin, ws) = futures_ws_client();
2063 ws.push_connection(vec![Ok(Some(
2064 r#"{"topic":"/contract/instrument:XBTUSDTM","type":"message",
2065 "subject":"funding.rate","data":{"fundingRate":0.000021,
2066 "timestamp":1788464640000}}"#
2067 .to_string(),
2068 ))]);
2069 KuCoin::subscribe_derivatives(&mut kucoin, &symbol(), DerivativesChannel::MarkIndex)
2070 .unwrap();
2071
2072 assert_eq!(KuCoin::poll_events(&mut kucoin), Vec::new());
2073 }
2074
2075 #[test]
2078 fn open_interest_multiplies_contracts_by_the_contract_size() {
2079 let http = Arc::new(MockHttpTransport::new());
2080 let opts = ExchangeOptions::mainnet(crate::MarketType::UsdMFutures);
2081 let kucoin = KuCoin::with_http(Box::new(ArcTransport(Arc::clone(&http))), &opts);
2082 http.push_json(
2083 200,
2084 r#"{"code":"200000","data":{"symbol":"XBTUSDTM","baseCurrency":"XBT",
2085 "multiplier":0.001,"openInterest":"16731943","lotSize":1}}"#,
2086 );
2087 let open_interest = KuCoin::open_interest(&kucoin, &symbol()).unwrap();
2088 assert_eq!(open_interest.open_interest, dec!(16731.943));
2089 assert!(http.recorded_requests()[0]
2090 .url
2091 .contains("/contracts/XBTUSDTM"));
2092 }
2093
2094 #[test]
2096 fn kucoin_refuses_what_it_does_not_publish() {
2097 let (mut kucoin, ws) = futures_ws_client();
2098 let err =
2099 KuCoin::subscribe_derivatives(&mut kucoin, &symbol(), DerivativesChannel::Liquidations)
2100 .expect_err("KuCoin has no public liquidation stream");
2101 assert!(matches!(err, Error::Exchange { ref code, .. } if code == "unsupported"));
2102 assert_eq!(ws.sent(), Vec::<String>::new());
2103
2104 let (kucoin, _) = futures_ws_client();
2105 assert!(KuCoin::long_short_ratio(&kucoin, &symbol()).is_err());
2106 }
2107
2108 #[test]
2109 fn a_spot_client_refuses_the_kucoin_derivatives_surface() {
2110 let ws = Arc::new(MockWsTransport::new());
2111 let http = Arc::new(MockHttpTransport::new());
2112 let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
2113 let mut kucoin = KuCoin::with_http(Box::new(ArcTransport(Arc::clone(&http))), &opts)
2114 .with_ws(Box::new(ArcWs(Arc::clone(&ws))));
2115 for channel in [
2116 DerivativesChannel::Funding,
2117 DerivativesChannel::MarkIndex,
2118 DerivativesChannel::Liquidations,
2119 ] {
2120 assert!(KuCoin::subscribe_derivatives(&mut kucoin, &symbol(), channel).is_err());
2121 }
2122 assert!(KuCoin::open_interest(&kucoin, &symbol()).is_err());
2123 }
2124
2125 #[test]
2127 fn a_futures_client_subscribes_to_contract_topics() {
2128 let (mut kucoin, ws) = futures_ws_client();
2129 KuCoin::subscribe_trades(&mut kucoin, &symbol()).unwrap();
2130 KuCoin::subscribe_book(&mut kucoin, &symbol()).unwrap();
2131 KuCoin::subscribe_ticker(&mut kucoin, &symbol()).unwrap();
2132
2133 assert_eq!(ws.connected_urls()[0], "wss://ws-api-futures.kucoin.com/");
2134 assert!(ws.sent()[0].contains("/contractMarket/execution:XBTUSDTM"));
2135 assert!(ws.sent()[1].contains("/contractMarket/level2:XBTUSDTM"));
2136 assert!(ws.sent()[2].contains("/contractMarket/ticker:XBTUSDTM"));
2137 assert!(ws.sent().iter().all(|f| !f.contains("/market/")));
2138 }
2139
2140 #[test]
2145 fn a_futures_trade_frame_converts_its_nanosecond_stamp() {
2146 let (mut kucoin, ws) = futures_ws_client();
2147 ws.push_connection(vec![Ok(Some(
2148 r#"{"topic":"/contractMarket/execution:XBTUSDTM","type":"message",
2149 "subject":"match","sn":1942782214230,"data":{"symbol":"XBTUSDTM",
2150 "sequence":1942782214230,"side":"buy","size":104,"price":"81303.2",
2151 "takerOrderId":"485067687312056321","tradeId":"1942782214230",
2152 "ts":1788462350779000000}}"#
2153 .to_string(),
2154 ))]);
2155 KuCoin::subscribe_trades(&mut kucoin, &symbol()).unwrap();
2156
2157 let events = KuCoin::poll_events(&mut kucoin);
2158 let Event::Trade(trade) = &events[0] else {
2159 panic!("expected a trade, got {:?}", events[0]);
2160 };
2161 assert_eq!(trade.price, dec!(81303.2));
2162 assert_eq!(trade.quantity, dec!(104));
2164 assert_eq!(trade.aggressor, OrderSide::Buy);
2165 assert_eq!(trade.timestamp, 1_788_462_350_779);
2166 }
2167
2168 #[test]
2170 fn a_futures_book_change_is_one_csv_level() {
2171 let (mut kucoin, ws) = futures_ws_client();
2172 ws.push_connection(vec![
2173 Ok(Some(
2174 r#"{"topic":"/contractMarket/level2:XBTUSDTM","type":"message",
2175 "subject":"level2","sn":1747328682771,"data":{"sequence":1747328682771,
2176 "change":"81385.8,sell,0","timestamp":1788462349758}}"#
2177 .to_string(),
2178 )),
2179 Ok(Some(
2180 r#"{"topic":"/contractMarket/level2:XBTUSDTM","type":"message",
2181 "subject":"level2","sn":1747328682772,"data":{"sequence":1747328682772,
2182 "change":"81300.1,buy,42","timestamp":1788462349759}}"#
2183 .to_string(),
2184 )),
2185 ]);
2186 KuCoin::subscribe_book(&mut kucoin, &symbol()).unwrap();
2187
2188 let events = KuCoin::poll_events(&mut kucoin);
2189 let Event::BookDelta(removed) = &events[0] else {
2190 panic!("expected a delta, got {:?}", events[0]);
2191 };
2192 assert_eq!(removed.bids, Vec::new());
2193 assert_eq!(removed.asks[0].price, dec!(81385.8));
2194 assert_eq!(removed.asks[0].quantity, Decimal::ZERO);
2196 assert_eq!(removed.timestamp, 1_788_462_349_758);
2197
2198 let Event::BookDelta(added) = &events[1] else {
2199 panic!("expected a delta, got {:?}", events[1]);
2200 };
2201 assert_eq!(added.asks, Vec::new());
2202 assert_eq!(added.bids[0].quantity, dec!(42));
2203 }
2204
2205 #[test]
2208 fn a_futures_ticker_parses() {
2209 let (mut kucoin, ws) = futures_ws_client();
2210 ws.push_connection(vec![Ok(Some(
2211 r#"{"topic":"/contractMarket/ticker:XBTUSDTM","type":"message","subject":"ticker",
2212 "sn":1942783616429,"data":{"symbol":"XBTUSDTM","sequence":1942783616429,
2213 "side":"buy","size":1,"price":"81484.4","bestBidSize":4775,
2214 "bestBidPrice":"81484.3","bestAskPrice":"81487.7","bestAskSize":68,
2215 "ts":1788463466281000000}}"#
2216 .to_string(),
2217 ))]);
2218 KuCoin::subscribe_ticker(&mut kucoin, &symbol()).unwrap();
2219
2220 let events = KuCoin::poll_events(&mut kucoin);
2221 let Event::Ticker(ticker) = &events[0] else {
2222 panic!("expected a ticker, got {:?}", events[0]);
2223 };
2224 assert_eq!(ticker.last, dec!(81484.4));
2225 assert_eq!(ticker.bid, dec!(81484.3));
2226 assert_eq!(ticker.ask, dec!(81487.7));
2227 assert_eq!(ticker.volume, Decimal::ZERO);
2228 assert_eq!(ticker.timestamp, 1_788_463_466_281);
2229 }
2230
2231 #[test]
2233 fn a_spot_client_still_subscribes_to_market_topics() {
2234 let ws = Arc::new(MockWsTransport::new());
2235 let http = Arc::new(MockHttpTransport::new());
2236 let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
2237 let mut kucoin = KuCoin::with_http(Box::new(ArcTransport(http)), &opts)
2238 .with_ws(Box::new(ArcWs(Arc::clone(&ws))));
2239 KuCoin::subscribe_trades(&mut kucoin, &symbol()).unwrap();
2240
2241 assert_eq!(ws.connected_urls()[0], "wss://ws-api-spot.kucoin.com/");
2242 assert!(ws.sent()[0].contains("/market/match:BTC-USDT"));
2243 }
2244
2245 fn signed_client(now_ms: i64) -> (KuCoin, Arc<MockHttpTransport>) {
2246 let mock = Arc::new(MockHttpTransport::new());
2247 let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
2248 let kucoin = KuCoin::with_credentials(
2249 Box::new(ArcTransport(Arc::clone(&mock))),
2250 &opts,
2251 Credentials::new("APIKEY", "SECRET").with_passphrase("PASS"),
2252 )
2253 .with_clock(Box::new(move || now_ms));
2254 (kucoin, mock)
2255 }
2256
2257 fn signed_ws_client(now_ms: i64) -> (KuCoin, Arc<MockHttpTransport>, Arc<MockWsTransport>) {
2258 let http = Arc::new(MockHttpTransport::new());
2259 let ws = Arc::new(MockWsTransport::new());
2260 let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
2261 let kucoin = KuCoin::with_credentials(
2262 Box::new(ArcTransport(Arc::clone(&http))),
2263 &opts,
2264 Credentials::new("APIKEY", "SECRET").with_passphrase("PASS"),
2265 )
2266 .with_ws(Box::new(ArcWs(Arc::clone(&ws))))
2267 .with_clock(Box::new(move || now_ms));
2268 (kucoin, http, ws)
2269 }
2270
2271 fn signed_futures_ws_client(
2274 now_ms: i64,
2275 ) -> (KuCoin, Arc<MockHttpTransport>, Arc<MockWsTransport>) {
2276 let http = Arc::new(MockHttpTransport::new());
2277 let ws = Arc::new(MockWsTransport::new());
2278 let opts = ExchangeOptions::mainnet(crate::MarketType::UsdMFutures);
2279 let kucoin = KuCoin::with_credentials(
2280 Box::new(ArcTransport(Arc::clone(&http))),
2281 &opts,
2282 Credentials::new("APIKEY", "SECRET").with_passphrase("PASS"),
2283 )
2284 .with_ws(Box::new(ArcWs(Arc::clone(&ws))))
2285 .with_clock(Box::new(move || now_ms));
2286 (kucoin, http, ws)
2287 }
2288 #[test]
2295 fn a_futures_client_subscribes_to_the_contract_account_not_the_spot_one() {
2296 let (mut kucoin, http, ws) = signed_futures_ws_client(1_700_000_000_000);
2297 http.push_json(
2298 200,
2299 r#"{"code":"200000","data":{"token":"t","instanceServers":[{"endpoint":"wss://ws-api-futures.kucoin.com/"}]}}"#,
2300 );
2301 kucoin.subscribe_user_data().unwrap();
2302
2303 let negotiated = &http.recorded_requests()[0];
2304 assert!(
2305 negotiated.url.starts_with("https://api-futures.kucoin.com"),
2306 "{}",
2307 negotiated.url
2308 );
2309 assert!(negotiated.url.contains("/api/v1/bullet-private"));
2310 let sent = ws.sent();
2311 assert!(
2312 sent[0].contains("/contractMarket/tradeOrders"),
2313 "{}",
2314 sent[0]
2315 );
2316 assert!(sent[1].contains("/contractAccount/wallet"), "{}", sent[1]);
2317 assert!(
2318 !sent.iter().any(|frame| frame.contains("/spotMarket/")),
2319 "a futures client subscribed to a spot topic: {sent:?}"
2320 );
2321 }
2322
2323 #[test]
2326 fn a_contract_order_frame_becomes_an_order_update() {
2327 let (mut kucoin, http, ws) = signed_futures_ws_client(1_700_000_000_000);
2328 http.push_json(
2329 200,
2330 r#"{"code":"200000","data":{"token":"t","instanceServers":[{"endpoint":"wss://ws-api-futures.kucoin.com/"}]}}"#,
2331 );
2332 ws.push_connection(vec![Ok(Some(
2333 r#"{"type":"message","topic":"/contractMarket/tradeOrders","subject":"symbolOrderChange",
2334 "data":{"orderId":"f-1","symbol":"XBTUSDTM","type":"canceled","status":"done",
2335 "side":"sell","orderType":"limit","size":3,"filledSize":1,
2336 "price":"19000","clientOid":"mine"}}"#
2337 .to_string(),
2338 ))]);
2339 kucoin.subscribe_user_data().unwrap();
2340
2341 let events = kucoin.poll_events();
2342 let Some(Event::OrderUpdate(order)) = events.first() else {
2343 panic!("expected an order update, got {events:?}");
2344 };
2345 assert_eq!(order.id, "f-1");
2346 assert_eq!(order.symbol, Symbol::new("BTC", "USDT"));
2349 assert_eq!(order.status, OrderStatus::Canceled);
2352 assert_eq!(order.quantity, dec!(3));
2353 assert_eq!(order.filled_quantity, dec!(1));
2354 assert_eq!(order.client_order_id.as_deref(), Some("mine"));
2355 }
2356
2357 #[test]
2358 fn a_contract_wallet_frame_becomes_a_balance_update() {
2359 let (mut kucoin, http, ws) = signed_futures_ws_client(1_700_000_000_000);
2360 http.push_json(
2361 200,
2362 r#"{"code":"200000","data":{"token":"t","instanceServers":[{"endpoint":"wss://ws-api-futures.kucoin.com/"}]}}"#,
2363 );
2364 ws.push_connection(vec![Ok(Some(
2365 r#"{"type":"message","topic":"/contractAccount/wallet","subject":"availableBalance.change",
2366 "data":{"currency":"USDT","availableBalance":"120.5","holdBalance":"9.5"}}"#
2367 .to_string(),
2368 ))]);
2369 kucoin.subscribe_user_data().unwrap();
2370
2371 let events = kucoin.poll_events();
2372 let Some(Event::BalanceUpdate(balances)) = events.first() else {
2373 panic!("expected a balance update, got {events:?}");
2374 };
2375 assert_eq!(balances[0].asset, "USDT");
2376 assert_eq!(balances[0].free, dec!(120.5));
2377 assert_eq!(balances[0].locked, dec!(9.5));
2379 }
2380
2381 #[test]
2384 fn a_contract_name_maps_back_to_its_pair() {
2385 assert_eq!(
2386 futures_symbol_from_wire("XBTUSDTM"),
2387 Symbol::new("BTC", "USDT")
2388 );
2389 assert_eq!(
2390 futures_symbol_from_wire("ETHUSDCM"),
2391 Symbol::new("ETH", "USDC")
2392 );
2393 assert_eq!(
2394 futures_symbol_from_wire("XBTUSDM"),
2395 Symbol::new("BTC", "USD")
2396 );
2397 assert_eq!(
2399 futures_symbol_from_wire(&KuCoin::futures_symbol(&Symbol::new("SOL", "USDT"))),
2400 Symbol::new("SOL", "USDT")
2401 );
2402 assert_eq!(futures_symbol_from_wire("WEIRD"), Symbol::new("WEIRD", ""));
2404 }
2405
2406 #[test]
2407 fn subscribe_user_data_negotiates_token_and_streams_orders_and_balance() {
2408 let (mut kucoin, http, ws) = signed_ws_client(1000);
2409 http.push_json(
2410 200,
2411 r#"{"code":"200000","data":{"token":"tok","instanceServers":[
2412 {"endpoint":"wss://push.kucoin.com/endpoint"}]}}"#,
2413 );
2414 ws.push_connection(vec![
2415 Ok(Some(r#"{"type":"ack","id":"1000"}"#.to_string())),
2416 Ok(Some(
2417 r#"{"type":"message","topic":"/spotMarket/tradeOrders","subject":"orderChange",
2418 "data":{"symbol":"BTC-USDT","orderId":"55","clientOid":"my","side":"buy",
2419 "orderType":"limit","type":"filled","status":"done","size":"1","filledSize":"1",
2420 "price":"100"}}"#
2421 .to_string(),
2422 )),
2423 Ok(Some(
2424 r#"{"type":"message","topic":"/account/balance","subject":"account.balance",
2425 "data":{"currency":"USDT","available":"900","hold":"50","total":"950"}}"#
2426 .to_string(),
2427 )),
2428 ]);
2429 kucoin.subscribe_user_data().unwrap();
2430
2431 let reqs = http.recorded_requests();
2432 assert!(reqs[0].url.contains("/api/v1/bullet-private"));
2433 assert_eq!(reqs[0].method, HttpMethod::Post);
2434 assert_eq!(
2435 ws.connected_urls()[0],
2436 "wss://push.kucoin.com/endpoint?token=tok&connectId=1000"
2437 );
2438 assert!(ws.sent()[0].contains("/spotMarket/tradeOrders"));
2439 assert!(ws.sent()[1].contains("/account/balance"));
2440
2441 let events = kucoin.poll_events();
2442 assert_eq!(events.len(), 2);
2443 let Event::OrderUpdate(order) = &events[0] else {
2444 panic!("first event must be an order update");
2445 };
2446 assert_eq!(order.id, "55");
2447 assert_eq!(order.client_order_id.as_deref(), Some("my"));
2448 assert_eq!(order.symbol, symbol());
2449 assert_eq!(order.side, OrderSide::Buy);
2450 assert_eq!(order.status, OrderStatus::Filled);
2451 assert_eq!(order.filled_quantity, dec!(1));
2452 let Event::BalanceUpdate(balances) = &events[1] else {
2453 panic!("second event must be a balance update");
2454 };
2455 assert_eq!(balances.len(), 1);
2456 assert_eq!(balances[0].asset, "USDT");
2457 assert_eq!(balances[0].free, dec!(900));
2458 assert_eq!(balances[0].locked, dec!(50));
2459 }
2460
2461 #[test]
2462 fn subscribe_user_data_requires_a_passphrase() {
2463 let http = Arc::new(MockHttpTransport::new());
2464 let ws = Arc::new(MockWsTransport::new());
2465 let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
2466 let mut kucoin = KuCoin::with_credentials(
2467 Box::new(ArcTransport(http)),
2468 &opts,
2469 Credentials::new("APIKEY", "SECRET"),
2470 )
2471 .with_ws(Box::new(ArcWs(Arc::clone(&ws))));
2472 assert!(matches!(
2473 kucoin.subscribe_user_data().unwrap_err(),
2474 Error::InvalidCredentials(_)
2475 ));
2476 }
2477
2478 #[test]
2479 fn keepalive_user_data_pings_the_private_stream() {
2480 let (mut kucoin, http, ws) = signed_ws_client(1000);
2481 http.push_json(
2482 200,
2483 r#"{"code":"200000","data":{"token":"tok","instanceServers":[
2484 {"endpoint":"wss://push.kucoin.com/endpoint"}]}}"#,
2485 );
2486 ws.push_connection(vec![]);
2487 kucoin.subscribe_user_data().unwrap();
2488 kucoin.keepalive_user_data().unwrap();
2489 assert!(ws.sent().iter().any(|f| f.contains(r#""type":"ping""#)));
2490 }
2491
2492 #[test]
2493 fn keepalive_user_data_is_a_noop_before_subscribe() {
2494 let (mut kucoin, _http, ws) = signed_ws_client(1000);
2495 kucoin.keepalive_user_data().unwrap();
2496 assert_eq!(ws.sent(), Vec::<String>::new());
2497 }
2498
2499 #[test]
2500 fn dropped_user_data_stream_reconnects_with_a_fresh_bullet_token() {
2501 let (mut kucoin, http, ws) = signed_ws_client(1000);
2502 http.push_json(
2503 200,
2504 r#"{"code":"200000","data":{"token":"tok-1","instanceServers":[
2505 {"endpoint":"wss://push.kucoin.com/endpoint"}]}}"#,
2506 );
2507 ws.push_connection(vec![Ok(None)]);
2510 ws.push_connection(vec![]);
2511 kucoin.subscribe_user_data().unwrap();
2512 http.push_json(
2514 200,
2515 r#"{"code":"200000","data":{"token":"tok-2","instanceServers":[
2516 {"endpoint":"wss://push.kucoin.com/endpoint"}]}}"#,
2517 );
2518
2519 let events = kucoin.poll_events();
2520 assert!(events.contains(&Event::Disconnected));
2521 assert!(events.contains(&Event::Reconnected));
2522 let bullets = http
2524 .recorded_requests()
2525 .into_iter()
2526 .filter(|r| r.url.contains("/api/v1/bullet-private"))
2527 .count();
2528 assert_eq!(bullets, 2);
2529 assert_eq!(ws.connected_urls().len(), 2);
2530 assert!(ws.connected_urls()[1].contains("token=tok-2"));
2531 }
2532
2533 #[test]
2534 fn ws_execution_is_a_documented_gap() {
2535 let (mut kucoin, _mock) = signed_client(0);
2538 assert!(matches!(
2539 kucoin
2540 .place_order_ws(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
2541 .unwrap_err(),
2542 Error::Exchange { .. }
2543 ));
2544 assert!(matches!(
2545 kucoin.cancel_order_ws(&symbol(), "1").unwrap_err(),
2546 Error::Exchange { .. }
2547 ));
2548 }
2549
2550 fn signed_futures_client(now_ms: i64) -> (KuCoin, Arc<MockHttpTransport>) {
2551 let mock = Arc::new(MockHttpTransport::new());
2552 let opts = ExchangeOptions::mainnet(crate::MarketType::UsdMFutures);
2553 let kucoin = KuCoin::with_credentials(
2554 Box::new(ArcTransport(Arc::clone(&mock))),
2555 &opts,
2556 Credentials::new("APIKEY", "SECRET").with_passphrase("PASS"),
2557 )
2558 .with_clock(Box::new(move || now_ms));
2559 (kucoin, mock)
2560 }
2561
2562 #[test]
2569 fn a_spot_trigger_goes_to_the_stop_order_path_with_its_direction() {
2570 for (side, expected) in [(OrderSide::Sell, "loss"), (OrderSide::Buy, "entry")] {
2571 let (kucoin, mock) = signed_client(1_700_000_000_000);
2572 mock.push_json(200, r#"{"code":"200000","data":{"orderId":"s-1"}}"#);
2573 let request = OrderRequest {
2574 order_type: OrderType::StopMarket,
2575 stop_price: Some(dec!(19000)),
2576 side,
2577 ..OrderRequest::market_sell(symbol(), dec!(1))
2578 };
2579 KuCoin::place_order(&kucoin, &request).unwrap();
2580
2581 let sent = &mock.recorded_requests()[0];
2582 assert!(sent.url.contains("/api/v1/stop-order"), "{}", sent.url);
2583 let body = sent.body.clone().unwrap();
2584 assert!(body.contains(r#""stopPrice":"19000""#), "{body}");
2585 assert!(
2586 body.contains(&format!(r#""stop":"{expected}""#)),
2587 "a {side:?} stop fires the other way: {body}"
2588 );
2589 assert!(!body.contains("stopPriceType"), "{body}");
2591 }
2592 }
2593
2594 #[test]
2597 fn a_futures_trigger_stays_on_the_order_path_and_watches_the_mark() {
2598 let (kucoin, mock) = signed_futures_client(1_700_000_000_000);
2599 mock.push_json(200, r#"{"code":"200000","data":{"orderId":"f-1"}}"#);
2600 let request = OrderRequest {
2601 order_type: OrderType::StopLimit,
2602 stop_price: Some(dec!(19000)),
2603 ..OrderRequest::limit_sell(symbol(), dec!(1), dec!(18900))
2604 };
2605 KuCoin::place_order(&kucoin, &request).unwrap();
2606
2607 let sent = &mock.recorded_requests()[0];
2608 assert!(sent.url.contains("/api/v1/orders"), "{}", sent.url);
2609 assert!(!sent.url.contains("stop-order"), "{}", sent.url);
2610 let body = sent.body.clone().unwrap();
2611 assert!(body.contains(r#""stopPriceType":"MP""#), "{body}");
2612 assert!(body.contains(r#""price":"18900""#), "{body}");
2613 }
2614
2615 #[test]
2616 fn a_kucoin_trigger_without_a_stop_price_is_refused() {
2617 let (kucoin, mock) = signed_client(1_700_000_000_000);
2618 let request = OrderRequest {
2619 order_type: OrderType::StopMarket,
2620 stop_price: None,
2621 ..OrderRequest::market_sell(symbol(), dec!(1))
2622 };
2623 assert!(KuCoin::place_order(&kucoin, &request).is_err());
2624 assert_eq!(mock.recorded_requests(), Vec::new());
2625 }
2626 fn hedged_futures_client(now_ms: i64) -> (KuCoin, Arc<MockHttpTransport>) {
2627 let mock = Arc::new(MockHttpTransport::new());
2628 let mut opts = ExchangeOptions::mainnet(crate::MarketType::UsdMFutures);
2629 opts.position_mode = PositionMode::Hedge;
2630 let kucoin = KuCoin::with_credentials(
2631 Box::new(ArcTransport(Arc::clone(&mock))),
2632 &opts,
2633 Credentials::new("APIKEY", "SECRET").with_passphrase("PASS"),
2634 )
2635 .with_clock(Box::new(move || now_ms));
2636 (kucoin, mock)
2637 }
2638
2639 #[test]
2640 fn hedge_mode_is_refused_rather_than_ignored() {
2641 let (hedged, _mock) = hedged_futures_client(1000);
2645 let err = hedged
2646 .place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
2647 .unwrap_err();
2648 assert!(matches!(err, Error::Exchange { ref code, .. } if code == "unsupported"));
2649
2650 let (one_way, _) = signed_futures_client(1000);
2652 let _ = one_way.place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)));
2653 }
2654
2655 const KU_POSITIONS: &str = r#"{"code":"200000","data":[
2656 {"symbol":"XBTUSDTM","currentQty":3,"avgEntryPrice":20000.0,"markPrice":20100.0,"realLeverage":10.0,"unrealisedPnl":30.0,"crossMode":false}
2657 ]}"#;
2658
2659 #[test]
2660 fn stp_maps_to_stp_flag() {
2661 let (kucoin, mock) = signed_client(1000);
2662 mock.push_json(200, r#"{"code":"200000","data":{"orderId":"1"}}"#);
2663 kucoin
2664 .place_order(
2665 &OrderRequest::limit_buy(symbol(), dec!(1), dec!(100))
2666 .with_stp(SelfTradePrevention::ExpireBoth),
2667 )
2668 .unwrap();
2669 let reqs = mock.recorded_requests();
2670 assert!(reqs[0].body.as_ref().unwrap().contains(r#""stp":"CB""#));
2671 }
2672
2673 #[test]
2676 fn the_public_reads_carry_the_venue_stamp() {
2677 let (kucoin, mock) = signed_client(1000);
2678 mock.push_json(
2679 200,
2680 r#"{"code":"200000","data":{"time":1788396666570,"last":"77083.8","buy":"77083.7","sell":"77083.8","vol":"1234.5"}}"#,
2681 );
2682 let ticker = kucoin.ticker(&symbol()).unwrap();
2683 assert_eq!(ticker.timestamp, 1_788_396_666_570);
2684
2685 let (kucoin, mock) = signed_client(1000);
2686 mock.push_json(
2687 200,
2688 r#"{"code":"200000","data":{"time":1788396668372,"sequence":"36500054130","bids":[["77083.7","0.92"]],"asks":[["77083.8","0.06"]]}}"#,
2689 );
2690 let book = kucoin.order_book(&symbol(), 5).unwrap();
2691 assert_eq!(book.timestamp, 1_788_396_668_372);
2692 }
2693
2694 #[test]
2695 fn place_batch_multi_per_order_results() {
2696 let (kucoin, mock) = signed_client(1000);
2697 mock.push_json(
2698 200,
2699 r#"{"code":"200000","data":{"data":[
2700 {"orderId":"o1","status":"success"},
2701 {"orderId":"","status":"fail","failMsg":"insufficient"}]}}"#,
2702 );
2703 let results = kucoin
2704 .place_batch(&[
2705 OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)),
2706 OrderRequest::limit_buy(symbol(), dec!(1), dec!(101)),
2707 ])
2708 .unwrap();
2709 assert_eq!(results[0].as_ref().unwrap().id, "o1");
2710 assert!(matches!(
2711 results[1].as_ref().unwrap_err(),
2712 Error::OrderRejected { .. }
2713 ));
2714 assert!(mock.recorded_requests()[0]
2715 .url
2716 .contains("/api/v1/orders/multi"));
2717 }
2718
2719 #[test]
2720 fn cancel_batch_is_sequential() {
2721 let (kucoin, mock) = signed_client(1000);
2722 mock.push_json(200, r#"{"code":"200000","data":{}}"#);
2723 mock.push_json(200, r#"{"code":"200000","data":{}}"#);
2724 kucoin
2725 .cancel_batch(&symbol(), &["1".to_string(), "2".to_string()])
2726 .unwrap();
2727 assert_eq!(mock.recorded_requests().len(), 2);
2728 }
2729
2730 #[test]
2731 fn place_oco_is_a_single_order_list() {
2732 let (kucoin, mock) = signed_client(1000);
2733 mock.push_json(200, r#"{"code":"200000","data":{"orderId":"oco1"}}"#);
2734 let legs = kucoin
2735 .place_oco(&OcoRequest::new(
2736 symbol(),
2737 OrderSide::Sell,
2738 dec!(1),
2739 dec!(110),
2740 dec!(95),
2741 ))
2742 .unwrap();
2743 assert_eq!(legs.len(), 1);
2744 assert_eq!(legs[0].id, "oco1");
2745 let reqs = mock.recorded_requests();
2746 assert!(reqs[0].url.contains("/api/v3/oco/order"));
2747 assert!(reqs[0]
2748 .body
2749 .as_ref()
2750 .unwrap()
2751 .contains(r#""stopPrice":"95""#));
2752 }
2753
2754 #[test]
2755 fn amend_is_unsupported() {
2756 let (mut kucoin, _mock) = signed_client(1000);
2757 assert!(matches!(
2758 AdvancedOrders::amend_order(&mut kucoin, &symbol(), "1", Some(dec!(1)), None)
2759 .unwrap_err(),
2760 Error::Exchange { .. }
2761 ));
2762 }
2763
2764 #[test]
2765 fn futures_client_uses_futures_host_and_contract_symbol() {
2766 let (kucoin, _mock) = signed_futures_client(1000);
2767 assert!(kucoin.rest_base.contains("api-futures.kucoin.com"));
2768 assert_eq!(KuCoin::futures_symbol(&symbol()), "XBTUSDTM");
2769 }
2770
2771 #[test]
2772 fn futures_place_order_carries_leverage_and_contract_symbol() {
2773 let (mut kucoin, mock) = signed_futures_client(1000);
2774 kucoin.set_leverage(&symbol(), 5).unwrap();
2775 mock.push_json(200, r#"{"code":"200000","data":{"orderId":"9"}}"#);
2776 kucoin
2777 .place_order(&OrderRequest::market_buy(symbol(), dec!(2)))
2778 .unwrap();
2779 let reqs = mock.recorded_requests();
2780 let body = reqs[0].body.as_deref().unwrap();
2781 assert!(body.contains(r#""symbol":"XBTUSDTM""#));
2782 assert!(body.contains(r#""leverage":"5""#));
2783 assert!(reqs[0].url.contains("api-futures.kucoin.com"));
2784 }
2785
2786 #[test]
2787 fn derivatives_positions_parse_contracts() {
2788 let (mut kucoin, mock) = signed_futures_client(1000);
2789 mock.push_json(200, KU_POSITIONS);
2790 let positions = Derivatives::positions(&mut kucoin, Some(&symbol())).unwrap();
2791 assert_eq!(positions.len(), 1);
2792 assert_eq!(positions[0].symbol, Symbol::new("BTC", "USDT"));
2793 assert_eq!(positions[0].side, PositionSide::Long);
2794 assert_eq!(positions[0].quantity, dec!(3)); assert_eq!(positions[0].margin_mode, MarginMode::Isolated);
2796 assert!(mock.recorded_requests()[0]
2797 .url
2798 .contains("/api/v1/positions"));
2799 }
2800
2801 #[test]
2802 fn derivatives_set_margin_mode_changes_mode() {
2803 let (mut kucoin, mock) = signed_futures_client(1000);
2804 mock.push_json(200, r#"{"code":"200000","data":{}}"#);
2805 Derivatives::set_margin_mode(&mut kucoin, &symbol(), MarginMode::Cross).unwrap();
2806 let req = &mock.recorded_requests()[0];
2807 assert!(req.url.contains("/api/v1/position/changeMarginMode"));
2808 assert!(req
2809 .body
2810 .as_deref()
2811 .unwrap()
2812 .contains(r#""marginMode":"CROSS""#));
2813 }
2814
2815 #[test]
2816 fn derivatives_close_position_reduce_only() {
2817 let (mut kucoin, mock) = signed_futures_client(1000);
2818 mock.push_json(200, KU_POSITIONS);
2819 mock.push_json(200, r#"{"code":"200000","data":{"orderId":"9"}}"#);
2820 Derivatives::close_position(&mut kucoin, &symbol()).unwrap();
2821 let reqs = mock.recorded_requests();
2822 let body = reqs[1].body.as_deref().unwrap();
2823 assert!(body.contains(r#""side":"sell""#));
2824 assert!(body.contains(r#""reduceOnly":true"#));
2825 }
2826
2827 #[test]
2828 fn ticker_from_stats() {
2829 let (kucoin, mock) = client();
2830 mock.push_json(
2831 200,
2832 r#"{"code":"200000","data":{"last":"20000","buy":"19999","sell":"20001","vol":"1234"}}"#,
2833 );
2834 let ticker = kucoin.ticker(&symbol()).unwrap();
2835 assert_eq!(ticker.last, dec!(20000));
2836 assert_eq!(ticker.bid, dec!(19999));
2837 assert_eq!(ticker.ask, dec!(20001));
2838 }
2839
2840 #[test]
2841 fn klines_field_order_and_reverse() {
2842 let (kucoin, mock) = client();
2843 mock.push_json(
2845 200,
2846 r#"{"code":"200000","data":[
2847 ["1700000060","105","105.5","106","104","2","0"],
2848 ["1700000000","100","105","110","95","12","0"]]}"#,
2849 );
2850 let candles = kucoin.klines(&symbol(), "1h", 2).unwrap();
2851 assert_eq!(candles[0].timestamp, 1_700_000_000);
2852 assert!((candles[0].high - 110.0).abs() < 1e-9);
2854 assert!((candles[0].low - 95.0).abs() < 1e-9);
2855 assert!((candles[0].close - 105.0).abs() < 1e-9);
2856 }
2857
2858 #[test]
2859 fn order_book_parses() {
2860 let (kucoin, mock) = client();
2861 mock.push_json(
2862 200,
2863 r#"{"code":"200000","data":{"sequence":"55","bids":[["100","1"]],"asks":[["101","2"]]}}"#,
2864 );
2865 let book = kucoin.order_book(&symbol(), 20).unwrap();
2866 assert_eq!(book.last_update_id, 55);
2867 assert_eq!(book.bids[0], BookLevel::new(dec!(100), dec!(1)));
2868 }
2869
2870 #[test]
2871 fn error_mapping() {
2872 let (kucoin, mock) = client();
2873 mock.push_json(200, r#"{"code":"200004","msg":"balance","data":null}"#);
2874 assert!(matches!(
2875 kucoin.ticker(&symbol()).unwrap_err(),
2876 Error::InsufficientBalance
2877 ));
2878 }
2879
2880 #[test]
2881 fn place_order_signs_with_kc_headers_and_signed_passphrase() {
2882 let (kucoin, mock) = signed_client(1000);
2883 mock.push_json(200, r#"{"code":"200000","data":{"orderId":"99"}}"#);
2884 let order = kucoin
2885 .place_order(
2886 &OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)).with_client_order_id("abc"),
2887 )
2888 .unwrap();
2889 assert_eq!(order.id, "99");
2890 assert_eq!(order.client_order_id.as_deref(), Some("abc"));
2891
2892 let req = &mock.recorded_requests()[0];
2893 let header = |name: &str| {
2894 req.headers
2895 .iter()
2896 .find(|(k, _)| k == name)
2897 .map(|(_, v)| v.as_str())
2898 .unwrap()
2899 };
2900 let ts = header("KC-API-TIMESTAMP");
2901 assert_eq!(ts, "1000");
2902 let body = req.body.as_ref().unwrap();
2903 let prehash = format!("{ts}POST/api/v1/orders{body}");
2904 assert_eq!(
2905 header("KC-API-SIGN"),
2906 hmac_sha256_base64(b"SECRET", prehash.as_bytes())
2907 );
2908 assert_eq!(
2910 header("KC-API-PASSPHRASE"),
2911 hmac_sha256_base64(b"SECRET", b"PASS")
2912 );
2913 assert_eq!(header("KC-API-KEY-VERSION"), "2");
2914 }
2915
2916 #[test]
2926 fn place_order_generates_a_unique_client_oid_when_absent() {
2927 let (kucoin, mock) = signed_client(1000);
2928 mock.push_json(200, r#"{"code":"200000","data":{"orderId":"1"}}"#);
2929 mock.push_json(200, r#"{"code":"200000","data":{"orderId":"2"}}"#);
2930
2931 let first = kucoin
2932 .place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
2933 .unwrap();
2934 let second = kucoin
2935 .place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
2936 .unwrap();
2937
2938 let first_id = first.client_order_id.expect("an id was generated");
2939 let second_id = second.client_order_id.expect("an id was generated");
2940 assert!(
2945 first_id.starts_with("wkex-"),
2946 "the generated id must carry the wkex- prefix"
2947 );
2948 assert_ne!(
2949 first_id, second_id,
2950 "two orders on a frozen clock must not share a client id"
2951 );
2952 }
2953
2954 #[test]
2955 fn query_order_status_from_flags() {
2956 let (kucoin, mock) = signed_client(1000);
2957 mock.push_json(
2958 200,
2959 r#"{"code":"200000","data":{"id":"99","clientOid":"","symbol":"BTC-USDT",
2960 "side":"sell","type":"limit","size":"2","dealSize":"0","price":"100",
2961 "isActive":false,"cancelExist":false}}"#,
2962 );
2963 let order = kucoin.query_order(&symbol(), "99").unwrap();
2964 assert_eq!(order.status, OrderStatus::Filled);
2966 assert_eq!(order.side, OrderSide::Sell);
2967 }
2968
2969 #[test]
2970 fn balances_and_open_orders() {
2971 let (kucoin, mock) = signed_client(1000);
2972 mock.push_json(
2973 200,
2974 r#"{"code":"200000","data":[{"currency":"USDT","available":"100.5","holds":"25.5"}]}"#,
2975 );
2976 let bals = kucoin.balances().unwrap();
2977 assert_eq!(bals[0].total(), dec!(126));
2978
2979 mock.push_json(
2980 200,
2981 r#"{"code":"200000","data":{"items":[{"id":"7","clientOid":"","symbol":"ETH-USDT",
2982 "side":"buy","type":"limit","size":"1","dealSize":"0","price":"50",
2983 "isActive":true,"cancelExist":false}]}}"#,
2984 );
2985 let open = kucoin.open_orders(None).unwrap();
2986 assert_eq!(open[0].symbol, Symbol::new("ETH", "USDT"));
2987 assert_eq!(open[0].status, OrderStatus::New);
2988 }
2989
2990 #[test]
2991 fn signed_requires_passphrase() {
2992 let mock = Arc::new(MockHttpTransport::new());
2993 let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
2994 let kucoin = KuCoin::with_credentials(
2995 Box::new(ArcTransport(mock)),
2996 &opts,
2997 Credentials::new("k", "s"),
2998 );
2999 assert!(matches!(
3000 kucoin.balances().unwrap_err(),
3001 Error::InvalidCredentials(_)
3002 ));
3003 }
3004
3005 #[test]
3006 fn ws_parses_trade_ticker_book() {
3007 let ws = Arc::new(MockWsTransport::new());
3008 ws.push_connection(vec![
3009 Ok(Some(
3010 r#"{"type":"message","topic":"/market/match:BTC-USDT","subject":"trade.l3match",
3011 "data":{"symbol":"BTC-USDT","side":"buy","price":"100","size":"0.5","time":"1"}}"#
3012 .to_string(),
3013 )),
3014 Ok(Some(
3015 r#"{"type":"message","topic":"/market/level2:BTC-USDT","subject":"trade.l2update",
3016 "data":{"sequenceEnd":9,"changes":{"bids":[["100","1","9"]],"asks":[]}}}"#
3017 .to_string(),
3018 )),
3019 Ok(Some(r#"{"type":"welcome"}"#.to_string())),
3020 ]);
3021 let http = Arc::new(MockHttpTransport::new());
3022 let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
3023 let mut kucoin = KuCoin::with_http(Box::new(ArcTransport(http)), &opts)
3024 .with_ws(Box::new(ArcWs(Arc::clone(&ws))));
3025 kucoin.subscribe_trades(&symbol()).unwrap();
3026 assert!(ws.sent()[0].contains(r#""topic":"/market/match:BTC-USDT""#));
3027
3028 let events = kucoin.poll_events();
3029 assert_eq!(events.len(), 2);
3030 let Event::Trade(t) = &events[0] else {
3031 panic!("expected trade")
3032 };
3033 assert_eq!(t.aggressor, OrderSide::Buy);
3034 let Event::BookDelta(d) = &events[1] else {
3035 panic!("expected book delta")
3036 };
3037 assert_eq!(d.final_update_id, 9);
3038 }
3039
3040 #[test]
3041 fn works_as_a_boxed_exchange() {
3042 let (kucoin, mock) = signed_client(1000);
3043 mock.push_json(200, r#"{"code":"200000","data":{"orderId":"1"}}"#);
3044 let mut exchange: Box<dyn Exchange> = Box::new(kucoin);
3045 assert_eq!(exchange.name(), "kucoin");
3046 let order = exchange
3047 .place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
3048 .unwrap();
3049 assert_eq!(order.id, "1");
3050 }
3051
3052 #[test]
3053 fn system_clock_is_sane() {
3054 assert!(system_now_ms() > 1_600_000_000_000);
3055 }
3056
3057 #[test]
3061 fn debug_reports_state_without_credentials() {
3062 let (client, _http) = signed_client(1_700_000_000_000);
3063 let rendered = format!("{client:?}");
3064
3065 assert!(rendered.starts_with("KuCoin {"));
3066 assert!(rendered.contains("authenticated: true"));
3067 assert!(!rendered.contains("SECRET"));
3068 assert!(!rendered.contains("APIKEY"));
3069 }
3070
3071 #[test]
3078 fn sync_time_shifts_signed_timestamps() {
3079 let (mut kucoin, http) = signed_client(1_000_000);
3080
3081 http.push_json(200, r#"{"code":"200000","data":1004500}"#);
3083 let offset = kucoin.sync_time().expect("sync must succeed");
3084 assert_eq!(offset, 4_500, "the venue is 4.5 s ahead of this machine");
3085
3086 http.push_json(200, r#"{"code":"200000","data":[]}"#);
3087 kucoin.balances().expect("balances must succeed");
3088
3089 let requests = http.recorded_requests();
3090 let signed = requests.last().expect("a signed request was recorded");
3091 let header = signed
3092 .headers
3093 .iter()
3094 .find(|(name, _)| name == "KC-API-TIMESTAMP")
3095 .map(|(_, value)| value.as_str())
3096 .expect("signed request carries KC-API-TIMESTAMP");
3097 assert_eq!(header, "1004500", "the venue's time, not ours");
3098 }
3099
3100 #[test]
3108 fn rate_limit_carries_the_venues_advised_wait() {
3109 let (kucoin, mock) = client();
3110 mock.push_response(
3111 crate::transport::HttpResponse::new(
3112 200,
3113 r#"{"code":"429000","msg":"rate","data":null}"#,
3114 )
3115 .with_header("Retry-After", "2.5"),
3116 );
3117 let err = kucoin.ticker(&symbol()).unwrap_err();
3118 let wait = std::time::Duration::from_millis(2500);
3119 assert!(matches!(err, Error::RateLimited { retry_after: Some(d) } if d == wait));
3120 }
3121}