import template from './dcf-calculator.html?raw'; import '../../css/dcf.css'; import { calculateDCF, calculateDCFPriceRange, classifyDCFPrice } from './dcf-math.js'; import { buildDCFPriceOption } from './dcf-price-chart.js'; import { buildDCFCashflowOption } from './dcf-cashflow-chart.js'; import { createDCFHelp } from './dcf-help.js'; const STORAGE_KEY = 'smart-fin-chart:dcf:v1'; let memory; const defaults = () => ({basis: 'fcff', fcf: '', year: '2026', shares: '', shareDate: '', rate: '10', terminal: '2', debt: '0', assets: '0', minority: '0', margin: '20', premium: '20', marketPrice: '', quoteDate: ''}); const initialStages = () => [{years: '3', growth: '12'}, {years: '4', growth: '8'}, {years: '3', growth: '4'}]; const profile = (name, example = false) => ({name, inputs: {...defaults(), ...(example ? {fcf: '100', shares: '10', debt: '100', marketPrice: '138'} : {})}, stages: initialStages(), detailOpen: false}); const fmt = value => new Intl.NumberFormat('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(value); function readStore() { if (memory) return memory; try { const saved = JSON.parse(localStorage.getItem(STORAGE_KEY)); if (saved?.version === 1 && Array.isArray(saved.profiles)) { const profiles = saved.profiles.filter(item => typeof item.id === 'string' && typeof item.name === 'string' && item.inputs && typeof item.inputs === 'object' && Array.isArray(item.stages) && item.stages.length >= 1 && item.stages.length <= 5 && item.stages.every(stage => stage && typeof stage === 'object')); if (profiles.length || (saved.profiles.length === 0 && Array.isArray(saved.deletedProfileIds))) return {...saved, profiles}; } } catch { /* Storage can be unavailable or corrupt; the calculator still works in memory. */ } return {version: 1, selected: 'example', profiles: []}; } export function renderDCFCalculator(container, companies = []) { container.innerHTML = template; const root = container.querySelector('.dcf-calculator'); const disposeHelp = createDCFHelp(root); const q = name => root.querySelector(`[data-${name}]`); const fields = Object.fromEntries([...root.querySelectorAll('[data-field]')].map(field => [field.dataset.field, field])); const store = readStore(); store.deletedProfileIds = Array.isArray(store.deletedProfileIds) ? store.deletedProfileIds.filter(id => typeof id === 'string') : []; const addPreset = (id, name, example = false) => { if (!store.deletedProfileIds.includes(id) && !store.profiles.some(item => item.id === id)) store.profiles.push({id, ...profile(name, example)}); }; addPreset('example', '教学示例(假设参数)', true); companies.forEach(company => addPreset(company.id, company.name)); let active = store.profiles.find(item => item.id === store.selected) || store.profiles[0]; store.selected = active?.id ?? null; let stages; let chart; let chartData; let cashflowChart; let cashflowRows; let resizeFrame; let disposed = false; memory = store; function save() { if (active) { // Retain legacy saved fields without using them in this DCF-only interface. active.inputs = {...active.inputs, ...Object.fromEntries(Object.entries(fields).map(([key, field]) => [key, field.value]))}; active.stages = stages.map(stage => ({...stage})); active.detailOpen = q('detail').open; } memory = store; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); q('save-status').textContent = '已自动保存到本机 · 每家公司独立保存'; } catch { q('save-status').textContent = '浏览器存储不可用,参数仅在本次页面中保留'; } } function deleteCompany(item) { if (!window.confirm(`删除「${item.name}」?\n该公司保存在本机的估值参数将一并删除,无法撤销。`)) return; save(); const index = store.profiles.findIndex(profile => profile.id === item.id); if (index < 0) return; store.profiles.splice(index, 1); if (!store.deletedProfileIds.includes(item.id)) store.deletedProfileIds.push(item.id); if (active?.id === item.id) active = store.profiles[Math.min(index, store.profiles.length - 1)]; store.selected = active?.id ?? null; applyProfile(); save(); const selected = q('company-buttons').querySelector('[aria-pressed="true"]'); (selected || q('company-name')).focus(); } function renderCompanies() { q('company-buttons').replaceChildren(...store.profiles.map(item => { const chip = document.createElement('span'); chip.className = 'df-company-chip'; chip.dataset.profileId = item.id; const button = document.createElement('button'); button.type = 'button'; button.textContent = item.name; button.setAttribute('aria-pressed', String(item.id === active?.id)); button.addEventListener('click', () => { if (active?.id === item.id) return; save(); active = item; store.selected = item.id; q('company-form').hidden = true; applyProfile(); save(); }); const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'df-company-delete'; remove.textContent = '×'; remove.setAttribute('aria-label', `删除公司:${item.name}`); remove.title = `删除${item.name}`; remove.addEventListener('click', () => deleteCompany(item)); chip.append(button, remove); return chip; })); } function chartColors() { const styles = getComputedStyle(root); const color = name => styles.getPropertyValue(name).trim(); return {ink: color('--df-ink'), muted: color('--df-muted'), line: color('--df-line'), paper: color('--df-paper'), accent: color('--df-accent'), secondary: color('--df-chart-secondary'), low: color('--df-low'), fair: color('--df-fair'), high: color('--df-high')}; } function drawChart() { if (disposed || !root.isConnected || !chartData) return; if (!globalThis.echarts) { q('market-error').textContent = '图表组件未加载,请检查网络后刷新页面;区间数值仍可查看。'; q('market-error').hidden = false; return; } if (!chart) chart = globalThis.echarts.init(q('market-chart'), null, {renderer: 'svg'}); chart.resize(); chart.setOption(buildDCFPriceOption({...chartData, colors: chartColors(), width: q('market-chart').clientWidth, reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches}), true); } function drawCashflowChart() { if (disposed || !root.isConnected || !cashflowRows) return; q('cashflow-error').hidden = true; try { if (!globalThis.echarts) throw new Error('图表组件未加载,仍可在上方表格查看逐年数据。'); const element = q('cashflow-chart'); if (!cashflowChart) cashflowChart = globalThis.echarts.init(element, null, {renderer: 'svg'}); cashflowChart.resize(); cashflowChart.setOption(buildDCFCashflowOption({rows: cashflowRows, colors: chartColors(), width: element.clientWidth, reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches}), true); } catch (error) { q('cashflow-error').textContent = error.message; q('cashflow-error').hidden = false; } } function clearMarket(message) { chartData = undefined; chart?.clear(); q('market-chart').hidden = true; q('zone-keys').hidden = true; q('market-badge').hidden = true; q('market-error').hidden = true; const price = Number(fields.marketPrice.value); q('market-current').textContent = fields.marketPrice.value.trim() && Number.isFinite(price) && price > 0 ? fmt(price) : '—'; q('deviation').textContent = '—'; delete q('deviation').dataset.priceRelation; q('price-position').textContent = '待补全有效的估值参数'; q('market-message').textContent = message; q('market-message').hidden = false; } function updateMarket(p, result) { clearMarket('填写当前股价后查看所处区间'); if (result.perShare <= 0) { q('market-message').textContent = '无正的每股股权价值,无法划分股价区间'; return; } try { if (!fields.premium.value.trim()) throw new Error('请填写高估溢价阈值'); const range = calculateDCFPriceRange({perShare: result.perShare, margin: p.margin, premium: Number(fields.premium.value) / 100}); q('zone-low').textContent = `< ${fmt(range.low)} 元`; q('zone-fair').textContent = `${fmt(range.low)}—${fmt(range.high)} 元`; q('zone-high').textContent = `> ${fmt(range.high)} 元`; q('zone-keys').hidden = false; q('market-message').hidden = true; q('price-position').textContent = '填写当前股价后查看所处区间'; let price; if (fields.marketPrice.value.trim()) { const entered = Number(fields.marketPrice.value); try { const position = classifyDCFPrice(entered, range); price = entered; q('market-current').textContent = fmt(price); q('market-badge').textContent = `${position.zone}区间`; q('market-badge').dataset.zone = position.zone; q('market-badge').hidden = false; q('deviation').textContent = `${position.deviation <= 0 ? '折价' : '溢价'} ${fmt(Math.abs(position.deviation) * 100)}%`; q('deviation').dataset.priceRelation = position.deviation <= 0 ? 'discount' : 'premium'; q('price-position').textContent = `处于${position.zone}区间`; q('market-message').hidden = true; } catch (error) { q('market-error').textContent = error.message; q('market-error').hidden = false; } } q('market-chart').hidden = false; q('market-chart').setAttribute('aria-label', `${price === undefined ? '尚未填写有效股价' : `当前股价 ${fmt(price)} 元`},DCF 每股价值 ${fmt(range.perShare)} 元,合理区间 ${fmt(range.low)} 至 ${fmt(range.high)} 元`); chartData = {range, price}; drawChart(); } catch (error) { clearMarket(error.message); q('market-error').textContent = error.message; q('market-error').hidden = false; q('market-message').hidden = true; } } function renderStages() { q('stages').replaceChildren(); stages.forEach((stage, index) => { const row = document.createElement('div'); row.className = 'df-stage-row'; const heading = document.createElement('div'); heading.className = 'df-stage-name'; heading.textContent = `阶段 ${index + 1}`; const range = document.createElement('small'); range.dataset.stageRange = String(index); heading.append(range); row.append(heading); for (const [key, caption] of [['years', '年数'], ['growth', '年增长率 %']]) { const label = document.createElement('label'); const text = document.createElement('span'); text.className = 'df-stage-key'; text.textContent = caption; const input = document.createElement('input'); input.type = 'number'; input.step = key === 'years' ? '1' : '0.5'; input.value = stage[key]; input.setAttribute('aria-label', `阶段${index + 1}${caption}`); input.dataset.stage = String(index); input.dataset.stageField = key; input.addEventListener('input', () => { stage[key] = input.value; update(); save(); }); label.append(text, input); row.append(label); } const remove = document.createElement('button'); remove.type = 'button'; remove.textContent = '×'; remove.setAttribute('aria-label', `删除阶段${index + 1}`); remove.disabled = stages.length === 1; remove.addEventListener('click', () => { stages.splice(index, 1); renderStages(); update(); save(); }); row.append(remove); q('stages').append(row); }); q('add').disabled = stages.length >= 5; } function read() { const input = {basis: fields.basis.value}; for (const key of ['fcf', 'year', 'shares', 'rate', 'terminal', 'margin', 'debt', 'assets', 'minority']) { if (input.basis === 'fcfe' && ['debt', 'assets', 'minority'].includes(key)) { input[key] = 0; continue; } if (!fields[key].value.trim()) throw new Error(key === 'shares' ? '请填写该公司的当前总股本(亿股),用于每股价值换算' : '请补全基期现金流、折现率和其他必填参数'); input[key] = Number(fields[key].value); } for (const key of ['rate', 'terminal', 'margin']) input[key] /= 100; input.stages = stages.map(stage => ({ years: stage.years.trim() ? Number(stage.years) : NaN, growth: stage.growth.trim() ? Number(stage.growth) / 100 : NaN, })); return input; } function update() { const fcfe = fields.basis.value === 'fcfe'; q('rate-label').textContent = fcfe ? '股权资本成本(%)' : '折现率 WACC(%)'; for (const name of ['debt-field', 'assets-field', 'minority-field', 'adjustment']) q(name).hidden = fcfe; q('value-label').textContent = fcfe ? '现金流股权价值' : '企业经营价值'; q('basis-caption').textContent = `${fcfe ? 'FCFE' : 'FCFF'} 口径`; q('basis-note').textContent = fcfe ? '直接折现股东现金流,不再重复扣减净债务。' : '先折现企业现金流,再调整为股权价值。'; q('model-note').textContent = fcfe ? 'FCFE 按股权资本成本折现,不再扣减净债务' : 'FCFF 按 WACC 折现,再调整为股权价值'; let first = 1; stages.forEach((stage, index) => { const years = Number(stage.years); root.querySelector(`[data-stage-range="${index}"]`).textContent = Number.isInteger(years) && years > 0 && Number.isFinite(first) ? `第 ${first}—${first + years - 1} 年` : '请填写有效年数'; first = Number.isInteger(years) && years > 0 ? first + years : NaN; }); q('error').hidden = true; q('warning').hidden = true; try { const p = read(); const r = calculateDCF(p); q('duration').textContent = `合计 ${r.years} 年 · ${p.year + 1}—${p.year + r.years} 年,各阶段逐年复利衔接`; q('table-summary').textContent = ` · ${r.years} 年`; const values = {pv: r.forecastPV, tv: r.terminalPV, ev: r.operatingValue, equity: r.equityValue, 'debt-value': p.debt, 'assets-value': p.assets, 'minority-value': p.minority}; for (const [name, value] of Object.entries(values)) q(name).textContent = fmt(value); q('price').textContent = r.equityValue > 0 ? fmt(r.perShare) : '无正股权价值'; q('value-per-share').textContent = r.equityValue > 0 ? fmt(r.perShare) : '—'; q('buy').textContent = r.equityValue > 0 ? fmt(r.entryPrice) : '—'; q('discount-note').textContent = `内在价值打 ${fmt((1 - p.margin) * 10)} 折`; q('shares-note').textContent = `按 ${fmt(p.shares)} 亿股换算${fields.shareDate.value ? ` · 股本日期 ${fields.shareDate.value}` : ''}`; q('forecast-share').textContent = `${fmt((1 - r.terminalWeight) * 100)}%`; q('terminal-share').textContent = `${fmt(r.terminalWeight * 100)}%`; q('terminal-note').textContent = `永续终值占经营现金流现值的 ${fmt(r.terminalWeight * 100)}%,结果对折现率和永续增长假设敏感。`; q('rows').replaceChildren(...r.rows.map(row => { const tr = document.createElement('tr'); for (const value of [`${row.year} · 阶段 ${row.stage}`, `${fmt(row.growth * 100)}%`, fmt(row.cash), row.factor.toFixed(4), fmt(row.presentValue)]) { const td = document.createElement('td'); td.textContent = value; tr.append(td); } return tr; })); const warnings = []; if (r.terminalWeight > .75) warnings.push('永续终值超过总现值的 75%,结果对折现率与永续增长率高度敏感。'); if (r.equityValue <= 0) warnings.push('调整后的股权价值不大于 0,无法据此给出正的每股估值或参考买入价。'); if (warnings.length) { q('warning').textContent = warnings.join(' '); q('warning').hidden = false; } q('detail').hidden = false; cashflowRows = r.rows; q('cashflow-section').hidden = false; q('cashflow-caption').textContent = `${p.year + 1}—${p.year + r.years} 年 · ${r.years} 年预测期 · 现值合计 ${fmt(r.forecastPV)} 亿元`; q('cashflow-chart').setAttribute('aria-label', `${r.years} 年自由现金流与折现现值对比,${r.rows.map(row => `${row.year} 年现金流 ${fmt(row.cash)}、现值 ${fmt(row.presentValue)} 亿元`).join(';')}`); drawCashflowChart(); q('composition-forecast').style.width = `${Math.max(0, Math.min(100, (1 - r.terminalWeight) * 100))}%`; q('composition-terminal').style.width = `${Math.max(0, Math.min(100, r.terminalWeight * 100))}%`; updateMarket(p, r); root.dataset.status = 'ready'; } catch (error) { q('error').textContent = error.message; q('error').hidden = false; q('detail').hidden = true; cashflowRows = undefined; cashflowChart?.clear(); q('cashflow-section').hidden = true; for (const name of ['price', 'buy', 'value-per-share', 'pv', 'tv', 'ev', 'equity', 'debt-value', 'assets-value', 'minority-value', 'forecast-share', 'terminal-share']) q(name).textContent = '—'; q('shares-note').textContent = '补全总股本后换算每股价值'; q('discount-note').textContent = '按设定的安全边际折价'; q('terminal-note').textContent = '补全有效参数后查看价值构成'; q('composition-forecast').style.width = '0%'; q('composition-terminal').style.width = '0%'; q('duration').textContent = '请补全有效参数后查看结果'; clearMarket('请补全有效的现金流、股本与折现参数后查看估值区间'); root.dataset.status = 'invalid'; } } function applyProfile() { root.dataset.empty = String(!active); q('company-empty').hidden = Boolean(active); if (!active) { stages = []; chartData = cashflowRows = undefined; chart?.clear(); cashflowChart?.clear(); root.dataset.status = 'empty'; q('company-form').hidden = false; q('company-error').textContent = ''; renderCompanies(); return; } const values = {...defaults(), ...active.inputs}; for (const [key, field] of Object.entries(fields)) field.value = values[key]; stages = active.stages.map(stage => ({years: String(stage.years ?? ''), growth: String(stage.growth ?? '')})); q('detail').open = active.detailOpen !== false; q('context').textContent = active.id === 'example' ? '教学示例 · 非实时数据 · 人民币' : `${active.name} · 用户手动输入 · 人民币`; renderCompanies(); renderStages(); update(); } q('add').addEventListener('click', () => { if (stages.length >= 5) return; stages.push({years: '3', growth: '3'}); renderStages(); update(); save(); }); for (const field of Object.values(fields)) { const onChange = () => { update(); save(); }; field.addEventListener('input', onChange); if (field.tagName === 'SELECT') field.addEventListener('change', onChange); } q('detail').addEventListener('toggle', () => { if (root.isConnected) save(); }); q('new-company').addEventListener('click', () => { q('company-form').hidden = false; q('company-error').textContent = ''; q('company-name').focus(); }); q('cancel-company').addEventListener('click', () => { q('company-form').hidden = true; }); q('company-form').addEventListener('submit', event => { event.preventDefault(); const name = q('company-name').value.trim(); if (!name) { q('company-error').textContent = '请输入公司名称'; return; } if (store.profiles.some(item => item.name === name)) { q('company-error').textContent = '该公司已存在,请点击公司按钮切换'; return; } save(); active = {id: `custom-${crypto.randomUUID()}`, ...profile(name)}; store.profiles.push(active); store.selected = active.id; q('company-form').hidden = true; q('company-name').value = ''; applyProfile(); save(); }); applyProfile(); const observer = new ResizeObserver(() => { cancelAnimationFrame(resizeFrame); resizeFrame = requestAnimationFrame(() => { drawChart(); drawCashflowChart(); }); }); observer.observe(q('market-chart')); observer.observe(q('cashflow-chart')); return () => { disposed = true; disposeHelp(); observer.disconnect(); cancelAnimationFrame(resizeFrame); chart?.dispose(); cashflowChart?.dispose(); }; }