finance-dashboard/lib/score.js

162 lines
6.8 KiB
JavaScript
Raw Normal View History

2026-06-02 09:40:21 +00:00
// ═══════════════════════════════════════════════════════════
// 計分引擎 — 把真實數據變成「總經健康分數 + 景氣燈號」
//
// 原則:透明、可解釋。從 50 分(中性)出發,依幾條白話規則
// 加減分,每一條都附在 breakdown 裡,讓初學者看得懂結論怎麼來。
// 分數越高代表總經環境對風險性資產越友善。
// ═══════════════════════════════════════════════════════════
function clamp(n, lo, hi) {
return Math.max(lo, Math.min(hi, n));
}
// 取得卡片原始值(缺資料回傳 null
function val(cards, key) {
const c = cards[key];
return c && Number.isFinite(c.rawValue) ? c.rawValue : null;
}
function dir(cards, key) {
return cards[key] ? cards[key].dir : null;
}
export function computeScore(cards) {
let score = 50;
const breakdown = [];
const add = (delta, label, note) => {
if (delta === 0) return;
score += delta;
breakdown.push({ label, delta, note });
};
// 1) 殖利率曲線(倒掛是重要衰退警訊)
const spread = val(cards, 'yield_spread'); // bp
if (spread != null) {
if (spread < 0) add(-15, '殖利率曲線', `倒掛 ${Math.round(spread)}bp歷史上的衰退前兆`);
else if (spread < 50) add(-4, '殖利率曲線', `偏平 ${Math.round(spread)}bp`);
else add(6, '殖利率曲線', `正常 ${Math.round(spread)}bp`);
}
// 2) 衰退機率模型
const rec = val(cards, 'recession_prob'); // %
if (rec != null) {
const penalty = -Math.round((rec / 100) * 20);
add(penalty, '衰退機率', `模型估 ${rec.toFixed(1)}% 未來一年衰退`);
}
// 3) 通膨是否接近 2% 目標(用 CPI 年增)
const cpi = val(cards, 'cpi');
if (cpi != null) {
if (cpi <= 2.5) add(10, '通膨', `CPI ${cpi.toFixed(1)}% 接近目標`);
else if (cpi <= 3.5) add(3, '通膨', `CPI ${cpi.toFixed(1)}% 略高於目標`);
else if (cpi <= 4.5) add(-6, '通膨', `CPI ${cpi.toFixed(1)}% 偏高`);
else add(-12, '通膨', `CPI ${cpi.toFixed(1)}% 過高`);
}
// 4) 失業率趨勢
const unemp = val(cards, 'unemployment');
const unempDir = dir(cards, 'unemployment');
if (unempDir) {
if (unempDir === 'up') add(-8, '就業', '失業率上升,勞動市場降溫');
else add(5, '就業', '失業率持平或下降');
}
if (unemp != null && unemp > 5) add(-3, '就業', `失業率 ${unemp.toFixed(1)}% 偏高`);
// 5) 信用利差(金融壓力)
const hy = val(cards, 'credit_spread'); // bp
if (hy != null) {
if (hy < 350) add(8, '信用利差', `${Math.round(hy)}bp 偏窄,風險偏好佳`);
else if (hy < 500) add(0, '信用利差', `${Math.round(hy)}bp 中性`);
else if (hy < 700) add(-8, '信用利差', `${Math.round(hy)}bp 擴大`);
else add(-15, '信用利差', `${Math.round(hy)}bp 大幅擴大,信用緊縮`);
}
// 6) 金融條件NFCI正值=偏緊)
const nfci = val(cards, 'fin_cond');
if (nfci != null) {
if (nfci < 0) add(8, '金融條件', `NFCI ${nfci.toFixed(2)}(偏寬鬆)`);
else if (nfci < 0.2) add(0, '金融條件', `NFCI ${nfci.toFixed(2)}(中性)`);
else add(-8, '金融條件', `NFCI ${nfci.toFixed(2)}(偏緊)`);
}
// 7) 製造業景氣Philly Fed>0 擴張)
const mfg = val(cards, 'mfg');
if (mfg != null) {
if (mfg > 0) add(6, '製造業', `指數 ${mfg.toFixed(1)}(擴張)`);
else add(-6, '製造業', `指數 ${mfg.toFixed(1)}(收縮)`);
}
// 8) 經濟成長(實質 GDP 年增)
const gdp = val(cards, 'gdp');
if (gdp != null) {
if (gdp > 2.5) add(8, '成長', `GDP 年增 ${gdp.toFixed(1)}%(穩健)`);
else if (gdp > 1) add(3, '成長', `GDP 年增 ${gdp.toFixed(1)}%(溫和)`);
else if (gdp > 0) add(0, '成長', `GDP 年增 ${gdp.toFixed(1)}%(停滯)`);
else add(-10, '成長', `GDP 年增 ${gdp.toFixed(1)}%(萎縮)`);
}
// 9) 市場波動VIX
const vix = val(cards, 'vix');
if (vix != null) {
if (vix < 16) add(4, '波動率', `VIX ${vix.toFixed(1)}(平靜)`);
else if (vix < 22) add(0, '波動率', `VIX ${vix.toFixed(1)}(正常)`);
else if (vix < 30) add(-5, '波動率', `VIX ${vix.toFixed(1)}(升高)`);
else add(-10, '波動率', `VIX ${vix.toFixed(1)}(恐慌)`);
}
score = Math.round(clamp(score, 0, 100));
// 景氣 regime
let regime;
if (score >= 65) regime = { label: '景氣穩健 ✓', colorKey: 'green' };
else if (score >= 50) regime = { label: '溫和成長', colorKey: 'yellow' };
else if (score >= 35) regime = { label: '景氣放緩 ⚠', colorKey: 'yellow' };
else regime = { label: '衰退風險高 ⚠', colorKey: 'red' };
return { score, regime, breakdown, signals: computeSignals(cards) };
}
// ─── 五個訊號燈(陳述客觀狀態,不只看分數)───
function computeSignals(cards) {
const signals = [];
const spread = val(cards, 'yield_spread');
signals.push(spread == null ? pill('殖利率曲線', '—', 'text2')
: spread < 0 ? pill('殖利率曲線', '倒掛', 'red')
: spread < 30 ? pill('殖利率曲線', '偏平', 'yellow')
: pill('殖利率曲線', '正常', 'green'));
const cpiDir = dir(cards, 'cpi');
signals.push(cpiDir == null ? pill('通膨趨勢', '—', 'text2')
: cpiDir === 'down' ? pill('通膨趨勢', '降溫中', 'green')
: cpiDir === 'up' ? pill('通膨趨勢', '升溫', 'red')
: pill('通膨趨勢', '持平', 'yellow'));
const unempDir = dir(cards, 'unemployment');
signals.push(unempDir == null ? pill('就業市場', '—', 'text2')
: unempDir === 'up' ? pill('就業市場', '降溫中', 'yellow')
: unempDir === 'down' ? pill('就業市場', '強勁', 'green')
: pill('就業市場', '穩定', 'green'));
const nfci = val(cards, 'fin_cond');
signals.push(nfci == null ? pill('金融條件', '—', 'text2')
: nfci < 0 ? pill('金融條件', '偏寬', 'green')
: nfci < 0.2 ? pill('金融條件', '中性', 'yellow')
: pill('金融條件', '偏緊', 'orange'));
const gdp = val(cards, 'gdp');
const mfg = val(cards, 'mfg');
const rec = val(cards, 'recession_prob');
let momentum;
if (gdp == null && mfg == null) momentum = pill('景氣動能', '—', 'text2');
else if ((rec != null && rec > 35) || (mfg != null && mfg < 0 && gdp != null && gdp < 1)) momentum = pill('景氣動能', '放緩', 'red');
else if (gdp != null && gdp > 2 && (mfg == null || mfg > 0)) momentum = pill('景氣動能', '擴張', 'green');
else momentum = pill('景氣動能', '溫和', 'yellow');
signals.push(momentum);
return signals;
}
function pill(label, value, colorKey) {
return { label, value, colorKey };
}