knitr::opts_chunk$set(echo = TRUE, message = FALSE, warning = FALSE) library(cre.dcf) library(dplyr) library(tidyr) library(ggplot2) library(readr) library(scales)
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" )
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)" )
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" )
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" ) 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" )
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
[ \texttt{exit_yield_spread_bps} \leftarrow \texttt{exit_yield_spread_bps} + \Delta 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" )
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" )
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 (\bar{r}), the helper styles_break_even_exit_yield() solves, via uniroot(), for the exit yield (y^\ast) such that
[ \mathrm{IRR}^{\text{equity}}_s(y^\ast) = \bar{r}, ]
holding all other configuration parameters fixed. In practice, the function reconstructs the spread exit_yield_spread_bps implied by a candidate (y^\ast), 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) )
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: r scales::percent(baseline_irr_tbl$irr_equity[baseline_irr_tbl$style == "core"], accuracy = 0.1),core_plus: r scales::percent(baseline_irr_tbl$irr_equity[baseline_irr_tbl$style == "core_plus"], accuracy = 0.1),value_added: r scales::percent(baseline_irr_tbl$irr_equity[baseline_irr_tbl$style == "value_added"], accuracy = 0.1),opportunistic: r scales::percent(baseline_irr_tbl$irr_equity[baseline_irr_tbl$style == "opportunistic"], accuracy = 0.1).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:
For a given covenant regime, the first period (t^\ast \ge 1) at which either
(\mathrm{DSCR}t < \mathrm{DSCR}{\min}), or
is interpreted as a covenant breach. 3. 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. 4. 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)." ) )
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))
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.