finance-dashboard/lib/fred.js

348 lines
14 KiB
JavaScript
Raw 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.

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