use chrono::{TimeZone, Utc};
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use super::*;
use crate::composition::AllocationMethod;
fn daily_ts(day: u32) -> DateTime<Utc> {
let base = Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp");
base + chrono::Duration::days((day - 1) as i64)
}
fn hourly_ts(day: u32, hour: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(2025, 1, day, hour, 0, 0)
.single()
.expect("valid timestamp")
}
#[test]
fn from_equity_curve_computes_returns() {
let ts = vec![daily_ts(1), daily_ts(2), daily_ts(3)];
let vals = vec![dec!(100), dec!(110), dec!(105)];
let rs = ReturnSeries::from_equity_curve("test", &ts, &vals).expect("valid result");
assert_eq!(rs.points.len(), 2);
assert_eq!(rs.points[0].value, dec!(0.1)); assert_eq!(rs.points[1].value, dec!(105) / dec!(110) - Decimal::ONE); assert_eq!(rs.frequency, Frequency::Daily);
assert_eq!(rs.label, "test");
}
#[test]
fn from_equity_curve_rejects_mismatched_lengths() {
let ts = vec![daily_ts(1), daily_ts(2)];
let vals = vec![dec!(100)];
assert!(ReturnSeries::from_equity_curve("test", &ts, &vals).is_err());
}
#[test]
fn from_equity_curve_rejects_single_point() {
let ts = vec![daily_ts(1)];
let vals = vec![dec!(100)];
let err = ReturnSeries::from_equity_curve("test", &ts, &vals).unwrap_err();
assert!(matches!(
err,
MetricsError::InsufficientData {
required: 2,
actual: 1
}
));
}
#[test]
fn from_equity_curve_skips_zero_denominator() {
let ts = vec![daily_ts(1), daily_ts(2), daily_ts(3)];
let vals = vec![dec!(0), dec!(100), dec!(110)];
let rs = ReturnSeries::from_equity_curve("test", &ts, &vals).expect("valid result");
assert_eq!(rs.points.len(), 1);
assert_eq!(rs.points[0].value, dec!(0.1));
}
#[test]
fn infer_daily_frequency() {
let ts = vec![
daily_ts(1),
daily_ts(2),
daily_ts(3),
daily_ts(4),
daily_ts(5),
];
assert_eq!(
Frequency::infer(&ts).expect("valid result"),
Frequency::Daily
);
}
#[test]
fn infer_hourly_frequency() {
let ts = vec![
hourly_ts(1, 0),
hourly_ts(1, 1),
hourly_ts(1, 2),
hourly_ts(1, 3),
];
assert_eq!(
Frequency::infer(&ts).expect("valid result"),
Frequency::Hourly
);
}
#[test]
fn infer_four_hour_frequency() {
let ts = vec![
hourly_ts(1, 0),
hourly_ts(1, 4),
hourly_ts(1, 8),
hourly_ts(1, 12),
];
assert_eq!(
Frequency::infer(&ts).expect("valid result"),
Frequency::FourHour
);
}
#[test]
fn compose_rejects_empty_legs() {
let result = compose(&[], dec!(10000));
assert!(result.is_err());
}
#[test]
fn compose_rejects_frequency_mismatch() {
let daily = ReturnSeries {
label: "daily_leg".into(),
points: vec![ReturnPoint {
timestamp: daily_ts(2),
value: dec!(0.01),
}],
frequency: Frequency::Daily,
};
let hourly = ReturnSeries {
label: "hourly_leg".into(),
points: vec![ReturnPoint {
timestamp: hourly_ts(1, 1),
value: dec!(0.01),
}],
frequency: Frequency::Hourly,
};
let result = compose(&[(&daily, dec!(0.5)), (&hourly, dec!(0.5))], dec!(10000));
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("frequency mismatch"), "got: {err}");
}
#[test]
fn compose_rejects_weights_not_summing_to_one() {
let series = ReturnSeries {
label: "A".into(),
points: vec![ReturnPoint {
timestamp: daily_ts(2),
value: dec!(0.01),
}],
frequency: Frequency::Daily,
};
let result = compose(&[(&series, dec!(0.3)), (&series, dec!(0.5))], dec!(10000));
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("0.8"), "got: {err}");
}
#[test]
fn compose_accepts_weights_within_tolerance() {
let series = ReturnSeries {
label: "A".into(),
points: vec![ReturnPoint {
timestamp: daily_ts(2),
value: dec!(0.01),
}],
frequency: Frequency::Daily,
};
let result = compose(
&[(&series, dec!(0.501)), (&series, dec!(0.500))],
dec!(10000),
);
assert!(result.is_ok());
}
#[test]
fn two_leg_equal_weight_basic_math() {
let leg_a = ReturnSeries {
label: "A".into(),
points: vec![ReturnPoint {
timestamp: daily_ts(2),
value: dec!(0.10),
}],
frequency: Frequency::Daily,
};
let leg_b = ReturnSeries {
label: "B".into(),
points: vec![ReturnPoint {
timestamp: daily_ts(2),
value: dec!(-0.05),
}],
frequency: Frequency::Daily,
};
let result =
compose(&[(&leg_a, dec!(0.5)), (&leg_b, dec!(0.5))], dec!(10000)).expect("valid result");
assert_eq!(result.equity_curve.len(), 2); assert_eq!(result.equity_curve[0].value, dec!(10000));
assert_eq!(result.equity_curve[1].value, dec!(10250));
}
#[test]
fn three_leg_portfolio_five_periods() {
let ts: Vec<DateTime<Utc>> = (2..=6).map(daily_ts).collect();
let leg_a = ReturnSeries {
label: "A".into(),
points: ts
.iter()
.zip([dec!(0.02), dec!(-0.03), dec!(0.04), dec!(0.01), dec!(-0.02)])
.map(|(&t, v)| ReturnPoint {
timestamp: t,
value: v,
})
.collect(),
frequency: Frequency::Daily,
};
let leg_b = ReturnSeries {
label: "B".into(),
points: ts
.iter()
.zip([dec!(0.01), dec!(0.01), dec!(-0.01), dec!(0.02), dec!(0.03)])
.map(|(&t, v)| ReturnPoint {
timestamp: t,
value: v,
})
.collect(),
frequency: Frequency::Daily,
};
let leg_c = ReturnSeries {
label: "C".into(),
points: ts
.iter()
.zip([dec!(-0.01), dec!(0.02), dec!(0.03), dec!(-0.01), dec!(0.01)])
.map(|(&t, v)| ReturnPoint {
timestamp: t,
value: v,
})
.collect(),
frequency: Frequency::Daily,
};
let result = compose(
&[
(&leg_a, dec!(0.5)),
(&leg_b, dec!(0.3)),
(&leg_c, dec!(0.2)),
],
dec!(10000),
)
.expect("valid result");
assert_eq!(result.equity_curve.len(), 6); assert_eq!(result.equity_curve[0].value, dec!(10000));
let ec1 = result.equity_curve[1].value;
assert_eq!(ec1, dec!(10000) * dec!(1.011));
let final_val = result.equity_curve[5].value;
assert!(
final_val > dec!(10360) && final_val < dec!(10365),
"expected ~10362, got {final_val}"
);
assert_eq!(result.periods_per_year, 365);
assert_eq!(result.leg_labels, vec!["A", "B", "C"]);
assert_eq!(result.leg_equity_curves.len(), 3);
for curve in &result.leg_equity_curves {
assert_eq!(curve.len(), 6); }
assert_eq!(result.leg_equity_curves[0][0].value, dec!(5000));
assert_eq!(
result.leg_equity_curves[0][1].value,
dec!(5000) * dec!(1.02)
);
}
#[test]
fn master_timeline_union_with_gaps() {
let leg_a = ReturnSeries {
label: "A".into(),
points: vec![
ReturnPoint {
timestamp: daily_ts(2),
value: dec!(0.01),
},
ReturnPoint {
timestamp: daily_ts(3),
value: dec!(0.02),
},
ReturnPoint {
timestamp: daily_ts(4),
value: dec!(-0.01),
},
],
frequency: Frequency::Daily,
};
let leg_b = ReturnSeries {
label: "B".into(),
points: vec![
ReturnPoint {
timestamp: daily_ts(2),
value: dec!(0.03),
},
ReturnPoint {
timestamp: daily_ts(4),
value: dec!(0.02),
},
],
frequency: Frequency::Daily,
};
let result =
compose(&[(&leg_a, dec!(0.5)), (&leg_b, dec!(0.5))], dec!(10000)).expect("valid result");
assert_eq!(result.equity_curve.len(), 4);
let ec1 = result.equity_curve[1].value;
assert_eq!(ec1, dec!(10000) * dec!(1.02));
let ec2 = result.equity_curve[2].value;
let expected_ec2 = ec1 * dec!(1.01);
assert_eq!(ec2, expected_ec2);
}
#[test]
fn portfolio_ec_feeds_into_existing_metrics() {
let ts: Vec<DateTime<Utc>> = (2..=10).map(daily_ts).collect();
let returns: Vec<Decimal> = vec![
dec!(0.01),
dec!(-0.005),
dec!(0.015),
dec!(0.008),
dec!(-0.012),
dec!(0.02),
dec!(0.005),
dec!(-0.003),
dec!(0.01),
];
let series = ReturnSeries {
label: "solo".into(),
points: ts
.iter()
.zip(returns.iter())
.map(|(&t, &v)| ReturnPoint {
timestamp: t,
value: v,
})
.collect(),
frequency: Frequency::Daily,
};
let result = compose(&[(&series, dec!(1.0))], dec!(10000)).expect("valid result");
let ec_values = result.equity_values();
let sharpe = crate::sharpe_ratio(&ec_values, dec!(0.02), result.periods_per_year);
assert!(sharpe.is_ok(), "sharpe failed: {:?}", sharpe.err());
let max_dd = crate::max_drawdown(&ec_values);
assert!(max_dd.is_ok(), "max_dd failed: {:?}", max_dd.err());
let total_ret = crate::total_return(&ec_values);
assert!(
total_ret.is_ok(),
"total_return failed: {:?}",
total_ret.err()
);
let cagr_val = crate::cagr(&ec_values, dec!(1));
assert!(cagr_val.is_ok(), "cagr failed: {:?}", cagr_val.err());
let calmar = crate::calmar_ratio(&ec_values, result.periods_per_year);
assert!(calmar.is_ok(), "calmar failed: {:?}", calmar.err());
let sortino = crate::sortino_ratio(&ec_values, dec!(0.02), result.periods_per_year);
assert!(sortino.is_ok(), "sortino failed: {:?}", sortino.err());
}
#[test]
fn periods_per_year_derived_from_frequency() {
let ts: Vec<DateTime<Utc>> = (2..=6).map(daily_ts).collect();
let series = ReturnSeries {
label: "daily".into(),
points: ts
.iter()
.map(|&t| ReturnPoint {
timestamp: t,
value: dec!(0.01),
})
.collect(),
frequency: Frequency::Daily,
};
let result = compose(&[(&series, dec!(1.0))], dec!(10000)).expect("valid result");
assert_eq!(result.periods_per_year, 365);
let ts_h: Vec<DateTime<Utc>> = (0..5).map(|h| hourly_ts(1, h)).collect();
let series_h = ReturnSeries {
label: "hourly".into(),
points: ts_h
.iter()
.map(|&t| ReturnPoint {
timestamp: t,
value: dec!(0.001),
})
.collect(),
frequency: Frequency::Hourly,
};
let result_h = compose(&[(&series_h, dec!(1.0))], dec!(10000)).expect("valid result");
assert_eq!(result_h.periods_per_year, 8760); }
fn make_daily_series(label: &str, count: usize, ret: Decimal) -> ReturnSeries {
let base = Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp");
ReturnSeries {
label: label.into(),
points: (0..count)
.map(|i| ReturnPoint {
timestamp: base + chrono::Duration::days(i as i64),
value: ret,
})
.collect(),
frequency: Frequency::Daily,
}
}
fn make_hourly_series(label: &str, count: usize, ret: Decimal) -> ReturnSeries {
let base = Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp");
ReturnSeries {
label: label.into(),
points: (0..count)
.map(|i| ReturnPoint {
timestamp: base + chrono::Duration::hours(i as i64),
value: ret,
})
.collect(),
frequency: Frequency::Hourly,
}
}
#[test]
fn compose_mixed_single_leg_matches_original_compose() {
let series = make_daily_series("A", 10, dec!(0.01));
let options = ComposeOptions {
capital: dec!(10000),
rebalance: RebalanceMode::None,
weight_schedule: vec![],
allocation: AllocationMethod::Custom,
};
let mixed =
compose_mixed(std::slice::from_ref(&series), &[dec!(1.0)], &options).expect("valid result");
let original = compose(&[(&series, dec!(1.0))], dec!(10000)).expect("valid result");
let mixed_final = mixed.equity_curve.last().expect("non-empty curve").value;
let orig_final = original.equity_curve.last().expect("non-empty curve").value;
let diff = (mixed_final - orig_final).abs();
assert!(diff < dec!(0.01), "mixed={mixed_final}, orig={orig_final}");
}
#[test]
fn compose_mixed_rejects_empty_series() {
let options = ComposeOptions {
capital: dec!(10000),
rebalance: RebalanceMode::None,
weight_schedule: vec![],
allocation: AllocationMethod::Custom,
};
let err = compose_mixed(&[], &[], &options).unwrap_err();
assert!(err.to_string().contains("at least one leg"));
}
#[test]
fn compose_mixed_rejects_mismatched_lengths() {
let series = make_daily_series("A", 5, dec!(0.01));
let options = ComposeOptions {
capital: dec!(10000),
rebalance: RebalanceMode::None,
weight_schedule: vec![],
allocation: AllocationMethod::Custom,
};
let err = compose_mixed(&[series], &[dec!(0.5), dec!(0.5)], &options).unwrap_err();
assert!(err.to_string().contains("same length"));
}
#[test]
fn compose_mixed_forward_fills_daily_in_hourly_timeline() {
let daily = make_daily_series("Daily", 3, dec!(0.02));
let hourly = make_hourly_series("Hourly", 72, dec!(0.001));
let options = ComposeOptions {
capital: dec!(10000),
rebalance: RebalanceMode::None,
weight_schedule: vec![],
allocation: AllocationMethod::Custom,
};
let result =
compose_mixed(&[daily, hourly], &[dec!(0.5), dec!(0.5)], &options).expect("valid result");
assert!(
result.equity_curve.len() > 50,
"expected hourly resolution, got {} points",
result.equity_curve.len()
);
assert_eq!(result.periods_per_year, 8760);
let final_eq = result.equity_curve.last().expect("non-empty curve").value;
assert!(final_eq > dec!(10000), "equity should grow, got {final_eq}");
}
#[test]
fn compose_mixed_no_rebalance_drifts_weights() {
let a = make_daily_series("A", 100, dec!(0.007)); let b = make_daily_series("B", 100, dec!(0.0));
let options = ComposeOptions {
capital: dec!(10000),
rebalance: RebalanceMode::None,
weight_schedule: vec![],
allocation: AllocationMethod::Custom,
};
let result = compose_mixed(&[a, b], &[dec!(0.5), dec!(0.5)], &options).expect("valid result");
let (_, weight_a) = result
.final_weights
.iter()
.find(|(n, _)| n == "A")
.expect("valid result");
assert!(
*weight_a > dec!(0.55),
"A should have drifted above 55%, got {weight_a}"
);
}
#[test]
fn compose_mixed_monthly_rebalance_records_events() {
let base = Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp");
let a = ReturnSeries {
label: "A".into(),
points: (0..90) .map(|i| ReturnPoint {
timestamp: base + chrono::Duration::days(i),
value: dec!(0.005),
})
.collect(),
frequency: Frequency::Daily,
};
let b = ReturnSeries {
label: "B".into(),
points: (0..90)
.map(|i| ReturnPoint {
timestamp: base + chrono::Duration::days(i),
value: dec!(0.001),
})
.collect(),
frequency: Frequency::Daily,
};
let options = ComposeOptions {
capital: dec!(10000),
rebalance: RebalanceMode::Monthly,
weight_schedule: vec![],
allocation: AllocationMethod::Custom,
};
let result = compose_mixed(&[a, b], &[dec!(0.5), dec!(0.5)], &options).expect("valid result");
assert!(
result.rebalance_events.len() >= 2,
"expected >= 2 rebalance events, got {}",
result.rebalance_events.len()
);
for event in &result.rebalance_events {
assert!(
event.turnover > Decimal::ZERO,
"turnover = {}",
event.turnover
);
}
}
#[test]
fn compose_mixed_quarterly_rebalance_fires_every_3_months() {
let base = Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp");
let make_series = |label: &str, ret: Decimal| ReturnSeries {
label: label.into(),
points: (0..360)
.map(|i| ReturnPoint {
timestamp: base + chrono::Duration::days(i),
value: ret,
})
.collect(),
frequency: Frequency::Daily,
};
let options = ComposeOptions {
capital: dec!(10000),
rebalance: RebalanceMode::Quarterly,
weight_schedule: vec![],
allocation: AllocationMethod::Custom,
};
let result = compose_mixed(
&[make_series("A", dec!(0.005)), make_series("B", dec!(0.001))],
&[dec!(0.5), dec!(0.5)],
&options,
)
.expect("valid result");
assert_eq!(
result.rebalance_events.len(),
3,
"expected 3 quarterly events, got {}",
result.rebalance_events.len()
);
}
#[test]
fn compose_mixed_weight_schedule_changes_allocation() {
let base = Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp");
let mid = Utc
.with_ymd_and_hms(2025, 4, 1, 0, 0, 0)
.single()
.expect("valid timestamp");
let make_series = |label: &str| ReturnSeries {
label: label.into(),
points: (0..180)
.map(|i| ReturnPoint {
timestamp: base + chrono::Duration::days(i),
value: dec!(0.003),
})
.collect(),
frequency: Frequency::Daily,
};
let options = ComposeOptions {
capital: dec!(10000),
rebalance: RebalanceMode::Monthly,
weight_schedule: vec![
WeightScheduleEntry {
date: base,
weights: vec![dec!(0.7), dec!(0.3)],
},
WeightScheduleEntry {
date: mid,
weights: vec![dec!(0.3), dec!(0.7)],
},
],
allocation: AllocationMethod::Custom,
};
let result = compose_mixed(
&[make_series("A"), make_series("B")],
&[dec!(0.7), dec!(0.3)],
&options,
)
.expect("valid result");
let post_change_events: Vec<_> = result
.rebalance_events
.iter()
.filter(|e| e.timestamp >= mid)
.collect();
assert!(
!post_change_events.is_empty(),
"expected rebalance events after schedule change"
);
for event in &post_change_events {
let diff = (event.weights_after[0] - dec!(0.3)).abs();
assert!(
diff < dec!(0.01),
"post-change weight[0] = {}, expected ~0.3",
event.weights_after[0]
);
}
}
#[test]
fn compose_mixed_margin_call_on_total_wipeout() {
let base = Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp");
let crash = ReturnSeries {
label: "Crash".into(),
points: (0..5)
.map(|i| ReturnPoint {
timestamp: base + chrono::Duration::days(i),
value: dec!(-1.0), })
.collect(),
frequency: Frequency::Daily,
};
let ok = ReturnSeries {
label: "OK".into(),
points: (0..5)
.map(|i| ReturnPoint {
timestamp: base + chrono::Duration::days(i),
value: dec!(-1.0),
})
.collect(),
frequency: Frequency::Daily,
};
let options = ComposeOptions {
capital: dec!(10000),
rebalance: RebalanceMode::None,
weight_schedule: vec![],
allocation: AllocationMethod::Custom,
};
let result =
compose_mixed(&[crash, ok], &[dec!(0.5), dec!(0.5)], &options).expect("valid result");
assert!(result.margin_call, "expected margin call");
}
#[test]
fn splice_returns_chains_at_date() {
let base = Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp");
let splice_date = Utc
.with_ymd_and_hms(2025, 1, 6, 0, 0, 0)
.single()
.expect("valid timestamp");
let old = ReturnSeries {
label: "Old".into(),
points: (0..10)
.map(|i| ReturnPoint {
timestamp: base + chrono::Duration::days(i),
value: dec!(0.01),
})
.collect(),
frequency: Frequency::Daily,
};
let new = ReturnSeries {
label: "New".into(),
points: (5..15)
.map(|i| ReturnPoint {
timestamp: base + chrono::Duration::days(i),
value: dec!(0.02),
})
.collect(),
frequency: Frequency::Daily,
};
let spliced = splice_returns(&old, &new, splice_date);
assert_eq!(spliced.points.len(), 15);
for p in &spliced.points[..5] {
assert_eq!(p.value, dec!(0.01));
}
for p in &spliced.points[5..] {
assert_eq!(p.value, dec!(0.02));
}
assert!(spliced.label.contains("Old"));
assert!(spliced.label.contains("New"));
}
#[test]
fn splice_returns_no_overlap() {
let base = Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp");
let splice_date = Utc
.with_ymd_and_hms(2025, 1, 11, 0, 0, 0)
.single()
.expect("valid timestamp");
let old = ReturnSeries {
label: "Old".into(),
points: (0..10)
.map(|i| ReturnPoint {
timestamp: base + chrono::Duration::days(i),
value: dec!(0.01),
})
.collect(),
frequency: Frequency::Daily,
};
let new = ReturnSeries {
label: "New".into(),
points: (10..20)
.map(|i| ReturnPoint {
timestamp: base + chrono::Duration::days(i),
value: dec!(0.02),
})
.collect(),
frequency: Frequency::Daily,
};
let spliced = splice_returns(&old, &new, splice_date);
assert_eq!(spliced.points.len(), 20);
}
#[test]
fn compose_mixed_negative_weight_inverts_returns() {
let long = make_daily_series("Long", 10, dec!(0.01));
let short_underlying = make_daily_series("Short", 10, dec!(0.01));
let options = ComposeOptions {
capital: dec!(10000),
rebalance: RebalanceMode::None,
weight_schedule: vec![],
allocation: AllocationMethod::Custom,
};
let result = compose_mixed(
&[long, short_underlying],
&[dec!(0.7), dec!(-0.3)],
&options,
)
.expect("valid result");
let ls_final = result.equity_curve.last().expect("non-empty curve").value;
assert!(
ls_final > Decimal::ZERO,
"portfolio should be positive, got {ls_final}"
);
assert!(
ls_final < dec!(10000),
"long-short with 0.4 net exposure should be < capital, got {ls_final}"
);
}
#[test]
fn compose_mixed_leverage_warning_when_weights_exceed_one() {
let base = Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp");
let series = ReturnSeries {
label: "A".into(),
points: (0..5)
.map(|i| ReturnPoint {
timestamp: base + chrono::Duration::days(i),
value: dec!(0.01),
})
.collect(),
frequency: Frequency::Daily,
};
let options = ComposeOptions {
capital: dec!(10000),
rebalance: RebalanceMode::None,
weight_schedule: vec![],
allocation: AllocationMethod::Custom,
};
let result = compose_mixed(&[series.clone(), series], &[dec!(0.8), dec!(0.7)], &options)
.expect("valid result");
assert!(
!result.warnings.is_empty(),
"expected leverage warning for weight sum > 1.0"
);
assert!(result.warnings[0].contains("leverage"));
}
#[test]
fn compose_mixed_no_warning_when_weights_sum_to_one() {
let base = Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp");
let series = ReturnSeries {
label: "A".into(),
points: (0..5)
.map(|i| ReturnPoint {
timestamp: base + chrono::Duration::days(i),
value: dec!(0.01),
})
.collect(),
frequency: Frequency::Daily,
};
let options = ComposeOptions {
capital: dec!(10000),
rebalance: RebalanceMode::None,
weight_schedule: vec![],
allocation: AllocationMethod::Custom,
};
let result = compose_mixed(&[series.clone(), series], &[dec!(0.6), dec!(0.4)], &options)
.expect("valid result");
assert!(
result.warnings.is_empty(),
"no warning expected for weight sum = 1.0"
);
}
fn make_vol_series(label: &str, daily_vol: f64, days: usize) -> ReturnSeries {
let base = Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp");
let vol = Decimal::try_from(daily_vol / 100.0).expect("valid result");
ReturnSeries {
label: label.into(),
points: (0..days)
.map(|i| {
let sign = if i % 2 == 0 {
Decimal::ONE
} else {
-Decimal::ONE
};
ReturnPoint {
timestamp: base + chrono::Duration::days(i as i64),
value: sign * vol,
}
})
.collect(),
frequency: Frequency::Daily,
}
}
#[test]
fn inverse_vol_gives_less_weight_to_high_vol() {
let high = make_vol_series("High", 5.0, 60);
let low = make_vol_series("Low", 1.0, 60);
let (weights, warnings) = compute_inverse_vol_weights(&[high, low], 60);
assert!(weights[1] > weights[0], "low-vol should get more weight");
let low_pct = weights[1] * dec!(100);
assert!(
low_pct > dec!(78) && low_pct < dec!(88),
"low-vol weight ~83%, got {low_pct}%"
);
assert!(warnings.is_empty());
}
#[test]
fn inverse_vol_equal_vol_produces_equal_weights() {
let a = make_vol_series("A", 2.0, 60);
let b = make_vol_series("B", 2.0, 60);
let (weights, _) = compute_inverse_vol_weights(&[a, b], 60);
let diff = (weights[0] - weights[1]).abs();
assert!(
diff < dec!(0.01),
"equal vol should give equal weights, got {:?}",
weights
);
}
#[test]
fn inverse_vol_zero_vol_falls_back_to_equal() {
let flat = ReturnSeries {
label: "Flat".into(),
points: (0..60)
.map(|i| ReturnPoint {
timestamp: Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp")
+ chrono::Duration::days(i),
value: Decimal::ZERO,
})
.collect(),
frequency: Frequency::Daily,
};
let normal = make_vol_series("Normal", 2.0, 60);
let (weights, warnings) = compute_inverse_vol_weights(&[flat, normal], 60);
assert!(
warnings.iter().any(|w| w.contains("zero")),
"expected zero-vol warning"
);
let diff = (weights[0] - weights[1]).abs();
assert!(diff < dec!(0.01), "expected equal weights fallback");
}
#[test]
fn inverse_vol_insufficient_lookback_warns() {
let short = make_vol_series("Short", 3.0, 10);
let long = make_vol_series("Long", 1.0, 10);
let (weights, warnings) = compute_inverse_vol_weights(&[short, long], 60);
assert!(
warnings.iter().any(|w| w.contains("lookback")),
"expected lookback warning"
);
assert!(weights[1] > weights[0], "lower-vol should get more weight");
}
#[test]
fn should_rebalance_daily_fires_each_day() {
let mut state = RebalanceState::default();
let base = Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp");
let fired = should_rebalance(&RebalanceMode::Daily, base, true, &mut state);
assert!(!fired, "first period should not rebalance");
let ts2 = base + chrono::Duration::days(1);
let fired2 = should_rebalance(&RebalanceMode::Daily, ts2, false, &mut state);
assert!(fired2, "new day should trigger rebalance");
let ts2b = ts2 + chrono::Duration::hours(6);
let fired2b = should_rebalance(&RebalanceMode::Daily, ts2b, false, &mut state);
assert!(!fired2b, "same day should not re-trigger");
}
#[test]
fn should_rebalance_weekly_fires_each_week() {
let mut state = RebalanceState::default();
let monday_w2 = Utc
.with_ymd_and_hms(2025, 1, 6, 0, 0, 0)
.single()
.expect("valid timestamp");
should_rebalance(&RebalanceMode::Weekly, monday_w2, true, &mut state);
let thursday_w2 = monday_w2 + chrono::Duration::days(3);
let fired = should_rebalance(&RebalanceMode::Weekly, thursday_w2, false, &mut state);
assert!(!fired, "same week should not trigger");
let monday_w3 = monday_w2 + chrono::Duration::days(7);
let fired2 = should_rebalance(&RebalanceMode::Weekly, monday_w3, false, &mut state);
assert!(fired2, "new week should trigger rebalance");
}
#[test]
fn compose_mixed_daily_rebalance_fires_each_day() {
let base = Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp");
let make = |label: &str, ret: Decimal| ReturnSeries {
label: label.into(),
points: (0..30)
.map(|i| ReturnPoint {
timestamp: base + chrono::Duration::days(i),
value: ret,
})
.collect(),
frequency: Frequency::Daily,
};
let options = ComposeOptions {
capital: dec!(10000),
rebalance: RebalanceMode::Daily,
weight_schedule: vec![],
allocation: AllocationMethod::Custom,
};
let result = compose_mixed(
&[make("A", dec!(0.005)), make("B", dec!(0.001))],
&[dec!(0.5), dec!(0.5)],
&options,
)
.expect("valid result");
assert_eq!(
result.rebalance_events.len(),
29,
"expected 29 daily rebalance events, got {}",
result.rebalance_events.len()
);
}
#[test]
fn compose_mixed_weekly_rebalance_spaced_correctly() {
let base = Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp");
let make = |label: &str, ret: Decimal| ReturnSeries {
label: label.into(),
points: (0..84)
.map(|i| ReturnPoint {
timestamp: base + chrono::Duration::days(i),
value: ret,
})
.collect(),
frequency: Frequency::Daily,
};
let options = ComposeOptions {
capital: dec!(10000),
rebalance: RebalanceMode::Weekly,
weight_schedule: vec![],
allocation: AllocationMethod::Custom,
};
let result = compose_mixed(
&[make("A", dec!(0.005)), make("B", dec!(0.001))],
&[dec!(0.5), dec!(0.5)],
&options,
)
.expect("valid result");
assert!(
result.rebalance_events.len() >= 11,
"expected >= 11 weekly events, got {}",
result.rebalance_events.len()
);
for pair in result.rebalance_events.windows(2) {
let gap = pair[1].timestamp - pair[0].timestamp;
assert!(
gap.num_days() >= 5,
"weekly gap = {} days, expected >= 5",
gap.num_days()
);
}
}
#[test]
fn compose_mixed_inverse_vol_overrides_weights() {
let high = make_vol_series("High", 5.0, 60);
let low = make_vol_series("Low", 1.0, 60);
let options = ComposeOptions {
capital: dec!(10000),
rebalance: RebalanceMode::None,
weight_schedule: vec![],
allocation: AllocationMethod::InverseVol { lookback: 60 },
};
let result =
compose_mixed(&[high, low], &[dec!(0.5), dec!(0.5)], &options).expect("valid result");
let low_weight = result
.final_weights
.iter()
.find(|(n, _)| n == "Low")
.map(|(_, w)| *w)
.expect("valid result");
assert!(
low_weight > dec!(0.7),
"inverse-vol should give Low >70% weight, got {low_weight}"
);
}
#[test]
fn inverse_vol_three_legs_weights_sum_to_one() {
let a = make_vol_series("A", 1.0, 60);
let b = make_vol_series("B", 2.0, 60);
let c = make_vol_series("C", 4.0, 60);
let (weights, warnings) = compute_inverse_vol_weights(&[a, b, c], 60);
assert_eq!(weights.len(), 3);
assert!(warnings.is_empty());
let sum: Decimal = weights.iter().sum();
let diff = (sum - Decimal::ONE).abs();
assert!(diff < dec!(0.001), "weights must sum to 1.0, got {sum}");
assert!(weights[0] > weights[1], "A(1%) should get more than B(2%)");
assert!(weights[1] > weights[2], "B(2%) should get more than C(4%)");
let a_pct = weights[0] * dec!(100);
assert!(
a_pct > dec!(52) && a_pct < dec!(62),
"A weight ~57%, got {a_pct}%"
);
}
#[test]
fn compose_mixed_inverse_vol_with_monthly_rebalance_uses_inv_vol_weights() {
let base = Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp");
let make = |label: &str, vol: f64| -> ReturnSeries {
let v = Decimal::try_from(vol / 100.0).expect("valid result");
ReturnSeries {
label: label.into(),
points: (0..90) .map(|i| {
let sign = if i % 2 == 0 {
Decimal::ONE
} else {
-Decimal::ONE
};
ReturnPoint {
timestamp: base + chrono::Duration::days(i as i64),
value: sign * v,
}
})
.collect(),
frequency: Frequency::Daily,
}
};
let options = ComposeOptions {
capital: dec!(10000),
rebalance: RebalanceMode::Monthly,
weight_schedule: vec![],
allocation: AllocationMethod::InverseVol { lookback: 60 },
};
let result = compose_mixed(
&[make("High", 5.0), make("Low", 1.0)],
&[dec!(0.5), dec!(0.5)],
&options,
)
.expect("valid result");
assert!(
!result.rebalance_events.is_empty(),
"expected rebalance events with monthly mode"
);
for event in &result.rebalance_events {
let low_target = event.weights_after[1]; assert!(
low_target > dec!(0.7),
"rebalance should target inv-vol weights, got Low={low_target} (expected >0.7)"
);
}
}
fn make_vol_series_with_gaps(
label: &str,
daily_vol: f64,
days: usize,
gaps: &[usize],
) -> ReturnSeries {
let base = Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp");
let vol = Decimal::try_from(daily_vol / 100.0).expect("valid result");
ReturnSeries {
label: label.into(),
points: (0..days)
.filter(|i| !gaps.contains(i))
.map(|i| {
let sign = if i % 2 == 0 {
Decimal::ONE
} else {
-Decimal::ONE
};
ReturnPoint {
timestamp: base + chrono::Duration::days(i as i64),
value: sign * vol,
}
})
.collect(),
frequency: Frequency::Daily,
}
}
#[test]
fn hrp_weights_with_mismatched_series_lengths_does_not_panic() {
let a = make_vol_series("A", 2.0, 60);
let b = make_vol_series_with_gaps("B", 3.0, 60, &[10, 11, 25, 26, 40]);
let c = make_vol_series("C", 1.5, 60);
let (weights, warnings) = compute_hrp_weights(&[a, b, c]);
assert_eq!(weights.len(), 3, "should produce one weight per leg");
let sum: Decimal = weights.iter().sum();
let diff = (sum - Decimal::ONE).abs();
assert!(diff < dec!(0.01), "HRP weights must sum to ~1.0, got {sum}");
for (i, w) in weights.iter().enumerate() {
assert!(*w > Decimal::ZERO, "weight[{i}] must be positive, got {w}");
}
assert!(
!warnings
.iter()
.any(|w| w.contains("insufficient") || w.contains("equal weights")),
"should not fall back to equal weights, got: {warnings:?}"
);
}
#[test]
fn hrp_weights_all_same_length_still_works() {
let a = make_vol_series("A", 1.0, 60);
let b = make_vol_series("B", 2.0, 60);
let c = make_vol_series("C", 4.0, 60);
let (weights, warnings) = compute_hrp_weights(&[a, b, c]);
assert_eq!(weights.len(), 3);
let sum: Decimal = weights.iter().sum();
let diff = (sum - Decimal::ONE).abs();
assert!(diff < dec!(0.01), "weights must sum to ~1.0, got {sum}");
assert!(
warnings.is_empty(),
"no warnings expected, got: {warnings:?}"
);
}
#[test]
fn hrp_pairwise_preserves_long_leg_variance() {
let a = make_vol_series("A", 2.0, 120);
let b = make_vol_series("B", 3.0, 40);
let c = make_vol_series("C", 1.0, 120);
let (weights, warnings) = compute_hrp_weights(&[a, b, c]);
assert_eq!(weights.len(), 3);
let sum: Decimal = weights.iter().sum();
assert!(
(sum - Decimal::ONE).abs() < dec!(0.01),
"weights sum to ~1.0, got {sum}"
);
assert!(
weights[2] > weights[0] && weights[2] > weights[1],
"lowest-vol leg C should get highest weight, got: {weights:?}"
);
assert!(
warnings
.iter()
.any(|w| w.contains("pair") && w.contains("'B'")),
"expected pairwise data-loss warning for short leg B, got: {warnings:?}"
);
}
#[test]
fn hrp_leg_below_min_obs_falls_back_to_equal_weights() {
let a = make_vol_series("A", 2.0, 60);
let b = make_vol_series("B", 3.0, 20);
let c = make_vol_series("C", 1.5, 60);
let (weights, warnings) = compute_hrp_weights(&[a, b, c]);
assert_eq!(weights.len(), 3);
let expected = Decimal::ONE / Decimal::from(3u32);
for (i, w) in weights.iter().enumerate() {
assert_eq!(*w, expected, "weight[{i}] should be 1/3, got {w}");
}
assert!(
warnings
.iter()
.any(|w| w.contains("'B'") && w.contains("20 observations")),
"expected per-leg insufficient data warning for B, got: {warnings:?}"
);
}
#[test]
fn hrp_pair_zero_overlap_assumes_zero_correlation() {
let base = Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp");
let vol_a = Decimal::try_from(2.0 / 100.0).expect("valid result");
let vol_b = Decimal::try_from(3.0 / 100.0).expect("valid result");
let a = ReturnSeries {
label: "A".into(),
points: (0..60)
.map(|i| ReturnPoint {
timestamp: base + chrono::Duration::days(i),
value: if i % 2 == 0 { vol_a } else { -vol_a },
})
.collect(),
frequency: Frequency::Daily,
};
let b = ReturnSeries {
label: "B".into(),
points: (60..120)
.map(|i| ReturnPoint {
timestamp: base + chrono::Duration::days(i),
value: if i % 2 == 0 { vol_b } else { -vol_b },
})
.collect(),
frequency: Frequency::Daily,
};
let c = make_vol_series("C", 1.5, 60);
let (weights, warnings) = compute_hrp_weights(&[a, b, c]);
assert_eq!(weights.len(), 3);
let sum: Decimal = weights.iter().sum();
assert!(
(sum - Decimal::ONE).abs() < dec!(0.01),
"weights must sum to ~1.0, got {sum}"
);
for (i, w) in weights.iter().enumerate() {
assert!(*w > Decimal::ZERO, "weight[{i}] must be positive, got {w}");
}
assert!(
warnings
.iter()
.any(|w| w.contains("assuming zero correlation") && w.contains("'B'")),
"expected zero-correlation warning for pairs involving B, got: {warnings:?}"
);
}
#[test]
fn regression_1356_inv_vol_from_equity_curve_not_equal() {
let days: usize = 120;
let timestamps: Vec<DateTime<Utc>> = (0..days)
.map(|d| {
Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp")
+ chrono::Duration::days(d as i64)
})
.collect();
let build_equity = |daily_return_pct: Decimal| -> Vec<Decimal> {
let mut eq = Vec::with_capacity(days);
eq.push(dec!(10000));
for i in 1..days {
let prev = eq[i - 1];
let r = if i % 2 == 0 {
daily_return_pct
} else {
-daily_return_pct
};
eq.push(prev * (Decimal::ONE + r));
}
eq
};
let high_vol_eq = build_equity(dec!(0.03)); let med_vol_eq = build_equity(dec!(0.015)); let low_vol_eq = build_equity(dec!(0.005));
let high =
ReturnSeries::from_equity_curve("TSLA", ×tamps, &high_vol_eq).expect("valid result");
let med =
ReturnSeries::from_equity_curve("AAPL", ×tamps, &med_vol_eq).expect("valid result");
let low =
ReturnSeries::from_equity_curve("JPM", ×tamps, &low_vol_eq).expect("valid result");
let equal_w = Decimal::ONE / Decimal::from(3u32);
let options = ComposeOptions {
capital: dec!(30000),
rebalance: RebalanceMode::None,
weight_schedule: vec![],
allocation: AllocationMethod::InverseVol { lookback: 60 },
};
let result =
compose_mixed(&[high, med, low], &[equal_w; 3], &options).expect("compose_mixed failed");
let tsla_w = result
.effective_weights
.iter()
.find(|(n, _)| n == "TSLA")
.expect("valid result")
.1;
let aapl_w = result
.effective_weights
.iter()
.find(|(n, _)| n == "AAPL")
.expect("valid result")
.1;
let jpm_w = result
.effective_weights
.iter()
.find(|(n, _)| n == "JPM")
.expect("valid result")
.1;
assert!(
jpm_w > aapl_w,
"JPM (low vol) should have higher weight than AAPL (med vol): JPM={jpm_w}, AAPL={aapl_w}"
);
assert!(
aapl_w > tsla_w,
"AAPL (med vol) should have higher weight than TSLA (high vol): AAPL={aapl_w}, TSLA={tsla_w}"
);
let tolerance = dec!(0.01);
assert!(
(jpm_w - tsla_w).abs() > tolerance,
"weights must not be equal: TSLA={tsla_w}, AAPL={aapl_w}, JPM={jpm_w}"
);
}