quant-metrics 0.7.0

Pure performance statistics library for trading — Sharpe, Sortino, drawdown, VaR, portfolio composition
Documentation
//! Risk-adjusted return metrics.

use rust_decimal::Decimal;

use crate::drawdown::max_drawdown;
use crate::returns::annualized_return;
use crate::MetricsError;

/// Calculate Sharpe ratio.
///
/// Measures excess return per unit of risk (volatility).
///
/// Formula: (mean_return - risk_free_rate) / std_dev * sqrt(periods_per_year)
///
/// # Arguments
/// * `equity` - Equity curve
/// * `risk_free_rate` - Annual risk-free rate as decimal (e.g., 0.02 for 2%)
/// * `periods_per_year` - Trading periods per year (252 for daily, 12 for monthly)
///
/// # Returns
/// Annualized Sharpe ratio.
///
/// # Example
/// ```
/// use quant_metrics::sharpe_ratio;
/// use rust_decimal_macros::dec;
///
/// let equity = vec![dec!(100), dec!(101), dec!(103), dec!(102), dec!(105)];
/// let sharpe = sharpe_ratio(&equity, dec!(0.02), 252).unwrap();
/// ```
pub fn sharpe_ratio(
    equity: &[Decimal],
    risk_free_rate: Decimal,
    periods_per_year: u32,
) -> Result<Decimal, MetricsError> {
    if equity.len() < 3 {
        return Err(MetricsError::InsufficientData {
            required: 3,
            actual: equity.len(),
        });
    }

    let returns = period_returns(equity);
    let mean_return = mean(&returns);
    let std_dev = std_deviation(&returns);

    if std_dev == Decimal::ZERO {
        return Err(MetricsError::DivisionByZero {
            context: "zero volatility",
        });
    }

    // Convert annual risk-free to period risk-free
    let period_rf = risk_free_rate / Decimal::from(periods_per_year);

    // Sharpe = (mean - rf) / std * sqrt(periods)
    let excess_return = mean_return - period_rf;
    let sqrt_periods = decimal_sqrt(Decimal::from(periods_per_year));

    Ok((excess_return / std_dev) * sqrt_periods)
}

/// Calculate Sortino ratio.
///
/// Like Sharpe but only penalizes downside volatility.
///
/// Formula: (mean_return - risk_free_rate) / downside_dev * sqrt(periods_per_year)
///
/// # Arguments
/// * `equity` - Equity curve
/// * `risk_free_rate` - Annual risk-free rate as decimal
/// * `periods_per_year` - Trading periods per year
pub fn sortino_ratio(
    equity: &[Decimal],
    risk_free_rate: Decimal,
    periods_per_year: u32,
) -> Result<Decimal, MetricsError> {
    if equity.len() < 3 {
        return Err(MetricsError::InsufficientData {
            required: 3,
            actual: equity.len(),
        });
    }

    let returns = period_returns(equity);
    let mean_return = mean(&returns);
    let downside_dev = downside_deviation(&returns, Decimal::ZERO);

    if downside_dev == Decimal::ZERO {
        // No downside = infinite Sortino, but we'll return a large number
        return Err(MetricsError::DivisionByZero {
            context: "zero downside deviation",
        });
    }

    let period_rf = risk_free_rate / Decimal::from(periods_per_year);
    let excess_return = mean_return - period_rf;
    let sqrt_periods = decimal_sqrt(Decimal::from(periods_per_year));

    Ok((excess_return / downside_dev) * sqrt_periods)
}

/// Calculate Calmar ratio.
///
/// Measures return relative to maximum drawdown.
///
/// Formula: annualized_return / abs(max_drawdown)
///
/// # Arguments
/// * `equity` - Equity curve
/// * `periods_per_year` - Trading periods per year
pub fn calmar_ratio(equity: &[Decimal], periods_per_year: u32) -> Result<Decimal, MetricsError> {
    let ann_return = annualized_return(equity, periods_per_year)?;
    let max_dd = max_drawdown(equity)?;

    if max_dd == Decimal::ZERO {
        // No drawdown = infinite Calmar
        return Err(MetricsError::DivisionByZero {
            context: "zero max drawdown",
        });
    }

    // max_dd is negative, so we use abs
    Ok(ann_return / max_dd.abs())
}

/// Calculate period-over-period returns.
fn period_returns(equity: &[Decimal]) -> Vec<Decimal> {
    equity
        .windows(2)
        .filter_map(|w| {
            if w[0] == Decimal::ZERO {
                None
            } else {
                Some((w[1] - w[0]) / w[0])
            }
        })
        .collect()
}

use crate::math::{decimal_sqrt, mean, std_deviation};

/// Calculate downside deviation (only negative returns).
fn downside_deviation(returns: &[Decimal], threshold: Decimal) -> Decimal {
    let downside: Vec<Decimal> = returns
        .iter()
        .filter(|&&r| r < threshold)
        .map(|&r| (r - threshold) * (r - threshold))
        .collect();

    if downside.is_empty() {
        return Decimal::ZERO;
    }

    let sum: Decimal = downside.iter().sum();
    let variance = sum / Decimal::from(returns.len() as u64); // Use total n, not just downside

    decimal_sqrt(variance)
}

/// Calculate Information Ratio.
///
/// Measures portfolio return vs benchmark relative to tracking error.
///
/// Formula: (portfolio_return - benchmark_return) / tracking_error
///
/// # Arguments
/// * `portfolio` - Portfolio equity curve
/// * `benchmark` - Benchmark equity curve (must be same length)
/// * `periods_per_year` - Trading periods per year
pub fn information_ratio(
    portfolio: &[Decimal],
    benchmark: &[Decimal],
    periods_per_year: u32,
) -> Result<Decimal, MetricsError> {
    if portfolio.len() != benchmark.len() {
        return Err(MetricsError::InvalidParameter(
            "portfolio and benchmark must have same length".into(),
        ));
    }

    if portfolio.len() < 3 {
        return Err(MetricsError::InsufficientData {
            required: 3,
            actual: portfolio.len(),
        });
    }

    let portfolio_returns = period_returns(portfolio);
    let benchmark_returns = period_returns(benchmark);

    // Active returns = portfolio - benchmark
    let active_returns: Vec<Decimal> = portfolio_returns
        .iter()
        .zip(benchmark_returns.iter())
        .map(|(&p, &b)| p - b)
        .collect();

    let mean_active = mean(&active_returns);
    let tracking_error = std_deviation(&active_returns);

    if tracking_error == Decimal::ZERO {
        return Err(MetricsError::DivisionByZero {
            context: "zero tracking error",
        });
    }

    // Annualize
    let sqrt_periods = decimal_sqrt(Decimal::from(periods_per_year));
    Ok((mean_active / tracking_error) * sqrt_periods)
}

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