419 lines
14 KiB
TypeScript
419 lines
14 KiB
TypeScript
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();
|