--- title: "Getting started: invariants on the run_case() object" output: rmarkdown::html_vignette: toc: true number_sections: true vignette: > %\VignetteIndexEntry{Getting started: invariants on the run_case() object} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include=FALSE} knitr::opts_chunk$set(echo = TRUE, message = FALSE, warning = FALSE) ``` ## Purpose The objective of this vignette is to provide a transparent and reproducible walkthrough of the internal mechanics of a *Discounted Cash Flow* (DCF) simulation as implemented in the `cre.dcf` package. Rather than focusing on the investment output itself, this document serves as a **didactic exploration of model identities** - the internal accounting relationships and mathematical consistencies that ensure the financial logic of a DCF model is sound. The vignette therefore acts as a *conceptual testbench* for doctoral or professional audiences interested in the structure of CRE valuation models, combining both computational validation and economic interpretation. More specifically, it demonstrates how the function `run_case()` produces a complete and self-contained case object, and how several key invariants can be verified on that object: 1. **Temporal consistency** - a single exit event occurs exactly at the end of the investment horizon, ensuring that terminal value is not double-counted. 2. **Net Present Value (NPV) identity** - the NPV computed manually from discounted cash flows matches the value reported by the model, within numerical tolerance. 3. **Internal Rate of Return (IRR) identity** - the IRR derived by solving the NPV equation equals the IRR reported by the package, verifying the link between NPV and IRR definitions. 4. **Discounting logic** - the discount factors used in the model evolve monotonically and are consistent with the accumulation factors applied internally. 5. **Accounting and pricing sanity checks** - key quantities such as *Net Operating Income (NOI)* and acquisition price are positive and coherent across data tables. 6. **Synthesis of results** - comparison between *unlevered* (project-level) and *levered* (equity-level) indicators illustrates the effect of financial leverage on performance metrics. Each section mirrors a fundamental identity or control equation that professional valuation models must satisfy. By executing the vignette, the user not only confirms the internal coherence of `cre.dcf`, but also **learns how each financial identity can be derived and verified** directly from the output data, without relying on opaque helper functions. In an academic context, this vignette can be used to: - teach the conceptual underpinnings of DCF modelling (NPV–IRR equivalence, discount factor dynamics, exit timing), - illustrate how computational reproducibility supports transparency in financial geography or real-estate finance research, - serve as a template for developing *validation notebooks* that accompany empirical models. In sum, this vignette transforms a basic test of numerical consistency into a **learning exercise on the epistemology of valuation**: it makes explicit the quantitative structure that underlies every DCF calculation - how time, cash flow, and capital are formally related through the principle of present value. ## 1. Build a case ```{r} library(cre.dcf) library(yaml) library(dplyr) path <- system.file("extdata", "preset_default.yml", package = "cre.dcf") stopifnot(nzchar(path)) cfg <- yaml::read_yaml(path) case <- run_case(cfg) ae <- case$all_equity al <- case$leveraged cf_all <- case$cashflows stopifnot( is.list(ae), is.list(al), is.data.frame(ae$cashflows), is.data.frame(al$cashflows), is.data.frame(cf_all) ) cfe <- ae$cashflows stopifnot(all(c("year", "free_cash_flow", "sale_proceeds") %in% names(cfe))) ``` ## 2. Exit occurs once at the final year (all-equity) A critical temporal invariant in single-asset DCF models is the uniqueness and timing of the exit event. For the all-equity case, sale proceeds should appear once and only once, in the final year of the projection. ```{r} ## 2. Exit occurs once at the final year (all-equity) t <- cfe$year exit_rows <- which(cfe$sale_proceeds > 0) # Checks: a single exit, and it occurs at the last period stopifnot(length(exit_rows) == 1L) stopifnot(exit_rows == which.max(t)) # ---- Display results for pedagogical clarity ---- exit_year <- t[exit_rows] sale_value <- cfe$sale_proceeds[exit_rows] free_cf_exit <- cfe$free_cash_flow[exit_rows] cat( "\nExit event diagnostics:\n", sprintf("• Number of exit events detected: %d (should be 1)\n", length(exit_rows)), sprintf("• Exit year (expected last period): %d\n", exit_year), sprintf("• Sale proceeds at exit: %s\n", formatC(sale_value, format = 'f', big.mark = " ")), sprintf("• Free cash flow in the exit year (before sale): %s\n", formatC(free_cf_exit, format = 'f', big.mark = " ")), sprintf("• Maximum year in series: %d\n", max(t)), if (exit_year == max(t)) "✓ Exit correctly occurs in the final year.\n" else "✗ Exit NOT in final year - investigate configuration.\n" ) ``` This test guarantees that the terminal value is not double-counted and that all intermediate cash flows are properly separated from the exit event. ## 3. IRR identity (all-equity): IRR is the root of NPV = 0 By definition, the IRR is the discount rate that sets the NPV of the cash-flow stream to zero. This section reconstructs that relationship explicitly and verifies that the IRR computed via root finding coincides with the IRR reported by run_case(). ```{r} ## 4. IRR identity (all-equity): verifying that IRR is the root of NPV = 0 # 4.1 Build cash-flow vector (t = year) stopifnot(is.integer(cfe$year) || is.numeric(cfe$year)) stopifnot(min(cfe$year) == 0) # ensure the time origin is correct flows <- cfe$free_cash_flow last <- which.max(cfe$year) # Add sale proceeds to the last period's free cash flow flows[last] <- flows[last] + cfe$sale_proceeds[last] npv_at <- function(r) { sum(flows / (1 + r)^(cfe$year)) } # 4.2 Detect automatically a valid interval where NPV changes sign grid <- seq(-0.9, 2.0, by = 0.01) vals <- sapply(grid, npv_at) sgn <- sign(vals) idx <- which(diff(sgn) != 0) stopifnot(length(idx) >= 1L) lower <- grid[idx[1]] upper <- grid[idx[1] + 1] # 4.3 Root finding with numerical control (reference IRR based on this vignette's convention) irr_root <- uniroot( npv_at, c(lower, upper), tol = .Machine$double.eps^0.5 )$root # 4.4 Checks: # (A) NPV(irr_root) ≈ 0 [hard invariance: must hold] # (B) NPV(ae$irr_project) reported for information only tol_cash <- 1e-2 # acceptable deviation in currency units npv_at_root <- npv_at(irr_root) npv_at_report <- npv_at(ae$irr_project) # Hard check on the IRR computed in this vignette stopifnot(abs(npv_at_root) <= tol_cash) # Informative diagnostics on the package's reported IRR gap_rate <- abs(irr_root - ae$irr_project) status_report <- if (is.finite(npv_at_report) && abs(npv_at_report) <= tol_cash) { "✓ Reported IRR behaves as a root of the NPV equation under this cash-flow convention." } else { paste0( "⚠ Reported IRR does not exactly solve NPV = 0 under this vignette's convention.\n", " This may reflect different timing or cash-flow conventions in the internal implementation." ) } # ---- Pedagogical printout ---- cat( "\nIRR identity diagnostic (all-equity case):\n", sprintf("• Interval used for root search: [%.2f, %.2f]\n", lower, upper), sprintf("• Computed IRR from cash-flow root: %.8f\n", irr_root), sprintf("• Reported IRR from run_case(): %.8f\n", ae$irr_project), sprintf("• Absolute rate gap (for information): %.10f\n", gap_rate), sprintf("• NPV evaluated at computed IRR: %.4f (tolerance %.2f)\n", npv_at_root, tol_cash), sprintf("• NPV evaluated at reported IRR: %.4f\n", npv_at_report), "\n", status_report, "\n" ) # Optional: tabular summary for visual output data.frame( irr_computed = irr_root, irr_reported = ae$irr_project, npv_at_irr_computed = npv_at_root, npv_at_irr_reported = npv_at_report ) ``` This block operationalises the textbook definition of IRR and checks that the implementation respects it. ## 5. Discount factor monotonicity DCF models rely on an implicit or explicit discount factor sequence. Here, the column df is interpreted as an accumulation factor (roughly $$ (1+r)t (1+r) t $$ ), and its inverse as the actual discount factor. The monotonicity of this inverse sequence is a simple but powerful diagnostic of time-value-of-money consistency. ```{r} ## 5. Discount factor consistency and interpretation stopifnot("df" %in% names(cf_all)) df <- cf_all$df df <- df[is.finite(df)] # In this package, `df` increases over time (≈ (1 + r)^t), # so its inverse is the true discount factor. disc_factor <- 1 / df # Theoretical properties of the discount sequence stopifnot(abs(disc_factor[1] - 1) < 1e-12) # t = 0 --> discount factor = 1 stopifnot(all(diff(disc_factor) <= 1e-10)) # should be non-increasing # Summary metrics for transparency rate_estimate <- (df[length(df)]^(1 / (length(df) - 1))) - 1 decay_ratio <- disc_factor[length(disc_factor)] / disc_factor[1] # ---- Pedagogical printout ---- cat( "\nDiscount factor diagnostics:\n", sprintf("• First value of df (t = 0): %.6f\n", df[1]), sprintf("• Last value of df (t = %d): %.6f\n", length(df) - 1, tail(df, 1)), sprintf("• Implied constant annual rate ≈ %.4f%%\n", 100 * rate_estimate), sprintf("• Discount factor at t = %d: %.6f\n", length(disc_factor) - 1, tail(disc_factor, 1)), sprintf("• Ratio (disc_t_end / disc_t0): %.6f\n", decay_ratio), if (all(diff(disc_factor) <= 1e-10)) "✓ Discount factors decrease monotonically - internal consistency confirmed.\n" else "✗ Discount factors not monotonic - check time indexing or rate definition.\n" ) # Display a concise comparative table for reader visibility knitr::kable( data.frame( year = cf_all$year, df = round(df, 6), discount_factor = round(disc_factor, 6) ), caption = "Evolution of accumulation and discount factors across time" ) ``` ## 6. Sanity checks Beyond identities, simple sanity checks help detect gross specification errors (negative NOI, inconsistent acquisition prices, etc.). This section implements such basic controls. ```{r} ## 6. Sanity checks and diagnostic printout # (a) NOI finiteness and range stopifnot("noi" %in% names(cf_all)) min_noi <- min(cf_all$noi, na.rm = TRUE) max_noi <- max(cf_all$noi, na.rm = TRUE) stopifnot(is.finite(min_noi), is.finite(max_noi)) # (b) Positive acquisition price (price_di) price_di <- case$pricing$price_di stopifnot(is.numeric(price_di), length(price_di) == 1L, price_di > 0) # (c) Acquisition price consistency between pricing and cashflow tables stopifnot("acquisition_price" %in% names(cfe)) price_cf <- cfe$acquisition_price[1] gap_price <- abs(price_di - price_cf) stopifnot(gap_price < 1e-6) # ---- Display results for transparency ---- cat( "\nSanity checks summary:\n", sprintf("• NOI range: [%s, %s]\n", formatC(min_noi, format = 'f', big.mark = " "), formatC(max_noi, format = 'f', big.mark = " ")), sprintf("• Reported acquisition price (pricing$price_di): %s\n", formatC(price_di, format = 'f', big.mark = " ")), sprintf("• Acquisition price at t0 in cashflows: %s\n", formatC(price_cf, format = 'f', big.mark = " ")), sprintf("• Absolute gap between the two: %.8f (tolerance 1e-6)\n", gap_price), if (min_noi < 0) "• Note: NOI dips below zero in some periods - consistent with transitional or opportunistic strategies, but deserves economic interpretation.\n" else "• Note: NOI remains non-negative over the horizon.\n" ) ``` ## 7. Display a compact summary Finally, a compact summary brings together the main unlevered and levered indicators for the base case. This provides a quick diagnostic of the leverage effect and of overall value creation at the chosen discount rate. ```{r} ## 7. Compact financial summary summary_tbl <- data.frame( Metric = c( "Unlevered IRR (project)", "Unlevered NPV (project, currency units)", "Equity IRR (levered case)", "Equity NPV (levered case, currency units)", "Acquisition price (price_di)" ), Value = c( ae$irr_project, ae$npv_project, al$irr_equity, al$npv_equity, case$pricing$price_di ) ) # Pedagogical printout with interpretation cat( "\n--- Summary of DCF core results ---\n", sprintf("• Unlevered IRR (project): %.4f%%\n", 100 * ae$irr_project), sprintf("• Unlevered NPV (project): %s\n", formatC(ae$npv_project, format = 'f', big.mark = " ")), sprintf("• Levered IRR (equity): %.4f%%\n", 100 * al$irr_equity), sprintf("• Levered NPV (equity): %s\n", formatC(al$npv_equity, format = 'f', big.mark = " ")), sprintf("• Acquisition price (price_di): %s\n", formatC(case$pricing$price_di, format = 'f', big.mark = " ")), "\nInterpretation:\n", " - The unlevered IRR reflects the intrinsic profitability of the asset before financing.\n", " - The levered IRR measures the equity return after accounting for debt leverage.\n", " - The gap between both IRRs quantifies the effect of financial leverage on expected return.\n", " - NPV values in currency units provide absolute measures of value creation at the chosen discount rate.\n" ) knitr::kable( summary_tbl, caption = "Key DCF performance metrics for the base case (unlevered and levered)" ) ```