institutional-trader/backend/src/services/fundamentalsService.js

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,
};
}