finance-dashboard/lib/marketdata.js

99 lines
4.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ═══════════════════════════════════════════════════════════
// marketdata.js — server 端抓取歷史股價(給「價格走勢」與「回測」共用)
// 來源Yahoo v8 chart免 crumb、公開含還原股價(adjclose)。
// 只負責抓取+正規化;快取由 server.js 以 DB 處理(節省 API
// ═══════════════════════════════════════════════════════════
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36';
export const RANGES = ['3mo', '6mo', '1y', '2y', '5y', '10y', 'max'];
export const INTERVALS = ['1d', '1wk', '1mo'];
async function jget(url, ms = 12000) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), ms);
try {
const res = await fetch(url, {
headers: { 'User-Agent': UA, Accept: 'application/json,text/plain,*/*', 'Accept-Language': 'en-US,en;q=0.9' },
signal: ctrl.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} finally { clearTimeout(timer); }
}
async function jgetH(url, headers, ms = 12000) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), ms);
try {
const res = await fetch(url, { headers: { 'User-Agent': UA, ...headers }, signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} finally { clearTimeout(timer); }
}
// range → 起始日期門檻
const RANGE_MONTHS = { '3mo': 3, '6mo': 6, '1y': 12, '2y': 24, '5y': 60, '10y': 120, max: null };
function cutoffDate(range) {
const m = RANGE_MONTHS[range];
if (m == null) return '1980-01-01';
const d = new Date(); d.setMonth(d.getMonth() - m);
return d.toISOString().slice(0, 10);
}
// Nasdaq 免金鑰每日歷史後備Yahoo 429 時用,美股最完整)。
async function fetchNasdaq(symbol, range) {
const from = cutoffDate(range), to = new Date().toISOString().slice(0, 10);
const H = { Accept: 'application/json', Origin: 'https://www.nasdaq.com', Referer: 'https://www.nasdaq.com/' };
for (const assetclass of ['stocks', 'etf']) {
let j;
try { j = await jgetH(`https://api.nasdaq.com/api/quote/${encodeURIComponent(symbol)}/chart?assetclass=${assetclass}&fromdate=${from}&todate=${to}`, H); }
catch { continue; }
const chart = j?.data?.chart;
if (!Array.isArray(chart) || chart.length < 2) continue;
const points = chart
.map(c => ({ date: new Date(c.x).toISOString().slice(0, 10), close: c.y, adjclose: c.y }))
.filter(p => p.close != null);
if (points.length >= 2) {
return { symbol, name: j.data.company || null, currency: 'USD', range, interval: '1d', points, source: 'Nasdaq' };
}
}
return null;
}
// 回傳 { symbol, name, currency, points:[{date:'YYYY-MM-DD', close, adjclose}] }
export async function getHistory(symbol, range = '5y', interval = '1d') {
if (!RANGES.includes(range)) range = '5y';
if (!INTERVALS.includes(interval)) interval = '1d';
let lastErr = null;
for (const host of ['query1', 'query2']) {
try {
const url = `https://${host}.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}`
+ `?range=${range}&interval=${interval}&includeAdjustedClose=true`;
const d = await jget(url);
const r = d?.chart?.result?.[0];
if (!r || !Array.isArray(r.timestamp)) { lastErr = new Error('Yahoo 無歷史資料'); continue; }
const ts = r.timestamp;
const close = r.indicators?.quote?.[0]?.close || [];
const adj = r.indicators?.adjclose?.[0]?.adjclose || [];
const points = [];
for (let i = 0; i < ts.length; i++) {
const c = close[i];
if (c == null) continue; // 跳過缺值(停牌/未成交)
const a = (adj[i] != null) ? adj[i] : c;
points.push({ date: new Date(ts[i] * 1000).toISOString().slice(0, 10), close: c, adjclose: a });
}
if (points.length < 2) { lastErr = new Error('歷史資料點過少'); continue; }
return {
symbol: r.meta?.symbol || symbol,
name: r.meta?.shortName || r.meta?.longName || null,
currency: r.meta?.currency || null,
range, interval, source: 'Yahoo Finance',
points,
};
} catch (e) { lastErr = e; }
}
// Yahoo 失敗(常見 429→ 改用 Nasdaq 免金鑰歷史
const fallback = await fetchNasdaq(symbol, range).catch(() => null);
if (fallback) return fallback;
throw lastErr || new Error('無法取得歷史股價');
}