use super::error::BinanceDataError;
use crate::subscription::candle::{
Candle, CandleInterval, close_time_from_open, open_time_from_close,
};
use async_stream::try_stream;
use chrono::{DateTime, Utc};
use futures::{Stream, StreamExt};
use reqwest::{Client, StatusCode};
use rust_decimal::Decimal;
use serde::Deserialize;
use std::time::Duration;
use tracing::debug;
const SPOT_BASE_URL: &str = "https://api.binance.com";
const FUTURES_BASE_URL: &str = "https://fapi.binance.com";
const SPOT_PAGE_LIMIT: u32 = 1000;
const FUTURES_PAGE_LIMIT: u32 = 1500;
const DEFAULT_SPOT_PACE: Duration = Duration::from_millis(20);
const DEFAULT_FUTURES_PACE: Duration = Duration::from_millis(250);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
enum Surface {
Spot,
FuturesContinuous,
}
impl Surface {
fn page_limit(self) -> u32 {
match self {
Surface::Spot => SPOT_PAGE_LIMIT,
Surface::FuturesContinuous => FUTURES_PAGE_LIMIT,
}
}
}
#[derive(Clone, Debug)]
pub struct BinanceHistoricalClient {
client: Client,
base_url: String,
surface: Surface,
pace: Duration,
}
impl BinanceHistoricalClient {
#[must_use]
pub fn spot() -> Self {
Self {
client: Client::new(),
base_url: SPOT_BASE_URL.to_owned(),
surface: Surface::Spot,
pace: DEFAULT_SPOT_PACE,
}
}
#[must_use]
pub fn futures() -> Self {
Self {
client: Client::new(),
base_url: FUTURES_BASE_URL.to_owned(),
surface: Surface::FuturesContinuous,
pace: DEFAULT_FUTURES_PACE,
}
}
#[must_use]
pub fn with_pace(mut self, pace: Duration) -> Self {
self.pace = pace;
self
}
#[must_use]
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into();
self
}
#[must_use]
pub fn with_client(mut self, client: Client) -> Self {
self.client = client;
self
}
#[must_use = "fetch_candles returns a lazy Stream that does nothing unless polled"]
pub fn fetch_candles<'a>(
&'a self,
symbol: &'a str,
interval: CandleInterval,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> impl Stream<Item = Result<Candle, BinanceDataError>> + 'a {
try_stream! {
if start > end {
Err(BinanceDataError::InvalidInput {
message: format!("start ({start}) must not be after end ({end})"),
})?;
}
let market = validate_symbol(symbol)?;
let step = interval.to_step();
let request_start = open_time_from_close(start, step).unwrap_or(start);
let mut start_ms = request_start.timestamp_millis();
let request_end = open_time_from_close(end, step).unwrap_or(end);
let end_ms = request_end.timestamp_millis();
let limit = self.surface.page_limit();
loop {
let url = self.page_url(&market, interval, start_ms, end_ms, limit);
debug!(%url, "Fetching Binance klines page");
let rows = self.fetch_page(&url).await?;
if rows.is_empty() {
break;
}
let last_open_ms = rows[rows.len() - 1].open_time_ms;
let row_count = rows.len();
for row in rows {
let candle = row.into_candle(interval)?;
if candle.close_time >= start && candle.close_time <= end {
yield candle;
}
}
if row_count < limit as usize {
break;
}
let Some(next_open) = close_time_from_open(
DateTime::from_timestamp_millis(last_open_ms)
.ok_or_else(|| BinanceDataError::Deserialize {
message: format!("open_time {last_open_ms} out of range"),
payload: String::new(),
})?,
step,
) else {
break;
};
start_ms = next_open.timestamp_millis();
if start_ms > end_ms {
break;
}
if !self.pace.is_zero() {
debug!(pace_ms = self.pace.as_millis(), "Pacing before next klines page");
tokio::time::sleep(self.pace).await;
}
}
}
}
#[must_use = "collect_candles returns the fetched candles (or an error) that should be used"]
pub async fn collect_candles(
&self,
symbol: &str,
interval: CandleInterval,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> Result<Vec<Candle>, BinanceDataError> {
let mut stream = std::pin::pin!(self.fetch_candles(symbol, interval, start, end));
let mut candles = Vec::new();
while let Some(candle) = stream.next().await {
candles.push(candle?);
}
Ok(candles)
}
fn page_url(
&self,
market: &str,
interval: CandleInterval,
start_ms: i64,
end_ms: i64,
limit: u32,
) -> String {
match self.surface {
Surface::Spot => format!(
"{}/api/v3/klines?symbol={}&interval={}&startTime={}&endTime={}&limit={}",
self.base_url,
market,
interval.as_str(),
start_ms,
end_ms,
limit,
),
Surface::FuturesContinuous => format!(
"{}/fapi/v1/continuousKlines?pair={}&contractType=PERPETUAL&interval={}&startTime={}&endTime={}&limit={}",
self.base_url,
market,
interval.as_str(),
start_ms,
end_ms,
limit,
),
}
}
async fn fetch_page(&self, url: &str) -> Result<Vec<BinanceKlineRow>, BinanceDataError> {
let response = self.client.get(url).timeout(REQUEST_TIMEOUT).send().await?;
let status = response.status();
let retry_after = response
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.map(Duration::from_secs);
if status == StatusCode::TOO_MANY_REQUESTS || status == StatusCode::IM_A_TEAPOT {
return Err(BinanceDataError::RateLimited { retry_after });
}
let body = response.text().await?;
if !status.is_success() {
return Err(BinanceDataError::Api {
status: status.as_u16(),
message: truncate_body(&body),
});
}
serde_json::from_str::<Vec<BinanceKlineRow>>(&body).map_err(|e| {
BinanceDataError::Deserialize {
message: e.to_string(),
payload: truncate_body(&body),
}
})
}
}
fn validate_symbol(symbol: &str) -> Result<String, BinanceDataError> {
if symbol.is_empty() {
return Err(BinanceDataError::InvalidInput {
message: "symbol must not be empty".to_owned(),
});
}
if symbol.contains(['/', '?', '#', ' ', '%', '&', '=', '+']) {
return Err(BinanceDataError::InvalidInput {
message: format!("symbol contains invalid URL characters: {symbol:?}"),
});
}
Ok(symbol.to_uppercase())
}
fn truncate_body(body: &str) -> String {
let boundary = body.floor_char_boundary(512);
body[..boundary].to_owned()
}
#[derive(Debug, Clone, PartialEq)]
struct BinanceKlineRow {
open_time_ms: i64,
open: Decimal,
high: Decimal,
low: Decimal,
close: Decimal,
volume: Decimal,
trade_count: u64,
}
impl BinanceKlineRow {
fn into_candle(self, interval: CandleInterval) -> Result<Candle, BinanceDataError> {
let open_time = DateTime::from_timestamp_millis(self.open_time_ms).ok_or_else(|| {
BinanceDataError::Deserialize {
message: format!("open_time {} out of representable range", self.open_time_ms),
payload: String::new(),
}
})?;
let close_time = close_time_from_open(open_time, interval.to_step()).ok_or_else(|| {
BinanceDataError::Deserialize {
message: format!(
"close_time overflow: open={open_time}, interval={}",
interval.as_str()
),
payload: String::new(),
}
})?;
Ok(Candle {
close_time,
open: self.open,
high: self.high,
low: self.low,
close: self.close,
volume: self.volume,
trade_count: self.trade_count,
})
}
}
impl<'de> Deserialize<'de> for BinanceKlineRow {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::{self, SeqAccess, Visitor};
use std::fmt;
struct RowVisitor;
impl<'de> Visitor<'de> for RowVisitor {
type Value = BinanceKlineRow;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a Binance kline array [openTime, O, H, L, C, V, closeTime, ...]")
}
fn visit_seq<A>(self, mut seq: A) -> Result<BinanceKlineRow, A::Error>
where
A: SeqAccess<'de>,
{
macro_rules! next {
($idx:literal, $field:literal, $ty:ty) => {
seq.next_element::<$ty>()?.ok_or_else(|| {
de::Error::invalid_length($idx, &concat!("missing ", $field))
})?
};
}
let open_time_ms = next!(0, "openTime", i64);
let open = parse_decimal::<A::Error>(next!(1, "open", &str))?;
let high = parse_decimal::<A::Error>(next!(2, "high", &str))?;
let low = parse_decimal::<A::Error>(next!(3, "low", &str))?;
let close = parse_decimal::<A::Error>(next!(4, "close", &str))?;
let volume = parse_decimal::<A::Error>(next!(5, "volume", &str))?;
let _close_time = next!(6, "closeTime", de::IgnoredAny);
let _quote_volume = next!(7, "quoteVolume", de::IgnoredAny);
let trade_count = next!(8, "trades", u64);
while seq.next_element::<de::IgnoredAny>()?.is_some() {}
Ok(BinanceKlineRow {
open_time_ms,
open,
high,
low,
close,
volume,
trade_count,
})
}
}
deserializer.deserialize_seq(RowVisitor)
}
}
fn parse_decimal<E: serde::de::Error>(raw: &str) -> Result<Decimal, E> {
raw.parse::<Decimal>()
.map_err(|e| E::custom(format!("invalid decimal {raw:?}: {e}")))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn row_deserializes_ohlcv_as_decimal_strings() {
let json = r#"[1780908960000,"63073.95000000","63093.79000000","63072.61000000","63093.79000000","3.09099000",1780909019999,"194978.89758500",1617,"1.48973000","93971.70741680","0"]"#;
let row: BinanceKlineRow = serde_json::from_str(json).unwrap();
assert_eq!(row.open_time_ms, 1_780_908_960_000);
assert_eq!(row.open, dec!(63073.95000000));
assert_eq!(row.high, dec!(63093.79000000));
assert_eq!(row.low, dec!(63072.61000000));
assert_eq!(row.close, dec!(63093.79000000));
assert_eq!(row.volume, dec!(3.09099000));
assert_eq!(row.trade_count, 1617);
}
#[test]
fn high_precision_ohlcv_round_trips_exactly() {
let json = r#"[0,"0.000000010000000","0.000000010000000","0.000000010000000","0.000000010000000","0.000000010000000",59999,"0",0]"#;
let row: BinanceKlineRow = serde_json::from_str(json).unwrap();
assert_eq!(row.open, dec!(0.000000010000000));
assert_eq!(row.open.to_string(), "0.000000010000000");
}
#[test]
fn row_into_candle_recomputes_close_time_from_open() {
let json = r#"[1780908960000,"1","2","0.5","1.5","10",1780909019999,"0",42]"#;
let row: BinanceKlineRow = serde_json::from_str(json).unwrap();
let candle = row.into_candle(CandleInterval::Min1).unwrap();
assert_eq!(candle.close_time.timestamp_millis(), 1_780_909_020_000);
assert_eq!(candle.open, dec!(1));
assert_eq!(candle.high, dec!(2));
assert_eq!(candle.low, dec!(0.5));
assert_eq!(candle.close, dec!(1.5));
assert_eq!(candle.volume, dec!(10));
assert_eq!(candle.trade_count, 42);
}
#[test]
fn zero_volume_gap_filled_candle_maps_not_dropped() {
let json = r#"[1780909046000,"63051.50","63051.50","63051.50","63051.50","0",1780909046999,"0",0]"#;
let row: BinanceKlineRow = serde_json::from_str(json).unwrap();
let candle = row.into_candle(CandleInterval::Sec1).unwrap();
assert_eq!(candle.volume, Decimal::ZERO);
assert_eq!(candle.trade_count, 0);
assert_eq!(candle.close_time.timestamp_millis(), 1_780_909_047_000);
}
#[test]
fn malformed_decimal_is_observable_error_not_silent() {
let json = r#"[0,"not_a_number","2","0.5","1.5","10",59999,"0",1]"#;
let err = serde_json::from_str::<BinanceKlineRow>(json).unwrap_err();
assert!(err.to_string().contains("invalid decimal"), "{err}");
}
#[test]
fn short_row_is_error_not_silent_default() {
let json = r#"[0,"1","2","0.5"]"#;
assert!(serde_json::from_str::<BinanceKlineRow>(json).is_err());
}
#[test]
fn validate_symbol_uppercases_and_rejects_bad_input() {
assert_eq!(validate_symbol("btcusdt").unwrap(), "BTCUSDT");
assert!(validate_symbol("").is_err());
assert!(validate_symbol("BTC/USDT").is_err());
assert!(validate_symbol("BTC USDT").is_err());
}
#[test]
fn page_url_differs_per_surface() {
let spot = BinanceHistoricalClient::spot();
let url = spot.page_url("BTCUSDT", CandleInterval::Min1, 100, 200, 1000);
assert!(url.contains("/api/v3/klines?symbol=BTCUSDT"));
assert!(url.contains("interval=1m"));
assert!(!url.contains("contractType"));
let futures = BinanceHistoricalClient::futures();
let url = futures.page_url("BTCUSDT", CandleInterval::Sec1, 100, 200, 1500);
assert!(url.contains("/fapi/v1/continuousKlines?pair=BTCUSDT"));
assert!(url.contains("contractType=PERPETUAL"));
assert!(url.contains("interval=1s"));
assert!(url.contains("limit=1500"));
}
#[test]
fn surface_defaults_are_distinct() {
assert_eq!(BinanceHistoricalClient::spot().pace, DEFAULT_SPOT_PACE);
assert_eq!(
BinanceHistoricalClient::futures().pace,
DEFAULT_FUTURES_PACE
);
assert_eq!(Surface::Spot.page_limit(), 1000);
assert_eq!(Surface::FuturesContinuous.page_limit(), 1500);
}
#[test]
fn with_pace_overrides_default() {
let c = BinanceHistoricalClient::futures().with_pace(Duration::from_millis(500));
assert_eq!(c.pace, Duration::from_millis(500));
}
#[tokio::test]
async fn inverted_range_is_invalid_input_not_empty() {
let end = DateTime::from_timestamp_millis(1_780_908_960_000).unwrap();
let start = end + chrono::Duration::hours(1);
let err = BinanceHistoricalClient::spot()
.collect_candles("BTCUSDT", CandleInterval::Min1, start, end)
.await
.unwrap_err();
assert!(
matches!(err, BinanceDataError::InvalidInput { .. }),
"expected InvalidInput, got {err:?}"
);
}
use std::io::{Read, Write};
use std::net::TcpListener;
struct MockResponse {
status: u16,
retry_after_secs: Option<u64>,
body: String,
}
fn ok(body: String) -> MockResponse {
MockResponse {
status: 200,
retry_after_secs: None,
body,
}
}
fn spawn_mock(responses: Vec<MockResponse>) -> (String, std::thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let handle = std::thread::spawn(move || {
for resp in responses {
let (mut stream, _) = listener.accept().unwrap();
let _ = stream.read(&mut [0u8; 4096]);
let retry = resp
.retry_after_secs
.map(|s| format!("Retry-After: {s}\r\n"))
.unwrap_or_default();
let http = format!(
"HTTP/1.1 {} X\r\nContent-Type: application/json\r\n{retry}Content-Length: {}\r\nConnection: close\r\n\r\n{}",
resp.status,
resp.body.len(),
resp.body,
);
stream.write_all(http.as_bytes()).unwrap();
}
});
(base, handle)
}
fn row(open_ms: i64) -> String {
format!(
r#"[{open_ms},"1","2","0.5","1.5","10",{},"0",1]"#,
open_ms + 59_999
)
}
fn page(opens: impl IntoIterator<Item = i64>) -> String {
let rows: Vec<String> = opens.into_iter().map(row).collect();
format!("[{}]", rows.join(","))
}
fn ms(millis: i64) -> DateTime<Utc> {
DateTime::from_timestamp_millis(millis).unwrap()
}
const MIN_MS: i64 = 60_000;
const BASE_MS: i64 = 1_700_000_000_000;
#[tokio::test]
async fn paginates_across_pages_without_gap_or_duplicate() {
let page1 = page((0..1000).map(|i| BASE_MS + i * MIN_MS));
let page2 = page([BASE_MS + 1000 * MIN_MS, BASE_MS + 1001 * MIN_MS]);
let (url, server) = spawn_mock(vec![ok(page1), ok(page2)]);
let candles = BinanceHistoricalClient::spot()
.with_base_url(url)
.with_pace(Duration::ZERO)
.collect_candles(
"BTCUSDT",
CandleInterval::Min1,
ms(BASE_MS + MIN_MS), ms(BASE_MS + 1002 * MIN_MS), )
.await
.unwrap();
assert_eq!(
candles.len(),
1002,
"every row across both pages is delivered once"
);
for w in candles.windows(2) {
assert_eq!(
w[1].close_time - w[0].close_time,
chrono::Duration::milliseconds(MIN_MS),
"close_time must advance by exactly one interval — no gap, no duplicate at the seam",
);
}
server.join().expect("mock server panicked");
}
#[tokio::test]
async fn close_time_trim_excludes_out_of_range_candles() {
let (url, server) = spawn_mock(vec![ok(page([
BASE_MS,
BASE_MS + MIN_MS,
BASE_MS + 2 * MIN_MS,
]))]);
let candles = BinanceHistoricalClient::spot()
.with_base_url(url)
.with_pace(Duration::ZERO)
.collect_candles(
"BTCUSDT",
CandleInterval::Min1,
ms(BASE_MS + 2 * MIN_MS), ms(BASE_MS + 2 * MIN_MS),
)
.await
.unwrap();
assert_eq!(candles.len(), 1);
assert_eq!(candles[0].close_time, ms(BASE_MS + 2 * MIN_MS));
server.join().expect("mock server panicked");
}
#[tokio::test]
async fn resume_at_last_close_plus_1ms_skips_the_boundary_duplicate() {
let last_close = BASE_MS + MIN_MS;
let resume_page = page([last_close - MIN_MS, last_close]);
let (dup_url, dup_server) = spawn_mock(vec![ok(resume_page.clone())]);
let dup = BinanceHistoricalClient::spot()
.with_base_url(dup_url)
.with_pace(Duration::ZERO)
.collect_candles(
"BTCUSDT",
CandleInterval::Min1,
ms(last_close),
ms(last_close + MIN_MS),
)
.await
.unwrap();
assert_eq!(
dup.len(),
2,
"resuming at last close_time re-delivers it (documents the duplicate)"
);
dup_server.join().expect("mock server panicked");
let (resumed_url, resumed_server) = spawn_mock(vec![ok(resume_page)]);
let resumed = BinanceHistoricalClient::spot()
.with_base_url(resumed_url)
.with_pace(Duration::ZERO)
.collect_candles(
"BTCUSDT",
CandleInterval::Min1,
ms(last_close + 1),
ms(last_close + MIN_MS),
)
.await
.unwrap();
assert_eq!(
resumed.len(),
1,
"last_close + 1ms resume skips the duplicate"
);
assert_eq!(resumed[0].close_time, ms(last_close + MIN_MS));
resumed_server.join().expect("mock server panicked");
}
#[tokio::test]
async fn rate_limited_yields_once_then_ends() {
let (url, server) = spawn_mock(vec![MockResponse {
status: 429,
retry_after_secs: Some(30),
body: "{}".to_owned(),
}]);
let client = BinanceHistoricalClient::spot()
.with_base_url(url)
.with_pace(Duration::ZERO);
let mut stream = std::pin::pin!(client.fetch_candles(
"BTCUSDT",
CandleInterval::Min1,
ms(0),
ms(MIN_MS)
));
let first = stream
.next()
.await
.expect("the stream must yield the rate-limit error");
assert!(
matches!(
first,
Err(BinanceDataError::RateLimited { retry_after: Some(d) }) if d == Duration::from_secs(30)
),
"429 must yield RateLimited carrying the parsed Retry-After, got {first:?}",
);
assert!(
stream.next().await.is_none(),
"the stream must end after RateLimited"
);
server.join().expect("mock server panicked");
}
}