import { hasAsmlEarningsIdentity, promoteReportedEarningsResults } from "../utils/reported-earnings-result"; import { exchangeRateMetadata } from "../utils/exchange-rate-snapshot"; import { fxFreshUntil } from "../utils/fx-market-hours"; import type { ExchangeRateSnapshot } from "../types/exchange-rate"; import type { Quote, PricePoint, TickerFinancials, OptionsChain, CompanyProfile, HolderData, AnalystResearchData, CorporateActionsData } from "../types/financials"; import type { PriceHistoryResult } from "../types/price-history"; import type { DataProvider, EarningsEvent, MarketDataRequestContext, NewsItem, SecFilingItem } from "../types/data-provider"; import type { TimeRange } from "../time-series/range"; import { type ChartResolutionSupport, type ManualChartResolution, } from "../time-series/resolution"; import type { InstrumentSearchResult } from "../types/instrument"; import { parseOptionSymbol } from "../utils/options"; import { SecEdgarClient } from "./sec-edgar"; import { mergeFinancialStatementRows } from "../utils/financial-statements"; import { hasShopOperatingIdentity, normalizeFinancialOperatingResults } from "../utils/operating-result"; import { withdrawKnownProviderStatements } from "../utils/statement-observations"; import { YahooHttpClient } from "./yahoo-finance/http"; import { normalizeSubUnitCurrency, } from "./yahoo-finance/mappers"; import { getYahooSymbol, getYahooSymbolsToTry } from "./yahoo-finance/symbols"; import type { ChartResult } from "./yahoo-finance/types"; import { fetchYahooAssetProfile, fetchYahooChart, fetchYahooExtendedHoursData, fetchYahooQuoteSupplement, fetchYahooTimeseries, type YahooQuoteSupplement, } from "./yahoo-finance/requests"; import { getYahooChartResolutionCapabilities, getYahooChartResolutionSupport, loadYahooPriceHistoryWithMetadata, loadYahooPriceHistoryForResolutionWithMetadata, } from "./yahoo-finance/history"; import { getYahooOptionQuote, loadYahooOptionsChain, loadYahooOptionsChainResult, } from "./yahoo-finance/options"; import { loadYahooAnalystResearch, loadYahooCorporateActions, loadYahooEarningsCalendar, loadYahooHolders, } from "./yahoo-finance/quote-summary"; import { loadYahooQuote, loadYahooTickerFinancials, } from "./yahoo-finance/snapshots"; const SEC_STATEMENT_SUPPLEMENT_EXCHANGES = new Set([ "", "AMEX", "ARCA", "BATS", "BYX", "IEX", "NASDAQ", "NMS", "NYSE", "NYSEARCA", "OTC", "PINK", ]); export class YahooFinanceClient implements DataProvider { readonly id = "yahoo"; readonly name = "Yahoo Finance"; private readonly secClient = new SecEdgarClient(); constructor(private readonly http = new YahooHttpClient()) {} private shouldSupplementSecStatements(ticker: string, exchange: string, financials: TickerFinancials): boolean { if (!/^[A-Z0-9.-]+$/i.test(ticker.trim())) return false; if (ticker.includes(".") && !/^[A-Z]+\.[AB]$/i.test(ticker)) return false; const normalizedExchange = exchange.trim().toUpperCase(); return (SEC_STATEMENT_SUPPLEMENT_EXCHANGES.has(normalizedExchange) || hasShopOperatingIdentity(financials, { symbol: ticker, exchange })) && (financials.quote?.currency ?? "USD").toUpperCase() === "USD" && ![financials.financialCurrency, ...financials.annualStatements.map((row) => row.currency), ...financials.quarterlyStatements.map((row) => row.currency)].some((currency) => currency != null && currency.toUpperCase() !== "USD"); } private async supplementSecStatements( ticker: string, exchange: string, financials: TickerFinancials, extended = false, ): Promise { const stamp = (status: "available" | "unsupported" | "retryable-failure") => extended ? { mode: "extended" as const, source: "sec" as const, status, fetchedAt: new Date().toISOString() } : undefined; if (hasAsmlEarningsIdentity(financials, { symbol: ticker, exchange })) { // Listing qualification above permits the Amsterdam symbol to use the // verified issuer lookup. Annual EPS does not establish full SEC history. const source = await this.secClient.getFinancialStatements("ASML", { reportedEarningsResults: true }); return { ...financials, statementHistory: stamp("unsupported"), earningsHistoryRetryAt: this.secClient.getEarningsHistoryRetryAt(), annualStatements: promoteReportedEarningsResults(financials.annualStatements, source?.reportedEarningsResults ?? [], "annual"), }; } if (!this.shouldSupplementSecStatements(ticker, exchange, financials)) return { ...financials, statementHistory: stamp("unsupported") }; const operatingTarget = hasShopOperatingIdentity(financials, { symbol: ticker, exchange }); const retry = () => operatingTarget ? this.secClient.getOperatingTablesRetryAt(ticker) : undefined; try { const secStatements = await this.secClient.getFinancialStatements(ticker, { reportedOperatingResults: operatingTarget }); if ( !secStatements || (secStatements.annualStatements.length === 0 && secStatements.quarterlyStatements.length === 0) ) { return { ...financials, statementHistory: stamp("unsupported"), operatingHistoryRetryAt: retry() }; } if (!operatingTarget) { for (const row of [...secStatements.annualStatements, ...secStatements.quarterlyStatements]) delete row.operatingResult; } return normalizeFinancialOperatingResults({ ...financials, financialCurrency: financials.financialCurrency ?? "USD", statementHistory: stamp("available"), operatingHistoryRetryAt: retry(), annualStatements: extended ? mergeFinancialStatementRows(secStatements.annualStatements, financials.annualStatements) : mergeFinancialStatementRows(financials.annualStatements, secStatements.annualStatements), quarterlyStatements: extended ? mergeFinancialStatementRows(secStatements.quarterlyStatements, financials.quarterlyStatements) : mergeFinancialStatementRows(financials.quarterlyStatements, secStatements.quarterlyStatements), }, { symbol: ticker, exchange }); } catch { return { ...financials, statementHistory: stamp("retryable-failure"), operatingHistoryRetryAt: retry() }; } } private async fetchChart(symbol: string, range: string, interval = "1d", includePrePost = false) { return fetchYahooChart(this.http, symbol, range, interval, includePrePost); } /** Fetch extended hours data using 1d intraday chart with pre/post market included */ private async fetchExtendedHoursData( symbol: string, meta: NonNullable, regularClose?: number, ) { return fetchYahooExtendedHoursData(this.http, symbol, meta, regularClose); } private async fetchTimeseries(symbol: string, types: string[], period1 = "2010-01-01") { return fetchYahooTimeseries(this.http, symbol, types, period1); } private async fetchAssetProfile(symbol: string): Promise { return fetchYahooAssetProfile(this.http, symbol); } private async fetchQuoteSupplement( symbol: string, currencyDivisor = 1, ): Promise { return fetchYahooQuoteSupplement(this.http, symbol, currencyDivisor); } /** Fetch full financials for a ticker */ async getTickerFinancials(ticker: string, exchange = "", context?: MarketDataRequestContext): Promise { const symbolsToTry = getYahooSymbolsToTry(ticker, exchange); let lastError: any; for (const symbol of symbolsToTry) { try { const result = await loadYahooTickerFinancials(symbol, { fetchAssetProfile: (targetSymbol) => this.fetchAssetProfile(targetSymbol), fetchChart: (targetSymbol, range, interval) => this.fetchChart(targetSymbol, range, interval), fetchExtendedHoursData: (targetSymbol, meta, regularClose) => ( this.fetchExtendedHoursData(targetSymbol, meta, regularClose) ), fetchQuoteSupplement: (targetSymbol, currencyDivisor) => this.fetchQuoteSupplement(targetSymbol, currencyDivisor), fetchTimeseries: (targetSymbol, types, period1) => this.fetchTimeseries(targetSymbol, types, period1), providerId: this.id, }); return withdrawKnownProviderStatements( await this.supplementSecStatements(ticker, exchange, result, context?.statementHistory === "extended"), { symbol: ticker, exchange }, "provider:yahoo", ); } catch (err) { lastError = err; } } throw lastError || new Error(`No data for ${ticker}`); } /** Fetch just a quote (lighter weight) */ async getQuote(ticker: string, exchange = "", context?: MarketDataRequestContext): Promise { if (parseOptionSymbol(ticker)) { return getYahooOptionQuote({ context, getOptionsChainResult: (underlying, requestExchange, expirationDate) => ( loadYahooOptionsChainResult({ exchange: requestExchange ?? "", expirationDate, fetchJsonWithCrumb: (url) => this.http.fetchJsonWithCrumb(url), ticker: underlying, }) ), providerId: this.id, ticker, }); } const symbolsToTry = getYahooSymbolsToTry(ticker, exchange); let lastError: any; for (const symbol of symbolsToTry) { try { return await loadYahooQuote(symbol, { fetchChart: (targetSymbol, range, interval) => this.fetchChart(targetSymbol, range, interval), fetchExtendedHoursData: (targetSymbol, meta, regularClose) => ( this.fetchExtendedHoursData(targetSymbol, meta, regularClose) ), fetchQuoteSupplement: (targetSymbol, currencyDivisor) => this.fetchQuoteSupplement(targetSymbol, currencyDivisor), providerId: this.id, }); } catch (err) { lastError = err; } } throw lastError || new Error(`No quote for ${ticker}`); } /** Fetch exchange rate to USD. */ async getExchangeRate(fromCurrency: string): Promise { return (await this.getExchangeRateSnapshot(fromCurrency)).rate; } async getExchangeRateSnapshot(fromCurrency: string): Promise { const { currency } = normalizeSubUnitCurrency(fromCurrency); const normalized = currency.trim().toUpperCase(); if (!/^[A-Z]{3}$/.test(normalized)) throw new Error("Exchange rates require a three-letter currency code"); const fetchedAt = new Date().toISOString(); if (normalized === "USD") return { fromCurrency: normalized, toCurrency: "USD", rate: 1, source: "identity", fetchedAt, stale: false }; const { meta, history } = await this.fetchChart(`${normalized}USD=X`, "1mo"); if ((meta.symbol && meta.symbol !== `${normalized}USD=X`) || (meta.currency && meta.currency !== "USD")) { throw new Error(`Exchange rate pair mismatch for ${normalized}/USD`); } const last = history.at(-1); const hasCurrent = typeof meta.regularMarketPrice === "number" && Number.isFinite(meta.regularMarketPrice) && meta.regularMarketPrice > 0; const currentTime = Number(meta.regularMarketTime) * 1000; // Yahoo rounds regularMarketPrice (JPY/USD can become 0.0065). Prefer // the full-precision chart close when it describes the same observation. const useBar = last && (!hasCurrent || last.date.getTime() >= currentTime); const rate = useBar ? last.close : hasCurrent ? meta.regularMarketPrice! : undefined; if (typeof rate !== "number" || !Number.isFinite(rate) || rate <= 0) throw new Error(`No exchange rate data for ${normalized}/USD`); const time = useBar ? last.date.getTime() : currentTime; const asOf = typeof time === "number" && Number.isFinite(time) && time > 0 ? new Date(time).toISOString() : undefined; const retrieved = Date.now(); const staleAt = Math.min(fxFreshUntil(retrieved, 60 * 60_000), asOf ? fxFreshUntil(Date.parse(asOf), 60 * 60_000) : Infinity); const snapshot: ExchangeRateSnapshot = { fromCurrency: normalized, toCurrency: "USD", rate, source: this.id, asOf, fetchedAt: new Date(retrieved).toISOString(), staleAt: new Date(staleAt).toISOString(), stale: staleAt <= retrieved, delayMinutes: 0 }; exchangeRateMetadata(snapshot, normalized, retrieved); return snapshot; } /** Search for a ticker by name/symbol - uses direct fetch (no retry) for speed */ async search(query: string): Promise { const url = `https://query1.finance.yahoo.com/v1/finance/search?q=${encodeURIComponent(query)}"esCount=10&newsCount=0`; try { const resp = await fetch(url, { headers: this.http.defaultHeaders(), signal: AbortSignal.timeout(5000), }); if (!resp.ok) return []; const data = await resp.json() as any; return (data.quotes || []).map((q: any) => ({ providerId: this.id, symbol: q.symbol || "", name: q.shortname || q.longname || "", exchange: q.exchDisp || q.exchange || "", type: q.quoteType || "", })); } catch { return []; } } /** Fetch news for a ticker */ async getNews(ticker: string, count = 10, exchange = "", _context?: MarketDataRequestContext): Promise { // Use the Yahoo symbol for better search results on international tickers const symbol = getYahooSymbol(ticker, exchange); const url = `https://query1.finance.yahoo.com/v1/finance/search?q=${encodeURIComponent(symbol)}"esCount=0&newsCount=${count}`; try { const resp = await fetch(url, { headers: this.http.defaultHeaders(), signal: AbortSignal.timeout(5000), }); if (!resp.ok) return []; const data = await resp.json() as any; // Search is a text query: for symbols it cannot match (crypto pairs, many // listings) it returns general headlines. Keep only items Yahoo links to the symbol. const wanted = new Set([symbol.toUpperCase(), ticker.toUpperCase()]); const items: NewsItem[] = (data.news || []) .filter((n: any) => Array.isArray(n.relatedTickers) && n.relatedTickers.some((related: unknown) => typeof related === "string" && wanted.has(related.toUpperCase()))) .map((n: any) => ({ title: n.title || "", url: n.link || "", source: n.publisher || "", publishedAt: new Date((n.providerPublishTime || 0) * 1000), summary: n.summary || undefined, })); return items; } catch { return []; } } async getHolders(ticker: string, exchange = "", _context?: MarketDataRequestContext): Promise { return loadYahooHolders({ exchange, fetchJsonWithCrumb: (url) => this.http.fetchJsonWithCrumb(url), providerId: this.id, ticker, }); } async getAnalystResearch(ticker: string, exchange = "", _context?: MarketDataRequestContext): Promise { return loadYahooAnalystResearch({ exchange, fetchJsonWithCrumb: (url) => this.http.fetchJsonWithCrumb(url), providerId: this.id, ticker, }); } async getCorporateActions(ticker: string, exchange = "", _context?: MarketDataRequestContext): Promise { return loadYahooCorporateActions({ exchange, fetchChart: (symbol, range, interval) => this.fetchChart(symbol, range, interval), fetchJsonWithCrumb: (url) => this.http.fetchJsonWithCrumb(url), providerId: this.id, ticker, }); } async getSecFilings(ticker: string, count = 10, _exchange = "", _context?: MarketDataRequestContext): Promise { return this.secClient.getRecentFilings(ticker, count); } async getSecFilingDocuments(filing: SecFilingItem) { return this.secClient.getFilingDocuments(filing); } async getSecFilingContent(filing: SecFilingItem): Promise { return this.secClient.getFilingContent(filing); } /** Fetch article summary by scraping og:description from the article page */ async getArticleSummary(url: string): Promise { try { const resp = await fetch(url, { headers: { ...this.http.defaultHeaders(), Accept: "text/html", }, signal: AbortSignal.timeout(8000), redirect: "follow", }); if (!resp.ok) return null; const html = await resp.text(); // Extract og:description content const match = html.match(/og:description"\s+content="([^"]*?)"/); if (!match?.[1]) return null; // Decode HTML entities return match[1] .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/'/g, "'"); } catch { return null; } } /** Fetch price history with appropriate granularity for the given time range */ getChartResolutionSupport(): ChartResolutionSupport[] { return getYahooChartResolutionSupport(); } getChartResolutionCapabilities(): ManualChartResolution[] { return getYahooChartResolutionCapabilities(); } async getPriceHistory(ticker: string, exchange = "", range: TimeRange, _context?: MarketDataRequestContext): Promise { return (await this.getPriceHistoryWithMetadata(ticker, exchange, range, _context)).points; } async getPriceHistoryWithMetadata(ticker: string, exchange = "", range: TimeRange, _context?: MarketDataRequestContext): Promise { return loadYahooPriceHistoryWithMetadata({ ticker, exchange, range, fetchChart: (symbol, chartRange, interval) => this.fetchChart(symbol, chartRange, interval), }); } async getPriceHistoryForResolution( ticker: string, exchange = "", bufferRange: TimeRange, resolution: ManualChartResolution, _context?: MarketDataRequestContext, ): Promise { return (await this.getPriceHistoryForResolutionWithMetadata(ticker, exchange, bufferRange, resolution, _context)).points; } async getPriceHistoryForResolutionWithMetadata( ticker: string, exchange = "", bufferRange: TimeRange, resolution: ManualChartResolution, _context?: MarketDataRequestContext, ): Promise { return loadYahooPriceHistoryForResolutionWithMetadata({ ticker, exchange, bufferRange, resolution, fetchChart: (symbol, chartRange, interval) => this.fetchChart(symbol, chartRange, interval), }); } // ── Options Chain ────────────────────────────────────────────────── async getOptionsChain(ticker: string, exchange = "", expirationDate?: number, _context?: MarketDataRequestContext): Promise { return loadYahooOptionsChain({ exchange, expirationDate, fetchJsonWithCrumb: (url) => this.http.fetchJsonWithCrumb(url), ticker, }); } async getEarningsCalendar(symbols: string[], _context?: MarketDataRequestContext): Promise { return loadYahooEarningsCalendar(symbols, (url) => this.http.fetchJsonWithCrumb(url)); } }