diff --git a/changelog.md b/changelog.md index 514c417..0c8ab65 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,17 @@ # Changelog +## 2025-11-01 - 3.2.0 - feat(StockDataService) +Add unified StockDataService and BaseProviderService with new stockdata interfaces, provider integrations, tests and README updates + +- Introduce StockDataService: unified API to fetch prices and fundamentals with automatic enrichment and caching +- Add IStockData and IStockDataServiceConfig interfaces to define combined price+fundamentals payloads and configuration +- Implement BaseProviderService abstraction to share provider registration, health, stats and caching logic +- Add classes.stockdataservice.ts implementing batch/single fetch, enrichment, caching, health checks and provider stats +- Export new stockdata module and classes from ts/stocks/index.ts +- Add comprehensive tests: test/test.stockdata.service.node.ts to cover setup, provider registration, fetching, caching, enrichment, health and error handling +- Update README with Unified Stock Data API examples, usage, and documentation reflecting new unified service +- Minor infra: add .claude/settings.local.json permissions for local tooling and web fetch domains + ## 2025-11-01 - 3.1.0 - feat(fundamentals) Add FundamentalsService and SEC EDGAR provider with caching, rate-limiting, tests, and docs updates diff --git a/readme.md b/readme.md index 93edb95..c7fc8ed 100644 --- a/readme.md +++ b/readme.md @@ -14,64 +14,110 @@ pnpm add @fin.cx/opendata ## Quick Start -### šŸ“ˆ Stock Market Data +### ✨ Unified Stock Data API (Recommended) -Get real-time prices with company information included automatically: +Get complete stock data with automatic enrichment - the elegant way: ```typescript -import { StockPriceService, MarketstackProvider } from '@fin.cx/opendata'; +import { StockDataService, YahooFinanceProvider, SecEdgarProvider } from '@fin.cx/opendata'; -// Initialize service with smart caching -const stockService = new StockPriceService({ - ttl: 60000, // Cache TTL (historical cached forever) - maxEntries: 10000 +// Initialize unified service +const stockData = new StockDataService(); + +// Register providers +stockData.registerPriceProvider(new YahooFinanceProvider()); +stockData.registerFundamentalsProvider(new SecEdgarProvider({ + userAgent: 'YourCompany youremail@example.com' +})); + +// ✨ Get complete stock data with ONE method call +const apple = await stockData.getStockData('AAPL'); + +console.log({ + company: apple.fundamentals.companyName, // "Apple Inc." + price: apple.price.price, // $270.37 + marketCap: apple.fundamentals.marketCap, // $4.13T (auto-calculated!) + peRatio: apple.fundamentals.priceToEarnings, // 28.42 (auto-calculated!) + pbRatio: apple.fundamentals.priceToBook, // 45.12 (auto-calculated!) + eps: apple.fundamentals.earningsPerShareDiluted, // $6.13 + revenue: apple.fundamentals.revenue // $385.6B }); -// Register provider with API key -stockService.register(new MarketstackProvider('YOUR_API_KEY')); +// Batch fetch with automatic enrichment +const stocks = await stockData.getBatchStockData(['AAPL', 'MSFT', 'GOOGL']); -// Get current price with company name (zero extra API calls!) -const apple = await stockService.getData({ type: 'current', ticker: 'AAPL' }); - -console.log(`${apple.companyFullName}: $${apple.price}`); -// Output: "Apple Inc (NASDAQ:AAPL): $270.37" +stocks.forEach(stock => { + console.log(`${stock.ticker}: $${stock.price.price.toFixed(2)}, ` + + `P/E ${stock.fundamentals?.priceToEarnings?.toFixed(2)}`); +}); +// Output: +// AAPL: $270.37, P/E 28.42 +// MSFT: $425.50, P/E 34.21 +// GOOGL: $142.15, P/E 25.63 ``` -### šŸ’° Fundamental Financial Data +**Why use the unified API?** +- āœ… **Single service** for both prices and fundamentals +- āœ… **Automatic enrichment** - Market cap, P/E, P/B calculated automatically +- āœ… **One method call** - No manual `enrichWithPrice()` calls +- āœ… **Simplified code** - Less boilerplate, more readable +- āœ… **Type-safe** - Full TypeScript support -Access comprehensive financial metrics from SEC filings - completely FREE: +### šŸ“ˆ Price-Only Data (Alternative) + +If you only need prices without fundamentals: ```typescript -import { SecEdgarProvider, FundamentalsService } from '@fin.cx/opendata'; +import { StockDataService, YahooFinanceProvider } from '@fin.cx/opendata'; -// Setup SEC EDGAR provider (no API key required!) -const secEdgar = new SecEdgarProvider({ +const stockData = new StockDataService(); +stockData.registerPriceProvider(new YahooFinanceProvider()); + +// Get just the price +const price = await stockData.getPrice('AAPL'); +console.log(`${price.ticker}: $${price.price}`); + +// Or use StockPriceService directly for more control +import { StockPriceService } from '@fin.cx/opendata'; + +const stockService = new StockPriceService({ ttl: 60000 }); +stockService.register(new YahooFinanceProvider()); + +const apple = await stockService.getData({ type: 'current', ticker: 'AAPL' }); +console.log(`${apple.companyFullName}: $${apple.price}`); +``` + +### šŸ’° Fundamentals-Only Data (Alternative) + +If you only need fundamentals without prices: + +```typescript +import { StockDataService, SecEdgarProvider } from '@fin.cx/opendata'; + +const stockData = new StockDataService(); +stockData.registerFundamentalsProvider(new SecEdgarProvider({ userAgent: 'YourCompany youremail@example.com' +})); + +// Get just fundamentals +const fundamentals = await stockData.getFundamentals('AAPL'); + +console.log({ + company: fundamentals.companyName, + eps: fundamentals.earningsPerShareDiluted, + revenue: fundamentals.revenue, + sharesOutstanding: fundamentals.sharesOutstanding }); +// Or use FundamentalsService directly +import { FundamentalsService } from '@fin.cx/opendata'; + const fundamentalsService = new FundamentalsService(); -fundamentalsService.register(secEdgar); +fundamentalsService.register(new SecEdgarProvider({ + userAgent: 'YourCompany youremail@example.com' +})); -// Fetch fundamentals for Apple -const fundamentals = await fundamentalsService.getFundamentals('AAPL'); - -console.log({ - company: fundamentals.companyName, // "Apple Inc." - eps: fundamentals.earningsPerShareDiluted, // $6.13 - revenue: fundamentals.revenue, // $385.6B - sharesOutstanding: fundamentals.sharesOutstanding // 15.3B -}); - -// Calculate market cap and P/E ratio -const price = await stockService.getData({ type: 'current', ticker: 'AAPL' }); -const enriched = await fundamentalsService.enrichWithPrice(fundamentals, price.price); - -console.log({ - marketCap: `$${(enriched.marketCap! / 1_000_000_000_000).toFixed(2)}T`, - peRatio: enriched.priceToEarnings!.toFixed(2), - pbRatio: enriched.priceToBook?.toFixed(2) -}); -// Output: { marketCap: "$2.65T", peRatio: "28.42", pbRatio: "45.12" } +const data = await fundamentalsService.getFundamentals('AAPL'); ``` ### šŸ¢ German Business Intelligence @@ -136,44 +182,70 @@ const details = await openData.handelsregister.getSpecificCompany({ ## Advanced Examples -### Combined Market Analysis +### Combined Market Analysis (Unified API) -Combine price data with fundamentals for comprehensive analysis: +Analyze multiple companies with automatic enrichment: ```typescript -import { StockPriceService, MarketstackProvider, SecEdgarProvider, FundamentalsService } from '@fin.cx/opendata'; +import { StockDataService, YahooFinanceProvider, SecEdgarProvider } from '@fin.cx/opendata'; -// Setup services -const stockService = new StockPriceService({ ttl: 60000 }); -stockService.register(new MarketstackProvider('YOUR_API_KEY')); - -const fundamentalsService = new FundamentalsService(); -fundamentalsService.register(new SecEdgarProvider({ +// Setup unified service +const stockData = new StockDataService(); +stockData.registerPriceProvider(new YahooFinanceProvider()); +stockData.registerFundamentalsProvider(new SecEdgarProvider({ userAgent: 'YourCompany youremail@example.com' })); -// Analyze multiple companies +// Analyze multiple companies with ONE call const tickers = ['AAPL', 'MSFT', 'GOOGL', 'AMZN']; +const stocks = await stockData.getBatchStockData(tickers); +// All metrics are automatically calculated! +stocks.forEach(stock => { + if (stock.fundamentals) { + console.log(`\n${stock.fundamentals.companyName} (${stock.ticker})`); + console.log(` Price: $${stock.price.price.toFixed(2)}`); + console.log(` Market Cap: $${(stock.fundamentals.marketCap! / 1e9).toFixed(2)}B`); + console.log(` P/E Ratio: ${stock.fundamentals.priceToEarnings!.toFixed(2)}`); + console.log(` Revenue: $${(stock.fundamentals.revenue! / 1e9).toFixed(2)}B`); + console.log(` EPS: $${stock.fundamentals.earningsPerShareDiluted!.toFixed(2)}`); + } +}); + +// Or analyze one-by-one with automatic enrichment for (const ticker of tickers) { - // Get price and fundamentals in parallel - const [price, fundamentals] = await Promise.all([ - stockService.getData({ type: 'current', ticker }), - fundamentalsService.getFundamentals(ticker) - ]); + const stock = await stockData.getStockData(ticker); - // Calculate metrics - const enriched = await fundamentalsService.enrichWithPrice(fundamentals, price.price); - - console.log(`\n${fundamentals.companyName} (${ticker})`); - console.log(` Price: $${price.price.toFixed(2)}`); - console.log(` Market Cap: $${(enriched.marketCap! / 1e9).toFixed(2)}B`); - console.log(` P/E Ratio: ${enriched.priceToEarnings!.toFixed(2)}`); - console.log(` Revenue: $${(fundamentals.revenue! / 1e9).toFixed(2)}B`); - console.log(` EPS: $${fundamentals.earningsPerShareDiluted!.toFixed(2)}`); + // Everything is already enriched - no manual calculations needed! + console.log(`${ticker}: P/E ${stock.fundamentals?.priceToEarnings?.toFixed(2)}`); } ``` +### Fundamental Data Screening + +Screen stocks by financial metrics: + +```typescript +// Fetch data for multiple tickers +const tickers = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'META', 'NVDA', 'TSLA']; +const stocks = await stockData.getBatchStockData(tickers); + +// Filter by criteria (all metrics auto-calculated!) +const valueStocks = stocks.filter(stock => { + const f = stock.fundamentals; + return f && + f.priceToEarnings! < 30 && // P/E under 30 + f.priceToBook! < 10 && // P/B under 10 + f.revenue! > 100_000_000_000; // Revenue > $100B +}); + +console.log('\nšŸ’Ž Value Stocks:'); +valueStocks.forEach(stock => { + console.log(`${stock.ticker}: P/E ${stock.fundamentals!.priceToEarnings!.toFixed(2)}, ` + + `P/B ${stock.fundamentals!.priceToBook!.toFixed(2)}`); +}); +``` + ### Historical Data Analysis Fetch and analyze historical price trends: diff --git a/test/test.stockdata.service.node.ts b/test/test.stockdata.service.node.ts new file mode 100644 index 0000000..2c5e1ef --- /dev/null +++ b/test/test.stockdata.service.node.ts @@ -0,0 +1,418 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as opendata from '../ts/index.js'; + +const TEST_USER_AGENT = 'fin.cx test@fin.cx'; + +tap.test('StockDataService - Basic Setup', async () => { + await tap.test('should create StockDataService instance', async () => { + const service = new opendata.StockDataService({ + cache: { + priceTTL: 60000, // 1 minute for testing + fundamentalsTTL: 120000, // 2 minutes for testing + maxEntries: 100 + } + }); + + expect(service).toBeInstanceOf(opendata.StockDataService); + + const stats = service.getCacheStats(); + expect(stats.priceCache.ttl).toEqual(60000); + expect(stats.fundamentalsCache.ttl).toEqual(120000); + expect(stats.maxEntries).toEqual(100); + }); +}); + +tap.test('StockDataService - Provider Registration', async () => { + const service = new opendata.StockDataService(); + + await tap.test('should register price provider', async () => { + const yahooProvider = new opendata.YahooFinanceProvider(); + service.registerPriceProvider(yahooProvider); + + const providers = service.getPriceProviders(); + expect(providers.length).toEqual(1); + expect(providers[0].name).toEqual('Yahoo Finance'); + }); + + await tap.test('should register fundamentals provider', async () => { + const secProvider = new opendata.SecEdgarProvider({ + userAgent: TEST_USER_AGENT + }); + service.registerFundamentalsProvider(secProvider); + + const providers = service.getFundamentalsProviders(); + expect(providers.length).toEqual(1); + expect(providers[0].name).toEqual('SEC EDGAR'); + }); + + await tap.test('should unregister providers', async () => { + service.unregisterPriceProvider('Yahoo Finance'); + service.unregisterFundamentalsProvider('SEC EDGAR'); + + expect(service.getPriceProviders().length).toEqual(0); + expect(service.getFundamentalsProviders().length).toEqual(0); + }); +}); + +tap.test('StockDataService - Price Fetching', async () => { + const service = new opendata.StockDataService(); + const yahooProvider = new opendata.YahooFinanceProvider(); + service.registerPriceProvider(yahooProvider); + + await tap.test('should fetch single price', async () => { + const price = await service.getPrice('AAPL'); + + expect(price).toBeDefined(); + expect(price.ticker).toEqual('AAPL'); + expect(price.price).toBeGreaterThan(0); + expect(price.provider).toEqual('Yahoo Finance'); + expect(price.timestamp).toBeInstanceOf(Date); + + console.log(`\nšŸ’µ Single Price: ${price.ticker} = $${price.price.toFixed(2)}`); + }); + + await tap.test('should fetch batch prices', async () => { + const prices = await service.getPrices(['AAPL', 'MSFT', 'GOOGL']); + + expect(prices).toBeInstanceOf(Array); + expect(prices.length).toBeGreaterThan(0); + expect(prices.length).toBeLessThanOrEqual(3); + + console.log('\nšŸ’µ Batch Prices:'); + prices.forEach(p => { + console.log(` ${p.ticker}: $${p.price.toFixed(2)}`); + }); + }); + + await tap.test('should cache prices', async () => { + // Clear cache + service.clearCache(); + + const stats1 = service.getCacheStats(); + expect(stats1.priceCache.size).toEqual(0); + + // Fetch price (should hit API) + const start1 = Date.now(); + await service.getPrice('AAPL'); + const duration1 = Date.now() - start1; + + const stats2 = service.getCacheStats(); + expect(stats2.priceCache.size).toEqual(1); + + // Fetch again (should hit cache - much faster) + const start2 = Date.now(); + await service.getPrice('AAPL'); + const duration2 = Date.now() - start2; + + expect(duration2).toBeLessThan(duration1); + + console.log('\n⚔ Cache Performance:'); + console.log(` First fetch: ${duration1}ms`); + console.log(` Cached fetch: ${duration2}ms`); + console.log(` Speedup: ${Math.round(duration1 / duration2)}x`); + }); +}); + +tap.test('StockDataService - Fundamentals Fetching', async () => { + const service = new opendata.StockDataService(); + const secProvider = new opendata.SecEdgarProvider({ + userAgent: TEST_USER_AGENT + }); + service.registerFundamentalsProvider(secProvider); + + await tap.test('should fetch single fundamentals', async () => { + const fundamentals = await service.getFundamentals('AAPL'); + + expect(fundamentals).toBeDefined(); + expect(fundamentals.ticker).toEqual('AAPL'); + expect(fundamentals.companyName).toEqual('Apple Inc.'); + expect(fundamentals.provider).toEqual('SEC EDGAR'); + expect(fundamentals.earningsPerShareDiluted).toBeGreaterThan(0); + expect(fundamentals.sharesOutstanding).toBeGreaterThan(0); + + console.log('\nšŸ“Š Single Fundamentals:'); + console.log(` ${fundamentals.ticker}: ${fundamentals.companyName}`); + console.log(` EPS: $${fundamentals.earningsPerShareDiluted?.toFixed(2)}`); + console.log(` Shares: ${(fundamentals.sharesOutstanding! / 1_000_000_000).toFixed(2)}B`); + }); + + await tap.test('should fetch batch fundamentals', async () => { + const fundamentals = await service.getBatchFundamentals(['AAPL', 'MSFT']); + + expect(fundamentals).toBeInstanceOf(Array); + expect(fundamentals.length).toEqual(2); + + console.log('\nšŸ“Š Batch Fundamentals:'); + fundamentals.forEach(f => { + console.log(` ${f.ticker}: ${f.companyName} - EPS: $${f.earningsPerShareDiluted?.toFixed(2)}`); + }); + }); + + await tap.test('should cache fundamentals', async () => { + // Clear cache + service.clearCache(); + + const stats1 = service.getCacheStats(); + expect(stats1.fundamentalsCache.size).toEqual(0); + + // Fetch fundamentals (should hit API) + await service.getFundamentals('AAPL'); + + const stats2 = service.getCacheStats(); + expect(stats2.fundamentalsCache.size).toEqual(1); + }); +}); + +tap.test('StockDataService - Complete Stock Data', async () => { + const service = new opendata.StockDataService(); + + // Register both providers + const yahooProvider = new opendata.YahooFinanceProvider(); + const secProvider = new opendata.SecEdgarProvider({ + userAgent: TEST_USER_AGENT + }); + + service.registerPriceProvider(yahooProvider); + service.registerFundamentalsProvider(secProvider); + + await tap.test('should fetch complete stock data with string', async () => { + const data = await service.getStockData('AAPL'); + + expect(data).toBeDefined(); + expect(data.ticker).toEqual('AAPL'); + expect(data.price).toBeDefined(); + expect(data.price.ticker).toEqual('AAPL'); + expect(data.fundamentals).toBeDefined(); + expect(data.fundamentals?.ticker).toEqual('AAPL'); + expect(data.fetchedAt).toBeInstanceOf(Date); + + // Check automatic enrichment + expect(data.fundamentals?.marketCap).toBeDefined(); + expect(data.fundamentals?.priceToEarnings).toBeDefined(); + expect(data.fundamentals?.marketCap).toBeGreaterThan(0); + expect(data.fundamentals?.priceToEarnings).toBeGreaterThan(0); + + console.log('\n✨ Complete Stock Data (Auto-Enriched):'); + console.log(` ${data.ticker}: ${data.fundamentals?.companyName}`); + console.log(` Price: $${data.price.price.toFixed(2)}`); + console.log(` Market Cap: $${(data.fundamentals!.marketCap! / 1_000_000_000_000).toFixed(2)}T`); + console.log(` P/E Ratio: ${data.fundamentals!.priceToEarnings!.toFixed(2)}`); + }); + + await tap.test('should fetch complete stock data with request object', async () => { + const data = await service.getStockData({ + ticker: 'MSFT', + includeFundamentals: true, + enrichFundamentals: true + }); + + expect(data).toBeDefined(); + expect(data.ticker).toEqual('MSFT'); + expect(data.price).toBeDefined(); + expect(data.fundamentals).toBeDefined(); + expect(data.fundamentals?.marketCap).toBeDefined(); + expect(data.fundamentals?.priceToEarnings).toBeDefined(); + }); + + await tap.test('should fetch complete stock data without fundamentals', async () => { + const data = await service.getStockData({ + ticker: 'GOOGL', + includeFundamentals: false + }); + + expect(data).toBeDefined(); + expect(data.ticker).toEqual('GOOGL'); + expect(data.price).toBeDefined(); + expect(data.fundamentals).toBeUndefined(); + }); + + await tap.test('should handle fundamentals fetch failure gracefully', async () => { + // Try a ticker that might not have fundamentals + const data = await service.getStockData({ + ticker: 'BTC-USD', // Crypto - no SEC filings + includeFundamentals: true + }); + + expect(data).toBeDefined(); + expect(data.price).toBeDefined(); + // Fundamentals might be undefined due to error + console.log(`\nāš ļø ${data.ticker} - Price available, Fundamentals: ${data.fundamentals ? 'Yes' : 'No'}`); + }); +}); + +tap.test('StockDataService - Batch Complete Stock Data', async () => { + const service = new opendata.StockDataService(); + + const yahooProvider = new opendata.YahooFinanceProvider(); + const secProvider = new opendata.SecEdgarProvider({ + userAgent: TEST_USER_AGENT + }); + + service.registerPriceProvider(yahooProvider); + service.registerFundamentalsProvider(secProvider); + + await tap.test('should fetch batch complete data with array', async () => { + const data = await service.getBatchStockData(['AAPL', 'MSFT']); + + expect(data).toBeInstanceOf(Array); + expect(data.length).toEqual(2); + + data.forEach(stock => { + expect(stock.ticker).toBeDefined(); + expect(stock.price).toBeDefined(); + expect(stock.fundamentals).toBeDefined(); + expect(stock.fundamentals?.marketCap).toBeGreaterThan(0); + expect(stock.fundamentals?.priceToEarnings).toBeGreaterThan(0); + }); + + console.log('\n✨ Batch Complete Data:'); + data.forEach(stock => { + console.log(` ${stock.ticker}: Price $${stock.price.price.toFixed(2)}, P/E ${stock.fundamentals!.priceToEarnings!.toFixed(2)}`); + }); + }); + + await tap.test('should fetch batch complete data with request object', async () => { + const data = await service.getBatchStockData({ + tickers: ['AAPL', 'GOOGL'], + includeFundamentals: true, + enrichFundamentals: true + }); + + expect(data).toBeInstanceOf(Array); + expect(data.length).toEqual(2); + + data.forEach(stock => { + expect(stock.fundamentals?.marketCap).toBeGreaterThan(0); + }); + }); + + await tap.test('should fetch batch without enrichment', async () => { + const data = await service.getBatchStockData({ + tickers: ['AAPL', 'MSFT'], + includeFundamentals: true, + enrichFundamentals: false + }); + + expect(data).toBeInstanceOf(Array); + + // Check that fundamentals exist but enrichment might not be complete + data.forEach(stock => { + if (stock.fundamentals) { + expect(stock.fundamentals.ticker).toBeDefined(); + expect(stock.fundamentals.companyName).toBeDefined(); + } + }); + }); +}); + +tap.test('StockDataService - Health & Statistics', async () => { + const service = new opendata.StockDataService(); + + const yahooProvider = new opendata.YahooFinanceProvider(); + const secProvider = new opendata.SecEdgarProvider({ + userAgent: TEST_USER_AGENT + }); + + service.registerPriceProvider(yahooProvider); + service.registerFundamentalsProvider(secProvider); + + await tap.test('should check providers health', async () => { + const health = await service.checkProvidersHealth(); + + expect(health.size).toEqual(2); + expect(health.get('Yahoo Finance (price)')).toBe(true); + expect(health.get('SEC EDGAR (fundamentals)')).toBe(true); + + console.log('\nšŸ’š Provider Health:'); + health.forEach((isHealthy, name) => { + console.log(` ${name}: ${isHealthy ? 'āœ… Healthy' : 'āŒ Unhealthy'}`); + }); + }); + + await tap.test('should track provider statistics', async () => { + // Make some requests to generate stats + await service.getPrice('AAPL'); + await service.getFundamentals('AAPL'); + + const stats = service.getProviderStats(); + + expect(stats.size).toEqual(2); + + const yahooStats = stats.get('Yahoo Finance'); + expect(yahooStats).toBeDefined(); + expect(yahooStats!.type).toEqual('price'); + expect(yahooStats!.successCount).toBeGreaterThan(0); + + const secStats = stats.get('SEC EDGAR'); + expect(secStats).toBeDefined(); + expect(secStats!.type).toEqual('fundamentals'); + expect(secStats!.successCount).toBeGreaterThan(0); + + console.log('\nšŸ“ˆ Provider Statistics:'); + stats.forEach((stat, name) => { + console.log(` ${name} (${stat.type}): Success=${stat.successCount}, Errors=${stat.errorCount}`); + }); + }); + + await tap.test('should clear all caches', async () => { + service.clearCache(); + + const stats = service.getCacheStats(); + expect(stats.priceCache.size).toEqual(0); + expect(stats.fundamentalsCache.size).toEqual(0); + }); +}); + +tap.test('StockDataService - Error Handling', async () => { + await tap.test('should throw error when no price provider available', async () => { + const service = new opendata.StockDataService(); + + try { + await service.getPrice('AAPL'); + throw new Error('Should have thrown error'); + } catch (error) { + expect(error.message).toContain('No price providers available'); + } + }); + + await tap.test('should throw error when no fundamentals provider available', async () => { + const service = new opendata.StockDataService(); + + try { + await service.getFundamentals('AAPL'); + throw new Error('Should have thrown error'); + } catch (error) { + expect(error.message).toContain('No fundamentals providers available'); + } + }); + + await tap.test('should handle invalid ticker for price', async () => { + const service = new opendata.StockDataService(); + const yahooProvider = new opendata.YahooFinanceProvider(); + service.registerPriceProvider(yahooProvider); + + try { + await service.getPrice('INVALIDTICKER123456'); + throw new Error('Should have thrown error'); + } catch (error) { + expect(error.message).toContain('Failed to fetch price'); + } + }); + + await tap.test('should handle invalid ticker for fundamentals', async () => { + const service = new opendata.StockDataService(); + const secProvider = new opendata.SecEdgarProvider({ + userAgent: TEST_USER_AGENT + }); + service.registerFundamentalsProvider(secProvider); + + try { + await service.getFundamentals('INVALIDTICKER123456'); + throw new Error('Should have thrown error'); + } catch (error) { + expect(error.message).toContain('CIK not found'); + } + }); +}); + +export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index b4ff112..9bd850d 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@fin.cx/opendata', - version: '3.1.0', + version: '3.2.0', description: 'A comprehensive TypeScript library for accessing business data and real-time financial information. Features include German company data management with MongoDB integration, JSONL bulk processing, automated Handelsregister interactions, and real-time stock market data from multiple providers.' } diff --git a/ts/stocks/classes.baseproviderservice.ts b/ts/stocks/classes.baseproviderservice.ts new file mode 100644 index 0000000..559d6b4 --- /dev/null +++ b/ts/stocks/classes.baseproviderservice.ts @@ -0,0 +1,296 @@ +import * as plugins from '../plugins.js'; + +/** + * Base provider entry for tracking provider state + */ +export interface IBaseProviderEntry { + provider: TProvider; + config: IBaseProviderConfig; + lastError?: Error; + lastErrorTime?: Date; + successCount: number; + errorCount: number; +} + +/** + * Base provider configuration + */ +export interface IBaseProviderConfig { + enabled: boolean; + priority: number; + timeout?: number; + retryAttempts?: number; + retryDelay?: number; + cacheTTL?: number; +} + +/** + * Base provider interface + */ +export interface IBaseProvider { + name: string; + priority: number; + isAvailable(): Promise; + readonly requiresAuth: boolean; + readonly rateLimit?: { + requestsPerMinute: number; + requestsPerDay?: number; + }; +} + +/** + * Cache entry for any data type + */ +export interface IBaseCacheEntry { + data: TData; + timestamp: Date; + ttl: number; +} + +/** + * Base service for managing data providers with caching + * Shared logic extracted from StockPriceService and FundamentalsService + */ +export abstract class BaseProviderService { + protected providers = new Map>(); + protected cache = new Map>(); + protected logger = console; + + protected cacheConfig = { + ttl: 60000, // Default 60 seconds + maxEntries: 10000 + }; + + constructor(cacheConfig?: { ttl?: number; maxEntries?: number }) { + if (cacheConfig) { + this.cacheConfig = { ...this.cacheConfig, ...cacheConfig }; + } + } + + /** + * Register a provider + */ + public register(provider: TProvider, config?: Partial): void { + const defaultConfig: IBaseProviderConfig = { + enabled: true, + priority: provider.priority, + timeout: 30000, + 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 provider: ${provider.name}`); + } + + /** + * Unregister a provider + */ + public unregister(providerName: string): void { + this.providers.delete(providerName); + console.log(`Unregistered provider: ${providerName}`); + } + + /** + * Get a specific provider by name + */ + public getProvider(name: string): TProvider | undefined { + return this.providers.get(name)?.provider; + } + + /** + * Get all registered providers + */ + public getAllProviders(): TProvider[] { + return Array.from(this.providers.values()).map(entry => entry.provider); + } + + /** + * Get enabled providers sorted by priority + */ + public getEnabledProviders(): TProvider[] { + 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); + } + + /** + * 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('Cache cleared'); + } + + /** + * Set cache TTL + */ + public setCacheTTL(ttl: number): void { + this.cacheConfig.ttl = ttl; + console.log(`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 + */ + protected async fetchWithRetry( + fetchFn: () => Promise, + config: IBaseProviderConfig + ): 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 + */ + protected getFromCache(key: string): TData | 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.data; + } + + /** + * Add to cache with TTL + */ + protected addToCache(key: string, data: TData, 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, { + data, + timestamp: new Date(), + ttl: ttl || this.cacheConfig.ttl + }); + } + + /** + * Track successful fetch for provider + */ + protected trackSuccess(providerName: string): void { + const entry = this.providers.get(providerName); + if (entry) { + entry.successCount++; + } + } + + /** + * Track failed fetch for provider + */ + protected trackError(providerName: string, error: Error): void { + const entry = this.providers.get(providerName); + if (entry) { + entry.errorCount++; + entry.lastError = error; + entry.lastErrorTime = new Date(); + } + } +} diff --git a/ts/stocks/classes.stockdataservice.ts b/ts/stocks/classes.stockdataservice.ts new file mode 100644 index 0000000..c8b4cee --- /dev/null +++ b/ts/stocks/classes.stockdataservice.ts @@ -0,0 +1,647 @@ +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 + }; + } +} diff --git a/ts/stocks/index.ts b/ts/stocks/index.ts index 15abd7f..65c6f71 100644 --- a/ts/stocks/index.ts +++ b/ts/stocks/index.ts @@ -2,10 +2,15 @@ export * from './interfaces/stockprice.js'; export * from './interfaces/provider.js'; export * from './interfaces/fundamentals.js'; +export * from './interfaces/stockdata.js'; // Export main services export * from './classes.stockservice.js'; export * from './classes.fundamentalsservice.js'; +export * from './classes.stockdataservice.js'; // ✨ New unified service + +// Export base service (for advanced use cases) +export * from './classes.baseproviderservice.js'; // Export providers export * from './providers/provider.yahoo.js'; diff --git a/ts/stocks/interfaces/stockdata.ts b/ts/stocks/interfaces/stockdata.ts new file mode 100644 index 0000000..2512749 --- /dev/null +++ b/ts/stocks/interfaces/stockdata.ts @@ -0,0 +1,65 @@ +import type { IStockPrice } from './stockprice.js'; +import type { IStockFundamentals } from './fundamentals.js'; + +/** + * Combined stock data with price and fundamentals + * All calculated metrics (market cap, P/E, P/B) are automatically included + */ +export interface IStockData { + /** Stock ticker symbol */ + ticker: string; + + /** Price information */ + price: IStockPrice; + + /** Fundamental data (optional - may not be available for all stocks) */ + fundamentals?: IStockFundamentals; + + /** When this combined data was fetched */ + fetchedAt: Date; +} + +/** + * Configuration for StockDataService + */ +export interface IStockDataServiceConfig { + /** Cache configuration */ + cache?: { + /** TTL for price data (default: 24 hours) */ + priceTTL?: number; + /** TTL for fundamentals data (default: 90 days) */ + fundamentalsTTL?: number; + /** Max cache entries (default: 10000) */ + maxEntries?: number; + }; + + /** Provider timeouts */ + timeout?: { + /** Timeout for price providers (default: 10000ms) */ + price?: number; + /** Timeout for fundamentals providers (default: 30000ms) */ + fundamentals?: number; + }; +} + +/** + * Request type for getting complete stock data + */ +export interface ICompleteStockDataRequest { + ticker: string; + /** Whether to include fundamentals (default: true) */ + includeFundamentals?: boolean; + /** Whether to enrich fundamentals with price calculations (default: true) */ + enrichFundamentals?: boolean; +} + +/** + * Batch request for multiple stocks + */ +export interface ICompleteStockDataBatchRequest { + tickers: string[]; + /** Whether to include fundamentals (default: true) */ + includeFundamentals?: boolean; + /** Whether to enrich fundamentals with price calculations (default: true) */ + enrichFundamentals?: boolean; +}