#!/usr/bin/env python3
"""
Gold Trader - 生产级 24/7 自动交易主程序（混合模式 + Agent 大方向决策）
规则层执行常态，Agent 层决策大方向。

===== ⚠️ 暂态架构 (TEMPORARY ARCHITECTURE) — 2026-05-14 =====
当前处于架构过渡期，系统存在两个独立进程同时运行：
  pipeline.py   → 唯一交易决策者 (Commander + Governor + Execution Orchestrator)
                   写入: execution_state.json, trading_ledger.ndjson, runtime snapshot
  gold_trader.py → 桥接适配层 + EA 报告归集 + 持仓退出生命周期 (--monitor-only)
                   写入: agent_state.json, bridge/to_ea/*.json, bridge/from_mt5/*.json

【已转移给 pipeline.py 的职责（gold_trader 不再有权做）】
  - 独立开仓判断、独立风控结论、主交易信号生成
  - 覆盖 pipeline 的交易裁决

【gold_trader.py 保留的职责】
  - 写 bridge request 文件、读 EA report 文件
  - 从 MT5 回执修正桥接状态 (reconcile EA reports)
  - 兼容旧 watchdog / CLI / 运维命令
  - 持仓退出生命周期监控 (monitor_position_loop)

【已知风险 — 不修复，等架构迁移解决】
  1. CLOSE 语义漂移: gold_trader 的 _seal_close_guard / _finalize_position_closure /
     _record_learning_closure 仍独立演化，最终会与 pipeline 持仓/状态视图漂移
  2. 双写竞争: pipeline 写 execution_state, gold_trader 写 agent_state.json；
     两个文件各自维护 position_open / consecutive_losses，可能短期不一致

【迁移路径】
  Phase 1 (当前): pipeline 为唯一决策者，gold_trader 以 --monitor-only 运行 ✅
  Phase 2 (规划中): 退出编排迁移到 pipeline，extract gold_trader 的 close guard /
                    finalize / reconcile 逻辑到 src/exit_orchestrator.py
  Phase 3 (目标): gold_trader 缩减为纯桥接层 (bridge adapter only)
"""
import argparse, json, math, os, re, sys, time, signal, atexit, pathlib, subprocess, sqlite3, uuid, datetime, threading, fcntl, shlex
from typing import Any, Optional

# ─── 逼宫机制状态 ───
# 用于记录底层模型连续发起高置信度反向信号的次数和冷却时间
_override_counter = 0
_last_override_signal_time = 0
_last_agent_wakeup_time = 0
_startup_banner_emitted = False
AGENT_WAKEUP_COOLDOWN = 600  # 逼宫唤醒冷却时间（10分钟），保证 M2 失效时能更快重判
_last_macro_poll_time = 0
HOLD_MACRO_POLL_MIN_STREAK = 2
HOLD_MACRO_POLL_INTERVAL_SEC = 240
_state_machine = None
from collections import deque
import queue as _queue
_mutiny_evidence_queue: deque = deque(maxlen=5)

# ============ OpenClaw 任务队列（限流 + 僵尸回收） ============
# 统一调度：线程不再各自 subprocess.run，改为写队列，由单一 worker 串行消费
# 限流：最多同时跑 MAX_CONCURRENT_OPENCLAW 个 openclaw 实例
_OPENCLAW_TASK_QUEUE: _queue.Queue = _queue.Queue()
_OPENCLAW_RUNNING: list = []   # 当前在跑的 subprocess.Popen 对象
_OPENCLAW_LOCK: threading.Lock = threading.Lock()
MAX_CONCURRENT_OPENCLAW = 2
_MAX_ZOMBIE_REAP_INTERVAL = 10  # 每 N 秒扫一次僵尸进程

# ============ 全局终止标志（信号处理） ============
KILL_FLAG = threading.Event()

def _sigterm_handler(signum, frame):
    KILL_FLAG.set()
    sys.exit(0)
# 信号处理器注册移至 __main__ --fg 分支，避免作为库被导入时注册

# ============ 路径配置 ============
# 使用 .resolve() 正确解析符号链接，确保无论通过什么路径运行，ROOT 都指向实际文件所在目录
ROOT      = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
import config_manager
from bridge.direction_registry import save_agent_direction as _registry_save_agent_direction
from bridge.direction_registry import load_macro_direction_record as _registry_load_macro_direction_record
from bridge.direction_registry import get_agent_direction as _registry_get_agent_direction
from strategy_runtime import get_strategy_runtime
from src.agent_decision_contract import load_active_interpreter_payload, load_macro_contract_payload, update_macro_contract
from src.position_lifecycle import (
    normalize_position_attribution,
    position_state_fields_from_attribution,
    reconcile_position_lifecycle,
    sync_close_fill,
    sync_close_request,
    sync_open_fill,
    sync_open_request,
)
from src.runtime_trade_guard import (
    build_profit_sense_cooldown_profile as _shared_build_profit_sense_cooldown_profile,
    cooldown_remaining_sec as _shared_cooldown_remaining_sec,
    same_side_reentry_veto as _shared_same_side_reentry_veto,
)

# 使用 config_manager 动态获取路径
APP_DIR = config_manager.get_app_dir()
DATA_DIR = config_manager.get_data_dir()
LOGS_DIR = config_manager.get_logs_dir()
EXECUTION_STATE_FILE = DATA_DIR / "execution_state.json"

# MT5 通信目录
MT5_FILES = config_manager.get_mt5_dir()

# 初始化 SQLite 状态库
DB_PATH = DATA_DIR / "bridge" / "state" / "agent_state.db"
DB_PATH.parent.mkdir(parents=True, exist_ok=True)


def _open_state_db(*, timeout: float = 10.0, check_same_thread: bool = False) -> sqlite3.Connection:
    # Runtime app dir can drift between dev, launchd, and packaged runs. Recreate
    # the parent on every open so reconciliation does not die on a missing path.
    DB_PATH.parent.mkdir(parents=True, exist_ok=True)
    conn = sqlite3.connect(str(DB_PATH), timeout=timeout, check_same_thread=check_same_thread)
    conn.execute("PRAGMA journal_mode=WAL")
    # 强制同步落盘，防止断电或崩溃时 position_open=False 丢失
    conn.execute("PRAGMA synchronous=FULL")
    conn.execute(f"PRAGMA busy_timeout={int(timeout * 1000)}")
    return conn


def init_db():
    # 使用 check_same_thread=False 解决多进程 fork 时的问题
    conn = _open_state_db(check_same_thread=False)
    c = conn.cursor()
    c.execute("""
    CREATE TABLE IF NOT EXISTS state_kv (
        key TEXT PRIMARY KEY,
        value TEXT,
        updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    )
    """)
    c.execute("""
    CREATE TABLE IF NOT EXISTS active_positions (
        ticket INTEGER PRIMARY KEY,
        side TEXT,
        volume REAL,
        entry_price REAL,
        sl REAL,
        tp REAL,
        agent_id TEXT,
        opened_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    )
    """)
    conn.commit()
    conn.close()

# 不要在模块加载时直接初始化，改到主循环里，防止 fork 崩溃
# init_db()

if not MT5_FILES:
    MT5_FILES = config_manager.get_mt5_dir()

BRIDGE_TO_EA   = MT5_FILES / "gold_autotrade/bridge/to_ea"
BRIDGE_FROM_EA = MT5_FILES / "gold_autotrade/bridge/from_ea"

ARCHIVE_DIR    = DATA_DIR / "bridge/archive"
LOG_DIR        = LOGS_DIR
STATE_FILE     = DATA_DIR / "bridge/state/agent_state.json"
PID_FILE       = LOGS_DIR / "gold_trader.pid"
DECISION_LINK_FILE = LOGS_DIR / "decision_signal_links.ndjson"
LOCK_FILE      = LOGS_DIR / "gold_trader.lock"
# Repo daemon and launchagent runtime must share one machine-wide singleton
# lock, otherwise legacy watchdogs can create a second execution brain.
GLOBAL_RUNTIME_DIR = pathlib.Path.home() / "Library" / "Application Support" / "LobsterQuant" / "runtime_locks"
GLOBAL_RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
GLOBAL_PID_FILE = GLOBAL_RUNTIME_DIR / "gold_trader.pid"
GLOBAL_LOCK_FILE = GLOBAL_RUNTIME_DIR / "gold_trader.lock"
DAEMON_LOG     = pathlib.Path.home() / "Library" / "Logs" / "gold_autotrade" / "gold_trader.daemon.log"
DAEMON_LOG.parent.mkdir(parents=True, exist_ok=True)  # 本地目录，避免 iCloud 阻塞
REVIEW_DIR     = LOGS_DIR / "review_pending"
AGENT_DIR      = LOGS_DIR / "agent_decisions"   # Agent 方向决策存储
TRAILING_TELEMETRY_FILE = LOGS_DIR / "trailing_telemetry_latest.json"

REVIEW_DIR.mkdir(parents=True, exist_ok=True)
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)

INTERVAL_NO_POS  = 120
INTERVAL_HAS_POS = 10
MIN_CONFIDENCE   = 0.68  # fallback only; runtime uses _get_effective_param("min_confidence_threshold", ...)
# SINGLE_OWNER_RISK_ARBITER unified arbitration mode:
#   When True (default), Governor operates in observe-only mode—risk decisions
#   flow through the single pipeline (risk_manager.py → GoldRiskManager.evaluate).
#   Legacy veto paths (governor, tech, grid mutex, dynamic cooldown) are disabled.
#   Set to False to re-enable legacy veto paths for A/B comparison during incident response.
SINGLE_OWNER_RISK_ARBITER = True

# 全局冷却期配置（秒）
COOLDOWN_AFTER_CLOSE = 15 * 60  # 平仓后强制冷却15分钟 (900秒)
_last_close_time = 0  # 记录上一次平仓的时间戳

# 执行层已知晓信号生成器不产出 volume_lots，此标志仅用于减少重复日志
_volume_lots_warning_logged = False
_last_macro_notice_key = ""
_last_macro_notice_time = 0.0
_last_veto_lifecycle_key = ""
_last_veto_lifecycle_time = 0.0
_last_micro_status_report_time = 0.0
MONITOR_ONLY = False
_PIPELINE_EA_LIVE_SOURCE = None
PIPELINE_SINGLE_TRACK_AUTOMATION = True
_PIPELINE_BLOCKED_REQUEST_ORIGINS = {
    "legacy_signal_poll",
    "soft_limit_trigger",
    "review_approved",
    "legacy_position_monitor",
    "legacy_macro_escape",
    # legacy_reject_fallback 不拦截：它是 EA 拒绝后的紧急平仓 fallback，
    # 只发 CLOSE/CLOSE_TICKET 不发 OPEN，拦截会造成仓位既平不掉又反复
    # 触发 PARTIAL_CLOSE → 死循环直到 daemon 崩溃。
    "legacy_post_fill_repair",
}

# ============ 日志 ============
def _log(msg, level="INFO"):
    now = time.strftime("%H:%M:%S")
    line = f"[{now}] [{level}] {msg}\n"
    try:
        with open(LOG_DIR / f"gold_trader.{time.strftime('%Y%m%d')}.log", "a") as f:
            f.write(line)
    except Exception:
        pass

def _daemon_log(msg):
    now = time.strftime("%H:%M:%S")
    try:
        with open(DAEMON_LOG, "a") as f:
            f.write(f"[{now}] [DAEMON] {msg}\n")
    except Exception:
        pass


def _pipeline_single_track_enabled() -> bool:
    return bool(PIPELINE_SINGLE_TRACK_AUTOMATION)


def _report_owned_by_pipeline(report: Optional[dict]) -> bool:
    payload = report if isinstance(report, dict) else {}
    if str(payload.get("writer_owner", "") or "").lower() == "pipeline":
        return True
    if str(payload.get("request_origin", "") or "").lower() == "pipeline_dispatch":
        return True
    attribution = payload.get("attribution", {}) if isinstance(payload.get("attribution", {}), dict) else {}
    if str(attribution.get("writer_owner", "") or "").lower() == "pipeline":
        return True
    return False


def _position_owned_by_pipeline(state: dict) -> bool:
    attribution = state.get("position_attribution", {}) if isinstance(state.get("position_attribution", {}), dict) else {}
    if str(attribution.get("writer_owner", "") or "").lower() == "pipeline":
        return True
    risk_decision = attribution.get("risk_decision", {}) if isinstance(attribution.get("risk_decision", {}), dict) else {}
    execution_owner = risk_decision.get("execution_owner", {}) if isinstance(risk_decision.get("execution_owner", {}), dict) else {}
    return str(execution_owner.get("owner_mode", "") or "").lower() == "pipeline_single_track"


def _block_legacy_trade_request(
    action: str,
    request_origin: str,
    *,
    decision_id: str = "",
    side: str = "",
) -> bool:
    action_upper = str(action or "").upper()
    origin = str(request_origin or "bridge_legacy").strip().lower()
    if not _pipeline_single_track_enabled():
        return False
    if action_upper not in {"OPEN", "CLOSE", "REDUCE", "MODIFY_SL", "MODIFY_TP", "CLOSE_ALL", "CLOSE_TICKET"}:
        return False
    if origin not in _PIPELINE_BLOCKED_REQUEST_ORIGINS:
        return False
    reason = f"PIPELINE_SINGLE_TRACK_BLOCK:{origin}:{action_upper}"
    _daemon_log(f"[PIPELINE_OWNER] blocked legacy trade request origin={origin} action={action_upper}")
    if decision_id:
        try:
            _transition_decision_state(
                decision_id,
                "REJECTED",
                reason=reason,
                request_origin=origin,
                request_action=action_upper,
                request_side=str(side or "").upper(),
            )
        except Exception as exc:
            _daemon_log(f"[PIPELINE_OWNER] transition reject failed: {exc}")
    return True


def _build_runtime_trade_summary(state: dict, ledger_path: Optional[pathlib.Path] = None) -> dict:
    summary = {"trades": 0, "pnl": 0.0, "source": "state_kv"}
    target = pathlib.Path(ledger_path) if ledger_path is not None else (ROOT / "logs" / "trading_ledger.ndjson")
    if target.exists():
        trades = 0
        pnl_sum = 0.0
        try:
            with target.open("r", encoding="utf-8") as handle:
                for line in handle:
                    if not line.strip():
                        continue
                    try:
                        row = json.loads(line)
                    except Exception:
                        continue
                    action = str(row.get("action", "CLOSE") or "CLOSE").upper()
                    if action == "OPEN":
                        continue
                    pnl = float(row.get("pnl", 0.0) or 0.0)
                    trades += 1
                    pnl_sum += pnl
            if trades > 0 or abs(pnl_sum) > 0.005:
                return {"trades": trades, "pnl": round(pnl_sum, 2), "source": "trading_ledger"}
        except Exception:
            pass

    rows = state.get("recent_closures", []) if isinstance(state, dict) else []
    if isinstance(rows, list) and rows:
        trades = 0
        pnl_sum = 0.0
        for row in rows:
            if not isinstance(row, dict):
                continue
            try:
                pnl_sum += float(row.get("pnl", 0.0) or 0.0)
                trades += 1
            except Exception:
                continue
        return {"trades": trades, "pnl": round(pnl_sum, 2), "source": "recent_closures"}

    try:
        summary["trades"] = max(0, int((state or {}).get("total_trades", 0) or 0))
    except Exception:
        summary["trades"] = 0
    try:
        summary["pnl"] = round(float((state or {}).get("total_pnl", 0.0) or 0.0), 2)
    except Exception:
        summary["pnl"] = 0.0
    return summary


_RUNTIME_BLACKBOARD = None
_RUNTIME_BLACKBOARD_PATH = APP_DIR / "blackboard_state.json"


def _get_runtime_blackboard():
    global _RUNTIME_BLACKBOARD
    if _RUNTIME_BLACKBOARD is None:
        from src.swarm.blackboard import Blackboard

        # Pin the runtime blackboard to APP_DIR so launchd, release copies, and
        # direct process restarts all converge on the same state file.
        _RUNTIME_BLACKBOARD = Blackboard(str(_RUNTIME_BLACKBOARD_PATH))
    return _RUNTIME_BLACKBOARD


def _runtime_blackboard_update(namespace: str, key: str, value):
    try:
        return _get_runtime_blackboard().update(namespace, key, value)
    except Exception as e:
        _daemon_log(f"[BLACKBOARD_SYNC] update {namespace}.{key} failed: {e}")
        return False


def _runtime_blackboard_log(role: str, msg_type: str, text: str):
    try:
        _get_runtime_blackboard().add_log(role, msg_type, text)
        return True
    except Exception as e:
        _daemon_log(f"[BLACKBOARD_SYNC] add_log {role}.{msg_type} failed: {e}")
        return False


def _refresh_micro_sniper_hold_state(state: dict):
    if not isinstance(state, dict) or not state.get("position_open"):
        return

    trend_regimes = {"TRENDING", "BREAKOUT", "SHOCK_REPRICING", "EXTREME_TREND", "DRIFTING"}
    market = {}
    micro = {}
    try:
        snapshot = _get_runtime_blackboard().get_snapshot(include_logs=0)
        if isinstance(snapshot, dict):
            market = snapshot.get("Market_Interpreter", {}) if isinstance(snapshot.get("Market_Interpreter", {}), dict) else {}
            micro = snapshot.get("Micro_Sniper", {}) if isinstance(snapshot.get("Micro_Sniper", {}), dict) else {}
    except Exception as e:
        _daemon_log(f"[BLACKBOARD_SYNC] hold snapshot read failed: {e}")

    pos_interp = state.get("position_interpreter", {}) if isinstance(state.get("position_interpreter", {}), dict) else {}
    session_metrics = state.get("position_session_metrics", {}) if isinstance(state.get("position_session_metrics", {}), dict) else {}
    regime = str(
        market.get("market_structure")
        or state.get("position_regime")
        or micro.get("current_regime")
        or "UNKNOWN"
    ).upper()
    counter_trend_risk = str(
        market.get("counter_trend_risk")
        or pos_interp.get("counter_trend_risk")
        or "LOW"
    ).upper()
    execution_advice = str(
        market.get("execution_advice")
        or pos_interp.get("execution_advice")
        or "UNKNOWN"
    ).upper()
    session_bias = str(
        market.get("session_execution_bias")
        or pos_interp.get("session_execution_bias")
        or session_metrics.get("session_mode")
        or ""
    ).upper()

    defense_mode = str(micro.get("defense_mode", "MARKET") or "MARKET").upper()
    try:
        defense_score = float(micro.get("defense_score", 0.0) or 0.0)
    except Exception:
        defense_score = 0.0

    strong_follow_release = (
        regime in trend_regimes
        and counter_trend_risk == "LOW"
        and execution_advice == "ALLOW_FOLLOW"
        and session_bias in {"HIGH_LIQUIDITY", "NY_VOLATILE", "LONDON_TREND", "LONDON_VOLATILE", "LONDON_NY_OVERLAP", "OVERLAP_TREND"}
    )

    if strong_follow_release:
        defense_mode = "MARKET"
        defense_score = min(defense_score if defense_score > 0 else 0.22, 0.22)
        is_defensive_env = False
        knife_catching_risk = False
    else:
        if execution_advice == "LIMIT_ONLY" or counter_trend_risk == "HIGH":
            if defense_mode not in {"TIGHT_LIMIT", "LIMIT", "WIDE_LIMIT"}:
                defense_mode = "LIMIT" if counter_trend_risk == "HIGH" else "TIGHT_LIMIT"
            defense_score = max(defense_score, 0.58 if counter_trend_risk == "HIGH" else 0.42)
        is_defensive_env = defense_mode in {"TIGHT_LIMIT", "LIMIT", "WIDE_LIMIT"}
        knife_catching_risk = is_defensive_env and counter_trend_risk == "HIGH"

    _runtime_blackboard_update("Micro_Sniper", "current_regime", regime)
    _runtime_blackboard_update("Micro_Sniper", "is_defensive_env", bool(is_defensive_env))
    _runtime_blackboard_update("Micro_Sniper", "knife_catching_risk", bool(knife_catching_risk))
    _runtime_blackboard_update("Micro_Sniper", "defense_mode", defense_mode)
    _runtime_blackboard_update("Micro_Sniper", "defense_score", round(defense_score, 4))
    _runtime_blackboard_update("Micro_Sniper", "recent_stop_losses", int(state.get("consecutive_losses", 0) or 0))


def _read_runtime_drawdown_snapshot() -> tuple[float, str]:
    candidates: list[tuple[str, float]] = []

    try:
        blackboard_file = APP_DIR / "blackboard_state.json"
        if blackboard_file.exists():
            payload = json.loads(blackboard_file.read_text(encoding="utf-8"))
            risk_commissar = payload.get("Risk_Commissar", {}) if isinstance(payload, dict) else {}
            current_drawdown = abs(float(risk_commissar.get("current_drawdown_pct", 0.0) or 0.0))
            if current_drawdown > 0:
                candidates.append(("BLACKBOARD_CURRENT_DRAWDOWN", current_drawdown))
    except Exception:
        pass

    try:
        system_state_file = APP_DIR / "tenants" / "default" / "ops" / "system_state.json"
        if system_state_file.exists():
            payload = json.loads(system_state_file.read_text(encoding="utf-8"))
            sections = payload.get("sections", {}) if isinstance(payload, dict) else {}
            goals = sections.get("goals", {}) if isinstance(sections, dict) else {}
            gates = goals.get("gates", {}) if isinstance(goals, dict) else {}
            dd_gate = gates.get("max_drawdown_pct", {}) if isinstance(gates, dict) else {}
            current_drawdown = abs(float(dd_gate.get("current", 0.0) or 0.0))
            if current_drawdown > 0:
                candidates.append(("OPS_MAX_DRAWDOWN_GATE", current_drawdown))
    except Exception:
        pass

    if not candidates:
        return 0.0, "NONE"

    source, value = max(candidates, key=lambda item: item[1])
    return round(float(value), 4), str(source)


def _get_total_loss_stop_snapshot(state: Optional[dict] = None) -> dict:
    state = state if isinstance(state, dict) else {}
    threshold_pct = max(0.0, float(_get_effective_param("account_total_loss_stop_pct", 30.0) or 0.0))
    candidates: list[tuple[str, float]] = []

    for key, source in (
        ("account_total_loss_current_pct", "STATE_ACCOUNT_TOTAL_LOSS"),
        ("intraday_drawdown_pct", "STATE_INTRADAY_DRAWDOWN"),
        ("daily_loss_pct", "STATE_DAILY_LOSS"),
    ):
        try:
            value = abs(float(state.get(key, 0.0) or 0.0))
            if value > 0:
                candidates.append((source, value))
        except Exception:
            pass

    runtime_drawdown_pct, runtime_source = _read_runtime_drawdown_snapshot()
    if runtime_drawdown_pct > 0:
        candidates.append((runtime_source, runtime_drawdown_pct))

    current_source = "NONE"
    current_pct = 0.0
    if candidates:
        current_source, current_pct = max(candidates, key=lambda item: item[1])

    active = threshold_pct > 0 and current_pct >= threshold_pct
    return {
        "threshold_pct": round(float(threshold_pct), 4),
        "current_pct": round(float(current_pct), 4),
        "current_source": str(current_source),
        "active": bool(active),
    }


def _get_trading_state_machine():
    global _state_machine
    if _state_machine is None:
        try:
            from src.state_machine import LedgerStateCallback, TradingStateMachine
            _state_machine = TradingStateMachine(ROOT, state_file=EXECUTION_STATE_FILE)
            _state_machine.register_ledger_callback(LedgerStateCallback(ROOT))
        except Exception as e:
            _daemon_log(f"state machine init failed: {e}")
            _state_machine = False
    return _state_machine or None


def _transition_decision_state(decision_id, to_state, reason=None, **metadata):
    if not decision_id:
        return
    sm = _get_trading_state_machine()
    if sm is None:
        return
    try:
        sm.transition(to_state, trace_id=str(decision_id), reason=reason, **metadata)
    except Exception as e:
        err_text = str(e)
        if to_state == "EXECUTING" and "不能从 EXECUTING 跳转到 EXECUTING" in err_text:
            return
        _daemon_log(f"state transition failed: decision={decision_id} to={to_state} err={e}")


def _close_decision_state(decision_id, to_state, reason=None, **metadata):
    if not decision_id:
        return
    _transition_decision_state(decision_id, to_state, reason=reason, **metadata)
    _transition_decision_state(decision_id, "IDLE", reason="COMPLETE", **metadata)


def _emit_veto_lifecycle(category, reason, cooldown_sec=180, **metadata):
    global _last_veto_lifecycle_key, _last_veto_lifecycle_time
    veto_key = f"{category}:{reason}"
    now_ts = time.time()
    if veto_key == _last_veto_lifecycle_key and (now_ts - _last_veto_lifecycle_time) < cooldown_sec:
        return
    _last_veto_lifecycle_key = veto_key
    _last_veto_lifecycle_time = now_ts
    trace_id = f"{category.lower()}_{int(now_ts)}"
    _close_decision_state(trace_id, "VETOED", reason=reason, category=category, **metadata)


def _emit_macro_notice(tag, reason, cooldown_sec=180):
    global _last_macro_notice_key, _last_macro_notice_time
    notice_key = f"{tag}:{reason}"
    now = time.time()
    if notice_key == _last_macro_notice_key and (now - _last_macro_notice_time) < cooldown_sec:
        return
    _last_macro_notice_key = notice_key
    _last_macro_notice_time = now
    _log(f"{tag}: {reason}", "WARN")

# ============ 参数建议系统（从 params_advisor.py 读取）============
_PARAMS_CACHE = None
_PARAMS_CACHE_TTL = 300  # 5分钟有效

def _load_params_suggestion(force=False):
    """读取并缓存参数建议，自动应用 HIGH Priority 项"""
    global _PARAMS_CACHE
    now = time.time()
    if not force and _PARAMS_CACHE and (now - _PARAMS_CACHE["_loaded_at"]) < _PARAMS_CACHE_TTL:
        return _PARAMS_CACHE

    try:
        from src.param_flow import load_runtime_param_payload
        data = load_runtime_param_payload()
    except Exception:
        return {}
    if not data:
        return {}

    # 标记加载时间
    data["_loaded_at"] = now
    data["_loaded_from_file"] = str(data.get("_source_file", ""))

    # Governor 熔断器负责实时拦截，参数调整需人工审批（不再自动应用任何参数）

    _PARAMS_CACHE = data
    return data

from config_manager import get_effective_param as _get_effective_param

# ============ 交易记忆系统 ============
def _log_open(state, open_ts=None):
    """开仓时记录到记忆系统（trading_ledger.ndjson）"""
    try:
        sys.path.insert(0, str(ROOT))
        from skill_trading_memory import append_trade
        from datetime import datetime, timezone
        attribution = _build_trade_attribution(state=state)
        regime = attribution.get("regime", state.get("position_regime", "UNKNOWN"))
        session_name = (attribution.get("session", {}) or {}).get("session_name", "UNKNOWN")
        if open_ts is not None:
            ts_str = datetime.fromtimestamp(open_ts, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        else:
            ts_str = datetime.now(timezone.utc).isoformat()
        append_trade(
            ticket=state.get("position_ticket", 0),
            side=state.get("position_side", "BUY"),
            volume=state.get("position_vol", 0.01),
            entry=state.get("position_entry", 0),
            exit_price=0,
            pnl=0,
            duration_min=0,
            session=session_name,
            regime=regime,
            confidence=float(state.get("position_confidence", 0.70) or 0.70),
            strategy=attribution.get("strategy_id", state.get("position_strategy_id", "AUTO")),
            decision_id=attribution.get("decision_id", state.get("position_decision_id", "")),
            signal_id=attribution.get("signal_id", state.get("position_signal_id", "")),
            trace_id=attribution.get("trace_id", state.get("position_trace_id", "")),
            strategy_id=attribution.get("strategy_id", state.get("position_strategy_id", "AUTO")),
            session_metrics=attribution.get("session", {}),
            interpreter=attribution.get("interpreter", {}),
            sl_hit=False,
            tp_hit=False,
            ts=ts_str,
            action="OPEN",
        )
    except Exception as e:
        _daemon_log(f"_log_open failed: {e}")


def _build_profit_sense_snapshot(state, exit_price=0.0):
    try:
        tele = state.get("trailing_telemetry", {}) if isinstance(state.get("trailing_telemetry", {}), dict) else {}
        side = str(state.get("position_side", "") or "").upper()
        entry = float(state.get("position_entry", 0.0) or 0.0)
        peak_profit_atr = float(tele.get("peak_profit_atr", 0.0) or 0.0)
        capture_ratio = float(tele.get("profit_capture_ratio", 0.0) or 0.0)
        if entry > 0 and exit_price > 0 and peak_profit_atr > 0 and capture_ratio > 0:
            favorable_points = (float(exit_price) - entry) if side == "BUY" else (entry - float(exit_price))
            favorable_points = max(0.0, favorable_points)
            atr_est = favorable_points / max(capture_ratio * peak_profit_atr, 1e-6)
            close_profit_atr = favorable_points / max(atr_est, 1e-6) if atr_est > 0 else (capture_ratio * peak_profit_atr)
        else:
            close_profit_atr = max(0.0, capture_ratio * peak_profit_atr)
        close_capture_ratio = (
            max(0.0, min(1.2, close_profit_atr / max(peak_profit_atr, 1e-6)))
            if peak_profit_atr > 0
            else 0.0
        )
        return {
            "trail_phase": str(tele.get("phase", "NONE") or "NONE"),
            "opportunity_state": str(tele.get("opportunity_state", "UNKNOWN") or "UNKNOWN"),
            "peak_profit_atr": round(peak_profit_atr, 4),
            "close_profit_atr": round(float(close_profit_atr or 0.0), 4),
            "profit_capture_ratio": round(float(close_capture_ratio), 4),
            "profit_giveback_atr": round(max(0.0, peak_profit_atr - close_profit_atr), 4),
            "expected_wave_atr": round(float(tele.get("expected_wave_atr", 0.0) or 0.0), 4),
            "wave_completion": round(float(tele.get("wave_completion", 0.0) or 0.0), 4),
            "peak_completion": round(float(tele.get("peak_completion", 0.0) or 0.0), 4),
            "bars_since_peak": int(tele.get("bars_since_peak", 0) or 0),
            "momentum_decay_score": round(float(tele.get("momentum_decay_score", 0.0) or 0.0), 4),
        }
    except Exception:
        return {
            "trail_phase": "NONE",
            "opportunity_state": "UNKNOWN",
            "peak_profit_atr": 0.0,
            "close_profit_atr": 0.0,
            "profit_capture_ratio": 0.0,
            "profit_giveback_atr": 0.0,
            "expected_wave_atr": 0.0,
            "wave_completion": 0.0,
            "peak_completion": 0.0,
            "bars_since_peak": 0,
            "momentum_decay_score": 0.0,
        }


def _build_profit_sense_cooldown_profile(state: dict) -> dict:
    return _shared_build_profit_sense_cooldown_profile(state)


def _seal_close_guard(
    state: dict,
    *,
    closed_side: str = "",
    closed_ticket: int = 0,
    close_action: str = "CLOSE",
    close_ts=None,
) -> None:
    """
    为所有平仓路径统一落盘“刚刚平仓”的护栏状态。
    这样即使 EA 回报顺序抖动，signal poll 也不会立刻重开同向单。
    """
    if not isinstance(state, dict):
        return
    ts_v = float(close_ts or time.time())
    side_u = str(closed_side or state.get("position_side", "") or "").upper()
    action_u = str(close_action or "CLOSE").upper()
    state["last_close_time"] = ts_v
    state["last_closed_ts"] = ts_v
    if side_u:
        state["last_closed_side"] = side_u
    if closed_ticket:
        state["last_closed_ticket"] = int(closed_ticket)
    state["last_close_action"] = action_u


def _same_side_reentry_veto(state: dict, side: str) -> tuple[int, str]:
    return _shared_same_side_reentry_veto(state, side)


def _log_trade(state, price_bid, price_ask, close_pnl=0, duration_min=0, close_ts=None):
    """交易完成（平仓）时记录到记忆系统
    
    Args:
        close_ts: EA真实平仓时间（Unix timestamp），如有则传入。
                   EA FILLED报告不含time字段，此时传None，用Python当前时间（事后由sync_mt5_history.py校正）。
    """
    try:
        sys.path.insert(0, str(ROOT))
        from skill_trading_memory import append_trade
        from datetime import datetime, timezone
        pos = state
        sl_hit = close_pnl < 0 and (pos.get("position_side") == "BUY" and price_bid <= pos.get("position_sl") or pos.get("position_side") == "SELL" and price_ask >= pos.get("position_sl"))
        tp_hit = close_pnl > 0 and (pos.get("position_side") == "BUY" and price_ask >= pos.get("position_tp") or pos.get("position_side") == "SELL" and price_bid <= pos.get("position_tp"))
        attribution = _build_trade_attribution(state=pos)
        regime = attribution.get("regime", pos.get("position_regime", "UNKNOWN"))
        session_name = (attribution.get("session", {}) or {}).get("session_name", "UNKNOWN")
        exit_price = (price_bid + price_ask) / 2
        profit_sense = _build_profit_sense_snapshot(pos, exit_price=exit_price)
        # 平仓时间：优先用EA真实时间，否则用Python当前时间（事后由sync_mt5_history.py校正）
        if close_ts is not None:
            ts_str = datetime.fromtimestamp(close_ts, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        else:
            ts_str = datetime.now(timezone.utc).isoformat()
        # 平仓记录
        append_trade(
            ticket=pos.get("position_ticket", 0),
            side=pos.get("position_side", "BUY"),
            volume=pos.get("position_vol", 0.01),
            entry=pos.get("position_entry", 0),
            exit_price=exit_price,
            pnl=close_pnl,
            duration_min=duration_min,
            session=session_name,
            regime=regime,
            confidence=float(pos.get("position_confidence", 0.70) or 0.70),
            strategy=attribution.get("strategy_id", pos.get("position_strategy_id", "AUTO")),
            decision_id=attribution.get("decision_id", pos.get("position_decision_id", "")),
            signal_id=attribution.get("signal_id", pos.get("position_signal_id", "")),
            trace_id=attribution.get("trace_id", pos.get("position_trace_id", "")),
            strategy_id=attribution.get("strategy_id", pos.get("position_strategy_id", "AUTO")),
            session_metrics=attribution.get("session", {}),
            interpreter=attribution.get("interpreter", {}),
            sl_hit=sl_hit,
            tp_hit=tp_hit,
            trail_phase=profit_sense.get("trail_phase", "NONE"),
            opportunity_state=profit_sense.get("opportunity_state", "UNKNOWN"),
            peak_profit_atr=profit_sense.get("peak_profit_atr", 0.0),
            close_profit_atr=profit_sense.get("close_profit_atr", 0.0),
            profit_capture_ratio=profit_sense.get("profit_capture_ratio", 0.0),
            profit_giveback_atr=profit_sense.get("profit_giveback_atr", 0.0),
            expected_wave_atr=profit_sense.get("expected_wave_atr", 0.0),
            wave_completion=profit_sense.get("wave_completion", 0.0),
            peak_completion=profit_sense.get("peak_completion", 0.0),
            bars_since_peak=profit_sense.get("bars_since_peak", 0),
            momentum_decay_score=profit_sense.get("momentum_decay_score", 0.0),
            ts=ts_str,
        )
    except Exception as e:
        _daemon_log(f"_log_trade failed: {e}")


def _update_shadow_system(position_open=None, total_trades=None, total_pnl=None, win_rate=None):
    """通过统一黑板 API 更新 Shadow_System，避免覆盖多策略命名空间。"""
    try:
        if position_open is not None:
            _runtime_blackboard_update("Shadow_System", "position_open", position_open)
        if total_trades is not None:
            _runtime_blackboard_update("Shadow_System", "total_trades", total_trades)
        if total_pnl is not None:
            _runtime_blackboard_update("Shadow_System", "total_pnl", total_pnl)
        if win_rate is not None:
            _runtime_blackboard_update("Shadow_System", "win_rate", win_rate)
    except Exception as e:
        _daemon_log(f"_update_shadow_system failed: {e}")


def _update_account_state(balance=None, equity=None):
    """通过统一黑板 API 写入账户状态，避免旧 schema 覆盖新黑板。"""
    try:
        if balance is not None:
            _runtime_blackboard_update("Account", "balance", balance)
        if equity is not None:
            _runtime_blackboard_update("Account", "equity", equity)
    except Exception as e:
        _daemon_log(f"_update_account_state failed: {e}")


def _log_risk_event(reason, price_bid, price_ask, spread=0):
    """风控拦截时记录"""
    try:
        sys.path.insert(0, str(ROOT))
        from skill_trading_memory import append_risk_event
        append_risk_event(
            event_type="GOVERNOR_BLOCK",
            reason=reason,
            spread=spread,
            threshold=70,
            action="HOLD",
            price_bid=price_bid,
        )
    except Exception as e:
        _daemon_log(f"_log_risk_event failed: {e}")

def _append_recent_closure(state, pnl, duration_min=0, exit_price=0.0, close_action="CLOSE"):
    """
    维护最近平仓样本，用于 RiskManager 细粒度亏损压力评估。
    """
    try:
        if not isinstance(state, dict):
            return
        pnl_v = float(pnl or 0.0)
        side = str(state.get("position_side", "") or "").upper()
        agent_dir = str(state.get("position_agent_direction", "") or "").upper()
        agent_aligned = (
            (agent_dir == "LONG" and side == "BUY")
            or (agent_dir == "SHORT" and side == "SELL")
        )
        session_metrics = state.get("position_session_metrics", {})
        if not isinstance(session_metrics, dict):
            session_metrics = {}
        interpreter = state.get("position_interpreter", {})
        if not isinstance(interpreter, dict):
            interpreter = {}
        row = {
            "ts": int(time.time()),
            "pnl": round(pnl_v, 6),
            "entry_confidence": float(state.get("position_confidence", 0.0) or 0.0),
            "duration_min": int(max(0, duration_min or 0)),
            "entry": float(state.get("position_entry", 0.0) or 0.0),
            "exit": float(exit_price or 0.0),
            "side": side,
            "agent_direction": agent_dir,
            "agent_aligned": bool(agent_aligned),
            "session_mode": str(session_metrics.get("session_mode", "") or ""),
            "selection_regime": str(state.get("position_regime", "UNKNOWN") or "UNKNOWN").upper(),
            "counter_trend_risk": str(interpreter.get("counter_trend_risk", "LOW") or "LOW").upper(),
            "close_action": str(close_action or "CLOSE"),
            "market_structure": str(interpreter.get("market_structure", "UNKNOWN") or "UNKNOWN").upper(),
            "confirmation_state": str(interpreter.get("confirmation_state", "NONE") or "NONE").upper(),
            "pullback_quality": str(interpreter.get("pullback_quality", "UNKNOWN") or "UNKNOWN").upper(),
            "execution_advice": str(interpreter.get("execution_advice", "UNKNOWN") or "UNKNOWN").upper(),
            "entry_style": str(interpreter.get("entry_style", state.get("position_entry_style", "UNKNOWN")) or "UNKNOWN").upper(),
            "strategy_id": str(state.get("position_entry_owner_strategy_id", state.get("position_strategy_id", "")) or ""),
            "instance_id": str(state.get("position_entry_owner_instance_id", "") or ""),
        }
        row.update(_build_profit_sense_snapshot(state, exit_price=exit_price))
        pattern_payload = None
        try:
            from src.learning.pattern_memory import record_closure_pattern, attach_runtime_adaptation

            pattern_payload = record_closure_pattern(
                state_before_close=state,
                pnl=pnl_v,
                exit_price=exit_price,
                close_action=str(close_action or "CLOSE"),
            )
            row["fingerprint_id"] = str(pattern_payload.get("fingerprint_id", "") or "")
            row["failure_mode"] = str(pattern_payload.get("failure_mode", "NONE") or "NONE")
            row["success_mode"] = str(pattern_payload.get("success_mode", "NONE") or "NONE")
            attach_runtime_adaptation(state, pattern_payload)
        except Exception as e:
            _daemon_log(f"pattern_memory record failed: {e}")
        rows = state.get("recent_closures", [])
        if not isinstance(rows, list):
            rows = []
        rows.append(row)
        state["recent_closures"] = rows[-40:]
    except Exception as e:
        _daemon_log(f"_append_recent_closure failed: {e}")


def _recent_closure_win_rate(state: dict) -> float:
    rows = state.get("recent_closures", []) if isinstance(state, dict) else []
    if not isinstance(rows, list) or not rows:
        return 0.0
    valid = []
    for row in rows:
        if not isinstance(row, dict):
            continue
        try:
            float(row.get("pnl", 0.0) or 0.0)
        except Exception:
            continue
        valid.append(row)
    if not valid:
        return 0.0
    wins = sum(1 for row in valid if float(row.get("pnl", 0.0) or 0.0) > 0)
    return wins / max(1, len(valid))


def _recalc_consecutive_losses_from_recent(state: dict, max_rows: int = 40) -> int:
    rows = state.get("recent_closures", []) if isinstance(state, dict) else []
    if not isinstance(rows, list):
        return 0
    streak = 0
    for row in reversed(rows[-max_rows:]):
        if not isinstance(row, dict):
            continue
        try:
            pnl_v = float(row.get("pnl", 0.0) or 0.0)
        except Exception:
            continue
        if pnl_v < 0:
            streak += 1
        else:
            break
    return streak


def _sync_consecutive_losses_from_recent(state: dict) -> int:
    """单一事实来源：从 recent_closures 计算连亏数并回写 state。

    consecutive_losses 的唯一计算入口。所有路径（平仓回写、自愈修正、
    对账恢复）都必须通过此函数同步，禁止各处自行拼装该字段。
    """
    losses = _recalc_consecutive_losses_from_recent(state)
    state["consecutive_losses"] = losses

    # 同步今日累计亏损占比（用于 blackboard / GUI / metrics）
    try:
        rows = state.get("recent_closures", []) if isinstance(state, dict) else []
        if isinstance(rows, list) and rows:
            today_start = int(time.time() // 86400 * 86400)
            today_pnl = sum(
                float(r.get("pnl", 0.0) or 0.0)
                for r in rows
                if isinstance(r, dict) and int(r.get("ts", 0) or 0) >= today_start
            )
            equity = float(state.get("balance", 0.0) or state.get("equity", 0.0) or 1.0)
            if today_pnl < 0 and equity > 0:
                state["daily_loss_pct"] = round(abs(today_pnl) / equity * 100.0, 4)
            else:
                state["daily_loss_pct"] = 0.0
    except Exception:
        state.setdefault("daily_loss_pct", 0.0)

    return losses


def _apply_realized_closure_stats(state: dict, realized_pnl: float, *, position_open: bool) -> None:
    if not isinstance(state, dict):
        return
    try:
        pnl_v = float(realized_pnl or 0.0)
    except Exception:
        pnl_v = 0.0
    _sync_consecutive_losses_from_recent(state)
    state["total_pnl"] = float(state.get("total_pnl", 0.0) or 0.0) + pnl_v
    state["win_rate"] = _recent_closure_win_rate(state)
    _update_shadow_system(
        position_open=position_open,
        total_trades=state.get("total_trades", 0),
        total_pnl=state.get("total_pnl", 0.0),
        win_rate=state.get("win_rate", 0.0),
    )


def _recent_win_rate_from_state(state: dict, max_rows: int = 20) -> tuple[int, float]:
    rows = state.get("recent_closures", []) if isinstance(state, dict) else []
    if not isinstance(rows, list):
        return 0, 0.0

    valid_rows = []
    for row in rows[-max_rows:]:
        if not isinstance(row, dict):
            continue
        try:
            float(row.get("pnl", 0.0) or 0.0)
        except Exception:
            continue
        valid_rows.append(row)

    if not valid_rows:
        return 0, 0.0

    wins = 0
    for row in valid_rows:
        try:
            if float(row.get("pnl", 0.0) or 0.0) > 0:
                wins += 1
        except Exception:
            continue
    return len(valid_rows), wins / max(1, len(valid_rows))


def _resolve_live_lot_rule_cap(state: dict) -> tuple[float, list[str]]:
    base_cap = float(_get_effective_param("max_position_size", 0.20) or 0.20)
    sim_stage_max = float(_get_effective_param("sim_stage_max", 0.10) or 0.10)
    loss_rate_threshold = float(_get_effective_param("loss_rate_threshold", 0.40) or 0.40)
    loss_rate_max_lots = float(_get_effective_param("loss_rate_max_lots", 0.05) or 0.05)
    consecutive_loss_halve = int(_get_effective_param("consecutive_loss_halve", 3) or 3)

    cap = base_cap
    reasons: list[str] = []
    total_trades = int((state or {}).get("total_trades", 0) or 0)
    if total_trades <= 0:
        cap = min(cap, sim_stage_max)
        reasons.append(f"SIM_STAGE<=0->{sim_stage_max:.2f}")

    sample_size, win_rate = _recent_win_rate_from_state(state, max_rows=20)
    if sample_size >= 5 and win_rate < loss_rate_threshold:
        cap = min(cap, loss_rate_max_lots)
        reasons.append(f"WIN_RATE<{loss_rate_threshold:.0%}({win_rate:.0%}/{sample_size})->{loss_rate_max_lots:.2f}")

    consecutive_losses = int((state or {}).get("consecutive_losses", 0) or 0)
    if consecutive_loss_halve > 0 and consecutive_losses >= consecutive_loss_halve:
        halved_cap = max(0.01, round(base_cap * 0.5, 2))
        cap = min(cap, halved_cap)
        reasons.append(f"CONSEC_LOSSES>={consecutive_loss_halve}->{halved_cap:.2f}")

    # 动态盈亏感知仓位缩放：复用 _build_profit_sense_cooldown_profile 的感知数据，
    # 将 cooldown_multiplier（反映连亏/回撤压力）平滑映射为仓位缩减系数。
    # 无硬阈值，连亏1单就开始渐进式缩仓，压力越大缩仓越多。
    try:
        profit_profile = _build_profit_sense_cooldown_profile(state)
        bias_label = str(profit_profile.get("bias_label", "NEUTRAL") or "NEUTRAL").upper()
        cd_mult = float(profit_profile.get("cooldown_multiplier", 1.0) or 1.0)
        if bias_label not in ("NEUTRAL", "HEALTHY_CAPTURE") or cd_mult > 1.05:
            dynamic_mult = max(0.20, 1.0 - (cd_mult - 1.0) * 2.0)
            reduced_cap = max(0.01, round(cap * dynamic_mult / 0.01) * 0.01)
            if reduced_cap < cap:
                reasons.append(f"PROFIT_SENSE({bias_label}): mult={dynamic_mult:.2f} cap={cap:.2f}->{reduced_cap:.2f}")
                cap = reduced_cap
    except Exception as e:
        _daemon_log(f"[PROFIT_SENSE_SIZE] failed: {e}")

    cap = max(0.01, round(cap / 0.01) * 0.01)
    return cap, reasons


def _self_heal_consecutive_losses(state: dict) -> bool:
    if not isinstance(state, dict):
        return False
    old_v = int(state.get("consecutive_losses", 0) or 0)
    new_v = _sync_consecutive_losses_from_recent(state)
    if old_v == new_v:
        return False
    state["win_rate"] = _recent_closure_win_rate(state)
    _runtime_blackboard_update("Micro_Sniper", "recent_stop_losses", new_v)
    _update_shadow_system(
        position_open=bool(state.get("position_open", False)),
        total_trades=state.get("total_trades", 0),
        total_pnl=state.get("total_pnl", 0.0),
        win_rate=state.get("win_rate", 0.0),
    )
    save_state(state)
    _daemon_log(
        f"[STATE_HEAL_CONSEC_LOSSES] corrected consecutive_losses: {old_v} -> {new_v} "
        f"(recent_closures={len(state.get('recent_closures', [])) if isinstance(state.get('recent_closures', []), list) else 0})"
    )
    return True


def _update_defense_metrics(state: dict, mode: str, reason_codes: list[str], score: float) -> bool:
    if not isinstance(state, dict):
        return False
    metrics = state.get("defense_metrics", {})
    if not isinstance(metrics, dict):
        metrics = {}
    mode_u = str(mode or "MARKET").upper()
    mode_counts = metrics.get("mode_counts", {})
    reason_counts = metrics.get("reason_counts", {})
    if not isinstance(mode_counts, dict):
        mode_counts = {}
    if not isinstance(reason_counts, dict):
        reason_counts = {}
    total = int(metrics.get("total", 0) or 0) + 1
    mode_counts[mode_u] = int(mode_counts.get(mode_u, 0) or 0) + 1
    for code in reason_codes[:6]:
        code_u = str(code or "UNKNOWN")
        reason_counts[code_u] = int(reason_counts.get(code_u, 0) or 0) + 1
    metrics.update(
        {
            "total": total,
            "mode_counts": mode_counts,
            "reason_counts": reason_counts,
            "last_mode": mode_u,
            "last_reason_codes": reason_codes[:6],
            "last_score": round(float(score or 0.0), 4),
            "updated_at": int(time.time()),
        }
    )
    state["defense_metrics"] = metrics
    # 周期性持久化，兼顾可观测和性能。
    return (total % 5 == 0) or (mode_u in {"LIMIT", "WIDE_LIMIT"})


def _count_delta(curr: dict, base: dict) -> dict:
    out = {}
    curr = curr if isinstance(curr, dict) else {}
    base = base if isinstance(base, dict) else {}
    keys = set(curr.keys()) | set(base.keys())
    for k in keys:
        out[k] = max(0, int(curr.get(k, 0) or 0) - int(base.get(k, 0) or 0))
    return out


def _persist_trailing_telemetry(telemetry: dict) -> None:
    if not isinstance(telemetry, dict) or not telemetry:
        return
    payload = dict(telemetry)
    payload.setdefault("updated_at", int(time.time()))
    try:
        TRAILING_TELEMETRY_FILE.write_text(
            json.dumps(payload, ensure_ascii=False, indent=2),
            encoding="utf-8",
        )
    except Exception as e:
        _daemon_log(f"[TRAIL_TELEMETRY] persist failed: {e}")


def _load_latest_trailing_telemetry() -> dict:
    try:
        if not TRAILING_TELEMETRY_FILE.exists():
            return {}
        payload = json.loads(TRAILING_TELEMETRY_FILE.read_text(encoding="utf-8"))
        return payload if isinstance(payload, dict) else {}
    except Exception:
        return {}


def _emit_defense_matrix_daily_report(state: dict, report_date=None) -> bool:
    if not isinstance(state, dict):
        return False
    metrics = state.get("defense_metrics", {})
    if not isinstance(metrics, dict) or int(metrics.get("total", 0) or 0) <= 0:
        return False
    day_str = report_date or datetime.datetime.now().strftime("%Y-%m-%d")
    if str(state.get("defense_report_last_date", "")) == day_str:
        return False

    baseline = state.get("defense_report_baseline", {})
    if not isinstance(baseline, dict):
        baseline = {}
    curr_total = int(metrics.get("total", 0) or 0)
    base_total = int(baseline.get("total", 0) or 0)
    mode_counts = metrics.get("mode_counts", {}) if isinstance(metrics.get("mode_counts", {}), dict) else {}
    reason_counts = metrics.get("reason_counts", {}) if isinstance(metrics.get("reason_counts", {}), dict) else {}
    mode_delta = _count_delta(mode_counts, baseline.get("mode_counts", {}))
    reason_delta = _count_delta(reason_counts, baseline.get("reason_counts", {}))
    total_delta = max(0, curr_total - base_total)

    top_reasons = sorted(reason_delta.items(), key=lambda x: x[1], reverse=True)[:4]
    trailing = state.get("trailing_telemetry", {})
    trailing_snapshot = {}
    if isinstance(trailing, dict) and trailing:
        trailing_snapshot = {
            "phase": str(trailing.get("phase", "NONE") or "NONE"),
            "trail_basis": float(trailing.get("trail_basis", 0.0) or 0.0),
            "trail_dist_atr": float(trailing.get("trail_dist_atr", 0.0) or 0.0),
            "blocked_count": int(trailing.get("sl_move_step_blocked_count", 0) or 0),
            "opportunity_state": str(trailing.get("opportunity_state", "UNKNOWN") or "UNKNOWN"),
            "capture_ratio": float(trailing.get("profit_capture_ratio", 0.0) or 0.0),
            "wave_completion": float(trailing.get("wave_completion", 0.0) or 0.0),
            "giveback_atr": float(trailing.get("profit_giveback_atr", 0.0) or 0.0),
            "updated_at": int(trailing.get("updated_at", 0) or 0),
        }
    payload = {
        "date": day_str,
        "generated_at": datetime.datetime.now().isoformat(timespec="seconds"),
        "total_delta": total_delta,
        "mode_delta": mode_delta,
        "reason_delta_top4": top_reasons,
        "last_mode": str(metrics.get("last_mode", "UNKNOWN") or "UNKNOWN"),
        "last_score": float(metrics.get("last_score", 0.0) or 0.0),
        "trailing_snapshot": trailing_snapshot,
    }

    report_file = LOGS_DIR / "defense_matrix_daily.json"
    history = []
    try:
        if report_file.exists():
            old = json.loads(report_file.read_text(encoding="utf-8"))
            if isinstance(old, list):
                history = [x for x in old if isinstance(x, dict) and str(x.get("date", "")) != day_str]
    except Exception:
        history = []
    history.append(payload)
    history = history[-45:]
    try:
        report_file.write_text(json.dumps(history, ensure_ascii=False, indent=2), encoding="utf-8")
    except Exception as e:
        _daemon_log(f"[DEFENSE_DAILY] write failed: {e}")

    try:
        m = mode_delta
        reason_txt = ", ".join([f"{k}:{v}" for k, v in top_reasons]) if top_reasons else "no_reason_delta"
        trail_txt = (
            f" trail={trailing_snapshot.get('phase', 'NONE')}"
            f"/basis={float(trailing_snapshot.get('trail_basis', 0.0) or 0.0):.2f}"
            f"/dist={float(trailing_snapshot.get('trail_dist_atr', 0.0) or 0.0):.2f}"
            f"/blocked={int(trailing_snapshot.get('blocked_count', 0) or 0)}"
            if trailing_snapshot
            else " trail=none"
        )
        msg = (
            f"[Defense日报 {day_str}] total={total_delta} "
            f"market={int(m.get('MARKET', 0))} tight={int(m.get('TIGHT_LIMIT', 0))} "
            f"limit={int(m.get('LIMIT', 0))} wide={int(m.get('WIDE_LIMIT', 0))} "
            f"top_reasons={reason_txt}{trail_txt}"
        )
        _runtime_blackboard_log("Risk_Commissar", "REPORT", msg)
    except Exception as e:
        _daemon_log(f"[DEFENSE_DAILY] webchat push failed: {e}")

    state["defense_report_last_date"] = day_str
    state["defense_report_baseline"] = {
        "total": curr_total,
        "mode_counts": mode_counts,
        "reason_counts": reason_counts,
    }
    save_state(state)
    _daemon_log(f"[DEFENSE_DAILY] published for {day_str}: total_delta={total_delta}")
    return True


def _record_learning_observation(decision_id, state, bid, ask, spread, price_source, price_age, agent_direction, signal):
    try:
        from src.learning.decision_recorder import record_observation, record_proposal
        now_ts = time.time()
        indicators = signal.get("indicators", {}) or {}
        attribution = _build_trade_attribution(signal=signal, state=state, agent_direction=agent_direction)
        structure_metrics = _extract_structure_metrics(attribution.get("interpreter", {}))
        payload = {
            "market": {
                "bid": bid,
                "ask": ask,
                "spread": spread,
                "price_source": price_source,
                "price_age": price_age,
                "regime": signal.get("regime", "UNKNOWN"),
                "indicators": indicators,
                "session": attribution.get("session", {}),
                "interpreter": attribution.get("interpreter", {}),
                "structure": structure_metrics,
            },
            "position": {
                "position_open": state.get("position_open", False),
                "position_side": state.get("position_side"),
                "position_entry": state.get("position_entry", 0),
                "position_vol": state.get("position_vol", 0),
                "position_ticket": state.get("position_ticket", 0),
                "time_since_close": max(0, now_ts - float(state.get("last_close_time", 0.0) or 0.0)),
            },
            "risk": {
                "daily_loss_pct": state.get("daily_loss_pct", 0.0),
                "intraday_drawdown_pct": state.get("intraday_drawdown_pct", 0.0),
                "consecutive_losses": state.get("consecutive_losses", 0),
                "kill_switch": state.get("kill_switch", False),
            },
            "macro": {
                "agent_direction": agent_direction,
            },
            "structure": structure_metrics,
            "attribution": attribution,
        }
        proposal = {
            "action": signal.get("action", "HOLD"),
            "side": signal.get("side"),
            "confidence": signal.get("confidence", 0),
            "entry": signal.get("entry", 0),
            "sl": signal.get("sl", 0),
            "tp": signal.get("tp", 0),
            "volume_lots": signal.get("volume_lots", 0),
            "reason": signal.get("reason", ""),
            "regime": signal.get("regime", "UNKNOWN"),
            "strategy_id": signal.get("_sig_strategy_id"),
            "signal_trace_id": signal.get("_sig_trace_id"),
            "selection_regime": signal.get("_selection_regime"),
            "selected_strategy": signal.get("_selected_strategy"),
            "session": attribution.get("session", {}),
            "interpreter": attribution.get("interpreter", {}),
            "structure": structure_metrics,
            "attribution": attribution,
        }
        record_observation(decision_id, "bridge.gold_trader", payload)
        record_proposal(decision_id, "bridge.gold_trader", proposal)
    except Exception as e:
        _daemon_log(f"_record_learning_observation failed: {e}")


def _extract_session_metrics(session_info):
    if not isinstance(session_info, dict):
        return {}
    analysis = session_info.get("analysis", {}) if isinstance(session_info.get("analysis", {}), dict) else {}
    current = analysis.get("current_analysis", {}) if isinstance(analysis.get("current_analysis", {}), dict) else {}
    session_profile = session_info.get("session_profile", {}) if isinstance(session_info.get("session_profile", {}), dict) else {}
    metrics = {
        "session_name": session_info.get("session_name", analysis.get("current_session", "UNKNOWN")),
        "session_label": session_info.get("session_label", analysis.get("current_session_name", "未知时段")),
        "session_mode": session_info.get("session_mode", session_profile.get("mode", "NORMAL")),
        "recommendation": session_info.get("recommendation", "CAUTIOUS"),
        "reason": session_info.get("reason", ""),
        "direction": current.get("direction"),
        "ema_bias": current.get("ema_bias"),
        "score": current.get("score"),
        "adx": current.get("adx"),
        "atr_pct": current.get("atr_pct"),
        "rsi": current.get("rsi"),
        "trend_strength": current.get("trend_strength"),
        "entry_style": session_info.get("entry_style", session_profile.get("entry_style", "MARKET")),
        "risk_multiplier": session_info.get("risk_multiplier", session_profile.get("risk_multiplier", 1.0)),
    }
    return {k: v for k, v in metrics.items() if v is not None}


def _format_session_metrics(session_metrics):
    if not isinstance(session_metrics, dict) or not session_metrics:
        return ""
    parts = []
    for key in ("session_label", "session_mode", "direction", "ema_bias", "score", "adx", "atr_pct", "rsi", "trend_strength"):
        value = session_metrics.get(key)
        if value in (None, ""):
            continue
        parts.append(f"{key}={value}")
    return f" [{' | '.join(parts)}]" if parts else ""


def _build_interpreter_context(override_data=None, agent_multiplier=1.0, reason="", applied=False):
    if not isinstance(override_data, dict) and not reason and agent_multiplier == 1.0:
        return {}
    override_data = override_data if isinstance(override_data, dict) else {}
    payload = {
        "view": override_data.get("view"),
        "buy_multiplier": override_data.get("buy_multiplier"),
        "sell_multiplier": override_data.get("sell_multiplier"),
        "applied_multiplier": agent_multiplier,
        "reason": reason or override_data.get("reason", ""),
        "valid_until": override_data.get("valid_until"),
        "source": override_data.get("source", "market_interpreter"),
        "applied": applied,
        "market_structure": override_data.get("market_structure"),
        "dominant_direction": override_data.get("dominant_direction"),
        "shock_direction": override_data.get("shock_direction"),
        "pullback_phase": override_data.get("pullback_phase"),
        "pullback_depth_bucket": override_data.get("pullback_depth_bucket"),
        "pullback_quality": override_data.get("pullback_quality"),
        "confirmation_state": override_data.get("confirmation_state"),
        "retrace_ratio": override_data.get("retrace_ratio"),
        "session_name": override_data.get("session_name"),
        "session_execution_bias": override_data.get("session_execution_bias"),
        "structure_signature": override_data.get("structure_signature"),
        "liquidity_sweep": override_data.get("liquidity_sweep"),
        "failed_breakout": override_data.get("failed_breakout"),
        "open_drive": override_data.get("open_drive"),
        "gap_continuation": override_data.get("gap_continuation"),
        "impulse_exhaustion": override_data.get("impulse_exhaustion"),
        "counter_trend_risk": override_data.get("counter_trend_risk"),
        "execution_advice": override_data.get("execution_advice"),
    }
    return {k: v for k, v in payload.items() if v not in (None, "", {})}

def _extract_structure_metrics(interpreter):
    interpreter = interpreter if isinstance(interpreter, dict) else {}
    metrics = {
        "market_structure": interpreter.get("market_structure", "UNCERTAIN"),
        "dominant_direction": interpreter.get("dominant_direction", "MIXED"),
        "shock_direction": interpreter.get("shock_direction", "NONE"),
        "pullback_phase": interpreter.get("pullback_phase", "NONE"),
        "pullback_depth_bucket": interpreter.get("pullback_depth_bucket", "NONE"),
        "pullback_quality": interpreter.get("pullback_quality", "UNKNOWN"),
        "confirmation_state": interpreter.get("confirmation_state", "NONE"),
        "retrace_ratio": interpreter.get("retrace_ratio", 0.0),
        "session_name": interpreter.get("session_name", "UNKNOWN"),
        "session_execution_bias": interpreter.get("session_execution_bias", "BALANCED"),
        "counter_trend_risk": interpreter.get("counter_trend_risk", "LOW"),
        "execution_advice": interpreter.get("execution_advice", "ALLOW_FOLLOW"),
        "liquidity_sweep": interpreter.get("liquidity_sweep", "NONE"),
        "failed_breakout": interpreter.get("failed_breakout", "NONE"),
        "open_drive": bool(interpreter.get("open_drive", False)),
        "gap_continuation": bool(interpreter.get("gap_continuation", False)),
        "impulse_exhaustion": bool(interpreter.get("impulse_exhaustion", False)),
        "view": interpreter.get("view"),
        "source": interpreter.get("source"),
        "applied_multiplier": interpreter.get("applied_multiplier"),
    }
    structure_signature = "|".join(
        str(metrics.get(key, ""))
        for key in ("market_structure", "shock_direction", "pullback_phase", "pullback_depth_bucket", "confirmation_state", "session_name")
    )
    metrics["structure_signature"] = interpreter.get("structure_signature", structure_signature)
    return metrics


def _build_trade_attribution(signal=None, state=None, agent_direction=None, override_data=None, agent_multiplier=1.0, interpreter_reason="", interpreter_applied=False):
    signal = signal if isinstance(signal, dict) else {}
    state = state if isinstance(state, dict) else {}
    existing = signal.get("attribution", {}) if isinstance(signal.get("attribution", {}), dict) else {}
    state_existing = state.get("position_attribution", {}) if isinstance(state.get("position_attribution", {}), dict) else {}
    session_info = signal.get("session") if isinstance(signal.get("session"), dict) else state.get("position_session_info", {})
    session_metrics = signal.get("session_metrics") if isinstance(signal.get("session_metrics"), dict) else state.get("position_session_metrics", {})
    if not session_metrics:
        session_metrics = existing.get("session", {}) or state_existing.get("session", {})
    if not session_metrics:
        session_metrics = _extract_session_metrics(session_info)
    interpreter = signal.get("interpreter", {}) if isinstance(signal.get("interpreter", {}), dict) else state.get("position_interpreter", {})
    if not interpreter:
        interpreter = _build_interpreter_context(
            override_data=override_data,
            agent_multiplier=agent_multiplier,
            reason=interpreter_reason,
            applied=interpreter_applied,
        )
    attribution = {
        "decision_id": signal.get("_learning_decision_id") or signal.get("signal_id") or state.get("position_decision_id") or existing.get("decision_id") or state_existing.get("decision_id"),
        "signal_id": signal.get("signal_id") or state.get("position_signal_id") or existing.get("signal_id") or state_existing.get("signal_id"),
        "trace_id": signal.get("_sig_trace_id") or state.get("position_trace_id") or existing.get("trace_id") or state_existing.get("trace_id"),
        "strategy_id": signal.get("_sig_strategy_id") or state.get("position_strategy_id") or existing.get("strategy_id") or state_existing.get("strategy_id"),
        "regime": signal.get("regime") or state.get("position_regime") or state.get("regime") or existing.get("regime") or state_existing.get("regime") or "UNKNOWN",
        "confidence": signal.get("confidence") if signal.get("confidence") is not None else state.get("position_confidence") or existing.get("confidence") or state_existing.get("confidence"),
        "session": session_metrics or existing.get("session", {}) or state_existing.get("session", {}),
        "interpreter": interpreter or existing.get("interpreter", {}) or state_existing.get("interpreter", {}),
        "agent_direction": agent_direction or signal.get("agent_direction") or state.get("position_agent_direction") or existing.get("agent_direction") or state_existing.get("agent_direction"),
    }
    normalized = {
        k: v
        for k, v in attribution.items()
        if v not in (None, "") and not (isinstance(v, dict) and not v)
    }
    return normalize_position_attribution(normalized, action=str(signal.get("action", "") or ""), fallback=state_existing)


def _record_learning_risk_verdict(decision_id, signal, risk_decision):
    try:
        from src.learning.decision_recorder import record_risk_verdict
        risk_payload = risk_decision.get("payload", {}) if isinstance(risk_decision, dict) else {}
        session_info = risk_payload.get("session_info", {})
        session_metrics = _extract_session_metrics(session_info)
        structure_metrics = _extract_structure_metrics(risk_payload.get("interpreter", signal.get("interpreter", {})))
        payload = {
            "proposal_action": signal.get("action", "HOLD"),
            "proposal_side": signal.get("side"),
            "proposal_confidence": signal.get("confidence", 0),
            "final_action": risk_payload.get("action", "HOLD"),
            "final_side": risk_payload.get("side"),
            "drop_reason": risk_payload.get("drop_reason"),
            "lots": risk_payload.get("lots", 0),
            "sl_price": risk_payload.get("sl_price", 0),
            "tp_price": risk_payload.get("tp_price", 0),
            "risk_checks": risk_payload.get("risk_checks", []),
            "session_info": session_info,
            "session_metrics": session_metrics,
            "interpreter": risk_payload.get("interpreter", signal.get("interpreter", {})),
            "structure": structure_metrics,
        }
        record_risk_verdict(decision_id, "bridge.gold_trader", payload)
    except Exception as e:
        _daemon_log(f"_record_learning_risk_verdict failed: {e}")


def _record_learning_execution(decision_id, report, state):
    try:
        from src.learning.decision_recorder import record_execution
        attribution = _build_trade_attribution(state=state)
        structure_metrics = _extract_structure_metrics(attribution.get("interpreter", {}))
        payload = {
            "signal_id": report.get("signal_id", ""),
            "status": report.get("status", ""),
            "action": report.get("action", ""),
            "side": report.get("side", state.get("position_side")),
            "ticket": report.get("ticket", 0) or state.get("position_ticket", 0),
            "fill_price": report.get("fill_price", 0) or state.get("position_entry", 0),
            "volume": report.get("volume", 0.0) or state.get("position_vol", 0.0),
            "sl_price": report.get("sl_price", 0) or state.get("position_sl", 0),
            "tp_price": report.get("tp_price", 0) or state.get("position_tp", 0),
            "regime": attribution.get("regime", "UNKNOWN"),
            "strategy_id": attribution.get("strategy_id"),
            "session": attribution.get("session", {}),
            "interpreter": attribution.get("interpreter", {}),
            "structure": structure_metrics,
            "attribution": attribution,
        }
        record_execution(decision_id, "bridge.gold_trader", payload)
    except Exception as e:
        _daemon_log(f"_record_learning_execution failed: {e}")


def _record_learning_closure(decision_id, state_before_close, pnl, fill_price, duration_min, spread=0.0):
    try:
        from src.learning.decision_recorder import record_closure
        from src.learning.pattern_memory import build_closure_fingerprint, classify_failure_success
        attribution = _build_trade_attribution(state=state_before_close)
        structure_metrics = _extract_structure_metrics(attribution.get("interpreter", {}))
        fingerprint_id, fingerprint = build_closure_fingerprint(state_before_close)
        failure_mode, success_mode = classify_failure_success(
            state_before_close,
            pnl,
            fill_price,
            "CLOSE",
        )
        next_state = {
            "position_open": False,
            "position_ticket": 0,
            "position_side": None,
            "position_entry": 0.0,
            "position_vol": 0.0,
            "position_time": 0,       # 清除旧时间戳，防止 exit_optimizer 误用残留值触发假 TIME_STOP
            "last_close_time": time.time(),
        }
        payload = {
            "ticket": state_before_close.get("position_ticket", 0),
            "side": state_before_close.get("position_side"),
            "entry": state_before_close.get("position_entry", 0),
            "exit_price": fill_price,
            "realized_pnl": pnl,
            "duration_min": duration_min,
            "spread": spread,
            "daily_loss_pct": state_before_close.get("daily_loss_pct", 0.0),
            "consecutive_losses": state_before_close.get("consecutive_losses", 0),
            "done_reason": "trade_closed",
            "next_state": next_state,
            "risk_rejected": False,
            "regime": attribution.get("regime", "UNKNOWN"),
            "strategy_id": attribution.get("strategy_id"),
            "session": attribution.get("session", {}),
            "interpreter": attribution.get("interpreter", {}),
            "structure": structure_metrics,
            "attribution": attribution,
            "fingerprint_id": fingerprint_id,
            "fingerprint": fingerprint,
            "failure_mode": failure_mode,
            "success_mode": success_mode,
        }
        record_closure(decision_id, "bridge.gold_trader", payload)
    except Exception as e:
        _daemon_log(f"_record_learning_closure failed: {e}")


def _record_trade_feedback(state_before_close, close_pnl, exit_price, duration_min, done_reason, decision_id):
    """Record trade closure event for tactical feedback (Phase 2)."""
    try:
        from src.system_bus import append_trade_event
        side = str(state_before_close.get("position_side", "") or "").upper()
        agent_dir = str(state_before_close.get("position_agent_direction", "") or "").upper()
        feedback_data = {
            "trace_id": decision_id or state_before_close.get("position_decision_id", "") or f"unknown_{time.time_ns()}",
            "side": side or "UNKNOWN",
            "pnl": round(float(close_pnl or 0), 2),
            "confidence": float(state_before_close.get("position_confidence", 0) or 0),
            "exit_price": float(exit_price or 0),
            "holding_sec": float(duration_min or 0) * 60,
            "agent_direction": agent_dir or "UNKNOWN",
            "regime": str(state_before_close.get("position_regime", "UNKNOWN") or "UNKNOWN"),
            "entry_price": float(state_before_close.get("position_entry", 0) or 0),
            "close_reason": str(done_reason or "UNKNOWN"),
        }
        append_trade_event(ROOT, "default", feedback_data)
        _daemon_log(f"[FEEDBACK] Trade recorded: {feedback_data['trace_id'][:16]} pnl={feedback_data['pnl']}")
    except Exception as e:
        _daemon_log(f"[FEEDBACK] record_trade_feedback failed: {e}")


def _finalize_position_closure(state_before_close, exit_price, close_pnl, duration_min, done_reason, close_ts=None, signal_id_override="", recovered=False):
    decision_id = str(
        state_before_close.get("position_decision_id", "")
        or _resolve_decision_id(
            signal_id=signal_id_override or state_before_close.get("position_signal_id", ""),
            ticket=state_before_close.get("position_ticket", 0),
        )
        or ""
    ).strip()
    if not decision_id:
        decision_id = f"ema_legacy_{state_before_close.get('position_ticket', 'unknown')}"
    _append_recent_closure(
        state_before_close,
        close_pnl,
        duration_min=duration_min,
        exit_price=exit_price,
        close_action=str(done_reason or "CLOSE"),
    )
    _append_decision_link(
        signal_id=signal_id_override or state_before_close.get("position_signal_id", "") or decision_id,
        decision_id=decision_id,
        action="CLOSE",
        ticket=state_before_close.get("position_ticket", 0),
        source=done_reason.lower(),
    )
    _log_trade(
        state_before_close,
        exit_price,
        exit_price,
        close_pnl=close_pnl,
        duration_min=duration_min,
        close_ts=close_ts,
    )
    _record_learning_closure(
        decision_id,
        state_before_close,
        close_pnl,
        exit_price,
        duration_min,
        spread=0.0,
    )
    try:
        from src.live_performance_factors import refresh_live_performance_factors
        refresh_live_performance_factors()
    except Exception as e:
        _daemon_log(f"refresh_live_performance_factors failed: {e}")
    _close_decision_state(
        decision_id,
        "FILLED",
        reason=done_reason,
        ticket=state_before_close.get("position_ticket", 0),
        signal_id=signal_id_override or state_before_close.get("position_signal_id", ""),
        side=state_before_close.get("position_side"),
        exit_price=exit_price,
        realized_pnl=close_pnl,
        recovered=recovered,
    )
    # Phase 2: Record tactical feedback for closed trade
    _record_trade_feedback(state_before_close, close_pnl, exit_price, duration_min, done_reason, decision_id)
    return decision_id

# ============ MT5 ============
def is_mt5_running():
    return bool(os.popen("ps aux | grep -i 'metatrader\\|metaquotes\\|wine64.*mt5' | grep -v grep").read().strip())

def restart_mt5(force_kill=False):
    try:
        if force_kill and is_mt5_running():
            subprocess.run(
                "pkill -f 'MetaTrader 5|metaquotes|wine64.*mt5'",
                shell=True,
                capture_output=True,
                text=True,
                timeout=8,
            )
            time.sleep(2)

        app_cfg = config_manager.load_app_config()
        launch_cmd = str(app_cfg.get("mt5_launch_command", "")).strip()
        if launch_cmd:
            cmd_list = shlex.split(launch_cmd)
            subprocess.Popen(
                cmd_list,
                cwd=str(ROOT),
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
        elif sys.platform == "darwin":
            subprocess.Popen(
                ["open", "-a", "MetaTrader 5"],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
        else:
            return False

        time.sleep(8)
        return is_mt5_running()
    except Exception as e:
        _daemon_log(f"restart_mt5 failed: {e}")
        return False

_last_mt5_error_time = 0


def _refresh_mt5_bridge_dirs():
    global MT5_FILES, BRIDGE_TO_EA, BRIDGE_FROM_EA
    latest_mt5_files = config_manager.get_mt5_dir()
    if latest_mt5_files and latest_mt5_files != MT5_FILES:
        MT5_FILES = latest_mt5_files
        BRIDGE_TO_EA = MT5_FILES / "gold_autotrade/bridge/to_ea"
        BRIDGE_FROM_EA = MT5_FILES / "gold_autotrade/bridge/from_ea"
        BRIDGE_TO_EA.mkdir(parents=True, exist_ok=True)
        BRIDGE_FROM_EA.mkdir(parents=True, exist_ok=True)
    return MT5_FILES


def _resolve_mt5_terminal_status_file():
    mt5_files = _refresh_mt5_bridge_dirs()
    candidates = []
    try:
        from src.ea_bridge_paths import resolve_bridge_layout
        layout = resolve_bridge_layout(ROOT)
        candidates.extend([
            layout["root"] / "from_ea" / "terminal_status.json",
            layout["root"] / "terminal_status.json"
        ])
    except Exception as e:
        _log(f"Failed to resolve bridge layout: {e}", "WARN")

    candidates.extend([
        mt5_files / "gold_autotrade/bridge/from_ea/terminal_status.json",
        mt5_files / "gold_autotrade/bridge/terminal_status.json",
    ])
    for candidate in candidates:
        if candidate.exists():
            return candidate
    return candidates[0]


def get_price():
    """
    获取 XAUUSD 实时报价（仅 MT5，不回退）。
    返回 (bid, ask, spread, source, age_seconds)
    source: 'mt5' | 'stale' | 'error'
    """
    import time as _time

    global _last_mt5_error_time
    
    # 修复路径错位：EA 实际上是把 terminal_status.json 写在 from_ea 目录下！
    # 加入微小重试机制，防止 MT5 正在覆写文件（FileOpen FILE_WRITE）时的短暂缺失
    for attempt in range(10):
        sf = _resolve_mt5_terminal_status_file()
        try:
            text_content = sf.read_text(encoding='utf-8', errors='ignore')
            data = json.loads(text_content)
            break  # 成功读取则跳出循环
        except Exception:
            # 可能是 FileNotFoundError，也可能是 JSONDecodeError (半截数据)
            _time.sleep(0.05)  # 等待50ms
    else:
        # 重试10次依然失败 (0.5秒)
        if _time.time() - _last_mt5_error_time > 60:
            _log(f"[ERROR] MT5 terminal_status.json not found in {sf.parent} (Make sure EA is attached to chart)", "ERROR")
            _last_mt5_error_time = _time.time()
        return None, None, None, "error", None
    try:
        if "bid" not in data or "ask" not in data:
            _log(f"[ERROR] MT5 data missing bid/ask", "ERROR")
            return None, None, None, "error", None

        bid = float(data["bid"])
        ask = float(data["ask"])
        spread = float(data.get("spread", 0))

        # 检查数据新鲜度
        age_seconds = None
        ea_updated = data.get("updated", "")
        if ea_updated:
            try:
                # 终极修复：EA 现在输出的是 TimeLocal()（即你电脑上的本地时间）。
                # 我们不再做任何 UTC 转换和时区假设，直接解析为 naive datetime，
                # 然后减去当前 Python 系统的 naive datetime，这是最防错的做法。
                ea_dt = datetime.strptime(ea_updated, "%Y.%m.%d %H:%M:%S")
                sys_dt = datetime.now()
                age_seconds = int((sys_dt - ea_dt).total_seconds())
            except Exception:
                pass

        if age_seconds is not None and age_seconds > 300:
            _log(f"[WARN] MT5 price stale: {age_seconds}s old (bid={bid})")
            return bid, ask, spread, "stale", age_seconds

        return bid, ask, spread, "mt5", age_seconds or 0

    except Exception as e:
        _log(f"[ERROR] get_price failed: {e}", "ERROR")
        return None, None, None, "error", None

def get_mt5_position_state():
    """读取 MT5 真实持仓状态。
    
    优先级：
    1. market_snapshot.json（Trading Agent 写入，有真实 MT5 数据）
    2. from_ea/terminal_status.json（EA OnTimer 写入，Wine 下可能陈旧）
    """
    import time as _time

    def _is_snapshot_fresh(snap_data: dict, snap_path, max_age_sec: float = 180.0) -> bool:
        try:
            tick = snap_data.get("tick", {}) if isinstance(snap_data, dict) else {}
            tick_time_ms = int((tick or {}).get("time_msc", 0) or 0)
            if tick_time_ms > 0:
                age_sec = max(0.0, (_time.time() * 1000.0 - tick_time_ms) / 1000.0)
                return age_sec <= max_age_sec
        except Exception:
            pass
        try:
            age_sec = max(0.0, _time.time() - float(snap_path.stat().st_mtime))
            return age_sec <= max_age_sec
        except Exception:
            return False

    # ── 优先从 market_snapshot.json 读取（最可靠）──
    snapshot_candidates = []
    try:
        from src.ea_bridge_paths import resolve_bridge_layout
        layout = resolve_bridge_layout(ROOT)
        snapshot_candidates.extend([
            layout["root"] / "market_snapshot.json",
        ])
    except Exception as e:
        _log(f"Failed to resolve bridge layout: {e}", "WARN")

    snapshot_candidates.extend([
        DATA_DIR / "bridge" / "market_snapshot.json",
        ROOT / "bridge" / "market_snapshot.json",
    ])
    for snap_path in snapshot_candidates:
        if snap_path.exists():
            try:
                snap_data = json.loads(snap_path.read_text(encoding='utf-8', errors='ignore'))
                if not _is_snapshot_fresh(snap_data, snap_path):
                    _daemon_log(f"[MT5_STATE] stale market_snapshot ignored: {snap_path}")
                    continue
                positions_all = snap_data.get("positions_all", [])
                if not isinstance(positions_all, list):
                    positions_all = []
                legacy_positions = snap_data.get("positions", [])
                if not isinstance(legacy_positions, list):
                    legacy_positions = []
                positions_snapshot = positions_all or legacy_positions
                positions_count = int(snap_data.get("positions_count", 0) or 0)
                account = snap_data.get("account", {})
                equity = float(account.get("equity", 0) or 0)
                balance = float(snap_data.get("market", {}).get("bid", 0) or 0)  # 近似
                if positions_snapshot:
                    p = positions_snapshot[0]
                    return {
                        "position_open": True,
                        "position_ticket": int(p.get("ticket", 0) or 0),
                        "position_side": str(p.get("side", "BUY") or "BUY").upper(),
                        "position_entry": float(p.get("entry", 0) or 0),
                        "position_price": float(p.get("entry", 0) or 0),
                        "position_sl": float(p.get("sl", 0) or 0),
                        "position_tp": float(p.get("tp", 0) or 0),
                        "position_vol": float(p.get("volume", 0) or 0),
                        "positions_all": positions_snapshot,
                        "balance": balance,
                        "equity": equity,
                        "_source": "market_snapshot",
                    }
                # Some live EA snapshots only expose positions_count while the detailed
                # position list is temporarily missing. Treating that as "flat" causes
                # agent_state to drift behind terminal_status and breaks runtime guards.
                if positions_count > 0:
                    _daemon_log(
                        f"[MT5_STATE] market_snapshot missing position details despite positions_count={positions_count}; "
                        "falling back to terminal_status"
                    )
                    continue
                else:
                    return {
                        "position_open": False,
                        "positions_all": [],
                        "balance": balance,
                        "equity": equity,
                        "_source": "market_snapshot",
                    }
            except Exception:
                pass

    # ── 回退到 terminal_status.json（EA 写入，Wine OnTimer 可能不触发）──
    # 指数退避 + 抖动，防止 EA 写入期间读到半截 JSON 后立即放弃。
    import random as _random
    for attempt in range(8):
        sf = _resolve_mt5_terminal_status_file()
        try:
            text_content = sf.read_text(encoding='utf-8', errors='ignore')
            data = json.loads(text_content)
            balance = float(data.get("balance", 0) or 0)
            equity = float(data.get("equity", 0) or 0)
            positions = data.get("positions", [])
            if not positions:
                return {"position_open": False, "positions_all": [], "balance": balance, "equity": equity}
            p = positions[0]
            return {
                "position_open": True,
                "position_ticket": p.get("ticket", 0),
                "position_side": p.get("side", "BUY"),
                "position_entry": p.get("entry", 0),
                "position_price": p.get("entry", 0),
                "position_sl": p.get("sl", 0),
                "position_tp": p.get("tp", 0),
                "position_vol": p.get("volume", 0),
                "positions_all": positions,
                "balance": balance,
                "equity": equity,
                "_source": "terminal_status",
            }
        except FileNotFoundError:
            if attempt == 7:
                return {"error": "file_missing"}
        except json.JSONDecodeError as e:
            if attempt == 7:
                _log(f"[DEBUG] get_mt5_position_state error: {e}", "DEBUG")
                return {"error": "json_parse_failed"}
        except Exception as e:
            if attempt == 7:
                _log(f"[DEBUG] get_mt5_position_state error: {e}", "DEBUG")
                return {"error": "read_failed"}
            # fall through to sleep+retry

        # Exponential backoff: 0.1s → 0.2s → 0.4s → ... max 1.6s, with ±25% jitter
        delay = min(0.1 * (2 ** attempt), 1.6)
        jitter = delay * 0.25 * (2.0 * _random.random() - 1.0)
        _time.sleep(max(0.02, delay + jitter))
    return {"error": "file_missing"}

def load_state():
    base = {}
    
    # 从 SQLite 加载 KV 状态
    try:
        conn = _open_state_db(timeout=10.0)
        c = conn.cursor()
        c.execute("SELECT key, value FROM state_kv")
        for row in c.fetchall():
            try:
                base[row[0]] = json.loads(row[1])
            except:
                base[row[0]] = row[1]
        conn.close()
    except Exception as e:
        pass  # 静默，JSON 备援会自动生效

    # 兼容老的 JSON 状态文件，如果 SQLite 没数据，从旧文件读并迁移
    if not base and STATE_FILE.exists():
        try: 
            base = json.loads(STATE_FILE.read_text())
            save_state(base) # 写入 SQLite
        except: pass
    
    defaults = {
        "position_open": False, "position_ticket": 0, "position_side": None,
        "position_price": 0.0, "position_sl": 0.0, "position_tp": 0.0,
        "position_vol": 0.0, "position_entry": 0.0,
        "position_signal_id": "",
        "position_decision_id": "",
        "position_regime": "UNKNOWN",
        "position_strategy_id": "",
        "position_trace_id": "",
        "position_confidence": 0.0,
        "position_session_info": {},
        "position_session_metrics": {},
        "position_interpreter": {},
        "position_attribution": {},
        "position_agent_direction": "",
        "position_entry_owner_instance_id": "",
        "position_entry_owner_strategy_id": "",
        "position_current_manager_instance_id": "",
        "position_current_manager_strategy_id": "",
        "position_expected_exit_owner_instance_id": "",
        "position_expected_exit_owner_strategy_id": "",
        "position_exit_owner_instance_id": "",
        "position_exit_owner_strategy_id": "",
        "position_contributors": [],
        "consecutive_losses": 0, "daily_loss_pct": 0.0, "intraday_drawdown_pct": 0.0,
        "account_total_loss_current_pct": 0.0, "account_total_loss_stop_active": False,
        "account_total_loss_stop_threshold_pct": 30.0, "account_total_loss_stop_source": "NONE",
        "total_pnl": 0.0, "win_rate": 0.0,
        "recent_closures": [],
        "confidence_calibration": {},
        "defense_metrics": {},
        "countertrend_budget": {},
        "trailing_telemetry": {},
        "last_closed_side": "",
        "last_closed_ticket": 0,
        "last_closed_ts": 0.0,
        "last_close_action": "",
        "last_signal_id": None, "last_run": None,
        "kill_switch": False, "total_trades": 0,
        "consecutive_timeouts": 0, "last_close_time": 0.0,
        "close_report_dedupe": [],
        "_mt5_sync_err_count": 0,
        "_mt5_sync_forced_close": False,
        "_mt5_sync_forced_close_reason": "",
    }
    for k, v in defaults.items(): base.setdefault(k, v)
    # Reset transient error counters on every load so stale values from a
    # previous run don't prematurely trigger forced-close after restart.
    base["_mt5_sync_err_count"] = 0
    base.pop("_mt5_sync_forced_close", None)
    base.pop("_mt5_sync_forced_close_reason", None)
    # Trim close_report_dedupe on every load — it accumulates when monitor_position_loop
    # runs (process_ea_reports is skipped while position_open=True).
    dedupe = base.get("close_report_dedupe")
    if isinstance(dedupe, list) and len(dedupe) > 200:
        base["close_report_dedupe"] = dedupe[-200:]
    base["win_rate"] = _recent_closure_win_rate(base)
    if not isinstance(base.get("trailing_telemetry", {}), dict):
        base["trailing_telemetry"] = {}
    if not base.get("trailing_telemetry"):
        cached_trailing = _load_latest_trailing_telemetry()
        if cached_trailing:
            base["trailing_telemetry"] = cached_trailing
    previous_position_open = bool(base.get("position_open", False))
    previous_ticket = int(base.get("position_ticket", 0) or 0)

    persisted_position_snapshot = {
        key: base.get(key)
        for key in base.keys()
        if key.startswith("position_") or key in {"l1_breakeven_done", "partial_done"}
    }
    mt5 = get_mt5_position_state()
    if bool(mt5.get("position_open")) and int(mt5.get("position_ticket", 0) or 0) > 0:
        for k in ("position_open", "position_ticket", "position_side",
                  "position_price", "position_sl", "position_tp", "position_vol", "position_entry", "positions_all"):
            if k in mt5:
                base[k] = mt5[k]
        mt5_ticket = int(mt5.get("position_ticket", 0) or 0)
        if previous_position_open and previous_ticket > 0 and previous_ticket != mt5_ticket:
            # A new MT5 ticket means any in-memory runner/breakeven markers belong to
            # the prior position and must not leak into the replacement position.
            base["l1_breakeven_done"] = False
            base["partial_done"] = False
            base["trend_run_mode"] = False
            base["trailing_telemetry"] = {}
            base["position_time"] = time.time()
            _daemon_log(
                f"[LOAD_STATE] detected ticket rollover {previous_ticket}->{mt5_ticket}; "
                "reset runner/breakeven state"
            )
    else:
        # MT5 当前未报告持仓时，显式标为无仓。
        # 只有在实时源不可用时，才保留旧快照给对账器做归因；若实时源已明确返回空仓，
        # 就不能再把 SQLite 里的旧票据/positions_all 回灌成“活跃持仓”。
        base["position_open"] = False
        # Single guard: if MT5 returned a positions_all key (even empty list),
        # it means MT5 is reachable and confirms no open positions — clear all.
        # If positions_all is missing (MT5 unreachable), preserve old snapshot
        # so the reconciler can attribute the ghost later.
        if "positions_all" in mt5:
            base["positions_all"] = mt5["positions_all"]
            base["position_ticket"] = 0
            base["position_side"] = None
            base["position_price"] = 0.0
            base["position_sl"] = 0.0
            base["position_tp"] = 0.0
            base["position_vol"] = 0.0
            base["position_entry"] = 0.0
            base["l1_breakeven_done"] = False
            base["partial_done"] = False
            base["trend_run_mode"] = False
            base["trailing_telemetry"] = {}
        elif int(persisted_position_snapshot.get("position_ticket", 0) or 0) > 0:
            for key, value in persisted_position_snapshot.items():
                if key == "position_open":
                    continue
                base[key] = value
    _update_account_state(mt5.get("balance"), mt5.get("equity"))

    # 从 effective param 注入最新的 risk_level 给 GUI 显示
    base["risk_level"] = _get_effective_param("risk_level", "balanced")
    total_loss_snapshot = _get_total_loss_stop_snapshot(base)
    base["account_total_loss_current_pct"] = float(total_loss_snapshot.get("current_pct", 0.0) or 0.0)
    base["account_total_loss_stop_active"] = bool(total_loss_snapshot.get("active", False))
    base["account_total_loss_stop_threshold_pct"] = float(total_loss_snapshot.get("threshold_pct", 30.0) or 30.0)
    base["account_total_loss_stop_source"] = str(total_loss_snapshot.get("current_source", "NONE") or "NONE")
    healed = _self_heal_position_state(base)
    if healed:
        try:
            save_state(base)
        except Exception:
            pass
    try:
        # Keep the root runtime blackboard aligned with the reconciled position state.
        _update_shadow_system(
            position_open=bool(base.get("position_open", False)),
            total_trades=base.get("total_trades", 0),
            total_pnl=base.get("total_pnl", 0.0),
            win_rate=base.get("win_rate", 0.0),
        )
        # ── 同步持仓详情到黑板（修复空仓误显问题）──
        try:
            _runtime_blackboard_update("Shadow_System", "position_ticket", int(base.get("position_ticket", 0) or 0))
            _runtime_blackboard_update("Shadow_System", "position_side", str(base.get("position_side") or ""))
            _runtime_blackboard_update("Shadow_System", "position_entry", float(base.get("position_entry", 0.0) or 0.0))
            _runtime_blackboard_update("Shadow_System", "position_price", float(base.get("position_price", 0.0) or 0.0))
            _runtime_blackboard_update("Shadow_System", "position_sl", float(base.get("position_sl", 0.0) or 0.0))
            _runtime_blackboard_update("Shadow_System", "position_tp", float(base.get("position_tp", 0.0) or 0.0))
            _runtime_blackboard_update("Shadow_System", "position_vol", float(base.get("position_vol", 0.0) or 0.0))
        except Exception:
            pass
        _runtime_blackboard_update("Micro_Sniper", "recent_stop_losses", int(base.get("consecutive_losses", 0) or 0))
    except Exception:
        pass
    
    return base


def _self_heal_position_state(state: dict) -> bool:
    if not isinstance(state, dict):
        return False
    changed = False
    pos_open = bool(state.get("position_open", False))
    ticket = int(state.get("position_ticket", 0) or 0)
    pos_time = float(state.get("position_time", 0) or 0)
    if not pos_open:
        # 当 MT5 侧暂时读不到持仓时，保留本地持仓元数据给 reconcile_positions()
        # 做 ghost/external close 归因。这里如果直接清空，会跳过统一平仓归档。
        return changed
    else:
        if ticket <= 0:
            state.update({
                "position_open": False,
                "position_ticket": 0,
                "position_time": 0,
                "position_side": None,
                "position_entry": 0.0,
                "position_price": 0.0,
                "position_sl": 0.0,
                "position_tp": 0.0,
                "position_vol": 0.0,
            })
            _daemon_log("[STATE_HEAL_POSITION] position_open=True but ticket=0; force reset")
            changed = True
        elif pos_time <= 0:
            state["position_time"] = time.time()
            _daemon_log(f"[STATE_HEAL_POSITION] patched missing position_time for ticket={ticket}")
            changed = True
    return changed


def _sanitize_flat_position_snapshot(state: dict) -> None:
    if not isinstance(state, dict):
        return
    positions_all = state.get("positions_all", [])
    if not isinstance(positions_all, list):
        positions_all = []
    position_open = bool(state.get("position_open", False))
    active_ticket = int(state.get("position_ticket", 0) or 0)
    # `positions_all` is only a derived MT5 snapshot. Once the runtime is flat,
    # keeping old rows here recreates ghost holdings in GUI/open guards.
    if (not position_open) or active_ticket <= 0:
        state["positions_all"] = []
        return
    state["positions_all"] = positions_all

def save_state(state):
    try:
        _sanitize_flat_position_snapshot(state)
        # SQLite WAL 模式 + 超时，避免锁死
        conn = _open_state_db(timeout=10.0)
        c = conn.cursor()

        # 将当前的软挂单队列也一并持久化到 state 中，供 GUI 读取
        global _soft_limits
        state["soft_limits"] = _soft_limits

        for k, v in state.items():
            c.execute("INSERT OR REPLACE INTO state_kv (key, value) VALUES (?, ?)", (k, json.dumps(v)))
        conn.commit()
        conn.close()
        # 同时写 JSON 备援
        _write_json_atomic(STATE_FILE, state)
    except sqlite3.OperationalError:
        # SQLite 只读/锁定: 回退到 JSON 备援
        try:
            _sanitize_flat_position_snapshot(state)
            _write_json_atomic(STATE_FILE, state)
        except Exception:
            pass
    except Exception as e:
        _daemon_log(f"[save_state ERROR] failed to persist state: {e}")


COOLDOWN_SECONDS = 300
TICKET_ZERO_PENDING_TIMEOUT_SEC = 120
BRIDGE_TIMEOUT_PENDING_TIMEOUT_SEC = 120
COUNTERTREND_STRONG_TREND_ADX = 30.0
COUNTERTREND_WEAK_TREND_ADX = 22.0
COUNTERTREND_MAX_TRADES_PER_DAY = 3
COUNTERTREND_MAX_CONSEC_LOSSES = 2
COUNTERTREND_LOSS_BUDGET_ABS = 35.0


def _cooldown_remaining_sec(state: dict, cooldown_sec: int = COOLDOWN_SECONDS) -> int:
    return _shared_cooldown_remaining_sec(state, cooldown_sec=cooldown_sec)


def _is_direction_conflict(agent_dir: str, side: str) -> bool:
    agent = str(agent_dir or "").upper()
    signal_side = str(side or "").upper()
    if agent not in {"SHORT", "LONG"} or signal_side not in {"BUY", "SELL"}:
        return False
    return (agent == "SHORT" and signal_side == "BUY") or (agent == "LONG" and signal_side == "SELL")


def _normalize_agent_override_multiplier(agent_dir: str, side: str, multiplier: float, override_data: dict) -> float:
    side_u = str(side or "").upper()
    agent_u = str(agent_dir or "").upper()
    mult = float(multiplier or 1.0)
    if mult >= 1.0:
        return mult
    if side_u not in {"BUY", "SELL"} or agent_u not in {"LONG", "SHORT"}:
        return mult
    if _is_direction_conflict(agent_u, side_u):
        return mult
    if not isinstance(override_data, dict):
        override_data = {}
    execution_advice = str(override_data.get("execution_advice", "ALLOW_FOLLOW") or "ALLOW_FOLLOW").upper()
    dominant_direction = str(override_data.get("dominant_direction", "") or "").upper()
    continuation_bias = str(override_data.get("continuation_bias", "") or "").upper()
    structural_side = "BUY" if dominant_direction == "BULLISH" or continuation_bias == "BULLISH" else "SELL" if dominant_direction == "BEARISH" or continuation_bias == "BEARISH" else ""
    aligned_floor = 0.90 if execution_advice == "ALLOW_FOLLOW" else 0.85
    if structural_side and structural_side != side_u:
        aligned_floor = min(aligned_floor, 0.80)
    return max(mult, aligned_floor)


def _soft_limit_registered_agent_direction(order: dict) -> str:
    if not isinstance(order, dict):
        return ""
    direct = str(order.get("registered_agent_direction", "") or "").upper()
    if direct:
        return direct
    attribution = order.get("attribution", {}) if isinstance(order.get("attribution", {}), dict) else {}
    return str(attribution.get("agent_direction", "") or "").upper()


def _is_bidirectional_regime(regime: str) -> bool:
    regime_u = str(regime or "UNKNOWN").upper()
    return regime_u in {"RANGING", "RANGE", "SIDEWAYS", "NEUTRAL", "UNCERTAIN"}


def _countertrend_regime_bucket(regime: str, adx: float, trend_strength: str = "") -> str:
    regime_u = str(regime or "UNKNOWN").upper()
    ts_u = str(trend_strength or "").upper()
    adx_v = float(adx or 0.0)
    if regime_u in {"EXTREME_TREND", "BREAKOUT", "SHOCK_REPRICING"}:
        return "STRONG_TREND"
    if regime_u == "TRENDING" and adx_v >= COUNTERTREND_STRONG_TREND_ADX:
        return "STRONG_TREND"
    if regime_u == "TRENDING" and adx_v >= COUNTERTREND_WEAK_TREND_ADX:
        if ts_u in {"DRIFTING", "RANGING", "UNCERTAIN"}:
            return "WEAK_TREND"
        return "WEAK_TREND"
    if _is_bidirectional_regime(regime_u):
        return "RANGE_NEUTRAL"
    return "TRANSITIONAL"


def _is_weak_trend_countertrend_window(regime: str, indicators: dict) -> bool:
    if str(regime or "").upper() != "TRENDING":
        return False
    if not isinstance(indicators, dict):
        return False
    adx_v = float(indicators.get("adx", 0.0) or 0.0)
    return COUNTERTREND_WEAK_TREND_ADX <= adx_v < COUNTERTREND_STRONG_TREND_ADX


def _rebuild_countertrend_budget_from_recent(state: dict) -> dict:
    budget = state.get("countertrend_budget", {}) if isinstance(state.get("countertrend_budget", {}), dict) else {}
    rows = state.get("recent_closures", []) if isinstance(state, dict) else []
    if not isinstance(rows, list):
        rows = []
    today = time.strftime("%Y%m%d")
    trades_used = 0
    loss_used_abs = 0.0
    consec_losses = 0
    for row in rows:
        if not isinstance(row, dict):
            continue
        ts_v = int(row.get("ts", 0) or 0)
        if ts_v <= 0:
            continue
        if time.strftime("%Y%m%d", time.localtime(ts_v)) != today:
            continue
        if bool(row.get("agent_aligned", True)):
            continue
        trades_used += 1
        try:
            pnl_v = float(row.get("pnl", 0.0) or 0.0)
        except Exception:
            pnl_v = 0.0
        if pnl_v < 0:
            loss_used_abs += abs(pnl_v)
    for row in reversed(rows):
        if not isinstance(row, dict):
            continue
        ts_v = int(row.get("ts", 0) or 0)
        if ts_v <= 0 or time.strftime("%Y%m%d", time.localtime(ts_v)) != today:
            continue
        if bool(row.get("agent_aligned", True)):
            continue
        try:
            pnl_v = float(row.get("pnl", 0.0) or 0.0)
        except Exception:
            pnl_v = 0.0
        if pnl_v < 0:
            consec_losses += 1
        else:
            break
    budget.update(
        {
            "date": today,
            "trades_used": int(trades_used),
            "loss_used_abs": round(float(loss_used_abs), 2),
            "consec_losses": int(consec_losses),
            "updated_at": int(time.time()),
        }
    )
    state["countertrend_budget"] = budget
    return budget


def _evaluate_countertrend_quality(signal: dict, bid: float, ask: float) -> dict:
    ind = signal.get("indicators", {}) if isinstance(signal.get("indicators", {}), dict) else {}
    interpreter = signal.get("interpreter", {}) if isinstance(signal.get("interpreter", {}), dict) else {}
    side = str(signal.get("side", "") or "").upper()
    event_tag = str(ind.get("event", "") or "").upper()
    conf = float(signal.get("confidence", 0.0) or 0.0)
    adx_v = float(ind.get("adx", 0.0) or 0.0)
    atr_v = max(0.01, float(ind.get("atr14", 0.0) or 0.0))
    entry = float(signal.get("entry", bid if side == "SELL" else ask) or (bid if side == "SELL" else ask))
    sl = float(signal.get("sl", 0.0) or 0.0)
    tp = float(signal.get("tp", 0.0) or 0.0)
    risk_dist = abs(entry - sl)
    reward_dist = abs(tp - entry)
    rr = (reward_dist / risk_dist) if risk_dist > 1e-6 else 0.0
    spread_to_atr = max(0.0, float(ask - bid)) / atr_v
    struct_score = 0.0
    if (side == "BUY" and event_tag == "BOS_UP") or (side == "SELL" and event_tag == "BOS_DOWN"):
        struct_score += 0.45
    confirmation = str(interpreter.get("confirmation_state", "NONE") or "NONE").upper()
    pullback = str(interpreter.get("pullback_quality", "UNKNOWN") or "UNKNOWN").upper()
    if confirmation == "CONFIRMED":
        struct_score += 0.25
    if pullback in {"HEALTHY", "STRONG"}:
        struct_score += 0.20
    if adx_v >= COUNTERTREND_WEAK_TREND_ADX:
        struct_score += 0.10
    # EMA 三线排列强结构加分：当信号方向与 EMA9>21>50 或 9<21<50 同向，
    # 视为强趋势结构证据（即使无 BOS 事件）
    ema9 = float(ind.get("ema9", 0.0) or 0.0)
    ema21 = float(ind.get("ema21", 0.0) or 0.0)
    ema50 = float(ind.get("ema50", 0.0) or 0.0)
    if ema9 and ema21 and ema50:
        if side == "BUY" and ema9 > ema21 > ema50:
            struct_score += 0.30
        elif side == "SELL" and ema9 < ema21 < ema50:
            struct_score += 0.30
    struct_score = max(0.0, min(1.0, struct_score))
    quality_score = max(
        0.0,
        min(
            1.0,
            0.35 * max(0.0, min(1.0, (conf - 0.58) / 0.26))
            + 0.30 * max(0.0, min(1.0, (rr - 1.05) / 0.85))
            + 0.25 * struct_score
            + 0.10 * max(0.0, min(1.0, (0.30 - spread_to_atr) / 0.30)),
        ),
    )
    return {
        "rr": round(rr, 3),
        "spread_to_atr": round(spread_to_atr, 3),
        "structure_score": round(struct_score, 3),
        "quality_score": round(quality_score, 3),
        "confidence": round(conf, 3),
    }


def _apply_countertrend_budget_gate(state: dict, signal: dict, agent_dir: str, bid: float, ask: float, force_macro_poll: bool = True) -> dict:
    side = str(signal.get("side", "") or "").upper()
    conflict = _is_direction_conflict(agent_dir, side)
    if not conflict:
        return {"allow": True, "countertrend": False, "reason": "ALIGNED"}
    ind = signal.get("indicators", {}) if isinstance(signal.get("indicators", {}), dict) else {}
    regime_u = str(signal.get("regime", "UNKNOWN") or "UNKNOWN").upper()
    adx_v = float(ind.get("adx", 0.0) or 0.0)
    trend_strength = str(ind.get("trend_strength", "") or "")
    bucket = _countertrend_regime_bucket(regime_u, adx_v, trend_strength)
    quality = _evaluate_countertrend_quality(signal, bid, ask)
    budget = _rebuild_countertrend_budget_from_recent(state)
    session_name = str((signal.get("session_metrics", {}) or {}).get("session_name", "") or "")
    max_trades = int(_get_effective_param("countertrend_daily_max_trades", COUNTERTREND_MAX_TRADES_PER_DAY) or COUNTERTREND_MAX_TRADES_PER_DAY)
    max_consec = int(_get_effective_param("countertrend_max_consecutive_losses", COUNTERTREND_MAX_CONSEC_LOSSES) or COUNTERTREND_MAX_CONSEC_LOSSES)
    max_loss_abs = float(_get_effective_param("countertrend_daily_loss_budget_abs", COUNTERTREND_LOSS_BUDGET_ABS) or COUNTERTREND_LOSS_BUDGET_ABS)
    if session_name == "ASIA":
        max_trades = max(1, max_trades - 1)
    elif session_name == "LONDON_NY_OVERLAP":
        max_trades = min(max_trades + 1, max_trades + 1)
    if bucket == "STRONG_TREND":
        allow = False
        reason = "COUNTERTREND_STRONG_TREND_BLOCK"
    else:
        rr_floor = 1.25 if bucket in {"RANGE_NEUTRAL", "TRANSITIONAL"} else 1.40
        conf_floor = 0.64 if bucket in {"RANGE_NEUTRAL", "TRANSITIONAL"} else 0.70
        struct_floor = 0.55 if bucket in {"RANGE_NEUTRAL", "TRANSITIONAL"} else 0.65
        spread_floor = 0.26 if bucket in {"RANGE_NEUTRAL", "TRANSITIONAL"} else 0.20
        # EMA 三线排列减免：当信号方向与 EMA9>21>50 或 9<21<50 同向时，
        # EMA 排列本身就是结构证据，降低 conf/struct 阈值
        _ind_ema = signal.get("indicators", {}) if isinstance(signal.get("indicators", {}), dict) else {}
        _e9 = float(_ind_ema.get("ema9", 0.0) or 0.0)
        _e21 = float(_ind_ema.get("ema21", 0.0) or 0.0)
        _e50 = float(_ind_ema.get("ema50", 0.0) or 0.0)
        if _e9 and _e21 and _e50 and ((side == "BUY" and _e9 > _e21 > _e50) or (side == "SELL" and _e9 < _e21 < _e50)):
            conf_floor = max(0.56, conf_floor - 0.08)
            struct_floor = max(0.40, struct_floor - 0.15)
        allow = (
            quality["rr"] >= rr_floor
            and quality["confidence"] >= conf_floor
            and quality["structure_score"] >= struct_floor
            and quality["spread_to_atr"] <= spread_floor
            and int(budget.get("trades_used", 0) or 0) < max_trades
            and int(budget.get("consec_losses", 0) or 0) < max_consec
            and float(budget.get("loss_used_abs", 0.0) or 0.0) < max_loss_abs
        )
        reason = "COUNTERTREND_ALLOWED" if allow else "COUNTERTREND_BUDGET_OR_QUALITY_BLOCK"
    high_quality_blocked = (not allow) and quality["quality_score"] >= 0.74
    blocked_streak = int(budget.get("blocked_hq_streak", 0) or 0)
    if high_quality_blocked:
        blocked_streak += 1
    else:
        blocked_streak = 0
    budget["blocked_hq_streak"] = blocked_streak
    budget["last_eval"] = {
        "bucket": bucket,
        "reason": reason,
        "quality": quality,
        "allow": bool(allow),
        "at": int(time.time()),
    }
    state["countertrend_budget"] = budget
    if high_quality_blocked and force_macro_poll and blocked_streak >= 2:
        last_poll = float(budget.get("last_hq_block_macro_poll_ts", 0.0) or 0.0)
        quick_poll_cooldown = float(_get_effective_param("countertrend_macro_repoll_cooldown_sec", 300) or 300)
        if (time.time() - last_poll) >= max(120.0, quick_poll_cooldown):
            _ct_pos = None
            if state.get("position_open") and state.get("position_side"):
                _ct_pos = {
                    "side": state["position_side"],
                    "entry_price": state.get("position_entry", 0),
                    "sl": state.get("position_sl", 0),
                    "tp": state.get("position_tp", 0),
                    "age_sec": time.time() - float(state.get("position_time", time.time())),
                }
            _ct_regime = str((signal.get("indicators", {}) or {}).get("regime", "UNKNOWN") or "UNKNOWN")
            if _trigger_macro_direction_poll("COUNTERTREND_HQ_BLOCKED_STREAK", force=True, current_position=_ct_pos, current_regime=_ct_regime):
                budget["last_hq_block_macro_poll_ts"] = time.time()
                budget["blocked_hq_streak"] = 0
                state["countertrend_budget"] = budget
    return {
        "allow": bool(allow),
        "countertrend": True,
        "bucket": bucket,
        "reason": reason,
        "quality": quality,
        "budget": {
            "trades_used": int(budget.get("trades_used", 0) or 0),
            "max_trades": int(max_trades),
            "loss_used_abs": float(budget.get("loss_used_abs", 0.0) or 0.0),
            "max_loss_abs": float(max_loss_abs),
            "consec_losses": int(budget.get("consec_losses", 0) or 0),
            "max_consec_losses": int(max_consec),
        },
    }


def _is_overlap_window(dt: datetime.datetime = None) -> bool:
    """判断当前是否在欧美重叠时段 (13:00-17:00 UTC)"""
    now_dt = dt or datetime.datetime.now(datetime.timezone.utc)
    return 13 <= now_dt.hour < 17


def _current_hour_key(dt: datetime.datetime = None) -> str:
    now_dt = dt or datetime.datetime.now()
    return now_dt.strftime("%Y%m%d%H")


def _get_overlap_attempts(state: dict, dt: datetime.datetime = None) -> int:
    budget = state.get("opportunity_budget", {}) if isinstance(state.get("opportunity_budget", {}), dict) else {}
    slot = budget.get(_current_hour_key(dt), {})
    if not isinstance(slot, dict):
        return 0
    return int(slot.get("effective_attempts", 0) or 0)


def _add_overlap_attempt(state: dict, reason: str = ""):
    now_dt = datetime.datetime.now()
    budget = state.get("opportunity_budget", {}) if isinstance(state.get("opportunity_budget", {}), dict) else {}
    key = _current_hour_key(now_dt)
    slot = budget.get(key, {}) if isinstance(budget.get(key, {}), dict) else {}
    attempts = int(slot.get("effective_attempts", 0) or 0) + 1
    budget[key] = {
        "effective_attempts": attempts,
        "last_reason": reason,
        "updated_at": int(time.time()),
    }
    # 仅保留最近 8 小时桶，避免 state 膨胀。
    keys = sorted(budget.keys())
    if len(keys) > 8:
        for old_key in keys[:-8]:
            budget.pop(old_key, None)
    state["opportunity_budget"] = budget


def _set_final_reject_reason(state: dict, reason: str, stage: str = "unknown"):
    state["last_final_reject"] = {
        "reason": str(reason or "UNKNOWN"),
        "stage": str(stage or "unknown"),
        "ts": int(time.time()),
    }


def _append_decision_link(
    *,
    signal_id: str,
    decision_id: str,
    action: str = "",
    stable_id: str = "",
    ticket: int = 0,
    source: str = "",
) -> None:
    try:
        if not signal_id or not decision_id:
            return
        DECISION_LINK_FILE.parent.mkdir(parents=True, exist_ok=True)
        with open(DECISION_LINK_FILE, "a", encoding="utf-8") as f:
            f.write(
                json.dumps(
                    {
                        "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
                        "signal_id": str(signal_id),
                        "decision_id": str(decision_id),
                        "action": str(action or ""),
                        "stable_id": str(stable_id or ""),
                        "ticket": int(ticket or 0),
                        "source": str(source or ""),
                    },
                    ensure_ascii=False,
                )
                + "\n"
            )
    except Exception as e:
        _daemon_log(f"append decision link failed: {e}")


def _resolve_decision_id(signal_id=None, ticket=0):
    try:
        if not DECISION_LINK_FILE.exists():
            return ""
        lines = DECISION_LINK_FILE.read_text(encoding="utf-8").splitlines()
        for line in reversed(lines[-500:]):
            if not line.strip():
                continue
            row = json.loads(line)
            if signal_id and str(row.get("signal_id", "")) == str(signal_id):
                return str(row.get("decision_id", ""))
            if ticket and int(row.get("ticket", 0) or 0) == int(ticket):
                return str(row.get("decision_id", ""))
    except Exception as e:
        _daemon_log(f"resolve decision link failed: {e}")
    return ""


def _get_pipeline_live_source():
    global _PIPELINE_EA_LIVE_SOURCE
    if _PIPELINE_EA_LIVE_SOURCE is None:
        from src.ea_bridge_live import EABridgeLiveSource

        # Keep pipeline live snapshots pinned to the runtime app dir so packaged
        # runs and dev runs resolve the same bridge files.
        _PIPELINE_EA_LIVE_SOURCE = EABridgeLiveSource("XAUUSD", "M5", project_root=APP_DIR)
    client = _PIPELINE_EA_LIVE_SOURCE
    if not client.connect():
        raise RuntimeError("PIPELINE_EA_FEED_NOT_CONNECTED")
    return client


def _handle_pipeline_live_entry(state: dict, envelope: dict[str, Any]) -> dict[str, Any]:
    signal = envelope.get("signal", {}) if isinstance(envelope.get("signal", {}), dict) else {}
    report = envelope.get("execution_report", {}) if isinstance(envelope.get("execution_report", {}), dict) else {}
    action = str(signal.get("action", "HOLD") or "HOLD").upper()
    side = str(signal.get("side", "NONE") or "NONE").upper()
    status = str(report.get("status", "") or "").upper()
    signal_id = str(signal.get("signal_id", report.get("signal_id", "")) or "")
    decision_id = str(signal.get("decision_id", signal_id) or signal_id)
    reject_reason = str(
        signal.get("reject_reason")
        or report.get("reason")
        or "PIPELINE_NO_ACTION"
    )

    if signal_id and decision_id:
        _append_decision_link(
            signal_id=signal_id,
            decision_id=decision_id,
            action=action,
            source="pipeline_primary_entry",
        )

    if action != "OPEN":
        _set_final_reject_reason(state, reject_reason, stage="pipeline_primary")
        save_state(state)
        if action == "HOLD":
            _daemon_log(f"[PIPELINE] HOLD reason={reject_reason}")
        else:
            _daemon_log(f"[PIPELINE] non-open action ignored: action={action} status={status or 'UNKNOWN'}")
        return {"handled": True, "position_open": False, "state": state}

    if status in {"ACKED", "FILLED"}:
        _transition_decision_state(
            decision_id,
            "EXECUTING",
            reason="PIPELINE_REQUEST_WRITTEN",
            request_action=action,
            request_side=side,
            entry=signal.get("entry_price", signal.get("entry", 0)),
            sl=signal.get("sl_price", signal.get("sl", 0)),
            tp=signal.get("tp_price", signal.get("tp", 0)),
            volume=signal.get("volume_lots", signal.get("volume", 0)),
            execution_status=status,
        )
        _daemon_log(f"[PIPELINE] OPEN {side} accepted: status={status} signal_id={signal_id}")
        time.sleep(20)
        state = load_state()
        state = process_ea_reports(state)
        if state.get("position_open"):
            save_state(state)
            return {"handled": True, "position_open": True, "state": state}
        return {"handled": True, "position_open": False, "state": state}

    if status == "TIMEOUT":
        pending_map = state.get("bridge_timeout_pending", {}) if isinstance(state.get("bridge_timeout_pending", {}), dict) else {}
        pending_map[str(decision_id)] = {
            "signal_id": signal_id,
            "created_ts": time.time(),
            "action": action,
            "side": side,
            "reason": reject_reason,
        }
        state["bridge_timeout_pending"] = pending_map
        _transition_decision_state(
            decision_id,
            "EXECUTING",
            reason="PIPELINE_TIMEOUT_PENDING_CONFIRM",
            execution_status=status,
            request_action=action,
            request_side=side,
        )
        save_state(state)
        _daemon_log(f"[PIPELINE] OPEN timeout pending confirm: signal_id={signal_id} reason={reject_reason}")
        return {"handled": True, "position_open": False, "state": state}

    terminal_reason = reject_reason or "PIPELINE_EXECUTION_FAILED"
    terminal_state = "REJECTED" if status == "REJECTED" else "FAILED"
    _close_decision_state(
        decision_id,
        terminal_state,
        reason=terminal_reason,
        execution_status=status or "UNKNOWN",
        request_action=action,
        request_side=side,
    )
    _set_final_reject_reason(state, terminal_reason, stage="pipeline_primary")
    save_state(state)
    _daemon_log(f"[PIPELINE] OPEN rejected: status={status or 'UNKNOWN'} reason={terminal_reason}")
    return {"handled": True, "position_open": False, "state": state}


def _try_pipeline_primary_entry(state: dict) -> dict[str, Any]:
    try:
        from src.pipeline import run_once as pipeline_run_once

        live_client = _get_pipeline_live_source()
        envelope = pipeline_run_once(
            project_root=APP_DIR,
            code_root=ROOT,
            tenant_id="default",
            source="ea_feed",
            runtime_intent="live_trade",
            input_path=DATA_DIR / "bridge/market_snapshot.json",
            output_path=DATA_DIR / "pipeline/live_entry_envelope.json",
            execution_mode="ea_bridge",
            symbol="XAUUSD",
            execution_report_path=None,
            enable_incident_autofix=False,
            live_client=live_client,
        )
    except Exception as e:
        error_text = str(e or "")
        fallback_allowed = isinstance(e, (ImportError, ModuleNotFoundError)) or any(
            token in error_text
            for token in (
                "PIPELINE_EA_FEED_NOT_CONNECTED",
                "PIPELINE_EA_LIVE_SOURCE_UNAVAILABLE",
                "EA_BRIDGE_LIVE_SOURCE_UNAVAILABLE",
            )
        )
        if fallback_allowed:
            _daemon_log(f"[PIPELINE] primary entry unavailable, fallback to legacy: {error_text}")
            return {"handled": False, "fallback_to_legacy": True, "state": state, "error": error_text}
        _set_final_reject_reason(state, error_text or "PIPELINE_PRIMARY_ENTRY_EXCEPTION", stage="pipeline_primary_exception")
        save_state(state)
        _daemon_log(f"[PIPELINE] primary entry hard-failed, legacy fallback blocked: {error_text}")
        return {"handled": True, "fallback_to_legacy": False, "position_open": False, "state": state, "error": error_text}
    result = _handle_pipeline_live_entry(state, envelope if isinstance(envelope, dict) else {})
    result.setdefault("fallback_to_legacy", False)
    return result


def _block_legacy_live_open_after_pipeline_unavailable(state: dict, pipeline_result: dict[str, Any]) -> dict[str, Any]:
    error_text = str((pipeline_result or {}).get("error", "") or "PIPELINE_PRIMARY_ENTRY_UNAVAILABLE")
    _set_final_reject_reason(state, error_text, stage="pipeline_primary_unavailable")
    save_state(state)
    _daemon_log(f"[PIPELINE] primary live open unavailable, legacy open disabled: {error_text}")
    return {"handled": True, "fallback_to_legacy": False, "position_open": False, "state": state, "error": error_text}


def _confirm_open_from_mt5(
    state: dict,
    *,
    decision_id: str = "",
    signal_id: str = "",
    report: Optional[dict] = None,
    pending_reason: str = "MT5_POSITION_CONFIRMED",
):
    """When EA reports a ghost fill (ticket=0), recover the real open from MT5 state."""
    try:
        mt5_state = get_mt5_position_state()
    except Exception as e:
        _daemon_log(f"[PENDING_CONFIRM] mt5 state read failed: {e}")
        return state, False

    if mt5_state.get("error"):
        return state, False

    mt5_ticket = int(mt5_state.get("position_ticket", 0) or 0)
    mt5_entry = float(mt5_state.get("position_entry", 0) or 0)
    if not mt5_state.get("position_open") or mt5_ticket <= 0 or mt5_entry <= 0:
        return state, False

    report_payload = report if isinstance(report, dict) else {}
    report_attribution = report_payload.get("attribution", {}) if isinstance(report_payload.get("attribution", {}), dict) else {}
    pending_attribution = state.get("position_attribution", {}) if isinstance(state.get("position_attribution", {}), dict) else {}
    execution_attribution = normalize_position_attribution(
        report_attribution or pending_attribution,
        action="OPEN",
        fallback=pending_attribution,
    )
    pipeline_owned = _report_owned_by_pipeline(report_payload) or str(execution_attribution.get("writer_owner", "") or "").lower() == "pipeline"

    resolved_decision_id = str(
        decision_id
        or report_payload.get("decision_id")
        or state.get("position_decision_id", "")
        or _resolve_decision_id(ticket=mt5_ticket)
        or _resolve_decision_id(signal_id=signal_id)
        or ""
    ).strip()
    resolved_signal_id = str(
        signal_id
        or report_payload.get("signal_id")
        or state.get("position_signal_id", "")
        or resolved_decision_id
        or ""
    ).strip()

    if report_payload:
        report_fill_price = float(report_payload.get("fill_price", 0) or 0)
        report_volume = float(report_payload.get("volume", 0.0) or 0.0)
        report_side = str(report_payload.get("side", "") or "").upper()
    else:
        report_fill_price = 0.0
        report_volume = 0.0
        report_side = ""

    was_open = bool(state.get("position_open"))
    prev_ticket = int(state.get("position_ticket", 0) or 0)
    next_open_state = {
        "position_open": True,
        "position_side": mt5_state.get("position_side") or report_side or "BUY",
        "position_entry": mt5_entry or report_fill_price,
        "position_price": float(mt5_state.get("position_price", mt5_entry) or mt5_entry or report_fill_price),
        "position_sl": float(mt5_state.get("position_sl", 0) or report_payload.get("sl_price", 0) or 0),
        "position_tp": float(mt5_state.get("position_tp", 0) or report_payload.get("tp_price", 0) or 0),
        "position_vol": float(mt5_state.get("position_vol", 0) or report_volume or 0.01),
        "position_ticket": mt5_ticket,
        "position_signal_id": resolved_signal_id,
        "position_decision_id": resolved_decision_id or resolved_signal_id,
        "position_regime": execution_attribution.get("regime", state.get("position_regime", "UNKNOWN")),
        "position_confidence": float(
            report_payload.get("confidence", state.get("position_confidence", 0.0))
            or state.get("position_confidence", 0.0)
            or 0.0
        ),
        "position_time": time.time(),
    }
    next_open_state.update(position_state_fields_from_attribution(execution_attribution, action="OPEN"))

    _update_account_state(mt5_state.get("balance"), mt5_state.get("equity"))
    state.update(next_open_state)
    state["l1_breakeven_done"] = False
    state["partial_done"] = False

    pending_map = state.get("ticket_zero_pending", {}) if isinstance(state.get("ticket_zero_pending", {}), dict) else {}
    if resolved_decision_id:
        pending_map.pop(resolved_decision_id, None)
    state["ticket_zero_pending"] = pending_map

    is_new_open = (not was_open) or prev_ticket != mt5_ticket
    if is_new_open:
        state["total_trades"] = int(state.get("total_trades", 0) or 0) + 1
        _log(
            f"✅ POSITION OPENED: {state['position_side']} @{state['position_entry']} "
            f"SL={state['position_sl']} TP={state['position_tp']} [confirmed by MT5]",
            "WARN",
        )
        if not pipeline_owned:
            _log_open(state)
            _update_shadow_system(position_open=True, total_trades=state.get("total_trades", 0), total_pnl=state.get("total_pnl", 0.0))
        try:
            queued_post_fill_repair = _queue_post_fill_sl_tp_repair(
                ticket=int(state.get("position_ticket", 0) or 0),
                side=str(state.get("position_side", "") or ""),
                fill_price=float(state.get("position_entry", 0) or 0),
                sl=float(state.get("position_sl", 0) or 0),
                tp=float(state.get("position_tp", 0) or 0),
                volume=float(state.get("position_vol", 0.0) or 0.0),
                decision_id=str(resolved_decision_id or state.get("position_decision_id", "") or ""),
                signal_id=str(resolved_signal_id or state.get("position_signal_id", "") or ""),
                attribution=execution_attribution,
            )
            if queued_post_fill_repair:
                state["position_post_fill_rr_repair_pending"] = True
        except Exception as post_fill_err:
            _daemon_log(f"[POST_FILL_RR_REPAIR] failed: {post_fill_err}")
        try:
            sys.path.insert(0, str(ROOT))
            from skill_trading_memory import save_position_snapshot
            save_position_snapshot(positions=[{
                "ticket": state["position_ticket"],
                "side": state["position_side"],
                "volume": state["position_vol"],
                "entry": state["position_entry"],
                "sl": state["position_sl"],
                "tp": state["position_tp"],
            }])
        except Exception as e:
            _daemon_log(f"save_position_snapshot failed: {e}")

    if resolved_decision_id and not pipeline_owned:
        _close_decision_state(
            resolved_decision_id,
            "CONFIRMED",
            reason=pending_reason,
            ticket=state.get("position_ticket", 0),
            signal_id=resolved_signal_id,
            side=state.get("position_side"),
            fill_price=state.get("position_entry", 0),
            volume=state.get("position_vol", 0),
        )
        _append_decision_link(
            signal_id=resolved_signal_id,
            decision_id=resolved_decision_id,
            action="OPEN",
            ticket=state.get("position_ticket", 0),
            source="mt5_pending_confirmed",
        )
        try:
            execution_report = dict(report_payload) if report_payload else {}
            execution_report.setdefault("signal_id", resolved_signal_id)
            execution_report.setdefault("decision_id", resolved_decision_id)
            execution_report.setdefault("status", "FILLED")
            execution_report.setdefault("action", "OPEN")
            execution_report.setdefault("ticket", state.get("position_ticket", 0))
            execution_report.setdefault("fill_price", state.get("position_entry", 0))
            execution_report.setdefault("sl_price", state.get("position_sl", 0))
            execution_report.setdefault("tp_price", state.get("position_tp", 0))
            execution_report.setdefault("volume", state.get("position_vol", 0))
            execution_report.setdefault("side", state.get("position_side", "BUY"))
            execution_report.setdefault("attribution", execution_attribution)
            _record_learning_execution(resolved_decision_id, execution_report, state)
        except Exception as learning_err:
            _daemon_log(f"_record_learning_execution pending confirm failed: {learning_err}")
    if not pipeline_owned:
        try:
            sync_open_fill(
                execution_attribution,
                data_dir=DATA_DIR,
                ticket=int(state.get("position_ticket", 0) or 0),
                side=str(state.get("position_side", "BUY") or "BUY"),
                volume=float(state.get("position_vol", 0.0) or 0.0),
                entry_price=float(state.get("position_entry", 0.0) or 0.0),
                sl=float(state.get("position_sl", 0.0) or 0.0),
                tp=float(state.get("position_tp", 0.0) or 0.0),
                symbol="XAUUSD",
                source="bridge_pending_confirm",
            )
        except Exception as lifecycle_err:
            _daemon_log(f"sync_open_fill pending confirm failed: {lifecycle_err}")

    return state, True


def _expire_bridge_timeout_pending(state: dict, decision_id: str, *, timeout_sec: int) -> None:
    _close_decision_state(
        decision_id,
        "FAILED",
        reason="EA_BRIDGE_TIMEOUT_FINAL",
        timeout_sec=timeout_sec,
    )


def _reconcile_bridge_timeout_pending(state: dict) -> dict:
    pending_map = state.get("bridge_timeout_pending", {}) if isinstance(state.get("bridge_timeout_pending", {}), dict) else {}
    if not pending_map:
        state["bridge_timeout_pending"] = {}
        return state

    now_ts = time.time()
    next_pending: dict[str, dict[str, Any]] = {}
    for decision_id, payload in pending_map.items():
        payload_dict = payload if isinstance(payload, dict) else {}
        signal_id = str(payload_dict.get("signal_id", "") or "").strip()
        created_ts = float(payload_dict.get("created_ts", 0.0) or 0.0)
        state, rescued = _confirm_open_from_mt5(
            state,
            decision_id=str(decision_id),
            signal_id=signal_id,
            pending_reason="EA_BRIDGE_TIMEOUT_MT5_CONFIRMED",
        )
        if rescued:
            continue
        age = max(0.0, now_ts - created_ts) if created_ts > 0 else (BRIDGE_TIMEOUT_PENDING_TIMEOUT_SEC + 1)
        if age >= BRIDGE_TIMEOUT_PENDING_TIMEOUT_SEC:
            _expire_bridge_timeout_pending(state, str(decision_id), timeout_sec=BRIDGE_TIMEOUT_PENDING_TIMEOUT_SEC)
            continue
        next_pending[str(decision_id)] = payload_dict

    state["bridge_timeout_pending"] = next_pending
    return state

# ============ Agent 大方向决策 ============
# Agent 的核心职责：定当天大方向（LONG / SHORT / NEUTRAL）
# 规则层只在该方向内执行，不允许逆势开仓

_DIRECTION_V2_CACHE = {
    "ts": 0.0,
    "direction": "NEUTRAL",
    "data": {},
    "last_flip_ts": 0.0,
    "pending_dir": "",
    "pending_count": 0,
}


def _ema_seq(values, period):
    if not values or len(values) < period:
        return []
    k = 2.0 / (period + 1.0)
    out = [sum(values[:period]) / period]
    for v in values[period:]:
        out.append(v * k + out[-1] * (1.0 - k))
    return out


def _sigmoid(x):
    x = max(-20.0, min(20.0, x))
    return 1.0 / (1.0 + math.exp(-x))


def _direction_from_bars(bars):
    closes = [float(b.get("close", 0.0) or 0.0) for b in bars if b.get("close") is not None]
    if len(closes) < 60:
        return 0.0, {}
    ema9 = _ema_seq(closes, 9)
    ema21 = _ema_seq(closes, 21)
    ema50 = _ema_seq(closes, 50)
    if not ema9 or not ema21 or not ema50:
        return 0.0, {}

    e9 = ema9[-1]
    e21 = ema21[-1]
    e50 = ema50[-1]
    trend = 1.0 if (e9 > e21 > e50) else -1.0 if (e9 < e21 < e50) else 0.0
    e21_prev = ema21[-4] if len(ema21) >= 4 else ema21[0]
    slope = (e21 - e21_prev) / max(abs(e21_prev) * 0.0002, 0.05)
    spread = (e9 - e21) / max(abs(e21) * 0.00015, 0.05)
    slope_score = math.tanh(slope)
    spread_score = math.tanh(spread)
    score = max(-1.0, min(1.0, 0.6 * trend + 0.25 * slope_score + 0.15 * spread_score))
    return score, {
        "ema9": round(e9, 3),
        "ema21": round(e21, 3),
        "ema50": round(e50, 3),
        "trend": trend,
        "slope_score": round(slope_score, 4),
        "spread_score": round(spread_score, 4),
        "score": round(score, 4),
    }


def _compute_direction_v2():
    try:
        from bridge.strategy import _read_bars
    except Exception:
        return "NEUTRAL", {"engine": "direction_v2", "reason": "read_bars_unavailable", "probability": 0.5}

    bars_m5 = _read_bars(220, timeframe="M5") or []
    bars_m15 = _read_bars(220, timeframe="M15") or []
    bars_h1 = _read_bars(220, timeframe="H1") or []
    if len(bars_m5) < 80 or len(bars_m15) < 80 or len(bars_h1) < 80:
        return "NEUTRAL", {"engine": "direction_v2", "reason": "insufficient_bars", "probability": 0.5}

    s5, d5 = _direction_from_bars(bars_m5)
    s15, d15 = _direction_from_bars(bars_m15)
    s1h, d1h = _direction_from_bars(bars_h1)
    raw = 0.45 * s5 + 0.35 * s15 + 0.20 * s1h

    # 结构突破增益（5m）
    highs = [float(b.get("high", 0.0) or 0.0) for b in bars_m5]
    lows = [float(b.get("low", 0.0) or 0.0) for b in bars_m5]
    close_last = float(bars_m5[-1].get("close", 0.0) or 0.0)
    if len(highs) > 25 and close_last > max(highs[-21:-1]):
        raw += 0.18
        structure_event = "BOS_UP"
    elif len(lows) > 25 and close_last < min(lows[-21:-1]):
        raw -= 0.18
        structure_event = "BOS_DOWN"
    else:
        structure_event = "NONE"

    # ADX 作为趋势可信度缩放（来自 M5 信号器）
    adx_v = 0.0
    try:
        gen = _get_cached_generator("ema_macd")
        if gen:
            r = gen.analyze(bars_m5)
            payload = r.get("payload", {}) if isinstance(r, dict) else {}
            ind = payload.get("indicators", {}) if isinstance(payload.get("indicators", {}), dict) else {}
            adx_v = float(ind.get("adx", 0.0) or 0.0)
    except Exception:
        adx_v = 0.0
    adx_scale = 0.72 + 0.38 * max(0.0, min(1.0, (adx_v - 18.0) / 20.0))
    raw *= adx_scale

    p_long = _sigmoid(raw * 2.6)
    # 宏观只做多：方向离散化仅输出 LONG / NEUTRAL
    direction = "LONG" if raw >= 0.18 else "NEUTRAL"
    probability = max(p_long, 1.0 - p_long)
    probability = max(0.5, min(0.98, probability))

    phase = "EARLY"
    if adx_v >= 35 and abs(raw) >= 0.45:
        phase = "MIDDLE"
    if abs(raw) < 0.22 or adx_v < 20:
        phase = "LATE"

    data = {
        "engine": "direction_v2",
        "direction": direction,
        "probability": round(probability, 4),
        "phase": phase,
        "raw_score": round(raw, 4),
        "adx": round(adx_v, 2),
        "structure_event": structure_event,
        "components": {"m5": d5, "m15": d15, "h1": d1h},
        "updated": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
        "updated_ts": time.time(),
    }
    return direction, data


def _load_macro_direction_record():
    """宏观方向唯一入口：只读取 M2（大模型）落盘结果，不允许技术核实时改写。"""
    contract_payload = load_macro_contract_payload()
    if contract_payload:
        return str(contract_payload.get("direction", "NEUTRAL") or "NEUTRAL").upper(), contract_payload
    return _registry_load_macro_direction_record()

def get_agent_direction():
    """读取 Agent 当天的方向决策。返回: LONG / SHORT / NEUTRAL"""
    contract_payload = load_macro_contract_payload()
    if contract_payload:
        return str(contract_payload.get("direction", "NEUTRAL") or "NEUTRAL").upper(), contract_payload
    return _registry_get_agent_direction()


def get_technical_trend_bias(force_refresh: bool = False):
    """
    只读技术趋势偏向，用于 GUI 展示。
    不参与主循环宏观方向裁决，也不改写宏观落盘结果。
    """
    now_ts = time.time()
    cache_ttl = 20.0
    if (
        not force_refresh
        and _DIRECTION_V2_CACHE.get("data")
        and (now_ts - float(_DIRECTION_V2_CACHE.get("ts", 0.0) or 0.0)) < cache_ttl
    ):
        return _DIRECTION_V2_CACHE.get("direction", "NEUTRAL"), dict(_DIRECTION_V2_CACHE.get("data", {}))

    direction, data = _compute_direction_v2()
    _DIRECTION_V2_CACHE["ts"] = now_ts
    _DIRECTION_V2_CACHE["direction"] = direction
    _DIRECTION_V2_CACHE["data"] = data if isinstance(data, dict) else {}
    return direction, dict(_DIRECTION_V2_CACHE.get("data", {}))


def _spawn_macro_direction_process(
    force_refresh: bool = False,
    reason: str = "",
    current_position: dict = None,
    current_regime: str = "UNKNOWN",
) -> bool:
    """直接调用 Commander API 通道，替代 subprocess(deepseek_agent.py)"""
    try:
        # 1. 收集多时段市场数据
        from bridge.session_analyzer import analyze_all_sessions
        session_data = analyze_all_sessions()
    except Exception as e:
        _daemon_log(f"[COMMANDER] session_analyzer failed: {e}")
        session_data = {}

    # 2. 市场结构快照
    structure_snapshot = {}
    try:
        from src.market_structure import load_structure_snapshot
        structure_snapshot = load_structure_snapshot(valid_only=True) or {}
    except Exception as e:
        _daemon_log(f"[COMMANDER] structure_snapshot failed: {e}")

    # 3. GARCH 波动率
    vol_ratio = 1.0
    try:
        from bridge.garch_volatility import get_vol_multiplier
        vol_info = get_vol_multiplier()
        vol_ratio = float(vol_info.get("vol_ratio", 1.0))
    except Exception:
        pass

    # 4. 调用 Commander API
    try:
        from bridge import commander_client
        result = commander_client.call_commander(
            session_data=session_data,
            structure_snapshot=structure_snapshot,
            current_regime=current_regime,
            vol_ratio=vol_ratio,
            current_position=current_position,
        )

        direction = str(result.get("direction", "LONG")).upper()
        # 宏观只做多：强制纠正 SHORT → LONG
        if direction == "SHORT":
            direction = "LONG"
        confidence = float(result.get("confidence", 0.5))
        c_reason = str(result.get("reason", ""))[:300]
        degraded = bool(result.get("degraded", True))
        conviction = result.get("conviction")
        timeframe = result.get("timeframe")
        risk_appetite = result.get("risk_appetite")
        macro_regime = result.get("macro_regime")
        key_levels = result.get("key_levels")

        save_agent_direction(
            direction, c_reason, confidence,
            indicators=session_data,
            degraded=degraded,
            conviction=conviction,
            timeframe=timeframe,
            risk_appetite=risk_appetite,
            key_levels=key_levels,
            macro_regime=macro_regime,
        )
        update_macro_contract(
            {
                "direction": direction,
                "confidence": confidence,
                "probability": float(result.get("calibrated_confidence", result.get("confidence", confidence)) or confidence),
                "reason": c_reason,
                "phase": "DEGRADED" if degraded else "FRESH",
                "degraded": degraded,
                "indicators": session_data if isinstance(session_data, dict) else {},
                "metadata": {
                    "source_reason": reason,
                    "force_refresh": bool(force_refresh),
                    "current_regime": current_regime,
                    "conviction": conviction,
                    "timeframe": timeframe,
                    "risk_appetite": risk_appetite,
                    "macro_regime": macro_regime,
                    "key_levels": key_levels,
                },
                "updated_ts": float(result.get("updated_ts", time.time()) or time.time()),
            },
            source="commander_client",
        )

        _daemon_log(
            f"[COMMANDER_API] direction={direction} conf={confidence:.2f} "
            f"degraded={degraded} reason={c_reason[:60]}"
        )
        return True

    except Exception as e:
        _daemon_log(f"[COMMANDER_API] call failed: {e}")
        import traceback
        _daemon_log(traceback.format_exc())
        return False


def _trigger_macro_direction_poll(reason: str, force: bool = False, current_position: dict = None, current_regime: str = "UNKNOWN") -> bool:
    global _last_macro_poll_time
    now_ts = time.time()
    if (not force) and (now_ts - _last_macro_poll_time) < HOLD_MACRO_POLL_INTERVAL_SEC:
        return False
    try:
        if not _spawn_macro_direction_process(
            force_refresh=force, reason=reason,
            current_position=current_position, current_regime=current_regime,
        ):
            return False
        _last_macro_poll_time = now_ts
        _daemon_log(f"[MACRO_POLL] triggered: {reason} regime={current_regime}")
        return True
    except Exception as e:
        _daemon_log(f"[MACRO_POLL] trigger failed: {e}")
        return False


def _resolve_direction_conflict(agent_dir: str, direction_data: dict, signal: dict, indicators: dict, state: dict):
    """
    方向时效冲突解决器（三态）：
    - FOLLOW_MACRO: 维持宏观方向
    - ALLOW_MICRO_PROBE: 允许微观探针（LIMIT/小仓）
    - FLIP_TO_MICRO: 当前周期切换到微观方向
    """
    side = str(signal.get("side", "") or "").upper()
    if side not in {"BUY", "SELL"} or agent_dir not in {"LONG", "SHORT"}:
        return {"action": "FOLLOW_MACRO", "macro_age_sec": 0.0, "micro_quality": 0.0, "conflict": False}

    conflict = (agent_dir == "LONG" and side == "SELL") or (agent_dir == "SHORT" and side == "BUY")
    if not conflict:
        return {"action": "FOLLOW_MACRO", "macro_age_sec": 0.0, "micro_quality": 0.0, "conflict": False}
    regime_u = str(signal.get("regime", "UNKNOWN") or "UNKNOWN").upper()
    two_way_allowed = _is_bidirectional_regime(regime_u)

    now_ts = time.time()
    ttl = float(_get_effective_param("macro_direction_stale_sec", 7200) or 7200)
    macro_updated_ts = float((direction_data or {}).get("updated_ts", 0.0) or 0.0)
    macro_age_sec = max(0.0, now_ts - macro_updated_ts) if macro_updated_ts > 0 else 9999.0
    macro_phase = str((direction_data or {}).get("phase", "UNKNOWN") or "UNKNOWN").upper()
    macro_prob = float((direction_data or {}).get("probability", 0.5) or 0.5)

    event_tag = str(indicators.get("event", "") or "").upper()
    adx_v = float(indicators.get("adx", 0.0) or 0.0)
    ema9_v = float(indicators.get("ema9", 0.0) or 0.0)
    ema21_v = float(indicators.get("ema21", 0.0) or 0.0)
    conf = float(signal.get("confidence", 0.0) or 0.0)
    ema_align = (side == "BUY" and ema9_v > ema21_v) or (side == "SELL" and ema9_v < ema21_v)
    bos_align = (side == "BUY" and event_tag == "BOS_UP") or (side == "SELL" and event_tag == "BOS_DOWN")
    micro_quality = 0.0
    micro_quality += 0.35 if bos_align else 0.0
    micro_quality += 0.25 if ema_align else 0.0
    micro_quality += min(0.25, max(0.0, (adx_v - 20.0) / 40.0))
    micro_quality += min(0.15, max(0.0, (conf - 0.55)))
    micro_quality = max(0.0, min(1.0, micro_quality))

    resolver_state = state.get("direction_resolver", {}) if isinstance(state.get("direction_resolver", {}), dict) else {}
    last_side = str(resolver_state.get("last_conflict_side", "") or "")
    last_ts = float(resolver_state.get("last_conflict_ts", 0.0) or 0.0)
    streak = int(resolver_state.get("conflict_streak", 0) or 0)
    if side == last_side and (now_ts - last_ts) <= max(120.0, ttl * 4):
        streak += 1
    else:
        streak = 1

    macro_degraded = bool((direction_data or {}).get("degraded", False))
    macro_stale = macro_degraded or macro_age_sec > max(ttl * 1.35, 90.0) or macro_phase in {"LATE", "DEGRADED", "FAILED"}

    # ---- 三维宏观权威度 macro_authority = τ(时间) × δ(价格验证) × σ(波动率) ----
    macro_age_h = macro_age_sec / 3600.0
    tau = max(0.05, 1.0 - (macro_age_h / 6.0) ** 0.8)  # 前2h缓慢衰减，后加速

    dir_indicators = (direction_data or {}).get("indicators", {}) or {}
    if not isinstance(dir_indicators, dict):
        dir_indicators = {}
    dir_ref_price = float(dir_indicators.get("bid", 0.0) or 0.0)
    current_price = float(indicators.get("bid", 0.0) or 0.0)
    atr_v = max(float(indicators.get("atr", 1.0) or 1.0), 0.1)

    if agent_dir == "LONG" and dir_ref_price > 0 and current_price > 0:
        drift_atr = (dir_ref_price - current_price) / atr_v  # >0=反向下跌
    elif agent_dir == "SHORT" and dir_ref_price > 0 and current_price > 0:
        drift_atr = (current_price - dir_ref_price) / atr_v  # >0=反向上涨
    else:
        drift_atr = 0.0

    if drift_atr >= 0:
        delta = max(0.30, 1.0 - drift_atr * 0.25)          # 背离惩罚
    else:
        delta = min(1.05, 1.0 + abs(drift_atr) * 0.03)     # 顺向微强化(≤5%)

    atr_pct = min(5.0, atr_v / max(current_price, 1.0) * 100.0) if current_price > 0 else 0.15
    sigma = max(0.60, min(1.20, 1.20 - (atr_pct - 0.10) * 2.0))

    macro_authority = max(0.0, min(1.0, tau * delta * sigma))

    # ---- 权威度决策矩阵 ----
    if macro_authority >= 0.70:
        probe_th = 0.75
        flip_th  = 0.90
    elif macro_authority >= 0.45:
        probe_th = 0.62
        flip_th  = 0.80
    elif macro_authority >= 0.25:
        probe_th = 0.50
        flip_th  = 0.70
    else:
        probe_th = 0.40
        flip_th  = 0.60

    if not two_way_allowed:
        probe_th = min(1.0, probe_th + 0.08)
        flip_th  = min(1.0, flip_th  + 0.08)

    # 冲突持续 N 周期 → 降阈值，让微信号更容易通过（持续相悖说明宏观可能已过时）
    if streak >= 3:
        probe_th = max(0.30, probe_th - 0.08)
        flip_th  = max(0.45, flip_th  - 0.08)
    elif streak >= 2:
        probe_th = max(0.35, probe_th - 0.04)
        flip_th  = max(0.50, flip_th  - 0.04)

    action = "FOLLOW_MACRO"
    if micro_quality >= flip_th:
        action = "FLIP_TO_MICRO"
    elif micro_quality >= probe_th:
        action = "ALLOW_MICRO_PROBE"

    state["direction_resolver"] = {
        "last_conflict_side": side,
        "last_conflict_ts": now_ts,
        "conflict_streak": streak,
        "macro_age_sec": round(macro_age_sec, 1),
        "macro_phase": macro_phase,
        "macro_prob": round(macro_prob, 3),
        "macro_degraded": bool(macro_degraded),
        "macro_authority": round(macro_authority, 4),
        "micro_quality": round(micro_quality, 3),
        "action": action,
        "two_way_allowed": bool(two_way_allowed),
        "regime": regime_u,
    }
    return {
        "action": action,
        "macro_age_sec": macro_age_sec,
        "macro_authority": round(macro_authority, 4),
        "micro_quality": micro_quality,
        "conflict": True,
        "streak": streak,
        "macro_stale": macro_stale,
        "macro_prob": macro_prob,
        "macro_degraded": bool(macro_degraded),
        "two_way_allowed": bool(two_way_allowed),
        "regime": regime_u,
    }

def save_agent_direction(direction, reason, confidence, indicators=None, probability=None, phase=None, degraded=False, metadata=None, conviction=None, timeframe=None, risk_appetite=None, key_levels=None, macro_regime=None):
    """保存 Agent 的方向决策（委托 direction_registry 核心写入 + 本地 side effects）"""
    # 宏观只做多：SHORT 方向硬纠正为 LONG
    direction = str(direction or "LONG").upper()
    if direction == "SHORT":
        direction = "LONG"
        reason = f"[AUTO-CORRECTED SHORT→LONG] {reason}"
    data = _registry_save_agent_direction(
        direction, reason, confidence,
        indicators=indicators, probability=probability, phase=phase,
        degraded=degraded, metadata=metadata,
        conviction=conviction, timeframe=timeframe,
        risk_appetite=risk_appetite, key_levels=key_levels,
        macro_regime=macro_regime,
    )
    normalized_direction = data.get("direction", "NEUTRAL")
    _daemon_log(f"Agent direction SAVED: {normalized_direction} ({reason})")

    try:
        snapshot = _get_runtime_blackboard().get_snapshot(include_logs=0)
        macro_state = snapshot.get("Macro_Commander", {}) if isinstance(snapshot, dict) else {}
        current_calls = int(macro_state.get("daily_calls", 0) or 0)
        _runtime_blackboard_update("Macro_Commander", "bias", normalized_direction)
        _runtime_blackboard_update("Macro_Commander", "confidence", float(confidence))
        _runtime_blackboard_update("Macro_Commander", "status", "DONE")
        _runtime_blackboard_update("Macro_Commander", "degraded", bool(degraded))
        _runtime_blackboard_update("Macro_Commander", "daily_calls", current_calls + 1)
    except Exception as e2:
        _daemon_log(f"sync Macro_Commander failed: {e2}")

    try:
        sys.path.insert(0, str(ROOT))
        from skill_trading_memory import append_agent_decision
        append_agent_decision(
            direction=normalized_direction,
            reason=reason,
            confidence=confidence,
            indicators=indicators or {},
        )
    except Exception as e2:
        _daemon_log(f"append_agent_decision failed: {e2}")

    return data

def should_reverse_direction(old_dir, new_dir):
    """检测是否需要反向（重大方向转换）"""
    opposites = {"LONG": "SHORT", "SHORT": "LONG"}
    if old_dir in opposites and opposites[old_dir] == new_dir:
        return True  # 多转空或空转多
    return False

def _agent_direction_to_side(agent_dir):
    normalized = str(agent_dir or "").upper()
    if normalized == "LONG":
        return "BUY"
    if normalized == "SHORT":
        return "SELL"
    return None

# ============ Governor ============
def governor_check(price_bid, price_ask, state):
    spread = round((price_ask - price_bid) * 100, 1)

    # 读取参数建议（每次检查刷新缓存）
    _load_params_suggestion()

    # 获取当前生效参数
    spread_kill = float(_get_effective_param("spread_kill", 70))
    kill_switch = state.get("kill_switch", False)
    total_loss_snapshot = _get_total_loss_stop_snapshot(state)
    
    max_consec = int(_get_effective_param("max_consecutive_losses", 4))

    # Governor 实时拦截逻辑（不修改底层配置）
    if spread > spread_kill: return False, f"WIDE_SPREAD_{spread}"
    if kill_switch: return False, "KILL_SWITCH"
    if bool(total_loss_snapshot.get("active", False)):
        current_pct = float(total_loss_snapshot.get("current_pct", 0.0) or 0.0)
        threshold_pct = float(total_loss_snapshot.get("threshold_pct", 0.0) or 0.0)
        source = str(total_loss_snapshot.get("current_source", "NONE") or "NONE")
        return False, f"TOTAL_LOSS_STOP_{current_pct:.2f}%>={threshold_pct:.2f}%[{source}]"
    if state.get("consecutive_losses", 0) >= max_consec: return False, "LOSS_LIMIT"
    return True, "APPROVED"

# ============ [DUAL-CALL] RiskManager 对比验证 =============
# 阶段二过渡期：新旧风控同时运行，对比结果，确保 RiskManager 行为与旧逻辑一致
# 确认无误后，可切换到纯 RiskManager，删除旧 governor_check

_RISK_MGR = None  # 延迟加载

def _get_risk_manager():
    """延迟加载 RiskManager（避免循环导入）"""
    global _RISK_MGR
    if _RISK_MGR is None:
        try:
            from risk_manager import GoldRiskManager
            _RISK_MGR = GoldRiskManager()
            _daemon_log("RiskManager loaded successfully")
        except Exception as e:
            _daemon_log(f"RiskManager load failed: {e}")
    return _RISK_MGR


def _compare_risk_results(old_approved: bool, old_reason: str,
                           new_result: dict, signal: dict) -> None:
    """
    对比新旧风控结果，记录差异（用于验证阶段）
    如果新结果与旧结果不一致，打印告警日志
    """
    new_action = new_result.get("payload", {}).get("action", "HOLD")
    new_drop = new_result.get("payload", {}).get("drop_reason")

    # 判定旧系统是否通过
    old_pass = old_approved  # True = APPROVED, False = BLOCKED

    # 判定新系统是否通过
    new_pass = (new_action != "HOLD")

    if old_pass != new_pass:
        _daemon_log(
            f"[RISK MISMATCH] old={'APPROVED' if old_pass else 'BLOCKED:'+old_reason} | "
            f"new={'APPROVED' if new_pass else 'BLOCKED:'+str(new_drop)} | "
            f"signal={signal.get('action')} conf={signal.get('confidence')} | "
            f"trace_id={new_result.get('trace_id')}"
        )


def governor_check_with_rm(price_bid, price_ask, state, signal=None):
    """
    双调风控：同时调用旧 governor_check 和新 RiskManager
    返回：(approved, reason, risk_decision)
    - approved/reason: 旧逻辑结果（保持向后兼容）
    - risk_decision: 新 RiskManager 结果（用于后续流程）
    """
    # 1. 旧逻辑（保持原有行为）
    approved, reason = governor_check(price_bid, price_ask, state)

    # 2. 新逻辑（RiskManager，阶段二验证用）
    rm = _get_risk_manager()
    risk_decision = None

    if rm is not None and signal is not None:
        try:
            # 构造 context
            spread = round((price_ask - price_bid) * 100, 1)
            context = {
                "price_bid": price_bid,
                "price_ask": price_ask,
                "spread": spread,
                "state": state,
                "params": {}
            }

            # 包装 signal（兼容 RiskManager 的 payload 格式）
            wrapped = {
                "schema_version": "2.1.0",
                "trace_id": f"daemon_{int(time.time()*1000)}",
                "strategy_id": "ema_macd_v2",
                "payload": {
                    "action": signal.get("action", "HOLD"),
                    "confidence": signal.get("confidence", 0),
                    "side": signal.get("side", "BUY"),
                    "entry": signal.get("entry", 0),
                    "sl": signal.get("sl", 0),
                    "tp": signal.get("tp", 0),
                    "indicators": signal.get("indicators", {}),
                }
            }

            risk_decision = rm.evaluate(wrapped, context)

            # 对比结果（只在有信号时对比）
            if signal.get("action") != "HOLD":
                _compare_risk_results(approved, reason, risk_decision, signal)

        except Exception as e:
            _daemon_log(f"RiskManager evaluate error: {e}")

    # 3. 返回旧逻辑结果（保持原有流程不变）
    return approved, reason, risk_decision


# ============ Agent 复核系统 ============
def write_review_request(signal_data, reason):
    """写入复核请求，已有同内容待复核则跳过"""
    REVIEW_DIR.mkdir(parents=True, exist_ok=True)

    # ── 僵尸复核文件自动清理（价格漂移 + 超时）──────────────
    # 获取当前价格用于漂移判断
    try:
        cur_bid, cur_ask, _, _, _ = get_price()
    except Exception:
        cur_bid = cur_ask = 0

    try:
        for rf in list(REVIEW_DIR.glob("*.review.json")):
            try:
                data = json.loads(rf.read_text())
                # 超时清理：底层是 5 分钟 K 线模型，超过 3 根 K 线（15分钟）直接视为失效
                ts = data.get("timestamp", "")
                if ts:
                    try:
                        file_age = time.time() - datetime.fromisoformat(ts.replace("Z","+00:00")).timestamp()
                        if file_age > 900:  # 15分钟 (900秒)
                            rf.unlink(missing_ok=True)
                            _daemon_log(f"[GC] Removed stale review (age={file_age/60:.1f}m > 15m): {rf.name}")
                            continue
                    except Exception:
                        pass
                # 漂移清理：当前价格已偏离 entry，因为是短线模型，超过 1.5×ATR 即视为错过入场点
                sig_entry = data.get("entry", 0)
                sig_atr = data.get("indicators", {}).get("atr14", 0) or 0
                if sig_entry > 0 and sig_atr > 0 and cur_bid > 0:
                    drift = abs(cur_bid - sig_entry)
                    if drift > sig_atr * 1.5:
                        rf.unlink(missing_ok=True)
                        _daemon_log(f"[GC] Removed drifted review: {rf.name} drift={drift:.1f} ({sig_atr*1.5:.1f}ATR threshold)")
            except Exception:
                pass
    except Exception:
        pass

    # 根据 signal 内容生成稳定 ID（同一信号不重复创建 review）
    entry = signal_data.get("entry", 0)
    sl = signal_data.get("sl", 0)
    tp = signal_data.get("tp", 0)
    side = signal_data.get("side", "?")

    # ── TP/SL 比例自动校验（关键防线）──────────────────────────
    # 修复 Bug 1：方向性验证，确保止损止盈在正确的物理位置
    if side == "BUY" and (sl >= entry or tp <= entry):
        _log(f"[AUDIT] ❌ 价格倒挂！BUY entry={entry}, sl={sl} (应<entry), tp={tp} (应>entry)，拒绝写入复核", "ERROR")
        return None
    if side == "SELL" and (sl <= entry or tp >= entry):
        _log(f"[AUDIT] ❌ 价格倒挂！SELL entry={entry}, sl={sl} (应>entry), tp={tp} (应<entry)，拒绝写入复核", "ERROR")
        return None

    atr = signal_data.get("indicators", {}).get("atr14", 0) or 0
    if atr > 0:
        sl_dist = abs(entry - sl)
        tp_dist = abs(tp - entry)
        rr = tp_dist / max(sl_dist, 0.001)
        if rr < 1.0:
            _log(f"[AUDIT] ⚠️ TP/SL盈亏比{rr:.2f}<1.0偏低（TP={tp_dist:.1f}点 SL={sl_dist:.1f}点），标记低RR", "WARN")
    # 用 entry+sl+tp 组合做 ID，同一信号不会重复创建
    stable_id = f"review_{side}_{round(entry,1)}_{round(sl,1)}_{round(tp,1)}"

    rf = REVIEW_DIR / f"{stable_id}.review.json"
    # 已有且未处理的 review 不重复写入
    if rf.exists():
        existing = {}
        try: existing = json.loads(rf.read_text())
        except: pass
        if existing.get("status") == "PENDING":
            _daemon_log(f"Review already pending: {stable_id}, skipping duplicate")
            return stable_id  # 已有待复核，跳过

    sig_id = f"{stable_id}_{int(time.time())}"  # 加时间戳防冲突
    rf.write_text(json.dumps({
        "signal_id": sig_id,
        "stable_id": stable_id,
        "decision_id": signal_data.get("_learning_decision_id", signal_data.get("signal_id", sig_id)),
        "action": signal_data.get("action"),
        "side": side,
        "entry": entry,
        "sl": sl,
        "tp": tp,
        "confidence": signal_data.get("confidence", 0),
        "regime": signal_data.get("regime", "?"),
        "reason": reason,
        "indicators": signal_data.get("indicators", {}),
        "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
        "status": "PENDING",
    }, indent=2))
    _daemon_log(f"Review request: {sig_id} reason={reason}")
    return sig_id

def check_review_approved_any():
    """
    检查是否有任何待处理的 approve/reject 文件。
    返回: ("APPROVED", sig_id, stable_id) / ("REJECTED", sig_id, stable_id) / (None, None, None)
    """
    # ── 增加僵尸文件自动清理 (Garbage Collection) ──
    now = time.time()
    try:
        if REVIEW_DIR.exists():
            for pending_f in REVIEW_DIR.glob("*.review.json"):
                # 5分钟模型信号最长只保留 15 分钟
                if now - pending_f.stat().st_mtime > 900:
                    _daemon_log(f"🧹 清理过期僵尸复核文件: {pending_f.name}")
                    pending_f.unlink(missing_ok=True)
    except Exception as e:
        _daemon_log(f"清理僵尸复核文件出错: {e}")

    for d in [REVIEW_DIR / "approve", REVIEW_DIR / "reject"]:
        if not d.exists(): continue
        files = sorted(d.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True)
        if files:
            f = files[0]
            try:
                data = json.loads(f.read_text())
                decision = "APPROVED" if "approve" in str(d) else "REJECTED"
                sig_id = data.get("signal_id", f.name)
                stable_id = data.get("stable_id", sig_id)
                f.unlink(missing_ok=True)
                return decision, sig_id, stable_id
            except:
                pass
    return None, None, None

def process_review_result(decision, sig_id, stable_id=None):
    """处理 approve/reject 结果"""
    if decision == "APPROVED":
        # 找到对应的 review 文件（用 stable_id 定位）
        try:
            rf = REVIEW_DIR / f"{stable_id or sig_id}.review.json"
            if rf.exists():
                sig = json.loads(rf.read_text())
                if _block_legacy_review_open(sig, sig_id=sig_id, stable_id=stable_id):
                    rf.write_text(json.dumps({**sig, "status": "BLOCKED_LEGACY_REVIEW_OPEN_DISABLED"}, indent=2))
                    return
                decision_id = sig.get("decision_id", stable_id or sig_id)
                _transition_decision_state(
                    decision_id,
                    "EXECUTING",
                    reason="REVIEW_APPROVED",
                    review_signal_id=sig.get("signal_id", sig_id),
                    stable_id=sig.get("stable_id", stable_id or sig_id),
                    review_reason=sig.get("reason", ""),
                )
                _log(f"✅ Review批准: {sig.get('action')} {sig.get('side')} conf={sig.get('confidence',0)*100:.0f}% → 执行交易", "WARN")
                write_trade_request(
                    sig.get("action", "OPEN"),
                    sig.get("side", "BUY"),
                    sig.get("entry", 0),
                    sig.get("sl", 0),
                    sig.get("tp", 0),
                    sig_id=sig.get("signal_id", sig_id),
                    decision_id=decision_id,
                    stable_id=sig.get("stable_id", stable_id or sig_id),
                    request_origin="review_approved",
                )
                rf.write_text(json.dumps({**sig, "status": "APPROVED_BY_REVIEW"}, indent=2))
        except Exception as e:
            _daemon_log(f"process_review_result error: {e}")
    elif decision == "REJECTED":
        try:
            rf = REVIEW_DIR / f"{stable_id or sig_id}.review.json"
            if rf.exists():
                sig = json.loads(rf.read_text())
                decision_id = sig.get("decision_id", stable_id or sig_id)
                _close_decision_state(
                    decision_id,
                    "VETOED",
                    reason="REVIEW_REJECTED",
                    review_signal_id=sig.get("signal_id", sig_id),
                    stable_id=sig.get("stable_id", stable_id or sig_id),
                    review_reason=sig.get("reason", ""),
                )
                rf.write_text(json.dumps({**sig, "status": "REJECTED_BY_REVIEW"}, indent=2))
        except Exception as e:
            _daemon_log(f"process_review_result reject error: {e}")
        _log(f"❌ Review否决: sig_id={sig_id}", "WARN")

def check_review_result(sig_id):
    for d in [REVIEW_DIR / "approve", REVIEW_DIR / "reject"]:
        if (d / f"{sig_id}.json").exists(): return "APPROVED" if "approve" in str(d) else "REJECTED"
    return "PENDING"

def wait_for_agent_review(sig_id, timeout=120): # 修改超时时间为 120 秒，对齐 Agent 的修复
    waited = 0
    while waited < timeout:
        time.sleep(10); waited += 10
        result = check_review_result(sig_id)
        if result in ("APPROVED", "REJECTED"):
            _daemon_log(f"Review done: {sig_id} => {result}")
            return result
    _daemon_log(f"Review timeout: {sig_id}")
    return "TIMEOUT"

# ============ 策略模块（只分析，不决策方向）============
def generate_signal(price_bid, price_ask, state):
    """生成技术分析结果，方向由 Agent 决定"""
    # ========== [SIGNAL LAYER] ==========
    # 统一使用新的 SignalGenerator
    try:
        from bridge.strategy import _read_bars
        bars = _read_bars(200)
        
        # 记录系统活跃心跳
        global _last_active_action
        try: _last_active_action = time.time()
        except NameError: pass

        if not bars or len(bars) < 50:
            return {
                "action": "HOLD", "regime": "UNCERTAIN",
                "confidence": 0, "reason": "K线数据不足(EA未响应)",
                "indicators": {}, "session": {},
                "volume_lots": _get_effective_param("volume_lots", 0.05)
            }

        # ── 市场感知策略选择（自动切换）───────────────────────────────────
        from src.market_structure import load_structure_snapshot
        structure = load_structure_snapshot(valid_only=True)
        regime_for_sel = _detect_regime_for_selection(bars)
        base_strategy = _get_best_strategy_for_regime(regime_for_sel)
        best_strategy, strategy_reason = _resolve_strategy_with_structure(
            regime_for_sel,
            base_strategy,
            structure,
        )
        if best_strategy != base_strategy:
            _daemon_log(
                f"[AutoStrategy] 细分结构覆盖: {base_strategy} -> {best_strategy} "
                f"(regime={regime_for_sel}, reason={strategy_reason})"
            )
        gen = _get_cached_generator(best_strategy)
        if gen is None:
            _daemon_log(f"[CRITICAL] SignalGenerator 创建失败 '{best_strategy}'，回退到 ema_macd")
            gen = _get_cached_generator("ema_macd")
            if gen is None:
                return {
                    "action": "HOLD", "regime": "UNCERTAIN",
                    "confidence": 0, "reason": "SignalGenerator unavailable",
                    "indicators": {}, "session": {},
                    "volume_lots": _get_effective_param("volume_lots", 0.05)
                }

        # 获取 GARCH vol_ratio，用于动态缩放信号层的 ATR 距离
        try:
            from bridge.garch_volatility import get_vol_multiplier
            vol_info = get_vol_multiplier()
            vol_ratio = vol_info.get("vol_ratio", 1.0)
        except Exception:
            vol_ratio = 1.0

        # 确定交易时段，传入信号发生器做 SL/TP 会话调整
        try:
            from bridge.session_analyzer import get_current_session
            _session_for_sig = get_current_session()
        except Exception:
            _session_for_sig = "NORMAL"

        try:
            sig_result = gen.analyze(bars, vol_ratio=vol_ratio, session_mode=_session_for_sig)
        except TypeError as e:
            estr = str(e)
            if "session_mode" in estr:
                try:
                    sig_result = gen.analyze(bars, vol_ratio=vol_ratio)
                except TypeError:
                    sig_result = gen.analyze(bars)
            elif "vol_ratio" in estr:
                try:
                    sig_result = gen.analyze(bars, session_mode=_session_for_sig)
                except TypeError:
                    sig_result = gen.analyze(bars)
            else:
                try:
                    sig_result = gen.analyze(bars)
                except Exception:
                    raise
        except Exception as e:
            _daemon_log(f"[CRITICAL] SignalGenerator.analyze 崩溃: {e}")
            import traceback
            _daemon_log(traceback.format_exc())
            return {
                "action": "HOLD", "regime": "UNCERTAIN",
                "confidence": 0, "reason": f"Signal Error: {e}",
                "indicators": {}, "session": {},
                "volume_lots": _get_effective_param("volume_lots", 0.05)
            }

        payload = sig_result.get("payload", {})
        raw_action = str(payload.get("action", "HOLD") or "HOLD").upper()
        action = raw_action
        if raw_action in ("BUY", "SELL"):
            payload["side"] = raw_action
            action = "OPEN"
        elif raw_action != "OPEN":
            action = "HOLD"
        regime = payload.get("indicators", {}).get("regime", "UNCERTAIN")

        # HOLD 二次路由：当主策略在震荡环境持续给出结构性 HOLD 时，
        # 尝试用备选策略复核，减少长时间无交易。
        hold_reason = str(payload.get("reason", "") or "")
        if action == "HOLD":
            hold_ind = payload.get("indicators", {}) if isinstance(payload.get("indicators", {}), dict) else {}
            hold_adx = float(hold_ind.get("adx", 0.0) or 0.0)
            hold_trend_strength = str(hold_ind.get("trend_strength", "") or "").upper()
            spring_fake_breakout_hold = (
                "无弹簧/假突破结构" in hold_reason
                or "无区间突破信号" in hold_reason
            )
            confirmation_state = str(structure.get("confirmation_state", "UNKNOWN") or "UNKNOWN").upper()
            entry_timing_hint = str(structure.get("entry_timing_hint", "WAIT") or "WAIT").upper()
            allow_fallback = regime_for_sel in {
                "DRIFTING",
                "TRENDING",
                "BREAKOUT",
                "SHOCK_REPRICING",
                "EXTREME_TREND",
            } and confirmation_state in {"CONFIRMED", "ACTIVE", "READY"} and entry_timing_hint not in {"WAIT", "LATE"}
            if allow_fallback:
                fallback_conf_floor = 0.78
                if regime_for_sel in {"TRENDING", "BREAKOUT", "SHOCK_REPRICING", "EXTREME_TREND"} and (
                    hold_adx >= 24 or hold_trend_strength in {"TRENDING", "STRONG_TREND"}
                ):
                    fallback_conf_floor = 0.74
                if spring_fake_breakout_hold:
                    allow_fallback = False

            if allow_fallback:
                candidate_chain = _build_fallback_candidates(best_strategy, structure)
                # 统一恢复链：确保 HOLD 不会被单策略长期锁死。
                for s in ("ema_macd", "composite", "wyckoff"):
                    if s != best_strategy and s not in candidate_chain:
                        candidate_chain.append(s)
                if spring_fake_breakout_hold:
                    # 先跑数学趋势策略，避免再次卡在“无弹簧/假突破结构”理由。
                    reordered = [s for s in ("ema_macd", "composite", "wyckoff") if s in candidate_chain]
                    candidate_chain = reordered + [s for s in candidate_chain if s not in reordered]

                for backup_strategy in candidate_chain:
                    backup_gen = _get_cached_generator(backup_strategy)
                    if backup_gen is None:
                        continue
                    try:
                        backup_result = backup_gen.analyze(bars, vol_ratio=vol_ratio)
                    except TypeError:
                        try:
                            backup_result = backup_gen.analyze(bars)
                        except Exception:
                            continue
                    backup_payload = backup_result.get("payload", {}) if isinstance(backup_result, dict) else {}
                    backup_action = str(backup_payload.get("action", "HOLD") or "HOLD").upper()
                    backup_conf = float(backup_payload.get("confidence", 0.0) or 0.0)
                    if backup_action in {"OPEN", "BUY", "SELL"} and backup_conf >= fallback_conf_floor:
                        _daemon_log(
                            f"[AutoStrategy] HOLD fallback 生效: {best_strategy} -> {backup_strategy}, "
                            f"conf={backup_conf:.2f} (floor={fallback_conf_floor:.2f})"
                        )
                        sig_result = backup_result
                        payload = backup_payload
                        raw_action = backup_action
                        action = "OPEN" if backup_action in {"OPEN", "BUY", "SELL"} else "HOLD"
                        if backup_action in {"BUY", "SELL"}:
                            payload["side"] = backup_action
                        regime = payload.get("indicators", {}).get("regime", regime)
                        best_strategy = backup_strategy
                        break

        if action == "OPEN":
            side = payload.get("side", "BUY")
            entry = price_ask if side == "BUY" else price_bid
            return {
                "action": "OPEN",
                "side": side,
                "regime": regime,
                "confidence": payload.get("confidence", 0),
                "entry": entry,
                "sl": payload.get("sl", 0),
                "tp": payload.get("tp", 0),
                "reason": payload.get("reason", ""),
                "indicators": payload.get("indicators", {}),
                "session": {},
                "volume_lots": _get_effective_param("volume_lots", 0.05),
                "_sig_trace_id": sig_result.get("trace_id"),
                "_sig_strategy_id": sig_result.get("strategy_id"),
                "_selection_regime": regime_for_sel,
                "_selected_strategy": best_strategy,
                "_sig_raw_action": raw_action,
            }
        else:
            hold_reason_final = str(payload.get("reason", "") or "")
            if "无弹簧/假突破结构" in hold_reason_final or "无区间突破信号" in hold_reason_final:
                hold_reason_final = "结构观察中（非阻断），等待动量/突破确认"
            return {
                "action": "HOLD",
                "regime": regime,
                "confidence": payload.get("confidence", 0),
                "reason": hold_reason_final,
                "indicators": payload.get("indicators", {}),
                "session": {},
                "volume_lots": _get_effective_param("volume_lots", 0.05),
                "_sig_trace_id": sig_result.get("trace_id"),
                "_sig_strategy_id": sig_result.get("strategy_id"),
                "_selection_regime": regime_for_sel,
                "_selected_strategy": best_strategy,
                "_sig_raw_action": raw_action,
            }

    except Exception as e:
        _daemon_log(f"SIGNAL LAYER error: {e}")
        return {"action": "HOLD", "error": str(e)}


# ============ [SIGNAL LAYER] 数据获取（已迁移至 strategy.py）============
# yfinance 已彻底移除，数据源统一为 MT5 EA Bridge via strategy._read_bars()


# ============ [SIGNAL LAYER] 生成器实例（延迟加载）============
_SIGNAL_GEN = None

# gridopt参数映射（effective_params的key → SignalGenerator的key）
_PARAM_MAP = {
    "ema_short": "ema_short",
    "ema_long": "ema_long",
    "sl_atr": "sl_atr_mult_trend",
    "tp_atr": "tp_atr_mult_trend",
    # 新的 regime-specific 参数（直接映射）
    "sl_atr_mult_trend": "sl_atr_mult_trend",
    "tp_atr_mult_trend": "tp_atr_mult_trend",
    "sl_atr_mult_range": "sl_atr_mult_range",
    "tp_atr_mult_range": "tp_atr_mult_range",
}

# ─────────────────────────────────────────────────────────────────────────────
# PARAM_SCHEMA: 信号生成器完整参数宪章
# 所有外部来源的参数在被应用到生成器之前，必须经过此 Schema 验证。
# 验证失败的参数将被拒绝（rejected），不会静默钳制。
# ─────────────────────────────────────────────────────────────────────────────
PARAM_SCHEMA = {
    # 周期性参数（类型: int）
    "ema_short":          {"type": int,   "min": 5,   "max": 50,  "default": 9,   "desc": "EMA 短期周期"},
    "ema_long":           {"type": int,   "min": 15,  "max": 100, "default": 21,  "desc": "EMA 长期周期"},
    "ema50":              {"type": int,   "min": 30,  "max": 200, "default": 50,  "desc": "EMA50 周期"},
    "rsi_period":         {"type": int,   "min": 7,   "max": 21,  "default": 14,  "desc": "RSI 周期"},
    "macd_fast":          {"type": int,   "min": 5,   "max": 20,  "default": 12,  "desc": "MACD 快线周期"},
    "macd_slow":          {"type": int,   "min": 15,  "max": 50,  "default": 26,  "desc": "MACD 慢线周期"},
    "atr_period":          {"type": int,   "min": 7,   "max": 30,  "default": 14,  "desc": "ATR 周期"},
    "bb_period":           {"type": int,   "min": 10,  "max": 50,  "default": 20,  "desc": "布林带周期"},
    # 乘数参数（类型: float）
    "sl_atr_mult_trend":  {"type": float, "min": 0.5, "max": 3.0, "default": 1.5, "desc": "趋势模式 SL ATR 倍数"},
    "tp_atr_mult_trend":  {"type": float, "min": 1.0, "max": 5.0, "default": 2.5, "desc": "趋势模式 TP ATR 倍数"},
    "sl_atr_mult_range":  {"type": float, "min": 0.3, "max": 2.0, "default": 0.5, "desc": "震荡模式 SL ATR 倍数"},
    "tp_atr_mult_range":  {"type": float, "min": 0.5, "max": 3.0, "default": 0.75,"desc": "震荡模式 TP ATR 倍数"},
    "bb_std":              {"type": float, "min": 1.0, "max": 3.0, "default": 2.0, "desc": "布林带标准差倍数"},
}


def _validate_params(suggestions):
    """
    验证外部参数建议列表，返回通过/拒绝分类。
    
    验证逻辑（拒绝优于钳制）：
    1. 参数必须存在于 PARAM_SCHEMA 中，否则拒绝（未知参数不可接受）
    2. 值类型必须与 Schema 一致，否则拒绝（不接受隐式转换）
    3. 值必须在 [min, max] 范围内，否则拒绝（不接受静默钳制）
    4. 通过全部检查的参数，以 (key, typed_value) 形式返回
    
    Returns:
        (validated: dict, rejected: list[dict], warnings: list[str])
    """
    validated = {}
    rejected = []
    warnings = []

    for s in suggestions:
        param = s.get("param")
        raw_val = s.get("applied_value")
        source = s.get("id", "?")
        note = s.get("note", "")

        # ── 检查1：参数是否在 Schema 中 ──
        if param not in _PARAM_MAP:
            rejected.append({
                "param": param, "value": raw_val, "source": source,
                "reason": f"未知参数（不在 PARAM_SCHEMA 中），拒绝加载"
            })
            continue

        gen_key = _PARAM_MAP[param]
        if gen_key not in PARAM_SCHEMA:
            rejected.append({
                "param": param, "value": raw_val, "source": source,
                "reason": f"参数映射到 '{gen_key}' 但该参数不在 Schema 中，拒绝加载"
            })
            continue

        schema = PARAM_SCHEMA[gen_key]
        expected_type = schema["type"]
        lo, hi = schema["min"], schema["max"]
        default = schema["default"]

        # ── 检查2：类型是否正确 ──
        type_ok = False
        typed_val = None
        if expected_type == int:
            if isinstance(raw_val, int) and not isinstance(raw_val, bool):
                typed_val = raw_val
                type_ok = True
            elif isinstance(raw_val, float) and raw_val == int(raw_val):
                typed_val = int(raw_val)
                warnings.append(f"[{gen_key}] 类型警告: float({raw_val})→int({typed_val})，建议修复数据源")
                type_ok = True
            elif isinstance(raw_val, str) and raw_val.isdigit():
                typed_val = int(raw_val)
                type_ok = True
        elif expected_type == float:
            if isinstance(raw_val, (int, float)) and not isinstance(raw_val, bool):
                typed_val = float(raw_val)
                type_ok = True
            elif isinstance(raw_val, str):
                try:
                    typed_val = float(raw_val)
                    type_ok = True
                except ValueError:
                    pass

        if not type_ok:
            rejected.append({
                "param": param, "value": raw_val, "source": source,
                "reason": f"类型错误: 期望 {expected_type.__name__}，实际 {type(raw_val).__name__}，拒绝加载"
            })
            continue

        # ── 检查3：值是否在允许范围内 ──
        if not (lo <= typed_val <= hi):
            rejected.append({
                "param": param, "value": raw_val, "source": source,
                "reason": f"值 {typed_val} 超出安全范围 [{lo}, {hi}]，拒绝加载（将使用默认值 {default}）"
            })
            continue

        validated[gen_key] = typed_val

    return validated, rejected, warnings


def _apply_effective_params_to_generator(gen):
    """
    从 effective_params.json 读取参数建议，Schema 验证后应用到信号生成器。
    所有外部参数必须经过 PARAM_SCHEMA 验证——验证失败 = 拒绝，不做钳制。
    """
    try:
        eff_file = LOGS_DIR / "effective_params.json"
        if not eff_file.exists():
            _daemon_log("effective_params.json 不存在，使用生成器默认值")
            return

        import json
        data = json.loads(eff_file.read_text())
        suggestions = data.get("suggestions", [])
        if not suggestions:
            _daemon_log("effective_params.json 为空，使用生成器默认值")
            return

        # ── 第一步：Schema 验证（拒绝优于钳制）──
        validated, rejected, warnings = _validate_params(suggestions)

        # 记录所有被拒绝的参数（这些参数不会被应用）
        for r in rejected:
            _daemon_log(
                f"[SCHEMA ❌ REJECTED] param={r['param']} value={r['value']} "
                f"source={r['source']} | reason={r['reason']}"
            )

        for w in warnings:
            _daemon_log(f"[SCHEMA ⚠️ WARNING] {w}")

        # ── 第二步：仅应用通过验证的参数 ──
        applied = []
        for gen_key, val in validated.items():
            old_val = gen.params.get(gen_key)
            gen.params[gen_key] = val
            applied.append(f"{gen_key}={old_val}→{val}")

        if applied:
            _daemon_log(f"[SCHEMA ✅ APPLIED] {', '.join(applied)}")
        else:
            _daemon_log("[SCHEMA] 所有外部参数均被拒绝，使用生成器默认值")

    except Exception as e:
        _daemon_log(f"_apply_effective_params_to_generator failed: {e}")


def _effective_strategy_payload() -> dict:
    eff_file = LOGS_DIR / "effective_params.json"
    if not eff_file.exists():
        return {}
    try:
        import json as _json

        return _json.loads(eff_file.read_text())
    except Exception:
        return {}


def _effective_strategy_type(default: str = "ema_macd") -> str:
    if _MANUAL_STRATEGY_OVERRIDE:
        return str(_MANUAL_STRATEGY_OVERRIDE or default)
    payload = _effective_strategy_payload()
    return str(payload.get("strategy_type", default) or default)


def set_strategy_override(strategy_type: Optional[str] = None) -> Optional[str]:
    global _MANUAL_STRATEGY_OVERRIDE
    _MANUAL_STRATEGY_OVERRIDE = str(strategy_type).strip() if strategy_type else None
    return _MANUAL_STRATEGY_OVERRIDE


def _runtime_identity_from_generator(gen) -> dict:
    runtime_identity = {}
    try:
        identity = getattr(gen, "get_runtime_identity", None)
        if callable(identity):
            runtime_identity = identity() or {}
    except Exception:
        runtime_identity = {}
    return runtime_identity if isinstance(runtime_identity, dict) else {}

def _get_signal_generator():
    """延迟加载 SignalGenerator（避免循环导入）"""
    global _SIGNAL_GEN
    if _SIGNAL_GEN is None:
        try:
            strategy_type = _effective_strategy_type("ema_macd")
            _SIGNAL_GEN = get_strategy_runtime().get_legacy_compatible_generator(strategy_type)
            _daemon_log(f"SignalGenerator loaded: {strategy_type}")
            # 应用 effective_params（gridopt最优参数）
            _apply_effective_params_to_generator(_SIGNAL_GEN)
        except Exception as e:
            _daemon_log(f"SignalGenerator load failed: {e}")
            _SIGNAL_GEN = None
    if _SIGNAL_GEN is None:
        _daemon_log(f"[WARN] _SIGNAL_GEN still None after load attempt, using ema_macd direct")
        _SIGNAL_GEN = get_strategy_runtime().get_legacy_compatible_generator("ema_macd")
    return _SIGNAL_GEN


def reset_signal_generator():
    """
    热重置：清除缓存的下单生成器，下次调用 _get_signal_generator() 时重新加载。
    用于策略切换（无需重启进程）。
    """
    global _SIGNAL_GEN
    old = _SIGNAL_GEN.__class__.__name__ if _SIGNAL_GEN else "None"
    _SIGNAL_GEN = None
    try:
        get_strategy_runtime().reset()
    except Exception:
        pass
    if _SIGNAL_GEN is None:
        _STRATEGY_CACHE.clear()
    _daemon_log(f"SignalGenerator reset triggered (was: {old})")
    return old


def get_signal_generator_status() -> dict:
    """返回当前策略状态（不触发加载）"""
    if _SIGNAL_GEN is None:
        return {"status": "not_loaded", "strategy_type": None, "strategy_name": None}
    runtime_identity = _runtime_identity_from_generator(_SIGNAL_GEN)
    return {
        "status": "loaded",
        "strategy_name": _SIGNAL_GEN.__class__.__name__,
        "strategy_id": _SIGNAL_GEN.get_strategy_id(),
        "strategy_version": runtime_identity.get("strategy_version"),
        "config_revision": runtime_identity.get("config_revision"),
        "params": _SIGNAL_GEN.params if hasattr(_SIGNAL_GEN, "params") else None,
    }

# ── 市场感知自动切换 ─────────────────────────────────────────────────────────
# 市场状态 → 最佳策略映射
REGIME_STRATEGY_MAP = {
    "EXTREME_TREND": "ema_macd",      # 极端趋势 → 快速顺势确认
    "BREAKOUT":       "ema_macd",     # 突破 → EMA/MACD 快速确认
    "SHOCK_REPRICING": "ema_macd",
    "TRENDING":        "ema_macd",    # 趋势 → EMA/MACD 顺势
    "RANGING":        "wyckoff",      # 震荡 → Wyckoff 区间交易
    "UNCERTAIN":      "composite",    # 数据不足 → composite 多策略聚合
    "NARROW_RANGE":   "composite",    # 窄幅震荡：避免单一结构信号过长 HOLD
    "WIDE_RANGE":     "wyckoff",      # 宽幅震荡：保留 Wyckoff
    "FLAG_CONSOLIDATION": "ema_macd", # 旗形整理：优先顺势突破确认
    "DRIFTING":       "ema_macd",     # 温和漂移趋势：EMA/MACD 更稳健
}

# 细分子结构 → 策略映射（优先级高于大类 regime）
SUBTYPE_STRATEGY_MAP = {
    "FLAG_COMPRESSION": "ema_macd",
    "TREND_FIRST_PULLBACK": "ema_macd",
    "REPRICING_IMPULSE": "ema_macd",
    "DRIFTING_CHANNEL": "ema_macd",
    "WIDE_BOX": "wyckoff",
    "NARROW_BOX": "composite",
}

# 策略黑名单：某些策略在特定市场表现差，主动回避
REGIME_BLACKLIST = {
    "wyckoff":      ["EXTREME_TREND", "BREAKOUT", "SHOCK_REPRICING"],  # Wyckoff 需要区间，爆发市场无区间
}

# 缓存策略实例（避免每次重建）
_STRATEGY_CACHE: dict = {}
_AUTO_SELECT_ENABLED = True  # 设为 False 可关闭自动切换
_MANUAL_STRATEGY_OVERRIDE: Optional[str] = None


def _detect_regime_for_selection(bars: list) -> str:
    """用简化指标判断当前市场状态"""
    try:
        from signal_generators.ema_macd_signal import _adx, _atr
        from src.market_structure import load_structure_snapshot
        if len(bars) < 50:
            return "UNCERTAIN"
        structure = load_structure_snapshot(valid_only=True)
        market_structure = str(structure.get("market_structure", "UNCERTAIN")).upper()
        channel_state = str(structure.get("channel_state", "UNKNOWN")).upper()
        channel_drift_bias = str(structure.get("channel_drift_bias", "NONE")).upper()
        if market_structure in ("SHOCK_REPRICING", "FIRST_PULLBACK"):
            return "SHOCK_REPRICING"
        if channel_state == "FLAG_CONSOLIDATION":
            return "FLAG_CONSOLIDATION"
        if market_structure == "RANGE_CHOP":
            if channel_state == "NARROW_RANGE":
                return "NARROW_RANGE"
            if channel_state == "WIDE_RANGE":
                return "WIDE_RANGE"
            return "RANGING"
        sample = bars[-min(200, len(bars)):]
        adx_value, _, _ = _adx(sample)
        atr_series = _atr(sample)
        latest = bars[-1]
        atr_val = next((float(v) for v in reversed(atr_series) if v is not None), 1.0)
        atr_valid = [float(v) for v in atr_series[-20:] if v is not None]
        atr_baseline = (sum(atr_valid) / len(atr_valid)) if atr_valid else atr_val
        atr_ratio = (atr_val / atr_baseline) if atr_baseline else 1.0
        body = abs(latest["close"] - latest["open"])
        bar_range = latest["high"] - latest["low"]
        if max(body, bar_range) > 2.4 * atr_val and adx_value >= 28:
            return "EXTREME_TREND"
        if atr_ratio >= 1.35 and bar_range > 1.2 * atr_val and body > 0.7 * bar_range:
            return "SHOCK_REPRICING"

        recent = bars[-49:-1]
        high_4h = max(b["high"] for b in recent) if recent else latest["high"]
        low_4h = min(b["low"] for b in recent) if recent else latest["low"]
        if (latest["close"] > high_4h or latest["close"] < low_4h) and (adx_value >= 20 or bar_range > 1.2 * atr_val):
            return "BREAKOUT"

        if adx_value >= 25:
            return "TRENDING"
        elif adx_value >= 18:
            return "RANGING"
        elif channel_drift_bias in {"BULLISH", "BEARISH"} and 14 <= adx_value < 22:
            return "DRIFTING"
        else:
            return "UNCERTAIN"
    except Exception:
        return "UNCERTAIN"


def _get_best_strategy_for_regime(regime: str) -> str:
    """根据市场状态返回最佳策略类型"""
    if not _AUTO_SELECT_ENABLED:
        # 关闭时用配置文件中指定策略
        return _effective_strategy_type("ema_macd")

    strategy = REGIME_STRATEGY_MAP.get(regime, "ema_macd")
    blacklist = REGIME_BLACKLIST.get(strategy, [])
    if regime in blacklist:
        # 黑名单策略 → 回退到 composite
        return "composite"
    return strategy


def _resolve_strategy_with_structure(regime: str, default_strategy: str, structure: dict) -> tuple[str, str]:
    """让细分标签直接驱动策略选择，避免只靠 regime 大类。"""
    if not isinstance(structure, dict):
        return default_strategy, "no-structure"

    regime_subtype = str(structure.get("regime_subtype", "STRUCTURE_UNCLEAR") or "STRUCTURE_UNCLEAR").upper()
    volatility_state = str(structure.get("volatility_state", "NORMAL") or "NORMAL").upper()
    liquidity_state = str(structure.get("liquidity_state", "NORMAL") or "NORMAL").upper()
    entry_timing_hint = str(structure.get("entry_timing_hint", "WAIT_CLEAR_SIGNAL") or "WAIT_CLEAR_SIGNAL").upper()
    llm_conf = float(structure.get("llm_confidence", 0.55) or 0.55)
    market_structure = str(structure.get("market_structure", "UNCERTAIN") or "UNCERTAIN").upper()

    # LLM 自身低置信或 whipsaw 流动性：降级到 composite，防止误激进。
    if llm_conf <= 0.42 or liquidity_state == "WHIPSAW":
        return "composite", f"guard:llm={llm_conf:.2f}|liq={liquidity_state}"

    # 子结构优先于大类 regime。
    if regime_subtype in SUBTYPE_STRATEGY_MAP:
        return SUBTYPE_STRATEGY_MAP[regime_subtype], f"subtype:{regime_subtype}"

    # 时机标签进一步修正执行策略。
    if entry_timing_hint == "CONFIRM_BREAK":
        return "ema_macd", "timing:confirm_break"
    if entry_timing_hint == "WAIT_PULLBACK" and market_structure in {"NORMAL_TREND", "FIRST_PULLBACK"}:
        return "ema_macd", "timing:wait_pullback"

    # 低波动+薄流动：尽量避免单策略长时间 HOLD。
    if volatility_state in {"ULTRA_LOW", "LOW"} and liquidity_state == "THIN":
        return "composite", "low_vol_thin_liq"

    return default_strategy, f"regime:{regime}"


def _build_fallback_candidates(primary: str, structure: dict) -> list[str]:
    """按细分结构给出备选策略链，减少 HOLD 卡死。"""
    regime_subtype = str((structure or {}).get("regime_subtype", "STRUCTURE_UNCLEAR") or "STRUCTURE_UNCLEAR").upper()
    entry_timing_hint = str((structure or {}).get("entry_timing_hint", "WAIT_CLEAR_SIGNAL") or "WAIT_CLEAR_SIGNAL").upper()

    chain: list[str] = []
    if regime_subtype in {"FLAG_COMPRESSION", "REPRICING_IMPULSE"} or entry_timing_hint == "CONFIRM_BREAK":
        chain = ["ema_macd", "composite", "wyckoff"]
    elif regime_subtype in {"WIDE_BOX", "REVERSAL_SETUP"}:
        chain = ["wyckoff", "composite", "ema_macd"]
    elif regime_subtype in {"TREND_FIRST_PULLBACK", "DRIFTING_CHANNEL"}:
        chain = ["ema_macd", "composite", "wyckoff"]
    else:
        chain = ["ema_macd", "composite", "wyckoff"]

    ordered = [s for s in chain if s != primary]
    return ordered


def _get_cached_generator(strategy_type: str):
    """获取或创建策略实例（带缓存）"""
    if strategy_type not in _STRATEGY_CACHE:
        try:
            _STRATEGY_CACHE.clear()  # 每次只缓存一个
            _STRATEGY_CACHE[strategy_type] = get_strategy_runtime().get_legacy_compatible_generator(
                strategy_type,
                force_reload=True,
            )
            _apply_effective_params_to_generator(_STRATEGY_CACHE[strategy_type])
            _daemon_log(f"[AutoStrategy] 市场驱动 → 策略切换: {strategy_type}")
        except Exception as e:
            _daemon_log(f"[AutoStrategy] 策略创建失败 {strategy_type}: {e}")
            return None
    return _STRATEGY_CACHE[strategy_type]


# ============ EA 响应处理 ============
def process_ea_reports(state):
    def _parse_est_profit(text: str):
        if not text:
            return None
        m = re.search(r"ALL_CLOSED_EST_PROFIT_(-?\d+(?:\.\d+)?)", text)
        if m:
            try:
                return float(m.group(1))
            except Exception:
                return None
        m = re.search(r"EST_PROFIT_(-?\d+(?:\.\d+)?)", text)
        if m:
            try:
                return float(m.group(1))
            except Exception:
                return None
        return None

    def _extract_normalized_close_pnl(report: dict):
        # 统一 CLOSE 盈亏口径：优先显式字段，其次 retcode/reason 中的 EST_PROFIT。
        explicit = []
        for key in ("pnl", "close_profit", "profit"):
            if key in report and report.get(key) is not None:
                try:
                    explicit.append((float(report.get(key)), key))
                except Exception:
                    pass
        for v, k in explicit:
            if abs(v) > 1e-9:
                return v, k
        retcode = str(report.get("retcode", "") or "")
        reason = str(report.get("reason", "") or "")
        est = _parse_est_profit(retcode) if retcode else None
        if est is None and reason:
            est = _parse_est_profit(reason)
        if est is not None:
            return float(est), "est_profit"
        if explicit:
            return explicit[0][0], explicit[0][1]
        return None, "missing"

    try:
        state = _reconcile_bridge_timeout_pending(state)
        # 清理 ticket=0 的伪 FILLED 挂起项，避免 EXECUTING 僵尸化。
        pending_map = state.get("ticket_zero_pending", {}) if isinstance(state.get("ticket_zero_pending", {}), dict) else {}
        now_ts = time.time()
        active_pending_decision = str(state.get("position_decision_id", "") or "").strip()
        active_pending_signal = str(state.get("position_signal_id", "") or "").strip()
        if pending_map:
            pending_target = ""
            if active_pending_decision and active_pending_decision in pending_map:
                pending_target = active_pending_decision
            elif len(pending_map) == 1:
                pending_target = next(iter(pending_map.keys()))
            if pending_target:
                state, rescued = _confirm_open_from_mt5(
                    state,
                    decision_id=pending_target,
                    signal_id=active_pending_signal,
                    pending_reason="TICKET_ZERO_PENDING_MT5_CONFIRMED",
                )
                if rescued:
                    pending_map = state.get("ticket_zero_pending", {}) if isinstance(state.get("ticket_zero_pending", {}), dict) else {}
        expired_decisions = []
        for decision_key, created_ts in pending_map.items():
            try:
                age = now_ts - float(created_ts or 0.0)
            except Exception:
                age = TICKET_ZERO_PENDING_TIMEOUT_SEC + 1
            if age >= TICKET_ZERO_PENDING_TIMEOUT_SEC:
                state, rescued = _confirm_open_from_mt5(
                    state,
                    decision_id=str(decision_key),
                    signal_id=active_pending_signal,
                    pending_reason="TICKET_ZERO_TIMEOUT_MT5_CONFIRMED",
                )
                if rescued:
                    pending_map = state.get("ticket_zero_pending", {}) if isinstance(state.get("ticket_zero_pending", {}), dict) else {}
                    continue
                expired_decisions.append(str(decision_key))
        for decision_id in expired_decisions:
            _close_decision_state(
                decision_id,
                "FAILED",
                reason="TICKET_ZERO_TIMEOUT",
                timeout_sec=TICKET_ZERO_PENDING_TIMEOUT_SEC,
            )
            pending_map.pop(decision_id, None)
        state["ticket_zero_pending"] = pending_map

        for f in sorted(BRIDGE_FROM_EA.glob("*.report.json")):
            raw_text = ""
            report = None
            import re
            # Retry with backoff: EA may still be writing the file (no file locks on MQL5 side).
            # A partial read → JSON parse failure. Retry up to 3 times (0.05s / 0.15s / 0.35s)
            # before giving up and deleting the corrupted file.
            for retry_attempt in range(3):
                try:
                    raw_text = f.read_text()
                    raw_text = re.sub(r"\"time\":\s*([^\"{,]+)\s*,", r"\"time\": \"\\1\",", raw_text)
                    report = json.loads(raw_text)
                    break
                except Exception as e:
                    if retry_attempt < 2:
                        _daemon_log(
                            f"[WARN] Report parse retry {retry_attempt + 1}/3 for {f.name}: {e}"
                        )
                        time.sleep(0.05 * (2 ** retry_attempt))
                    else:
                        _daemon_log(
                            f"[ERROR] Failed to parse report {f.name} after 3 retries: {e}. "
                            f"Raw: {raw_text[:100]}"
                        )
            if report is None:
                f.unlink(missing_ok=True)
                continue
            # 归档报告文件，防止数据丢失（方便事后审计和问题排查）
            try:
                ARCHIVE_DIR = DATA_DIR / "bridge" / "archive"
                ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
                # f.stem 是 xxx.report，再加 .report 会变成 .report.report.json
                # 直接用 f.name 就够了（不含 FROM_EA 目录的嵌套路径部分）
                archive_path = ARCHIVE_DIR / f.name
                if not archive_path.exists():
                    archive_path.write_text(json.dumps(report, ensure_ascii=False), encoding="utf-8")
            except Exception as arch_err:
                _daemon_log(f"[WARN] Failed to archive report {f.name}: {arch_err}")
            f.unlink(missing_ok=True)
            status = report.get("status", ""); sig_id = report.get("signal_id", "")
            resolved_decision_id = str(
                report.get("decision_id")
                or _resolve_decision_id(signal_id=sig_id, ticket=report.get("ticket", 0))
                or state.get("position_decision_id", "")
            ).strip()
            bridge_timeout_pending = state.get("bridge_timeout_pending", {}) if isinstance(state.get("bridge_timeout_pending", {}), dict) else {}
            if resolved_decision_id and resolved_decision_id in bridge_timeout_pending:
                bridge_timeout_pending.pop(resolved_decision_id, None)
                state["bridge_timeout_pending"] = bridge_timeout_pending
            _log(f"EA report: {status} {report.get('action','')} {sig_id} retcode={report.get('retcode','')}", "INFO")
            if status == "FILLED" and report.get("action") == "OPEN":
                # ── 防护：ticket=0 的 FILLED 是 EA 虚假确认，不代表 MT5 真实成交 ──
                # 真实成交必有非零 ticket；0 表示 EA 未收到 MT5 确认，属幽灵单
                report_ticket = report.get("ticket", 0)
                if not report_ticket:
                    _daemon_log(f"[WARN] FILLED with ticket=0 treated as PENDING (sig={sig_id}); waiting for real MT5 confirmation")
                    # 不设置 position_open，让请求维持 EXECUTING 状态，等下一轮 MT5 真实确认
                    if resolved_decision_id:
                        pending_map = state.get("ticket_zero_pending", {}) if isinstance(state.get("ticket_zero_pending", {}), dict) else {}
                        already_pending = resolved_decision_id in pending_map
                        pending_map[resolved_decision_id] = time.time()
                        state["ticket_zero_pending"] = pending_map
                        if not already_pending:
                            _transition_decision_state(resolved_decision_id, "EXECUTING", reason="TICKET_ZERO_PENDING")
                    state, rescued = _confirm_open_from_mt5(
                        state,
                        decision_id=resolved_decision_id,
                        signal_id=sig_id,
                        report=report,
                        pending_reason="TICKET_ZERO_REPORT_MT5_CONFIRMED",
                    )
                    if rescued:
                        pending_map = state.get("ticket_zero_pending", {}) if isinstance(state.get("ticket_zero_pending", {}), dict) else {}
                    continue
                if resolved_decision_id:
                    pending_map = state.get("ticket_zero_pending", {}) if isinstance(state.get("ticket_zero_pending", {}), dict) else {}
                    pending_map.pop(resolved_decision_id, None)
                    state["ticket_zero_pending"] = pending_map
                report_attribution = report.get("attribution", {}) if isinstance(report.get("attribution", {}), dict) else {}
                pending_attribution = state.get("position_attribution", {}) if isinstance(state.get("position_attribution", {}), dict) else {}
                execution_attribution = normalize_position_attribution(
                    report_attribution or pending_attribution,
                    action="OPEN",
                    fallback=pending_attribution,
                )
                pipeline_owned = _report_owned_by_pipeline(report) or str(execution_attribution.get("writer_owner", "") or "").lower() == "pipeline"
                fill_price = float(report.get("fill_price", 0) or 0)
                next_open_state = {
                    "position_open": True,
                    "position_side": report.get("side", "BUY"),
                    "position_entry": fill_price,
                    "position_price": fill_price,
                    "position_sl": float(report.get("sl_price", 0) or 0),
                    "position_tp": float(report.get("tp_price", 0) or 0),
                    "position_vol": float(report.get("volume", 0.01) or 0.01),
                    "position_ticket": int(report.get("ticket", 0) or state.get("position_ticket", 0) or 0),
                    "position_signal_id": sig_id or state.get("position_signal_id", ""),
                    "position_decision_id": resolved_decision_id or sig_id or state.get("position_decision_id", ""),
                    "position_regime": execution_attribution.get("regime", state.get("position_regime", "UNKNOWN")),
                    "position_confidence": float(report.get("confidence", state.get("position_confidence", 0.0)) or state.get("position_confidence", 0.0) or 0.0),
                    "position_time": time.time(),
                }
                next_open_state.update(position_state_fields_from_attribution(execution_attribution, action="OPEN"))
                state["l1_breakeven_done"] = False
                state["partial_done"] = False
                try:
                    mt5_state = get_mt5_position_state()
                    if mt5_state.get("position_open") and mt5_state.get("position_ticket"):
                        # 注意：position_time 使用本地时间，不从 MT5 覆盖（防止时区/重载导致时间止损误触发）
                        for k in ["position_ticket", "position_entry", "position_price", "position_sl", "position_tp", "position_vol", "position_side"]:
                            if k in mt5_state:
                                next_open_state[k] = mt5_state[k]
                except Exception as e:
                    _daemon_log(f"load mt5 position after fill failed: {e}")
                _update_account_state(mt5_state.get("balance"), mt5_state.get("equity"))

                # OPEN 状态原子一致性检查：关键信息缺失则不落地为持仓，避免半状态污染后续出场引擎。
                open_ticket = int(next_open_state.get("position_ticket", 0) or 0)
                open_entry = float(next_open_state.get("position_entry", 0) or 0)
                if open_ticket <= 0 or open_entry <= 0:
                    _daemon_log(
                        f"[OPEN_ATOMIC_ABORT] incomplete OPEN state, keep pending: "
                        f"ticket={open_ticket} entry={open_entry} sig={sig_id}"
                    )
                    if resolved_decision_id:
                        _transition_decision_state(resolved_decision_id, "EXECUTING", reason="OPEN_STATE_INCOMPLETE_PENDING")
                    continue
                state.update(next_open_state)
                state["total_trades"] += 1
                _log(f"✅ POSITION OPENED: {state['position_side']} @{state['position_entry']} SL={state['position_sl']} TP={state['position_tp']}", "WARN")
                if not pipeline_owned:
                    try:
                        sync_open_fill(
                            execution_attribution,
                            data_dir=DATA_DIR,
                            ticket=int(state.get("position_ticket", 0) or 0),
                            side=str(state.get("position_side", "BUY") or "BUY"),
                            volume=float(state.get("position_vol", 0.0) or 0.0),
                            entry_price=float(state.get("position_entry", 0.0) or 0.0),
                            sl=float(state.get("position_sl", 0.0) or 0.0),
                            tp=float(state.get("position_tp", 0.0) or 0.0),
                            symbol="XAUUSD",
                            source="bridge_ea_filled",
                        )
                    except Exception as lifecycle_err:
                        _daemon_log(f"sync_open_fill ea report failed: {lifecycle_err}")
                try:
                    queued_post_fill_repair = _queue_post_fill_sl_tp_repair(
                        ticket=int(state.get("position_ticket", 0) or 0),
                        side=str(state.get("position_side", "") or ""),
                        fill_price=float(state.get("position_entry", 0) or 0),
                        sl=float(state.get("position_sl", 0) or 0),
                        tp=float(state.get("position_tp", 0) or 0),
                        volume=float(state.get("position_vol", 0.0) or 0.0),
                        decision_id=str(resolved_decision_id or state.get("position_decision_id", "") or ""),
                        signal_id=str(sig_id or state.get("position_signal_id", "") or ""),
                        attribution=execution_attribution,
                    )
                    if queued_post_fill_repair:
                        state["position_post_fill_rr_repair_pending"] = True
                except Exception as post_fill_err:
                    _daemon_log(f"[POST_FILL_RR_REPAIR] failed: {post_fill_err}")
                # 记录开仓到 trading_ledger
                if not pipeline_owned:
                    _log_open(state)
                    _update_shadow_system(position_open=True, total_trades=state.get("total_trades", 0), total_pnl=state.get("total_pnl", 0.0))
                if resolved_decision_id and not pipeline_owned:
                    _close_decision_state(
                        resolved_decision_id,
                        "CONFIRMED",
                        reason="OPEN_FILLED",
                        ticket=state.get("position_ticket", 0),
                        signal_id=sig_id,
                        side=state.get("position_side"),
                        fill_price=state.get("position_entry", 0),
                        volume=state.get("position_vol", 0),
                    )
                    _append_decision_link(
                        signal_id=sig_id,
                        decision_id=resolved_decision_id,
                        action="OPEN",
                        ticket=state.get("position_ticket", 0),
                        source="ea_open_filled",
                    )
                # 持仓快照
                try:
                    sys.path.insert(0, str(ROOT))
                    from skill_trading_memory import save_position_snapshot
                    save_position_snapshot(positions=[{
                        "ticket": state["position_ticket"],
                        "side": state["position_side"],
                        "volume": state["position_vol"],
                        "entry": state["position_entry"],
                        "sl": state["position_sl"],
                        "tp": state["position_tp"],
                    }])
                except Exception as e:
                    _daemon_log(f"save_position_snapshot failed: {e}")
                if resolved_decision_id and not pipeline_owned:
                    _record_learning_execution(resolved_decision_id, report, state)
            elif status in ("SL_MODIFIED", "TP_MODIFIED"):
                action = report.get("action", "")
                reason = str(report.get("comment", "") or report.get("reason", ""))
                modify_telemetry = state.get("_exit_modify_telemetry", {}) if isinstance(state.get("_exit_modify_telemetry", {}), dict) else {}
                if status == "SL_MODIFIED":
                    try:
                        new_sl = float(report.get("sl_price", 0) or reason.split("sl=")[-1])
                        state["position_sl"] = new_sl
                        state["position_post_fill_rr_repair_pending"] = False
                        modify_telemetry["last_sl_modify_ts"] = int(time.time())
                        modify_telemetry["last_sl_value"] = new_sl
                        pending_sl_reason = str(modify_telemetry.pop("pending_sl_reason", "") or "")
                        modify_telemetry.pop("pending_sl_request_ts", None)
                        modify_telemetry.pop("pending_sl_value", None)
                        modify_telemetry.pop("pending_sl_signal_id", None)
                        state["_exit_modify_telemetry"] = modify_telemetry
                        entry = float(state.get("position_entry", 0) or 0)
                        side = state.get("position_side")
                        l1_confirmed = "L1_BREAKEVEN" in pending_sl_reason or "L1保本" in pending_sl_reason
                        if not pending_sl_reason and not bool(state.get("partial_done", False)):
                            l1_confirmed = (side == "BUY" and new_sl >= entry) or (side == "SELL" and new_sl <= entry)
                        if l1_confirmed:
                            state["l1_breakeven_done"] = True
                    except Exception:
                        pass
                elif status == "TP_MODIFIED":
                    try:
                        new_tp = float(report.get("tp_price", 0) or reason.split("tp=")[-1])
                        state["position_tp"] = new_tp
                        state["position_post_fill_rr_repair_pending"] = False
                        modify_telemetry["last_tp_modify_ts"] = int(time.time())
                        modify_telemetry["last_tp_value"] = new_tp
                        modify_telemetry.pop("pending_tp_request_ts", None)
                        modify_telemetry.pop("pending_tp_value", None)
                        modify_telemetry.pop("pending_tp_signal_id", None)
                        modify_telemetry.pop("pending_tp_reason", None)
                        state["_exit_modify_telemetry"] = modify_telemetry
                    except Exception:
                        if "0" in reason:
                            state["position_tp"] = 0
            elif status == "FILLED" and report.get("action") in ("CLOSE", "CLOSE_ALL", "CLOSE_PARTIAL", "CLOSE_TICKET"):
                pnl, pnl_source = _extract_normalized_close_pnl(report)
                pipeline_owned = _report_owned_by_pipeline(report) or _position_owned_by_pipeline(state)
                reason_str = report.get("reason", "") or report.get("retcode", "")
                if pnl is None:
                    pnl = 0.0
                action_upper = str(report.get("action", "")).upper()
                report_ticket = int(report.get("ticket", 0) or 0)
                # 去重键：防止 EA 重复 CLOSE 回报污染连亏统计。
                close_sig = str(sig_id or "")
                dedupe_key = f"{action_upper}|{report_ticket}|{close_sig}|{round(float(pnl or 0.0), 2)}"
                close_dedupe = state.get("close_report_dedupe", [])
                if not isinstance(close_dedupe, list):
                    close_dedupe = []
                if dedupe_key in close_dedupe:
                    _daemon_log(f"[WARN] Duplicate CLOSE report ignored: {dedupe_key}")
                    continue
                close_dedupe.append(dedupe_key)
                state["close_report_dedupe"] = close_dedupe[-200:]
                # 无持仓时不再处理 CLOSE，避免重复 CLOSE 报告把 losses 累加到错误值。
                if (not bool(state.get("position_open", False))) and action_upper != "CLOSE_PARTIAL":
                    _daemon_log(
                        f"[WARN] Stale CLOSE ignored: action={action_upper}, ticket={report_ticket}, sig={close_sig}, pnl={pnl}"
                    )
                    continue

                fill_price = float(report.get("fill_price", 0) or 0)
                if fill_price == 0 and "close_price" in report:
                    fill_price = float(report.get("close_price", 0) or 0)

                # 如果 EA 连 fill_price 都不传，尝试用 get_price 的当前价
                if fill_price == 0:
                    try:
                        bid_t, ask_t, _, _, _ = get_price()
                        fill_price = ask_t if state.get("position_side") == "SELL" else bid_t
                    except:
                        fill_price = 0

                # 移除兜底：不再允许 Python 强算 PnL，如果没有拿到真实数据，就记为0，等待 sync_mt5_history.py 修复
                if abs(float(pnl or 0.0)) < 1e-9 and pnl_source == "missing":
                    _daemon_log(f"[ERROR] 无法从 EA 获取真实利润，PnL 记为 0，等待事后同步。Reason: {reason_str}")

                # 计算持仓时长
                duration_min = 0
                pos_time = state.get("position_time", 0)
                if pos_time > 0:
                    duration_min = int((time.time() - pos_time) / 60)

                # 对于部分平仓，记录利润并更新冷却时间，但不清空持仓状态
                if report.get("action") == "CLOSE_PARTIAL":
                    _log(f"✅ PARTIAL CLOSED: PnL={pnl}", "WARN")
                    state["partial_done"] = True
                    if not pipeline_owned:
                        try:
                            _log_trade(state, fill_price, fill_price, close_pnl=pnl, duration_min=duration_min)
                        except Exception as e:
                            _daemon_log(f"_log_trade (partial) failed: {e}")
                        try:
                            from bridge.shadow_evolver import record_trade
                            record_trade(live_pnl=pnl)
                        except Exception:
                            pass
                    # BUG FIX: 部分平仓也要触发冷却时间更新和历史记录
                    _seal_close_guard(
                        state,
                        closed_side=str(state.get("position_side", "") or ""),
                        closed_ticket=report_ticket,
                        close_action="CLOSE_PARTIAL",
                    )
                    _append_recent_closure(
                        state,
                        pnl,
                        duration_min=duration_min,
                        exit_price=fill_price,
                        close_action="CLOSE_PARTIAL",
                    )
                    _apply_realized_closure_stats(state, pnl, position_open=True)
                    save_state(state)
                else:
                    # 完全平仓逻辑
                    decision_id = str(state.get("position_decision_id", "") or resolved_decision_id or "").strip()
                    state_before_close = dict(state)
                    if not pipeline_owned:
                        try:
                            _log_trade(state, fill_price, fill_price, close_pnl=pnl, duration_min=duration_min)
                        except Exception as e:
                            _daemon_log(f"_log_trade (close_all) failed: {e}")
                            try:
                                bid2, ask2, _, _, _ = get_price()
                                if bid2 and ask2:
                                    _log_trade(state, bid2, ask2, close_pnl=pnl, duration_min=duration_min)
                                else:
                                    _daemon_log(f"[ERROR] _log_trade skipped: no price available for ticket={state.get('position_ticket')}")
                            except Exception as e2:
                                _daemon_log(f"get_price for log_trade failed: {e2}")
                        try:
                            from bridge.shadow_evolver import record_trade
                            record_trade(live_pnl=pnl)
                        except Exception:
                            pass
                    try:
                        _append_recent_closure(
                            state_before_close,
                            pnl,
                            duration_min=duration_min,
                            exit_price=fill_price,
                            close_action=action_upper,
                        )
                    except Exception as e:
                        _daemon_log(f"_append_recent_closure (filled close) failed: {e}")
                    state["recent_closures"] = state_before_close.get("recent_closures", [])
                    state["pattern_memory"] = state_before_close.get("pattern_memory", {})
                    state["runtime_adaptation"] = state_before_close.get("runtime_adaptation", {})
                    state["learning_adaptation_summary"] = state_before_close.get("learning_adaptation_summary", {})
                    # 只基于 recent_closures 重建，避免 archive 重复 CLOSE 回报污染连亏数据。
                    losses = _sync_consecutive_losses_from_recent(state)
                    _log(f"✅ CLOSED: PnL={pnl}({pnl_source}) losses={losses}", "WARN")
                    # 平仓快照（空持仓）
                    try:
                        sys.path.insert(0, str(ROOT))
                        from skill_trading_memory import save_position_snapshot
                        save_position_snapshot(positions=[])
                    except Exception as e:
                        _daemon_log(f"save_position_snapshot failed: {e}")
                    # Always record closure - even if decision_id is empty, use ticket as fallback
                    if not decision_id:
                        decision_id = 'ema_legacy_' + str(report.get('ticket', state.get('position_ticket', 'unknown')))
                    close_attribution = normalize_position_attribution(
                        report.get("attribution", {}) if isinstance(report.get("attribution", {}), dict) else {},
                        action="CLOSE",
                        fallback=state_before_close.get("position_attribution", {}) if isinstance(state_before_close.get("position_attribution", {}), dict) else {},
                    )
                    if not pipeline_owned:
                        try:
                            sync_close_fill(
                                close_attribution,
                                data_dir=DATA_DIR,
                                ticket=int(report.get("ticket", state_before_close.get("position_ticket", 0)) or 0),
                                exit_price=float(fill_price or 0.0),
                                pnl=float(pnl or 0.0),
                                side=str(state_before_close.get("position_side", "") or ""),
                                reason=str(report.get("reason", "") or action_upper),
                                source="bridge_ea_close",
                            )
                        except Exception as lifecycle_err:
                            _daemon_log(f"sync_close_fill ea close failed: {lifecycle_err}")
                        _append_decision_link(
                            signal_id=sig_id or state.get("position_signal_id", ""),
                            decision_id=decision_id,
                            action=str(report.get("action", "")),
                            ticket=report.get("ticket", state.get("position_ticket", 0)),
                            source="ea_close_filled",
                        )
                        _record_learning_closure(
                            decision_id,
                            state_before_close,
                            pnl,
                            fill_price,
                            duration_min,
                            spread=0.0,
                        )
                        _close_decision_state(
                            decision_id,
                            "CONFIRMED",
                            reason="POSITION_CLOSED",
                            ticket=report.get("ticket", state.get("position_ticket", 0)),
                            realized_pnl=pnl,
                            fill_price=fill_price,
                            duration_min=duration_min,
                        )
                    # 彻底清空所有状态标记，包括 L2/L3 平仓标记
                    state.update({
                        "position_open":False,
                        "position_ticket":0,
                        "position_signal_id":"",
                        "position_decision_id":"",
                        "position_regime":"UNKNOWN",
                        "position_strategy_id":"",
                        "position_trace_id":"",
                        "position_confidence":0.0,
                        "position_session_info":{},
                        "position_session_metrics":{},
                        "position_interpreter":{},
                        "position_attribution":{},
                        "position_agent_direction":"",
                        "position_entry_owner_instance_id":"",
                        "position_entry_owner_strategy_id":"",
                        "position_current_manager_instance_id":"",
                        "position_current_manager_strategy_id":"",
                        "position_expected_exit_owner_instance_id":"",
                        "position_expected_exit_owner_strategy_id":"",
                        "position_exit_owner_instance_id":"",
                        "position_exit_owner_strategy_id":"",
                        "position_contributors":[],
                        "position_side":None,
                        "position_time":0,
                        "l1_breakeven_done": False,
                        "partial_done": False
                    })
                    _seal_close_guard(
                        state,
                        closed_side=str(state_before_close.get("position_side", "") or ""),
                        closed_ticket=int(state_before_close.get("position_ticket", 0) or 0),
                        close_action=action_upper,
                    )
                    _apply_realized_closure_stats(state, pnl, position_open=False)
                    # 同步更新旧的全局变量以防遗漏
                    global _last_close_time
                    _last_close_time = state["last_close_time"]
                    # 立即持久化 last_close_time，防止被后续 FILLED OPEN 覆盖或 save_state 遗漏
                    save_state(state)
                    # Phase 2: Record tactical feedback for EA FILLED close
                    _record_trade_feedback(state_before_close, pnl, fill_price, duration_min, action_upper, decision_id)
            elif status == "REJECTED":
                retcode = str(report.get("retcode", ""))
                action = report.get("action", "")
                modify_telemetry = state.get("_exit_modify_telemetry", {}) if isinstance(state.get("_exit_modify_telemetry", {}), dict) else {}
                if action == "MODIFY_SL":
                    modify_telemetry.pop("pending_sl_request_ts", None)
                    modify_telemetry.pop("pending_sl_value", None)
                    modify_telemetry.pop("pending_sl_signal_id", None)
                    modify_telemetry.pop("pending_sl_reason", None)
                    state["_exit_modify_telemetry"] = modify_telemetry
                elif action == "MODIFY_TP":
                    modify_telemetry.pop("pending_tp_request_ts", None)
                    modify_telemetry.pop("pending_tp_value", None)
                    modify_telemetry.pop("pending_tp_signal_id", None)
                    modify_telemetry.pop("pending_tp_reason", None)
                    state["_exit_modify_telemetry"] = modify_telemetry
                _log(f"EA REJECTED: {retcode} {report.get('comment','')}", "ERROR")
                if resolved_decision_id:
                    _close_decision_state(
                        resolved_decision_id,
                        "FAILED",
                        reason=retcode or "EA_REJECTED",
                        signal_id=sig_id,
                        action=action,
                        retcode=retcode,
                        comment=report.get("comment", ""),
                    )
                if action == "CLOSE_ALL" and "UNKNOWN_ACTION" in retcode:
                    _log("⚠️ CLOSE_ALL 不受支持，自动回退为 CLOSE", "WARN")
                    write_trade_request(
                        "CLOSE",
                        state.get("position_side", "BUY"),
                        0, 0, 0,
                        vol=state.get("position_vol", 0.01),
                        sig_id=None,
                        request_origin="legacy_reject_fallback",
                    )
                    continue
                if action == "CLOSE_TICKET" and state.get("position_open"):
                    _log("⚠️ CLOSE_TICKET 被拒，自动回退为 CLOSE", "WARN")
                    write_trade_request(
                        "CLOSE",
                        state.get("position_side", "BUY"),
                        0, 0, 0,
                        vol=state.get("position_vol", 0.01),
                        sig_id=None,
                        request_origin="legacy_reject_fallback",
                    )
                    continue
                # PARTIAL_CLOSE_NOT_SUPPORTED fallback：券商不支持部分平仓时，全平以锁利
                if action == "CLOSE_PARTIAL":
                    state["partial_done"] = True
                    # PARTIAL_CLOSE_NOT_SUPPORTED：券商不支持部分平仓 → 全平锁利
                    if "PARTIAL_CLOSE_NOT_SUPPORTED" in retcode:
                        _log(f"⚠️ PARTIAL_CLOSE 不支持，执行全平 fallback", "WARN")
                    else:
                        _log(f"⚠️ PARTIAL_CLOSE 被拒 ({retcode})，标记已完成并执行全平 fallback", "WARN")
                    if state.get("position_open") and state.get("position_ticket"):
                        write_trade_request(
                            "CLOSE_TICKET",
                            state.get("position_side", "BUY"),
                            0, 0, 0,
                            vol=state.get("position_vol", 0.01),
                            sig_id=None,
                            ticket=state.get("position_ticket", 0),
                            request_origin="legacy_reject_fallback",
                        )
    except Exception as e: _daemon_log(f"process_ea_reports error: {e}")
    return state

# ============ 软挂单队列 (Soft Limits) ============
_soft_limits = {}  # 结构: {decision_id: {"side": "BUY", "limit_price": 1234.5, "sl": 1230, "tp": 1240, "vol": 0.05, "expire": time.time() + 1800, ...}}


def _remove_soft_limit(decision_id, to_state="VETOED", reason="SOFT_LIMIT_CLEARED", **metadata):
    global _soft_limits
    order = _soft_limits.pop(decision_id, None)
    if order is None:
        return None
    if to_state:
        _close_decision_state(
            decision_id,
            to_state,
            reason=reason,
            side=order.get("side"),
            limit_price=order.get("limit_price"),
            volume=order.get("vol"),
            entry_style=order.get("entry_style", "LIMIT"),
            **metadata,
        )
    return order


def _retire_legacy_soft_limits(state: Optional[dict] = None, *, reason: str = "PIPELINE_SINGLE_TRACK_SOFT_LIMIT_DISABLED") -> int:
    global _soft_limits
    if not _soft_limits:
        return 0
    retired = 0
    for sig_id in list(_soft_limits.keys()):
        removed = _remove_soft_limit(sig_id, to_state="VETOED", reason=reason)
        if removed is not None:
            retired += 1
    if retired > 0:
        current_state = state if isinstance(state, dict) else load_state()
        current_state["soft_limits"] = _soft_limits
        save_state(current_state)
        _daemon_log(f"[PIPELINE] retired {retired} legacy soft limits: reason={reason}")
    return retired


def _block_legacy_review_open(sig: dict, *, sig_id: str, stable_id: Optional[str] = None) -> bool:
    action = str(sig.get("action", "HOLD") or "HOLD").upper()
    if action not in {"OPEN", "BUY", "SELL"}:
        return False
    decision_id = str(sig.get("decision_id", stable_id or sig_id) or stable_id or sig_id)
    _close_decision_state(
        decision_id,
        "VETOED",
        reason="LEGACY_REVIEW_OPEN_DISABLED",
        review_signal_id=sig.get("signal_id", sig_id),
        stable_id=sig.get("stable_id", stable_id or sig_id),
        review_reason=sig.get("reason", ""),
    )
    try:
        state = load_state()
        _set_final_reject_reason(state, "LEGACY_REVIEW_OPEN_DISABLED", stage="review_approved_legacy_open")
        save_state(state)
    except Exception:
        pass
    _log("⚠️ Review 批准的旧开仓路径已停用，需改由 pipeline 统一执行", "WARN")
    return True

def _reanchor_sl_tp_by_design(side: str, exec_entry: float, design_entry: float, design_sl: float, design_tp: float):
    """
    用“原始设计距离”在最终执行价上重锚 SL/TP。
    目的：不论市价还是挂单触发，成交后都沿用同一套设计风险半径。
    """
    try:
        side_u = str(side or "").upper()
        entry_v = float(exec_entry or 0.0)
        design_entry_v = float(design_entry or 0.0)
        design_sl_v = float(design_sl or 0.0)
        design_tp_v = float(design_tp or 0.0)
    except Exception:
        return design_sl, design_tp

    if entry_v <= 0 or design_entry_v <= 0:
        return design_sl, design_tp

    sl_dist = abs(design_entry_v - design_sl_v) if design_sl_v > 0 else 0.0
    tp_dist = abs(design_tp_v - design_entry_v) if design_tp_v > 0 else 0.0
    if sl_dist <= 0 or tp_dist <= 0:
        return design_sl, design_tp

    if side_u == "BUY":
        return round(entry_v - sl_dist, 3), round(entry_v + tp_dist, 3)
    if side_u == "SELL":
        return round(entry_v + sl_dist, 3), round(entry_v - tp_dist, 3)
    return design_sl, design_tp


def _repair_sl_tp_for_rr(side: str, entry: float, sl: float, tp: float, atr: float = 0.0, min_rr: float = 1.0):
    """
    自动修复 SL/TP：
    1) 修复 BUY/SELL 的物理方向；
    2) 保障 TP/SL >= min_rr；
    3) 优先保留原始风险半径与目标，不做过度改写。
    """
    try:
        side_u = str(side or "").upper()
        entry_v = float(entry or 0.0)
        sl_v = float(sl or 0.0)
        tp_v = float(tp or 0.0)
        atr_v = max(0.0, float(atr or 0.0))
        rr_floor = max(1.0, float(min_rr or 1.0))
    except Exception:
        return sl, tp, False, 0.0

    if entry_v <= 0 or side_u not in {"BUY", "SELL"}:
        return sl_v, tp_v, False, 0.0

    min_stop = max(0.6, atr_v * 0.35)
    changed = False

    if side_u == "BUY":
        # 风险半径优先沿用原值，不合法时用 ATR/最小半径兜底
        sl_dist = entry_v - sl_v if 0 < sl_v < entry_v else abs(entry_v - sl_v)
        if sl_dist <= 0:
            sl_dist = max(min_stop, atr_v * 0.9)
        if sl_dist < min_stop:
            sl_dist = min_stop
        sl_new = entry_v - sl_dist
        tp_min = entry_v + sl_dist * rr_floor
        tp_candidate = tp_v if tp_v > entry_v else 0.0
        tp_new = max(tp_candidate, tp_min)
        changed = abs(sl_new - sl_v) > 1e-6 or abs(tp_new - tp_v) > 1e-6
    else:
        sl_dist = sl_v - entry_v if sl_v > entry_v > 0 else abs(sl_v - entry_v)
        if sl_dist <= 0:
            sl_dist = max(min_stop, atr_v * 0.9)
        if sl_dist < min_stop:
            sl_dist = min_stop
        sl_new = entry_v + sl_dist
        tp_min = entry_v - sl_dist * rr_floor
        tp_candidate = tp_v if 0 < tp_v < entry_v else 0.0
        tp_new = min(tp_candidate, tp_min) if tp_candidate > 0 else tp_min
        changed = abs(sl_new - sl_v) > 1e-6 or abs(tp_new - tp_v) > 1e-6

    rr_new = abs(tp_new - entry_v) / max(abs(sl_new - entry_v), 1e-6)
    return round(sl_new, 3), round(tp_new, 3), changed, rr_new


def _write_json_atomic(path: pathlib.Path, payload: dict) -> pathlib.Path:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp_path = path.with_name(f".{path.name}.{os.getpid()}.{time.time_ns()}.tmp")
    try:
        with open(tmp_path, "w", encoding="utf-8") as handle:
            handle.write(json.dumps(payload, ensure_ascii=False, indent=2))
            handle.flush()
            os.fsync(handle.fileno())
        os.replace(tmp_path, path)
    finally:
        try:
            if tmp_path.exists():
                tmp_path.unlink()
        except Exception:
            pass
    return path


def _build_trade_request_intent_id(req: dict, *, existing_intent_id: str = "") -> str:
    if str(existing_intent_id or "").strip():
        return str(existing_intent_id).strip()
    try:
        from src.mt5_execution import generate_intent_id
    except ModuleNotFoundError:
        from mt5_execution import generate_intent_id
    signal_payload = {
        "signal_id": str(req.get("signal_id", "") or ""),
        "action": str(req.get("action", "HOLD") or "HOLD"),
        "side": str(req.get("side", "NONE") or "NONE"),
        "entry_price": float(req.get("entry_price", 0.0) or 0.0),
    }
    return str(generate_intent_id(signal_payload, ts_ms=int(time.time() * 1000)))


def _queue_post_fill_sl_tp_repair(
    *,
    ticket: int,
    side: str,
    fill_price: float,
    sl: float,
    tp: float,
    volume: float,
    decision_id: str = "",
    signal_id: str = "",
    attribution: Optional[dict] = None,
) -> bool:
    """成交后按真实 fill 价格复核 RR，必要时下发 SL/TP 修正请求。"""
    try:
        ticket_i = int(ticket or 0)
        fill_v = float(fill_price or 0.0)
        sl_v = float(sl or 0.0)
        tp_v = float(tp or 0.0)
        side_u = str(side or "").upper()
    except Exception:
        return False

    if ticket_i <= 0 or fill_v <= 0 or side_u not in {"BUY", "SELL"}:
        return False

    repaired_sl, repaired_tp, changed, repaired_rr = _repair_sl_tp_for_rr(
        side_u,
        fill_v,
        sl_v,
        tp_v,
        atr=0.0,
        min_rr=1.0,
    )
    if not changed:
        return False

    queued = False
    if abs(repaired_sl - sl_v) > 1e-6:
        sl_sig = f"postfill_{ticket_i}_MODIFY_SL"
        queued = bool(
            write_trade_request(
                "MODIFY_SL",
                side_u,
                fill_v,
                repaired_sl,
                repaired_tp,
                vol=volume,
                sig_id=sl_sig,
                ticket=ticket_i,
                decision_id=decision_id or signal_id or sl_sig,
                attribution=attribution if isinstance(attribution, dict) else None,
                request_origin="legacy_post_fill_repair",
            )
        ) or queued
    if abs(repaired_tp - tp_v) > 1e-6:
        tp_sig = f"postfill_{ticket_i}_MODIFY_TP"
        queued = bool(
            write_trade_request(
                "MODIFY_TP",
                side_u,
                fill_v,
                repaired_sl,
                repaired_tp,
                vol=volume,
                sig_id=tp_sig,
                ticket=ticket_i,
                decision_id=decision_id or signal_id or tp_sig,
                attribution=attribution if isinstance(attribution, dict) else None,
                request_origin="legacy_post_fill_repair",
            )
        ) or queued

    if queued:
        _daemon_log(
            f"[POST_FILL_RR_REPAIR] ticket={ticket_i} side={side_u} fill={fill_v:.2f} "
            f"SL {sl_v:.2f}->{repaired_sl:.2f} TP {tp_v:.2f}->{repaired_tp:.2f} RR={repaired_rr:.2f}"
        )
    return queued


def process_soft_limits(price_bid, price_ask):
    """处理并触发隐形软挂单"""
    global _soft_limits
    now = time.time()
    triggered = []
    expired = []
    state_now = load_state()
    cooldown_remaining = _cooldown_remaining_sec(state_now)
    agent_dir_now, _agent_meta = get_agent_direction()
    
    for sig_id, order in list(_soft_limits.items()):
        if now > order["expire"]:
            expired.append(sig_id)
            continue
        registered_agent_dir = _soft_limit_registered_agent_direction(order)
        order_side = str(order.get("side", "") or "").upper()
        order_regime = str(order.get("regime", "UNKNOWN") or "UNKNOWN")
        if registered_agent_dir in ("LONG", "SHORT") and order_side in ("BUY", "SELL"):
            if _is_direction_conflict(registered_agent_dir, order_side):
                _daemon_log(
                    f"⛔ [RegisteredDirection] 软挂单登记方向与订单方向冲突: "
                    f"registered={registered_agent_dir} side={order_side} limit={order.get('limit_price')}"
                )
                _remove_soft_limit(
                    sig_id,
                    to_state="REJECTED",
                    reason="SOFT_LIMIT_REGISTERED_DIRECTION_CONFLICT",
                    registered_agent_dir=registered_agent_dir,
                    signal_side=order_side,
                    limit_price=order.get("limit_price"),
                )
                continue
            if agent_dir_now in ("LONG", "SHORT") and agent_dir_now != registered_agent_dir:
                _daemon_log(
                    f"⛔ [DirectionDrift] 软挂单方向漂移作废: registered={registered_agent_dir} "
                    f"current={str(agent_dir_now).upper()} side={order_side} limit={order.get('limit_price')}"
                )
                _remove_soft_limit(
                    sig_id,
                    to_state="REJECTED",
                    reason="SOFT_LIMIT_AGENT_DIRECTION_DRIFT",
                    registered_agent_dir=registered_agent_dir,
                    current_agent_dir=str(agent_dir_now).upper(),
                    signal_side=order_side,
                    limit_price=order.get("limit_price"),
                )
                continue
            
        if order["side"] == "BUY" and price_ask <= order["limit_price"]:
            if cooldown_remaining > 0:
                _daemon_log(
                    f"⛔ [Cooldown] 软挂单触发被拦截(BUY): remaining={cooldown_remaining}s, limit={order['limit_price']}, ask={price_ask}"
                )
                continue
            if _is_direction_conflict(agent_dir_now, "BUY"):
                _daemon_log(
                    f"⛔ [Direction] 软挂单触发被拦截(BUY): agent={str(agent_dir_now).upper()} limit={order['limit_price']}"
                )
                _remove_soft_limit(
                    sig_id,
                    to_state="REJECTED",
                    reason="DIRECTION_CONFLICT_HARD_BLOCK",
                    agent_dir=str(agent_dir_now).upper(),
                    signal_side="BUY",
                    trigger_price=price_ask,
                )
                continue
            triggered.append(sig_id)
            decision_id = str(order.get("decision_id", "") or sig_id)
            _daemon_log(f"🎯 软挂单(Buy Limit)触发: {price_ask} <= {order['limit_price']}")
            design_entry = float(order.get("design_entry", order.get("entry_ref", order["limit_price"])) or order["limit_price"])
            new_sl, new_tp = _reanchor_sl_tp_by_design("BUY", price_ask, design_entry, order.get("sl", 0), order.get("tp", 0))
            _transition_decision_state(
                decision_id,
                "EXECUTING",
                reason="SOFT_LIMIT_TRIGGERED",
                trigger_price=price_ask,
                limit_price=order["limit_price"],
                entry_style=order.get("entry_style", "LIMIT"),
                side=order["side"],
                design_entry=design_entry,
                anchored_sl=new_sl,
                anchored_tp=new_tp,
            )
            write_trade_request(
                "OPEN",
                "BUY",
                price_ask,
                new_sl,
                new_tp,
                order["vol"],
                sig_id,
                decision_id=decision_id,
                attribution=order.get("attribution"),
                request_origin="soft_limit_trigger",
            )
        elif order["side"] == "SELL" and price_bid >= order["limit_price"]:
            if cooldown_remaining > 0:
                _daemon_log(
                    f"⛔ [Cooldown] 软挂单触发被拦截(SELL): remaining={cooldown_remaining}s, limit={order['limit_price']}, bid={price_bid}"
                )
                continue
            if _is_direction_conflict(agent_dir_now, "SELL"):
                _daemon_log(
                    f"⛔ [Direction] 软挂单触发被拦截(SELL): agent={str(agent_dir_now).upper()} limit={order['limit_price']}"
                )
                _remove_soft_limit(
                    sig_id,
                    to_state="REJECTED",
                    reason="DIRECTION_CONFLICT_HARD_BLOCK",
                    agent_dir=str(agent_dir_now).upper(),
                    signal_side="SELL",
                    trigger_price=price_bid,
                )
                continue
            triggered.append(sig_id)
            decision_id = str(order.get("decision_id", "") or sig_id)
            _daemon_log(f"🎯 软挂单(Sell Limit)触发: {price_bid} >= {order['limit_price']}")
            design_entry = float(order.get("design_entry", order.get("entry_ref", order["limit_price"])) or order["limit_price"])
            new_sl, new_tp = _reanchor_sl_tp_by_design("SELL", price_bid, design_entry, order.get("sl", 0), order.get("tp", 0))
            _transition_decision_state(
                decision_id,
                "EXECUTING",
                reason="SOFT_LIMIT_TRIGGERED",
                trigger_price=price_bid,
                limit_price=order["limit_price"],
                entry_style=order.get("entry_style", "LIMIT"),
                side=order["side"],
                design_entry=design_entry,
                anchored_sl=new_sl,
                anchored_tp=new_tp,
            )
            write_trade_request(
                "OPEN",
                "SELL",
                price_bid,
                new_sl,
                new_tp,
                order["vol"],
                sig_id,
                decision_id=decision_id,
                attribution=order.get("attribution"),
                request_origin="soft_limit_trigger",
            )
            
    for sig_id in expired:
        _daemon_log(f"⏳ 软挂单过期清理: {sig_id}")
        _remove_soft_limit(sig_id, to_state="VETOED", reason="SOFT_LIMIT_EXPIRED", expired_at=now)
    for sig_id in triggered:
        _soft_limits.pop(sig_id, None)
        
    # 同步状态，让前端立刻知道挂单被清理了
    if len(triggered) > 0 or len(expired) > 0:
        # 这里只需要简单返回一个信号让主循环去 save_state 即可
        pass
    return len(triggered) > 0

_last_rejected_signal = None
_last_rejected_time = 0

def write_trade_request(
    action,
    side,
    entry,
    sl,
    tp,
    vol=0.01,
    sig_id=None,
    ticket=0,
    decision_id=None,
    stable_id=None,
    attribution=None,
    request_origin: str = "bridge_legacy",
    legacy_fallback: bool = False,
    intent_id: str = "",
):
    global _last_rejected_signal, _last_rejected_time
    
    sig_id = sig_id or f"sig_{time.strftime('%Y%m%d_%H%M%S')}"
    decision_id = str(decision_id or sig_id)
    action_upper = str(action or "").upper()
    request_origin = str(request_origin or "bridge_legacy")
    if _block_legacy_trade_request(
        action_upper,
        request_origin,
        decision_id=decision_id,
        side=str(side or ""),
    ):
        return None
    if action_upper == "OPEN":
        try:
            entry = float(entry or 0)
            sl = float(sl or 0)
            tp = float(tp or 0)
            # 兜底修复：任何 OPEN 在落盘前都先修复一次 RR/方向，避免错误参数穿透到 EA。
            sl, tp, repaired, repaired_rr = _repair_sl_tp_for_rr(side, entry, sl, tp, atr=0.0, min_rr=1.0)
            if repaired:
                _daemon_log(
                    f"[OPEN_REPAIR] side={side} entry={entry:.2f} repaired SL/TP -> "
                    f"SL={sl:.2f} TP={tp:.2f} RR={repaired_rr:.2f}"
                )
            
            # ── 熔断去重机制：防止相同倒挂信号短时间内疯狂刷屏 ──
            current_sig_hash = f"{side}_{entry}_{sl}_{tp}"
            now = time.time()
            
            # ── 硬性拦截：SL=0 或 TP=0 的开仓单永不写入，直接抛异常 ──
            if sl <= 0 or tp <= 0:
                if current_sig_hash != _last_rejected_signal or (now - _last_rejected_time) > 300:
                    msg = f"[AUDIT ❌] 致命拦截：尝试写入 SL={sl} 或 TP={tp} 的裸奔订单，已强制阻断！"
                    _log(msg, "ERROR")
                    _daemon_log(msg)
                    _last_rejected_signal = current_sig_hash
                    _last_rejected_time = now
                return None
                    
            # ── 新的硬性拦截：禁止发向 MT5 的任何倒挂订单 ──
            if side == "BUY" and (sl >= entry or tp <= entry):
                if current_sig_hash != _last_rejected_signal or (now - _last_rejected_time) > 300:
                    msg = f"[AUDIT ❌] 致命拦截：BUY 单倒挂 (entry={entry}, sl={sl}, tp={tp})，已强制阻断！"
                    _log(msg, "ERROR")
                    _daemon_log(msg)
                    _last_rejected_signal = current_sig_hash
                    _last_rejected_time = now
                return None
            if side == "SELL" and (sl <= entry or tp >= entry):
                if current_sig_hash != _last_rejected_signal or (now - _last_rejected_time) > 300:
                    msg = f"[AUDIT ❌] 致命拦截：SELL 单倒挂 (entry={entry}, sl={sl}, tp={tp})，已强制阻断！"
                    _log(msg, "ERROR")
                    _daemon_log(msg)
                    _last_rejected_signal = current_sig_hash
                    _last_rejected_time = now
                return None

            # ── 冷却硬闸门：任何 OPEN 请求都必须通过，防止软挂单等旁路绕过 ──
            state_cooldown = load_state()
            cooldown_remaining = _cooldown_remaining_sec(state_cooldown)
            if cooldown_remaining > 0:
                if current_sig_hash != _last_rejected_signal or (now - _last_rejected_time) > 30:
                    msg = (
                        f"[AUDIT ⛔] 冷却拦截：OPEN {side} 被阻断，remaining={cooldown_remaining}s "
                        f"(entry={entry}, vol={float(vol or 0):.2f})"
                    )
                    _log(msg, "WARN")
                    _daemon_log(msg)
                    _last_rejected_signal = current_sig_hash
                    _last_rejected_time = now
                return None

            reentry_remaining, reentry_reason = _same_side_reentry_veto(state_cooldown, side)
            if reentry_remaining > 0:
                if current_sig_hash != _last_rejected_signal or (now - _last_rejected_time) > 30:
                    msg = (
                        f"[AUDIT ⛔] 同向瞬时重开拦截：OPEN {side} 被阻断，remaining={reentry_remaining}s "
                        f"reason={reentry_reason} (entry={entry}, vol={float(vol or 0):.2f})"
                    )
                    _log(msg, "WARN")
                    _daemon_log(msg)
                    _last_rejected_signal = current_sig_hash
                    _last_rejected_time = now
                return None

            regime_for_gate = str((attribution or {}).get("regime", state_cooldown.get("position_regime", "UNKNOWN")) or "UNKNOWN")
            current_agent_dir, _ = get_agent_direction()
            current_agent_dir = str(current_agent_dir or "").upper()
            registered_agent_dir = str((attribution or {}).get("agent_direction", "") or "").upper()
            effective_agent_dir = current_agent_dir if current_agent_dir in ("LONG", "SHORT") else registered_agent_dir
            if effective_agent_dir in ("LONG", "SHORT") and side in ("BUY", "SELL"):
                if _is_direction_conflict(effective_agent_dir, side):
                    if current_sig_hash != _last_rejected_signal or (now - _last_rejected_time) > 30:
                        msg = (
                            f"[AUDIT ⛔] 方向终审拦截：OPEN {side} 被阻断，agent={effective_agent_dir} "
                            f"(current={current_agent_dir or 'N/A'}, registered={registered_agent_dir or 'N/A'}, regime={regime_for_gate})"
                        )
                        _log(msg, "ERROR")
                        _daemon_log(msg)
                        _last_rejected_signal = current_sig_hash
                        _last_rejected_time = now
                    if decision_id:
                        _transition_decision_state(
                            decision_id,
                            "REJECTED",
                            reason="DIRECTION_CONFLICT_HARD_BLOCK",
                            agent_dir=effective_agent_dir,
                            signal_side=side,
                            registered_agent_dir=registered_agent_dir,
                            current_agent_dir=current_agent_dir,
                            regime=regime_for_gate,
                        )
                    return None
                
        except Exception as e:
            _daemon_log(f"write_trade_request audit failed: {e}")
        try:
            def _same_open_fingerprint(payload):
                payload_action = str(payload.get("action", "")).upper()
                payload_side = str(payload.get("side", "")).upper()
                payload_entry = float(payload.get("entry_price", 0) or 0)
                payload_sl = float(payload.get("sl_price", 0) or 0)
                payload_tp = float(payload.get("tp_price", 0) or 0)
                payload_vol = float(payload.get("volume", 0) or 0)
                return (
                    payload_action == "OPEN"
                    and payload_side == str(side).upper()
                    and abs(payload_entry - entry) <= 0.35
                    and abs(payload_sl - sl) <= 0.35
                    and abs(payload_tp - tp) <= 0.35
                    and abs(payload_vol - float(vol or 0)) <= 1e-6
                )

            state_now = load_state()
            for p in state_now.get("positions_all", []) or []:
                p_side = str(p.get("side", "")).upper()
                p_entry = float(p.get("entry", 0) or 0)
                p_vol = float(p.get("volume", 0) or 0)
                if p_side == str(side).upper() and abs(p_entry - entry) <= 0.35 and abs(p_vol - float(vol or 0)) <= 1e-6:
                    _daemon_log(f"Skip duplicate OPEN: existing MT5 position side={side} entry={p_entry} vol={p_vol}")
                    return None

            if BRIDGE_TO_EA.exists():
                for req_file in sorted(BRIDGE_TO_EA.glob("*.request.json"), key=lambda x: x.stat().st_mtime, reverse=True)[:20]:
                    try:
                        if time.time() - req_file.stat().st_mtime > 180:
                            continue
                        existing_req = json.loads(req_file.read_text())
                        if _same_open_fingerprint(existing_req):
                            _daemon_log(f"Skip duplicate OPEN: pending request {req_file.name}")
                            return None
                    except Exception:
                        pass

            if ARCHIVE_DIR.exists():
                for report_file in sorted(ARCHIVE_DIR.glob("*.report*.json"), key=lambda x: x.stat().st_mtime, reverse=True)[:50]:
                    try:
                        if time.time() - report_file.stat().st_mtime > 180:
                            continue
                        report = json.loads(report_file.read_text())
                        payload = {
                            "action": report.get("action"),
                            "side": report.get("side"),
                            "entry_price": entry,
                            "sl_price": report.get("sl_price", 0),
                            "tp_price": report.get("tp_price", 0),
                            "volume": report.get("volume", 0),
                        }
                        if _same_open_fingerprint(payload):
                            _daemon_log(f"Skip duplicate OPEN: recent report {report_file.name}")
                            return None
                    except Exception:
                        pass
        except Exception as e:
            _daemon_log(f"duplicate OPEN guard failed: {e}")
    req = {"signal_id": sig_id, "decision_id": decision_id, "action": action_upper, "side": side, "symbol": "XAUUSD",
           "volume": vol, "entry_price": entry, "sl_price": sl, "tp_price": tp,
           "reason": "auto", "confidence": 0.80, "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ")}
    try:
        lot_step = float(_get_effective_param("lot_step", 0.01) or 0.01)
        min_lot = float(_get_effective_param("min_lot", lot_step) or lot_step)
        v = float(req.get("volume", vol) or vol or 0.0)
        if lot_step > 0 and v > 0:
            v = round(round(v / lot_step) * lot_step, 2)
        if v > 0:
            v = max(min_lot, v)
        req["volume"] = round(v, 2)
    except Exception:
        pass
    if stable_id:
        req["stable_id"] = stable_id
    if ticket:
        req["ticket"] = int(ticket)
    req["request_origin"] = str(request_origin or "bridge_legacy")
    req["legacy_fallback"] = bool(legacy_fallback)
    req["intent_id"] = _build_trade_request_intent_id(req, existing_intent_id=intent_id)
    try:
        BRIDGE_TO_EA.mkdir(parents=True, exist_ok=True)
        state_now = load_state()
        if not isinstance(attribution, dict) or not attribution:
            if action in {"CLOSE", "REDUCE"}:
                attribution = _build_trade_attribution(state=state_now)
            else:
                attribution = {}
        normalized_attribution = (
            normalize_position_attribution(
                attribution,
                action=action,
                fallback=state_now.get("position_attribution", {}) if isinstance(state_now.get("position_attribution", {}), dict) else {},
            )
            if isinstance(attribution, dict) and attribution
            else {}
        )
        if normalized_attribution:
            req["attribution"] = normalized_attribution
        if action_upper == "OPEN" and normalized_attribution:
            state_now.update(position_state_fields_from_attribution(normalized_attribution, action="OPEN"))
            save_state(state_now)
            sync_open_request(normalized_attribution, data_dir=DATA_DIR, reason="bridge_write_trade_request")
        elif action_upper in {"CLOSE", "REDUCE"} and normalized_attribution:
            state_now.update(position_state_fields_from_attribution(normalized_attribution, action="CLOSE"))
            save_state(state_now)
            sync_close_request(
                normalized_attribution,
                data_dir=DATA_DIR,
                ticket=int(ticket or state_now.get("position_ticket", 0) or 0),
                reason="bridge_write_trade_request",
            )
        # 使用原子重命名，防止 EA 读到半截文件
        target_path = BRIDGE_TO_EA / f"{sig_id}.request.json"
        _write_json_atomic(target_path, req)
        _append_decision_link(
            signal_id=sig_id,
            decision_id=decision_id,
            action=action,
            stable_id=str(stable_id or ""),
            ticket=ticket,
            source="write_trade_request",
            request_origin=req.get("request_origin", ""),
            legacy_fallback=req.get("legacy_fallback", False),
            intent_id=req.get("intent_id", ""),
        )
        
        _daemon_log(f"Request: {action_upper} {side} @{entry}")
        return sig_id
    except Exception as e: _daemon_log(f"Write req failed: {e}"); return None


def trigger_panic_close_and_block(reason: str = "MANUAL_PANIC", source: str = "gui") -> dict:
    """人工紧急平仓: 立即阻断开仓、清空软挂单、清理待执行 OPEN 请求，并请求当前持仓全平。"""
    global _soft_limits

    state = load_state()
    now_ts = time.time()
    state["kill_switch"] = True
    state["kill_switch_reason"] = str(reason or "MANUAL_PANIC")
    state["kill_switch_source"] = str(source or "gui")
    state["kill_switch_activated_at"] = now_ts

    cleared_soft_limits = 0
    for sig_id in list(_soft_limits.keys()):
        _remove_soft_limit(sig_id, to_state="VETOED", reason="KILL_SWITCH_PANIC")
        cleared_soft_limits += 1

    removed_open_requests = 0
    try:
        for req_file in list(BRIDGE_TO_EA.glob("*.request.json")):
            try:
                payload = json.loads(req_file.read_text(encoding="utf-8"))
            except Exception:
                continue
            if str(payload.get("action", "") or "").upper() != "OPEN":
                continue
            req_file.unlink(missing_ok=True)
            removed_open_requests += 1
    except Exception as e:
        _daemon_log(f"[PANIC] purge pending OPEN requests failed: {e}")

    state["soft_limits"] = _soft_limits
    save_state(state)

    mt5_state = get_mt5_position_state()
    _update_account_state(mt5_state.get("balance"), mt5_state.get("equity"))
    positions_all = mt5_state.get("positions_all", []) if isinstance(mt5_state.get("positions_all", []), list) else []
    detected_positions = positions_all or state.get("positions_all", []) or []
    active_ticket = int(
        mt5_state.get("position_ticket", 0)
        or state.get("position_ticket", 0)
        or 0
    )
    active_side = (
        mt5_state.get("position_side")
        or state.get("position_side")
        or (detected_positions[0].get("side") if detected_positions else "BUY")
    )
    active_vol = float(
        mt5_state.get("position_vol", 0.0)
        or state.get("position_vol", 0.0)
        or (detected_positions[0].get("volume", 0.0) if detected_positions else 0.01)
        or 0.01
    )

    close_requested = False
    requested_actions = []
    has_live_position = bool(mt5_state.get("position_open")) or bool(state.get("position_open", False)) or bool(detected_positions)
    if has_live_position:
        close_all_sig = f"panic_close_all_{int(now_ts)}"
        close_requested = (
            write_trade_request(
                "CLOSE",
                active_side or "BUY",
                0,
                0,
                0,
                vol=active_vol,
                sig_id=close_all_sig,
                decision_id=state.get("position_decision_id") or close_all_sig,
                request_origin="manual_panic",
            )
            is not None
        )
        if close_requested:
            requested_actions.append("CLOSE")
            _log(
                f"🛑 Panic close issued: action=CLOSE positions={len(detected_positions) or 1} side={active_side} vol={active_vol}",
                "ERROR",
            )
        if active_ticket > 0:
            close_ticket_sig = f"panic_close_ticket_{active_ticket}_{int(now_ts)}"
            close_ticket_requested = (
                write_trade_request(
                    "CLOSE_TICKET",
                    active_side or "BUY",
                    0,
                    0,
                    0,
                    vol=active_vol,
                    sig_id=close_ticket_sig,
                    decision_id=state.get("position_decision_id") or close_ticket_sig,
                    ticket=active_ticket,
                    request_origin="manual_panic",
                )
                is not None
            )
            close_requested = close_requested or close_ticket_requested
            if close_ticket_requested:
                requested_actions.append("CLOSE_TICKET")

    message = (
        f"紧急阻断已生效: kill_switch=ON, soft_limits_cleared={cleared_soft_limits}, "
        f"pending_open_requests_cleared={removed_open_requests}, close_requested={close_requested}, "
        f"requested_actions={','.join(requested_actions) or 'NONE'}, detected_positions={len(detected_positions)}"
    )
    _daemon_log(f"[PANIC] {message}")
    return {
        "success": True,
        "message": message,
        "kill_switch": True,
        "soft_limits_cleared": cleared_soft_limits,
        "pending_open_requests_cleared": removed_open_requests,
        "close_requested": close_requested,
        "requested_actions": requested_actions,
        "detected_positions": len(detected_positions),
        "position_ticket": active_ticket,
    }

# ============ 平仓检查 ============
def check_close_conditions(price_bid, price_ask, state):
    if not state.get("position_open"): return False, None, None
    side = state.get("position_side","BUY"); sl = state.get("position_sl",0)
    tp = state.get("position_tp",0); spread = round((price_ask-price_bid)*100,1)
    if spread > 150: return False, None, None
    # ── 防护：如果连 sl 都没有（无保护），不触发平仓（防止刚开仓时的瞬时读取错乱）──
    if sl <= 0: return False, None, None
    if side == "BUY":
        if price_bid <= sl: return True, f"SL bid={price_bid}<=sl={sl}", "CLOSE"
        if tp > 0 and price_bid >= tp: return True, f"TP bid={price_bid}>=tp={tp}", "CLOSE"
    elif side == "SELL":
        if price_ask >= sl: return True, f"SL ask={price_ask}>=sl={sl}", "CLOSE"
        if tp > 0 and price_ask <= tp: return True, f"TP ask={price_ask}<=tp={tp}", "CLOSE"
    return False, None, None

# ============ 有持仓：高频监控（+ 平仓优化）============
def monitor_position_loop():
    state = load_state()
    if not state.get("position_open"): return

    # 加载平仓优化器
    try:
        sys.path.insert(0, str(ROOT))
        from bridge.exit_optimizer import evaluate_exit, execute_exit_actions
    except Exception as e:
        _daemon_log(f"exit_optimizer load failed: {e}")

    pos_dur_0 = int((time.time() - state.get("position_time", time.time())) / 60)
    _log(f"🟢 持仓监控启动: {state['position_side']} 入场={state['position_entry']} SL={state['position_sl']} TP={state['position_tp']} | 已持仓{pos_dur_0}分 | 单号={state.get('position_ticket', '?')}", "WARN")
    cycle = 0
    mt5_absent_count = 0
    while True:
        cycle += 1
        try:
            state = load_state()
            state = process_ea_reports(state)
            # ── 强制从 MT5 terminal_status.json 同步真实持仓数据 ──
            # 防止 bridge 报告 fill_price=0 导致 entry=0，污染所有浮盈计算
            mt5_state = get_mt5_position_state()
            if mt5_state.get("error"):
                # 只在 daemon 日志里记录一次，不跳过整个循环，因为 state 里还有缓存的持仓可以继续运算！
                _daemon_log(f"无法读取 MT5 持仓状态 ({mt5_state['error']})，使用本地缓存继续监控")
                # 不 continue，继续往下走
            else:
                _update_account_state(mt5_state.get("balance"), mt5_state.get("equity"))
                # 同时写入 state dict，确保 save_state 持久化到 agent_state.json
                if mt5_state.get("balance") is not None:
                    state["balance"] = float(mt5_state["balance"])
                if mt5_state.get("equity") is not None:
                    state["equity"] = float(mt5_state["equity"])

            # 双写竞争下，pipeline 的 position_lifecycle 可能残留 open，
            # 但 bridge/MT5 已确认空仓；这里统一把 lifecycle 拉回 flat。
            if not mt5_state.get("position_open") and not state.get("position_open", False):
                try:
                    reconcile_position_lifecycle(data_dir=DATA_DIR)
                except Exception as lifecycle_reconcile_err:
                    _daemon_log(f"[LIFECYCLE_FLAT_RECONCILE] failed: {lifecycle_reconcile_err}")

            was_open = state.get("position_open", False)
            prev_ticket = int(state.get("position_ticket", 0) or 0)
            prev_side = str(state.get("position_side", "") or "").upper()
            if mt5_state.get("position_open") and mt5_state.get("position_entry", 0) > 0:
                mt5_absent_count = 0
                for k in ["position_entry", "position_price", "position_sl", "position_tp", "position_vol", "position_side", "position_ticket"]:
                    if k in mt5_state:
                        state[k] = mt5_state[k]
                state["position_open"] = True
                # 新仓接管保护：如果票据变化（或上一轮无持仓），必须立刻刷新 position_time，
                # 防止沿用旧仓时间导致 TIME_STOP 在开仓后秒触发。
                curr_ticket = int(state.get("position_ticket", 0) or 0)
                if curr_ticket and (not was_open or curr_ticket != prev_ticket):
                    state["position_time"] = time.time()
                    _daemon_log(
                        f"[POSITION_TIME_RESET] new/open ticket detected: prev={prev_ticket} curr={curr_ticket}, reset position_time"
                    )
                    # 新仓 → 同步 position_lifecycle，保持和 agent_state 一致
                    if not _position_owned_by_pipeline(state):
                        try:
                            sync_open_fill(
                                normalize_position_attribution(
                                    state.get("position_attribution", {}) if isinstance(state.get("position_attribution", {}), dict) else {},
                                    action="OPEN",
                                    fallback=state.get("position_attribution", {}) if isinstance(state.get("position_attribution", {}), dict) else {},
                                ),
                                data_dir=DATA_DIR,
                                ticket=curr_ticket,
                                side=str(state.get("position_side", "BUY") or "BUY"),
                                volume=float(state.get("position_vol", 0.0) or 0.0),
                                entry_price=float(state.get("position_entry", 0.0) or 0.0),
                                sl=float(state.get("position_sl", 0.0) or 0.0),
                                tp=float(state.get("position_tp", 0.0) or 0.0),
                                symbol="XAUUSD",
                                source="bridge_mt5_sync",
                            )
                        except Exception as lifecycle_err:
                            _daemon_log(f"sync_open_fill bridge detect failed: {lifecycle_err}")
                # ── 防御性修复：如果 position_time 超过 24 小时，视为残留值，直接用当前时间覆盖 ──
                # 防止孤儿单恢复/MT5状态残留导致的 TIME_STOP 误触发
                pt = state.get("position_time", 0)
                if pt == 0 or (pt > 0 and (time.time() - pt) > 86400):
                    _log(f"[WARN] position_time 残留超过24小时({time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(pt))})，重置为当前时间", "WARN")
                    state["position_time"] = time.time()
                # ── 生命周期自我修复：agent_state 有持仓但 position_lifecycle 为 flat ──
                # 进程重启/孤儿恢复后 lifecycle 可能被 _sanitize_current 回退为 flat
                if not _position_owned_by_pipeline(state):
                    try:
                        import json as _json
                        lc_path = DATA_DIR / "position_lifecycle.json"
                        if lc_path.exists():
                            lc = _json.loads(lc_path.read_text(encoding="utf-8"))
                            lc_cur = lc.get("current", {}) if isinstance(lc, dict) else {}
                            if str(lc_cur.get("status", "") or "").lower() == "flat":
                                _daemon_log(f"[LIFECYCLE_HEAL] agent has position_open but lifecycle is flat, syncing ticket={curr_ticket}")
                                sync_open_fill(
                                    normalize_position_attribution(
                                        state.get("position_attribution", {}) if isinstance(state.get("position_attribution", {}), dict) else {},
                                        action="OPEN",
                                        fallback=state.get("position_attribution", {}) if isinstance(state.get("position_attribution", {}), dict) else {},
                                    ),
                                    data_dir=DATA_DIR,
                                    ticket=curr_ticket,
                                    side=str(state.get("position_side", "BUY") or "BUY"),
                                    volume=float(state.get("position_vol", 0.0) or 0.0),
                                    entry_price=float(state.get("position_entry", 0.0) or 0.0),
                                    sl=float(state.get("position_sl", 0.0) or 0.0),
                                    tp=float(state.get("position_tp", 0.0) or 0.0),
                                    symbol="XAUUSD",
                                    source="bridge_lifecycle_heal",
                                )
                    except Exception as lc_heal_err:
                        _daemon_log(f"[LIFECYCLE_HEAL] sync failed: {lc_heal_err}")
            elif mt5_state.get("position_open") and mt5_state.get("position_entry", 0) == 0:
                _daemon_log(f"[WARN] MT5持仓存在但entry=0，使用本地缓存 entry")

            # ── 检测外部平仓（SL/TP/手动平仓）──
            # 如果上一轮有持仓，现在 MT5 里没了，说明被外部事件关闭
            # 此时 terminal_status.json 中该笔持仓已移除，必须立即补写 ledger
            if was_open and not mt5_state.get("position_open"):
                mt5_absent_count += 1
                if mt5_absent_count < 3:
                    _daemon_log(f"MT5 position temporarily missing ({mt5_absent_count}/3), waiting for sync")
                    save_state(state)
                    time.sleep(INTERVAL_HAS_POS)
                    continue
                _log(f"🔍 检测到外部平仓，补写 ledger", "WARN")
                try:
                    bid_rt, ask_rt, _, _, _ = get_price()
                except Exception:
                    bid_rt = ask_rt = 0
                # 从 terminal_status 中获取平仓瞬间的浮动盈亏作为参考
                mt5_pnl = 0.0
                for pos in mt5_state.get("positions_all", []):
                    # 找刚才消失的那笔持仓（按 ticket 匹配）
                    if pos.get("ticket") == state.get("position_ticket"):
                        mt5_pnl = float(pos.get("pnl", 0) or 0)
                        break
                # 如果 terminal_status 里已经没有了，用当前行情估算
                exit_price = (bid_rt + ask_rt) / 2 if bid_rt and ask_rt else 0
                close_pnl = mt5_pnl if mt5_pnl != 0 else 0
                duration_min = int((time.time() - state.get("position_time", time.time())) / 60)
                state_before_close = dict(state)
                _finalize_position_closure(
                    state_before_close,
                    exit_price=exit_price,
                    close_pnl=close_pnl,
                    duration_min=duration_min,
                    done_reason="EXTERNAL_CLOSE_DETECTED",
                    close_ts=time.time(),
                )
                if not _position_owned_by_pipeline(state_before_close):
                    try:
                        sync_close_fill(
                            normalize_position_attribution(
                                state_before_close.get("position_attribution", {}) if isinstance(state_before_close.get("position_attribution", {}), dict) else {},
                                action="CLOSE",
                                fallback=state_before_close.get("position_attribution", {}) if isinstance(state_before_close.get("position_attribution", {}), dict) else {},
                            ),
                            data_dir=DATA_DIR,
                            ticket=int(state_before_close.get("position_ticket", 0) or 0),
                            exit_price=float(exit_price or 0.0),
                            pnl=float(close_pnl or 0.0),
                            side=str(state_before_close.get("position_side", "") or ""),
                            reason="EXTERNAL_CLOSE_DETECTED",
                            source="bridge_external_close",
                        )
                    except Exception as lifecycle_err:
                        _daemon_log(f"sync_close_fill external close failed: {lifecycle_err}")
                state["position_open"] = False
                _seal_close_guard(
                    state,
                    closed_side=str(state_before_close.get("position_side", "") or ""),
                    closed_ticket=int(state_before_close.get("position_ticket", 0) or 0),
                    close_action="EXTERNAL_CLOSE_DETECTED",
                )
                state["position_ticket"] = 0
                state["position_time"] = 0
                state["position_signal_id"] = ""
                state["position_decision_id"] = ""
                state["position_regime"] = "UNKNOWN"
                state["position_strategy_id"] = ""
                state["position_trace_id"] = ""
                state["position_confidence"] = 0.0
                state["position_session_info"] = {}
                state["position_session_metrics"] = {}
                state["position_interpreter"] = {}
                state["position_attribution"] = {}
                state["position_agent_direction"] = ""
                state["position_entry_owner_instance_id"] = ""
                state["position_entry_owner_strategy_id"] = ""
                state["position_current_manager_instance_id"] = ""
                state["position_current_manager_strategy_id"] = ""
                state["position_expected_exit_owner_instance_id"] = ""
                state["position_expected_exit_owner_strategy_id"] = ""
                state["position_exit_owner_instance_id"] = ""
                state["position_exit_owner_strategy_id"] = ""
                state["position_contributors"] = []
                state["recent_closures"] = state_before_close.get("recent_closures", [])
                state["pattern_memory"] = state_before_close.get("pattern_memory", {})
                state["runtime_adaptation"] = state_before_close.get("runtime_adaptation", {})
                state["learning_adaptation_summary"] = state_before_close.get("learning_adaptation_summary", {})
                _apply_realized_closure_stats(state, close_pnl, position_open=False)
                global _last_close_time
                _last_close_time = state["last_close_time"]
                save_state(state)
                _log(f"🔴 持仓退出: PnL={close_pnl:.2f} | 持仓{duration_min}分 | {prev_side} 入场={state_before_close.get('position_entry',0):.2f} | 单号={prev_ticket}", "WARN")
                return

            if not state.get("position_open"):
                if was_open:
                    _seal_close_guard(
                        state,
                        closed_side=prev_side,
                        closed_ticket=prev_ticket,
                        close_action="MONITOR_EXIT_DETECTED",
                    )
                save_state(state); _log("监控退出：已平仓", "WARN"); return
            bid, ask, _, _, _ = get_price()
            if bid is None: time.sleep(INTERVAL_HAS_POS); continue

            # ==== SL/TP 触发检查 ====
            should_close, reason, _ = check_close_conditions(bid, ask, state)
            if should_close:
                _log(f"🚨 SL/TP触发平仓: {reason} bid={bid} ask={ask}", "WARN")
                write_trade_request(
                    "CLOSE",
                    state["position_side"],
                    0,
                    0,
                    0,
                    state.get("position_vol", 0.01),
                    sig_id=state.get("position_signal_id") or state.get("position_decision_id"),
                    decision_id=state.get("position_decision_id"),
                    ticket=state.get("position_ticket", 0),
                    request_origin="legacy_position_monitor",
                )
                time.sleep(5); state = load_state(); state = process_ea_reports(state)
                if not state.get("position_open"):
                    _seal_close_guard(
                        state,
                        closed_side=prev_side,
                        closed_ticket=prev_ticket,
                        close_action="SLTP_CLOSE_DETECTED",
                    )
                    save_state(state)
                    return

            # ==== 出场策略引擎（直接内联，每10秒）====
            if cycle % 1 == 0:
                try:
                    # 检查宏观一票否决权（如果在持仓中遇到高危数据公布，强制触发清仓逃跑）
                    from bridge.macro_calendar import get_macro_event_signal
                    macro_signal = get_macro_event_signal()
                    macro_tier = macro_signal.get("tier", "NONE")
                    macro_reason = macro_signal.get("reason", "")
                    if macro_tier == "BLOCK":
                        _log(f"🚨 MACRO ESCAPE: 触发宏观静默期，强制清仓逃跑! ({macro_reason})", "WARN")
                        write_trade_request(
                            "CLOSE",
                            state["position_side"],
                            0,
                            0,
                            0,
                            state.get("position_vol", 0.01),
                            sig_id=state.get("position_signal_id") or state.get("position_decision_id"),
                            decision_id=state.get("position_decision_id"),
                            ticket=state.get("position_ticket", 0),
                            request_origin="legacy_macro_escape",
                        )
                        time.sleep(5)
                        state = load_state()
                        state = process_ea_reports(state)
                        if not state.get("position_open"):
                            _seal_close_guard(
                                state,
                                closed_side=prev_side,
                                closed_ticket=prev_ticket,
                                close_action="MACRO_ESCAPE_CLOSE_DETECTED",
                            )
                            save_state(state)
                            return
                    elif macro_tier == "ALERT":
                        _emit_macro_notice("🟡 MACRO ALERT", macro_reason, cooldown_sec=300)

                    from bridge.exit_optimizer import evaluate_exit, execute_exit_actions
                    from bridge.strategy import _read_bars
                    from src.market_structure import load_structure_snapshot
                    # 读取实时指标
                    bars = _read_bars(120)
                    if bars:
                        from signal_generators.ema_macd_signal import _ema, _adx, _rsi, _macd, _atr
                        closes = [b["close"] for b in bars]
                        ema9_v = _ema(closes, 9)[-1]
                        ema21_v = _ema(closes, 21)[-1]
                        adx_v, regime_label, adx_series = _adx(bars, 14)
                        adx_prev_v = adx_series[-2] if len(adx_series) >= 2 else adx_v
                        rsi_v = _rsi(closes, 14)[-1]
                        macd_v, macd_sig_v, macd_hist_v = _macd(closes)
                        macd_hist_prev_v = macd_hist_v[-2] if len(macd_hist_v) >= 2 else macd_hist_v[-1]
                        atr_v = _atr(bars, 14)[-1]
                        structure_snapshot = load_structure_snapshot(valid_only=True)
                        trend_strength_v = "TRENDING" if adx_v >= 30 else ("WEAK" if adx_v < 20 else "NORMAL")
                        indicators = {
                            "ema9": ema9_v, "ema21": ema21_v,
                            "adx": adx_v, "regime": regime_label,
                            "rsi": rsi_v, "macd_hist": macd_hist_v[-1],
                            "macd_hist_prev": macd_hist_prev_v,
                            "atr": atr_v,
                            "adx_prev": adx_prev_v,
                            "trend_strength": trend_strength_v,
                            "bars": bars[-60:],
                            "structure_snapshot": structure_snapshot,
                            "macro_signal": macro_signal,
                            "bid": bid, "ask": ask,
                        }
                        should_exit, reason, exit_detail = evaluate_exit(bid, ask, state, indicators, atr_v)
                        trail_debug = (exit_detail or {}).get("trail_debug", {}) if isinstance(exit_detail, dict) else {}
                        if isinstance(trail_debug, dict) and trail_debug:
                            tele = state.get("trailing_telemetry", {}) if isinstance(state.get("trailing_telemetry", {}), dict) else {}
                            blocked_total = int(tele.get("sl_move_step_blocked_count", 0) or 0) + int(
                                trail_debug.get("blocked_step_count", 0) or 0
                            )
                            tele.update(
                                {
                                    "phase": str(trail_debug.get("phase", "NONE") or "NONE"),
                                    "trail_basis": float(trail_debug.get("trail_basis", 0.0) or 0.0),
                                    "trail_dist_atr": float(trail_debug.get("trail_dist_atr", 0.0) or 0.0),
                                    "candidate_sl": float(trail_debug.get("candidate_sl", 0.0) or 0.0),
                                    "blocked_by_step": bool(trail_debug.get("blocked_by_step", False)),
                                    "opportunity_state": str(trail_debug.get("opportunity_state", "UNKNOWN") or "UNKNOWN"),
                                    "profit_capture_ratio": float(trail_debug.get("profit_capture_ratio", 0.0) or 0.0),
                                    "profit_giveback_atr": float(trail_debug.get("profit_giveback_atr", 0.0) or 0.0),
                                    "expected_wave_atr": float(trail_debug.get("expected_wave_atr", 0.0) or 0.0),
                                    "wave_completion": float(trail_debug.get("wave_completion", 0.0) or 0.0),
                                    "peak_completion": float(trail_debug.get("peak_completion", 0.0) or 0.0),
                                    "bars_since_peak": int(trail_debug.get("bars_since_peak", 0) or 0),
                                    "momentum_decay_score": float(trail_debug.get("momentum_decay_score", 0.0) or 0.0),
                                    "trend_run_score": float(trail_debug.get("trend_run_score", 0.0) or 0.0),
                                    "trend_run_qualified": bool(trail_debug.get("trend_run_qualified", False)),
                                    "trend_run_transition": str(trail_debug.get("trend_run_transition", "WAIT") or "WAIT"),
                                    "sl_move_step_blocked_count": blocked_total,
                                    "updated_at": int(time.time()),
                                }
                            )
                            state["trailing_telemetry"] = tele
                            state["trend_run_mode"] = bool((exit_detail or {}).get("trend_run_mode", False))
                            _persist_trailing_telemetry(tele)
                            if cycle % 3 == 0:
                                save_state(state)
                        if should_exit and exit_detail and "actions" in exit_detail:
                            actions = exit_detail["actions"]
                            _log(f"🚨 出场优化: {reason} | ATR={atr_v:.1f}pts", "WARN")
                            exec_count, total_count = execute_exit_actions(actions, state, bid, ask)
                            _log(f"  执行 {exec_count}/{total_count} 个动作", "WARN")
                        
                            # 请求写入成功不等于券商已执行；实际持仓字段必须等待 EA 回执确认后再更新。
                            save_state(state)

                            time.sleep(3)
                            state = load_state()
                            partial_before = bool(state.get("partial_done", False))
                            state = process_ea_reports(state)
                            if bool(state.get("partial_done", False)) and not partial_before:
                                save_state(state)
                            if not state.get("position_open"):
                                _seal_close_guard(
                                    state,
                                    closed_side=prev_side,
                                    closed_ticket=prev_ticket,
                                    close_action="EXIT_OPTIMIZER_CLOSE_DETECTED",
                                )
                                save_state(state)
                                _log("出场完成，已平仓", "WARN")
                                return
                except Exception as e:
                    import traceback
                    _daemon_log(f"ExitOptimizer error: {e}")
                    _daemon_log(f"Trace: {traceback.format_exc()[:500]}")

            # ==== 持仓状态更新日志（每轮必写，提供SL/TP/ATR/时长） ====
            entry = state.get("position_entry", 0)
            vol = state.get("position_vol", 0.01)
            sl = state.get("position_sl", 0)
            tp = state.get("position_tp", 0)
            pnl = (bid - entry) * 100 * vol if state["position_side"] == "BUY" else (entry - ask) * 100 * vol
            pos_dur = int((time.time() - state.get("position_time", time.time())) / 60)

            # 尝试获取 ATR 供日志参考
            _pos_atr_str = ""
            try:
                from bridge.strategy import _read_bars
                _pos_bars = _read_bars(50)
                if len(_pos_bars) >= 20:
                    from signal_generators.ema_macd_signal import _atr
                    _pos_atr_series = _atr(_pos_bars)
                    _pos_atr_v = next((float(v) for v in reversed(_pos_atr_series) if v is not None), 0)
                    if _pos_atr_v > 0:
                        sl_dist = abs(entry - sl) / _pos_atr_v if sl > 0 else 0
                        tp_dist = abs(tp - entry) / _pos_atr_v if tp > 0 else 0
                        _pos_atr_str = f" ATR={_pos_atr_v:.2f} sl={sl_dist:.1f}x tp={tp_dist:.1f}x"
            except Exception:
                pass

            if cycle % 6 == 0:
                _log(
                    f"📊 持仓 #{cycle}: {state['position_side']} "
                    f"入场={entry:.2f} SL={sl:.2f} TP={tp:.2f} "
                    f"现价={bid:.2f}/{ask:.2f} "
                    f"浮盈~${pnl:.2f} "
                    f"持仓{pos_dur}分"
                    f"{_pos_atr_str}"
                )
            _refresh_micro_sniper_hold_state(state)
            # 每轮更新黑板的浮盈数据（不阻塞主流程）
            try:
                _update_shadow_system(position_open=True)
            except Exception:
                pass
            save_state(state)
        except Exception as e:
            import traceback
            _daemon_log(f"监控循环异常: {e}")
            _daemon_log(f"Trace: {traceback.format_exc()[:500]}")
        time.sleep(INTERVAL_HAS_POS)

# ============ 无持仓：信号轮询（含 Agent 方向过滤）============
def signal_poll_loop():
    global _override_counter, _last_override_signal_time, _last_agent_wakeup_time, _startup_banner_emitted
    state = load_state()
    if not isinstance(state, dict):
        state = {}
    _self_heal_consecutive_losses(state)
    if state.get("position_open"): return
    _daemon_log("Signal poll mode")
    cycle = 0

    # ===== 每次轮询开始前，检查/更新 Agent 方向决策 =====
    agent_dir, agent_data = get_agent_direction()
    _daemon_log(f"Agent direction: {agent_dir} (updated: {agent_data.get('updated','unknown')})")

    # ── 系统启动信息日志 ──
    if not _startup_banner_emitted:
        try:
            startup_trade_summary = _build_runtime_trade_summary(state)
            _log(
                f"🟢 系统启动 | PID={os.getpid()} | LobsterQuant gold_autotrade | "
                f"Agent={agent_dir} 更新于={agent_data.get('updated','unknown')} | "
                f"已平仓盈亏={startup_trade_summary.get('pnl', 0.0):+.2f} "
                f"({startup_trade_summary.get('trades', 0)}笔已平仓)",
                "INFO",
            )
            _startup_banner_emitted = True
        except Exception:
            pass

    while True:
        cycle += 1
        state = load_state()
        if not isinstance(state, dict):
            state = {}
        _self_heal_consecutive_losses(state)
        if state.get("position_open"): return
        state = process_ea_reports(state)
        if state.get("position_open"): return

        # ==== 状态对账（每轮必检，防止孤儿/幽灵持仓） ====
        state = reconcile_positions(state)
        if not isinstance(state, dict):
            _daemon_log("Reconciliation returned invalid state; recovering from load_state()")
            state = load_state()
            if not isinstance(state, dict):
                state = {}
        if state.get("position_open"): return

        # ==== 平仓后强制最短冷却期（防止双开 bug） ====
        # 在 reconcile 清理持仓后立即检查，避免冷却时间未被 signal poll 读取到就先执行了新信号
        cooldown_remaining = _cooldown_remaining_sec(state)
        if cooldown_remaining > 0:
            if cycle % 3 == 1:  # 每3个周期打印一次，避免日志刷屏
                _log(f"⏳ [Cooldown] 平仓后冷却中，还需{cooldown_remaining}秒", "INFO")
            time.sleep(30)
            continue

        if state.get("position_ticket") and not state.get("position_open"):
            _daemon_log(f"Signal poll deferred: pending ghost reconciliation for ticket={state.get('position_ticket')}")
            # 不调用 save_state：ghost_check_count 已由 reconcile_positions 写入 DB，
            # save_state 会把 state 字典中的旧值（1）覆盖掉刚 commit 的新值（2）
            return

        # 低置信复核队列已停用（与 LOW_CONFIDENCE 合并为单闸门），不再处理 review approve/reject。

        # ==== 宏观新闻一票否决拦截 ====
        try:
            from bridge.macro_calendar import get_macro_event_signal
            macro_signal = get_macro_event_signal()
            macro_tier = macro_signal.get("tier", "NONE")
            macro_reason = macro_signal.get("reason", "")
            if macro_tier == "BLOCK":
                _emit_veto_lifecycle(
                    "MACRO",
                    macro_reason or "MACRO_BLOCK",
                    cooldown_sec=180,
                    tier=macro_tier,
                    reason_text=macro_reason,
                )
                _emit_macro_notice("🚨 MACRO BLOCK", macro_reason, cooldown_sec=180)
                time.sleep(30)
                continue
            if macro_tier == "ALERT":
                _emit_macro_notice("🟡 MACRO ALERT", macro_reason, cooldown_sec=300)
        except Exception as e:
            _daemon_log(f"Macro block check failed: {e}")

        bid, ask, spread, price_source, price_age = get_price()
        if bid is None: time.sleep(30); continue
        # 数据过期 → 停止所有交易
        if price_source == "stale":
            _daemon_log(f"[CRITICAL] MT5数据过期{price_age}s，引擎暂停交易，等待数据恢复")
            time.sleep(30); continue
        try:
            from src.learning.shadow_learner import update_shadow_positions
            update_shadow_positions(bid, ask)
        except Exception as e:
            _daemon_log(f"shadow learner position update failed: {e}")
        try:
            from bridge.shadow_evolver import update_positions as evolver_update_positions
            evolver_update_positions(bid, ask)
        except Exception as e:
            _daemon_log(f"evolver shadow position update failed: {e}")
        if cycle % 3 == 0:
            try:
                from src.auto_governance import run_auto_governance_cycle
                auto_result = run_auto_governance_cycle(ROOT, "default", source="gold_trader")
                if auto_result.get("action") not in {"NOOP", "AUTO_BLOCK"}:
                    _daemon_log(
                        f"auto governance action={auto_result.get('action')} "
                        f"mode={auto_result.get('promotion_mode')} "
                        f"reason={auto_result.get('reason')}"
                    )
            except Exception as e:
                _daemon_log(f"auto governance cycle failed: {e}")
        if cycle % 30 == 0:
            try:
                from bridge.shadow_evolver import check_promotion
                promo_result = check_promotion()
                if promo_result in ("PROMOTE", "ROLLBACK"):
                    _log(f"EVOLVER {promo_result}: 影子参数对比完成", "WARN")
            except Exception as e:
                _daemon_log(f"evolver check_promotion failed: {e}")

        # ==== 软挂单触发检查 ====
        _retire_legacy_soft_limits(state)
        process_soft_limits(bid, ask)

        # ── EA K线数据新鲜度检查（基于实际数据可用性，而非文件时间戳）──
        # 验证 generate_signal 返回的 bars 是否真正可用
        # 如果 bars 不足（EA 未响应），generate_signal 本身会返回 HOLD，无需在此额外拦截
        # 此处仅记录 EA 响应延迟指标，供健康监控使用
        try:
            from_ea_dir = BRIDGE_FROM_EA
            latest_req_mtime = None
            if from_ea_dir.exists():
                # 检查 to_ea 中是否有积压的未处理请求（说明 EA 响应慢）
                to_ea_dir = BRIDGE_TO_EA
                pending = sorted(to_ea_dir.glob("*.request.json"), key=lambda x: x.stat().st_mtime)
                if pending:
                    oldest = int(time.time() - pending[0].stat().st_mtime)
                    if oldest > 60:
                        _daemon_log(f"[WARN] EA 响应延迟{oldest}s，to_ea积压{len(pending)}个请求")
        except Exception as e:
            _daemon_log(f"[WARN] EA响应延迟检查失败: {e}")

        # 价格合理性检查已移除（yfinance 已弃用，数据源唯一为 MT5）

        # ============ [GRID LAYER] 网格交易 ==========
        # 周期级锁：防止网格和主趋势在同一周期重复开仓
        grid_order_written_this_cycle = False
        # 有持仓时跳过网格（有主趋势单在跑）
        if not state.get("position_open"):
            try:
                from bridge.grid_strategy import check_grid_signals, write_grid_order
                agent_dir_str, _ = get_agent_direction()
                grid_orders = check_grid_signals(bid, ask, state, agent_dir_str)
                for order in grid_orders:
                    if write_grid_order(order):
                        _daemon_log(f"[GRID] Triggered: {order['side']} grid @ zone {order.get('enter_zone')} vol={order['volume']}")
                        grid_order_written_this_cycle = True
            except Exception as e:
                _daemon_log(f"[GRID] error: {e}")
        # ===========================================

        # 统一仲裁模式：Governor 只保留观测权，不再拥有硬阻断权。
        approved, reason = True, "SINGLE_OWNER_RISK_ARBITER"
        if not SINGLE_OWNER_RISK_ARBITER:
            approved, reason = governor_check(bid, ask, state)
            if not approved:
                _emit_veto_lifecycle(
                    "GOVERNOR",
                    reason or "GOVERNOR_BLOCKED",
                    cooldown_sec=180,
                    bid=bid,
                    ask=ask,
                    spread=spread,
                )
                _log_risk_event(reason, bid, ask, spread)
                if cycle % 5 == 0:
                    _log(f"Governor: {reason} | XAUUSD {bid}/{ask}", "INFO")
                time.sleep(INTERVAL_NO_POS)
                continue
        elif cycle % 20 == 0:
            obs_ok, obs_reason = governor_check(bid, ask, state)
            if not obs_ok:
                _daemon_log(f"[OBSERVE_ONLY] Governor would block: {obs_reason}")

        # 生成技术信号（方向由 v2 与冲突解决器统一仲裁）
        agent_dir_current, agent_dir_data = get_agent_direction()
        pipeline_result = _try_pipeline_primary_entry(state)
        if pipeline_result.get("handled"):
            state = pipeline_result.get("state", state)
            if state.get("position_open") or pipeline_result.get("position_open"):
                save_state(state)
                return
            time.sleep(INTERVAL_NO_POS)
            continue
        if pipeline_result.get("fallback_to_legacy"):
            state = _block_legacy_live_open_after_pipeline_unavailable(state, pipeline_result).get("state", state)
            time.sleep(INTERVAL_NO_POS)
            continue
        signal = generate_signal(bid, ask, state)
        decision_id = signal.get("_sig_trace_id") or f"learn_{uuid.uuid4().hex}"
        signal["_learning_decision_id"] = decision_id
        signal.setdefault("signal_id", decision_id)
        macro_age_sec = float((agent_dir_data or {}).get("macro_age_sec", 0.0) or 0.0)
        # 宏观方向 >4h 未更新 → 强制重判（不论 HOLD 状态，含 240s cooldown）
        if macro_age_sec > 14400:
            _trigger_macro_direction_poll(
                reason=f"MACRO_AUTHORITY_DECAYED,age={int(macro_age_sec)}s",
                current_regime=str(signal.get("regime", "UNKNOWN") or "UNKNOWN"),
            )
        # HOLD 升级机制：连续 HOLD 且趋势成立时，强制进入探针执行路径，避免无限等待 Wave4。
        if signal.get("action") == "HOLD":
            hold_stats = state.get("hold_stats", {}) if isinstance(state.get("hold_stats", {}), dict) else {}
            hold_streak = int(hold_stats.get("streak", 0) or 0) + 1
            hold_stats["streak"] = hold_streak
            hold_stats.setdefault("first_hold_ts", time.time())
            hold_stats["last_reason"] = str(signal.get("reason", "") or "")
            hold_stats["last_regime"] = str(signal.get("regime", "UNKNOWN") or "UNKNOWN")
            hold_duration_sec = max(0.0, time.time() - float(hold_stats.get("first_hold_ts", time.time())))
            hold_stats["hold_duration_sec"] = int(hold_duration_sec)
            macro_stale = bool((agent_dir_data or {}).get("macro_stale", False))
            if (
                hold_streak >= HOLD_MACRO_POLL_MIN_STREAK
                and (macro_stale or macro_age_sec > 14400 or hold_duration_sec >= HOLD_MACRO_POLL_INTERVAL_SEC)
            ):
                _h_pos = None
                if state.get("position_open") and state.get("position_side"):
                    _h_pos = {
                        "side": state["position_side"],
                        "entry_price": state.get("position_entry", 0),
                        "sl": state.get("position_sl", 0),
                        "tp": state.get("position_tp", 0),
                        "age_sec": time.time() - float(state.get("position_time", time.time())),
                    }
                _h_regime = str(signal.get("regime", "UNKNOWN") or "UNKNOWN")
                if _trigger_macro_direction_poll(
                    reason=f"HOLD_STREAK={hold_streak},hold_dur={int(hold_duration_sec)}s,macro_age={int(macro_age_sec)}s",
                    current_position=_h_pos, current_regime=_h_regime,
                ):
                    hold_stats["last_macro_poll_ts"] = int(time.time())
            state["hold_stats"] = hold_stats
        else:
            state["hold_stats"] = {
                "streak": 0,
                "first_hold_ts": 0,
                "hold_duration_sec": 0,
                "last_macro_poll_ts": int(state.get("hold_stats", {}).get("last_macro_poll_ts", 0) or 0)
                if isinstance(state.get("hold_stats", {}), dict)
                else 0,
                "last_reason": "",
                "last_regime": str(signal.get("regime", "UNKNOWN") or "UNKNOWN"),
            }

        if signal.get("action") == "HOLD":
            hold_stats = state.get("hold_stats", {}) if isinstance(state.get("hold_stats", {}), dict) else {}
            hold_streak = int(hold_stats.get("streak", 0) or 0)
            ind_h = signal.get("indicators", {}) if isinstance(signal.get("indicators", {}), dict) else {}
            hold_reason = str(signal.get("reason", "") or "")
            ema9_h = float(ind_h.get("ema9", 0.0) or 0.0)
            ema21_h = float(ind_h.get("ema21", 0.0) or 0.0)
            adx_h = float(ind_h.get("adx", 0.0) or 0.0)
            atr_h = float(ind_h.get("atr14", 0.0) or 0.0)
            regime_h = str(signal.get("regime", "UNKNOWN") or "UNKNOWN").upper()
            overlap_active = _is_overlap_window()
            min_attempts = int(_get_effective_param("overlap_min_attempts_per_hour", 2) or 2)
            overlap_attempts = _get_overlap_attempts(state)
            trend_side = "BUY" if ema9_h > ema21_h else "SELL" if ema9_h < ema21_h else ""
            trend_ready = adx_h >= 28 and trend_side in {"BUY", "SELL"} and regime_h in {"TRENDING", "BREAKOUT", "SHOCK_REPRICING", "DRIFTING", "EXTREME_TREND", "UNCERTAIN"}
            range_hold_reason = (
                "无弹簧/假突破结构" in hold_reason
                or "无区间突破信号" in hold_reason
                or "所有子策略均为 HOLD" in hold_reason
            )
            range_probe_ready = (
                hold_streak >= 2
                and range_hold_reason
                and overlap_active
                and overlap_attempts < min_attempts
                and trend_side in {"BUY", "SELL"}
                and adx_h >= 20
                and atr_h > 0
            )
            should_escalate_probe = (
                hold_streak >= 3
                and trend_ready
                and overlap_active
                and overlap_attempts < min_attempts
            )
            if (should_escalate_probe or range_probe_ready) and atr_h > 0:
                signal["reason"] = (
                    f"{signal.get('reason','')} | HOLD_ESCALATED_REVIEW(streak={hold_streak},budget={overlap_attempts}/{min_attempts})"
                )
                _daemon_log(
                    f"[HOLD_ESCALATION] review requested: adx={adx_h:.1f} streak={hold_streak} "
                    f"overlap_budget={overlap_attempts}/{min_attempts} range_probe={range_probe_ready}"
                )
                _h2_pos = None
                if state.get("position_open") and state.get("position_side"):
                    _h2_pos = {
                        "side": state["position_side"],
                        "entry_price": state.get("position_entry", 0),
                        "sl": state.get("position_sl", 0),
                        "tp": state.get("position_tp", 0),
                        "age_sec": time.time() - float(state.get("position_time", time.time())),
                    }
                _h2_regime = str(signal.get("regime", "UNKNOWN") or "UNKNOWN")
                _trigger_macro_direction_poll(
                    reason=(
                        f"HOLD_ESCALATED_REVIEW:streak={hold_streak},regime={regime_h},"
                        f"budget={overlap_attempts}/{min_attempts},side={trend_side}"
                    ),
                    force=True,
                    current_position=_h2_pos, current_regime=_h2_regime,
                )
        # 方向时效冲突解决：宏观方向不再无限期压制微观高质量突破。
        resolver = _resolve_direction_conflict(
            agent_dir_current,
            agent_dir_data if isinstance(agent_dir_data, dict) else {},
            signal,
            signal.get("indicators", {}) if isinstance(signal.get("indicators", {}), dict) else {},
            state,
        )
        if resolver.get("conflict"):
            action = str(resolver.get("action", "FOLLOW_MACRO") or "FOLLOW_MACRO")
            two_way_allowed = bool(resolver.get("two_way_allowed", False))
            signal.setdefault("resolver", {})
            signal["resolver"] = {
                "action": action,
                "macro_age_sec": round(float(resolver.get("macro_age_sec", 0.0) or 0.0), 1),
                "macro_authority": round(float(resolver.get("macro_authority", 1.0) or 1.0), 4),
                "micro_quality": round(float(resolver.get("micro_quality", 0.0) or 0.0), 3),
                "streak": int(resolver.get("streak", 0) or 0),
                "macro_prob": round(float(resolver.get("macro_prob", 0.0) or 0.0), 3),
                "two_way_allowed": two_way_allowed,
                "regime": str(resolver.get("regime", signal.get("regime", "UNKNOWN"))),
            }
            signal["reason"] = f"{signal.get('reason','')} | resolver={action}"
            if action == "ALLOW_MICRO_PROBE":
                signal["confidence"] = max(float(signal.get("confidence", 0.0) or 0.0), 0.64)
            elif action == "FLIP_TO_MICRO":
                signal["confidence"] = max(float(signal.get("confidence", 0.0) or 0.0), 0.68)
            weak_countertrend_window = _is_weak_trend_countertrend_window(
                signal.get("regime", "UNKNOWN"),
                signal.get("indicators", {}) if isinstance(signal.get("indicators", {}), dict) else {},
            )
            if (not two_way_allowed) and (not weak_countertrend_window) and _is_direction_conflict(agent_dir_current, signal.get("side", "")):
                if action in ("ALLOW_MICRO_PROBE", "FLIP_TO_MICRO"):
                    _daemon_log(
                        f"[DIRECTION_LOCK] resolver overrode lock: action={action} "
                        f"regime={signal.get('regime','UNKNOWN')} agent={agent_dir_current} side={signal.get('side','')}"
                    )
                else:
                    signal["action"] = "HOLD"
                    signal["reason"] = f"{signal.get('reason','')} | TREND_DIRECTION_LOCK"
                    _daemon_log(
                        f"[DIRECTION_LOCK] trend regime blocks counter-trend signal: regime={signal.get('regime','UNKNOWN')} "
                        f"agent={agent_dir_current} side={signal.get('side','')}"
                    )
            _daemon_log(
                f"[DIRECTION_RESOLVER] action={action} authority={resolver.get('macro_authority',1):.3f} "
                f"macro_age={resolver.get('macro_age_sec',0):.1f}s "
                f"micro_q={resolver.get('micro_quality',0):.2f} streak={resolver.get('streak',0)}"
            )
            save_state(state)
            macro_authority_v = float(resolver.get("macro_authority", 1.0) or 1.0)
            if macro_authority_v < 0.20:
                _trigger_macro_direction_poll(
                    reason=f"MACRO_AUTHORITY_COLLAPSED,authority={macro_authority_v:.3f},age={resolver.get('macro_age_sec',0):.0f}s",
                    current_regime=str(signal.get("regime", "UNKNOWN") or "UNKNOWN"),
                )
        if signal.get("action") != "HOLD":
            _transition_decision_state(
                decision_id,
                "SIGNALING",
                reason="SIGNAL_GENERATED",
                action=signal.get("action"),
                side=signal.get("side"),
                confidence=signal.get("confidence", 0),
                regime=signal.get("regime", "UNKNOWN"),
            )
        # 将 EMA 数据加入日志输出
        ema_str = ""
        ind = signal.get("indicators", {})
        if "ema9" in ind and "ema21" in ind and "ema50" in ind:
            ema_str = f" [EMA 9={ind['ema9']:.1f}/21={ind['ema21']:.1f}/50={ind['ema50']:.1f}]"

        # 阶段二双调：信号生成后，调用 RiskManager 做完整风控评估（用于对比验证）
        risk_decision = None
        learner_signal = {"status": "EMPTY", "action": "HOLD"}
        learner_risk_payload = {}
        if signal.get("action") != "HOLD":
            _transition_decision_state(
                decision_id,
                "RISK_CHECK",
                reason="ENTER_RISK_CHECK",
                action=signal.get("action"),
                side=signal.get("side"),
                confidence=signal.get("confidence", 0),
            )
            
            # [Swarm] 提前上报 Micro_Sniper 的发现
            try:
                runtime_blackboard = _get_runtime_blackboard()
                current_t = int(time.time())
                last_side = getattr(runtime_blackboard, "_last_alert_side", None)
                last_time = getattr(runtime_blackboard, "_last_alert_time", 0)
                _tmp_conf = signal.get("confidence", 0) * 100
                if current_t - last_time > 60 or signal.get("side") != last_side:
                    side_zh = "做多(BUY)" if signal.get('side') == "BUY" else "做空(SELL)"
                    msg = f"报告政委：我捕捉到了一个强烈的 {side_zh} 信号（置信度: {_tmp_conf:.1f}%）。理由是 {signal.get('reason', '')}。请求战术指导。"
                    _runtime_blackboard_log("Micro_Sniper", "alert", msg)
                    runtime_blackboard._last_alert_time = current_t
                    runtime_blackboard._last_alert_side = signal.get("side")
            except Exception as e:
                _daemon_log(f"[BLACKBOARD_SYNC] Micro_Sniper alert sync failed: {e}")

            pre_rm_agent_multiplier = 1.0
            pre_rm_agent_reason = ""
            pre_rm_override_data = load_active_interpreter_payload()
            if pre_rm_override_data:
                side = signal.get("side")
                if side == "BUY":
                    pre_rm_agent_multiplier = pre_rm_override_data.get("buy_multiplier", 1.0)
                elif side == "SELL":
                    pre_rm_agent_multiplier = pre_rm_override_data.get("sell_multiplier", 1.0)
                pre_rm_agent_multiplier = _normalize_agent_override_multiplier(
                    agent_dir_current,
                    side,
                    pre_rm_agent_multiplier,
                    pre_rm_override_data,
                )
                if pre_rm_agent_multiplier != 1.0:
                    pre_rm_agent_reason = pre_rm_override_data.get("reason", "")
            signal["agent_direction"] = agent_dir_current
            signal["interpreter"] = _build_interpreter_context(
                override_data=pre_rm_override_data,
                agent_multiplier=pre_rm_agent_multiplier,
                reason=pre_rm_agent_reason,
                applied=pre_rm_agent_multiplier != 1.0,
            )
            rm = _get_risk_manager()
            if rm:
                try:
                    _load_params_suggestion()  # 刷新参数缓存
                    context = {
                        "price_bid": bid, "price_ask": ask,
                        "spread": spread, "state": state, "params": {},
                        "interpreter": signal.get("interpreter", {}),
                        "agent_direction": agent_dir_current,
                        "selection_regime": signal.get("_selection_regime", signal.get("regime", "UNKNOWN")),
                        "selected_strategy": signal.get("_selected_strategy", signal.get("_sig_strategy_id", "unknown")),
                    }
                    # 将旧的 RSI/BB 过滤合并为 RiskManager 输入因子，避免 evaluate 之后二次夺权。
                    ind0 = signal.get("indicators", {}) if isinstance(signal.get("indicators", {}), dict) else {}
                    side0 = str(signal.get("side", "") or "").upper()
                    rsi0 = float(ind0.get("rsi14", ind0.get("rsi", 50.0)) or 50.0)
                    long_blocked0 = _get_effective_param("trend_mode_long_filter", None) == "BLOCK"
                    short_blocked0 = _get_effective_param("trend_mode_short_filter", None) == "BLOCK"
                    bb_upper0 = float(ind0.get("bb_upper", 0.0) or 0.0)
                    bb_lower0 = float(ind0.get("bb_lower", 0.0) or 0.0)
                    close0 = float(ind0.get("close", 0.0) or 0.0)
                    vol_ratio0 = float(_get_effective_param("vol_ratio", 1.0) or 1.0)
                    context["legacy_filters"] = {
                        "rsi_veto_buy": bool(side0 == "BUY" and long_blocked0),
                        "rsi_veto_sell": bool(side0 == "SELL" and short_blocked0),
                        "rsi_value": rsi0,
                        "bb_touch_buy_upper": bool(side0 == "BUY" and bb_upper0 > 0 and close0 >= bb_upper0),
                        "bb_touch_sell_lower": bool(side0 == "SELL" and bb_lower0 > 0 and close0 <= bb_lower0),
                        "bb_vol_ratio": vol_ratio0,
                    }
                    try:
                        from src.market_adaptive_profile import build_market_adaptive_profile
                        context["adaptive_profile"] = build_market_adaptive_profile(signal.get("interpreter", {}))
                    except Exception:
                        context["adaptive_profile"] = {}
                    wrapped = {
                        "schema_version": "2.1.0",
                        "trace_id": f"daemon_{int(time.time()*1000)}",
                        "strategy_id": signal.get("_sig_strategy_id", "ema_macd_v2"),
                        "payload": {
                            "action": signal.get("action", "HOLD"),
                            "confidence": signal.get("confidence", 0),
                            "side": signal.get("side", "BUY"),
                            "entry": signal.get("entry", 0),
                            "sl": signal.get("sl", 0),
                            "tp": signal.get("tp", 0),
                            "indicators": signal.get("indicators", {}),
                            "regime": signal.get("regime", "UNKNOWN"),
                        }
                    }
                    risk_decision = rm.evaluate(wrapped, context)
                    _record_learning_risk_verdict(decision_id, signal, risk_decision)
                    # 对比新旧结果（记录到日志）
                    _compare_risk_results(approved, reason, risk_decision, signal)
                    rm_payload = risk_decision.get("payload", {}) if isinstance(risk_decision, dict) else {}
                    try:
                        from bridge.strategy import _read_bars
                        from src.learning.shadow_learner import build_shadow_signal, record_shadow_comparison, register_shadow_position
                        try:
                            from bridge.garch_volatility import get_vol_multiplier
                            shadow_vol_ratio = get_vol_multiplier().get("vol_ratio", 1.0)
                        except Exception:
                            shadow_vol_ratio = 1.0
                        shadow_bars = _read_bars(200)
                        learner_signal = build_shadow_signal(shadow_bars, bid, ask, shadow_vol_ratio)
                        if learner_signal.get("status") == "READY" and learner_signal.get("action") == "OPEN":
                            learner_wrapped = {
                                "schema_version": "2.1.0",
                                "trace_id": f"shadow_{decision_id}",
                                "strategy_id": "ema_macd_v2_shadow",
                                "payload": {
                                    "action": learner_signal.get("action", "HOLD"),
                                    "confidence": learner_signal.get("confidence", 0),
                                    "side": learner_signal.get("side", "BUY"),
                                    "entry": learner_signal.get("entry", 0),
                                    "sl": learner_signal.get("sl", 0),
                                    "tp": learner_signal.get("tp", 0),
                                    "indicators": learner_signal.get("indicators", {}),
                                },
                            }
                            learner_risk_decision = rm.evaluate(learner_wrapped, context)
                            learner_risk_payload = learner_risk_decision.get("payload", {}) if isinstance(learner_risk_decision, dict) else {}
                        record_shadow_comparison(
                            {
                                "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
                                "decision_id": decision_id,
                                "baseline": {
                                    "action": signal.get("action", "HOLD"),
                                    "side": signal.get("side", ""),
                                    "confidence": signal.get("confidence", 0),
                                    "risk": rm_payload,
                                },
                                "learner": {
                                    "status": learner_signal.get("status", "UNKNOWN"),
                                    "action": learner_signal.get("action", "HOLD"),
                                    "side": learner_signal.get("side", ""),
                                    "confidence": learner_signal.get("confidence", 0),
                                    "risk": learner_risk_payload,
                                    "reason": learner_signal.get("reason", ""),
                                },
                            }
                        )
                        register_shadow_position(decision_id, learner_signal, learner_risk_payload)
                        # — Evolver 影子信号（params_shadow.json）—
                        try:
                            from bridge.shadow_evolver import build_signal as evolver_build_signal, register_position as evolver_register_position
                            evolver_signal = evolver_build_signal(shadow_bars, bid, ask, shadow_vol_ratio)
                            if evolver_signal.get("status") == "READY" and evolver_signal.get("action") == "OPEN":
                                evolver_register_position(f"evolver_{decision_id}", evolver_signal)
                        except Exception as e2:
                            _daemon_log(f"evolver build_signal failed: {e2}")
                        from src.learning.live_learner import choose_live_signal
                        selected_signal, live_selection = choose_live_signal(
                            decision_id,
                            dict(signal),
                            learner_signal,
                            rm_payload,
                            learner_risk_payload,
                        )
                        if live_selection.get("evidence", {}).get("selected_source") == "learner_live":
                            signal = selected_signal
                            rm_payload = learner_risk_payload
                            _daemon_log(
                                f"learner live selected: mode={live_selection.get('evidence', {}).get('promotion_mode')} "
                                f"reason={live_selection.get('evidence', {}).get('selection_reason')}"
                            )
                    except Exception as e:
                        _daemon_log(f"shadow learner compare failed: {e}")
                    if rm_payload.get("action") == "HOLD":
                        drop_reason = rm_payload.get('drop_reason', 'unknown')
                        session_metrics = _extract_session_metrics(rm_payload.get("session_info", {}))
                        session_metrics_str = _format_session_metrics(session_metrics)
                        _close_decision_state(
                            decision_id,
                            "REJECTED",
                            reason=drop_reason,
                            drop_reason=drop_reason,
                            session_metrics=session_metrics,
                        )
                        _log(f"RiskManager BLOCKED: {drop_reason}{session_metrics_str}{ema_str}", "WARN")
                        _set_final_reject_reason(state, drop_reason, stage="risk_manager")
                        save_state(state)
                        # 更新活跃时间：系统正在工作（只是被风控拦截），不是死锁
                        global _last_active_action; _last_active_action = time.time()
                        
                        # 【异步回执机制】：将风控拦截原因同步推送到全局黑板，实现认知对齐
                        try:
                            side_zh = "做多" if signal.get("side") == "BUY" else "做空"
                            budget_hint = ""
                            if _is_overlap_window():
                                attempts = _get_overlap_attempts(state)
                                min_attempts = int(_get_effective_param("overlap_min_attempts_per_hour", 2) or 2)
                                budget_hint = f" 当前机会预算={attempts}/{min_attempts}。"
                            reply_msg = f"已驳回 {side_zh} 请求。原因: {drop_reason}{session_metrics_str}。{budget_hint}继续潜伏。"
                            _runtime_blackboard_log("Risk_Commissar", "WARN", reply_msg)
                        except Exception:
                            pass
                            
                        time.sleep(INTERVAL_NO_POS); continue
                    if rm_payload:
                        signal["sl"] = rm_payload.get("sl_price", signal.get("sl", 0))
                        signal["tp"] = rm_payload.get("tp_price", signal.get("tp", 0))
                        signal["volume_lots"] = rm_payload.get("lots", signal.get("volume_lots", 0.01))
                        signal["session"] = rm_payload.get("session_info", signal.get("session", {}))
                        signal["interpreter"] = rm_payload.get("interpreter", signal.get("interpreter", {}))
                        signal["session_metrics"] = _extract_session_metrics(rm_payload.get("session_info", {}))
                        signal["performance_profile"] = rm_payload.get("performance_profile", {})
                        signal["contextual_policy"] = rm_payload.get("contextual_policy", {})
                        signal["confidence_threshold_profile"] = rm_payload.get("confidence_threshold_profile", {})
                        signal["confidence"] = rm_payload.get("confidence", signal.get("confidence", 0))
                        

                except Exception as e:
                    _daemon_log(f"RiskManager evaluate error: {e}")
        regime = signal.get("regime","?")
        state["regime"] = regime
        save_state(state)
        ind = signal.get("indicators",{})
        conf = signal.get("confidence", 0)
        raw_conf = float(conf or 0.0)
        override_conf = raw_conf
        
        _skip_execute = False
        # --- 注入 Agent 实时盘面解读 (Market Interpreter) ---
        agent_multiplier = 1.0
        agent_reason = ""
        override_data = load_active_interpreter_payload()
        if override_data:
            side = signal.get("side")
            if side == "BUY":
                agent_multiplier = override_data.get("buy_multiplier", 1.0)
            elif side == "SELL":
                agent_multiplier = override_data.get("sell_multiplier", 1.0)
            agent_multiplier = _normalize_agent_override_multiplier(
                agent_dir_current,
                side,
                agent_multiplier,
                override_data,
            )

            if agent_multiplier != 1.0 and conf > 0:
                agent_reason = override_data.get("reason", "")
                old_conf = conf

                # 修复 Bug 2：防止极低质量信号被大模型强行拉升
                # 如果原始置信度太低 (< 60%)，即使有乘数也不允许它超过 79% (进不了自动开仓门槛)
                if old_conf < 0.60:
                    _skip_execute = True
                    conf = min(0.79, conf * agent_multiplier)
                else:
                    conf = min(0.98, conf * agent_multiplier)
                override_conf = float(conf or 0.0)

                if cycle % 10 == 0:
                    _log(f"🧠 [Agent 参谋加持] {side} 乘数 x{agent_multiplier:.2f} (置信度 {old_conf*100:.1f}% -> {conf*100:.1f}%) | 理由: {agent_reason}", "INFO")
                signal["reason"] = f"{signal.get('reason', '')} | Agent: {agent_reason[:40]}"
                signal["confidence"] = conf
        # --------------------------------------------------
        # --- Online confidence calibration (置信度在线校准) ---
        if bool(_get_effective_param("enable_online_confidence_calibrator", True)) and conf > 0:
            try:
                from bridge.confidence_calibrator import calibrate_confidence_online

                calibrated_conf, calibration_meta = calibrate_confidence_online(
                    raw_confidence=float(conf),
                    recent_closures=state.get("recent_closures", []),
                )
                signal["confidence_raw"] = float(raw_conf)
                signal["confidence_after_override"] = float(override_conf)
                signal["confidence"] = float(calibrated_conf)
                signal["confidence_calibration"] = calibration_meta
                conf = float(calibrated_conf)
                state["confidence_calibration"] = {
                    **(calibration_meta if isinstance(calibration_meta, dict) else {}),
                    "updated_at": int(time.time()),
                }
                if bool((calibration_meta or {}).get("ready", False)):
                    delta = float((calibration_meta or {}).get("delta", 0.0) or 0.0)
                    if abs(delta) >= 0.02 and cycle % 5 == 0:
                        _daemon_log(
                            f"[CONF_CAL] raw={float(signal.get('confidence_raw',0.0))*100:.1f}% -> "
                            f"cal={conf*100:.1f}% (delta={delta*100:.1f}pp, n={int((calibration_meta or {}).get('sample_count',0) or 0)})"
                        )
                save_state(state)
            except Exception as e:
                _daemon_log(f"[CONF_CAL] failed: {e}")

        signal["agent_direction"] = agent_dir_current
        signal["interpreter"] = _build_interpreter_context(
            override_data=override_data,
            agent_multiplier=agent_multiplier,
            reason=agent_reason,
            applied=agent_multiplier != 1.0,
        )
        signal["confidence_trace"] = {
            "raw": round(float(raw_conf), 6),
            "after_override": round(float(override_conf), 6),
            "final": round(float(conf), 6),
            "override_multiplier": round(float(agent_multiplier or 1.0), 6),
            "override_applied": bool(agent_multiplier != 1.0),
            "override_reason": str(agent_reason or ""),
        }
        signal["attribution"] = _build_trade_attribution(
            signal=signal,
            state=state,
            agent_direction=agent_dir_current,
            override_data=override_data,
            agent_multiplier=agent_multiplier,
            interpreter_reason=agent_reason,
            interpreter_applied=agent_multiplier != 1.0,
        )
        _record_learning_observation(
            decision_id,
            state,
            bid,
            ask,
            spread,
            price_source,
            price_age,
            agent_dir_current,
            signal,
        )

        conf_pct = round(conf*100,1)
        session_info = signal.get("session",{})

        # 记录信号到记忆系统（无论是否执行）
        try:
            sys.path.insert(0, str(ROOT))
            from skill_trading_memory import append_signal
            sig_id_for_log = f"sig_{time.strftime('%Y%m%d%H%M%S')}"
            append_signal(
                signal_id=sig_id_for_log,
                action=signal.get("action", "HOLD"),
                side=signal.get("side", "BUY"),
                regime=regime,
                confidence=conf,
                session=session_info.get("session_name", "UNKNOWN"),
                agent_direction=agent_dir_current,
                decision_id=decision_id,
                trace_id=signal.get("_sig_trace_id", ""),
                strategy_id=signal.get("_sig_strategy_id", ""),
                session_metrics=signal.get("session_metrics", {}),
                interpreter=signal.get("interpreter", {}),
                governor="APPROVED" if approved else reason,
                result="GENERATED" if signal.get("action") != "HOLD" else "N/A",
                duration_sec=0,
                indicators=ind,
                sl=signal.get("sl", 0),
                tp=signal.get("tp", 0),
                entry=signal.get("entry", 0),
            )
        except Exception as e:
            _daemon_log(f"append_signal failed: {e}")

        # ===== Agent 方向只保留为总仲裁输入，不在这里二次物理掐灭 =====
        block_reason = ""

        # 完全信任 signal / RiskManager 合成后的置信度
        ema9 = ind.get("ema9", 0); ema21 = ind.get("ema21", 0)
        effective_conf = conf

        # ===== 逆势预算器（趋势主航道 + 逆势小波段预算） =====
        if signal.get("action") != "HOLD":
            ct_gate = _apply_countertrend_budget_gate(
                state=state,
                signal=signal,
                agent_dir=agent_dir_current,
                bid=bid,
                ask=ask,
            )
            signal["countertrend_gate"] = ct_gate
            if ct_gate.get("countertrend"):
                bucket = str(ct_gate.get("bucket", "TRANSITIONAL") or "TRANSITIONAL")
                quality = ct_gate.get("quality", {}) if isinstance(ct_gate.get("quality", {}), dict) else {}
                budget_info = ct_gate.get("budget", {}) if isinstance(ct_gate.get("budget", {}), dict) else {}
                if not bool(ct_gate.get("allow", False)):
                    signal["action"] = "HOLD"
                    signal["reason"] = (
                        f"{signal.get('reason','')} | {ct_gate.get('reason','COUNTERTREND_BLOCK')} "
                        f"(q={float(quality.get('quality_score', 0.0) or 0.0):.2f}, "
                        f"rr={float(quality.get('rr', 0.0) or 0.0):.2f}, "
                        f"used={int(budget_info.get('trades_used', 0) or 0)}/{int(budget_info.get('max_trades', 0) or 0)})"
                    )
                    _daemon_log(
                        f"[COUNTERTREND_BLOCK] bucket={bucket} reason={ct_gate.get('reason')} "
                        f"q={float(quality.get('quality_score',0.0) or 0.0):.2f} "
                        f"rr={float(quality.get('rr',0.0) or 0.0):.2f} "
                        f"budget={int(budget_info.get('trades_used',0) or 0)}/{int(budget_info.get('max_trades',0) or 0)} "
                        f"loss={float(budget_info.get('loss_used_abs',0.0) or 0.0):.2f}/{float(budget_info.get('max_loss_abs',0.0) or 0.0):.2f}"
                    )
                else:
                    contextual_policy = signal.get("contextual_policy", {}) if isinstance(signal.get("contextual_policy", {}), dict) else {}
                    size_cap = 0.30 if bucket == "WEAK_TREND" else 0.40
                    current_mult = float(contextual_policy.get("size_multiplier", 1.0) or 1.0)
                    contextual_policy["size_multiplier"] = min(current_mult, size_cap)
                    contextual_policy["limit_only"] = True
                    contextual_policy["entry_style"] = "LIMIT"
                    contextual_policy["countertrend_bucket"] = bucket
                    contextual_policy["countertrend_quality"] = quality
                    contextual_policy["countertrend_budget"] = budget_info
                    contextual_policy["countertrend_time_stop_sec"] = int(
                        _get_effective_param(
                            "countertrend_time_stop_sec_overlap" if str((signal.get("session_metrics", {}) or {}).get("session_name", "") or "") == "LONDON_NY_OVERLAP"
                            else "countertrend_time_stop_sec",
                            1200,
                        )
                        or 1200
                    )
                    signal["contextual_policy"] = contextual_policy
                    signal["reason"] = (
                        f"{signal.get('reason','')} | COUNTERTREND_BUDGET_OK"
                        f"(bucket={bucket},q={float(quality.get('quality_score',0.0) or 0.0):.2f})"
                    )
            save_state(state)

        # 逼宫机制状态更新
        current_time = time.time()
        side = signal.get("side")
        opposes_agent = (
            (agent_dir_current == "LONG" and side == "SELL")
            or (agent_dir_current == "SHORT" and side == "BUY")
        )
        if opposes_agent and conf >= 0.80:
            if current_time - _last_override_signal_time > 400:
                _override_counter = 1
            else:
                _override_counter += 1
            _last_override_signal_time = current_time
            # ── Append to mutiny evidence queue ──
            _mutiny_evidence_queue.append({
                "side": side,
                "confidence": conf,
                "timestamp": current_time,
                "regime": regime,
                "agent_direction": agent_dir_current,
            })
            _log(
                f"🧭 宏观 {agent_dir_current} 与微观 {side} 冲突，交由总仲裁降权；逼宫计数 {_override_counter}/2",
                "WARN",
            )
        elif side in ("BUY", "SELL"):
            _override_counter = 0
            _mutiny_evidence_queue.clear()

        # 触发逼宫唤醒 Agent
        # ── 写入黑板：逼宫状态透明化（决策大厅可见）──────────────────
        hour_now = datetime.datetime.now(datetime.timezone.utc).hour
        _runtime_blackboard_update("Micro_Sniper", "override_counter", _override_counter)
        _runtime_blackboard_update("Micro_Sniper", "override_threshold", 2)
        _runtime_blackboard_update("Micro_Sniper", "ny_session_active", (13 <= hour_now < 17))

        if _override_counter >= 2:
            if current_time - _last_agent_wakeup_time > AGENT_WAKEUP_COOLDOWN:
                _log("🚨 [微观逼宫] 底层连续 2 次发出 >85% 的反向信号，强行唤醒 Agent 重新定调！", "WARN")
                # ── Write mutiny evidence before triggering ──
                try:
                    from src.system_bus import append_mutiny_event
                    evidence = {
                        "event_type": "mutiny.micro_override",
                        "override_counter": _override_counter,
                        "opposing_signals": list(_mutiny_evidence_queue),
                        "agent_direction": agent_dir_current,
                        "market_regime": regime,
                        "price_bid": bid,
                        "price_ask": ask,
                        "consecutive_losses": 0,
                    }
                    append_mutiny_event(ROOT, "default", evidence)
                except Exception:
                    pass
                _last_agent_wakeup_time = current_time
                _override_counter = 0
                _mutiny_evidence_queue.clear()
                _spawn_macro_direction_process(force_refresh=True, reason="MICRO_OVERRIDE_WAKEUP")
            else:
                _log("⏳ [微观逼宫] 达到阈值，但处于唤醒冷却期中，等待...", "DEBUG")

        # ===== 大方向与格局仅作为解释信息，不在这里新增 veto =====
        tech_major = "NEUTRAL"
        if ema9 > ema21: tech_major = "LONG"
        elif ema9 < ema21: tech_major = "SHORT"
        if regime == "RANGING":
            if tech_major == "SHORT" and signal.get("side") == "BUY":
                _log("大方向空+震荡，逆势做多，交由总仲裁降权处理", "DEBUG")
            elif tech_major == "LONG" and signal.get("side") == "SELL":
                _log("大方向多+震荡，逆势做空，交由总仲裁降权处理", "DEBUG")

        # ===== 日志输出 =====
        agent_tag = f"Agent={agent_dir_current}"
        
        # 准备 EMA 日志字符串
        ema_str = ""
        if "ema9" in ind and "ema21" in ind and "ema50" in ind:
            ema_str = f" [EMA 9={ind['ema9']:.1f}/21={ind['ema21']:.1f}/50={ind['ema50']:.1f}]"

        # 统一日志：记录所有信号（OPEN/HOLD/逆势/顺势），不再屏蔽与 Agent 同向的信号
        log_level = "INFO"
        if signal["action"] == "HOLD" or block_reason:
            log_level = "INFO"
        _log(
            f"Signal: {signal['action']} {signal.get('side','?')} | {regime} | {agent_tag} "
            f"| eff_conf={round(effective_conf*100,1)}% "
            f"{'| ' + block_reason if block_reason else ''}"
            f"{ema_str} | XAUUSD {bid}/{ask}",
            log_level,
        )

        # [Bug Fix] 先同步 regime 到黑板，避免前端出现 UNKNOWN
        regime = signal.get("regime", "UNKNOWN")
        _runtime_blackboard_update("Micro_Sniper", "current_regime", regime)
        _runtime_blackboard_update("Micro_Sniper", "recent_stop_losses", int(state.get("consecutive_losses", 0) or 0))

        # [Swarm] 猎犬汇报微观侦察结果
        global _last_micro_status_report_time
        now_ts = time.time()
        if (now_ts - _last_micro_status_report_time) >= 120:  # 真实按秒节流，保持决策大厅持续更新
            msg = f"探明最新报价 {bid}/{ask}，当前环境: {signal.get('regime', '?')}"
            if state.get("position_open"):
                msg += f" | 正在持仓，追踪止损防守。"
            if _runtime_blackboard_log("Micro_Sniper", "REPORT", msg):
                _last_micro_status_report_time = now_ts
                


        # ===== 执行判断 =====
        # 从参数建议获取动态门槛
        min_conf = _get_effective_param("min_confidence_threshold", MIN_CONFIDENCE)
        # GARCH vol_ratio 动态提升置信度下限: 高波动(>1.5) +0.05
        try:
            from garch_volatility import get_vol_multiplier
            _vol_info = get_vol_multiplier()
            _vol_ratio_t = float(_vol_info.get("vol_ratio", 1.0))
            if _vol_ratio_t > 1.5:
                min_conf = min(0.85, min_conf + 0.05)
                _log(f"GARCH vol_ratio={_vol_ratio_t:.2f}>1.5, min_conf boosted to {min_conf:.2f}", "WARN")
        except Exception:
            pass

        # RSI/BB 过滤已合并进 RiskManager，执行层不再独立二次裁断。

        # 仓位限制：默认原始仓位从 base_lots 起步，再由用户所选档位 max_lots 做硬上限。
        configured_base_lots = float(_get_effective_param("base_lots", 0.05) or 0.05)
        rule_cap_lots, rule_cap_reasons = _resolve_live_lot_rule_cap(state)
        base_lots = min(configured_base_lots, rule_cap_lots)
        if rule_cap_reasons:
            _log(
                f"Live 仓位上限收敛: raw_base={configured_base_lots:.2f}, trade_base={base_lots:.2f} -> rule_cap={rule_cap_lots:.2f} "
                f"({' | '.join(rule_cap_reasons)})",
                "WARN",
            )
        elif configured_base_lots != base_lots:
            _log(
                f"Live 原始仓位受档位上限约束: raw_base={configured_base_lots:.2f} -> trade_base={base_lots:.2f}",
                "WARN",
            )
        performance_profile = signal.get("performance_profile", {}) if isinstance(signal.get("performance_profile", {}), dict) else {}
        perf_live_multiplier = float(performance_profile.get("live_multiplier", 1.0) or 1.0)
        perf_components = (
            performance_profile.get("components", {})
            if isinstance(performance_profile.get("components", {}), dict)
            else {}
        )
        perf_total_trades = 0
        perf_weighted_recent_win = 0.0
        for comp in perf_components.values():
            if not isinstance(comp, dict):
                continue
            trades = int(comp.get("trades", 0) or 0)
            if trades <= 0:
                continue
            recent_wr = float(comp.get("recent_win_rate_pct", comp.get("win_rate_pct", 50.0)) or 50.0)
            perf_total_trades += trades
            perf_weighted_recent_win += recent_wr * trades
        if perf_total_trades > 0:
            perf_weighted_recent_win /= perf_total_trades
        else:
            perf_weighted_recent_win = 50.0
        low_win_environment = (
            perf_live_multiplier < 0.95
            or (perf_total_trades >= 8 and perf_weighted_recent_win < 52.0)
        )

        # GARCH 波动率动态调整仓位
        # 逻辑升级：仅在“趋势+表现达标”场景允许放大，低波动/低胜率时禁止放大。
        max_lots = base_lots
        try:
            from garch_volatility import get_vol_multiplier
            vol_info = get_vol_multiplier()
            vol_mult  = float(vol_info.get("multiplier", 1.0))
            vol_ratio = float(vol_info.get("vol_ratio", 1.0))
            vol_regime = str(vol_info.get("regime", "UNKNOWN") or "UNKNOWN").upper()
            
            # 根源修复：确保乘数在合理范围内 [0.25, 1.5]
            safe_mult = max(0.25, min(1.5, vol_mult))
            regime_tag = str(signal.get("regime", "") or "").upper()
            allow_amplify = (
                safe_mult > 1.0
                and "LOW" not in vol_regime
                and regime_tag in {"TRENDING", "EXTREME_TREND", "BREAKOUT", "SHOCK_REPRICING"}
                and not low_win_environment
                and perf_live_multiplier >= 1.0
            )
            if safe_mult > 1.0 and not allow_amplify:
                _log(
                    f"GARCH 放大抑制: mult={safe_mult:.2f}, regime={vol_regime}/{regime_tag}, "
                    f"live_mult={perf_live_multiplier:.2f}, rwr={perf_weighted_recent_win:.1f}, n={perf_total_trades}",
                    "WARN",
                )
                safe_mult = 1.0
            elif allow_amplify:
                boost_cap = 1.35 if (perf_live_multiplier >= 1.08 and perf_weighted_recent_win >= 58.0) else 1.15
                safe_mult = min(safe_mult, boost_cap)
            
            if safe_mult != 1.0:
                adjusted_lots = round(base_lots * safe_mult, 3) # 保留3位小数防止截断
                # MT5 手数必须是 0.01 的倍数
                adjusted_lots = max(0.01, round(adjusted_lots / 0.01) * 0.01)
                
                _log(f"GARCH 动态调仓: {base_lots} × {safe_mult} = {adjusted_lots}手（{vol_regime} regime, vol_ratio={vol_ratio:.2f}）", "WARN")
                max_lots = adjusted_lots
        except Exception as e:
            _log(f"GARCH 波动率获取失败，使用基础仓位: {e}", "WARN")

        if max_lots > rule_cap_lots:
            _log(f"Live 仓位硬上限: {max_lots} → {rule_cap_lots}（统一风控口径）", "WARN")
            max_lots = rule_cap_lots

        requested_volume_lots = float(signal.get("volume_lots", max_lots) or max_lots)
        signal["requested_volume_lots"] = requested_volume_lots
        signal["configured_base_lots"] = configured_base_lots
        signal["rule_cap_lots"] = rule_cap_lots
        signal["rule_cap_reasons"] = list(rule_cap_reasons)

        sig_vol = requested_volume_lots # 默认读取最大手数
        if sig_vol > max_lots:
            _log(f"Signal 仓位调整: {sig_vol} → {max_lots}（参数限制）", "WARN")
            signal["volume_lots"] = max_lots
        elif "volume_lots" not in signal:
            # generate_signal() 不返回 volume_lots，必须在此兜底赋值
            signal["volume_lots"] = max_lots
            # 静默赋值，不再打印没意义的警告
            pass
        base_signal_volume_lots = float(signal.get("volume_lots", max_lots) or max_lots)
        signal["final_volume_lots"] = base_signal_volume_lots
        if signal["action"] != "HOLD":
            _log(
                "Volume plan: "
                f"requested={requested_volume_lots:.2f}, "
                f"configured_base={configured_base_lots:.2f}, "
                f"rule_cap={rule_cap_lots:.2f}, "
                f"final={base_signal_volume_lots:.2f}",
                "INFO",
            )

        if signal["action"] == "HOLD":
            # 对于自身就是 HOLD 的信号，也输出一下 EMA 状态，方便诊断
            reason_str = signal.get("reason", "")
            _log(f"Signal: HOLD - | {regime} | {agent_tag} | {reason_str}{ema_str} | XAUUSD {bid}/{ask}", "INFO")
            # 将 Wyckoff 结构类 HOLD 理由降级为观察标签，不再作为“最终拒绝”写入状态。
            hold_observation_only = (
                "无弹簧/假突破结构" in reason_str
                or "无区间突破信号" in reason_str
            )
            final_reason = "SIGNAL_HOLD_OBSERVATION_ONLY" if hold_observation_only else (reason_str or "SIGNAL_HOLD")
            _set_final_reject_reason(state, final_reason, stage="signal_layer")
            save_state(state)
            # 决策大厅保持“侦察提案 -> 风控裁决”语义；等待态不再伪装成政委批复。
            pass
        elif grid_order_written_this_cycle and not SINGLE_OWNER_RISK_ARBITER:
            _close_decision_state(decision_id, "VETOED", reason="GRID_ORDER_WRITTEN_THIS_CYCLE", regime=regime)
            _log(f"Signal BLOCKED: 本周期网格单已触发，跳过主趋势单{ema_str}", "WARN")
            time.sleep(INTERVAL_NO_POS); continue
        
        # ── 强制冷却期拦截 (综合多因子动态评估) ──
        if signal["action"] != "HOLD":
            # 使用持久化状态中的 last_close_time 作为冷却判断依据
            time_since_close = time.time() - state.get("last_close_time", 0.0)
            
            # 1. 基础冷却时间评估 (基于波动率和市场环境)
            base_cooldown = 15 * 60  # 默认基础 15 分钟
            if regime in ("EXTREME_TREND", "BREAKOUT"):
                base_cooldown = 3 * 60   # 强趋势/突破：缩短基础至 3 分钟
            elif regime == "TRENDING":
                base_cooldown = 5 * 60   # 趋势市：缩短基础至 5 分钟
            elif regime == "RANGING":
                base_cooldown = 20 * 60  # 震荡市：延长基础至 20 分钟防磨损
                
            # 2. 宏观 Agent 视角评估
            agent_dir_current, _ = get_agent_direction()
            agent_side = _agent_direction_to_side(agent_dir_current)
                
            agent_multiplier = 1.0
            if agent_side and signal["side"] == agent_side:
                agent_multiplier = 0.5  # Agent 顺势赞同，冷却时间减半
            elif agent_side and signal["side"] != agent_side:
                agent_multiplier = 1.5  # Agent 认为逆势，冷却时间增加 50%
                
            # 3. 微观 5 分钟模型置信度评估
            conf_multiplier = 1.0
            if effective_conf >= 0.85:
                conf_multiplier = 0.5   # 置信度极高，冷却时间再减半
            elif effective_conf < 0.70:
                conf_multiplier = 1.2   # 置信度勉强及格，冷却时间延长 20%
                
            # 4. 微观动能扩张评估
            macd_hist = ind.get("macd_hist", 0)
            macd_hist_prev = ind.get("macd_hist_prev", 0)
            momentum_expanding = False
            if signal["side"] == "BUY" and macd_hist > 0 and macd_hist > macd_hist_prev:
                momentum_expanding = True # 多头动能正在放量
            elif signal["side"] == "SELL" and macd_hist < 0 and macd_hist < macd_hist_prev:
                momentum_expanding = True # 空头动能正在放量
                
            mom_multiplier = 0.7 if momentum_expanding else 1.0  # 动能扩张，冷却减少 30%
            profit_cooldown_profile = _build_profit_sense_cooldown_profile(state)
            
            # 5. 综合计算最终动态冷却期，并钳制上下限 [1分钟, 45分钟]
            dynamic_cooldown = int(base_cooldown * agent_multiplier * conf_multiplier * mom_multiplier)
            dynamic_cooldown = int(
                dynamic_cooldown * float(profit_cooldown_profile.get("cooldown_multiplier", 1.0) or 1.0)
            )
            dynamic_cooldown += int(profit_cooldown_profile.get("cooldown_add_sec", 0) or 0)
            # 高波动强趋势允许更快重入，但仍保留最低 1 分钟防抖。
            dynamic_cooldown = max(1 * 60, min(45 * 60, dynamic_cooldown))
            interpreter = signal.get("interpreter", {}) if isinstance(signal.get("interpreter", {}), dict) else {}
            confirmation_state = str(interpreter.get("confirmation_state", "NONE") or "NONE").upper()
            pullback_quality = str(interpreter.get("pullback_quality", "UNKNOWN") or "UNKNOWN").upper()
            execution_advice = str(interpreter.get("execution_advice", "ALLOW_FOLLOW") or "ALLOW_FOLLOW").upper()
            counter_trend_risk = str(interpreter.get("counter_trend_risk", "LOW") or "LOW").upper()
            score = float((signal.get("contextual_policy", {}) or {}).get("score", 0) or 0)
            # 全局冷却重标定：高质量信号仅保留最小冷却，不做一刀切。
            if (
                effective_conf >= 0.84
                and score >= 1
                and confirmation_state == "CONFIRMED"
                and pullback_quality in ("HEALTHY", "STRONG")
                and execution_advice != "BLOCK_COUNTER_TREND"
                and counter_trend_risk != "HIGH"
                and bool(profit_cooldown_profile.get("allow_fast_reentry", True))
            ):
                dynamic_cooldown = max(2 * 60, int(dynamic_cooldown * 0.35))
            # 强趋势/突破 + 动能扩张 + 高置信度：进一步压缩冷却，避免错过单边行情。
            if (
                regime in ("EXTREME_TREND", "BREAKOUT")
                and momentum_expanding
                and effective_conf >= 0.80
                and execution_advice != "BLOCK_COUNTER_TREND"
                and counter_trend_risk != "HIGH"
                and bool(profit_cooldown_profile.get("allow_fast_reentry", True))
            ):
                dynamic_cooldown = min(dynamic_cooldown, 90)
            dynamic_cooldown = max(
                int(profit_cooldown_profile.get("floor_sec", 60) or 60),
                dynamic_cooldown,
            )

            # 执行拦截判定
            if time_since_close < dynamic_cooldown and not SINGLE_OWNER_RISK_ARBITER:
                remaining_mins = int((dynamic_cooldown - time_since_close) / 60)
                _close_decision_state(
                    decision_id,
                    "VETOED",
                    reason="DYNAMIC_COOLDOWN",
                    dynamic_cooldown=dynamic_cooldown,
                    remaining_seconds=max(0, int(dynamic_cooldown - time_since_close)),
                    agent_direction=agent_dir_current,
                    effective_confidence=effective_conf,
                    momentum_expanding=momentum_expanding,
                    profit_sense_bias=profit_cooldown_profile.get("bias_label", "NEUTRAL"),
                    profit_capture_ratio=profit_cooldown_profile.get("profit_capture_ratio", 0.0),
                    profit_giveback_atr=profit_cooldown_profile.get("profit_giveback_atr", 0.0),
                    consecutive_losses=profit_cooldown_profile.get("consecutive_losses", 0),
                )
                _log(
                    f"Signal BLOCKED: 综合冷却拦截({int(dynamic_cooldown/60)}m，剩{remaining_mins}m) "
                    f"[Regime={regime}, Agent={agent_dir_current}, Conf={effective_conf:.2f}, "
                    f"MomExp={momentum_expanding}, ProfitSense={profit_cooldown_profile.get('bias_label', 'NEUTRAL')}]"
                    f"{ema_str}",
                    "WARN",
                )
                
                # [Swarm] 冷却期拦截日志
                try:
                    side_zh = "做多" if signal.get("side") == "BUY" else "做空"
                    reply_msg = f"已驳回 {side_zh} 请求。原因：刚刚平仓，仍处于动态冷却期中 (还需等待 {remaining_mins} 分钟)。保持克制！"
                    _runtime_blackboard_log("Risk_Commissar", "WARN", reply_msg)
                except Exception:
                    pass
                    
                time.sleep(INTERVAL_NO_POS)
                continue
            else:
                if time_since_close < 30 * 60: # 只对半小时内的开单打印通过理由
                    _log(
                        f"⚡ [综合评估通过] 距上次平仓 {int(time_since_close/60)}m，"
                        f"已满足当前动态冷却要求({int(dynamic_cooldown/60)}m, "
                        f"ProfitSense={profit_cooldown_profile.get('bias_label', 'NEUTRAL')})",
                        "WARN",
                    )
            if time_since_close < dynamic_cooldown and SINGLE_OWNER_RISK_ARBITER:
                remaining_mins = int((dynamic_cooldown - time_since_close) / 60)
                _daemon_log(
                    f"[OBSERVE_ONLY] Dynamic cooldown veto suppressed: remain={remaining_mins}m "
                    f"required={int(dynamic_cooldown/60)}m"
                )

        contextual_policy = signal.get("contextual_policy", {})
        if not isinstance(contextual_policy, dict):
            contextual_policy = {}
        threshold_profile = signal.get("confidence_threshold_profile", {})
        if not isinstance(threshold_profile, dict):
            threshold_profile = {}
        execution_score = float(contextual_policy.get("score", 0) or 0)
        execute_floor = min_conf
        hard_floor = max(0.50, execute_floor - 0.10)
        soft_size_multiplier = 0.60
        if execution_score >= 1 and effective_conf >= (min_conf - 0.06):
            execute_floor = max(0.54, min_conf - 0.06)
        if execution_score >= 2 and effective_conf >= (min_conf - 0.08):
            execute_floor = max(0.52, min_conf - 0.08)
        if threshold_profile:
            execute_floor = float(threshold_profile.get("target", execute_floor) or execute_floor)
            hard_floor = float(threshold_profile.get("hard_floor", hard_floor) or hard_floor)
            soft_size_multiplier = float(threshold_profile.get("soft_size_multiplier", soft_size_multiplier) or soft_size_multiplier)
            contextual_policy["market_state"] = str(threshold_profile.get("market_state", contextual_policy.get("market_state", "UNKNOWN")) or "UNKNOWN")
            contextual_policy["trend_state"] = str(threshold_profile.get("trend_state", contextual_policy.get("trend_state", "UNKNOWN")) or "UNKNOWN")
            contextual_policy["vol_state"] = str(threshold_profile.get("vol_state", contextual_policy.get("vol_state", "UNKNOWN")) or "UNKNOWN")
        resolver_action = str((signal.get("resolver", {}) or {}).get("action", "FOLLOW_MACRO") or "FOLLOW_MACRO")
        if resolver_action == "ALLOW_MICRO_PROBE":
            execute_floor = min(execute_floor, 0.62)
            contextual_policy["limit_only"] = True
            contextual_policy["entry_style"] = "LIMIT"
            contextual_policy["size_multiplier"] = min(float(contextual_policy.get("size_multiplier", 1.0) or 1.0), 0.50)
            signal["contextual_policy"] = contextual_policy
        elif resolver_action == "FLIP_TO_MICRO":
            execute_floor = min(execute_floor, 0.66)

        # 顺势突破直通：避免强有效突破在灰区复核中被反复截流。
        event_tag = str(ind.get("event", "") or "").upper()
        adx_now = float(ind.get("adx", 0.0) or 0.0)
        ema9_now = float(ind.get("ema9", 0.0) or 0.0)
        ema21_now = float(ind.get("ema21", 0.0) or 0.0)
        breakout_with_trend = (
            signal.get("action") != "HOLD"
            and (
                (signal.get("side") == "SELL" and event_tag == "BOS_DOWN" and ema9_now < ema21_now)
                or (signal.get("side") == "BUY" and event_tag == "BOS_UP" and ema9_now > ema21_now)
            )
            and adx_now >= 28
            and effective_conf >= max(0.60, min_conf - 0.12)
        )
        if breakout_with_trend:
            execute_floor = min(execute_floor, effective_conf)
            _log(
                f"⚡ 顺势突破直通: side={signal.get('side')} event={event_tag} adx={adx_now:.1f} conf={effective_conf:.3f}",
                "WARN",
            )

        conf_entry_tier = "NORMAL"
        if signal["action"] != "HOLD" and hard_floor <= effective_conf < execute_floor:
            conf_entry_tier = "SOFT"
            soft_market_fastlane = (
                regime in ("EXTREME_TREND", "BREAKOUT", "TRENDING")
                and momentum_expanding
                and effective_conf >= max(hard_floor + 0.03, execute_floor - 0.04)
                and counter_trend_risk != "HIGH"
            )
            if soft_market_fastlane:
                contextual_policy["limit_only"] = False
                contextual_policy["entry_style"] = "MARKET"
            else:
                contextual_policy["limit_only"] = True
                contextual_policy["entry_style"] = "LIMIT"
            contextual_policy["size_multiplier"] = min(
                float(contextual_policy.get("size_multiplier", 1.0) or 1.0),
                max(0.40, min(0.95 if soft_market_fastlane else 0.90, soft_size_multiplier)),
            )
            contextual_policy["confidence_tier"] = conf_entry_tier
            signal["contextual_policy"] = contextual_policy
            signal["reason"] = (
                f"{signal.get('reason','')} | SOFT_CONF_ENTRY "
                f"({effective_conf:.2f} in [{hard_floor:.2f}, {execute_floor:.2f}))"
            )
            _log(
                f"🟡 灰区信号降级执行: conf={effective_conf:.2f}, floor={hard_floor:.2f}, target={execute_floor:.2f}, "
                f"mode={contextual_policy.get('entry_style','LIMIT')}, size_mult={float(contextual_policy.get('size_multiplier',1.0) or 1.0):.2f}",
                "WARN",
            )
        elif signal["action"] != "HOLD":
            contextual_policy["confidence_tier"] = conf_entry_tier
            signal["contextual_policy"] = contextual_policy

        if signal["action"] != "HOLD":
            contextual_policy = signal.get("contextual_policy", {}) if isinstance(signal.get("contextual_policy", {}), dict) else {}
            size_multiplier = float(contextual_policy.get("size_multiplier", 1.0) or 1.0)
            if size_multiplier < 1.0:
                adjusted = max(
                    0.01,
                    round(base_signal_volume_lots * max(0.1, min(1.0, size_multiplier)), 2),
                )
                if adjusted < base_signal_volume_lots:
                    _log(
                        f"Contextual 仓位缩放: {base_signal_volume_lots:.2f} -> {adjusted:.2f} (mult={size_multiplier:.2f})",
                        "WARN",
                    )
                    signal["volume_lots"] = adjusted
                else:
                    signal["volume_lots"] = base_signal_volume_lots
            else:
                signal["volume_lots"] = base_signal_volume_lots

        # ── 柔性武器 2：防接飞刀与限价接针 (Dynamic Entry Optimization) ──
        # 不再一刀切地在亚盘/震荡市全面开启限价单，而是根据动能和乖离率动态判断是否处于“飞刀”风险中
        if signal["action"] != "HOLD" and effective_conf >= hard_floor:
            # ── 最终安全网：SL=0 或 TP=0 的订单不得下单 ──
            sl_val = signal.get("sl", 0)
            tp_val = signal.get("tp", 0)
            if sl_val <= 0 or tp_val <= 0:
                _close_decision_state(decision_id, "FAILED", reason="INVALID_SL_TP", sl=sl_val, tp=tp_val, atr=ind.get("atr14", "?"))
                _log(f"❌ 订单被拦截: SL={sl_val} TP={tp_val} (ATR={ind.get('atr14','?')})，跳过下单{ema_str}", "ERROR")
                time.sleep(INTERVAL_NO_POS); continue
            
            current_action = signal["action"]
            current_entry = signal["entry"]
            
            # 动态评估“接飞刀”风险（连续评分，替代二元阈值）
            macd_hist = float(ind.get("macd_hist", 0.0) or 0.0)
            macd_hist_prev = float(ind.get("macd_hist_prev", 0.0) or 0.0)
            ema21 = float(ind.get("ema21", current_entry) or current_entry)
            adx_val = float(ind.get("adx", 0.0) or 0.0)
            trend_strength_val = str(ind.get("trend_strength", "") or "").upper()
            atr_now = max(0.01, float(ind.get("atr14", 2.0) or 2.0))
            policy_score = float((contextual_policy or {}).get("score", 0) or 0)
            session_profile = session_info.get("session_profile", {}) if isinstance(session_info, dict) else {}
            session_mode = str(contextual_policy.get("session_mode", session_profile.get("mode", "NORMAL")) or "NORMAL")
            session_entry_style = str(contextual_policy.get("entry_style", session_profile.get("entry_style", "MARKET")) or "MARKET").upper()
            interpreter = signal.get("interpreter", {}) if isinstance(signal.get("interpreter", {}), dict) else {}
            structure_market = str(interpreter.get("market_structure", "UNCERTAIN"))
            structure_pullback = str(interpreter.get("pullback_phase", "NONE"))
            structure_confirmation = str(interpreter.get("confirmation_state", "NONE"))
            structure_quality = str(interpreter.get("pullback_quality", "UNKNOWN"))
            structure_execution = str(interpreter.get("execution_advice", "ALLOW_FOLLOW"))
            structure_reason = str(interpreter.get("reason", "") or "")
            shock_direction = str(interpreter.get("shock_direction", "NONE"))
            counter_trend_risk = str(interpreter.get("counter_trend_risk", "LOW"))
            structure_session_bias = str(interpreter.get("session_execution_bias", "BALANCED"))
            structure_open_drive = bool(interpreter.get("open_drive", False))
            structure_gap_continuation = bool(interpreter.get("gap_continuation", False))
            structure_impulse_exhaustion = bool(interpreter.get("impulse_exhaustion", False))
            structure_liquidity_sweep = str(interpreter.get("liquidity_sweep", "NONE"))
            structure_side = "BUY" if shock_direction == "BULLISH" else "SELL" if shock_direction == "BEARISH" else None
            structure_limit_only = bool(contextual_policy.get("limit_only", False))

            # ---- 提取自适应画像与运行时适应 ----
            try:
                _adp = context.get("adaptive_profile", {}) if isinstance(context, dict) else {}
            except NameError:
                _adp = {}
            _defensive_delta = float(_adp.get("defensive_offset_delta", 0.0))
            _conf_thresh_delta = float(_adp.get("confidence_threshold_delta", 0.0))
            _sync_mode = str(_adp.get("strategy_sync_mode", "BALANCED") or "BALANCED").upper()
            _risk_bias = str(_adp.get("evolution_risk_bias", "NEUTRAL") or "NEUTRAL").upper()
            _vol_state = str(_adp.get("volatility_state", "NORMAL") or "NORMAL").upper()
            _liq_state = str(_adp.get("liquidity_state", "NORMAL") or "NORMAL").upper()

            # ---- KnifeRisk 连续评分 ----
            spread_usd = max(0.01, float(ask - bid))
            spread_to_atr = spread_usd / atr_now
            side = str(signal.get("side", "") or "").upper()

            def _clamp(v, lo, hi):
                return max(lo, min(hi, v))

            def _sigmoid(v: float) -> float:
                x = _clamp(v, -8.0, 8.0)
                return 1.0 / (1.0 + math.exp(-x))

            momentum_following = (
                (side == "BUY" and macd_hist > 0 and macd_hist > macd_hist_prev)
                or (side == "SELL" and macd_hist < 0 and macd_hist < macd_hist_prev)
            )
            strong_momentum_trend = (
                momentum_following
                and (adx_val >= 26 or trend_strength_val in {"TRENDING", "STRONG_TREND"})
                and effective_conf >= 0.82
                and policy_score >= 1
            )

            # 顺势 + 高置信度 → 跳过任何软挂单/limit_only 限制
            # 趋势不等人，限价单在强势行情中几乎不可能成交，
            # 等到了说明回撤已深，趋势可能走完。
            if strong_momentum_trend and effective_conf >= 0.82:
                session_entry_style = "MARKET"
                structure_limit_only = False

            ext_ratio = max(0.0, (current_entry - ema21) / atr_now) if side == "BUY" else max(0.0, (ema21 - current_entry) / atr_now)
            ext_allow = _clamp(1.20 + _defensive_delta * 0.5, 0.90, 3.20)
            if strong_momentum_trend:
                ext_allow += 0.55
            if effective_conf >= 0.90:
                ext_allow += 0.25
            elif effective_conf >= 0.84:
                ext_allow += 0.15
            if (
                structure_confirmation == "CONFIRMED"
                and structure_quality in {"HEALTHY", "STRONG"}
                and structure_side == side
            ):
                ext_allow += 0.18
            if session_mode in {"ASIA_NOISE", "ASIA_DEFENSIVE"}:
                ext_allow -= 0.25
            if counter_trend_risk == "HIGH":
                ext_allow -= 0.20
            if spread_to_atr >= 0.45:
                ext_allow -= 0.12
            ext_allow = _clamp(ext_allow, 0.9, 3.2)
            ext_scale = 0.36 if not strong_momentum_trend else 0.42
            r_ext = _sigmoid((ext_ratio - ext_allow) / ext_scale)

            mom_denom = max(0.25, abs(macd_hist), abs(macd_hist_prev))
            if side == "BUY":
                mom_raw = max(0.0, -macd_hist) + max(0.0, -(macd_hist - macd_hist_prev))
            else:
                mom_raw = max(0.0, macd_hist) + max(0.0, (macd_hist - macd_hist_prev))
            mom_norm = mom_raw / mom_denom
            r_mom = _sigmoid((mom_norm - 0.40) / 0.25)

            r_struct = _clamp(0.55 + _defensive_delta * 0.3, 0.0, 1.0)
            if (
                structure_confirmation == "CONFIRMED"
                and structure_quality in {"HEALTHY", "STRONG"}
                and structure_side == side
            ):
                r_struct -= 0.25
            if (
                (side == "BUY" and event_tag == "BOS_UP")
                or (side == "SELL" and event_tag == "BOS_DOWN")
            ):
                r_struct -= 0.15
            if counter_trend_risk == "HIGH":
                r_struct += 0.25
            if structure_execution == "BLOCK_COUNTER_TREND":
                r_struct += 0.20
            r_struct = _clamp(r_struct, 0.0, 1.0)

            r_micro = _clamp(0.50 + _defensive_delta * 0.3, 0.0, 1.0)
            if session_mode in {"ASIA_NOISE", "ASIA_DEFENSIVE"}:
                r_micro += 0.18
            elif "OVERLAP" in session_mode:
                r_micro -= 0.08
            if structure_session_bias in {"ASIA_CONSERVATIVE", "ASIA_DEFENSIVE"}:
                r_micro += 0.10
            elif structure_session_bias in {"HIGH_LIQUIDITY", "NY_VOLATILE"}:
                r_micro -= 0.12
            if spread_to_atr >= 0.45:
                r_micro += 0.15
            elif spread_to_atr <= 0.20:
                r_micro -= 0.06
            r_micro = _clamp(r_micro, 0.0, 1.0)

            # 自适应组件权值：趋势行情 extension+momentum 主导，震荡 structure+micro 主导
            if regime in {"EXTREME_TREND", "BREAKOUT", "TRENDING"}:
                _w_ext, _w_mom, _w_struct, _w_micro = 0.45, 0.30, 0.15, 0.10
            elif regime == "RANGING":
                _w_ext, _w_mom, _w_struct, _w_micro = 0.30, 0.20, 0.30, 0.20
            elif _sync_mode in ("MEAN_REVERSION_GUARD", "UNCERTAINTY_PROTECT"):
                _w_ext, _w_mom, _w_struct, _w_micro = 0.30, 0.15, 0.35, 0.20
            elif _sync_mode == "COMPRESSION_PROBE":
                _w_ext, _w_mom, _w_struct, _w_micro = 0.35, 0.15, 0.30, 0.20
            else:
                _w_ext, _w_mom, _w_struct, _w_micro = 0.40, 0.25, 0.20, 0.15

            knife_risk_score = _clamp(
                _w_ext * r_ext + _w_mom * r_mom + _w_struct * r_struct + _w_micro * r_micro,
                0.0, 1.0,
            )
            _tier_delta = _clamp(_conf_thresh_delta, -0.05, 0.05)
            _tier_wide = 0.78 - _tier_delta
            _tier_limit = 0.55 - _tier_delta
            _tier_tight = 0.30 - _tier_delta
            # 趋势/突破行情提高追价风险触发阈值，避免主升/主跌段长期挂防守灯。
            if regime in {"EXTREME_TREND", "BREAKOUT", "SHOCK_REPRICING", "TRENDING"}:
                _tier_wide += 0.04
                _tier_limit += 0.07
                _tier_tight += 0.08
            knife_risk_tier = "MARKET"
            if knife_risk_score >= _tier_wide:
                knife_risk_tier = "WIDE_LIMIT"
            elif knife_risk_score >= _tier_limit:
                knife_risk_tier = "LIMIT"
            elif knife_risk_score >= _tier_tight:
                knife_risk_tier = "TIGHT_LIMIT"

            catching_knife = knife_risk_tier != "MARKET"
            if knife_risk_tier == "MARKET":
                knife_reason = "连续评分通过，允许市价执行"
            elif knife_risk_tier == "TIGHT_LIMIT":
                knife_reason = "追价风险轻度，改紧限价"
            elif knife_risk_tier == "LIMIT":
                knife_reason = "追价风险中等，改防守限价"
            else:
                knife_reason = "追价风险偏高，宽限价等待回踩"
            contextual_policy["knife_risk_score"] = round(knife_risk_score, 4)
            contextual_policy["knife_risk_tier"] = knife_risk_tier
            contextual_policy["knife_ext_ratio"] = round(ext_ratio, 4)
            contextual_policy["knife_ext_allow"] = round(ext_allow, 4)
            contextual_policy["knife_components"] = {
                "ext": round(r_ext, 4),
                "mom": round(r_mom, 4),
                "struct": round(r_struct, 4),
                "micro": round(r_micro, 4),
            }
            contextual_policy["adaptive"] = {
                "defensive_delta": round(_defensive_delta, 4),
                "conf_thresh_delta": round(_conf_thresh_delta, 4),
                "tier_delta": round(_tier_delta, 4),
                "strategy_sync": _sync_mode,
                "risk_bias": _risk_bias,
                "vol_state": _vol_state,
                "liq_state": _liq_state,
                "weights": {
                    "ext": round(_w_ext, 3),
                    "mom": round(_w_mom, 3),
                    "struct": round(_w_struct, 3),
                    "micro": round(_w_micro, 3),
                },
                "tier_thresholds": {
                    "wide": round(_tier_wide, 3),
                    "limit": round(_tier_limit, 3),
                    "tight": round(_tier_tight, 3),
                },
            }
            signal["contextual_policy"] = contextual_policy

            # ===== 防守矩阵 V2：统一打分，避免多个 OR 条件粗暴叠加 =====
            legacy_or_gate_on = bool(_get_effective_param("legacy_or_gate_on", False))
            defense_matrix_v2_on = bool(_get_effective_param("defense_matrix_v2_on", True))
            knife_only_mode = bool(_get_effective_param("knife_only_mode", False))
            if structure_limit_only:
                session_entry_style = "LIMIT"
            elif session_entry_style not in ("MARKET", "LIMIT"):
                session_entry_style = "MARKET"

            regime_risk = 0.55 if regime == "RANGING" else 0.10
            if regime in {"UNKNOWN", "NEUTRAL"}:
                regime_risk = 0.25
            style_risk = 0.75 if session_entry_style == "LIMIT" else 0.0
            structure_risk = 0.0
            if structure_limit_only:
                structure_risk += 0.55
            if counter_trend_risk == "HIGH":
                structure_risk += 0.35
            elif counter_trend_risk == "MEDIUM":
                structure_risk += 0.18
            structure_risk = _clamp(structure_risk, 0.0, 1.0)
            spread_risk = _sigmoid((spread_to_atr - 0.28) / 0.10)
            strong_trend_release = (
                regime in {"EXTREME_TREND", "BREAKOUT", "SHOCK_REPRICING", "TRENDING"}
                and effective_conf >= 0.82
                and adx_val >= 28
                and momentum_following
                and counter_trend_risk == "LOW"
                and spread_to_atr <= 0.32
                and (
                    breakout_with_trend
                    or structure_session_bias in {"HIGH_LIQUIDITY", "NY_VOLATILE"}
                )
            )
            trend_pullback_release = (
                regime in {"EXTREME_TREND", "BREAKOUT", "SHOCK_REPRICING", "TRENDING"}
                and effective_conf >= 0.78
                and adx_val >= 24
                and counter_trend_risk in {"LOW", "MEDIUM"}
                and spread_to_atr <= 0.34
                and (
                    breakout_with_trend
                    or structure_open_drive
                    or structure_gap_continuation
                    or (
                        structure_confirmation in {"CONFIRMED", "EARLY"}
                        and structure_quality in {"HEALTHY", "STRONG"}
                    )
                )
            )
            trend_release_credit = 0.0
            if strong_trend_release:
                trend_release_credit = 0.18
            elif trend_pullback_release:
                trend_release_credit = 0.10
            if trend_release_credit > 0.0 and session_entry_style == "LIMIT":
                trend_release_credit += 0.04
            if trend_release_credit > 0.0 and structure_limit_only:
                trend_release_credit += 0.04

            if knife_only_mode:
                defense_score = knife_risk_score
            else:
                # 自适应防御矩阵：趋势/防御偏 knife+regime，震荡偏 regime+structure
                if regime in {"EXTREME_TREND", "BREAKOUT", "TRENDING"}:
                    _w_knife, _w_regime, _w_struct_m, _w_style, _w_spread = 0.50, 0.20, 0.15, 0.10, 0.05
                elif regime == "RANGING":
                    _w_knife, _w_regime, _w_struct_m, _w_style, _w_spread = 0.40, 0.25, 0.20, 0.10, 0.05
                elif _risk_bias == "DEFENSIVE":
                    _w_knife, _w_regime, _w_struct_m, _w_style, _w_spread = 0.50, 0.20, 0.15, 0.10, 0.05
                else:
                    _w_knife, _w_regime, _w_struct_m, _w_style, _w_spread = 0.55, 0.15, 0.15, 0.10, 0.05

                regime_risk = 0.55 if regime == "RANGING" else 0.10
                if regime in {"UNKNOWN", "NEUTRAL"}:
                    regime_risk = 0.25
                if _risk_bias == "AGGRESSIVE":
                    regime_risk = max(0.05, regime_risk - 0.10)
                elif _risk_bias == "DEFENSIVE":
                    regime_risk = min(0.70, regime_risk + 0.10)

                defense_score = _clamp(
                    _w_knife * knife_risk_score
                    + _w_regime * regime_risk
                    + _w_struct_m * structure_risk
                    + _w_style * style_risk
                    + _w_spread * spread_risk,
                    0.0, 1.0,
                )
                if trend_release_credit > 0.0:
                    defense_score = _clamp(defense_score - trend_release_credit, 0.0, 1.0)
            defense_reason_codes = []
            if knife_risk_score >= 0.55:
                defense_reason_codes.append("KNIFE_RISK_HIGH")
            elif knife_risk_score >= 0.30:
                defense_reason_codes.append("KNIFE_RISK_MID")
            if regime == "RANGING":
                defense_reason_codes.append("REGIME_RANGING")
            if session_entry_style == "LIMIT":
                defense_reason_codes.append("SESSION_LIMIT_STYLE")
            if structure_limit_only:
                defense_reason_codes.append("STRUCTURE_LIMIT_ONLY")
            if counter_trend_risk == "HIGH":
                defense_reason_codes.append("COUNTER_TREND_HIGH")
            if trend_release_credit > 0.0:
                defense_reason_codes.append("TREND_RELEASE_CREDIT")

            defense_tight = 0.30
            defense_limit = 0.55
            defense_wide = 0.78
            if regime in {"EXTREME_TREND", "BREAKOUT", "SHOCK_REPRICING", "TRENDING"}:
                defense_tight = 0.40
                defense_limit = 0.62
                defense_wide = 0.82
            if defense_matrix_v2_on and not legacy_or_gate_on:
                if defense_score >= defense_wide:
                    defense_mode = "WIDE_LIMIT"
                elif defense_score >= defense_limit:
                    defense_mode = "LIMIT"
                elif defense_score >= defense_tight:
                    defense_mode = "TIGHT_LIMIT"
                else:
                    defense_mode = "MARKET"
            else:
                # 兼容旧逻辑，可开关回滚。
                old_is_defensive = (regime == "RANGING") or catching_knife or session_entry_style == "LIMIT"
                defense_mode = knife_risk_tier if catching_knife else ("LIMIT" if old_is_defensive else "MARKET")

            whitelist_market = (
                breakout_with_trend
                and effective_conf >= 0.80
                and adx_val >= 26
                and structure_confirmation in {"CONFIRMED", "EARLY"}
                and structure_quality in {"HEALTHY", "STRONG"}
                and counter_trend_risk != "HIGH"
                and spread_to_atr <= 0.35
            )
            momentum_market_fastlane = (
                regime in {"EXTREME_TREND", "BREAKOUT", "SHOCK_REPRICING", "TRENDING"}
                and adx_val >= 24
                and effective_conf >= 0.76
                and momentum_following
                and counter_trend_risk in {"LOW", "MEDIUM"}
                and spread_to_atr <= 0.34
                and defense_score < 0.72
            )
            breakout_market_override = (
                regime in {"BREAKOUT", "EXTREME_TREND", "SHOCK_REPRICING"}
                and effective_conf >= 0.82
                and adx_val >= 26
                and counter_trend_risk != "HIGH"
                and spread_to_atr <= 0.45
            )
            trend_market_release = (
                strong_trend_release
                and defense_mode != "MARKET"
                and defense_score < 0.58
                and knife_risk_tier in {"MARKET", "TIGHT_LIMIT"}
            )
            if whitelist_market and defense_mode != "MARKET":
                defense_mode = "MARKET"
                defense_reason_codes.append("TREND_BREAKOUT_WHITELIST")
            elif breakout_market_override and defense_mode != "MARKET":
                defense_mode = "MARKET"
                defense_reason_codes.append("BREAKOUT_MARKET_OVERRIDE")
            elif momentum_market_fastlane and defense_mode != "MARKET":
                defense_mode = "MARKET"
                defense_reason_codes.append("MOMENTUM_FASTLANE")
            elif trend_market_release:
                defense_mode = "MARKET"
                defense_reason_codes.append("TREND_MARKET_RELEASE")

            final_limit_tier = defense_mode if defense_mode in {"TIGHT_LIMIT", "LIMIT", "WIDE_LIMIT"} else "MARKET"
            # 黑板/UI/执行统一以最终执行模式为准，避免“已放行但仍显示飞刀风险/仍走限价”。
            final_catching_knife = (
                final_limit_tier != "MARKET"
                and knife_risk_tier != "MARKET"
            )
            is_defensive_env = final_limit_tier != "MARKET"
            contextual_policy["defense_score"] = round(defense_score, 4)
            contextual_policy["defense_mode"] = defense_mode
            contextual_policy["final_limit_tier"] = final_limit_tier
            contextual_policy["trend_release_credit"] = round(trend_release_credit, 4)
            contextual_policy["trend_market_release"] = bool(trend_market_release)
            contextual_policy["defense_reason_codes"] = defense_reason_codes[:6]
            contextual_policy["defense_weights"] = {
                "knife": round(_w_knife, 3),
                "regime": round(_w_regime, 3),
                "structure": round(_w_struct_m, 3),
                "style": round(_w_style, 3),
                "spread": round(_w_spread, 3),
            }
            contextual_policy["regime_risk_adapted"] = round(regime_risk, 3)
            signal["contextual_policy"] = contextual_policy
            if _update_defense_metrics(state, defense_mode, defense_reason_codes, defense_score):
                save_state(state)

            _runtime_blackboard_update("Micro_Sniper", "is_defensive_env", bool(is_defensive_env))
            _runtime_blackboard_update("Micro_Sniper", "knife_catching_risk", bool(final_catching_knife))
            _runtime_blackboard_update("Micro_Sniper", "current_regime", regime)
            _runtime_blackboard_update("Micro_Sniper", "defense_mode", defense_mode)
            _runtime_blackboard_update("Micro_Sniper", "defense_score", round(defense_score, 4))

            if is_defensive_env and ind.get('atr14'):
                atr_val = ind.get('atr14')
                base_offset_multiplier = 0.4
                active_limit_tier = final_limit_tier
                if active_limit_tier in {"TIGHT_LIMIT", "LIMIT", "WIDE_LIMIT"}:
                    if active_limit_tier == "TIGHT_LIMIT":
                        base_offset_multiplier = 0.35
                    elif active_limit_tier == "LIMIT":
                        base_offset_multiplier = 0.60
                    else:
                        base_offset_multiplier = 0.85
                    # 自适应：防守行情放宽偏移，激进行情收窄偏移
                    base_offset_multiplier = _clamp(
                        base_offset_multiplier + _defensive_delta * 0.5,
                        0.20, 0.95,
                    )
                    reason_msg = knife_reason
                elif session_mode == "ASIA_TREND":
                    base_offset_multiplier = 0.25
                    reason_msg = "亚盘趋势延续，轻仓限价跟随"
                elif session_mode == "ASIA_DEFENSIVE":
                    base_offset_multiplier = 0.45
                    reason_msg = "亚盘波动偏弱，防守挂单等待确认"
                elif regime == "RANGING":
                    base_offset_multiplier = 0.5
                    reason_msg = "震荡市不追 EMA，改为限价等待回归"
                elif structure_market == "FAILED_BREAKOUT":
                    base_offset_multiplier = 0.55
                    reason_msg = structure_reason or "假突破回落，限价等待确认"
                elif structure_market == "LIQUIDITY_SWEEP":
                    base_offset_multiplier = 0.3
                    reason_msg = structure_reason or "流动性扫单后回收，限价埋伏"
                elif structure_impulse_exhaustion:
                    base_offset_multiplier = 0.6
                    reason_msg = structure_reason or "脉冲衰竭，避免追最后一脚"
                elif structure_open_drive or structure_gap_continuation:
                    base_offset_multiplier = 0.3 if structure_confirmation == "CONFIRMED" else 0.45
                    reason_msg = structure_reason or "开盘驱动/高开延续，等待回踩确认"
                elif structure_limit_only:
                    base_offset_multiplier = 0.25 if structure_pullback == "POST_CONFIRMATION" else 0.4
                    reason_msg = structure_reason or "大模型识别异常脉冲结构，等待回踩确认"
                else:
                    base_offset_multiplier = 0.4
                    reason_msg = "震荡市防追高" if signal["side"] == "BUY" else "震荡市防杀跌"
                # LIMIT 偏移动态化：
                # 置信度高/结构确认好/高流动性 -> 收窄偏移，提升成交率；
                # 亚盘防守/逆势高风险 -> 放宽偏移，保持防守纪律。
                conf_tighten = 0.0
                if effective_conf >= 0.90:
                    conf_tighten = -0.20
                elif effective_conf >= 0.85:
                    conf_tighten = -0.14
                elif effective_conf >= 0.80:
                    conf_tighten = -0.08
                elif effective_conf < 0.70:
                    conf_tighten = 0.10

                session_adjust = 0.0
                if structure_session_bias in ("HIGH_LIQUIDITY", "NY_VOLATILE"):
                    session_adjust -= 0.10
                elif structure_session_bias in ("ASIA_CONSERVATIVE", "ASIA_DEFENSIVE"):
                    session_adjust += 0.10

                structure_adjust = 0.0
                if structure_confirmation == "CONFIRMED" and structure_quality in ("HEALTHY", "STRONG"):
                    structure_adjust -= 0.08
                if counter_trend_risk == "HIGH":
                    structure_adjust += 0.12

                dynamic_scale = 1.0 + conf_tighten + session_adjust + structure_adjust
                dynamic_scale = max(0.6, min(1.4, dynamic_scale))
                offset_multiplier = round(max(0.18, min(0.95, base_offset_multiplier * dynamic_scale)), 2)
                raw_offset = atr_val * offset_multiplier

                # 挂单距离护栏（美元）：避免固定距离，也避免极端行情下挂得过近/过远。
                min_offset_usd = 1.5
                max_offset_usd = 14.0
                if active_limit_tier in {"TIGHT_LIMIT", "LIMIT", "WIDE_LIMIT"} or counter_trend_risk == "HIGH":
                    if active_limit_tier == "TIGHT_LIMIT":
                        min_offset_usd = 1.0
                        max_offset_usd = 10.0
                    elif active_limit_tier == "LIMIT":
                        min_offset_usd = 2.0
                        max_offset_usd = 14.0
                    else:
                        min_offset_usd = 3.0
                        max_offset_usd = 18.0
                elif structure_session_bias in ("ASIA_CONSERVATIVE", "ASIA_DEFENSIVE") or regime == "RANGING":
                    min_offset_usd = 3.0
                    max_offset_usd = 20.0
                elif structure_session_bias in ("HIGH_LIQUIDITY", "NY_VOLATILE"):
                    min_offset_usd = 1.2
                    max_offset_usd = 12.0
                elif structure_confirmation == "CONFIRMED" and structure_quality in ("HEALTHY", "STRONG") and effective_conf >= 0.85:
                    min_offset_usd = 1.0
                    max_offset_usd = 10.0

                # 点差下限：点差放大时，挂单距离至少是点差的若干倍，避免贴脸挂单。
                spread_usd = max(0.01, float(ask - bid))
                spread_floor_multiplier = 4.0
                if structure_session_bias in ("ASIA_CONSERVATIVE", "ASIA_DEFENSIVE"):
                    spread_floor_multiplier = 5.0
                elif structure_session_bias in ("HIGH_LIQUIDITY", "NY_VOLATILE"):
                    spread_floor_multiplier = 3.0
                if active_limit_tier in {"TIGHT_LIMIT", "LIMIT", "WIDE_LIMIT"}:
                    if active_limit_tier == "TIGHT_LIMIT":
                        spread_floor_multiplier = max(spread_floor_multiplier, 4.5)
                    elif active_limit_tier == "LIMIT":
                        spread_floor_multiplier = max(spread_floor_multiplier, 5.5)
                    else:
                        spread_floor_multiplier = max(spread_floor_multiplier, 6.5)
                spread_floor_usd = spread_usd * spread_floor_multiplier

                offset = max(raw_offset, spread_floor_usd, min_offset_usd)
                offset = min(offset, max_offset_usd)
                
                if signal["side"] == "BUY":
                    current_entry = round(bid - offset, 2)
                    _log(
                        f"📉 [防接飞刀] {reason_msg}，做多市价 {bid} 风险大，改为挂单 {current_entry} "
                        f"(tier={defense_mode}, score={defense_score:.2f}, ext={ext_ratio:.2f}/{ext_allow:.2f}, "
                        f"ATR={raw_offset:.2f}, spread_floor={spread_floor_usd:.2f}, clamp={offset:.2f})",
                        "WARN",
                    )
                else:
                    current_entry = round(ask + offset, 2)
                    _log(
                        f"📈 [防接飞刀] {reason_msg}，做空市价 {ask} 风险大，改为挂单 {current_entry} "
                        f"(tier={defense_mode}, score={defense_score:.2f}, ext={ext_ratio:.2f}/{ext_allow:.2f}, "
                        f"ATR={raw_offset:.2f}, spread_floor={spread_floor_usd:.2f}, clamp={offset:.2f})",
                        "WARN",
                    )
                
                # 将软挂单推入队列，暂不发给 EA
                sig_id = signal.get("signal_id", f"sig_{time.time()}")
                
                # 【防呆机制：同向挂单点位更新】
                # 如果当前队列里已经有同方向的挂单，不要傻傻地堆积多个，而是保留最优（最保守）的点位，或者直接更新
                keys_to_delete = []
                for k, existing_order in _soft_limits.items():
                    if existing_order["side"] == signal["side"]:
                        keys_to_delete.append(k)
                    elif existing_order["side"] != signal["side"]:
                        # 【防呆机制：反向撤销】
                        # 如果原来挂着多单，现在突然出了空单信号，说明行情已经彻底反转
                        # 必须立刻撤销之前的多头挂单，防止被“回头箭”射死！
                        keys_to_delete.append(k)
                        _log(f"⚠️ [防呆撤销] 行情反转出 {signal['side']} 信号，主动撤销旧的 {existing_order['side']} 挂单！", "WARN")
                
                for k in keys_to_delete:
                    replaced_order = _soft_limits.get(k, {})
                    replacement_reason = "SOFT_LIMIT_REPLACED_SAME_SIDE" if replaced_order.get("side") == signal["side"] else "SOFT_LIMIT_REPLACED_REVERSED"
                    _remove_soft_limit(
                        k,
                        to_state="VETOED",
                        reason=replacement_reason,
                        replacement_side=signal["side"],
                        replacement_entry=current_entry,
                    )

                # ── R:R 自动修复：参数倒挂时先修复再注册软挂单 ──
                sig_sl = float(signal.get("sl", 0) or 0)
                sig_tp = float(signal.get("tp", 0) or 0)
                sig_entry = float(signal.get("entry", current_entry) or current_entry or 0)
                if sig_sl > 0 and sig_tp > 0 and sig_entry > 0:
                    atr_hint = float(signal.get("indicators", {}).get("atr14", 0) or 0)
                    fixed_sl, fixed_tp, fixed, fixed_rr = _repair_sl_tp_for_rr(
                        signal.get("side", ""),
                        sig_entry,
                        sig_sl,
                        sig_tp,
                        atr=atr_hint,
                        min_rr=1.0,
                    )
                    if fixed:
                        _log(
                            f"[AUDIT ✅] 自动修复低RR/倒挂参数: side={signal.get('side')} "
                            f"entry={sig_entry:.2f} SL {sig_sl:.2f}->{fixed_sl:.2f} TP {sig_tp:.2f}->{fixed_tp:.2f} RR={fixed_rr:.2f}",
                            "WARN",
                        )
                        signal["sl"] = fixed_sl
                        signal["tp"] = fixed_tp

                _soft_limits[sig_id] = {
                    "decision_id": decision_id,
                    "side": signal["side"],
                    "limit_price": current_entry,
                    "design_entry": float(signal.get("entry", current_entry) or current_entry),
                    "sl": signal["sl"],
                    "tp": signal["tp"],
                    "vol": signal["volume_lots"],
                    "expire": time.time() + 1800,  # 挂单半小时有效
                    "entry_style": "LIMIT",
                    "session_mode": session_mode,
                    "regime": signal.get("regime", "UNKNOWN"),
                    "reason": reason_msg,
                    "atr_offset_usd": round(raw_offset, 2),
                    "spread_floor_usd": round(spread_floor_usd, 2),
                    "min_offset_usd": round(min_offset_usd, 2),
                    "max_offset_usd": round(max_offset_usd, 2),
                    "final_offset_usd": round(offset, 2),
                    "defense_mode": defense_mode,
                    "defense_score": round(defense_score, 4),
                    "defense_reason_codes": defense_reason_codes[:6],
                    "registered_agent_direction": str(agent_dir_current or "").upper(),
                    "attribution": signal.get("attribution", {}),
                }
                _append_decision_link(
                    signal_id=sig_id,
                    decision_id=decision_id,
                    action="OPEN",
                    source="soft_limit_registered",
                )
                _transition_decision_state(
                    decision_id,
                    "WAITING_TRIGGER",
                    reason="SOFT_LIMIT_REGISTERED",
                    limit_price=current_entry,
                    side=signal["side"],
                    volume=signal["volume_lots"],
                    session_mode=session_mode,
                    defensive_reason=reason_msg,
                    defense_mode=defense_mode,
                    defense_score=round(defense_score, 4),
                    defense_reason_codes=defense_reason_codes[:6],
                    expire_at=_soft_limits[sig_id]["expire"],
                )
                _log(f"✅ 软挂单已注册: {signal['side']} LIMIT @{current_entry} (有效半小时){ema_str}", "WARN")
                if _is_overlap_window():
                    _add_overlap_attempt(state, reason="soft_limit_registered")
                    save_state(state)
                
                # [Swarm] 政委同意执行，但判定需要挂单防守
                try:
                    side_zh = "做多" if signal.get("side") == "BUY" else "做空"
                    reply_msg = f"已批准 {side_zh} 请求！但当前有{reason_msg}风险，已转为防守挂单 (LIMIT @{current_entry})，SL={signal['sl']:.1f}, TP={signal['tp']:.1f}。继续潜伏。"
                    _runtime_blackboard_log("Risk_Commissar", "success", reply_msg)
                except Exception:
                    pass
            else:
                # 安全环境，正常市价单发送
                anchored_sl, anchored_tp = _reanchor_sl_tp_by_design(
                    signal.get("side", ""),
                    current_entry,
                    float(signal.get("entry", current_entry) or current_entry),
                    signal.get("sl", 0),
                    signal.get("tp", 0),
                )

                # ── Layer 2: 自适应画像 SL/TP 微调 ──
                _adp_delta = max(-0.15, min(0.15, _defensive_delta))
                sl_adj = 1.0 + _adp_delta
                tp_adj = 1.0 + _adp_delta * 0.5
                sl_adj = max(0.85, min(1.15, sl_adj))
                tp_adj = max(0.85, min(1.15, tp_adj))

                # 波动率状态调整
                if _vol_state in ("EXPLOSIVE", "HIGH"):
                    vol_sl_bonus = 1.10
                    vol_tp_bonus = 1.15
                elif _vol_state in ("LOW", "ULTRA_LOW"):
                    vol_sl_bonus = 0.95
                    vol_tp_bonus = 0.95
                else:
                    vol_sl_bonus = 1.0
                    vol_tp_bonus = 1.0
                if _liq_state == "WHIPSAW":
                    vol_sl_bonus = min(vol_sl_bonus, 0.92)
                    vol_tp_bonus = min(vol_tp_bonus, 0.92)

                if sl_adj != 1.0 or tp_adj != 1.0 or vol_sl_bonus != 1.0 or vol_tp_bonus != 1.0:
                    if signal["side"] == "BUY":
                        _sl_d = current_entry - anchored_sl
                        _tp_d = anchored_tp - current_entry
                        anchored_sl = round(current_entry - _sl_d * sl_adj * vol_sl_bonus, 3)
                        anchored_tp = round(current_entry + _tp_d * tp_adj * vol_tp_bonus, 3)
                    else:
                        _sl_d = anchored_sl - current_entry
                        _tp_d = current_entry - anchored_tp
                        anchored_sl = round(current_entry + _sl_d * sl_adj * vol_sl_bonus, 3)
                        anchored_tp = round(current_entry - _tp_d * tp_adj * vol_tp_bonus, 3)
                    _log(
                        f"  Layer2 自适应微调: SL×{sl_adj*vol_sl_bonus:.3f} TP×{tp_adj*vol_tp_bonus:.3f} "
                        f"(delta={_adp_delta:+.3f} vol={_vol_state} liq={_liq_state})",
                        "WARN",
                    )

                _log(
                    f"✅ 自动执行: {current_action} {signal['side']} @{current_entry} "
                    f"SL={anchored_sl} TP={anchored_tp} "
                    f"| ATR={atr_now:.2f} sl={abs(current_entry-anchored_sl)/max(atr_now,0.01):.2f}xatr "
                    f"tp={abs(anchored_tp-current_entry)/max(atr_now,0.01):.2f}xatr "
                    f"RR={abs(anchored_tp-current_entry)/max(abs(current_entry-anchored_sl),0.01):.1f} "
                    f"[{agent_tag}] conf={conf_pct}%{ema_str}",
                    "WARN",
                )
                
                # [Swarm] 政委同意执行并立即发单
                try:
                    side_zh = "做多" if signal.get("side") == "BUY" else "做空"
                    reply_msg = f"已批准 {side_zh} 请求！风控参数：SL={signal['sl']:.1f}, TP={signal['tp']:.1f}, 手数={signal['volume_lots']}。全军出击！"
                    _runtime_blackboard_log("Risk_Commissar", "success", reply_msg)
                except Exception:
                    pass

                # ── 最终方向门：Agent SHORT 时禁止 BUY，Agent LONG 时禁止 SELL ──
                # 这是最后一层硬阻断，确保宏观方向与微观信号冲突时不会执行反向单
                agent_dir_upper = str(agent_dir_current or "").upper()
                signal_side_upper = str(signal.get("side") or "").upper()
                regime_for_gate = str(signal.get("regime", "UNKNOWN") or "UNKNOWN")
                if agent_dir_upper in ("SHORT", "LONG") and signal_side_upper in ("BUY", "SELL"):
                    direction_conflict = (agent_dir_upper == "SHORT" and signal_side_upper == "BUY") or (agent_dir_upper == "LONG" and signal_side_upper == "SELL")
                    if direction_conflict:
                        _log(f"[AUDIT ❌] 方向硬阻断：Agent方向={agent_dir_upper}，信号方向={signal_side_upper}，拒绝执行反向单！", "ERROR")
                        _transition_decision_state(decision_id, "REJECTED", reason="DIRECTION_CONFLICT_HARD_BLOCK", agent_dir=agent_dir_upper, signal_side=signal_side_upper)
                        time.sleep(INTERVAL_NO_POS); continue

                sig_id = write_trade_request(
                    current_action,
                    signal["side"],
                    current_entry,
                    anchored_sl,
                    anchored_tp,
                    vol=signal["volume_lots"],
                    sig_id=signal.get("signal_id"),
                    decision_id=decision_id,
                    attribution=signal.get("attribution"),
                    request_origin="legacy_signal_poll",
                    legacy_fallback=False,
                )
                if sig_id:
                    if _is_overlap_window():
                        _add_overlap_attempt(state, reason="market_request_written")
                        save_state(state)
                    _transition_decision_state(
                        decision_id,
                        "EXECUTING",
                        reason="REQUEST_WRITTEN",
                        request_action=current_action,
                        request_side=signal.get("side"),
                        entry=current_entry,
                        sl=signal.get("sl"),
                        tp=signal.get("tp"),
                        volume=signal.get("volume_lots"),
                    )
                    time.sleep(20); state = load_state(); state = process_ea_reports(state)
                    if state.get("position_open"): save_state(state); return
                else:
                    _close_decision_state(
                        decision_id,
                        "FAILED",
                        reason="REQUEST_WRITE_FAILED",
                        request_action=current_action,
                        request_side=signal.get("side"),
                    )
        elif signal["action"] == "HOLD" or _skip_execute:
            # HOLD 信号或被强制拦截的低质量信号不进入复核队列，直接跳过
            pass
        else:
            # LOW_CONFIDENCE 与 Review 合并为单判定：不再进入复核队列，直接执行统一拒绝。
            _close_decision_state(
                decision_id,
                "REJECTED",
                reason="LOW_CONFIDENCE_SINGLE_GATE",
                raw_confidence=raw_conf,
                confidence_after_override=override_conf,
                requested_confidence=conf,
                min_confidence=min_conf,
                execute_floor=execute_floor,
                hard_floor=hard_floor,
                block_reason=block_reason,
                override_multiplier=agent_multiplier,
                override_reason=agent_reason,
            )
            _set_final_reject_reason(state, "LOW_CONFIDENCE_SINGLE_GATE", stage="execution_gate")
            save_state(state)
            _log(
                f"🛑 低置信单闸门拒绝: {signal['action']} {signal.get('side','?')} [{agent_tag}] "
                f"raw={raw_conf*100:.1f}% -> final={conf_pct}% < floor={hard_floor*100:.0f}% "
                f"(target={execute_floor*100:.0f}%, mult={float(agent_multiplier or 1.0):.2f}){ema_str}",
                "WARN",
            )

        time.sleep(INTERVAL_NO_POS)

# ============ 主循环 ============
def reconcile_positions(state):
    """
    状态对账与异常接管：对比 MT5 真实持仓和本地 SQLite 记录
    如果发现 MT5 有仓位但本地没有记录（孤儿单），则接管并写入本地状态。
    如果发现本地有记录但 MT5 没仓位（被手动平仓或止盈止损），则清理本地状态。
    同时清理 orphaned journal 文件。
    """
    mt5_state = get_mt5_position_state()
    if mt5_state.get("error"):
        # MT5 files are unreadable — advance ghost counter if we track a position
        # so that extended EA outages don't permanently mask ghost orders.
        db_ticket = int(state.get("position_ticket", 0) or 0)
        if db_ticket > 0:
            ghost_count = int(state.get("ghost_check_count", 0)) + 1
            state["ghost_check_count"] = ghost_count
            _log(
                f"⚠️ MT5 读取失败 ({mt5_state['error']})，对账跳过但幽灵计数器推进: "
                f"ticket={db_ticket} count={ghost_count}/3",
                "WARN",
            )
        else:
            _log(f"⚠️ 无法读取 MT5 持仓状态 ({mt5_state['error']})，本次对账跳过", "WARN")
        return state
    _update_account_state(mt5_state.get("balance"), mt5_state.get("equity"))

    mt5_positions = mt5_state.get("positions_all", [])
    mt5_tickets = {p["ticket"] for p in mt5_positions}

    persist_after_reconcile = False
    try:
        conn = _open_state_db(timeout=10.0)
        c = conn.cursor()

        # ── 1. 读取 state_kv 中的持仓状态（权威数据源）──
        c.execute("SELECT value FROM state_kv WHERE key = 'position_ticket'")
        row = c.fetchone()
        db_ticket = int(row[0]) if row and row[0] else 0

        # 修复点：确保我们使用的是全局的 mt5_positions 而不是局部空表
        # 因为 MT5_FILES 轮询机制下，get_mt5_position_state 返回的 positions_all 才是最新的。

        # ── 2. 幽灵单：state_kv 说有持仓，但 MT5 实际没有（被外部平仓/止损）──
        # 加入防误杀机制：如果连续 N 次发现 MT5 没单，才真正清理。防止 EA 重启瞬间的数据为空导致误杀。
        if db_ticket and db_ticket not in mt5_tickets:
            ghost_count = int(state.get("ghost_check_count", 0)) + 1
            state["ghost_check_count"] = ghost_count
            
            if ghost_count >= 3: # 连续 3 次 (约 30 秒) 都没在 MT5 看到这笔单子，才判定为幽灵单
                _log(f"Reconciliation: Ghost ticket={db_ticket} in DB but not in MT5 (checked {ghost_count} times). Cleaning.", "WARN")
                state_before_close = dict(state)
                # 强制设置状态并在内存和数据库中落盘，防止被并发覆盖
                state["position_open"] = False
                save_state(state)
                
                db_side = str(state_before_close.get("position_side", "") or "").upper()
                position_started_at = float(state_before_close.get("position_time", time.time()) or time.time())
                duration_min = int((time.time() - position_started_at) / 60)
                entry = float(state_before_close.get("position_entry", state_before_close.get("position_price", 0.0)) or 0.0)
                exit_price = entry
                close_pnl = 0.0
                # `load_state()` can intentionally preserve ghost metadata while marking
                # `position_open=False`; reconcile still needs that metadata to close out.
                if state_before_close.get("position_ticket"):
                    try:
                        bid_rt, ask_rt, _, _, _ = get_price()
                    except Exception:
                        bid_rt = ask_rt = 0.0
                    exit_price = (bid_rt + ask_rt) / 2 if bid_rt and ask_rt else entry
                    if entry and exit_price:
                        side = db_side or "BUY"
                        close_pnl = (exit_price - entry) if side == "BUY" else (entry - exit_price)
                    try:
                        _finalize_position_closure(
                            state_before_close,
                            exit_price=exit_price,
                            close_pnl=round(close_pnl, 4),
                            duration_min=duration_min,
                            done_reason="GHOST_RECONCILIATION",
                            close_ts=time.time(),
                            recovered=True,
                        )
                    except Exception as e:
                        _log(f"Reconciliation: failed to finalize ghost closure for ticket={db_ticket}: {e}", "WARN")
                    if not _position_owned_by_pipeline(state_before_close):
                        try:
                            sync_close_fill(
                                normalize_position_attribution(
                                    state_before_close.get("position_attribution", {}) if isinstance(state_before_close.get("position_attribution", {}), dict) else {},
                                    action="CLOSE",
                                    fallback=state_before_close.get("position_attribution", {}) if isinstance(state_before_close.get("position_attribution", {}), dict) else {},
                                ),
                                data_dir=DATA_DIR,
                                ticket=int(db_ticket or 0),
                                exit_price=float(exit_price or 0.0),
                                pnl=float(close_pnl or 0.0),
                                side=str(db_side or ""),
                                reason="GHOST_RECONCILIATION",
                                source="bridge_reconcile_ghost",
                            )
                        except Exception as lifecycle_err:
                            _daemon_log(f"sync_close_fill ghost reconcile failed: {lifecycle_err}")
                c.execute("DELETE FROM state_kv WHERE key IN (?, ?, ?, ?, ?, ?, ?, ?)",
                          ("position_open", "position_side", "position_ticket",
                           "position_entry", "position_price", "position_sl", "position_tp", "position_vol"))
                # 清理幽灵单后，重置计数器
                state["ghost_check_count"] = 0
                conn.commit()
                # 只有这里修改 state 对象
                state.update({
                    "position_open": False,
                    "position_ticket": 0,
                    "position_time": 0,
                    "position_signal_id": "",
                    "position_decision_id": "",
                    "position_regime": "UNKNOWN",
                    "position_strategy_id": "",
                    "position_trace_id": "",
                    "position_confidence": 0.0,
                    "position_session_info": {},
                    "position_session_metrics": {},
                    "position_interpreter": {},
                    "position_attribution": {},
                    "position_agent_direction": "",
                    "position_entry_owner_instance_id": "",
                    "position_entry_owner_strategy_id": "",
                    "position_current_manager_instance_id": "",
                    "position_current_manager_strategy_id": "",
                    "position_expected_exit_owner_instance_id": "",
                    "position_expected_exit_owner_strategy_id": "",
                    "position_exit_owner_instance_id": "",
                    "position_exit_owner_strategy_id": "",
                    "position_contributors": [],
                    "recent_closures": state_before_close.get("recent_closures", []),
                    "pattern_memory": state_before_close.get("pattern_memory", {}),
                    "runtime_adaptation": state_before_close.get("runtime_adaptation", {}),
                    "learning_adaptation_summary": state_before_close.get("learning_adaptation_summary", {}),
                })
                _apply_realized_closure_stats(state, close_pnl, position_open=False)
                _seal_close_guard(
                    state,
                    closed_side=str(db_side or ""),
                    closed_ticket=int(db_ticket or 0),
                    close_action="RECONCILE_AUTO_CLOSE",
                )
                global _last_close_time
                _last_close_time = state["last_close_time"]
                persist_after_reconcile = True
                _log("Reconciliation: Ghost position cleared from state_kv, memory state reset, _last_close_time updated", "WARN")
            else:
                _log(f"Reconciliation: Potential ghost ticket={db_ticket} (check {ghost_count}/3). Waiting for MT5 sync.", "INFO")
                save_state(state)
        elif db_ticket:
            if int(state.get("ghost_check_count", 0)) > 0:
                state["ghost_check_count"] = 0
                save_state(state)

        # ── 3. 孤儿单：MT5 有，但 state_kv 没有（断电前开仓刚好在启动期间成交，或因 Bug 漏记）──
        # 对称于幽灵单的防误杀机制：连续 N 次确认 MT5 有该单子才接管，防止 EA 重启瞬间
        # 数据闪烁导致的错误接管。
        if mt5_tickets and not db_ticket:
            t = next(iter(mt5_tickets))
            p_info = next((p for p in mt5_positions if p["ticket"] == t), None)
            if p_info:
                orphan_key = f"orphan_{t}"
                orphan_check_count = int(state.get("orphan_check_counts", {}).get(orphan_key, 0)) + 1
                state.setdefault("orphan_check_counts", {})[orphan_key] = orphan_check_count

                if orphan_check_count < 3:
                    _log(f"Reconciliation: Potential orphan ticket={t} (check {orphan_check_count}/3). Waiting for MT5 sync.", "INFO")
                    save_state(state)
                else:
                    # 修复孤儿单接管逻辑，确保完全同步 MT5 的所有字段
                    side_str = p_info["side"]
                    entry_price = float(p_info["entry"])
                    current_sl = float(p_info.get("sl", 0))
                    current_tp = float(p_info.get("tp", 0))
                    current_vol = float(p_info.get("volume", 0.01))

                    keys = ["position_open","position_side","position_ticket","position_entry",
                            "position_price","position_sl","position_tp","position_vol","position_time"]
                    vals = ["true", f'"{side_str}"', str(t),
                            str(entry_price), str(entry_price),
                            str(current_sl), str(current_tp),
                            str(current_vol),
                            str(int(time.time()))]

                    c.execute("DELETE FROM state_kv WHERE key IN (?, ?, ?, ?, ?, ?, ?, ?, ?)", keys)
                    for k, v in zip(keys, vals):
                        c.execute("INSERT INTO state_kv (key, value, updated_at) VALUES (?, ?, datetime('now'))", [k, v])
                    conn.commit()

                    # 同步更新内存中的 state
                    # 注意：position_time 使用孤儿单恢复时间（time.time()），
                    # 且标记为孤儿单以区分真实开仓时间（用于时间止损判断）
                    state.update({
                        "position_open": True,
                        "position_side": side_str,
                        "position_ticket": t,
                        "position_entry": entry_price,
                        "position_sl": current_sl,
                        "position_tp": current_tp,
                        "position_vol": current_vol,
                        "position_time": time.time(),
                        "orphan_recovered": True,   # 孤儿单标记：时间止损应宽容处理
                        "partial_done": False
                    })
                    try:
                        orphan_attribution = normalize_position_attribution(
                            state.get("position_attribution", {}) if isinstance(state.get("position_attribution", {}), dict) else {},
                            action="OPEN",
                        )
                        state.update(position_state_fields_from_attribution(orphan_attribution, action="OPEN"))
                        if not _position_owned_by_pipeline(state):
                            sync_open_fill(
                                orphan_attribution,
                                data_dir=DATA_DIR,
                                ticket=int(t or 0),
                                side=side_str,
                                volume=float(current_vol or 0.0),
                                entry_price=float(entry_price or 0.0),
                                sl=float(current_sl or 0.0),
                                tp=float(current_tp or 0.0),
                                symbol="XAUUSD",
                                source="bridge_reconcile_orphan",
                            )
                    except Exception as lifecycle_err:
                        _daemon_log(f"sync_open_fill orphan reconcile failed: {lifecycle_err}")
                    # 清理该 ticket 的确认计数
                    state.get("orphan_check_counts", {}).pop(orphan_key, None)
                    _log(f"Reconciliation: Recovered orphan position ticket={t} from MT5 (confirmed {orphan_check_count} times). state synchronized. position_time reset to now (orphan).", "WARN")

        conn.close()

        if persist_after_reconcile:
            # 在 DB 连接关闭后再持久化，避免锁竞争导致写入静默失败。
            save_state(state)

        # ── 4. 清理 DB 残留的 orphaned journal 文件 ──
        try:
            import glob as _glob
            journal_files = _glob.glob(str(DB_PATH) + "-mj-*") + _glob.glob(str(DB_PATH) + "-wal-*")
            for jf in journal_files:
                try:
                    os.unlink(jf)
                    _log(f"Reconciliation: Removed orphaned journal {jf}", "INFO")
                except OSError:
                    pass
        except Exception:
            pass

    except Exception as e:
        _log(f"Reconciliation failed: {e} | db={DB_PATH}", "ERROR")

    return state


# ============ OpenClaw 任务队列 Worker（单例，仅在 daemon_loop 中启动） ============
def _openclaw_worker_thread():
    """单 worker 线程：从队列消费任务，限流 + 僵尸回收"""
    while not KILL_FLAG.is_set():
        # 1. 僵尸回收（定期扫描）
        with _OPENCLAW_LOCK:
            dead = []
            for proc in _OPENCLAW_RUNNING:
                ret = proc.poll()
                if ret is not None:
                    dead.append(proc)
            for proc in dead:
                _OPENCLAW_RUNNING.remove(proc)
                _daemon_log(f"[openclaw_worker] reaped zombie pid={proc.pid} rc={ret}")

        # 2. 限流：已跑满则 sleep 后继续
        with _OPENCLAW_LOCK:
            active = len(_OPENCLAW_RUNNING)
        if active >= MAX_CONCURRENT_OPENCLAW:
            time.sleep(2)
            continue

        # 3. 取任务（阻塞最多 2 秒，等队列有任务或超时退出）
        try:
            task = _OPENCLAW_TASK_QUEUE.get(timeout=2.0)
        except _queue.Empty:
            continue

        cmd = task["cmd"]
        label = task.get("label", "openclaw")
        timeout = task.get("timeout", 120)
        _daemon_log(f"[openclaw_worker] dispatching {label}: {' '.join(cmd[:3])}...")

        # 4. 启动进程，登记到 RUNNING
        try:
            proc = subprocess.Popen(
                cmd,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                start_new_session=False,
            )
            with _OPENCLAW_LOCK:
                _OPENCLAW_RUNNING.append(proc)
            _daemon_log(f"[openclaw_worker] started {label} pid={proc.pid} (active={active+1})")

            # 5. 等待（带超时），防止永久阻塞
            try:
                proc.wait(timeout=timeout)
                _daemon_log(f"[openclaw_worker] finished {label} pid={proc.pid} rc={proc.returncode}")
            except subprocess.TimeoutExpired:
                _daemon_log(f"[openclaw_worker] {label} timed out after {timeout}s, killing pid={proc.pid}")
                proc.kill()
                proc.wait()
                _daemon_log(f"[openclaw_worker] killed {label} pid={proc.pid}")
        except Exception as e:
            _daemon_log(f"[openclaw_worker] failed to spawn {label}: {e}")
        finally:
            with _OPENCLAW_LOCK:
                if proc in _OPENCLAW_RUNNING:
                    _OPENCLAW_RUNNING.remove(proc)
            _OPENCLAW_TASK_QUEUE.task_done()

def enqueue_openclaw_task(cmd: list, label: str = "openclaw", timeout: int = 120):
    """线程安全地把 openclaw 任务入队（非阻塞）"""
    _OPENCLAW_TASK_QUEUE.put({"cmd": cmd, "label": label, "timeout": timeout})
    _daemon_log(f"[openclaw] queued task '{label}', queue_size={_OPENCLAW_TASK_QUEUE.qsize()}")

def _trim_stale_ledgers():
    """修剪 logs/ 下的 ndjson 账本文件，防止无限增长。"""
    logs_dir = ROOT / "logs"
    if not logs_dir.exists():
        return
    ndjson_limits = {
        "decision_ledger.ndjson": 10_000,
        "trading_ledger.ndjson": 10_000,
        "signal_ledger.ndjson": 5_000,
        "risk_events.ndjson": 5_000,
        "decision_signal_links.ndjson": 5_000,
        "shadow_learner_decisions.ndjson": 5_000,
        "live_learner_selections.ndjson": 5_000,
        "restart_fingerprint.ndjson": 2_000,
        "autonomy_loop_history.ndjson": 2_000,
    }
    for filename, max_lines in ndjson_limits.items():
        path = logs_dir / filename
        if not path.exists():
            continue
        try:
            lines = path.read_text(encoding="utf-8").splitlines()
            if len(lines) > max_lines:
                path.write_text("\n".join(lines[-max_lines:]) + "\n", encoding="utf-8")
        except Exception:
            pass


def daemon_loop():
    # 进程启动后再初始化数据库，避开 fork
    init_db()

    _daemon_log("Daemon main loop started")

    # 启动时先修剪一次过长的账本文件
    _trim_stale_ledgers()

    # ── 启动 OpenClaw Worker 线程（限流 + 僵尸回收）─────────
    _last_heartbeat = time.time()
    _last_active_action = time.time()
    _worker_t = threading.Thread(target=_openclaw_worker_thread, daemon=True)
    _worker_t.start()
    _daemon_log(f"OpenClaw worker thread started (max_concurrent={MAX_CONCURRENT_OPENCLAW})")

    def interpreter_thread():
        _daemon_log("Market Interpreter thread started")
        interpreter_script = ROOT / "src" / "market_interpreter.py"
        while not KILL_FLAG.is_set():
            try:
                # 只有周一到周五运行
                now_dt = datetime.datetime.now()
                if not (now_dt.weekday() == 5 or (now_dt.weekday() == 6 and now_dt.hour < 22)):
                    if interpreter_script.exists():
                        _daemon_log("Queueing Market Interpreter (5min cycle)...")
                        enqueue_openclaw_task(
                            [sys.executable, str(interpreter_script)],
                            label="market_interpreter",
                            timeout=180,
                        )
            except Exception as e:
                _daemon_log(f"Market Interpreter error: {e}")
            
            for _ in range(300):
                if KILL_FLAG.is_set():
                    break
                time.sleep(1)

    t_interp = threading.Thread(target=interpreter_thread, daemon=True)
    t_interp.start()

    def patrol_thread():
        _daemon_log("Local Patrol (Self Healer) thread started")
        healer_script = ROOT / "src" / "self_healer.py"
        while not KILL_FLAG.is_set():
            try:
                # 只有周一到周五运行
                now_dt = datetime.datetime.now()
                if not (now_dt.weekday() == 5 or (now_dt.weekday() == 6 and now_dt.hour < 22)):
                    if healer_script.exists():
                        # Self-healer: 只打日志，不阻塞，不抛异常给主循环
                        # _daemon_log("Queueing Local Patrol / Self Healer...")
                        enqueue_openclaw_task(
                            [sys.executable, str(healer_script)],
                            label="self_healer",
                            timeout=120,
                        )
            except Exception as e:
                _daemon_log(f"Local Patrol error: {e}")
            
            # 高频实时监测：每 2 分钟 (120秒) 巡查一次系统报错和卡顿
            for _ in range(120):
                if KILL_FLAG.is_set():
                    break
                time.sleep(1)

    t_patrol = threading.Thread(target=patrol_thread, daemon=True)
    t_patrol.start()

    def watchdog_thread():
        nonlocal _last_heartbeat, _last_active_action
        _last_mt5_repair_attempt = 0
        mt5_repair_cooldown = 300
        _daemon_log("Watchdog thread started")
        while not KILL_FLAG.is_set():
            try:
                # 0. 静默期超时告警 (Idle Timeout Alert) - 防死锁不报
                # 如果超过 4 小时 (14400秒) 系统没有任何交易动作且不在周末休市期，则触发主动告警
                now_ts = time.time()
                if now_ts - _last_active_action > 14400:
                    now_dt = datetime.datetime.now()
                    # 排除周末 (周六凌晨到周一早上)
                    if not (now_dt.weekday() == 5 or (now_dt.weekday() == 6 and now_dt.hour < 22)):
                        alert_msg = f"🚨 严重静默告警: 系统已超过 4 小时未产生任何有效信号或交易动作，可能发生死锁或行情断流！请立即检查面板！"
                        _log(alert_msg, "CRITICAL")
                        _daemon_log(alert_msg)
                        # 触发飞书/QQ推送（通过队列限流，不阻塞）
                        notify_script = ROOT / "bridge" / "review_notify.py"
                        if notify_script.exists():
                            enqueue_openclaw_task(
                                [sys.executable, "--run-notify", "--text", alert_msg] if getattr(sys, "frozen", False) else [sys.executable, str(notify_script), "--text", alert_msg],
                                label="review_notify",
                                timeout=30,
                            )
                        # 重置计时器，避免疯狂弹窗，等下一个 4 小时再报
                        _last_active_action = now_ts

                # 1. MT5 心跳检查
                if MT5_FILES:
                    sf = _resolve_mt5_terminal_status_file()
                    if sf.exists():
                        diff = time.time() - sf.stat().st_mtime
                        if diff > 180:
                            _daemon_log(f"Watchdog: MT5 heartbeat lost for {int(diff)}s")
                            now_ts = time.time()
                            if now_ts - _last_mt5_repair_attempt >= mt5_repair_cooldown:
                                _last_mt5_repair_attempt = now_ts
                                _daemon_log("Watchdog: attempting MT5 recovery")
                                ok = restart_mt5(force_kill=True)
                                if ok:
                                    _daemon_log("Watchdog: MT5 recovery success")
                                else:
                                    _daemon_log("Watchdog: MT5 recovery failed")
                    else:
                        now_ts = time.time()
                        if (not is_mt5_running()) and (now_ts - _last_mt5_repair_attempt >= mt5_repair_cooldown):
                            _last_mt5_repair_attempt = now_ts
                            _daemon_log("Watchdog: terminal_status.json missing, attempting MT5 recovery")
                            ok = restart_mt5(force_kill=False)
                            if ok:
                                _daemon_log("Watchdog: MT5 startup success")
                            else:
                                _daemon_log("Watchdog: MT5 startup failed")

                # 2. Daemon 自身心跳检查（每60秒写一次心跳）
                if time.time() - _last_heartbeat > 60:
                    # 自身心跳超时 → 写事件到 SQLite（供外部 health_check 读取）
                    try:
                        conn = _open_state_db(timeout=10.0)
                        c = conn.cursor()
                        c.execute("INSERT OR REPLACE INTO state_kv (key, value, updated_at) VALUES (?, ?, datetime('now'))",
                                  ("daemon_heartbeat", str(int(time.time()))))
                        conn.commit()
                        conn.close()
                    except Exception:
                        pass
                    _last_heartbeat = time.time()
                    _daemon_log(f"Watchdog: daemon heartbeat OK (pid={os.getpid()})")

            except Exception as e:
                _daemon_log(f"Watchdog error: {e}")
            time.sleep(30)

    wt = threading.Thread(target=watchdog_thread, daemon=True)
    wt.start()

    # GARCH 波动率后台刷新线程（每 5 分钟刷新一次缓存）
    GARCH_REFRESH_INTERVAL = 300  # 5 分钟

    def garch_refresh_thread():
        _daemon_log("GARCH refresh thread started")
        while not KILL_FLAG.is_set():
            try:
                from bridge.garch_volatility import get_vol_multiplier
                result = get_vol_multiplier(refresh=True)
                regime = result.get("regime", "unknown")
                vol_ratio = result.get("vol_ratio", 0)
                mult = result.get("multiplier", 1.0)
                age = result.get("age_seconds", -1)
                _daemon_log(f"GARCH refresh: regime={regime}, vol_ratio={vol_ratio:.3f}, mult={mult}, age={age}s")
            except Exception as e:
                _daemon_log(f"GARCH refresh error: {e}")
            time.sleep(GARCH_REFRESH_INTERVAL)

    garch_t = threading.Thread(target=garch_refresh_thread, daemon=True)
    garch_t.start()

    # 每日与定时任务线程
    def daily_task_thread():
        _daemon_log("Daily task thread started")
        last_run_date_briefing = None
        last_run_date_evolution = None
        last_run_date_defense = None
        last_macro_run_hour = -1  # 记录上次执行宏观分析的小时
        _prev_macro_score = None  # 记录上次唤醒时的分数
        _last_macro_trigger_time = 0 # 记录上次触发的时间
        _last_ledger_cleanup_hour = -1  # 记录上次账本清理的小时
        
        while not KILL_FLAG.is_set():
            try:
                now = datetime.datetime.now()

                # === 1. 宏观战略 Agent (DeepSeek) 触发逻辑 ===
                # 获取当前的 overall_score 以判断是否发生了剧烈变化
                curr_s = 0.0
                try:
                    from config_manager import get_app_dir
                    bb = json.loads((get_app_dir() / "blackboard_state.json").read_text())
                    curr_s = float(bb.get("Macro_Commander", {}).get("session_score", 0.0))
                except:
                    pass

                _macro_trigger = False
                is_ny_session = False
                try:
                    sa = json.loads((LOGS_DIR / "session_analysis_latest.json").read_text())
                    is_ny_session = (sa.get("current_regime") == "NEW_YORK")
                except:
                    pass
                
                ny_score_threshold = 0.5 if is_ny_session else 1.0

                current_time = time.time()
                if _prev_macro_score is None:
                    _macro_trigger = True  # 冷启动
                    _last_macro_trigger_time = current_time
                else:
                    score_change = abs(curr_s - _prev_macro_score)
                    if score_change >= 1.0 and (current_time - _last_macro_trigger_time > 1800):
                        # 层级1：score剧变（正向或反向），立即触发，打破59分钟延迟，但增加30分钟防抖
                        _macro_trigger = True
                        _last_macro_trigger_time = current_time
                        _daemon_log(f"[M2快速通道] score变化{score_change:.1f}（≥1.0），且满足30分钟防抖冷却，立即触发M2")
                    elif score_change >= ny_score_threshold and (now.minute < 5 or is_ny_session) and last_macro_run_hour != now.hour:
                        # 层级2/2b：常规整点触发 或 NY高波动时段随时触发
                        if is_ny_session:
                            _daemon_log(f"[M2·NY强化] NY高波动时段，score变化{score_change:.1f}（≥{ny_score_threshold}），降低门槛触发M2")
                        _macro_trigger = True
                        _last_macro_trigger_time = current_time

                if _macro_trigger:
                    _prev_macro_score = curr_s
                    _daemon_log("Triggering Macro Strategic Agent (DeepSeek) for hourly update...")
                    macro_path = ROOT / "src" / "deepseek_agent.py"
                    if macro_path.exists() and _spawn_macro_direction_process(
                        force_refresh=False,
                        reason=f"SCHEDULED_MACRO_RUN:{now.hour:02d}",
                    ):
                        last_macro_run_hour = now.hour
                        _daemon_log("Macro Strategic Agent dispatched.")

                # 1. 早上 8:00 到 8:05 之间执行早报
                if now.hour == 8 and 0 <= now.minute < 5 and last_run_date_briefing != now.date():
                    _daemon_log("Queueing morning briefing...")
                    script_path = ROOT / "bridge" / "morning_briefing.py"
                    enqueue_openclaw_task(
                        [sys.executable, "--run-briefing"] if getattr(sys, "frozen", False) else [sys.executable, str(script_path)],
                        label="morning_briefing",
                        timeout=120,
                    )
                    last_run_date_briefing = now.date()
                    _daemon_log("Morning briefing queued.")

                # 2. 凌晨 3:00 到 3:05 之间执行系统进化分析 (Agent Evolution) - 每周六执行一次 (now.weekday() == 5)
                if now.weekday() == 5 and now.hour == 3 and 0 <= now.minute < 5 and last_run_date_evolution != now.date():
                    _daemon_log("Queueing Agent Strategy Evolution...")
                    evo_path = ROOT / "src" / "strategy_evolution.py"
                    if evo_path.exists():
                        enqueue_openclaw_task(
                            [sys.executable, "--run-evo"] if getattr(sys, "frozen", False) else [sys.executable, str(evo_path)],
                            label="strategy_evolution",
                            timeout=300,
                        )
                        last_run_date_evolution = now.date()
                        _daemon_log("Agent Evolution queued.")

                # 3. 防守矩阵日报：每天 00:00~00:05 生成并推送到 WebChat
                if now.hour == 0 and 0 <= now.minute < 5 and last_run_date_defense != now.date():
                    s = load_state()
                    if _emit_defense_matrix_daily_report(s, report_date=now.strftime("%Y-%m-%d")):
                        last_run_date_defense = now.date()

                # 4. 账本日志清理：每4小时修剪一次 ndjson 文件防止无限增长
                if now.hour % 4 == 0 and now.minute < 1 and _last_ledger_cleanup_hour != now.hour:
                    _trim_stale_ledgers()
                    _last_ledger_cleanup_hour = now.hour

            except Exception as e:
                _daemon_log(f"Daily task error: {e}")
            time.sleep(60)

    daily_t = threading.Thread(target=daily_task_thread, daemon=True)
    daily_t.start()

    while not KILL_FLAG.is_set():
        try:
            state = load_state()
            # ── Phase 2 (2026-05-15): pipeline owns exit orchestration ──
            # When --monitor-only is active, gold_trader is a pure bridge
            # adapter.  Pipeline drives both signal generation (no position)
            # AND exit monitoring (has position).  gold_trader only does
            # lightweight EA report processing + reconciliation as a
            # secondary health check.
            if MONITOR_ONLY:
                state = process_ea_reports(state)
                state = reconcile_positions(state)
                time.sleep(2)
            elif state.get("position_open"):
                monitor_position_loop()
            else:
                signal_poll_loop()
        except Exception as e:
            import traceback
            _daemon_log(f"[FATAL] daemon loop crashed: {e}\n{traceback.format_exc()}")
        time.sleep(1)

# ============ Unix Daemon ============
def become_daemon():
    # 双保险单实例：flock 锁 + PID 文件
    # flock 由操作系统内核管理，crash 后自动释放
    # LOCK_NB = 非阻塞，失败则说明已有实例
    try:
        _lock_fd = os.open(str(GLOBAL_LOCK_FILE), os.O_CREAT | os.O_RDWR)
        fcntl.flock(_lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except (IOError, OSError):
        # 另一个实例正在运行，尝试清理其僵死进程后重试
        if GLOBAL_PID_FILE.exists():
            try:
                old = int(GLOBAL_PID_FILE.read_text().strip())
                os.kill(old, signal.SIGTERM)
                time.sleep(1)
                try: os.kill(old, 0); os.kill(old, signal.SIGKILL)
                except OSError: pass
            except (ValueError, OSError): pass
        # 再试一次
        try:
            _lock_fd = os.open(str(GLOBAL_LOCK_FILE), os.O_CREAT | os.O_RDWR)
            fcntl.flock(_lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except (IOError, OSError):
            print("[FATAL] Cannot acquire lock — another daemon is running. Aborting.")
            sys.exit(1)

    atexit.register(lambda: (fcntl.flock(_lock_fd, fcntl.LOCK_UN), os.close(_lock_fd), pathlib.Path(GLOBAL_LOCK_FILE).unlink(missing_ok=True)))
    atexit.register(lambda: pathlib.Path(GLOBAL_PID_FILE).unlink(missing_ok=True))
    atexit.register(lambda: pathlib.Path(LOCK_FILE).unlink(missing_ok=True))
    atexit.register(lambda: pathlib.Path(PID_FILE).unlink(missing_ok=True))

    # 重定向输出到日志
    sys.stdout.flush(); sys.stderr.flush()
    try:
        with open("/dev/null","r") as dn: os.dup2(dn.fileno(), sys.stdin.fileno())
        with open(DAEMON_LOG,"a") as lf:
            os.dup2(lf.fileno(), sys.stdout.fileno())
            os.dup2(lf.fileno(), sys.stderr.fileno())
    except: pass

    PID_FILE.write_text(str(os.getpid()))
    GLOBAL_PID_FILE.write_text(str(os.getpid()))
    _daemon_log(f"PID={os.getpid()} [lock acquired]")
    return os.getpid()

def start_daemon():
    BRIDGE_TO_EA.mkdir(parents=True, exist_ok=True)
    BRIDGE_FROM_EA.mkdir(parents=True, exist_ok=True)
    cmd = [sys.executable]
    if getattr(sys, "frozen", False):
        cmd.append("--fg")
    else:
        cmd.extend([str(pathlib.Path(__file__).resolve()), "--fg"])
    env = os.environ.copy()
    env.setdefault("PYTHONUNBUFFERED", "1")
    DAEMON_LOG.parent.mkdir(parents=True, exist_ok=True)
    with open(DAEMON_LOG, "a", encoding="utf-8") as lf:
        proc = subprocess.Popen(
            cmd,
            cwd=str(pathlib.Path(__file__).resolve().parent),
            stdin=subprocess.DEVNULL,
            stdout=lf,
            stderr=lf,
            start_new_session=True,
            close_fds=True,
            env=env,
        )
    print(f"[daemon] started PID {proc.pid}")
    return proc.pid

# ============ CLI ============
if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--daemon", action="store_true")
    ap.add_argument("--fg", action="store_true", help="前台模式（供 launchd 使用，不 fork）")
    ap.add_argument("--monitor-only", action="store_true", help="仅做持仓监控/移动止损，不参与开仓轮询")
    # Backward-compatibility: several legacy watchdog scripts still append
    # `--interval 120` when starting the daemon. The current daemon loop uses
    # its own internal cadence, so keep accepting the flag as a no-op instead
    # of crashing at process startup and breaking the execution chain.
    ap.add_argument("--interval", type=int, default=120, help="Legacy compatibility flag; accepted but ignored")
    ap.add_argument("--once", action="store_true")
    ap.add_argument("--status", action="store_true")
    ap.add_argument("--kill", action="store_true")
    ap.add_argument("--review", action="store_true")
    ap.add_argument("--approve", type=str)
    ap.add_argument("--reject", type=str)
    ap.add_argument("--direction", type=str, help="设置Agent大方向: LONG/SHORT/NEUTRAL")
    ap.add_argument("--set-direction", nargs=3, metavar=("DIRECTION","REASON","CONFIDENCE"),
                   help="设置当天方向: python gold_trader.py --set-direction LONG 欧洲局势紧张 0.85")
    args = ap.parse_args()
    MONITOR_ONLY = bool(args.monitor_only)

    REVIEW_DIR.mkdir(parents=True, exist_ok=True)
    AGENT_DIR.mkdir(parents=True, exist_ok=True)

    if args.kill:
        killed = False
        for pf in [PID_FILE, GLOBAL_PID_FILE]:
            if pf.exists():
                try:
                    pid = int(pf.read_text().strip())
                    os.kill(pid, signal.SIGTERM)
                    print(f"Killed {pid}")
                    killed = True
                except: pass
        if not killed:
            print("Daemon: no PID file found")
        print("Daemon stopped"); sys.exit(0)

    if args.status:
        # Check both PID_FILE (local) and GLOBAL_PID_FILE (LobsterQuant launchd)
        daemon_pid = None
        pid_source = None
        for pf, src in [(PID_FILE, "local"), (GLOBAL_PID_FILE, "global")]:
            if pf.exists():
                try:
                    pid = int(pf.read_text().strip())
                    os.kill(pid, 0)
                    daemon_pid = pid
                    pid_source = src
                    break
                except: pass
        if daemon_pid:
            print(f"Daemon: RUNNING (PID {daemon_pid}) [{pid_source}]")
        else:
            print("Daemon: NOT RUNNING")
        state = load_state()
        bid, ask, spr, price_source, price_age = get_price()
        agent_dir, _ = get_agent_direction()
        print(f"MT5: {'RUNNING' if is_mt5_running() else 'STOPPED'}")
        if bid: print(f"XAUUSD: {bid}/{ask} spread={spr}")
        print(f"Agent Direction: {agent_dir}")
        print(f"Position: {'OPEN '+state.get('position_side','') if state.get('position_open') else 'None'}")
        if state.get("position_open"): print(f"  Entry:{state.get('position_entry')} SL:{state.get('position_sl')} TP:{state.get('position_tp')}")
        print(f"Trades:{state.get('total_trades',0)} Losses:{state.get('consecutive_losses',0)}/4 Kill:{state.get('kill_switch',False)}")
        print(f"Review pending: {len(list(REVIEW_DIR.glob('*.review.json')))}")
        sys.exit(0)

    if args.set_direction:
        dir_arg, reason_arg, conf_arg = args.set_direction
        save_agent_direction(dir_arg, reason_arg, float(conf_arg))
        print(f"✅ Agent方向已设置: {dir_arg} | 原因: {reason_arg} | 置信度: {conf_arg}")
        sys.exit(0)

    if args.direction:
        save_agent_direction(args.direction, "manual", 0.80)
        print(f"✅ Agent方向设为: {args.direction}"); sys.exit(0)

    if args.review:
        pending = sorted(REVIEW_DIR.glob("*.review.json"), key=lambda f: f.stat().st_mtime, reverse=True)
        if not pending: print("✅ 无待复核信号")
        else:
            print(f"=== 待复核 ({len(pending)}个) ===")
            for f in pending[:10]:
                d = json.loads(f.read_text())
                print(f"  [{d.get('signal_id')}] {d.get('action')} {d.get('side')} conf={round((d.get('confidence') or 0)*100,1)}% regime={d.get('regime')}")
        sys.exit(0)

    if args.approve:
        sig_id = args.approve
        (REVIEW_DIR/"approve").mkdir(exist_ok=True)
        src = REVIEW_DIR/f"{sig_id}.review.json"
        if src.exists():
            (REVIEW_DIR/"approve"/f"{sig_id}.json").write_text(src.read_text())
            _log(f"Agent APPROVED: {sig_id}", "WARN"); print(f"✅ 已批准: {sig_id}")
        else: print(f"❌ 未找到: {sig_id}")
        sys.exit(0)

    if args.reject:
        sig_id = args.reject
        (REVIEW_DIR/"reject").mkdir(exist_ok=True)
        src = REVIEW_DIR/f"{sig_id}.review.json"
        if src.exists():
            (REVIEW_DIR/"reject"/f"{sig_id}.json").write_text(src.read_text())
            _log(f"Agent REJECTED: {sig_id}", "WARN"); print(f"❌ 已否决: {sig_id}")
        else: print(f"❌ 未找到: {sig_id}")
        sys.exit(0)

    if args.daemon:
        start_daemon()
        sys.exit(0)

    if args.fg:
        # 前台模式：launchd 直接监控此进程，不 fork
        signal.signal(signal.SIGTERM, _sigterm_handler)
        signal.signal(signal.SIGINT, _sigterm_handler)
        become_daemon()
        init_db()
        daemon_loop()

    if args.once:
        state = load_state(); bid, ask, _, _, _ = get_price()
        print(f"XAUUSD: {bid}/{ask} Position: {'OPEN' if state.get('position_open') else 'None'}")
        agent_dir, _ = get_agent_direction()
        print(f"Agent Direction: {agent_dir}")
