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
+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};
}