"""Tests for the 10 new fund quant modules.

Tests each module directly without going through FundManager (which requires
auth_require_ultra). All tests are self-contained and require no network access.

Modules tested:
    1. _regime.py - RegimeDetector, RegimeSnapshot
    2. _alpha_decay.py - AlphaDecayTracker, DecayProfile, DecayAlert
    3. _alpha_model.py - AlphaModel, AlphaEstimate, FactorValue, FactorIC
    4. _hypothesis.py - HypothesisManager, Hypothesis, HypothesisState
    5. _signal_ensemble.py - SignalEnsemble, Signal, EnsembleOutput, SignalQuality
    6. _research_intel.py - ResearchIntelligence, ResearchTrigger, CrossMarketOpportunity
    7. _portfolio_optimizer.py - PortfolioOptimizer, OptimizationConstraints, OptimizationResult
    8. _risk_analytics.py - RiskAnalytics, DynamicLimits, TailRiskReport, PortfolioGreeksReport
    9. _performance_attribution.py - PerformanceAttribution, PerformanceSnapshot, AlphaBetaDecomposition
    10. _execution_intelligence.py - ExecutionIntelligence, ToxicityAlert, InventoryRiskReport, ExecutionSchedule

Plus wiring tests for _research.py, _adaptive.py, _autopilot.py.
"""

import math
import random
import time

import pytest

# All fund quant modules import from horizon._horizon (the Rust extension).
# If the extension is not built, skip the entire module.
horizon_ext = pytest.importorskip(
    "horizon._horizon",
    reason="Rust extension not built (run `maturin develop --features skip-auth`)",
)

from horizon.fund._regime import RegimeDetector, RegimeSnapshot
from horizon.fund._alpha_decay import AlphaDecayTracker, DecayProfile, DecayAlert
from horizon.fund._alpha_model import AlphaModel, AlphaEstimate, FactorValue, FactorIC
from horizon.fund._hypothesis import HypothesisManager, Hypothesis, HypothesisState
from horizon.fund._signal_ensemble import SignalEnsemble, Signal, EnsembleOutput, SignalQuality
from horizon.fund._research_intel import (
    ResearchIntelligence,
    ResearchTrigger,
    CrossMarketOpportunity,
    TRIGGER_REGIME_CHANGE,
    TRIGGER_SETTLEMENT_CASCADE,
    TRIGGER_NEW_MARKET,
    TRIGGER_VOLUME_SPIKE,
    TRIGGER_CROSS_MARKET_DIVERGENCE,
)
from horizon.fund._portfolio_optimizer import (
    PortfolioOptimizer,
    OptimizationConstraints,
    OptimizationResult,
    RebalanceTrigger,
)
from horizon.fund._risk_analytics import (
    RiskAnalytics,
    DynamicLimits,
    TailRiskReport,
    PortfolioGreeksReport,
)
from horizon.fund._performance_attribution import (
    PerformanceAttribution,
    PerformanceSnapshot,
    AlphaBetaDecomposition,
    StrategyAttribution,
    CostAttribution,
)
from horizon.fund._execution_intelligence import (
    ExecutionIntelligence,
    ToxicityAlert,
    InventoryRiskReport,
    ExecutionSchedule,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _synthetic_returns(n: int, mu: float = 0.0, sigma: float = 0.01, seed: int = 42) -> list[float]:
    """Generate reproducible synthetic returns."""
    rng = random.Random(seed)
    return [rng.gauss(mu, sigma) for _ in range(n)]


def _declining_edges(n: int, start: float = 0.10, decay_rate: float = 0.03) -> list[float]:
    """Generate a monotonically declining edge series."""
    return [start * math.exp(-decay_rate * i) for i in range(n)]


# ===========================================================================
# 1. RegimeDetector
# ===========================================================================

class TestRegimeDetector:
    """Tests for RegimeDetector and RegimeSnapshot."""

    def test_constructor(self):
        det = RegimeDetector()
        assert det.current_regime() is None

    def test_update_insufficient_data(self):
        det = RegimeDetector()
        snap = det.update([0.01, -0.01, 0.005])
        assert isinstance(snap, RegimeSnapshot)
        # With < 30 observations the HMM will not fit, so regime is a fallback
        assert isinstance(snap.regime, str)

    def test_update_with_enough_data(self):
        det = RegimeDetector()
        returns = _synthetic_returns(50, mu=0.001, sigma=0.02)
        snap = det.update(returns)
        assert isinstance(snap, RegimeSnapshot)
        assert snap.regime in ("quiet", "volatile", "trending_up", "trending_down", "mean_reverting")
        assert isinstance(snap.confidence, float)
        assert isinstance(snap.volatility, float)
        assert isinstance(snap.trend_strength, float)
        assert isinstance(snap.mean_reversion_score, float)
        assert snap.timestamp > 0

    def test_current_regime_after_update(self):
        det = RegimeDetector()
        det.update(_synthetic_returns(50))
        snap = det.current_regime()
        assert snap is not None
        assert isinstance(snap, RegimeSnapshot)

    def test_regime_history(self):
        det = RegimeDetector()
        det.update(_synthetic_returns(50, seed=1))
        det.update(_synthetic_returns(50, seed=2))
        det.update(_synthetic_returns(50, seed=3))
        history = det.regime_history(limit=10)
        assert len(history) == 3
        assert all(isinstance(s, RegimeSnapshot) for s in history)

    def test_regime_history_limit(self):
        det = RegimeDetector()
        for i in range(10):
            det.update(_synthetic_returns(35, seed=i))
        history = det.regime_history(limit=5)
        assert len(history) == 5

    def test_nan_values_filtered(self):
        det = RegimeDetector()
        returns = _synthetic_returns(50)
        returns[10] = float("nan")
        returns[20] = float("inf")
        snap = det.update(returns)
        assert isinstance(snap, RegimeSnapshot)

    def test_volatile_regime_with_high_vol(self):
        det = RegimeDetector()
        # Large volatile returns
        returns = _synthetic_returns(60, mu=0.0, sigma=0.15)
        snap = det.update(returns)
        assert isinstance(snap.regime, str)

    def test_multiple_updates_accumulate_data(self):
        det = RegimeDetector(lookback=500)
        for i in range(5):
            det.update(_synthetic_returns(20, seed=i))
        snap = det.current_regime()
        assert snap is not None


# ===========================================================================
# 2. AlphaDecayTracker
# ===========================================================================

class TestAlphaDecayTracker:
    """Tests for AlphaDecayTracker, DecayProfile, and DecayAlert."""

    def test_constructor(self):
        tracker = AlphaDecayTracker(min_observations=10)
        assert tracker.decay_profiles() == []

    def test_record_edge(self):
        tracker = AlphaDecayTracker(min_observations=5)
        for i in range(10):
            tracker.record_edge("strat_a", 0.10 - 0.005 * i, timestamp=1000.0 + i * 86400)
        profiles = tracker.decay_profiles()
        assert len(profiles) == 1
        assert profiles[0].strategy_name == "strat_a"

    def test_fit_decay_insufficient_data(self):
        tracker = AlphaDecayTracker(min_observations=10)
        tracker.record_edge("strat_a", 0.10, timestamp=1000.0)
        assert tracker.fit_decay("strat_a") is None

    def test_fit_decay_declining_edges(self):
        tracker = AlphaDecayTracker(min_observations=5)
        edges = _declining_edges(20, start=0.10, decay_rate=0.05)
        for i, edge_val in enumerate(edges):
            tracker.record_edge("strat_a", edge_val, timestamp=1000.0 + i * 86400)
        profile = tracker.fit_decay("strat_a")
        assert profile is not None
        assert isinstance(profile, DecayProfile)
        assert profile.strategy_name == "strat_a"
        assert profile.initial_edge > 0
        assert profile.current_edge > 0
        assert profile.current_edge < profile.initial_edge
        assert profile.half_life_days > 0
        assert 0.0 <= profile.r_squared <= 1.0
        assert profile.edge_type in ("structural", "temporary", "unknown")

    def test_should_retire_below_threshold(self):
        tracker = AlphaDecayTracker(min_observations=5)
        # Declining from 0.10 down to near zero
        edges = _declining_edges(30, start=0.10, decay_rate=0.15)
        for i, edge_val in enumerate(edges):
            tracker.record_edge("strat_a", edge_val, timestamp=1000.0 + i * 86400)
        # The last edge is very small
        assert edges[-1] < 0.01
        result = tracker.should_retire("strat_a", min_edge=0.01)
        assert isinstance(result, bool)

    def test_should_retire_unknown_strategy(self):
        tracker = AlphaDecayTracker()
        assert tracker.should_retire("nonexistent") is False

    def test_check_alerts_approaching_zero(self):
        tracker = AlphaDecayTracker(min_observations=5)
        edges = _declining_edges(20, start=0.05, decay_rate=0.10)
        for i, edge_val in enumerate(edges):
            tracker.record_edge("strat_a", edge_val, timestamp=1000.0 + i * 86400)
        alerts = tracker.check_alerts(min_edge=0.02)
        assert isinstance(alerts, list)
        for alert in alerts:
            assert isinstance(alert, DecayAlert)
            assert alert.strategy_name == "strat_a"
            assert alert.alert_type in ("approaching_zero", "half_life_reached", "edge_refreshed")

    def test_recent_alerts(self):
        tracker = AlphaDecayTracker(min_observations=5)
        edges = _declining_edges(15, start=0.05, decay_rate=0.10)
        for i, edge_val in enumerate(edges):
            tracker.record_edge("strat_a", edge_val, timestamp=1000.0 + i * 86400)
        tracker.check_alerts(min_edge=0.02)
        recent = tracker.recent_alerts(limit=5)
        assert isinstance(recent, list)

    def test_multiple_strategies(self):
        tracker = AlphaDecayTracker(min_observations=5)
        for i in range(10):
            tracker.record_edge("fast_decay", 0.10 * math.exp(-0.2 * i), timestamp=1000.0 + i * 86400)
            tracker.record_edge("slow_decay", 0.10 * math.exp(-0.01 * i), timestamp=1000.0 + i * 86400)
        profiles = tracker.decay_profiles()
        names = {p.strategy_name for p in profiles}
        assert "fast_decay" in names
        assert "slow_decay" in names


# ===========================================================================
# 3. AlphaModel
# ===========================================================================

class TestAlphaModel:
    """Tests for AlphaModel, AlphaEstimate, FactorValue, FactorIC."""

    def test_constructor(self):
        model = AlphaModel()
        assert model is not None

    def test_estimate_alpha_basic(self):
        model = AlphaModel()
        est = model.estimate_alpha("btc-100k", price=0.55, volume_24h=50000, median_volume=30000)
        assert isinstance(est, AlphaEstimate)
        assert est.market_id == "btc-100k"
        assert -1.0 <= est.composite_alpha <= 1.0
        assert isinstance(est.factor_values, list)
        assert len(est.factor_values) == 7
        assert isinstance(est.confidence, float)

    def test_factor_values_structure(self):
        model = AlphaModel()
        est = model.estimate_alpha("mkt1", price=0.5)
        for fv in est.factor_values:
            assert isinstance(fv, FactorValue)
            assert isinstance(fv.name, str)
            assert isinstance(fv.value, float)
            assert isinstance(fv.raw_value, float)
            assert fv.timestamp > 0

    def test_estimate_alpha_with_all_params(self):
        model = AlphaModel()
        est = model.estimate_alpha(
            market_id="mkt1",
            price=0.65,
            volume_24h=100000,
            median_volume=50000,
            days_to_expiry=5.0,
            current_spread=0.02,
            avg_spread=0.05,
            cross_price=0.70,
            price_history=[0.60, 0.62, 0.64, 0.65],
        )
        assert isinstance(est, AlphaEstimate)
        assert est.composite_alpha != 0.0  # with all these inputs, should be non-trivial

    def test_record_outcome(self):
        model = AlphaModel()
        model.record_outcome("mkt1", 1.0)
        model.record_outcome("mkt1", 0.0)
        # Just verifying no error is raised

    def test_factor_report_initial(self):
        model = AlphaModel()
        report = model.factor_report()
        assert len(report) == 7
        for fic in report:
            assert isinstance(fic, FactorIC)
            assert fic.ic == 0.0
            assert fic.n_observations == 0

    def test_update_ics_insufficient_data(self):
        model = AlphaModel(min_ic_observations=20)
        for i in range(5):
            model.estimate_alpha(f"mkt{i}", price=0.5)
            model.record_outcome(f"mkt{i}", float(i % 2))
        # Should not crash with insufficient data
        model.update_ics()
        report = model.factor_report()
        assert len(report) == 7

    def test_update_ics_with_data(self):
        model = AlphaModel(min_ic_observations=10, lookback=100)
        rng = random.Random(42)
        for i in range(30):
            price = 0.3 + rng.random() * 0.4
            model.estimate_alpha(f"mkt{i}", price=price, volume_24h=rng.random() * 100000)
            model.record_outcome(f"mkt{i}", 1.0 if price > 0.5 else 0.0)
        model.update_ics()
        report = model.factor_report()
        assert len(report) == 7

    def test_as_edge_estimator(self):
        model = AlphaModel()
        estimator = model.as_edge_estimator()
        assert callable(estimator)
        edge_val, confidence = estimator("mkt1", "polymarket")
        assert isinstance(edge_val, float)
        assert isinstance(confidence, float)
        assert edge_val >= 0


# ===========================================================================
# 4. HypothesisManager
# ===========================================================================

class TestHypothesisManager:
    """Tests for HypothesisManager, Hypothesis, HypothesisState."""

    @pytest.fixture
    def manager(self, tmp_path):
        db_path = str(tmp_path / "test_hypotheses.db")
        mgr = HypothesisManager(db_path=db_path)
        yield mgr
        mgr.close()

    def test_constructor(self, manager):
        assert manager is not None

    def test_form_hypothesis(self, manager):
        hyp = manager.form(
            market_id="btc-100k",
            statement="BTC will exceed 100k by March",
            direction="yes",
            edge=0.08,
            confidence=0.6,
            source="research",
        )
        assert isinstance(hyp, Hypothesis)
        assert hyp.market_id == "btc-100k"
        assert hyp.state == HypothesisState.FORMED
        assert hyp.edge_estimate == 0.08
        assert 0.05 <= hyp.confidence <= 0.95
        assert hyp.evidence_count == 0

    def test_form_confidence_clamped(self, manager):
        hyp = manager.form("mkt1", "statement", "yes", 0.1, 0.99, "test")
        assert hyp.confidence == 0.95
        hyp2 = manager.form("mkt2", "statement2", "no", 0.1, 0.01, "test")
        assert hyp2.confidence == 0.05

    def test_update_evidence_positive(self, manager):
        hyp = manager.form("mkt1", "test", "yes", 0.10, 0.5, "test")
        updated = manager.update_evidence(hyp.hypothesis_id, observed_return=0.05)
        assert updated is not None
        assert updated.evidence_count == 1
        # Positive return aligns with "yes" direction, so confidence should increase
        assert updated.confidence > 0.5

    def test_update_evidence_negative(self, manager):
        hyp = manager.form("mkt1", "test", "yes", 0.10, 0.5, "test")
        updated = manager.update_evidence(hyp.hypothesis_id, observed_return=-0.05)
        assert updated is not None
        assert updated.confidence < 0.5

    def test_update_evidence_no_direction(self, manager):
        hyp = manager.form("mkt1", "test", "no", 0.10, 0.5, "test")
        updated = manager.update_evidence(hyp.hypothesis_id, observed_return=-0.03)
        assert updated is not None
        # Negative return aligns with "no" direction
        assert updated.confidence > 0.5

    def test_update_evidence_nonexistent(self, manager):
        result = manager.update_evidence("nonexistent-id", 0.01)
        assert result is None

    def test_validate_passes(self, manager):
        hyp = manager.form("mkt1", "test", "yes", 0.10, 0.7, "test")
        # Add evidence to increase evidence_count
        for _ in range(100):
            manager.update_evidence(hyp.hypothesis_id, 0.01)
        validated = manager.validate(
            hyp.hypothesis_id,
            backtest_sharpe=2.0,
            p_value=0.001,
            walk_forward_pct=0.8,
        )
        assert validated is not None
        # Whether it actually passes depends on deflated_sharpe, which requires Rust
        assert validated.backtest_sharpe == 2.0
        assert validated.backtest_pvalue == 0.001

    def test_validate_fails_low_sharpe(self, manager):
        hyp = manager.form("mkt1", "test", "yes", 0.10, 0.7, "test")
        validated = manager.validate(
            hyp.hypothesis_id,
            backtest_sharpe=0.3,
            p_value=0.5,
            walk_forward_pct=0.3,
        )
        assert validated is not None
        assert validated.state != HypothesisState.VALIDATED

    def test_start_monitoring(self, manager):
        hyp = manager.form("mkt1", "test", "yes", 0.10, 0.7, "test")
        monitored = manager.start_monitoring(hyp.hypothesis_id)
        assert monitored is not None
        assert monitored.state == HypothesisState.MONITORING

    def test_check_decay_triggers(self, manager):
        hyp = manager.form("mkt1", "test", "yes", 0.10, 0.7, "test")
        # Edge drops to less than 30% of original
        is_decaying = manager.check_decay(hyp.hypothesis_id, current_edge=0.02)
        assert is_decaying is True
        reloaded = manager.best_hypothesis("mkt1")
        if reloaded is not None:
            assert reloaded.state == HypothesisState.DECAYING

    def test_check_decay_no_trigger(self, manager):
        hyp = manager.form("mkt1", "test", "yes", 0.10, 0.7, "test")
        is_decaying = manager.check_decay(hyp.hypothesis_id, current_edge=0.09)
        assert is_decaying is False

    def test_retire(self, manager):
        hyp = manager.form("mkt1", "test", "yes", 0.10, 0.7, "test")
        retired = manager.retire(hyp.hypothesis_id, reason="edge gone")
        assert retired is not None
        assert retired.state == HypothesisState.RETIRED

    def test_invalidate(self, manager):
        hyp = manager.form("mkt1", "test", "yes", 0.10, 0.7, "test")
        invalidated = manager.invalidate(hyp.hypothesis_id, reason="false premise")
        assert invalidated is not None
        assert invalidated.state == HypothesisState.INVALIDATED

    def test_best_hypothesis(self, manager):
        manager.form("mkt1", "hyp A", "yes", 0.10, 0.7, "test")
        manager.form("mkt1", "hyp B", "yes", 0.20, 0.8, "test")
        best = manager.best_hypothesis("mkt1")
        assert best is not None
        # Best = highest confidence * edge
        assert best.statement == "hyp B"

    def test_best_hypothesis_excludes_retired(self, manager):
        hyp_a = manager.form("mkt1", "hyp A", "yes", 0.10, 0.7, "test")
        hyp_b = manager.form("mkt1", "hyp B", "yes", 0.20, 0.8, "test")
        manager.retire(hyp_b.hypothesis_id, "done")
        best = manager.best_hypothesis("mkt1")
        assert best is not None
        assert best.hypothesis_id == hyp_a.hypothesis_id

    def test_active_hypotheses(self, manager):
        manager.form("mkt1", "A", "yes", 0.10, 0.7, "test")
        manager.form("mkt1", "B", "yes", 0.10, 0.7, "test")
        active = manager.active_hypotheses()
        assert len(active) == 2

    def test_active_hypotheses_filtered_by_state(self, manager):
        hyp = manager.form("mkt1", "A", "yes", 0.10, 0.7, "test")
        manager.start_monitoring(hyp.hypothesis_id)
        manager.form("mkt1", "B", "yes", 0.10, 0.7, "test")
        monitoring = manager.active_hypotheses(state=HypothesisState.MONITORING)
        assert len(monitoring) == 1
        assert monitoring[0].hypothesis_id == hyp.hypothesis_id

    def test_to_deploy_candidate(self, manager):
        hyp = manager.form("mkt1", "test", "yes", 0.10, 0.7, "test")
        candidate = manager.to_deploy_candidate(hyp)
        assert isinstance(candidate, dict)
        assert candidate["market_id"] == "mkt1"
        assert candidate["edge_estimate"] == 0.10
        assert candidate["confidence"] == hyp.confidence

    def test_full_lifecycle(self, manager):
        # Form
        hyp = manager.form("mkt1", "test hyp", "yes", 0.10, 0.6, "research")
        assert hyp.state == HypothesisState.FORMED
        # Evidence
        for _ in range(5):
            hyp = manager.update_evidence(hyp.hypothesis_id, 0.02)
        assert hyp.evidence_count == 5
        assert hyp.confidence > 0.6
        # Validate (may not pass criteria but state machine moves)
        hyp = manager.validate(hyp.hypothesis_id, 2.0, 0.01, 0.8)
        # Monitor
        hyp = manager.start_monitoring(hyp.hypothesis_id)
        assert hyp.state == HypothesisState.MONITORING
        # Decay
        decaying = manager.check_decay(hyp.hypothesis_id, 0.02)
        assert decaying is True
        # Retire
        hyp = manager.retire(hyp.hypothesis_id, "edge gone")
        assert hyp.state == HypothesisState.RETIRED


# ===========================================================================
# 5. SignalEnsemble
# ===========================================================================

class TestSignalEnsemble:
    """Tests for SignalEnsemble, Signal, EnsembleOutput, SignalQuality."""

    def _make_signal(self, name, value, market_id="mkt1", confidence=0.8, source="ml"):
        return Signal(
            name=name,
            value=value,
            confidence=confidence,
            timestamp=time.time(),
            source=source,
            market_id=market_id,
        )

    def test_constructor(self):
        ensemble = SignalEnsemble()
        assert ensemble is not None

    def test_submit_and_active_signals(self):
        ensemble = SignalEnsemble(stale_threshold_secs=300)
        ensemble.submit(self._make_signal("model_a", 0.6))
        ensemble.submit(self._make_signal("model_b", 0.4))
        active = ensemble.active_signals("mkt1")
        assert len(active) == 2

    def test_combine_insufficient_signals(self):
        ensemble = SignalEnsemble(min_signals=2)
        ensemble.submit(self._make_signal("model_a", 0.6))
        result = ensemble.combine("mkt1")
        assert result is None

    def test_combine_success(self):
        ensemble = SignalEnsemble(min_signals=2)
        ensemble.submit(self._make_signal("model_a", 0.6))
        ensemble.submit(self._make_signal("model_b", 0.4))
        result = ensemble.combine("mkt1")
        assert result is not None
        assert isinstance(result, EnsembleOutput)
        assert -1.0 <= result.combined_signal <= 1.0
        assert 0.0 <= result.combined_confidence <= 1.0
        assert result.n_signals == 2
        assert "model_a" in result.weights_used
        assert "model_b" in result.weights_used
        assert abs(sum(result.weights_used.values()) - 1.0) < 1e-6

    def test_combine_different_markets(self):
        ensemble = SignalEnsemble(min_signals=2)
        ensemble.submit(self._make_signal("model_a", 0.6, market_id="mkt1"))
        ensemble.submit(self._make_signal("model_b", 0.4, market_id="mkt2"))
        # Different markets, so combining mkt1 should return None (only 1 signal)
        result = ensemble.combine("mkt1")
        assert result is None

    def test_stale_signals_excluded(self):
        ensemble = SignalEnsemble(stale_threshold_secs=1, min_signals=2)
        old_sig = Signal(
            name="old", value=0.5, confidence=0.8,
            timestamp=time.time() - 10, source="ml", market_id="mkt1",
        )
        ensemble.submit(old_sig)
        ensemble.submit(self._make_signal("new", 0.6))
        result = ensemble.combine("mkt1")
        assert result is None  # "old" is stale

    def test_record_outcome(self):
        ensemble = SignalEnsemble()
        ensemble.record_outcome("model_a", 1.0)
        ensemble.record_outcome("model_a", 0.0)
        # No crash

    def test_update_quality(self):
        ensemble = SignalEnsemble(lookback=200)
        rng = random.Random(42)
        for i in range(20):
            sig = Signal(
                name="model_a",
                value=rng.uniform(-1, 1),
                confidence=0.8,
                timestamp=time.time(),
                source="ml",
                market_id="mkt1",
            )
            ensemble.submit(sig)
            ensemble.record_outcome("model_a", rng.uniform(-1, 1))
        ensemble.update_quality()
        q = ensemble.signal_quality("model_a")
        if q is not None:
            assert isinstance(q, SignalQuality)
            assert q.name == "model_a"

    def test_all_quality(self):
        ensemble = SignalEnsemble(lookback=200)
        rng = random.Random(42)
        for i in range(10):
            ensemble.submit(Signal("m_a", rng.uniform(-1, 1), 0.8, time.time(), "ml", "mkt1"))
            ensemble.submit(Signal("m_b", rng.uniform(-1, 1), 0.7, time.time(), "stat", "mkt1"))
            ensemble.record_outcome("m_a", rng.uniform(-1, 1))
            ensemble.record_outcome("m_b", rng.uniform(-1, 1))
        ensemble.update_quality()
        all_q = ensemble.all_quality()
        assert isinstance(all_q, list)

    def test_redundancy_penalty(self):
        ensemble = SignalEnsemble(min_signals=2, lookback=50)
        # Submit many identical signals to build history
        for _ in range(10):
            ensemble.submit(Signal("m_a", 0.5, 0.8, time.time(), "ml", "mkt1"))
            ensemble.submit(Signal("m_b", 0.5, 0.8, time.time(), "ml", "mkt1"))
        result = ensemble.combine("mkt1")
        assert result is not None
        # With identical signals, redundancy penalty should be high
        assert result.redundancy_penalty >= 0.0


# ===========================================================================
# 6. ResearchIntelligence
# ===========================================================================

class TestResearchIntelligence:
    """Tests for ResearchIntelligence, ResearchTrigger, CrossMarketOpportunity."""

    def test_constructor(self):
        ri = ResearchIntelligence()
        assert ri.pending_count() == 0

    def test_on_regime_change(self):
        ri = ResearchIntelligence()
        ri.on_regime_change("quiet", "volatile")
        assert ri.pending_count() == 1
        triggers = ri.consume_triggers(max_count=5)
        assert len(triggers) == 1
        assert triggers[0].trigger_type == TRIGGER_REGIME_CHANGE
        assert triggers[0].priority == 1
        assert triggers[0].source_data["old_regime"] == "quiet"
        assert triggers[0].source_data["new_regime"] == "volatile"

    def test_on_settlement(self):
        ri = ResearchIntelligence()
        ri.on_settlement("mkt1", pnl=100.0)
        triggers = ri.consume_triggers()
        assert len(triggers) == 1
        assert triggers[0].trigger_type == TRIGGER_SETTLEMENT_CASCADE

    def test_on_settlement_zero_pnl_ignored(self):
        ri = ResearchIntelligence()
        ri.on_settlement("mkt1", pnl=0.0)
        assert ri.pending_count() == 0

    def test_on_new_market(self):
        ri = ResearchIntelligence()
        ri.on_new_market("new-mkt")
        triggers = ri.consume_triggers()
        assert len(triggers) == 1
        assert triggers[0].trigger_type == TRIGGER_NEW_MARKET
        assert triggers[0].market_id == "new-mkt"

    def test_on_volume_spike(self):
        ri = ResearchIntelligence()
        ri.on_volume_spike("mkt1", volume=50000, avg_volume=10000)
        triggers = ri.consume_triggers()
        assert len(triggers) == 1
        assert triggers[0].trigger_type == TRIGGER_VOLUME_SPIKE

    def test_on_volume_spike_below_threshold(self):
        ri = ResearchIntelligence()
        ri.on_volume_spike("mkt1", volume=15000, avg_volume=10000)
        assert ri.pending_count() == 0

    def test_consume_triggers_priority_order(self):
        ri = ResearchIntelligence()
        ri.on_new_market("mkt1")        # priority 3
        ri.on_settlement("mkt2", 50.0)  # priority 2
        ri.on_regime_change("quiet", "volatile")  # priority 1
        triggers = ri.consume_triggers(max_count=10)
        assert len(triggers) == 3
        # Should be sorted by priority ascending (1 first)
        assert triggers[0].priority <= triggers[1].priority <= triggers[2].priority

    def test_consume_triggers_max_count(self):
        ri = ResearchIntelligence()
        for i in range(5):
            ri.on_new_market(f"mkt{i}")
        triggers = ri.consume_triggers(max_count=2)
        assert len(triggers) == 2
        assert ri.pending_count() == 3

    def test_register_pair_and_update_prices(self):
        ri = ResearchIntelligence(divergence_threshold=0.05, correlation_lookback=50)
        ri.register_pair("mkt_a", "mkt_b")
        # Feed enough prices so they diverge
        for i in range(10):
            ri.update_prices({"mkt_a": 0.50 + 0.001 * i, "mkt_b": 0.50 - 0.001 * i})
        # After divergence > 0.05 threshold, trigger should fire
        # 10 steps: mkt_a=0.509, mkt_b=0.491, diff=0.018 (not enough)
        # Need bigger divergence
        ri2 = ResearchIntelligence(divergence_threshold=0.05)
        ri2.register_pair("mkt_a", "mkt_b")
        for i in range(20):
            ri2.update_prices({"mkt_a": 0.50 + 0.005 * i, "mkt_b": 0.50 - 0.005 * i})
        triggers = ri2.consume_triggers()
        # With divergence exceeding 0.05, should have cross-market triggers
        cross_triggers = [t for t in triggers if t.trigger_type == TRIGGER_CROSS_MARKET_DIVERGENCE]
        assert len(cross_triggers) > 0

    def test_record_attribution(self):
        ri = ResearchIntelligence()
        ri.record_attribution(
            strategy_name="strat1",
            hypothesis_id="hyp-123",
            trigger_type=TRIGGER_REGIME_CHANGE,
            signals_used=["model_a", "model_b"],
            pnl=150.0,
            information_ratio=1.5,
        )
        eff = ri.trigger_effectiveness()
        assert TRIGGER_REGIME_CHANGE in eff
        assert eff[TRIGGER_REGIME_CHANGE]["count"] == 1
        assert eff[TRIGGER_REGIME_CHANGE]["total_pnl"] == 150.0

    def test_trigger_effectiveness_empty(self):
        ri = ResearchIntelligence()
        assert ri.trigger_effectiveness() == {}

    def test_recent_triggers(self):
        ri = ResearchIntelligence()
        ri.on_new_market("mkt1")
        ri.on_new_market("mkt2")
        recent = ri.recent_triggers(limit=5)
        assert len(recent) == 2


# ===========================================================================
# 7. PortfolioOptimizer
# ===========================================================================

class TestPortfolioOptimizer:
    """Tests for PortfolioOptimizer, OptimizationConstraints, OptimizationResult."""

    def test_constructor(self):
        opt = PortfolioOptimizer()
        assert opt is not None

    def test_optimize_empty(self):
        opt = PortfolioOptimizer()
        result = opt.optimize([])
        assert isinstance(result, OptimizationResult)
        assert result.target_weights == {}
        assert result.expected_return == 0.0

    def test_optimize_single_strategy(self):
        opt = PortfolioOptimizer(total_capital=100_000)
        result = opt.optimize(["strat_a"])
        assert "strat_a" in result.target_weights
        assert result.target_weights["strat_a"] > 0

    def test_optimize_multiple_strategies(self):
        opt = PortfolioOptimizer(total_capital=100_000)
        result = opt.optimize(["strat_a", "strat_b", "strat_c"])
        assert len(result.target_weights) == 3
        total = sum(result.target_weights.values())
        assert total > 0
        assert total <= 100_000 + 1  # approximately sum to capital

    def test_optimize_with_constraints(self):
        constraints = OptimizationConstraints(
            max_per_market=0.10,
            max_per_exchange=0.40,
            max_positions=50,
        )
        opt = PortfolioOptimizer(constraints=constraints, total_capital=100_000)
        result = opt.optimize(["s1", "s2", "s3", "s4", "s5"])
        for name, weight in result.target_weights.items():
            assert weight <= 100_000 * 0.10 + 1  # per-market cap (with small rounding tolerance)

    def test_optimize_max_positions_violation(self):
        constraints = OptimizationConstraints(max_positions=2)
        opt = PortfolioOptimizer(constraints=constraints)
        result = opt.optimize(["s1", "s2", "s3"])
        assert len(result.violations) > 0
        assert "Position count" in result.violations[0]

    def test_allocate_simplified(self):
        opt = PortfolioOptimizer(total_capital=100_000)
        alloc = opt.allocate(["strat_a", "strat_b"])
        assert isinstance(alloc, dict)
        assert len(alloc) == 2

    def test_update_returns(self):
        opt = PortfolioOptimizer()
        for i in range(15):
            opt.update_returns("strat_a", 0.01)
            opt.update_returns("strat_b", -0.005)
        result = opt.optimize(["strat_a", "strat_b"])
        assert isinstance(result, OptimizationResult)

    def test_check_rebalance_drift(self):
        opt = PortfolioOptimizer()
        current = {"s1": 0.3, "s2": 0.7}
        target = {"s1": 0.5, "s2": 0.5}
        triggers = opt.check_rebalance(current, target)
        assert len(triggers) >= 1
        assert triggers[0].trigger_type == "drift"

    def test_check_rebalance_no_drift(self):
        opt = PortfolioOptimizer()
        current = {"s1": 0.50, "s2": 0.50}
        target = {"s1": 0.51, "s2": 0.49}
        triggers = opt.check_rebalance(current, target)
        assert len(triggers) == 0


# ===========================================================================
# 8. RiskAnalytics
# ===========================================================================

class TestRiskAnalytics:
    """Tests for RiskAnalytics, DynamicLimits, TailRiskReport, PortfolioGreeksReport."""

    def test_constructor(self):
        ra = RiskAnalytics()
        assert ra is not None

    def test_dynamic_limits_quiet(self):
        ra = RiskAnalytics(base_position_pct=0.05, base_var_limit=0.10)
        limits = ra.dynamic_limits("quiet")
        assert isinstance(limits, DynamicLimits)
        assert limits.regime == "quiet"
        assert limits.position_scale == 1.0
        assert limits.max_position_pct == 0.05
        assert limits.var_limit == 0.10
        assert limits.spread_multiplier == 1.0

    def test_dynamic_limits_volatile(self):
        ra = RiskAnalytics(base_position_pct=0.05, base_var_limit=0.10)
        limits = ra.dynamic_limits("volatile")
        assert limits.position_scale == 0.7
        assert limits.max_position_pct == pytest.approx(0.05 * 0.7)
        assert limits.spread_multiplier == 1.3

    def test_dynamic_limits_crisis(self):
        ra = RiskAnalytics(base_position_pct=0.05, base_var_limit=0.10)
        limits = ra.dynamic_limits("crisis")
        assert limits.position_scale == 0.5
        assert limits.spread_multiplier == 1.5

    def test_dynamic_limits_trending(self):
        ra = RiskAnalytics()
        limits_up = ra.dynamic_limits("trending_up")
        limits_down = ra.dynamic_limits("trending_down")
        assert limits_up.position_scale == 1.0
        assert limits_down.position_scale == 1.0

    def test_dynamic_limits_unknown_regime(self):
        ra = RiskAnalytics()
        limits = ra.dynamic_limits("unknown_regime")
        assert limits.regime == "unknown_regime"
        # Falls back to "quiet" params
        assert limits.position_scale == 1.0

    def test_volatile_escalates_to_crisis(self):
        ra = RiskAnalytics()
        # Feed very volatile returns
        for r in _synthetic_returns(50, mu=0.0, sigma=0.10):
            ra.update_returns(r)
        limits = ra.dynamic_limits("volatile")
        # Should escalate to crisis if realized vol > 0.04
        assert limits.regime in ("volatile", "crisis")

    def test_tail_risk_insufficient_data(self):
        ra = RiskAnalytics()
        report = ra.tail_risk([0.01, -0.01])
        assert report is None

    def test_tail_risk_with_data(self):
        ra = RiskAnalytics()
        returns = _synthetic_returns(100, mu=0.0, sigma=0.02)
        report = ra.tail_risk(returns)
        assert report is not None
        assert isinstance(report, TailRiskReport)
        assert report.historical_var >= 0
        assert report.historical_cvar >= 0
        assert isinstance(report.cf_var, float)
        assert isinstance(report.cf_cvar, float)
        assert isinstance(report.skewness, float)
        assert isinstance(report.kurtosis, float)
        assert isinstance(report.tail_ratio, float)

    def test_tail_risk_from_internal_history(self):
        ra = RiskAnalytics()
        for r in _synthetic_returns(50, mu=0.0, sigma=0.02):
            ra.update_returns(r)
        report = ra.tail_risk()
        assert report is not None

    def test_portfolio_greeks(self):
        ra = RiskAnalytics()
        positions = [
            {"strategy_name": "strat_a", "price": 0.55, "size": 100, "is_yes": True, "t_hours": 24.0, "vol": 0.2},
            {"strategy_name": "strat_b", "price": 0.40, "size": 50, "is_yes": False, "t_hours": 48.0, "vol": 0.3},
        ]
        report = ra.portfolio_greeks(positions)
        assert isinstance(report, PortfolioGreeksReport)
        assert isinstance(report.total_delta, float)
        assert isinstance(report.total_gamma, float)
        assert isinstance(report.total_theta, float)
        assert isinstance(report.total_vega, float)
        assert "strat_a" in report.per_strategy
        assert "strat_b" in report.per_strategy
        assert report.timestamp > 0

    def test_portfolio_greeks_empty(self):
        ra = RiskAnalytics()
        report = ra.portfolio_greeks([])
        assert report.total_delta == 0.0
        assert report.total_gamma == 0.0

    def test_update_returns_filters_nan(self):
        ra = RiskAnalytics()
        ra.update_returns(float("nan"))
        ra.update_returns(float("inf"))
        ra.update_returns(0.01)
        # Only 1 valid return recorded, so tail_risk should return None
        report = ra.tail_risk()
        assert report is None


# ===========================================================================
# 9. PerformanceAttribution
# ===========================================================================

class TestPerformanceAttribution:
    """Tests for PerformanceAttribution, PerformanceSnapshot, AlphaBetaDecomposition."""

    def test_constructor(self):
        pa = PerformanceAttribution()
        assert pa is not None

    def test_update_and_compute_insufficient(self):
        pa = PerformanceAttribution()
        for i in range(5):
            pa.update(0.01, 0.005)
        ab = pa.compute_alpha_beta()
        assert ab is None  # fewer than 20 observations

    def test_compute_alpha_beta(self):
        pa = PerformanceAttribution()
        rng = random.Random(42)
        for i in range(30):
            bench = rng.gauss(0.001, 0.01)
            port = bench * 1.2 + rng.gauss(0.0005, 0.005)
            pa.update(port, bench)
        ab = pa.compute_alpha_beta()
        assert ab is not None
        assert isinstance(ab, AlphaBetaDecomposition)
        assert isinstance(ab.alpha, float)
        assert isinstance(ab.beta, float)
        assert 0.0 <= ab.r_squared <= 1.0
        assert isinstance(ab.information_ratio, float)

    def test_record_cost(self):
        pa = PerformanceAttribution()
        pa.record_cost("strat_a", spread=0.001, slippage=0.0005, fee=0.0002)
        pa.record_cost("strat_a", spread=0.001, slippage=0.0005, fee=0.0002)
        cost = pa.compute_cost_attribution()
        assert isinstance(cost, CostAttribution)
        assert cost.spread_cost == pytest.approx(0.002)
        assert cost.slippage_cost == pytest.approx(0.001)
        assert cost.fee_cost == pytest.approx(0.0004)

    def test_strategy_attributions(self):
        pa = PerformanceAttribution()
        rng = random.Random(42)
        for i in range(10):
            port_ret = rng.gauss(0.001, 0.01)
            pa.update(port_ret, 0.0, strategy_returns={"strat_a": port_ret * 0.6, "strat_b": port_ret * 0.4})
        attribs = pa.compute_strategy_attributions()
        assert isinstance(attribs, list)
        assert len(attribs) == 2
        for attr in attribs:
            assert isinstance(attr, StrategyAttribution)
            assert attr.strategy_name in ("strat_a", "strat_b")
            assert isinstance(attr.sharpe, float)
            assert 0.0 <= attr.win_rate <= 1.0

    def test_compute_full_snapshot(self):
        pa = PerformanceAttribution()
        rng = random.Random(42)
        for i in range(25):
            bench = rng.gauss(0.001, 0.01)
            port = bench + rng.gauss(0.0005, 0.005)
            pa.update(port, bench, strategy_returns={"strat_a": port})
        pa.record_cost("strat_a", spread=0.001)
        snapshot = pa.compute()
        assert isinstance(snapshot, PerformanceSnapshot)
        assert isinstance(snapshot.alpha_beta, AlphaBetaDecomposition)
        assert isinstance(snapshot.strategy_attributions, list)
        assert isinstance(snapshot.cost, CostAttribution)
        assert snapshot.timestamp > 0

    def test_compute_snapshot_insufficient_data(self):
        pa = PerformanceAttribution()
        for i in range(3):
            pa.update(0.01, 0.005)
        snapshot = pa.compute()
        # Should return a valid snapshot with zero alpha/beta
        assert isinstance(snapshot, PerformanceSnapshot)
        assert snapshot.alpha_beta.alpha == 0.0
        assert snapshot.alpha_beta.beta == 0.0

    def test_recent_snapshots(self):
        pa = PerformanceAttribution()
        for i in range(25):
            pa.update(0.001, 0.0005)
        pa.compute()
        pa.compute()
        snaps = pa.recent_snapshots(limit=5)
        assert len(snaps) == 2


# ===========================================================================
# 10. ExecutionIntelligence
# ===========================================================================

class TestExecutionIntelligence:
    """Tests for ExecutionIntelligence, ToxicityAlert, InventoryRiskReport, ExecutionSchedule."""

    def test_constructor(self):
        ei = ExecutionIntelligence()
        assert ei is not None

    def test_update_toxicity_no_alert(self):
        ei = ExecutionIntelligence(vpin_threshold=0.7, bucket_volume=100)
        # Single update should not produce alert (VPIN needs many buckets)
        alert = ei.update_toxicity("mkt1", price=100.0, volume=50.0, is_buy=True)
        assert alert is None

    def test_update_toxicity_with_alert(self):
        ei = ExecutionIntelligence(vpin_threshold=0.5, bucket_volume=10)
        # Feed many buy-side trades to push VPIN high
        alert = None
        for i in range(200):
            result = ei.update_toxicity("mkt1", price=100.0, volume=10.0, is_buy=True)
            if result is not None:
                alert = result
        # May or may not trigger depending on VPIN computation
        if alert is not None:
            assert isinstance(alert, ToxicityAlert)
            assert alert.market_id == "mkt1"
            assert alert.vpin > 0.5
            assert alert.recommended_action in ("widen_spread", "reduce_size", "halt_trading")

    def test_update_ofi(self):
        ei = ExecutionIntelligence()
        ofi = ei.update_ofi("mkt1", best_bid_qty=100.0, best_ask_qty=50.0)
        assert isinstance(ofi, float)

    def test_update_ofi_multiple(self):
        ei = ExecutionIntelligence()
        ei.update_ofi("mkt1", 100.0, 50.0)
        ei.update_ofi("mkt1", 120.0, 40.0)
        ofi = ei.current_ofi("mkt1")
        assert isinstance(ofi, float)

    def test_inventory_risk(self):
        ei = ExecutionIntelligence(gamma=0.1, kappa=1.5)
        report = ei.inventory_risk(
            market_id="mkt1",
            mid_price=0.55,
            net_inventory=10.0,
            volatility=0.05,
            time_horizon=1.0,
        )
        assert isinstance(report, InventoryRiskReport)
        assert report.market_id == "mkt1"
        assert report.net_inventory == 10.0
        assert isinstance(report.reservation_price, float)
        assert isinstance(report.optimal_spread, float)
        assert report.recommended_bid < report.recommended_ask
        assert report.timestamp > 0

    def test_inventory_risk_zero_inventory(self):
        ei = ExecutionIntelligence()
        report = ei.inventory_risk("mkt1", mid_price=0.50, net_inventory=0.0, volatility=0.05)
        # With zero inventory, reservation price should be close to mid
        assert abs(report.reservation_price - 0.50) < 0.01

    def test_schedule_execution_urgent(self):
        ei = ExecutionIntelligence()
        schedule = ei.schedule_execution("mkt1", total_size=1000, urgency=0.9, depth=500)
        assert isinstance(schedule, ExecutionSchedule)
        assert schedule.market_id == "mkt1"
        assert schedule.total_size == 1000
        assert schedule.method == "immediate"
        assert schedule.slice_count >= 1
        assert abs(sum(schedule.slice_sizes) - 1000) < 1e-6
        assert schedule.interval_secs > 0

    def test_schedule_execution_patient(self):
        ei = ExecutionIntelligence()
        schedule = ei.schedule_execution("mkt1", total_size=1000, urgency=0.1, depth=500)
        assert schedule.method == "twap"
        assert schedule.slice_count >= 3

    def test_schedule_execution_adaptive(self):
        ei = ExecutionIntelligence()
        schedule = ei.schedule_execution("mkt1", total_size=1000, urgency=0.5, depth=500)
        assert schedule.method == "adaptive"

    def test_schedule_expected_cost(self):
        ei = ExecutionIntelligence()
        s_urgent = ei.schedule_execution("mkt1", total_size=1000, urgency=0.9, depth=500)
        s_patient = ei.schedule_execution("mkt1", total_size=1000, urgency=0.1, depth=500)
        # More urgent = higher expected cost
        assert s_urgent.expected_cost > s_patient.expected_cost

    def test_current_vpin_untracked(self):
        ei = ExecutionIntelligence()
        assert ei.current_vpin("unknown") == 0.0

    def test_current_ofi_untracked(self):
        ei = ExecutionIntelligence()
        assert ei.current_ofi("unknown") == 0.0

    def test_recent_alerts(self):
        ei = ExecutionIntelligence()
        alerts = ei.recent_alerts(limit=10)
        assert isinstance(alerts, list)
        assert len(alerts) == 0

    def test_reset_market(self):
        ei = ExecutionIntelligence(bucket_volume=10)
        # Create state for a market
        ei.update_toxicity("mkt1", 100.0, 10.0, True)
        ei.update_ofi("mkt1", 100.0, 50.0)
        # Reset should clear trackers
        ei.reset_market("mkt1")
        assert ei.current_vpin("mkt1") == 0.0
        assert ei.current_ofi("mkt1") == 0.0


# ===========================================================================
# Wiring tests: _research.py, _adaptive.py, _autopilot.py
# ===========================================================================

class TestResearchPipelineRegimeParam:
    """Test that _research.py scan() accepts regime parameter."""

    def test_scan_accepts_regime(self):
        from horizon.fund._research import ResearchPipeline
        pipeline = ResearchPipeline()
        # Create a mock regime snapshot
        regime = RegimeSnapshot(
            regime="trending_up",
            confidence=0.8,
            volatility=0.02,
            trend_strength=0.5,
            mean_reversion_score=0.1,
            timestamp=time.time(),
        )
        # scan with empty universe and regime param should not crash
        result = pipeline.scan(universe_entries=[], regime=regime)
        assert result.markets_scanned == 0
        assert result.opportunities_found == 0

    def test_scan_without_regime(self):
        from horizon.fund._research import ResearchPipeline
        pipeline = ResearchPipeline()
        result = pipeline.scan(universe_entries=[])
        assert result.markets_scanned == 0


class TestAdaptiveVpinRule:
    """Test that _adaptive.py Rule 5 (vpin-based) works."""

    def test_vpin_rule_widens_spread(self):
        from horizon.fund._adaptive import AdaptiveExecutionTuner, AdaptiveConfig
        config = AdaptiveConfig(
            min_fills_before_adjust=1,
            adjustment_interval_secs=0,
        )
        tuner = AdaptiveExecutionTuner(config=config)
        metrics = {
            "total_fills": 50,
            "fill_rate": 0.7,
            "avg_slippage_bps": 5.0,
            "adverse_selection": 0.005,
            "market_impact_bps": 5.0,
            "vpin": 0.85,
        }
        adjustments = tuner.tune("strat_a", metrics)
        vpin_adjustments = [a for a in adjustments if "vpin" in a.reason]
        assert len(vpin_adjustments) >= 1
        adj = vpin_adjustments[0]
        assert adj.parameter == "spread_multiplier"
        assert adj.new_value > adj.old_value

    def test_vpin_below_threshold_no_rule5(self):
        from horizon.fund._adaptive import AdaptiveExecutionTuner, AdaptiveConfig
        config = AdaptiveConfig(
            min_fills_before_adjust=1,
            adjustment_interval_secs=0,
        )
        tuner = AdaptiveExecutionTuner(config=config)
        metrics = {
            "total_fills": 50,
            "fill_rate": 0.7,
            "avg_slippage_bps": 5.0,
            "adverse_selection": 0.005,
            "market_impact_bps": 5.0,
            "vpin": 0.3,
        }
        adjustments = tuner.tune("strat_a", metrics)
        vpin_adjustments = [a for a in adjustments if "vpin" in a.reason]
        assert len(vpin_adjustments) == 0


class TestAutopilotWiring:
    """Test that _autopilot.py accepts decay_tracker and hypothesis_id."""

    def test_deploy_candidate_has_hypothesis_id(self):
        from horizon.fund._autopilot import DeployCandidate
        candidate = DeployCandidate(
            market_id="mkt1",
            exchange="polymarket",
            strategy_type="market_maker",
            hypothesis_id="hyp-abc-123",
        )
        assert candidate.hypothesis_id == "hyp-abc-123"

    def test_deploy_candidate_hypothesis_id_default_none(self):
        from horizon.fund._autopilot import DeployCandidate
        candidate = DeployCandidate(
            market_id="mkt1",
            exchange="polymarket",
            strategy_type="market_maker",
        )
        assert candidate.hypothesis_id is None

    def test_autopilot_accepts_decay_tracker(self):
        from horizon.fund._autopilot import Autopilot
        tracker = AlphaDecayTracker(min_observations=5)
        autopilot = Autopilot(decay_tracker=tracker)
        assert autopilot._decay_tracker is tracker

    def test_autopilot_without_decay_tracker(self):
        from horizon.fund._autopilot import Autopilot
        autopilot = Autopilot()
        assert autopilot._decay_tracker is None
