import type { DatabaseSync } from 'node:sqlite'; export interface AnalystRating { firm: string; action: string | null; gradeFrom: string | null; gradeTo: string | null; ratingDate: string; targetFrom: number | null; targetTo: number | null; } export interface AnalystConsensus { strongBuy: number; buy: number; hold: number; sell: number; strongSell: number; } const RATINGS_TTL_MS = 86_400_000; // 24h const MIN_FETCH_INTERVAL_MS = 5_000; // 5s between fetches per symbol const RATE_LIMIT_BACKOFF_MS = 120_000; // 2min backoff after rate-limit const YAHOO_TIMEOUT_MS = 6_000; // 6s — shorter than frontend 8s TRPC timeout // In-memory per-symbol throttle to prevent stampeding in the absence of queue integration const lastFetchBySymbol = new Map(); const rateLimitUntil = new Map(); // Singleton YahooFinance instance (crumb/cookie is fetched once, reused across calls). let yfInstance: { quoteSummary(...args: unknown[]): Promise> } | null = null; async function getYf(): Promise<{ quoteSummary(...args: unknown[]): Promise> }> { if (!yfInstance) { const mod = await import('yahoo-finance2'); yfInstance = new mod.default({ suppressNotices: ['yahooSurvey'] }) as typeof yfInstance; } return yfInstance; } async function fetchFromYahoo(symbol: string): Promise<{ ratings: AnalystRating[]; consensus: AnalystConsensus } | { error: string }> { const now = Date.now(); // Check rate-limit backoff first (applies even if MIN_FETCH_INTERVAL would pass) const rlUntil = rateLimitUntil.get(symbol); if (rlUntil && rlUntil > now) { return { error: `Rate-limited for ${symbol} — retry in ${Math.ceil((rlUntil - now) / 1000)}s` }; } const last = lastFetchBySymbol.get(symbol); if (last && now - last < MIN_FETCH_INTERVAL_MS) { return { error: `Throttled — wait ${Math.ceil((MIN_FETCH_INTERVAL_MS - (now - last)) / 1000)}s before retrying ${symbol}` }; } lastFetchBySymbol.set(symbol, now); const yf = await getYf(); let raw: Record; let timeoutId: ReturnType | null = null; try { const yfPromise = yf.quoteSummary(symbol, { modules: ['upgradeDowngradeHistory', 'recommendationTrend'] }, { validateResult: false }); const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error('Yahoo Finance timed out')), YAHOO_TIMEOUT_MS); }); raw = await Promise.race([yfPromise, timeoutPromise]) as Record; } catch (e) { if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } const msg = (e as Error).message; if (/too many requests|rate[- ]?limit|429|edge:\s*too many/i.test(msg)) { rateLimitUntil.set(symbol, Date.now() + RATE_LIMIT_BACKOFF_MS); return { error: `Edge: Too Many Requests — yfinance rate limit hit for ${symbol}` }; } const yfErr = e as Record; if (yfErr.errors) console.error(`[analystRatings] Schema errors for ${symbol}:`, JSON.stringify(yfErr.errors).slice(0, 500)); if (yfErr.result) console.error(`[analystRatings] Raw result for ${symbol}:`, JSON.stringify(yfErr.result).slice(0, 500)); return { error: msg }; } const udh = raw.upgradeDowngradeHistory as Record | undefined; const hist = (udh?.history ?? []) as Array>; const ratings: AnalystRating[] = hist.map((h: Record) => { let ratingDate = ''; const rawDate = h.epochGradeDate; if (rawDate instanceof Date) ratingDate = rawDate.toISOString().slice(0, 10); else if (typeof rawDate === 'string') ratingDate = rawDate.slice(0, 10); else if (typeof rawDate === 'number') ratingDate = new Date(rawDate * 1000).toISOString().slice(0, 10); return { firm: String(h.firm ?? ''), action: h.action ? String(h.action) : null, gradeFrom: h.fromGrade ? String(h.fromGrade) : null, gradeTo: h.toGrade ? String(h.toGrade) : null, ratingDate, targetFrom: typeof h.priorPriceTarget === 'number' ? h.priorPriceTarget : null, targetTo: typeof h.currentPriceTarget === 'number' ? h.currentPriceTarget : null, }; }).filter((r) => r.firm && r.ratingDate); const rt = raw.recommendationTrend as Record | undefined; const trend = (rt?.trend ?? []) as Array>; const current = trend.find((t: Record) => t.period === '0m' || t.period === '0q'); const consensus: AnalystConsensus = { strongBuy: Number(current?.strongBuy ?? 0), buy: Number(current?.buy ?? 0), hold: Number(current?.hold ?? 0), sell: Number(current?.sell ?? 0), strongSell: Number(current?.strongSell ?? 0), }; return { ratings, consensus }; } export function getAnalystRatings(db: DatabaseSync, symbol: string): { ratings: AnalystRating[]; consensus: AnalystConsensus | null; stale: boolean } | null { const fresh = db.prepare(` SELECT firm, action, grade_from, grade_to, target_from, target_to, rating_date, fetched_at FROM analyst_ratings WHERE symbol = ? ORDER BY rating_date DESC, firm `).all(symbol) as Array<{ firm: string; action: string | null; grade_from: string | null; grade_to: string | null; target_from: number | null; target_to: number | null; rating_date: string; fetched_at: string }>; if (fresh.length === 0) return null; const latestFetch = fresh[0].fetched_at; const stale = Date.now() - new Date(latestFetch).getTime() > RATINGS_TTL_MS; return { ratings: fresh.map((r) => ({ firm: r.firm, action: r.action, gradeFrom: r.grade_from, gradeTo: r.grade_to, ratingDate: r.rating_date, targetFrom: r.target_from, targetTo: r.target_to, })), consensus: (() => { const row = db.prepare('SELECT strong_buy, buy, hold, sell, strong_sell FROM analyst_consensus WHERE symbol = ?').get(symbol) as { strong_buy: number; buy: number; hold: number; sell: number; strong_sell: number } | undefined; if (!row) return null; return { strongBuy: row.strong_buy, buy: row.buy, hold: row.hold, sell: row.sell, strongSell: row.strong_sell }; })(), stale, }; } export async function fetchAndStoreAnalystRatings(db: DatabaseSync, symbol: string): Promise<{ ratings: AnalystRating[]; consensus: AnalystConsensus | null; stale: boolean } | { error: string }> { const upper = symbol.toUpperCase(); const result = await fetchFromYahoo(upper); if ('error' in result) return { error: result.error }; const now = new Date().toISOString(); const upsert = db.prepare(` INSERT OR REPLACE INTO analyst_ratings (symbol, firm, action, grade_from, grade_to, target_from, target_to, rating_date, fetched_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `); for (const r of result.ratings) { upsert.run(upper, r.firm, r.action, r.gradeFrom, r.gradeTo, r.targetFrom, r.targetTo, r.ratingDate, now); } db.prepare(` INSERT OR REPLACE INTO analyst_consensus (symbol, strong_buy, buy, hold, sell, strong_sell, fetched_at) VALUES (?, ?, ?, ?, ?, ?, ?) `).run(upper, result.consensus.strongBuy, result.consensus.buy, result.consensus.hold, result.consensus.sell, result.consensus.strongSell, now); return { ratings: result.ratings, consensus: result.consensus, stale: false }; }