import * as plugins from '../plugins.js'; import type { IFundamentalsProvider, IFundamentalsProviderConfig, IFundamentalsProviderRegistry, IStockFundamentals, IFundamentalsRequest } from './interfaces/fundamentals.js'; interface IProviderEntry { provider: IFundamentalsProvider; config: IFundamentalsProviderConfig; lastError?: Error; lastErrorTime?: Date; successCount: number; errorCount: number; } interface ICacheEntry { fundamentals: IStockFundamentals | IStockFundamentals[]; timestamp: Date; ttl: number; } /** * Service for managing fundamental data providers and caching * Parallel to StockPriceService but for fundamental data instead of prices */ export class FundamentalsService implements IFundamentalsProviderRegistry { private providers = new Map(); private cache = new Map(); private logger = console; private cacheConfig = { ttl: 90 * 24 * 60 * 60 * 1000, // 90 days default (fundamentals change quarterly) maxEntries: 10000 }; constructor(cacheConfig?: { ttl?: number; maxEntries?: number }) { if (cacheConfig) { this.cacheConfig = { ...this.cacheConfig, ...cacheConfig }; } } /** * Register a fundamentals provider */ public register(provider: IFundamentalsProvider, config?: IFundamentalsProviderConfig): void { const defaultConfig: IFundamentalsProviderConfig = { enabled: true, priority: provider.priority, timeout: 30000, // Longer timeout for fundamental data retryAttempts: 2, retryDelay: 1000, cacheTTL: this.cacheConfig.ttl }; const mergedConfig = { ...defaultConfig, ...config }; this.providers.set(provider.name, { provider, config: mergedConfig, successCount: 0, errorCount: 0 }); console.log(`Registered fundamentals provider: ${provider.name}`); } /** * Unregister a provider */ public unregister(providerName: string): void { this.providers.delete(providerName); console.log(`Unregistered fundamentals provider: ${providerName}`); } /** * Get a specific provider by name */ public getProvider(name: string): IFundamentalsProvider | undefined { return this.providers.get(name)?.provider; } /** * Get all registered providers */ public getAllProviders(): IFundamentalsProvider[] { return Array.from(this.providers.values()).map(entry => entry.provider); } /** * Get enabled providers sorted by priority */ public getEnabledProviders(): IFundamentalsProvider[] { 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); } /** * Get fundamental data for a single ticker */ public async getFundamentals(ticker: string): Promise { const result = await this.getData({ type: 'fundamentals-current', ticker }); return result as IStockFundamentals; } /** * Get fundamental data for multiple tickers */ public async getBatchFundamentals(tickers: string[]): Promise { const result = await this.getData({ type: 'fundamentals-batch', tickers }); return result as IStockFundamentals[]; } /** * Unified data fetching method */ public async getData( request: IFundamentalsRequest ): Promise { const cacheKey = this.getCacheKey(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 fundamentals 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 ); entry.successCount++; // Use provider-specific cache TTL or default const ttl = entry.config.cacheTTL || this.cacheConfig.ttl; 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}` ); } /** * Enrich fundamentals with calculated metrics using current price */ public async enrichWithPrice( fundamentals: IStockFundamentals, price: number ): Promise { 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; } /** * Enrich batch fundamentals with prices */ public async enrichBatchWithPrices( fundamentalsList: IStockFundamentals[], priceMap: Map ): Promise { return Promise.all( fundamentalsList.map(fundamentals => { const price = priceMap.get(fundamentals.ticker); if (price) { return this.enrichWithPrice(fundamentals, price); } return Promise.resolve(fundamentals); }) ); } /** * Check health of all providers */ 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; } /** * Get provider statistics */ public getProviderStats(): Map< string, { successCount: number; errorCount: number; lastError?: string; lastErrorTime?: Date; } > { 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; } /** * Clear all cached data */ public clearCache(): void { this.cache.clear(); console.log('Fundamentals cache cleared'); } /** * Set cache TTL */ public setCacheTTL(ttl: number): void { this.cacheConfig.ttl = ttl; console.log(`Fundamentals cache TTL set to ${ttl}ms`); } /** * Get cache statistics */ public getCacheStats(): { size: number; maxEntries: number; ttl: number; } { return { size: this.cache.size, maxEntries: this.cacheConfig.maxEntries, ttl: this.cacheConfig.ttl }; } /** * Fetch with retry logic */ private async fetchWithRetry( fetchFn: () => Promise, config: 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'); } /** * Generate cache key for request */ private getCacheKey(request: IFundamentalsRequest): string { switch (request.type) { case 'fundamentals-current': return `fundamentals:${request.ticker}`; case 'fundamentals-batch': const tickers = request.tickers.sort().join(','); return `fundamentals-batch:${tickers}`; default: return `unknown:${JSON.stringify(request)}`; } } /** * Get from cache if not expired */ private getFromCache(key: string): IStockFundamentals | IStockFundamentals[] | 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.fundamentals; } /** * Add to cache with TTL */ private addToCache( key: string, fundamentals: IStockFundamentals | IStockFundamentals[], 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, { fundamentals, timestamp: new Date(), ttl: ttl || this.cacheConfig.ttl }); } /** * Get human-readable request description */ private getRequestDescription(request: IFundamentalsRequest): string { switch (request.type) { case 'fundamentals-current': return `fundamentals for ${request.ticker}`; case 'fundamentals-batch': return `fundamentals for ${request.tickers.length} tickers`; default: return 'fundamentals data'; } } }