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, Years};
use crate::CorpFinanceResult;
const TAYLOR_EXP_TERMS: u32 = 30;
const NEWTON_ITERATIONS: u32 = 20;
fn decimal_exp(x: Decimal) -> Decimal {
if x < Decimal::ZERO {
let pos_exp = decimal_exp(-x);
if pos_exp == Decimal::ZERO {
return Decimal::ZERO;
}
return Decimal::ONE / pos_exp;
}
let mut k: u32 = 0;
let mut reduced = x;
while reduced > Decimal::ONE {
reduced /= dec!(2);
k += 1;
}
let mut sum = Decimal::ONE;
let mut term = Decimal::ONE;
for n in 1..=TAYLOR_EXP_TERMS {
term = term * reduced / Decimal::from(n);
sum += term;
}
for _ in 0..k {
sum = sum * sum;
}
sum
}
fn decimal_ln(x: Decimal) -> Decimal {
if x <= Decimal::ZERO {
return Decimal::ZERO;
}
let mut y = (x - Decimal::ONE) / (x + Decimal::ONE) * dec!(2);
for _ in 0..NEWTON_ITERATIONS {
let ey = decimal_exp(y);
if ey == Decimal::ZERO {
break;
}
y += dec!(2) * (x - ey) / (x + ey);
}
y
}
fn decimal_sqrt(x: Decimal) -> Decimal {
if x <= Decimal::ZERO {
return Decimal::ZERO;
}
let mut guess = x / dec!(2);
if guess == Decimal::ZERO {
guess = Decimal::ONE;
}
for _ in 0..NEWTON_ITERATIONS {
if guess == Decimal::ZERO {
break;
}
guess = (guess + x / guess) / dec!(2);
}
guess
}
fn decimal_pow(base: Decimal, exp: Decimal) -> Decimal {
if base <= Decimal::ZERO {
return Decimal::ZERO;
}
decimal_exp(exp * decimal_ln(base))
}
fn decimal_powi(base: Decimal, n: u32) -> Decimal {
let mut result = Decimal::ONE;
for _ in 0..n {
result *= base;
}
result
}
fn norm_cdf(x: Decimal) -> Decimal {
if x < dec!(-10) {
return Decimal::ZERO;
}
if x > dec!(10) {
return Decimal::ONE;
}
let is_neg = x < Decimal::ZERO;
let ax = if is_neg { -x } else { x };
let p = dec!(0.2316419);
let b1 = dec!(0.319381530);
let b2 = dec!(-0.356563782);
let b3 = dec!(1.781477937);
let b4 = dec!(-1.821255978);
let b5 = dec!(1.330274429);
let t = Decimal::ONE / (Decimal::ONE + p * ax);
let t2 = t * t;
let t3 = t2 * t;
let t4 = t3 * t;
let t5 = t4 * t;
let inv_sqrt_2pi = dec!(0.3989422804014327);
let pdf = inv_sqrt_2pi * decimal_exp(-ax * ax / dec!(2));
let cdf = Decimal::ONE - pdf * (b1 * t + b2 * t2 + b3 * t3 + b4 * t4 + b5 * t5);
if is_neg {
Decimal::ONE - cdf
} else {
cdf
}
}
#[allow(dead_code)]
fn norm_pdf(x: Decimal) -> Decimal {
let inv_sqrt_2pi = dec!(0.3989422804014327);
inv_sqrt_2pi * decimal_exp(-x * x / dec!(2))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZcisInput {
pub notional: Money,
pub maturity_years: Years,
pub cpi_base: Decimal,
pub cpi_current: Decimal,
pub expected_inflation: Rate,
pub real_discount_rate: Rate,
pub nominal_discount_rate: Rate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZcisOutput {
pub fair_swap_rate: Rate,
pub fixed_leg_pv: Money,
pub floating_leg_pv: Money,
pub swap_npv: Money,
pub breakeven_inflation_implied: Rate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct YyisInput {
pub notional: Money,
pub num_periods: u32,
pub payment_frequency: u8,
pub cpi_base: Decimal,
pub expected_inflation_curve: Vec<Rate>,
pub real_discount_curve: Vec<Rate>,
pub nominal_discount_curve: Vec<Rate>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct YyisCashflow {
pub period: u32,
pub yoy_inflation: Rate,
pub floating_payment: Money,
pub fixed_payment: Money,
pub discount_factor: Decimal,
pub net_pv: Money,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct YyisOutput {
pub fair_swap_rate: Rate,
pub period_cashflows: Vec<YyisCashflow>,
pub fixed_leg_pv: Money,
pub floating_leg_pv: Money,
pub swap_npv: Money,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum InflationOptionType {
Cap,
Floor,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InflationCapFloorInput {
pub notional: Money,
pub strike_rate: Rate,
pub option_type: InflationOptionType,
pub num_periods: u32,
pub expected_inflation_curve: Vec<Rate>,
pub inflation_vol: Rate,
pub discount_curve: Vec<Rate>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapletFloorletValue {
pub period: u32,
pub premium: Money,
pub intrinsic: Money,
pub time_value: Money,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InflationCapFloorOutput {
pub total_premium: Money,
pub caplet_floorlet_values: Vec<CapletFloorletValue>,
pub implied_breakeven: Rate,
pub delta: Decimal,
pub vega: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum InflationDerivativeModel {
Zcis(ZcisInput),
Yyis(YyisInput),
CapFloor(InflationCapFloorInput),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InflationDerivativeInput {
pub model: InflationDerivativeModel,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum InflationDerivativeOutput {
Zcis(ZcisOutput),
Yyis(YyisOutput),
CapFloor(InflationCapFloorOutput),
}
pub fn analyze_inflation_derivatives(
input: &InflationDerivativeInput,
) -> CorpFinanceResult<ComputationOutput<InflationDerivativeOutput>> {
let start = Instant::now();
let warnings: Vec<String> = Vec::new();
let result = match &input.model {
InflationDerivativeModel::Zcis(z) => {
let out = compute_zcis(z)?;
InflationDerivativeOutput::Zcis(out)
}
InflationDerivativeModel::Yyis(y) => {
let out = compute_yyis(y)?;
InflationDerivativeOutput::Yyis(out)
}
InflationDerivativeModel::CapFloor(c) => {
let out = compute_inflation_cap_floor(c)?;
InflationDerivativeOutput::CapFloor(out)
}
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"Inflation Derivatives Analytics",
&serde_json::json!({
"precision": "rust_decimal_128bit",
"taylor_exp_terms": TAYLOR_EXP_TERMS,
"newton_iterations": NEWTON_ITERATIONS,
}),
warnings,
elapsed,
result,
))
}
fn compute_zcis(input: &ZcisInput) -> CorpFinanceResult<ZcisOutput> {
if input.notional <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "notional".into(),
reason: "Must be positive".into(),
});
}
if input.maturity_years <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "maturity_years".into(),
reason: "Must be positive".into(),
});
}
if input.cpi_base <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "cpi_base".into(),
reason: "Must be positive".into(),
});
}
let t = input.maturity_years;
let cpi_ratio = input.cpi_current / input.cpi_base;
let projected_growth = decimal_powi(Decimal::ONE + input.expected_inflation, t_to_periods(t));
let expected_cpi_ratio_at_maturity = cpi_ratio * projected_growth;
let nom_df =
Decimal::ONE / decimal_powi(Decimal::ONE + input.nominal_discount_rate, t_to_periods(t));
let floating_leg_pv = input.notional * (expected_cpi_ratio_at_maturity - Decimal::ONE) * nom_df;
let denom = input.notional * nom_df;
let fair_swap_rate = if denom > Decimal::ZERO {
let ratio = Decimal::ONE + floating_leg_pv / denom;
let inv_t = Decimal::ONE / t;
decimal_pow(ratio, inv_t) - Decimal::ONE
} else {
input.expected_inflation
};
let fixed_leg_factor =
decimal_powi(Decimal::ONE + fair_swap_rate, t_to_periods(t)) - Decimal::ONE;
let fixed_leg_pv = input.notional * fixed_leg_factor * nom_df;
let swap_npv = floating_leg_pv - fixed_leg_pv;
let breakeven_inflation_implied = fair_swap_rate;
Ok(ZcisOutput {
fair_swap_rate,
fixed_leg_pv,
floating_leg_pv,
swap_npv,
breakeven_inflation_implied,
})
}
fn t_to_periods(t: Decimal) -> u32 {
let rounded = t.round();
if rounded <= Decimal::ZERO {
return 1;
}
let val: u64 = rounded.try_into().unwrap_or(1);
val as u32
}
fn compute_yyis(input: &YyisInput) -> CorpFinanceResult<YyisOutput> {
if input.notional <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "notional".into(),
reason: "Must be positive".into(),
});
}
if input.num_periods == 0 {
return Err(CorpFinanceError::InvalidInput {
field: "num_periods".into(),
reason: "Must be at least 1".into(),
});
}
if input.payment_frequency == 0 {
return Err(CorpFinanceError::InvalidInput {
field: "payment_frequency".into(),
reason: "Must be at least 1".into(),
});
}
if input.expected_inflation_curve.len() < input.num_periods as usize {
return Err(CorpFinanceError::InsufficientData(
"expected_inflation_curve must have at least num_periods entries".into(),
));
}
if input.nominal_discount_curve.len() < input.num_periods as usize {
return Err(CorpFinanceError::InsufficientData(
"nominal_discount_curve must have at least num_periods entries".into(),
));
}
let freq = Decimal::from(input.payment_frequency);
let mut floating_leg_pv = Decimal::ZERO;
let mut df = Decimal::ONE; let mut cashflows: Vec<YyisCashflow> = Vec::with_capacity(input.num_periods as usize);
for t in 0..input.num_periods {
let idx = t as usize;
let yoy_inflation = input.expected_inflation_curve[idx] / freq;
let nom_rate = input.nominal_discount_curve[idx] / freq;
df /= Decimal::ONE + nom_rate;
let floating_pmt = input.notional * yoy_inflation;
floating_leg_pv += floating_pmt * df;
cashflows.push(YyisCashflow {
period: t + 1,
yoy_inflation,
floating_payment: floating_pmt,
fixed_payment: Decimal::ZERO, discount_factor: df,
net_pv: Decimal::ZERO, });
}
let sum_df: Decimal = cashflows.iter().map(|cf| cf.discount_factor).sum();
let fair_swap_rate = if sum_df > Decimal::ZERO && input.notional > Decimal::ZERO {
(floating_leg_pv / (input.notional * sum_df)) * freq
} else {
Decimal::ZERO
};
let fixed_per_period = input.notional * fair_swap_rate / freq;
let mut fixed_leg_pv = Decimal::ZERO;
for cf in &mut cashflows {
cf.fixed_payment = fixed_per_period;
let net = cf.floating_payment - cf.fixed_payment;
cf.net_pv = net * cf.discount_factor;
fixed_leg_pv += cf.fixed_payment * cf.discount_factor;
}
let swap_npv = floating_leg_pv - fixed_leg_pv;
Ok(YyisOutput {
fair_swap_rate,
period_cashflows: cashflows,
fixed_leg_pv,
floating_leg_pv,
swap_npv,
})
}
fn compute_inflation_cap_floor(
input: &InflationCapFloorInput,
) -> CorpFinanceResult<InflationCapFloorOutput> {
if input.notional <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "notional".into(),
reason: "Must be positive".into(),
});
}
if input.num_periods == 0 {
return Err(CorpFinanceError::InvalidInput {
field: "num_periods".into(),
reason: "Must be at least 1".into(),
});
}
if input.inflation_vol < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "inflation_vol".into(),
reason: "Must be non-negative".into(),
});
}
if input.expected_inflation_curve.len() < input.num_periods as usize {
return Err(CorpFinanceError::InsufficientData(
"expected_inflation_curve must have at least num_periods entries".into(),
));
}
if input.discount_curve.len() < input.num_periods as usize {
return Err(CorpFinanceError::InsufficientData(
"discount_curve must have at least num_periods entries".into(),
));
}
let mut total_premium = Decimal::ZERO;
let mut caplet_floorlet_values = Vec::with_capacity(input.num_periods as usize);
let mut df = Decimal::ONE;
let mut weighted_inflation_sum = Decimal::ZERO;
let mut df_sum = Decimal::ZERO;
let bump = dec!(0.0001);
let mut total_premium_up = Decimal::ZERO; let mut total_premium_vol_up = Decimal::ZERO;
for t in 0..input.num_periods {
let idx = t as usize;
let fwd_inflation = input.expected_inflation_curve[idx];
let disc_rate = input.discount_curve[idx];
let time_to_expiry = Decimal::from(t + 1);
df /= Decimal::ONE + disc_rate;
let vol_t = input.inflation_vol * decimal_sqrt(time_to_expiry);
let premium = if vol_t > Decimal::ZERO {
let d1 =
(decimal_ln(fwd_inflation / input.strike_rate) + vol_t * vol_t / dec!(2)) / vol_t;
let d2 = d1 - vol_t;
match input.option_type {
InflationOptionType::Cap => {
input.notional
* df
* (fwd_inflation * norm_cdf(d1) - input.strike_rate * norm_cdf(d2))
}
InflationOptionType::Floor => {
input.notional
* df
* (input.strike_rate * norm_cdf(-d2) - fwd_inflation * norm_cdf(-d1))
}
}
} else {
let intrinsic = match input.option_type {
InflationOptionType::Cap => {
if fwd_inflation > input.strike_rate {
fwd_inflation - input.strike_rate
} else {
Decimal::ZERO
}
}
InflationOptionType::Floor => {
if input.strike_rate > fwd_inflation {
input.strike_rate - fwd_inflation
} else {
Decimal::ZERO
}
}
};
input.notional * df * intrinsic
};
let intrinsic = match input.option_type {
InflationOptionType::Cap => {
let raw = fwd_inflation - input.strike_rate;
if raw > Decimal::ZERO {
input.notional * df * raw
} else {
Decimal::ZERO
}
}
InflationOptionType::Floor => {
let raw = input.strike_rate - fwd_inflation;
if raw > Decimal::ZERO {
input.notional * df * raw
} else {
Decimal::ZERO
}
}
};
let time_value = premium - intrinsic;
total_premium += premium;
caplet_floorlet_values.push(CapletFloorletValue {
period: t + 1,
premium,
intrinsic,
time_value,
});
weighted_inflation_sum += fwd_inflation * df;
df_sum += df;
let fwd_up = fwd_inflation + bump;
let prem_up = black_capfloor_price(
input.notional,
fwd_up,
input.strike_rate,
input.inflation_vol,
time_to_expiry,
df,
&input.option_type,
);
total_premium_up += prem_up;
let prem_vol_up = black_capfloor_price(
input.notional,
fwd_inflation,
input.strike_rate,
input.inflation_vol + bump,
time_to_expiry,
df,
&input.option_type,
);
total_premium_vol_up += prem_vol_up;
}
let implied_breakeven = if df_sum > Decimal::ZERO {
weighted_inflation_sum / df_sum
} else {
Decimal::ZERO
};
let delta = if bump > Decimal::ZERO {
(total_premium_up - total_premium) / bump
} else {
Decimal::ZERO
};
let vega = if bump > Decimal::ZERO {
(total_premium_vol_up - total_premium) / bump
} else {
Decimal::ZERO
};
Ok(InflationCapFloorOutput {
total_premium,
caplet_floorlet_values,
implied_breakeven,
delta,
vega,
})
}
fn black_capfloor_price(
notional: Money,
forward: Rate,
strike: Rate,
vol: Rate,
time_to_expiry: Decimal,
df: Decimal,
option_type: &InflationOptionType,
) -> Money {
let vol_t = vol * decimal_sqrt(time_to_expiry);
if vol_t <= Decimal::ZERO || forward <= Decimal::ZERO || strike <= Decimal::ZERO {
let intr = match option_type {
InflationOptionType::Cap => {
if forward > strike {
forward - strike
} else {
Decimal::ZERO
}
}
InflationOptionType::Floor => {
if strike > forward {
strike - forward
} else {
Decimal::ZERO
}
}
};
return notional * df * intr;
}
let d1 = (decimal_ln(forward / strike) + vol_t * vol_t / dec!(2)) / vol_t;
let d2 = d1 - vol_t;
match option_type {
InflationOptionType::Cap => {
notional * df * (forward * norm_cdf(d1) - strike * norm_cdf(d2))
}
InflationOptionType::Floor => {
notional * df * (strike * norm_cdf(-d2) - forward * norm_cdf(-d1))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_zcis_input() -> ZcisInput {
ZcisInput {
notional: dec!(10_000_000),
maturity_years: dec!(5),
cpi_base: dec!(300),
cpi_current: dec!(300), expected_inflation: dec!(0.025),
real_discount_rate: dec!(0.01),
nominal_discount_rate: dec!(0.035),
}
}
fn default_yyis_input() -> YyisInput {
YyisInput {
notional: dec!(10_000_000),
num_periods: 5,
payment_frequency: 1,
cpi_base: dec!(300),
expected_inflation_curve: vec![
dec!(0.025),
dec!(0.024),
dec!(0.023),
dec!(0.022),
dec!(0.021),
],
real_discount_curve: vec![
dec!(0.01),
dec!(0.011),
dec!(0.012),
dec!(0.013),
dec!(0.014),
],
nominal_discount_curve: vec![
dec!(0.035),
dec!(0.036),
dec!(0.037),
dec!(0.038),
dec!(0.039),
],
}
}
fn default_cap_input() -> InflationCapFloorInput {
InflationCapFloorInput {
notional: dec!(10_000_000),
strike_rate: dec!(0.03),
option_type: InflationOptionType::Cap,
num_periods: 5,
expected_inflation_curve: vec![
dec!(0.025),
dec!(0.026),
dec!(0.027),
dec!(0.028),
dec!(0.03),
],
inflation_vol: dec!(0.01),
discount_curve: vec![
dec!(0.035),
dec!(0.036),
dec!(0.037),
dec!(0.038),
dec!(0.039),
],
}
}
fn default_floor_input() -> InflationCapFloorInput {
let mut input = default_cap_input();
input.option_type = InflationOptionType::Floor;
input
}
#[test]
fn zcis_fair_rate_near_expected_inflation() {
let input = default_zcis_input();
let out = compute_zcis(&input).unwrap();
let diff = (out.fair_swap_rate - input.expected_inflation).abs();
assert!(
diff < dec!(0.01),
"Fair swap rate {} should be near expected inflation {}",
out.fair_swap_rate,
input.expected_inflation
);
}
#[test]
fn zcis_npv_near_zero_at_fair_rate() {
let input = default_zcis_input();
let out = compute_zcis(&input).unwrap();
let npv_ratio = if out.floating_leg_pv != Decimal::ZERO {
(out.swap_npv / out.floating_leg_pv).abs()
} else {
out.swap_npv.abs()
};
assert!(
npv_ratio < dec!(0.01),
"NPV should be near zero at fair rate, got ratio {}",
npv_ratio
);
}
#[test]
fn zcis_positive_legs() {
let input = default_zcis_input();
let out = compute_zcis(&input).unwrap();
assert!(
out.fixed_leg_pv > Decimal::ZERO,
"Fixed leg PV should be positive"
);
assert!(
out.floating_leg_pv > Decimal::ZERO,
"Floating leg PV should be positive"
);
}
#[test]
fn zcis_breakeven_equals_fair_rate() {
let input = default_zcis_input();
let out = compute_zcis(&input).unwrap();
assert_eq!(out.breakeven_inflation_implied, out.fair_swap_rate);
}
#[test]
fn zcis_higher_inflation_higher_fair_rate() {
let mut input1 = default_zcis_input();
input1.expected_inflation = dec!(0.02);
let out1 = compute_zcis(&input1).unwrap();
let mut input2 = default_zcis_input();
input2.expected_inflation = dec!(0.04);
let out2 = compute_zcis(&input2).unwrap();
assert!(
out2.fair_swap_rate > out1.fair_swap_rate,
"Higher expected inflation should produce higher fair rate"
);
}
#[test]
fn zcis_invalid_notional() {
let mut input = default_zcis_input();
input.notional = dec!(-1000);
assert!(compute_zcis(&input).is_err());
}
#[test]
fn zcis_invalid_maturity() {
let mut input = default_zcis_input();
input.maturity_years = Decimal::ZERO;
assert!(compute_zcis(&input).is_err());
}
#[test]
fn zcis_zero_inflation() {
let mut input = default_zcis_input();
input.expected_inflation = Decimal::ZERO;
let out = compute_zcis(&input).unwrap();
assert!(
out.fair_swap_rate.abs() < dec!(0.001),
"Fair rate should be near zero with zero inflation: {}",
out.fair_swap_rate
);
}
#[test]
fn yyis_fair_rate_positive() {
let input = default_yyis_input();
let out = compute_yyis(&input).unwrap();
assert!(out.fair_swap_rate > Decimal::ZERO);
}
#[test]
fn yyis_npv_near_zero_at_fair_rate() {
let input = default_yyis_input();
let out = compute_yyis(&input).unwrap();
let npv_ratio = if out.floating_leg_pv != Decimal::ZERO {
(out.swap_npv / out.floating_leg_pv).abs()
} else {
out.swap_npv.abs()
};
assert!(
npv_ratio < dec!(0.01),
"YYIS NPV should be near zero at fair rate, got ratio {}",
npv_ratio
);
}
#[test]
fn yyis_cashflow_count() {
let input = default_yyis_input();
let out = compute_yyis(&input).unwrap();
assert_eq!(out.period_cashflows.len(), input.num_periods as usize);
}
#[test]
fn yyis_fixed_payments_uniform() {
let input = default_yyis_input();
let out = compute_yyis(&input).unwrap();
let first_fixed = out.period_cashflows[0].fixed_payment;
for cf in &out.period_cashflows {
assert_eq!(
cf.fixed_payment, first_fixed,
"Fixed payments should be uniform"
);
}
}
#[test]
fn yyis_consistency_with_zcis_direction() {
let input = default_yyis_input();
let out = compute_yyis(&input).unwrap();
let avg_inflation: Decimal = input.expected_inflation_curve.iter().sum::<Decimal>()
/ Decimal::from(input.expected_inflation_curve.len() as u32);
let diff = (out.fair_swap_rate - avg_inflation).abs();
assert!(
diff < dec!(0.01),
"YYIS fair rate {} should be near average inflation {}",
out.fair_swap_rate,
avg_inflation
);
}
#[test]
fn yyis_invalid_num_periods() {
let mut input = default_yyis_input();
input.num_periods = 0;
assert!(compute_yyis(&input).is_err());
}
#[test]
fn yyis_insufficient_curve_data() {
let mut input = default_yyis_input();
input.expected_inflation_curve = vec![dec!(0.025)]; assert!(compute_yyis(&input).is_err());
}
#[test]
fn cap_positive_premium() {
let input = default_cap_input();
let out = compute_inflation_cap_floor(&input).unwrap();
assert!(
out.total_premium > Decimal::ZERO,
"Cap premium should be positive"
);
}
#[test]
fn floor_positive_premium() {
let input = default_floor_input();
let out = compute_inflation_cap_floor(&input).unwrap();
assert!(
out.total_premium > Decimal::ZERO,
"Floor premium should be positive"
);
}
#[test]
fn cap_floor_parity_approximation() {
let cap_input = default_cap_input();
let floor_input = default_floor_input();
let cap_out = compute_inflation_cap_floor(&cap_input).unwrap();
let floor_out = compute_inflation_cap_floor(&floor_input).unwrap();
let cap_minus_floor = cap_out.total_premium - floor_out.total_premium;
let mut swap_value = Decimal::ZERO;
let mut df = Decimal::ONE;
for t in 0..cap_input.num_periods {
let idx = t as usize;
df = df / (Decimal::ONE + cap_input.discount_curve[idx]);
let fwd = cap_input.expected_inflation_curve[idx];
swap_value += cap_input.notional * (fwd - cap_input.strike_rate) * df;
}
let diff = (cap_minus_floor - swap_value).abs();
let tolerance = cap_out.total_premium.abs() * dec!(0.05); assert!(
diff < tolerance + dec!(1), "Cap - Floor ({}) should approx equal swap value ({}), diff = {}",
cap_minus_floor,
swap_value,
diff
);
}
#[test]
fn cap_premium_increases_with_vol() {
let mut input_low = default_cap_input();
input_low.inflation_vol = dec!(0.005);
let out_low = compute_inflation_cap_floor(&input_low).unwrap();
let mut input_high = default_cap_input();
input_high.inflation_vol = dec!(0.02);
let out_high = compute_inflation_cap_floor(&input_high).unwrap();
assert!(
out_high.total_premium >= out_low.total_premium,
"Higher vol ({}) should give higher premium ({}) vs ({})",
input_high.inflation_vol,
out_high.total_premium,
out_low.total_premium
);
}
#[test]
fn cap_caplet_count() {
let input = default_cap_input();
let out = compute_inflation_cap_floor(&input).unwrap();
assert_eq!(out.caplet_floorlet_values.len(), input.num_periods as usize);
}
#[test]
fn cap_delta_positive() {
let input = default_cap_input();
let out = compute_inflation_cap_floor(&input).unwrap();
assert!(
out.delta >= Decimal::ZERO,
"Cap delta should be non-negative"
);
}
#[test]
fn floor_delta_negative_or_zero() {
let input = default_floor_input();
let out = compute_inflation_cap_floor(&input).unwrap();
assert!(
out.delta <= Decimal::ZERO,
"Floor delta should be non-positive, got {}",
out.delta
);
}
#[test]
fn cap_vega_positive() {
let input = default_cap_input();
let out = compute_inflation_cap_floor(&input).unwrap();
assert!(out.vega >= Decimal::ZERO, "Cap vega should be non-negative");
}
#[test]
fn cap_zero_vol_intrinsic_only() {
let mut input = default_cap_input();
input.inflation_vol = Decimal::ZERO;
input.expected_inflation_curve = vec![
dec!(0.035),
dec!(0.035),
dec!(0.035),
dec!(0.035),
dec!(0.035),
];
let out = compute_inflation_cap_floor(&input).unwrap();
for cv in &out.caplet_floorlet_values {
assert!(
cv.time_value.abs() < dec!(0.01),
"Time value should be ~0 at zero vol, got {}",
cv.time_value
);
}
}
#[test]
fn cap_invalid_notional() {
let mut input = default_cap_input();
input.notional = dec!(-1000);
assert!(compute_inflation_cap_floor(&input).is_err());
}
#[test]
fn cap_invalid_num_periods() {
let mut input = default_cap_input();
input.num_periods = 0;
assert!(compute_inflation_cap_floor(&input).is_err());
}
#[test]
fn cap_negative_vol_error() {
let mut input = default_cap_input();
input.inflation_vol = dec!(-0.01);
assert!(compute_inflation_cap_floor(&input).is_err());
}
#[test]
fn cap_long_maturity() {
let mut input = default_cap_input();
input.num_periods = 30;
input.expected_inflation_curve = (0..30)
.map(|i| dec!(0.025) + Decimal::from(i) * dec!(0.0001))
.collect();
input.discount_curve = (0..30).map(|_| dec!(0.035)).collect();
let out = compute_inflation_cap_floor(&input).unwrap();
assert!(out.total_premium > Decimal::ZERO);
assert_eq!(out.caplet_floorlet_values.len(), 30);
}
#[test]
fn wrapper_zcis() {
let input = InflationDerivativeInput {
model: InflationDerivativeModel::Zcis(default_zcis_input()),
};
let out = analyze_inflation_derivatives(&input).unwrap();
assert_eq!(out.methodology, "Inflation Derivatives Analytics");
match out.result {
InflationDerivativeOutput::Zcis(z) => {
assert!(z.fair_swap_rate > Decimal::ZERO);
}
_ => panic!("Expected ZCIS output"),
}
}
#[test]
fn wrapper_yyis() {
let input = InflationDerivativeInput {
model: InflationDerivativeModel::Yyis(default_yyis_input()),
};
let out = analyze_inflation_derivatives(&input).unwrap();
match out.result {
InflationDerivativeOutput::Yyis(y) => {
assert!(y.fair_swap_rate > Decimal::ZERO);
}
_ => panic!("Expected YYIS output"),
}
}
#[test]
fn wrapper_cap_floor() {
let input = InflationDerivativeInput {
model: InflationDerivativeModel::CapFloor(default_cap_input()),
};
let out = analyze_inflation_derivatives(&input).unwrap();
match out.result {
InflationDerivativeOutput::CapFloor(c) => {
assert!(c.total_premium > Decimal::ZERO);
}
_ => panic!("Expected CapFloor output"),
}
}
#[test]
fn test_norm_cdf_symmetry() {
let n0 = norm_cdf(Decimal::ZERO);
let diff = (n0 - dec!(0.5)).abs();
assert!(diff < dec!(0.001), "N(0) should be 0.5, got {}", n0);
}
#[test]
fn test_norm_cdf_tail() {
let n3 = norm_cdf(dec!(3));
assert!(n3 > dec!(0.998));
let nm3 = norm_cdf(dec!(-3));
assert!(nm3 < dec!(0.002));
}
#[test]
fn test_decimal_powi() {
let result = decimal_powi(dec!(1.05), 10);
let diff = (result - dec!(1.62889)).abs();
assert!(
diff < dec!(0.001),
"(1.05)^10 should be ~1.62889, got {}",
result
);
}
}