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