This vignette provides a compact template for running sensitivities
on top of run_case().
The code below stays deliberately simple so it can be adapted to other drivers such as rent growth, capex, or LTV.
The grid is kept small so the vignette stays quick to run while still showing the logic of a two-parameter sensitivity.
# 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 5 x 5 = 25 points) so the vignette stays light while still showing the sensitivity 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.1444153 1402150.8 0.11107633 1346183.3
## 2 0.040 0.0374 0.1444153 1319399.3 0.11107633 1241968.8
## 3 0.040 0.0424 0.1444153 1238970.8 0.11107633 1140680.7
## 4 0.040 0.0474 0.1444153 1160788.7 0.11107633 1042222.7
## 5 0.040 0.0524 0.1444153 1084779.4 0.11107633 946502.0
## 6 0.045 0.0324 0.1120116 933927.3 0.08630995 877959.9
## 7 0.045 0.0374 0.1120116 862351.2 0.08630995 784920.6
## 8 0.045 0.0424 0.1120116 792779.5 0.08630995 694489.4
## 9 0.045 0.0474 0.1120116 725146.2 0.08630995 606580.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 plot is intentionally simple. In a deal notebook, you would usually customise the scales and annotations for the question at hand.
We now read a few simple patterns from the grid.
##
## === Grid checks ===
## 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 check:\n",
sprintf("• Median SD of equity IRR across discount-rate variations (per exit_yield slice): %.3e\n",
irr_sd_median),
" --> Near-zero dispersion means IRR is not being driven by the discount rate used for NPV.\n"
)##
## IRR check:
## • Median SD of equity IRR across discount-rate variations (per exit_yield slice): 0.000e+00
## --> Near-zero dispersion means IRR is not being driven by the discount rate used for NPV.
## 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 check vs 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 reduce NPV.\n"
)##
## NPV check vs 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 reduce NPV.
## 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 check vs exit yield:\n",
sprintf("• Share of discount-rate slices where equity NPV is non-increasing in exit_yield: %.1f%%\n",
100 * share_monotone_exit),
" --> A higher exit yield reduces terminal value, so NPV should usually go down.\n"
)##
## NPV check vs exit yield:
## • Share of discount-rate slices where equity NPV is non-increasing in exit_yield: 100.0%
## --> A higher exit yield reduces terminal value, so NPV should usually go down.
These checks are reported for interpretation, not enforced as unit tests.
This same pattern extends easily to other parameters. Replace
exit_yield or disc_rate in the grid and in
cfg_with_params(), then rerun the loop on the new scenario
set.