use crate::compat::Instant;
use rust_decimal::Decimal;
use rust_decimal::MathematicalOps;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use crate::error::CorpFinanceError;
use crate::types::{with_metadata, ComputationOutput, Money, Rate};
use crate::CorpFinanceResult;
fn sqrt_decimal(val: Decimal) -> Decimal {
if val <= Decimal::ZERO {
return Decimal::ZERO;
}
let mut guess = val.sqrt().unwrap_or_else(|| {
val / dec!(2)
});
let two = dec!(2);
for _ in 0..20 {
if guess.is_zero() {
return Decimal::ZERO;
}
guess = (guess + val / guess) / two;
}
guess
}
fn decimal_pow(base: Decimal, exp: u32) -> Decimal {
let mut result = Decimal::ONE;
for _ in 0..exp {
result *= base;
}
result
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PremiumPricingInput {
pub line_of_business: String,
pub exposure_units: Decimal,
pub claim_frequency: Decimal,
pub average_severity: Money,
pub severity_trend: Rate,
pub frequency_trend: Rate,
pub projection_years: u32,
pub expense_ratio_target: Rate,
pub profit_margin_target: Rate,
pub reinsurance_cost_pct: Rate,
pub investment_income_credit: Rate,
pub large_loss_load_pct: Rate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateComponents {
pub loss_cost: Money,
pub loss_cost_pct: Rate,
pub expense_load: Money,
pub profit_load: Money,
pub reinsurance_load: Money,
pub large_loss_load: Money,
pub investment_credit: Money,
pub total: Money,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectedYear {
pub year: u32,
pub projected_frequency: Decimal,
pub projected_severity: Money,
pub projected_pure_premium: Money,
pub loss_ratio: Rate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PremiumPricingOutput {
pub pure_premium: Money,
pub trended_pure_premium: Money,
pub gross_premium: Money,
pub premium_per_unit: Money,
pub rate_components: RateComponents,
pub projected_experience: Vec<ProjectedYear>,
}
pub fn price_premium(
input: &PremiumPricingInput,
) -> CorpFinanceResult<ComputationOutput<PremiumPricingOutput>> {
let start = Instant::now();
let mut warnings: Vec<String> = Vec::new();
if input.exposure_units <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "exposure_units".into(),
reason: "Exposure units must be positive".into(),
});
}
if input.claim_frequency < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "claim_frequency".into(),
reason: "Claim frequency cannot be negative".into(),
});
}
if input.average_severity < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "average_severity".into(),
reason: "Average severity cannot be negative".into(),
});
}
let denominator = Decimal::ONE
- input.expense_ratio_target
- input.profit_margin_target
- input.reinsurance_cost_pct
- input.large_loss_load_pct
+ input.investment_income_credit;
if denominator <= Decimal::ZERO {
return Err(CorpFinanceError::FinancialImpossibility(
"Loading factors exceed 100% — cannot compute a positive gross premium".into(),
));
}
if input.expense_ratio_target >= Decimal::ONE {
warnings.push("Expense ratio target >= 100% — this is unusual".into());
}
let pure_premium = input.exposure_units * input.claim_frequency * input.average_severity;
let freq_factor = decimal_pow(Decimal::ONE + input.frequency_trend, input.projection_years);
let sev_factor = decimal_pow(Decimal::ONE + input.severity_trend, input.projection_years);
let trended_frequency = input.claim_frequency * freq_factor;
let trended_severity = input.average_severity * sev_factor;
let trended_pure_premium = input.exposure_units * trended_frequency * trended_severity;
let gross_premium = trended_pure_premium / denominator;
let premium_per_unit = gross_premium / input.exposure_units;
let loss_cost = trended_pure_premium;
let expense_load = gross_premium * input.expense_ratio_target;
let profit_load = gross_premium * input.profit_margin_target;
let reinsurance_load = gross_premium * input.reinsurance_cost_pct;
let large_loss_load = gross_premium * input.large_loss_load_pct;
let investment_credit = gross_premium * input.investment_income_credit;
let loss_cost_pct = if gross_premium.is_zero() {
Decimal::ZERO
} else {
loss_cost / gross_premium
};
let rate_components = RateComponents {
loss_cost,
loss_cost_pct,
expense_load,
profit_load,
reinsurance_load,
large_loss_load,
investment_credit,
total: gross_premium,
};
let mut projected_experience = Vec::with_capacity(input.projection_years as usize);
for y in 1..=input.projection_years {
let proj_freq =
input.claim_frequency * decimal_pow(Decimal::ONE + input.frequency_trend, y);
let proj_sev = input.average_severity * decimal_pow(Decimal::ONE + input.severity_trend, y);
let proj_pure = input.exposure_units * proj_freq * proj_sev;
let loss_ratio = if gross_premium.is_zero() {
Decimal::ZERO
} else {
proj_pure / gross_premium
};
projected_experience.push(ProjectedYear {
year: y,
projected_frequency: proj_freq,
projected_severity: proj_sev,
projected_pure_premium: proj_pure,
loss_ratio,
});
}
let output = PremiumPricingOutput {
pure_premium,
trended_pure_premium,
gross_premium,
premium_per_unit,
rate_components,
projected_experience,
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"Insurance Premium Pricing (Frequency x Severity)",
&serde_json::json!({
"line_of_business": input.line_of_business,
"exposure_units": input.exposure_units.to_string(),
"projection_years": input.projection_years,
"loading_denominator": denominator.to_string(),
}),
warnings,
elapsed,
output,
))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InsurancePeriod {
pub year: u32,
pub net_earned_premium: Money,
pub net_incurred_losses: Money,
pub loss_adjustment_expenses: Money,
pub underwriting_expenses: Money,
pub policyholder_dividends: Money,
pub net_investment_income: Money,
pub realized_gains: Money,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CombinedRatioInput {
pub company_name: String,
pub periods: Vec<InsurancePeriod>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeriodResult {
pub year: u32,
pub loss_ratio: Rate,
pub lae_ratio: Rate,
pub expense_ratio: Rate,
pub dividend_ratio: Rate,
pub combined_ratio: Rate,
pub operating_ratio: Rate,
pub underwriting_profit_loss: Money,
pub net_income: Money,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RatioSummary {
pub avg_loss_ratio: Rate,
pub avg_combined_ratio: Rate,
pub avg_operating_ratio: Rate,
pub trend_direction: String,
pub best_year: u32,
pub worst_year: u32,
pub profitable_years: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CombinedRatioOutput {
pub period_results: Vec<PeriodResult>,
pub summary: RatioSummary,
}
pub fn analyze_combined_ratio(
input: &CombinedRatioInput,
) -> CorpFinanceResult<ComputationOutput<CombinedRatioOutput>> {
let start = Instant::now();
let warnings: Vec<String> = Vec::new();
if input.periods.is_empty() {
return Err(CorpFinanceError::InsufficientData(
"At least one insurance period is required".into(),
));
}
let mut period_results = Vec::with_capacity(input.periods.len());
for p in &input.periods {
if p.net_earned_premium.is_zero() {
return Err(CorpFinanceError::DivisionByZero {
context: format!("Net earned premium is zero for year {}", p.year),
});
}
let nep = p.net_earned_premium;
let loss_ratio = p.net_incurred_losses / nep;
let lae_ratio = p.loss_adjustment_expenses / nep;
let expense_ratio = p.underwriting_expenses / nep;
let dividend_ratio = p.policyholder_dividends / nep;
let combined_ratio = loss_ratio + lae_ratio + expense_ratio + dividend_ratio;
let investment_ratio = p.net_investment_income / nep;
let operating_ratio = combined_ratio - investment_ratio;
let underwriting_profit_loss = nep
- p.net_incurred_losses
- p.loss_adjustment_expenses
- p.underwriting_expenses
- p.policyholder_dividends;
let net_income = underwriting_profit_loss + p.net_investment_income + p.realized_gains;
period_results.push(PeriodResult {
year: p.year,
loss_ratio,
lae_ratio,
expense_ratio,
dividend_ratio,
combined_ratio,
operating_ratio,
underwriting_profit_loss,
net_income,
});
}
let n = Decimal::from(period_results.len() as i64);
let avg_loss_ratio: Rate = period_results.iter().map(|r| r.loss_ratio).sum::<Decimal>() / n;
let avg_combined_ratio: Rate = period_results
.iter()
.map(|r| r.combined_ratio)
.sum::<Decimal>()
/ n;
let avg_operating_ratio: Rate = period_results
.iter()
.map(|r| r.operating_ratio)
.sum::<Decimal>()
/ n;
let best = period_results
.iter()
.min_by(|a, b| a.combined_ratio.cmp(&b.combined_ratio))
.unwrap();
let worst = period_results
.iter()
.max_by(|a, b| a.combined_ratio.cmp(&b.combined_ratio))
.unwrap();
let trend_direction = determine_trend(&period_results);
let profitable_years = period_results
.iter()
.filter(|r| r.combined_ratio < Decimal::ONE)
.count() as u32;
let summary = RatioSummary {
avg_loss_ratio,
avg_combined_ratio,
avg_operating_ratio,
trend_direction,
best_year: best.year,
worst_year: worst.year,
profitable_years,
};
let output = CombinedRatioOutput {
period_results,
summary,
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"Combined Ratio Analysis",
&serde_json::json!({
"company": input.company_name,
"periods_analyzed": input.periods.len(),
}),
warnings,
elapsed,
output,
))
}
fn determine_trend(results: &[PeriodResult]) -> String {
if results.len() < 2 {
return "Stable".to_string();
}
let mid = results.len() / 2;
let first_half_avg: Decimal = results[..mid]
.iter()
.map(|r| r.combined_ratio)
.sum::<Decimal>()
/ Decimal::from(mid as i64);
let second_half_avg: Decimal = results[mid..]
.iter()
.map(|r| r.combined_ratio)
.sum::<Decimal>()
/ Decimal::from((results.len() - mid) as i64);
let diff = second_half_avg - first_half_avg;
let threshold = dec!(0.02);
if diff < -threshold {
"Improving".to_string()
} else if diff > threshold {
"Deteriorating".to_string()
} else {
"Stable".to_string()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PremiumReserveRisk {
pub net_earned_premium: Money,
pub net_best_estimate_reserves: Money,
pub premium_risk_factor: Rate,
pub reserve_risk_factor: Rate,
pub geographic_diversification: Rate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScrInput {
pub company_name: String,
pub premium_risk: PremiumReserveRisk,
pub catastrophe_risk: Money,
pub market_risk: Money,
pub credit_risk: Money,
pub operational_risk_premium: Money,
pub eligible_own_funds: Money,
pub mcr_factor: Rate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScrOutput {
pub non_life_underwriting_scr: Money,
pub catastrophe_scr: Money,
pub market_scr: Money,
pub credit_scr: Money,
pub operational_scr: Money,
pub diversification_benefit: Money,
pub total_scr: Money,
pub mcr: Money,
pub solvency_ratio: Rate,
pub meets_scr: bool,
pub meets_mcr: bool,
pub surplus: Money,
}
pub fn calculate_scr(input: &ScrInput) -> CorpFinanceResult<ComputationOutput<ScrOutput>> {
let start = Instant::now();
let mut warnings: Vec<String> = Vec::new();
let pr = &input.premium_risk;
if pr.geographic_diversification <= Decimal::ZERO
|| pr.geographic_diversification > Decimal::ONE
{
return Err(CorpFinanceError::InvalidInput {
field: "geographic_diversification".into(),
reason: "Must be between 0 (exclusive) and 1 (inclusive)".into(),
});
}
if input.mcr_factor < Decimal::ZERO || input.mcr_factor > Decimal::ONE {
return Err(CorpFinanceError::InvalidInput {
field: "mcr_factor".into(),
reason: "MCR factor must be between 0 and 1".into(),
});
}
let pf_nep = pr.premium_risk_factor * pr.net_earned_premium;
let rf_res = pr.reserve_risk_factor * pr.net_best_estimate_reserves;
let correlation = dec!(0.5); let under_sqrt = pf_nep * pf_nep + rf_res * rf_res + dec!(2) * correlation * pf_nep * rf_res;
let premium_reserve_scr = sqrt_decimal(under_sqrt) * pr.geographic_diversification;
let non_life_underwriting_scr = premium_reserve_scr + input.catastrophe_risk;
let catastrophe_scr = input.catastrophe_risk;
let market_scr = input.market_risk;
let credit_scr = input.credit_risk;
let op_base = if pr.net_earned_premium >= pr.net_best_estimate_reserves {
pr.net_earned_premium
} else {
pr.net_best_estimate_reserves
};
let operational_scr = dec!(0.03) * op_base;
let rho_nl_mkt = dec!(0.25);
let rho_nl_cr = dec!(0.50);
let rho_mkt_cr = dec!(0.25);
let nl = non_life_underwriting_scr;
let mkt = market_scr;
let cr = credit_scr;
let sum_corr = nl * nl
+ mkt * mkt
+ cr * cr
+ dec!(2) * rho_nl_mkt * nl * mkt
+ dec!(2) * rho_nl_cr * nl * cr
+ dec!(2) * rho_mkt_cr * mkt * cr;
let basic_scr = sqrt_decimal(sum_corr);
let total_scr = basic_scr + operational_scr;
let sum_modules = nl + mkt + cr;
let diversification_benefit = if sum_modules > basic_scr {
sum_modules - basic_scr
} else {
Decimal::ZERO
};
let mcr_raw = total_scr * input.mcr_factor;
let mcr_floor = total_scr * dec!(0.25);
let mcr = if mcr_raw < mcr_floor {
mcr_floor
} else {
mcr_raw
};
let solvency_ratio = if total_scr.is_zero() {
if input.eligible_own_funds > Decimal::ZERO {
warnings.push("SCR is zero — solvency ratio undefined, reported as 999%".into());
dec!(9.99)
} else {
Decimal::ZERO
}
} else {
input.eligible_own_funds / total_scr
};
let meets_scr = solvency_ratio >= Decimal::ONE;
let meets_mcr = input.eligible_own_funds >= mcr;
let surplus = input.eligible_own_funds - total_scr;
let output = ScrOutput {
non_life_underwriting_scr,
catastrophe_scr,
market_scr,
credit_scr,
operational_scr,
diversification_benefit,
total_scr,
mcr,
solvency_ratio,
meets_scr,
meets_mcr,
surplus,
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"Solvency II SCR Standard Formula",
&serde_json::json!({
"company": input.company_name,
"correlation_premium_reserve": "0.5",
"correlation_matrix": "NL-Mkt 0.25, NL-Cr 0.50, Mkt-Cr 0.25",
"operational_risk_method": "3% of max(premium, reserves)",
}),
warnings,
elapsed,
output,
))
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn basic_pricing_input() -> PremiumPricingInput {
PremiumPricingInput {
line_of_business: "Motor".into(),
exposure_units: dec!(1000),
claim_frequency: dec!(0.05),
average_severity: dec!(10000),
severity_trend: dec!(0.03),
frequency_trend: dec!(-0.01),
projection_years: 3,
expense_ratio_target: dec!(0.30),
profit_margin_target: dec!(0.05),
reinsurance_cost_pct: dec!(0.10),
investment_income_credit: dec!(0.02),
large_loss_load_pct: dec!(0.05),
}
}
#[test]
fn test_pure_premium_frequency_times_severity() {
let input = basic_pricing_input();
let result = price_premium(&input).unwrap();
assert_eq!(result.result.pure_premium, dec!(500000));
}
#[test]
fn test_trended_premium_with_trends() {
let input = basic_pricing_input();
let result = price_premium(&input).unwrap();
let trended = result.result.trended_pure_premium;
assert_ne!(trended, result.result.pure_premium);
assert!(trended > Decimal::ZERO);
}
#[test]
fn test_gross_premium_with_all_loadings() {
let input = basic_pricing_input();
let result = price_premium(&input).unwrap();
let gross = result.result.gross_premium;
let trended = result.result.trended_pure_premium;
let expected_denom = dec!(0.52);
let expected_gross = trended / expected_denom;
assert_eq!(gross, expected_gross);
}
#[test]
fn test_rate_components_sum_to_gross() {
let input = basic_pricing_input();
let result = price_premium(&input).unwrap();
let rc = &result.result.rate_components;
assert_eq!(rc.total, result.result.gross_premium);
let reconstructed = rc.loss_cost
+ rc.expense_load
+ rc.profit_load
+ rc.reinsurance_load
+ rc.large_loss_load
- rc.investment_credit;
let diff = (reconstructed - rc.total).abs();
assert!(
diff < dec!(0.01),
"Components do not sum to total: reconstructed={}, total={}, diff={}",
reconstructed,
rc.total,
diff
);
}
#[test]
fn test_projected_experience_over_5_years() {
let mut input = basic_pricing_input();
input.projection_years = 5;
let result = price_premium(&input).unwrap();
assert_eq!(result.result.projected_experience.len(), 5);
for (i, pe) in result.result.projected_experience.iter().enumerate() {
assert_eq!(pe.year, (i + 1) as u32);
}
for i in 1..result.result.projected_experience.len() {
assert!(
result.result.projected_experience[i].projected_severity
> result.result.projected_experience[i - 1].projected_severity
);
}
}
#[test]
fn test_premium_per_unit() {
let input = basic_pricing_input();
let result = price_premium(&input).unwrap();
let expected = result.result.gross_premium / input.exposure_units;
assert_eq!(result.result.premium_per_unit, expected);
}
#[test]
fn test_investment_income_credit_reduces_premium() {
let mut input_with = basic_pricing_input();
input_with.investment_income_credit = dec!(0.05);
let mut input_without = basic_pricing_input();
input_without.investment_income_credit = Decimal::ZERO;
let result_with = price_premium(&input_with).unwrap();
let result_without = price_premium(&input_without).unwrap();
assert!(
result_with.result.gross_premium < result_without.result.gross_premium,
"Investment income credit should reduce gross premium"
);
}
#[test]
fn test_loading_factors_exceed_100_pct() {
let mut input = basic_pricing_input();
input.expense_ratio_target = dec!(0.90);
let result = price_premium(&input);
assert!(result.is_err());
}
#[test]
fn test_zero_claims_pure_premium_is_zero() {
let mut input = basic_pricing_input();
input.claim_frequency = Decimal::ZERO;
let result = price_premium(&input).unwrap();
assert_eq!(result.result.pure_premium, Decimal::ZERO);
assert_eq!(result.result.trended_pure_premium, Decimal::ZERO);
}
fn profitable_period(year: u32) -> InsurancePeriod {
InsurancePeriod {
year,
net_earned_premium: dec!(1000000),
net_incurred_losses: dec!(600000),
loss_adjustment_expenses: dec!(50000),
underwriting_expenses: dec!(250000),
policyholder_dividends: dec!(10000),
net_investment_income: dec!(80000),
realized_gains: dec!(20000),
}
}
fn unprofitable_period(year: u32) -> InsurancePeriod {
InsurancePeriod {
year,
net_earned_premium: dec!(1000000),
net_incurred_losses: dec!(750000),
loss_adjustment_expenses: dec!(100000),
underwriting_expenses: dec!(200000),
policyholder_dividends: dec!(20000),
net_investment_income: dec!(60000),
realized_gains: dec!(10000),
}
}
#[test]
fn test_combined_ratio_profitable() {
let input = CombinedRatioInput {
company_name: "TestCo".into(),
periods: vec![profitable_period(2023)],
};
let result = analyze_combined_ratio(&input).unwrap();
let pr = &result.result.period_results[0];
assert_eq!(pr.combined_ratio, dec!(0.91));
assert!(pr.combined_ratio < Decimal::ONE);
assert!(pr.underwriting_profit_loss > Decimal::ZERO);
}
#[test]
fn test_combined_ratio_unprofitable() {
let input = CombinedRatioInput {
company_name: "TestCo".into(),
periods: vec![unprofitable_period(2023)],
};
let result = analyze_combined_ratio(&input).unwrap();
let pr = &result.result.period_results[0];
assert_eq!(pr.combined_ratio, dec!(1.07));
assert!(pr.combined_ratio > Decimal::ONE);
assert!(pr.underwriting_profit_loss < Decimal::ZERO);
}
#[test]
fn test_loss_ratio_calculation() {
let input = CombinedRatioInput {
company_name: "TestCo".into(),
periods: vec![profitable_period(2023)],
};
let result = analyze_combined_ratio(&input).unwrap();
assert_eq!(result.result.period_results[0].loss_ratio, dec!(0.60));
}
#[test]
fn test_expense_ratio_calculation() {
let input = CombinedRatioInput {
company_name: "TestCo".into(),
periods: vec![profitable_period(2023)],
};
let result = analyze_combined_ratio(&input).unwrap();
assert_eq!(result.result.period_results[0].expense_ratio, dec!(0.25));
}
#[test]
fn test_operating_ratio_includes_investment_income() {
let input = CombinedRatioInput {
company_name: "TestCo".into(),
periods: vec![profitable_period(2023)],
};
let result = analyze_combined_ratio(&input).unwrap();
let pr = &result.result.period_results[0];
assert_eq!(pr.operating_ratio, dec!(0.83));
assert!(pr.operating_ratio < pr.combined_ratio);
}
#[test]
fn test_net_income_calculation() {
let input = CombinedRatioInput {
company_name: "TestCo".into(),
periods: vec![profitable_period(2023)],
};
let result = analyze_combined_ratio(&input).unwrap();
let pr = &result.result.period_results[0];
assert_eq!(pr.underwriting_profit_loss, dec!(90000));
assert_eq!(pr.net_income, dec!(190000));
}
#[test]
fn test_multi_year_trend_analysis() {
let periods = vec![
unprofitable_period(2020),
unprofitable_period(2021),
profitable_period(2022),
profitable_period(2023),
];
let input = CombinedRatioInput {
company_name: "TestCo".into(),
periods,
};
let result = analyze_combined_ratio(&input).unwrap();
let summary = &result.result.summary;
assert_eq!(summary.trend_direction, "Improving");
assert_eq!(summary.best_year, 2022); assert_eq!(summary.worst_year, 2021); }
#[test]
fn test_summary_best_worst_year() {
let periods = vec![profitable_period(2022), unprofitable_period(2023)];
let input = CombinedRatioInput {
company_name: "TestCo".into(),
periods,
};
let result = analyze_combined_ratio(&input).unwrap();
let summary = &result.result.summary;
assert_eq!(summary.best_year, 2022);
assert_eq!(summary.worst_year, 2023);
assert_eq!(summary.profitable_years, 1); }
#[test]
fn test_combined_ratio_zero_losses() {
let period = InsurancePeriod {
year: 2023,
net_earned_premium: dec!(1000000),
net_incurred_losses: Decimal::ZERO,
loss_adjustment_expenses: Decimal::ZERO,
underwriting_expenses: dec!(200000),
policyholder_dividends: Decimal::ZERO,
net_investment_income: dec!(50000),
realized_gains: Decimal::ZERO,
};
let input = CombinedRatioInput {
company_name: "TestCo".into(),
periods: vec![period],
};
let result = analyze_combined_ratio(&input).unwrap();
assert_eq!(result.result.period_results[0].loss_ratio, Decimal::ZERO);
assert_eq!(result.result.period_results[0].lae_ratio, Decimal::ZERO);
}
#[test]
fn test_combined_ratio_100pct_expenses() {
let period = InsurancePeriod {
year: 2023,
net_earned_premium: dec!(1000000),
net_incurred_losses: Decimal::ZERO,
loss_adjustment_expenses: Decimal::ZERO,
underwriting_expenses: dec!(1000000),
policyholder_dividends: Decimal::ZERO,
net_investment_income: Decimal::ZERO,
realized_gains: Decimal::ZERO,
};
let input = CombinedRatioInput {
company_name: "TestCo".into(),
periods: vec![period],
};
let result = analyze_combined_ratio(&input).unwrap();
let pr = &result.result.period_results[0];
assert_eq!(pr.expense_ratio, Decimal::ONE);
assert_eq!(pr.combined_ratio, Decimal::ONE);
assert_eq!(pr.underwriting_profit_loss, Decimal::ZERO);
}
#[test]
fn test_combined_ratio_empty_periods() {
let input = CombinedRatioInput {
company_name: "TestCo".into(),
periods: vec![],
};
assert!(analyze_combined_ratio(&input).is_err());
}
fn basic_scr_input() -> ScrInput {
ScrInput {
company_name: "TestInsurer".into(),
premium_risk: PremiumReserveRisk {
net_earned_premium: dec!(10000000),
net_best_estimate_reserves: dec!(15000000),
premium_risk_factor: dec!(0.10),
reserve_risk_factor: dec!(0.08),
geographic_diversification: dec!(0.85),
},
catastrophe_risk: dec!(2000000),
market_risk: dec!(3000000),
credit_risk: dec!(1000000),
operational_risk_premium: dec!(12000000),
eligible_own_funds: dec!(15000000),
mcr_factor: dec!(0.35),
}
}
#[test]
fn test_scr_premium_reserve_risk() {
let input = basic_scr_input();
let result = calculate_scr(&input).unwrap();
let nl = result.result.non_life_underwriting_scr;
assert!(nl > dec!(3000000), "NL UW SCR should be > 3M, got {}", nl);
assert!(nl < dec!(4000000), "NL UW SCR should be < 4M, got {}", nl);
}
#[test]
fn test_scr_with_diversification_benefit() {
let input = basic_scr_input();
let result = calculate_scr(&input).unwrap();
assert!(
result.result.diversification_benefit > Decimal::ZERO,
"Diversification benefit should be positive"
);
let simple_sum = result.result.non_life_underwriting_scr
+ result.result.market_scr
+ result.result.credit_scr
+ result.result.operational_scr;
assert!(
result.result.total_scr < simple_sum,
"Total SCR {} should be less than simple sum {}",
result.result.total_scr,
simple_sum
);
}
#[test]
fn test_scr_operational_risk() {
let input = basic_scr_input();
let result = calculate_scr(&input).unwrap();
assert_eq!(result.result.operational_scr, dec!(450000));
}
#[test]
fn test_solvency_ratio_calculation() {
let input = basic_scr_input();
let result = calculate_scr(&input).unwrap();
let expected = input.eligible_own_funds / result.result.total_scr;
assert_eq!(result.result.solvency_ratio, expected);
}
#[test]
fn test_mcr_floor_at_25_pct() {
let mut input = basic_scr_input();
input.mcr_factor = dec!(0.10);
let result = calculate_scr(&input).unwrap();
let floor = result.result.total_scr * dec!(0.25);
assert!(
result.result.mcr >= floor,
"MCR {} should be >= 25% floor {}",
result.result.mcr,
floor
);
assert_eq!(result.result.mcr, floor);
}
#[test]
fn test_meets_scr_but_not_mcr() {
let mut input = basic_scr_input();
input.mcr_factor = dec!(0.45);
input.eligible_own_funds = dec!(5000000); let result = calculate_scr(&input).unwrap();
if result.result.total_scr > dec!(5000000) {
assert!(!result.result.meets_scr);
if result.result.mcr < dec!(5000000) {
assert!(result.result.meets_mcr);
}
}
}
#[test]
fn test_surplus_calculation() {
let input = basic_scr_input();
let result = calculate_scr(&input).unwrap();
let expected_surplus = input.eligible_own_funds - result.result.total_scr;
assert_eq!(result.result.surplus, expected_surplus);
}
#[test]
fn test_scr_invalid_geographic_diversification() {
let mut input = basic_scr_input();
input.premium_risk.geographic_diversification = Decimal::ZERO;
assert!(calculate_scr(&input).is_err());
}
#[test]
fn test_scr_catastrophe_passthrough() {
let input = basic_scr_input();
let result = calculate_scr(&input).unwrap();
assert_eq!(result.result.catastrophe_scr, dec!(2000000));
}
#[test]
fn test_sqrt_decimal_basic() {
let val = dec!(4);
let result = sqrt_decimal(val);
let diff = (result - dec!(2)).abs();
assert!(
diff < dec!(0.0000001),
"sqrt(4) should be ~2, got {}",
result
);
}
#[test]
fn test_sqrt_decimal_zero() {
assert_eq!(sqrt_decimal(Decimal::ZERO), Decimal::ZERO);
}
#[test]
fn test_sqrt_decimal_negative() {
assert_eq!(sqrt_decimal(dec!(-1)), Decimal::ZERO);
}
#[test]
fn test_decimal_pow() {
let result = decimal_pow(dec!(1.03), 3);
let expected = dec!(1.092727);
let diff = (result - expected).abs();
assert!(
diff < dec!(0.000001),
"(1.03)^3 should be ~1.092727, got {}",
result
);
}
}