feat:dcf 计算估值
This commit is contained in:
+37
-69
@@ -3,22 +3,16 @@ 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: '',
|
||||
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: '',
|
||||
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'});
|
||||
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), ...(example ? {fcf: '100', shares: '10', debt: '100', marketPrice: '138'} : {})}, stages: initialStages(), detailOpen: false});
|
||||
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() {
|
||||
@@ -40,32 +34,7 @@ export function renderDCFCalculator(container, companies = []) {
|
||||
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) => {
|
||||
@@ -82,15 +51,14 @@ export function renderDCFCalculator(container, companies = []) {
|
||||
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;
|
||||
// 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 {
|
||||
@@ -102,7 +70,7 @@ export function renderDCFCalculator(container, companies = []) {
|
||||
}
|
||||
|
||||
function deleteCompany(item) {
|
||||
if (!window.confirm(`删除「${item.name}」?\n该公司保存在本机的 DCF 与收益校验参数将一并删除,无法撤销。`)) return;
|
||||
if (!window.confirm(`删除「${item.name}」?\n该公司保存在本机的估值参数将一并删除,无法撤销。`)) return;
|
||||
save();
|
||||
const index = store.profiles.findIndex(profile => profile.id === item.id);
|
||||
if (index < 0) return;
|
||||
@@ -191,7 +159,9 @@ export function renderDCFCalculator(container, companies = []) {
|
||||
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('deviation').textContent = '—';
|
||||
delete q('deviation').dataset.priceRelation;
|
||||
q('price-position').textContent = '待补全有效的估值参数';
|
||||
q('market-message').textContent = message;
|
||||
q('market-message').hidden = false;
|
||||
}
|
||||
@@ -210,7 +180,7 @@ export function renderDCFCalculator(container, companies = []) {
|
||||
q('zone-high').textContent = `> ${fmt(range.high)} 元`;
|
||||
q('zone-keys').hidden = false;
|
||||
q('market-message').hidden = true;
|
||||
q('deviation').textContent = '填写当前股价后查看所处区间';
|
||||
q('price-position').textContent = '填写当前股价后查看所处区间';
|
||||
let price;
|
||||
if (fields.marketPrice.value.trim()) {
|
||||
const entered = Number(fields.marketPrice.value);
|
||||
@@ -221,7 +191,9 @@ export function renderDCFCalculator(container, companies = []) {
|
||||
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('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;
|
||||
@@ -311,6 +283,8 @@ export function renderDCFCalculator(container, companies = []) {
|
||||
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) => {
|
||||
@@ -326,13 +300,17 @@ export function renderDCFCalculator(container, companies = []) {
|
||||
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};
|
||||
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('margin-label').textContent = fmt(p.margin * 100) + '%';
|
||||
q('discount-note').textContent = `内在价值打 ${fmt((1 - p.margin) * 10)} 折`;
|
||||
q('shares-note').textContent = `按 ${fmt(p.shares)} 亿股换算${fields.shareDate.value ? ` · 股本日期 ${fields.shareDate.value}` : ''}`;
|
||||
q('terminal-share').textContent = `永续终值占比 ${fmt(r.terminalWeight * 100)}%`;
|
||||
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)]) {
|
||||
@@ -352,13 +330,9 @@ export function renderDCFCalculator(container, companies = []) {
|
||||
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;
|
||||
@@ -367,12 +341,14 @@ export function renderDCFCalculator(container, companies = []) {
|
||||
cashflowRows = undefined;
|
||||
cashflowChart?.clear();
|
||||
q('cashflow-section').hidden = true;
|
||||
root.querySelector('.df-output').hidden = true;
|
||||
root.querySelector('.df-results').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('请补全有效的现金流、股本与折现参数后查看估值区间');
|
||||
lastDCFInput = undefined;
|
||||
crosscheck.update(undefined);
|
||||
root.dataset.status = 'invalid';
|
||||
}
|
||||
}
|
||||
@@ -382,7 +358,7 @@ export function renderDCFCalculator(container, companies = []) {
|
||||
q('company-empty').hidden = Boolean(active);
|
||||
if (!active) {
|
||||
stages = [];
|
||||
lastDCFInput = chartData = cashflowRows = undefined;
|
||||
chartData = cashflowRows = undefined;
|
||||
chart?.clear();
|
||||
cashflowChart?.clear();
|
||||
root.dataset.status = 'empty';
|
||||
@@ -391,11 +367,10 @@ export function renderDCFCalculator(container, companies = []) {
|
||||
renderCompanies();
|
||||
return;
|
||||
}
|
||||
const values = {...defaults(active.id === 'example'), ...active.inputs};
|
||||
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('cross-advanced').open = Boolean(active.crossAdvancedOpen);
|
||||
q('context').textContent = active.id === 'example' ? '教学示例 · 非实时数据 · 人民币' : `${active.name} · 用户手动输入 · 人民币`;
|
||||
renderCompanies();
|
||||
renderStages();
|
||||
@@ -407,13 +382,12 @@ export function renderDCFCalculator(container, companies = []) {
|
||||
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();
|
||||
});
|
||||
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('cross-advanced').addEventListener('toggle', () => { if (root.isConnected) save(); });
|
||||
q('new-company').addEventListener('click', () => {
|
||||
q('company-form').hidden = false;
|
||||
q('company-error').textContent = '';
|
||||
@@ -433,12 +407,6 @@ export function renderDCFCalculator(container, companies = []) {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user