use std::cmp::Reverse;
use rust_decimal::Decimal;
use serde::Deserialize;
use crate::adapters::{
candles as candle_pages, inclusive_millis_at_or_after, inclusive_millis_before,
};
use crate::error::{Error, Result};
use crate::feature::Feature;
use crate::request::CandleRequest;
use crate::transport::HttpRequest;
use crate::types::{Candle, Market, MarketInfo, MarketKind, OrderBook, Ticker, Timestamp, Trade};
use super::{
BinanceAdapter, BinanceAggregateTrade, BinanceAggregateTradesRequest, BinanceMarkPrice,
BinanceMarket, BinanceOpenInterest, BinanceSpotAveragePrice, EXCHANGE, now_millis, parse,
};
const MAX_TRADE_LIMIT: u32 = 1_000;
const MAX_AGGREGATE_TRADE_LIMIT: u32 = 1_000;
const USD_M_AGGREGATE_TRADE_WINDOW_MILLIS: i64 = 60 * 60 * 1_000;
const MAX_SPOT_DEPTH: u32 = 5_000;
const USD_M_DEPTHS: &[u32] = &[5, 10, 20, 50, 100, 500, 1_000];
const MAX_SPOT_CANDLES: u32 = 1_000;
const MAX_USD_M_CANDLES: u32 = 1_500;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct BinanceExchangeInfo {
pub venue: BinanceMarket,
pub timezone: Option<String>,
pub server_time: Option<Timestamp>,
pub symbols: Vec<BinanceExchangeSymbol>,
pub raw_json: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct BinanceExchangeSymbol {
pub symbol: String,
pub status: String,
pub base_asset: String,
pub quote_asset: String,
pub contract_type: Option<String>,
pub margin_asset: Option<String>,
pub raw_json: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawProviderExchangeInfo {
#[serde(default)]
timezone: Option<String>,
#[serde(default)]
server_time: Option<i64>,
#[serde(default)]
symbols: Vec<RawProviderExchangeSymbol>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawProviderExchangeSymbol {
symbol: String,
status: String,
base_asset: String,
quote_asset: String,
#[serde(default)]
contract_type: Option<String>,
#[serde(default)]
margin_asset: Option<String>,
}
impl BinanceMarket {
const fn public_prefix(self) -> &'static str {
match self {
Self::Spot => "/api/v3",
Self::UsdMFutures => "/fapi/v1",
}
}
const fn max_candles(self) -> u32 {
match self {
Self::Spot => MAX_SPOT_CANDLES,
Self::UsdMFutures => MAX_USD_M_CANDLES,
}
}
}
pub(super) fn encode(raw: &str) -> String {
let mut encoded = String::with_capacity(raw.len());
for byte in raw.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
encoded.push(char::from(byte));
}
other => encoded.push_str(&format!("%{other:02X}")),
}
}
encoded
}
pub(super) fn query(params: &[(&str, String)]) -> String {
params
.iter()
.map(|(name, value)| format!("{}={}", name, encode(value)))
.collect::<Vec<_>>()
.join("&")
}
pub(super) fn markets_request(venue: BinanceMarket) -> HttpRequest {
HttpRequest::get(format!("{}/exchangeInfo", venue.public_prefix()))
}
pub(super) fn trades_request(
adapter: &BinanceAdapter,
market: &Market,
limit: Option<u32>,
) -> Result<HttpRequest> {
let mut params = vec![("symbol", adapter.symbol(market)?)];
if let Some(limit) = limit {
if !(1..=MAX_TRADE_LIMIT).contains(&limit) {
return Err(Error::invalid_request(
"limit",
format!("binance serves 1 to {MAX_TRADE_LIMIT} trades per call, not {limit}"),
));
}
params.push(("limit", limit.to_string()));
}
Ok(
HttpRequest::get(format!("{}/trades", adapter.venue().public_prefix()))
.query(query(¶ms)),
)
}
pub(super) fn aggregate_trades_request(
adapter: &BinanceAdapter,
request: &BinanceAggregateTradesRequest,
) -> Result<HttpRequest> {
let limit = request.limit.unwrap_or(500);
if !(1..=MAX_AGGREGATE_TRADE_LIMIT).contains(&limit) {
return Err(Error::invalid_request(
"limit",
format!(
"binance serves 1 to {MAX_AGGREGATE_TRADE_LIMIT} aggregate trades per call, not {limit}"
),
));
}
if adapter.venue() == BinanceMarket::UsdMFutures
&& request.from_id.is_some()
&& (request.start_time.is_some() || request.end_time.is_some())
{
return Err(Error::invalid_request(
"from_id",
"Binance aggregate trades use either from_id or start/end time bounds, not both",
));
}
if request
.from_id
.is_some_and(|from_id| from_id > i64::MAX as u64)
{
return Err(Error::invalid_request(
"from_id",
"Binance aggregate trade IDs must fit the documented signed 64-bit range",
));
}
let start_millis = request.start_time.map(inclusive_millis_at_or_after);
let end_millis = request.end_time.map(inclusive_millis_at_or_before);
if let (Some(start), Some(end)) = (start_millis, end_millis) {
if end < start {
return Err(Error::invalid_request(
"end_time",
"must not precede start_time",
));
}
if adapter.venue() == BinanceMarket::UsdMFutures
&& end - start >= USD_M_AGGREGATE_TRADE_WINDOW_MILLIS
{
return Err(Error::invalid_request(
"end_time",
"Binance aggregate trade time windows must be shorter than one hour",
));
}
}
let mut params = vec![("symbol", adapter.symbol(&request.market)?)];
if let Some(from_id) = request.from_id {
params.push(("fromId", from_id.to_string()));
}
if let Some(start) = start_millis {
params.push(("startTime", start.to_string()));
}
if let Some(end) = end_millis {
params.push(("endTime", end.to_string()));
}
params.push(("limit", limit.to_string()));
Ok(
HttpRequest::get(format!("{}/aggTrades", adapter.venue().public_prefix()))
.query(query(¶ms)),
)
}
fn inclusive_millis_at_or_before(value: Timestamp) -> i64 {
value.as_nanos().div_euclid(1_000_000)
}
pub(super) fn order_book_request(
adapter: &BinanceAdapter,
market: &Market,
depth: Option<u32>,
) -> Result<HttpRequest> {
let venue = adapter.venue();
let mut params = vec![("symbol", adapter.symbol(market)?)];
if let Some(depth) = depth {
let accepted = match venue {
BinanceMarket::Spot => (1..=MAX_SPOT_DEPTH).contains(&depth),
BinanceMarket::UsdMFutures => USD_M_DEPTHS.contains(&depth),
};
if !accepted {
let expected = match venue {
BinanceMarket::Spot => format!("1 to {MAX_SPOT_DEPTH}"),
BinanceMarket::UsdMFutures => format!("one of {USD_M_DEPTHS:?}"),
};
return Err(Error::invalid_request(
"depth",
format!("binance serves book depths {expected} on this venue, not {depth}"),
));
}
params.push(("limit", depth.to_string()));
}
Ok(HttpRequest::get(format!("{}/depth", venue.public_prefix())).query(query(¶ms)))
}
pub(super) fn ticker_request(adapter: &BinanceAdapter, market: &Market) -> Result<HttpRequest> {
let params = [("symbol", adapter.symbol(market)?)];
Ok(
HttpRequest::get(format!("{}/ticker/24hr", adapter.venue().public_prefix()))
.query(query(¶ms)),
)
}
pub(super) fn spot_average_price_request(
adapter: &BinanceAdapter,
market: &Market,
) -> Result<HttpRequest> {
if adapter.venue() != BinanceMarket::Spot {
return Err(Error::unsupported(
Feature::Ticker,
EXCHANGE,
"average price is available on the Spot adapter only",
));
}
let params = [("symbol", adapter.symbol(market)?)];
Ok(HttpRequest::get("/api/v3/avgPrice").query(query(¶ms)))
}
fn check_usd_m_market_data(adapter: &BinanceAdapter, what: &str) -> Result<()> {
if adapter.venue() == BinanceMarket::UsdMFutures {
return Ok(());
}
Err(Error::unsupported(
Feature::FundingRates,
EXCHANGE,
format!("{what} is available on the USD-M futures adapter only"),
))
}
pub(super) fn mark_price_request(adapter: &BinanceAdapter, market: &Market) -> Result<HttpRequest> {
check_usd_m_market_data(adapter, "mark price")?;
let params = [("symbol", adapter.symbol(market)?)];
Ok(HttpRequest::get(format!(
"{}/premiumIndex",
BinanceMarket::UsdMFutures.public_prefix()
))
.query(query(¶ms)))
}
pub(super) fn mark_prices_request(adapter: &BinanceAdapter) -> Result<HttpRequest> {
check_usd_m_market_data(adapter, "mark prices")?;
Ok(HttpRequest::get(format!(
"{}/premiumIndex",
BinanceMarket::UsdMFutures.public_prefix()
)))
}
pub(super) fn open_interest_request(
adapter: &BinanceAdapter,
market: &Market,
) -> Result<HttpRequest> {
check_usd_m_market_data(adapter, "open interest")?;
let params = [("symbol", adapter.symbol(market)?)];
Ok(HttpRequest::get(format!(
"{}/openInterest",
BinanceMarket::UsdMFutures.public_prefix()
))
.query(query(¶ms)))
}
pub(super) fn candles_request(
adapter: &BinanceAdapter,
request: &CandleRequest,
cursor: Option<Timestamp>,
count: u32,
) -> Result<HttpRequest> {
let venue = adapter.venue();
let mut params = vec![
("symbol", adapter.symbol(&request.market)?),
(
"interval",
venue.interval_code(request.interval)?.to_string(),
),
];
if let Some(cursor) = cursor {
params.push(("endTime", inclusive_millis_before(cursor).to_string()));
}
params.push(("limit", count.to_string()));
Ok(HttpRequest::get(format!("{}/klines", venue.public_prefix())).query(query(¶ms)))
}
pub(super) async fn markets(adapter: &BinanceAdapter, kind: MarketKind) -> Result<Vec<MarketInfo>> {
if kind != adapter.venue().market_kind() {
return Ok(Vec::new());
}
let body = adapter.send(markets_request(adapter.venue())).await?;
let listing: parse::RawExchangeInfo = parse::json(&body, "exchangeInfo")?;
Ok(listing
.symbols
.iter()
.filter_map(|symbol| parse::market_info(adapter.venue(), symbol))
.collect())
}
pub(super) async fn exchange_info(
adapter: &BinanceAdapter,
expected_venue: BinanceMarket,
) -> Result<BinanceExchangeInfo> {
if adapter.venue() != expected_venue {
return Err(Error::unsupported(
Feature::Markets,
EXCHANGE,
match expected_venue {
BinanceMarket::Spot => {
"Spot exchange information requires an adapter built with `spot`"
}
BinanceMarket::UsdMFutures => {
"USD-M exchange information requires an adapter built with `usd_m_futures`"
}
},
));
}
let body = adapter.send(markets_request(expected_venue)).await?;
exchange_info_from_body(expected_venue, &body)
}
fn exchange_info_from_body(venue: BinanceMarket, body: &str) -> Result<BinanceExchangeInfo> {
let response: serde_json::Value = parse::json(body, "exchangeInfo")?;
if !response.is_object() {
return Err(Error::decode(
"Binance exchangeInfo response is not an object",
));
}
let raw: RawProviderExchangeInfo = serde_json::from_value(response.clone())
.map_err(|error| Error::decode(format!("unreadable exchangeInfo: {error}")))?;
let symbols = response
.get("symbols")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| Error::decode("Binance exchangeInfo response has no symbol array"))?;
if raw.symbols.len() != symbols.len() {
return Err(Error::decode(
"Binance exchangeInfo symbols could not be paired with raw entries",
));
}
let symbols = raw
.symbols
.into_iter()
.zip(symbols)
.map(|(symbol, raw_json)| {
Ok(BinanceExchangeSymbol {
symbol: symbol.symbol,
status: symbol.status,
base_asset: symbol.base_asset,
quote_asset: symbol.quote_asset,
contract_type: symbol.contract_type,
margin_asset: symbol.margin_asset,
raw_json: parse::canonical_json(raw_json, "exchangeInfo symbol")?,
})
})
.collect::<Result<Vec<_>>>()?;
Ok(BinanceExchangeInfo {
venue,
timezone: raw.timezone,
server_time: raw.server_time.map(parse::millis),
symbols,
raw_json: parse::canonical_json(&response, "exchangeInfo")?,
})
}
pub(super) async fn trades(
adapter: &BinanceAdapter,
market: &Market,
limit: Option<u32>,
) -> Result<Vec<Trade>> {
let body = adapter
.send(trades_request(adapter, market, limit)?)
.await?;
newest_first(market, parse::json(&body, "trades")?)
}
pub(super) async fn aggregate_trades(
adapter: &BinanceAdapter,
request: &BinanceAggregateTradesRequest,
) -> Result<Vec<BinanceAggregateTrade>> {
let body = adapter
.send(aggregate_trades_request(adapter, request)?)
.await?;
let values: Vec<serde_json::Value> = parse::json(&body, "aggregate trades")?;
values
.iter()
.map(|value| {
let raw = serde_json::from_value::<parse::RawAggregateTrade>(value.clone())
.map_err(|error| Error::decode(format!("unreadable aggregate trade: {error}")))?;
parse::aggregate_trade(&request.market, &raw, value)
})
.collect()
}
fn newest_first(market: &Market, mut raw: Vec<parse::RawTrade>) -> Result<Vec<Trade>> {
raw.sort_unstable_by_key(|trade| Reverse(trade.id));
raw.iter().map(|raw| parse::trade(market, raw)).collect()
}
pub(super) async fn order_book(
adapter: &BinanceAdapter,
market: &Market,
depth: Option<u32>,
) -> Result<OrderBook> {
let body = adapter
.send(order_book_request(adapter, market, depth)?)
.await?;
let raw: parse::RawDepth = parse::json(&body, "depth")?;
parse::order_book(market, Timestamp::now(), &raw)
}
pub(super) async fn ticker(adapter: &BinanceAdapter, market: &Market) -> Result<Ticker> {
let body = adapter.send(ticker_request(adapter, market)?).await?;
let raw: parse::RawTicker = parse::json(&body, "ticker")?;
parse::ticker(market, &raw)
}
pub(super) async fn spot_average_price(
adapter: &BinanceAdapter,
market: &Market,
) -> Result<BinanceSpotAveragePrice> {
let body = adapter
.send(spot_average_price_request(adapter, market)?)
.await?;
let raw: parse::RawSpotAveragePrice = parse::json(&body, "average price")?;
parse::spot_average_price(market, &raw)
}
pub(super) async fn mark_price(
adapter: &BinanceAdapter,
market: &Market,
) -> Result<BinanceMarkPrice> {
let body = adapter.send(mark_price_request(adapter, market)?).await?;
let raw: parse::RawMarkPrice = parse::json(&body, "mark price")?;
parse::mark_price(market, &raw)
}
pub(super) async fn mark_prices(adapter: &BinanceAdapter) -> Result<Vec<BinanceMarkPrice>> {
let request = mark_prices_request(adapter)?;
let markets = markets(adapter, MarketKind::Perpetual).await?;
let body = adapter.send(request).await?;
let raw: Vec<parse::RawMarkPrice> = parse::json(&body, "mark prices")?;
mark_price_list(&markets, &raw)
}
fn mark_price_list(
markets: &[MarketInfo],
raw: &[parse::RawMarkPrice],
) -> Result<Vec<BinanceMarkPrice>> {
raw.iter()
.filter_map(|entry| {
markets
.iter()
.find(|market| market.native_symbol == entry.symbol)
.map(|market| parse::mark_price(&market.market, entry))
})
.collect()
}
pub(super) async fn open_interest(
adapter: &BinanceAdapter,
market: &Market,
) -> Result<BinanceOpenInterest> {
let body = adapter
.send(open_interest_request(adapter, market)?)
.await?;
let raw: parse::RawOpenInterest = parse::json(&body, "open interest")?;
parse::open_interest(market, &raw)
}
pub(super) async fn candles(
adapter: &BinanceAdapter,
request: &CandleRequest,
) -> Result<Vec<Candle>> {
let max = adapter.venue().max_candles();
let now = now_millis();
candle_pages::read(request, EXCHANGE, max, |cursor, count| async move {
let body = adapter
.send(candles_request(adapter, request, cursor, count)?)
.await?;
parse::json::<Vec<parse::RawCandle>>(&body, "klines")?
.iter()
.map(|raw| parse::candle(&request.market, request.interval, raw, now))
.collect()
})
.await
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct BinanceSymbolFilters {
pub symbol: String,
pub tick_size: Option<Decimal>,
pub min_price: Option<Decimal>,
pub max_price: Option<Decimal>,
pub step_size: Option<Decimal>,
pub min_quantity: Option<Decimal>,
pub max_quantity: Option<Decimal>,
pub min_notional: Option<Decimal>,
}
pub(super) fn symbol_filters(raw: &parse::RawSymbol) -> Result<BinanceSymbolFilters> {
let mut filters = BinanceSymbolFilters {
symbol: raw.symbol.clone(),
tick_size: None,
min_price: None,
max_price: None,
step_size: None,
min_quantity: None,
max_quantity: None,
min_notional: None,
};
let read = |text: &Option<String>, field: &'static str| -> Result<Option<Decimal>> {
text.as_deref()
.map(|value| parse::decimal(value, field))
.transpose()
};
for filter in &raw.filters {
match filter.filter_type.as_str() {
"PRICE_FILTER" => {
filters.tick_size = read(&filter.tick_size, "tickSize")?;
filters.min_price = read(&filter.min_price, "minPrice")?;
filters.max_price = read(&filter.max_price, "maxPrice")?;
}
"LOT_SIZE" => {
filters.step_size = read(&filter.step_size, "stepSize")?;
filters.min_quantity = read(&filter.min_qty, "minQty")?;
filters.max_quantity = read(&filter.max_qty, "maxQty")?;
}
"NOTIONAL" | "MIN_NOTIONAL" => {
filters.min_notional = read(&filter.min_notional, "minNotional")?;
}
_ => {}
}
}
Ok(filters)
}
pub(super) async fn spot_symbol_filters(
adapter: &BinanceAdapter,
market: &Market,
) -> Result<BinanceSymbolFilters> {
if adapter.venue() != BinanceMarket::Spot {
return Err(Error::unsupported(
Feature::Markets,
EXCHANGE,
"symbol filters are read here for spot; USD-M publishes a different filter set",
));
}
let symbol = adapter.symbol(market)?;
let body = adapter
.send(markets_request(BinanceMarket::Spot).query(query(&[("symbol", symbol.clone())])))
.await?;
let listing: parse::RawExchangeInfo = parse::json(&body, "exchangeInfo")?;
let raw = listing
.symbols
.iter()
.find(|entry| entry.symbol == symbol)
.ok_or_else(|| Error::decode(format!("binance did not list `{symbol}`")))?;
symbol_filters(raw)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Exchange, Interval, MarketStatus};
fn spot() -> BinanceAdapter {
BinanceAdapter::spot()
}
fn perp() -> BinanceAdapter {
BinanceAdapter::usd_m_futures()
}
fn btc_usdt() -> Market {
Market::spot(Exchange::Binance, "BTC", "USDT")
}
fn btc_usdt_perp() -> Market {
Market::perpetual(Exchange::Binance, "BTC", "USDT")
}
#[test]
fn the_two_venues_serve_the_same_endpoints_under_different_prefixes() {
assert_eq!(
markets_request(BinanceMarket::Spot).target(),
"/api/v3/exchangeInfo"
);
assert_eq!(
markets_request(BinanceMarket::UsdMFutures).target(),
"/fapi/v1/exchangeInfo"
);
assert_eq!(
trades_request(&spot(), &btc_usdt(), Some(10))
.expect("a valid limit")
.target(),
"/api/v3/trades?symbol=BTCUSDT&limit=10"
);
assert_eq!(
trades_request(&perp(), &btc_usdt_perp(), Some(10))
.expect("a valid limit")
.target(),
"/fapi/v1/trades?symbol=BTCUSDT&limit=10"
);
assert_eq!(
ticker_request(&spot(), &btc_usdt())
.expect("a market")
.target(),
"/api/v3/ticker/24hr?symbol=BTCUSDT"
);
let authenticated_spot = spot().with_credentials("key", "secret");
let average_price =
spot_average_price_request(&authenticated_spot, &btc_usdt()).expect("a Spot market");
assert_eq!(average_price.method, crate::transport::HttpMethod::Get);
assert_eq!(average_price.target(), "/api/v3/avgPrice?symbol=BTCUSDT");
assert!(average_price.headers.is_empty());
assert_eq!(average_price.body, None);
assert_eq!(
order_book_request(&spot(), &btc_usdt(), Some(100))
.expect("a valid depth")
.target(),
"/api/v3/depth?symbol=BTCUSDT&limit=100"
);
assert_eq!(
mark_price_request(&perp(), &btc_usdt_perp())
.expect("a USD-M market")
.target(),
"/fapi/v1/premiumIndex?symbol=BTCUSDT"
);
assert_eq!(
mark_prices_request(&perp())
.expect("a USD-M adapter")
.target(),
"/fapi/v1/premiumIndex"
);
assert_eq!(
open_interest_request(&perp(), &btc_usdt_perp())
.expect("a USD-M market")
.target(),
"/fapi/v1/openInterest?symbol=BTCUSDT"
);
}
#[test]
fn provider_exchange_info_keeps_every_symbol_and_raw_filter_data() {
let listing = exchange_info_from_body(
BinanceMarket::UsdMFutures,
r#"{
"timezone":"UTC","serverTime":1700000000000,
"symbols":[
{"symbol":"BTCUSDT","status":"TRADING","baseAsset":"BTC","quoteAsset":"USDT","contractType":"PERPETUAL","marginAsset":"USDT","filters":[{"filterType":"PRICE_FILTER"}]},
{"symbol":"BTCUSDT_240628","status":"TRADING","baseAsset":"BTC","quoteAsset":"USDT","contractType":"CURRENT_QUARTER","marginAsset":"USDT","filters":[{"filterType":"PRICE_FILTER"}]}
],"exchangeFilters":[{"futureField":true}]
}"#,
)
.expect("official-shaped exchangeInfo response");
assert_eq!(listing.symbols.len(), 2);
assert_eq!(
listing.symbols[1].contract_type.as_deref(),
Some("CURRENT_QUARTER")
);
assert!(listing.symbols[0].raw_json.contains("filters"));
assert!(listing.raw_json.contains("exchangeFilters"));
}
#[test]
fn aggregate_trades_select_the_venue_path_and_preserve_query_order() {
let request = BinanceAggregateTradesRequest::new(btc_usdt_perp())
.with_from_id(26129)
.limit(50);
assert_eq!(
aggregate_trades_request(&perp(), &request)
.expect("a valid aggregate-trade request")
.target(),
"/fapi/v1/aggTrades?symbol=BTCUSDT&fromId=26129&limit=50"
);
let timed = BinanceAggregateTradesRequest::new(btc_usdt_perp())
.start_time(Timestamp::from_nanos(1_623_319_461_670_500_000))
.end_time(Timestamp::from_nanos(1_623_319_462_670_499_999));
assert_eq!(
aggregate_trades_request(&perp(), &timed)
.expect("a valid time window")
.target(),
"/fapi/v1/aggTrades?symbol=BTCUSDT&startTime=1623319461671&endTime=1623319462670&limit=500"
);
let spot_timed = BinanceAggregateTradesRequest::new(btc_usdt())
.with_from_id(26129)
.start_time(Timestamp::from_millis(1_623_319_461_670))
.end_time(Timestamp::from_millis(1_623_322_461_670));
assert_eq!(
aggregate_trades_request(&spot(), &spot_timed)
.expect("a valid Spot aggregate-trade request")
.target(),
"/api/v3/aggTrades?symbol=BTCUSDT&fromId=26129&startTime=1623319461670&endTime=1623322461670&limit=500"
);
}
#[test]
fn aggregate_trades_apply_venue_specific_validation_before_network() {
let spot_wide_window = BinanceAggregateTradesRequest::new(btc_usdt())
.start_time(Timestamp::from_millis(1_000))
.end_time(Timestamp::from_millis(3_601_000));
assert!(aggregate_trades_request(&spot(), &spot_wide_window).is_ok());
let spot_mixed = BinanceAggregateTradesRequest::new(btc_usdt())
.with_from_id(10)
.start_time(Timestamp::from_millis(1_000));
assert!(aggregate_trades_request(&spot(), &spot_mixed).is_ok());
let usd_m_mixed = BinanceAggregateTradesRequest::new(btc_usdt_perp())
.with_from_id(10)
.start_time(Timestamp::from_millis(1_000));
assert!(matches!(
aggregate_trades_request(&perp(), &usd_m_mixed),
Err(Error::InvalidRequest { field, .. }) if field == "from_id"
));
let usd_m_wide_window = BinanceAggregateTradesRequest::new(btc_usdt_perp())
.start_time(Timestamp::from_millis(1_000))
.end_time(Timestamp::from_millis(3_601_000));
assert!(matches!(
aggregate_trades_request(&perp(), &usd_m_wide_window),
Err(Error::InvalidRequest { field, .. }) if field == "end_time"
));
let rounded_under_one_hour = BinanceAggregateTradesRequest::new(btc_usdt_perp())
.start_time(Timestamp::from_nanos(500_000))
.end_time(Timestamp::from_nanos(3_600_000_500_000));
assert_eq!(
aggregate_trades_request(&perp(), &rounded_under_one_hour)
.expect("a window shorter than one hour on the wire")
.target(),
"/fapi/v1/aggTrades?symbol=BTCUSDT&startTime=1&endTime=3600000&limit=500"
);
let one_hour_on_the_wire = BinanceAggregateTradesRequest::new(btc_usdt_perp())
.start_time(Timestamp::from_nanos(500_000))
.end_time(Timestamp::from_nanos(3_600_001_000_000));
assert!(matches!(
aggregate_trades_request(&perp(), &one_hour_on_the_wire),
Err(Error::InvalidRequest { field, .. }) if field == "end_time"
));
let out_of_range =
BinanceAggregateTradesRequest::new(btc_usdt_perp()).with_from_id(i64::MAX as u64 + 1);
assert!(matches!(
aggregate_trades_request(&perp(), &out_of_range),
Err(Error::InvalidRequest { field, .. }) if field == "from_id"
));
for request in [
BinanceAggregateTradesRequest::new(btc_usdt_perp()).limit(0),
BinanceAggregateTradesRequest::new(btc_usdt_perp()).limit(1_001),
] {
assert!(matches!(
aggregate_trades_request(&perp(), &request),
Err(Error::InvalidRequest { .. })
));
}
}
#[test]
fn aggregate_trades_parse_spot_fields_without_normal_quantity() {
let values: Vec<serde_json::Value> = parse::json(
r#"[{"a":26129,"p":"0.01633102","q":"4.70443515",
"f":27781,"l":27784,"T":1498793709153,"m":true,"M":true}]"#,
"aggregate trades",
)
.expect("official aggregate-trade payload");
let raw = serde_json::from_value::<parse::RawAggregateTrade>(values[0].clone())
.expect("aggregate trade fields");
let trade = parse::aggregate_trade(&btc_usdt(), &raw, &values[0]).expect("aggregate trade");
assert_eq!(trade.aggregate_id, 26129);
assert_eq!(trade.first_trade_id, 27781);
assert_eq!(trade.last_trade_id, 27784);
assert_eq!(trade.quantity.to_string(), "4.70443515");
assert_eq!(trade.normal_quantity, None);
assert_eq!(trade.best_price_match, Some(true));
assert_eq!(trade.taker_side, crate::types::Side::Sell);
assert_eq!(
trade.raw_json,
r#"{"a":26129,"p":"0.01633102","q":"4.70443515","f":27781,"l":27784,"T":1498793709153,"m":true,"M":true}"#
);
}
#[test]
fn aggregate_trades_parse_usd_m_fields_without_spot_marker() {
let values: Vec<serde_json::Value> = parse::json(
r#"[{"a":26130,"p":"0.01633103","q":"1.2","nq":"1.00000000",
"f":27785,"l":27785,"T":1498793709253,"m":false}]"#,
"aggregate trades",
)
.expect("official aggregate-trade payload");
let raw = serde_json::from_value::<parse::RawAggregateTrade>(values[0].clone())
.expect("aggregate trade fields");
let trade =
parse::aggregate_trade(&btc_usdt_perp(), &raw, &values[0]).expect("aggregate trade");
assert_eq!(trade.normal_quantity.expect("nq").to_string(), "1.00000000");
assert_eq!(trade.best_price_match, None);
assert_eq!(trade.taker_side, crate::types::Side::Buy);
let invalid = parse::RawAggregateTrade {
aggregate_id: 1,
price: "1".to_owned(),
quantity: "1".to_owned(),
normal_quantity: None,
first_trade_id: 3,
last_trade_id: 2,
time: 0,
is_buyer_maker: false,
best_price_match: None,
};
assert!(matches!(
parse::aggregate_trade(&btc_usdt(), &invalid, &values[0]),
Err(Error::Decode { .. })
));
}
#[test]
fn mark_price_and_open_interest_are_usd_m_only() {
assert!(matches!(
mark_price_request(&spot(), &btc_usdt()),
Err(Error::Unsupported { feature, .. }) if feature == Feature::FundingRates
));
assert!(matches!(
mark_prices_request(&spot()),
Err(Error::Unsupported { feature, .. }) if feature == Feature::FundingRates
));
assert!(matches!(
open_interest_request(&spot(), &btc_usdt()),
Err(Error::Unsupported { feature, .. }) if feature == Feature::FundingRates
));
}
#[test]
fn mark_price_list_keeps_only_exchange_info_perpetuals() {
let raw: Vec<parse::RawMarkPrice> = parse::json(
r#"[
{"symbol":"BTCUSDT","markPrice":"1","indexPrice":"1","estimatedSettlePrice":"0","lastFundingRate":"0","interestRate":"0","nextFundingTime":0,"time":0},
{"symbol":"BTCU","markPrice":"1","indexPrice":"1","estimatedSettlePrice":"0","lastFundingRate":"0","interestRate":"0","nextFundingTime":0,"time":0},
{"symbol":"TRADIFIUSDT","markPrice":"1","indexPrice":"1","estimatedSettlePrice":"0","lastFundingRate":"0","interestRate":"0","nextFundingTime":0,"time":0},
{"symbol":"BTCUSDT_260925","markPrice":"1","indexPrice":"1","estimatedSettlePrice":"0","lastFundingRate":"0","interestRate":"0","nextFundingTime":0,"time":0}
]"#,
"mark prices",
)
.expect("Binance mark-price array");
let markets = vec![
MarketInfo {
market: btc_usdt_perp(),
native_symbol: "BTCUSDT".to_owned(),
status: MarketStatus::Active,
korean_name: None,
english_name: None,
},
MarketInfo {
market: Market::perpetual(Exchange::Binance, "BTC", "U"),
native_symbol: "BTCU".to_owned(),
status: MarketStatus::Active,
korean_name: None,
english_name: None,
},
];
let prices = mark_price_list(&markets, &raw).expect("perpetual prices");
assert_eq!(
prices.iter().map(|price| &price.market).collect::<Vec<_>>(),
vec![
&btc_usdt_perp(),
&Market::perpetual(Exchange::Binance, "BTC", "U")
]
);
}
#[test]
fn spot_accepts_any_depth_through_5000_while_futures_keeps_its_fixed_set() {
assert_eq!(
order_book_request(&spot(), &btc_usdt(), Some(30))
.expect("spot accepts every depth through 5000")
.target(),
"/api/v3/depth?symbol=BTCUSDT&limit=30"
);
assert!(order_book_request(&spot(), &btc_usdt(), Some(5_000)).is_ok());
assert!(matches!(
order_book_request(&spot(), &btc_usdt(), Some(5_001)),
Err(Error::InvalidRequest { field, .. }) if field == "depth"
));
assert!(matches!(
order_book_request(&perp(), &btc_usdt_perp(), Some(30)),
Err(Error::InvalidRequest { field, .. }) if field == "depth"
));
assert!(matches!(
order_book_request(&perp(), &btc_usdt_perp(), Some(5_000)),
Err(Error::InvalidRequest { field, .. }) if field == "depth"
));
}
#[test]
fn each_venue_asks_for_no_more_than_it_serves_in_one_response() {
assert_eq!(spot().venue().max_candles(), 1_000);
assert_eq!(perp().venue().max_candles(), 1_500);
}
#[test]
fn a_candle_window_is_half_open_on_the_wire_as_well_as_in_the_request() {
let request = CandleRequest::new(btc_usdt(), Interval::Min15);
assert_eq!(
candles_request(
&spot(),
&request,
Some(Timestamp::from_millis(1_499_644_800_000)),
250
)
.expect("a valid request")
.target(),
"/api/v3/klines?symbol=BTCUSDT&interval=15m&endTime=1499644799999&limit=250"
);
assert_eq!(
candles_request(
&spot(),
&request,
Some(Timestamp::from_nanos(1_499_644_800_000_000_001)),
250
)
.expect("a sub-millisecond exclusive end")
.target(),
"/api/v3/klines?symbol=BTCUSDT&interval=15m&endTime=1499644800000&limit=250"
);
}
#[test]
fn a_one_second_candle_request_reaches_spot_and_is_refused_on_futures() {
let spot_request = CandleRequest::new(btc_usdt(), Interval::Sec1);
let perp_request = CandleRequest::new(btc_usdt_perp(), Interval::Sec1);
assert_eq!(
candles_request(&spot(), &spot_request, None, 500)
.expect("spot serves one-second candles")
.target(),
"/api/v3/klines?symbol=BTCUSDT&interval=1s&limit=500"
);
assert!(matches!(
candles_request(&perp(), &perp_request, None, 500),
Err(Error::Unsupported {
feature: Feature::Candles,
..
})
));
}
#[test]
fn every_interval_in_the_common_baseline_reaches_both_venues() {
for interval in [
Interval::Min1,
Interval::Min5,
Interval::Min15,
Interval::Min30,
Interval::Hour1,
Interval::Hour4,
Interval::Day1,
Interval::Week1,
] {
assert!(
candles_request(&spot(), &CandleRequest::new(btc_usdt(), interval), None, 1)
.is_ok(),
"spot refused {interval:?}"
);
assert!(
candles_request(
&perp(),
&CandleRequest::new(btc_usdt_perp(), interval),
None,
1
)
.is_ok(),
"usd-m refused {interval:?}"
);
}
}
#[test]
fn a_market_from_the_other_venue_never_reaches_the_wire() {
assert!(trades_request(&spot(), &btc_usdt_perp(), None).is_err());
assert!(ticker_request(&perp(), &btc_usdt()).is_err());
assert!(
order_book_request(&spot(), &Market::spot(Exchange::Upbit, "BTC", "KRW"), None)
.is_err()
);
assert!(matches!(
spot_average_price_request(&perp(), &btc_usdt_perp()),
Err(Error::Unsupported {
feature: Feature::Ticker,
..
})
));
assert!(matches!(
spot_average_price_request(&spot(), &btc_usdt_perp()),
Err(Error::InvalidRequest { field, .. }) if field == "market"
));
}
#[test]
fn spot_average_price_preserves_the_server_window_price_and_close_time() {
let raw: parse::RawSpotAveragePrice = parse::json(
r#"{"mins":7,"price":"9.357518340000000000","closeTime":1694061154503}"#,
"average price",
)
.expect("official average-price payload");
let average = parse::spot_average_price(&btc_usdt(), &raw).expect("average price");
assert_eq!(average.market, btc_usdt());
assert_eq!(average.minutes, 7);
assert_eq!(average.price.to_string(), "9.357518340000000000");
assert_eq!(
average.close_time,
Timestamp::from_millis(1_694_061_154_503)
);
}
#[test]
fn every_limit_above_binances_cap_is_refused_rather_than_clamped() {
assert!(matches!(
trades_request(&spot(), &btc_usdt(), Some(1_001)),
Err(Error::InvalidRequest { field, .. }) if field == "limit"
));
assert!(matches!(
trades_request(&spot(), &btc_usdt(), Some(0)),
Err(Error::InvalidRequest { field, .. }) if field == "limit"
));
assert!(trades_request(&spot(), &btc_usdt(), Some(MAX_TRADE_LIMIT)).is_ok());
}
#[test]
fn a_query_value_is_percent_encoded_the_same_way_signing_will_see_it() {
assert_eq!(encode("BTCUSDT"), "BTCUSDT");
assert_eq!(encode("a b&c=d"), "a%20b%26c%3Dd");
assert_eq!(encode("0.1"), "0.1");
assert_eq!(
query(&[("a", "1".to_string()), ("b", "x y".to_string())]),
"a=1&b=x%20y"
);
}
#[test]
fn a_non_ascii_spot_asset_is_percent_encoded_in_public_rest_queries() {
let market = Market::spot(Exchange::Binance, "币安人生", "USDT");
assert_eq!(
trades_request(&spot(), &market, Some(1))
.expect("Binance lists UTF-8 asset names")
.target(),
"/api/v3/trades?symbol=%E5%B8%81%E5%AE%89%E4%BA%BA%E7%94%9FUSDT&limit=1"
);
}
#[test]
fn recent_trades_come_back_newest_first_whichever_order_binance_sent() {
const ASCENDING: &str = r#"[
{"id":28457,"price":"4.00000100","qty":"12.00000000","quoteQty":"48.000012",
"time":1499865549590,"isBuyerMaker":true,"isBestMatch":true},
{"id":28458,"price":"4.00000200","qty":"1.00000000","quoteQty":"4.000002",
"time":1499865549590,"isBuyerMaker":false,"isBestMatch":true},
{"id":28459,"price":"4.00000300","qty":"2.00000000","quoteQty":"8.000006",
"time":1499865549712,"isBuyerMaker":true,"isBestMatch":true}
]"#;
let raw: Vec<parse::RawTrade> =
parse::json(ASCENDING, "trades").expect("official trades payload");
assert_eq!(
raw.iter().map(|entry| entry.id).collect::<Vec<_>>(),
vec![28457, 28458, 28459],
"the payload under test must be in binance's own order"
);
let trades = newest_first(&btc_usdt(), raw).expect("three trades");
assert_eq!(
trades
.iter()
.map(|trade| trade.id.clone().unwrap_or_default())
.collect::<Vec<_>>(),
vec!["28459", "28458", "28457"]
);
assert!(
trades
.windows(2)
.all(|pair| pair[0].timestamp >= pair[1].timestamp)
);
}
#[test]
fn spot_filters_are_read_off_the_listing_by_name() {
let listing: parse::RawExchangeInfo = parse::json(
r#"{
"symbols": [
{
"symbol": "BTCUSDT",
"status": "TRADING",
"baseAsset": "BTC",
"quoteAsset": "USDT",
"filters": [
{
"filterType": "PRICE_FILTER",
"minPrice": "0.01000000",
"maxPrice": "1000000.00000000",
"tickSize": "0.01000000"
},
{
"filterType": "LOT_SIZE",
"minQty": "0.00001000",
"maxQty": "9000.00000000",
"stepSize": "0.00001000"
},
{
"filterType": "NOTIONAL",
"minNotional": "5.00000000",
"applyMinToMarket": true
},
{ "filterType": "MAX_NUM_ORDERS", "maxNumOrders": 200 }
]
}
]
}"#,
"exchangeInfo",
)
.expect("official listing payload");
let filters = symbol_filters(&listing.symbols[0]).expect("a filter set");
assert_eq!(filters.symbol, "BTCUSDT");
assert_eq!(filters.tick_size, Some(Decimal::new(1_000_000, 8)));
assert_eq!(filters.step_size, Some(Decimal::new(1_000, 8)));
assert_eq!(filters.min_notional, Some(Decimal::new(500_000_000, 8)));
assert_eq!(filters.max_quantity, Some(Decimal::new(900_000_000_000, 8)));
assert_eq!(
symbol_filters(&parse::json::<parse::RawExchangeInfo>(
r#"{"symbols":[{"symbol":"NEWUSDT","status":"TRADING","baseAsset":"NEW","quoteAsset":"USDT","filters":[]}]}"#,
"exchangeInfo"
)
.expect("a listing")
.symbols[0])
.expect("a filter set")
.tick_size,
None
);
}
}