use nalgebra::{DMatrix, DVector};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum VarError {
#[error("weights length {weights} does not match covariance dimension {cov_dim}")]
DimensionMismatch {
weights: usize,
cov_dim: usize,
},
#[error("confidence level must be in (0, 1), got {0}")]
InvalidConfidence(f64),
#[error("portfolio volatility is zero — all assets may be identical")]
ZeroVolatility,
}
#[derive(Debug, Clone)]
pub struct VarResult {
pub var: f64,
pub portfolio_volatility: f64,
pub portfolio_return: f64,
pub confidence: f64,
}
#[must_use]
fn normal_quantile(p: f64) -> f64 {
if p < 0.5 {
return -normal_quantile(1.0 - p);
}
let t = (-2.0 * (1.0 - p).ln()).sqrt();
let c0 = 2.515_517;
let c1 = 0.802_853;
let c2 = 0.010_328;
let d1 = 1.432_788;
let d2 = 0.189_269;
let d3 = 0.001_308;
t - (c0 + c1 * t + c2 * t * t) / (1.0 + d1 * t + d2 * t * t + d3 * t * t * t)
}
#[allow(clippy::cast_precision_loss)]
pub fn parametric_var(
weights: &DVector<f64>,
expected_returns: &DVector<f64>,
covariance: &DMatrix<f64>,
confidence: f64,
) -> Result<VarResult, VarError> {
validate_inputs(weights, covariance, confidence)?;
let portfolio_return = weights.dot(expected_returns);
let portfolio_variance = (weights.transpose() * covariance * weights)[(0, 0)];
let portfolio_volatility = portfolio_variance.sqrt();
if portfolio_volatility < f64::EPSILON {
return Err(VarError::ZeroVolatility);
}
let z_alpha = normal_quantile(confidence);
let var = -(portfolio_return - z_alpha * portfolio_volatility);
Ok(VarResult {
var,
portfolio_volatility,
portfolio_return,
confidence,
})
}
#[allow(clippy::cast_precision_loss)]
pub fn cornish_fisher_var(
weights: &DVector<f64>,
expected_returns: &DVector<f64>,
covariance: &DMatrix<f64>,
confidence: f64,
skewness: f64,
excess_kurtosis: f64,
) -> Result<VarResult, VarError> {
validate_inputs(weights, covariance, confidence)?;
let portfolio_return = weights.dot(expected_returns);
let portfolio_variance = (weights.transpose() * covariance * weights)[(0, 0)];
let portfolio_volatility = portfolio_variance.sqrt();
if portfolio_volatility < f64::EPSILON {
return Err(VarError::ZeroVolatility);
}
let z = normal_quantile(1.0 - confidence);
let z_cf = z + (z * z - 1.0) * skewness / 6.0 + (z.powi(3) - 3.0 * z) * excess_kurtosis / 24.0
- (2.0 * z.powi(3) - 5.0 * z) * skewness * skewness / 36.0;
let var = -(portfolio_return + z_cf * portfolio_volatility);
Ok(VarResult {
var,
portfolio_volatility,
portfolio_return,
confidence,
})
}
fn validate_inputs(
weights: &DVector<f64>,
covariance: &DMatrix<f64>,
confidence: f64,
) -> Result<(), VarError> {
let (rows, cols) = covariance.shape();
if weights.len() != rows || rows != cols {
return Err(VarError::DimensionMismatch {
weights: weights.len(),
cov_dim: rows,
});
}
if confidence <= 0.0 || confidence >= 1.0 {
return Err(VarError::InvalidConfidence(confidence));
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::similar_names)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn test_setup() -> (DVector<f64>, DVector<f64>, DMatrix<f64>) {
let weights = DVector::from_vec(vec![0.5, 0.5]);
let expected_returns = DVector::from_vec(vec![0.0, 0.0]);
#[rustfmt::skip]
let cov = DMatrix::from_row_slice(2, 2, &[
0.04, 0.01,
0.01, 0.04,
]);
(weights, expected_returns, cov)
}
#[test]
fn test_normal_quantile_975() {
let z = normal_quantile(0.975);
assert_relative_eq!(z, 1.96, epsilon = 0.01);
}
#[test]
fn test_normal_quantile_50() {
let z = normal_quantile(0.5);
assert_relative_eq!(z, 0.0, epsilon = 0.01);
}
#[test]
fn test_normal_quantile_025() {
let z = normal_quantile(0.025);
assert_relative_eq!(z, -1.96, epsilon = 0.01);
}
#[test]
fn test_parametric_var_positive() {
let (weights, expected_returns, cov) = test_setup();
let result = parametric_var(&weights, &expected_returns, &cov, 0.975).unwrap();
assert!(result.var > 0.0, "VaR should be positive: {}", result.var);
}
#[test]
fn test_var_scales_with_confidence() {
let (weights, expected_returns, cov) = test_setup();
let var_95 = parametric_var(&weights, &expected_returns, &cov, 0.95)
.unwrap()
.var;
let var_975 = parametric_var(&weights, &expected_returns, &cov, 0.975)
.unwrap()
.var;
let var_99 = parametric_var(&weights, &expected_returns, &cov, 0.99)
.unwrap()
.var;
assert!(var_99 > var_975);
assert!(var_975 > var_95);
}
#[test]
fn test_parametric_var_known_value() {
let (weights, expected_returns, cov) = test_setup();
let result = parametric_var(&weights, &expected_returns, &cov, 0.975).unwrap();
assert_relative_eq!(
result.portfolio_volatility,
0.025_f64.sqrt(),
epsilon = 1e-10
);
assert_relative_eq!(result.var, 1.96 * 0.025_f64.sqrt(), epsilon = 0.01);
}
#[test]
fn test_parametric_var_dimension_mismatch() {
let weights = DVector::from_vec(vec![0.5, 0.3, 0.2]);
let expected_returns = DVector::from_vec(vec![0.0, 0.0, 0.0]);
let cov = DMatrix::identity(2, 2);
assert!(parametric_var(&weights, &expected_returns, &cov, 0.975).is_err());
}
#[test]
fn test_parametric_var_invalid_confidence() {
let (weights, expected_returns, cov) = test_setup();
assert!(parametric_var(&weights, &expected_returns, &cov, 0.0).is_err());
assert!(parametric_var(&weights, &expected_returns, &cov, 1.0).is_err());
}
#[test]
fn test_cf_equals_parametric_when_gaussian() {
let (weights, expected_returns, cov) = test_setup();
let param = parametric_var(&weights, &expected_returns, &cov, 0.975).unwrap();
let cf = cornish_fisher_var(&weights, &expected_returns, &cov, 0.975, 0.0, 0.0).unwrap();
assert_relative_eq!(param.var, cf.var, epsilon = 1e-10);
}
#[test]
fn test_cf_negative_skewness_increases_var() {
let (weights, expected_returns, cov) = test_setup();
let gaussian =
cornish_fisher_var(&weights, &expected_returns, &cov, 0.975, 0.0, 0.0).unwrap();
let negskew =
cornish_fisher_var(&weights, &expected_returns, &cov, 0.975, -1.0, 0.0).unwrap();
assert!(
negskew.var > gaussian.var,
"negative skew should increase VaR: gaussian={}, negskew={}",
gaussian.var,
negskew.var
);
}
#[test]
fn test_cf_excess_kurtosis_increases_var() {
let (weights, expected_returns, cov) = test_setup();
let gaussian =
cornish_fisher_var(&weights, &expected_returns, &cov, 0.975, 0.0, 0.0).unwrap();
let leptokurtic =
cornish_fisher_var(&weights, &expected_returns, &cov, 0.975, 0.0, 3.0).unwrap();
assert!(
leptokurtic.var > gaussian.var,
"excess kurtosis should increase VaR: gaussian={}, lepto={}",
gaussian.var,
leptokurtic.var
);
}
}