feat: 增加 DCF 等估值工具

This commit is contained in:
R524809
2026-10-22 15:43:17 +08:00
parent f646ee6718
commit a46de853a0
22 changed files with 2645 additions and 3 deletions
+89
View File
@@ -0,0 +1,89 @@
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、利润来源及边界校验');
+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import { calculateDCF } from '../js/tools/dcf-math.js';
const base = {basis: 'fcff', 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 near = (actual, expected) => assert.ok(Math.abs(actual - expected) <= 1e-8 * Math.max(1, Math.abs(expected)), `${actual} ≠ ${expected}`);
const r = calculateDCF(base);
assert.equal(r.rows.length, 10);
near(r.rows[0].cash, 112);
near(r.rows[3].cash, 100 * 1.12 ** 3 * 1.08);
near(r.rows.at(-1).cash, 100 * 1.12 ** 3 * 1.08 ** 4 * 1.04 ** 3);
near(r.forecastPV, r.rows.reduce((sum, row) => sum + row.cash / 1.1 ** (row.year - base.year), 0));
near(r.terminalPV, r.rows.at(-1).cash * 1.02 / .08 / 1.1 ** 10);
near(r.equityValue, r.operatingValue - 100);
near(r.perShare, r.equityValue / 10);
near(r.entryPrice, r.perShare * .8);
// Each company's share count changes per-share valuation, never the company's total DCF.
const diluted = calculateDCF({...base, shares: 20});
near(diluted.equityValue, r.equityValue);
near(diluted.perShare, r.perShare / 2);
near(diluted.entryPrice, r.entryPrice / 2);
for (const years of [1, 10, 30]) {
near(calculateDCF({...base, terminal: 0, stages: [{years, growth: 0}]}).operatingValue, 1000);
near(calculateDCF({...base, stages: [{years, growth: .02}]}).operatingValue, 1275);
}
const fcfe = calculateDCF({...base, basis: 'fcfe', debt: NaN, assets: NaN, minority: NaN});
near(fcfe.equityValue, r.operatingValue);
near(fcfe.adjustment, 0);
const adjusted = calculateDCF({...base, debt: -50, assets: 20, minority: 10});
near(adjusted.equityValue, r.operatingValue + 60);
const noEquity = calculateDCF({...base, debt: 1e6});
assert.ok(noEquity.equityValue < 0);
const shrinking = calculateDCF({...base, stages: [{years: 10, growth: -.05}]});
assert.ok(shrinking.rows.at(-1).cash < 100);
for (const patch of [{shares: 0}, {shares: -1}, {shares: NaN}, {fcf: 0}, {fcf: Infinity}, {year: 2026.5},
{basis: 'invalid'}, {terminal: .1}, {terminal: -1}, {rate: -1}, {margin: 1}, {margin: -.1},
{stages: []}, {stages: [{years: 31, growth: .1}]}, {stages: [{years: 1.5, growth: .1}]},
{stages: [{years: 1, growth: -1}]}, {stages: [{years: 1, growth: Infinity}]}, {debt: NaN},
{stages: [{years: 30, growth: 1e300}]}]) assert.throws(() => calculateDCF({...base, ...patch}));
console.log('✓ DCF 分段复利、永续现金流、折现时点、股权调整、公司股本换算及无效输入');
// Price-zone thresholds are inclusive at both ends of the fair band.
const { calculateDCFPriceRange, classifyDCFPrice } = await import('../js/tools/dcf-math.js');
const { buildDCFPriceOption } = await import('../js/tools/dcf-price-chart.js');
const range = calculateDCFPriceRange({perShare: 100, margin: .2, premium: .2});
assert.deepEqual(range, {low: 80, high: 120, perShare: 100});
for (const [price, zone] of [[79.99, '低估'], [80, '合理'], [100, '合理'], [120, '合理'], [120.01, '高估']]) {
assert.equal(classifyDCFPrice(price, range).zone, zone);
}
near(classifyDCFPrice(80, range).deviation, -.2);
const pointRange = calculateDCFPriceRange({perShare: 100, margin: 0, premium: 0});
assert.equal(classifyDCFPrice(100, pointRange).zone, '合理');
for (const patch of [{perShare: 0}, {perShare: -1}, {perShare: NaN}, {margin: -.1}, {margin: 1}, {premium: -.1}, {premium: Infinity}]) {
assert.throws(() => calculateDCFPriceRange({perShare: 100, margin: .2, premium: .2, ...patch}));
}
for (const price of [0, -1, NaN, Infinity]) assert.throws(() => classifyDCFPrice(price, range));
const colors = {ink:'#333', muted:'#777', line:'#eee', paper:'#fff', accent:'#654', low:'#ded', fair:'#eed', high:'#edd'};
const unquoted = buildDCFPriceOption({range, colors, width: 320});
assert.equal(unquoted.series.some(series => series.type === 'scatter'), false);
const quoted = buildDCFPriceOption({range, price: 200, colors, width: 320});
assert.ok(quoted.xAxis.max > 200);
assert.deepEqual(quoted.series.at(-1).data, [[200, 0]]);
near(quoted.series.slice(0, 3).reduce((sum, series) => sum + series.data[0], 0), quoted.xAxis.max);
console.log('✓ DCF 低估/合理/高估边界、折溢价、可选股价及图表标记');
+40
View File
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import { calculate } from '../js/tools/stock-return-math.js';
const base = {price: 220, base_profit_100m: 860, shares_100m: 46.27, exit_pe: 13,
years: 3, base_year: 2026, target_return: .13, growth_rate: .1,
safety_margin: 0, annual_dividend_per_share: 0, dividend_growth_rate: 0, annual_dilution_rate: 0};
const near = (actual, expected, tolerance = 1e-8) => assert.ok(Math.abs(actual - expected) < tolerance, `${actual} ≠ ${expected}`);
const example = calculate(base);
near(example.required_growth.required_growth_rate, .0952, .0001);
near(example.required_growth.required_exit_price, 317.43734);
near(example.required_growth.required_terminal_profit_100m, 317.43734 * 46.27 / 13);
// Reverse/forward cash-flow consistency with dividends, dilution, negative rates and FX-normalized inputs.
for (const patch of [{}, {annual_dividend_per_share: 9.3, dividend_growth_rate: .08},
{annual_dilution_rate: .02}, {annual_dilution_rate: -.03, growth_rate: -.08},
{years: 5, target_return: .15}, {target_return: -.15},
{price: 398 * .85619, annual_dividend_per_share: 5.3 * .85619, base_profit_100m: 2300, shares_100m: 90.93, exit_pe: 15}]) {
for (const safety_margin of [0, .2, .5]) {
const input = {...base, ...patch, safety_margin};
const r = calculate(input);
const required = r.required_growth_with_safety_margin.required_growth_rate;
const forward = calculate({...input, growth_rate: required});
near(forward.max_purchase_price_with_safety_margin, input.price);
near(forward.margin_of_safety_at_purchase, safety_margin);
near(r.max_purchase_price_with_safety_margin, r.max_purchase_price_at_target_return * (1 - safety_margin));
near(calculate({...input, safety_margin: 0}).holding_period_irr, r.holding_period_irr);
const noMargin = calculate({...input, growth_rate: r.required_growth.required_growth_rate});
near(noMargin.holding_period_irr, input.target_return);
}
}
const covered = calculate({...base, annual_dividend_per_share: 100, safety_margin: .5});
assert.equal(covered.required_growth.required_growth_rate, null);
assert.notEqual(covered.required_growth_with_safety_margin.required_growth_rate, null);
assert.equal(calculate({...base, annual_dividend_per_share: 500}).required_growth_with_safety_margin.required_growth_rate, null);
for (const patch of [{price: 0}, {base_profit_100m: -1}, {shares_100m: 0}, {exit_pe: 0},
{years: 0}, {years: 3.2}, {years: 31}, {target_return: -1}, {annual_dilution_rate: -1},
{annual_dividend_per_share: -2}, {safety_margin: -1}, {safety_margin: 1}, {price: Infinity}]) {
assert.throws(() => calculate({...base, ...patch}));
}
console.log('✓ 估值示例、现金流反向/正向验证、安全边际、多币种、分红、摊薄及边界条件');