A DCF Is a Numerical Stability Problem
January 23, 2020
A discounted cash flow model can be arithmetically correct and operationally useless. The spreadsheet returns a value to the cent. A 25 basis point change in the cost of capital removes several dollars per share.
The point estimate is only one output. The shape of the valuation surface around it carries the risk information.
The chart shows a conventional five-year DCF across weighted average cost of capital and terminal growth assumptions. Color is enterprise value relative to the base case. The white contour marks cases where the terminal period contributes about 75 percent of enterprise value.
A smooth surface can still be steep. That distinction matters in a stock screener. A valuation engine that ranks 5,000 securities converts small model errors into false precision at scale.
The denominator owns the model
The standard terminal value formula is usually written as:
Here, is the discount rate and is the perpetual growth rate. The denominator is a spread. The model becomes increasingly sensitive as that spread narrows.
Consider an 8 percent discount rate and a 3 percent terminal growth rate. The spread is 5 percentage points. Opposing 25 basis point errors in the two inputs can change the spread by 50 basis points. The denominator has moved by 10 percent before the forecast cash flows have changed.
At a 6 percent discount rate and a 4 percent growth rate, the same input error changes the spread by 25 percent.
This is an ill-conditioned calculation. More digits in the output do not improve the conditioning.
Growth consumes capital
The common formula also hides a financing assumption. Growth requires reinvestment. A terminal free cash flow that grows forever without a reinvestment charge embeds free growth.
For a stable business, the reinvestment rate follows from growth and return on invested capital:
Terminal value can therefore be written as:
This form exposes the economic assumption. Terminal growth creates value only when the return on incremental capital exceeds the cost of capital.
When terminal ROIC equals WACC, the growth effect cancels. More growth requires proportionally more reinvestment and produces no additional value. When terminal ROIC is below WACC, growth destroys value.
A fair valuation engine should store terminal growth, terminal ROIC, and reinvestment rate as a linked state. Treating them as independent fields permits impossible scenarios.
Compute the DCF as a numerical routine
A production implementation needs explicit invariants. It also needs stable primitives. log1p retains precision when rates are close to zero, and fsum avoids avoidable summation error across cash-flow legs.
from dataclasses import dataclass
from math import exp, fsum, log1p
from typing import Sequence
@dataclass(frozen=True)
class DCFResult:
enterprise_value: float
forecast_value: float
terminal_value: float
terminal_share: float
reinvestment_rate: float
def enterprise_value(
forecast_fcf: Sequence[float],
wacc: float,
terminal_growth: float,
terminal_nopat: float,
terminal_roic: float,
) -> DCFResult:
if not forecast_fcf:
raise ValueError("forecast_fcf must contain at least one period")
if wacc <= -1.0:
raise ValueError("wacc must be greater than -100%")
if terminal_roic <= 0.0:
raise ValueError("terminal_roic must be positive")
if terminal_growth >= wacc:
raise ValueError("terminal_growth must be below wacc")
if terminal_growth >= terminal_roic:
raise ValueError(
"terminal_growth implies a reinvestment rate of 100% or more"
)
horizon = len(forecast_fcf)
log_discount = log1p(wacc)
forecast_value = fsum(
cash_flow * exp(-period * log_discount)
for period, cash_flow in enumerate(forecast_fcf, start=1)
)
reinvestment_rate = terminal_growth / terminal_roic
terminal_fcf = terminal_nopat * (1.0 - reinvestment_rate)
terminal_value_at_horizon = terminal_fcf / (wacc - terminal_growth)
terminal_value = terminal_value_at_horizon * exp(
-horizon * log_discount
)
total = fsum((forecast_value, terminal_value))
if total == 0.0:
terminal_share = float("nan")
else:
terminal_share = terminal_value / total
return DCFResult(
enterprise_value=total,
forecast_value=forecast_value,
terminal_value=terminal_value,
terminal_share=terminal_share,
reinvestment_rate=reinvestment_rate,
)
The validation is part of the model. Silently accepting creates a singular or negative terminal value. Silently accepting implies that every dollar of terminal operating profit, or more, must be reinvested.
Both states can occur in an input table. Neither should pass through a valuation service without an explicit policy.
Differentiate the model
Most sensitivity tables use arbitrary increments such as plus or minus 1 percentage point. The better diagnostic is the local derivative.
For the present value of the terminal leg:
The sensitivities are:
These derivatives have direct operational meaning. Multiply either value by an input change in decimal form to obtain the approximate percentage change in the terminal leg.
from dataclasses import dataclass
@dataclass(frozen=True)
class TerminalSensitivity:
dlog_value_d_wacc: float
dlog_value_d_growth: float
def approximate_change(
self,
wacc_basis_points: float = 0.0,
growth_basis_points: float = 0.0,
) -> float:
basis_point = 1e-4
return (
self.dlog_value_d_wacc * wacc_basis_points * basis_point
+ self.dlog_value_d_growth
* growth_basis_points
* basis_point
)
def terminal_sensitivity(
wacc: float,
terminal_growth: float,
terminal_roic: float,
horizon: int,
) -> TerminalSensitivity:
spread = wacc - terminal_growth
if spread <= 0.0:
raise ValueError("wacc must exceed terminal_growth")
if terminal_roic <= terminal_growth:
raise ValueError("terminal_roic must exceed terminal_growth")
if horizon < 1:
raise ValueError("horizon must be positive")
return TerminalSensitivity(
dlog_value_d_wacc=(
-1.0 / spread - horizon / (1.0 + wacc)
),
dlog_value_d_growth=(
1.0 / spread
- 1.0 / (terminal_roic - terminal_growth)
),
)
sensitivity = terminal_sensitivity(
wacc=0.09,
terminal_growth=0.025,
terminal_roic=0.12,
horizon=5,
)
wacc_move = sensitivity.approximate_change(wacc_basis_points=25)
growth_move = sensitivity.approximate_change(growth_basis_points=25)
print(f"25 bp WACC move: {wacc_move:.2%}")
print(f"25 bp growth move: {growth_move:.2%}")
This case produces an approximate 5.0 percent decline in the terminal leg from a 25 basis point increase in WACC. A 25 basis point increase in terminal growth adds about 1.2 percent because the model also increases reinvestment.
The naive terminal formula would report much greater growth sensitivity. It would be measuring an unstated assumption that growth requires no capital.
The derivative should be stored with every valuation result. It is a quality metric, not a presentation option.
Sensitivity grids are diagnostics
A two-dimensional sensitivity table is useful for debugging. It is a poor substitute for a scenario model.
WACC and terminal growth share drivers. Inflation can raise nominal growth and the nominal risk-free rate together. A recession can reduce near-term cash flow, the risk-free rate, and terminal growth while widening credit spreads. Independent rectangular shocks include combinations that have little economic coherence.
Generate scenarios from common factors, then derive valuation inputs.
from dataclasses import dataclass
from typing import Iterable, Iterator, Tuple
@dataclass(frozen=True)
class CapitalMarketState:
inflation: float
real_risk_free_rate: float
equity_risk_premium: float
beta: float
debt_spread: float
debt_weight: float
tax_rate: float
terminal_real_growth: float
@property
def nominal_risk_free_rate(self) -> float:
return (
(1.0 + self.real_risk_free_rate)
* (1.0 + self.inflation)
- 1.0
)
@property
def terminal_growth(self) -> float:
return (
(1.0 + self.terminal_real_growth)
* (1.0 + self.inflation)
- 1.0
)
@property
def wacc(self) -> float:
cost_of_equity = (
self.nominal_risk_free_rate
+ self.beta * self.equity_risk_premium
)
pre_tax_cost_of_debt = (
self.nominal_risk_free_rate + self.debt_spread
)
equity_weight = 1.0 - self.debt_weight
return (
equity_weight * cost_of_equity
+ self.debt_weight
* pre_tax_cost_of_debt
* (1.0 - self.tax_rate)
)
def coherent_states(
base: CapitalMarketState,
inflation_shocks: Iterable[float],
risk_premium_shocks: Iterable[float],
) -> Iterator[Tuple[float, float]]:
for inflation_shock in inflation_shocks:
for risk_premium_shock in risk_premium_shocks:
state = CapitalMarketState(
inflation=base.inflation + inflation_shock,
real_risk_free_rate=base.real_risk_free_rate,
equity_risk_premium=(
base.equity_risk_premium + risk_premium_shock
),
beta=base.beta,
debt_spread=base.debt_spread + 0.35 * risk_premium_shock,
debt_weight=base.debt_weight,
tax_rate=base.tax_rate,
terminal_real_growth=base.terminal_real_growth,
)
yield state.wacc, state.terminal_growth
The 0.35 coefficient is a scenario assumption, not a universal constant. It belongs in versioned model configuration with an effective date, a source, and a calibration record.
The factor approach makes the dependence structure reviewable without claiming forecast accuracy.
A stock screener needs rank stability
A screener eventually converts intrinsic value into a score:
Sorting by that number creates a total order. The model uncertainty rarely supports one.
Suppose Company A has estimated upside between 18 and 34 percent. Company B has estimated upside between 22 and 29 percent. A point estimate can rank either company first. The intervals overlap, so the ordering is unstable.
The correct data structure is a partial order. Company A dominates Company B only when A’s low valuation exceeds B’s high valuation.
from dataclasses import dataclass
from itertools import combinations
from typing import Iterable, Iterator, Tuple
@dataclass(frozen=True)
class ValuationBand:
ticker: str
low: float
central: float
high: float
def stable_dominance_edges(
bands: Iterable[ValuationBand],
) -> Iterator[Tuple[str, str]]:
ordered = tuple(bands)
for left, right in combinations(ordered, 2):
if left.low > right.high:
yield left.ticker, right.ticker
elif right.low > left.high:
yield right.ticker, left.ticker
The resulting edges form a ranking graph. A topological layer contains securities that the model cannot distinguish reliably. Within that layer, liquidity, balance-sheet risk, data quality, or a separate factor model can break ties.
This changes the behavior of a fair valuation engine. It can still expose a central estimate. It also reports whether the estimate is strong enough to support the rank.
Store the fragility with the value
A production valuation record should include at least:
- The central enterprise value and equity value.
- Forecast-period present value and terminal present value.
- Terminal value share.
- WACC and growth derivatives.
- The minimum spread across accepted scenarios.
- The terminal ROIC and implied reinvestment rate.
- A valuation interval from coherent scenarios.
- A rank-stability flag.
- The complete model version and input lineage.
A DCF model is a numerical program with economic constraints. Its failure mode is rarely a division-by-zero exception. The common failure is a plausible number that changes the ranking when an input moves inside its estimation error.
The valuation engine should detect that condition before the stock screener publishes it.