/** * Fundamentals Service * Fetches deep trader metrics from Yahoo Finance using authenticated crumb requests * Calculates RSI-14, MACD (12,26,9), SMAs from OHLCV data */ import NodeCache from 'node-cache'; import { classifyCapTier } from './stockUniverseService.js'; import { yfFetchAuth } from './yahooCrumb.js'; const fundamentalsCache = new NodeCache({ stdTTL: 60 }); const technicalCache = new NodeCache({ stdTTL: 300 }); const quoteSummaryCache = new NodeCache({ stdTTL: 120 }); const YF_BASE = 'https://query1.finance.yahoo.com'; /** * Fetch basic quote (price, volume, change, market cap) */ export async function fetchBasicQuote(symbol) { const cacheKey = `quote_${symbol}`; const cached = fundamentalsCache.get(cacheKey); if (cached) return cached; const url = `${YF_BASE}/v8/finance/chart/${symbol}?interval=1d&range=5d`; try { const data = await yfFetchAuth(url); const result = data?.chart?.result?.[0]; if (!result) return null; const meta = result.meta; const quotes = result.indicators?.quote?.[0] || {}; const timestamps = result.timestamp || []; const currentPrice = meta.regularMarketPrice ?? meta.previousClose ?? null; const prevClose = meta.previousClose ?? null; const change = currentPrice && prevClose ? currentPrice - prevClose : null; const changePct = change && prevClose ? (change / prevClose) * 100 : null; const history = []; const startIdx = Math.max(0, timestamps.length - 5); for (let i = startIdx; i < timestamps.length; i++) { const v = quotes.volume?.[i]; const c = quotes.close?.[i]; if (c != null) { history.push({ date: new Date(timestamps[i] * 1000).toISOString().split('T')[0], open: quotes.open?.[i] ?? null, high: quotes.high?.[i] ?? null, low: quotes.low?.[i] ?? null, close: c, volume: v ?? null, }); } } history.sort((a, b) => new Date(b.date) - new Date(a.date)); const q = { symbol: symbol.toUpperCase(), price: currentPrice, prev_close: prevClose, change, change_pct: changePct ? Math.round(changePct * 100) / 100 : null, open: meta.regularMarketOpen ?? null, high: meta.regularMarketDayHigh ?? null, low: meta.regularMarketDayLow ?? null, volume: meta.regularMarketVolume ?? null, market_cap: meta.marketCap ?? null, currency: meta.currency ?? 'USD', exchange: meta.exchangeName ?? null, market_state: meta.marketState ?? null, history, }; fundamentalsCache.set(cacheKey, q); return q; } catch (err) { console.warn(`[fundamentalsService] quote failed for ${symbol}:`, err.message); return null; } } /** * Fetch full fundamentals via quoteSummary v10 */ export async function fetchQuoteSummary(symbol) { const cacheKey = `qs_${symbol}`; const cached = quoteSummaryCache.get(cacheKey); if (cached) return cached; const modules = [ 'summaryDetail', 'defaultKeyStatistics', 'financialData', 'recommendationTrend', 'earningsTrend' ].join(','); const url = `${YF_BASE}/v10/finance/quoteSummary/${symbol}?modules=${modules}`; try { const data = await yfFetchAuth(url); const result = data?.quoteSummary?.result?.[0]; if (!result) return null; const sd = result.summaryDetail || {}; const dks = result.defaultKeyStatistics || {}; const fd = result.financialData || {}; const rt = result.recommendationTrend?.trend?.[0] || {}; const parsed = { pe_ttm: sd.trailingPE?.raw ?? null, pe_forward: sd.forwardPE?.raw ?? null, price_to_book: dks.priceToBook?.raw ?? null, price_to_sales: dks.priceToSalesTrailing12Months?.raw ?? null, ev_ebitda: dks.enterpriseToEbitda?.raw ?? null, eps_ttm: dks.trailingEps?.raw ?? null, eps_forward: dks.forwardEps?.raw ?? null, earnings_growth: fd.earningsGrowth?.raw ?? null, revenue_growth: fd.revenueGrowth?.raw ?? null, gross_margins: fd.grossMargins?.raw ?? null, ebitda_margins: fd.ebitdaMargins?.raw ?? null, operating_margins: fd.operatingMargins?.raw ?? null, profit_margins: dks.profitMargins?.raw ?? null, debt_to_equity: fd.debtToEquity?.raw ?? null, current_ratio: fd.currentRatio?.raw ?? null, quick_ratio: fd.quickRatio?.raw ?? null, return_on_equity: fd.returnOnEquity?.raw ?? null, return_on_assets: fd.returnOnAssets?.raw ?? null, free_cash_flow: fd.freeCashflow?.raw ?? null, total_revenue: fd.totalRevenue?.raw ?? null, total_debt: fd.totalDebt?.raw ?? null, beta: sd.beta?.raw ?? null, market_cap: sd.marketCap?.raw ?? null, shares_outstanding: dks.sharesOutstanding?.raw ?? null, float_shares: dks.floatShares?.raw ?? null, short_pct_float: dks.shortPercentOfFloat?.raw ?? null, shares_short: dks.sharesShort?.raw ?? null, held_pct_institutions: dks.heldPercentInstitutions?.raw ?? null, held_pct_insiders: dks.heldPercentInsiders?.raw ?? null, book_value: dks.bookValue?.raw ?? null, week52_high: sd.fiftyTwoWeekHigh?.raw ?? null, week52_low: sd.fiftyTwoWeekLow?.raw ?? null, week52_change: dks.fiftyTwoWeekChange?.raw ?? null, avg_volume_10d: sd.averageVolume10days?.raw ?? null, avg_volume_3m: sd.averageVolume?.raw ?? null, dividend_yield: sd.dividendYield?.raw ?? null, payout_ratio: sd.payoutRatio?.raw ?? null, target_high: fd.targetHighPrice?.raw ?? null, target_low: fd.targetLowPrice?.raw ?? null, target_mean: fd.targetMeanPrice?.raw ?? null, target_median: fd.targetMedianPrice?.raw ?? null, recommendation: fd.recommendationKey ?? null, analyst_count: fd.numberOfAnalystOpinions?.raw ?? null, rec_strong_buy: rt.strongBuy ?? 0, rec_buy: rt.buy ?? 0, rec_hold: rt.hold ?? 0, rec_sell: rt.sell ?? 0, rec_strong_sell: rt.strongSell ?? 0, capTier: classifyCapTier(sd.marketCap?.raw ?? null), }; quoteSummaryCache.set(cacheKey, parsed); return parsed; } catch (err) { console.warn(`[fundamentalsService] quoteSummary failed for ${symbol}:`, err.message); return null; } } /** * Fetch OHLCV history and calculate technical indicators */ export async function fetchTechnicals(symbol) { const cacheKey = `tech_${symbol}`; const cached = technicalCache.get(cacheKey); if (cached) return cached; const url = `${YF_BASE}/v8/finance/chart/${symbol}?interval=1d&range=1y`; try { const data = await yfFetchAuth(url); const result = data?.chart?.result?.[0]; if (!result) return null; const closes = result.indicators?.quote?.[0]?.close?.filter(v => v != null) || []; if (closes.length < 26) return null; const rsi = calculateRSI(closes, 14); const macd = calculateMACD(closes, 12, 26, 9); const sma50 = closes.length >= 50 ? avg(closes.slice(-50)) : null; const sma200 = closes.length >= 200 ? avg(closes.slice(-200)) : null; const currentPrice = closes[closes.length - 1]; const result_data = { rsi_14: rsi !== null ? Math.round(rsi * 100) / 100 : null, macd_line: macd?.line ?? null, macd_signal: macd?.signal ?? null, macd_histogram: macd?.histogram ?? null, sma_50: sma50 ? Math.round(sma50 * 100) / 100 : null, sma_200: sma200 ? Math.round(sma200 * 100) / 100 : null, above_sma50: sma50 ? currentPrice > sma50 : null, above_sma200: sma200 ? currentPrice > sma200 : null, pct_from_sma50: sma50 ? ((currentPrice - sma50) / sma50 * 100) : null, pct_from_sma200: sma200 ? ((currentPrice - sma200) / sma200 * 100) : null, }; technicalCache.set(cacheKey, result_data); return result_data; } catch (err) { console.warn(`[fundamentalsService] technicals failed for ${symbol}:`, err.message); return null; } } /** * Full deep analysis for a single symbol */ export async function fetchFullAnalysis(symbol) { const [quote, fundamentals, technicals] = await Promise.allSettled([ fetchBasicQuote(symbol), fetchQuoteSummary(symbol), fetchTechnicals(symbol), ]); return { symbol: symbol.toUpperCase(), quote: quote.status === 'fulfilled' ? quote.value : null, fundamentals: fundamentals.status === 'fulfilled' ? fundamentals.value : null, technicals: technicals.status === 'fulfilled' ? technicals.value : null, fetchedAt: new Date().toISOString(), }; } // ─── Technical Indicator Calculations ─────────────────────────────────────── function avg(arr) { return arr.reduce((a, b) => a + b, 0) / arr.length; } function calculateRSI(closes, period = 14) { if (closes.length < period + 1) return null; const recent = closes.slice(-period - 1); let gains = 0, losses = 0; for (let i = 1; i <= period; i++) { const diff = recent[i] - recent[i - 1]; if (diff >= 0) gains += diff; else losses += Math.abs(diff); } const avgGain = gains / period; const avgLoss = losses / period; if (avgLoss === 0) return 100; const rs = avgGain / avgLoss; return 100 - (100 / (1 + rs)); } function ema(data, period) { if (data.length < period) return []; const k = 2 / (period + 1); const result = []; let emaVal = avg(data.slice(0, period)); result.push(emaVal); for (let i = period; i < data.length; i++) { emaVal = data[i] * k + emaVal * (1 - k); result.push(emaVal); } return result; } function calculateMACD(closes, fast = 12, slow = 26, signal = 9) { if (closes.length < slow + signal) return null; const emaFast = ema(closes, fast); const emaSlow = ema(closes, slow); const diff = emaSlow.length - emaFast.length; const macdLine = emaFast.slice(diff).map((v, i) => v - emaSlow[i]); const signalLine = ema(macdLine, signal); const lastMacd = macdLine[macdLine.length - 1]; const lastSignal = signalLine[signalLine.length - 1]; return { line: Math.round(lastMacd * 10000) / 10000, signal: Math.round(lastSignal * 10000) / 10000, histogram: Math.round((lastMacd - lastSignal) * 10000) / 10000, }; }