@fin.cx/opendata 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,757 @@
1
+ import * as plugins from '../../plugins.js';
2
+ import type { IStockProvider, IProviderConfig } from '../interfaces/provider.js';
3
+ import type {
4
+ IStockPrice,
5
+ IStockDataRequest,
6
+ IStockCurrentRequest,
7
+ IStockHistoricalRequest,
8
+ IStockIntradayRequest,
9
+ IStockBatchCurrentRequest
10
+ } from '../interfaces/stockprice.js';
11
+
12
+ /**
13
+ * Custom error for rate limit exceeded responses
14
+ */
15
+ class RateLimitError extends Error {
16
+ constructor(
17
+ message: string,
18
+ public waitTime: number,
19
+ public retryAfter?: number
20
+ ) {
21
+ super(message);
22
+ this.name = 'RateLimitError';
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Rate limiter for CoinGecko API
28
+ * Free tier (Demo): 30 requests per minute
29
+ * Without registration: 5-15 requests per minute
30
+ */
31
+ class RateLimiter {
32
+ private requestTimes: number[] = [];
33
+ private maxRequestsPerMinute: number;
34
+ private consecutiveRateLimitErrors: number = 0;
35
+
36
+ constructor(maxRequestsPerMinute: number = 30) {
37
+ this.maxRequestsPerMinute = maxRequestsPerMinute;
38
+ }
39
+
40
+ public async waitForSlot(): Promise<void> {
41
+ const now = Date.now();
42
+ const oneMinuteAgo = now - 60000;
43
+
44
+ // Remove requests older than 1 minute
45
+ this.requestTimes = this.requestTimes.filter(time => time > oneMinuteAgo);
46
+
47
+ // If we've hit the limit, wait
48
+ if (this.requestTimes.length >= this.maxRequestsPerMinute) {
49
+ const oldestRequest = this.requestTimes[0];
50
+ const waitTime = 60000 - (now - oldestRequest) + 100; // +100ms buffer
51
+ await plugins.smartdelay.delayFor(waitTime);
52
+ return this.waitForSlot(); // Recursively check again
53
+ }
54
+
55
+ // Record this request
56
+ this.requestTimes.push(now);
57
+ }
58
+
59
+ /**
60
+ * Get time in milliseconds until next request slot is available
61
+ */
62
+ public getTimeUntilNextSlot(): number {
63
+ const now = Date.now();
64
+ const oneMinuteAgo = now - 60000;
65
+
66
+ // Clean old requests
67
+ const recentRequests = this.requestTimes.filter(time => time > oneMinuteAgo);
68
+
69
+ if (recentRequests.length < this.maxRequestsPerMinute) {
70
+ return 0; // Slot available now
71
+ }
72
+
73
+ // Calculate wait time until oldest request expires
74
+ const oldestRequest = recentRequests[0];
75
+ return Math.max(0, 60000 - (now - oldestRequest) + 100);
76
+ }
77
+
78
+ /**
79
+ * Handle rate limit error with exponential backoff
80
+ * Returns wait time in milliseconds
81
+ */
82
+ public handleRateLimitError(): number {
83
+ this.consecutiveRateLimitErrors++;
84
+
85
+ // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, 60s (max)
86
+ const baseWait = 1000; // 1 second
87
+ const exponent = this.consecutiveRateLimitErrors - 1;
88
+ const backoff = Math.min(
89
+ baseWait * Math.pow(2, exponent),
90
+ 60000 // max 60 seconds
91
+ );
92
+
93
+ // After 3 consecutive 429s, reduce rate limit to 80% as safety measure
94
+ if (this.consecutiveRateLimitErrors >= 3) {
95
+ const newLimit = Math.floor(this.maxRequestsPerMinute * 0.8);
96
+ if (newLimit < this.maxRequestsPerMinute) {
97
+ console.warn(
98
+ `Adjusting rate limit from ${this.maxRequestsPerMinute} to ${newLimit} requests/min due to repeated 429 errors`
99
+ );
100
+ this.maxRequestsPerMinute = newLimit;
101
+ }
102
+ }
103
+
104
+ return backoff;
105
+ }
106
+
107
+ /**
108
+ * Reset consecutive error count on successful request
109
+ */
110
+ public resetErrors(): void {
111
+ if (this.consecutiveRateLimitErrors > 0) {
112
+ this.consecutiveRateLimitErrors = 0;
113
+ }
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Interface for coin list response
119
+ */
120
+ interface ICoinListItem {
121
+ id: string;
122
+ symbol: string;
123
+ name: string;
124
+ }
125
+
126
+ /**
127
+ * CoinGecko Crypto Price Provider
128
+ *
129
+ * Documentation: https://docs.coingecko.com/v3.0.1/reference/endpoint-overview
130
+ *
131
+ * Features:
132
+ * - Current crypto prices (single and batch)
133
+ * - Historical price data with OHLCV
134
+ * - 13M+ tokens, 240+ networks, 1600+ exchanges
135
+ * - Accepts both ticker symbols (BTC, ETH) and CoinGecko IDs (bitcoin, ethereum)
136
+ * - 24/7 market data (crypto never closes)
137
+ *
138
+ * Rate Limits:
139
+ * - Free tier (no key): 5-15 requests/minute
140
+ * - Demo plan (free with registration): ~30 requests/minute, 10,000/month
141
+ * - Paid plans: Higher limits
142
+ *
143
+ * API Authentication:
144
+ * - Optional API key for Demo/paid plans
145
+ * - Header: x-cg-demo-api-key (Demo) or x-cg-pro-api-key (paid)
146
+ */
147
+ export class CoinGeckoProvider implements IStockProvider {
148
+ public name = 'CoinGecko';
149
+ public priority = 90; // High priority for crypto, between Yahoo (100) and Marketstack (80)
150
+ public readonly requiresAuth = false; // API key is optional
151
+ public readonly rateLimit = {
152
+ requestsPerMinute: 30, // Demo plan default
153
+ requestsPerDay: 10000 // Demo plan monthly quota / 30
154
+ };
155
+
156
+ private logger = console;
157
+ private baseUrl = 'https://api.coingecko.com/api/v3';
158
+ private apiKey?: string;
159
+ private rateLimiter: RateLimiter;
160
+
161
+ // Coin mapping cache
162
+ private coinMapCache = new Map<string, string>(); // ticker/id -> coingecko id
163
+ private coinListLoadedAt: Date | null = null;
164
+ private readonly coinListCacheTTL = 24 * 60 * 60 * 1000; // 24 hours
165
+
166
+ // Priority map for common crypto tickers (to avoid conflicts)
167
+ private readonly priorityTickerMap = new Map<string, string>([
168
+ ['btc', 'bitcoin'],
169
+ ['eth', 'ethereum'],
170
+ ['usdt', 'tether'],
171
+ ['bnb', 'binancecoin'],
172
+ ['sol', 'solana'],
173
+ ['usdc', 'usd-coin'],
174
+ ['xrp', 'ripple'],
175
+ ['ada', 'cardano'],
176
+ ['doge', 'dogecoin'],
177
+ ['trx', 'tron'],
178
+ ['dot', 'polkadot'],
179
+ ['matic', 'matic-network'],
180
+ ['ltc', 'litecoin'],
181
+ ['shib', 'shiba-inu'],
182
+ ['avax', 'avalanche-2'],
183
+ ['link', 'chainlink'],
184
+ ['atom', 'cosmos'],
185
+ ['uni', 'uniswap'],
186
+ ['etc', 'ethereum-classic'],
187
+ ['xlm', 'stellar']
188
+ ]);
189
+
190
+ constructor(apiKey?: string, private config?: IProviderConfig) {
191
+ this.apiKey = apiKey;
192
+ this.rateLimiter = new RateLimiter(this.rateLimit.requestsPerMinute);
193
+ }
194
+
195
+ /**
196
+ * Unified data fetching method supporting all request types
197
+ */
198
+ public async fetchData(request: IStockDataRequest): Promise<IStockPrice | IStockPrice[]> {
199
+ switch (request.type) {
200
+ case 'current':
201
+ return this.fetchCurrentPrice(request);
202
+ case 'batch':
203
+ return this.fetchBatchCurrentPrices(request);
204
+ case 'historical':
205
+ return this.fetchHistoricalPrices(request);
206
+ case 'intraday':
207
+ return this.fetchIntradayPrices(request);
208
+ default:
209
+ throw new Error(`Unsupported request type: ${(request as any).type}`);
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Fetch current price for a single crypto
215
+ */
216
+ private async fetchCurrentPrice(request: IStockCurrentRequest): Promise<IStockPrice> {
217
+ return this.fetchWithRateLimitRetry(async () => {
218
+ // Resolve ticker to CoinGecko ID
219
+ const coinId = await this.resolveCoinId(request.ticker);
220
+
221
+ // Build URL
222
+ const params = new URLSearchParams({
223
+ ids: coinId,
224
+ vs_currencies: 'usd',
225
+ include_market_cap: 'true',
226
+ include_24hr_vol: 'true',
227
+ include_24hr_change: 'true',
228
+ include_last_updated_at: 'true'
229
+ });
230
+
231
+ const url = `${this.baseUrl}/simple/price?${params}`;
232
+
233
+ // Wait for rate limit slot
234
+ await this.rateLimiter.waitForSlot();
235
+
236
+ // Make request
237
+ const response = await plugins.smartrequest.SmartRequest.create()
238
+ .url(url)
239
+ .headers(this.buildHeaders())
240
+ .timeout(this.config?.timeout || 10000)
241
+ .get();
242
+
243
+ const responseData = await response.json() as any;
244
+
245
+ // Check for rate limit error
246
+ if (this.isRateLimitError(responseData)) {
247
+ const waitTime = this.rateLimiter.handleRateLimitError();
248
+ throw new RateLimitError(
249
+ `Rate limit exceeded for ${request.ticker}`,
250
+ waitTime
251
+ );
252
+ }
253
+
254
+ if (!responseData[coinId]) {
255
+ throw new Error(`No data found for ${request.ticker} (${coinId})`);
256
+ }
257
+
258
+ return this.mapToStockPrice(request.ticker, coinId, responseData[coinId], 'live');
259
+ }, `current price for ${request.ticker}`);
260
+ }
261
+
262
+ /**
263
+ * Fetch batch current prices for multiple cryptos
264
+ */
265
+ private async fetchBatchCurrentPrices(request: IStockBatchCurrentRequest): Promise<IStockPrice[]> {
266
+ return this.fetchWithRateLimitRetry(async () => {
267
+ // Resolve all tickers to CoinGecko IDs
268
+ const coinIds = await Promise.all(
269
+ request.tickers.map(ticker => this.resolveCoinId(ticker))
270
+ );
271
+
272
+ // Build URL with comma-separated IDs
273
+ const params = new URLSearchParams({
274
+ ids: coinIds.join(','),
275
+ vs_currencies: 'usd',
276
+ include_market_cap: 'true',
277
+ include_24hr_vol: 'true',
278
+ include_24hr_change: 'true',
279
+ include_last_updated_at: 'true'
280
+ });
281
+
282
+ const url = `${this.baseUrl}/simple/price?${params}`;
283
+
284
+ // Wait for rate limit slot
285
+ await this.rateLimiter.waitForSlot();
286
+
287
+ // Make request
288
+ const response = await plugins.smartrequest.SmartRequest.create()
289
+ .url(url)
290
+ .headers(this.buildHeaders())
291
+ .timeout(this.config?.timeout || 15000)
292
+ .get();
293
+
294
+ const responseData = await response.json() as any;
295
+
296
+ // Check for rate limit error
297
+ if (this.isRateLimitError(responseData)) {
298
+ const waitTime = this.rateLimiter.handleRateLimitError();
299
+ throw new RateLimitError(
300
+ `Rate limit exceeded for batch request`,
301
+ waitTime
302
+ );
303
+ }
304
+
305
+ const prices: IStockPrice[] = [];
306
+
307
+ // Map responses back to original tickers
308
+ for (let i = 0; i < request.tickers.length; i++) {
309
+ const ticker = request.tickers[i];
310
+ const coinId = coinIds[i];
311
+
312
+ if (responseData[coinId]) {
313
+ try {
314
+ prices.push(this.mapToStockPrice(ticker, coinId, responseData[coinId], 'live'));
315
+ } catch (error) {
316
+ this.logger.warn(`Failed to parse data for ${ticker}:`, error);
317
+ }
318
+ } else {
319
+ this.logger.warn(`No data returned for ${ticker} (${coinId})`);
320
+ }
321
+ }
322
+
323
+ if (prices.length === 0) {
324
+ throw new Error('No valid price data received from batch request');
325
+ }
326
+
327
+ return prices;
328
+ }, `batch prices for ${request.tickers.length} tickers`);
329
+ }
330
+
331
+ /**
332
+ * Fetch historical prices with OHLCV data
333
+ */
334
+ private async fetchHistoricalPrices(request: IStockHistoricalRequest): Promise<IStockPrice[]> {
335
+ return this.fetchWithRateLimitRetry(async () => {
336
+ const coinId = await this.resolveCoinId(request.ticker);
337
+
338
+ // Calculate days between dates
339
+ const days = Math.ceil((request.to.getTime() - request.from.getTime()) / (1000 * 60 * 60 * 24));
340
+
341
+ // Build URL
342
+ const params = new URLSearchParams({
343
+ vs_currency: 'usd',
344
+ days: days.toString(),
345
+ interval: 'daily' // Explicit daily granularity for historical data
346
+ });
347
+
348
+ const url = `${this.baseUrl}/coins/${coinId}/market_chart?${params}`;
349
+
350
+ // Wait for rate limit slot
351
+ await this.rateLimiter.waitForSlot();
352
+
353
+ // Make request
354
+ const response = await plugins.smartrequest.SmartRequest.create()
355
+ .url(url)
356
+ .headers(this.buildHeaders())
357
+ .timeout(this.config?.timeout || 20000)
358
+ .get();
359
+
360
+ const responseData = await response.json() as any;
361
+
362
+ // Check for rate limit error
363
+ if (this.isRateLimitError(responseData)) {
364
+ const waitTime = this.rateLimiter.handleRateLimitError();
365
+ throw new RateLimitError(
366
+ `Rate limit exceeded for historical ${request.ticker}`,
367
+ waitTime
368
+ );
369
+ }
370
+
371
+ if (!responseData.prices || !Array.isArray(responseData.prices)) {
372
+ this.logger.error(`Invalid API response for ${request.ticker}:`, JSON.stringify(responseData).substring(0, 500));
373
+ throw new Error(`Invalid response format for ${request.ticker}: ${JSON.stringify(responseData).substring(0, 200)}`);
374
+ }
375
+
376
+ const prices: IStockPrice[] = [];
377
+ const priceData = responseData.prices;
378
+ const marketCapData = responseData.market_caps || [];
379
+ const volumeData = responseData.total_volumes || [];
380
+
381
+ // Process each data point
382
+ for (let i = 0; i < priceData.length; i++) {
383
+ const [timestamp, price] = priceData[i];
384
+ const date = new Date(timestamp);
385
+
386
+ // Filter by date range
387
+ if (date < request.from || date > request.to) continue;
388
+
389
+ const marketCap = marketCapData[i]?.[1];
390
+ const volume = volumeData[i]?.[1];
391
+
392
+ // Calculate previous close for change calculation
393
+ const previousClose = i > 0 ? priceData[i - 1][1] : price;
394
+ const change = price - previousClose;
395
+ const changePercent = previousClose !== 0 ? (change / previousClose) * 100 : 0;
396
+
397
+ prices.push({
398
+ ticker: request.ticker.toUpperCase(),
399
+ price: price,
400
+ currency: 'USD',
401
+ change: change,
402
+ changePercent: changePercent,
403
+ previousClose: previousClose,
404
+ timestamp: date,
405
+ provider: this.name,
406
+ marketState: 'REGULAR', // Crypto markets are always open
407
+
408
+ // OHLCV data (note: market_chart doesn't provide OHLC, only close prices)
409
+ volume: volume,
410
+
411
+ dataType: 'eod',
412
+ fetchedAt: new Date(),
413
+
414
+ companyName: coinId.charAt(0).toUpperCase() + coinId.slice(1)
415
+ });
416
+ }
417
+
418
+ return prices;
419
+ }, `historical prices for ${request.ticker}`);
420
+ }
421
+
422
+ /**
423
+ * Fetch intraday prices with hourly intervals
424
+ */
425
+ private async fetchIntradayPrices(request: IStockIntradayRequest): Promise<IStockPrice[]> {
426
+ return this.fetchWithRateLimitRetry(async () => {
427
+ const coinId = await this.resolveCoinId(request.ticker);
428
+
429
+ // Map interval to days parameter (CoinGecko auto-granularity)
430
+ // For hourly data, request 1-7 days
431
+ let days = 1;
432
+ switch (request.interval) {
433
+ case '1min':
434
+ case '5min':
435
+ case '10min':
436
+ case '15min':
437
+ case '30min':
438
+ throw new Error('CoinGecko only supports hourly intervals in market_chart. Use interval: "1hour"');
439
+ case '1hour':
440
+ days = 1; // Last 24 hours with hourly granularity
441
+ break;
442
+ }
443
+
444
+ // Build URL (omit interval param for automatic granularity based on days)
445
+ const params = new URLSearchParams({
446
+ vs_currency: 'usd',
447
+ days: days.toString()
448
+ });
449
+
450
+ const url = `${this.baseUrl}/coins/${coinId}/market_chart?${params}`;
451
+
452
+ // Wait for rate limit slot
453
+ await this.rateLimiter.waitForSlot();
454
+
455
+ // Make request
456
+ const response = await plugins.smartrequest.SmartRequest.create()
457
+ .url(url)
458
+ .headers(this.buildHeaders())
459
+ .timeout(this.config?.timeout || 15000)
460
+ .get();
461
+
462
+ const responseData = await response.json() as any;
463
+
464
+ // Check for rate limit error
465
+ if (this.isRateLimitError(responseData)) {
466
+ const waitTime = this.rateLimiter.handleRateLimitError();
467
+ throw new RateLimitError(
468
+ `Rate limit exceeded for intraday ${request.ticker}`,
469
+ waitTime
470
+ );
471
+ }
472
+
473
+ if (!responseData.prices || !Array.isArray(responseData.prices)) {
474
+ this.logger.error(`Invalid API response for ${request.ticker}:`, JSON.stringify(responseData).substring(0, 500));
475
+ throw new Error(`Invalid response format for ${request.ticker}: ${JSON.stringify(responseData).substring(0, 200)}`);
476
+ }
477
+
478
+ const prices: IStockPrice[] = [];
479
+ const priceData = responseData.prices;
480
+ const marketCapData = responseData.market_caps || [];
481
+ const volumeData = responseData.total_volumes || [];
482
+
483
+ // Apply limit if specified
484
+ const limit = request.limit || priceData.length;
485
+ const dataToProcess = priceData.slice(-limit);
486
+
487
+ for (let i = 0; i < dataToProcess.length; i++) {
488
+ const actualIndex = priceData.length - limit + i;
489
+ const [timestamp, price] = dataToProcess[i];
490
+ const date = new Date(timestamp);
491
+
492
+ const marketCap = marketCapData[actualIndex]?.[1];
493
+ const volume = volumeData[actualIndex]?.[1];
494
+
495
+ const previousClose = i > 0 ? dataToProcess[i - 1][1] : price;
496
+ const change = price - previousClose;
497
+ const changePercent = previousClose !== 0 ? (change / previousClose) * 100 : 0;
498
+
499
+ prices.push({
500
+ ticker: request.ticker.toUpperCase(),
501
+ price: price,
502
+ currency: 'USD',
503
+ change: change,
504
+ changePercent: changePercent,
505
+ previousClose: previousClose,
506
+ timestamp: date,
507
+ provider: this.name,
508
+ marketState: 'REGULAR',
509
+
510
+ volume: volume,
511
+
512
+ dataType: 'intraday',
513
+ fetchedAt: new Date(),
514
+
515
+ companyName: coinId.charAt(0).toUpperCase() + coinId.slice(1)
516
+ });
517
+ }
518
+
519
+ return prices;
520
+ }, `intraday prices for ${request.ticker}`);
521
+ }
522
+
523
+ /**
524
+ * Check if CoinGecko API is available
525
+ */
526
+ public async isAvailable(): Promise<boolean> {
527
+ try {
528
+ const url = `${this.baseUrl}/simple/price?ids=bitcoin&vs_currencies=usd`;
529
+
530
+ await this.rateLimiter.waitForSlot();
531
+
532
+ const response = await plugins.smartrequest.SmartRequest.create()
533
+ .url(url)
534
+ .headers(this.buildHeaders())
535
+ .timeout(5000)
536
+ .get();
537
+
538
+ const responseData = await response.json() as any;
539
+ return responseData.bitcoin?.usd !== undefined;
540
+ } catch (error) {
541
+ this.logger.warn('CoinGecko provider is not available:', error);
542
+ return false;
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Check if a market/network is supported
548
+ * CoinGecko supports 240+ networks
549
+ */
550
+ public supportsMarket(market: string): boolean {
551
+ // CoinGecko has extensive crypto network coverage
552
+ const supportedNetworks = [
553
+ 'CRYPTO', 'BTC', 'ETH', 'BSC', 'POLYGON', 'AVALANCHE',
554
+ 'SOLANA', 'ARBITRUM', 'OPTIMISM', 'BASE'
555
+ ];
556
+ return supportedNetworks.includes(market.toUpperCase());
557
+ }
558
+
559
+ /**
560
+ * Check if a ticker format is supported
561
+ * Supports both ticker symbols (BTC) and CoinGecko IDs (bitcoin)
562
+ */
563
+ public supportsTicker(ticker: string): boolean {
564
+ // Accept alphanumeric with hyphens (for coin IDs like 'wrapped-bitcoin')
565
+ return /^[A-Za-z0-9\-]{1,50}$/.test(ticker);
566
+ }
567
+
568
+ /**
569
+ * Resolve ticker symbol or CoinGecko ID to canonical CoinGecko ID
570
+ * Supports both formats: "BTC" -> "bitcoin", "bitcoin" -> "bitcoin"
571
+ */
572
+ private async resolveCoinId(tickerOrId: string): Promise<string> {
573
+ const normalized = tickerOrId.toLowerCase();
574
+
575
+ // Check priority map first (for common cryptos)
576
+ if (this.priorityTickerMap.has(normalized)) {
577
+ const coinId = this.priorityTickerMap.get(normalized)!;
578
+ this.coinMapCache.set(normalized, coinId);
579
+ return coinId;
580
+ }
581
+
582
+ // Check cache
583
+ if (this.coinMapCache.has(normalized)) {
584
+ return this.coinMapCache.get(normalized)!;
585
+ }
586
+
587
+ // Check if it's already a valid CoinGecko ID (contains hyphens or is all lowercase with original case)
588
+ if (normalized.includes('-') || normalized === tickerOrId) {
589
+ // Assume it's a CoinGecko ID, cache it
590
+ this.coinMapCache.set(normalized, normalized);
591
+ return normalized;
592
+ }
593
+
594
+ // Load coin list if needed
595
+ if (!this.coinListLoadedAt ||
596
+ Date.now() - this.coinListLoadedAt.getTime() > this.coinListCacheTTL) {
597
+ await this.loadCoinList();
598
+ }
599
+
600
+ // Try to find in cache after loading
601
+ if (this.coinMapCache.has(normalized)) {
602
+ return this.coinMapCache.get(normalized)!;
603
+ }
604
+
605
+ // Not found - return as-is and let API handle the error
606
+ this.logger.warn(`Could not resolve ticker ${tickerOrId} to CoinGecko ID, using as-is`);
607
+ return normalized;
608
+ }
609
+
610
+ /**
611
+ * Load complete coin list from CoinGecko API
612
+ */
613
+ private async loadCoinList(): Promise<void> {
614
+ try {
615
+ const url = `${this.baseUrl}/coins/list`;
616
+
617
+ await this.rateLimiter.waitForSlot();
618
+
619
+ const response = await plugins.smartrequest.SmartRequest.create()
620
+ .url(url)
621
+ .headers(this.buildHeaders())
622
+ .timeout(10000)
623
+ .get();
624
+
625
+ const coinList = await response.json() as ICoinListItem[];
626
+
627
+ // Build mapping: symbol -> id
628
+ for (const coin of coinList) {
629
+ const symbol = coin.symbol.toLowerCase();
630
+ const id = coin.id.toLowerCase();
631
+
632
+ // Don't overwrite priority mappings or existing cache entries
633
+ if (!this.priorityTickerMap.has(symbol) && !this.coinMapCache.has(symbol)) {
634
+ this.coinMapCache.set(symbol, id);
635
+ }
636
+ // Always cache the ID mapping
637
+ this.coinMapCache.set(id, id);
638
+ }
639
+
640
+ this.coinListLoadedAt = new Date();
641
+ this.logger.info(`Loaded ${coinList.length} coins from CoinGecko`);
642
+ } catch (error) {
643
+ this.logger.error('Failed to load coin list from CoinGecko:', error);
644
+ // Don't throw - we can still work with direct IDs
645
+ }
646
+ }
647
+
648
+ /**
649
+ * Map CoinGecko simple/price response to IStockPrice
650
+ */
651
+ private mapToStockPrice(
652
+ ticker: string,
653
+ coinId: string,
654
+ data: any,
655
+ dataType: 'live' | 'eod' | 'intraday'
656
+ ): IStockPrice {
657
+ const price = data.usd;
658
+ const change24h = data.usd_24h_change || 0;
659
+
660
+ // Calculate previous close from 24h change
661
+ const changePercent = change24h;
662
+ const change = (price * changePercent) / 100;
663
+ const previousClose = price - change;
664
+
665
+ // Parse last updated timestamp
666
+ const timestamp = data.last_updated_at
667
+ ? new Date(data.last_updated_at * 1000)
668
+ : new Date();
669
+
670
+ return {
671
+ ticker: ticker.toUpperCase(),
672
+ price: price,
673
+ currency: 'USD',
674
+ change: change,
675
+ changePercent: changePercent,
676
+ previousClose: previousClose,
677
+ timestamp: timestamp,
678
+ provider: this.name,
679
+ marketState: 'REGULAR', // Crypto markets are 24/7
680
+
681
+ // Volume and market cap
682
+ volume: data.usd_24h_vol,
683
+
684
+ dataType: dataType,
685
+ fetchedAt: new Date(),
686
+
687
+ // Company identification (use coin name)
688
+ companyName: coinId.charAt(0).toUpperCase() + coinId.slice(1),
689
+ companyFullName: `${coinId.charAt(0).toUpperCase() + coinId.slice(1)} (${ticker.toUpperCase()})`
690
+ };
691
+ }
692
+
693
+ /**
694
+ * Build HTTP headers with optional API key
695
+ */
696
+ private buildHeaders(): Record<string, string> {
697
+ const headers: Record<string, string> = {
698
+ 'Accept': 'application/json'
699
+ };
700
+
701
+ if (this.apiKey) {
702
+ // Use Demo or Pro API key header
703
+ // CoinGecko accepts both x-cg-demo-api-key and x-cg-pro-api-key
704
+ headers['x-cg-demo-api-key'] = this.apiKey;
705
+ }
706
+
707
+ return headers;
708
+ }
709
+
710
+ /**
711
+ * Check if response indicates a rate limit error (429)
712
+ */
713
+ private isRateLimitError(responseData: any): boolean {
714
+ return responseData?.status?.error_code === 429;
715
+ }
716
+
717
+ /**
718
+ * Wrapper for fetch operations with automatic rate limit retry and exponential backoff
719
+ */
720
+ private async fetchWithRateLimitRetry<T>(
721
+ fetchFn: () => Promise<T>,
722
+ operationName: string,
723
+ maxRetries: number = 3
724
+ ): Promise<T> {
725
+ let lastError: Error | undefined;
726
+
727
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
728
+ try {
729
+ const result = await fetchFn();
730
+ this.rateLimiter.resetErrors();
731
+ return result;
732
+ } catch (error) {
733
+ lastError = error as Error;
734
+
735
+ if (error instanceof RateLimitError) {
736
+ const attemptInfo = `${attempt + 1}/${maxRetries}`;
737
+ this.logger.warn(
738
+ `Rate limit hit for ${operationName}, waiting ${error.waitTime}ms before retry ${attemptInfo}`
739
+ );
740
+
741
+ if (attempt < maxRetries - 1) {
742
+ await plugins.smartdelay.delayFor(error.waitTime);
743
+ continue;
744
+ } else {
745
+ this.logger.error(`Max retries (${maxRetries}) exceeded for ${operationName} due to rate limiting`);
746
+ throw error;
747
+ }
748
+ }
749
+
750
+ // Non-rate-limit errors: throw immediately
751
+ throw error;
752
+ }
753
+ }
754
+
755
+ throw lastError!;
756
+ }
757
+ }