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;
const MAX_ITERATIONS: u32 = 50;
const EPSILON: Decimal = dec!(0.0000001);
const ONE_BP: Decimal = dec!(0.0001);
const IG_CEILING: Decimal = dec!(0.0200);
const HY_CEILING: Decimal = dec!(0.1000);
const DEFAULT_RECOVERY: Decimal = dec!(0.40);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BenchmarkPoint {
pub maturity: Decimal,
pub rate: Rate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreditSpreadInput {
pub face_value: Money,
pub coupon_rate: Rate,
pub coupon_frequency: u8,
pub market_price: Money,
pub years_to_maturity: Decimal,
pub benchmark_curve: Vec<BenchmarkPoint>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recovery_rate: Option<Rate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_probability: Option<Rate>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreditSpreadOutput {
pub i_spread: Rate,
pub g_spread: Rate,
pub z_spread: Rate,
pub oas_estimate: Option<Rate>,
pub ytm: Rate,
pub benchmark_yield: Rate,
pub spread_duration: Decimal,
pub cds_spread: Option<Rate>,
pub credit_quality_indicator: String,
}
pub fn calculate_credit_spreads(
input: &CreditSpreadInput,
) -> CorpFinanceResult<ComputationOutput<CreditSpreadOutput>> {
let start = Instant::now();
let warnings: Vec<String> = Vec::new();
validate_input(input)?;
let coupon_per_period =
input.face_value * input.coupon_rate / Decimal::from(input.coupon_frequency);
let total_periods = input.years_to_maturity * Decimal::from(input.coupon_frequency);
let cashflows = build_cashflow_schedule(input, coupon_per_period, total_periods);
let ytm = solve_ytm(input, &cashflows)?;
let benchmark_yield = interpolate_rate(&input.benchmark_curve, input.years_to_maturity)?;
let i_spread = ytm - benchmark_yield;
let g_spread = i_spread;
let z_spread = solve_z_spread(input, &cashflows)?;
let spread_duration = compute_spread_duration(input, &cashflows, z_spread)?;
let cds_spread = input.default_probability.map(|pd| {
let recovery = input.recovery_rate.unwrap_or(DEFAULT_RECOVERY);
(Decimal::ONE - recovery) * pd
});
let credit_quality_indicator = classify_credit_quality(z_spread);
let output = CreditSpreadOutput {
i_spread,
g_spread,
z_spread,
oas_estimate: None, ytm,
benchmark_yield,
spread_duration,
cds_spread,
credit_quality_indicator,
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"Credit Spreads (I-spread, G-spread, Z-spread, CDS)",
&serde_json::json!({
"ytm_method": "Newton-Raphson (50 iter, eps 1e-7)",
"z_spread_method": "Newton-Raphson (50 iter, eps 1e-7)",
"spread_duration_bump": "1 bp",
"cds_model": "simplified annual premium: (1-R)*PD",
"benchmark_interpolation": "linear",
}),
warnings,
elapsed,
output,
))
}
fn validate_input(input: &CreditSpreadInput) -> CorpFinanceResult<()> {
if input.face_value <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "face_value".into(),
reason: "Face value must be positive".into(),
});
}
if input.market_price <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "market_price".into(),
reason: "Market price must be positive".into(),
});
}
if !matches!(input.coupon_frequency, 1 | 2 | 4 | 12) {
return Err(CorpFinanceError::InvalidInput {
field: "coupon_frequency".into(),
reason: "Coupon frequency must be 1, 2, 4, or 12".into(),
});
}
if input.years_to_maturity <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "years_to_maturity".into(),
reason: "Years to maturity must be positive".into(),
});
}
if input.benchmark_curve.len() < 2 {
return Err(CorpFinanceError::InsufficientData(
"Benchmark curve must contain at least 2 points".into(),
));
}
for w in input.benchmark_curve.windows(2) {
if w[1].maturity <= w[0].maturity {
return Err(CorpFinanceError::InvalidInput {
field: "benchmark_curve".into(),
reason: "Benchmark curve must be sorted ascending by maturity".into(),
});
}
}
if let Some(rr) = input.recovery_rate {
if rr < Decimal::ZERO || rr > Decimal::ONE {
return Err(CorpFinanceError::InvalidInput {
field: "recovery_rate".into(),
reason: "Recovery rate must be between 0 and 1".into(),
});
}
}
if let Some(pd) = input.default_probability {
if pd < Decimal::ZERO || pd > Decimal::ONE {
return Err(CorpFinanceError::InvalidInput {
field: "default_probability".into(),
reason: "Default probability must be between 0 and 1".into(),
});
}
}
Ok(())
}
fn build_cashflow_schedule(
input: &CreditSpreadInput,
coupon_per_period: Decimal,
total_periods: Decimal,
) -> Vec<(Decimal, Decimal)> {
let freq = Decimal::from(input.coupon_frequency);
let n_periods = total_periods_as_u32(total_periods);
let mut cfs = Vec::with_capacity(n_periods as usize);
for i in 1..=n_periods {
let t = Decimal::from(i) / freq;
let mut cf = coupon_per_period;
if i == n_periods {
cf += input.face_value; }
cfs.push((t, cf));
}
cfs
}
fn total_periods_as_u32(total: Decimal) -> u32 {
let rounded = total.round().to_string().parse::<u32>().unwrap_or(1);
if rounded == 0 {
1
} else {
rounded
}
}
fn bond_price_from_yield(cashflows: &[(Decimal, Decimal)], y_annual: Decimal, freq: u8) -> Decimal {
let freq_d = Decimal::from(freq);
let y_per_period = y_annual / freq_d;
let one_plus_y = Decimal::ONE + y_per_period;
let mut price = Decimal::ZERO;
for &(t, cf) in cashflows {
let periods = (t * freq_d).round();
let n = periods.to_string().parse::<u32>().unwrap_or(1);
let mut discount = Decimal::ONE;
for _ in 0..n {
discount *= one_plus_y;
}
if !discount.is_zero() {
price += cf / discount;
}
}
price
}
fn bond_price_derivative(cashflows: &[(Decimal, Decimal)], y_annual: Decimal, freq: u8) -> Decimal {
let freq_d = Decimal::from(freq);
let y_per_period = y_annual / freq_d;
let one_plus_y = Decimal::ONE + y_per_period;
let mut deriv = Decimal::ZERO;
for &(t, cf) in cashflows {
let periods = (t * freq_d).round();
let n = periods.to_string().parse::<u32>().unwrap_or(1);
let mut discount = Decimal::ONE;
for _ in 0..=n {
discount *= one_plus_y;
}
if !discount.is_zero() {
deriv -= Decimal::from(n) / freq_d * cf / discount;
}
}
deriv
}
fn solve_ytm(
input: &CreditSpreadInput,
cashflows: &[(Decimal, Decimal)],
) -> CorpFinanceResult<Rate> {
let mut y = if input.face_value.is_zero() {
dec!(0.05)
} else {
input.coupon_rate
+ (input.face_value - input.market_price) / (input.face_value * input.years_to_maturity)
};
if y < dec!(-0.50) {
y = dec!(-0.50);
} else if y > dec!(2.0) {
y = dec!(2.0);
}
let freq = input.coupon_frequency;
for iter in 0..MAX_ITERATIONS {
let price = bond_price_from_yield(cashflows, y, freq);
let residual = price - input.market_price;
if residual.abs() < EPSILON {
return Ok(y);
}
let dpdy = bond_price_derivative(cashflows, y, freq);
if dpdy.is_zero() {
return Err(CorpFinanceError::ConvergenceFailure {
function: "YTM solver".into(),
iterations: iter,
last_delta: residual,
});
}
y -= residual / dpdy;
if y < dec!(-0.99) {
y = dec!(-0.99);
} else if y > dec!(5.0) {
y = dec!(5.0);
}
}
Err(CorpFinanceError::ConvergenceFailure {
function: "YTM solver".into(),
iterations: MAX_ITERATIONS,
last_delta: bond_price_from_yield(cashflows, y, freq) - input.market_price,
})
}
fn interpolate_rate(curve: &[BenchmarkPoint], t: Decimal) -> CorpFinanceResult<Rate> {
if curve.len() < 2 {
return Err(CorpFinanceError::InsufficientData(
"Need at least 2 benchmark points for interpolation".into(),
));
}
let idx = curve.partition_point(|p| p.maturity < t);
let (left, right) = if idx == 0 {
(&curve[0], &curve[1])
} else if idx >= curve.len() {
(&curve[curve.len() - 2], &curve[curve.len() - 1])
} else {
(&curve[idx - 1], &curve[idx])
};
let span = right.maturity - left.maturity;
if span.is_zero() {
return Ok(left.rate);
}
let weight = (t - left.maturity) / span;
Ok(left.rate + weight * (right.rate - left.rate))
}
fn price_with_z_spread(
cashflows: &[(Decimal, Decimal)],
curve: &[BenchmarkPoint],
z: Decimal,
) -> CorpFinanceResult<Decimal> {
let mut price = Decimal::ZERO;
for &(t, cf) in cashflows {
let spot = interpolate_rate(curve, t)?;
let annual_rate = spot + z;
let discount = iterative_discount(annual_rate, t);
if !discount.is_zero() {
price += cf / discount;
}
}
Ok(price)
}
fn iterative_discount(annual_rate: Decimal, t: Decimal) -> Decimal {
let one_plus_r = Decimal::ONE + annual_rate;
let whole = t.floor();
let n = whole.to_string().parse::<u32>().unwrap_or(0);
let mut factor = Decimal::ONE;
for _ in 0..n {
factor *= one_plus_r;
}
let frac = t - whole;
if frac > Decimal::ZERO {
factor *= Decimal::ONE + frac * annual_rate;
}
factor
}
fn z_spread_price_derivative(
cashflows: &[(Decimal, Decimal)],
curve: &[BenchmarkPoint],
z: Decimal,
) -> CorpFinanceResult<Decimal> {
let mut deriv = Decimal::ZERO;
for &(t, cf) in cashflows {
let spot = interpolate_rate(curve, t)?;
let annual_rate = spot + z;
let one_plus_r = Decimal::ONE + annual_rate;
let discount_t_plus_1 = iterative_discount(annual_rate, t) * one_plus_r;
if !discount_t_plus_1.is_zero() {
deriv -= t * cf / discount_t_plus_1;
}
}
Ok(deriv)
}
fn solve_z_spread(
input: &CreditSpreadInput,
cashflows: &[(Decimal, Decimal)],
) -> CorpFinanceResult<Rate> {
let benchmark_yield = interpolate_rate(&input.benchmark_curve, input.years_to_maturity)?;
let ytm_guess = input.coupon_rate
+ (input.face_value - input.market_price) / (input.face_value * input.years_to_maturity);
let mut z = ytm_guess - benchmark_yield;
if z < dec!(-0.50) {
z = dec!(-0.50);
} else if z > dec!(2.0) {
z = dec!(2.0);
}
for iter in 0..MAX_ITERATIONS {
let price = price_with_z_spread(cashflows, &input.benchmark_curve, z)?;
let residual = price - input.market_price;
if residual.abs() < EPSILON {
return Ok(z);
}
let dprice_dz = z_spread_price_derivative(cashflows, &input.benchmark_curve, z)?;
if dprice_dz.is_zero() {
return Err(CorpFinanceError::ConvergenceFailure {
function: "Z-spread solver".into(),
iterations: iter,
last_delta: residual,
});
}
z -= residual / dprice_dz;
if z < dec!(-0.99) {
z = dec!(-0.99);
} else if z > dec!(5.0) {
z = dec!(5.0);
}
}
Err(CorpFinanceError::ConvergenceFailure {
function: "Z-spread solver".into(),
iterations: MAX_ITERATIONS,
last_delta: price_with_z_spread(cashflows, &input.benchmark_curve, z)? - input.market_price,
})
}
fn compute_spread_duration(
input: &CreditSpreadInput,
cashflows: &[(Decimal, Decimal)],
z: Decimal,
) -> CorpFinanceResult<Decimal> {
let p_down = price_with_z_spread(cashflows, &input.benchmark_curve, z - ONE_BP)?;
let p_up = price_with_z_spread(cashflows, &input.benchmark_curve, z + ONE_BP)?;
let p_base = input.market_price;
if p_base.is_zero() {
return Err(CorpFinanceError::DivisionByZero {
context: "spread duration denominator (market_price)".into(),
});
}
let denom = dec!(2) * p_base * ONE_BP;
if denom.is_zero() {
return Err(CorpFinanceError::DivisionByZero {
context: "spread duration denominator".into(),
});
}
Ok((p_down - p_up) / denom)
}
fn classify_credit_quality(z_spread: Rate) -> String {
if z_spread < IG_CEILING {
"investment_grade".to_string()
} else if z_spread < HY_CEILING {
"high_yield".to_string()
} else {
"distressed".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn flat_curve(rate: Rate) -> Vec<BenchmarkPoint> {
vec![
BenchmarkPoint {
maturity: dec!(1),
rate,
},
BenchmarkPoint {
maturity: dec!(5),
rate,
},
BenchmarkPoint {
maturity: dec!(10),
rate,
},
BenchmarkPoint {
maturity: dec!(30),
rate,
},
]
}
fn sample_curve() -> Vec<BenchmarkPoint> {
vec![
BenchmarkPoint {
maturity: dec!(1),
rate: dec!(0.03),
},
BenchmarkPoint {
maturity: dec!(2),
rate: dec!(0.035),
},
BenchmarkPoint {
maturity: dec!(5),
rate: dec!(0.04),
},
BenchmarkPoint {
maturity: dec!(10),
rate: dec!(0.045),
},
BenchmarkPoint {
maturity: dec!(30),
rate: dec!(0.05),
},
]
}
fn par_bond_input(curve_rate: Rate) -> CreditSpreadInput {
CreditSpreadInput {
face_value: dec!(1000),
coupon_rate: curve_rate,
coupon_frequency: 2,
market_price: dec!(1000),
years_to_maturity: dec!(5),
benchmark_curve: flat_curve(curve_rate),
recovery_rate: None,
default_probability: None,
}
}
#[test]
fn test_par_bond_zero_spread() {
let input = par_bond_input(dec!(0.05));
let result = calculate_credit_spreads(&input).unwrap();
let out = &result.result;
assert!(
(out.ytm - dec!(0.05)).abs() < dec!(0.001),
"YTM {}, expected ~0.05",
out.ytm
);
assert!(
out.i_spread.abs() < dec!(0.001),
"I-spread {}, expected ~0",
out.i_spread
);
assert!(
out.z_spread.abs() < dec!(0.002),
"Z-spread {}, expected ~0",
out.z_spread
);
}
#[test]
fn test_i_spread_positive() {
let input = CreditSpreadInput {
face_value: dec!(1000),
coupon_rate: dec!(0.04),
coupon_frequency: 2,
market_price: dec!(950),
years_to_maturity: dec!(5),
benchmark_curve: flat_curve(dec!(0.03)),
recovery_rate: None,
default_probability: None,
};
let result = calculate_credit_spreads(&input).unwrap();
assert!(
result.result.i_spread > Decimal::ZERO,
"I-spread should be positive for a discount bond, got {}",
result.result.i_spread
);
}
#[test]
fn test_z_spread_positive() {
let input = CreditSpreadInput {
face_value: dec!(1000),
coupon_rate: dec!(0.04),
coupon_frequency: 2,
market_price: dec!(950),
years_to_maturity: dec!(5),
benchmark_curve: flat_curve(dec!(0.03)),
recovery_rate: None,
default_probability: None,
};
let result = calculate_credit_spreads(&input).unwrap();
assert!(
result.result.z_spread > Decimal::ZERO,
"Z-spread should be positive, got {}",
result.result.z_spread
);
}
#[test]
fn test_z_spread_vs_i_spread() {
let input = CreditSpreadInput {
face_value: dec!(1000),
coupon_rate: dec!(0.05),
coupon_frequency: 2,
market_price: dec!(960),
years_to_maturity: dec!(7),
benchmark_curve: sample_curve(),
recovery_rate: None,
default_probability: None,
};
let result = calculate_credit_spreads(&input).unwrap();
let out = &result.result;
assert!(out.i_spread > Decimal::ZERO);
assert!(out.z_spread > Decimal::ZERO);
assert!(
(out.z_spread - out.i_spread).abs() < dec!(0.01),
"Z-spread {} and I-spread {} should be relatively close",
out.z_spread,
out.i_spread
);
}
#[test]
fn test_z_spread_flat_curve_equals_i_spread() {
let input = CreditSpreadInput {
face_value: dec!(1000),
coupon_rate: dec!(0.06),
coupon_frequency: 2,
market_price: dec!(970),
years_to_maturity: dec!(5),
benchmark_curve: flat_curve(dec!(0.04)),
recovery_rate: None,
default_probability: None,
};
let result = calculate_credit_spreads(&input).unwrap();
let out = &result.result;
let diff = (out.z_spread - out.i_spread).abs();
assert!(
diff < dec!(0.002),
"On a flat curve, Z-spread ({}) and I-spread ({}) should be nearly equal (diff: {})",
out.z_spread,
out.i_spread,
diff
);
}
#[test]
fn test_spread_duration_positive() {
let input = CreditSpreadInput {
face_value: dec!(1000),
coupon_rate: dec!(0.05),
coupon_frequency: 2,
market_price: dec!(980),
years_to_maturity: dec!(5),
benchmark_curve: flat_curve(dec!(0.04)),
recovery_rate: None,
default_probability: None,
};
let result = calculate_credit_spreads(&input).unwrap();
assert!(
result.result.spread_duration > Decimal::ZERO,
"Spread duration should be positive, got {}",
result.result.spread_duration
);
assert!(
result.result.spread_duration < dec!(10),
"Spread duration {} seems unreasonably high for a 5y bond",
result.result.spread_duration
);
}
#[test]
fn test_cds_spread_calculation() {
let input = CreditSpreadInput {
face_value: dec!(1000),
coupon_rate: dec!(0.05),
coupon_frequency: 2,
market_price: dec!(1000),
years_to_maturity: dec!(5),
benchmark_curve: flat_curve(dec!(0.05)),
recovery_rate: Some(dec!(0.40)),
default_probability: Some(dec!(0.02)),
};
let result = calculate_credit_spreads(&input).unwrap();
let cds = result.result.cds_spread.unwrap();
assert_eq!(cds, dec!(0.012));
}
#[test]
fn test_investment_grade_indicator() {
let input = CreditSpreadInput {
face_value: dec!(1000),
coupon_rate: dec!(0.045),
coupon_frequency: 2,
market_price: dec!(995),
years_to_maturity: dec!(5),
benchmark_curve: flat_curve(dec!(0.04)),
recovery_rate: None,
default_probability: None,
};
let result = calculate_credit_spreads(&input).unwrap();
assert_eq!(
result.result.credit_quality_indicator, "investment_grade",
"Z-spread {} should classify as investment_grade",
result.result.z_spread
);
}
#[test]
fn test_high_yield_indicator() {
let input = CreditSpreadInput {
face_value: dec!(1000),
coupon_rate: dec!(0.05),
coupon_frequency: 2,
market_price: dec!(850),
years_to_maturity: dec!(5),
benchmark_curve: flat_curve(dec!(0.03)),
recovery_rate: None,
default_probability: None,
};
let result = calculate_credit_spreads(&input).unwrap();
let z = result.result.z_spread;
assert!(
z >= IG_CEILING && z < HY_CEILING,
"Z-spread {} should be in high_yield range [{}, {})",
z,
IG_CEILING,
HY_CEILING
);
assert_eq!(result.result.credit_quality_indicator, "high_yield");
}
#[test]
fn test_distressed_indicator() {
let input = CreditSpreadInput {
face_value: dec!(1000),
coupon_rate: dec!(0.05),
coupon_frequency: 2,
market_price: dec!(600),
years_to_maturity: dec!(5),
benchmark_curve: flat_curve(dec!(0.03)),
recovery_rate: None,
default_probability: None,
};
let result = calculate_credit_spreads(&input).unwrap();
assert_eq!(
result.result.credit_quality_indicator, "distressed",
"Z-spread {} should classify as distressed",
result.result.z_spread
);
}
#[test]
fn test_benchmark_interpolation() {
let curve = sample_curve();
let rate = interpolate_rate(&curve, dec!(3)).unwrap();
let expected = dec!(0.035) + (dec!(1) / dec!(3)) * dec!(0.005);
assert!(
(rate - expected).abs() < dec!(0.0001),
"Interpolated rate {} expected ~{}",
rate,
expected
);
let rate_exact = interpolate_rate(&curve, dec!(5)).unwrap();
assert!(
(rate_exact - dec!(0.04)).abs() < dec!(0.0001),
"At exact point, rate {} expected 0.04",
rate_exact
);
}
#[test]
fn test_ytm_solve_accuracy() {
let input = CreditSpreadInput {
face_value: dec!(1000),
coupon_rate: dec!(0.05),
coupon_frequency: 2,
market_price: dec!(1000),
years_to_maturity: dec!(5),
benchmark_curve: flat_curve(dec!(0.05)),
recovery_rate: None,
default_probability: None,
};
let result = calculate_credit_spreads(&input).unwrap();
assert!(
(result.result.ytm - dec!(0.05)).abs() < dec!(0.001),
"YTM {} should be ~0.05 for a par bond",
result.result.ytm
);
let input2 = CreditSpreadInput {
face_value: dec!(1000),
coupon_rate: dec!(0.06),
coupon_frequency: 1,
market_price: dec!(950),
years_to_maturity: dec!(2),
benchmark_curve: flat_curve(dec!(0.04)),
recovery_rate: None,
default_probability: None,
};
let result2 = calculate_credit_spreads(&input2).unwrap();
assert!(
result2.result.ytm > dec!(0.06),
"YTM {} should exceed coupon rate 0.06 for a discount bond",
result2.result.ytm
);
}
#[test]
fn test_invalid_face_value_error() {
let input = CreditSpreadInput {
face_value: dec!(-100),
coupon_rate: dec!(0.05),
coupon_frequency: 2,
market_price: dec!(1000),
years_to_maturity: dec!(5),
benchmark_curve: flat_curve(dec!(0.05)),
recovery_rate: None,
default_probability: None,
};
let err = calculate_credit_spreads(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "face_value");
}
other => panic!("Expected InvalidInput for face_value, got {other:?}"),
}
}
#[test]
fn test_insufficient_benchmark_points_error() {
let input = CreditSpreadInput {
face_value: dec!(1000),
coupon_rate: dec!(0.05),
coupon_frequency: 2,
market_price: dec!(1000),
years_to_maturity: dec!(5),
benchmark_curve: vec![BenchmarkPoint {
maturity: dec!(5),
rate: dec!(0.04),
}],
recovery_rate: None,
default_probability: None,
};
let err = calculate_credit_spreads(&input).unwrap_err();
match err {
CorpFinanceError::InsufficientData(_) => {}
other => panic!("Expected InsufficientData, got {other:?}"),
}
}
#[test]
fn test_recovery_rate_bounds_error() {
let input = CreditSpreadInput {
face_value: dec!(1000),
coupon_rate: dec!(0.05),
coupon_frequency: 2,
market_price: dec!(1000),
years_to_maturity: dec!(5),
benchmark_curve: flat_curve(dec!(0.05)),
recovery_rate: Some(dec!(1.5)),
default_probability: Some(dec!(0.01)),
};
let err = calculate_credit_spreads(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "recovery_rate");
}
other => panic!("Expected InvalidInput for recovery_rate, got {other:?}"),
}
}
#[test]
fn test_metadata_populated() {
let input = par_bond_input(dec!(0.05));
let result = calculate_credit_spreads(&input).unwrap();
assert_eq!(
result.methodology,
"Credit Spreads (I-spread, G-spread, Z-spread, CDS)"
);
assert_eq!(result.metadata.precision, "rust_decimal_128bit");
assert!(!result.metadata.version.is_empty());
}
#[test]
fn test_cds_spread_default_recovery() {
let input = CreditSpreadInput {
face_value: dec!(1000),
coupon_rate: dec!(0.05),
coupon_frequency: 2,
market_price: dec!(1000),
years_to_maturity: dec!(5),
benchmark_curve: flat_curve(dec!(0.05)),
recovery_rate: None, default_probability: Some(dec!(0.03)),
};
let result = calculate_credit_spreads(&input).unwrap();
let cds = result.result.cds_spread.unwrap();
assert_eq!(cds, dec!(0.018));
}
#[test]
fn test_no_default_probability_no_cds() {
let input = par_bond_input(dec!(0.05));
let result = calculate_credit_spreads(&input).unwrap();
assert!(result.result.cds_spread.is_none());
}
#[test]
fn test_invalid_coupon_frequency_error() {
let input = CreditSpreadInput {
face_value: dec!(1000),
coupon_rate: dec!(0.05),
coupon_frequency: 3, market_price: dec!(1000),
years_to_maturity: dec!(5),
benchmark_curve: flat_curve(dec!(0.05)),
recovery_rate: None,
default_probability: None,
};
let err = calculate_credit_spreads(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "coupon_frequency");
}
other => panic!("Expected InvalidInput for coupon_frequency, got {other:?}"),
}
}
#[test]
fn test_default_probability_bounds_error() {
let input = CreditSpreadInput {
face_value: dec!(1000),
coupon_rate: dec!(0.05),
coupon_frequency: 2,
market_price: dec!(1000),
years_to_maturity: dec!(5),
benchmark_curve: flat_curve(dec!(0.05)),
recovery_rate: Some(dec!(0.40)),
default_probability: Some(dec!(1.5)),
};
let err = calculate_credit_spreads(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "default_probability");
}
other => panic!("Expected InvalidInput for default_probability, got {other:?}"),
}
}
}