"""Tests for the AFML Rust modules:
 - bars.rs: information-driven bars
 - labeling.rs: triple barrier labeling, CUSUM filter, meta-labels
 - fracdiff.rs: fractional differentiation, ADF test, min_frac_diff
 - hrp.rs: Hierarchical Risk Parity, Marcenko-Pastur denoising, detoning
"""

import math
import pytest

import horizon as hz


# ============================================================================
# Helpers: generate realistic test data
# ============================================================================

def make_timestamps(n, start=1_700_000_000.0):
    """Monotonic timestamps (one-second spacing)."""
    return [start + i for i in range(n)]


def make_prices_uptrend(n, start=100.0, step=0.1):
    """Monotonically increasing prices."""
    return [start + i * step for i in range(n)]


def make_prices_downtrend(n, start=100.0, step=0.1):
    """Monotonically decreasing prices."""
    return [start - i * step for i in range(n)]


def make_prices_constant(n, price=100.0):
    """Flat price series."""
    return [price] * n


def make_volumes_constant(n, vol=10.0):
    """Constant volume at each tick."""
    return [vol] * n


def make_prices_mean_reverting(n, center=100.0, amplitude=2.0, period=20):
    """Sinusoidal mean-reverting series."""
    return [center + amplitude * math.sin(2 * math.pi * i / period) for i in range(n)]


def make_random_walk(n, start=100.0, seed=42):
    """Deterministic pseudo-random walk using linear congruential generator."""
    prices = [start]
    x = seed
    for _ in range(n - 1):
        x = (x * 6364136223846793005 + 1442695040888963407) % (2 ** 64)
        # Map to [-1, 1]
        step = ((x >> 33) / (2 ** 31) - 0.5) * 2.0
        prices.append(prices[-1] + step)
    return prices


def make_identity_cov(n):
    """Identity covariance matrix."""
    return [[1.0 if i == j else 0.0 for j in range(n)] for i in range(n)]


def make_diagonal_cov(variances):
    """Diagonal covariance matrix from a list of variances."""
    n = len(variances)
    return [[variances[i] if i == j else 0.0 for j in range(n)] for i in range(n)]


def make_block_cov():
    """4x4 block-structured covariance: (0,1) correlated, (2,3) correlated."""
    return [
        [0.04, 0.03, 0.001, 0.001],
        [0.03, 0.04, 0.001, 0.001],
        [0.001, 0.001, 0.09, 0.06],
        [0.001, 0.001, 0.06, 0.09],
    ]


# ============================================================================
# 1. INFORMATION-DRIVEN BARS (bars.rs)
# ============================================================================


class TestBarType:
    """Test that hz.Bar has all expected attributes."""

    def test_bar_fields_exist(self):
        bars = hz.tick_bars([1.0, 2.0], [100.0, 101.0], [10.0, 20.0], 2)
        assert len(bars) == 1
        bar = bars[0]
        assert hasattr(bar, "timestamp")
        assert hasattr(bar, "open")
        assert hasattr(bar, "high")
        assert hasattr(bar, "low")
        assert hasattr(bar, "close")
        assert hasattr(bar, "volume")
        assert hasattr(bar, "vwap")
        assert hasattr(bar, "n_ticks")

    def test_bar_repr(self):
        bars = hz.tick_bars([1.0], [100.0], [5.0], 1)
        r = repr(bars[0])
        assert "Bar(" in r

    def test_bar_is_frozen(self):
        bars = hz.tick_bars([1.0], [100.0], [5.0], 1)
        with pytest.raises(AttributeError):
            bars[0].open = 999.0


class TestTickBars:
    def test_basic_10_ticks_per_bar(self):
        n = 100
        bars = hz.tick_bars(make_timestamps(n), make_prices_uptrend(n), make_volumes_constant(n), 10)
        assert len(bars) == 10
        for b in bars:
            assert b.n_ticks == 10

    def test_partial_last_bar(self):
        n = 15
        bars = hz.tick_bars(make_timestamps(n), make_prices_uptrend(n), make_volumes_constant(n), 10)
        assert len(bars) == 2
        assert bars[0].n_ticks == 10
        assert bars[1].n_ticks == 5

    def test_single_tick(self):
        bars = hz.tick_bars([1.0], [100.0], [5.0], 1)
        assert len(bars) == 1
        b = bars[0]
        assert b.open == 100.0
        assert b.close == 100.0
        assert b.high == 100.0
        assert b.low == 100.0
        assert b.volume == 5.0
        assert b.n_ticks == 1

    def test_empty_input(self):
        bars = hz.tick_bars([], [], [], 10)
        assert len(bars) == 0

    def test_ohlcv_correctness(self):
        ts = [1.0, 2.0, 3.0, 4.0, 5.0]
        px = [100.0, 105.0, 98.0, 102.0, 101.0]
        vol = [10.0, 20.0, 15.0, 25.0, 30.0]
        bars = hz.tick_bars(ts, px, vol, 5)
        assert len(bars) == 1
        b = bars[0]
        assert b.open == 100.0
        assert b.close == 101.0
        assert b.high == 105.0
        assert b.low == 98.0
        assert b.volume == pytest.approx(100.0)
        assert b.timestamp == 1.0

    def test_vwap_correctness(self):
        ts = [1.0, 2.0]
        px = [100.0, 200.0]
        vol = [10.0, 30.0]
        bars = hz.tick_bars(ts, px, vol, 2)
        # VWAP = (100*10 + 200*30) / 40 = 7000/40 = 175.0
        assert bars[0].vwap == pytest.approx(175.0, abs=1e-10)

    def test_vwap_zero_volume(self):
        bars = hz.tick_bars([1.0, 2.0], [100.0, 200.0], [0.0, 0.0], 2)
        assert len(bars) == 1
        # Zero volume -> VWAP falls back to close
        assert bars[0].vwap == bars[0].close

    def test_constant_price(self):
        n = 20
        bars = hz.tick_bars(make_timestamps(n), make_prices_constant(n), make_volumes_constant(n), 5)
        for b in bars:
            assert b.open == b.close == b.high == b.low == 100.0

    def test_high_gte_low(self):
        n = 50
        bars = hz.tick_bars(
            make_timestamps(n), make_prices_mean_reverting(n), make_volumes_constant(n), 10
        )
        for b in bars:
            assert b.high >= b.low
            assert b.high >= b.open
            assert b.high >= b.close
            assert b.low <= b.open
            assert b.low <= b.close

    def test_mismatched_lengths_raises(self):
        with pytest.raises(ValueError):
            hz.tick_bars([1.0, 2.0], [100.0], [10.0], 1)


class TestVolumeBars:
    def test_basic(self):
        n = 100
        bars = hz.volume_bars(
            make_timestamps(n), make_prices_uptrend(n), make_volumes_constant(n, 10.0), 100.0
        )
        # Each bar needs 10 ticks * 10.0 vol = 100.0
        assert len(bars) == 10
        for b in bars:
            assert b.volume >= 100.0

    def test_negative_threshold_returns_empty(self):
        bars = hz.volume_bars([1.0], [100.0], [10.0], -5.0)
        assert len(bars) == 0

    def test_zero_threshold_returns_empty(self):
        bars = hz.volume_bars([1.0], [100.0], [10.0], 0.0)
        assert len(bars) == 0

    def test_large_threshold_single_partial_bar(self):
        bars = hz.volume_bars([1.0, 2.0], [100.0, 101.0], [5.0, 5.0], 1000.0)
        assert len(bars) == 1
        assert bars[0].n_ticks == 2

    def test_varying_volumes(self):
        ts = [1.0, 2.0, 3.0, 4.0, 5.0]
        px = [100.0, 101.0, 102.0, 103.0, 104.0]
        vol = [30.0, 40.0, 50.0, 10.0, 20.0]
        # Threshold 60: first bar at tick 2 (30+40=70), next at tick 3 (50>=60), etc.
        bars = hz.volume_bars(ts, px, vol, 60.0)
        assert len(bars) >= 2
        for b in bars:
            assert b.high >= b.low


class TestDollarBars:
    def test_basic(self):
        n = 50
        px = [100.0] * n
        vol = make_volumes_constant(n, 10.0)
        # dollar per tick = 100*10 = 1000
        bars = hz.dollar_bars(make_timestamps(n), px, vol, 5000.0)
        assert len(bars) == 10

    def test_zero_volume(self):
        bars = hz.dollar_bars(
            [1.0, 2.0, 3.0], [100.0, 100.0, 100.0], [0.0, 0.0, 0.0], 1000.0
        )
        # No dollar threshold reached, single partial bar
        assert len(bars) == 1
        assert bars[0].n_ticks == 3

    def test_empty_input(self):
        bars = hz.dollar_bars([], [], [], 1000.0)
        assert len(bars) == 0

    def test_increasing_price_affects_bar_frequency(self):
        n = 100
        # Increasing prices => more dollar volume per tick => more bars
        px_up = make_prices_uptrend(n, start=100.0, step=1.0)
        px_flat = make_prices_constant(n, 100.0)
        vol = make_volumes_constant(n, 10.0)
        ts = make_timestamps(n)
        bars_up = hz.dollar_bars(ts, px_up, vol, 5000.0)
        bars_flat = hz.dollar_bars(ts, px_flat, vol, 5000.0)
        # Higher prices => reach dollar threshold faster => more bars
        assert len(bars_up) >= len(bars_flat)


class TestTickImbalanceBars:
    def test_all_up_trend(self):
        n = 100
        bars = hz.tick_imbalance_bars(
            make_timestamps(n), make_prices_uptrend(n), make_volumes_constant(n), 10.0
        )
        assert len(bars) >= 1
        # First bar should have ~10 ticks (imbalance all +1)
        assert bars[0].n_ticks >= 10

    def test_constant_price_carry_forward(self):
        # Constant prices: b_0 = 1, then carry forward => theta grows +1 per tick
        n = 50
        bars = hz.tick_imbalance_bars(
            make_timestamps(n), make_prices_constant(n), make_volumes_constant(n), 10.0
        )
        assert len(bars) >= 1
        assert bars[0].n_ticks >= 10

    def test_empty_input(self):
        bars = hz.tick_imbalance_bars([], [], [], 10.0)
        assert len(bars) == 0

    def test_zero_estimate_returns_empty(self):
        bars = hz.tick_imbalance_bars([1.0], [100.0], [10.0], 0.0)
        assert len(bars) == 0

    def test_alternating_prices_fewer_bars(self):
        # Alternating up/down means imbalance oscillates near 0
        n = 200
        px = [100.0 + (i % 2) for i in range(n)]
        bars = hz.tick_imbalance_bars(make_timestamps(n), px, make_volumes_constant(n), 5.0)
        assert len(bars) >= 1


class TestVolumeImbalanceBars:
    def test_all_up(self):
        n = 100
        bars = hz.volume_imbalance_bars(
            make_timestamps(n), make_prices_uptrend(n), make_volumes_constant(n, 10.0), 50.0
        )
        assert len(bars) >= 1
        # theta = sum(b_i * v_i) = 10 per tick (all b=1), so first bar at ~5 ticks
        assert bars[0].n_ticks >= 5

    def test_zero_estimate_returns_empty(self):
        bars = hz.volume_imbalance_bars([1.0], [100.0], [10.0], 0.0)
        assert len(bars) == 0

    def test_ohlcv_invariants(self):
        n = 200
        bars = hz.volume_imbalance_bars(
            make_timestamps(n),
            make_prices_mean_reverting(n),
            make_volumes_constant(n, 5.0),
            25.0,
        )
        for b in bars:
            assert b.high >= b.low
            assert b.volume > 0.0


class TestTickRunBars:
    def test_all_up(self):
        n = 100
        bars = hz.tick_run_bars(
            make_timestamps(n), make_prices_uptrend(n), make_volumes_constant(n), 10.0
        )
        assert len(bars) >= 1
        assert bars[0].n_ticks >= 10

    def test_alternating(self):
        n = 200
        px = [100.0 + (i % 2) for i in range(n)]
        bars = hz.tick_run_bars(make_timestamps(n), px, make_volumes_constant(n), 10.0)
        assert len(bars) >= 1

    def test_empty_input(self):
        bars = hz.tick_run_bars([], [], [], 10.0)
        assert len(bars) == 0


class TestVolumeRunBars:
    def test_all_up(self):
        n = 100
        bars = hz.volume_run_bars(
            make_timestamps(n), make_prices_uptrend(n), make_volumes_constant(n, 5.0), 25.0
        )
        assert len(bars) >= 1
        assert bars[0].n_ticks >= 5

    def test_zero_volume_single_partial(self):
        n = 10
        bars = hz.volume_run_bars(
            make_timestamps(n), make_prices_uptrend(n), [0.0] * n, 10.0
        )
        assert len(bars) == 1
        assert bars[0].n_ticks == n

    def test_ohlcv_invariants(self):
        n = 200
        bars = hz.volume_run_bars(
            make_timestamps(n),
            make_prices_mean_reverting(n),
            make_volumes_constant(n, 10.0),
            50.0,
        )
        for b in bars:
            assert b.high >= b.low
            assert b.volume >= 0.0
            assert b.n_ticks >= 1

    def test_mixed_direction(self):
        px = [100.0, 101.0, 102.0, 101.0, 102.0, 103.0, 102.0, 103.0, 104.0, 103.0]
        n = len(px)
        bars = hz.volume_run_bars(make_timestamps(n), px, make_volumes_constant(n, 10.0), 20.0)
        assert len(bars) >= 1
        for b in bars:
            assert b.high >= b.low


# ============================================================================
# 2. TRIPLE BARRIER LABELING (labeling.rs)
# ============================================================================


class TestBarrierLabelType:
    """Test that hz.BarrierLabel has all expected attributes."""

    def test_fields_exist(self):
        # Generate a simple label to inspect
        prices = make_prices_uptrend(100, step=0.5)
        ts = make_timestamps(100)
        labels = hz.triple_barrier_labels(prices, ts, [10], [1.0, 1.0], 0.0, 50, 20)
        assert len(labels) == 1
        lbl = labels[0]
        assert hasattr(lbl, "event_idx")
        assert hasattr(lbl, "label")
        assert hasattr(lbl, "ret")
        assert hasattr(lbl, "barrier")
        assert hasattr(lbl, "touch_idx")

    def test_repr(self):
        prices = make_prices_uptrend(100, step=0.5)
        ts = make_timestamps(100)
        labels = hz.triple_barrier_labels(prices, ts, [10], [1.0, 1.0], 0.0, 50, 20)
        r = repr(labels[0])
        assert "BarrierLabel(" in r

    def test_label_values_are_integers(self):
        prices = make_prices_uptrend(100, step=0.5)
        ts = make_timestamps(100)
        labels = hz.triple_barrier_labels(prices, ts, [10], [1.0, 1.0], 0.0, 50, 20)
        assert labels[0].label in (-1, 0, 1)


class TestGetDailyVol:
    def test_basic_length(self):
        prices = make_prices_uptrend(100)
        vol = hz.get_daily_vol(prices, 20)
        assert len(vol) == len(prices)

    def test_first_element_is_zero(self):
        prices = make_prices_uptrend(100)
        vol = hz.get_daily_vol(prices, 20)
        assert vol[0] == 0.0

    def test_positive_values_for_volatile_series(self):
        prices = make_prices_mean_reverting(200, amplitude=5.0, period=10)
        vol = hz.get_daily_vol(prices, 20)
        # After some warm-up, vol should be positive
        assert any(v > 0.0 for v in vol[20:])

    def test_small_vol_for_constant_uptrend(self):
        # Constant-step uptrend has constant log returns => small vol
        prices = make_prices_uptrend(200, step=0.1)
        vol = hz.get_daily_vol(prices, 20)
        # Vol should be near zero since log returns are nearly constant
        for v in vol[25:]:
            assert v < 0.01

    def test_too_short_raises(self):
        with pytest.raises(ValueError):
            hz.get_daily_vol([100.0], 10)

    def test_zero_span_raises(self):
        with pytest.raises(ValueError):
            hz.get_daily_vol([100.0, 101.0], 0)

    def test_span_larger_than_series(self):
        # Should still work (expanding window phase covers all)
        vol = hz.get_daily_vol([100.0, 101.0, 102.0], 100)
        assert len(vol) == 3


class TestCusumFilter:
    def test_detects_upward_jump(self):
        prices = [100.0] * 10 + [110.0] + [110.0] * 10
        events = hz.cusum_filter(prices, 5.0)
        assert len(events) >= 1
        assert events[0] == 10  # jump at index 10

    def test_detects_downward_jump(self):
        prices = [100.0] * 10 + [90.0] + [90.0] * 10
        events = hz.cusum_filter(prices, 5.0)
        assert len(events) >= 1
        assert events[0] == 10

    def test_no_events_for_small_moves(self):
        prices = [100.0 + i * 0.001 for i in range(100)]
        events = hz.cusum_filter(prices, 10.0)
        assert len(events) == 0

    def test_multiple_events(self):
        prices = [100.0] * 5 + [120.0] + [120.0] * 10 + [140.0] + [140.0] * 5
        events = hz.cusum_filter(prices, 10.0)
        assert len(events) == 2

    def test_resets_after_event(self):
        # After detecting an event, CUSUM should reset
        prices = [100.0] * 5 + [115.0] + [115.0] * 20 + [130.0] + [130.0] * 5
        events = hz.cusum_filter(prices, 10.0)
        assert len(events) == 2

    def test_invalid_threshold_raises(self):
        with pytest.raises(ValueError):
            hz.cusum_filter([100.0, 101.0], 0.0)
        with pytest.raises(ValueError):
            hz.cusum_filter([100.0, 101.0], -1.0)

    def test_single_price_returns_empty(self):
        events = hz.cusum_filter([100.0], 5.0)
        assert len(events) == 0

    def test_empty_returns_empty(self):
        events = hz.cusum_filter([], 5.0)
        assert len(events) == 0

    def test_mean_reverting_generates_events(self):
        # Big oscillations should trigger CUSUM
        prices = make_prices_mean_reverting(500, amplitude=10.0, period=50)
        events = hz.cusum_filter(prices, 5.0)
        assert len(events) >= 2


class TestTripleBarrierLabels:
    def test_profit_taking_in_uptrend(self):
        n = 100
        prices = make_prices_uptrend(n, step=0.5)
        ts = make_timestamps(n)
        labels = hz.triple_barrier_labels(prices, ts, [10], [1.0, 1.0], 0.0, 50, 20)
        assert len(labels) == 1
        assert labels[0].label == 1
        assert labels[0].barrier == "pt"
        assert labels[0].ret > 0.0

    def test_stop_loss_in_downtrend(self):
        n = 100
        prices = make_prices_downtrend(n, step=0.5)
        ts = make_timestamps(n)
        labels = hz.triple_barrier_labels(prices, ts, [10], [1.0, 1.0], 0.0, 50, 20)
        assert len(labels) == 1
        assert labels[0].label == -1
        assert labels[0].barrier == "sl"
        assert labels[0].ret < 0.0

    def test_vertical_barrier_flat_prices(self):
        n = 100
        prices = make_prices_constant(n)
        ts = make_timestamps(n)
        labels = hz.triple_barrier_labels(prices, ts, [10], [1.0, 1.0], 0.0, 10, 5)
        assert len(labels) == 1
        assert labels[0].barrier == "vb"
        # Flat prices: ret=0.0, which is not > 0.0 so label is -1 (not 0)
        # because min_ret=0.0 means abs(ret) < 0.0 is False, and ret > 0 is False
        assert labels[0].label in (-1, 0)

    def test_min_ret_suppresses_small_moves(self):
        n = 100
        prices = [100.0 + i * 0.001 for i in range(n)]  # tiny uptrend
        ts = make_timestamps(n)
        labels = hz.triple_barrier_labels(prices, ts, [10], [0.0, 0.0], 0.5, 20, 5)
        assert len(labels) == 1
        assert labels[0].label == 0  # below min_ret

    def test_multiple_events(self):
        n = 200
        prices = make_prices_uptrend(n, step=0.3)
        ts = make_timestamps(n)
        events = [20, 50, 80, 120]
        labels = hz.triple_barrier_labels(prices, ts, events, [1.0, 1.0], 0.0, 30, 20)
        assert len(labels) == 4
        for lbl in labels:
            assert lbl.label in (-1, 0, 1)
            assert lbl.barrier in ("pt", "sl", "vb")

    def test_touch_idx_gte_event_idx(self):
        n = 200
        prices = make_prices_mean_reverting(n, amplitude=5.0, period=40)
        ts = make_timestamps(n)
        events = list(range(10, 150, 20))
        labels = hz.triple_barrier_labels(prices, ts, events, [2.0, 2.0], 0.0, 30, 10)
        for lbl in labels:
            assert lbl.touch_idx >= lbl.event_idx

    def test_event_out_of_bounds_raises(self):
        with pytest.raises(ValueError):
            hz.triple_barrier_labels([100.0] * 10, list(range(10)), [100], [1.0, 1.0], 0.0, 5, 3)

    def test_mismatched_timestamps_raises(self):
        with pytest.raises(ValueError):
            hz.triple_barrier_labels([100.0] * 10, [1.0] * 5, [0], [1.0, 1.0], 0.0, 5, 3)

    def test_disabled_pt_only_sl_or_vb(self):
        n = 100
        prices = make_prices_uptrend(n, step=0.5)
        ts = make_timestamps(n)
        labels = hz.triple_barrier_labels(prices, ts, [10], [0.0, 1.0], 0.0, 50, 20)
        # PT disabled => can only hit SL or VB
        assert len(labels) == 1
        assert labels[0].barrier in ("sl", "vb")

    def test_disabled_sl_only_pt_or_vb(self):
        n = 100
        prices = make_prices_downtrend(n, step=0.5)
        ts = make_timestamps(n)
        labels = hz.triple_barrier_labels(prices, ts, [10], [1.0, 0.0], 0.0, 50, 20)
        # SL disabled => can only hit PT or VB
        assert len(labels) == 1
        assert labels[0].barrier in ("pt", "vb")


class TestMetaLabels:
    def test_correct_long(self):
        # Long signal, price goes up => meta = 1
        n = 100
        prices = make_prices_uptrend(n, step=0.5)
        ts = make_timestamps(n)
        labels = hz.meta_labels(prices, ts, [(10, 1)], [1.0, 1.0], 50, 20)
        assert len(labels) == 1
        assert labels[0].label == 1

    def test_wrong_long(self):
        # Long signal, price goes down => meta = 0
        n = 100
        prices = make_prices_downtrend(n, step=0.5)
        ts = make_timestamps(n)
        labels = hz.meta_labels(prices, ts, [(10, 1)], [1.0, 1.0], 50, 20)
        assert len(labels) == 1
        assert labels[0].label == 0

    def test_correct_short(self):
        # Short signal, price goes down => meta = 1
        n = 100
        prices = make_prices_downtrend(n, step=0.5)
        ts = make_timestamps(n)
        labels = hz.meta_labels(prices, ts, [(10, -1)], [1.0, 1.0], 50, 20)
        assert len(labels) == 1
        assert labels[0].label == 1

    def test_wrong_short(self):
        # Short signal, price goes up => meta = 0
        n = 100
        prices = make_prices_uptrend(n, step=0.5)
        ts = make_timestamps(n)
        labels = hz.meta_labels(prices, ts, [(10, -1)], [1.0, 1.0], 50, 20)
        assert len(labels) == 1
        assert labels[0].label == 0

    def test_meta_labels_binary_only(self):
        n = 200
        prices = make_prices_mean_reverting(n, amplitude=5.0, period=30)
        ts = make_timestamps(n)
        signals = [(20, 1), (50, -1), (80, 1), (120, -1)]
        labels = hz.meta_labels(prices, ts, signals, [2.0, 2.0], 30, 10)
        assert len(labels) == 4
        for lbl in labels:
            assert lbl.label in (0, 1)  # binary

    def test_invalid_side_raises(self):
        with pytest.raises(ValueError):
            hz.meta_labels([100.0] * 10, list(range(10)), [(5, 2)], [1.0, 1.0], 5, 3)

    def test_multiple_signals(self):
        n = 300
        prices = make_random_walk(n)
        ts = make_timestamps(n)
        signals = [(i, 1 if i % 2 == 0 else -1) for i in range(20, 250, 30)]
        labels = hz.meta_labels(prices, ts, signals, [1.0, 1.0], 20, 10)
        assert len(labels) == len(signals)


class TestDropLabels:
    def test_removes_rare_class(self):
        # Use triple_barrier_labels to generate real BarrierLabel objects
        n = 200
        prices = make_prices_uptrend(n, step=0.5)
        ts = make_timestamps(n)
        events = list(range(10, 180, 5))
        labels = hz.triple_barrier_labels(prices, ts, events, [1.0, 1.0], 0.0, 10, 20)
        # Count classes
        counts = {}
        for lbl in labels:
            counts[lbl.label] = counts.get(lbl.label, 0) + 1
        total = len(labels)
        # Apply drop_labels with a threshold that removes the rarest class
        if len(counts) > 1:
            min_class_pct = min(counts.values()) / total
            threshold = min_class_pct + 0.01  # just above the rarest
            filtered = hz.drop_labels(labels, threshold)
            assert len(filtered) <= len(labels)

    def test_keeps_all_when_balanced(self):
        # Create two balanced classes via meta_labels
        n_up = 100
        prices_up = make_prices_uptrend(n_up, step=0.5)
        ts_up = make_timestamps(n_up)
        labels_pos = hz.triple_barrier_labels(prices_up, ts_up, [10], [1.0, 1.0], 0.0, 50, 20)

        n_down = 100
        prices_down = make_prices_downtrend(n_down, step=0.5)
        ts_down = make_timestamps(n_down)
        labels_neg = hz.triple_barrier_labels(prices_down, ts_down, [10], [1.0, 1.0], 0.0, 50, 20)

        all_labels = labels_pos + labels_neg
        filtered = hz.drop_labels(all_labels, 0.10)
        # Both classes >= 10% so nothing dropped
        assert len(filtered) == len(all_labels)

    def test_empty_input(self):
        filtered = hz.drop_labels([], 0.05)
        assert len(filtered) == 0

    def test_invalid_min_pct_raises(self):
        with pytest.raises(ValueError):
            hz.drop_labels([], -0.1)
        with pytest.raises(ValueError):
            hz.drop_labels([], 1.5)

    def test_zero_min_pct_keeps_all(self):
        n = 100
        prices = make_prices_uptrend(n, step=0.5)
        ts = make_timestamps(n)
        labels = hz.triple_barrier_labels(prices, ts, [10, 20, 30], [1.0, 1.0], 0.0, 20, 10)
        filtered = hz.drop_labels(labels, 0.0)
        assert len(filtered) == len(labels)


class TestIntegrationCusumToLabels:
    def test_cusum_events_fed_to_labels(self):
        # Build a series with a regime change
        prices = [100.0 + math.sin(i * 0.01) * 0.5 for i in range(100)]
        prices += [100.0 + (i - 100) * 0.3 for i in range(100, 250)]
        ts = make_timestamps(len(prices))
        events = hz.cusum_filter(prices, 1.0)
        assert len(events) > 0
        labels = hz.triple_barrier_labels(prices, ts, events, [2.0, 2.0], 0.0, 20, 10)
        assert len(labels) == len(events)
        # Some labels in the uptrend should be +1
        pos = sum(1 for l in labels if l.label == 1)
        assert pos > 0


# ============================================================================
# 3. FRACTIONAL DIFFERENTIATION (fracdiff.rs)
# ============================================================================


class TestFracDiffWeights:
    def test_d_zero(self):
        w = hz.frac_diff_weights(0.0)
        assert len(w) == 1
        assert w[0] == pytest.approx(1.0)

    def test_d_one(self):
        w = hz.frac_diff_weights(1.0)
        assert len(w) >= 2
        assert w[0] == pytest.approx(1.0)
        assert w[1] == pytest.approx(-1.0)

    def test_d_half(self):
        w = hz.frac_diff_weights(0.5)
        assert len(w) >= 3
        assert w[0] == pytest.approx(1.0)
        assert w[1] == pytest.approx(-0.5)
        assert w[2] == pytest.approx(-0.125)

    def test_weights_diminish(self):
        w = hz.frac_diff_weights(0.3, threshold=1e-8)
        for k in range(1, len(w)):
            assert abs(w[k]) <= abs(w[0]) + 1e-10

    def test_more_weights_with_lower_threshold(self):
        w_loose = hz.frac_diff_weights(0.5, threshold=1e-3)
        w_tight = hz.frac_diff_weights(0.5, threshold=1e-8)
        assert len(w_tight) >= len(w_loose)

    def test_d_two(self):
        # d=2 => second difference: w = [1, -2, 1, 0, ...]
        w = hz.frac_diff_weights(2.0)
        assert w[0] == pytest.approx(1.0)
        assert w[1] == pytest.approx(-2.0)
        assert w[2] == pytest.approx(1.0)


class TestFracDiffFFD:
    def test_d_zero_is_identity(self):
        series = [10.0, 20.0, 30.0, 40.0, 50.0]
        result = hz.frac_diff_ffd(series, 0.0)
        assert len(result) == len(series)
        for a, b in zip(result, series):
            assert a == pytest.approx(b)

    def test_d_one_is_first_diff(self):
        series = [100.0, 102.0, 101.0, 105.0, 103.0]
        result = hz.frac_diff_ffd(series, 1.0, threshold=0.5)
        # weights = [1, -1], output[t] = series[t] - series[t-1]
        assert len(result) == 4
        assert result[0] == pytest.approx(2.0)
        assert result[1] == pytest.approx(-1.0)
        assert result[2] == pytest.approx(4.0)
        assert result[3] == pytest.approx(-2.0)

    def test_output_length(self):
        series = list(range(100))
        series = [float(x) for x in series]
        result = hz.frac_diff_ffd(series, 0.5, threshold=1e-3)
        weights = hz.frac_diff_weights(0.5, threshold=1e-3)
        assert len(result) == len(series) - len(weights) + 1

    def test_empty_series_raises(self):
        with pytest.raises(ValueError):
            hz.frac_diff_ffd([], 0.5)

    def test_negative_d_raises(self):
        with pytest.raises(ValueError):
            hz.frac_diff_ffd([1.0, 2.0, 3.0], -0.5)

    def test_invalid_threshold_raises(self):
        with pytest.raises(ValueError):
            hz.frac_diff_ffd([1.0, 2.0, 3.0], 0.5, threshold=0.0)
        with pytest.raises(ValueError):
            hz.frac_diff_ffd([1.0, 2.0, 3.0], 0.5, threshold=-1.0)

    def test_preserves_stationarity(self):
        # Apply frac_diff to a trending series, check ADF becomes more negative
        series = [float(i) for i in range(200)]
        diffed = hz.frac_diff_ffd(series, 0.7, threshold=1e-3)
        if len(diffed) >= 3:
            adf_diffed = hz.adf_statistic(diffed)
            # The differenced series should be more stationary
            assert adf_diffed < 0  # should be negative


class TestFracDiffExpanding:
    def test_d_zero_is_identity(self):
        series = [5.0, 10.0, 15.0]
        result = hz.frac_diff_expanding(series, 0.0)
        assert len(result) == 3
        for a, b in zip(result, series):
            assert a == pytest.approx(b)

    def test_d_one_first_diff(self):
        series = [100.0, 102.0, 105.0]
        result = hz.frac_diff_expanding(series, 1.0)
        assert len(result) == 3
        assert result[0] == pytest.approx(100.0)
        assert result[1] == pytest.approx(2.0)   # 102 - 100
        assert result[2] == pytest.approx(3.0)   # 105 - 102

    def test_preserves_length(self):
        series = [float(i) for i in range(50)]
        result = hz.frac_diff_expanding(series, 0.4)
        assert len(result) == len(series)

    def test_expanding_longer_output_than_ffd(self):
        series = [float(i) for i in range(100)]
        result_exp = hz.frac_diff_expanding(series, 0.5)
        result_ffd = hz.frac_diff_ffd(series, 0.5, threshold=1e-3)
        assert len(result_exp) >= len(result_ffd)

    def test_empty_raises(self):
        with pytest.raises(ValueError):
            hz.frac_diff_expanding([], 0.5)

    def test_negative_d_raises(self):
        with pytest.raises(ValueError):
            hz.frac_diff_expanding([1.0, 2.0], -0.1)


class TestADFStatistic:
    def test_stationary_series_negative(self):
        # Mean-reverting series: y[t] = 0.3*y[t-1] + noise
        series = [0.0]
        for i in range(1, 300):
            noise = math.sin(i * 0.73) * 0.01
            series.append(0.3 * series[-1] + noise)
        adf = hz.adf_statistic(series)
        assert adf < -2.0

    def test_random_walk_closer_to_zero(self):
        # Random walk is unit root => ADF should not be very negative
        series = make_random_walk(200, start=0.0, seed=123)
        adf = hz.adf_statistic(series)
        # Should be closer to 0 than a stationary series
        assert adf > -10.0

    def test_constant_series(self):
        series = [5.0] * 100
        adf = hz.adf_statistic(series)
        # Constant is trivially stationary => very negative or -inf
        assert adf == float("-inf") or adf < -10.0

    def test_too_short_raises(self):
        with pytest.raises(ValueError):
            hz.adf_statistic([1.0, 2.0])

    def test_nan_input_raises(self):
        with pytest.raises(ValueError):
            hz.adf_statistic([1.0, float("nan"), 3.0])

    def test_trending_series(self):
        # Linear trend: not stationary but ADF might still be somewhat negative
        series = [float(i) for i in range(200)]
        adf = hz.adf_statistic(series)
        # Pure linear trend should be close to unit root
        assert isinstance(adf, float)


class TestMinFracDiff:
    def test_returns_d_in_range(self):
        series = [float(i) * 0.1 + math.sin(i * 0.05) for i in range(200)]
        result = hz.min_frac_diff(series)
        # min_frac_diff returns (d, results_list)
        d = result[0]
        assert 0.0 <= d <= 1.0

    def test_scan_results_sorted_by_d(self):
        series = [float(i) * 0.1 + math.sin(i * 0.05) for i in range(200)]
        d, results = hz.min_frac_diff(series)
        for i in range(1, len(results)):
            assert results[i][0] >= results[i - 1][0]

    def test_stationary_series_low_d(self):
        # Clearly mean-reverting AR(1) process => should need minimal differencing
        series = [0.0]
        for i in range(1, 500):
            noise = math.sin(i * 0.73) * 0.5
            series.append(0.3 * series[-1] + noise)
        d, _ = hz.min_frac_diff(series, n_steps=20)
        assert d <= 0.5

    def test_too_short_raises(self):
        with pytest.raises(ValueError):
            hz.min_frac_diff([1.0] * 5)

    def test_invalid_max_d_raises(self):
        series = [float(i) for i in range(50)]
        with pytest.raises(ValueError):
            hz.min_frac_diff(series, max_d=0.0)

    def test_custom_parameters(self):
        series = [float(i) + math.sin(i * 0.1) for i in range(200)]
        d, results = hz.min_frac_diff(series, p_threshold=0.05, max_d=2.0, n_steps=10, weight_threshold=1e-3)
        assert 0.0 <= d <= 2.0
        assert len(results) >= 1

    def test_returns_finite_adf_values(self):
        series = [math.log(i + 1) for i in range(200)]
        d, scan = hz.min_frac_diff(series)
        for di, adf_i in scan:
            assert math.isfinite(di)
            assert math.isfinite(adf_i) or adf_i == float("-inf")


class TestFracDiffIntegration:
    def test_ffd_reduces_adf(self):
        """Fractional differencing should make a non-stationary series more stationary."""
        # Unit root series (cumulative sum of noise)
        noise = [math.sin(i * 0.37) * 0.5 for i in range(300)]
        series = [0.0]
        for n in noise:
            series.append(series[-1] + n)

        diffed = hz.frac_diff_ffd(series, 0.5, threshold=1e-3)
        if len(diffed) >= 3:
            adf_orig = hz.adf_statistic(series)
            adf_diff = hz.adf_statistic(diffed)
            # Differenced should be more stationary (more negative ADF)
            assert adf_diff < adf_orig + 5  # generous margin


# ============================================================================
# 4. HRP + DENOISING (hrp.rs)
# ============================================================================


class TestHRPResultType:
    """Test that hz.HRPResult has all expected attributes."""

    def test_fields_exist(self):
        cov = [[1.0, 0.5], [0.5, 1.0]]
        result = hz.hrp_weights(cov)
        assert hasattr(result, "weights")
        assert hasattr(result, "sorted_indices")
        assert hasattr(result, "linkage")

    def test_repr(self):
        cov = [[1.0, 0.5], [0.5, 1.0]]
        result = hz.hrp_weights(cov)
        r = repr(result)
        assert "HRPResult" in r


class TestDenoisedCovType:
    """Test that hz.DenoisedCov has all expected attributes."""

    def test_fields_exist(self):
        cov = [[1.0, 0.5], [0.5, 1.0]]
        result = hz.denoise_covariance(cov, 100)
        assert hasattr(result, "covariance")
        assert hasattr(result, "eigenvalues")
        assert hasattr(result, "n_signals")
        assert hasattr(result, "n_noise")

    def test_repr(self):
        cov = [[1.0, 0.5], [0.5, 1.0]]
        result = hz.denoise_covariance(cov, 100)
        r = repr(result)
        assert "DenoisedCov" in r


class TestHRPWeights:
    def test_single_asset(self):
        result = hz.hrp_weights([[0.04]])
        assert len(result.weights) == 1
        assert result.weights[0] == pytest.approx(1.0)

    def test_two_equal_assets(self):
        cov = [[1.0, 0.5], [0.5, 1.0]]
        result = hz.hrp_weights(cov)
        assert len(result.weights) == 2
        total = sum(result.weights)
        assert total == pytest.approx(1.0, abs=1e-10)
        # Equal variance => roughly equal weights
        assert abs(result.weights[0] - result.weights[1]) < 0.1

    def test_weights_sum_to_one(self):
        cov = [
            [0.04, 0.006, 0.002],
            [0.006, 0.09, 0.009],
            [0.002, 0.009, 0.01],
        ]
        result = hz.hrp_weights(cov)
        assert sum(result.weights) == pytest.approx(1.0, abs=1e-10)

    def test_all_weights_positive(self):
        cov = [
            [0.04, 0.006, 0.002],
            [0.006, 0.09, 0.009],
            [0.002, 0.009, 0.01],
        ]
        result = hz.hrp_weights(cov)
        for w in result.weights:
            assert w > 0.0

    def test_identity_cov_equal_weights(self):
        n = 5
        result = hz.hrp_weights(make_identity_cov(n))
        assert len(result.weights) == n
        for w in result.weights:
            assert w == pytest.approx(0.2, abs=0.05)

    def test_low_var_gets_more_weight(self):
        cov = make_block_cov()
        result = hz.hrp_weights(cov)
        # Assets 0,1 have var=0.04; assets 2,3 have var=0.09
        low_var = result.weights[0] + result.weights[1]
        high_var = result.weights[2] + result.weights[3]
        assert low_var > high_var

    def test_linkage_length(self):
        cov = [
            [0.04, 0.006, 0.002],
            [0.006, 0.09, 0.009],
            [0.002, 0.009, 0.01],
        ]
        result = hz.hrp_weights(cov)
        assert len(result.linkage) == 2  # n-1 merges for n=3

    def test_sorted_indices_is_permutation(self):
        cov = make_block_cov()
        result = hz.hrp_weights(cov)
        sorted_copy = sorted(result.sorted_indices)
        assert sorted_copy == [0, 1, 2, 3]

    def test_empty_raises(self):
        with pytest.raises(ValueError):
            hz.hrp_weights([])

    def test_non_square_raises(self):
        with pytest.raises(ValueError):
            hz.hrp_weights([[1.0, 0.5], [0.5, 1.0, 0.3]])

    def test_negative_diagonal_raises(self):
        with pytest.raises(ValueError):
            hz.hrp_weights([[-1.0, 0.0], [0.0, 1.0]])

    def test_diagonal_cov_equal_var(self):
        cov = make_diagonal_cov([0.04, 0.04, 0.04])
        result = hz.hrp_weights(cov)
        for w in result.weights:
            assert w == pytest.approx(1.0 / 3, abs=0.05)

    def test_diagonal_cov_unequal_var(self):
        cov = make_diagonal_cov([0.01, 0.04, 0.16])
        result = hz.hrp_weights(cov)
        # Lowest variance (0.01) should get highest weight
        assert result.weights[0] > result.weights[1]
        assert result.weights[1] > result.weights[2]

    def test_larger_matrix(self):
        n = 8
        # Build a valid covariance: diagonal + small off-diagonal
        cov = [[0.0] * n for _ in range(n)]
        for i in range(n):
            cov[i][i] = 0.01 * (i + 1)
            for j in range(i + 1, n):
                val = 0.001
                cov[i][j] = val
                cov[j][i] = val
        result = hz.hrp_weights(cov)
        assert len(result.weights) == n
        assert sum(result.weights) == pytest.approx(1.0, abs=1e-8)
        for w in result.weights:
            assert w > 0.0


class TestDenoiseCov:
    def test_basic(self):
        cov = [[1.0, 0.5], [0.5, 1.0]]
        result = hz.denoise_covariance(cov, 100)
        assert len(result.covariance) == 2
        assert len(result.eigenvalues) == 2
        assert result.n_signals + result.n_noise == 2

    def test_preserves_symmetry(self):
        cov = [
            [0.04, 0.006, 0.002],
            [0.006, 0.09, 0.009],
            [0.002, 0.009, 0.01],
        ]
        result = hz.denoise_covariance(cov, 100)
        n = len(result.covariance)
        for i in range(n):
            for j in range(n):
                assert result.covariance[i][j] == pytest.approx(
                    result.covariance[j][i], abs=1e-10
                )

    def test_positive_diagonal(self):
        cov = make_block_cov()
        result = hz.denoise_covariance(cov, 200)
        for i in range(4):
            assert result.covariance[i][i] > 0.0

    def test_eigenvalues_non_negative(self):
        cov = make_block_cov()
        result = hz.denoise_covariance(cov, 200)
        for ev in result.eigenvalues:
            assert ev >= 0.0

    def test_eigenvalues_descending(self):
        cov = make_block_cov()
        result = hz.denoise_covariance(cov, 200)
        for i in range(1, len(result.eigenvalues)):
            assert result.eigenvalues[i] <= result.eigenvalues[i - 1] + 1e-10

    def test_n_signals_plus_noise_equals_n(self):
        cov = [
            [0.04, 0.006, 0.002],
            [0.006, 0.09, 0.009],
            [0.002, 0.009, 0.01],
        ]
        result = hz.denoise_covariance(cov, 100)
        assert result.n_signals + result.n_noise == 3

    def test_identity_classified_as_noise(self):
        # Identity cov: all eigenvalues equal (=1.0). Under Marcenko-Pastur,
        # equal eigenvalues match the random matrix null, so they are classified
        # as noise. This is correct behavior for pure-noise covariance.
        result = hz.denoise_covariance(make_identity_cov(3), 1000)
        assert result.n_signals + result.n_noise == 3
        # With identity cov, lambda_+ ~ sigma^2 * (1 + sqrt(N/T))^2 > 1.0
        # so all eigenvalues (=1.0) are below the threshold => noise
        assert result.n_noise == 3

    def test_single_asset(self):
        result = hz.denoise_covariance([[0.04]], 100)
        assert len(result.covariance) == 1
        assert result.n_signals == 1

    def test_too_few_observations_raises(self):
        with pytest.raises(ValueError):
            hz.denoise_covariance([[1.0, 0.5], [0.5, 1.0]], 1)

    def test_empty_raises(self):
        with pytest.raises(ValueError):
            hz.denoise_covariance([], 100)

    def test_denoised_cov_still_valid_for_hrp(self):
        """Denoised covariance should be usable as input to HRP."""
        cov = make_block_cov()
        denoised = hz.denoise_covariance(cov, 200)
        result = hz.hrp_weights(denoised.covariance)
        assert len(result.weights) == 4
        assert sum(result.weights) == pytest.approx(1.0, abs=1e-10)


class TestDetoneCov:
    def test_basic_reduces_trace(self):
        cov = [
            [0.04, 0.006, 0.002],
            [0.006, 0.09, 0.009],
            [0.002, 0.009, 0.01],
        ]
        detoned = hz.detone_covariance(cov)
        orig_trace = sum(cov[i][i] for i in range(3))
        det_trace = sum(detoned[i][i] for i in range(3))
        assert det_trace < orig_trace + 1e-10

    def test_preserves_symmetry(self):
        cov = [
            [0.04, 0.006, 0.002],
            [0.006, 0.09, 0.009],
            [0.002, 0.009, 0.01],
        ]
        detoned = hz.detone_covariance(cov)
        n = len(detoned)
        for i in range(n):
            for j in range(n):
                assert detoned[i][j] == pytest.approx(detoned[j][i], abs=1e-10)

    def test_single_asset_zeros_out(self):
        detoned = hz.detone_covariance([[0.04]])
        assert detoned[0][0] == pytest.approx(0.0, abs=1e-10)

    def test_identity_removes_one_eigenvalue(self):
        detoned = hz.detone_covariance(make_identity_cov(3))
        trace = sum(detoned[i][i] for i in range(3))
        # Removed one eigenvalue of 1.0 => trace ~ 2.0
        assert trace == pytest.approx(2.0, abs=0.1)

    def test_empty_raises(self):
        with pytest.raises(ValueError):
            hz.detone_covariance([])

    def test_2x2_case(self):
        cov = [[2.0, 1.0], [1.0, 2.0]]
        detoned = hz.detone_covariance(cov)
        assert len(detoned) == 2
        # Eigenvalues of [[2,1],[1,2]] are 3 and 1
        # Removing largest (3) => trace should be ~1.0
        trace = detoned[0][0] + detoned[1][1]
        assert trace == pytest.approx(1.0, abs=0.1)

    def test_diagonal_matrix(self):
        cov = make_diagonal_cov([4.0, 2.0, 1.0])
        detoned = hz.detone_covariance(cov)
        # Should have smaller trace (removed the 4.0 eigenvalue)
        orig_trace = 7.0
        det_trace = sum(detoned[i][i] for i in range(3))
        assert det_trace < orig_trace
        assert det_trace == pytest.approx(3.0, abs=0.5)  # 2.0 + 1.0 roughly


class TestHRPWithDenoisedCov:
    """Integration test: denoise then HRP."""

    def test_full_pipeline(self):
        cov = make_block_cov()
        denoised = hz.denoise_covariance(cov, 200)
        hrp = hz.hrp_weights(denoised.covariance)
        assert len(hrp.weights) == 4
        assert sum(hrp.weights) == pytest.approx(1.0, abs=1e-10)
        for w in hrp.weights:
            assert w > 0.0

    def test_detone_then_hrp(self):
        """Detone can produce zero/near-zero diagonal, but HRP should handle it."""
        cov = make_block_cov()
        detoned = hz.detone_covariance(cov)
        # Diagonal may be near-zero; only run if all diag > 0
        all_positive = all(detoned[i][i] > 0 for i in range(4))
        if all_positive:
            hrp = hz.hrp_weights(detoned)
            assert sum(hrp.weights) == pytest.approx(1.0, abs=1e-10)


# ============================================================================
# Cross-module integration tests
# ============================================================================


class TestCrossModuleIntegration:
    def test_bars_to_labeling(self):
        """Generate bars from tick data, extract close prices, then label."""
        n = 500
        ts = make_timestamps(n)
        prices = make_random_walk(n, start=100.0, seed=7)
        vol = make_volumes_constant(n, 10.0)

        bars = hz.tick_bars(ts, prices, vol, 10)
        assert len(bars) >= 5

        close_prices = [b.close for b in bars]
        bar_ts = [b.timestamp for b in bars]

        if len(close_prices) >= 20:
            events = hz.cusum_filter(close_prices, 1.0)
            if len(events) > 0:
                labels = hz.triple_barrier_labels(
                    close_prices, bar_ts, events, [1.0, 1.0], 0.0, 10, 5
                )
                assert len(labels) == len(events)

    def test_fracdiff_then_adf(self):
        """Apply fractional differencing and verify ADF improves."""
        series = [math.log(100.0 + i * 0.5) for i in range(300)]
        d_opt, scan = hz.min_frac_diff(series, n_steps=10)
        assert d_opt >= 0.0
        # Check that ADF at optimal d is below critical value (or close)
        if len(scan) > 0:
            last_adf = scan[-1][1]
            assert math.isfinite(last_adf) or last_adf == float("-inf")
