368 lines
12 KiB
TypeScript
368 lines
12 KiB
TypeScript
import * as plugins from '../../plugins.js';
|
|
import type { IStockProvider, IProviderConfig } from '../interfaces/provider.js';
|
|
import type {
|
|
IStockPrice,
|
|
IStockQuoteRequest,
|
|
IStockBatchQuoteRequest,
|
|
IStockDataRequest,
|
|
IStockCurrentRequest,
|
|
IStockHistoricalRequest,
|
|
IStockIntradayRequest,
|
|
IStockBatchCurrentRequest,
|
|
IPaginatedResponse,
|
|
TSortOrder
|
|
} 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}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Legacy: Fetch latest EOD price for a single ticker
|
|
* @deprecated Use fetchData with IStockDataRequest instead
|
|
*/
|
|
public async fetchPrice(request: IStockQuoteRequest): Promise<IStockPrice> {
|
|
// Map legacy request to new format
|
|
return this.fetchCurrentPrice({
|
|
type: 'current',
|
|
ticker: request.ticker
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Legacy: Fetch latest EOD prices for multiple tickers
|
|
* @deprecated Use fetchData with IStockDataRequest instead
|
|
*/
|
|
public async fetchPrices(request: IStockBatchQuoteRequest): Promise<IStockPrice[]> {
|
|
// Map legacy request to new format
|
|
return this.fetchBatchCurrentPrices({
|
|
type: 'batch',
|
|
tickers: request.tickers
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
};
|
|
|
|
return stockPrice;
|
|
}
|
|
|
|
/**
|
|
* 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}`;
|
|
}
|
|
}
|