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)
}
#[test]
fn attribution_two_legs_positive_returns() {
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"];
let sum = a_contrib + b_contrib;
assert!((sum - dec!(100)).abs() < dec!(0.1), "sum = {sum}");
assert!(
a_contrib > dec!(75) && a_contrib < dec!(85),
"A = {a_contrib}"
);
assert!(
b_contrib > dec!(15) && b_contrib < dec!(25),
"B = {b_contrib}"
);
}
#[test]
fn attribution_negative_contribution_from_losing_leg() {
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"]
);
let sum: Decimal = attr.values().sum();
assert!((sum - dec!(100)).abs() < dec!(1), "sum = {sum}");
}
#[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]
);
}
}
}
#[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
);
}
#[test]
fn drawdown_overlap_no_overlap_when_alternating() {
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.02) } else { dec!(0.10) },
})
.collect();
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");
}
#[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)];
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);
assert!(analytics.attribution.contains_key("A"));
assert!(analytics.attribution.contains_key("B"));
assert_eq!(analytics.correlation_matrix.len(), 2);
assert_eq!(analytics.correlation_matrix[0].len(), 2);
assert_eq!(analytics.leg_labels, vec!["A", "B"]);
assert!(analytics.tail_risk.cvar_95 <= analytics.tail_risk.var_95);
}