The hardware and bandwidth for this mirror is donated by dogado GmbH, the Webhosting and Full Service-Cloud Provider. Check out our Wordpress Tutorial.
If you wish to report a bug, or if you are interested in having us mirror your free-software or open-source project, please feel free to contact us at mirror[@]dogado.de.

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.

These binaries (installable software) and packages are in development.
They may not be fully stable and should be used with caution. We make no claims about them.
Health stats visible at Monitor.