Credit structures: bullet vs amortization (baseline comparison)

Package cre.dcf

1 Purpose

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

2 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")
Summary comparison of bullet vs amortizing structures
scenario irr_equity npv_equity irr_project npv_project min_dscr max_ltv_forward ops_share tv_share
all_equity 0.0458885 3862887 0.0458885 3862887 NA 0.0000000 0.3702565 0.6297435
debt_bullet 0.0619978 6353091 0.0458885 3862887 5.257009 0.4494449 0.3702565 0.6297435
debt_amort 0.0536756 5350105 0.0458885 3862887 1.028086 0.4087726 0.3702565 0.6297435

3 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")
## 
## Qualitative comparison of debt structures:
cat(sprintf(
  "• IRR equity : bullet = %.4f%% | amort. = %.4f%%\n",
  100 * bullet$irr_equity,
  100 * amort$irr_equity
))
## • IRR equity : bullet = 6.1998% | amort. = 5.3676%
cat(sprintf(
  "• Min DSCR   : bullet = %.3f  | amort. = %.3f\n",
  bullet$min_dscr,
  amort$min_dscr
))
## • Min DSCR   : bullet = 5.257  | amort. = 1.028
cat(sprintf(
  "• Max LTV f. : bullet = %.3f  | amort. = %.3f\n",
  bullet$max_ltv_forward,
  amort$max_ltv_forward
))
## • Max LTV f. : bullet = 0.449  | amort. = 0.409
# 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.

4 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"
)
## 
## Interest cover summary:
##  • Min ICR    : bullet = 5.257 | amort. = 5.257
##  • Mean ICR   : bullet = 5.517 | amort. = 15.411
##  • Last-year ICR (t = 10 / 10) : bullet = 5.793 | amort. = 52.629
##    • Read together with DSCR, debt yield and forward LTV.
# 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.

5 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"
)
## 
## DSCR sign summary:
##  • Bullet   – min DSCR = 0.125, share of negative DSCR (interest > 0): 0.0%
##  • Amort.   – min DSCR = 1.028, share of negative DSCR (interest > 0): 0.0%
##    • Negative values can appear in transitional years.

6 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"
)
## 
## Equity NPV comparison:
##  • Global sum of discounted equity flows (cf_all$equity_disc): 6 353 091.2521
##  • Bullet scenario equity NPV (comparison summary)        : 6 353 091.2521
##  • Amort. scenario equity NPV (comparison summary)        : 5 350 105.3940
##  • Leveraged equity NPV reported in case$leveraged        : 6 353 091.2521
##  • Global – bullet NPV gap                               : 0.0000
##  • Global – amort. NPV gap                              : 1 002 985.8580
##  
##  The consolidated column `equity_disc` comes from the main merged table.
##  Scenario NPVs in `comparison$summary` and `case$leveraged` come from their own
##  scenario-specific equity cash-flow streams, so they should be compared, not
##  forced into a single algebraic identity.