rustrade_data/exchange/binance/historical.rs
1//! Historical klines (OHLCV candles) via Binance's **public, unauthenticated**
2//! REST endpoints.
3//!
4//! Gives consumers free historical candle data for research/backtest on both
5//! [`BinanceSpot`](crate::exchange::binance::spot::BinanceSpot) and
6//! [`BinanceFuturesUsd`](crate::exchange::binance::futures::BinanceFuturesUsd) — no API key, no paid
7//! data subscription. Construct a client for the surface you want and call
8//! [`fetch_candles`](BinanceHistoricalClient::fetch_candles):
9//!
10//! ```ignore
11//! use rustrade_data::exchange::binance::historical::BinanceHistoricalClient;
12//! use rustrade_data::subscription::candle::CandleInterval;
13//! use chrono::{Duration, Utc};
14//! use futures::StreamExt;
15//!
16//! let client = BinanceHistoricalClient::spot();
17//! let end = Utc::now();
18//! let start = end - Duration::days(1);
19//!
20//! let mut stream = client.fetch_candles("BTCUSDT", CandleInterval::Min1, start, end);
21//! while let Some(candle) = stream.next().await {
22//! println!("{:?}", candle?);
23//! }
24//! ```
25//!
26//! # Two surfaces, one mapping
27//!
28//! Spot and futures return the **same** array-of-arrays row shape and share one
29//! row→[`Candle`](crate::subscription::candle::Candle) mapping. They differ only on host, page cap, and URL params:
30//!
31//! | Surface | Endpoint | Host | Page cap | Market param |
32//! |---|---|---|---|---|
33//! | [`spot`](BinanceHistoricalClient::spot) | `/api/v3/klines` | `api.binance.com` | 1000 | `symbol` |
34//! | [`futures`](BinanceHistoricalClient::futures) | `/fapi/v1/continuousKlines` | `fapi.binance.com` | 1500 | `pair` + `contractType=PERPETUAL` |
35//!
36//! The futures path uses the **continuous-contract** surface (`contractType=PERPETUAL`)
37//! rather than `/fapi/v1/klines`. For a perpetual this is the same data as the
38//! symbol klines **plus** sub-minute resolutions: `/fapi/v1/klines` returns
39//! `400 Invalid interval` for [`Sec1`](crate::subscription::candle::CandleInterval::Sec1), whereas the
40//! continuous surface serves genuine 1-second candles.
41//!
42//! # Rate limits & resumable backfill
43//!
44//! On HTTP `429`/`418` the stream yields
45//! [`BinanceDataError::RateLimited`] and **ends** — it never waits, retries, or
46//! runs a process-global limiter (the consumer owns retry/backoff). The stream is
47//! **resumable**: on a `RateLimited` error, wait `retry_after`, then re-invoke
48//! `fetch_candles` with `start` advanced to **1ms past the last `close_time`
49//! already received** (`last_close_time + 1ms`, i.e. the next candle's open). The
50//! `[start, end]` range is `close_time`-inclusive, so resuming exactly at
51//! `last_close_time` would re-yield that final candle; the `+1ms` step skips it
52//! without leaving a gap. No progress is lost — pagination keys off `open_time`,
53//! and `open ≡ close − interval`.
54//!
55//! A long unattended backfill (a 90-day `1s` series is ≈ 7.8M candles over
56//! thousands of requests) will **not** "just work" without that resume loop on
57//! the consumer side, but the default [pre-pacing](BinanceHistoricalClient#pre-pacing)
58//! keeps a single steady backfill within Binance's weight budget so the common
59//! case rarely trips a `429` in the first place.
60
61use super::error::BinanceDataError;
62use crate::subscription::candle::{
63 Candle, CandleInterval, close_time_from_open, open_time_from_close,
64};
65use async_stream::try_stream;
66use chrono::{DateTime, Utc};
67use futures::{Stream, StreamExt};
68use reqwest::{Client, StatusCode};
69use rust_decimal::Decimal;
70use serde::Deserialize;
71use std::time::Duration;
72use tracing::debug;
73
74/// Spot REST base URL (`/api/v3/klines`).
75const SPOT_BASE_URL: &str = "https://api.binance.com";
76/// USD-M futures REST base URL (`/fapi/v1/continuousKlines`).
77const FUTURES_BASE_URL: &str = "https://fapi.binance.com";
78
79/// Maximum klines per page on the spot surface.
80const SPOT_PAGE_LIMIT: u32 = 1000;
81/// Maximum klines per page on the futures continuous surface.
82const FUTURES_PAGE_LIMIT: u32 = 1500;
83
84/// Default inter-page pace for the **spot** surface.
85///
86/// Spot `/api/v3/klines` is flat **weight 2/req** against the IP budget of
87/// ~6,000 weight/min ⇒ ~3,000 req/min. A ~20ms floor keeps a single backfill
88/// comfortably under that (throughput ceiling ≈ 3M candles/min at 1000/page).
89const DEFAULT_SPOT_PACE: Duration = Duration::from_millis(20);
90
91/// Default inter-page pace for the **futures** continuous surface.
92///
93/// Futures `/fapi/v1/continuousKlines` weight is **limit-based** — weight 10/req
94/// at the 1500/page max — against a *lower* ~2,400 weight/min budget ⇒ only
95/// ~240 req/min. Futures is therefore far more request-constrained than spot
96/// despite its bigger page, so its default pace is ~12× larger (~250ms ⇒
97/// throughput ≈ 360k candles/min).
98const DEFAULT_FUTURES_PACE: Duration = Duration::from_millis(250);
99
100/// Per-request HTTP timeout.
101const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
102
103/// Which Binance REST surface a [`BinanceHistoricalClient`] targets.
104///
105/// Selects the endpoint path, page cap, and market query parameter. Set once at
106/// construction via [`BinanceHistoricalClient::spot`] / [`BinanceHistoricalClient::futures`].
107#[derive(Copy, Clone, Eq, PartialEq, Debug)]
108enum Surface {
109 /// Spot `/api/v3/klines` (`symbol=`, max 1000/page).
110 Spot,
111 /// USD-M futures continuous contract `/fapi/v1/continuousKlines`
112 /// (`pair=` + `contractType=PERPETUAL`, max 1500/page).
113 FuturesContinuous,
114}
115
116impl Surface {
117 /// Maximum klines returned per page on this surface.
118 fn page_limit(self) -> u32 {
119 match self {
120 Surface::Spot => SPOT_PAGE_LIMIT,
121 Surface::FuturesContinuous => FUTURES_PAGE_LIMIT,
122 }
123 }
124}
125
126/// REST client for Binance historical klines on a single surface (spot **or**
127/// futures-continuous). Construct with [`spot`](Self::spot) or
128/// [`futures`](Self::futures); both bake in the surface's host, page cap, and a
129/// conservative default [pre-pace](#pre-pacing).
130///
131/// # Pre-pacing
132///
133/// A fixed, bounded delay is applied **between pages** so a single backfill stays
134/// within Binance's weight budget without tripping `429`/`418`. It is
135/// `tracing`-observable (logged at `debug`) and **caller-overridable** via
136/// [`with_pace`](Self::with_pace). This is *proactive courtesy only* — it never
137/// inspects a `429`, never retries, and never adapts to `retry_after`; the
138/// surface-`RateLimited`-and-end contract is unchanged.
139#[derive(Clone, Debug)]
140pub struct BinanceHistoricalClient {
141 client: Client,
142 base_url: String,
143 surface: Surface,
144 pace: Duration,
145}
146
147impl BinanceHistoricalClient {
148 /// Create a client for the **spot** surface (`/api/v3/klines`, max 1000/page,
149 /// default pace ~20ms).
150 #[must_use]
151 pub fn spot() -> Self {
152 Self {
153 client: Client::new(),
154 base_url: SPOT_BASE_URL.to_owned(),
155 surface: Surface::Spot,
156 pace: DEFAULT_SPOT_PACE,
157 }
158 }
159
160 /// Create a client for the **USD-M futures continuous** surface
161 /// (`/fapi/v1/continuousKlines`, `contractType=PERPETUAL`, max 1500/page,
162 /// default pace ~250ms).
163 ///
164 /// This is the surface that unlocks [`Sec1`](CandleInterval::Sec1) on
165 /// futures; `/fapi/v1/klines` rejects it with `400 Invalid interval`.
166 #[must_use]
167 pub fn futures() -> Self {
168 Self {
169 client: Client::new(),
170 base_url: FUTURES_BASE_URL.to_owned(),
171 surface: Surface::FuturesContinuous,
172 pace: DEFAULT_FUTURES_PACE,
173 }
174 }
175
176 /// Override the inter-page [pre-pace](#pre-pacing).
177 ///
178 /// Use a smaller value on a higher API weight tier, or [`Duration::ZERO`] to
179 /// disable pacing entirely (the caller then owns staying within the weight
180 /// budget). The default is sized to the surface's *public* weight budget.
181 #[must_use]
182 pub fn with_pace(mut self, pace: Duration) -> Self {
183 self.pace = pace;
184 self
185 }
186
187 /// Override the REST base URL (for tests against a mock server).
188 #[must_use]
189 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
190 self.base_url = base_url.into();
191 self
192 }
193
194 /// Inject a pre-built [`reqwest::Client`].
195 ///
196 /// By default each constructor builds its own `Client` (one connection pool
197 /// per client). Pass a shared `Client` here to reuse a single pool across,
198 /// e.g., a spot and a futures client, or to apply custom transport
199 /// configuration (proxy, TLS).
200 ///
201 /// Note: a 30-second per-request timeout is always applied at request level
202 /// regardless of any client-level timeout configured on the injected
203 /// `Client`, so a shorter client-level timeout will not take effect.
204 #[must_use]
205 pub fn with_client(mut self, client: Client) -> Self {
206 self.client = client;
207 self
208 }
209
210 /// Fetch historical candles for `symbol` at `interval`, paginating
211 /// automatically across the surface's page cap.
212 ///
213 /// Returns a [`Stream`] that processes each page as it arrives (does **not**
214 /// buffer the whole range in memory — a 90d `1s` backfill is millions of
215 /// candles). For a convenience `Vec` see [`collect_candles`](Self::collect_candles).
216 ///
217 /// # Range contract
218 ///
219 /// Yields exactly the candles whose [`close_time`](Candle::close_time) falls
220 /// in `[start, end]` (both inclusive), matched on `close_time` — the field
221 /// consumers receive. Binance filters by the bar's *open* time, so this method
222 /// maps both bounds from `close_time` to `open_time` (lower bound widens to
223 /// capture the candle whose `close_time == start`, i.e. `open == start −
224 /// interval`; upper bound narrows to `open == end − interval`) and trims by
225 /// `close_time`, consistent with the library's other historical fetches.
226 ///
227 /// `close_time` is computed library-side as the exclusive period-end boundary
228 /// (`open_time + interval`) — Binance's raw wire `closeTime` (`period-end −
229 /// 1ms`) is **discarded** (see [`Candle::close_time`]).
230 ///
231 /// Zero-trade periods are **not** dropped: Binance REST server-side gap-fills
232 /// them (`volume == 0`, OHLC == prior close), and the library delivers them
233 /// (filtering is consumer policy). The live WS path omits them entirely — an
234 /// asymmetry consumers should expect.
235 ///
236 /// # Arguments
237 ///
238 /// * `symbol` - Market symbol, e.g. `"BTCUSDT"` (uppercased for Binance).
239 /// * `interval` - Candle resolution. Both Binance surfaces support the full
240 /// [`CandleInterval`] set, including [`Sec1`](CandleInterval::Sec1).
241 /// * `start` / `end` - Inclusive `close_time` range bounds.
242 ///
243 /// # Errors
244 ///
245 /// Each yielded item is a `Result`. On HTTP `429`/`418` the stream yields
246 /// [`BinanceDataError::RateLimited`] and ends (resume by re-calling with
247 /// `start` = last received `close_time` **+ 1ms**, the next candle's open —
248 /// resuming exactly at the last `close_time` re-yields that candle, as the
249 /// range is `close_time`-inclusive). Other failures surface as
250 /// [`BinanceDataError::Api`] / [`Http`](BinanceDataError::Http) /
251 /// [`Deserialize`](BinanceDataError::Deserialize) /
252 /// [`InvalidInput`](BinanceDataError::InvalidInput).
253 #[must_use = "fetch_candles returns a lazy Stream that does nothing unless polled"]
254 pub fn fetch_candles<'a>(
255 &'a self,
256 symbol: &'a str,
257 interval: CandleInterval,
258 start: DateTime<Utc>,
259 end: DateTime<Utc>,
260 ) -> impl Stream<Item = Result<Candle, BinanceDataError>> + 'a {
261 try_stream! {
262 // An inverted range is a caller error, not an empty result: Binance
263 // would return an empty array (silent success) or a confusing 400.
264 // A zero-width range (`start == end`) stays valid — it yields the
265 // single candle whose `close_time == start == end`.
266 if start > end {
267 Err(BinanceDataError::InvalidInput {
268 message: format!("start ({start}) must not be after end ({end})"),
269 })?;
270 }
271 let market = validate_symbol(symbol)?;
272 let step = interval.to_step();
273
274 // Range contract: yield candles whose `close_time ∈ [start, end]`.
275 // Binance filters by the bar's open-time, so widen the lower bound by
276 // one interval to capture the candle whose `close_time == start`
277 // (open == start − interval), then trim by `close_time` below.
278 // `None` (underflow near DateTime::MIN_UTC) is not an error: the
279 // boundary candle would have an unrepresentable open and so cannot
280 // exist, making the un-widened bound already correct.
281 let request_start = open_time_from_close(start, step).unwrap_or(start);
282 let mut start_ms = request_start.timestamp_millis();
283 // Mirror the lower bound: `endTime` is an open-time filter too, and the
284 // last wanted candle (`close_time == end`) opens at `end − interval`.
285 // Narrowing the upper bound the same way keeps `endTime` an honest
286 // open-time value (not a close-time) and makes the trim exact on the
287 // upper end. Underflow near DateTime::MIN ⇒ fall back to `end`: the
288 // `close_time <= end` trim below stays exact regardless, so the
289 // un-narrowed bound only requests at most one extra page and never
290 // admits an out-of-range candle.
291 let request_end = open_time_from_close(end, step).unwrap_or(end);
292 let end_ms = request_end.timestamp_millis();
293 let limit = self.surface.page_limit();
294
295 loop {
296 let url = self.page_url(&market, interval, start_ms, end_ms, limit);
297 debug!(%url, "Fetching Binance klines page");
298
299 let rows = self.fetch_page(&url).await?;
300 if rows.is_empty() {
301 break;
302 }
303
304 // Advance the cursor to the next candle's open BEFORE trimming, so
305 // pagination is driven purely by open-time (path ii) and never by
306 // the trimmed/yielded subset.
307 let last_open_ms = rows[rows.len() - 1].open_time_ms;
308 let row_count = rows.len();
309
310 for row in rows {
311 let candle = row.into_candle(interval)?;
312 if candle.close_time >= start && candle.close_time <= end {
313 yield candle;
314 }
315 }
316
317 // A short page means Binance had no more data in the window.
318 if row_count < limit as usize {
319 break;
320 }
321
322 // Next page starts at the candle after the last one received:
323 // open_of_last + interval (keyed off open-time, consistent with
324 // path ii). Overflow ⇒ no further representable candles ⇒ stop.
325 let Some(next_open) = close_time_from_open(
326 DateTime::from_timestamp_millis(last_open_ms)
327 .ok_or_else(|| BinanceDataError::Deserialize {
328 message: format!("open_time {last_open_ms} out of range"),
329 payload: String::new(),
330 })?,
331 step,
332 ) else {
333 break;
334 };
335 start_ms = next_open.timestamp_millis();
336 if start_ms > end_ms {
337 break;
338 }
339
340 // Proactive courtesy pace between pages (see struct docs). Bounded,
341 // observable, never reacts to a 429.
342 if !self.pace.is_zero() {
343 debug!(pace_ms = self.pace.as_millis(), "Pacing before next klines page");
344 tokio::time::sleep(self.pace).await;
345 }
346 }
347 }
348 }
349
350 /// Convenience wrapper over [`fetch_candles`](Self::fetch_candles) that
351 /// collects the full range into a `Vec` (oldest first).
352 ///
353 /// **Heavy for large ranges** — a 90d `1s` backfill is millions of `Candle`s
354 /// (hundreds of MB). Prefer the streaming API for long ranges.
355 #[must_use = "collect_candles returns the fetched candles (or an error) that should be used"]
356 pub async fn collect_candles(
357 &self,
358 symbol: &str,
359 interval: CandleInterval,
360 start: DateTime<Utc>,
361 end: DateTime<Utc>,
362 ) -> Result<Vec<Candle>, BinanceDataError> {
363 let mut stream = std::pin::pin!(self.fetch_candles(symbol, interval, start, end));
364 let mut candles = Vec::new();
365 while let Some(candle) = stream.next().await {
366 candles.push(candle?);
367 }
368 Ok(candles)
369 }
370
371 /// Build the paginated request URL for this surface.
372 fn page_url(
373 &self,
374 market: &str,
375 interval: CandleInterval,
376 start_ms: i64,
377 end_ms: i64,
378 limit: u32,
379 ) -> String {
380 match self.surface {
381 Surface::Spot => format!(
382 "{}/api/v3/klines?symbol={}&interval={}&startTime={}&endTime={}&limit={}",
383 self.base_url,
384 market,
385 interval.as_str(),
386 start_ms,
387 end_ms,
388 limit,
389 ),
390 Surface::FuturesContinuous => format!(
391 "{}/fapi/v1/continuousKlines?pair={}&contractType=PERPETUAL&interval={}&startTime={}&endTime={}&limit={}",
392 self.base_url,
393 market,
394 interval.as_str(),
395 start_ms,
396 end_ms,
397 limit,
398 ),
399 }
400 }
401
402 /// Fetch and deserialise a single page of klines.
403 async fn fetch_page(&self, url: &str) -> Result<Vec<BinanceKlineRow>, BinanceDataError> {
404 let response = self.client.get(url).timeout(REQUEST_TIMEOUT).send().await?;
405 let status = response.status();
406
407 // Extract retry-after before consuming the body. Only the integer
408 // delay-seconds form is parsed; the RFC 7231 §7.1.3 HTTP-date form is
409 // not supported (Binance sends delay-seconds) and yields `None`.
410 let retry_after = response
411 .headers()
412 .get(reqwest::header::RETRY_AFTER)
413 .and_then(|v| v.to_str().ok())
414 .and_then(|s| s.parse::<u64>().ok())
415 .map(Duration::from_secs);
416
417 // 429 (rate limited) and 418 (IP banned for repeat violations) both end
418 // the stream with RateLimited — the consumer owns retry/backoff/resume.
419 if status == StatusCode::TOO_MANY_REQUESTS || status == StatusCode::IM_A_TEAPOT {
420 return Err(BinanceDataError::RateLimited { retry_after });
421 }
422
423 let body = response.text().await?;
424
425 if !status.is_success() {
426 return Err(BinanceDataError::Api {
427 status: status.as_u16(),
428 message: truncate_body(&body),
429 });
430 }
431
432 serde_json::from_str::<Vec<BinanceKlineRow>>(&body).map_err(|e| {
433 BinanceDataError::Deserialize {
434 message: e.to_string(),
435 payload: truncate_body(&body),
436 }
437 })
438 }
439}
440
441/// Validate and normalise a market symbol for a Binance REST request.
442///
443/// Binance REST requires uppercase symbols and rejects lowercase with `400
444/// Invalid symbol`, so the symbol is uppercased. Rejects empty input and any
445/// URL-breaking characters up front (an observable client-side error rather than
446/// a confusing API 400).
447fn validate_symbol(symbol: &str) -> Result<String, BinanceDataError> {
448 if symbol.is_empty() {
449 return Err(BinanceDataError::InvalidInput {
450 message: "symbol must not be empty".to_owned(),
451 });
452 }
453 if symbol.contains(['/', '?', '#', ' ', '%', '&', '=', '+']) {
454 return Err(BinanceDataError::InvalidInput {
455 message: format!("symbol contains invalid URL characters: {symbol:?}"),
456 });
457 }
458 Ok(symbol.to_uppercase())
459}
460
461/// Truncate a response body for error messages (max 512 chars, UTF-8 safe).
462fn truncate_body(body: &str) -> String {
463 let boundary = body.floor_char_boundary(512);
464 body[..boundary].to_owned()
465}
466
467/// One Binance kline, as the wire's positional array-of-arrays row.
468///
469/// Both the spot (`/api/v3/klines`) and futures continuous
470/// (`/fapi/v1/continuousKlines`) surfaces return the identical layout:
471///
472/// ```text
473/// [ openTime(int ms), open(str), high(str), low(str), close(str),
474/// volume(str), closeTime(int ms), quoteVolume(str), trades(int), ... ]
475/// ```
476///
477/// OHLCV are JSON **strings** and are parsed `str`→[`Decimal`] (an `f64` hop
478/// would silently truncate precision). `openTime`/`trades` are JSON integers.
479/// The wire `closeTime` (index 6) is **ignored** — [`into_candle`](Self::into_candle)
480/// recomputes the boundary from `openTime` (see [`Candle::close_time`]).
481#[derive(Debug, Clone, PartialEq)]
482struct BinanceKlineRow {
483 open_time_ms: i64,
484 open: Decimal,
485 high: Decimal,
486 low: Decimal,
487 close: Decimal,
488 volume: Decimal,
489 trade_count: u64,
490}
491
492impl BinanceKlineRow {
493 /// Map this row to a normalised [`Candle`].
494 ///
495 /// `close_time = close_time_from_open(openTime, interval.step)` — the exclusive
496 /// period-end boundary, **not** the wire `closeTime` (`period-end − 1ms`).
497 fn into_candle(self, interval: CandleInterval) -> Result<Candle, BinanceDataError> {
498 let open_time = DateTime::from_timestamp_millis(self.open_time_ms).ok_or_else(|| {
499 BinanceDataError::Deserialize {
500 message: format!("open_time {} out of representable range", self.open_time_ms),
501 payload: String::new(),
502 }
503 })?;
504
505 let close_time = close_time_from_open(open_time, interval.to_step()).ok_or_else(|| {
506 BinanceDataError::Deserialize {
507 message: format!(
508 "close_time overflow: open={open_time}, interval={}",
509 interval.as_str()
510 ),
511 payload: String::new(),
512 }
513 })?;
514
515 Ok(Candle {
516 close_time,
517 open: self.open,
518 high: self.high,
519 low: self.low,
520 close: self.close,
521 volume: self.volume,
522 trade_count: self.trade_count,
523 })
524 }
525}
526
527impl<'de> Deserialize<'de> for BinanceKlineRow {
528 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
529 where
530 D: serde::Deserializer<'de>,
531 {
532 use serde::de::{self, SeqAccess, Visitor};
533 use std::fmt;
534
535 struct RowVisitor;
536
537 impl<'de> Visitor<'de> for RowVisitor {
538 type Value = BinanceKlineRow;
539
540 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
541 f.write_str("a Binance kline array [openTime, O, H, L, C, V, closeTime, ...]")
542 }
543
544 fn visit_seq<A>(self, mut seq: A) -> Result<BinanceKlineRow, A::Error>
545 where
546 A: SeqAccess<'de>,
547 {
548 /// Read the array element at index `idx` (field `field`), erroring
549 /// if the array ran short. `idx` is the actual element count seen so
550 /// far, so `invalid_length` reports an honest length rather than `0`.
551 macro_rules! next {
552 ($idx:literal, $field:literal, $ty:ty) => {
553 seq.next_element::<$ty>()?.ok_or_else(|| {
554 de::Error::invalid_length($idx, &concat!("missing ", $field))
555 })?
556 };
557 }
558
559 let open_time_ms = next!(0, "openTime", i64);
560 // OHLCV arrive as JSON strings; parse str→Decimal (never f64).
561 let open = parse_decimal::<A::Error>(next!(1, "open", &str))?;
562 let high = parse_decimal::<A::Error>(next!(2, "high", &str))?;
563 let low = parse_decimal::<A::Error>(next!(3, "low", &str))?;
564 let close = parse_decimal::<A::Error>(next!(4, "close", &str))?;
565 let volume = parse_decimal::<A::Error>(next!(5, "volume", &str))?;
566 // [6] closeTime — consumed and ignored (boundary is recomputed).
567 let _close_time = next!(6, "closeTime", de::IgnoredAny);
568 // [7] quoteVolume — ignored.
569 let _quote_volume = next!(7, "quoteVolume", de::IgnoredAny);
570 let trade_count = next!(8, "trades", u64);
571
572 // Drain any trailing elements (takerBuyBase, takerBuyQuote, …) so
573 // the seq is fully consumed.
574 while seq.next_element::<de::IgnoredAny>()?.is_some() {}
575
576 Ok(BinanceKlineRow {
577 open_time_ms,
578 open,
579 high,
580 low,
581 close,
582 volume,
583 trade_count,
584 })
585 }
586 }
587
588 deserializer.deserialize_seq(RowVisitor)
589 }
590}
591
592/// Parse a Binance OHLCV string field as [`Decimal`], mapping a parse failure to
593/// a serde error so it surfaces as [`BinanceDataError::Deserialize`].
594fn parse_decimal<E: serde::de::Error>(raw: &str) -> Result<Decimal, E> {
595 raw.parse::<Decimal>()
596 .map_err(|e| E::custom(format!("invalid decimal {raw:?}: {e}")))
597}
598
599#[cfg(test)]
600// Test code: panics on bad input are acceptable (expect carries a diagnostic message).
601#[allow(clippy::unwrap_used, clippy::expect_used)]
602mod tests {
603 use super::*;
604 use rust_decimal_macros::dec;
605
606 #[test]
607 fn row_deserializes_ohlcv_as_decimal_strings() {
608 // Real spot row shape (trailing fields present); OHLCV are JSON strings.
609 let json = r#"[1780908960000,"63073.95000000","63093.79000000","63072.61000000","63093.79000000","3.09099000",1780909019999,"194978.89758500",1617,"1.48973000","93971.70741680","0"]"#;
610 let row: BinanceKlineRow = serde_json::from_str(json).unwrap();
611 assert_eq!(row.open_time_ms, 1_780_908_960_000);
612 assert_eq!(row.open, dec!(63073.95000000));
613 assert_eq!(row.high, dec!(63093.79000000));
614 assert_eq!(row.low, dec!(63072.61000000));
615 assert_eq!(row.close, dec!(63093.79000000));
616 assert_eq!(row.volume, dec!(3.09099000));
617 assert_eq!(row.trade_count, 1617);
618 }
619
620 #[test]
621 fn high_precision_ohlcv_round_trips_exactly() {
622 // An f64 intermediate would truncate this; str→Decimal must not.
623 let json = r#"[0,"0.000000010000000","0.000000010000000","0.000000010000000","0.000000010000000","0.000000010000000",59999,"0",0]"#;
624 let row: BinanceKlineRow = serde_json::from_str(json).unwrap();
625 assert_eq!(row.open, dec!(0.000000010000000));
626 assert_eq!(row.open.to_string(), "0.000000010000000");
627 }
628
629 #[test]
630 fn row_into_candle_recomputes_close_time_from_open() {
631 // Wire closeTime (index 6) is open + 59999ms; the candle's close_time must
632 // be the exclusive boundary open + 60000ms instead.
633 let json = r#"[1780908960000,"1","2","0.5","1.5","10",1780909019999,"0",42]"#;
634 let row: BinanceKlineRow = serde_json::from_str(json).unwrap();
635 let candle = row.into_candle(CandleInterval::Min1).unwrap();
636 assert_eq!(candle.close_time.timestamp_millis(), 1_780_909_020_000);
637 assert_eq!(candle.open, dec!(1));
638 assert_eq!(candle.high, dec!(2));
639 assert_eq!(candle.low, dec!(0.5));
640 assert_eq!(candle.close, dec!(1.5));
641 assert_eq!(candle.volume, dec!(10));
642 assert_eq!(candle.trade_count, 42);
643 }
644
645 #[test]
646 fn zero_volume_gap_filled_candle_maps_not_dropped() {
647 // Binance REST gap-fills zero-trade periods (V=0, OHLC=prev close). The
648 // mapping must produce a candle — dropping V=0 is consumer policy.
649 let json = r#"[1780909046000,"63051.50","63051.50","63051.50","63051.50","0",1780909046999,"0",0]"#;
650 let row: BinanceKlineRow = serde_json::from_str(json).unwrap();
651 let candle = row.into_candle(CandleInterval::Sec1).unwrap();
652 assert_eq!(candle.volume, Decimal::ZERO);
653 assert_eq!(candle.trade_count, 0);
654 assert_eq!(candle.close_time.timestamp_millis(), 1_780_909_047_000);
655 }
656
657 #[test]
658 fn malformed_decimal_is_observable_error_not_silent() {
659 let json = r#"[0,"not_a_number","2","0.5","1.5","10",59999,"0",1]"#;
660 let err = serde_json::from_str::<BinanceKlineRow>(json).unwrap_err();
661 assert!(err.to_string().contains("invalid decimal"), "{err}");
662 }
663
664 #[test]
665 fn short_row_is_error_not_silent_default() {
666 // A truncated row must fail loudly rather than defaulting missing fields.
667 let json = r#"[0,"1","2","0.5"]"#;
668 assert!(serde_json::from_str::<BinanceKlineRow>(json).is_err());
669 }
670
671 #[test]
672 fn validate_symbol_uppercases_and_rejects_bad_input() {
673 assert_eq!(validate_symbol("btcusdt").unwrap(), "BTCUSDT");
674 assert!(validate_symbol("").is_err());
675 assert!(validate_symbol("BTC/USDT").is_err());
676 assert!(validate_symbol("BTC USDT").is_err());
677 }
678
679 #[test]
680 fn page_url_differs_per_surface() {
681 let spot = BinanceHistoricalClient::spot();
682 let url = spot.page_url("BTCUSDT", CandleInterval::Min1, 100, 200, 1000);
683 assert!(url.contains("/api/v3/klines?symbol=BTCUSDT"));
684 assert!(url.contains("interval=1m"));
685 assert!(!url.contains("contractType"));
686
687 let futures = BinanceHistoricalClient::futures();
688 let url = futures.page_url("BTCUSDT", CandleInterval::Sec1, 100, 200, 1500);
689 assert!(url.contains("/fapi/v1/continuousKlines?pair=BTCUSDT"));
690 assert!(url.contains("contractType=PERPETUAL"));
691 assert!(url.contains("interval=1s"));
692 assert!(url.contains("limit=1500"));
693 }
694
695 #[test]
696 fn surface_defaults_are_distinct() {
697 assert_eq!(BinanceHistoricalClient::spot().pace, DEFAULT_SPOT_PACE);
698 assert_eq!(
699 BinanceHistoricalClient::futures().pace,
700 DEFAULT_FUTURES_PACE
701 );
702 assert_eq!(Surface::Spot.page_limit(), 1000);
703 assert_eq!(Surface::FuturesContinuous.page_limit(), 1500);
704 }
705
706 #[test]
707 fn with_pace_overrides_default() {
708 let c = BinanceHistoricalClient::futures().with_pace(Duration::from_millis(500));
709 assert_eq!(c.pace, Duration::from_millis(500));
710 }
711
712 #[tokio::test]
713 async fn inverted_range_is_invalid_input_not_empty() {
714 // An inverted range must surface as an observable client-side error
715 // before any network I/O — not a silently-empty result.
716 let end = DateTime::from_timestamp_millis(1_780_908_960_000).unwrap();
717 let start = end + chrono::Duration::hours(1);
718 let err = BinanceHistoricalClient::spot()
719 .collect_candles("BTCUSDT", CandleInterval::Min1, start, end)
720 .await
721 .unwrap_err();
722 assert!(
723 matches!(err, BinanceDataError::InvalidInput { .. }),
724 "expected InvalidInput, got {err:?}"
725 );
726 }
727
728 // --- Mock-server pagination / rate-limit tests -------------------------------------------
729 //
730 // A throwaway in-process HTTP/1.1 server (std-only, no extra dependency) serves canned
731 // responses to the real `reqwest` client via `with_base_url`, exercising the multi-page
732 // loop, the `close_time` trim, the resume contract, and the `RateLimited`-then-ends path.
733
734 use std::io::{Read, Write};
735 use std::net::TcpListener;
736
737 /// One canned HTTP response for the mock server.
738 struct MockResponse {
739 status: u16,
740 retry_after_secs: Option<u64>,
741 body: String,
742 }
743
744 fn ok(body: String) -> MockResponse {
745 MockResponse {
746 status: 200,
747 retry_after_secs: None,
748 body,
749 }
750 }
751
752 /// Spawn a single-shot HTTP/1.1 server that serves `responses` in order, one per accepted
753 /// connection (`Connection: close` makes `reqwest` open a fresh connection per page). Returns
754 /// `(base_url, join_handle)`. The caller must:
755 ///
756 /// 1. Pass the base URL to [`with_base_url`](BinanceHistoricalClient::with_base_url).
757 /// 2. Drive **exactly** `responses.len()` page requests (fewer leaves the server thread
758 /// parked in `accept()` with responses unserved, so the test silently covers fewer pages
759 /// than intended).
760 /// 3. Call `handle.join().expect("mock server panicked")` after all awaits complete so that
761 /// any server-side panic surfaces as a test failure rather than being swallowed.
762 fn spawn_mock(responses: Vec<MockResponse>) -> (String, std::thread::JoinHandle<()>) {
763 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
764 let base = format!("http://{}", listener.local_addr().unwrap());
765 let handle = std::thread::spawn(move || {
766 for resp in responses {
767 let (mut stream, _) = listener.accept().unwrap();
768 // A GET carries no body; one read of the request line + headers is enough on
769 // loopback (the request is far smaller than the buffer — we only need to let the
770 // client finish writing before we reply; the bytes themselves are discarded).
771 let _ = stream.read(&mut [0u8; 4096]);
772 let retry = resp
773 .retry_after_secs
774 .map(|s| format!("Retry-After: {s}\r\n"))
775 .unwrap_or_default();
776 let http = format!(
777 "HTTP/1.1 {} X\r\nContent-Type: application/json\r\n{retry}Content-Length: {}\r\nConnection: close\r\n\r\n{}",
778 resp.status,
779 resp.body.len(),
780 resp.body,
781 );
782 stream.write_all(http.as_bytes()).unwrap();
783 }
784 });
785 (base, handle)
786 }
787
788 /// A minimal valid spot kline row (9 elements) opening at `open_ms`. The wire `closeTime`
789 /// (index 6) is `open + 59_999ms`, so the recomputed exclusive boundary (`open + 60_000ms`)
790 /// is provably not the wire value.
791 fn row(open_ms: i64) -> String {
792 format!(
793 r#"[{open_ms},"1","2","0.5","1.5","10",{},"0",1]"#,
794 open_ms + 59_999
795 )
796 }
797
798 /// Build a JSON array page from the given row open-times.
799 fn page(opens: impl IntoIterator<Item = i64>) -> String {
800 let rows: Vec<String> = opens.into_iter().map(row).collect();
801 format!("[{}]", rows.join(","))
802 }
803
804 fn ms(millis: i64) -> DateTime<Utc> {
805 DateTime::from_timestamp_millis(millis).unwrap()
806 }
807
808 const MIN_MS: i64 = 60_000;
809 const BASE_MS: i64 = 1_700_000_000_000;
810
811 #[tokio::test]
812 async fn paginates_across_pages_without_gap_or_duplicate() {
813 // Page 1 fills the spot page cap (1000) so the loop continues; page 2 is a short page
814 // that terminates it. The cursor advance keys off open-time, so the page seam must be
815 // seamless: candle[1000] follows candle[999] by exactly one interval.
816 let page1 = page((0..1000).map(|i| BASE_MS + i * MIN_MS));
817 let page2 = page([BASE_MS + 1000 * MIN_MS, BASE_MS + 1001 * MIN_MS]);
818 let (url, server) = spawn_mock(vec![ok(page1), ok(page2)]);
819
820 let candles = BinanceHistoricalClient::spot()
821 .with_base_url(url)
822 .with_pace(Duration::ZERO)
823 .collect_candles(
824 "BTCUSDT",
825 CandleInterval::Min1,
826 ms(BASE_MS + MIN_MS), // close of row 0
827 ms(BASE_MS + 1002 * MIN_MS), // close of the last row
828 )
829 .await
830 .unwrap();
831
832 assert_eq!(
833 candles.len(),
834 1002,
835 "every row across both pages is delivered once"
836 );
837 for w in candles.windows(2) {
838 assert_eq!(
839 w[1].close_time - w[0].close_time,
840 chrono::Duration::milliseconds(MIN_MS),
841 "close_time must advance by exactly one interval — no gap, no duplicate at the seam",
842 );
843 }
844 server.join().expect("mock server panicked");
845 }
846
847 #[tokio::test]
848 async fn close_time_trim_excludes_out_of_range_candles() {
849 // A page of three candles (close_times base+1m, base+2m, base+3m); the window admits
850 // only the middle one, proving both trim bounds are exact and applied per row.
851 let (url, server) = spawn_mock(vec![ok(page([
852 BASE_MS,
853 BASE_MS + MIN_MS,
854 BASE_MS + 2 * MIN_MS,
855 ]))]);
856
857 let candles = BinanceHistoricalClient::spot()
858 .with_base_url(url)
859 .with_pace(Duration::ZERO)
860 .collect_candles(
861 "BTCUSDT",
862 CandleInterval::Min1,
863 ms(BASE_MS + 2 * MIN_MS), // == close of the middle candle
864 ms(BASE_MS + 2 * MIN_MS),
865 )
866 .await
867 .unwrap();
868
869 assert_eq!(candles.len(), 1);
870 assert_eq!(candles[0].close_time, ms(BASE_MS + 2 * MIN_MS));
871 server.join().expect("mock server panicked");
872 }
873
874 #[tokio::test]
875 async fn resume_at_last_close_plus_1ms_skips_the_boundary_duplicate() {
876 // The candle already received before the 429 has close_time `last_close`. Binance widens
877 // the lower bound by one interval, so a resume page leads with that boundary candle
878 // (open = last_close − interval) followed by the next candle (open = last_close). The mock
879 // serves this page verbatim regardless of the query params, so the exclusion asserted
880 // below is driven by the library's `close_time >= start` trim, not Binance-side
881 // `startTime` filtering — which is exactly the layer the resume contract relies on.
882 let last_close = BASE_MS + MIN_MS;
883 let resume_page = page([last_close - MIN_MS, last_close]);
884
885 // Resuming exactly at `last_close` re-yields the boundary candle (the `[start, end]`
886 // lower bound is inclusive) → duplicate. This is the trap the resume contract warns of.
887 let (dup_url, dup_server) = spawn_mock(vec![ok(resume_page.clone())]);
888 let dup = BinanceHistoricalClient::spot()
889 .with_base_url(dup_url)
890 .with_pace(Duration::ZERO)
891 .collect_candles(
892 "BTCUSDT",
893 CandleInterval::Min1,
894 ms(last_close),
895 ms(last_close + MIN_MS),
896 )
897 .await
898 .unwrap();
899 assert_eq!(
900 dup.len(),
901 2,
902 "resuming at last close_time re-delivers it (documents the duplicate)"
903 );
904 dup_server.join().expect("mock server panicked");
905
906 // Resuming at `last_close + 1ms` (the documented contract) skips the boundary candle and
907 // delivers only the next one — lossless and duplicate-free.
908 let (resumed_url, resumed_server) = spawn_mock(vec![ok(resume_page)]);
909 let resumed = BinanceHistoricalClient::spot()
910 .with_base_url(resumed_url)
911 .with_pace(Duration::ZERO)
912 .collect_candles(
913 "BTCUSDT",
914 CandleInterval::Min1,
915 ms(last_close + 1),
916 ms(last_close + MIN_MS),
917 )
918 .await
919 .unwrap();
920 assert_eq!(
921 resumed.len(),
922 1,
923 "last_close + 1ms resume skips the duplicate"
924 );
925 assert_eq!(resumed[0].close_time, ms(last_close + MIN_MS));
926 resumed_server.join().expect("mock server panicked");
927 }
928
929 #[tokio::test]
930 async fn rate_limited_yields_once_then_ends() {
931 let (url, server) = spawn_mock(vec![MockResponse {
932 status: 429,
933 retry_after_secs: Some(30),
934 body: "{}".to_owned(),
935 }]);
936
937 let client = BinanceHistoricalClient::spot()
938 .with_base_url(url)
939 .with_pace(Duration::ZERO);
940 let mut stream = std::pin::pin!(client.fetch_candles(
941 "BTCUSDT",
942 CandleInterval::Min1,
943 ms(0),
944 ms(MIN_MS)
945 ));
946
947 let first = stream
948 .next()
949 .await
950 .expect("the stream must yield the rate-limit error");
951 assert!(
952 matches!(
953 first,
954 Err(BinanceDataError::RateLimited { retry_after: Some(d) }) if d == Duration::from_secs(30)
955 ),
956 "429 must yield RateLimited carrying the parsed Retry-After, got {first:?}",
957 );
958 assert!(
959 stream.next().await.is_none(),
960 "the stream must end after RateLimited"
961 );
962 server.join().expect("mock server panicked");
963 }
964}