Compare commits
	
		
			10 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 909b30117b | |||
| 47fd770e48 | |||
| fdea1bb149 | |||
| 8286c30edf | |||
| ea76776ee1 | |||
| 54818293a1 | |||
| d49a738880 | |||
| 6273faa2f9 | |||
| d33c7e0f52 | |||
| 79930c40ac | 
							
								
								
									
										48
									
								
								changelog.md
									
									
									
									
									
								
							
							
						
						
									
										48
									
								
								changelog.md
									
									
									
									
									
								
							@@ -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
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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
									
									
									
								
							
							
						
						
									
										1054
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -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:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										261
									
								
								test/test.coingecko.node+bun+deno.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										261
									
								
								test/test.coingecko.node+bun+deno.ts
									
									
									
									
									
										Normal 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();
 | 
			
		||||
							
								
								
									
										287
									
								
								test/test.fundamentals.service.node+bun+deno.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										287
									
								
								test/test.fundamentals.service.node+bun+deno.ts
									
									
									
									
									
										Normal 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();
 | 
			
		||||
							
								
								
									
										261
									
								
								test/test.secedgar.provider.node+bun+deno.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										261
									
								
								test/test.secedgar.provider.node+bun+deno.ts
									
									
									
									
									
										Normal 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();
 | 
			
		||||
							
								
								
									
										418
									
								
								test/test.stockdata.service.node+bun+deno.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										418
									
								
								test/test.stockdata.service.node+bun+deno.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,418 @@
 | 
			
		||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
 | 
			
		||||
import * as opendata from '../ts/index.js';
 | 
			
		||||
 | 
			
		||||
const TEST_USER_AGENT = 'fin.cx test@fin.cx';
 | 
			
		||||
 | 
			
		||||
tap.test('StockDataService - Basic Setup', async () => {
 | 
			
		||||
  await tap.test('should create StockDataService instance', async () => {
 | 
			
		||||
    const service = new opendata.StockDataService({
 | 
			
		||||
      cache: {
 | 
			
		||||
        priceTTL: 60000, // 1 minute for testing
 | 
			
		||||
        fundamentalsTTL: 120000, // 2 minutes for testing
 | 
			
		||||
        maxEntries: 100
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    expect(service).toBeInstanceOf(opendata.StockDataService);
 | 
			
		||||
 | 
			
		||||
    const stats = service.getCacheStats();
 | 
			
		||||
    expect(stats.priceCache.ttl).toEqual(60000);
 | 
			
		||||
    expect(stats.fundamentalsCache.ttl).toEqual(120000);
 | 
			
		||||
    expect(stats.maxEntries).toEqual(100);
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
tap.test('StockDataService - Provider Registration', async () => {
 | 
			
		||||
  const service = new opendata.StockDataService();
 | 
			
		||||
 | 
			
		||||
  await tap.test('should register price provider', async () => {
 | 
			
		||||
    const yahooProvider = new opendata.YahooFinanceProvider();
 | 
			
		||||
    service.registerPriceProvider(yahooProvider);
 | 
			
		||||
 | 
			
		||||
    const providers = service.getPriceProviders();
 | 
			
		||||
    expect(providers.length).toEqual(1);
 | 
			
		||||
    expect(providers[0].name).toEqual('Yahoo Finance');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  await tap.test('should register fundamentals provider', async () => {
 | 
			
		||||
    const secProvider = new opendata.SecEdgarProvider({
 | 
			
		||||
      userAgent: TEST_USER_AGENT
 | 
			
		||||
    });
 | 
			
		||||
    service.registerFundamentalsProvider(secProvider);
 | 
			
		||||
 | 
			
		||||
    const providers = service.getFundamentalsProviders();
 | 
			
		||||
    expect(providers.length).toEqual(1);
 | 
			
		||||
    expect(providers[0].name).toEqual('SEC EDGAR');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  await tap.test('should unregister providers', async () => {
 | 
			
		||||
    service.unregisterPriceProvider('Yahoo Finance');
 | 
			
		||||
    service.unregisterFundamentalsProvider('SEC EDGAR');
 | 
			
		||||
 | 
			
		||||
    expect(service.getPriceProviders().length).toEqual(0);
 | 
			
		||||
    expect(service.getFundamentalsProviders().length).toEqual(0);
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
tap.test('StockDataService - Price Fetching', async () => {
 | 
			
		||||
  const service = new opendata.StockDataService();
 | 
			
		||||
  const yahooProvider = new opendata.YahooFinanceProvider();
 | 
			
		||||
  service.registerPriceProvider(yahooProvider);
 | 
			
		||||
 | 
			
		||||
  await tap.test('should fetch single price', async () => {
 | 
			
		||||
    const price = await service.getPrice('AAPL');
 | 
			
		||||
 | 
			
		||||
    expect(price).toBeDefined();
 | 
			
		||||
    expect(price.ticker).toEqual('AAPL');
 | 
			
		||||
    expect(price.price).toBeGreaterThan(0);
 | 
			
		||||
    expect(price.provider).toEqual('Yahoo Finance');
 | 
			
		||||
    expect(price.timestamp).toBeInstanceOf(Date);
 | 
			
		||||
 | 
			
		||||
    console.log(`\n💵 Single Price: ${price.ticker} = $${price.price.toFixed(2)}`);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  await tap.test('should fetch batch prices', async () => {
 | 
			
		||||
    const prices = await service.getPrices(['AAPL', 'MSFT', 'GOOGL']);
 | 
			
		||||
 | 
			
		||||
    expect(prices).toBeInstanceOf(Array);
 | 
			
		||||
    expect(prices.length).toBeGreaterThan(0);
 | 
			
		||||
    expect(prices.length).toBeLessThanOrEqual(3);
 | 
			
		||||
 | 
			
		||||
    console.log('\n💵 Batch Prices:');
 | 
			
		||||
    prices.forEach(p => {
 | 
			
		||||
      console.log(`  ${p.ticker}: $${p.price.toFixed(2)}`);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  await tap.test('should cache prices', async () => {
 | 
			
		||||
    // Clear cache
 | 
			
		||||
    service.clearCache();
 | 
			
		||||
 | 
			
		||||
    const stats1 = service.getCacheStats();
 | 
			
		||||
    expect(stats1.priceCache.size).toEqual(0);
 | 
			
		||||
 | 
			
		||||
    // Fetch price (should hit API)
 | 
			
		||||
    const start1 = Date.now();
 | 
			
		||||
    await service.getPrice('AAPL');
 | 
			
		||||
    const duration1 = Date.now() - start1;
 | 
			
		||||
 | 
			
		||||
    const stats2 = service.getCacheStats();
 | 
			
		||||
    expect(stats2.priceCache.size).toEqual(1);
 | 
			
		||||
 | 
			
		||||
    // Fetch again (should hit cache - much faster)
 | 
			
		||||
    const start2 = Date.now();
 | 
			
		||||
    await service.getPrice('AAPL');
 | 
			
		||||
    const duration2 = Date.now() - start2;
 | 
			
		||||
 | 
			
		||||
    expect(duration2).toBeLessThan(duration1);
 | 
			
		||||
 | 
			
		||||
    console.log('\n⚡ Cache Performance:');
 | 
			
		||||
    console.log(`  First fetch: ${duration1}ms`);
 | 
			
		||||
    console.log(`  Cached fetch: ${duration2}ms`);
 | 
			
		||||
    console.log(`  Speedup: ${Math.round(duration1 / duration2)}x`);
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
tap.test('StockDataService - Fundamentals Fetching', async () => {
 | 
			
		||||
  const service = new opendata.StockDataService();
 | 
			
		||||
  const secProvider = new opendata.SecEdgarProvider({
 | 
			
		||||
    userAgent: TEST_USER_AGENT
 | 
			
		||||
  });
 | 
			
		||||
  service.registerFundamentalsProvider(secProvider);
 | 
			
		||||
 | 
			
		||||
  await tap.test('should fetch single fundamentals', async () => {
 | 
			
		||||
    const fundamentals = await service.getFundamentals('AAPL');
 | 
			
		||||
 | 
			
		||||
    expect(fundamentals).toBeDefined();
 | 
			
		||||
    expect(fundamentals.ticker).toEqual('AAPL');
 | 
			
		||||
    expect(fundamentals.companyName).toEqual('Apple Inc.');
 | 
			
		||||
    expect(fundamentals.provider).toEqual('SEC EDGAR');
 | 
			
		||||
    expect(fundamentals.earningsPerShareDiluted).toBeGreaterThan(0);
 | 
			
		||||
    expect(fundamentals.sharesOutstanding).toBeGreaterThan(0);
 | 
			
		||||
 | 
			
		||||
    console.log('\n📊 Single Fundamentals:');
 | 
			
		||||
    console.log(`  ${fundamentals.ticker}: ${fundamentals.companyName}`);
 | 
			
		||||
    console.log(`  EPS: $${fundamentals.earningsPerShareDiluted?.toFixed(2)}`);
 | 
			
		||||
    console.log(`  Shares: ${(fundamentals.sharesOutstanding! / 1_000_000_000).toFixed(2)}B`);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  await tap.test('should fetch batch fundamentals', async () => {
 | 
			
		||||
    const fundamentals = await service.getBatchFundamentals(['AAPL', 'MSFT']);
 | 
			
		||||
 | 
			
		||||
    expect(fundamentals).toBeInstanceOf(Array);
 | 
			
		||||
    expect(fundamentals.length).toEqual(2);
 | 
			
		||||
 | 
			
		||||
    console.log('\n📊 Batch Fundamentals:');
 | 
			
		||||
    fundamentals.forEach(f => {
 | 
			
		||||
      console.log(`  ${f.ticker}: ${f.companyName} - EPS: $${f.earningsPerShareDiluted?.toFixed(2)}`);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  await tap.test('should cache fundamentals', async () => {
 | 
			
		||||
    // Clear cache
 | 
			
		||||
    service.clearCache();
 | 
			
		||||
 | 
			
		||||
    const stats1 = service.getCacheStats();
 | 
			
		||||
    expect(stats1.fundamentalsCache.size).toEqual(0);
 | 
			
		||||
 | 
			
		||||
    // Fetch fundamentals (should hit API)
 | 
			
		||||
    await service.getFundamentals('AAPL');
 | 
			
		||||
 | 
			
		||||
    const stats2 = service.getCacheStats();
 | 
			
		||||
    expect(stats2.fundamentalsCache.size).toEqual(1);
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
tap.test('StockDataService - Complete Stock Data', async () => {
 | 
			
		||||
  const service = new opendata.StockDataService();
 | 
			
		||||
 | 
			
		||||
  // Register both providers
 | 
			
		||||
  const yahooProvider = new opendata.YahooFinanceProvider();
 | 
			
		||||
  const secProvider = new opendata.SecEdgarProvider({
 | 
			
		||||
    userAgent: TEST_USER_AGENT
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  service.registerPriceProvider(yahooProvider);
 | 
			
		||||
  service.registerFundamentalsProvider(secProvider);
 | 
			
		||||
 | 
			
		||||
  await tap.test('should fetch complete stock data with string', async () => {
 | 
			
		||||
    const data = await service.getStockData('AAPL');
 | 
			
		||||
 | 
			
		||||
    expect(data).toBeDefined();
 | 
			
		||||
    expect(data.ticker).toEqual('AAPL');
 | 
			
		||||
    expect(data.price).toBeDefined();
 | 
			
		||||
    expect(data.price.ticker).toEqual('AAPL');
 | 
			
		||||
    expect(data.fundamentals).toBeDefined();
 | 
			
		||||
    expect(data.fundamentals?.ticker).toEqual('AAPL');
 | 
			
		||||
    expect(data.fetchedAt).toBeInstanceOf(Date);
 | 
			
		||||
 | 
			
		||||
    // Check automatic enrichment
 | 
			
		||||
    expect(data.fundamentals?.marketCap).toBeDefined();
 | 
			
		||||
    expect(data.fundamentals?.priceToEarnings).toBeDefined();
 | 
			
		||||
    expect(data.fundamentals?.marketCap).toBeGreaterThan(0);
 | 
			
		||||
    expect(data.fundamentals?.priceToEarnings).toBeGreaterThan(0);
 | 
			
		||||
 | 
			
		||||
    console.log('\n✨ Complete Stock Data (Auto-Enriched):');
 | 
			
		||||
    console.log(`  ${data.ticker}: ${data.fundamentals?.companyName}`);
 | 
			
		||||
    console.log(`  Price: $${data.price.price.toFixed(2)}`);
 | 
			
		||||
    console.log(`  Market Cap: $${(data.fundamentals!.marketCap! / 1_000_000_000_000).toFixed(2)}T`);
 | 
			
		||||
    console.log(`  P/E Ratio: ${data.fundamentals!.priceToEarnings!.toFixed(2)}`);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  await tap.test('should fetch complete stock data with request object', async () => {
 | 
			
		||||
    const data = await service.getStockData({
 | 
			
		||||
      ticker: 'MSFT',
 | 
			
		||||
      includeFundamentals: true,
 | 
			
		||||
      enrichFundamentals: true
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    expect(data).toBeDefined();
 | 
			
		||||
    expect(data.ticker).toEqual('MSFT');
 | 
			
		||||
    expect(data.price).toBeDefined();
 | 
			
		||||
    expect(data.fundamentals).toBeDefined();
 | 
			
		||||
    expect(data.fundamentals?.marketCap).toBeDefined();
 | 
			
		||||
    expect(data.fundamentals?.priceToEarnings).toBeDefined();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  await tap.test('should fetch complete stock data without fundamentals', async () => {
 | 
			
		||||
    const data = await service.getStockData({
 | 
			
		||||
      ticker: 'GOOGL',
 | 
			
		||||
      includeFundamentals: false
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    expect(data).toBeDefined();
 | 
			
		||||
    expect(data.ticker).toEqual('GOOGL');
 | 
			
		||||
    expect(data.price).toBeDefined();
 | 
			
		||||
    expect(data.fundamentals).toBeUndefined();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  await tap.test('should handle fundamentals fetch failure gracefully', async () => {
 | 
			
		||||
    // Try a ticker that might not have fundamentals
 | 
			
		||||
    const data = await service.getStockData({
 | 
			
		||||
      ticker: 'BTC-USD', // Crypto - no SEC filings
 | 
			
		||||
      includeFundamentals: true
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    expect(data).toBeDefined();
 | 
			
		||||
    expect(data.price).toBeDefined();
 | 
			
		||||
    // Fundamentals might be undefined due to error
 | 
			
		||||
    console.log(`\n⚠️  ${data.ticker} - Price available, Fundamentals: ${data.fundamentals ? 'Yes' : 'No'}`);
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
tap.test('StockDataService - Batch Complete Stock Data', async () => {
 | 
			
		||||
  const service = new opendata.StockDataService();
 | 
			
		||||
 | 
			
		||||
  const yahooProvider = new opendata.YahooFinanceProvider();
 | 
			
		||||
  const secProvider = new opendata.SecEdgarProvider({
 | 
			
		||||
    userAgent: TEST_USER_AGENT
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  service.registerPriceProvider(yahooProvider);
 | 
			
		||||
  service.registerFundamentalsProvider(secProvider);
 | 
			
		||||
 | 
			
		||||
  await tap.test('should fetch batch complete data with array', async () => {
 | 
			
		||||
    const data = await service.getBatchStockData(['AAPL', 'MSFT']);
 | 
			
		||||
 | 
			
		||||
    expect(data).toBeInstanceOf(Array);
 | 
			
		||||
    expect(data.length).toEqual(2);
 | 
			
		||||
 | 
			
		||||
    data.forEach(stock => {
 | 
			
		||||
      expect(stock.ticker).toBeDefined();
 | 
			
		||||
      expect(stock.price).toBeDefined();
 | 
			
		||||
      expect(stock.fundamentals).toBeDefined();
 | 
			
		||||
      expect(stock.fundamentals?.marketCap).toBeGreaterThan(0);
 | 
			
		||||
      expect(stock.fundamentals?.priceToEarnings).toBeGreaterThan(0);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    console.log('\n✨ Batch Complete Data:');
 | 
			
		||||
    data.forEach(stock => {
 | 
			
		||||
      console.log(`  ${stock.ticker}: Price $${stock.price.price.toFixed(2)}, P/E ${stock.fundamentals!.priceToEarnings!.toFixed(2)}`);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  await tap.test('should fetch batch complete data with request object', async () => {
 | 
			
		||||
    const data = await service.getBatchStockData({
 | 
			
		||||
      tickers: ['AAPL', 'GOOGL'],
 | 
			
		||||
      includeFundamentals: true,
 | 
			
		||||
      enrichFundamentals: true
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    expect(data).toBeInstanceOf(Array);
 | 
			
		||||
    expect(data.length).toEqual(2);
 | 
			
		||||
 | 
			
		||||
    data.forEach(stock => {
 | 
			
		||||
      expect(stock.fundamentals?.marketCap).toBeGreaterThan(0);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  await tap.test('should fetch batch without enrichment', async () => {
 | 
			
		||||
    const data = await service.getBatchStockData({
 | 
			
		||||
      tickers: ['AAPL', 'MSFT'],
 | 
			
		||||
      includeFundamentals: true,
 | 
			
		||||
      enrichFundamentals: false
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    expect(data).toBeInstanceOf(Array);
 | 
			
		||||
 | 
			
		||||
    // Check that fundamentals exist but enrichment might not be complete
 | 
			
		||||
    data.forEach(stock => {
 | 
			
		||||
      if (stock.fundamentals) {
 | 
			
		||||
        expect(stock.fundamentals.ticker).toBeDefined();
 | 
			
		||||
        expect(stock.fundamentals.companyName).toBeDefined();
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
tap.test('StockDataService - Health & Statistics', async () => {
 | 
			
		||||
  const service = new opendata.StockDataService();
 | 
			
		||||
 | 
			
		||||
  const yahooProvider = new opendata.YahooFinanceProvider();
 | 
			
		||||
  const secProvider = new opendata.SecEdgarProvider({
 | 
			
		||||
    userAgent: TEST_USER_AGENT
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  service.registerPriceProvider(yahooProvider);
 | 
			
		||||
  service.registerFundamentalsProvider(secProvider);
 | 
			
		||||
 | 
			
		||||
  await tap.test('should check providers health', async () => {
 | 
			
		||||
    const health = await service.checkProvidersHealth();
 | 
			
		||||
 | 
			
		||||
    expect(health.size).toEqual(2);
 | 
			
		||||
    expect(health.get('Yahoo Finance (price)')).toBe(true);
 | 
			
		||||
    expect(health.get('SEC EDGAR (fundamentals)')).toBe(true);
 | 
			
		||||
 | 
			
		||||
    console.log('\n💚 Provider Health:');
 | 
			
		||||
    health.forEach((isHealthy, name) => {
 | 
			
		||||
      console.log(`  ${name}: ${isHealthy ? '✅ Healthy' : '❌ Unhealthy'}`);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  await tap.test('should track provider statistics', async () => {
 | 
			
		||||
    // Make some requests to generate stats
 | 
			
		||||
    await service.getPrice('AAPL');
 | 
			
		||||
    await service.getFundamentals('AAPL');
 | 
			
		||||
 | 
			
		||||
    const stats = service.getProviderStats();
 | 
			
		||||
 | 
			
		||||
    expect(stats.size).toEqual(2);
 | 
			
		||||
 | 
			
		||||
    const yahooStats = stats.get('Yahoo Finance');
 | 
			
		||||
    expect(yahooStats).toBeDefined();
 | 
			
		||||
    expect(yahooStats!.type).toEqual('price');
 | 
			
		||||
    expect(yahooStats!.successCount).toBeGreaterThan(0);
 | 
			
		||||
 | 
			
		||||
    const secStats = stats.get('SEC EDGAR');
 | 
			
		||||
    expect(secStats).toBeDefined();
 | 
			
		||||
    expect(secStats!.type).toEqual('fundamentals');
 | 
			
		||||
    expect(secStats!.successCount).toBeGreaterThan(0);
 | 
			
		||||
 | 
			
		||||
    console.log('\n📈 Provider Statistics:');
 | 
			
		||||
    stats.forEach((stat, name) => {
 | 
			
		||||
      console.log(`  ${name} (${stat.type}): Success=${stat.successCount}, Errors=${stat.errorCount}`);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  await tap.test('should clear all caches', async () => {
 | 
			
		||||
    service.clearCache();
 | 
			
		||||
 | 
			
		||||
    const stats = service.getCacheStats();
 | 
			
		||||
    expect(stats.priceCache.size).toEqual(0);
 | 
			
		||||
    expect(stats.fundamentalsCache.size).toEqual(0);
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
tap.test('StockDataService - Error Handling', async () => {
 | 
			
		||||
  await tap.test('should throw error when no price provider available', async () => {
 | 
			
		||||
    const service = new opendata.StockDataService();
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      await service.getPrice('AAPL');
 | 
			
		||||
      throw new Error('Should have thrown error');
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      expect(error.message).toContain('No price providers available');
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  await tap.test('should throw error when no fundamentals provider available', async () => {
 | 
			
		||||
    const service = new opendata.StockDataService();
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      await service.getFundamentals('AAPL');
 | 
			
		||||
      throw new Error('Should have thrown error');
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      expect(error.message).toContain('No fundamentals providers available');
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  await tap.test('should handle invalid ticker for price', async () => {
 | 
			
		||||
    const service = new opendata.StockDataService();
 | 
			
		||||
    const yahooProvider = new opendata.YahooFinanceProvider();
 | 
			
		||||
    service.registerPriceProvider(yahooProvider);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      await service.getPrice('INVALIDTICKER123456');
 | 
			
		||||
      throw new Error('Should have thrown error');
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      expect(error.message).toContain('Failed to fetch price');
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  await tap.test('should handle invalid ticker for fundamentals', async () => {
 | 
			
		||||
    const service = new opendata.StockDataService();
 | 
			
		||||
    const secProvider = new opendata.SecEdgarProvider({
 | 
			
		||||
      userAgent: TEST_USER_AGENT
 | 
			
		||||
    });
 | 
			
		||||
    service.registerFundamentalsProvider(secProvider);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      await service.getFundamentals('INVALIDTICKER123456');
 | 
			
		||||
      throw new Error('Should have thrown error');
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      expect(error.message).toContain('CIK not found');
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default tap.start();
 | 
			
		||||
@@ -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.'
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
// node native scope
 | 
			
		||||
import * as path from 'path';
 | 
			
		||||
import * as path from 'node:path';
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
  path,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										296
									
								
								ts/stocks/classes.baseproviderservice.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										296
									
								
								ts/stocks/classes.baseproviderservice.ts
									
									
									
									
									
										Normal 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();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										404
									
								
								ts/stocks/classes.fundamentalsservice.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										404
									
								
								ts/stocks/classes.fundamentalsservice.ts
									
									
									
									
									
										Normal 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';
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										647
									
								
								ts/stocks/classes.stockdataservice.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										647
									
								
								ts/stocks/classes.stockdataservice.ts
									
									
									
									
									
										Normal 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
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -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';
 | 
			
		||||
							
								
								
									
										102
									
								
								ts/stocks/interfaces/fundamentals.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								ts/stocks/interfaces/fundamentals.ts
									
									
									
									
									
										Normal 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[];
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										65
									
								
								ts/stocks/interfaces/stockdata.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								ts/stocks/interfaces/stockdata.ts
									
									
									
									
									
										Normal 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;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										757
									
								
								ts/stocks/providers/provider.coingecko.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										757
									
								
								ts/stocks/providers/provider.coingecko.ts
									
									
									
									
									
										Normal 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!;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										467
									
								
								ts/stocks/providers/provider.secedgar.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										467
									
								
								ts/stocks/providers/provider.secedgar.ts
									
									
									
									
									
										Normal 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');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user