use crate::clock::ServerClock;
use crate::credentials::Credentials;
use crate::error::{Error, Result};
use crate::events::{BookDelta, BookLevel, Event, OrderBookSnapshot, TradePrint};
use crate::feeds::{
DerivativesChannel, DerivativesFeed, FundingRate, LongShortRatio, MarkIndex, OpenInterest,
};
use crate::normalize::{format_decimal, parse_decimal};
use crate::options::{ExchangeOptions, MarginMode, MarketType, PositionMode, SelfTradePrevention};
use crate::positions::{Position, PositionSide};
use crate::signing::{hmac_sha512_hex, sha512_hex};
use crate::symbol::Symbol;
use crate::traits::{
AdvancedOrders, Derivatives, DerivativesStream, Exchange, Execution, MarketData, WsExecution,
WsUserData,
};
use crate::transport::{
HttpMethod, HttpRequest, HttpResponse, HttpTransport, WsConnection, WsTransport,
};
use crate::types::{
Balance, OcoRequest, Order, OrderRequest, OrderSide, OrderStatus, OrderType, Ticker,
TimeInForce,
};
use rust_decimal::prelude::ToPrimitive;
use rust_decimal::Decimal;
use serde::Deserialize;
use std::cell::RefCell;
use std::collections::HashMap;
use std::fmt;
use wickra_core::Candle;
fn system_now_ms() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock is before the Unix epoch")
.as_millis() as i64
}
pub struct Gate {
http: Box<dyn HttpTransport>,
ws: Option<Box<dyn WsTransport>>,
rest_base: String,
market_type: MarketType,
position_mode: PositionMode,
credentials: Option<Credentials>,
now_ms: Box<dyn Fn() -> i64 + Send + Sync>,
clock: ServerClock,
connection: Option<Box<dyn WsConnection>>,
sub_messages: Vec<String>,
subscriptions: Vec<(String, Symbol)>,
futures_user_id: RefCell<Option<String>>,
derivatives_channels: Vec<(String, DerivativesChannel)>,
leverage: u32,
private_connection: Option<Box<dyn WsConnection>>,
user_data_active: bool,
ws_api_connection: Option<Box<dyn WsConnection>>,
}
impl fmt::Debug for Gate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Gate")
.field("ws", &self.ws.is_some())
.field("rest_base", &self.rest_base)
.field("market_type", &self.market_type)
.field("authenticated", &self.credentials.is_some())
.field("clock_offset_ms", &self.clock.offset_ms())
.field("connection", &self.connection.is_some())
.field("sub_messages", &self.sub_messages.len())
.field("subscriptions", &self.subscriptions.len())
.field("leverage", &self.leverage)
.field("private_connection", &self.private_connection.is_some())
.field("user_data_active", &self.user_data_active)
.field("ws_api_connection", &self.ws_api_connection.is_some())
.finish_non_exhaustive()
}
}
impl Gate {
fn ensure_market_is_routed(&self) -> Result<()> {
super::ensure_market_is_routed("Gate.io", self.market_type, super::SPOT_AND_LINEAR)
}
fn build(
http: Box<dyn HttpTransport>,
options: &ExchangeOptions,
credentials: Option<Credentials>,
) -> Self {
Self {
http,
ws: None,
rest_base: "https://api.gateio.ws".to_string(),
market_type: options.market_type,
position_mode: options.position_mode,
credentials,
now_ms: Box::new(system_now_ms),
clock: ServerClock::new(),
connection: None,
sub_messages: Vec::new(),
subscriptions: Vec::new(),
futures_user_id: RefCell::new(None),
derivatives_channels: Vec::new(),
leverage: 1,
private_connection: None,
user_data_active: false,
ws_api_connection: None,
}
}
fn signed_now_ms(&self) -> i64 {
self.clock.server_time_ms((self.now_ms)())
}
pub fn sync_time(&mut self) -> Result<i64> {
let local_before = (self.now_ms)();
let value = self.get("/api/v4/spot/time", "")?;
let server_ms = value
.get("server_time")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| Error::Deserialization("spot/time: no server_time".into()))?;
self.clock.sync(local_before, server_ms);
Ok(self.clock.offset_ms())
}
fn is_futures(&self) -> bool {
self.market_type.is_derivatives()
}
#[must_use]
pub fn with_http(http: Box<dyn HttpTransport>, options: &ExchangeOptions) -> Self {
Self::build(http, options, None)
}
#[must_use]
pub fn with_credentials(
http: Box<dyn HttpTransport>,
options: &ExchangeOptions,
credentials: Credentials,
) -> Self {
Self::build(http, options, Some(credentials))
}
#[must_use]
pub fn with_clock(mut self, now_ms: Box<dyn Fn() -> i64 + Send + Sync>) -> Self {
self.now_ms = now_ms;
self
}
#[must_use]
pub fn with_ws(mut self, ws: Box<dyn WsTransport>) -> Self {
self.ws = Some(ws);
self
}
#[must_use]
pub fn wire_symbol(symbol: &Symbol) -> String {
format!("{}_{}", symbol.base(), symbol.quote())
}
pub fn ticker(&self, symbol: &Symbol) -> Result<Ticker> {
if self.is_futures() {
return self.futures_ticker(symbol);
}
let query = format!("currency_pair={}", Self::wire_symbol(symbol));
let value = self.get("/api/v4/spot/tickers", &query)?;
let list: Vec<RawTicker> = parse_json(value)?;
let entry = list
.into_iter()
.next()
.ok_or_else(|| Error::NotFound(format!("no ticker for {symbol}")))?;
Ok(Ticker {
symbol: symbol.clone(),
last: parse_decimal(&entry.last)?,
bid: parse_decimal(&entry.highest_bid)?,
ask: parse_decimal(&entry.lowest_ask)?,
volume: parse_decimal(&entry.base_volume)?,
timestamp: 0,
})
}
fn futures_ticker(&self, symbol: &Symbol) -> Result<Ticker> {
let contract = Self::wire_symbol(symbol);
let value = self.get(
"/api/v4/futures/usdt/tickers",
&format!("contract={contract}"),
)?;
let list: Vec<RawFuturesTicker> = parse_json(value)?;
let entry = list
.into_iter()
.next()
.ok_or_else(|| Error::NotFound(format!("no ticker for {symbol}")))?;
let book = self.order_book(symbol, 1)?;
Ok(Ticker {
symbol: symbol.clone(),
last: parse_decimal(&entry.last)?,
bid: book.bids.first().map_or(Decimal::ZERO, |l| l.price),
ask: book.asks.first().map_or(Decimal::ZERO, |l| l.price),
volume: parse_decimal(&entry.volume_24h_base)?,
timestamp: 0,
})
}
pub fn klines(&self, symbol: &Symbol, interval: &str, limit: u32) -> Result<Vec<Candle>> {
if self.is_futures() {
let query = format!(
"contract={}&interval={}&limit={limit}",
Self::wire_symbol(symbol),
map_interval(interval),
);
let value = self.get("/api/v4/futures/usdt/candlesticks", &query)?;
let rows: Vec<RawFuturesCandle> = parse_json(value)?;
return rows.iter().map(parse_futures_candle).collect();
}
let query = format!(
"currency_pair={}&interval={}&limit={limit}",
Self::wire_symbol(symbol),
map_interval(interval),
);
let value = self.get("/api/v4/spot/candlesticks", &query)?;
let rows: Vec<Vec<String>> = parse_json(value)?;
rows.iter().map(|row| parse_kline_row(row)).collect()
}
pub fn order_book(&self, symbol: &Symbol, depth: u32) -> Result<OrderBookSnapshot> {
if self.is_futures() {
let query = format!("contract={}&limit={depth}", Self::wire_symbol(symbol));
let value = self.get("/api/v4/futures/usdt/order_book", &query)?;
let raw: RawFuturesDepth = parse_json(value)?;
return Ok(OrderBookSnapshot {
symbol: symbol.clone(),
last_update_id: raw.id,
bids: parse_futures_levels(&raw.bids)?,
asks: parse_futures_levels(&raw.asks)?,
timestamp: 0,
});
}
let query = format!("currency_pair={}&limit={depth}", Self::wire_symbol(symbol));
let value = self.get("/api/v4/spot/order_book", &query)?;
let raw: RawDepth = parse_json(value)?;
Ok(OrderBookSnapshot {
symbol: symbol.clone(),
last_update_id: raw.update,
bids: parse_levels(&raw.bids)?,
asks: parse_levels(&raw.asks)?,
timestamp: i64::try_from(raw.update).unwrap_or(0),
})
}
pub fn subscribe_derivatives(
&mut self,
symbol: &Symbol,
channel: DerivativesChannel,
) -> Result<()> {
if !self.is_futures() {
return Err(Error::unsupported_field(
"Gate.io",
"a derivatives channel on a spot client",
"funding and mark/index exist only on the futures venue",
));
}
if channel == DerivativesChannel::Liquidations {
return Err(Error::unsupported_field(
"Gate.io",
"a liquidations channel",
"Gate's `futures.liquidates` needs authentication and reports the \
caller's own liquidations, not the venue's forced flow",
));
}
let wire = Self::wire_symbol(symbol);
if !self
.derivatives_channels
.iter()
.any(|(w, c)| w == &wire && *c == channel)
{
self.derivatives_channels.push((wire, channel));
}
self.subscribe(symbol, "spot.tickers")
}
pub fn open_interest(&self, symbol: &Symbol) -> Result<OpenInterest> {
if !self.is_futures() {
return Err(Error::unsupported_field(
"Gate.io",
"open interest on a spot client",
"open interest is a futures figure",
));
}
let point = self.contract_stats(symbol)?;
Ok(OpenInterest {
symbol: symbol.clone(),
open_interest: parse_decimal(
&point
.get("open_interest")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| Error::Deserialization("no open_interest".to_string()))?
.to_string(),
)?,
timestamp: stats_millis(&point),
})
}
pub fn long_short_ratio(&self, symbol: &Symbol) -> Result<LongShortRatio> {
if !self.is_futures() {
return Err(Error::unsupported_field(
"Gate.io",
"long/short positioning on a spot client",
"positioning is a futures figure",
));
}
let point = self.contract_stats(symbol)?;
let ratio = point
.get("lsr_account")
.and_then(serde_json::Value::as_f64)
.and_then(|r| parse_decimal(&r.to_string()).ok())
.ok_or_else(|| Error::Deserialization("no lsr_account".to_string()))?;
let total = Decimal::ONE + ratio;
if total.is_zero() {
return Err(Error::Deserialization(
"a long/short ratio of -1 has no proportions".to_string(),
));
}
Ok(LongShortRatio {
symbol: symbol.clone(),
long_size: ratio / total,
short_size: Decimal::ONE / total,
timestamp: stats_millis(&point),
})
}
fn contract_stats(&self, symbol: &Symbol) -> Result<serde_json::Value> {
let query = format!("contract={}&limit=1", Self::wire_symbol(symbol));
let value = self.get("/futures/usdt/contract_stats", &query)?;
value
.as_array()
.and_then(|rows| rows.first())
.cloned()
.ok_or_else(|| Error::NotFound("no contract-stats data point".to_string()))
}
pub fn subscribe_trades(&mut self, symbol: &Symbol) -> Result<()> {
self.subscribe(symbol, "spot.trades")
}
pub fn subscribe_book(&mut self, symbol: &Symbol) -> Result<()> {
self.subscribe(symbol, "spot.order_book_update")
}
pub fn subscribe_ticker(&mut self, symbol: &Symbol) -> Result<()> {
self.subscribe(symbol, "spot.tickers")
}
fn subscribe(&mut self, symbol: &Symbol, channel: &str) -> Result<()> {
self.ensure_market_is_routed()?;
let futures = self.is_futures();
let channel = if futures {
&channel.replace("spot.", "futures.")
} else {
channel
};
let wire = Self::wire_symbol(symbol);
if self.connection.is_none() {
let ws = self.ws.as_ref().ok_or(Error::NotConnected)?;
let url = if futures {
FUTURES_WS_URL
} else {
"wss://api.gateio.ws/ws/v4/"
};
let connection = ws.connect(url)?;
self.connection = Some(connection);
}
let time = self.signed_now_ms() / 1000;
let payload = if channel == "futures.order_book_update" {
format!(r#""{wire}","100ms","20""#)
} else {
format!(r#""{wire}""#)
};
let message = format!(
r#"{{"time":{time},"channel":"{channel}","event":"subscribe","payload":[{payload}]}}"#
);
self.connection
.as_mut()
.expect("connection just ensured")
.send(&message)?;
if !self.sub_messages.contains(&message) {
self.sub_messages.push(message.clone());
}
if !self.subscriptions.iter().any(|(w, _)| w == &wire) {
self.subscriptions.push((wire, symbol.clone()));
}
Ok(())
}
pub fn poll_events(&mut self) -> Vec<Event> {
let subscriptions: HashMap<String, Symbol> = self.subscriptions.iter().cloned().collect();
let resolve = |wire: &str| {
subscriptions
.get(wire)
.cloned()
.unwrap_or_else(|| Symbol::new(wire, ""))
};
let channels = self.derivatives_channels.clone();
let subscribed = |wire: &str, channel: DerivativesChannel| {
channels.iter().any(|(w, c)| w == wire && *c == channel)
};
let mut events = Vec::new();
if let Some(connection) = self.connection.as_mut() {
while let Ok(Some(frame)) = connection.recv() {
if let Ok(mut parsed) = parse_ws_message(&frame, &resolve, &subscribed) {
events.append(&mut parsed);
}
}
}
if let Some(connection) = self.private_connection.as_mut() {
while let Ok(Some(frame)) = connection.recv() {
if let Ok(mut parsed) = parse_ws_message(&frame, &resolve, &subscribed) {
events.append(&mut parsed);
}
}
}
if self.user_data_active
&& self
.private_connection
.as_ref()
.is_some_and(|c| !c.is_connected())
{
events.push(Event::Disconnected);
self.private_connection = None;
if self.subscribe_user_data().is_ok() {
events.push(Event::Reconnected);
}
}
let url = "wss://api.gateio.ws/ws/v4/";
crate::wsutil::reconnect_if_dropped(
self.ws.as_deref(),
url,
&mut self.connection,
&self.sub_messages,
&mut events,
);
events
}
pub fn subscribe_user_data(&mut self) -> Result<()> {
self.ensure_market_is_routed()?;
let futures = self.is_futures();
let user_id = if futures {
Some(self.futures_user_id()?)
} else {
None
};
let creds = self.credentials.as_ref().ok_or(Error::InvalidCredentials(
"user-data stream requires credentials",
))?;
let time = self.signed_now_ms() / 1000;
let url = if futures {
FUTURES_WS_URL
} else {
"wss://api.gateio.ws/ws/v4/"
};
let ws = self.ws.as_ref().ok_or(Error::NotConnected)?;
let mut connection = ws.connect(url)?;
let futures_orders = user_id
.as_ref()
.map(|id| format!(r#"["{id}","!all"]"#))
.unwrap_or_default();
let futures_balances = user_id
.as_ref()
.map(|id| format!(r#"["{id}"]"#))
.unwrap_or_default();
let channels: [(&str, &str); 2] = if futures {
[
("futures.orders", futures_orders.as_str()),
("futures.balances", futures_balances.as_str()),
]
} else {
[("spot.orders", r#"["!all"]"#), ("spot.balances", "[]")]
};
for (channel, payload) in channels {
let sign_string = format!("channel={channel}&event=subscribe&time={time}");
let sign = hmac_sha512_hex(creds.api_secret.as_bytes(), sign_string.as_bytes());
let message = format!(
r#"{{"time":{time},"channel":"{channel}","event":"subscribe","payload":{payload},"auth":{{"method":"api_key","KEY":"{}","SIGN":"{sign}"}}}}"#,
creds.api_key
);
connection.send(&message)?;
}
self.private_connection = Some(connection);
self.user_data_active = true;
Ok(())
}
pub fn keepalive_user_data(&mut self) -> Result<()> {
if let Some(connection) = self.private_connection.as_mut() {
connection.send("ping")?;
}
Ok(())
}
pub fn place_order_ws(&mut self, request: &OrderRequest) -> Result<Order> {
if request.order_type.is_trigger() {
return Err(Error::unsupported_trigger("Gate.io"));
}
self.ensure_reduce_only_is_reducible(request)?;
request.validate()?;
if self.is_futures() {
return Err(Error::Exchange {
code: "unsupported".to_string(),
message: "Gate's WebSocket order API is wired for spot here; place \
futures orders over REST (/api/v4/futures/usdt/orders)"
.to_string(),
});
}
let mut req_param = serde_json::json!({
"currency_pair": Self::wire_symbol(&request.symbol),
"type": order_type_str(request.order_type),
"account": "spot",
"side": side_str(request.side),
"amount": format_decimal(request.quantity),
});
if let Some(price) = request.price {
req_param["price"] = serde_json::json!(format_decimal(price));
}
if let Some(tif) = tif_str(request)? {
req_param["time_in_force"] = serde_json::json!(tif);
}
if let Some(id) = &request.client_order_id {
let text = if id.starts_with("t-") {
id.clone()
} else {
format!("t-{id}")
};
req_param["text"] = serde_json::json!(text);
}
if let Some(act) = stp_act_str(request.stp) {
req_param["stp_act"] = serde_json::json!(act);
}
let result = self.ws_api_request("spot.order_place", &req_param)?;
let raw: RawOrder =
serde_json::from_value(result).map_err(|e| Error::Deserialization(e.to_string()))?;
order_from_raw(request.symbol.clone(), &raw)
}
pub fn cancel_order_ws(&mut self, symbol: &Symbol, order_id: &str) -> Result<()> {
if self.is_futures() {
return Err(Error::Exchange {
code: "unsupported".to_string(),
message: "Gate's WebSocket order API is wired for spot here; cancel \
futures orders over REST"
.to_string(),
});
}
let req_param = serde_json::json!({
"order_id": order_id,
"currency_pair": Self::wire_symbol(symbol),
});
self.ws_api_request("spot.order_cancel", &req_param)?;
Ok(())
}
fn ensure_ws_api(&mut self) -> Result<()> {
self.ensure_market_is_routed()?;
if self.ws_api_connection.is_some() {
return Ok(());
}
let creds = self.credentials.as_ref().ok_or(Error::InvalidCredentials(
"WebSocket order API requires credentials",
))?;
let timestamp = self.signed_now_ms() / 1000;
let signature = hmac_sha512_hex(
creds.api_secret.as_bytes(),
format!("api\nspot.login\n\n{timestamp}").as_bytes(),
);
let req_id = format!("wkex-login-{}", (self.now_ms)());
let login = format!(
r#"{{"time":{timestamp},"channel":"spot.login","event":"api","payload":{{"req_id":"{req_id}","api_key":"{}","signature":"{signature}","timestamp":"{timestamp}"}}}}"#,
creds.api_key
);
let ws = self.ws.as_ref().ok_or(Error::NotConnected)?;
let mut connection = ws.connect("wss://api.gateio.ws/ws/v4/")?;
connection.send(&login)?;
connection.recv()?; self.ws_api_connection = Some(connection);
Ok(())
}
fn ws_api_request(
&mut self,
channel: &str,
req_param: &serde_json::Value,
) -> Result<serde_json::Value> {
self.ensure_ws_api()?;
let time = self.signed_now_ms() / 1000;
let req_id = format!("wkex-{}", (self.now_ms)());
let frame = serde_json::json!({
"time": time,
"channel": channel,
"event": "api",
"payload": { "req_id": req_id, "req_param": req_param },
})
.to_string();
let connection = self
.ws_api_connection
.as_mut()
.expect("ws order connection just ensured");
connection.send(&frame)?;
let Some(response) = connection.recv()? else {
return Err(Error::NotConnected);
};
let value: serde_json::Value =
serde_json::from_str(&response).map_err(|e| Error::Deserialization(e.to_string()))?;
let status = value
.get("header")
.and_then(|header| header.get("status"))
.and_then(serde_json::Value::as_str)
.unwrap_or("");
if status == "200" {
Ok(value
.get("data")
.and_then(|data| data.get("result"))
.cloned()
.unwrap_or(serde_json::Value::Null))
} else {
let errs = value.get("data").and_then(|data| data.get("errs"));
let label = errs
.and_then(|e| e.get("label"))
.and_then(serde_json::Value::as_str)
.unwrap_or("ws");
let message = errs
.and_then(|e| e.get("message"))
.and_then(serde_json::Value::as_str)
.unwrap_or("order rejected")
.to_string();
Err(Error::OrderRejected {
code: label.to_string(),
message,
})
}
}
fn ensure_reduce_only_is_reducible(&self, request: &OrderRequest) -> Result<()> {
if request.reduce_only && !self.is_futures() {
return Err(Error::unsupported_field(
"Gate.io",
"reduce_only on a spot order",
"spot holds balances, not positions, and has none to reduce",
));
}
Ok(())
}
fn place_price_order(&self, request: &OrderRequest) -> Result<Order> {
let stop = request
.stop_price
.ok_or(Error::InvalidOrder("a trigger order requires a stop price"))?;
let rule = match request.side {
OrderSide::Sell => 2,
OrderSide::Buy => 1,
};
let (path, body) = if self.is_futures() {
let size = decimal_to_contracts(request.quantity)?;
let size = match request.side {
OrderSide::Buy => size,
OrderSide::Sell => -size,
};
(
"/api/v4/futures/usdt/price_orders",
serde_json::json!({
"initial": {
"contract": Self::wire_symbol(&request.symbol),
"size": size,
"price": request.price.map_or_else(
|| "0".to_string(),
format_decimal,
),
},
"trigger": {
"strategy_type": 0,
"price_type": 0,
"price": format_decimal(stop),
"rule": rule,
},
}),
)
} else {
let Some(price) = request.price else {
return Err(Error::unsupported_field(
"Gate.io",
"a spot stop-market order",
"Gate's spot price order can only place a limit, and choosing \
one here would decide how much slippage the caller's stop may take",
));
};
(
"/api/v4/spot/price_orders",
serde_json::json!({
"market": Self::wire_symbol(&request.symbol),
"trigger": {
"price": format_decimal(stop),
"rule": if rule == 2 { "<=" } else { ">=" },
"expiration": 0,
},
"put": {
"type": "limit",
"side": side_str(request.side),
"price": format_decimal(price),
"amount": format_decimal(request.quantity),
"account": "normal",
"time_in_force": "gtc",
},
}),
)
};
let value = self.signed_request(HttpMethod::Post, path, "", &body.to_string())?;
Ok(Order {
id: value
.get("id")
.map(|id| match id.as_str() {
Some(text) => text.to_string(),
None => id.to_string(),
})
.unwrap_or_default(),
client_order_id: request.client_order_id.clone(),
symbol: request.symbol.clone(),
side: request.side,
order_type: request.order_type,
status: OrderStatus::New,
quantity: request.quantity,
filled_quantity: Decimal::ZERO,
price: request.price,
average_price: None,
})
}
pub fn place_order(&self, request: &OrderRequest) -> Result<Order> {
self.ensure_reduce_only_is_reducible(request)?;
request.validate()?;
if request.order_type.is_trigger() {
return self.place_price_order(request);
}
if self.is_futures() {
return self.place_futures_order(request);
}
let mut body = serde_json::json!({
"currency_pair": Self::wire_symbol(&request.symbol),
"side": side_str(request.side),
"type": order_type_str(request.order_type),
"amount": format_decimal(request.quantity),
});
if let Some(price) = request.price {
body["price"] = serde_json::json!(format_decimal(price));
}
if let Some(tif) = tif_str(request)? {
body["time_in_force"] = serde_json::json!(tif);
}
if let Some(id) = &request.client_order_id {
let text = if id.starts_with("t-") {
id.clone()
} else {
format!("t-{id}")
};
body["text"] = serde_json::json!(text);
}
if let Some(act) = stp_act_str(request.stp) {
body["stp_act"] = serde_json::json!(act);
}
let value = self.signed_request(
HttpMethod::Post,
"/api/v4/spot/orders",
"",
&body.to_string(),
)?;
let raw: RawOrder = parse_json(value)?;
order_from_raw(request.symbol.clone(), &raw)
}
pub fn cancel_order(&self, symbol: &Symbol, order_id: &str) -> Result<()> {
if self.is_futures() {
let path = format!("/api/v4/futures/usdt/orders/{order_id}");
self.signed_request(HttpMethod::Delete, &path, "", "")?;
return Ok(());
}
let path = format!("/api/v4/spot/orders/{order_id}");
let query = format!("currency_pair={}", Self::wire_symbol(symbol));
self.signed_request(HttpMethod::Delete, &path, &query, "")?;
Ok(())
}
pub fn query_order(&self, symbol: &Symbol, order_id: &str) -> Result<Order> {
if self.is_futures() {
let path = format!("/api/v4/futures/usdt/orders/{order_id}");
let value = self.signed_request(HttpMethod::Get, &path, "", "")?;
let raw: RawFuturesOrder = parse_json(value)?;
return futures_order_from_raw(symbol.clone(), &raw);
}
let path = format!("/api/v4/spot/orders/{order_id}");
let query = format!("currency_pair={}", Self::wire_symbol(symbol));
let value = self.signed_request(HttpMethod::Get, &path, &query, "")?;
let raw: RawOrder = parse_json(value)?;
order_from_raw(symbol.clone(), &raw)
}
pub fn open_orders(&self, symbol: Option<&Symbol>) -> Result<Vec<Order>> {
let sym = symbol.ok_or(Error::InvalidOrder("Gate open_orders requires a symbol"))?;
if self.is_futures() {
let query = format!("contract={}&status=open", Self::wire_symbol(sym));
let value =
self.signed_request(HttpMethod::Get, "/api/v4/futures/usdt/orders", &query, "")?;
let list: Vec<RawFuturesOrder> = parse_json(value)?;
return list
.iter()
.map(|raw| futures_order_from_raw(sym.clone(), raw))
.collect();
}
let query = format!("currency_pair={}&status=open", Self::wire_symbol(sym));
let value = self.signed_request(HttpMethod::Get, "/api/v4/spot/orders", &query, "")?;
let list: Vec<RawOrder> = parse_json(value)?;
list.iter()
.map(|raw| order_from_raw(sym.clone(), raw))
.collect()
}
fn futures_user_id(&self) -> Result<String> {
if let Some(id) = self.futures_user_id.borrow().clone() {
return Ok(id);
}
let value =
self.signed_request(HttpMethod::Get, "/api/v4/futures/usdt/accounts", "", "")?;
let id = value
.get("user")
.map(|user| {
user.as_str()
.map_or_else(|| user.to_string(), str::to_string)
})
.ok_or_else(|| Error::Deserialization("futures account has no user id".to_string()))?;
*self.futures_user_id.borrow_mut() = Some(id.clone());
Ok(id)
}
pub fn balances(&self) -> Result<Vec<Balance>> {
if self.is_futures() {
let value =
self.signed_request(HttpMethod::Get, "/api/v4/futures/usdt/accounts", "", "")?;
let acct: RawFuturesAccount = parse_json(value)?;
let total = dec_or_zero(&acct.total);
let available = dec_or_zero(&acct.available);
return Ok(vec![Balance {
asset: acct.currency,
free: available,
locked: (total - available).max(Decimal::ZERO),
}]);
}
let value = self.signed_request(HttpMethod::Get, "/api/v4/spot/accounts", "", "")?;
let list: Vec<RawAccount> = parse_json(value)?;
Ok(list
.iter()
.map(|a| Balance {
asset: a.currency.clone(),
free: dec_or_zero(&a.available),
locked: dec_or_zero(&a.locked),
})
.collect())
}
fn place_futures_order(&self, request: &OrderRequest) -> Result<Order> {
let contract = Self::wire_symbol(&request.symbol);
let magnitude = decimal_to_contracts(request.quantity)?;
let size = match request.side {
OrderSide::Buy => magnitude,
OrderSide::Sell => -magnitude,
};
let mut body = if self.position_mode == PositionMode::Hedge && request.reduce_only {
let auto_size = match PositionSide::for_order(request.side, true) {
PositionSide::Long => "close_long",
PositionSide::Short => "close_short",
};
serde_json::json!({
"contract": contract,
"size": 0,
"auto_size": auto_size,
"reduce_only": true,
})
} else {
serde_json::json!({
"contract": contract,
"size": size,
"reduce_only": request.reduce_only,
})
};
match request.order_type {
OrderType::Market | OrderType::StopMarket => {
if request.time_in_force == TimeInForce::Fok {
return Err(Error::unsupported_field(
"Gate.io",
"a FOK time-in-force on a futures market order",
"the futures market order is a zero-price IOC",
));
}
body["price"] = serde_json::json!("0");
body["tif"] = serde_json::json!("ioc");
}
OrderType::Limit | OrderType::StopLimit => {
let price = request
.price
.ok_or(Error::InvalidOrder("limit order requires a price"))?;
body["price"] = serde_json::json!(format_decimal(price));
if let Some(tif) = tif_str(request)? {
body["tif"] = serde_json::json!(tif);
}
}
}
if let Some(id) = &request.client_order_id {
let text = if id.starts_with("t-") {
id.clone()
} else {
format!("t-{id}")
};
body["text"] = serde_json::json!(text);
}
let value = self.signed_request(
HttpMethod::Post,
"/api/v4/futures/usdt/orders",
"",
&body.to_string(),
)?;
let raw: RawFuturesOrder = parse_json(value)?;
futures_order_from_raw(request.symbol.clone(), &raw)
}
pub fn positions(&self, symbol: Option<&Symbol>) -> Result<Vec<Position>> {
let value =
self.signed_request(HttpMethod::Get, "/api/v4/futures/usdt/positions", "", "")?;
let list: Vec<RawFuturesPosition> = parse_json(value)?;
let wanted = symbol.map(Self::wire_symbol);
Ok(list
.iter()
.filter(|p| p.size != 0)
.filter(|p| wanted.as_ref().is_none_or(|w| &p.contract == w))
.map(parse_futures_position)
.collect())
}
pub fn set_leverage(&mut self, symbol: &Symbol, leverage: u32) -> Result<()> {
self.leverage = leverage.max(1);
self.apply_leverage(symbol, leverage)
}
pub fn set_margin_mode(&mut self, symbol: &Symbol, mode: MarginMode) -> Result<()> {
let leverage = match mode {
MarginMode::Cross => 0,
MarginMode::Isolated => self.leverage.max(1),
};
self.apply_leverage(symbol, leverage)
}
fn apply_leverage(&self, symbol: &Symbol, leverage: u32) -> Result<()> {
let path = format!(
"/api/v4/futures/usdt/positions/{}/leverage",
Self::wire_symbol(symbol)
);
let query = format!("leverage={leverage}");
self.signed_request(HttpMethod::Post, &path, &query, "")?;
Ok(())
}
pub fn close_position(&self, symbol: &Symbol) -> Result<Order> {
let position = self
.positions(Some(symbol))?
.into_iter()
.find(|p| &p.symbol == symbol)
.ok_or_else(|| Error::NotFound(format!("no open position for {symbol}")))?;
let request = match position.side {
PositionSide::Long => OrderRequest::market_sell(symbol.clone(), position.quantity),
PositionSide::Short => OrderRequest::market_buy(symbol.clone(), position.quantity),
}
.reduce_only();
self.place_order(&request)
}
fn get(&self, path: &str, query: &str) -> Result<serde_json::Value> {
self.ensure_market_is_routed()?;
let url = format!("{}{path}?{query}", self.rest_base);
let response = self.http.execute(&HttpRequest::get(url))?;
parse_body(&response)
}
fn signed_request(
&self,
method: HttpMethod,
path: &str,
query: &str,
body: &str,
) -> Result<serde_json::Value> {
self.ensure_market_is_routed()?;
let creds = self.credentials.as_ref().ok_or(Error::InvalidCredentials(
"signed endpoint requires credentials",
))?;
let timestamp = (self.signed_now_ms() / 1000).to_string();
let body_hash = sha512_hex(body.as_bytes());
let sign_string = format!(
"{}\n{path}\n{query}\n{body_hash}\n{timestamp}",
method.as_str()
);
let signature = hmac_sha512_hex(creds.api_secret.as_bytes(), sign_string.as_bytes());
let url = if query.is_empty() {
format!("{}{path}", self.rest_base)
} else {
format!("{}{path}?{query}", self.rest_base)
};
let mut request = HttpRequest::new(method, url)
.with_header("KEY", creds.api_key.clone())
.with_header("SIGN", signature)
.with_header("Timestamp", timestamp);
if !body.is_empty() {
request = request
.with_header("Content-Type", "application/json")
.with_body(body.to_string());
}
let response = self.http.execute(&request)?;
parse_body(&response)
}
}
fn parse_body(response: &HttpResponse) -> Result<serde_json::Value> {
if response.is_success() {
serde_json::from_str(&response.body).map_err(|e| Error::Deserialization(e.to_string()))
} else {
let err: GateError = serde_json::from_str(&response.body).unwrap_or(GateError {
label: response.status.to_string(),
message: response.body.clone(),
});
Err(map_error(&err.label, &err.message).with_retry_after(response.retry_after()))
}
}
fn parse_json<T: for<'de> Deserialize<'de>>(value: serde_json::Value) -> Result<T> {
serde_json::from_value(value).map_err(|e| Error::Deserialization(e.to_string()))
}
fn map_interval(interval: &str) -> String {
match interval {
"1w" => "7d",
other => other,
}
.to_string()
}
fn map_error(label: &str, message: &str) -> Error {
match label {
"TOO_MANY_REQUESTS" | "RATE_LIMIT" => Error::RateLimited { retry_after: None },
"INVALID_KEY" | "INVALID_SIGNATURE" | "SIGN_MISMATCH" | "AUTHENTICATION_FAILED" => {
Error::Auth(message.to_string())
}
"BALANCE_NOT_ENOUGH" | "INSUFFICIENT_AVAILABLE" => Error::InsufficientBalance,
"ORDER_NOT_FOUND" => Error::NotFound(message.to_string()),
"INVALID_CURRENCY_PAIR" => Error::InvalidSymbol(message.to_string()),
other => Error::Exchange {
code: other.to_string(),
message: message.to_string(),
},
}
}
fn side_str(side: OrderSide) -> &'static str {
match side {
OrderSide::Buy => "buy",
OrderSide::Sell => "sell",
}
}
fn stp_act_str(stp: SelfTradePrevention) -> Option<&'static str> {
match stp {
SelfTradePrevention::None => None,
SelfTradePrevention::ExpireMaker => Some("co"),
SelfTradePrevention::ExpireTaker => Some("cn"),
SelfTradePrevention::ExpireBoth => Some("cb"),
}
}
fn tif_str(request: &OrderRequest) -> Result<Option<&'static str>> {
match (request.post_only, request.time_in_force) {
(true, TimeInForce::Gtc) => Ok(Some("poc")),
(true, _) => Err(Error::unsupported_field(
"Gate.io",
"post_only together with a non-GTC time-in-force",
"`poc` is Gate's post-only and is itself a time-in-force",
)),
(false, TimeInForce::Gtc) => Ok(None),
(false, TimeInForce::Ioc) => Ok(Some("ioc")),
(false, TimeInForce::Fok) => Ok(Some("fok")),
}
}
fn order_type_str(order_type: OrderType) -> &'static str {
match order_type {
OrderType::Market | OrderType::StopMarket => "market",
OrderType::Limit | OrderType::StopLimit => "limit",
}
}
fn parse_side(raw: &str) -> Result<OrderSide> {
match raw {
"buy" => Ok(OrderSide::Buy),
"sell" => Ok(OrderSide::Sell),
other => Err(Error::Deserialization(format!("unknown side {other:?}"))),
}
}
fn symbol_from_wire(wire: &str) -> Symbol {
match wire.split_once('_') {
Some((base, quote)) if !base.is_empty() && !quote.is_empty() => Symbol::new(base, quote),
_ => Symbol::new(wire, ""),
}
}
fn parse_order_type(raw: &str) -> Result<OrderType> {
match raw {
"market" => Ok(OrderType::Market),
"limit" => Ok(OrderType::Limit),
other => Err(Error::Deserialization(format!(
"unknown order type {other:?}"
))),
}
}
fn dec_or_zero(raw: &str) -> Decimal {
crate::normalize::parse_opt_decimal(Some(raw))
.ok()
.flatten()
.unwrap_or(Decimal::ZERO)
}
fn nonzero_decimal(raw: &str) -> Option<Decimal> {
crate::normalize::parse_opt_decimal(Some(raw))
.ok()
.flatten()
.filter(|d| *d > Decimal::ZERO)
}
fn parse_levels(levels: &[[String; 2]]) -> Result<Vec<BookLevel>> {
levels
.iter()
.map(|[price, qty]| {
Ok(BookLevel {
price: parse_decimal(price)?,
quantity: parse_decimal(qty)?,
})
})
.collect()
}
fn parse_kline_row(row: &[String]) -> Result<Candle> {
if row.len() < 7 {
return Err(Error::Deserialization("kline row too short".to_string()));
}
let ts = row[0]
.parse::<i64>()
.map_err(|e| Error::Deserialization(format!("kline ts not an integer: {e}")))?;
let f = |i: usize| -> Result<f64> {
row[i]
.parse::<f64>()
.map_err(|e| Error::Deserialization(format!("kline field not a number: {e}")))
};
Candle::new(f(5)?, f(3)?, f(4)?, f(2)?, f(6)?, ts)
.map_err(|e| Error::Deserialization(e.to_string()))
}
fn order_from_raw(symbol: Symbol, raw: &RawOrder) -> Result<Order> {
let filled = if raw.filled_amount.is_empty() && !raw.left.is_empty() {
(dec_or_zero(&raw.amount) - dec_or_zero(&raw.left)).max(Decimal::ZERO)
} else {
dec_or_zero(&raw.filled_amount)
};
let status = match raw.status.as_str() {
"cancelled" => OrderStatus::Canceled,
"closed" => OrderStatus::Filled,
"open" => {
if filled > Decimal::ZERO {
OrderStatus::PartiallyFilled
} else {
OrderStatus::New
}
}
other => return Err(Error::Deserialization(format!("unknown status {other:?}"))),
};
Ok(Order {
id: raw.id.clone(),
client_order_id: (!raw.text.is_empty()).then(|| raw.text.clone()),
symbol,
side: parse_side(&raw.side)?,
order_type: parse_order_type(&raw.order_type)?,
status,
quantity: parse_decimal(&raw.amount)?,
filled_quantity: filled,
price: nonzero_decimal(&raw.price),
average_price: nonzero_decimal(&raw.avg_deal_price),
})
}
fn field_str<'a>(value: &'a serde_json::Value, key: &str) -> Result<&'a str> {
value
.get(key)
.and_then(serde_json::Value::as_str)
.ok_or_else(|| Error::Deserialization(format!("missing string field {key:?}")))
}
fn opt_str<'a>(value: &'a serde_json::Value, key: &str) -> &'a str {
value
.get(key)
.and_then(serde_json::Value::as_str)
.unwrap_or("")
}
fn opt_u64(value: &serde_json::Value, key: &str) -> u64 {
value
.get(key)
.and_then(|v| {
v.as_u64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
})
.unwrap_or(0)
}
fn opt_i64(value: &serde_json::Value, key: &str) -> i64 {
value
.get(key)
.and_then(|v| {
v.as_i64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
})
.unwrap_or(0)
}
fn parse_ws_levels(value: Option<&serde_json::Value>) -> Result<Vec<BookLevel>> {
let array = value
.and_then(serde_json::Value::as_array)
.ok_or_else(|| Error::Deserialization("missing depth levels".to_string()))?;
array
.iter()
.map(|level| {
let pair = level
.as_array()
.ok_or_else(|| Error::Deserialization("depth level not an array".to_string()))?;
let price = parse_decimal(
pair.first()
.and_then(serde_json::Value::as_str)
.ok_or_else(|| Error::Deserialization("depth price missing".to_string()))?,
)?;
let quantity =
parse_decimal(pair.get(1).and_then(serde_json::Value::as_str).ok_or_else(
|| Error::Deserialization("depth quantity missing".to_string()),
)?)?;
Ok(BookLevel { price, quantity })
})
.collect()
}
fn stats_millis(point: &serde_json::Value) -> i64 {
point
.get("time")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0)
* 1000
}
const FUTURES_WS_URL: &str = "wss://fx-ws.gateio.ws/v4/ws/usdt";
fn futures_signed_size(value: &serde_json::Value, key: &str) -> Option<(Decimal, OrderSide)> {
let raw = value.get(key)?;
let text = raw
.as_str()
.map(ToString::to_string)
.or_else(|| raw.as_i64().map(|n| n.to_string()))?;
let size = parse_decimal(&text).ok()?;
let side = if size.is_sign_negative() {
OrderSide::Sell
} else {
OrderSide::Buy
};
Some((size.abs(), side))
}
fn futures_levels(value: Option<&serde_json::Value>) -> Vec<BookLevel> {
value
.and_then(serde_json::Value::as_array)
.map(|rows| {
rows.iter()
.filter_map(|row| {
let size = row.get("s")?;
let size = size
.as_str()
.map(ToString::to_string)
.or_else(|| size.as_i64().map(|n| n.to_string()))?;
Some(BookLevel {
price: parse_decimal(row.get("p")?.as_str()?).ok()?,
quantity: parse_decimal(&size).ok()?,
})
})
.collect()
})
.unwrap_or_default()
}
fn parse_ws_message(
text: &str,
resolve: &impl Fn(&str) -> Symbol,
subscribed: &impl Fn(&str, DerivativesChannel) -> bool,
) -> Result<Vec<Event>> {
let value: serde_json::Value =
serde_json::from_str(text).map_err(|e| Error::Deserialization(e.to_string()))?;
if value.get("event").and_then(serde_json::Value::as_str) != Some("update") {
return Ok(Vec::new()); }
let Some(channel) = value.get("channel").and_then(serde_json::Value::as_str) else {
return Ok(Vec::new());
};
let null = serde_json::Value::Null;
let result = value.get("result").unwrap_or(&null);
match channel {
"futures.trades" => Ok(result
.as_array()
.map(|trades| {
trades
.iter()
.filter_map(|trade| {
let (quantity, aggressor) = futures_signed_size(trade, "size")?;
Some(Event::Trade(TradePrint {
symbol: resolve(trade.get("contract")?.as_str()?),
price: parse_decimal(trade.get("price")?.as_str()?).ok()?,
quantity,
aggressor,
timestamp: trade
.get("create_time_ms")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0),
}))
})
.collect()
})
.unwrap_or_default()),
"futures.order_book_update" => {
let Some(contract) = result.get("s").and_then(serde_json::Value::as_str) else {
return Ok(Vec::new());
};
Ok(vec![Event::BookDelta(BookDelta {
symbol: resolve(contract),
first_update_id: result
.get("U")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0),
final_update_id: result
.get("u")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0),
bids: futures_levels(result.get("b")),
asks: futures_levels(result.get("a")),
timestamp: result
.get("t")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0),
})])
}
"futures.tickers" => {
let empty = Vec::new();
let mut out = Vec::new();
for ticker in result.as_array().unwrap_or(&empty) {
let Some(wire) = ticker.get("contract").and_then(serde_json::Value::as_str) else {
continue;
};
let symbol = resolve(wire);
let timestamp = ticker
.get("t")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
let field = |key: &str| {
ticker
.get(key)
.and_then(serde_json::Value::as_str)
.and_then(|v| parse_decimal(v).ok())
};
if let Some(last) = field("last") {
out.push(Event::Ticker(Ticker {
symbol: symbol.clone(),
last,
bid: Decimal::ZERO,
ask: Decimal::ZERO,
volume: field("volume_24h_base").unwrap_or_default(),
timestamp,
}));
}
let mark = field("mark_price");
if subscribed(wire, DerivativesChannel::Funding) {
if let (Some(rate), Some(mark_price)) = (field("funding_rate"), mark) {
out.push(Event::Derivatives(DerivativesFeed::Funding(FundingRate {
symbol: symbol.clone(),
rate,
mark_price,
timestamp,
})));
}
}
if subscribed(wire, DerivativesChannel::MarkIndex) {
if let (Some(mark_price), Some(index_price)) = (mark, field("index_price")) {
out.push(Event::Derivatives(DerivativesFeed::MarkIndex(MarkIndex {
symbol,
mark_price,
index_price,
timestamp,
})));
}
}
}
Ok(out)
}
"spot.trades" => Ok(vec![Event::Trade(TradePrint {
symbol: resolve(field_str(result, "currency_pair")?),
price: parse_decimal(field_str(result, "price")?)?,
quantity: parse_decimal(field_str(result, "amount")?)?,
aggressor: parse_side(field_str(result, "side")?)?,
timestamp: opt_i64(result, "create_time_ms"),
})]),
"spot.tickers" => Ok(vec![Event::Ticker(Ticker {
symbol: resolve(field_str(result, "currency_pair")?),
last: parse_decimal(field_str(result, "last")?)?,
bid: dec_or_zero(opt_str(result, "highest_bid")),
ask: dec_or_zero(opt_str(result, "lowest_ask")),
volume: dec_or_zero(opt_str(result, "base_volume")),
timestamp: 0,
})]),
"spot.order_book_update" => {
let update_id = opt_u64(result, "u");
Ok(vec![Event::BookDelta(BookDelta {
symbol: resolve(opt_str(result, "s")),
first_update_id: update_id,
final_update_id: update_id,
bids: parse_ws_levels(result.get("b"))?,
asks: parse_ws_levels(result.get("a"))?,
timestamp: opt_u64(result, "t").try_into().unwrap_or(0),
})])
}
"spot.orders" => result
.as_array()
.ok_or_else(|| Error::Deserialization("spot.orders result not an array".to_string()))?
.iter()
.map(|order| {
let symbol = symbol_from_wire(field_str(order, "currency_pair")?);
let raw: RawOrder = serde_json::from_value(order.clone())
.map_err(|e| Error::Deserialization(e.to_string()))?;
Ok(Event::OrderUpdate(order_from_raw(symbol, &raw)?))
})
.collect(),
"futures.orders" => result
.as_array()
.ok_or_else(|| {
Error::Deserialization("futures.orders result not an array".to_string())
})?
.iter()
.map(|order| Ok(Event::OrderUpdate(futures_order_from_frame(order)?)))
.collect(),
"futures.balances" => {
let balances = result
.as_array()
.ok_or_else(|| {
Error::Deserialization("futures.balances result not an array".to_string())
})?
.iter()
.map(|entry| {
Ok(Balance {
asset: field_str(entry, "currency")?.to_string(),
free: number_or_string(entry, "balance"),
locked: Decimal::ZERO,
})
})
.collect::<Result<Vec<_>>>()?;
Ok(vec![Event::BalanceUpdate(balances)])
}
"spot.balances" => {
let balances = result
.as_array()
.ok_or_else(|| {
Error::Deserialization("spot.balances result not an array".to_string())
})?
.iter()
.map(|entry| {
let total = dec_or_zero(opt_str(entry, "total"));
let available = dec_or_zero(opt_str(entry, "available"));
Ok(Balance {
asset: field_str(entry, "currency")?.to_string(),
free: available,
locked: (total - available).max(Decimal::ZERO),
})
})
.collect::<Result<Vec<_>>>()?;
Ok(vec![Event::BalanceUpdate(balances)])
}
_ => Ok(Vec::new()),
}
}
#[derive(Deserialize)]
struct GateError {
#[serde(default)]
label: String,
#[serde(default)]
message: String,
}
#[derive(Deserialize)]
struct RawTicker {
last: String,
highest_bid: String,
lowest_ask: String,
base_volume: String,
}
#[derive(Deserialize)]
struct RawDepth {
#[serde(default)]
update: u64,
bids: Vec<[String; 2]>,
asks: Vec<[String; 2]>,
}
fn futures_order_from_frame(order: &serde_json::Value) -> Result<Order> {
let size = order
.get("size")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| Error::Deserialization("futures order has no size".to_string()))?;
let side = if size < 0 {
OrderSide::Sell
} else {
OrderSide::Buy
};
let quantity = Decimal::from(size.abs());
let left = number_or_string(order, "left").abs();
let price = nonzero_decimal(opt_str(order, "price"));
let text = opt_str(order, "text");
Ok(Order {
id: order
.get("id")
.map(|id| id.as_str().map_or_else(|| id.to_string(), str::to_string))
.ok_or_else(|| Error::Deserialization("futures order has no id".to_string()))?,
client_order_id: text.starts_with("t-").then(|| text.to_string()),
symbol: symbol_from_wire(field_str(order, "contract")?),
side,
order_type: if price.is_some() {
OrderType::Limit
} else {
OrderType::Market
},
status: futures_order_status(
opt_str(order, "status"),
opt_str(order, "finish_as"),
(quantity - left).max(Decimal::ZERO),
),
quantity,
filled_quantity: (quantity - left).max(Decimal::ZERO),
price,
average_price: nonzero_decimal(opt_str(order, "fill_price")),
})
}
fn futures_order_status(status: &str, finish_as: &str, filled: Decimal) -> OrderStatus {
match status {
"finished" if finish_as == "filled" => OrderStatus::Filled,
"finished" => OrderStatus::Canceled,
_ if filled > Decimal::ZERO => OrderStatus::PartiallyFilled,
_ => OrderStatus::New,
}
}
fn number_or_string(value: &serde_json::Value, key: &str) -> Decimal {
value.get(key).map_or(Decimal::ZERO, |field| {
field.as_str().map_or_else(
|| {
field
.as_f64()
.and_then(|n| parse_decimal(&n.to_string()).ok())
.unwrap_or(Decimal::ZERO)
},
|text| parse_decimal(text).unwrap_or(Decimal::ZERO),
)
})
}
#[derive(Deserialize)]
struct RawOrder {
id: String,
#[serde(default)]
text: String,
side: String,
#[serde(rename = "type")]
order_type: String,
status: String,
amount: String,
#[serde(rename = "filled_amount", default)]
filled_amount: String,
#[serde(default)]
left: String,
#[serde(default)]
price: String,
#[serde(rename = "avg_deal_price", default)]
avg_deal_price: String,
}
#[derive(Deserialize)]
struct RawAccount {
currency: String,
#[serde(default)]
available: String,
#[serde(default)]
locked: String,
}
#[derive(Deserialize)]
struct RawFuturesTicker {
last: String,
#[serde(rename = "volume_24h_base", default)]
volume_24h_base: String,
}
#[derive(Deserialize)]
struct RawFuturesCandle {
t: i64,
o: String,
h: String,
l: String,
c: String,
#[serde(default)]
v: f64,
}
#[derive(Deserialize)]
struct RawFuturesLevel {
p: String,
s: i64,
}
#[derive(Deserialize)]
struct RawFuturesDepth {
#[serde(default)]
id: u64,
bids: Vec<RawFuturesLevel>,
asks: Vec<RawFuturesLevel>,
}
#[derive(Deserialize)]
struct RawFuturesOrder {
id: i64,
size: i64,
#[serde(default)]
left: i64,
#[serde(default)]
price: String,
#[serde(rename = "fill_price", default)]
fill_price: String,
status: String,
#[serde(rename = "finish_as", default)]
finish_as: String,
#[serde(default)]
text: String,
}
#[derive(Deserialize)]
struct RawFuturesAccount {
#[serde(default = "usdt_currency")]
currency: String,
#[serde(default)]
total: String,
#[serde(default)]
available: String,
}
fn usdt_currency() -> String {
"USDT".to_string()
}
#[derive(Deserialize)]
struct RawFuturesPosition {
contract: String,
size: i64,
#[serde(default)]
leverage: String,
#[serde(rename = "cross_leverage_limit", default)]
cross_leverage_limit: String,
#[serde(rename = "entry_price", default)]
entry_price: String,
#[serde(rename = "mark_price", default)]
mark_price: String,
#[serde(rename = "unrealised_pnl", default)]
unrealised_pnl: String,
}
fn decimal_to_contracts(quantity: Decimal) -> Result<i64> {
let contracts = quantity
.round()
.to_i64()
.filter(|c| *c != 0)
.ok_or(Error::InvalidOrder("futures size rounds to zero contracts"))?;
Ok(contracts.abs())
}
fn parse_futures_candle(row: &RawFuturesCandle) -> Result<Candle> {
let f = |s: &str| -> Result<f64> {
s.parse::<f64>()
.map_err(|e| Error::Deserialization(format!("candle field not a number: {e}")))
};
Candle::new(f(&row.o)?, f(&row.h)?, f(&row.l)?, f(&row.c)?, row.v, row.t)
.map_err(|e| Error::Deserialization(e.to_string()))
}
fn parse_futures_levels(levels: &[RawFuturesLevel]) -> Result<Vec<BookLevel>> {
levels
.iter()
.map(|level| {
Ok(BookLevel {
price: parse_decimal(&level.p)?,
quantity: Decimal::from(level.s.abs()),
})
})
.collect()
}
fn futures_order_from_raw(symbol: Symbol, raw: &RawFuturesOrder) -> Result<Order> {
let side = if raw.size >= 0 {
OrderSide::Buy
} else {
OrderSide::Sell
};
let quantity = Decimal::from(raw.size.abs());
let filled = Decimal::from(raw.size.abs() - raw.left.abs());
let order_type = if raw.price == "0" || raw.price.is_empty() {
OrderType::Market
} else {
OrderType::Limit
};
let status = match raw.status.as_str() {
"open" => {
if filled > Decimal::ZERO {
OrderStatus::PartiallyFilled
} else {
OrderStatus::New
}
}
"finished" => {
if raw.finish_as == "filled" {
OrderStatus::Filled
} else {
OrderStatus::Canceled
}
}
other => return Err(Error::Deserialization(format!("unknown status {other:?}"))),
};
Ok(Order {
id: raw.id.to_string(),
client_order_id: (!raw.text.is_empty()).then(|| raw.text.clone()),
symbol,
side,
order_type,
status,
quantity,
filled_quantity: filled,
price: nonzero_decimal(&raw.price),
average_price: nonzero_decimal(&raw.fill_price),
})
}
fn parse_futures_position(raw: &RawFuturesPosition) -> Position {
let side = if raw.size > 0 {
PositionSide::Long
} else {
PositionSide::Short
};
let is_cross = raw.leverage.is_empty() || raw.leverage == "0";
let leverage = if is_cross {
dec_or_zero(&raw.cross_leverage_limit)
} else {
dec_or_zero(&raw.leverage)
};
Position {
symbol: symbol_from_contract(&raw.contract),
side,
quantity: Decimal::from(raw.size.abs()),
entry_price: dec_or_zero(&raw.entry_price),
mark_price: dec_or_zero(&raw.mark_price),
leverage,
unrealized_pnl: dec_or_zero(&raw.unrealised_pnl),
margin_mode: if is_cross {
MarginMode::Cross
} else {
MarginMode::Isolated
},
}
}
fn symbol_from_contract(contract: &str) -> Symbol {
match contract.split_once('_') {
Some((base, quote)) => Symbol::new(base, quote),
None => Symbol::new(contract, ""),
}
}
impl MarketData for Gate {
fn ticker(&mut self, symbol: &Symbol) -> Result<Ticker> {
Gate::ticker(self, symbol)
}
fn klines(&mut self, symbol: &Symbol, interval: &str, limit: u32) -> Result<Vec<Candle>> {
Gate::klines(self, symbol, interval, limit)
}
fn order_book(&mut self, symbol: &Symbol, depth: u32) -> Result<OrderBookSnapshot> {
Gate::order_book(self, symbol, depth)
}
fn subscribe_trades(&mut self, symbol: &Symbol) -> Result<()> {
Gate::subscribe_trades(self, symbol)
}
fn subscribe_book(&mut self, symbol: &Symbol) -> Result<()> {
Gate::subscribe_book(self, symbol)
}
fn subscribe_ticker(&mut self, symbol: &Symbol) -> Result<()> {
Gate::subscribe_ticker(self, symbol)
}
fn poll_events(&mut self) -> Vec<Event> {
Gate::poll_events(self)
}
}
impl Execution for Gate {
fn place_order(&mut self, request: &OrderRequest) -> Result<Order> {
Gate::place_order(self, request)
}
fn cancel_order(&mut self, symbol: &Symbol, order_id: &str) -> Result<()> {
Gate::cancel_order(self, symbol, order_id)
}
fn query_order(&mut self, symbol: &Symbol, order_id: &str) -> Result<Order> {
Gate::query_order(self, symbol, order_id)
}
fn open_orders(&mut self, symbol: Option<&Symbol>) -> Result<Vec<Order>> {
Gate::open_orders(self, symbol)
}
fn balances(&mut self) -> Result<Vec<Balance>> {
Gate::balances(self)
}
}
impl Exchange for Gate {
fn name(&self) -> &'static str {
"gate"
}
}
impl WsUserData for Gate {
fn subscribe_user_data(&mut self) -> Result<()> {
Gate::subscribe_user_data(self)
}
fn keepalive_user_data(&mut self) -> Result<()> {
Gate::keepalive_user_data(self)
}
}
impl WsExecution for Gate {
fn place_order_ws(&mut self, request: &OrderRequest) -> Result<Order> {
Gate::place_order_ws(self, request)
}
fn cancel_order_ws(&mut self, symbol: &Symbol, order_id: &str) -> Result<()> {
Gate::cancel_order_ws(self, symbol, order_id)
}
}
impl Derivatives for Gate {
fn positions(&mut self, symbol: Option<&Symbol>) -> Result<Vec<Position>> {
Gate::positions(self, symbol)
}
fn set_leverage(&mut self, symbol: &Symbol, leverage: u32) -> Result<()> {
Gate::set_leverage(self, symbol, leverage)
}
fn set_margin_mode(&mut self, symbol: &Symbol, mode: MarginMode) -> Result<()> {
Gate::set_margin_mode(self, symbol, mode)
}
fn close_position(&mut self, symbol: &Symbol) -> Result<Order> {
Gate::close_position(self, symbol)
}
}
impl DerivativesStream for Gate {
fn subscribe_derivatives(
&mut self,
symbol: &Symbol,
channel: DerivativesChannel,
) -> Result<()> {
Gate::subscribe_derivatives(self, symbol, channel)
}
fn open_interest(&mut self, symbol: &Symbol) -> Result<OpenInterest> {
Gate::open_interest(self, symbol)
}
fn long_short_ratio(&mut self, symbol: &Symbol) -> Result<LongShortRatio> {
Gate::long_short_ratio(self, symbol)
}
}
impl Gate {
pub fn amend_order(
&self,
symbol: &Symbol,
order_id: &str,
new_price: Option<Decimal>,
new_quantity: Option<Decimal>,
) -> Result<Order> {
let mut body = serde_json::json!({});
if let Some(q) = new_quantity {
body["amount"] = serde_json::json!(format_decimal(q));
}
if let Some(p) = new_price {
body["price"] = serde_json::json!(format_decimal(p));
}
let path = format!("/api/v4/spot/orders/{order_id}");
let query = format!("currency_pair={}", Self::wire_symbol(symbol));
let value = self.signed_request(HttpMethod::Patch, &path, &query, &body.to_string())?;
let raw: RawOrder = parse_json(value)?;
order_from_raw(symbol.clone(), &raw)
}
pub fn place_batch(&self, requests: &[OrderRequest]) -> Result<Vec<Result<Order>>> {
if requests.iter().any(|r| r.order_type.is_trigger()) {
return Err(Error::unsupported_trigger("Gate.io"));
}
for request in requests {
self.ensure_reduce_only_is_reducible(request)?;
}
let tifs = requests.iter().map(tif_str).collect::<Result<Vec<_>>>()?;
let items: Vec<serde_json::Value> = requests
.iter()
.zip(&tifs)
.map(|(r, tif)| {
let mut o = serde_json::json!({
"currency_pair": Self::wire_symbol(&r.symbol),
"side": side_str(r.side),
"type": order_type_str(r.order_type),
"amount": format_decimal(r.quantity),
});
if let Some(price) = r.price {
o["price"] = serde_json::json!(format_decimal(price));
}
if let Some(tif) = tif {
o["time_in_force"] = serde_json::json!(*tif);
}
if let Some(id) = &r.client_order_id {
let text = if id.starts_with("t-") {
id.clone()
} else {
format!("t-{id}")
};
o["text"] = serde_json::json!(text);
}
if let Some(act) = stp_act_str(r.stp) {
o["stp_act"] = serde_json::json!(act);
}
o
})
.collect();
let body = serde_json::Value::Array(items).to_string();
let value =
self.signed_request(HttpMethod::Post, "/api/v4/spot/batch_orders", "", &body)?;
let elements: Vec<serde_json::Value> = parse_json(value)?;
Ok(requests
.iter()
.zip(elements)
.map(|(req, elem)| {
let succeeded = elem
.get("succeeded")
.and_then(serde_json::Value::as_bool)
.unwrap_or(true);
if !succeeded {
let message = elem
.get("message")
.or_else(|| elem.get("label"))
.and_then(serde_json::Value::as_str)
.unwrap_or("order rejected in batch")
.to_string();
return Err(Error::OrderRejected {
code: "batch".to_string(),
message,
});
}
let raw: RawOrder = serde_json::from_value(elem)
.map_err(|e| Error::Deserialization(e.to_string()))?;
order_from_raw(req.symbol.clone(), &raw)
})
.collect())
}
pub fn cancel_batch(&self, symbol: &Symbol, order_ids: &[String]) -> Result<()> {
let pair = Self::wire_symbol(symbol);
let items: Vec<serde_json::Value> = order_ids
.iter()
.map(|id| serde_json::json!({ "currency_pair": pair, "id": id }))
.collect();
let body = serde_json::Value::Array(items).to_string();
self.signed_request(
HttpMethod::Post,
"/api/v4/spot/cancel_batch_orders",
"",
&body,
)?;
Ok(())
}
}
impl AdvancedOrders for Gate {
fn amend_order(
&mut self,
symbol: &Symbol,
order_id: &str,
new_price: Option<Decimal>,
new_quantity: Option<Decimal>,
) -> Result<Order> {
Gate::amend_order(self, symbol, order_id, new_price, new_quantity)
}
fn place_batch(&mut self, requests: &[OrderRequest]) -> Result<Vec<Result<Order>>> {
Gate::place_batch(self, requests)
}
fn cancel_batch(&mut self, symbol: &Symbol, order_ids: &[String]) -> Result<()> {
Gate::cancel_batch(self, symbol, order_ids)
}
fn place_oco(&mut self, _request: &OcoRequest) -> Result<Vec<Order>> {
Err(Error::Exchange {
code: "unsupported".to_string(),
message: "Gate has no OCO order-list; use price-triggered orders".to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transport::{MockHttpTransport, MockWsTransport};
use rust_decimal_macros::dec;
use std::sync::Arc;
struct ArcTransport(Arc<MockHttpTransport>);
impl HttpTransport for ArcTransport {
fn execute(&self, request: &HttpRequest) -> Result<HttpResponse> {
self.0.execute(request)
}
}
struct ArcWs(Arc<MockWsTransport>);
impl WsTransport for ArcWs {
fn connect(&self, url: &str) -> Result<Box<dyn WsConnection>> {
self.0.connect(url)
}
}
fn symbol() -> Symbol {
Symbol::new("BTC", "USDT")
}
fn client() -> (Gate, Arc<MockHttpTransport>) {
let mock = Arc::new(MockHttpTransport::new());
let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
(
Gate::with_http(Box::new(ArcTransport(Arc::clone(&mock))), &opts),
mock,
)
}
fn futures_ws_client() -> (Gate, Arc<MockWsTransport>) {
let http = Arc::new(MockHttpTransport::new());
let ws = Arc::new(MockWsTransport::new());
let opts = ExchangeOptions::mainnet(crate::MarketType::UsdMFutures);
let gate = Gate::with_http(Box::new(ArcTransport(http)), &opts)
.with_ws(Box::new(ArcWs(Arc::clone(&ws))));
(gate, ws)
}
#[test]
fn gate_polled_reads_report_what_went_wrong() {
let http = Arc::new(MockHttpTransport::new());
let opts = ExchangeOptions::mainnet(crate::MarketType::UsdMFutures);
let gate = Gate::with_http(Box::new(ArcTransport(Arc::clone(&http))), &opts);
http.push_json(200, "[]");
assert!(matches!(
Gate::open_interest(&gate, &symbol()),
Err(Error::NotFound(_))
));
http.push_json(200, r#"[{"time":1788464400}]"#);
assert!(matches!(
Gate::open_interest(&gate, &symbol()),
Err(Error::Deserialization(_))
));
http.push_json(200, r#"[{"time":1788464400,"open_interest":1}]"#);
assert!(matches!(
Gate::long_short_ratio(&gate, &symbol()),
Err(Error::Deserialization(_))
));
http.push_json(200, r#"[{"time":1788464400,"lsr_account":-1.0}]"#);
assert!(matches!(
Gate::long_short_ratio(&gate, &symbol()),
Err(Error::Deserialization(_))
));
}
#[test]
fn gate_drops_futures_frames_it_cannot_read() {
let (mut gate, ws) = futures_ws_client();
ws.push_connection(vec![
Ok(Some(
r#"{"time":1,"channel":"futures.order_book_update","event":"update",
"result":{"t":1,"U":1,"u":2,"a":[],"b":[]}}"#
.to_string(),
)),
Ok(Some(
r#"{"time":1,"channel":"futures.trades","event":"update",
"result":[{"size":1,"contract":"BTC_USDT"}]}"#
.to_string(),
)),
Ok(Some(
r#"{"time":1,"channel":"futures.tickers","event":"update",
"result":[{"contract":"BTC_USDT"}]}"#
.to_string(),
)),
Ok(Some(
r#"{"time":1,"channel":"futures.order_book_update","event":"update",
"result":{"t":1,"U":1,"u":2,"s":"BTC_USDT","a":[{"p":"1.0"}],"b":[]}}"#
.to_string(),
)),
]);
Gate::subscribe_book(&mut gate, &symbol()).unwrap();
let events = Gate::poll_events(&mut gate);
assert_eq!(events.len(), 1, "{events:?}");
let Event::BookDelta(delta) = &events[0] else {
panic!("expected a delta");
};
assert!(delta.asks.is_empty() && delta.bids.is_empty());
}
#[test]
fn the_futures_ticker_answers_funding_and_mark_index_on_gate() {
let (mut gate, ws) = futures_ws_client();
ws.push_connection(vec![Ok(Some(
r#"{"time":1788462378,"channel":"futures.tickers","event":"update",
"result":[{"contract":"BTC_USDT","last":"81231.4","mark_price":"81238.4",
"index_price":"81271.1","funding_rate":"0.000073",
"volume_24h_base":"81628.265","t":1788462378855}]}"#
.to_string(),
))]);
Gate::subscribe_derivatives(&mut gate, &symbol(), DerivativesChannel::Funding).unwrap();
Gate::subscribe_derivatives(&mut gate, &symbol(), DerivativesChannel::MarkIndex).unwrap();
let events = Gate::poll_events(&mut gate);
assert_eq!(events.len(), 3, "ticker, funding, mark/index: {events:?}");
let Event::Derivatives(DerivativesFeed::Funding(funding)) = &events[1] else {
panic!("expected funding, got {:?}", events[1]);
};
assert_eq!(funding.rate, dec!(0.000073));
assert_eq!(funding.mark_price, dec!(81238.4));
let Event::Derivatives(DerivativesFeed::MarkIndex(mark)) = &events[2] else {
panic!("expected mark/index, got {:?}", events[2]);
};
assert_eq!(mark.index_price, dec!(81271.1));
}
#[test]
fn gate_refuses_liquidations() {
let (mut gate, ws) = futures_ws_client();
let err =
Gate::subscribe_derivatives(&mut gate, &symbol(), DerivativesChannel::Liquidations)
.expect_err("Gate publishes no public forced-order feed");
assert!(matches!(err, Error::Exchange { ref code, .. } if code == "unsupported"));
assert_eq!(ws.sent(), Vec::<String>::new());
}
#[test]
fn the_polled_figures_come_from_one_row_stamped_in_seconds() {
let http = Arc::new(MockHttpTransport::new());
let opts = ExchangeOptions::mainnet(crate::MarketType::UsdMFutures);
let gate = Gate::with_http(Box::new(ArcTransport(Arc::clone(&http))), &opts);
http.push_json(
200,
r#"[{"time":1788464400,"lsr_taker":1.6098,"lsr_account":3.0,
"open_interest":626095712,"mark_price":81477,
"open_interest_usd":5101240032.6624}]"#,
);
let open_interest = Gate::open_interest(&gate, &symbol()).unwrap();
assert_eq!(open_interest.open_interest, dec!(626095712));
assert_eq!(open_interest.timestamp, 1_788_464_400_000);
http.push_json(
200,
r#"[{"time":1788464400,"lsr_account":3.0,"open_interest":626095712}]"#,
);
let ratio = Gate::long_short_ratio(&gate, &symbol()).unwrap();
assert_eq!(ratio.long_size, dec!(0.75));
assert_eq!(ratio.short_size, dec!(0.25));
assert_eq!(ratio.long_size + ratio.short_size, Decimal::ONE);
}
#[test]
fn a_spot_client_refuses_the_gate_derivatives_surface() {
let ws = Arc::new(MockWsTransport::new());
let http = Arc::new(MockHttpTransport::new());
let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
let mut gate = Gate::with_http(Box::new(ArcTransport(Arc::clone(&http))), &opts)
.with_ws(Box::new(ArcWs(Arc::clone(&ws))));
for channel in [
DerivativesChannel::Funding,
DerivativesChannel::MarkIndex,
DerivativesChannel::Liquidations,
] {
assert!(Gate::subscribe_derivatives(&mut gate, &symbol(), channel).is_err());
}
assert!(Gate::open_interest(&gate, &symbol()).is_err());
assert!(Gate::long_short_ratio(&gate, &symbol()).is_err());
}
#[test]
fn a_futures_client_subscribes_on_the_futures_host() {
let (mut gate, ws) = futures_ws_client();
Gate::subscribe_trades(&mut gate, &symbol()).unwrap();
Gate::subscribe_book(&mut gate, &symbol()).unwrap();
assert_eq!(ws.connected_urls()[0], "wss://fx-ws.gateio.ws/v4/ws/usdt");
assert!(ws.sent()[0].contains(r#""channel":"futures.trades""#));
assert!(!ws.sent()[0].contains("spot."));
assert!(ws.sent()[1].contains(r#""channel":"futures.order_book_update""#));
assert!(ws.sent()[1].contains(r#""payload":["BTC_USDT","100ms","20"]"#));
}
#[test]
fn a_futures_trade_takes_its_side_from_the_sign_of_the_size() {
let (mut gate, ws) = futures_ws_client();
ws.push_connection(vec![
Ok(Some(
r#"{"time":1788462378,"time_ms":1788462378855,"channel":"futures.trades",
"event":"update","result":[{"id":826990332,"size":14372,
"create_time":1788462378,"create_time_ms":1788462378855,
"price":"81231.4","contract":"BTC_USDT"}]}"#
.to_string(),
)),
Ok(Some(
r#"{"time":1788462379,"channel":"futures.trades","event":"update",
"result":[{"id":826990333,"size":-25,"create_time_ms":1788462379000,
"price":"81230.0","contract":"BTC_USDT"}]}"#
.to_string(),
)),
]);
Gate::subscribe_trades(&mut gate, &symbol()).unwrap();
let events = Gate::poll_events(&mut gate);
let Event::Trade(bought) = &events[0] else {
panic!("expected a trade, got {:?}", events[0]);
};
assert_eq!(bought.aggressor, OrderSide::Buy);
assert_eq!(bought.quantity, dec!(14372));
assert_eq!(bought.price, dec!(81231.4));
assert_eq!(bought.timestamp, 1_788_462_378_855);
let Event::Trade(sold) = &events[1] else {
panic!("expected a trade, got {:?}", events[1]);
};
assert_eq!(sold.aggressor, OrderSide::Sell);
assert_eq!(sold.quantity, dec!(25));
}
#[test]
fn a_futures_book_update_parses_both_sides() {
let (mut gate, ws) = futures_ws_client();
ws.push_connection(vec![Ok(Some(
r#"{"time":1788462378,"channel":"futures.order_book_update","event":"update",
"result":{"t":1788462378780,"U":124074489866,"u":124074489905,"s":"BTC_USDT",
"a":[{"p":"81231.4","s":4572},{"p":"81239.3","s":0}],
"b":[{"p":"81230.0","s":12}],"l":"20"}}"#
.to_string(),
))]);
Gate::subscribe_book(&mut gate, &symbol()).unwrap();
let events = Gate::poll_events(&mut gate);
let Event::BookDelta(delta) = &events[0] else {
panic!("expected a delta, got {:?}", events[0]);
};
assert_eq!(delta.asks.len(), 2);
assert_eq!(delta.asks[0].price, dec!(81231.4));
assert_eq!(delta.asks[1].quantity, Decimal::ZERO);
assert_eq!(delta.bids.len(), 1);
assert_eq!(delta.first_update_id, 124_074_489_866);
assert_eq!(delta.final_update_id, 124_074_489_905);
}
#[test]
fn a_futures_ticker_reports_no_top_of_book() {
let (mut gate, ws) = futures_ws_client();
ws.push_connection(vec![Ok(Some(
r#"{"time":1788462378,"channel":"futures.tickers","event":"update",
"result":[{"contract":"BTC_USDT","last":"81231.4","mark_price":"81238.4",
"index_price":"81271.1","funding_rate":"0.000073","volume_24h_base":"81628.265",
"t":1788462378855}]}"#
.to_string(),
))]);
Gate::subscribe_ticker(&mut gate, &symbol()).unwrap();
let events = Gate::poll_events(&mut gate);
let Event::Ticker(ticker) = &events[0] else {
panic!("expected a ticker, got {:?}", events[0]);
};
assert_eq!(ticker.last, dec!(81231.4));
assert_eq!(ticker.volume, dec!(81628.265));
assert_eq!(ticker.bid, Decimal::ZERO);
assert_eq!(ticker.ask, Decimal::ZERO);
assert_eq!(ticker.timestamp, 1_788_462_378_855);
}
#[test]
fn a_spot_client_still_subscribes_to_spot_channels() {
let ws = Arc::new(MockWsTransport::new());
let http = Arc::new(MockHttpTransport::new());
let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
let mut gate = Gate::with_http(Box::new(ArcTransport(http)), &opts)
.with_ws(Box::new(ArcWs(Arc::clone(&ws))));
Gate::subscribe_trades(&mut gate, &symbol()).unwrap();
assert_eq!(ws.connected_urls()[0], "wss://api.gateio.ws/ws/v4/");
assert!(ws.sent()[0].contains(r#""channel":"spot.trades""#));
}
fn signed_client(now_ms: i64) -> (Gate, Arc<MockHttpTransport>) {
let mock = Arc::new(MockHttpTransport::new());
let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
let gate = Gate::with_credentials(
Box::new(ArcTransport(Arc::clone(&mock))),
&opts,
Credentials::new("APIKEY", "SECRET"),
)
.with_clock(Box::new(move || now_ms));
(gate, mock)
}
fn signed_ws_client(now_ms: i64) -> (Gate, Arc<MockWsTransport>) {
let http = Arc::new(MockHttpTransport::new());
let ws = Arc::new(MockWsTransport::new());
let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
let gate = Gate::with_credentials(
Box::new(ArcTransport(http)),
&opts,
Credentials::new("APIKEY", "SECRET"),
)
.with_ws(Box::new(ArcWs(Arc::clone(&ws))))
.with_clock(Box::new(move || now_ms));
(gate, ws)
}
#[test]
fn subscribe_user_data_authenticates_and_streams_orders_and_balances() {
let (mut gate, ws) = signed_ws_client(1_700_000_000_000);
ws.push_connection(vec![
Ok(Some(r#"{"channel":"spot.orders","event":"subscribe"}"#.to_string())),
Ok(Some(r#"{"channel":"spot.balances","event":"subscribe"}"#.to_string())),
Ok(Some(
r#"{"time":1,"channel":"spot.orders","event":"update","result":[{"id":"55",
"text":"my","currency_pair":"BTC_USDT","side":"buy","type":"limit","status":"closed",
"amount":"1","left":"0","price":"100","avg_deal_price":"100"}]}"#
.to_string(),
)),
Ok(Some(
r#"{"time":2,"channel":"spot.balances","event":"update","result":[{"currency":"USDT",
"available":"900","total":"950"}]}"#
.to_string(),
)),
]);
gate.subscribe_user_data().unwrap();
assert_eq!(ws.connected_urls()[0], "wss://api.gateio.ws/ws/v4/");
assert!(ws.sent()[0].contains(r#""channel":"spot.orders""#));
assert!(ws.sent()[0].contains(r#""KEY":"APIKEY""#));
assert!(ws.sent()[0].contains(r#""SIGN""#));
assert!(ws.sent()[1].contains(r#""channel":"spot.balances""#));
let events = gate.poll_events();
assert_eq!(events.len(), 2);
let Event::OrderUpdate(order) = &events[0] else {
panic!("first event must be an order update");
};
assert_eq!(order.id, "55");
assert_eq!(order.symbol, symbol());
assert_eq!(order.side, OrderSide::Buy);
assert_eq!(order.status, OrderStatus::Filled);
assert_eq!(order.filled_quantity, dec!(1));
assert_eq!(order.average_price, Some(dec!(100)));
let Event::BalanceUpdate(balances) = &events[1] else {
panic!("second event must be a balance update");
};
assert_eq!(balances.len(), 1);
assert_eq!(balances[0].asset, "USDT");
assert_eq!(balances[0].free, dec!(900));
assert_eq!(balances[0].locked, dec!(50));
}
#[test]
fn subscribe_user_data_requires_credentials() {
let http = Arc::new(MockHttpTransport::new());
let ws = Arc::new(MockWsTransport::new());
let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
let mut gate = Gate::with_http(Box::new(ArcTransport(http)), &opts)
.with_ws(Box::new(ArcWs(Arc::clone(&ws))));
assert!(matches!(
gate.subscribe_user_data().unwrap_err(),
Error::InvalidCredentials(_)
));
}
#[test]
fn keepalive_user_data_pings_the_private_stream() {
let (mut gate, ws) = signed_ws_client(1_700_000_000_000);
ws.push_connection(vec![]);
gate.subscribe_user_data().unwrap();
gate.keepalive_user_data().unwrap();
assert!(ws.sent().iter().any(|f| f == "ping"));
}
#[test]
fn keepalive_user_data_is_a_noop_before_subscribe() {
let (mut gate, ws) = signed_ws_client(1_700_000_000_000);
gate.keepalive_user_data().unwrap();
assert_eq!(ws.sent(), Vec::<String>::new());
}
#[test]
fn dropped_user_data_stream_reconnects_with_fresh_signed_frames() {
let (mut gate, ws) = signed_ws_client(1_700_000_000_000);
ws.push_connection(vec![Ok(None)]);
ws.push_connection(vec![]);
gate.subscribe_user_data().unwrap();
let events = gate.poll_events();
assert!(events.contains(&Event::Disconnected));
assert!(events.contains(&Event::Reconnected));
let signed_subs = ws
.sent()
.into_iter()
.filter(|f| f.contains(r#""SIGN""#))
.count();
assert_eq!(signed_subs, 4);
assert_eq!(ws.connected_urls().len(), 2);
assert_eq!(ws.connected_urls()[1], "wss://api.gateio.ws/ws/v4/");
}
#[test]
fn the_websocket_frame_carries_the_stp_policy() {
let (mut gate, ws) = signed_ws_client(1_700_000_000_000);
ws.push_connection(vec![
Ok(Some(
r#"{"request_id":"login","header":{"status":"200","channel":"spot.login",
"event":"api"},"data":{"result":{"api_key":"APIKEY"}}}"#
.to_string(),
)),
Ok(Some(
r#"{"request_id":"b","header":{"status":"200","channel":"spot.order_place",
"event":"api"},"data":{"result":{"id":"55","text":"","currency_pair":"BTC_USDT",
"side":"buy","type":"limit","status":"open","amount":"1","left":"1","price":"100",
"avg_deal_price":"0"}}}"#
.to_string(),
)),
]);
gate.place_order_ws(
&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100))
.with_stp(SelfTradePrevention::ExpireTaker),
)
.unwrap();
assert!(ws.sent()[1].contains(r#""stp_act":"cn""#));
}
#[test]
fn place_and_cancel_order_over_ws() {
let (mut gate, ws) = signed_ws_client(1_700_000_000_000);
ws.push_connection(vec![
Ok(Some(
r#"{"request_id":"login","header":{"status":"200","channel":"spot.login",
"event":"api"},"data":{"result":{"api_key":"APIKEY"}}}"#
.to_string(),
)),
Ok(Some(
r#"{"request_id":"o","header":{"status":"200","channel":"spot.order_place",
"event":"api"},"data":{"result":{"id":"55","text":"t-my","currency_pair":"BTC_USDT",
"side":"buy","type":"limit","status":"closed","amount":"1","left":"0","price":"100",
"avg_deal_price":"100"}}}"#
.to_string(),
)),
Ok(Some(
r#"{"request_id":"c","header":{"status":"200","channel":"spot.order_cancel",
"event":"api"},"data":{"result":{"id":"55"}}}"#
.to_string(),
)),
]);
let order = gate
.place_order_ws(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
.unwrap();
assert_eq!(order.id, "55");
assert_eq!(order.symbol, symbol());
assert_eq!(order.status, OrderStatus::Filled);
assert_eq!(order.filled_quantity, dec!(1));
assert_eq!(ws.connected_urls()[0], "wss://api.gateio.ws/ws/v4/");
assert!(ws.sent()[0].contains(r#""channel":"spot.login""#));
assert!(ws.sent()[0].contains(r#""signature""#));
assert!(ws.sent()[1].contains(r#""channel":"spot.order_place""#));
assert!(ws.sent()[1].contains(r#""currency_pair":"BTC_USDT""#));
gate.cancel_order_ws(&symbol(), "55").unwrap();
assert!(ws.sent()[2].contains(r#""channel":"spot.order_cancel""#));
assert!(ws.sent()[2].contains(r#""order_id":"55""#));
}
#[test]
fn ws_order_surfaces_rejection() {
let (mut gate, ws) = signed_ws_client(1000);
ws.push_connection(vec![
Ok(Some(
r#"{"header":{"status":"200","channel":"spot.login","event":"api"}}"#.to_string(),
)),
Ok(Some(
r#"{"header":{"status":"400","channel":"spot.order_place","event":"api"},
"data":{"errs":{"label":"BALANCE_NOT_ENOUGH","message":"not enough balance"}}}"#
.to_string(),
)),
]);
assert!(matches!(
gate.place_order_ws(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
.unwrap_err(),
Error::OrderRejected { .. }
));
}
#[test]
fn ws_order_requires_credentials() {
let http = Arc::new(MockHttpTransport::new());
let ws = Arc::new(MockWsTransport::new());
let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
let mut gate = Gate::with_http(Box::new(ArcTransport(http)), &opts)
.with_ws(Box::new(ArcWs(Arc::clone(&ws))));
assert!(matches!(
gate.place_order_ws(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
.unwrap_err(),
Error::InvalidCredentials(_)
));
}
fn signed_futures_client(now_ms: i64) -> (Gate, Arc<MockHttpTransport>) {
let mock = Arc::new(MockHttpTransport::new());
let opts = ExchangeOptions::mainnet(crate::MarketType::UsdMFutures);
let gate = Gate::with_credentials(
Box::new(ArcTransport(Arc::clone(&mock))),
&opts,
Credentials::new("APIKEY", "SECRET"),
)
.with_clock(Box::new(move || now_ms));
(gate, mock)
}
#[test]
fn a_spot_trigger_goes_to_the_price_order_endpoint_with_its_rule() {
for (side, rule) in [(OrderSide::Sell, "<="), (OrderSide::Buy, ">=")] {
let (gate, mock) = signed_client(1_000_000);
mock.push_json(200, r#"{"id":77}"#);
let request = OrderRequest {
order_type: OrderType::StopLimit,
stop_price: Some(dec!(19000)),
side,
..OrderRequest::limit_sell(symbol(), dec!(1), dec!(18900))
};
let order = gate.place_order(&request).unwrap();
assert_eq!(order.id, "77");
let sent = &mock.recorded_requests()[0];
assert!(
sent.url.contains("/api/v4/spot/price_orders"),
"{}",
sent.url
);
let body = sent.body.clone().unwrap();
assert!(body.contains(r#""price":"19000""#), "{body}");
assert!(
body.contains(&format!(r#""rule":"{rule}""#)),
"a {side:?} stop fires the other way: {body}"
);
assert!(body.contains(r#""price":"18900""#), "{body}");
}
}
#[test]
fn a_spot_stop_market_is_refused_rather_than_given_an_invented_price() {
let (gate, mock) = signed_client(1_000_000);
let request = OrderRequest {
order_type: OrderType::StopMarket,
stop_price: Some(dec!(19000)),
..OrderRequest::market_sell(symbol(), dec!(1))
};
let err = gate.place_order(&request).unwrap_err();
assert!(matches!(err, Error::Exchange { ref code, .. } if code == "unsupported"));
assert_eq!(mock.recorded_requests(), Vec::new());
}
#[test]
fn a_futures_stop_market_takes_the_market_and_signs_its_size() {
let (gate, mock) = signed_futures_client(1_000_000);
mock.push_json(200, r#"{"id":78}"#);
let request = OrderRequest {
order_type: OrderType::StopMarket,
stop_price: Some(dec!(19000)),
..OrderRequest::market_sell(symbol(), dec!(1))
};
gate.place_order(&request).unwrap();
let sent = &mock.recorded_requests()[0];
assert!(
sent.url.contains("/api/v4/futures/usdt/price_orders"),
"{}",
sent.url
);
let body = sent.body.clone().unwrap();
assert!(body.contains(r#""price":"0""#), "{body}");
assert!(
body.contains(r#""size":-1"#),
"a sell is a negative size: {body}"
);
assert!(body.contains(r#""rule":2"#), "{body}");
}
#[test]
fn a_gate_trigger_without_a_stop_price_is_refused() {
let (gate, mock) = signed_futures_client(1_000_000);
let request = OrderRequest {
order_type: OrderType::StopMarket,
stop_price: None,
..OrderRequest::market_sell(symbol(), dec!(1))
};
assert!(gate.place_order(&request).is_err());
assert_eq!(mock.recorded_requests(), Vec::new());
}
fn signed_futures_ws_client(
now_ms: i64,
) -> (Gate, Arc<MockHttpTransport>, Arc<MockWsTransport>) {
let http = Arc::new(MockHttpTransport::new());
let ws = Arc::new(MockWsTransport::new());
let opts = ExchangeOptions::mainnet(crate::MarketType::UsdMFutures);
let gate = Gate::with_credentials(
Box::new(ArcTransport(Arc::clone(&http))),
&opts,
Credentials::new("APIKEY", "SECRET"),
)
.with_ws(Box::new(ArcWs(Arc::clone(&ws))))
.with_clock(Box::new(move || now_ms));
(gate, http, ws)
}
#[test]
fn a_futures_client_subscribes_to_the_futures_channels_for_its_own_user() {
let (mut gate, http, ws) = signed_futures_ws_client(1_700_000_000_000);
http.push_json(
200,
r#"{"user":4242,"currency":"USDT","total":"1","available":"1"}"#,
);
gate.subscribe_user_data().unwrap();
let looked_up = &http.recorded_requests()[0];
assert!(
looked_up.url.contains("/api/v4/futures/usdt/accounts"),
"{}",
looked_up.url
);
assert_eq!(ws.connected_urls()[0], "wss://fx-ws.gateio.ws/v4/ws/usdt");
let sent = ws.sent();
assert!(
sent[0].contains(r#""channel":"futures.orders""#),
"{}",
sent[0]
);
assert!(
sent[0].contains(r#""payload":["4242","!all"]"#),
"{}",
sent[0]
);
assert!(
sent[1].contains(r#""channel":"futures.balances""#),
"{}",
sent[1]
);
assert!(
!sent.iter().any(|frame| frame.contains("spot.")),
"a futures client subscribed to a spot channel: {sent:?}"
);
}
#[test]
fn the_futures_user_id_is_looked_up_once() {
let (mut gate, http, ws) = signed_futures_ws_client(1_700_000_000_000);
http.push_json(
200,
r#"{"user":4242,"currency":"USDT","total":"1","available":"1"}"#,
);
gate.subscribe_user_data().unwrap();
gate.subscribe_user_data().unwrap();
assert_eq!(http.recorded_requests().len(), 1);
assert_eq!(ws.sent().len(), 4);
}
#[test]
fn a_futures_order_frame_becomes_an_order_update() {
let (mut gate, http, ws) = signed_futures_ws_client(1_700_000_000_000);
http.push_json(
200,
r#"{"user":1,"currency":"USDT","total":"1","available":"1"}"#,
);
ws.push_connection(vec![Ok(Some(
r#"{"time":1700000000,"channel":"futures.orders","event":"update","result":[
{"id":88,"contract":"BTC_USDT","size":-4,"left":1,"price":"19000",
"status":"open","text":"t-mine","fill_price":"19010"}]}"#
.to_string(),
))]);
gate.subscribe_user_data().unwrap();
let events = gate.poll_events();
let Some(Event::OrderUpdate(order)) = events.first() else {
panic!("expected an order update, got {events:?}");
};
assert_eq!(order.id, "88");
assert_eq!(order.symbol, Symbol::new("BTC", "USDT"));
assert_eq!(order.side, OrderSide::Sell);
assert_eq!(order.quantity, dec!(4));
assert_eq!(order.filled_quantity, dec!(3));
assert_eq!(order.status, OrderStatus::PartiallyFilled);
assert_eq!(order.client_order_id.as_deref(), Some("t-mine"));
}
#[test]
fn a_finished_futures_order_is_a_fill_or_a_cancel_by_finish_as() {
assert_eq!(
futures_order_status("finished", "filled", dec!(4)),
OrderStatus::Filled
);
assert_eq!(
futures_order_status("finished", "cancelled", dec!(0)),
OrderStatus::Canceled
);
assert_eq!(
futures_order_status("finished", "reduce_only", dec!(0)),
OrderStatus::Canceled
);
assert_eq!(futures_order_status("open", "", dec!(0)), OrderStatus::New);
}
#[test]
fn a_futures_balance_frame_becomes_a_balance_update() {
let (mut gate, http, ws) = signed_futures_ws_client(1_700_000_000_000);
http.push_json(
200,
r#"{"user":1,"currency":"USDT","total":"1","available":"1"}"#,
);
ws.push_connection(vec![Ok(Some(
r#"{"time":1700000000,"channel":"futures.balances","event":"update","result":[
{"balance":9999.5,"change":"-0.5","currency":"USDT","type":"fee"}]}"#
.to_string(),
))]);
gate.subscribe_user_data().unwrap();
let events = gate.poll_events();
let Some(Event::BalanceUpdate(balances)) = events.first() else {
panic!("expected a balance update, got {events:?}");
};
assert_eq!(balances[0].asset, "USDT");
assert_eq!(balances[0].free, dec!(9999.5));
}
const GATE_ORDER: &str = r#"{"id":"1","text":"","side":"buy","type":"limit","status":"open",
"amount":"1","filled_amount":"0","price":"100","avg_deal_price":"0"}"#;
#[test]
fn stp_maps_to_stp_act() {
let (gate, mock) = signed_client(1_000_000);
mock.push_json(200, GATE_ORDER);
gate.place_order(
&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100))
.with_stp(SelfTradePrevention::ExpireBoth),
)
.unwrap();
let reqs = mock.recorded_requests();
assert!(reqs[0].body.as_ref().unwrap().contains(r#""stp_act":"cb""#));
}
#[test]
fn amend_order_patches_in_place() {
let (gate, mock) = signed_client(1_000_000);
mock.push_json(
200,
r#"{"id":"1","text":"","side":"buy","type":"limit","status":"open",
"amount":"2","filled_amount":"0","price":"101","avg_deal_price":"0"}"#,
);
let order = gate
.amend_order(&symbol(), "1", Some(dec!(101)), Some(dec!(2)))
.unwrap();
assert_eq!(order.quantity, dec!(2));
assert_eq!(order.price, Some(dec!(101)));
let reqs = mock.recorded_requests();
assert_eq!(reqs[0].method, HttpMethod::Patch);
assert!(reqs[0]
.url
.contains("/api/v4/spot/orders/1?currency_pair=BTC_USDT"));
let body = reqs[0].body.as_ref().unwrap();
assert!(body.contains(r#""amount":"2""#));
assert!(body.contains(r#""price":"101""#));
}
#[test]
fn futures_orders_carry_the_time_in_force_or_refuse_it() {
let (gate, mock) = signed_futures_client(1000);
mock.push_json(200, "{}");
let ioc = OrderRequest::limit_buy(symbol(), dec!(1), dec!(100))
.with_time_in_force(TimeInForce::Ioc);
let _ = gate.place_order(&ioc);
let body = mock.recorded_requests()[0].body.clone().unwrap_or_default();
assert!(body.contains(r#""tif":"ioc""#));
let (gate, mock) = signed_futures_client(1000);
mock.push_json(200, "{}");
let poc = OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)).post_only();
let _ = gate.place_order(&poc);
let body = mock.recorded_requests()[0].body.clone().unwrap_or_default();
assert!(body.contains(r#""tif":"poc""#));
let (gate, mock) = signed_futures_client(1000);
let fok = OrderRequest::market_buy(symbol(), dec!(1)).with_time_in_force(TimeInForce::Fok);
let err = gate.place_order(&fok).unwrap_err();
assert!(matches!(err, Error::Exchange { ref code, .. } if code == "unsupported"));
assert_eq!(mock.recorded_requests(), Vec::new());
}
#[test]
fn place_batch_reads_succeeded_flag() {
let (gate, mock) = signed_client(1_000_000);
mock.push_json(
200,
r#"[{"succeeded":true,"id":"o1","text":"","side":"buy","type":"limit","status":"open",
"amount":"1","filled_amount":"0","price":"100","avg_deal_price":"0"},
{"succeeded":false,"label":"BALANCE_NOT_ENOUGH","message":"no funds"}]"#,
);
let results = gate
.place_batch(&[
OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)),
OrderRequest::limit_buy(symbol(), dec!(1), dec!(101)),
])
.unwrap();
assert_eq!(results[0].as_ref().unwrap().id, "o1");
assert!(matches!(
results[1].as_ref().unwrap_err(),
Error::OrderRejected { .. }
));
assert!(mock.recorded_requests()[0]
.url
.contains("/api/v4/spot/batch_orders"));
}
#[test]
fn cancel_batch_is_one_call() {
let (gate, mock) = signed_client(1_000_000);
mock.push_json(200, "[]");
gate.cancel_batch(&symbol(), &["1".to_string(), "2".to_string()])
.unwrap();
let reqs = mock.recorded_requests();
assert_eq!(reqs.len(), 1);
assert!(reqs[0].url.contains("/api/v4/spot/cancel_batch_orders"));
}
#[test]
fn place_oco_is_unsupported() {
let (mut gate, _mock) = signed_client(1_000_000);
assert!(matches!(
AdvancedOrders::place_oco(
&mut gate,
&OcoRequest::new(symbol(), OrderSide::Sell, dec!(1), dec!(110), dec!(95))
)
.unwrap_err(),
Error::Exchange { .. }
));
}
fn futures_client() -> (Gate, Arc<MockHttpTransport>) {
let mock = Arc::new(MockHttpTransport::new());
let opts = ExchangeOptions::mainnet(crate::MarketType::UsdMFutures);
(
Gate::with_http(Box::new(ArcTransport(Arc::clone(&mock))), &opts),
mock,
)
}
#[test]
fn futures_ticker_takes_bid_ask_from_the_book() {
let (gate, mock) = futures_client();
mock.push_json(
200,
r#"[{"contract":"BTC_USDT","last":"20000","volume_24h_base":"1234"}]"#,
);
mock.push_json(
200,
r#"{"id":66,"bids":[{"p":"19999","s":10}],"asks":[{"p":"20001","s":8}]}"#,
);
let ticker = gate.ticker(&symbol()).unwrap();
assert_eq!(ticker.last, dec!(20000));
assert_eq!(ticker.bid, dec!(19999));
assert_eq!(ticker.ask, dec!(20001));
assert_eq!(ticker.volume, dec!(1234));
assert!(mock.recorded_requests()[0]
.url
.contains("/api/v4/futures/usdt/tickers?contract=BTC_USDT"));
}
#[test]
fn futures_klines_parse_object_rows() {
let (gate, mock) = futures_client();
mock.push_json(
200,
r#"[{"t":1700000000,"v":12,"o":"100","h":"110","l":"95","c":"105"}]"#,
);
let candles = gate.klines(&symbol(), "1h", 1).unwrap();
assert!((candles[0].open - 100.0).abs() < 1e-9);
assert!((candles[0].high - 110.0).abs() < 1e-9);
assert!((candles[0].close - 105.0).abs() < 1e-9);
assert_eq!(candles[0].timestamp, 1_700_000_000);
assert!(mock.recorded_requests()[0]
.url
.contains("/api/v4/futures/usdt/candlesticks"));
}
#[test]
fn futures_order_book_parses_object_levels() {
let (gate, mock) = futures_client();
mock.push_json(
200,
r#"{"id":77,"bids":[{"p":"100","s":1}],"asks":[{"p":"101","s":2}]}"#,
);
let book = gate.order_book(&symbol(), 5).unwrap();
assert_eq!(book.last_update_id, 77);
assert_eq!(book.bids[0], BookLevel::new(dec!(100), dec!(1)));
assert_eq!(book.asks[0], BookLevel::new(dec!(101), dec!(2)));
}
fn hedged_futures_client(now_ms: i64) -> (Gate, Arc<MockHttpTransport>) {
let mock = Arc::new(MockHttpTransport::new());
let mut opts = ExchangeOptions::mainnet(crate::MarketType::UsdMFutures);
opts.position_mode = crate::PositionMode::Hedge;
let gate = Gate::with_credentials(
Box::new(ArcTransport(Arc::clone(&mock))),
&opts,
Credentials::new("APIKEY", "SECRET"),
)
.with_clock(Box::new(move || now_ms));
(gate, mock)
}
const GATE_FUTURES_FILL: &str = r#"{"id":88,"size":2,"left":0,"price":"0",
"fill_price":"20000","status":"finished","finish_as":"filled","text":""}"#;
#[test]
fn dual_mode_closes_with_auto_size_and_opens_by_sign() {
let (hedged, mock) = hedged_futures_client(1_000_000);
for _ in 0..2 {
mock.push_json(200, GATE_FUTURES_FILL);
}
hedged
.place_order(&OrderRequest::market_sell(symbol(), dec!(2)).reduce_only())
.unwrap();
hedged
.place_order(&OrderRequest::market_buy(symbol(), dec!(2)))
.unwrap();
let reqs = mock.recorded_requests();
let close = reqs[0].body.as_deref().unwrap();
assert!(close.contains(r#""auto_size":"close_long""#));
assert!(close.contains(r#""size":0"#));
let open = reqs[1].body.as_deref().unwrap();
assert!(open.contains(r#""size":2"#));
assert!(!open.contains("auto_size"));
mock.push_json(200, GATE_FUTURES_FILL);
hedged
.place_order(&OrderRequest::market_buy(symbol(), dec!(2)).reduce_only())
.unwrap();
assert!(mock.recorded_requests()[2]
.body
.as_deref()
.unwrap()
.contains(r#""auto_size":"close_short""#));
let (one_way, one_way_mock) = signed_futures_client(1_000_000);
one_way_mock.push_json(200, GATE_FUTURES_FILL);
one_way
.place_order(&OrderRequest::market_sell(symbol(), dec!(2)).reduce_only())
.unwrap();
let body = one_way_mock.recorded_requests()[0].body.clone().unwrap();
assert!(body.contains(r#""reduce_only":true"#));
assert!(body.contains(r#""size":-2"#));
assert!(!body.contains("auto_size"));
}
#[test]
fn futures_market_order_uses_signed_size_and_ioc() {
let (gate, mock) = signed_futures_client(1_000_000);
mock.push_json(
200,
r#"{"id":88,"size":2,"left":0,"price":"0","fill_price":"20000",
"status":"finished","finish_as":"filled","text":""}"#,
);
let order = gate
.place_order(&OrderRequest::market_buy(symbol(), dec!(2)))
.unwrap();
assert_eq!(order.id, "88");
assert_eq!(order.status, OrderStatus::Filled);
assert_eq!(order.side, OrderSide::Buy);
assert_eq!(order.average_price, Some(dec!(20000)));
let req = &mock.recorded_requests()[0];
assert!(req.url.contains("/api/v4/futures/usdt/orders"));
let body = req.body.as_ref().unwrap();
assert!(body.contains(r#""contract":"BTC_USDT""#));
assert!(body.contains(r#""size":2"#));
assert!(body.contains(r#""price":"0""#));
assert!(body.contains(r#""tif":"ioc""#));
assert!(body.contains(r#""reduce_only":false"#));
}
#[test]
fn futures_limit_sell_signs_size_negative() {
let (gate, mock) = signed_futures_client(1_000_000);
mock.push_json(
200,
r#"{"id":90,"size":-3,"left":3,"price":"21000","fill_price":"0",
"status":"open","finish_as":"","text":""}"#,
);
let order = gate
.place_order(&OrderRequest::limit_sell(symbol(), dec!(3), dec!(21000)))
.unwrap();
assert_eq!(order.side, OrderSide::Sell);
assert_eq!(order.status, OrderStatus::New);
assert_eq!(order.order_type, OrderType::Limit);
let reqs = mock.recorded_requests();
let body = reqs[0].body.as_ref().unwrap();
assert!(body.contains(r#""size":-3"#));
assert!(body.contains(r#""price":"21000""#));
}
#[test]
fn derivatives_positions_parse_cross_and_isolated() {
let (mut gate, mock) = signed_futures_client(1_000_000);
mock.push_json(
200,
r#"[{"contract":"BTC_USDT","size":3,"leverage":"10","entry_price":"20000",
"mark_price":"20100","unrealised_pnl":"30"},
{"contract":"ETH_USDT","size":-2,"leverage":"0","cross_leverage_limit":"5",
"entry_price":"3000","mark_price":"2900","unrealised_pnl":"200"}]"#,
);
let positions = Derivatives::positions(&mut gate, None).unwrap();
assert_eq!(positions.len(), 2);
assert_eq!(positions[0].symbol, Symbol::new("BTC", "USDT"));
assert_eq!(positions[0].side, PositionSide::Long);
assert_eq!(positions[0].quantity, dec!(3));
assert_eq!(positions[0].leverage, dec!(10));
assert_eq!(positions[0].margin_mode, MarginMode::Isolated);
assert_eq!(positions[1].side, PositionSide::Short);
assert_eq!(positions[1].leverage, dec!(5));
assert_eq!(positions[1].margin_mode, MarginMode::Cross);
assert!(mock.recorded_requests()[0]
.url
.contains("/api/v4/futures/usdt/positions"));
}
#[test]
fn derivatives_set_leverage_and_cross_switch() {
let (mut gate, mock) = signed_futures_client(1_000_000);
mock.push_json(200, "{}");
Derivatives::set_leverage(&mut gate, &symbol(), 5).unwrap();
assert!(mock.recorded_requests()[0]
.url
.contains("/api/v4/futures/usdt/positions/BTC_USDT/leverage?leverage=5"));
mock.push_json(200, "{}");
Derivatives::set_margin_mode(&mut gate, &symbol(), MarginMode::Cross).unwrap();
assert!(mock.recorded_requests()[1]
.url
.contains("leverage?leverage=0"));
}
#[test]
fn derivatives_close_position_is_reduce_only_opposite() {
let (mut gate, mock) = signed_futures_client(1_000_000);
mock.push_json(
200,
r#"[{"contract":"BTC_USDT","size":3,"leverage":"10","entry_price":"20000",
"mark_price":"20100","unrealised_pnl":"30"}]"#,
);
mock.push_json(
200,
r#"{"id":99,"size":-3,"left":0,"price":"0","fill_price":"20100",
"status":"finished","finish_as":"filled","text":""}"#,
);
let order = Derivatives::close_position(&mut gate, &symbol()).unwrap();
assert_eq!(order.status, OrderStatus::Filled);
let reqs = mock.recorded_requests();
let body = reqs[1].body.as_ref().unwrap();
assert!(body.contains(r#""size":-3"#));
assert!(body.contains(r#""reduce_only":true"#));
}
#[test]
fn futures_balances_split_total_and_available() {
let (gate, mock) = signed_futures_client(1_000_000);
mock.push_json(
200,
r#"{"currency":"USDT","total":"1000","available":"800"}"#,
);
let bals = gate.balances().unwrap();
assert_eq!(bals[0].asset, "USDT");
assert_eq!(bals[0].free, dec!(800));
assert_eq!(bals[0].locked, dec!(200));
assert!(mock.recorded_requests()[0]
.url
.contains("/api/v4/futures/usdt/accounts"));
}
#[test]
fn futures_query_order_uses_futures_endpoint_and_shape() {
let (gate, mock) = signed_futures_client(1_000_000);
mock.push_json(
200,
r#"{"id":88,"size":2,"left":0,"price":"100","fill_price":"100",
"status":"finished","finish_as":"filled","text":""}"#,
);
let order = gate.query_order(&symbol(), "88").unwrap();
assert_eq!(order.id, "88");
assert_eq!(order.side, OrderSide::Buy);
assert_eq!(order.status, OrderStatus::Filled);
assert!(mock.recorded_requests()[0]
.url
.contains("/api/v4/futures/usdt/orders/88"));
}
#[test]
fn futures_cancel_order_targets_futures_endpoint() {
let (gate, mock) = signed_futures_client(1_000_000);
mock.push_json(
200,
r#"{"id":88,"size":0,"left":0,"status":"finished","finish_as":"cancelled"}"#,
);
gate.cancel_order(&symbol(), "88").unwrap();
let req = &mock.recorded_requests()[0];
assert_eq!(req.method, HttpMethod::Delete);
assert!(req.url.contains("/api/v4/futures/usdt/orders/88"));
}
#[test]
fn futures_open_orders_parse_contract_shape() {
let (gate, mock) = signed_futures_client(1_000_000);
mock.push_json(
200,
r#"[{"id":90,"size":-3,"left":3,"price":"21000","fill_price":"0",
"status":"open","finish_as":"","text":""}]"#,
);
let orders = gate.open_orders(Some(&symbol())).unwrap();
assert_eq!(orders.len(), 1);
assert_eq!(orders[0].side, OrderSide::Sell);
assert_eq!(orders[0].status, OrderStatus::New);
assert!(mock.recorded_requests()[0]
.url
.contains("/api/v4/futures/usdt/orders?contract=BTC_USDT&status=open"));
}
#[test]
fn ticker_parses() {
let (gate, mock) = client();
mock.push_json(
200,
r#"[{"currency_pair":"BTC_USDT","last":"20000","highest_bid":"19999",
"lowest_ask":"20001","base_volume":"1234"}]"#,
);
let ticker = gate.ticker(&symbol()).unwrap();
assert_eq!(ticker.last, dec!(20000));
assert_eq!(ticker.bid, dec!(19999));
let req = &mock.recorded_requests()[0];
assert_eq!(
req.url,
"https://api.gateio.ws/api/v4/spot/tickers?currency_pair=BTC_USDT"
);
}
#[test]
fn klines_field_order() {
let (gate, mock) = client();
mock.push_json(
200,
r#"[["1700000000","0","105","110","95","100","12","true"]]"#,
);
let candles = gate.klines(&symbol(), "1h", 1).unwrap();
assert!((candles[0].open - 100.0).abs() < 1e-9);
assert!((candles[0].high - 110.0).abs() < 1e-9);
assert!((candles[0].close - 105.0).abs() < 1e-9);
assert_eq!(candles[0].timestamp, 1_700_000_000);
}
#[test]
fn order_book_parses() {
let (gate, mock) = client();
mock.push_json(
200,
r#"{"update":66,"bids":[["100","1"]],"asks":[["101","2"]]}"#,
);
let book = gate.order_book(&symbol(), 5).unwrap();
assert_eq!(book.last_update_id, 66);
assert_eq!(book.bids[0], BookLevel::new(dec!(100), dec!(1)));
}
#[test]
fn http_error_maps_to_taxonomy() {
let (gate, mock) = client();
mock.push_json(400, r#"{"label":"BALANCE_NOT_ENOUGH","message":"nope"}"#);
assert!(matches!(
gate.ticker(&symbol()).unwrap_err(),
Error::InsufficientBalance
));
}
#[test]
fn place_order_signs_with_sha512_body_hash() {
let (gate, mock) = signed_client(1_000_000); mock.push_json(
200,
r#"{"id":"77","text":"t-abc","side":"buy","type":"limit","status":"open",
"amount":"1","filled_amount":"0","price":"100","avg_deal_price":"0"}"#,
);
let order = gate
.place_order(
&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)).with_client_order_id("abc"),
)
.unwrap();
assert_eq!(order.id, "77");
assert_eq!(order.status, OrderStatus::New);
let req = &mock.recorded_requests()[0];
let header = |name: &str| {
req.headers
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.as_str())
.unwrap()
};
assert_eq!(header("Timestamp"), "1000");
assert_eq!(header("KEY"), "APIKEY");
let body = req.body.as_ref().unwrap();
let body_hash = sha512_hex(body.as_bytes());
let sign_string = format!("POST\n/api/v4/spot/orders\n\n{body_hash}\n1000");
assert_eq!(
header("SIGN"),
hmac_sha512_hex(b"SECRET", sign_string.as_bytes())
);
assert!(body.contains(r#""text":"t-abc""#));
}
#[test]
fn query_and_balances() {
let (gate, mock) = signed_client(1_000_000);
mock.push_json(
200,
r#"{"id":"77","text":"","side":"sell","type":"market","status":"closed",
"amount":"2","filled_amount":"2","price":"0","avg_deal_price":"100"}"#,
);
let order = gate.query_order(&symbol(), "77").unwrap();
assert_eq!(order.status, OrderStatus::Filled);
assert_eq!(order.average_price, Some(dec!(100)));
mock.push_json(
200,
r#"[{"currency":"USDT","available":"100.5","locked":"25.5"}]"#,
);
let bals = gate.balances().unwrap();
assert_eq!(bals[0].total(), dec!(126));
}
#[test]
fn open_orders_requires_symbol() {
let (gate, _mock) = signed_client(1_000_000);
assert!(matches!(
gate.open_orders(None).unwrap_err(),
Error::InvalidOrder(_)
));
}
#[test]
fn signed_requires_credentials() {
let (gate, _) = client();
assert!(matches!(
gate.balances().unwrap_err(),
Error::InvalidCredentials(_)
));
}
#[test]
fn ws_parses_trade_and_book() {
let ws = Arc::new(MockWsTransport::new());
ws.push_connection(vec![
Ok(Some(
r#"{"time":1,"channel":"spot.trades","event":"update","result":
{"currency_pair":"BTC_USDT","side":"buy","amount":"0.5","price":"100","create_time_ms":1700}}"#
.to_string(),
)),
Ok(Some(
r#"{"time":2,"channel":"spot.order_book_update","event":"update","result":
{"s":"BTC_USDT","u":9,"b":[["100","1"]],"a":[["101","2"]]}}"#
.to_string(),
)),
Ok(Some(r#"{"channel":"spot.trades","event":"subscribe"}"#.to_string())),
]);
let http = Arc::new(MockHttpTransport::new());
let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
let mut gate = Gate::with_http(Box::new(ArcTransport(http)), &opts)
.with_ws(Box::new(ArcWs(Arc::clone(&ws))));
gate.subscribe_trades(&symbol()).unwrap();
assert!(ws.sent()[0].contains(r#""channel":"spot.trades""#));
let events = gate.poll_events();
assert_eq!(events.len(), 2);
let Event::Trade(t) = &events[0] else {
panic!("expected trade")
};
assert_eq!(t.aggressor, OrderSide::Buy);
assert_eq!(t.timestamp, 1700);
let Event::BookDelta(d) = &events[1] else {
panic!("expected book delta")
};
assert_eq!(d.final_update_id, 9);
}
#[test]
fn works_as_a_boxed_exchange() {
let (gate, mock) = signed_client(1_000_000);
mock.push_json(
200,
r#"{"id":"1","text":"","side":"buy","type":"limit","status":"open",
"amount":"1","filled_amount":"0","price":"100","avg_deal_price":"0"}"#,
);
let mut exchange: Box<dyn Exchange> = Box::new(gate);
assert_eq!(exchange.name(), "gate");
let order = exchange
.place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
.unwrap();
assert_eq!(order.id, "1");
}
#[test]
fn system_clock_is_sane() {
assert!(system_now_ms() > 1_600_000_000_000);
}
#[test]
fn debug_reports_state_without_credentials() {
let (client, _http) = signed_client(1_700_000_000_000);
let rendered = format!("{client:?}");
assert!(rendered.starts_with("Gate {"));
assert!(rendered.contains("authenticated: true"));
assert!(!rendered.contains("SECRET"));
assert!(!rendered.contains("APIKEY"));
}
#[test]
fn sync_time_shifts_signed_timestamps() {
let (mut gate, http) = signed_client(1_000_000);
http.push_json(200, r#"{"server_time":1004500}"#);
let offset = gate.sync_time().expect("sync must succeed");
assert_eq!(offset, 4_500, "the venue is 4.5 s ahead of this machine");
http.push_json(200, r"[]");
gate.balances().expect("balances must succeed");
let requests = http.recorded_requests();
let signed = requests.last().expect("a signed request was recorded");
let header = signed
.headers
.iter()
.find(|(name, _)| name == "Timestamp")
.map(|(_, value)| value.as_str())
.expect("signed request carries Timestamp");
assert_eq!(header, "1004", "seconds of the venue's time, not ours");
}
#[test]
fn rate_limit_carries_the_venues_advised_wait() {
let (gate, mock) = client();
mock.push_response(
crate::transport::HttpResponse::new(
429,
r#"{"label":"TOO_MANY_REQUESTS","message":"slow down"}"#,
)
.with_header("Retry-After", "2.5"),
);
let err = gate.ticker(&symbol()).unwrap_err();
let wait = std::time::Duration::from_millis(2500);
assert!(matches!(err, Error::RateLimited { retry_after: Some(d) } if d == wait));
}
}