"""Tests for advanced fund modules.

Covers: universe, memory, correlation, VaR budget, stress testing,
execution quality, adaptive tuning, routing, research, autopilot,
decision framework, autonomous agent, and FundManager integration.
"""

import math
import tempfile
import time

import pytest
from unittest.mock import MagicMock, patch

from horizon.fund._universe import MarketUniverse, UniverseConfig, UniverseEntry
from horizon.fund._memory import StrategyMemory, MemoryEntry
from horizon.fund._correlation import CorrelationMonitor, CorrelationPair, ConcentrationAlert
from horizon.fund._var_budget import VaRBudgetManager, VaRAllocation, VaRBudgetSnapshot
from horizon.fund._fund_stress import FundStressTester, FundScenario, FundStressResult, FUND_ALL_DOWN_10
from horizon.fund._exec_quality import ExecutionQualityTracker, ExecutionSnapshot, FundExecutionReport
from horizon.fund._adaptive import AdaptiveExecutionTuner, AdaptiveConfig, ParameterAdjustment
from horizon.fund._routing import SmartRouter, RouteDecision, RouteScore, ExchangeHealth
from horizon.fund._research import ResearchPipeline, ResearchScan, Opportunity
from horizon.fund._autopilot import Autopilot, DeployCandidate, DeployCriteria
from horizon.fund._decisions import (
    DecisionFramework,
    OperatingMode,
    ActionType,
    ConfidenceThresholds,
    RateLimits,
    Decision,
)
from horizon.fund._agent import AutonomousAgent
from horizon.fund._types import AllocationMethod, FundConfig, StrategyConfig, StrategyState, MarketScore
from horizon.fund import FundManager


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

def _make_market_score(market_id: str, composite: float = 0.6) -> MarketScore:
    return MarketScore(
        market_id=market_id,
        exchange="polymarket",
        composite_score=composite,
        liquidity_score=0.5,
        edge_score=0.5,
        fit_score=0.5,
        volume_24h=10000.0,
        spread=0.02,
        price=0.55,
    )


def _make_universe_entry(
    market_id: str,
    exchange: str = "polymarket",
    name: str = "",
    score: MarketScore | None = None,
    sector: str = "",
) -> UniverseEntry:
    return UniverseEntry(
        market_id=market_id,
        exchange=exchange,
        name=name or market_id,
        added_at=time.time(),
        score=score,
        sector=sector,
        last_refreshed=time.time(),
        active=True,
    )


# ===========================================================================
# 1. MarketUniverse
# ===========================================================================

class TestMarketUniverse:
    """Tests for _universe.py."""

    def test_add_market(self):
        u = MarketUniverse()
        u.add_market("m1", "polymarket", "Market 1")
        assert u.size == 1
        entry = u.get("m1")
        assert entry is not None
        assert entry.market_id == "m1"
        assert entry.exchange == "polymarket"
        assert entry.active is True

    def test_add_existing_reactivates(self):
        u = MarketUniverse()
        u.add_market("m1", "polymarket")
        u.remove_market("m1")
        assert u.size == 0
        u.add_market("m1", "polymarket")
        assert u.size == 1

    def test_add_at_capacity_raises(self):
        cfg = UniverseConfig(max_markets=2)
        u = MarketUniverse(cfg)
        u.add_market("m1", "poly")
        u.add_market("m2", "poly")
        with pytest.raises(ValueError, match="capacity"):
            u.add_market("m3", "poly")

    def test_remove_market(self):
        u = MarketUniverse()
        u.add_market("m1", "poly")
        u.remove_market("m1", reason="test")
        assert u.size == 0
        entry = u.get("m1")
        assert entry is not None
        assert entry.active is False

    def test_remove_nonexistent_is_noop(self):
        u = MarketUniverse()
        u.remove_market("does_not_exist")  # should not raise

    def test_active_markets(self):
        u = MarketUniverse()
        u.add_market("m1", "poly")
        u.add_market("m2", "poly")
        u.remove_market("m1")
        active = u.active_markets()
        assert len(active) == 1
        assert active[0].market_id == "m2"

    def test_unassigned_markets(self):
        u = MarketUniverse()
        u.add_market("m1", "poly")
        u.add_market("m2", "poly")
        u.assign_strategy("m1", "strategy_a")
        unassigned = u.unassigned_markets()
        assert len(unassigned) == 1
        assert unassigned[0].market_id == "m2"

    def test_update_scores(self):
        u = MarketUniverse()
        u.add_market("m1", "poly")
        score = _make_market_score("m1", composite=0.8)
        u.update_scores({"m1": score})
        entry = u.get("m1")
        assert entry.score is not None
        assert entry.score.composite_score == 0.8

    def test_update_scores_low_deactivates(self):
        cfg = UniverseConfig(min_composite_score=0.5)
        u = MarketUniverse(cfg)
        u.add_market("m1", "poly")
        low_score = _make_market_score("m1", composite=0.2)
        u.update_scores({"m1": low_score})
        assert u.size == 0  # deactivated due to low score

    def test_ranked(self):
        u = MarketUniverse()
        u.add_market("m1", "poly")
        u.add_market("m2", "poly")
        u.update_scores({
            "m1": _make_market_score("m1", 0.9),
            "m2": _make_market_score("m2", 0.5),
        })
        ranked = u.ranked(top_n=10)
        assert len(ranked) == 2
        assert ranked[0].market_id == "m1"
        assert ranked[1].market_id == "m2"

    def test_ranked_top_n(self):
        u = MarketUniverse()
        for i in range(5):
            u.add_market(f"m{i}", "poly")
            u.update_scores({f"m{i}": _make_market_score(f"m{i}", 0.5 + i * 0.1)})
        ranked = u.ranked(top_n=2)
        assert len(ranked) == 2

    def test_digest_changes_on_add(self):
        u = MarketUniverse()
        u.add_market("m1", "poly")
        d1 = u.digest()
        u.add_market("m2", "poly")
        d2 = u.digest()
        assert d1 != d2

    def test_digest_stable(self):
        u = MarketUniverse()
        u.add_market("m1", "poly")
        assert u.digest() == u.digest()

    def test_by_sector(self):
        u = MarketUniverse()
        u.add_market("m1", "poly", sector="politics")
        u.add_market("m2", "poly", sector="sports")
        u.add_market("m3", "poly", sector="politics")
        sectors = u.by_sector()
        assert "politics" in sectors
        assert len(sectors["politics"]) == 2
        assert "sports" in sectors
        assert len(sectors["sports"]) == 1

    def test_by_sector_uncategorized(self):
        u = MarketUniverse()
        u.add_market("m1", "poly")  # no sector
        sectors = u.by_sector()
        assert "uncategorized" in sectors

    def test_needs_refresh_initially_true(self):
        u = MarketUniverse()
        assert u.needs_refresh() is True

    def test_removal_history(self):
        u = MarketUniverse()
        u.add_market("m1", "poly")
        u.remove_market("m1", reason="test_reason")
        history = u.removal_history()
        assert len(history) == 1
        assert history[0]["market_id"] == "m1"
        assert history[0]["reason"] == "test_reason"

    def test_is_excluded_by_market_id(self):
        cfg = UniverseConfig(excluded_market_ids=["bad_market"])
        u = MarketUniverse(cfg)
        assert u._is_excluded("bad_market", "") is True
        assert u._is_excluded("good_market", "") is False

    def test_is_excluded_by_category(self):
        cfg = UniverseConfig(excluded_categories=["Adult"])
        u = MarketUniverse(cfg)
        assert u._is_excluded("m1", "adult") is True
        assert u._is_excluded("m1", "politics") is False


# ===========================================================================
# 2. StrategyMemory
# ===========================================================================

class TestStrategyMemory:
    """Tests for _memory.py using temp SQLite files."""

    @pytest.fixture(autouse=True)
    def _setup(self, tmp_path):
        self.db_path = str(tmp_path / "test_memory.db")
        self.mem = StrategyMemory(db_path=self.db_path)
        yield
        self.mem.close()

    def test_record_and_query(self):
        eid = self.mem.record("general", "test_key", "test_value")
        assert eid > 0
        entries = self.mem.query(category="general")
        assert len(entries) == 1
        assert entries[0].key == "test_key"
        assert entries[0].value == "test_value"

    def test_record_upsert(self):
        self.mem.record("general", "k1", "v1")
        self.mem.record("general", "k1", "v2")
        entries = self.mem.query(category="general")
        assert len(entries) == 1
        assert entries[0].value == "v2"

    def test_record_strategy_outcome(self):
        eid = self.mem.record_strategy_outcome(
            strategy_name="mm_pol",
            market_type="political",
            pnl=500.0,
            sharpe=1.5,
            duration_days=7.0,
        )
        assert eid > 0
        entries = self.mem.query(category="strategy_outcome")
        assert len(entries) == 1
        assert entries[0].metadata["pnl"] == 500.0

    def test_record_failure(self):
        eid = self.mem.record_failure("bad_strat", "too_much_slippage", market_id="m1")
        assert eid > 0
        entries = self.mem.past_failures("bad_strat")
        assert len(entries) == 1
        assert entries[0].value == "too_much_slippage"

    def test_record_pattern(self):
        eid = self.mem.record_pattern("political", "spread widens before close", confidence=0.8)
        assert eid > 0
        entries = self.mem.query(category="market_pattern")
        assert len(entries) == 1
        assert entries[0].confidence == 0.8

    def test_record_regime_mapping(self):
        self.mem.record_regime_mapping("high_vol", "trend_follow", 0.9)
        entries = self.mem.regime_strategies("high_vol")
        assert len(entries) == 1
        assert entries[0].metadata["effectiveness"] == 0.9

    def test_record_edge_decay(self):
        self.mem.record_edge_decay("strat_a", initial_edge=0.10, current_edge=0.05, days_elapsed=30.0)
        entries = self.mem.query(category="edge_decay")
        assert len(entries) == 1
        assert entries[0].metadata["decay_rate_per_day"] == pytest.approx(0.05 / 30.0)

    def test_similar_strategies(self):
        self.mem.record_strategy_outcome("mm_pol", "political", 100.0, 1.0, 5.0)
        self.mem.record_strategy_outcome("trend_sports", "sports", 200.0, 2.0, 10.0)
        results = self.mem.similar_strategies("political")
        assert len(results) == 1
        assert "political" in results[0].key

    def test_past_failures_all(self):
        self.mem.record_failure("s1", "reason_a")
        self.mem.record_failure("s2", "reason_b")
        all_failures = self.mem.past_failures()
        assert len(all_failures) == 2

    def test_forget(self):
        eid = self.mem.record("general", "temp", "temporary")
        entries_before = self.mem.query(category="general")
        assert len(entries_before) == 1
        deleted = self.mem.forget(eid)
        assert deleted is True
        entries_after = self.mem.query(category="general")
        assert len(entries_after) == 0

    def test_forget_nonexistent(self):
        assert self.mem.forget(999999) is False

    def test_prune_recent_not_deleted(self):
        # Record a recent entry; pruning with a generous age should keep it
        self.mem.record("general", "recent", "value", confidence=0.05)
        # Prune entries older than 30 days with confidence < 0.1
        pruned = self.mem.prune(max_age_days=30, min_confidence=0.1)
        assert pruned == 0

    def test_prune_old_entries(self):
        # Insert a record and manually set updated_at to the past
        self.mem.record("general", "ancient", "value", confidence=0.01)
        old_time = time.time() - 400 * 86400  # 400 days ago
        self.mem._conn.execute(
            "UPDATE memory SET updated_at = ? WHERE key = ?",
            (old_time, "ancient"),
        )
        self.mem._conn.commit()
        pruned = self.mem.prune(max_age_days=365, min_confidence=0.1)
        assert pruned == 1

    def test_stats(self):
        self.mem.record("general", "k1", "v1")
        self.mem.record("strategy_outcome", "k2", "v2")
        stats = self.mem.stats()
        assert stats["total_entries"] == 2
        assert "general" in stats["by_category"]
        assert "strategy_outcome" in stats["by_category"]

    def test_export(self):
        self.mem.record("general", "k1", "v1")
        self.mem.record("general", "k2", "v2")
        exported = self.mem.export()
        assert len(exported) == 2
        assert all("entry_id" in e for e in exported)

    def test_confidence_clamped(self):
        self.mem.record("general", "hi", "value", confidence=5.0)
        entries = self.mem.query(category="general")
        assert entries[0].confidence == 1.0

    def test_query_min_confidence(self):
        self.mem.record("general", "low", "v", confidence=0.1)
        self.mem.record("general", "high", "v", confidence=0.9)
        entries = self.mem.query(min_confidence=0.5)
        assert len(entries) == 1
        assert entries[0].key == "high"


# ===========================================================================
# 3. CorrelationMonitor
# ===========================================================================

class TestCorrelationMonitor:
    """Tests for _correlation.py."""

    @staticmethod
    def _feed_returns(mon, name, returns):
        """Feed a series of NAVs computed from returns."""
        nav = 100.0
        mon.update({name: nav})
        for r in returns:
            nav *= (1 + r)
            mon.update({name: nav})

    def test_update_no_data(self):
        mon = CorrelationMonitor()
        alerts = mon.update({"s1": 100.0})
        assert alerts == []

    def test_update_builds_returns(self):
        mon = CorrelationMonitor()
        mon.update({"s1": 100.0})
        mon.update({"s1": 105.0})
        mon.update({"s1": 110.0})
        # Should have 2 returns stored
        assert len(mon._returns["s1"]) == 2

    def test_correlation_matrix_empty(self):
        mon = CorrelationMonitor()
        matrix = mon.correlation_matrix()
        assert matrix == {}

    def test_correlated_strategies_generate_alert(self):
        mon = CorrelationMonitor(max_correlation=0.8, lookback=50)
        # Feed perfectly correlated returns
        import random
        random.seed(42)
        returns = [random.gauss(0, 0.02) for _ in range(30)]

        nav_a = 100.0
        nav_b = 100.0
        alerts_total = []
        # Initialize
        mon.update({"s1": nav_a, "s2": nav_b})
        for r in returns:
            nav_a *= (1 + r)
            nav_b *= (1 + r * 0.99 + 0.0001)  # nearly identical
            alerts = mon.update({"s1": nav_a, "s2": nav_b})
            alerts_total.extend(alerts)

        # Eventually should detect high correlation
        assert len(alerts_total) > 0
        assert alerts_total[0].strategy_a == "s1"
        assert alerts_total[0].strategy_b == "s2"
        assert abs(alerts_total[0].correlation) >= 0.8

    def test_uncorrelated_no_alert(self):
        mon = CorrelationMonitor(max_correlation=0.9, lookback=50)
        import random
        random.seed(123)

        nav_a = 100.0
        nav_b = 100.0
        alerts_total = []
        mon.update({"s1": nav_a, "s2": nav_b})
        for _ in range(30):
            nav_a *= (1 + random.gauss(0, 0.02))
            nav_b *= (1 + random.gauss(0, 0.02))  # independent
            alerts = mon.update({"s1": nav_a, "s2": nav_b})
            alerts_total.extend(alerts)

        # May or may not have alerts, but with independent random streams
        # at threshold 0.9, it should be unlikely
        # (if by chance it happens, it's still correct behavior)

    def test_correlation_matrix_self_is_one(self):
        mon = CorrelationMonitor(lookback=50)
        import random
        random.seed(99)

        nav_a = 100.0
        nav_b = 100.0
        mon.update({"s1": nav_a, "s2": nav_b})
        for _ in range(20):
            nav_a *= (1 + random.gauss(0, 0.02))
            nav_b *= (1 + random.gauss(0, 0.02))
            mon.update({"s1": nav_a, "s2": nav_b})

        matrix = mon.correlation_matrix()
        if "s1" in matrix:
            assert matrix["s1"]["s1"] == 1.0
        if "s2" in matrix:
            assert matrix["s2"]["s2"] == 1.0

    def test_pairs(self):
        mon = CorrelationMonitor(lookback=50)
        import random
        random.seed(77)

        nav_a, nav_b = 100.0, 100.0
        mon.update({"s1": nav_a, "s2": nav_b})
        for _ in range(20):
            nav_a *= (1 + random.gauss(0, 0.02))
            nav_b *= (1 + random.gauss(0, 0.02))
            mon.update({"s1": nav_a, "s2": nav_b})

        pairs = mon.pairs()
        assert len(pairs) == 1
        assert pairs[0].strategy_a == "s1"
        assert pairs[0].strategy_b == "s2"
        assert -1.0 <= pairs[0].correlation <= 1.0

    def test_remove_strategy(self):
        mon = CorrelationMonitor()
        mon.update({"s1": 100.0, "s2": 100.0})
        mon.update({"s1": 105.0, "s2": 110.0})
        mon.remove_strategy("s1")
        assert "s1" not in mon._returns
        assert "s1" not in mon._last_nav

    def test_recent_alerts(self):
        mon = CorrelationMonitor()
        assert mon.recent_alerts() == []

    def test_pearson_too_few(self):
        result = CorrelationMonitor._pearson([1, 2, 3], [4, 5, 6])
        assert result is None  # fewer than 5

    def test_pearson_perfect_correlation(self):
        xs = [1.0, 2.0, 3.0, 4.0, 5.0]
        ys = [2.0, 4.0, 6.0, 8.0, 10.0]
        corr = CorrelationMonitor._pearson(xs, ys)
        assert corr is not None
        assert corr == pytest.approx(1.0, abs=1e-6)


# ===========================================================================
# 4. VaRBudgetManager
# ===========================================================================

class TestVaRBudgetManager:
    """Tests for _var_budget.py."""

    def test_set_allocation(self):
        mgr = VaRBudgetManager(total_var_budget=100_000)
        mgr.set_allocation("s1", 0.3)
        snap = mgr.snapshot()
        allocs = {a.strategy: a for a in snap.allocations}
        assert "s1" in allocs
        assert allocs["s1"].budget_pct == 0.3

    def test_set_allocation_capped(self):
        mgr = VaRBudgetManager(total_var_budget=100_000, reserve_pct=0.25)
        mgr.set_allocation("s1", 0.9)
        snap = mgr.snapshot()
        allocs = {a.strategy: a for a in snap.allocations}
        assert allocs["s1"].budget_pct <= 0.75

    def test_set_allocations_normalizes(self):
        mgr = VaRBudgetManager(total_var_budget=100_000, reserve_pct=0.25)
        mgr.set_allocations({"s1": 2.0, "s2": 2.0})
        snap = mgr.snapshot()
        allocs = {a.strategy: a for a in snap.allocations}
        total_alloc = sum(a.budget_pct for a in snap.allocations)
        assert total_alloc == pytest.approx(0.75, abs=0.01)
        assert allocs["s1"].budget_pct == pytest.approx(allocs["s2"].budget_pct, abs=0.01)

    def test_update_navs(self):
        mgr = VaRBudgetManager()
        mgr.set_allocation("s1", 0.5)
        mgr.update_navs({"s1": 10000.0})
        mgr.update_navs({"s1": 10100.0})
        # Should have one return
        assert len(mgr._returns["s1"]) == 1

    def test_compute_strategy_var_no_data(self):
        mgr = VaRBudgetManager()
        var = mgr.compute_strategy_var("nonexistent")
        assert var == 0.0

    def test_compute_strategy_var_with_data(self):
        mgr = VaRBudgetManager()
        mgr.set_allocation("s1", 0.5)
        nav = 10000.0
        mgr.update_navs({"s1": nav})
        # Generate some return data
        import random
        random.seed(42)
        for _ in range(20):
            nav *= (1 + random.gauss(-0.001, 0.02))
            mgr.update_navs({"s1": nav})
        var = mgr.compute_strategy_var("s1")
        assert var >= 0.0

    def test_snapshot_structure(self):
        mgr = VaRBudgetManager(total_var_budget=50_000, reserve_pct=0.25)
        mgr.set_allocation("s1", 0.5)
        snap = mgr.snapshot()
        assert isinstance(snap, VaRBudgetSnapshot)
        assert snap.total_budget == 50_000
        assert snap.reserve_pct == 0.25
        assert snap.reserve_amount == 12_500

    def test_can_increase_exposure_no_data(self):
        mgr = VaRBudgetManager()
        mgr.set_allocation("s1", 0.3)
        assert mgr.can_increase_exposure("s1") is True  # no returns yet

    def test_can_increase_exposure_false_at_limit(self):
        mgr = VaRBudgetManager(total_var_budget=100.0)
        mgr.set_allocation("s1", 0.001)  # tiny budget
        nav = 10000.0
        mgr.update_navs({"s1": nav})
        import random
        random.seed(7)
        for _ in range(20):
            nav *= (1 + random.gauss(-0.01, 0.05))
            mgr.update_navs({"s1": nav})
        # With such tiny budget and large moves, should be at limit
        can = mgr.can_increase_exposure("s1")
        # Not deterministic, but the budget is 0.1 so any VaR > 0.1 triggers
        assert isinstance(can, bool)

    def test_marginal_var_no_data(self):
        mgr = VaRBudgetManager()
        assert mgr.marginal_var("s1", -0.05) == 0.0

    def test_marginal_var_with_data(self):
        mgr = VaRBudgetManager()
        mgr.set_allocation("s1", 0.5)
        nav = 10000.0
        mgr.update_navs({"s1": nav})
        import random
        random.seed(55)
        for _ in range(15):
            nav *= (1 + random.gauss(0, 0.01))
            mgr.update_navs({"s1": nav})
        mvar = mgr.marginal_var("s1", -0.10)
        assert isinstance(mvar, float)

    def test_remove_strategy(self):
        mgr = VaRBudgetManager()
        mgr.set_allocation("s1", 0.3)
        mgr.update_navs({"s1": 100.0})
        mgr.remove_strategy("s1")
        snap = mgr.snapshot()
        assert len(snap.allocations) == 0


# ===========================================================================
# 5. FundStressTester
# ===========================================================================

class TestFundStressTester:
    """Tests for _fund_stress.py."""

    @staticmethod
    def _mock_engine(positions=None):
        engine = MagicMock()
        if positions is None:
            positions = []
        engine.positions.return_value = positions
        return engine

    @staticmethod
    def _mock_position(size=10.0, avg_entry_price=0.55, exchange=""):
        pos = MagicMock()
        pos.size = size
        pos.avg_entry_price = avg_entry_price
        pos.exchange = exchange
        return pos

    def test_run_default_scenarios(self):
        st = FundStressTester()
        eng = self._mock_engine([self._mock_position()])
        result = st.run(
            engines={"s1": eng},
            strategy_capitals={"s1": 10_000},
            current_nav=100_000,
            hwm=100_000,
        )
        assert isinstance(result, FundStressResult)
        assert len(result.scenarios) == 6  # 6 default scenarios
        assert result.current_nav == 100_000

    def test_run_custom_scenario(self):
        st = FundStressTester()
        scenario = FundScenario(name="custom_crash", price_shock_pct=-50.0)
        eng = self._mock_engine([self._mock_position(100, 0.5)])
        result = st.run(
            engines={"s1": eng},
            strategy_capitals={"s1": 10_000},
            current_nav=100_000,
            hwm=100_000,
            scenarios=[scenario],
        )
        assert len(result.scenarios) == 1
        assert result.scenarios[0].scenario.name == "custom_crash"
        assert result.scenarios[0].pnl_impact < 0

    def test_strategy_impact_price_shock(self):
        st = FundStressTester()
        pos = self._mock_position(size=100, avg_entry_price=0.50)
        eng = self._mock_engine([pos])
        scenario = FundScenario(name="test", price_shock_pct=-10.0)
        impact = st._strategy_impact(scenario, eng, 5000.0)
        # notional = 100 * 0.50 = 50, impact = 50 * -0.10 = -5
        assert impact == pytest.approx(-5.0)

    def test_strategy_impact_exchange_outage(self):
        st = FundStressTester()
        pos = self._mock_position(size=100, avg_entry_price=1.0, exchange="polymarket")
        eng = self._mock_engine([pos])
        scenario = FundScenario(
            name="outage",
            price_shock_pct=-5.0,
            exchange_down="polymarket",
        )
        impact = st._strategy_impact(scenario, eng, 10_000.0)
        # price shock: 100 * 1.0 * -0.05 = -5
        # outage: 100 * 1.0 * -0.05 = -5
        assert impact == pytest.approx(-10.0)

    def test_strategy_impact_liquidity_reduction(self):
        st = FundStressTester()
        pos = self._mock_position(size=100, avg_entry_price=1.0)
        eng = self._mock_engine([pos])
        scenario = FundScenario(name="liq", price_shock_pct=0.0, liquidity_reduction_pct=80.0)
        impact = st._strategy_impact(scenario, eng, 10_000.0)
        # slippage = 100 * 1.0 * 0.80 * 0.05 = -4.0
        assert impact == pytest.approx(-4.0)

    def test_breached_strategies(self):
        st = FundStressTester(max_strategy_drawdown_pct=5.0)
        pos = self._mock_position(size=1000, avg_entry_price=1.0)
        eng = self._mock_engine([pos])
        scenario = FundScenario(name="crash", price_shock_pct=-20.0)
        result = st.run(
            engines={"s1": eng},
            strategy_capitals={"s1": 1000},
            current_nav=10_000,
            hwm=10_000,
            scenarios=[scenario],
        )
        assert "s1" in result.scenarios[0].strategies_breached

    def test_passes_all(self):
        st = FundStressTester(max_fund_drawdown_pct=99.0)
        eng = self._mock_engine([self._mock_position(1, 0.01)])
        result = st.run(
            engines={"s1": eng},
            strategy_capitals={"s1": 100},
            current_nav=100_000,
            hwm=100_000,
        )
        assert result.passes_all is True

    def test_no_positions_zero_impact(self):
        st = FundStressTester()
        eng = self._mock_engine([])
        scenario = FundScenario(name="t", price_shock_pct=-10.0)
        impact = st._strategy_impact(scenario, eng, 5000.0)
        assert impact == 0.0

    def test_engine_error_falls_back_to_capital(self):
        st = FundStressTester()
        eng = MagicMock()
        eng.positions.side_effect = RuntimeError("boom")
        scenario = FundScenario(name="t", price_shock_pct=-10.0)
        impact = st._strategy_impact(scenario, eng, 5000.0)
        assert impact == pytest.approx(-500.0)

    def test_worst_scenario(self):
        st = FundStressTester()
        eng = self._mock_engine([self._mock_position(100, 1.0)])
        result = st.run(
            engines={"s1": eng},
            strategy_capitals={"s1": 10_000},
            current_nav=100_000,
            hwm=100_000,
        )
        assert result.worst_scenario == "black_swan"  # -30% is the worst


# ===========================================================================
# 6. ExecutionQualityTracker
# ===========================================================================

class TestExecutionQualityTracker:
    """Tests for _exec_quality.py."""

    def test_record_fill(self):
        t = ExecutionQualityTracker()
        t.record_fill("s1", expected_price=0.50, fill_price=0.51, mid_price_at_fill=0.505)
        metrics = t.strategy_metrics("s1")
        assert metrics["total_fills"] == 1
        assert metrics["avg_slippage_bps"] > 0

    def test_record_quote(self):
        t = ExecutionQualityTracker()
        t.record_quote("s1")
        t.record_quote("s1")
        metrics = t.strategy_metrics("s1")
        assert metrics["total_quotes"] == 2

    def test_fill_rate(self):
        t = ExecutionQualityTracker()
        for _ in range(10):
            t.record_quote("s1")
        for _ in range(5):
            t.record_fill("s1", 0.50, 0.50, 0.50)
        metrics = t.strategy_metrics("s1")
        assert metrics["fill_rate"] == pytest.approx(0.5)

    def test_snapshot(self):
        t = ExecutionQualityTracker()
        t.record_quote("s1")
        t.record_fill("s1", 0.50, 0.505, 0.50)
        report = t.snapshot()
        assert isinstance(report, FundExecutionReport)
        assert report.total_fills == 1
        assert report.total_quotes == 1
        assert len(report.strategies) == 1

    def test_snapshot_empty(self):
        t = ExecutionQualityTracker()
        report = t.snapshot()
        assert report.total_fills == 0
        assert report.total_quotes == 0

    def test_strategy_metrics_unknown(self):
        t = ExecutionQualityTracker()
        metrics = t.strategy_metrics("nonexistent")
        assert metrics["fill_rate"] == 0.0

    def test_adverse_selection_tracking(self):
        t = ExecutionQualityTracker()
        t.record_fill(
            "s1",
            expected_price=0.50,
            fill_price=0.50,
            mid_price_at_fill=0.50,
            mid_price_after=0.48,  # adverse move
        )
        metrics = t.strategy_metrics("s1")
        assert metrics["adverse_selection"] > 0

    def test_recommendations_low_fill_rate(self):
        t = ExecutionQualityTracker()
        for _ in range(20):
            t.record_quote("s1")
        for _ in range(5):
            t.record_fill("s1", 0.50, 0.50, 0.50)
        recs = t.recommendations()
        assert "s1" in recs
        assert recs["s1"]["spread"] == "tighten_5pct"

    def test_recommendations_high_adverse(self):
        t = ExecutionQualityTracker()
        for _ in range(20):
            t.record_quote("s1")
            t.record_fill("s1", 0.50, 0.50, 0.50, mid_price_after=0.40)
        recs = t.recommendations()
        assert "s1" in recs
        assert recs["s1"]["spread"] == "widen_10pct"

    def test_remove_strategy(self):
        t = ExecutionQualityTracker()
        t.record_fill("s1", 0.50, 0.50, 0.50)
        t.remove_strategy("s1")
        metrics = t.strategy_metrics("s1")
        assert metrics["fill_rate"] == 0.0

    def test_multiple_strategies(self):
        t = ExecutionQualityTracker()
        t.record_fill("s1", 0.50, 0.50, 0.50)
        t.record_fill("s2", 0.60, 0.61, 0.605)
        report = t.snapshot()
        assert len(report.strategies) == 2
        assert report.total_fills == 2


# ===========================================================================
# 7. AdaptiveExecutionTuner
# ===========================================================================

class TestAdaptiveExecutionTuner:
    """Tests for _adaptive.py."""

    def test_tune_disabled(self):
        config = AdaptiveConfig(enabled=False)
        tuner = AdaptiveExecutionTuner(config)
        adjustments = tuner.tune("s1", {"fill_rate": 0.2, "total_fills": 50})
        assert adjustments == []

    def test_tune_not_enough_fills(self):
        tuner = AdaptiveExecutionTuner()
        adjustments = tuner.tune("s1", {"fill_rate": 0.2, "total_fills": 5})
        assert adjustments == []

    def test_tune_high_adverse_widens_spread(self):
        tuner = AdaptiveExecutionTuner()
        metrics = {
            "fill_rate": 0.7,
            "avg_slippage_bps": 5.0,
            "adverse_selection": 0.02,  # above default 0.01 threshold
            "market_impact_bps": 5.0,
            "total_fills": 50,
        }
        adjustments = tuner.tune("s1", metrics)
        spread_adj = [a for a in adjustments if a.parameter == "spread_multiplier"]
        assert len(spread_adj) == 1
        assert spread_adj[0].new_value > spread_adj[0].old_value

    def test_tune_low_fill_rate_tightens(self):
        tuner = AdaptiveExecutionTuner()
        metrics = {
            "fill_rate": 0.3,  # below 0.5
            "avg_slippage_bps": 2.0,
            "adverse_selection": 0.005,  # low
            "market_impact_bps": 2.0,
            "total_fills": 50,
        }
        adjustments = tuner.tune("s1", metrics)
        spread_adj = [a for a in adjustments if a.parameter == "spread_multiplier"]
        assert len(spread_adj) == 1
        assert spread_adj[0].new_value < spread_adj[0].old_value

    def test_tune_high_slippage_reduces_size(self):
        tuner = AdaptiveExecutionTuner()
        metrics = {
            "fill_rate": 0.7,
            "avg_slippage_bps": 30.0,  # above 20 threshold
            "adverse_selection": 0.005,
            "market_impact_bps": 5.0,
            "total_fills": 50,
        }
        adjustments = tuner.tune("s1", metrics)
        size_adj = [a for a in adjustments if a.parameter == "size_multiplier"]
        assert len(size_adj) == 1
        assert size_adj[0].new_value < size_adj[0].old_value

    def test_tune_good_quality_increases_size(self):
        tuner = AdaptiveExecutionTuner()
        metrics = {
            "fill_rate": 0.95,  # above 0.9
            "avg_slippage_bps": 2.0,
            "adverse_selection": 0.002,  # below 0.01 * 0.5
            "market_impact_bps": 2.0,
            "total_fills": 50,
        }
        adjustments = tuner.tune("s1", metrics)
        size_adj = [a for a in adjustments if a.parameter == "size_multiplier"]
        assert len(size_adj) == 1
        assert size_adj[0].new_value > size_adj[0].old_value

    def test_rate_limiting(self):
        tuner = AdaptiveExecutionTuner(AdaptiveConfig(adjustment_interval_secs=600))
        metrics = {"fill_rate": 0.3, "adverse_selection": 0.005,
                    "avg_slippage_bps": 2.0, "market_impact_bps": 2.0, "total_fills": 50}
        # First call succeeds
        adj1 = tuner.tune("s1", metrics)
        assert len(adj1) > 0
        # Second call rate-limited
        adj2 = tuner.tune("s1", metrics)
        assert adj2 == []

    def test_reset(self):
        tuner = AdaptiveExecutionTuner()
        metrics = {"fill_rate": 0.3, "adverse_selection": 0.005,
                    "avg_slippage_bps": 2.0, "market_impact_bps": 2.0, "total_fills": 50}
        tuner.tune("s1", metrics)
        params_before = tuner.current_params("s1")
        assert params_before != {}
        tuner.reset("s1")
        assert tuner.current_params("s1") == {}

    def test_current_params_default(self):
        tuner = AdaptiveExecutionTuner()
        assert tuner.current_params("unknown") == {}

    def test_recent_adjustments(self):
        tuner = AdaptiveExecutionTuner()
        metrics = {"fill_rate": 0.3, "adverse_selection": 0.005,
                    "avg_slippage_bps": 2.0, "market_impact_bps": 2.0, "total_fills": 50}
        tuner.tune("s1", metrics)
        recent = tuner.recent_adjustments()
        assert len(recent) > 0


# ===========================================================================
# 8. SmartRouter
# ===========================================================================

class TestSmartRouter:
    """Tests for _routing.py."""

    def test_route_basic(self):
        router = SmartRouter()
        decision = router.route(
            market_id="m1",
            side="buy",
            size=10.0,
            venue_prices={"polymarket": 0.50, "kalshi": 0.52},
        )
        assert isinstance(decision, RouteDecision)
        assert decision.selected_exchange in ("polymarket", "kalshi")
        assert decision.market_id == "m1"

    def test_route_buy_prefers_lower_price(self):
        router = SmartRouter(price_weight=1.0, liquidity_weight=0.0,
                             fee_weight=0.0, latency_weight=0.0)
        decision = router.route("m1", "buy", 10.0,
                                {"a": 0.40, "b": 0.60})
        assert decision.selected_exchange == "a"

    def test_route_sell_prefers_higher_price(self):
        router = SmartRouter(price_weight=1.0, liquidity_weight=0.0,
                             fee_weight=0.0, latency_weight=0.0)
        decision = router.route("m1", "sell", 10.0,
                                {"a": 0.40, "b": 0.60})
        assert decision.selected_exchange == "b"

    def test_route_with_health(self):
        router = SmartRouter()
        router.update_health("bad_ex", latency_ms=10_000, error_rate=0.5,
                             fill_rate=0.1, last_fill_age_secs=600)
        router.update_health("good_ex", latency_ms=50, error_rate=0.01,
                             fill_rate=0.9, last_fill_age_secs=5)
        decision = router.route(
            "m1", "buy", 10.0,
            {"bad_ex": 0.50, "good_ex": 0.51},
        )
        # bad_ex is unhealthy, should pick good_ex
        assert decision.selected_exchange == "good_ex"

    def test_update_health(self):
        router = SmartRouter()
        h = router.update_health("poly", 50, 0.01, 0.9, 5.0)
        assert isinstance(h, ExchangeHealth)
        assert h.is_healthy is True

    def test_update_health_unhealthy(self):
        router = SmartRouter()
        h = router.update_health("bad", 10_000, 0.5, 0.1, 600)
        assert h.is_healthy is False

    def test_update_latency(self):
        router = SmartRouter()
        router.update_latency("poly", 100.0)
        assert router._latencies["poly"] == 100.0

    def test_fee_rates(self):
        router = SmartRouter()
        router.set_fee_rate("new_ex", 0.005)
        assert router._fee_rates["new_ex"] == 0.005

    def test_route_no_venues_raises(self):
        router = SmartRouter()
        with pytest.raises(ValueError, match="No venues"):
            router.route("m1", "buy", 10.0, {})

    def test_route_all_unhealthy_fallback(self):
        router = SmartRouter()
        router.update_health("ex1", 10_000, 0.5, 0.1, 600)
        decision = router.route("m1", "buy", 10.0, {"ex1": 0.50})
        assert decision.selected_exchange == "ex1"
        assert decision.reason == "all_venues_unhealthy"

    def test_route_decision_scores_sorted(self):
        router = SmartRouter()
        decision = router.route(
            "m1", "buy", 10.0,
            {"a": 0.50, "b": 0.55, "c": 0.45},
        )
        if len(decision.scores) >= 2:
            assert decision.scores[0].composite_score >= decision.scores[1].composite_score

    def test_recent_decisions(self):
        router = SmartRouter()
        router.route("m1", "buy", 10.0, {"a": 0.50})
        router.route("m2", "sell", 5.0, {"b": 0.60})
        decisions = router.recent_decisions()
        assert len(decisions) == 2


# ===========================================================================
# 9. ResearchPipeline
# ===========================================================================

class TestResearchPipeline:
    """Tests for _research.py."""

    @staticmethod
    def _make_entry(market_id, score_val=0.6, exchange="polymarket"):
        score = _make_market_score(market_id, composite=score_val)
        return _make_universe_entry(market_id, exchange=exchange, score=score)

    def test_scan_no_entries(self):
        rp = ResearchPipeline()
        scan = rp.scan([])
        assert scan.markets_scanned == 0
        assert scan.opportunities_found == 0

    def test_scan_with_edge_estimator(self):
        rp = ResearchPipeline(min_fitness=0.3, min_edge=0.01, min_confidence=0.3)
        rp.set_edge_estimator(lambda mid, ex: (0.05, 0.7))
        entries = [self._make_entry("m1", 0.8), self._make_entry("m2", 0.7)]
        scan = rp.scan(entries)
        assert scan.markets_scanned == 2
        assert scan.opportunities_found == 2

    def test_scan_filters_low_fitness(self):
        rp = ResearchPipeline(min_fitness=0.5, min_edge=0.01, min_confidence=0.3)
        rp.set_edge_estimator(lambda mid, ex: (0.05, 0.7))
        entries = [self._make_entry("m1", 0.2)]  # below min_fitness
        scan = rp.scan(entries)
        assert scan.opportunities_found == 0

    def test_scan_filters_low_edge(self):
        rp = ResearchPipeline(min_fitness=0.3, min_edge=0.10, min_confidence=0.3)
        rp.set_edge_estimator(lambda mid, ex: (0.02, 0.9))  # edge too low
        entries = [self._make_entry("m1", 0.8)]
        scan = rp.scan(entries)
        assert scan.opportunities_found == 0

    def test_scan_filters_low_confidence(self):
        rp = ResearchPipeline(min_fitness=0.3, min_edge=0.01, min_confidence=0.8)
        rp.set_edge_estimator(lambda mid, ex: (0.05, 0.3))  # confidence too low
        entries = [self._make_entry("m1", 0.8)]
        scan = rp.scan(entries)
        assert scan.opportunities_found == 0

    def test_set_strategy_matcher(self):
        rp = ResearchPipeline(min_fitness=0.3, min_edge=0.01, min_confidence=0.3)
        rp.set_edge_estimator(lambda mid, ex: (0.05, 0.7))
        rp.set_strategy_matcher(lambda score: "trend_follow")
        entries = [self._make_entry("m1", 0.8)]
        scan = rp.scan(entries)
        assert scan.opportunities[0].strategy_type == "trend_follow"

    def test_recent_scans(self):
        rp = ResearchPipeline(min_fitness=0.3, min_edge=0.01, min_confidence=0.3)
        rp.set_edge_estimator(lambda mid, ex: (0.05, 0.7))
        rp.scan([self._make_entry("m1", 0.8)])
        rp.scan([self._make_entry("m2", 0.7)])
        assert len(rp.recent_scans()) == 2

    def test_best_opportunities_empty(self):
        rp = ResearchPipeline()
        assert rp.best_opportunities() == []

    def test_best_opportunities(self):
        rp = ResearchPipeline(min_fitness=0.3, min_edge=0.01, min_confidence=0.3)
        rp.set_edge_estimator(lambda mid, ex: (0.05, 0.7))
        entries = [self._make_entry(f"m{i}", 0.5 + i * 0.05) for i in range(10)]
        rp.scan(entries)
        best = rp.best_opportunities(limit=3)
        assert len(best) == 3

    def test_opportunities_ranked(self):
        rp = ResearchPipeline(min_fitness=0.3, min_edge=0.01, min_confidence=0.3)
        rp.set_edge_estimator(lambda mid, ex: (0.05, 0.7))
        entries = [self._make_entry("m1", 0.9), self._make_entry("m2", 0.4)]
        scan = rp.scan(entries)
        if len(scan.opportunities) == 2:
            # Higher fitness should rank first
            assert scan.opportunities[0].fitness_score >= scan.opportunities[1].fitness_score

    def test_max_opportunities_cap(self):
        rp = ResearchPipeline(
            min_fitness=0.1, min_edge=0.01, min_confidence=0.1,
            max_opportunities=2,
        )
        rp.set_edge_estimator(lambda mid, ex: (0.05, 0.7))
        entries = [self._make_entry(f"m{i}", 0.5) for i in range(10)]
        scan = rp.scan(entries)
        assert scan.opportunities_found <= 2


# ===========================================================================
# 10. Autopilot
# ===========================================================================

class TestAutopilot:
    """Tests for _autopilot.py."""

    @staticmethod
    def _make_candidate(**kwargs):
        defaults = {
            "market_id": "m1",
            "exchange": "polymarket",
            "strategy_type": "market_maker",
            "pipeline": [lambda ctx: ctx],
            "backtest_sharpe": 0.0,
            "backtest_trades": 0,
            "backtest_drawdown_pct": 0.0,
            "backtest_profit_factor": 0.0,
            "robustness_p_value": 1.0,
        }
        defaults.update(kwargs)
        return DeployCandidate(**defaults)

    def test_evaluate_passes(self):
        ap = Autopilot(criteria=DeployCriteria(
            min_sharpe=1.0, min_trades=10, max_drawdown_pct=20.0,
            min_profit_factor=1.0, max_robustness_p=0.10,
        ))
        candidate = self._make_candidate(
            backtest_sharpe=1.5, backtest_trades=50,
            backtest_drawdown_pct=10.0, backtest_profit_factor=1.5,
            robustness_p_value=0.02,
        )
        result = ap.evaluate(candidate)
        assert result.passes_criteria is True
        assert result.rejection_reasons == []

    def test_evaluate_fails_sharpe(self):
        ap = Autopilot(criteria=DeployCriteria(min_sharpe=2.0))
        candidate = self._make_candidate(
            backtest_sharpe=0.5, backtest_trades=50,
            backtest_drawdown_pct=10.0, backtest_profit_factor=1.5,
            robustness_p_value=0.01,
        )
        result = ap.evaluate(candidate)
        assert result.passes_criteria is False
        assert any("sharpe" in r for r in result.rejection_reasons)

    def test_evaluate_fails_trades(self):
        ap = Autopilot(criteria=DeployCriteria(min_trades=100))
        candidate = self._make_candidate(
            backtest_sharpe=2.0, backtest_trades=10,
            backtest_drawdown_pct=10.0, backtest_profit_factor=1.5,
            robustness_p_value=0.01,
        )
        result = ap.evaluate(candidate)
        assert result.passes_criteria is False
        assert any("trades" in r for r in result.rejection_reasons)

    def test_evaluate_with_backtest_fn(self):
        ap = Autopilot(criteria=DeployCriteria(
            min_sharpe=1.0, min_trades=10, max_drawdown_pct=30.0,
            min_profit_factor=1.0, max_robustness_p=1.0,
        ))
        mock_metrics = MagicMock()
        mock_metrics.sharpe = 2.0
        mock_metrics.total_trades = 100
        mock_metrics.max_drawdown_pct = -5.0
        mock_metrics.profit_factor = 2.0
        mock_result = MagicMock()
        mock_result.metrics = mock_metrics

        candidate = self._make_candidate()
        result = ap.evaluate(candidate, backtest_fn=lambda c: mock_result)
        assert result.backtest_sharpe == 2.0
        assert result.backtest_trades == 100

    def test_deploy_candidates(self):
        ap = Autopilot()
        candidate = self._make_candidate(
            backtest_sharpe=2.0, backtest_trades=50,
            backtest_drawdown_pct=10.0, backtest_profit_factor=2.0,
            robustness_p_value=0.01,
        )
        ap.evaluate(candidate)
        assert candidate.passes_criteria is True

        mock_fund = MagicMock()
        deployed = ap.deploy_candidates(mock_fund)
        assert len(deployed) == 1
        assert deployed[0].deployed is True
        assert mock_fund.add_strategy.called

    def test_deploy_skips_no_pipeline(self):
        ap = Autopilot()
        candidate = self._make_candidate(
            pipeline=None,
            backtest_sharpe=2.0, backtest_trades=50,
            backtest_drawdown_pct=10.0, backtest_profit_factor=2.0,
            robustness_p_value=0.01,
        )
        ap.evaluate(candidate)
        mock_fund = MagicMock()
        deployed = ap.deploy_candidates(mock_fund)
        assert len(deployed) == 0

    def test_passing_candidates(self):
        ap = Autopilot(criteria=DeployCriteria(
            min_sharpe=1.0, min_trades=10, max_drawdown_pct=30.0,
            min_profit_factor=1.0, max_robustness_p=1.0,
        ))
        good = self._make_candidate(
            market_id="good",
            backtest_sharpe=2.0, backtest_trades=50,
            backtest_drawdown_pct=10.0, backtest_profit_factor=2.0,
            robustness_p_value=0.01,
        )
        bad = self._make_candidate(
            market_id="bad",
            backtest_sharpe=0.1, backtest_trades=5,
        )
        ap.evaluate(good)
        ap.evaluate(bad)
        assert len(ap.passing_candidates()) == 1
        assert len(ap.rejected_candidates()) == 1

    def test_rejected_candidates_have_reasons(self):
        ap = Autopilot(criteria=DeployCriteria(min_sharpe=5.0))
        c = self._make_candidate(backtest_sharpe=0.1, backtest_trades=50,
                                 backtest_profit_factor=1.5, robustness_p_value=0.01)
        ap.evaluate(c)
        rejected = ap.rejected_candidates()
        assert len(rejected) == 1
        assert len(rejected[0].rejection_reasons) > 0

    def test_clear(self):
        ap = Autopilot()
        c = self._make_candidate(backtest_sharpe=2.0, backtest_trades=50,
                                 backtest_drawdown_pct=10.0, backtest_profit_factor=2.0,
                                 robustness_p_value=0.01)
        ap.evaluate(c)
        assert len(ap.all_candidates()) == 1
        ap.clear()
        assert len(ap.all_candidates()) == 0

    def test_max_deploys_per_cycle(self):
        ap = Autopilot(max_deploys_per_cycle=1)
        for i in range(3):
            c = self._make_candidate(
                market_id=f"m{i}",
                backtest_sharpe=2.0, backtest_trades=50,
                backtest_drawdown_pct=10.0, backtest_profit_factor=2.0,
                robustness_p_value=0.01,
            )
            ap.evaluate(c)
        mock_fund = MagicMock()
        deployed = ap.deploy_candidates(mock_fund)
        assert len(deployed) == 1


# ===========================================================================
# 11. DecisionFramework
# ===========================================================================

class TestDecisionFramework:
    """Tests for _decisions.py using temp SQLite files."""

    @pytest.fixture(autouse=True)
    def _setup(self, tmp_path):
        self.db_path = str(tmp_path / "test_decisions.db")
        self.fw = DecisionFramework(
            mode=OperatingMode.AUTONOMOUS,
            db_path=self.db_path,
        )
        yield
        self.fw.close()

    def test_propose_executed_autonomous(self):
        d = self.fw.propose(
            ActionType.RESEARCH_SCAN, {}, "test scan", confidence=0.9,
        )
        assert d.outcome == "executed"

    def test_propose_dry_run_pending(self):
        fw = DecisionFramework(
            mode=OperatingMode.DRY_RUN,
            db_path=self.db_path.replace(".db", "_dry.db"),
        )
        d = fw.propose(ActionType.RESEARCH_SCAN, {}, "test", confidence=0.9)
        assert d.outcome == "pending"
        assert len(fw.pending_decisions()) == 1
        fw.close()

    def test_propose_supervised_low_risk_executes(self):
        fw = DecisionFramework(
            mode=OperatingMode.SUPERVISED,
            db_path=self.db_path.replace(".db", "_sup.db"),
        )
        d = fw.propose(ActionType.RESEARCH_SCAN, {}, "scan", confidence=0.9)
        assert d.outcome == "executed"
        fw.close()

    def test_propose_supervised_high_risk_escalates(self):
        fw = DecisionFramework(
            mode=OperatingMode.SUPERVISED,
            db_path=self.db_path.replace(".db", "_sup2.db"),
        )
        d = fw.propose(ActionType.PROMOTE_LIVE, {}, "promote", confidence=0.9)
        assert d.outcome == "escalated"
        fw.close()

    def test_confidence_below_threshold_escalates(self):
        thresholds = ConfidenceThresholds(research_scan=0.5)
        fw = DecisionFramework(
            mode=OperatingMode.AUTONOMOUS,
            thresholds=thresholds,
            db_path=self.db_path.replace(".db", "_conf.db"),
        )
        d = fw.propose(ActionType.RESEARCH_SCAN, {}, "test", confidence=0.2)
        assert d.outcome == "escalated"
        assert "confidence" in d.error
        fw.close()

    def test_rate_limit_deploys(self):
        rl = RateLimits(max_deploys_per_hour=1)
        fw = DecisionFramework(
            mode=OperatingMode.AUTONOMOUS,
            rate_limits=rl,
            db_path=self.db_path.replace(".db", "_rl.db"),
        )
        d1 = fw.propose(ActionType.DEPLOY_STAGING, {"n": 1}, "first", confidence=0.9)
        assert d1.outcome == "executed"
        d2 = fw.propose(ActionType.DEPLOY_STAGING, {"n": 2}, "second", confidence=0.9)
        assert d2.outcome == "rejected"
        assert "rate limit" in d2.error
        fw.close()

    def test_approve(self):
        fw = DecisionFramework(
            mode=OperatingMode.DRY_RUN,
            db_path=self.db_path.replace(".db", "_appr.db"),
        )
        d = fw.propose(ActionType.RESEARCH_SCAN, {}, "test", confidence=0.9)
        assert d.outcome == "pending"
        result = fw.approve(d.decision_id)
        assert result is True
        assert len(fw.pending_decisions()) == 0
        fw.close()

    def test_reject(self):
        fw = DecisionFramework(
            mode=OperatingMode.DRY_RUN,
            db_path=self.db_path.replace(".db", "_rej.db"),
        )
        d = fw.propose(ActionType.RESEARCH_SCAN, {}, "test", confidence=0.9)
        result = fw.reject(d.decision_id, "not now")
        assert result is True
        assert len(fw.pending_decisions()) == 0
        fw.close()

    def test_approve_nonexistent(self):
        assert self.fw.approve("nonexistent") is False

    def test_reject_nonexistent(self):
        assert self.fw.reject("nonexistent") is False

    def test_recent_decisions(self):
        self.fw.propose(ActionType.RESEARCH_SCAN, {}, "a", confidence=0.9)
        self.fw.propose(ActionType.REBALANCE, {}, "b", confidence=0.9)
        recent = self.fw.recent_decisions(limit=10)
        assert len(recent) == 2

    def test_recent_decisions_filter_action(self):
        self.fw.propose(ActionType.RESEARCH_SCAN, {}, "a", confidence=0.9)
        self.fw.propose(ActionType.REBALANCE, {}, "b", confidence=0.9)
        recent = self.fw.recent_decisions(action=ActionType.RESEARCH_SCAN)
        assert len(recent) == 1

    def test_stats(self):
        self.fw.propose(ActionType.RESEARCH_SCAN, {}, "a", confidence=0.9)
        stats = self.fw.stats()
        assert stats["mode"] == "autonomous"
        assert "by_outcome" in stats
        assert "executed_by_action" in stats

    def test_set_mode(self):
        self.fw.set_mode(OperatingMode.DRY_RUN)
        assert self.fw.mode == OperatingMode.DRY_RUN

    def test_decision_id_deterministic(self):
        id1 = self.fw._generate_id(ActionType.RESEARCH_SCAN, {"a": 1}, 12345.0)
        id2 = self.fw._generate_id(ActionType.RESEARCH_SCAN, {"a": 1}, 12345.0)
        assert id1 == id2

    def test_confidence_clamped(self):
        d = self.fw.propose(ActionType.RESEARCH_SCAN, {}, "test", confidence=5.0)
        assert d.confidence == 1.0


# ===========================================================================
# 12. AutonomousAgent
# ===========================================================================

class TestAutonomousAgent:
    """Tests for _agent.py with mocked FundManager."""

    @pytest.fixture(autouse=True)
    def _setup(self, tmp_path):
        self.db_path = str(tmp_path / "test_agent.db")
        self.mock_fund = MagicMock()
        self.mock_fund.status.return_value = {"running": True, "nav": 100_000}
        self.mock_fund.controller.status.return_value = MagicMock(allocated_capital=5000.0)
        self.agent = AutonomousAgent(
            fund=self.mock_fund,
            mode=OperatingMode.AUTONOMOUS,
            db_path=self.db_path,
        )
        yield
        self.agent.close()

    def test_deploy_strategy_paper(self):
        config = StrategyConfig(
            name="test_strat",
            pipeline=[lambda ctx: ctx],
            markets=["m1"],
            mode="paper",
        )
        decision = self.agent.deploy_strategy(config, "good edge", confidence=0.9)
        assert decision.outcome == "executed"
        assert self.mock_fund.add_strategy.called

    def test_deploy_strategy_live_escalated_in_supervised(self):
        agent = AutonomousAgent(
            fund=self.mock_fund,
            mode=OperatingMode.SUPERVISED,
            db_path=self.db_path.replace(".db", "_sup.db"),
        )
        config = StrategyConfig(
            name="live_strat",
            pipeline=[lambda ctx: ctx],
            markets=["m1"],
            mode="live",
        )
        decision = agent.deploy_strategy(config, "live deploy", confidence=0.9)
        assert decision.outcome == "escalated"  # PROMOTE_LIVE is high-risk
        agent.close()

    def test_retire_strategy(self):
        decision = self.agent.retire_strategy("old_strat", "underperforming", confidence=0.8)
        assert decision.outcome == "executed"
        self.mock_fund.remove_strategy.assert_called_with("old_strat")

    def test_scale_strategy(self):
        decision = self.agent.scale_strategy("s1", 10_000.0, "scaling up", confidence=0.9)
        # SCALE_UP is high-risk in supervised, but we're autonomous
        assert decision.outcome == "executed"
        self.mock_fund.scale_strategy.assert_called_with("s1", 10_000.0)

    def test_activate_kill_switch(self):
        decision = self.agent.activate_kill_switch("emergency", confidence=0.9)
        assert decision.outcome == "executed"

    def test_rebalance(self):
        self.mock_fund.controller.engines = {"s1": MagicMock()}
        self.mock_fund.allocator.allocate.return_value = {"s1": 50_000}
        decision = self.agent.rebalance("periodic", confidence=0.9)
        assert decision.outcome == "executed"

    def test_research_scan(self):
        decision = self.agent.research_scan("daily scan", confidence=0.8)
        assert decision.outcome == "executed"

    def test_approve(self):
        agent = AutonomousAgent(
            fund=self.mock_fund,
            mode=OperatingMode.DRY_RUN,
            db_path=self.db_path.replace(".db", "_appr.db"),
        )
        config = StrategyConfig(name="s", pipeline=[lambda c: c], markets=["m1"], mode="paper")
        decision = agent.deploy_strategy(config, "test", confidence=0.9)
        assert decision.outcome == "pending"
        result = agent.approve(decision.decision_id)
        assert result is True
        agent.close()

    def test_reject(self):
        agent = AutonomousAgent(
            fund=self.mock_fund,
            mode=OperatingMode.DRY_RUN,
            db_path=self.db_path.replace(".db", "_rej.db"),
        )
        decision = agent.research_scan("test", confidence=0.9)
        assert decision.outcome == "pending"
        result = agent.reject(decision.decision_id, "not needed")
        assert result is True
        agent.close()

    def test_mode_switching(self):
        assert self.agent.mode == OperatingMode.AUTONOMOUS
        self.agent.set_mode("dry_run")
        assert self.agent.mode == OperatingMode.DRY_RUN
        self.agent.set_mode(OperatingMode.SUPERVISED)
        assert self.agent.mode == OperatingMode.SUPERVISED

    def test_pending(self):
        agent = AutonomousAgent(
            fund=self.mock_fund,
            mode=OperatingMode.DRY_RUN,
            db_path=self.db_path.replace(".db", "_pend.db"),
        )
        agent.research_scan("test", confidence=0.9)
        assert len(agent.pending()) == 1
        agent.close()

    def test_decision_log(self):
        self.agent.research_scan("scan1", confidence=0.9)
        self.agent.research_scan("scan2", confidence=0.9)
        log = self.agent.decision_log(limit=10)
        assert len(log) == 2

    def test_fund_property(self):
        assert self.agent.fund is self.mock_fund


# ===========================================================================
# 13. FundManager integration
# ===========================================================================

class TestFundManagerIntegration:
    """Tests that FundManager exposes all new properties and status fields."""

    def test_universe_property(self):
        fm = FundManager()
        assert isinstance(fm.universe, MarketUniverse)
        fm.close()

    def test_memory_property(self):
        fm = FundManager()
        assert isinstance(fm.memory, StrategyMemory)
        fm.close()

    def test_correlation_monitor_property(self):
        fm = FundManager()
        assert isinstance(fm.correlation_monitor, CorrelationMonitor)
        fm.close()

    def test_var_budget_property(self):
        fm = FundManager()
        assert isinstance(fm.var_budget, VaRBudgetManager)
        fm.close()

    def test_stress_tester_property(self):
        fm = FundManager()
        assert isinstance(fm.stress_tester, FundStressTester)
        fm.close()

    def test_exec_quality_property(self):
        fm = FundManager()
        assert isinstance(fm.exec_quality, ExecutionQualityTracker)
        fm.close()

    def test_adaptive_tuner_property(self):
        fm = FundManager()
        assert isinstance(fm.adaptive_tuner, AdaptiveExecutionTuner)
        fm.close()

    def test_router_property(self):
        fm = FundManager()
        assert isinstance(fm.router, SmartRouter)
        fm.close()

    def test_research_property(self):
        fm = FundManager()
        assert isinstance(fm.research, ResearchPipeline)
        fm.close()

    def test_autopilot_property(self):
        fm = FundManager()
        assert isinstance(fm.autopilot, Autopilot)
        fm.close()

    def test_status_includes_universe_size(self):
        fm = FundManager()
        status = fm.status()
        assert "universe_size" in status
        assert status["universe_size"] == 0
        fm.close()

    def test_status_includes_correlation_alerts(self):
        fm = FundManager()
        status = fm.status()
        assert "correlation_alerts" in status
        assert status["correlation_alerts"] == 0
        fm.close()

    def test_var_budget_tied_to_capital(self):
        fm = FundManager(FundConfig(total_capital=200_000))
        # VaR budget should be 10% of capital
        snap = fm.var_budget.snapshot()
        assert snap.total_budget == 20_000
        fm.close()

    def test_stress_tester_tied_to_config(self):
        fm = FundManager(FundConfig(
            max_fund_drawdown_pct=10.0,
            max_strategy_drawdown_pct=20.0,
        ))
        assert fm.stress_tester._max_fund_dd == 10.0
        assert fm.stress_tester._max_strat_dd == 20.0
        fm.close()

    def test_autopilot_has_bias_guard(self):
        fm = FundManager()
        assert fm.autopilot._bias_guard is fm.bias_guard
        fm.close()


# ---------------------------------------------------------------------------
# BiasGuard
# ---------------------------------------------------------------------------

from horizon.fund._bias_guard import BiasGuard, BiasGuardConfig, BiasAlert


class TestBiasGuard:
    """Test the 5-bias detection system."""

    def setup_method(self):
        self.guard = BiasGuard()

    # -- 1. Backtest overfitting --

    def test_pbo_blocks_when_too_high(self):
        alerts = self.guard.check_deploy(
            strategy_type="mm", market_id="m1", backtest_sharpe=2.0,
            n_trades=50, pbo=0.60,
        )
        assert any(a.severity == "block" and a.bias_type == "backtest_overfitting" for a in alerts)
        assert self.guard.should_block(alerts)

    def test_pbo_passes_when_low(self):
        alerts = self.guard.check_deploy(
            strategy_type="mm", market_id="m1", backtest_sharpe=2.0,
            n_trades=50, pbo=0.30,
        )
        block_alerts = [a for a in alerts if a.severity == "block" and a.bias_type == "backtest_overfitting"]
        assert len(block_alerts) == 0

    def test_deflated_sharpe_blocks_when_low(self):
        alerts = self.guard.check_deploy(
            strategy_type="mm", market_id="m2", backtest_sharpe=3.0,
            n_trades=50, deflated_sharpe=0.5, n_trials=10,
        )
        assert any(a.bias_type == "backtest_overfitting" and "Deflated" in a.message for a in alerts)

    def test_oos_sharpe_blocks_when_too_low(self):
        alerts = self.guard.check_deploy(
            strategy_type="mm", market_id="m3", backtest_sharpe=2.0,
            n_trades=50, oos_sharpe=0.3,
        )
        assert any(a.severity == "block" and "Out-of-sample" in a.message for a in alerts)

    def test_oos_sharpe_degradation_warning(self):
        alerts = self.guard.check_deploy(
            strategy_type="mm", market_id="m4", backtest_sharpe=3.0,
            n_trades=50, oos_sharpe=1.0,
        )
        # OOS/IS = 1.0/3.0 = 0.33 < 0.5 → warning
        assert any(a.bias_type == "sharpe_degradation" for a in alerts)

    def test_clean_deploy_no_blocks(self):
        alerts = self.guard.check_deploy(
            strategy_type="mm", market_id="clean1", backtest_sharpe=2.0,
            n_trades=50, pbo=0.20, deflated_sharpe=1.5, oos_sharpe=1.8,
        )
        assert not self.guard.should_block(alerts)

    # -- 2. Recency bias --

    def test_decay_confidence_halves_at_half_life(self):
        decayed = self.guard.decay_confidence(0.8, age_days=90.0)
        assert abs(decayed - 0.4) < 0.01

    def test_decay_no_decay_at_zero_days(self):
        assert self.guard.decay_confidence(0.8, 0.0) == 0.8

    def test_decay_negative_age_returns_original(self):
        assert self.guard.decay_confidence(0.8, -5.0) == 0.8

    def test_memory_mature_check(self):
        assert not self.guard.is_memory_mature(1.0)  # too young
        assert self.guard.is_memory_mature(7.0)  # exactly at threshold
        assert self.guard.is_memory_mature(30.0)  # old enough

    # -- 3. Confirmation bias --

    def test_confirmation_bias_blocks_repeated_deploys(self):
        config = BiasGuardConfig(max_similar_deploys=2, similarity_window_secs=3600.0)
        guard = BiasGuard(config)
        # Deploy same type 2 times (fills history)
        guard.check_deploy("mm", "m1", 2.0, 50)
        guard.check_deploy("mm", "m2", 2.0, 50)
        # Third should trigger confirmation bias block
        alerts = guard.check_deploy("mm", "m3", 2.0, 50)
        assert any(a.bias_type == "confirmation_bias" and a.severity == "block" for a in alerts)

    def test_different_strategy_types_no_confirmation_bias(self):
        config = BiasGuardConfig(max_similar_deploys=2)
        guard = BiasGuard(config)
        guard.check_deploy("mm", "m1", 2.0, 50)
        guard.check_deploy("arb", "m2", 2.0, 50)
        alerts = guard.check_deploy("trend", "m3", 2.0, 50)
        conf_blocks = [a for a in alerts if a.bias_type == "confirmation_bias" and a.severity == "block"]
        assert len(conf_blocks) == 0

    def test_same_market_warning(self):
        guard = BiasGuard()
        guard.check_deploy("mm", "same_market", 2.0, 50)
        guard.check_deploy("arb", "same_market", 2.0, 50)
        alerts = guard.check_deploy("trend", "same_market", 2.0, 50)
        assert any(a.bias_type == "confirmation_bias" and a.severity == "warning" for a in alerts)

    # -- 4. Performance chasing --

    def test_scale_up_blocked_when_too_young(self):
        alerts = self.guard.check_scale_up("strat1", [0.01] * 5, age_days=3.0)
        assert any(a.severity == "block" and a.bias_type == "performance_chasing" for a in alerts)

    def test_scale_up_hot_hand_warning(self):
        alerts = self.guard.check_scale_up(
            "strat2", [0.01, 0.02, 0.04, 0.05], age_days=20.0,
        )
        # last 3 days sum = 0.02 + 0.04 + 0.05 = 0.11 > 0.10 → warning
        assert any(a.bias_type == "performance_chasing" and a.severity == "warning" for a in alerts)

    def test_scale_up_passes_with_mature_stable_returns(self):
        alerts = self.guard.check_scale_up(
            "strat3",
            [0.005] * 20,  # stable 0.5% daily, 20 observations
            age_days=30.0,
        )
        assert not self.guard.should_block(alerts)
        # No performance chasing warnings either
        chase = [a for a in alerts if a.bias_type == "performance_chasing"]
        assert len(chase) == 0

    def test_scale_up_small_sample_warning(self):
        alerts = self.guard.check_scale_up("strat4", [0.01] * 5, age_days=20.0)
        assert any(a.bias_type == "small_sample" for a in alerts)

    # -- 5. Small-sample learning --

    def test_memory_record_insufficient_observations(self):
        alerts = self.guard.check_memory_record("strategy_outcome", n_observations=5, confidence=0.8)
        assert len(alerts) > 0
        assert alerts[0].bias_type == "small_sample"
        # Confidence should be capped
        capped = alerts[0].details["capped_confidence"]
        assert capped < 0.8

    def test_memory_record_sufficient_observations(self):
        alerts = self.guard.check_memory_record("general", n_observations=10, confidence=0.8)
        assert len(alerts) == 0

    def test_cap_confidence_by_sample(self):
        # 3 out of 5 minimum observations for general
        capped = self.guard.cap_confidence_by_sample(0.8, 3, "general")
        assert capped == pytest.approx(0.8 * 3 / 5, abs=0.01)

    def test_cap_confidence_at_threshold(self):
        capped = self.guard.cap_confidence_by_sample(0.8, 5, "general")
        assert capped == 0.8

    def test_cap_confidence_strategy_outcome_needs_30(self):
        capped = self.guard.cap_confidence_by_sample(0.9, 15, "strategy_outcome")
        assert capped == pytest.approx(0.9 * 15 / 30, abs=0.01)

    # -- Utility --

    def test_should_block_static(self):
        assert BiasGuard.should_block([BiasAlert("x", "block", "msg")])
        assert not BiasGuard.should_block([BiasAlert("x", "warning", "msg")])
        assert not BiasGuard.should_block([])

    def test_stats(self):
        self.guard.check_deploy("mm", "m1", 2.0, 10)  # small_sample warning
        stats = self.guard.stats()
        assert stats["total_deploy_attempts"] >= 1
        assert stats["total_alerts"] >= 1

    def test_alerts_by_type(self):
        self.guard.check_deploy("mm", "m1", 2.0, 10, pbo=0.9)
        counts = self.guard.alerts_by_type()
        assert "backtest_overfitting" in counts

    def test_reset_clears_state(self):
        self.guard.check_deploy("mm", "m1", 2.0, 50)
        self.guard.reset()
        assert self.guard.stats()["total_deploy_attempts"] == 0
        assert self.guard.stats()["total_alerts"] == 0

    def test_deploy_history_bounded(self):
        guard = BiasGuard()
        for i in range(600):
            guard.check_deploy(f"t{i}", f"m{i}", 2.0, 50)
        assert len(guard._deploy_history) <= 500


# ---------------------------------------------------------------------------
# Memory confidence decay integration
# ---------------------------------------------------------------------------


class TestMemoryDecay:
    """Test confidence decay in StrategyMemory.query()."""

    def setup_method(self):
        self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
        self.memory = StrategyMemory(db_path=self.tmp.name)

    def teardown_method(self):
        self.memory.close()
        import os
        os.unlink(self.tmp.name)

    def test_query_without_decay_returns_original_confidence(self):
        self.memory.record("general", "k1", "v1", confidence=0.9)
        entries = self.memory.query(apply_decay=False)
        assert entries[0].confidence == 0.9

    def test_query_with_decay_reduces_old_entries(self):
        # Insert entry and backdate it
        self.memory.record("general", "k1", "v1", confidence=0.9)
        # Manually backdate the updated_at to 90 days ago
        old_time = time.time() - 90 * 86400
        with self.memory._lock:
            self.memory._conn.execute(
                "UPDATE memory SET updated_at = ? WHERE key = ?",
                (old_time, "k1"),
            )
            self.memory._conn.commit()

        entries = self.memory.query(apply_decay=True, memory_half_life_days=90.0)
        assert len(entries) == 1
        # After 90 days at 90-day half-life, confidence should be ~0.45
        assert entries[0].confidence < 0.5
        assert entries[0].confidence > 0.3

    def test_query_with_decay_filters_below_min_confidence(self):
        self.memory.record("general", "k1", "v1", confidence=0.3)
        old_time = time.time() - 180 * 86400
        with self.memory._lock:
            self.memory._conn.execute(
                "UPDATE memory SET updated_at = ? WHERE key = ?",
                (old_time, "k1"),
            )
            self.memory._conn.commit()

        # After 180 days at 90-day half-life: 0.3 * 0.25 = 0.075
        entries = self.memory.query(
            apply_decay=True, min_confidence=0.1, memory_half_life_days=90.0,
        )
        assert len(entries) == 0

    def test_query_fresh_entry_barely_decays(self):
        self.memory.record("general", "k1", "v1", confidence=0.9)
        entries = self.memory.query(apply_decay=True, memory_half_life_days=90.0)
        assert len(entries) == 1
        # Entry is seconds old - should be basically unchanged
        assert entries[0].confidence > 0.89


# ---------------------------------------------------------------------------
# Autopilot + BiasGuard integration
# ---------------------------------------------------------------------------


class TestAutopilotBiasGuard:
    """Test bias guard wired into Autopilot.evaluate()."""

    def test_pbo_blocks_candidate(self):
        guard = BiasGuard()
        ap = Autopilot(bias_guard=guard)
        candidate = DeployCandidate(
            market_id="m1", exchange="paper", strategy_type="mm",
            backtest_sharpe=2.0, backtest_trades=50,
            backtest_profit_factor=1.5, robustness_p_value=0.01,
            pbo=0.70,  # way over 0.50 limit
        )
        result = ap.evaluate(candidate)
        assert not result.passes_criteria
        assert any("bias_block" in r for r in result.rejection_reasons)
        assert len(result.bias_alerts) > 0

    def test_clean_candidate_passes_with_bias_guard(self):
        guard = BiasGuard()
        ap = Autopilot(bias_guard=guard)
        candidate = DeployCandidate(
            market_id="m2", exchange="paper", strategy_type="trend",
            backtest_sharpe=2.0, backtest_trades=50,
            backtest_profit_factor=1.5, robustness_p_value=0.01,
            pbo=0.20, deflated_sharpe=1.5, oos_sharpe=1.8,
        )
        result = ap.evaluate(candidate)
        assert result.passes_criteria

    def test_without_bias_guard_skips_checks(self):
        ap = Autopilot(bias_guard=None)
        candidate = DeployCandidate(
            market_id="m3", exchange="paper", strategy_type="mm",
            backtest_sharpe=2.0, backtest_trades=50,
            backtest_profit_factor=1.5, robustness_p_value=0.01,
            pbo=0.99,  # would block with guard, but no guard
        )
        result = ap.evaluate(candidate)
        assert result.passes_criteria  # No bias guard → no block
        assert len(result.bias_alerts) == 0

    def test_oos_sharpe_degrades_blocks(self):
        guard = BiasGuard()
        ap = Autopilot(bias_guard=guard)
        candidate = DeployCandidate(
            market_id="m4", exchange="paper", strategy_type="mm",
            backtest_sharpe=2.0, backtest_trades=50,
            backtest_profit_factor=1.5, robustness_p_value=0.01,
            oos_sharpe=0.3,  # below 0.5 minimum
        )
        result = ap.evaluate(candidate)
        assert not result.passes_criteria


# ---------------------------------------------------------------------------
# Agent + BiasGuard integration
# ---------------------------------------------------------------------------


class TestAgentBiasGuard:
    """Test bias guard wired into AutonomousAgent."""

    def _make_agent(self):
        fund = MagicMock()
        fund.bias_guard = BiasGuard()
        fund.controller.status.return_value = MagicMock(allocated_capital=1000.0)
        tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
        agent = AutonomousAgent(
            fund=fund, mode="autonomous", db_path=tmp.name,
        )
        return agent, tmp.name

    def test_deploy_blocked_by_high_pbo(self):
        agent, db = self._make_agent()
        try:
            config = StrategyConfig(
                name="test_strat", pipeline=[lambda ctx: None],
                markets=["m1"], mode="paper",
            )
            decision = agent.deploy_strategy(
                config=config, reasoning="test", confidence=0.9,
                backtest_sharpe=2.0, n_trades=50, pbo=0.80,
            )
            assert decision.outcome == "rejected"
            assert "Bias guard blocked" in (decision.error or "")
        finally:
            agent.close()
            import os
            os.unlink(db)

    def test_deploy_passes_clean_metrics(self):
        agent, db = self._make_agent()
        try:
            config = StrategyConfig(
                name="test_strat", pipeline=[lambda ctx: None],
                markets=["m1"], mode="paper",
            )
            decision = agent.deploy_strategy(
                config=config, reasoning="test", confidence=0.9,
                backtest_sharpe=2.0, n_trades=50, pbo=0.20,
                deflated_sharpe=1.5, oos_sharpe=1.8,
            )
            # Should not be bias-blocked (may still be executed or other outcome)
            assert decision.outcome != "rejected" or "Bias guard" not in (decision.error or "")
        finally:
            agent.close()
            import os
            os.unlink(db)

    def test_scale_up_blocked_when_too_young(self):
        agent, db = self._make_agent()
        try:
            decision = agent.scale_strategy(
                name="strat1", new_capital=5000.0,
                reasoning="scaling", confidence=0.8,
                daily_returns=[0.01] * 5, age_days=3.0,
            )
            assert decision.outcome == "rejected"
            assert "Bias guard blocked" in (decision.error or "")
        finally:
            agent.close()
            import os
            os.unlink(db)

    def test_scale_down_skips_bias_check(self):
        agent, db = self._make_agent()
        try:
            # Scale down from 1000 to 500 - no bias check
            decision = agent.scale_strategy(
                name="strat1", new_capital=500.0,
                reasoning="reducing", confidence=0.8,
                daily_returns=[0.01] * 5, age_days=3.0,
            )
            # Should not be bias-blocked (scale down doesn't trigger guard)
            assert "Bias guard blocked" not in (decision.error or "")
        finally:
            agent.close()
            import os
            os.unlink(db)


# ===========================================================================
# 30 Improvements + Ultra gate tests
# ===========================================================================


class TestFundConfigValidation:
    """Tests for FundConfig.__post_init__ validation."""

    def test_valid_config(self):
        config = FundConfig(total_capital=50_000, management_fee_bps=100, performance_fee_pct=10)
        assert config.total_capital == 50_000

    def test_management_fee_too_high(self):
        with pytest.raises(ValueError, match="management_fee_bps"):
            FundConfig(management_fee_bps=600)

    def test_management_fee_negative(self):
        with pytest.raises(ValueError, match="management_fee_bps"):
            FundConfig(management_fee_bps=-1)

    def test_performance_fee_too_high(self):
        with pytest.raises(ValueError, match="performance_fee_pct"):
            FundConfig(performance_fee_pct=60)

    def test_total_capital_zero(self):
        with pytest.raises(ValueError, match="total_capital"):
            FundConfig(total_capital=0)

    def test_total_capital_negative(self):
        with pytest.raises(ValueError, match="total_capital"):
            FundConfig(total_capital=-100)

    def test_max_fund_drawdown_out_of_range(self):
        with pytest.raises(ValueError, match="max_fund_drawdown_pct"):
            FundConfig(max_fund_drawdown_pct=0)

    def test_max_fund_drawdown_over_100(self):
        with pytest.raises(ValueError, match="max_fund_drawdown_pct"):
            FundConfig(max_fund_drawdown_pct=101)

    def test_max_strategy_drawdown_out_of_range(self):
        with pytest.raises(ValueError, match="max_strategy_drawdown_pct"):
            FundConfig(max_strategy_drawdown_pct=0)

    def test_max_correlation_zero(self):
        with pytest.raises(ValueError, match="max_correlation"):
            FundConfig(max_correlation=0)

    def test_max_correlation_over_one(self):
        with pytest.raises(ValueError, match="max_correlation"):
            FundConfig(max_correlation=1.5)

    def test_new_config_fields_defaults(self):
        config = FundConfig()
        assert config.max_failed_strategies == 3
        assert config.auto_rebalance_interval_ticks == 60
        assert config.universe_refresh_interval_ticks == 120
        assert config.strategy_cycle_secs == 0.5
        assert config.auto_restart_max_retries == 3
        assert config.pending_decision_expiry_secs == 86400.0
        assert config.fee_accrual_interval_secs == 86400.0
        assert config.kill_switch_recovery_drawdown_pct == 5.0


class TestFundRiskImprovements:
    """Tests for auto-unpause, kill switch recovery, configurable thresholds."""

    def test_configurable_failed_threshold(self):
        from horizon.fund._fund_risk import FundRiskMonitor, RiskAlert
        from horizon.fund._types import NAVSnapshot
        config = FundConfig(max_failed_strategies=5)
        monitor = FundRiskMonitor(config)
        nav = NAVSnapshot(
            timestamp=time.time(), total_nav=100_000, cash=50_000,
            positions_value=50_000, unrealized_pnl=0, realized_pnl=0,
            high_water_mark=100_000, drawdown_pct=0,
        )
        # 3 failures: below threshold of 5
        alerts = monitor.check(nav, {}, ["s1", "s2", "s3"])
        failure_alerts = [a for a in alerts if a.category == "strategy_failure"]
        assert len(failure_alerts) == 0

        # 5 failures: at threshold
        alerts = monitor.check(nav, {}, ["s1", "s2", "s3", "s4", "s5"])
        failure_alerts = [a for a in alerts if a.category == "strategy_failure"]
        assert len(failure_alerts) == 1

    def test_auto_unpause(self):
        from horizon.fund._fund_risk import FundRiskMonitor
        from horizon.fund._types import NAVSnapshot
        config = FundConfig(max_strategy_drawdown_pct=20.0)
        monitor = FundRiskMonitor(config)
        nav = NAVSnapshot(
            timestamp=time.time(), total_nav=100_000, cash=50_000,
            positions_value=50_000, unrealized_pnl=0, realized_pnl=0,
            high_water_mark=100_000, drawdown_pct=0,
        )
        # Trigger pause
        monitor.check(nav, {"s1": 25.0}, [])
        assert "s1" in monitor.paused_strategies

        # Drawdown recovers to below 50% of limit (10%)
        monitor.check(nav, {"s1": 5.0}, [])
        assert "s1" not in monitor.paused_strategies

    def test_kill_switch_recovery(self):
        from horizon.fund._fund_risk import FundRiskMonitor
        from horizon.fund._types import NAVSnapshot
        config = FundConfig(
            max_fund_drawdown_pct=15.0,
            kill_switch_recovery_drawdown_pct=5.0,
        )
        monitor = FundRiskMonitor(config)
        # Trigger kill switch
        nav_bad = NAVSnapshot(
            timestamp=time.time(), total_nav=80_000, cash=30_000,
            positions_value=50_000, unrealized_pnl=-20_000, realized_pnl=0,
            high_water_mark=100_000, drawdown_pct=20.0,
        )
        monitor.check(nav_bad, {}, [])
        assert monitor.fund_kill_switch_active

        # Drawdown recovers
        nav_good = NAVSnapshot(
            timestamp=time.time(), total_nav=96_000, cash=46_000,
            positions_value=50_000, unrealized_pnl=-4_000, realized_pnl=0,
            high_water_mark=100_000, drawdown_pct=4.0,
        )
        monitor.check(nav_good, {}, [])
        assert not monitor.fund_kill_switch_active


class TestSettlementImprovements:
    """Tests for settlement outcome logic and settled_positions."""

    def test_outcome_long_profit(self):
        from horizon.fund._settlement import SettlementMonitor
        monitor = SettlementMonitor()
        engine = MagicMock()
        pos = MagicMock()
        pos.market_id = "m1"
        pos.size = 10.0  # long
        pos.realized_pnl = 50.0  # profit
        engine.positions.return_value = [pos]
        engine.is_market_resolved.return_value = True
        events = monitor.check_settlements("s1", engine)
        assert len(events) == 1
        assert events[0].outcome == "yes"  # long + profit = resolved YES
        assert events[0].side == "buy"

    def test_outcome_short_profit(self):
        from horizon.fund._settlement import SettlementMonitor
        monitor = SettlementMonitor()
        engine = MagicMock()
        pos = MagicMock()
        pos.market_id = "m2"
        pos.size = -10.0  # short
        pos.realized_pnl = 50.0  # profit
        engine.positions.return_value = [pos]
        engine.is_market_resolved.return_value = True
        events = monitor.check_settlements("s1", engine)
        assert len(events) == 1
        assert events[0].outcome == "no"  # short + profit = resolved NO
        assert events[0].side == "sell"

    def test_settled_positions_property(self):
        from horizon.fund._settlement import SettlementMonitor
        monitor = SettlementMonitor()
        engine = MagicMock()
        pos = MagicMock()
        pos.market_id = "m1"
        pos.size = 10.0
        pos.realized_pnl = 50.0
        engine.positions.return_value = [pos]
        engine.is_market_resolved.return_value = True
        monitor.check_settlements("s1", engine)
        assert ("s1", "m1") in monitor.settled_positions


class TestControllerImprovements:
    """Tests for drawdown, HWM tracking, retry_count."""

    def test_strategy_handle_new_fields(self):
        from horizon.fund._controller import _StrategyHandle
        engine = MagicMock()
        config = StrategyConfig(name="test", pipeline=[], markets=["m1"])
        handle = _StrategyHandle(config, engine, 10_000.0)
        assert handle.hwm_pnl == 10_000.0
        assert handle.retry_count == 0

    def test_strategy_drawdown_hwm_based(self):
        from horizon.fund._controller import StrategyController
        config = FundConfig()
        controller = StrategyController(config)

        # Mock handle
        from horizon.fund._controller import _StrategyHandle
        engine = MagicMock()
        status = MagicMock()
        status.total_realized_pnl = 1000.0
        status.total_unrealized_pnl = 0.0
        engine.status.return_value = status

        strat_config = StrategyConfig(name="s1", pipeline=[], markets=["m1"])
        handle = _StrategyHandle(strat_config, engine, 10_000.0)
        handle.state = StrategyState.RUNNING
        handle.hwm_pnl = 12_000.0  # HWM is higher than current

        controller._strategies["s1"] = handle
        dd = controller.strategy_drawdowns()
        # current_nav = 10000 + 1000 = 11000, hwm = 12000
        # dd = (12000 - 11000) / 12000 * 100 = 8.33%
        assert abs(dd["s1"] - 8.333) < 0.1


class TestNAVImprovements:
    """Tests for NaN/Inf validation and persist_snapshot."""

    def test_nan_nav_returns_last_valid(self):
        from horizon.fund._nav import NAVEngine
        nav_engine = NAVEngine(100_000.0)

        # First: compute a valid NAV
        engine = MagicMock()
        status = MagicMock()
        status.total_realized_pnl = 100.0
        status.total_unrealized_pnl = 50.0
        engine.status.return_value = status
        engine.positions.return_value = []
        snap1 = nav_engine.compute_nav({"s1": engine})
        assert snap1.total_nav == 100_150.0

        # Now: produce NaN
        status2 = MagicMock()
        status2.total_realized_pnl = float("nan")
        status2.total_unrealized_pnl = 0.0
        engine2 = MagicMock()
        engine2.status.return_value = status2
        engine2.positions.return_value = []
        snap2 = nav_engine.compute_nav({"s1": engine2})
        # Should return last valid
        assert snap2.total_nav == 100_150.0

    def test_persist_snapshot(self):
        import tempfile
        from horizon.fund._nav import NAVEngine
        nav_engine = NAVEngine(100_000.0)
        engine = MagicMock()
        status = MagicMock()
        status.total_realized_pnl = 0.0
        status.total_unrealized_pnl = 0.0
        engine.status.return_value = status
        engine.positions.return_value = []
        nav_engine.compute_nav({"s1": engine})

        with tempfile.NamedTemporaryFile(mode="r", suffix=".jsonl", delete=False) as f:
            path = f.name
        nav_engine.persist_snapshot(path)
        import json
        with open(path) as f:
            line = f.readline()
            data = json.loads(line)
            assert data["total_nav"] == 100_000.0
        import os
        os.unlink(path)


class TestAllocatorRiskParity:
    """Tests for improved risk-parity allocation."""

    def test_risk_parity_uses_vol_proxy(self):
        from horizon.fund._allocator import CapitalAllocator
        config = FundConfig(allocation_method=AllocationMethod.RISK_PARITY)
        allocator = CapitalAllocator(config)

        # Strategy with high daily_pnl swing (volatile)
        engine_volatile = MagicMock()
        status_v = MagicMock()
        status_v.total_realized_pnl = 100.0
        status_v.total_unrealized_pnl = 0.0
        status_v.daily_pnl = 50.0  # high
        engine_volatile.status.return_value = status_v

        # Strategy with low daily_pnl swing (stable)
        engine_stable = MagicMock()
        status_s = MagicMock()
        status_s.total_realized_pnl = 100.0
        status_s.total_unrealized_pnl = 0.0
        status_s.daily_pnl = 1.0  # low
        engine_stable.status.return_value = status_s

        engines = {"volatile": engine_volatile, "stable": engine_stable}
        allocs = allocator.allocate(["volatile", "stable"], engines)
        # Stable should get more capital (inverse vol)
        assert allocs["stable"] > allocs["volatile"]

    def test_risk_parity_fallback_no_engines(self):
        from horizon.fund._allocator import CapitalAllocator
        config = FundConfig(allocation_method=AllocationMethod.RISK_PARITY)
        allocator = CapitalAllocator(config)
        allocs = allocator.allocate(["s1", "s2"], None)
        assert allocs["s1"] == allocs["s2"]


class TestExecQualityDirectional:
    """Tests for directional adverse selection."""

    def test_buy_adverse_selection(self):
        tracker = ExecutionQualityTracker()
        # Bought at mid 100, mid went to 98 -> adverse
        tracker.record_fill("s1", 100.0, 100.0, 100.0, mid_price_after=98.0, side="buy")
        metrics = tracker.strategy_metrics("s1")
        assert metrics["adverse_selection"] > 0

    def test_sell_adverse_selection(self):
        tracker = ExecutionQualityTracker()
        # Sold at mid 100, mid went to 102 -> adverse
        tracker.record_fill("s1", 100.0, 100.0, 100.0, mid_price_after=102.0, side="sell")
        metrics = tracker.strategy_metrics("s1")
        assert metrics["adverse_selection"] > 0

    def test_buy_no_adverse_when_price_rises(self):
        tracker = ExecutionQualityTracker()
        # Bought at mid 100, mid went to 102 -> NOT adverse
        tracker.record_fill("s1", 100.0, 100.0, 100.0, mid_price_after=102.0, side="buy")
        metrics = tracker.strategy_metrics("s1")
        assert metrics["adverse_selection"] == 0.0

    def test_sell_no_adverse_when_price_falls(self):
        tracker = ExecutionQualityTracker()
        # Sold at mid 100, mid went to 98 -> NOT adverse
        tracker.record_fill("s1", 100.0, 100.0, 100.0, mid_price_after=98.0, side="sell")
        metrics = tracker.strategy_metrics("s1")
        assert metrics["adverse_selection"] == 0.0


class TestAdaptiveGetMultipliers:
    """Tests for get_multipliers method."""

    def test_default_multipliers(self):
        from horizon.fund._adaptive import AdaptiveExecutionTuner
        tuner = AdaptiveExecutionTuner()
        mults = tuner.get_multipliers("unknown_strategy")
        assert mults["spread_multiplier"] == 1.0
        assert mults["size_multiplier"] == 1.0

    def test_multipliers_after_tune(self):
        from horizon.fund._adaptive import AdaptiveExecutionTuner
        tuner = AdaptiveExecutionTuner()
        metrics = {
            "fill_rate": 0.3,  # low
            "avg_slippage_bps": 2.0,
            "adverse_selection": 0.005,
            "market_impact_bps": 2.0,
            "total_fills": 50,
        }
        tuner.tune("s1", metrics)
        mults = tuner.get_multipliers("s1")
        assert mults["spread_multiplier"] < 1.0  # tightened


class TestDecisionsImprovements:
    """Tests for RETIRE_STRATEGY high risk + expire_pending."""

    def test_retire_is_high_risk(self):
        assert DecisionFramework._is_high_risk(ActionType.RETIRE_STRATEGY) is True

    def test_expire_pending(self):
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
            db = f.name
        try:
            fw = DecisionFramework(
                mode=OperatingMode.DRY_RUN,
                db_path=db,
            )
            # Propose a decision (dry-run -> pending)
            d = fw.propose(ActionType.RESEARCH_SCAN, {}, "test", 0.9)
            assert d.outcome == "pending"
            assert len(fw.pending_decisions()) == 1

            # Expire with 0 age -> should expire
            expired = fw.expire_pending(max_age_secs=0)
            assert expired == 1
            assert len(fw.pending_decisions()) == 0
            fw.close()
        finally:
            import os
            os.unlink(db)

    def test_expire_pending_not_expired(self):
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
            db = f.name
        try:
            fw = DecisionFramework(
                mode=OperatingMode.DRY_RUN,
                db_path=db,
            )
            fw.propose(ActionType.RESEARCH_SCAN, {}, "test", 0.9)
            # Don't expire (long timeout)
            expired = fw.expire_pending(max_age_secs=86400)
            assert expired == 0
            assert len(fw.pending_decisions()) == 1
            fw.close()
        finally:
            import os
            os.unlink(db)


class TestAgentExecuteApproved:
    """Tests for the expanded _execute_approved method."""

    def test_execute_promote_live(self):
        fund = MagicMock()
        fund.controller = MagicMock()
        fund.controller.status.return_value = MagicMock(allocated_capital=1000)
        fund.bias_guard = None

        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
            db = f.name
        try:
            agent = AutonomousAgent(fund, mode="autonomous", db_path=db)
            decision = MagicMock()
            decision.action = ActionType.PROMOTE_LIVE
            decision.parameters = {"name": "test_live", "markets": ["m1"]}
            decision.decision_id = "d-test"
            agent._execute_approved(decision)
            fund.add_strategy.assert_called_once()
            config_arg = fund.add_strategy.call_args[0][0]
            assert config_arg.mode == "live"
            agent.close()
        finally:
            import os
            os.unlink(db)

    def test_execute_scale_up(self):
        fund = MagicMock()
        fund.controller = MagicMock()
        fund.controller.status.return_value = MagicMock(allocated_capital=1000)
        fund.bias_guard = None

        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
            db = f.name
        try:
            agent = AutonomousAgent(fund, mode="autonomous", db_path=db)
            decision = MagicMock()
            decision.action = ActionType.SCALE_UP
            decision.parameters = {"name": "s1", "new_capital": 5000.0}
            decision.decision_id = "d-test"
            agent._execute_approved(decision)
            fund.scale_strategy.assert_called_once_with("s1", 5000.0)
            agent.close()
        finally:
            import os
            os.unlink(db)

    def test_execute_rebalance(self):
        fund = MagicMock()
        fund.controller = MagicMock()
        fund.controller.engines = {"s1": MagicMock()}
        fund.allocator = MagicMock()
        fund.allocator.allocate.return_value = {"s1": 50_000.0}
        fund.bias_guard = None

        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
            db = f.name
        try:
            agent = AutonomousAgent(fund, mode="autonomous", db_path=db)
            decision = MagicMock()
            decision.action = ActionType.REBALANCE
            decision.parameters = {}
            decision.decision_id = "d-test"
            agent._execute_approved(decision)
            fund.scale_strategy.assert_called_once_with("s1", 50_000.0)
            agent.close()
        finally:
            import os
            os.unlink(db)


class TestMemoryImprovements:
    """Tests for auto-prune and default decay."""

    def test_auto_prune_no_excess(self):
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
            db = f.name
        try:
            mem = StrategyMemory(db_path=db)
            mem.record("cat", "k1", "v1", confidence=0.5)
            pruned = mem.auto_prune(max_entries=100)
            assert pruned == 0
            mem.close()
        finally:
            import os
            os.unlink(db)

    def test_auto_prune_excess(self):
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
            db = f.name
        try:
            mem = StrategyMemory(db_path=db)
            for i in range(10):
                mem.record("cat", f"k{i}", f"v{i}", confidence=0.1 * i)
            pruned = mem.auto_prune(max_entries=5)
            assert pruned == 5
            stats = mem.stats()
            assert stats["total_entries"] == 5
            mem.close()
        finally:
            import os
            os.unlink(db)

    def test_query_default_decay_true(self):
        """Verify that query now defaults to apply_decay=True."""
        import inspect
        sig = inspect.signature(StrategyMemory.query)
        assert sig.parameters["apply_decay"].default is True


class TestAccountingFeeBatching:
    """Tests for fee batching with min_fee_interval_secs."""

    def test_fee_batching_skips_early(self):
        from horizon.fund._accounting import FundAccounting
        from horizon.fund._types import NAVSnapshot
        config = FundConfig()
        acct = FundAccounting(config, min_fee_interval_secs=3600.0)
        nav = NAVSnapshot(
            timestamp=time.time(), total_nav=100_000, cash=50_000,
            positions_value=50_000, unrealized_pnl=0, realized_pnl=0,
            high_water_mark=100_000, drawdown_pct=0,
        )
        # Called immediately after init -> elapsed < 3600s
        fee = acct.compute_management_fee(nav)
        assert fee == 0.0

    def test_fee_batching_accrues_after_interval(self):
        from horizon.fund._accounting import FundAccounting
        from horizon.fund._types import NAVSnapshot
        config = FundConfig()
        acct = FundAccounting(config, min_fee_interval_secs=0.0)  # no minimum
        nav = NAVSnapshot(
            timestamp=time.time(), total_nav=100_000, cash=50_000,
            positions_value=50_000, unrealized_pnl=0, realized_pnl=0,
            high_water_mark=100_000, drawdown_pct=0,
        )
        # With min_fee_interval_secs=0, should accrue immediately
        # But elapsed_secs is very small (~0), so fee will be ~0
        # Set last_mgmt_fee_time to the past
        acct._last_mgmt_fee_time = time.time() - 86400  # 1 day ago
        fee = acct.compute_management_fee(nav)
        assert fee > 0


class TestFundStressMarkPrice:
    """Tests for mark price usage in stress testing."""

    def test_stress_uses_feed_snapshot_price(self):
        from horizon.fund._fund_stress import FundStressTester, FundScenario
        tester = FundStressTester()
        scenario = FundScenario(name="test", price_shock_pct=-10.0)

        engine = MagicMock()
        pos = MagicMock()
        pos.market_id = "m1"
        pos.size = 100.0
        pos.avg_entry_price = 1.0
        engine.positions.return_value = [pos]

        snap = MagicMock()
        snap.price = 2.0  # mark price is 2x entry
        engine.feed_snapshot.return_value = snap

        impact = tester._strategy_impact(scenario, engine, 10_000.0)
        # notional = 100 * 2.0 = 200, impact = 200 * -0.10 = -20
        assert abs(impact - (-20.0)) < 0.01

    def test_stress_falls_back_to_entry_price(self):
        from horizon.fund._fund_stress import FundStressTester, FundScenario
        tester = FundStressTester()
        scenario = FundScenario(name="test", price_shock_pct=-10.0)

        engine = MagicMock()
        pos = MagicMock()
        pos.market_id = "m1"
        pos.size = 100.0
        pos.avg_entry_price = 1.0
        engine.positions.return_value = [pos]
        engine.feed_snapshot.side_effect = Exception("no feed")

        impact = tester._strategy_impact(scenario, engine, 10_000.0)
        # notional = 100 * 1.0 = 100, impact = 100 * -0.10 = -10
        assert abs(impact - (-10.0)) < 0.01


class TestUltraGate:
    """Tests for Ultra subscription gate (uses Rust auth_require_ultra)."""

    def test_fund_manager_uses_rust_auth_gate(self):
        """FundManager.__init__ calls auth_require_ultra from Rust layer."""
        # With skip-auth feature enabled during maturin develop, this passes
        fund = FundManager()
        assert fund.config.total_capital == 100_000.0

    def test_auth_require_ultra_importable(self):
        """The Rust auth gate function is importable."""
        from horizon._horizon import auth_require_ultra
        # With skip-auth, this should not raise
        auth_require_ultra()

    def test_auth_current_tier_is_ultra_in_test(self):
        """With skip-auth feature, tier is ultra."""
        from horizon._horizon import auth_current_tier
        tier = auth_current_tier()
        assert tier == "ultra"

    def test_fund_manager_consistent_with_other_ultra_features(self):
        """Fund uses the same auth pattern as other Ultra features."""
        # Verify the fund manager import path matches other Ultra modules
        import inspect
        source = inspect.getsource(FundManager.__init__)
        assert "auth_require_ultra" in source
