Skip to main content

termichart_data/
exchange.rs

1//! Multi-exchange abstraction for fetching OHLCV candles and price data.
2//!
3//! Supports Binance and CoinGecko with a unified interface.
4//!
5//! # Example
6//!
7//! ```no_run
8//! use termichart_data::exchange::{Exchange, ExchangeClient};
9//!
10//! let client = ExchangeClient::binance();
11//! let candles = client.fetch_candles("BTCUSDT", "1m", 60);
12//! let price = client.fetch_spot_price("BTCUSDT");
13//! ```
14
15use termichart_core::{Candle, Point};
16
17/// Supported exchanges.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub enum Exchange {
20    /// Binance public REST API (no API key required for market data).
21    Binance,
22    /// CoinGecko free API (no API key required).
23    CoinGecko,
24}
25
26/// A client that fetches market data from a specific exchange.
27///
28/// All methods return `Option` and never panic; network or parse errors
29/// are silently mapped to `None` so callers can retry or fall back.
30pub struct ExchangeClient {
31    exchange: Exchange,
32}
33
34impl ExchangeClient {
35    /// Create a client for the given exchange.
36    pub fn new(exchange: Exchange) -> Self {
37        Self { exchange }
38    }
39
40    /// Convenience constructor for Binance.
41    pub fn binance() -> Self {
42        Self::new(Exchange::Binance)
43    }
44
45    /// Convenience constructor for CoinGecko.
46    pub fn coingecko() -> Self {
47        Self::new(Exchange::CoinGecko)
48    }
49
50    /// Fetch OHLCV kline candles.
51    ///
52    /// * **Binance** -- `symbol` is a trading pair like `"BTCUSDT"`, `interval`
53    ///   is a Binance interval string (`"1m"`, `"5m"`, `"1h"`, `"1d"`, ...),
54    ///   and `limit` is the number of candles (max 1000).
55    ///
56    /// * **CoinGecko** -- `symbol` is the coin *id* (e.g. `"bitcoin"`),
57    ///   `interval` is interpreted as the number of days of history
58    ///   (`"1"`, `"7"`, `"30"`, `"365"`), and `limit` is currently
59    ///   unused (the API decides granularity from `days`).
60    pub fn fetch_candles(&self, symbol: &str, interval: &str, limit: usize) -> Option<Vec<Candle>> {
61        match self.exchange {
62            Exchange::Binance => fetch_binance_klines(symbol, interval, limit),
63            Exchange::CoinGecko => fetch_coingecko_ohlc(symbol, interval),
64        }
65    }
66
67    /// Fetch close prices as [`Point`]s suitable for line charts.
68    ///
69    /// Each point has `x = timestamp` (seconds) and `y = close price`.
70    ///
71    /// * **Binance** -- uses klines; `x` is the candle open time.
72    /// * **CoinGecko** -- uses the `market_chart` endpoint which returns
73    ///   price samples at varying resolution depending on `days`.
74    pub fn fetch_prices(&self, symbol: &str, interval: &str, limit: usize) -> Option<Vec<Point>> {
75        match self.exchange {
76            Exchange::Binance => {
77                let candles = fetch_binance_klines(symbol, interval, limit)?;
78                Some(
79                    candles
80                        .into_iter()
81                        .map(|c| Point { x: c.time, y: c.close })
82                        .collect(),
83                )
84            }
85            Exchange::CoinGecko => fetch_coingecko_prices(symbol, interval),
86        }
87    }
88
89    /// Fetch the current spot (last traded) price.
90    ///
91    /// * **Binance** -- uses `/api/v3/ticker/price`.
92    /// * **CoinGecko** -- uses `/api/v3/simple/price`.
93    pub fn fetch_spot_price(&self, symbol: &str) -> Option<f64> {
94        match self.exchange {
95            Exchange::Binance => fetch_binance_spot(symbol),
96            Exchange::CoinGecko => fetch_coingecko_spot(symbol),
97        }
98    }
99}
100
101// ---------------------------------------------------------------------------
102// Binance helpers
103// ---------------------------------------------------------------------------
104
105/// Fetch kline candles from Binance.
106///
107/// Kline JSON array element:
108/// `[open_time_ms, open, high, low, close, volume, close_time_ms, ...]`
109fn fetch_binance_klines(symbol: &str, interval: &str, limit: usize) -> Option<Vec<Candle>> {
110    let url = format!(
111        "https://api.binance.com/api/v3/klines?symbol={}&interval={}&limit={}",
112        symbol, interval, limit,
113    );
114    let resp = ureq::get(&url).call().ok()?;
115    let body: serde_json::Value = resp.into_json().ok()?;
116    let arr = body.as_array()?;
117
118    let mut candles = Vec::with_capacity(arr.len());
119    for kline in arr {
120        let k = kline.as_array()?;
121        let time = k[0].as_f64()? / 1000.0; // ms -> seconds
122        let open = k[1].as_str()?.parse::<f64>().ok()?;
123        let high = k[2].as_str()?.parse::<f64>().ok()?;
124        let low = k[3].as_str()?.parse::<f64>().ok()?;
125        let close = k[4].as_str()?.parse::<f64>().ok()?;
126        let volume = k[5].as_str()?.parse::<f64>().ok()?;
127        candles.push(Candle {
128            time,
129            open,
130            high,
131            low,
132            close,
133            volume,
134        });
135    }
136    Some(candles)
137}
138
139/// Fetch the latest traded price from Binance.
140///
141/// Response: `{"symbol":"BTCUSDT","price":"12345.67"}`
142fn fetch_binance_spot(symbol: &str) -> Option<f64> {
143    let url = format!(
144        "https://api.binance.com/api/v3/ticker/price?symbol={}",
145        symbol,
146    );
147    let resp = ureq::get(&url).call().ok()?;
148    let body: serde_json::Value = resp.into_json().ok()?;
149    body["price"].as_str()?.parse::<f64>().ok()
150}
151
152// ---------------------------------------------------------------------------
153// CoinGecko helpers
154// ---------------------------------------------------------------------------
155
156/// Fetch OHLC candles from CoinGecko.
157///
158/// The `interval` parameter is interpreted as the number of days.
159/// Response: `[[timestamp_ms, open, high, low, close], ...]`
160fn fetch_coingecko_ohlc(coin_id: &str, days: &str) -> Option<Vec<Candle>> {
161    let url = format!(
162        "https://api.coingecko.com/api/v3/coins/{}/ohlc?vs_currency=usd&days={}",
163        coin_id, days,
164    );
165    let resp = ureq::get(&url).call().ok()?;
166    let body: serde_json::Value = resp.into_json().ok()?;
167    let arr = body.as_array()?;
168
169    let mut candles = Vec::with_capacity(arr.len());
170    for item in arr {
171        let row = item.as_array()?;
172        let time = row[0].as_f64()? / 1000.0; // ms -> seconds
173        let open = row[1].as_f64()?;
174        let high = row[2].as_f64()?;
175        let low = row[3].as_f64()?;
176        let close = row[4].as_f64()?;
177        candles.push(Candle {
178            time,
179            open,
180            high,
181            low,
182            close,
183            volume: 0.0, // CoinGecko OHLC does not include volume
184        });
185    }
186    Some(candles)
187}
188
189/// Fetch price history from CoinGecko as [`Point`]s.
190///
191/// The `days` parameter controls how far back to look.
192/// Response: `{"prices":[[timestamp_ms, price], ...], ...}`
193fn fetch_coingecko_prices(coin_id: &str, days: &str) -> Option<Vec<Point>> {
194    let url = format!(
195        "https://api.coingecko.com/api/v3/coins/{}/market_chart?vs_currency=usd&days={}",
196        coin_id, days,
197    );
198    let resp = ureq::get(&url).call().ok()?;
199    let body: serde_json::Value = resp.into_json().ok()?;
200    let prices = body["prices"].as_array()?;
201
202    let mut points = Vec::with_capacity(prices.len());
203    for entry in prices {
204        let pair = entry.as_array()?;
205        let x = pair[0].as_f64()? / 1000.0; // ms -> seconds
206        let y = pair[1].as_f64()?;
207        points.push(Point { x, y });
208    }
209    Some(points)
210}
211
212/// Fetch the current price from CoinGecko.
213///
214/// Response: `{"bitcoin":{"usd":12345.67}}`
215fn fetch_coingecko_spot(coin_id: &str) -> Option<f64> {
216    let url = format!(
217        "https://api.coingecko.com/api/v3/simple/price?ids={}&vs_currencies=usd",
218        coin_id,
219    );
220    let resp = ureq::get(&url).call().ok()?;
221    let body: serde_json::Value = resp.into_json().ok()?;
222    body[coin_id]["usd"].as_f64()
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn test_exchange_client_constructors() {
231        let b = ExchangeClient::binance();
232        assert_eq!(b.exchange, Exchange::Binance);
233
234        let c = ExchangeClient::coingecko();
235        assert_eq!(c.exchange, Exchange::CoinGecko);
236
237        let n = ExchangeClient::new(Exchange::Binance);
238        assert_eq!(n.exchange, Exchange::Binance);
239    }
240}