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