135 lines
3.9 KiB
JavaScript
135 lines
3.9 KiB
JavaScript
// Yahoo Finance — Live market quotes (no API key required)
|
|
// Provides real-time prices for stocks, ETFs, crypto, commodities
|
|
// Replaces the need for Alpaca or any paid market data provider
|
|
|
|
import { safeFetch } from '../utils/fetch.mjs';
|
|
|
|
const BASE = 'https://query1.finance.yahoo.com/v8/finance/chart';
|
|
|
|
// Symbols to track — covers broad market, rates, commodities, crypto, volatility
|
|
const SYMBOLS = {
|
|
// Indexes / ETFs
|
|
'^GSPC': 'S&P 500',
|
|
'^IXIC': 'Nasdaq Composite',
|
|
'^DJI': 'Dow Jones',
|
|
'^RUT': 'Russell 2000',
|
|
// Rates / Credit
|
|
TLT: '20Y+ Treasury',
|
|
HYG: 'High Yield Corp',
|
|
LQD: 'IG Corporate',
|
|
// Commodities
|
|
'GC=F': 'Gold',
|
|
'SI=F': 'Silver',
|
|
'CL=F': 'WTI Crude',
|
|
'BZ=F': 'Brent Crude',
|
|
'NG=F': 'Natural Gas',
|
|
// Crypto
|
|
'BTC-USD': 'Bitcoin',
|
|
'ETH-USD': 'Ethereum',
|
|
// Volatility
|
|
'^VIX': 'VIX',
|
|
};
|
|
|
|
async function fetchQuote(symbol) {
|
|
try {
|
|
const url = `${BASE}/${encodeURIComponent(symbol)}?range=5d&interval=1d&includePrePost=false`;
|
|
const data = await safeFetch(url, {
|
|
timeout: 8000,
|
|
retries: 2,
|
|
source: `YFinance:${symbol}`,
|
|
headers: {
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
'Accept': 'application/json,text/plain,*/*',
|
|
},
|
|
});
|
|
if (data?.error) throw new Error(data.error);
|
|
|
|
const result = data?.chart?.result?.[0];
|
|
if (!result) throw new Error(data?.chart?.error?.description || 'Yahoo response missing chart result');
|
|
|
|
const meta = result.meta || {};
|
|
const quotes = result.indicators?.quote?.[0] || {};
|
|
const closes = quotes.close || [];
|
|
const timestamps = result.timestamp || [];
|
|
|
|
// Get current price and previous close
|
|
const price = meta.regularMarketPrice ?? closes[closes.length - 1];
|
|
const prevClose = meta.chartPreviousClose ?? meta.previousClose ?? closes[closes.length - 2];
|
|
const change = price && prevClose ? price - prevClose : 0;
|
|
const changePct = prevClose ? (change / prevClose) * 100 : 0;
|
|
|
|
// Build 5-day history
|
|
const history = [];
|
|
for (let i = 0; i < timestamps.length; i++) {
|
|
if (closes[i] != null) {
|
|
history.push({
|
|
date: new Date(timestamps[i] * 1000).toISOString().split('T')[0],
|
|
close: Math.round(closes[i] * 100) / 100,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
symbol,
|
|
name: SYMBOLS[symbol] || meta.shortName || symbol,
|
|
price: Math.round(price * 100) / 100,
|
|
prevClose: Math.round((prevClose || 0) * 100) / 100,
|
|
change: Math.round(change * 100) / 100,
|
|
changePct: Math.round(changePct * 100) / 100,
|
|
currency: meta.currency || 'USD',
|
|
exchange: meta.exchangeName || '',
|
|
marketState: meta.marketState || 'UNKNOWN',
|
|
history,
|
|
};
|
|
} catch (e) {
|
|
return { symbol, name: SYMBOLS[symbol] || symbol, error: e.message };
|
|
}
|
|
}
|
|
|
|
export async function briefing() {
|
|
return collect();
|
|
}
|
|
|
|
export async function collect() {
|
|
const symbols = Object.keys(SYMBOLS);
|
|
const results = await Promise.allSettled(
|
|
symbols.map(s => fetchQuote(s))
|
|
);
|
|
|
|
const quotes = {};
|
|
let ok = 0;
|
|
let failed = 0;
|
|
|
|
for (const r of results) {
|
|
const q = r.status === 'fulfilled' ? r.value : null;
|
|
if (q && !q.error) {
|
|
quotes[q.symbol] = q;
|
|
ok++;
|
|
} else {
|
|
failed++;
|
|
const sym = q?.symbol || 'unknown';
|
|
quotes[sym] = q || { symbol: sym, error: 'fetch failed' };
|
|
}
|
|
}
|
|
|
|
// Categorize for easy dashboard consumption
|
|
return {
|
|
quotes,
|
|
summary: {
|
|
totalSymbols: symbols.length,
|
|
ok,
|
|
failed,
|
|
timestamp: new Date().toISOString(),
|
|
},
|
|
indexes: pickGroup(quotes, ['^GSPC', '^IXIC', '^DJI', '^RUT']),
|
|
rates: pickGroup(quotes, ['TLT', 'HYG', 'LQD']),
|
|
commodities: pickGroup(quotes, ['GC=F', 'SI=F', 'CL=F', 'BZ=F', 'NG=F']),
|
|
crypto: pickGroup(quotes, ['BTC-USD', 'ETH-USD']),
|
|
volatility: pickGroup(quotes, ['^VIX']),
|
|
};
|
|
}
|
|
|
|
function pickGroup(quotes, symbols) {
|
|
return symbols.map(s => quotes[s]).filter(Boolean);
|
|
}
|