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, PartialEq, Eq)]
pub enum LimitType {
Notional,
VaR,
Concentration,
Sector,
Country,
}
impl std::fmt::Display for LimitType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LimitType::Notional => write!(f, "Notional"),
LimitType::VaR => write!(f, "VaR"),
LimitType::Concentration => write!(f, "Concentration"),
LimitType::Sector => write!(f, "Sector"),
LimitType::Country => write!(f, "Country"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum LimitStatus {
Green,
Amber,
Red,
Breach,
}
impl std::fmt::Display for LimitStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LimitStatus::Green => write!(f, "Green"),
LimitStatus::Amber => write!(f, "Amber"),
LimitStatus::Red => write!(f, "Red"),
LimitStatus::Breach => write!(f, "Breach"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LimitDefinition {
pub name: String,
pub limit_type: LimitType,
pub limit_value: Decimal,
pub current_value: Decimal,
pub warning_threshold: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LimitManagementInput {
pub limits: Vec<LimitDefinition>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LimitStatusDetail {
pub name: String,
pub limit_type: LimitType,
pub utilization_pct: Decimal,
pub headroom: Decimal,
pub status: LimitStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LimitManagementOutput {
pub limit_status: Vec<LimitStatusDetail>,
pub total_breaches: u32,
pub total_warnings: u32,
pub worst_utilization: Decimal,
}
pub fn evaluate_limits(input: &LimitManagementInput) -> CorpFinanceResult<LimitManagementOutput> {
validate_limit_input(input)?;
let mut limit_status = Vec::with_capacity(input.limits.len());
let mut total_breaches: u32 = 0;
let mut total_warnings: u32 = 0;
let mut worst_utilization = Decimal::ZERO;
for limit in &input.limits {
let utilization_pct = if limit.limit_value.is_zero() {
if limit.current_value.is_zero() {
Decimal::ZERO
} else {
dec!(999.99)
}
} else {
limit.current_value / limit.limit_value
};
let headroom = limit.limit_value - limit.current_value;
let status = if utilization_pct > Decimal::ONE {
LimitStatus::Breach
} else if utilization_pct == Decimal::ONE {
LimitStatus::Red
} else if utilization_pct >= limit.warning_threshold {
LimitStatus::Amber
} else {
LimitStatus::Green
};
match status {
LimitStatus::Breach => total_breaches += 1,
LimitStatus::Amber => total_warnings += 1,
LimitStatus::Red => total_breaches += 1,
LimitStatus::Green => {}
}
if utilization_pct > worst_utilization {
worst_utilization = utilization_pct;
}
limit_status.push(LimitStatusDetail {
name: limit.name.clone(),
limit_type: limit.limit_type.clone(),
utilization_pct,
headroom,
status,
});
}
Ok(LimitManagementOutput {
limit_status,
total_breaches,
total_warnings,
worst_utilization,
})
}
fn validate_limit_input(input: &LimitManagementInput) -> CorpFinanceResult<()> {
if input.limits.is_empty() {
return Err(CorpFinanceError::InsufficientData(
"At least one limit definition is required.".into(),
));
}
for limit in &input.limits {
if limit.limit_value < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "limit_value".into(),
reason: format!(
"Limit value must be non-negative for limit '{}'.",
limit.name
),
});
}
if limit.current_value < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "current_value".into(),
reason: format!(
"Current value must be non-negative for limit '{}'.",
limit.name
),
});
}
if limit.warning_threshold < Decimal::ZERO || limit.warning_threshold > Decimal::ONE {
return Err(CorpFinanceError::InvalidInput {
field: "warning_threshold".into(),
reason: format!(
"Warning threshold must be in [0, 1] for limit '{}'.",
limit.name
),
});
}
}
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 make_base_input() -> LimitManagementInput {
LimitManagementInput {
limits: vec![
LimitDefinition {
name: "Notional_Total".into(),
limit_type: LimitType::Notional,
limit_value: dec!(100_000_000),
current_value: dec!(75_000_000),
warning_threshold: dec!(0.80),
},
LimitDefinition {
name: "VaR_Daily".into(),
limit_type: LimitType::VaR,
limit_value: dec!(5_000_000),
current_value: dec!(4_500_000),
warning_threshold: dec!(0.80),
},
LimitDefinition {
name: "Single_Name".into(),
limit_type: LimitType::Concentration,
limit_value: dec!(10_000_000),
current_value: dec!(6_000_000),
warning_threshold: dec!(0.80),
},
LimitDefinition {
name: "Sector_Tech".into(),
limit_type: LimitType::Sector,
limit_value: dec!(30_000_000),
current_value: dec!(32_000_000),
warning_threshold: dec!(0.80),
},
LimitDefinition {
name: "Country_EM".into(),
limit_type: LimitType::Country,
limit_value: dec!(20_000_000),
current_value: dec!(10_000_000),
warning_threshold: dec!(0.80),
},
],
}
}
#[test]
fn test_status_count_matches_limits() {
let input = make_base_input();
let out = evaluate_limits(&input).unwrap();
assert_eq!(out.limit_status.len(), 5);
}
#[test]
fn test_green_status_below_threshold() {
let input = make_base_input();
let out = evaluate_limits(&input).unwrap();
let country = out
.limit_status
.iter()
.find(|s| s.name == "Country_EM")
.unwrap();
assert_eq!(country.status, LimitStatus::Green);
}
#[test]
fn test_amber_status_above_threshold() {
let input = make_base_input();
let out = evaluate_limits(&input).unwrap();
let var = out
.limit_status
.iter()
.find(|s| s.name == "VaR_Daily")
.unwrap();
assert_eq!(var.status, LimitStatus::Amber);
}
#[test]
fn test_breach_status_over_limit() {
let input = make_base_input();
let out = evaluate_limits(&input).unwrap();
let sector = out
.limit_status
.iter()
.find(|s| s.name == "Sector_Tech")
.unwrap();
assert_eq!(sector.status, LimitStatus::Breach);
}
#[test]
fn test_utilization_calculation() {
let input = make_base_input();
let out = evaluate_limits(&input).unwrap();
let notional = out
.limit_status
.iter()
.find(|s| s.name == "Notional_Total")
.unwrap();
assert_eq!(notional.utilization_pct, dec!(0.75));
}
#[test]
fn test_headroom_calculation() {
let input = make_base_input();
let out = evaluate_limits(&input).unwrap();
let notional = out
.limit_status
.iter()
.find(|s| s.name == "Notional_Total")
.unwrap();
assert_eq!(notional.headroom, dec!(25_000_000));
}
#[test]
fn test_negative_headroom_on_breach() {
let input = make_base_input();
let out = evaluate_limits(&input).unwrap();
let sector = out
.limit_status
.iter()
.find(|s| s.name == "Sector_Tech")
.unwrap();
assert_eq!(sector.headroom, dec!(-2_000_000));
}
#[test]
fn test_total_breaches_count() {
let input = make_base_input();
let out = evaluate_limits(&input).unwrap();
assert_eq!(out.total_breaches, 1);
}
#[test]
fn test_total_warnings_count() {
let input = make_base_input();
let out = evaluate_limits(&input).unwrap();
assert!(out.total_warnings >= 1);
}
#[test]
fn test_worst_utilization() {
let input = make_base_input();
let out = evaluate_limits(&input).unwrap();
let expected = dec!(32_000_000) / dec!(30_000_000);
assert!(
approx_eq(out.worst_utilization, expected, dec!(0.001)),
"Worst utilization should be ~{}, got {}",
expected,
out.worst_utilization
);
}
#[test]
fn test_all_green_no_breaches_no_warnings() {
let input = LimitManagementInput {
limits: vec![
LimitDefinition {
name: "Safe_1".into(),
limit_type: LimitType::Notional,
limit_value: dec!(100),
current_value: dec!(10),
warning_threshold: dec!(0.80),
},
LimitDefinition {
name: "Safe_2".into(),
limit_type: LimitType::VaR,
limit_value: dec!(100),
current_value: dec!(20),
warning_threshold: dec!(0.80),
},
],
};
let out = evaluate_limits(&input).unwrap();
assert_eq!(out.total_breaches, 0);
assert_eq!(out.total_warnings, 0);
for s in &out.limit_status {
assert_eq!(s.status, LimitStatus::Green);
}
}
#[test]
fn test_exact_at_limit_is_red() {
let input = LimitManagementInput {
limits: vec![LimitDefinition {
name: "At_Limit".into(),
limit_type: LimitType::Notional,
limit_value: dec!(100),
current_value: dec!(100),
warning_threshold: dec!(0.80),
}],
};
let out = evaluate_limits(&input).unwrap();
assert_eq!(out.limit_status[0].status, LimitStatus::Red);
}
#[test]
fn test_exact_at_warning_threshold_is_amber() {
let input = LimitManagementInput {
limits: vec![LimitDefinition {
name: "At_Warning".into(),
limit_type: LimitType::VaR,
limit_value: dec!(100),
current_value: dec!(80),
warning_threshold: dec!(0.80),
}],
};
let out = evaluate_limits(&input).unwrap();
assert_eq!(out.limit_status[0].status, LimitStatus::Amber);
}
#[test]
fn test_just_below_warning_is_green() {
let input = LimitManagementInput {
limits: vec![LimitDefinition {
name: "Below_Warning".into(),
limit_type: LimitType::Concentration,
limit_value: dec!(100),
current_value: dec!(79),
warning_threshold: dec!(0.80),
}],
};
let out = evaluate_limits(&input).unwrap();
assert_eq!(out.limit_status[0].status, LimitStatus::Green);
}
#[test]
fn test_zero_current_is_green() {
let input = LimitManagementInput {
limits: vec![LimitDefinition {
name: "Unused".into(),
limit_type: LimitType::Country,
limit_value: dec!(100),
current_value: Decimal::ZERO,
warning_threshold: dec!(0.80),
}],
};
let out = evaluate_limits(&input).unwrap();
assert_eq!(out.limit_status[0].status, LimitStatus::Green);
assert_eq!(out.limit_status[0].utilization_pct, Decimal::ZERO);
assert_eq!(out.limit_status[0].headroom, dec!(100));
}
#[test]
fn test_zero_limit_zero_current_is_green() {
let input = LimitManagementInput {
limits: vec![LimitDefinition {
name: "Zero_Zero".into(),
limit_type: LimitType::Notional,
limit_value: Decimal::ZERO,
current_value: Decimal::ZERO,
warning_threshold: dec!(0.80),
}],
};
let out = evaluate_limits(&input).unwrap();
assert_eq!(out.limit_status[0].utilization_pct, Decimal::ZERO);
}
#[test]
fn test_zero_limit_nonzero_current_is_breach() {
let input = LimitManagementInput {
limits: vec![LimitDefinition {
name: "Zero_Limit".into(),
limit_type: LimitType::VaR,
limit_value: Decimal::ZERO,
current_value: dec!(1),
warning_threshold: dec!(0.80),
}],
};
let out = evaluate_limits(&input).unwrap();
assert_eq!(out.limit_status[0].status, LimitStatus::Breach);
}
#[test]
fn test_limit_type_preserved() {
let input = make_base_input();
let out = evaluate_limits(&input).unwrap();
assert_eq!(out.limit_status[0].limit_type, LimitType::Notional);
assert_eq!(out.limit_status[1].limit_type, LimitType::VaR);
assert_eq!(out.limit_status[2].limit_type, LimitType::Concentration);
assert_eq!(out.limit_status[3].limit_type, LimitType::Sector);
assert_eq!(out.limit_status[4].limit_type, LimitType::Country);
}
#[test]
fn test_multiple_breaches() {
let input = LimitManagementInput {
limits: vec![
LimitDefinition {
name: "Breach_1".into(),
limit_type: LimitType::Notional,
limit_value: dec!(100),
current_value: dec!(150),
warning_threshold: dec!(0.80),
},
LimitDefinition {
name: "Breach_2".into(),
limit_type: LimitType::VaR,
limit_value: dec!(50),
current_value: dec!(60),
warning_threshold: dec!(0.80),
},
],
};
let out = evaluate_limits(&input).unwrap();
assert_eq!(out.total_breaches, 2);
}
#[test]
fn test_warning_threshold_zero_all_amber_or_higher() {
let input = LimitManagementInput {
limits: vec![LimitDefinition {
name: "Zero_Threshold".into(),
limit_type: LimitType::Sector,
limit_value: dec!(100),
current_value: dec!(50),
warning_threshold: Decimal::ZERO,
}],
};
let out = evaluate_limits(&input).unwrap();
assert_eq!(out.limit_status[0].status, LimitStatus::Amber);
}
#[test]
fn test_warning_threshold_one_only_breach_triggers() {
let input = LimitManagementInput {
limits: vec![LimitDefinition {
name: "High_Threshold".into(),
limit_type: LimitType::Country,
limit_value: dec!(100),
current_value: dec!(99),
warning_threshold: Decimal::ONE,
}],
};
let out = evaluate_limits(&input).unwrap();
assert_eq!(out.limit_status[0].status, LimitStatus::Green);
}
#[test]
fn test_reject_empty_limits() {
let input = LimitManagementInput { limits: vec![] };
assert!(evaluate_limits(&input).is_err());
}
#[test]
fn test_reject_negative_limit_value() {
let input = LimitManagementInput {
limits: vec![LimitDefinition {
name: "Bad".into(),
limit_type: LimitType::Notional,
limit_value: dec!(-100),
current_value: dec!(50),
warning_threshold: dec!(0.80),
}],
};
assert!(evaluate_limits(&input).is_err());
}
#[test]
fn test_reject_negative_current_value() {
let input = LimitManagementInput {
limits: vec![LimitDefinition {
name: "Bad".into(),
limit_type: LimitType::VaR,
limit_value: dec!(100),
current_value: dec!(-10),
warning_threshold: dec!(0.80),
}],
};
assert!(evaluate_limits(&input).is_err());
}
#[test]
fn test_reject_warning_threshold_above_one() {
let input = LimitManagementInput {
limits: vec![LimitDefinition {
name: "Bad".into(),
limit_type: LimitType::Concentration,
limit_value: dec!(100),
current_value: dec!(50),
warning_threshold: dec!(1.5),
}],
};
assert!(evaluate_limits(&input).is_err());
}
#[test]
fn test_reject_negative_warning_threshold() {
let input = LimitManagementInput {
limits: vec![LimitDefinition {
name: "Bad".into(),
limit_type: LimitType::Sector,
limit_value: dec!(100),
current_value: dec!(50),
warning_threshold: dec!(-0.1),
}],
};
assert!(evaluate_limits(&input).is_err());
}
#[test]
fn test_serialization_roundtrip() {
let input = make_base_input();
let out = evaluate_limits(&input).unwrap();
let json = serde_json::to_string(&out).unwrap();
let _: LimitManagementOutput = serde_json::from_str(&json).unwrap();
}
}