This vignette provides a reproducible skeleton for conducting local comparative statics on DCF outputs by perturbing configuration parameters and re-running run_case() on a grid. The goal is twofold:
Pedagogical – to visualise how valuation metrics respond to small changes in key parameters (e.g., exit yield, discount rate), and to read iso-NPV or iso-IRR loci.
Diagnostic – to verify theoretical regularities:
The code below is intentionally minimal-no helper functions from the package are required-so that it can be adapted to other parameters (e.g., rent growth, capex, LTV) and used as a building block for larger sensitivity notebooks.
In classical discounted-cash-flow theory, the sensitivity of valuation metrics to parameter changes constitutes a local comparative static exercise. It allows identifying how valuation outcomes respond to exogenous shifts in yield or required return, under ceteris paribus assumptions. This procedure, widely used in real-estate investment and corporate finance, reveals whether model behaviour conforms to theoretical expectations of monotonicity, invariance, and convexity.
# 2.1 Load baseline configuration (core preset)
cfg_path <- system.file("extdata", "preset_default.yml", package = "cre.dcf")
stopifnot(nzchar(cfg_path))
cfg0 <- yaml::read_yaml(cfg_path)
# 2.2 Derive baseline exit yield from entry_yield and spread (in bps)
stopifnot(!is.null(cfg0$entry_yield))
spread_bps0 <- cfg0$exit_yield_spread_bps
if (is.null(spread_bps0)) spread_bps0 <- 0L
exit_yield_0 <- cfg0$entry_yield + as.numeric(spread_bps0) / 10000
# 2.3 Derive baseline discount rate as WACC when disc_method == "wacc"
stopifnot(!is.null(cfg0$disc_method))
if (cfg0$disc_method != "wacc") {
stop("This sensitivity skeleton assumes disc_method = 'wacc' in preset_core.yml.")
}
ltv0 <- cfg0$ltv_init
kd0 <- cfg0$rate_annual
scr0 <- if (is.null(cfg0$scr_ratio)) 0 else cfg0$scr_ratio
stopifnot(!is.null(ltv0), !is.null(kd0))
ke0 <- cfg0$disc_rate_wacc$KE
if (is.null(ke0)) {
stop("disc_rate_wacc$KE is missing in preset_core.yml; cannot compute baseline WACC.")
}
disc_rate_0 <- (1 - ltv0) * ke0 + ltv0 * kd0 * (1 - scr0)
# 2.4 Define a local grid around (exit_yield_0, disc_rate_0)
step_bps <- 50L # 50 bps increments
span_bps <- 100L # ±100 bps around baseline
seq_around <- function(x, span_bps = 100L, step_bps = 50L) {
x + seq(-span_bps, span_bps, by = step_bps) / 10000
}
exit_grid <- seq_around(exit_yield_0, span_bps, step_bps)
disc_grid <- seq_around(disc_rate_0, span_bps, step_bps)
param_grid <- expand.grid(
exit_yield = exit_grid,
disc_rate = disc_grid,
KEEP.OUT.ATTRS = FALSE,
stringsAsFactors = FALSE
)
head(param_grid)## exit_yield disc_rate
## 1 0.040 0.0324
## 2 0.045 0.0324
## 3 0.050 0.0324
## 4 0.055 0.0324
## 5 0.060 0.0324
## 6 0.040 0.0374
The grid is deliberately small (here 3×3=9 3×3=9 points) in order to keep the vignette computationally light while illustrating the comparative statics logic.
To vary the discount rate, we treat the target disc_rate as a WACC and invert the WACC formula to recover the corresponding cost of equity KE* K E *
. The entry yield is kept fixed, and the exit yield is adjusted by setting exit_yield_spread_bps.
# 3.1 Helper: invert WACC to obtain KE from target discount rate
# WACC(d) = (1 - LTV)*KE + LTV * KD * (1 - SCR)
wacc_invert_ke <- function(d, ltv, kd, scr) {
num <- d - ltv * kd * (1 - scr)
den <- 1 - ltv
ke <- num / den
if (!is.finite(ke)) stop("Non-finite KE from WACC inversion; check inputs.")
# Soft clamp to [0, 1] with a warning in extreme cases
if (ke < 0 || ke > 1) {
warning(sprintf("Implied KE=%.4f outside [0,1]; clamped.", ke))
}
pmax(0, pmin(1, ke))
}
# 3.2 Helper: apply (exit_yield, disc_rate) to a copy of cfg0
cfg_with_params <- function(cfg_base, e, d) {
cfg_mod <- cfg_base
# 3.2.1 Adjust exit_yield via spread on entry_yield
if (is.null(cfg_mod$entry_yield)) {
stop("entry_yield missing in config; cannot derive exit_yield spread.")
}
spread_bps <- round((e - cfg_mod$entry_yield) * 10000)
cfg_mod$exit_yield_spread_bps <- as.integer(spread_bps)
# 3.2.2 Adjust cost of equity so that WACC equals target d
ltv <- cfg_mod$ltv_init
kd <- cfg_mod$rate_annual
scr <- if (is.null(cfg_mod$scr_ratio)) 0 else cfg_mod$scr_ratio
ke_star <- wacc_invert_ke(d = d, ltv = ltv, kd = kd, scr = scr)
cfg_mod$disc_method <- "wacc"
if (is.null(cfg_mod$disc_rate_wacc) || !is.list(cfg_mod$disc_rate_wacc)) {
cfg_mod$disc_rate_wacc <- list(KE = ke_star, KD = kd, tax_rate = scr)
} else {
cfg_mod$disc_rate_wacc$KE <- ke_star
cfg_mod$disc_rate_wacc$KD <- kd
}
cfg_mod
}
# 3.3 One simulation at (exit_yield, disc_rate)
run_one <- function(e, d) {
cfg_i <- cfg_with_params(cfg0, e = e, d = d)
out <- run_case(cfg_i)
data.frame(
exit_yield = e,
disc_rate = d,
irr_equity = out$leveraged$irr_equity,
npv_equity = out$leveraged$npv_equity,
irr_proj = out$all_equity$irr_project,
npv_proj = out$all_equity$npv_project
)
}
# 3.4 Grid sweep
message("Running DCF grid sweep - number of simulations: ", nrow(param_grid))
res_list <- vector("list", nrow(param_grid))
for (i in seq_len(nrow(param_grid))) {
e <- param_grid$exit_yield[i]
d <- param_grid$disc_rate[i]
res_list[[i]] <- run_one(e, d)
}
res <- dplyr::bind_rows(res_list)
cat("\nSample of computed sensitivity grid (first 9 rows):\n")##
## Sample of computed sensitivity grid (first 9 rows):
## exit_yield disc_rate irr_equity npv_equity irr_proj npv_proj
## 1 0.040 0.0324 0.1376971 1306012.3 0.10597530 1250044.9
## 2 0.040 0.0374 0.1376971 1225298.2 0.10597530 1147867.6
## 3 0.040 0.0424 0.1376971 1146853.8 0.10597530 1048563.7
## 4 0.040 0.0474 0.1376971 1070604.4 0.10597530 952038.3
## 5 0.040 0.0524 0.1376971 996477.7 0.10597530 858200.3
## 6 0.045 0.0324 0.1049111 842424.7 0.08104276 786457.3
## 7 0.045 0.0374 0.1049111 772775.3 0.08104276 695344.7
## 8 0.045 0.0424 0.1049111 705080.2 0.08104276 606790.1
## 9 0.045 0.0474 0.1049111 639275.1 0.08104276 520709.1
##
## Grid coverage (raw values):
## • exit_yield range: [0.0400, 0.0600]
## • disc_rate range: [0.0324, 0.0524]
## • total simulations: 25
If ggplot2 is available, a simple heatmap can be used to visualise the sensitivity of equity NPV across the (disc_rate,exit_yield) (disc_rate,exit_yield) grid.
if (requireNamespace("ggplot2", quietly = TRUE)) {
ggplot2::ggplot(res, ggplot2::aes(x = disc_rate, y = exit_yield, fill = npv_equity)) +
ggplot2::geom_tile() +
ggplot2::geom_contour(ggplot2::aes(z = npv_equity),
bins = 10, alpha = 0.5) +
ggplot2::labs(
title = "Iso-NPV (equity) across (discount rate, exit yield)",
x = "Discount rate (target WACC, decimal)",
y = "Exit yield (decimal)",
fill = "Equity NPV"
)
}This visualisation is intentionally minimal; it can be elaborated (alternative colour scales, percentage axes, log-NPV) in dedicated analysis notebooks.
We now compute a set of diagnostics that compare the numerical behaviour of the model to the expectations of basic DCF theory.
##
## === Theoretical diagnostics: invariants and monotonicities ===
## 5.1 Invariance of IRR with respect to discount rate (within each exit_yield)
## ---------------------------------------------------------------------------
irr_sd_by_exit <- res |>
group_by(exit_yield) |>
summarise(
irr_sd_over_disc = sd(irr_equity, na.rm = TRUE),
.groups = "drop"
)
irr_sd_median <- median(irr_sd_by_exit$irr_sd_over_disc, na.rm = TRUE)
cat(
"\nIRR invariance diagnostics:\n",
sprintf("• Median SD of equity IRR across discount-rate variations (per exit_yield slice): %.3e\n",
irr_sd_median),
" --> Near-zero dispersion indicates that IRR behaves as an internal rate of return,\n",
" independent of the exogenous discount rate used for NPV computation.\n"
)##
## IRR invariance diagnostics:
## • Median SD of equity IRR across discount-rate variations (per exit_yield slice): 0.000e+00
## --> Near-zero dispersion indicates that IRR behaves as an internal rate of return,
## independent of the exogenous discount rate used for NPV computation.
## 5.2 Monotonicity of equity NPV with respect to disc_rate
## --------------------------------------------------------
npv_monotone_disc <- res |>
group_by(exit_yield) |>
arrange(disc_rate, .by_group = TRUE) |>
summarise(
all_non_increasing = all(diff(npv_equity) <= 1e-8),
.groups = "drop"
)
share_monotone_disc <- mean(npv_monotone_disc$all_non_increasing, na.rm = TRUE)
cat(
"\nNPV monotonicity w.r.t discount rate:\n",
sprintf("• Share of exit_yield slices where equity NPV is non-increasing in disc_rate: %.1f%%\n",
100 * share_monotone_disc),
" --> In a standard DCF, higher discount rates should decrease NPV. Deviations from\n",
" strict monotonicity may indicate discrete changes in cash-flow structure or\n",
" numerical tolerances.\n"
)##
## NPV monotonicity w.r.t discount rate:
## • Share of exit_yield slices where equity NPV is non-increasing in disc_rate: 100.0%
## --> In a standard DCF, higher discount rates should decrease NPV. Deviations from
## strict monotonicity may indicate discrete changes in cash-flow structure or
## numerical tolerances.
## 5.3 Monotonicity of equity NPV with respect to exit_yield
## ---------------------------------------------------------
npv_monotone_exit <- res |>
group_by(disc_rate) |>
arrange(exit_yield, .by_group = TRUE) |>
summarise(
all_non_increasing = all(diff(npv_equity) <= 1e-8),
.groups = "drop"
)
share_monotone_exit <- mean(npv_monotone_exit$all_non_increasing, na.rm = TRUE)
cat(
"\nNPV monotonicity w.r.t exit yield:\n",
sprintf("• Share of discount-rate slices where equity NPV is non-increasing in exit_yield: %.1f%%\n",
100 * share_monotone_exit),
" --> Since a higher exit yield reduces terminal value, one expects lower NPV when\n",
" exit_yield increases, all else equal. Non-monotonic patterns typically reflect\n",
" changes in covenant status or other discrete thresholds in the model.\n"
)##
## NPV monotonicity w.r.t exit yield:
## • Share of discount-rate slices where equity NPV is non-increasing in exit_yield: 100.0%
## --> Since a higher exit yield reduces terminal value, one expects lower NPV when
## exit_yield increases, all else equal. Non-monotonic patterns typically reflect
## changes in covenant status or other discrete thresholds in the model.
In this vignette, these diagnostics are reported but not enforced as hard assertions. They are intended as a starting point for model calibration and for more systematic sensitivity studies rather than as unit tests.
The sensitivity skeleton developed here shows how a relatively compact grid over (disc_rate,exit_yield) (disc_rate,exit_yield) can be used to:
verify that the equity IRR behaves as an internal rate of return, largely invariant to the exogenous discount rate,
check that equity NPV decreases as the discount rate or exit yield increases, in line with standard DCF theory,
identify potential non-monotonicities that may stem from structural features of the model (covenants, refinancing triggers, tax effects) rather than from pure discounting.
The same logic extends immediately to other parameters (rent indexation, CAPEX intensity, LTV, DSCR thresholds). By replacing (exit_yield, disc_rate) in the grid construction and in cfg_with_params(), the user can construct a wide range of local comparative-statics experiments rooted in a single, reproducible run_case() grammar.