finance-dashboard/lib/fincheck.js

178 lines
11 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.

// ═══════════════════════════════════════════════════════════
// 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);
}