1use std::fmt::Write as _;
21
22use serde::Deserialize;
23
24use super::binance::Interval;
25use crate::error::{Error, Result};
26use wickra_core::Candle;
27
28const MAX_LIMIT: u16 = 1000;
30
31#[derive(Debug, Clone)]
35pub struct BinanceRestConfig {
36 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#[derive(Debug, Deserialize)]
54struct RawRestKline(
55 i64, String, String, String, String, String, serde::de::IgnoredAny, serde::de::IgnoredAny, serde::de::IgnoredAny, serde::de::IgnoredAny, serde::de::IgnoredAny, serde::de::IgnoredAny, );
68
69fn 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
87pub 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
116pub 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 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 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 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 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 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}