Skip to main content

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}