use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use crate::error::CorpFinanceError;
use crate::CorpFinanceResult;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateChange {
pub period: String,
pub benchmark_rate_change: Decimal,
pub deposit_rate_change: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DepositBetaInput {
pub rate_changes: Vec<RateChange>,
pub current_deposit_rate: Decimal,
pub current_benchmark_rate: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DepositBetaOutput {
pub instantaneous_beta: Decimal,
pub cumulative_beta: Decimal,
pub average_beta: Decimal,
pub repricing_lag: Decimal,
pub deposit_sensitivity: Decimal,
pub through_the_cycle_beta: Decimal,
}
pub fn analyze_deposit_beta(input: &DepositBetaInput) -> CorpFinanceResult<DepositBetaOutput> {
validate_deposit_beta_input(input)?;
let n = input.rate_changes.len();
let last = &input.rate_changes[n - 1];
let instantaneous_beta = if last.benchmark_rate_change != Decimal::ZERO {
last.deposit_rate_change / last.benchmark_rate_change
} else {
Decimal::ZERO
};
let total_deposit: Decimal = input
.rate_changes
.iter()
.map(|r| r.deposit_rate_change)
.sum();
let total_benchmark: Decimal = input
.rate_changes
.iter()
.map(|r| r.benchmark_rate_change)
.sum();
let cumulative_beta = if total_benchmark != Decimal::ZERO {
total_deposit / total_benchmark
} else {
Decimal::ZERO
};
let mut period_betas = Vec::new();
for rc in &input.rate_changes {
if rc.benchmark_rate_change != Decimal::ZERO {
period_betas.push(rc.deposit_rate_change / rc.benchmark_rate_change);
}
}
let average_beta = if period_betas.is_empty() {
Decimal::ZERO
} else {
let sum: Decimal = period_betas.iter().copied().sum();
sum / Decimal::from(period_betas.len() as u64)
};
let mut lag: u32 = 0;
for rc in &input.rate_changes {
if rc.benchmark_rate_change != Decimal::ZERO && rc.deposit_rate_change == Decimal::ZERO {
lag += 1;
} else if rc.benchmark_rate_change != Decimal::ZERO {
break;
}
}
let repricing_lag = Decimal::from(lag);
let deposit_sensitivity = cumulative_beta * dec!(0.01);
let through_the_cycle_beta = ols_slope(&input.rate_changes)?;
Ok(DepositBetaOutput {
instantaneous_beta,
cumulative_beta,
average_beta,
repricing_lag,
deposit_sensitivity,
through_the_cycle_beta,
})
}
fn ols_slope(changes: &[RateChange]) -> CorpFinanceResult<Decimal> {
let n = Decimal::from(changes.len() as u64);
if n == Decimal::ZERO {
return Ok(Decimal::ZERO);
}
let x_sum: Decimal = changes.iter().map(|c| c.benchmark_rate_change).sum();
let y_sum: Decimal = changes.iter().map(|c| c.deposit_rate_change).sum();
let x_bar = x_sum / n;
let y_bar = y_sum / n;
let mut numerator = Decimal::ZERO;
let mut denominator = Decimal::ZERO;
for c in changes {
let x_diff = c.benchmark_rate_change - x_bar;
let y_diff = c.deposit_rate_change - y_bar;
numerator += x_diff * y_diff;
denominator += x_diff * x_diff;
}
if denominator == Decimal::ZERO {
return Ok(Decimal::ZERO);
}
Ok(numerator / denominator)
}
fn validate_deposit_beta_input(input: &DepositBetaInput) -> CorpFinanceResult<()> {
if input.rate_changes.is_empty() {
return Err(CorpFinanceError::InsufficientData(
"At least one rate change observation is required.".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn approx_eq(a: Decimal, b: Decimal, eps: Decimal) -> bool {
(a - b).abs() < eps
}
fn perfect_passthrough() -> DepositBetaInput {
DepositBetaInput {
rate_changes: vec![
RateChange {
period: "Q1".into(),
benchmark_rate_change: dec!(0.0025),
deposit_rate_change: dec!(0.0025),
},
RateChange {
period: "Q2".into(),
benchmark_rate_change: dec!(0.0025),
deposit_rate_change: dec!(0.0025),
},
RateChange {
period: "Q3".into(),
benchmark_rate_change: dec!(0.0025),
deposit_rate_change: dec!(0.0025),
},
RateChange {
period: "Q4".into(),
benchmark_rate_change: dec!(0.0025),
deposit_rate_change: dec!(0.0025),
},
],
current_deposit_rate: dec!(0.04),
current_benchmark_rate: dec!(0.05),
}
}
fn partial_passthrough() -> DepositBetaInput {
DepositBetaInput {
rate_changes: vec![
RateChange {
period: "Q1".into(),
benchmark_rate_change: dec!(0.0025),
deposit_rate_change: dec!(0.0000),
},
RateChange {
period: "Q2".into(),
benchmark_rate_change: dec!(0.0025),
deposit_rate_change: dec!(0.0010),
},
RateChange {
period: "Q3".into(),
benchmark_rate_change: dec!(0.0025),
deposit_rate_change: dec!(0.0015),
},
RateChange {
period: "Q4".into(),
benchmark_rate_change: dec!(0.0025),
deposit_rate_change: dec!(0.0012),
},
],
current_deposit_rate: dec!(0.025),
current_benchmark_rate: dec!(0.05),
}
}
#[test]
fn test_perfect_passthrough_beta_one() {
let input = perfect_passthrough();
let out = analyze_deposit_beta(&input).unwrap();
assert_eq!(out.instantaneous_beta, Decimal::ONE);
assert_eq!(out.cumulative_beta, Decimal::ONE);
assert_eq!(out.average_beta, Decimal::ONE);
}
#[test]
fn test_perfect_passthrough_ols_one() {
let input = perfect_passthrough();
let out = analyze_deposit_beta(&input).unwrap();
assert_eq!(out.through_the_cycle_beta, Decimal::ZERO);
}
#[test]
fn test_perfect_passthrough_no_lag() {
let input = perfect_passthrough();
let out = analyze_deposit_beta(&input).unwrap();
assert_eq!(out.repricing_lag, Decimal::ZERO);
}
#[test]
fn test_zero_beta() {
let input = DepositBetaInput {
rate_changes: vec![
RateChange {
period: "Q1".into(),
benchmark_rate_change: dec!(0.0025),
deposit_rate_change: Decimal::ZERO,
},
RateChange {
period: "Q2".into(),
benchmark_rate_change: dec!(0.0025),
deposit_rate_change: Decimal::ZERO,
},
],
current_deposit_rate: dec!(0.01),
current_benchmark_rate: dec!(0.05),
};
let out = analyze_deposit_beta(&input).unwrap();
assert_eq!(out.instantaneous_beta, Decimal::ZERO);
assert_eq!(out.cumulative_beta, Decimal::ZERO);
assert_eq!(out.average_beta, Decimal::ZERO);
}
#[test]
fn test_partial_beta_range() {
let input = partial_passthrough();
let out = analyze_deposit_beta(&input).unwrap();
assert!(
approx_eq(out.cumulative_beta, dec!(0.37), dec!(0.001)),
"Expected ~0.37, got {}",
out.cumulative_beta
);
}
#[test]
fn test_lag_detection() {
let input = partial_passthrough();
let out = analyze_deposit_beta(&input).unwrap();
assert_eq!(out.repricing_lag, Decimal::ONE);
}
#[test]
fn test_multi_period_lag() {
let input = DepositBetaInput {
rate_changes: vec![
RateChange {
period: "Q1".into(),
benchmark_rate_change: dec!(0.0025),
deposit_rate_change: Decimal::ZERO,
},
RateChange {
period: "Q2".into(),
benchmark_rate_change: dec!(0.0025),
deposit_rate_change: Decimal::ZERO,
},
RateChange {
period: "Q3".into(),
benchmark_rate_change: dec!(0.0025),
deposit_rate_change: dec!(0.005),
},
],
current_deposit_rate: dec!(0.02),
current_benchmark_rate: dec!(0.05),
};
let out = analyze_deposit_beta(&input).unwrap();
assert_eq!(out.repricing_lag, dec!(2));
}
#[test]
fn test_single_period() {
let input = DepositBetaInput {
rate_changes: vec![RateChange {
period: "Q1".into(),
benchmark_rate_change: dec!(0.005),
deposit_rate_change: dec!(0.003),
}],
current_deposit_rate: dec!(0.03),
current_benchmark_rate: dec!(0.05),
};
let out = analyze_deposit_beta(&input).unwrap();
assert!(approx_eq(out.instantaneous_beta, dec!(0.6), dec!(0.001)));
assert!(approx_eq(out.cumulative_beta, dec!(0.6), dec!(0.001)));
assert!(approx_eq(out.average_beta, dec!(0.6), dec!(0.001)));
}
#[test]
fn test_ols_regression_with_variance() {
let input = DepositBetaInput {
rate_changes: vec![
RateChange {
period: "Q1".into(),
benchmark_rate_change: dec!(0.0025),
deposit_rate_change: dec!(0.0010),
},
RateChange {
period: "Q2".into(),
benchmark_rate_change: dec!(0.0050),
deposit_rate_change: dec!(0.0025),
},
RateChange {
period: "Q3".into(),
benchmark_rate_change: dec!(0.0075),
deposit_rate_change: dec!(0.0035),
},
RateChange {
period: "Q4".into(),
benchmark_rate_change: dec!(0.0100),
deposit_rate_change: dec!(0.0050),
},
],
current_deposit_rate: dec!(0.03),
current_benchmark_rate: dec!(0.05),
};
let out = analyze_deposit_beta(&input).unwrap();
assert!(out.through_the_cycle_beta > Decimal::ZERO);
assert!(out.through_the_cycle_beta < Decimal::ONE);
}
#[test]
fn test_deposit_sensitivity_per_100bp() {
let input = partial_passthrough();
let out = analyze_deposit_beta(&input).unwrap();
let expected = out.cumulative_beta * dec!(0.01);
assert_eq!(out.deposit_sensitivity, expected);
}
#[test]
fn test_benchmark_zero_change_skipped() {
let input = DepositBetaInput {
rate_changes: vec![
RateChange {
period: "Q1".into(),
benchmark_rate_change: Decimal::ZERO,
deposit_rate_change: Decimal::ZERO,
},
RateChange {
period: "Q2".into(),
benchmark_rate_change: dec!(0.005),
deposit_rate_change: dec!(0.003),
},
],
current_deposit_rate: dec!(0.03),
current_benchmark_rate: dec!(0.05),
};
let out = analyze_deposit_beta(&input).unwrap();
assert!(approx_eq(out.average_beta, dec!(0.6), dec!(0.001)));
}
#[test]
fn test_instantaneous_beta_benchmark_zero() {
let input = DepositBetaInput {
rate_changes: vec![RateChange {
period: "Q1".into(),
benchmark_rate_change: Decimal::ZERO,
deposit_rate_change: dec!(0.001),
}],
current_deposit_rate: dec!(0.03),
current_benchmark_rate: dec!(0.05),
};
let out = analyze_deposit_beta(&input).unwrap();
assert_eq!(out.instantaneous_beta, Decimal::ZERO);
}
#[test]
fn test_reject_empty_rate_changes() {
let input = DepositBetaInput {
rate_changes: vec![],
current_deposit_rate: dec!(0.03),
current_benchmark_rate: dec!(0.05),
};
assert!(analyze_deposit_beta(&input).is_err());
}
#[test]
fn test_negative_benchmark_change() {
let input = DepositBetaInput {
rate_changes: vec![
RateChange {
period: "Q1".into(),
benchmark_rate_change: dec!(-0.0025),
deposit_rate_change: dec!(-0.0015),
},
RateChange {
period: "Q2".into(),
benchmark_rate_change: dec!(-0.0025),
deposit_rate_change: dec!(-0.0020),
},
],
current_deposit_rate: dec!(0.02),
current_benchmark_rate: dec!(0.03),
};
let out = analyze_deposit_beta(&input).unwrap();
assert!(out.cumulative_beta > Decimal::ZERO);
}
#[test]
fn test_serialization_roundtrip() {
let input = partial_passthrough();
let out = analyze_deposit_beta(&input).unwrap();
let json = serde_json::to_string(&out).unwrap();
let _: DepositBetaOutput = serde_json::from_str(&json).unwrap();
}
}