finance-dashboard/lib/score.js

162 lines
6.8 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.

// ═══════════════════════════════════════════════════════════
// 計分引擎 — 把真實數據變成「總經健康分數 + 景氣燈號」
//
// 原則:透明、可解釋。從 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 };
}