import * as plugins from '../plugins.js'; import type { IStockProvider, IProviderConfig } from './interfaces/provider.js'; import type { IFundamentalsProvider, IFundamentalsProviderConfig, IStockFundamentals } from './interfaces/fundamentals.js'; import type { IStockPrice, IStockDataRequest as IPriceRequest } from './interfaces/stockprice.js'; import type { IStockData, IStockDataServiceConfig, ICompleteStockDataRequest, ICompleteStockDataBatchRequest } from './interfaces/stockdata.js'; interface IProviderEntry { provider: T; config: IProviderConfig | IFundamentalsProviderConfig; lastError?: Error; lastErrorTime?: Date; successCount: number; errorCount: number; } interface ICacheEntry { data: T; timestamp: Date; ttl: number; } /** * Unified service for managing both stock prices and fundamentals * Provides automatic enrichment and convenient combined data access */ export class StockDataService { private priceProviders = new Map>(); private fundamentalsProviders = new Map>(); private priceCache = new Map>(); private fundamentalsCache = new Map>(); private logger = console; private config: Required = { cache: { priceTTL: 24 * 60 * 60 * 1000, // 24 hours fundamentalsTTL: 90 * 24 * 60 * 60 * 1000, // 90 days maxEntries: 10000 }, timeout: { price: 10000, // 10 seconds fundamentals: 30000 // 30 seconds } }; constructor(config?: IStockDataServiceConfig) { if (config) { this.config = { cache: { ...this.config.cache, ...config.cache }, timeout: { ...this.config.timeout, ...config.timeout } }; } } // ========== Provider Management ========== /** * Register a price provider */ public registerPriceProvider(provider: IStockProvider, config?: IProviderConfig): void { const defaultConfig: IProviderConfig = { enabled: true, priority: provider.priority, timeout: this.config.timeout.price, retryAttempts: 2, retryDelay: 1000 }; const mergedConfig = { ...defaultConfig, ...config }; this.priceProviders.set(provider.name, { provider, config: mergedConfig, successCount: 0, errorCount: 0 }); console.log(`Registered price provider: ${provider.name}`); } /** * Register a fundamentals provider */ public registerFundamentalsProvider( provider: IFundamentalsProvider, config?: IFundamentalsProviderConfig ): void { const defaultConfig: IFundamentalsProviderConfig = { enabled: true, priority: provider.priority, timeout: this.config.timeout.fundamentals, retryAttempts: 2, retryDelay: 1000, cacheTTL: this.config.cache.fundamentalsTTL }; const mergedConfig = { ...defaultConfig, ...config }; this.fundamentalsProviders.set(provider.name, { provider, config: mergedConfig, successCount: 0, errorCount: 0 }); console.log(`Registered fundamentals provider: ${provider.name}`); } /** * Unregister a price provider */ public unregisterPriceProvider(providerName: string): void { this.priceProviders.delete(providerName); console.log(`Unregistered price provider: ${providerName}`); } /** * Unregister a fundamentals provider */ public unregisterFundamentalsProvider(providerName: string): void { this.fundamentalsProviders.delete(providerName); console.log(`Unregistered fundamentals provider: ${providerName}`); } /** * Get all registered price providers */ public getPriceProviders(): IStockProvider[] { return Array.from(this.priceProviders.values()).map(entry => entry.provider); } /** * Get all registered fundamentals providers */ public getFundamentalsProviders(): IFundamentalsProvider[] { return Array.from(this.fundamentalsProviders.values()).map(entry => entry.provider); } /** * Get enabled price providers sorted by priority */ private getEnabledPriceProviders(): IStockProvider[] { return Array.from(this.priceProviders.values()) .filter(entry => entry.config.enabled) .sort((a, b) => (b.config.priority || 0) - (a.config.priority || 0)) .map(entry => entry.provider); } /** * Get enabled fundamentals providers sorted by priority */ private getEnabledFundamentalsProviders(): IFundamentalsProvider[] { return Array.from(this.fundamentalsProviders.values()) .filter(entry => entry.config.enabled) .sort((a, b) => (b.config.priority || 0) - (a.config.priority || 0)) .map(entry => entry.provider); } // ========== Data Fetching Methods ========== /** * Get current price for a single ticker */ public async getPrice(ticker: string): Promise { const cacheKey = `price:${ticker}`; const cached = this.getFromCache(this.priceCache, cacheKey); if (cached) { console.log(`Cache hit for price: ${ticker}`); return cached as IStockPrice; } const providers = this.getEnabledPriceProviders(); if (providers.length === 0) { throw new Error('No price providers available'); } let lastError: Error | undefined; for (const provider of providers) { const entry = this.priceProviders.get(provider.name)!; try { const result = await this.fetchWithRetry( () => provider.fetchData({ type: 'current', ticker }), entry.config ); entry.successCount++; const price = result as IStockPrice; this.addToCache(this.priceCache, cacheKey, price, this.config.cache.priceTTL); console.log(`Successfully fetched price for ${ticker} from ${provider.name}`); return price; } catch (error) { entry.errorCount++; entry.lastError = error as Error; entry.lastErrorTime = new Date(); lastError = error as Error; console.warn(`Provider ${provider.name} failed for ${ticker}: ${error.message}`); } } throw new Error( `Failed to fetch price for ${ticker} from all providers. Last error: ${lastError?.message}` ); } /** * Get current prices for multiple tickers */ public async getPrices(tickers: string[]): Promise { const cacheKey = `prices:${tickers.sort().join(',')}`; const cached = this.getFromCache(this.priceCache, cacheKey); if (cached) { console.log(`Cache hit for prices: ${tickers.length} tickers`); return cached as IStockPrice[]; } const providers = this.getEnabledPriceProviders(); if (providers.length === 0) { throw new Error('No price providers available'); } let lastError: Error | undefined; for (const provider of providers) { const entry = this.priceProviders.get(provider.name)!; try { const result = await this.fetchWithRetry( () => provider.fetchData({ type: 'batch', tickers }), entry.config ); entry.successCount++; const prices = result as IStockPrice[]; this.addToCache(this.priceCache, cacheKey, prices, this.config.cache.priceTTL); console.log(`Successfully fetched ${prices.length} prices from ${provider.name}`); return prices; } catch (error) { entry.errorCount++; entry.lastError = error as Error; entry.lastErrorTime = new Date(); lastError = error as Error; console.warn(`Provider ${provider.name} failed for batch prices: ${error.message}`); } } throw new Error( `Failed to fetch prices for ${tickers.length} tickers from all providers. Last error: ${lastError?.message}` ); } /** * Get fundamentals for a single ticker */ public async getFundamentals(ticker: string): Promise { const cacheKey = `fundamentals:${ticker}`; const cached = this.getFromCache(this.fundamentalsCache, cacheKey); if (cached) { console.log(`Cache hit for fundamentals: ${ticker}`); return cached as IStockFundamentals; } const providers = this.getEnabledFundamentalsProviders(); if (providers.length === 0) { throw new Error('No fundamentals providers available'); } let lastError: Error | undefined; for (const provider of providers) { const entry = this.fundamentalsProviders.get(provider.name)!; try { const result = await this.fetchWithRetry( () => provider.fetchData({ type: 'fundamentals-current', ticker }), entry.config ); entry.successCount++; const fundamentals = result as IStockFundamentals; const ttl = (entry.config as IFundamentalsProviderConfig).cacheTTL || this.config.cache.fundamentalsTTL; this.addToCache(this.fundamentalsCache, cacheKey, fundamentals, ttl); console.log(`Successfully fetched fundamentals for ${ticker} from ${provider.name}`); return fundamentals; } catch (error) { entry.errorCount++; entry.lastError = error as Error; entry.lastErrorTime = new Date(); lastError = error as Error; console.warn(`Provider ${provider.name} failed for ${ticker} fundamentals: ${error.message}`); } } throw new Error( `Failed to fetch fundamentals for ${ticker} from all providers. Last error: ${lastError?.message}` ); } /** * Get fundamentals for multiple tickers */ public async getBatchFundamentals(tickers: string[]): Promise { const cacheKey = `fundamentals-batch:${tickers.sort().join(',')}`; const cached = this.getFromCache(this.fundamentalsCache, cacheKey); if (cached) { console.log(`Cache hit for batch fundamentals: ${tickers.length} tickers`); return cached as IStockFundamentals[]; } const providers = this.getEnabledFundamentalsProviders(); if (providers.length === 0) { throw new Error('No fundamentals providers available'); } let lastError: Error | undefined; for (const provider of providers) { const entry = this.fundamentalsProviders.get(provider.name)!; try { const result = await this.fetchWithRetry( () => provider.fetchData({ type: 'fundamentals-batch', tickers }), entry.config ); entry.successCount++; const fundamentals = result as IStockFundamentals[]; const ttl = (entry.config as IFundamentalsProviderConfig).cacheTTL || this.config.cache.fundamentalsTTL; this.addToCache(this.fundamentalsCache, cacheKey, fundamentals, ttl); console.log(`Successfully fetched ${fundamentals.length} fundamentals from ${provider.name}`); return fundamentals; } catch (error) { entry.errorCount++; entry.lastError = error as Error; entry.lastErrorTime = new Date(); lastError = error as Error; console.warn(`Provider ${provider.name} failed for batch fundamentals: ${error.message}`); } } throw new Error( `Failed to fetch fundamentals for ${tickers.length} tickers from all providers. Last error: ${lastError?.message}` ); } /** * ✨ Get complete stock data (price + fundamentals) with automatic enrichment */ public async getStockData(request: string | ICompleteStockDataRequest): Promise { const normalizedRequest = typeof request === 'string' ? { ticker: request, includeFundamentals: true, enrichFundamentals: true } : { includeFundamentals: true, enrichFundamentals: true, ...request }; const price = await this.getPrice(normalizedRequest.ticker); let fundamentals: IStockFundamentals | undefined; if (normalizedRequest.includeFundamentals) { try { fundamentals = await this.getFundamentals(normalizedRequest.ticker); // Enrich fundamentals with price calculations if (normalizedRequest.enrichFundamentals && fundamentals) { fundamentals = this.enrichWithPrice(fundamentals, price.price); } } catch (error) { console.warn(`Failed to fetch fundamentals for ${normalizedRequest.ticker}: ${error.message}`); // Continue without fundamentals } } return { ticker: normalizedRequest.ticker, price, fundamentals, fetchedAt: new Date() }; } /** * ✨ Get complete stock data for multiple tickers with automatic enrichment */ public async getBatchStockData(request: string[] | ICompleteStockDataBatchRequest): Promise { const normalizedRequest = Array.isArray(request) ? { tickers: request, includeFundamentals: true, enrichFundamentals: true } : { includeFundamentals: true, enrichFundamentals: true, ...request }; const prices = await this.getPrices(normalizedRequest.tickers); const priceMap = new Map(prices.map(p => [p.ticker, p])); let fundamentalsMap = new Map(); if (normalizedRequest.includeFundamentals) { try { const fundamentals = await this.getBatchFundamentals(normalizedRequest.tickers); // Enrich with prices if requested if (normalizedRequest.enrichFundamentals) { for (const fund of fundamentals) { const price = priceMap.get(fund.ticker); if (price) { fundamentalsMap.set(fund.ticker, this.enrichWithPrice(fund, price.price)); } else { fundamentalsMap.set(fund.ticker, fund); } } } else { fundamentalsMap = new Map(fundamentals.map(f => [f.ticker, f])); } } catch (error) { console.warn(`Failed to fetch batch fundamentals: ${error.message}`); // Continue without fundamentals } } return normalizedRequest.tickers.map(ticker => ({ ticker, price: priceMap.get(ticker)!, fundamentals: fundamentalsMap.get(ticker), fetchedAt: new Date() })); } // ========== Helper Methods ========== /** * Enrich fundamentals with calculated metrics using current price */ private enrichWithPrice(fundamentals: IStockFundamentals, price: number): IStockFundamentals { const enriched = { ...fundamentals }; // Calculate market cap: price × shares outstanding if (fundamentals.sharesOutstanding) { enriched.marketCap = price * fundamentals.sharesOutstanding; } // Calculate P/E ratio: price / EPS if (fundamentals.earningsPerShareDiluted && fundamentals.earningsPerShareDiluted > 0) { enriched.priceToEarnings = price / fundamentals.earningsPerShareDiluted; } // Calculate price-to-book: market cap / stockholders equity if (enriched.marketCap && fundamentals.stockholdersEquity && fundamentals.stockholdersEquity > 0) { enriched.priceToBook = enriched.marketCap / fundamentals.stockholdersEquity; } return enriched; } /** * Fetch with retry logic */ private async fetchWithRetry( fetchFn: () => Promise, config: IProviderConfig | IFundamentalsProviderConfig ): Promise { const maxAttempts = config.retryAttempts || 1; let lastError: Error | undefined; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await fetchFn(); } catch (error) { lastError = error as Error; if (attempt < maxAttempts) { const delay = (config.retryDelay || 1000) * attempt; console.log(`Retry attempt ${attempt} after ${delay}ms`); await plugins.smartdelay.delayFor(delay); } } } throw lastError || new Error('Unknown error during fetch'); } /** * Get from cache if not expired */ private getFromCache(cache: Map>, key: string): T | null { const entry = cache.get(key); if (!entry) { return null; } // Check if cache entry has expired const age = Date.now() - entry.timestamp.getTime(); if (entry.ttl !== Infinity && age > entry.ttl) { cache.delete(key); return null; } return entry.data; } /** * Add to cache with TTL */ private addToCache(cache: Map>, key: string, data: T, ttl: number): void { // Enforce max entries limit if (cache.size >= this.config.cache.maxEntries) { // Remove oldest entry const oldestKey = cache.keys().next().value; if (oldestKey) { cache.delete(oldestKey); } } cache.set(key, { data, timestamp: new Date(), ttl }); } // ========== Health & Statistics ========== /** * Check health of all providers (both price and fundamentals) */ public async checkProvidersHealth(): Promise> { const health = new Map(); // Check price providers for (const [name, entry] of this.priceProviders) { if (!entry.config.enabled) { health.set(`${name} (price)`, false); continue; } try { const isAvailable = await entry.provider.isAvailable(); health.set(`${name} (price)`, isAvailable); } catch (error) { health.set(`${name} (price)`, false); console.error(`Health check failed for ${name}:`, error); } } // Check fundamentals providers for (const [name, entry] of this.fundamentalsProviders) { if (!entry.config.enabled) { health.set(`${name} (fundamentals)`, false); continue; } try { const isAvailable = await entry.provider.isAvailable(); health.set(`${name} (fundamentals)`, isAvailable); } catch (error) { health.set(`${name} (fundamentals)`, false); console.error(`Health check failed for ${name}:`, error); } } return health; } /** * Get statistics for all providers */ public getProviderStats(): Map< string, { type: 'price' | 'fundamentals'; successCount: number; errorCount: number; lastError?: string; lastErrorTime?: Date; } > { const stats = new Map(); // Price provider stats for (const [name, entry] of this.priceProviders) { stats.set(name, { type: 'price', successCount: entry.successCount, errorCount: entry.errorCount, lastError: entry.lastError?.message, lastErrorTime: entry.lastErrorTime }); } // Fundamentals provider stats for (const [name, entry] of this.fundamentalsProviders) { stats.set(name, { type: 'fundamentals', successCount: entry.successCount, errorCount: entry.errorCount, lastError: entry.lastError?.message, lastErrorTime: entry.lastErrorTime }); } return stats; } /** * Clear all caches */ public clearCache(): void { this.priceCache.clear(); this.fundamentalsCache.clear(); console.log('All caches cleared'); } /** * Get cache statistics */ public getCacheStats(): { priceCache: { size: number; ttl: number }; fundamentalsCache: { size: number; ttl: number }; maxEntries: number; } { return { priceCache: { size: this.priceCache.size, ttl: this.config.cache.priceTTL }, fundamentalsCache: { size: this.fundamentalsCache.size, ttl: this.config.cache.fundamentalsTTL }, maxEntries: this.config.cache.maxEntries }; } }