377 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			377 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
import * as plugins from '../../plugins.js';
 | 
						|
import type { IStockProvider, IProviderConfig } from '../interfaces/provider.js';
 | 
						|
import type {
 | 
						|
  IStockPrice,
 | 
						|
  IStockDataRequest,
 | 
						|
  IStockCurrentRequest,
 | 
						|
  IStockHistoricalRequest,
 | 
						|
  IStockIntradayRequest,
 | 
						|
  IStockBatchCurrentRequest
 | 
						|
} from '../interfaces/stockprice.js';
 | 
						|
 | 
						|
/**
 | 
						|
 * Marketstack API v2 Provider - Enhanced
 | 
						|
 * Documentation: https://docs.apilayer.com/marketstack/docs/marketstack-api-v2-v-2-0-0
 | 
						|
 *
 | 
						|
 * Features:
 | 
						|
 * - End-of-Day (EOD) stock prices with historical data
 | 
						|
 * - Intraday pricing with multiple intervals (1min, 5min, 15min, 30min, 1hour)
 | 
						|
 * - Exchange filtering via MIC codes (XNAS, XNYS, XLON, etc.)
 | 
						|
 * - Supports 500,000+ tickers across 72+ exchanges worldwide
 | 
						|
 * - OHLCV data (Open, High, Low, Close, Volume)
 | 
						|
 * - Pagination for large datasets
 | 
						|
 * - Requires API key authentication
 | 
						|
 *
 | 
						|
 * Rate Limits:
 | 
						|
 * - Free Plan: 100 requests/month (EOD only)
 | 
						|
 * - Basic Plan: 10,000 requests/month
 | 
						|
 * - Professional Plan: 100,000 requests/month (intraday access)
 | 
						|
 *
 | 
						|
 * Phase 1 Enhancements:
 | 
						|
 * - Historical data retrieval with date ranges
 | 
						|
 * - Exchange filtering
 | 
						|
 * - OHLCV data support
 | 
						|
 * - Pagination handling
 | 
						|
 */
 | 
						|
export class MarketstackProvider implements IStockProvider {
 | 
						|
  public name = 'Marketstack';
 | 
						|
  public priority = 80; // Lower than Yahoo (100) due to rate limits and EOD-only data
 | 
						|
  public readonly requiresAuth = true;
 | 
						|
  public readonly rateLimit = {
 | 
						|
    requestsPerMinute: undefined, // No per-minute limit specified
 | 
						|
    requestsPerDay: undefined // Varies by plan
 | 
						|
  };
 | 
						|
 | 
						|
  private logger = console;
 | 
						|
  private baseUrl = 'https://api.marketstack.com/v2';
 | 
						|
  private apiKey: string;
 | 
						|
 | 
						|
  constructor(apiKey: string, private config?: IProviderConfig) {
 | 
						|
    if (!apiKey) {
 | 
						|
      throw new Error('API key is required for Marketstack provider');
 | 
						|
    }
 | 
						|
 | 
						|
    this.apiKey = apiKey;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Unified data fetching method supporting all request types
 | 
						|
   */
 | 
						|
  public async fetchData(request: IStockDataRequest): Promise<IStockPrice[] | IStockPrice> {
 | 
						|
    switch (request.type) {
 | 
						|
      case 'current':
 | 
						|
        return this.fetchCurrentPrice(request);
 | 
						|
      case 'historical':
 | 
						|
        return this.fetchHistoricalPrices(request);
 | 
						|
      case 'intraday':
 | 
						|
        return this.fetchIntradayPrices(request);
 | 
						|
      case 'batch':
 | 
						|
        return this.fetchBatchCurrentPrices(request);
 | 
						|
      default:
 | 
						|
        throw new Error(`Unsupported request type: ${(request as any).type}`);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Fetch current/latest EOD price for a single ticker (new API)
 | 
						|
   */
 | 
						|
  private async fetchCurrentPrice(request: IStockCurrentRequest): Promise<IStockPrice> {
 | 
						|
    try {
 | 
						|
      let url = `${this.baseUrl}/tickers/${request.ticker}/eod/latest?access_key=${this.apiKey}`;
 | 
						|
 | 
						|
      // Add exchange filter if specified
 | 
						|
      if (request.exchange) {
 | 
						|
        url += `&exchange=${request.exchange}`;
 | 
						|
      }
 | 
						|
 | 
						|
      const response = await plugins.smartrequest.SmartRequest.create()
 | 
						|
        .url(url)
 | 
						|
        .timeout(this.config?.timeout || 10000)
 | 
						|
        .get();
 | 
						|
 | 
						|
      const responseData = await response.json() as any;
 | 
						|
 | 
						|
      // Check for API errors
 | 
						|
      if (responseData.error) {
 | 
						|
        throw new Error(`Marketstack API error: ${responseData.error.message || JSON.stringify(responseData.error)}`);
 | 
						|
      }
 | 
						|
 | 
						|
      // For single ticker endpoint, response is direct object (not wrapped in data field)
 | 
						|
      if (!responseData || !responseData.close) {
 | 
						|
        throw new Error(`No data found for ticker ${request.ticker}`);
 | 
						|
      }
 | 
						|
 | 
						|
      return this.mapToStockPrice(responseData, 'eod');
 | 
						|
    } catch (error) {
 | 
						|
      this.logger.error(`Failed to fetch current price for ${request.ticker}:`, error);
 | 
						|
      throw new Error(`Marketstack: Failed to fetch current price for ${request.ticker}: ${error.message}`);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Fetch historical EOD prices for a ticker with date range
 | 
						|
   */
 | 
						|
  private async fetchHistoricalPrices(request: IStockHistoricalRequest): Promise<IStockPrice[]> {
 | 
						|
    try {
 | 
						|
      const allPrices: IStockPrice[] = [];
 | 
						|
      let offset = request.offset || 0;
 | 
						|
      const limit = request.limit || 1000; // Max per page
 | 
						|
      const maxRecords = 10000; // Safety limit
 | 
						|
 | 
						|
      while (true) {
 | 
						|
        let url = `${this.baseUrl}/eod?access_key=${this.apiKey}`;
 | 
						|
        url += `&symbols=${request.ticker}`;
 | 
						|
        url += `&date_from=${this.formatDate(request.from)}`;
 | 
						|
        url += `&date_to=${this.formatDate(request.to)}`;
 | 
						|
        url += `&limit=${limit}`;
 | 
						|
        url += `&offset=${offset}`;
 | 
						|
 | 
						|
        if (request.exchange) {
 | 
						|
          url += `&exchange=${request.exchange}`;
 | 
						|
        }
 | 
						|
 | 
						|
        if (request.sort) {
 | 
						|
          url += `&sort=${request.sort}`;
 | 
						|
        }
 | 
						|
 | 
						|
        const response = await plugins.smartrequest.SmartRequest.create()
 | 
						|
          .url(url)
 | 
						|
          .timeout(this.config?.timeout || 15000)
 | 
						|
          .get();
 | 
						|
 | 
						|
        const responseData = await response.json() as any;
 | 
						|
 | 
						|
        // Check for API errors
 | 
						|
        if (responseData.error) {
 | 
						|
          throw new Error(`Marketstack API error: ${responseData.error.message || JSON.stringify(responseData.error)}`);
 | 
						|
        }
 | 
						|
 | 
						|
        if (!responseData?.data || !Array.isArray(responseData.data)) {
 | 
						|
          throw new Error('Invalid response format from Marketstack API');
 | 
						|
        }
 | 
						|
 | 
						|
        // Map data to stock prices
 | 
						|
        for (const data of responseData.data) {
 | 
						|
          try {
 | 
						|
            allPrices.push(this.mapToStockPrice(data, 'eod'));
 | 
						|
          } catch (error) {
 | 
						|
            this.logger.warn(`Failed to parse historical data for ${data.symbol}:`, error);
 | 
						|
          }
 | 
						|
        }
 | 
						|
 | 
						|
        // Check if we have more pages
 | 
						|
        const pagination = responseData.pagination;
 | 
						|
        const hasMore = pagination && offset + limit < pagination.total;
 | 
						|
 | 
						|
        // Safety check: don't fetch more than maxRecords
 | 
						|
        if (!hasMore || allPrices.length >= maxRecords) {
 | 
						|
          break;
 | 
						|
        }
 | 
						|
 | 
						|
        offset += limit;
 | 
						|
      }
 | 
						|
 | 
						|
      return allPrices;
 | 
						|
    } catch (error) {
 | 
						|
      this.logger.error(`Failed to fetch historical prices for ${request.ticker}:`, error);
 | 
						|
      throw new Error(`Marketstack: Failed to fetch historical prices for ${request.ticker}: ${error.message}`);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Fetch intraday prices with specified interval (Phase 2 placeholder)
 | 
						|
   */
 | 
						|
  private async fetchIntradayPrices(request: IStockIntradayRequest): Promise<IStockPrice[]> {
 | 
						|
    throw new Error('Intraday data support coming in Phase 2. For now, use EOD data with type: "current" or "historical"');
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Fetch current prices for multiple tickers (new API)
 | 
						|
   */
 | 
						|
  private async fetchBatchCurrentPrices(request: IStockBatchCurrentRequest): Promise<IStockPrice[]> {
 | 
						|
    try {
 | 
						|
      const symbols = request.tickers.join(',');
 | 
						|
      let url = `${this.baseUrl}/eod/latest?access_key=${this.apiKey}&symbols=${symbols}`;
 | 
						|
 | 
						|
      if (request.exchange) {
 | 
						|
        url += `&exchange=${request.exchange}`;
 | 
						|
      }
 | 
						|
 | 
						|
      const response = await plugins.smartrequest.SmartRequest.create()
 | 
						|
        .url(url)
 | 
						|
        .timeout(this.config?.timeout || 15000)
 | 
						|
        .get();
 | 
						|
 | 
						|
      const responseData = await response.json() as any;
 | 
						|
 | 
						|
      // Check for API errors
 | 
						|
      if (responseData.error) {
 | 
						|
        throw new Error(`Marketstack API error: ${responseData.error.message || JSON.stringify(responseData.error)}`);
 | 
						|
      }
 | 
						|
 | 
						|
      if (!responseData?.data || !Array.isArray(responseData.data)) {
 | 
						|
        throw new Error('Invalid response format from Marketstack API');
 | 
						|
      }
 | 
						|
 | 
						|
      const prices: IStockPrice[] = [];
 | 
						|
 | 
						|
      for (const data of responseData.data) {
 | 
						|
        try {
 | 
						|
          prices.push(this.mapToStockPrice(data, 'eod'));
 | 
						|
        } catch (error) {
 | 
						|
          this.logger.warn(`Failed to parse data for ${data.symbol}:`, error);
 | 
						|
          // Continue processing other tickers
 | 
						|
        }
 | 
						|
      }
 | 
						|
 | 
						|
      if (prices.length === 0) {
 | 
						|
        throw new Error('No valid price data received from batch request');
 | 
						|
      }
 | 
						|
 | 
						|
      return prices;
 | 
						|
    } catch (error) {
 | 
						|
      this.logger.error(`Failed to fetch batch current prices:`, error);
 | 
						|
      throw new Error(`Marketstack: Failed to fetch batch current prices: ${error.message}`);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Check if the Marketstack API is available and accessible
 | 
						|
   */
 | 
						|
  public async isAvailable(): Promise<boolean> {
 | 
						|
    try {
 | 
						|
      // Test with a well-known ticker
 | 
						|
      const url = `${this.baseUrl}/tickers/AAPL/eod/latest?access_key=${this.apiKey}`;
 | 
						|
 | 
						|
      const response = await plugins.smartrequest.SmartRequest.create()
 | 
						|
        .url(url)
 | 
						|
        .timeout(5000)
 | 
						|
        .get();
 | 
						|
 | 
						|
      const responseData = await response.json() as any;
 | 
						|
 | 
						|
      // Check if we got valid data (not an error)
 | 
						|
      // Single ticker endpoint returns direct object, not wrapped in data field
 | 
						|
      return !responseData.error && responseData.close !== undefined;
 | 
						|
    } catch (error) {
 | 
						|
      this.logger.warn('Marketstack provider is not available:', error);
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Check if a market is supported
 | 
						|
   * Marketstack supports 72+ exchanges worldwide
 | 
						|
   */
 | 
						|
  public supportsMarket(market: string): boolean {
 | 
						|
    // Marketstack has broad international coverage including:
 | 
						|
    // US, UK, DE, FR, JP, CN, HK, AU, CA, IN, etc.
 | 
						|
    const supportedMarkets = [
 | 
						|
      'US', 'UK', 'GB', 'DE', 'FR', 'JP', 'CN', 'HK', 'AU', 'CA',
 | 
						|
      'IN', 'BR', 'MX', 'IT', 'ES', 'NL', 'SE', 'CH', 'NO', 'DK'
 | 
						|
    ];
 | 
						|
    return supportedMarkets.includes(market.toUpperCase());
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Check if a ticker format is supported
 | 
						|
   */
 | 
						|
  public supportsTicker(ticker: string): boolean {
 | 
						|
    // Basic validation - Marketstack supports most standard ticker formats
 | 
						|
    return /^[A-Z0-9\.\-]{1,10}$/.test(ticker.toUpperCase());
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Map Marketstack API response to IStockPrice interface
 | 
						|
   */
 | 
						|
  private mapToStockPrice(data: any, dataType: 'eod' | 'intraday' | 'live' = 'eod'): IStockPrice {
 | 
						|
    if (!data.close) {
 | 
						|
      throw new Error('Missing required price data');
 | 
						|
    }
 | 
						|
 | 
						|
    // Calculate change and change percent
 | 
						|
    // EOD data: previous close is typically open price of the same day
 | 
						|
    // For better accuracy, we'd need previous day's close, but that requires another API call
 | 
						|
    const currentPrice = data.close;
 | 
						|
    const previousClose = data.open || currentPrice;
 | 
						|
    const change = currentPrice - previousClose;
 | 
						|
    const changePercent = previousClose !== 0 ? (change / previousClose) * 100 : 0;
 | 
						|
 | 
						|
    // Parse timestamp
 | 
						|
    const timestamp = data.date ? new Date(data.date) : new Date();
 | 
						|
    const fetchedAt = new Date();
 | 
						|
 | 
						|
    const stockPrice: IStockPrice = {
 | 
						|
      ticker: data.symbol.toUpperCase(),
 | 
						|
      price: currentPrice,
 | 
						|
      currency: data.price_currency || 'USD',
 | 
						|
      change: change,
 | 
						|
      changePercent: changePercent,
 | 
						|
      previousClose: previousClose,
 | 
						|
      timestamp: timestamp,
 | 
						|
      provider: this.name,
 | 
						|
      marketState: 'CLOSED', // EOD data is always for closed markets
 | 
						|
      exchange: data.exchange,
 | 
						|
      exchangeName: data.exchange_code || data.name,
 | 
						|
 | 
						|
      // Phase 1 enhancements: OHLCV data
 | 
						|
      volume: data.volume,
 | 
						|
      open: data.open,
 | 
						|
      high: data.high,
 | 
						|
      low: data.low,
 | 
						|
      adjusted: data.adj_close !== undefined, // If adj_close exists, price is adjusted
 | 
						|
      dataType: dataType,
 | 
						|
      fetchedAt: fetchedAt,
 | 
						|
 | 
						|
      // Company identification
 | 
						|
      companyName: data.company_name || data.name || undefined,
 | 
						|
      companyFullName: this.buildCompanyFullName(data)
 | 
						|
    };
 | 
						|
 | 
						|
    return stockPrice;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Build full company name with exchange and ticker information
 | 
						|
   * Example: "Apple Inc (NASDAQ:AAPL)"
 | 
						|
   */
 | 
						|
  private buildCompanyFullName(data: any): string | undefined {
 | 
						|
    // Check if API already provides full name
 | 
						|
    if (data.full_name || data.long_name) {
 | 
						|
      return data.full_name || data.long_name;
 | 
						|
    }
 | 
						|
 | 
						|
    // Build from available data
 | 
						|
    const companyName = data.company_name || data.name;
 | 
						|
    const exchangeCode = data.exchange_code; // e.g., "NASDAQ"
 | 
						|
    const symbol = data.symbol; // e.g., "AAPL"
 | 
						|
 | 
						|
    if (!companyName) {
 | 
						|
      return undefined;
 | 
						|
    }
 | 
						|
 | 
						|
    // If we have exchange and symbol, build full name: "Apple Inc (NASDAQ:AAPL)"
 | 
						|
    if (exchangeCode && symbol) {
 | 
						|
      return `${companyName} (${exchangeCode}:${symbol})`;
 | 
						|
    }
 | 
						|
 | 
						|
    // If we only have symbol: "Apple Inc (AAPL)"
 | 
						|
    if (symbol) {
 | 
						|
      return `${companyName} (${symbol})`;
 | 
						|
    }
 | 
						|
 | 
						|
    // Otherwise just return company name
 | 
						|
    return companyName;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Format date to YYYY-MM-DD for API requests
 | 
						|
   */
 | 
						|
  private formatDate(date: Date): string {
 | 
						|
    const year = date.getFullYear();
 | 
						|
    const month = String(date.getMonth() + 1).padStart(2, '0');
 | 
						|
    const day = String(date.getDate()).padStart(2, '0');
 | 
						|
    return `${year}-${month}-${day}`;
 | 
						|
  }
 | 
						|
}
 |