finance-query 3.0.0

A Rust library for querying financial data
Documentation
//! CoinGecko API client with rate limiting.
//!
//! Free public API: 30 requests/minute. No API key required.

use std::sync::Arc;
use std::time::Duration;

use reqwest::{Client, StatusCode};
use tracing::debug;

use super::models::{CoinQuote, GlobalResponseDTO, SearchResponseDTO, TrendingResponseDTO};
use crate::error::{FinanceError, Result};
use crate::rate_limiter::RateLimiter;

const COINGECKO_BASE: &str = "https://api.coingecko.com/api/v3";
/// 30 req/min = 0.5 req/sec
const COINGECKO_RATE_PER_SEC: f64 = 0.5;

pub(crate) struct CoinGeckoClient {
    http: Client,
    limiter: Arc<RateLimiter>,
}

impl CoinGeckoClient {
    pub fn new() -> Result<Self> {
        let http = Client::builder()
            .timeout(Duration::from_secs(30))
            .user_agent(format!(
                "finance-query/{} (https://github.com/Verdenroz/finance-query)",
                env!("CARGO_PKG_VERSION")
            ))
            .build()?;

        Ok(Self {
            http,
            limiter: Arc::new(RateLimiter::new(COINGECKO_RATE_PER_SEC)),
        })
    }

    /// Fetch top coins by market cap.
    ///
    /// # Arguments
    ///
    /// * `vs_currency` - Quote currency (e.g., `"usd"`, `"eur"`)
    /// * `count` - Number of coins to return (max 250 per request)
    pub async fn coins(&self, vs_currency: &str, count: usize) -> Result<Vec<CoinQuote>> {
        self.limiter.acquire().await;

        let per_page = count.min(250);
        let url = format!(
            "{COINGECKO_BASE}/coins/markets?vs_currency={vs_currency}&order=market_cap_desc&per_page={per_page}&page=1&sparkline=false"
        );

        debug!("CoinGecko request: coins(vs_currency={vs_currency}, count={count})");
        let resp = self.http.get(&url).send().await?;
        CoinGeckoClient::check_status(&resp)?;
        Ok(resp.json().await?)
    }

    /// Fetch a single coin by its CoinGecko ID (e.g., `"bitcoin"`, `"ethereum"`).
    pub async fn coin(&self, id: &str, vs_currency: &str) -> Result<CoinQuote> {
        self.limiter.acquire().await;

        let url = format!(
            "{COINGECKO_BASE}/coins/markets?vs_currency={vs_currency}&ids={id}&order=market_cap_desc&per_page=1&page=1&sparkline=false"
        );

        debug!("CoinGecko request: coin(id={id})");
        let resp = self.http.get(&url).send().await?;
        CoinGeckoClient::check_status(&resp)?;
        let mut list: Vec<CoinQuote> = resp.json().await?;

        list.pop().ok_or_else(|| FinanceError::SymbolNotFound {
            symbol: Some(id.to_string()),
            context: format!("CoinGecko returned no coin for id '{id}'"),
        })
    }

    /// Fetch OHLC candles for a coin.
    ///
    /// `days` must be one of the public tier's accepted spans
    /// (`1`/`7`/`14`/`30`/`90`/`180`/`365`/`max`); granularity is chosen by
    /// CoinGecko from that span.
    pub async fn ohlc(
        &self,
        id: &str,
        vs_currency: &str,
        days: &str,
    ) -> Result<Vec<super::chart::OhlcRowDTO>> {
        self.limiter.acquire().await;

        let url = format!("{COINGECKO_BASE}/coins/{id}/ohlc?vs_currency={vs_currency}&days={days}");
        debug!("CoinGecko request: ohlc(id={id}, vs={vs_currency}, days={days})");
        let resp = self.http.get(&url).send().await?;
        CoinGeckoClient::check_status(&resp)?;
        Ok(resp.json().await?)
    }

    /// Search CoinGecko's coin/exchange/category catalog by free-text query.
    pub async fn search(&self, query: &str) -> Result<SearchResponseDTO> {
        self.limiter.acquire().await;

        let url = format!("{COINGECKO_BASE}/search");
        debug!("CoinGecko request: search(query={query})");
        let resp = self
            .http
            .get(&url)
            .query(&[("query", query)])
            .send()
            .await?;
        CoinGeckoClient::check_status(&resp)?;
        Ok(resp.json().await?)
    }

    /// Fetch coins/nfts/categories trending in the last 24h.
    pub async fn trending(&self) -> Result<TrendingResponseDTO> {
        self.limiter.acquire().await;

        let url = format!("{COINGECKO_BASE}/search/trending");
        debug!("CoinGecko request: trending()");
        let resp = self.http.get(&url).send().await?;
        CoinGeckoClient::check_status(&resp)?;
        Ok(resp.json().await?)
    }

    /// Fetch aggregate global cryptocurrency market statistics.
    pub async fn global(&self) -> Result<GlobalResponseDTO> {
        self.limiter.acquire().await;

        let url = format!("{COINGECKO_BASE}/global");
        debug!("CoinGecko request: global()");
        let resp = self.http.get(&url).send().await?;
        CoinGeckoClient::check_status(&resp)?;
        Ok(resp.json().await?)
    }

    fn check_status(resp: &reqwest::Response) -> Result<()> {
        match resp.status() {
            StatusCode::OK => Ok(()),
            StatusCode::TOO_MANY_REQUESTS => Err(FinanceError::RateLimited {
                retry_after: Some(60),
            }),
            s => Err(FinanceError::ExternalApiError {
                api: "CoinGecko".to_string(),
                status: s.as_u16(),
            }),
        }
    }
}