120 lines
4.1 KiB
TypeScript
120 lines
4.1 KiB
TypeScript
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<any> {
|
|
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<void>((resolve) => setTimeout(resolve, backoff * (attempt + 1)));
|
|
}
|
|
}
|
|
}
|
|
|
|
async fetchCalendarEvents(ticker: string): Promise<any | null> {
|
|
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<YahooNewsItem[]> {
|
|
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<string[]> {
|
|
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<string, { days: number; interval: string }> = {
|
|
'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<PricePoint[]> {
|
|
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 [];
|
|
}
|
|
}
|
|
}
|