finance-dashboard/lib/fincheck.js

178 lines
11 KiB
JavaScript
Raw Permalink Normal View History

2026-06-03 09:21:58 +00:00
// ═══════════════════════════════════════════════════════════
// fincheck.js — 財報健檢規則引擎
// 輸入 fundamentals.js 正規化後的財報,照 Emmy「財報基本功」五步驟
// 產出紅/黃/綠燈 + 白話提醒,每條檢查連回對應名詞/心法/案例。
// 純規則、不抓網路;連結目標為 knowledge linkMap 的鍵,前端負責跳轉。
// ═══════════════════════════════════════════════════════════
const L = {
營收: { target: '名詞/營收', label: '營收' },
YoY: { target: '名詞/YoY', label: 'YoY' },
QoQ: { target: '名詞/QoQ', label: 'QoQ' },
毛利率: { target: '名詞/毛利率', label: '毛利率' },
EPS: { target: '名詞/EPS', label: 'EPS' },
淨利: { target: '名詞/淨利', label: '淨利' },
Capex: { target: '名詞/Capex', label: 'Capex' },
現金流: { target: '名詞/現金流量表', label: '現金流量表' },
負債率: { target: '名詞/負債率', label: '負債率' },
資產負債表: { target: '名詞/資產負債表', label: '資產負債表' },
NonGAAP: { target: '名詞/Non-GAAP', label: 'Non-GAAP' },
財測: { target: '名詞/財測', label: '財測' },
進攻Capex: { target: 'Emmy 投資心法#原則三進攻型Capex', label: '原則三進攻型Capex' },
定價權: { target: 'Emmy 投資心法#原則四:供給瓶頸定價權', label: '原則四:供給瓶頸定價權' },
毛利溫度計: { target: 'Emmy 投資心法#原則四十七:毛利率是規模化的溫度計', label: '原則四十七:毛利率是規模化的溫度計' },
財測重要: { target: 'Emmy 投資心法#原則十七:財測比財報重要', label: '原則十七:財測比財報重要' },
總經優先: { target: 'Emmy 投資心法#原則五十:總經大於產業大於個股', label: '原則五十:總經>產業>個股' },
財報基本功: { target: '學習分類/財報基本功', label: '財報基本功' },
capex案例: { target: '案例講解/AI四巨頭Capex怎麼看', label: 'AI四巨頭Capex怎麼看' },
財報案例: { target: '案例講解/NVIDIA財報怎麼看', label: 'NVIDIA財報怎麼看' },
};
const signed = (v, d = 0, suf = '%') => v == null || isNaN(v) ? '—' : (v >= 0 ? '+' : '') + v.toFixed(d) + suf;
const na = (id, step, label, note, links) => ({ id, step, label, status: 'na', value: '無資料', note, links: links || [] });
function yoy(cur, prev) { return (cur != null && prev) ? ((cur - prev) / Math.abs(prev)) * 100 : null; }
// 找最接近「一年前」的那一季(容忍 ±70 天),避免 EDGAR 不單獨揭露 Q4 造成的索引錯位
function yearAgo(q, ref) {
if (!ref || !ref.end) return null;
const target = new Date(ref.end); target.setUTCFullYear(target.getUTCFullYear() - 1);
let best = null, bd = Infinity;
for (let i = 1; i < q.length; i++) {
if (!q[i].end) continue;
const d = Math.abs(new Date(q[i].end) - target) / 86400000;
if (d < bd) { bd = d; best = q[i]; }
}
return bd <= 70 ? best : null;
}
export function buildReport(f) {
const q = f.quarters || [];
const a = f.annual || [];
const bal = f.balance || {};
const checks = []; // {step}
const add = c => checks.push(c);
const cur = q[0] || null;
const ya = cur ? yearAgo(q, cur) : null;
// ── 步驟 1營收成長 ──
if (cur && ya && cur.revenue != null && ya.revenue != null) {
const g = yoy(cur.revenue, ya.revenue);
add({ id: 'rev_yoy', step: 1, label: `營收年增 YoY${cur.label} vs ${ya.label}`, value: signed(g, 0),
status: g >= 15 ? 'good' : g >= 0 ? 'warn' : 'bad',
note: g >= 15 ? '營收年增強勁,需求結構性成長。' : g >= 0 ? '營收仍在成長但動能偏緩,留意是否放慢。' : '營收較去年同期衰退,需求或競爭出問題。',
links: [L.營收, L.YoY] });
} else if (a.length >= 2 && a[0].revenue != null && a[1].revenue != null) {
const g = yoy(a[0].revenue, a[1].revenue);
add({ id: 'rev_yoy', step: 1, label: `營收年增(${a[1].label}${a[0].label},年度)`, value: signed(g, 0),
status: g >= 15 ? 'good' : g >= 0 ? 'warn' : 'bad',
note: '季資料不足,改用年度營收比較。', links: [L.營收, L.YoY] });
} else add(na('rev_yoy', 1, '營收年增 YoY', '財報期數不足,無法計算年增。', [L.營收]));
if (q.length >= 2 && cur.revenue != null && q[1].revenue != null) {
const g = yoy(cur.revenue, q[1].revenue);
add({ id: 'rev_qoq', step: 1, label: `營收環比 QoQ${cur.label} vs ${q[1].label}`, value: signed(g, 0),
status: g >= 0 ? 'good' : g >= -10 ? 'warn' : 'bad',
note: '看短期動能;高成長股最怕增速放慢。注意產業淡旺季與會計年度。', links: [L.QoQ] });
}
// ── 步驟 2毛利率與獲利品質 ──
const gm = q.find(p => p.grossMargin != null)?.grossMargin ?? a.find(p => p.grossMargin != null)?.grossMargin;
if (gm != null) {
add({ id: 'gm_level', step: 2, label: '毛利率水準', value: gm.toFixed(1) + '%',
status: gm >= 50 ? 'good' : gm >= 25 ? 'warn' : 'bad',
note: gm >= 50 ? '毛利率高,通常代表定價權、技術門檻或供給瓶頸。' : gm >= 25 ? '毛利率中等,視產業而定(硬體偏低、軟體偏高)。' : '毛利率偏低,定價權或成本結構需留意。',
links: [L.毛利率, L.定價權] });
if (cur && ya && cur.grossMargin != null && ya.grossMargin != null) {
const d = cur.grossMargin - ya.grossMargin;
add({ id: 'gm_trend', step: 2, label: '毛利率趨勢(年比)', value: signed(d, 1, 'pp'),
status: d >= 0.5 ? 'good' : d >= -1 ? 'warn' : 'bad',
note: d >= 0.5 ? '毛利率走升,是規模化/產品組合優化的訊號。' : d >= -1 ? '毛利率大致持平。' : '毛利率下滑,留意是否降價、成本上升或組合變差。',
links: [L.毛利溫度計] });
}
} else add(na('gm_level', 2, '毛利率', '此來源未提供毛利資料(部分公司不揭露銷貨成本)。', [L.毛利率]));
if (cur && ya && cur.operatingMargin != null && ya.operatingMargin != null) {
const d = cur.operatingMargin - ya.operatingMargin;
add({ id: 'om_trend', step: 2, label: '營業利潤率趨勢(年比)', value: signed(d, 1, 'pp'),
status: d >= 0 ? 'good' : d >= -2 ? 'warn' : 'bad',
note: '營收成長率高於淨利成長時,常代表營運槓桿開始發酵。', links: [L.淨利] });
}
// ── 步驟 3EPS 與獲利 ──
if (cur && ya && cur.eps != null && ya.eps != null) {
const g = yoy(cur.eps, ya.eps);
add({ id: 'eps_yoy', step: 3, label: `EPS 年增(${cur.label} vs ${ya.label}`, value: signed(g, 0),
status: g >= 15 ? 'good' : g >= 0 ? 'warn' : 'bad',
note: f.source === 'Yahoo Finance' ? 'EPS 以淨利÷流通股數估算,僅供概略參考。' : 'EPS 取自申報的稀釋每股盈餘。',
links: [L.EPS] });
} else add(na('eps_yoy', 3, 'EPS 年增', 'EPS 期數不足或來源未提供。', [L.EPS]));
const nm = q.find(p => p.netMargin != null)?.netMargin;
if (nm != null) {
add({ id: 'net_margin', step: 3, label: '淨利率', value: nm.toFixed(1) + '%',
status: nm >= 15 ? 'good' : nm > 0 ? 'warn' : 'bad',
note: nm > 0 ? '最後真正落袋的獲利佔營收比例。' : '目前淨利為負,須看是投入期還是結構性虧損。',
links: [L.淨利] });
}
// ── 步驟 4Capex 與現金流 ──
const ay = a.find(p => p.capex != null && p.netIncome != null);
if (ay) {
const burn = Math.abs(ay.capex) / Math.abs(ay.netIncome);
const profitable = ay.netIncome > 0;
add({ id: 'capex_burn', step: 4, label: `燒錢倍數 Capex ÷ 淨利(${ay.label}`, value: profitable ? burn.toFixed(2) + 'x' : '淨利為負',
status: !profitable ? 'bad' : burn <= 0.6 ? 'good' : burn <= 1 ? 'warn' : 'bad',
note: !profitable ? '淨利為負時的高 Capex 風險較大。' : burn <= 0.6 ? 'Capex 遠低於獲利,擴張遊刃有餘。' : burn <= 1 ? 'Capex 接近全年獲利,留意現金消耗。' : 'Capex 高於獲利=在燒錢擴張,須判斷是「進攻型」還是壓力。',
links: [L.Capex, L.進攻Capex, L.capex案例] });
} else add(na('capex_burn', 4, '燒錢倍數 Capex÷淨利', '年度 Capex 或淨利資料不足。', [L.Capex, L.進攻Capex]));
const ocfY = a.find(p => p.ocf != null);
if (ocfY) {
add({ id: 'ocf', step: 4, label: `營業現金流(${ocfY.label}`, value: fmtMoneyShort(ocfY.ocf),
status: ocfY.ocf > 0 ? 'good' : 'bad',
note: ocfY.ocf > 0 ? '本業真的有現金流入,不只是帳面獲利。' : '營業現金流為負,獲利品質要打問號。',
links: [L.現金流] });
}
// ── 步驟 5資產負債 / 槓桿 ──
if (bal.debtToAssets != null) {
add({ id: 'leverage', step: 5, label: '負債率(總負債 ÷ 總資產)', value: bal.debtToAssets.toFixed(0) + '%',
status: bal.debtToAssets < 50 ? 'good' : bal.debtToAssets <= 70 ? 'warn' : 'bad',
note: bal.debtToAssets < 50 ? '負債比例健康,高利率環境下抗風險能力較強。' : bal.debtToAssets <= 70 ? '負債比例中等,留意利息負擔。' : '負債比例偏高,升息環境下風險較大。',
links: [L.負債率, L.資產負債表] });
} else add(na('leverage', 5, '負債率', '資產負債資料不足。', [L.資產負債表]));
// ── 統計 + 結論 ──
const cnt = { good: 0, warn: 0, bad: 0, na: 0 };
for (const c of checks) cnt[c.status]++;
let verdict, verdictColor;
if (cnt.good + cnt.warn + cnt.bad === 0) { verdict = '資料不足'; verdictColor = 'warn'; }
else if (cnt.bad >= 3) { verdict = '偏弱'; verdictColor = 'bad'; }
else if (cnt.bad >= 1) { verdict = '留意'; verdictColor = 'warn'; }
else if (cnt.warn >= 3) { verdict = '尚可'; verdictColor = 'warn'; }
else { verdict = '穩健'; verdictColor = 'good'; }
const STEP_TITLES = ['', '營收成長', '毛利率與獲利品質', 'EPS 與獲利', 'Capex 與現金流', '資產負債與槓桿'];
const steps = [1, 2, 3, 4, 5].map(n => ({ num: n, title: STEP_TITLES[n], checks: checks.filter(c => c.step === n) }));
const caveats = [
{ text: '財報只是判斷的一面。Emmy 強調 {link},下任何個股判斷前先看天氣(總經與大盤水位)。同一份財報在多頭與空頭環境,市場解讀可能完全不同。',
links: [L.總經優先] },
{ text: '本工具使用申報的 GAAP 數字;看科技股時市場常用 {link},記得對照差異與是否每季都有「一次性費用」。財報也別只看好壞,要和 {link}(市場預期)一起看。',
links: [L.NonGAAP, L.財測] },
{ text: '想學完整看財報的方法,回到 {link} 與 {link}。',
links: [L.財報基本功, L.財報案例] },
];
return { summary: { ...cnt, verdict, verdictColor }, steps, caveats };
}
function fmtMoneyShort(v) {
if (v == null || isNaN(v)) return '—';
const x = Math.abs(v), s = v < 0 ? '-' : '';
if (x >= 1e12) return s + '$' + (x / 1e12).toFixed(2) + 'T';
if (x >= 1e9) return s + '$' + (x / 1e9).toFixed(2) + 'B';
if (x >= 1e6) return s + '$' + (x / 1e6).toFixed(2) + 'M';
return s + '$' + x.toFixed(0);
}