const YAHOO_BASE = 'https://query1.finance.yahoo.com/v8/finance/chart'; const QUOTE_CACHE_TTL_MS = 1000 * 60; const PRICE_HISTORY_CACHE_TTL_MS = 1000 * 60 * 15; const FAILURE_CACHE_TTL_MS = 1000 * 30; export type QuoteResult = { value: number | null; stale: boolean; }; export type PriceHistoryResult = { value: Array<{ date: string; close: number }> | null; stale: boolean; }; type QuoteCacheEntry = { expiresAt: number; value: number | null; }; type PriceHistoryCacheEntry = { expiresAt: number; value: Array<{ date: string; close: number }> | null; }; const quoteCache = new Map(); const priceHistoryCache = new Map(); function buildYahooChartUrl(ticker: string, params: string) { return `${YAHOO_BASE}/${encodeURIComponent(ticker.trim().toUpperCase())}?${params}`; } export async function getQuote(ticker: string): Promise { const normalizedTicker = ticker.trim().toUpperCase(); const cached = quoteCache.get(normalizedTicker); if (cached && cached.expiresAt > Date.now()) { return { value: cached.value, stale: false }; } const staleEntry = cached; try { const response = await fetch(buildYahooChartUrl(normalizedTicker, 'interval=1d&range=1d'), { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; FiscalClone/3.0)' }, cache: 'no-store' }); if (!response.ok) { if (staleEntry?.value !== null && staleEntry?.value !== undefined) { return { value: staleEntry.value, stale: true }; } quoteCache.set(normalizedTicker, { value: null, expiresAt: Date.now() + FAILURE_CACHE_TTL_MS }); return { value: null, stale: false }; } const payload = await response.json() as { chart?: { result?: Array<{ meta?: { regularMarketPrice?: number } }>; }; }; const price = payload.chart?.result?.[0]?.meta?.regularMarketPrice; if (typeof price !== 'number' || !Number.isFinite(price)) { if (staleEntry?.value !== null && staleEntry?.value !== undefined) { return { value: staleEntry.value, stale: true }; } quoteCache.set(normalizedTicker, { value: null, expiresAt: Date.now() + FAILURE_CACHE_TTL_MS }); return { value: null, stale: false }; } quoteCache.set(normalizedTicker, { value: price, expiresAt: Date.now() + QUOTE_CACHE_TTL_MS }); return { value: price, stale: false }; } catch { if (staleEntry?.value !== null && staleEntry?.value !== undefined) { return { value: staleEntry.value, stale: true }; } quoteCache.set(normalizedTicker, { value: null, expiresAt: Date.now() + FAILURE_CACHE_TTL_MS }); return { value: null, stale: false }; } } export async function getQuoteOrNull(ticker: string): Promise { const result = await getQuote(ticker); return result.value; } export async function getHistoricalClosingPrices(ticker: string, dates: string[]) { const normalizedTicker = ticker.trim().toUpperCase(); const normalizedDates = dates .map((value) => { const parsed = Date.parse(value); return Number.isFinite(parsed) ? { raw: value, iso: new Date(parsed).toISOString().slice(0, 10), epoch: parsed } : null; }) .filter((entry): entry is { raw: string; iso: string; epoch: number } => entry !== null); if (normalizedDates.length === 0) { return {} as Record; } try { const response = await fetch(buildYahooChartUrl(normalizedTicker, 'interval=1d&range=20y'), { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; FiscalClone/3.0)' }, cache: 'no-store' }); if (!response.ok) { return Object.fromEntries(normalizedDates.map((entry) => [entry.raw, null])); } const payload = await response.json() as { chart?: { result?: Array<{ timestamp?: number[]; indicators?: { quote?: Array<{ close?: Array; }>; }; }>; }; }; const result = payload.chart?.result?.[0]; const timestamps = result?.timestamp ?? []; const closes = result?.indicators?.quote?.[0]?.close ?? []; const points = timestamps .map((timestamp, index) => { const close = closes[index]; if (typeof close !== 'number' || !Number.isFinite(close)) { return null; } return { epoch: timestamp * 1000, close }; }) .filter((entry): entry is { epoch: number; close: number } => entry !== null); return Object.fromEntries(normalizedDates.map((entry) => { const point = [...points] .reverse() .find((candidate) => candidate.epoch <= entry.epoch) ?? null; return [entry.raw, point?.close ?? null]; })); } catch { return Object.fromEntries(normalizedDates.map((entry) => [entry.raw, null])); } } export async function getPriceHistory(ticker: string): Promise { const normalizedTicker = ticker.trim().toUpperCase(); const cached = priceHistoryCache.get(normalizedTicker); if (cached && cached.expiresAt > Date.now()) { return { value: cached.value, stale: false }; } const staleEntry = cached; try { const response = await fetch(buildYahooChartUrl(normalizedTicker, 'interval=1wk&range=20y'), { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; FiscalClone/3.0)' }, cache: 'no-store' }); if (!response.ok) { if (staleEntry?.value !== null && staleEntry?.value !== undefined) { return { value: staleEntry.value, stale: true }; } priceHistoryCache.set(normalizedTicker, { value: null, expiresAt: Date.now() + FAILURE_CACHE_TTL_MS }); return { value: null, stale: false }; } const payload = await response.json() as { chart?: { result?: Array<{ timestamp?: number[]; indicators?: { quote?: Array<{ close?: Array; }>; }; }>; }; }; const result = payload.chart?.result?.[0]; const timestamps = result?.timestamp ?? []; const closes = result?.indicators?.quote?.[0]?.close ?? []; const points = timestamps .map((timestamp, index) => { const close = closes[index]; if (typeof close !== 'number' || !Number.isFinite(close)) { return null; } return { date: new Date(timestamp * 1000).toISOString(), close }; }) .filter((entry): entry is { date: string; close: number } => entry !== null); if (points.length === 0) { if (staleEntry?.value !== null && staleEntry?.value !== undefined) { return { value: staleEntry.value, stale: true }; } priceHistoryCache.set(normalizedTicker, { value: null, expiresAt: Date.now() + FAILURE_CACHE_TTL_MS }); return { value: null, stale: false }; } priceHistoryCache.set(normalizedTicker, { value: points, expiresAt: Date.now() + PRICE_HISTORY_CACHE_TTL_MS }); return { value: points, stale: false }; } catch { if (staleEntry?.value !== null && staleEntry?.value !== undefined) { return { value: staleEntry.value, stale: true }; } priceHistoryCache.set(normalizedTicker, { value: null, expiresAt: Date.now() + FAILURE_CACHE_TTL_MS }); return { value: null, stale: false }; } } export const __pricesInternals = { FAILURE_CACHE_TTL_MS, PRICE_HISTORY_CACHE_TTL_MS, QUOTE_CACHE_TTL_MS, resetCaches() { quoteCache.clear(); priceHistoryCache.clear(); } };