feat: 增加 DCF 等估值工具

This commit is contained in:
R524809
2026-10-22 15:43:17 +08:00
parent f646ee6718
commit a46de853a0
22 changed files with 2645 additions and 3 deletions
+99
View File
@@ -0,0 +1,99 @@
<section class="dcf-calculator return-calculator" aria-label="DCF 估值计算器">
<header class="df-header"><div><div class="df-eyebrow">VALUATION WORKBENCH <span>/ 估值研究</span></div><h2>DCF 估值工作台</h2><p class="df-subtitle">从现金流价值出发,用盈利增长检验回报。</p></div><div class="df-header-actions"><div class="df-header-mark"><span class="df-live-dot"></span> 参数调整 · 即时试算</div><button class="df-help-trigger" type="button" data-help-open aria-label="打开估值概念说明" aria-haspopup="dialog" title="估值概念说明"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle><path d="M9.5 9a2.5 2.5 0 0 1 5 .3c0 1.7-2.5 2-2.5 3.7"></path><circle cx="12" cy="16.5" r=".8" fill="currentColor" stroke="none"></circle></svg></button></div></header>
<div class="df-profile-bar">
<div class="df-company-buttons" data-company-buttons role="group" aria-label="切换估值公司"></div>
<button class="export-button" type="button" data-new-company>+ 新增公司</button>
<span class="text-small text-muted df-save-status" data-save-status role="status">每家公司的参数独立保存在本机浏览器</span>
</div>
<form class="df-new-company" data-company-form hidden>
<label>公司名称<input class="form-control" data-company-name maxlength="60" placeholder="输入公司名称" required></label>
<button class="export-button" type="submit">添加</button>
<button class="export-button" type="button" data-cancel-company>取消</button>
<span class="text-small text-destructive" data-company-error role="alert"></span>
</form>
<div class="df-company-empty" data-company-empty role="status" hidden>还没有估值公司,添加一家公司开始估值。</div>
<nav class="df-jumpnav" aria-label="估值工作台导航"><button type="button" data-jump="overview">01 估值总览</button><button type="button" data-jump="parameters">02 DCF 参数</button><button type="button" data-jump="crosscheck">03 双模型校验</button><button type="button" data-jump="detail">04 计算明细</button></nav>
<div class="df-overview" data-section="overview" tabindex="-1"><div class="df-output" aria-live="polite"><div class="df-hero"><div class="df-muted">DCF 每股内在价值</div><div class="df-number" data-price>—</div><div class="df-muted">人民币/股 · 当前现金流假设</div><div class="df-value-note"><span data-margin-label>20%</span> 安全边际参考价 <b data-buy>—</b> <span class="df-muted">元/股</span><div class="df-muted" data-shares-note></div></div></div>
</div><div class="df-top-comparison" aria-live="polite" aria-label="现价与收益门槛对比">
<div><h3>当前股价</h3>
<div class="df-price-readout"><span class="df-market-price" data-market-current>—</span><span class="df-muted">元/股</span><span class="df-zone-badge" data-market-badge hidden></span></div>
<div class="df-muted" data-deviation>填写当前股价后查看所处区间</div>
</div>
<div class="df-top-return"><h3>预计年化 IRR</h3>
<div class="df-top-return-value" data-top-irr>—</div>
<div class="df-muted" data-top-irr-detail>补全交叉验证参数后显示</div>
</div>
<div class="df-top-return"><h3>目标收益所需利润年增速(含边际)</h3>
<div class="df-top-return-value" data-top-growth>—</div>
<div class="df-muted" data-top-growth-detail>按现价与基准退出 PE 反推</div>
</div>
</div></div>
<section class="df-decision" aria-label="双模型结论" aria-live="polite"><div><span class="df-eyebrow">双模型共同参考上限</span><strong data-overview-ceiling>—</strong><span class="df-muted">元/股</span></div><p data-overview-verdict>填写当前股价及利润假设,比较现金流价值与目标收益门槛。</p><button type="button" data-jump="crosscheck">查看校验 ↓</button></section>
<section class="df-market" aria-label="当前股价与估值区间">
<div class="df-market-head">
<div><div class="df-eyebrow">PRICE POSITION</div><h3>现价在估值的什么位置?</h3><div class="df-muted">以当前 DCF 假设为基准的价格区间</div></div>
<div class="df-price-fields">
<label>当前股价(人民币)<input data-field="marketPrice" type="number" min="0" step="any" placeholder="手动输入当前股价"></label>
<label>股价日期(选填)<input data-field="quoteDate" type="date"></label>
</div>
</div>
<div class="df-price-chart" data-market-chart role="img" aria-label="当前股价与 DCF 估值区间" hidden></div>
<div class="df-zone-keys" data-zone-keys hidden>
<div class="df-zone-key"><span><i class="df-zone-dot"></i>低估区间</span><strong data-zone-low>—</strong></div>
<div class="df-zone-key"><span><i class="df-zone-dot"></i>合理区间</span><strong data-zone-fair>—</strong></div>
<div class="df-zone-key"><span><i class="df-zone-dot"></i>高估区间</span><strong data-zone-high>—</strong></div>
</div>
<div class="df-muted df-market-message" data-market-message>请补全现金流与股本后查看估值区间</div>
<div class="df-error" data-market-error role="alert" hidden></div>
<div class="df-interval-rule">
<span class="df-muted">区间依据当前 DCF 假设与折溢价阈值划分,不自动获取行情</span>
<details><summary>区间设置</summary>
<div class="df-fields df-interval-fields">
<label>低估折价/安全边际(%)<input data-field="margin" type="number" min="0" max="99.99" value="20" step="any"></label>
<label>高估溢价阈值(%)<input data-field="premium" type="number" min="0" max="1000" value="20" step="any"></label>
</div>
</details>
</div>
</section>
<div class="df-layout">
<div class="df-form" data-section="parameters" tabindex="-1"><div class="df-column-heading"><span>01</span><div><h3>现金流估值</h3><p>企业能产生多少可分配现金流</p></div><b>DCF</b></div>
<section><div class="df-section-head"><h3>基础参数</h3><span class="df-muted"><span data-context>教学示例 · 人民币</span></span></div>
<div class="df-fields">
<label>现金流口径<select class="cursor-interaction" data-field="basis"><option value="fcff">企业自由现金流 FCFF</option><option value="fcfe">股权自由现金流 FCFE</option></select></label>
<label>基期自由现金流(亿元)<input data-field="fcf" type="number" value="100" step="any"></label>
<label class="df-share-field">当前总股本(亿股,必填)<input data-field="shares" type="number" min="0" step="any" required placeholder="填写该公司总股本"><small class="df-muted">用于每股价值换算,1 亿股 = 100,000,000 股</small></label>
<label>股本数据日期(选填)<input data-field="shareDate" type="date"></label>
<label>现金流基期年份<input data-field="year" type="number" value="2026" step="1"></label>
<label><span data-rate-label>折现率 WACC(%)</span><input data-field="rate" type="number" value="10" step="0.5"></label>
</div></section>
<section><div class="df-section-head"><h3>分段增长率</h3><button class="cursor-interaction" type="button" data-add>+ 添加阶段</button></div>
<div class="df-stages" data-stages></div><div class="df-total df-muted" data-duration></div>
</section>
<section><div class="df-section-head"><h3>永续经营与股权换算</h3></div>
<div class="df-fields">
<label>永续增长率(%)<input data-field="terminal" type="number" value="2" step="0.5"></label>
<label data-debt-field>净债务(亿元,可为负)<input data-field="debt" type="number" value="100" step="any"></label>
<label data-assets-field>其他非经营资产净额(亿元)<input data-field="assets" type="number" value="0" step="any"></label>
<label data-minority-field>少数股东权益(亿元)<input data-field="minority" type="number" value="0" step="any"></label>
</div></section>
<div class="df-results"><h3>价值拆解 <span class="df-muted">/亿元</span></h3><div class="df-result-line"><span>预测期现金流现值</span><b data-pv>—</b></div><div class="df-result-line"><span>永续终值现值</span><b data-tv>—</b></div><div class="df-result-line df-sum"><span data-value-label>企业经营价值</span><b data-ev>—</b></div><div class="df-result-line" data-adjustment><span>− 净债务 + 非经营资产 − 少数股东权益</span><b data-bridge>—</b></div><div class="df-result-line df-sum"><span>股权价值</span><b data-equity>—</b></div><div class="df-composition" aria-hidden="true"><span class="df-segment-a" data-composition-forecast></span><span class="df-segment-b" data-composition-terminal></span></div><div class="df-legend"><span>预测期现金流</span><span>永续终值</span></div><div class="df-muted" data-terminal-share></div></div>
</div>
<div class="df-analysis" data-section="crosscheck" tabindex="-1" data-crosscheck-slot></div>
</div>
<div class="df-error" role="alert" data-error hidden></div>
<div class="df-warning" data-warning hidden></div>
<details class="df-table-section" data-detail data-section="detail" tabindex="-1"><summary class="cursor-interaction">逐年现金流与折现明细 <span class="df-muted" data-table-summary></span></summary><div class="df-table-wrap"><table aria-label="逐年现金流折现"><thead><tr><th>年份/阶段</th><th>增长率</th><th>自由现金流(亿元)</th><th>折现因子</th><th>现值(亿元)</th></tr></thead><tbody data-rows></tbody></table></div></details>
<section class="df-cashflow-section" data-cashflow-section aria-label="逐年现金流与现值图表" hidden>
<div class="df-section-head"><div><h3>逐年现金流与折现现值</h3><p class="df-muted" data-cashflow-caption></p></div><span class="df-muted">单位:亿元</span></div>
<div class="df-cashflow-chart" data-cashflow-chart role="img" aria-label="逐年自由现金流与折现现值对比"></div>
<div class="df-error" data-cashflow-error role="alert" hidden></div>
<p class="df-muted">浅棕为当年自由现金流,深棕为折算到基期的现值;仅展示显式预测期,不含永续终值。悬停或轻触柱形可查看年份、增长阶段及折现因子。</p>
</section>
<footer class="df-foot"><span>假设示例 · 年末现金流 · 永续增长法</span><span data-model-note></span></footer>
<p class="df-muted df-note">净利润不能直接代替自由现金流。FCFF 按 WACC 折现后调整为股权价值,FCFE 按股权资本成本折现。非经营资产不得与净债务中的超额现金重复计入。所有金额使用人民币,股本在预测期内保持不变,终值采用持续经营假设。</p>
</section>
+457
View File
@@ -0,0 +1,457 @@
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();
};
}
+36
View File
@@ -0,0 +1,36 @@
const fmt = value => new Intl.NumberFormat('zh-CN', {maximumFractionDigits: 2}).format(value);
// Both series use the same annual rows as the retained table, in 亿元.
export function buildDCFCashflowOption({rows, colors, width, reducedMotion = false}) {
const compact = width < 520;
return {
animation: !reducedMotion,
animationDuration: 0,
animationDurationUpdate: 200,
color: [colors.secondary, colors.accent],
legend: {top: 0, left: 'center', itemWidth: 12, itemHeight: 8,
textStyle: {color: colors.muted, fontSize: 11}, data: ['自由现金流', '折现后现值']},
grid: {left: compact ? 8 : 12, right: 12, top: 58, bottom: rows.length > 15 ? 70 : 16, containLabel: true},
tooltip: {trigger: 'axis', confine: true, backgroundColor: colors.paper, borderColor: colors.line,
textStyle: {color: colors.ink, fontSize: 12}, axisPointer: {type: 'shadow'},
formatter: params => {
const row = rows[params[0].dataIndex];
return `${row.year} 年 · 阶段 ${row.stage}<br>自由现金流:${fmt(row.cash)} 亿元<br>折现后现值:${fmt(row.presentValue)} 亿元<br>折现因子:${row.factor.toFixed(4)}<br>现金流增长率:${fmt(row.growth * 100)}%`;
}},
xAxis: {type: 'category', data: rows.map(row => String(row.year)),
axisLine: {lineStyle: {color: colors.line}}, axisTick: {show: false},
axisLabel: {color: colors.muted, fontSize: 11, hideOverlap: true}},
yAxis: {type: 'value', name: '亿元', nameTextStyle: {color: colors.muted, fontSize: 11},
axisLabel: {color: colors.muted, fontSize: 11},
splitLine: {lineStyle: {color: colors.line, type: 'dashed'}}},
dataZoom: rows.length > 15 ? [{type: 'slider', height: 18, bottom: 8,
startValue: 0, endValue: 14, borderColor: colors.line,
textStyle: {color: colors.muted, fontSize: 10}}] : [],
series: [
{name: '自由现金流', type: 'bar', data: rows.map(row => row.cash),
barMaxWidth: 24, itemStyle: {borderRadius: [3, 3, 0, 0]}},
{name: '折现后现值', type: 'bar', data: rows.map(row => row.presentValue),
barMaxWidth: 24, itemStyle: {borderRadius: [3, 3, 0, 0]}},
],
};
}
+50
View File
@@ -0,0 +1,50 @@
import { calculateDCF } from './dcf-math.js';
import { calculate as calculateReturn } from './stock-return-math.js';
// Cash amounts: 亿元; shares: 亿股; prices/dividends: 元/股; rates: decimals.
export function calculateDCFCrosscheck(dcfInput, assumptions) {
const dcf = calculateDCF(dcfInput);
if (dcf.perShare <= 0) throw new Error('无正的 DCF 每股股权价值,无法进行双价格验证');
if (!Number.isFinite(assumptions.price) || assumptions.price <= 0) throw new Error('请在股价区填写大于 0 的当前股价');
if (!['manual', 'estimate'].includes(assumptions.profitMode)) throw new Error('请选择归母净利润来源');
let profit;
if (assumptions.profitMode === 'estimate') {
if (dcfInput.basis !== 'fcfe') throw new Error('FCFF 口径不能直接估算归母净利润,请手动填写');
if (!Number.isFinite(assumptions.conversion) || assumptions.conversion <= 0) throw new Error('基期现金转化率须大于 0');
profit = dcfInput.fcf / assumptions.conversion;
} else profit = assumptions.profit;
if (!Number.isFinite(profit) || profit <= 0) throw new Error('基期归母净利润须大于 0');
for (const pe of [assumptions.peLow, assumptions.pe, assumptions.peHigh]) {
if (!Number.isFinite(pe) || pe <= 0) throw new Error('悲观、基准和乐观退出 PE 均须大于 0');
}
if (assumptions.peLow > assumptions.pe || assumptions.pe > assumptions.peHigh) throw new Error('退出 PE 请按悲观 ≤ 基准 ≤ 乐观填写');
const model = {base_year: dcfInput.year, base_profit_100m: profit, shares_100m: dcfInput.shares,
years: assumptions.years, growth_rate: assumptions.profitGrowth, target_return: assumptions.target,
annual_dividend_per_share: assumptions.dividend, dividend_growth_rate: assumptions.dividendGrowth,
annual_dilution_rate: assumptions.dilution,
// Use the standalone calculator's safety-margin convention for both price and growth.
// Actual IRR still uses the actual buy price, without a synthetic price adjustment.
safety_margin: dcfInput.margin};
const scenario = (price, exitPe) => {
const result = calculateReturn({...model, price, exit_pe: exitPe});
return {pe: exitPe, irr: result.holding_period_irr,
requiredGrowth: result.required_growth_with_safety_margin.required_growth_rate,
requiredGrowthWithoutMargin: result.required_growth.required_growth_rate,
maxPrice: result.max_purchase_price_at_target_return,
maxPriceWithMargin: result.max_purchase_price_with_safety_margin,
meetsSafetyMargin: result.meets_safety_margin,
meetsTarget: result.holding_period_irr + 1e-10 >= assumptions.target};
};
const rows = [
{name: '当前股价买入', price: assumptions.price},
{name: 'DCF 参考价买入', price: dcf.entryPrice},
].map(row => ({...row, scenarios: [assumptions.peLow, assumptions.pe, assumptions.peHigh].map(pe => scenario(row.price, pe))}));
rows.forEach(row => { row.base = row.scenarios[1]; });
const returnCeiling = rows[0].base.maxPriceWithMargin;
const jointCeiling = Math.min(dcf.entryPrice, returnCeiling);
const dcfCondition = assumptions.price <= dcf.entryPrice + 1e-10;
const returnCondition = rows[0].base.meetsSafetyMargin;
return {profit, estimated: assumptions.profitMode === 'estimate', dcf, rows, returnCeiling, jointCeiling,
dcfCondition, returnCondition, dcfDiscount: 1 - assumptions.price / dcf.perShare,
growthGap: rows[0].base.requiredGrowth === null ? null : assumptions.profitGrowth - rows[0].base.requiredGrowth};
}
+69
View File
@@ -0,0 +1,69 @@
<section class="df-crosscheck" aria-label="目标收益交叉验证">
<div class="df-column-heading"><span>02</span><div><h3>目标收益交叉验证</h3><p>以当前价格买入,需要怎样的盈利增长</p></div><b>IRR</b></div>
<div class="df-cross-shared df-muted" data-cross-shared>共用当前公司的股价、股本、基期及 DCF 参考价</div>
<details class="df-cross-inputs" open><summary>利润与收益假设 <span class="df-muted">独立于 DCF 现金流增速</span></summary><div class="df-cross-grid">
<div>
<label>基期归母净利润来源
<select data-field="crossProfitMode"><option value="manual">手动填写归母净利润</option><option value="estimate">共用 FCFE + 现金转化率估算</option></select>
</label>
<div class="df-cross-bridge" data-cross-bridge hidden>
<div class="df-muted">已关联基础参数:<span data-cross-fcf>—</span> 亿元 FCFE</div>
<label>基期现金转化率(FCFE ÷ 归母净利润,%)<input data-field="crossConversion" type="number" min="0" step="any" placeholder="填写基期转化率假设"></label>
<div class="df-cross-derived"><span>基期归母净利润(估算)</span><b data-cross-derived>—</b></div>
<div class="df-muted">基期转化率为用户假设;100% 表示假设基期 FCFE 等于归母利润。</div>
</div>
<label class="df-cross-profit" data-cross-manual>基期归母净利润(亿元)<input data-field="crossProfit" type="number" min="0" step="any" placeholder="填写归母净利润"></label>
<div class="df-muted df-cross-source-note" data-cross-source-note></div>
<label class="df-cross-growth">预计归母净利润年增长率(%)<input data-field="crossProfitGrowth" type="number" min="-99.99" max="1000" step="any" placeholder="独立填写利润增速假设"></label>
</div>
<div>
<div class="df-fields">
<label>目标年化收益率(%)<input data-field="crossTarget" type="number" min="-99.99" max="1000" step="any"></label>
<label>持有年限<input data-field="crossYears" type="number" min="1" max="30" step="1"></label>
</div>
<div class="df-cross-pe">
<label>悲观退出 PE(倍)<input data-field="crossPeLow" type="number" min="0" step="any"></label>
<label>基准退出 PE(倍)<input data-field="crossPe" type="number" min="0" step="any"></label>
<label>乐观退出 PE(倍)<input data-field="crossPeHigh" type="number" min="0" step="any"></label>
</div>
<div class="df-muted df-cross-pe-note">退出 PE 为情景假设,不是未来市场定价承诺。目标收益率不会改写 DCF 折现率。</div>
</div>
</div>
<details class="df-cross-advanced" data-cross-advanced>
<summary>分红与股本变化 <span class="df-muted" data-cross-advanced-caption></span></summary>
<div class="df-fields">
<label>首年每股现金股息(元)<input data-field="crossDividend" type="number" min="0" step="any"></label>
<label>每股股息年增长率(%)<input data-field="crossDividendGrowth" type="number" min="-99.99" max="1000" step="any"></label>
<label>股本年变化率(%)<input data-field="crossDilution" type="number" min="-99.99" max="1000" step="any"></label>
</div>
<div class="df-muted df-cross-pe-note">每年末收到股息,末年同时收到股息与退出款;股息路径独立于利润,不计再投资与税费。DCF 按固定股本计算,本区股本变化仅用于收益验证。</div>
</details>
</details>
<div class="df-error" data-cross-error role="alert" hidden></div>
<div class="df-cross-results" data-cross-results aria-live="polite" hidden>
<section class="df-cross-joint" aria-label="共同参考买入上限">
<div class="df-cross-joint-grid">
<div><span>DCF 参考价</span><b data-cross-joint-dcf></b></div>
<div><span>目标收益参考价(含边际)</span><b data-cross-joint-return></b><small class="df-muted" data-cross-return-base></small></div>
<div><span>共同参考买入上限</span><b data-cross-joint-price></b></div>
</div>
<div class="df-muted" data-cross-joint-note></div>
</section>
<section class="df-scenarios" aria-label="现价买入的三种退出情景"><div class="df-section-head"><h3>三种退出情景</h3><span class="df-muted">现价买入 · 预计年化 IRR</span></div><div class="df-scenario-grid" data-cross-cards></div></section>
<section class="df-cross-sensitivity" aria-label="退出 PE 情景验证">
<div class="df-section-head"><h3>退出 PE 情景验证</h3><span class="df-muted">表中为预计年化 IRR</span></div>
<div class="df-table-wrap"><table aria-label="退出 PE 敏感性"><thead><tr><th>买入情景</th><th data-cross-head-low></th><th data-cross-head-base></th><th data-cross-head-high></th></tr></thead><tbody data-cross-scenario-rows></tbody></table></div>
</section>
<details class="df-cross-prices"><summary>展开双价格验证与条件拆解</summary>
<div class="df-section-head"><h3>两种买入价格,分别验证</h3><span class="df-muted" data-cross-caption></span></div>
<div class="df-table-wrap"><table aria-label="双买入价格交叉验证"><thead><tr><th>买入情景</th><th>买入价(元)</th><th>所需利润年增速</th><th>预计年化 IRR</th><th>目标回报条件</th></tr></thead><tbody data-cross-price-rows></tbody></table></div>
<div class="df-muted df-cross-table-note" data-cross-growth-note></div>
<div class="df-cross-checks">
<div><span class="df-muted">现金流估值条件 · 按现价</span><strong data-cross-dcf-condition></strong><p class="df-muted" data-cross-dcf-gap></p></div>
<div><span class="df-muted">盈利与安全边际门槛 · 基准 PE · 按现价</span><strong data-cross-growth-condition></strong><p class="df-muted" data-cross-growth-gap></p></div>
</div>
<div class="df-cross-verdict" data-cross-verdict></div>
</details>
</div>
</section>
+130
View File
@@ -0,0 +1,130 @@
import { calculateDCFCrosscheck } from './dcf-crosscheck-math.js';
const fmt = value => new Intl.NumberFormat('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(value);
const pct = value => `${fmt(value * 100)}%`;
const cell = (text, ok) => {
const td = document.createElement('td');
if (ok === undefined) td.textContent = text;
else {
const span = document.createElement('span');
span.className = 'df-cross-tag';
span.dataset.ok = String(ok);
span.textContent = text;
td.append(span);
}
return td;
};
export function createDCFCrosscheck(root, fields, onResult = () => {}) {
const q = name => root.querySelector(`[data-cross-${name}]`);
const number = (name, label) => {
if (!fields[name].value.trim()) throw new Error(`请填写${label}`);
const value = Number(fields[name].value);
if (!Number.isFinite(value)) throw new Error(`${label}须为有效数字`);
return value;
};
function reset(message) {
q('results').hidden = true;
q('error').textContent = message;
q('error').hidden = false;
q('derived').textContent = '—';
root.dataset.status = 'invalid';
onResult(undefined);
}
function update(dcfInput) {
const fcff = fields.basis.value !== 'fcfe';
fields.crossProfitMode.querySelector('[value="estimate"]').disabled = fcff;
if (fcff && fields.crossProfitMode.value === 'estimate') fields.crossProfitMode.value = 'manual';
const estimated = fields.crossProfitMode.value === 'estimate';
q('bridge').hidden = !estimated;
q('manual').hidden = estimated;
q('source-note').textContent = fcff ? 'FCFF 包含债权人现金流,归母净利润需独立输入。'
: estimated ? '采用基期转化率假设估算利润,非独立财报验证。' : '归母净利润独立输入,不随基期现金流自动变动。';
q('fcf').textContent = dcfInput ? fmt(dcfInput.fcf) : '—';
if (!dcfInput) {
q('shared').textContent = '共用当前公司的股价、股本和基期,请先补全有效的 DCF 参数。';
reset('完成 DCF 参数后,可继续填写利润与目标收益假设进行验证。');
return;
}
q('shared').textContent = `已共用当前公司 · ${dcfInput.year} 年基期 · ${fmt(dcfInput.shares)} 亿股 · 人民币口径`;
try {
const assumptions = {
price: number('marketPrice', '当前股价(上方股价区)'),
profitMode: fields.crossProfitMode.value,
profit: estimated ? undefined : number('crossProfit', '基期归母净利润'),
conversion: estimated ? number('crossConversion', '基期现金转化率') / 100 : undefined,
profitGrowth: number('crossProfitGrowth', '预计利润年增长率') / 100,
target: number('crossTarget', '目标年化收益率') / 100,
years: number('crossYears', '持有年限'),
peLow: number('crossPeLow', '悲观退出 PE'), pe: number('crossPe', '基准退出 PE'), peHigh: number('crossPeHigh', '乐观退出 PE'),
dividend: number('crossDividend', '首年股息'),
dividendGrowth: number('crossDividendGrowth', '股息增长率') / 100,
dilution: number('crossDilution', '股本年变化率') / 100,
};
const result = calculateDCFCrosscheck(dcfInput, assumptions);
const current = result.rows[0].base;
const growthText = growth => growth === null ? '无需最低增速' : pct(growth);
q('derived').textContent = `${fmt(result.profit)} 亿元`;
q('caption').textContent = `基准退出 PE ${fmt(assumptions.pe)} 倍 · 持有 ${assumptions.years} 年`;
q('advanced-caption').textContent = ` · 首年股息 ${fmt(assumptions.dividend)} 元 · 股本年变化 ${pct(assumptions.dilution)}`;
q('price-rows').replaceChildren(...result.rows.map(row => {
const tr = document.createElement('tr');
tr.append(cell(row.name), cell(fmt(row.price)), cell(`${growthText(row.base.requiredGrowth)}(含边际);无边际 ${growthText(row.base.requiredGrowthWithoutMargin)}`),
cell(pct(row.base.irr)), cell(row.base.meetsTarget ? '达到目标' : '未达目标', row.base.meetsTarget));
return tr;
}));
q('dcf-condition').textContent = result.dcfCondition ? '现价满足 DCF 安全边际要求' : '现价未满足 DCF 安全边际要求';
q('dcf-gap').textContent = `相对 DCF 每股价值${result.dcfDiscount >= 0 ? '折价' : '溢价'} ${pct(Math.abs(result.dcfDiscount))}`;
q('growth-condition').textContent = current.requiredGrowth === null ? '股息现值已覆盖含边际要求'
: `预计利润年增 ${pct(assumptions.profitGrowth)},含边际门槛 ${pct(current.requiredGrowth)}`;
q('growth-gap').textContent = result.growthGap === null ? '当前分红假设下,无最低退出盈利要求。'
: `${result.growthGap >= 0 ? '高于' : '低于'}门槛 ${fmt(Math.abs(result.growthGap) * 100)} 个百分点`;
q('verdict').textContent = result.dcfCondition
? result.returnCondition ? '当前两套模型条件均满足;结果仍取决于现金流、利润和退出 PE 假设。'
: 'DCF 有安全边际,但当前利润增长与退出 PE 假设尚未同时满足目标回报和设定的安全边际。'
: result.returnCondition ? '收益情景满足目标回报和设定的安全边际,但现价尚未满足 DCF 安全边际要求,需复核两边假设。'
: '现价尚未同时满足 DCF 与目标收益模型的安全边际条件。';
q('joint-dcf').textContent = fmt(result.dcf.entryPrice);
q('joint-return').textContent = fmt(result.returnCeiling);
q('return-base').textContent = `折扣前 ${fmt(current.maxPrice)} 元/股`;
q('joint-price').textContent = fmt(result.jointCeiling);
q('joint-note').textContent = `两套参考价各应用一次 ${pct(dcfInput.margin)} 安全边际后取较低值;单位:元/股。`;
q('growth-note').textContent = `所需增速计入 ${pct(dcfInput.margin)} 安全边际,与“收益反推”的主结果口径相同,同时列示无边际对照。预计 IRR 按实际买入价、股息与退出款计算,不因安全边际设置而改变。`;
for (const [name, value] of [['low', assumptions.peLow], ['base', assumptions.pe], ['high', assumptions.peHigh]]) {
q(`head-${name}`).textContent = `${{low: '悲观', base: '基准', high: '乐观'}[name]} · ${fmt(value)} 倍`;
}
q('cards').replaceChildren(...currentScenarios());
function currentScenarios() {
return result.rows[0].scenarios.map((scenario, index) => {
const card = document.createElement('article');
card.className = 'df-scenario-card';
card.dataset.base = String(index === 1);
card.dataset.ok = String(scenario.meetsTarget);
const caption = document.createElement('span');
caption.textContent = `${['悲观', '基准', '乐观'][index]} · PE ${fmt(scenario.pe)}`;
const value = document.createElement('strong');
value.textContent = pct(scenario.irr);
const growth = document.createElement('small');
growth.textContent = `含边际所需年增 ${growthText(scenario.requiredGrowth)};无边际 ${growthText(scenario.requiredGrowthWithoutMargin)}`;
const status = document.createElement('span');
status.className = 'df-scenario-status';
status.textContent = scenario.meetsTarget ? '✓ 达到目标收益' : '未达目标收益';
card.append(caption, value, growth, status);
return card;
});
}
q('scenario-rows').replaceChildren(...result.rows.map(row => {
const tr = document.createElement('tr');
tr.append(cell(row.name), ...row.scenarios.map(scenario => cell(pct(scenario.irr))));
return tr;
}));
q('results').hidden = false;
q('error').hidden = true;
root.dataset.status = 'ready';
onResult({result, assumptions});
} catch (error) {
reset(error.message);
}
}
return {update};
}
+49
View File
@@ -0,0 +1,49 @@
<dialog class="df-help-dialog" aria-labelledby="dcf-help-title" aria-describedby="dcf-help-intro">
<header class="df-help-head">
<div><h2 id="dcf-help-title">估值概念说明</h2><p id="dcf-help-intro" class="df-muted">理解现金流口径,再选择适合数据的估值方式。</p></div>
<button class="df-help-close" type="button" data-help-close aria-label="关闭概念说明" autofocus>×</button>
</header>
<nav class="df-help-nav" aria-label="概念目录">
<button type="button" data-help-jump="fcff">FCFF 企业现金流</button>
<button type="button" data-help-jump="fcfe">FCFE 股东现金流</button>
<button type="button" data-help-jump="comparison">区别与选择</button>
<button type="button" data-help-jump="profit">与净利润的关系</button>
</nav>
<div class="df-help-body">
<p class="df-help-lead">FCFF 看企业能为<strong>股东和债权人整体</strong>创造多少现金;FCFE 看处理完债务融资后,能留给<strong>股东</strong>多少现金。</p>
<section data-help-section="fcff" tabindex="-1">
<h3>FCFF · 企业自由现金流</h3>
<p>公司支付经营支出、税费,并满足必要的再投资后,可以供股东和债权人分配的现金。它尚未扣除向债权人支付的利息及本金,也不包含新借款流入。</p>
<p>可以把公司想成一家你和银行共同出资的工厂:FCFF 衡量工厂业务本身创造现金的能力。</p>
<div class="df-help-formula">FCFF = 税后经营利润 + 折旧摊销等非现金费用 − 资本开支 − 经营营运资金增加</div>
<p><strong>本页如何使用:</strong>按 WACC(股东与债权人的综合资金成本)折现,先得到企业经营价值,再扣净债务、加其他非经营资产净额、扣少数股东权益,换算为每股价值。超额现金若已在净债务中扣除,不应重复加回。</p>
</section>
<section data-help-section="fcfe" tabindex="-1">
<h3>FCFE · 股权自由现金流</h3>
<p>满足经营、税费及再投资需求,并考虑利息、偿还借款和新增借款后,可供普通股股东分配的现金。它代表分配能力,实际分红可能低于这个金额。</p>
<div class="df-help-formula">FCFE = FCFF − 利息 ×(1 − 所得税率)+ 新增借款 − 偿还借款本金</div>
<p><strong>假设例子:</strong>FCFF 为 100 万元,利息为 10 万元,所得税率 25%,偿还本金 20 万元,新借款 10 万元。则 FCFE = 100 − 7.5 − 20 + 10 = <strong>82.5 万元</strong>。</p>
<p><strong>本页如何使用:</strong>按股权资本成本(股东要求的回报率)折现,直接得到现金流对应的股权价值。债务融资影响已在现金流预测中考虑,本页不再单独扣减净债务;其他未纳入现金流的资产需另行核对。</p>
</section>
<section data-help-section="comparison" tabindex="-1">
<h3>两种口径怎么选?</h3>
<div class="df-help-table"><table aria-label="FCFF 与 FCFE 对比"><thead><tr><th>对比</th><th>FCFF</th><th>FCFE</th></tr></thead><tbody>
<tr><th scope="row">现金属于谁</th><td>股东与债权人整体</td><td>普通股股东</td></tr>
<tr><th scope="row">折现率</th><td>WACC</td><td>股权资本成本</td></tr>
<tr><th scope="row">折现结果</th><td>企业经营价值</td><td>现金流股权价值</td></tr>
<tr><th scope="row">本页净债务处理</th><td>从经营价值中扣减</td><td>不再单独扣减</td></tr>
</tbody></table></div>
<p>根据手上数据的实际定义选择。切换下拉选项不会自动把 FCFF 转换为 FCFE,也不会自动换算折现率;现金流金额、预测增长率与折现率都要使用匹配的假设。</p>
<p>财报中的“经营活动现金流净额”还没有扣除资本开支,不能直接当作自由现金流。“经营现金流减资本开支”也需核对利息分类与借款处理,才能确定其口径。</p>
</section>
<section data-help-section="profit" tabindex="-1">
<h3>为什么还要输入归母净利润?</h3>
<p>DCF 使用自由现金流;目标收益交叉验证通过利润、股本和退出 PE 推算回报。利润和现金流会因折旧、资本开支、应收账款与存货占款等产生差异。</p>
<div class="df-help-formula">现金转化率 = FCFE ÷ 归母净利润<br>估算归母净利润 = FCFE ÷ 现金转化率</div>
<p>例如 FCFE 为 80 亿元、现金转化率假设为 80%,估算利润为 100 亿元。该比例是用户假设;填 100% 表示假设两者相等。</p>
<p><strong>本页的选择限制:</strong>“共用 FCFE + 现金转化率估算”仅在基础参数选择 FCFE 时启用。FCFF 模式需手动输入归母净利润。使用独立财报利润更有利于交叉验证;共用现金流估算的利润会依赖同一套假设。</p>
<p><strong>与“收益反推”对照:</strong>目标收益参考价和所需利润增速均计入本页设置的安全边际。两页对照时,需同时保持买入价、利润、股本、持有年限、退出 PE、目标收益率、安全边际、分红与股本变化假设一致。预计 IRR 始终按实际买入价计算。</p>
</section>
<p class="df-help-source">参考:<a href="https://www.cfainstitute.org/insights/professional-learning/refresher-readings/2026/free-cash-flow-valuation" target="_blank" rel="noopener noreferrer">CFA Institute · Free Cash Flow Valuation(英文)</a></p>
</div>
</dialog>
+59
View File
@@ -0,0 +1,59 @@
import template from './dcf-help.html?raw';
// Keep the documentation separate from calculator inputs and per-company storage.
// Add future concepts as a section and a matching directory button in the template.
export function createDCFHelp(root) {
root.insertAdjacentHTML('beforeend', template);
const dialog = root.querySelector('.df-help-dialog');
const trigger = root.querySelector('[data-help-open]');
const body = dialog.querySelector('.df-help-body');
let previousOverflow;
const unlock = () => {
if (previousOverflow === undefined) return;
document.documentElement.style.overflow = previousOverflow;
previousOverflow = undefined;
};
trigger.addEventListener('click', () => {
if (dialog.open) return;
dialog.showModal();
previousOverflow = document.documentElement.style.overflow;
document.documentElement.style.overflow = 'hidden';
body.scrollTop = 0;
});
dialog.querySelector('[data-help-close]').addEventListener('click', () => dialog.close());
dialog.addEventListener('close', () => {
unlock();
if (trigger.isConnected) trigger.focus({preventScroll: true});
});
dialog.addEventListener('keydown', event => {
if (event.key !== 'Tab') return;
const items = [...dialog.querySelectorAll('button:not(:disabled), a[href]')];
const destination = event.shiftKey && document.activeElement === items[0] ? items.at(-1)
: !event.shiftKey && document.activeElement === items.at(-1) ? items[0] : undefined;
if (destination) {
event.preventDefault();
destination.focus();
}
});
let backdropPointer = false;
const outside = event => {
const rect = dialog.getBoundingClientRect();
return event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom;
};
dialog.addEventListener('pointerdown', event => { backdropPointer = event.target === dialog && outside(event); });
dialog.addEventListener('click', event => {
if (backdropPointer && event.target === dialog && outside(event)) dialog.close();
backdropPointer = false;
});
dialog.querySelectorAll('[data-help-jump]').forEach(button => button.addEventListener('click', () => {
const section = dialog.querySelector(`[data-help-section="${button.dataset.helpJump}"]`);
body.scrollTop = section.getBoundingClientRect().top - body.getBoundingClientRect().top + body.scrollTop;
section.focus({preventScroll: true});
}));
return () => {
if (dialog.open) dialog.close();
unlock();
dialog.remove();
};
}
+71
View File
@@ -0,0 +1,71 @@
// Segmented, year-end cash-flow DCF. Rates are decimals; cash amounts are 亿元, shares 亿股.
export function calculateDCF(p) {
const finite = (value, label) => {
if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error(`${label}须为有效数字`);
};
if (!['fcff', 'fcfe'].includes(p.basis)) throw new Error('请选择现金流口径');
for (const [key, label] of Object.entries({fcf: '基期自由现金流', shares: '当前总股本', rate: '折现率', terminal: '永续增长率', margin: '安全边际'})) finite(p[key], label);
if (p.fcf <= 0) throw new Error('本模型要求基期可持续自由现金流大于 0');
if (p.shares <= 0) throw new Error('请填写该公司的当前总股本(亿股),且须大于 0');
if (!Number.isInteger(p.year) || p.year < 1900 || p.year > 2200) throw new Error('基期年份须为 1900—2200 的整数');
if (p.rate <= -1 || p.terminal <= -1 || p.terminal >= p.rate) throw new Error('永续增长率须小于折现率,且两者均须大于 −100%');
if (p.margin < 0 || p.margin >= 1) throw new Error('安全边际须大于等于 0% 且小于 100%');
if (!Array.isArray(p.stages) || p.stages.length < 1 || p.stages.length > 5) throw new Error('请设置 1—5 个增长阶段');
let years = 0;
for (const stage of p.stages) {
if (!Number.isInteger(stage.years) || stage.years < 1) throw new Error('每阶段年数须为正整数');
finite(stage.growth, '阶段增长率');
if (stage.growth <= -1) throw new Error('阶段增长率须大于 −100%');
years += stage.years;
}
if (years > 30) throw new Error('显式预测期合计最多 30 年');
let adjustment = 0;
if (p.basis === 'fcff') {
for (const [key, label] of Object.entries({debt: '净债务', assets: '非经营资产净额', minority: '少数股东权益'})) finite(p[key], label);
adjustment = -p.debt + p.assets - p.minority;
}
let cash = p.fcf, t = 0, forecastPV = 0;
const rows = [];
p.stages.forEach((stage, i) => {
for (let y = 0; y < stage.years; y++) {
t++;
cash *= 1 + stage.growth;
const factor = (1 + p.rate) ** -t;
const presentValue = cash * factor;
forecastPV += presentValue;
rows.push({year: p.year + t, stage: i + 1, growth: stage.growth, cash, factor, presentValue});
}
});
const terminalCash = cash * (1 + p.terminal);
const terminalValue = terminalCash / (p.rate - p.terminal);
const terminalPV = terminalValue / (1 + p.rate) ** years;
const operatingValue = forecastPV + terminalPV;
const equityValue = operatingValue + adjustment;
const perShare = equityValue / p.shares;
const entryPrice = perShare * (1 - p.margin);
const terminalWeight = terminalPV / operatingValue;
const result = {years, rows, terminalCash, terminalValue, forecastPV, terminalPV, operatingValue,
adjustment, equityValue, perShare, entryPrice, terminalWeight};
if (![terminalCash, terminalValue, forecastPV, terminalPV, operatingValue, equityValue, perShare,
entryPrice, terminalWeight, ...rows.flatMap(row => [row.cash, row.factor, row.presentValue])].every(Number.isFinite)) {
throw new Error('参数超出有效计算范围,请缩小参数');
}
return result;
}
// The price bands are user-defined discounts/premiums around this DCF scenario.
export function calculateDCFPriceRange({perShare, margin, premium}) {
if (!Number.isFinite(perShare) || perShare <= 0) throw new Error('无正的每股股权价值,无法划分股价区间');
if (!Number.isFinite(margin) || margin < 0 || margin >= 1) throw new Error('低估折价须大于等于 0% 且小于 100%');
if (!Number.isFinite(premium) || premium < 0 || premium > 10) throw new Error('高估溢价阈值须为 0%—1000%');
const low = perShare * (1 - margin);
const high = perShare * (1 + premium);
if (![low, high].every(Number.isFinite)) throw new Error('区间超出有效数值范围');
return {low, high, perShare};
}
export function classifyDCFPrice(price, range) {
if (!Number.isFinite(price) || price <= 0) throw new Error('当前股价须为大于 0 的有效数字');
const zone = price < range.low ? '低估' : price > range.high ? '高估' : '合理';
return {zone, deviation: price / range.perShare - 1};
}
+36
View File
@@ -0,0 +1,36 @@
const fmt = value => new Intl.NumberFormat('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(value);
/** Pure option builder; the calculator owns chart mounting, resizing and disposal. */
export function buildDCFPriceOption({range, price, colors, width, reducedMotion = false}) {
const {low, high, perShare} = range;
const max = Math.max(high * 1.3, perShare * 1.6, price === undefined ? 0 : price * 1.2);
if (!Number.isFinite(max)) throw new Error('股价超出有效图表范围');
const plotWidth = Math.max(1, width - 40);
const label = (text, amount) => ({show: amount / max * plotWidth > 40, position: 'inside', formatter: text, color: colors.ink, fontSize: 12});
const series = [
{name: `低估:低于 ${fmt(low)} 元`, type: 'bar', stack: 'zone', data: [low], barWidth: 42,
itemStyle: {color: colors.low}, label: label('低估', low)},
{name: `合理:${fmt(low)}—${fmt(high)} 元`, type: 'bar', stack: 'zone', data: [high - low],
itemStyle: {color: colors.fair}, label: label('合理', high - low),
markLine: {silent: true, symbol: ['none', 'none'], lineStyle: {type: 'dashed', color: colors.accent, width: 1.5},
label: {show: true, position: 'end', formatter: `DCF ${fmt(perShare)}`, align: perShare / max < .18 ? 'left' : 'center', color: colors.ink, fontSize: 12},
data: [{xAxis: perShare}]}},
{name: `高估:高于 ${fmt(high)} 元`, type: 'bar', stack: 'zone', data: [max - high],
itemStyle: {color: colors.high}, label: label('高估', max - high)},
];
if (price !== undefined) series.push({name: '当前股价', type: 'scatter', data: [[price, 0]], symbol: 'triangle', symbolSize: 13,
symbolOffset: [0, 31], itemStyle: {color: colors.accent}, z: 6,
label: {show: true, position: price / max < .12 ? 'right' : 'bottom', distance: 5, formatter: fmt(price), color: colors.ink, fontSize: 12}});
return {
animation: !reducedMotion, animationDuration: 0, animationDurationUpdate: 260,
grid: {left: 16, right: 24, top: 45, bottom: 65},
tooltip: {trigger: 'item', confine: true, backgroundColor: colors.paper, borderColor: colors.line,
textStyle: {color: colors.ink, fontSize: 12}, formatter: params => params.seriesType === 'scatter' ? `当前股价:${fmt(price)} 元/股` : params.seriesName},
xAxis: {type: 'value', min: 0, max, splitNumber: width < 400 ? 3 : 5, name: '股价(元/股)', nameLocation: 'middle', nameGap: 42,
nameTextStyle: {color: colors.muted, fontSize: 11}, axisLine: {show: true, lineStyle: {color: colors.line}},
axisTick: {show: false}, splitLine: {show: false},
axisLabel: {color: colors.muted, fontSize: 11, formatter: value => Number(value).toFixed(0), hideOverlap: true}},
yAxis: {type: 'category', data: ['价格区间'], axisLine: {show: false}, axisTick: {show: false}, axisLabel: {show: false}},
series,
};
}
+99
View File
@@ -0,0 +1,99 @@
<div id="stock-return-calculator" class="return-calculator" aria-label="目标收益反推增长率计算器">
<div class="calc-heading">
<h2>目标收益反推增长率</h2>
<div data-company></div>
<div class="text-small text-muted" data-context></div>
</div>
<div class="viz-controls">
<label class="form-label">计算方式
<select class="form-select" data-field="mode">
<option value="reverse">反推所需盈利增速</option>
<option value="forward">计算预期年化回报</option>
<option value="buy">反推最高买入价</option>
</select>
</label>
<label class="form-label">持有年限
<select class="form-select" data-field="years" aria-label="持有年限"></select>
</label>
</div>
<div class="viz-controls">
<label class="form-label"><span data-price-label>买入价格(元)</span>
<input class="form-control" data-field="price" type="number" min="0.01" step="any" required>
</label>
<label class="form-label"><span data-profit-label>基期归母净利润(亿元)</span>
<input class="form-control" data-field="profit" type="number" min="0.01" step="any" required>
</label>
<label class="form-label"><span data-pe-label>退出PE(倍)</span>
<input class="form-control" data-field="pe" type="number" min="0.01" step="any" required>
</label>
<label class="form-label">目标年化收益率(%)
<input class="form-control" data-field="target" type="number" min="-99.99" max="1000" step="any" required>
</label>
<label class="form-label">当前总股本(亿股)
<input class="form-control" data-field="shares" type="number" min="0.01" step="any" required>
</label>
<label class="form-label">安全边际(%,按目标回报价格打折)
<input class="form-control" data-field="safetyMargin" type="number" min="0" max="99.99" step="any" required>
</label>
<label class="form-label" data-growth-field hidden>预计利润年增长率(%)
<input class="form-control" data-field="growth" type="number" min="-99.99" max="1000" step="any" required>
</label>
</div>
<details data-advanced>
<summary>分红、股本、币种与基期</summary>
<div class="calc-advanced">
<div class="viz-controls">
<label class="form-label"><span data-dividend-label>首年每股现金股息(元)</span>
<input class="form-control" data-field="dividend" type="number" min="0" step="any" required>
</label>
<label class="form-label">每股股息年增长率(%)
<input class="form-control" data-field="dividendGrowth" type="number" min="-99.99" max="1000" step="any" required>
</label>
<label class="form-label">股本年变化率(%,负值为净减少)
<input class="form-control" data-field="dilution" type="number" min="-99.99" max="1000" step="any" required>
</label>
<label class="form-label">利润基期年份
<input class="form-control" data-field="baseYear" type="number" min="1900" max="2200" step="1" required>
</label>
<label class="form-label">股价/股息币种
<select class="form-select" data-field="tradeCurrency">
<option value="CNY">人民币 CNY</option><option value="HKD">港元 HKD</option><option value="USD">美元 USD</option><option value="OTHER">其他</option>
</select>
</label>
<label class="form-label">财报利润币种
<select class="form-select" data-field="profitCurrency">
<option value="CNY">人民币 CNY</option><option value="HKD">港元 HKD</option><option value="USD">美元 USD</option><option value="OTHER">其他</option>
</select>
</label>
<label class="form-label"><span data-fx-label>1单位股价币种=财报币种</span>
<input class="form-control" data-field="fx" type="number" min="0.000001" step="any" required>
</label>
</div>
</div>
</details>
<div class="calc-actions"><button type="button" class="export-button" data-reset>恢复示例参数</button><span class="text-small text-muted" data-save-status>参数自动保存在本机浏览器</span></div>
<p class="calc-message text-small text-muted" data-assumptions></p>
<div data-error role="alert" class="text-destructive" hidden></div>
<div data-results class="calc-section" aria-live="polite" aria-atomic="true">
<div class="viz-grid">
<div class="card viz-stat">
<div data-main-label>所需归母利润年复合增长率</div>
<div class="viz-stat-value tabular-nums" data-main-value>—</div>
</div>
<div class="card viz-stat">
<div data-terminal-label>目标年所需归母利润</div>
<div class="viz-stat-value tabular-nums" data-terminal-value>—</div>
</div>
<div class="card viz-stat">
<div data-exit-label>所需退出价</div>
<div class="viz-stat-value tabular-nums" data-exit-value>—</div>
</div>
</div>
<div class="text-small text-muted" data-result-detail></div>
</div>
<div class="calc-notes text-small text-muted">
<p>所需增速是达到目标回报的门槛,不是企业盈利预测。退出 PE 是用户假设,本模型依赖未来市场估值,不等于企业内在价值。</p>
<p>每年末收取股息,末年同时收到股息和退出款;股息按独立增速计算,不自动与利润联动,不计股息再投资和交易税费。股本增加会摊薄每股盈利。</p>
<p>安全边际按股息与退出款的总现值打折,20% 表示打八折。币种切换不会自动换算已填金额,跨币种请填写有来源日期的固定汇率;本工具不联网获取行情或财报。</p>
</div>
</div>
+144
View File
@@ -0,0 +1,144 @@
// UI and formulas adapted from the stock-return-valuation skill (v1.2.0).
import template from './stock-return-calculator.html?raw';
import * as math from './stock-return-math.js';
export const valuationMenu = {
id: 'valuation-tools', name: '估值工具', kind: 'tools', chartCatalog: {},
tabs: [
{id: 'dcf', name: 'DCF 估值', chartRefs: []},
{id: 'stock-return-valuation', name: '收益反推', chartRefs: []},
],
};
const STORAGE_KEY = 'smart-fin-chart:stock-return-valuation:v1';
let memoryState;
const example = {
model: {company: '通用股票估值 · 教学示例', base_year: 2026, years: 3, price: 220,
base_profit_100m: 860, shares_100m: 46.27, exit_pe: 13, target_return: 0.13,
growth_rate: 0.10, safety_margin: 0, annual_dividend_per_share: 0,
dividend_growth_rate: 0, annual_dilution_rate: 0},
view: {mode: 'reverse', fx: 1, trade_currency: 'CNY', profit_currency: 'CNY',
context: '默认参数仅为情景假设,不是实时行情、已披露财报或机构预测。请替换为你的研究数据。'},
};
export function renderStockReturnCalculator(container) {
container.innerHTML = template;
const root = container.querySelector('.return-calculator');
const fields = Object.fromEntries(Array.from(root.querySelectorAll('[data-field]')).map(el => [el.dataset.field, el]));
const q = name => root.querySelector('[data-' + name + ']');
const currencyName = {CNY:'人民币',HKD:'港元',USD:'美元',OTHER:'其他币种'};
const format = n => new Intl.NumberFormat('zh-CN', {maximumFractionDigits:2, minimumFractionDigits:2}).format(n);
const model = example.model, view = example.view, fx = view.fx;
for (let n = 1; n <= 30; n++) fields.years.add(new Option(n + '年', n));
const defaults = {
mode:view.mode, years:model.years, price:model.price / fx, profit:model.base_profit_100m, pe:model.exit_pe,
target:model.target_return * 100, shares:model.shares_100m, growth:model.growth_rate * 100,
safetyMargin:(model.safety_margin || 0) * 100,
dividend:model.annual_dividend_per_share / fx, dividendGrowth:model.dividend_growth_rate * 100,
dilution:model.annual_dilution_rate * 100, baseYear:model.base_year,
tradeCurrency:view.trade_currency, profitCurrency:view.profit_currency, fx:fx
};
q('company').textContent = model.company || '股票估值';
q('context').textContent = view.context || model.data_as_of || '';
function read() {
const raw = {};
for (const [name, field] of Object.entries(fields)) {
if (['mode','tradeCurrency','profitCurrency'].includes(name)) raw[name] = field.value;
else {
if (!field.value.trim()) throw new Error('请填写完整参数');
raw[name] = Number(field.value);
if (!Number.isFinite(raw[name])) throw new Error('参数必须是有效数字');
}
}
if (!['reverse','forward','buy'].includes(raw.mode)) throw new Error('请选择计算方式');
if (!(raw.fx > 0)) throw new Error('汇率必须大于0');
if (raw.tradeCurrency === raw.profitCurrency && Math.abs(raw.fx - 1) > 1e-10) throw new Error('币种相同时,请将汇率设为1');
return raw;
}
function normalize(raw) {
return {company:model.company, base_year:raw.baseYear, years:raw.years, price:raw.price * raw.fx,
base_profit_100m:raw.profit, shares_100m:raw.shares, exit_pe:raw.pe, target_return:raw.target / 100,
growth_rate:raw.growth / 100, safety_margin:raw.safetyMargin / 100, annual_dividend_per_share:raw.dividend * raw.fx,
dividend_growth_rate:raw.dividendGrowth / 100, annual_dilution_rate:raw.dilution / 100};
}
function updateLabels() {
const tc = currencyName[fields.tradeCurrency.value], pc = currencyName[fields.profitCurrency.value];
const targetYear = Number(fields.baseYear.value) + Number(fields.years.value);
q('price-label').textContent = '买入价格(' + tc + ')';
q('profit-label').textContent = fields.baseYear.value + '年归母净利润(亿' + pc + ')';
q('pe-label').textContent = (Number.isFinite(targetYear) ? targetYear + '年' : '') + '退出PE(倍)';
q('dividend-label').textContent = '首年每股现金股息(' + tc + ')';
q('fx-label').textContent = '1' + tc + '=多少' + pc;
q('growth-field').hidden = fields.mode.value === 'reverse';
}
function render() {
updateLabels();
try {
const raw = read(), p = normalize(raw), r = math.calculate(p), rev = r.required_growth_with_safety_margin;
const tc = currencyName[raw.tradeCurrency], pc = currencyName[raw.profitCurrency], endYear = raw.baseYear + raw.years;
const isReverse = raw.mode === 'reverse';
let main, profit, exit;
if (isReverse) {
main = rev.required_growth_rate === null ? '无需最低增速' : format(rev.required_growth_rate * 100) + '%';
profit = rev.required_growth_rate === null ? null : rev.required_terminal_profit_100m;
exit = rev.required_growth_rate === null ? null : rev.required_exit_price / raw.fx;
} else {
main = raw.mode === 'forward' ? format(r.holding_period_irr * 100) + '%' : format(r.max_purchase_price_with_safety_margin / raw.fx) + ' ' + tc;
profit = r.future_years[r.future_years.length-1].profit_100m;
exit = r.exit_price / raw.fx;
}
q('main-label').textContent = isReverse ? '所需归母利润年复合增长率' : raw.mode === 'forward' ? '预期税前年化IRR' : '含安全边际的最高买入价';
q('terminal-label').textContent = endYear + '年' + (isReverse ? '所需' : '预计') + '归母利润';
q('exit-label').textContent = isReverse ? '所需退出价' : '预计退出价';
q('main-value').textContent = main;
q('terminal-value').textContent = profit === null ? '—' : format(profit) + ' 亿' + pc;
q('exit-value').textContent = exit === null ? '—' : format(exit) + ' ' + tc;
const baseline = r.required_growth.required_growth_rate;
q('result-detail').textContent = isReverse ?
(rev.required_growth_rate === null ? rev.message : '已计入安全边际 ' + format(raw.safetyMargin) + '%') +
' · 不加安全边际时:' + (baseline === null ? '无需最低增速' : format(baseline * 100) + '%') :
'折扣前最高买入价 ' + format(r.max_purchase_price_at_target_return / raw.fx) + tc +
' · 折扣后 ' + format(r.max_purchase_price_with_safety_margin / raw.fx) + tc +
' · 当前价格隐含安全边际 ' + format(r.margin_of_safety_at_purchase * 100) + '%' +
' · ' + (r.meets_safety_margin ? '满足' : '未满足') + '设定的 ' + format(raw.safetyMargin) + '%';
if (isReverse && baseline !== null) {
const rows = math.schedule(p, baseline);
const verified = math.irr(p.price, rows.map(row => row.dividend_per_share), rows.at(-1).eps * p.exit_pe);
q('result-detail').textContent += ' · 无边际情景正向 IRR 验证:' + format(verified * 100) + '%';
}
q('assumptions').textContent = (raw.dividend === 0 ? '不计分红' : '首年股息 ' + format(raw.dividend) + tc + ',每股股息年增 ' + format(raw.dividendGrowth) + '%') + ' · 股本年变化 ' + format(raw.dilution) + '% · 税费前 · 汇率固定';
q('error').hidden = true; q('results').hidden = false;
root.dataset.status = 'ready';
} catch (error) {
q('error').textContent = error.message || '参数无效'; q('error').hidden = false; q('results').hidden = true;
root.dataset.status = 'invalid';
}
}
function save() {
// Save raw input, including incomplete edits, so navigation and resize never lose it.
const parameters = Object.fromEntries(Object.entries(fields).map(([key, field]) => [key, field.value]));
const saved = {version: 1, parameters, advanced: q('advanced').open};
memoryState = saved;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
q('save-status').textContent = '参数已自动保存在本机浏览器';
} catch {
q('save-status').textContent = '浏览器存储不可用,参数仅在本次页面中保留';
}
}
let saved = memoryState;
if (!saved) {
try { saved = JSON.parse(localStorage.getItem(STORAGE_KEY)); } catch { /* Storage may be unavailable. */ }
}
const values = {...defaults, ...(saved?.version === 1 ? saved.parameters : {})};
for (const [key, field] of Object.entries(fields)) field.value = values[key];
q('advanced').open = Boolean(saved?.advanced);
render();
root.addEventListener('input', () => { save(); render(); });
root.addEventListener('change', () => { save(); render(); });
q('advanced').addEventListener('toggle', save);
q('reset').addEventListener('click', () => {
for (const [key, field] of Object.entries(fields)) field.value = defaults[key];
save();
render();
});
}
+71
View File
@@ -0,0 +1,71 @@
/* Mirrors scripts/valuation.py. All amounts reaching the engine use one currency. */
function num(x, name, positive) {
if (typeof x !== 'number' || !Number.isFinite(x) || (positive && x <= 0)) throw new Error(name + '必须为' + (positive ? '正数' : '有限数值'));
return x;
}
function validate(p) {
['price', 'base_profit_100m', 'shares_100m', 'exit_pe'].forEach(k => num(p[k], k, true));
if (!Number.isInteger(p.years) || p.years < 1 || p.years > 30) throw new Error('持有年限须为1—30年的整数');
if (!Number.isInteger(p.base_year) || p.base_year < 1900 || p.base_year > 2200) throw new Error('基期年份须为1900—2200的整数');
['growth_rate', 'target_return', 'dividend_growth_rate', 'annual_dilution_rate'].forEach(k => {
const x = num(p[k], k, false);
if (x <= -1 || x > 10) throw new Error('增长率和收益率须大于−100%且不超过1000%');
});
if (num(p.annual_dividend_per_share, '首年股息', false) < 0) throw new Error('首年股息不能为负');
const margin = num(p.safety_margin === undefined ? 0 : p.safety_margin, '安全边际', false);
if (margin < 0 || margin >= 1) throw new Error('安全边际须大于等于0%且小于100%');
}
function schedule(p, growth) {
return Array.from({length: p.years}, (_, i) => {
const t = i + 1, profit = p.base_profit_100m * (1 + growth) ** t;
const shares = p.shares_100m * (1 + p.annual_dilution_rate) ** t;
return {year: p.base_year + t, profit_100m: profit, shares_100m: shares, eps: profit / shares,
dividend_per_share: p.annual_dividend_per_share * (1 + p.dividend_growth_rate) ** i};
});
}
function pv(rate, dividends, exit) {
return dividends.reduce((s, d, i) => s + d / (1 + rate) ** (i + 1), 0) + exit / (1 + rate) ** dividends.length;
}
function irr(price, dividends, exit) {
let low = -.999999999, high = 10;
if (pv(high, dividends, exit) > price) throw new Error('回报率超过模型上限1000%');
for (let i = 0; i < 150; i++) {
const m = (low + high) / 2;
if (pv(m, dividends, exit) > price) low = m; else high = m;
}
return (low + high) / 2;
}
function calculate(p) {
validate(p);
const rows = schedule(p, p.growth_rate), last = rows[rows.length - 1];
const dividends = rows.map(x => x.dividend_per_share), exit = last.eps * p.exit_pe;
const pvDiv = dividends.reduce((s, d, i) => s + d / (1 + p.target_return) ** (i + 1), 0);
function reverseForPrice(price) {
const requiredExit = (price - pvDiv) * (1 + p.target_return) ** p.years;
const requiredProfit = requiredExit * last.shares_100m / p.exit_pe;
return requiredExit <= 0 ? {required_growth_rate: null, message: '股息现值已覆盖所需现值,无最低退出盈利要求。'} : {
required_growth_rate: (requiredProfit / p.base_profit_100m) ** (1 / p.years) - 1,
required_terminal_profit_100m: requiredProfit, required_terminal_eps: requiredProfit / last.shares_100m,
required_exit_price: requiredExit, pv_dividends_per_share: pvDiv
};
}
const margin = p.safety_margin === undefined ? 0 : p.safety_margin;
const reverse = reverseForPrice(p.price), buffered = reverseForPrice(p.price / (1-margin));
const maxPrice = pv(p.target_return, dividends, exit), marginPrice = maxPrice * (1-margin);
const result = {base_eps: p.base_profit_100m / p.shares_100m, base_pe: p.price * p.shares_100m / p.base_profit_100m,
future_years: rows, exit_price: exit, holding_period_irr: irr(p.price, dividends, exit),
required_growth: reverse, required_growth_with_safety_margin: buffered,
max_purchase_price_at_target_return: maxPrice, max_purchase_price_with_safety_margin: marginPrice,
margin_of_safety_at_purchase: 1-p.price/maxPrice, meets_safety_margin: p.price <= marginPrice + 1e-12*maxPrice};
[result.base_pe, result.exit_price, result.holding_period_irr, result.max_purchase_price_at_target_return,
...rows.flatMap(x => [x.profit_100m, x.shares_100m, x.eps, x.dividend_per_share])].forEach(x => {
if (!Number.isFinite(x)) throw new Error('计算超出有效数值范围,请缩小参数');
});
for (const r of [reverse, buffered]) {
if (r.required_growth_rate !== null && ![r.required_growth_rate, r.required_terminal_profit_100m,
r.required_exit_price, r.required_terminal_eps].every(Number.isFinite)) throw new Error('无法反推增长率,请缩小参数');
}
return result;
}
export { calculate, validate, schedule, pv, irr };