// ═══════════════════════════════════════════════════════════ // 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('無法取得歷史股價'); }