quant-metrics 0.7.0

Pure performance statistics library for trading — Sharpe, Sortino, drawdown, VaR, portfolio composition
Documentation
//! Portfolio analytics: attribution, correlation, diversification, tail risk.
//!
//! Higher-level portfolio analysis functions that operate on return series
//! and produce attribution, correlation, and risk metrics. Pure math, no I/O.

use std::collections::HashMap;

use rust_decimal::Decimal;

use crate::composition::ReturnPoint;
use crate::risk_metrics::{cvar, var};

/// Diversification metrics for a portfolio.
pub struct DiversificationMetrics {
    /// Portfolio MaxDD (negative percentage).
    pub portfolio_max_dd: Decimal,
    /// Average leg MaxDD (negative percentage).
    pub avg_leg_max_dd: Decimal,
    /// Percentage reduction in MaxDD vs average leg MaxDD.
    /// Positive means the portfolio reduced drawdown.
    pub max_dd_reduction_pct: Decimal,
}

/// Tail risk metrics for a portfolio.
pub struct TailRiskMetrics {
    /// VaR at 95% confidence (historical).
    pub var_95: Decimal,
    /// VaR at 99% confidence (historical).
    pub var_99: Decimal,
    /// CVaR (expected shortfall) at 95% confidence.
    pub cvar_95: Decimal,
    /// CVaR (expected shortfall) at 99% confidence.
    pub cvar_99: Decimal,
}

/// Full portfolio analytics result.
pub struct PortfolioAnalytics {
    /// Per-leg P&L contribution (name -> percentage).
    pub attribution: HashMap<String, Decimal>,
    /// Pairwise correlation matrix (NxN).
    pub correlation_matrix: Vec<Vec<f64>>,
    /// Leg labels (in order matching correlation matrix indices).
    pub leg_labels: Vec<String>,
    /// Diversification metrics.
    pub diversification: DiversificationMetrics,
    /// Number of periods where 2+ legs are simultaneously in drawdown.
    pub drawdown_overlap_periods: usize,
    /// Tail risk metrics.
    pub tail_risk: TailRiskMetrics,
}

/// Per-leg P&L attribution.
///
/// Returns a map of leg name -> contribution percentage.
/// Contribution = (w_i * cumulative_return_i) / portfolio_cumulative_return * 100.
pub fn attribution(legs: &[(&str, Decimal, &[ReturnPoint])]) -> HashMap<String, Decimal> {
    let mut result = HashMap::new();

    // Compute cumulative return for each leg: product of (1 + r_t) - 1
    let mut weighted_returns: Vec<(String, Decimal)> = Vec::new();
    for &(name, weight, points) in legs {
        let cumulative = points
            .iter()
            .fold(Decimal::ONE, |acc, rp| acc * (Decimal::ONE + rp.value))
            - Decimal::ONE;
        weighted_returns.push((name.to_string(), weight * cumulative));
    }

    let total_weighted: Decimal = weighted_returns.iter().map(|(_, wr)| wr).sum();

    if total_weighted == Decimal::ZERO {
        // Equal attribution if total return is zero
        let equal = Decimal::from(100) / Decimal::from(legs.len() as u32);
        for &(name, _, _) in legs {
            result.insert(name.to_string(), equal);
        }
    } else {
        for (name, wr) in weighted_returns {
            let contribution = (wr / total_weighted) * Decimal::from(100);
            result.insert(name, contribution);
        }
    }

    result
}

/// Pairwise Pearson correlation matrix on return series.
///
/// Returns an NxN matrix where entry [i][j] is the correlation between
/// series i and series j.
pub fn correlation_matrix(series: &[&[ReturnPoint]]) -> Vec<Vec<f64>> {
    let n = series.len();

    // Convert Decimal returns to f64 for correlation computation
    let f64_series: Vec<Vec<f64>> = series
        .iter()
        .map(|pts| {
            pts.iter()
                .map(|rp| rp.value.try_into().unwrap_or(0.0))
                .collect()
        })
        .collect();

    let mut matrix = vec![vec![0.0; n]; n];

    for i in 0..n {
        matrix[i][i] = 1.0;
        for j in (i + 1)..n {
            let corr = crate::cointegration::pearson_correlation(&f64_series[i], &f64_series[j])
                .unwrap_or(0.0);
            matrix[i][j] = corr;
            matrix[j][i] = corr;
        }
    }

    matrix
}

/// Compute diversification metrics: MaxDD reduction compared to average leg MaxDD.
pub fn diversification_metrics(legs: &[(&str, Decimal, &[ReturnPoint])]) -> DiversificationMetrics {
    // Build per-leg equity curves and compute their MaxDD
    let mut leg_max_dds = Vec::new();
    for &(_, _, points) in legs {
        let equity = returns_to_equity(points);
        let max_dd = crate::max_drawdown(&equity).unwrap_or(Decimal::ZERO);
        leg_max_dds.push(max_dd);
    }

    // Build portfolio equity curve (equal or given weights)
    let portfolio_equity = build_weighted_equity(legs);
    let portfolio_max_dd = crate::max_drawdown(&portfolio_equity).unwrap_or(Decimal::ZERO);

    // Average leg MaxDD (these are negative, so avg is also negative)
    let avg_leg_max_dd: Decimal = if leg_max_dds.is_empty() {
        Decimal::ZERO
    } else {
        leg_max_dds.iter().sum::<Decimal>() / Decimal::from(leg_max_dds.len() as u32)
    };

    let reduction_pct = if avg_leg_max_dd == Decimal::ZERO {
        Decimal::ZERO
    } else {
        // Both values are negative. Reduction = (avg - portfolio) / avg * 100
        // If portfolio_max_dd is less extreme (closer to 0), reduction is positive.
        ((avg_leg_max_dd - portfolio_max_dd) / avg_leg_max_dd) * Decimal::from(100)
    };

    DiversificationMetrics {
        portfolio_max_dd,
        avg_leg_max_dd,
        max_dd_reduction_pct: reduction_pct,
    }
}

/// Count periods where 2+ legs are simultaneously in drawdown.
pub fn drawdown_overlap_count(series: &[&[ReturnPoint]]) -> usize {
    if series.is_empty() {
        return 0;
    }

    let len = series.iter().map(|s| s.len()).min().unwrap_or(0);

    // For each leg, compute whether it's in drawdown at each period
    let drawdown_flags: Vec<Vec<bool>> = series
        .iter()
        .map(|pts| {
            let equity = returns_to_equity(pts);
            let dd = crate::drawdown_series(&equity).unwrap_or_default();
            dd.iter().skip(1).map(|&d| d < Decimal::ZERO).collect()
        })
        .collect();

    let mut count = 0;
    for t in 0..len {
        let legs_in_dd = drawdown_flags
            .iter()
            .filter(|flags| flags.get(t).copied().unwrap_or(false))
            .count();
        if legs_in_dd >= 2 {
            count += 1;
        }
    }

    count
}

/// Compute all portfolio analytics in one call.
pub fn portfolio_analytics(
    legs: &[(&str, Decimal, &[ReturnPoint])],
    portfolio_returns: &[Decimal],
) -> PortfolioAnalytics {
    let attr = attribution(legs);
    let series: Vec<&[ReturnPoint]> = legs.iter().map(|(_, _, pts)| *pts).collect();
    let corr = correlation_matrix(&series);
    let labels: Vec<String> = legs.iter().map(|(n, _, _)| n.to_string()).collect();
    let div = diversification_metrics(legs);
    let overlap = drawdown_overlap_count(&series);

    let tail = TailRiskMetrics {
        var_95: var(portfolio_returns, 95),
        var_99: var(portfolio_returns, 99),
        cvar_95: cvar(portfolio_returns, 95),
        cvar_99: cvar(portfolio_returns, 99),
    };

    PortfolioAnalytics {
        attribution: attr,
        correlation_matrix: corr,
        leg_labels: labels,
        diversification: div,
        drawdown_overlap_periods: overlap,
        tail_risk: tail,
    }
}

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

/// Convert a return series to an equity curve starting at 100.
fn returns_to_equity(points: &[ReturnPoint]) -> Vec<Decimal> {
    let mut equity = Vec::with_capacity(points.len() + 1);
    equity.push(Decimal::from(100));
    let mut current = Decimal::from(100);
    for rp in points {
        current *= Decimal::ONE + rp.value;
        equity.push(current);
    }
    equity
}

/// Build a weighted portfolio equity curve from legs.
fn build_weighted_equity(legs: &[(&str, Decimal, &[ReturnPoint])]) -> Vec<Decimal> {
    if legs.is_empty() {
        return vec![];
    }

    let len = legs.iter().map(|(_, _, pts)| pts.len()).min().unwrap_or(0);
    let capital = Decimal::from(100);

    let mut equity = Vec::with_capacity(len + 1);
    equity.push(capital);

    let mut current = capital;
    for t in 0..len {
        let mut portfolio_return = Decimal::ZERO;
        for &(_, weight, points) in legs {
            if t < points.len() {
                portfolio_return += weight * points[t].value;
            }
        }
        current *= Decimal::ONE + portfolio_return;
        equity.push(current);
    }

    equity
}

#[cfg(test)]
#[path = "analytics_tests.rs"]
mod tests;