finance-dashboard/lib/fred.js

348 lines
14 KiB
JavaScript
Raw Normal View History

2026-06-02 09:40:21 +00:00
// ═══════════════════════════════════════════════════════════
// 資料引擎 — 負責「抓真實資料」並換算成卡片要顯示的數字
//
// 流程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 };
}