R/dcf.R

Defines functions project_terminal_noi .infer_terminal_noi_growth select_terminal_noi lease_effective_rent dcf_add_noi_columns irr_partition leases_tbl_structuration build_lease_table compute_noi_y1 price_from_cap cf_compute_levered cf_make_full_table compute_leveraged_metrics compute_unleveraged_metrics .pv_component_split discount_factor dcf_calculate

Documented in build_lease_table cf_compute_levered cf_make_full_table compute_leveraged_metrics compute_noi_y1 compute_unleveraged_metrics dcf_add_noi_columns dcf_calculate discount_factor irr_partition lease_effective_rent leases_tbl_structuration price_from_cap project_terminal_noi select_terminal_noi

#' Unlevered discounted cash flow model for a commercial real estate asset
#'
#' @description
#' Builds an indexed annual pro forma over years 0..N, a terminal value, and
#' unlevered valuation metrics including net present value (NPV) and internal
#' rate of return (IRR) for a directly held commercial real estate (CRE) asset,
#' without debt. The annual operating chain is made explicit through gross
#' effective income (GEI), net operating income (NOI), and property before-tax
#' cash flow (PBTCF).
#'
#' @details
#' Time convention: \code{year = 0..N}. The acquisition is booked at \code{year = 0}
#' in \code{free_cash_flow} as a negative cash flow equal to the acquisition price,
#' and the sale is booked only at \code{year = N} in \code{sale_proceeds}. The
#' project NPV corresponds to the sum of \code{discounted_cash_flow}.
#'
#' Two construction modes are available for the NOI path:
#'
#' \itemize{
#' \item \strong{Top-down mode} (default): when \code{noi} is \code{NULL}, the NOI
#'   path is derived from the entry yield and acquisition price:
#'   \code{NOI[1] = entry_yield * acq_price}, then indexed with \code{index_rent}
#'   and adjusted by \code{vacancy}. In this mode, \code{gei} is reconstructed
#'   as \code{noi + opex} so that the cap-rate convention remains anchored on
#'   \code{NOI1}, which is the textbook convention used in direct capitalization
#'   and terminal-value estimation.
#' \item \strong{Bottom-up mode}: when \code{noi} is supplied (scalar or vector), it
#'   is recycled to length \code{N} and used as the \code{NOI[1..N]} path. In this
#'   case, \code{entry_yield}, \code{index_rent}, and \code{vacancy} are not used to
#'   recompute NOI.
#' }
#'
#' @param acq_price Numeric scalar. Acquisition price (net of tax or all-in,
#'   depending on the chosen convention).
#' @param entry_yield Numeric scalar in \code{[0, 1]}. Entry yield; in top-down mode,
#'   \code{NOI[1] = entry_yield * acq_price}.
#' @param exit_yield Numeric scalar in \code{(0, 1]}. Exit yield.
#' @param horizon_years Integer scalar greater than or equal to 1. Projection horizon \code{N} in years.
#' @param disc_rate Numeric scalar in \code{(0, 1]}. Discount rate.
#' @param exit_cost Numeric scalar in \code{[0, 1)}. Exit cost as a fraction of the sale price. Default is 0.
#' @param capex Numeric scalar or numeric vector of length \code{N}. Capital expenditure per year. Default is 0.
#' @param index_rent Numeric scalar or numeric vector of length \code{N}. Annual rent indexation rate.
#'   Used only in top-down mode. Default is 0.
#' @param vacancy Numeric scalar or numeric vector of length \code{N} in \code{[0, 1)}. Average annual vacancy.
#'   Used only in top-down mode. Default is 0.
#' @param opex Numeric scalar or numeric vector of length \code{N}. Operating expenses (non-recoverable). Default is 0.
#' @param noi Numeric scalar or numeric vector of length \code{N}, optional.
#'   Exogenous operating income path (for example computed from leases). When
#'   non-\code{NULL}, it replaces the internal income calculation and is treated
#'   as the NOI path from which explicit \code{gei}, \code{noi}, and
#'   \code{pbtcf} columns are derived.
#' @param terminal_growth Optional numeric scalar. Growth rate used to forwardize
#'   the stabilised terminal NOI by one year for resale valuation. When
#'   \code{NULL}, the function infers a conservative growth rate from the latest
#'   clean NOI observations, falling back to zero when no robust inference is
#'   available.
#'
#' @return A list with:
#' \itemize{
#'   \item \code{inputs}: list of main assumptions,
#'   \item \code{cashflows}: tibble 0..N with standardised columns,
#'   \item \code{npv}: project net present value (NPV),
#'   \item \code{irr_project}: project internal rate of return (IRR), unlevered.
#' }
#'
#' @examples
#' res <- dcf_calculate(
#'   acq_price = 1000,
#'   entry_yield = 0.06,
#'   exit_yield = 0.055,
#'   horizon_years = 3,
#'   disc_rate = 0.08,
#'   capex = c(5, 5, 0),
#'   index_rent = c(0.01, 0.01, 0.01),
#'   vacancy = c(0.05, 0.05, 0),
#'   opex = c(10, 10, 10)
#' )
#' res$npv
#' res$irr_project
#' head(res$cashflows)
#'
#' @export

dcf_calculate <- function(
    acq_price,
    entry_yield,
    exit_yield,
    horizon_years,
    disc_rate,
    exit_cost = 0,
    capex = 0,
    index_rent = 0,
    vacancy = 0,
    opex = 0,
    noi = NULL,
    terminal_growth = NULL
) {
  # ── scalar validations ─────────────────────────────────────────────────────
  checkmate::assert_number(acq_price, lower = 0)
  checkmate::assert_number(entry_yield, lower = 0, upper = 1)
  checkmate::assert_number(exit_yield, lower = .Machine$double.eps, upper = 1)
  checkmate::assert_int(horizon_years, lower = 1)
  checkmate::assert_number(disc_rate, lower = .Machine$double.eps, upper = 1)
  checkmate::assert_number(exit_cost, lower = 0, upper = 1)

  # ── recycling to length N ──────────────────────────────────────────────────
  N <- horizon_years
  recycle <- function(x) if (length(x) == 1L) rep(x, N) else x

  capex      <- recycle(capex)
  index_rent <- recycle(index_rent)
  vacancy    <- recycle(vacancy)
  opex       <- recycle(opex)

  checkmate::assert_numeric(capex,      len = N, any.missing = FALSE)
  checkmate::assert_numeric(index_rent, len = N, any.missing = FALSE)
  checkmate::assert_numeric(vacancy,    len = N, lower = 0, upper = 0.999, any.missing = FALSE)
  checkmate::assert_numeric(opex,       len = N, any.missing = FALSE)

  if (!is.null(terminal_growth)) {
    checkmate::assert_number(terminal_growth, lower = -0.999999, finite = TRUE)
  }

  # ── Income path: bottom-up (if supplied) or top-down (fallback) ───────────
  if (!is.null(noi)) {
    # bottom-up mode: imposed NOI path (for example, aggregated leases)
    noi_path <- recycle(noi)
    checkmate::assert_numeric(noi_path, len = N, any.missing = FALSE)
    gei <- noi_path + opex
  } else {
    # top-down mode: cap-rate convention is anchored on NOI_1 = entry_yield * price
    noi_path <- numeric(N)
    noi_path[1] <- entry_yield * acq_price
    if (N > 1) {
      for (t in 2:N) {
        noi_path[t] <- noi_path[t - 1] * (1 + index_rent[t]) * (1 - vacancy[t]) / (1 - vacancy[t - 1])
      }
    }
    gei <- noi_path + opex
  }

  pbtcf    <- noi_path - capex

  # ── terminal value and net sale proceeds ───────────────────────────────────
  # choose a stabilised terminal NOI base, then forwardize by one year
  noi_terminal_base <- select_terminal_noi(
    noi             = noi_path,
    vacancy         = vacancy,
    capex           = capex,
    noi_theoretical = NULL  # or a value computed upstream if passed from cfg_normalize()
  )

  noi_terminal <- project_terminal_noi(
    noi             = noi_path,
    vacancy         = vacancy,
    capex           = capex,
    noi_theoretical = noi_terminal_base,
    growth_rate     = terminal_growth
  )

  gross_sale <- noi_terminal / exit_yield
  net_sale   <- gross_sale * (1 - exit_cost)

  # ── table 0...N (sale only at N) ─────────────────────────────────────────────
  free_cf <- pbtcf

  # add the sale in the final year
  free_cf[N] <- free_cf[N] + net_sale
  gei_all <- c(0, gei)
  noi_all <- c(0, noi_path)
  pbtcf_all <- c(0, pbtcf)
  capex_all <- c(0, capex)
  opex_all <- c(0, opex)
  legacy_income_all <- gei_all
  free_cf_all <- c(-acq_price, free_cf)
  discount_factor_vec <- (1 + disc_rate)^(0:N)

  cash <- tibble::tibble(
    year                 = 0:N,
    gei                  = gei_all,
    noi                  = noi_all,
    pbtcf                = pbtcf_all,
    net_operating_income = legacy_income_all,
    capex                = capex_all,
    opex                 = opex_all,
    free_cash_flow       = free_cf_all,
    sale_proceeds        = c(rep(0, N), net_sale),
    discount_factor      = discount_factor_vec,
    discounted_cash_flow = free_cf_all / discount_factor_vec,
    asset_value          = c(rep(NA_real_, N), gross_sale),
    acquisition_price    = c(acq_price, rep(NA_real_, N))
  )

  # minimal contract on columns and uniqueness of the sale
  req <- c(
    "year", "gei", "noi", "pbtcf", "net_operating_income", "opex", "capex", "free_cash_flow",
    "sale_proceeds", "discount_factor", "discounted_cash_flow",
    "asset_value", "acquisition_price"
  )
  miss <- setdiff(req, names(cash))
  if (length(miss)) {
    stop("dcf_calculate(): missing columns: ", paste(miss, collapse = ", "))
  }
  if (sum(cash$sale_proceeds[cash$year < N]) != 0) {
    stop("dcf_calculate(): sale_proceeds must be zero for year < N.")
  }

  # alias for potential compatibility
  cash$discounted_cf <- cash$discounted_cash_flow

  # ── project aggregates ─────────────────────────────────────────────────────
  npv_proj <- sum(cash$discounted_cash_flow, na.rm = TRUE)
  irr_proj <- irr_safe(cash$free_cash_flow)

  list(
    inputs = list(
      acq_price          = acq_price,
      entry_yield        = entry_yield,
      exit_yield         = exit_yield,
      horizon_years      = N,
      disc_rate          = disc_rate,
      exit_cost          = exit_cost,
      terminal_noi_base  = noi_terminal_base,
      terminal_noi       = noi_terminal,
      terminal_growth    = if (is.null(terminal_growth)) {
        .infer_terminal_noi_growth(noi_path, vacancy = vacancy, capex = capex)
      } else {
        terminal_growth
      }
    ),
    cashflows   = cash,
    npv         = npv_proj,
    irr_project = irr_proj
  )
}

#' Discount factor
#' @keywords internal
discount_factor <- function(r, t) 1 / (1 + r)^t

#' Present-value split between operations and terminal value
#' @keywords internal
#' @noRd
.pv_component_split <- function(cashflows) {
  checkmate::assert_data_frame(cashflows, min.rows = 2L)

  need <- c("year", "free_cash_flow", "discount_factor")
  miss <- setdiff(need, names(cashflows))
  if (length(miss)) {
    stop(".pv_component_split(): missing columns: ", paste(miss, collapse = ", "))
  }

  sale <- if ("sale_proceeds" %in% names(cashflows)) {
    as.numeric(cashflows$sale_proceeds)
  } else {
    rep(0, nrow(cashflows))
  }

  idx <- cashflows$year >= 1
  df  <- as.numeric(cashflows$discount_factor[idx])
  cf  <- as.numeric(cashflows$free_cash_flow[idx])
  tv  <- as.numeric(sale[idx])

  pv_operations <- sum((cf - tv) / df, na.rm = TRUE)
  pv_terminal   <- sum(tv / df, na.rm = TRUE)
  pv_total      <- pv_operations + pv_terminal

  if (!is.finite(pv_total) || abs(pv_total) < .Machine$double.eps) {
    return(list(
      pv_operations = pv_operations,
      pv_terminal   = pv_terminal,
      ops_share     = NA_real_,
      tv_share      = NA_real_
    ))
  }

  list(
    pv_operations = pv_operations,
    pv_terminal   = pv_terminal,
    ops_share     = pv_operations / pv_total,
    tv_share      = pv_terminal / pv_total
  )
}

#' Unlevered summary (project metrics)
#' @description Derives project-level metrics from the standard DCF table.
#' @param dcf_res list. Output of `dcf_calculate()`.
#' @return list containing `irr_project`, `npv_project`, `irr_equity`,
#'   `npv_equity`, and `cashflows`.
#' @export
compute_unleveraged_metrics <- function(dcf_res) {
  checkmate::assert_list(dcf_res, any.missing = FALSE)
  checkmate::assert_data_frame(dcf_res$cashflows, min.rows = 2L)
  cf <- dcf_res$cashflows
  pv_split <- .pv_component_split(cf)

  irr_proj <- irr_safe(cf$free_cash_flow)

  npv_proj <- sum(cf$discounted_cash_flow, na.rm = TRUE)

  list(
    scenario     = "all_equity",
    irr_equity   = irr_proj,
    npv_equity   = npv_proj,
    irr_project  = irr_proj,
    npv_project  = npv_proj,
    cashflows    = cf,
    pv_operations = pv_split$pv_operations,
    pv_terminal   = pv_split$pv_terminal,
    ops_share     = pv_split$ops_share,
    tv_share      = pv_split$tv_share
  )
}

#' Levered summary (equity cash flows and equity metrics)
#' @description Builds equity cash flows from a  Discounted Cash Flow (DCF) table and a standardised
#'   debt schedule.
#' @param dcf_res list. Output of `dcf_calculate()`.
#' @param debt_sched data.frame. Output of `debt_built_schedule()` (0...N).
#' @param equity_invest numeric(1). Equity contribution at `t = 0` (positive).
#' @return list containing `irr_equity`, `npv_equity`, `cashflows` (levered table),
#'   and a reminder of the project-level metrics.
#' @export
compute_leveraged_metrics <- function(dcf_res, debt_sched, equity_invest) {

  # --- validations ----------------------------------------------------------
  checkmate::assert_list(dcf_res, min.len = 1)
  checkmate::assert_data_frame(dcf_res$cashflows, min.rows = 2)
  checkmate::assert_data_frame(debt_sched, min.rows = 2)
  checkmate::assert_number(equity_invest, lower = 0)

  cf <- dcf_res$cashflows

  req_cf <- c("year", "free_cash_flow", "discount_factor")
  miss_cf <- setdiff(req_cf, names(cf))
  if (length(miss_cf))
    stop("compute_leveraged_metrics(): missing DCF-side columns: ",
         paste(miss_cf, collapse = ", "))

  req_ds <- c("year", "payment", "interest", "outstanding_debt",
              "arrangement_fee", "debt_draw")
  miss_ds <- setdiff(req_ds, names(debt_sched))
  if (length(miss_ds))
    stop("compute_leveraged_metrics(): missing debt-side columns: ",
         paste(miss_ds, collapse = ", "))

  # --- Alignment 0...N --------------------------------------------------------
  years <- cf$year
  idx <- match(debt_sched$year, years)

  serv <- intr <- outst <- arr <- draw <- rep(0, length(years))
  serv[idx] <- as.numeric(debt_sched$payment)
  intr[idx] <- as.numeric(debt_sched$interest)
  outst[idx] <- as.numeric(debt_sched$outstanding_debt)
  arr[idx]   <- as.numeric(debt_sched$arrangement_fee)
  draw[idx]  <- as.numeric(debt_sched$debt_draw)

  # --- Equity cash flows ----------------------------------------------------
  eq_cf <- as.numeric(cf$free_cash_flow)

  # t = 0 : equity investment (negative from investor perspective)
  eq_cf[1] <- -equity_invest

  # t >= 1 : subtract debt service + fees
  if (length(eq_cf) >= 2) {
    eq_cf[2:length(eq_cf)] <- cf$free_cash_flow[2:length(eq_cf)] -
                              serv[2:length(eq_cf)] -
                              arr[2:length(eq_cf)]
  }

  # t = N : DO NOT ADD ANYTHING --> the sale is already in free_cash_flow[N]

  # --- Metrics --------------------------------------------------------------
  df_vec <- cf$discount_factor

  irr_eq <- irr_safe(eq_cf)
  npv_eq <- if (all(is.finite(df_vec))) sum(eq_cf / df_vec) else NA_real_

  irr_prj <- dcf_res$irr_project %||%
    irr_safe(cf$free_cash_flow)

  npv_prj <- dcf_res$npv %||%
    sum(cf$free_cash_flow / df_vec)
  pv_split <- .pv_component_split(dcf_res$cashflows)

  # --- Levered table --------------------------------------------------------
  levered_cf <- tibble::tibble(
    year             = years,
    free_cash_flow   = cf$free_cash_flow,
    discount_factor  = df_vec,
    payment          = serv,
    interest         = intr,
    outstanding_debt = outst,
    arrangement_fee  = arr,
    debt_draw        = draw,
    equity_cf        = eq_cf
  )

  list(
    scenario      = "levered",
    irr_equity    = irr_eq,
    npv_equity    = npv_eq,
    irr_project   = irr_prj,
    npv_project   = npv_prj,
    cashflows     = levered_cf,
    equity_0      = equity_invest,
    loan_draw_0   = draw[1],
    pv_operations = pv_split$pv_operations,
    pv_terminal   = pv_split$pv_terminal,
    ops_share     = pv_split$ops_share,
    tv_share      = pv_split$tv_share
  )
}

#' Assemble the full cash-flow table (discounted cash flow and debt)
#'
#' Builds an annual table by merging operating cash flows from a discounted cash
#' flow model with a debt schedule; standardises gross effective income (GEI) and
#' net operating income (NOI), computes post-debt cash flows, the equity cash flow,
#' and discounted equity cash flows. Enforces a minimal contract on expected
#' columns on both inputs.
#'
#' @param dcf A `list` containing at least an element `cashflows` (data.frame or tibble)
#'   with one row per `year` and the following columns:
#'   - `year` (integer, 0 = acquisition date),
#'   - `net_operating_income` (numeric),
#'   - `capex` (numeric, optional),
#'   - `free_cash_flow` (numeric, pre-debt cash flow),
#'   - `sale_proceeds` (numeric, sale proceeds in the exit year, 0 otherwise),
#'   - `discount_factor` (numeric, strictly positive discount factor).
#'
#'   If `gei` or `noi` are missing, they are derived according to the convention:
#'   `gei := net_operating_income` and `noi := gei - opex`. If `opex` is missing,
#'   it is set to 0.
#'
#' @param schedule A data.frame or tibble of the debt schedule with one row per
#'   `year` and the required columns:
#'   - `year` (integer, aligned with `dcf$cashflows$year`),
#'   - `debt_draw` (numeric, drawdown; typically positive at `year == 0`),
#'   - `interest` (numeric),
#'   - `amortization` (numeric),
#'   - `payment` (numeric, debt service = interest + amortization; must be 0 at `year == 0`),
#'   - `arrangement_fee` (numeric, upfront or recurring fees),
#'   - `outstanding_debt` (numeric, end-of-period outstanding balance).
#'
#' @return A merged tibble (join on `year`) containing:
#'   - all input columns from the  Discounted Cash Flow (DCF) and the debt schedule,
#'   - `df` (alias of `discount_factor`),
#'   - `cf_pre_debt` (= `free_cash_flow`),
#'   - `cf_post_debt` (= `free_cash_flow - payment - arrangement_fee + debt_draw`),
#'   - `equity_flow` (= `cf_post_debt`; sale proceeds are already embedded in
#'     `free_cash_flow` at the exit year),
#'   - `equity_disc` (= `equity_flow / df`).
#'
#' @details
#' Invariants and checks:
#' - Stop if required columns are missing on the  Discounted Cash Flow (DCF) or the debt side.
#' - Stop if `payment[year == 0] != 0`.
#' - Warn if `debt_draw[year == 0] <= 0`.
#'
#' @examples
#' cf <- tibble::tibble(
#'   year = 0:2,
#'   net_operating_income = c(NA, 120, 124),
#'   opex = c(0, 20, 21),
#'   capex = c(0, 5, 5),
#'   free_cash_flow = c(-100, 95, 98),
#'   sale_proceeds = c(0, 0, 150),
#'   discount_factor = c(1, 1.05, 1.1025)
#' )
#' dcf <- list(cashflows = cf)
#'
#' schedule <- tibble::tibble(
#'   year = 0:2,
#'   debt_draw = c(60, 0, 0),
#'   interest = c(0, 3, 2),
#'   amortization = c(0, 10, 50),
#'   payment = interest + amortization,
#'   arrangement_fee = c(0.6, 0, 0),
#'   outstanding_debt = c(60, 50, 0)
#' )
#'
#' res <- cf_make_full_table(dcf, schedule)
#' res
#'
#' @export

cf_make_full_table <- function(dcf, schedule) {
  stopifnot(is.list(dcf), "cashflows" %in% names(dcf))
  cf <- dcf$cashflows

  # Minimal  Discounted Cash Flow (DCF)-side contract
  req_cf <- c("year","net_operating_income","opex","capex",
              "free_cash_flow","sale_proceeds","discount_factor")
  miss_cf <- setdiff(req_cf, names(cf))
  if (length(miss_cf)) {
    stop("cf_make_full_table(): missing DCF-side columns: ",
         paste(miss_cf, collapse = ", "))
  }

  # GEI/NOI/PBTCF standardisation
  cf <- dcf_add_noi_columns(cf)

  # Minimal debt-side contract
  req_debt <- c("year","debt_draw","interest","amortization","payment",
                "arrangement_fee","outstanding_debt")
  miss_debt <- setdiff(req_debt, names(schedule))
  if (length(miss_debt)) {
    stop("cf_make_full_table(): missing debt-side columns: ",
         paste(miss_debt, collapse = ", "))
  }

  df <- dplyr::left_join(cf, schedule, by = "year") %>%
    dplyr::mutate(
      df           = discount_factor,
      cf_pre_debt  = free_cash_flow,
      cf_post_debt = free_cash_flow - payment - arrangement_fee + debt_draw,
      equity_flow  = cf_post_debt,
      equity_disc  = equity_flow / df
    )

  if (any(df$year == 0 & df$payment != 0, na.rm = TRUE)) {
    stop("payment[t = 0] must be 0.")
  }
  if (any(df$year == 0 & df$debt_draw <= 0, na.rm = TRUE)) {
    warning("debt_draw[t = 0] should be > 0 for an initial loan drawdown.")
  }

  df
}

#' Equity cash flows and metrics in the presence of debt
#'
#' @description
#' Computes equity cash flows over \eqn{t = 0..N} from an unlevered Discounted Cash Flow (DCF) and an
#' annual debt schedule, then derives equity IRR and equity NPV. The convention
#' is that \code{free_cash_flow} includes the acquisition at \eqn{t = 0} as a
#' negative flow and includes operating free cash flows for \eqn{t >= 1}. Sale
#' proceeds are booked at \eqn{t = N} via \code{sale_proceeds}.
#'
#' @param dcf_res list. Result of \code{dcf_calculate()}. Must contain:
#'   \itemize{
#'     \item \code{inputs} with at least \code{acq_price}, \code{disc_rate}, \code{exit_yield},
#'     \item \code{cashflows} with at least \code{year}, \code{free_cash_flow}, \code{sale_proceeds},
#'           \code{net_operating_income}.
#'   }
#'
#' @param debt_sched data.frame or tibble. Debt schedule (output of
#'   \code{debt_built_schedule()}). Minimal columns: \code{year}, \code{payment},
#'   \code{interest}, \code{amortization}, \code{outstanding_debt}. Years must be
#'   compatible with \code{dcf_res$cashflows$year}.
#'
#' @param cfg list. Financing parameters. Must contain \code{ltv_init}. Optional:
#'   \code{arrangement_fee_pct} (default 0) and \code{capitalized_fees} (default TRUE).
#'
#' @return
#' A list with:
#' \itemize{
#'   \item \code{equity_cf}: numeric vector of equity cash flows,
#'   \item \code{metrics}: list with \code{irr_equity}, \code{npv_equity},
#'         \code{equity_0}, \code{loan_draw_0},
#'   \item \code{full}: \code{dcf_res$cashflows} enriched by \code{add_credit_ratios()}.
#' }
#'
#' @examples
#' dcf <- dcf_calculate(
#'   acq_price = 1e7, entry_yield = 0.05, exit_yield = 0.055,
#'   horizon_years = 10, disc_rate = 0.07
#' )
#' sch <- debt_built_schedule(
#'   principal = 6e6, rate_annual = 0.045, maturity = 5, type = "bullet"
#' )
#' out <- cf_compute_levered(
#'   dcf_res = dcf,
#'   debt_sched = sch,
#'   cfg = list(ltv_init = 0.6, arrangement_fee_pct = 0, capitalized_fees = TRUE)
#' )
#' stopifnot(is.numeric(out$metrics$irr_equity) || is.na(out$metrics$irr_equity))
#' stopifnot(is.numeric(out$equity_cf))
#' @export
cf_compute_levered <- function(dcf_res, debt_sched, cfg) {
  checkmate::assert_list(dcf_res, any.missing = FALSE)
  checkmate::assert_data_frame(debt_sched, min.rows = 1)
  checkmate::assert_list(cfg, any.missing = FALSE)

  acq_price <- dcf_res$inputs$acq_price %||% stop("dcf_res$inputs$acq_price is missing")
  ltv_init  <- cfg$ltv_init %||% stop("cfg$ltv_init is missing")
  arr_fee   <- cfg$arrangement_fee_pct %||% 0
  cap_fees  <- cfg$capitalized_fees %||% TRUE

  eq0 <- compute_equity_invest(
    acq_price, ltv_init,
    arrangement_fee_pct = arr_fee,
    capitalized_fees    = cap_fees
  )
  lev <- compute_leveraged_metrics(
    dcf_res = dcf_res,
    debt_sched = debt_sched,
    equity_invest = eq0$equity_0
  )

  full <- cf_make_full_table(dcf_res, debt_sched)
  full <- add_credit_ratios(full, debt_sched, exit_yield = dcf_res$inputs$exit_yield)

  list(
    equity_cf = lev$cashflows$equity_cf,
    metrics = list(
      irr_equity  = lev$irr_equity,
      npv_equity  = lev$npv_equity,
      equity_0    = eq0$equity_0,
      loan_draw_0 = eq0$loan_draw_0
    ),
    full = full
  )
}

#' Acquisition price from an entry capitalization rate
#' @description Converts a given \code{NOI_y1} and \code{entry_yield} into a net
#'   purchase price (HT) and an all-in price including acquisition costs
#'   (via \code{acq_cost_rate}).
#' @param noi_y1 numeric(1). Expected \eqn{NOI} for year 1.
#' @param entry_yield numeric(1) in \code{(0,1]}. Entry capitalization rate.
#' @param acq_cost_rate numeric(1) in \code{[0,1)}. Acquisition cost rate.
#' @return list(ht = net price, di = all-in price).
#' @examples
#' price_from_cap(500000, 0.05, acq_cost_rate = 0.07)
#' @export
price_from_cap <- function(noi_y1, entry_yield, acq_cost_rate = 0) {
  checkmate::assert_number(noi_y1, lower = 0)
  checkmate::assert_number(entry_yield, lower = .Machine$double.eps, upper = 1)
  checkmate::assert_number(acq_cost_rate, lower = 0, upper = 1 - 1e-12)
  price_ht <- noi_y1 / entry_yield
  price_di <- price_ht * (1 + acq_cost_rate)
  list(ht = price_ht, di = price_di)
}


#' Quick computation of year-1 NOI
#' @param rent_signed numeric(1). Face rent (€/m²/year).
#' @param lettable_area numeric(1). Lettable area (m²).
#' @param vac_rate numeric(1) in \code{[0,1)}. Average vacancy rate.
#' @return numeric(1) \eqn{NOI_{y1}} rounded to cents.
#' @examples
#' compute_noi_y1(400, 2500, vac_rate = 0.05)
#' @export
compute_noi_y1 <- function(rent_signed, lettable_area, vac_rate = 0) {
  checkmate::assert_number(rent_signed, lower = 0)
  checkmate::assert_number(lettable_area, lower = 0)
  checkmate::assert_number(vac_rate, lower = 0, upper = 0.999)
  round(rent_signed * lettable_area * (1 - vac_rate), 2)
}


#' Stylised rent table (lease cash-flow)
#' @description Builds a minimal \code{year}-\code{noi} table for \code{n_years}
#'   with optionally vectorised vacancy rates.
#' @param rent_signed numeric. Face rent (€/m²/year) (scalar or vector).
#' @param surface_m2 numeric. Floor area (m²) (scalar or vector).
#' @param n_years integer(1). Number of years.
#' @param vac_rate_vec numeric. Vacancy (scalar or vector), recycled to \code{n_years}.
#' @return \code{tibble(year, noi)}.
#' @examples
#' build_lease_table(400, 2500, n_years = 5, vac_rate_vec = c(0, .05, .1))
#' @export
build_lease_table <- function(rent_signed, surface_m2,
                              n_years, vac_rate_vec = 0) {
  checkmate::assert_integerish(n_years, lower = 1, len = 1)
  vac_rate_vec <- as.numeric(unlist(vac_rate_vec))
  rent_signed   <- rep(rent_signed,   length.out = n_years)
  surface_m2    <- rep(surface_m2,    length.out = n_years)
  vac_rate_vec  <- rep(vac_rate_vec,  length.out = n_years)
  checkmate::assert_numeric(vac_rate_vec, lower = 0, upper = 0.999, any.missing = FALSE)

  tibble::tibble(
    year = seq_len(n_years),
    noi  = rent_signed * surface_m2 * (1 - vac_rate_vec)
  )
}

#' Aggregate lease events into annual vectors aligned on base_year..base_year+horizon-1
#'
#' @description
#' Converts a list of lease events into annual vectors for rent, vacancy, free months,
#' tenant capex (€/sqm), and a new_lease flag. The `[start, end]` convention is used:
#' an event applies to years y with start <= y <= end. Overlaps within a unit resolve as:
#' rent/vac/new_lease: last event wins; capex_sqm/free_months: accumulated at start year.
#' Returned vectors are **non-indexed** (indexation is applied later in cfg_normalize()).
#'
#' @param ev list of events with fields: start, end, rent, vac, free_months,
#'   capex_sqm, new_lease.
#' @param horizon integer(1) >= 1, number of annual steps.
#' @param base_year integer(1), first absolute year of the horizon.
#'
#' @return list with numeric vectors of length `horizon`:
#'   `rent`, `vac`, `free`, `capex_sqm`, `new_lease`.
#' @export
leases_tbl_structuration <- function(ev, horizon, base_year) {
  checkmate::assert_list(ev, min.len = 1)
  checkmate::assert_integerish(horizon, lower = 1, len = 1)
  checkmate::assert_integerish(base_year, len = 1)

  `%||%` <- function(x, y) if (is.null(x)) y else x

  yrs_abs <- seq(base_year, length.out = horizon)

  # initialise
  rent_vec      <- numeric(horizon)  # €/sqm/year
  vac_vec       <- numeric(horizon)  # rate in [0,1]
  free_vec      <- numeric(horizon)  # fraction of year (0..1) applied at start year
  capex_sqm_vec <- numeric(horizon)  # €/sqm, distributed across event span
  new_lease_vec <- numeric(horizon)  # indicator (0/1) at start year

  for (e in ev) {
    # sanitize event fields
    e$start        <- as.integer(e$start)
    e$end          <- as.integer(e$end)
    e$rent         <- e$rent        %||% 0
    e$vac          <- e$vac         %||% 0
    e$free_months  <- e$free_months %||% 0
    e$capex_sqm    <- e$capex_sqm   %||% 0
    e$new_lease    <- e$new_lease   %||% 0

    # CLOSED interval: start <= year <= end
    idx <- which(yrs_abs >= e$start & yrs_abs <= e$end)
    if (length(idx) == 0L) next

    # last-wins for rent and vacancy over the active span
    rent_vec[idx] <- e$rent
    vac_vec[idx]  <- e$vac

    # one-off accumulators at the start year
    i0 <- idx[1]
    free_frac <- max(0, min(1, e$free_months / 12))

    # Free rent applies only for a new lease
    if (isTRUE(e$new_lease) || e$new_lease == 1) {
      free_vec[i0] <- free_vec[i0] + free_frac
    }

    # CAPEX distributed across the event span
    n_years      <- length(idx)
    annual_capex <- e$capex_sqm / n_years
    capex_sqm_vec[idx] <- capex_sqm_vec[idx] + annual_capex

    # new_lease flag: last event wins at start year
    new_lease_vec[i0] <- as.numeric(e$new_lease)
  }

  # clamp accidental over-accumulation of free months to [0,1]
  free_vec <- pmax(0, pmin(1, free_vec))

  list(
    rent      = rent_vec,
    vac       = vac_vec,
    free      = free_vec,
    capex_sqm = capex_sqm_vec,
    new_lease = new_lease_vec
  )
}



#' IRR decomposition between operations and resale
#'
#' @description
#' Approximates the relative contribution of:
#'   - operational cash flows (acquisition + NOI - capex - opex),
#'   - resale (net sale in year N),
#' to the total IRR, using NPV shares (`share`) and mapping them to
#' `irr_total` (`irr_contrib = irr_total * share`).
#'
#' @param cashflows data.frame. Must contain at least
#'   \code{year}, \code{free_cash_flow}, \code{discount_factor}.
#'   If \code{sale_proceeds} is missing, it is assumed to be zero.
#' @param tv_disc numeric(1). Terminal value already discounted (net sale),
#'   if available. If \code{NULL}, it is derived from \code{sale_proceeds}
#'   and \code{discount_factor}.
#' @param irr_total numeric(1). Total IRR (project or equity) for which the
#'   decomposition is sought (e.g. \code{dcf_res$irr_project} or an equity IRR).
#' @param initial_investment numeric(1). Not used here (kept for API compatibility).
#'
#' @return \code{tibble(component, share, irr_contrib)} with two rows:
#'   "Operations" and "Resale".
#' @export
irr_partition <- function(cashflows, tv_disc = NULL, irr_total, initial_investment = NULL) {
  checkmate::assert_data_frame(cashflows, min.rows = 1)
  need <- c("year", "free_cash_flow", "discount_factor")
  checkmate::assert_subset(need, names(cashflows))

  years <- cashflows$year
  df    <- cashflows$discount_factor

  # add sale_proceeds if missing
  if (!("sale_proceeds" %in% names(cashflows))) {
    cashflows$sale_proceeds <- 0
  }
  sale <- cashflows$sale_proceeds

  # index of last year (N)
  last <- which.max(years)

  # --- NPV of operations ----------------------------------------------------
  # free_cash_flow already includes resale at N:
  # remove resale component to isolate operations.
  ops_cf <- cashflows$free_cash_flow
  ops_cf[last] <- ops_cf[last] - sale[last]

  pv_ops <- sum(ops_cf / df, na.rm = TRUE)

  # --- NPV of resale --------------------------------------------------------
  pv_tv <- if (!is.null(tv_disc)) {
    checkmate::assert_number(tv_disc)
    tv_disc
  } else {
    sale[last] / df[last]
  }

  total_pv <- pv_ops + pv_tv

  if (!is.finite(total_pv) || abs(total_pv) < .Machine$double.eps) {
    return(tibble::tibble(
      component   = c("Operations", "Resale"),
      share       = c(NA_real_, NA_real_),
      irr_contrib = c(NA_real_, NA_real_)
    ))
  }

  s_ops <- pv_ops / total_pv
  s_tv  <- pv_tv  / total_pv

  tibble::tibble(
    component   = c("Operations", "Resale"),
    share       = c(s_ops, s_tv),
    irr_contrib = irr_total * c(s_ops, s_tv)
  )
}

#' Explicitly standardise GEI and NOI columns in a Discounted Cash Flow (DCF) cash-flow table
#'
#' @description
#' Guarantees the presence of numeric columns \code{gei} and \code{noi} in a
#' cash-flow table, to make explicit the income base used for the unlevered
#' project IRR. In this package, \code{gei} denotes gross effective income
#' (after vacancy and rent-free effects) and \code{noi} is computed as
#' \code{gei - opex}.
#'
#' The input may provide \code{gei} directly, or a legacy column
#' \code{net_operating_income} which is interpreted here as \code{gei}
#' (compatibility with earlier pipelines).
#'
#' @param cf_tab data.frame|tibble Cash-flow table for periods 0..N, typically
#'   produced by \code{dcf_calculate()}.
#'   Required columns: \code{opex} and either \code{gei} or
#'   \code{net_operating_income}.
#'
#' @return A \code{tibble} with guaranteed numeric columns \code{gei},
#'   \code{noi}, and \code{pbtcf}. Existing \code{noi} or \code{pbtcf} are
#'   preserved when present, but a warning is emitted if they differ from the
#'   implied identities beyond a small tolerance.
#'
#' @examples
#' # Minimal example with a legacy column name (net_operating_income interpreted as GEI)
#' cf_tab <- tibble::tibble(
#'   year = 0:2,
#'   net_operating_income = c(0, 120, 124),
#'   opex = c(0, 20, 21)
#' )
#' dcf_add_noi_columns(cf_tab)
#'
#' # Example where GEI is provided explicitly and NOI is already present
#' cf_tab2 <- tibble::tibble(
#'   year = 0:2,
#'   gei  = c(0, 120, 124),
#'   opex = c(0, 20, 21),
#'   noi  = c(0, 100, 103)
#' )
#' dcf_add_noi_columns(cf_tab2)
#' @export
dcf_add_noi_columns <- function(cf_tab) {
  checkmate::assert_data_frame(cf_tab, min.rows = 2)

  nms <- names(cf_tab)
  has <- function(nm) nm %in% nms

  gei_col <- dplyr::case_when(
    has("gei") ~ "gei",
    has("net_operating_income") ~ "net_operating_income",
    TRUE ~ NA_character_
  )

  if (is.na(gei_col)) {
    stop(
      "dcf_add_noi_columns(): missing income base. ",
      "Provide column 'gei' or 'net_operating_income'. ",
      "Available columns: ", paste(nms, collapse = ", ")
    )
  }

  if (!has("opex")) {
    stop(
      "dcf_add_noi_columns(): column 'opex' required. ",
      "Available columns: ", paste(nms, collapse = ", ")
    )
  }

  # Coerce to numeric and warn if coercion introduces new missing values
  to_num_checked <- function(x, col_name) {
    x_chr <- as.character(x)
    x_num <- suppressWarnings(as.numeric(x_chr))

    introduced_na <- is.na(x_num) & !is.na(x_chr) & trimws(x_chr) != ""
    if (any(introduced_na)) {
      bad_examples <- unique(utils::head(x_chr[introduced_na], 5))
      warning(
        "dcf_add_noi_columns(): numeric coercion introduced NA in '", col_name,
        "'. Examples: ", paste(bad_examples, collapse = " | ")
      )
    }
    x_num
  }

  gei_num  <- to_num_checked(cf_tab[[gei_col]], gei_col)
  opex_num <- to_num_checked(cf_tab[["opex"]], "opex")
  capex_num <- if (has("capex")) {
    to_num_checked(cf_tab[["capex"]], "capex")
  } else {
    rep(0, nrow(cf_tab))
  }

  checkmate::assert_numeric(gei_num, any.missing = TRUE)
  checkmate::assert_numeric(opex_num, any.missing = TRUE)
  checkmate::assert_numeric(capex_num, any.missing = TRUE)
  checkmate::assert_true(length(gei_num) == nrow(cf_tab))
  checkmate::assert_true(length(opex_num) == nrow(cf_tab))
  checkmate::assert_true(length(capex_num) == nrow(cf_tab))

  noi_calc <- gei_num - opex_num
  pbtcf_calc <- noi_calc - capex_num

  out <- dplyr::as_tibble(cf_tab)

  # Always standardise gei as numeric, regardless of original type
  out$gei <- gei_num

  # Add noi if missing; if present, keep it but warn if it contradicts gei - opex
  if (!has("noi")) {
    out$noi <- noi_calc
  } else {
    noi_num <- to_num_checked(out$noi, "noi")
    out$noi <- noi_num

    ok <- is.finite(noi_num) & is.finite(noi_calc)
    if (any(ok)) {
      # Tolerance scaled to magnitude to avoid spurious warnings on large flows
      tol <- 1e-8 + 1e-6 * pmax(1, abs(noi_calc[ok]))
      if (any(abs(noi_num[ok] - noi_calc[ok]) > tol, na.rm = TRUE)) {
        warning(
          "dcf_add_noi_columns(): existing 'noi' differs from 'gei - opex' for at least one row. ",
          "The existing 'noi' has been preserved."
        )
      }
    }
  }

  if (!has("pbtcf")) {
    out$pbtcf <- pbtcf_calc
  } else {
    pbtcf_num <- to_num_checked(out$pbtcf, "pbtcf")
    out$pbtcf <- pbtcf_num

    ok <- is.finite(pbtcf_num) & is.finite(pbtcf_calc)
    if (any(ok)) {
      tol <- 1e-8 + 1e-6 * pmax(1, abs(pbtcf_calc[ok]))
      if (any(abs(pbtcf_num[ok] - pbtcf_calc[ok]) > tol, na.rm = TRUE)) {
        warning(
          "dcf_add_noi_columns(): existing 'pbtcf' differs from 'noi - capex' for at least one row. ",
          "The existing 'pbtcf' has been preserved."
        )
      }
    }
  }

  out
}

#' Lease effective rent from a stream of lease cash flows
#'
#' @description
#' Discounts a lease cash-flow vector and converts its present value into an
#' equivalent level annuity. This is a compact helper for comparing lease
#' structures with different concessions, rent steps, or timing conventions.
#'
#' @param cashflows Numeric vector of lease cash flows already expressed from the
#'   chosen perspective.
#' @param discount_rate Numeric scalar in \code{[0, 1]}. Discount rate per
#'   period.
#' @param area Optional numeric scalar greater than 0. When supplied, the
#'   effective rent is also reported per unit of area.
#' @param timing Character string. Either \code{"advance"} (cash flows at the
#'   start of each period) or \code{"arrears"} (cash flows at the end).
#' @param perspective Character string. Either \code{"landlord"} or
#'   \code{"tenant"}; this does not alter the sign convention and is stored for
#'   reporting.
#'
#' @return A one-row tibble with present value, equivalent annuity, and effective
#'   rent. When \code{area} is supplied, a per-area metric is also returned.
#'
#' @examples
#' lease_effective_rent(
#'   cashflows = c(0, 100, 100, 100, 100),
#'   discount_rate = 0.08,
#'   timing = "arrears",
#'   perspective = "landlord"
#' )
#' @export
lease_effective_rent <- function(cashflows,
                                 discount_rate,
                                 area = NULL,
                                 timing = c("advance", "arrears"),
                                 perspective = c("landlord", "tenant")) {
  timing <- match.arg(timing)
  perspective <- match.arg(perspective)

  checkmate::assert_numeric(cashflows, any.missing = FALSE, min.len = 1)
  checkmate::assert_number(discount_rate, lower = 0, upper = 1)
  if (!is.null(area)) {
    checkmate::assert_number(area, lower = .Machine$double.eps)
  }

  n <- length(cashflows)
  times <- if (identical(timing, "advance")) {
    0:(n - 1L)
  } else {
    seq_len(n)
  }

  disc_vec <- (1 + discount_rate)^times
  pv <- sum(cashflows / disc_vec)
  annuity_factor <- sum(1 / disc_vec)
  eq_annuity <- if (annuity_factor > 0) pv / annuity_factor else NA_real_

  out <- tibble::tibble(
    periods            = n,
    discount_rate      = discount_rate,
    timing             = timing,
    perspective        = perspective,
    pv                 = pv,
    equivalent_annuity = eq_annuity,
    effective_rent     = eq_annuity
  )

  if (!is.null(area)) {
    out$effective_rent_per_area <- eq_annuity / area
  }

  out
}

#' Robust selection of terminal NOI for resale valuation
#'
#' @description
#' Chooses a stabilised net operating income (NOI) for terminal value
#' calculation, using a hierarchical decision rule designed to mitigate
#' distortions driven by vacancy, capital expenditure, or atypical end-of-horizon
#' cash-flow patterns.
#'
#' The selection logic proceeds as follows:
#' \enumerate{
#'   \item If \code{NOI_N} is (numerically) zero and
#'     \code{force_theoretical_if_noi_n_zero} is \code{TRUE}, use
#'     \code{noi_theoretical} when provided.
#'   \item If year \code{N} is clean (zero vacancy, zero capex, and \code{NOI_N > 0}),
#'     use \code{NOI_N}.
#'   \item If year \code{N} is distorted but year \code{N-1} is clean and
#'     \code{NOI_{N-1} > 0}, use \code{NOI_{N-1}}.
#'   \item Otherwise, if \code{noi_theoretical} is provided, use it.
#'   \item As a last resort, fall back to \code{NOI_N}. A warning is emitted only
#'     when \code{NOI_N <= 0}.
#' }
#'
#' @param noi Numeric vector of length \code{N} containing annual NOI values
#'   for years 1..N.
#' @param vacancy Optional numeric vector of length \code{N} giving annual
#'   vacancy rates. When \code{NULL}, vacancy is assumed to be zero in all years.
#' @param capex Optional numeric vector of length \code{N} giving annual capital
#'   expenditures. When \code{NULL}, capex is assumed to be zero in all years.
#' @param noi_theoretical Optional numeric scalar giving a stabilised theoretical
#'   NOI (for example market rent multiplied by area).
#' @param force_theoretical_if_noi_n_zero Logical scalar. When \code{TRUE}, and
#'   \code{NOI_N} is numerically zero, \code{noi_theoretical} is used when
#'   available.
#'
#' @return Numeric scalar giving the NOI retained for capitalization.
#' @export
select_terminal_noi <- function(noi,
                                vacancy = NULL,
                                capex = NULL,
                                noi_theoretical = NULL,
                                force_theoretical_if_noi_n_zero = TRUE) {

  checkmate::assert_numeric(noi, any.missing = FALSE)
  N <- length(noi)
  if (N < 1L) stop("select_terminal_noi(): 'noi' must have length >= 1.")

  # standardise vacancy/capex
  vac <- if (is.null(vacancy)) {
    rep(0, N)
  } else {
    checkmate::assert_numeric(vacancy, len = N, lower = 0, upper = 1)
    vacancy
  }

  cap <- if (is.null(capex)) {
    rep(0, N)
  } else {
    checkmate::assert_numeric(capex, len = N, lower = 0)
    capex
  }

  # NOI_N == 0 is considered distorted by default
  noi_N_zero <- (abs(noi[N]) < .Machine$double.eps)

  # distorted N = vacancy > 0, capex > 0, or NOI_N == 0
  is_distorted_N <- (vac[N] > 0) || (cap[N] > 0) || noi_N_zero

  # 1) Explicit case: NOI_theoretical if NOI_N == 0 and flag = TRUE
  if (noi_N_zero && isTRUE(force_theoretical_if_noi_n_zero)) {
    if (!is.null(noi_theoretical)) {
      checkmate::assert_number(noi_theoretical, lower = 0)
      return(noi_theoretical)
    }
  }

  # 2) Simple case: clean year N --> NOI_N
  if (!is_distorted_N) {
    return(noi[N])
  }

  # 3) Fallback: distorted N but clean N-1
  if (N >= 2L && vac[N - 1L] == 0 && cap[N - 1L] == 0 && noi[N - 1L] > 0) {
    return(noi[N - 1L])
  }

  # 4) Reinforced fallback: use theoretical NOI if available
  if (!is.null(noi_theoretical)) {
    checkmate::assert_number(noi_theoretical, lower = 0)
    return(noi_theoretical)
  }

  # 5) Extreme fallback: use NOI_N; warn only when NOI_N <= 0
  if (noi[N] <= 0) {
    warning(
      "select_terminal_noi(): unable to determine a clean stabilised NOI. ",
      "Falling back to NOI_N = ", noi[N]
    )
  }
  noi[N]
}

#' Infer a forward NOI growth rate from the latest robust observations
#' @keywords internal
#' @noRd
.infer_terminal_noi_growth <- function(noi, vacancy = NULL, capex = NULL) {
  checkmate::assert_numeric(noi, any.missing = FALSE)
  N <- length(noi)
  if (N < 2L) {
    return(0)
  }

  vac <- if (is.null(vacancy)) rep(0, N) else vacancy
  cap <- if (is.null(capex)) rep(0, N) else capex

  clean <- is.finite(noi) & noi > 0 & is.finite(vac) & is.finite(cap) &
    vac == 0 & cap == 0

  idx_pair <- integer(0)
  for (i in seq.int(N, 2L)) {
    if (clean[i] && clean[i - 1L]) {
      idx_pair <- c(i - 1L, i)
      break
    }
  }

  if (length(idx_pair) == 0L) {
    for (i in seq.int(N, 2L)) {
      if (is.finite(noi[i]) && is.finite(noi[i - 1L]) && noi[i] > 0 && noi[i - 1L] > 0) {
        idx_pair <- c(i - 1L, i)
        break
      }
    }
  }

  if (length(idx_pair) == 0L) {
    return(0)
  }

  growth <- noi[idx_pair[2L]] / noi[idx_pair[1L]] - 1
  if (!is.finite(growth) || growth <= -0.999999) {
    return(0)
  }

  growth
}

#' Project the NOI capitalized in the terminal value one year forward
#'
#' @description
#' Selects a stabilised terminal NOI within the explicit holding period and
#' forwardizes it by one year, following the standard real-estate reversion
#' convention in which a resale at time \eqn{T} is capitalized off the next
#' year's NOI (or a stabilized forward NOI when year \eqn{T+1} is atypical).
#'
#' @param noi Numeric vector of annual NOI values for years 1..N.
#' @param vacancy Optional numeric vector of annual vacancy rates.
#' @param capex Optional numeric vector of annual capital expenditures.
#' @param noi_theoretical Optional stabilised NOI candidate.
#' @param growth_rate Optional numeric scalar. If \code{NULL}, a growth rate is
#'   inferred from the latest robust NOI observations.
#'
#' @return Numeric scalar. Forwardized terminal NOI used for resale valuation.
#' @export
project_terminal_noi <- function(noi,
                                 vacancy = NULL,
                                 capex = NULL,
                                 noi_theoretical = NULL,
                                 growth_rate = NULL) {
  base_noi <- select_terminal_noi(
    noi = noi,
    vacancy = vacancy,
    capex = capex,
    noi_theoretical = noi_theoretical
  )

  if (is.null(growth_rate)) {
    growth_rate <- .infer_terminal_noi_growth(noi, vacancy = vacancy, capex = capex)
  } else {
    checkmate::assert_number(growth_rate, lower = -0.999999, finite = TRUE)
  }

  base_noi * (1 + growth_rate)
}

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.