Files
smart-fin-chart/js/tools/dcf-crosscheck-math.js
T
2026-10-22 15:43:17 +08:00

51 lines
3.5 KiB
JavaScript

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