finance-dashboard/lib/marketdata.js

155 lines
6.4 KiB
JavaScript
Raw Permalink 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, fromISO) {
const from = fromISO || 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 => {
const y = c.y;
if (y == null) return null;
const date = new Date(c.x).toISOString().slice(0, 10);
return { date, open: y, high: y, low: y, close: y, adjclose: y, volume: c.volume ?? null };
})
.filter(Boolean);
if (points.length >= 2) {
return { symbol, name: j.data.company || null, currency: 'USD', range, interval: '1d', points, source: 'Nasdaq' };
}
}
return null;
}
function normalizeYahooChart(d, symbol, range, interval) {
const r = d?.chart?.result?.[0];
if (!r || !Array.isArray(r.timestamp)) throw new Error('Yahoo 無歷史資料');
const ts = r.timestamp;
const q = r.indicators?.quote?.[0] || {};
const close = q.close || [];
const open = q.open || [];
const high = q.high || [];
const low = q.low || [];
const volume = q.volume || [];
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),
open: open[i] != null ? open[i] : c,
high: high[i] != null ? high[i] : c,
low: low[i] != null ? low[i] : c,
close: c,
volume: volume[i] != null ? volume[i] : null,
adjclose: a,
});
}
if (points.length < 1) throw new Error('歷史資料點過少');
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,
};
}
async function fetchYahooHistory(symbol, range, interval, fromISO) {
let lastErr = null;
for (const host of ['query1', 'query2']) {
try {
let url = `https://${host}.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}`;
if (fromISO) {
const period1 = Math.floor(new Date(fromISO + 'T00:00:00Z').getTime() / 1000);
const period2 = Math.floor(Date.now() / 1000);
url += `?period1=${period1}&period2=${period2}&interval=${interval}&includeAdjustedClose=true`;
} else {
url += `?range=${range}&interval=${interval}&includeAdjustedClose=true`;
}
return normalizeYahooChart(await jget(url), symbol, range, interval);
} catch (e) { lastErr = e; }
}
throw lastErr || new Error('無法取得 Yahoo 歷史股價');
}
// 回傳 { symbol, name, currency, points:[{date:'YYYY-MM-DD', close, adjclose}] }
export async function getHistory(symbol, range = '5y', interval = '1d') {
if (!RANGES.includes(range)) range = interval === '1d' ? 'max' : 'max';
if (!INTERVALS.includes(interval)) interval = '1d';
try {
const hist = await fetchYahooHistory(symbol, range, interval, null);
if (hist.points.length >= 2) return hist;
} catch (e) {
if (interval === '1d') {
const fallback = await fetchNasdaq(symbol, range).catch(() => null);
if (fallback) return fallback;
}
throw e;
}
throw new Error('歷史資料點過少');
}
export async function getHistorySince(symbol, fromISO, range = 'max', interval = '1d') {
if (!INTERVALS.includes(interval)) interval = '1d';
const start = new Date(fromISO);
if (isNaN(start)) throw new Error('起始日期不正確');
const padDays = interval === '1mo' ? 45 : interval === '1wk' ? 14 : 5;
const since = new Date(start.getTime() - padDays * 86400000).toISOString().slice(0, 10);
try {
return await fetchYahooHistory(symbol, range, interval, since);
} catch (e) {
if (interval === '1d') {
const fallback = await fetchNasdaq(symbol, range, since).catch(() => null);
if (fallback) return fallback;
}
throw e;
}
throw new Error('無法取得增量歷史股價');
}