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、利润来源及边界校验');