feat:dcf 计算估值
This commit is contained in:
@@ -1,89 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { calculateDCFCrosscheck } from '../js/tools/dcf-crosscheck-math.js';
|
||||
import { calculate as calculateReturn } from '../js/tools/stock-return-math.js';
|
||||
const dcf = {basis: 'fcfe', fcf: 100, year: 2026, rate: .1, terminal: .02, shares: 10,
|
||||
debt: 100, assets: 0, minority: 0, margin: .2,
|
||||
stages: [{years: 3, growth: .12}, {years: 4, growth: .08}, {years: 3, growth: .04}]};
|
||||
const assumptions = {price: 138, profitMode: 'manual', profit: 125, conversion: .8,
|
||||
profitGrowth: .08, target: .13, years: 5, peLow: 9, pe: 12, peHigh: 15,
|
||||
dividend: 0, dividendGrowth: 0, dilution: 0};
|
||||
const near = (a, b) => assert.ok(Math.abs(a - b) <= 1e-8 * Math.max(1, Math.abs(b)), `${a} ≠ ${b}`);
|
||||
const result = calculateDCFCrosscheck(dcf, assumptions);
|
||||
assert.equal(result.dcf.perShare.toFixed(2), '203.46');
|
||||
assert.equal(result.rows.length, 2);
|
||||
for (const row of result.rows) {
|
||||
for (const scenario of row.scenarios) {
|
||||
const standalone = calculateReturn({base_year: dcf.year, base_profit_100m: assumptions.profit,
|
||||
shares_100m: dcf.shares, price: row.price, years: 5, exit_pe: scenario.pe, growth_rate: .08,
|
||||
target_return: .13, annual_dividend_per_share: 0, dividend_growth_rate: 0, annual_dilution_rate: 0, safety_margin: dcf.margin});
|
||||
near(scenario.irr, standalone.holding_period_irr);
|
||||
near(scenario.requiredGrowth, standalone.required_growth_with_safety_margin.required_growth_rate);
|
||||
near(scenario.requiredGrowthWithoutMargin, standalone.required_growth.required_growth_rate);
|
||||
near(scenario.maxPrice, standalone.max_purchase_price_at_target_return);
|
||||
near(scenario.maxPriceWithMargin, standalone.max_purchase_price_with_safety_margin);
|
||||
}
|
||||
}
|
||||
assert.equal(result.dcfCondition, true);
|
||||
assert.equal(result.returnCondition, false);
|
||||
near(result.returnCeiling, 125 / 10 * 1.08 ** 5 * 12 / 1.13 ** 5 * .8);
|
||||
near(result.jointCeiling, Math.min(result.dcf.entryPrice, result.returnCeiling));
|
||||
// Safety margin changes the growth hurdle and price ceiling, while actual IRR stays unchanged.
|
||||
const noMargin = calculateDCFCrosscheck({...dcf, margin: 0}, assumptions);
|
||||
near(result.returnCeiling, noMargin.returnCeiling * .8);
|
||||
near(result.rows[0].base.irr, noMargin.rows[0].base.irr);
|
||||
near(result.rows[0].base.requiredGrowthWithoutMargin, noMargin.rows[0].base.requiredGrowth);
|
||||
assert.ok(result.rows[0].base.requiredGrowth > noMargin.rows[0].base.requiredGrowth);
|
||||
const estimated = calculateDCFCrosscheck(dcf, {...assumptions, profitMode: 'estimate', profit: NaN});
|
||||
near(estimated.profit, 125);
|
||||
near(estimated.rows[0].base.irr, result.rows[0].base.irr);
|
||||
const changedCash = calculateDCFCrosscheck({...dcf, fcf: 120}, assumptions);
|
||||
near(changedCash.profit, 125);
|
||||
near(changedCash.rows[0].base.irr, result.rows[0].base.irr);
|
||||
assert.notEqual(changedCash.dcf.perShare, result.dcf.perShare);
|
||||
near(calculateDCFCrosscheck({...dcf, fcf: 120}, {...assumptions, profitMode: 'estimate'}).profit, 150);
|
||||
const distribution = calculateDCFCrosscheck(dcf, {...assumptions, dividend: 100});
|
||||
assert.equal(distribution.rows[0].base.requiredGrowth, null);
|
||||
assert.equal(distribution.growthGap, null);
|
||||
assert.equal(calculateDCFCrosscheck(dcf, {...assumptions, profitGrowth: .2}).returnCondition, true);
|
||||
// A scenario can reach the actual IRR target yet fail the requested price safety margin.
|
||||
const targetOnly = calculateDCFCrosscheck(dcf, {...assumptions, price: 110});
|
||||
assert.equal(targetOnly.rows[0].base.meetsTarget, true);
|
||||
assert.equal(targetOnly.returnCondition, false);
|
||||
// End-to-end round trips: DCF forward price -> standalone reverse growth -> DCF price.
|
||||
// Exercise zero/nonzero margins, payouts, dilution, negative growth and long holdings.
|
||||
for (const patch of [{}, {dividend: 2, dividendGrowth: .03, dilution: .02},
|
||||
{profitGrowth: -.08, dilution: -.03, years: 3}, {years: 30}, {target: -.1}]) {
|
||||
for (const margin of [0, .2, .5]) {
|
||||
const a = {...assumptions, ...patch};
|
||||
const p = {...dcf, margin};
|
||||
const cross = calculateDCFCrosscheck(p, a);
|
||||
const input = {base_year: p.year, base_profit_100m: a.profit, shares_100m: p.shares,
|
||||
price: cross.returnCeiling, years: a.years, exit_pe: a.pe, growth_rate: a.profitGrowth,
|
||||
target_return: a.target, annual_dividend_per_share: a.dividend,
|
||||
dividend_growth_rate: a.dividendGrowth, annual_dilution_rate: a.dilution, safety_margin: margin};
|
||||
const reversed = calculateReturn(input);
|
||||
near(reversed.required_growth_with_safety_margin.required_growth_rate, a.profitGrowth);
|
||||
near(reversed.max_purchase_price_with_safety_margin, cross.returnCeiling);
|
||||
const atCeiling = calculateDCFCrosscheck(p, {...a, price: cross.returnCeiling});
|
||||
near(atCeiling.rows[0].base.requiredGrowth, a.profitGrowth);
|
||||
assert.equal(atCeiling.returnCondition, true);
|
||||
const fromReverse = calculateDCFCrosscheck(p, {...a, profitGrowth: reversed.required_growth_with_safety_margin.required_growth_rate});
|
||||
near(fromReverse.returnCeiling, cross.returnCeiling);
|
||||
}
|
||||
}
|
||||
// Dividends may cover the actual price but not the price buffered by the safety margin.
|
||||
const dividendCovered = calculateDCFCrosscheck(dcf, {...assumptions, dividend: 40});
|
||||
assert.equal(dividendCovered.rows[0].base.requiredGrowthWithoutMargin, null);
|
||||
assert.notEqual(dividendCovered.rows[0].base.requiredGrowth, null);
|
||||
const diluted = calculateDCFCrosscheck(dcf, {...assumptions, dilution: .02, dividend: 2, dividendGrowth: .03});
|
||||
assert.ok(diluted.rows.every(row => row.scenarios.every(scenario => Number.isFinite(scenario.irr))));
|
||||
assert.throws(() => calculateDCFCrosscheck({...dcf, basis: 'fcff'}, {...assumptions, profitMode: 'estimate'}));
|
||||
assert.throws(() => calculateDCFCrosscheck({...dcf, basis: 'fcff', debt: 1e9}, assumptions));
|
||||
for (const patch of [{profit: 0}, {profitMode: 'invalid'}, {price: 0}, {profitGrowth: -1}, {years: 0},
|
||||
{years: 3.5}, {target: -1}, {peLow: 15}, {peHigh: 10}, {pe: 0}, {dividend: -1}, {dilution: -1}]) {
|
||||
assert.throws(() => calculateDCFCrosscheck(dcf, {...assumptions, ...patch}));
|
||||
}
|
||||
for (const conversion of [0, -1, Infinity, NaN]) {
|
||||
assert.throws(() => calculateDCFCrosscheck(dcf, {...assumptions, profitMode: 'estimate', conversion}));
|
||||
}
|
||||
console.log('✓ DCF 与收益反推的含边际增速一致、价格/增速往返、实际 IRR、利润来源及边界校验');
|
||||
Reference in New Issue
Block a user