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