feat: 增加 DCF 等估值工具
This commit is contained in:
@@ -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};
|
||||
}
|
||||
Reference in New Issue
Block a user