Sensitivity skeleton: discount rate and exit yield

Package cre.dcf

1 Purpose

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:

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.

2 Baseline configuration and grid design

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.

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.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
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 visualisation is intentionally minimal; it can be elaborated (alternative colour scales, percentage axes, log-NPV) in dedicated analysis notebooks.

5 Theoretical diagnostics: invariants and monotonicities

We now compute a set of diagnostics that compare the numerical behaviour of the model to the expectations of basic DCF theory.

cat("\n=== Theoretical diagnostics: invariants and monotonicities ===\n")
## 
## === 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.

6 Interpretation

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.