feat(StockDataService): Add unified StockDataService and BaseProviderService with new stockdata interfaces, provider integrations, tests and README updates
This commit is contained in:
418
test/test.stockdata.service.node.ts
Normal file
418
test/test.stockdata.service.node.ts
Normal file
@@ -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();
|
||||
Reference in New Issue
Block a user