R/config.R

Defines functions cfg_explain as_yaml cfg_missing as_yaml run_from_config dcf_read_config cfg_normalize `%||%` cfg_validate get_cfg run_case dcf_spec_template

Documented in as_yaml cfg_explain cfg_missing cfg_normalize cfg_validate dcf_read_config dcf_spec_template get_cfg run_case run_from_config

#' Minimal specification template for a Discounted Cash Flow (DCF) case
#'
#' Returns a ready-to-edit list that matches the package's YAML grammar.
#' Use this for interactive prototyping or to generate a YAML file.
#'
#' @return A named list with all required top-level keys and sane defaults.
#' @examples
#' cfg <- dcf_spec_template()
#' str(cfg, max.level = 1)
#' @export
dcf_spec_template <- function() {
  list(
    purchase_year = as.integer(format(Sys.Date(), "%Y")),
    horizon_years = 10L,
    index_rate = 0.02,
    entry_yield = 0.065,
    acq_cost_rate = 0.06,
    exit_yield_spread_bps = 0,
    exit_cost = 0.015,
    disc_method = "wacc",
    disc_rate_wacc = list(KE = 0.08, KD = 0.04, tax_rate = 0.28),
    # alternatives (not used unless disc_method changes)
    disc_rate_wacc_capm = list(risk_free = 0.03, beta = 1.0,
                               mkt_return = 0.07, KD = 0.04,
                               size_illiquidity_bps = 0, target_ltv = 0.5, tax_rate = 0.28),
    disc_rate_risk_premium = list(rf = 0.03,
                                  liquidity_premium = 0.00,
                                  obsolescence_premium = 0.00,
                                  income_risk_premium = 0.00),
    disc_rate_yield_plus_growth = list(property_yield = 0.065, growth = 0.01,
                                       adj_obsolescence_bps = 0),
    ltv_init = 0.55,
    rate_annual = 0.045,
    extra_amort_pct = 0.00,
    scr_ratio = 0.28,
    opex_sqm = 0,
    leasing_cost_pct = 0,
    capitalized_fees = FALSE,
    arrangement_fee_pct = 0.00,
    maturity = NULL,          # default is min(horizon, 5) in cfg_normalize()
    ltv_base = "price_ht",    # or "price_di" | "value"
    leases = list()           # fill with per-unit structures as in your examples
  )
}

#' Run a full DCF case from a list or a YAML file
#'
#' User-facing single entry point. Accepts either an in-memory \code{config} list
#' or a \code{config_file} path to YAML. Both routes share the same validation
#' and normalization pathway, ensuring identical downstream behavior.
#'
#' @param config Optional list configuration following the YAML grammar.
#' @param config_file Optional path to a YAML configuration file. If both
#'   \code{config} and \code{config_file} are \code{NULL}, defaults to the package
#'   example at \code{inst/extdata/config.yml}.
#' @param debt_type Optional debt schedule type to use (\code{"bullet"} or
#'   \code{"amort"}). When \code{NULL} (default), the normalized type inferred
#'   from the configuration is used. A non-\code{NULL} value overrides it.
#' @param ltv_base Base for loan-to-value (LTV) and initial principal. One of
#'   \code{"price_di"}, \code{"price_ht"}, or \code{"value"}.
#'
#' @return A list containing pricing (acquisition price net of taxes, acquisition costs,
#'   and acquisition price including costs), all-equity metrics, leveraged metrics,
#'   a comparison table, the full cash-flow table with credit ratios, and selected
#'   configuration flags.
#'
#' @details
#' The function centralizes user ergonomics:
#' \itemize{
#'   \item Reads either a list or a YAML file.
#'   \item Validates and normalizes with \code{cfg_validate()} and \code{cfg_normalize()}.
#'   \item Computes the unlevered discounted cash flow (DCF), builds a debt schedule,
#'     computes leveraged metrics, and adds credit ratios to the full cash-flow table.
#'   \item Handles capitalized arrangement fees by adjusting the scheduled principal
#'     to avoid double-counting.
#' }
#'
#' @importFrom yaml read_yaml
#' @importFrom tibble tibble
#' @importFrom dplyr mutate
#'
#' @examples
#' # R list route
#' cfg <- dcf_spec_template()
#' cfg$leases <- list(
#'   list(
#'     unit = "U",
#'     area = 1000,
#'     events = list(
#'       list(
#'         start = cfg$purchase_year,
#'         end   = cfg$purchase_year + cfg$horizon_years,  # keep NOI positive in terminal year
#'         rent = 200,
#'         free_months = 0,
#'         capex_sqm = 0,
#'         vac = 0,
#'         new_lease = 0
#'       )
#'     )
#'   )
#' )
#' out <- run_case(config = cfg, debt_type = "bullet")
#' names(out)
#' @export
run_case <- function(config = NULL,
                     config_file = NULL,
                     debt_type = NULL,
                     ltv_base  = c("price_di", "price_ht", "value")) {
  prepared <- .engine_prepare_case(
    config = config,
    config_file = config_file,
    debt_type = debt_type,
    ltv_base = ltv_base
  )
  executed <- .engine_execute_case(prepared, include_comparison = TRUE)

  # 7) Pricing breakdown (HT / costs / DI)
  price_ht <- prepared$norm$acq_price_ht
  price_di <- prepared$norm$acq_price_di
  acq_cost <- price_di - price_ht

  list(
    pricing    = list(price_ht = price_ht, acq_cost = acq_cost, price_di = price_di),
    all_equity = executed$all_equity,
    leveraged  = executed$leveraged,
    comparison = executed$comparison,
    cashflows  = executed$ratios,
    config     = list(
      ltv_base            = prepared$ltv_base,   # "price_di" / "price_ht" / "value"
      debt_type           = prepared$debt_type_eff,
      ltv_init            = prepared$norm$ltv_init,
      debt_init           = prepared$financing$debt_init,
      equity_init         = prepared$financing$equity_invest,
      capitalized_fees    = prepared$financing$capitalized_fees,
      arrangement_fee_pct = prepared$fee_pct,
      disc_method         = prepared$config$disc_method,
      disc_rate           = prepared$norm$disc_rate,
      disc_detail         = prepared$norm$disc_detail
    )
  )
}


#' Safe access to nested YAML values
#' @param cfg list configuration object.
#' @param ... nested keys.
#' @param default value if missing.
#' @return value or default.
#' @export
get_cfg <- function(cfg, ..., default = NULL) {
  cur <- cfg
  for (k in list(...)) {
    if (is.null(cur[[k]])) return(default)
    cur <- cur[[k]]
  }
  cur %||% default
}

#' Validate YAML configuration structure
#' @param cfg list returned by dcf_read_config().
#' @return cfg invisibly (or error if invalid).
#' @export
cfg_validate <- function(cfg) {
  checkmate::assert_list(cfg, min.len = 1)
  checkmate::assert_integerish(cfg$purchase_year, any.missing = FALSE, len = 1)
  checkmate::assert_integerish(cfg$horizon_years, lower = 1, len = 1)

  checkmate::assert_number(cfg$index_rate, lower = 0, upper = 1)
  checkmate::assert_number(cfg$entry_yield, lower = 0, upper = 1)
  checkmate::assert_number(cfg$acq_cost_rate, lower = 0, upper = 1)
  checkmate::assert_number(cfg$exit_yield_spread_bps, finite = TRUE)

  # exit_cost is either legacy or new structure
  if (!is.null(cfg$exit_transaction_costs)) {
    checkmate::assert_list(cfg$exit_transaction_costs)
  } else {
    checkmate::assert_number(cfg$exit_cost, lower = 0, upper = 1)
  }

  checkmate::assert_choice(
    cfg$disc_method,
    choices = c("wacc", "wacc_capm", "risk_premium", "yield_plus_growth")
  )

  # Discount rate block checks -
  if (cfg$disc_method == "wacc") {
    w <- cfg$disc_rate_wacc
    checkmate::assert_list(w)
    checkmate::assert_number(w$KE, lower = 0, upper = 1)
    checkmate::assert_number(w$KD, lower = 0, upper = 1)
  }

  if (cfg$disc_method == "wacc_capm") {
    cr <- cfg$disc_rate_wacc_capm
    checkmate::assert_list(cr)
    checkmate::assert_number(cr$risk_free)
    checkmate::assert_number(cr$beta)
    checkmate::assert_number(cr$mkt_return, null.ok = TRUE)
    checkmate::assert_number(cr$KD)
    checkmate::assert_number(cr$target_ltv, null.ok = TRUE)
  }

  if (cfg$disc_method == "risk_premium") {
    rp <- cfg$disc_rate_risk_premium
    checkmate::assert_list(rp)
    checkmate::assert_number(rp$rf)
  }

  if (cfg$disc_method == "yield_plus_growth") {
    yg <- cfg$disc_rate_yield_plus_growth
    checkmate::assert_list(yg)
    checkmate::assert_number(yg$property_yield)
    checkmate::assert_number(yg$growth)
  }

  checkmate::assert_number(cfg$ltv_init,       lower = 0, upper = 1)
  checkmate::assert_number(cfg$rate_annual,    lower = 0, upper = 1)
  checkmate::assert_number(cfg$extra_amort_pct, lower = 0, upper = 1)
  checkmate::assert_number(cfg$scr_ratio,      lower = 0, upper = 1)
  checkmate::assert_number(cfg$opex_sqm,       lower = 0)

  # -
  # Helper: validate events for one unit (no overlaps, no gaps, sane bounds)
  # -
  validate_unit_events <- function(events, unit_label, purchase_year, horizon_years) {
    checkmate::assert_list(events, min.len = 1)

    # Extract and validate start/end as integer vectors
    starts <- vapply(
      events,
      function(e) {
        checkmate::assert_integerish(e$start, any.missing = FALSE, len = 1)
        as.integer(e$start)
      },
      integer(1)
    )

    ends <- vapply(
      events,
      function(e) {
        checkmate::assert_integerish(e$end, any.missing = FALSE, len = 1)
        as.integer(e$end)
      },
      integer(1)
    )

    # Basic temporal consistency
    if (any(ends < starts)) {
      idx <- which(ends < starts)[1L]
      stop(sprintf(
        "Lease '%s': event %d has end (%d) < start (%d).",
        unit_label, idx, ends[idx], starts[idx]
      ))
    }

    # Sort events by (start, end) to have a stable ordering
    ord    <- order(starts, ends)
    starts <- starts[ord]
    ends   <- ends[ord]
    events <- events[ord]

    # Check that lease timeline does not start before purchase_year
    if (min(starts) < purchase_year) {
      stop(sprintf(
        "Lease '%s': first event starts in %d before purchase_year = %d.",
        unit_label, min(starts), purchase_year
      ))
    }

    # Optional: disallow events that extend strictly beyond the simulation horizon
    horizon_end <- purchase_year + cfg$horizon_years
    if (max(ends) > horizon_end) {
      warning(sprintf(
        "Lease '%s': last event ends in %d beyond horizon end %d. ",
        unit_label, max(ends), horizon_end
      ))
    }

    # Enforce no overlaps and no gaps between successive events
    if (length(starts) > 1L) {
      gaps <- starts[-1L] - (ends[-length(ends)] + 1L)

      if (any(gaps > 0L)) {
        k <- which(gaps > 0L)[1L]
        stop(sprintf(
          paste0(
            "Lease '%s': gap detected between events %d and %d ",
            "(end = %d, next start = %d). ",
            "Encode vacancy as an explicit event with vac = 1 rather than leaving a hole."
          ),
          unit_label,
          k, k + 1L,
          ends[k], starts[k + 1L]
        ))
      }

      if (any(gaps < 0L)) {
        k <- which(gaps < 0L)[1L]
        stop(sprintf(
          paste0(
            "Lease '%s': overlapping events %d and %d ",
            "(end = %d, next start = %d)."
          ),
          unit_label,
          k, k + 1L,
          ends[k], starts[k + 1L]
        ))
      }
    }

    # Per-event numeric checks (kept close to your original logic)
    for (i in seq_along(events)) {
      e <- events[[i]]

      checkmate::assert_number(e$rent,       lower = 0, null.ok = TRUE)
      checkmate::assert_number(e$free_months, lower = 0, null.ok = TRUE)
      checkmate::assert_number(e$capex_sqm,  lower = 0, null.ok = TRUE)
      checkmate::assert_number(e$vac,        lower = 0, upper = 1, null.ok = TRUE)
      checkmate::assert_integerish(e$new_lease, null.ok = TRUE, len = 1)
    }

    list(
      first_start = min(starts),
      last_end    = max(ends)
    )
  }

  # ===========================================================================
  # Lease block: stronger structural checks on events timelines
  # ===========================================================================
  if (!is.null(cfg$leases)) {
    checkmate::assert_list(cfg$leases)

    max_end <- cfg$purchase_year

    for (u in cfg$leases) {
      # unit label is only for error messages, falls back to "unknown"
      unit_label <- u$unit %||% "unknown_unit"

      checkmate::assert_number(u$area, lower = 0)

      res <- validate_unit_events(
        events        = u$events,
        unit_label    = unit_label,
        purchase_year = cfg$purchase_year,
        horizon_years = cfg$horizon_years
      )

      # Update global max_end across all units
      max_end <- max(max_end, res$last_end)
    }

    # Compute required minimum horizon (same logic as before)
    required_horizon <- max_end - cfg$purchase_year

    if (cfg$horizon_years < required_horizon) {
      stop(sprintf(
        paste(
          "Invalid  Discounted Cash Flow (DCF) horizon: horizon_years = %d, but leases run until %d.",
          "Required minimum horizon = %d."
        ),
        cfg$horizon_years, max_end, required_horizon
      ))
    }
  }

  invisible(cfg)
}

# Safe helper for defaulting NULL
`%||%` <- function(x, y) if (is.null(x)) y else x



#' Normalize YAML into Discounted Cash Flow (DCF) and debt parameters
#'
#' @description
#' Converts a raw YAML configuration into a set of scalars and vectors
#' directly usable by `dcf_calculate()` and `debt_built_schedule()`.
#'
#' @param cfg list parsed from YAML (raw, not yet normalized).
#'
#' @return list including in particular:
#' \itemize{
#'   \item `disc_rate`, `exit_yield`, `exit_cost`,
#'   \item `acq_price_ht`, `acq_price_di`,
#'   \item `ltv_init`, `rate_annual`, `maturity`, `type`,
#'   \item `arrangement_fee_pct`, `capitalized_fees`,
#'   \item `noi_vec`, `opex_vec`, `capex_vec` (vectors of length `N`).
#' }
#' @export
cfg_normalize <- function(cfg) {
  market_inputs <- .engine_normalize_capital_market_inputs(cfg)
  operations <- .engine_project_operations(cfg)
  pricing <- .engine_resolve_pricing(
    cfg = cfg,
    operations = operations,
    market_inputs = market_inputs
  )

  .engine_flatten_normalized_case(
    market_inputs = market_inputs,
    operations = operations,
    pricing = pricing
  )
}



#' Read a configuration YAML
#' @param config_file path; default to inst/extdata/config.yml in the package.
#' @return list
#' @export
dcf_read_config <- function(
    config_file = system.file("extdata", "preset_default.yml", package = "cre.dcf")
) {
  checkmate::assert(
    "File must be readable",
    checkmate::check_file_exists(config_file),
    checkmate::check_true(isOpen(file(config_file)))
  )

  if (!file.exists(config_file)) {
    stop("Configuration file not found: ", config_file)
  }
  yaml::read_yaml(config_file)
}


#' Canonical pipeline from a YAML file
#' @param config_file path to YAML.
#' @param ltv_base "price_ht" | "price_di" | "value".
#' @return list(dcf, debt, full, ratios, norm)
#' @export
run_from_config <- function(config_file, ltv_base = c("price_ht", "price_di", "value")) {
  prepared <- .engine_prepare_case(
    config_file = config_file,
    ltv_base = ltv_base
  )
  executed <- .engine_execute_case(prepared, include_comparison = FALSE)

  list(
    dcf = executed$dcf,
    debt = executed$debt,
    full = executed$full,
    ratios = executed$ratios,
    norm = prepared$norm
  )
}

#' Serialize a validated configuration list to YAML
#'
#' @description
#' Validates a configuration list against the package grammar using
#' \code{cfg_validate()} and serializes it to a YAML file on disk.
#' This helper is intended for reproducibility and interoperability,
#' allowing a fully specified in-memory configuration to be persisted
#' and reused in subsequent runs or edited manually by users.
#'
#' @param config List specification following the package configuration grammar
#'   (typically created with \code{dcf_spec_template()} and possibly modified).
#' @param path Character scalar. Output file path where the YAML file is written
#'   (for example \code{"case.yml"}).
#'
#' @return
#' The input \code{path}, returned invisibly, to allow use in pipelines.
#'
#' @details
#' The function performs validation before writing to disk. If validation
#' fails, an error is raised and no file is written. The YAML output is a
#' direct serialization of the validated configuration list and therefore
#' preserves all fields, including nested structures.
#'
#' @examples
#' tmp <- tempfile(fileext = ".yml")
#' cfg <- dcf_spec_template()
#' cfg$entry_yield <- 0.06
#' as_yaml(cfg, tmp)
#' stopifnot(file.exists(tmp))
#'
#' @importFrom yaml write_yaml
#' @export
as_yaml <- function(config, path) {
  cfg_validate(config)
  yaml::write_yaml(config, path)
  invisible(path)
}

#' Report missing or inconsistent fields in a config list
#'
#' Runs lightweight checks aligned with \code{cfg_validate()} and returns a table
#' of issues, if any. This is a convenience wrapper for user-facing checks;
#' it does not replace \code{cfg_validate()}.
#'
#' @param config List configuration to inspect.
#' @return A tibble with columns \code{field}, \code{problem}, \code{hint}, or an
#'   empty tibble if no issues are detected.
#'
#' @examples
#' tib <- cfg_missing(list())
#' tib
#'
#' @export
cfg_missing <- function(config) {
  checkmate::assert_list(config)
  issues <- list()
  add <- function(field, problem, hint = NULL) {
    issues[[length(issues) + 1L]] <<- tibble::tibble(
      field = field, problem = problem, hint = hint %||% ""
    )
  }

  # Required top-level keys (per cfg_validate)
  req <- c(
    "purchase_year","horizon_years","index_rate","entry_yield",
    "acq_cost_rate","exit_yield_spread_bps","exit_cost",
    "disc_method","ltv_init","rate_annual",
    "extra_amort_pct","scr_ratio","opex_sqm"
  )
  for (k in req) if (is.null(config[[k]])) add(k, "missing", "Provide a numeric value.")

  # Discount method specific
  dm <- config$disc_method
  if (!is.null(dm)) {
    if (dm == "wacc") {
      w <- config$disc_rate_wacc
      if (is.null(w) || is.null(w$KE) || is.null(w$KD)) {
        add("disc_rate_wacc", "missing", "Provide KE and KD in [0,1].")
      }
    } else if (dm == "wacc_capm") {
      cr <- config$disc_rate_wacc_capm
      needed <- c("risk_free","beta","KD")
      if (is.null(cr) || any(vapply(needed, function(nm) is.null(cr[[nm]]), logical(1)))) {
        add("disc_rate_wacc_capm", "missing", "Provide risk_free, beta, KD (and mkt_return or mrp).")
      }
    } else if (dm == "risk_premium") {
      rp <- config$disc_rate_risk_premium
      if (is.null(rp) || is.null(rp$rf)) {
        add("disc_rate_risk_premium", "missing", "Provide rf and any premia.")
      }
    } else if (dm == "yield_plus_growth") {
      yg <- config$disc_rate_yield_plus_growth
      if (is.null(yg) || is.null(yg$property_yield) || is.null(yg$growth)) {
        add("disc_rate_yield_plus_growth", "missing", "Provide property_yield and growth.")
      }
    }
  }

  if (length(issues) == 0L) {
    return(tibble::tibble(field = character(), problem = character(), hint = character()))
  }
  dplyr::bind_rows(issues)
}

#' Serialize a validated configuration list to YAML
#'
#' Validates \code{config} and writes it to \code{path} as \code{'YAML'}.
#'
#' @param config List specification following the package grammar.
#' @param path Output file path (for example \code{"case.yml"}).
#' @return The input \code{path}, invisibly.
#' @importFrom yaml write_yaml
#'
#' @examples
#' cfg <- dcf_spec_template()
#' cfg$entry_yield <- 0.06
#' tmp <- tempfile(fileext = ".yml")
#' as_yaml(cfg, tmp)
#' stopifnot(file.exists(tmp))
#' unlink(tmp)
#'
#' @export
as_yaml <- function(config, path) {
  cfg_validate(config)
  yaml::write_yaml(config, path)
  invisible(path)
}


#' Explain effective parameters after normalization
#'
#' Produces a compact tibble that reports selected effective inputs used by the
#' engine after validation and normalization (see \code{cfg_normalize()}).
#'
#' @param config List configuration (not a file path).
#' @return A tibble with selected effective parameters and derived values.
#'
#' @examples
#' cfg <- dcf_spec_template()
#' cfg$acq_price_ht <- 1e6
#' ex <- cfg_explain(cfg)
#' str(ex)
#'
#' @export
cfg_explain <- function(config) {
  cfg_validate(config)
  norm <- cfg_normalize(config)
  tibble::tibble(
    param = c("disc_rate","exit_yield","exit_cost",
              "acq_price_ht","acq_price_di","ltv_init","rate_annual",
              "maturity","debt_type","arrangement_fee_pct","capitalized_fees",
              "noi_y1","opex_y1","capex_y1"),
    value = c(
      norm$disc_rate, norm$exit_yield, norm$exit_cost,
      norm$acq_price_ht, norm$acq_price_di, norm$ltv_init, norm$rate_annual,
      norm$maturity, (norm$type %||% NA_character_), norm$arrangement_fee_pct,
      isTRUE(norm$capitalized_fees),
      (norm$noi_vec %||% numeric(1))[1],
      (norm$opex_vec %||% numeric(1))[1],
      (norm$capex_vec %||% numeric(1))[1]
    )
  )

}

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.