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

426 lines
20 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 { 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();
};
}