Author: Package cre.dcf

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

- In a bullet (in fine) structure, only interest is serviced during the term, and principal is repaid entirely at maturity.
  This configuration maximizes leverage and tends to enhance the equity internal rate of return (IRR), but also concentrates refinancing risk at the end of the loan.
- In an amortizing structure, regular principal repayments reduce the outstanding balance through time.
  The resulting Debt Service Coverage Ratio (DSCR) and forward Loan-to-Value (LTV) trajectories reflect progressive deleveraging and improved solvency, though at the cost of lower financial gearing.

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