import YahooFinance from 'yahoo-finance2'; import type { YahooNewsItem, YahooSearchOptions, YahooFinanceLib, PricePoint } from '../types'; import { YAHOO_MODULES } from '../config/constants'; export class YahooFinanceClient { private lib: YahooFinanceLib; constructor() { this.lib = new (YahooFinance as unknown as new (_opts: object) => YahooFinanceLib)({ suppressNotices: ['yahooSurvey'], }); } /** Normalise ticker before hitting Yahoo: BRK.B → BRK-B */ private static normalise(ticker: string): string { return ticker.toUpperCase().replace(/\./g, '-'); } async fetchSummary(ticker: string, retries = 3, backoff = 1000): Promise { const normalised = YahooFinanceClient.normalise(ticker); for (let attempt = 0; attempt < retries; attempt++) { try { return await this.lib.quoteSummary( normalised, { modules: YAHOO_MODULES }, { validateResult: false }, ); } catch (error) { if (attempt === retries - 1) throw error; await new Promise((resolve) => setTimeout(resolve, backoff * (attempt + 1))); } } } async fetchCalendarEvents(ticker: string): Promise { try { const result = await this.lib.quoteSummary( YahooFinanceClient.normalise(ticker), { modules: ['calendarEvents'] }, { validateResult: false }, ); return result.calendarEvents ?? null; } catch { return null; } } async search(query: string, opts: YahooSearchOptions = {}): Promise { const { news = [] } = await this.lib.search(query, opts); return news; } /** * Top holdings of an ETF (ticker symbols, largest weight first). * Used for sector drill-down. Returns [] on any failure. */ async fetchTopHoldings(etf: string, limit = 10): Promise { try { const result = await this.lib.quoteSummary( YahooFinanceClient.normalise(etf), { modules: ['topHoldings'] }, { validateResult: false }, ); const holdings = (result?.topHoldings?.holdings ?? []) as Array<{ symbol?: string }>; return holdings .map((h) => h.symbol) .filter((s): s is string => Boolean(s)) .slice(0, limit) .map((s) => s.toUpperCase()); } catch { return []; } } /** Chart range presets — Robinhood/Yahoo-style. Intraday for short ranges. */ static readonly CHART_RANGES: Record = { '1d': { days: 1, interval: '5m' }, '5d': { days: 5, interval: '30m' }, '1mo': { days: 30, interval: '1d' }, '3mo': { days: 91, interval: '1d' }, '6mo': { days: 182, interval: '1d' }, ytd: { days: 0, interval: '1d' }, // days computed dynamically (Jan 1 → now) '1y': { days: 365, interval: '1d' }, '5y': { days: 1826, interval: '1wk' }, // weekly bars keep ~260 points }; /** * Closing prices for a named range (ticker modal chart). Intraday ranges * keep the full timestamp; daily ranges keep the date only. * Returns [] on any failure — the chart is a nice-to-have, never a blocker. */ async fetchCloses(ticker: string, range = '6mo'): Promise { const preset = YahooFinanceClient.CHART_RANGES[range] ?? YahooFinanceClient.CHART_RANGES['6mo']; try { const period1 = range === 'ytd' ? new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1)) : new Date(Date.now() - preset.days * 24 * 60 * 60 * 1000); const result = await this.lib.chart( YahooFinanceClient.normalise(ticker), { period1, interval: preset.interval }, { validateResult: false }, ); const quotes = (result?.quotes ?? []) as Array<{ date?: string | Date; close?: number }>; const intraday = preset.interval !== '1d'; return quotes .filter((q) => q.close != null && q.date != null) .map((q) => { const iso = new Date(q.date as string | Date).toISOString(); return { date: intraday ? iso : iso.slice(0, 10), close: +(q.close as number).toFixed(2), }; }); } catch { return []; } } }