@fin.cx/opendata 3.1.0 → 3.2.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,647 @@
1
+ import * as plugins from '../plugins.js';
2
+ import type { IStockProvider, IProviderConfig } from './interfaces/provider.js';
3
+ import type { IFundamentalsProvider, IFundamentalsProviderConfig, IStockFundamentals } from './interfaces/fundamentals.js';
4
+ import type { IStockPrice, IStockDataRequest as IPriceRequest } from './interfaces/stockprice.js';
5
+ import type { IStockData, IStockDataServiceConfig, ICompleteStockDataRequest, ICompleteStockDataBatchRequest } from './interfaces/stockdata.js';
6
+
7
+ interface IProviderEntry<T> {
8
+ provider: T;
9
+ config: IProviderConfig | IFundamentalsProviderConfig;
10
+ lastError?: Error;
11
+ lastErrorTime?: Date;
12
+ successCount: number;
13
+ errorCount: number;
14
+ }
15
+
16
+ interface ICacheEntry<T> {
17
+ data: T;
18
+ timestamp: Date;
19
+ ttl: number;
20
+ }
21
+
22
+ /**
23
+ * Unified service for managing both stock prices and fundamentals
24
+ * Provides automatic enrichment and convenient combined data access
25
+ */
26
+ export class StockDataService {
27
+ private priceProviders = new Map<string, IProviderEntry<IStockProvider>>();
28
+ private fundamentalsProviders = new Map<string, IProviderEntry<IFundamentalsProvider>>();
29
+
30
+ private priceCache = new Map<string, ICacheEntry<IStockPrice | IStockPrice[]>>();
31
+ private fundamentalsCache = new Map<string, ICacheEntry<IStockFundamentals | IStockFundamentals[]>>();
32
+
33
+ private logger = console;
34
+
35
+ private config: Required<IStockDataServiceConfig> = {
36
+ cache: {
37
+ priceTTL: 24 * 60 * 60 * 1000, // 24 hours
38
+ fundamentalsTTL: 90 * 24 * 60 * 60 * 1000, // 90 days
39
+ maxEntries: 10000
40
+ },
41
+ timeout: {
42
+ price: 10000, // 10 seconds
43
+ fundamentals: 30000 // 30 seconds
44
+ }
45
+ };
46
+
47
+ constructor(config?: IStockDataServiceConfig) {
48
+ if (config) {
49
+ this.config = {
50
+ cache: { ...this.config.cache, ...config.cache },
51
+ timeout: { ...this.config.timeout, ...config.timeout }
52
+ };
53
+ }
54
+ }
55
+
56
+ // ========== Provider Management ==========
57
+
58
+ /**
59
+ * Register a price provider
60
+ */
61
+ public registerPriceProvider(provider: IStockProvider, config?: IProviderConfig): void {
62
+ const defaultConfig: IProviderConfig = {
63
+ enabled: true,
64
+ priority: provider.priority,
65
+ timeout: this.config.timeout.price,
66
+ retryAttempts: 2,
67
+ retryDelay: 1000
68
+ };
69
+
70
+ const mergedConfig = { ...defaultConfig, ...config };
71
+
72
+ this.priceProviders.set(provider.name, {
73
+ provider,
74
+ config: mergedConfig,
75
+ successCount: 0,
76
+ errorCount: 0
77
+ });
78
+
79
+ console.log(`Registered price provider: ${provider.name}`);
80
+ }
81
+
82
+ /**
83
+ * Register a fundamentals provider
84
+ */
85
+ public registerFundamentalsProvider(
86
+ provider: IFundamentalsProvider,
87
+ config?: IFundamentalsProviderConfig
88
+ ): void {
89
+ const defaultConfig: IFundamentalsProviderConfig = {
90
+ enabled: true,
91
+ priority: provider.priority,
92
+ timeout: this.config.timeout.fundamentals,
93
+ retryAttempts: 2,
94
+ retryDelay: 1000,
95
+ cacheTTL: this.config.cache.fundamentalsTTL
96
+ };
97
+
98
+ const mergedConfig = { ...defaultConfig, ...config };
99
+
100
+ this.fundamentalsProviders.set(provider.name, {
101
+ provider,
102
+ config: mergedConfig,
103
+ successCount: 0,
104
+ errorCount: 0
105
+ });
106
+
107
+ console.log(`Registered fundamentals provider: ${provider.name}`);
108
+ }
109
+
110
+ /**
111
+ * Unregister a price provider
112
+ */
113
+ public unregisterPriceProvider(providerName: string): void {
114
+ this.priceProviders.delete(providerName);
115
+ console.log(`Unregistered price provider: ${providerName}`);
116
+ }
117
+
118
+ /**
119
+ * Unregister a fundamentals provider
120
+ */
121
+ public unregisterFundamentalsProvider(providerName: string): void {
122
+ this.fundamentalsProviders.delete(providerName);
123
+ console.log(`Unregistered fundamentals provider: ${providerName}`);
124
+ }
125
+
126
+ /**
127
+ * Get all registered price providers
128
+ */
129
+ public getPriceProviders(): IStockProvider[] {
130
+ return Array.from(this.priceProviders.values()).map(entry => entry.provider);
131
+ }
132
+
133
+ /**
134
+ * Get all registered fundamentals providers
135
+ */
136
+ public getFundamentalsProviders(): IFundamentalsProvider[] {
137
+ return Array.from(this.fundamentalsProviders.values()).map(entry => entry.provider);
138
+ }
139
+
140
+ /**
141
+ * Get enabled price providers sorted by priority
142
+ */
143
+ private getEnabledPriceProviders(): IStockProvider[] {
144
+ return Array.from(this.priceProviders.values())
145
+ .filter(entry => entry.config.enabled)
146
+ .sort((a, b) => (b.config.priority || 0) - (a.config.priority || 0))
147
+ .map(entry => entry.provider);
148
+ }
149
+
150
+ /**
151
+ * Get enabled fundamentals providers sorted by priority
152
+ */
153
+ private getEnabledFundamentalsProviders(): IFundamentalsProvider[] {
154
+ return Array.from(this.fundamentalsProviders.values())
155
+ .filter(entry => entry.config.enabled)
156
+ .sort((a, b) => (b.config.priority || 0) - (a.config.priority || 0))
157
+ .map(entry => entry.provider);
158
+ }
159
+
160
+ // ========== Data Fetching Methods ==========
161
+
162
+ /**
163
+ * Get current price for a single ticker
164
+ */
165
+ public async getPrice(ticker: string): Promise<IStockPrice> {
166
+ const cacheKey = `price:${ticker}`;
167
+ const cached = this.getFromCache(this.priceCache, cacheKey);
168
+
169
+ if (cached) {
170
+ console.log(`Cache hit for price: ${ticker}`);
171
+ return cached as IStockPrice;
172
+ }
173
+
174
+ const providers = this.getEnabledPriceProviders();
175
+ if (providers.length === 0) {
176
+ throw new Error('No price providers available');
177
+ }
178
+
179
+ let lastError: Error | undefined;
180
+
181
+ for (const provider of providers) {
182
+ const entry = this.priceProviders.get(provider.name)!;
183
+
184
+ try {
185
+ const result = await this.fetchWithRetry(
186
+ () => provider.fetchData({ type: 'current', ticker }),
187
+ entry.config
188
+ );
189
+
190
+ entry.successCount++;
191
+
192
+ const price = result as IStockPrice;
193
+ this.addToCache(this.priceCache, cacheKey, price, this.config.cache.priceTTL);
194
+
195
+ console.log(`Successfully fetched price for ${ticker} from ${provider.name}`);
196
+ return price;
197
+ } catch (error) {
198
+ entry.errorCount++;
199
+ entry.lastError = error as Error;
200
+ entry.lastErrorTime = new Date();
201
+ lastError = error as Error;
202
+
203
+ console.warn(`Provider ${provider.name} failed for ${ticker}: ${error.message}`);
204
+ }
205
+ }
206
+
207
+ throw new Error(
208
+ `Failed to fetch price for ${ticker} from all providers. Last error: ${lastError?.message}`
209
+ );
210
+ }
211
+
212
+ /**
213
+ * Get current prices for multiple tickers
214
+ */
215
+ public async getPrices(tickers: string[]): Promise<IStockPrice[]> {
216
+ const cacheKey = `prices:${tickers.sort().join(',')}`;
217
+ const cached = this.getFromCache(this.priceCache, cacheKey);
218
+
219
+ if (cached) {
220
+ console.log(`Cache hit for prices: ${tickers.length} tickers`);
221
+ return cached as IStockPrice[];
222
+ }
223
+
224
+ const providers = this.getEnabledPriceProviders();
225
+ if (providers.length === 0) {
226
+ throw new Error('No price providers available');
227
+ }
228
+
229
+ let lastError: Error | undefined;
230
+
231
+ for (const provider of providers) {
232
+ const entry = this.priceProviders.get(provider.name)!;
233
+
234
+ try {
235
+ const result = await this.fetchWithRetry(
236
+ () => provider.fetchData({ type: 'batch', tickers }),
237
+ entry.config
238
+ );
239
+
240
+ entry.successCount++;
241
+
242
+ const prices = result as IStockPrice[];
243
+ this.addToCache(this.priceCache, cacheKey, prices, this.config.cache.priceTTL);
244
+
245
+ console.log(`Successfully fetched ${prices.length} prices from ${provider.name}`);
246
+ return prices;
247
+ } catch (error) {
248
+ entry.errorCount++;
249
+ entry.lastError = error as Error;
250
+ entry.lastErrorTime = new Date();
251
+ lastError = error as Error;
252
+
253
+ console.warn(`Provider ${provider.name} failed for batch prices: ${error.message}`);
254
+ }
255
+ }
256
+
257
+ throw new Error(
258
+ `Failed to fetch prices for ${tickers.length} tickers from all providers. Last error: ${lastError?.message}`
259
+ );
260
+ }
261
+
262
+ /**
263
+ * Get fundamentals for a single ticker
264
+ */
265
+ public async getFundamentals(ticker: string): Promise<IStockFundamentals> {
266
+ const cacheKey = `fundamentals:${ticker}`;
267
+ const cached = this.getFromCache(this.fundamentalsCache, cacheKey);
268
+
269
+ if (cached) {
270
+ console.log(`Cache hit for fundamentals: ${ticker}`);
271
+ return cached as IStockFundamentals;
272
+ }
273
+
274
+ const providers = this.getEnabledFundamentalsProviders();
275
+ if (providers.length === 0) {
276
+ throw new Error('No fundamentals providers available');
277
+ }
278
+
279
+ let lastError: Error | undefined;
280
+
281
+ for (const provider of providers) {
282
+ const entry = this.fundamentalsProviders.get(provider.name)!;
283
+
284
+ try {
285
+ const result = await this.fetchWithRetry(
286
+ () => provider.fetchData({ type: 'fundamentals-current', ticker }),
287
+ entry.config
288
+ );
289
+
290
+ entry.successCount++;
291
+
292
+ const fundamentals = result as IStockFundamentals;
293
+ const ttl = (entry.config as IFundamentalsProviderConfig).cacheTTL || this.config.cache.fundamentalsTTL;
294
+ this.addToCache(this.fundamentalsCache, cacheKey, fundamentals, ttl);
295
+
296
+ console.log(`Successfully fetched fundamentals for ${ticker} from ${provider.name}`);
297
+ return fundamentals;
298
+ } catch (error) {
299
+ entry.errorCount++;
300
+ entry.lastError = error as Error;
301
+ entry.lastErrorTime = new Date();
302
+ lastError = error as Error;
303
+
304
+ console.warn(`Provider ${provider.name} failed for ${ticker} fundamentals: ${error.message}`);
305
+ }
306
+ }
307
+
308
+ throw new Error(
309
+ `Failed to fetch fundamentals for ${ticker} from all providers. Last error: ${lastError?.message}`
310
+ );
311
+ }
312
+
313
+ /**
314
+ * Get fundamentals for multiple tickers
315
+ */
316
+ public async getBatchFundamentals(tickers: string[]): Promise<IStockFundamentals[]> {
317
+ const cacheKey = `fundamentals-batch:${tickers.sort().join(',')}`;
318
+ const cached = this.getFromCache(this.fundamentalsCache, cacheKey);
319
+
320
+ if (cached) {
321
+ console.log(`Cache hit for batch fundamentals: ${tickers.length} tickers`);
322
+ return cached as IStockFundamentals[];
323
+ }
324
+
325
+ const providers = this.getEnabledFundamentalsProviders();
326
+ if (providers.length === 0) {
327
+ throw new Error('No fundamentals providers available');
328
+ }
329
+
330
+ let lastError: Error | undefined;
331
+
332
+ for (const provider of providers) {
333
+ const entry = this.fundamentalsProviders.get(provider.name)!;
334
+
335
+ try {
336
+ const result = await this.fetchWithRetry(
337
+ () => provider.fetchData({ type: 'fundamentals-batch', tickers }),
338
+ entry.config
339
+ );
340
+
341
+ entry.successCount++;
342
+
343
+ const fundamentals = result as IStockFundamentals[];
344
+ const ttl = (entry.config as IFundamentalsProviderConfig).cacheTTL || this.config.cache.fundamentalsTTL;
345
+ this.addToCache(this.fundamentalsCache, cacheKey, fundamentals, ttl);
346
+
347
+ console.log(`Successfully fetched ${fundamentals.length} fundamentals from ${provider.name}`);
348
+ return fundamentals;
349
+ } catch (error) {
350
+ entry.errorCount++;
351
+ entry.lastError = error as Error;
352
+ entry.lastErrorTime = new Date();
353
+ lastError = error as Error;
354
+
355
+ console.warn(`Provider ${provider.name} failed for batch fundamentals: ${error.message}`);
356
+ }
357
+ }
358
+
359
+ throw new Error(
360
+ `Failed to fetch fundamentals for ${tickers.length} tickers from all providers. Last error: ${lastError?.message}`
361
+ );
362
+ }
363
+
364
+ /**
365
+ * ✨ Get complete stock data (price + fundamentals) with automatic enrichment
366
+ */
367
+ public async getStockData(request: string | ICompleteStockDataRequest): Promise<IStockData> {
368
+ const normalizedRequest = typeof request === 'string'
369
+ ? { ticker: request, includeFundamentals: true, enrichFundamentals: true }
370
+ : { includeFundamentals: true, enrichFundamentals: true, ...request };
371
+
372
+ const price = await this.getPrice(normalizedRequest.ticker);
373
+
374
+ let fundamentals: IStockFundamentals | undefined;
375
+
376
+ if (normalizedRequest.includeFundamentals) {
377
+ try {
378
+ fundamentals = await this.getFundamentals(normalizedRequest.ticker);
379
+
380
+ // Enrich fundamentals with price calculations
381
+ if (normalizedRequest.enrichFundamentals && fundamentals) {
382
+ fundamentals = this.enrichWithPrice(fundamentals, price.price);
383
+ }
384
+ } catch (error) {
385
+ console.warn(`Failed to fetch fundamentals for ${normalizedRequest.ticker}: ${error.message}`);
386
+ // Continue without fundamentals
387
+ }
388
+ }
389
+
390
+ return {
391
+ ticker: normalizedRequest.ticker,
392
+ price,
393
+ fundamentals,
394
+ fetchedAt: new Date()
395
+ };
396
+ }
397
+
398
+ /**
399
+ * ✨ Get complete stock data for multiple tickers with automatic enrichment
400
+ */
401
+ public async getBatchStockData(request: string[] | ICompleteStockDataBatchRequest): Promise<IStockData[]> {
402
+ const normalizedRequest = Array.isArray(request)
403
+ ? { tickers: request, includeFundamentals: true, enrichFundamentals: true }
404
+ : { includeFundamentals: true, enrichFundamentals: true, ...request };
405
+
406
+ const prices = await this.getPrices(normalizedRequest.tickers);
407
+ const priceMap = new Map(prices.map(p => [p.ticker, p]));
408
+
409
+ let fundamentalsMap = new Map<string, IStockFundamentals>();
410
+
411
+ if (normalizedRequest.includeFundamentals) {
412
+ try {
413
+ const fundamentals = await this.getBatchFundamentals(normalizedRequest.tickers);
414
+
415
+ // Enrich with prices if requested
416
+ if (normalizedRequest.enrichFundamentals) {
417
+ for (const fund of fundamentals) {
418
+ const price = priceMap.get(fund.ticker);
419
+ if (price) {
420
+ fundamentalsMap.set(fund.ticker, this.enrichWithPrice(fund, price.price));
421
+ } else {
422
+ fundamentalsMap.set(fund.ticker, fund);
423
+ }
424
+ }
425
+ } else {
426
+ fundamentalsMap = new Map(fundamentals.map(f => [f.ticker, f]));
427
+ }
428
+ } catch (error) {
429
+ console.warn(`Failed to fetch batch fundamentals: ${error.message}`);
430
+ // Continue without fundamentals
431
+ }
432
+ }
433
+
434
+ return normalizedRequest.tickers.map(ticker => ({
435
+ ticker,
436
+ price: priceMap.get(ticker)!,
437
+ fundamentals: fundamentalsMap.get(ticker),
438
+ fetchedAt: new Date()
439
+ }));
440
+ }
441
+
442
+ // ========== Helper Methods ==========
443
+
444
+ /**
445
+ * Enrich fundamentals with calculated metrics using current price
446
+ */
447
+ private enrichWithPrice(fundamentals: IStockFundamentals, price: number): IStockFundamentals {
448
+ const enriched = { ...fundamentals };
449
+
450
+ // Calculate market cap: price × shares outstanding
451
+ if (fundamentals.sharesOutstanding) {
452
+ enriched.marketCap = price * fundamentals.sharesOutstanding;
453
+ }
454
+
455
+ // Calculate P/E ratio: price / EPS
456
+ if (fundamentals.earningsPerShareDiluted && fundamentals.earningsPerShareDiluted > 0) {
457
+ enriched.priceToEarnings = price / fundamentals.earningsPerShareDiluted;
458
+ }
459
+
460
+ // Calculate price-to-book: market cap / stockholders equity
461
+ if (enriched.marketCap && fundamentals.stockholdersEquity && fundamentals.stockholdersEquity > 0) {
462
+ enriched.priceToBook = enriched.marketCap / fundamentals.stockholdersEquity;
463
+ }
464
+
465
+ return enriched;
466
+ }
467
+
468
+ /**
469
+ * Fetch with retry logic
470
+ */
471
+ private async fetchWithRetry<T>(
472
+ fetchFn: () => Promise<T>,
473
+ config: IProviderConfig | IFundamentalsProviderConfig
474
+ ): Promise<T> {
475
+ const maxAttempts = config.retryAttempts || 1;
476
+ let lastError: Error | undefined;
477
+
478
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
479
+ try {
480
+ return await fetchFn();
481
+ } catch (error) {
482
+ lastError = error as Error;
483
+
484
+ if (attempt < maxAttempts) {
485
+ const delay = (config.retryDelay || 1000) * attempt;
486
+ console.log(`Retry attempt ${attempt} after ${delay}ms`);
487
+ await plugins.smartdelay.delayFor(delay);
488
+ }
489
+ }
490
+ }
491
+
492
+ throw lastError || new Error('Unknown error during fetch');
493
+ }
494
+
495
+ /**
496
+ * Get from cache if not expired
497
+ */
498
+ private getFromCache<T>(cache: Map<string, ICacheEntry<T>>, key: string): T | null {
499
+ const entry = cache.get(key);
500
+
501
+ if (!entry) {
502
+ return null;
503
+ }
504
+
505
+ // Check if cache entry has expired
506
+ const age = Date.now() - entry.timestamp.getTime();
507
+ if (entry.ttl !== Infinity && age > entry.ttl) {
508
+ cache.delete(key);
509
+ return null;
510
+ }
511
+
512
+ return entry.data;
513
+ }
514
+
515
+ /**
516
+ * Add to cache with TTL
517
+ */
518
+ private addToCache<T>(cache: Map<string, ICacheEntry<T>>, key: string, data: T, ttl: number): void {
519
+ // Enforce max entries limit
520
+ if (cache.size >= this.config.cache.maxEntries) {
521
+ // Remove oldest entry
522
+ const oldestKey = cache.keys().next().value;
523
+ if (oldestKey) {
524
+ cache.delete(oldestKey);
525
+ }
526
+ }
527
+
528
+ cache.set(key, {
529
+ data,
530
+ timestamp: new Date(),
531
+ ttl
532
+ });
533
+ }
534
+
535
+ // ========== Health & Statistics ==========
536
+
537
+ /**
538
+ * Check health of all providers (both price and fundamentals)
539
+ */
540
+ public async checkProvidersHealth(): Promise<Map<string, boolean>> {
541
+ const health = new Map<string, boolean>();
542
+
543
+ // Check price providers
544
+ for (const [name, entry] of this.priceProviders) {
545
+ if (!entry.config.enabled) {
546
+ health.set(`${name} (price)`, false);
547
+ continue;
548
+ }
549
+
550
+ try {
551
+ const isAvailable = await entry.provider.isAvailable();
552
+ health.set(`${name} (price)`, isAvailable);
553
+ } catch (error) {
554
+ health.set(`${name} (price)`, false);
555
+ console.error(`Health check failed for ${name}:`, error);
556
+ }
557
+ }
558
+
559
+ // Check fundamentals providers
560
+ for (const [name, entry] of this.fundamentalsProviders) {
561
+ if (!entry.config.enabled) {
562
+ health.set(`${name} (fundamentals)`, false);
563
+ continue;
564
+ }
565
+
566
+ try {
567
+ const isAvailable = await entry.provider.isAvailable();
568
+ health.set(`${name} (fundamentals)`, isAvailable);
569
+ } catch (error) {
570
+ health.set(`${name} (fundamentals)`, false);
571
+ console.error(`Health check failed for ${name}:`, error);
572
+ }
573
+ }
574
+
575
+ return health;
576
+ }
577
+
578
+ /**
579
+ * Get statistics for all providers
580
+ */
581
+ public getProviderStats(): Map<
582
+ string,
583
+ {
584
+ type: 'price' | 'fundamentals';
585
+ successCount: number;
586
+ errorCount: number;
587
+ lastError?: string;
588
+ lastErrorTime?: Date;
589
+ }
590
+ > {
591
+ const stats = new Map();
592
+
593
+ // Price provider stats
594
+ for (const [name, entry] of this.priceProviders) {
595
+ stats.set(name, {
596
+ type: 'price',
597
+ successCount: entry.successCount,
598
+ errorCount: entry.errorCount,
599
+ lastError: entry.lastError?.message,
600
+ lastErrorTime: entry.lastErrorTime
601
+ });
602
+ }
603
+
604
+ // Fundamentals provider stats
605
+ for (const [name, entry] of this.fundamentalsProviders) {
606
+ stats.set(name, {
607
+ type: 'fundamentals',
608
+ successCount: entry.successCount,
609
+ errorCount: entry.errorCount,
610
+ lastError: entry.lastError?.message,
611
+ lastErrorTime: entry.lastErrorTime
612
+ });
613
+ }
614
+
615
+ return stats;
616
+ }
617
+
618
+ /**
619
+ * Clear all caches
620
+ */
621
+ public clearCache(): void {
622
+ this.priceCache.clear();
623
+ this.fundamentalsCache.clear();
624
+ console.log('All caches cleared');
625
+ }
626
+
627
+ /**
628
+ * Get cache statistics
629
+ */
630
+ public getCacheStats(): {
631
+ priceCache: { size: number; ttl: number };
632
+ fundamentalsCache: { size: number; ttl: number };
633
+ maxEntries: number;
634
+ } {
635
+ return {
636
+ priceCache: {
637
+ size: this.priceCache.size,
638
+ ttl: this.config.cache.priceTTL
639
+ },
640
+ fundamentalsCache: {
641
+ size: this.fundamentalsCache.size,
642
+ ttl: this.config.cache.fundamentalsTTL
643
+ },
644
+ maxEntries: this.config.cache.maxEntries
645
+ };
646
+ }
647
+ }
@@ -2,10 +2,15 @@
2
2
  export * from './interfaces/stockprice.js';
3
3
  export * from './interfaces/provider.js';
4
4
  export * from './interfaces/fundamentals.js';
5
+ export * from './interfaces/stockdata.js';
5
6
 
6
7
  // Export main services
7
8
  export * from './classes.stockservice.js';
8
9
  export * from './classes.fundamentalsservice.js';
10
+ export * from './classes.stockdataservice.js'; // ✨ New unified service
11
+
12
+ // Export base service (for advanced use cases)
13
+ export * from './classes.baseproviderservice.js';
9
14
 
10
15
  // Export providers
11
16
  export * from './providers/provider.yahoo.js';