| |
| """ |
| CoinGecko API Client - REAL DATA ONLY with CACHING and RATE LIMIT PROTECTION |
| Fetches real cryptocurrency market data from CoinGecko |
| NO MOCK DATA - All data from live CoinGecko API |
| ENHANCED: 5-minute mandatory cache, exponential backoff, auto-blacklist on 429 |
| """ |
|
|
| import httpx |
| import logging |
| import time |
| import asyncio |
| from typing import Dict, Any, List, Optional |
| from datetime import datetime, timedelta |
| from fastapi import HTTPException |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| _cache: Dict[str, Dict[str, Any]] = {} |
| _last_request_time = 0.0 |
| _min_request_interval = 10.0 |
| _blacklist_until = 0.0 |
| _consecutive_429s = 0 |
|
|
|
|
| def _get_cache_key(method: str, **kwargs) -> str: |
| """Generate cache key from method and parameters""" |
| params_str = "_".join(f"{k}={v}" for k, v in sorted(kwargs.items())) |
| return f"{method}:{params_str}" |
|
|
|
|
| def _get_from_cache(cache_key: str, ttl: int = 300) -> Optional[Any]: |
| """Get data from cache if not expired (default 5 min TTL)""" |
| global _cache |
| if cache_key in _cache: |
| cached_data = _cache[cache_key] |
| if time.time() - cached_data["timestamp"] < ttl: |
| logger.info(f"β
CoinGecko: Cache hit for {cache_key}") |
| return cached_data["data"] |
| else: |
| |
| del _cache[cache_key] |
| return None |
|
|
|
|
| def _set_cache(cache_key: str, data: Any): |
| """Set data in cache with current timestamp""" |
| global _cache |
| _cache[cache_key] = { |
| "data": data, |
| "timestamp": time.time() |
| } |
|
|
|
|
| def _check_rate_limit() -> bool: |
| """Check if we should rate limit (return True if we should wait)""" |
| global _last_request_time, _min_request_interval, _blacklist_until |
| |
| current_time = time.time() |
| |
| |
| if current_time < _blacklist_until: |
| logger.warning(f"π΄ CoinGecko: Blacklisted until {datetime.fromtimestamp(_blacklist_until).strftime('%H:%M:%S')}") |
| return True |
| |
| |
| time_since_last = current_time - _last_request_time |
| if time_since_last < _min_request_interval: |
| wait_time = _min_request_interval - time_since_last |
| logger.warning(f"β³ CoinGecko: Rate limiting - wait {wait_time:.1f}s") |
| return True |
| |
| return False |
|
|
|
|
| async def _wait_for_rate_limit(): |
| """Wait until rate limit allows next request""" |
| global _last_request_time, _min_request_interval |
| |
| current_time = time.time() |
| time_since_last = current_time - _last_request_time |
| |
| if time_since_last < _min_request_interval: |
| wait_time = _min_request_interval - time_since_last |
| logger.info(f"β³ CoinGecko: Waiting {wait_time:.1f}s before next request") |
| await asyncio.sleep(wait_time) |
|
|
|
|
| def _update_last_request_time(): |
| """Update the last request timestamp""" |
| global _last_request_time |
| _last_request_time = time.time() |
|
|
|
|
| def _handle_429_error(): |
| """Handle 429 rate limit error with exponential backoff""" |
| global _consecutive_429s, _blacklist_until, _min_request_interval |
| |
| _consecutive_429s += 1 |
| |
| if _consecutive_429s >= 3: |
| |
| _blacklist_until = time.time() + 600 |
| logger.error(f"π΄ CoinGecko: {_consecutive_429s} consecutive 429s - BLACKLISTED for 10 minutes") |
| else: |
| |
| backoff_time = min(60 * (2 ** _consecutive_429s), 300) |
| _blacklist_until = time.time() + backoff_time |
| logger.warning(f"β οΈ CoinGecko: 429 rate limit - backing off for {backoff_time}s") |
|
|
|
|
| def _reset_429_counter(): |
| """Reset 429 counter on successful request""" |
| global _consecutive_429s |
| if _consecutive_429s > 0: |
| logger.info(f"β
CoinGecko: Successful request - resetting 429 counter (was {_consecutive_429s})") |
| _consecutive_429s = 0 |
|
|
|
|
| class CoinGeckoClient: |
| """ |
| Real CoinGecko API Client with CACHING and RATE LIMIT PROTECTION |
| Primary source for real-time cryptocurrency market prices |
| ENHANCED: 5-minute mandatory cache, exponential backoff, auto-blacklist on 429 |
| """ |
| |
| def __init__(self): |
| self.base_url = "https://api.coingecko.com/api/v3" |
| self.timeout = 15.0 |
| |
| |
| self.symbol_to_id = { |
| "BTC": "bitcoin", |
| "ETH": "ethereum", |
| "BNB": "binancecoin", |
| "XRP": "ripple", |
| "ADA": "cardano", |
| "DOGE": "dogecoin", |
| "SOL": "solana", |
| "TRX": "tron", |
| "DOT": "polkadot", |
| "MATIC": "matic-network", |
| "LTC": "litecoin", |
| "SHIB": "shiba-inu", |
| "AVAX": "avalanche-2", |
| "UNI": "uniswap", |
| "LINK": "chainlink", |
| "ATOM": "cosmos", |
| "XLM": "stellar", |
| "ETC": "ethereum-classic", |
| "XMR": "monero", |
| "BCH": "bitcoin-cash" |
| } |
| |
| |
| self.id_to_symbol = {v: k for k, v in self.symbol_to_id.items()} |
| |
| def _symbol_to_coingecko_id(self, symbol: str) -> str: |
| """Convert crypto symbol to CoinGecko coin ID""" |
| symbol = symbol.upper().replace("USDT", "").replace("USD", "") |
| return self.symbol_to_id.get(symbol, symbol.lower()) |
| |
| def _coingecko_id_to_symbol(self, coin_id: str) -> str: |
| """Convert CoinGecko coin ID to symbol""" |
| return self.id_to_symbol.get(coin_id, coin_id.upper()) |
| |
| async def get_market_prices( |
| self, |
| symbols: Optional[List[str]] = None, |
| limit: int = 100 |
| ) -> List[Dict[str, Any]]: |
| """ |
| Fetch REAL market prices from CoinGecko with CACHING and RATE LIMITING |
| |
| Args: |
| symbols: List of crypto symbols (e.g., ["BTC", "ETH"]) |
| limit: Maximum number of results |
| |
| Returns: |
| List of real market data |
| |
| ENHANCED: 5-minute mandatory cache, rate limiting, exponential backoff |
| """ |
| |
| cache_key = _get_cache_key("market_prices", symbols=str(symbols), limit=limit) |
| |
| |
| cached_data = _get_from_cache(cache_key, ttl=300) |
| if cached_data is not None: |
| return cached_data |
| |
| |
| if _check_rate_limit(): |
| |
| if cache_key in _cache: |
| logger.warning("π΄ CoinGecko: Rate limited - returning stale cache") |
| return _cache[cache_key]["data"] |
| else: |
| raise HTTPException( |
| status_code=429, |
| detail="CoinGecko rate limited - no cached data available" |
| ) |
| |
| try: |
| |
| await _wait_for_rate_limit() |
| |
| async with httpx.AsyncClient(timeout=self.timeout) as client: |
| if symbols: |
| |
| coin_ids = [self._symbol_to_coingecko_id(s) for s in symbols] |
| |
| response = await client.get( |
| f"{self.base_url}/simple/price", |
| params={ |
| "ids": ",".join(coin_ids), |
| "vs_currencies": "usd", |
| "include_24hr_change": "true", |
| "include_24hr_vol": "true", |
| "include_market_cap": "true" |
| } |
| ) |
| response.raise_for_status() |
| data = response.json() |
| |
| |
| prices = [] |
| for coin_id, coin_data in data.items(): |
| symbol = self._coingecko_id_to_symbol(coin_id) |
| prices.append({ |
| "symbol": symbol, |
| "name": symbol, |
| "price": coin_data.get("usd", 0), |
| "change24h": coin_data.get("usd_24h_change", 0), |
| "changePercent24h": coin_data.get("usd_24h_change", 0), |
| "volume24h": coin_data.get("usd_24h_vol", 0), |
| "marketCap": coin_data.get("usd_market_cap", 0), |
| "source": "coingecko", |
| "timestamp": int(datetime.utcnow().timestamp() * 1000) |
| }) |
| |
| logger.info(f"β
CoinGecko: Fetched {len(prices)} real prices for specific symbols") |
| |
| |
| _update_last_request_time() |
| _reset_429_counter() |
| |
| |
| _set_cache(cache_key, prices) |
| |
| return prices |
| |
| else: |
| |
| response = await client.get( |
| f"{self.base_url}/coins/markets", |
| params={ |
| "vs_currency": "usd", |
| "order": "market_cap_desc", |
| "per_page": min(limit, 250), |
| "page": 1, |
| "sparkline": "false", |
| "price_change_percentage": "24h" |
| } |
| ) |
| response.raise_for_status() |
| data = response.json() |
| |
| |
| prices = [] |
| for coin in data: |
| prices.append({ |
| "id": coin.get("id", ""), |
| "symbol": coin.get("symbol", "").upper(), |
| "name": coin.get("name", ""), |
| "image": coin.get("image", ""), |
| "price": coin.get("current_price", 0), |
| "change24h": coin.get("price_change_24h", 0), |
| "changePercent24h": coin.get("price_change_percentage_24h", 0), |
| "volume24h": coin.get("total_volume", 0), |
| "marketCap": coin.get("market_cap", 0), |
| "market_cap_rank": coin.get("market_cap_rank", 0), |
| "circulating_supply": coin.get("circulating_supply", 0), |
| "total_supply": coin.get("total_supply", 0), |
| "max_supply": coin.get("max_supply", 0), |
| "ath": coin.get("ath", 0), |
| "atl": coin.get("atl", 0), |
| "source": "coingecko", |
| "timestamp": int(datetime.utcnow().timestamp() * 1000) |
| }) |
| |
| logger.info(f"β
CoinGecko: Fetched {len(prices)} real market prices") |
| |
| |
| _update_last_request_time() |
| _reset_429_counter() |
| |
| |
| _set_cache(cache_key, prices) |
| |
| return prices |
| |
| except httpx.HTTPStatusError as e: |
| if e.response.status_code == 429: |
| |
| _handle_429_error() |
| |
| |
| if cache_key in _cache: |
| logger.warning("π΄ CoinGecko: 429 rate limit - returning stale cache") |
| return _cache[cache_key]["data"] |
| |
| raise HTTPException( |
| status_code=429, |
| detail="CoinGecko rate limited - please try again later" |
| ) |
| |
| logger.error(f"β CoinGecko API HTTP error: {e}") |
| raise HTTPException( |
| status_code=503, |
| detail=f"CoinGecko API error: HTTP {e.response.status_code}" |
| ) |
| |
| except httpx.HTTPError as e: |
| logger.error(f"β CoinGecko API HTTP error: {e}") |
| raise HTTPException( |
| status_code=503, |
| detail=f"CoinGecko API temporarily unavailable: {str(e)}" |
| ) |
| except Exception as e: |
| logger.error(f"β CoinGecko API failed: {e}") |
| raise HTTPException( |
| status_code=503, |
| detail=f"Failed to fetch real market data from CoinGecko: {str(e)}" |
| ) |
| |
| async def get_ohlcv(self, symbol: str, days: int = 7) -> Dict[str, Any]: |
| """ |
| Fetch REAL OHLCV (price history) data from CoinGecko with CACHING |
| |
| Args: |
| symbol: Cryptocurrency symbol (e.g., "BTC", "ETH") |
| days: Number of days of historical data (1, 7, 14, 30, 90, 180, 365, max) |
| |
| Returns: |
| Dict with OHLCV data |
| |
| ENHANCED: 5-minute cache, rate limiting |
| """ |
| |
| cache_key = _get_cache_key("ohlcv", symbol=symbol, days=days) |
| |
| |
| cached_data = _get_from_cache(cache_key, ttl=300) |
| if cached_data is not None: |
| return cached_data |
| |
| |
| if _check_rate_limit(): |
| if cache_key in _cache: |
| logger.warning("π΄ CoinGecko OHLCV: Rate limited - returning stale cache") |
| return _cache[cache_key]["data"] |
| else: |
| raise HTTPException( |
| status_code=429, |
| detail="CoinGecko rate limited - no cached data available" |
| ) |
| |
| try: |
| await _wait_for_rate_limit() |
| |
| coin_id = self._symbol_to_coingecko_id(symbol) |
| |
| async with httpx.AsyncClient(timeout=self.timeout) as client: |
| |
| response = await client.get( |
| f"{self.base_url}/coins/{coin_id}/market_chart", |
| params={ |
| "vs_currency": "usd", |
| "days": str(days), |
| "interval": "daily" if days > 1 else "hourly" |
| } |
| ) |
| response.raise_for_status() |
| data = response.json() |
| |
| logger.info(f"β
CoinGecko: Fetched {days} days of OHLCV data for {symbol}") |
| |
| |
| _update_last_request_time() |
| _reset_429_counter() |
| |
| |
| _set_cache(cache_key, data) |
| |
| return data |
| |
| except httpx.HTTPStatusError as e: |
| if e.response.status_code == 429: |
| _handle_429_error() |
| if cache_key in _cache: |
| logger.warning("π΄ CoinGecko OHLCV: 429 - returning stale cache") |
| return _cache[cache_key]["data"] |
| raise HTTPException(status_code=429, detail="CoinGecko rate limited") |
| |
| logger.error(f"β CoinGecko OHLCV API HTTP error: {e}") |
| raise HTTPException(status_code=503, detail=f"CoinGecko OHLCV API error: HTTP {e.response.status_code}") |
| |
| except httpx.HTTPError as e: |
| logger.error(f"β CoinGecko OHLCV API HTTP error: {e}") |
| raise HTTPException( |
| status_code=503, |
| detail=f"CoinGecko OHLCV API unavailable: {str(e)}" |
| ) |
| except Exception as e: |
| logger.error(f"β CoinGecko OHLCV API failed: {e}") |
| raise HTTPException( |
| status_code=503, |
| detail=f"Failed to fetch OHLCV data from CoinGecko: {str(e)}" |
| ) |
| |
| async def get_trending_coins(self, limit: int = 10) -> List[Dict[str, Any]]: |
| """ |
| Fetch REAL trending coins from CoinGecko with CACHING |
| |
| Returns: |
| List of real trending coins |
| |
| ENHANCED: 5-minute cache, rate limiting |
| """ |
| |
| cache_key = _get_cache_key("trending", limit=limit) |
| |
| |
| cached_data = _get_from_cache(cache_key, ttl=300) |
| if cached_data is not None: |
| return cached_data |
| |
| |
| if _check_rate_limit(): |
| if cache_key in _cache: |
| logger.warning("π΄ CoinGecko trending: Rate limited - returning stale cache") |
| return _cache[cache_key]["data"] |
| else: |
| raise HTTPException(status_code=429, detail="CoinGecko rate limited") |
| |
| try: |
| await _wait_for_rate_limit() |
| |
| async with httpx.AsyncClient(timeout=self.timeout) as client: |
| |
| response = await client.get(f"{self.base_url}/search/trending") |
| response.raise_for_status() |
| data = response.json() |
| |
| trending = [] |
| coins = data.get("coins", [])[:limit] |
| |
| |
| if coins: |
| coin_ids = [coin["item"]["id"] for coin in coins] |
| |
| |
| price_response = await client.get( |
| f"{self.base_url}/simple/price", |
| params={ |
| "ids": ",".join(coin_ids), |
| "vs_currencies": "usd", |
| "include_24hr_change": "true" |
| } |
| ) |
| price_response.raise_for_status() |
| price_data = price_response.json() |
| |
| for idx, coin_obj in enumerate(coins): |
| coin = coin_obj["item"] |
| coin_id = coin["id"] |
| prices = price_data.get(coin_id, {}) |
| |
| trending.append({ |
| "symbol": coin.get("symbol", "").upper(), |
| "name": coin.get("name", ""), |
| "rank": idx + 1, |
| "price": prices.get("usd", 0), |
| "change24h": prices.get("usd_24h_change", 0), |
| "marketCapRank": coin.get("market_cap_rank", 0), |
| "source": "coingecko", |
| "timestamp": int(datetime.utcnow().timestamp() * 1000) |
| }) |
| |
| logger.info(f"β
CoinGecko: Fetched {len(trending)} real trending coins") |
| |
| |
| _update_last_request_time() |
| _reset_429_counter() |
| |
| |
| _set_cache(cache_key, trending) |
| |
| return trending |
| |
| except httpx.HTTPStatusError as e: |
| if e.response.status_code == 429: |
| _handle_429_error() |
| if cache_key in _cache: |
| logger.warning("π΄ CoinGecko trending: 429 - returning stale cache") |
| return _cache[cache_key]["data"] |
| raise HTTPException(status_code=429, detail="CoinGecko rate limited") |
| |
| logger.error(f"β CoinGecko trending API HTTP error: {e}") |
| raise HTTPException(status_code=503, detail=f"CoinGecko trending API error: HTTP {e.response.status_code}") |
| |
| except httpx.HTTPError as e: |
| logger.error(f"β CoinGecko trending API HTTP error: {e}") |
| raise HTTPException( |
| status_code=503, |
| detail=f"CoinGecko trending API unavailable: {str(e)}" |
| ) |
| except Exception as e: |
| logger.error(f"β CoinGecko trending API failed: {e}") |
| raise HTTPException( |
| status_code=503, |
| detail=f"Failed to fetch trending coins: {str(e)}" |
| ) |
|
|
|
|
| |
| coingecko_client = CoinGeckoClient() |
|
|
|
|
| __all__ = ["CoinGeckoClient", "coingecko_client"] |
|
|