This vignette uses the preset YAML files shipped in
inst/extdata to compare four commercial real-estate (CRE)
investment styles:
corecore_plusvalue_addedopportunisticAll four presets are processed through the same pipeline with
run_case(). The vignette then extracts a small set of
indicators:
The main goal is to confirm that the presets preserve the expected ordering:
To make the four profiles directly comparable, the vignette begins by constructing a compact “manifest” that records, for each style:
Under the preset calibration, the four styles are expected to satisfy a clear risk-return and leverage-coverage hierarchy:
core --> core_plus --> value_added --> opportunistic;The package also reports ltv_max_fwd, but this metric
should be read differently. It is a conditional stress
indicator computed along the simulated business plan. In
transitional presets, a temporarily depressed forward NOI can create a
sharper early LTV spike than in a shorter opportunistic case, so
ltv_max_fwd is informative without needing to be
monotonic.
# Retrieve manifest
tbl_print <- styles_manifest()
# Ensure expected ordering
tbl_print <- tbl_print |>
dplyr::filter(style %in% c("core", "core_plus", "value_added", "opportunistic")) |>
dplyr::mutate(
style = factor(
style,
levels = c("core", "core_plus", "value_added", "opportunistic")
)
) |>
dplyr::arrange(style) |>
dplyr::select(
style,
irr_project,
irr_equity,
dscr_min_bul,
ltv_max_fwd,
ops_share,
tv_share,
npv_equity
)
# Defensive: stop if table empty (should never happen if helpers/tests are correct)
if (nrow(tbl_print) == 0L) {
stop("No style presets were found. Check inst/extdata and helper logic.")
}
# Render table
knitr::kable(
tbl_print,
digits = c(0, 0, 4, 4, 3, 3, 3, 3, 0),
caption = "Style presets: returns, credit profile, and value composition"
)| style | irr_project | irr_equity | dscr_min_bul | ltv_max_fwd | ops_share | tv_share | npv_equity |
|---|---|---|---|---|---|---|---|
| core | 0 | 0.0620 | 5.2570 | 0.449 | 0.370 | 0.630 | 6353091 |
| core_plus | 0 | 0.0942 | 4.6054 | 0.536 | 0.332 | 0.668 | 3998037 |
| value_added | 0 | 0.1703 | 0.6739 | 3.666 | 0.241 | 0.759 | 3921925 |
| opportunistic | 0 | 0.2065 | 0.3538 | 0.638 | 0.183 | 0.817 | 2585124 |
The next step places the four styles on a simple risk-return chart, with unlevered project IRR on the x-axis and levered equity IRR on the y-axis. The 45-degree line shows where leverage would leave IRR unchanged.
core --> core_plus --> value_added --> opportunistic.These inequalities are enforced both by automated tests and by the geometry of the figure.
tbl_rr <- styles_manifest() |>
dplyr::filter(style %in% c("core", "core_plus", "value_added", "opportunistic")) |>
dplyr::mutate(
style = factor(
style,
levels = c("core", "core_plus", "value_added", "opportunistic")
),
irr_uplift = irr_equity - irr_project
) |>
dplyr::arrange(style)
if (requireNamespace("ggplot2", quietly = TRUE)) {
ggplot2::ggplot(
tbl_rr,
ggplot2::aes(x = irr_project, y = irr_equity, label = style, colour = style)
) +
ggplot2::geom_abline(slope = 1, intercept = 0, linetype = 3) +
ggplot2::geom_point(size = 3) +
ggplot2::geom_text(nudge_y = 0.002, size = 3) +
ggplot2::scale_x_continuous(labels = scales::percent_format(accuracy = 0.1)) +
ggplot2::scale_y_continuous(labels = scales::percent_format(accuracy = 0.1)) +
ggplot2::labs(
title = "Risk–return cloud (project vs equity IRR)",
x = "IRR project (unlevered)",
y = "IRR equity (levered)"
)
}In typical calibrations, core-like styles cluster in the lower-left part of the chart, while non-core styles move to the north-east with a larger leverage uplift.
The second chart focuses on the credit profile of each style from a lender’s standpoint. For each preset, under the bullet-debt scenario, it considers:
ltv_init,
x-axis), anddscr_min_bul, y-axis).The initial LTV reflects a structural leverage choice at signing, before any business-plan uncertainty has materialised. It measures how much debt is carried relative to the acquisition price (plus costs). By contrast, the minimum DSCR captures the deepest coverage trough induced by the business plan, that is, the weakest ratio of NOI to debt service once vacancy and capex have bitten into rents while interest remains due.
For completeness, the manifest also tracks the maximum forward LTV
(ltv_max_fwd), which summarises the worst ratio of
outstanding debt to revalued asset value under the simulated plan. This
is a conditional balance-sheet indicator, after value creation
and repricing have played out. Unlike initial LTV, it is not meant to
form a rigid monotone ranking across styles, because lease-up or ramp-up
years can temporarily depress forward value.
tbl_cov <- styles_manifest() |>
dplyr::filter(style %in% c("core", "core_plus", "value_added", "opportunistic")) |>
dplyr::mutate(
style = factor(
style,
levels = c("core", "core_plus", "value_added", "opportunistic")
)
) |>
dplyr::arrange(style) |>
dplyr::select(
style,
irr_project,
irr_equity,
dscr_min_bul,
ltv_init, # structural leverage at origination
ltv_max_fwd, # worst forward LTV under the business plan
npv_equity
)
if (requireNamespace("ggplot2", quietly = TRUE)) {
ggplot2::ggplot(
tbl_cov,
ggplot2::aes(
x = ltv_init,
y = dscr_min_bul,
label = style,
colour = style
)
) +
ggplot2::geom_hline(yintercept = 1.2, linetype = 3) + # illustrative DSCR guardrail
ggplot2::geom_vline(xintercept = 0.65, linetype = 3) + # illustrative initial-LTV guardrail
ggplot2::geom_point(size = 3) +
ggplot2::geom_text(nudge_y = 0.05, size = 3) +
ggplot2::scale_x_continuous(labels = scales::percent_format(accuracy = 0.1)) +
ggplot2::labs(
title = "Leverage–coverage map",
x = "Initial LTV (bullet)",
y = "Min DSCR (bullet)"
)
}The dashed lines illustrate generic covenant guardrails (DSCR ≈ 1.20, initial LTV ≈ 65 %). In the preset scenarios:
core sits comfortably in the quadrant of low LTV and
high DSCR;core_plus moves closer to the guardrails but remains
covenant-friendly;value_added and opportunistic migrate
towards higher LTV and lower DSCR, where covenant breaches become
plausible if the business plan underperforms.Beyond static summaries, one often wishes to know how frequently a given style approaches or breaches covenant thresholds over the life of the loan. The next block explores this dimension, again under the bullet-debt scenario for comparability.
To keep the table discriminating after the recalibration, the counting exercise uses slightly tighter guardrails than the illustrative lines shown on the previous chart.
guard <- list(min_dscr = 1.50, max_ltv = 0.60)
breach_tbl <- styles_breach_counts(
styles = c("core", "core_plus", "value_added", "opportunistic"),
min_dscr_guard = guard$min_dscr,
max_ltv_guard = guard$max_ltv
)
knitr::kable(
breach_tbl,
caption = "Covenant-breach counts by style (bullet)"
)| style | n_dscr_breach | n_ltv_breach |
|---|---|---|
| core | 1 | 0 |
| core_plus | 1 | 0 |
| value_added | 3 | 2 |
| opportunistic | 1 | 1 |
Under these tighter, underwriting-oriented guardrails, the main pressure appears on forward LTV rather than on a systematic DSCR collapse:
core and core_plus remain broadly
covenant-friendly on balance-sheet metrics;value_added and opportunistic are the
first styles to breach the forward-LTV line, which is a more realistic
signature of transitional and exit-dependent plans;value_added plan can even show the sharpest temporary
LTV spike if the debt remains in place while NOI is still ramping
up.The presets are first calibrated under a WACC-based discounting rule. It is useful to check that the ranking of styles does not depend entirely on that choice.
A simple robustness check consists in re-evaluating the same YAML
presets under a simpler "yield_plus_growth" rule, while
leaving cash-flow assumptions unchanged. In this alternative, the
discount rate is reconstructed as
entry_yield;
andindex_rate,without explicit reference to capital structure.
This remains a deliberately stylized convention. It starts from an NOI-based entry yield, not from a fully PBTCF-adjusted market cash yield, so it should be read as a robustness exercise rather than as a literal recovery of textbook OCC from transaction cap rates.
styles_revalue_yield_plus_growth() returns leveraged
equity IRR and NPV under the alternative convention.
styles_vec <- c("core", "core_plus", "value_added", "opportunistic")
# Baseline (WACC) equity metrics from the manifest
base_tbl <- styles_manifest(styles_vec) |>
dplyr::select(style, irr_equity, npv_equity)
# Re-evaluation under the yield+growth rule
yg_tbl <- styles_revalue_yield_plus_growth(styles_vec)
rob_tbl <- dplyr::left_join(base_tbl, yg_tbl, by = "style") |>
dplyr::mutate(
delta_npv = npv_equity_y - npv_equity
)
knitr::kable(
rob_tbl,
digits = 4,
caption = "Robustness: equity IRR (invariant) and NPV under WACC vs yield+growth"
)| style | irr_equity | npv_equity | irr_equity_y | npv_equity_y | delta_npv |
|---|---|---|---|---|---|
| core | 0.0620 | 6353091 | 0.0620 | 448151.8 | -5904939 |
| core_plus | 0.0942 | 3998037 | 0.0942 | 1531921.0 | -2466116 |
| value_added | 0.1703 | 3921925 | 0.1703 | 2068358.2 | -1853566 |
| opportunistic | 0.2065 | 2585124 | 0.2065 | 1611377.8 | -973746 |
In the current calibration, equity IRRs are identical across the two rules by construction, while equity NPVs differ. The ranking still holds.
Another useful angle is the time profile of leveraged equity cash flows. In these preset scenarios:
core and core_plus configurations are
calibrated to return a meaningful fraction of equity progressively, on
the back of relatively stable NOI and modest refinancing risk;value_added and opportunistic strategies
tend to back-load value creation into the terminal event, with thinner
interim distributions and a stronger dependence on the exit.styles_equity_cashflows() extracts year-by-year equity
cash flow under the leveraged scenario. The vignette then builds a
timing indicator: the share of total positive equity distributions
received before the final year.
Formally, for each style, share_early_equity is defined
as the ratio between:
styles_vec <- c("core", "core_plus", "value_added", "opportunistic")
# 1) Equity cash flows and horizons ----------------------------------------
eq_tbl <- styles_equity_cashflows(styles_vec) |>
dplyr::group_by(style) |>
dplyr::arrange(style, year)
horizon_tbl <- eq_tbl |>
dplyr::group_by(style) |>
dplyr::summarise(
horizon_years = max(year),
.groups = "drop"
)
eq_with_h <- dplyr::left_join(eq_tbl, horizon_tbl, by = "style")
# 2) Share of total positive equity CF received before the final year ------
timing_tbl <- eq_with_h |>
dplyr::group_by(style) |>
dplyr::summarise(
total_pos_equity = sum(pmax(equity_cf, 0), na.rm = TRUE),
early_pos_equity = sum(
pmax(equity_cf, 0) * (year < horizon_years),
na.rm = TRUE
),
share_early_equity = dplyr::if_else(
total_pos_equity > 0,
early_pos_equity / total_pos_equity,
NA_real_
),
.groups = "drop"
)
knitr::kable(
timing_tbl |>
dplyr::select(style, share_early_equity),
digits = 3,
caption = "Share of total positive equity distributions received before the final year"
)| style | share_early_equity |
|---|---|
| core | 0.366 |
| core_plus | 0.343 |
| opportunistic | 0.358 |
| value_added | 0.281 |
if (requireNamespace("ggplot2", quietly = TRUE)) {
eq_cum_tbl <- eq_with_h |>
dplyr::group_by(style) |>
dplyr::mutate(cum_equity = cumsum(equity_cf))
ggplot2::ggplot(
eq_cum_tbl,
ggplot2::aes(x = year, y = cum_equity, colour = style)
) +
ggplot2::geom_hline(yintercept = 0, linetype = 3) +
ggplot2::geom_line() +
ggplot2::labs(
title = "Cumulative leveraged equity cash flows by style",
x = "Year",
y = "Cumulative equity CF"
)
}In the present calibration, this metric declines monotonically from
core to opportunistic. Core-like styles
therefore return more cash before exit, while non-core styles rely more
heavily on the final transaction.
This timing indicator complements the return and credit metrics by showing when equity gets paid back.
The styles also differ in the relative contribution of ongoing operations versus terminal value to present value. In broad terms:
The style manifest now exposes this split directly through
ops_share and tv_share, which are computed
from the same DCF engine used everywhere else in the package. This is
especially useful in light of the methodological discussion in Baum and
Hartzell, where the analyst is encouraged to ask how much of value is
expected to come from resale proceeds.
styles_vec <- c("core", "core_plus", "value_added", "opportunistic")
pv_tbl <- styles_manifest(styles_vec) |>
dplyr::mutate(style = factor(style, levels = styles_vec))
knitr::kable(
pv_tbl |>
dplyr::select(style, ops_share, tv_share),
digits = 3,
caption = "Present-value split between operations and terminal value by style"
)| style | ops_share | tv_share |
|---|---|---|
| core | 0.370 | 0.630 |
| core_plus | 0.332 | 0.668 |
| value_added | 0.241 | 0.759 |
| opportunistic | 0.183 | 0.817 |
In the recalibrated presets, tv_share still increases
from core to opportunistic, but it stays in a
range that is easier to reconcile with textbook-style underwriting.
Non-core styles remain more exit-dependent, without becoming implausibly
dominated by a single terminal event.
A different perspective on style differentiation is obtained by examining how sensitive each profile is to small shocks on exit yield and on rental growth. Strategies that rely heavily on value capture at exit should exhibit a larger change in equity IRR for a given shift in exit yield; they effectively have a longer “duration” with respect to terminal-value assumptions.
The first sensitivity perturbs the exit-yield spread by +/- 50 basis
points around its baseline value and recomputes leveraged equity IRR for
each style. For each preset and each shock, the helper
styles_exit_sensitivity() shifts
[ + y]
and runs run_case() under otherwise unchanged
assumptions.
## Sensitivity to +/- 50 bps on exit yield ----------------------------------
styles_vec <- c("core", "core_plus", "value_added", "opportunistic")
exit_sens <- styles_exit_sensitivity(
styles = styles_vec,
delta_bps = c(-50, 0, 50)
)
knitr::kable(
exit_sens |>
tidyr::pivot_wider(
names_from = shock_bps,
values_from = irr_equity
),
digits = 4,
caption = "Equity IRR sensitivity to +/- 50 bps exit-yield shock by style"
)| style | -50 | 0 | 50 |
|---|---|---|---|
| core | 0.0742 | 0.0620 | 0.0507 |
| core_plus | 0.1104 | 0.0942 | 0.0790 |
| value_added | 0.1834 | 0.1703 | 0.1579 |
| opportunistic | 0.2280 | 0.2065 | 0.1854 |
In the current calibration, core and
core_plus show relatively modest IRR changes when exit
yields move by +/- 50 bps, consistent with a larger share of value
coming from intermediate NOI. By contrast, value_added and
opportunistic usually react more strongly because more of
their performance sits in the terminal value.
The next sensitivity focuses on rental growth and indexation. Here
the global index_rate parameter is shifted by +/- 1
percentage point, and leveraged equity IRR is recomputed for each
shocked scenario.
## Sensitivity to rental-growth shocks --------------------------------------
growth_sens <- styles_growth_sensitivity(
styles = styles_vec,
delta = c(-0.01, 0, 0.01)
)
knitr::kable(
growth_sens |>
tidyr::pivot_wider(
names_from = shock_growth,
values_from = irr_equity
),
digits = 4,
caption = "Equity IRR sensitivity to rental-growth shocks by style"
)| style | -0.01 | 0 | 0.01 |
|---|---|---|---|
| core | 0.0461 | 0.0620 | 0.0771 |
| core_plus | 0.0763 | 0.0942 | 0.1113 |
| value_added | 0.1506 | 0.1703 | 0.1892 |
| opportunistic | 0.1837 | 0.2065 | 0.2282 |
This table shows how strongly each profile depends on NOI growth to reach its target return. Core configurations are usually less sensitive to a +/- 1 percentage-point change in indexation, while value_added and opportunistic strategies move more because they rely more on lease-up, reversion and growth.
A further synthetic indicator is the break-even exit yield required for each style to achieve a common target equity IRR. This gives a simple measure of how demanding the exit assumption must be for the business plan to meet a hurdle.
For a style (s) and a target equity IRR ({r}), the helper
styles_break_even_exit_yield() solves, via
uniroot(), for the exit yield (y^) such that
[ ^{}_s(y^) = {r},]
holding all other configuration parameters fixed. In practice, the
function reconstructs the spread exit_yield_spread_bps
implied by a candidate (y^), reruns run_case(), and
searches for the root over a bounded interval.
target_irr <- 0.10 # 10% equity IRR as illustrative hurdle
be_tbl <- styles_break_even_exit_yield(
styles = c("core", "core_plus", "value_added", "opportunistic"),
target_irr = target_irr
)
baseline_irr_tbl <- styles_manifest(
c("core", "core_plus", "value_added", "opportunistic")
) |>
dplyr::select(style, irr_equity)
knitr::kable(
be_tbl,
digits = 4,
caption = sprintf("Break-even exit yield to hit %.1f%% equity IRR by style", 100 * target_irr)
)| style | target_irr | be_exit_yield |
|---|---|---|
| core | 0.1 | 0.0383 |
| core_plus | 0.1 | 0.0557 |
| value_added | 0.1 | NA |
| opportunistic | 0.1 | NA |
Interpreting this table requires keeping in view the baseline equity IRRs of the four presets under their unperturbed exit yields. In the current calibration, these baselines are approximately:
core: 6.2%,core_plus: 9.4%,value_added: 17.0%,opportunistic: 20.6%.A 10 % hurdle is therefore ambitious for the core and core_plus
presets, but modest for the value_added and opportunistic ones. This
asymmetry explains the pattern usually observed in
be_tbl:
core, the equity IRR never reaches 10 % within a
realistic exit-yield bracket (for example ([3%, 10%])). The
corresponding be_exit_yield is therefore NA.
Economically, this means that, at the given purchase price and leverage,
a 10 % equity IRR is simply unattainable without implausibly tight exit
pricing. This is consistent with the role of core as a low-risk,
low-return style.core_plus, the baseline IRR lies below 10 %, so the
root is found by tightening the exit yield. The reported break-even exit
yield is below the baseline yield and can be read as the level of
pricing perfection required for a core_plus deal to attain a
double-digit equity IRR.value_added and opportunistic,
baseline IRRs exceed 10 %. The root is therefore reached by
widening the exit yield (higher yield, lower price) until the
IRR falls back down to 10 %. The corresponding break-even yields are
markedly higher than the baselines, meaning that these non-core styles
can absorb a substantial deterioration in exit pricing and still deliver
10 % to equity.The break-even table does not say that non-core styles “require tighter yields” to be viable. It shows how much adverse repricing each style can absorb before falling below a given equity-IRR benchmark. Core has almost no buffer relative to a 10% target; core_plus has a narrow margin; value_added and opportunistic have a larger buffer.
The same machinery used to construct baseline credit profiles can be mobilised to emulate a simplified distressed-exit mechanism. The aim is not to model a full restructuring process, but to approximate a lender-driven sale triggered when covenants are breached.
In this stylised setting, a distressed exit is defined as follows:
Under the bullet-debt scenario, the paths of DSCR and forward LTV are computed for each style.
For a given covenant regime, the first period (t^) at which either
is interpreted as a covenant breach.
If a breach occurs before a stylised refinancing window (for instance year 3), the exit is shifted to the start of that window; otherwise, the exit takes place at the breach year.
At the distressed exit date, the exit yield is penalised by a fire-sale spread (e.g. +100 bps), and the case is re-run with a shortened horizon.
The covenant clock itself can now be read in two ways. With
underwriting_mode = "transition" (the default), covenant
testing starts at the preset’s stabilization_year, which is
often more realistic for lease-up or refurbishment business plans. With
underwriting_mode = "stabilized", covenant testing starts
in year 1, producing a stricter reading that is closer to a standard
stabilized-income loan.
Because distressed cash-flow patterns can be extreme, the equity IRR
may become undefined when the equity cash-flow vector never changes
sign. Rather than forcing an artificial IRR, the analysis keeps those
NA outcomes and supplements them with more robust
performance indicators:
The helper styles_distressed_exit() (defined in the
package utilities) encapsulates this logic. The vignette uses it with
three illustrative covenant regimes:
and applies a one-percentage-point fire-sale penalty to the exit yield in all regimes.
## Distressed exit across regimes --------------------------------
# Covenant regimes: strict / baseline / flexible
regimes <- tibble::tibble(
regime = c("strict", "baseline", "flexible"),
min_dscr = c(1.20, 1.15, 1.10),
max_ltv = c(0.65, 0.70, 0.75)
)
distress_tbl <- styles_distressed_exit(
styles = c("core", "core_plus", "value_added", "opportunistic"),
regimes = regimes,
fire_sale_bps = 100, # +100 bps exit-yield penalty
refi_min_year = 3L, # refinancing window opens in year 3
allow_year1_distress = FALSE, # breaches before year 3 --> exit at year 3
underwriting_mode = "transition"
)
# For compact display in the vignette, focus on the baseline regime
distress_baseline <- distress_tbl |>
dplyr::filter(regime == "baseline") |>
dplyr::select(
style,
underwriting_mode,
covenant_start_year,
breach_year,
breach_type,
irr_equity_base,
irr_equity_distress,
distress_undefined,
equity_multiple_base,
equity_multiple_distress,
equity_loss_pct_distress
) |>
dplyr::arrange(style)
knitr::kable(
distress_baseline,
digits = c(0, 0, 0, 0, 0, 4, 4, 0, 2, 2, 2),
caption = paste(
"Baseline distressed-exit summary by style (bullet debt scenario,",
"+100 bps fire-sale penalty; breaches before year 3 shifted to year 3)."
)
)| style | underwriting_mode | covenant_start_year | breach_year | breach_type | irr_equity_base | irr_equity_distress | distress_undefined | equity_multiple_base | equity_multiple_distress | equity_loss_pct_distress |
|---|---|---|---|---|---|---|---|---|---|---|
| core | transition | 1 | NA | NA | 0.0620 | NA | FALSE | 1.61 | NA | NA |
| core_plus | transition | 1 | NA | NA | 0.0942 | NA | FALSE | 1.78 | NA | NA |
| opportunistic | transition | 2 | NA | NA | 0.2065 | NA | FALSE | 2.34 | NA | NA |
| value_added | transition | 3 | NA | NA | 0.1703 | NA | FALSE | 2.82 | NA | NA |
This table is read as follows:
breach_year and
breach_type locate the first covenant failure under the
baseline regime (DSCR 1.15, forward LTV 70 %).underwriting_mode and covenant_start_year
indicate whether covenant testing begins at year 1 or only once the
asset is treated as stabilised.irr_equity_base reports the baseline leveraged IRR
under the standard horizon and exit yield, while
irr_equity_distress reports the IRR under the shortened,
fire-sale horizon. When the distressed cash-flow path does not contain
both negative and positive equity flows, the IRR is left undefined and
flagged by distress_undefined = TRUE.equity_multiple_base and
equity_multiple_distress summarise total equity returned
relative to equity paid in in the baseline and distressed cases,
respectively; equity_loss_pct_distress reports the loss
percentage implied by the distressed multiple.In a typical calibration, core and
core_plus presets exhibit:
By contrast, value_added and especially
opportunistic styles tend to:
equity_loss_pct_distress, signalling substantial or
near-total loss of the initial equity stake.This comparison operationalises, in reduced form, the idea that non-core strategies are structurally more exposed to covenant-driven forced-sale dynamics and to value capture concentrated in the terminal event. Core-like strategies, in contrast, show both delayed breaches and more resilient equity profiles, even under penalised exit conditions.
# Export results and breaches (CSV) to facilitate off-notebook auditing
out_dir <- tempfile("cre_dcf_styles_")
dir.create(out_dir, recursive = TRUE, showWarnings = FALSE)
readr::write_csv(tbl_print, file.path(out_dir, "styles_summary.csv"))
readr::write_csv(breach_tbl, file.path(out_dir, "covenant_breaches.csv"))
cat(sprintf("\nArtifacts written to: %s\n", out_dir))##
## Artifacts written to: /tmp/RtmpN9LTYF/cre_dcf_styles_4741f70383