// 依真實歷史序列,替總經卡片產生「這數字/歷史上/會影響」白話脈絡 const CONTEXT_YEARS = 20; function fmtCtx(val, format, decimals = 2) { const d = decimals ?? 2; if (!Number.isFinite(val)) return '—'; switch (format) { case 'pct': case 'pct_signed': return `${val.toFixed(d)}%`; case 'bp': return `${Math.round(val)}bp`; case 'num0': return Math.round(val).toLocaleString('en-US'); case 'num1': return val.toFixed(1); case 'num2': case 'num2_signed': return val.toFixed(2); case 'k': case 'k_signed': return `${Math.round(val).toLocaleString('en-US')}K`; case 'trillions': return `$${val.toFixed(d)}T`; case 'usd': return `$${val.toFixed(d)}`; case 'usd0': return `$${Math.round(val).toLocaleString('en-US')}`; default: return val.toFixed(d); } } function percentileRank(values, current) { if (!values.length || !Number.isFinite(current)) return null; let below = 0; for (const v of values) if (v < current) below++; return Math.round((below / values.length) * 100); } function recentWindow(metric, years = CONTEXT_YEARS) { if (!metric.length) return []; const cutoff = new Date(metric[metric.length - 1].date); cutoff.setFullYear(cutoff.getFullYear() - years); const t = cutoff.getTime(); const window = metric.filter(m => new Date(m.date).getTime() >= t); return window.length >= 24 ? window : metric; } function levelFromPercentile(pct, ind) { if (pct == null) return { label: '—', tone: 'neutral' }; let label; if (pct <= 10) label = '極低'; else if (pct <= 30) label = '偏低'; else if (pct <= 70) label = '接近平均'; else if (pct <= 90) label = '偏高'; else label = '極高'; let tone = 'neutral'; if (ind.inverted) { if (pct >= 70) tone = 'bad'; else if (pct <= 30) tone = 'good'; } else if (!ind.excludeFromScore) { if (pct >= 70) tone = 'good'; else if (pct <= 30) tone = 'bad'; } return { label, tone }; } const NUMBER_EXPLAIN = { treasury_10y: (v, raw) => `借錢 10 年,市場現在要求年化 ${v} 的利息。等於每借 100 萬,一年約付 ${(raw * 10000).toFixed(0)} 元利息(簡化估算)。`, treasury_2y: (v) => `借錢 2 年,市場要求年化 ${v} 的利息;通常反映「未來一兩年升息或降息」的預期。`, fed_funds: (v) => `銀行隔夜互相借錢的「官方上限」約 ${v}。越高=全社會借錢越貴。`, yield_spread: (v) => `10 年殖利率減 2 年,差距是 ${v}。正值=正常向上斜;負值=倒掛(短借比長借還貴)。`, real_rate: (v) => `扣掉通膨後,長期「真實」借錢成本約 ${v}。`, cpi: (v) => `一籃子日常東西,平均比一年前貴 ${v}。`, core_cpi: (v) => `扣掉油價與食物後,物價仍比一年前貴 ${v}。`, pce: (v) => `Fed 最在意的通膨指標,比一年前貴 ${v}(目標約 2%)。`, ppi: (v) => `工廠賣出去的東西,比一年前貴 ${v},常領先 CPI。`, breakeven: (v) => `債券市場預期,未來 5 年平均通膨約 ${v}。`, unemployment: (v) => `100 個想工作的人裡,約 ${v} 找不到工作。`, nfp: (v) => `非農業上月新增 ${v} 個工作(千人=千人,200K=20 萬人)。`, claims: (v) => `每週約 ${v} 人第一次申請失業救濟。`, wages: (v) => `平均時薪比一年前漲 ${v}。`, gdp: (v) => `整體經濟產出比一年前多 ${v}(已扣通膨)。`, recession_prob: (v) => `模型估計未來 12 個月內,美國陷入衰退的機率約 ${v}。`, credit_spread: (v) => `買高風險債,比公債多要求 ${v} 的額外利息補償。`, vix: (v) => `市場預期未來 30 天股市會上下震盪的幅度指數;現在約 ${v}(20 以下偏平靜,30 以上偏恐慌)。`, m2: (v, raw) => `流通中的廣義貨幣,比一年前 ${raw >= 0 ? '多' : '少'} ${Math.abs(raw).toFixed(1)}%。`, oil: (v) => `一桶原油 ${v}。`, gold: (v) => `一盎司黃金 ${v}。`, sp500: (v) => `美國 500 大公司的股價加總指數,現在約 ${v} 點。`, }; const AFFECTS = { rates: '房貸利率、公司借錢成本、股票估值(利率高→未來利潤折現變便宜→尤其打擊高成長股)、債券價格(利率升→舊債跌價)。', treasury_10y: '30 年房貸參考利率、企業長期融資、科技/成長股估值、美元走勢。它是「長期借錢有多貴」的標尺。', treasury_2y: '短期定存、浮動利率貸款、市場對 Fed 升降息的押注。', yield_spread: '衰退預警、銀行放貸意願、景氣循環(倒掛常領先衰退)。', inflation: 'Fed 是否升息/降息、薪資購買力、必需消費品價格、債券實質報酬。', labor: '家庭收入、消費能力、Fed 政策(就業太熱+通膨高→難降息)。', growth: '企業營收展望、股市盈餘預期、原物料與週期股表現。', money: '企業融資難易、新興市場資金、信用危機風險、風險資產流動性。', sentiment: '股市波動、避險情緒、大宗商品與匯率連動。', credit_spread: '高收益債、小型股、槓桿高的公司——利差一擴大,這些通常先跌。', vix: '選擇權價格、短線波動、避險資產(黃金、公債)需求。', recession_prob: '整體股票部位、景氣循環股 vs 防禦股、Fed 降息預期。', }; function numberExplain(ind, value, formatted) { const fn = NUMBER_EXPLAIN[ind.key]; if (fn) return fn(formatted, value); switch (ind.format) { case 'pct': case 'pct_signed': if (ind.transform === 'yoy') return `比一年前變化 ${formatted}。`; return `目前讀數是 ${formatted}。`; case 'bp': return `目前差距是 ${formatted}(100bp=1%)。`; case 'k': case 'k_signed': return `目前讀數約 ${formatted}。`; case 'trillions': return `規模約 ${formatted}。`; case 'usd': case 'usd0': return `目前價格 ${formatted}。`; default: return `目前讀數是 ${formatted}。`; } } function affectsExplain(ind) { if (AFFECTS[ind.key]) return AFFECTS[ind.key]; if (AFFECTS[ind.group]) return AFFECTS[ind.group]; return ind.tip?.impact || '會連動到其他總經指標與風險資產,建議搭配走勢圖一起看方向。'; } function benchmarkNote(ind, value) { if (['cpi', 'core_cpi', 'pce', 'breakeven'].includes(ind.key)) { const diff = value - 2; if (Math.abs(diff) <= 0.3) return '接近 Fed 的 2% 通膨目標。'; if (diff > 0.3) return `比 Fed 2% 目標高出約 ${diff.toFixed(1)} 個百分點,偏熱。`; return `比 Fed 2% 目標低約 ${Math.abs(diff).toFixed(1)} 個百分點,偏冷。`; } if (ind.key === 'recession_prob') { if (value >= 40) return '屬於歷史上偏高的衰退風險區間。'; if (value >= 25) return '已進入值得留意的警戒區(常見閾值 25~30%)。'; return '目前模型認為衰退機率不算高。'; } if (ind.key === 'yield_spread' && value < 0) return '曲線倒掛中——歷史上常領先衰退約 6~18 個月。'; if (ind.key === 'mfg' || ind.key === 'sentiment_consumer') { if (value > 0) return '大於 0 代表在擴張/偏樂觀區間。'; return '小於 0 代表在收縮/偏悲觀區間。'; } return ''; } function historyExplain(ind, stats, formatted) { const { pct, min, max, years, level, p50 } = stats; if (pct == null) return '歷史資料不足,暫無法對照。'; const range = `${fmtCtx(min, ind.format, ind.decimals)} ~ ${fmtCtx(max, ind.format, ind.decimals)}`; let text = `過去約 ${years} 年落在 ${range};現在 ${formatted},比這段時間裡約 ${pct}% 的時候都${valueWord(ind, pct)}(${level.label})。`; if (p50 != null) { const med = fmtCtx(p50, ind.format, ind.decimals); const diff = ind.format === 'bp' ? (stats.current - p50) : (stats.current - p50); if (Math.abs(diff) > (ind.format === 'bp' ? 15 : 0.15)) { text += ` 中位數約 ${med},現在${diff > 0 ? '高' : '低'}於中位數。`; } } const bench = benchmarkNote(ind, stats.current); if (bench) text += ` ${bench}`; return text; } function valueWord(ind, pct) { if (pct >= 50) return '高'; return '低'; } function cardSummary(ind, stats) { if (stats.pct == null) return ''; const y = stats.years; const p = stats.pct; const lv = stats.level.label; if (ind.key === 'treasury_10y') { return p >= 70 ? `近${y}年偏高(第${p}百分位)· 長期借錢偏貴` : p <= 30 ? `近${y}年偏低(第${p}百分位)· 長期借錢偏便宜` : `近${y}年${lv}(第${p}百分位)`; } if (ind.inverted) { return p >= 70 ? `近${y}年偏高(第${p}百分位)· 偏警訊` : p <= 30 ? `近${y}年偏低(第${p}百分位)· 偏友善` : `近${y}年${lv}(第${p}百分位)`; } if (!ind.excludeFromScore) { return p >= 70 ? `近${y}年偏高(第${p}百分位)· 偏順風` : p <= 30 ? `近${y}年偏低(第${p}百分位)· 偏逆風` : `近${y}年${lv}(第${p}百分位)`; } return `近${y}年${lv}(第${p}百分位)`; } export function buildHistoricalContext(metric, ind, value) { const window = recentWindow(metric); const vals = window.map(m => m.val).filter(Number.isFinite); if (!vals.length || !Number.isFinite(value)) return null; const sorted = [...vals].sort((a, b) => a - b); const pct = percentileRank(vals, value); const level = levelFromPercentile(pct, ind); const p50 = sorted[Math.floor(sorted.length / 2)]; const years = Math.max(1, Math.round((new Date(window[window.length - 1].date) - new Date(window[0].date)) / (365.25 * 86400000))); const formatted = fmtCtx(value, ind.format, ind.decimals); const stats = { pct, min: sorted[0], max: sorted[sorted.length - 1], p50, current: value, years, level }; return { percentile: pct, level: level.label, tone: level.tone, rangeMin: sorted[0], rangeMax: sorted[sorted.length - 1], rangeYears: years, summary: cardSummary(ind, stats), number: numberExplain(ind, value, formatted), history: historyExplain(ind, stats, formatted), affects: affectsExplain(ind), }; }