Skip to main content

wickra_exchange_core/exchanges/
gate.rs

1//! Gate.io (v4 API) — the sixth exchange.
2//!
3//! Gate signs with `SIGN = hex(HMAC-SHA512(secret, sig_string))`, where
4//! `sig_string = METHOD\npath\nquery\nhex(SHA512(body))\ntimestamp` (unix
5//! seconds), carried in `KEY`/`SIGN`/`Timestamp` headers. Symbols use an
6//! underscore (`BTC_USDT`) and there is no response envelope — success is the raw
7//! JSON, errors come back as an HTTP error status with `{label, message}`.
8//!
9//! When built with a futures [`MarketType`](crate::MarketType), market data,
10//! `place_order`, `balances` and the [`Derivatives`] trait route to the
11//! USDT-margined perpetual endpoints (`/api/v4/futures/usdt/*`), where orders
12//! carry a signed integer contract `size`. `query_order`/`cancel_order`/
13//! `open_orders` route to the futures order endpoints too, parsing the futures
14//! order object (numeric id, signed `size`, `finish_as`) via
15//! `futures_order_from_raw`.
16//!
17//! [`AdvancedOrders`]: STP via `stp_act` on order create, native in-place amend
18//! (`PATCH /api/v4/spot/orders/{id}`) and native spot batch place/cancel
19//! (`/api/v4/spot/batch_orders`, `/cancel_batch_orders`, per-order `succeeded`
20//! flag). Gate has no OCO order-list, so `place_oco` is a documented gap.
21
22use crate::clock::ServerClock;
23use crate::credentials::Credentials;
24use crate::error::{Error, Result};
25use crate::events::{BookDelta, BookLevel, Event, OrderBookSnapshot, TradePrint};
26use crate::feeds::{
27    DerivativesChannel, DerivativesFeed, FundingRate, LongShortRatio, MarkIndex, OpenInterest,
28};
29use crate::normalize::{format_decimal, parse_decimal};
30use crate::options::{ExchangeOptions, MarginMode, MarketType, PositionMode, SelfTradePrevention};
31use crate::positions::{Position, PositionSide};
32use crate::signing::{hmac_sha512_hex, sha512_hex};
33use crate::symbol::Symbol;
34use crate::traits::{
35    AdvancedOrders, Derivatives, DerivativesStream, Exchange, Execution, MarketData, WsExecution,
36    WsUserData,
37};
38use crate::transport::{
39    HttpMethod, HttpRequest, HttpResponse, HttpTransport, WsConnection, WsTransport,
40};
41use crate::types::{
42    Balance, OcoRequest, Order, OrderRequest, OrderSide, OrderStatus, OrderType, Ticker,
43    TimeInForce,
44};
45use rust_decimal::prelude::ToPrimitive;
46use rust_decimal::Decimal;
47use serde::Deserialize;
48use std::cell::RefCell;
49use std::collections::HashMap;
50use std::fmt;
51use wickra_core::Candle;
52
53fn system_now_ms() -> i64 {
54    use std::time::{SystemTime, UNIX_EPOCH};
55    SystemTime::now()
56        .duration_since(UNIX_EPOCH)
57        .expect("system clock is before the Unix epoch")
58        .as_millis() as i64
59}
60
61/// A Gate.io client over injected transports.
62pub struct Gate {
63    http: Box<dyn HttpTransport>,
64    ws: Option<Box<dyn WsTransport>>,
65    rest_base: String,
66    market_type: MarketType,
67    /// One-way or hedge. Gate calls hedge "dual mode", and a dual-mode close is
68    /// expressed with `auto_size` rather than with `reduce_only`.
69    position_mode: PositionMode,
70    credentials: Option<Credentials>,
71    now_ms: Box<dyn Fn() -> i64 + Send + Sync>,
72    /// Offset between this machine's clock and the venue's, applied to every
73    /// signed timestamp. Zero until [`sync_time`](Self::sync_time) is called.
74    clock: ServerClock,
75    connection: Option<Box<dyn WsConnection>>,
76    sub_messages: Vec<String>,
77    subscriptions: Vec<(String, Symbol)>,
78    /// The futures account's user id, fetched once for the private stream.
79    ///
80    /// Gate addresses its futures private channels to a user id where the spot
81    /// ones need none, so it is looked up on first subscribe and kept.
82    futures_user_id: RefCell<Option<String>>,
83    /// Which derivatives channels are subscribed, per contract.
84    ///
85    /// `futures.tickers` carries the funding rate, the mark price and the
86    /// index beside the quote, so one frame can answer three subscriptions.
87    derivatives_channels: Vec<(String, DerivativesChannel)>,
88    /// Leverage applied when switching to isolated margin. Gate couples the
89    /// margin mode with its leverage endpoint (`leverage=0` = cross), so
90    /// [`set_leverage`](Self::set_leverage) records the value here.
91    leverage: u32,
92    /// The private user-data connection, opened by
93    /// [`subscribe_user_data`](Self::subscribe_user_data) and drained by
94    /// [`poll_events`](Self::poll_events) alongside the public stream.
95    private_connection: Option<Box<dyn WsConnection>>,
96    /// Set once the private stream is subscribed, so [`poll_events`](Self::poll_events)
97    /// re-subscribes it after a drop.
98    user_data_active: bool,
99    /// A dedicated logged-in connection to the WebSocket order API, opened lazily
100    /// on the first [`place_order_ws`](Self::place_order_ws) /
101    /// [`cancel_order_ws`](Self::cancel_order_ws) call.
102    ws_api_connection: Option<Box<dyn WsConnection>>,
103}
104
105/// Hand-written: the client holds `Box<dyn HttpTransport>`, `Box<dyn WsTransport>`
106/// and a `Box<dyn Fn() -> i64>` clock, none of which a derive can reach. The
107/// transports are shown by whether a connection is open rather than by value, and
108/// the credentials only by whether they are set -- printing them would put secret
109/// material into every log line that formats a client.
110impl fmt::Debug for Gate {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        f.debug_struct("Gate")
113            .field("ws", &self.ws.is_some())
114            .field("rest_base", &self.rest_base)
115            .field("market_type", &self.market_type)
116            .field("authenticated", &self.credentials.is_some())
117            .field("clock_offset_ms", &self.clock.offset_ms())
118            .field("connection", &self.connection.is_some())
119            .field("sub_messages", &self.sub_messages.len())
120            .field("subscriptions", &self.subscriptions.len())
121            .field("leverage", &self.leverage)
122            .field("private_connection", &self.private_connection.is_some())
123            .field("user_data_active", &self.user_data_active)
124            .field("ws_api_connection", &self.ws_api_connection.is_some())
125            .finish_non_exhaustive()
126    }
127}
128
129impl Gate {
130    /// Refuse a market this client does not route.
131    ///
132    /// Called at every seam that reaches the venue -- the HTTP helpers and each
133    /// WebSocket connect -- so an unrouted market is refused before a request
134    /// is built rather than answered by whichever market the URL happened to
135    /// name. See [`ensure_market_is_routed`](super::ensure_market_is_routed)
136    /// for what each client used to answer instead.
137    fn ensure_market_is_routed(&self) -> Result<()> {
138        super::ensure_market_is_routed("Gate.io", self.market_type, super::SPOT_AND_LINEAR)
139    }
140
141    fn build(
142        http: Box<dyn HttpTransport>,
143        options: &ExchangeOptions,
144        credentials: Option<Credentials>,
145    ) -> Self {
146        Self {
147            http,
148            ws: None,
149            rest_base: "https://api.gateio.ws".to_string(),
150            market_type: options.market_type,
151            position_mode: options.position_mode,
152            credentials,
153            now_ms: Box::new(system_now_ms),
154            clock: ServerClock::new(),
155            connection: None,
156            sub_messages: Vec::new(),
157            subscriptions: Vec::new(),
158            futures_user_id: RefCell::new(None),
159            derivatives_channels: Vec::new(),
160            leverage: 1,
161            private_connection: None,
162            user_data_active: false,
163            ws_api_connection: None,
164        }
165    }
166
167    /// The timestamp a signed request must carry: this machine's clock plus the
168    /// offset learned from the venue.
169    ///
170    /// A venue rejects a signed request whose timestamp falls outside its own
171    /// receive window, so a machine a few seconds off has every order refused --
172    /// with a message about the window rather than about the clock. Until
173    /// [`sync_time`](Self::sync_time) is called the offset is zero and this is
174    /// the local time, which is the previous behaviour.
175    fn signed_now_ms(&self) -> i64 {
176        self.clock.server_time_ms((self.now_ms)())
177    }
178
179    /// Learn the offset between this machine's clock and the venue's, from
180    /// `GET /api/v4/spot/time`.
181    ///
182    /// Explicit rather than automatic: it costs a request, and a client should
183    /// not make one the caller did not ask for. Call it once after connecting,
184    /// and again if the process runs long enough for drift to matter.
185    ///
186    /// Returns the new offset in milliseconds (`server - local`).
187    ///
188    /// # Errors
189    /// Returns an [`Error`] if the request fails or the response cannot be parsed.
190    pub fn sync_time(&mut self) -> Result<i64> {
191        let local_before = (self.now_ms)();
192        let value = self.get("/api/v4/spot/time", "")?;
193        let server_ms = value
194            .get("server_time")
195            .and_then(serde_json::Value::as_i64)
196            .ok_or_else(|| Error::Deserialization("spot/time: no server_time".into()))?;
197        self.clock.sync(local_before, server_ms);
198        Ok(self.clock.offset_ms())
199    }
200
201    /// Whether this client targets Gate USDT-margined perpetual futures
202    /// (`/api/v4/futures/usdt/*`) rather than spot.
203    fn is_futures(&self) -> bool {
204        self.market_type.is_derivatives()
205    }
206
207    /// Build a public Gate.io client.
208    #[must_use]
209    pub fn with_http(http: Box<dyn HttpTransport>, options: &ExchangeOptions) -> Self {
210        Self::build(http, options, None)
211    }
212
213    /// Build an authenticated Gate.io client.
214    #[must_use]
215    pub fn with_credentials(
216        http: Box<dyn HttpTransport>,
217        options: &ExchangeOptions,
218        credentials: Credentials,
219    ) -> Self {
220        Self::build(http, options, Some(credentials))
221    }
222
223    /// Override the timestamp source (deterministic signing in tests).
224    #[must_use]
225    pub fn with_clock(mut self, now_ms: Box<dyn Fn() -> i64 + Send + Sync>) -> Self {
226        self.now_ms = now_ms;
227        self
228    }
229
230    /// Attach a WebSocket transport.
231    #[must_use]
232    pub fn with_ws(mut self, ws: Box<dyn WsTransport>) -> Self {
233        self.ws = Some(ws);
234        self
235    }
236
237    /// The Gate wire symbol for a canonical [`Symbol`] (`BTC/USDT` -> `BTC_USDT`).
238    #[must_use]
239    pub fn wire_symbol(symbol: &Symbol) -> String {
240        format!("{}_{}", symbol.base(), symbol.quote())
241    }
242
243    /// A ticker for `symbol`.
244    ///
245    /// # Errors
246    /// Returns an [`Error`] if the request fails or the symbol is unknown.
247    pub fn ticker(&self, symbol: &Symbol) -> Result<Ticker> {
248        if self.is_futures() {
249            return self.futures_ticker(symbol);
250        }
251        let query = format!("currency_pair={}", Self::wire_symbol(symbol));
252        let value = self.get("/api/v4/spot/tickers", &query)?;
253        let list: Vec<RawTicker> = parse_json(value)?;
254        let entry = list
255            .into_iter()
256            .next()
257            .ok_or_else(|| Error::NotFound(format!("no ticker for {symbol}")))?;
258        Ok(Ticker {
259            symbol: symbol.clone(),
260            last: parse_decimal(&entry.last)?,
261            bid: parse_decimal(&entry.highest_bid)?,
262            ask: parse_decimal(&entry.lowest_ask)?,
263            volume: parse_decimal(&entry.base_volume)?,
264            // Gate's spot ticker carries no timestamp; verified against the
265            // live endpoint.
266            timestamp: 0,
267        })
268    }
269
270    /// A futures ticker. Gate's perpetual ticker carries `last`/`volume_24h_base`
271    /// but no best bid/ask, so the top of the futures order book supplies those.
272    fn futures_ticker(&self, symbol: &Symbol) -> Result<Ticker> {
273        let contract = Self::wire_symbol(symbol);
274        let value = self.get(
275            "/api/v4/futures/usdt/tickers",
276            &format!("contract={contract}"),
277        )?;
278        let list: Vec<RawFuturesTicker> = parse_json(value)?;
279        let entry = list
280            .into_iter()
281            .next()
282            .ok_or_else(|| Error::NotFound(format!("no ticker for {symbol}")))?;
283        let book = self.order_book(symbol, 1)?;
284        Ok(Ticker {
285            symbol: symbol.clone(),
286            last: parse_decimal(&entry.last)?,
287            bid: book.bids.first().map_or(Decimal::ZERO, |l| l.price),
288            ask: book.asks.first().map_or(Decimal::ZERO, |l| l.price),
289            volume: parse_decimal(&entry.volume_24h_base)?,
290            // Gate's futures ticker carries no timestamp either.
291            timestamp: 0,
292        })
293    }
294
295    /// Up to `limit` candles for `symbol` at `interval` (unified). Gate returns
296    /// oldest-first, already chronological.
297    ///
298    /// # Errors
299    /// Returns an [`Error`] if the request fails or a row cannot be parsed.
300    pub fn klines(&self, symbol: &Symbol, interval: &str, limit: u32) -> Result<Vec<Candle>> {
301        if self.is_futures() {
302            let query = format!(
303                "contract={}&interval={}&limit={limit}",
304                Self::wire_symbol(symbol),
305                map_interval(interval),
306            );
307            let value = self.get("/api/v4/futures/usdt/candlesticks", &query)?;
308            let rows: Vec<RawFuturesCandle> = parse_json(value)?;
309            return rows.iter().map(parse_futures_candle).collect();
310        }
311        let query = format!(
312            "currency_pair={}&interval={}&limit={limit}",
313            Self::wire_symbol(symbol),
314            map_interval(interval),
315        );
316        let value = self.get("/api/v4/spot/candlesticks", &query)?;
317        let rows: Vec<Vec<String>> = parse_json(value)?;
318        rows.iter().map(|row| parse_kline_row(row)).collect()
319    }
320
321    /// A depth snapshot of `symbol` up to `depth` levels per side.
322    ///
323    /// # Errors
324    /// Returns an [`Error`] if the request fails or the response cannot be parsed.
325    pub fn order_book(&self, symbol: &Symbol, depth: u32) -> Result<OrderBookSnapshot> {
326        if self.is_futures() {
327            let query = format!("contract={}&limit={depth}", Self::wire_symbol(symbol));
328            let value = self.get("/api/v4/futures/usdt/order_book", &query)?;
329            let raw: RawFuturesDepth = parse_json(value)?;
330            return Ok(OrderBookSnapshot {
331                symbol: symbol.clone(),
332                last_update_id: raw.id,
333                bids: parse_futures_levels(&raw.bids)?,
334                asks: parse_futures_levels(&raw.asks)?,
335                timestamp: 0,
336            });
337        }
338        let query = format!("currency_pair={}&limit={depth}", Self::wire_symbol(symbol));
339        let value = self.get("/api/v4/spot/order_book", &query)?;
340        let raw: RawDepth = parse_json(value)?;
341        Ok(OrderBookSnapshot {
342            symbol: symbol.clone(),
343            last_update_id: raw.update,
344            bids: parse_levels(&raw.bids)?,
345            asks: parse_levels(&raw.asks)?,
346            // `update` is the book's own last-change time in milliseconds,
347            // which is also what the client uses as the sequence id.
348            timestamp: i64::try_from(raw.update).unwrap_or(0),
349        })
350    }
351
352    /// Subscribe to a pushed derivatives channel.
353    ///
354    /// Gate publishes the funding rate, the mark price and the index price
355    /// inside the `futures.tickers` frame -- one reading at one moment -- so
356    /// `Funding` and `MarkIndex` are the same subscription seen two ways.
357    ///
358    /// `Liquidations` is refused. Gate has a `futures.liquidates` channel, but
359    /// it answers an anonymous subscribe with *"authentication required for
360    /// Channel futures.liquidates"*: it reports the caller's own liquidations,
361    /// not the venue's forced flow, so it is not the public feed this channel
362    /// means.
363    ///
364    /// # Errors
365    /// Returns an [`Error`] on a spot client, for `Liquidations`, or if the
366    /// subscription fails.
367    pub fn subscribe_derivatives(
368        &mut self,
369        symbol: &Symbol,
370        channel: DerivativesChannel,
371    ) -> Result<()> {
372        if !self.is_futures() {
373            return Err(Error::unsupported_field(
374                "Gate.io",
375                "a derivatives channel on a spot client",
376                "funding and mark/index exist only on the futures venue",
377            ));
378        }
379        if channel == DerivativesChannel::Liquidations {
380            return Err(Error::unsupported_field(
381                "Gate.io",
382                "a liquidations channel",
383                "Gate's `futures.liquidates` needs authentication and reports the \
384                 caller's own liquidations, not the venue's forced flow",
385            ));
386        }
387        let wire = Self::wire_symbol(symbol);
388        if !self
389            .derivatives_channels
390            .iter()
391            .any(|(w, c)| w == &wire && *c == channel)
392        {
393            self.derivatives_channels.push((wire, channel));
394        }
395        self.subscribe(symbol, "spot.tickers")
396    }
397
398    /// The current open interest
399    /// (`GET /futures/usdt/contract_stats`), most recent point.
400    ///
401    /// Polled, not pushed. The figure is in contracts, the unit this client's
402    /// futures order path takes. The row's `time` is in *seconds*, where every
403    /// other stamp in this crate is milliseconds.
404    ///
405    /// # Errors
406    /// Returns an [`Error`] on a spot client, if the request fails, or if the
407    /// venue returns no data point.
408    pub fn open_interest(&self, symbol: &Symbol) -> Result<OpenInterest> {
409        if !self.is_futures() {
410            return Err(Error::unsupported_field(
411                "Gate.io",
412                "open interest on a spot client",
413                "open interest is a futures figure",
414            ));
415        }
416        let point = self.contract_stats(symbol)?;
417        Ok(OpenInterest {
418            symbol: symbol.clone(),
419            open_interest: parse_decimal(
420                &point
421                    .get("open_interest")
422                    .and_then(serde_json::Value::as_i64)
423                    .ok_or_else(|| Error::Deserialization("no open_interest".to_string()))?
424                    .to_string(),
425            )?,
426            timestamp: stats_millis(&point),
427        })
428    }
429
430    /// The current long/short account ratio
431    /// (`GET /futures/usdt/contract_stats`), most recent point.
432    ///
433    /// Polled, not pushed. Gate publishes `lsr_account`, the ratio of long
434    /// accounts to short ones, where Binance publishes the two proportions.
435    /// With two categories the conversion is exact and sums to one.
436    ///
437    /// # Errors
438    /// Returns an [`Error`] on a spot client, if the request fails, or if the
439    /// venue returns no data point.
440    pub fn long_short_ratio(&self, symbol: &Symbol) -> Result<LongShortRatio> {
441        if !self.is_futures() {
442            return Err(Error::unsupported_field(
443                "Gate.io",
444                "long/short positioning on a spot client",
445                "positioning is a futures figure",
446            ));
447        }
448        let point = self.contract_stats(symbol)?;
449        let ratio = point
450            .get("lsr_account")
451            .and_then(serde_json::Value::as_f64)
452            .and_then(|r| parse_decimal(&r.to_string()).ok())
453            .ok_or_else(|| Error::Deserialization("no lsr_account".to_string()))?;
454        let total = Decimal::ONE + ratio;
455        if total.is_zero() {
456            return Err(Error::Deserialization(
457                "a long/short ratio of -1 has no proportions".to_string(),
458            ));
459        }
460        Ok(LongShortRatio {
461            symbol: symbol.clone(),
462            long_size: ratio / total,
463            short_size: Decimal::ONE / total,
464            timestamp: stats_millis(&point),
465        })
466    }
467
468    /// The newest `contract_stats` row, which carries both polled figures.
469    fn contract_stats(&self, symbol: &Symbol) -> Result<serde_json::Value> {
470        let query = format!("contract={}&limit=1", Self::wire_symbol(symbol));
471        let value = self.get("/futures/usdt/contract_stats", &query)?;
472        value
473            .as_array()
474            .and_then(|rows| rows.first())
475            .cloned()
476            .ok_or_else(|| Error::NotFound("no contract-stats data point".to_string()))
477    }
478
479    /// Subscribe to the public trade stream for `symbol`.
480    ///
481    /// # Errors
482    /// Returns [`Error::NotConnected`] if no WebSocket transport is configured.
483    pub fn subscribe_trades(&mut self, symbol: &Symbol) -> Result<()> {
484        self.subscribe(symbol, "spot.trades")
485    }
486
487    /// Subscribe to the order-book stream for `symbol`.
488    ///
489    /// # Errors
490    /// See [`subscribe_trades`](Self::subscribe_trades).
491    pub fn subscribe_book(&mut self, symbol: &Symbol) -> Result<()> {
492        self.subscribe(symbol, "spot.order_book_update")
493    }
494
495    /// Subscribe to the ticker stream for `symbol`.
496    ///
497    /// # Errors
498    /// See [`subscribe_trades`](Self::subscribe_trades).
499    pub fn subscribe_ticker(&mut self, symbol: &Symbol) -> Result<()> {
500        self.subscribe(symbol, "spot.tickers")
501    }
502
503    fn subscribe(&mut self, symbol: &Symbol, channel: &str) -> Result<()> {
504        self.ensure_market_is_routed()?;
505        // Gate serves spot and futures from different hosts with differently
506        // named channels, so both follow the market the client is on. The
507        // channel arrives spelled `spot.<name>`; on a futures client the prefix
508        // is swapped rather than the call sites being duplicated.
509        let futures = self.is_futures();
510        let channel = if futures {
511            &channel.replace("spot.", "futures.")
512        } else {
513            channel
514        };
515        let wire = Self::wire_symbol(symbol);
516        if self.connection.is_none() {
517            let ws = self.ws.as_ref().ok_or(Error::NotConnected)?;
518            let url = if futures {
519                FUTURES_WS_URL
520            } else {
521                "wss://api.gateio.ws/ws/v4/"
522            };
523            let connection = ws.connect(url)?;
524            self.connection = Some(connection);
525        }
526        let time = self.signed_now_ms() / 1000;
527        // `futures.order_book_update` needs an interval and a depth beside the
528        // contract; the other channels take the contract alone.
529        let payload = if channel == "futures.order_book_update" {
530            format!(r#""{wire}","100ms","20""#)
531        } else {
532            format!(r#""{wire}""#)
533        };
534        let message = format!(
535            r#"{{"time":{time},"channel":"{channel}","event":"subscribe","payload":[{payload}]}}"#
536        );
537        self.connection
538            .as_mut()
539            .expect("connection just ensured")
540            .send(&message)?;
541        if !self.sub_messages.contains(&message) {
542            self.sub_messages.push(message.clone());
543        }
544        if !self.subscriptions.iter().any(|(w, _)| w == &wire) {
545            self.subscriptions.push((wire, symbol.clone()));
546        }
547        Ok(())
548    }
549
550    /// Drain all stream events available since the last call. Non-blocking.
551    pub fn poll_events(&mut self) -> Vec<Event> {
552        let subscriptions: HashMap<String, Symbol> = self.subscriptions.iter().cloned().collect();
553        let resolve = |wire: &str| {
554            subscriptions
555                .get(wire)
556                .cloned()
557                .unwrap_or_else(|| Symbol::new(wire, ""))
558        };
559        let channels = self.derivatives_channels.clone();
560        let subscribed = |wire: &str, channel: DerivativesChannel| {
561            channels.iter().any(|(w, c)| w == wire && *c == channel)
562        };
563        let mut events = Vec::new();
564        if let Some(connection) = self.connection.as_mut() {
565            while let Ok(Some(frame)) = connection.recv() {
566                if let Ok(mut parsed) = parse_ws_message(&frame, &resolve, &subscribed) {
567                    events.append(&mut parsed);
568                }
569            }
570        }
571        // Drain the private user-data stream (spot.orders/spot.balances), if open.
572        if let Some(connection) = self.private_connection.as_mut() {
573            while let Ok(Some(frame)) = connection.recv() {
574                if let Ok(mut parsed) = parse_ws_message(&frame, &resolve, &subscribed) {
575                    events.append(&mut parsed);
576                }
577            }
578        }
579        // A dropped private stream is re-subscribed with fresh signed frames (the
580        // per-channel signature is time-bound, so a stale replay would be rejected).
581        if self.user_data_active
582            && self
583                .private_connection
584                .as_ref()
585                .is_some_and(|c| !c.is_connected())
586        {
587            events.push(Event::Disconnected);
588            self.private_connection = None;
589            if self.subscribe_user_data().is_ok() {
590                events.push(Event::Reconnected);
591            }
592        }
593        let url = "wss://api.gateio.ws/ws/v4/";
594        crate::wsutil::reconnect_if_dropped(
595            self.ws.as_deref(),
596            url,
597            &mut self.connection,
598            &self.sub_messages,
599            &mut events,
600        );
601        events
602    }
603
604    /// Open the private user-data stream (`wss://api.gateio.ws/ws/v4/`). Sends
605    /// signed `subscribe` frames for the `spot.orders` and `spot.balances`
606    /// channels (each carries an `auth` object whose `SIGN` is HMAC-SHA512 over
607    /// `channel=<channel>&event=subscribe&time=<time>`). Afterwards
608    /// [`poll_events`](Self::poll_events) also surfaces the account's own
609    /// [`Event::OrderUpdate`] and [`Event::BalanceUpdate`].
610    ///
611    /// A dropped private stream is re-subscribed automatically on the next
612    /// [`poll_events`](Self::poll_events); call
613    /// [`keepalive_user_data`](Self::keepalive_user_data) periodically to keep it
614    /// from being dropped for inactivity.
615    ///
616    /// # Errors
617    /// Returns [`Error::InvalidCredentials`] without credentials,
618    /// [`Error::NotConnected`] without a WebSocket transport, or another
619    /// [`Error`] if the request fails.
620    pub fn subscribe_user_data(&mut self) -> Result<()> {
621        self.ensure_market_is_routed()?;
622        // Spot and futures are separate accounts on separate hosts: a futures
623        // order never appears in the spot account, so watching the wrong one is
624        // waiting for a fill that cannot arrive.
625        //
626        // The futures channels are addressed to a **user id**, which the spot
627        // ones are not, so it is fetched first -- once, and cached. Subscribing
628        // without it is a subscription to nobody.
629        let futures = self.is_futures();
630        let user_id = if futures {
631            Some(self.futures_user_id()?)
632        } else {
633            None
634        };
635        let creds = self.credentials.as_ref().ok_or(Error::InvalidCredentials(
636            "user-data stream requires credentials",
637        ))?;
638        let time = self.signed_now_ms() / 1000;
639        // The futures private channels live on the same socket as the public
640        // futures ones -- Gate separates them by authentication, not by host.
641        let url = if futures {
642            FUTURES_WS_URL
643        } else {
644            "wss://api.gateio.ws/ws/v4/"
645        };
646        let ws = self.ws.as_ref().ok_or(Error::NotConnected)?;
647        let mut connection = ws.connect(url)?;
648        // `spot.orders` takes `!all` to cover every pair; `spot.balances` takes
649        // no payload. The futures pair is addressed to the user id instead, and
650        // `!all` again covers every contract.
651        let futures_orders = user_id
652            .as_ref()
653            .map(|id| format!(r#"["{id}","!all"]"#))
654            .unwrap_or_default();
655        let futures_balances = user_id
656            .as_ref()
657            .map(|id| format!(r#"["{id}"]"#))
658            .unwrap_or_default();
659        let channels: [(&str, &str); 2] = if futures {
660            [
661                ("futures.orders", futures_orders.as_str()),
662                ("futures.balances", futures_balances.as_str()),
663            ]
664        } else {
665            [("spot.orders", r#"["!all"]"#), ("spot.balances", "[]")]
666        };
667        for (channel, payload) in channels {
668            let sign_string = format!("channel={channel}&event=subscribe&time={time}");
669            let sign = hmac_sha512_hex(creds.api_secret.as_bytes(), sign_string.as_bytes());
670            let message = format!(
671                r#"{{"time":{time},"channel":"{channel}","event":"subscribe","payload":{payload},"auth":{{"method":"api_key","KEY":"{}","SIGN":"{sign}"}}}}"#,
672                creds.api_key
673            );
674            connection.send(&message)?;
675        }
676        self.private_connection = Some(connection);
677        self.user_data_active = true;
678        Ok(())
679    }
680
681    /// Send an application-level heartbeat (the `ping` text frame) on the private
682    /// stream so it is not dropped for inactivity. A no-op before
683    /// [`subscribe_user_data`](Self::subscribe_user_data).
684    ///
685    /// # Errors
686    /// Returns an [`Error`] if the ping cannot be sent.
687    pub fn keepalive_user_data(&mut self) -> Result<()> {
688        if let Some(connection) = self.private_connection.as_mut() {
689            connection.send("ping")?;
690        }
691        Ok(())
692    }
693
694    /// Place a spot order over the Gate WebSocket order API (`spot.order_place`).
695    /// Logs into a dedicated connection on first use (`spot.login`, signature =
696    /// HMAC-SHA512 over `api\nspot.login\n\n<timestamp>`), then exchanges the same
697    /// order args as the REST path.
698    ///
699    /// # Errors
700    /// Returns [`Error::Exchange`] on the **futures** client (this binding's WS
701    /// order API covers spot), [`Error::NotConnected`] without a WebSocket
702    /// transport, or another [`Error`] if the order is invalid or rejected.
703    pub fn place_order_ws(&mut self, request: &OrderRequest) -> Result<Order> {
704        if request.order_type.is_trigger() {
705            return Err(Error::unsupported_trigger("Gate.io"));
706        }
707        self.ensure_reduce_only_is_reducible(request)?;
708        request.validate()?;
709        if self.is_futures() {
710            return Err(Error::Exchange {
711                code: "unsupported".to_string(),
712                message: "Gate's WebSocket order API is wired for spot here; place \
713                          futures orders over REST (/api/v4/futures/usdt/orders)"
714                    .to_string(),
715            });
716        }
717        let mut req_param = serde_json::json!({
718            "currency_pair": Self::wire_symbol(&request.symbol),
719            "type": order_type_str(request.order_type),
720            "account": "spot",
721            "side": side_str(request.side),
722            "amount": format_decimal(request.quantity),
723        });
724        if let Some(price) = request.price {
725            req_param["price"] = serde_json::json!(format_decimal(price));
726        }
727        if let Some(tif) = tif_str(request)? {
728            req_param["time_in_force"] = serde_json::json!(tif);
729        }
730        if let Some(id) = &request.client_order_id {
731            let text = if id.starts_with("t-") {
732                id.clone()
733            } else {
734                format!("t-{id}")
735            };
736            req_param["text"] = serde_json::json!(text);
737        }
738        if let Some(act) = stp_act_str(request.stp) {
739            req_param["stp_act"] = serde_json::json!(act);
740        }
741        let result = self.ws_api_request("spot.order_place", &req_param)?;
742        let raw: RawOrder =
743            serde_json::from_value(result).map_err(|e| Error::Deserialization(e.to_string()))?;
744        order_from_raw(request.symbol.clone(), &raw)
745    }
746
747    /// Cancel a spot order over the Gate WebSocket order API (`spot.order_cancel`).
748    ///
749    /// # Errors
750    /// Returns [`Error::Exchange`] on the futures client, [`Error::NotConnected`]
751    /// without a WebSocket transport, or another [`Error`] if the request fails.
752    pub fn cancel_order_ws(&mut self, symbol: &Symbol, order_id: &str) -> Result<()> {
753        if self.is_futures() {
754            return Err(Error::Exchange {
755                code: "unsupported".to_string(),
756                message: "Gate's WebSocket order API is wired for spot here; cancel \
757                          futures orders over REST"
758                    .to_string(),
759            });
760        }
761        let req_param = serde_json::json!({
762            "order_id": order_id,
763            "currency_pair": Self::wire_symbol(symbol),
764        });
765        self.ws_api_request("spot.order_cancel", &req_param)?;
766        Ok(())
767    }
768
769    /// Open and log into the WebSocket order connection if needed. The `spot.login`
770    /// request signs `api\nspot.login\n\n<timestamp>`; later requests on the same
771    /// connection inherit the session, so they are unsigned.
772    fn ensure_ws_api(&mut self) -> Result<()> {
773        self.ensure_market_is_routed()?;
774        if self.ws_api_connection.is_some() {
775            return Ok(());
776        }
777        let creds = self.credentials.as_ref().ok_or(Error::InvalidCredentials(
778            "WebSocket order API requires credentials",
779        ))?;
780        let timestamp = self.signed_now_ms() / 1000;
781        let signature = hmac_sha512_hex(
782            creds.api_secret.as_bytes(),
783            format!("api\nspot.login\n\n{timestamp}").as_bytes(),
784        );
785        let req_id = format!("wkex-login-{}", (self.now_ms)());
786        let login = format!(
787            r#"{{"time":{timestamp},"channel":"spot.login","event":"api","payload":{{"req_id":"{req_id}","api_key":"{}","signature":"{signature}","timestamp":"{timestamp}"}}}}"#,
788            creds.api_key
789        );
790        let ws = self.ws.as_ref().ok_or(Error::NotConnected)?;
791        let mut connection = ws.connect("wss://api.gateio.ws/ws/v4/")?;
792        connection.send(&login)?;
793        connection.recv()?; // consume the login acknowledgement
794        self.ws_api_connection = Some(connection);
795        Ok(())
796    }
797
798    /// Send an order-API request frame and return its `data.result`, mapping a
799    /// non-`"200"` header status onto the error taxonomy.
800    fn ws_api_request(
801        &mut self,
802        channel: &str,
803        req_param: &serde_json::Value,
804    ) -> Result<serde_json::Value> {
805        self.ensure_ws_api()?;
806        let time = self.signed_now_ms() / 1000;
807        let req_id = format!("wkex-{}", (self.now_ms)());
808        let frame = serde_json::json!({
809            "time": time,
810            "channel": channel,
811            "event": "api",
812            "payload": { "req_id": req_id, "req_param": req_param },
813        })
814        .to_string();
815        let connection = self
816            .ws_api_connection
817            .as_mut()
818            .expect("ws order connection just ensured");
819        connection.send(&frame)?;
820        let Some(response) = connection.recv()? else {
821            return Err(Error::NotConnected);
822        };
823        let value: serde_json::Value =
824            serde_json::from_str(&response).map_err(|e| Error::Deserialization(e.to_string()))?;
825        let status = value
826            .get("header")
827            .and_then(|header| header.get("status"))
828            .and_then(serde_json::Value::as_str)
829            .unwrap_or("");
830        if status == "200" {
831            Ok(value
832                .get("data")
833                .and_then(|data| data.get("result"))
834                .cloned()
835                .unwrap_or(serde_json::Value::Null))
836        } else {
837            let errs = value.get("data").and_then(|data| data.get("errs"));
838            let label = errs
839                .and_then(|e| e.get("label"))
840                .and_then(serde_json::Value::as_str)
841                .unwrap_or("ws");
842            let message = errs
843                .and_then(|e| e.get("message"))
844                .and_then(serde_json::Value::as_str)
845                .unwrap_or("order rejected")
846                .to_string();
847            Err(Error::OrderRejected {
848                code: label.to_string(),
849                message,
850            })
851        }
852    }
853
854    /// Place an order.
855    ///
856    /// # Errors
857    /// Returns an [`Error`] if the order is invalid, credentials are missing, or
858    /// the venue rejects it.
859    /// Refuse `reduce_only` where there is no position to reduce.
860    ///
861    /// A spot account holds balances, not positions, so a reduce-only order has
862    /// nothing to close. Dropping the flag would place an opening order where a
863    /// closing one was asked for -- the opposite trade -- so it is refused, the
864    /// way Binance's spot path and Upbit already refuse it.
865    fn ensure_reduce_only_is_reducible(&self, request: &OrderRequest) -> Result<()> {
866        if request.reduce_only && !self.is_futures() {
867            return Err(Error::unsupported_field(
868                "Gate.io",
869                "reduce_only on a spot order",
870                "spot holds balances, not positions, and has none to reduce",
871            ));
872        }
873        Ok(())
874    }
875
876    /// Place a trigger order through Gate's price-order endpoint.
877    ///
878    /// Gate calls these *price* orders and nests them: a `trigger` says when to
879    /// act and a `put` says what to place. Spot and futures use different
880    /// shapes for both halves.
881    ///
882    /// `rule` is the comparison, and Gate will not infer it: `2` is "at or
883    /// below", which is what protects a long, and `1` is "at or above", which
884    /// covers a short. The wrong rule arms the order on the side that never
885    /// comes.
886    ///
887    /// **Gate's spot price order can only place a limit.** A stop-market is
888    /// refused rather than sent with an invented limit price, since that price
889    /// decides how much slippage the caller's stop may take. Futures has no such
890    /// limit: a `price` of `0` there means "take the market".
891    ///
892    /// # Errors
893    /// Returns an [`Error`] if the trigger price is missing, if a spot
894    /// stop-market is asked for, or if the request fails.
895    fn place_price_order(&self, request: &OrderRequest) -> Result<Order> {
896        let stop = request
897            .stop_price
898            .ok_or(Error::InvalidOrder("a trigger order requires a stop price"))?;
899        // Selling to protect a long fires on the way down; buying to cover a
900        // short fires on the way up.
901        let rule = match request.side {
902            OrderSide::Sell => 2,
903            OrderSide::Buy => 1,
904        };
905        let (path, body) = if self.is_futures() {
906            let size = decimal_to_contracts(request.quantity)?;
907            let size = match request.side {
908                OrderSide::Buy => size,
909                OrderSide::Sell => -size,
910            };
911            (
912                "/api/v4/futures/usdt/price_orders",
913                serde_json::json!({
914                    "initial": {
915                        "contract": Self::wire_symbol(&request.symbol),
916                        "size": size,
917                        // `0` is Gate's "take the market" on a futures price
918                        // order; a limit is sent as itself.
919                        "price": request.price.map_or_else(
920                            || "0".to_string(),
921                            format_decimal,
922                        ),
923                    },
924                    "trigger": {
925                        "strategy_type": 0,
926                        // 0 is the last traded price; 1 the mark, 2 the index.
927                        "price_type": 0,
928                        "price": format_decimal(stop),
929                        "rule": rule,
930                    },
931                }),
932            )
933        } else {
934            let Some(price) = request.price else {
935                return Err(Error::unsupported_field(
936                    "Gate.io",
937                    "a spot stop-market order",
938                    "Gate's spot price order can only place a limit, and choosing \
939                     one here would decide how much slippage the caller's stop may take",
940                ));
941            };
942            (
943                "/api/v4/spot/price_orders",
944                serde_json::json!({
945                    "market": Self::wire_symbol(&request.symbol),
946                    "trigger": {
947                        "price": format_decimal(stop),
948                        "rule": if rule == 2 { "<=" } else { ">=" },
949                        // Zero is Gate's "no expiry" on a spot price order.
950                        "expiration": 0,
951                    },
952                    "put": {
953                        "type": "limit",
954                        "side": side_str(request.side),
955                        "price": format_decimal(price),
956                        "amount": format_decimal(request.quantity),
957                        "account": "normal",
958                        "time_in_force": "gtc",
959                    },
960                }),
961            )
962        };
963        let value = self.signed_request(HttpMethod::Post, path, "", &body.to_string())?;
964        Ok(Order {
965            id: value
966                .get("id")
967                .map(|id| match id.as_str() {
968                    Some(text) => text.to_string(),
969                    None => id.to_string(),
970                })
971                .unwrap_or_default(),
972            client_order_id: request.client_order_id.clone(),
973            symbol: request.symbol.clone(),
974            side: request.side,
975            order_type: request.order_type,
976            status: OrderStatus::New,
977            quantity: request.quantity,
978            filled_quantity: Decimal::ZERO,
979            price: request.price,
980            average_price: None,
981        })
982    }
983
984    pub fn place_order(&self, request: &OrderRequest) -> Result<Order> {
985        self.ensure_reduce_only_is_reducible(request)?;
986        request.validate()?;
987        // A trigger is a different endpoint here, with a different body shape.
988        if request.order_type.is_trigger() {
989            return self.place_price_order(request);
990        }
991        if self.is_futures() {
992            return self.place_futures_order(request);
993        }
994        let mut body = serde_json::json!({
995            "currency_pair": Self::wire_symbol(&request.symbol),
996            "side": side_str(request.side),
997            "type": order_type_str(request.order_type),
998            "amount": format_decimal(request.quantity),
999        });
1000        if let Some(price) = request.price {
1001            body["price"] = serde_json::json!(format_decimal(price));
1002        }
1003        if let Some(tif) = tif_str(request)? {
1004            body["time_in_force"] = serde_json::json!(tif);
1005        }
1006        if let Some(id) = &request.client_order_id {
1007            let text = if id.starts_with("t-") {
1008                id.clone()
1009            } else {
1010                format!("t-{id}")
1011            };
1012            body["text"] = serde_json::json!(text);
1013        }
1014        if let Some(act) = stp_act_str(request.stp) {
1015            body["stp_act"] = serde_json::json!(act);
1016        }
1017        let value = self.signed_request(
1018            HttpMethod::Post,
1019            "/api/v4/spot/orders",
1020            "",
1021            &body.to_string(),
1022        )?;
1023        let raw: RawOrder = parse_json(value)?;
1024        order_from_raw(request.symbol.clone(), &raw)
1025    }
1026
1027    /// Cancel an open order by venue id.
1028    ///
1029    /// # Errors
1030    /// Returns an [`Error`] if credentials are missing or the venue rejects it.
1031    pub fn cancel_order(&self, symbol: &Symbol, order_id: &str) -> Result<()> {
1032        if self.is_futures() {
1033            let path = format!("/api/v4/futures/usdt/orders/{order_id}");
1034            self.signed_request(HttpMethod::Delete, &path, "", "")?;
1035            return Ok(());
1036        }
1037        let path = format!("/api/v4/spot/orders/{order_id}");
1038        let query = format!("currency_pair={}", Self::wire_symbol(symbol));
1039        self.signed_request(HttpMethod::Delete, &path, &query, "")?;
1040        Ok(())
1041    }
1042
1043    /// Query a single order by venue id.
1044    ///
1045    /// # Errors
1046    /// Returns an [`Error`] if credentials are missing or the order is unknown.
1047    pub fn query_order(&self, symbol: &Symbol, order_id: &str) -> Result<Order> {
1048        if self.is_futures() {
1049            let path = format!("/api/v4/futures/usdt/orders/{order_id}");
1050            let value = self.signed_request(HttpMethod::Get, &path, "", "")?;
1051            let raw: RawFuturesOrder = parse_json(value)?;
1052            return futures_order_from_raw(symbol.clone(), &raw);
1053        }
1054        let path = format!("/api/v4/spot/orders/{order_id}");
1055        let query = format!("currency_pair={}", Self::wire_symbol(symbol));
1056        let value = self.signed_request(HttpMethod::Get, &path, &query, "")?;
1057        let raw: RawOrder = parse_json(value)?;
1058        order_from_raw(symbol.clone(), &raw)
1059    }
1060
1061    /// Open orders for one `symbol` (Gate requires a currency pair / contract here).
1062    ///
1063    /// # Errors
1064    /// Returns an [`Error`] if credentials are missing, no symbol is given, or the
1065    /// request fails.
1066    pub fn open_orders(&self, symbol: Option<&Symbol>) -> Result<Vec<Order>> {
1067        let sym = symbol.ok_or(Error::InvalidOrder("Gate open_orders requires a symbol"))?;
1068        if self.is_futures() {
1069            let query = format!("contract={}&status=open", Self::wire_symbol(sym));
1070            let value =
1071                self.signed_request(HttpMethod::Get, "/api/v4/futures/usdt/orders", &query, "")?;
1072            let list: Vec<RawFuturesOrder> = parse_json(value)?;
1073            return list
1074                .iter()
1075                .map(|raw| futures_order_from_raw(sym.clone(), raw))
1076                .collect();
1077        }
1078        let query = format!("currency_pair={}&status=open", Self::wire_symbol(sym));
1079        let value = self.signed_request(HttpMethod::Get, "/api/v4/spot/orders", &query, "")?;
1080        let list: Vec<RawOrder> = parse_json(value)?;
1081        list.iter()
1082            .map(|raw| order_from_raw(sym.clone(), raw))
1083            .collect()
1084    }
1085
1086    /// The futures account's user id, fetched once and cached.
1087    ///
1088    /// Gate's futures private channels are addressed to a user id where the
1089    /// spot ones are not: `futures.orders` takes `[user_id, contract]`. The id
1090    /// comes back on the account call the balance path already uses.
1091    ///
1092    /// # Errors
1093    /// Returns an [`Error`] if credentials are missing or the request fails.
1094    fn futures_user_id(&self) -> Result<String> {
1095        if let Some(id) = self.futures_user_id.borrow().clone() {
1096            return Ok(id);
1097        }
1098        let value =
1099            self.signed_request(HttpMethod::Get, "/api/v4/futures/usdt/accounts", "", "")?;
1100        let id = value
1101            .get("user")
1102            .map(|user| {
1103                user.as_str()
1104                    .map_or_else(|| user.to_string(), str::to_string)
1105            })
1106            .ok_or_else(|| Error::Deserialization("futures account has no user id".to_string()))?;
1107        *self.futures_user_id.borrow_mut() = Some(id.clone());
1108        Ok(id)
1109    }
1110
1111    /// Spot account balances.
1112    ///
1113    /// # Errors
1114    /// Returns an [`Error`] if credentials are missing or the request fails.
1115    pub fn balances(&self) -> Result<Vec<Balance>> {
1116        if self.is_futures() {
1117            let value =
1118                self.signed_request(HttpMethod::Get, "/api/v4/futures/usdt/accounts", "", "")?;
1119            let acct: RawFuturesAccount = parse_json(value)?;
1120            let total = dec_or_zero(&acct.total);
1121            let available = dec_or_zero(&acct.available);
1122            return Ok(vec![Balance {
1123                asset: acct.currency,
1124                free: available,
1125                locked: (total - available).max(Decimal::ZERO),
1126            }]);
1127        }
1128        let value = self.signed_request(HttpMethod::Get, "/api/v4/spot/accounts", "", "")?;
1129        let list: Vec<RawAccount> = parse_json(value)?;
1130        Ok(list
1131            .iter()
1132            .map(|a| Balance {
1133                asset: a.currency.clone(),
1134                free: dec_or_zero(&a.available),
1135                locked: dec_or_zero(&a.locked),
1136            })
1137            .collect())
1138    }
1139
1140    /// Place a Gate USDT-margined futures order. Futures orders carry a **signed
1141    /// integer `size`** (contracts; positive = long/buy, negative = short/sell)
1142    /// and use `price="0"` with `tif="ioc"` for market orders.
1143    fn place_futures_order(&self, request: &OrderRequest) -> Result<Order> {
1144        let contract = Self::wire_symbol(&request.symbol);
1145        let magnitude = decimal_to_contracts(request.quantity)?;
1146        let size = match request.side {
1147            OrderSide::Buy => magnitude,
1148            OrderSide::Sell => -magnitude,
1149        };
1150        let mut body = if self.position_mode == PositionMode::Hedge && request.reduce_only {
1151            // In dual mode a close names the side it closes and carries no size
1152            // of its own: Gate reads `auto_size` and requires `size` to be 0.
1153            // `reduce_only` alone is the one-way spelling and does not say which
1154            // of the two open sides is meant.
1155            let auto_size = match PositionSide::for_order(request.side, true) {
1156                PositionSide::Long => "close_long",
1157                PositionSide::Short => "close_short",
1158            };
1159            serde_json::json!({
1160                "contract": contract,
1161                "size": 0,
1162                "auto_size": auto_size,
1163                "reduce_only": true,
1164            })
1165        } else {
1166            // Opening in dual mode needs nothing extra: the sign of `size`
1167            // already says which side grows.
1168            serde_json::json!({
1169                "contract": contract,
1170                "size": size,
1171                "reduce_only": request.reduce_only,
1172            })
1173        };
1174        match request.order_type {
1175            OrderType::Market | OrderType::StopMarket => {
1176                // A Gate futures market order is a zero-price IOC; the venue has
1177                // no other spelling for it, so a caller asking for FOK is asking
1178                // for an order this path cannot place.
1179                if request.time_in_force == TimeInForce::Fok {
1180                    return Err(Error::unsupported_field(
1181                        "Gate.io",
1182                        "a FOK time-in-force on a futures market order",
1183                        "the futures market order is a zero-price IOC",
1184                    ));
1185                }
1186                body["price"] = serde_json::json!("0");
1187                body["tif"] = serde_json::json!("ioc");
1188            }
1189            OrderType::Limit | OrderType::StopLimit => {
1190                let price = request
1191                    .price
1192                    .ok_or(Error::InvalidOrder("limit order requires a price"))?;
1193                body["price"] = serde_json::json!(format_decimal(price));
1194                if let Some(tif) = tif_str(request)? {
1195                    body["tif"] = serde_json::json!(tif);
1196                }
1197            }
1198        }
1199        if let Some(id) = &request.client_order_id {
1200            let text = if id.starts_with("t-") {
1201                id.clone()
1202            } else {
1203                format!("t-{id}")
1204            };
1205            body["text"] = serde_json::json!(text);
1206        }
1207        let value = self.signed_request(
1208            HttpMethod::Post,
1209            "/api/v4/futures/usdt/orders",
1210            "",
1211            &body.to_string(),
1212        )?;
1213        let raw: RawFuturesOrder = parse_json(value)?;
1214        futures_order_from_raw(request.symbol.clone(), &raw)
1215    }
1216
1217    /// Open positions on the USDT-margined futures account
1218    /// (`/api/v4/futures/usdt/positions`).
1219    ///
1220    /// # Errors
1221    /// Returns an [`Error`] if credentials are missing or the request fails.
1222    pub fn positions(&self, symbol: Option<&Symbol>) -> Result<Vec<Position>> {
1223        let value =
1224            self.signed_request(HttpMethod::Get, "/api/v4/futures/usdt/positions", "", "")?;
1225        let list: Vec<RawFuturesPosition> = parse_json(value)?;
1226        let wanted = symbol.map(Self::wire_symbol);
1227        Ok(list
1228            .iter()
1229            .filter(|p| p.size != 0)
1230            .filter(|p| wanted.as_ref().is_none_or(|w| &p.contract == w))
1231            .map(parse_futures_position)
1232            .collect())
1233    }
1234
1235    /// Set the leverage for `symbol` (isolated margin; `leverage=0` = cross).
1236    ///
1237    /// # Errors
1238    /// Returns an [`Error`] if the leverage is rejected or the request fails.
1239    pub fn set_leverage(&mut self, symbol: &Symbol, leverage: u32) -> Result<()> {
1240        self.leverage = leverage.max(1);
1241        self.apply_leverage(symbol, leverage)
1242    }
1243
1244    /// Switch the margin mode for `symbol`. Gate couples this with the leverage
1245    /// endpoint: cross is `leverage=0`, isolated re-applies the recorded leverage.
1246    ///
1247    /// # Errors
1248    /// Returns an [`Error`] if the change is rejected or the request fails.
1249    pub fn set_margin_mode(&mut self, symbol: &Symbol, mode: MarginMode) -> Result<()> {
1250        let leverage = match mode {
1251            MarginMode::Cross => 0,
1252            MarginMode::Isolated => self.leverage.max(1),
1253        };
1254        self.apply_leverage(symbol, leverage)
1255    }
1256
1257    fn apply_leverage(&self, symbol: &Symbol, leverage: u32) -> Result<()> {
1258        let path = format!(
1259            "/api/v4/futures/usdt/positions/{}/leverage",
1260            Self::wire_symbol(symbol)
1261        );
1262        let query = format!("leverage={leverage}");
1263        self.signed_request(HttpMethod::Post, &path, &query, "")?;
1264        Ok(())
1265    }
1266
1267    /// Flatten the open position in `symbol` with a reduce-only market order.
1268    ///
1269    /// # Errors
1270    /// Returns [`Error::NotFound`] if there is no open position, or another
1271    /// [`Error`] if the request fails.
1272    pub fn close_position(&self, symbol: &Symbol) -> Result<Order> {
1273        let position = self
1274            .positions(Some(symbol))?
1275            .into_iter()
1276            .find(|p| &p.symbol == symbol)
1277            .ok_or_else(|| Error::NotFound(format!("no open position for {symbol}")))?;
1278        let request = match position.side {
1279            PositionSide::Long => OrderRequest::market_sell(symbol.clone(), position.quantity),
1280            PositionSide::Short => OrderRequest::market_buy(symbol.clone(), position.quantity),
1281        }
1282        .reduce_only();
1283        self.place_order(&request)
1284    }
1285
1286    fn get(&self, path: &str, query: &str) -> Result<serde_json::Value> {
1287        self.ensure_market_is_routed()?;
1288        let url = format!("{}{path}?{query}", self.rest_base);
1289        let response = self.http.execute(&HttpRequest::get(url))?;
1290        parse_body(&response)
1291    }
1292
1293    /// Sign with `KEY`/`SIGN`/`Timestamp`: `SIGN = hex(HMAC-SHA512(secret,
1294    /// METHOD\npath\nquery\nhex(SHA512(body))\ntimestamp))`.
1295    fn signed_request(
1296        &self,
1297        method: HttpMethod,
1298        path: &str,
1299        query: &str,
1300        body: &str,
1301    ) -> Result<serde_json::Value> {
1302        self.ensure_market_is_routed()?;
1303        let creds = self.credentials.as_ref().ok_or(Error::InvalidCredentials(
1304            "signed endpoint requires credentials",
1305        ))?;
1306        let timestamp = (self.signed_now_ms() / 1000).to_string();
1307        let body_hash = sha512_hex(body.as_bytes());
1308        let sign_string = format!(
1309            "{}\n{path}\n{query}\n{body_hash}\n{timestamp}",
1310            method.as_str()
1311        );
1312        let signature = hmac_sha512_hex(creds.api_secret.as_bytes(), sign_string.as_bytes());
1313        let url = if query.is_empty() {
1314            format!("{}{path}", self.rest_base)
1315        } else {
1316            format!("{}{path}?{query}", self.rest_base)
1317        };
1318        let mut request = HttpRequest::new(method, url)
1319            .with_header("KEY", creds.api_key.clone())
1320            .with_header("SIGN", signature)
1321            .with_header("Timestamp", timestamp);
1322        if !body.is_empty() {
1323            request = request
1324                .with_header("Content-Type", "application/json")
1325                .with_body(body.to_string());
1326        }
1327        let response = self.http.execute(&request)?;
1328        parse_body(&response)
1329    }
1330}
1331
1332/// Parse a Gate response: raw JSON on success, `{label, message}` on an HTTP error.
1333fn parse_body(response: &HttpResponse) -> Result<serde_json::Value> {
1334    if response.is_success() {
1335        serde_json::from_str(&response.body).map_err(|e| Error::Deserialization(e.to_string()))
1336    } else {
1337        let err: GateError = serde_json::from_str(&response.body).unwrap_or(GateError {
1338            label: response.status.to_string(),
1339            message: response.body.clone(),
1340        });
1341        Err(map_error(&err.label, &err.message).with_retry_after(response.retry_after()))
1342    }
1343}
1344
1345fn parse_json<T: for<'de> Deserialize<'de>>(value: serde_json::Value) -> Result<T> {
1346    serde_json::from_value(value).map_err(|e| Error::Deserialization(e.to_string()))
1347}
1348
1349fn map_interval(interval: &str) -> String {
1350    match interval {
1351        "1w" => "7d",
1352        other => other,
1353    }
1354    .to_string()
1355}
1356
1357fn map_error(label: &str, message: &str) -> Error {
1358    match label {
1359        "TOO_MANY_REQUESTS" | "RATE_LIMIT" => Error::RateLimited { retry_after: None },
1360        "INVALID_KEY" | "INVALID_SIGNATURE" | "SIGN_MISMATCH" | "AUTHENTICATION_FAILED" => {
1361            Error::Auth(message.to_string())
1362        }
1363        "BALANCE_NOT_ENOUGH" | "INSUFFICIENT_AVAILABLE" => Error::InsufficientBalance,
1364        "ORDER_NOT_FOUND" => Error::NotFound(message.to_string()),
1365        "INVALID_CURRENCY_PAIR" => Error::InvalidSymbol(message.to_string()),
1366        other => Error::Exchange {
1367            code: other.to_string(),
1368            message: message.to_string(),
1369        },
1370    }
1371}
1372
1373fn side_str(side: OrderSide) -> &'static str {
1374    match side {
1375        OrderSide::Buy => "buy",
1376        OrderSide::Sell => "sell",
1377    }
1378}
1379
1380/// The Gate `stp_act` value for a self-trade-prevention policy, or `None` to omit.
1381/// Gate cancels the oldest (`co`), newest (`cn`) or both (`cb`) order.
1382fn stp_act_str(stp: SelfTradePrevention) -> Option<&'static str> {
1383    match stp {
1384        SelfTradePrevention::None => None,
1385        SelfTradePrevention::ExpireMaker => Some("co"),
1386        SelfTradePrevention::ExpireTaker => Some("cn"),
1387        SelfTradePrevention::ExpireBoth => Some("cb"),
1388    }
1389}
1390
1391/// Gate spells time-in-force and post-only through one field (`time_in_force`
1392/// on spot, `tif` on futures), because `poc` *is* a post-only GTC. That makes
1393/// the two settings able to conflict, so a post-only IOC/FOK is refused rather
1394/// than silently resolved in one direction or the other.
1395///
1396/// `Gtc` is the request default and Gate's own default, so it is left off the
1397/// wire; `Ioc` and `Fok` are what a dropped field would change, and they are
1398/// always sent.
1399fn tif_str(request: &OrderRequest) -> Result<Option<&'static str>> {
1400    match (request.post_only, request.time_in_force) {
1401        (true, TimeInForce::Gtc) => Ok(Some("poc")),
1402        (true, _) => Err(Error::unsupported_field(
1403            "Gate.io",
1404            "post_only together with a non-GTC time-in-force",
1405            "`poc` is Gate's post-only and is itself a time-in-force",
1406        )),
1407        (false, TimeInForce::Gtc) => Ok(None),
1408        (false, TimeInForce::Ioc) => Ok(Some("ioc")),
1409        (false, TimeInForce::Fok) => Ok(Some("fok")),
1410    }
1411}
1412
1413fn order_type_str(order_type: OrderType) -> &'static str {
1414    match order_type {
1415        OrderType::Market | OrderType::StopMarket => "market",
1416        OrderType::Limit | OrderType::StopLimit => "limit",
1417    }
1418}
1419
1420fn parse_side(raw: &str) -> Result<OrderSide> {
1421    match raw {
1422        "buy" => Ok(OrderSide::Buy),
1423        "sell" => Ok(OrderSide::Sell),
1424        other => Err(Error::Deserialization(format!("unknown side {other:?}"))),
1425    }
1426}
1427
1428/// Split a Gate wire pair (`BTC_USDT`) into a canonical [`Symbol`].
1429fn symbol_from_wire(wire: &str) -> Symbol {
1430    match wire.split_once('_') {
1431        Some((base, quote)) if !base.is_empty() && !quote.is_empty() => Symbol::new(base, quote),
1432        _ => Symbol::new(wire, ""),
1433    }
1434}
1435
1436fn parse_order_type(raw: &str) -> Result<OrderType> {
1437    match raw {
1438        "market" => Ok(OrderType::Market),
1439        "limit" => Ok(OrderType::Limit),
1440        other => Err(Error::Deserialization(format!(
1441            "unknown order type {other:?}"
1442        ))),
1443    }
1444}
1445
1446fn dec_or_zero(raw: &str) -> Decimal {
1447    crate::normalize::parse_opt_decimal(Some(raw))
1448        .ok()
1449        .flatten()
1450        .unwrap_or(Decimal::ZERO)
1451}
1452
1453fn nonzero_decimal(raw: &str) -> Option<Decimal> {
1454    crate::normalize::parse_opt_decimal(Some(raw))
1455        .ok()
1456        .flatten()
1457        .filter(|d| *d > Decimal::ZERO)
1458}
1459
1460fn parse_levels(levels: &[[String; 2]]) -> Result<Vec<BookLevel>> {
1461    levels
1462        .iter()
1463        .map(|[price, qty]| {
1464            Ok(BookLevel {
1465                price: parse_decimal(price)?,
1466                quantity: parse_decimal(qty)?,
1467            })
1468        })
1469        .collect()
1470}
1471
1472fn parse_kline_row(row: &[String]) -> Result<Candle> {
1473    // Gate candle: [ts, quoteVol, close, high, low, open, baseVol, closed].
1474    if row.len() < 7 {
1475        return Err(Error::Deserialization("kline row too short".to_string()));
1476    }
1477    let ts = row[0]
1478        .parse::<i64>()
1479        .map_err(|e| Error::Deserialization(format!("kline ts not an integer: {e}")))?;
1480    let f = |i: usize| -> Result<f64> {
1481        row[i]
1482            .parse::<f64>()
1483            .map_err(|e| Error::Deserialization(format!("kline field not a number: {e}")))
1484    };
1485    // open=row[5], high=row[3], low=row[4], close=row[2], volume=row[6].
1486    Candle::new(f(5)?, f(3)?, f(4)?, f(2)?, f(6)?, ts)
1487        .map_err(|e| Error::Deserialization(e.to_string()))
1488}
1489
1490fn order_from_raw(symbol: Symbol, raw: &RawOrder) -> Result<Order> {
1491    // REST reports `filled_amount` directly; the `spot.orders` stream reports the
1492    // remaining `left`, so the filled quantity is `amount - left`.
1493    let filled = if raw.filled_amount.is_empty() && !raw.left.is_empty() {
1494        (dec_or_zero(&raw.amount) - dec_or_zero(&raw.left)).max(Decimal::ZERO)
1495    } else {
1496        dec_or_zero(&raw.filled_amount)
1497    };
1498    let status = match raw.status.as_str() {
1499        "cancelled" => OrderStatus::Canceled,
1500        "closed" => OrderStatus::Filled,
1501        "open" => {
1502            if filled > Decimal::ZERO {
1503                OrderStatus::PartiallyFilled
1504            } else {
1505                OrderStatus::New
1506            }
1507        }
1508        other => return Err(Error::Deserialization(format!("unknown status {other:?}"))),
1509    };
1510    Ok(Order {
1511        id: raw.id.clone(),
1512        client_order_id: (!raw.text.is_empty()).then(|| raw.text.clone()),
1513        symbol,
1514        side: parse_side(&raw.side)?,
1515        order_type: parse_order_type(&raw.order_type)?,
1516        status,
1517        quantity: parse_decimal(&raw.amount)?,
1518        filled_quantity: filled,
1519        price: nonzero_decimal(&raw.price),
1520        average_price: nonzero_decimal(&raw.avg_deal_price),
1521    })
1522}
1523
1524fn field_str<'a>(value: &'a serde_json::Value, key: &str) -> Result<&'a str> {
1525    value
1526        .get(key)
1527        .and_then(serde_json::Value::as_str)
1528        .ok_or_else(|| Error::Deserialization(format!("missing string field {key:?}")))
1529}
1530
1531fn opt_str<'a>(value: &'a serde_json::Value, key: &str) -> &'a str {
1532    value
1533        .get(key)
1534        .and_then(serde_json::Value::as_str)
1535        .unwrap_or("")
1536}
1537
1538fn opt_u64(value: &serde_json::Value, key: &str) -> u64 {
1539    value
1540        .get(key)
1541        .and_then(|v| {
1542            v.as_u64()
1543                .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
1544        })
1545        .unwrap_or(0)
1546}
1547
1548fn opt_i64(value: &serde_json::Value, key: &str) -> i64 {
1549    value
1550        .get(key)
1551        .and_then(|v| {
1552            v.as_i64()
1553                .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
1554        })
1555        .unwrap_or(0)
1556}
1557
1558fn parse_ws_levels(value: Option<&serde_json::Value>) -> Result<Vec<BookLevel>> {
1559    let array = value
1560        .and_then(serde_json::Value::as_array)
1561        .ok_or_else(|| Error::Deserialization("missing depth levels".to_string()))?;
1562    array
1563        .iter()
1564        .map(|level| {
1565            let pair = level
1566                .as_array()
1567                .ok_or_else(|| Error::Deserialization("depth level not an array".to_string()))?;
1568            let price = parse_decimal(
1569                pair.first()
1570                    .and_then(serde_json::Value::as_str)
1571                    .ok_or_else(|| Error::Deserialization("depth price missing".to_string()))?,
1572            )?;
1573            let quantity =
1574                parse_decimal(pair.get(1).and_then(serde_json::Value::as_str).ok_or_else(
1575                    || Error::Deserialization("depth quantity missing".to_string()),
1576                )?)?;
1577            Ok(BookLevel { price, quantity })
1578        })
1579        .collect()
1580}
1581
1582/// A `contract_stats` row's stamp, in milliseconds.
1583///
1584/// Gate reports it in *seconds* here, where every other stamp in this crate is
1585/// milliseconds. Carrying it through unconverted would date every reading to
1586/// 1970.
1587fn stats_millis(point: &serde_json::Value) -> i64 {
1588    point
1589        .get("time")
1590        .and_then(serde_json::Value::as_i64)
1591        .unwrap_or(0)
1592        * 1000
1593}
1594
1595/// Gate's futures WebSocket.
1596const FUTURES_WS_URL: &str = "wss://fx-ws.gateio.ws/v4/ws/usdt";
1597
1598/// A signed contract size from a futures frame.
1599///
1600/// Gate's futures trades carry no side field: the size is signed, and negative
1601/// means the aggressor sold. Sizes are in contracts, which is the unit this
1602/// client's futures order path already takes -- `decimal_to_contracts` rounds a
1603/// quantity to whole contracts rather than converting from base units, so the
1604/// stream and the order path speak the same number.
1605fn futures_signed_size(value: &serde_json::Value, key: &str) -> Option<(Decimal, OrderSide)> {
1606    let raw = value.get(key)?;
1607    let text = raw
1608        .as_str()
1609        .map(ToString::to_string)
1610        .or_else(|| raw.as_i64().map(|n| n.to_string()))?;
1611    let size = parse_decimal(&text).ok()?;
1612    let side = if size.is_sign_negative() {
1613        OrderSide::Sell
1614    } else {
1615        OrderSide::Buy
1616    };
1617    Some((size.abs(), side))
1618}
1619
1620/// `[{p, s}]` levels from a futures book frame. A size of zero removes the
1621/// level, which is what the delta type already means.
1622fn futures_levels(value: Option<&serde_json::Value>) -> Vec<BookLevel> {
1623    value
1624        .and_then(serde_json::Value::as_array)
1625        .map(|rows| {
1626            rows.iter()
1627                .filter_map(|row| {
1628                    let size = row.get("s")?;
1629                    let size = size
1630                        .as_str()
1631                        .map(ToString::to_string)
1632                        .or_else(|| size.as_i64().map(|n| n.to_string()))?;
1633                    Some(BookLevel {
1634                        price: parse_decimal(row.get("p")?.as_str()?).ok()?,
1635                        quantity: parse_decimal(&size).ok()?,
1636                    })
1637                })
1638                .collect()
1639        })
1640        .unwrap_or_default()
1641}
1642
1643fn parse_ws_message(
1644    text: &str,
1645    resolve: &impl Fn(&str) -> Symbol,
1646    subscribed: &impl Fn(&str, DerivativesChannel) -> bool,
1647) -> Result<Vec<Event>> {
1648    let value: serde_json::Value =
1649        serde_json::from_str(text).map_err(|e| Error::Deserialization(e.to_string()))?;
1650    if value.get("event").and_then(serde_json::Value::as_str) != Some("update") {
1651        return Ok(Vec::new()); // subscribe ack / other events
1652    }
1653    let Some(channel) = value.get("channel").and_then(serde_json::Value::as_str) else {
1654        return Ok(Vec::new());
1655    };
1656    let null = serde_json::Value::Null;
1657    let result = value.get("result").unwrap_or(&null);
1658
1659    match channel {
1660        // A futures trade frame is a list, and its size is signed rather than
1661        // carrying a side.
1662        "futures.trades" => Ok(result
1663            .as_array()
1664            .map(|trades| {
1665                trades
1666                    .iter()
1667                    .filter_map(|trade| {
1668                        let (quantity, aggressor) = futures_signed_size(trade, "size")?;
1669                        Some(Event::Trade(TradePrint {
1670                            symbol: resolve(trade.get("contract")?.as_str()?),
1671                            price: parse_decimal(trade.get("price")?.as_str()?).ok()?,
1672                            quantity,
1673                            aggressor,
1674                            timestamp: trade
1675                                .get("create_time_ms")
1676                                .and_then(serde_json::Value::as_i64)
1677                                .unwrap_or(0),
1678                        }))
1679                    })
1680                    .collect()
1681            })
1682            .unwrap_or_default()),
1683        "futures.order_book_update" => {
1684            let Some(contract) = result.get("s").and_then(serde_json::Value::as_str) else {
1685                return Ok(Vec::new());
1686            };
1687            Ok(vec![Event::BookDelta(BookDelta {
1688                symbol: resolve(contract),
1689                first_update_id: result
1690                    .get("U")
1691                    .and_then(serde_json::Value::as_u64)
1692                    .unwrap_or(0),
1693                final_update_id: result
1694                    .get("u")
1695                    .and_then(serde_json::Value::as_u64)
1696                    .unwrap_or(0),
1697                bids: futures_levels(result.get("b")),
1698                asks: futures_levels(result.get("a")),
1699                timestamp: result
1700                    .get("t")
1701                    .and_then(serde_json::Value::as_i64)
1702                    .unwrap_or(0),
1703            })])
1704        }
1705        // One frame, up to three answers: the futures ticker carries the
1706        // funding rate, the mark price and the index beside the quote, all read
1707        // at one moment. Only what was subscribed to is emitted.
1708        "futures.tickers" => {
1709            let empty = Vec::new();
1710            let mut out = Vec::new();
1711            for ticker in result.as_array().unwrap_or(&empty) {
1712                let Some(wire) = ticker.get("contract").and_then(serde_json::Value::as_str) else {
1713                    continue;
1714                };
1715                let symbol = resolve(wire);
1716                let timestamp = ticker
1717                    .get("t")
1718                    .and_then(serde_json::Value::as_i64)
1719                    .unwrap_or(0);
1720                let field = |key: &str| {
1721                    ticker
1722                        .get(key)
1723                        .and_then(serde_json::Value::as_str)
1724                        .and_then(|v| parse_decimal(v).ok())
1725                };
1726                if let Some(last) = field("last") {
1727                    out.push(Event::Ticker(Ticker {
1728                        symbol: symbol.clone(),
1729                        last,
1730                        // The futures ticker publishes no top of book; the book
1731                        // channel is where those live, and inventing them from
1732                        // the last price would be a fabrication.
1733                        bid: Decimal::ZERO,
1734                        ask: Decimal::ZERO,
1735                        volume: field("volume_24h_base").unwrap_or_default(),
1736                        timestamp,
1737                    }));
1738                }
1739                let mark = field("mark_price");
1740                if subscribed(wire, DerivativesChannel::Funding) {
1741                    if let (Some(rate), Some(mark_price)) = (field("funding_rate"), mark) {
1742                        out.push(Event::Derivatives(DerivativesFeed::Funding(FundingRate {
1743                            symbol: symbol.clone(),
1744                            rate,
1745                            mark_price,
1746                            timestamp,
1747                        })));
1748                    }
1749                }
1750                if subscribed(wire, DerivativesChannel::MarkIndex) {
1751                    if let (Some(mark_price), Some(index_price)) = (mark, field("index_price")) {
1752                        out.push(Event::Derivatives(DerivativesFeed::MarkIndex(MarkIndex {
1753                            symbol,
1754                            mark_price,
1755                            index_price,
1756                            timestamp,
1757                        })));
1758                    }
1759                }
1760            }
1761            Ok(out)
1762        }
1763        "spot.trades" => Ok(vec![Event::Trade(TradePrint {
1764            symbol: resolve(field_str(result, "currency_pair")?),
1765            price: parse_decimal(field_str(result, "price")?)?,
1766            quantity: parse_decimal(field_str(result, "amount")?)?,
1767            aggressor: parse_side(field_str(result, "side")?)?,
1768            timestamp: opt_i64(result, "create_time_ms"),
1769        })]),
1770        "spot.tickers" => Ok(vec![Event::Ticker(Ticker {
1771            symbol: resolve(field_str(result, "currency_pair")?),
1772            last: parse_decimal(field_str(result, "last")?)?,
1773            bid: dec_or_zero(opt_str(result, "highest_bid")),
1774            ask: dec_or_zero(opt_str(result, "lowest_ask")),
1775            volume: dec_or_zero(opt_str(result, "base_volume")),
1776            timestamp: 0,
1777        })]),
1778        "spot.order_book_update" => {
1779            let update_id = opt_u64(result, "u");
1780            Ok(vec![Event::BookDelta(BookDelta {
1781                symbol: resolve(opt_str(result, "s")),
1782                first_update_id: update_id,
1783                final_update_id: update_id,
1784                bids: parse_ws_levels(result.get("b"))?,
1785                asks: parse_ws_levels(result.get("a"))?,
1786                timestamp: opt_u64(result, "t").try_into().unwrap_or(0),
1787            })])
1788        }
1789        // Private order channel: `result` is an array of order objects, each
1790        // carrying its own `currency_pair`.
1791        "spot.orders" => result
1792            .as_array()
1793            .ok_or_else(|| Error::Deserialization("spot.orders result not an array".to_string()))?
1794            .iter()
1795            .map(|order| {
1796                // The private stream carries no public subscription, so the wire
1797                // `currency_pair` is split directly.
1798                let symbol = symbol_from_wire(field_str(order, "currency_pair")?);
1799                let raw: RawOrder = serde_json::from_value(order.clone())
1800                    .map_err(|e| Error::Deserialization(e.to_string()))?;
1801                Ok(Event::OrderUpdate(order_from_raw(symbol, &raw)?))
1802            })
1803            .collect(),
1804        // The futures private order channel. It says everything the spot one
1805        // says, and says all of it differently: the contract instead of the
1806        // pair, a *signed* size instead of a side, and a two-part outcome --
1807        // `status` only reaches `finished`, and `finish_as` says whether that
1808        // was a fill or a cancel. Reading `status` alone reports every
1809        // cancelled order as filled.
1810        "futures.orders" => result
1811            .as_array()
1812            .ok_or_else(|| {
1813                Error::Deserialization("futures.orders result not an array".to_string())
1814            })?
1815            .iter()
1816            .map(|order| Ok(Event::OrderUpdate(futures_order_from_frame(order)?)))
1817            .collect(),
1818        // The futures balance channel reports one account currency, and reports
1819        // it as a running total rather than as free/locked -- margin in use is
1820        // not broken out here, so `locked` is left at zero rather than guessed.
1821        "futures.balances" => {
1822            let balances = result
1823                .as_array()
1824                .ok_or_else(|| {
1825                    Error::Deserialization("futures.balances result not an array".to_string())
1826                })?
1827                .iter()
1828                .map(|entry| {
1829                    Ok(Balance {
1830                        asset: field_str(entry, "currency")?.to_string(),
1831                        free: number_or_string(entry, "balance"),
1832                        locked: Decimal::ZERO,
1833                    })
1834                })
1835                .collect::<Result<Vec<_>>>()?;
1836            Ok(vec![Event::BalanceUpdate(balances)])
1837        }
1838        // Private balance channel: `result` is an array of per-currency balances,
1839        // emitted together as one balance-update snapshot.
1840        "spot.balances" => {
1841            let balances = result
1842                .as_array()
1843                .ok_or_else(|| {
1844                    Error::Deserialization("spot.balances result not an array".to_string())
1845                })?
1846                .iter()
1847                .map(|entry| {
1848                    let total = dec_or_zero(opt_str(entry, "total"));
1849                    let available = dec_or_zero(opt_str(entry, "available"));
1850                    Ok(Balance {
1851                        asset: field_str(entry, "currency")?.to_string(),
1852                        free: available,
1853                        locked: (total - available).max(Decimal::ZERO),
1854                    })
1855                })
1856                .collect::<Result<Vec<_>>>()?;
1857            Ok(vec![Event::BalanceUpdate(balances)])
1858        }
1859        _ => Ok(Vec::new()),
1860    }
1861}
1862
1863#[derive(Deserialize)]
1864struct GateError {
1865    #[serde(default)]
1866    label: String,
1867    #[serde(default)]
1868    message: String,
1869}
1870
1871#[derive(Deserialize)]
1872struct RawTicker {
1873    last: String,
1874    highest_bid: String,
1875    lowest_ask: String,
1876    base_volume: String,
1877}
1878
1879#[derive(Deserialize)]
1880struct RawDepth {
1881    #[serde(default)]
1882    update: u64,
1883    bids: Vec<[String; 2]>,
1884    asks: Vec<[String; 2]>,
1885}
1886
1887/// Build an [`Order`] from a `futures.orders` frame.
1888///
1889/// Gate's futures order carries a **signed** size rather than a side -- negative
1890/// is a sell -- and reports what is left rather than what filled. The id is a
1891/// number here where the spot channel sends a string, and the client's own id
1892/// rides in `text`.
1893fn futures_order_from_frame(order: &serde_json::Value) -> Result<Order> {
1894    let size = order
1895        .get("size")
1896        .and_then(serde_json::Value::as_i64)
1897        .ok_or_else(|| Error::Deserialization("futures order has no size".to_string()))?;
1898    let side = if size < 0 {
1899        OrderSide::Sell
1900    } else {
1901        OrderSide::Buy
1902    };
1903    let quantity = Decimal::from(size.abs());
1904    let left = number_or_string(order, "left").abs();
1905    let price = nonzero_decimal(opt_str(order, "price"));
1906    let text = opt_str(order, "text");
1907    Ok(Order {
1908        id: order
1909            .get("id")
1910            .map(|id| id.as_str().map_or_else(|| id.to_string(), str::to_string))
1911            .ok_or_else(|| Error::Deserialization("futures order has no id".to_string()))?,
1912        // Gate prefixes a caller's own id with `t-`; an order the caller did not
1913        // name carries an id Gate made up, which is not the caller's to match on.
1914        client_order_id: text.starts_with("t-").then(|| text.to_string()),
1915        symbol: symbol_from_wire(field_str(order, "contract")?),
1916        side,
1917        // A futures order with no price is a market order; `price` is "0" there.
1918        order_type: if price.is_some() {
1919            OrderType::Limit
1920        } else {
1921            OrderType::Market
1922        },
1923        status: futures_order_status(
1924            opt_str(order, "status"),
1925            opt_str(order, "finish_as"),
1926            (quantity - left).max(Decimal::ZERO),
1927        ),
1928        quantity,
1929        filled_quantity: (quantity - left).max(Decimal::ZERO),
1930        price,
1931        average_price: nonzero_decimal(opt_str(order, "fill_price")),
1932    })
1933}
1934
1935/// Map a `futures.orders` outcome to an [`OrderStatus`].
1936///
1937/// `status` is only ever `open` or `finished`, and `finish_as` says which kind
1938/// of finished -- `filled`, or one of several ways of ending early, all of
1939/// which leave the order not filled. A **partial** fill is neither: it is an
1940/// order still open that has filled some, which only the amount already filled
1941/// can say.
1942fn futures_order_status(status: &str, finish_as: &str, filled: Decimal) -> OrderStatus {
1943    match status {
1944        "finished" if finish_as == "filled" => OrderStatus::Filled,
1945        "finished" => OrderStatus::Canceled,
1946        _ if filled > Decimal::ZERO => OrderStatus::PartiallyFilled,
1947        _ => OrderStatus::New,
1948    }
1949}
1950
1951/// Read a field Gate's futures channels may send as a number or as a string.
1952fn number_or_string(value: &serde_json::Value, key: &str) -> Decimal {
1953    value.get(key).map_or(Decimal::ZERO, |field| {
1954        field.as_str().map_or_else(
1955            || {
1956                field
1957                    .as_f64()
1958                    .and_then(|n| parse_decimal(&n.to_string()).ok())
1959                    .unwrap_or(Decimal::ZERO)
1960            },
1961            |text| parse_decimal(text).unwrap_or(Decimal::ZERO),
1962        )
1963    })
1964}
1965
1966#[derive(Deserialize)]
1967struct RawOrder {
1968    id: String,
1969    #[serde(default)]
1970    text: String,
1971    side: String,
1972    #[serde(rename = "type")]
1973    order_type: String,
1974    status: String,
1975    amount: String,
1976    #[serde(rename = "filled_amount", default)]
1977    filled_amount: String,
1978    // The private `spot.orders` stream reports the remaining size as `left`
1979    // rather than the filled amount; the filled quantity is derived from it.
1980    #[serde(default)]
1981    left: String,
1982    #[serde(default)]
1983    price: String,
1984    #[serde(rename = "avg_deal_price", default)]
1985    avg_deal_price: String,
1986}
1987
1988#[derive(Deserialize)]
1989struct RawAccount {
1990    currency: String,
1991    #[serde(default)]
1992    available: String,
1993    #[serde(default)]
1994    locked: String,
1995}
1996
1997#[derive(Deserialize)]
1998struct RawFuturesTicker {
1999    last: String,
2000    #[serde(rename = "volume_24h_base", default)]
2001    volume_24h_base: String,
2002}
2003
2004#[derive(Deserialize)]
2005struct RawFuturesCandle {
2006    t: i64,
2007    o: String,
2008    h: String,
2009    l: String,
2010    c: String,
2011    #[serde(default)]
2012    v: f64,
2013}
2014
2015#[derive(Deserialize)]
2016struct RawFuturesLevel {
2017    p: String,
2018    s: i64,
2019}
2020
2021#[derive(Deserialize)]
2022struct RawFuturesDepth {
2023    #[serde(default)]
2024    id: u64,
2025    bids: Vec<RawFuturesLevel>,
2026    asks: Vec<RawFuturesLevel>,
2027}
2028
2029#[derive(Deserialize)]
2030struct RawFuturesOrder {
2031    id: i64,
2032    size: i64,
2033    #[serde(default)]
2034    left: i64,
2035    #[serde(default)]
2036    price: String,
2037    #[serde(rename = "fill_price", default)]
2038    fill_price: String,
2039    status: String,
2040    #[serde(rename = "finish_as", default)]
2041    finish_as: String,
2042    #[serde(default)]
2043    text: String,
2044}
2045
2046#[derive(Deserialize)]
2047struct RawFuturesAccount {
2048    #[serde(default = "usdt_currency")]
2049    currency: String,
2050    #[serde(default)]
2051    total: String,
2052    #[serde(default)]
2053    available: String,
2054}
2055
2056fn usdt_currency() -> String {
2057    "USDT".to_string()
2058}
2059
2060#[derive(Deserialize)]
2061struct RawFuturesPosition {
2062    contract: String,
2063    size: i64,
2064    #[serde(default)]
2065    leverage: String,
2066    #[serde(rename = "cross_leverage_limit", default)]
2067    cross_leverage_limit: String,
2068    #[serde(rename = "entry_price", default)]
2069    entry_price: String,
2070    #[serde(rename = "mark_price", default)]
2071    mark_price: String,
2072    #[serde(rename = "unrealised_pnl", default)]
2073    unrealised_pnl: String,
2074}
2075
2076/// Round a base quantity to a whole number of Gate futures contracts.
2077fn decimal_to_contracts(quantity: Decimal) -> Result<i64> {
2078    let contracts = quantity
2079        .round()
2080        .to_i64()
2081        .filter(|c| *c != 0)
2082        .ok_or(Error::InvalidOrder("futures size rounds to zero contracts"))?;
2083    Ok(contracts.abs())
2084}
2085
2086fn parse_futures_candle(row: &RawFuturesCandle) -> Result<Candle> {
2087    let f = |s: &str| -> Result<f64> {
2088        s.parse::<f64>()
2089            .map_err(|e| Error::Deserialization(format!("candle field not a number: {e}")))
2090    };
2091    Candle::new(f(&row.o)?, f(&row.h)?, f(&row.l)?, f(&row.c)?, row.v, row.t)
2092        .map_err(|e| Error::Deserialization(e.to_string()))
2093}
2094
2095fn parse_futures_levels(levels: &[RawFuturesLevel]) -> Result<Vec<BookLevel>> {
2096    levels
2097        .iter()
2098        .map(|level| {
2099            Ok(BookLevel {
2100                price: parse_decimal(&level.p)?,
2101                quantity: Decimal::from(level.s.abs()),
2102            })
2103        })
2104        .collect()
2105}
2106
2107fn futures_order_from_raw(symbol: Symbol, raw: &RawFuturesOrder) -> Result<Order> {
2108    let side = if raw.size >= 0 {
2109        OrderSide::Buy
2110    } else {
2111        OrderSide::Sell
2112    };
2113    let quantity = Decimal::from(raw.size.abs());
2114    let filled = Decimal::from(raw.size.abs() - raw.left.abs());
2115    let order_type = if raw.price == "0" || raw.price.is_empty() {
2116        OrderType::Market
2117    } else {
2118        OrderType::Limit
2119    };
2120    let status = match raw.status.as_str() {
2121        "open" => {
2122            if filled > Decimal::ZERO {
2123                OrderStatus::PartiallyFilled
2124            } else {
2125                OrderStatus::New
2126            }
2127        }
2128        "finished" => {
2129            if raw.finish_as == "filled" {
2130                OrderStatus::Filled
2131            } else {
2132                OrderStatus::Canceled
2133            }
2134        }
2135        other => return Err(Error::Deserialization(format!("unknown status {other:?}"))),
2136    };
2137    Ok(Order {
2138        id: raw.id.to_string(),
2139        client_order_id: (!raw.text.is_empty()).then(|| raw.text.clone()),
2140        symbol,
2141        side,
2142        order_type,
2143        status,
2144        quantity,
2145        filled_quantity: filled,
2146        price: nonzero_decimal(&raw.price),
2147        average_price: nonzero_decimal(&raw.fill_price),
2148    })
2149}
2150
2151fn parse_futures_position(raw: &RawFuturesPosition) -> Position {
2152    let side = if raw.size > 0 {
2153        PositionSide::Long
2154    } else {
2155        PositionSide::Short
2156    };
2157    // Gate reports `leverage == "0"` for cross positions and carries the effective
2158    // cap in `cross_leverage_limit`.
2159    let is_cross = raw.leverage.is_empty() || raw.leverage == "0";
2160    let leverage = if is_cross {
2161        dec_or_zero(&raw.cross_leverage_limit)
2162    } else {
2163        dec_or_zero(&raw.leverage)
2164    };
2165    Position {
2166        symbol: symbol_from_contract(&raw.contract),
2167        side,
2168        quantity: Decimal::from(raw.size.abs()),
2169        entry_price: dec_or_zero(&raw.entry_price),
2170        mark_price: dec_or_zero(&raw.mark_price),
2171        leverage,
2172        unrealized_pnl: dec_or_zero(&raw.unrealised_pnl),
2173        margin_mode: if is_cross {
2174            MarginMode::Cross
2175        } else {
2176            MarginMode::Isolated
2177        },
2178    }
2179}
2180
2181/// Reconstruct a canonical [`Symbol`] from a Gate contract (`BTC_USDT`).
2182fn symbol_from_contract(contract: &str) -> Symbol {
2183    match contract.split_once('_') {
2184        Some((base, quote)) => Symbol::new(base, quote),
2185        None => Symbol::new(contract, ""),
2186    }
2187}
2188
2189impl MarketData for Gate {
2190    fn ticker(&mut self, symbol: &Symbol) -> Result<Ticker> {
2191        Gate::ticker(self, symbol)
2192    }
2193    fn klines(&mut self, symbol: &Symbol, interval: &str, limit: u32) -> Result<Vec<Candle>> {
2194        Gate::klines(self, symbol, interval, limit)
2195    }
2196    fn order_book(&mut self, symbol: &Symbol, depth: u32) -> Result<OrderBookSnapshot> {
2197        Gate::order_book(self, symbol, depth)
2198    }
2199    fn subscribe_trades(&mut self, symbol: &Symbol) -> Result<()> {
2200        Gate::subscribe_trades(self, symbol)
2201    }
2202    fn subscribe_book(&mut self, symbol: &Symbol) -> Result<()> {
2203        Gate::subscribe_book(self, symbol)
2204    }
2205    fn subscribe_ticker(&mut self, symbol: &Symbol) -> Result<()> {
2206        Gate::subscribe_ticker(self, symbol)
2207    }
2208    fn poll_events(&mut self) -> Vec<Event> {
2209        Gate::poll_events(self)
2210    }
2211}
2212
2213impl Execution for Gate {
2214    fn place_order(&mut self, request: &OrderRequest) -> Result<Order> {
2215        Gate::place_order(self, request)
2216    }
2217    fn cancel_order(&mut self, symbol: &Symbol, order_id: &str) -> Result<()> {
2218        Gate::cancel_order(self, symbol, order_id)
2219    }
2220    fn query_order(&mut self, symbol: &Symbol, order_id: &str) -> Result<Order> {
2221        Gate::query_order(self, symbol, order_id)
2222    }
2223    fn open_orders(&mut self, symbol: Option<&Symbol>) -> Result<Vec<Order>> {
2224        Gate::open_orders(self, symbol)
2225    }
2226    fn balances(&mut self) -> Result<Vec<Balance>> {
2227        Gate::balances(self)
2228    }
2229}
2230
2231impl Exchange for Gate {
2232    fn name(&self) -> &'static str {
2233        "gate"
2234    }
2235}
2236
2237impl WsUserData for Gate {
2238    fn subscribe_user_data(&mut self) -> Result<()> {
2239        Gate::subscribe_user_data(self)
2240    }
2241    fn keepalive_user_data(&mut self) -> Result<()> {
2242        Gate::keepalive_user_data(self)
2243    }
2244}
2245
2246impl WsExecution for Gate {
2247    fn place_order_ws(&mut self, request: &OrderRequest) -> Result<Order> {
2248        Gate::place_order_ws(self, request)
2249    }
2250    fn cancel_order_ws(&mut self, symbol: &Symbol, order_id: &str) -> Result<()> {
2251        Gate::cancel_order_ws(self, symbol, order_id)
2252    }
2253}
2254
2255impl Derivatives for Gate {
2256    fn positions(&mut self, symbol: Option<&Symbol>) -> Result<Vec<Position>> {
2257        Gate::positions(self, symbol)
2258    }
2259    fn set_leverage(&mut self, symbol: &Symbol, leverage: u32) -> Result<()> {
2260        Gate::set_leverage(self, symbol, leverage)
2261    }
2262    fn set_margin_mode(&mut self, symbol: &Symbol, mode: MarginMode) -> Result<()> {
2263        Gate::set_margin_mode(self, symbol, mode)
2264    }
2265    fn close_position(&mut self, symbol: &Symbol) -> Result<Order> {
2266        Gate::close_position(self, symbol)
2267    }
2268}
2269
2270impl DerivativesStream for Gate {
2271    fn subscribe_derivatives(
2272        &mut self,
2273        symbol: &Symbol,
2274        channel: DerivativesChannel,
2275    ) -> Result<()> {
2276        Gate::subscribe_derivatives(self, symbol, channel)
2277    }
2278    fn open_interest(&mut self, symbol: &Symbol) -> Result<OpenInterest> {
2279        Gate::open_interest(self, symbol)
2280    }
2281    fn long_short_ratio(&mut self, symbol: &Symbol) -> Result<LongShortRatio> {
2282        Gate::long_short_ratio(self, symbol)
2283    }
2284}
2285
2286impl Gate {
2287    /// Amend a resting spot order's price and/or amount in place
2288    /// (`PATCH /api/v4/spot/orders/{id}`).
2289    ///
2290    /// # Errors
2291    /// Returns an [`Error`] if the order is unknown or the amend is rejected.
2292    pub fn amend_order(
2293        &self,
2294        symbol: &Symbol,
2295        order_id: &str,
2296        new_price: Option<Decimal>,
2297        new_quantity: Option<Decimal>,
2298    ) -> Result<Order> {
2299        let mut body = serde_json::json!({});
2300        if let Some(q) = new_quantity {
2301            body["amount"] = serde_json::json!(format_decimal(q));
2302        }
2303        if let Some(p) = new_price {
2304            body["price"] = serde_json::json!(format_decimal(p));
2305        }
2306        let path = format!("/api/v4/spot/orders/{order_id}");
2307        let query = format!("currency_pair={}", Self::wire_symbol(symbol));
2308        let value = self.signed_request(HttpMethod::Patch, &path, &query, &body.to_string())?;
2309        let raw: RawOrder = parse_json(value)?;
2310        order_from_raw(symbol.clone(), &raw)
2311    }
2312
2313    /// Place several spot orders in one request (`/api/v4/spot/batch_orders`).
2314    /// Each element carries a `succeeded` flag, so each request's own [`Result`]
2315    /// is preserved.
2316    ///
2317    /// # Errors
2318    /// Returns an [`Error`] if the batch request itself fails.
2319    pub fn place_batch(&self, requests: &[OrderRequest]) -> Result<Vec<Result<Order>>> {
2320        if requests.iter().any(|r| r.order_type.is_trigger()) {
2321            return Err(Error::unsupported_trigger("Gate.io"));
2322        }
2323        for request in requests {
2324            self.ensure_reduce_only_is_reducible(request)?;
2325        }
2326        // Resolved before the batch is built, so a request the venue cannot
2327        // express refuses the whole call rather than being sent weakened.
2328        let tifs = requests.iter().map(tif_str).collect::<Result<Vec<_>>>()?;
2329        let items: Vec<serde_json::Value> = requests
2330            .iter()
2331            .zip(&tifs)
2332            .map(|(r, tif)| {
2333                let mut o = serde_json::json!({
2334                    "currency_pair": Self::wire_symbol(&r.symbol),
2335                    "side": side_str(r.side),
2336                    "type": order_type_str(r.order_type),
2337                    "amount": format_decimal(r.quantity),
2338                });
2339                if let Some(price) = r.price {
2340                    o["price"] = serde_json::json!(format_decimal(price));
2341                }
2342                if let Some(tif) = tif {
2343                    o["time_in_force"] = serde_json::json!(*tif);
2344                }
2345                if let Some(id) = &r.client_order_id {
2346                    let text = if id.starts_with("t-") {
2347                        id.clone()
2348                    } else {
2349                        format!("t-{id}")
2350                    };
2351                    o["text"] = serde_json::json!(text);
2352                }
2353                if let Some(act) = stp_act_str(r.stp) {
2354                    o["stp_act"] = serde_json::json!(act);
2355                }
2356                o
2357            })
2358            .collect();
2359        let body = serde_json::Value::Array(items).to_string();
2360        let value =
2361            self.signed_request(HttpMethod::Post, "/api/v4/spot/batch_orders", "", &body)?;
2362        let elements: Vec<serde_json::Value> = parse_json(value)?;
2363        Ok(requests
2364            .iter()
2365            .zip(elements)
2366            .map(|(req, elem)| {
2367                let succeeded = elem
2368                    .get("succeeded")
2369                    .and_then(serde_json::Value::as_bool)
2370                    .unwrap_or(true);
2371                if !succeeded {
2372                    let message = elem
2373                        .get("message")
2374                        .or_else(|| elem.get("label"))
2375                        .and_then(serde_json::Value::as_str)
2376                        .unwrap_or("order rejected in batch")
2377                        .to_string();
2378                    return Err(Error::OrderRejected {
2379                        code: "batch".to_string(),
2380                        message,
2381                    });
2382                }
2383                let raw: RawOrder = serde_json::from_value(elem)
2384                    .map_err(|e| Error::Deserialization(e.to_string()))?;
2385                order_from_raw(req.symbol.clone(), &raw)
2386            })
2387            .collect())
2388    }
2389
2390    /// Cancel several orders on one `symbol` in one request
2391    /// (`/api/v4/spot/cancel_batch_orders`).
2392    ///
2393    /// # Errors
2394    /// Returns an [`Error`] if the request fails.
2395    pub fn cancel_batch(&self, symbol: &Symbol, order_ids: &[String]) -> Result<()> {
2396        let pair = Self::wire_symbol(symbol);
2397        let items: Vec<serde_json::Value> = order_ids
2398            .iter()
2399            .map(|id| serde_json::json!({ "currency_pair": pair, "id": id }))
2400            .collect();
2401        let body = serde_json::Value::Array(items).to_string();
2402        self.signed_request(
2403            HttpMethod::Post,
2404            "/api/v4/spot/cancel_batch_orders",
2405            "",
2406            &body,
2407        )?;
2408        Ok(())
2409    }
2410}
2411
2412impl AdvancedOrders for Gate {
2413    fn amend_order(
2414        &mut self,
2415        symbol: &Symbol,
2416        order_id: &str,
2417        new_price: Option<Decimal>,
2418        new_quantity: Option<Decimal>,
2419    ) -> Result<Order> {
2420        Gate::amend_order(self, symbol, order_id, new_price, new_quantity)
2421    }
2422    fn place_batch(&mut self, requests: &[OrderRequest]) -> Result<Vec<Result<Order>>> {
2423        Gate::place_batch(self, requests)
2424    }
2425    fn cancel_batch(&mut self, symbol: &Symbol, order_ids: &[String]) -> Result<()> {
2426        Gate::cancel_batch(self, symbol, order_ids)
2427    }
2428    /// Gate has no OCO order-list (it uses standalone price-triggered orders), so
2429    /// this returns an [`Error::Exchange`].
2430    fn place_oco(&mut self, _request: &OcoRequest) -> Result<Vec<Order>> {
2431        Err(Error::Exchange {
2432            code: "unsupported".to_string(),
2433            message: "Gate has no OCO order-list; use price-triggered orders".to_string(),
2434        })
2435    }
2436}
2437
2438#[cfg(test)]
2439mod tests {
2440    use super::*;
2441    use crate::transport::{MockHttpTransport, MockWsTransport};
2442    use rust_decimal_macros::dec;
2443    use std::sync::Arc;
2444
2445    struct ArcTransport(Arc<MockHttpTransport>);
2446    impl HttpTransport for ArcTransport {
2447        fn execute(&self, request: &HttpRequest) -> Result<HttpResponse> {
2448            self.0.execute(request)
2449        }
2450    }
2451    struct ArcWs(Arc<MockWsTransport>);
2452    impl WsTransport for ArcWs {
2453        fn connect(&self, url: &str) -> Result<Box<dyn WsConnection>> {
2454            self.0.connect(url)
2455        }
2456    }
2457
2458    fn symbol() -> Symbol {
2459        Symbol::new("BTC", "USDT")
2460    }
2461
2462    fn client() -> (Gate, Arc<MockHttpTransport>) {
2463        let mock = Arc::new(MockHttpTransport::new());
2464        let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
2465        (
2466            Gate::with_http(Box::new(ArcTransport(Arc::clone(&mock))), &opts),
2467            mock,
2468        )
2469    }
2470
2471    fn futures_ws_client() -> (Gate, Arc<MockWsTransport>) {
2472        let http = Arc::new(MockHttpTransport::new());
2473        let ws = Arc::new(MockWsTransport::new());
2474        let opts = ExchangeOptions::mainnet(crate::MarketType::UsdMFutures);
2475        let gate = Gate::with_http(Box::new(ArcTransport(http)), &opts)
2476            .with_ws(Box::new(ArcWs(Arc::clone(&ws))));
2477        (gate, ws)
2478    }
2479
2480    /// The answers Gate gives on a bad day.
2481    #[test]
2482    fn gate_polled_reads_report_what_went_wrong() {
2483        let http = Arc::new(MockHttpTransport::new());
2484        let opts = ExchangeOptions::mainnet(crate::MarketType::UsdMFutures);
2485        let gate = Gate::with_http(Box::new(ArcTransport(Arc::clone(&http))), &opts);
2486
2487        http.push_json(200, "[]");
2488        assert!(matches!(
2489            Gate::open_interest(&gate, &symbol()),
2490            Err(Error::NotFound(_))
2491        ));
2492
2493        http.push_json(200, r#"[{"time":1788464400}]"#);
2494        assert!(matches!(
2495            Gate::open_interest(&gate, &symbol()),
2496            Err(Error::Deserialization(_))
2497        ));
2498
2499        http.push_json(200, r#"[{"time":1788464400,"open_interest":1}]"#);
2500        assert!(matches!(
2501            Gate::long_short_ratio(&gate, &symbol()),
2502            Err(Error::Deserialization(_))
2503        ));
2504
2505        // A ratio of -1 has no proportions: 1 + r is zero, and the division
2506        // that follows would be by zero.
2507        http.push_json(200, r#"[{"time":1788464400,"lsr_account":-1.0}]"#);
2508        assert!(matches!(
2509            Gate::long_short_ratio(&gate, &symbol()),
2510            Err(Error::Deserialization(_))
2511        ));
2512    }
2513
2514    /// Frames the futures parser cannot use are dropped.
2515    #[test]
2516    fn gate_drops_futures_frames_it_cannot_read() {
2517        let (mut gate, ws) = futures_ws_client();
2518        ws.push_connection(vec![
2519            // A book update with no contract.
2520            Ok(Some(
2521                r#"{"time":1,"channel":"futures.order_book_update","event":"update",
2522                "result":{"t":1,"U":1,"u":2,"a":[],"b":[]}}"#
2523                    .to_string(),
2524            )),
2525            // A trade with no price.
2526            Ok(Some(
2527                r#"{"time":1,"channel":"futures.trades","event":"update",
2528                "result":[{"size":1,"contract":"BTC_USDT"}]}"#
2529                    .to_string(),
2530            )),
2531            // A ticker with no last price.
2532            Ok(Some(
2533                r#"{"time":1,"channel":"futures.tickers","event":"update",
2534                "result":[{"contract":"BTC_USDT"}]}"#
2535                    .to_string(),
2536            )),
2537            // A level whose size is missing.
2538            Ok(Some(
2539                r#"{"time":1,"channel":"futures.order_book_update","event":"update",
2540                "result":{"t":1,"U":1,"u":2,"s":"BTC_USDT","a":[{"p":"1.0"}],"b":[]}}"#
2541                    .to_string(),
2542            )),
2543        ]);
2544        Gate::subscribe_book(&mut gate, &symbol()).unwrap();
2545
2546        let events = Gate::poll_events(&mut gate);
2547        // Only the last frame produces anything, and its one unreadable level
2548        // is dropped rather than defaulted.
2549        assert_eq!(events.len(), 1, "{events:?}");
2550        let Event::BookDelta(delta) = &events[0] else {
2551            panic!("expected a delta");
2552        };
2553        assert!(delta.asks.is_empty() && delta.bids.is_empty());
2554    }
2555
2556    /// One ticker frame answers both subscriptions.
2557    #[test]
2558    fn the_futures_ticker_answers_funding_and_mark_index_on_gate() {
2559        let (mut gate, ws) = futures_ws_client();
2560        ws.push_connection(vec![Ok(Some(
2561            r#"{"time":1788462378,"channel":"futures.tickers","event":"update",
2562            "result":[{"contract":"BTC_USDT","last":"81231.4","mark_price":"81238.4",
2563            "index_price":"81271.1","funding_rate":"0.000073",
2564            "volume_24h_base":"81628.265","t":1788462378855}]}"#
2565                .to_string(),
2566        ))]);
2567        Gate::subscribe_derivatives(&mut gate, &symbol(), DerivativesChannel::Funding).unwrap();
2568        Gate::subscribe_derivatives(&mut gate, &symbol(), DerivativesChannel::MarkIndex).unwrap();
2569
2570        let events = Gate::poll_events(&mut gate);
2571        assert_eq!(events.len(), 3, "ticker, funding, mark/index: {events:?}");
2572
2573        let Event::Derivatives(DerivativesFeed::Funding(funding)) = &events[1] else {
2574            panic!("expected funding, got {:?}", events[1]);
2575        };
2576        assert_eq!(funding.rate, dec!(0.000073));
2577        assert_eq!(funding.mark_price, dec!(81238.4));
2578
2579        let Event::Derivatives(DerivativesFeed::MarkIndex(mark)) = &events[2] else {
2580            panic!("expected mark/index, got {:?}", events[2]);
2581        };
2582        assert_eq!(mark.index_price, dec!(81271.1));
2583    }
2584
2585    /// Gate's `futures.liquidates` needs authentication and reports the
2586    /// caller's own liquidations, not the venue's forced flow, so it is not the
2587    /// public feed this channel means.
2588    #[test]
2589    fn gate_refuses_liquidations() {
2590        let (mut gate, ws) = futures_ws_client();
2591        let err =
2592            Gate::subscribe_derivatives(&mut gate, &symbol(), DerivativesChannel::Liquidations)
2593                .expect_err("Gate publishes no public forced-order feed");
2594        assert!(matches!(err, Error::Exchange { ref code, .. } if code == "unsupported"));
2595        assert_eq!(ws.sent(), Vec::<String>::new());
2596    }
2597
2598    /// `contract_stats` carries both polled figures, and stamps in **seconds**.
2599    #[test]
2600    fn the_polled_figures_come_from_one_row_stamped_in_seconds() {
2601        let http = Arc::new(MockHttpTransport::new());
2602        let opts = ExchangeOptions::mainnet(crate::MarketType::UsdMFutures);
2603        let gate = Gate::with_http(Box::new(ArcTransport(Arc::clone(&http))), &opts);
2604        http.push_json(
2605            200,
2606            r#"[{"time":1788464400,"lsr_taker":1.6098,"lsr_account":3.0,
2607            "open_interest":626095712,"mark_price":81477,
2608            "open_interest_usd":5101240032.6624}]"#,
2609        );
2610        let open_interest = Gate::open_interest(&gate, &symbol()).unwrap();
2611        assert_eq!(open_interest.open_interest, dec!(626095712));
2612        // Seconds on the wire, milliseconds in the type.
2613        assert_eq!(open_interest.timestamp, 1_788_464_400_000);
2614
2615        http.push_json(
2616            200,
2617            r#"[{"time":1788464400,"lsr_account":3.0,"open_interest":626095712}]"#,
2618        );
2619        let ratio = Gate::long_short_ratio(&gate, &symbol()).unwrap();
2620        // A ratio of 3 is 0.75 long and 0.25 short, and the two sum to one.
2621        assert_eq!(ratio.long_size, dec!(0.75));
2622        assert_eq!(ratio.short_size, dec!(0.25));
2623        assert_eq!(ratio.long_size + ratio.short_size, Decimal::ONE);
2624    }
2625
2626    #[test]
2627    fn a_spot_client_refuses_the_gate_derivatives_surface() {
2628        let ws = Arc::new(MockWsTransport::new());
2629        let http = Arc::new(MockHttpTransport::new());
2630        let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
2631        let mut gate = Gate::with_http(Box::new(ArcTransport(Arc::clone(&http))), &opts)
2632            .with_ws(Box::new(ArcWs(Arc::clone(&ws))));
2633        for channel in [
2634            DerivativesChannel::Funding,
2635            DerivativesChannel::MarkIndex,
2636            DerivativesChannel::Liquidations,
2637        ] {
2638            assert!(Gate::subscribe_derivatives(&mut gate, &symbol(), channel).is_err());
2639        }
2640        assert!(Gate::open_interest(&gate, &symbol()).is_err());
2641        assert!(Gate::long_short_ratio(&gate, &symbol()).is_err());
2642    }
2643
2644    /// Both the host and the channel prefix follow the market.
2645    #[test]
2646    fn a_futures_client_subscribes_on_the_futures_host() {
2647        let (mut gate, ws) = futures_ws_client();
2648        Gate::subscribe_trades(&mut gate, &symbol()).unwrap();
2649        Gate::subscribe_book(&mut gate, &symbol()).unwrap();
2650
2651        assert_eq!(ws.connected_urls()[0], "wss://fx-ws.gateio.ws/v4/ws/usdt");
2652        assert!(ws.sent()[0].contains(r#""channel":"futures.trades""#));
2653        assert!(!ws.sent()[0].contains("spot."));
2654        // The book channel needs an interval and a depth beside the contract.
2655        assert!(ws.sent()[1].contains(r#""channel":"futures.order_book_update""#));
2656        assert!(ws.sent()[1].contains(r#""payload":["BTC_USDT","100ms","20"]"#));
2657    }
2658
2659    /// A futures trade carries no side: the size is signed, and negative means
2660    /// the aggressor sold.
2661    #[test]
2662    fn a_futures_trade_takes_its_side_from_the_sign_of_the_size() {
2663        let (mut gate, ws) = futures_ws_client();
2664        ws.push_connection(vec![
2665            Ok(Some(
2666                r#"{"time":1788462378,"time_ms":1788462378855,"channel":"futures.trades",
2667                "event":"update","result":[{"id":826990332,"size":14372,
2668                "create_time":1788462378,"create_time_ms":1788462378855,
2669                "price":"81231.4","contract":"BTC_USDT"}]}"#
2670                    .to_string(),
2671            )),
2672            Ok(Some(
2673                r#"{"time":1788462379,"channel":"futures.trades","event":"update",
2674                "result":[{"id":826990333,"size":-25,"create_time_ms":1788462379000,
2675                "price":"81230.0","contract":"BTC_USDT"}]}"#
2676                    .to_string(),
2677            )),
2678        ]);
2679        Gate::subscribe_trades(&mut gate, &symbol()).unwrap();
2680
2681        let events = Gate::poll_events(&mut gate);
2682        let Event::Trade(bought) = &events[0] else {
2683            panic!("expected a trade, got {:?}", events[0]);
2684        };
2685        assert_eq!(bought.aggressor, OrderSide::Buy);
2686        assert_eq!(bought.quantity, dec!(14372));
2687        assert_eq!(bought.price, dec!(81231.4));
2688        assert_eq!(bought.timestamp, 1_788_462_378_855);
2689
2690        let Event::Trade(sold) = &events[1] else {
2691            panic!("expected a trade, got {:?}", events[1]);
2692        };
2693        assert_eq!(sold.aggressor, OrderSide::Sell);
2694        // The magnitude, not the signed figure: a quantity is not negative.
2695        assert_eq!(sold.quantity, dec!(25));
2696    }
2697
2698    #[test]
2699    fn a_futures_book_update_parses_both_sides() {
2700        let (mut gate, ws) = futures_ws_client();
2701        ws.push_connection(vec![Ok(Some(
2702            r#"{"time":1788462378,"channel":"futures.order_book_update","event":"update",
2703            "result":{"t":1788462378780,"U":124074489866,"u":124074489905,"s":"BTC_USDT",
2704            "a":[{"p":"81231.4","s":4572},{"p":"81239.3","s":0}],
2705            "b":[{"p":"81230.0","s":12}],"l":"20"}}"#
2706                .to_string(),
2707        ))]);
2708        Gate::subscribe_book(&mut gate, &symbol()).unwrap();
2709
2710        let events = Gate::poll_events(&mut gate);
2711        let Event::BookDelta(delta) = &events[0] else {
2712            panic!("expected a delta, got {:?}", events[0]);
2713        };
2714        assert_eq!(delta.asks.len(), 2);
2715        assert_eq!(delta.asks[0].price, dec!(81231.4));
2716        // A size of zero removes the level, which the delta type already means.
2717        assert_eq!(delta.asks[1].quantity, Decimal::ZERO);
2718        assert_eq!(delta.bids.len(), 1);
2719        assert_eq!(delta.first_update_id, 124_074_489_866);
2720        assert_eq!(delta.final_update_id, 124_074_489_905);
2721    }
2722
2723    /// The futures ticker publishes no top of book, so those stay zero rather
2724    /// than being invented from the last price.
2725    #[test]
2726    fn a_futures_ticker_reports_no_top_of_book() {
2727        let (mut gate, ws) = futures_ws_client();
2728        ws.push_connection(vec![Ok(Some(
2729            r#"{"time":1788462378,"channel":"futures.tickers","event":"update",
2730            "result":[{"contract":"BTC_USDT","last":"81231.4","mark_price":"81238.4",
2731            "index_price":"81271.1","funding_rate":"0.000073","volume_24h_base":"81628.265",
2732            "t":1788462378855}]}"#
2733                .to_string(),
2734        ))]);
2735        Gate::subscribe_ticker(&mut gate, &symbol()).unwrap();
2736
2737        let events = Gate::poll_events(&mut gate);
2738        let Event::Ticker(ticker) = &events[0] else {
2739            panic!("expected a ticker, got {:?}", events[0]);
2740        };
2741        assert_eq!(ticker.last, dec!(81231.4));
2742        assert_eq!(ticker.volume, dec!(81628.265));
2743        assert_eq!(ticker.bid, Decimal::ZERO);
2744        assert_eq!(ticker.ask, Decimal::ZERO);
2745        assert_eq!(ticker.timestamp, 1_788_462_378_855);
2746    }
2747
2748    /// The spot client keeps its own host and channels.
2749    #[test]
2750    fn a_spot_client_still_subscribes_to_spot_channels() {
2751        let ws = Arc::new(MockWsTransport::new());
2752        let http = Arc::new(MockHttpTransport::new());
2753        let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
2754        let mut gate = Gate::with_http(Box::new(ArcTransport(http)), &opts)
2755            .with_ws(Box::new(ArcWs(Arc::clone(&ws))));
2756        Gate::subscribe_trades(&mut gate, &symbol()).unwrap();
2757
2758        assert_eq!(ws.connected_urls()[0], "wss://api.gateio.ws/ws/v4/");
2759        assert!(ws.sent()[0].contains(r#""channel":"spot.trades""#));
2760    }
2761
2762    fn signed_client(now_ms: i64) -> (Gate, Arc<MockHttpTransport>) {
2763        let mock = Arc::new(MockHttpTransport::new());
2764        let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
2765        let gate = Gate::with_credentials(
2766            Box::new(ArcTransport(Arc::clone(&mock))),
2767            &opts,
2768            Credentials::new("APIKEY", "SECRET"),
2769        )
2770        .with_clock(Box::new(move || now_ms));
2771        (gate, mock)
2772    }
2773
2774    fn signed_ws_client(now_ms: i64) -> (Gate, Arc<MockWsTransport>) {
2775        let http = Arc::new(MockHttpTransport::new());
2776        let ws = Arc::new(MockWsTransport::new());
2777        let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
2778        let gate = Gate::with_credentials(
2779            Box::new(ArcTransport(http)),
2780            &opts,
2781            Credentials::new("APIKEY", "SECRET"),
2782        )
2783        .with_ws(Box::new(ArcWs(Arc::clone(&ws))))
2784        .with_clock(Box::new(move || now_ms));
2785        (gate, ws)
2786    }
2787
2788    #[test]
2789    fn subscribe_user_data_authenticates_and_streams_orders_and_balances() {
2790        let (mut gate, ws) = signed_ws_client(1_700_000_000_000);
2791        ws.push_connection(vec![
2792            Ok(Some(r#"{"channel":"spot.orders","event":"subscribe"}"#.to_string())),
2793            Ok(Some(r#"{"channel":"spot.balances","event":"subscribe"}"#.to_string())),
2794            Ok(Some(
2795                r#"{"time":1,"channel":"spot.orders","event":"update","result":[{"id":"55",
2796                "text":"my","currency_pair":"BTC_USDT","side":"buy","type":"limit","status":"closed",
2797                "amount":"1","left":"0","price":"100","avg_deal_price":"100"}]}"#
2798                    .to_string(),
2799            )),
2800            Ok(Some(
2801                r#"{"time":2,"channel":"spot.balances","event":"update","result":[{"currency":"USDT",
2802                "available":"900","total":"950"}]}"#
2803                    .to_string(),
2804            )),
2805        ]);
2806        gate.subscribe_user_data().unwrap();
2807        assert_eq!(ws.connected_urls()[0], "wss://api.gateio.ws/ws/v4/");
2808        assert!(ws.sent()[0].contains(r#""channel":"spot.orders""#));
2809        assert!(ws.sent()[0].contains(r#""KEY":"APIKEY""#));
2810        assert!(ws.sent()[0].contains(r#""SIGN""#));
2811        assert!(ws.sent()[1].contains(r#""channel":"spot.balances""#));
2812
2813        let events = gate.poll_events();
2814        assert_eq!(events.len(), 2);
2815        let Event::OrderUpdate(order) = &events[0] else {
2816            panic!("first event must be an order update");
2817        };
2818        assert_eq!(order.id, "55");
2819        assert_eq!(order.symbol, symbol());
2820        assert_eq!(order.side, OrderSide::Buy);
2821        assert_eq!(order.status, OrderStatus::Filled);
2822        assert_eq!(order.filled_quantity, dec!(1));
2823        assert_eq!(order.average_price, Some(dec!(100)));
2824        let Event::BalanceUpdate(balances) = &events[1] else {
2825            panic!("second event must be a balance update");
2826        };
2827        assert_eq!(balances.len(), 1);
2828        assert_eq!(balances[0].asset, "USDT");
2829        assert_eq!(balances[0].free, dec!(900));
2830        assert_eq!(balances[0].locked, dec!(50));
2831    }
2832
2833    #[test]
2834    fn subscribe_user_data_requires_credentials() {
2835        let http = Arc::new(MockHttpTransport::new());
2836        let ws = Arc::new(MockWsTransport::new());
2837        let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
2838        let mut gate = Gate::with_http(Box::new(ArcTransport(http)), &opts)
2839            .with_ws(Box::new(ArcWs(Arc::clone(&ws))));
2840        assert!(matches!(
2841            gate.subscribe_user_data().unwrap_err(),
2842            Error::InvalidCredentials(_)
2843        ));
2844    }
2845
2846    #[test]
2847    fn keepalive_user_data_pings_the_private_stream() {
2848        let (mut gate, ws) = signed_ws_client(1_700_000_000_000);
2849        ws.push_connection(vec![]);
2850        gate.subscribe_user_data().unwrap();
2851        gate.keepalive_user_data().unwrap();
2852        assert!(ws.sent().iter().any(|f| f == "ping"));
2853    }
2854
2855    #[test]
2856    fn keepalive_user_data_is_a_noop_before_subscribe() {
2857        let (mut gate, ws) = signed_ws_client(1_700_000_000_000);
2858        gate.keepalive_user_data().unwrap();
2859        assert_eq!(ws.sent(), Vec::<String>::new());
2860    }
2861
2862    #[test]
2863    fn dropped_user_data_stream_reconnects_with_fresh_signed_frames() {
2864        let (mut gate, ws) = signed_ws_client(1_700_000_000_000);
2865        // The first private connection closes on the first recv; the reconnect
2866        // target is a fresh open connection.
2867        ws.push_connection(vec![Ok(None)]);
2868        ws.push_connection(vec![]);
2869        gate.subscribe_user_data().unwrap();
2870
2871        let events = gate.poll_events();
2872        assert!(events.contains(&Event::Disconnected));
2873        assert!(events.contains(&Event::Reconnected));
2874        // Two private connections (initial + reconnect); each re-signs the
2875        // spot.orders subscribe, so four signed subscribe frames total.
2876        let signed_subs = ws
2877            .sent()
2878            .into_iter()
2879            .filter(|f| f.contains(r#""SIGN""#))
2880            .count();
2881        assert_eq!(signed_subs, 4);
2882        assert_eq!(ws.connected_urls().len(), 2);
2883        assert_eq!(ws.connected_urls()[1], "wss://api.gateio.ws/ws/v4/");
2884    }
2885
2886    #[test]
2887    fn the_websocket_frame_carries_the_stp_policy() {
2888        // `stp_act` is on the REST spot body and was missing from this frame,
2889        // which otherwise uses the same field names.
2890        let (mut gate, ws) = signed_ws_client(1_700_000_000_000);
2891        ws.push_connection(vec![
2892            Ok(Some(
2893                r#"{"request_id":"login","header":{"status":"200","channel":"spot.login",
2894                "event":"api"},"data":{"result":{"api_key":"APIKEY"}}}"#
2895                    .to_string(),
2896            )),
2897            Ok(Some(
2898                r#"{"request_id":"b","header":{"status":"200","channel":"spot.order_place",
2899                "event":"api"},"data":{"result":{"id":"55","text":"","currency_pair":"BTC_USDT",
2900                "side":"buy","type":"limit","status":"open","amount":"1","left":"1","price":"100",
2901                "avg_deal_price":"0"}}}"#
2902                    .to_string(),
2903            )),
2904        ]);
2905        gate.place_order_ws(
2906            &OrderRequest::limit_buy(symbol(), dec!(1), dec!(100))
2907                .with_stp(SelfTradePrevention::ExpireTaker),
2908        )
2909        .unwrap();
2910        assert!(ws.sent()[1].contains(r#""stp_act":"cn""#));
2911    }
2912
2913    #[test]
2914    fn place_and_cancel_order_over_ws() {
2915        let (mut gate, ws) = signed_ws_client(1_700_000_000_000);
2916        ws.push_connection(vec![
2917            Ok(Some(
2918                r#"{"request_id":"login","header":{"status":"200","channel":"spot.login",
2919                "event":"api"},"data":{"result":{"api_key":"APIKEY"}}}"#
2920                    .to_string(),
2921            )),
2922            Ok(Some(
2923                r#"{"request_id":"o","header":{"status":"200","channel":"spot.order_place",
2924                "event":"api"},"data":{"result":{"id":"55","text":"t-my","currency_pair":"BTC_USDT",
2925                "side":"buy","type":"limit","status":"closed","amount":"1","left":"0","price":"100",
2926                "avg_deal_price":"100"}}}"#
2927                    .to_string(),
2928            )),
2929            Ok(Some(
2930                r#"{"request_id":"c","header":{"status":"200","channel":"spot.order_cancel",
2931                "event":"api"},"data":{"result":{"id":"55"}}}"#
2932                    .to_string(),
2933            )),
2934        ]);
2935        let order = gate
2936            .place_order_ws(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
2937            .unwrap();
2938        assert_eq!(order.id, "55");
2939        assert_eq!(order.symbol, symbol());
2940        assert_eq!(order.status, OrderStatus::Filled);
2941        assert_eq!(order.filled_quantity, dec!(1));
2942        assert_eq!(ws.connected_urls()[0], "wss://api.gateio.ws/ws/v4/");
2943        // The login frame is sent first, then the order request.
2944        assert!(ws.sent()[0].contains(r#""channel":"spot.login""#));
2945        assert!(ws.sent()[0].contains(r#""signature""#));
2946        assert!(ws.sent()[1].contains(r#""channel":"spot.order_place""#));
2947        assert!(ws.sent()[1].contains(r#""currency_pair":"BTC_USDT""#));
2948
2949        gate.cancel_order_ws(&symbol(), "55").unwrap();
2950        assert!(ws.sent()[2].contains(r#""channel":"spot.order_cancel""#));
2951        assert!(ws.sent()[2].contains(r#""order_id":"55""#));
2952    }
2953
2954    #[test]
2955    fn ws_order_surfaces_rejection() {
2956        let (mut gate, ws) = signed_ws_client(1000);
2957        ws.push_connection(vec![
2958            Ok(Some(
2959                r#"{"header":{"status":"200","channel":"spot.login","event":"api"}}"#.to_string(),
2960            )),
2961            Ok(Some(
2962                r#"{"header":{"status":"400","channel":"spot.order_place","event":"api"},
2963                "data":{"errs":{"label":"BALANCE_NOT_ENOUGH","message":"not enough balance"}}}"#
2964                    .to_string(),
2965            )),
2966        ]);
2967        assert!(matches!(
2968            gate.place_order_ws(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
2969                .unwrap_err(),
2970            Error::OrderRejected { .. }
2971        ));
2972    }
2973
2974    #[test]
2975    fn ws_order_requires_credentials() {
2976        let http = Arc::new(MockHttpTransport::new());
2977        let ws = Arc::new(MockWsTransport::new());
2978        let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
2979        let mut gate = Gate::with_http(Box::new(ArcTransport(http)), &opts)
2980            .with_ws(Box::new(ArcWs(Arc::clone(&ws))));
2981        assert!(matches!(
2982            gate.place_order_ws(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
2983                .unwrap_err(),
2984            Error::InvalidCredentials(_)
2985        ));
2986    }
2987
2988    fn signed_futures_client(now_ms: i64) -> (Gate, Arc<MockHttpTransport>) {
2989        let mock = Arc::new(MockHttpTransport::new());
2990        let opts = ExchangeOptions::mainnet(crate::MarketType::UsdMFutures);
2991        let gate = Gate::with_credentials(
2992            Box::new(ArcTransport(Arc::clone(&mock))),
2993            &opts,
2994            Credentials::new("APIKEY", "SECRET"),
2995        )
2996        .with_clock(Box::new(move || now_ms));
2997        (gate, mock)
2998    }
2999
3000    /// A spot trigger goes to the price-order endpoint, whose body nests a
3001    /// `trigger` (when to act) inside a `put` (what to place).
3002    ///
3003    /// `rule` is the comparison and Gate will not infer it: `<=` protects a
3004    /// long, `>=` covers a short. The wrong one arms the order on the side that
3005    /// never comes.
3006    #[test]
3007    fn a_spot_trigger_goes_to_the_price_order_endpoint_with_its_rule() {
3008        for (side, rule) in [(OrderSide::Sell, "<="), (OrderSide::Buy, ">=")] {
3009            let (gate, mock) = signed_client(1_000_000);
3010            mock.push_json(200, r#"{"id":77}"#);
3011            let request = OrderRequest {
3012                order_type: OrderType::StopLimit,
3013                stop_price: Some(dec!(19000)),
3014                side,
3015                ..OrderRequest::limit_sell(symbol(), dec!(1), dec!(18900))
3016            };
3017            let order = gate.place_order(&request).unwrap();
3018            assert_eq!(order.id, "77");
3019
3020            let sent = &mock.recorded_requests()[0];
3021            assert!(
3022                sent.url.contains("/api/v4/spot/price_orders"),
3023                "{}",
3024                sent.url
3025            );
3026            let body = sent.body.clone().unwrap();
3027            assert!(body.contains(r#""price":"19000""#), "{body}");
3028            assert!(
3029                body.contains(&format!(r#""rule":"{rule}""#)),
3030                "a {side:?} stop fires the other way: {body}"
3031            );
3032            // The limit that gets placed once it fires, not the trigger.
3033            assert!(body.contains(r#""price":"18900""#), "{body}");
3034        }
3035    }
3036
3037    /// Gate's spot price order can only place a limit.
3038    ///
3039    /// A stop-market is refused rather than sent with an invented limit price:
3040    /// that price is exactly how much slippage the caller's stop is allowed to
3041    /// take, and it is not this library's to choose.
3042    #[test]
3043    fn a_spot_stop_market_is_refused_rather_than_given_an_invented_price() {
3044        let (gate, mock) = signed_client(1_000_000);
3045        let request = OrderRequest {
3046            order_type: OrderType::StopMarket,
3047            stop_price: Some(dec!(19000)),
3048            ..OrderRequest::market_sell(symbol(), dec!(1))
3049        };
3050        let err = gate.place_order(&request).unwrap_err();
3051        assert!(matches!(err, Error::Exchange { ref code, .. } if code == "unsupported"));
3052        assert_eq!(mock.recorded_requests(), Vec::new());
3053    }
3054
3055    /// Futures has no such limit: `0` there means "take the market", so a
3056    /// stop-market is carried rather than refused. Size sits on the initial
3057    /// order and is signed by side, the way every other Gate futures order is.
3058    #[test]
3059    fn a_futures_stop_market_takes_the_market_and_signs_its_size() {
3060        let (gate, mock) = signed_futures_client(1_000_000);
3061        mock.push_json(200, r#"{"id":78}"#);
3062        let request = OrderRequest {
3063            order_type: OrderType::StopMarket,
3064            stop_price: Some(dec!(19000)),
3065            ..OrderRequest::market_sell(symbol(), dec!(1))
3066        };
3067        gate.place_order(&request).unwrap();
3068
3069        let sent = &mock.recorded_requests()[0];
3070        assert!(
3071            sent.url.contains("/api/v4/futures/usdt/price_orders"),
3072            "{}",
3073            sent.url
3074        );
3075        let body = sent.body.clone().unwrap();
3076        assert!(body.contains(r#""price":"0""#), "{body}");
3077        assert!(
3078            body.contains(r#""size":-1"#),
3079            "a sell is a negative size: {body}"
3080        );
3081        assert!(body.contains(r#""rule":2"#), "{body}");
3082    }
3083
3084    #[test]
3085    fn a_gate_trigger_without_a_stop_price_is_refused() {
3086        let (gate, mock) = signed_futures_client(1_000_000);
3087        let request = OrderRequest {
3088            order_type: OrderType::StopMarket,
3089            stop_price: None,
3090            ..OrderRequest::market_sell(symbol(), dec!(1))
3091        };
3092        assert!(gate.place_order(&request).is_err());
3093        assert_eq!(mock.recorded_requests(), Vec::new());
3094    }
3095
3096    fn signed_futures_ws_client(
3097        now_ms: i64,
3098    ) -> (Gate, Arc<MockHttpTransport>, Arc<MockWsTransport>) {
3099        let http = Arc::new(MockHttpTransport::new());
3100        let ws = Arc::new(MockWsTransport::new());
3101        let opts = ExchangeOptions::mainnet(crate::MarketType::UsdMFutures);
3102        let gate = Gate::with_credentials(
3103            Box::new(ArcTransport(Arc::clone(&http))),
3104            &opts,
3105            Credentials::new("APIKEY", "SECRET"),
3106        )
3107        .with_ws(Box::new(ArcWs(Arc::clone(&ws))))
3108        .with_clock(Box::new(move || now_ms));
3109        (gate, http, ws)
3110    }
3111
3112    /// A futures client watches the futures account, on the futures socket.
3113    ///
3114    /// The host and the channel name were verified against the live venue:
3115    /// `fx-ws.gateio.ws/v4/ws/usdt` accepts the socket and answers a
3116    /// `futures.orders` subscribe with `authentication required` -- an
3117    /// unrecognised channel answers differently.
3118    ///
3119    /// Gate addresses these channels to a **user id**, which the spot channels
3120    /// do not take. Subscribing without it is a subscription to nobody, so the
3121    /// id is fetched first.
3122    #[test]
3123    fn a_futures_client_subscribes_to_the_futures_channels_for_its_own_user() {
3124        let (mut gate, http, ws) = signed_futures_ws_client(1_700_000_000_000);
3125        http.push_json(
3126            200,
3127            r#"{"user":4242,"currency":"USDT","total":"1","available":"1"}"#,
3128        );
3129        gate.subscribe_user_data().unwrap();
3130
3131        let looked_up = &http.recorded_requests()[0];
3132        assert!(
3133            looked_up.url.contains("/api/v4/futures/usdt/accounts"),
3134            "{}",
3135            looked_up.url
3136        );
3137        assert_eq!(ws.connected_urls()[0], "wss://fx-ws.gateio.ws/v4/ws/usdt");
3138        let sent = ws.sent();
3139        assert!(
3140            sent[0].contains(r#""channel":"futures.orders""#),
3141            "{}",
3142            sent[0]
3143        );
3144        assert!(
3145            sent[0].contains(r#""payload":["4242","!all"]"#),
3146            "{}",
3147            sent[0]
3148        );
3149        assert!(
3150            sent[1].contains(r#""channel":"futures.balances""#),
3151            "{}",
3152            sent[1]
3153        );
3154        assert!(
3155            !sent.iter().any(|frame| frame.contains("spot.")),
3156            "a futures client subscribed to a spot channel: {sent:?}"
3157        );
3158    }
3159
3160    /// The user id is fetched once, not per subscribe.
3161    #[test]
3162    fn the_futures_user_id_is_looked_up_once() {
3163        let (mut gate, http, ws) = signed_futures_ws_client(1_700_000_000_000);
3164        http.push_json(
3165            200,
3166            r#"{"user":4242,"currency":"USDT","total":"1","available":"1"}"#,
3167        );
3168        gate.subscribe_user_data().unwrap();
3169        gate.subscribe_user_data().unwrap();
3170        assert_eq!(http.recorded_requests().len(), 1);
3171        assert_eq!(ws.sent().len(), 4);
3172    }
3173
3174    /// A futures order frame says everything the spot one says, differently:
3175    /// a signed size instead of a side, what is left instead of what filled,
3176    /// and a two-part outcome.
3177    #[test]
3178    fn a_futures_order_frame_becomes_an_order_update() {
3179        let (mut gate, http, ws) = signed_futures_ws_client(1_700_000_000_000);
3180        http.push_json(
3181            200,
3182            r#"{"user":1,"currency":"USDT","total":"1","available":"1"}"#,
3183        );
3184        ws.push_connection(vec![Ok(Some(
3185            r#"{"time":1700000000,"channel":"futures.orders","event":"update","result":[
3186                {"id":88,"contract":"BTC_USDT","size":-4,"left":1,"price":"19000",
3187                 "status":"open","text":"t-mine","fill_price":"19010"}]}"#
3188                .to_string(),
3189        ))]);
3190        gate.subscribe_user_data().unwrap();
3191
3192        let events = gate.poll_events();
3193        let Some(Event::OrderUpdate(order)) = events.first() else {
3194            panic!("expected an order update, got {events:?}");
3195        };
3196        assert_eq!(order.id, "88");
3197        assert_eq!(order.symbol, Symbol::new("BTC", "USDT"));
3198        // A negative size is a sell; there is no side field to read.
3199        assert_eq!(order.side, OrderSide::Sell);
3200        assert_eq!(order.quantity, dec!(4));
3201        // Three of four contracts filled -- the frame says only that one is left.
3202        assert_eq!(order.filled_quantity, dec!(3));
3203        assert_eq!(order.status, OrderStatus::PartiallyFilled);
3204        assert_eq!(order.client_order_id.as_deref(), Some("t-mine"));
3205    }
3206
3207    /// `status` only ever reaches `finished`; `finish_as` says whether that was
3208    /// a fill or a cancel. Reading the status alone reports every cancelled
3209    /// order as filled.
3210    #[test]
3211    fn a_finished_futures_order_is_a_fill_or_a_cancel_by_finish_as() {
3212        assert_eq!(
3213            futures_order_status("finished", "filled", dec!(4)),
3214            OrderStatus::Filled
3215        );
3216        assert_eq!(
3217            futures_order_status("finished", "cancelled", dec!(0)),
3218            OrderStatus::Canceled
3219        );
3220        // Ended early for any other reason: not filled either.
3221        assert_eq!(
3222            futures_order_status("finished", "reduce_only", dec!(0)),
3223            OrderStatus::Canceled
3224        );
3225        assert_eq!(futures_order_status("open", "", dec!(0)), OrderStatus::New);
3226    }
3227
3228    #[test]
3229    fn a_futures_balance_frame_becomes_a_balance_update() {
3230        let (mut gate, http, ws) = signed_futures_ws_client(1_700_000_000_000);
3231        http.push_json(
3232            200,
3233            r#"{"user":1,"currency":"USDT","total":"1","available":"1"}"#,
3234        );
3235        ws.push_connection(vec![Ok(Some(
3236            r#"{"time":1700000000,"channel":"futures.balances","event":"update","result":[
3237                {"balance":9999.5,"change":"-0.5","currency":"USDT","type":"fee"}]}"#
3238                .to_string(),
3239        ))]);
3240        gate.subscribe_user_data().unwrap();
3241
3242        let events = gate.poll_events();
3243        let Some(Event::BalanceUpdate(balances)) = events.first() else {
3244            panic!("expected a balance update, got {events:?}");
3245        };
3246        assert_eq!(balances[0].asset, "USDT");
3247        // The frame carries a number, not a string, and it is the whole balance.
3248        assert_eq!(balances[0].free, dec!(9999.5));
3249    }
3250
3251    const GATE_ORDER: &str = r#"{"id":"1","text":"","side":"buy","type":"limit","status":"open",
3252        "amount":"1","filled_amount":"0","price":"100","avg_deal_price":"0"}"#;
3253
3254    #[test]
3255    fn stp_maps_to_stp_act() {
3256        let (gate, mock) = signed_client(1_000_000);
3257        mock.push_json(200, GATE_ORDER);
3258        gate.place_order(
3259            &OrderRequest::limit_buy(symbol(), dec!(1), dec!(100))
3260                .with_stp(SelfTradePrevention::ExpireBoth),
3261        )
3262        .unwrap();
3263        let reqs = mock.recorded_requests();
3264        assert!(reqs[0].body.as_ref().unwrap().contains(r#""stp_act":"cb""#));
3265    }
3266
3267    #[test]
3268    fn amend_order_patches_in_place() {
3269        let (gate, mock) = signed_client(1_000_000);
3270        mock.push_json(
3271            200,
3272            r#"{"id":"1","text":"","side":"buy","type":"limit","status":"open",
3273            "amount":"2","filled_amount":"0","price":"101","avg_deal_price":"0"}"#,
3274        );
3275        let order = gate
3276            .amend_order(&symbol(), "1", Some(dec!(101)), Some(dec!(2)))
3277            .unwrap();
3278        assert_eq!(order.quantity, dec!(2));
3279        assert_eq!(order.price, Some(dec!(101)));
3280        let reqs = mock.recorded_requests();
3281        assert_eq!(reqs[0].method, HttpMethod::Patch);
3282        assert!(reqs[0]
3283            .url
3284            .contains("/api/v4/spot/orders/1?currency_pair=BTC_USDT"));
3285        let body = reqs[0].body.as_ref().unwrap();
3286        assert!(body.contains(r#""amount":"2""#));
3287        assert!(body.contains(r#""price":"101""#));
3288    }
3289
3290    /// The futures order path spells the time-in-force in `tif`, and a futures
3291    /// market order is a zero-price IOC with no fill-or-kill spelling at all.
3292    #[test]
3293    fn futures_orders_carry_the_time_in_force_or_refuse_it() {
3294        let (gate, mock) = signed_futures_client(1000);
3295        mock.push_json(200, "{}");
3296        let ioc = OrderRequest::limit_buy(symbol(), dec!(1), dec!(100))
3297            .with_time_in_force(TimeInForce::Ioc);
3298        let _ = gate.place_order(&ioc);
3299        let body = mock.recorded_requests()[0].body.clone().unwrap_or_default();
3300        assert!(body.contains(r#""tif":"ioc""#));
3301
3302        let (gate, mock) = signed_futures_client(1000);
3303        mock.push_json(200, "{}");
3304        let poc = OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)).post_only();
3305        let _ = gate.place_order(&poc);
3306        let body = mock.recorded_requests()[0].body.clone().unwrap_or_default();
3307        assert!(body.contains(r#""tif":"poc""#));
3308
3309        let (gate, mock) = signed_futures_client(1000);
3310        let fok = OrderRequest::market_buy(symbol(), dec!(1)).with_time_in_force(TimeInForce::Fok);
3311        let err = gate.place_order(&fok).unwrap_err();
3312        assert!(matches!(err, Error::Exchange { ref code, .. } if code == "unsupported"));
3313        assert_eq!(mock.recorded_requests(), Vec::new());
3314    }
3315
3316    #[test]
3317    fn place_batch_reads_succeeded_flag() {
3318        let (gate, mock) = signed_client(1_000_000);
3319        mock.push_json(
3320            200,
3321            r#"[{"succeeded":true,"id":"o1","text":"","side":"buy","type":"limit","status":"open",
3322            "amount":"1","filled_amount":"0","price":"100","avg_deal_price":"0"},
3323            {"succeeded":false,"label":"BALANCE_NOT_ENOUGH","message":"no funds"}]"#,
3324        );
3325        let results = gate
3326            .place_batch(&[
3327                OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)),
3328                OrderRequest::limit_buy(symbol(), dec!(1), dec!(101)),
3329            ])
3330            .unwrap();
3331        assert_eq!(results[0].as_ref().unwrap().id, "o1");
3332        assert!(matches!(
3333            results[1].as_ref().unwrap_err(),
3334            Error::OrderRejected { .. }
3335        ));
3336        assert!(mock.recorded_requests()[0]
3337            .url
3338            .contains("/api/v4/spot/batch_orders"));
3339    }
3340
3341    #[test]
3342    fn cancel_batch_is_one_call() {
3343        let (gate, mock) = signed_client(1_000_000);
3344        mock.push_json(200, "[]");
3345        gate.cancel_batch(&symbol(), &["1".to_string(), "2".to_string()])
3346            .unwrap();
3347        let reqs = mock.recorded_requests();
3348        assert_eq!(reqs.len(), 1);
3349        assert!(reqs[0].url.contains("/api/v4/spot/cancel_batch_orders"));
3350    }
3351
3352    #[test]
3353    fn place_oco_is_unsupported() {
3354        let (mut gate, _mock) = signed_client(1_000_000);
3355        assert!(matches!(
3356            AdvancedOrders::place_oco(
3357                &mut gate,
3358                &OcoRequest::new(symbol(), OrderSide::Sell, dec!(1), dec!(110), dec!(95))
3359            )
3360            .unwrap_err(),
3361            Error::Exchange { .. }
3362        ));
3363    }
3364
3365    fn futures_client() -> (Gate, Arc<MockHttpTransport>) {
3366        let mock = Arc::new(MockHttpTransport::new());
3367        let opts = ExchangeOptions::mainnet(crate::MarketType::UsdMFutures);
3368        (
3369            Gate::with_http(Box::new(ArcTransport(Arc::clone(&mock))), &opts),
3370            mock,
3371        )
3372    }
3373
3374    #[test]
3375    fn futures_ticker_takes_bid_ask_from_the_book() {
3376        let (gate, mock) = futures_client();
3377        mock.push_json(
3378            200,
3379            r#"[{"contract":"BTC_USDT","last":"20000","volume_24h_base":"1234"}]"#,
3380        );
3381        mock.push_json(
3382            200,
3383            r#"{"id":66,"bids":[{"p":"19999","s":10}],"asks":[{"p":"20001","s":8}]}"#,
3384        );
3385        let ticker = gate.ticker(&symbol()).unwrap();
3386        assert_eq!(ticker.last, dec!(20000));
3387        assert_eq!(ticker.bid, dec!(19999));
3388        assert_eq!(ticker.ask, dec!(20001));
3389        assert_eq!(ticker.volume, dec!(1234));
3390        assert!(mock.recorded_requests()[0]
3391            .url
3392            .contains("/api/v4/futures/usdt/tickers?contract=BTC_USDT"));
3393    }
3394
3395    #[test]
3396    fn futures_klines_parse_object_rows() {
3397        let (gate, mock) = futures_client();
3398        mock.push_json(
3399            200,
3400            r#"[{"t":1700000000,"v":12,"o":"100","h":"110","l":"95","c":"105"}]"#,
3401        );
3402        let candles = gate.klines(&symbol(), "1h", 1).unwrap();
3403        assert!((candles[0].open - 100.0).abs() < 1e-9);
3404        assert!((candles[0].high - 110.0).abs() < 1e-9);
3405        assert!((candles[0].close - 105.0).abs() < 1e-9);
3406        assert_eq!(candles[0].timestamp, 1_700_000_000);
3407        assert!(mock.recorded_requests()[0]
3408            .url
3409            .contains("/api/v4/futures/usdt/candlesticks"));
3410    }
3411
3412    #[test]
3413    fn futures_order_book_parses_object_levels() {
3414        let (gate, mock) = futures_client();
3415        mock.push_json(
3416            200,
3417            r#"{"id":77,"bids":[{"p":"100","s":1}],"asks":[{"p":"101","s":2}]}"#,
3418        );
3419        let book = gate.order_book(&symbol(), 5).unwrap();
3420        assert_eq!(book.last_update_id, 77);
3421        assert_eq!(book.bids[0], BookLevel::new(dec!(100), dec!(1)));
3422        assert_eq!(book.asks[0], BookLevel::new(dec!(101), dec!(2)));
3423    }
3424
3425    fn hedged_futures_client(now_ms: i64) -> (Gate, Arc<MockHttpTransport>) {
3426        let mock = Arc::new(MockHttpTransport::new());
3427        let mut opts = ExchangeOptions::mainnet(crate::MarketType::UsdMFutures);
3428        opts.position_mode = crate::PositionMode::Hedge;
3429        let gate = Gate::with_credentials(
3430            Box::new(ArcTransport(Arc::clone(&mock))),
3431            &opts,
3432            Credentials::new("APIKEY", "SECRET"),
3433        )
3434        .with_clock(Box::new(move || now_ms));
3435        (gate, mock)
3436    }
3437
3438    const GATE_FUTURES_FILL: &str = r#"{"id":88,"size":2,"left":0,"price":"0",
3439        "fill_price":"20000","status":"finished","finish_as":"filled","text":""}"#;
3440
3441    #[test]
3442    fn dual_mode_closes_with_auto_size_and_opens_by_sign() {
3443        // Gate calls hedge "dual mode". A close there names the side and sends
3444        // no size of its own; `reduce_only` alone does not say which of the two
3445        // open sides is meant. Opening is unchanged: the sign of `size` says it.
3446        let (hedged, mock) = hedged_futures_client(1_000_000);
3447        for _ in 0..2 {
3448            mock.push_json(200, GATE_FUTURES_FILL);
3449        }
3450        hedged
3451            .place_order(&OrderRequest::market_sell(symbol(), dec!(2)).reduce_only())
3452            .unwrap();
3453        hedged
3454            .place_order(&OrderRequest::market_buy(symbol(), dec!(2)))
3455            .unwrap();
3456
3457        let reqs = mock.recorded_requests();
3458        let close = reqs[0].body.as_deref().unwrap();
3459        assert!(close.contains(r#""auto_size":"close_long""#));
3460        assert!(close.contains(r#""size":0"#));
3461        let open = reqs[1].body.as_deref().unwrap();
3462        assert!(open.contains(r#""size":2"#));
3463        assert!(!open.contains("auto_size"));
3464
3465        // The other close: a buy that reduces is closing the short side.
3466        mock.push_json(200, GATE_FUTURES_FILL);
3467        hedged
3468            .place_order(&OrderRequest::market_buy(symbol(), dec!(2)).reduce_only())
3469            .unwrap();
3470        assert!(mock.recorded_requests()[2]
3471            .body
3472            .as_deref()
3473            .unwrap()
3474            .contains(r#""auto_size":"close_short""#));
3475
3476        // One-way is untouched: reduce_only, real size, no auto_size.
3477        let (one_way, one_way_mock) = signed_futures_client(1_000_000);
3478        one_way_mock.push_json(200, GATE_FUTURES_FILL);
3479        one_way
3480            .place_order(&OrderRequest::market_sell(symbol(), dec!(2)).reduce_only())
3481            .unwrap();
3482        let body = one_way_mock.recorded_requests()[0].body.clone().unwrap();
3483        assert!(body.contains(r#""reduce_only":true"#));
3484        assert!(body.contains(r#""size":-2"#));
3485        assert!(!body.contains("auto_size"));
3486    }
3487
3488    #[test]
3489    fn futures_market_order_uses_signed_size_and_ioc() {
3490        let (gate, mock) = signed_futures_client(1_000_000);
3491        mock.push_json(
3492            200,
3493            r#"{"id":88,"size":2,"left":0,"price":"0","fill_price":"20000",
3494            "status":"finished","finish_as":"filled","text":""}"#,
3495        );
3496        let order = gate
3497            .place_order(&OrderRequest::market_buy(symbol(), dec!(2)))
3498            .unwrap();
3499        assert_eq!(order.id, "88");
3500        assert_eq!(order.status, OrderStatus::Filled);
3501        assert_eq!(order.side, OrderSide::Buy);
3502        assert_eq!(order.average_price, Some(dec!(20000)));
3503        let req = &mock.recorded_requests()[0];
3504        assert!(req.url.contains("/api/v4/futures/usdt/orders"));
3505        let body = req.body.as_ref().unwrap();
3506        assert!(body.contains(r#""contract":"BTC_USDT""#));
3507        assert!(body.contains(r#""size":2"#));
3508        assert!(body.contains(r#""price":"0""#));
3509        assert!(body.contains(r#""tif":"ioc""#));
3510        assert!(body.contains(r#""reduce_only":false"#));
3511    }
3512
3513    #[test]
3514    fn futures_limit_sell_signs_size_negative() {
3515        let (gate, mock) = signed_futures_client(1_000_000);
3516        mock.push_json(
3517            200,
3518            r#"{"id":90,"size":-3,"left":3,"price":"21000","fill_price":"0",
3519            "status":"open","finish_as":"","text":""}"#,
3520        );
3521        let order = gate
3522            .place_order(&OrderRequest::limit_sell(symbol(), dec!(3), dec!(21000)))
3523            .unwrap();
3524        assert_eq!(order.side, OrderSide::Sell);
3525        assert_eq!(order.status, OrderStatus::New);
3526        assert_eq!(order.order_type, OrderType::Limit);
3527        let reqs = mock.recorded_requests();
3528        let body = reqs[0].body.as_ref().unwrap();
3529        assert!(body.contains(r#""size":-3"#));
3530        assert!(body.contains(r#""price":"21000""#));
3531    }
3532
3533    #[test]
3534    fn derivatives_positions_parse_cross_and_isolated() {
3535        let (mut gate, mock) = signed_futures_client(1_000_000);
3536        mock.push_json(
3537            200,
3538            r#"[{"contract":"BTC_USDT","size":3,"leverage":"10","entry_price":"20000",
3539            "mark_price":"20100","unrealised_pnl":"30"},
3540            {"contract":"ETH_USDT","size":-2,"leverage":"0","cross_leverage_limit":"5",
3541            "entry_price":"3000","mark_price":"2900","unrealised_pnl":"200"}]"#,
3542        );
3543        let positions = Derivatives::positions(&mut gate, None).unwrap();
3544        assert_eq!(positions.len(), 2);
3545        assert_eq!(positions[0].symbol, Symbol::new("BTC", "USDT"));
3546        assert_eq!(positions[0].side, PositionSide::Long);
3547        assert_eq!(positions[0].quantity, dec!(3));
3548        assert_eq!(positions[0].leverage, dec!(10));
3549        assert_eq!(positions[0].margin_mode, MarginMode::Isolated);
3550        assert_eq!(positions[1].side, PositionSide::Short);
3551        assert_eq!(positions[1].leverage, dec!(5));
3552        assert_eq!(positions[1].margin_mode, MarginMode::Cross);
3553        assert!(mock.recorded_requests()[0]
3554            .url
3555            .contains("/api/v4/futures/usdt/positions"));
3556    }
3557
3558    #[test]
3559    fn derivatives_set_leverage_and_cross_switch() {
3560        let (mut gate, mock) = signed_futures_client(1_000_000);
3561        mock.push_json(200, "{}");
3562        Derivatives::set_leverage(&mut gate, &symbol(), 5).unwrap();
3563        assert!(mock.recorded_requests()[0]
3564            .url
3565            .contains("/api/v4/futures/usdt/positions/BTC_USDT/leverage?leverage=5"));
3566
3567        mock.push_json(200, "{}");
3568        Derivatives::set_margin_mode(&mut gate, &symbol(), MarginMode::Cross).unwrap();
3569        assert!(mock.recorded_requests()[1]
3570            .url
3571            .contains("leverage?leverage=0"));
3572    }
3573
3574    #[test]
3575    fn derivatives_close_position_is_reduce_only_opposite() {
3576        let (mut gate, mock) = signed_futures_client(1_000_000);
3577        mock.push_json(
3578            200,
3579            r#"[{"contract":"BTC_USDT","size":3,"leverage":"10","entry_price":"20000",
3580            "mark_price":"20100","unrealised_pnl":"30"}]"#,
3581        );
3582        mock.push_json(
3583            200,
3584            r#"{"id":99,"size":-3,"left":0,"price":"0","fill_price":"20100",
3585            "status":"finished","finish_as":"filled","text":""}"#,
3586        );
3587        let order = Derivatives::close_position(&mut gate, &symbol()).unwrap();
3588        assert_eq!(order.status, OrderStatus::Filled);
3589        let reqs = mock.recorded_requests();
3590        let body = reqs[1].body.as_ref().unwrap();
3591        assert!(body.contains(r#""size":-3"#));
3592        assert!(body.contains(r#""reduce_only":true"#));
3593    }
3594
3595    #[test]
3596    fn futures_balances_split_total_and_available() {
3597        let (gate, mock) = signed_futures_client(1_000_000);
3598        mock.push_json(
3599            200,
3600            r#"{"currency":"USDT","total":"1000","available":"800"}"#,
3601        );
3602        let bals = gate.balances().unwrap();
3603        assert_eq!(bals[0].asset, "USDT");
3604        assert_eq!(bals[0].free, dec!(800));
3605        assert_eq!(bals[0].locked, dec!(200));
3606        assert!(mock.recorded_requests()[0]
3607            .url
3608            .contains("/api/v4/futures/usdt/accounts"));
3609    }
3610
3611    #[test]
3612    fn futures_query_order_uses_futures_endpoint_and_shape() {
3613        let (gate, mock) = signed_futures_client(1_000_000);
3614        mock.push_json(
3615            200,
3616            r#"{"id":88,"size":2,"left":0,"price":"100","fill_price":"100",
3617            "status":"finished","finish_as":"filled","text":""}"#,
3618        );
3619        let order = gate.query_order(&symbol(), "88").unwrap();
3620        assert_eq!(order.id, "88");
3621        assert_eq!(order.side, OrderSide::Buy);
3622        assert_eq!(order.status, OrderStatus::Filled);
3623        assert!(mock.recorded_requests()[0]
3624            .url
3625            .contains("/api/v4/futures/usdt/orders/88"));
3626    }
3627
3628    #[test]
3629    fn futures_cancel_order_targets_futures_endpoint() {
3630        let (gate, mock) = signed_futures_client(1_000_000);
3631        mock.push_json(
3632            200,
3633            r#"{"id":88,"size":0,"left":0,"status":"finished","finish_as":"cancelled"}"#,
3634        );
3635        gate.cancel_order(&symbol(), "88").unwrap();
3636        let req = &mock.recorded_requests()[0];
3637        assert_eq!(req.method, HttpMethod::Delete);
3638        assert!(req.url.contains("/api/v4/futures/usdt/orders/88"));
3639    }
3640
3641    #[test]
3642    fn futures_open_orders_parse_contract_shape() {
3643        let (gate, mock) = signed_futures_client(1_000_000);
3644        mock.push_json(
3645            200,
3646            r#"[{"id":90,"size":-3,"left":3,"price":"21000","fill_price":"0",
3647            "status":"open","finish_as":"","text":""}]"#,
3648        );
3649        let orders = gate.open_orders(Some(&symbol())).unwrap();
3650        assert_eq!(orders.len(), 1);
3651        assert_eq!(orders[0].side, OrderSide::Sell);
3652        assert_eq!(orders[0].status, OrderStatus::New);
3653        assert!(mock.recorded_requests()[0]
3654            .url
3655            .contains("/api/v4/futures/usdt/orders?contract=BTC_USDT&status=open"));
3656    }
3657
3658    #[test]
3659    fn ticker_parses() {
3660        let (gate, mock) = client();
3661        mock.push_json(
3662            200,
3663            r#"[{"currency_pair":"BTC_USDT","last":"20000","highest_bid":"19999",
3664            "lowest_ask":"20001","base_volume":"1234"}]"#,
3665        );
3666        let ticker = gate.ticker(&symbol()).unwrap();
3667        assert_eq!(ticker.last, dec!(20000));
3668        assert_eq!(ticker.bid, dec!(19999));
3669        let req = &mock.recorded_requests()[0];
3670        assert_eq!(
3671            req.url,
3672            "https://api.gateio.ws/api/v4/spot/tickers?currency_pair=BTC_USDT"
3673        );
3674    }
3675
3676    #[test]
3677    fn klines_field_order() {
3678        let (gate, mock) = client();
3679        // [ts, quoteVol, close, high, low, open, baseVol, closed].
3680        mock.push_json(
3681            200,
3682            r#"[["1700000000","0","105","110","95","100","12","true"]]"#,
3683        );
3684        let candles = gate.klines(&symbol(), "1h", 1).unwrap();
3685        assert!((candles[0].open - 100.0).abs() < 1e-9);
3686        assert!((candles[0].high - 110.0).abs() < 1e-9);
3687        assert!((candles[0].close - 105.0).abs() < 1e-9);
3688        assert_eq!(candles[0].timestamp, 1_700_000_000);
3689    }
3690
3691    #[test]
3692    fn order_book_parses() {
3693        let (gate, mock) = client();
3694        mock.push_json(
3695            200,
3696            r#"{"update":66,"bids":[["100","1"]],"asks":[["101","2"]]}"#,
3697        );
3698        let book = gate.order_book(&symbol(), 5).unwrap();
3699        assert_eq!(book.last_update_id, 66);
3700        assert_eq!(book.bids[0], BookLevel::new(dec!(100), dec!(1)));
3701    }
3702
3703    #[test]
3704    fn http_error_maps_to_taxonomy() {
3705        let (gate, mock) = client();
3706        mock.push_json(400, r#"{"label":"BALANCE_NOT_ENOUGH","message":"nope"}"#);
3707        assert!(matches!(
3708            gate.ticker(&symbol()).unwrap_err(),
3709            Error::InsufficientBalance
3710        ));
3711    }
3712
3713    #[test]
3714    fn place_order_signs_with_sha512_body_hash() {
3715        let (gate, mock) = signed_client(1_000_000); // ms -> 1000 s
3716        mock.push_json(
3717            200,
3718            r#"{"id":"77","text":"t-abc","side":"buy","type":"limit","status":"open",
3719            "amount":"1","filled_amount":"0","price":"100","avg_deal_price":"0"}"#,
3720        );
3721        let order = gate
3722            .place_order(
3723                &OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)).with_client_order_id("abc"),
3724            )
3725            .unwrap();
3726        assert_eq!(order.id, "77");
3727        assert_eq!(order.status, OrderStatus::New);
3728
3729        let req = &mock.recorded_requests()[0];
3730        let header = |name: &str| {
3731            req.headers
3732                .iter()
3733                .find(|(k, _)| k == name)
3734                .map(|(_, v)| v.as_str())
3735                .unwrap()
3736        };
3737        assert_eq!(header("Timestamp"), "1000");
3738        assert_eq!(header("KEY"), "APIKEY");
3739        let body = req.body.as_ref().unwrap();
3740        let body_hash = sha512_hex(body.as_bytes());
3741        let sign_string = format!("POST\n/api/v4/spot/orders\n\n{body_hash}\n1000");
3742        assert_eq!(
3743            header("SIGN"),
3744            hmac_sha512_hex(b"SECRET", sign_string.as_bytes())
3745        );
3746        assert!(body.contains(r#""text":"t-abc""#));
3747    }
3748
3749    #[test]
3750    fn query_and_balances() {
3751        let (gate, mock) = signed_client(1_000_000);
3752        mock.push_json(
3753            200,
3754            r#"{"id":"77","text":"","side":"sell","type":"market","status":"closed",
3755            "amount":"2","filled_amount":"2","price":"0","avg_deal_price":"100"}"#,
3756        );
3757        let order = gate.query_order(&symbol(), "77").unwrap();
3758        assert_eq!(order.status, OrderStatus::Filled);
3759        assert_eq!(order.average_price, Some(dec!(100)));
3760
3761        mock.push_json(
3762            200,
3763            r#"[{"currency":"USDT","available":"100.5","locked":"25.5"}]"#,
3764        );
3765        let bals = gate.balances().unwrap();
3766        assert_eq!(bals[0].total(), dec!(126));
3767    }
3768
3769    #[test]
3770    fn open_orders_requires_symbol() {
3771        let (gate, _mock) = signed_client(1_000_000);
3772        assert!(matches!(
3773            gate.open_orders(None).unwrap_err(),
3774            Error::InvalidOrder(_)
3775        ));
3776    }
3777
3778    #[test]
3779    fn signed_requires_credentials() {
3780        let (gate, _) = client();
3781        assert!(matches!(
3782            gate.balances().unwrap_err(),
3783            Error::InvalidCredentials(_)
3784        ));
3785    }
3786
3787    #[test]
3788    fn ws_parses_trade_and_book() {
3789        let ws = Arc::new(MockWsTransport::new());
3790        ws.push_connection(vec![
3791            Ok(Some(
3792                r#"{"time":1,"channel":"spot.trades","event":"update","result":
3793                {"currency_pair":"BTC_USDT","side":"buy","amount":"0.5","price":"100","create_time_ms":1700}}"#
3794                    .to_string(),
3795            )),
3796            Ok(Some(
3797                r#"{"time":2,"channel":"spot.order_book_update","event":"update","result":
3798                {"s":"BTC_USDT","u":9,"b":[["100","1"]],"a":[["101","2"]]}}"#
3799                    .to_string(),
3800            )),
3801            Ok(Some(r#"{"channel":"spot.trades","event":"subscribe"}"#.to_string())),
3802        ]);
3803        let http = Arc::new(MockHttpTransport::new());
3804        let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
3805        let mut gate = Gate::with_http(Box::new(ArcTransport(http)), &opts)
3806            .with_ws(Box::new(ArcWs(Arc::clone(&ws))));
3807        gate.subscribe_trades(&symbol()).unwrap();
3808        assert!(ws.sent()[0].contains(r#""channel":"spot.trades""#));
3809
3810        let events = gate.poll_events();
3811        assert_eq!(events.len(), 2);
3812        let Event::Trade(t) = &events[0] else {
3813            panic!("expected trade")
3814        };
3815        assert_eq!(t.aggressor, OrderSide::Buy);
3816        assert_eq!(t.timestamp, 1700);
3817        let Event::BookDelta(d) = &events[1] else {
3818            panic!("expected book delta")
3819        };
3820        assert_eq!(d.final_update_id, 9);
3821    }
3822
3823    #[test]
3824    fn works_as_a_boxed_exchange() {
3825        let (gate, mock) = signed_client(1_000_000);
3826        mock.push_json(
3827            200,
3828            r#"{"id":"1","text":"","side":"buy","type":"limit","status":"open",
3829            "amount":"1","filled_amount":"0","price":"100","avg_deal_price":"0"}"#,
3830        );
3831        let mut exchange: Box<dyn Exchange> = Box::new(gate);
3832        assert_eq!(exchange.name(), "gate");
3833        let order = exchange
3834            .place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
3835            .unwrap();
3836        assert_eq!(order.id, "1");
3837    }
3838
3839    #[test]
3840    fn system_clock_is_sane() {
3841        assert!(system_now_ms() > 1_600_000_000_000);
3842    }
3843
3844    /// `Debug` reports connection state, never secret material. A client is
3845    /// formatted into logs and error messages, so anything it prints is
3846    /// somewhere a credential must not be.
3847    #[test]
3848    fn debug_reports_state_without_credentials() {
3849        let (client, _http) = signed_client(1_700_000_000_000);
3850        let rendered = format!("{client:?}");
3851
3852        assert!(rendered.starts_with("Gate {"));
3853        assert!(rendered.contains("authenticated: true"));
3854        assert!(!rendered.contains("SECRET"));
3855        assert!(!rendered.contains("APIKEY"));
3856    }
3857
3858    /// The clock offset must reach the wire, not just the struct.
3859    ///
3860    /// A venue refuses a signed request whose timestamp is outside its own
3861    /// receive window, so a machine a few seconds off has every order rejected
3862    /// -- with a message about the window, not about the clock. This asserts the
3863    /// whole path: sync, then a signed request carrying the adjusted time.
3864    #[test]
3865    fn sync_time_shifts_signed_timestamps() {
3866        let (mut gate, http) = signed_client(1_000_000);
3867
3868        // Shape verified against the live public endpoint on 2026-09-01.
3869        http.push_json(200, r#"{"server_time":1004500}"#);
3870        let offset = gate.sync_time().expect("sync must succeed");
3871        assert_eq!(offset, 4_500, "the venue is 4.5 s ahead of this machine");
3872
3873        http.push_json(200, r"[]");
3874        gate.balances().expect("balances must succeed");
3875
3876        let requests = http.recorded_requests();
3877        let signed = requests.last().expect("a signed request was recorded");
3878        let header = signed
3879            .headers
3880            .iter()
3881            .find(|(name, _)| name == "Timestamp")
3882            .map(|(_, value)| value.as_str())
3883            .expect("signed request carries Timestamp");
3884        assert_eq!(header, "1004", "seconds of the venue's time, not ours");
3885    }
3886
3887    /// A rate-limited response carries the venue's advised wait.
3888    ///
3889    /// The limit itself is recognised from the body -- a code in the error
3890    /// envelope -- while the wait arrives in the `Retry-After` header, so the
3891    /// two are read in different places. Until they were joined, every
3892    /// `RateLimited` this client raised carried `retry_after: None`: a field
3893    /// the error type documents, and that nothing in the crate ever filled.
3894    #[test]
3895    fn rate_limit_carries_the_venues_advised_wait() {
3896        let (gate, mock) = client();
3897        mock.push_response(
3898            crate::transport::HttpResponse::new(
3899                429,
3900                r#"{"label":"TOO_MANY_REQUESTS","message":"slow down"}"#,
3901            )
3902            .with_header("Retry-After", "2.5"),
3903        );
3904        let err = gate.ticker(&symbol()).unwrap_err();
3905        let wait = std::time::Duration::from_millis(2500);
3906        assert!(matches!(err, Error::RateLimited { retry_after: Some(d) } if d == wait));
3907    }
3908}