@fin.cx/opendata 3.3.0 → 3.5.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.
@@ -156,11 +156,22 @@ export class StockPriceService implements IProviderRegistry {
156
156
  */
157
157
  public async getData(request: IStockDataRequest): Promise<IStockPrice | IStockPrice[]> {
158
158
  const cacheKey = this.getDataCacheKey(request);
159
- const cached = this.getFromCache(cacheKey);
160
159
 
161
- if (cached) {
162
- console.log(`Cache hit for ${this.getRequestDescription(request)}`);
163
- return cached;
160
+ // For intraday requests without date filter, ALWAYS try incremental fetch
161
+ // This ensures we check for new data even if cache hasn't expired
162
+ if (request.type === 'intraday' && !request.date) {
163
+ const incrementalResult = await this.tryIncrementalFetch(request, cacheKey);
164
+ if (incrementalResult) {
165
+ return incrementalResult;
166
+ }
167
+ // If incremental fetch returns null, continue to normal fetch below
168
+ } else {
169
+ // For other request types (historical, current, batch), use simple cache
170
+ const cached = this.getFromCache(cacheKey);
171
+ if (cached) {
172
+ console.log(`Cache hit for ${this.getRequestDescription(request)}`);
173
+ return cached;
174
+ }
164
175
  }
165
176
 
166
177
  const providers = this.getEnabledProviders();
@@ -204,6 +215,137 @@ export class StockPriceService implements IProviderRegistry {
204
215
  );
205
216
  }
206
217
 
218
+ /**
219
+ * Try incremental fetch: Only fetch NEW data since last cached timestamp
220
+ * Returns merged result if successful, null if incremental fetch not applicable
221
+ */
222
+ private async tryIncrementalFetch(
223
+ request: IStockDataRequest,
224
+ cacheKey: string
225
+ ): Promise<IStockPrice[] | null> {
226
+ // Only applicable for intraday requests without date filter
227
+ if (request.type !== 'intraday' || request.date) {
228
+ return null;
229
+ }
230
+
231
+ // Check if we have similar cached data (same ticker, interval, but any limit/date)
232
+ const baseKey = `intraday:${request.ticker}:${request.interval}:latest`;
233
+ let cachedData: IStockPrice[] | null = null;
234
+ let matchedKey: string | null = null;
235
+
236
+ // Find any cached intraday data for this ticker+interval
237
+ for (const [key, entry] of this.cache.entries()) {
238
+ if (key.startsWith(baseKey)) {
239
+ const age = Date.now() - entry.timestamp.getTime();
240
+ if (entry.ttl !== Infinity && age > entry.ttl) {
241
+ continue; // Expired
242
+ }
243
+ cachedData = Array.isArray(entry.price) ? entry.price as IStockPrice[] : null;
244
+ matchedKey = key;
245
+ break;
246
+ }
247
+ }
248
+
249
+ if (!cachedData || cachedData.length === 0) {
250
+ return null; // No cached data to build on
251
+ }
252
+
253
+ // Find latest timestamp in cached data
254
+ const latestCached = cachedData.reduce((latest, price) => {
255
+ return price.timestamp > latest ? price.timestamp : latest;
256
+ }, new Date(0));
257
+
258
+ // Freshness check: If latest data is less than 1 minute old, just return cache
259
+ const dataAge = Date.now() - latestCached.getTime();
260
+ const freshnessThreshold = 60 * 1000; // 1 minute
261
+
262
+ if (dataAge < freshnessThreshold) {
263
+ console.log(`🔄 Incremental cache: Latest data is ${Math.round(dataAge / 1000)}s old (< 1min), returning cached data`);
264
+ return cachedData;
265
+ }
266
+
267
+ console.log(`🔄 Incremental cache: Found ${cachedData.length} cached records, latest: ${latestCached.toISOString()} (${Math.round(dataAge / 1000)}s old)`);
268
+
269
+ // Fetch only NEW data since latest cached timestamp
270
+ // Create a modified request with date filter
271
+ const modifiedRequest: IStockIntradayRequest = {
272
+ ...request,
273
+ date: latestCached // Fetch from this date forward
274
+ };
275
+
276
+ const providers = this.getEnabledProviders();
277
+ for (const provider of providers) {
278
+ const entry = this.providers.get(provider.name)!;
279
+
280
+ try {
281
+ const newData = await this.fetchWithRetry(
282
+ () => provider.fetchData(modifiedRequest),
283
+ entry.config
284
+ ) as IStockPrice[];
285
+
286
+ entry.successCount++;
287
+
288
+ // Filter out data at or before latest cached timestamp (avoid duplicates)
289
+ const filteredNew = newData.filter(p => p.timestamp > latestCached);
290
+
291
+ if (filteredNew.length === 0) {
292
+ console.log(`🔄 Incremental cache: No new data since ${latestCached.toISOString()}, using cache`);
293
+ return cachedData;
294
+ }
295
+
296
+ console.log(`🔄 Incremental cache: Fetched ${filteredNew.length} new records since ${latestCached.toISOString()}`);
297
+
298
+ // Merge cached + new data
299
+ const merged = [...cachedData, ...filteredNew];
300
+
301
+ // Sort by timestamp (ascending)
302
+ merged.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
303
+
304
+ // Deduplicate by timestamp (keep latest)
305
+ const deduped = this.deduplicateByTimestamp(merged);
306
+
307
+ // Apply limit if specified in original request
308
+ const effectiveLimit = request.limit || deduped.length;
309
+ const result = deduped.slice(-effectiveLimit); // Take most recent N
310
+
311
+ // Update cache with merged result
312
+ const ttl = this.getRequestTTL(request, result);
313
+ this.addToCache(cacheKey, result, ttl);
314
+
315
+ console.log(`🔄 Incremental cache: Returning ${result.length} total records (${cachedData.length} cached + ${filteredNew.length} new)`);
316
+ return result;
317
+
318
+ } catch (error) {
319
+ entry.errorCount++;
320
+ entry.lastError = error as Error;
321
+ entry.lastErrorTime = new Date();
322
+ console.warn(`Incremental fetch failed for ${provider.name}, falling back to full fetch`);
323
+ continue; // Try next provider or fall back to normal fetch
324
+ }
325
+ }
326
+
327
+ return null; // Incremental fetch failed, fall back to normal fetch
328
+ }
329
+
330
+ /**
331
+ * Deduplicate array of prices by timestamp, keeping the latest data for each timestamp
332
+ */
333
+ private deduplicateByTimestamp(prices: IStockPrice[]): IStockPrice[] {
334
+ const seen = new Map<number, IStockPrice>();
335
+
336
+ for (const price of prices) {
337
+ const ts = price.timestamp.getTime();
338
+ const existing = seen.get(ts);
339
+
340
+ // Keep the entry with the latest fetchedAt (most recent data)
341
+ if (!existing || price.fetchedAt > existing.fetchedAt) {
342
+ seen.set(ts, price);
343
+ }
344
+ }
345
+
346
+ return Array.from(seen.values());
347
+ }
348
+
207
349
  /**
208
350
  * Get TTL based on request type and result
209
351
  */
@@ -328,7 +470,8 @@ export class StockPriceService implements IProviderRegistry {
328
470
  return `historical:${request.ticker}:${fromStr}:${toStr}${request.exchange ? `:${request.exchange}` : ''}`;
329
471
  case 'intraday':
330
472
  const dateStr = request.date ? request.date.toISOString().split('T')[0] : 'latest';
331
- return `intraday:${request.ticker}:${request.interval}:${dateStr}${request.exchange ? `:${request.exchange}` : ''}`;
473
+ const limitStr = request.limit ? `:limit${request.limit}` : '';
474
+ return `intraday:${request.ticker}:${request.interval}:${dateStr}${limitStr}${request.exchange ? `:${request.exchange}` : ''}`;
332
475
  case 'batch':
333
476
  const tickers = request.tickers.sort().join(',');
334
477
  return `batch:${tickers}${request.exchange ? `:${request.exchange}` : ''}`;
@@ -355,6 +498,15 @@ export class StockPriceService implements IProviderRegistry {
355
498
  }
356
499
 
357
500
  private addToCache(key: string, price: IStockPrice | IStockPrice[], ttl?: number): void {
501
+ // Deduplicate array entries by timestamp before caching
502
+ if (Array.isArray(price)) {
503
+ const beforeCount = price.length;
504
+ price = this.deduplicateByTimestamp(price);
505
+ if (price.length < beforeCount) {
506
+ console.log(`Deduplicated ${beforeCount - price.length} duplicate timestamps in cache entry for ${key}`);
507
+ }
508
+ }
509
+
358
510
  // Enforce max entries limit
359
511
  if (this.cache.size >= this.cacheConfig.maxEntries) {
360
512
  // Remove oldest entry
@@ -24,6 +24,8 @@ export interface IProviderConfig {
24
24
  timeout?: number;
25
25
  retryAttempts?: number;
26
26
  retryDelay?: number;
27
+ maxRecords?: number; // Maximum records to fetch per request (default: 10000)
28
+ defaultIntradayLimit?: number; // Default limit for intraday requests without explicit limit (default: 1000)
27
29
  }
28
30
 
29
31
  export interface IProviderRegistry {
@@ -378,8 +378,18 @@ export class CoinGeckoProvider implements IStockProvider {
378
378
  const marketCapData = responseData.market_caps || [];
379
379
  const volumeData = responseData.total_volumes || [];
380
380
 
381
- // Process each data point
382
- for (let i = 0; i < priceData.length; i++) {
381
+ // Warn if processing large amount of historical data
382
+ const maxRecords = this.config?.maxRecords || 10000;
383
+ if (priceData.length > maxRecords) {
384
+ this.logger.warn(
385
+ `Historical request for ${request.ticker} returned ${priceData.length} records, ` +
386
+ `which exceeds maxRecords limit of ${maxRecords}. Processing first ${maxRecords} only.`
387
+ );
388
+ }
389
+
390
+ // Process each data point (up to maxRecords)
391
+ const recordsToProcess = Math.min(priceData.length, maxRecords);
392
+ for (let i = 0; i < recordsToProcess; i++) {
383
393
  const [timestamp, price] = priceData[i];
384
394
  const date = new Date(timestamp);
385
395
 
@@ -480,8 +490,19 @@ export class CoinGeckoProvider implements IStockProvider {
480
490
  const marketCapData = responseData.market_caps || [];
481
491
  const volumeData = responseData.total_volumes || [];
482
492
 
483
- // Apply limit if specified
484
- const limit = request.limit || priceData.length;
493
+ // Apply default limit if user didn't specify one (performance optimization)
494
+ const effectiveLimit = request.limit || this.config?.defaultIntradayLimit || 1000;
495
+
496
+ // Warn if fetching large amount of data without explicit limit
497
+ if (!request.limit && priceData.length > effectiveLimit) {
498
+ this.logger.warn(
499
+ `Intraday request for ${request.ticker} returned ${priceData.length} records but no limit specified. ` +
500
+ `Applying default limit of ${effectiveLimit}. Consider adding a limit to the request for better performance.`
501
+ );
502
+ }
503
+
504
+ // Apply limit (take most recent data)
505
+ const limit = Math.min(effectiveLimit, priceData.length);
485
506
  const dataToProcess = priceData.slice(-limit);
486
507
 
487
508
  for (let i = 0; i < dataToProcess.length; i++) {
@@ -624,21 +645,34 @@ export class CoinGeckoProvider implements IStockProvider {
624
645
 
625
646
  const coinList = await response.json() as ICoinListItem[];
626
647
 
648
+ // Clear cache before rebuilding to prevent memory leak
649
+ // Keep only entries that are in priorityTickerMap
650
+ const priorityEntries = new Map<string, string>();
651
+ for (const [key, value] of this.priorityTickerMap) {
652
+ priorityEntries.set(key, value);
653
+ }
654
+ this.coinMapCache.clear();
655
+
656
+ // Restore priority mappings
657
+ for (const [key, value] of priorityEntries) {
658
+ this.coinMapCache.set(key, value);
659
+ }
660
+
627
661
  // Build mapping: symbol -> id
628
662
  for (const coin of coinList) {
629
663
  const symbol = coin.symbol.toLowerCase();
630
664
  const id = coin.id.toLowerCase();
631
665
 
632
- // Don't overwrite priority mappings or existing cache entries
633
- if (!this.priorityTickerMap.has(symbol) && !this.coinMapCache.has(symbol)) {
666
+ // Don't overwrite priority mappings
667
+ if (!this.priorityTickerMap.has(symbol)) {
634
668
  this.coinMapCache.set(symbol, id);
635
669
  }
636
- // Always cache the ID mapping
670
+ // Always cache the ID mapping (id -> id for when users pass CoinGecko IDs directly)
637
671
  this.coinMapCache.set(id, id);
638
672
  }
639
673
 
640
674
  this.coinListLoadedAt = new Date();
641
- this.logger.info(`Loaded ${coinList.length} coins from CoinGecko`);
675
+ this.logger.info(`Loaded ${coinList.length} coins from CoinGecko (cache: ${this.coinMapCache.size} entries)`);
642
676
  } catch (error) {
643
677
  this.logger.error('Failed to load coin list from CoinGecko:', error);
644
678
  // Don't throw - we can still work with direct IDs