from __future__ import annotations

import json
from types import SimpleNamespace

from hlbot.brain import LLMBrain, LLMTradeDecision
from hlbot.cli import _ioc_entry_limit, _rr_at_entry
from hlbot.exit_plan import evaluate_exit_plan
from hlbot.level_repair import repair_trade_levels
from hlbot.models import Candle, MarketRegime, OrderflowSnapshot, TradeTick
from hlbot.orderflow import CVDTracker
from hlbot.policy import TradingPolicy, format_alignment
from hlbot.research_gate import evaluate_research_gate
from hlbot.trigger_state import build_trigger_features
from hlbot.ws_orderflow import LiveOrderflowCache


def _of(**overrides) -> OrderflowSnapshot:
    data = dict(
        ts=1,
        mid=100.0,
        spread_bps=1.0,
        book_imbalance=0.35,
        wall_skew=0.0,
        stacked_bid_count=2,
        stacked_ask_count=0,
        bid_depth_usd=100_000,
        ask_depth_usd=50_000,
        tape_delta_usd=10_000,
        tape_delta_pct=0.35,
        cvd=10_000,
        cvd_divergence=False,
        large_prints=1,
        trades_per_sec=2.0,
        bid_absorption=0.2,
        ask_absorption=0.55,
        vwap=99.8,
        price_vs_vwap=0.002,
        rsi_14=52.0,
        macd_histogram=0.01,
        atr_14=1.0,
        vpoc=100.0,
        vah=104.0,
        val=96.0,
        funding_rate=0.0,
        open_interest=1000.0,
        premium_bps=0.0,
    )
    data.update(overrides)
    return OrderflowSnapshot(**data)


def _decision() -> SimpleNamespace:
    return SimpleNamespace(
        action="enter",
        side="long",
        setup_type="trend_pullback_continuation",
        playbook_match={
            "playbook": "trend_pullback_continuation",
            "trigger_present": True,
            "trigger_level": "100.0 explicit computed trigger_state pullback_retest_long",
            "price_response": "price held the computed level and moved in the trade direction",
            "anti_chase_check": "entry is after the computed hold, not a blind current mid chase",
            "self_reject_if": "wait if price loses the computed trigger level",
        },
        level_reasoning={"entry": "book tape trigger at level"},
        trade_plan={"entry_trigger": "computed pullback_retest_long"},
        evidence=[],
        reason="structured test",
        regime_note="",
        risk_assessment="",
        exit_plan={},
    )


def _short_sweep_decision() -> SimpleNamespace:
    return SimpleNamespace(
        action="enter",
        side="short",
        setup_type="liquidity_sweep_reclaim",
        entry_price=2115.0,
        stop_price=2118.7,
        target_price=2092.5,
        playbook_match={
            "playbook": "liquidity_sweep_reclaim",
            "trigger_present": True,
            "trigger_level": "sweep_reclaim_short at prior high",
            "price_response": "sell tape appeared after sweep",
            "anti_chase_check": "entry after sweep, not current-mid chase",
            "self_reject_if": "wait if price reclaims",
        },
        level_reasoning={"entry": "sweep_reclaim_short trigger at level"},
        trade_plan={"entry_trigger": "computed sweep_reclaim_short"},
        evidence=[],
        reason="structured short test",
        regime_note="",
        risk_assessment="",
        exit_plan={},
    )


def test_policy_prompt_renders_shared_thresholds() -> None:
    policy = TradingPolicy(absorption_hard_veto_threshold=0.64, min_alignment_score=6)
    prompt = LLMBrain(api_key="x", policy=policy)._system_prompt()
    assert "at least 6/7" in prompt
    assert "Ask absorption <= 0.64" in prompt
    assert "`target_price` must be the final executable target" in prompt


def test_brain_prompt_includes_ohlc_lessons_and_missed_candidates(tmp_path) -> None:
    path = tmp_path / "lessons.json"
    path.write_text(
        json.dumps(
            {
                "generated_at": "2026-05-25 18:17:10",
                "rules": ["missed candidates require next structural target"],
                "missed_opportunities": [
                    {
                        "coin": "TON",
                        "side": "long",
                        "first_hit": "target",
                        "mfe_pct": 12.981,
                    }
                ],
            }
        )
    )
    prompt = LLMBrain(api_key="x", ohlc_lessons_path=str(path))._build_prompt([])
    assert "RECENT OHLC LESSONS" in prompt
    assert "TON" in prompt
    assert "first_hit" in prompt


def test_brain_normalizes_side_specific_playbook_names() -> None:
    brain = LLMBrain(api_key="x")
    match = brain._normalize_playbook_match({"playbook": "breakdown_acceptance_short"})
    assert match["playbook"] == "breakout_acceptance"
    match = brain._normalize_playbook_match({"playbook": "absorption_reversal_long"})
    assert match["playbook"] == "absorption_reversal"


def test_exit_plan_accepts_numeric_rr_string() -> None:
    brain = LLMBrain(api_key="x")
    missing = brain._missing_exit_plan(
        {
            "stop_invalidation_level": "above swing high invalidates short",
            "stop_noise_check": "stop distance is above 1m and 5m noise range",
            "target_level": "prior low liquidity level 17.50",
            "target_reason": "prior low liquidity pocket below value",
            "rr_after_noise_stop": "1.62",
            "if_wrong_fast": "exit if price reclaims level with buy tape",
        }
    )
    assert "rr_after_noise_stop" not in missing


def test_parse_rejects_side_playbook_conflict() -> None:
    brain = LLMBrain(api_key="x")
    raw = json.dumps(
        {
            "playbook_match": {
                "playbook": "absorption_reversal_long",
                "trigger_present": True,
                "trigger_level": "support reclaim long",
                "price_response": "price bounced from support",
                "anti_chase_check": "not chasing",
                "self_reject_if": "lose support",
            },
            "trade_plan": {"entry_trigger": "support reclaim"},
            "level_reasoning": {
                "entry": "support reclaim level with buy tape, not chase",
                "stop": "below swing low structural invalidation noise level",
                "target": "prior high liquidity target level",
            },
            "exit_plan": {
                "stop_invalidation_level": "below swing low invalidates",
                "stop_noise_check": "above 1m and 5m noise distance",
                "target_level": "prior high liquidity level",
                "target_reason": "prior high liquidity level",
                "rr_after_noise_stop": "1.8",
                "if_wrong_fast": "exit if price accepts below support",
            },
            "action": "enter",
            "coin": "ETH",
            "side": "short",
            "confidence": 0.8,
            "entry_price": 100,
            "stop_price": 101,
            "target_price": 98,
            "setup_type": "absorption_reversal_long",
        }
    )
    decision = brain._parse(raw)
    assert decision.action == "wait"
    assert "side/playbook conflict" in decision.reason


def test_parse_completes_target_reason_from_level_reasoning() -> None:
    brain = LLMBrain(api_key="x")
    raw = json.dumps(
        {
            "playbook_match": {
                "playbook": "breakdown_acceptance_short",
                "trigger_present": True,
                "trigger_level": "breakdown_acceptance_short below 15m low",
                "price_response": "price accepted below the level",
                "anti_chase_check": "entry follows retest",
                "self_reject_if": "reclaim level",
            },
            "trade_plan": {"entry_trigger": "breakdown retest"},
            "level_reasoning": {
                "entry": "15m low trigger level retest with sell tape, not chase",
                "stop": "above swing high structural invalidation and noise level",
                "target": "prior low liquidity pocket target level",
            },
            "exit_plan": {
                "stop_invalidation_level": "above swing high invalidates",
                "stop_noise_check": "above ATR and 1m/5m noise distance",
                "target_level": "17.0",
                "rr_after_noise_stop": "1.7",
                "if_wrong_fast": "exit if price reclaims level with buy tape",
            },
            "action": "enter",
            "coin": "VVV",
            "side": "short",
            "confidence": 0.8,
            "entry_price": 18,
            "stop_price": 18.5,
            "target_price": 17,
            "setup_type": "breakdown_acceptance_short",
        }
    )
    decision = brain._parse(raw)
    assert decision.action == "enter"
    assert "target_reason" in decision.exit_plan


def test_parse_allows_target_room_for_level_repair() -> None:
    brain = LLMBrain(api_key="x")
    raw = json.dumps(
        {
            "playbook_match": {
                "playbook": "breakout_acceptance",
                "trigger_present": True,
                "trigger_level": "breakout above 15m high 84.445 and 1h high 84.519",
                "price_response": "price accepted above the level",
                "anti_chase_check": "entry follows acceptance and is not a blind current-mid chase",
                "self_reject_if": "price falls back below breakout level",
            },
            "trade_plan": {"entry_trigger": "breakout acceptance"},
            "level_reasoning": {
                "entry": "breakout trigger level with buy tape confirmation, not chase",
                "stop": "below 1h low structural invalidation and noise level",
                "target": "85.0 round number resistance target, but need farther target not provided so R:R insufficient",
            },
            "exit_plan": {
                "stop_invalidation_level": "84.25 below 1h low invalidates",
                "stop_noise_check": "stop distance above ATR and 1m/5m noise distance",
                "target_level": "85.0 round number resistance level",
                "target_reason": "psychological resistance level",
                "rr_after_noise_stop": "1.50 but borderline after fees",
                "if_wrong_fast": "exit if price loses breakout level with sell tape",
            },
            "action": "enter",
            "coin": "SOL",
            "side": "long",
            "confidence": 0.65,
            "entry_price": 84.55,
            "stop_price": 84.25,
            "target_price": 85.0,
            "setup_type": "breakout_acceptance",
            "reason": "counter-trend and R:R borderline",
        }
    )
    decision = brain._parse(raw)
    assert decision.action == "enter"
    assert decision.coin == "SOL"


def test_level_repair_rejects_micro_target_without_farther_level() -> None:
    brain = LLMBrain(api_key="x")
    raw = json.dumps(
        {
            "playbook_match": {
                "playbook": "breakout_acceptance",
                "trigger_present": True,
                "trigger_level": "breakout above 15m high 84.445 and 1h high 84.519",
                "price_response": "price accepted above the level",
                "anti_chase_check": "entry follows acceptance and is not a blind current-mid chase",
                "self_reject_if": "price falls back below breakout level",
            },
            "trade_plan": {"entry_trigger": "breakout acceptance"},
            "level_reasoning": {
                "entry": "breakout trigger level with buy tape confirmation, not chase",
                "stop": "below 1h low structural invalidation and noise level",
                "target": "85.0 round number resistance target, but need farther target not provided so R:R insufficient",
            },
            "exit_plan": {
                "stop_invalidation_level": "84.25 below 1h low invalidates",
                "stop_noise_check": "stop distance above ATR and 1m/5m noise distance",
                "target_level": "85.0 round number resistance level",
                "target_reason": "psychological resistance level",
                "rr_after_noise_stop": "1.50 but borderline after fees",
                "if_wrong_fast": "exit if price loses breakout level with sell tape",
            },
            "action": "enter",
            "coin": "SOL",
            "side": "long",
            "confidence": 0.65,
            "entry_price": 84.55,
            "stop_price": 84.25,
            "target_price": 85.0,
            "setup_type": "breakout_acceptance",
            "reason": "counter-trend and R:R borderline",
        }
    )
    decision = brain._parse(raw)
    coin_data = {
        "of": _of(mid=84.55, atr_14=0.218),
        "noise_state": {"dynamic_stop_floor": 0.2618, "atr14": 0.218},
        "levels": {
            "prior_15m_high": 85.0,
            "prior_1h_low": 84.25,
            "vwap": 84.10,
        },
    }
    result = repair_trade_levels(
        decision,
        coin_data,
        min_rr=1.5,
        min_stop_noise_multiple=1.10,
        fee_bps=4.5,
        min_target_pct=0.008,
        min_target_atr_multiple=2.5,
        prefer_htf_targets=True,
    )
    assert not result.allowed
    assert "target reward" in result.reason


def test_level_repair_uses_farther_structural_target() -> None:
    brain = LLMBrain(api_key="x")
    raw = json.dumps(
        {
            "playbook_match": {
                "playbook": "breakout_acceptance",
                "trigger_present": True,
                "trigger_level": "breakout above 15m high 84.445 and 1h high 84.519",
                "price_response": "price accepted above the level",
                "anti_chase_check": "entry follows acceptance and is not a blind current-mid chase",
                "self_reject_if": "price falls back below breakout level",
            },
            "trade_plan": {"entry_trigger": "breakout acceptance"},
            "level_reasoning": {
                "entry": "breakout trigger level with buy tape confirmation, not chase",
                "stop": "below 1h low structural invalidation and noise level",
                "target": "85.0 minor target, but need farther target",
            },
            "exit_plan": {
                "stop_invalidation_level": "84.25 below 1h low invalidates",
                "stop_noise_check": "stop distance above ATR and 1m/5m noise distance",
                "target_level": "85.0 minor level",
                "target_reason": "minor level too close",
                "rr_after_noise_stop": "borderline",
                "if_wrong_fast": "exit if price loses breakout level with sell tape",
            },
            "action": "enter",
            "coin": "SOL",
            "side": "long",
            "confidence": 0.65,
            "entry_price": 84.55,
            "stop_price": 84.25,
            "target_price": 85.0,
            "setup_type": "breakout_acceptance",
            "reason": "target needs repair",
        }
    )
    decision = brain._parse(raw)
    coin_data = {
        "of": _of(mid=84.55, atr_14=0.218),
        "noise_state": {"dynamic_stop_floor": 0.2618, "atr14": 0.218},
        "levels": {
            "prior_15m_high": 85.0,
            "prior_1h_low": 84.25,
            "prior_1h_high": 85.40,
            "last_swing_high": 85.75,
        },
    }
    result = repair_trade_levels(
        decision,
        coin_data,
        min_rr=1.5,
        min_stop_noise_multiple=1.10,
        fee_bps=4.5,
        min_target_pct=0.008,
        min_target_atr_multiple=2.5,
        prefer_htf_targets=True,
    )
    assert result.allowed
    assert result.changed
    assert result.decision.target_price == 85.40


def test_level_repair_upgrades_to_runner_target_from_ohlc_extreme() -> None:
    decision = LLMTradeDecision(
        action="enter",
        coin="ZEC",
        side="short",
        confidence=0.70,
        entry_price=613.13,
        stop_price=624.41,
        target_price=608.58,
        setup_type="absorption_reversal",
        playbook_match={
            "playbook": "absorption_reversal",
            "trigger_present": True,
            "trigger_level": "ask absorption at resistance",
            "price_response": "price rejected lower after absorption",
        },
        level_reasoning={
            "entry": "ask absorption rejection at structural level",
            "stop": "above swing high structural invalidation and noise",
            "target": "nearest support is too small for the full move",
        },
        exit_plan={
            "target_reason": "nearest target is too small; use runner if provided",
            "target_level": "608.58 support",
        },
    )
    coin_data = {
        "of": _of(mid=613.13, atr_14=4.0),
        "noise_state": {"dynamic_stop_floor": 3.0, "atr14": 4.0},
        "levels": {
            "prior_1h_low": 608.58,
            "last_swing_high": 624.41,
        },
        "candles_1h": [
            Candle(1, 620.0, 626.0, 600.0, 610.0, 1),
            Candle(2, 610.0, 615.0, 565.01, 580.0, 1),
        ],
        "candles_4h": [
            Candle(1, 640.0, 650.0, 565.01, 590.0, 1),
        ],
    }
    result = repair_trade_levels(
        decision,
        coin_data,
        min_rr=1.5,
        min_stop_noise_multiple=1.10,
        fee_bps=4.5,
        min_target_pct=0.008,
        min_target_atr_multiple=2.5,
        prefer_htf_targets=True,
        runner_target_rr=2.5,
        max_target_rr=5.0,
        runner_min_confidence=0.65,
    )
    assert result.allowed
    assert result.changed
    assert result.decision.target_price == 565.01
    assert "runner" in result.reason


def test_level_repair_uses_reaction_projection_when_no_structural_target_reaches_rr() -> None:
    decision = LLMTradeDecision(
        action="enter",
        coin="HYPE",
        side="long",
        confidence=0.72,
        entry_price=100.0,
        stop_price=98.0,
        target_price=101.0,
        setup_type="liquidity_sweep_reclaim",
        playbook_match={
            "playbook": "liquidity_sweep_reclaim",
            "trigger_present": True,
            "trigger_level": "sweep reclaim at value low",
            "price_response": "price reclaimed with buy tape",
            "anti_chase_check": "entry is on reclaim, not first sweep candle",
        },
        level_reasoning={
            "entry": "reclaim with buy tape",
            "stop": "below sweep low beyond noise",
            "target": "nearest level too small, use reaction mode",
        },
        exit_plan={
            "target_mode": "reaction",
            "target_reason": "mixed HTF, take reachable reaction instead of runner",
        },
        risk_portfolio_decision={
            "target_mode": "reaction",
            "final_action": "approve_enter",
            "approved_target_price": 101.0,
        },
    )
    coin_data = {
        "of": _of(mid=100.0, atr_14=1.0, vwap=0.0, vpoc=0.0, vah=0.0, val=0.0),
        "noise_state": {"dynamic_stop_floor": 1.0, "atr14": 1.0},
        "levels": {
            "prior_15m_high": 101.0,
            "prior_15m_low": 98.0,
        },
    }
    result = repair_trade_levels(
        decision,
        coin_data,
        min_rr=1.5,
        min_stop_noise_multiple=1.10,
        fee_bps=4.5,
        min_target_pct=0.008,
        min_target_atr_multiple=2.5,
        prefer_htf_targets=True,
        max_target_rr=5.0,
    )
    assert result.allowed
    assert result.changed
    assert round(result.decision.target_price, 2) == 103.09
    assert "reaction" in result.reason


def test_ioc_entry_limit_preserves_min_rr() -> None:
    plan = SimpleNamespace(entry=100.0, stop=103.0, target=94.0)
    cfg = SimpleNamespace(
        ioc_slippage_bps=20.0,
        ioc_slippage_spread_mult=1.5,
        ioc_slippage_max_bps=50.0,
        min_rr=1.5,
    )
    limit = _ioc_entry_limit(plan, "short", 10.0, cfg)
    assert limit < plan.entry
    assert _rr_at_entry("short", limit, plan.stop, plan.target) >= cfg.min_rr


def test_format_alignment_handles_range_thresholds() -> None:
    text = format_alignment(_of(), "long", TradingPolicy())
    assert "rsi_not_exhausted" in text
    assert "35..65" in text


def test_research_gate_uses_hard_absorption_veto_not_warning() -> None:
    policy = TradingPolicy(absorption_warning_threshold=0.5, absorption_hard_veto_threshold=0.6)
    coin_data = {
        "of": _of(ask_absorption=0.55),
        "regime": MarketRegime("up", "up", "chop", "normal", "ranging"),
        "trigger_state": {"pullback_retest_long": True},
        "ws_summary": {
            "available": True,
            "book_available": True,
            "tape_available": True,
            "book_stale_s": 1.0,
            "tape_stale_s": 1.0,
            "tape_windows": {
                "30s": {"delta_ratio": 0.30, "total_usd": 1000},
                "1m": {"delta_ratio": 0.25, "total_usd": 2000},
                "2m": {"total_usd": 3000},
            },
        },
        "candles_15m": [
            Candle(1, 99, 101, 98, 99, 1),
            Candle(2, 99, 101, 98.5, 100.5, 1),
        ],
    }
    result = evaluate_research_gate(_decision(), coin_data, policy=policy)
    assert result.allowed, result.reasons


def test_research_gate_rejects_recent_eth_style_sweep_short() -> None:
    coin_data = {
        "of": _of(
            book_imbalance=-0.18,
            tape_delta_pct=-0.99,
            cvd=-1000,
            price_vs_vwap=0.008,
            macd_histogram=-0.6,
            bid_absorption=0.0,
            ask_absorption=0.0,
        ),
        "regime": MarketRegime("chop", "chop", "up", "normal", "ranging"),
        "trigger_state": {"sweep_reclaim_short": True},
        "trigger_meta": {
            "resistance_response": False,
            "support_response": False,
            "ws_delta": -0.86,
        },
        "noise_state": {"dynamic_stop_floor": 3.831429},
        "ws_summary": {
            "available": True,
            "book_available": True,
            "tape_available": True,
            "book_stale_s": 1.0,
            "tape_stale_s": 1.0,
            "tape_windows": {
                "30s": {"delta_ratio": -0.80, "total_usd": 1000},
                "1m": {"delta_ratio": -0.70, "total_usd": 2000},
                "2m": {"total_usd": 3000},
            },
        },
        "candles_15m": [
            Candle(1, 2116, 2118, 2114, 2116, 1),
            Candle(2, 2115, 2116, 2114, 2114.5, 1),
        ],
    }
    result = evaluate_research_gate(_short_sweep_decision(), coin_data, policy=TradingPolicy())
    assert not result.allowed
    joined = "; ".join(result.reasons)
    assert "no computed resistance_response" in joined
    assert "4h up" in joined


def test_research_gate_rejects_stop_too_close_to_noise_floor() -> None:
    coin_data = {
        "of": _of(
            book_imbalance=-0.35,
            tape_delta_pct=-0.35,
            cvd=-1000,
            price_vs_vwap=-0.01,
            macd_histogram=-0.01,
            bid_absorption=0.0,
            ask_absorption=0.3,
        ),
        "regime": MarketRegime("down", "chop", "chop", "normal", "ranging"),
        "trigger_state": {"sweep_reclaim_short": True},
        "trigger_meta": {"resistance_response": True, "ws_delta": -0.35},
        "noise_state": {"dynamic_stop_floor": 3.9},
        "ws_summary": {
            "available": True,
            "book_available": True,
            "tape_available": True,
            "book_stale_s": 1.0,
            "tape_stale_s": 1.0,
            "tape_windows": {
                "30s": {"delta_ratio": -0.30, "total_usd": 1000},
                "1m": {"delta_ratio": -0.30, "total_usd": 2000},
                "2m": {"total_usd": 3000},
            },
        },
        "candles_15m": [
            Candle(1, 100, 103, 98, 101, 1),
            Candle(2, 101, 103, 99, 100, 1),
        ],
    }
    decision = _short_sweep_decision()
    decision.entry_price = 100.0
    decision.stop_price = 104.0
    decision.target_price = 90.0
    result = evaluate_research_gate(decision, coin_data, policy=TradingPolicy(min_stop_noise_multiple=1.10))
    assert not result.allowed
    assert "stop blocked by OHLC noise lesson" in "; ".join(result.reasons)


def test_research_gate_blocks_countertrend_breakout_below_vwap() -> None:
    decision = SimpleNamespace(
        action="enter",
        side="long",
        setup_type="breakout_acceptance",
        entry_price=84.55,
        stop_price=84.25,
        target_price=85.0,
        playbook_match={
            "playbook": "breakout_acceptance",
            "trigger_present": True,
            "trigger_level": "breakout_acceptance_long above 1h high 84.519",
            "price_response": "price accepted above the level",
            "anti_chase_check": "entry follows acceptance and is not a blind current-mid chase",
            "self_reject_if": "price loses breakout level",
        },
        level_reasoning={"entry": "breakout trigger with buy tape"},
        trade_plan={"entry_trigger": "breakout_acceptance_long"},
        evidence=[],
        reason="counter-trend and R:R borderline",
        regime_note="",
        risk_assessment="",
        exit_plan={},
    )
    coin_data = {
        "of": _of(
            mid=84.55,
            book_imbalance=0.88,
            tape_delta_pct=0.44,
            price_vs_vwap=-0.0038,
            macd_histogram=0.025,
            rsi_14=48.0,
            bid_absorption=0.0,
            ask_absorption=0.0,
            atr_14=0.218,
        ),
        "regime": MarketRegime("chop", "chop", "down", "normal", "ranging"),
        "trigger_state": {"breakout_acceptance_long": True},
        "trigger_meta": {"support_response": True, "ws_delta": 0.44},
        "noise_state": {"dynamic_stop_floor": 0.2618},
        "ws_summary": {
            "available": True,
            "book_available": True,
            "tape_available": True,
            "book_stale_s": 1.0,
            "tape_stale_s": 1.0,
            "tape_windows": {
                "30s": {"delta_ratio": 0.44, "total_usd": 1000},
                "1m": {"delta_ratio": 0.44, "total_usd": 2000},
                "2m": {"total_usd": 3000},
            },
        },
        "candles_15m": [
            Candle(1, 84.3, 84.5, 84.2, 84.4, 1),
            Candle(2, 84.4, 84.6, 84.3, 84.55, 1),
        ],
    }
    result = evaluate_research_gate(decision, coin_data, policy=TradingPolicy())
    assert not result.allowed
    assert "breakout long blocked" in "; ".join(result.reasons)


def test_exit_plan_requires_noise_buffer_not_equal_floor() -> None:
    coin_data = {
        "of": _of(),
        "candles_1m": [],
        "candles_5m": [],
        "trigger_state": {"sweep_reclaim_short": True},
        "noise_state": {"dynamic_stop_floor": 3.9},
    }
    decision = _short_sweep_decision()
    decision.entry_price = 100.0
    decision.stop_price = 104.0
    decision.target_price = 90.0
    result = evaluate_exit_plan(
        decision,
        coin_data,
        min_rr=1.5,
        min_stop_noise_multiple=1.10,
    )
    assert not result.allowed
    assert "stop too close to current noise" in result.reason
    assert result.metrics["required_stop_distance"] == 4.29


def test_exit_plan_rejects_micro_target_in_larger_trade_mode() -> None:
    coin_data = {
        "of": _of(mid=84.55, atr_14=0.218),
        "candles_1m": [],
        "candles_5m": [],
        "trigger_state": {"breakout_acceptance_long": True},
        "noise_state": {"dynamic_stop_floor": 0.2618, "atr14": 0.218},
    }
    decision = SimpleNamespace(
        action="enter",
        side="long",
        entry_price=84.55,
        stop_price=84.25,
        target_price=85.0,
    )
    result = evaluate_exit_plan(
        decision,
        coin_data,
        min_rr=1.5,
        min_stop_noise_multiple=1.10,
        min_target_pct=0.008,
        min_target_atr_multiple=2.5,
    )
    assert not result.allowed
    assert "target too small for larger-trade mode" in result.reason
    assert result.metrics["required_target_distance"] == 0.6764


def test_trigger_state_uses_configurable_tolerance_factor() -> None:
    of = _of(mid=100, atr_14=4.0)
    candles = [
        Candle(1, 100, 102, 98, 100, 1),
        Candle(2, 99, 101, 97.9, 100.5, 1),
    ]
    features = build_trigger_features(
        of=of,
        regime=MarketRegime("up", "chop", "chop", "normal", "ranging"),
        candles_15m=candles,
        candles_1h=candles,
        ws_summary={"available": True, "tape_stale_s": 1, "delta_ratio_30s": 0.25},
        policy=TradingPolicy(trigger_tolerance_factor=0.5),
    )
    assert features["trigger_meta"]["trigger_tolerance_factor"] == 0.5
    assert "sweep_tolerance" in features["trigger_meta"]


def test_cvd_tracker_persists_same_session(tmp_path) -> None:
    path = tmp_path / "cvd.json"
    tracker = CVDTracker(state_path=str(path))
    tracker.update("BTC", [TradeTick(ts=1, price=100.0, size=1.0, side="buy")], 100.0)
    restored = CVDTracker(state_path=str(path))
    assert restored.get_cvd("BTC") == 100.0


def test_ws_cache_has_silent_reconnect_threshold() -> None:
    cache = LiveOrderflowCache(api_url="https://api.hyperliquid.xyz", coins=["BTC"], max_silent_s=12.5)
    assert cache.max_silent_s == 12.5
