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