feat(stocks): Add Marketstack provider (EOD) with tests, exports and documentation updates
This commit is contained in:
@@ -6,4 +6,5 @@ export * from './interfaces/provider.js';
|
||||
export * from './classes.stockservice.js';
|
||||
|
||||
// Export providers
|
||||
export * from './providers/provider.yahoo.js';
|
||||
export * from './providers/provider.yahoo.js';
|
||||
export * from './providers/provider.marketstack.js';
|
200
ts/stocks/providers/provider.marketstack.ts
Normal file
200
ts/stocks/providers/provider.marketstack.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
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<IStockPrice> {
|
||||
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<IStockPrice[]> {
|
||||
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<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): 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;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user