"""Tests for all arbitrage methods (parity, event, spread, stat, mm, latency, composite).

~125 tests covering:
- Rust types and functions (parity arb, latency arb, cointegration, spread zscore)
- Engine methods (scan_parity_arb, execute_parity_arb, scan_event_arb, etc.)
- Python scanners and sweeps
- Backward compatibility
"""

import os
import time
import math
import pytest

os.environ.setdefault("HORIZON_API_KEY", "test-key-123")

from horizon._horizon import (
    Engine,
    RiskConfig,
    Side,
    OrderSide,
    FeedSnapshot,
    ParityArbitrageOpportunity,
    LatencyArbOpportunity,
    cointegration_test,
    spread_zscore,
)
from horizon.context import Context, FeedData, InventorySnapshot
from horizon.arb import (
    ArbResult,
    EventArbResult,
    SpreadSignal,
    StatArbResult,
    CompositeArbResult,
    arb_scanner,
    arb_sweep,
    parity_arb_scanner,
    parity_arb_sweep,
    event_arb_scanner,
    event_arb_sweep,
    spread_convergence,
    StatArbConfig,
    stat_arb,
    MMArbConfig,
    mm_arb,
    latency_arb,
    ArbMethodConfig,
    composite_arb,
)


@pytest.fixture
def engine():
    config = RiskConfig(
        max_position_per_market=1000.0,
        max_portfolio_notional=10000.0,
        max_order_size=500.0,
    )
    return Engine(risk_config=config, paper_fee_rate=0.0)


@pytest.fixture
def ctx(engine):
    return Context(
        feeds={},
        inventory=InventorySnapshot(),
        params={"engine": engine},
    )


# ===========================================================================
# Rust type tests: ParityArbitrageOpportunity
# ===========================================================================


class TestParityArbitrageOpportunityType:
    def test_type_exists(self):
        assert ParityArbitrageOpportunity is not None

    def test_repr(self):
        # Can't construct directly from Python, but type is importable
        assert "ParityArbitrageOpportunity" in str(ParityArbitrageOpportunity)


# ===========================================================================
# Rust type tests: LatencyArbOpportunity
# ===========================================================================


class TestLatencyArbOpportunityType:
    def test_type_exists(self):
        assert LatencyArbOpportunity is not None

    def test_repr(self):
        assert "LatencyArbOpportunity" in str(LatencyArbOpportunity)


# ===========================================================================
# Rust function tests: cointegration_test
# ===========================================================================


class TestCointegrationTest:
    def test_basic_cointegration(self):
        a = [float(i) for i in range(1, 21)]
        b = [float(i) + 0.1 for i in range(1, 21)]
        ratio, residuals, adf = cointegration_test(a, b)
        assert abs(ratio - 1.0) < 0.2
        assert len(residuals) == 20
        assert math.isfinite(adf)

    def test_perfect_correlation(self):
        a = [1.0, 2.0, 3.0, 4.0, 5.0]
        b = [2.0, 4.0, 6.0, 8.0, 10.0]
        ratio, residuals, adf = cointegration_test(a, b)
        assert abs(ratio - 0.5) < 0.01  # a = 0.5 * b
        assert len(residuals) == 5

    def test_unequal_lengths(self):
        with pytest.raises(ValueError):
            cointegration_test([1.0, 2.0, 3.0], [1.0, 2.0])

    def test_too_short(self):
        with pytest.raises(ValueError):
            cointegration_test([1.0, 2.0], [1.0, 2.0])

    def test_nan_input(self):
        with pytest.raises(ValueError):
            cointegration_test([1.0, float("nan"), 3.0, 4.0, 5.0], [1.0, 2.0, 3.0, 4.0, 5.0])

    def test_zero_variance_b(self):
        with pytest.raises(ValueError):
            cointegration_test([1.0, 2.0, 3.0, 4.0, 5.0], [5.0, 5.0, 5.0, 5.0, 5.0])

    def test_residuals_sum_near_zero(self):
        a = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]
        b = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]
        ratio, residuals, adf = cointegration_test(a, b)
        assert abs(ratio - 1.0) < 1e-10
        for r in residuals:
            assert abs(r) < 1e-10

    def test_negative_adf_strong(self):
        # Random walk pair - ADF should be less negative (non-stationary)
        import random
        random.seed(42)
        a = [0.0]
        b = [0.0]
        for _ in range(99):
            a.append(a[-1] + random.gauss(0, 1))
            b.append(b[-1] + random.gauss(0, 1))
        _, _, adf = cointegration_test(a, b)
        assert math.isfinite(adf)

    def test_many_observations(self):
        a = [float(i) * 0.01 for i in range(200)]
        b = [float(i) * 0.01 + 0.5 for i in range(200)]
        ratio, residuals, adf = cointegration_test(a, b)
        assert len(residuals) == 200
        assert math.isfinite(ratio)


# ===========================================================================
# Rust function tests: spread_zscore
# ===========================================================================


class TestSpreadZscore:
    def test_basic(self):
        residuals = [0.0, 0.0, 0.0, 0.0, 10.0]
        z = spread_zscore(residuals, 5)
        assert z > 1.0

    def test_empty(self):
        assert spread_zscore([], 10) == 0.0

    def test_zero_lookback(self):
        assert spread_zscore([1.0, 2.0], 0) == 0.0

    def test_constant(self):
        assert spread_zscore([5.0, 5.0, 5.0, 5.0, 5.0], 5) == 0.0

    def test_negative_zscore(self):
        residuals = [10.0, 10.0, 10.0, 10.0, 0.0]
        z = spread_zscore(residuals, 5)
        assert z < -1.0

    def test_lookback_window(self):
        residuals = [100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0]
        # With lookback=3, only last 3 values matter (all 0.0)
        z = spread_zscore(residuals, 3)
        assert z == 0.0

    def test_single_value(self):
        z = spread_zscore([5.0], 10)
        assert z == 0.0

    def test_two_values(self):
        z = spread_zscore([0.0, 10.0], 10)
        # Should be positive: 10 is above mean of 5
        assert z > 0.0

    def test_lookback_larger_than_data(self):
        residuals = [0.0, 0.0, 10.0]
        z = spread_zscore(residuals, 100)
        assert z > 0.0


# ===========================================================================
# Engine: scan_parity_arb
# ===========================================================================


class TestScanParityArb:
    def test_no_feed_manager(self, engine):
        result = engine.scan_parity_arb("mkt1", "feed1")
        assert result is None

    def test_no_feed(self, engine):
        result = engine.scan_parity_arb("mkt1", "nonexistent", 0.002, 50.0)
        assert result is None


# ===========================================================================
# Engine: execute_parity_arb
# ===========================================================================


class TestExecuteParityArb:
    def test_basic_execution(self, engine):
        yes_id, no_id = engine.execute_parity_arb(
            "mkt1", "paper", 0.45, 0.48, 10.0,
        )
        assert yes_id
        assert no_id
        assert yes_id != no_id

    def test_rollback_on_risk_reject(self):
        config = RiskConfig(max_order_size=5.0)
        engine = Engine(risk_config=config)
        with pytest.raises(Exception):
            engine.execute_parity_arb(
                "mkt1", "paper", 0.45, 0.48, 100.0,
            )

    def test_with_token_id(self, engine):
        yes_id, no_id = engine.execute_parity_arb(
            "mkt1", "paper", 0.45, 0.48, 10.0,
            token_id="tok123",
        )
        assert yes_id and no_id


# ===========================================================================
# Engine: scan_event_arb
# ===========================================================================


class TestScanEventArb:
    def test_no_event(self, engine):
        result = engine.scan_event_arb("unknown_event")
        assert result is None

    def test_registered_event_no_feeds(self, engine):
        engine.register_event("election", ["mkt_a", "mkt_b", "mkt_c"])
        result = engine.scan_event_arb("election")
        assert result is None


# ===========================================================================
# Engine: execute_event_arb
# ===========================================================================


class TestExecuteEventArb:
    def test_unknown_event(self, engine):
        with pytest.raises(ValueError):
            engine.execute_event_arb("unknown", "buy_all", 10.0)

    def test_invalid_direction(self, engine):
        engine.register_event("ev1", ["m1", "m2"])
        with pytest.raises(ValueError):
            engine.execute_event_arb("ev1", "invalid", 10.0)

    def test_basic_buy_all(self, engine):
        engine.register_event("ev1", ["m1", "m2", "m3"])
        order_ids = engine.execute_event_arb("ev1", "buy_all", 30.0)
        assert len(order_ids) == 3
        assert all(oid for oid in order_ids)

    def test_sell_all(self, engine):
        engine.register_event("ev1", ["m1", "m2"])
        order_ids = engine.execute_event_arb("ev1", "sell_all", 20.0)
        assert len(order_ids) == 2

    def test_proportional_sizing(self, engine):
        engine.register_event("ev1", ["m1", "m2"])
        order_ids = engine.execute_event_arb(
            "ev1", "buy_all", 50.0, proportional=True,
        )
        assert len(order_ids) == 2


# ===========================================================================
# Engine: scan_latency_arb
# ===========================================================================


class TestScanLatencyArb:
    def test_no_feeds(self, engine):
        result = engine.scan_latency_arb("mkt1", "ref_feed", "mkt_feed")
        assert result is None


# ===========================================================================
# Python: ArbResult types
# ===========================================================================


class TestArbResultTypes:
    def test_arb_result(self):
        r = ArbResult(
            market_id="m1", buy_exchange="a", sell_exchange="b",
            buy_price=0.48, sell_price=0.52, size=10.0,
            raw_edge=0.04, net_edge=0.02,
        )
        assert r.market_id == "m1"
        assert r.timestamp > 0

    def test_event_arb_result(self):
        r = EventArbResult(
            event_id="ev1", direction="buy_all",
            price_sum=0.90, net_edge=0.05, size=50.0,
            order_ids=["o1", "o2"],
        )
        assert r.event_id == "ev1"
        assert r.direction == "buy_all"
        assert len(r.order_ids) == 2

    def test_spread_signal(self):
        s = SpreadSignal(
            market_a="m1", market_b="m2",
            spread=0.05, spread_mean=0.02, spread_std=0.01,
            zscore=3.0, signal="long_b_short_a",
        )
        assert s.signal == "long_b_short_a"
        assert s.zscore == 3.0

    def test_stat_arb_result(self):
        r = StatArbResult(
            pair=("m1", "m2"), hedge_ratio=0.95,
            zscore=2.5, half_life=20.0, adf_stat=-3.5,
            signal="long_a_short_b",
        )
        assert r.pair == ("m1", "m2")
        assert r.hedge_ratio == 0.95

    def test_composite_arb_result(self):
        r = CompositeArbResult(
            method="parity", score=0.15,
            net_edge=0.03, capital_needed=100.0,
        )
        assert r.method == "parity"
        assert r.score == 0.15


# ===========================================================================
# Python: parity_arb_scanner
# ===========================================================================


class TestParityArbScanner:
    def test_no_engine(self):
        scanner = parity_arb_scanner("mkt1")
        ctx = Context(feeds={}, inventory=InventorySnapshot(), params={})
        result = scanner(ctx)
        assert result is None

    def test_no_feeds(self, ctx):
        scanner = parity_arb_scanner("mkt1")
        result = scanner(ctx)
        assert result is None

    def test_name(self):
        scanner = parity_arb_scanner("mkt1")
        assert scanner.__name__ == "parity_arb_scanner"

    def test_with_custom_params(self):
        scanner = parity_arb_scanner(
            "mkt1", exchange="paper", min_edge=0.01,
            max_size=100.0, fee_rate=0.005,
        )
        assert scanner.__name__ == "parity_arb_scanner"


# ===========================================================================
# Python: parity_arb_sweep
# ===========================================================================


class TestParityArbSweep:
    def test_no_feed(self, engine):
        result = parity_arb_sweep(engine, "mkt1")
        assert result is None

    def test_custom_params(self, engine):
        result = parity_arb_sweep(
            engine, "mkt1", exchange="paper",
            min_edge=0.001, max_size=100.0,
        )
        assert result is None


# ===========================================================================
# Python: event_arb_scanner
# ===========================================================================


class TestEventArbScanner:
    def test_no_engine(self):
        scanner = event_arb_scanner("ev1")
        ctx = Context(feeds={}, inventory=InventorySnapshot(), params={})
        result = scanner(ctx)
        assert result is None

    def test_no_event(self, ctx):
        scanner = event_arb_scanner("unknown_event")
        result = scanner(ctx)
        assert result is None

    def test_name(self):
        scanner = event_arb_scanner("ev1")
        assert scanner.__name__ == "event_arb_scanner"


# ===========================================================================
# Python: event_arb_sweep
# ===========================================================================


class TestEventArbSweep:
    def test_no_event(self, engine):
        result = event_arb_sweep(engine, "unknown_event")
        assert result is None

    def test_registered_no_feeds(self, engine):
        engine.register_event("ev1", ["m1", "m2"])
        result = event_arb_sweep(engine, "ev1")
        assert result is None


# ===========================================================================
# Python: spread_convergence
# ===========================================================================


class TestSpreadConvergence:
    def test_no_engine(self):
        scanner = spread_convergence("m1", "m2", "f1", "f2")
        ctx = Context(feeds={}, inventory=InventorySnapshot(), params={})
        result = scanner(ctx)
        assert result is None

    def test_no_feeds(self, ctx):
        scanner = spread_convergence("m1", "m2", "f1", "f2")
        result = scanner(ctx)
        assert result is None

    def test_name(self):
        scanner = spread_convergence("m1", "m2", "f1", "f2")
        assert scanner.__name__ == "spread_convergence"

    def test_custom_params(self):
        scanner = spread_convergence(
            "m1", "m2", "f1", "f2",
            lookback=50, entry_zscore=3.0, exit_zscore=1.0,
            max_spread=0.10, size=5.0,
        )
        assert scanner.__name__ == "spread_convergence"


# ===========================================================================
# Python: StatArbConfig + stat_arb
# ===========================================================================


class TestStatArb:
    def test_config_creation(self):
        config = StatArbConfig(
            pair=("m1", "m2"),
            feeds=("f1", "f2"),
        )
        assert config.pair == ("m1", "m2")
        assert config.lookback == 200
        assert config.entry_zscore == 2.0

    def test_config_custom_params(self):
        config = StatArbConfig(
            pair=("m1", "m2"), feeds=("f1", "f2"),
            lookback=100, entry_zscore=1.5, exit_zscore=0.3,
            stop_zscore=5.0, recalibrate_every=25,
            min_half_life=10.0, max_half_life=100.0,
        )
        assert config.lookback == 100
        assert config.min_half_life == 10.0

    def test_no_engine(self):
        config = StatArbConfig(pair=("m1", "m2"), feeds=("f1", "f2"))
        scanner = stat_arb(config)
        ctx = Context(feeds={}, inventory=InventorySnapshot(), params={})
        result = scanner(ctx)
        assert result is None

    def test_no_feeds(self, ctx):
        config = StatArbConfig(pair=("m1", "m2"), feeds=("f1", "f2"))
        scanner = stat_arb(config)
        result = scanner(ctx)
        assert result is None

    def test_name(self):
        config = StatArbConfig(pair=("m1", "m2"), feeds=("f1", "f2"))
        scanner = stat_arb(config)
        assert scanner.__name__ == "stat_arb"


# ===========================================================================
# Python: MMArbConfig + mm_arb
# ===========================================================================


class TestMmArb:
    def test_config_creation(self):
        config = MMArbConfig(
            quote_exchange="paper",
            hedge_exchange="poly",
            quote_feed="f1",
            hedge_feed="f2",
        )
        assert config.quote_exchange == "paper"
        assert config.base_spread == 0.04

    def test_config_custom(self):
        config = MMArbConfig(
            quote_exchange="paper", hedge_exchange="poly",
            quote_feed="f1", hedge_feed="f2",
            base_spread=0.06, gamma=0.3,
            max_position=50.0, hedge_threshold=5.0,
        )
        assert config.base_spread == 0.06
        assert config.gamma == 0.3

    def test_no_engine(self):
        config = MMArbConfig(
            quote_exchange="paper", hedge_exchange="poly",
            quote_feed="f1", hedge_feed="f2",
        )
        quoter = mm_arb(config)
        ctx = Context(feeds={}, inventory=InventorySnapshot(), params={})
        result = quoter(ctx)
        assert result == []

    def test_name(self):
        config = MMArbConfig(
            quote_exchange="paper", hedge_exchange="poly",
            quote_feed="f1", hedge_feed="f2",
        )
        quoter = mm_arb(config)
        assert quoter.__name__ == "mm_arb"

    def test_returns_list(self):
        config = MMArbConfig(
            quote_exchange="paper", hedge_exchange="poly",
            quote_feed="f1", hedge_feed="f2",
        )
        quoter = mm_arb(config)
        ctx = Context(feeds={}, inventory=InventorySnapshot(), params={})
        result = quoter(ctx)
        assert isinstance(result, list)


# ===========================================================================
# Python: latency_arb
# ===========================================================================


class TestLatencyArb:
    def test_no_engine(self):
        scanner = latency_arb("mkt1", "ref", "mkt")
        ctx = Context(feeds={}, inventory=InventorySnapshot(), params={})
        result = scanner(ctx)
        assert result is None

    def test_no_feeds(self, ctx):
        scanner = latency_arb("mkt1", "ref", "mkt")
        result = scanner(ctx)
        assert result is None

    def test_name(self):
        scanner = latency_arb("mkt1", "ref", "mkt")
        assert scanner.__name__ == "latency_arb"

    def test_custom_params(self):
        scanner = latency_arb(
            "mkt1", "ref", "mkt",
            sensitivity=0.5, min_staleness_ms=1000.0,
            min_edge=0.05, max_size=10.0,
            max_daily_trades=20,
        )
        assert scanner.__name__ == "latency_arb"

    def test_daily_trade_limit(self):
        """Scanner respects max_daily_trades."""
        scanner = latency_arb("mkt1", "ref", "mkt", max_daily_trades=0)
        engine = Engine()
        ctx = Context(
            feeds={}, inventory=InventorySnapshot(),
            params={"engine": engine},
        )
        result = scanner(ctx)
        assert result is None


# ===========================================================================
# Python: ArbMethodConfig + composite_arb
# ===========================================================================


class TestCompositeArb:
    def test_method_config(self):
        mc = ArbMethodConfig(method="parity", weight=2.0, max_capital=1000.0)
        assert mc.method == "parity"
        assert mc.weight == 2.0
        assert mc.enabled is True

    def test_method_config_disabled(self):
        mc = ArbMethodConfig(method="latency", enabled=False)
        assert mc.enabled is False

    def test_no_engine(self):
        methods = [ArbMethodConfig(method="parity")]
        scanner = composite_arb(methods)
        ctx = Context(feeds={}, inventory=InventorySnapshot(), params={})
        result = scanner(ctx)
        assert result is None

    def test_name(self):
        methods = [ArbMethodConfig(method="parity")]
        scanner = composite_arb(methods)
        assert scanner.__name__ == "composite_arb"

    def test_empty_methods(self):
        scanner = composite_arb([])
        engine = Engine()
        ctx = Context(
            feeds={}, inventory=InventorySnapshot(),
            params={"engine": engine},
        )
        result = scanner(ctx)
        assert result is None

    def test_all_methods_disabled(self):
        methods = [
            ArbMethodConfig(method="parity", enabled=False),
            ArbMethodConfig(method="cross_exchange", enabled=False),
        ]
        scanner = composite_arb(methods)
        engine = Engine()
        ctx = Context(
            feeds={}, inventory=InventorySnapshot(),
            params={"engine": engine},
        )
        result = scanner(ctx)
        assert result is None

    def test_scoring_with_parity_result(self):
        """Composite scanner picks up parity results from params."""
        methods = [ArbMethodConfig(method="parity", weight=1.0)]
        scanner = composite_arb(methods, rebalance_interval=0)
        engine = Engine()
        # Simulate a parity arb opportunity in params
        from horizon._horizon import ParityArbitrageOpportunity

        ctx = Context(
            feeds={}, inventory=InventorySnapshot(),
            params={"engine": engine},
        )
        # No real opportunity, so composite returns empty
        scanner(ctx)
        composite_result = ctx.params.get("last_composite_arb", [])
        assert isinstance(composite_result, list)


# ===========================================================================
# Backward compatibility tests
# ===========================================================================


class TestBackwardCompat:
    def test_import_from_horizon_arb(self):
        from horizon.arb import ArbResult, arb_scanner, arb_sweep
        assert ArbResult is not None
        assert arb_scanner is not None
        assert arb_sweep is not None

    def test_import_from_horizon(self):
        from horizon import ArbResult, arb_scanner, arb_sweep
        assert ArbResult is not None

    def test_import_new_from_horizon(self):
        from horizon import (
            parity_arb_scanner, parity_arb_sweep,
            event_arb_scanner, event_arb_sweep,
            spread_convergence, stat_arb, mm_arb,
            latency_arb, composite_arb,
            StatArbConfig, MMArbConfig, ArbMethodConfig,
            EventArbResult, SpreadSignal, StatArbResult,
            CompositeArbResult,
        )
        assert parity_arb_scanner is not None
        assert StatArbConfig is not None

    def test_import_new_from_arb_package(self):
        from horizon.arb import (
            parity_arb_scanner, event_arb_scanner,
            spread_convergence, stat_arb, mm_arb,
            latency_arb, composite_arb,
        )
        assert parity_arb_scanner is not None

    def test_rust_types_from_horizon(self):
        from horizon import (
            ParityArbitrageOpportunity,
            LatencyArbOpportunity,
            cointegration_test,
            spread_zscore,
        )
        assert ParityArbitrageOpportunity is not None
        assert cointegration_test is not None


# ===========================================================================
# Integration: cointegration + spread_zscore pipeline
# ===========================================================================


class TestStatArbIntegration:
    def test_cointegration_then_zscore(self):
        """Full stat arb pipeline: cointegration -> residuals -> zscore."""
        import random
        random.seed(99)
        a = [float(i) + 0.5 + random.gauss(0, 0.1) for i in range(50)]
        b = [float(i) + random.gauss(0, 0.1) for i in range(50)]
        ratio, residuals, adf = cointegration_test(a, b)
        z = spread_zscore(residuals, 20)
        assert math.isfinite(z)
        assert adf < 0  # Should be negative (stationary or -inf)
        assert abs(ratio - 1.0) < 0.2

    def test_mean_reverting_pair(self):
        """Test with a pair that mean-reverts (should have negative ADF)."""
        import random
        random.seed(123)
        b = [50.0 + random.gauss(0, 1) for _ in range(100)]
        # a tracks b with noise + offset
        a = [bi + 0.5 + random.gauss(0, 0.1) for bi in b]
        ratio, residuals, adf = cointegration_test(a, b)
        # Residuals should be near-stationary
        assert abs(ratio - 1.0) < 0.5
        # ADF should be strongly negative
        assert adf < 0


# ===========================================================================
# Edge cases
# ===========================================================================


class TestEdgeCases:
    def test_parity_arb_scanner_cooldown(self):
        """Scanner respects cooldown."""
        scanner = parity_arb_scanner("mkt1", cooldown=1000.0, auto_execute=True)
        ctx = Context(
            feeds={}, inventory=InventorySnapshot(),
            params={"engine": Engine()},
        )
        # Should not crash even with no feeds
        result = scanner(ctx)
        assert result is None

    def test_event_arb_scanner_cooldown(self):
        scanner = event_arb_scanner("ev1", cooldown=1000.0, auto_execute=True)
        ctx = Context(
            feeds={}, inventory=InventorySnapshot(),
            params={"engine": Engine()},
        )
        result = scanner(ctx)
        assert result is None

    def test_spread_convergence_max_spread(self):
        """Spread exceeding max_spread is ignored."""
        scanner = spread_convergence(
            "m1", "m2", "f1", "f2", max_spread=0.01,
        )
        ctx = Context(
            feeds={}, inventory=InventorySnapshot(),
            params={"engine": Engine()},
        )
        result = scanner(ctx)
        assert result is None

    def test_stat_arb_recalibrate_interval(self):
        config = StatArbConfig(
            pair=("m1", "m2"), feeds=("f1", "f2"),
            recalibrate_every=1,
        )
        scanner = stat_arb(config)
        ctx = Context(
            feeds={}, inventory=InventorySnapshot(),
            params={"engine": Engine()},
        )
        result = scanner(ctx)
        assert result is None

    def test_mm_arb_no_feed(self, ctx):
        config = MMArbConfig(
            quote_exchange="paper", hedge_exchange="poly",
            quote_feed="nonexistent", hedge_feed="also_nonexistent",
        )
        quoter = mm_arb(config)
        result = quoter(ctx)
        assert result == []

    def test_composite_rebalance_interval(self):
        """Composite respects rebalance_interval."""
        methods = [ArbMethodConfig(method="parity")]
        scanner = composite_arb(methods, rebalance_interval=1000.0)
        engine = Engine()
        ctx = Context(
            feeds={}, inventory=InventorySnapshot(),
            params={"engine": engine},
        )
        # First call should run (last_rebalance starts at 0)
        scanner(ctx)
        # Second immediate call should be skipped
        scanner(ctx)
