10 Commits

Author SHA1 Message Date
909b30117b 3.3.0
Some checks failed
Default (tags) / security (push) Failing after 23s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-02 16:34:24 +00:00
47fd770e48 feat(stocks/CoinGeckoProvider): Add CoinGecko provider for cryptocurrency prices, export and tests, and update documentation 2025-11-02 16:34:24 +00:00
fdea1bb149 3.2.2
Some checks failed
Default (tags) / security (push) Failing after 21s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-01 15:20:20 +00:00
8286c30edf fix(handelsregister): Correct screenshot path handling in HandelsRegister and add local tool permissions 2025-11-01 15:20:20 +00:00
ea76776ee1 3.2.1
Some checks failed
Default (tags) / security (push) Failing after 21s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-01 14:54:04 +00:00
54818293a1 fix(stocks/providers/provider.secedgar): Improve SEC EDGAR provider networking and error handling, update plugin path import, bump dev deps and add/refresh tests and lockfile 2025-11-01 14:54:04 +00:00
d49a738880 3.2.0
Some checks failed
Default (tags) / security (push) Failing after 21s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-01 12:35:53 +00:00
6273faa2f9 feat(StockDataService): Add unified StockDataService and BaseProviderService with new stockdata interfaces, provider integrations, tests and README updates 2025-11-01 12:35:53 +00:00
d33c7e0f52 3.1.0
Some checks failed
Default (tags) / security (push) Failing after 23s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-01 11:59:23 +00:00
79930c40ac feat(fundamentals): Add FundamentalsService and SEC EDGAR provider with caching, rate-limiting, tests, and docs updates 2025-11-01 11:59:23 +00:00
22 changed files with 12374 additions and 1021 deletions

View File

@@ -1,5 +1,53 @@
# Changelog
## 2025-11-02 - 3.3.0 - feat(stocks/CoinGeckoProvider)
Add CoinGecko provider for cryptocurrency prices, export and tests, and update documentation
- Implemented CoinGeckoProvider with rate limiting, coin-id resolution, and support for current, batch, historical and intraday price endpoints
- Added unit/integration tests for CoinGecko: test/test.coingecko.node+bun+deno.ts
- Exported CoinGeckoProvider from ts/stocks/index.ts
- Updated README and readme.hints.md with CoinGecko usage, provider notes and examples
- Added .claude/settings.local.json with webfetch and bash permissions required for testing and CI
## 2025-11-01 - 3.2.2 - fix(handelsregister)
Correct screenshot path handling in HandelsRegister and add local tool permissions
- ts/classes.handelsregister.ts: Replace string concatenation for screenshot path with a template literal and explicit string assertion to ensure the path is formed correctly for page.screenshot() and avoid type issues.
- Add .claude/settings.local.json: Introduce local Claude settings that grant specific tool permissions used during development and testing (bash commands, web fetches, pnpm build, tstest, etc.).
## 2025-11-01 - 3.2.1 - fix(stocks/providers/provider.secedgar)
Improve SEC EDGAR provider networking and error handling, update plugin path import, bump dev deps and add/refresh tests and lockfile
- SEC EDGAR provider: switch from SmartRequest to native fetch for ticker list and company facts, add AbortController-based timeouts, handle gzip automatically, improve response validation and error messages, and keep CIK/ticker-list caching
- Improve timeout and rate-limit handling in SecEdgarProvider (uses native fetch + explicit timeout clear), plus clearer logging on failures
- Update ts/plugins import to use node:path for Node compatibility
- Bump devDependencies: @git.zone/tsrun to ^1.6.2 and @git.zone/tstest to ^2.7.0; bump @push.rocks/smartrequest to ^4.3.4
- Add and refresh comprehensive test files (node/bun/deno variants) for fundamentals, marketstack, secedgar and stockdata services
- Add deno.lock (dependency lock) and a local .claude/settings.local.json for CI/permissions
## 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
- Introduce FundamentalsService to manage fundamentals providers, caching, retry logic and provider statistics
- Add SecEdgarProvider to fetch SEC EDGAR company facts (CIK lookup, company facts parsing) with rate limiting and local caches
- Expose fundamentals interfaces and services from ts/stocks (exports updated)
- Add comprehensive tests for FundamentalsService and SecEdgarProvider (new test files)
- Update README with new Fundamentals module documentation, usage examples, and configuration guidance
- Implement caching and TTL handling for fundamentals data and provider-specific cache TTL support
- Add .claude/settings.local.json (local permissions) and various test improvements
## 2025-10-31 - 3.0.0 - BREAKING CHANGE(stocks)
Unify stock provider API to discriminated IStockDataRequest and add company name/fullname enrichment

7209
deno.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@fin.cx/opendata",
"version": "3.0.0",
"version": "3.3.0",
"private": false,
"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.",
"main": "dist_ts/index.js",
@@ -16,8 +16,8 @@
"devDependencies": {
"@git.zone/tsbuild": "^2.6.8",
"@git.zone/tsbundle": "^2.5.1",
"@git.zone/tsrun": "^1.3.3",
"@git.zone/tstest": "^2.4.2",
"@git.zone/tsrun": "^1.6.2",
"@git.zone/tstest": "^2.7.0",
"@types/node": "^22.14.0"
},
"dependencies": {
@@ -32,7 +32,7 @@
"@push.rocks/smartlog": "^3.1.10",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartrequest": "^4.3.1",
"@push.rocks/smartrequest": "^4.3.4",
"@push.rocks/smartstream": "^3.2.5",
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartxml": "^1.1.1",

1054
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
## Stocks Module
### Overview
The stocks module provides real-time stock price data through various provider implementations. Currently supports Yahoo Finance with an extensible architecture for additional providers.
The stocks module provides real-time stock price data and cryptocurrency prices through various provider implementations. Currently supports Yahoo Finance, Marketstack, and CoinGecko with an extensible architecture for additional providers.
### Architecture
- **Provider Pattern**: Each stock data source implements the `IStockProvider` interface
@@ -31,11 +31,55 @@ const price = await stockService.getPrice({ ticker: 'AAPL' });
console.log(`${price.ticker}: $${price.price}`);
```
### CoinGecko Provider Notes
- Cryptocurrency price provider supporting 13M+ tokens
- Three main endpoints:
- `/simple/price` - Current prices with market data (batch supported)
- `/coins/{id}/market_chart` - Historical and intraday prices with OHLCV
- `/coins/list` - Complete coin list for ticker-to-ID mapping
- **Rate Limiting**:
- Free tier: 5-15 calls/minute (no registration)
- Demo plan: 30 calls/minute, 10,000/month (free with registration)
- Custom rate limiter tracks requests per minute
- **Ticker Resolution**:
- Accepts both ticker symbols (BTC, ETH) and CoinGecko IDs (bitcoin, ethereum)
- Lazy-loads coin list on first ticker resolution
- Caches coin mappings for 24 hours
- IDs with hyphens (wrapped-bitcoin) assumed to be CoinGecko IDs
- **24/7 Markets**: Crypto markets always return `marketState: 'REGULAR'`
- **Optional API Key**: Pass key to constructor for higher rate limits
- Demo plan: `x-cg-demo-api-key` header
- Paid plans: `x-cg-pro-api-key` header
- **Data Granularity**:
- Historical: Daily data for date ranges
- Intraday: Hourly data only (1-90 days based on `days` param)
- Current: Real-time prices with 24h change and volume
### Usage Example (Crypto)
```typescript
import { StockPriceService, CoinGeckoProvider } from '@fin.cx/opendata';
const stockService = new StockPriceService({ ttl: 30000 });
const coingeckoProvider = new CoinGeckoProvider(); // or new CoinGeckoProvider('api-key')
stockService.register(coingeckoProvider);
// Using ticker symbol
const btc = await stockService.getPrice({ ticker: 'BTC' });
console.log(`${btc.ticker}: $${btc.price}`);
// Using CoinGecko ID
const ethereum = await stockService.getPrice({ ticker: 'ethereum' });
// Batch fetch
const cryptos = await stockService.getPrices({ tickers: ['BTC', 'ETH', 'USDT'] });
```
### Testing
- Tests use real API calls (be mindful of rate limits)
- Mock invalid ticker 'INVALID_TICKER_XYZ' for error testing
- Clear cache between tests to ensure fresh data
- The spark endpoint may return fewer results than requested
- CoinGecko tests may take longer due to rate limiting (wait between requests)
### Future Providers
To add a new provider:

1044
readme.md

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,261 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as opendata from '../ts/index.js';
// Test data
const testCryptos = ['BTC', 'ETH', 'USDT'];
const testCryptoIds = ['bitcoin', 'ethereum', 'tether'];
const invalidCrypto = 'INVALID_CRYPTO_XYZ_12345';
let stockService: opendata.StockPriceService;
let coingeckoProvider: opendata.CoinGeckoProvider;
tap.test('should create StockPriceService instance', async () => {
stockService = new opendata.StockPriceService({
ttl: 30000, // 30 seconds cache
maxEntries: 100
});
expect(stockService).toBeInstanceOf(opendata.StockPriceService);
});
tap.test('should create CoinGeckoProvider instance without API key', async () => {
coingeckoProvider = new opendata.CoinGeckoProvider();
expect(coingeckoProvider).toBeInstanceOf(opendata.CoinGeckoProvider);
expect(coingeckoProvider.name).toEqual('CoinGecko');
expect(coingeckoProvider.requiresAuth).toEqual(false);
expect(coingeckoProvider.priority).toEqual(90);
});
tap.test('should register CoinGecko provider with the service', async () => {
stockService.register(coingeckoProvider);
const providers = stockService.getAllProviders();
expect(providers).toContainEqual(coingeckoProvider);
expect(stockService.getProvider('CoinGecko')).toEqual(coingeckoProvider);
});
tap.test('should check CoinGecko provider health', async () => {
const health = await stockService.checkProvidersHealth();
expect(health.get('CoinGecko')).toEqual(true);
});
tap.test('should fetch single crypto price using ticker symbol (BTC)', async () => {
const price = await stockService.getPrice({ ticker: 'BTC' });
expect(price).toHaveProperty('ticker');
expect(price).toHaveProperty('price');
expect(price).toHaveProperty('currency');
expect(price).toHaveProperty('change');
expect(price).toHaveProperty('changePercent');
expect(price).toHaveProperty('previousClose');
expect(price).toHaveProperty('timestamp');
expect(price).toHaveProperty('provider');
expect(price).toHaveProperty('marketState');
expect(price.ticker).toEqual('BTC');
expect(price.price).toBeGreaterThan(0);
expect(price.currency).toEqual('USD');
expect(price.provider).toEqual('CoinGecko');
expect(price.marketState).toEqual('REGULAR'); // Crypto is 24/7
expect(price.timestamp).toBeInstanceOf(Date);
expect(price.dataType).toEqual('live');
console.log(`\n📊 BTC Price: $${price.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`);
console.log(` Change: ${price.changePercent >= 0 ? '+' : ''}${price.changePercent.toFixed(2)}%`);
});
tap.test('should fetch single crypto price using CoinGecko ID (bitcoin)', async () => {
// Clear cache to ensure fresh fetch
stockService.clearCache();
const price = await stockService.getPrice({ ticker: 'bitcoin' });
expect(price.ticker).toEqual('BITCOIN');
expect(price.price).toBeGreaterThan(0);
expect(price.provider).toEqual('CoinGecko');
expect(price.companyName).toInclude('Bitcoin');
});
tap.test('should fetch multiple crypto prices (batch)', async () => {
stockService.clearCache();
const prices = await stockService.getPrices({
tickers: testCryptos
});
expect(prices).toBeArray();
expect(prices.length).toEqual(testCryptos.length);
for (const price of prices) {
expect(testCryptos).toContain(price.ticker);
expect(price.price).toBeGreaterThan(0);
expect(price.provider).toEqual('CoinGecko');
expect(price.marketState).toEqual('REGULAR');
console.log(`\n💰 ${price.ticker}: $${price.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 8 })}`);
console.log(` Change 24h: ${price.changePercent >= 0 ? '+' : ''}${price.changePercent.toFixed(2)}%`);
if (price.volume) {
console.log(` Volume 24h: $${price.volume.toLocaleString('en-US')}`);
}
}
});
tap.test('should fetch historical crypto prices', async () => {
// Add delay to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 3000));
const to = new Date();
const from = new Date(to.getTime() - 7 * 24 * 60 * 60 * 1000); // 7 days ago
const prices = await stockService.getData({
type: 'historical',
ticker: 'BTC',
from: from,
to: to
});
expect(prices).toBeArray();
expect((prices as opendata.IStockPrice[]).length).toBeGreaterThan(0);
const pricesArray = prices as opendata.IStockPrice[];
console.log(`\n📈 Historical BTC Prices (${pricesArray.length} days):`);
// Show first few and last few
const toShow = Math.min(3, pricesArray.length);
for (let i = 0; i < toShow; i++) {
const price = pricesArray[i];
const date = price.timestamp.toISOString().split('T')[0];
console.log(` ${date}: $${price.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`);
}
if (pricesArray.length > toShow * 2) {
console.log(' ...');
}
for (let i = Math.max(toShow, pricesArray.length - toShow); i < pricesArray.length; i++) {
const price = pricesArray[i];
const date = price.timestamp.toISOString().split('T')[0];
console.log(` ${date}: $${price.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`);
}
// Validate first entry
const firstPrice = pricesArray[0];
expect(firstPrice.ticker).toEqual('BTC');
expect(firstPrice.dataType).toEqual('eod');
expect(firstPrice.provider).toEqual('CoinGecko');
});
tap.test('should fetch intraday crypto prices (hourly)', async () => {
// Add delay to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 3000));
const prices = await stockService.getData({
type: 'intraday',
ticker: 'ETH',
interval: '1hour',
limit: 12 // Last 12 hours
});
expect(prices).toBeArray();
expect((prices as opendata.IStockPrice[]).length).toBeGreaterThan(0);
const pricesArray = prices as opendata.IStockPrice[];
console.log(`\n⏰ Intraday ETH Prices (hourly, last ${pricesArray.length} hours):`);
// Show first few entries
const toShow = Math.min(5, pricesArray.length);
for (let i = 0; i < toShow; i++) {
const price = pricesArray[i];
const time = price.timestamp.toISOString().replace('T', ' ').substring(0, 16);
console.log(` ${time}: $${price.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`);
}
// Validate first entry
const firstPrice = pricesArray[0];
expect(firstPrice.ticker).toEqual('ETH');
expect(firstPrice.dataType).toEqual('intraday');
expect(firstPrice.provider).toEqual('CoinGecko');
});
tap.test('should serve cached prices on subsequent requests', async () => {
// First request - should hit the API
const firstRequest = await stockService.getPrice({ ticker: 'BTC' });
// Second request - should be served from cache
const secondRequest = await stockService.getPrice({ ticker: 'BTC' });
expect(secondRequest.ticker).toEqual(firstRequest.ticker);
expect(secondRequest.price).toEqual(firstRequest.price);
expect(secondRequest.timestamp).toEqual(firstRequest.timestamp);
expect(secondRequest.fetchedAt).toEqual(firstRequest.fetchedAt);
});
tap.test('should handle invalid crypto ticker gracefully', async () => {
try {
await stockService.getPrice({ ticker: invalidCrypto });
throw new Error('Should have thrown an error for invalid ticker');
} catch (error) {
expect(error.message).toInclude('Failed to fetch');
}
});
tap.test('should support market checking', async () => {
expect(coingeckoProvider.supportsMarket('CRYPTO')).toEqual(true);
expect(coingeckoProvider.supportsMarket('BTC')).toEqual(true);
expect(coingeckoProvider.supportsMarket('ETH')).toEqual(true);
expect(coingeckoProvider.supportsMarket('NASDAQ')).toEqual(false);
});
tap.test('should support ticker validation', async () => {
expect(coingeckoProvider.supportsTicker('BTC')).toEqual(true);
expect(coingeckoProvider.supportsTicker('bitcoin')).toEqual(true);
expect(coingeckoProvider.supportsTicker('wrapped-bitcoin')).toEqual(true);
expect(coingeckoProvider.supportsTicker('BTC!')).toEqual(false);
expect(coingeckoProvider.supportsTicker('BTC@USD')).toEqual(false);
});
tap.test('should display provider statistics', async () => {
const stats = stockService.getProviderStats();
const coingeckoStats = stats.get('CoinGecko');
expect(coingeckoStats).toBeTruthy();
expect(coingeckoStats.successCount).toBeGreaterThan(0);
console.log('\n📊 CoinGecko Provider Statistics:');
console.log(` Success Count: ${coingeckoStats.successCount}`);
console.log(` Error Count: ${coingeckoStats.errorCount}`);
if (coingeckoStats.lastError) {
console.log(` Last Error: ${coingeckoStats.lastError}`);
}
});
tap.test('should display crypto price dashboard', async () => {
// Add delay to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 3000));
stockService.clearCache();
const cryptos = ['BTC', 'ETH', 'BNB', 'SOL', 'ADA'];
const prices = await stockService.getPrices({ tickers: cryptos });
console.log('\n╔═══════════════════════════════════════════════════════════╗');
console.log('║ 🌐 CRYPTOCURRENCY PRICE DASHBOARD ║');
console.log('╠═══════════════════════════════════════════════════════════╣');
for (const price of prices) {
const priceStr = `$${price.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 8 })}`;
const changeStr = `${price.changePercent >= 0 ? '+' : ''}${price.changePercent.toFixed(2)}%`;
const changeIcon = price.changePercent >= 0 ? '📈' : '📉';
console.log(`${price.ticker.padEnd(6)} ${changeIcon} ${priceStr.padStart(20)}${changeStr.padStart(10)}`);
}
console.log('╚═══════════════════════════════════════════════════════════╝');
console.log(`Provider: ${prices[0].provider} | Market State: ${prices[0].marketState} (24/7)`);
console.log(`Fetched at: ${prices[0].fetchedAt.toISOString()}`);
});
tap.test('should clear cache', async () => {
stockService.clearCache();
// Cache is cleared, no assertions needed
});
export default tap.start();

View File

@@ -0,0 +1,287 @@
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('FundamentalsService - Provider Registration', async () => {
const service = new opendata.FundamentalsService();
const provider = new opendata.SecEdgarProvider({
userAgent: TEST_USER_AGENT
});
await tap.test('should register provider', async () => {
service.register(provider);
const registered = service.getProvider('SEC EDGAR');
expect(registered).toBeDefined();
expect(registered?.name).toEqual('SEC EDGAR');
});
await tap.test('should get all providers', async () => {
const providers = service.getAllProviders();
expect(providers.length).toBeGreaterThan(0);
expect(providers[0].name).toEqual('SEC EDGAR');
});
await tap.test('should get enabled providers', async () => {
const providers = service.getEnabledProviders();
expect(providers.length).toBeGreaterThan(0);
});
await tap.test('should unregister provider', async () => {
service.unregister('SEC EDGAR');
const registered = service.getProvider('SEC EDGAR');
expect(registered).toBeUndefined();
// Re-register for other tests
service.register(provider);
});
});
tap.test('FundamentalsService - Fetch Fundamentals', async () => {
const service = new opendata.FundamentalsService();
const provider = new opendata.SecEdgarProvider({
userAgent: TEST_USER_AGENT
});
service.register(provider);
await tap.test('should fetch fundamentals for single ticker', 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📊 Fetched via Service:');
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 fundamentals for multiple tickers', async () => {
const fundamentalsList = await service.getBatchFundamentals(['AAPL', 'MSFT']);
expect(fundamentalsList).toBeInstanceOf(Array);
expect(fundamentalsList.length).toEqual(2);
const apple = fundamentalsList.find(f => f.ticker === 'AAPL');
const msft = fundamentalsList.find(f => f.ticker === 'MSFT');
expect(apple).toBeDefined();
expect(msft).toBeDefined();
expect(apple!.companyName).toEqual('Apple Inc.');
expect(msft!.companyName).toContain('Microsoft');
console.log('\n📊 Batch Fetch via Service:');
fundamentalsList.forEach(f => {
console.log(` ${f.ticker}: ${f.companyName} - EPS: $${f.earningsPerShareDiluted?.toFixed(2)}`);
});
});
});
tap.test('FundamentalsService - Caching', async () => {
const service = new opendata.FundamentalsService({
ttl: 60000, // 60 seconds for testing
maxEntries: 100
});
const provider = new opendata.SecEdgarProvider({
userAgent: TEST_USER_AGENT
});
service.register(provider);
await tap.test('should cache fundamentals data', async () => {
// Clear cache first
service.clearCache();
let stats = service.getCacheStats();
expect(stats.size).toEqual(0);
// First fetch (should hit API)
const start1 = Date.now();
await service.getFundamentals('AAPL');
const duration1 = Date.now() - start1;
stats = service.getCacheStats();
expect(stats.size).toEqual(1);
// Second fetch (should hit cache - much faster)
const start2 = Date.now();
await service.getFundamentals('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`);
});
await tap.test('should respect cache TTL', async () => {
// Set very short TTL
service.setCacheTTL(100); // 100ms
// Fetch and cache
await service.getFundamentals('MSFT');
// Wait for TTL to expire
await new Promise(resolve => setTimeout(resolve, 150));
// This should fetch again (cache expired)
const stats = service.getCacheStats();
console.log(`\n Cache TTL: ${stats.ttl}ms`);
});
await tap.test('should clear cache', async () => {
service.clearCache();
const stats = service.getCacheStats();
expect(stats.size).toEqual(0);
});
});
tap.test('FundamentalsService - Price Enrichment', async () => {
const service = new opendata.FundamentalsService();
const provider = new opendata.SecEdgarProvider({
userAgent: TEST_USER_AGENT
});
service.register(provider);
await tap.test('should enrich fundamentals with price to calculate market cap', async () => {
const fundamentals = await service.getFundamentals('AAPL');
// Simulate current price
const currentPrice = 270.37;
const enriched = await service.enrichWithPrice(fundamentals, currentPrice);
expect(enriched.marketCap).toBeDefined();
expect(enriched.priceToEarnings).toBeDefined();
expect(enriched.priceToBook).toBeDefined();
expect(enriched.marketCap).toBeGreaterThan(0);
expect(enriched.priceToEarnings).toBeGreaterThan(0);
console.log('\n💰 Enriched with Price ($270.37):');
console.log(` Market Cap: $${(enriched.marketCap! / 1_000_000_000_000).toFixed(2)}T`);
console.log(` P/E Ratio: ${enriched.priceToEarnings!.toFixed(2)}`);
console.log(` P/B Ratio: ${enriched.priceToBook?.toFixed(2) || 'N/A'}`);
// Verify calculations
const expectedMarketCap = fundamentals.sharesOutstanding! * currentPrice;
expect(Math.abs(enriched.marketCap! - expectedMarketCap)).toBeLessThan(1); // Allow for rounding
const expectedPE = currentPrice / fundamentals.earningsPerShareDiluted!;
expect(Math.abs(enriched.priceToEarnings! - expectedPE)).toBeLessThan(0.01);
});
await tap.test('should enrich batch fundamentals with prices', async () => {
const fundamentalsList = await service.getBatchFundamentals(['AAPL', 'MSFT']);
const priceMap = new Map<string, number>([
['AAPL', 270.37],
['MSFT', 425.50]
]);
const enriched = await service.enrichBatchWithPrices(fundamentalsList, priceMap);
expect(enriched.length).toEqual(2);
const apple = enriched.find(f => f.ticker === 'AAPL')!;
const msft = enriched.find(f => f.ticker === 'MSFT')!;
expect(apple.marketCap).toBeGreaterThan(0);
expect(msft.marketCap).toBeGreaterThan(0);
console.log('\n💰 Batch Enrichment:');
console.log(` AAPL: Market Cap $${(apple.marketCap! / 1_000_000_000_000).toFixed(2)}T, P/E ${apple.priceToEarnings!.toFixed(2)}`);
console.log(` MSFT: Market Cap $${(msft.marketCap! / 1_000_000_000_000).toFixed(2)}T, P/E ${msft.priceToEarnings!.toFixed(2)}`);
});
});
tap.test('FundamentalsService - Provider Health', async () => {
const service = new opendata.FundamentalsService();
const provider = new opendata.SecEdgarProvider({
userAgent: TEST_USER_AGENT
});
service.register(provider);
await tap.test('should check provider health', async () => {
const health = await service.checkProvidersHealth();
expect(health.size).toEqual(1);
expect(health.get('SEC EDGAR')).toBe(true);
console.log('\n💚 Provider Health:');
health.forEach((isHealthy, name) => {
console.log(` ${name}: ${isHealthy ? '✅ Healthy' : '❌ Unhealthy'}`);
});
});
});
tap.test('FundamentalsService - Provider Statistics', async () => {
const service = new opendata.FundamentalsService();
const provider = new opendata.SecEdgarProvider({
userAgent: TEST_USER_AGENT
});
service.register(provider);
await tap.test('should track provider statistics', async () => {
// Make some requests
await service.getFundamentals('AAPL');
await service.getFundamentals('MSFT');
const stats = service.getProviderStats();
expect(stats.size).toEqual(1);
const secStats = stats.get('SEC EDGAR');
expect(secStats).toBeDefined();
expect(secStats!.successCount).toBeGreaterThan(0);
console.log('\n📈 Provider Stats:');
console.log(` Success Count: ${secStats!.successCount}`);
console.log(` Error Count: ${secStats!.errorCount}`);
});
});
tap.test('FundamentalsService - Error Handling', async () => {
const service = new opendata.FundamentalsService();
const provider = new opendata.SecEdgarProvider({
userAgent: TEST_USER_AGENT
});
service.register(provider);
await tap.test('should throw error for invalid ticker', async () => {
try {
await service.getFundamentals('INVALIDTICKER123456');
throw new Error('Should have thrown error');
} catch (error) {
expect(error.message).toContain('CIK not found');
}
});
await tap.test('should throw error when no providers available', async () => {
const emptyService = new opendata.FundamentalsService();
try {
await emptyService.getFundamentals('AAPL');
throw new Error('Should have thrown error');
} catch (error) {
expect(error.message).toContain('No fundamentals providers available');
}
});
});
export default tap.start();

View File

@@ -0,0 +1,261 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as opendata from '../ts/index.js';
// Test configuration
const TEST_USER_AGENT = 'fin.cx test@fin.cx';
const TEST_TICKER = 'AAPL'; // Apple Inc - well-known test case
const RATE_LIMIT_DELAY = 150; // 150ms between requests (< 10 req/sec)
tap.test('SEC EDGAR Provider - Constructor', async () => {
await tap.test('should create provider with valid User-Agent', async () => {
const provider = new opendata.SecEdgarProvider({
userAgent: TEST_USER_AGENT
});
expect(provider.name).toEqual('SEC EDGAR');
expect(provider.priority).toEqual(100);
expect(provider.requiresAuth).toBe(false);
expect(provider.rateLimit?.requestsPerMinute).toEqual(600);
});
await tap.test('should throw error if User-Agent is missing', async () => {
expect(() => {
new opendata.SecEdgarProvider({
userAgent: ''
});
}).toThrow('User-Agent is required');
});
await tap.test('should throw error if User-Agent format is invalid', async () => {
expect(() => {
new opendata.SecEdgarProvider({
userAgent: 'InvalidFormat'
});
}).toThrow('Invalid User-Agent format');
});
await tap.test('should accept valid User-Agent with space and email', async () => {
const provider = new opendata.SecEdgarProvider({
userAgent: 'MyCompany contact@example.com'
});
expect(provider).toBeInstanceOf(opendata.SecEdgarProvider);
});
});
tap.test('SEC EDGAR Provider - Availability', async () => {
const provider = new opendata.SecEdgarProvider({
userAgent: TEST_USER_AGENT
});
await tap.test('should report as available', async () => {
const isAvailable = await provider.isAvailable();
expect(isAvailable).toBe(true);
});
});
tap.test('SEC EDGAR Provider - Fetch Fundamentals', async () => {
const provider = new opendata.SecEdgarProvider({
userAgent: TEST_USER_AGENT,
timeout: 30000
});
await tap.test('should fetch fundamentals for Apple (AAPL)', async () => {
const fundamentals = await provider.fetchData({
type: 'fundamentals-current',
ticker: TEST_TICKER
});
// Verify structure
expect(fundamentals).toBeDefined();
expect(fundamentals).not.toBeInstanceOf(Array);
const data = fundamentals as opendata.IStockFundamentals;
// Basic fields
expect(data.ticker).toEqual('AAPL');
expect(data.cik).toBeDefined();
expect(data.companyName).toEqual('Apple Inc.');
expect(data.provider).toEqual('SEC EDGAR');
expect(data.timestamp).toBeInstanceOf(Date);
expect(data.fetchedAt).toBeInstanceOf(Date);
// Financial metrics (Apple should have all of these)
expect(data.earningsPerShareDiluted).toBeDefined();
expect(data.earningsPerShareDiluted).toBeGreaterThan(0);
expect(data.sharesOutstanding).toBeDefined();
expect(data.sharesOutstanding).toBeGreaterThan(0);
expect(data.revenue).toBeDefined();
expect(data.revenue).toBeGreaterThan(0);
expect(data.netIncome).toBeDefined();
expect(data.assets).toBeDefined();
expect(data.assets).toBeGreaterThan(0);
expect(data.liabilities).toBeDefined();
expect(data.stockholdersEquity).toBeDefined();
// Metadata
expect(data.fiscalYear).toBeDefined();
console.log('\n📊 Sample Apple Fundamentals:');
console.log(` Company: ${data.companyName} (CIK: ${data.cik})`);
console.log(` EPS (Diluted): $${data.earningsPerShareDiluted?.toFixed(2)}`);
console.log(` Shares Outstanding: ${(data.sharesOutstanding! / 1_000_000_000).toFixed(2)}B`);
console.log(` Revenue: $${(data.revenue! / 1_000_000_000).toFixed(2)}B`);
console.log(` Net Income: $${(data.netIncome! / 1_000_000_000).toFixed(2)}B`);
console.log(` Assets: $${(data.assets! / 1_000_000_000).toFixed(2)}B`);
console.log(` Fiscal Year: ${data.fiscalYear}`);
});
await tap.test('should throw error for invalid ticker', async () => {
try {
await provider.fetchData({
type: 'fundamentals-current',
ticker: 'INVALIDTICKER123456'
});
throw new Error('Should have thrown error');
} catch (error) {
expect(error.message).toContain('CIK not found');
}
});
});
tap.test('SEC EDGAR Provider - Batch Fetch', async () => {
const provider = new opendata.SecEdgarProvider({
userAgent: TEST_USER_AGENT,
timeout: 30000
});
await tap.test('should fetch fundamentals for multiple tickers', async () => {
const result = await provider.fetchData({
type: 'fundamentals-batch',
tickers: ['AAPL', 'MSFT']
});
expect(result).toBeInstanceOf(Array);
const fundamentalsList = result as opendata.IStockFundamentals[];
expect(fundamentalsList.length).toEqual(2);
// Check Apple
const apple = fundamentalsList.find(f => f.ticker === 'AAPL');
expect(apple).toBeDefined();
expect(apple!.companyName).toEqual('Apple Inc.');
expect(apple!.earningsPerShareDiluted).toBeGreaterThan(0);
// Check Microsoft
const microsoft = fundamentalsList.find(f => f.ticker === 'MSFT');
expect(microsoft).toBeDefined();
expect(microsoft!.companyName).toContain('Microsoft');
expect(microsoft!.earningsPerShareDiluted).toBeGreaterThan(0);
console.log('\n📊 Batch Fetch Results:');
fundamentalsList.forEach(f => {
console.log(` ${f.ticker}: ${f.companyName} - EPS: $${f.earningsPerShareDiluted?.toFixed(2)}`);
});
});
});
tap.test('SEC EDGAR Provider - CIK Caching', async () => {
const provider = new opendata.SecEdgarProvider({
userAgent: TEST_USER_AGENT
});
await tap.test('should cache CIK lookups', async () => {
// Clear cache first
provider.clearCache();
let stats = provider.getCacheStats();
expect(stats.cikCacheSize).toEqual(0);
// First fetch (should populate cache)
await provider.fetchData({
type: 'fundamentals-current',
ticker: 'AAPL'
});
stats = provider.getCacheStats();
expect(stats.cikCacheSize).toBeGreaterThan(0);
console.log(`\n💾 CIK Cache: ${stats.cikCacheSize} entries`);
});
await tap.test('should clear cache', async () => {
provider.clearCache();
const stats = provider.getCacheStats();
expect(stats.cikCacheSize).toEqual(0);
expect(stats.hasTickerList).toBe(false);
});
});
tap.test('SEC EDGAR Provider - Rate Limiting', async () => {
const provider = new opendata.SecEdgarProvider({
userAgent: TEST_USER_AGENT
});
await tap.test('should handle multiple rapid requests without exceeding rate limit', async () => {
// Make 5 requests in succession
// Rate limiter should ensure we don't exceed 10 req/sec
const tickers = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'META'];
const startTime = Date.now();
const promises = tickers.map(ticker =>
provider.fetchData({
type: 'fundamentals-current',
ticker
})
);
const results = await Promise.all(promises);
const duration = Date.now() - startTime;
expect(results.length).toEqual(5);
console.log(`\n⏱ 5 requests completed in ${duration}ms (avg: ${Math.round(duration / 5)}ms/request)`);
// Verify all results are valid
results.forEach((result, index) => {
expect(result).toBeDefined();
const data = result as opendata.IStockFundamentals;
expect(data.ticker).toEqual(tickers[index]);
expect(data.earningsPerShareDiluted).toBeGreaterThan(0);
});
});
});
tap.test('SEC EDGAR Provider - Market Cap Calculation', async () => {
const provider = new opendata.SecEdgarProvider({
userAgent: TEST_USER_AGENT
});
await tap.test('should provide data needed for market cap calculation', async () => {
const fundamentals = await provider.fetchData({
type: 'fundamentals-current',
ticker: 'AAPL'
}) as opendata.IStockFundamentals;
expect(fundamentals.sharesOutstanding).toBeDefined();
expect(fundamentals.earningsPerShareDiluted).toBeDefined();
// Simulate current price (in real usage, this comes from price provider)
const simulatedPrice = 270.37;
// Calculate market cap
const marketCap = fundamentals.sharesOutstanding! * simulatedPrice;
const pe = simulatedPrice / fundamentals.earningsPerShareDiluted!;
console.log('\n💰 Calculated Metrics (with simulated price $270.37):');
console.log(` Shares Outstanding: ${(fundamentals.sharesOutstanding! / 1_000_000_000).toFixed(2)}B`);
console.log(` Market Cap: $${(marketCap / 1_000_000_000_000).toFixed(2)}T`);
console.log(` EPS: $${fundamentals.earningsPerShareDiluted!.toFixed(2)}`);
console.log(` P/E Ratio: ${pe.toFixed(2)}`);
expect(marketCap).toBeGreaterThan(0);
expect(pe).toBeGreaterThan(0);
});
});
export default tap.start();

View 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();

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@fin.cx/opendata',
version: '3.0.0',
version: '3.3.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.'
}

View File

@@ -78,7 +78,7 @@ export class HandelsRegister {
timeout: 30000,
})
.catch(async (err) => {
await pageArg.screenshot({ path: this.downloadDir + '/error.png' });
await pageArg.screenshot({ path: `${this.downloadDir}/error.png` as `${string}.png` });
throw err;
});

View File

@@ -1,5 +1,5 @@
// node native scope
import * as path from 'path';
import * as path from 'node:path';
export {
path,

View File

@@ -0,0 +1,296 @@
import * as plugins from '../plugins.js';
/**
* Base provider entry for tracking provider state
*/
export interface IBaseProviderEntry<TProvider> {
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<boolean>;
readonly requiresAuth: boolean;
readonly rateLimit?: {
requestsPerMinute: number;
requestsPerDay?: number;
};
}
/**
* Cache entry for any data type
*/
export interface IBaseCacheEntry<TData> {
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<TProvider extends IBaseProvider, TData> {
protected providers = new Map<string, IBaseProviderEntry<TProvider>>();
protected cache = new Map<string, IBaseCacheEntry<TData>>();
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<IBaseProviderConfig>): 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<Map<string, boolean>> {
const health = new Map<string, boolean>();
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<T>(
fetchFn: () => Promise<T>,
config: IBaseProviderConfig
): Promise<T> {
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();
}
}
}

View File

@@ -0,0 +1,404 @@
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<string, IProviderEntry>();
private cache = new Map<string, ICacheEntry>();
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<IStockFundamentals> {
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<IStockFundamentals[]> {
const result = await this.getData({
type: 'fundamentals-batch',
tickers
});
return result as IStockFundamentals[];
}
/**
* Unified data fetching method
*/
public async getData(
request: IFundamentalsRequest
): Promise<IStockFundamentals | IStockFundamentals[]> {
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<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;
}
/**
* Enrich batch fundamentals with prices
*/
public async enrichBatchWithPrices(
fundamentalsList: IStockFundamentals[],
priceMap: Map<string, number>
): Promise<IStockFundamentals[]> {
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<Map<string, boolean>> {
const health = new Map<string, boolean>();
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<T>(
fetchFn: () => Promise<T>,
config: IFundamentalsProviderConfig
): Promise<T> {
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';
}
}
}

View File

@@ -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<T> {
provider: T;
config: IProviderConfig | IFundamentalsProviderConfig;
lastError?: Error;
lastErrorTime?: Date;
successCount: number;
errorCount: number;
}
interface ICacheEntry<T> {
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<string, IProviderEntry<IStockProvider>>();
private fundamentalsProviders = new Map<string, IProviderEntry<IFundamentalsProvider>>();
private priceCache = new Map<string, ICacheEntry<IStockPrice | IStockPrice[]>>();
private fundamentalsCache = new Map<string, ICacheEntry<IStockFundamentals | IStockFundamentals[]>>();
private logger = console;
private config: Required<IStockDataServiceConfig> = {
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<IStockPrice> {
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<IStockPrice[]> {
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<IStockFundamentals> {
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<IStockFundamentals[]> {
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<IStockData> {
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<IStockData[]> {
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<string, IStockFundamentals>();
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<T>(
fetchFn: () => Promise<T>,
config: IProviderConfig | IFundamentalsProviderConfig
): Promise<T> {
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<T>(cache: Map<string, ICacheEntry<T>>, 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<T>(cache: Map<string, ICacheEntry<T>>, 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<Map<string, boolean>> {
const health = new Map<string, boolean>();
// 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
};
}
}

View File

@@ -1,10 +1,19 @@
// Export all interfaces
export * from './interfaces/stockprice.js';
export * from './interfaces/provider.js';
export * from './interfaces/fundamentals.js';
export * from './interfaces/stockdata.js';
// Export main service
// 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';
export * from './providers/provider.marketstack.js';
export * from './providers/provider.secedgar.js';
export * from './providers/provider.coingecko.js';

View File

@@ -0,0 +1,102 @@
/**
* Interfaces for stock fundamental data (financials from SEC filings)
* Separate from stock price data (OHLCV) to maintain clean architecture
*/
// Request types for fundamental data
export interface IFundamentalsCurrentRequest {
type: 'fundamentals-current';
ticker: string;
}
export interface IFundamentalsBatchRequest {
type: 'fundamentals-batch';
tickers: string[];
}
export type IFundamentalsRequest =
| IFundamentalsCurrentRequest
| IFundamentalsBatchRequest;
/**
* Stock fundamental data from SEC filings (10-K, 10-Q)
* Contains financial metrics like EPS, Revenue, Assets, etc.
*/
export interface IStockFundamentals {
ticker: string;
cik: string;
companyName: string;
provider: string;
timestamp: Date;
fetchedAt: Date;
// Per-share metrics
earningsPerShareBasic?: number;
earningsPerShareDiluted?: number;
sharesOutstanding?: number;
weightedAverageSharesOutstanding?: number;
// Income statement (annual USD)
revenue?: number;
netIncome?: number;
operatingIncome?: number;
grossProfit?: number;
costOfRevenue?: number;
// Balance sheet (annual USD)
assets?: number;
liabilities?: number;
stockholdersEquity?: number;
cash?: number;
propertyPlantEquipment?: number;
// Calculated metrics (requires current price)
marketCap?: number; // price × sharesOutstanding
priceToEarnings?: number; // price / EPS
priceToBook?: number; // marketCap / stockholdersEquity
// Metadata
fiscalYear?: string;
fiscalQuarter?: string;
filingDate?: Date;
form?: '10-K' | '10-Q' | string;
}
/**
* Provider interface for fetching fundamental data
* Parallel to IStockProvider but for fundamentals instead of prices
*/
export interface IFundamentalsProvider {
name: string;
priority: number;
fetchData(request: IFundamentalsRequest): Promise<IStockFundamentals | IStockFundamentals[]>;
isAvailable(): Promise<boolean>;
readonly requiresAuth: boolean;
readonly rateLimit?: {
requestsPerMinute: number;
requestsPerDay?: number;
};
}
/**
* Configuration for fundamentals providers
*/
export interface IFundamentalsProviderConfig {
enabled: boolean;
priority?: number;
timeout?: number;
retryAttempts?: number;
retryDelay?: number;
cacheTTL?: number; // Custom cache TTL for this provider
}
/**
* Registry for managing fundamental data providers
*/
export interface IFundamentalsProviderRegistry {
register(provider: IFundamentalsProvider, config?: IFundamentalsProviderConfig): void;
unregister(providerName: string): void;
getProvider(name: string): IFundamentalsProvider | undefined;
getAllProviders(): IFundamentalsProvider[];
getEnabledProviders(): IFundamentalsProvider[];
}

View File

@@ -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;
}

View File

@@ -0,0 +1,757 @@
import * as plugins from '../../plugins.js';
import type { IStockProvider, IProviderConfig } from '../interfaces/provider.js';
import type {
IStockPrice,
IStockDataRequest,
IStockCurrentRequest,
IStockHistoricalRequest,
IStockIntradayRequest,
IStockBatchCurrentRequest
} from '../interfaces/stockprice.js';
/**
* Custom error for rate limit exceeded responses
*/
class RateLimitError extends Error {
constructor(
message: string,
public waitTime: number,
public retryAfter?: number
) {
super(message);
this.name = 'RateLimitError';
}
}
/**
* Rate limiter for CoinGecko API
* Free tier (Demo): 30 requests per minute
* Without registration: 5-15 requests per minute
*/
class RateLimiter {
private requestTimes: number[] = [];
private maxRequestsPerMinute: number;
private consecutiveRateLimitErrors: number = 0;
constructor(maxRequestsPerMinute: number = 30) {
this.maxRequestsPerMinute = maxRequestsPerMinute;
}
public async waitForSlot(): Promise<void> {
const now = Date.now();
const oneMinuteAgo = now - 60000;
// Remove requests older than 1 minute
this.requestTimes = this.requestTimes.filter(time => time > oneMinuteAgo);
// If we've hit the limit, wait
if (this.requestTimes.length >= this.maxRequestsPerMinute) {
const oldestRequest = this.requestTimes[0];
const waitTime = 60000 - (now - oldestRequest) + 100; // +100ms buffer
await plugins.smartdelay.delayFor(waitTime);
return this.waitForSlot(); // Recursively check again
}
// Record this request
this.requestTimes.push(now);
}
/**
* Get time in milliseconds until next request slot is available
*/
public getTimeUntilNextSlot(): number {
const now = Date.now();
const oneMinuteAgo = now - 60000;
// Clean old requests
const recentRequests = this.requestTimes.filter(time => time > oneMinuteAgo);
if (recentRequests.length < this.maxRequestsPerMinute) {
return 0; // Slot available now
}
// Calculate wait time until oldest request expires
const oldestRequest = recentRequests[0];
return Math.max(0, 60000 - (now - oldestRequest) + 100);
}
/**
* Handle rate limit error with exponential backoff
* Returns wait time in milliseconds
*/
public handleRateLimitError(): number {
this.consecutiveRateLimitErrors++;
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, 60s (max)
const baseWait = 1000; // 1 second
const exponent = this.consecutiveRateLimitErrors - 1;
const backoff = Math.min(
baseWait * Math.pow(2, exponent),
60000 // max 60 seconds
);
// After 3 consecutive 429s, reduce rate limit to 80% as safety measure
if (this.consecutiveRateLimitErrors >= 3) {
const newLimit = Math.floor(this.maxRequestsPerMinute * 0.8);
if (newLimit < this.maxRequestsPerMinute) {
console.warn(
`Adjusting rate limit from ${this.maxRequestsPerMinute} to ${newLimit} requests/min due to repeated 429 errors`
);
this.maxRequestsPerMinute = newLimit;
}
}
return backoff;
}
/**
* Reset consecutive error count on successful request
*/
public resetErrors(): void {
if (this.consecutiveRateLimitErrors > 0) {
this.consecutiveRateLimitErrors = 0;
}
}
}
/**
* Interface for coin list response
*/
interface ICoinListItem {
id: string;
symbol: string;
name: string;
}
/**
* CoinGecko Crypto Price Provider
*
* Documentation: https://docs.coingecko.com/v3.0.1/reference/endpoint-overview
*
* Features:
* - Current crypto prices (single and batch)
* - Historical price data with OHLCV
* - 13M+ tokens, 240+ networks, 1600+ exchanges
* - Accepts both ticker symbols (BTC, ETH) and CoinGecko IDs (bitcoin, ethereum)
* - 24/7 market data (crypto never closes)
*
* Rate Limits:
* - Free tier (no key): 5-15 requests/minute
* - Demo plan (free with registration): ~30 requests/minute, 10,000/month
* - Paid plans: Higher limits
*
* API Authentication:
* - Optional API key for Demo/paid plans
* - Header: x-cg-demo-api-key (Demo) or x-cg-pro-api-key (paid)
*/
export class CoinGeckoProvider implements IStockProvider {
public name = 'CoinGecko';
public priority = 90; // High priority for crypto, between Yahoo (100) and Marketstack (80)
public readonly requiresAuth = false; // API key is optional
public readonly rateLimit = {
requestsPerMinute: 30, // Demo plan default
requestsPerDay: 10000 // Demo plan monthly quota / 30
};
private logger = console;
private baseUrl = 'https://api.coingecko.com/api/v3';
private apiKey?: string;
private rateLimiter: RateLimiter;
// Coin mapping cache
private coinMapCache = new Map<string, string>(); // ticker/id -> coingecko id
private coinListLoadedAt: Date | null = null;
private readonly coinListCacheTTL = 24 * 60 * 60 * 1000; // 24 hours
// Priority map for common crypto tickers (to avoid conflicts)
private readonly priorityTickerMap = new Map<string, string>([
['btc', 'bitcoin'],
['eth', 'ethereum'],
['usdt', 'tether'],
['bnb', 'binancecoin'],
['sol', 'solana'],
['usdc', 'usd-coin'],
['xrp', 'ripple'],
['ada', 'cardano'],
['doge', 'dogecoin'],
['trx', 'tron'],
['dot', 'polkadot'],
['matic', 'matic-network'],
['ltc', 'litecoin'],
['shib', 'shiba-inu'],
['avax', 'avalanche-2'],
['link', 'chainlink'],
['atom', 'cosmos'],
['uni', 'uniswap'],
['etc', 'ethereum-classic'],
['xlm', 'stellar']
]);
constructor(apiKey?: string, private config?: IProviderConfig) {
this.apiKey = apiKey;
this.rateLimiter = new RateLimiter(this.rateLimit.requestsPerMinute);
}
/**
* Unified data fetching method supporting all request types
*/
public async fetchData(request: IStockDataRequest): Promise<IStockPrice | IStockPrice[]> {
switch (request.type) {
case 'current':
return this.fetchCurrentPrice(request);
case 'batch':
return this.fetchBatchCurrentPrices(request);
case 'historical':
return this.fetchHistoricalPrices(request);
case 'intraday':
return this.fetchIntradayPrices(request);
default:
throw new Error(`Unsupported request type: ${(request as any).type}`);
}
}
/**
* Fetch current price for a single crypto
*/
private async fetchCurrentPrice(request: IStockCurrentRequest): Promise<IStockPrice> {
return this.fetchWithRateLimitRetry(async () => {
// Resolve ticker to CoinGecko ID
const coinId = await this.resolveCoinId(request.ticker);
// Build URL
const params = new URLSearchParams({
ids: coinId,
vs_currencies: 'usd',
include_market_cap: 'true',
include_24hr_vol: 'true',
include_24hr_change: 'true',
include_last_updated_at: 'true'
});
const url = `${this.baseUrl}/simple/price?${params}`;
// Wait for rate limit slot
await this.rateLimiter.waitForSlot();
// Make request
const response = await plugins.smartrequest.SmartRequest.create()
.url(url)
.headers(this.buildHeaders())
.timeout(this.config?.timeout || 10000)
.get();
const responseData = await response.json() as any;
// Check for rate limit error
if (this.isRateLimitError(responseData)) {
const waitTime = this.rateLimiter.handleRateLimitError();
throw new RateLimitError(
`Rate limit exceeded for ${request.ticker}`,
waitTime
);
}
if (!responseData[coinId]) {
throw new Error(`No data found for ${request.ticker} (${coinId})`);
}
return this.mapToStockPrice(request.ticker, coinId, responseData[coinId], 'live');
}, `current price for ${request.ticker}`);
}
/**
* Fetch batch current prices for multiple cryptos
*/
private async fetchBatchCurrentPrices(request: IStockBatchCurrentRequest): Promise<IStockPrice[]> {
return this.fetchWithRateLimitRetry(async () => {
// Resolve all tickers to CoinGecko IDs
const coinIds = await Promise.all(
request.tickers.map(ticker => this.resolveCoinId(ticker))
);
// Build URL with comma-separated IDs
const params = new URLSearchParams({
ids: coinIds.join(','),
vs_currencies: 'usd',
include_market_cap: 'true',
include_24hr_vol: 'true',
include_24hr_change: 'true',
include_last_updated_at: 'true'
});
const url = `${this.baseUrl}/simple/price?${params}`;
// Wait for rate limit slot
await this.rateLimiter.waitForSlot();
// Make request
const response = await plugins.smartrequest.SmartRequest.create()
.url(url)
.headers(this.buildHeaders())
.timeout(this.config?.timeout || 15000)
.get();
const responseData = await response.json() as any;
// Check for rate limit error
if (this.isRateLimitError(responseData)) {
const waitTime = this.rateLimiter.handleRateLimitError();
throw new RateLimitError(
`Rate limit exceeded for batch request`,
waitTime
);
}
const prices: IStockPrice[] = [];
// Map responses back to original tickers
for (let i = 0; i < request.tickers.length; i++) {
const ticker = request.tickers[i];
const coinId = coinIds[i];
if (responseData[coinId]) {
try {
prices.push(this.mapToStockPrice(ticker, coinId, responseData[coinId], 'live'));
} catch (error) {
this.logger.warn(`Failed to parse data for ${ticker}:`, error);
}
} else {
this.logger.warn(`No data returned for ${ticker} (${coinId})`);
}
}
if (prices.length === 0) {
throw new Error('No valid price data received from batch request');
}
return prices;
}, `batch prices for ${request.tickers.length} tickers`);
}
/**
* Fetch historical prices with OHLCV data
*/
private async fetchHistoricalPrices(request: IStockHistoricalRequest): Promise<IStockPrice[]> {
return this.fetchWithRateLimitRetry(async () => {
const coinId = await this.resolveCoinId(request.ticker);
// Calculate days between dates
const days = Math.ceil((request.to.getTime() - request.from.getTime()) / (1000 * 60 * 60 * 24));
// Build URL
const params = new URLSearchParams({
vs_currency: 'usd',
days: days.toString(),
interval: 'daily' // Explicit daily granularity for historical data
});
const url = `${this.baseUrl}/coins/${coinId}/market_chart?${params}`;
// Wait for rate limit slot
await this.rateLimiter.waitForSlot();
// Make request
const response = await plugins.smartrequest.SmartRequest.create()
.url(url)
.headers(this.buildHeaders())
.timeout(this.config?.timeout || 20000)
.get();
const responseData = await response.json() as any;
// Check for rate limit error
if (this.isRateLimitError(responseData)) {
const waitTime = this.rateLimiter.handleRateLimitError();
throw new RateLimitError(
`Rate limit exceeded for historical ${request.ticker}`,
waitTime
);
}
if (!responseData.prices || !Array.isArray(responseData.prices)) {
this.logger.error(`Invalid API response for ${request.ticker}:`, JSON.stringify(responseData).substring(0, 500));
throw new Error(`Invalid response format for ${request.ticker}: ${JSON.stringify(responseData).substring(0, 200)}`);
}
const prices: IStockPrice[] = [];
const priceData = responseData.prices;
const marketCapData = responseData.market_caps || [];
const volumeData = responseData.total_volumes || [];
// Process each data point
for (let i = 0; i < priceData.length; i++) {
const [timestamp, price] = priceData[i];
const date = new Date(timestamp);
// Filter by date range
if (date < request.from || date > request.to) continue;
const marketCap = marketCapData[i]?.[1];
const volume = volumeData[i]?.[1];
// Calculate previous close for change calculation
const previousClose = i > 0 ? priceData[i - 1][1] : price;
const change = price - previousClose;
const changePercent = previousClose !== 0 ? (change / previousClose) * 100 : 0;
prices.push({
ticker: request.ticker.toUpperCase(),
price: price,
currency: 'USD',
change: change,
changePercent: changePercent,
previousClose: previousClose,
timestamp: date,
provider: this.name,
marketState: 'REGULAR', // Crypto markets are always open
// OHLCV data (note: market_chart doesn't provide OHLC, only close prices)
volume: volume,
dataType: 'eod',
fetchedAt: new Date(),
companyName: coinId.charAt(0).toUpperCase() + coinId.slice(1)
});
}
return prices;
}, `historical prices for ${request.ticker}`);
}
/**
* Fetch intraday prices with hourly intervals
*/
private async fetchIntradayPrices(request: IStockIntradayRequest): Promise<IStockPrice[]> {
return this.fetchWithRateLimitRetry(async () => {
const coinId = await this.resolveCoinId(request.ticker);
// Map interval to days parameter (CoinGecko auto-granularity)
// For hourly data, request 1-7 days
let days = 1;
switch (request.interval) {
case '1min':
case '5min':
case '10min':
case '15min':
case '30min':
throw new Error('CoinGecko only supports hourly intervals in market_chart. Use interval: "1hour"');
case '1hour':
days = 1; // Last 24 hours with hourly granularity
break;
}
// Build URL (omit interval param for automatic granularity based on days)
const params = new URLSearchParams({
vs_currency: 'usd',
days: days.toString()
});
const url = `${this.baseUrl}/coins/${coinId}/market_chart?${params}`;
// Wait for rate limit slot
await this.rateLimiter.waitForSlot();
// Make request
const response = await plugins.smartrequest.SmartRequest.create()
.url(url)
.headers(this.buildHeaders())
.timeout(this.config?.timeout || 15000)
.get();
const responseData = await response.json() as any;
// Check for rate limit error
if (this.isRateLimitError(responseData)) {
const waitTime = this.rateLimiter.handleRateLimitError();
throw new RateLimitError(
`Rate limit exceeded for intraday ${request.ticker}`,
waitTime
);
}
if (!responseData.prices || !Array.isArray(responseData.prices)) {
this.logger.error(`Invalid API response for ${request.ticker}:`, JSON.stringify(responseData).substring(0, 500));
throw new Error(`Invalid response format for ${request.ticker}: ${JSON.stringify(responseData).substring(0, 200)}`);
}
const prices: IStockPrice[] = [];
const priceData = responseData.prices;
const marketCapData = responseData.market_caps || [];
const volumeData = responseData.total_volumes || [];
// Apply limit if specified
const limit = request.limit || priceData.length;
const dataToProcess = priceData.slice(-limit);
for (let i = 0; i < dataToProcess.length; i++) {
const actualIndex = priceData.length - limit + i;
const [timestamp, price] = dataToProcess[i];
const date = new Date(timestamp);
const marketCap = marketCapData[actualIndex]?.[1];
const volume = volumeData[actualIndex]?.[1];
const previousClose = i > 0 ? dataToProcess[i - 1][1] : price;
const change = price - previousClose;
const changePercent = previousClose !== 0 ? (change / previousClose) * 100 : 0;
prices.push({
ticker: request.ticker.toUpperCase(),
price: price,
currency: 'USD',
change: change,
changePercent: changePercent,
previousClose: previousClose,
timestamp: date,
provider: this.name,
marketState: 'REGULAR',
volume: volume,
dataType: 'intraday',
fetchedAt: new Date(),
companyName: coinId.charAt(0).toUpperCase() + coinId.slice(1)
});
}
return prices;
}, `intraday prices for ${request.ticker}`);
}
/**
* Check if CoinGecko API is available
*/
public async isAvailable(): Promise<boolean> {
try {
const url = `${this.baseUrl}/simple/price?ids=bitcoin&vs_currencies=usd`;
await this.rateLimiter.waitForSlot();
const response = await plugins.smartrequest.SmartRequest.create()
.url(url)
.headers(this.buildHeaders())
.timeout(5000)
.get();
const responseData = await response.json() as any;
return responseData.bitcoin?.usd !== undefined;
} catch (error) {
this.logger.warn('CoinGecko provider is not available:', error);
return false;
}
}
/**
* Check if a market/network is supported
* CoinGecko supports 240+ networks
*/
public supportsMarket(market: string): boolean {
// CoinGecko has extensive crypto network coverage
const supportedNetworks = [
'CRYPTO', 'BTC', 'ETH', 'BSC', 'POLYGON', 'AVALANCHE',
'SOLANA', 'ARBITRUM', 'OPTIMISM', 'BASE'
];
return supportedNetworks.includes(market.toUpperCase());
}
/**
* Check if a ticker format is supported
* Supports both ticker symbols (BTC) and CoinGecko IDs (bitcoin)
*/
public supportsTicker(ticker: string): boolean {
// Accept alphanumeric with hyphens (for coin IDs like 'wrapped-bitcoin')
return /^[A-Za-z0-9\-]{1,50}$/.test(ticker);
}
/**
* Resolve ticker symbol or CoinGecko ID to canonical CoinGecko ID
* Supports both formats: "BTC" -> "bitcoin", "bitcoin" -> "bitcoin"
*/
private async resolveCoinId(tickerOrId: string): Promise<string> {
const normalized = tickerOrId.toLowerCase();
// Check priority map first (for common cryptos)
if (this.priorityTickerMap.has(normalized)) {
const coinId = this.priorityTickerMap.get(normalized)!;
this.coinMapCache.set(normalized, coinId);
return coinId;
}
// Check cache
if (this.coinMapCache.has(normalized)) {
return this.coinMapCache.get(normalized)!;
}
// Check if it's already a valid CoinGecko ID (contains hyphens or is all lowercase with original case)
if (normalized.includes('-') || normalized === tickerOrId) {
// Assume it's a CoinGecko ID, cache it
this.coinMapCache.set(normalized, normalized);
return normalized;
}
// Load coin list if needed
if (!this.coinListLoadedAt ||
Date.now() - this.coinListLoadedAt.getTime() > this.coinListCacheTTL) {
await this.loadCoinList();
}
// Try to find in cache after loading
if (this.coinMapCache.has(normalized)) {
return this.coinMapCache.get(normalized)!;
}
// Not found - return as-is and let API handle the error
this.logger.warn(`Could not resolve ticker ${tickerOrId} to CoinGecko ID, using as-is`);
return normalized;
}
/**
* Load complete coin list from CoinGecko API
*/
private async loadCoinList(): Promise<void> {
try {
const url = `${this.baseUrl}/coins/list`;
await this.rateLimiter.waitForSlot();
const response = await plugins.smartrequest.SmartRequest.create()
.url(url)
.headers(this.buildHeaders())
.timeout(10000)
.get();
const coinList = await response.json() as ICoinListItem[];
// Build mapping: symbol -> id
for (const coin of coinList) {
const symbol = coin.symbol.toLowerCase();
const id = coin.id.toLowerCase();
// Don't overwrite priority mappings or existing cache entries
if (!this.priorityTickerMap.has(symbol) && !this.coinMapCache.has(symbol)) {
this.coinMapCache.set(symbol, id);
}
// Always cache the ID mapping
this.coinMapCache.set(id, id);
}
this.coinListLoadedAt = new Date();
this.logger.info(`Loaded ${coinList.length} coins from CoinGecko`);
} catch (error) {
this.logger.error('Failed to load coin list from CoinGecko:', error);
// Don't throw - we can still work with direct IDs
}
}
/**
* Map CoinGecko simple/price response to IStockPrice
*/
private mapToStockPrice(
ticker: string,
coinId: string,
data: any,
dataType: 'live' | 'eod' | 'intraday'
): IStockPrice {
const price = data.usd;
const change24h = data.usd_24h_change || 0;
// Calculate previous close from 24h change
const changePercent = change24h;
const change = (price * changePercent) / 100;
const previousClose = price - change;
// Parse last updated timestamp
const timestamp = data.last_updated_at
? new Date(data.last_updated_at * 1000)
: new Date();
return {
ticker: ticker.toUpperCase(),
price: price,
currency: 'USD',
change: change,
changePercent: changePercent,
previousClose: previousClose,
timestamp: timestamp,
provider: this.name,
marketState: 'REGULAR', // Crypto markets are 24/7
// Volume and market cap
volume: data.usd_24h_vol,
dataType: dataType,
fetchedAt: new Date(),
// Company identification (use coin name)
companyName: coinId.charAt(0).toUpperCase() + coinId.slice(1),
companyFullName: `${coinId.charAt(0).toUpperCase() + coinId.slice(1)} (${ticker.toUpperCase()})`
};
}
/**
* Build HTTP headers with optional API key
*/
private buildHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Accept': 'application/json'
};
if (this.apiKey) {
// Use Demo or Pro API key header
// CoinGecko accepts both x-cg-demo-api-key and x-cg-pro-api-key
headers['x-cg-demo-api-key'] = this.apiKey;
}
return headers;
}
/**
* Check if response indicates a rate limit error (429)
*/
private isRateLimitError(responseData: any): boolean {
return responseData?.status?.error_code === 429;
}
/**
* Wrapper for fetch operations with automatic rate limit retry and exponential backoff
*/
private async fetchWithRateLimitRetry<T>(
fetchFn: () => Promise<T>,
operationName: string,
maxRetries: number = 3
): Promise<T> {
let lastError: Error | undefined;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const result = await fetchFn();
this.rateLimiter.resetErrors();
return result;
} catch (error) {
lastError = error as Error;
if (error instanceof RateLimitError) {
const attemptInfo = `${attempt + 1}/${maxRetries}`;
this.logger.warn(
`Rate limit hit for ${operationName}, waiting ${error.waitTime}ms before retry ${attemptInfo}`
);
if (attempt < maxRetries - 1) {
await plugins.smartdelay.delayFor(error.waitTime);
continue;
} else {
this.logger.error(`Max retries (${maxRetries}) exceeded for ${operationName} due to rate limiting`);
throw error;
}
}
// Non-rate-limit errors: throw immediately
throw error;
}
}
throw lastError!;
}
}

View File

@@ -0,0 +1,467 @@
import * as plugins from '../../plugins.js';
import type {
IFundamentalsProvider,
IStockFundamentals,
IFundamentalsRequest,
IFundamentalsCurrentRequest,
IFundamentalsBatchRequest
} from '../interfaces/fundamentals.js';
/**
* Configuration for SEC EDGAR provider
*/
export interface ISecEdgarConfig {
userAgent: string; // Required: Format "Company Name Email" (e.g., "fin.cx info@fin.cx")
cikCacheTTL?: number; // Default: 30 days (CIK codes rarely change)
fundamentalsCacheTTL?: number; // Default: 90 days (quarterly filings)
timeout?: number; // Request timeout in ms
}
/**
* Rate limiter for SEC EDGAR API
* SEC requires: 10 requests per second maximum
*/
class RateLimiter {
private requestTimes: number[] = [];
private readonly maxRequestsPerSecond = 10;
public async waitForSlot(): Promise<void> {
const now = Date.now();
const oneSecondAgo = now - 1000;
// Remove requests older than 1 second
this.requestTimes = this.requestTimes.filter(time => time > oneSecondAgo);
// If we've hit the limit, wait
if (this.requestTimes.length >= this.maxRequestsPerSecond) {
const oldestRequest = this.requestTimes[0];
const waitTime = 1000 - (now - oldestRequest) + 10; // +10ms buffer
await plugins.smartdelay.delayFor(waitTime);
return this.waitForSlot(); // Recursively check again
}
// Record this request
this.requestTimes.push(now);
}
}
/**
* SEC EDGAR Fundamental Data Provider
*
* Features:
* - Free public access (no API key required)
* - All US public companies
* - Financial data from 10-K/10-Q filings
* - US GAAP standardized metrics
* - Historical data back to ~2009
* - < 1 minute filing delay
*
* Documentation: https://www.sec.gov/edgar/sec-api-documentation
*
* Rate Limits:
* - 10 requests per second (enforced by SEC)
* - Requires User-Agent header in format: "Company Name Email"
*
* Data Sources:
* - Company Facts API: /api/xbrl/companyfacts/CIK##########.json
* - Ticker Lookup: /files/company_tickers.json
*/
export class SecEdgarProvider implements IFundamentalsProvider {
public name = 'SEC EDGAR';
public priority = 100; // High priority - free, authoritative, comprehensive
public readonly requiresAuth = false; // No API key needed!
public readonly rateLimit = {
requestsPerMinute: 600, // 10 requests/second = 600/minute
requestsPerDay: undefined // No daily limit
};
private logger = console;
private baseUrl = 'https://data.sec.gov/api/xbrl';
private tickersUrl = 'https://www.sec.gov/files/company_tickers.json';
private userAgent: string;
private config: Required<ISecEdgarConfig>;
// Caching
private cikCache = new Map<string, { cik: string; timestamp: Date }>();
private tickerListCache: { data: any; timestamp: Date } | null = null;
// Rate limiting
private rateLimiter = new RateLimiter();
constructor(config: ISecEdgarConfig) {
// Validate User-Agent
if (!config.userAgent) {
throw new Error('User-Agent is required for SEC EDGAR provider (format: "Company Name Email")');
}
// Validate User-Agent format (must contain space and @ symbol)
if (!config.userAgent.includes(' ') || !config.userAgent.includes('@')) {
throw new Error(
'Invalid User-Agent format. Required: "Company Name Email" (e.g., "fin.cx info@fin.cx")'
);
}
this.userAgent = config.userAgent;
this.config = {
userAgent: config.userAgent,
cikCacheTTL: config.cikCacheTTL || 30 * 24 * 60 * 60 * 1000, // 30 days
fundamentalsCacheTTL: config.fundamentalsCacheTTL || 90 * 24 * 60 * 60 * 1000, // 90 days
timeout: config.timeout || 30000
};
}
/**
* Unified data fetching method
*/
public async fetchData(
request: IFundamentalsRequest
): Promise<IStockFundamentals | IStockFundamentals[]> {
switch (request.type) {
case 'fundamentals-current':
return this.fetchFundamentals(request);
case 'fundamentals-batch':
return this.fetchBatchFundamentals(request);
default:
throw new Error(`Unsupported request type: ${(request as any).type}`);
}
}
/**
* Fetch fundamental data for a single ticker
*/
private async fetchFundamentals(request: IFundamentalsCurrentRequest): Promise<IStockFundamentals> {
try {
// 1. Get CIK for ticker (with caching)
const cik = await this.getCIK(request.ticker);
// 2. Fetch company facts from SEC (with rate limiting)
const companyFacts = await this.fetchCompanyFacts(cik);
// 3. Parse facts into structured fundamental data
return this.parseCompanyFacts(request.ticker, cik, companyFacts);
} catch (error) {
this.logger.error(`Failed to fetch fundamentals for ${request.ticker}:`, error);
throw new Error(`SEC EDGAR: Failed to fetch fundamentals for ${request.ticker}: ${error.message}`);
}
}
/**
* Fetch fundamental data for multiple tickers
*/
private async fetchBatchFundamentals(
request: IFundamentalsBatchRequest
): Promise<IStockFundamentals[]> {
const results: IStockFundamentals[] = [];
const errors: string[] = [];
for (const ticker of request.tickers) {
try {
const fundamentals = await this.fetchFundamentals({
type: 'fundamentals-current',
ticker
});
results.push(fundamentals);
} catch (error) {
this.logger.warn(`Failed to fetch fundamentals for ${ticker}:`, error);
errors.push(`${ticker}: ${error.message}`);
// Continue with other tickers
}
}
if (results.length === 0) {
throw new Error(`Failed to fetch fundamentals for all tickers. Errors: ${errors.join(', ')}`);
}
return results;
}
/**
* Get CIK (Central Index Key) for a ticker symbol
* Uses SEC's public ticker-to-CIK mapping file
*/
private async getCIK(ticker: string): Promise<string> {
const tickerUpper = ticker.toUpperCase();
// Check cache first
const cached = this.cikCache.get(tickerUpper);
if (cached) {
const age = Date.now() - cached.timestamp.getTime();
if (age < this.config.cikCacheTTL) {
return cached.cik;
}
// Cache expired, remove it
this.cikCache.delete(tickerUpper);
}
// Fetch ticker list (with caching at list level)
const tickers = await this.fetchTickerList();
// Find ticker in list (case-insensitive)
const entry = Object.values(tickers).find((t: any) => t.ticker === tickerUpper);
if (!entry) {
throw new Error(`CIK not found for ticker ${ticker}`);
}
const cik = String((entry as any).cik_str);
// Cache the result
this.cikCache.set(tickerUpper, {
cik,
timestamp: new Date()
});
return cik;
}
/**
* Fetch the SEC ticker-to-CIK mapping list
* Cached for 24 hours (list updates daily)
* Uses native fetch for automatic gzip decompression
*/
private async fetchTickerList(): Promise<any> {
// Check cache
if (this.tickerListCache) {
const age = Date.now() - this.tickerListCache.timestamp.getTime();
if (age < 24 * 60 * 60 * 1000) { // 24 hours
return this.tickerListCache.data;
}
}
// Wait for rate limit slot
await this.rateLimiter.waitForSlot();
// Fetch from SEC using native fetch (handles gzip automatically)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
try {
const response = await fetch(this.tickersUrl, {
headers: {
'User-Agent': this.userAgent,
'Accept': 'application/json'
// Note: Accept-Encoding is set automatically by fetch
},
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Cache the list
this.tickerListCache = {
data,
timestamp: new Date()
};
return data;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
/**
* Fetch company facts from SEC EDGAR
* Uses native fetch for automatic gzip decompression
*/
private async fetchCompanyFacts(cik: string): Promise<any> {
// Pad CIK to 10 digits
const paddedCIK = cik.padStart(10, '0');
const url = `${this.baseUrl}/companyfacts/CIK${paddedCIK}.json`;
// Wait for rate limit slot
await this.rateLimiter.waitForSlot();
// Fetch from SEC using native fetch (handles gzip automatically)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
try {
const response = await fetch(url, {
headers: {
'User-Agent': this.userAgent,
'Accept': 'application/json',
'Host': 'data.sec.gov'
// Note: Accept-Encoding is set automatically by fetch and gzip is handled transparently
},
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Validate response
if (!data || !data.facts) {
throw new Error('Invalid response from SEC EDGAR API');
}
return data;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
/**
* Parse SEC company facts into structured fundamental data
*/
private parseCompanyFacts(ticker: string, cik: string, data: any): IStockFundamentals {
const usGaap = data.facts?.['us-gaap'];
if (!usGaap) {
throw new Error('No US GAAP data available');
}
// Extract latest values for key metrics
const fundamentals: IStockFundamentals = {
ticker: ticker.toUpperCase(),
cik: cik,
companyName: data.entityName,
provider: this.name,
timestamp: new Date(),
fetchedAt: new Date(),
// Per-share metrics
earningsPerShareBasic: this.getLatestValue(usGaap, 'EarningsPerShareBasic'),
earningsPerShareDiluted: this.getLatestValue(usGaap, 'EarningsPerShareDiluted'),
sharesOutstanding: this.getLatestValue(usGaap, 'CommonStockSharesOutstanding'),
weightedAverageSharesOutstanding: this.getLatestValue(
usGaap,
'WeightedAverageNumberOfSharesOutstandingBasic'
),
// Income statement
revenue: this.getLatestValue(usGaap, 'Revenues') ||
this.getLatestValue(usGaap, 'RevenueFromContractWithCustomerExcludingAssessedTax'),
netIncome: this.getLatestValue(usGaap, 'NetIncomeLoss'),
operatingIncome: this.getLatestValue(usGaap, 'OperatingIncomeLoss'),
grossProfit: this.getLatestValue(usGaap, 'GrossProfit'),
costOfRevenue: this.getLatestValue(usGaap, 'CostOfRevenue'),
// Balance sheet
assets: this.getLatestValue(usGaap, 'Assets'),
liabilities: this.getLatestValue(usGaap, 'Liabilities'),
stockholdersEquity: this.getLatestValue(usGaap, 'StockholdersEquity'),
cash: this.getLatestValue(usGaap, 'CashAndCashEquivalentsAtCarryingValue'),
propertyPlantEquipment: this.getLatestValue(usGaap, 'PropertyPlantAndEquipmentNet'),
// Metadata (from latest available data point)
fiscalYear: this.getLatestMetadata(usGaap, 'EarningsPerShareDiluted')?.fy,
fiscalQuarter: this.getLatestMetadata(usGaap, 'EarningsPerShareDiluted')?.fp,
filingDate: this.getLatestMetadata(usGaap, 'EarningsPerShareDiluted')?.filed
? new Date(this.getLatestMetadata(usGaap, 'EarningsPerShareDiluted')!.filed)
: undefined,
form: this.getLatestMetadata(usGaap, 'EarningsPerShareDiluted')?.form
};
return fundamentals;
}
/**
* Get the latest value for a US GAAP metric
*/
private getLatestValue(usGaap: any, metricName: string): number | undefined {
const metric = usGaap[metricName];
if (!metric?.units) {
return undefined;
}
// Get the first unit type (USD, shares, etc.)
const unitType = Object.keys(metric.units)[0];
const values = metric.units[unitType];
if (!values || !Array.isArray(values) || values.length === 0) {
return undefined;
}
// Get the latest value (last in array)
const latest = values[values.length - 1];
return latest?.val;
}
/**
* Get metadata from the latest data point
*/
private getLatestMetadata(usGaap: any, metricName: string): any | undefined {
const metric = usGaap[metricName];
if (!metric?.units) {
return undefined;
}
const unitType = Object.keys(metric.units)[0];
const values = metric.units[unitType];
if (!values || !Array.isArray(values) || values.length === 0) {
return undefined;
}
return values[values.length - 1];
}
/**
* Check if SEC EDGAR API is available
* Uses native fetch for automatic gzip decompression
*/
public async isAvailable(): Promise<boolean> {
try {
// Test with Apple's well-known CIK
const url = `${this.baseUrl}/companyfacts/CIK0000320193.json`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(url, {
headers: {
'User-Agent': this.userAgent,
'Accept': 'application/json'
},
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
return false;
}
const data = await response.json();
return data && data.facts !== undefined;
} catch (error) {
this.logger.warn('SEC EDGAR provider is not available:', error);
return false;
}
}
/**
* Get cache statistics
*/
public getCacheStats(): {
cikCacheSize: number;
hasTickerList: boolean;
} {
return {
cikCacheSize: this.cikCache.size,
hasTickerList: this.tickerListCache !== null
};
}
/**
* Clear all caches
*/
public clearCache(): void {
this.cikCache.clear();
this.tickerListCache = null;
this.logger.log('SEC EDGAR cache cleared');
}
}