2026-06-03 09:21:58 +00:00
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
// 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 時用,美股最完整)。
|
2026-06-03 16:42:07 +00:00
|
|
|
|
async function fetchNasdaq(symbol, range, fromISO) {
|
|
|
|
|
|
const from = fromISO || cutoffDate(range), to = new Date().toISOString().slice(0, 10);
|
2026-06-03 09:21:58 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 16:42:07 +00:00
|
|
|
|
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 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 < 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) {
|
2026-06-03 09:21:58 +00:00
|
|
|
|
let lastErr = null;
|
|
|
|
|
|
for (const host of ['query1', 'query2']) {
|
|
|
|
|
|
try {
|
2026-06-03 16:42:07 +00:00
|
|
|
|
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`;
|
2026-06-03 09:21:58 +00:00
|
|
|
|
}
|
2026-06-03 16:42:07 +00:00
|
|
|
|
return normalizeYahooChart(await jget(url), symbol, range, interval);
|
2026-06-03 09:21:58 +00:00
|
|
|
|
} catch (e) { lastErr = e; }
|
|
|
|
|
|
}
|
2026-06-03 16:42:07 +00:00
|
|
|
|
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 = '5y';
|
|
|
|
|
|
if (!INTERVALS.includes(interval)) interval = '1d';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const hist = await fetchYahooHistory(symbol, range, interval, null);
|
|
|
|
|
|
if (hist.points.length >= 2) return hist;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
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 since = new Date(start.getTime() - 3 * 86400000).toISOString().slice(0, 10);
|
|
|
|
|
|
try {
|
|
|
|
|
|
return await fetchYahooHistory(symbol, range, interval, since);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
const fallback = await fetchNasdaq(symbol, range, since).catch(() => null);
|
|
|
|
|
|
if (fallback) return fallback;
|
|
|
|
|
|
}
|
|
|
|
|
|
throw new Error('無法取得增量歷史股價');
|
2026-06-03 09:21:58 +00:00
|
|
|
|
}
|