quant-metrics 0.7.0

Pure performance statistics library for trading — Sharpe, Sortino, drawdown, VaR, portfolio composition
Documentation
use chrono::{DateTime, TimeZone, Utc};
use rust_decimal::Decimal;
use rust_decimal_macros::dec;

use super::*;
use crate::composition::ReturnPoint;

fn daily_ts(day: u32) -> DateTime<Utc> {
    let base = Utc
        .with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
        .earliest()
        .expect("hardcoded date 2025-01-01T00:00:00Z is always valid");
    base + chrono::Duration::days((day - 1) as i64)
}

// ---------------------------------------------------------------------------
// Attribution tests
// ---------------------------------------------------------------------------

#[test]
fn attribution_two_legs_positive_returns() {
    // Leg A: weight 0.6, cumulative return = (1.02)*(1.03) - 1 = 0.0506
    // Leg B: weight 0.4, cumulative return = (1.01)*(1.01) - 1 = 0.0201
    // Weighted A: 0.6 * 0.0506 = 0.03036
    // Weighted B: 0.4 * 0.0201 = 0.00804
    // Total: 0.0384
    // Contribution A: 0.03036 / 0.0384 * 100 = ~79.06%
    // Contribution B: 0.00804 / 0.0384 * 100 = ~20.94%
    let ts: Vec<DateTime<Utc>> = (2..=3).map(daily_ts).collect();
    let leg_a_pts: Vec<ReturnPoint> = ts
        .iter()
        .zip([dec!(0.02), dec!(0.03)])
        .map(|(&t, v)| ReturnPoint {
            timestamp: t,
            value: v,
        })
        .collect();
    let leg_b_pts: Vec<ReturnPoint> = ts
        .iter()
        .zip([dec!(0.01), dec!(0.01)])
        .map(|(&t, v)| ReturnPoint {
            timestamp: t,
            value: v,
        })
        .collect();

    let legs: Vec<(&str, Decimal, &[ReturnPoint])> =
        vec![("A", dec!(0.6), &leg_a_pts), ("B", dec!(0.4), &leg_b_pts)];

    let attr = attribution(&legs);
    let a_contrib = attr["A"];
    let b_contrib = attr["B"];

    // Verify contributions sum to ~100%
    let sum = a_contrib + b_contrib;
    assert!((sum - dec!(100)).abs() < dec!(0.1), "sum = {sum}");

    // A should contribute ~79%
    assert!(
        a_contrib > dec!(75) && a_contrib < dec!(85),
        "A = {a_contrib}"
    );
    // B should contribute ~21%
    assert!(
        b_contrib > dec!(15) && b_contrib < dec!(25),
        "B = {b_contrib}"
    );
}

#[test]
fn attribution_negative_contribution_from_losing_leg() {
    // Winner gains enough to make overall portfolio positive,
    // so the loser's negative return shows as negative contribution.
    // Winner: (1.10)*(1.10) - 1 = 0.21, weighted: 0.5 * 0.21 = 0.105
    // Loser:  (0.95)*(0.95) - 1 = -0.0975, weighted: 0.5 * -0.0975 = -0.04875
    // Total: 0.105 + (-0.04875) = 0.05625 (positive)
    // Loser contribution: -0.04875 / 0.05625 * 100 = -86.7% (negative)
    let ts: Vec<DateTime<Utc>> = (2..=3).map(daily_ts).collect();
    let winner_pts: Vec<ReturnPoint> = ts
        .iter()
        .zip([dec!(0.10), dec!(0.10)])
        .map(|(&t, v)| ReturnPoint {
            timestamp: t,
            value: v,
        })
        .collect();
    let loser_pts: Vec<ReturnPoint> = ts
        .iter()
        .zip([dec!(-0.05), dec!(-0.05)])
        .map(|(&t, v)| ReturnPoint {
            timestamp: t,
            value: v,
        })
        .collect();

    let legs: Vec<(&str, Decimal, &[ReturnPoint])> = vec![
        ("Winner", dec!(0.5), &winner_pts),
        ("Loser", dec!(0.5), &loser_pts),
    ];

    let attr = attribution(&legs);
    assert!(
        attr["Loser"] < Decimal::ZERO,
        "Loser contribution should be negative: {}",
        attr["Loser"]
    );
    // Contributions should still sum to ~100%
    let sum: Decimal = attr.values().sum();
    assert!((sum - dec!(100)).abs() < dec!(1), "sum = {sum}");
}

// ---------------------------------------------------------------------------
// Correlation matrix tests
// ---------------------------------------------------------------------------

#[test]
fn correlation_identical_series_is_one() {
    let ts: Vec<DateTime<Utc>> = (2..=11).map(daily_ts).collect();
    let pts: Vec<ReturnPoint> = ts
        .iter()
        .enumerate()
        .map(|(i, &t)| ReturnPoint {
            timestamp: t,
            value: if i % 2 == 0 { dec!(0.01) } else { dec!(-0.01) },
        })
        .collect();

    let matrix = correlation_matrix(&[&pts, &pts]);
    assert!(
        (matrix[0][1] - 1.0).abs() < 0.001,
        "expected 1.0, got {}",
        matrix[0][1]
    );
}

#[test]
fn correlation_negated_series_is_negative_one() {
    let ts: Vec<DateTime<Utc>> = (2..=11).map(daily_ts).collect();
    let pts_a: Vec<ReturnPoint> = ts
        .iter()
        .enumerate()
        .map(|(i, &t)| ReturnPoint {
            timestamp: t,
            value: if i % 2 == 0 { dec!(0.01) } else { dec!(-0.01) },
        })
        .collect();
    let pts_b: Vec<ReturnPoint> = pts_a
        .iter()
        .map(|rp| ReturnPoint {
            timestamp: rp.timestamp,
            value: -rp.value,
        })
        .collect();

    let matrix = correlation_matrix(&[&pts_a, &pts_b]);
    assert!(
        (matrix[0][1] - (-1.0)).abs() < 0.001,
        "expected -1.0, got {}",
        matrix[0][1]
    );
}

#[test]
fn correlation_matrix_is_symmetric() {
    let ts: Vec<DateTime<Utc>> = (2..=21).map(daily_ts).collect();
    let pts_a: Vec<ReturnPoint> = ts
        .iter()
        .enumerate()
        .map(|(i, &t)| ReturnPoint {
            timestamp: t,
            value: Decimal::from((i % 5) as i32) * dec!(0.003),
        })
        .collect();
    let pts_b: Vec<ReturnPoint> = ts
        .iter()
        .enumerate()
        .map(|(i, &t)| ReturnPoint {
            timestamp: t,
            value: Decimal::from((i % 3) as i32) * dec!(0.002),
        })
        .collect();
    let pts_c: Vec<ReturnPoint> = ts
        .iter()
        .enumerate()
        .map(|(i, &t)| ReturnPoint {
            timestamp: t,
            value: Decimal::from((i % 7) as i32) * dec!(0.001),
        })
        .collect();

    let matrix = correlation_matrix(&[&pts_a, &pts_b, &pts_c]);

    for (i, row) in matrix.iter().enumerate() {
        assert!((row[i] - 1.0).abs() < 0.001, "diagonal [{i}] != 1.0");
        for (j, &val) in row.iter().enumerate() {
            assert!(
                (val - matrix[j][i]).abs() < 0.001,
                "asymmetric: [{i}][{j}]={} != [{j}][{i}]={}",
                val,
                matrix[j][i]
            );
        }
    }
}

// ---------------------------------------------------------------------------
// Diversification metrics tests
// ---------------------------------------------------------------------------

#[test]
fn diversification_identical_legs_zero_reduction() {
    let ts: Vec<DateTime<Utc>> = (2..=51).map(daily_ts).collect();
    let pts: Vec<ReturnPoint> = ts
        .iter()
        .enumerate()
        .map(|(i, &t)| ReturnPoint {
            timestamp: t,
            value: if i % 2 == 0 { dec!(0.015) } else { dec!(-0.01) },
        })
        .collect();

    let legs: Vec<(&str, Decimal, &[ReturnPoint])> =
        vec![("A", dec!(0.5), &pts), ("B", dec!(0.5), &pts)];

    let metrics = diversification_metrics(&legs);
    assert!(
        metrics.max_dd_reduction_pct.abs() < dec!(5),
        "identical legs should have ~0% reduction, got {}%",
        metrics.max_dd_reduction_pct
    );
}

#[test]
fn diversification_anticorrelated_legs_significant_reduction() {
    let ts: Vec<DateTime<Utc>> = (2..=51).map(daily_ts).collect();
    let pts_a: Vec<ReturnPoint> = ts
        .iter()
        .enumerate()
        .map(|(i, &t)| ReturnPoint {
            timestamp: t,
            value: if i % 2 == 0 { dec!(0.015) } else { dec!(-0.01) },
        })
        .collect();
    let pts_b: Vec<ReturnPoint> = pts_a
        .iter()
        .map(|rp| ReturnPoint {
            timestamp: rp.timestamp,
            value: -rp.value,
        })
        .collect();

    let legs: Vec<(&str, Decimal, &[ReturnPoint])> =
        vec![("A", dec!(0.5), &pts_a), ("B", dec!(0.5), &pts_b)];

    let metrics = diversification_metrics(&legs);
    assert!(
        metrics.max_dd_reduction_pct > dec!(50),
        "anticorrelated legs should have >50% reduction, got {}%",
        metrics.max_dd_reduction_pct
    );
}

// ---------------------------------------------------------------------------
// Drawdown overlap tests
// ---------------------------------------------------------------------------

#[test]
fn drawdown_overlap_no_overlap_when_alternating() {
    let ts: Vec<DateTime<Utc>> = (2..=11).map(daily_ts).collect();
    // Leg A draws down on even periods, recovers strongly on odd
    let pts_a: Vec<ReturnPoint> = ts
        .iter()
        .enumerate()
        .map(|(i, &t)| ReturnPoint {
            timestamp: t,
            value: if i % 2 == 0 { dec!(-0.02) } else { dec!(0.10) },
        })
        .collect();
    // Leg B draws down on odd periods, recovers strongly on even
    let pts_b: Vec<ReturnPoint> = ts
        .iter()
        .enumerate()
        .map(|(i, &t)| ReturnPoint {
            timestamp: t,
            value: if i % 2 == 1 { dec!(-0.02) } else { dec!(0.10) },
        })
        .collect();

    let overlap = drawdown_overlap_count(&[&pts_a, &pts_b]);
    assert_eq!(overlap, 0, "alternating drawdowns should not overlap");
}

// ---------------------------------------------------------------------------
// portfolio_analytics() integration
// ---------------------------------------------------------------------------

#[test]
fn portfolio_analytics_returns_all_fields() {
    let ts: Vec<DateTime<Utc>> = (2..=51).map(daily_ts).collect();
    let pts_a: Vec<ReturnPoint> = ts
        .iter()
        .enumerate()
        .map(|(i, &t)| ReturnPoint {
            timestamp: t,
            value: Decimal::from((i % 3) as i32) * dec!(0.005) - dec!(0.003),
        })
        .collect();
    let pts_b: Vec<ReturnPoint> = ts
        .iter()
        .enumerate()
        .map(|(i, &t)| ReturnPoint {
            timestamp: t,
            value: Decimal::from((i % 5) as i32) * dec!(0.004) - dec!(0.005),
        })
        .collect();

    let legs: Vec<(&str, Decimal, &[ReturnPoint])> =
        vec![("A", dec!(0.5), &pts_a), ("B", dec!(0.5), &pts_b)];

    // Build portfolio returns for tail risk
    let portfolio_returns: Vec<Decimal> = (0..50)
        .map(|t| dec!(0.5) * pts_a[t].value + dec!(0.5) * pts_b[t].value)
        .collect();

    let analytics = portfolio_analytics(&legs, &portfolio_returns);

    // Attribution has both legs
    assert!(analytics.attribution.contains_key("A"));
    assert!(analytics.attribution.contains_key("B"));

    // Correlation matrix is 2x2
    assert_eq!(analytics.correlation_matrix.len(), 2);
    assert_eq!(analytics.correlation_matrix[0].len(), 2);

    // Labels correct
    assert_eq!(analytics.leg_labels, vec!["A", "B"]);

    // Tail risk fields populated
    assert!(analytics.tail_risk.cvar_95 <= analytics.tail_risk.var_95);
}