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