// ═══════════════════════════════════════════════════════════ // 資料引擎 — 負責「抓真實資料」並換算成卡片要顯示的數字 // // 流程:FRED / Stooq 取得原始時間序列 // → computeMetric() 依設定做 YoY / MoM / 變動量等換算 // → 取最新值當顯示值、與前一期比較算「變動」 // → 取近期資料做成 sparkline 走勢圖(真實,不再是假的) // → formatValue() 套上 % / bp / $ / K 等單位 // ═══════════════════════════════════════════════════════════ import { INDICATORS } from './indicators.js'; const FRED_BASE = 'https://api.stlouisfed.org/fred/series/observations'; const YAHOO_BASE = 'https://query1.finance.yahoo.com/v8/finance/chart/'; // 自訂錯誤:金鑰未設定時用,讓 API 端點能回傳友善訊息 export class MissingKeyError extends Error {} function getApiKey() { const key = process.env.FRED_API_KEY; if (!key || key === 'your_fred_api_key_here') { throw new MissingKeyError('尚未設定 FRED_API_KEY'); } return key; } function isoDaysAgo(days) { const d = new Date(Date.now() - days * 86400000); return d.toISOString().slice(0, 10); } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // 併發限制器:FRED 對「短時間內大量同時請求」會回 429, // 因此一次最多只放行 5 個請求,其餘排隊。 function createLimiter(max) { let active = 0; const queue = []; const next = () => { if (active >= max || queue.length === 0) return; active++; const { fn, resolve, reject } = queue.shift(); fn().then(resolve, reject).finally(() => { active--; next(); }); }; return (fn) => new Promise((resolve, reject) => { queue.push({ fn, resolve, reject }); next(); }); } const limit = createLimiter(2); // ─── 抓取 FRED 序列 → [{date, value:Number}](已濾掉缺值 '.')─── // 透過限制器排隊,並對 429(請求過多)自動退避重試。 async function fetchFredSeries(seriesId, startISO) { const key = getApiKey(); const url = `${FRED_BASE}?series_id=${encodeURIComponent(seriesId)}` + `&api_key=${key}&file_type=json&sort_order=asc&observation_start=${startISO}`; return limit(async () => { for (let attempt = 0; attempt < 9; attempt++) { const res = await fetch(url); if (res.status === 429) { // 過多請求,退避後重試(含抖動避免同步重試) await sleep(700 * (attempt + 1) + Math.random() * 400); continue; } if (!res.ok) throw new Error(`FRED ${seriesId} 回應 ${res.status}`); const json = await res.json(); return (json.observations || []) .filter((o) => o.value !== '.' && o.value !== '' && o.value != null) .map((o) => ({ date: o.date, value: Number(o.value) })) .filter((o) => Number.isFinite(o.value)); } throw new Error(`FRED ${seriesId} 持續回應 429(請稍後再試)`); }); } // ─── 抓取 Yahoo Finance 行情(日線)→ [{date, value:收盤}] ─── // 伺服器對伺服器呼叫,無 CORS 問題、免金鑰;用於 FRED 無法良好提供的黃金。 async function fetchYahooSeries(symbol, range = '1y') { const url = `${YAHOO_BASE}${encodeURIComponent(symbol)}?range=${range}&interval=1d`; const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }); if (!res.ok) throw new Error(`Yahoo ${symbol} 回應 ${res.status}`); const json = await res.json(); const result = json?.chart?.result?.[0]; const ts = result?.timestamp; const closes = result?.indicators?.quote?.[0]?.close; if (!ts || !closes) throw new Error(`Yahoo ${symbol} 無資料`); const out = []; for (let i = 0; i < ts.length; i++) { if (closes[i] == null || !Number.isFinite(closes[i])) continue; out.push({ date: new Date(ts[i] * 1000).toISOString().slice(0, 10), value: closes[i] }); } return out; } // ─── 依 transform 把原始序列換算成「要顯示的指標序列」 ─── function computeMetric(points, transform, periodsPerYear) { const v = points.map((p) => p.value); const out = []; switch (transform) { case 'level': // 直接用原值 case 'usd': for (let i = 0; i < points.length; i++) out.push({ date: points[i].date, val: v[i] }); break; case 'yoy': { // 年增率:與一年前比較 const n = periodsPerYear || 12; for (let i = n; i < points.length; i++) { if (v[i - n] === 0) continue; out.push({ date: points[i].date, val: (v[i] / v[i - n] - 1) * 100 }); } break; } case 'mom': { // 月增率:與上一期比較 for (let i = 1; i < points.length; i++) { if (v[i - 1] === 0) continue; out.push({ date: points[i].date, val: (v[i] / v[i - 1] - 1) * 100 }); } break; } case 'payems_diff': { // 非農:本期減上期(PAYEMS 單位已是千人) for (let i = 1; i < points.length; i++) { out.push({ date: points[i].date, val: v[i] - v[i - 1] }); } break; } case 'percent_to_bp': // 百分比 → 基點 for (let i = 0; i < points.length; i++) out.push({ date: points[i].date, val: v[i] * 100 }); break; case 'level_per_thousand': // 人數 → 千人 for (let i = 0; i < points.length; i++) out.push({ date: points[i].date, val: v[i] / 1000 }); break; case 'millions_to_trillions': // 百萬 → 兆 for (let i = 0; i < points.length; i++) out.push({ date: points[i].date, val: v[i] / 1e6 }); break; default: for (let i = 0; i < points.length; i++) out.push({ date: points[i].date, val: v[i] }); } return out; } // ─── 取近期資料、平均取樣成 sparkline(最多 24 點)─── function buildSparkline(metric) { if (metric.length === 0) return []; // 推估資料頻率(相鄰兩點的中位數天數),決定要回看多久 const gaps = []; for (let i = 1; i < metric.length; i++) { gaps.push((new Date(metric[i].date) - new Date(metric[i - 1].date)) / 86400000); } gaps.sort((a, b) => a - b); const medianGap = gaps.length ? gaps[Math.floor(gaps.length / 2)] : 30; let spanDays; if (medianGap <= 4) spanDays = 365; // 每日 else if (medianGap <= 10) spanDays = 730; // 每週 else if (medianGap <= 45) spanDays = 1460; // 每月 else spanDays = 2920; // 每季 const cutoff = Date.now() - spanDays * 86400000; let recent = metric.filter((m) => new Date(m.date).getTime() >= cutoff); if (recent.length < 8) recent = metric.slice(-16); // 點太少就直接取最後 16 點 // 平均取樣到最多 24 點 const target = 24; if (recent.length <= target) return recent.map((m) => m.val); const step = (recent.length - 1) / (target - 1); const sampled = []; for (let i = 0; i < target; i++) sampled.push(recent[Math.round(i * step)].val); return sampled; } // ─── 數值格式化 ─── function fmtNum(n, d) { return n.toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d }); } function signed(n, d) { return (n >= 0 ? '+' : '') + n.toFixed(d); } function formatValue(val, format, decimals) { const d = decimals ?? 2; switch (format) { case 'pct': return `${val.toFixed(d)}%`; case 'pct_signed': return `${signed(val, d)}%`; case 'bp': return `${Math.round(val)}bp`; case 'num0': return fmtNum(val, 0); case 'num1': return val.toFixed(1); case 'num2': return val.toFixed(2); case 'num2_signed': return signed(val, 2); case 'k': return `${Math.round(val).toLocaleString('en-US')}K`; case 'k_signed': return `${val >= 0 ? '+' : ''}${Math.round(val)}K`; case 'trillions': return `$${val.toFixed(d)}T`; case 'usd': return `$${val.toFixed(d)}`; case 'usd0': return `$${fmtNum(val, 0)}`; default: return val.toFixed(d); } } function formatChange(delta, format, decimals) { const d = decimals ?? 2; if (!Number.isFinite(delta)) return ''; switch (format) { case 'pct': case 'pct_signed': return signed(delta, d); // 例:+0.03(單位同上方百分比) case 'num0': return `${delta >= 0 ? '+' : ''}${Math.round(delta)}`; case 'num1': return signed(delta, 1); case 'num2': case 'num2_signed': return signed(delta, 2); case 'bp': return `${delta >= 0 ? '+' : ''}${Math.round(delta)}bp`; case 'k': case 'k_signed': return `${delta >= 0 ? '+' : ''}${Math.round(delta)}K`; case 'trillions': return `${delta >= 0 ? '+' : '-'}$${Math.abs(delta * 1000).toFixed(0)}B`; // 兆→十億 case 'usd': return `${delta >= 0 ? '+' : '-'}$${Math.abs(delta).toFixed(1)}`; case 'usd0': return `${delta >= 0 ? '+' : '-'}$${fmtNum(Math.abs(delta), 0)}`; default: return signed(delta, d); } } // ─── 顏色與徽章判定 ─── function classify(ind, dir) { const meaningful = !ind.excludeFromScore; const flat = dir === 'neutral'; // 反向指標:下降才是好;一般指標:上升才是好 const good = ind.inverted ? dir === 'down' : dir === 'up'; let valueColorKey, changeColorKey, badgeKind; if (!meaningful) { valueColorKey = 'blue'; // 中性指標用藍色,不暗示好壞 changeColorKey = 'text2'; badgeKind = 'neutral'; } else if (flat) { valueColorKey = 'yellow'; changeColorKey = 'text2'; badgeKind = 'neutral'; } else { valueColorKey = good ? 'green' : 'red'; changeColorKey = good ? 'green' : 'red'; badgeKind = good ? 'good' : 'bad'; } return { valueColorKey, changeColorKey, badgeKind, good, meaningful }; } function dirOf(delta, scale) { const eps = scale * 0.0005; // 極小變動視為持平 if (!Number.isFinite(delta)) return 'neutral'; if (delta > eps) return 'up'; if (delta < -eps) return 'down'; return 'neutral'; } // ─── 抓取單一指標並組成卡片 ─── async function buildCard(ind) { let points; if (ind.source === 'yahoo') { points = await fetchYahooSeries(ind.symbol, 'max'); } else { // 回看年數拉長到約 26 年,讓走勢大圖能涵蓋 2000 網路泡沫、2008 金融海嘯等 // 歷史事件(多數 FRED 序列起點更早,會自動回傳實際擁有的範圍)。 // yoy 需多一年前置資料;季資料再多抓幾年確保換算完整。 const yearsBack = ind.transform === 'yoy' ? (ind.periodsPerYear === 4 ? 30 : 27) : 26; points = await fetchFredSeries(ind.seriesId, isoDaysAgo(yearsBack * 365)); } const metric = computeMetric(points, ind.transform, ind.periodsPerYear); if (metric.length === 0) throw new Error(`${ind.key} 換算後無資料`); const latest = metric[metric.length - 1]; const prev = metric.length > 1 ? metric[metric.length - 2] : null; const value = latest.val; const delta = prev ? value - prev.val : NaN; const scale = Math.max(Math.abs(value), 1); const dir = dirOf(delta, scale); const cls = classify(ind, dir); const card = { key: ind.key, group: ind.group, label: ind.label, labelEn: ind.labelEn, value: formatValue(value, ind.format, ind.decimals), rawValue: value, change: formatChange(delta, ind.format, ind.decimals), dir, badge: dir === 'up' ? '上升' : dir === 'down' ? '下降' : '持平', badgeKind: cls.badgeKind, valueColorKey: cls.valueColorKey, changeColorKey: cls.changeColorKey, inverted: !!ind.inverted, good: cls.good, meaningful: cls.meaningful, spark: buildSparkline(metric), substitute: ind.substitute || null, tip: ind.tip, format: ind.format, decimals: ind.decimals ?? 2, asOf: latest.date, }; return { card, metric }; } // 把單一指標的格式化函式對外公開(供 /api/series 用) export { formatValue }; // ─── 抓取全部指標(容錯:個別失敗不影響其他)─── export async function getIndicatorCards() { const results = await Promise.allSettled(INDICATORS.map((ind) => buildCard(ind))); const cards = {}; const seriesHistory = {}; // key → 完整歷史序列 [{date,val}] const degraded = []; let missingKey = false; results.forEach((r, idx) => { const ind = INDICATORS[idx]; if (r.status === 'fulfilled') { cards[ind.key] = r.value.card; seriesHistory[ind.key] = r.value.metric; } else { if (r.reason instanceof MissingKeyError) missingKey = true; degraded.push({ key: ind.key, label: ind.label, reason: String(r.reason?.message || r.reason) }); } }); // 只要偵測到金鑰未設定,就視為設定問題(畫面顯示設定教學), // 不因為少數免金鑰來源(如黃金)成功就誤判為正常。 if (missingKey) { throw new MissingKeyError('尚未設定 FRED_API_KEY'); } return { cards, seriesHistory, degraded }; } // ─── 殖利率曲線(真實,跨天期)─── const CURVE_SERIES = [ ['3M', 'DGS3MO'], ['6M', 'DGS6MO'], ['1Y', 'DGS1'], ['2Y', 'DGS2'], ['3Y', 'DGS3'], ['5Y', 'DGS5'], ['7Y', 'DGS7'], ['10Y', 'DGS10'], ['20Y', 'DGS20'], ['30Y', 'DGS30'], ]; export async function getYieldCurve() { const results = await Promise.allSettled( CURVE_SERIES.map(([, id]) => fetchFredSeries(id, isoDaysAgo(90))) ); const maturities = []; const yields = []; const prevYields = []; results.forEach((r, i) => { if (r.status !== 'fulfilled' || r.value.length === 0) return; const pts = r.value; const last = pts[pts.length - 1].value; // 約一個月前(21 個交易日) const ago = pts[Math.max(0, pts.length - 22)].value; maturities.push(CURVE_SERIES[i][0]); yields.push(last); prevYields.push(ago); }); const inverted = yields.length >= 2 && yields[0] > yields[yields.length - 1]; return { maturities, yields, prevYields, inverted }; }