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