wickra-data 0.9.9

Data sources for Wickra: CSV readers, tick-to-candle aggregator, and live exchange feeds.
Documentation
//! Binance spot REST historical kline fetcher.
//!
//! Where [`super::binance`] streams *live* klines over a WebSocket, this module
//! pulls *historical* candles from Binance's public REST endpoint
//! (`GET /api/v3/klines`) with a single blocking request. It is the native,
//! dependency-free replacement for hand-rolled `urllib` / `jackson` / `jsonlite`
//! download helpers in every binding.
//!
//! Example (requires the `live-binance` feature):
//!
//! ```no_run
//! use wickra_data::live::binance::Interval;
//! use wickra_data::live::binance_rest::fetch_klines;
//! # fn run() -> wickra_data::Result<()> {
//! let candles = fetch_klines("BTCUSDT", Interval::OneHour, 500, None, None)?;
//! println!("got {} candles", candles.len());
//! # Ok(()) }
//! ```

use std::fmt::Write as _;

use serde::Deserialize;

use super::binance::Interval;
use crate::error::{Error, Result};
use wickra_core::Candle;

/// Binance allows at most 1000 klines per REST request.
const MAX_LIMIT: u16 = 1000;

/// Endpoint configuration for [`fetch_klines_with_config`]. The default points
/// at Binance's public production REST host; tests and Testnet users override
/// `base_url` to aim the request elsewhere.
#[derive(Debug, Clone)]
pub struct BinanceRestConfig {
    /// REST host **without** path, e.g. `"https://api.binance.com"`. The
    /// `/api/v3/klines` path and query string are appended internally.
    pub base_url: String,
}

impl Default for BinanceRestConfig {
    fn default() -> Self {
        Self {
            base_url: "https://api.binance.com".to_string(),
        }
    }
}

/// One row of Binance's `/api/v3/klines` response. The wire format is a fixed
/// 12-element JSON array of mixed types; only the first seven fields carry the
/// OHLCV candle, the rest are ignored. Deserializing positionally lets serde
/// reject a malformed shape before we touch the numbers.
#[derive(Debug, Deserialize)]
struct RawRestKline(
    i64,                   // open time (ms)
    String,                // open
    String,                // high
    String,                // low
    String,                // close
    String,                // volume
    serde::de::IgnoredAny, // close time
    serde::de::IgnoredAny, // quote asset volume
    serde::de::IgnoredAny, // number of trades
    serde::de::IgnoredAny, // taker buy base volume
    serde::de::IgnoredAny, // taker buy quote volume
    serde::de::IgnoredAny, // unused
);

/// Parse one of the five OHLCV string fields into an `f64`, tagging the field
/// name on failure so a bad payload is diagnosable.
fn parse_field(raw: &str, field: &str) -> Result<f64> {
    raw.parse()
        .map_err(|_| Error::Malformed(format!("bad {field} '{raw}'")))
}

impl RawRestKline {
    fn into_candle(self) -> Result<Candle> {
        let open = parse_field(&self.1, "open")?;
        let high = parse_field(&self.2, "high")?;
        let low = parse_field(&self.3, "low")?;
        let close = parse_field(&self.4, "close")?;
        let volume = parse_field(&self.5, "volume")?;
        Ok(Candle::new(open, high, low, close, volume, self.0)?)
    }
}

/// Fetch historical klines from Binance's production endpoint.
///
/// `symbol` is upper-cased to match Binance's convention; `limit` must be in
/// `1..=1000`. `start_ms` / `end_ms` are optional inclusive Unix-millisecond
/// bounds (`None` lets Binance pick the most recent `limit` candles). Returns
/// the candles in ascending open-time order.
///
/// # Errors
/// Returns [`Error::Malformed`] for an out-of-range `limit` or an unparseable
/// price field, [`Error::Http`] for a transport or non-2xx response,
/// [`Error::Json`] for a malformed body, and [`Error::Core`] when a row's OHLC
/// values violate candle invariants.
pub fn fetch_klines(
    symbol: &str,
    interval: Interval,
    limit: u16,
    start_ms: Option<i64>,
    end_ms: Option<i64>,
) -> Result<Vec<Candle>> {
    fetch_klines_with_config(
        symbol,
        interval,
        limit,
        start_ms,
        end_ms,
        &BinanceRestConfig::default(),
    )
}

/// Like [`fetch_klines`] but against a custom [`BinanceRestConfig`] (Testnet or
/// a local mock server).
///
/// # Errors
/// See [`fetch_klines`].
pub fn fetch_klines_with_config(
    symbol: &str,
    interval: Interval,
    limit: u16,
    start_ms: Option<i64>,
    end_ms: Option<i64>,
    config: &BinanceRestConfig,
) -> Result<Vec<Candle>> {
    if limit == 0 || limit > MAX_LIMIT {
        return Err(Error::Malformed(format!(
            "limit must be in 1..={MAX_LIMIT}, got {limit}"
        )));
    }
    let mut url = format!(
        "{}/api/v3/klines?symbol={}&interval={}&limit={}",
        config.base_url,
        symbol.to_uppercase(),
        interval.as_str(),
        limit
    );
    if let Some(start) = start_ms {
        let _ = write!(url, "&startTime={start}");
    }
    if let Some(end) = end_ms {
        let _ = write!(url, "&endTime={end}");
    }

    // ureq 2.x does not auto-configure native-tls, so build an agent with an
    // explicit native-tls connector (verifies against the OS trust store; no
    // bundled CA roots). The connector is cheap and a one-shot fetch needs no
    // pooling, so we build it per call.
    let connector = native_tls::TlsConnector::new()
        .map_err(|e| Error::Malformed(format!("native-tls init failed: {e}")))?;
    let agent = ureq::AgentBuilder::new()
        .tls_connector(std::sync::Arc::new(connector))
        .build();
    let body = agent.get(&url).call().map_err(Box::new)?.into_string()?;
    let rows: Vec<RawRestKline> = serde_json::from_str(&body)?;
    rows.into_iter().map(RawRestKline::into_candle).collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::{Read, Write};
    use std::net::TcpListener;
    use std::thread::JoinHandle;

    /// A canonical single-row klines response (12-element array, OHLCV in the
    /// first seven slots) — mirrors Binance's documented wire format.
    fn sample_response() -> String {
        r#"[[1700000000000,"30000.0","30100.0","29950.0","30050.0","12.5",1700000059999,"375000.0",50,"6.25","187500.0","0"]]"#.to_string()
    }

    /// Spawn a one-shot mock HTTP server: accept a single connection, read the
    /// request, and reply `200 OK` with `body`. Returns the base URL plus a
    /// [`JoinHandle`] the test joins so the handler thread always reaches its
    /// closing brace (every step `.unwrap()`s — a failure is a test bug).
    fn mock_http(status_line: &'static str, body: String) -> (String, JoinHandle<()>) {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let addr = listener.local_addr().unwrap();
        let handle = std::thread::spawn(move || {
            let (mut stream, _) = listener.accept().unwrap();
            let mut buf = [0u8; 1024];
            let _ = stream.read(&mut buf).unwrap();
            let response = format!(
                "{status_line}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
                body.len()
            );
            stream.write_all(response.as_bytes()).unwrap();
        });
        (format!("http://{addr}"), handle)
    }

    #[test]
    fn fetch_parses_a_real_klines_response() {
        let (base, handle) = mock_http("HTTP/1.1 200 OK", sample_response());
        let candles = fetch_klines_with_config(
            "btcusdt",
            Interval::OneHour,
            1,
            Some(1_700_000_000_000),
            Some(1_700_000_059_999),
            &BinanceRestConfig { base_url: base },
        )
        .unwrap();
        handle.join().unwrap();
        assert_eq!(candles.len(), 1);
        assert_eq!(candles[0].open, 30_000.0);
        assert_eq!(candles[0].high, 30_100.0);
        assert_eq!(candles[0].low, 29_950.0);
        assert_eq!(candles[0].close, 30_050.0);
        assert_eq!(candles[0].volume, 12.5);
        assert_eq!(candles[0].timestamp, 1_700_000_000_000);
    }

    #[test]
    fn fetch_handles_an_empty_result() {
        let (base, handle) = mock_http("HTTP/1.1 200 OK", "[]".to_string());
        let candles = fetch_klines_with_config(
            "BTCUSDT",
            Interval::OneMinute,
            10,
            None,
            None,
            &BinanceRestConfig { base_url: base },
        )
        .unwrap();
        handle.join().unwrap();
        assert!(candles.is_empty());
    }

    #[test]
    fn fetch_rejects_a_zero_limit() {
        let err = fetch_klines("BTCUSDT", Interval::OneHour, 0, None, None).unwrap_err();
        assert!(matches!(err, Error::Malformed(_)));
    }

    #[test]
    fn fetch_rejects_a_limit_above_the_cap() {
        let err =
            fetch_klines("BTCUSDT", Interval::OneHour, MAX_LIMIT + 1, None, None).unwrap_err();
        assert!(matches!(err, Error::Malformed(_)));
    }

    #[test]
    fn fetch_surfaces_a_transport_error_for_an_unreachable_host() {
        // Port 1 is privileged and unbound — the connection is refused.
        let err = fetch_klines_with_config(
            "BTCUSDT",
            Interval::OneHour,
            1,
            None,
            None,
            &BinanceRestConfig {
                base_url: "http://127.0.0.1:1".to_string(),
            },
        )
        .unwrap_err();
        assert!(matches!(err, Error::Http(_)));
    }

    #[test]
    fn fetch_surfaces_a_json_error_for_a_malformed_body() {
        let (base, handle) = mock_http("HTTP/1.1 200 OK", "not json".to_string());
        let err = fetch_klines_with_config(
            "BTCUSDT",
            Interval::OneHour,
            1,
            None,
            None,
            &BinanceRestConfig { base_url: base },
        )
        .unwrap_err();
        handle.join().unwrap();
        assert!(matches!(err, Error::Json(_)));
    }

    #[test]
    fn fetch_rejects_an_unparseable_price_field() {
        let body =
            r#"[[1700000000000,"not-a-number","0","0","0","0",0,"0",0,"0","0","0"]]"#.to_string();
        let (base, handle) = mock_http("HTTP/1.1 200 OK", body);
        let err = fetch_klines_with_config(
            "BTCUSDT",
            Interval::OneHour,
            1,
            None,
            None,
            &BinanceRestConfig { base_url: base },
        )
        .unwrap_err();
        handle.join().unwrap();
        assert!(matches!(err, Error::Malformed(_)));
    }

    #[test]
    fn fetch_rejects_a_row_that_violates_candle_invariants() {
        // high (1) below low (100) — Candle::new rejects it.
        let body = r#"[[1700000000000,"50","1","100","50","1",0,"0",0,"0","0","0"]]"#.to_string();
        let (base, handle) = mock_http("HTTP/1.1 200 OK", body);
        let err = fetch_klines_with_config(
            "BTCUSDT",
            Interval::OneHour,
            1,
            None,
            None,
            &BinanceRestConfig { base_url: base },
        )
        .unwrap_err();
        handle.join().unwrap();
        assert!(matches!(err, Error::Core(_)));
    }

    #[test]
    fn rest_config_default_targets_production() {
        assert_eq!(
            BinanceRestConfig::default().base_url,
            "https://api.binance.com"
        );
    }
}