Credit structures: bullet vs amortization (baseline comparison)

knitr::opts_chunk$set(echo = TRUE, message = FALSE, warning = FALSE)
library(cre.dcf)
library(dplyr)

Purpose

This vignette compares bullet and amortizing debt structures on the same stabilised operating case.

Build a case and extract comparison details

cfg_path <- system.file("extdata", "preset_core.yml", package = "cre.dcf")
stopifnot(nzchar(cfg_path))

cfg  <- yaml::read_yaml(cfg_path)
case <- run_case(cfg)

cmp <- case$comparison
stopifnot(is.list(cmp), is.data.frame(cmp$summary))

# Ensure expected fields are present

required_fields <- c("scenario","irr_equity","npv_equity","min_dscr","max_ltv_forward")
stopifnot(all(required_fields %in% names(cmp$summary)))

knitr::kable(cmp$summary, caption = "Summary comparison of bullet vs amortizing structures")

Qualitative invariants: bullet vs amort

# Extract scenario rows --------------------------------------------------

rows <- split(cmp$summary, cmp$summary$scenario)
stopifnot(all(c("debt_bullet", "debt_amort") %in% names(rows)))

bullet <- rows$debt_bullet
amort  <- rows$debt_amort

# readable summary --------------------------------------------

cat("\nQualitative comparison of debt structures:\n")
cat(sprintf(
  "• IRR equity : bullet = %.4f%% | amort. = %.4f%%\n",
  100 * bullet$irr_equity,
  100 * amort$irr_equity
))
cat(sprintf(
  "• Min DSCR   : bullet = %.3f  | amort. = %.3f\n",
  bullet$min_dscr,
  amort$min_dscr
))
cat(sprintf(
  "• Max LTV f. : bullet = %.3f  | amort. = %.3f\n",
  bullet$max_ltv_forward,
  amort$max_ltv_forward
))

# Expected financial ordering (sanity checks) ----------------------------

## (a) Leverage effect on IRR - bullet should give a higher equity IRR
stopifnot(bullet$irr_equity > amort$irr_equity)

## (b) DSCR - the ordering is not universal and depends on where the NOI trough
##     occurs relative to the amortization profile. Here we only check that the
##     summary exposes interpretable finite values.
stopifnot(is.finite(bullet$min_dscr))
stopifnot(is.finite(amort$min_dscr))

## (c) Forward LTV - amortizing structure should deleverage over time
stopifnot(bullet$max_ltv_forward > amort$max_ltv_forward)

In this stabilised core case, bullet debt keeps leverage higher for longer, while amortization improves the balance-sheet profile over time. The exact DSCR ordering remains scenario-dependent, but the current preset is calibrated so that both structures remain legible from an underwriting standpoint.

Interest cover (ICR): confirming the expected ordering

# Extract interest-cover paths ------------------------------------------

rat_bul <- case$comparison$details$debt_bullet$ratios
rat_amo <- case$comparison$details$debt_amort$ratios

required_ratio_fields <- c("year", "interest_cover_ratio", "interest")
stopifnot(all(required_ratio_fields %in% names(rat_bul)))
stopifnot(all(required_ratio_fields %in% names(rat_amo)))

# Restrict to operating years (exclude t = 0)

icr_bul <- rat_bul$interest_cover_ratio[rat_bul$year >= 1]
icr_amo <- rat_amo$interest_cover_ratio[rat_amo$year >= 1]

icr_min_bul  <- min(icr_bul, na.rm = TRUE)
icr_min_amo  <- min(icr_amo, na.rm = TRUE)
icr_mean_bul <- mean(icr_bul, na.rm = TRUE)
icr_mean_amo <- mean(icr_amo, na.rm = TRUE)

last_year_bul <- max(rat_bul$year[rat_bul$year >= 1])
last_year_amo <- max(rat_amo$year[rat_amo$year >= 1])

# Last-year ICR among operating years

icr_last_bul <- tail(icr_bul, 1L)
icr_last_amo <- tail(icr_amo, 1L)

cat(
"\nInterest cover summary:\n",
sprintf("• Min ICR    : bullet = %.3f | amort. = %.3f\n", icr_min_bul, icr_min_amo),
sprintf("• Mean ICR   : bullet = %.3f | amort. = %.3f\n", icr_mean_bul, icr_mean_amo),
sprintf(
"• Last-year ICR (t = %d / %d) : bullet = %.3f | amort. = %.3f\n",
last_year_bul, last_year_amo, icr_last_bul, icr_last_amo
),
"  • Read together with DSCR, debt yield and forward LTV.\n"
)

# Internal sanity check: ICR must be finite whenever interest > 0 and NOI > 0 --

stopifnot(all(is.finite(rat_bul$interest_cover_ratio[rat_bul$interest > 0 & rat_bul$noi > 0])))
stopifnot(all(is.finite(rat_amo$interest_cover_ratio[rat_amo$interest > 0 & rat_amo$noi > 0])))

Bullet debt tends to support equity returns, while amortization usually improves forward LTV and reduces refinance risk. In the present vignette, this trade-off is visible on a case that looks closer to a real prime-office financing memo than the former ultra-light default example.

Internal consistency checks on credit ratios

# DSCR availability when debt service is positive and NOI is positive -----

stopifnot("dscr" %in% names(rat_bul))
stopifnot("dscr" %in% names(rat_amo))

bul_idx <- rat_bul$payment > 0 & rat_bul$noi > 0
amo_idx <- rat_amo$payment > 0 & rat_amo$noi > 0

stopifnot(all(is.finite(rat_bul$dscr[bul_idx])))
stopifnot(all(is.finite(rat_amo$dscr[amo_idx])))

# Read the sign of DSCR --------------------------------------------------

neg_share_bul <- mean(rat_bul$dscr[bul_idx] < 0, na.rm = TRUE)
neg_share_amo <- mean(rat_amo$dscr[amo_idx] < 0, na.rm = TRUE)

cat(
"\nDSCR sign summary:\n",
sprintf(
"• Bullet   – min DSCR = %.3f, share of negative DSCR (interest > 0): %.1f%%\n",
min(rat_bul$dscr[bul_idx], na.rm = TRUE),
100 * neg_share_bul
),
sprintf(
"• Amort.   – min DSCR = %.3f, share of negative DSCR (interest > 0): %.1f%%\n",
min(rat_amo$dscr[amo_idx], na.rm = TRUE),
100 * neg_share_amo
),
"  • Negative values can appear in transitional years.\n"
)

Equity NPV read-across

# Global sum of discounted equity flows in the consolidated table --------

cf_all <- case$cashflows
stopifnot("equity_disc" %in% names(cf_all))

npv_equity_sum <- sum(cf_all$equity_disc, na.rm = TRUE)
stopifnot(is.finite(npv_equity_sum))

# 5.2 Scenario-level equity NPVs from the comparison summary -----------------

npv_equity_bullet <- cmp$summary$npv_equity[cmp$summary$scenario == "debt_bullet"]
npv_equity_amort  <- cmp$summary$npv_equity[cmp$summary$scenario == "debt_amort"]

stopifnot(
length(npv_equity_bullet) == 1L,
length(npv_equity_amort)  == 1L
)

# Leveraged NPV reported in the main case object -------------------------

npv_equity_lev <- case$leveraged$npv_equity
stopifnot(is.finite(npv_equity_lev))

# Read the relationship between these quantities -------------------------

gap_bullet_global <- npv_equity_sum - npv_equity_bullet
gap_amort_global  <- npv_equity_sum - npv_equity_amort

cat(
"\nEquity NPV comparison:\n",
sprintf(
"• Global sum of discounted equity flows (cf_all$equity_disc): %s\n",
formatC(npv_equity_sum, format = 'f', big.mark = " ")
),
sprintf(
"• Bullet scenario equity NPV (comparison summary)        : %s\n",
formatC(npv_equity_bullet, format = 'f', big.mark = " ")
),
sprintf(
"• Amort. scenario equity NPV (comparison summary)        : %s\n",
formatC(npv_equity_amort, format = 'f', big.mark = " ")
),
sprintf(
"• Leveraged equity NPV reported in case$leveraged        : %s\n",
formatC(npv_equity_lev, format = 'f', big.mark = " ")
),
sprintf(
"• Global – bullet NPV gap                               : %s\n",
formatC(gap_bullet_global, format = 'f', big.mark = " ")
),
sprintf(
"• Global – amort. NPV gap                              : %s\n",
formatC(gap_amort_global,  format = 'f', big.mark = " ")
),
"\n",
"The consolidated column `equity_disc` comes from the main merged table.\n",
"Scenario NPVs in `comparison$summary` and `case$leveraged` come from their own\n",
"scenario-specific equity cash-flow streams, so they should be compared, not\n",
"forced into a single algebraic identity.\n"
)


Try the cre.dcf package in your browser

Any scripts or data that you put into this service are public.

cre.dcf documentation built on April 10, 2026, 5:08 p.m.