import { matchesYahooChartInterval, yahooHistoryRangeParams } from "./yahoo-chart-interval"; import type { CompanyProfile, PricePoint, Quote, } from "../../types/financials"; import { deriveMarketState, extractExtendedHoursPrices, financeRawNumber, normalizeMarketValue, normalizePositiveMarketValue, type ExtendedHoursData, } from "./mappers"; import type { ChartResponse, ChartResult, QuoteSummaryResponse, TimeseriesResponse, } from "./types"; import type { YahooHttpClient } from "./http"; import { applyYahooHistoryCoverage } from "../history-coverage"; import { coverFxOpenClose, reconcileYahooCurrentPeriod, withoutLiveRowVolume } from "./chart-period"; import { CHART_RESOLUTION_STEP_MS, isIntradayResolution, type ManualChartResolution } from "../../time-series/resolution"; import { yahooFuturesAliasName, yahooSecurityName } from "./names"; /** * Yahoo stamps calendar bars at midnight in the instrument's zone, so an FX * day opens at 23:00 UTC the evening before. Label such a bar with its own * date at UTC midnight, the convention Cloud daily bars use. */ function dateCalendarBars(points: PricePoint[], interval: string, timeZone: unknown): PricePoint[] { if (/^\d+[mh]$/.test(interval) || typeof timeZone !== "string") return points; let formatter: Intl.DateTimeFormat; try { formatter = new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }); } catch { return points; } return points.map((point) => { const parts = new Map(formatter.formatToParts(point.date).map((part) => [part.type, part.value])); const local = `${parts.get("year")}-${parts.get("month")}-${parts.get("day")}`; return local === point.date.toISOString().slice(0, 10) ? point : { ...point, date: new Date(`${local}T00:00:00Z`) }; }); } export async function fetchYahooChart( http: YahooHttpClient, symbol: string, range: string, interval = "1d", includePrePost = false, ): Promise<{ meta: NonNullable; history: PricePoint[]; /** Dates of rows Yahoo listed without a close, which history omits. */ missingCloses: Date[]; events: ChartResult["events"]; observedAt: number; regularHoursOnly: boolean; }> { const params = new URLSearchParams({ interval, ...yahooHistoryRangeParams(range), includePrePost: String(includePrePost), events: "div,split", }); const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?${params}`; const data = await http.fetchJson(url); const observedAt = Date.now(); const result = data.chart?.result?.[0]; if (!result?.timestamp?.length) { throw new Error(data.chart?.error?.description || `No chart data for ${symbol}`); } if (!matchesYahooChartInterval(interval, result.meta?.dataGranularity)) { throw new Error(`Yahoo returned ${result.meta?.dataGranularity ?? "unknown"} bars for requested ${interval} history`); } const quote = result.indicators?.quote?.[0]; if (!quote) throw new Error(`Missing indicators for ${symbol}`); const rows: PricePoint[] = result.timestamp.map((timestamp, i) => ({ date: new Date(timestamp * 1000), open: quote.open?.[i] ?? undefined, high: quote.high?.[i] ?? undefined, low: quote.low?.[i] ?? undefined, close: quote.close?.[i] ?? Number.NaN, volume: quote.volume?.[i] ?? undefined, })); let reconciled = reconcileYahooCurrentPeriod(rows, interval, result.meta); const resolution = interval as ManualChartResolution; if (Object.hasOwn(CHART_RESOLUTION_STEP_MS, interval) && isIntradayResolution(resolution)) { reconciled = withoutLiveRowVolume(reconciled, CHART_RESOLUTION_STEP_MS[resolution]); } else if (result.meta?.instrumentType === "CURRENCY") { reconciled = coverFxOpenClose(reconciled); } const dated = dateCalendarBars(reconciled, interval, result.meta?.exchangeTimezoneName); const hasClose = (point: PricePoint) => Number.isFinite(point.close) && point.close > 0; const history = dated.filter(hasClose); return { meta: result.meta || {}, history: applyYahooHistoryCoverage(symbol, result.meta || {}, interval, history), missingCloses: dated.filter((point) => !hasClose(point)).map((point) => point.date), events: result.events, observedAt, regularHoursOnly: !includePrePost }; } export async function fetchYahooExtendedHoursData( http: YahooHttpClient, symbol: string, meta: NonNullable, regularClose?: number, ): Promise { const marketState = deriveMarketState(meta); if (marketState !== "PRE" && marketState !== "POST") return {}; try { const params = new URLSearchParams({ interval: "5m", range: "1d", includePrePost: "true", }); const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?${params}`; const data = await http.fetchJson(url); const result = data.chart?.result?.[0]; if (!result?.timestamp?.length) return {}; const closes = result.indicators?.quote?.[0]?.close || []; return extractExtendedHoursPrices(meta, result.timestamp, closes, marketState, regularClose); } catch { return {}; } } export async function fetchYahooTimeseries( http: YahooHttpClient, symbol: string, types: string[], period1 = "2010-01-01", ): Promise>> { const p1 = Math.floor(new Date(period1).getTime() / 1000); const p2 = Math.floor(Date.now() / 1000); const params = new URLSearchParams({ type: types.join(","), period1: String(p1), period2: String(p2), }); const url = `https://query1.finance.yahoo.com/ws/fundamentals-timeseries/v1/finance/timeseries/${encodeURIComponent(symbol)}?${params}`; const data = await http.fetchJson(url); return data.timeseries?.result || []; } export async function fetchYahooAssetProfile( http: YahooHttpClient, symbol: string, ): Promise { const params = new URLSearchParams({ modules: "assetProfile" }); const url = `https://query1.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(symbol)}?${params}`; const data = await http.fetchJsonWithCrumb(url); const profile = data.quoteSummary?.result?.[0]?.assetProfile; if (!profile) return undefined; const normalized: CompanyProfile = { description: profile.longBusinessSummary?.trim() || undefined, sector: profile.sector?.trim() || undefined, industry: profile.industry?.trim() || undefined, }; return normalized.description || normalized.sector || normalized.industry ? normalized : undefined; } /** Summary fields layered over the chart quote; name is set only when it corrects the chart's (a rolled futures alias). */ export type YahooQuoteSupplement = Pick< Quote, "bid" | "ask" | "bidSize" | "askSize" | "previousClose" | "open" | "high" | "low" | "name" >; export async function fetchYahooQuoteSupplement( http: YahooHttpClient, symbol: string, currencyDivisor = 1, ): Promise { try { // A continuous futures alias also needs the contract its price belongs to. const futuresAlias = /=F$/i.test(symbol); const params = new URLSearchParams({ modules: futuresAlias ? "summaryDetail,price" : "summaryDetail" }); const url = `https://query1.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(symbol)}?${params}`; const data = await http.fetchJsonWithCrumb(url); const summaryDetail = data.quoteSummary?.result?.[0]?.summaryDetail; if (!summaryDetail) return {}; const price = data.quoteSummary?.result?.[0]?.price; const name = futuresAlias ? yahooFuturesAliasName(symbol, yahooSecurityName(price?.shortName, price?.longName), price?.underlyingSymbol) : undefined; const bid = normalizePositiveMarketValue( financeRawNumber(summaryDetail.bid), currencyDivisor, ); const ask = normalizePositiveMarketValue( financeRawNumber(summaryDetail.ask), currencyDivisor, ); const bidSize = financeRawNumber(summaryDetail.bidSize); const askSize = financeRawNumber(summaryDetail.askSize); const previousClose = normalizeMarketValue( financeRawNumber(summaryDetail.previousClose), currencyDivisor, ); const open = normalizeMarketValue( financeRawNumber(summaryDetail.open), currencyDivisor, ); const high = normalizeMarketValue( financeRawNumber(summaryDetail.dayHigh), currencyDivisor, ); const low = normalizeMarketValue( financeRawNumber(summaryDetail.dayLow), currencyDivisor, ); return { bid, ask, bidSize, askSize, previousClose, open, high, low, ...(name ? { name } : {}), }; } catch { return {}; } }