use hmac::{Hmac, KeyInit, Mac};
use rust_decimal::Decimal;
use serde::Deserialize;
use sha2::Sha256;
use crate::adapters::{inclusive_millis_at_or_after, inclusive_millis_before};
use crate::error::{Error, Result};
use crate::feature::Feature;
use crate::request::{HistoryRequest, MarginRequest, OrderRequest};
use crate::transport::{HttpMethod, HttpRequest};
use crate::types::{
Balance, Cursor, FundingPayment, FundingRate, MarginMode, MarginSummary, Market, Order,
OrderType, Page, Position, Side, Size, TimeInForce, Timestamp,
};
use super::{
API_KEY_HEADER, BinanceAdapter, BinanceCredentials, BinanceMarket, EXCHANGE, decode_cursor,
encode_cursor, now_millis, parse,
rest::{encode, query},
};
const MAX_HISTORY_LIMIT: u32 = 1_000;
const DEFAULT_HISTORY_LIMIT: u32 = 100;
fn signature(credentials: &BinanceCredentials, payload: &str) -> Result<String> {
let mut mac = Hmac::<Sha256>::new_from_slice(credentials.secret_key.as_bytes())
.map_err(|_| Error::auth("binance secret key cannot be used as an HMAC key"))?;
mac.update(payload.as_bytes());
Ok(hex::encode(mac.finalize().into_bytes()))
}
fn sign(credentials: &BinanceCredentials, payload: &str) -> Result<String> {
let signature = signature(credentials, payload)?;
Ok(if payload.is_empty() {
format!("signature={signature}")
} else {
format!("{payload}&signature={signature}")
})
}
pub(super) fn signed(
adapter: &BinanceAdapter,
method: HttpMethod,
path: &str,
mut params: Vec<(&str, String)>,
) -> Result<HttpRequest> {
let credentials = adapter.credentials()?;
params.push(("timestamp", now_millis().to_string()));
Ok(HttpRequest::new(method, path.to_string())
.query(sign(credentials, &query(¶ms))?)
.header(API_KEY_HEADER, credentials.api_key.clone()))
}
fn api_key_only(
adapter: &BinanceAdapter,
method: HttpMethod,
path: &str,
params: &[(&str, String)],
) -> Result<HttpRequest> {
let credentials = adapter.credentials()?;
Ok(HttpRequest::new(method, path.to_string())
.query(query(params))
.header(API_KEY_HEADER, credentials.api_key.clone()))
}
#[derive(Debug, Deserialize)]
struct RawSpotAccount {
balances: Vec<RawSpotBalance>,
}
#[derive(Debug, Deserialize)]
struct RawSpotBalance {
asset: String,
free: String,
locked: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawFuturesAccount {
total_margin_balance: String,
total_initial_margin: String,
available_balance: String,
assets: Vec<RawFuturesAsset>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawFuturesAsset {
asset: String,
wallet_balance: String,
available_balance: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawPosition {
symbol: String,
position_amt: String,
entry_price: String,
mark_price: String,
#[serde(rename = "unRealizedProfit")]
unrealized_profit: String,
notional: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawFundingRate {
funding_time: i64,
funding_rate: String,
mark_price: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawIncome {
income: String,
time: i64,
tran_id: serde_json::Number,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawListenKey {
listen_key: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawSpotOrderDetail {
#[serde(flatten)]
order: parse::RawOrder,
client_order_id: String,
#[serde(rename = "type")]
order_type: String,
time_in_force: String,
cummulative_quote_qty: String,
update_time: Option<i64>,
}
fn account_path(venue: BinanceMarket) -> &'static str {
match venue {
BinanceMarket::Spot => "/api/v3/account",
BinanceMarket::UsdMFutures => "/fapi/v3/account",
}
}
fn order_path(venue: BinanceMarket) -> &'static str {
match venue {
BinanceMarket::Spot => "/api/v3/order",
BinanceMarket::UsdMFutures => "/fapi/v1/order",
}
}
fn open_orders_path(venue: BinanceMarket) -> &'static str {
match venue {
BinanceMarket::Spot => "/api/v3/openOrders",
BinanceMarket::UsdMFutures => "/fapi/v1/openOrders",
}
}
const USD_M_LISTEN_KEY_PATH: &str = "/fapi/v1/listenKey";
pub(super) fn balances_request(adapter: &BinanceAdapter) -> Result<HttpRequest> {
signed(
adapter,
HttpMethod::Get,
account_path(adapter.venue()),
Vec::new(),
)
}
pub(super) fn open_orders_request(
adapter: &BinanceAdapter,
market: Option<&Market>,
) -> Result<HttpRequest> {
let mut params = Vec::new();
if let Some(market) = market {
params.push(("symbol", adapter.symbol(market)?));
}
signed(
adapter,
HttpMethod::Get,
open_orders_path(adapter.venue()),
params,
)
}
fn side_code(side: Side) -> &'static str {
match side {
Side::Buy => "BUY",
Side::Sell => "SELL",
}
}
fn order_shape(
venue: BinanceMarket,
request: &OrderRequest,
) -> Result<Vec<(&'static str, String)>> {
let post_only = request.time_in_force == Some(TimeInForce::PostOnly);
let mut params: Vec<(&'static str, String)> = Vec::new();
let order_type = match (&request.order_type, venue, post_only) {
(OrderType::Market, _, true) => {
return Err(Error::invalid_request(
"time_in_force",
"a market order takes liquidity by definition and cannot be post-only",
));
}
(OrderType::Market, _, false) => "MARKET",
(OrderType::Limit, BinanceMarket::Spot, true) => "LIMIT_MAKER",
(OrderType::Limit, _, _) => "LIMIT",
(OrderType::Best, BinanceMarket::UsdMFutures, _) => {
return Err(Error::unsupported(
Feature::Trading,
EXCHANGE,
"Binance USD-M has no best-price order type; use a market or explicitly priced limit order",
));
}
(OrderType::Best, BinanceMarket::Spot, _) => "LIMIT",
};
params.push(("type", order_type.to_string()));
match (&request.size, &request.order_type, venue) {
(Size::Base(quantity), _, _) => params.push(("quantity", quantity.to_string())),
(Size::Quote(amount), OrderType::Market, BinanceMarket::Spot) => {
params.push(("quoteOrderQty", amount.to_string()));
}
(Size::Quote(_), OrderType::Market, BinanceMarket::UsdMFutures) => {
return Err(Error::invalid_request(
"size",
"USD-M futures sizes every order in contracts; use Size::Base",
));
}
(Size::Quote(_), OrderType::Limit, _) => {
return Err(Error::invalid_request(
"size",
"binance sizes a limit order in the base asset; use Size::Base",
));
}
(Size::Quote(_), OrderType::Best, BinanceMarket::Spot) => {
return Err(Error::invalid_request(
"size",
"a Binance Spot best-price order uses base quantity; use Size::Base",
));
}
(_, OrderType::Best, BinanceMarket::UsdMFutures) => {
unreachable!("USD-M best orders returned above")
}
}
if matches!(&request.order_type, OrderType::Best) {
if request.price.is_some() {
return Err(Error::invalid_request(
"price",
"a Binance Spot best-price order gets its price from the opposing book",
));
}
if !matches!(
request.time_in_force,
Some(TimeInForce::ImmediateOrCancel | TimeInForce::FillOrKill)
) {
return Err(Error::invalid_request(
"time_in_force",
"a Binance Spot best-price order requires immediate-or-cancel or fill-or-kill",
));
}
params.push(("pegPriceType", "MARKET_PEG".to_string()));
} else if let Some(price) = request.price {
params.push(("price", price.to_string()));
}
if order_type == "LIMIT"
&& let Some(code) = time_in_force_code(venue, request.time_in_force)
{
params.push(("timeInForce", code.to_string()));
}
Ok(params)
}
fn time_in_force_code(venue: BinanceMarket, tif: Option<TimeInForce>) -> Option<&'static str> {
Some(match tif {
None | Some(TimeInForce::GoodTilCancelled) => "GTC",
Some(TimeInForce::ImmediateOrCancel) => "IOC",
Some(TimeInForce::FillOrKill) => "FOK",
Some(TimeInForce::PostOnly) => match venue {
BinanceMarket::UsdMFutures => "GTX",
BinanceMarket::Spot => return None,
},
})
}
pub(super) fn place_order_request(
adapter: &BinanceAdapter,
request: &OrderRequest,
) -> Result<HttpRequest> {
let venue = adapter.venue();
if request.reduce_only && venue == BinanceMarket::Spot {
return Err(Error::unsupported(
Feature::ReduceOnlyOrders,
EXCHANGE,
"spot holds no positions to reduce",
));
}
let mut params = vec![
("symbol", adapter.symbol(&request.market)?),
("side", side_code(request.side).to_string()),
];
params.extend(order_shape(venue, request)?);
if request.reduce_only {
params.push(("reduceOnly", "true".to_string()));
}
if let Some(client_id) = &request.client_id {
validate_client_order_id(client_id)?;
params.push(("newClientOrderId", client_id.clone()));
}
params.push(("newOrderRespType", "RESULT".to_string()));
signed(adapter, HttpMethod::Post, order_path(venue), params)
}
fn validate_client_order_id(value: &str) -> Result<()> {
if (1..=36).contains(&value.len())
&& value.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'/' | b':' | b'_' | b'-')
})
{
return Ok(());
}
Err(Error::invalid_request(
"client_id",
"a Binance client order id must contain 1-36 ASCII letters, digits, '.', '/', ':', '_' or '-'",
))
}
pub(super) fn cancel_order_request(
adapter: &BinanceAdapter,
market: &Market,
order_id: &str,
) -> Result<HttpRequest> {
check_order_id(order_id)?;
let params = vec![
("symbol", adapter.symbol(market)?),
("orderId", order_id.to_string()),
];
signed(
adapter,
HttpMethod::Delete,
order_path(adapter.venue()),
params,
)
}
pub(super) fn cancel_order_by_client_id_request(
adapter: &BinanceAdapter,
market: &Market,
client_id: &str,
) -> Result<HttpRequest> {
validate_client_order_id(client_id)?;
let params = vec![
("symbol", adapter.symbol(market)?),
("origClientOrderId", client_id.to_string()),
];
signed(
adapter,
HttpMethod::Delete,
order_path(adapter.venue()),
params,
)
}
fn check_order_id(order_id: &str) -> Result<()> {
if !order_id.is_empty() && order_id.bytes().all(|byte| byte.is_ascii_digit()) {
return Ok(());
}
Err(Error::invalid_request(
"order_id",
format!("`{order_id}` is not a Binance order id: expected decimal digits"),
))
}
pub(super) fn positions_request(
adapter: &BinanceAdapter,
market: Option<&Market>,
) -> Result<HttpRequest> {
check_futures_only(adapter, Feature::Positions)?;
let mut params = Vec::new();
if let Some(market) = market {
params.push(("symbol", adapter.symbol(market)?));
}
signed(adapter, HttpMethod::Get, "/fapi/v3/positionRisk", params)
}
pub(super) fn funding_rates_request(
adapter: &BinanceAdapter,
request: &HistoryRequest,
) -> Result<HttpRequest> {
check_futures_only(adapter, Feature::FundingRates)?;
let mut params = vec![("symbol", adapter.symbol(&request.market)?)];
params.extend(history_window(request)?);
Ok(HttpRequest::get("/fapi/v1/fundingRate").query(query(¶ms)))
}
pub(super) fn funding_payments_request(
adapter: &BinanceAdapter,
request: &HistoryRequest,
) -> Result<HttpRequest> {
check_futures_only(adapter, Feature::FundingPayments)?;
let mut params = vec![
("symbol", adapter.symbol(&request.market)?),
("incomeType", "FUNDING_FEE".to_string()),
];
params.extend(history_window(request)?);
signed(adapter, HttpMethod::Get, "/fapi/v1/income", params)
}
fn history_window(request: &HistoryRequest) -> Result<Vec<(&'static str, String)>> {
let limit = request.limit.unwrap_or(DEFAULT_HISTORY_LIMIT);
if !(1..=MAX_HISTORY_LIMIT).contains(&limit) {
return Err(Error::invalid_request(
"limit",
format!(
"binance serves 1 to {MAX_HISTORY_LIMIT} history entries per page, not {limit}"
),
));
}
let start = match &request.cursor {
Some(cursor) => Some(decode_cursor(cursor)?),
None => request.from.map(inclusive_millis_at_or_after),
};
let mut params = Vec::new();
if let Some(start) = start {
params.push(("startTime", start.to_string()));
}
if let Some(to) = request.to {
params.push(("endTime", inclusive_millis_before(to).to_string()));
}
params.push(("limit", limit.to_string()));
Ok(params)
}
pub(super) fn set_margin_requests(
adapter: &BinanceAdapter,
request: &MarginRequest,
) -> Result<Vec<HttpRequest>> {
check_futures_only(adapter, Feature::MarginConfig)?;
if request.leverage.is_none() && request.margin_mode.is_none() {
return Err(Error::invalid_request(
"leverage",
"set at least one of leverage or margin mode",
));
}
let symbol = adapter.symbol(&request.market)?;
let mut requests = Vec::with_capacity(2);
if let Some(leverage) = request.leverage {
requests.push(signed(
adapter,
HttpMethod::Post,
"/fapi/v1/leverage",
vec![
("symbol", symbol.clone()),
("leverage", leverage_code(leverage)?),
],
)?);
}
if let Some(mode) = request.margin_mode {
requests.push(signed(
adapter,
HttpMethod::Post,
"/fapi/v1/marginType",
vec![
("symbol", symbol),
(
"marginType",
match mode {
MarginMode::Cross => "CROSSED".to_string(),
MarginMode::Isolated => "ISOLATED".to_string(),
},
),
],
)?);
}
Ok(requests)
}
fn leverage_code(leverage: Decimal) -> Result<String> {
if leverage.fract() != Decimal::ZERO || leverage < Decimal::ONE {
return Err(Error::invalid_request(
"leverage",
format!("binance takes whole leverage multipliers from 1 upwards, not {leverage}"),
));
}
Ok(leverage.trunc().to_string())
}
fn check_futures_only(adapter: &BinanceAdapter, feature: Feature) -> Result<()> {
if adapter.venue() == BinanceMarket::UsdMFutures {
return Ok(());
}
Err(Error::unsupported(
feature,
EXCHANGE,
"binance spot is not a derivatives venue; build the adapter with `usd_m_futures`",
))
}
pub(super) async fn balances(adapter: &BinanceAdapter) -> Result<Vec<Balance>> {
let body = adapter.send(balances_request(adapter)?).await?;
match adapter.venue() {
BinanceMarket::Spot => parse::json::<RawSpotAccount>(&body, "account")?
.balances
.iter()
.map(|raw| {
Ok(Balance {
asset: raw.asset.to_ascii_uppercase(),
available: parse::decimal(&raw.free, "free")?,
locked: parse::decimal(&raw.locked, "locked")?,
})
})
.collect(),
BinanceMarket::UsdMFutures => parse::json::<RawFuturesAccount>(&body, "account")?
.assets
.iter()
.map(futures_balance)
.collect(),
}
}
fn futures_balance(raw: &RawFuturesAsset) -> Result<Balance> {
let wallet = parse::decimal(&raw.wallet_balance, "walletBalance")?;
let available = parse::decimal(&raw.available_balance, "availableBalance")?;
Ok(Balance {
asset: raw.asset.to_ascii_uppercase(),
available,
locked: (wallet - available).max(Decimal::ZERO),
})
}
pub(super) async fn open_orders(
adapter: &BinanceAdapter,
market: Option<&Market>,
) -> Result<Vec<Order>> {
let body = adapter.send(open_orders_request(adapter, market)?).await?;
parse::json::<Vec<parse::RawOrder>>(&body, "openOrders")?
.iter()
.map(|raw| {
let market = adapter.market(&raw.symbol)?;
parse::order(&market, raw)
})
.collect()
}
pub(super) async fn place_order(adapter: &BinanceAdapter, request: &OrderRequest) -> Result<Order> {
let body = adapter.send(place_order_request(adapter, request)?).await?;
let raw: parse::RawOrder = parse::json(&body, "order")?;
parse::order(&request.market, &raw)
}
pub(super) async fn cancel_order(
adapter: &BinanceAdapter,
market: &Market,
order_id: &str,
) -> Result<()> {
let body = adapter
.send(cancel_order_request(adapter, market, order_id)?)
.await?;
let raw: parse::RawOrder = parse::json(&body, "order")?;
parse::order(market, &raw).map(drop)
}
pub(super) async fn cancel_order_by_client_id(
adapter: &BinanceAdapter,
market: &Market,
client_id: &str,
) -> Result<()> {
let body = adapter
.send(cancel_order_by_client_id_request(
adapter, market, client_id,
)?)
.await?;
let raw: parse::RawOrder = parse::json(&body, "order")?;
parse::order(market, &raw).map(drop)
}
pub(super) async fn positions(
adapter: &BinanceAdapter,
market: Option<&Market>,
) -> Result<Vec<Position>> {
let body = adapter.send(positions_request(adapter, market)?).await?;
let raw: Vec<RawPosition> = parse::json(&body, "positionRisk")?;
raw.iter().map(|raw| position(adapter, raw)).collect()
}
fn position(adapter: &BinanceAdapter, raw: &RawPosition) -> Result<Position> {
let signed_quantity = parse::decimal(&raw.position_amt, "positionAmt")?;
Ok(Position {
market: adapter.market(&raw.symbol)?,
side: if signed_quantity.is_zero() {
None
} else if signed_quantity.is_sign_negative() {
Some(Side::Sell)
} else {
Some(Side::Buy)
},
quantity: signed_quantity.abs(),
entry_price: parse::decimal_or_none(&raw.entry_price, "entryPrice")?,
mark_price: parse::decimal_or_none(&raw.mark_price, "markPrice")?,
notional: Some(parse::decimal(&raw.notional, "notional")?.abs()),
unrealized_pnl: Some(parse::decimal(&raw.unrealized_profit, "unRealizedProfit")?),
leverage: None,
margin_mode: None,
})
}
pub(super) async fn margin_summary(adapter: &BinanceAdapter) -> Result<MarginSummary> {
check_futures_only(adapter, Feature::Margin)?;
let body = adapter.send(balances_request(adapter)?).await?;
margin_summary_of(&parse::json(&body, "account")?)
}
fn margin_summary_of(raw: &RawFuturesAccount) -> Result<MarginSummary> {
Ok(MarginSummary {
asset: "USDT".to_string(),
equity: Some(parse::decimal(
&raw.total_margin_balance,
"totalMarginBalance",
)?),
margin_balance: Some(parse::decimal(
&raw.total_initial_margin,
"totalInitialMargin",
)?),
available_balance: Some(parse::decimal(&raw.available_balance, "availableBalance")?),
})
}
pub(super) async fn funding_rates(
adapter: &BinanceAdapter,
request: &HistoryRequest,
) -> Result<Page<FundingRate>> {
let body = adapter
.send(funding_rates_request(adapter, request)?)
.await?;
let raw: Vec<RawFundingRate> = parse::json(&body, "fundingRate")?;
let items = raw
.iter()
.map(|entry| {
Ok(FundingRate {
market: request.market.clone(),
timestamp: parse::millis(entry.funding_time),
rate: parse::decimal(&entry.funding_rate, "fundingRate")?,
mark_price: entry
.mark_price
.as_deref()
.map(|price| parse::decimal_or_none(price, "markPrice"))
.transpose()?
.flatten(),
})
})
.collect::<Result<Vec<_>>>()?;
Ok(Page {
next: next_cursor(
request,
items.len(),
raw.last().map(|entry| entry.funding_time),
),
items,
})
}
pub(super) async fn funding_payments(
adapter: &BinanceAdapter,
request: &HistoryRequest,
) -> Result<Page<FundingPayment>> {
let body = adapter
.send(funding_payments_request(adapter, request)?)
.await?;
let raw: Vec<RawIncome> = parse::json(&body, "income")?;
let items = raw
.iter()
.map(|entry| {
Ok(FundingPayment {
market: request.market.clone(),
timestamp: parse::millis(entry.time),
amount: parse::decimal(&entry.income, "income")?,
rate: None,
id: Some(entry.tran_id.to_string()),
})
})
.collect::<Result<Vec<_>>>()?;
Ok(Page {
next: next_cursor(request, items.len(), raw.last().map(|entry| entry.time)),
items,
})
}
fn next_cursor(
request: &HistoryRequest,
returned: usize,
last_millis: Option<i64>,
) -> Option<Cursor> {
let limit = request.limit.unwrap_or(DEFAULT_HISTORY_LIMIT) as usize;
if returned < limit {
return None;
}
last_millis.map(|millis| encode_cursor(millis.saturating_add(1)))
}
pub(super) async fn set_margin(adapter: &BinanceAdapter, request: &MarginRequest) -> Result<()> {
for request in set_margin_requests(adapter, request)? {
adapter.send(request).await?;
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct BinanceSpotOrderDetail {
pub order: Order,
pub client_order_id: String,
pub order_type: String,
pub time_in_force: String,
pub filled_quote_quantity: Decimal,
pub updated_at: Option<Timestamp>,
}
pub(super) async fn spot_order(
adapter: &BinanceAdapter,
market: &Market,
order_id: &str,
) -> Result<BinanceSpotOrderDetail> {
if adapter.venue() != BinanceMarket::Spot {
return Err(Error::unsupported(
Feature::OpenOrders,
EXCHANGE,
"this lookup reads a spot order; build the adapter with `spot`",
));
}
check_order_id(order_id)?;
let request = signed(
adapter,
HttpMethod::Get,
"/api/v3/order",
vec![
("symbol", adapter.symbol(market)?),
("orderId", order_id.to_string()),
],
)?;
let body = adapter.send(request).await?;
let raw: RawSpotOrderDetail = parse::json(&body, "order")?;
Ok(BinanceSpotOrderDetail {
order: parse::order(market, &raw.order)?,
client_order_id: raw.client_order_id,
order_type: raw.order_type,
time_in_force: raw.time_in_force,
filled_quote_quantity: parse::decimal(&raw.cummulative_quote_qty, "cummulativeQuoteQty")?,
updated_at: raw.update_time.map(parse::millis),
})
}
#[derive(Clone, PartialEq, Eq)]
pub struct BinanceListenKey(String);
impl BinanceListenKey {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for BinanceListenKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("BinanceListenKey")
.field(&"<redacted>")
.finish()
}
}
pub(super) async fn create_listen_key(adapter: &BinanceAdapter) -> Result<BinanceListenKey> {
let request = api_key_only(adapter, HttpMethod::Post, USD_M_LISTEN_KEY_PATH, &[])?;
let body = adapter.send(request).await?;
let raw: RawListenKey = parse::json(&body, "listenKey")?;
if raw.listen_key.trim().is_empty() {
return Err(Error::decode("binance returned an empty listen key"));
}
Ok(BinanceListenKey(raw.listen_key))
}
pub(super) fn keepalive_listen_key_request(adapter: &BinanceAdapter) -> Result<HttpRequest> {
api_key_only(adapter, HttpMethod::Put, USD_M_LISTEN_KEY_PATH, &[])
}
pub(super) async fn keepalive_listen_key(adapter: &BinanceAdapter) -> Result<()> {
let request = keepalive_listen_key_request(adapter)?;
adapter.send(request).await.map(|_| ())
}
pub(super) async fn close_listen_key(adapter: &BinanceAdapter) -> Result<()> {
let request = close_listen_key_request(adapter)?;
adapter.send(request).await.map(|_| ())
}
pub(super) fn close_listen_key_request(adapter: &BinanceAdapter) -> Result<HttpRequest> {
api_key_only(adapter, HttpMethod::Delete, USD_M_LISTEN_KEY_PATH, &[])
}
pub(super) const USD_M_ACCOUNT_EVENTS: &str = "ORDER_TRADE_UPDATE/ACCOUNT_UPDATE/listenKeyExpired";
pub(super) fn usd_m_user_data_stream_url(key: &BinanceListenKey) -> String {
format!(
"wss://fstream.binance.com/private/ws?listenKey={}&events={USD_M_ACCOUNT_EVENTS}",
encode(&key.0)
)
}
pub(super) fn spot_user_data_subscribe_frame(adapter: &BinanceAdapter) -> Result<String> {
let credentials = adapter.credentials()?;
let timestamp = now_millis();
let payload = format!(
"apiKey={}&recvWindow={SPOT_SUBSCRIBE_RECV_WINDOW_MS}×tamp={timestamp}",
credentials.api_key
);
Ok(serde_json::json!({
"id": timestamp.to_string(),
"method": "userDataStream.subscribe.signature",
"params": {
"apiKey": credentials.api_key,
"recvWindow": SPOT_SUBSCRIBE_RECV_WINDOW_MS,
"timestamp": timestamp,
"signature": signature(credentials, &payload)?,
},
})
.to_string())
}
const SPOT_SUBSCRIBE_RECV_WINDOW_MS: u64 = 60_000;
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Exchange, MarketKind, OrderStatus};
const DOC_SECRET_KEY: &str = "NhqPtmdSJYdKjVHjA7PZj4Mge3R5YNiP1e3UZjInClVN65XAbvqqM6A7H5fATj0j";
const DOC_QUERY: &str = "symbol=LTCBTC&side=BUY&type=LIMIT&timeInForce=GTC&quantity=1&price=0.1&recvWindow=5000×tamp=1499827319559";
const DOC_SIGNATURE: &str = "c8db56825ae71d6d79447849e617115f4a920fa2acdcab2b053c4b2838bd6b71";
fn spot() -> BinanceAdapter {
BinanceAdapter::spot().with_credentials("key", "secret")
}
fn perp() -> BinanceAdapter {
BinanceAdapter::usd_m_futures().with_credentials("key", "secret")
}
fn btc_usdt() -> Market {
Market::spot(Exchange::Binance, "BTC", "USDT")
}
fn btc_usdt_perp() -> Market {
Market::perpetual(Exchange::Binance, "BTC", "USDT")
}
fn signed_params(request: &HttpRequest) -> String {
let query = request.target();
let (params, _) = query.split_once("&signature=").expect("a signed query");
let (params, _) = params.rsplit_once("×tamp=").expect("a stamped query");
params.to_string()
}
#[test]
fn the_signature_matches_binances_own_worked_example() {
let credentials = BinanceCredentials {
api_key: "vmPUZE6mv9SD5VNHk4HlWFsOr6aKE2zvsw0MuIgwCIPy6utIco14y7Ju91duEh8A".to_string(),
secret_key: DOC_SECRET_KEY.to_string(),
};
let signed = sign(&credentials, DOC_QUERY).expect("a signature");
assert_eq!(signed, format!("{DOC_QUERY}&signature={DOC_SIGNATURE}"));
}
#[test]
fn an_empty_query_still_carries_a_signature() {
let credentials = BinanceCredentials {
api_key: "key".to_string(),
secret_key: DOC_SECRET_KEY.to_string(),
};
let signed = sign(&credentials, "").expect("a signature");
assert!(signed.starts_with("signature="));
assert!(!signed.starts_with("signature=&"));
assert_eq!(signed.len(), "signature=".len() + 64);
}
#[test]
fn every_signed_request_carries_the_api_key_header() {
let request = balances_request(&spot()).expect("credentials are set");
assert!(
request
.headers
.iter()
.any(|(name, value)| name == API_KEY_HEADER && value == "key")
);
assert!(request.target().starts_with("/api/v3/account?"));
assert!(request.target().contains("&signature="));
}
#[test]
fn an_unauthenticated_adapter_never_builds_a_private_request() {
let public = BinanceAdapter::spot();
assert!(matches!(balances_request(&public), Err(Error::Auth { .. })));
assert!(matches!(
open_orders_request(&public, None),
Err(Error::Auth { .. })
));
}
#[test]
fn the_two_venues_read_the_same_questions_at_different_paths() {
assert_eq!(account_path(BinanceMarket::Spot), "/api/v3/account");
assert_eq!(account_path(BinanceMarket::UsdMFutures), "/fapi/v3/account");
assert_eq!(open_orders_path(BinanceMarket::Spot), "/api/v3/openOrders");
assert_eq!(
open_orders_path(BinanceMarket::UsdMFutures),
"/fapi/v1/openOrders"
);
assert!(
balances_request(&perp())
.expect("credentials are set")
.target()
.starts_with("/fapi/v3/account?")
);
assert!(
open_orders_request(&perp(), Some(&btc_usdt_perp()))
.expect("a futures market")
.target()
.starts_with("/fapi/v1/openOrders?symbol=BTCUSDT")
);
}
#[test]
fn a_spot_market_buy_can_be_sized_in_the_quote_asset_and_a_futures_one_cannot() {
let quote_sized =
OrderRequest::market(btc_usdt(), Side::Buy, Size::Quote(Decimal::from(10_000)));
let futures_quote_sized = OrderRequest::market(
btc_usdt_perp(),
Side::Buy,
Size::Quote(Decimal::from(10_000)),
);
assert_eq!(
signed_params(&place_order_request(&spot(), "e_sized).expect("a spot order")),
"/api/v3/order?symbol=BTCUSDT&side=BUY&type=MARKET"eOrderQty=10000&newOrderRespType=RESULT"
);
assert!(matches!(
place_order_request(&perp(), &futures_quote_sized),
Err(Error::InvalidRequest { field, .. }) if field == "size"
));
}
#[test]
fn post_only_is_an_order_type_on_spot_and_a_time_in_force_on_futures() {
let spot_order = OrderRequest::limit(
btc_usdt(),
Side::Sell,
Size::Base(Decimal::new(1, 2)),
Decimal::from(100_000),
)
.time_in_force(TimeInForce::PostOnly);
let futures_order = OrderRequest::limit(
btc_usdt_perp(),
Side::Sell,
Size::Base(Decimal::new(1, 2)),
Decimal::from(100_000),
)
.time_in_force(TimeInForce::PostOnly);
assert_eq!(
signed_params(&place_order_request(&spot(), &spot_order).expect("a spot order")),
"/api/v3/order?symbol=BTCUSDT&side=SELL&type=LIMIT_MAKER&quantity=0.01&price=100000&newOrderRespType=RESULT"
);
assert_eq!(
signed_params(&place_order_request(&perp(), &futures_order).expect("a futures order")),
"/fapi/v1/order?symbol=BTCUSDT&side=SELL&type=LIMIT&quantity=0.01&price=100000&timeInForce=GTX&newOrderRespType=RESULT"
);
}
#[test]
fn a_limit_order_defaults_to_the_time_in_force_binance_itself_defaults_to() {
let order = OrderRequest::limit(
btc_usdt(),
Side::Buy,
Size::Base(Decimal::ONE),
Decimal::from(50_000),
);
assert!(
signed_params(&place_order_request(&spot(), &order).expect("a spot order"))
.contains("&timeInForce=GTC")
);
assert_eq!(
time_in_force_code(BinanceMarket::Spot, Some(TimeInForce::FillOrKill)),
Some("FOK")
);
assert_eq!(
time_in_force_code(BinanceMarket::Spot, Some(TimeInForce::ImmediateOrCancel)),
Some("IOC")
);
}
#[test]
fn a_market_order_carries_no_time_in_force() {
let order = OrderRequest::market(btc_usdt(), Side::Buy, Size::Base(Decimal::ONE));
assert!(
!signed_params(&place_order_request(&spot(), &order).expect("a spot order"))
.contains("timeInForce")
);
assert!(matches!(
place_order_request(&spot(), &order.clone().time_in_force(TimeInForce::PostOnly)),
Err(Error::InvalidRequest { field, .. }) if field == "time_in_force"
));
}
#[test]
fn reduce_only_reaches_futures_and_is_refused_on_spot() {
let futures_order =
OrderRequest::market(btc_usdt_perp(), Side::Sell, Size::Base(Decimal::ONE))
.reduce_only();
let spot_order =
OrderRequest::market(btc_usdt(), Side::Sell, Size::Base(Decimal::ONE)).reduce_only();
assert_eq!(
signed_params(&place_order_request(&perp(), &futures_order).expect("a futures order")),
"/fapi/v1/order?symbol=BTCUSDT&side=SELL&type=MARKET&quantity=1&reduceOnly=true&newOrderRespType=RESULT"
);
assert!(matches!(
place_order_request(&spot(), &spot_order),
Err(Error::Unsupported {
feature: Feature::ReduceOnlyOrders,
..
})
));
}
#[test]
fn an_order_id_that_is_not_binances_never_reaches_a_cancel() {
assert_eq!(
signed_params(&cancel_order_request(&spot(), &btc_usdt(), "28").expect("a numeric id")),
"/api/v3/order?symbol=BTCUSDT&orderId=28"
);
for bad in ["", "abc", "28&symbol=ETHUSDT", "-1", "2 8"] {
assert!(
matches!(
cancel_order_request(&spot(), &btc_usdt(), bad),
Err(Error::InvalidRequest { field, .. }) if field == "order_id"
),
"{bad}"
);
}
}
#[test]
fn a_client_order_id_cancels_through_its_own_parameter() {
assert_eq!(
signed_params(
&cancel_order_by_client_id_request(&spot(), &btc_usdt(), "client-1")
.expect("a client order id"),
),
"/api/v3/order?symbol=BTCUSDT&origClientOrderId=client-1"
);
}
#[test]
fn a_spot_best_order_uses_the_opposing_book_peg() {
let request = OrderRequest::best(
btc_usdt(),
Side::Buy,
Size::Base(Decimal::new(1, 2)),
TimeInForce::ImmediateOrCancel,
)
.client_id("client/1");
assert_eq!(
signed_params(&place_order_request(&spot(), &request).expect("a pegged order")),
"/api/v3/order?symbol=BTCUSDT&side=BUY&type=LIMIT&quantity=0.01&pegPriceType=MARKET_PEG&timeInForce=IOC&newClientOrderId=client%2F1&newOrderRespType=RESULT"
);
assert!(matches!(
cancel_order_by_client_id_request(&spot(), &btc_usdt(), "bad&client"),
Err(Error::InvalidRequest { field, .. }) if field == "client_id"
));
}
#[test]
fn every_derivatives_call_is_refused_on_a_spot_adapter() {
let history = HistoryRequest::new(btc_usdt());
let margin = MarginRequest::new(btc_usdt()).leverage(Decimal::from(10));
assert!(matches!(
positions_request(&spot(), None),
Err(Error::Unsupported {
feature: Feature::Positions,
..
})
));
assert!(matches!(
funding_rates_request(&spot(), &history),
Err(Error::Unsupported {
feature: Feature::FundingRates,
..
})
));
assert!(matches!(
funding_payments_request(&spot(), &history),
Err(Error::Unsupported {
feature: Feature::FundingPayments,
..
})
));
assert!(matches!(
set_margin_requests(&spot(), &margin),
Err(Error::Unsupported {
feature: Feature::MarginConfig,
..
})
));
}
#[test]
fn funding_rate_history_is_public_and_funding_payments_are_not() {
let request = HistoryRequest::new(btc_usdt_perp())
.from(Timestamp::from_millis(1_570_608_000_000))
.to(Timestamp::from_millis(1_570_636_800_000))
.limit(1_000);
let rates = funding_rates_request(&perp(), &request).expect("a futures market");
let payments = funding_payments_request(&perp(), &request).expect("a futures market");
assert_eq!(
rates.target(),
"/fapi/v1/fundingRate?symbol=BTCUSDT&startTime=1570608000000&endTime=1570636799999&limit=1000"
);
assert!(rates.headers.is_empty());
assert!(
signed_params(&payments)
.starts_with("/fapi/v1/income?symbol=BTCUSDT&incomeType=FUNDING_FEE")
);
assert!(
payments
.headers
.iter()
.any(|(name, _)| name == API_KEY_HEADER)
);
let sub_millisecond_end = HistoryRequest::new(btc_usdt_perp())
.to(Timestamp::from_nanos(1_570_636_800_000_000_001));
assert!(
funding_rates_request(&perp(), &sub_millisecond_end)
.expect("a sub-millisecond exclusive end")
.target()
.contains("endTime=1570636800000")
);
let sub_millisecond_start = HistoryRequest::new(btc_usdt_perp())
.from(Timestamp::from_nanos(1_570_608_000_000_000_001));
assert!(
funding_rates_request(&perp(), &sub_millisecond_start)
.expect("a sub-millisecond inclusive start")
.target()
.contains("startTime=1570608000001")
);
}
#[test]
fn a_cursor_supersedes_the_requested_start_and_resumes_past_the_last_entry() {
let resumed = HistoryRequest::new(btc_usdt_perp())
.from(Timestamp::from_millis(1))
.cursor(encode_cursor(1_570_636_800_001));
assert_eq!(
funding_rates_request(&perp(), &resumed)
.expect("a futures market")
.target(),
"/fapi/v1/fundingRate?symbol=BTCUSDT&startTime=1570636800001&limit=100"
);
assert!(matches!(
funding_rates_request(
&perp(),
&HistoryRequest::new(btc_usdt_perp()).cursor(Cursor("page-2".to_string()))
),
Err(Error::InvalidRequest { field, .. }) if field == "cursor"
));
}
#[test]
fn a_short_page_ends_the_history_and_a_full_one_offers_another() {
let request = HistoryRequest::new(btc_usdt_perp()).limit(2);
assert_eq!(next_cursor(&request, 1, Some(1_000)), None);
assert_eq!(
next_cursor(&request, 2, Some(1_000)),
Some(encode_cursor(1_001))
);
let defaulted = HistoryRequest::new(btc_usdt_perp());
assert_eq!(next_cursor(&defaulted, 99, Some(1_000)), None);
assert_eq!(
next_cursor(&defaulted, 100, Some(1_000)),
Some(encode_cursor(1_001))
);
}
#[test]
fn a_history_page_larger_than_binance_serves_is_refused() {
let request = HistoryRequest::new(btc_usdt_perp()).limit(1_001);
assert!(matches!(
funding_rates_request(&perp(), &request),
Err(Error::InvalidRequest { field, .. }) if field == "limit"
));
}
#[test]
fn margin_changes_go_to_one_endpoint_each() {
let both = MarginRequest::new(btc_usdt_perp())
.leverage(Decimal::from(10))
.margin_mode(MarginMode::Cross);
let requests = set_margin_requests(&perp(), &both).expect("a futures market");
assert_eq!(requests.len(), 2);
assert_eq!(
signed_params(&requests[0]),
"/fapi/v1/leverage?symbol=BTCUSDT&leverage=10"
);
assert_eq!(
signed_params(&requests[1]),
"/fapi/v1/marginType?symbol=BTCUSDT&marginType=CROSSED"
);
}
#[test]
fn a_margin_request_that_changes_nothing_is_a_caller_mistake() {
assert!(matches!(
set_margin_requests(&perp(), &MarginRequest::new(btc_usdt_perp())),
Err(Error::InvalidRequest { field, .. }) if field == "leverage"
));
assert!(matches!(
set_margin_requests(
&perp(),
&MarginRequest::new(btc_usdt_perp()).leverage(Decimal::new(15, 1))
),
Err(Error::InvalidRequest { field, .. }) if field == "leverage"
));
assert_eq!(
leverage_code(Decimal::from(125)).expect("a multiplier"),
"125"
);
}
#[test]
fn a_futures_balance_splits_the_wallet_into_free_and_posted() {
let raw: RawFuturesAccount = parse::json(
r#"{
"totalInitialMargin": "0.00000000",
"totalMaintMargin": "0.00000000",
"totalWalletBalance": "126.72469206",
"totalUnrealizedProfit": "0.00000000",
"totalMarginBalance": "126.72469206",
"availableBalance": "100.12345678",
"assets": [
{
"asset": "USDT",
"walletBalance": "126.72469206",
"unrealizedProfit": "0.00000000",
"marginBalance": "126.72469206",
"availableBalance": "100.12345678",
"updateTime": 1625474304765
}
],
"positions": []
}"#,
"account",
)
.expect("official account payload");
let balance = futures_balance(&raw.assets[0]).expect("a balance");
assert_eq!(balance.asset, "USDT");
assert_eq!(balance.available.to_string(), "100.12345678");
assert_eq!(balance.locked.to_string(), "26.60123528");
assert_eq!(balance.total().to_string(), "126.72469206");
}
#[test]
fn a_margin_summary_reads_each_figure_off_the_field_that_means_it() {
let raw: RawFuturesAccount = parse::json(
r#"{
"totalInitialMargin": "250.00000000",
"totalMaintMargin": "50.00000000",
"totalWalletBalance": "1000.00000000",
"totalUnrealizedProfit": "12.50000000",
"totalMarginBalance": "1012.50000000",
"availableBalance": "750.00000000",
"assets": [],
"positions": []
}"#,
"account",
)
.expect("official account payload");
let summary = margin_summary_of(&raw).expect("a summary");
assert_eq!(summary.asset, "USDT");
assert_eq!(summary.equity, Some(Decimal::new(101_250, 2)));
assert_eq!(summary.margin_balance, Some(Decimal::from(250)));
assert_eq!(summary.available_balance, Some(Decimal::from(750)));
assert_eq!(
summary
.equity
.zip(summary.margin_balance)
.map(|(e, m)| e - m),
Some(Decimal::new(76_250, 2))
);
}
#[test]
fn a_position_takes_its_direction_from_the_sign_of_its_size() {
let raw: Vec<RawPosition> = parse::json(
r#"[
{
"symbol": "BTCUSDT",
"positionSide": "BOTH",
"positionAmt": "-1.000",
"entryPrice": "0.00000",
"breakEvenPrice": "0.0",
"markPrice": "6679.50671178",
"unRealizedProfit": "0.00000000",
"liquidationPrice": "0",
"isolatedMargin": "0.00000000",
"notional": "-6679.50671178",
"marginAsset": "USDT",
"isolatedWallet": "0",
"initialMargin": "0",
"maintMargin": "0",
"positionInitialMargin": "0",
"openOrderInitialMargin": "0",
"adl": 0,
"bidNotional": "0",
"askNotional": "0",
"updateTime": 0
}
]"#,
"positionRisk",
)
.expect("official position payload");
let position = position(&perp(), &raw[0]).expect("a position");
assert_eq!(position.side, Some(Side::Sell));
assert_eq!(position.quantity.to_string(), "1.000");
assert_eq!(
position.notional.expect("a notional").to_string(),
"6679.50671178"
);
assert_eq!(position.entry_price, None);
assert_eq!(position.market.kind, MarketKind::Perpetual);
assert_eq!(position.leverage, None);
assert_eq!(position.margin_mode, None);
assert_eq!(crate::client::open_positions(vec![position]).len(), 1);
}
const POSITION_RISK_WITH_A_RESTING_ORDER: &str = r#"[{
"symbol": "XRPUSDT",
"positionSide": "BOTH",
"positionAmt": "0.0",
"entryPrice": "0.0",
"markPrice": "1.08710784",
"unRealizedProfit": "0.00000000",
"notional": "0"
}]"#;
#[test]
fn a_symbol_with_only_a_resting_order_is_not_reported_as_a_position() {
let raw: Vec<RawPosition> = parse::json(POSITION_RISK_WITH_A_RESTING_ORDER, "positionRisk")
.expect("the captured payload");
let mapped = position(&perp(), &raw[0]).expect("a position");
assert!(mapped.is_flat());
assert_eq!(mapped.side, None);
assert_eq!(mapped.market.kind, MarketKind::Perpetual);
assert_eq!(
crate::client::open_positions(vec![mapped]),
Vec::new(),
"a symbol carrying only a resting order was reported as a position"
);
}
#[test]
fn a_spot_order_lookup_keeps_the_fields_the_common_order_has_no_room_for() {
let raw: RawSpotOrderDetail = parse::json(
r#"{
"symbol": "LTCBTC",
"orderId": 1,
"orderListId": -1,
"clientOrderId": "myOrder1",
"price": "0.1",
"origQty": "1.0",
"executedQty": "0.5",
"cummulativeQuoteQty": "0.05",
"status": "PARTIALLY_FILLED",
"timeInForce": "GTC",
"type": "LIMIT",
"side": "BUY",
"stopPrice": "0.0",
"icebergQty": "0.0",
"time": 1499827319559,
"updateTime": 1499827319559,
"isWorking": true,
"workingTime": 1499827319559,
"origQuoteOrderQty": "0.000000",
"selfTradePreventionMode": "NONE"
}"#,
"order",
)
.expect("official order payload");
let order = parse::order(&btc_usdt(), &raw.order).expect("an order");
assert_eq!(order.status, OrderStatus::PartiallyFilled);
assert_eq!(order.filled_quantity.to_string(), "0.5");
assert_eq!(order.remaining_quantity.to_string(), "0.5");
assert_eq!(raw.client_order_id, "myOrder1");
assert_eq!(raw.cummulative_quote_qty, "0.05");
assert_eq!(raw.time_in_force, "GTC");
}
#[test]
fn a_listen_key_stays_out_of_a_debug_line_and_goes_into_the_stream_url() {
let key = BinanceListenKey(
"pqia91ma19a5s61cv6a81va65sdf19v8a65a1a5s61cv6a81va65sdf19v8a65a1".to_string(),
);
assert_eq!(format!("{key:?}"), r#"BinanceListenKey("<redacted>")"#);
assert!(!format!("{key:?}").contains("pqia91"));
assert!(key.as_str().starts_with("pqia91"));
assert_eq!(
usd_m_user_data_stream_url(&key),
format!(
"wss://fstream.binance.com/private/ws?listenKey={}\
&events=ORDER_TRADE_UPDATE/ACCOUNT_UPDATE/listenKeyExpired",
key.as_str()
)
);
assert_eq!(USD_M_LISTEN_KEY_PATH, "/fapi/v1/listenKey");
}
#[test]
fn no_request_reaches_for_the_removed_spot_listen_key_endpoints() {
let requests = [
keepalive_listen_key_request(&spot()).expect("credentials are set"),
keepalive_listen_key_request(&perp()).expect("credentials are set"),
api_key_only(&spot(), HttpMethod::Post, USD_M_LISTEN_KEY_PATH, &[])
.expect("credentials are set"),
];
for request in &requests {
assert!(
!request.target().contains("/api/v3/userDataStream"),
"{}",
request.target()
);
}
assert!(!USD_M_LISTEN_KEY_PATH.contains("userDataStream"));
}
#[test]
fn the_spot_subscribe_frame_is_signed_over_its_parameters_in_binances_own_order() {
let frame = spot_user_data_subscribe_frame(&spot()).expect("credentials are set");
let parsed: serde_json::Value = serde_json::from_str(&frame).expect("a JSON frame");
assert_eq!(parsed["method"], "userDataStream.subscribe.signature");
assert!(parsed["id"].is_string(), "{frame}");
assert_eq!(parsed["params"]["apiKey"], "key");
let timestamp = parsed["params"]["timestamp"]
.as_i64()
.expect("a numeric timestamp");
assert_eq!(parsed["id"], timestamp.to_string());
let credentials = BinanceCredentials {
api_key: "key".to_string(),
secret_key: "secret".to_string(),
};
let expected = signature(
&credentials,
&format!("apiKey=key&recvWindow=60000×tamp={timestamp}"),
)
.expect("a signature");
assert_eq!(parsed["params"]["signature"], expected);
assert_eq!(parsed["params"]["recvWindow"], 60_000);
}
#[test]
fn an_unauthenticated_adapter_never_subscribes_to_a_spot_user_data_stream() {
assert!(matches!(
spot_user_data_subscribe_frame(&BinanceAdapter::spot()),
Err(Error::Auth { .. })
));
}
#[test]
fn the_usd_m_account_socket_names_an_entry_point_and_leaves_the_separator_literal() {
let key = BinanceListenKey("listen-key".to_string());
let url = usd_m_user_data_stream_url(&key);
assert!(
url.starts_with("wss://fstream.binance.com/private/"),
"{url}"
);
let query = url.split_once('?').expect("a query").1;
assert!(query.contains('/'), "{url}");
assert!(!query.contains("%2F"), "{url}");
}
#[test]
fn keepalive_uses_put_and_never_names_the_key_binance_would_reject() {
let request = keepalive_listen_key_request(&perp()).expect("credentials are set");
assert_eq!(request.method, HttpMethod::Put);
assert_eq!(request.target(), "/fapi/v1/listenKey");
assert!(!request.target().contains("signature"));
assert!(
request
.headers
.iter()
.any(|(name, _)| name == API_KEY_HEADER)
);
}
#[test]
fn close_uses_delete_and_never_names_the_key_binance_would_reject() {
let request = close_listen_key_request(&perp()).expect("credentials are set");
assert_eq!(request.method, HttpMethod::Delete);
assert_eq!(request.target(), "/fapi/v1/listenKey");
assert!(!request.target().contains("signature"));
assert!(
request
.headers
.iter()
.any(|(name, _)| name == API_KEY_HEADER)
);
}
#[test]
fn a_listen_key_request_authenticates_with_the_key_alone() {
let request = api_key_only(&perp(), HttpMethod::Post, USD_M_LISTEN_KEY_PATH, &[])
.expect("credentials are set");
assert_eq!(request.target(), "/fapi/v1/listenKey");
assert!(!request.target().contains("signature"));
assert!(
request
.headers
.iter()
.any(|(name, value)| name == API_KEY_HEADER && value == "key")
);
}
}