quant-metrics 0.7.0

Pure performance statistics library for trading — Sharpe, Sortino, drawdown, VaR, portfolio composition
Documentation
//! Drawdown calculations.

use rust_decimal::Decimal;

use crate::MetricsError;

/// Calculate maximum drawdown as a percentage.
///
/// Drawdown is the peak-to-trough decline during a specific period.
/// Max drawdown is the largest such decline.
///
/// Formula: min((trough - peak) / peak * 100) for all peaks
///
/// # Arguments
/// * `equity` - Equity curve (NAV or portfolio value over time)
///
/// # Returns
/// Maximum drawdown as a negative percentage (e.g., -15.5 for 15.5% drawdown).
/// Returns 0 if no drawdown occurred.
///
/// # Example
/// ```
/// use quant_metrics::max_drawdown;
/// use rust_decimal_macros::dec;
///
/// let equity = vec![dec!(100), dec!(110), dec!(90), dec!(105)];
/// // Peak was 110, trough was 90 -> -18.18%
/// let dd = max_drawdown(&equity).unwrap();
/// assert!(dd < dec!(0));
/// ```
pub fn max_drawdown(equity: &[Decimal]) -> Result<Decimal, MetricsError> {
    if equity.len() < 2 {
        return Err(MetricsError::InsufficientData {
            required: 2,
            actual: equity.len(),
        });
    }

    let dd_series = drawdown_series(equity)?;
    Ok(dd_series.into_iter().min().unwrap_or(Decimal::ZERO))
}

/// Calculate drawdown at each point in the equity curve.
///
/// Returns a series of drawdown values (negative percentages from peak).
///
/// # Arguments
/// * `equity` - Equity curve
///
/// # Returns
/// Vector of drawdown percentages at each point.
pub fn drawdown_series(equity: &[Decimal]) -> Result<Vec<Decimal>, MetricsError> {
    if equity.is_empty() {
        return Err(MetricsError::InsufficientData {
            required: 1,
            actual: 0,
        });
    }

    let mut peak = equity[0];
    let mut drawdowns = Vec::with_capacity(equity.len());

    for &value in equity {
        if value > peak {
            peak = value;
        }

        if peak == Decimal::ZERO {
            drawdowns.push(Decimal::ZERO);
        } else {
            let dd = ((value - peak) / peak) * Decimal::from(100);
            drawdowns.push(dd);
        }
    }

    Ok(drawdowns)
}

/// Calculate maximum drawdown duration in periods.
///
/// Duration is the number of periods from peak to recovery (or end if not recovered).
///
/// # Arguments
/// * `equity` - Equity curve
///
/// # Returns
/// Maximum number of periods spent in drawdown.
pub fn max_drawdown_duration(equity: &[Decimal]) -> Result<usize, MetricsError> {
    if equity.len() < 2 {
        return Err(MetricsError::InsufficientData {
            required: 2,
            actual: equity.len(),
        });
    }

    let mut peak = equity[0];
    let mut peak_idx = 0;
    let mut max_duration = 0;
    let mut current_duration = 0;

    for (i, &value) in equity.iter().enumerate() {
        if value >= peak {
            // New peak or recovery
            if current_duration > max_duration {
                max_duration = current_duration;
            }
            peak = value;
            peak_idx = i;
            current_duration = 0;
        } else {
            // In drawdown
            current_duration = i - peak_idx;
        }
    }

    // Check if still in drawdown at end
    if current_duration > max_duration {
        max_duration = current_duration;
    }

    Ok(max_duration)
}

/// Calculate time to recover from maximum drawdown in periods.
///
/// Returns the number of periods from the max drawdown trough to full recovery,
/// or None if recovery hasn't occurred yet.
///
/// # Arguments
/// * `equity` - Equity curve
///
/// # Returns
/// Number of periods to recover, or None if still in drawdown.
pub fn recovery_time(equity: &[Decimal]) -> Result<Option<usize>, MetricsError> {
    if equity.len() < 2 {
        return Err(MetricsError::InsufficientData {
            required: 2,
            actual: equity.len(),
        });
    }

    let Some((trough_idx, trough_peak)) = find_max_drawdown_trough(equity) else {
        // No drawdown occurred
        return Ok(Some(0));
    };

    // Find recovery point (first time equity >= peak after trough)
    for (i, &value) in equity.iter().enumerate().skip(trough_idx + 1) {
        if value >= trough_peak {
            return Ok(Some(i - trough_idx));
        }
    }

    // Not recovered yet
    Ok(None)
}

/// Locate the index and peak value of the maximum drawdown trough.
///
/// Returns `None` if no drawdown occurred (equity never falls below a prior peak).
fn find_max_drawdown_trough(equity: &[Decimal]) -> Option<(usize, Decimal)> {
    let mut peak = equity[0];
    let mut max_dd = Decimal::ZERO;
    let mut max_dd_trough_idx = 0;
    let mut max_dd_peak = equity[0];

    for (i, &value) in equity.iter().enumerate() {
        if value > peak {
            peak = value;
        }
        if peak != Decimal::ZERO {
            let dd = (value - peak) / peak;
            if dd < max_dd {
                max_dd = dd;
                max_dd_trough_idx = i;
                max_dd_peak = peak;
            }
        }
    }

    if max_dd == Decimal::ZERO {
        None
    } else {
        Some((max_dd_trough_idx, max_dd_peak))
    }
}

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