Investment styles panorama: API-only comparison

Package cre.dcf

1 Aim of this vignette

This vignette uses the preset YAML files shipped in inst/extdata to compare four commercial real-estate (CRE) investment styles:

All 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:

2 A style-by-style manifest

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:

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 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

3 Risk–return cloud: project vs equity IRR

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.

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.

4 Leverage–coverage map (initial LTV vs min-DSCR)

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:

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:

5 Covenant flags and breach counts

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)"
)
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:

6 Robustness to the discounting rule

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

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"
)
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.

7 Time profile of equity cash flows

Another useful angle is the time profile of leveraged equity cash flows. In these preset scenarios:

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:

  1. the sum of positive equity cash flows in years strictly earlier than the horizon; and
  2. the sum of all positive equity cash flows over the horizon.
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"
)
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.

8 Value composition: operations vs exit

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"
)
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.

9 Exit-yield and rental-growth sensitivities

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.

9.1 Exit-yield shock

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"
)
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.

9.2 Rental-growth shock

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"
)
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.

10 Break-even exit yield for a target equity IRR

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)
)
Break-even exit yield to hit 10.0% equity IRR by style
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:

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:

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.

11 Distressed exit comparison under covenant breach

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:

  1. Under the bullet-debt scenario, the paths of DSCR and forward LTV are computed for each style.

  2. For a given covenant regime, the first period (t^) at which either

    • (t < {}), or
    • (LTV^{}t > LTV{})

    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)."
  )
)
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:

In a typical calibration, core and core_plus presets exhibit:

By contrast, value_added and especially opportunistic styles tend to:

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.

12 Export for audit and replication

# 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