65 lines
4.2 KiB
JavaScript
65 lines
4.2 KiB
JavaScript
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 低估/合理/高估边界、折溢价、可选股价及图表标记');
|