This commit is contained in:
Juergen Kunz
2025-07-11 08:38:48 +00:00
parent 298172c00b
commit daeff1ce93
11 changed files with 845 additions and 9 deletions

View 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
View 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';

View 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[];
}

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

View 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';
}
}
}