405 lines
10 KiB
TypeScript
405 lines
10 KiB
TypeScript
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';
|
||
}
|
||
}
|
||
}
|