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();
};
}