use std::sync::Arc;
use std::time::Duration;
use reqwest::{Client, StatusCode};
use tracing::debug;
use super::models::{BinanceError, Kline, Ticker24hr};
use crate::adapters::common::{keyless_http_client, status_error};
use crate::error::{FinanceError, Result};
use crate::rate_limiter::RateLimiter;
pub(super) const BINANCE_BASE: &str = "https://data-api.binance.vision";
pub(super) const MAX_KLINES: u32 = 1000;
pub(super) struct BinanceClient {
http: Client,
limiter: Arc<RateLimiter>,
base_url: String,
}
impl BinanceClient {
pub(super) fn new(
timeout: Duration,
limiter: Arc<RateLimiter>,
base_url: impl Into<String>,
) -> Result<Self> {
Ok(Self {
http: keyless_http_client(timeout)?,
limiter,
base_url: base_url.into(),
})
}
pub(super) async fn ticker_24hr(&self, symbol: &str) -> Result<Ticker24hr> {
self.limiter.acquire().await;
let url = format!("{}/api/v3/ticker/24hr", self.base_url);
debug!("Binance request: ticker/24hr {symbol}");
let resp = self
.http
.get(&url)
.query(&[("symbol", symbol)])
.send()
.await?;
let bytes = Self::body(resp, symbol).await?;
serde_json::from_slice(&bytes).map_err(|e| FinanceError::ResponseStructureError {
field: "binance.ticker24hr".to_string(),
context: format!("unrecognised Binance ticker payload: {e}"),
})
}
pub(super) async fn klines(
&self,
symbol: &str,
interval: &str,
start_ms: i64,
end_ms: i64,
limit: u32,
) -> Result<Vec<Kline>> {
self.limiter.acquire().await;
let url = format!("{}/api/v3/klines", self.base_url);
debug!("Binance request: klines {symbol} {interval} from {start_ms}");
let start = start_ms.max(0).to_string();
let end = end_ms.to_string();
let limit = limit.clamp(1, MAX_KLINES).to_string();
let resp = self
.http
.get(&url)
.query(&[
("symbol", symbol),
("interval", interval),
("startTime", &start),
("endTime", &end),
("limit", &limit),
])
.send()
.await?;
let bytes = Self::body(resp, symbol).await?;
serde_json::from_slice(&bytes).map_err(|e| FinanceError::ResponseStructureError {
field: "binance.klines".to_string(),
context: format!("unrecognised Binance kline payload: {e}"),
})
}
async fn body(resp: reqwest::Response, symbol: &str) -> Result<Vec<u8>> {
let status = resp.status();
let bytes = resp.bytes().await?;
if status.is_success() {
return Ok(bytes.to_vec());
}
let detail = serde_json::from_slice::<BinanceError>(&bytes).ok();
Err(match status {
StatusCode::BAD_REQUEST => FinanceError::SymbolNotFound {
symbol: Some(symbol.to_string()),
context: detail
.and_then(|e| e.msg)
.unwrap_or_else(|| "Binance rejected this market symbol".to_string()),
},
StatusCode::IM_A_TEAPOT => FinanceError::RateLimited { retry_after: None },
StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS => FinanceError::ApiError(
"Binance is not available from this region; route CRYPTO to Kraken or CoinGecko"
.to_string(),
),
s => status_error("Binance", s),
})
}
}