@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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/stocks/classes.stockservice.d.ts +9 -0
- package/dist_ts/stocks/classes.stockservice.js +126 -6
- package/dist_ts/stocks/interfaces/provider.d.ts +2 -0
- package/dist_ts/stocks/providers/provider.coingecko.js +34 -9
- package/dist_ts/stocks/providers/provider.marketstack.d.ts +73 -12
- package/dist_ts/stocks/providers/provider.marketstack.js +344 -64
- package/package.json +1 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/stocks/classes.stockservice.ts +157 -5
- package/ts/stocks/interfaces/provider.ts +2 -0
- package/ts/stocks/providers/provider.coingecko.ts +42 -8
- package/ts/stocks/providers/provider.marketstack.ts +398 -70
|
@@ -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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
382
|
-
|
|
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
|
|
484
|
-
const
|
|
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
|
|
633
|
-
if (!this.priorityTickerMap.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
|