292 lines
10 KiB
JavaScript
292 lines
10 KiB
JavaScript
/**
|
|
* 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,
|
|
};
|
|
}
|