use crate::compat::Instant;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use crate::error::CorpFinanceError;
use crate::types::{with_metadata, ComputationOutput, Money, Rate};
use crate::CorpFinanceResult;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetAllocation {
pub asset_class: String,
pub weight: Rate,
pub expected_return: Rate,
pub duration: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LdiInstrument {
pub name: String,
pub instrument_type: String,
pub duration: Decimal,
pub yield_rate: Rate,
#[serde(skip_serializing_if = "Option::is_none")]
pub convexity: Option<Decimal>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GlidePath {
pub current_funded_ratio: Rate,
pub target_funded_ratio: Rate,
pub years_to_target: u32,
pub growth_allocation_start: Rate,
pub growth_allocation_end: Rate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LdiInput {
pub plan_name: String,
pub liability_pv: Money,
pub liability_duration: Decimal,
#[serde(skip_serializing_if = "Option::is_none")]
pub liability_convexity: Option<Decimal>,
pub plan_assets: Money,
pub current_asset_duration: Decimal,
pub current_asset_allocation: Vec<AssetAllocation>,
pub available_instruments: Vec<LdiInstrument>,
pub target_hedge_ratio: Rate,
#[serde(skip_serializing_if = "Option::is_none")]
pub rebalancing_trigger: Option<Rate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub glide_path: Option<GlidePath>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecommendedAllocation {
pub asset_class: String,
pub current_weight: Rate,
pub target_weight: Rate,
pub rebalance_amount: Money,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HedgingInstrument {
pub name: String,
pub allocation_amount: Money,
pub weight: Rate,
pub contribution_to_duration: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HedgingPortfolio {
pub total_hedging_amount: Money,
pub instruments: Vec<HedgingInstrument>,
pub portfolio_duration: Decimal,
#[serde(skip_serializing_if = "Option::is_none")]
pub portfolio_convexity: Option<Decimal>,
pub duration_match_error: Decimal,
pub hedge_ratio_achieved: Rate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImmunizationResult {
pub is_immunized: bool,
pub duration_match: bool,
pub convexity_match: bool,
pub surplus_pv: Money,
pub rate_sensitivity_bps: Money,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GlidePathStep {
pub year: u32,
pub target_funded_ratio: Rate,
pub growth_allocation: Rate,
pub hedging_allocation: Rate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LdiOutput {
pub plan_name: String,
pub current_duration_gap: Decimal,
pub dollar_duration_gap: Money,
pub interest_rate_risk_1pct: Money,
pub recommended_allocation: Vec<RecommendedAllocation>,
pub hedging_portfolio: HedgingPortfolio,
pub surplus_at_risk: Money,
pub immunization_analysis: ImmunizationResult,
#[serde(skip_serializing_if = "Option::is_none")]
pub glide_path_schedule: Option<Vec<GlidePathStep>>,
}
pub fn design_ldi_strategy(input: &LdiInput) -> CorpFinanceResult<ComputationOutput<LdiOutput>> {
let start = Instant::now();
let mut warnings: Vec<String> = Vec::new();
if input.liability_pv <= dec!(0) {
return Err(CorpFinanceError::InvalidInput {
field: "liability_pv".into(),
reason: "Must be positive".into(),
});
}
if input.plan_assets <= dec!(0) {
return Err(CorpFinanceError::InvalidInput {
field: "plan_assets".into(),
reason: "Must be positive".into(),
});
}
if input.liability_duration < dec!(0) {
return Err(CorpFinanceError::InvalidInput {
field: "liability_duration".into(),
reason: "Cannot be negative".into(),
});
}
if input.current_asset_duration < dec!(0) {
return Err(CorpFinanceError::InvalidInput {
field: "current_asset_duration".into(),
reason: "Cannot be negative".into(),
});
}
if input.target_hedge_ratio <= dec!(0) || input.target_hedge_ratio > dec!(1) {
return Err(CorpFinanceError::InvalidInput {
field: "target_hedge_ratio".into(),
reason: "Must be in (0, 1]".into(),
});
}
if input.available_instruments.is_empty() {
return Err(CorpFinanceError::InvalidInput {
field: "available_instruments".into(),
reason: "At least one instrument is required".into(),
});
}
let leverage_ratio = input.liability_pv / input.plan_assets;
let target_asset_duration = leverage_ratio * input.liability_duration;
let duration_gap = input.current_asset_duration - target_asset_duration;
let dollar_dur_assets = input.plan_assets * input.current_asset_duration / dec!(100);
let dollar_dur_liabilities = input.liability_pv * input.liability_duration / dec!(100);
let dollar_duration_gap = dollar_dur_assets - dollar_dur_liabilities;
let interest_rate_risk_1pct = dollar_duration_gap;
let surplus_at_risk = dollar_duration_gap.abs();
let hedge_amount = input.plan_assets * input.target_hedge_ratio;
let _growth_amount = input.plan_assets - hedge_amount;
let hedge_target_duration = if input.target_hedge_ratio > dec!(0) {
target_asset_duration / input.target_hedge_ratio
} else {
dec!(0)
};
let hedging_portfolio = build_hedging_portfolio(
&input.available_instruments,
hedge_amount,
hedge_target_duration,
input.target_hedge_ratio,
input.liability_convexity,
);
let hedge_ratio = input.target_hedge_ratio;
let growth_ratio = dec!(1) - hedge_ratio;
let mut recommended_allocation = Vec::new();
let current_hedge_weight: Decimal = input
.current_asset_allocation
.iter()
.filter(|a| is_hedging_asset(&a.asset_class))
.map(|a| a.weight)
.sum();
let current_growth_weight = dec!(1) - current_hedge_weight;
recommended_allocation.push(RecommendedAllocation {
asset_class: "Hedging (LDI)".into(),
current_weight: current_hedge_weight,
target_weight: hedge_ratio,
rebalance_amount: (hedge_ratio - current_hedge_weight) * input.plan_assets,
});
recommended_allocation.push(RecommendedAllocation {
asset_class: "Growth (Return-Seeking)".into(),
current_weight: current_growth_weight,
target_weight: growth_ratio,
rebalance_amount: (growth_ratio - current_growth_weight) * input.plan_assets,
});
let achieved_duration = hedging_portfolio.portfolio_duration * input.target_hedge_ratio;
let rebalance_threshold = input.rebalancing_trigger.unwrap_or(dec!(0.5));
let duration_match = (achieved_duration - target_asset_duration).abs() < rebalance_threshold;
let convexity_match = match (
input.liability_convexity,
hedging_portfolio.portfolio_convexity,
) {
(Some(l_conv), Some(p_conv)) => p_conv >= l_conv,
_ => false,
};
let surplus_pv = input.plan_assets - input.liability_pv;
let rate_sensitivity_bps = dollar_duration_gap / dec!(100);
let immunization = ImmunizationResult {
is_immunized: duration_match && (convexity_match || input.liability_convexity.is_none()),
duration_match,
convexity_match,
surplus_pv,
rate_sensitivity_bps,
};
let glide_path_schedule = input.glide_path.as_ref().map(build_glide_path);
if duration_gap.abs() > dec!(2) {
warnings.push(format!(
"Large duration gap of {:.2} years — significant interest rate risk",
duration_gap
));
}
if surplus_pv < dec!(0) {
warnings.push("Plan is underfunded — surplus is negative".into());
}
if !duration_match {
warnings.push(format!(
"Duration not matched within threshold ({:.1} years). Gap: {:.2}",
rebalance_threshold,
(achieved_duration - target_asset_duration).abs()
));
}
let output = LdiOutput {
plan_name: input.plan_name.clone(),
current_duration_gap: duration_gap,
dollar_duration_gap,
interest_rate_risk_1pct,
recommended_allocation,
hedging_portfolio,
surplus_at_risk,
immunization_analysis: immunization,
glide_path_schedule,
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"Liability-Driven Investing Strategy (duration matching + immunization)",
&serde_json::json!({
"target_hedge_ratio": input.target_hedge_ratio.to_string(),
"liability_duration": input.liability_duration.to_string(),
"target_asset_duration": target_asset_duration.to_string(),
"leverage_ratio": leverage_ratio.to_string(),
}),
warnings,
elapsed,
output,
))
}
fn is_hedging_asset(asset_class: &str) -> bool {
let lower = asset_class.to_lowercase();
lower.contains("bond")
|| lower.contains("fixed")
|| lower.contains("treasury")
|| lower.contains("gilt")
|| lower.contains("tips")
|| lower.contains("swap")
|| lower.contains("ldi")
|| lower.contains("hedg")
}
fn build_hedging_portfolio(
instruments: &[LdiInstrument],
hedge_amount: Money,
target_duration: Decimal,
target_hedge_ratio: Rate,
liability_convexity: Option<Decimal>,
) -> HedgingPortfolio {
if instruments.is_empty() || hedge_amount <= dec!(0) {
return HedgingPortfolio {
total_hedging_amount: dec!(0),
instruments: vec![],
portfolio_duration: dec!(0),
portfolio_convexity: None,
duration_match_error: target_duration.abs(),
hedge_ratio_achieved: dec!(0),
};
}
let mut sorted: Vec<&LdiInstrument> = instruments.iter().collect();
sorted.sort_by(|a, b| a.duration.cmp(&b.duration));
let instrument_weights: Vec<(usize, Decimal)>;
if sorted.len() == 1 {
instrument_weights = vec![(0, dec!(1))];
} else {
let mut lower_idx: Option<usize> = None;
let mut upper_idx: Option<usize> = None;
for (i, inst) in sorted.iter().enumerate() {
if inst.duration <= target_duration {
lower_idx = Some(i);
}
if inst.duration >= target_duration && upper_idx.is_none() {
upper_idx = Some(i);
}
}
match (lower_idx, upper_idx) {
(Some(li), Some(ui)) if li != ui => {
let d_low = sorted[li].duration;
let d_high = sorted[ui].duration;
let span = d_high - d_low;
if span > dec!(0) {
let w_high = (target_duration - d_low) / span;
let w_low = dec!(1) - w_high;
instrument_weights = vec![(li, w_low), (ui, w_high)];
} else {
instrument_weights = vec![(li, dec!(1))];
}
}
(Some(li), _) => {
instrument_weights = vec![(li, dec!(1))];
}
(_, Some(ui)) => {
instrument_weights = vec![(ui, dec!(1))];
}
_ => {
let n = Decimal::from(sorted.len() as u32);
let w = dec!(1) / n;
instrument_weights = (0..sorted.len()).map(|i| (i, w)).collect();
}
}
}
let mut hedging_instruments = Vec::new();
let mut portfolio_duration = dec!(0);
let mut portfolio_convexity_sum = dec!(0);
let mut has_convexity = false;
for &(idx, weight) in &instrument_weights {
let inst = sorted[idx];
let amount = hedge_amount * weight;
let dur_contribution = weight * inst.duration;
portfolio_duration += dur_contribution;
if let Some(conv) = inst.convexity {
portfolio_convexity_sum += weight * conv;
has_convexity = true;
}
hedging_instruments.push(HedgingInstrument {
name: inst.name.clone(),
allocation_amount: amount,
weight,
contribution_to_duration: dur_contribution,
});
}
let portfolio_convexity = if has_convexity {
Some(portfolio_convexity_sum)
} else {
None
};
let duration_match_error = (portfolio_duration - target_duration).abs();
let _ = liability_convexity;
HedgingPortfolio {
total_hedging_amount: hedge_amount,
instruments: hedging_instruments,
portfolio_duration,
portfolio_convexity,
duration_match_error,
hedge_ratio_achieved: target_hedge_ratio,
}
}
fn build_glide_path(gp: &GlidePath) -> Vec<GlidePathStep> {
let mut steps = Vec::new();
let years = gp.years_to_target.max(1);
for y in 0..=years {
let frac = Decimal::from(y) / Decimal::from(years);
let growth = gp.growth_allocation_start
+ (gp.growth_allocation_end - gp.growth_allocation_start) * frac;
let hedging = dec!(1) - growth;
let funded =
gp.current_funded_ratio + (gp.target_funded_ratio - gp.current_funded_ratio) * frac;
steps.push(GlidePathStep {
year: y,
target_funded_ratio: funded,
growth_allocation: growth,
hedging_allocation: hedging,
});
}
steps
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn basic_allocation() -> Vec<AssetAllocation> {
vec![
AssetAllocation {
asset_class: "Equities".into(),
weight: dec!(0.60),
expected_return: dec!(0.08),
duration: dec!(0),
},
AssetAllocation {
asset_class: "Bonds".into(),
weight: dec!(0.40),
expected_return: dec!(0.04),
duration: dec!(7),
},
]
}
fn basic_instruments() -> Vec<LdiInstrument> {
vec![
LdiInstrument {
name: "Long Govt Bond".into(),
instrument_type: "Government Bond".into(),
duration: dec!(15),
yield_rate: dec!(0.035),
convexity: Some(dec!(250)),
},
LdiInstrument {
name: "Intermediate Corp".into(),
instrument_type: "Corporate Bond".into(),
duration: dec!(7),
yield_rate: dec!(0.045),
convexity: Some(dec!(60)),
},
]
}
fn basic_input() -> LdiInput {
LdiInput {
plan_name: "Test LDI Plan".into(),
liability_pv: dec!(1000000),
liability_duration: dec!(12),
liability_convexity: Some(dec!(200)),
plan_assets: dec!(800000),
current_asset_duration: dec!(5),
current_asset_allocation: basic_allocation(),
available_instruments: basic_instruments(),
target_hedge_ratio: dec!(0.80),
rebalancing_trigger: Some(dec!(0.5)),
glide_path: None,
}
}
#[test]
fn test_duration_gap_calculation() {
let input = basic_input();
let result = design_ldi_strategy(&input).unwrap();
let r = &result.result;
assert_eq!(r.current_duration_gap, dec!(5) - dec!(15));
}
#[test]
fn test_dollar_duration_gap() {
let input = basic_input();
let result = design_ldi_strategy(&input).unwrap();
let r = &result.result;
let expected = dec!(40000) - dec!(120000);
assert_eq!(r.dollar_duration_gap, expected);
}
#[test]
fn test_interest_rate_risk_equals_dd_gap() {
let result = design_ldi_strategy(&basic_input()).unwrap();
let r = &result.result;
assert_eq!(r.interest_rate_risk_1pct, r.dollar_duration_gap);
}
#[test]
fn test_surplus_at_risk_positive() {
let result = design_ldi_strategy(&basic_input()).unwrap();
assert!(result.result.surplus_at_risk > dec!(0));
}
#[test]
fn test_hedging_portfolio_amount() {
let input = basic_input();
let result = design_ldi_strategy(&input).unwrap();
let hp = &result.result.hedging_portfolio;
assert_eq!(hp.total_hedging_amount, dec!(640000));
}
#[test]
fn test_hedging_instruments_weights_sum_to_one() {
let result = design_ldi_strategy(&basic_input()).unwrap();
let hp = &result.result.hedging_portfolio;
let weight_sum: Decimal = hp.instruments.iter().map(|i| i.weight).sum();
let diff = (weight_sum - dec!(1)).abs();
assert!(
diff < dec!(0.0001),
"Weights should sum to 1, got {}",
weight_sum
);
}
#[test]
fn test_hedging_portfolio_duration_interpolation() {
let result = design_ldi_strategy(&basic_input()).unwrap();
let hp = &result.result.hedging_portfolio;
assert!(hp.portfolio_duration >= dec!(7));
assert!(hp.portfolio_duration <= dec!(15));
}
#[test]
fn test_recommended_allocation_has_entries() {
let result = design_ldi_strategy(&basic_input()).unwrap();
assert_eq!(result.result.recommended_allocation.len(), 2);
}
#[test]
fn test_recommended_target_weights_sum_to_one() {
let result = design_ldi_strategy(&basic_input()).unwrap();
let total: Decimal = result
.result
.recommended_allocation
.iter()
.map(|r| r.target_weight)
.sum();
assert_eq!(total, dec!(1));
}
#[test]
fn test_immunization_not_achieved_with_large_gap() {
let result = design_ldi_strategy(&basic_input()).unwrap();
let imm = &result.result.immunization_analysis;
assert_eq!(imm.surplus_pv, dec!(800000) - dec!(1000000));
}
#[test]
fn test_glide_path_none_when_not_provided() {
let result = design_ldi_strategy(&basic_input()).unwrap();
assert!(result.result.glide_path_schedule.is_none());
}
#[test]
fn test_glide_path_schedule_generated() {
let mut input = basic_input();
input.glide_path = Some(GlidePath {
current_funded_ratio: dec!(0.80),
target_funded_ratio: dec!(1.00),
years_to_target: 5,
growth_allocation_start: dec!(0.60),
growth_allocation_end: dec!(0.20),
});
let result = design_ldi_strategy(&input).unwrap();
let schedule = result.result.glide_path_schedule.as_ref().unwrap();
assert_eq!(schedule.len(), 6);
}
#[test]
fn test_glide_path_start_end_values() {
let mut input = basic_input();
input.glide_path = Some(GlidePath {
current_funded_ratio: dec!(0.80),
target_funded_ratio: dec!(1.00),
years_to_target: 5,
growth_allocation_start: dec!(0.60),
growth_allocation_end: dec!(0.20),
});
let result = design_ldi_strategy(&input).unwrap();
let schedule = result.result.glide_path_schedule.as_ref().unwrap();
assert_eq!(schedule[0].growth_allocation, dec!(0.60));
assert_eq!(schedule[0].hedging_allocation, dec!(0.40));
assert_eq!(schedule[5].growth_allocation, dec!(0.20));
assert_eq!(schedule[5].hedging_allocation, dec!(0.80));
}
#[test]
fn test_glide_path_funded_ratio_progression() {
let mut input = basic_input();
input.glide_path = Some(GlidePath {
current_funded_ratio: dec!(0.80),
target_funded_ratio: dec!(1.00),
years_to_target: 4,
growth_allocation_start: dec!(0.50),
growth_allocation_end: dec!(0.10),
});
let result = design_ldi_strategy(&input).unwrap();
let schedule = result.result.glide_path_schedule.as_ref().unwrap();
for i in 1..schedule.len() {
assert!(schedule[i].target_funded_ratio >= schedule[i - 1].target_funded_ratio);
}
}
#[test]
fn test_validation_negative_liability_pv() {
let mut input = basic_input();
input.liability_pv = dec!(-1);
let err = design_ldi_strategy(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => assert_eq!(field, "liability_pv"),
_ => panic!("Expected InvalidInput error"),
}
}
#[test]
fn test_validation_zero_assets() {
let mut input = basic_input();
input.plan_assets = dec!(0);
let err = design_ldi_strategy(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => assert_eq!(field, "plan_assets"),
_ => panic!("Expected InvalidInput error"),
}
}
#[test]
fn test_validation_hedge_ratio_out_of_range() {
let mut input = basic_input();
input.target_hedge_ratio = dec!(1.5);
let err = design_ldi_strategy(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "target_hedge_ratio")
}
_ => panic!("Expected InvalidInput error"),
}
}
#[test]
fn test_validation_no_instruments() {
let mut input = basic_input();
input.available_instruments = vec![];
let err = design_ldi_strategy(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "available_instruments")
}
_ => panic!("Expected InvalidInput error"),
}
}
#[test]
fn test_single_instrument_portfolio() {
let mut input = basic_input();
input.available_instruments = vec![LdiInstrument {
name: "Single Bond".into(),
instrument_type: "Government Bond".into(),
duration: dec!(10),
yield_rate: dec!(0.04),
convexity: None,
}];
let result = design_ldi_strategy(&input).unwrap();
let hp = &result.result.hedging_portfolio;
assert_eq!(hp.instruments.len(), 1);
assert_eq!(hp.instruments[0].weight, dec!(1));
assert_eq!(hp.portfolio_duration, dec!(10));
}
#[test]
fn test_warning_on_underfunded() {
let result = design_ldi_strategy(&basic_input()).unwrap();
assert!(result.warnings.iter().any(|w| w.contains("underfunded")));
}
}