Skip to main content

wickra_exchange_core/exchanges/
kucoin.rs

1//! KuCoin (v1/v2 API) — the fifth exchange.
2//!
3//! KuCoin signs with base64(HMAC-SHA256) over `msTimestamp + METHOD + endpoint +
4//! body` under `KC-API-*` headers, and — unlike OKX/Bitget — the passphrase
5//! itself is HMAC-signed (`KC-API-PASSPHRASE = base64(HMAC-SHA256(secret,
6//! passphrase))`, key version 2). Symbols are dash-form (`BTC-USDT`); the
7//! envelope success code is the string `"200000"`. KuCoin candles are
8//! newest-first and ordered `[time, open, close, high, low, volume, turnover]`.
9//!
10//! [`AdvancedOrders`]: STP via the `stp` flag on order create, native spot batch
11//! place (`/api/v1/orders/multi`, per-order results), native OCO
12//! (`/api/v3/oco/order`, returned as one order-list). KuCoin has no
13//! batch-cancel-by-id (so `cancel_batch` cancels sequentially) and no in-place
14//! amend (`amend_order` is a documented gap).
15
16use 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
53/// A KuCoin client over injected transports.
54pub struct KuCoin {
55    http: Box<dyn HttpTransport>,
56    ws: Option<Box<dyn WsTransport>>,
57    rest_base: String,
58    market_type: MarketType,
59    /// One-way or hedge. KuCoin Futures is one-way only, so a hedged
60    /// configuration is refused rather than silently ignored.
61    position_mode: PositionMode,
62    credentials: Option<Credentials>,
63    now_ms: Box<dyn Fn() -> i64 + Send + Sync>,
64    /// Client order ids for orders the caller did not name.
65    ///
66    /// Seeded from the clock at construction and monotonic from there: the
67    /// seed keeps two clients in one process apart, the counter keeps two
68    /// orders in one millisecond apart. The previous fallback was the
69    /// millisecond alone, so a second order placed inside the same
70    /// millisecond carried an id the venue had already seen and was refused
71    /// as a duplicate.
72    client_ids: ClientIdGenerator,
73    /// Offset between this machine's clock and the venue's, applied to every
74    /// signed timestamp. Zero until [`sync_time`](Self::sync_time) is called.
75    clock: ServerClock,
76    connection: Option<Box<dyn WsConnection>>,
77    sub_messages: Vec<String>,
78    subscriptions: Vec<(String, Symbol)>,
79    /// Which derivatives channels are subscribed, per contract.
80    ///
81    /// `/contract/instrument` carries both subjects, so one subscription
82    /// delivers frames for the other; only what was asked for is emitted.
83    derivatives_channels: Vec<(String, DerivativesChannel)>,
84    /// Leverage applied to futures orders. KuCoin sets leverage per order rather
85    /// than per account, so [`set_leverage`](Self::set_leverage) records it here.
86    leverage: u32,
87    /// The private user-data connection, opened by
88    /// [`subscribe_user_data`](Self::subscribe_user_data) and drained by
89    /// [`poll_events`](Self::poll_events) alongside the public stream.
90    private_connection: Option<Box<dyn WsConnection>>,
91    /// Set once the private stream is subscribed, so [`poll_events`](Self::poll_events)
92    /// re-subscribes it (re-negotiating a fresh bullet token) after a drop.
93    user_data_active: bool,
94}
95
96/// Hand-written: the client holds `Box<dyn HttpTransport>`, `Box<dyn WsTransport>`
97/// and a `Box<dyn Fn() -> i64>` clock, none of which a derive can reach. The
98/// transports are shown by whether a connection is open rather than by value, and
99/// the credentials only by whether they are set -- printing them would put secret
100/// material into every log line that formats a client.
101impl 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    /// Refuse a market this client does not route.
121    ///
122    /// Called at every seam that reaches the venue -- the HTTP helpers and each
123    /// WebSocket connect -- so an unrouted market is refused before a request
124    /// is built rather than answered by whichever market the URL happened to
125    /// name. See [`ensure_market_is_routed`](super::ensure_market_is_routed)
126    /// for what each client used to answer instead.
127    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    /// The timestamp a signed request must carry: this machine's clock plus the
165    /// offset learned from the venue.
166    ///
167    /// A venue rejects a signed request whose timestamp falls outside its own
168    /// receive window, so a machine a few seconds off has every order refused --
169    /// with a message about the window rather than about the clock. Until
170    /// [`sync_time`](Self::sync_time) is called the offset is zero and this is
171    /// the local time, which is the previous behaviour.
172    fn signed_now_ms(&self) -> i64 {
173        self.clock.server_time_ms((self.now_ms)())
174    }
175
176    /// Learn the offset between this machine's clock and the venue's, from
177    /// `GET /api/v1/timestamp`.
178    ///
179    /// Explicit rather than automatic: it costs a request, and a client should
180    /// not make one the caller did not ask for. Call it once after connecting,
181    /// and again if the process runs long enough for drift to matter.
182    ///
183    /// Returns the new offset in milliseconds (`server - local`).
184    ///
185    /// # Errors
186    /// Returns an [`Error`] if the request fails or the response cannot be parsed.
187    pub fn sync_time(&mut self) -> Result<i64> {
188        // unwrap_envelope hands back `data`, which here is a bare number rather
189        // than an object: {"code":"200000","data":1788295957789}.
190        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    /// Whether this client targets KuCoin Futures (a separate host and API with
200    /// contract symbols like `XBTUSDTM`) rather than spot.
201    fn is_futures(&self) -> bool {
202        self.market_type.is_derivatives()
203    }
204
205    /// Refuse a futures order on a client configured for hedge mode.
206    ///
207    /// KuCoin Futures holds one net position per contract and has no
208    /// hedge (dual-side) mode, so there is no
209    /// field on the order that could carry the side. Placing the order anyway
210    /// would open or close the single net position -- which is what the caller
211    /// asked to avoid by configuring hedge. Same treatment as
212    /// `set_margin_mode(Isolated)` here: say so rather than do something else.
213    ///
214    /// # Errors
215    /// Returns [`Error::Exchange`] on a futures client in
216    /// [`PositionMode::Hedge`](crate::PositionMode).
217    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    /// The KuCoin **futures** contract symbol for a canonical [`Symbol`]:
229    /// `BTC/USDT` -> `XBTUSDTM` (KuCoin uses `XBT` for Bitcoin and a trailing
230    /// `M` for the USDⓈ-M perpetual).
231    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    /// Build a public KuCoin client.
241    #[must_use]
242    pub fn with_http(http: Box<dyn HttpTransport>, options: &ExchangeOptions) -> Self {
243        Self::build(http, options, None)
244    }
245
246    /// Build an authenticated KuCoin client (credentials must carry a passphrase).
247    #[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    /// Override the timestamp source (deterministic signing in tests).
257    #[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    /// Attach a WebSocket transport.
264    #[must_use]
265    pub fn with_ws(mut self, ws: Box<dyn WsTransport>) -> Self {
266        self.ws = Some(ws);
267        self
268    }
269
270    /// The KuCoin wire symbol for a canonical [`Symbol`] (`BTC/USDT` -> `BTC-USDT`).
271    #[must_use]
272    pub fn wire_symbol(symbol: &Symbol) -> String {
273        format!("{}-{}", symbol.base(), symbol.quote())
274    }
275
276    /// A ticker for `symbol` (from the 24-hour stats endpoint).
277    ///
278    /// # Errors
279    /// Returns an [`Error`] if the request fails or the symbol is unknown.
280    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    /// Up to `limit` candles for `symbol` at `interval` (unified). KuCoin returns
295    /// newest-first; the result is chronological.
296    ///
297    /// # Errors
298    /// Returns an [`Error`] if the request fails or a row cannot be parsed.
299    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    /// A depth snapshot of `symbol` (top 20 levels per side).
316    ///
317    /// # Errors
318    /// Returns an [`Error`] if the request fails or the response cannot be parsed.
319    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    /// Subscribe to a pushed derivatives channel.
333    ///
334    /// KuCoin publishes both on `/contract/instrument`, under two subjects:
335    /// `funding.rate` and `mark.index.price`. The funding subject carries no
336    /// price, so a funding print reports a mark of zero rather than one taken
337    /// from the other subject at another moment.
338    ///
339    /// `Liquidations` is refused: KuCoin publishes no public stream of forced
340    /// orders.
341    ///
342    /// # Errors
343    /// Returns an [`Error`] on a spot client, for `Liquidations`, or if the
344    /// subscription fails.
345    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    /// Subscribe to `/contract/instrument`, which is not a `/market/` topic and
376    /// so does not go through the spot-topic mapping.
377    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    /// The current open interest (`GET /api/v1/contracts/<contract>`).
403    ///
404    /// Polled, not pushed. KuCoin reports it in contracts and publishes the
405    /// contract's `multiplier` in the same reply, so the base-currency figure
406    /// is exact arithmetic on one reading rather than two.
407    ///
408    /// # Errors
409    /// Returns an [`Error`] on a spot client, if the request fails, or if the
410    /// reply carries neither figure.
411    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    /// KuCoin publishes no public long/short positioning figure.
435    ///
436    /// # Errors
437    /// Always returns an [`Error`]: there is nothing to read.
438    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    /// Subscribe to the public trade stream for `symbol`.
447    ///
448    /// # Errors
449    /// Returns [`Error::NotConnected`] if no WebSocket transport is configured.
450    pub fn subscribe_trades(&mut self, symbol: &Symbol) -> Result<()> {
451        self.subscribe(symbol, "/market/match")
452    }
453
454    /// Subscribe to the order-book stream for `symbol`.
455    ///
456    /// # Errors
457    /// See [`subscribe_trades`](Self::subscribe_trades).
458    pub fn subscribe_book(&mut self, symbol: &Symbol) -> Result<()> {
459        self.subscribe(symbol, "/market/level2")
460    }
461
462    /// Subscribe to the ticker stream for `symbol`.
463    ///
464    /// # Errors
465    /// See [`subscribe_trades`](Self::subscribe_trades).
466    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        // Spot and futures are different hosts with differently named topics.
473        // The topic arrives spelled `/market/<name>`; on a futures client it is
474        // mapped once here rather than at each call site.
475        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            // The bullet-token negotiation and instance endpoint are handled by
483            // the real transport adapter; the module only produces the topics.
484            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    /// Drain all stream events available since the last call. Non-blocking.
511    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        // Drain the private user-data stream (tradeOrders/account.balance), if open.
532        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        // A dropped private stream is re-subscribed by re-negotiating a fresh
540        // bullet-private token over REST (the previous token is single-use and
541        // time-bound, so it cannot be replayed).
542        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    /// Open the private user-data stream. Negotiates a bullet-private token over
566    /// REST (`POST /api/v1/bullet-private`, signed), connects the returned
567    /// instance endpoint (`<endpoint>?token=<token>&connectId=<id>`), then
568    /// subscribes to the account's own order and balance channels -- on spot
569    /// `/spotMarket/tradeOrders` and `/account/balance`, on futures
570    /// `/contractMarket/tradeOrders` and `/contractAccount/wallet`. A futures
571    /// client negotiates its token against the futures host, which serves the
572    /// same `/api/v1/bullet-private` path. Afterwards [`poll_events`](Self::poll_events) also surfaces the
573    /// account's own [`Event::OrderUpdate`] and [`Event::BalanceUpdate`].
574    ///
575    /// The bullet token expires;
576    /// [`keepalive_user_data`](Self::keepalive_user_data) sends KuCoin's ping to
577    /// keep it alive, and a dropped stream is re-subscribed automatically on the
578    /// next [`poll_events`](Self::poll_events), which re-negotiates a fresh token.
579    ///
580    /// # Errors
581    /// Returns [`Error::InvalidCredentials`] without credentials or a passphrase,
582    /// [`Error::NotConnected`] without a WebSocket transport, or another
583    /// [`Error`] if the token negotiation or subscription fails.
584    pub fn subscribe_user_data(&mut self) -> Result<()> {
585        self.ensure_market_is_routed()?;
586        // Spot and futures are separate accounts on separate hosts: a futures
587        // order never appears in the spot account, so watching the wrong one is
588        // waiting for a fill that cannot arrive. `rest_base` already points at
589        // the market's host, and both serve the same negotiation path.
590        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    /// Send KuCoin's application-level heartbeat (`{"id":..,"type":"ping"}`) on the
622    /// private stream so the bullet token is not dropped for inactivity. A no-op
623    /// before [`subscribe_user_data`](Self::subscribe_user_data).
624    ///
625    /// # Errors
626    /// Returns an [`Error`] if the ping cannot be sent.
627    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    /// Place an order. KuCoin requires a client order id; one is generated from
636    /// the clock when the request does not supply it.
637    ///
638    /// # Errors
639    /// Returns an [`Error`] if the order is invalid, credentials are missing, or
640    /// the venue rejects it.
641    /// Refuse `reduce_only` where there is no position to reduce.
642    ///
643    /// A spot account holds balances, not positions, so a reduce-only order has
644    /// nothing to close. Dropping the flag would place an opening order where a
645    /// closing one was asked for -- the opposite trade -- so it is refused, the
646    /// way Binance's spot path and Upbit already refuse it.
647    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    /// Add KuCoin's trigger fields to an order body.
659    ///
660    /// `stop` says **which way the market must cross the trigger**, and KuCoin
661    /// will not infer it. `loss` fires when the market moves against the side
662    /// -- down for a sell, up for a buy -- which is what a stop-loss means;
663    /// `entry` fires the other way, which is a breakout entry. The wrong one is
664    /// a different order that happens to use the same price.
665    ///
666    /// On futures, `stopPriceType` names the price that arms it: `MP` is the
667    /// mark, which is what a position is liquidated against, so it is what a
668    /// stop there is meant to watch. Spot has no mark and takes no such field.
669    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            // Futures orders carry the per-order leverage; size is in contracts.
718            body["leverage"] = serde_json::json!(self.leverage.to_string());
719            if request.reduce_only {
720                body["reduceOnly"] = serde_json::json!(true);
721            }
722        }
723        // Spot serves trigger orders from their own path; futures takes them on
724        // this one, with three extra fields. The body above is shared, so the
725        // two cannot drift on everything that is not the trigger.
726        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    /// Cancel an open order by venue id.
753    ///
754    /// # Errors
755    /// Returns an [`Error`] if credentials are missing or the venue rejects it.
756    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    /// Query a single order by venue id.
763    ///
764    /// # Errors
765    /// Returns an [`Error`] if credentials are missing or the order is unknown.
766    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    /// All open orders, optionally filtered to one `symbol`.
774    ///
775    /// # Errors
776    /// Returns an [`Error`] if credentials are missing or the request fails.
777    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    /// Trade-account balances.
799    ///
800    /// # Errors
801    /// Returns an [`Error`] if credentials are missing or the request fails.
802    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    /// Sign with the `KC-API-*` headers (key version 2, HMAC-signed passphrase).
823    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
924/// The KuCoin `stp` flag for a self-trade-prevention policy, or `None` to omit.
925/// KuCoin cancels the oldest (`CO`), newest (`CN`) or both (`CB`) order.
926/// The KuCoin `timeInForce` value. KuCoin carries it as its own field beside
927/// `postOnly`, so the two never conflict; `Gtc` is KuCoin's default and is left
928/// off the wire.
929fn 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    // KuCoin candle: [time, open, close, high, low, volume, turnover].
992    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    // Note the KuCoin field order: open, close, high, low.
1004    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
1048/// Read a `u64` that a venue may encode as either a JSON number or a string.
1049fn 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
1083/// An integer field, or zero. KuCoin's futures frames send sizes and stamps as
1084/// JSON numbers where its spot frames send strings.
1085fn 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
1092/// KuCoin Futures' WebSocket host.
1093const FUTURES_WS_URL: &str = "wss://ws-api-futures.kucoin.com/";
1094
1095/// The futures topic that answers the same question as a spot one.
1096///
1097/// The two streams are not the same protocol with a prefix swapped -- the
1098/// payloads differ as well -- but the mapping of *what is asked for* is
1099/// one-to-one, so it lives in one place.
1100fn 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
1108/// KuCoin Futures stamps in nanoseconds where its spot stream uses
1109/// milliseconds. Reporting the raw figure would put every futures event about
1110/// 56,000 years into the future.
1111fn futures_millis(raw: i64) -> i64 {
1112    raw / 1_000_000
1113}
1114
1115/// One level from `/contractMarket/level2`, which sends `"price,side,size"` as
1116/// a single string rather than a bids/asks object. A size of zero removes the
1117/// level, which is what the delta type already means.
1118fn 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); // ack/welcome/pong
1139    }
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    // --- futures ------------------------------------------------------------
1149    if topic.starts_with("/contract/instrument:") {
1150        // One topic, two subjects. `funding.rate` carries no price, so a
1151        // funding print reports a mark of zero rather than one taken from the
1152        // other subject at a different moment.
1153        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        // `size` is an integer contract count and `ts` is in nanoseconds.
1195        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            // The futures ticker publishes no traded volume; inventing one from
1209            // the print size would be a different figure wearing the same name.
1210            volume: Decimal::ZERO,
1211            timestamp: futures_millis(opt_i64(data, "ts")),
1212        })));
1213    } else if topic.starts_with("/contractMarket/level2:") {
1214        // One level per frame, as `"price,side,size"`.
1215        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        // One currency per frame, the way the spot balance channel sends it.
1275        // `holdBalance` is the margin already committed to open orders and
1276        // positions, which is what `locked` means everywhere else here.
1277        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
1293/// Turn a KuCoin **futures** contract symbol back into a [`Symbol`].
1294///
1295/// `XBTUSDTM` -> `BTC/USDT`: KuCoin spells Bitcoin `XBT` and marks the
1296/// perpetual with a trailing `M`. The private order stream carries this form
1297/// and is not subscribed per symbol, so there is no subscription to look the
1298/// symbol up in -- it has to be read back out of the wire name.
1299fn futures_symbol_from_wire(wire: &str) -> Symbol {
1300    let stem = wire.strip_suffix('M').unwrap_or(wire);
1301    // Longest first: `USDT` and `USDC` both end in a quote that is also a quote.
1302    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
1313/// Map a `/contractMarket/tradeOrders` status to an [`OrderStatus`].
1314///
1315/// The futures channel says less than the spot one: `done` covers both a fill
1316/// and a cancel, and only the event `type` separates them. Reading `status`
1317/// alone would report every cancelled order as filled.
1318fn 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
1327/// Build an [`Order`] from a `/contractMarket/tradeOrders` message payload.
1328fn 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        // Futures sizes are contract counts and arrive as numbers, not strings.
1338        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
1345/// Read a field the futures stream may send as either a number or a string.
1346fn 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
1360/// Map a `/spotMarket/tradeOrders` status (with the event `type` disambiguating a
1361/// finished order) to an [`OrderStatus`].
1362fn 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
1371/// Build an [`Order`] from a `/spotMarket/tradeOrders` message payload.
1372fn 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/// The bullet-private token negotiation response.
1391#[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    /// Verified against the live endpoint: KuCoin stamps this payload.
1415    #[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    /// Verified against the live endpoint: KuCoin stamps this payload.
1426    #[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    /// Open futures positions (`/api/v1/positions` on the futures host); flat
1519    /// positions are omitted. Quantities are in **contracts**, KuCoin's native
1520    /// futures unit.
1521    ///
1522    /// # Errors
1523    /// Returns an [`Error`] if credentials are missing or the request fails.
1524    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    /// Record the leverage applied to subsequent futures orders. KuCoin has no
1534    /// standalone set-leverage endpoint — leverage is a per-order field — so this
1535    /// stores it locally rather than issuing a request.
1536    ///
1537    /// # Errors
1538    /// Never errors; the signature matches the [`Derivatives`] trait.
1539    pub fn set_leverage(&mut self, _symbol: &Symbol, leverage: u32) -> Result<()> {
1540        self.leverage = leverage.max(1);
1541        Ok(())
1542    }
1543
1544    /// Set the margin mode for `symbol` (`/api/v1/position/changeMarginMode`).
1545    ///
1546    /// # Errors
1547    /// Returns an [`Error`] if the change is rejected or the request fails.
1548    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    /// Flatten the open position in `symbol` with a reduce-only market order
1567    /// (size in contracts).
1568    ///
1569    /// # Errors
1570    /// Returns [`Error::NotFound`] if there is no open position, or another
1571    /// [`Error`] if the request fails.
1572    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    /// Place several spot orders on one symbol in one request
1588    /// (`/api/v1/orders/multi`). KuCoin returns the results in request order, so
1589    /// each element maps to its request's own [`Result`].
1590    ///
1591    /// # Errors
1592    /// Returns an [`Error`] if the batch request itself fails, or if called on a
1593    /// futures client (multi is a spot endpoint).
1594    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    /// Cancel several orders by id. KuCoin has no batch-cancel-by-id endpoint, so
1679    /// the ids are cancelled sequentially.
1680    ///
1681    /// # Errors
1682    /// Returns an [`Error`] if any cancel fails.
1683    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    /// Place a one-cancels-other bracket (`/api/v3/oco/order`). KuCoin models an
1691    /// OCO as one order-list, so the returned vector holds one order carrying the
1692    /// list id.
1693    ///
1694    /// # Errors
1695    /// Returns an [`Error`] if the OCO is invalid or rejected.
1696    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    /// KuCoin has no in-place amend (orders are cancelled and re-placed), so this
1733    /// returns an [`Error::Exchange`].
1734    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    /// KuCoin exposes no WebSocket order-entry API — its WebSocket surface is
1790    /// subscription-only (market data plus the private `tradeOrders`/`balance`
1791    /// push channels). Orders are placed over REST, so this returns a documented
1792    /// [`Error::Exchange`].
1793    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
1843/// Reconstruct a canonical [`Symbol`] from a KuCoin futures contract symbol
1844/// (`XBTUSDTM` -> `BTC/USDT`): drop the trailing `M`, split off a known quote
1845/// suffix, and map `XBT` back to `BTC`.
1846fn 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; // flat position
1880    }
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    /// The answers KuCoin gives on a bad day.
1946    #[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        // A contract with no multiplier: the base-currency figure cannot be
1953        // derived, and guessing one would be a fabricated size.
1954        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    /// Frames the futures parser cannot use are dropped.
1965    #[test]
1966    fn kucoin_drops_futures_frames_it_cannot_read() {
1967        let (mut kucoin, ws) = futures_ws_client();
1968        ws.push_connection(vec![
1969            // A book change that is not `price,side,size`.
1970            Ok(Some(
1971                r#"{"topic":"/contractMarket/level2:XBTUSDTM","type":"message",
1972                "subject":"level2","data":{"sequence":1,"change":"nonsense"}}"#
1973                    .to_string(),
1974            )),
1975            // A change naming a side the venue does not use.
1976            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            // An instrument frame with an unknown subject.
1982            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    /// An instrument frame missing its prices is dropped rather than half-read.
1996    #[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    /// Both channels arrive on one topic, under two subjects.
2019    #[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        // One subscription frame: the second channel is on the same topic.
2040        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        // The funding subject carries no price, so the mark is zero rather than
2055        // one borrowed from the other subject at another moment.
2056        assert_eq!(funding.mark_price, Decimal::ZERO);
2057    }
2058
2059    /// A frame for a channel that was not subscribed to is not emitted.
2060    #[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    /// Open interest is in contracts; the multiplier arrives in the same reply,
2076    /// so the base-currency figure is arithmetic on one reading.
2077    #[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    /// KuCoin publishes neither forced orders nor a positioning figure.
2095    #[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    /// Host, topic and symbol all follow the market.
2126    #[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    /// KuCoin Futures stamps in **nanoseconds**.
2141    ///
2142    /// Carrying the figure through unchanged would put every futures event
2143    /// roughly 56,000 years into the future, and every staleness check with it.
2144    #[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        // Contracts, which is the unit the futures order path takes.
2163        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    /// The futures book sends one level as `"price,side,size"`, not an object.
2169    #[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        // A size of zero removes the level, which the delta type already means.
2195        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    /// The futures ticker carries a last price and a top of book, and no volume
2206    /// — so volume stays zero rather than borrowing the print size.
2207    #[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    /// The spot client keeps its own host and topics.
2232    #[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    /// The same client on the futures host, which is a separate account with
2272    /// separate topics.
2273    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    /// A futures client watches the futures account, not the spot one.
2289    ///
2290    /// Its own orders never appear in the spot account, so subscribing there is
2291    /// waiting for a fill that cannot arrive. The negotiation is the same call
2292    /// on the futures host, which was verified live: `bullet-public` on
2293    /// `api-futures.kucoin.com` answers with an instance server.
2294    #[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    /// The futures order frame names the contract, not the pair, and says less
2324    /// than the spot one: `done` covers a fill and a cancel alike.
2325    #[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        // `XBTUSDTM` is BTC/USDT: KuCoin spells Bitcoin XBT and marks the
2347        // perpetual with a trailing M.
2348        assert_eq!(order.symbol, Symbol::new("BTC", "USDT"));
2349        // `done` with a `canceled` type is a cancel; reading the status alone
2350        // would report it as filled.
2351        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        // Margin committed to open orders and positions is locked, not free.
2378        assert_eq!(balances[0].locked, dec!(9.5));
2379    }
2380
2381    /// A futures contract name maps back to the pair it was built from, and an
2382    /// unknown one is not silently turned into a pair that does not exist.
2383    #[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        // Round-trips with the name the client sends.
2398        assert_eq!(
2399            futures_symbol_from_wire(&KuCoin::futures_symbol(&Symbol::new("SOL", "USDT"))),
2400            Symbol::new("SOL", "USDT")
2401        );
2402        // Nothing recognisable: kept whole rather than split at a guess.
2403        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        // The first private connection closes on the first recv; the reconnect
2508        // target is a fresh open connection.
2509        ws.push_connection(vec![Ok(None)]);
2510        ws.push_connection(vec![]);
2511        kucoin.subscribe_user_data().unwrap();
2512        // The reconnect re-negotiates a fresh bullet token over REST.
2513        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        // Two bullet-private POSTs (initial + reconnect) and a second WS connection.
2523        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        // KuCoin has no WebSocket order-entry API; the trait methods return a
2536        // documented error rather than faking a round trip.
2537        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    /// Spot triggers go to their own path; `stop` says which way the market has
2563    /// to cross the trigger and KuCoin will not infer it.
2564    ///
2565    /// `loss` fires when the market moves against the side -- down for a sell --
2566    /// which is what a stop-loss means. `entry` fires the other way, which is a
2567    /// breakout entry: a different order that happens to use the same price.
2568    #[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            // Spot has no mark price, so it takes no `stopPriceType`.
2590            assert!(!body.contains("stopPriceType"), "{body}");
2591        }
2592    }
2593
2594    /// Futures takes the trigger on the ordinary order path, and names the mark
2595    /// as the price that arms it -- the price a position is liquidated against.
2596    #[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        // KuCoin Futures is one-way only: there is no field on the order that
2642        // could carry a side. Placing it anyway would move the single net
2643        // position, which is what configuring hedge asked to avoid.
2644        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        // One-way on the same venue still places orders.
2651        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    /// KuCoin stamps both public reads with `data.time`. Payloads captured from
2674    /// the live endpoints.
2675    #[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)); // contracts
2795        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        // [time, open, close, high, low, volume, turnover], newest-first.
2844        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        // open=100, high=110, low=95, close=105.
2853        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        // The passphrase header is itself HMAC-signed.
2909        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    /// An unnamed order still gets a client id, and two of them get *different*
2917    /// ones.
2918    ///
2919    /// The generated id used to be the millisecond alone, so two orders placed
2920    /// inside the same millisecond carried the same id -- and KuCoin
2921    /// deduplicates on it, so the second was refused as a repeat of the first.
2922    /// The assertion is on that property rather than on an exact string: the
2923    /// value is seeded from the clock, and pinning it would only re-test the
2924    /// seed.
2925    #[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        // The id is not interpolated into the message. CodeQL traces any
2941        // value out of a credentials-holding client as sensitive, and the
2942        // rule is right in spirit even where this particular value is a
2943        // counter: a test message is still output.
2944        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        // Inactive, not cancelled -> filled.
2965        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    /// `Debug` reports connection state, never secret material. A client is
3058    /// formatted into logs and error messages, so anything it prints is
3059    /// somewhere a credential must not be.
3060    #[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    /// The clock offset must reach the wire, not just the struct.
3072    ///
3073    /// A venue refuses a signed request whose timestamp is outside its own
3074    /// receive window, so a machine a few seconds off has every order rejected
3075    /// -- with a message about the window, not about the clock. This asserts the
3076    /// whole path: sync, then a signed request carrying the adjusted time.
3077    #[test]
3078    fn sync_time_shifts_signed_timestamps() {
3079        let (mut kucoin, http) = signed_client(1_000_000);
3080
3081        // Shape verified against the live public endpoint on 2026-09-01.
3082        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    /// A rate-limited response carries the venue's advised wait.
3101    ///
3102    /// The limit itself is recognised from the body -- a code in the error
3103    /// envelope -- while the wait arrives in the `Retry-After` header, so the
3104    /// two are read in different places. Until they were joined, every
3105    /// `RateLimited` this client raised carried `retry_after: None`: a field
3106    /// the error type documents, and that nothing in the crate ever filled.
3107    #[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}