use std::cmp::Reverse;
use rust_decimal::Decimal;
use crate::adapters::{candles as candle_pages, 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, BinanceMarket, EXCHANGE, now_millis, parse};
const MAX_TRADE_LIMIT: u32 = 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;
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 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 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 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")?)
}
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 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};
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"
);
assert_eq!(
order_book_request(&spot(), &btc_usdt(), Some(100))
.expect("a valid depth")
.target(),
"/api/v3/depth?symbol=BTCUSDT&limit=100"
);
}
#[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()
);
}
#[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
);
}
}