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