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, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Seniority {
DIP,
FirstLien,
SecondLien,
Senior,
SeniorSub,
Subordinated,
Mezzanine,
}
impl std::fmt::Display for Seniority {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DIP => write!(f, "DIP"),
Self::FirstLien => write!(f, "First Lien"),
Self::SecondLien => write!(f, "Second Lien"),
Self::Senior => write!(f, "Senior Unsecured"),
Self::SeniorSub => write!(f, "Senior Subordinated"),
Self::Subordinated => write!(f, "Subordinated"),
Self::Mezzanine => write!(f, "Mezzanine"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TreatmentType {
Reinstate,
Amend,
Exchange,
EquityConversion,
CashPaydown,
Combination,
}
impl std::fmt::Display for TreatmentType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Reinstate => write!(f, "Reinstate"),
Self::Amend => write!(f, "Amend & Extend"),
Self::Exchange => write!(f, "Exchange Offer"),
Self::EquityConversion => write!(f, "Equity Conversion"),
Self::CashPaydown => write!(f, "Cash Paydown"),
Self::Combination => write!(f, "Combination"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DebtTranche {
pub name: String,
pub face_value: Money,
pub market_price: Decimal,
pub coupon_rate: Rate,
pub maturity_years: Decimal,
pub seniority: Seniority,
pub is_secured: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RestructuringTreatment {
pub tranche_name: String,
pub treatment_type: TreatmentType,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_face_value: Option<Money>,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_coupon: Option<Rate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub equity_conversion_pct: Option<Decimal>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cash_paydown: Option<Money>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DipTerms {
pub commitment: Money,
pub drawn: Money,
pub rate: Rate,
pub fees_pct: Rate,
pub term_months: u32,
pub converts_to_exit: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OperatingAssumptions {
pub annual_ebitda: Money,
pub maintenance_capex: Money,
pub working_capital_change: Money,
pub restructuring_costs: Money,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DistressedDebtInput {
pub enterprise_value: Money,
pub exit_enterprise_value: Money,
pub exit_timeline_years: Decimal,
pub capital_structure: Vec<DebtTranche>,
pub proposed_treatment: Vec<RestructuringTreatment>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dip_facility: Option<DipTerms>,
pub operating_assumptions: OperatingAssumptions,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FulcrumDetail {
pub tranche_name: String,
pub face_value: Money,
pub recovery_rate: Decimal,
pub implied_price: Decimal,
pub current_price: Decimal,
pub mispricing: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrancheAnalysis {
pub name: String,
pub face_value: Money,
pub current_market_value: Money,
pub recovery_value: Money,
pub recovery_rate: Decimal,
pub post_restructuring_value: Money,
pub irr_at_market: Decimal,
pub treatment: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DipAnalysis {
pub total_cost: Money,
pub converts_to_exit_debt: bool,
pub effective_rate: Rate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DistressedDebtOutput {
pub fulcrum_security: String,
pub fulcrum_analysis: FulcrumDetail,
pub tranche_analysis: Vec<TrancheAnalysis>,
pub plan_value: Money,
pub total_claims: Money,
pub overall_recovery: Decimal,
pub equity_value_created: Money,
#[serde(skip_serializing_if = "Option::is_none")]
pub dip_analysis: Option<DipAnalysis>,
pub credit_bid_value: Money,
}
pub fn analyze_distressed_debt(
input: &DistressedDebtInput,
) -> CorpFinanceResult<ComputationOutput<DistressedDebtOutput>> {
let start = Instant::now();
let mut warnings: Vec<String> = Vec::new();
validate_input(input)?;
let mut tranches = input.capital_structure.clone();
tranches.sort_by(|a, b| a.seniority.cmp(&b.seniority));
let treatments: std::collections::HashMap<&str, &RestructuringTreatment> = input
.proposed_treatment
.iter()
.map(|t| (t.tranche_name.as_str(), t))
.collect();
let dip_analysis = input.dip_facility.as_ref().map(|dip| {
let term_years = Decimal::from(dip.term_months) / dec!(12);
let interest_cost = dip.drawn * dip.rate * term_years;
let fee_cost = dip.commitment * dip.fees_pct;
let total_cost = interest_cost + fee_cost;
let effective_rate = if dip.drawn > Decimal::ZERO && term_years > Decimal::ZERO {
total_cost / (dip.drawn * term_years)
} else {
Decimal::ZERO
};
DipAnalysis {
total_cost,
converts_to_exit_debt: dip.converts_to_exit,
effective_rate,
}
});
let plan_value = input.exit_enterprise_value;
let mut remaining = plan_value;
let total_claims: Money = tranches.iter().map(|t| t.face_value).sum();
let mut total_surviving_debt = Decimal::ZERO;
if let Some(dip) = &input.dip_facility {
let dip_claim = dip.drawn;
let dip_consumed = remaining.min(dip_claim);
remaining -= dip_consumed;
if dip.converts_to_exit {
total_surviving_debt += dip_claim;
}
}
let mut tranche_results: Vec<TrancheAnalysis> = Vec::new();
let mut fulcrum: Option<FulcrumDetail> = None;
let mut total_equity_conversion_pct = Decimal::ZERO;
for tranche in &tranches {
let treatment = treatments.get(tranche.name.as_str());
let face = tranche.face_value;
let market_value = face * tranche.market_price;
let (recovery_value, post_restructuring_value, treatment_desc) = compute_tranche_recovery(
tranche,
treatment,
remaining,
&mut total_surviving_debt,
&mut total_equity_conversion_pct,
);
let consumed = recovery_value.min(remaining);
remaining -= consumed;
let recovery_rate = if face > Decimal::ZERO {
recovery_value / face
} else {
Decimal::ZERO
};
if fulcrum.is_none() && recovery_rate < Decimal::ONE {
fulcrum = Some(FulcrumDetail {
tranche_name: tranche.name.clone(),
face_value: face,
recovery_rate,
implied_price: recovery_rate,
current_price: tranche.market_price,
mispricing: recovery_rate - tranche.market_price,
});
}
let irr = compute_irr_at_market(market_value, recovery_value, input.exit_timeline_years);
tranche_results.push(TrancheAnalysis {
name: tranche.name.clone(),
face_value: face,
current_market_value: market_value,
recovery_value,
recovery_rate,
post_restructuring_value,
irr_at_market: irr,
treatment: treatment_desc,
});
}
let fulcrum_detail = fulcrum.unwrap_or_else(|| {
let last = tranches.last().expect("at least one tranche validated");
FulcrumDetail {
tranche_name: last.name.clone(),
face_value: last.face_value,
recovery_rate: Decimal::ONE,
implied_price: Decimal::ONE,
current_price: last.market_price,
mispricing: Decimal::ONE - last.market_price,
}
});
let credit_bid_value = compute_credit_bid(
plan_value,
&tranches,
&fulcrum_detail.tranche_name,
input.dip_facility.as_ref(),
);
let equity_value = plan_value - total_surviving_debt;
let overall_recovery = if total_claims > Decimal::ZERO {
plan_value / total_claims
} else {
Decimal::ZERO
};
if fulcrum_detail.mispricing > Decimal::ZERO {
warnings.push(format!(
"Fulcrum security '{}' appears undervalued: implied price {:.2} vs market {:.2} \
(mispricing: +{:.2})",
fulcrum_detail.tranche_name,
fulcrum_detail.implied_price,
fulcrum_detail.current_price,
fulcrum_detail.mispricing,
));
}
if equity_value < Decimal::ZERO {
warnings.push(format!(
"Negative equity value ({}) — surviving debt exceeds exit enterprise value",
equity_value,
));
}
if let Some(dip) = &input.dip_facility {
if dip.drawn > plan_value * dec!(0.5) {
warnings.push(format!(
"DIP facility ({}) exceeds 50% of exit enterprise value ({}) — \
may impair junior recovery",
dip.drawn, plan_value,
));
}
}
let output = DistressedDebtOutput {
fulcrum_security: fulcrum_detail.tranche_name.clone(),
fulcrum_analysis: fulcrum_detail,
tranche_analysis: tranche_results,
plan_value,
total_claims,
overall_recovery,
equity_value_created: equity_value,
dip_analysis,
credit_bid_value,
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"Distressed Debt / Restructuring Analysis",
&serde_json::json!({
"enterprise_value": input.enterprise_value.to_string(),
"exit_enterprise_value": input.exit_enterprise_value.to_string(),
"exit_timeline_years": input.exit_timeline_years.to_string(),
"num_tranches": input.capital_structure.len(),
"num_treatments": input.proposed_treatment.len(),
"has_dip": input.dip_facility.is_some(),
}),
warnings,
elapsed,
output,
))
}
fn validate_input(input: &DistressedDebtInput) -> CorpFinanceResult<()> {
if input.enterprise_value <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "enterprise_value".into(),
reason: "Enterprise value must be positive".into(),
});
}
if input.exit_enterprise_value < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "exit_enterprise_value".into(),
reason: "Exit enterprise value cannot be negative".into(),
});
}
if input.exit_timeline_years <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "exit_timeline_years".into(),
reason: "Exit timeline must be positive".into(),
});
}
if input.capital_structure.is_empty() {
return Err(CorpFinanceError::InvalidInput {
field: "capital_structure".into(),
reason: "At least one debt tranche is required".into(),
});
}
for tranche in &input.capital_structure {
if tranche.face_value < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: format!("capital_structure[{}].face_value", tranche.name),
reason: "Face value cannot be negative".into(),
});
}
if tranche.market_price < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: format!("capital_structure[{}].market_price", tranche.name),
reason: "Market price cannot be negative".into(),
});
}
}
let tranche_names: std::collections::HashSet<&str> = input
.capital_structure
.iter()
.map(|t| t.name.as_str())
.collect();
for treatment in &input.proposed_treatment {
if !tranche_names.contains(treatment.tranche_name.as_str()) {
return Err(CorpFinanceError::InvalidInput {
field: format!(
"proposed_treatment[{}].tranche_name",
treatment.tranche_name
),
reason: format!(
"Treatment references unknown tranche '{}'. Valid tranches: {:?}",
treatment.tranche_name, tranche_names
),
});
}
}
if let Some(dip) = &input.dip_facility {
if dip.commitment < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "dip_facility.commitment".into(),
reason: "DIP commitment cannot be negative".into(),
});
}
if dip.drawn < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "dip_facility.drawn".into(),
reason: "DIP drawn amount cannot be negative".into(),
});
}
if dip.drawn > dip.commitment {
return Err(CorpFinanceError::InvalidInput {
field: "dip_facility.drawn".into(),
reason: "DIP drawn amount cannot exceed commitment".into(),
});
}
}
if input.operating_assumptions.annual_ebitda < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "operating_assumptions.annual_ebitda".into(),
reason: "Annual EBITDA cannot be negative".into(),
});
}
if input.operating_assumptions.maintenance_capex < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "operating_assumptions.maintenance_capex".into(),
reason: "Maintenance capex cannot be negative".into(),
});
}
Ok(())
}
fn compute_tranche_recovery(
tranche: &DebtTranche,
treatment: Option<&&RestructuringTreatment>,
remaining: Decimal,
total_surviving_debt: &mut Decimal,
total_equity_conversion_pct: &mut Decimal,
) -> (Money, Money, String) {
let face = tranche.face_value;
match treatment {
Some(t) => match &t.treatment_type {
TreatmentType::Reinstate => {
let recovery = face.min(remaining);
*total_surviving_debt += face;
(recovery, face, "Reinstate".to_string())
}
TreatmentType::Amend => {
let new_coupon = t.new_coupon.unwrap_or(tranche.coupon_rate);
let recovery = face.min(remaining);
*total_surviving_debt += face;
(
recovery,
face,
format!(
"Amend & Extend (new coupon: {:.2}%)",
new_coupon * dec!(100)
),
)
}
TreatmentType::Exchange => {
let new_face = t.new_face_value.unwrap_or(face);
let recovery = new_face.min(remaining);
*total_surviving_debt += new_face;
(
recovery,
new_face,
format!("Exchange Offer (new face: {})", new_face),
)
}
TreatmentType::EquityConversion => {
let equity_pct = t.equity_conversion_pct.unwrap_or(Decimal::ZERO);
*total_equity_conversion_pct += equity_pct;
let recovery = remaining * equity_pct;
(
recovery,
recovery,
format!("Equity Conversion ({:.1}% equity)", equity_pct * dec!(100)),
)
}
TreatmentType::CashPaydown => {
let cash = t.cash_paydown.unwrap_or(Decimal::ZERO);
let recovery = cash.min(remaining);
(recovery, cash, format!("Cash Paydown ({})", cash))
}
TreatmentType::Combination => {
let cash = t.cash_paydown.unwrap_or(Decimal::ZERO);
let new_face = t.new_face_value.unwrap_or(Decimal::ZERO);
let equity_pct = t.equity_conversion_pct.unwrap_or(Decimal::ZERO);
let debt_recovery = cash + new_face;
let equity_recovery = (remaining - debt_recovery).max(Decimal::ZERO) * equity_pct;
let total_recovery = (debt_recovery + equity_recovery).min(remaining);
*total_surviving_debt += new_face;
*total_equity_conversion_pct += equity_pct;
(
total_recovery,
total_recovery,
format!(
"Combination (cash: {}, new debt: {}, equity: {:.1}%)",
cash,
new_face,
equity_pct * dec!(100)
),
)
}
},
None => {
let recovery = face.min(remaining);
(
recovery,
recovery,
"Waterfall (no explicit treatment)".to_string(),
)
}
}
}
fn compute_irr_at_market(market_value: Money, recovery_value: Money, years: Decimal) -> Decimal {
if market_value <= Decimal::ZERO || years <= Decimal::ZERO {
return Decimal::ZERO;
}
if recovery_value <= Decimal::ZERO {
return dec!(-1); }
let ratio = recovery_value / market_value;
let exponent = Decimal::ONE / years;
decimal_pow(ratio, exponent) - Decimal::ONE
}
fn decimal_pow(base: Decimal, exp: Decimal) -> Decimal {
if base <= Decimal::ZERO {
return Decimal::ZERO;
}
if base == Decimal::ONE || exp == Decimal::ZERO {
return Decimal::ONE;
}
if exp == Decimal::ONE {
return base;
}
let ln_base = decimal_ln(base);
decimal_exp(exp * ln_base)
}
fn decimal_ln(x: Decimal) -> Decimal {
if x <= Decimal::ZERO {
return Decimal::ZERO; }
if x == Decimal::ONE {
return Decimal::ZERO;
}
let ln2 = dec!(0.69314718055994530941723);
let mut val = x;
let mut adjustment = Decimal::ZERO;
while val > dec!(2) {
val /= dec!(2);
adjustment += ln2;
}
while val < dec!(0.5) {
val *= dec!(2);
adjustment -= ln2;
}
let u = (val - Decimal::ONE) / (val + Decimal::ONE);
let u_sq = u * u;
let mut term = u;
let mut result = u;
for k in 1u32..40 {
term *= u_sq;
let denom = Decimal::from(2 * k + 1);
result += term / denom;
}
dec!(2) * result + adjustment
}
fn decimal_exp(x: Decimal) -> Decimal {
let mut term = Decimal::ONE;
let mut result = Decimal::ONE;
for n in 1u32..60 {
term *= x / Decimal::from(n);
result += term;
if term.abs() < dec!(0.0000000000000001) {
break;
}
}
result
}
fn compute_credit_bid(
plan_value: Money,
tranches: &[DebtTranche],
fulcrum_name: &str,
dip: Option<&DipTerms>,
) -> Money {
let mut senior_claims = Decimal::ZERO;
if let Some(dip_terms) = dip {
senior_claims += dip_terms.drawn;
}
for tranche in tranches {
if tranche.name == fulcrum_name {
break;
}
senior_claims += tranche.face_value;
}
(plan_value - senior_claims).max(Decimal::ZERO)
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn default_operating() -> OperatingAssumptions {
OperatingAssumptions {
annual_ebitda: dec!(50),
maintenance_capex: dec!(10),
working_capital_change: dec!(5),
restructuring_costs: dec!(15),
}
}
fn two_tranche_input() -> DistressedDebtInput {
DistressedDebtInput {
enterprise_value: dec!(500),
exit_enterprise_value: dec!(600),
exit_timeline_years: dec!(2),
capital_structure: vec![
DebtTranche {
name: "First Lien".into(),
face_value: dec!(400),
market_price: dec!(0.95),
coupon_rate: dec!(0.05),
maturity_years: dec!(3),
seniority: Seniority::FirstLien,
is_secured: true,
},
DebtTranche {
name: "Second Lien".into(),
face_value: dec!(300),
market_price: dec!(0.40),
coupon_rate: dec!(0.10),
maturity_years: dec!(5),
seniority: Seniority::SecondLien,
is_secured: true,
},
],
proposed_treatment: vec![
RestructuringTreatment {
tranche_name: "First Lien".into(),
treatment_type: TreatmentType::Reinstate,
new_face_value: None,
new_coupon: None,
equity_conversion_pct: None,
cash_paydown: None,
},
RestructuringTreatment {
tranche_name: "Second Lien".into(),
treatment_type: TreatmentType::EquityConversion,
new_face_value: None,
new_coupon: None,
equity_conversion_pct: Some(dec!(1.0)),
cash_paydown: None,
},
],
dip_facility: None,
operating_assumptions: default_operating(),
}
}
#[test]
fn test_simple_two_tranche() {
let input = two_tranche_input();
let result = analyze_distressed_debt(&input).unwrap();
let out = &result.result;
assert_eq!(out.plan_value, dec!(600));
assert_eq!(out.total_claims, dec!(700));
assert_eq!(out.tranche_analysis[0].name, "First Lien");
assert_eq!(out.tranche_analysis[0].recovery_value, dec!(400));
assert_eq!(out.tranche_analysis[0].recovery_rate, Decimal::ONE);
assert_eq!(out.tranche_analysis[1].name, "Second Lien");
assert!(out.tranche_analysis[1].recovery_rate < Decimal::ONE);
assert_eq!(out.fulcrum_security, "Second Lien");
}
#[test]
fn test_full_capital_structure() {
let input = DistressedDebtInput {
enterprise_value: dec!(1000),
exit_enterprise_value: dec!(1200),
exit_timeline_years: dec!(3),
capital_structure: vec![
DebtTranche {
name: "Revolver".into(),
face_value: dec!(100),
market_price: dec!(1.0),
coupon_rate: dec!(0.04),
maturity_years: dec!(2),
seniority: Seniority::FirstLien,
is_secured: true,
},
DebtTranche {
name: "Term Loan A".into(),
face_value: dec!(300),
market_price: dec!(0.90),
coupon_rate: dec!(0.06),
maturity_years: dec!(4),
seniority: Seniority::FirstLien,
is_secured: true,
},
DebtTranche {
name: "Term Loan B".into(),
face_value: dec!(200),
market_price: dec!(0.70),
coupon_rate: dec!(0.08),
maturity_years: dec!(5),
seniority: Seniority::SecondLien,
is_secured: true,
},
DebtTranche {
name: "Senior Notes".into(),
face_value: dec!(250),
market_price: dec!(0.50),
coupon_rate: dec!(0.09),
maturity_years: dec!(6),
seniority: Seniority::Senior,
is_secured: false,
},
DebtTranche {
name: "Sub Notes".into(),
face_value: dec!(150),
market_price: dec!(0.15),
coupon_rate: dec!(0.12),
maturity_years: dec!(7),
seniority: Seniority::Subordinated,
is_secured: false,
},
],
proposed_treatment: vec![
RestructuringTreatment {
tranche_name: "Revolver".into(),
treatment_type: TreatmentType::CashPaydown,
new_face_value: None,
new_coupon: None,
equity_conversion_pct: None,
cash_paydown: Some(dec!(100)),
},
RestructuringTreatment {
tranche_name: "Term Loan A".into(),
treatment_type: TreatmentType::Reinstate,
new_face_value: None,
new_coupon: None,
equity_conversion_pct: None,
cash_paydown: None,
},
RestructuringTreatment {
tranche_name: "Term Loan B".into(),
treatment_type: TreatmentType::Exchange,
new_face_value: Some(dec!(150)),
new_coupon: Some(dec!(0.07)),
equity_conversion_pct: None,
cash_paydown: None,
},
RestructuringTreatment {
tranche_name: "Senior Notes".into(),
treatment_type: TreatmentType::Amend,
new_face_value: None,
new_coupon: Some(dec!(0.06)),
equity_conversion_pct: None,
cash_paydown: None,
},
RestructuringTreatment {
tranche_name: "Sub Notes".into(),
treatment_type: TreatmentType::EquityConversion,
new_face_value: None,
new_coupon: None,
equity_conversion_pct: Some(dec!(0.10)),
cash_paydown: None,
},
],
dip_facility: None,
operating_assumptions: default_operating(),
};
let result = analyze_distressed_debt(&input).unwrap();
let out = &result.result;
assert_eq!(out.tranche_analysis.len(), 5);
assert_eq!(out.plan_value, dec!(1200));
assert_eq!(out.total_claims, dec!(1000));
assert!(out.tranche_analysis[0].treatment.contains("Cash Paydown"));
assert!(out.tranche_analysis[1].treatment.contains("Reinstate"));
assert!(out.tranche_analysis[2].treatment.contains("Exchange"));
}
#[test]
fn test_dip_with_conversion() {
let mut input = two_tranche_input();
input.dip_facility = Some(DipTerms {
commitment: dec!(100),
drawn: dec!(80),
rate: dec!(0.10),
fees_pct: dec!(0.03),
term_months: 18,
converts_to_exit: true,
});
let result = analyze_distressed_debt(&input).unwrap();
let out = &result.result;
assert!(out.dip_analysis.is_some());
let dip = out.dip_analysis.as_ref().unwrap();
assert!(dip.total_cost > Decimal::ZERO);
assert!(dip.converts_to_exit_debt);
assert!(dip.effective_rate > Decimal::ZERO);
assert_eq!(out.tranche_analysis[0].recovery_value, dec!(400));
}
#[test]
fn test_exchange_offer() {
let input = DistressedDebtInput {
enterprise_value: dec!(500),
exit_enterprise_value: dec!(500),
exit_timeline_years: dec!(2),
capital_structure: vec![DebtTranche {
name: "Senior Notes".into(),
face_value: dec!(400),
market_price: dec!(0.60),
coupon_rate: dec!(0.08),
maturity_years: dec!(3),
seniority: Seniority::Senior,
is_secured: false,
}],
proposed_treatment: vec![RestructuringTreatment {
tranche_name: "Senior Notes".into(),
treatment_type: TreatmentType::Exchange,
new_face_value: Some(dec!(300)),
new_coupon: Some(dec!(0.06)),
equity_conversion_pct: None,
cash_paydown: None,
}],
dip_facility: None,
operating_assumptions: default_operating(),
};
let result = analyze_distressed_debt(&input).unwrap();
let out = &result.result;
let tranche = &out.tranche_analysis[0];
assert_eq!(tranche.recovery_value, dec!(300));
assert_eq!(tranche.post_restructuring_value, dec!(300));
assert!(tranche.treatment.contains("Exchange"));
assert_eq!(out.equity_value_created, dec!(200));
}
#[test]
fn test_equity_conversion() {
let input = DistressedDebtInput {
enterprise_value: dec!(300),
exit_enterprise_value: dec!(400),
exit_timeline_years: dec!(2),
capital_structure: vec![
DebtTranche {
name: "Senior Secured".into(),
face_value: dec!(200),
market_price: dec!(0.90),
coupon_rate: dec!(0.06),
maturity_years: dec!(3),
seniority: Seniority::FirstLien,
is_secured: true,
},
DebtTranche {
name: "Unsecured".into(),
face_value: dec!(300),
market_price: dec!(0.25),
coupon_rate: dec!(0.10),
maturity_years: dec!(5),
seniority: Seniority::Senior,
is_secured: false,
},
],
proposed_treatment: vec![
RestructuringTreatment {
tranche_name: "Senior Secured".into(),
treatment_type: TreatmentType::Reinstate,
new_face_value: None,
new_coupon: None,
equity_conversion_pct: None,
cash_paydown: None,
},
RestructuringTreatment {
tranche_name: "Unsecured".into(),
treatment_type: TreatmentType::EquityConversion,
new_face_value: None,
new_coupon: None,
equity_conversion_pct: Some(dec!(0.95)),
cash_paydown: None,
},
],
dip_facility: None,
operating_assumptions: default_operating(),
};
let result = analyze_distressed_debt(&input).unwrap();
let out = &result.result;
assert_eq!(out.tranche_analysis[0].recovery_value, dec!(200));
assert_eq!(out.tranche_analysis[0].recovery_rate, Decimal::ONE);
let unsecured = &out.tranche_analysis[1];
assert!(unsecured.treatment.contains("Equity Conversion"));
assert!(unsecured.treatment.contains("95.0%"));
assert_eq!(unsecured.recovery_value, dec!(190));
assert_eq!(out.fulcrum_security, "Unsecured");
}
#[test]
fn test_fulcrum_identification() {
let input = DistressedDebtInput {
enterprise_value: dec!(400),
exit_enterprise_value: dec!(450),
exit_timeline_years: dec!(2),
capital_structure: vec![
DebtTranche {
name: "1L".into(),
face_value: dec!(200),
market_price: dec!(0.98),
coupon_rate: dec!(0.05),
maturity_years: dec!(3),
seniority: Seniority::FirstLien,
is_secured: true,
},
DebtTranche {
name: "2L".into(),
face_value: dec!(200),
market_price: dec!(0.60),
coupon_rate: dec!(0.08),
maturity_years: dec!(5),
seniority: Seniority::SecondLien,
is_secured: true,
},
DebtTranche {
name: "Mezz".into(),
face_value: dec!(200),
market_price: dec!(0.10),
coupon_rate: dec!(0.14),
maturity_years: dec!(7),
seniority: Seniority::Mezzanine,
is_secured: false,
},
],
proposed_treatment: vec![],
dip_facility: None,
operating_assumptions: default_operating(),
};
let result = analyze_distressed_debt(&input).unwrap();
let out = &result.result;
assert_eq!(out.fulcrum_security, "Mezz");
assert_eq!(out.fulcrum_analysis.tranche_name, "Mezz");
assert!(out.fulcrum_analysis.recovery_rate < Decimal::ONE);
}
#[test]
fn test_mispricing_detection() {
let input = two_tranche_input();
let result = analyze_distressed_debt(&input).unwrap();
let out = &result.result;
assert!(out.fulcrum_analysis.mispricing > Decimal::ZERO);
assert!(out.fulcrum_analysis.implied_price > out.fulcrum_analysis.current_price);
assert!(
result.warnings.iter().any(|w| w.contains("undervalued")),
"Expected undervalued warning, got: {:?}",
result.warnings
);
}
#[test]
fn test_credit_bid_calculation() {
let input = two_tranche_input();
let result = analyze_distressed_debt(&input).unwrap();
let out = &result.result;
assert_eq!(out.credit_bid_value, dec!(200));
}
#[test]
fn test_credit_bid_with_dip() {
let mut input = two_tranche_input();
input.dip_facility = Some(DipTerms {
commitment: dec!(50),
drawn: dec!(50),
rate: dec!(0.12),
fees_pct: dec!(0.02),
term_months: 12,
converts_to_exit: false,
});
let result = analyze_distressed_debt(&input).unwrap();
let out = &result.result;
assert_eq!(out.credit_bid_value, dec!(150));
}
#[test]
fn test_irr_computation() {
let input = two_tranche_input();
let result = analyze_distressed_debt(&input).unwrap();
let out = &result.result;
let fl_irr = out.tranche_analysis[0].irr_at_market;
assert!(fl_irr > Decimal::ZERO, "First lien IRR should be positive");
assert!(
fl_irr > dec!(0.02) && fl_irr < dec!(0.04),
"Expected ~2.6% IRR, got {}",
fl_irr
);
let sl_irr = out.tranche_analysis[1].irr_at_market;
assert!(sl_irr > Decimal::ZERO, "Second lien IRR should be positive");
assert!(
sl_irr > dec!(0.20) && sl_irr < dec!(0.40),
"Expected ~29% IRR, got {}",
sl_irr
);
}
#[test]
fn test_zero_recovery_tranche() {
let input = DistressedDebtInput {
enterprise_value: dec!(200),
exit_enterprise_value: dec!(250),
exit_timeline_years: dec!(2),
capital_structure: vec![
DebtTranche {
name: "Senior".into(),
face_value: dec!(250),
market_price: dec!(0.90),
coupon_rate: dec!(0.06),
maturity_years: dec!(3),
seniority: Seniority::Senior,
is_secured: false,
},
DebtTranche {
name: "Sub".into(),
face_value: dec!(100),
market_price: dec!(0.02),
coupon_rate: dec!(0.12),
maturity_years: dec!(5),
seniority: Seniority::Subordinated,
is_secured: false,
},
],
proposed_treatment: vec![],
dip_facility: None,
operating_assumptions: default_operating(),
};
let result = analyze_distressed_debt(&input).unwrap();
let out = &result.result;
assert_eq!(out.tranche_analysis[1].recovery_value, Decimal::ZERO);
assert_eq!(out.tranche_analysis[1].recovery_rate, Decimal::ZERO);
assert_eq!(out.tranche_analysis[1].irr_at_market, dec!(-1));
}
#[test]
fn test_all_tranches_full_recovery() {
let input = DistressedDebtInput {
enterprise_value: dec!(500),
exit_enterprise_value: dec!(1000),
exit_timeline_years: dec!(2),
capital_structure: vec![
DebtTranche {
name: "1L".into(),
face_value: dec!(200),
market_price: dec!(0.98),
coupon_rate: dec!(0.05),
maturity_years: dec!(3),
seniority: Seniority::FirstLien,
is_secured: true,
},
DebtTranche {
name: "2L".into(),
face_value: dec!(200),
market_price: dec!(0.85),
coupon_rate: dec!(0.08),
maturity_years: dec!(5),
seniority: Seniority::SecondLien,
is_secured: true,
},
],
proposed_treatment: vec![],
dip_facility: None,
operating_assumptions: default_operating(),
};
let result = analyze_distressed_debt(&input).unwrap();
let out = &result.result;
assert_eq!(out.tranche_analysis[0].recovery_rate, Decimal::ONE);
assert_eq!(out.tranche_analysis[1].recovery_rate, Decimal::ONE);
assert_eq!(out.fulcrum_security, "2L");
assert_eq!(out.fulcrum_analysis.recovery_rate, Decimal::ONE);
assert_eq!(out.equity_value_created, dec!(1000));
}
#[test]
fn test_negative_equity_warning() {
let input = DistressedDebtInput {
enterprise_value: dec!(300),
exit_enterprise_value: dec!(400),
exit_timeline_years: dec!(2),
capital_structure: vec![DebtTranche {
name: "Senior".into(),
face_value: dec!(500),
market_price: dec!(0.70),
coupon_rate: dec!(0.06),
maturity_years: dec!(3),
seniority: Seniority::Senior,
is_secured: false,
}],
proposed_treatment: vec![RestructuringTreatment {
tranche_name: "Senior".into(),
treatment_type: TreatmentType::Reinstate,
new_face_value: None,
new_coupon: None,
equity_conversion_pct: None,
cash_paydown: None,
}],
dip_facility: None,
operating_assumptions: default_operating(),
};
let result = analyze_distressed_debt(&input).unwrap();
assert!(result.result.equity_value_created < Decimal::ZERO);
assert!(
result
.warnings
.iter()
.any(|w| w.contains("Negative equity")),
"Expected negative equity warning, got: {:?}",
result.warnings
);
}
#[test]
fn test_dip_exceeds_fifty_pct_warning() {
let mut input = two_tranche_input();
input.dip_facility = Some(DipTerms {
commitment: dec!(400),
drawn: dec!(350),
rate: dec!(0.12),
fees_pct: dec!(0.03),
term_months: 12,
converts_to_exit: false,
});
let result = analyze_distressed_debt(&input).unwrap();
assert!(
result.warnings.iter().any(|w| w.contains("exceeds 50%")),
"Expected DIP > 50% warning, got: {:?}",
result.warnings
);
}
#[test]
fn test_validation_ev_positive() {
let mut input = two_tranche_input();
input.enterprise_value = dec!(-100);
let err = analyze_distressed_debt(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "enterprise_value");
}
other => panic!("Expected InvalidInput for enterprise_value, got {other:?}"),
}
}
#[test]
fn test_validation_empty_tranches() {
let mut input = two_tranche_input();
input.capital_structure.clear();
let err = analyze_distressed_debt(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "capital_structure");
}
other => panic!("Expected InvalidInput for capital_structure, got {other:?}"),
}
}
#[test]
fn test_validation_treatment_name_mismatch() {
let mut input = two_tranche_input();
input.proposed_treatment.push(RestructuringTreatment {
tranche_name: "Nonexistent Tranche".into(),
treatment_type: TreatmentType::Reinstate,
new_face_value: None,
new_coupon: None,
equity_conversion_pct: None,
cash_paydown: None,
});
let err = analyze_distressed_debt(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert!(field.contains("Nonexistent Tranche"));
}
other => panic!("Expected InvalidInput for treatment name, got {other:?}"),
}
}
#[test]
fn test_validation_negative_face_value() {
let mut input = two_tranche_input();
input.capital_structure[0].face_value = dec!(-100);
let err = analyze_distressed_debt(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert!(field.contains("face_value"));
}
other => panic!("Expected InvalidInput for face_value, got {other:?}"),
}
}
#[test]
fn test_validation_dip_overdrawn() {
let mut input = two_tranche_input();
input.dip_facility = Some(DipTerms {
commitment: dec!(50),
drawn: dec!(80),
rate: dec!(0.10),
fees_pct: dec!(0.02),
term_months: 12,
converts_to_exit: false,
});
let err = analyze_distressed_debt(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert!(field.contains("drawn"));
}
other => panic!("Expected InvalidInput for DIP drawn, got {other:?}"),
}
}
#[test]
fn test_overall_recovery() {
let input = two_tranche_input();
let result = analyze_distressed_debt(&input).unwrap();
let out = &result.result;
let expected = dec!(600) / dec!(700);
assert_eq!(out.overall_recovery, expected);
}
#[test]
fn test_combination_treatment() {
let input = DistressedDebtInput {
enterprise_value: dec!(500),
exit_enterprise_value: dec!(600),
exit_timeline_years: dec!(2),
capital_structure: vec![DebtTranche {
name: "Senior Notes".into(),
face_value: dec!(500),
market_price: dec!(0.55),
coupon_rate: dec!(0.08),
maturity_years: dec!(4),
seniority: Seniority::Senior,
is_secured: false,
}],
proposed_treatment: vec![RestructuringTreatment {
tranche_name: "Senior Notes".into(),
treatment_type: TreatmentType::Combination,
new_face_value: Some(dec!(200)),
new_coupon: Some(dec!(0.06)),
equity_conversion_pct: Some(dec!(0.50)),
cash_paydown: Some(dec!(100)),
}],
dip_facility: None,
operating_assumptions: default_operating(),
};
let result = analyze_distressed_debt(&input).unwrap();
let out = &result.result;
let tranche = &out.tranche_analysis[0];
assert!(tranche.treatment.contains("Combination"));
assert_eq!(tranche.recovery_value, dec!(450));
}
#[test]
fn test_metadata_populated() {
let input = two_tranche_input();
let result = analyze_distressed_debt(&input).unwrap();
assert!(!result.methodology.is_empty());
assert!(result.methodology.contains("Distressed"));
assert_eq!(result.metadata.precision, "rust_decimal_128bit");
assert!(result.metadata.computation_time_us > 0 || true); }
#[test]
fn test_seniority_ordering() {
let input = DistressedDebtInput {
enterprise_value: dec!(300),
exit_enterprise_value: dec!(350),
exit_timeline_years: dec!(2),
capital_structure: vec![
DebtTranche {
name: "Mezz".into(),
face_value: dec!(100),
market_price: dec!(0.10),
coupon_rate: dec!(0.14),
maturity_years: dec!(7),
seniority: Seniority::Mezzanine,
is_secured: false,
},
DebtTranche {
name: "1L".into(),
face_value: dec!(200),
market_price: dec!(0.95),
coupon_rate: dec!(0.05),
maturity_years: dec!(3),
seniority: Seniority::FirstLien,
is_secured: true,
},
DebtTranche {
name: "Senior".into(),
face_value: dec!(150),
market_price: dec!(0.50),
coupon_rate: dec!(0.09),
maturity_years: dec!(5),
seniority: Seniority::Senior,
is_secured: false,
},
],
proposed_treatment: vec![],
dip_facility: None,
operating_assumptions: default_operating(),
};
let result = analyze_distressed_debt(&input).unwrap();
let out = &result.result;
assert_eq!(out.tranche_analysis[0].name, "1L");
assert_eq!(out.tranche_analysis[1].name, "Senior");
assert_eq!(out.tranche_analysis[2].name, "Mezz");
assert_eq!(out.tranche_analysis[0].recovery_rate, Decimal::ONE);
assert_eq!(out.tranche_analysis[1].recovery_rate, Decimal::ONE);
assert_eq!(out.tranche_analysis[2].recovery_rate, Decimal::ZERO);
assert_eq!(out.fulcrum_security, "Mezz");
}
#[test]
fn test_dip_cost_calculation() {
let mut input = two_tranche_input();
input.dip_facility = Some(DipTerms {
commitment: dec!(100),
drawn: dec!(80),
rate: dec!(0.10),
fees_pct: dec!(0.03),
term_months: 12,
converts_to_exit: false,
});
let result = analyze_distressed_debt(&input).unwrap();
let dip = result.result.dip_analysis.as_ref().unwrap();
assert_eq!(dip.total_cost, dec!(11));
assert!(!dip.converts_to_exit_debt);
}
#[test]
fn test_validation_exit_timeline() {
let mut input = two_tranche_input();
input.exit_timeline_years = Decimal::ZERO;
let err = analyze_distressed_debt(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "exit_timeline_years");
}
other => panic!("Expected InvalidInput for exit_timeline_years, got {other:?}"),
}
}
#[test]
fn test_cash_paydown() {
let input = DistressedDebtInput {
enterprise_value: dec!(500),
exit_enterprise_value: dec!(500),
exit_timeline_years: dec!(1),
capital_structure: vec![DebtTranche {
name: "Revolver".into(),
face_value: dec!(200),
market_price: dec!(0.95),
coupon_rate: dec!(0.04),
maturity_years: dec!(1),
seniority: Seniority::FirstLien,
is_secured: true,
}],
proposed_treatment: vec![RestructuringTreatment {
tranche_name: "Revolver".into(),
treatment_type: TreatmentType::CashPaydown,
new_face_value: None,
new_coupon: None,
equity_conversion_pct: None,
cash_paydown: Some(dec!(200)),
}],
dip_facility: None,
operating_assumptions: default_operating(),
};
let result = analyze_distressed_debt(&input).unwrap();
let out = &result.result;
let tranche = &out.tranche_analysis[0];
assert_eq!(tranche.recovery_value, dec!(200));
assert_eq!(tranche.recovery_rate, Decimal::ONE);
assert!(tranche.treatment.contains("Cash Paydown"));
assert_eq!(out.equity_value_created, dec!(500));
}
#[test]
fn test_amend_treatment() {
let input = DistressedDebtInput {
enterprise_value: dec!(500),
exit_enterprise_value: dec!(600),
exit_timeline_years: dec!(2),
capital_structure: vec![DebtTranche {
name: "Term Loan".into(),
face_value: dec!(400),
market_price: dec!(0.80),
coupon_rate: dec!(0.08),
maturity_years: dec!(3),
seniority: Seniority::FirstLien,
is_secured: true,
}],
proposed_treatment: vec![RestructuringTreatment {
tranche_name: "Term Loan".into(),
treatment_type: TreatmentType::Amend,
new_face_value: None,
new_coupon: Some(dec!(0.05)),
equity_conversion_pct: None,
cash_paydown: None,
}],
dip_facility: None,
operating_assumptions: default_operating(),
};
let result = analyze_distressed_debt(&input).unwrap();
let out = &result.result;
let tranche = &out.tranche_analysis[0];
assert_eq!(tranche.recovery_value, dec!(400));
assert!(tranche.treatment.contains("Amend"));
assert!(tranche.treatment.contains("5.00%"));
assert_eq!(out.equity_value_created, dec!(200)); }
}