finance-dashboard/lib/context.js

214 lines
10 KiB
JavaScript
Raw Normal View History

2026-06-03 16:42:07 +00:00
// 依真實歷史序列,替總經卡片產生「這數字/歷史上/會影響」白話脈絡
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} 個工作千人千人200K20 萬人)。`,
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}100bp1%)。`;
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 '已進入值得留意的警戒區(常見閾值 2530%)。';
return '目前模型認為衰退機率不算高。';
}
if (ind.key === 'yield_spread' && value < 0) return '曲線倒掛中——歷史上常領先衰退約 618 個月。';
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),
};
}