quant-metrics 0.7.0

Pure performance statistics library for trading — Sharpe, Sortino, drawdown, VaR, portfolio composition
Documentation
//! Trading performance metrics.
//!
//! Metrics computed from individual trade P&L values.

use rust_decimal::Decimal;

use crate::MetricsError;

/// Calculate win rate as a percentage.
///
/// Formula: winning_trades / total_trades * 100
///
/// # Arguments
/// * `trade_pnls` - P&L of each trade (positive = win, negative = loss)
///
/// # Returns
/// Win rate as percentage (0-100).
///
/// # Example
/// ```
/// use quant_metrics::win_rate;
/// use rust_decimal_macros::dec;
///
/// let trades = vec![dec!(100), dec!(-50), dec!(75), dec!(-25), dec!(80)];
/// assert_eq!(win_rate(&trades).unwrap(), dec!(60)); // 3 wins / 5 trades
/// ```
pub fn win_rate(trade_pnls: &[Decimal]) -> Result<Decimal, MetricsError> {
    if trade_pnls.is_empty() {
        return Err(MetricsError::InsufficientData {
            required: 1,
            actual: 0,
        });
    }

    let wins = trade_pnls
        .iter()
        .filter(|&&pnl| pnl > Decimal::ZERO)
        .count();
    let total = trade_pnls.len();

    Ok(Decimal::from(wins as u64) / Decimal::from(total as u64) * Decimal::from(100))
}

/// Calculate profit factor.
///
/// Formula: gross_profit / gross_loss
///
/// A profit factor > 1 indicates profitable trading.
///
/// # Arguments
/// * `trade_pnls` - P&L of each trade
///
/// # Returns
/// Profit factor (gross profit / gross loss).
pub fn profit_factor(trade_pnls: &[Decimal]) -> Result<Decimal, MetricsError> {
    if trade_pnls.is_empty() {
        return Err(MetricsError::InsufficientData {
            required: 1,
            actual: 0,
        });
    }

    let gross_profit: Decimal = trade_pnls.iter().filter(|&&pnl| pnl > Decimal::ZERO).sum();

    let gross_loss: Decimal = trade_pnls
        .iter()
        .filter(|&&pnl| pnl < Decimal::ZERO)
        .map(|pnl| pnl.abs())
        .sum();

    if gross_loss == Decimal::ZERO {
        if gross_profit == Decimal::ZERO {
            return Ok(Decimal::ONE); // No trades = neutral
        }
        // All wins, no losses = infinite profit factor
        return Err(MetricsError::DivisionByZero {
            context: "no losing trades",
        });
    }

    Ok(gross_profit / gross_loss)
}

/// Calculate average winning trade.
///
/// # Arguments
/// * `trade_pnls` - P&L of each trade
///
/// # Returns
/// Average P&L of winning trades.
pub fn avg_win(trade_pnls: &[Decimal]) -> Result<Decimal, MetricsError> {
    let wins: Vec<Decimal> = trade_pnls
        .iter()
        .filter(|&&pnl| pnl > Decimal::ZERO)
        .copied()
        .collect();

    if wins.is_empty() {
        return Err(MetricsError::InsufficientData {
            required: 1,
            actual: 0,
        });
    }

    let sum: Decimal = wins.iter().sum();
    Ok(sum / Decimal::from(wins.len() as u64))
}

/// Calculate average losing trade.
///
/// # Arguments
/// * `trade_pnls` - P&L of each trade
///
/// # Returns
/// Average P&L of losing trades (as negative number).
pub fn avg_loss(trade_pnls: &[Decimal]) -> Result<Decimal, MetricsError> {
    let losses: Vec<Decimal> = trade_pnls
        .iter()
        .filter(|&&pnl| pnl < Decimal::ZERO)
        .copied()
        .collect();

    if losses.is_empty() {
        return Err(MetricsError::InsufficientData {
            required: 1,
            actual: 0,
        });
    }

    let sum: Decimal = losses.iter().sum();
    Ok(sum / Decimal::from(losses.len() as u64))
}

/// Calculate expectancy (expected value per trade).
///
/// Formula: (win_rate * avg_win) + ((1 - win_rate) * avg_loss)
///
/// # Arguments
/// * `trade_pnls` - P&L of each trade
///
/// # Returns
/// Expected value per trade.
pub fn expectancy(trade_pnls: &[Decimal]) -> Result<Decimal, MetricsError> {
    if trade_pnls.is_empty() {
        return Err(MetricsError::InsufficientData {
            required: 1,
            actual: 0,
        });
    }

    let wins: Vec<Decimal> = trade_pnls
        .iter()
        .filter(|&&pnl| pnl > Decimal::ZERO)
        .copied()
        .collect();

    let losses: Vec<Decimal> = trade_pnls
        .iter()
        .filter(|&&pnl| pnl < Decimal::ZERO)
        .copied()
        .collect();

    let total = trade_pnls.len() as u64;
    let win_pct = Decimal::from(wins.len() as u64) / Decimal::from(total);
    let loss_pct = Decimal::ONE - win_pct;

    let avg_w = if wins.is_empty() {
        Decimal::ZERO
    } else {
        wins.iter().sum::<Decimal>() / Decimal::from(wins.len() as u64)
    };

    let avg_l = if losses.is_empty() {
        Decimal::ZERO
    } else {
        losses.iter().sum::<Decimal>() / Decimal::from(losses.len() as u64)
    };

    Ok((win_pct * avg_w) + (loss_pct * avg_l))
}

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