use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
use reqwest::header::HeaderMap;
use serde_json::{json, Value};
use crate::core::{
HttpClient, Credentials,
ExchangeId, ExchangeType, AccountType,
ExchangeError, ExchangeResult,
Price, Kline, Ticker, OrderBook,
Order, OrderSide, OrderType, Balance, AccountInfo,
Position, FundingRate,
OrderRequest, CancelRequest, CancelScope,
BalanceQuery, PositionQuery, PositionModification,
OrderHistoryFilter, PlaceOrderResponse, FeeInfo,
UserTrade, UserTradeFilter,
MarketDataCapabilities, TradingCapabilities, AccountCapabilities,
SymbolInput,
};
use crate::core::traits::{
ExchangeIdentity, MarketData, Trading, Account, Positions,
};
use crate::core::{CancelAll, BatchOrders, AccountTransfers, CustodialFunds, SubAccounts};
use crate::core::types::{
ConnectorStats, CancelAllResponse, OrderResult,
TransferRequest, TransferHistoryFilter, TransferResponse,
DepositAddress, WithdrawRequest, WithdrawResponse, FundsRecord, FundsHistoryFilter, FundsRecordType,
SubAccountOperation, SubAccountResult, SubAccount,
OpenInterest, MarkPrice,
};
use crate::core::utils::{RuntimeLimiter, RateLimitMonitor, RateLimitPressure};
use crate::core::types::{RateLimitCapabilities, LimitModel, RestLimitPool, WsLimits, OrderbookCapabilities, WsBookChannel};
use super::endpoints::{HtxUrls, HtxEndpoint, map_kline_interval};
use super::auth::HtxAuth;
use super::parser::HtxParser;
fn to_linear_swap_code(symbol: &str) -> String {
let upper = symbol.to_uppercase();
if upper.contains('-') {
return upper;
}
if let Some(pos) = symbol.find('/') {
let (base, quote) = symbol.split_at(pos);
return format!("{}-{}", base.to_uppercase(), quote[1..].to_uppercase());
}
for quote in &["USDT", "BUSD", "USDC", "BTC", "ETH", "BNB"] {
if let Some(base) = upper.strip_suffix(quote) {
if !base.is_empty() {
return format!("{}-{}", base, quote);
}
}
}
upper
}
static HTX_POOLS: &[RestLimitPool] = &[RestLimitPool {
name: "default",
max_budget: 100,
window_seconds: 10,
is_weight: true,
has_server_headers: true,
server_header: Some("X-RateLimit-Used"),
header_reports_used: true,
}];
static HTX_RATE_CAPS: RateLimitCapabilities = RateLimitCapabilities {
model: LimitModel::Weight,
rest_pools: HTX_POOLS,
decaying: None,
endpoint_weights: &[],
ws: WsLimits {
max_connections: None,
max_subs_per_conn: None,
max_msg_per_sec: None,
max_streams_per_conn: None,
},
};
pub struct HtxConnector {
http: HttpClient,
auth: Option<HtxAuth>,
testnet: bool,
limiter: Arc<Mutex<RuntimeLimiter>>,
monitor: Arc<Mutex<RateLimitMonitor>>,
account_id: Arc<Mutex<Option<i64>>>,
precision: crate::core::utils::precision::PrecisionCache,
}
impl HtxConnector {
pub async fn new(credentials: Option<Credentials>, testnet: bool) -> ExchangeResult<Self> {
let http = HttpClient::new(30_000)?;
let mut auth = credentials.as_ref().map(HtxAuth::new);
if auth.is_some() {
let base_url = HtxUrls::base_url(testnet);
let url = format!("{}/v1/common/timestamp", base_url);
if let Ok(response) = http.get(&url, &HashMap::new()).await {
if response["status"] == "ok" {
if let Some(time_ms) = response["data"].as_i64() {
if let Some(ref mut a) = auth {
a.sync_time(time_ms);
}
}
}
}
}
let limiter = Arc::new(Mutex::new(RuntimeLimiter::from_caps(&HTX_RATE_CAPS)));
let monitor = Arc::new(Mutex::new(RateLimitMonitor::new("HTX")));
Ok(Self {
http,
auth,
testnet,
limiter,
monitor,
account_id: Arc::new(Mutex::new(None)),
precision: crate::core::utils::precision::PrecisionCache::new(),
})
}
pub async fn public(testnet: bool) -> ExchangeResult<Self> {
Self::new(None, testnet).await
}
fn update_rate_from_headers(&self, headers: &HeaderMap) {
let remaining = headers
.get("X-HB-RateLimit-Requests-Remain")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u32>().ok())
.or_else(|| {
headers
.get("ratelimit-remaining")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u32>().ok())
});
let limit = headers
.get("X-HB-RateLimit-Requests-Limit")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u32>().ok())
.or_else(|| {
headers
.get("ratelimit-limit")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u32>().ok())
});
if let (Some(remaining), Some(limit)) = (remaining, limit) {
let used = limit.saturating_sub(remaining);
if let Ok(mut limiter) = self.limiter.lock() {
limiter.update_from_server("default", used);
}
}
}
async fn rate_limit_wait(&self, weight: u32, essential: bool) -> bool {
loop {
let wait_time = {
let mut limiter = self.limiter.lock()
.expect("rate limiter mutex poisoned");
let pressure = self.monitor.lock()
.expect("rate monitor mutex poisoned")
.check(&mut limiter);
if pressure >= RateLimitPressure::Cutoff && !essential {
return false;
}
if limiter.try_acquire("default", weight) {
return true;
}
limiter.time_until_ready("default", weight)
};
if wait_time > Duration::ZERO {
tokio::time::sleep(wait_time).await;
}
}
}
async fn get(
&self,
endpoint: HtxEndpoint,
params: HashMap<String, String>,
) -> ExchangeResult<Value> {
if !self.rate_limit_wait(1, false).await {
return Err(ExchangeError::RateLimitExceeded {
retry_after: None,
message: "Rate limit budget >= 90% used; non-essential market data request dropped".to_string(),
});
}
let base_url = match endpoint {
HtxEndpoint::FuturesTicker
| HtxEndpoint::FuturesOrderbook
| HtxEndpoint::FuturesKlines
| HtxEndpoint::FuturesTrades
| HtxEndpoint::OpenInterest
| HtxEndpoint::FundingRateHistory
| HtxEndpoint::MarkPrice
| HtxEndpoint::MarkPriceKline
| HtxEndpoint::EliteAccountRatio
| HtxEndpoint::HistoricalFundingRate => HtxUrls::futures_base_url(self.testnet),
_ => HtxUrls::base_url(self.testnet),
};
let path = endpoint.path();
let query = if endpoint.is_private() {
let auth = self.auth.as_ref()
.ok_or_else(|| ExchangeError::Auth("Authentication required".to_string()))?;
auth.build_signed_query("GET", "api.huobi.pro", path, ¶ms)
} else {
if params.is_empty() {
String::new()
} else {
let qs: Vec<String> = params.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect();
qs.join("&")
}
};
let url = if query.is_empty() {
format!("{}{}", base_url, path)
} else {
format!("{}{}?{}", base_url, path, query)
};
let (response, resp_headers) = self.http.get_with_response_headers(&url, &HashMap::new(), &HashMap::new()).await?;
self.update_rate_from_headers(&resp_headers);
Ok(response)
}
async fn post(
&self,
endpoint: HtxEndpoint,
body: Value,
) -> ExchangeResult<Value> {
self.rate_limit_wait(1, true).await;
let base_url = HtxUrls::base_url(self.testnet);
let path = endpoint.path();
let auth = self.auth.as_ref()
.ok_or_else(|| ExchangeError::Auth("Authentication required".to_string()))?;
let query = auth.build_signed_query("POST", "api.huobi.pro", path, &HashMap::new());
let url = format!("{}{}?{}", base_url, path, query);
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/json".to_string());
let (response, resp_headers) = self.http.post_with_response_headers(&url, &body, &headers).await?;
self.update_rate_from_headers(&resp_headers);
Ok(response)
}
async fn get_account_id(&self) -> ExchangeResult<i64> {
{
let cached = self.account_id.lock().expect("Mutex poisoned");
if let Some(id) = *cached {
return Ok(id);
}
}
let response = self.get(HtxEndpoint::AccountList, HashMap::new()).await?;
let accounts = HtxParser::parse_account_list(&response)?;
let spot_account = accounts.iter()
.find(|(_, account_type)| account_type == "spot")
.ok_or_else(|| ExchangeError::Parse("No spot account found".to_string()))?;
let id = spot_account.0;
{
let mut cached = self.account_id.lock().expect("Mutex poisoned");
*cached = Some(id);
}
Ok(id)
}
pub async fn get_symbols(&self) -> ExchangeResult<Value> {
self.get(HtxEndpoint::SymbolsV1, HashMap::new()).await
}
pub async fn get_exchange_info_parsed(&self, account_type: crate::core::types::AccountType) -> ExchangeResult<Vec<crate::core::types::SymbolInfo>> {
let response = self.get_symbols().await?;
HtxParser::parse_exchange_info(&response, account_type)
}
pub async fn cancel_all_orders(&self, symbol: Option<&str>) -> ExchangeResult<Vec<String>> {
let account_id = self.get_account_id().await?;
let mut params = HashMap::new();
params.insert("account-id".to_string(), account_id.to_string());
if let Some(s) = symbol {
params.insert("symbol".to_string(), s.to_string());
}
let response = self.get(HtxEndpoint::OpenOrders, params.clone()).await?;
let data = HtxParser::extract_result_v1(&response)?;
let order_ids: Vec<String> = data.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v["id"].as_i64().map(|id| id.to_string()))
.collect()
})
.unwrap_or_default();
if !order_ids.is_empty() {
let body = json!({
"order-ids": order_ids,
});
let _ = self.post(HtxEndpoint::CancelAllOrders, body).await?;
}
Ok(order_ids)
}
}
impl ExchangeIdentity for HtxConnector {
fn exchange_id(&self) -> ExchangeId {
ExchangeId::HTX
}
fn metrics(&self) -> ConnectorStats {
let (http_requests, http_errors, last_latency_ms) = self.http.stats();
let (rate_used, rate_max) = if let Ok(mut limiter) = self.limiter.lock() {
limiter.primary_stats()
} else {
(0, 0)
};
ConnectorStats {
http_requests,
http_errors,
last_latency_ms,
rate_used,
rate_max,
rate_groups: Vec::new(),
ws_ping_rtt_ms: 0,
}
}
fn is_testnet(&self) -> bool {
self.testnet
}
fn supported_account_types(&self) -> Vec<AccountType> {
vec![
AccountType::Spot,
AccountType::Margin,
AccountType::FuturesCross,
]
}
fn exchange_type(&self) -> ExchangeType {
ExchangeType::Cex
}
fn rate_limit_capabilities(&self) -> RateLimitCapabilities {
HTX_RATE_CAPS
}
fn orderbook_capabilities(&self, _account_type: AccountType) -> OrderbookCapabilities {
static HTX_CHANNELS: &[WsBookChannel] = &[
WsBookChannel::delta("mbp.5", Some(5), None ),
WsBookChannel::delta("mbp.10", Some(10), Some(100) ),
WsBookChannel::delta("mbp.20", Some(20), Some(100) ),
WsBookChannel::delta("mbp.150", Some(150), Some(100) ),
WsBookChannel::delta("mbp.400", Some(400), Some(100) ),
WsBookChannel::snapshot("depth.step0", 150, 100),
WsBookChannel::snapshot("depth.step1", 20, 100),
WsBookChannel::snapshot("depth.step2", 20, 100),
WsBookChannel::snapshot("depth.step3", 20, 100),
WsBookChannel::snapshot("depth.step4", 20, 100),
WsBookChannel::snapshot("depth.step5", 20, 100),
];
OrderbookCapabilities {
ws_depths: &[5, 10, 20, 150, 400],
ws_default_depth: Some(20),
rest_max_depth: Some(150),
rest_depth_values: &[5, 10, 20, 30, 150],
supports_snapshot: true,
supports_delta: true,
update_speeds_ms: &[100],
default_speed_ms: Some(100),
ws_channels: HTX_CHANNELS,
checksum: None,
has_sequence: true,
has_prev_sequence: true,
supports_aggregation: true,
aggregation_levels: &["step0", "step1", "step2", "step3", "step4", "step5"],
}
}
}
#[async_trait]
impl MarketData for HtxConnector {
async fn get_price(
&self,
symbol: SymbolInput<'_>,
account_type: AccountType,
) -> ExchangeResult<Price> {
let symbol = symbol.resolve(ExchangeId::HTX, account_type)?;
let mut params = HashMap::new();
let (endpoint, param_name) = match account_type {
AccountType::FuturesCross | AccountType::FuturesIsolated => {
(HtxEndpoint::FuturesTicker, "contract_code")
}
_ => (HtxEndpoint::Ticker, "symbol"),
};
params.insert(param_name.to_string(), symbol.to_string());
let response = self.get(endpoint, params).await?;
let ticker = HtxParser::parse_ticker(&response, &symbol)?;
Ok(ticker.last_price)
}
async fn get_orderbook(
&self,
symbol: SymbolInput<'_>,
depth: Option<u16>,
account_type: AccountType,
) -> ExchangeResult<OrderBook> {
let symbol = symbol.resolve(ExchangeId::HTX, account_type)?;
let mut params = HashMap::new();
let (endpoint, param_name) = match account_type {
AccountType::FuturesCross | AccountType::FuturesIsolated => {
(HtxEndpoint::FuturesOrderbook, "contract_code")
}
_ => (HtxEndpoint::Orderbook, "symbol"),
};
params.insert(param_name.to_string(), symbol.to_string());
params.insert("type".to_string(), "step0".to_string());
if let Some(d) = depth {
let depth_str = match d {
1..=5 => "5",
6..=10 => "10",
_ => "20",
};
params.insert("depth".to_string(), depth_str.to_string());
}
let response = self.get(endpoint, params).await?;
HtxParser::parse_orderbook(&response)
}
async fn get_klines(
&self,
symbol: SymbolInput<'_>,
interval: &str,
limit: Option<u16>,
account_type: AccountType,
_end_time: Option<i64>,
) -> ExchangeResult<Vec<Kline>> {
let symbol = symbol.resolve(ExchangeId::HTX, account_type)?;
let mut params = HashMap::new();
let (endpoint, param_name) = match account_type {
AccountType::FuturesCross | AccountType::FuturesIsolated => {
(HtxEndpoint::FuturesKlines, "contract_code")
}
_ => (HtxEndpoint::Klines, "symbol"),
};
params.insert(param_name.to_string(), symbol.to_string());
params.insert("period".to_string(), map_kline_interval(interval).to_string());
if let Some(l) = limit {
params.insert("size".to_string(), l.min(2000).to_string());
}
let response = self.get(endpoint, params).await?;
HtxParser::parse_klines(&response)
}
async fn get_ticker(
&self,
symbol: SymbolInput<'_>,
account_type: AccountType,
) -> ExchangeResult<Ticker> {
let symbol = symbol.resolve(ExchangeId::HTX, account_type)?;
let mut params = HashMap::new();
let (endpoint, param_name) = match account_type {
AccountType::FuturesCross | AccountType::FuturesIsolated => {
(HtxEndpoint::FuturesTicker, "contract_code")
}
_ => (HtxEndpoint::Ticker, "symbol"),
};
params.insert(param_name.to_string(), symbol.to_string());
let response = self.get(endpoint, params).await?;
HtxParser::parse_ticker(&response, &symbol)
}
async fn ping(&self) -> ExchangeResult<()> {
let response = self.get(HtxEndpoint::ServerTime, HashMap::new()).await?;
if response["status"] == "ok" {
Ok(())
} else {
Err(ExchangeError::Api {
code: 0,
message: "Ping failed".to_string(),
})
}
}
async fn get_exchange_info(&self, account_type: AccountType) -> ExchangeResult<Vec<crate::core::types::SymbolInfo>> {
let response = self.get_symbols().await?;
let symbols = HtxParser::parse_exchange_info(&response, account_type)?;
self.precision.load_from_symbols(&symbols);
Ok(symbols)
}
fn market_data_capabilities(&self, _account_type: AccountType) -> MarketDataCapabilities {
MarketDataCapabilities {
has_ping: true,
has_price: true,
has_ticker: true,
has_orderbook: true,
has_klines: true,
has_exchange_info: true,
has_recent_trades: false,
supported_intervals: &["1m", "5m", "15m", "30m", "1h", "4h", "1d", "1w", "1M", "1y"],
max_kline_limit: Some(2000),
has_ws_klines: true,
has_ws_trades: true,
has_ws_orderbook: true,
has_ws_ticker: true,
}
}
}
fn millis_to_date_str(unix_secs: i64) -> String {
let days = unix_secs / 86400;
let mut y = 1970i64;
let mut remaining_days = days;
loop {
let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
let days_in_year = if leap { 366 } else { 365 };
if remaining_days < days_in_year {
break;
}
remaining_days -= days_in_year;
y += 1;
}
let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
let days_per_month: [i64; 12] = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut m = 1i64;
for &dim in &days_per_month {
if remaining_days < dim {
break;
}
remaining_days -= dim;
m += 1;
}
let d = remaining_days + 1;
format!("{:04}-{:02}-{:02}", y, m, d)
}
#[async_trait]
impl Trading for HtxConnector {
async fn place_order(&self, req: OrderRequest) -> ExchangeResult<PlaceOrderResponse> {
let symbol = req.symbol.clone();
let side = req.side;
let quantity = req.quantity;
let _account_type = req.account_type;
let account_id = self.get_account_id().await?;
let client_order_id = format!("cc_{}", crate::core::timestamp_millis());
let htx_symbol = symbol
.raw()
.map(|r| r.to_string())
.unwrap_or_else(|| format!("{}{}", symbol.base.to_lowercase(), symbol.quote.to_lowercase()));
let qty_str = self.precision.qty(&htx_symbol, quantity);
let side_str = match side {
OrderSide::Buy => "buy",
OrderSide::Sell => "sell",
};
match req.order_type {
OrderType::Market => {
let order_type = format!("{}-market", side_str);
let body = json!({
"account-id": account_id.to_string(),
"symbol": htx_symbol,
"type": order_type,
"amount": qty_str,
"client-order-id": client_order_id,
});
let response = self.post(HtxEndpoint::PlaceOrder, body).await?;
let order = HtxParser::parse_order(&response)?;
Ok(PlaceOrderResponse::Simple(Order {
id: order.id,
client_order_id: Some(client_order_id),
symbol: Some(symbol.to_string()),
side,
order_type: OrderType::Market,
status: crate::core::OrderStatus::New,
price: None,
stop_price: None,
quantity,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: crate::core::timestamp_millis() as i64,
updated_at: None,
time_in_force: crate::core::TimeInForce::Gtc,
}))
}
OrderType::Limit { price } => {
let order_type = format!("{}-limit", side_str);
let body = json!({
"account-id": account_id.to_string(),
"symbol": htx_symbol,
"type": order_type,
"amount": qty_str,
"price": self.precision.price(&htx_symbol, price),
"client-order-id": client_order_id,
});
let response = self.post(HtxEndpoint::PlaceOrder, body).await?;
let order = HtxParser::parse_order(&response)?;
Ok(PlaceOrderResponse::Simple(Order {
id: order.id,
client_order_id: Some(client_order_id),
symbol: Some(symbol.to_string()),
side,
order_type: OrderType::Limit { price: 0.0 },
status: crate::core::OrderStatus::New,
price: Some(price),
stop_price: None,
quantity,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: crate::core::timestamp_millis() as i64,
updated_at: None,
time_in_force: crate::core::TimeInForce::Gtc,
}))
}
OrderType::StopLimit { stop_price, limit_price } => {
let order_type = format!("{}-stop-limit", side_str);
let operator = match side {
OrderSide::Buy => "gte",
OrderSide::Sell => "lte",
};
let body = json!({
"account-id": account_id.to_string(),
"symbol": htx_symbol,
"type": order_type,
"amount": qty_str,
"stop-price": self.precision.price(&htx_symbol, stop_price),
"price": self.precision.price(&htx_symbol, limit_price),
"operator": operator,
"client-order-id": client_order_id,
});
let response = self.post(HtxEndpoint::PlaceOrder, body).await?;
let order = HtxParser::parse_order(&response)?;
Ok(PlaceOrderResponse::Simple(Order {
id: order.id,
client_order_id: Some(client_order_id),
symbol: Some(symbol.to_string()),
side,
order_type: OrderType::StopLimit { stop_price, limit_price },
status: crate::core::OrderStatus::New,
price: Some(limit_price),
stop_price: Some(stop_price),
quantity,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: crate::core::timestamp_millis() as i64,
updated_at: None,
time_in_force: crate::core::TimeInForce::Gtc,
}))
}
OrderType::PostOnly { price } => {
let order_type = format!("{}-limit-maker", side_str);
let body = json!({
"account-id": account_id.to_string(),
"symbol": htx_symbol,
"type": order_type,
"amount": qty_str,
"price": self.precision.price(&htx_symbol, price),
"client-order-id": client_order_id,
});
let response = self.post(HtxEndpoint::PlaceOrder, body).await?;
let order = HtxParser::parse_order(&response)?;
Ok(PlaceOrderResponse::Simple(Order {
id: order.id,
client_order_id: Some(client_order_id),
symbol: Some(symbol.to_string()),
side,
order_type: OrderType::PostOnly { price },
status: crate::core::OrderStatus::New,
price: Some(price),
stop_price: None,
quantity,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: crate::core::timestamp_millis() as i64,
updated_at: None,
time_in_force: crate::core::TimeInForce::Gtc,
}))
}
OrderType::Ioc { price } => {
let price_val = price.unwrap_or(0.0);
let order_type = format!("{}-ioc", side_str);
let body = json!({
"account-id": account_id.to_string(),
"symbol": htx_symbol,
"type": order_type,
"amount": qty_str,
"price": self.precision.price(&htx_symbol, price_val),
"client-order-id": client_order_id,
});
let response = self.post(HtxEndpoint::PlaceOrder, body).await?;
let order = HtxParser::parse_order(&response)?;
Ok(PlaceOrderResponse::Simple(Order {
id: order.id,
client_order_id: Some(client_order_id),
symbol: Some(symbol.to_string()),
side,
order_type: OrderType::Ioc { price },
status: crate::core::OrderStatus::New,
price,
stop_price: None,
quantity,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: crate::core::timestamp_millis() as i64,
updated_at: None,
time_in_force: crate::core::TimeInForce::Ioc,
}))
}
OrderType::Fok { price } => {
let order_type = format!("{}-limit-fok", side_str);
let body = json!({
"account-id": account_id.to_string(),
"symbol": htx_symbol,
"type": order_type,
"amount": qty_str,
"price": self.precision.price(&htx_symbol, price),
"client-order-id": client_order_id,
});
let response = self.post(HtxEndpoint::PlaceOrder, body).await?;
let order = HtxParser::parse_order(&response)?;
Ok(PlaceOrderResponse::Simple(Order {
id: order.id,
client_order_id: Some(client_order_id),
symbol: Some(symbol.to_string()),
side,
order_type: OrderType::Fok { price },
status: crate::core::OrderStatus::New,
price: Some(price),
stop_price: None,
quantity,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: crate::core::timestamp_millis() as i64,
updated_at: None,
time_in_force: crate::core::TimeInForce::Fok,
}))
}
OrderType::TrailingStop { callback_rate, activation_price } => {
let body = json!({
"accountId": account_id.to_string(),
"symbol": htx_symbol,
"orderSide": side_str,
"orderSize": qty_str,
"orderType": "trailing-stop-order",
"trailingRate": format!("{:.4}", callback_rate.clamp(0.0001, 5.0)),
"activationPrice": activation_price
.map(|p| self.precision.price(&htx_symbol, p))
.unwrap_or_default(),
});
let response = self.post(HtxEndpoint::AlgoOrders, body).await?;
let order_id_str = response
.pointer("/data/orderId")
.or_else(|| response.pointer("/data/clientOrderId"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Ok(PlaceOrderResponse::Simple(Order {
id: order_id_str,
client_order_id: Some(client_order_id),
symbol: Some(symbol.to_string()),
side,
order_type: OrderType::TrailingStop { callback_rate, activation_price },
status: crate::core::OrderStatus::New,
price: activation_price,
stop_price: None,
quantity,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: crate::core::timestamp_millis() as i64,
updated_at: None,
time_in_force: crate::core::TimeInForce::Gtc,
}))
}
_ => Err(ExchangeError::UnsupportedOperation(
format!("{:?} order type not supported on {:?}", req.order_type, self.exchange_id())
)),
}
}
async fn cancel_order(&self, req: CancelRequest) -> ExchangeResult<Order> {
match req.scope {
CancelScope::Single { ref order_id } => {
let path = HtxEndpoint::CancelOrder.path_with_vars(&[("order-id", order_id)]);
let base_url = HtxUrls::base_url(self.testnet);
let auth = self.auth.as_ref()
.ok_or_else(|| ExchangeError::Auth("Authentication required".to_string()))?;
let query = auth.build_signed_query("POST", "api.huobi.pro", &path, &HashMap::new());
let url = format!("{}{}?{}", base_url, path, query);
let body = json!({});
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/json".to_string());
self.rate_limit_wait(1, true).await;
let (response, resp_headers) = self.http.post_with_response_headers(&url, &body, &headers).await?;
self.update_rate_from_headers(&resp_headers);
HtxParser::parse_order(&response)
}
CancelScope::Batch { ref order_ids } => {
let body = json!({
"order-ids": order_ids,
});
let _response = self.post(HtxEndpoint::CancelAllOrders, body).await?;
Ok(Order {
id: order_ids.first().cloned().unwrap_or_default(),
client_order_id: None,
symbol: req.symbol.as_ref().map(|s| s.to_string()),
side: OrderSide::Buy,
order_type: OrderType::Market,
status: crate::core::OrderStatus::Canceled,
price: None,
stop_price: None,
quantity: 0.0,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: 0,
updated_at: Some(crate::core::timestamp_millis() as i64),
time_in_force: crate::core::TimeInForce::Gtc,
})
}
_ => Err(ExchangeError::UnsupportedOperation(
format!("{:?} cancel scope not supported — use CancelAll trait", req.scope)
)),
}
}
async fn get_order_history(
&self,
filter: OrderHistoryFilter,
_account_type: AccountType,
) -> ExchangeResult<Vec<Order>> {
let mut params = HashMap::new();
if let Some(sym) = &filter.symbol {
let raw = sym.raw()
.map(|r| r.to_string())
.unwrap_or_else(|| format!("{}{}", sym.base.to_lowercase(), sym.quote.to_lowercase()));
params.insert("symbol".to_string(), raw);
}
params.insert("states".to_string(), "filled,canceled".to_string());
if let Some(start) = filter.start_time {
params.insert("start-time".to_string(), start.to_string());
}
if let Some(end) = filter.end_time {
params.insert("end-time".to_string(), end.to_string());
}
if let Some(limit) = filter.limit {
params.insert("size".to_string(), limit.min(100).to_string());
}
let response = self.get(HtxEndpoint::OrderHistory, params).await?;
let data = HtxParser::extract_result_v1(&response)?;
let orders = data.as_array()
.ok_or_else(|| ExchangeError::Parse("Data is not an array".into()))?
.iter()
.filter_map(|order_json| {
let wrapped = json!({"status": "ok", "data": order_json});
HtxParser::parse_order(&wrapped).ok()
})
.collect();
Ok(orders)
}
async fn get_order(
&self,
_symbol: &str,
order_id: &str,
_account_type: AccountType,
) -> ExchangeResult<Order> {
let path = HtxEndpoint::OrderStatus.path_with_vars(&[("order-id", order_id)]);
let base_url = HtxUrls::base_url(self.testnet);
let auth = self.auth.as_ref()
.ok_or_else(|| ExchangeError::Auth("Authentication required".to_string()))?;
let query = auth.build_signed_query("GET", "api.huobi.pro", &path, &HashMap::new());
let url = format!("{}{}?{}", base_url, path, query);
self.rate_limit_wait(1, true).await;
let (response, resp_headers) = self.http.get_with_response_headers(&url, &HashMap::new(), &HashMap::new()).await?;
self.update_rate_from_headers(&resp_headers);
HtxParser::parse_order(&response)
}
async fn get_open_orders(
&self,
symbol: Option<&str>,
_account_type: AccountType,
) -> ExchangeResult<Vec<Order>> {
let account_id = self.get_account_id().await?;
let mut params = HashMap::new();
params.insert("account-id".to_string(), account_id.to_string());
if let Some(s) = symbol {
params.insert("symbol".to_string(), s.to_string());
}
let response = self.get(HtxEndpoint::OpenOrders, params).await?;
let data = HtxParser::extract_result_v1(&response)?;
let orders = data.as_array()
.ok_or_else(|| ExchangeError::Parse("Data is not an array".into()))?
.iter()
.filter_map(|order_json| {
let wrapped = json!({"status": "ok", "data": order_json});
HtxParser::parse_order(&wrapped).ok()
})
.collect();
Ok(orders)
}
async fn get_user_trades(
&self,
filter: UserTradeFilter,
_account_type: AccountType,
) -> ExchangeResult<Vec<UserTrade>> {
let mut params = HashMap::new();
if let Some(sym) = &filter.symbol {
params.insert("symbol".to_string(), sym.clone());
}
if let Some(lim) = filter.limit {
params.insert("size".to_string(), lim.min(500).to_string());
}
if let Some(start_ms) = filter.start_time {
let start_secs = (start_ms / 1000) as i64;
let date = millis_to_date_str(start_secs);
params.insert("start-date".to_string(), date);
}
if let Some(end_ms) = filter.end_time {
let end_secs = (end_ms / 1000) as i64;
let date = millis_to_date_str(end_secs);
params.insert("end-date".to_string(), date);
}
let response = self.get(HtxEndpoint::MatchResults, params).await?;
HtxParser::parse_user_trades(&response)
}
fn trading_capabilities(&self, account_type: AccountType) -> TradingCapabilities {
let is_futures = !matches!(account_type, AccountType::Spot | AccountType::Margin);
if is_futures {
TradingCapabilities {
has_market_order: false,
has_limit_order: false,
has_stop_market: false,
has_stop_limit: false,
has_trailing_stop: false,
has_bracket: false,
has_oco: false,
has_amend: false,
has_batch: false,
max_batch_size: None,
has_cancel_all: false,
has_user_trades: false,
has_order_history: false,
}
} else {
TradingCapabilities {
has_market_order: true,
has_limit_order: true,
has_stop_market: false,
has_stop_limit: true,
has_trailing_stop: true,
has_bracket: false,
has_oco: false,
has_amend: false,
has_batch: false,
max_batch_size: None,
has_cancel_all: true,
has_user_trades: true,
has_order_history: true,
}
}
}
}
#[async_trait]
impl Account for HtxConnector {
async fn get_balance(&self, query: BalanceQuery) -> ExchangeResult<Vec<Balance>> {
let _asset = query.asset.clone();
let _account_type = query.account_type;
let account_id = self.get_account_id().await?;
let path = HtxEndpoint::Balance.path_with_vars(&[("account-id", &account_id.to_string())]);
let base_url = HtxUrls::base_url(self.testnet);
let auth = self.auth.as_ref()
.ok_or_else(|| ExchangeError::Auth("Authentication required".to_string()))?;
let query = auth.build_signed_query("GET", "api.huobi.pro", &path, &HashMap::new());
let url = format!("{}{}?{}", base_url, path, query);
self.rate_limit_wait(1, true).await;
let (response, resp_headers) = self.http.get_with_response_headers(&url, &HashMap::new(), &HashMap::new()).await?;
self.update_rate_from_headers(&resp_headers);
HtxParser::parse_balance(&response)
}
async fn get_account_info(&self, _account_type: AccountType) -> ExchangeResult<AccountInfo> {
let balances = self.get_balance(BalanceQuery { asset: None, account_type: _account_type }).await?;
Ok(AccountInfo {
account_type: _account_type,
can_trade: true,
can_withdraw: true,
can_deposit: true,
maker_commission: 0.002, taker_commission: 0.002,
balances,
})
}
async fn get_fees(&self, symbol: Option<&str>) -> ExchangeResult<FeeInfo> {
let mut params = HashMap::new();
if let Some(sym) = symbol {
params.insert("symbols".to_string(), sym.to_string());
}
let response = self.get(HtxEndpoint::TransactFee, params).await?;
let data = response.get("data")
.and_then(|d| d.as_array())
.and_then(|arr| arr.first())
.ok_or_else(|| ExchangeError::Parse("No fee data".to_string()))?;
let maker_rate = data.get("makerFeeRate")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.002);
let taker_rate = data.get("takerFeeRate")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.002);
Ok(FeeInfo {
maker_rate,
taker_rate,
symbol: symbol.map(|s| s.to_string()),
tier: None,
})
}
fn account_capabilities(&self, account_type: AccountType) -> AccountCapabilities {
let is_futures = !matches!(account_type, AccountType::Spot | AccountType::Margin);
AccountCapabilities {
has_balances: !is_futures,
has_account_info: !is_futures,
has_fees: !is_futures,
has_transfers: true,
has_sub_accounts: true,
has_deposit_withdraw: true,
has_margin: false,
has_earn_staking: false,
has_funding_history: false,
has_ledger: false,
has_convert: false,
has_positions: false,
}
}
}
#[async_trait]
impl Positions for HtxConnector {
async fn get_positions(&self, _query: PositionQuery) -> ExchangeResult<Vec<Position>> {
Ok(vec![])
}
async fn get_funding_rate(
&self,
_symbol: &str,
_account_type: AccountType,
) -> ExchangeResult<FundingRate> {
Err(ExchangeError::NotSupported("Funding rate not available for spot trading".to_string()))
}
async fn get_mark_price(
&self,
symbol: &str,
) -> ExchangeResult<MarkPrice> {
let contract_code = to_linear_swap_code(symbol);
let response = self.get_mark_price_kline(&contract_code, "1min", Some(1)).await?;
let data = response
.get("data")
.and_then(|v| v.as_array())
.ok_or_else(|| ExchangeError::Parse("Missing data array".to_string()))?;
let kline = data
.first()
.ok_or_else(|| ExchangeError::Parse("Empty data array".to_string()))?;
let mark_price = kline
.get("close")
.and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
.ok_or_else(|| ExchangeError::Parse("Missing close in mark price kline".to_string()))?;
Ok(MarkPrice {
mark_price,
index_price: None,
funding_rate: None,
timestamp: kline
.get("id")
.and_then(|v| v.as_i64())
.map(|t| t * 1000)
.unwrap_or_else(|| crate::core::timestamp_millis() as i64),
})
}
async fn modify_position(&self, req: PositionModification) -> ExchangeResult<()> {
match req {
PositionModification::SetLeverage { .. } => {
Err(ExchangeError::NotSupported("Leverage not available for spot trading".to_string()))
}
_ => Err(ExchangeError::UnsupportedOperation(
"Position modification not supported on HTX spot".to_string()
)),
}
}
async fn get_open_interest(
&self,
symbol: &str,
_account_type: AccountType,
) -> ExchangeResult<OpenInterest> {
let contract_code = to_linear_swap_code(symbol);
let response = self.get_open_interest(Some(&contract_code)).await?;
let data = response.get("data")
.and_then(|d| d.as_array())
.and_then(|a| a.first())
.ok_or_else(|| ExchangeError::Parse("HTX OI: missing data[0]".to_string()))?;
let oi = data.get("open_interest")
.and_then(|v| v.as_f64())
.or_else(|| data.get("volume").and_then(|v| v.as_f64()))
.unwrap_or(0.0);
let ts = data.get("timestamp")
.and_then(|v| v.as_i64())
.unwrap_or_else(|| crate::core::timestamp_millis() as i64);
Ok(OpenInterest {
open_interest: oi,
open_interest_value: None,
timestamp: ts,
})
}
}
#[async_trait]
impl CancelAll for HtxConnector {
async fn cancel_all_orders(
&self,
scope: CancelScope,
_account_type: AccountType,
) -> ExchangeResult<CancelAllResponse> {
let account_id = self.get_account_id().await?;
match scope {
CancelScope::All { symbol: None } => {
let body = json!({
"account-id": account_id.to_string(),
});
let response = self.post(HtxEndpoint::CancelOpenOrders, body).await?;
let data = HtxParser::extract_result_v1(&response)?;
let cancelled_count = data.get("success-count")
.and_then(|v| v.as_u64())
.map(|n| n as u32)
.unwrap_or(0);
let failed_count = data.get("failed-count")
.and_then(|v| v.as_u64())
.map(|n| n as u32)
.unwrap_or(0);
Ok(CancelAllResponse {
cancelled_count,
failed_count,
details: vec![],
})
}
CancelScope::All { symbol: Some(sym) } | CancelScope::BySymbol { symbol: sym } => {
let htx_symbol = sym.raw()
.map(|r| r.to_string())
.unwrap_or_else(|| format!("{}{}", sym.base.to_lowercase(), sym.quote.to_lowercase()));
let body = json!({
"account-id": account_id.to_string(),
"symbol": htx_symbol,
});
let response = self.post(HtxEndpoint::CancelOpenOrders, body).await?;
let data = HtxParser::extract_result_v1(&response)?;
let cancelled_count = data.get("success-count")
.and_then(|v| v.as_u64())
.map(|n| n as u32)
.unwrap_or(0);
let failed_count = data.get("failed-count")
.and_then(|v| v.as_u64())
.map(|n| n as u32)
.unwrap_or(0);
Ok(CancelAllResponse {
cancelled_count,
failed_count,
details: vec![],
})
}
_ => Err(ExchangeError::UnsupportedOperation(
format!("{:?} not supported in cancel_all_orders", scope)
)),
}
}
}
#[async_trait]
impl BatchOrders for HtxConnector {
async fn place_orders_batch(
&self,
_orders: Vec<crate::core::OrderRequest>,
) -> ExchangeResult<Vec<OrderResult>> {
Err(ExchangeError::UnsupportedOperation(
"Batch order placement not available on HTX spot".to_string()
))
}
async fn cancel_orders_batch(
&self,
order_ids: Vec<String>,
_symbol: Option<&str>,
_account_type: AccountType,
) -> ExchangeResult<Vec<OrderResult>> {
let chunks: Vec<Vec<String>> = order_ids.chunks(50)
.map(|chunk| chunk.to_vec())
.collect();
let mut results = Vec::new();
for chunk in chunks {
let body = json!({ "order-ids": chunk });
match self.post(HtxEndpoint::CancelAllOrders, body).await {
Ok(response) => {
let data = HtxParser::extract_result_v1(&response)?;
if let Some(success_arr) = data.get("success").and_then(|v| v.as_array()) {
for _id_val in success_arr {
results.push(OrderResult {
order: None,
client_order_id: None,
success: true,
error: None,
error_code: None,
});
}
}
if let Some(failed_arr) = data.get("failed").and_then(|v| v.as_array()) {
for item in failed_arr {
let err_msg = item.get("err-msg")
.and_then(|v| v.as_str())
.unwrap_or("Cancel failed")
.to_string();
results.push(OrderResult {
order: None,
client_order_id: None,
success: false,
error: Some(err_msg),
error_code: None,
});
}
}
}
Err(e) => {
for _ in &chunk {
results.push(OrderResult {
order: None,
client_order_id: None,
success: false,
error: Some(e.to_string()),
error_code: None,
});
}
}
}
}
Ok(results)
}
fn max_batch_place_size(&self) -> usize {
0 }
fn max_batch_cancel_size(&self) -> usize {
50
}
}
#[async_trait]
impl AccountTransfers for HtxConnector {
async fn transfer(&self, req: TransferRequest) -> ExchangeResult<TransferResponse> {
use crate::core::AccountType;
let transfer_type = match (req.from_account, req.to_account) {
(AccountType::Spot | AccountType::Margin, AccountType::FuturesCross | AccountType::FuturesIsolated) => {
"pro-to-futures"
}
(AccountType::FuturesCross | AccountType::FuturesIsolated, AccountType::Spot | AccountType::Margin) => {
"futures-to-pro"
}
_ => {
return Err(ExchangeError::UnsupportedOperation(
format!("HTX transfer from {:?} to {:?} not supported", req.from_account, req.to_account)
));
}
};
let body = json!({
"currency": req.asset.to_lowercase(),
"amount": req.amount,
"type": transfer_type,
});
let response = self.post(HtxEndpoint::Transfer, body).await?;
let tran_id = response.get("data")
.and_then(|v| v.as_i64())
.map(|n| n.to_string())
.unwrap_or_else(|| "unknown".to_string());
Ok(TransferResponse {
transfer_id: tran_id,
status: "Successful".to_string(),
asset: req.asset,
amount: req.amount,
timestamp: Some(crate::core::timestamp_millis() as i64),
})
}
async fn get_transfer_history(
&self,
filter: TransferHistoryFilter,
) -> ExchangeResult<Vec<TransferResponse>> {
let mut params = HashMap::new();
if let Some(start) = filter.start_time {
params.insert("startTime".to_string(), start.to_string());
}
if let Some(end) = filter.end_time {
params.insert("endTime".to_string(), end.to_string());
}
if let Some(limit) = filter.limit {
params.insert("limit".to_string(), limit.to_string());
}
let response = self.get(HtxEndpoint::TransferHistory, params).await?;
let data = response.get("data")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let records = data.iter().map(|item| {
let tran_id = item["transactId"]
.as_str()
.map(|s| s.to_string())
.or_else(|| item["transactId"].as_i64().map(|n| n.to_string()))
.unwrap_or_else(|| "unknown".to_string());
let asset = item["currency"].as_str().unwrap_or("").to_string();
let amount = item["amount"]
.as_str().and_then(|s| s.parse::<f64>().ok())
.or_else(|| item["amount"].as_f64())
.unwrap_or(0.0);
let status = item["state"].as_str().unwrap_or("Unknown").to_string();
let timestamp = item["createAt"].as_i64();
TransferResponse {
transfer_id: tran_id,
status,
asset,
amount,
timestamp,
}
}).collect();
Ok(records)
}
}
#[async_trait]
impl CustodialFunds for HtxConnector {
async fn get_deposit_address(
&self,
asset: &str,
network: Option<&str>,
) -> ExchangeResult<DepositAddress> {
let mut params = HashMap::new();
params.insert("currency".to_string(), asset.to_lowercase());
if let Some(chain) = network {
params.insert("chain".to_string(), chain.to_string());
}
let response = self.get(HtxEndpoint::DepositAddress, params).await?;
let data = response.get("data")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.ok_or_else(|| ExchangeError::Parse("No deposit address data".into()))?;
let address = data["address"]
.as_str()
.ok_or_else(|| ExchangeError::Parse("Missing deposit address".into()))?
.to_string();
let tag = data["addressTag"]
.as_str()
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let net = data["chain"]
.as_str()
.or(network)
.map(|s| s.to_string());
Ok(DepositAddress {
address,
tag,
network: net,
asset: asset.to_uppercase(),
created_at: None,
})
}
async fn withdraw(&self, req: WithdrawRequest) -> ExchangeResult<WithdrawResponse> {
let mut body = json!({
"address": req.address,
"amount": req.amount.to_string(),
"currency": req.asset.to_lowercase(),
});
if let Some(chain) = &req.network {
body["chain"] = json!(chain);
}
if let Some(tag) = &req.tag {
body["addr-tag"] = json!(tag);
}
let response = self.post(HtxEndpoint::Withdraw, body).await?;
let withdraw_id = response.get("data")
.and_then(|v| v.as_i64())
.map(|n| n.to_string())
.unwrap_or_else(|| "unknown".to_string());
Ok(WithdrawResponse {
withdraw_id,
status: "Pending".to_string(),
tx_hash: None,
})
}
async fn get_funds_history(
&self,
filter: FundsHistoryFilter,
) -> ExchangeResult<Vec<FundsRecord>> {
let mut records = Vec::new();
let mut base_params = HashMap::new();
if let Some(asset) = &filter.asset {
base_params.insert("currency".to_string(), asset.to_lowercase());
}
if let Some(limit) = filter.limit {
base_params.insert("size".to_string(), limit.to_string());
}
if matches!(filter.record_type, FundsRecordType::Deposit | FundsRecordType::Both) {
let mut params = base_params.clone();
params.insert("type".to_string(), "deposit".to_string());
let response = self.get(HtxEndpoint::DepositHistory, params).await?;
let data = HtxParser::extract_result_v1(&response)
.ok()
.and_then(|v| v.as_array().cloned())
.unwrap_or_default();
for item in &data {
let id = item["id"]
.as_i64().map(|n| n.to_string())
.or_else(|| item["id"].as_str().map(|s| s.to_string()))
.unwrap_or_default();
let asset = item["currency"].as_str().unwrap_or("").to_string();
let amount = item["amount"].as_f64().unwrap_or(0.0);
let tx_hash = item["txHash"].as_str().map(|s| s.to_string());
let network = item["chain"].as_str().map(|s| s.to_string());
let status = item["state"].as_str().unwrap_or("Unknown").to_string();
let timestamp = item["updatedAt"].as_i64()
.or_else(|| item["createAt"].as_i64())
.unwrap_or(0);
records.push(FundsRecord::Deposit {
id,
asset,
amount,
tx_hash,
network,
status,
timestamp,
});
}
}
if matches!(filter.record_type, FundsRecordType::Withdrawal | FundsRecordType::Both) {
let mut params = base_params;
params.insert("type".to_string(), "withdraw".to_string());
let response = self.get(HtxEndpoint::WithdrawHistory, params).await?;
let data = HtxParser::extract_result_v1(&response)
.ok()
.and_then(|v| v.as_array().cloned())
.unwrap_or_default();
for item in &data {
let id = item["id"]
.as_i64().map(|n| n.to_string())
.or_else(|| item["id"].as_str().map(|s| s.to_string()))
.unwrap_or_default();
let asset = item["currency"].as_str().unwrap_or("").to_string();
let amount = item["amount"].as_f64().unwrap_or(0.0);
let fee = item["fee"].as_f64();
let address = item["address"].as_str().unwrap_or("").to_string();
let tag = item["addressTag"].as_str()
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let tx_hash = item["txHash"].as_str().map(|s| s.to_string());
let network = item["chain"].as_str().map(|s| s.to_string());
let status = item["state"].as_str().unwrap_or("Unknown").to_string();
let timestamp = item["updatedAt"].as_i64()
.or_else(|| item["createAt"].as_i64())
.unwrap_or(0);
records.push(FundsRecord::Withdrawal {
id,
asset,
amount,
fee,
address,
tag,
tx_hash,
network,
status,
timestamp,
});
}
}
Ok(records)
}
}
#[async_trait]
impl SubAccounts for HtxConnector {
async fn sub_account_operation(
&self,
op: SubAccountOperation,
) -> ExchangeResult<SubAccountResult> {
match op {
SubAccountOperation::Create { label } => {
let body = json!({
"userList": [{"userName": label.clone()}]
});
let response = self.post(HtxEndpoint::SubAccountCreate, body).await?;
let data = response.get("data")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first());
let id = data.and_then(|d| d["uid"].as_i64()).map(|n| n.to_string());
Ok(SubAccountResult {
id,
name: Some(label),
accounts: vec![],
transaction_id: None,
})
}
SubAccountOperation::List => {
let response = self.get(HtxEndpoint::SubAccountList, HashMap::new()).await?;
let data = response.get("data")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let accounts = data.iter().map(|item| {
let id = item["uid"]
.as_i64().map(|n| n.to_string())
.unwrap_or_default();
let name = item["userName"].as_str().unwrap_or("").to_string();
let status = if item["userState"].as_str() == Some("lock") {
"Frozen".to_string()
} else {
"Normal".to_string()
};
SubAccount { id, name, status }
}).collect();
Ok(SubAccountResult {
id: None,
name: None,
accounts,
transaction_id: None,
})
}
SubAccountOperation::Transfer { sub_account_id, asset, amount, to_sub } => {
let transfer_type = if to_sub {
"master-transfer-out"
} else {
"master-transfer-in"
};
let sub_uid: i64 = sub_account_id.parse().map_err(|_| {
ExchangeError::InvalidRequest(
format!("HTX sub-account id must be numeric UID, got: {}", sub_account_id)
)
})?;
let body = json!({
"sub-uid": sub_uid,
"currency": asset.to_lowercase(),
"amount": amount.to_string(),
"type": transfer_type,
});
let response = self.post(HtxEndpoint::SubAccountTransfer, body).await?;
let tran_id = response.get("data")
.and_then(|v| v.as_i64())
.map(|n| n.to_string());
Ok(SubAccountResult {
id: None,
name: None,
accounts: vec![],
transaction_id: tran_id,
})
}
SubAccountOperation::GetBalance { sub_account_id } => {
let path = HtxEndpoint::SubAccountBalance.path_with_vars(&[("sub-uid", &sub_account_id)]);
let base_url = HtxUrls::base_url(self.testnet);
let auth = self.auth.as_ref()
.ok_or_else(|| ExchangeError::Auth("Authentication required".to_string()))?;
let query = auth.build_signed_query("GET", "api.huobi.pro", &path, &HashMap::new());
let url = format!("{}{}?{}", base_url, path, query);
self.rate_limit_wait(1, true).await;
let (response, resp_headers) = self.http.get_with_response_headers(&url, &HashMap::new(), &HashMap::new()).await?;
self.update_rate_from_headers(&resp_headers);
let _ = response;
Ok(SubAccountResult {
id: Some(sub_account_id),
name: None,
accounts: vec![],
transaction_id: None,
})
}
}
}
}
impl HtxConnector {
pub async fn get_order_match_results(&self, order_id: &str) -> ExchangeResult<Value> {
let path = HtxEndpoint::OrderMatchResults.path_with_vars(&[("order-id", order_id)]);
let auth = self.auth.as_ref()
.ok_or_else(|| ExchangeError::Auth("Authentication required".to_string()))?;
let query = auth.build_signed_query("GET", "api.huobi.pro", &path, &HashMap::new());
let base_url = HtxUrls::base_url(self.testnet);
let url = format!("{}{}?{}", base_url, path, query);
self.rate_limit_wait(1, true).await;
let (response, resp_headers) = self.http.get_with_response_headers(&url, &HashMap::new(), &HashMap::new()).await?;
self.update_rate_from_headers(&resp_headers);
Ok(response)
}
pub async fn get_open_interest(&self, contract_code: Option<&str>) -> ExchangeResult<Value> {
let mut params = HashMap::new();
if let Some(code) = contract_code {
params.insert("contract_code".to_string(), code.to_string());
}
self.get(HtxEndpoint::OpenInterest, params).await
}
pub async fn get_funding_rate_history(
&self,
contract_code: &str,
page_index: Option<u32>,
page_size: Option<u32>,
) -> ExchangeResult<Value> {
let mut params = HashMap::new();
params.insert("contract_code".to_string(), contract_code.to_string());
if let Some(idx) = page_index {
params.insert("page_index".to_string(), idx.to_string());
}
if let Some(size) = page_size {
params.insert("page_size".to_string(), size.to_string());
}
self.get(HtxEndpoint::FundingRateHistory, params).await
}
pub async fn get_mark_price(&self, contract_code: &str) -> ExchangeResult<Value> {
let mut params = HashMap::new();
params.insert("contract_code".to_string(), contract_code.to_string());
self.get(HtxEndpoint::MarkPrice, params).await
}
pub async fn get_elite_account_ratio(
&self,
contract_code: &str,
period: &str,
) -> ExchangeResult<Value> {
let mut params = HashMap::new();
params.insert("contract_code".to_string(), contract_code.to_string());
params.insert("period".to_string(), period.to_string());
self.get(HtxEndpoint::EliteAccountRatio, params).await
}
pub async fn get_historical_funding_rate(
&self,
contract_code: &str,
page_index: Option<u32>,
page_size: Option<u32>,
) -> ExchangeResult<Value> {
let mut params = HashMap::new();
params.insert("contract_code".to_string(), contract_code.to_string());
if let Some(idx) = page_index {
params.insert("page_index".to_string(), idx.to_string());
}
if let Some(size) = page_size {
params.insert("page_size".to_string(), size.to_string());
}
self.get(HtxEndpoint::HistoricalFundingRate, params).await
}
pub async fn get_mark_price_kline(
&self,
contract_code: &str,
period: &str,
size: Option<u32>,
) -> ExchangeResult<Value> {
let mut params = HashMap::new();
params.insert("contract_code".to_string(), contract_code.to_string());
params.insert("period".to_string(), period.to_string());
if let Some(s) = size {
params.insert("size".to_string(), s.to_string());
}
self.get(HtxEndpoint::MarkPriceKline, params).await
}
}
impl crate::core::traits::HasCapabilities for HtxConnector {
fn capabilities(&self) -> crate::core::types::ConnectorCapabilities {
crate::core::types::ConnectorCapabilities {
has_ticker: true, has_orderbook: true, has_klines: true,
has_recent_trades: false, has_exchange_info: true,
has_liquidation_history: false, has_open_interest_history: false,
has_premium_index: false, has_long_short_ratio_history: false,
has_funding_rate_history: false, has_mark_price_klines: false,
has_index_price_klines: false,
has_market_order: true, has_limit_order: true,
has_open_orders: true, has_order_history: true, has_user_trades: true,
has_positions: true, has_mark_price: true, has_modify_position: true,
has_closed_pnl: false, has_long_short_ratio: false,
has_cancel_all: true, has_amend_order: false,
has_batch_place: false, has_batch_cancel: true,
max_batch_place_size: 0, max_batch_cancel_size: 50,
has_balance: true, has_account_info: true, has_fees: true,
has_transfers: true, has_deposit_withdraw: true, has_sub_accounts: true,
has_funding_payments: false, has_ledger: false,
has_websocket: true, has_ws_klines: true, has_ws_trades: true,
has_ws_orderbook: true, has_ws_ticker: true,
has_ws_mark_price: false, has_ws_funding_rate: false,
validation: self.validation_status(),
}
}
fn validation_status(&self) -> Option<&'static crate::core::types::ValidationStamp> {
crate::core::utils::validation_snapshot::validation_for(crate::core::types::ExchangeId::HTX)
}
}