Compare commits
5 Commits
Author | SHA1 | Date | |
---|---|---|---|
3dbf194320 | |||
a29a50e825 | |||
daeff1ce93 | |||
298172c00b | |||
df677b38fb |
20
package.json
20
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@fin.cx/opendata",
|
"name": "@fin.cx/opendata",
|
||||||
"version": "1.5.3",
|
"version": "1.5.4",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A comprehensive TypeScript library that manages open business data for German companies by integrating MongoDB, processing JSONL bulk data, and automating browser interactions for Handelsregister data retrieval.",
|
"description": "A comprehensive TypeScript library that manages open business data for German companies by integrating MongoDB, processing JSONL bulk data, and automating browser interactions for Handelsregister data retrieval.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
@@ -9,34 +9,34 @@
|
|||||||
"author": "Task Venture Capital GmbH",
|
"author": "Task Venture Capital GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(tstest test/ --web)",
|
"test": "(tstest test/ --verbose)",
|
||||||
"build": "(tsbuild --web --allowimplicitany)",
|
"build": "(tsbuild --web --allowimplicitany)",
|
||||||
"buildDocs": "(tsdoc)"
|
"buildDocs": "(tsdoc)"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^2.3.2",
|
"@git.zone/tsbuild": "^2.6.4",
|
||||||
"@git.zone/tsbundle": "^2.2.5",
|
"@git.zone/tsbundle": "^2.5.1",
|
||||||
"@git.zone/tsrun": "^1.3.3",
|
"@git.zone/tsrun": "^1.3.3",
|
||||||
"@git.zone/tstest": "^1.0.96",
|
"@git.zone/tstest": "^2.3.1",
|
||||||
"@push.rocks/tapbundle": "^5.6.2",
|
|
||||||
"@types/node": "^22.14.0"
|
"@types/node": "^22.14.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@push.rocks/lik": "^6.1.0",
|
"@push.rocks/lik": "^6.2.2",
|
||||||
"@push.rocks/qenv": "^6.1.0",
|
"@push.rocks/qenv": "^6.1.0",
|
||||||
"@push.rocks/smartarchive": "^4.0.39",
|
"@push.rocks/smartarchive": "^4.0.39",
|
||||||
"@push.rocks/smartarray": "^1.1.0",
|
"@push.rocks/smartarray": "^1.1.0",
|
||||||
"@push.rocks/smartbrowser": "^2.0.8",
|
"@push.rocks/smartbrowser": "^2.0.8",
|
||||||
"@push.rocks/smartdata": "^5.2.12",
|
"@push.rocks/smartdata": "^5.15.1",
|
||||||
"@push.rocks/smartdelay": "^3.0.5",
|
"@push.rocks/smartdelay": "^3.0.5",
|
||||||
"@push.rocks/smartfile": "^11.2.0",
|
"@push.rocks/smartfile": "^11.2.5",
|
||||||
|
"@push.rocks/smartlog": "^3.1.8",
|
||||||
"@push.rocks/smartpath": "^5.0.18",
|
"@push.rocks/smartpath": "^5.0.18",
|
||||||
"@push.rocks/smartpromise": "^4.2.3",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"@push.rocks/smartrequest": "^2.1.0",
|
"@push.rocks/smartrequest": "^2.1.0",
|
||||||
"@push.rocks/smartstream": "^3.2.5",
|
"@push.rocks/smartstream": "^3.2.5",
|
||||||
"@push.rocks/smartunique": "^3.0.9",
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
"@push.rocks/smartxml": "^1.1.1",
|
"@push.rocks/smartxml": "^1.1.1",
|
||||||
"@tsclass/tsclass": "^8.2.0"
|
"@tsclass/tsclass": "^9.2.0"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
3704
pnpm-lock.yaml
generated
3704
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
|||||||
|
# OpenData Project Hints
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- **Provider Pattern**: Each stock data source implements the `IStockProvider` interface
|
||||||
|
- **Service Registry**: `StockPriceService` manages providers with priority-based selection
|
||||||
|
- **Caching**: Built-in cache with configurable TTL to reduce API calls
|
||||||
|
- **Fallback Logic**: Automatic failover between providers if one fails
|
||||||
|
|
||||||
|
### Yahoo Finance Provider Notes
|
||||||
|
- Uses public API endpoints (no authentication required)
|
||||||
|
- Two main endpoints:
|
||||||
|
- `/v8/finance/chart/{ticker}` - Single ticker with full data
|
||||||
|
- `/v8/finance/spark?symbols={tickers}` - Multiple tickers with basic data
|
||||||
|
- Response data is in `response.body` when using smartrequest
|
||||||
|
- Requires User-Agent header to avoid rate limiting
|
||||||
|
|
||||||
|
### Usage Example
|
||||||
|
```typescript
|
||||||
|
import { StockPriceService, YahooFinanceProvider } from '@fin.cx/opendata';
|
||||||
|
|
||||||
|
const stockService = new StockPriceService({ ttl: 60000 });
|
||||||
|
const yahooProvider = new YahooFinanceProvider();
|
||||||
|
stockService.register(yahooProvider);
|
||||||
|
|
||||||
|
const price = await stockService.getPrice({ ticker: 'AAPL' });
|
||||||
|
console.log(`${price.ticker}: $${price.price}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### Future Providers
|
||||||
|
To add a new provider:
|
||||||
|
1. Create `ts/stocks/providers/provider.{name}.ts`
|
||||||
|
2. Implement the `IStockProvider` interface
|
||||||
|
3. Register with `StockPriceService`
|
||||||
|
4. No changes needed to existing code
|
193
readme.plan.md
Normal file
193
readme.plan.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# Stock Prices Module Implementation Plan
|
||||||
|
|
||||||
|
Command to reread guidelines: Read /home/philkunz/.claude/CLAUDE.md
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Implementation of a stocks module for fetching current stock prices using various APIs. The architecture will support multiple providers, but we'll start with implementing only Yahoo Finance API. The design will make it easy to add additional providers (Alpha Vantage, IEX Cloud, etc.) in the future without changing the core architecture.
|
||||||
|
|
||||||
|
## Phase 1: Yahoo Finance Implementation
|
||||||
|
|
||||||
|
### 1.1 Research & Documentation
|
||||||
|
- [ ] Research Yahoo Finance API endpoints (no official API, using public endpoints)
|
||||||
|
- [ ] Document available data fields and formats
|
||||||
|
- [ ] Identify rate limits and restrictions
|
||||||
|
- [ ] Test endpoints manually with curl
|
||||||
|
|
||||||
|
### 1.2 Module Structure
|
||||||
|
```
|
||||||
|
ts/
|
||||||
|
├── index.ts # Main exports
|
||||||
|
├── plugins.ts # External dependencies
|
||||||
|
└── stocks/
|
||||||
|
├── index.ts # Stocks module exports
|
||||||
|
├── classes.stockservice.ts # Main StockPriceService class
|
||||||
|
├── interfaces/
|
||||||
|
│ ├── stockprice.ts # IStockPrice interface
|
||||||
|
│ └── provider.ts # IStockProvider interface (for all providers)
|
||||||
|
└── providers/
|
||||||
|
├── provider.yahoo.ts # Yahoo Finance implementation
|
||||||
|
└── (future: provider.alphavantage.ts, provider.iex.ts, etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Core Interfaces
|
||||||
|
```typescript
|
||||||
|
// IStockPrice - Standardized stock price data
|
||||||
|
interface IStockPrice {
|
||||||
|
ticker: string;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
change: number;
|
||||||
|
changePercent: number;
|
||||||
|
timestamp: Date;
|
||||||
|
provider: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IStockProvider - Provider implementation contract
|
||||||
|
interface IStockProvider {
|
||||||
|
name: string;
|
||||||
|
fetchPrice(ticker: string): Promise<IStockPrice>;
|
||||||
|
fetchPrices(tickers: string[]): Promise<IStockPrice[]>;
|
||||||
|
isAvailable(): Promise<boolean>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 Yahoo Finance Provider Implementation
|
||||||
|
- [ ] Create YahooFinanceProvider class
|
||||||
|
- [ ] Implement HTTP requests to Yahoo Finance endpoints
|
||||||
|
- [ ] Parse response data into IStockPrice format
|
||||||
|
- [ ] Handle errors and edge cases
|
||||||
|
- [ ] Add request throttling/rate limiting
|
||||||
|
|
||||||
|
### 1.5 Main Service Class
|
||||||
|
- [ ] Create StockPriceService class with provider registry
|
||||||
|
- [ ] Implement provider interface for pluggable providers
|
||||||
|
- [ ] Register Yahoo provider (with ability to add more later)
|
||||||
|
- [ ] Add method for single ticker lookup
|
||||||
|
- [ ] Add method for batch ticker lookup
|
||||||
|
- [ ] Implement error handling with graceful degradation
|
||||||
|
- [ ] Design fallback mechanism (ready for multiple providers)
|
||||||
|
|
||||||
|
## Phase 2: Core Features
|
||||||
|
|
||||||
|
### 2.1 Service Architecture
|
||||||
|
- [ ] Create provider registry pattern for managing multiple providers
|
||||||
|
- [ ] Implement provider priority and selection logic
|
||||||
|
- [ ] Design provider health check interface
|
||||||
|
- [ ] Create provider configuration system
|
||||||
|
- [ ] Implement provider discovery mechanism
|
||||||
|
- [ ] Add provider capability querying (which tickers/markets supported)
|
||||||
|
|
||||||
|
## Phase 3: Advanced Features
|
||||||
|
|
||||||
|
### 3.1 Caching System
|
||||||
|
- [ ] Design cache interface
|
||||||
|
- [ ] Implement in-memory cache with TTL
|
||||||
|
- [ ] Add cache invalidation logic
|
||||||
|
- [ ] Make cache configurable per ticker
|
||||||
|
|
||||||
|
### 3.2 Configuration
|
||||||
|
- [ ] Provider configuration (timeout, retry settings)
|
||||||
|
- [ ] Cache configuration (TTL, max entries)
|
||||||
|
- [ ] Request timeout configuration
|
||||||
|
- [ ] Error handling configuration
|
||||||
|
|
||||||
|
### 3.3 Error Handling
|
||||||
|
- [ ] Define custom error types
|
||||||
|
- [ ] Implement retry logic with exponential backoff
|
||||||
|
- [ ] Add circuit breaker pattern for failing providers
|
||||||
|
- [ ] Comprehensive error logging
|
||||||
|
|
||||||
|
## Phase 4: Testing
|
||||||
|
|
||||||
|
### 4.1 Unit Tests
|
||||||
|
- [ ] Test each provider independently
|
||||||
|
- [ ] Mock HTTP requests for predictable testing
|
||||||
|
- [ ] Test error scenarios
|
||||||
|
- [ ] Test data transformation logic
|
||||||
|
|
||||||
|
### 4.2 Integration Tests
|
||||||
|
- [ ] Test with real API calls (rate limit aware)
|
||||||
|
- [ ] Test provider fallback scenarios
|
||||||
|
- [ ] Test batch operations
|
||||||
|
- [ ] Test cache behavior
|
||||||
|
|
||||||
|
### 4.3 Performance Tests
|
||||||
|
- [ ] Measure response times
|
||||||
|
- [ ] Test concurrent request handling
|
||||||
|
- [ ] Validate cache effectiveness
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. **Week 1: Yahoo Finance Provider**
|
||||||
|
- Research and test Yahoo endpoints
|
||||||
|
- Implement basic provider and service
|
||||||
|
- Create core interfaces
|
||||||
|
- Basic error handling
|
||||||
|
|
||||||
|
2. **Week 2: Service Architecture**
|
||||||
|
- Create extensible provider system
|
||||||
|
- Implement provider interface
|
||||||
|
- Add provider registration
|
||||||
|
|
||||||
|
3. **Week 3: Advanced Features**
|
||||||
|
- Implement caching system
|
||||||
|
- Add configuration management
|
||||||
|
- Enhance error handling
|
||||||
|
|
||||||
|
4. **Week 4: Testing & Documentation**
|
||||||
|
- Write comprehensive tests
|
||||||
|
- Create usage documentation
|
||||||
|
- Performance optimization
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Required
|
||||||
|
- `@push.rocks/smartrequest` - HTTP requests
|
||||||
|
- `@push.rocks/smartpromise` - Promise utilities
|
||||||
|
- `@push.rocks/smartlog` - Logging
|
||||||
|
|
||||||
|
### Development
|
||||||
|
- `@git.zone/tstest` - Testing framework
|
||||||
|
- `@git.zone/tsrun` - TypeScript execution
|
||||||
|
|
||||||
|
## API Endpoints Research
|
||||||
|
|
||||||
|
### Yahoo Finance
|
||||||
|
- Base URL: `https://query1.finance.yahoo.com/v8/finance/chart/{ticker}`
|
||||||
|
- No authentication required
|
||||||
|
- Returns JSON with price data
|
||||||
|
- Rate limits unknown (need to test)
|
||||||
|
- Alternative endpoints to explore:
|
||||||
|
- `/v7/finance/quote` - Simplified quote data
|
||||||
|
- `/v10/finance/quoteSummary` - Detailed company data
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
1. Can fetch current stock prices for any valid ticker
|
||||||
|
2. Extensible architecture for future providers
|
||||||
|
3. Response time < 1 second for cached data
|
||||||
|
4. Response time < 3 seconds for fresh data
|
||||||
|
5. Proper error handling and recovery
|
||||||
|
6. Comprehensive test coverage (>80%)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Yahoo Finance provides free stock data without authentication
|
||||||
|
- **Architecture designed for multiple providers**: While only implementing Yahoo Finance initially, all interfaces, classes, and patterns are designed to support multiple stock data providers
|
||||||
|
- The provider registry pattern allows adding new providers without modifying existing code
|
||||||
|
- Each provider implements the same IStockProvider interface for consistency
|
||||||
|
- Future providers can be added by simply creating a new provider class and registering it
|
||||||
|
- Implement proper TypeScript types for all data structures
|
||||||
|
- Follow the project's coding standards (prefix interfaces with 'I')
|
||||||
|
- Use plugins.ts for all external dependencies
|
||||||
|
- Keep filenames lowercase
|
||||||
|
- Write tests using @git.zone/tstest with smartexpect syntax
|
||||||
|
- Focus on clean, extensible architecture for future growth
|
||||||
|
|
||||||
|
## Future Provider Addition Example
|
||||||
|
|
||||||
|
When ready to add a new provider (e.g., Alpha Vantage), the process will be:
|
||||||
|
1. Create `ts/stocks/providers/provider.alphavantage.ts`
|
||||||
|
2. Implement the `IStockProvider` interface
|
||||||
|
3. Register the provider in the StockPriceService
|
||||||
|
4. No changes needed to existing code or interfaces
|
@@ -1,4 +1,4 @@
|
|||||||
import { expect, expectAsync, tap } from '@push.rocks/tapbundle';
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
import * as opendata from '../ts/index.js'
|
import * as opendata from '../ts/index.js'
|
||||||
|
|
||||||
import { BusinessRecord } from '../ts/classes.businessrecord.js';
|
import { BusinessRecord } from '../ts/classes.businessrecord.js';
|
||||||
@@ -34,7 +34,7 @@ tap.test('should get the data for a specific company', async () => {
|
|||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should stop the instance', async () => {
|
tap.test('should stop the instance', async (toolsArg) => {
|
||||||
await testOpenDataInstance.stop();
|
await testOpenDataInstance.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
280
test/test.stocks.ts
Normal file
280
test/test.stocks.ts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as opendata from '../ts/index.js';
|
||||||
|
|
||||||
|
// Test data
|
||||||
|
const testTickers = ['AAPL', 'MSFT', 'GOOGL'];
|
||||||
|
const invalidTicker = 'INVALID_TICKER_XYZ';
|
||||||
|
|
||||||
|
let stockService: opendata.StockPriceService;
|
||||||
|
let yahooProvider: opendata.YahooFinanceProvider;
|
||||||
|
|
||||||
|
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 YahooFinanceProvider instance', async () => {
|
||||||
|
yahooProvider = new opendata.YahooFinanceProvider({
|
||||||
|
enabled: true,
|
||||||
|
timeout: 10000,
|
||||||
|
retryAttempts: 2,
|
||||||
|
retryDelay: 500
|
||||||
|
});
|
||||||
|
expect(yahooProvider).toBeInstanceOf(opendata.YahooFinanceProvider);
|
||||||
|
expect(yahooProvider.name).toEqual('Yahoo Finance');
|
||||||
|
expect(yahooProvider.requiresAuth).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should register Yahoo provider with the service', async () => {
|
||||||
|
stockService.register(yahooProvider);
|
||||||
|
const providers = stockService.getAllProviders();
|
||||||
|
expect(providers).toContainEqual(yahooProvider);
|
||||||
|
expect(stockService.getProvider('Yahoo Finance')).toEqual(yahooProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should check provider health', async () => {
|
||||||
|
const health = await stockService.checkProvidersHealth();
|
||||||
|
expect(health.get('Yahoo Finance')).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should fetch single stock price', async () => {
|
||||||
|
const price = await stockService.getPrice({ ticker: 'AAPL' });
|
||||||
|
|
||||||
|
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('AAPL');
|
||||||
|
expect(price.price).toBeGreaterThan(0);
|
||||||
|
expect(price.provider).toEqual('Yahoo Finance');
|
||||||
|
expect(price.timestamp).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should fetch multiple stock prices', async () => {
|
||||||
|
const prices = await stockService.getPrices({
|
||||||
|
tickers: testTickers
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prices).toBeArray();
|
||||||
|
expect(prices.length).toBeGreaterThan(0);
|
||||||
|
expect(prices.length).toBeLessThanOrEqual(testTickers.length);
|
||||||
|
|
||||||
|
for (const price of prices) {
|
||||||
|
expect(testTickers).toContain(price.ticker);
|
||||||
|
expect(price.price).toBeGreaterThan(0);
|
||||||
|
expect(price.provider).toEqual('Yahoo Finance');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should serve cached prices on subsequent requests', async () => {
|
||||||
|
// First request - should hit the API
|
||||||
|
const firstRequest = await stockService.getPrice({ ticker: 'AAPL' });
|
||||||
|
|
||||||
|
// Second request - should be served from cache
|
||||||
|
const secondRequest = await stockService.getPrice({ ticker: 'AAPL' });
|
||||||
|
|
||||||
|
expect(secondRequest.ticker).toEqual(firstRequest.ticker);
|
||||||
|
expect(secondRequest.price).toEqual(firstRequest.price);
|
||||||
|
expect(secondRequest.timestamp).toEqual(firstRequest.timestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle invalid ticker gracefully', async () => {
|
||||||
|
try {
|
||||||
|
await stockService.getPrice({ ticker: invalidTicker });
|
||||||
|
throw new Error('Should have thrown an error for invalid ticker');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error.message).toInclude('Failed to fetch price');
|
||||||
|
expect(error.message).toInclude(invalidTicker);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should support market checking', async () => {
|
||||||
|
expect(yahooProvider.supportsMarket('US')).toEqual(true);
|
||||||
|
expect(yahooProvider.supportsMarket('UK')).toEqual(true);
|
||||||
|
expect(yahooProvider.supportsMarket('DE')).toEqual(true);
|
||||||
|
expect(yahooProvider.supportsMarket('INVALID')).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should validate ticker format', async () => {
|
||||||
|
expect(yahooProvider.supportsTicker('AAPL')).toEqual(true);
|
||||||
|
expect(yahooProvider.supportsTicker('MSFT')).toEqual(true);
|
||||||
|
expect(yahooProvider.supportsTicker('BRK.B')).toEqual(true);
|
||||||
|
expect(yahooProvider.supportsTicker('123456789012')).toEqual(false);
|
||||||
|
expect(yahooProvider.supportsTicker('invalid@ticker')).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should get provider statistics', async () => {
|
||||||
|
const stats = stockService.getProviderStats();
|
||||||
|
const yahooStats = stats.get('Yahoo Finance');
|
||||||
|
|
||||||
|
expect(yahooStats).not.toEqual(undefined);
|
||||||
|
expect(yahooStats.successCount).toBeGreaterThan(0);
|
||||||
|
expect(yahooStats.errorCount).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should clear cache', async () => {
|
||||||
|
// Ensure we have something in cache
|
||||||
|
await stockService.getPrice({ ticker: 'AAPL' });
|
||||||
|
|
||||||
|
// Clear cache
|
||||||
|
stockService.clearCache();
|
||||||
|
|
||||||
|
// Next request should hit the API again (we can't directly test this,
|
||||||
|
// but we can verify the method doesn't throw)
|
||||||
|
const price = await stockService.getPrice({ ticker: 'AAPL' });
|
||||||
|
expect(price).not.toEqual(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle provider unavailability', async () => {
|
||||||
|
// Clear cache first to ensure we don't get cached results
|
||||||
|
stockService.clearCache();
|
||||||
|
|
||||||
|
// Unregister all providers
|
||||||
|
stockService.unregister('Yahoo Finance');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use a different ticker to avoid any caching
|
||||||
|
await stockService.getPrice({ ticker: 'TSLA' });
|
||||||
|
throw new Error('Should have thrown an error with no providers');
|
||||||
|
} catch (error: any) {
|
||||||
|
expect(error.message).toEqual('No stock price providers available');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should fetch major market indicators', async () => {
|
||||||
|
// Re-register provider if needed
|
||||||
|
if (!stockService.getProvider('Yahoo Finance')) {
|
||||||
|
stockService.register(yahooProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
const marketIndicators = [
|
||||||
|
// Indices
|
||||||
|
{ ticker: '^GSPC', name: 'S&P 500' },
|
||||||
|
{ ticker: '^IXIC', name: 'NASDAQ' },
|
||||||
|
{ ticker: '^DJI', name: 'DOW Jones' },
|
||||||
|
// Tech Stocks
|
||||||
|
{ ticker: 'AAPL', name: 'Apple' },
|
||||||
|
{ ticker: 'AMZN', name: 'Amazon' },
|
||||||
|
{ ticker: 'GOOGL', name: 'Google' },
|
||||||
|
{ ticker: 'META', name: 'Meta' },
|
||||||
|
{ ticker: 'MSFT', name: 'Microsoft' },
|
||||||
|
{ ticker: 'PLTR', name: 'Palantir' },
|
||||||
|
// Crypto
|
||||||
|
{ ticker: 'BTC-USD', name: 'Bitcoin' },
|
||||||
|
{ ticker: 'ETH-USD', name: 'Ethereum' },
|
||||||
|
{ ticker: 'ADA-USD', name: 'Cardano' },
|
||||||
|
// Forex & Commodities
|
||||||
|
{ ticker: 'EURUSD=X', name: 'EUR/USD' },
|
||||||
|
{ ticker: 'GC=F', name: 'Gold Futures' },
|
||||||
|
{ ticker: 'CL=F', name: 'Crude Oil Futures' }
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('\n📊 Current Market Values:');
|
||||||
|
console.log('═'.repeat(65));
|
||||||
|
|
||||||
|
// Fetch all prices in batch for better performance
|
||||||
|
try {
|
||||||
|
const prices = await stockService.getPrices({
|
||||||
|
tickers: marketIndicators.map(i => i.ticker)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a map for easy lookup
|
||||||
|
const priceMap = new Map(prices.map(p => [p.ticker, p]));
|
||||||
|
|
||||||
|
// Check which tickers are missing and fetch them individually
|
||||||
|
const missingTickers: typeof marketIndicators = [];
|
||||||
|
for (const indicator of marketIndicators) {
|
||||||
|
if (!priceMap.has(indicator.ticker)) {
|
||||||
|
missingTickers.push(indicator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch missing tickers individually
|
||||||
|
if (missingTickers.length > 0) {
|
||||||
|
for (const indicator of missingTickers) {
|
||||||
|
try {
|
||||||
|
const price = await stockService.getPrice({ ticker: indicator.ticker });
|
||||||
|
priceMap.set(indicator.ticker, price);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore individual errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display all results with section headers
|
||||||
|
let lastSection = '';
|
||||||
|
for (const indicator of marketIndicators) {
|
||||||
|
// Add section headers
|
||||||
|
if (indicator.ticker.startsWith('^') && lastSection !== 'indices') {
|
||||||
|
console.log('\n📈 Market Indices');
|
||||||
|
console.log('─'.repeat(65));
|
||||||
|
lastSection = 'indices';
|
||||||
|
} else if (['AAPL', 'AMZN', 'GOOGL', 'META', 'MSFT', 'PLTR'].includes(indicator.ticker) && lastSection !== 'stocks') {
|
||||||
|
console.log('\n💻 Tech Stocks');
|
||||||
|
console.log('─'.repeat(65));
|
||||||
|
lastSection = 'stocks';
|
||||||
|
} else if (indicator.ticker.includes('-USD') && lastSection !== 'crypto') {
|
||||||
|
console.log('\n🪙 Cryptocurrencies');
|
||||||
|
console.log('─'.repeat(65));
|
||||||
|
lastSection = 'crypto';
|
||||||
|
} else if ((indicator.ticker.includes('=') || indicator.ticker.includes('=F')) && lastSection !== 'forex') {
|
||||||
|
console.log('\n💱 Forex & Commodities');
|
||||||
|
console.log('─'.repeat(65));
|
||||||
|
lastSection = 'forex';
|
||||||
|
}
|
||||||
|
|
||||||
|
const price = priceMap.get(indicator.ticker);
|
||||||
|
if (price) {
|
||||||
|
const changeSymbol = price.change >= 0 ? '↑' : '↓';
|
||||||
|
const changeColor = price.change >= 0 ? '\x1b[32m' : '\x1b[31m'; // Green or Red
|
||||||
|
const resetColor = '\x1b[0m';
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`${indicator.name.padEnd(20)} ${price.price.toLocaleString('en-US', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: indicator.name.includes('coin') || indicator.name.includes('EUR') || indicator.name === 'Cardano' ? 4 : 2
|
||||||
|
}).padStart(12)} ${changeColor}${changeSymbol} ${price.change >= 0 ? '+' : ''}${price.change.toFixed(2)} (${price.changePercent >= 0 ? '+' : ''}${price.changePercent.toFixed(2)}%)${resetColor}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(`${indicator.name.padEnd(20)} Data not available`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Error fetching market data:', error);
|
||||||
|
// Fallback to individual fetches
|
||||||
|
for (const indicator of marketIndicators) {
|
||||||
|
try {
|
||||||
|
const price = await stockService.getPrice({ ticker: indicator.ticker });
|
||||||
|
const changeSymbol = price.change >= 0 ? '↑' : '↓';
|
||||||
|
const changeColor = price.change >= 0 ? '\x1b[32m' : '\x1b[31m';
|
||||||
|
const resetColor = '\x1b[0m';
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`${indicator.name.padEnd(20)} ${price.price.toLocaleString('en-US', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: indicator.name.includes('coin') || indicator.name.includes('EUR') || indicator.name === 'Cardano' ? 4 : 2
|
||||||
|
}).padStart(12)} ${changeColor}${changeSymbol} ${price.change >= 0 ? '+' : ''}${price.change.toFixed(2)} (${price.changePercent >= 0 ? '+' : ''}${price.changePercent.toFixed(2)}%)${resetColor}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`${indicator.name.padEnd(20)} Error fetching data`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('═'.repeat(65));
|
||||||
|
console.log(`Last updated: ${new Date().toLocaleString()}\n`);
|
||||||
|
|
||||||
|
// Test passes if we successfully fetch at least some indicators
|
||||||
|
expect(true).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
@@ -1,4 +1,4 @@
|
|||||||
import { expect, expectAsync, tap } from '@push.rocks/tapbundle';
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
import * as opendata from '../ts/index.js'
|
import * as opendata from '../ts/index.js'
|
||||||
|
|
||||||
import { BusinessRecord } from '../ts/classes.businessrecord.js';
|
import { BusinessRecord } from '../ts/classes.businessrecord.js';
|
||||||
|
@@ -1 +1,2 @@
|
|||||||
export * from './classes.main.opendata.js';
|
export * from './classes.main.opendata.js';
|
||||||
|
export * from './stocks/index.js';
|
||||||
|
@@ -14,6 +14,7 @@ import * as smartbrowser from '@push.rocks/smartbrowser';
|
|||||||
import * as smartdata from '@push.rocks/smartdata';
|
import * as smartdata from '@push.rocks/smartdata';
|
||||||
import * as smartdelay from '@push.rocks/smartdelay';
|
import * as smartdelay from '@push.rocks/smartdelay';
|
||||||
import * as smartfile from '@push.rocks/smartfile';
|
import * as smartfile from '@push.rocks/smartfile';
|
||||||
|
import * as smartlog from '@push.rocks/smartlog';
|
||||||
import * as smartpath from '@push.rocks/smartpath';
|
import * as smartpath from '@push.rocks/smartpath';
|
||||||
import * as smartpromise from '@push.rocks/smartpromise';
|
import * as smartpromise from '@push.rocks/smartpromise';
|
||||||
import * as smartrequest from '@push.rocks/smartrequest';
|
import * as smartrequest from '@push.rocks/smartrequest';
|
||||||
@@ -30,6 +31,7 @@ export {
|
|||||||
smartdata,
|
smartdata,
|
||||||
smartdelay,
|
smartdelay,
|
||||||
smartfile,
|
smartfile,
|
||||||
|
smartlog,
|
||||||
smartpath,
|
smartpath,
|
||||||
smartpromise,
|
smartpromise,
|
||||||
smartrequest,
|
smartrequest,
|
||||||
|
309
ts/stocks/classes.stockservice.ts
Normal file
309
ts/stocks/classes.stockservice.ts
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { IStockProvider, IProviderConfig, IProviderRegistry } from './interfaces/provider.js';
|
||||||
|
import type { IStockPrice, IStockQuoteRequest, IStockBatchQuoteRequest, IStockPriceError } from './interfaces/stockprice.js';
|
||||||
|
|
||||||
|
interface IProviderEntry {
|
||||||
|
provider: IStockProvider;
|
||||||
|
config: IProviderConfig;
|
||||||
|
lastError?: Error;
|
||||||
|
lastErrorTime?: Date;
|
||||||
|
successCount: number;
|
||||||
|
errorCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ICacheEntry {
|
||||||
|
price: IStockPrice;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StockPriceService implements IProviderRegistry {
|
||||||
|
private providers = new Map<string, IProviderEntry>();
|
||||||
|
private cache = new Map<string, ICacheEntry>();
|
||||||
|
private logger = console;
|
||||||
|
|
||||||
|
private cacheConfig = {
|
||||||
|
ttl: 60000, // 60 seconds default
|
||||||
|
maxEntries: 1000
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(cacheConfig?: { ttl?: number; maxEntries?: number }) {
|
||||||
|
if (cacheConfig) {
|
||||||
|
this.cacheConfig = { ...this.cacheConfig, ...cacheConfig };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public register(provider: IStockProvider, config?: IProviderConfig): void {
|
||||||
|
const defaultConfig: IProviderConfig = {
|
||||||
|
enabled: true,
|
||||||
|
priority: provider.priority,
|
||||||
|
timeout: 10000,
|
||||||
|
retryAttempts: 2,
|
||||||
|
retryDelay: 1000
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergedConfig = { ...defaultConfig, ...config };
|
||||||
|
|
||||||
|
this.providers.set(provider.name, {
|
||||||
|
provider,
|
||||||
|
config: mergedConfig,
|
||||||
|
successCount: 0,
|
||||||
|
errorCount: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Registered provider: ${provider.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public unregister(providerName: string): void {
|
||||||
|
this.providers.delete(providerName);
|
||||||
|
console.log(`Unregistered provider: ${providerName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getProvider(name: string): IStockProvider | undefined {
|
||||||
|
return this.providers.get(name)?.provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAllProviders(): IStockProvider[] {
|
||||||
|
return Array.from(this.providers.values()).map(entry => entry.provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getEnabledProviders(): IStockProvider[] {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getPrice(request: IStockQuoteRequest): Promise<IStockPrice> {
|
||||||
|
const cacheKey = this.getCacheKey(request);
|
||||||
|
const cached = this.getFromCache(cacheKey);
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
console.log(`Cache hit for ${request.ticker}`);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = this.getEnabledProviders();
|
||||||
|
if (providers.length === 0) {
|
||||||
|
throw new Error('No stock price providers available');
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError: Error | undefined;
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
const entry = this.providers.get(provider.name)!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const price = await this.fetchWithRetry(
|
||||||
|
() => provider.fetchPrice(request),
|
||||||
|
entry.config
|
||||||
|
);
|
||||||
|
|
||||||
|
entry.successCount++;
|
||||||
|
this.addToCache(cacheKey, price);
|
||||||
|
console.log(`Successfully fetched ${request.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 ${request.ticker}: ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch price for ${request.ticker} from all providers. Last error: ${lastError?.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getPrices(request: IStockBatchQuoteRequest): Promise<IStockPrice[]> {
|
||||||
|
const cachedPrices: IStockPrice[] = [];
|
||||||
|
const tickersToFetch: string[] = [];
|
||||||
|
|
||||||
|
// Check cache for each ticker
|
||||||
|
for (const ticker of request.tickers) {
|
||||||
|
const cacheKey = this.getCacheKey({ ticker, includeExtendedHours: request.includeExtendedHours });
|
||||||
|
const cached = this.getFromCache(cacheKey);
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
cachedPrices.push(cached);
|
||||||
|
} else {
|
||||||
|
tickersToFetch.push(ticker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tickersToFetch.length === 0) {
|
||||||
|
console.log(`All ${request.tickers.length} tickers served from cache`);
|
||||||
|
return cachedPrices;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = this.getEnabledProviders();
|
||||||
|
if (providers.length === 0) {
|
||||||
|
throw new Error('No stock price providers available');
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError: Error | undefined;
|
||||||
|
let fetchedPrices: IStockPrice[] = [];
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
const entry = this.providers.get(provider.name)!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
fetchedPrices = await this.fetchWithRetry(
|
||||||
|
() => provider.fetchPrices({
|
||||||
|
tickers: tickersToFetch,
|
||||||
|
includeExtendedHours: request.includeExtendedHours
|
||||||
|
}),
|
||||||
|
entry.config
|
||||||
|
);
|
||||||
|
|
||||||
|
entry.successCount++;
|
||||||
|
|
||||||
|
// Cache the fetched prices
|
||||||
|
for (const price of fetchedPrices) {
|
||||||
|
const cacheKey = this.getCacheKey({
|
||||||
|
ticker: price.ticker,
|
||||||
|
includeExtendedHours: request.includeExtendedHours
|
||||||
|
});
|
||||||
|
this.addToCache(cacheKey, price);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Successfully fetched ${fetchedPrices.length} prices from ${provider.name}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
} 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 request: ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fetchedPrices.length === 0 && lastError) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch prices from all providers. Last error: ${lastError.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...cachedPrices, ...fetchedPrices];
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearCache(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
console.log('Cache cleared');
|
||||||
|
}
|
||||||
|
|
||||||
|
public setCacheTTL(ttl: number): void {
|
||||||
|
this.cacheConfig.ttl = ttl;
|
||||||
|
console.log(`Cache TTL set to ${ttl}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchWithRetry<T>(
|
||||||
|
fetchFn: () => Promise<T>,
|
||||||
|
config: IProviderConfig
|
||||||
|
): 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCacheKey(request: IStockQuoteRequest): string {
|
||||||
|
return `${request.ticker}:${request.includeExtendedHours || false}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFromCache(key: string): IStockPrice | null {
|
||||||
|
const entry = this.cache.get(key);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const age = Date.now() - entry.timestamp.getTime();
|
||||||
|
if (age > this.cacheConfig.ttl) {
|
||||||
|
this.cache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.price;
|
||||||
|
}
|
||||||
|
|
||||||
|
private addToCache(key: string, price: IStockPrice): 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, {
|
||||||
|
price,
|
||||||
|
timestamp: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
9
ts/stocks/index.ts
Normal file
9
ts/stocks/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// Export all interfaces
|
||||||
|
export * from './interfaces/stockprice.js';
|
||||||
|
export * from './interfaces/provider.js';
|
||||||
|
|
||||||
|
// Export main service
|
||||||
|
export * from './classes.stockservice.js';
|
||||||
|
|
||||||
|
// Export providers
|
||||||
|
export * from './providers/provider.yahoo.js';
|
36
ts/stocks/interfaces/provider.ts
Normal file
36
ts/stocks/interfaces/provider.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { IStockPrice, IStockQuoteRequest, IStockBatchQuoteRequest } from './stockprice.js';
|
||||||
|
|
||||||
|
export interface IStockProvider {
|
||||||
|
name: string;
|
||||||
|
priority: number;
|
||||||
|
|
||||||
|
fetchPrice(request: IStockQuoteRequest): Promise<IStockPrice>;
|
||||||
|
fetchPrices(request: IStockBatchQuoteRequest): Promise<IStockPrice[]>;
|
||||||
|
isAvailable(): Promise<boolean>;
|
||||||
|
|
||||||
|
supportsMarket?(market: string): boolean;
|
||||||
|
supportsTicker?(ticker: string): boolean;
|
||||||
|
|
||||||
|
readonly requiresAuth: boolean;
|
||||||
|
readonly rateLimit?: {
|
||||||
|
requestsPerMinute: number;
|
||||||
|
requestsPerDay?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IProviderConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
priority?: number;
|
||||||
|
apiKey?: string;
|
||||||
|
timeout?: number;
|
||||||
|
retryAttempts?: number;
|
||||||
|
retryDelay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IProviderRegistry {
|
||||||
|
register(provider: IStockProvider, config?: IProviderConfig): void;
|
||||||
|
unregister(providerName: string): void;
|
||||||
|
getProvider(name: string): IStockProvider | undefined;
|
||||||
|
getAllProviders(): IStockProvider[];
|
||||||
|
getEnabledProviders(): IStockProvider[];
|
||||||
|
}
|
30
ts/stocks/interfaces/stockprice.ts
Normal file
30
ts/stocks/interfaces/stockprice.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export interface IStockPrice {
|
||||||
|
ticker: string;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
change: number;
|
||||||
|
changePercent: number;
|
||||||
|
previousClose: number;
|
||||||
|
timestamp: Date;
|
||||||
|
provider: string;
|
||||||
|
marketState: 'PRE' | 'REGULAR' | 'POST' | 'CLOSED';
|
||||||
|
exchange?: string;
|
||||||
|
exchangeName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IStockPriceError {
|
||||||
|
ticker: string;
|
||||||
|
error: string;
|
||||||
|
provider: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IStockQuoteRequest {
|
||||||
|
ticker: string;
|
||||||
|
includeExtendedHours?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IStockBatchQuoteRequest {
|
||||||
|
tickers: string[];
|
||||||
|
includeExtendedHours?: boolean;
|
||||||
|
}
|
159
ts/stocks/providers/provider.yahoo.ts
Normal file
159
ts/stocks/providers/provider.yahoo.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { IStockProvider, IProviderConfig } from '../interfaces/provider.js';
|
||||||
|
import type { IStockPrice, IStockQuoteRequest, IStockBatchQuoteRequest } from '../interfaces/stockprice.js';
|
||||||
|
|
||||||
|
export class YahooFinanceProvider implements IStockProvider {
|
||||||
|
public name = 'Yahoo Finance';
|
||||||
|
public priority = 100;
|
||||||
|
public readonly requiresAuth = false;
|
||||||
|
public readonly rateLimit = {
|
||||||
|
requestsPerMinute: 100, // Conservative estimate
|
||||||
|
requestsPerDay: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
private logger = console;
|
||||||
|
private baseUrl = 'https://query1.finance.yahoo.com';
|
||||||
|
private userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||||
|
|
||||||
|
constructor(private config?: IProviderConfig) {}
|
||||||
|
|
||||||
|
public async fetchPrice(request: IStockQuoteRequest): Promise<IStockPrice> {
|
||||||
|
try {
|
||||||
|
const url = `${this.baseUrl}/v8/finance/chart/${request.ticker}`;
|
||||||
|
const response = await plugins.smartrequest.getJson(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': this.userAgent
|
||||||
|
},
|
||||||
|
timeout: this.config?.timeout || 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseData = response.body as any;
|
||||||
|
|
||||||
|
if (!responseData?.chart?.result?.[0]) {
|
||||||
|
throw new Error(`No data found for ticker ${request.ticker}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = responseData.chart.result[0];
|
||||||
|
const meta = data.meta;
|
||||||
|
|
||||||
|
if (!meta.regularMarketPrice) {
|
||||||
|
throw new Error(`No price data available for ${request.ticker}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stockPrice: IStockPrice = {
|
||||||
|
ticker: request.ticker.toUpperCase(),
|
||||||
|
price: meta.regularMarketPrice,
|
||||||
|
currency: meta.currency || 'USD',
|
||||||
|
change: meta.regularMarketPrice - meta.previousClose,
|
||||||
|
changePercent: ((meta.regularMarketPrice - meta.previousClose) / meta.previousClose) * 100,
|
||||||
|
previousClose: meta.previousClose,
|
||||||
|
timestamp: new Date(meta.regularMarketTime * 1000),
|
||||||
|
provider: this.name,
|
||||||
|
marketState: this.determineMarketState(meta),
|
||||||
|
exchange: meta.exchange,
|
||||||
|
exchangeName: meta.exchangeName
|
||||||
|
};
|
||||||
|
|
||||||
|
return stockPrice;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch price for ${request.ticker}:`, error);
|
||||||
|
throw new Error(`Yahoo Finance: Failed to fetch price for ${request.ticker}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async fetchPrices(request: IStockBatchQuoteRequest): Promise<IStockPrice[]> {
|
||||||
|
try {
|
||||||
|
const symbols = request.tickers.join(',');
|
||||||
|
const url = `${this.baseUrl}/v8/finance/spark?symbols=${symbols}&range=1d&interval=5m`;
|
||||||
|
|
||||||
|
const response = await plugins.smartrequest.getJson(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': this.userAgent
|
||||||
|
},
|
||||||
|
timeout: this.config?.timeout || 15000
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseData = response.body as any;
|
||||||
|
const prices: IStockPrice[] = [];
|
||||||
|
|
||||||
|
for (const [ticker, data] of Object.entries(responseData)) {
|
||||||
|
if (!data || typeof data !== 'object') continue;
|
||||||
|
|
||||||
|
const sparkData = data as any;
|
||||||
|
if (!sparkData.previousClose || !sparkData.close?.length) {
|
||||||
|
console.warn(`Incomplete data for ${ticker}, skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPrice = sparkData.close[sparkData.close.length - 1];
|
||||||
|
const timestamp = sparkData.timestamp?.[sparkData.timestamp.length - 1];
|
||||||
|
|
||||||
|
prices.push({
|
||||||
|
ticker: ticker.toUpperCase(),
|
||||||
|
price: currentPrice,
|
||||||
|
currency: sparkData.currency || 'USD',
|
||||||
|
change: currentPrice - sparkData.previousClose,
|
||||||
|
changePercent: ((currentPrice - sparkData.previousClose) / sparkData.previousClose) * 100,
|
||||||
|
previousClose: sparkData.previousClose,
|
||||||
|
timestamp: timestamp ? new Date(timestamp * 1000) : new Date(),
|
||||||
|
provider: this.name,
|
||||||
|
marketState: sparkData.marketState || 'REGULAR',
|
||||||
|
exchange: sparkData.exchange,
|
||||||
|
exchangeName: sparkData.exchangeName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prices.length === 0) {
|
||||||
|
throw new Error('No valid price data received from batch request');
|
||||||
|
}
|
||||||
|
|
||||||
|
return prices;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch batch prices:`, error);
|
||||||
|
throw new Error(`Yahoo Finance: Failed to fetch batch prices: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async isAvailable(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Test with a well-known ticker
|
||||||
|
await this.fetchPrice({ ticker: 'AAPL' });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Yahoo Finance provider is not available:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public supportsMarket(market: string): boolean {
|
||||||
|
// Yahoo Finance supports most major markets
|
||||||
|
const supportedMarkets = ['US', 'UK', 'DE', 'FR', 'JP', 'CN', 'HK', 'AU', 'CA'];
|
||||||
|
return supportedMarkets.includes(market.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
public supportsTicker(ticker: string): boolean {
|
||||||
|
// Basic validation - Yahoo supports most tickers
|
||||||
|
return /^[A-Z0-9\.\-]{1,10}$/.test(ticker.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
private determineMarketState(meta: any): 'PRE' | 'REGULAR' | 'POST' | 'CLOSED' {
|
||||||
|
const marketState = meta.marketState?.toUpperCase();
|
||||||
|
|
||||||
|
switch (marketState) {
|
||||||
|
case 'PRE':
|
||||||
|
return 'PRE';
|
||||||
|
case 'POST':
|
||||||
|
return 'POST';
|
||||||
|
case 'REGULAR':
|
||||||
|
return 'REGULAR';
|
||||||
|
default:
|
||||||
|
// Check if market is currently open based on timestamps
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
const regularMarketTime = meta.regularMarketTime;
|
||||||
|
const timeDiff = now - regularMarketTime;
|
||||||
|
|
||||||
|
// If last update was more than 1 hour ago, market is likely closed
|
||||||
|
return timeDiff > 3600 ? 'CLOSED' : 'REGULAR';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user