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> {
    // Use Duration from epoch to support day counts > 31
    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")
}

// --- ReturnSeries::from_equity_curve ---

#[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)); // +10%
    assert_eq!(rs.points[1].value, dec!(105) / dec!(110) - Decimal::ONE); // -4.545...%
    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");
    // First return (0 -> 100) skipped, second (100 -> 110) = 0.1
    assert_eq!(rs.points.len(), 1);
    assert_eq!(rs.points[0].value, dec!(0.1));
}

// --- Frequency inference ---

#[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
    );
}

// --- compose() validation ---

#[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,
    };

    // Weights sum to 0.8
    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,
    };

    // Weights sum to 1.001 — within 0.001 tolerance
    let result = compose(
        &[(&series, dec!(0.501)), (&series, dec!(0.500))],
        dec!(10000),
    );
    assert!(result.is_ok());
}

// --- Core composition math ---

#[test]
fn two_leg_equal_weight_basic_math() {
    // Leg A: +10%, Leg B: -5%. Equal weight → portfolio return = +2.5%
    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");

    // EC: [10000, 10000 * (1 + 0.5*0.10 + 0.5*(-0.05))] = [10000, 10250]
    assert_eq!(result.equity_curve.len(), 2); // t=0 + 1 point
    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() {
    // Hand-calculated 3-leg portfolio over 5 periods
    // Weights: A=0.5, B=0.3, C=0.2
    //
    // Period returns:
    //   A: [+2%, -3%, +4%, +1%, -2%]
    //   B: [+1%, +1%, -1%, +2%, +3%]
    //   C: [-1%, +2%, +3%, -1%, +1%]
    //
    // Portfolio returns:
    //   R(1) = 0.5*0.02 + 0.3*0.01 + 0.2*(-0.01) = 0.010 + 0.003 - 0.002 = 0.011
    //   R(2) = 0.5*(-0.03) + 0.3*0.01 + 0.2*0.02 = -0.015 + 0.003 + 0.004 = -0.008
    //   R(3) = 0.5*0.04 + 0.3*(-0.01) + 0.2*0.03 = 0.020 - 0.003 + 0.006 = 0.023
    //   R(4) = 0.5*0.01 + 0.3*0.02 + 0.2*(-0.01) = 0.005 + 0.006 - 0.002 = 0.009
    //   R(5) = 0.5*(-0.02) + 0.3*0.03 + 0.2*0.01 = -0.010 + 0.009 + 0.002 = 0.001

    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");

    // EC(0) = 10000
    // EC(1) = 10000 * 1.011 = 10110.000
    // EC(2) = 10110 * 0.992 = 10029.120
    // EC(3) = 10029.120 * 1.023 = 10259.793...
    // EC(4) = 10259.793 * 1.009 = 10352.091...
    // EC(5) = 10352.091 * 1.001 = 10362.443...
    assert_eq!(result.equity_curve.len(), 6); // t=0 + 5 periods
    assert_eq!(result.equity_curve[0].value, dec!(10000));

    // Check EC(1) = 10110
    let ec1 = result.equity_curve[1].value;
    assert_eq!(ec1, dec!(10000) * dec!(1.011));

    // Check final value approximately
    let final_val = result.equity_curve[5].value;
    // Hand-calc: 10000 * 1.011 * 0.992 * 1.023 * 1.009 * 1.001 ≈ 10362.44
    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"]);

    // Verify per-leg equity curves exist and have correct structure
    assert_eq!(result.leg_equity_curves.len(), 3);
    for curve in &result.leg_equity_curves {
        assert_eq!(curve.len(), 6); // t=0 + 5 periods
    }

    // Leg A starts at 0.5 * 10000 = 5000, first return +2%
    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() {
    // Leg A has returns at days 2,3,4
    // Leg B has returns at days 2,4 (missing day 3 — e.g. holiday)
    // Master timeline = {2,3,4}. Leg B contributes 0 at day 3.
    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),
            },
            // day 3 missing — contributes 0
            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");

    // 4 points: t=0 + days 2,3,4
    assert_eq!(result.equity_curve.len(), 4);

    // Day 2: R = 0.5*0.01 + 0.5*0.03 = 0.02
    let ec1 = result.equity_curve[1].value;
    assert_eq!(ec1, dec!(10000) * dec!(1.02));

    // Day 3: R = 0.5*0.02 + 0.5*0 = 0.01 (B missing, contributes 0)
    let ec2 = result.equity_curve[2].value;
    let expected_ec2 = ec1 * dec!(1.01);
    assert_eq!(ec2, expected_ec2);
}

// --- Integration with existing metrics ---

#[test]
fn portfolio_ec_feeds_into_existing_metrics() {
    // Build a simple portfolio and verify metrics functions accept the output
    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();

    // All metrics functions must accept portfolio EC without error
    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()
    );

    // cagr takes years as Decimal, not periods_per_year
    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); // Daily = 365, not hardcoded 252

    // Hourly series
    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); // 365 * 24
}

// ---------------------------------------------------------------------------
// compose_mixed tests
// ---------------------------------------------------------------------------

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");

    // Final equity should match
    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)); // 3 days

    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");

    // Timeline should have hourly resolution (72+ points)
    assert!(
        result.equity_curve.len() > 50,
        "expected hourly resolution, got {} points",
        result.equity_curve.len()
    );

    // periods_per_year should be hourly (highest frequency)
    assert_eq!(result.periods_per_year, 8760);

    // Equity should be positive and growing (both legs have positive returns)
    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() {
    // Leg A doubles (high return), Leg B is flat
    let a = make_daily_series("A", 100, dec!(0.007)); // ~100% cumulative
    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");

    // A should have drifted above 50%
    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) // 3 months
            .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");

    // Should have rebalance events at Feb and Mar boundaries
    assert!(
        result.rebalance_events.len() >= 2,
        "expected >= 2 rebalance events, got {}",
        result.rebalance_events.len()
    );

    // Each event should have positive turnover (legs have different returns)
    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");

    // ~3 quarterly events in 12 months (Q2, Q3, Q4)
    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");

    // After the schedule change, rebalance events should use 30/70 weights
    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 {
        // weights_after should be close to [0.3, 0.7]
        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), // total wipeout each day
            })
            .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");
}

// ---------------------------------------------------------------------------
// splice_returns tests
// ---------------------------------------------------------------------------

#[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);

    // Should have points from day 0..5 (old) + day 5..15 (new) = 15 total
    assert_eq!(spliced.points.len(), 15);

    // First 5 points from old (value 0.01)
    for p in &spliced.points[..5] {
        assert_eq!(p.value, dec!(0.01));
    }

    // Remaining 10 points from new (value 0.02)
    for p in &spliced.points[5..] {
        assert_eq!(p.value, dec!(0.02));
    }

    // Label combines both
    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() {
    // Long-short portfolio: 130/30 (leveraged)
    // Long goes up 1%/day, short underlying goes up 1%/day (so short loses)
    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,
    };

    // 70/30 long-short with different underlyings
    // Long gains 1%/day, short underlying gains 1%/day (short LOSES)
    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;

    // Sum of dollar_alloc starts at 7000 + (-3000) = 4000
    // Both grow at 1%/day, so total = 4000 * 1.01^10
    // Portfolio should NOT be 10000-based, it should be ~4000 * 1.01^10 ≈ 4418
    assert!(
        ls_final > Decimal::ZERO,
        "portfolio should be positive, got {ls_final}"
    );
    // The short leg weight inverts: negative alloc * positive return = loss
    // Final value should be less than capital (net exposure < 1.0)
    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,
    };

    // Weights sum to 1.5 (leveraged)
    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"
    );
}

// ---------------------------------------------------------------------------
// compute_inverse_vol_weights unit tests
// ---------------------------------------------------------------------------

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");
    // Low-vol ~83%, high-vol ~17%
    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"
    );
    // Falls back to equal weights
    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"
    );
    // Should still compute correct relative weights
    assert!(weights[1] > weights[0], "lower-vol should get more weight");
}

// ---------------------------------------------------------------------------
// should_rebalance unit tests for Daily/Weekly
// ---------------------------------------------------------------------------

#[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");

    // First call (day 1) — is_first=true, should NOT fire
    let fired = should_rebalance(&RebalanceMode::Daily, base, true, &mut state);
    assert!(!fired, "first period should not rebalance");

    // Second call (day 2) — new day, should fire
    let ts2 = base + chrono::Duration::days(1);
    let fired2 = should_rebalance(&RebalanceMode::Daily, ts2, false, &mut state);
    assert!(fired2, "new day should trigger rebalance");

    // Same day, different hour — should NOT fire
    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();
    // 2025-01-06 is a Monday (week 2)
    let monday_w2 = Utc
        .with_ymd_and_hms(2025, 1, 6, 0, 0, 0)
        .single()
        .expect("valid timestamp");

    // First call — is_first=true
    should_rebalance(&RebalanceMode::Weekly, monday_w2, true, &mut state);

    // Same week, Thursday — should NOT fire
    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");

    // Next week Monday — should fire
    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");

    // 30 days → 29 rebalance events (first day excluded)
    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()
    );

    // Verify spacing >= 5 days between events
    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 },
    };

    // Pass equal weights — inverse-vol should override them
    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());

    // Weights must sum to 1.0
    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}");

    // A (lowest vol) should get highest weight
    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%)");

    // Approximate: inv(1):inv(2):inv(4) = 1:0.5:0.25 → normalized: 4/7:2/7:1/7
    // A ≈ 57%, B ≈ 29%, C ≈ 14%
    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() {
    // When combining InverseVol allocation with monthly rebalancing,
    // rebalance events should reset to inverse-vol-computed weights,
    // NOT the original passed-in 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) // 3 months
                .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 },
    };

    // Pass 50/50 weights — inverse-vol should compute ~83/17
    let result = compose_mixed(
        &[make("High", 5.0), make("Low", 1.0)],
        &[dec!(0.5), dec!(0.5)],
        &options,
    )
    .expect("valid result");

    // Rebalance events should exist
    assert!(
        !result.rebalance_events.is_empty(),
        "expected rebalance events with monthly mode"
    );

    // Rebalance target weights should be inverse-vol computed (~17/83), NOT 50/50
    for event in &result.rebalance_events {
        let low_target = event.weights_after[1]; // "Low" is second
        assert!(
            low_target > dec!(0.7),
            "rebalance should target inv-vol weights, got Low={low_target} (expected >0.7)"
        );
    }
}

// --- HRP pairwise correlation (#1355) ---

/// Helper: create a series with specific days removed (interior gaps).
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() {
    // Reproduce #1355: legs with different candle counts.
    // Series B has interior gaps (collector outage / exchange downtime).
    // Pairwise correlation uses each pair's shared timestamps independently.
    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}");
    }
    // No data-loss warnings — B has 55/60 observations (> 80% of A/C)
    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() {
    // A and C have 120 observations, B has only 40.
    // Per-leg variance of A and C must use all 120 points — not truncated to 40.
    // With global intersection, A/C would lose 80 observations (67% data loss).
    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}"
    );

    // C has lowest vol → should get highest weight (inverse-variance bisection)
    assert!(
        weights[2] > weights[0] && weights[2] > weights[1],
        "lowest-vol leg C should get highest weight, got: {weights:?}"
    );

    // B has only 40 obs — pair warnings expected for (A,B) and (B,C)
    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() {
    // B has only 20 observations total (< 30 minimum).
    // Per-leg check fires before pairwise correlation is even computed.
    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);
    // Falls back to equal weights (1/3 each)
    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() {
    // A covers days 0..59, B covers days 60..119 (zero overlap), C covers days 0..59.
    // Each leg has >= 30 observations, but pairs (A,B) and (B,C) share 0 timestamps.
    // Those pairs should get zero correlation with a warning.
    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); // days 0..59, overlaps with A

    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}");
    }
    // Pairs (A,B) and (B,C) should warn about zero common observations
    assert!(
        warnings
            .iter()
            .any(|w| w.contains("assuming zero correlation") && w.contains("'B'")),
        "expected zero-correlation warning for pairs involving B, got: {warnings:?}"
    );
}

/// Regression test for #1356: inv-vol allocation with equity-curve-derived returns
/// must produce different weights for legs with different volatilities.
///
/// The CLI path uses `ReturnSeries::from_equity_curve` which converts equity values
/// to percentage returns. This test verifies that the full pipeline (equity curve →
/// returns → inverse-vol weights) produces non-equal weights.
#[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();

    // Build equity curves with different volatility profiles
    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)); // 3% daily swings
    let med_vol_eq = build_equity(dec!(0.015)); // 1.5% daily swings
    let low_vol_eq = build_equity(dec!(0.005)); // 0.5% daily swings

    let high =
        ReturnSeries::from_equity_curve("TSLA", &timestamps, &high_vol_eq).expect("valid result");
    let med =
        ReturnSeries::from_equity_curve("AAPL", &timestamps, &med_vol_eq).expect("valid result");
    let low =
        ReturnSeries::from_equity_curve("JPM", &timestamps, &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;

    // JPM (lowest vol) must get highest weight
    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}"
    );

    // Weights must NOT be equal (the bug: all legs get 33.3%)
    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}"
    );
}
