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 NEWTON_MAX_ITERATIONS: u32 = 50;
const NEWTON_EPSILON: Decimal = dec!(0.0000001);
const BINOMIAL_TERMS: u32 = 15;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum MuniBondType {
GeneralObligation,
Revenue,
Assessment,
TaxIncrement,
CertificateOfParticipation,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MuniCashFlow {
pub period: u32,
pub amount: Money,
pub cashflow_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeMinimisResult {
pub is_de_minimis: bool,
pub oid_amount: Money,
pub tax_treatment: String,
pub threshold: Money,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MuniBondInput {
pub bond_name: String,
pub face_value: Money,
pub coupon_rate: Rate,
pub coupon_frequency: u32,
pub maturity_years: Decimal,
pub yield_to_maturity: Rate,
pub federal_tax_rate: Rate,
pub state_tax_rate: Rate,
pub state_tax_exempt: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub amt_rate: Option<Rate>,
pub is_private_activity: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub purchase_price: Option<Money>,
#[serde(skip_serializing_if = "Option::is_none")]
pub par_call_price: Option<Money>,
#[serde(skip_serializing_if = "Option::is_none")]
pub call_date_years: Option<Decimal>,
#[serde(skip_serializing_if = "Option::is_none")]
pub call_premium: Option<Rate>,
pub credit_rating: String,
pub bond_type: MuniBondType,
#[serde(skip_serializing_if = "Option::is_none")]
pub comparable_treasury_yield: Option<Rate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub comparable_corporate_yield: Option<Rate>,
pub day_count: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MuniBondOutput {
pub clean_price: Money,
pub dirty_price: Money,
pub accrued_interest: Money,
pub current_yield: Rate,
pub yield_to_maturity: Rate,
pub tax_equivalent_yield: Rate,
pub state_adjusted_tey: Rate,
pub after_tax_yield: Rate,
#[serde(skip_serializing_if = "Option::is_none")]
pub muni_to_treasury_ratio: Option<Rate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub taxable_equivalent_spread: Option<Decimal>,
#[serde(skip_serializing_if = "Option::is_none")]
pub corporate_spread_pickup: Option<Decimal>,
#[serde(skip_serializing_if = "Option::is_none")]
pub de_minimis_analysis: Option<DeMinimisResult>,
#[serde(skip_serializing_if = "Option::is_none")]
pub yield_to_call: Option<Rate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub yield_to_worst: Option<Rate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub call_protection_value: Option<Money>,
#[serde(skip_serializing_if = "Option::is_none")]
pub credit_spread_bps: Option<Decimal>,
pub cashflow_schedule: Vec<MuniCashFlow>,
pub total_return_if_held: Money,
pub warnings: Vec<String>,
}
pub fn price_muni_bond(
input: &MuniBondInput,
) -> CorpFinanceResult<ComputationOutput<MuniBondOutput>> {
let start = Instant::now();
let mut warnings: Vec<String> = Vec::new();
validate_input(input)?;
let freq = Decimal::from(input.coupon_frequency);
let coupon_per_period = input.face_value * input.coupon_rate / freq;
let annual_coupon = input.face_value * input.coupon_rate;
let n_periods = input.maturity_years * freq;
let n_int = decimal_to_u32(n_periods);
let cashflow_schedule = build_cashflow_schedule(n_int, coupon_per_period, input.face_value);
let clean_price = compute_clean_price(
coupon_per_period,
input.face_value,
input.yield_to_maturity,
freq,
n_int,
);
let accrued_interest =
compute_accrued_interest_30_360(input.maturity_years, freq, coupon_per_period);
let dirty_price = clean_price + accrued_interest;
let current_yield = if clean_price > Decimal::ZERO {
annual_coupon / clean_price
} else {
warnings.push("Clean price is zero or negative; current yield undefined".into());
Decimal::ZERO
};
let tey_federal = compute_federal_tey(
input.yield_to_maturity,
input.federal_tax_rate,
input.is_private_activity,
input.amt_rate,
&mut warnings,
);
let state_adjusted_tey = compute_state_adjusted_tey(
input.yield_to_maturity,
input.federal_tax_rate,
input.state_tax_rate,
input.state_tax_exempt,
input.is_private_activity,
input.amt_rate,
&mut warnings,
);
let after_tax_yield = input.yield_to_maturity;
let muni_to_treasury_ratio = input.comparable_treasury_yield.map(|tsy| {
if tsy > Decimal::ZERO {
input.yield_to_maturity / tsy
} else {
warnings.push("Treasury yield is zero; ratio undefined".into());
Decimal::ZERO
}
});
let taxable_equivalent_spread = input
.comparable_treasury_yield
.map(|tsy| (state_adjusted_tey - tsy) * dec!(10000));
let corporate_spread_pickup = input
.comparable_corporate_yield
.map(|corp| (state_adjusted_tey - corp) * dec!(10000));
let de_minimis_analysis = input
.purchase_price
.map(|pp| compute_de_minimis(input.face_value, pp, input.maturity_years));
let (yield_to_call, yield_to_worst, call_protection_value) =
compute_call_analysis(input, clean_price, coupon_per_period, freq, &mut warnings);
let credit_spread_bps = compute_credit_spread(&input.credit_rating);
let total_return_if_held = annual_coupon * input.maturity_years + input.face_value;
let output = MuniBondOutput {
clean_price,
dirty_price,
accrued_interest,
current_yield,
yield_to_maturity: input.yield_to_maturity,
tax_equivalent_yield: tey_federal,
state_adjusted_tey,
after_tax_yield,
muni_to_treasury_ratio,
taxable_equivalent_spread,
corporate_spread_pickup,
de_minimis_analysis,
yield_to_call,
yield_to_worst,
call_protection_value,
credit_spread_bps,
cashflow_schedule,
total_return_if_held,
warnings: warnings.clone(),
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"Municipal Bond Pricing — tax-exempt PV with TEY, de minimis, and call analysis",
input,
warnings,
elapsed,
output,
))
}
fn validate_input(input: &MuniBondInput) -> CorpFinanceResult<()> {
if input.face_value <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "face_value".into(),
reason: "Face value must be positive".into(),
});
}
if input.coupon_rate < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "coupon_rate".into(),
reason: "Coupon rate cannot be negative".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.maturity_years <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "maturity_years".into(),
reason: "Maturity years must be positive".into(),
});
}
if input.federal_tax_rate < Decimal::ZERO || input.federal_tax_rate >= Decimal::ONE {
return Err(CorpFinanceError::InvalidInput {
field: "federal_tax_rate".into(),
reason: "Federal tax rate must be in [0, 1)".into(),
});
}
if input.state_tax_rate < Decimal::ZERO || input.state_tax_rate >= Decimal::ONE {
return Err(CorpFinanceError::InvalidInput {
field: "state_tax_rate".into(),
reason: "State tax rate must be in [0, 1)".into(),
});
}
if let Some(amt) = input.amt_rate {
if amt < Decimal::ZERO || amt >= Decimal::ONE {
return Err(CorpFinanceError::InvalidInput {
field: "amt_rate".into(),
reason: "AMT rate must be in [0, 1)".into(),
});
}
}
if let Some(call_years) = input.call_date_years {
if call_years <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "call_date_years".into(),
reason: "Call date years must be positive".into(),
});
}
if call_years >= input.maturity_years {
return Err(CorpFinanceError::InvalidInput {
field: "call_date_years".into(),
reason: "Call date must be before maturity".into(),
});
}
}
if input.day_count != "30/360" {
return Err(CorpFinanceError::InvalidInput {
field: "day_count".into(),
reason: "Municipal bonds use 30/360 day count convention".into(),
});
}
Ok(())
}
fn compute_clean_price(
coupon_per_period: Money,
face_value: Money,
ytm: Rate,
freq: Decimal,
n_periods: u32,
) -> Money {
if n_periods == 0 {
return face_value;
}
let periodic_yield = ytm / freq;
let one_plus_py = Decimal::ONE + periodic_yield;
let mut pv = Decimal::ZERO;
let mut discount_factor = Decimal::ONE;
for i in 0..n_periods {
discount_factor *= one_plus_py;
if discount_factor.is_zero() {
continue;
}
let cashflow = if i == n_periods - 1 {
coupon_per_period + face_value
} else {
coupon_per_period
};
pv += cashflow / discount_factor;
}
pv
}
fn compute_accrued_interest_30_360(
maturity_years: Decimal,
freq: Decimal,
coupon_per_period: Money,
) -> Money {
let total_periods = maturity_years * freq;
let total_periods_rounded = total_periods.round();
let fractional = (total_periods - total_periods_rounded).abs();
if fractional < dec!(0.001) {
return Decimal::ZERO;
}
let periods_remaining_frac = total_periods - total_periods.floor();
let elapsed_frac = Decimal::ONE - periods_remaining_frac;
coupon_per_period * elapsed_frac
}
fn compute_federal_tey(
muni_yield: Rate,
federal_tax_rate: Rate,
is_private_activity: bool,
amt_rate: Option<Rate>,
warnings: &mut Vec<String>,
) -> Rate {
let effective_rate = if is_private_activity {
if let Some(amt) = amt_rate {
if amt > federal_tax_rate {
warnings.push(format!(
"AMT rate ({}) exceeds federal rate ({}); using AMT rate for TEY",
amt, federal_tax_rate
));
amt
} else {
federal_tax_rate
}
} else {
warnings
.push("Private activity bond but no AMT rate provided; using federal rate".into());
federal_tax_rate
}
} else {
federal_tax_rate
};
let denominator = Decimal::ONE - effective_rate;
if denominator <= Decimal::ZERO {
return muni_yield; }
muni_yield / denominator
}
fn compute_state_adjusted_tey(
muni_yield: Rate,
federal_tax_rate: Rate,
state_tax_rate: Rate,
state_tax_exempt: bool,
is_private_activity: bool,
amt_rate: Option<Rate>,
warnings: &mut Vec<String>,
) -> Rate {
let effective_fed_rate = if is_private_activity {
amt_rate
.filter(|&amt| amt > federal_tax_rate)
.unwrap_or(federal_tax_rate)
} else {
federal_tax_rate
};
if state_tax_exempt {
let combined = effective_fed_rate + state_tax_rate * (Decimal::ONE - effective_fed_rate);
let denominator = Decimal::ONE - combined;
if denominator <= Decimal::ZERO {
warnings.push("Combined tax rate >= 100%; TEY calculation clamped".into());
return muni_yield;
}
muni_yield / denominator
} else {
let denominator = Decimal::ONE - effective_fed_rate;
if denominator <= Decimal::ZERO {
return muni_yield;
}
muni_yield / denominator
}
}
fn compute_de_minimis(
face_value: Money,
purchase_price: Money,
maturity_years: Decimal,
) -> DeMinimisResult {
let market_discount = face_value - purchase_price;
let threshold = face_value * dec!(0.0025) * maturity_years;
if market_discount <= Decimal::ZERO {
return DeMinimisResult {
is_de_minimis: false,
oid_amount: Decimal::ZERO,
tax_treatment: "No market discount — purchased at par or premium".into(),
threshold,
};
}
if market_discount <= threshold {
DeMinimisResult {
is_de_minimis: true,
oid_amount: market_discount,
tax_treatment: format!(
"De minimis: discount ({}) <= threshold ({}). \
Discount taxed as capital gain at maturity.",
market_discount, threshold
),
threshold,
}
} else {
DeMinimisResult {
is_de_minimis: false,
oid_amount: market_discount,
tax_treatment: format!(
"Exceeds de minimis: discount ({}) > threshold ({}). \
Entire discount taxed as ordinary income.",
market_discount, threshold
),
threshold,
}
}
}
fn compute_call_analysis(
input: &MuniBondInput,
clean_price: Money,
coupon_per_period: Money,
freq: Decimal,
warnings: &mut Vec<String>,
) -> (Option<Rate>, Option<Rate>, Option<Money>) {
let call_date_years = match input.call_date_years {
Some(y) => y,
None => return (None, None, None),
};
let call_premium = input.call_premium.unwrap_or(Decimal::ZERO);
let par_call = input.par_call_price.unwrap_or(input.face_value);
let call_price = par_call * (Decimal::ONE + call_premium);
let n_call = decimal_to_u32(call_date_years * freq);
if n_call == 0 {
warnings.push("Call date too near; cannot compute YTC".into());
return (None, None, None);
}
match solve_ytc_newton(clean_price, coupon_per_period, call_price, n_call, freq) {
Ok(ytc) => {
let ytw = if ytc < input.yield_to_maturity {
ytc
} else {
input.yield_to_maturity
};
let n_mat = decimal_to_u32(input.maturity_years * freq);
let price_to_maturity =
compute_clean_price(coupon_per_period, input.face_value, ytw, freq, n_mat);
let price_to_call =
compute_clean_price(coupon_per_period, call_price, ytw, freq, n_call);
let call_prot = if price_to_maturity > price_to_call {
price_to_maturity - price_to_call
} else {
Decimal::ZERO
};
(Some(ytc), Some(ytw), Some(call_prot))
}
Err(_) => {
warnings.push("YTC Newton-Raphson did not converge".into());
(None, None, None)
}
}
}
fn solve_ytc_newton(
target_price: Money,
coupon_per_period: Money,
call_price: Money,
n_periods: u32,
freq: Decimal,
) -> CorpFinanceResult<Rate> {
let total_cf = coupon_per_period * Decimal::from(n_periods) + call_price;
let mut y = if target_price > Decimal::ZERO && Decimal::from(n_periods) > Decimal::ZERO {
let n_years = Decimal::from(n_periods) / freq;
((total_cf / target_price) - Decimal::ONE) / n_years
} else {
dec!(0.04)
};
for iteration in 0..NEWTON_MAX_ITERATIONS {
let periodic_y = y / freq;
let one_plus_py = Decimal::ONE + periodic_y;
if one_plus_py <= Decimal::ZERO {
y = dec!(0.01);
continue;
}
let mut price = Decimal::ZERO;
let mut dprice = Decimal::ZERO;
let mut factor = Decimal::ONE;
for i in 1..=n_periods {
factor *= one_plus_py;
if factor.is_zero() {
break;
}
let cf = if i == n_periods {
coupon_per_period + call_price
} else {
coupon_per_period
};
price += cf / factor;
let i_dec = Decimal::from(i);
dprice -= i_dec / freq * cf / (factor * one_plus_py);
}
let f_val = price - target_price;
if f_val.abs() < NEWTON_EPSILON {
return Ok(y);
}
if dprice.is_zero() {
return Err(CorpFinanceError::ConvergenceFailure {
function: "Muni YTC".into(),
iterations: iteration,
last_delta: f_val,
});
}
y -= f_val / dprice;
if y < dec!(-0.50) {
y = dec!(-0.50);
} else if y > dec!(1.0) {
y = dec!(1.0);
}
}
Err(CorpFinanceError::ConvergenceFailure {
function: "Muni YTC".into(),
iterations: NEWTON_MAX_ITERATIONS,
last_delta: Decimal::ZERO,
})
}
fn compute_credit_spread(credit_rating: &str) -> Option<Decimal> {
let rating_upper = credit_rating.to_uppercase();
let bps = match rating_upper.as_str() {
"AAA" => dec!(0),
"AA+" | "AA" | "AA-" => dec!(30),
"A+" | "A" | "A-" => dec!(75),
"BBB+" | "BBB" | "BBB-" => dec!(150),
"BB+" | "BB" | "BB-" => dec!(300),
"B+" | "B" | "B-" => dec!(500),
_ => return None,
};
Some(bps)
}
fn build_cashflow_schedule(
n_periods: u32,
coupon_per_period: Money,
face_value: Money,
) -> Vec<MuniCashFlow> {
let mut schedule = Vec::with_capacity(n_periods as usize);
for i in 1..=n_periods {
let is_last = i == n_periods;
let (amount, cf_type) = if is_last {
(
coupon_per_period + face_value,
"coupon+principal".to_string(),
)
} else {
(coupon_per_period, "coupon".to_string())
};
schedule.push(MuniCashFlow {
period: i,
amount,
cashflow_type: cf_type,
});
}
schedule
}
fn decimal_to_u32(d: Decimal) -> u32 {
let rounded = d.round();
if rounded < Decimal::ZERO {
0
} else {
rounded.to_string().parse::<u32>().unwrap_or(0)
}
}
#[allow(dead_code)]
fn decimal_pow_fraction(base: Decimal, frac: Decimal) -> Decimal {
if frac.is_zero() {
return Decimal::ONE;
}
if frac == Decimal::ONE {
return base;
}
if base == Decimal::ONE {
return Decimal::ONE;
}
let x = base - Decimal::ONE;
let mut result = Decimal::ONE;
let mut term = Decimal::ONE;
for k in 1..=BINOMIAL_TERMS {
let k_dec = Decimal::from(k);
term *= (frac - k_dec + Decimal::ONE) * x / k_dec;
result += term;
if term.abs() < dec!(0.00000000001) {
break;
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn standard_muni() -> MuniBondInput {
MuniBondInput {
bond_name: "Test Muni GO".to_string(),
face_value: dec!(5000),
coupon_rate: dec!(0.05),
coupon_frequency: 2,
maturity_years: dec!(10),
yield_to_maturity: dec!(0.05),
federal_tax_rate: dec!(0.37),
state_tax_rate: dec!(0.05),
state_tax_exempt: true,
amt_rate: None,
is_private_activity: false,
purchase_price: None,
par_call_price: None,
call_date_years: None,
call_premium: None,
credit_rating: "AA".to_string(),
bond_type: MuniBondType::GeneralObligation,
comparable_treasury_yield: None,
comparable_corporate_yield: None,
day_count: "30/360".to_string(),
}
}
#[test]
fn test_muni_par_bond_price() {
let input = standard_muni();
let result = price_muni_bond(&input).unwrap();
let out = &result.result;
let diff = (out.clean_price - dec!(5000)).abs();
assert!(
diff < dec!(1.0),
"Par muni bond clean price should be ~5000, got {}",
out.clean_price
);
assert_eq!(out.yield_to_maturity, dec!(0.05));
}
#[test]
fn test_tax_equivalent_yield_federal() {
let mut input = standard_muni();
input.yield_to_maturity = dec!(0.03);
input.coupon_rate = dec!(0.03);
input.federal_tax_rate = dec!(0.37);
input.state_tax_exempt = false;
let result = price_muni_bond(&input).unwrap();
let out = &result.result;
let expected_tey = dec!(0.03) / dec!(0.63);
let diff = (out.tax_equivalent_yield - expected_tey).abs();
assert!(
diff < dec!(0.001),
"Federal TEY should be ~{}, got {}",
expected_tey,
out.tax_equivalent_yield
);
}
#[test]
fn test_state_adjusted_tey_in_state() {
let mut input = standard_muni();
input.yield_to_maturity = dec!(0.03);
input.coupon_rate = dec!(0.03);
input.federal_tax_rate = dec!(0.37);
input.state_tax_rate = dec!(0.05);
input.state_tax_exempt = true;
let result = price_muni_bond(&input).unwrap();
let out = &result.result;
let combined = dec!(0.37) + dec!(0.05) * (Decimal::ONE - dec!(0.37));
let expected = dec!(0.03) / (Decimal::ONE - combined);
let diff = (out.state_adjusted_tey - expected).abs();
assert!(
diff < dec!(0.001),
"State-adjusted TEY should be ~{}, got {}",
expected,
out.state_adjusted_tey
);
assert!(
out.state_adjusted_tey > out.tax_equivalent_yield,
"State-adjusted TEY ({}) should exceed federal TEY ({})",
out.state_adjusted_tey,
out.tax_equivalent_yield
);
}
#[test]
fn test_de_minimis_below_threshold() {
let mut input = standard_muni();
input.purchase_price = Some(dec!(4900));
let result = price_muni_bond(&input).unwrap();
let dm = result.result.de_minimis_analysis.unwrap();
assert!(
dm.is_de_minimis,
"Discount of 100 should be within de minimis threshold of 125"
);
assert_eq!(dm.oid_amount, dec!(100));
assert!(dm.tax_treatment.contains("capital gain"));
assert_eq!(dm.threshold, dec!(125));
}
#[test]
fn test_de_minimis_above_threshold() {
let mut input = standard_muni();
input.purchase_price = Some(dec!(4800));
let result = price_muni_bond(&input).unwrap();
let dm = result.result.de_minimis_analysis.unwrap();
assert!(
!dm.is_de_minimis,
"Discount of 200 should exceed de minimis threshold of 125"
);
assert_eq!(dm.oid_amount, dec!(200));
assert!(dm.tax_treatment.contains("ordinary income"));
}
#[test]
fn test_yield_to_call() {
let mut input = standard_muni();
input.coupon_rate = dec!(0.05);
input.yield_to_maturity = dec!(0.04); input.par_call_price = Some(dec!(5000));
input.call_date_years = Some(dec!(5));
input.call_premium = Some(dec!(0.02));
let result = price_muni_bond(&input).unwrap();
let out = &result.result;
assert!(
out.yield_to_call.is_some(),
"YTC should be computed for callable bond"
);
let ytc = out.yield_to_call.unwrap();
assert!(
ytc > dec!(0.01) && ytc < dec!(0.15),
"YTC should be a reasonable rate, got {}",
ytc
);
}
#[test]
fn test_yield_to_worst() {
let mut input = standard_muni();
input.coupon_rate = dec!(0.05);
input.yield_to_maturity = dec!(0.04);
input.par_call_price = Some(dec!(5000));
input.call_date_years = Some(dec!(5));
input.call_premium = Some(dec!(0.02));
let result = price_muni_bond(&input).unwrap();
let out = &result.result;
assert!(out.yield_to_worst.is_some(), "YTW should be computed");
let ytw = out.yield_to_worst.unwrap();
let ytc = out.yield_to_call.unwrap();
let expected_min = if input.yield_to_maturity < ytc {
input.yield_to_maturity
} else {
ytc
};
assert_eq!(
ytw, expected_min,
"YTW should be min(YTM={}, YTC={}), got {}",
input.yield_to_maturity, ytc, ytw
);
}
#[test]
fn test_muni_treasury_ratio() {
let mut input = standard_muni();
input.yield_to_maturity = dec!(0.03);
input.coupon_rate = dec!(0.03);
input.comparable_treasury_yield = Some(dec!(0.04));
let result = price_muni_bond(&input).unwrap();
let out = &result.result;
let ratio = out.muni_to_treasury_ratio.unwrap();
let expected = dec!(0.75);
let diff = (ratio - expected).abs();
assert!(
diff < dec!(0.001),
"Muni/Treasury ratio should be 0.75, got {}",
ratio
);
}
#[test]
fn test_amt_adjusted_tey() {
let mut input = standard_muni();
input.yield_to_maturity = dec!(0.035);
input.coupon_rate = dec!(0.035);
input.federal_tax_rate = dec!(0.37);
input.is_private_activity = true;
input.amt_rate = Some(dec!(0.28));
input.state_tax_exempt = false;
let result = price_muni_bond(&input).unwrap();
let out = &result.result;
let expected = dec!(0.035) / dec!(0.63);
let diff = (out.tax_equivalent_yield - expected).abs();
assert!(
diff < dec!(0.001),
"TEY with AMT < federal should use federal rate, expected ~{}, got {}",
expected,
out.tax_equivalent_yield
);
}
#[test]
fn test_amt_rate_higher_than_federal() {
let mut input = standard_muni();
input.yield_to_maturity = dec!(0.035);
input.coupon_rate = dec!(0.035);
input.federal_tax_rate = dec!(0.24);
input.is_private_activity = true;
input.amt_rate = Some(dec!(0.28));
input.state_tax_exempt = false;
let result = price_muni_bond(&input).unwrap();
let out = &result.result;
let expected = dec!(0.035) / dec!(0.72);
let diff = (out.tax_equivalent_yield - expected).abs();
assert!(
diff < dec!(0.001),
"TEY with AMT > federal should use AMT rate, expected ~{}, got {}",
expected,
out.tax_equivalent_yield
);
}
#[test]
fn test_premium_muni_bond() {
let mut input = standard_muni();
input.coupon_rate = dec!(0.06);
input.yield_to_maturity = dec!(0.04);
let result = price_muni_bond(&input).unwrap();
let out = &result.result;
assert!(
out.clean_price > dec!(5000),
"Premium muni (6% coupon, 4% YTM) should price above par, got {}",
out.clean_price
);
}
#[test]
fn test_discount_muni_bond() {
let mut input = standard_muni();
input.coupon_rate = dec!(0.03);
input.yield_to_maturity = dec!(0.05);
let result = price_muni_bond(&input).unwrap();
let out = &result.result;
assert!(
out.clean_price < dec!(5000),
"Discount muni (3% coupon, 5% YTM) should price below par, got {}",
out.clean_price
);
}
#[test]
fn test_cashflow_schedule() {
let mut input = standard_muni();
input.maturity_years = dec!(3);
input.coupon_frequency = 2;
let result = price_muni_bond(&input).unwrap();
let out = &result.result;
assert_eq!(out.cashflow_schedule.len(), 6);
let coupon_per_period = dec!(5000) * dec!(0.05) / dec!(2); for cf in &out.cashflow_schedule[..5] {
assert_eq!(cf.cashflow_type, "coupon");
assert_eq!(cf.amount, coupon_per_period);
}
let last = &out.cashflow_schedule[5];
assert_eq!(last.cashflow_type, "coupon+principal");
assert_eq!(last.amount, coupon_per_period + dec!(5000));
}
#[test]
fn test_taxable_equivalent_spread() {
let mut input = standard_muni();
input.yield_to_maturity = dec!(0.03);
input.coupon_rate = dec!(0.03);
input.federal_tax_rate = dec!(0.37);
input.state_tax_rate = dec!(0.05);
input.state_tax_exempt = true;
input.comparable_treasury_yield = Some(dec!(0.04));
let result = price_muni_bond(&input).unwrap();
let out = &result.result;
let spread = out.taxable_equivalent_spread.unwrap();
assert!(
spread > Decimal::ZERO,
"TEY spread vs Treasury should be positive, got {}",
spread
);
}
#[test]
fn test_corporate_spread_pickup() {
let mut input = standard_muni();
input.yield_to_maturity = dec!(0.03);
input.coupon_rate = dec!(0.03);
input.federal_tax_rate = dec!(0.37);
input.state_tax_rate = dec!(0.05);
input.state_tax_exempt = true;
input.comparable_corporate_yield = Some(dec!(0.045));
let result = price_muni_bond(&input).unwrap();
let out = &result.result;
assert!(out.corporate_spread_pickup.is_some());
}
#[test]
fn test_credit_spread_ratings() {
assert_eq!(compute_credit_spread("AAA"), Some(dec!(0)));
assert_eq!(compute_credit_spread("AA"), Some(dec!(30)));
assert_eq!(compute_credit_spread("A"), Some(dec!(75)));
assert_eq!(compute_credit_spread("BBB"), Some(dec!(150)));
assert_eq!(compute_credit_spread("BB"), Some(dec!(300)));
assert_eq!(compute_credit_spread("B"), Some(dec!(500)));
assert_eq!(compute_credit_spread("CCC"), None);
}
#[test]
fn test_total_return_if_held() {
let input = standard_muni();
let result = price_muni_bond(&input).unwrap();
let out = &result.result;
assert_eq!(out.total_return_if_held, dec!(7500));
}
#[test]
fn test_metadata_populated() {
let input = standard_muni();
let result = price_muni_bond(&input).unwrap();
assert!(result.methodology.contains("Municipal Bond Pricing"));
assert_eq!(result.metadata.precision, "rust_decimal_128bit");
assert!(!result.metadata.version.is_empty());
}
#[test]
fn test_invalid_face_value() {
let mut input = standard_muni();
input.face_value = dec!(-1000);
let result = price_muni_bond(&input);
assert!(result.is_err());
match result.unwrap_err() {
CorpFinanceError::InvalidInput { field, .. } => assert_eq!(field, "face_value"),
other => panic!("Expected InvalidInput, got {:?}", other),
}
}
#[test]
fn test_invalid_coupon_frequency() {
let mut input = standard_muni();
input.coupon_frequency = 3;
let result = price_muni_bond(&input);
assert!(result.is_err());
match result.unwrap_err() {
CorpFinanceError::InvalidInput { field, .. } => assert_eq!(field, "coupon_frequency"),
other => panic!("Expected InvalidInput, got {:?}", other),
}
}
#[test]
fn test_invalid_day_count() {
let mut input = standard_muni();
input.day_count = "ACT/360".to_string();
let result = price_muni_bond(&input);
assert!(result.is_err());
match result.unwrap_err() {
CorpFinanceError::InvalidInput { field, .. } => assert_eq!(field, "day_count"),
other => panic!("Expected InvalidInput, got {:?}", other),
}
}
#[test]
fn test_call_date_after_maturity() {
let mut input = standard_muni();
input.call_date_years = Some(dec!(15));
let result = price_muni_bond(&input);
assert!(result.is_err());
match result.unwrap_err() {
CorpFinanceError::InvalidInput { field, .. } => assert_eq!(field, "call_date_years"),
other => panic!("Expected InvalidInput, got {:?}", other),
}
}
#[test]
fn test_dirty_equals_clean_plus_accrued() {
let input = standard_muni();
let result = price_muni_bond(&input).unwrap();
let out = &result.result;
let reconstructed = out.clean_price + out.accrued_interest;
let diff = (out.dirty_price - reconstructed).abs();
assert!(
diff < dec!(0.01),
"Dirty ({}) should equal clean ({}) + accrued ({}), diff = {}",
out.dirty_price,
out.clean_price,
out.accrued_interest,
diff
);
}
#[test]
fn test_out_of_state_no_state_benefit() {
let mut input = standard_muni();
input.yield_to_maturity = dec!(0.03);
input.coupon_rate = dec!(0.03);
input.federal_tax_rate = dec!(0.37);
input.state_tax_rate = dec!(0.05);
input.state_tax_exempt = false;
let result = price_muni_bond(&input).unwrap();
let out = &result.result;
let diff = (out.state_adjusted_tey - out.tax_equivalent_yield).abs();
assert!(
diff < dec!(0.0001),
"Out-of-state: state-adjusted TEY ({}) should equal federal TEY ({})",
out.state_adjusted_tey,
out.tax_equivalent_yield
);
}
#[test]
fn test_de_minimis_at_par() {
let mut input = standard_muni();
input.purchase_price = Some(dec!(5000));
let result = price_muni_bond(&input).unwrap();
let dm = result.result.de_minimis_analysis.unwrap();
assert!(!dm.is_de_minimis);
assert_eq!(dm.oid_amount, Decimal::ZERO);
assert!(dm.tax_treatment.contains("No market discount"));
}
#[test]
fn test_current_yield() {
let input = standard_muni();
let result = price_muni_bond(&input).unwrap();
let out = &result.result;
let diff = (out.current_yield - dec!(0.05)).abs();
assert!(
diff < dec!(0.005),
"Current yield at par should be ~5%, got {}",
out.current_yield
);
}
#[test]
fn test_revenue_bond_type() {
let mut input = standard_muni();
input.bond_type = MuniBondType::Revenue;
let result = price_muni_bond(&input);
assert!(result.is_ok(), "Revenue bond type should be accepted");
}
}