finance-portfolio 0.1.0

Standard financial portfolio tools
Documentation
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Example {
    pub stuff: String,
}

impl Example {
    pub fn new(value: String) -> Self {
        Example { stuff: value }
    }
}

pub type Matrix = Vec<Vec<f64>>;
pub type RiskContributionParts = (Vec<f64>, Vec<f64>, Vec<f64>);

fn validate_matrix(matrix: &Matrix) -> Result<(usize, usize), String> {
    if matrix.is_empty() {
        return Err("matrix must not be empty".to_string());
    }
    let cols = matrix[0].len();
    if cols == 0 {
        return Err("matrix rows must not be empty".to_string());
    }
    if matrix.iter().any(|row| row.len() != cols) {
        return Err("matrix rows must have equal length".to_string());
    }
    Ok((matrix.len(), cols))
}

fn validate_square(matrix: &Matrix) -> Result<usize, String> {
    let (rows, cols) = validate_matrix(matrix)?;
    if rows != cols {
        return Err("matrix must be square".to_string());
    }
    Ok(rows)
}

fn normalize(mut weights: Vec<f64>, gross: bool) -> Result<Vec<f64>, String> {
    let denominator = if gross {
        weights.iter().map(|value| value.abs()).sum::<f64>()
    } else {
        weights.iter().sum::<f64>()
    };
    if denominator == 0.0 {
        return Err("weights cannot be normalized when the denominator is zero".to_string());
    }
    for weight in &mut weights {
        *weight /= denominator;
    }
    Ok(weights)
}

pub fn equal_weights(count: usize) -> Result<Vec<f64>, String> {
    if count == 0 {
        return Err("assets must not be empty".to_string());
    }
    Ok(vec![1.0 / count as f64; count])
}

pub fn rank_weights(values: Vec<f64>, ascending: bool) -> Result<Vec<f64>, String> {
    if values.is_empty() {
        return Err("signals must not be empty".to_string());
    }
    let mut ranked: Vec<(usize, f64)> = values.into_iter().enumerate().collect();
    ranked.sort_by(|left, right| {
        let ordering = left
            .1
            .partial_cmp(&right.1)
            .unwrap_or(std::cmp::Ordering::Equal);
        if ascending {
            ordering
        } else {
            ordering.reverse()
        }
    });
    let mut raw = vec![0.0; ranked.len()];
    for (rank, (idx, _value)) in ranked.into_iter().enumerate() {
        raw[idx] = rank as f64 + 1.0;
    }
    normalize(raw, false)
}

pub fn signal_proportional_weights(values: Vec<f64>) -> Result<Vec<f64>, String> {
    if values.is_empty() {
        return Err("signals must not be empty".to_string());
    }
    normalize(values, true)
}

pub fn target_volatility_weights(volatility: Vec<f64>) -> Result<Vec<f64>, String> {
    if volatility.is_empty() {
        return Err("volatility must not be empty".to_string());
    }
    if volatility.iter().any(|value| *value <= 0.0) {
        return Err("all volatility values must be positive".to_string());
    }
    normalize(
        volatility.into_iter().map(|value| 1.0 / value).collect(),
        false,
    )
}

fn mat_vec(matrix: &Matrix, vector: &[f64]) -> Vec<f64> {
    matrix
        .iter()
        .map(|row| {
            row.iter()
                .zip(vector)
                .map(|(left, right)| left * right)
                .sum()
        })
        .collect()
}

fn dot(left: &[f64], right: &[f64]) -> f64 {
    left.iter().zip(right).map(|(l, r)| l * r).sum()
}

fn invert_matrix(matrix: &Matrix) -> Option<Matrix> {
    let n = matrix.len();
    let mut augmented = vec![vec![0.0; n * 2]; n];
    for row in 0..n {
        for col in 0..n {
            augmented[row][col] = matrix[row][col];
        }
        augmented[row][n + row] = 1.0;
    }
    for col in 0..n {
        let mut pivot = col;
        for row in (col + 1)..n {
            if augmented[row][col].abs() > augmented[pivot][col].abs() {
                pivot = row;
            }
        }
        if augmented[pivot][col].abs() < 1e-14 {
            return None;
        }
        augmented.swap(col, pivot);
        let divisor = augmented[col][col];
        for value in &mut augmented[col] {
            *value /= divisor;
        }
        for row in 0..n {
            if row == col {
                continue;
            }
            let factor = augmented[row][col];
            let pivot_row = augmented[col].clone();
            for (target, source) in augmented[row].iter_mut().zip(pivot_row) {
                *target -= factor * source;
            }
        }
    }
    Some(augmented.into_iter().map(|row| row[n..].to_vec()).collect())
}

fn invert_with_ridge(covariance: &Matrix) -> Result<Matrix, String> {
    if let Some(inverse) = invert_matrix(covariance) {
        return Ok(inverse);
    }
    let mut regularized = covariance.clone();
    for (idx, row) in regularized.iter_mut().enumerate() {
        row[idx] += 1e-12;
    }
    invert_matrix(&regularized).ok_or_else(|| "covariance matrix is singular".to_string())
}

pub fn minimum_variance_weights(covariance: Matrix) -> Result<Vec<f64>, String> {
    let size = validate_square(&covariance)?;
    let inverse = invert_with_ridge(&covariance)?;
    let ones = vec![1.0; size];
    let inv_ones = mat_vec(&inverse, &ones);
    normalize(inv_ones, false)
}

pub fn mean_variance_weights(
    expected_returns: Vec<f64>,
    covariance: Matrix,
    risk_aversion: f64,
) -> Result<Vec<f64>, String> {
    let size = validate_square(&covariance)?;
    if expected_returns.len() != size {
        return Err("expected_returns length must match covariance dimensions".to_string());
    }
    let inverse = invert_with_ridge(&covariance)?;
    let scale = risk_aversion.max(1e-12);
    let raw: Vec<f64> = mat_vec(&inverse, &expected_returns)
        .into_iter()
        .map(|value| value / scale)
        .collect();
    if raw.iter().sum::<f64>() == 0.0 {
        return equal_weights(size);
    }
    normalize(raw, false)
}

pub fn risk_parity_weights(
    covariance: Matrix,
    max_iter: usize,
    tolerance: f64,
) -> Result<Vec<f64>, String> {
    let size = validate_square(&covariance)?;
    let mut weights: Vec<f64> = (0..size)
        .map(|idx| 1.0 / covariance[idx][idx].max(1e-18).sqrt())
        .collect();
    weights = normalize(weights, false)?;
    let target = 1.0 / size as f64;
    for _ in 0..max_iter {
        let cov_weights = mat_vec(&covariance, &weights);
        let variance = dot(&weights, &cov_weights);
        let vol = variance.max(0.0).sqrt();
        if vol == 0.0 {
            break;
        }
        let mut max_error: f64 = 0.0;
        for idx in 0..size {
            let pct = weights[idx] * cov_weights[idx] / variance;
            max_error = max_error.max((pct - target).abs());
            if pct > 0.0 {
                weights[idx] *= (target / pct).sqrt();
            }
            weights[idx] = weights[idx].max(1e-12);
        }
        weights = normalize(weights, false)?;
        if max_error < tolerance {
            break;
        }
    }
    Ok(weights)
}

pub fn hierarchical_risk_parity_weights(covariance: Matrix) -> Result<Vec<f64>, String> {
    let size = validate_square(&covariance)?;
    let raw: Vec<f64> = (0..size)
        .map(|idx| 1.0 / covariance[idx][idx].max(1e-18))
        .collect();
    normalize(raw, false)
}

pub fn portfolio_variance(weights: Vec<f64>, covariance: Matrix) -> Result<f64, String> {
    let size = validate_square(&covariance)?;
    if weights.len() != size {
        return Err("covariance dimensions must match weights".to_string());
    }
    Ok(dot(&weights, &mat_vec(&covariance, &weights)))
}

pub fn portfolio_volatility(weights: Vec<f64>, covariance: Matrix) -> Result<f64, String> {
    Ok(portfolio_variance(weights, covariance)?.max(0.0).sqrt())
}

pub fn risk_contribution(
    weights: Vec<f64>,
    covariance: Matrix,
) -> Result<RiskContributionParts, String> {
    let size = validate_square(&covariance)?;
    if weights.len() != size {
        return Err("covariance dimensions must match weights".to_string());
    }
    let cov_weights = mat_vec(&covariance, &weights);
    let vol = dot(&weights, &cov_weights).max(0.0).sqrt();
    let marginal: Vec<f64> = if vol == 0.0 {
        vec![0.0; size]
    } else {
        cov_weights.into_iter().map(|value| value / vol).collect()
    };
    let component: Vec<f64> = weights
        .iter()
        .zip(&marginal)
        .map(|(weight, marg)| weight * marg)
        .collect();
    let percentage: Vec<f64> = if vol == 0.0 {
        vec![0.0; size]
    } else {
        component.iter().map(|value| value / vol).collect()
    };
    Ok((marginal, component, percentage))
}

pub fn tracking_error(active_weights: Vec<f64>, covariance: Matrix) -> Result<f64, String> {
    portfolio_volatility(active_weights, covariance)
}

pub fn active_share(active_weights: Vec<f64>) -> f64 {
    0.5 * active_weights.iter().map(|value| value.abs()).sum::<f64>()
}

pub fn brinson_attribution(
    portfolio_weights: Vec<f64>,
    benchmark_weights: Vec<f64>,
    portfolio_returns: Vec<f64>,
    benchmark_returns: Vec<f64>,
) -> Result<(f64, f64, f64, f64), String> {
    let size = portfolio_weights.len();
    if benchmark_weights.len() != size
        || portfolio_returns.len() != size
        || benchmark_returns.len() != size
    {
        return Err("all input vectors must have the same length".to_string());
    }
    let benchmark_total = benchmark_weights
        .iter()
        .zip(&benchmark_returns)
        .map(|(weight, ret)| weight * ret)
        .sum::<f64>();
    let mut allocation = 0.0;
    let mut selection = 0.0;
    let mut interaction = 0.0;
    for idx in 0..size {
        allocation += (portfolio_weights[idx] - benchmark_weights[idx])
            * (benchmark_returns[idx] - benchmark_total);
        selection += benchmark_weights[idx] * (portfolio_returns[idx] - benchmark_returns[idx]);
        interaction += (portfolio_weights[idx] - benchmark_weights[idx])
            * (portfolio_returns[idx] - benchmark_returns[idx]);
    }
    Ok((
        allocation,
        selection,
        interaction,
        allocation + selection + interaction,
    ))
}

pub fn factor_return_decomposition(
    exposures: Vec<f64>,
    factor_returns: Vec<f64>,
) -> Result<(Vec<f64>, f64), String> {
    if exposures.len() != factor_returns.len() {
        return Err("exposures and factor_returns must have the same length".to_string());
    }
    let components: Vec<f64> = exposures
        .iter()
        .zip(&factor_returns)
        .map(|(exposure, ret)| exposure * ret)
        .collect();
    let total = components.iter().sum();
    Ok((components, total))
}

/**********************************/
#[cfg(test)]
mod example_tests {
    use super::*;

    #[test]
    fn test_new() {
        let e = Example::new(String::from("test"));
        assert_eq!(e.stuff, String::from("test"));
    }

    #[test]
    fn test_clone_and_eq() {
        let e = Example::new(String::from("test"));
        assert_eq!(e, e.clone());
    }

    #[test]
    fn test_debug() {
        let e = Example::new(String::from("test"));
        assert_eq!(format!("{e:?}"), "Example { stuff: \"test\" }");
    }
}