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 - Professional Plan with Intelligent Intraday Support * Documentation: https://docs.apilayer.com/marketstack/docs/marketstack-api-v2-v-2-0-0 * * Features: * - Intelligent endpoint selection based on market hours * - Real-time intraday pricing with multiple intervals (1min, 5min, 10min, 15min, 30min, 1hour) * - End-of-Day (EOD) stock prices with historical data * - Market state detection (PRE, REGULAR, POST, CLOSED) * - 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 * - Automatic fallback from intraday to EOD on errors * - Requires API key authentication * * Intelligent Endpoint Selection: * - During market hours (PRE/REGULAR/POST): Uses intraday endpoints for fresh data * - After market close (CLOSED): Uses EOD endpoints to save API credits * - Automatic fallback to EOD if intraday fails (rate limits, plan restrictions, etc.) * * Rate Limits: * - Free Plan: 100 requests/month (EOD only) * - Basic Plan: 10,000 requests/month (EOD only) * - Professional Plan: 100,000 requests/month (intraday + EOD) * * Intraday Access: * - Intervals below 15min (1min, 5min, 10min) require Professional Plan or higher * - Real-time data from IEX Exchange for US tickers * - Symbol formatting: Periods replaced with hyphens for intraday (BRK.B → BRK-B) */ export class MarketstackProvider implements IStockProvider { public name = 'Marketstack'; public priority = 90; // Increased from 80 - now supports real-time intraday data during market hours 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 { 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 price with intelligent endpoint selection * Uses intraday during market hours (PRE, REGULAR, POST) for fresh data * Uses EOD after market close (CLOSED) to save API credits */ private async fetchCurrentPrice(request: IStockCurrentRequest): Promise { try { // Determine current market state const marketState = this.getUsMarketState(); const useIntraday = this.shouldUseIntradayEndpoint(marketState); if (useIntraday) { // Use intraday endpoint for fresh data during market hours return await this.fetchCurrentPriceIntraday(request, marketState); } else { // Use EOD endpoint for after-close data return await this.fetchCurrentPriceEod(request); } } catch (error) { // If intraday fails, fallback to EOD with warning if (error.message?.includes('intraday') || error.message?.includes('Marketstack API error')) { this.logger.warn(`Intraday endpoint failed for ${request.ticker}, falling back to EOD:`, error.message); try { return await this.fetchCurrentPriceEod(request); } catch (eodError) { // Both failed, throw original error throw error; } } throw error; } } /** * Fetch current price using intraday endpoint (during market hours) * Uses 1min interval for most recent data */ private async fetchCurrentPriceIntraday( request: IStockCurrentRequest, marketState: 'PRE' | 'REGULAR' | 'POST' | 'CLOSED' ): Promise { const formattedSymbol = this.formatSymbolForIntraday(request.ticker); let url = `${this.baseUrl}/tickers/${formattedSymbol}/intraday/latest?access_key=${this.apiKey}`; url += `&interval=1min`; // Use 1min for most recent data // 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)}`); } if (!responseData || !responseData.close) { throw new Error(`No intraday data found for ticker ${request.ticker}`); } return this.mapToStockPrice(responseData, 'intraday', marketState); } /** * Fetch current price using EOD endpoint (after market close) */ private async fetchCurrentPriceEod(request: IStockCurrentRequest): Promise { 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'); } /** * Fetch historical EOD prices for a ticker with date range */ private async fetchHistoricalPrices(request: IStockHistoricalRequest): Promise { 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 * Supports intervals: 1min, 5min, 10min, 15min, 30min, 1hour * Note: Intervals below 15min require Professional Plan or higher */ private async fetchIntradayPrices(request: IStockIntradayRequest): Promise { try { const allPrices: IStockPrice[] = []; let offset = 0; const limit = 1000; // Max per page for intraday const maxRecords = 10000; // Safety limit // Format symbol for intraday endpoint (replace . with -) const formattedSymbol = this.formatSymbolForIntraday(request.ticker); while (true) { let url = `${this.baseUrl}/tickers/${formattedSymbol}/intraday?access_key=${this.apiKey}`; url += `&interval=${request.interval}`; url += `&limit=${limit}`; url += `&offset=${offset}`; // Add date filter if specified if (request.date) { url += `&date_from=${this.formatDate(request.date)}`; url += `&date_to=${this.formatDate(request.date)}`; } // 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 || 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, 'intraday')); } catch (error) { this.logger.warn(`Failed to parse intraday data for ${data.symbol}:`, error); } } // Check if we have more pages const pagination = responseData.pagination; const hasMore = pagination && offset + limit < pagination.total; // Honor limit from request if specified, or safety limit if (!hasMore || (request.limit && allPrices.length >= request.limit) || allPrices.length >= maxRecords) { break; } offset += limit; } // Apply limit if specified if (request.limit && allPrices.length > request.limit) { return allPrices.slice(0, request.limit); } return allPrices; } catch (error) { this.logger.error(`Failed to fetch intraday prices for ${request.ticker}:`, error); throw new Error(`Marketstack: Failed to fetch intraday prices for ${request.ticker}: ${error.message}`); } } /** * Fetch current prices for multiple tickers with intelligent endpoint selection * Uses intraday during market hours (PRE, REGULAR, POST) for fresh data * Uses EOD after market close (CLOSED) to save API credits */ private async fetchBatchCurrentPrices(request: IStockBatchCurrentRequest): Promise { try { // Determine current market state const marketState = this.getUsMarketState(); const useIntraday = this.shouldUseIntradayEndpoint(marketState); if (useIntraday) { return await this.fetchBatchCurrentPricesIntraday(request, marketState); } else { return await this.fetchBatchCurrentPricesEod(request); } } catch (error) { // Fallback to EOD if intraday fails if (error.message?.includes('intraday') || error.message?.includes('Marketstack API error')) { this.logger.warn(`Intraday batch endpoint failed, falling back to EOD:`, error.message); try { return await this.fetchBatchCurrentPricesEod(request); } catch (eodError) { // Both failed, throw original error throw error; } } throw error; } } /** * Fetch batch current prices using intraday endpoint */ private async fetchBatchCurrentPricesIntraday( request: IStockBatchCurrentRequest, marketState: 'PRE' | 'REGULAR' | 'POST' | 'CLOSED' ): Promise { // Format symbols for intraday (replace . with -) const formattedSymbols = request.tickers.map(t => this.formatSymbolForIntraday(t)).join(','); let url = `${this.baseUrl}/intraday/latest?access_key=${this.apiKey}`; url += `&symbols=${formattedSymbols}`; url += `&interval=1min`; // Use 1min for most recent data 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; 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, 'intraday', marketState)); } catch (error) { this.logger.warn(`Failed to parse intraday data for ${data.symbol}:`, error); } } if (prices.length === 0) { throw new Error('No valid price data received from batch intraday request'); } return prices; } /** * Fetch batch current prices using EOD endpoint */ private async fetchBatchCurrentPricesEod(request: IStockBatchCurrentRequest): Promise { 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; } /** * 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 * @param data - API response data * @param dataType - Type of data (eod, intraday, live) * @param explicitMarketState - Override market state (used for intraday data fetched during known market hours) */ private mapToStockPrice( data: any, dataType: 'eod' | 'intraday' | 'live' = 'eod', explicitMarketState?: 'PRE' | 'REGULAR' | 'POST' | 'CLOSED' ): 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(); // Determine market state intelligently let marketState: 'PRE' | 'REGULAR' | 'POST' | 'CLOSED'; if (explicitMarketState) { // Use provided market state (for intraday data fetched during known market hours) marketState = explicitMarketState; } else if (dataType === 'eod') { // EOD data is always for closed markets marketState = 'CLOSED'; } else if (dataType === 'intraday') { // For intraday data without explicit state, determine from timestamp marketState = this.getUsMarketState(timestamp.getTime()); } else { // Default fallback marketState = 'CLOSED'; } 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: marketState, // Now dynamic based on data type, timestamp, and explicit state 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}`; } /** * Get US market state based on Eastern Time * Regular hours: 9:30 AM - 4:00 PM ET * Pre-market: 4:00 AM - 9:30 AM ET * After-hours: 4:00 PM - 8:00 PM ET * * @param timestampMs - Optional timestamp in milliseconds (defaults to current time) * @returns Market state: PRE, REGULAR, POST, or CLOSED */ private getUsMarketState(timestampMs?: number): 'PRE' | 'REGULAR' | 'POST' | 'CLOSED' { const now = timestampMs ? new Date(timestampMs) : new Date(); // Convert to ET (UTC-5 or UTC-4 depending on DST) // For simplicity, we'll use a rough approximation // TODO: Add proper timezone library for production use const etOffset = -5; // Standard time, adjust for DST if needed const etTime = new Date(now.getTime() + (etOffset * 60 * 60 * 1000)); // Get day of week (0 = Sunday, 6 = Saturday) const dayOfWeek = etTime.getUTCDay(); // Check if weekend if (dayOfWeek === 0 || dayOfWeek === 6) { return 'CLOSED'; } // Get hour and minute in ET const hours = etTime.getUTCHours(); const minutes = etTime.getUTCMinutes(); const timeInMinutes = hours * 60 + minutes; // Define market hours in minutes const preMarketStart = 4 * 60; // 4:00 AM const regularMarketStart = 9 * 60 + 30; // 9:30 AM const regularMarketEnd = 16 * 60; // 4:00 PM const afterHoursEnd = 20 * 60; // 8:00 PM if (timeInMinutes >= preMarketStart && timeInMinutes < regularMarketStart) { return 'PRE'; } else if (timeInMinutes >= regularMarketStart && timeInMinutes < regularMarketEnd) { return 'REGULAR'; } else if (timeInMinutes >= regularMarketEnd && timeInMinutes < afterHoursEnd) { return 'POST'; } else { return 'CLOSED'; } } /** * Determine if intraday endpoint should be used based on market state * Uses intraday for PRE, REGULAR, and POST market states * Uses EOD for CLOSED state to save API credits * * @param marketState - Current market state * @returns true if intraday endpoint should be used */ private shouldUseIntradayEndpoint(marketState: 'PRE' | 'REGULAR' | 'POST' | 'CLOSED'): boolean { return marketState !== 'CLOSED'; } /** * Format ticker symbol for intraday endpoints * Marketstack intraday API requires periods to be replaced with hyphens * Example: BRK.B → BRK-B * * @param symbol - Original ticker symbol * @returns Formatted symbol for intraday endpoints */ private formatSymbolForIntraday(symbol: string): string { return symbol.replace(/\./g, '-'); } }