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};
use crate::CorpFinanceResult;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AssetClass {
Sovereign,
Bank,
Corporate,
Retail,
Mortgage,
Equity,
Other,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum CollateralType {
Cash,
GovernmentBond,
CorporateBond,
Equity,
RealEstate,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum OpRiskApproach {
BasicIndicator,
Standardised,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapitalStructure {
pub cet1: Money,
pub additional_tier1: Money,
pub tier2: Money,
pub deductions: Money,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreditExposure {
pub name: String,
pub exposure_amount: Money,
pub asset_class: AssetClass,
#[serde(skip_serializing_if = "Option::is_none")]
pub risk_weight: Option<Decimal>,
#[serde(skip_serializing_if = "Option::is_none")]
pub external_rating: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub collateral_value: Option<Money>,
#[serde(skip_serializing_if = "Option::is_none")]
pub collateral_type: Option<CollateralType>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BusinessLineIncome {
pub line: String,
pub gross_income: Money,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OperationalRiskInput {
pub approach: OpRiskApproach,
pub gross_income_3yr: Vec<Money>,
#[serde(skip_serializing_if = "Option::is_none")]
pub business_lines: Option<Vec<BusinessLineIncome>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapitalBuffers {
pub conservation_buffer: Decimal,
pub countercyclical_buffer: Decimal,
pub systemic_buffer: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegulatoryCapitalInput {
pub institution_name: String,
pub capital: CapitalStructure,
pub credit_exposures: Vec<CreditExposure>,
#[serde(skip_serializing_if = "Option::is_none")]
pub market_risk_charge: Option<Money>,
pub operational_risk: OperationalRiskInput,
#[serde(skip_serializing_if = "Option::is_none")]
pub buffers: Option<CapitalBuffers>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapitalSummary {
pub cet1_capital: Money,
pub tier1_capital: Money,
pub total_capital: Money,
pub deductions: Money,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapitalRatios {
pub cet1_ratio: Decimal,
pub tier1_ratio: Decimal,
pub total_capital_ratio: Decimal,
pub leverage_ratio: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExposureDetail {
pub name: String,
pub exposure: Money,
pub risk_weight: Decimal,
pub rwa: Money,
pub crm_benefit: Money,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BufferRequirements {
pub min_cet1: Decimal,
pub conservation: Decimal,
pub countercyclical: Decimal,
pub systemic: Decimal,
pub total_cet1_requirement: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SurplusDeficit {
pub cet1_surplus: Decimal,
pub tier1_surplus: Decimal,
pub total_capital_surplus: Decimal,
pub meets_requirements: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegulatoryCapitalOutput {
pub capital_summary: CapitalSummary,
pub credit_rwa: Money,
pub market_rwa: Money,
pub operational_rwa: Money,
pub total_rwa: Money,
pub capital_ratios: CapitalRatios,
pub exposure_details: Vec<ExposureDetail>,
pub buffer_requirements: BufferRequirements,
pub surplus_deficit: SurplusDeficit,
}
const MIN_CET1_RATIO: Decimal = dec!(0.045);
const MIN_TIER1_RATIO: Decimal = dec!(0.06);
const MIN_TOTAL_CAPITAL_RATIO: Decimal = dec!(0.08);
const DEFAULT_CONSERVATION: Decimal = dec!(0.025);
pub fn calculate_regulatory_capital(
input: &RegulatoryCapitalInput,
) -> CorpFinanceResult<ComputationOutput<RegulatoryCapitalOutput>> {
let start = Instant::now();
let mut warnings: Vec<String> = Vec::new();
validate_input(input, &mut warnings)?;
let cap = &input.capital;
let cet1_capital = cap.cet1 - cap.deductions;
let tier1_capital = cap.cet1 + cap.additional_tier1 - cap.deductions;
let total_capital = cap.cet1 + cap.additional_tier1 + cap.tier2 - cap.deductions;
let capital_summary = CapitalSummary {
cet1_capital,
tier1_capital,
total_capital,
deductions: cap.deductions,
};
let mut exposure_details: Vec<ExposureDetail> = Vec::new();
let mut credit_rwa = Decimal::ZERO;
for exp in &input.credit_exposures {
let detail = calculate_exposure_rwa(exp, &mut warnings)?;
credit_rwa += detail.rwa;
exposure_details.push(detail);
}
let market_rwa = input.market_risk_charge.unwrap_or(Decimal::ZERO);
let operational_rwa = calculate_operational_risk(&input.operational_risk, &mut warnings)?;
let total_rwa = credit_rwa + market_rwa + operational_rwa;
if total_rwa.is_zero() {
return Err(CorpFinanceError::DivisionByZero {
context: "total RWA is zero; cannot compute capital ratios".into(),
});
}
let cet1_ratio = cet1_capital / total_rwa;
let tier1_ratio = tier1_capital / total_rwa;
let total_capital_ratio = total_capital / total_rwa;
let total_exposure: Decimal = input
.credit_exposures
.iter()
.map(|e| e.exposure_amount)
.sum::<Decimal>()
+ market_rwa;
let leverage_ratio = if total_exposure.is_zero() {
warnings.push("Total exposure is zero; leverage ratio set to zero.".into());
Decimal::ZERO
} else {
tier1_capital / total_exposure
};
let capital_ratios = CapitalRatios {
cet1_ratio,
tier1_ratio,
total_capital_ratio,
leverage_ratio,
};
let buffers = input.buffers.as_ref();
let conservation = buffers
.map(|b| b.conservation_buffer)
.unwrap_or(DEFAULT_CONSERVATION);
let countercyclical = buffers
.map(|b| b.countercyclical_buffer)
.unwrap_or(Decimal::ZERO);
let systemic = buffers.map(|b| b.systemic_buffer).unwrap_or(Decimal::ZERO);
let total_cet1_requirement = MIN_CET1_RATIO + conservation + countercyclical + systemic;
let buffer_requirements = BufferRequirements {
min_cet1: MIN_CET1_RATIO,
conservation,
countercyclical,
systemic,
total_cet1_requirement,
};
let cet1_surplus = cet1_ratio - total_cet1_requirement;
let tier1_surplus = tier1_ratio - MIN_TIER1_RATIO;
let total_capital_surplus = total_capital_ratio - MIN_TOTAL_CAPITAL_RATIO;
let meets_requirements = cet1_ratio >= total_cet1_requirement
&& tier1_ratio >= MIN_TIER1_RATIO
&& total_capital_ratio >= MIN_TOTAL_CAPITAL_RATIO;
if !meets_requirements {
warnings.push("Institution does NOT meet minimum capital requirements.".into());
}
let surplus_deficit = SurplusDeficit {
cet1_surplus,
tier1_surplus,
total_capital_surplus,
meets_requirements,
};
let output = RegulatoryCapitalOutput {
capital_summary,
credit_rwa,
market_rwa,
operational_rwa,
total_rwa,
capital_ratios,
exposure_details,
buffer_requirements,
surplus_deficit,
};
let elapsed = start.elapsed().as_micros() as u64;
let assumptions = serde_json::json!({
"framework": "Basel III / IV Standardised Approach",
"credit_risk": "SA risk weights by asset class and external rating",
"operational_risk": format!("{:?}", input.operational_risk.approach),
"minimum_cet1": "4.5%",
"minimum_tier1": "6.0%",
"minimum_total_capital": "8.0%",
});
Ok(with_metadata(
"Basel III/IV Regulatory Capital (Standardised Approach)",
&assumptions,
warnings,
elapsed,
output,
))
}
fn validate_input(
input: &RegulatoryCapitalInput,
warnings: &mut Vec<String>,
) -> CorpFinanceResult<()> {
let cap = &input.capital;
if cap.cet1 < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "capital.cet1".into(),
reason: "CET1 capital cannot be negative.".into(),
});
}
if cap.additional_tier1 < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "capital.additional_tier1".into(),
reason: "AT1 capital cannot be negative.".into(),
});
}
if cap.tier2 < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "capital.tier2".into(),
reason: "Tier 2 capital cannot be negative.".into(),
});
}
if cap.deductions < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "capital.deductions".into(),
reason: "Deductions cannot be negative.".into(),
});
}
if input.credit_exposures.is_empty() {
warnings.push("No credit exposures provided; credit RWA will be zero.".into());
}
for (i, exp) in input.credit_exposures.iter().enumerate() {
if exp.exposure_amount < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: format!("credit_exposures[{}].exposure_amount", i),
reason: "Exposure amount cannot be negative.".into(),
});
}
if let Some(rw) = exp.risk_weight {
if rw < Decimal::ZERO || rw > dec!(1.5) {
return Err(CorpFinanceError::InvalidInput {
field: format!("credit_exposures[{}].risk_weight", i),
reason: "Risk weight must be between 0 and 1.5.".into(),
});
}
}
}
let op = &input.operational_risk;
if op.gross_income_3yr.is_empty() {
return Err(CorpFinanceError::InsufficientData(
"At least one year of gross income required for operational risk.".into(),
));
}
if op.approach == OpRiskApproach::Standardised && op.business_lines.is_none() {
return Err(CorpFinanceError::InvalidInput {
field: "operational_risk.business_lines".into(),
reason: "Business line breakdown required for Standardised Approach.".into(),
});
}
Ok(())
}
fn calculate_exposure_rwa(
exp: &CreditExposure,
warnings: &mut Vec<String>,
) -> CorpFinanceResult<ExposureDetail> {
let risk_weight = match exp.risk_weight {
Some(rw) => rw,
None => derive_risk_weight(&exp.asset_class, &exp.external_rating, warnings),
};
let (adjusted_ead, crm_benefit) = apply_crm(exp, risk_weight, warnings);
let rwa = adjusted_ead * risk_weight;
Ok(ExposureDetail {
name: exp.name.clone(),
exposure: exp.exposure_amount,
risk_weight,
rwa,
crm_benefit,
})
}
fn derive_risk_weight(
asset_class: &AssetClass,
rating: &Option<String>,
warnings: &mut Vec<String>,
) -> Decimal {
let rating_str = rating.as_deref().unwrap_or("Unrated").trim().to_uppercase();
match asset_class {
AssetClass::Sovereign => match rating_str.as_str() {
"AAA" | "AA" => dec!(0),
"A" => dec!(0.20),
"BBB" => dec!(0.50),
"BB" | "B" => dec!(1.00),
"CCC" => dec!(1.50),
"UNRATED" => {
warnings.push("Unrated sovereign assigned 100% risk weight.".into());
dec!(1.00)
}
_ => {
warnings.push(format!(
"Unknown sovereign rating '{}'; assigned 150% risk weight.",
rating_str
));
dec!(1.50)
}
},
AssetClass::Bank => match rating_str.as_str() {
"AAA" | "AA" | "A" => dec!(0.20),
"BBB" => dec!(0.50),
"BB" | "B" => dec!(1.00),
"CCC" => dec!(1.50),
"UNRATED" => {
warnings.push("Unrated bank assigned 50% risk weight.".into());
dec!(0.50)
}
_ => {
warnings.push(format!(
"Unknown bank rating '{}'; assigned 150% risk weight.",
rating_str
));
dec!(1.50)
}
},
AssetClass::Corporate => match rating_str.as_str() {
"AAA" | "AA" => dec!(0.20),
"A" => dec!(0.50),
"BBB" | "BB" => dec!(1.00),
"B" => dec!(1.50),
"CCC" => dec!(1.50),
"UNRATED" => dec!(1.00),
_ => {
warnings.push(format!(
"Unknown corporate rating '{}'; assigned 150% risk weight.",
rating_str
));
dec!(1.50)
}
},
AssetClass::Retail => dec!(0.75),
AssetClass::Mortgage => {
dec!(0.35)
}
AssetClass::Equity => {
dec!(1.00)
}
AssetClass::Other => dec!(1.00),
}
}
fn apply_crm(
exp: &CreditExposure,
risk_weight: Decimal,
_warnings: &mut Vec<String>,
) -> (Decimal, Decimal) {
let ead = exp.exposure_amount;
let (collateral_val, collateral_ty) = match (&exp.collateral_value, &exp.collateral_type) {
(Some(cv), Some(ct)) if *cv > Decimal::ZERO => (*cv, ct.clone()),
_ => return (ead, Decimal::ZERO),
};
let haircut = collateral_haircut(&collateral_ty);
let effective_collateral = collateral_val * (Decimal::ONE - haircut);
let adjusted_ead = (ead - effective_collateral).max(Decimal::ZERO);
let original_rwa = ead * risk_weight;
let new_rwa = adjusted_ead * risk_weight;
let crm_benefit = original_rwa - new_rwa;
(adjusted_ead, crm_benefit)
}
fn collateral_haircut(ct: &CollateralType) -> Decimal {
match ct {
CollateralType::Cash => dec!(0),
CollateralType::GovernmentBond => dec!(0.02),
CollateralType::CorporateBond => dec!(0.08),
CollateralType::Equity => dec!(0.25),
CollateralType::RealEstate => dec!(0.30),
}
}
fn calculate_operational_risk(
op: &OperationalRiskInput,
warnings: &mut Vec<String>,
) -> CorpFinanceResult<Decimal> {
match op.approach {
OpRiskApproach::BasicIndicator => {
let positive_incomes: Vec<Decimal> = op
.gross_income_3yr
.iter()
.copied()
.filter(|gi| *gi > Decimal::ZERO)
.collect();
if positive_incomes.is_empty() {
warnings
.push("No positive gross income years; operational risk RWA is zero.".into());
return Ok(Decimal::ZERO);
}
let count = Decimal::from(positive_incomes.len() as u64);
let sum: Decimal = positive_incomes.into_iter().sum();
let avg = sum / count;
Ok(avg * dec!(0.15))
}
OpRiskApproach::Standardised => {
let business_lines =
op.business_lines
.as_ref()
.ok_or_else(|| CorpFinanceError::InvalidInput {
field: "operational_risk.business_lines".into(),
reason: "Business line data required for Standardised Approach.".into(),
})?;
let mut total = Decimal::ZERO;
for bl in business_lines {
let beta = beta_factor(&bl.line, warnings);
total += bl.gross_income * beta;
}
Ok(total.max(Decimal::ZERO))
}
}
}
fn beta_factor(line: &str, warnings: &mut Vec<String>) -> Decimal {
match line {
"CorporateFinance" => dec!(0.18),
"Trading" => dec!(0.18),
"Retail" => dec!(0.12),
"Commercial" => dec!(0.15),
"Payment" => dec!(0.18),
"Agency" => dec!(0.15),
"AssetMgmt" => dec!(0.12),
"RetailBrokerage" => dec!(0.12),
other => {
warnings.push(format!(
"Unknown business line '{}'; using default beta of 15%.",
other
));
dec!(0.15)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn default_capital() -> CapitalStructure {
CapitalStructure {
cet1: dec!(10_000),
additional_tier1: dec!(2_000),
tier2: dec!(3_000),
deductions: dec!(1_000),
}
}
fn default_op_risk() -> OperationalRiskInput {
OperationalRiskInput {
approach: OpRiskApproach::BasicIndicator,
gross_income_3yr: vec![dec!(50_000), dec!(55_000), dec!(60_000)],
business_lines: None,
}
}
fn simple_exposure(name: &str, amount: Decimal, class: AssetClass) -> CreditExposure {
CreditExposure {
name: name.to_string(),
exposure_amount: amount,
asset_class: class,
risk_weight: None,
external_rating: None,
collateral_value: None,
collateral_type: None,
}
}
fn rated_exposure(
name: &str,
amount: Decimal,
class: AssetClass,
rating: &str,
) -> CreditExposure {
CreditExposure {
name: name.to_string(),
exposure_amount: amount,
asset_class: class,
risk_weight: None,
external_rating: Some(rating.to_string()),
collateral_value: None,
collateral_type: None,
}
}
fn make_input(exposures: Vec<CreditExposure>) -> RegulatoryCapitalInput {
RegulatoryCapitalInput {
institution_name: "Test Bank".to_string(),
capital: default_capital(),
credit_exposures: exposures,
market_risk_charge: None,
operational_risk: default_op_risk(),
buffers: None,
}
}
#[test]
fn test_100pct_risk_weight_rwa_equals_exposure() {
let exp = simple_exposure("Other Asset", dec!(100_000), AssetClass::Other);
let input = make_input(vec![exp]);
let result = calculate_regulatory_capital(&input).unwrap();
let detail = &result.result.exposure_details[0];
assert_eq!(detail.risk_weight, dec!(1.00));
assert_eq!(detail.rwa, dec!(100_000));
assert_eq!(result.result.credit_rwa, dec!(100_000));
}
#[test]
fn test_sovereign_aaa_zero_risk_weight() {
let exp = rated_exposure("US Treasury", dec!(50_000), AssetClass::Sovereign, "AAA");
let input = make_input(vec![exp]);
let result = calculate_regulatory_capital(&input).unwrap();
let detail = &result.result.exposure_details[0];
assert_eq!(detail.risk_weight, dec!(0));
assert_eq!(detail.rwa, dec!(0));
}
#[test]
fn test_sovereign_aa_zero_risk_weight() {
let exp = rated_exposure("UK Gilt", dec!(30_000), AssetClass::Sovereign, "AA");
let input = make_input(vec![exp]);
let result = calculate_regulatory_capital(&input).unwrap();
assert_eq!(result.result.exposure_details[0].risk_weight, dec!(0));
}
#[test]
fn test_corporate_bbb_100pct() {
let exp = rated_exposure("Corp BBB", dec!(80_000), AssetClass::Corporate, "BBB");
let input = make_input(vec![exp]);
let result = calculate_regulatory_capital(&input).unwrap();
let detail = &result.result.exposure_details[0];
assert_eq!(detail.risk_weight, dec!(1.00));
assert_eq!(detail.rwa, dec!(80_000));
}
#[test]
fn test_retail_75pct() {
let exp = simple_exposure("Retail Portfolio", dec!(40_000), AssetClass::Retail);
let input = make_input(vec![exp]);
let result = calculate_regulatory_capital(&input).unwrap();
let detail = &result.result.exposure_details[0];
assert_eq!(detail.risk_weight, dec!(0.75));
assert_eq!(detail.rwa, dec!(30_000));
}
#[test]
fn test_residential_mortgage_35pct() {
let exp = simple_exposure("Mortgages", dec!(200_000), AssetClass::Mortgage);
let input = make_input(vec![exp]);
let result = calculate_regulatory_capital(&input).unwrap();
let detail = &result.result.exposure_details[0];
assert_eq!(detail.risk_weight, dec!(0.35));
assert_eq!(detail.rwa, dec!(70_000));
}
#[test]
fn test_cet1_ratio_calculation() {
let exp = simple_exposure("Loan", dec!(100_000), AssetClass::Other);
let input = make_input(vec![exp]);
let result = calculate_regulatory_capital(&input).unwrap();
let expected_cet1 = dec!(9_000);
let expected_total_rwa = dec!(100_000) + dec!(8_250);
let expected_ratio = expected_cet1 / expected_total_rwa;
assert_eq!(result.result.capital_summary.cet1_capital, expected_cet1);
assert_eq!(result.result.operational_rwa, dec!(8_250));
assert_eq!(result.result.total_rwa, expected_total_rwa);
assert_eq!(result.result.capital_ratios.cet1_ratio, expected_ratio);
}
#[test]
fn test_meets_all_requirements() {
let mut input = make_input(vec![simple_exposure(
"Small Loan",
dec!(10_000),
AssetClass::Other,
)]);
input.capital = CapitalStructure {
cet1: dec!(50_000),
additional_tier1: dec!(10_000),
tier2: dec!(15_000),
deductions: dec!(2_000),
};
let result = calculate_regulatory_capital(&input).unwrap();
assert!(result.result.surplus_deficit.meets_requirements);
assert!(result.result.surplus_deficit.cet1_surplus > Decimal::ZERO);
assert!(result.result.surplus_deficit.tier1_surplus > Decimal::ZERO);
assert!(result.result.surplus_deficit.total_capital_surplus > Decimal::ZERO);
}
#[test]
fn test_fails_cet1_minimum() {
let mut input = make_input(vec![simple_exposure(
"Big Loan",
dec!(1_000_000),
AssetClass::Other,
)]);
input.capital = CapitalStructure {
cet1: dec!(5_000),
additional_tier1: dec!(1_000),
tier2: dec!(2_000),
deductions: dec!(0),
};
let result = calculate_regulatory_capital(&input).unwrap();
assert!(!result.result.surplus_deficit.meets_requirements);
assert!(result.result.surplus_deficit.cet1_surplus < Decimal::ZERO);
}
#[test]
fn test_operational_risk_bia() {
let input = make_input(vec![simple_exposure(
"Placeholder",
dec!(10_000),
AssetClass::Other,
)]);
let result = calculate_regulatory_capital(&input).unwrap();
assert_eq!(result.result.operational_rwa, dec!(8_250));
}
#[test]
fn test_operational_risk_standardised() {
let mut input = make_input(vec![simple_exposure(
"Loan",
dec!(10_000),
AssetClass::Other,
)]);
input.operational_risk = OperationalRiskInput {
approach: OpRiskApproach::Standardised,
gross_income_3yr: vec![dec!(100_000)],
business_lines: Some(vec![
BusinessLineIncome {
line: "CorporateFinance".to_string(),
gross_income: dec!(30_000),
},
BusinessLineIncome {
line: "Retail".to_string(),
gross_income: dec!(20_000),
},
BusinessLineIncome {
line: "Trading".to_string(),
gross_income: dec!(10_000),
},
]),
};
let result = calculate_regulatory_capital(&input).unwrap();
assert_eq!(result.result.operational_rwa, dec!(9_600));
}
#[test]
fn test_collateral_reduces_rwa() {
let exp = CreditExposure {
name: "Secured Loan".to_string(),
exposure_amount: dec!(100_000),
asset_class: AssetClass::Corporate,
risk_weight: None,
external_rating: Some("BBB".to_string()),
collateral_value: Some(dec!(50_000)),
collateral_type: Some(CollateralType::Cash),
};
let input = make_input(vec![exp]);
let result = calculate_regulatory_capital(&input).unwrap();
let detail = &result.result.exposure_details[0];
assert_eq!(detail.rwa, dec!(50_000));
assert_eq!(detail.crm_benefit, dec!(50_000));
}
#[test]
fn test_govt_bond_collateral_haircut() {
let exp = CreditExposure {
name: "Govt Bond Secured".to_string(),
exposure_amount: dec!(100_000),
asset_class: AssetClass::Corporate,
risk_weight: None,
external_rating: Some("BBB".to_string()),
collateral_value: Some(dec!(50_000)),
collateral_type: Some(CollateralType::GovernmentBond),
};
let input = make_input(vec![exp]);
let result = calculate_regulatory_capital(&input).unwrap();
let detail = &result.result.exposure_details[0];
assert_eq!(detail.rwa, dec!(51_000));
assert_eq!(detail.crm_benefit, dec!(49_000));
}
#[test]
fn test_buffer_requirements_stack() {
let mut input = make_input(vec![simple_exposure(
"Loan",
dec!(100_000),
AssetClass::Other,
)]);
input.buffers = Some(CapitalBuffers {
conservation_buffer: dec!(0.025),
countercyclical_buffer: dec!(0.01),
systemic_buffer: dec!(0.02),
});
let result = calculate_regulatory_capital(&input).unwrap();
let buf = &result.result.buffer_requirements;
assert_eq!(buf.min_cet1, dec!(0.045));
assert_eq!(buf.conservation, dec!(0.025));
assert_eq!(buf.countercyclical, dec!(0.01));
assert_eq!(buf.systemic, dec!(0.02));
assert_eq!(buf.total_cet1_requirement, dec!(0.100));
}
#[test]
fn test_leverage_ratio() {
let exp = simple_exposure("Loan", dec!(100_000), AssetClass::Other);
let input = make_input(vec![exp]);
let result = calculate_regulatory_capital(&input).unwrap();
assert_eq!(result.result.capital_ratios.leverage_ratio, dec!(0.11));
}
#[test]
fn test_multiple_exposure_classes() {
let exposures = vec![
rated_exposure("Sovereign", dec!(50_000), AssetClass::Sovereign, "AAA"),
rated_exposure("Bank", dec!(30_000), AssetClass::Bank, "A"),
rated_exposure("Corporate", dec!(40_000), AssetClass::Corporate, "A"),
simple_exposure("Retail", dec!(20_000), AssetClass::Retail),
simple_exposure("Mortgage", dec!(60_000), AssetClass::Mortgage),
];
let input = make_input(exposures);
let result = calculate_regulatory_capital(&input).unwrap();
assert_eq!(result.result.credit_rwa, dec!(62_000));
assert_eq!(result.result.exposure_details[0].rwa, dec!(0));
assert_eq!(result.result.exposure_details[1].rwa, dec!(6_000));
assert_eq!(result.result.exposure_details[2].rwa, dec!(20_000));
assert_eq!(result.result.exposure_details[3].rwa, dec!(15_000));
assert_eq!(result.result.exposure_details[4].rwa, dec!(21_000));
}
#[test]
fn test_surplus_deficit_calculation() {
let exp = simple_exposure("Loan", dec!(100_000), AssetClass::Other);
let input = make_input(vec![exp]);
let result = calculate_regulatory_capital(&input).unwrap();
let total_rwa = result.result.total_rwa;
let cet1_ratio = dec!(9_000) / total_rwa;
let tier1_ratio = dec!(11_000) / total_rwa;
let tc_ratio = dec!(14_000) / total_rwa;
let expected_cet1_surplus = cet1_ratio - dec!(0.070);
let expected_tier1_surplus = tier1_ratio - dec!(0.06);
let expected_tc_surplus = tc_ratio - dec!(0.08);
assert_eq!(
result.result.surplus_deficit.cet1_surplus,
expected_cet1_surplus
);
assert_eq!(
result.result.surplus_deficit.tier1_surplus,
expected_tier1_surplus
);
assert_eq!(
result.result.surplus_deficit.total_capital_surplus,
expected_tc_surplus
);
}
#[test]
fn test_capital_deductions_applied() {
let mut input = make_input(vec![simple_exposure(
"Loan",
dec!(100_000),
AssetClass::Other,
)]);
input.capital.deductions = dec!(5_000);
let result = calculate_regulatory_capital(&input).unwrap();
let summary = &result.result.capital_summary;
assert_eq!(summary.cet1_capital, dec!(5_000));
assert_eq!(summary.tier1_capital, dec!(7_000));
assert_eq!(summary.total_capital, dec!(10_000));
assert_eq!(summary.deductions, dec!(5_000));
}
#[test]
fn test_unrated_corporate_100pct() {
let exp = simple_exposure("Unrated Corp", dec!(60_000), AssetClass::Corporate);
let input = make_input(vec![exp]);
let result = calculate_regulatory_capital(&input).unwrap();
let detail = &result.result.exposure_details[0];
assert_eq!(detail.risk_weight, dec!(1.00));
assert_eq!(detail.rwa, dec!(60_000));
}
#[test]
fn test_below_bb_corporate_150pct() {
let exp = rated_exposure("Distressed Corp", dec!(40_000), AssetClass::Corporate, "B");
let input = make_input(vec![exp]);
let result = calculate_regulatory_capital(&input).unwrap();
let detail = &result.result.exposure_details[0];
assert_eq!(detail.risk_weight, dec!(1.50));
assert_eq!(detail.rwa, dec!(60_000));
}
#[test]
fn test_ccc_corporate_150pct() {
let exp = rated_exposure("CCC Corp", dec!(20_000), AssetClass::Corporate, "CCC");
let input = make_input(vec![exp]);
let result = calculate_regulatory_capital(&input).unwrap();
let detail = &result.result.exposure_details[0];
assert_eq!(detail.risk_weight, dec!(1.50));
assert_eq!(detail.rwa, dec!(30_000));
}
#[test]
fn test_negative_cet1_rejected() {
let mut input = make_input(vec![simple_exposure(
"Loan",
dec!(10_000),
AssetClass::Other,
)]);
input.capital.cet1 = dec!(-1);
let err = calculate_regulatory_capital(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "capital.cet1");
}
other => panic!("Expected InvalidInput, got {other:?}"),
}
}
#[test]
fn test_negative_exposure_rejected() {
let exp = simple_exposure("Bad", dec!(-100), AssetClass::Other);
let input = make_input(vec![exp]);
let err = calculate_regulatory_capital(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert!(field.contains("exposure_amount"));
}
other => panic!("Expected InvalidInput, got {other:?}"),
}
}
#[test]
fn test_risk_weight_override_out_of_range() {
let exp = CreditExposure {
name: "Bad RW".to_string(),
exposure_amount: dec!(10_000),
asset_class: AssetClass::Other,
risk_weight: Some(dec!(2.0)), external_rating: None,
collateral_value: None,
collateral_type: None,
};
let input = make_input(vec![exp]);
let err = calculate_regulatory_capital(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert!(field.contains("risk_weight"));
}
other => panic!("Expected InvalidInput, got {other:?}"),
}
}
#[test]
fn test_risk_weight_override() {
let exp = CreditExposure {
name: "Custom RW".to_string(),
exposure_amount: dec!(100_000),
asset_class: AssetClass::Corporate,
risk_weight: Some(dec!(0.50)),
external_rating: Some("BBB".to_string()), collateral_value: None,
collateral_type: None,
};
let input = make_input(vec![exp]);
let result = calculate_regulatory_capital(&input).unwrap();
let detail = &result.result.exposure_details[0];
assert_eq!(detail.risk_weight, dec!(0.50));
assert_eq!(detail.rwa, dec!(50_000));
}
#[test]
fn test_market_risk_charge_included() {
let mut input = make_input(vec![simple_exposure(
"Loan",
dec!(100_000),
AssetClass::Other,
)]);
input.market_risk_charge = Some(dec!(15_000));
let result = calculate_regulatory_capital(&input).unwrap();
assert_eq!(result.result.market_rwa, dec!(15_000));
assert_eq!(result.result.total_rwa, dec!(123_250));
}
#[test]
fn test_bia_negative_income_excluded() {
let mut input = make_input(vec![simple_exposure(
"Loan",
dec!(10_000),
AssetClass::Other,
)]);
input.operational_risk.gross_income_3yr = vec![dec!(100_000), dec!(-20_000), dec!(80_000)];
let result = calculate_regulatory_capital(&input).unwrap();
assert_eq!(result.result.operational_rwa, dec!(13_500));
}
#[test]
fn test_equity_collateral_haircut() {
let exp = CreditExposure {
name: "Equity Secured".to_string(),
exposure_amount: dec!(100_000),
asset_class: AssetClass::Corporate,
risk_weight: None,
external_rating: Some("BBB".to_string()),
collateral_value: Some(dec!(40_000)),
collateral_type: Some(CollateralType::Equity),
};
let input = make_input(vec![exp]);
let result = calculate_regulatory_capital(&input).unwrap();
let detail = &result.result.exposure_details[0];
assert_eq!(detail.rwa, dec!(70_000));
assert_eq!(detail.crm_benefit, dec!(30_000));
}
#[test]
fn test_tier1_and_total_capital_ratios() {
let exp = simple_exposure("Loan", dec!(100_000), AssetClass::Other);
let input = make_input(vec![exp]);
let result = calculate_regulatory_capital(&input).unwrap();
let total_rwa = result.result.total_rwa;
let expected_tier1_ratio = dec!(11_000) / total_rwa;
let expected_tc_ratio = dec!(14_000) / total_rwa;
assert_eq!(
result.result.capital_ratios.tier1_ratio,
expected_tier1_ratio
);
assert_eq!(
result.result.capital_ratios.total_capital_ratio,
expected_tc_ratio
);
}
#[test]
fn test_metadata_populated() {
let input = make_input(vec![simple_exposure(
"Loan",
dec!(10_000),
AssetClass::Other,
)]);
let result = calculate_regulatory_capital(&input).unwrap();
assert!(result.methodology.contains("Basel III"));
assert_eq!(result.metadata.precision, "rust_decimal_128bit");
assert!(!result.warnings.is_empty() || result.warnings.is_empty()); }
#[test]
fn test_bank_rating_risk_weights() {
let exp = rated_exposure("Bank AAA", dec!(10_000), AssetClass::Bank, "AAA");
let mut warnings = Vec::new();
let detail = calculate_exposure_rwa(&exp, &mut warnings).unwrap();
assert_eq!(detail.risk_weight, dec!(0.20));
let exp = rated_exposure("Bank BBB", dec!(10_000), AssetClass::Bank, "BBB");
let detail = calculate_exposure_rwa(&exp, &mut warnings).unwrap();
assert_eq!(detail.risk_weight, dec!(0.50));
let exp = rated_exposure("Bank BB", dec!(10_000), AssetClass::Bank, "BB");
let detail = calculate_exposure_rwa(&exp, &mut warnings).unwrap();
assert_eq!(detail.risk_weight, dec!(1.00));
let exp = rated_exposure("Bank CCC", dec!(10_000), AssetClass::Bank, "CCC");
let detail = calculate_exposure_rwa(&exp, &mut warnings).unwrap();
assert_eq!(detail.risk_weight, dec!(1.50));
}
#[test]
fn test_sovereign_rating_categories() {
let mut warnings = Vec::new();
let exp = rated_exposure("Sov A", dec!(10_000), AssetClass::Sovereign, "A");
let detail = calculate_exposure_rwa(&exp, &mut warnings).unwrap();
assert_eq!(detail.risk_weight, dec!(0.20));
let exp = rated_exposure("Sov BBB", dec!(10_000), AssetClass::Sovereign, "BBB");
let detail = calculate_exposure_rwa(&exp, &mut warnings).unwrap();
assert_eq!(detail.risk_weight, dec!(0.50));
let exp = rated_exposure("Sov BB", dec!(10_000), AssetClass::Sovereign, "BB");
let detail = calculate_exposure_rwa(&exp, &mut warnings).unwrap();
assert_eq!(detail.risk_weight, dec!(1.00));
let exp = rated_exposure("Sov CCC", dec!(10_000), AssetClass::Sovereign, "CCC");
let detail = calculate_exposure_rwa(&exp, &mut warnings).unwrap();
assert_eq!(detail.risk_weight, dec!(1.50));
}
#[test]
fn test_default_buffers_when_none() {
let input = make_input(vec![simple_exposure(
"Loan",
dec!(10_000),
AssetClass::Other,
)]);
let result = calculate_regulatory_capital(&input).unwrap();
let buf = &result.result.buffer_requirements;
assert_eq!(buf.conservation, dec!(0.025));
assert_eq!(buf.countercyclical, Decimal::ZERO);
assert_eq!(buf.systemic, Decimal::ZERO);
assert_eq!(buf.total_cet1_requirement, dec!(0.070));
}
#[test]
fn test_no_exposures_errors_on_zero_rwa() {
let mut input = make_input(vec![]);
input.operational_risk.gross_income_3yr = vec![dec!(-10)]; let result = calculate_regulatory_capital(&input);
assert!(result.is_err());
}
#[test]
fn test_equity_exposure_100pct() {
let exp = simple_exposure("Listed Equity", dec!(25_000), AssetClass::Equity);
let input = make_input(vec![exp]);
let result = calculate_regulatory_capital(&input).unwrap();
let detail = &result.result.exposure_details[0];
assert_eq!(detail.risk_weight, dec!(1.00));
assert_eq!(detail.rwa, dec!(25_000));
}
#[test]
fn test_corporate_aa_20pct() {
let exp = rated_exposure("Corp AA", dec!(50_000), AssetClass::Corporate, "AA");
let input = make_input(vec![exp]);
let result = calculate_regulatory_capital(&input).unwrap();
let detail = &result.result.exposure_details[0];
assert_eq!(detail.risk_weight, dec!(0.20));
assert_eq!(detail.rwa, dec!(10_000));
}
#[test]
fn test_corporate_a_50pct() {
let exp = rated_exposure("Corp A", dec!(50_000), AssetClass::Corporate, "A");
let input = make_input(vec![exp]);
let result = calculate_regulatory_capital(&input).unwrap();
let detail = &result.result.exposure_details[0];
assert_eq!(detail.risk_weight, dec!(0.50));
assert_eq!(detail.rwa, dec!(25_000));
}
#[test]
fn test_collateral_exceeding_ead_clamps_to_zero() {
let exp = CreditExposure {
name: "Over-Secured".to_string(),
exposure_amount: dec!(50_000),
asset_class: AssetClass::Corporate,
risk_weight: None,
external_rating: Some("BBB".to_string()),
collateral_value: Some(dec!(100_000)),
collateral_type: Some(CollateralType::Cash),
};
let input = make_input(vec![exp]);
let result = calculate_regulatory_capital(&input).unwrap();
let detail = &result.result.exposure_details[0];
assert_eq!(detail.rwa, dec!(0));
assert_eq!(detail.crm_benefit, dec!(50_000));
}
#[test]
fn test_sa_missing_business_lines_rejected() {
let mut input = make_input(vec![simple_exposure(
"Loan",
dec!(10_000),
AssetClass::Other,
)]);
input.operational_risk = OperationalRiskInput {
approach: OpRiskApproach::Standardised,
gross_income_3yr: vec![dec!(100_000)],
business_lines: None,
};
let err = calculate_regulatory_capital(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert!(field.contains("business_lines"));
}
other => panic!("Expected InvalidInput, got {other:?}"),
}
}
#[test]
fn test_empty_gross_income_rejected() {
let mut input = make_input(vec![simple_exposure(
"Loan",
dec!(10_000),
AssetClass::Other,
)]);
input.operational_risk.gross_income_3yr = vec![];
let err = calculate_regulatory_capital(&input).unwrap_err();
match err {
CorpFinanceError::InsufficientData(_) => {} other => panic!("Expected InsufficientData, got {other:?}"),
}
}
}