The hardware and bandwidth for this mirror is donated by METANET, the Webhosting and Full Service-Cloud Provider.
If you wish to report a bug, or if you are interested in having us mirror your free-software or open-source project, please feel free to contact us at mirror[@]metanet.ch.

Sensitivity skeleton: discount rate and exit yield

Package cre.dcf

1 Purpose

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.

2 Baseline configuration and grid design

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.

3 Running the model on the grid

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):
print(dplyr::arrange(res, exit_yield, disc_rate)[1:min(9, nrow(res)), ])
##   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
cat("\nGrid coverage (raw values):\n")
## 
## Grid coverage (raw values):
cat(sprintf("• exit_yield range: [%.4f, %.4f]\n",
min(res$exit_yield), max(res$exit_yield)))
## • exit_yield range: [0.0400, 0.0600]
cat(sprintf("• disc_rate  range: [%.4f, %.4f]\n",
min(res$disc_rate),  max(res$disc_rate)))
## • disc_rate  range: [0.0324, 0.0524]
cat(sprintf("• total simulations: %d\n", nrow(res)))
## • total simulations: 25

4 Optional visualisation: iso-NPV map

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.

5 Simple checks on the grid

We now read a few simple patterns from the grid.

cat("\n=== Grid checks ===\n")
## 
## === 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.

6 Interpretation

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.

These binaries (installable software) and packages are in development.
They may not be fully stable and should be used with caution. We make no claims about them.