@fin.cx/opendata 1.7.0 → 2.1.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.
@@ -1,6 +1,17 @@
1
1
  import * as plugins from '../plugins.js';
2
2
  import type { IStockProvider, IProviderConfig, IProviderRegistry } from './interfaces/provider.js';
3
- import type { IStockPrice, IStockQuoteRequest, IStockBatchQuoteRequest, IStockPriceError } from './interfaces/stockprice.js';
3
+ import type {
4
+ IStockPrice,
5
+ IStockQuoteRequest,
6
+ IStockBatchQuoteRequest,
7
+ IStockPriceError,
8
+ IStockDataRequest,
9
+ IStockCurrentRequest,
10
+ IStockHistoricalRequest,
11
+ IStockIntradayRequest,
12
+ IStockBatchCurrentRequest,
13
+ TIntervalType
14
+ } from './interfaces/stockprice.js';
4
15
 
5
16
  interface IProviderEntry {
6
17
  provider: IStockProvider;
@@ -12,18 +23,19 @@ interface IProviderEntry {
12
23
  }
13
24
 
14
25
  interface ICacheEntry {
15
- price: IStockPrice;
26
+ price: IStockPrice | IStockPrice[];
16
27
  timestamp: Date;
28
+ ttl: number; // Specific TTL for this entry
17
29
  }
18
30
 
19
31
  export class StockPriceService implements IProviderRegistry {
20
32
  private providers = new Map<string, IProviderEntry>();
21
33
  private cache = new Map<string, ICacheEntry>();
22
34
  private logger = console;
23
-
35
+
24
36
  private cacheConfig = {
25
- ttl: 60000, // 60 seconds default
26
- maxEntries: 1000
37
+ ttl: 60000, // 60 seconds default (for backward compatibility)
38
+ maxEntries: 10000 // Increased for historical data
27
39
  };
28
40
 
29
41
  constructor(cacheConfig?: { ttl?: number; maxEntries?: number }) {
@@ -32,6 +44,43 @@ export class StockPriceService implements IProviderRegistry {
32
44
  }
33
45
  }
34
46
 
47
+ /**
48
+ * Get data-type aware TTL for smart caching
49
+ */
50
+ private getCacheTTL(dataType: 'eod' | 'historical' | 'intraday' | 'live', interval?: TIntervalType): number {
51
+ switch (dataType) {
52
+ case 'historical':
53
+ return Infinity; // Historical data never changes
54
+ case 'eod':
55
+ return 24 * 60 * 60 * 1000; // 24 hours (EOD is static after market close)
56
+ case 'intraday':
57
+ // Match cache TTL to interval
58
+ return this.getIntervalMs(interval);
59
+ case 'live':
60
+ return 30 * 1000; // 30 seconds for live data
61
+ default:
62
+ return this.cacheConfig.ttl; // Fallback to default
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Convert interval to milliseconds
68
+ */
69
+ private getIntervalMs(interval?: TIntervalType): number {
70
+ if (!interval) return 60 * 1000; // Default 1 minute
71
+
72
+ const intervalMap: Record<TIntervalType, number> = {
73
+ '1min': 60 * 1000,
74
+ '5min': 5 * 60 * 1000,
75
+ '10min': 10 * 60 * 1000,
76
+ '15min': 15 * 60 * 1000,
77
+ '30min': 30 * 60 * 1000,
78
+ '1hour': 60 * 60 * 1000
79
+ };
80
+
81
+ return intervalMap[interval] || 60 * 1000;
82
+ }
83
+
35
84
  public register(provider: IStockProvider, config?: IProviderConfig): void {
36
85
  const defaultConfig: IProviderConfig = {
37
86
  enabled: true,
@@ -75,8 +124,8 @@ export class StockPriceService implements IProviderRegistry {
75
124
 
76
125
  public async getPrice(request: IStockQuoteRequest): Promise<IStockPrice> {
77
126
  const cacheKey = this.getCacheKey(request);
78
- const cached = this.getFromCache(cacheKey);
79
-
127
+ const cached = this.getFromCache(cacheKey) as IStockPrice | null;
128
+
80
129
  if (cached) {
81
130
  console.log(`Cache hit for ${request.ticker}`);
82
131
  return cached;
@@ -91,7 +140,7 @@ export class StockPriceService implements IProviderRegistry {
91
140
 
92
141
  for (const provider of providers) {
93
142
  const entry = this.providers.get(provider.name)!;
94
-
143
+
95
144
  try {
96
145
  const price = await this.fetchWithRetry(
97
146
  () => provider.fetchPrice(request),
@@ -99,7 +148,11 @@ export class StockPriceService implements IProviderRegistry {
99
148
  );
100
149
 
101
150
  entry.successCount++;
102
- this.addToCache(cacheKey, price);
151
+
152
+ // Use smart TTL based on data type
153
+ const ttl = this.getCacheTTL(price.dataType);
154
+ this.addToCache(cacheKey, price, ttl);
155
+
103
156
  console.log(`Successfully fetched ${request.ticker} from ${provider.name}`);
104
157
  return price;
105
158
  } catch (error) {
@@ -107,7 +160,7 @@ export class StockPriceService implements IProviderRegistry {
107
160
  entry.lastError = error as Error;
108
161
  entry.lastErrorTime = new Date();
109
162
  lastError = error as Error;
110
-
163
+
111
164
  console.warn(
112
165
  `Provider ${provider.name} failed for ${request.ticker}: ${error.message}`
113
166
  );
@@ -126,8 +179,8 @@ export class StockPriceService implements IProviderRegistry {
126
179
  // Check cache for each ticker
127
180
  for (const ticker of request.tickers) {
128
181
  const cacheKey = this.getCacheKey({ ticker, includeExtendedHours: request.includeExtendedHours });
129
- const cached = this.getFromCache(cacheKey);
130
-
182
+ const cached = this.getFromCache(cacheKey) as IStockPrice | null;
183
+
131
184
  if (cached) {
132
185
  cachedPrices.push(cached);
133
186
  } else {
@@ -150,25 +203,26 @@ export class StockPriceService implements IProviderRegistry {
150
203
 
151
204
  for (const provider of providers) {
152
205
  const entry = this.providers.get(provider.name)!;
153
-
206
+
154
207
  try {
155
208
  fetchedPrices = await this.fetchWithRetry(
156
- () => provider.fetchPrices({
157
- tickers: tickersToFetch,
158
- includeExtendedHours: request.includeExtendedHours
209
+ () => provider.fetchPrices({
210
+ tickers: tickersToFetch,
211
+ includeExtendedHours: request.includeExtendedHours
159
212
  }),
160
213
  entry.config
161
214
  );
162
215
 
163
216
  entry.successCount++;
164
-
165
- // Cache the fetched prices
217
+
218
+ // Cache the fetched prices with smart TTL
166
219
  for (const price of fetchedPrices) {
167
- const cacheKey = this.getCacheKey({
168
- ticker: price.ticker,
169
- includeExtendedHours: request.includeExtendedHours
220
+ const cacheKey = this.getCacheKey({
221
+ ticker: price.ticker,
222
+ includeExtendedHours: request.includeExtendedHours
170
223
  });
171
- this.addToCache(cacheKey, price);
224
+ const ttl = this.getCacheTTL(price.dataType);
225
+ this.addToCache(cacheKey, price, ttl);
172
226
  }
173
227
 
174
228
  console.log(
@@ -180,7 +234,7 @@ export class StockPriceService implements IProviderRegistry {
180
234
  entry.lastError = error as Error;
181
235
  entry.lastErrorTime = new Date();
182
236
  lastError = error as Error;
183
-
237
+
184
238
  console.warn(
185
239
  `Provider ${provider.name} failed for batch request: ${error.message}`
186
240
  );
@@ -196,6 +250,101 @@ export class StockPriceService implements IProviderRegistry {
196
250
  return [...cachedPrices, ...fetchedPrices];
197
251
  }
198
252
 
253
+ /**
254
+ * New unified data fetching method supporting all request types
255
+ */
256
+ public async getData(request: IStockDataRequest): Promise<IStockPrice | IStockPrice[]> {
257
+ const cacheKey = this.getDataCacheKey(request);
258
+ const cached = this.getFromCache(cacheKey);
259
+
260
+ if (cached) {
261
+ console.log(`Cache hit for ${this.getRequestDescription(request)}`);
262
+ return cached;
263
+ }
264
+
265
+ const providers = this.getEnabledProviders();
266
+ if (providers.length === 0) {
267
+ throw new Error('No stock price providers available');
268
+ }
269
+
270
+ let lastError: Error | undefined;
271
+
272
+ for (const provider of providers) {
273
+ const entry = this.providers.get(provider.name)!;
274
+
275
+ // Check if provider supports the new fetchData method
276
+ if (typeof (provider as any).fetchData !== 'function') {
277
+ console.warn(`Provider ${provider.name} does not support new API, skipping`);
278
+ continue;
279
+ }
280
+
281
+ try {
282
+ const result = await this.fetchWithRetry(
283
+ () => (provider as any).fetchData(request),
284
+ entry.config
285
+ ) as IStockPrice | IStockPrice[];
286
+
287
+ entry.successCount++;
288
+
289
+ // Determine TTL based on request type
290
+ const ttl = this.getRequestTTL(request, result);
291
+ this.addToCache(cacheKey, result, ttl);
292
+
293
+ console.log(`Successfully fetched ${this.getRequestDescription(request)} from ${provider.name}`);
294
+ return result;
295
+ } catch (error) {
296
+ entry.errorCount++;
297
+ entry.lastError = error as Error;
298
+ entry.lastErrorTime = new Date();
299
+ lastError = error as Error;
300
+
301
+ console.warn(
302
+ `Provider ${provider.name} failed for ${this.getRequestDescription(request)}: ${error.message}`
303
+ );
304
+ }
305
+ }
306
+
307
+ throw new Error(
308
+ `Failed to fetch ${this.getRequestDescription(request)} from all providers. Last error: ${lastError?.message}`
309
+ );
310
+ }
311
+
312
+ /**
313
+ * Get TTL based on request type and result
314
+ */
315
+ private getRequestTTL(request: IStockDataRequest, result: IStockPrice | IStockPrice[]): number {
316
+ switch (request.type) {
317
+ case 'historical':
318
+ return Infinity; // Historical data never changes
319
+ case 'current':
320
+ return this.getCacheTTL('eod');
321
+ case 'batch':
322
+ return this.getCacheTTL('eod');
323
+ case 'intraday':
324
+ return this.getCacheTTL('intraday', request.interval);
325
+ default:
326
+ return this.cacheConfig.ttl;
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Get human-readable description of request
332
+ */
333
+ private getRequestDescription(request: IStockDataRequest): string {
334
+ switch (request.type) {
335
+ case 'current':
336
+ return `current price for ${request.ticker}${request.exchange ? ` on ${request.exchange}` : ''}`;
337
+ case 'historical':
338
+ return `historical prices for ${request.ticker} from ${request.from.toISOString().split('T')[0]} to ${request.to.toISOString().split('T')[0]}`;
339
+ case 'intraday':
340
+ return `intraday ${request.interval} prices for ${request.ticker}`;
341
+ case 'batch':
342
+ return `batch prices for ${request.tickers.length} tickers`;
343
+ default:
344
+ return 'data';
345
+ }
346
+ }
347
+
199
348
  public async checkProvidersHealth(): Promise<Map<string, boolean>> {
200
349
  const health = new Map<string, boolean>();
201
350
 
@@ -271,19 +420,45 @@ export class StockPriceService implements IProviderRegistry {
271
420
  throw lastError || new Error('Unknown error during fetch');
272
421
  }
273
422
 
423
+ /**
424
+ * Legacy cache key generation
425
+ */
274
426
  private getCacheKey(request: IStockQuoteRequest): string {
275
427
  return `${request.ticker}:${request.includeExtendedHours || false}`;
276
428
  }
277
429
 
278
- private getFromCache(key: string): IStockPrice | null {
430
+ /**
431
+ * New cache key generation for discriminated union requests
432
+ */
433
+ private getDataCacheKey(request: IStockDataRequest): string {
434
+ switch (request.type) {
435
+ case 'current':
436
+ return `current:${request.ticker}${request.exchange ? `:${request.exchange}` : ''}`;
437
+ case 'historical':
438
+ const fromStr = request.from.toISOString().split('T')[0];
439
+ const toStr = request.to.toISOString().split('T')[0];
440
+ return `historical:${request.ticker}:${fromStr}:${toStr}${request.exchange ? `:${request.exchange}` : ''}`;
441
+ case 'intraday':
442
+ const dateStr = request.date ? request.date.toISOString().split('T')[0] : 'latest';
443
+ return `intraday:${request.ticker}:${request.interval}:${dateStr}${request.exchange ? `:${request.exchange}` : ''}`;
444
+ case 'batch':
445
+ const tickers = request.tickers.sort().join(',');
446
+ return `batch:${tickers}${request.exchange ? `:${request.exchange}` : ''}`;
447
+ default:
448
+ return `unknown:${JSON.stringify(request)}`;
449
+ }
450
+ }
451
+
452
+ private getFromCache(key: string): IStockPrice | IStockPrice[] | null {
279
453
  const entry = this.cache.get(key);
280
-
454
+
281
455
  if (!entry) {
282
456
  return null;
283
457
  }
284
458
 
459
+ // Check if cache entry has expired
285
460
  const age = Date.now() - entry.timestamp.getTime();
286
- if (age > this.cacheConfig.ttl) {
461
+ if (entry.ttl !== Infinity && age > entry.ttl) {
287
462
  this.cache.delete(key);
288
463
  return null;
289
464
  }
@@ -291,7 +466,7 @@ export class StockPriceService implements IProviderRegistry {
291
466
  return entry.price;
292
467
  }
293
468
 
294
- private addToCache(key: string, price: IStockPrice): void {
469
+ private addToCache(key: string, price: IStockPrice | IStockPrice[], ttl?: number): void {
295
470
  // Enforce max entries limit
296
471
  if (this.cache.size >= this.cacheConfig.maxEntries) {
297
472
  // Remove oldest entry
@@ -303,7 +478,8 @@ export class StockPriceService implements IProviderRegistry {
303
478
 
304
479
  this.cache.set(key, {
305
480
  price,
306
- timestamp: new Date()
481
+ timestamp: new Date(),
482
+ ttl: ttl || this.cacheConfig.ttl
307
483
  });
308
484
  }
309
485
  }
@@ -1,5 +1,6 @@
1
1
  import * as plugins from '../../plugins.js';
2
2
 
3
+ // Enhanced stock price interface with additional OHLCV data
3
4
  export interface IStockPrice {
4
5
  ticker: string;
5
6
  price: number;
@@ -12,6 +13,15 @@ export interface IStockPrice {
12
13
  marketState: 'PRE' | 'REGULAR' | 'POST' | 'CLOSED';
13
14
  exchange?: string;
14
15
  exchangeName?: string;
16
+
17
+ // Phase 1 enhancements
18
+ volume?: number; // Trading volume
19
+ open?: number; // Opening price
20
+ high?: number; // Day high
21
+ low?: number; // Day low
22
+ adjusted?: boolean; // If price is split/dividend adjusted
23
+ dataType: 'eod' | 'intraday' | 'live'; // What kind of data this is
24
+ fetchedAt: Date; // When we fetched (vs data timestamp)
15
25
  }
16
26
  type CheckStockPrice = plugins.tsclass.typeFest.IsEqual<
17
27
  IStockPrice,
@@ -25,11 +35,74 @@ export interface IStockPriceError {
25
35
  timestamp: Date;
26
36
  }
27
37
 
38
+ // Pagination support for large datasets
39
+ export interface IPaginatedResponse<T> {
40
+ data: T[];
41
+ pagination: {
42
+ currentPage: number;
43
+ totalPages: number;
44
+ totalRecords: number;
45
+ hasMore: boolean;
46
+ limit: number;
47
+ offset: number;
48
+ };
49
+ }
50
+
51
+ // Phase 1: Discriminated union types for different request types
52
+ export type TIntervalType = '1min' | '5min' | '10min' | '15min' | '30min' | '1hour';
53
+ export type TSortOrder = 'ASC' | 'DESC';
54
+
55
+ // Current price request (latest EOD or live)
56
+ export interface IStockCurrentRequest {
57
+ type: 'current';
58
+ ticker: string;
59
+ exchange?: string; // MIC code like 'XNAS', 'XNYS', 'XLON'
60
+ }
61
+
62
+ // Historical price request (date range)
63
+ export interface IStockHistoricalRequest {
64
+ type: 'historical';
65
+ ticker: string;
66
+ from: Date;
67
+ to: Date;
68
+ exchange?: string;
69
+ sort?: TSortOrder;
70
+ limit?: number; // Max results per page (default 1000)
71
+ offset?: number; // For pagination
72
+ }
73
+
74
+ // Intraday price request (real-time intervals)
75
+ export interface IStockIntradayRequest {
76
+ type: 'intraday';
77
+ ticker: string;
78
+ interval: TIntervalType;
79
+ exchange?: string;
80
+ limit?: number; // Number of bars to return
81
+ date?: Date; // Specific date for historical intraday
82
+ }
83
+
84
+ // Batch current prices request
85
+ export interface IStockBatchCurrentRequest {
86
+ type: 'batch';
87
+ tickers: string[];
88
+ exchange?: string;
89
+ }
90
+
91
+ // Union type for all stock data requests
92
+ export type IStockDataRequest =
93
+ | IStockCurrentRequest
94
+ | IStockHistoricalRequest
95
+ | IStockIntradayRequest
96
+ | IStockBatchCurrentRequest;
97
+
98
+ // Legacy interfaces (for backward compatibility during migration)
99
+ /** @deprecated Use IStockDataRequest with type: 'current' instead */
28
100
  export interface IStockQuoteRequest {
29
101
  ticker: string;
30
102
  includeExtendedHours?: boolean;
31
103
  }
32
104
 
105
+ /** @deprecated Use IStockDataRequest with type: 'batch' instead */
33
106
  export interface IStockBatchQuoteRequest {
34
107
  tickers: string[];
35
108
  includeExtendedHours?: boolean;