import * as plugins from '../plugins.js'; import type { IStockProvider, IProviderConfig, IProviderRegistry } from './interfaces/provider.js'; import type { IStockPrice, IStockPriceError, IStockDataRequest, IStockCurrentRequest, IStockHistoricalRequest, IStockIntradayRequest, IStockBatchCurrentRequest, TIntervalType } from './interfaces/stockprice.js'; // Simple request interfaces for convenience methods interface ISimpleQuoteRequest { ticker: string; } interface ISimpleBatchRequest { tickers: string[]; } interface IProviderEntry { provider: IStockProvider; config: IProviderConfig; lastError?: Error; lastErrorTime?: Date; successCount: number; errorCount: number; } interface ICacheEntry { price: IStockPrice | IStockPrice[]; timestamp: Date; ttl: number; // Specific TTL for this entry } export class StockPriceService implements IProviderRegistry { private providers = new Map(); private cache = new Map(); private logger = console; private cacheConfig = { ttl: 60000, // 60 seconds default (for backward compatibility) maxEntries: 10000 // Increased for historical data }; constructor(cacheConfig?: { ttl?: number; maxEntries?: number }) { if (cacheConfig) { this.cacheConfig = { ...this.cacheConfig, ...cacheConfig }; } } /** * Get data-type aware TTL for smart caching */ private getCacheTTL(dataType: 'eod' | 'historical' | 'intraday' | 'live', interval?: TIntervalType): number { switch (dataType) { case 'historical': return Infinity; // Historical data never changes case 'eod': return 24 * 60 * 60 * 1000; // 24 hours (EOD is static after market close) case 'intraday': // Match cache TTL to interval return this.getIntervalMs(interval); case 'live': return 30 * 1000; // 30 seconds for live data default: return this.cacheConfig.ttl; // Fallback to default } } /** * Convert interval to milliseconds */ private getIntervalMs(interval?: TIntervalType): number { if (!interval) return 60 * 1000; // Default 1 minute const intervalMap: Record = { '1min': 60 * 1000, '5min': 5 * 60 * 1000, '10min': 10 * 60 * 1000, '15min': 15 * 60 * 1000, '30min': 30 * 60 * 1000, '1hour': 60 * 60 * 1000 }; return intervalMap[interval] || 60 * 1000; } public register(provider: IStockProvider, config?: IProviderConfig): void { const defaultConfig: IProviderConfig = { enabled: true, priority: provider.priority, timeout: 10000, retryAttempts: 2, retryDelay: 1000 }; const mergedConfig = { ...defaultConfig, ...config }; this.providers.set(provider.name, { provider, config: mergedConfig, successCount: 0, errorCount: 0 }); console.log(`Registered provider: ${provider.name}`); } public unregister(providerName: string): void { this.providers.delete(providerName); console.log(`Unregistered provider: ${providerName}`); } public getProvider(name: string): IStockProvider | undefined { return this.providers.get(name)?.provider; } public getAllProviders(): IStockProvider[] { return Array.from(this.providers.values()).map(entry => entry.provider); } public getEnabledProviders(): IStockProvider[] { return Array.from(this.providers.values()) .filter(entry => entry.config.enabled) .sort((a, b) => (b.config.priority || 0) - (a.config.priority || 0)) .map(entry => entry.provider); } /** * Convenience method: Get current price for a single ticker */ public async getPrice(request: ISimpleQuoteRequest): Promise { const result = await this.getData({ type: 'current', ticker: request.ticker }); return result as IStockPrice; } /** * Convenience method: Get current prices for multiple tickers */ public async getPrices(request: ISimpleBatchRequest): Promise { const result = await this.getData({ type: 'batch', tickers: request.tickers }); return result as IStockPrice[]; } /** * New unified data fetching method supporting all request types */ public async getData(request: IStockDataRequest): Promise { const cacheKey = this.getDataCacheKey(request); const cached = this.getFromCache(cacheKey); if (cached) { console.log(`Cache hit for ${this.getRequestDescription(request)}`); return cached; } const providers = this.getEnabledProviders(); if (providers.length === 0) { throw new Error('No stock price providers available'); } let lastError: Error | undefined; for (const provider of providers) { const entry = this.providers.get(provider.name)!; try { const result = await this.fetchWithRetry( () => provider.fetchData(request), entry.config ) as IStockPrice | IStockPrice[]; entry.successCount++; // Determine TTL based on request type const ttl = this.getRequestTTL(request, result); this.addToCache(cacheKey, result, ttl); console.log(`Successfully fetched ${this.getRequestDescription(request)} from ${provider.name}`); return result; } catch (error) { entry.errorCount++; entry.lastError = error as Error; entry.lastErrorTime = new Date(); lastError = error as Error; console.warn( `Provider ${provider.name} failed for ${this.getRequestDescription(request)}: ${error.message}` ); } } throw new Error( `Failed to fetch ${this.getRequestDescription(request)} from all providers. Last error: ${lastError?.message}` ); } /** * Get TTL based on request type and result */ private getRequestTTL(request: IStockDataRequest, result: IStockPrice | IStockPrice[]): number { switch (request.type) { case 'historical': return Infinity; // Historical data never changes case 'current': return this.getCacheTTL('eod'); case 'batch': return this.getCacheTTL('eod'); case 'intraday': return this.getCacheTTL('intraday', request.interval); default: return this.cacheConfig.ttl; } } /** * Get human-readable description of request */ private getRequestDescription(request: IStockDataRequest): string { switch (request.type) { case 'current': return `current price for ${request.ticker}${request.exchange ? ` on ${request.exchange}` : ''}`; case 'historical': return `historical prices for ${request.ticker} from ${request.from.toISOString().split('T')[0]} to ${request.to.toISOString().split('T')[0]}`; case 'intraday': return `intraday ${request.interval} prices for ${request.ticker}`; case 'batch': return `batch prices for ${request.tickers.length} tickers`; default: return 'data'; } } public async checkProvidersHealth(): Promise> { const health = new Map(); for (const [name, entry] of this.providers) { if (!entry.config.enabled) { health.set(name, false); continue; } try { const isAvailable = await entry.provider.isAvailable(); health.set(name, isAvailable); } catch (error) { health.set(name, false); console.error(`Health check failed for ${name}:`, error); } } return health; } public getProviderStats(): Map { const stats = new Map(); for (const [name, entry] of this.providers) { stats.set(name, { successCount: entry.successCount, errorCount: entry.errorCount, lastError: entry.lastError?.message, lastErrorTime: entry.lastErrorTime }); } return stats; } public clearCache(): void { this.cache.clear(); console.log('Cache cleared'); } public setCacheTTL(ttl: number): void { this.cacheConfig.ttl = ttl; console.log(`Cache TTL set to ${ttl}ms`); } private async fetchWithRetry( fetchFn: () => Promise, config: IProviderConfig ): 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'); } /** * New cache key generation for discriminated union requests */ private getDataCacheKey(request: IStockDataRequest): string { switch (request.type) { case 'current': return `current:${request.ticker}${request.exchange ? `:${request.exchange}` : ''}`; case 'historical': const fromStr = request.from.toISOString().split('T')[0]; const toStr = request.to.toISOString().split('T')[0]; return `historical:${request.ticker}:${fromStr}:${toStr}${request.exchange ? `:${request.exchange}` : ''}`; case 'intraday': const dateStr = request.date ? request.date.toISOString().split('T')[0] : 'latest'; return `intraday:${request.ticker}:${request.interval}:${dateStr}${request.exchange ? `:${request.exchange}` : ''}`; case 'batch': const tickers = request.tickers.sort().join(','); return `batch:${tickers}${request.exchange ? `:${request.exchange}` : ''}`; default: return `unknown:${JSON.stringify(request)}`; } } private getFromCache(key: string): IStockPrice | IStockPrice[] | null { const entry = this.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) { this.cache.delete(key); return null; } return entry.price; } private addToCache(key: string, price: IStockPrice | IStockPrice[], ttl?: number): void { // Enforce max entries limit if (this.cache.size >= this.cacheConfig.maxEntries) { // Remove oldest entry const oldestKey = this.cache.keys().next().value; if (oldestKey) { this.cache.delete(oldestKey); } } this.cache.set(key, { price, timestamp: new Date(), ttl: ttl || this.cacheConfig.ttl }); } }