// ═══════════════════════════════════════════════════════════ // 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.淨利] }); } // ── 步驟 3:EPS 與獲利 ── 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.淨利] }); } // ── 步驟 4:Capex 與現金流 ── 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); }