Skip to main content
Glama
Ademscodeisnotsobad

Quant Companion MCP

marketData.ts27.8 kB
/** * Market data abstraction layer * * Provides a pluggable interface for fetching market data from various providers. * Supported: Polygon.io (recommended), Yahoo Finance (unlimited fallback) */ import type { OptionContract, OptionsChain, OptionType } from "@quant-companion/core"; /** Yahoo Finance option quote structure */ interface YahooOptionQuote { contractSymbol?: string; strike?: number; lastPrice?: number; bid?: number; ask?: number; volume?: number; openInterest?: number; impliedVolatility?: number; } /** Helper to parse Yahoo option data into OptionContract */ function parseYahooOption( underlying: string, opt: YahooOptionQuote, right: OptionType, expiration: string, timeToMaturityYears: number ): OptionContract { const bid = opt.bid ?? 0; const ask = opt.ask ?? 0; const mid = (bid + ask) / 2; return { underlying, optionSymbol: opt.contractSymbol || "", right, strike: opt.strike ?? 0, expiration, timeToMaturityYears, lastPrice: opt.lastPrice ?? 0, bid, ask, mid, volume: opt.volume ?? 0, openInterest: opt.openInterest ?? 0, impliedVol: opt.impliedVolatility, }; } export interface OHLCV { timestamp: number; open: number; high: number; low: number; close: number; volume?: number; } export interface Quote { symbol: string; price: number; timestamp: number; } export interface OptionsChainParams { symbol: string; /** Optional specific expiration date (ISO string). If omitted, returns all available expirations. */ expiration?: string; } /** Risk-free rate point */ export interface RatePoint { /** Maturity in years */ maturityYears: number; /** Annualized rate */ rate: number; } export interface MarketDataProvider { readonly name: string; /** * Get current quote for a symbol */ getQuote(symbol: string): Promise<Quote>; /** * Get historical OHLCV data */ getHistoricalOHLCV(params: { symbol: string; start: Date; end: Date; interval: "1d" | "1h" | "5m"; }): Promise<OHLCV[]>; /** * Get options chain for a symbol */ getOptionsChain(params: OptionsChainParams): Promise<OptionsChain>; /** * Get risk-free rate curve (optional) * Returns flat rate if not implemented by provider */ getRateCurve?(): Promise<RatePoint[]>; /** * Get dividend yield for a symbol (optional) * Returns 0 if not implemented by provider */ getDividendYield?(symbol: string): Promise<number>; } /** * Yahoo Finance provider using free public endpoints */ export class YahooFinanceProvider implements MarketDataProvider { readonly name = "Yahoo Finance"; private crumb: string | null = null; private cookies: string | null = null; private async fetchCrumb(): Promise<void> { if (this.crumb && this.cookies) return; // First, get cookies from Yahoo Finance const initResponse = await fetch("https://fc.yahoo.com", { headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", }, }); // Get set-cookie header const setCookie = initResponse.headers.get("set-cookie"); if (setCookie) { this.cookies = setCookie; } // Now get crumb const crumbResponse = await fetch("https://query2.finance.yahoo.com/v1/test/getcrumb", { headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Cookie": this.cookies || "", }, }); if (crumbResponse.ok) { this.crumb = await crumbResponse.text(); } } async getQuote(symbol: string): Promise<Quote> { const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?interval=1d&range=1d`; const response = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0", }, }); if (!response.ok) { throw new Error(`Failed to fetch quote for ${symbol}: ${response.statusText}`); } const data = (await response.json()) as { chart?: { result?: Array<{ meta: { symbol?: string; regularMarketPrice?: number; previousClose?: number } }> }; }; const result = data.chart?.result?.[0]; if (!result) { throw new Error(`No data found for symbol ${symbol}`); } const meta = result.meta; const price = meta.regularMarketPrice ?? meta.previousClose; if (price === undefined) { throw new Error(`No price data available for ${symbol}`); } return { symbol: meta.symbol || symbol, price, timestamp: Date.now(), }; } async getOptionsChain(params: OptionsChainParams): Promise<OptionsChain> { const { symbol, expiration } = params; // Ensure we have auth await this.fetchCrumb(); // Build URL - Yahoo's options endpoint let url = `https://query2.finance.yahoo.com/v7/finance/options/${encodeURIComponent(symbol)}`; const queryParams: string[] = []; if (expiration) { // Yahoo expects Unix timestamp for expiration const expDate = new Date(expiration); const expTimestamp = Math.floor(expDate.getTime() / 1000); queryParams.push(`date=${expTimestamp}`); } if (this.crumb) { queryParams.push(`crumb=${encodeURIComponent(this.crumb)}`); } if (queryParams.length > 0) { url += `?${queryParams.join("&")}`; } const response = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept": "application/json", "Accept-Language": "en-US,en;q=0.9", "Cookie": this.cookies || "", }, }); if (!response.ok) { throw new Error(`Failed to fetch options chain for ${symbol}: ${response.statusText}`); } const data = (await response.json()) as { optionChain?: { result?: Array<{ underlyingSymbol?: string; expirationDates?: number[]; quote?: { regularMarketPrice?: number; }; options?: Array<{ expirationDate?: number; calls?: YahooOptionQuote[]; puts?: YahooOptionQuote[]; }>; }>; error?: { description?: string }; }; }; if (data.optionChain?.error) { throw new Error(`Options data not found for ${symbol}: ${data.optionChain.error.description || "Unknown error"}`); } const result = data.optionChain?.result?.[0]; if (!result) { throw new Error(`No options data found for symbol ${symbol}`); } const underlyingPrice = result.quote?.regularMarketPrice ?? 0; const now = new Date(); const asOf = now.toISOString(); // Convert expiration timestamps to ISO dates const expirations = (result.expirationDates || []).map((ts) => new Date(ts * 1000).toISOString().split("T")[0] ); // Parse option contracts const contracts: OptionContract[] = []; for (const optionData of result.options || []) { const expDateTs = optionData.expirationDate || 0; const expDate = new Date(expDateTs * 1000); const expirationStr = expDate.toISOString().split("T")[0]; const timeToMaturityYears = Math.max(0, (expDate.getTime() - now.getTime()) / (365.25 * 24 * 60 * 60 * 1000)); // Process calls for (const call of optionData.calls || []) { contracts.push(parseYahooOption(symbol, call, "call", expirationStr, timeToMaturityYears)); } // Process puts for (const put of optionData.puts || []) { contracts.push(parseYahooOption(symbol, put, "put", expirationStr, timeToMaturityYears)); } } return { symbol, asOf, underlyingPrice, expirations, contracts, }; } async getHistoricalOHLCV(params: { symbol: string; start: Date; end: Date; interval: "1d" | "1h" | "5m"; }): Promise<OHLCV[]> { const { symbol, start, end, interval } = params; const period1 = Math.floor(start.getTime() / 1000); const period2 = Math.floor(end.getTime() / 1000); const intervalMap: Record<string, string> = { "1d": "1d", "1h": "1h", "5m": "5m", }; const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?period1=${period1}&period2=${period2}&interval=${intervalMap[interval]}`; const response = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0", }, }); if (!response.ok) { throw new Error( `Failed to fetch historical data for ${symbol}: ${response.statusText}` ); } const data = (await response.json()) as { chart?: { result?: Array<{ timestamp?: number[]; indicators?: { quote?: Array<{ open?: (number | null)[]; high?: (number | null)[]; low?: (number | null)[]; close?: (number | null)[]; volume?: (number | null)[]; }>; }; }>; }; }; const result = data.chart?.result?.[0]; if (!result) { throw new Error(`No historical data found for symbol ${symbol}`); } const timestamps = result.timestamp || []; const quote = result.indicators?.quote?.[0] || {}; const candles: OHLCV[] = []; for (let i = 0; i < timestamps.length; i++) { const open = quote.open?.[i]; const high = quote.high?.[i]; const low = quote.low?.[i]; const close = quote.close?.[i]; const volume = quote.volume?.[i]; if (open != null && high != null && low != null && close != null) { candles.push({ timestamp: timestamps[i] * 1000, // Convert to milliseconds open, high, low, close, volume: volume ?? undefined, }); } } return candles; } /** * Get risk-free rate curve (simplified flat rate) */ async getRateCurve(): Promise<RatePoint[]> { // Return a flat rate curve (simplified - in production would fetch from FRED or Treasury) const flatRate = 0.045; // ~4.5% as of late 2024 return [ { maturityYears: 0.25, rate: flatRate }, { maturityYears: 0.5, rate: flatRate }, { maturityYears: 1, rate: flatRate }, { maturityYears: 2, rate: flatRate }, { maturityYears: 5, rate: flatRate }, ]; } /** * Get dividend yield for a symbol (simplified) */ async getDividendYield(_symbol: string): Promise<number> { // Simplified: return 0 for all symbols // In production, would fetch from Yahoo's quote data return 0; } } /** * Alpha Vantage provider (requires API key) */ export class AlphaVantageProvider implements MarketDataProvider { readonly name = "Alpha Vantage"; private apiKey: string; private baseUrl = "https://www.alphavantage.co/query"; constructor(apiKey: string) { this.apiKey = apiKey; } async getQuote(symbol: string): Promise<Quote> { const url = `${this.baseUrl}?function=GLOBAL_QUOTE&symbol=${encodeURIComponent(symbol)}&apikey=${this.apiKey}`; const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to fetch quote for ${symbol}: ${response.statusText}`); } const data = (await response.json()) as { "Global Quote"?: { "01. symbol"?: string; "05. price"?: string; }; }; const quote = data["Global Quote"]; if (!quote || !quote["05. price"]) { throw new Error(`No data found for symbol ${symbol}`); } return { symbol: quote["01. symbol"] || symbol, price: parseFloat(quote["05. price"]), timestamp: Date.now(), }; } async getHistoricalOHLCV(params: { symbol: string; start: Date; end: Date; interval: "1d" | "1h" | "5m"; }): Promise<OHLCV[]> { const { symbol, start, end, interval } = params; let functionName: string; let timeKey: string; if (interval === "1d") { functionName = "TIME_SERIES_DAILY"; timeKey = "Time Series (Daily)"; } else if (interval === "1h") { functionName = "TIME_SERIES_INTRADAY"; timeKey = "Time Series (60min)"; } else { functionName = "TIME_SERIES_INTRADAY"; timeKey = "Time Series (5min)"; } let url = `${this.baseUrl}?function=${functionName}&symbol=${encodeURIComponent(symbol)}&apikey=${this.apiKey}&outputsize=full`; if (interval !== "1d") { url += `&interval=${interval === "1h" ? "60min" : "5min"}`; } const response = await fetch(url); if (!response.ok) { throw new Error( `Failed to fetch historical data for ${symbol}: ${response.statusText}` ); } const data = (await response.json()) as Record<string, Record<string, Record<string, string>> | undefined>; const timeSeries = data[timeKey]; if (!timeSeries) { throw new Error(`No historical data found for symbol ${symbol}`); } const candles: OHLCV[] = []; const startTime = start.getTime(); const endTime = end.getTime(); for (const [dateStr, values] of Object.entries(timeSeries)) { const timestamp = new Date(dateStr).getTime(); if (timestamp >= startTime && timestamp <= endTime) { const v = values as Record<string, string>; candles.push({ timestamp, open: parseFloat(v["1. open"]), high: parseFloat(v["2. high"]), low: parseFloat(v["3. low"]), close: parseFloat(v["4. close"]), volume: parseInt(v["5. volume"], 10), }); } } // Sort by timestamp ascending candles.sort((a, b) => a.timestamp - b.timestamp); return candles; } async getOptionsChain(_params: OptionsChainParams): Promise<OptionsChain> { throw new Error("Options chain data not available from Alpha Vantage provider"); } } // ============================================================================ // Polygon.io Types // ============================================================================ interface PolygonTickerSnapshot { ticker?: string; day?: { c?: number; h?: number; l?: number; o?: number; v?: number }; lastTrade?: { p?: number; t?: number }; prevDay?: { c?: number }; } interface PolygonAggBar { t: number; // Unix ms timestamp o: number; // Open h: number; // High l: number; // Low c: number; // Close v: number; // Volume } interface PolygonOptionContract { ticker?: string; underlying_ticker?: string; contract_type?: "call" | "put"; strike_price?: number; expiration_date?: string; } interface PolygonOptionQuote { day?: { c?: number; o?: number; h?: number; l?: number; v?: number }; last_quote?: { bid?: number; ask?: number; bid_size?: number; ask_size?: number }; open_interest?: number; implied_volatility?: number; greeks?: { delta?: number; gamma?: number; theta?: number; vega?: number; }; } /** * Polygon.io provider - Professional-grade market data * * Features: * - Real-time & delayed US stocks, options, FX, crypto * - Full options chain with Greeks and IV * - High-quality historical data * * Requires API key: Set POLYGON_API_KEY environment variable * Get one at: https://polygon.io (free tier available) */ export class PolygonProvider implements MarketDataProvider { readonly name = "Polygon.io"; private apiKey: string; private baseUrl = "https://api.polygon.io"; constructor(apiKey?: string) { this.apiKey = apiKey || process.env.POLYGON_API_KEY || ""; if (!this.apiKey) { throw new Error( "Polygon API key required. Set POLYGON_API_KEY environment variable or pass to constructor." ); } } private async fetchJson<T>(url: string): Promise<T> { const separator = url.includes("?") ? "&" : "?"; const fullUrl = `${url}${separator}apiKey=${this.apiKey}`; const response = await fetch(fullUrl, { headers: { "User-Agent": "QuantCompanion/1.0", }, }); if (!response.ok) { const errorText = await response.text().catch(() => "Unknown error"); if (response.status === 403) { throw new Error(`Polygon API access denied. Check your API key and subscription tier.`); } if (response.status === 429) { throw new Error(`Polygon API rate limit exceeded. Please wait and try again.`); } throw new Error(`Polygon API error (${response.status}): ${errorText}`); } return response.json() as Promise<T>; } async getQuote(symbol: string): Promise<Quote> { const upperSymbol = symbol.toUpperCase(); // Use ticker snapshot endpoint for real-time quote const data = await this.fetchJson<{ status?: string; ticker?: PolygonTickerSnapshot; }>(`${this.baseUrl}/v2/snapshot/locale/us/markets/stocks/tickers/${upperSymbol}`); if (!data.ticker) { throw new Error(`No data found for symbol ${upperSymbol}`); } const snapshot = data.ticker; // Prefer last trade price, fall back to day close, then prev day close const price = snapshot.lastTrade?.p ?? snapshot.day?.c ?? snapshot.prevDay?.c; if (price === undefined) { throw new Error(`No price data available for ${upperSymbol}`); } return { symbol: upperSymbol, price, timestamp: snapshot.lastTrade?.t ?? Date.now(), }; } async getHistoricalOHLCV(params: { symbol: string; start: Date; end: Date; interval: "1d" | "1h" | "5m"; }): Promise<OHLCV[]> { const { symbol, start, end, interval } = params; const upperSymbol = symbol.toUpperCase(); // Map interval to Polygon timespan/multiplier const timespanMap: Record<string, { multiplier: number; timespan: string }> = { "1d": { multiplier: 1, timespan: "day" }, "1h": { multiplier: 1, timespan: "hour" }, "5m": { multiplier: 5, timespan: "minute" }, }; const { multiplier, timespan } = timespanMap[interval]; const startStr = start.toISOString().split("T")[0]; const endStr = end.toISOString().split("T")[0]; // Polygon aggregates endpoint const url = `${this.baseUrl}/v2/aggs/ticker/${upperSymbol}/range/${multiplier}/${timespan}/${startStr}/${endStr}?adjusted=true&sort=asc&limit=50000`; const data = await this.fetchJson<{ status?: string; resultsCount?: number; results?: PolygonAggBar[]; }>(url); if (!data.results || data.results.length === 0) { throw new Error(`No historical data found for ${upperSymbol} in the specified period`); } return data.results.map((bar) => ({ timestamp: bar.t, open: bar.o, high: bar.h, low: bar.l, close: bar.c, volume: bar.v, })); } async getOptionsChain(params: OptionsChainParams): Promise<OptionsChain> { const { symbol, expiration } = params; const upperSymbol = symbol.toUpperCase(); // First get underlying price const quote = await this.getQuote(upperSymbol); const underlyingPrice = quote.price; // Build options chain URL // Reference endpoint for all contracts let contractsUrl = `${this.baseUrl}/v3/reference/options/contracts?underlying_ticker=${upperSymbol}&limit=1000`; if (expiration) { contractsUrl += `&expiration_date=${expiration}`; } else { // Get near-term expirations (next 60 days) const futureDate = new Date(); futureDate.setDate(futureDate.getDate() + 60); contractsUrl += `&expiration_date.lte=${futureDate.toISOString().split("T")[0]}`; } // Fetch contracts const contractsData = await this.fetchJson<{ status?: string; results?: PolygonOptionContract[]; next_url?: string; }>(contractsUrl); if (!contractsData.results || contractsData.results.length === 0) { throw new Error(`No options contracts found for ${upperSymbol}`); } // Build set of expirations const expirationSet = new Set<string>(); const now = new Date(); // Now fetch snapshot data for these contracts (includes IV, Greeks, quotes) const snapshotUrl = `${this.baseUrl}/v3/snapshot/options/${upperSymbol}?limit=250`; const snapshotData = await this.fetchJson<{ status?: string; results?: Array<{ details?: PolygonOptionContract; day?: { c?: number; o?: number; h?: number; l?: number; v?: number }; last_quote?: { bid?: number; ask?: number }; open_interest?: number; implied_volatility?: number; greeks?: { delta?: number; gamma?: number; theta?: number; vega?: number }; }>; }>(snapshotUrl); const contracts: OptionContract[] = []; // Process snapshot data if available if (snapshotData.results && snapshotData.results.length > 0) { for (const opt of snapshotData.results) { const details = opt.details; if (!details) continue; const expDate = details.expiration_date; if (!expDate) continue; // Filter by requested expiration if specified if (expiration && expDate !== expiration) continue; expirationSet.add(expDate); const strike = details.strike_price ?? 0; const right = details.contract_type === "put" ? "put" : "call"; const optionExpDate = new Date(expDate); const timeToMaturityYears = Math.max( 0, (optionExpDate.getTime() - now.getTime()) / (365.25 * 24 * 60 * 60 * 1000) ); const bid = opt.last_quote?.bid ?? 0; const ask = opt.last_quote?.ask ?? 0; const mid = (bid + ask) / 2; contracts.push({ underlying: upperSymbol, optionSymbol: details.ticker || "", right, strike, expiration: expDate, timeToMaturityYears, lastPrice: opt.day?.c ?? mid, bid, ask, mid, volume: opt.day?.v ?? 0, openInterest: opt.open_interest ?? 0, impliedVol: opt.implied_volatility, }); } } // If snapshot didn't return enough data, supplement from contracts reference if (contracts.length === 0) { // Fallback: build contracts from reference data (without live quotes) for (const contract of contractsData.results) { const expDate = contract.expiration_date; if (!expDate) continue; if (expiration && expDate !== expiration) continue; expirationSet.add(expDate); const strike = contract.strike_price ?? 0; const right = contract.contract_type === "put" ? "put" : "call"; const optionExpDate = new Date(expDate); const timeToMaturityYears = Math.max( 0, (optionExpDate.getTime() - now.getTime()) / (365.25 * 24 * 60 * 60 * 1000) ); contracts.push({ underlying: upperSymbol, optionSymbol: contract.ticker || "", right, strike, expiration: expDate, timeToMaturityYears, lastPrice: 0, bid: 0, ask: 0, mid: 0, volume: 0, openInterest: 0, impliedVol: undefined, }); } } // Sort expirations const expirations = Array.from(expirationSet).sort(); return { symbol: upperSymbol, asOf: new Date().toISOString(), underlyingPrice, expirations, contracts, }; } /** * Get risk-free rate curve (simplified flat rate) */ async getRateCurve(): Promise<RatePoint[]> { const flatRate = 0.045; return [ { maturityYears: 0.25, rate: flatRate }, { maturityYears: 0.5, rate: flatRate }, { maturityYears: 1, rate: flatRate }, { maturityYears: 2, rate: flatRate }, { maturityYears: 5, rate: flatRate }, ]; } /** * Get dividend yield for a symbol */ async getDividendYield(_symbol: string): Promise<number> { return 0; } } /** * Fallback provider that cascades through multiple providers * * Tries providers in order, falling back to next on: * - Rate limit errors (429) * - API errors * - Data not found * * This gives you Polygon quality when available, with Yahoo as unlimited backup. */ export class FallbackProvider implements MarketDataProvider { readonly name: string; private providers: MarketDataProvider[]; private lastUsed: Map<string, number> = new Map(); // Track which provider worked last constructor(providers: MarketDataProvider[]) { if (providers.length === 0) { throw new Error("FallbackProvider requires at least one provider"); } this.providers = providers; this.name = `Fallback(${providers.map(p => p.name).join(" → ")})`; } private async tryProviders<T>( operation: string, fn: (provider: MarketDataProvider) => Promise<T> ): Promise<T> { const errors: string[] = []; for (let i = 0; i < this.providers.length; i++) { const provider = this.providers[i]; try { const result = await fn(provider); // Track successful provider for logging this.lastUsed.set(operation, i); return result; } catch (error) { const message = error instanceof Error ? error.message : String(error); errors.push(`[${provider.name}] ${message}`); // Log fallback (but not for last provider) if (i < this.providers.length - 1) { console.error(`[FallbackProvider] ${provider.name} failed for ${operation}, trying ${this.providers[i + 1].name}...`); } } } // All providers failed throw new Error(`All providers failed:\n${errors.join("\n")}`); } async getQuote(symbol: string): Promise<Quote> { return this.tryProviders(`getQuote(${symbol})`, (p) => p.getQuote(symbol)); } async getHistoricalOHLCV(params: { symbol: string; start: Date; end: Date; interval: "1d" | "1h" | "5m"; }): Promise<OHLCV[]> { return this.tryProviders( `getHistoricalOHLCV(${params.symbol})`, (p) => p.getHistoricalOHLCV(params) ); } async getOptionsChain(params: OptionsChainParams): Promise<OptionsChain> { return this.tryProviders( `getOptionsChain(${params.symbol})`, (p) => p.getOptionsChain(params) ); } async getRateCurve(): Promise<RatePoint[]> { // Try first provider that implements it for (const provider of this.providers) { if (provider.getRateCurve) { try { return await provider.getRateCurve(); } catch { continue; } } } // Default flat rate return [{ maturityYears: 1, rate: 0.045 }]; } async getDividendYield(symbol: string): Promise<number> { // Try first provider that implements it for (const provider of this.providers) { if (provider.getDividendYield) { try { return await provider.getDividendYield(symbol); } catch { continue; } } } return 0; } } // Default provider instance let defaultProvider: MarketDataProvider = new YahooFinanceProvider(); export function setDefaultProvider(provider: MarketDataProvider): void { defaultProvider = provider; } export function getDefaultProvider(): MarketDataProvider { return defaultProvider; } /** * Create the recommended provider setup: * - If POLYGON_API_KEY is set: Polygon → Yahoo fallback * - Otherwise: Yahoo only */ export function createDefaultProvider(): MarketDataProvider { const polygonKey = process.env.POLYGON_API_KEY; if (polygonKey) { return new FallbackProvider([ new PolygonProvider(polygonKey), new YahooFinanceProvider(), ]); } return new YahooFinanceProvider(); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Ademscodeisnotsobad/Quant-Companion-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server