use anyhow::Result;
use chrono::Utc;
use hmac::{Hmac, Mac};
use reqwest::{Client, Method};
use serde_json::{json, Value};
use sha2::{Digest, Sha512};
use hex;
use crate::config::ApiKeys;
type HmacSha512 = Hmac<Sha512>;
const BASE_HOST: &str = "https://api.gateio.ws";
const EMPTY_BODY_HASH: &str = "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e";
#[derive(Clone)]
pub struct GateClient {
http: Client,
pub api_key: String,
pub api_secret: String,
pub settle: String,
}
impl GateClient {
pub fn new() -> Self {
let api = ApiKeys::load();
Self {
http: Client::new(),
api_key: api.gate_api_key.trim().to_owned(),
api_secret: api.gate_secret_key.trim().to_owned(),
settle: "usdt".to_string(),
}
}
fn sign(&self, method: &str, url_path: &str, query: &str, body: &str, ts: &str) -> String {
let body_hash = if body.is_empty() {
EMPTY_BODY_HASH.to_string()
} else {
hex::encode(Sha512::digest(body.as_bytes()))
};
let payload = format!(
"{}\n{}\n{}\n{}\n{}",
method.to_uppercase(),
url_path,
query,
body_hash,
ts
);
let mut mac = HmacSha512::new_from_slice(self.api_secret.as_bytes())
.expect("HMAC init failed");
mac.update(payload.as_bytes());
hex::encode(mac.finalize().into_bytes())
}
async fn request(
&self,
method: Method,
path: &str,
query: Option<&Value>,
body: Option<&Value>,
) -> Result<Value, String> {
let ts = Utc::now().timestamp();
let timestamp = ts.to_string();
let query_str = query
.map(|q| serde_qs::to_string(q).unwrap_or_default())
.unwrap_or_default();
let body_str = body.map(|b| b.to_string()).unwrap_or_default();
let url_path = format!("/api/v4{}", path);
let signature = self.sign(method.as_str(), &url_path, &query_str, &body_str, ×tamp);
let url = if query_str.is_empty() {
format!("{BASE_HOST}{url_path}")
} else {
format!("{BASE_HOST}{url_path}?{query_str}")
};
let mut req = self.http
.request(method.clone(), &url)
.header("KEY", &self.api_key)
.header("Timestamp", timestamp)
.header("SIGN", signature)
.header("Content-Type", "application/json");
if let Some(b) = body {
req = req.json(b);
}
let resp = req.send().await.map_err(|e| e.to_string())?;
let status = resp.status();
let text = resp.text().await.map_err(|e| e.to_string())?;
if !status.is_success() {
return Err(format!("Gate.io {} {}: {}", status, url_path, text));
}
serde_json::from_str(&text).map_err(|e| format!("JSON parse error: {e}"))
}
pub async fn get_futures_account(&self) -> Result<Value, String> {
self.request(Method::GET, "/futures/usdt/accounts", None, None).await
}
pub async fn get_real_balance_usdt(&self) -> Result<f64, String> {
let acc = self.get_futures_account().await?;
acc["cross_margin_balance"]
.as_str()
.ok_or("no cross_margin_balance")?
.parse()
.map_err(|_| "parse error".to_string())
}
pub async fn list_open_orders(&self) -> Result<Value, String> {
let query = json!({"settle": "usdt", "status": "open"});
self.request(Method::GET, "/futures/usdt/orders", Some(&query), None).await
}
pub async fn create_limit_order(
&self,
contract: &str,
is_long: bool,
size: f64,
price: f64,
reduce_only: bool,
) -> Result<Value, String> {
let size_int = if is_long { size.round() as i64 } else { -(size.round() as i64) };
if size_int == 0 { return Err("size = 0".into()); }
let body = json!({
"contract": contract,
"size": size_int,
"price": format!("{:.8}", price),
"tif": "gtc",
"reduce_only": reduce_only,
"text": "t-pure_ai_v2"
});
self.request(Method::POST, "/futures/usdt/orders", None, Some(&body)).await
}
pub async fn create_market_order(
&self,
contract: &str,
is_long: bool,
size: f64,
) -> Result<Value, String> {
Self::create_market_order_with_reduce(self, contract, is_long, size, false).await
}
/// Создать MARKET ордер с опцией reduce_only (для закрытия позиций)
pub async fn create_reduce_only_order(
&self,
contract: &str,
is_long: bool,
size: f64,
) -> Result<Value, String> {
Self::create_market_order_with_reduce(self, contract, is_long, size, true).await
}
/// Внутренняя функция для создания ордера с опцией reduce_only
async fn create_market_order_with_reduce(
&self,
contract: &str,
is_long: bool,
size: f64,
reduce_only: bool,
) -> Result<Value, String> {
let size_int = if is_long { size.round() as i64 } else { -(size.round() as i64) };
let timestamp = Utc::now().timestamp_millis();
let body = json!({
"contract": contract,
"size": size_int,
"price": "0",
"tif": "ioc",
"reduce_only": reduce_only,
// НЕ меняем margin mode - используем тот что установлен в аккаунте
"text": format!("t-{}", timestamp)
});
self.request(Method::POST, "/futures/usdt/orders", None, Some(&body)).await
}
pub async fn get_contract_details(&self, contract: &str) -> Result<Value, String> {
let path = format!("/futures/usdt/contracts/{}", contract);
self.request(Method::GET, &path, None, None).await
}
pub async fn get_leverage_limits(&self, contract: &str) -> Result<(f64, f64), String> {
let details = self.get_contract_details(contract).await?;
let min = details["leverage_min"]
.as_str()
.ok_or("no leverage_min")?
.parse::<f64>()
.map_err(|_| "parse leverage_min error")?;
let max = details["leverage_max"]
.as_str()
.ok_or("no leverage_max")?
.parse::<f64>()
.map_err(|_| "parse leverage_max error")?;
Ok((min, max))
}
pub async fn update_leverage(
&self,
contract: &str,
leverage: f64,
) -> Result<Value, String> {
let (min_lev, max_lev) = self.get_leverage_limits(contract).await?;
let lev = leverage.clamp(min_lev, max_lev).round() as u32;
let query = json!({
"leverage": lev.to_string()
// НЕ меняем margin mode - используем тот что установлен в аккаунте
});
let path = format!("/futures/usdt/positions/{}/leverage", contract);
self.request(Method::POST, &path, Some(&query), None).await
}
/// Установить leverage для DUAL+CROSS режима
/// ВАЖНО: Для CROSS mode нужно использовать специальный endpoint и параметры!
///
/// Параметры:
/// - leverage: 0 (отключает isolated leverage)
/// - cross_leverage_limit: реальное плечо для CROSS margin
///
/// Endpoint: /futures/usdt/dual_comp/positions/{contract}/leverage
pub async fn update_dual_leverage(
&self,
contract: &str,
cross_leverage: f64,
) -> Result<Value, String> {
let (min_lev, max_lev) = self.get_leverage_limits(contract).await?;
let lev = cross_leverage.clamp(min_lev, max_lev).round() as u32;
let query = json!({
"leverage": "0", // Отключаем isolated leverage
"cross_leverage_limit": lev.to_string() // Устанавливаем CROSS leverage
});
let path = format!("/futures/usdt/dual_comp/positions/{}/leverage", contract);
self.request(Method::POST, &path, Some(&query), None).await
}
pub async fn get_positions(&self, contract: &str) -> Result<Value, String> {
let path = "/futures/usdt/positions";
let query = json!({"contract": contract});
self.request(Method::GET, &path, Some(&query), None).await
}
/// Установить DUAL position mode (для одновременного LONG и SHORT)
/// position_mode: "dual_long" или "dual_short"
pub async fn set_position_mode(&self, mode: &str) -> Result<Value, String> {
let body = json!({
"position_mode": mode // "dual_long" или "dual_short"
});
self.request(Method::POST, "/futures/usdt/set_position_mode", None, Some(&body)).await
}
/// Установить CROSS margin mode для DUAL position mode
/// ВАЖНО: Это ДРУГОЙ endpoint чем для обычного режима!
/// - Обычный: /futures/usdt/positions/cross_mode
/// - Dual: /futures/usdt/dual_comp/positions/cross_mode
pub async fn set_dual_cross_mode(
&self,
contract: &str,
mode: &str, // "CROSS" или "ISOLATED"
) -> Result<Value, String> {
let body = json!({
"mode": mode,
"contract": contract
});
self.request(
Method::POST,
"/futures/usdt/dual_comp/positions/cross_mode",
None,
Some(&body)
).await
}
/// Включить DUAL position mode (если выключен)
/// ВАЖНО: dual_mode передаётся как QUERY parameter, а не в body!
/// POST /futures/usdt/dual_mode?dual_mode=true
pub async fn set_dual_mode_enabled(&self, dual_mode: bool) -> Result<Value, String> {
let query = json!({
"dual_mode": dual_mode
});
self.request(
Method::POST,
"/futures/usdt/dual_mode",
Some(&query),
None // No body!
).await
}
/// Получить детальную информацию о позициях с mode
pub async fn get_positions_with_mode(&self, contract: &str) -> Result<Value, String> {
let path = "/futures/usdt/positions";
let query = json!({"contract": contract});
self.request(Method::GET, &path, Some(&query), None).await
}
/// Получить позиции из DUAL_COMP endpoint (правильный для DUAL mode)
pub async fn get_dual_positions(&self, contract: &str) -> Result<Value, String> {
let path = format!("/futures/usdt/dual_comp/positions/{}", contract);
self.request(Method::GET, &path, None, None).await
}
pub async fn cancel_all_orders(&self) -> Result<(), String> {
self.request(Method::DELETE, "/futures/usdt/orders", None, None).await?;
Ok(())
}
}