finance-dashboard/lib/context.js

214 lines
10 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.

// 依真實歷史序列,替總經卡片產生「這數字/歷史上/會影響」白話脈絡
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),
};
}