373 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			373 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
import * as plugins from '../plugins.js';
 | 
						|
import type { IStockProvider, IProviderConfig, IProviderRegistry } from './interfaces/provider.js';
 | 
						|
import type {
 | 
						|
  IStockPrice,
 | 
						|
  IStockPriceError,
 | 
						|
  IStockDataRequest,
 | 
						|
  IStockCurrentRequest,
 | 
						|
  IStockHistoricalRequest,
 | 
						|
  IStockIntradayRequest,
 | 
						|
  IStockBatchCurrentRequest,
 | 
						|
  TIntervalType
 | 
						|
} from './interfaces/stockprice.js';
 | 
						|
 | 
						|
// Simple request interfaces for convenience methods
 | 
						|
interface ISimpleQuoteRequest {
 | 
						|
  ticker: string;
 | 
						|
}
 | 
						|
 | 
						|
interface ISimpleBatchRequest {
 | 
						|
  tickers: string[];
 | 
						|
}
 | 
						|
 | 
						|
interface IProviderEntry {
 | 
						|
  provider: IStockProvider;
 | 
						|
  config: IProviderConfig;
 | 
						|
  lastError?: Error;
 | 
						|
  lastErrorTime?: Date;
 | 
						|
  successCount: number;
 | 
						|
  errorCount: number;
 | 
						|
}
 | 
						|
 | 
						|
interface ICacheEntry {
 | 
						|
  price: IStockPrice | IStockPrice[];
 | 
						|
  timestamp: Date;
 | 
						|
  ttl: number; // Specific TTL for this entry
 | 
						|
}
 | 
						|
 | 
						|
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 (for backward compatibility)
 | 
						|
    maxEntries: 10000 // Increased for historical data
 | 
						|
  };
 | 
						|
 | 
						|
  constructor(cacheConfig?: { ttl?: number; maxEntries?: number }) {
 | 
						|
    if (cacheConfig) {
 | 
						|
      this.cacheConfig = { ...this.cacheConfig, ...cacheConfig };
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Get data-type aware TTL for smart caching
 | 
						|
   */
 | 
						|
  private getCacheTTL(dataType: 'eod' | 'historical' | 'intraday' | 'live', interval?: TIntervalType): number {
 | 
						|
    switch (dataType) {
 | 
						|
      case 'historical':
 | 
						|
        return Infinity; // Historical data never changes
 | 
						|
      case 'eod':
 | 
						|
        return 24 * 60 * 60 * 1000; // 24 hours (EOD is static after market close)
 | 
						|
      case 'intraday':
 | 
						|
        // Match cache TTL to interval
 | 
						|
        return this.getIntervalMs(interval);
 | 
						|
      case 'live':
 | 
						|
        return 30 * 1000; // 30 seconds for live data
 | 
						|
      default:
 | 
						|
        return this.cacheConfig.ttl; // Fallback to default
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Convert interval to milliseconds
 | 
						|
   */
 | 
						|
  private getIntervalMs(interval?: TIntervalType): number {
 | 
						|
    if (!interval) return 60 * 1000; // Default 1 minute
 | 
						|
 | 
						|
    const intervalMap: Record<TIntervalType, number> = {
 | 
						|
      '1min': 60 * 1000,
 | 
						|
      '5min': 5 * 60 * 1000,
 | 
						|
      '10min': 10 * 60 * 1000,
 | 
						|
      '15min': 15 * 60 * 1000,
 | 
						|
      '30min': 30 * 60 * 1000,
 | 
						|
      '1hour': 60 * 60 * 1000
 | 
						|
    };
 | 
						|
 | 
						|
    return intervalMap[interval] || 60 * 1000;
 | 
						|
  }
 | 
						|
 | 
						|
  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);
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Convenience method: Get current price for a single ticker
 | 
						|
   */
 | 
						|
  public async getPrice(request: ISimpleQuoteRequest): Promise<IStockPrice> {
 | 
						|
    const result = await this.getData({
 | 
						|
      type: 'current',
 | 
						|
      ticker: request.ticker
 | 
						|
    });
 | 
						|
    return result as IStockPrice;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Convenience method: Get current prices for multiple tickers
 | 
						|
   */
 | 
						|
  public async getPrices(request: ISimpleBatchRequest): Promise<IStockPrice[]> {
 | 
						|
    const result = await this.getData({
 | 
						|
      type: 'batch',
 | 
						|
      tickers: request.tickers
 | 
						|
    });
 | 
						|
    return result as IStockPrice[];
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * New unified data fetching method supporting all request types
 | 
						|
   */
 | 
						|
  public async getData(request: IStockDataRequest): Promise<IStockPrice | IStockPrice[]> {
 | 
						|
    const cacheKey = this.getDataCacheKey(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 stock price 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
 | 
						|
        ) as IStockPrice | IStockPrice[];
 | 
						|
 | 
						|
        entry.successCount++;
 | 
						|
 | 
						|
        // Determine TTL based on request type
 | 
						|
        const ttl = this.getRequestTTL(request, result);
 | 
						|
        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}`
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Get TTL based on request type and result
 | 
						|
   */
 | 
						|
  private getRequestTTL(request: IStockDataRequest, result: IStockPrice | IStockPrice[]): number {
 | 
						|
    switch (request.type) {
 | 
						|
      case 'historical':
 | 
						|
        return Infinity; // Historical data never changes
 | 
						|
      case 'current':
 | 
						|
        return this.getCacheTTL('eod');
 | 
						|
      case 'batch':
 | 
						|
        return this.getCacheTTL('eod');
 | 
						|
      case 'intraday':
 | 
						|
        return this.getCacheTTL('intraday', request.interval);
 | 
						|
      default:
 | 
						|
        return this.cacheConfig.ttl;
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Get human-readable description of request
 | 
						|
   */
 | 
						|
  private getRequestDescription(request: IStockDataRequest): string {
 | 
						|
    switch (request.type) {
 | 
						|
      case 'current':
 | 
						|
        return `current price for ${request.ticker}${request.exchange ? ` on ${request.exchange}` : ''}`;
 | 
						|
      case 'historical':
 | 
						|
        return `historical prices for ${request.ticker} from ${request.from.toISOString().split('T')[0]} to ${request.to.toISOString().split('T')[0]}`;
 | 
						|
      case 'intraday':
 | 
						|
        return `intraday ${request.interval} prices for ${request.ticker}`;
 | 
						|
      case 'batch':
 | 
						|
        return `batch prices for ${request.tickers.length} tickers`;
 | 
						|
      default:
 | 
						|
        return 'data';
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  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');
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * New cache key generation for discriminated union requests
 | 
						|
   */
 | 
						|
  private getDataCacheKey(request: IStockDataRequest): string {
 | 
						|
    switch (request.type) {
 | 
						|
      case 'current':
 | 
						|
        return `current:${request.ticker}${request.exchange ? `:${request.exchange}` : ''}`;
 | 
						|
      case 'historical':
 | 
						|
        const fromStr = request.from.toISOString().split('T')[0];
 | 
						|
        const toStr = request.to.toISOString().split('T')[0];
 | 
						|
        return `historical:${request.ticker}:${fromStr}:${toStr}${request.exchange ? `:${request.exchange}` : ''}`;
 | 
						|
      case 'intraday':
 | 
						|
        const dateStr = request.date ? request.date.toISOString().split('T')[0] : 'latest';
 | 
						|
        return `intraday:${request.ticker}:${request.interval}:${dateStr}${request.exchange ? `:${request.exchange}` : ''}`;
 | 
						|
      case 'batch':
 | 
						|
        const tickers = request.tickers.sort().join(',');
 | 
						|
        return `batch:${tickers}${request.exchange ? `:${request.exchange}` : ''}`;
 | 
						|
      default:
 | 
						|
        return `unknown:${JSON.stringify(request)}`;
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  private getFromCache(key: string): IStockPrice | IStockPrice[] | 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.price;
 | 
						|
  }
 | 
						|
 | 
						|
  private addToCache(key: string, price: IStockPrice | IStockPrice[], 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, {
 | 
						|
      price,
 | 
						|
      timestamp: new Date(),
 | 
						|
      ttl: ttl || this.cacheConfig.ttl
 | 
						|
    });
 | 
						|
  }
 | 
						|
} |