Skip to main content

wickra_data/live/
binance_rest.rs

1//! Binance spot REST historical kline fetcher.
2//!
3//! Where [`super::binance`] streams *live* klines over a WebSocket, this module
4//! pulls *historical* candles from Binance's public REST endpoint
5//! (`GET /api/v3/klines`) with a single blocking request. It is the native,
6//! dependency-free replacement for hand-rolled `urllib` / `jackson` / `jsonlite`
7//! download helpers in every binding.
8//!
9//! Example (requires the `live-binance` feature):
10//!
11//! ```no_run
12//! use wickra_data::live::binance::Interval;
13//! use wickra_data::live::binance_rest::fetch_klines;
14//! # fn run() -> wickra_data::Result<()> {
15//! let candles = fetch_klines("BTCUSDT", Interval::OneHour, 500, None, None)?;
16//! println!("got {} candles", candles.len());
17//! # Ok(()) }
18//! ```
19
20use std::fmt::Write as _;
21
22use serde::Deserialize;
23
24use super::binance::Interval;
25use crate::error::{Error, Result};
26use wickra_core::Candle;
27
28/// Binance allows at most 1000 klines per REST request.
29const MAX_LIMIT: u16 = 1000;
30
31/// Endpoint configuration for [`fetch_klines_with_config`]. The default points
32/// at Binance's public production REST host; tests and Testnet users override
33/// `base_url` to aim the request elsewhere.
34#[derive(Debug, Clone)]
35pub struct BinanceRestConfig {
36    /// REST host **without** path, e.g. `"https://api.binance.com"`. The
37    /// `/api/v3/klines` path and query string are appended internally.
38    pub base_url: String,
39}
40
41impl Default for BinanceRestConfig {
42    fn default() -> Self {
43        Self {
44            base_url: "https://api.binance.com".to_string(),
45        }
46    }
47}
48
49/// One row of Binance's `/api/v3/klines` response. The wire format is a fixed
50/// 12-element JSON array of mixed types; only the first seven fields carry the
51/// OHLCV candle, the rest are ignored. Deserializing positionally lets serde
52/// reject a malformed shape before we touch the numbers.
53#[derive(Debug, Deserialize)]
54struct RawRestKline(
55    i64,                   // open time (ms)
56    String,                // open
57    String,                // high
58    String,                // low
59    String,                // close
60    String,                // volume
61    serde::de::IgnoredAny, // close time
62    serde::de::IgnoredAny, // quote asset volume
63    serde::de::IgnoredAny, // number of trades
64    serde::de::IgnoredAny, // taker buy base volume
65    serde::de::IgnoredAny, // taker buy quote volume
66    serde::de::IgnoredAny, // unused
67);
68
69/// Parse one of the five OHLCV string fields into an `f64`, tagging the field
70/// name on failure so a bad payload is diagnosable.
71fn parse_field(raw: &str, field: &str) -> Result<f64> {
72    raw.parse()
73        .map_err(|_| Error::Malformed(format!("bad {field} '{raw}'")))
74}
75
76impl RawRestKline {
77    fn into_candle(self) -> Result<Candle> {
78        let open = parse_field(&self.1, "open")?;
79        let high = parse_field(&self.2, "high")?;
80        let low = parse_field(&self.3, "low")?;
81        let close = parse_field(&self.4, "close")?;
82        let volume = parse_field(&self.5, "volume")?;
83        Ok(Candle::new(open, high, low, close, volume, self.0)?)
84    }
85}
86
87/// Fetch historical klines from Binance's production endpoint.
88///
89/// `symbol` is upper-cased to match Binance's convention; `limit` must be in
90/// `1..=1000`. `start_ms` / `end_ms` are optional inclusive Unix-millisecond
91/// bounds (`None` lets Binance pick the most recent `limit` candles). Returns
92/// the candles in ascending open-time order.
93///
94/// # Errors
95/// Returns [`Error::Malformed`] for an out-of-range `limit` or an unparseable
96/// price field, [`Error::Http`] for a transport or non-2xx response,
97/// [`Error::Json`] for a malformed body, and [`Error::Core`] when a row's OHLC
98/// values violate candle invariants.
99pub fn fetch_klines(
100    symbol: &str,
101    interval: Interval,
102    limit: u16,
103    start_ms: Option<i64>,
104    end_ms: Option<i64>,
105) -> Result<Vec<Candle>> {
106    fetch_klines_with_config(
107        symbol,
108        interval,
109        limit,
110        start_ms,
111        end_ms,
112        &BinanceRestConfig::default(),
113    )
114}
115
116/// Like [`fetch_klines`] but against a custom [`BinanceRestConfig`] (Testnet or
117/// a local mock server).
118///
119/// # Errors
120/// See [`fetch_klines`].
121pub fn fetch_klines_with_config(
122    symbol: &str,
123    interval: Interval,
124    limit: u16,
125    start_ms: Option<i64>,
126    end_ms: Option<i64>,
127    config: &BinanceRestConfig,
128) -> Result<Vec<Candle>> {
129    if limit == 0 || limit > MAX_LIMIT {
130        return Err(Error::Malformed(format!(
131            "limit must be in 1..={MAX_LIMIT}, got {limit}"
132        )));
133    }
134    let mut url = format!(
135        "{}/api/v3/klines?symbol={}&interval={}&limit={}",
136        config.base_url,
137        symbol.to_uppercase(),
138        interval.as_str(),
139        limit
140    );
141    if let Some(start) = start_ms {
142        let _ = write!(url, "&startTime={start}");
143    }
144    if let Some(end) = end_ms {
145        let _ = write!(url, "&endTime={end}");
146    }
147
148    // ureq 2.x does not auto-configure native-tls, so build an agent with an
149    // explicit native-tls connector (verifies against the OS trust store; no
150    // bundled CA roots). The connector is cheap and a one-shot fetch needs no
151    // pooling, so we build it per call.
152    let connector = native_tls::TlsConnector::new()
153        .map_err(|e| Error::Malformed(format!("native-tls init failed: {e}")))?;
154    let agent = ureq::AgentBuilder::new()
155        .tls_connector(std::sync::Arc::new(connector))
156        .build();
157    let body = agent.get(&url).call().map_err(Box::new)?.into_string()?;
158    let rows: Vec<RawRestKline> = serde_json::from_str(&body)?;
159    rows.into_iter().map(RawRestKline::into_candle).collect()
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use std::io::{Read, Write};
166    use std::net::TcpListener;
167    use std::thread::JoinHandle;
168
169    /// A canonical single-row klines response (12-element array, OHLCV in the
170    /// first seven slots) — mirrors Binance's documented wire format.
171    fn sample_response() -> String {
172        r#"[[1700000000000,"30000.0","30100.0","29950.0","30050.0","12.5",1700000059999,"375000.0",50,"6.25","187500.0","0"]]"#.to_string()
173    }
174
175    /// Spawn a one-shot mock HTTP server: accept a single connection, read the
176    /// request, and reply `200 OK` with `body`. Returns the base URL plus a
177    /// [`JoinHandle`] the test joins so the handler thread always reaches its
178    /// closing brace (every step `.unwrap()`s — a failure is a test bug).
179    fn mock_http(status_line: &'static str, body: String) -> (String, JoinHandle<()>) {
180        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
181        let addr = listener.local_addr().unwrap();
182        let handle = std::thread::spawn(move || {
183            let (mut stream, _) = listener.accept().unwrap();
184            let mut buf = [0u8; 1024];
185            let _ = stream.read(&mut buf).unwrap();
186            let response = format!(
187                "{status_line}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
188                body.len()
189            );
190            stream.write_all(response.as_bytes()).unwrap();
191        });
192        (format!("http://{addr}"), handle)
193    }
194
195    #[test]
196    fn fetch_parses_a_real_klines_response() {
197        let (base, handle) = mock_http("HTTP/1.1 200 OK", sample_response());
198        let candles = fetch_klines_with_config(
199            "btcusdt",
200            Interval::OneHour,
201            1,
202            Some(1_700_000_000_000),
203            Some(1_700_000_059_999),
204            &BinanceRestConfig { base_url: base },
205        )
206        .unwrap();
207        handle.join().unwrap();
208        assert_eq!(candles.len(), 1);
209        assert_eq!(candles[0].open, 30_000.0);
210        assert_eq!(candles[0].high, 30_100.0);
211        assert_eq!(candles[0].low, 29_950.0);
212        assert_eq!(candles[0].close, 30_050.0);
213        assert_eq!(candles[0].volume, 12.5);
214        assert_eq!(candles[0].timestamp, 1_700_000_000_000);
215    }
216
217    #[test]
218    fn fetch_handles_an_empty_result() {
219        let (base, handle) = mock_http("HTTP/1.1 200 OK", "[]".to_string());
220        let candles = fetch_klines_with_config(
221            "BTCUSDT",
222            Interval::OneMinute,
223            10,
224            None,
225            None,
226            &BinanceRestConfig { base_url: base },
227        )
228        .unwrap();
229        handle.join().unwrap();
230        assert!(candles.is_empty());
231    }
232
233    #[test]
234    fn fetch_rejects_a_zero_limit() {
235        let err = fetch_klines("BTCUSDT", Interval::OneHour, 0, None, None).unwrap_err();
236        assert!(matches!(err, Error::Malformed(_)));
237    }
238
239    #[test]
240    fn fetch_rejects_a_limit_above_the_cap() {
241        let err =
242            fetch_klines("BTCUSDT", Interval::OneHour, MAX_LIMIT + 1, None, None).unwrap_err();
243        assert!(matches!(err, Error::Malformed(_)));
244    }
245
246    #[test]
247    fn fetch_surfaces_a_transport_error_for_an_unreachable_host() {
248        // Port 1 is privileged and unbound — the connection is refused.
249        let err = fetch_klines_with_config(
250            "BTCUSDT",
251            Interval::OneHour,
252            1,
253            None,
254            None,
255            &BinanceRestConfig {
256                base_url: "http://127.0.0.1:1".to_string(),
257            },
258        )
259        .unwrap_err();
260        assert!(matches!(err, Error::Http(_)));
261    }
262
263    #[test]
264    fn fetch_surfaces_a_json_error_for_a_malformed_body() {
265        let (base, handle) = mock_http("HTTP/1.1 200 OK", "not json".to_string());
266        let err = fetch_klines_with_config(
267            "BTCUSDT",
268            Interval::OneHour,
269            1,
270            None,
271            None,
272            &BinanceRestConfig { base_url: base },
273        )
274        .unwrap_err();
275        handle.join().unwrap();
276        assert!(matches!(err, Error::Json(_)));
277    }
278
279    #[test]
280    fn fetch_rejects_an_unparseable_price_field() {
281        let body =
282            r#"[[1700000000000,"not-a-number","0","0","0","0",0,"0",0,"0","0","0"]]"#.to_string();
283        let (base, handle) = mock_http("HTTP/1.1 200 OK", body);
284        let err = fetch_klines_with_config(
285            "BTCUSDT",
286            Interval::OneHour,
287            1,
288            None,
289            None,
290            &BinanceRestConfig { base_url: base },
291        )
292        .unwrap_err();
293        handle.join().unwrap();
294        assert!(matches!(err, Error::Malformed(_)));
295    }
296
297    #[test]
298    fn fetch_rejects_a_row_that_violates_candle_invariants() {
299        // high (1) below low (100) — Candle::new rejects it.
300        let body = r#"[[1700000000000,"50","1","100","50","1",0,"0",0,"0","0","0"]]"#.to_string();
301        let (base, handle) = mock_http("HTTP/1.1 200 OK", body);
302        let err = fetch_klines_with_config(
303            "BTCUSDT",
304            Interval::OneHour,
305            1,
306            None,
307            None,
308            &BinanceRestConfig { base_url: base },
309        )
310        .unwrap_err();
311        handle.join().unwrap();
312        assert!(matches!(err, Error::Core(_)));
313    }
314
315    #[test]
316    fn rest_config_default_targets_production() {
317        assert_eq!(
318            BinanceRestConfig::default().base_url,
319            "https://api.binance.com"
320        );
321    }
322}