"""
ExitOptimizer - 智能平仓引擎 v2.0
核心理念：不让盈利单变亏损

三层保护：
  L1 保本：profit >= 0.5ATR → SL 移到 entry（保本）
  L2 锁利：profit >= 1.0ATR → 止盈40%，SL移到 entry+0.3ATR
  L3 奔跑：profit >= 1.5ATR → 再止盈30%，SL追踪0.6ATR

动量退出（不等SL）：
  BUY: RSI > 72 强制止盈（超买）
  SELL: RSI < 28 强制止盈（超卖）
  MACD柱收缩 > 50% 强制检查

时间退出：已移除 —— 趋势持仓需数小时运行，时间止损截断长波段利润
"""

import time
import json
import pathlib
import os
from typing import Tuple, Optional


# ── Module-level GARCH vol_ratio cache: read once per bar, shared by all exit types ──
_GARCH_CACHE: dict = {}
_GARCH_CACHE_TS: float = 0.0

def _get_vol_scale() -> float:
    global _GARCH_CACHE, _GARCH_CACHE_TS
    now = time.time()
    if _GARCH_CACHE and now - _GARCH_CACHE_TS < 60.0:
        return _GARCH_CACHE.get('vol_scale', 1.0)
    try:
        from bridge.garch_volatility import get_vol_multiplier
        result = get_vol_multiplier(refresh=False)
        vol_ratio = result.get("vol_ratio", 1.0)
        _GARCH_CACHE['vol_scale'] = max(0.6, min(1.8, vol_ratio))
        _GARCH_CACHE_TS = now
        return _GARCH_CACHE['vol_scale']
    except Exception:
        return 1.0

try:
    from config_manager import get_effective_param as _get_effective_param
except Exception:  # pragma: no cover - frozen/runtime fallback
    _get_effective_param = None

try:
    from src.market_context import is_defensive_regime, is_range_regime, normalize_regime_label
except ModuleNotFoundError:
    from market_context import is_defensive_regime, is_range_regime, normalize_regime_label

# ROOT now resolved via frozen_path (PyInstaller-safe)
from frozen_path import APP_ROOT as ROOT
_PEAK_FILE = ROOT / "logs" / "exit_tp_peak.json"

# ── Adaptive price rounding (2-digit vs 3-digit brokers) ──────────
try:
    from src.price_digits import round_price as _round_price
except ImportError:
    def _round_price(price, **_kwargs):
        return round(float(price), 2)


def _write_request_atomic(target_path: pathlib.Path, payload: dict):
    tmp_path = target_path.with_suffix(target_path.suffix + ".tmp")
    if tmp_path.exists():
        tmp_path.unlink(missing_ok=True)
    tmp_path.write_text(json.dumps(payload, indent=2))
    os.replace(tmp_path, target_path)

def _load_peak_file():
    if not _PEAK_FILE.exists():
        return {}
    try:
        return json.loads(_PEAK_FILE.read_text(encoding="utf-8"))
    except:
        return {}

def _save_peak_file(data):
    try:
        _write_request_atomic(_PEAK_FILE, data)
    except Exception:
        pass

# ─── TP 追踪配置 ────────────────────────────────────────────
TP_TRAIL_MIN_PROFIT_ATR = 0.8   # 浮盈达到多少 ATR 后激活 TP 追踪
# ==========================================
# 核心配置：动态呼吸空间参数
# ==========================================
TP_TRAIL_BASE_ATR       = 2.2   # 基础追踪距离
MIN_TRAIL_ATR           = 1.2   # 最小追踪距离兜底
MAX_TRAIL_ATR           = 3.8   # 最大追踪距离天花板
L3_TIGHT_MULTIPLIER     = 0.8   # 狂飙阶段紧 multiplier
TP_TRAIL_STEP_ATR       = 0.5   # TP 移动步长

# ==========================================
# 核心配置：各级防守阈值
# ==========================================
L1_PROFIT_ATR = 1.2          # L1 保本触发线（gold-calibrated: ~$5 profit before breakeven）
L2_PROFIT_ATR = 2.0          # L2 锁利触发线
L3_PROFIT_ATR = 3.5          # L3 狂飙触发线
L1_LOCKED_PROFIT_ATR = 0.2   # L1 轻量保本
L2_MIN_LOCKED_PROFIT_ATR = 0.4  # L2 最低锁盈 floor

# ─── 亏损保护 ─────────────────────────────────────
MAX_LOSS_ATR = 4.0           # 浮亏超过 4.0 ATR 硬止损（仅在 SL 未触发时兜底）
L2_PARTIAL_MIN_PROFIT_ATR = 1.2  # L2 分批止盈：降到 1.2 ATR 即锁定部分利润
L3_PARTIAL_MIN_PROFIT_ATR = 2.0  # L3 分批止盈：降到 2.0 ATR 进一步锁定利润

# ==========================================
# Strategy-aware exit routing (Codex v2)
# ==========================================
# Strategy → base type classification (independent of regime)
TREND_STRATEGIES = {"ema_macd", "elliott_wave", "wyckoff", "adx_breakout", "pullback_fade"}
REVERSION_STRATEGIES = {"rsi_extreme", "boll_band", "rsi_divergence", "key_level"}
RANGE_STRATEGIES = {"grid_osc"}
BREAKOUT_STRATEGIES = {"breakout_strategy"}

# Regime Router labels → exit routing type
# Maps the actual regime_router.classify_regime() output to exit behavior.
# TREND: wide stops, late trail, let profits run
# RANGE: tight stops, early lock-in, quick partials
# REVERSION: fixed target, no trail
# DEFAULT: conservative balanced (fallback)
REGIME_TO_EXIT_TYPE = {
    "STRONG_TREND":     "TREND",
    "WEAK_TREND":       "WEAK_TREND",
    "STEALTH_TREND":    "TREND",
    "RANGING":          "RANGE",
    "FAKE_TREND":       "RANGE",       # false breakout → treat as ranging (tight defense)
    "VOLATILITY_SPIKE": "RANGE",       # chaos → tight defense
    "EXTREME":          "RANGE",       # extreme → conservative
    "TRANSITION":       "DEFAULT",     # uncertain → TP/SL only, no active optimizer exit
    "UNKNOWN":          "DEFAULT",
}

# Strategy-type → default exit type (when regime is UNKNOWN)
STRATEGY_DEFAULT_EXIT = {
    "trend":           "TREND",
    "mean_reversion":  "REVERSION",
    "pullback":        "TREND",
    "grid":            "RANGE",
    "hybrid":          "DEFAULT",
}

def _strategy_exit_type(strategy_id: str, regime: str) -> str:
    """Determine exit routing type from current regime + strategy.

    Priority:
      1. Regime-driven: use REGIME_TO_EXIT_TYPE mapping (covers all regime_router labels)
      2. Strategy-driven fallback: use STRATEGY_DEFAULT_EXIT for unknown regimes
      3. Hardcoded rules for specific strategy families
    """
    sid = str(strategy_id or "").lower().strip()
    reg = normalize_regime_label(regime)
    is_trend_strategy = sid in TREND_STRATEGIES or sid in {"composite", "composite_v1"}
    is_reversion_strategy = sid in REVERSION_STRATEGIES
    is_range_strategy = sid in RANGE_STRATEGIES
    is_breakout_strategy = sid in BREAKOUT_STRATEGIES

    if not (is_trend_strategy or is_reversion_strategy or is_range_strategy or is_breakout_strategy):
        is_trend_strategy = "trend" in sid or "macd" in sid or "ema" in sid
        is_range_strategy = "range" in sid or "grid" in sid or "osc" in sid
        is_reversion_strategy = (
            "reversion" in sid or "rsi" in sid or "boll" in sid or "key_level" in sid
        )

    # ── 1. Regime-driven routing (primary) ──
    exit_type = REGIME_TO_EXIT_TYPE.get(reg)
    if exit_type and exit_type != "DEFAULT":
        # Keep the exit family aligned with the opened position's strategy family.
        # Trend/composite runners can tighten thresholds later in evaluate_exit,
        # but should not be downgraded into fast RANGE exits that cut them before
        # trailing protection has a chance to arm.
        if is_trend_strategy and exit_type == "RANGE":
            return "TREND"
        if exit_type == "TREND" and is_reversion_strategy:
            return "REVERSION"  # Reversion strategy always uses reversion exit
        if is_range_strategy:
            return "RANGE"
        if is_breakout_strategy:
            return "BREAKOUT"
        return exit_type

    # ── 2. Strategy-driven fallback ──
    if is_trend_strategy:
        return "TREND"
    if is_reversion_strategy:
        return "REVERSION"
    if is_range_strategy:
        return "RANGE"
    if is_breakout_strategy:
        return "BREAKOUT"

    # ── 3. Pattern-based fallback ──
    if is_trend_strategy:
        return "TREND"
    if is_range_strategy:
        return "RANGE"
    if is_reversion_strategy:
        return "REVERSION"

    return "DEFAULT"


# ─── 时间止损 (Time Stop) 配置 ─────────────────────────────
MAX_HOLD_SECONDS_FLAT = 5400    # 震荡无盈利状态下的最大持仓时间：从 45分钟 延长至 90分钟 (给足震荡破局的耐心)
TIME_STOP_MIN_PROFIT_ATR = 0.3  # 如果持仓超过阈值，但浮盈小于这个ATR，则强制平仓
_MIN_HOLD_FOR_TIME_STOP = 120   # 最小持仓时间（秒）：低于此视为 position_time Bug，不触发时间止损（从.bak2移植）
_MIN_HOLD_FOR_PROTECTIVE_TRAIL = 120  # 最小持仓时间：降至 2 分钟，数据行情脉冲后快速进入保护
_MIN_HOLD_FOR_PARTIAL_CLOSE = 240  # 最小持仓时间：降至 4 分钟，更快分批锁定利润

def _first_effective_param(keys: list[str], default):
    if _get_effective_param is None:
        return default
    for key in keys:
        try:
            value = _get_effective_param(key, None)
        except Exception:
            value = None
        if value is not None:
            return value
    return default


def _as_bool(value, default: bool) -> bool:
    if value is None:
        return default
        return value
    text = str(value).strip().lower()
    if text in {"1", "true", "yes", "on"}:
        return True
    if text in {"0", "false", "no", "off"}:
        return False
    return default


def _as_float(value, default: float) -> float:
    try:
        return float(value)
    except Exception:
        return float(default)


def _load_runtime_exit_params() -> dict:
    break_even_trigger = _as_float(
        _first_effective_param(
            ["exit_strategy.break_even.trigger_atr"],
            0.5,
        ),
        0.5,
    )
    trend_trail_base = _as_float(
        _first_effective_param(
            [
                "exit_strategy.trailing_stop.trend.trail_distance_atr",
                "exit_strategy.trailing_stop.trend.trail_base_atr",
            ],
            2.0,
        ),
        2.0,
    )
    trend_trail_min = _as_float(
        _first_effective_param(
            ["exit_strategy.trailing_stop.trend.min_trail_atr"],
            1.0,
        ),
        1.0,
    )
    trend_trail_max = _as_float(
        _first_effective_param(
            ["exit_strategy.trailing_stop.trend.max_trail_atr"],
            3.5,
        ),
        3.5,
    )
    return {
        "break_even_enabled": _as_bool(
            _first_effective_param(["exit_strategy.break_even.enabled"], True),
            True,
        ),
        "break_even_trigger_atr": max(0.0, break_even_trigger),
        "break_even_lock_atr": max(
            0.0,
            _as_float(
                _first_effective_param(["exit_strategy.break_even.lock_atr"], L1_LOCKED_PROFIT_ATR),
                L1_LOCKED_PROFIT_ATR,
            ),
        ),
        "trend_trail_enabled": _as_bool(
            _first_effective_param(["exit_strategy.trailing_stop.trend.enabled"], True),
            True,
        ),
        "trend_trail_min_profit_atr": max(
            0.0,
            _as_float(
                _first_effective_param(["exit_strategy.trailing_stop.trend.min_profit_atr"], 0.8),
                0.8,
            ),
        ),
        "trend_trail_base_atr": max(0.1, trend_trail_base),
        "trend_trail_min_atr": max(0.1, trend_trail_min),
        "trend_trail_max_atr": max(trend_trail_min, trend_trail_max),
        "range_trail_enabled": _as_bool(
            _first_effective_param(["exit_strategy.trailing_stop.range.enabled"], False),
            False,
        ),
        "range_trail_min_profit_atr": max(
            0.0,
            _as_float(
                _first_effective_param(["exit_strategy.trailing_stop.range.min_profit_atr"], 0.3),
                0.3,
            ),
        ),
        "range_trail_distance_atr": max(
            0.1,
            _as_float(
                _first_effective_param(["exit_strategy.trailing_stop.range.trail_distance_atr"], 0.4),
                0.4,
            ),
        ),
        "trend_partial_enabled": _as_bool(
            _first_effective_param(["exit_strategy.partial_close.trend.enabled"], True),
            True,
        ),
        "trend_partial1_atr": max(
            0.0,
            _as_float(
                _first_effective_param(
                    [
                        "exit_strategy.partial_close.trend.partial1_atr",
                        "exit_strategy.partial_close.trend.partial_tp_atr",
                    ],
                    1.5,
                ),
                1.5,
            ),
        ),
        "trend_partial1_pct": max(
            0.0,
            min(
                0.95,
                _as_float(
                    _first_effective_param(
                        [
                            "exit_strategy.partial_close.trend.partial1_pct",
                            "exit_strategy.partial_close.trend.partial_close_pct",
                        ],
                        0.4,
                    ),
                    0.4,
                ),
            ),
        ),
        "trend_partial2_atr": max(
            0.0,
            _as_float(
                _first_effective_param(["exit_strategy.partial_close.trend.partial2_atr"], 2.0),
                2.0,
            ),
        ),
        "trend_partial2_pct": max(
            0.0,
            min(
                0.95,
                _as_float(
                    _first_effective_param(["exit_strategy.partial_close.trend.partial2_pct"], 0.3),
                    0.3,
                ),
            ),
        ),
        "runner_trail_enabled": _as_bool(
            _first_effective_param(["exit_strategy.partial_close.trend.runner_trail"], True),
            True,
        ),
        "range_partial1_atr": max(
            0.0,
            _as_float(
                _first_effective_param(["exit_strategy.partial_close.range.partial1_atr"], 0.5),
                0.5,
            ),
        ),
        "range_partial1_pct": max(
            0.0,
            min(
                0.95,
                _as_float(
                    _first_effective_param(["exit_strategy.partial_close.range.partial1_pct"], 0.5),
                    0.5,
                ),
            ),
        ),
        "range_partial2_atr": max(
            0.0,
            _as_float(
                _first_effective_param(["exit_strategy.partial_close.range.partial2_atr"], 1.0),
                1.0,
            ),
        ),
        "range_partial2_pct": max(
            0.0,
            min(
                0.95,
                _as_float(
                    _first_effective_param(["exit_strategy.partial_close.range.partial2_pct"], 0.5),
                    0.5,
                ),
            ),
        ),
        "rsi_long_exit_trend": _as_float(
            _first_effective_param(["exit_strategy.rsi_exit.trend.rsi_long_exit"], 75),
            75,
        ),
        "rsi_short_exit_trend": _as_float(
            _first_effective_param(["exit_strategy.rsi_exit.trend.rsi_short_exit"], 25),
            25,
        ),
        "rsi_profit_required_trend": max(
            0.0,
            _as_float(
                _first_effective_param(["exit_strategy.rsi_exit.trend.profit_required_atr"], 0.3),
                0.3,
            ),
        ),
        "rsi_long_exit_range": _as_float(
            _first_effective_param(["exit_strategy.rsi_exit.range.rsi_long_exit"], 68),
            68,
        ),
        "rsi_short_exit_range": _as_float(
            _first_effective_param(["exit_strategy.rsi_exit.range.rsi_short_exit"], 32),
            32,
        ),
        "rsi_profit_required_range": max(
            0.0,
            _as_float(
                _first_effective_param(["exit_strategy.rsi_exit.range.profit_required_atr"], 0.2),
                0.2,
            ),
        ),
        "time_exit_range_enabled": _as_bool(
            _first_effective_param(["exit_strategy.time_exit.range.enabled"], True),
            True,
        ),
        "time_exit_range_max_hold_secs": max(
            0.0,
            _as_float(
                _first_effective_param(["exit_strategy.time_exit.range.max_hold_secs"], 3600),
                3600,
            ),
        ),
    }


def _get_trail_distance_atr(
    atr: float,
    vol_ratio: float,
    base_atr: float = TP_TRAIL_BASE_ATR,
    min_atr: float = MIN_TRAIL_ATR,
    max_atr: float = MAX_TRAIL_ATR,
) -> float:
    """
    根据 GARCH 波动率动态计算追踪距离 (ATR倍数)
    高波动时拉宽 (给足呼吸空间)，低波动时收紧
    
    计算公式：trail_distance = base_atr × vol_ratio
    约束到 [MIN_TRAIL_ATR, MAX_TRAIL_ATR]
    """
    # 钳制波动率极值
    vol_ratio = max(0.3, min(2.5, vol_ratio))
    distance = base_atr * vol_ratio
    return round(max(min_atr, min(max_atr, distance)), 2)

_logger = None

def _log(msg, level="INFO"):
    global _logger
    if _logger:
        _logger.log(msg, level)
    else:
        ts = time.strftime("%H:%M:%S")
        print(f"[{ts}] [ExitOpt] {msg}")


def set_logger(logger_fn):
    global _logger
    _logger = logger_fn


def evaluate_exit(
    bid: float, ask: float, state: dict, indicators: dict, atr: float,
    strategy_id: str = None, regime: str = None,
    market_ctx=None,  # ExitMarketSensor MarketContext
) -> Tuple[bool, str, Optional[dict]]:
    """
    判断是否应该平仓。7维 market_ctx 提供行情感知增强。

    返回: (should_exit, reason, exit_detail)
    exit_detail: {"action": "CLOSE"|"CLOSE_PARTIAL", "volume": float, "sl_price": float, "tp_price": float}
    """
    if not state.get("position_open"):
        return False, "", None
    runtime_cfg = _load_runtime_exit_params()

    # ── 异动应急平仓（最高优先级）──
    if market_ctx is not None and getattr(market_ctx, 'data_valid', False):
        emerg = _check_anomaly_emergency(bid, ask, state, indicators, atr, market_ctx)
        if emerg:
            return emerg

    side = (state or {}).get("position_side", "BUY")
    entry = state.get("position_entry", 0)
    cur_sl = state.get("position_sl", 0)
    cur_tp = state.get("position_tp", 0)
    vol = state.get("position_vol", 0.01)

    if entry <= 0 or atr <= 0:
        return False, "", None

    # F3: Fast 3-bar displacement perception.
    # If recent 3 bars show low displacement (< 0.30 ATR), trend may have stalled.
    # Only applies to TREND regimes. RANGING/TRANSITION need low displacement naturally;
    # killing those regimes causes DEFAULT path (0.3 ATR trail) to shred positions in chop.
    _bars = (indicators or {}).get("bars", [])
    if regime and regime in ("STRONG_TREND", "WEAK_TREND", "STEALTH_TREND") and len(_bars) >= 4:
        try:
            _c3 = _bars[-1]["close"]
            _c0 = _bars[-4]["close"]
            _d3 = abs(_c3 - _c0) / max(atr, 0.1)
            if _d3 < 0.30:
                regime = ""
        except (IndexError, KeyError, TypeError, ZeroDivisionError):
            pass

    # === Strategy-Aware Exit Router ===
    if regime:
        exit_type = _strategy_exit_type(strategy_id or "", regime or "")
        if exit_type == "TREND":
            return _trend_exit(bid, ask, state, indicators, atr, market_ctx=market_ctx, runtime_cfg=runtime_cfg)
        elif exit_type == "WEAK_TREND":
            return _weak_trend_exit(bid, ask, state, indicators, atr, market_ctx=market_ctx, runtime_cfg=runtime_cfg)
        elif exit_type == "RANGE":
            return _range_exit(bid, ask, state, indicators, atr, market_ctx=market_ctx, runtime_cfg=runtime_cfg)
        elif exit_type == "REVERSION":
            return _reversion_exit(bid, ask, state, indicators, atr, market_ctx=market_ctx)
        elif exit_type == "BREAKOUT":
            return _breakout_exit(bid, ask, state, indicators, atr, market_ctx=market_ctx, runtime_cfg=runtime_cfg)
    # Fall through to original logic for DEFAULT


    # ── 获取 GARCH 波动率数据（用于动态追踪距离）──
    try:
        from bridge.garch_volatility import get_vol_multiplier
        garch_result = get_vol_multiplier(refresh=False)
        vol_ratio = garch_result.get("vol_ratio", 1.0)
        garch_regime = garch_result.get("regime", "NORMAL")
    except Exception:
        vol_ratio = 1.0
        garch_regime = "NORMAL"

    # ── 计算当前浮盈 ────────────────────────────
    if side == "BUY":
        profit_pts = bid - entry
    else:
        profit_pts = entry - ask

    profit_atr = profit_pts / atr if atr > 0 else 0

    # ── 峰值追踪（用于追踪止损，而非当前价）──
    peak_data = _load_peak_file()
    ticket = str(state.get("position_ticket", ""))
    peak_key = f"{ticket}_{side}"
    peak_price = peak_data.get(peak_key, entry if side == "BUY" else entry)
    if side == "BUY":
        if bid > peak_price:
            peak_price = bid
    else:
        if ask < peak_price:
            peak_price = ask
    peak_data[peak_key] = peak_price

# ── 获取时段信息（用于亚盘紧凑追踪）──
    try:
        from bridge.session_analyzer import get_current_session
        current_session = get_current_session()
    except Exception:
        utc_hour = time.gmtime().tm_hour
        current_session = "ASIA" if utc_hour >= 22 or utc_hour < 8 else "UNKNOWN"

    # ── GARCH 波动率缩放：高波动时放大阈值，低波动时收紧 ──
    # clamp vol_ratio to [0.6, 1.8] for threshold scaling to avoid extremes
    vol_scale = max(0.6, min(1.8, vol_ratio))

    # ── 柔性武器 3：亚盘/震荡市 延迟介入 + 放宽呼吸空间 ──
    # 震荡环境里最怕"刚有点利润就收紧"，所以要更晚介入、介入后也更松。
    normalized_garch_regime = normalize_regime_label(garch_regime)
    is_defensive_env = (
        is_range_regime(normalized_garch_regime)
        or is_defensive_regime(normalized_garch_regime)
        or current_session == "ASIA"
    )

    # ── Adaptive thresholds (market_ctx aware) ──
    adaptive = _adaptive_exit_params(market_ctx, 0.0, is_defensive_env, state, runtime_cfg=runtime_cfg)

    # Apply GARCH vol scaling on top of adaptive base
    active_L1_PROFIT_ATR = round(adaptive["L1"] * vol_scale, 2)
    active_L2_PROFIT_ATR = round(adaptive["L2"] * vol_scale, 2)
    active_L3_PROFIT_ATR = round(adaptive["L3"] * vol_scale, 2)
    active_TP_TRAIL_BASE_ATR = _round_price(adaptive["trail"] * vol_scale)

    # Adaptive partial close pcts
    L2_partial_pct = adaptive["pct2"]
    L3_partial_pct = adaptive["pct3"]

    if _logger:
        _logger.log(
            f"[自适应阈值] L1={active_L1_PROFIT_ATR}ATR L2={active_L2_PROFIT_ATR}ATR "
            f"L3={active_L3_PROFIT_ATR}ATR Trail={active_TP_TRAIL_BASE_ATR}ATR "
            f"pct2={L2_partial_pct:.0%} pct3={L3_partial_pct:.0%} "
            f"(GARCH={vol_scale:.2f} Phase={getattr(market_ctx, 'phase', '?') if market_ctx else '?'})",
            "DEBUG")

    # ── L0: 初始止损保护 ───────────────────────
    # 如果还没到初始止损位置，不考虑提前退出
    if side == "BUY" and bid < cur_sl:
        return True, "SL_HIT", None
    if side == "SELL" and ask > cur_sl:
        return True, "SL_HIT", None

    rsi = indicators.get("rsi", 50)
    macd_hist = indicators.get("macd_hist", 0)
    adx = indicators.get("adx", 25)
    macd_hist_prev = indicators.get("macd_hist_prev", macd_hist)
    macd_divergence = str(indicators.get("macd_divergence", "NONE") or "NONE").upper()
    macd_norm = float(indicators.get("macd_norm", 0.0) or 0.0)

    reason_parts = []
    actions = []

    # ══ 持仓时间计算（仅用于保护性追踪和分批止盈的最小持仓门槛）══
    open_time = state.get("position_time", 0)
    if open_time > 0:
        hold_seconds = time.time() - open_time
    else:
        hold_seconds = 0
    # ── 时间强制平仓已移除：趋势持仓需要数小时运行，时间止损会截断长波段利润 ──

    # ══ L_DIVERGENCE: MACD 背离保护 ══════════════════════════
    if profit_atr > 0.5 and macd_divergence in ("BEARISH", "BULLISH"):
        div_side_conflict = (side == "BUY" and macd_divergence == "BEARISH") or (side == "SELL" and macd_divergence == "BULLISH")
        if div_side_conflict:
            if hold_seconds >= _MIN_HOLD_FOR_PARTIAL_CLOSE:
                divergence_close_vol = round(vol * 0.5, 2)
                if divergence_close_vol >= 0.01:
                    actions.append({
                        "action": "CLOSE_PARTIAL",
                        "volume": divergence_close_vol,
                        "reason": f"MACD_DIVERGENCE_{macd_divergence} 平50%@{profit_atr:.2f}ATR"
                    })
                    reason_parts.append(f"MACD背离{macd_divergence}减半仓")
            elif profit_atr >= 1.0:
                if side == "BUY":
                    tighter_sl = max(cur_sl, entry + 0.3 * atr)
                    if tighter_sl > cur_sl + 0.1 * atr:
                        actions.append({"action": "MODIFY_SL", "new_sl": _round_price(tighter_sl), "reason": "MACD_DIVERGENCE_PROTECT entry+0.3ATR"})
                        reason_parts.append("MACD背离收紧SL")
                elif side == "SELL":
                    tighter_sl = min(cur_sl, entry - 0.3 * atr)
                    if tighter_sl < cur_sl - 0.1 * atr:
                        actions.append({"action": "MODIFY_SL", "new_sl": _round_price(tighter_sl), "reason": "MACD_DIVERGENCE_PROTECT entry-0.3ATR"})
                        reason_parts.append("MACD背离收紧SL")

    # ══ L_LOSS: 亏损保护（新增）══════════════════════
    # 浮亏过大或持仓过久无利润 → 强制止损，提高盈亏比
    if profit_atr <= -MAX_LOSS_ATR:
        actions.append({
            "action": "CLOSE",
            "volume": vol,
            "reason": f"LOSS_CUT 浮亏{profit_atr:.2f}ATR超过{MAX_LOSS_ATR}ATR上限"
        })
        reason_parts.append(f"亏损截断@{profit_atr:.2f}ATR")
        if "exit_detail" not in locals(): exit_detail = {}
        exit_detail["mark_loss_cut"] = True
    # ── 时间+亏损组合截断已移除：让 SL 和纯亏损截断(MAX_LOSS_ATR) 负责风控 ──

    # ══ L1: 保本规则 (包含点差补偿) ══════════════════════════
    # 浮盈 >= L1_PROFIT_ATR → 把SL移到 entry + 补偿空间（抵消滑点和点差）
    if (
        runtime_cfg["break_even_enabled"]
        and
        profit_atr >= active_L1_PROFIT_ATR
        and profit_atr < active_L2_PROFIT_ATR
        and not state.get("l1_breakeven_done", False)
        and hold_seconds >= _MIN_HOLD_FOR_PROTECTIVE_TRAIL
    ):
        # L1 只做轻量保本，优先覆盖点差/滑点而不是过早锁死小利润。
        spread = max(0.0, ask - bid)
        buffer = max(spread * 1.5, runtime_cfg["break_even_lock_atr"] * atr)
        if side == "BUY":
            safe_sl = _round_price(entry + buffer)
            if cur_sl < safe_sl:
                actions.append({
                    "action": "MODIFY_SL",
                    "new_sl": safe_sl,
                    "reason": f"L1_BREAKEVEN entry={entry:.2f} safe_sl={safe_sl:.2f} profit={profit_atr:.2f}ATR"
                })
                reason_parts.append(f"L1保本@safe({safe_sl:.2f})")
                if "exit_detail" not in locals(): exit_detail = {}
                exit_detail["mark_l1_done"] = True
        elif side == "SELL":
            safe_sl = _round_price(entry - buffer)
            if cur_sl > safe_sl:
                actions.append({
                    "action": "MODIFY_SL",
                    "new_sl": safe_sl,
                    "reason": f"L1_BREAKEVEN entry={entry:.2f} safe_sl={safe_sl:.2f} profit={profit_atr:.2f}ATR"
                })
                reason_parts.append(f"L1保本@safe({safe_sl:.2f})")
                if "exit_detail" not in locals(): exit_detail = {}
                exit_detail["mark_l1_done"] = True
    # ══ L2: 动态锁利追踪 (Trailing Stop) ═══════
    # 浮盈 >= active_L2_PROFIT_ATR → 使用有数据支撑的动态距离（vol_ratio）进行跟随
    allow_runner_trail = runtime_cfg["trend_trail_enabled"] and runtime_cfg["runner_trail_enabled"]
    if (
        allow_runner_trail
        and
        profit_atr >= active_L2_PROFIT_ATR
        and profit_atr < active_L3_PROFIT_ATR
        and hold_seconds >= _MIN_HOLD_FOR_PROTECTIVE_TRAIL
    ):
        if cur_tp and cur_tp > 0:
            actions.append({
                "action": "MODIFY_TP",
                "new_tp": 0,
                "reason": "L2取消静态TP，释放趋势利润空间"
            })
            reason_parts.append("L2取消静态TP")

        # 获取具有严谨数据支撑的动态追踪距离 (基于 GARCH vol_ratio 缩放)
        trail_dist_atr = _get_trail_distance_atr(
            atr,
            vol_ratio,
            runtime_cfg["trend_trail_base_atr"],
            runtime_cfg["trend_trail_min_atr"],
            runtime_cfg["trend_trail_max_atr"],
        )
        
        # L2 仅要求轻量锁盈，让大部分利润保护交给更宽的动态追踪距离。
        min_locked_profit = L2_MIN_LOCKED_PROFIT_ATR * atr
        
        if side == "BUY":
            trail_sl = _round_price(peak_price - trail_dist_atr * atr)
            # 兜底：至少锁定 entry + 0.3ATR
            trail_sl = max(trail_sl, entry + min_locked_profit)
        else:
            trail_sl = _round_price(peak_price + trail_dist_atr * atr)
            # 兜底：至少锁定 entry - 0.3ATR
            trail_sl = min(trail_sl, entry - min_locked_profit)
        
        # 只有当计算出的 trail_sl 比当前 sl 更有利时（棘轮效应），才进行移动
        trail_armed = False
        if (side == "BUY" and trail_sl > cur_sl) or (side == "SELL" and trail_sl < cur_sl):
            actions.append({
                "action": "MODIFY_SL",
                "new_sl": trail_sl,
                "reason": f"L2动态追踪@{trail_sl:.2f} dist={trail_dist_atr:.1f}ATR"
            })
            reason_parts.append(f"L2止损追踪@{trail_sl:.2f}")
            trail_armed = True
        if trail_armed and cur_tp and cur_tp > 0:
            actions.append({
                "action": "MODIFY_TP",
                "new_tp": 0,
                "reason": "L2取消静态TP，释放趋势利润空间"
            })
            reason_parts.append("L2取消静态TP")

        # L2 阶段分批平仓逻辑（解耦追踪止损，即使已经平仓，上面的追踪止损依然生效）
        # 1. 如果是震荡市 (NORMAL/RANGING)，利润极易回吐，优先落袋为安，达标即切仓。
        # 2. 如果是趋势市 (TRENDING/EXTREME_TREND)，让利润奔跑，直到动能明显衰减才切仓。
        momentum_decay = False
        # 动能衰减过滤：要求 MACD 柱状图收缩超过 30%，过滤微小波动杂音
        if side == "BUY" and macd_hist_prev > 0 and macd_hist < (macd_hist_prev * 0.7):
            momentum_decay = True
        elif side == "SELL" and macd_hist_prev < 0 and macd_hist > (macd_hist_prev * 0.7):
            momentum_decay = True

        should_partial_close = False
        # 修复 Bug：之前把 NORMAL（正常波动）也算作震荡市强制切仓了，这会导致单边行情刚启动就被切掉。
        # 只有在明确的 RANGING (波动率低且没有单边方向) 下，才直接落袋为安。
        if garch_regime in ("RANGING", "UNCERTAIN"):
            should_partial_close = True  # 震荡市，直接落袋为安
        elif momentum_decay:
            should_partial_close = True  # 趋势市，动能衰减时切仓锁定

        active_L2_partial_min = round(runtime_cfg["trend_partial1_atr"] * vol_scale, 2)
        if (
            runtime_cfg["trend_partial_enabled"]
            and
            not state.get("l2_partial_closed", False)
            and should_partial_close
            and profit_atr >= active_L2_partial_min
            and hold_seconds >= _MIN_HOLD_FOR_PARTIAL_CLOSE
        ):
            partial_vol = round(vol * min(L2_partial_pct, runtime_cfg["trend_partial1_pct"]), 2)
            if partial_vol >= 0.01:
                reason_msg = "L2落袋为安" if garch_regime in ("RANGING", "UNCERTAIN") else "L2动能衰减"
                actions.append({
                    "action": "CLOSE_PARTIAL",
                    "volume": partial_vol,
                    "reason": f"{reason_msg}锁利40%@{profit_atr:.2f}ATR ({garch_regime})"
                })
                reason_parts.append(f"{reason_msg}止盈40%@{profit_atr:.2f}ATR")
                if "exit_detail" not in locals(): exit_detail = {}
                exit_detail["mark_l2_closed"] = True

    # ══ L3: 奔跑与极紧止损追踪 ══════════════════════════════
    # 浮盈 >= active_L3_PROFIT_ATR → 进入单边狂飙模式，使用收紧的动态距离保护绝大部分利润
    if profit_atr >= active_L3_PROFIT_ATR:
        # 在 L3 阶段，我们缩小呼吸空间（乘以 L3_TIGHT_MULTIPLIER），因为利润已经足够丰厚，要防范暴力反转
        tight_dist_atr = max(
            runtime_cfg["trend_trail_min_atr"],
            _get_trail_distance_atr(
                atr,
                vol_ratio,
                runtime_cfg["trend_trail_base_atr"],
                runtime_cfg["trend_trail_min_atr"],
                runtime_cfg["trend_trail_max_atr"],
            ) * L3_TIGHT_MULTIPLIER,
        )
        
        trail_sl3 = _round_price(peak_price - tight_dist_atr * atr) if side == "BUY" else _round_price(peak_price + tight_dist_atr * atr)
        
        trail_armed_l3 = False
        if (side == "BUY" and trail_sl3 > cur_sl) or (side == "SELL" and trail_sl3 < cur_sl):
            actions.append({
                "action": "MODIFY_SL",
                "new_sl": trail_sl3,
                "reason": f"L3奔跑追踪@{trail_sl3:.2f} dist={tight_dist_atr:.1f}ATR"
            })
            reason_parts.append(f"L3止损追踪@{trail_sl3:.2f}")
            trail_armed_l3 = True
        if trail_armed_l3 and cur_tp and cur_tp > 0:
            actions.append({
                "action": "MODIFY_TP",
                "new_tp": 0,
                "reason": "L3取消静态TP，释放尾仓奔跑空间"
            })
            reason_parts.append("L3取消静态TP")

        # 同理，防连环平仓，且在 L3 阶段也要判断市场状态和动能
        momentum_decay_l3 = False
        # 动能衰减过滤：要求 MACD 柱状图收缩超过 30%，过滤微小波动杂音
        if side == "BUY" and macd_hist_prev > 0 and macd_hist < (macd_hist_prev * 0.7):
            momentum_decay_l3 = True
        elif side == "SELL" and macd_hist_prev < 0 and macd_hist > (macd_hist_prev * 0.7):
            momentum_decay_l3 = True

        should_partial_close_l3 = False
        if garch_regime in ("RANGING", "UNCERTAIN"):
            should_partial_close_l3 = True
        elif momentum_decay_l3:
            should_partial_close_l3 = True

        active_L3_partial_min = round(max(runtime_cfg["trend_partial2_atr"], runtime_cfg["trend_partial1_atr"]) * vol_scale, 2)
        if (
            runtime_cfg["trend_partial_enabled"]
            and
            not state.get("l3_partial_closed", False)
            and should_partial_close_l3
            and profit_atr >= active_L3_partial_min
            and hold_seconds >= _MIN_HOLD_FOR_PARTIAL_CLOSE
        ):
            vol_remaining = round(vol * min(L3_partial_pct, runtime_cfg["trend_partial2_pct"]), 2)
            if vol_remaining >= 0.01:
                reason_msg_l3 = "L3落袋为安" if garch_regime in ("NORMAL", "RANGING", "UNCERTAIN") else "L3动能衰减"
                actions.append({
                    "action": "CLOSE_PARTIAL",
                    "volume": vol_remaining,
                    "reason": f"{reason_msg_l3}锁利60%@{profit_atr:.2f}ATR"
                })
                reason_parts.append(f"{reason_msg_l3}止盈60%@{profit_atr:.2f}ATR")
                if "exit_detail" not in locals(): exit_detail = {}
                exit_detail["mark_l3_closed"] = True

    # ══ TP 追踪（动态止盈线）已废除 ══════════════════
    # 之前这里有一段当 profit_atr >= TP_TRAIL_MIN_PROFIT_ATR 时动态修改 TP 的代码。
    # 为了实现"截断亏损，让利润奔跑"，并配合上面的"动能衰减部分平仓"逻辑，
    # 我们彻底废除了在趋势中动态拉近 TP 线的危险行为。
    # 现在的逻辑是：不设 TP 天花板，只靠上移 SL 和动能衰减来出场。

    # ══ 动量退出（不等ATR触发） ═══════════════
    # 之前这里会在 RSI>72 时直接平仓 50%，导致趋势刚启动就被腰斩。
    # 现修改为：动量指标（RSI/MACD）不再触发直接平仓，而是将这些指标作为预警，
    # 实际平仓交给 L2/L3 的动态 ATR 追踪止损来完成，保护大趋势利润。
    
    # ══ MACD 背离警告 ══════════════════════════
    # (原逻辑：MACD收缩>60%平仓50% - 已废弃，交由追踪止损处理)

    # ══ 保存峰值追踪数据 ══
    _save_peak_file(peak_data)

    # ══ 最终决策 ══════════════════════════════
    if not actions:
        return False, "HOLD", None

    reason = " | ".join(reason_parts)
    if "exit_detail" not in locals():
        exit_detail = {}
    exit_detail["actions"] = actions
    # ── trail_debug 反馈（供 pipeline 写入 trailing_telemetry 供 trade_guard 使用）──
    exit_detail["trail_debug"] = {
        "profit_atr": round(profit_atr, 3),
        "peak_price": peak_price,
        "vol_ratio": round(vol_ratio, 3),
        "phase": "L3" if profit_atr >= active_L3_PROFIT_ATR else ("L2" if profit_atr >= active_L2_PROFIT_ATR else ("L1" if profit_atr >= active_L1_PROFIT_ATR else "PRE_L1")),
        "active_L1": active_L1_PROFIT_ATR,
        "active_L2": active_L2_PROFIT_ATR,
        "active_L3": active_L3_PROFIT_ATR,
        "active_trail_base": active_TP_TRAIL_BASE_ATR,
        "garch_regime": garch_regime,
        "session": current_session,
        "is_defensive": is_defensive_env,
        "hold_seconds": hold_seconds,
    }
    return True, reason, exit_detail


# ═══════════════════════════════════════════════════════════════
# Anomaly Emergency Exit (checks BEFORE normal exit logic)
# ═══════════════════════════════════════════════════════════════

def _check_anomaly_emergency(
    bid: float, ask: float, state: dict, indicators: dict, atr: float, market_ctx
) -> Tuple[bool, str, Optional[dict]] | None:
    """Emergency exit for extreme market anomalies. Returns (should_exit, reason, detail) or None."""
    side = (state or {}).get("position_side", "BUY")
    entry = state.get("position_entry", 0)
    vol = state.get("position_vol", 0.01)
    profit_pts = (bid - entry) if side == "BUY" else (entry - ask)
    profit_atr = profit_pts / max(0.01, atr)

    # ① Flash spike: >3× ATR bar — exit immediately if against position
    if getattr(market_ctx, 'flash_spike', False):
        spike_against = (
            (side == "BUY" and (bid - entry) / max(0.01, atr) < -1.0) or
            (side == "SELL" and (entry - ask) / max(0.01, atr) < -1.0)
        )
        if spike_against:
            return True, "EMERGENCY_FLASH_SPIKE_AGAINST", {
                "actions": [{"action": "CLOSE", "volume": vol, "reason": "FLASH_SPIKE_EMERGENCY"}],
                "mark_emergency": True,
            }
        # Even if with position, flash spike after profit → lock in
        if profit_atr >= 2.0:
            return True, "EMERGENCY_FLASH_SPIKE_LOCK", {
                "actions": [{"action": "CLOSE", "volume": vol, "reason": "FLASH_SPIKE_LOCK_PROFIT"}],
                "mark_emergency": True,
            }

    # ② Liquidity vacuum: spread > 3× normal → exit to avoid slippage disaster
    if getattr(market_ctx, 'liquidity_vacuum', False):
        return True, "EMERGENCY_LIQUIDITY_VACUUM", {
            "actions": [{"action": "CLOSE", "volume": vol, "reason": "LIQUIDITY_VACUUM_EXIT"}],
            "mark_emergency": True,
        }

    # ③ Gap against position: price gapped past SL → market-order exit
    if getattr(market_ctx, 'gap_detected', False):
        gap_against = (
            (side == "BUY" and bid < state.get("position_sl", 0)) or
            (side == "SELL" and ask > state.get("position_sl", 0))
        )
        if gap_against:
            return True, "EMERGENCY_GAP_THROUGH_SL", {
                "actions": [{"action": "CLOSE", "volume": vol, "reason": "GAP_THROUGH_SL"}],
                "mark_emergency": True,
            }

    # ④ Structure break: trend structure broken + profit erosion > 50%
    if not getattr(market_ctx, 'structure_intact', True):
        peak_data = _load_peak_file()
        ticket = str(state.get("position_ticket", ""))
        peak_key = f"{ticket}_{side}"
        peak_price = peak_data.get(peak_key, entry)
        peak_profit_atr = abs(peak_price - entry) / max(0.01, atr)
        if peak_profit_atr >= 2.0 and profit_atr < peak_profit_atr * 0.3:
            return True, "EMERGENCY_STRUCTURE_BREAK", {
                "actions": [{"action": "CLOSE", "volume": vol,
                             "reason": f"STRUCTURE_BREAK peak={peak_profit_atr:.1f}ATR→now={profit_atr:.1f}ATR"}],
                "mark_emergency": True,
            }

    # ⑤ Extreme volatility: vol_regime=EXTREME + no position edge
    if getattr(market_ctx, 'vol_regime', '') == "EXTREME":
        if profit_atr < 0.5 and abs(profit_atr) < 1.0:
            return True, "EMERGENCY_EXTREME_VOL", {
                "actions": [{"action": "CLOSE", "volume": vol, "reason": "EXTREME_VOLATILITY_EXIT"}],
                "mark_emergency": True,
            }

    # ⑥ Anomaly score > 0.7: composite high-risk — lighten position
    if getattr(market_ctx, 'anomaly_score', 0.0) >= 0.7:
        close_vol = round(vol * 0.5, 2)
        if close_vol >= 0.01:
            return True, "EMERGENCY_HIGH_ANOMALY_SCORE", {
                "actions": [{"action": "CLOSE_PARTIAL", "volume": close_vol,
                             "reason": f"HIGH_ANOMALY_SCORE={market_ctx.anomaly_score:.2f}"}],
                "mark_emergency": True,
            }

    return None  # no emergency


# ═══════════════════════════════════════════════════════════════
# Market-Aware Adaptive Parameters
# ═══════════════════════════════════════════════════════════════

def _adaptive_exit_params(market_ctx, profit_atr: float, is_defensive: bool, state: dict = None, runtime_cfg: dict | None = None) -> dict:
    """Compute adaptive exit thresholds based on market context.

    When market_ctx is available, thresholds are shifted based on:
    - Trend strength: stronger trend → wider trail, later partials
    - Volatility expansion: higher vol → wider thresholds (avoid noise exits)
    - Volume energy: expanding vol → trend-confirmed → let run
    - Market phase: markup → let run; chop → lock quickly
    """
    runtime_cfg = runtime_cfg or _load_runtime_exit_params()
    # Default values
    base_L1 = runtime_cfg["break_even_trigger_atr"] if runtime_cfg["break_even_enabled"] else L1_PROFIT_ATR
    base_L2 = L2_PROFIT_ATR
    base_L3 = L3_PROFIT_ATR
    trail_dist = runtime_cfg["trend_trail_base_atr"]
    partial_pct_L2 = runtime_cfg["trend_partial1_pct"]
    partial_pct_L3 = runtime_cfg["trend_partial2_pct"]

    if market_ctx is None or not getattr(market_ctx, 'data_valid', False):
        if is_defensive:
            return {"L1": 2.2, "L2": 3.0, "L3": 4.0, "trail": 3.0, "pct2": 0.20, "pct3": 0.25}
        return {"L1": base_L1, "L2": base_L2, "L3": base_L3, "trail": trail_dist, "pct2": partial_pct_L2, "pct3": partial_pct_L3}

    # ── Trend strength adaptation ──
    adx = getattr(market_ctx, "adx", 25.0)
    trend_score = getattr(market_ctx, "trend_score", 0.0)
    vol_ratio_ctx = getattr(market_ctx, "vol_ratio", 1.0)
    phase = getattr(market_ctx, "phase", "CHOP")
    vol_regime = getattr(market_ctx, "vol_regime", "NORMAL")
    atr_ratio = getattr(market_ctx, "atr_ratio", 1.0)

    # Trend multiplier: stronger trend → more breathing room
    trend_mult = 1.0
    if adx >= 30 and abs(trend_score) > 0.5:
        trend_mult = 1.5  # Strong trend: let it run
    elif adx >= 25 and abs(trend_score) > 0.3:
        trend_mult = 1.2
    elif adx < 18:
        trend_mult = 0.8  # Weak trend: tighten

    # Phase multiplier: markup → let run; chop/distribution → lock
    phase_mult = 1.0
    if phase == "MARKUP":
        phase_mult = 1.3  # Let profits run in markup
        partial_pct_L2 = 0.15  # was 0.25 — keep more running in markup
        partial_pct_L3 = 0.20  # was 0.40
    elif phase == "MARKDOWN":
        phase_mult = 0.8  # Tight stops in markdown
    elif phase in ("DISTRIBUTION", "CHOP"):
        phase_mult = 0.7  # Lock quickly
        partial_pct_L2 = 0.25  # was 0.50 — reduced
        partial_pct_L3 = 0.25  # was 0.50 — reduced

    # Volatility adaptation
    vol_mult = 1.0
    if atr_ratio > 1.5:
        vol_mult = 1.4  # High vol: wider stops to avoid noise
    elif atr_ratio < 0.6:
        vol_mult = 0.85  # Low vol: tighter stops

    # Volume confirmation
    vol_energy_boost = 1.0
    if getattr(market_ctx, 'vol_expanding', False):
        vol_energy_boost = 1.1  # Expanding vol confirms trend → let run
    elif getattr(market_ctx, 'vol_contracting', False):
        vol_energy_boost = 0.9  # Contracting vol → trend weakening → tighten

    # ── Range loosening override: midpoint between trend and range thresholds ──
    route_family = getattr(market_ctx, 'route_family', '') if market_ctx else ''
    loosening_mult = 1.0
    if route_family == 'range_loosening':
        # Interpolate base thresholds toward range defaults (trend→range midpoint)
        base_L1 = round((L1_PROFIT_ATR + 0.8) / 2, 2)   # 1.0 ATR
        base_L2 = round((L2_PROFIT_ATR + 1.5) / 2, 2)   # 1.75 ATR
        base_L3 = round((L3_PROFIT_ATR + 2.5) / 2, 2)   # 3.0 ATR
        trail_dist = round((TP_TRAIL_BASE_ATR + 1.5) / 2, 2)  # 1.85 ATR
        partial_pct_L2 = 0.25   # Midpoint: 0.20–0.30
        partial_pct_L3 = 0.25
        loosening_mult = 0.9   # Slightly more conservative than full combined_mult

    combined_mult = trend_mult * phase_mult * vol_mult * vol_energy_boost * loosening_mult

    L1 = round(base_L1 * combined_mult, 2)
    L2 = round(base_L2 * combined_mult, 2)
    L3 = round(base_L3 * combined_mult, 2)
    trail = round(trail_dist * combined_mult, 2)

    # Key level proximity: tighten if near resistance (long) or support (short)
    dist_to_res_atr = getattr(market_ctx, 'dist_to_resistance_atr', 999.0)
    dist_to_sup_atr = getattr(market_ctx, 'dist_to_support_atr', 999.0)
    side = (state or {}).get("position_side", "BUY")
    if side == "BUY" and dist_to_res_atr < 1.0:
        L2 = min(L2, 1.5)  # Near resistance → accelerate lock-in
        partial_pct_L2 = 0.50
    elif side == "SELL" and dist_to_sup_atr < 1.0:
        L2 = min(L2, 1.5)
        partial_pct_L2 = 0.50

    return {
        "L1": L1, "L2": L2, "L3": L3, "trail": trail,
        "pct2": partial_pct_L2, "pct3": partial_pct_L3,
    }

def _trend_exit(bid, ask, state, indicators, atr, market_ctx=None, runtime_cfg=None):
    """Trend exit: wide breathing room, let profits run, protect against catastrophic reversals.

    Philosophy: In a trend, the biggest mistake is exiting too early. Give the position
    room to survive pullbacks. Only intervene when the trend shows clear signs of exhaustion
    (MACD decay, peak decline > 50% from max profit).
    """
    # ── Get GARCH vol_ratio for adaptive threshold scaling ──
    vol_scale = _get_vol_scale()

    runtime_cfg = runtime_cfg or _load_runtime_exit_params()
    side = state.get("position_side","BUY")
    entry = state.get("position_entry",0)
    vol = state.get("position_vol",0.01)
    cur_sl = state.get("position_sl",0)
    adx = float(indicators.get("adx",25) or 25)
    macd_hist = float(indicators.get("macd_hist",0) or 0)
    macd_hist_prev = float(indicators.get("macd_hist_prev",macd_hist) or 0)
    profit_pts = (bid-entry) if side=="BUY" else (entry-ask)
    profit_atr = profit_pts / max(0.01, atr)
    peak_data = _load_peak_file()
    ticket = str(state.get("position_ticket",""))
    peak_key = ticket + "_" + side
    peak_price = peak_data.get(peak_key, entry if side=="BUY" else entry)
    if side=="BUY" and bid>peak_price: peak_price = bid
    elif side=="SELL" and ask<peak_price: peak_price = ask
    peak_data[peak_key] = peak_price
    actions = []; reasons = []

    # Apply vol_scale to ATR thresholds (high vol → wider, low vol → tighter)
    L1_atr = round(max(1.2, runtime_cfg["break_even_trigger_atr"]) * vol_scale, 2)  # TREND min 1.2
    L2_atr = round(runtime_cfg["trend_trail_min_profit_atr"] * vol_scale, 2)
    L3_atr = round(max(runtime_cfg["trend_partial1_atr"], runtime_cfg["trend_partial2_atr"]) * vol_scale, 2)
    peak_decline_atr = round(2.5 * vol_scale, 2)

    # ── Wyckoff phase adaptation ──
    phase = getattr(market_ctx, 'phase', 'CHOP') if market_ctx else 'CHOP'
    phase_mult = 1.0
    if phase == 'MARKUP':
        phase_mult = 1.2   # Let profits run in markup — trend is healthy
    elif phase == 'ACCUMULATION':
        phase_mult = 1.1   # Slightly wider — building base, give room
    elif phase == 'DISTRIBUTION':
        phase_mult = 0.8   # Tighten — distribution often precedes reversal
        peak_decline_atr = round(1.8 * vol_scale, 2)  # Exit faster on decline
    elif phase == 'MARKDOWN':
        phase_mult = 0.7   # Tightest — downtrend
        peak_decline_atr = round(1.5 * vol_scale, 2)
    if phase != 'CHOP':
        L1_atr = round(L1_atr * phase_mult, 2)
        L2_atr = round(L2_atr * phase_mult, 2)
        L3_atr = round(L3_atr * phase_mult, 2)

    # ── L1: Breakeven @ scaled ATR — preserve capital once trend is confirmed ──
    if runtime_cfg["break_even_enabled"] and profit_atr >= L1_atr and not state.get("l1_breakeven_done"):
        buf = max(abs(ask-bid)*1.5, runtime_cfg["break_even_lock_atr"]*atr)
        if side=="BUY":
            sl=_round_price(entry+buf)
            if cur_sl<sl: actions.append({"action":"MODIFY_SL","new_sl":sl,"reason":"TREND_L1"}); reasons.append("L1保本@"+str(_round_price(sl)))
        else:
            sl=_round_price(entry-buf)
            if cur_sl>sl: actions.append({"action":"MODIFY_SL","new_sl":sl,"reason":"TREND_L1"}); reasons.append("L1保本@"+str(_round_price(sl)))
    # ── L2: Wide trail @ scaled ATR — follow the trend with generous breathing room ──
    if runtime_cfg["trend_trail_enabled"] and runtime_cfg["runner_trail_enabled"] and profit_atr >= L2_atr:
        # ADX-scaled: stronger trend = wider trail (max 3.5 ATR, min 2.0 ATR)
        base_trail = runtime_cfg["trend_trail_base_atr"] * vol_scale
        trail_dist = max(
            runtime_cfg["trend_trail_min_atr"] * vol_scale,
            min(runtime_cfg["trend_trail_max_atr"], base_trail * max(20, adx) / 25.0),
        )
        if side=="BUY": sl=_round_price(max(entry+0.5*atr*vol_scale, peak_price-trail_dist*atr))
        else: sl=_round_price(min(entry-0.5*atr*vol_scale, peak_price+trail_dist*atr))
        trail_armed = False
        if (side=="BUY" and sl>cur_sl) or (side=="SELL" and sl<cur_sl):
            actions.append({"action":"MODIFY_SL","new_sl":sl,"reason":"TREND_L2"}); reasons.append("L2追踪@"+str(_round_price(sl)))
            trail_armed = True
        cur_tp = state.get("position_tp", 0)
        if trail_armed and cur_tp and cur_tp > 0:
            actions.append({"action":"MODIFY_TP","new_tp":0,"reason":"TREND_L2取消TP"})
            reasons.append("移除了止盈")

    # ── L3: Partial close 25% @ scaled ATR — bank some profit, let the rest run ──
    if runtime_cfg["trend_partial_enabled"] and profit_atr >= L3_atr and not state.get("l3_partial_closed"):
        decay = (side=="BUY" and macd_hist_prev>0 and macd_hist<macd_hist_prev*0.5)
        decay |= (side=="SELL" and macd_hist_prev<0 and macd_hist>macd_hist_prev*0.5)
        if decay:
            pv=round(vol*runtime_cfg["trend_partial1_pct"],2)
            if pv>=0.01: actions.append({"action":"CLOSE_PARTIAL","volume":pv,"reason":"TREND_L3"}); reasons.append(f"L3止盈{int(runtime_cfg['trend_partial1_pct']*100)}%")

    # ── Peak decline exit: if price retraces > 50% from peak profit, exit ──
    peak_profit = (peak_price-entry) if side=="BUY" else (entry-peak_price)
    if peak_profit > peak_decline_atr*atr and profit_atr < 0.5*peak_profit/max(0.01,atr):
        actions.append({"action":"CLOSE","volume":vol,"reason":"TREND_PEAK_DECLINE"})

    _save_peak_file(peak_data)
    if not actions: return False,"",None
    return True," | ".join(reasons),{"actions":actions}


def _weak_trend_exit(bid, ask, state, indicators, atr, market_ctx=None, runtime_cfg=None):
    """Weak-trend exit: tighter than trend, wider than range.

    Philosophy: WEAK_TREND is the most frequently misclassified regime. Give it
    less room than a strong trend so false trends don't erode profits, but keep
    some trailing structure so genuine minor trends can still run.
    """
    vol_scale = _get_vol_scale()
    runtime_cfg = runtime_cfg or _load_runtime_exit_params()
    side = state.get("position_side", "BUY")
    entry = state.get("position_entry", 0)
    vol = state.get("position_vol", 0.01)
    cur_sl = state.get("position_sl", 0)
    adx = float(indicators.get("adx", 25) or 25)
    macd_hist = float(indicators.get("macd_hist", 0) or 0)
    macd_hist_prev = float(indicators.get("macd_hist_prev", macd_hist) or macd_hist)
    profit_pts = (bid - entry) if side == "BUY" else (entry - ask)
    profit_atr = profit_pts / max(0.01, atr)
    peak_data = _load_peak_file()
    ticket = str(state.get("position_ticket", ""))
    peak_key = ticket + "_" + side
    peak_price = peak_data.get(peak_key, entry if side == "BUY" else entry)
    if side == "BUY" and bid > peak_price:
        peak_price = bid
    elif side == "SELL" and ask < peak_price:
        peak_price = ask
    peak_data[peak_key] = peak_price
    actions = []
    reasons = []

    # Tighter fixed thresholds ( overrides the wider runtime defaults used by TREND )
    L1_atr = round(0.8 * vol_scale, 2)
    L2_atr = round(1.4 * vol_scale, 2)
    L3_atr = round(2.2 * vol_scale, 2)
    peak_decline_atr = round(1.5 * vol_scale, 2)

    # Phase adaptation: tighten further in distribution/markdown
    phase = getattr(market_ctx, 'phase', 'CHOP') if market_ctx else 'CHOP'
    phase_mult = 1.0
    if phase == 'MARKUP':
        phase_mult = 1.1
    elif phase == 'ACCUMULATION':
        phase_mult = 1.0
    elif phase == 'DISTRIBUTION':
        phase_mult = 0.85
        peak_decline_atr = round(1.2 * vol_scale, 2)
    elif phase == 'MARKDOWN':
        phase_mult = 0.8
        peak_decline_atr = round(1.0 * vol_scale, 2)
    if phase != 'CHOP':
        L1_atr = round(L1_atr * phase_mult, 2)
        L2_atr = round(L2_atr * phase_mult, 2)
        L3_atr = round(L3_atr * phase_mult, 2)

    # L1: Breakeven
    if runtime_cfg["break_even_enabled"] and profit_atr >= L1_atr and not state.get("l1_breakeven_done"):
        buf = max(abs(ask - bid) * 1.5, runtime_cfg["break_even_lock_atr"] * atr)
        if side == "BUY":
            sl = _round_price(entry + buf)
            if cur_sl < sl:
                actions.append({"action": "MODIFY_SL", "new_sl": sl, "reason": "WEAK_TREND_L1"})
                reasons.append("L1保本@" + str(_round_price(sl)))
        else:
            sl = _round_price(entry - buf)
            if cur_sl > sl:
                actions.append({"action": "MODIFY_SL", "new_sl": sl, "reason": "WEAK_TREND_L1"})
                reasons.append("L1保本@" + str(_round_price(sl)))

    # L2: Trail with tighter base distance (1.5 ATR vs 2.2 ATR in TREND)
    if runtime_cfg["trend_trail_enabled"] and runtime_cfg["runner_trail_enabled"] and profit_atr >= L2_atr:
        base_trail = 1.5 * vol_scale
        trail_dist = max(
            runtime_cfg["trend_trail_min_atr"] * vol_scale,
            min(runtime_cfg["trend_trail_max_atr"], base_trail * max(20, adx) / 25.0),
        )
        if side == "BUY":
            sl = _round_price(max(entry + 0.4 * atr * vol_scale, peak_price - trail_dist * atr))
        else:
            sl = _round_price(min(entry - 0.4 * atr * vol_scale, peak_price + trail_dist * atr))
        trail_armed = False
        if (side == "BUY" and sl > cur_sl) or (side == "SELL" and sl < cur_sl):
            actions.append({"action": "MODIFY_SL", "new_sl": sl, "reason": "WEAK_TREND_L2"})
            reasons.append("L2追踪@" + str(_round_price(sl)))
            trail_armed = True
        cur_tp = state.get("position_tp", 0)
        if trail_armed and cur_tp and cur_tp > 0:
            actions.append({"action": "MODIFY_TP", "new_tp": 0, "reason": "WEAK_TREND_L2取消TP"})
            reasons.append("移除了止盈")

    # L3: Partial close on MACD decay
    if runtime_cfg["trend_partial_enabled"] and profit_atr >= L3_atr and not state.get("l3_partial_closed"):
        decay = (side == "BUY" and macd_hist_prev > 0 and macd_hist < macd_hist_prev * 0.5)
        decay |= (side == "SELL" and macd_hist_prev < 0 and macd_hist > macd_hist_prev * 0.5)
        if decay:
            pv = round(vol * runtime_cfg["trend_partial1_pct"], 2)
            if pv >= 0.01:
                actions.append({"action": "CLOSE_PARTIAL", "volume": pv, "reason": "WEAK_TREND_L3"})
                reasons.append(f"L3止盈{int(runtime_cfg['trend_partial1_pct']*100)}%")

    # Peak decline: tighter than TREND (1.5 ATR trigger)
    peak_profit = (peak_price - entry) if side == "BUY" else (entry - peak_price)
    if peak_profit > peak_decline_atr * atr and profit_atr < 0.5 * peak_profit / max(0.01, atr):
        actions.append({"action": "CLOSE", "volume": vol, "reason": "WEAK_TREND_PEAK_DECLINE"})

    _save_peak_file(peak_data)
    if not actions:
        return False, "", None
    return True, " | ".join(reasons), {"actions": actions}


def _range_exit(bid, ask, state, indicators, atr, market_ctx=None, runtime_cfg=None):
    """Range exit: tight stops, quick profit-taking, prevent round-trips.

    Philosophy: In a range, profits are fleeting. Lock in quickly at resistance/support.
    Don't let a +2.0 ATR winner become a -0.5 ATR loser because you "waited for the trend."
    """
    # ── Get GARCH vol_ratio for adaptive threshold scaling ──
    vol_scale = _get_vol_scale()

    runtime_cfg = runtime_cfg or _load_runtime_exit_params()
    side = state.get("position_side","BUY")
    entry = state.get("position_entry",0)
    vol = state.get("position_vol",0.01)
    cur_sl = state.get("position_sl",0)
    rsi = float(indicators.get("rsi", 50) or 50)
    profit_pts = (bid-entry) if side=="BUY" else (entry-ask)
    profit_atr = profit_pts / max(0.01, atr)
    hold_sec = time.time() - state.get("position_time",time.time())
    actions = []; reasons = []

    # Apply vol_scale to ATR thresholds
    L1_atr = round(max(1.2, runtime_cfg["break_even_trigger_atr"]) * vol_scale, 2)  # TREND min 1.2
    L2_atr = round(runtime_cfg["range_partial1_atr"] * vol_scale, 2)
    L3_atr = round(runtime_cfg["range_partial2_atr"] * vol_scale, 2)
    trail_atr = round(runtime_cfg["range_trail_min_profit_atr"] * vol_scale, 2)

    # ── Wyckoff phase adaptation (range-specific: ranges are fragile, react faster) ──
    phase = getattr(market_ctx, 'phase', 'CHOP') if market_ctx else 'CHOP'
    phase_mult = 1.0
    if phase == 'MARKUP':
        phase_mult = 0.9   # Range may be breaking out → lock sooner
    elif phase == 'DISTRIBUTION':
        phase_mult = 0.7   # Distribution in range → tight defense
    elif phase == 'MARKDOWN':
        phase_mult = 0.7   # Downtrend pressure
    elif phase == 'ACCUMULATION':
        phase_mult = 1.0   # Normal range behavior — no adjustment
    if phase != 'CHOP':
        L1_atr = round(L1_atr * phase_mult, 2)
        L2_atr = round(L2_atr * phase_mult, 2)
        L3_atr = round(L3_atr * phase_mult, 2)
        trail_atr = round(trail_atr * phase_mult, 2)

    # ── L1: Breakeven @ scaled ATR — protect early in ranges ──
    if runtime_cfg["break_even_enabled"] and profit_atr >= L1_atr and not state.get("l1_breakeven_done"):
        buf = max(abs(ask-bid)*1.5, runtime_cfg["break_even_lock_atr"]*atr)
        if side=="BUY":
            sl=_round_price(entry+buf)
            if cur_sl<sl: actions.append({"action":"MODIFY_SL","new_sl":sl,"reason":"RANGE_L1"}); reasons.append("L1保本@"+str(_round_price(sl)))
        else:
            sl=_round_price(entry-buf)
            if cur_sl>sl: actions.append({"action":"MODIFY_SL","new_sl":sl,"reason":"RANGE_L1"}); reasons.append("L1保本@"+str(_round_price(sl)))

    # ── L2: Partial close 20% @ scaled ATR — light trim, let profit run ──
    if profit_atr >= L2_atr and not state.get("l2_partial_closed"):
        pv=round(vol*runtime_cfg["range_partial1_pct"],2)
        if pv>=0.01: actions.append({"action":"CLOSE_PARTIAL","volume":pv,"reason":"RANGE_L2"}); reasons.append("L2止盈20%")

    # ── L3: Second partial 20% @ scaled ATR — further light trim ──
    if profit_atr >= L3_atr and not state.get("l3_partial_closed"):
        pv=round(vol*runtime_cfg["range_partial2_pct"],2)
        if pv>=0.01: actions.append({"action":"CLOSE_PARTIAL","volume":pv,"reason":"RANGE_L3"}); reasons.append("L3止盈20%")

    # ── Tight trail @ scaled ATR — don't let profits evaporate in ranges ──
    if runtime_cfg["range_trail_enabled"] and profit_atr >= trail_atr:
        trail_dist = runtime_cfg["range_trail_distance_atr"] * vol_scale
        if side=="BUY": sl=_round_price(max(cur_sl, bid-trail_dist*atr))
        else: sl=_round_price(min(cur_sl, ask+trail_dist*atr))
        if (side=="BUY" and sl>cur_sl) or (side=="SELL" and sl<cur_sl):
            actions.append({"action":"MODIFY_SL","new_sl":sl,"reason":"RANGE_TRAIL"}); reasons.append("追踪@"+str(_round_price(sl)))

    # ── RSI extreme: overbought/oversold → PARTIAL close 50%, not full ──
    if (side=="BUY" and rsi >= runtime_cfg["rsi_long_exit_range"] and profit_atr >= runtime_cfg["rsi_profit_required_range"] * vol_scale) or \
       (side=="SELL" and rsi <= runtime_cfg["rsi_short_exit_range"] and profit_atr >= runtime_cfg["rsi_profit_required_range"] * vol_scale):
        pv = round(vol * 0.50, 2)
        if pv >= 0.01:
            actions.append({"action":"CLOSE_PARTIAL","volume":pv,"reason":"RANGE_RSI_EXTREME"})
            reasons.append("RSI极值止盈50%")

    # ── Time exit: 4h with no progress → capital is better used elsewhere ──
    if runtime_cfg["time_exit_range_enabled"] and hold_sec >= runtime_cfg["time_exit_range_max_hold_secs"] and profit_atr < 1.0:
        actions.append({"action":"CLOSE","volume":vol,"reason":"RANGE_TIME_EXIT"})

    if not actions: return False,"",None
    return True," | ".join(reasons),{"actions":actions}

def _reversion_exit(bid, ask, state, indicators, atr, market_ctx=None):
    # Reversion exit: multi-factor exit for mean reversion strategies.
    # Exit when RSI/SMI returns to neutral, time expires, or price hits max target.
    vol_scale = _get_vol_scale()
    side = state.get("position_side","BUY")
    entry = state.get("position_entry",0)
    vol = state.get("position_vol",0.01)
    cur_sl = state.get("position_sl",0)
    profit_pts = (bid-entry) if side=="BUY" else (entry-ask)
    profit_atr = profit_pts / max(0.01, atr)
    hold_sec = time.time() - state.get("position_time", time.time())
    rsi = float(indicators.get("rsi", 50) or 50)
    smi = float(indicators.get("smi", 50) or 50)
    actions = []; reasons = []

    # 1. Hard protect: breakeven after 1.2 ATR profit
    breakeven_atr = round(1.2 * vol_scale, 2)
    if profit_atr >= breakeven_atr and not state.get("l1_breakeven_done"):
        buf = max(abs(ask-bid)*1.5, 0.3*atr)
        new_sl = _round_price(entry+buf) if side=="BUY" else _round_price(entry-buf)
        if (side=="BUY" and new_sl > cur_sl) or (side=="SELL" and new_sl < cur_sl):
            actions.append({"action":"MODIFY_SL","new_sl":new_sl,"reason":"REV_L1"})
            reasons.append(f"L1保本@{new_sl}")

    # 2. RSI/SMI mean reversion: exit when indicator returns to neutral zone
    neutral_low, neutral_high = 40, 60
    if side == "BUY" and rsi >= neutral_high and smi >= neutral_high and profit_atr > 0.3:
        actions.append({"action":"CLOSE","volume":vol,"reason":"REV_RSI_NEUTRAL"})
        reasons.append(f"RSI{rsi:.0f}/SMI{smi:.0f}回到中枢")
    elif side == "SELL" and rsi <= neutral_low and smi <= neutral_low and profit_atr > 0.3:
        actions.append({"action":"CLOSE","volume":vol,"reason":"REV_RSI_NEUTRAL"})
        reasons.append(f"RSI{rsi:.0f}/SMI{smi:.0f}回到中枢")

    # 3. Time exit: 6h with no progress
    max_hold = 6 * 3600
    if hold_sec >= max_hold and profit_atr < 1.0 and not actions:
        actions.append({"action":"CLOSE","volume":vol,"reason":"REV_TIME_EXIT"})
        reasons.append(f"持仓{hold_sec/3600:.1f}h超时")

    # 4. Profit target: capped at 3.0 ATR (reversions rarely exceed this)
    max_target_atr = round(3.0 * vol_scale, 2)
    if profit_atr >= max_target_atr:
        actions.append({"action":"CLOSE","volume":vol,"reason":"REV_MAX_TARGET"})
        reasons.append(f"最大目标@{profit_atr:.1f}ATR")

    if not actions:
        return False,"",None
    return True," | ".join(reasons),{"actions":actions}

def _breakout_exit(bid, ask, state, indicators, atr, market_ctx=None, runtime_cfg=None):
    # Breakout exit: like trend but tighter (GARCH-scaled)
    vol_scale = _get_vol_scale()
    runtime_cfg = runtime_cfg or _load_runtime_exit_params()
    side = state.get("position_side","BUY")
    entry = state.get("position_entry",0)
    cur_sl = state.get("position_sl",0)
    adx = float(indicators.get("adx",25) or 25)
    profit_pts = (bid-entry) if side=="BUY" else (entry-ask)
    profit_atr = profit_pts / max(0.01, atr)
    peak_data = _load_peak_file()
    ticket = str(state.get("position_ticket",""))
    peak_key = ticket + "_" + side
    peak_price = peak_data.get(peak_key, entry if side=="BUY" else entry)
    if side=="BUY" and bid>peak_price: peak_price = bid
    elif side=="SELL" and ask<peak_price: peak_price = ask
    peak_data[peak_key] = peak_price
    actions = []; reasons = []
    l1_profit_atr = round(runtime_cfg["break_even_trigger_atr"] * vol_scale, 2)
    l2_profit_atr = round(runtime_cfg["trend_trail_min_profit_atr"] * vol_scale, 2)
    if runtime_cfg["break_even_enabled"] and profit_atr >= l1_profit_atr and not state.get("l1_breakeven_done"):
        buf = max(abs(ask-bid)*1.5, runtime_cfg["break_even_lock_atr"]*atr)
        if side=="BUY":
            sl=_round_price(entry+buf)
            if cur_sl<sl: actions.append({"action":"MODIFY_SL","new_sl":sl,"reason":"BREAKOUT_L1"}); reasons.append("L1@"+str(_round_price(sl)))
        else:
            sl=_round_price(entry-buf)
            if cur_sl>sl: actions.append({"action":"MODIFY_SL","new_sl":sl,"reason":"BREAKOUT_L1"}); reasons.append("L1@"+str(_round_price(sl)))
    if runtime_cfg["trend_trail_enabled"] and runtime_cfg["runner_trail_enabled"] and profit_atr >= l2_profit_atr:
        trail_dist = max(
            runtime_cfg["trend_trail_min_atr"],
            min(runtime_cfg["trend_trail_max_atr"], runtime_cfg["trend_trail_base_atr"] * max(15,adx)/25.0),
        )
        if side=="BUY": sl=_round_price(peak_price-trail_dist*atr)
        else: sl=_round_price(peak_price+trail_dist*atr)
        if (side=="BUY" and sl>cur_sl) or (side=="SELL" and sl<cur_sl):
            actions.append({"action":"MODIFY_SL","new_sl":sl,"reason":"BREAKOUT_L2"}); reasons.append("L2@"+str(_round_price(sl)))
    _save_peak_file(peak_data)
    if not actions: return False,"",None
    return True," | ".join(reasons),{"actions":actions}

def execute_exit_actions(actions: list, state: dict, bid: float, ask: float):
    """
    执行平仓动作列表。
    返回: (success_count, total_actions)
    """
    from pathlib import Path; import os; _appdata = Path(os.environ.get("APPDATA","")); _mt5_dirs = sorted(_appdata.glob("MetaQuotes/Terminal/*/MQL5/Files"), key=lambda p: p.stat().st_mtime if p.exists() else 0, reverse=True); _common_dir = _appdata / "MetaQuotes" / "Terminal" / "Common" / "Files"; _bridge_base = _common_dir if _common_dir.exists() else (_mt5_dirs[0] if _mt5_dirs else _appdata / "GoldAutoTrade" / "data" / "bridge"); BRIDGE_TO_EA = _bridge_base / "gold_autotrade" / "bridge" / "to_ea"

    sig_id = f"exit_{int(time.time())}"
    executed = 0

    if not actions:
        return 0, 0

    normalized_actions = []
    idx = 0
    while idx < len(actions):
        act = actions[idx]
        next_act = actions[idx + 1] if idx + 1 < len(actions) else None
        if (
            isinstance(act, dict)
            and act.get("action") == "MODIFY_SL"
            and isinstance(next_act, dict)
            and next_act.get("action") == "MODIFY_TP"
            and "new_tp" in next_act
            and abs(float(next_act.get("new_tp", 0) or 0)) <= 1e-9
        ):
            merged = dict(act)
            merged["_defer_tp_clear_until_sl_confirmed"] = True
            merged["_deferred_tp_value"] = 0.0
            merged["_deferred_tp_reason"] = str(next_act.get("reason", "") or "")
            normalized_actions.append(merged)
            idx += 2
            continue
        if (
            isinstance(act, dict)
            and act.get("action") == "MODIFY_TP"
            and "new_tp" in act
            and abs(float(act.get("new_tp", 0) or 0)) <= 1e-9
            and isinstance(next_act, dict)
            and next_act.get("action") == "MODIFY_SL"
        ):
            merged = dict(next_act)
            merged["_defer_tp_clear_until_sl_confirmed"] = True
            merged["_deferred_tp_value"] = 0.0
            merged["_deferred_tp_reason"] = str(act.get("reason", "") or "")
            normalized_actions.append(merged)
            idx += 2
            continue
        normalized_actions.append(act)
        idx += 1
    actions = normalized_actions

    # 从 actions 自身的 reason 字段构建日志（reason_parts/atr 在 execute_exit_actions 作用域中不可访问）
    action_reasons = [a.get("reason", a["action"]) for a in actions]
    _log(f"[EXIT] {' | '.join(action_reasons)}", "WARN")

    executed = 0
    for act in actions:
        act_type = act["action"]
        sig_id = f"exit_{int(time.time())}_{act_type}"
        ticket = state.get("position_ticket", 0)
        vol = state.get("position_vol", 0.01)

        # ── MODIFY_TP / MODIFY_SL：EA 期望的精简格式（action 在顶层） ──
        if act_type in ("MODIFY_TP", "MODIFY_SL"):
            new_tp = act.get("new_tp", 0)
            new_sl = act.get("new_sl", 0)

            if not ticket:
                _log(f"{act_type} skipped: no ticket in state", "WARN")
                continue
            if act_type == "MODIFY_TP" and "new_tp" not in act:
                continue
            if act_type == "MODIFY_SL" and not new_sl:
                continue

            # EA 读取方式：string action = _JsonStr(body, "action"); action 必须在顶层
            req = {
                "signal_id": sig_id,
                "action": act_type,
                "symbol": "XAUUSD",
                "side": state.get("position_side", "BUY"),
                "ticket": int(ticket),
                "volume": vol,
            }
            if act_type == "MODIFY_TP":
                req["tp_price"] = float(new_tp)
            else:
                req["sl_price"] = float(new_sl)
                if "new_tp" in act:
                    req["tp_price"] = float(new_tp)

        # ── CLOSE_PARTIAL / 其他：标准信号格式 ──
        else:
            req = {
                "signal_id": sig_id,
                "action": act_type,
                "symbol": "XAUUSD",
                "side": state.get("position_side", "BUY"),
                "ticket": int(ticket),
                "volume": float(act.get("volume", vol)),
                "sl_price": float(act.get("sl_price", 0) or 0),
                "tp_price": float(act.get("tp_price", 0) or 0),
                "signal": {
                    "side": state.get("position_side", "BUY"),
                    "symbol": "XAUUSD",
                    "entry_price": 0,
                    "sl_price": 0,
                    "tp_price": 0,
                    "reason": act.get("reason", ""),
                    "confidence": 0.95,
                    "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
                }
            }

        try:
            BRIDGE_TO_EA.mkdir(parents=True, exist_ok=True)
            req_file = BRIDGE_TO_EA / f"{sig_id}.request.json"
            _write_request_atomic(req_file, req)
            modify_telemetry = state.get("_exit_modify_telemetry", {}) if isinstance(state.get("_exit_modify_telemetry", {}), dict) else {}
            if act_type == "MODIFY_SL":
                modify_telemetry["pending_sl_request_ts"] = int(time.time())
                modify_telemetry["pending_sl_value"] = float(new_sl)
                modify_telemetry["pending_sl_signal_id"] = sig_id
                modify_telemetry["pending_sl_reason"] = str(act.get("reason", "") or "")
                if act.get("_defer_tp_clear_until_sl_confirmed") and float(state.get("position_tp", 0) or 0) > 0:
                    modify_telemetry["pending_tp_after_sl_confirmed"] = float(act.get("_deferred_tp_value", 0.0) or 0.0)
                    modify_telemetry["pending_tp_after_sl_reason"] = str(act.get("_deferred_tp_reason", "") or act.get("reason", ""))
                    modify_telemetry["pending_tp_after_sl_ticket"] = int(ticket or 0)
                state["_exit_modify_telemetry"] = modify_telemetry
            elif act_type == "MODIFY_TP":
                modify_telemetry["pending_tp_request_ts"] = int(time.time())
                modify_telemetry["pending_tp_value"] = float(new_tp)
                modify_telemetry["pending_tp_signal_id"] = sig_id
                modify_telemetry["pending_tp_reason"] = str(act.get("reason", "") or "")
                state["_exit_modify_telemetry"] = modify_telemetry
            _log(f"EXIT ACTION: {act_type} | {act.get('reason','')}")
            executed += 1
        except Exception as e:
            _log(f"EXIT ACTION failed: {e}", "ERROR")

    return executed, len(actions)
