import * as plugins from '../../plugins.js'; import type { IStockProvider, IProviderConfig } from '../interfaces/provider.js'; import type { IStockPrice, IStockQuoteRequest, IStockBatchQuoteRequest } from '../interfaces/stockprice.js'; /** * Marketstack API v2 Provider * Documentation: https://marketstack.com/documentation_v2 * * Features: * - End-of-Day (EOD) stock prices * - Supports 125,000+ tickers across 72+ exchanges worldwide * - 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 * * Note: This provider returns EOD data, not real-time prices */ 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; } /** * Fetch latest EOD price for a single ticker */ public async fetchPrice(request: IStockQuoteRequest): Promise { try { const url = `${this.baseUrl}/tickers/${request.ticker}/eod/latest?access_key=${this.apiKey}`; 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); } catch (error) { this.logger.error(`Failed to fetch price for ${request.ticker}:`, error); throw new Error(`Marketstack: Failed to fetch price for ${request.ticker}: ${error.message}`); } } /** * Fetch latest EOD prices for multiple tickers */ public async fetchPrices(request: IStockBatchQuoteRequest): Promise { try { const symbols = request.tickers.join(','); const url = `${this.baseUrl}/eod/latest?access_key=${this.apiKey}&symbols=${symbols}`; 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)); } 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 prices:`, error); throw new Error(`Marketstack: Failed to fetch batch prices: ${error.message}`); } } /** * Check if the Marketstack API is available and accessible */ public async isAvailable(): Promise { 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): 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; 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 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 }; return stockPrice; } }