Files
smart-fin-chart/js/tools/dcf-calculator.js
T
2026-10-22 15:43:17 +08:00

458 lines
22 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.
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 crosscheckTemplate from './dcf-crosscheck.html?raw';
import { createDCFCrosscheck } from './dcf-crosscheck.js';
import { createDCFHelp } from './dcf-help.js';
const STORAGE_KEY = 'smart-fin-chart:dcf:v1';
let memory;
const defaults = (example = false) => ({basis: 'fcff', fcf: '', year: '2026', shares: '', shareDate: '',
rate: '10', terminal: '2', debt: '0', assets: '0', minority: '0', margin: '20',
premium: '20', marketPrice: '', quoteDate: '',
crossProfitMode: 'manual', crossProfit: example ? '125' : '', crossConversion: example ? '80' : '',
crossProfitGrowth: example ? '8' : '', crossTarget: '13', crossYears: '5',
crossPeLow: '9', crossPe: '12', crossPeHigh: '15',
crossDividend: '0', crossDividendGrowth: '0', crossDilution: '0'});
const initialStages = () => [{years: '3', growth: '12'}, {years: '4', growth: '8'}, {years: '3', growth: '4'}];
const profile = (name, example = false) => ({name,
inputs: {...defaults(example), ...(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}]`);
q('crosscheck-slot').innerHTML = crosscheckTemplate;
const fields = Object.fromEntries([...root.querySelectorAll('[data-field]')].map(field => [field.dataset.field, field]));
const crosscheck = createDCFCrosscheck(q('crosscheck-slot').querySelector('.df-crosscheck'), fields, (data) => {
if (!data) {
q('overview-ceiling').textContent = '—';
q('overview-verdict').textContent = '填写有效的股价及利润假设,比较现金流价值与目标收益门槛。';
q('overview-verdict').closest('.df-decision').removeAttribute('data-ok');
q('top-irr').textContent = '—';
q('top-growth').textContent = '—';
q('top-irr-detail').textContent = '补全交叉验证参数后显示';
q('top-growth-detail').textContent = '按现价与基准退出 PE 反推';
return;
}
const {result, assumptions} = data;
const current = result.rows[0].base;
q('overview-ceiling').textContent = fmt(result.jointCeiling);
const decision = q('overview-verdict').closest('.df-decision');
decision.dataset.ok = String(assumptions.price <= result.jointCeiling + 1e-10);
q('overview-verdict').textContent = assumptions.price <= result.jointCeiling + 1e-10
? '现价低于共同参考上限,两套价格上限均留有设定的安全边际。'
: '现价高于共同参考上限,需复核现金流、利润增长与退出 PE 假设。';
q('top-irr').textContent = `${fmt(current.irr * 100)}%`;
q('top-growth').textContent = current.requiredGrowth === null ? '无需最低增速' : `${fmt(current.requiredGrowth * 100)}%`;
q('top-irr-detail').textContent = `${current.meetsTarget ? '达到' : '未达'}目标 ${fmt(assumptions.target * 100)}% · 持有 ${assumptions.years} 年`;
q('top-growth-detail').textContent = `含安全边际 ${fmt(Number(fields.margin.value))}% · 无边际 ${current.requiredGrowthWithoutMargin === null ? '无需最低增速' : fmt(current.requiredGrowthWithoutMargin * 100) + '%'} · 退出 PE ${fmt(assumptions.pe)} 倍`;
});
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;
let lastDCFInput;
memory = store;
function save() {
if (active) {
active.inputs = Object.fromEntries(Object.entries(fields).map(([key, field]) => [key, field.value]));
active.stages = stages.map(stage => ({...stage}));
active.detailOpen = q('detail').open;
active.crossAdvancedOpen = q('cross-advanced').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该公司保存在本机的 DCF 与收益校验参数将一并删除,无法撤销。`)) 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 = '待补全有效的估值参数';
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('deviation').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 = `相对 DCF 每股价值${position.deviation <= 0 ? '折价' : '溢价'} ${fmt(Math.abs(position.deviation) * 100)}%`;
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('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, bridge: r.adjustment};
for (const [name, value] of Object.entries(values)) q(name).textContent = fmt(value);
q('price').textContent = r.equityValue > 0 ? fmt(r.perShare) : '无正股权价值';
q('buy').textContent = r.equityValue > 0 ? fmt(r.entryPrice) : '—';
q('margin-label').textContent = fmt(p.margin * 100) + '%';
q('shares-note').textContent = `按 ${fmt(p.shares)} 亿股换算${fields.shareDate.value ? ` · 股本日期 ${fields.shareDate.value}` : ''}`;
q('terminal-share').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();
root.querySelector('.df-output').hidden = false;
root.querySelector('.df-results').hidden = false;
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);
lastDCFInput = p;
crosscheck.update(p);
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;
root.querySelector('.df-output').hidden = true;
root.querySelector('.df-results').hidden = true;
q('duration').textContent = '请补全有效参数后查看结果';
clearMarket('请补全有效的现金流、股本与折现参数后查看估值区间');
lastDCFInput = undefined;
crosscheck.update(undefined);
root.dataset.status = 'invalid';
}
}
function applyProfile() {
root.dataset.empty = String(!active);
q('company-empty').hidden = Boolean(active);
if (!active) {
stages = [];
lastDCFInput = 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.id === 'example'), ...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('cross-advanced').open = Boolean(active.crossAdvancedOpen);
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 [name, field] of Object.entries(fields)) field.addEventListener('input', () => {
if (name.startsWith('cross')) crosscheck.update(lastDCFInput);
else update();
save();
});
q('detail').addEventListener('toggle', () => { if (root.isConnected) save(); });
q('cross-advanced').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();
});
root.querySelectorAll('[data-jump]').forEach(button => button.addEventListener('click', () => {
const target = root.querySelector(`[data-section="${button.dataset.jump}"]`);
if (target === q('detail')) target.open = true;
target.scrollIntoView({behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth', block: 'start'});
target.focus({preventScroll: true});
}));
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();
};
}