import * as plugins from '../../plugins.js'; import type { IStockProvider, IProviderConfig } from '../interfaces/provider.js'; import type { IStockPrice, IStockDataRequest, IStockCurrentRequest, IStockHistoricalRequest, IStockIntradayRequest, IStockBatchCurrentRequest } from '../interfaces/stockprice.js'; /** * Custom error for rate limit exceeded responses */ class RateLimitError extends Error { constructor( message: string, public waitTime: number, public retryAfter?: number ) { super(message); this.name = 'RateLimitError'; } } /** * Rate limiter for CoinGecko API * Free tier (Demo): 30 requests per minute * Without registration: 5-15 requests per minute */ class RateLimiter { private requestTimes: number[] = []; private maxRequestsPerMinute: number; private consecutiveRateLimitErrors: number = 0; constructor(maxRequestsPerMinute: number = 30) { this.maxRequestsPerMinute = maxRequestsPerMinute; } public async waitForSlot(): Promise { const now = Date.now(); const oneMinuteAgo = now - 60000; // Remove requests older than 1 minute this.requestTimes = this.requestTimes.filter(time => time > oneMinuteAgo); // If we've hit the limit, wait if (this.requestTimes.length >= this.maxRequestsPerMinute) { const oldestRequest = this.requestTimes[0]; const waitTime = 60000 - (now - oldestRequest) + 100; // +100ms buffer await plugins.smartdelay.delayFor(waitTime); return this.waitForSlot(); // Recursively check again } // Record this request this.requestTimes.push(now); } /** * Get time in milliseconds until next request slot is available */ public getTimeUntilNextSlot(): number { const now = Date.now(); const oneMinuteAgo = now - 60000; // Clean old requests const recentRequests = this.requestTimes.filter(time => time > oneMinuteAgo); if (recentRequests.length < this.maxRequestsPerMinute) { return 0; // Slot available now } // Calculate wait time until oldest request expires const oldestRequest = recentRequests[0]; return Math.max(0, 60000 - (now - oldestRequest) + 100); } /** * Handle rate limit error with exponential backoff * Returns wait time in milliseconds */ public handleRateLimitError(): number { this.consecutiveRateLimitErrors++; // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, 60s (max) const baseWait = 1000; // 1 second const exponent = this.consecutiveRateLimitErrors - 1; const backoff = Math.min( baseWait * Math.pow(2, exponent), 60000 // max 60 seconds ); // After 3 consecutive 429s, reduce rate limit to 80% as safety measure if (this.consecutiveRateLimitErrors >= 3) { const newLimit = Math.floor(this.maxRequestsPerMinute * 0.8); if (newLimit < this.maxRequestsPerMinute) { console.warn( `Adjusting rate limit from ${this.maxRequestsPerMinute} to ${newLimit} requests/min due to repeated 429 errors` ); this.maxRequestsPerMinute = newLimit; } } return backoff; } /** * Reset consecutive error count on successful request */ public resetErrors(): void { if (this.consecutiveRateLimitErrors > 0) { this.consecutiveRateLimitErrors = 0; } } } /** * Interface for coin list response */ interface ICoinListItem { id: string; symbol: string; name: string; } /** * CoinGecko Crypto Price Provider * * Documentation: https://docs.coingecko.com/v3.0.1/reference/endpoint-overview * * Features: * - Current crypto prices (single and batch) * - Historical price data with OHLCV * - 13M+ tokens, 240+ networks, 1600+ exchanges * - Accepts both ticker symbols (BTC, ETH) and CoinGecko IDs (bitcoin, ethereum) * - 24/7 market data (crypto never closes) * * Rate Limits: * - Free tier (no key): 5-15 requests/minute * - Demo plan (free with registration): ~30 requests/minute, 10,000/month * - Paid plans: Higher limits * * API Authentication: * - Optional API key for Demo/paid plans * - Header: x-cg-demo-api-key (Demo) or x-cg-pro-api-key (paid) */ export class CoinGeckoProvider implements IStockProvider { public name = 'CoinGecko'; public priority = 90; // High priority for crypto, between Yahoo (100) and Marketstack (80) public readonly requiresAuth = false; // API key is optional public readonly rateLimit = { requestsPerMinute: 30, // Demo plan default requestsPerDay: 10000 // Demo plan monthly quota / 30 }; private logger = console; private baseUrl = 'https://api.coingecko.com/api/v3'; private apiKey?: string; private rateLimiter: RateLimiter; // Coin mapping cache private coinMapCache = new Map(); // ticker/id -> coingecko id private coinListLoadedAt: Date | null = null; private readonly coinListCacheTTL = 24 * 60 * 60 * 1000; // 24 hours // Priority map for common crypto tickers (to avoid conflicts) private readonly priorityTickerMap = new Map([ ['btc', 'bitcoin'], ['eth', 'ethereum'], ['usdt', 'tether'], ['bnb', 'binancecoin'], ['sol', 'solana'], ['usdc', 'usd-coin'], ['xrp', 'ripple'], ['ada', 'cardano'], ['doge', 'dogecoin'], ['trx', 'tron'], ['dot', 'polkadot'], ['matic', 'matic-network'], ['ltc', 'litecoin'], ['shib', 'shiba-inu'], ['avax', 'avalanche-2'], ['link', 'chainlink'], ['atom', 'cosmos'], ['uni', 'uniswap'], ['etc', 'ethereum-classic'], ['xlm', 'stellar'] ]); constructor(apiKey?: string, private config?: IProviderConfig) { this.apiKey = apiKey; this.rateLimiter = new RateLimiter(this.rateLimit.requestsPerMinute); } /** * Unified data fetching method supporting all request types */ public async fetchData(request: IStockDataRequest): Promise { switch (request.type) { case 'current': return this.fetchCurrentPrice(request); case 'batch': return this.fetchBatchCurrentPrices(request); case 'historical': return this.fetchHistoricalPrices(request); case 'intraday': return this.fetchIntradayPrices(request); default: throw new Error(`Unsupported request type: ${(request as any).type}`); } } /** * Fetch current price for a single crypto */ private async fetchCurrentPrice(request: IStockCurrentRequest): Promise { return this.fetchWithRateLimitRetry(async () => { // Resolve ticker to CoinGecko ID const coinId = await this.resolveCoinId(request.ticker); // Build URL const params = new URLSearchParams({ ids: coinId, vs_currencies: 'usd', include_market_cap: 'true', include_24hr_vol: 'true', include_24hr_change: 'true', include_last_updated_at: 'true' }); const url = `${this.baseUrl}/simple/price?${params}`; // Wait for rate limit slot await this.rateLimiter.waitForSlot(); // Make request const response = await plugins.smartrequest.SmartRequest.create() .url(url) .headers(this.buildHeaders()) .timeout(this.config?.timeout || 10000) .get(); const responseData = await response.json() as any; // Check for rate limit error if (this.isRateLimitError(responseData)) { const waitTime = this.rateLimiter.handleRateLimitError(); throw new RateLimitError( `Rate limit exceeded for ${request.ticker}`, waitTime ); } if (!responseData[coinId]) { throw new Error(`No data found for ${request.ticker} (${coinId})`); } return this.mapToStockPrice(request.ticker, coinId, responseData[coinId], 'live'); }, `current price for ${request.ticker}`); } /** * Fetch batch current prices for multiple cryptos */ private async fetchBatchCurrentPrices(request: IStockBatchCurrentRequest): Promise { return this.fetchWithRateLimitRetry(async () => { // Resolve all tickers to CoinGecko IDs const coinIds = await Promise.all( request.tickers.map(ticker => this.resolveCoinId(ticker)) ); // Build URL with comma-separated IDs const params = new URLSearchParams({ ids: coinIds.join(','), vs_currencies: 'usd', include_market_cap: 'true', include_24hr_vol: 'true', include_24hr_change: 'true', include_last_updated_at: 'true' }); const url = `${this.baseUrl}/simple/price?${params}`; // Wait for rate limit slot await this.rateLimiter.waitForSlot(); // Make request const response = await plugins.smartrequest.SmartRequest.create() .url(url) .headers(this.buildHeaders()) .timeout(this.config?.timeout || 15000) .get(); const responseData = await response.json() as any; // Check for rate limit error if (this.isRateLimitError(responseData)) { const waitTime = this.rateLimiter.handleRateLimitError(); throw new RateLimitError( `Rate limit exceeded for batch request`, waitTime ); } const prices: IStockPrice[] = []; // Map responses back to original tickers for (let i = 0; i < request.tickers.length; i++) { const ticker = request.tickers[i]; const coinId = coinIds[i]; if (responseData[coinId]) { try { prices.push(this.mapToStockPrice(ticker, coinId, responseData[coinId], 'live')); } catch (error) { this.logger.warn(`Failed to parse data for ${ticker}:`, error); } } else { this.logger.warn(`No data returned for ${ticker} (${coinId})`); } } if (prices.length === 0) { throw new Error('No valid price data received from batch request'); } return prices; }, `batch prices for ${request.tickers.length} tickers`); } /** * Fetch historical prices with OHLCV data */ private async fetchHistoricalPrices(request: IStockHistoricalRequest): Promise { return this.fetchWithRateLimitRetry(async () => { const coinId = await this.resolveCoinId(request.ticker); // Calculate days between dates const days = Math.ceil((request.to.getTime() - request.from.getTime()) / (1000 * 60 * 60 * 24)); // Build URL const params = new URLSearchParams({ vs_currency: 'usd', days: days.toString(), interval: 'daily' // Explicit daily granularity for historical data }); const url = `${this.baseUrl}/coins/${coinId}/market_chart?${params}`; // Wait for rate limit slot await this.rateLimiter.waitForSlot(); // Make request const response = await plugins.smartrequest.SmartRequest.create() .url(url) .headers(this.buildHeaders()) .timeout(this.config?.timeout || 20000) .get(); const responseData = await response.json() as any; // Check for rate limit error if (this.isRateLimitError(responseData)) { const waitTime = this.rateLimiter.handleRateLimitError(); throw new RateLimitError( `Rate limit exceeded for historical ${request.ticker}`, waitTime ); } if (!responseData.prices || !Array.isArray(responseData.prices)) { this.logger.error(`Invalid API response for ${request.ticker}:`, JSON.stringify(responseData).substring(0, 500)); throw new Error(`Invalid response format for ${request.ticker}: ${JSON.stringify(responseData).substring(0, 200)}`); } const prices: IStockPrice[] = []; const priceData = responseData.prices; const marketCapData = responseData.market_caps || []; const volumeData = responseData.total_volumes || []; // Process each data point for (let i = 0; i < priceData.length; i++) { const [timestamp, price] = priceData[i]; const date = new Date(timestamp); // Filter by date range if (date < request.from || date > request.to) continue; const marketCap = marketCapData[i]?.[1]; const volume = volumeData[i]?.[1]; // Calculate previous close for change calculation const previousClose = i > 0 ? priceData[i - 1][1] : price; const change = price - previousClose; const changePercent = previousClose !== 0 ? (change / previousClose) * 100 : 0; prices.push({ ticker: request.ticker.toUpperCase(), price: price, currency: 'USD', change: change, changePercent: changePercent, previousClose: previousClose, timestamp: date, provider: this.name, marketState: 'REGULAR', // Crypto markets are always open // OHLCV data (note: market_chart doesn't provide OHLC, only close prices) volume: volume, dataType: 'eod', fetchedAt: new Date(), companyName: coinId.charAt(0).toUpperCase() + coinId.slice(1) }); } return prices; }, `historical prices for ${request.ticker}`); } /** * Fetch intraday prices with hourly intervals */ private async fetchIntradayPrices(request: IStockIntradayRequest): Promise { return this.fetchWithRateLimitRetry(async () => { const coinId = await this.resolveCoinId(request.ticker); // Map interval to days parameter (CoinGecko auto-granularity) // For hourly data, request 1-7 days let days = 1; switch (request.interval) { case '1min': case '5min': case '10min': case '15min': case '30min': throw new Error('CoinGecko only supports hourly intervals in market_chart. Use interval: "1hour"'); case '1hour': days = 1; // Last 24 hours with hourly granularity break; } // Build URL (omit interval param for automatic granularity based on days) const params = new URLSearchParams({ vs_currency: 'usd', days: days.toString() }); const url = `${this.baseUrl}/coins/${coinId}/market_chart?${params}`; // Wait for rate limit slot await this.rateLimiter.waitForSlot(); // Make request const response = await plugins.smartrequest.SmartRequest.create() .url(url) .headers(this.buildHeaders()) .timeout(this.config?.timeout || 15000) .get(); const responseData = await response.json() as any; // Check for rate limit error if (this.isRateLimitError(responseData)) { const waitTime = this.rateLimiter.handleRateLimitError(); throw new RateLimitError( `Rate limit exceeded for intraday ${request.ticker}`, waitTime ); } if (!responseData.prices || !Array.isArray(responseData.prices)) { this.logger.error(`Invalid API response for ${request.ticker}:`, JSON.stringify(responseData).substring(0, 500)); throw new Error(`Invalid response format for ${request.ticker}: ${JSON.stringify(responseData).substring(0, 200)}`); } const prices: IStockPrice[] = []; const priceData = responseData.prices; const marketCapData = responseData.market_caps || []; const volumeData = responseData.total_volumes || []; // Apply limit if specified const limit = request.limit || priceData.length; const dataToProcess = priceData.slice(-limit); for (let i = 0; i < dataToProcess.length; i++) { const actualIndex = priceData.length - limit + i; const [timestamp, price] = dataToProcess[i]; const date = new Date(timestamp); const marketCap = marketCapData[actualIndex]?.[1]; const volume = volumeData[actualIndex]?.[1]; const previousClose = i > 0 ? dataToProcess[i - 1][1] : price; const change = price - previousClose; const changePercent = previousClose !== 0 ? (change / previousClose) * 100 : 0; prices.push({ ticker: request.ticker.toUpperCase(), price: price, currency: 'USD', change: change, changePercent: changePercent, previousClose: previousClose, timestamp: date, provider: this.name, marketState: 'REGULAR', volume: volume, dataType: 'intraday', fetchedAt: new Date(), companyName: coinId.charAt(0).toUpperCase() + coinId.slice(1) }); } return prices; }, `intraday prices for ${request.ticker}`); } /** * Check if CoinGecko API is available */ public async isAvailable(): Promise { try { const url = `${this.baseUrl}/simple/price?ids=bitcoin&vs_currencies=usd`; await this.rateLimiter.waitForSlot(); const response = await plugins.smartrequest.SmartRequest.create() .url(url) .headers(this.buildHeaders()) .timeout(5000) .get(); const responseData = await response.json() as any; return responseData.bitcoin?.usd !== undefined; } catch (error) { this.logger.warn('CoinGecko provider is not available:', error); return false; } } /** * Check if a market/network is supported * CoinGecko supports 240+ networks */ public supportsMarket(market: string): boolean { // CoinGecko has extensive crypto network coverage const supportedNetworks = [ 'CRYPTO', 'BTC', 'ETH', 'BSC', 'POLYGON', 'AVALANCHE', 'SOLANA', 'ARBITRUM', 'OPTIMISM', 'BASE' ]; return supportedNetworks.includes(market.toUpperCase()); } /** * Check if a ticker format is supported * Supports both ticker symbols (BTC) and CoinGecko IDs (bitcoin) */ public supportsTicker(ticker: string): boolean { // Accept alphanumeric with hyphens (for coin IDs like 'wrapped-bitcoin') return /^[A-Za-z0-9\-]{1,50}$/.test(ticker); } /** * Resolve ticker symbol or CoinGecko ID to canonical CoinGecko ID * Supports both formats: "BTC" -> "bitcoin", "bitcoin" -> "bitcoin" */ private async resolveCoinId(tickerOrId: string): Promise { const normalized = tickerOrId.toLowerCase(); // Check priority map first (for common cryptos) if (this.priorityTickerMap.has(normalized)) { const coinId = this.priorityTickerMap.get(normalized)!; this.coinMapCache.set(normalized, coinId); return coinId; } // Check cache if (this.coinMapCache.has(normalized)) { return this.coinMapCache.get(normalized)!; } // Check if it's already a valid CoinGecko ID (contains hyphens or is all lowercase with original case) if (normalized.includes('-') || normalized === tickerOrId) { // Assume it's a CoinGecko ID, cache it this.coinMapCache.set(normalized, normalized); return normalized; } // Load coin list if needed if (!this.coinListLoadedAt || Date.now() - this.coinListLoadedAt.getTime() > this.coinListCacheTTL) { await this.loadCoinList(); } // Try to find in cache after loading if (this.coinMapCache.has(normalized)) { return this.coinMapCache.get(normalized)!; } // Not found - return as-is and let API handle the error this.logger.warn(`Could not resolve ticker ${tickerOrId} to CoinGecko ID, using as-is`); return normalized; } /** * Load complete coin list from CoinGecko API */ private async loadCoinList(): Promise { try { const url = `${this.baseUrl}/coins/list`; await this.rateLimiter.waitForSlot(); const response = await plugins.smartrequest.SmartRequest.create() .url(url) .headers(this.buildHeaders()) .timeout(10000) .get(); const coinList = await response.json() as ICoinListItem[]; // Build mapping: symbol -> id for (const coin of coinList) { const symbol = coin.symbol.toLowerCase(); const id = coin.id.toLowerCase(); // Don't overwrite priority mappings or existing cache entries if (!this.priorityTickerMap.has(symbol) && !this.coinMapCache.has(symbol)) { this.coinMapCache.set(symbol, id); } // Always cache the ID mapping this.coinMapCache.set(id, id); } this.coinListLoadedAt = new Date(); this.logger.info(`Loaded ${coinList.length} coins from CoinGecko`); } catch (error) { this.logger.error('Failed to load coin list from CoinGecko:', error); // Don't throw - we can still work with direct IDs } } /** * Map CoinGecko simple/price response to IStockPrice */ private mapToStockPrice( ticker: string, coinId: string, data: any, dataType: 'live' | 'eod' | 'intraday' ): IStockPrice { const price = data.usd; const change24h = data.usd_24h_change || 0; // Calculate previous close from 24h change const changePercent = change24h; const change = (price * changePercent) / 100; const previousClose = price - change; // Parse last updated timestamp const timestamp = data.last_updated_at ? new Date(data.last_updated_at * 1000) : new Date(); return { ticker: ticker.toUpperCase(), price: price, currency: 'USD', change: change, changePercent: changePercent, previousClose: previousClose, timestamp: timestamp, provider: this.name, marketState: 'REGULAR', // Crypto markets are 24/7 // Volume and market cap volume: data.usd_24h_vol, dataType: dataType, fetchedAt: new Date(), // Company identification (use coin name) companyName: coinId.charAt(0).toUpperCase() + coinId.slice(1), companyFullName: `${coinId.charAt(0).toUpperCase() + coinId.slice(1)} (${ticker.toUpperCase()})` }; } /** * Build HTTP headers with optional API key */ private buildHeaders(): Record { const headers: Record = { 'Accept': 'application/json' }; if (this.apiKey) { // Use Demo or Pro API key header // CoinGecko accepts both x-cg-demo-api-key and x-cg-pro-api-key headers['x-cg-demo-api-key'] = this.apiKey; } return headers; } /** * Check if response indicates a rate limit error (429) */ private isRateLimitError(responseData: any): boolean { return responseData?.status?.error_code === 429; } /** * Wrapper for fetch operations with automatic rate limit retry and exponential backoff */ private async fetchWithRateLimitRetry( fetchFn: () => Promise, operationName: string, maxRetries: number = 3 ): Promise { let lastError: Error | undefined; for (let attempt = 0; attempt < maxRetries; attempt++) { try { const result = await fetchFn(); this.rateLimiter.resetErrors(); return result; } catch (error) { lastError = error as Error; if (error instanceof RateLimitError) { const attemptInfo = `${attempt + 1}/${maxRetries}`; this.logger.warn( `Rate limit hit for ${operationName}, waiting ${error.waitTime}ms before retry ${attemptInfo}` ); if (attempt < maxRetries - 1) { await plugins.smartdelay.delayFor(error.waitTime); continue; } else { this.logger.error(`Max retries (${maxRetries}) exceeded for ${operationName} due to rate limiting`); throw error; } } // Non-rate-limit errors: throw immediately throw error; } } throw lastError!; } }