use crate::compat::Instant;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use crate::error::CorpFinanceError;
use crate::types::{with_metadata, ComputationOutput, Money, Rate};
use crate::CorpFinanceResult;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DebtTranche {
pub name: String,
pub amount: Money,
pub interest_rate: Rate,
pub term_years: u32,
pub amortization_years: Option<u32>,
pub io_period_years: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GoNoGoDecision {
Go,
Conditional { failed_thresholds: Vec<String> },
NoGo { failed_thresholds: Vec<String> },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProFormaYear {
pub year: u32,
pub noi: Money,
pub debt_service: Money,
pub cash_flow_after_debt: Money,
pub cash_on_cash: Rate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AcquisitionModelInput {
pub purchase_price: Money,
pub closing_costs: Money,
pub capex_reserves: Money,
pub noi_year1: Money,
pub noi_growth_rate: Rate,
pub hold_period_years: u32,
pub exit_cap_rate: Rate,
pub disposition_cost_rate: Rate,
pub debt_tranches: Vec<DebtTranche>,
pub discount_rate: Rate,
pub target_irr: Option<Rate>,
pub target_dscr: Option<Rate>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AcquisitionModelOutput {
pub total_uses: Money,
pub total_debt: Money,
pub equity_required: Money,
pub going_in_cap_rate: Rate,
pub pro_forma: Vec<ProFormaYear>,
pub exit_sale_price: Money,
pub net_sale_proceeds: Money,
pub levered_irr: Rate,
pub unlevered_irr: Rate,
pub equity_multiple: Decimal,
pub dscr_year1: Decimal,
pub decision: GoNoGoDecision,
}
pub fn acquisition_model(
input: &AcquisitionModelInput,
) -> CorpFinanceResult<ComputationOutput<AcquisitionModelOutput>> {
let start = Instant::now();
let mut warnings: Vec<String> = Vec::new();
validate_cap_rate(input.exit_cap_rate, "exit_cap_rate")?;
if input.purchase_price <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "purchase_price".into(),
reason: "Purchase price must be positive".into(),
});
}
if input.hold_period_years < 1 {
return Err(CorpFinanceError::InvalidInput {
field: "hold_period_years".into(),
reason: "Hold period must be at least 1 year".into(),
});
}
if input.noi_year1 <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "noi_year1".into(),
reason: "Year-1 NOI must be positive".into(),
});
}
let total_uses = input.purchase_price + input.closing_costs + input.capex_reserves;
let total_debt: Money = input.debt_tranches.iter().map(|t| t.amount).sum();
let equity_required = total_uses - total_debt;
if equity_required <= Decimal::ZERO {
warnings.push("Equity required is zero or negative — over-leveraged".into());
}
let going_in_cap_rate = input.noi_year1 / input.purchase_price;
let n = input.hold_period_years as usize;
let tranche_ds = build_tranche_debt_service(&input.debt_tranches, n, &mut warnings)?;
let mut pro_forma = Vec::with_capacity(n);
let mut noi = input.noi_year1;
for yr in 0..n {
if yr > 0 {
noi *= Decimal::ONE + input.noi_growth_rate;
}
let ds: Money = tranche_ds.iter().map(|t| t[yr]).sum();
let cf_after_debt = noi - ds;
let coc = if equity_required.is_zero() || equity_required < Decimal::ZERO {
Decimal::ZERO
} else {
cf_after_debt / equity_required
};
pro_forma.push(ProFormaYear {
year: (yr + 1) as u32,
noi,
debt_service: ds,
cash_flow_after_debt: cf_after_debt,
cash_on_cash: coc,
});
}
let exit_noi = noi * (Decimal::ONE + input.noi_growth_rate); let exit_sale_price = exit_noi / input.exit_cap_rate;
let disposition_costs = exit_sale_price * input.disposition_cost_rate;
let total_remaining_debt = compute_total_remaining_debt(&input.debt_tranches, n)?;
let net_sale_proceeds = exit_sale_price - disposition_costs - total_remaining_debt;
let ds_year1: Money = tranche_ds.iter().map(|t| t[0]).sum();
let dscr_year1 = if ds_year1.is_zero() {
dec!(999.99)
} else {
input.noi_year1 / ds_year1
};
if dscr_year1 < dec!(1.2) && dscr_year1 < dec!(999) {
warnings.push(format!(
"Year-1 DSCR of {dscr_year1:.2}x is below 1.20x — lender covenant risk"
));
}
let mut lev_cfs = Vec::with_capacity(n + 1);
lev_cfs.push(-equity_required);
for (i, pf) in pro_forma.iter().enumerate() {
if i == n - 1 {
lev_cfs.push(pf.cash_flow_after_debt + net_sale_proceeds);
} else {
lev_cfs.push(pf.cash_flow_after_debt);
}
}
let levered_irr = newton_raphson_irr(&lev_cfs, &mut warnings);
let mut unlev_cfs = Vec::with_capacity(n + 1);
unlev_cfs.push(-total_uses);
for (i, pf) in pro_forma.iter().enumerate() {
if i == n - 1 {
unlev_cfs.push(pf.noi + exit_sale_price - disposition_costs);
} else {
unlev_cfs.push(pf.noi);
}
}
let unlevered_irr = newton_raphson_irr(&unlev_cfs, &mut warnings);
let total_distributions: Money = pro_forma
.iter()
.map(|pf| pf.cash_flow_after_debt)
.sum::<Decimal>()
+ net_sale_proceeds;
let equity_multiple = if equity_required.is_zero() || equity_required < Decimal::ZERO {
Decimal::ZERO
} else {
total_distributions / equity_required
};
let mut failed = Vec::new();
if let Some(target_irr) = input.target_irr {
if levered_irr < target_irr {
failed.push(format!(
"Levered IRR {:.2}% < target {:.2}%",
levered_irr * dec!(100),
target_irr * dec!(100)
));
}
}
if let Some(target_dscr) = input.target_dscr {
if dscr_year1 < target_dscr {
failed.push(format!("DSCR {dscr_year1:.2}x < target {target_dscr:.2}x"));
}
}
let decision = if failed.is_empty() {
GoNoGoDecision::Go
} else if failed.len() == 1 {
GoNoGoDecision::Conditional {
failed_thresholds: failed,
}
} else {
GoNoGoDecision::NoGo {
failed_thresholds: failed,
}
};
let output = AcquisitionModelOutput {
total_uses,
total_debt,
equity_required,
going_in_cap_rate,
pro_forma,
exit_sale_price,
net_sale_proceeds,
levered_irr,
unlevered_irr,
equity_multiple,
dscr_year1,
decision,
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"Institutional Real Estate Acquisition Model",
input,
warnings,
elapsed,
output,
))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HoldSellInput {
pub current_value: Money,
pub current_noi: Money,
pub noi_growth_rate: Rate,
pub remaining_hold_years: u32,
pub exit_cap_rate: Rate,
pub disposition_cost_rate: Rate,
pub remaining_debt: Money,
pub annual_debt_service: Money,
pub discount_rate: Rate,
pub original_equity: Money,
pub years_held: u32,
pub max_additional_years: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HoldSellOutput {
pub hold_npv: Money,
pub sell_npv: Money,
pub npv_advantage_of_holding: Money,
pub breakeven_exit_cap_rate: Option<Rate>,
pub optimal_hold_period_years: u32,
pub optimal_irr: Rate,
}
pub fn hold_sell_analysis(
input: &HoldSellInput,
) -> CorpFinanceResult<ComputationOutput<HoldSellOutput>> {
let start = Instant::now();
let mut warnings: Vec<String> = Vec::new();
validate_cap_rate(input.exit_cap_rate, "exit_cap_rate")?;
if input.discount_rate <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "discount_rate".into(),
reason: "Discount rate must be positive".into(),
});
}
if input.remaining_hold_years < 1 {
return Err(CorpFinanceError::InvalidInput {
field: "remaining_hold_years".into(),
reason: "Remaining hold years must be at least 1".into(),
});
}
let sell_proceeds = input.current_value
- input.current_value * input.disposition_cost_rate
- input.remaining_debt;
let sell_npv = sell_proceeds;
let n = input.remaining_hold_years as usize;
let mut noi = input.current_noi;
let mut hold_pv = Decimal::ZERO;
let one_plus_r = Decimal::ONE + input.discount_rate;
let mut df = Decimal::ONE;
for yr in 0..n {
if yr > 0 {
noi *= Decimal::ONE + input.noi_growth_rate;
}
let cf = noi - input.annual_debt_service;
df /= one_plus_r;
hold_pv += cf * df;
}
let exit_noi = noi * (Decimal::ONE + input.noi_growth_rate);
let terminal_value = exit_noi / input.exit_cap_rate;
let exit_disposition = terminal_value * input.disposition_cost_rate;
let terminal_net = terminal_value - exit_disposition - input.remaining_debt;
let hold_npv = hold_pv + terminal_net * df;
let npv_advantage = hold_npv - sell_npv;
if npv_advantage < Decimal::ZERO {
warnings.push("Sell NPV exceeds Hold NPV — consider selling".into());
}
let breakeven_exit_cap_rate = find_breakeven_cap_rate(input, sell_npv, &mut warnings);
let max_search = input
.max_additional_years
.unwrap_or(15)
.max(input.remaining_hold_years);
let mut best_irr = dec!(-1.0);
let mut best_year = 1u32;
for test_years in 1..=max_search {
let irr = compute_hold_irr(input, test_years as usize, &mut warnings);
if irr > best_irr {
best_irr = irr;
best_year = test_years;
}
}
let output = HoldSellOutput {
hold_npv,
sell_npv,
npv_advantage_of_holding: npv_advantage,
breakeven_exit_cap_rate,
optimal_hold_period_years: best_year,
optimal_irr: best_irr,
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"Hold vs. Sell Analysis (RE-CONTRACT-006)",
input,
warnings,
elapsed,
output,
))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValueAddIrrInput {
pub acquisition_cost: Money,
pub renovation_capex: Vec<Money>,
pub equity_at_close: Money,
pub current_occupancy: Rate,
pub stabilised_occupancy: Rate,
pub lease_up_years: u32,
pub stabilised_gpi: Money,
pub opex_ratio: Rate,
pub hold_period_years: u32,
pub exit_cap_rate: Rate,
pub disposition_cost_rate: Rate,
pub noi_growth_rate: Rate,
pub promote_rate: Option<Rate>,
pub debt: Option<DebtTranche>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValueAddIrrOutput {
pub gross_irr: Rate,
pub net_irr: Rate,
pub equity_multiple: Decimal,
pub peak_equity: Money,
pub return_on_cost: Rate,
pub stabilised_noi: Money,
pub noi_schedule: Vec<Money>,
}
pub fn value_add_irr(
input: &ValueAddIrrInput,
) -> CorpFinanceResult<ComputationOutput<ValueAddIrrOutput>> {
let start = Instant::now();
let mut warnings: Vec<String> = Vec::new();
validate_cap_rate(input.exit_cap_rate, "exit_cap_rate")?;
if input.hold_period_years < 1 {
return Err(CorpFinanceError::InvalidInput {
field: "hold_period_years".into(),
reason: "Hold period must be at least 1 year".into(),
});
}
if input.stabilised_occupancy <= Decimal::ZERO || input.stabilised_occupancy > Decimal::ONE {
return Err(CorpFinanceError::InvalidInput {
field: "stabilised_occupancy".into(),
reason: "Stabilised occupancy must be between 0 (excl) and 1 (incl)".into(),
});
}
let n = input.hold_period_years as usize;
let lease_up = input.lease_up_years as usize;
let mut noi_schedule = Vec::with_capacity(n);
let stabilised_egi = input.stabilised_gpi * input.stabilised_occupancy;
let stabilised_noi = stabilised_egi * (Decimal::ONE - input.opex_ratio);
let mut prev_noi = Decimal::ZERO;
for yr in 0..n {
let occupancy = if lease_up == 0 || yr >= lease_up {
input.stabilised_occupancy
} else {
let frac = Decimal::from((yr + 1) as u32) / Decimal::from(lease_up as u32);
input.current_occupancy + (input.stabilised_occupancy - input.current_occupancy) * frac
};
let egi = input.stabilised_gpi * occupancy;
let mut year_noi = egi * (Decimal::ONE - input.opex_ratio);
if yr >= lease_up && yr > 0 && prev_noi > Decimal::ZERO {
if yr == lease_up {
year_noi = stabilised_noi;
} else {
year_noi = prev_noi * (Decimal::ONE + input.noi_growth_rate);
}
}
prev_noi = year_noi;
noi_schedule.push(year_noi);
}
let total_renovation: Money = input.renovation_capex.iter().copied().sum();
let total_cost = input.acquisition_cost + total_renovation;
let return_on_cost = if total_cost.is_zero() {
Decimal::ZERO
} else {
stabilised_noi / total_cost
};
let (_annual_ds, debt_balance_at_exit) = match &input.debt {
Some(d) => {
let ds = compute_tranche_annual_ds(d, 0)?; let bal = compute_tranche_balance(d, n)?;
(ds, bal)
}
None => (Decimal::ZERO, Decimal::ZERO),
};
let mut gross_cfs = Vec::with_capacity(n + 1);
gross_cfs.push(-input.equity_at_close);
let mut cumulative_equity = input.equity_at_close;
let mut peak_equity = cumulative_equity;
for (yr, &noi_yr) in noi_schedule.iter().enumerate().take(n) {
let capex = if yr < input.renovation_capex.len() && yr > 0 {
input.renovation_capex[yr]
} else {
Decimal::ZERO
};
if capex > Decimal::ZERO {
cumulative_equity += capex;
if cumulative_equity > peak_equity {
peak_equity = cumulative_equity;
}
}
let ds = match &input.debt {
Some(d) => compute_tranche_annual_ds(d, yr)?,
None => Decimal::ZERO,
};
let cf = noi_yr - ds - capex;
if yr == n - 1 {
let exit_noi = if n > lease_up {
prev_noi * (Decimal::ONE + input.noi_growth_rate)
} else {
stabilised_noi
};
let sale_price = exit_noi / input.exit_cap_rate;
let disp_cost = sale_price * input.disposition_cost_rate;
let net_exit = sale_price - disp_cost - debt_balance_at_exit;
gross_cfs.push(cf + net_exit);
} else {
gross_cfs.push(cf);
}
}
let gross_irr = newton_raphson_irr(&gross_cfs, &mut warnings);
let promote = input.promote_rate.unwrap_or(Decimal::ZERO);
let net_cfs: Vec<Decimal> = gross_cfs
.iter()
.enumerate()
.map(|(i, &cf)| {
if i == 0 || cf <= Decimal::ZERO {
cf
} else {
cf * (Decimal::ONE - promote)
}
})
.collect();
let net_irr = newton_raphson_irr(&net_cfs, &mut warnings);
let total_distributions: Money = gross_cfs.iter().skip(1).copied().sum();
let equity_multiple = if input.equity_at_close.is_zero() {
Decimal::ZERO
} else {
total_distributions / input.equity_at_close
};
if return_on_cost < input.exit_cap_rate {
warnings.push(format!(
"Return on cost {:.2}% < exit cap rate {:.2}% — value-add may not create spread",
return_on_cost * dec!(100),
input.exit_cap_rate * dec!(100)
));
}
let output = ValueAddIrrOutput {
gross_irr,
net_irr,
equity_multiple,
peak_equity,
return_on_cost,
stabilised_noi,
noi_schedule,
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"Value-Add IRR Analysis",
input,
warnings,
elapsed,
output,
))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DevelopmentFeasibilityInput {
pub land_cost: Money,
pub hard_costs: Money,
pub soft_cost_pct: Rate,
pub construction_months: u32,
pub draw_schedule_pct: Vec<Rate>,
pub construction_loan_rate: Rate,
pub lease_up_months: u32,
pub stabilised_noi: Money,
pub market_cap_rate: Rate,
pub target_profit_margin: Option<Rate>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DevelopmentFeasibilityOutput {
pub soft_costs: Money,
pub total_construction_costs: Money,
pub financing_carry: Money,
pub total_development_cost: Money,
pub development_yield: Rate,
pub development_spread: Rate,
pub stabilised_value: Money,
pub residual_land_value: Money,
pub profit_margin: Rate,
pub decision: GoNoGoDecision,
}
pub fn development_feasibility(
input: &DevelopmentFeasibilityInput,
) -> CorpFinanceResult<ComputationOutput<DevelopmentFeasibilityOutput>> {
let start = Instant::now();
let mut warnings: Vec<String> = Vec::new();
validate_cap_rate(input.market_cap_rate, "market_cap_rate")?;
if input.hard_costs <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "hard_costs".into(),
reason: "Hard costs must be positive".into(),
});
}
if input.construction_months == 0 {
return Err(CorpFinanceError::InvalidInput {
field: "construction_months".into(),
reason: "Construction period must be at least 1 month".into(),
});
}
let soft_costs = input.hard_costs * input.soft_cost_pct;
let total_construction_costs = input.hard_costs + soft_costs;
let periods = input.construction_months as usize;
let draw_pcts: Vec<Decimal> = if input.draw_schedule_pct.len() == periods {
input.draw_schedule_pct.clone()
} else {
let pct_per_month = Decimal::ONE / Decimal::from(periods as u32);
vec![pct_per_month; periods]
};
let monthly_rate = input.construction_loan_rate / dec!(12);
let mut outstanding = Decimal::ZERO;
let mut total_interest = Decimal::ZERO;
for pct in &draw_pcts {
let draw = total_construction_costs * *pct;
outstanding += draw;
let interest = outstanding * monthly_rate;
total_interest += interest;
}
for _ in 0..input.lease_up_months {
let interest = outstanding * monthly_rate;
total_interest += interest;
}
let financing_carry = total_interest;
let total_development_cost = input.land_cost + total_construction_costs + financing_carry;
let development_yield = if total_development_cost.is_zero() {
Decimal::ZERO
} else {
input.stabilised_noi / total_development_cost
};
let development_spread = development_yield - input.market_cap_rate;
if development_spread < Decimal::ZERO {
warnings.push(format!(
"Negative development spread ({:.2}%) — project may not justify risk",
development_spread * dec!(100)
));
}
let stabilised_value = input.stabilised_noi / input.market_cap_rate;
let residual_land_value = stabilised_value - total_construction_costs - financing_carry;
let profit_margin = if total_development_cost.is_zero() {
Decimal::ZERO
} else {
(stabilised_value - total_development_cost) / total_development_cost
};
if profit_margin < dec!(0.15) {
warnings.push(format!(
"Profit margin {:.1}% is below 15% — thin for development risk",
profit_margin * dec!(100)
));
}
let mut failed = Vec::new();
if let Some(target) = input.target_profit_margin {
if profit_margin < target {
failed.push(format!(
"Profit margin {:.1}% < target {:.1}%",
profit_margin * dec!(100),
target * dec!(100)
));
}
}
if development_spread < dec!(0.01) {
failed.push(format!(
"Development spread {:.2}% < 100 bps",
development_spread * dec!(100)
));
}
let decision = if failed.is_empty() {
GoNoGoDecision::Go
} else if failed.len() == 1 {
GoNoGoDecision::Conditional {
failed_thresholds: failed,
}
} else {
GoNoGoDecision::NoGo {
failed_thresholds: failed,
}
};
let output = DevelopmentFeasibilityOutput {
soft_costs,
total_construction_costs,
financing_carry,
total_development_cost,
development_yield,
development_spread,
stabilised_value,
residual_land_value,
profit_margin,
decision,
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"Development Feasibility Analysis",
input,
warnings,
elapsed,
output,
))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RefinancingInput {
pub property_value: Money,
pub current_noi: Money,
pub existing_balance: Money,
pub existing_rate: Rate,
pub existing_remaining_years: u32,
pub existing_amort_years: Option<u32>,
pub proposed_amount: Money,
pub proposed_rate: Rate,
pub proposed_term_years: u32,
pub proposed_amort_years: Option<u32>,
pub prepayment_penalty: Money,
pub closing_costs: Money,
pub discount_rate: Rate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RefinancingOutput {
pub existing_annual_ds: Money,
pub proposed_annual_ds: Money,
pub annual_interest_savings: Money,
pub npv_of_savings: Money,
pub total_refi_cost: Money,
pub breakeven_months: u32,
pub post_refi_ltv: Rate,
pub post_refi_dscr: Decimal,
pub debt_yield: Rate,
pub cash_out_amount: Money,
pub recommend_refi: bool,
}
pub fn refinancing(
input: &RefinancingInput,
) -> CorpFinanceResult<ComputationOutput<RefinancingOutput>> {
let start = Instant::now();
let mut warnings: Vec<String> = Vec::new();
if input.property_value <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "property_value".into(),
reason: "Property value must be positive".into(),
});
}
if input.existing_remaining_years == 0 {
return Err(CorpFinanceError::InvalidInput {
field: "existing_remaining_years".into(),
reason: "Remaining years must be at least 1".into(),
});
}
let existing_annual_ds = compute_annual_debt_service(
input.existing_balance,
input.existing_rate,
input.existing_amort_years,
)?;
let proposed_annual_ds = compute_annual_debt_service(
input.proposed_amount,
input.proposed_rate,
input.proposed_amort_years,
)?;
let annual_interest_savings = existing_annual_ds - proposed_annual_ds;
let analysis_years = input
.existing_remaining_years
.min(input.proposed_term_years);
let one_plus_r = Decimal::ONE + input.discount_rate;
let mut npv_savings = Decimal::ZERO;
let mut df = Decimal::ONE;
for _ in 0..analysis_years {
df /= one_plus_r;
npv_savings += annual_interest_savings * df;
}
let total_refi_cost = input.prepayment_penalty + input.closing_costs;
let monthly_savings = annual_interest_savings / dec!(12);
let breakeven_months = if monthly_savings <= Decimal::ZERO {
u32::MAX } else {
let months_dec = total_refi_cost / monthly_savings;
let truncated = months_dec.trunc();
if months_dec > truncated {
(truncated + Decimal::ONE)
.to_string()
.parse::<u32>()
.unwrap_or(u32::MAX)
} else {
truncated.to_string().parse::<u32>().unwrap_or(u32::MAX)
}
};
let post_refi_ltv = input.proposed_amount / input.property_value;
let post_refi_dscr = if proposed_annual_ds.is_zero() {
dec!(999.99)
} else {
input.current_noi / proposed_annual_ds
};
let debt_yield = if input.proposed_amount.is_zero() {
Decimal::ZERO
} else {
input.current_noi / input.proposed_amount
};
let cash_out_amount = input.proposed_amount - input.existing_balance - total_refi_cost;
if post_refi_ltv > dec!(0.75) {
warnings.push(format!(
"Post-refi LTV {:.1}% exceeds 75%",
post_refi_ltv * dec!(100)
));
}
if post_refi_dscr < dec!(1.25) && post_refi_dscr < dec!(999) {
warnings.push(format!("Post-refi DSCR {post_refi_dscr:.2}x below 1.25x"));
}
if breakeven_months > 36 {
warnings.push(format!(
"Break-even period of {breakeven_months} months exceeds 3 years"
));
}
let recommend_refi = npv_savings > total_refi_cost && breakeven_months < (analysis_years * 12);
let output = RefinancingOutput {
existing_annual_ds,
proposed_annual_ds,
annual_interest_savings,
npv_of_savings: npv_savings,
total_refi_cost,
breakeven_months,
post_refi_ltv,
post_refi_dscr,
debt_yield,
cash_out_amount,
recommend_refi,
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"Refinancing Analysis",
input,
warnings,
elapsed,
output,
))
}
fn validate_cap_rate(cap_rate: Rate, field: &str) -> CorpFinanceResult<()> {
if cap_rate <= Decimal::ZERO || cap_rate >= Decimal::ONE {
return Err(CorpFinanceError::InvalidInput {
field: field.into(),
reason: "Cap rate must be positive and less than 1.0 (RE-CONTRACT-004)".into(),
});
}
Ok(())
}
fn newton_raphson_irr(cash_flows: &[Decimal], warnings: &mut Vec<String>) -> Decimal {
let max_iter: u32 = 50;
let epsilon = dec!(0.0000001);
let mut rate = dec!(0.10);
for _ in 0..max_iter {
let (npv, dnpv) = npv_and_derivative(cash_flows, rate);
if dnpv.abs() < dec!(0.000000001) {
warnings.push("IRR: derivative near zero — result may be imprecise".into());
break;
}
let new_rate = rate - npv / dnpv;
if (new_rate - rate).abs() < epsilon {
return new_rate;
}
rate = new_rate;
if rate < dec!(-0.99) {
rate = dec!(-0.99);
}
if rate > dec!(10.0) {
rate = dec!(10.0);
}
}
rate
}
fn npv_and_derivative(cash_flows: &[Decimal], rate: Decimal) -> (Decimal, Decimal) {
let one_plus_r = Decimal::ONE + rate;
let mut npv = Decimal::ZERO;
let mut dnpv = Decimal::ZERO;
let mut discount = Decimal::ONE;
for (t, cf) in cash_flows.iter().enumerate() {
npv += *cf * discount;
if t > 0 {
dnpv += Decimal::from(-(t as i64)) * *cf * discount / one_plus_r;
}
discount /= one_plus_r;
}
(npv, dnpv)
}
fn build_tranche_debt_service(
tranches: &[DebtTranche],
years: usize,
_warnings: &mut Vec<String>,
) -> CorpFinanceResult<Vec<Vec<Money>>> {
let mut result = Vec::with_capacity(tranches.len());
for tranche in tranches {
let mut ds_vec = Vec::with_capacity(years);
for yr in 0..years {
let ds = compute_tranche_annual_ds(tranche, yr)?;
ds_vec.push(ds);
}
result.push(ds_vec);
}
Ok(result)
}
fn compute_tranche_annual_ds(tranche: &DebtTranche, year: usize) -> CorpFinanceResult<Money> {
let io_years = tranche.io_period_years.unwrap_or(0) as usize;
if year < io_years {
Ok(tranche.amount * tranche.interest_rate)
} else {
match tranche.amortization_years {
None => {
Ok(tranche.amount * tranche.interest_rate)
}
Some(amort_years) => {
let monthly_rate = tranche.interest_rate / dec!(12);
let total_months = amort_years * 12;
if monthly_rate.is_zero() {
if total_months == 0 {
return Err(CorpFinanceError::DivisionByZero {
context: "tranche amortisation with zero months".into(),
});
}
Ok(tranche.amount / Decimal::from(amort_years))
} else {
let monthly_pmt =
compute_monthly_payment_simple(tranche.amount, monthly_rate, total_months)?;
Ok(monthly_pmt * dec!(12))
}
}
}
}
}
fn compute_tranche_balance(tranche: &DebtTranche, years: usize) -> CorpFinanceResult<Money> {
let io_years = tranche.io_period_years.unwrap_or(0) as usize;
if years <= io_years {
return Ok(tranche.amount);
}
match tranche.amortization_years {
None => Ok(tranche.amount), Some(amort_years) => {
let amort_payments_years = years - io_years;
let monthly_rate = tranche.interest_rate / dec!(12);
let total_months = amort_years * 12;
let payments_made = (amort_payments_years as u32) * 12;
if monthly_rate.is_zero() {
let paid =
tranche.amount * Decimal::from(payments_made) / Decimal::from(total_months);
return Ok((tranche.amount - paid).max(Decimal::ZERO));
}
let monthly_pmt =
compute_monthly_payment_simple(tranche.amount, monthly_rate, total_months)?;
let mut balance = tranche.amount;
for _ in 0..payments_made {
let interest = balance * monthly_rate;
let principal = monthly_pmt - interest;
balance -= principal;
if balance < Decimal::ZERO {
balance = Decimal::ZERO;
break;
}
}
Ok(balance)
}
}
}
fn compute_total_remaining_debt(
tranches: &[DebtTranche],
years: usize,
) -> CorpFinanceResult<Money> {
let mut total = Decimal::ZERO;
for tranche in tranches {
total += compute_tranche_balance(tranche, years)?;
}
Ok(total)
}
fn compute_monthly_payment_simple(
principal: Money,
monthly_rate: Rate,
total_months: u32,
) -> CorpFinanceResult<Money> {
if monthly_rate.is_zero() {
if total_months == 0 {
return Err(CorpFinanceError::DivisionByZero {
context: "monthly payment with zero rate and zero months".into(),
});
}
return Ok(principal / Decimal::from(total_months));
}
let mut compound = Decimal::ONE;
for _ in 0..total_months {
compound *= Decimal::ONE + monthly_rate;
}
let numerator = principal * monthly_rate * compound;
let denominator = compound - Decimal::ONE;
if denominator.is_zero() {
return Err(CorpFinanceError::DivisionByZero {
context: "mortgage payment denominator".into(),
});
}
Ok(numerator / denominator)
}
fn compute_annual_debt_service(
balance: Money,
annual_rate: Rate,
amort_years: Option<u32>,
) -> CorpFinanceResult<Money> {
match amort_years {
None => {
Ok(balance * annual_rate)
}
Some(ay) => {
let monthly_rate = annual_rate / dec!(12);
let total_months = ay * 12;
let monthly_pmt = compute_monthly_payment_simple(balance, monthly_rate, total_months)?;
Ok(monthly_pmt * dec!(12))
}
}
}
fn find_breakeven_cap_rate(
input: &HoldSellInput,
sell_npv: Money,
_warnings: &mut Vec<String>,
) -> Option<Rate> {
let mut lo = dec!(0.01);
let mut hi = dec!(0.50);
for _ in 0..60 {
let mid = (lo + hi) / dec!(2);
let hold_npv = compute_hold_npv_with_cap(input, mid);
let diff = hold_npv - sell_npv;
if diff.abs() < dec!(0.01) {
return Some(mid);
}
if diff > Decimal::ZERO {
lo = mid;
} else {
hi = mid;
}
}
None
}
fn compute_hold_npv_with_cap(input: &HoldSellInput, exit_cap: Rate) -> Money {
let n = input.remaining_hold_years as usize;
let one_plus_r = Decimal::ONE + input.discount_rate;
let mut df = Decimal::ONE;
let mut noi = input.current_noi;
let mut hold_pv = Decimal::ZERO;
for yr in 0..n {
if yr > 0 {
noi *= Decimal::ONE + input.noi_growth_rate;
}
let cf = noi - input.annual_debt_service;
df /= one_plus_r;
hold_pv += cf * df;
}
let exit_noi = noi * (Decimal::ONE + input.noi_growth_rate);
let terminal = exit_noi / exit_cap;
let exit_disp = terminal * input.disposition_cost_rate;
let terminal_net = terminal - exit_disp - input.remaining_debt;
hold_pv + terminal_net * df
}
fn compute_hold_irr(input: &HoldSellInput, hold_years: usize, warnings: &mut Vec<String>) -> Rate {
let mut cfs = Vec::with_capacity(hold_years + 1);
cfs.push(-input.original_equity);
let mut noi = input.current_noi;
for yr in 0..hold_years {
if yr > 0 {
noi *= Decimal::ONE + input.noi_growth_rate;
}
let cf = noi - input.annual_debt_service;
if yr == hold_years - 1 {
let exit_noi = noi * (Decimal::ONE + input.noi_growth_rate);
let sale = exit_noi / input.exit_cap_rate;
let disp = sale * input.disposition_cost_rate;
let net_exit = sale - disp - input.remaining_debt;
cfs.push(cf + net_exit);
} else {
cfs.push(cf);
}
}
newton_raphson_irr(&cfs, warnings)
}
#[cfg(test)]
mod tests {
use super::*;
fn senior_tranche() -> DebtTranche {
DebtTranche {
name: "Senior".into(),
amount: dec!(7_000_000),
interest_rate: dec!(0.055),
term_years: 10,
amortization_years: Some(30),
io_period_years: Some(2),
}
}
fn mezz_tranche() -> DebtTranche {
DebtTranche {
name: "Mezzanine".into(),
amount: dec!(1_500_000),
interest_rate: dec!(0.09),
term_years: 5,
amortization_years: None,
io_period_years: None,
}
}
fn basic_acquisition_input() -> AcquisitionModelInput {
AcquisitionModelInput {
purchase_price: dec!(10_000_000),
closing_costs: dec!(200_000),
capex_reserves: dec!(300_000),
noi_year1: dec!(700_000),
noi_growth_rate: dec!(0.03),
hold_period_years: 5,
exit_cap_rate: dec!(0.06),
disposition_cost_rate: dec!(0.02),
debt_tranches: vec![senior_tranche()],
discount_rate: dec!(0.08),
target_irr: Some(dec!(0.12)),
target_dscr: Some(dec!(1.20)),
}
}
fn basic_hold_sell_input() -> HoldSellInput {
HoldSellInput {
current_value: dec!(12_000_000),
current_noi: dec!(750_000),
noi_growth_rate: dec!(0.03),
remaining_hold_years: 5,
exit_cap_rate: dec!(0.06),
disposition_cost_rate: dec!(0.02),
remaining_debt: dec!(6_000_000),
annual_debt_service: dec!(450_000),
discount_rate: dec!(0.08),
original_equity: dec!(4_000_000),
years_held: 3,
max_additional_years: Some(10),
}
}
fn basic_value_add_input() -> ValueAddIrrInput {
ValueAddIrrInput {
acquisition_cost: dec!(8_000_000),
renovation_capex: vec![dec!(500_000), dec!(1_000_000), dec!(500_000)],
equity_at_close: dec!(4_000_000),
current_occupancy: dec!(0.60),
stabilised_occupancy: dec!(0.93),
lease_up_years: 3,
stabilised_gpi: dec!(1_500_000),
opex_ratio: dec!(0.40),
hold_period_years: 7,
exit_cap_rate: dec!(0.055),
disposition_cost_rate: dec!(0.02),
noi_growth_rate: dec!(0.025),
promote_rate: Some(dec!(0.20)),
debt: Some(DebtTranche {
name: "Senior".into(),
amount: dec!(5_000_000),
interest_rate: dec!(0.05),
term_years: 7,
amortization_years: Some(30),
io_period_years: Some(2),
}),
}
}
fn basic_dev_input() -> DevelopmentFeasibilityInput {
DevelopmentFeasibilityInput {
land_cost: dec!(3_000_000),
hard_costs: dec!(12_000_000),
soft_cost_pct: dec!(0.20),
construction_months: 24,
draw_schedule_pct: vec![],
construction_loan_rate: dec!(0.065),
lease_up_months: 12,
stabilised_noi: dec!(1_800_000),
market_cap_rate: dec!(0.055),
target_profit_margin: Some(dec!(0.15)),
}
}
fn basic_refi_input() -> RefinancingInput {
RefinancingInput {
property_value: dec!(15_000_000),
current_noi: dec!(1_000_000),
existing_balance: dec!(8_000_000),
existing_rate: dec!(0.065),
existing_remaining_years: 7,
existing_amort_years: Some(25),
proposed_amount: dec!(9_000_000),
proposed_rate: dec!(0.05),
proposed_term_years: 10,
proposed_amort_years: Some(30),
prepayment_penalty: dec!(160_000),
closing_costs: dec!(90_000),
discount_rate: dec!(0.07),
}
}
#[test]
fn test_acquisition_model_sources_uses() {
let r = acquisition_model(&basic_acquisition_input()).unwrap();
let o = &r.result;
assert_eq!(o.total_uses, dec!(10_500_000));
assert_eq!(o.total_debt, dec!(7_000_000));
assert_eq!(o.equity_required, dec!(3_500_000));
}
#[test]
fn test_acquisition_model_going_in_cap() {
let r = acquisition_model(&basic_acquisition_input()).unwrap();
assert_eq!(r.result.going_in_cap_rate, dec!(0.07));
}
#[test]
fn test_acquisition_model_pro_forma_length() {
let r = acquisition_model(&basic_acquisition_input()).unwrap();
assert_eq!(r.result.pro_forma.len(), 5);
}
#[test]
fn test_acquisition_model_noi_growth() {
let r = acquisition_model(&basic_acquisition_input()).unwrap();
let pf = &r.result.pro_forma;
assert_eq!(pf[0].noi, dec!(700_000));
let expected_y2 = dec!(700_000) * dec!(1.03);
assert_eq!(pf[1].noi, expected_y2);
}
#[test]
fn test_acquisition_model_io_period_debt_service() {
let r = acquisition_model(&basic_acquisition_input()).unwrap();
let pf = &r.result.pro_forma;
let io_ds = dec!(7_000_000) * dec!(0.055);
assert_eq!(pf[0].debt_service, io_ds);
assert_eq!(pf[1].debt_service, io_ds);
assert!(pf[2].debt_service > io_ds);
}
#[test]
fn test_acquisition_model_levered_irr_positive() {
let r = acquisition_model(&basic_acquisition_input()).unwrap();
assert!(r.result.levered_irr > Decimal::ZERO);
}
#[test]
fn test_acquisition_model_unlevered_irr_positive() {
let r = acquisition_model(&basic_acquisition_input()).unwrap();
assert!(r.result.unlevered_irr > Decimal::ZERO);
}
#[test]
fn test_acquisition_model_leverage_amplifies_irr() {
let r = acquisition_model(&basic_acquisition_input()).unwrap();
assert!(r.result.levered_irr > r.result.unlevered_irr);
}
#[test]
fn test_acquisition_model_equity_multiple_above_one() {
let r = acquisition_model(&basic_acquisition_input()).unwrap();
assert!(r.result.equity_multiple > Decimal::ONE);
}
#[test]
fn test_acquisition_model_decision_go() {
let mut input = basic_acquisition_input();
input.target_irr = Some(dec!(0.01)); input.target_dscr = Some(dec!(1.0));
let r = acquisition_model(&input).unwrap();
assert!(matches!(r.result.decision, GoNoGoDecision::Go));
}
#[test]
fn test_acquisition_model_decision_nogo() {
let mut input = basic_acquisition_input();
input.target_irr = Some(dec!(0.50)); input.target_dscr = Some(dec!(5.0));
let r = acquisition_model(&input).unwrap();
assert!(matches!(r.result.decision, GoNoGoDecision::NoGo { .. }));
}
#[test]
fn test_acquisition_model_mezz_increases_leverage() {
let mut input = basic_acquisition_input();
input.debt_tranches.push(mezz_tranche());
let r = acquisition_model(&input).unwrap();
assert_eq!(r.result.total_debt, dec!(8_500_000));
assert_eq!(r.result.equity_required, dec!(2_000_000));
}
#[test]
fn test_acquisition_model_invalid_cap_rate_zero() {
let mut input = basic_acquisition_input();
input.exit_cap_rate = Decimal::ZERO;
assert!(acquisition_model(&input).is_err());
}
#[test]
fn test_acquisition_model_invalid_cap_rate_above_one() {
let mut input = basic_acquisition_input();
input.exit_cap_rate = dec!(1.5);
assert!(acquisition_model(&input).is_err());
}
#[test]
fn test_acquisition_model_invalid_noi() {
let mut input = basic_acquisition_input();
input.noi_year1 = Decimal::ZERO;
assert!(acquisition_model(&input).is_err());
}
#[test]
fn test_acquisition_model_methodology() {
let r = acquisition_model(&basic_acquisition_input()).unwrap();
assert!(r.methodology.contains("Acquisition"));
}
#[test]
fn test_hold_sell_both_npvs_computed() {
let r = hold_sell_analysis(&basic_hold_sell_input()).unwrap();
let _hold = r.result.hold_npv;
let _sell = r.result.sell_npv;
}
#[test]
fn test_hold_sell_sell_npv_calculation() {
let input = basic_hold_sell_input();
let r = hold_sell_analysis(&input).unwrap();
let expected = dec!(12_000_000) - dec!(240_000) - dec!(6_000_000);
assert_eq!(r.result.sell_npv, expected);
}
#[test]
fn test_hold_sell_npv_advantage_sign() {
let r = hold_sell_analysis(&basic_hold_sell_input()).unwrap();
let diff = r.result.hold_npv - r.result.sell_npv;
assert_eq!(r.result.npv_advantage_of_holding, diff);
}
#[test]
fn test_hold_sell_breakeven_cap_rate_exists() {
let r = hold_sell_analysis(&basic_hold_sell_input()).unwrap();
assert!(r.result.breakeven_exit_cap_rate.is_some());
let be = r.result.breakeven_exit_cap_rate.unwrap();
assert!(be > Decimal::ZERO && be < Decimal::ONE);
}
#[test]
fn test_hold_sell_optimal_period_at_least_one() {
let r = hold_sell_analysis(&basic_hold_sell_input()).unwrap();
assert!(r.result.optimal_hold_period_years >= 1);
}
#[test]
fn test_hold_sell_optimal_irr_positive() {
let r = hold_sell_analysis(&basic_hold_sell_input()).unwrap();
assert!(r.result.optimal_irr > Decimal::ZERO);
}
#[test]
fn test_hold_sell_invalid_cap_rate() {
let mut input = basic_hold_sell_input();
input.exit_cap_rate = Decimal::ZERO;
assert!(hold_sell_analysis(&input).is_err());
}
#[test]
fn test_hold_sell_invalid_discount_rate() {
let mut input = basic_hold_sell_input();
input.discount_rate = Decimal::ZERO;
assert!(hold_sell_analysis(&input).is_err());
}
#[test]
fn test_hold_sell_methodology() {
let r = hold_sell_analysis(&basic_hold_sell_input()).unwrap();
assert!(r.methodology.contains("RE-CONTRACT-006"));
}
#[test]
fn test_value_add_noi_schedule_length() {
let r = value_add_irr(&basic_value_add_input()).unwrap();
assert_eq!(r.result.noi_schedule.len(), 7);
}
#[test]
fn test_value_add_noi_increases_during_leaseup() {
let r = value_add_irr(&basic_value_add_input()).unwrap();
let sched = &r.result.noi_schedule;
assert!(sched[1] > sched[0]);
assert!(sched[2] > sched[1]);
}
#[test]
fn test_value_add_stabilised_noi() {
let input = basic_value_add_input();
let r = value_add_irr(&input).unwrap();
let expected = dec!(1_500_000) * dec!(0.93) * dec!(0.60);
assert_eq!(r.result.stabilised_noi, expected);
}
#[test]
fn test_value_add_return_on_cost() {
let input = basic_value_add_input();
let r = value_add_irr(&input).unwrap();
assert!(r.result.return_on_cost > dec!(0.08));
assert!(r.result.return_on_cost < dec!(0.09));
}
#[test]
fn test_value_add_gross_irr_positive() {
let r = value_add_irr(&basic_value_add_input()).unwrap();
assert!(r.result.gross_irr > Decimal::ZERO);
}
#[test]
fn test_value_add_net_irr_less_than_gross() {
let r = value_add_irr(&basic_value_add_input()).unwrap();
assert!(r.result.net_irr <= r.result.gross_irr);
}
#[test]
fn test_value_add_peak_equity() {
let r = value_add_irr(&basic_value_add_input()).unwrap();
assert!(r.result.peak_equity >= dec!(4_000_000));
}
#[test]
fn test_value_add_equity_multiple() {
let r = value_add_irr(&basic_value_add_input()).unwrap();
assert!(r.result.equity_multiple > Decimal::ZERO);
}
#[test]
fn test_value_add_invalid_cap_rate() {
let mut input = basic_value_add_input();
input.exit_cap_rate = dec!(1.5);
assert!(value_add_irr(&input).is_err());
}
#[test]
fn test_value_add_invalid_occupancy() {
let mut input = basic_value_add_input();
input.stabilised_occupancy = dec!(1.5);
assert!(value_add_irr(&input).is_err());
}
#[test]
fn test_dev_soft_costs() {
let r = development_feasibility(&basic_dev_input()).unwrap();
assert_eq!(r.result.soft_costs, dec!(2_400_000));
}
#[test]
fn test_dev_total_construction_costs() {
let r = development_feasibility(&basic_dev_input()).unwrap();
assert_eq!(r.result.total_construction_costs, dec!(14_400_000));
}
#[test]
fn test_dev_financing_carry_positive() {
let r = development_feasibility(&basic_dev_input()).unwrap();
assert!(r.result.financing_carry > Decimal::ZERO);
}
#[test]
fn test_dev_total_cost_includes_all() {
let r = development_feasibility(&basic_dev_input()).unwrap();
let o = &r.result;
assert_eq!(
o.total_development_cost,
dec!(3_000_000) + o.total_construction_costs + o.financing_carry
);
}
#[test]
fn test_dev_yield_positive() {
let r = development_feasibility(&basic_dev_input()).unwrap();
assert!(r.result.development_yield > Decimal::ZERO);
}
#[test]
fn test_dev_stabilised_value() {
let r = development_feasibility(&basic_dev_input()).unwrap();
let expected = dec!(1_800_000) / dec!(0.055);
assert_eq!(r.result.stabilised_value, expected);
}
#[test]
fn test_dev_residual_land_value() {
let r = development_feasibility(&basic_dev_input()).unwrap();
let o = &r.result;
let expected = o.stabilised_value - o.total_construction_costs - o.financing_carry;
assert_eq!(o.residual_land_value, expected);
}
#[test]
fn test_dev_profit_margin() {
let r = development_feasibility(&basic_dev_input()).unwrap();
let o = &r.result;
let expected = (o.stabilised_value - o.total_development_cost) / o.total_development_cost;
assert_eq!(o.profit_margin, expected);
}
#[test]
fn test_dev_go_decision_feasible() {
let mut input = basic_dev_input();
input.target_profit_margin = Some(dec!(0.01)); let r = development_feasibility(&input).unwrap();
assert!(!matches!(r.result.decision, GoNoGoDecision::NoGo { .. }));
}
#[test]
fn test_dev_invalid_cap_rate() {
let mut input = basic_dev_input();
input.market_cap_rate = Decimal::ZERO;
assert!(development_feasibility(&input).is_err());
}
#[test]
fn test_dev_invalid_hard_costs() {
let mut input = basic_dev_input();
input.hard_costs = Decimal::ZERO;
assert!(development_feasibility(&input).is_err());
}
#[test]
fn test_dev_custom_draw_schedule() {
let mut input = basic_dev_input();
let mut sched = vec![dec!(0.05); 24];
sched[0] = dec!(0.10);
sched[23] = dec!(0.05);
let total: Decimal = sched.iter().copied().sum();
let normalised: Vec<Decimal> = sched.iter().map(|s| *s / total).collect();
input.draw_schedule_pct = normalised;
let r = development_feasibility(&input).unwrap();
assert!(r.result.financing_carry > Decimal::ZERO);
}
#[test]
fn test_refi_annual_savings_positive() {
let r = refinancing(&basic_refi_input()).unwrap();
assert!(r.result.annual_interest_savings > Decimal::ZERO);
}
#[test]
fn test_refi_proposed_ds_lower() {
let r = refinancing(&basic_refi_input()).unwrap();
assert!(r.result.existing_annual_ds > Decimal::ZERO);
assert!(r.result.proposed_annual_ds > Decimal::ZERO);
}
#[test]
fn test_refi_npv_savings() {
let r = refinancing(&basic_refi_input()).unwrap();
assert!(r.result.npv_of_savings > Decimal::ZERO);
}
#[test]
fn test_refi_total_cost() {
let r = refinancing(&basic_refi_input()).unwrap();
assert_eq!(r.result.total_refi_cost, dec!(250_000));
}
#[test]
fn test_refi_breakeven_months() {
let r = refinancing(&basic_refi_input()).unwrap();
assert!(r.result.breakeven_months > 0);
assert!(r.result.breakeven_months < 999);
}
#[test]
fn test_refi_post_ltv() {
let r = refinancing(&basic_refi_input()).unwrap();
assert_eq!(r.result.post_refi_ltv, dec!(0.6));
}
#[test]
fn test_refi_post_dscr_positive() {
let r = refinancing(&basic_refi_input()).unwrap();
assert!(r.result.post_refi_dscr > Decimal::ZERO);
}
#[test]
fn test_refi_debt_yield() {
let r = refinancing(&basic_refi_input()).unwrap();
let expected = dec!(1_000_000) / dec!(9_000_000);
assert_eq!(r.result.debt_yield, expected);
}
#[test]
fn test_refi_cash_out() {
let r = refinancing(&basic_refi_input()).unwrap();
assert_eq!(r.result.cash_out_amount, dec!(750_000));
}
#[test]
fn test_refi_recommend() {
let r = refinancing(&basic_refi_input()).unwrap();
assert!(r.result.recommend_refi);
}
#[test]
fn test_refi_no_recommend_high_penalty() {
let mut input = basic_refi_input();
input.prepayment_penalty = dec!(5_000_000); let r = refinancing(&input).unwrap();
assert!(!r.result.recommend_refi);
}
#[test]
fn test_refi_invalid_property_value() {
let mut input = basic_refi_input();
input.property_value = Decimal::ZERO;
assert!(refinancing(&input).is_err());
}
#[test]
fn test_refi_io_existing() {
let mut input = basic_refi_input();
input.existing_amort_years = None; let r = refinancing(&input).unwrap();
assert_eq!(r.result.existing_annual_ds, dec!(520_000));
}
#[test]
fn test_validate_cap_rate_zero() {
assert!(validate_cap_rate(Decimal::ZERO, "test").is_err());
}
#[test]
fn test_validate_cap_rate_one() {
assert!(validate_cap_rate(Decimal::ONE, "test").is_err());
}
#[test]
fn test_validate_cap_rate_negative() {
assert!(validate_cap_rate(dec!(-0.05), "test").is_err());
}
#[test]
fn test_validate_cap_rate_valid() {
assert!(validate_cap_rate(dec!(0.06), "test").is_ok());
}
#[test]
fn test_newton_raphson_simple() {
let cfs = vec![dec!(-100), dec!(110)];
let mut w = Vec::new();
let irr = newton_raphson_irr(&cfs, &mut w);
assert!((irr - dec!(0.10)).abs() < dec!(0.001));
}
#[test]
fn test_newton_raphson_multi_year() {
let cfs = vec![dec!(-1000), dec!(400), dec!(400), dec!(400)];
let mut w = Vec::new();
let irr = newton_raphson_irr(&cfs, &mut w);
assert!(irr > dec!(0.08) && irr < dec!(0.12));
}
#[test]
fn test_monthly_payment_simple() {
let pmt =
compute_monthly_payment_simple(dec!(1_000_000), dec!(0.05) / dec!(12), 360).unwrap();
assert!(pmt > dec!(5_000) && pmt < dec!(6_000));
}
#[test]
fn test_tranche_balance_io_only() {
let t = DebtTranche {
name: "IO".into(),
amount: dec!(5_000_000),
interest_rate: dec!(0.05),
term_years: 5,
amortization_years: None,
io_period_years: None,
};
let bal = compute_tranche_balance(&t, 3).unwrap();
assert_eq!(bal, dec!(5_000_000));
}
#[test]
fn test_tranche_balance_during_io_period() {
let t = DebtTranche {
name: "Senior".into(),
amount: dec!(5_000_000),
interest_rate: dec!(0.05),
term_years: 10,
amortization_years: Some(30),
io_period_years: Some(3),
};
let bal = compute_tranche_balance(&t, 2).unwrap();
assert_eq!(bal, dec!(5_000_000));
}
#[test]
fn test_tranche_balance_after_io() {
let t = DebtTranche {
name: "Senior".into(),
amount: dec!(5_000_000),
interest_rate: dec!(0.05),
term_years: 10,
amortization_years: Some(30),
io_period_years: Some(2),
};
let bal = compute_tranche_balance(&t, 5).unwrap();
assert!(bal < dec!(5_000_000));
assert!(bal > dec!(4_500_000)); }
}