Files
opendata/ts/stocks/classes.stockdataservice.ts

648 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
};
}
}