import os
import sys
import json
import math
import signal
import pathlib
import subprocess
import atexit
import fcntl
import psutil
import threading
from contextlib import contextmanager
def is_process_running(pid: int) -> bool:
    import psutil
    try:
        p = psutil.Process(pid)
        if not p.is_running() or p.status() == psutil.STATUS_ZOMBIE:
            return False
        cmdline = " ".join(p.cmdline()).lower()
        try:
            exe = str(p.exe()).lower()
        except Exception:
            exe = ""
        combined = f"{exe} {cmdline}"
        if (
            "--daemon" in combined
            or "gold_trader.py" in combined
            or "src/pipeline.py" in combined
            or "pipeline.py" in combined
            or "goldautotradeengine" in combined
            or "lobsterquant" in combined
        ):
            return True
        return False
    except psutil.NoSuchProcess:
        return False
    except Exception:
        # Fallback to os.kill if psutil fails for some reason
        try:
            import os
            os.kill(pid, 0)
            return True
        except OSError:
            return False

import datetime
import time
import logging
import psutil
import uuid
from contextlib import asynccontextmanager

from fastapi import FastAPI, BackgroundTasks, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from pydantic import BaseModel, Field
from typing import List, Dict, Any, Set, Optional, Union

# 禁用 Uvicorn 默认的访问日志输出,防止刷屏
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)

# 添加当前目录到 sys.path,以便能引用 bridge 和 config_manager
sys.path.insert(0, str(pathlib.Path(__file__).parent))

import config_manager
import updater as app_updater
from src.agent_decision_contract import (
    load_agent_decision_contract,
    update_decision_policy_contract,
)
from src.mt5_execution import export_history_via_ea
from src.position_lifecycle import load_position_lifecycle
from src.runtime_state.decision_snapshot import load_decision_snapshot
from src.runtime_state.training_review_state import (
    build_training_review_state,
    public_training_review_state,
)
from bridge.trader_api import (
    load_state,
    get_price,
    get_agent_direction,
    get_technical_trend_bias,
    is_mt5_running,
    DAEMON_LOG,
)
from src.openclaw_runtime import resolve_openclaw_config_path


@asynccontextmanager
async def _app_lifespan(_: FastAPI):
    _startup_supervisor()
    yield


app = FastAPI(lifespan=_app_lifespan)

# 允许跨域(本地开发和混合部署时都需要)
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# 获取动态路径
APP_DIR = config_manager.get_app_dir()
DATA_DIR = config_manager.get_data_dir()
LOGS_DIR = config_manager.get_logs_dir()
CONFIG_DIR = config_manager.get_config_dir()
REPO_ROOT = pathlib.Path(__file__).resolve().parent
RELEASE_ROOT = REPO_ROOT
_SRC_DIR = RELEASE_ROOT / "src" if (RELEASE_ROOT / "src").is_dir() else APP_DIR / "src"
_GUI_LOCK_HANDLE = None
_STRATEGY_STATE_LOCK = threading.RLock()


def _acquire_gui_singleton_lock(lock_dir: Optional[pathlib.Path] = None):
    global _GUI_LOCK_HANDLE
    target_dir = pathlib.Path(lock_dir) if lock_dir is not None else LOGS_DIR
    target_dir.mkdir(parents=True, exist_ok=True)
    lock_path = target_dir / "gui_backend.lock"
    handle = lock_path.open("a+", encoding="utf-8")
    try:
        fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
    except BlockingIOError:
        handle.seek(0)
        owner = handle.read().strip()
        handle.close()
        owner_text = owner or "unknown_owner"
        raise SystemExit(f"gui singleton lock already held: {owner_text}")
    handle.seek(0)
    handle.truncate()
    handle.write(f"pid={os.getpid()} root={RELEASE_ROOT}\n")
    handle.flush()
    _GUI_LOCK_HANDLE = handle
    return handle


def _release_gui_singleton_lock() -> None:
    global _GUI_LOCK_HANDLE
    handle = _GUI_LOCK_HANDLE
    _GUI_LOCK_HANDLE = None
    if handle is None:
        return
    try:
        fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
    except Exception:
        pass
    try:
        handle.close()
    except Exception:
        pass


def _write_json_atomic(path: pathlib.Path, payload: dict[str, Any]) -> pathlib.Path:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp_path = path.with_name(f".{path.name}.{os.getpid()}.tmp")
    tmp_path.write_text(
        json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    os.replace(tmp_path, path)
    return path


def _append_ndjson_line(path: pathlib.Path, payload: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("a", encoding="utf-8") as handle:
        handle.write(json.dumps(payload, ensure_ascii=False) + "\n")


@contextmanager
def _strategy_market_state_transaction(shared: bool = False):
    lock_path = CONFIG_DIR / "strategy_market_state.lock"
    lock_path.parent.mkdir(parents=True, exist_ok=True)
    with _STRATEGY_STATE_LOCK:
        handle = lock_path.open("a+", encoding="utf-8")
        lock_type = fcntl.LOCK_SH if shared else fcntl.LOCK_EX
        try:
            fcntl.flock(handle.fileno(), lock_type)
            yield
        finally:
            try:
                fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
            except Exception:
                pass
            handle.close()


class UpdateRequest(BaseModel):
    manifest: Optional[str] = None
    force: bool = False


class UpdateConfigRequest(BaseModel):
    update_manifest_url: str = ""


# ── 策略定制模块 Models ────────────────────────────────────────────────

class StrategyParamUpdate(BaseModel):
    key: str
    value: Any


class StrategyParamsBatchUpdate(BaseModel):
    strategy_id: str
    params: List[StrategyParamUpdate]
    change_note: str = ""
    bypass_paper_mode: bool = False
    bypass_runtime_safety: bool = False


class StrategyCloneRequest(BaseModel):
    strategy_id_from: str
    new_name: str
    change_note: str = ""


class StrategyToggleRequest(BaseModel):
    strategy_id: str
    enabled: bool


class StrategyDeleteRequest(BaseModel):
    strategy_id: str


class StrategyPresetSave(BaseModel):
    name: str
    description: str = ""
    strategy_id: str
    params_snapshot: Optional[Dict[str, Any]] = None


# ── 策略定制缓存 ────────────────────────────────────────────────────

_STRATEGY_CUSTOM_STATE_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None}
_STRATEGY_CUSTOM_STATE_CACHE_TTL_SEC = 2.0

_PARAMS_SCHEMA_CACHE: Optional[List[Dict[str, Any]]] = None


def _load_params_schema() -> list:
    global _PARAMS_SCHEMA_CACHE
    if _PARAMS_SCHEMA_CACHE is None:
        try:
            from src.strategy_params_schema import (
                export_schema_for_frontend,
                get_params_by_group,
                get_all_groups_sorted,
                validate_params_batch,
                compute_params_summary,
                get_param_def,
            )
            _PARAMS_SCHEMA_CACHE = export_schema_for_frontend()
        except Exception:
            _PARAMS_SCHEMA_CACHE = []
    return _PARAMS_SCHEMA_CACHE


def _load_strategy_market_state() -> dict:
    path = _resolve_runtime_artifact("config", "strategy_market_state.json") or (CONFIG_DIR / "strategy_market_state.json")
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return {"strategies": {}, "runtime_instances": [], "decision_policy": {}}


def _save_strategy_market_state(state: dict) -> None:
    path = CONFIG_DIR / "strategy_market_state.json"
    state["updated_at"] = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
    _write_json_atomic(path, state)


def _load_strategy_custom_state() -> dict:
    path = CONFIG_DIR / "strategy_custom_state.json"
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return {"draft": {}, "generatedHistory": []}


def _save_strategy_custom_state(state: dict) -> None:
    path = CONFIG_DIR / "strategy_custom_state.json"
    _write_json_atomic(path, state)


def _load_strategy_rules_v3() -> dict:
    path = CONFIG_DIR / "strategy_rules_v3.json"
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return {}


def _save_strategy_rules_v3(rules: dict) -> None:
    path = CONFIG_DIR / "strategy_rules_v3.json"
    rules["last_updated"] = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
    _write_json_atomic(path, rules)


def _default_strategy_template(strategy_id: str) -> dict:
    return {
        "timeframe": "M5",
        "risk_multiplier": 1.0,
        "sl_multiplier": 1.0,
        "tp_multiplier": 1.0,
        "strategy_id": strategy_id,
    }


def _resolve_strategy_params(strategy_id: str) -> dict:
    """将当前规则文件展平为 key->value 字典，供前端展示编辑。"""
    rules = _load_strategy_rules_v3()
    state = _load_strategy_market_state()
    strat = state.get("strategies", {}).get(strategy_id, {})
    template = strat.get("template", {}) if isinstance(strat, dict) else {}

    flat: Dict[str, Any] = {}

    def _flatten(prefix: str, obj: Any) -> None:
        if isinstance(obj, dict) and not any(
            isinstance(v, (dict, list)) for v in obj.values()
        ):
            for k, v in obj.items():
                flat[f"{prefix}.{k}"] = v
        elif isinstance(obj, dict):
            for k, v in obj.items():
                _flatten(f"{prefix}.{k}", v)
        else:
            flat[prefix] = obj

    _flatten("trend_vote_model", rules.get("trend_vote_model", {}))
    _flatten("entry_rules", rules.get("entry_rules", {}))
    _flatten("entry_direction_filter", rules.get("entry_direction_filter", {}))
    _flatten("exit_strategy", rules.get("exit_strategy", {}))
    _flatten("risk_governor", rules.get("risk_governor", {}))
    _flatten("position_sizing", rules.get("position_sizing", {}))

    # Decision policy from market state
    dp = state.get("decision_policy", {}) if isinstance(state, dict) else {}
    _flatten("decision_policy", dp)

    # Strategy template params
    for k, v in template.items():
        flat[f"strategy.{k}"] = v

    return flat


def _apply_param_update(base: dict, dotted_key: str, value: Any) -> dict:
    """将 dotted key 写回到嵌套 dict 中。返回修改后的 dict（原地修改）。"""
    parts = dotted_key.split(".")
    current = base
    for i, part in enumerate(parts[:-1]):
        if part.isdigit():
            part_idx = int(part)
            if not isinstance(current, list):
                current = []
            while len(current) <= part_idx:
                current.append({})
            current = current[part_idx]
        else:
            if part not in current or not isinstance(current[part], (dict, list)):
                current[part] = {}
            current = current[part]
    last = parts[-1]
    if last.isdigit():
        last_idx = int(last)
        if not isinstance(current, list):
            current = []
        while len(current) <= last_idx:
            current.append(None)
        current[last_idx] = value
    else:
        current[last] = value
    return base


def _apply_params_to_rules(rules: dict, state: dict, params: Dict[str, Any]) -> tuple[dict, dict]:
    """将展平参数写回到 rules v3 和 state 的正确位置。"""
    for key, value in params.items():
        if key.startswith("decision_policy."):
            dp_key = key[len("decision_policy."):]
            dp = state.setdefault("decision_policy", {})
            _apply_param_update(dp, dp_key, value)
        elif key.startswith("strategy."):
            pass  # strategy template handled separately
        else:
            _apply_param_update(rules, key, value)
    return rules, state


# ── 策略定制 API ────────────────────────────────────────────────────

@app.get("/api/strategy/params-schema")
def get_params_schema():
    """返回所有可调参数的 schema 元数据 + 分组信息。"""
    try:
        from src.strategy_params_schema import (
            export_schema_for_frontend,
            get_params_by_group,
            get_all_groups_sorted,
        )
        schema = export_schema_for_frontend()
        grouped = {}
        for group_meta in get_all_groups_sorted():
            gk = group_meta["key"]
            grouped[gk] = {
                **group_meta,
                "params": [p for p in schema if p["group"] == gk],
            }
        return {
            "success": True,
            "schema": schema,
            "groups": grouped,
        }
    except Exception as e:
        return {"success": False, "error": str(e)}


@app.get("/api/strategy/params/{strategy_id}")
def get_strategy_params(strategy_id: str):
    """获取指定策略的所有当前参数值（已展平）。"""
    try:
        flat = _resolve_strategy_params(strategy_id)
        if not flat:
            return {"success": False, "error": f"未知策略: {strategy_id}"}
        return {"success": True, "strategy_id": strategy_id, "params": flat}
    except Exception as e:
        return {"success": False, "error": str(e)}


@app.post("/api/strategy/params/validate")
def validate_strategy_params(req: StrategyParamsBatchUpdate):
    """校验参数值是否合法。不保存，只返回校验结果。"""
    try:
        from src.strategy_params_schema import validate_params_batch, compute_params_summary

        params_dict = {p.key: p.value for p in req.params}
        errors = validate_params_batch(params_dict)
        summary = compute_params_summary(params_dict)
        return {
            "success": len(errors) == 0,
            "errors": errors,
            "summary": summary,
            "param_count": len(params_dict),
        }
    except Exception as e:
        return {"success": False, "error": str(e)}


@app.post("/api/strategy/params/apply")
def apply_strategy_params(req: StrategyParamsBatchUpdate):
    """应用参数更新。自动通过 validation 和 paper mode 闸门。"""
    try:
        from src.strategy_params_schema import validate_params_batch, compute_params_summary, get_param_def as _get_param_def

        params_dict = {p.key: p.value for p in req.params}
        errors = validate_params_batch(params_dict)
        if errors:
            return {
                "success": False,
                "error": "参数校验未通过",
                "validation_errors": errors,
            }

        summary = compute_params_summary(params_dict)

        # Paper mode gate: critical/high-risk params require paper mode first
        if summary["requires_paper_mode"] and not req.bypass_paper_mode:
            return {
                "success": False,
                "error": "修改包含关键风控参数，必须先进入 Paper Mode 运行 3 天验证",
                "requires_paper_mode": True,
                "paper_mode_required_for": [
                    p.key for p in req.params
                    if _get_param_def(p.key) and _get_param_def(p.key).requires_paper_mode
                ],
                "summary": summary,
            }

        # Runtime safety gate (P1): block cold params when engine is busy / positions open
        if not req.bypass_runtime_safety:
            from src.strategy_params_schema import runtime_safety_check as _safety_check
            es = _runtime_execution_state_payload()
            engine_busy = bool(es.get("isTrading", False) or es.get("bridgeActive", False))
            has_positions = bool(es.get("positionOpen", False) or
                                 (isinstance(es.get("positions"), list) and len(es.get("positions", [])) > 0))
            safety = _safety_check(params_dict, engine_busy=engine_busy, has_positions=has_positions)
            if safety.get("block"):
                return {
                    "success": False,
                    "error": "参数运行时安全锁已激活",
                    "runtime_safety": {
                        "blocked": safety["block"],
                        "warnings": safety.get("warn", []),
                        "engine_busy": engine_busy,
                        "has_positions": has_positions,
                    },
                }

        rules = _load_strategy_rules_v3()
        state = _load_strategy_market_state()
        rules, state = _apply_params_to_rules(rules, state, params_dict)

        # Version the strategy
        strat = state.get("strategies", {}).get(req.strategy_id)
        if isinstance(strat, dict):
            versions = strat.get("versions", [])
            if not isinstance(versions, list):
                versions = []
            cur_ver = str(strat.get("current_version", "1.0.0"))
            try:
                parts = cur_ver.split(".")
                new_ver = f"{parts[0]}.{parts[1]}.{int(parts[2]) + 1}"
            except Exception:
                new_ver = "1.0.1"
            now_iso = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
            versions.append({
                "version": new_ver,
                "note": req.change_note or "manual_params_update",
                "published_at": now_iso,
                "params_changed": list(params_dict.keys()),
                "summary": summary,
            })
            strat["current_version"] = new_ver
            strat["versions"] = versions

        _save_strategy_rules_v3(rules)
        _save_strategy_market_state(state)

        return {
            "success": True,
            "message": f"策略参数已更新 ({len(params_dict)} 项)",
            "strategy_id": req.strategy_id,
            "new_version": (
                state.get("strategies", {}).get(req.strategy_id, {}).get("current_version", "")
            ),
            "summary": summary,
        }
    except Exception as e:
        return {"success": False, "error": str(e)}


@app.post("/api/strategy/clone")
def clone_strategy(req: StrategyCloneRequest):
    """克隆已有策略为新策略，保留所有参数配置。"""
    try:
        state = _load_strategy_market_state()
        src = state.get("strategies", {}).get(req.strategy_id_from)
        if not isinstance(src, dict):
            return {"success": False, "error": f"源策略不存在: {req.strategy_id_from}"}

        slug = "".join(ch.lower() if ch.isalnum() else "_" for ch in req.new_name).strip("_")[:28]
        new_id = f"clone_{slug}_{int(time.time())}"
        now_iso = datetime.datetime.now().astimezone().isoformat(timespec="seconds")

        import copy
        new_strat = copy.deepcopy(src)
        new_strat["display_name"] = req.new_name
        new_strat["is_custom"] = True
        new_strat["cloned_from"] = req.strategy_id_from
        new_strat["versions"] = [{
            "version": "1.0.0",
            "note": f"cloned from {req.strategy_id_from}",
            "published_at": now_iso,
        }]
        new_strat["current_version"] = "1.0.0"
        new_strat["created_at"] = now_iso
        # 更新 template 中的 strategy_id
        if isinstance(new_strat.get("template"), dict):
            new_strat["template"]["strategy_id"] = new_id

        state["strategies"][new_id] = new_strat
        _save_strategy_market_state(state)
        return {
            "success": True,
            "message": f"策略已克隆: {req.new_name}",
            "strategy_id": new_id,
            "version": "1.0.0",
        }
    except Exception as e:
        return {"success": False, "error": str(e)}


@app.post("/api/strategy/toggle")
def toggle_strategy(req: StrategyToggleRequest):
    """启用/禁用策略。"""
    state = _load_strategy_market_state()
    strat = state.get("strategies", {}).get(req.strategy_id)
    if not isinstance(strat, dict):
        return {"success": False, "error": f"策略不存在: {req.strategy_id}"}
    strat["enabled"] = bool(req.enabled)
    _save_strategy_market_state(state)
    return {
        "success": True,
        "strategy_id": req.strategy_id,
        "enabled": req.enabled,
    }


@app.post("/api/strategy/delete")
def delete_strategy(req: StrategyDeleteRequest):
    """删除自定义策略（内置策略不可删除）。"""
    state = _load_strategy_market_state()
    strat = state.get("strategies", {}).get(req.strategy_id)
    if not isinstance(strat, dict):
        return {"success": False, "error": f"策略不存在: {req.strategy_id}"}
    if strat.get("default_mode") == "builtin" and not strat.get("is_custom"):
        return {"success": False, "error": "内置策略不可删除"}
    del state["strategies"][req.strategy_id]
    # 清理关联的 runtime instances
    state["runtime_instances"] = [
        inst for inst in state.get("runtime_instances", [])
        if str(inst.get("strategy_id", "")) != req.strategy_id
    ]
    _save_strategy_market_state(state)
    return {
        "success": True,
        "message": f"策略已删除: {req.strategy_id}",
    }


@app.get("/api/strategy/history/{strategy_id}")
def get_strategy_version_history(strategy_id: str):
    """获取策略的版本历史与变更记录。"""
    state = _load_strategy_market_state()
    strat = state.get("strategies", {}).get(strategy_id)
    if not isinstance(strat, dict):
        return {"success": False, "error": f"未知策略: {strategy_id}"}
    return {
        "success": True,
        "strategy_id": strategy_id,
        "current_version": strat.get("current_version", ""),
        "versions": strat.get("versions", []),
    }


@app.get("/api/strategy/compare")
def compare_strategy_params(strategy_id_a: str = "", strategy_id_b: str = ""):
    """比较两个策略的参数差异。"""
    if not strategy_id_a or not strategy_id_b:
        return {"success": False, "error": "需要提供两个策略 ID"}
    params_a = _resolve_strategy_params(strategy_id_a)
    params_b = _resolve_strategy_params(strategy_id_b)
    all_keys = set(params_a.keys()) | set(params_b.keys())
    diff = {}
    for key in sorted(all_keys):
        va = params_a.get(key)
        vb = params_b.get(key)
        if va != vb:
            diff[key] = {"a": va, "b": vb}
    return {
        "success": True,
        "strategy_a": strategy_id_a,
        "strategy_b": strategy_id_b,
        "diff_count": len(diff),
        "diff": diff,
    }


@app.get("/api/strategy/risk-budget")
def get_risk_budget():
    """获取当前策略风险预算分配与投票权重。"""
    try:
        from src.strategy_intelligence import get_intelligence_hub
        from pathlib import Path

        hub = get_intelligence_hub()
        state = _load_strategy_market_state()
        instances = state.get("runtime_instances", [])
        if not isinstance(instances, list):
            instances = []

        project_root = Path(APP_DIR)
        health_scores = hub.refresh_all(project_root, instances)
        voting_weights = hub.compute_voting_weights(health_scores)
        budget = hub.allocate_risk_budget(health_scores, total_budget_lots=0.20)

        return {
            "success": True,
            "health_scores": {k: v.to_dict() for k, v in health_scores.items()},
            "voting_weights": voting_weights,
            "risk_budget": budget,
        }
    except Exception as e:
        return {"success": False, "error": str(e)}


@app.get("/api/strategy/presets")
def list_strategy_presets():
    """列出所有已保存的策略预设模板。"""
    presets_dir = APP_DIR / "presets"
    if not presets_dir.exists():
        return {"success": True, "presets": []}
    presets = []
    for f in sorted(presets_dir.glob("*.json")):
        try:
            data = json.loads(f.read_text(encoding="utf-8"))
            presets.append({
                "id": f.stem,
                "name": data.get("name", f.stem),
                "description": data.get("description", ""),
                "strategy_id": data.get("strategy_id", ""),
                "saved_at": data.get("saved_at", ""),
            })
        except Exception:
            pass
    return {"success": True, "presets": presets}


@app.post("/api/strategy/presets/save")
def save_strategy_preset(req: StrategyPresetSave):
    """保存当前参数配置为预设模板。"""
    presets_dir = APP_DIR / "presets"
    presets_dir.mkdir(parents=True, exist_ok=True)
    params = req.params_snapshot or _resolve_strategy_params(req.strategy_id)
    payload = {
        "name": req.name,
        "description": req.description,
        "strategy_id": req.strategy_id,
        "params": params,
        "saved_at": datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
    }
    slug = "".join(ch.lower() if ch.isalnum() else "_" for ch in req.name)[:40]
    path = presets_dir / f"{slug}.json"
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
    return {"success": True, "message": f"预设已保存: {req.name}", "preset_id": slug}


@app.post("/api/strategy/presets/apply")
def apply_strategy_preset(preset_id: str = "", target_strategy_id: str = ""):
    """将预设模板应用到指定策略。"""
    if not preset_id or not target_strategy_id:
        return {"success": False, "error": "需要 preset_id 和 target_strategy_id"}
    path = APP_DIR / "presets" / f"{preset_id}.json"
    if not path.exists():
        return {"success": False, "error": f"预设不存在: {preset_id}"}
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
        params = data.get("params", {})
        if not isinstance(params, dict) or not params:
            return {"success": False, "error": "预设中无有效参数"}
    except Exception as e:
        return {"success": False, "error": f"读取预设失败: {e}"}

    rules = _load_strategy_rules_v3()
    state = _load_strategy_market_state()
    rules, state = _apply_params_to_rules(rules, state, params)
    _save_strategy_rules_v3(rules)
    _save_strategy_market_state(state)

    return {
        "success": True,
        "message": f"预设 {preset_id} 已应用到 {target_strategy_id}",
        "params_applied": len(params),
    }


_UPDATE_STATUS_CACHE: Dict[str, Any] = {
    "ts": 0.0,
    "value": None,
    "refreshing": False,
}
_TIMELINE_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None}
_TIMELINE_CACHE_TTL_SECONDS = 2.0
_TRAINING_AUTOPILOT_THREAD: Optional[threading.Thread] = None
_TRAINING_AUTOPILOT_CONTEXT = threading.local()

# --- MT5 每日统计缓存 ---
# 为了避免每 5 秒前端轮询时都对 MT5 进行高强度的磁盘 IO 和历史导出，
# 我们将 MT5 的历史查询结果缓存在内存中。
# 更新机制：每 60 秒自动强制刷新一次；或者当检测到持仓数量发生变化（有单子平仓）时立即刷新。
_MT5_DAILY_CACHE = {
    "data": {
        "trades": 0, "pnl": 0.0, "wins": 0, "losses": 0, 
        "win_rate": 0.0, "avg_win": 0.0, "avg_loss": 0.0,
        "_ok": False, "_source": "init",
    },
    "updated_at": 0.0,
    "last_positions_count": -1  # 记录上一次查询时的持仓数量
}


def _system_messages_file() -> pathlib.Path:
    return LOGS_DIR / "system_messages.ndjson"


def _file_repair_events_file() -> pathlib.Path:
    return LOGS_DIR / "file_repair_events.ndjson"


def _system_message_candidates() -> list[pathlib.Path]:
    return _runtime_scope_candidates("logs", "system_messages.ndjson")


def _file_repair_event_candidates() -> list[pathlib.Path]:
    return _runtime_scope_candidates("logs", "file_repair_events.ndjson")


def _empty_trade_stats(*, ok: bool = False, source: str = "unknown") -> dict:
    return {
        "trades": 0,
        "pnl": 0.0,
        "wins": 0,
        "losses": 0,
        "win_rate": 0.0,
        "avg_win": 0.0,
        "avg_loss": 0.0,
        "_ok": ok,
        "_source": source,
    }


def _normalize_trade_stats(stats: Optional[dict], *, ok: bool = False, source: str = "unknown") -> dict:
    base = _empty_trade_stats(ok=ok, source=source)
    if not isinstance(stats, dict):
        return base
    normalized = dict(base)
    for key in ("trades", "wins", "losses"):
        try:
            normalized[key] = max(0, int(stats.get(key, normalized[key]) or 0))
        except Exception:
            normalized[key] = base[key]
    for key in ("pnl", "win_rate", "avg_win", "avg_loss"):
        try:
            normalized[key] = float(stats.get(key, normalized[key]) or 0.0)
        except Exception:
            normalized[key] = base[key]
    normalized["_ok"] = bool(stats.get("_ok", ok))
    normalized["_source"] = str(stats.get("_source", source) or source)
    return normalized


def _parse_trade_ledger_timestamp(value: Any) -> Optional[datetime.datetime]:
    text = str(value or "").strip()
    if not text:
        return None
    try:
        if text.endswith("Z"):
            text = text[:-1] + "+00:00"
        return datetime.datetime.fromisoformat(text)
    except Exception:
        return None


def _read_today_stats_from_trading_ledger(ledger_file: Optional[pathlib.Path]) -> dict:
    if ledger_file is None or not ledger_file.exists():
        return _empty_trade_stats(ok=False, source="trading_ledger_missing")

    stats = _empty_trade_stats(ok=True, source="trading_ledger")
    today = datetime.datetime.now(datetime.timezone.utc).astimezone().date()
    pnl_sum = 0.0
    win_pnl = 0.0
    loss_pnl = 0.0

    try:
        with ledger_file.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
                ts = _parse_trade_ledger_timestamp(row.get("ts"))
                if ts is not None and ts.astimezone().date() != today:
                    continue
                try:
                    pnl = float(row.get("pnl", 0.0) or 0.0)
                except Exception:
                    continue
                stats["trades"] += 1
                pnl_sum += pnl
                if pnl > 0:
                    stats["wins"] += 1
                    win_pnl += pnl
                elif pnl < 0:
                    stats["losses"] += 1
                    loss_pnl += pnl
    except Exception:
        return _empty_trade_stats(ok=False, source="trading_ledger_error")

    stats["pnl"] = round(pnl_sum, 2)
    if stats["trades"] > 0:
        stats["win_rate"] = round((stats["wins"] / stats["trades"]) * 100, 1)
    if stats["wins"] > 0:
        stats["avg_win"] = round(win_pnl / stats["wins"], 2)
    if stats["losses"] > 0:
        stats["avg_loss"] = round(loss_pnl / stats["losses"], 2)
    return stats

def _safe_load_state_worker() -> None:
    data: Dict[str, Any] = {}
    try:
        loaded = load_state()
        if isinstance(loaded, dict):
            data = loaded
    except Exception:
        data = {}
    with _STATE_LOAD_LOCK:
        _STATE_LOAD_CACHE["value"] = data
        _STATE_LOAD_CACHE["ts"] = time.time()
        _STATE_LOAD_CACHE["inflight"] = False
        _STATE_LOAD_CACHE["thread"] = None


def _safe_load_state(timeout_sec: float = 0.25, max_stale_sec: float = 3.0) -> Dict[str, Any]:
    """
    Avoid /api/status spawning unbounded worker threads when SQLite is contended.
    Keep one in-flight loader thread and return recent cached state immediately.
    """
    now = time.time()
    inflight_thread = None
    with _STATE_LOAD_LOCK:
        cached = _STATE_LOAD_CACHE.get("value", {})
        cached_ts = float(_STATE_LOAD_CACHE.get("ts", 0.0))
        if isinstance(cached, dict) and cached and (now - cached_ts) <= max_stale_sec:
            return dict(cached)
        inflight = bool(_STATE_LOAD_CACHE.get("inflight", False))
        current_thread = _STATE_LOAD_CACHE.get("thread")
        if isinstance(current_thread, threading.Thread):
            inflight_thread = current_thread
        if not inflight:
            t = threading.Thread(
                target=_safe_load_state_worker,
                daemon=True,
                name="gui-state-loader",
            )
            _STATE_LOAD_CACHE["inflight"] = True
            _STATE_LOAD_CACHE["thread"] = t
            inflight_thread = t
            t.start()
    if isinstance(inflight_thread, threading.Thread):
        inflight_thread.join(timeout=max(0.0, float(timeout_sec)))
    with _STATE_LOAD_LOCK:
        latest = _STATE_LOAD_CACHE.get("value", {})
        return dict(latest) if isinstance(latest, dict) else {}

def _resolve_terminal_status_file(mt5_dir: pathlib.Path) -> Optional[pathlib.Path]:
    """Resolve freshest terminal_status.json across EA legacy/new paths."""
    candidates = [
        mt5_dir / "gold_autotrade/bridge/from_ea/terminal_status.json",
        mt5_dir / "gold_autotrade/bridge/terminal_status.json",
    ]
    existing = [p for p in candidates if p.exists()]
    if not existing:
        return None
    return max(existing, key=lambda p: p.stat().st_mtime)


_POSITION_STATUS_ALERT_STATE: dict[str, Any] = {
    "fingerprint": "",
    "ts": 0.0,
}


def _position_status_alert_file() -> pathlib.Path:
    return LOGS_DIR / "position_status_alerts.ndjson"


def _normalize_terminal_status_position(raw: Any) -> Optional[dict[str, Any]]:
    if not isinstance(raw, dict):
        return None
    try:
        ticket = int(raw.get("ticket", 0) or 0)
    except Exception:
        ticket = 0
    try:
        volume = float(raw.get("volume", raw.get("vol", 0.0)) or 0.0)
    except Exception:
        volume = 0.0
    try:
        entry = float(raw.get("entry", raw.get("entry_price", 0.0)) or 0.0)
    except Exception:
        entry = 0.0
    try:
        pnl = float(raw.get("pnl", raw.get("profit", 0.0)) or 0.0)
    except Exception:
        pnl = 0.0
    side = str(raw.get("side", raw.get("direction", "")) or "").upper()
    if side not in {"BUY", "SELL"}:
        return None
    return {
        **raw,
        "ticket": ticket,
        "side": side,
        "volume": volume,
        "entry": entry,
        "sl": float(raw.get("sl", 0.0) or 0.0),
        "tp": float(raw.get("tp", 0.0) or 0.0),
        "pnl": pnl,
    }


def _extract_terminal_status_positions(payload: Any) -> list[dict[str, Any]]:
    if not isinstance(payload, dict):
        return []
    for key in ("positions_all", "positions"):
        rows = payload.get(key, [])
        if not isinstance(rows, list):
            continue
        normalized = [
            item
            for item in (_normalize_terminal_status_position(row) for row in rows)
            if item is not None
        ]
        if normalized or key in payload:
            return normalized
    return []


def _terminal_status_positions_count(payload: Any) -> Optional[int]:
    if not isinstance(payload, dict):
        return None
    positions = _extract_terminal_status_positions(payload)
    if positions:
        return len(positions)
    for key in ("positions_count", "count"):
        raw_value = payload.get(key)
        if raw_value is None:
            continue
        try:
            value = int(raw_value)
        except Exception:
            continue
        if value >= 0:
            return value
    if "positions_all" in payload or "positions" in payload:
        return 0
    return None


def _read_terminal_status_snapshot(
    mt5_dir: Optional[pathlib.Path] = None,
) -> tuple[Optional[pathlib.Path], Optional[dict[str, Any]]]:
    try:
        resolved_mt5_dir = mt5_dir or config_manager.get_mt5_dir()
        if not resolved_mt5_dir:
            return None, None
        sf = _resolve_terminal_status_file(resolved_mt5_dir)
        if not sf or not sf.exists():
            return sf, None
        if int(sf.stat().st_size) > 512 * 1024:
            return sf, None
        payload = json.loads(sf.read_text(encoding="utf-8"))
        if isinstance(payload, dict):
            return sf, payload
        return sf, None
    except Exception:
        return None, None


def _emit_position_status_alert(reason: str, **details: Any) -> None:
    fingerprint = json.dumps(
        {"reason": str(reason or ""), "details": details},
        ensure_ascii=False,
        sort_keys=True,
    )
    now = time.time()
    last_fingerprint = str(_POSITION_STATUS_ALERT_STATE.get("fingerprint", "") or "")
    last_ts = float(_POSITION_STATUS_ALERT_STATE.get("ts", 0.0) or 0.0)
    if fingerprint == last_fingerprint and (now - last_ts) < 30.0:
        return
    payload = {
        "ts": now,
        "reason": str(reason or "unknown_position_status_anomaly"),
        "details": details,
    }
    try:
        _append_ndjson_line(_position_status_alert_file(), payload)
    except Exception:
        pass
    logging.warning("position status anomaly: %s %s", payload["reason"], details)
    _POSITION_STATUS_ALERT_STATE["fingerprint"] = fingerprint
    _POSITION_STATUS_ALERT_STATE["ts"] = now


def _read_terminal_status_positions_count() -> Optional[int]:
    _, st_data = _read_terminal_status_snapshot()
    return _terminal_status_positions_count(st_data)


def _map_risk_block_reason(raw_reason: str) -> dict:
    reason = str(raw_reason or "").strip()
    if reason.startswith("CONTEXT_ARB_hermes_session_block"):
        return {
            "categoryCode": "SESSION_WINDOW",
            "categoryLabel": "会话时段约束",
            "summary": "欧美重叠时段被会话规则拦截",
        }
    if reason.startswith("CONTEXT_ARB_hermes_session_matrix_block"):
        return {
            "categoryCode": "SESSION_MATRIX",
            "categoryLabel": "时段矩阵风控",
            "summary": "命中时段分层矩阵的阻断条件",
        }
    if reason.startswith("CONTEXT_ARB_hermes_regime_block"):
        return {
            "categoryCode": "REGIME_GUARD",
            "categoryLabel": "行情结构约束",
            "summary": "当前结构与策略偏好不匹配",
        }
    if reason.startswith("PERF_GATE_session_rule_blocked"):
        return {
            "categoryCode": "PERFORMANCE_GATE",
            "categoryLabel": "性能门控",
            "summary": "命中性能门控规则，策略暂缓执行",
        }
    if reason.startswith("LOW_CONFIDENCE"):
        return {
            "categoryCode": "CONFIDENCE_GATE",
            "categoryLabel": "置信门槛",
            "summary": "置信度低于当前执行门槛",
        }
    if reason.startswith(("KILL_SWITCH", "LOSS_LIMIT", "DAILY_LOSS_LIMIT")):
        return {
            "categoryCode": "TERMINAL_RISK",
            "categoryLabel": "终局风控",
            "summary": "触发账户级终局风险保护",
        }
    if reason.startswith(("WIDE_SPREAD", "MAX_POSITIONS")):
        return {
            "categoryCode": "MARKET_GUARD",
            "categoryLabel": "市场保护",
            "summary": "点差或仓位限制触发保护",
        }
    if reason.startswith("DYNAMIC_COOLDOWN"):
        return {
            "categoryCode": "COOLDOWN",
            "categoryLabel": "动态冷却",
            "summary": "距离上次平仓过近，处于冷却期",
        }
    if reason.startswith(("BOLLINGER_", "RSI_")):
        return {
            "categoryCode": "TECH_FILTER",
            "categoryLabel": "技术过滤",
            "summary": "命中技术过滤条件",
        }
    return {
        "categoryCode": "UNKNOWN",
        "categoryLabel": "未分类",
        "summary": "未匹配到已知阻断类型",
    }


_NON_RISK_HOLD_MARKERS = (
    "AGGREGATED_HOLD_LOW_NET_SCORE",
    "AGGREGATED_HOLD_NO_ACTIONABLE_SIGNAL",
)


def _normalize_blocker_reasons(raw_items: Any) -> list[str]:
    if not isinstance(raw_items, list):
        return []
    normalized: list[str] = []
    seen: set[str] = set()
    for item in raw_items:
        reason = str(item or "").strip()
        if not reason:
            continue
        if any(marker in reason for marker in _NON_RISK_HOLD_MARKERS):
            continue
        if reason in seen:
            continue
        seen.add(reason)
        normalized.append(reason)
    return normalized


def _default_risk_block_payload() -> dict:
    return {
        "exists": False,
        "rawReason": "",
        "categoryCode": "NONE",
        "categoryLabel": "无阻断",
        "summary": "",
        "time": "",
        "line": "",
    }


def _read_tail_lines(path: pathlib.Path, max_bytes: int = 128 * 1024, max_lines: int = 3000) -> list[str]:
    """
    Read only the tail of a log file.
    This avoids loading large log files into memory on every /api/status poll.
    """
    try:
        with path.open("rb") as f:
            f.seek(0, os.SEEK_END)
            size = f.tell()
            read_size = min(max_bytes, size)
            f.seek(max(0, size - read_size), os.SEEK_SET)
            chunk = f.read(read_size)
    except Exception:
        return []
    text = chunk.decode("utf-8", errors="ignore")
    lines = text.splitlines()
    if len(lines) > max_lines:
        lines = lines[-max_lines:]
    return lines


def _is_readable_file(path: pathlib.Path) -> bool:
    try:
        return path.exists() and path.is_file() and os.access(path, os.R_OK)
    except Exception:
        return False


def _is_readable_dir(path: pathlib.Path) -> bool:
    try:
        return path.exists() and path.is_dir() and os.access(path, os.R_OK | os.X_OK)
    except Exception:
        return False


def _latest_mtime_in_dir(path: pathlib.Path) -> float:
    latest = 0.0
    try:
        for candidate in path.glob("*.jsonl"):
            latest = max(latest, candidate.stat().st_mtime)
    except Exception:
        return 0.0
    return latest


def _parse_hms_seconds(value: str) -> Optional[int]:
    try:
        hh, mm, ss = str(value).split(":")
        return int(hh) * 3600 + int(mm) * 60 + int(ss)
    except Exception:
        return None


def _entry_sort_key(item: dict) -> tuple[float, str]:
    ts = item.get("ts")
    if isinstance(ts, (int, float)):
        return (float(ts), str(item.get("event_id", "")))
    raw_timestamp = str(item.get("timestamp", "")).strip()
    if raw_timestamp:
        try:
            return (
                datetime.datetime.fromisoformat(raw_timestamp.replace("Z", "+00:00")).timestamp(),
                str(item.get("event_id", "")),
            )
        except Exception:
            pass
    inferred = item.get("_sort_ts")
    if isinstance(inferred, datetime.datetime):
        return (inferred.timestamp(), str(item.get("event_id", "")))
    return (float("-inf"), str(item.get("time", "")))


def _load_swarm_entries_with_inferred_ts(log_file: pathlib.Path, max_lines: int) -> list[dict]:
    entries: list[dict] = []
    try:
        raw_lines = _read_tail_lines(log_file, max_lines=max_lines, max_bytes=128 * 1024)
    except Exception:
        return entries

    for line in raw_lines:
        if not line.strip():
            continue
        try:
            entries.append(json.loads(line))
        except Exception:
            continue

    if not entries:
        return entries

    base_date = datetime.datetime.fromtimestamp(log_file.stat().st_mtime).date()
    current_date = base_date
    last_seconds: Optional[int] = None

    # 反向扫描，遇到 `23:xx -> 00:xx` 这类回绕时把日期减一天，补上跨天顺序。
    for entry in reversed(entries):
        seconds = _parse_hms_seconds(str(entry.get("time", "")))
        if seconds is not None and last_seconds is not None and seconds > last_seconds:
            current_date -= datetime.timedelta(days=1)
        raw_timestamp = str(entry.get("timestamp", "")).strip()
        if raw_timestamp:
            try:
                parsed = datetime.datetime.fromisoformat(raw_timestamp.replace("Z", "+00:00"))
                entry["_sort_ts"] = parsed
                continue
            except Exception:
                pass
        ts = entry.get("ts")
        if isinstance(ts, (int, float)):
            entry["_sort_ts"] = datetime.datetime.fromtimestamp(float(ts), datetime.timezone.utc)
            continue
        if seconds is not None:
            entry["_sort_ts"] = datetime.datetime.combine(
                current_date,
                datetime.time(second=0),
            ) + datetime.timedelta(seconds=seconds)
            last_seconds = seconds
        else:
            entry["_sort_ts"] = datetime.datetime.combine(current_date, datetime.time.min)

    return entries


def _resolve_active_swarm_dir() -> Optional[pathlib.Path]:
    candidates = _runtime_root_candidates("logs", "swarm")
    readable = [path for path in candidates if _is_readable_dir(path)]
    if not readable:
        return None
    readable.sort(key=lambda path: (_latest_mtime_in_dir(path), str(path)))
    return readable[-1]


def _resolve_existing_file(paths: list[pathlib.Path]) -> Optional[pathlib.Path]:
    for path in paths:
        if _is_readable_file(path):
            return path
    return None


def _dedupe_paths(paths: list[pathlib.Path]) -> list[pathlib.Path]:
    deduped: list[pathlib.Path] = []
    seen: set[str] = set()
    for path in paths:
        try:
            key = str(path.resolve())
        except Exception:
            key = str(path)
        if key in seen:
            continue
        seen.add(key)
        deduped.append(path)
    return deduped


def _resolve_freshest_existing_file(paths: list[pathlib.Path]) -> Optional[pathlib.Path]:
    freshest_path: Optional[pathlib.Path] = None
    freshest_key: tuple[float, int, str] = (0.0, -1, "")
    for path in _dedupe_paths(paths):
        if not _is_readable_file(path):
            continue
        try:
            stat = path.stat()
            candidate_key = (float(stat.st_mtime), int(stat.st_size), str(path))
        except Exception:
            candidate_key = (0.0, 0, str(path))
        if freshest_path is None or candidate_key > freshest_key:
            freshest_path = path
            freshest_key = candidate_key
    return freshest_path


def _managed_release_log_candidates(name: str) -> list[pathlib.Path]:
    candidates: list[pathlib.Path] = []
    releases_dirs = [
        APP_DIR / "managed_app" / "releases",
        pathlib.Path.home() / "Library" / "Application Support" / "LobsterQuant" / "managed_app" / "releases",
    ]
    seen_roots: set[str] = set()
    for releases_dir in releases_dirs:
        if not _is_readable_dir(releases_dir):
            continue
        try:
            release_roots = sorted(
                [path for path in releases_dir.iterdir() if path.is_dir()],
                key=lambda path: path.stat().st_mtime,
                reverse=True,
            )
        except Exception:
            continue
        for release_root in release_roots:
            root_key = str(release_root.resolve())
            if root_key in seen_roots:
                continue
            seen_roots.add(root_key)
            candidates.append(release_root / "logs" / name)
    return candidates


_ACTIVE_TRADER_LOG_CACHE: dict[str, Any] = {
    "ts": 0.0,
    "paths": {},
}
_ACTIVE_RUNTIME_ROOT_CACHE: dict[str, Any] = {
    "ts": 0.0,
    "roots": [],
}


def _normalize_runtime_log_root(root: pathlib.Path) -> pathlib.Path:
    try:
        candidate = root.resolve()
    except Exception:
        candidate = root
    if candidate.name in {"bridge", "src"}:
        parent = candidate.parent
        if (parent / "logs").exists() or (parent / "config").exists() or (parent / "gui_backend.py").exists():
            return parent
    # When a pipeline process runs from a monorepo root (e.g. openclaw/),
    # the actual gold_autotrade project lives one level deeper.
    for sub in ["projects/gold_autotrade"]:
        sub_path = candidate / sub
        if (sub_path / "logs").exists() or (sub_path / "gui_backend.py").exists():
            return sub_path
    return candidate


def _managed_storage_root_for_runtime_root(root: pathlib.Path) -> Optional[pathlib.Path]:
    normalized_root = _normalize_runtime_log_root(root)
    for parent in normalized_root.parents:
        releases_dir = parent / "managed_app" / "releases"
        try:
            normalized_root.relative_to(releases_dir)
            return parent
        except ValueError:
            continue
    return None


def _active_trader_runtime_roots() -> list[pathlib.Path]:
    now = time.time()
    cached_roots = _ACTIVE_RUNTIME_ROOT_CACHE.get("roots", [])
    if (
        isinstance(cached_roots, list)
        and (now - float(_ACTIVE_RUNTIME_ROOT_CACHE.get("ts", 0.0) or 0.0)) < 3.0
    ):
        return [path for path in cached_roots if isinstance(path, pathlib.Path)]

    roots: list[pathlib.Path] = []
    seen: set[str] = set()
    try:
        for proc in psutil.process_iter(["pid", "cmdline", "name"]):
            try:
                cmdline = [str(part) for part in (proc.info.get("cmdline") or []) if str(part).strip()]
            except Exception:
                cmdline = []
            if not cmdline:
                continue
            combined = " ".join(cmdline)
            if "gold_trader.py" not in combined and "src/pipeline.py" not in combined:
                continue

            proc_roots: list[pathlib.Path] = []
            try:
                cwd = proc.cwd()
                if cwd:
                    proc_roots.append(pathlib.Path(cwd))
            except Exception:
                pass

            for part in cmdline:
                try:
                    script_path = pathlib.Path(part)
                except Exception:
                    continue
                if script_path.name == "gold_trader.py" and script_path.parent.name == "bridge":
                    proc_roots.append(script_path.parent.parent)
                elif script_path.name == "pipeline.py" and script_path.parent.name == "src":
                    proc_roots.append(script_path.parent.parent)

            for root in proc_roots:
                normalized_root = _normalize_runtime_log_root(root)
                try:
                    root_key = str(normalized_root.resolve())
                except Exception:
                    root_key = str(normalized_root)
                if not root_key or root_key in seen:
                    continue
                seen.add(root_key)
                roots.append(normalized_root)
    except Exception:
        pass

    _ACTIVE_RUNTIME_ROOT_CACHE["ts"] = now
    _ACTIVE_RUNTIME_ROOT_CACHE["roots"] = list(roots)
    return roots


def _runtime_scope_candidates(scope: str, *relative_parts: str) -> list[pathlib.Path]:
    rel_path = pathlib.Path(*relative_parts)
    candidates: list[pathlib.Path] = []
    scope_dir = {
        "logs": LOGS_DIR,
        "data": DATA_DIR,
        "config": CONFIG_DIR,
    }.get(scope)
    if scope_dir is not None:
        candidates.append(scope_dir / rel_path)

    for root in _active_trader_runtime_roots():
        normalized = _normalize_runtime_log_root(root)
        candidates.append(normalized / scope / rel_path)
        candidates.append(root / scope / rel_path)
        managed_storage_root = _managed_storage_root_for_runtime_root(root)
        if managed_storage_root is not None:
            candidates.append(managed_storage_root / scope / rel_path)

    if scope == "logs":
        candidates.append(RELEASE_ROOT / "logs" / rel_path)
    elif APP_DIR.resolve() == REPO_ROOT.resolve():
        candidates.append(REPO_ROOT / scope / rel_path)

    return _dedupe_paths(candidates)


def _resolve_runtime_artifact(scope: str, *relative_parts: str) -> Optional[pathlib.Path]:
    return _resolve_freshest_existing_file(_runtime_scope_candidates(scope, *relative_parts))


def _runtime_root_candidates(*relative_parts: str) -> list[pathlib.Path]:
    rel_path = pathlib.Path(*relative_parts)
    candidates: list[pathlib.Path] = [APP_DIR / rel_path]

    for root in _active_trader_runtime_roots():
        candidates.append(root / rel_path)
        managed_storage_root = _managed_storage_root_for_runtime_root(root)
        if managed_storage_root is not None:
            candidates.append(managed_storage_root / rel_path)

    candidates.append(RELEASE_ROOT / rel_path)
    if APP_DIR.resolve() == REPO_ROOT.resolve():
        candidates.append(REPO_ROOT / rel_path)
    else:
        candidates.append(REPO_ROOT / rel_path)
    return _dedupe_paths(candidates)


def _resolve_runtime_root_artifact(*relative_parts: str) -> Optional[pathlib.Path]:
    return _resolve_freshest_existing_file(_runtime_root_candidates(*relative_parts))


def _runtime_execution_state_file() -> pathlib.Path:
    return _resolve_runtime_artifact("data", "execution_state.json") or (DATA_DIR / "execution_state.json")


def _runtime_execution_state_payload() -> dict[str, Any]:
    return _read_json_file(_runtime_execution_state_file())


def _runtime_strategy_status_file() -> pathlib.Path:
    return _resolve_runtime_artifact("logs", "strategy_runtime_status.json") or (LOGS_DIR / "strategy_runtime_status.json")


def _runtime_strategy_status_payload() -> dict[str, Any]:
    return _read_json_file(_runtime_strategy_status_file())


def _read_json_from_candidates(paths: list[pathlib.Path]) -> tuple[Optional[pathlib.Path], Dict[str, Any]]:
    path = _resolve_freshest_existing_file(paths)
    if path is None:
        return None, {}
    return path, _read_json_file(path)


def _file_mtime_ts(path: Optional[pathlib.Path]) -> float:
    if path is None:
        return 0.0
    try:
        return float(path.stat().st_mtime)
    except Exception:
        return 0.0


def _runtime_activity_anchor_ts() -> float:
    anchor_paths = [
        _resolve_runtime_root_artifact("blackboard_state.json"),
        _resolve_runtime_artifact("logs", "strategy_runtime_status.json"),
        _resolve_runtime_artifact("logs", "runtime_scheduler_snapshot.json"),
        _resolve_runtime_artifact("logs", "effective_params.json"),
        _resolve_runtime_artifact("data", "execution_state.json"),
        _resolve_runtime_artifact("data", "position_lifecycle.json"),
        _resolve_runtime_artifact("config", "strategy_market_state.json"),
    ]
    return max((_file_mtime_ts(path) for path in anchor_paths), default=0.0)


def _entry_event_ts(entry: dict) -> Optional[float]:
    ts = entry.get("ts")
    if isinstance(ts, (int, float)):
        return float(ts)
    raw_timestamp = str(entry.get("timestamp", "")).strip()
    if raw_timestamp:
        try:
            return datetime.datetime.fromisoformat(raw_timestamp.replace("Z", "+00:00")).timestamp()
        except Exception:
            pass
    inferred = entry.get("_sort_ts")
    if isinstance(inferred, datetime.datetime):
        return inferred.timestamp()
    return None


def _is_event_fresh(entry_ts: Optional[float], *, anchor_ts: float, max_age_sec: float) -> bool:
    if anchor_ts <= 0:
        return True
    if entry_ts is None or entry_ts <= 0:
        return False
    return (anchor_ts - entry_ts) <= max_age_sec


def _active_trader_workspace_log_candidates(name: str) -> list[pathlib.Path]:
    now = time.time()
    cached_paths = _ACTIVE_TRADER_LOG_CACHE.get("paths", {})
    if (
        isinstance(cached_paths, dict)
        and (now - float(_ACTIVE_TRADER_LOG_CACHE.get("ts", 0.0))) < 3.0
        and name in cached_paths
    ):
        cached = cached_paths.get(name)
        return list(cached) if isinstance(cached, list) else []

    candidates = _runtime_scope_candidates("logs", name)

    if not isinstance(cached_paths, dict):
        cached_paths = {}
    cached_paths[name] = list(candidates)
    _ACTIVE_TRADER_LOG_CACHE["ts"] = now
    _ACTIVE_TRADER_LOG_CACHE["paths"] = cached_paths
    return candidates


def _runtime_log_candidates(name: str) -> list[pathlib.Path]:
    candidates = [
        *_active_trader_workspace_log_candidates(name),
    ]
    # Only consult the repo workspace when the GUI is actually running from the repo.
    if APP_DIR.resolve() == REPO_ROOT.resolve():
        candidates.append(REPO_ROOT / "logs" / name)
    else:
        candidates.extend(_managed_release_log_candidates(name))
    return _dedupe_paths(candidates)


def _read_text_from_candidates(paths: list[pathlib.Path], encoding: str = "utf-8") -> tuple[Optional[pathlib.Path], str]:
    path = _resolve_existing_file(paths)
    if path is None:
        return None, ""
    try:
        return path, path.read_text(encoding=encoding)
    except PermissionError:
        return None, ""
    except Exception:
        return None, ""


def _ensure_runtime_import_paths() -> None:
    candidates = [
        RELEASE_ROOT,
        RELEASE_ROOT / "src",
        REPO_ROOT,
        REPO_ROOT / "src",
    ]
    for candidate in candidates:
        try:
            candidate_str = str(candidate)
            if candidate.exists() and candidate_str not in sys.path:
                sys.path.insert(0, candidate_str)
        except Exception:
            continue


def _load_global_blackboard():
    _ensure_runtime_import_paths()
    try:
        from src.swarm.blackboard import global_blackboard

        return global_blackboard
    except Exception:
        return None


def _existing_log_candidates(
    paths: list[pathlib.Path],
    *,
    preserve_priority: bool = False,
) -> list[pathlib.Path]:
    existing: list[pathlib.Path] = []
    for path in paths:
        try:
            if path.exists() and path.is_file():
                existing.append(path)
        except Exception:
            continue
    if not preserve_priority:
        existing.sort(key=lambda p: (p.stat().st_size > 0, p.stat().st_mtime), reverse=True)
    return existing


def _resolve_log_targets(log_type: str) -> list[pathlib.Path]:
    today_log = f"gold_trader.{time.strftime('%Y%m%d')}.log"
    if log_type == "daemon":
        candidates = [
            *_runtime_log_candidates("gold_trader.daemon.log"),
            RELEASE_ROOT / "lobsterquant_trader.log",
            RELEASE_ROOT / "bridge" / "logs" / "gold_trader.daemon.log",
            REPO_ROOT / "bridge" / "logs" / "gold_trader.daemon.log",
            pathlib.Path(DAEMON_LOG),
        ]
    elif log_type == "system":
        candidates = [
            *_runtime_log_candidates(today_log),
            *_runtime_log_candidates("runtime.log"),
            RELEASE_ROOT / "lobsterquant_trader.log",
            APP_DIR / "logs" / "gui_backend.manual.log",
            APP_DIR / "logs" / "supervisor.launchd.log",
            *_runtime_log_candidates("gold_trader.daemon.log"),
            pathlib.Path(DAEMON_LOG),
        ]
    else:
        candidates = []
    return _existing_log_candidates(candidates, preserve_priority=True)


def _resolve_signal_pipeline_log_file() -> Optional[pathlib.Path]:
    direct_candidates = _runtime_log_candidates("runtime.log")
    direct_match = _resolve_freshest_existing_file(direct_candidates)
    if direct_match is not None:
        return direct_match

    rollover_candidates: list[pathlib.Path] = []
    seen_dirs: set[str] = set()
    for candidate in direct_candidates:
        parent = candidate.parent
        try:
            parent_key = str(parent.resolve())
        except Exception:
            parent_key = str(parent)
        if parent_key in seen_dirs or not _is_readable_dir(parent):
            continue
        seen_dirs.add(parent_key)
        try:
            for log_path in parent.glob("gold_trader.*.log"):
                if ".daemon." in log_path.name:
                    continue
                rollover_candidates.append(log_path)
        except Exception:
            continue
    return _resolve_freshest_existing_file(rollover_candidates)


def _resolve_gold_trader_daily_log_file() -> Optional[pathlib.Path]:
    today_log = f"gold_trader.{time.strftime('%Y%m%d')}.log"
    direct_candidates = _runtime_log_candidates(today_log)
    direct_match = _resolve_freshest_existing_file(direct_candidates)
    if direct_match is not None:
        return direct_match

    rollover_candidates: list[pathlib.Path] = []
    seen_dirs: set[str] = set()
    for candidate in direct_candidates:
        parent = candidate.parent
        try:
            parent_key = str(parent.resolve())
        except Exception:
            parent_key = str(parent)
        if parent_key in seen_dirs or not _is_readable_dir(parent):
            continue
        seen_dirs.add(parent_key)
        try:
            for log_path in parent.glob("gold_trader.*.log"):
                if ".daemon." in log_path.name:
                    continue
                rollover_candidates.append(log_path)
        except Exception:
            continue
    return _resolve_freshest_existing_file(rollover_candidates)


def _resolve_gold_trader_bridge_log_files() -> list[pathlib.Path]:
    candidates: list[pathlib.Path] = []
    daily_log = _resolve_gold_trader_daily_log_file()
    if daily_log is not None:
        candidates.append(daily_log)
    daemon_candidates = _resolve_log_targets("daemon")
    if daemon_candidates:
        candidates.append(daemon_candidates[0])
    return _dedupe_paths(candidates)


def _format_log_time_label(value: Any, *, fallback_ts: float = 0.0) -> str:
    ts = _parse_timestamp_value(value)
    if ts is None and fallback_ts > 0:
        ts = fallback_ts
    if ts is not None and ts > 0:
        try:
            return datetime.datetime.fromtimestamp(ts).strftime("%H:%M:%S")
        except Exception:
            pass
    raw = str(value or "").strip()
    if raw.startswith("[") and "]" in raw:
        return raw[1:].split("]", 1)[0]
    return raw or "--:--:--"


def _pipeline_log_level(entry: dict[str, Any]) -> str:
    execution_report = entry.get("execution_report", {}) if isinstance(entry.get("execution_report", {}), dict) else {}
    status = str(execution_report.get("status", "") or "").upper()
    incidents = entry.get("incidents", []) if isinstance(entry.get("incidents", []), list) else []
    if status in {"REJECTED", "TIMEOUT", "VETOED", "FAILED"}:
        return "WARN"
    if incidents:
        return "WARN"
    return "INFO"


def _pipeline_side_label(side: str) -> str:
    normalized = str(side or "").upper()
    return {
        "BUY": "做多",
        "SELL": "做空",
        "LONG": "做多",
        "SHORT": "做空",
        "NONE": "无方向",
        "UNKNOWN": "未知方向",
    }.get(normalized, normalized or "未知方向")


def _pipeline_action_label(action: str, side: str) -> str:
    normalized_action = str(action or "").upper()
    open_side_label = {
        "BUY": "多单",
        "LONG": "多单",
        "SELL": "空单",
        "SHORT": "空单",
    }.get(str(side or "").upper(), f"{_pipeline_side_label(side)}持仓")
    if normalized_action == "OPEN":
        return f"策略尝试开{open_side_label}"
    if normalized_action == "CLOSE":
        return f"策略尝试平掉{open_side_label}"
    if normalized_action == "MODIFY":
        return f"策略尝试调整{open_side_label}"
    if normalized_action == "REDUCE":
        return f"策略尝试减仓{open_side_label}"
    if normalized_action == "HOLD":
        return "本轮未触发开仓动作"
    return f"{normalized_action} {open_side_label}".strip()


def _pipeline_status_label(status: str) -> str:
    normalized = str(status or "").upper()
    return {
        "REJECTED": "已拒绝",
        "BLOCKED": "已阻断",
        "VETOED": "已否决",
        "FAILED": "执行失败",
        "ERROR": "执行异常",
        "FILLED": "已成交",
        "SENT": "已发送",
        "PENDING": "待确认",
        "SKIPPED": "已跳过",
        "TIMEOUT": "等待超时",
    }.get(normalized, normalized or "未知状态")


def _pipeline_reason_label(reason: str) -> str:
    normalized = str(reason or "").upper()
    return {
        "ACTIVE_POSITION_EXISTS": "当前已有活动持仓",
        "DIRECTION_BLOCKED": "宏观方向风控阻断",
        "MACRO_DIRECTION_CONFLICT": "宏观方向与信号冲突",
        "LOW_CONFIDENCE": "信号置信度不足",
        "LIMIT_ONLY": "当前环境只允许保守执行",
        "NO_SIGNAL": "当前无有效信号",
        "RISK_BLOCKED": "风险闸门阻断",
    }.get(normalized, str(reason or "-"))


def _pipeline_policy_sentence(macro: str, advice: str, structure: str, strategy_id: str) -> str:
    macro_label = _pipeline_side_label(macro)
    structure_label = _pipeline_side_label(structure)
    advice_label = {
        "LIMIT_ONLY": "仅允许保守执行",
        "BLOCK_ALL": "当前禁止自动执行",
        "NORMAL": "允许正常执行",
        "UNKNOWN": "执行建议未明确",
    }.get(str(advice or "").upper(), str(advice or "UNKNOWN"))
    return (
        f"策略 {strategy_id} 当前看到的环境是：宏观偏{macro_label}，"
        f"局部结构偏{structure_label}，执行建议为“{advice_label}”。"
    )


def _pipeline_guard_sentence(open_guard: dict[str, Any], reason: str) -> str:
    allow_open = bool(open_guard.get("allow_open", False))
    guard_reason = str(open_guard.get("reason", reason) or reason)
    active_ticket = str(open_guard.get("active_ticket", "") or "").strip()
    active_side = str(open_guard.get("active_side", "") or "").strip()
    if allow_open:
        return f"执行闸门放行，本轮信号允许继续进入下单阶段。（{guard_reason or 'ALLOW_OPEN'}）"
    reason_label = _pipeline_reason_label(guard_reason)
    if active_side and active_ticket:
        return (
            f"执行闸门拦截：检测到已有{_pipeline_side_label(active_side)}持仓 {active_ticket}，"
            f"因此禁止继续反向开仓。（{guard_reason}）"
        )
    if active_side:
        return (
            f"执行闸门拦截：当前已有{_pipeline_side_label(active_side)}持仓在场，"
            f"本轮信号不允许继续开仓。（{guard_reason}）"
        )
    return f"执行闸门拦截：{reason_label}。（{guard_reason}）"


def _format_pipeline_runtime_lines(log_path: pathlib.Path, lines: int) -> list[str]:
    entry_limit = max(12, min(160, max(lines, 1)))
    entries = _read_ndjson_tail(log_path, limit=entry_limit)
    rendered: list[str] = []
    for entry in entries:
        if not isinstance(entry, dict):
            continue
        signal = entry.get("signal", {}) if isinstance(entry.get("signal", {}), dict) else {}
        execution_report = entry.get("execution_report", {}) if isinstance(entry.get("execution_report", {}), dict) else {}
        trace = signal.get("trace", {}) if isinstance(signal.get("trace", {}), dict) else {}
        policy = trace.get("policy", {}) if isinstance(trace.get("policy", {}), dict) else {}
        open_guard = execution_report.get("open_guard", {}) if isinstance(execution_report.get("open_guard", {}), dict) else {}
        fallback_ts = 0.0
        try:
            fallback_ts = float(log_path.stat().st_mtime)
        except Exception:
            fallback_ts = 0.0
        time_label = _format_log_time_label(entry.get("timestamp"), fallback_ts=fallback_ts)
        action = str(signal.get("action", "HOLD") or "HOLD").upper()
        side = str(signal.get("side", "NONE") or "NONE").upper()
        confidence = float(trace.get("confidence", 0.0) or 0.0)
        status = str(execution_report.get("status", "PENDING") or "PENDING").upper()
        reason = str(execution_report.get("reason", "") or signal.get("reject_reason", "") or "-")
        macro = str(
            policy.get("binding_macro_direction")
            or policy.get("effective_macro_direction")
            or policy.get("macro_direction")
            or "UNKNOWN"
        ).upper()
        advice = str(policy.get("execution_advice", "UNKNOWN") or "UNKNOWN").upper()
        structure = str(policy.get("structural_side", "UNKNOWN") or "UNKNOWN").upper()
        strategy_id = str(signal.get("strategy_id", signal.get("selected_strategy_id", "-")) or "-")
        effects = policy.get("effects", []) if isinstance(policy.get("effects", []), list) else []
        incidents = entry.get("incidents", []) if isinstance(entry.get("incidents", []), list) else []
        auto_actions = entry.get("auto_actions", []) if isinstance(entry.get("auto_actions", []), list) else []
        rendered.append(
            f"[{time_label}] [{_pipeline_log_level(entry)}] [PipelineDecision] "
            f"{_pipeline_action_label(action, side)}，信号置信度 {confidence:.2f}；"
            f"结果：{_pipeline_status_label(status)}（{_pipeline_reason_label(reason)} / {status} / {reason}）"
        )
        rendered.append(
            f"[{time_label}] [INFO] [PipelinePolicy] "
            f"{_pipeline_policy_sentence(macro, advice, structure, strategy_id)} "
            f"(macro={macro} | advice={advice} | structure={structure} | strategy={strategy_id})"
        )
        if effects:
            rendered.append(
                f"[{time_label}] [INFO] [PipelineEffects] {', '.join(str(item) for item in effects[:4])}"
            )
        if open_guard:
            active_ticket = str(open_guard.get("active_ticket", "") or "").strip()
            active_side = str(open_guard.get("active_side", "") or "").strip()
            guard_bits = [
                f"allow_open={str(bool(open_guard.get('allow_open', False))).lower()}",
                f"reason={str(open_guard.get('reason', reason) or reason)}",
            ]
            if active_side:
                guard_bits.append(f"active_side={active_side}")
            if active_ticket:
                guard_bits.append(f"ticket={active_ticket}")
            rendered.append(
                f"[{time_label}] [WARN] [PipelineGuard] "
                f"{_pipeline_guard_sentence(open_guard, reason)} "
                f"({' | '.join(guard_bits)})"
            )
        if incidents:
            rendered.append(
                f"[{time_label}] [WARN] [PipelineIncident] "
                + " | ".join(str(item) for item in incidents[:3])
            )
        if auto_actions:
            rendered.append(
                f"[{time_label}] [INFO] [PipelineAction] "
                + " | ".join(str(item) for item in auto_actions[:3])
            )
    return rendered[-max(lines, 1) :]


def _filter_gold_trader_execution_lines(lines: list[str]) -> list[str]:
    markers = (
        "EA report:",
        "✅ POSITION OPENED",
        "✅ PARTIAL CLOSED:",
        "✅ CLOSED:",
        "EA REJECTED:",
        "⚠️ PARTIAL_CLOSE 被拒",
        "🟢 持仓监控启动",
        "🔴 持仓退出",
        "出场完成，已平仓",
        "EA 响应延迟",
        "to_ea积压",
        "MODIFY_SL",
        "pending confirm",
        "confirmed by MT5",
        "Signal poll deferred:",
        "OPEN timeout pending confirm",
        "Skip duplicate OPEN:",
        "无法读取 MT5 持仓状态",
        "MT5持仓存在但entry=0",
        "监控退出：已平仓",
        "监控循环异常",
        "Review request:",
    )
    filtered: list[str] = []
    last_normalized = ""
    for line in lines:
        normalized = str(line).strip()
        if not normalized:
            continue
        if "[Pipeline]" in normalized or "[MarketInterpreter]" in normalized or "[DirectionGate]" in normalized:
            continue
        if not any(marker in normalized for marker in markers):
            continue
        if normalized == last_normalized:
            continue
        last_normalized = normalized
        filtered.append(line)
    return filtered


def _extract_log_line_sort_key(line: str) -> tuple[int, int, str]:
    normalized = str(line).strip()
    if normalized.startswith("[") and "]" in normalized:
        token = normalized[1:].split("]", 1)[0]
        seconds = _parse_hms_seconds(token)
        if seconds is not None:
            return (0, seconds, normalized)
    try:
        head = normalized.split(" ", 2)
        if len(head) >= 2:
            dt = datetime.datetime.fromisoformat(f"{head[0]}T{head[1].split(',')[0]}")
            return (1, dt.hour * 3600 + dt.minute * 60 + dt.second, normalized)
    except Exception:
        pass
    return (2, 0, normalized)


def _read_gold_trader_bridge_progress_lines(lines: int) -> tuple[str, list[str]]:
    rendered: list[str] = []
    source_paths: list[str] = []
    for path in _resolve_gold_trader_bridge_log_files():
        source_paths.append(str(path))
        if path.name == "gold_trader.daemon.log":
            raw_lines = _read_tail_lines(path, max_lines=max(lines * 10, 500), max_bytes=300000)
        else:
            raw_lines = _read_tail_lines(path, max_lines=max(lines * 8, 400), max_bytes=300000)
        rendered.extend(_filter_gold_trader_execution_lines(raw_lines))
    deduped: list[str] = []
    seen: set[str] = set()
    for line in sorted(rendered, key=_extract_log_line_sort_key):
        normalized = str(line).strip()
        if normalized in seen:
            continue
        seen.add(normalized)
        deduped.append(line)
    source = " | ".join(source_paths)
    return source, deduped[-max(lines, 1) :]


def _filter_log_lines(log_type: str, lines: list[str]) -> list[str]:
    if log_type != "system":
        return lines
    filtered: list[str] = []
    startup_lines: list[str] = []
    last_normalized = ""
    for line in lines:
        normalized = str(line).strip()
        if not normalized:
            continue
        if "MallocStackLogging: can't turn off malloc stack logging because it was not enabled." in normalized:
            continue
        if normalized.startswith("Traceback (most recent call last):"):
            continue
        if normalized.startswith("File \"") and ", line " in normalized:
            continue
        if normalized.startswith("INFO:     ") and " - \"" in normalized:
            continue
        if normalized.startswith("await ") or normalized.startswith("raise "):
            continue
        # Keep at most one startup line as fallback; don't let it dominate.
        if "🟢 系统启动 | PID=" in normalized:
            startup_lines.append(line)
            continue
        if normalized.startswith("[daemon] started PID "):
            startup_lines.append(line)
            continue
        # Collapse contiguous duplicates to keep realtime panel readable.
        if normalized == last_normalized:
            continue
        last_normalized = normalized
        filtered.append(line)
    if filtered:
        return filtered
    if startup_lines:
        return [startup_lines[-1]]
    return filtered


def _score_log_candidate(log_type: str, path: pathlib.Path, lines: list[str]) -> tuple[int, float, int]:
    meaningful_lines = [
        line for line in lines if str(line).strip() and not str(line).strip().startswith(f"[{log_type}] source:")
    ]
    startup_only = all(
        str(line).strip().startswith("[daemon] started PID ")
        or "🟢 系统启动 | PID=" in str(line).strip()
        for line in meaningful_lines
    )
    try:
        stat = path.stat()
        mtime = float(stat.st_mtime)
        size = int(stat.st_size)
    except Exception:
        mtime = 0.0
        size = 0
    score = len(meaningful_lines) * 3
    if meaningful_lines and not startup_only:
        score += 1000
    if log_type == "system":
        path_name = path.name
        age_sec = max(0.0, time.time() - mtime) if mtime > 0 else 0.0
        # Signal Pipeline tiered scoring — system.log shows the trading
        # system's signal evaluation and trade execution pipeline.
        # Tier 1: gold_trader daily log — the canonical signal pipeline
        #          (DynamicAdj / MarketInterpreter / DirectionGate).
        # Tier 2: runtime.log — pipeline JSONL output (realtime fallback).
        # Tier 3: lobsterquant_trader.log — pipeline stdout (buffered).
        # Tier 4: daemon log (>=10KB) — watchdog/worker heartbeat fallback.
        if path_name.startswith("gold_trader.") and path_name.endswith(".log") and ".daemon." not in path_name:
            score += 2000
        elif path_name == "runtime.log":
            score += 1500
        elif path_name == "lobsterquant_trader.log":
            score += 1200
        elif path_name == "gold_trader.daemon.log":
            if size >= 10000:
                score += 800
            else:
                score -= 1200
        trade_markers = (
            "EA report: FILLED OPEN",
            "✅ POSITION OPENED",
            "🟢 持仓监控启动",
            "🔴 持仓退出",
            "✅ CLOSED:",
            "✅ PARTIAL CLOSED:",
            "出场完成，已平仓",
        )
        trade_line_hits = sum(1 for line in meaningful_lines if any(marker in str(line) for marker in trade_markers))
        if trade_line_hits > 0:
            score += 800 + min(300, trade_line_hits * 80)
        # Time decay scales inversely with content volume — a rich file stays
        # relevant longer, a nearly-empty file decays fast.
        line_count = max(1, len(meaningful_lines))
        # Content-aware hard-decay threshold: richer files stay relevant longer.
        hard_decay_sec = max(1800, 300 + line_count * 60)
        if age_sec > hard_decay_sec:
            score -= 4000
        elif age_sec > 300:
            decay_per_tick = max(5, 90 // line_count)
            score -= min(3000, int((age_sec - 300) // 30) * decay_per_tick)
    return score, mtime, size


def _read_swarm_jsonl_logs(limit: int = 200) -> list[dict]:
    logs: list[dict] = []
    swarm_dir = _resolve_active_swarm_dir()
    if not swarm_dir:
        return logs
    for log_file in sorted(
        swarm_dir.glob("*.jsonl"),
        key=lambda path: (path.stat().st_mtime, path.name),
    ):
        logs.extend(_load_swarm_entries_with_inferred_ts(log_file, max_lines=limit))

    logs.sort(key=_entry_sort_key)
    return logs[-limit:]


def _has_trustworthy_mailbox_timestamp(entry: dict) -> bool:
    if isinstance(entry.get("ts"), (int, float)):
        return True
    raw_timestamp = str(entry.get("timestamp", "")).strip()
    if raw_timestamp:
        try:
            datetime.datetime.fromisoformat(raw_timestamp.replace("Z", "+00:00"))
            return True
        except Exception:
            return False
    inferred = entry.get("_sort_ts")
    return isinstance(inferred, datetime.datetime)


def _read_latest_risk_block_event() -> dict:
    log_paths = [
        LOGS_DIR / f"gold_trader.{time.strftime('%Y%m%d')}.log",
        pathlib.Path("/tmp/gold_trader.log"),
    ]
    now = time.time()
    latest_line = ""
    latest_file = ""
    latest_mtime = 0.0
    latest_size = 0
    for p in log_paths:
        if not p.exists():
            continue
        try:
            stat = p.stat()
            mtime = float(stat.st_mtime)
            size = int(stat.st_size)
        except Exception:
            mtime = 0.0
            size = 0
        if (
            _RISK_BLOCK_CACHE.get("value") is not None
            and str(_RISK_BLOCK_CACHE.get("file", "")) == str(p)
            and float(_RISK_BLOCK_CACHE.get("mtime", 0.0)) == mtime
            and int(_RISK_BLOCK_CACHE.get("size", 0)) == size
            and (now - float(_RISK_BLOCK_CACHE.get("ts", 0.0))) < 3.0
        ):
            cached_value = _RISK_BLOCK_CACHE.get("value")
            return dict(cached_value) if isinstance(cached_value, dict) else _default_risk_block_payload()
        try:
            lines = _read_tail_lines(p)
        except Exception:
            continue
        for line in reversed(lines[-3000:]):
            if "RiskManager BLOCKED:" in line:
                latest_line = line
                latest_file = str(p)
                latest_mtime = mtime
                latest_size = size
                break

    if not latest_line:
        payload = _default_risk_block_payload()
        _RISK_BLOCK_CACHE.update({
            "ts": now,
            "file": latest_file,
            "mtime": latest_mtime,
            "size": latest_size,
            "value": payload,
        })
        return payload

    reason = latest_line.split("RiskManager BLOCKED:", 1)[1].strip()
    if " [" in reason:
        reason = reason.split(" [", 1)[0].strip()

    mapped = _map_risk_block_reason(reason)
    block_time = ""
    if latest_line.startswith("[") and "]" in latest_line:
        block_time = latest_line[1:].split("]", 1)[0]
    payload = {
        "exists": True,
        "rawReason": reason,
        "categoryCode": mapped["categoryCode"],
        "categoryLabel": mapped["categoryLabel"],
        "summary": mapped["summary"],
        "time": block_time,
        "line": latest_line,
    }
    _RISK_BLOCK_CACHE.update({
        "ts": now,
        "file": latest_file,
        "mtime": latest_mtime,
        "size": latest_size,
        "value": payload,
    })
    return payload

def _query_mt5_daily() -> dict:
    """
    直接向 MT5 EA 发 EXPORT_HISTORY 请求，等待最多 5 秒，
    解析当天已平仓成交的 real profit，返回每日统计。
    加入智能缓存机制：如果持仓数量没变，且未到 60 秒，直接返回缓存；
    一旦检测到持仓数减少（发生平仓），立即穿透缓存重新查询。
    """
    import threading, queue, json  # datetime/time already global

    global _MT5_DAILY_CACHE
    now = time.time()
    
    # 获取当前的持仓数量
    current_positions_count = _read_terminal_status_positions_count() or 0

    # 缓存失效条件：超过 60 秒，或者持仓数量变少了（说明有单子平掉了，必须刷新历史）
    # 或者持仓数量变多了（说明新开仓了，虽然不影响历史，但我们顺便刷新）
    positions_changed = current_positions_count != _MT5_DAILY_CACHE.get("last_positions_count", -1)
    if not positions_changed and (now - _MT5_DAILY_CACHE["updated_at"] < 60):
        return dict(_MT5_DAILY_CACHE["data"])

    _MT5_DAILY_CACHE["last_positions_count"] = current_positions_count

    now_ts = int(time.time())

    result_queue = queue.Queue()

    def _do_query():
        try:
            sig_id = f"daily_{int(now_ts * 1000)}"
            data = export_history_via_ea(APP_DIR, days=2, timeout_s=5.0, signal_id=sig_id)
            if data is None:
                result_queue.put(None)
                return

            # 解析 deals，计算今日统计
            entries = data.get("deals", [])
            trades = 0; pnl = 0.0
            wins = 0; losses = 0
            win_pnl = 0.0; loss_pnl = 0.0

            # 核心修正：寻找这批订单里最新的时间，以此作为 MT5 服务器当前时间的锚点
            # 然后计算出这个锚点对应服务器当天的 00:00:00 的时间戳。
            max_ts = 0
            for entry in entries:
                t = int(entry.get("time", 0))
                if t > max_ts:
                    max_ts = t
            
            if max_ts == 0:
                # 兜底：如果完全没有历史，就取当前系统时间加 3 小时当做服务器时间
                max_ts = int(time.time()) + 3 * 3600
                
            # =========================================================================
            # 彻底解决跨时区与夏令时(DST)导致“今日”数据截断的持久性方案：
            # 无论 MT5 处于东欧时间、UTC 还是经历了夏令时切换，
            # 只要以过去 24 小时（86400秒）内的数据，或者当前本地时间的自然日为准，
            # 这里统一采用：以当前最新的订单时间往前推算“今天”。
            # 由于时区偏移问题可能导致本地 00:00 时 MT5 还在昨天，
            # 最稳妥的方法是利用 MT5 传来的最新时间戳(max_ts)所处的“自然日”来卡点。
            # =========================================================================
            import datetime
            # 将 MT5 最新的时间戳转换为 datetime 对象（这里先不考虑时区，只当成一个绝对流逝秒数）
            # 或者更符合交易员直觉的：直接获取距离当前物理时间 24 小时内的订单。
            # 但为了严格对应“今日面板”，我们计算：获取本地电脑自然日的 00:00。
            # 因为不论 MT5 是什么时间，用户在电脑前看到的“今天”就是电脑的今天。
            # 考虑到 MT5 传回来的 `time` 其实是 EA 里通过 `TimeCurrent()` 生成并直接当整数发过来的
            # 它是“经纪商服务器的本地时间（例如 2026-03-27 10:00:00 被硬当做 Unix 时间戳）”
            # 这会导致它和真实的 UTC 时间戳存在固定偏差（夏令时会变）。
            
            # 【绝对正确的持久化修复】：
            # 既然我们要在面板上显示“今天”的收益，且用户的“今天”是以他本地电脑的自然日为准，
            # 那么无论 MT5 的时间戳是加了 3 小时还是减了 2 小时，
            # 最准确且不会因夏令时崩盘的做法是：
            # 直接取过去 24 小时内的订单。但为了和自然日对齐，
            # 我们直接对 MT5 传回来的时间戳强行按服务器的自然日切分。
            
            # 由于 MT5 的 time 字段是直接把本地时间（如东欧时间 00:00）当成 UTC 存进来的，
            # 所以它自带了固定的时区偏移。
            # 直接对这个“伪 UTC 时间戳”取 86400 的余数，算出来的就是服务器当地的 00:00。
            # 这是一个不需要任何 datetime 转换、绝对可靠的数学推导。
            server_today_start_ts = max_ts - (max_ts % 86400)

            for entry in entries:
                # 过滤条件1：只统计我们 EA 的订单 (magic=1024)
                magic = int(entry.get("magic", 0))
                if magic != 1024:
                    continue

                # 过滤条件2：只统计出场交易 (entry_type: 1 = OUT, 2 = INOUT)
                entry_type = int(entry.get("entry_type", 0))
                if entry_type not in (1, 2):
                    continue
                    
                entry_time_ts = int(entry.get("time", 0))
                if not entry_time_ts:
                    continue
                    
                # 过滤条件3：必须大于等于服务器的当日 00:00:00
                if entry_time_ts < server_today_start_ts:
                    continue

                profit = float(entry.get("profit", 0) or 0)
                commission = float(entry.get("commission", 0) or 0)
                swap = float(entry.get("swap", 0) or 0)
                fee = float(entry.get("fee", 0) or 0)
                net_profit = profit + commission + swap + fee

                trades += 1
                pnl += net_profit
                if net_profit > 0:
                    wins += 1
                    win_pnl += net_profit
                elif net_profit < 0:
                    losses += 1
                    loss_pnl += net_profit

            wr = round(wins / trades * 100, 1) if trades > 0 else 0.0
            aw = round(win_pnl / wins, 2) if wins > 0 else 0.0
            al = round(loss_pnl / losses, 2) if losses > 0 else 0.0

            result_queue.put(_normalize_trade_stats({
                "trades": trades, "pnl": round(pnl, 2),
                "wins": wins, "losses": losses,
                "win_rate": wr, "avg_win": aw, "avg_loss": al,
            }, ok=True, source="mt5_export_history"))
        except Exception:
            result_queue.put(None)

    t = threading.Thread(target=_do_query, daemon=True)
    t.start()
    t.join(timeout=6)

    try:
        result = result_queue.get_nowait()
    except queue.Empty:
        result = None

    if result is None:
        result = _empty_trade_stats(ok=False, source="mt5_export_history_unavailable")

    _MT5_DAILY_CACHE["data"] = dict(result)
    _MT5_DAILY_CACHE["updated_at"] = now
    return dict(result)

PID_FILE = LOGS_DIR / "gold_trader.pid"
MANUAL_STOP_FILE = LOGS_DIR / ".daemon_manual_stop.json"
TRAINING_PID_FILE = DATA_DIR / "offline" / "training_pid.txt"
MACRO_REFRESH_PID_FILE = LOGS_DIR / "macro_refresh.pid"
MACRO_REFRESH_STATUS_FILE = LOGS_DIR / "macro_refresh_status.json"
_TODAY_STATS_CACHE = {
    "ts": 0.0,
    "db_mtime": 0.0,
    "value": {
        "trades": 0,
        "pnl": 0.0,
        "wins": 0,
        "losses": 0,
        "win_rate": 0.0,
        "avg_win": 0.0,
        "avg_loss": 0.0,
    },
}
_STATUS_RESPONSE_CACHE = {
    "ts": 0.0,
    "value": None,
    "refreshing": False,
    "thread": None,
    "refresh_started_ts": 0.0,
    "terminal_status_mtime": 0.0,
    "terminal_status_size": 0,
}
_STATUS_CACHE_TTL_SEC = 20.0
_STATUS_CACHE_LOCK = threading.Lock()
_LIGHTWEIGHT_STATUS_CACHE = {
    "ts": 0.0,
    "value": None,
}
_LIGHTWEIGHT_STATUS_CACHE_TTL_SEC = 2.0
_STATE_LOAD_CACHE = {
    "ts": 0.0,
    "value": {},
    "inflight": False,
    "thread": None,
}
_STATE_LOAD_LOCK = threading.Lock()
_RISK_BLOCK_CACHE = {
    "ts": 0.0,
    "file": "",
    "mtime": 0.0,
    "size": 0,
    "value": None,
}
_BLACKBOARD_CACHE = {
    "ts": 0.0,
    "file": "",
    "mtime": 0.0,
    "size": 0,
    "value": None,
}
_TRADE_EVENTS_CACHE = {
    "ts": 0.0,
    "mtime": 0.0,
    "size": 0,
    "path": "",
    "value": [],
}
_RUNTIME_OBSERVABILITY_CACHE = {
    "ts": 0.0,
    "value": None,
    "refreshing": False,
    "thread": None,
    "refresh_started_ts": 0.0,
}
_RUNTIME_OBSERVABILITY_CACHE_TTL_SEC = 60.0
_RUNTIME_OBSERVABILITY_REFRESH_STUCK_SEC = 60.0
_RUNTIME_OBSERVABILITY_CACHE_LOCK = threading.Lock()
_TRAINING_STATUS_RESPONSE_CACHE = {
    "ts": 0.0,
    "value": None,
    "refreshing": False,
    "thread": None,
}
_TRAINING_STATUS_CACHE_TTL_SEC = 30.0
_TRAINING_GOVERNANCE_CACHE = {
    "ts": 0.0,
    "value": None,
    "refreshing": False,
    "thread": None,
}
_TRAINING_GOVERNANCE_CACHE_TTL_SEC = 30.0
_TRAINING_APPROVAL_CHAIN_CACHE = {
    "ts": 0.0,
    "value": None,
    "refreshing": False,
    "thread": None,
}
_TRAINING_APPROVAL_CHAIN_CACHE_TTL_SEC = 30.0
_TRAINING_APPROVAL_PENDING_MAX_AGE_SEC = 24 * 3600.0
_GOVERNANCE_LIFECYCLE_CACHE = {
    "ts": 0.0,
    "value": None,
    "refreshing": False,
    "thread": None,
}
_GOVERNANCE_LIFECYCLE_CACHE_TTL_SEC = 30.0
_STRATEGY_WORKBENCH_CACHE = {
    "ts": 0.0,
    "value": None,
    "refreshing": False,
    "thread": None,
}
_STRATEGY_WORKBENCH_CACHE_TTL_SEC = 60.0
_STRATEGY_MARKETPLACE_CACHE = {
    "ts": 0.0,
    "value": None,
    "refreshing": False,
    "thread": None,
}
_STRATEGY_MARKETPLACE_CACHE_TTL_SEC = 60.0
_RUNTIME_INSTANCES_CACHE = {
    "ts": 0.0,
    "value": None,
    "refreshing": False,
    "thread": None,
}
_RUNTIME_INSTANCES_CACHE_TTL_SEC = 45.0
_RUNTIME_ARBITRATION_CACHE = {
    "path": "",
    "mtime": 0.0,
    "size": 0,
    "value": None,
}
_AUX_RESPONSE_CACHE_LOCK = threading.Lock()
_TIMELINE_DIRECTION_MAX_AGE_SEC = 2 * 60 * 60
_TECHNICAL_TREND_CACHE = {
    "ts": 0.0,
    "direction": "NEUTRAL",
    "data": {},
    "refreshing": False,
}
_GOAL_TARGETS_CACHE = {
    "ts": 0.0,
    "value": {},
}
_TRAINING_PNL_HISTORY_CACHE = {
    "mtime": 0.0,
    "value": [],
}
_TRAINING_PNL_HISTORY_MAX_POINTS = 360
_MACRO_REFRESH_THREAD = None
_MACRO_WATCHDOG_THREAD: Optional[threading.Thread] = None
_MACRO_WATCHDOG_STATE = {
    "last_trigger_ts": 0.0,
}
_MACRO_WATCHDOG_MIN_REFRESH_SEC = 300.0
_GUI_BACKGROUND_AUTOPILOT_ENABLED = str(os.getenv("GUI_BACKGROUND_AUTOPILOT", "0") or "0").strip().lower() in {
    "1",
    "true",
    "yes",
    "on",
}
_PANIC_ACTION_THREAD = None
_PANIC_ACTION_STATE = {
    "status": "idle",
    "message": "",
    "updated_at": "",
    "result": {},
}

# 允许跨域(方便前端开发阶段)
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

def _is_daemon_running():
    if PID_FILE.exists():
        try:
            pid = int(PID_FILE.read_text().strip())
            return is_process_running(pid)
        except (ValueError, ProcessLookupError, OSError):
            pass
    return _find_runtime_daemon_pid() is not None


def _find_runtime_daemon_pid() -> Optional[int]:
    """Fallback runtime detector when pidfile is missing or stale."""
    patterns = (
        "gold_trader.py --daemon",
        "gold_trader.py --fg",
        "src/pipeline.py --source ea_feed --runtime-intent live_trade",
        "pipeline.py --source ea_feed --runtime-intent live_trade",
    )
    try:
        for proc in psutil.process_iter(["pid", "cmdline"]):
            cmdline = proc.info.get("cmdline") or []
            combined = " ".join(str(x) for x in cmdline).lower()
            if any(p in combined for p in patterns):
                return int(proc.info["pid"])
    except Exception:
        return None
    return None


def _read_today_stats_cached(db_path: pathlib.Path) -> dict:
    """Cache today's PnL aggregation to avoid high CPU on frequent UI polling."""
    now = time.time()
    ttl_sec = 15.0
    db_mtime = 0.0
    try:
        if db_path.exists():
            db_mtime = float(db_path.stat().st_mtime)
    except Exception:
        db_mtime = 0.0

    # 这里必须优先按 TTL 直接命中缓存；否则高频写 DB 会让 mtime 每秒变化，导致缓存形同虚设。
    if now - float(_TODAY_STATS_CACHE.get("ts", 0.0)) < ttl_sec:
        return dict(_TODAY_STATS_CACHE.get("value", {}))

    stats = _empty_trade_stats()

    mt5_stats = _normalize_trade_stats(_query_mt5_daily(), source="mt5_export_history")
    if mt5_stats.get("_ok"):
        stats = mt5_stats
    else:
        ledger_file = _resolve_existing_file(_runtime_log_candidates("trading_ledger.ndjson"))
        ledger_stats = _read_today_stats_from_trading_ledger(ledger_file)
        if ledger_stats.get("_ok"):
            stats = ledger_stats
        elif db_path.exists():
            try:
                import sqlite3
                import datetime

                today_start_ts = int(datetime.datetime.combine(datetime.date.today(), datetime.time.min).timestamp())
                # 状态接口不能被 DB 锁阻塞数秒；锁冲突时快速失败并使用缓存。
                conn = sqlite3.connect(str(db_path), timeout=0.2)
                conn.execute("PRAGMA journal_mode=WAL")
                c = conn.cursor()
                table = c.execute(
                    "SELECT name FROM sqlite_master WHERE type='table' AND name='ledger_kv'"
                ).fetchone()
                rows = []
                if table:
                    c.execute("SELECT value FROM ledger_kv WHERE updated_at >= ?", (today_start_ts,))
                    rows = c.fetchall()
                conn.close()

                pnl_sum = 0.0
                wins = 0
                losses = 0
                win_pnl = 0.0
                loss_pnl = 0.0
                trades = 0
                for row in rows:
                    try:
                        data = json.loads(row[0])
                        if "close_pnl" in data and "ticket" in data:
                            pnl = float(data.get("close_pnl", 0.0))
                            if abs(pnl) < 0.01:
                                continue
                            trades += 1
                            pnl_sum += pnl
                            if pnl > 0:
                                wins += 1
                                win_pnl += pnl
                            else:
                                losses += 1
                                loss_pnl += pnl
                    except Exception:
                        pass

                stats = _normalize_trade_stats(
                    {
                        "trades": trades,
                        "pnl": round(pnl_sum, 2),
                        "wins": wins,
                        "losses": losses,
                        "win_rate": round((wins / trades) * 100, 1) if trades > 0 else 0.0,
                        "avg_win": round(win_pnl / wins, 2) if wins > 0 else 0.0,
                        "avg_loss": round(loss_pnl / losses, 2) if losses > 0 else 0.0,
                    },
                    ok=bool(table),
                    source="ledger_kv",
                )
            except Exception:
                stats = _empty_trade_stats(ok=False, source="today_stats_fallback_error")

    _TODAY_STATS_CACHE.update({"ts": now, "db_mtime": db_mtime, "value": stats})
    return dict(stats)


def _load_goal_targets_cached() -> dict:
    now = time.time()
    ttl_sec = 30.0
    if (now - float(_GOAL_TARGETS_CACHE.get("ts", 0.0))) < ttl_sec:
        cached = _GOAL_TARGETS_CACHE.get("value", {})
        return dict(cached) if isinstance(cached, dict) else {}
    try:
        from src.metrics_agg import build_tenant_metrics

        tenant_metrics = build_tenant_metrics(APP_DIR, "default")
        goal_targets = tenant_metrics.get("goal_targets", {}) if isinstance(tenant_metrics, dict) else {}
        if not isinstance(goal_targets, dict):
            goal_targets = {}
    except Exception:
        goal_targets = {}
    _GOAL_TARGETS_CACHE.update({"ts": now, "value": goal_targets})
    return dict(goal_targets)

def _set_manual_stop(enabled: bool):
    if enabled:
        MANUAL_STOP_FILE.write_text(json.dumps({"manual_stop": True, "ts": time.time()}, ensure_ascii=False), encoding="utf-8")
    else:
        MANUAL_STOP_FILE.unlink(missing_ok=True)

def _is_manual_stop_requested() -> bool:
    return MANUAL_STOP_FILE.exists()


def _build_live_pipeline_cmd(app_dir: pathlib.Path) -> list[str]:
    return [
        "python3",
        str(_SRC_DIR / "pipeline.py"),
        "--source", "ea_feed",
        "--runtime-intent", "live_trade",
        "--tenant-id", "default",
        "--execution-mode", "ea_bridge",
        "--symbol", "XAUUSD",
        "--timeframe", "M5",
        "--input-path", str(app_dir / "data" / "inbox" / "request.json"),
        "--output-path", str(app_dir / "data" / "outbox" / "signal.json"),
        "--execution-report-path", str(app_dir / "data" / "inbox" / "execution_report.json"),
        "--poll-seconds", "2",
        "--enable-incident-autofix", "true",
    ]


def _spawn_daemon() -> tuple[bool, str]:
    if _is_daemon_running():
        return False, "System already running"

    # 单入口：实盘由 src/pipeline.py 统一编排，不再直接启动 bridge/gold_trader.py --daemon
    pipeline_cmd = _build_live_pipeline_cmd(APP_DIR)

    if getattr(sys, 'frozen', False):
        cmd = [sys.executable, "--auto", "--daemon-pipeline"]
    else:
        cmd = pipeline_cmd

    env = os.environ.copy()
    existing_pythonpath = str(env.get("PYTHONPATH", "") or "").strip()
    app_dir_str = str(APP_DIR)
    if existing_pythonpath:
        env["PYTHONPATH"] = f"{app_dir_str}:{existing_pythonpath}"
    else:
        env["PYTHONPATH"] = app_dir_str

    proc = subprocess.Popen(
        cmd,
        start_new_session=True,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        env=env,
    )
    try:
        PID_FILE.write_text(str(proc.pid), encoding="utf-8")
    except Exception:
        pass
    return True, "Starting gateway..."

def _ensure_daemon_running(force: bool = False):
    if _is_daemon_running():
        return
    if not force and _is_manual_stop_requested():
        return
    _spawn_daemon()


def _is_pid_alive(pid: int) -> bool:
    try:
        p = psutil.Process(pid)
        return p.is_running() and p.status() != psutil.STATUS_ZOMBIE
    except Exception:
        return False


def _read_macro_refresh_status() -> dict:
    if not MACRO_REFRESH_STATUS_FILE.exists():
        return {}
    try:
        data = json.loads(MACRO_REFRESH_STATUS_FILE.read_text(encoding="utf-8"))
        if not isinstance(data, dict):
            return {}
        pid = int(data.get("pid", 0) or 0)
        status = str(data.get("status", "") or "").lower()
        global _MACRO_REFRESH_THREAD
        if status == "running" and _MACRO_REFRESH_THREAD is not None and not _MACRO_REFRESH_THREAD.is_alive():
            data["status"] = "failed"
            data["finished_at"] = datetime.datetime.now().isoformat()
            data["message"] = str(data.get("message", "") or "宏观刷新线程已退出")
            _write_macro_refresh_status(data)
        elif status == "running" and pid > 0 and not _is_pid_alive(pid):
            data["status"] = "failed"
            data["finished_at"] = datetime.datetime.now().isoformat()
            data["message"] = str(data.get("message", "") or "宏观刷新任务已退出")
            data["stale_pid"] = pid
            try:
                MACRO_REFRESH_PID_FILE.unlink(missing_ok=True)
            except Exception:
                pass
            _write_macro_refresh_status(data)
        return data
    except Exception:
        return {}


def _write_macro_refresh_status(payload: dict):
    try:
        MACRO_REFRESH_STATUS_FILE.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
    except Exception:
        pass


def _finish_macro_refresh_status(status: str, message: str, pid: int = 0, **extra):
    payload = _read_macro_refresh_status()
    payload.update(
        {
            "status": status,
            "message": message,
            "pid": int(pid or payload.get("pid", 0) or 0),
            "finished_at": datetime.datetime.now().isoformat(),
        }
    )
    payload.update(extra)
    _write_macro_refresh_status(payload)
    try:
        current_pid = int(payload.get("pid", 0) or 0)
        if current_pid > 0:
            MACRO_REFRESH_PID_FILE.unlink(missing_ok=True)
    except Exception:
        pass


def _macro_refresh_worker(force: bool = True):
    pid = os.getpid()
    try:
        from src.deepseek_agent import run_macro_analysis

        run_macro_analysis(force_refresh=force)
        _finish_macro_refresh_status(
            "completed",
            "宏观方向刷新完成",
            pid=pid,
            force=force,
        )
    except Exception as exc:
        _finish_macro_refresh_status(
            "failed",
            f"宏观方向刷新失败: {exc}",
            pid=pid,
            force=force,
        )


def _spawn_macro_refresh(force: bool = True) -> tuple[bool, str, int]:
    global _MACRO_REFRESH_THREAD
    if _MACRO_REFRESH_THREAD is not None and _MACRO_REFRESH_THREAD.is_alive():
        return False, "宏观刷新任务正在运行", os.getpid()

    pid = os.getpid()
    MACRO_REFRESH_PID_FILE.write_text(str(pid), encoding="utf-8")
    _write_macro_refresh_status(
        {
            "status": "running",
            "force": bool(force),
            "pid": pid,
            "started_at": datetime.datetime.now().isoformat(),
            "message": "宏观方向强制刷新已触发",
        }
    )
    _MACRO_REFRESH_THREAD = threading.Thread(
        target=_macro_refresh_worker,
        args=(bool(force),),
        daemon=True,
        name="macro-refresh-worker",
    )
    _MACRO_REFRESH_THREAD.start()
    return True, "宏观方向强制刷新已触发", pid


def _set_panic_action_state(status: str, message: str, result: Optional[dict] = None):
    _PANIC_ACTION_STATE.update(
        {
            "status": str(status or "idle"),
            "message": str(message or ""),
            "updated_at": datetime.datetime.now().isoformat(),
            "result": dict(result or {}),
        }
    )


def _panic_action_worker():
    try:
        from bridge.trader_api import trigger_panic_close_and_block

        result = trigger_panic_close_and_block(reason="MANUAL_PANIC_GUI", source="gui")
        _set_panic_action_state(
            "completed" if bool(result.get("success", False)) else "failed",
            str(result.get("message", "阻断指令已下发")),
            result=result,
        )
    except Exception as exc:
        _set_panic_action_state("failed", f"阻断执行失败: {exc}", result={})


def _spawn_panic_action() -> tuple[bool, str, dict]:
    global _PANIC_ACTION_THREAD
    if _PANIC_ACTION_THREAD is not None and _PANIC_ACTION_THREAD.is_alive():
        return False, "阻断任务正在执行", dict(_PANIC_ACTION_STATE)

    _set_panic_action_state("running", "紧急阻断已触发，正在后台执行", result={})
    _PANIC_ACTION_THREAD = threading.Thread(
        target=_panic_action_worker,
        daemon=True,
        name="panic-action-worker",
    )
    _PANIC_ACTION_THREAD.start()
    return True, "紧急阻断已触发，正在后台执行", dict(_PANIC_ACTION_STATE)

def _daemon_supervisor_loop():
    while True:
        try:
            _ensure_daemon_running(force=False)
        except Exception:
            pass
        time.sleep(15)


def _training_autopilot_loop():
    while True:
        try:
            _run_training_autopilot_step()
        except Exception:
            pass
        time.sleep(8)


def _run_macro_watchdog_step() -> None:
    try:
        _status = get_status()
    except Exception:
        return
    direction_data = _status.get("agent_direction_data", {})
    if not isinstance(direction_data, dict):
        return
    macro_age_sec = float(direction_data.get("macro_age_sec", 999999.0) or 999999.0)
    degraded = bool(direction_data.get("degraded", False) or direction_data.get("macro_failure", False))
    running = bool(_read_macro_refresh_status().get("status") == "running")
    if running:
        return
    last_trigger_ts = float(_MACRO_WATCHDOG_STATE.get("last_trigger_ts", 0.0) or 0.0)
    if (time.time() - last_trigger_ts) < _MACRO_WATCHDOG_MIN_REFRESH_SEC:
        return
    if degraded or macro_age_sec >= 900.0:
        ok, _msg, _pid = _spawn_macro_refresh(force=True)
        if ok:
            _MACRO_WATCHDOG_STATE["last_trigger_ts"] = time.time()


def _macro_watchdog_loop():
    while True:
        try:
            _run_macro_watchdog_step()
        except Exception:
            pass
        time.sleep(60)


def _should_enable_training_autopilot(
    status: Dict[str, Any],
    approval: Optional[Dict[str, Any]] = None,
) -> bool:
    if bool(status.get("isTraining", False)):
        return True

    approval = approval if isinstance(approval, dict) else get_training_approval_chain()
    decision = approval.get("decision", {}) if isinstance(approval.get("decision", {}), dict) else {}
    recommended_actions = decision.get("recommendedActions", []) if isinstance(decision.get("recommendedActions", []), list) else []
    candidate_value = decision.get("candidateValue", {}) if isinstance(decision.get("candidateValue", {}), dict) else {}
    suggestion_stage = str(approval.get("stage", "NO_SUGGESTION") or "NO_SUGGESTION")
    has_training_output = bool(status.get("currentEpoch", 0)) or (DATA_DIR / "offline" / "best_params.json").exists()
    error_dispatch = _read_training_error_dispatch_state(approval, status)
    dispatch_waiting = str(error_dispatch.get("status", "") or "") == "waiting"
    dispatch_fingerprint = str(error_dispatch.get("fingerprint", "") or "")
    current_fingerprint = ""
    try:
        from src.training_error_dispatch import build_training_error_ticket

        current_fingerprint = str(build_training_error_ticket(approval, status).get("fingerprint", "") or "")
    except Exception:
        current_fingerprint = ""
    waiting_same_invalid_error = (
        suggestion_stage == "NO_SUGGESTION"
        and has_training_output
        and not bool(candidate_value.get("ready_for_manual_review", True))
        and dispatch_waiting
        and bool(dispatch_fingerprint)
        and dispatch_fingerprint == current_fingerprint
    )

    if waiting_same_invalid_error:
        return False

    if suggestion_stage == "NO_SUGGESTION" and has_training_output:
        return True
    if suggestion_stage == "PENDING_APPROVAL" and "approve_to_shadow" in recommended_actions:
        return True
    if any(
        isinstance(action, str)
        and (action.startswith("repair:") or action == "rebuild_shadow_params")
        for action in recommended_actions
    ):
        return True
    return False


def _kick_training_autopilot() -> None:
    if not _GUI_BACKGROUND_AUTOPILOT_ENABLED:
        return
    # Startup hooks can be skipped by some launch paths; make the worker self-healing.
    if bool(getattr(_TRAINING_AUTOPILOT_CONTEXT, "active", False)):
        return
    global _TRAINING_AUTOPILOT_THREAD
    try:
        if _TRAINING_AUTOPILOT_THREAD is None or not _TRAINING_AUTOPILOT_THREAD.is_alive():
            _TRAINING_AUTOPILOT_THREAD = threading.Thread(
                target=_training_autopilot_loop,
                daemon=True,
                name="training-autopilot",
            )
            _TRAINING_AUTOPILOT_THREAD.start()
    except Exception:
        pass

def _startup_supervisor():
    # GUI 默认不再自带后台认知/训练循环，避免控制面把整机资源顶满。
    if _GUI_BACKGROUND_AUTOPILOT_ENABLED:
        threading.Thread(target=_training_autopilot_loop, daemon=True, name="training-autopilot").start()
        global _MACRO_WATCHDOG_THREAD
        if _MACRO_WATCHDOG_THREAD is None or not _MACRO_WATCHDOG_THREAD.is_alive():
            _MACRO_WATCHDOG_THREAD = threading.Thread(
                target=_macro_watchdog_loop,
                daemon=True,
                name="macro-watchdog",
            )
            _MACRO_WATCHDOG_THREAD.start()
    try:
        from bridge.trader_api import preload_runtime_bindings
        preload_runtime_bindings()
    except Exception:
        pass
    _ensure_daemon_running(force=False)
    try:
        _schedule_status_cache_refresh()
    except Exception:
        pass
    for cache, builder, thread_name in (
        (_STRATEGY_MARKETPLACE_CACHE, _build_strategy_workbench_marketplace_response, "gui-strategy-marketplace-prewarm"),
        (_RUNTIME_INSTANCES_CACHE, _build_runtime_instances_response, "gui-runtime-instances-prewarm"),
        (_STRATEGY_WORKBENCH_CACHE, _build_strategy_workbench_response, "gui-strategy-workbench-prewarm"),
    ):
        try:
            _schedule_aux_payload_refresh(cache, builder, thread_name)
        except Exception:
            pass

@app.get("/api/price_probe")
def price_probe():
    """
    本地模型价格探活:对比 MT5 实时 vs yfinance 参考
    返回: mt5_price, ref_price, divergence, status
    """
    from bridge.health_monitor import probe_price_freshness
    ok, detail = probe_price_freshness()
    return {
        "ok": ok,
        "detail": detail,
        "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ")
    }

@app.get("/api/ping")
async def ping():
    """Lightweight health endpoint for launcher/keepalive checks."""
    return {"ok": True, "ts": time.time()}


def _build_lightweight_payload_from_status(
    status_payload: Optional[dict[str, Any]],
    *,
    cache_age_sec: Optional[float] = None,
    refreshing: bool = False,
    now: Optional[float] = None,
) -> dict[str, Any]:
    payload = dict(status_payload) if isinstance(status_payload, dict) else {}
    now_ts = float(now if now is not None else time.time())
    mt5_online = bool(payload.get("mt5Online", False))
    bridge_ok = bool(payload.get("bridgeOk", False))
    is_running = bool(payload.get("isRunning", True))
    light = str(payload.get("light") or ("GREEN" if (is_running and mt5_online and bridge_ok) else ("YELLOW" if mt5_online else "RED")))
    mt5_latency = -1
    system_stats = payload.get("system_stats")
    if isinstance(system_stats, dict):
        try:
            mt5_latency = int(system_stats.get("mt5_latency", -1) or -1)
        except Exception:
            mt5_latency = -1
    cache_age = None if cache_age_sec is None else round(max(0.0, float(cache_age_sec)), 3)
    return {
        "ok": True,
        "backendStarted": True,
        "isRunning": is_running,
        "mt5Online": mt5_online,
        "bridgeOk": bridge_ok,
        "light": light,
        "mt5Latency": mt5_latency,
        "priceSource": str(payload.get("priceSource", "unknown") or "unknown"),
        "priceAge": float(payload.get("priceAge", 0.0) or 0.0),
        "statusRefreshing": bool(refreshing),
        "statusCacheAgeSec": cache_age,
        "warmingUp": not bool(payload),
        "ts": now_ts,
    }


def _store_lightweight_status_payload(payload: dict[str, Any], now: Optional[float] = None) -> dict[str, Any]:
    cache_ts = float(now if now is not None else time.time())
    _LIGHTWEIGHT_STATUS_CACHE["ts"] = cache_ts
    _LIGHTWEIGHT_STATUS_CACHE["value"] = payload
    return payload


def _refresh_aux_payload_cache_worker(cache: dict[str, Any], builder) -> None:
    try:
        payload = builder()
        if isinstance(payload, dict):
            with _AUX_RESPONSE_CACHE_LOCK:
                cache["ts"] = time.time()
                cache["value"] = payload
    finally:
        with _AUX_RESPONSE_CACHE_LOCK:
            cache["refreshing"] = False
            cache["thread"] = None


def _schedule_aux_payload_refresh(cache: dict[str, Any], builder, thread_name: str) -> bool:
    with _AUX_RESPONSE_CACHE_LOCK:
        if bool(cache.get("refreshing", False)):
            return False
        t = threading.Thread(
            target=_refresh_aux_payload_cache_worker,
            args=(cache, builder),
            daemon=True,
            name=thread_name,
        )
        cache["refreshing"] = True
        cache["thread"] = t
        t.start()
        return True


def _get_cached_aux_payload(
    cache: dict[str, Any],
    ttl_sec: float,
    builder,
    thread_name: str,
    warming_payload: dict[str, Any],
    *,
    force_refresh: bool = False,
) -> dict[str, Any]:
    if force_refresh:
        payload = builder()
        if isinstance(payload, dict):
            with _AUX_RESPONSE_CACHE_LOCK:
                cache["ts"] = time.time()
                cache["value"] = payload
        return payload
    cache_now = time.time()
    with _AUX_RESPONSE_CACHE_LOCK:
        cached_payload = cache.get("value")
        cache_age = cache_now - float(cache.get("ts", 0.0) or 0.0)
        refreshing = bool(cache.get("refreshing", False))
    if isinstance(cached_payload, dict) and cache_age < ttl_sec:
        return cached_payload
    if isinstance(cached_payload, dict):
        if not refreshing:
            _schedule_aux_payload_refresh(cache, builder, thread_name)
        stale_payload = dict(cached_payload)
        stale_payload["stale"] = True
        stale_payload["cacheAgeSec"] = round(cache_age, 1)
        return stale_payload
    if not refreshing:
        _schedule_aux_payload_refresh(cache, builder, thread_name)
    fallback_payload = dict(warming_payload)
    fallback_payload["warming"] = True
    fallback_payload["stale"] = True
    return fallback_payload


def _resolve_daemon_running() -> bool:
    daemon_running = False
    if PID_FILE.exists():
        try:
            pid = int(PID_FILE.read_text().strip())
            if is_process_running(pid):
                daemon_running = True
            else:
                PID_FILE.unlink(missing_ok=True)
        except Exception:
            pass
    if not daemon_running:
        fallback_pid = _find_runtime_daemon_pid()
        if fallback_pid:
            daemon_running = True
            try:
                PID_FILE.write_text(str(fallback_pid), encoding="utf-8")
            except Exception:
                pass
    return daemon_running


def _probe_bridge_runtime(
    *,
    daemon_running: bool,
    mt5_online: bool,
    price_source: str = "",
) -> tuple[bool, Optional[pathlib.Path], Optional[pathlib.Path], Optional[float]]:
    bridge_ok = False
    mt5_dir = None
    sf = None
    diff = None
    try:
        mt5_dir = config_manager.get_mt5_dir()
        if mt5_dir:
            sf = _resolve_terminal_status_file(mt5_dir)
            if sf and sf.exists():
                mtime = sf.stat().st_mtime
                diff = time.time() - mtime
                bridge_ok = diff < 180  # 3分钟内有更新都视为桥接在线，减少瞬时抖动误报
    except Exception:
        pass
    if not bridge_ok and daemon_running and mt5_online and str(price_source or "").lower() in {"mt5", "stale"}:
        # terminal_status 读取偶发抖动时，用价格源兜底，避免 UI 频繁误报“离线”。
        bridge_ok = True
    return bridge_ok, mt5_dir, sf, diff


def _build_lightweight_status_payload() -> dict[str, Any]:
    now = time.time()
    with _STATUS_CACHE_LOCK:
        maybe_cached = _STATUS_RESPONSE_CACHE.get("value")
        cached_status = dict(maybe_cached) if isinstance(maybe_cached, dict) else None
        cached_status_ts = float(_STATUS_RESPONSE_CACHE.get("ts", 0.0) or 0.0)
        refreshing = bool(_STATUS_RESPONSE_CACHE.get("refreshing", False))
    cache_age = (now - cached_status_ts) if cached_status_ts > 0 else None
    if cached_status is None and not refreshing:
        _schedule_status_cache_refresh()
    return _build_lightweight_payload_from_status(
        cached_status,
        cache_age_sec=cache_age,
        refreshing=refreshing,
        now=now,
    )


@app.get("/api/lightweight-status")
def get_lightweight_status():
    now = time.time()
    cached = _LIGHTWEIGHT_STATUS_CACHE.get("value")
    cached_ts = float(_LIGHTWEIGHT_STATUS_CACHE.get("ts", 0.0) or 0.0)
    if isinstance(cached, dict) and (now - cached_ts) < _LIGHTWEIGHT_STATUS_CACHE_TTL_SEC:
        return cached
    payload = _build_lightweight_status_payload()
    return _store_lightweight_status_payload(payload, now=now)


def _build_status_payload():
    # 尝试读取行情
    bid, ask, spr, price_source, price_age = get_price()

    # 尝试读取 agent 状态（SQLite 锁冲突时快速降级，避免接口超时被前端判定为“后端未启动”）
    state = _safe_load_state(timeout_sec=0.8)

    # 检测守护进程
    daemon_running = _resolve_daemon_running()

    mt5_online = is_mt5_running()

    # 检查 EA bridge 状态
    bridge_ok, mt5_dir, sf, diff = _probe_bridge_runtime(
        daemon_running=daemon_running,
        mt5_online=mt5_online,
        price_source=str(price_source or ""),
    )

    # 指示灯逻辑:运行且 MT5 在线为绿,否则黄/红
    light = "GREEN" if (daemon_running and mt5_online and bridge_ok) else ("YELLOW" if mt5_online else "RED")

    # 获取当前 Agent 的宏观方向定调
    try:
        agent_dir_current, agent_dir_data = get_agent_direction()
    except Exception:
        agent_dir_current = "NEUTRAL"
        agent_dir_data = {}
    try:
        direction_snapshot = load_decision_snapshot()
    except Exception:
        direction_snapshot = {}
    snapshot_macro = (
        direction_snapshot.get("macro", {})
        if isinstance(direction_snapshot.get("macro", {}), dict)
        else {}
    )
    snapshot_contract = (
        direction_snapshot.get("contract", {})
        if isinstance(direction_snapshot.get("contract", {}), dict)
        else {}
    )
    if snapshot_macro:
        agent_dir_current = str(
            snapshot_macro.get("binding_direction", agent_dir_current) or agent_dir_current
        ).upper()
        base_direction_data = dict(agent_dir_data) if isinstance(agent_dir_data, dict) else {}
        base_direction_data.update(
            {
                "direction": str(snapshot_macro.get("direction", agent_dir_current) or agent_dir_current).upper(),
                "binding_direction": str(snapshot_macro.get("binding_direction", agent_dir_current) or agent_dir_current).upper(),
                "effective_direction": str(snapshot_macro.get("effective_direction", "NEUTRAL") or "NEUTRAL").upper(),
                "allowed_side": str(snapshot_macro.get("allowed_side", "BOTH") or "BOTH").upper(),
                "state": str(snapshot_macro.get("state", "UNKNOWN") or "UNKNOWN").upper(),
                "lineage": (
                    snapshot_contract.get("lineage", {})
                    if isinstance(snapshot_contract.get("lineage", {}), dict)
                    else {}
                ),
            }
        )
        agent_dir_data = base_direction_data
    technical_trend_bias, technical_trend_data = _get_technical_trend_snapshot()

    # 获取 CPU / RAM
    try:
        cpu_percent = psutil.cpu_percent(interval=0.0)
        ram = psutil.virtual_memory()
        ram_used_mb = int(ram.used / (1024 * 1024))
        ram_percent = ram.percent
        process_rss_mb = round(psutil.Process(os.getpid()).memory_info().rss / 1024 / 1024, 1)
    except:
        cpu_percent = 0.0
        ram_used_mb = 0
        ram_percent = 0.0
        process_rss_mb = 0.0

    # 计算 MT5 延迟
    mt5_latency = -1
    try:
        if mt5_dir and sf and sf.exists() and diff is not None:
            mt5_latency = int(diff * 1000) if diff > 0 else 0
    except:
        pass

    # ── 从本地数据库(sqlite)聚合今日盈亏（加缓存，防止高频轮询拉高 CPU） ──
    today_stats = {"trades": 0, "pnl": 0.0, "wins": 0, "losses": 0, "win_rate": 0.0, "avg_win": 0.0, "avg_loss": 0.0}
    try:
        db_path = config_manager.get_data_dir() / "bridge" / "state" / "agent_state.db"
        today_stats = _read_today_stats_cached(db_path)
    except Exception:
        pass

    today_trades = today_stats["trades"]
    today_pnl = today_stats["pnl"]
    today_wins = today_stats["wins"]
    today_losses = today_stats["losses"]
    today_win_rate = today_stats["win_rate"]
    today_avg_win = today_stats["avg_win"]
    today_avg_loss = today_stats["avg_loss"]

    blackboard = _get_blackboard_data()
    blackboard_preview = _compact_blackboard_for_status(blackboard)
    micro_sniper = blackboard.get("Micro_Sniper", {}) if isinstance(blackboard, dict) else {}

    # ── 读取当前的软挂单队列 (Soft Limits) 和防守环境 ──
    soft_limits = state.get("soft_limits", {})
    active_soft_limits = []
    now_ts = time.time()
    for sig_id, order in soft_limits.items():
        if now_ts < order.get("expire", 0):
            # 计算剩余有效分钟数
            remain_mins = int((order["expire"] - now_ts) / 60)
            active_soft_limits.append({
                "id": sig_id,
                "side": order.get("side"),
                "limit_price": order.get("limit_price"),
                "vol": order.get("vol"),
                "remain_mins": max(1, remain_mins),
                "reason": order.get("reason", ""),
                "atr_offset_usd": order.get("atr_offset_usd"),
                "spread_floor_usd": order.get("spread_floor_usd"),
                "min_offset_usd": order.get("min_offset_usd"),
                "max_offset_usd": order.get("max_offset_usd"),
                "final_offset_usd": order.get("final_offset_usd"),
            })

    regime_current = micro_sniper.get("current_regime") or state.get("regime", "UNKNOWN")
    if "is_defensive_env" in micro_sniper:
        is_defensive_env = bool(micro_sniper.get("is_defensive_env"))
    else:
        is_defensive_env = regime_current in ("RANGING", "UNCERTAIN")

    latest_risk_block = _read_latest_risk_block_event()
    final_reject = state.get("last_final_reject", {}) if isinstance(state.get("last_final_reject", {}), dict) else {}
    budget = state.get("opportunity_budget", {}) if isinstance(state.get("opportunity_budget", {}), dict) else {}
    hour_key = datetime.datetime.now().strftime("%Y%m%d%H")
    overlap_budget = budget.get(hour_key, {}) if isinstance(budget.get(hour_key, {}), dict) else {}
    overlap_budget_target = int(config_manager.get_effective_param("overlap_min_attempts_per_hour", 2) or 2)
    enriched_positions = _enrich_positions_with_attribution(
        state.get("positions_all", []) if isinstance(state.get("positions_all", []), list) else []
    )
    _, terminal_status_payload = _read_terminal_status_snapshot(mt5_dir)
    terminal_positions = _extract_terminal_status_positions(terminal_status_payload)
    terminal_positions_count = _terminal_status_positions_count(terminal_status_payload)
    if terminal_positions_count == 0:
        if enriched_positions:
            _emit_position_status_alert(
                "terminal_flattened_state_positions",
                state_position_open=bool(state.get("position_open", False)),
                state_positions_count=len(enriched_positions),
                position_ticket=int(state.get("position_ticket", 0) or 0),
            )
        enriched_positions = []
    elif not enriched_positions and terminal_positions:
        enriched_positions = _enrich_positions_with_attribution(terminal_positions)
        _emit_position_status_alert(
            "terminal_positions_used_as_status_fallback",
            terminal_positions_count=len(terminal_positions),
            state_position_open=bool(state.get("position_open", False)),
            state_positions_count=0,
        )
    elif (
        terminal_positions_count is not None
        and terminal_positions_count > 0
        and len(enriched_positions) != terminal_positions_count
    ):
        _emit_position_status_alert(
            "position_snapshot_count_mismatch",
            terminal_positions_count=terminal_positions_count,
            state_positions_count=len(enriched_positions),
            state_position_open=bool(state.get("position_open", False)),
        )
    position_side = state.get("position_side")
    position_entry = state.get("position_entry", 0)
    if enriched_positions:
        first_position = enriched_positions[0]
        if not position_side:
            position_side = first_position.get("side")
        if not position_entry:
            position_entry = first_position.get("entry", 0)

    payload = {
        "isRunning": daemon_running,
        "mt5Online": mt5_online,
        "bridgeOk": bridge_ok,
        "bid": bid or 0,
        "ask": ask or 0,
        "spread": spr or 0,
        "priceSource": price_source or "unknown",
        "priceAge": price_age or 0,
        "riskLevel": state.get("risk_level", "平衡"),
        "light": light,
        "agent_dir": agent_dir_current,
        "agent_direction_data": agent_dir_data,
        "technical_trend_bias": technical_trend_bias,
        "technical_trend_data": technical_trend_data,
        "is_defensive": is_defensive_env,
        "regime": regime_current,
        "risk_block": latest_risk_block,
        "final_reject": final_reject,
        "overlap_budget": overlap_budget,
        "overlap_budget_target": overlap_budget_target,
        "tradesCount": state.get("total_trades", 0),
        "todayTrades": today_trades,
        "todayPnl": round(today_pnl, 2),
        "todayWinRate": today_win_rate,
        "todayAvgWin": today_avg_win,
        "todayAvgLoss": today_avg_loss,
        "todayWins": today_wins,
        "todayLosses": today_losses,
        "positionOpen": bool(enriched_positions),
        "positionSide": position_side,
        "positionEntry": position_entry,
        "positions": enriched_positions,
        "account_total_loss_stop_pct": float(state.get("account_total_loss_stop_threshold_pct", 30.0) or 30.0),
        "account_total_loss_current_pct": float(state.get("account_total_loss_current_pct", 0.0) or 0.0),
        "account_total_loss_stop_active": bool(state.get("account_total_loss_stop_active", False)),
        "account_total_loss_stop_source": str(state.get("account_total_loss_stop_source", "NONE") or "NONE"),
        "soft_limits": active_soft_limits,  # 暴露出软挂单给前端雷达
        "system_stats": {
            "cpu_percent": cpu_percent,
            "ram_used_mb": ram_used_mb,
            "ram_percent": ram_percent,
            "process_rss_mb": process_rss_mb,
            "memory_warning": bool(ram_percent >= 85.0 or process_rss_mb >= 1024.0),
            "mt5_latency": mt5_latency
        },
        "blackboard": blackboard_preview
    }
    return payload


def _refresh_status_cache_worker() -> None:
    refresh_ts = time.time()
    try:
        payload = _build_status_payload()
        terminal_status_mtime = 0.0
        terminal_status_size = 0
        try:
            mt5_dir = config_manager.get_mt5_dir()
            if mt5_dir:
                sf = _resolve_terminal_status_file(mt5_dir)
                if sf and sf.exists():
                    st = sf.stat()
                    terminal_status_mtime = float(st.st_mtime)
                    terminal_status_size = int(st.st_size)
        except Exception:
            terminal_status_mtime = 0.0
            terminal_status_size = 0
        with _STATUS_CACHE_LOCK:
            _STATUS_RESPONSE_CACHE["ts"] = refresh_ts
            _STATUS_RESPONSE_CACHE["value"] = payload
            _STATUS_RESPONSE_CACHE["terminal_status_mtime"] = terminal_status_mtime
            _STATUS_RESPONSE_CACHE["terminal_status_size"] = terminal_status_size
        _store_lightweight_status_payload(
            _build_lightweight_payload_from_status(payload, cache_age_sec=0.0, refreshing=False, now=refresh_ts),
            now=refresh_ts,
        )
    finally:
        with _STATUS_CACHE_LOCK:
            _STATUS_RESPONSE_CACHE["refreshing"] = False
            _STATUS_RESPONSE_CACHE["thread"] = None
            _STATUS_RESPONSE_CACHE["refresh_started_ts"] = 0.0


def _schedule_status_cache_refresh() -> bool:
    with _STATUS_CACHE_LOCK:
        if bool(_STATUS_RESPONSE_CACHE.get("refreshing", False)):
            return False
        t = threading.Thread(
            target=_refresh_status_cache_worker,
            daemon=True,
            name="gui-status-refresh",
        )
        _STATUS_RESPONSE_CACHE["refreshing"] = True
        _STATUS_RESPONSE_CACHE["thread"] = t
        _STATUS_RESPONSE_CACHE["refresh_started_ts"] = time.time()
        t.start()
        return True


def _build_status_fallback_payload() -> dict[str, Any]:
    return {
        "isRunning": True,
        "mt5Online": False,
        "bridgeOk": False,
        "bid": 0,
        "ask": 0,
        "spread": 0,
        "priceSource": "warming_up",
        "priceAge": 0,
        "riskLevel": "平衡",
        "light": "YELLOW",
        "agent_dir": "NEUTRAL",
        "agent_direction_data": {},
        "technical_trend_bias": "UNKNOWN",
        "technical_trend_data": {},
        "is_defensive": True,
        "regime": "UNKNOWN",
        "risk_block": {},
        "final_reject": {},
        "overlap_budget": {},
        "overlap_budget_target": 0,
        "tradesCount": 0,
        "todayTrades": 0,
        "todayPnl": 0.0,
        "todayWinRate": 0.0,
        "todayAvgWin": 0.0,
        "todayAvgLoss": 0.0,
        "todayWins": 0,
        "todayLosses": 0,
        "positionOpen": False,
        "positionSide": None,
        "positionEntry": 0,
        "positions": [],
        "account_total_loss_stop_pct": 30.0,
        "account_total_loss_current_pct": 0.0,
        "account_total_loss_stop_active": False,
        "account_total_loss_stop_source": "NONE",
        "soft_limits": [],
        "system_stats": {
            "cpu_percent": 0.0,
            "ram_used_mb": 0,
            "ram_percent": 0.0,
            "process_rss_mb": 0.0,
            "memory_warning": False,
            "mt5_latency": -1,
        },
        "blackboard": {},
        "warmingUp": True,
    }


@app.get("/api/status")
def get_status():
    """获取整体运行状态与行情"""
    cache_now = time.time()
    cached_payload = None
    cache_age = 0.0
    refreshing = False
    cached_terminal_status_mtime = 0.0
    cached_terminal_status_size = 0
    schedule_refresh_after_unlock = False
    with _STATUS_CACHE_LOCK:
        cached_payload = _STATUS_RESPONSE_CACHE.get("value")
        cache_age = cache_now - float(_STATUS_RESPONSE_CACHE.get("ts", 0.0) or 0.0)
        refreshing = bool(_STATUS_RESPONSE_CACHE.get("refreshing", False))
        cached_terminal_status_mtime = float(_STATUS_RESPONSE_CACHE.get("terminal_status_mtime", 0.0) or 0.0)
        cached_terminal_status_size = int(_STATUS_RESPONSE_CACHE.get("terminal_status_size", 0) or 0)
        if refreshing:
            thread = _STATUS_RESPONSE_CACHE.get("thread")
            started_ts = float(_STATUS_RESPONSE_CACHE.get("refresh_started_ts", 0.0) or 0.0)
            thread_alive = bool(getattr(thread, "is_alive", lambda: False)()) if thread is not None else False
            stuck = started_ts > 0 and (cache_now - started_ts) >= 60.0
            if (thread is not None and not thread_alive) or stuck:
                _STATUS_RESPONSE_CACHE["refreshing"] = False
                _STATUS_RESPONSE_CACHE["thread"] = None
                _STATUS_RESPONSE_CACHE["refresh_started_ts"] = 0.0
                refreshing = False
                schedule_refresh_after_unlock = True
        if cached_payload is not None and cache_age < _STATUS_CACHE_TTL_SEC:
            if not schedule_refresh_after_unlock:
                position_open = False
                try:
                    position_open = bool(cached_payload.get("positionOpen", False)) or bool(cached_payload.get("positions", []))
                except Exception:
                    position_open = False
                if not position_open:
                    return cached_payload

    if schedule_refresh_after_unlock:
        _schedule_status_cache_refresh()

    if cached_payload is not None and cache_age < _STATUS_CACHE_TTL_SEC:
        terminal_status_changed = False
        sf = None
        try:
            mt5_dir = config_manager.get_mt5_dir()
            if mt5_dir:
                sf = _resolve_terminal_status_file(mt5_dir)
            if sf and sf.exists():
                st = sf.stat()
                terminal_status_changed = (
                    float(st.st_mtime) != cached_terminal_status_mtime
                    or int(st.st_size) != cached_terminal_status_size
                )
        except Exception:
            terminal_status_changed = False
            sf = None

        if terminal_status_changed:
            _schedule_status_cache_refresh()
            try:
                if sf and sf.exists():
                    _, st_data = _read_terminal_status_snapshot(mt5_dir)
                    terminal_positions = _extract_terminal_status_positions(st_data)
                    terminal_positions_count = _terminal_status_positions_count(st_data)
                    if terminal_positions_count == 0:
                        patched = dict(cached_payload) if isinstance(cached_payload, dict) else {}
                        patched["positionOpen"] = False
                        patched["positionSide"] = None
                        patched["positionEntry"] = 0
                        patched["positions"] = []
                        return patched
                    if terminal_positions:
                        patched = dict(cached_payload) if isinstance(cached_payload, dict) else {}
                        patched_positions = _enrich_positions_with_attribution(terminal_positions)
                        patched["positionOpen"] = True
                        patched["positionSide"] = patched_positions[0].get("side")
                        patched["positionEntry"] = patched_positions[0].get("entry", 0)
                        patched["positions"] = patched_positions
                        return patched
            except Exception:
                pass

        return cached_payload

    with _STATUS_CACHE_LOCK:
        cached_payload = _STATUS_RESPONSE_CACHE.get("value")
        refreshing = bool(_STATUS_RESPONSE_CACHE.get("refreshing", False))
        if not refreshing:
            t = threading.Thread(
                target=_refresh_status_cache_worker,
                daemon=True,
                name="gui-status-refresh",
            )
            _STATUS_RESPONSE_CACHE["refreshing"] = True
            _STATUS_RESPONSE_CACHE["thread"] = t
            _STATUS_RESPONSE_CACHE["refresh_started_ts"] = cache_now
            t.start()
        if cached_payload is not None:
            return cached_payload
    return _build_status_fallback_payload()


_HEAP_SNAPSHOT_START = None  # (timestamp, tracemalloc_snapshot)


def _deep_getsizeof(obj, seen=None):
    """递归计算对象及其引用子树的实际字节数（避免重复计算循环引用）"""
    if seen is None:
        seen = set()
    obj_id = id(obj)
    if obj_id in seen:
        return 0
    seen.add(obj_id)
    size = sys.getsizeof(obj)
    if isinstance(obj, dict):
        size += sum(_deep_getsizeof(k, seen) + _deep_getsizeof(v, seen) for k, v in obj.items())
    elif isinstance(obj, (list, tuple, set, frozenset)):
        size += sum(_deep_getsizeof(item, seen) for item in obj)
    elif isinstance(obj, (str, bytes, bytearray)):
        pass  # getsizeof already captured string/bytes overhead
    elif hasattr(obj, '__dict__'):
        size += _deep_getsizeof(obj.__dict__, seen)
    return size


def get_memory_debug(deep: int = 0):
    """内存诊断（默认轻量）。deep=1 时才执行深度扫描，避免拖垮 18790。"""
    import gc

    result = {
        "process_rss_mb": 0,
        "python_heap_mb": None,
        "caches": {},
        "blackboard_mb": None,
        "file_sizes": {},
        "gc_stats": None,
        "mode": "deep" if deep else "light",
    }
    try:
        import psutil
        p = psutil.Process(os.getpid())
        result["process_rss_mb"] = round(p.memory_info().rss / 1024 / 1024, 1)
    except Exception:
        pass
    result["gc_stats"] = gc.get_stats()
    try:
        cache_keys = [
            "_MT5_DAILY_CACHE", "_STATUS_RESPONSE_CACHE", "_TODAY_STATS_CACHE",
            "_RUNTIME_OBSERVABILITY_CACHE", "_GOAL_TARGETS_CACHE",
            "_PANIC_ACTION_STATE", "_DIRECTION_V2_CACHE",
            "_MACRO_REFRESH_THREAD",
        ]
        for k in cache_keys:
            if k in globals():
                try:
                    if deep:
                        sz = _deep_getsizeof(globals()[k])
                        result["caches"][k] = f"{sz / 1024 / 1024:.2f} MB"
                    else:
                        sz = sys.getsizeof(globals()[k])
                        result["caches"][k] = f"{sz / 1024:.1f} KB"
                except Exception as ex:
                    result["caches"][k] = f"error: {ex}"
        # 关键文件尺寸
        for fname, rel in [
            ("blackboard_state.json", "blackboard_state.json"),
            ("agent_state.json", "bridge/state/agent_state.json"),
            ("execution_state.json", "execution_state.json"),
            ("position_lifecycle.json", "position_lifecycle.json"),
            ("agent_decision_contract.json", "agent_decision_contract.json"),
        ]:
            fpath = DATA_DIR / rel
            if fpath.exists():
                result["file_sizes"][fname] = f"{fpath.stat().st_size / 1024:.1f} KB"
        if deep:
            try:
                gc.collect()
                result["python_heap_mb"] = round(sum(sys.getsizeof(o) for o in gc.get_objects()) / 1024 / 1024, 1)
            except Exception as ex:
                result["python_heap_mb"] = f"error: {ex}"
            try:
                global_blackboard = _load_global_blackboard()
                bb = global_blackboard._ensure() if global_blackboard and hasattr(global_blackboard, "_ensure") else None
                if bb is None and global_blackboard is not None:
                    bb = getattr(global_blackboard, "_instance", None)
                if bb is not None:
                    result["blackboard_mb"] = round(_deep_getsizeof(vars(bb)) / 1024 / 1024, 3)
            except Exception as ex:
                result["blackboard_mb"] = f"error: {ex}"
    except Exception as e:
        result["error"] = str(e)
    return result

def _get_blackboard_data():
    try:
        bb_file = _resolve_runtime_root_artifact("blackboard_state.json")
        now = time.time()
        if bb_file is None or not bb_file.exists():
            return None
        try:
            stat = bb_file.stat()
            mtime = float(stat.st_mtime)
            size = int(stat.st_size)
        except Exception:
            mtime = 0.0
            size = 0
        cached_value = _BLACKBOARD_CACHE.get("value")
        if (
            cached_value is not None
            and (now - float(_BLACKBOARD_CACHE.get("ts", 0.0))) < 2.0
            and str(_BLACKBOARD_CACHE.get("file", "")) == str(bb_file)
            and float(_BLACKBOARD_CACHE.get("mtime", 0.0)) == mtime
            and int(_BLACKBOARD_CACHE.get("size", 0)) == size
        ):
            return cached_value
        # A very large blackboard file can stall the status route; keep serving cached value.
        if size > 2 * 1024 * 1024 and cached_value is not None:
            return cached_value
        import json
        data = json.loads(bb_file.read_text())
        _BLACKBOARD_CACHE.update({
            "ts": now,
            "file": str(bb_file),
            "mtime": mtime,
            "size": size,
            "value": data,
        })
        return data
    except Exception:
        pass
    return None


def _clean_str_list(values: Any) -> list[str]:
    if not isinstance(values, list):
        return []
    return [str(item).strip() for item in values if str(item).strip()]


def _first_non_empty_str(*values: Any) -> str:
    for value in values:
        text = str(value or "").strip()
        if text:
            return text
    return ""


def _strategy_name_from_catalog(catalog: dict[str, str], strategy_id: str, default: str = "未知策略") -> str:
    normalized = str(strategy_id or "").strip()
    if not normalized:
        return default
    return str(catalog.get(normalized) or normalized)


def _normalize_strategy_projection(
    *,
    catalog: dict[str, str],
    effective_payload: dict[str, Any],
    runtime_active: dict[str, Any],
    runtime_scheduler: dict[str, Any],
    scheduler_fallback: dict[str, Any],
    config_revision: str = "",
    effective_updated_at: Optional[float] = None,
    loaded_set: Optional[set[str]] = None,
) -> dict[str, Any]:
    runtime_snapshot_source = bool(
        runtime_scheduler
        and (
            _first_non_empty_str(
                runtime_scheduler.get("snapshot_id"),
                runtime_scheduler.get("generated_at"),
            )
            or isinstance(runtime_scheduler.get("source_versions", {}), dict)
        )
    )
    configured_strategy_id = _first_non_empty_str(
        effective_payload.get("strategy_type"),
        runtime_active.get("strategy_id"),
        runtime_scheduler.get("selected_strategy_id"),
        scheduler_fallback.get("selected_strategy_id"),
        "ema_macd",
    )
    configured_source = (
        "effective_params"
        if _first_non_empty_str(effective_payload.get("strategy_type"))
        else "strategy_runtime_status"
        if _first_non_empty_str(runtime_active.get("strategy_id"))
        else "blackboard_scheduler"
        if _first_non_empty_str(runtime_scheduler.get("selected_strategy_id"))
        else "effective_runtime_scheduler"
        if _first_non_empty_str(scheduler_fallback.get("selected_strategy_id"))
        else "default"
    )
    configured_name = _strategy_name_from_catalog(catalog, configured_strategy_id)

    runtime_active_strategy_ids = _clean_str_list(
        runtime_scheduler.get("active_strategy_ids")
        or runtime_active.get("strategy_ids")
    )
    runtime_active_instance_ids = _clean_str_list(
        runtime_scheduler.get("active_instance_ids")
        or runtime_active.get("instance_ids")
    )
    runtime_selected_strategy_id = _first_non_empty_str(
        runtime_scheduler.get("selected_strategy_id"),
        runtime_active.get("selected_strategy_id"),
    )
    if not runtime_selected_strategy_id and runtime_active_strategy_ids:
        runtime_selected_strategy_id = runtime_active_strategy_ids[0]
    runtime_selected_instance_id = _first_non_empty_str(
        runtime_scheduler.get("selected_instance_id"),
        runtime_active.get("selected_instance_id"),
    )
    if not runtime_selected_instance_id and runtime_active_instance_ids:
        runtime_selected_instance_id = runtime_active_instance_ids[0]
    runtime_mode_raw = _first_non_empty_str(
        runtime_scheduler.get("mode"),
        runtime_active.get("mode"),
    )
    runtime_arbiter_raw = _first_non_empty_str(
        runtime_scheduler.get("arbiter"),
        runtime_active.get("arbiter"),
    )
    runtime_reason_raw = _first_non_empty_str(
        runtime_scheduler.get("reason"),
        runtime_active.get("reason"),
    )
    runtime_route_payload = (
        runtime_scheduler.get("route", {})
        if isinstance(runtime_scheduler.get("route", {}), dict)
        else {}
    )
    runtime_direction_guard = (
        runtime_scheduler.get("direction_guard", {})
        if isinstance(runtime_scheduler.get("direction_guard", {}), dict)
        else runtime_route_payload.get("direction_guard", {})
        if isinstance(runtime_route_payload.get("direction_guard", {}), dict)
        else {}
    )
    runtime_candidate_strategy_ids = _clean_str_list(
        runtime_scheduler.get("candidate_strategy_ids")
    )
    runtime_router_weights = (
        runtime_scheduler.get("router_weights")
        if isinstance(runtime_scheduler.get("router_weights", {}), dict)
        else runtime_route_payload.get("weights", {})
        if isinstance(runtime_route_payload.get("weights", {}), dict)
        else {}
    )
    if not runtime_router_weights and isinstance(runtime_route_payload.get("router_weights", {}), dict):
        runtime_router_weights = runtime_route_payload.get("router_weights", {})
    runtime_strategy_weights = (
        runtime_scheduler.get("strategy_weights")
        if isinstance(runtime_scheduler.get("strategy_weights", {}), dict)
        else {}
    )
    runtime_instance_weights = (
        runtime_scheduler.get("instance_weights")
        if isinstance(runtime_scheduler.get("instance_weights", {}), dict)
        else {}
    )
    runtime_route_family = _first_non_empty_str(
        runtime_scheduler.get("route_family"),
        runtime_route_payload.get("route_family"),
    )
    runtime_allowed_side = _first_non_empty_str(
        runtime_scheduler.get("allowed_side"),
        runtime_route_payload.get("allowed_side"),
    )
    runtime_source = (
        "runtime_scheduler_snapshot"
        if runtime_snapshot_source
        else "blackboard_scheduler"
        if (
            runtime_active_strategy_ids
            or runtime_active_instance_ids
            or runtime_selected_strategy_id
            or runtime_selected_instance_id
            or runtime_mode_raw
            or runtime_arbiter_raw
            or runtime_reason_raw
            or runtime_route_payload
            or runtime_direction_guard
            or runtime_candidate_strategy_ids
            or runtime_router_weights
            or runtime_strategy_weights
            or runtime_instance_weights
        )
        and bool(runtime_scheduler)
        else "strategy_runtime_status"
        if bool(runtime_active)
        else "none"
    )

    fallback_active_strategy_ids = _clean_str_list(scheduler_fallback.get("active_strategy_ids"))
    fallback_active_instance_ids = _clean_str_list(
        scheduler_fallback.get("active_instance_ids")
        or scheduler_fallback.get("active_instances")
    )
    fallback_selected_strategy_id = _first_non_empty_str(
        scheduler_fallback.get("selected_strategy_id")
    )
    if not fallback_selected_strategy_id and fallback_active_strategy_ids:
        fallback_selected_strategy_id = fallback_active_strategy_ids[0]
    fallback_selected_instance_id = _first_non_empty_str(
        scheduler_fallback.get("selected_instance_id")
    )
    if not fallback_selected_instance_id and fallback_active_instance_ids:
        fallback_selected_instance_id = fallback_active_instance_ids[0]
    fallback_mode_raw = _first_non_empty_str(scheduler_fallback.get("mode"))
    fallback_arbiter_raw = _first_non_empty_str(scheduler_fallback.get("arbiter"))
    fallback_reason_raw = _first_non_empty_str(scheduler_fallback.get("reason"))
    fallback_route_payload = (
        scheduler_fallback.get("route", {})
        if isinstance(scheduler_fallback.get("route", {}), dict)
        else {}
    )
    fallback_direction_guard = (
        scheduler_fallback.get("direction_guard", {})
        if isinstance(scheduler_fallback.get("direction_guard", {}), dict)
        else fallback_route_payload.get("direction_guard", {})
        if isinstance(fallback_route_payload.get("direction_guard", {}), dict)
        else {}
    )
    fallback_candidate_strategy_ids = _clean_str_list(
        scheduler_fallback.get("candidate_strategy_ids")
        or fallback_route_payload.get("candidate_strategy_ids")
    )
    fallback_router_weights = (
        scheduler_fallback.get("router_weights")
        if isinstance(scheduler_fallback.get("router_weights", {}), dict)
        else fallback_route_payload.get("weights", {})
        if isinstance(fallback_route_payload.get("weights", {}), dict)
        else {}
    )
    if not fallback_router_weights and isinstance(fallback_route_payload.get("router_weights", {}), dict):
        fallback_router_weights = fallback_route_payload.get("router_weights", {})
    fallback_strategy_weights = (
        scheduler_fallback.get("strategy_weights")
        if isinstance(scheduler_fallback.get("strategy_weights", {}), dict)
        else {}
    )
    fallback_instance_weights = (
        scheduler_fallback.get("instance_weights")
        if isinstance(scheduler_fallback.get("instance_weights", {}), dict)
        else {}
    )
    fallback_route_family = _first_non_empty_str(
        scheduler_fallback.get("route_family"),
        fallback_route_payload.get("route_family"),
    )
    fallback_allowed_side = _first_non_empty_str(
        scheduler_fallback.get("allowed_side"),
        fallback_route_payload.get("allowed_side"),
    )
    fallback_source = "effective_runtime_scheduler" if bool(scheduler_fallback) else "none"

    effective_active_strategy_ids = (
        runtime_active_strategy_ids
        or fallback_active_strategy_ids
        or ([configured_strategy_id] if configured_strategy_id else [])
    )
    effective_active_instance_ids = runtime_active_instance_ids or fallback_active_instance_ids
    effective_selected_strategy_id = (
        runtime_selected_strategy_id
        or fallback_selected_strategy_id
        or (effective_active_strategy_ids[0] if effective_active_strategy_ids else configured_strategy_id)
    )
    effective_selected_instance_id = (
        runtime_selected_instance_id
        or fallback_selected_instance_id
        or (effective_active_instance_ids[0] if effective_active_instance_ids else "")
    )
    effective_mode = _first_non_empty_str(runtime_mode_raw, fallback_mode_raw, "single_active")
    effective_arbiter = _first_non_empty_str(runtime_arbiter_raw, fallback_arbiter_raw, "allocation-weighted")
    effective_reason = _first_non_empty_str(runtime_reason_raw, fallback_reason_raw, effective_arbiter)
    effective_route_payload = runtime_route_payload if runtime_route_payload else fallback_route_payload
    effective_direction_guard = runtime_direction_guard if runtime_direction_guard else fallback_direction_guard
    effective_candidate_strategy_ids = (
        runtime_candidate_strategy_ids
        if runtime_candidate_strategy_ids
        else fallback_candidate_strategy_ids
    )
    effective_router_weights = runtime_router_weights if runtime_router_weights else fallback_router_weights
    effective_strategy_weights = runtime_strategy_weights if runtime_strategy_weights else fallback_strategy_weights
    effective_instance_weights = runtime_instance_weights if runtime_instance_weights else fallback_instance_weights
    effective_route_family = _first_non_empty_str(runtime_route_family, fallback_route_family)
    effective_allowed_side = _first_non_empty_str(runtime_allowed_side, fallback_allowed_side)

    field_sources = {
        "activeStrategyIds": (
            "runtime"
            if runtime_active_strategy_ids
            else "fallback"
            if fallback_active_strategy_ids
            else "configured"
        ),
        "activeInstanceIds": (
            "runtime"
            if runtime_active_instance_ids
            else "fallback"
            if fallback_active_instance_ids
            else "none"
        ),
        "selectedStrategyId": (
            "runtime"
            if runtime_selected_strategy_id
            else "fallback"
            if fallback_selected_strategy_id
            else "configured"
        ),
        "selectedInstanceId": (
            "runtime"
            if runtime_selected_instance_id
            else "fallback"
            if fallback_selected_instance_id
            else "none"
        ),
        "mode": "runtime" if runtime_mode_raw else "fallback" if fallback_mode_raw else "default",
        "arbiter": "runtime" if runtime_arbiter_raw else "fallback" if fallback_arbiter_raw else "default",
        "reason": "runtime" if runtime_reason_raw else "fallback" if fallback_reason_raw else "default",
        "route": "runtime" if runtime_route_payload else "fallback" if fallback_route_payload else "none",
    }
    source_layer = field_sources["selectedStrategyId"]
    if source_layer == "configured" and field_sources["activeStrategyIds"] != "configured":
        source_layer = field_sources["activeStrategyIds"]
    runtime_loaded = bool(
        loaded_set
        and any(strategy_id in loaded_set for strategy_id in effective_active_strategy_ids)
    )

    configured_projection = {
        "strategyId": configured_strategy_id,
        "strategyName": configured_name,
        "configRevision": str(config_revision or ""),
        "effectiveUpdatedAt": _utc_iso_from_ts(effective_updated_at),
        "source": configured_source,
        "runtimeLoaded": runtime_loaded,
    }
    runtime_projection = {
        "mode": runtime_mode_raw,
        "arbiter": runtime_arbiter_raw,
        "reason": runtime_reason_raw,
        "activeStrategyIds": runtime_active_strategy_ids,
        "activeInstanceIds": runtime_active_instance_ids,
        "selectedStrategyId": runtime_selected_strategy_id,
        "selectedStrategyName": _strategy_name_from_catalog(catalog, runtime_selected_strategy_id, ""),
        "selectedInstanceId": runtime_selected_instance_id,
        "route": runtime_route_payload,
        "routeFamily": runtime_route_family,
        "allowedSide": runtime_allowed_side,
        "candidateStrategyIds": runtime_candidate_strategy_ids,
        "routerWeights": runtime_router_weights,
        "directionGuard": runtime_direction_guard,
        "strategyWeights": runtime_strategy_weights,
        "instanceWeights": runtime_instance_weights,
        "source": runtime_source,
        "snapshotId": str(runtime_scheduler.get("snapshot_id", "") or ""),
        "generatedAt": str(runtime_scheduler.get("generated_at", "") or ""),
        "sourceVersions": (
            runtime_scheduler.get("source_versions", {})
            if isinstance(runtime_scheduler.get("source_versions", {}), dict)
            else {}
        ),
    }
    fallback_projection = {
        "mode": fallback_mode_raw,
        "arbiter": fallback_arbiter_raw,
        "reason": fallback_reason_raw,
        "activeStrategyIds": fallback_active_strategy_ids,
        "activeInstanceIds": fallback_active_instance_ids,
        "selectedStrategyId": fallback_selected_strategy_id,
        "selectedStrategyName": _strategy_name_from_catalog(catalog, fallback_selected_strategy_id, ""),
        "selectedInstanceId": fallback_selected_instance_id,
        "route": fallback_route_payload,
        "routeFamily": fallback_route_family,
        "allowedSide": fallback_allowed_side,
        "candidateStrategyIds": fallback_candidate_strategy_ids,
        "routerWeights": fallback_router_weights,
        "directionGuard": fallback_direction_guard,
        "strategyWeights": fallback_strategy_weights,
        "instanceWeights": fallback_instance_weights,
        "updatedAt": str(scheduler_fallback.get("updated_at", "") or ""),
        "source": fallback_source,
    }
    effective_projection = {
        "mode": effective_mode,
        "arbiter": effective_arbiter,
        "reason": effective_reason,
        "activeStrategyIds": effective_active_strategy_ids,
        "activeStrategyNames": [
            _strategy_name_from_catalog(catalog, strategy_id, strategy_id)
            for strategy_id in effective_active_strategy_ids
        ],
        "activeInstanceIds": effective_active_instance_ids,
        "selectedStrategyId": effective_selected_strategy_id,
        "selectedStrategyName": _strategy_name_from_catalog(catalog, effective_selected_strategy_id),
        "selectedInstanceId": effective_selected_instance_id,
        "route": effective_route_payload,
        "routeFamily": effective_route_family,
        "allowedSide": effective_allowed_side,
        "candidateStrategyIds": effective_candidate_strategy_ids,
        "routerWeights": effective_router_weights,
        "directionGuard": effective_direction_guard,
        "strategyWeights": effective_strategy_weights,
        "instanceWeights": effective_instance_weights,
        "runtimeLoaded": runtime_loaded,
        "sourceLayer": source_layer,
        "fieldSources": field_sources,
        "snapshotId": str(runtime_scheduler.get("snapshot_id", "") or ""),
        "generatedAt": str(runtime_scheduler.get("generated_at", "") or ""),
        "sourceVersions": (
            runtime_scheduler.get("source_versions", {})
            if isinstance(runtime_scheduler.get("source_versions", {}), dict)
            else {}
        ),
    }
    current_projection = {
        "strategyId": configured_strategy_id,
        "strategyName": configured_name,
        "activeStrategyIds": effective_active_strategy_ids,
        "activeInstanceIds": effective_active_instance_ids,
        "mode": effective_mode,
        "arbiter": effective_arbiter,
        "selectedStrategyId": effective_selected_strategy_id,
        "selectedInstanceId": effective_selected_instance_id,
        "effectiveStrategyId": effective_selected_strategy_id,
        "effectiveStrategyName": effective_projection["selectedStrategyName"],
        "configRevision": str(config_revision or ""),
        "runtimeLoaded": runtime_loaded,
        "effectiveUpdatedAt": _utc_iso_from_ts(effective_updated_at),
        "sourceLayer": source_layer,
        "configuredSource": configured_source,
        "runtimeSource": runtime_source,
        "fallbackSource": fallback_source,
    }
    return {
        "configured": configured_projection,
        "runtime": runtime_projection,
        "fallback": fallback_projection,
        "effective": effective_projection,
        "current": current_projection,
    }


def _compact_blackboard_for_status(blackboard: Any) -> dict:
    if not isinstance(blackboard, dict):
        return {}
    macro = blackboard.get("Macro_Commander", {}) if isinstance(blackboard.get("Macro_Commander", {}), dict) else {}
    micro = blackboard.get("Micro_Sniper", {}) if isinstance(blackboard.get("Micro_Sniper", {}), dict) else {}
    risk = blackboard.get("Risk_Commissar", {}) if isinstance(blackboard.get("Risk_Commissar", {}), dict) else {}
    scheduler = blackboard.get("Scheduler_Runtime", {}) if isinstance(blackboard.get("Scheduler_Runtime", {}), dict) else {}
    arbitration = blackboard.get("Execution_Arbitration", {}) if isinstance(blackboard.get("Execution_Arbitration", {}), dict) else {}
    strategy_instances = blackboard.get("Strategy_Instances", {}) if isinstance(blackboard.get("Strategy_Instances", {}), dict) else {}
    risk_protocol = blackboard.get("Risk_Protocol", {}) if isinstance(blackboard.get("Risk_Protocol", {}), dict) else {}
    runtime_scheduler_snapshot = _load_runtime_scheduler_snapshot()
    runtime_scheduler_runtime = runtime_scheduler_snapshot if runtime_scheduler_snapshot else scheduler
    runtime_status = _runtime_strategy_status_payload()
    runtime_active = runtime_status.get("active", {}) if isinstance(runtime_status.get("active", {}), dict) else {}
    effective_payload = _load_effective_params_payload()
    scheduler_fallback = (
        effective_payload.get("runtime_scheduler", {})
        if isinstance(effective_payload.get("runtime_scheduler", {}), dict)
        else {}
    )
    market_state = _read_json_file(_strategy_market_state_file())
    market_instances = (
        market_state.get("runtime_instances", [])
        if isinstance(market_state.get("runtime_instances", []), list)
        else []
    )

    catalog = _strategy_catalog_from_state(market_state)
    strategy_projection = _normalize_strategy_projection(
        catalog=catalog,
        effective_payload=effective_payload,
        runtime_active=runtime_active,
        runtime_scheduler=runtime_scheduler_runtime,
        scheduler_fallback=scheduler_fallback,
    )
    effective_projection = (
        strategy_projection.get("effective", {})
        if isinstance(strategy_projection.get("effective", {}), dict)
        else {}
    )
    active_strategy_ids = _clean_str_list(effective_projection.get("activeStrategyIds"))
    active_instance_ids = _clean_str_list(effective_projection.get("activeInstanceIds"))
    selected_strategy_id = _first_non_empty_str(effective_projection.get("selectedStrategyId"))
    selected_instance_id = _first_non_empty_str(effective_projection.get("selectedInstanceId"))
    route_payload = (
        effective_projection.get("route", {})
        if isinstance(effective_projection.get("route", {}), dict)
        else {}
    )
    direction_guard = (
        effective_projection.get("directionGuard", {})
        if isinstance(effective_projection.get("directionGuard", {}), dict)
        else {}
    )
    snapshot_route = (
        runtime_scheduler_snapshot.get("route", {})
        if isinstance(runtime_scheduler_snapshot.get("route", {}), dict)
        else {}
    )
    snapshot_risk_decision = (
        runtime_scheduler_snapshot.get("risk_decision", {})
        if isinstance(runtime_scheduler_snapshot.get("risk_decision", {}), dict)
        else {}
    )
    snapshot_execution_owner = (
        runtime_scheduler_snapshot.get("execution_owner", {})
        if isinstance(runtime_scheduler_snapshot.get("execution_owner", {}), dict)
        and runtime_scheduler_snapshot.get("execution_owner", {})
        else snapshot_risk_decision.get("execution_owner", {})
        if isinstance(snapshot_risk_decision.get("execution_owner", {}), dict)
        else {}
    )

    strategy_instance_rows = (
        strategy_instances.get("instances", {})
        if isinstance(strategy_instances.get("instances", {}), dict)
        else {}
    )
    if not strategy_instance_rows and active_instance_ids:
        built_rows: dict[str, Any] = {}
        market_instance_map = {
            str(item.get("instance_id", "")).strip(): item
            for item in market_instances
            if isinstance(item, dict) and str(item.get("instance_id", "")).strip()
        }
        instance_weights = (
            effective_projection.get("instanceWeights", {})
            if isinstance(effective_projection.get("instanceWeights", {}), dict)
            else {}
        )
        for instance_id in active_instance_ids:
            row = market_instance_map.get(instance_id, {})
            built_rows[instance_id] = {
                "strategy_id": str(row.get("strategy_id", "") or ""),
                "role": str(row.get("role", "") or ""),
                "allocation": float(row.get("allocation", 0.0) or 0.0),
                "weight": float(instance_weights.get(instance_id, 0.0) or 0.0),
                "status": str(row.get("status", "") or ""),
                "selected": instance_id == selected_instance_id,
            }
        strategy_instance_rows = built_rows

    governor_blockers = _normalize_blocker_reasons(risk_protocol.get("governor_blockers", []))
    if not governor_blockers:
        governor_blockers = _normalize_blocker_reasons(snapshot_risk_decision.get("governor_blockers", []))
    if not governor_blockers:
        governor_blockers = _normalize_blocker_reasons(snapshot_risk_decision.get("blocked_reasons", []))
    if not governor_blockers:
        governor_blockers = _normalize_blocker_reasons(arbitration.get("blocked_reasons", []))
    risk_protocol_global_state = str(
        risk_protocol.get("global_state")
        or snapshot_risk_decision.get("global_state")
        or ""
    ).lower()
    if risk_protocol_global_state not in {"blocked", "normal"}:
        risk_protocol_global_state = "normal"
    if risk_protocol_global_state == "blocked" and not governor_blockers:
        risk_protocol_global_state = "normal"

    return {
        "Macro_Commander": {
            "bias": macro.get("bias"),
            "confidence": macro.get("confidence"),
            "daily_calls": macro.get("daily_calls"),
            "trigger_reason": macro.get("trigger_reason"),
        },
        "Micro_Sniper": {
            "is_defensive_env": micro.get("is_defensive_env"),
            "knife_catching_risk": micro.get("knife_catching_risk"),
            "ny_session_active": micro.get("ny_session_active"),
            "current_regime": micro.get("current_regime"),
            "override_counter": micro.get("override_counter"),
            "override_threshold": micro.get("override_threshold"),
        },
        "Risk_Commissar": {
            "position_multiplier": risk.get("position_multiplier", 1.0),
        },
        "Scheduler_Runtime": {
            "mode": effective_projection.get("mode"),
            "arbiter": effective_projection.get("arbiter"),
            "reason": effective_projection.get("reason"),
            "active_strategy_ids": active_strategy_ids,
            "active_instance_ids": active_instance_ids,
            "selected_strategy_id": selected_strategy_id,
            "selected_instance_id": selected_instance_id,
            "route": route_payload,
            "route_family": effective_projection.get("routeFamily"),
            "allowed_side": effective_projection.get("allowedSide"),
            "candidate_strategy_ids": _clean_str_list(
                effective_projection.get("candidateStrategyIds")
                or route_payload.get("candidate_strategy_ids")
            ),
            "router_weights": (
                effective_projection.get("routerWeights", {})
                if isinstance(effective_projection.get("routerWeights", {}), dict)
                and effective_projection.get("routerWeights", {})
                else route_payload.get("weights", {})
                if isinstance(route_payload.get("weights", {}), dict)
                and route_payload.get("weights", {})
                else route_payload.get("router_weights", {})
                if isinstance(route_payload.get("router_weights", {}), dict)
                else {}
            ),
            "direction_guard": direction_guard,
            "strategy_weights": (
                effective_projection.get("strategyWeights", {})
                if isinstance(effective_projection.get("strategyWeights", {}), dict)
                else {}
            ),
            "instance_weights": (
                effective_projection.get("instanceWeights", {})
                if isinstance(effective_projection.get("instanceWeights", {}), dict)
                else {}
            ),
            "configured_strategy_id": strategy_projection.get("configured", {}).get("strategyId"),
            "configured_strategy_name": strategy_projection.get("configured", {}).get("strategyName"),
            "selected_strategy_name": effective_projection.get("selectedStrategyName"),
            "source_layer": effective_projection.get("sourceLayer"),
            "field_sources": (
                effective_projection.get("fieldSources", {})
                if isinstance(effective_projection.get("fieldSources", {}), dict)
                else {}
            ),
            "snapshot_id": str(runtime_scheduler_snapshot.get("snapshot_id", "") or ""),
            "generated_at": str(runtime_scheduler_snapshot.get("generated_at", "") or ""),
            "source_versions": (
                runtime_scheduler_snapshot.get("source_versions", {})
                if isinstance(runtime_scheduler_snapshot.get("source_versions", {}), dict)
                else {}
            ),
        },
        "Execution_Arbitration": {
            "final_action": arbitration.get("final_action"),
            "final_side": arbitration.get("final_side"),
            "net_score": arbitration.get("net_score"),
            "confidence": arbitration.get("confidence"),
            "hold_mode": arbitration.get("hold_mode"),
            "blocked_reasons": (
                arbitration.get("blocked_reasons", [])
                if isinstance(arbitration.get("blocked_reasons", []), list) and arbitration.get("blocked_reasons", [])
                else runtime_scheduler_snapshot.get("blocked_reasons", [])
                if isinstance(runtime_scheduler_snapshot.get("blocked_reasons", []), list)
                else []
            ),
            "last_execution_status": (
                arbitration.get("last_execution_status")
                or runtime_scheduler_snapshot.get("last_execution_status")
            ),
            "selected_instance_id": str(runtime_scheduler_snapshot.get("selected_instance_id", "") or ""),
            "selected_strategy_id": str(runtime_scheduler_snapshot.get("selected_strategy_id", "") or ""),
            "entry_owner_instance_id": str(
                snapshot_execution_owner.get("entry_owner_instance_id")
                or runtime_scheduler_snapshot.get("entry_owner_instance_id", "")
                or snapshot_route.get("entry_owner_instance_id", "")
                or ""
            ),
            "entry_owner_strategy_id": str(
                snapshot_execution_owner.get("entry_owner_strategy_id")
                or runtime_scheduler_snapshot.get("entry_owner_strategy_id", "")
                or snapshot_route.get("entry_owner_strategy_id", "")
                or ""
            ),
            "source_layer": "runtime_snapshot" if runtime_scheduler_snapshot else "blackboard",
        },
        "Strategy_Instances": {
            "selected_strategy_id": strategy_instances.get("selected_strategy_id") or selected_strategy_id,
            "selected_instance_id": strategy_instances.get("selected_instance_id") or selected_instance_id,
            "contributors_count": strategy_instances.get("contributors_count") or len(strategy_instance_rows),
            "instances": strategy_instance_rows,
        },
        "Risk_Protocol": {
            "global_state": risk_protocol_global_state,
            "governor_blockers": governor_blockers,
            "decision": str(risk_protocol.get("decision") or snapshot_risk_decision.get("decision") or ""),
            "risk_code": str(risk_protocol.get("risk_code") or snapshot_risk_decision.get("risk_code") or ""),
            "risk_layer": str(risk_protocol.get("risk_layer") or snapshot_risk_decision.get("risk_layer") or ""),
            "risk_source": str(risk_protocol.get("risk_source") or snapshot_risk_decision.get("risk_source") or ""),
            "blocking_rule_id": str(
                risk_protocol.get("blocking_rule_id") or snapshot_risk_decision.get("blocking_rule_id") or ""
            ),
            "active_strategy_ids": (
                risk_protocol.get("active_strategy_ids", [])
                if isinstance(risk_protocol.get("active_strategy_ids", []), list) and risk_protocol.get("active_strategy_ids", [])
                else active_strategy_ids
            ),
            "active_instance_ids": (
                risk_protocol.get("active_instance_ids", [])
                if isinstance(risk_protocol.get("active_instance_ids", []), list) and risk_protocol.get("active_instance_ids", [])
                else active_instance_ids
            ),
            "instance_risk_state": risk_protocol.get("instance_risk_state", {}),
            "instance_health_score": risk_protocol.get("instance_health_score", {}),
            "execution_owner": snapshot_execution_owner,
            "source_layer": "runtime_snapshot" if runtime_scheduler_snapshot else "blackboard",
        },
        "Strategy_Projection": {
            "configured": (
                strategy_projection.get("configured", {})
                if isinstance(strategy_projection.get("configured", {}), dict)
                else {}
            ),
            "runtime": (
                strategy_projection.get("runtime", {})
                if isinstance(strategy_projection.get("runtime", {}), dict)
                else {}
            ),
            "fallback": (
                strategy_projection.get("fallback", {})
                if isinstance(strategy_projection.get("fallback", {}), dict)
                else {}
            ),
            "effective": effective_projection,
        },
    }


def _refresh_technical_trend_cache(force_refresh: bool = False) -> None:
    try:
        direction, data = get_technical_trend_bias(force_refresh=force_refresh)
        _TECHNICAL_TREND_CACHE["ts"] = time.time()
        _TECHNICAL_TREND_CACHE["direction"] = direction or "NEUTRAL"
        _TECHNICAL_TREND_CACHE["data"] = data if isinstance(data, dict) else {}
    finally:
        _TECHNICAL_TREND_CACHE["refreshing"] = False


def _get_technical_trend_snapshot() -> tuple[str, dict]:
    now = time.time()
    cache_age = now - float(_TECHNICAL_TREND_CACHE.get("ts", 0.0) or 0.0)
    if cache_age < 30.0:
        return (
            str(_TECHNICAL_TREND_CACHE.get("direction", "NEUTRAL") or "NEUTRAL"),
            dict(_TECHNICAL_TREND_CACHE.get("data", {}) or {}),
        )
    if not bool(_TECHNICAL_TREND_CACHE.get("refreshing", False)):
        _TECHNICAL_TREND_CACHE["refreshing"] = True
        threading.Thread(
            target=_refresh_technical_trend_cache,
            kwargs={"force_refresh": False},
            daemon=True,
            name="gui-technical-trend-refresh",
        ).start()
    return (
        str(_TECHNICAL_TREND_CACHE.get("direction", "NEUTRAL") or "NEUTRAL"),
        dict(_TECHNICAL_TREND_CACHE.get("data", {}) or {}),
    )

@app.post("/api/action/panic")
def panic_system():
    """紧急阻断: 立即阻断开仓并请求当前持仓平仓，但不主动杀 daemon。"""
    return execute_action("panic")

@app.post("/api/start")
def start_system():
    if PID_FILE.exists():
        try:
            pid = int(PID_FILE.read_text().strip())
            if is_process_running(pid):
                _set_manual_stop(False)
                return {"success": False, "message": "System already running"}
            PID_FILE.unlink(missing_ok=True)
        except Exception:
            pass

    _set_manual_stop(False)
    ok, message = _spawn_daemon()
    return {"success": ok, "message": message}

@app.post("/api/stop")
def stop_gateway():
    """停止交易网关"""
    _set_manual_stop(True)
    if PID_FILE.exists():
        try:
            pid = int(PID_FILE.read_text().strip())
            os.kill(pid, signal.SIGTERM)
            return {"success": True, "message": f"Killed PID {pid}"}
        except Exception as e:
            return {"success": False, "error": str(e)}
    return {"success": True, "message": "Not running"}


@app.post("/api/macro/refresh")
def force_refresh_macro_direction():
    """
    强制刷新宏观方向：立即唤醒 M2 决策主脑输出最新宏观方向。
    """
    try:
        ok, msg, pid = _spawn_macro_refresh(force=True)
        status = _read_macro_refresh_status()
        return {
            "success": ok,
            "message": msg,
            "pid": pid,
            "refresh_status": status,
        }
    except Exception as e:
        return {"success": False, "message": f"宏观方向刷新失败: {e}"}

@app.post("/api/action/{action_type}")
def execute_action(action_type: str):
    """处理底部的一键操作按钮逻辑"""
    import time
    if action_type == "install":
        # 模拟安装过程
        time.sleep(2)
        return {"success": True, "message": "依赖安装完成,环境已就绪"}
    elif action_type == "repair":
        # 模拟修复连接过程,比如重启 MT5
        time.sleep(1)
        from bridge.trader_api import restart_mt5
        # restart_mt5() # 实际环境中可取消注释
        return {"success": True, "message": "MT5 及网络连接已尝试修复"}
    elif action_type == "export":
        # 模拟导出配置
        export_path = str(pathlib.Path.home() / "Desktop" / "gold_autotrade_backup.zip")
        return {"success": True, "message": f"迁移包已导出至: {export_path}"}
    elif action_type == "import":
        # 模拟导入配置
        return {"success": True, "message": "客户配置已成功导入并覆盖当前设置"}
    elif action_type == "approve_evolution":
        return approve_evolution_action()
    elif action_type == "reject_evolution":
        return reject_evolution_action()
    elif action_type == "refresh_macro":
        return force_refresh_macro_direction()
    elif action_type == "panic":
        try:
            ok, msg, state = _spawn_panic_action()
            return {
                "success": bool(ok),
                "message": msg,
                "result": state.get("result", {}),
                "panic_status": state,
            }
        except Exception as e:
            return {"success": False, "message": f"紧急平仓失败: {e}"}
    else:
        return {"success": False, "message": "未知的操作类型"}

@app.get("/api/setup_status")
def get_setup_status():
    """获取开箱即用各环节的状态"""
    # 1. 检查 Python / OpenClaw 环境
    env_ok = True

    # 2. 检查 MT5 是否已配置
    try:
        mt5_files_path = config_manager.get_mt5_dir()
    except Exception:
        mt5_files_path = None
    mt5_ok = bool(mt5_files_path and mt5_files_path.exists())

    # 3. 检查依赖是否已安装
    deps_ok = True

    # 4. 检查是否有历史运行记录或状态文件
    agent_ok = (DATA_DIR / "bridge" / "state" / "agent_state.json").exists()

    return {
        "steps": [
            { "id": "env", "title": "环境与依赖检测", "desc": "检查 Python 及基础运行库是否就绪", "status": "success" if env_ok else "pending" },
            { "id": "mt5", "title": "MT5 终端连接", "desc": "检测是否已找到 MT5 数据目录并允许 DLL 桥接", "status": "success" if mt5_ok else "warning" },
            { "id": "deps", "title": "量化组件加载", "desc": "安装 yfinance, pandas 等离线数据计算组件", "status": "success" if deps_ok else "pending" },
            { "id": "agent", "title": "策略引擎初始化", "desc": "启动后台守护进程与风控大脑", "status": "success" if agent_ok else "pending" }
        ]
    }

@app.get("/api/alerts")
def get_alerts():
    """获取关键风控告警与风险事件"""
    risk_file = LOGS_DIR / "risk_events.ndjson"
    alerts = []

    if risk_file.exists():
        try:
            lines = _read_tail_lines(risk_file, max_lines=50, max_bytes=50000)
            for line in reversed(lines[-20:]):
                if not line.strip(): continue
                try:
                    event = json.loads(line)
                    # 转换时间格式
                    ts = event.get("timestamp", 0)
                    dt = datetime.datetime.fromtimestamp(ts) if ts else datetime.datetime.now()
                    time_str = dt.strftime("%H:%M:%S")

                    event_type = event.get("event_type", "UNKNOWN")
                    if event_type == "RISK_INTERCEPT":
                        type_label = "风控拦截"
                        color_class = "warning"
                        desc = event.get("reason", "阻止了高风险操作")
                    elif event_type == "FORCE_CLOSE":
                        type_label = "强制平仓"
                        color_class = "danger"
                        desc = f"触发强平,票号: {event.get('ticket', 'N/A')}"
                    elif event_type == "M2_REVIEW":
                        type_label = "人工复核"
                        color_class = "primary"
                        desc = f"挂起信号,原因: {event.get('reason', '需要人工确认')}"
                    else:
                        type_label = "系统告警"
                        color_class = "warning"
                        desc = event.get("reason", str(event))

                    alerts.append({
                        "id": f"alert_{ts}_{len(alerts)}",
                        "type": type_label,
                        "time": time_str,
                        "desc": desc,
                        "color": color_class,
                        "ts": float(ts or time.time()),
                        "kind": "risk_event",
                    })
                except Exception:
                    pass
        except Exception as e:
            print(f"Error reading alerts: {e}")

    repair_events_file = _resolve_freshest_existing_file(_file_repair_event_candidates()) or _file_repair_events_file()
    if repair_events_file.exists():
        try:
            lines = _read_tail_lines(repair_events_file, max_lines=30, max_bytes=30000)
            for line in reversed(lines[-10:]):
                if not line.strip():
                    continue
                try:
                    event = json.loads(line)
                except Exception:
                    continue
                ts = float(event.get("timestamp", time.time()) or time.time())
                dt = datetime.datetime.fromtimestamp(ts)
                filename = str(event.get("filename", "") or pathlib.Path(str(event.get("file", "") or "")).name or "unknown.json")
                backup_path = str(event.get("backup_path", "") or "")
                backup_suffix = "，已备份损坏原文件" if backup_path else ""
                desc = (
                    f"检测到配置/状态文件损坏，系统已自动恢复 `{filename}`{backup_suffix}。"
                    f" 原因: {str(event.get('reason', 'invalid_json') or 'invalid_json')}"
                )
                alerts.append({
                    "id": f"repair_{int(ts)}_{filename}",
                    "type": "自动修复",
                    "time": dt.strftime("%H:%M:%S"),
                    "desc": desc,
                    "color": "warning",
                    "ts": ts,
                    "kind": "file_repair",
                })
        except Exception as e:
            print(f"Error reading file repair alerts: {e}")

    # 兜底:如果完全没有数据,还是显示空状态,而不是写死假数据
    alerts.sort(key=lambda item: float(item.get("ts", 0.0) or 0.0), reverse=True)
    return {"success": True, "alerts": alerts}


@app.get("/api/system_messages")
def get_system_messages(limit: int = 80):
    rows: list[dict[str, Any]] = []
    safe_limit = max(1, min(200, int(limit or 80)))

    system_messages_file = _resolve_freshest_existing_file(_system_message_candidates()) or _system_messages_file()
    if system_messages_file.exists():
        try:
            lines = _read_tail_lines(system_messages_file, max_lines=max(80, safe_limit * 2), max_bytes=120000)
            for line in reversed(lines[-safe_limit:]):
                if not line.strip():
                    continue
                try:
                    event = json.loads(line)
                except Exception:
                    continue
                ts = float(event.get("timestamp", time.time()) or time.time())
                rows.append(
                    {
                        "id": f"sys_{int(ts * 1000)}_{len(rows)}",
                        "kind": str(event.get("kind", "system") or "system"),
                        "title": str(event.get("title", "系统消息") or "系统消息"),
                        "desc": str(event.get("desc", "") or ""),
                        "color": str(event.get("color", "primary") or "primary"),
                        "source": str(event.get("source", "system") or "system"),
                        "metadata": event.get("metadata", {}) if isinstance(event.get("metadata", {}), dict) else {},
                        "ts": ts,
                        "time": datetime.datetime.fromtimestamp(ts).strftime("%H:%M:%S"),
                    }
                )
        except Exception:
            pass

    rows.sort(key=lambda item: float(item.get("ts", 0.0) or 0.0), reverse=True)
    return {
        "success": True,
        "messages": rows[:safe_limit],
        "source": str(system_messages_file) if system_messages_file.exists() else "",
        "summary": {
            "total": len(rows[:safe_limit]),
            "publishCount": sum(1 for item in rows[:safe_limit] if str(item.get("kind", "")) == "publish"),
            "rollbackCount": sum(1 for item in rows[:safe_limit] if str(item.get("kind", "")) == "rollback"),
            "templateCount": sum(1 for item in rows[:safe_limit] if str(item.get("kind", "")) in {"template", "template_hot_update"}),
            "repairCount": sum(1 for item in rows[:safe_limit] if str(item.get("kind", "")) == "file_repair"),
        },
    }

@app.get("/api/logs/{log_type}")
def get_logs(log_type: str, lines: int = 50):
    """获取指定类型的实时日志"""
    if log_type not in {"daemon", "system"}:
        return {"success": False, "error": "Unknown log type"}

    try:
        source = ""
        lines_data: list[str] = []
        if log_type == "system":
            target_file = _resolve_signal_pipeline_log_file()
            if target_file is not None:
                source = str(target_file)
                lines_data = _format_pipeline_runtime_lines(target_file, lines)
        else:
            source, lines_data = _read_gold_trader_bridge_progress_lines(lines)
        if not lines_data:
            if log_type == "daemon" and source:
                lines_data = ["[gold_trader] 当前暂无新的 MT5 执行/回报事件。"]
            else:
                lines_data = [f"[{log_type}] Log file not found or not created yet."]
        return {"success": True, "source": source, "lines": lines_data}
    except Exception as e:
        return {"success": False, "error": str(e)}

@app.get("/api/mailbox")
def get_mailbox():
    """获取多Agent通信的决策大厅历史 (Blackboard)"""
    logs = []
    try:
        # 只读取跨进程稳定可见的持久化来源，避免把当前 API 进程内的旧黑板内存
        # 混进来，导致蜂群大厅显示陈旧消息或接口被导入/锁竞争拖慢。
        source_groups: list[list[dict]] = []

        # 来源 A：blackboard_state.json（同步持久化，时效性最强）
        bb_file = _resolve_runtime_root_artifact("blackboard_state.json")
        if bb_file is not None and bb_file.exists():
            try:
                data = json.loads(bb_file.read_text())
                bb_logs = data.get("Swarm_Logs", [])
                if bb_logs:
                    source_groups.append(bb_logs)
            except Exception:
                pass

        # 来源 B：logs/swarm/*.jsonl（异步刷盘，批量完整性好）
        disk_logs = _read_swarm_jsonl_logs(limit=200)
        if disk_logs:
            source_groups.append(disk_logs)

        combined_by_key: dict[str, dict] = {}
        for group in source_groups:
            for log in group:
                if not isinstance(log, dict):
                    continue
                key = str(log.get("event_id") or (
                    f"{str(log.get('role', ''))}|{str(log.get('time', ''))}|{str(log.get('text', ''))}"
                ))
                if key in combined_by_key:
                    combined_by_key.pop(key)
                combined_by_key[key] = log
        combined_logs = list(combined_by_key.values())
        combined_logs.sort(key=_entry_sort_key)
        freshness_anchor_ts = _runtime_activity_anchor_ts()

        # Cascading freshness windows: show entries from progressively wider time
        # ranges when recent data is sparse, so the decision hall never goes blank
        # during quiet periods (e.g. monitor-only mode).
        windows = [30 * 60, 2 * 3600, 6 * 3600, None]  # 30min, 2h, 6h, unlimited
        min_entries_per_window = [10, 8, 5, 1]
        selected_window_sec: Optional[int] = windows[0]

        def _collect_logs(window_sec: Optional[int]) -> list[dict]:
            collected: list[dict] = []
            for log in combined_logs:
                text = log.get("text", "")
                role = log.get("role", "System")
                if not _has_trustworthy_mailbox_timestamp(log):
                    continue
                if window_sec is not None and not _is_event_fresh(
                    _entry_event_ts(log),
                    anchor_ts=freshness_anchor_ts,
                    max_age_sec=window_sec,
                ):
                    continue
                event_timestamp = str(log.get("timestamp", "")).strip()
                if not event_timestamp:
                    inferred_ts = log.get("_sort_ts")
                    if isinstance(inferred_ts, datetime.datetime):
                        event_timestamp = (
                            inferred_ts.astimezone(datetime.timezone.utc)
                            .isoformat()
                            .replace("+00:00", "Z")
                        )
                event_id = str(log.get("event_id", "") or "")
                if not event_id:
                    event_id = f"{role}|{event_timestamp or str(log.get('time', ''))}|{text}"

                if any(kw in text for kw in [
                    "Signal: HOLD", "Signal: OPEN", "Signal: CLOSE",
                    "MT5 terminal_status.json not found", "MT5异常",
                    "信号状态 |"
                ]):
                    continue
                if role == "Risk_Commissar" and (
                    ("本轮未受理" in text and "未进入审批链" in text)
                    or text.startswith("本轮继续观望：")
                    or text.startswith("shadow 当前仅作离线观察与训练证据，")
                ):
                    continue

                collected.append({
                    "sender": role,
                    "timestamp": log.get("time", ""),
                    "eventTimestamp": event_timestamp,
                    "eventId": event_id,
                    "content": text
                })
            return collected

        for window_sec, min_entries in zip(windows, min_entries_per_window):
            logs = _collect_logs(window_sec)
            if len(logs) >= min_entries:
                selected_window_sec = window_sec
                break

        # 降序返回（最新在最前），前端直接按接口顺序渲染。
        logs = list(reversed(logs[-50:]))
    except Exception:
        pass
    return JSONResponse(
        content={
            "success": True,
            "logs": logs,
            "source": {
                "blackboard": str(bb_file) if bb_file is not None else "",
                "swarmDir": str(_resolve_active_swarm_dir() or ""),
                "freshnessAnchorTs": freshness_anchor_ts,
                "freshnessWindowSec": selected_window_sec,
            },
            "generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"),
        },
        headers={
            "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
            "Pragma": "no-cache",
            "Expires": "0",
        },
    )


def _run_update_apply_job(manifest: Optional[str], force: bool):
    try:
        app_updater.run_auto(manifest, force=force)
    except Exception as e:
        app_updater.write_state({"status": "apply_failed", "error": repr(e)})


@app.get("/api/update/status")
def get_update_status():
    now = time.time()
    cached_value = _UPDATE_STATUS_CACHE.get("value")
    cache_age = now - float(_UPDATE_STATUS_CACHE.get("ts", 0.0) or 0.0)

    version = app_updater.load_display_version()
    current_release = app_updater.get_current_release()
    candidate_release = app_updater.get_candidate_release()
    update_state = app_updater.load_update_state()

    if isinstance(cached_value, dict) and cache_age < 60.0:
        return {
            "success": True,
            "version": version,
            "currentRelease": current_release,
            "candidateRelease": candidate_release,
            "updateState": update_state,
            "quality": cached_value,
            "qualityStale": False,
        }

    fallback_quality = cached_value if isinstance(cached_value, dict) else {
        "runtime": {},
        "learning": {},
        "blockers": [],
        "safe_to_switch": True,
    }
    return {
        "success": True,
        "version": version,
        "currentRelease": current_release,
        "candidateRelease": candidate_release,
        "updateState": update_state,
        "quality": fallback_quality,
        "qualityStale": True,
    }


@app.get("/api/settings/apikey")
def get_api_key_status():
    config = config_manager.load_app_config()
    has_key = bool(config.get("DEEPSEEK_API_KEY", "") or os.environ.get("DEEPSEEK_API_KEY", ""))
    return {
        "success": True,
        "configured": has_key,
        "masked_key": _mask_api_key(config.get("DEEPSEEK_API_KEY", "")),
        "source": "env" if os.environ.get("DEEPSEEK_API_KEY") else "config",
    }


@app.post("/api/settings/apikey")
def save_api_key(payload: dict):
    api_key = (payload.get("api_key") or payload.get("apiKey") or "").strip()
    if not api_key:
        return {"success": False, "error": "api_key is required"}
    config_manager.save_app_config({"DEEPSEEK_API_KEY": api_key})
    return {"success": True, "configured": True, "masked_key": _mask_api_key(api_key)}


def _mask_api_key(key: str) -> str:
    if not key or len(key) < 8:
        return ""
    return key[:4] + "****" + key[-4:]


@app.post("/api/settings/mt5path")
def save_mt5_path(payload: dict):
    mt5_path = (payload.get("mt5_data_dir") or "").strip()
    if not mt5_path:
        return {"success": False, "error": "mt5_data_dir is required"}
    config_manager.save_app_config({"mt5_data_dir": mt5_path})
    return {"success": True}


@app.get("/api/update/config")
def get_update_config():
    config = config_manager.load_app_config()
    return {
        "success": True,
        "update_manifest_url": config.get("update_manifest_url", ""),
    }


@app.post("/api/update/config")
def save_update_config(payload: UpdateConfigRequest):
    config_manager.save_app_config({"update_manifest_url": payload.update_manifest_url.strip()})
    return {"success": True, "update_manifest_url": payload.update_manifest_url.strip()}


@app.post("/api/update/check")
def check_update(payload: Optional[UpdateRequest] = None):
    manifest = payload.manifest if payload else None
    result = app_updater.run_check(app_updater.resolve_manifest_source(manifest))
    return {"success": True, **result}


@app.post("/api/update/apply")
def apply_update(payload: Optional[UpdateRequest] = None, background_tasks: BackgroundTasks = None):
    manifest = payload.manifest if payload else None
    force = bool(payload.force) if payload else False
    if background_tasks is None:
        result = app_updater.run_auto(manifest, force=force)
        return {"success": result.get("status") in {"candidate_ready", "installed", "up_to_date", "no_manifest"}, **result}
    background_tasks.add_task(_run_update_apply_job, manifest, force)
    app_updater.write_state({"status": "apply_started", "manifest": manifest or "", "force": force})
    return {"success": True, "status": "apply_started"}


@app.post("/api/update/promote")
def promote_update(payload: Optional[UpdateRequest] = None):
    force = bool(payload.force) if payload else False
    result = app_updater.promote_candidate(force=force)
    return {"success": result.get("status") == "promoted", **result}


@app.post("/api/update/rollback")
def rollback_update():
    result = app_updater.rollback_to_previous()
    return {"success": result.get("status") == "rolled_back", **result}

@app.get("/api/timeline")
def get_timeline():
    """获取大模型交易复盘时间轴数据 (从真实状态解析)"""
    return _get_timeline_payload_cached(limit=40)

@app.get("/api/pending_signals")
def get_pending_signals():
    """获取等待人工复核的信号列表"""
    REVIEW_DIR = LOGS_DIR / "review_pending"
    if not REVIEW_DIR.exists():
        return {"success": True, "signalReviewPendingCount": 0, "signals": []}

    signals = []
    for f in REVIEW_DIR.glob("*.review.json"):
        try:
            d = json.loads(f.read_text())
            stable_id = str(d.get("stable_id") or f.stem)
            signal_id = str(d.get("signal_id") or "")
            signals.append({
                # action_id 统一使用 stable_id（与 review 文件名一致）
                "id": stable_id,
                "stable_id": stable_id,
                "signal_id": signal_id,
                "action": d.get("action"),
                "side": d.get("side"),
                "entry": d.get("entry"),
                "confidence": d.get("requested_confidence", d.get("confidence", 0)),
                "confidenceRaw": d.get("confidence", 0),
                "reason": d.get("reason", "低置信度需复核")
            })
        except:
            pass
    return {
        "success": True,
        "signalReviewPendingCount": len(signals),
        "signals": signals,
    }

@app.post("/api/signals/{signal_id}/{action}")
def process_signal(signal_id: str, action: str):
    """处理待复核信号 (approve/reject)"""
    import shutil
    REVIEW_DIR = LOGS_DIR / "review_pending"
    src = REVIEW_DIR / f"{signal_id}.review.json"
    resolved_id = signal_id

    # 兼容旧前端传 signal_id（带时间戳）而非 stable_id 的情况。
    if not src.exists():
        for f in REVIEW_DIR.glob("*.review.json"):
            try:
                d = json.loads(f.read_text())
            except Exception:
                continue
            if str(d.get("signal_id") or "") == signal_id:
                src = f
                resolved_id = f.stem
                break

    if not src.exists():
        return {"success": False, "message": "信号已过期或不存在"}

    dest_dir = REVIEW_DIR / action
    dest_dir.mkdir(exist_ok=True)
    shutil.move(str(src), str(dest_dir / f"{resolved_id}.json"))

    return {"success": True, "message": f"信号已 {action}", "id": resolved_id}

from pydantic import BaseModel

class RiskParams(BaseModel):
    risk_level: str
    max_position_size: float
    max_open_positions: int
    sl_pips: int
    tp_pips: int
    human_in_the_loop: bool
    min_confidence: float
    account_total_loss_stop_pct: float


_DEFAULT_BASE_LOTS = 0.05
_RISK_LEVEL_PROFILES = {
    "conservative": {
        "max_position_size": 0.05,
        "max_open_positions": 1,
        "sl_pips": 20,
        "tp_pips": 40,
        "min_confidence": 80.0,
    },
    "balanced": {
        "max_position_size": 0.10,
        "max_open_positions": 2,
        "sl_pips": 40,
        "tp_pips": 80,
        "min_confidence": 70.0,
    },
    "aggressive": {
        "max_position_size": 0.20,
        "max_open_positions": 3,
        "sl_pips": 60,
        "tp_pips": 120,
        "min_confidence": 60.0,
    },
}


def _normalize_risk_level(level: Any) -> str:
    value = str(level or "").strip().lower()
    if value in _RISK_LEVEL_PROFILES:
        return value
    return "balanced"


def _risk_profile_for_level(level: Any) -> Dict[str, Any]:
    return dict(_RISK_LEVEL_PROFILES[_normalize_risk_level(level)])


def _risk_level_from_max_position_size(max_position_size: float) -> str:
    size = float(max_position_size or 0.0)
    if size <= 0.051:
        return "conservative"
    if size >= 0.195:
        return "aggressive"
    return "balanced"


def _strategy_rules_file() -> pathlib.Path:
    runtime_file = CONFIG_DIR / "strategy_rules_v3.json"
    if runtime_file.exists():
        return runtime_file
    repo_file = pathlib.Path(__file__).resolve().parent / "config" / "strategy_rules_v3.json"
    if repo_file.exists():
        return repo_file
    return runtime_file


def _load_strategy_rules_for_update() -> tuple[pathlib.Path, dict]:
    strategy_file = _strategy_rules_file()
    data: dict = {}
    if strategy_file.exists():
        try:
            loaded = json.loads(strategy_file.read_text(encoding="utf-8"))
            if isinstance(loaded, dict):
                data = loaded
        except Exception:
            data = {}
    data.setdefault("position_sizing", {})
    data.setdefault("risk_governor", {})
    return strategy_file, data


class TrainingStartRequest(BaseModel):
    model: Optional[str] = None
    dataset: Optional[str] = None


class TrainingAnalyzeRequest(BaseModel):
    model: Optional[str] = None


class TrainingEvolveRequest(BaseModel):
    model: Optional[str] = None


DEFAULT_ANALYSIS_MODEL = "deepseek/deepseek-v4-pro"
TRAINING_RUNTIME_CONFIG = {
    "engine": "Grid Search",
    "source": "Yahoo Finance",
    "symbol": "GC=F",
    "lookbackDays": 10,
    "interval": "5m",
    "mode": "train",
}
TRAINING_DATASET_PRESETS = [
    {
        "id": "gc_f_5m_10d",
        "label": "GC=F · 10d · 5m · Yahoo Finance",
        "symbol": "GC=F",
        "lookbackDays": 10,
        "interval": "5m",
        "source": "Yahoo Finance",
    },
    {
        "id": "gc_f_5m_30d",
        "label": "GC=F · 30d · 5m · Yahoo Finance",
        "symbol": "GC=F",
        "lookbackDays": 30,
        "interval": "5m",
        "source": "Yahoo Finance",
    },
    {
        "id": "gc_f_15m_30d",
        "label": "GC=F · 30d · 15m · Yahoo Finance",
        "symbol": "GC=F",
        "lookbackDays": 30,
        "interval": "15m",
        "source": "Yahoo Finance",
    },
]


def _default_training_dataset_label() -> str:
    return (
        f"{TRAINING_RUNTIME_CONFIG['symbol']} · "
        f"{TRAINING_RUNTIME_CONFIG['lookbackDays']}d · "
        f"{TRAINING_RUNTIME_CONFIG['interval']} · "
        f"{TRAINING_RUNTIME_CONFIG['source']}"
    )


def _normalize_training_dataset(dataset: Optional[str]) -> str:
    value = (dataset or "").strip()
    if not value or value in {"-", "loading", "TRAINING_BLOCKED"}:
        return _default_training_dataset_label()
    return value


def _get_training_dataset_presets() -> List[Dict[str, Any]]:
    return [
        {
            **preset,
            "label": preset.get("label") or (
                f"{preset.get('symbol')} · {preset.get('lookbackDays')}d · "
                f"{preset.get('interval')} · {preset.get('source')}"
            ),
        }
        for preset in TRAINING_DATASET_PRESETS
    ]


def _resolve_training_dataset(dataset: Optional[str]) -> Dict[str, Any]:
    value = _normalize_training_dataset(dataset)
    for preset in _get_training_dataset_presets():
        if value in {str(preset.get("id", "")), str(preset.get("label", ""))}:
            return preset
    return {
        "id": "custom",
        "label": value,
        "symbol": TRAINING_RUNTIME_CONFIG["symbol"],
        "lookbackDays": TRAINING_RUNTIME_CONFIG["lookbackDays"],
        "interval": TRAINING_RUNTIME_CONFIG["interval"],
        "source": TRAINING_RUNTIME_CONFIG["source"],
    }


def _normalize_analysis_model(model: Optional[str]) -> str:
    value = (model or "").strip()
    if not value or value in {"-", "loading", "hybrid-ensemble"}:
        return DEFAULT_ANALYSIS_MODEL
    return value


def _read_json_file(path: pathlib.Path) -> Dict[str, Any]:
    if not path.exists():
        return {}
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return {}
    return payload if isinstance(payload, dict) else {}


def _load_runtime_scheduler_snapshot() -> dict[str, Any]:
    _path, payload = _read_json_from_candidates(_runtime_scope_candidates("logs", "runtime_scheduler_snapshot.json"))
    return payload if isinstance(payload, dict) else {}


def _record_file_repair_event(
    path: pathlib.Path,
    *,
    reason: str,
    backup_path: str = "",
) -> None:
    event = {
        "timestamp": time.time(),
        "event_type": "FILE_AUTO_REPAIRED",
        "file": str(path),
        "filename": path.name,
        "backup_path": backup_path,
        "reason": str(reason or "invalid_json"),
    }
    try:
        repair_events_file = _file_repair_events_file()
        repair_events_file.parent.mkdir(parents=True, exist_ok=True)
        with repair_events_file.open("a", encoding="utf-8") as handle:
            handle.write(json.dumps(event, ensure_ascii=False) + "\n")
    except Exception:
        pass


def _record_system_message(
    *,
    kind: str,
    title: str,
    desc: str,
    color: str = "primary",
    source: str = "system",
    metadata: Optional[Dict[str, Any]] = None,
) -> None:
    payload = {
        "timestamp": time.time(),
        "kind": str(kind or "system"),
        "title": str(title or "系统消息"),
        "desc": str(desc or ""),
        "color": str(color or "primary"),
        "source": str(source or "system"),
        "metadata": metadata if isinstance(metadata, dict) else {},
    }
    try:
        system_messages_file = _system_messages_file()
        system_messages_file.parent.mkdir(parents=True, exist_ok=True)
        with system_messages_file.open("a", encoding="utf-8") as handle:
            handle.write(json.dumps(payload, ensure_ascii=False) + "\n")
    except Exception:
        pass


def _recover_corrupt_json_file(
    path: pathlib.Path,
    default_payload: Dict[str, Any],
    *,
    reason: str,
) -> None:
    backup_path = ""
    if path.exists():
        try:
            ts_suffix = int(time.time())
            backup_target = path.with_name(f"{path.stem}.corrupt.{ts_suffix}{path.suffix}")
            path.replace(backup_target)
            backup_path = str(backup_target)
        except Exception as exc:
            reason = f"{reason}; backup_failed={exc}"
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(
            json.dumps(default_payload, ensure_ascii=False, indent=2) + "\n",
            encoding="utf-8",
        )
    except Exception as exc:
        _record_file_repair_event(
            path,
            reason=f"{reason}; rewrite_failed={exc}",
            backup_path=backup_path,
        )
        return
    _record_file_repair_event(path, reason=reason, backup_path=backup_path)
    _record_system_message(
        kind="file_repair",
        title="自动修复已执行",
        desc=(
            f"检测到 `{path.name}` 损坏，系统已自动恢复默认结构"
            f"{'，并备份损坏原文件' if backup_path else ''}。"
        ),
        color="warning",
        source="auto_repair",
        metadata={
            "file": str(path),
            "filename": path.name,
            "backup_path": backup_path,
            "reason": reason,
        },
    )


def _parse_timestamp_value(value: Any) -> Optional[float]:
    if value is None:
        return None
    if isinstance(value, (int, float)):
        ts = float(value)
        return ts if ts > 0 else None
    if isinstance(value, str):
        text = value.strip()
        if not text:
            return None
        try:
            return float(text)
        except Exception:
            pass
        try:
            normalized = text.replace("Z", "+00:00")
            return datetime.datetime.fromisoformat(normalized).timestamp()
        except Exception:
            return None
    return None


def _utc_iso_from_ts(ts: Optional[float]) -> str:
    if not ts:
        return ""
    return datetime.datetime.fromtimestamp(ts, datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def _collect_projection_source_versions(
    source_paths: Dict[str, Optional[pathlib.Path]],
) -> Dict[str, str]:
    source_versions: Dict[str, str] = {}
    for source_name, path in source_paths.items():
        if not source_name or path is None:
            continue
        try:
            if path.exists():
                source_versions[source_name] = _utc_iso_from_ts(path.stat().st_mtime)
        except Exception:
            continue
    return source_versions


def _attach_projection_metadata(
    payload: Dict[str, Any],
    *,
    snapshot_name: str,
    source_paths: Dict[str, Optional[pathlib.Path]],
) -> Dict[str, Any]:
    projection = dict(payload)
    snapshot_ts = time.time()
    projection["snapshot_id"] = f"{snapshot_name}_{int(snapshot_ts * 1000)}_{uuid.uuid4().hex[:8]}"
    projection["generated_at"] = _utc_iso_from_ts(snapshot_ts)
    projection["source_versions"] = _collect_projection_source_versions(source_paths)
    return projection


def _runtime_projection_source_paths() -> Dict[str, Optional[pathlib.Path]]:
    strategy_rules_file, _strategy_rules = _load_strategy_rules_for_update()
    return {
        "strategy_market_state": _resolve_runtime_artifact("config", "strategy_market_state.json"),
        "strategy_runtime_status": _resolve_runtime_artifact("logs", "strategy_runtime_status.json"),
        "runtime_scheduler_snapshot": _resolve_runtime_artifact("logs", "runtime_scheduler_snapshot.json"),
        "effective_params": _resolve_runtime_artifact("logs", "effective_params.json"),
        "strategy_rules": strategy_rules_file,
        "execution_state": _resolve_runtime_artifact("data", "execution_state.json"),
        "position_lifecycle": _resolve_runtime_artifact("data", "position_lifecycle.json"),
        "blackboard_state": _resolve_runtime_root_artifact("blackboard_state.json"),
    }


def _age_seconds_from_ts(ts: Optional[float]) -> Optional[float]:
    if not ts:
        return None
    return round(max(0.0, time.time() - float(ts)), 1)


def _timeline_event(
    *,
    source: str,
    ts: Optional[float],
    role: str,
    event_type: str,
    title: str,
    content: str,
    data: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
    event_ts = float(ts or time.time())
    return {
        "source": source,
        "time": datetime.datetime.fromtimestamp(event_ts).strftime("%H:%M:%S"),
        "timestamp": event_ts,
        "role": role,
        "type": event_type,
        "title": title,
        "content": content,
        "data": data or {},
    }


def _read_ndjson_tail(path: pathlib.Path, limit: int = 20) -> list[dict[str, Any]]:
    if not path.exists():
        return []
    try:
        lines = path.read_text(encoding="utf-8").splitlines()
    except Exception:
        return []
    rows: list[dict[str, Any]] = []
    for raw in lines[-max(limit * 3, limit) :]:
        if not raw.strip():
            continue
        try:
            payload = json.loads(raw)
        except Exception:
            continue
        if isinstance(payload, dict):
            rows.append(payload)
    return rows[-limit:]


def _collect_execution_signal_rows(limit: int = 12) -> dict[str, list[dict[str, Any]]]:
    execution_state = _runtime_execution_state_payload()
    signals = execution_state.get("signals", {}) if isinstance(execution_state.get("signals", {}), dict) else {}
    rows: list[dict[str, Any]] = []
    terminal_states = {"FILLED", "REJECTED", "VETOED", "TIMEOUT", "RECONCILED"}
    for signal_id, entry in signals.items():
        if not isinstance(entry, dict):
            continue
        history = entry.get("history", []) if isinstance(entry.get("history", []), list) else []
        latest = history[-1] if history else {}
        metadata = entry.get("metadata", {}) if isinstance(entry.get("metadata", {}), dict) else {}
        latest_ts = _parse_timestamp_value(
            latest.get("ts")
            if isinstance(latest, dict)
            else None
        ) or _parse_timestamp_value(entry.get("updated_at")) or _parse_timestamp_value(entry.get("created_at"))
        state = str(entry.get("state", "CREATED") or "CREATED").upper()
        rows.append(
            {
                "signalId": str(entry.get("signal_id") or signal_id),
                "traceId": str(entry.get("trace_id") or entry.get("signal_id") or signal_id),
                "state": state,
                "latestTs": latest_ts or 0.0,
                "latestTime": datetime.datetime.fromtimestamp(latest_ts).strftime("%H:%M:%S") if latest_ts else "",
                "reason": str(latest.get("reason", "") if isinstance(latest, dict) else ""),
                "side": str(
                    metadata.get("side")
                    or entry.get("side")
                    or (latest.get("side") if isinstance(latest, dict) else "")
                    or ""
                ),
                "strategyId": str(
                    metadata.get("strategy_id")
                    or metadata.get("strategy")
                    or entry.get("strategy_id")
                    or ""
                ),
                "action": str(
                    metadata.get("action")
                    or entry.get("action")
                    or (latest.get("action") if isinstance(latest, dict) else "")
                    or ""
                ),
                "entry": metadata.get("entry") or entry.get("entry"),
                "history": history[-4:],
            }
        )
    rows.sort(key=lambda item: float(item.get("latestTs", 0.0) or 0.0), reverse=True)
    active = [row for row in rows if str(row.get("state", "")).upper() not in terminal_states][:limit]
    recent = rows[:limit]
    return {"active": active, "recent": recent}


def _read_latest_jsonl_dict_from_path(
    path: pathlib.Path,
    *,
    max_bytes: int = 256 * 1024,
    max_lines: int = 4000,
) -> dict[str, Any]:
    try:
        lines = _read_tail_lines(path, max_bytes=max_bytes, max_lines=max_lines)
    except Exception:
        return {}
    for raw in reversed(lines):
        raw = str(raw or "").strip()
        if not raw:
            continue
        try:
            payload = json.loads(raw)
        except Exception:
            continue
        if isinstance(payload, dict):
            return payload
    return {}


def _read_latest_jsonl_dict(paths: list[pathlib.Path]) -> dict[str, Any]:
    path = _resolve_existing_file(paths)
    if path is None:
        return {}
    return _read_latest_jsonl_dict_from_path(path)


def _signal_reason_summary(signal: dict[str, Any]) -> str:
    trace = signal.get("trace", {}) if isinstance(signal.get("trace", {}), dict) else {}
    evidence = trace.get("evidence", []) if isinstance(trace.get("evidence", []), list) else []
    if evidence:
        return " | ".join(str(item) for item in evidence[:4])
    reject_reason = str(signal.get("reject_reason", "") or "").strip()
    if reject_reason:
        return reject_reason
    return str(signal.get("mode", signal.get("action", "HOLD")) or "HOLD")


def _hold_mode_label(
    aggregated_action: str,
    contributors: list[dict[str, Any]],
    blocked_reasons: list[str],
) -> str:
    if aggregated_action != "HOLD":
        return "executed"
    has_directional_contributor = any(
        str(item.get("action", "HOLD") or "HOLD").upper() != "HOLD"
        and str(item.get("side", "NONE") or "NONE").upper() in {"BUY", "SELL"}
        for item in contributors
        if isinstance(item, dict)
    )
    if has_directional_contributor:
        return "conflict_suppressed_hold"
    if blocked_reasons:
        return "blocked_hold"
    return "proactive_hold"


def _build_runtime_arbitration_payload() -> dict[str, Any]:
    log_path = _resolve_existing_file(_runtime_log_candidates("runtime.log"))
    if log_path is None:
        return {
            "generatedAt": "",
            "summary": {
                "finalAction": "UNKNOWN",
                "finalSide": "NONE",
                "holdMode": "unknown",
                "reason": "暂无运行解释数据",
            },
            "contributors": [],
        }
    try:
        stat = log_path.stat()
    except Exception:
        stat = None
    if stat is not None:
        cache_key = (str(log_path), float(stat.st_mtime), int(stat.st_size))
        cached_path = str(_RUNTIME_ARBITRATION_CACHE.get("path", "") or "")
        cached_mtime = float(_RUNTIME_ARBITRATION_CACHE.get("mtime", 0.0) or 0.0)
        cached_size = int(_RUNTIME_ARBITRATION_CACHE.get("size", 0) or 0)
        cached_value = _RUNTIME_ARBITRATION_CACHE.get("value")
        if (
            isinstance(cached_value, dict)
            and cached_path == cache_key[0]
            and cached_mtime == cache_key[1]
            and cached_size == cache_key[2]
        ):
            return dict(cached_value)
    latest_envelope = _read_latest_jsonl_dict_from_path(log_path)
    if not latest_envelope:
        return {
            "generatedAt": "",
            "summary": {
                "finalAction": "UNKNOWN",
                "finalSide": "NONE",
                "holdMode": "unknown",
                "reason": "暂无运行解释数据",
            },
            "contributors": [],
        }

    signal = latest_envelope.get("signal", {}) if isinstance(latest_envelope.get("signal", {}), dict) else {}
    aggregated = (
        latest_envelope.get("_aggregated_intent", {})
        if isinstance(latest_envelope.get("_aggregated_intent", {}), dict)
        else {}
    )
    strategy_intents = (
        latest_envelope.get("_strategy_intents", [])
        if isinstance(latest_envelope.get("_strategy_intents", []), list)
        else []
    )
    scheduler = (
        latest_envelope.get("_scheduler", {})
        if isinstance(latest_envelope.get("_scheduler", {}), dict)
        else {}
    )
    contributor_rows = (
        aggregated.get("contributors", [])
        if isinstance(aggregated.get("contributors", []), list)
        else []
    )
    signal_by_instance: dict[str, dict[str, Any]] = {}
    for item in strategy_intents:
        if not isinstance(item, dict):
            continue
        instance_id = str(item.get("_instance_id", "") or "").strip()
        if instance_id:
            signal_by_instance[instance_id] = item
    blocked_reasons = [str(item) for item in aggregated.get("blocked_reasons", []) if str(item).strip()] if isinstance(aggregated.get("blocked_reasons", []), list) else []
    normalized: list[dict[str, Any]] = []
    for row in contributor_rows:
        if not isinstance(row, dict):
            continue
        instance_id = str(row.get("instance_id", "") or "").strip()
        raw_signal = signal_by_instance.get(instance_id, {})
        signed_score = float(row.get("signed_score", 0.0) or 0.0)
        action = str(row.get("action", "HOLD") or "HOLD").upper()
        side = str(row.get("side", "NONE") or "NONE").upper()
        classification = "neutral"
        if action == "HOLD":
            classification = "hold"
        elif signed_score > 0:
            classification = "support_buy"
        elif signed_score < 0:
            classification = "support_sell"
        suppression_reason = ""
        if str(aggregated.get("action", "HOLD") or "HOLD").upper() == "HOLD" and action != "HOLD":
            suppression_reason = "conflict_or_low_net_score"
        elif action != "HOLD" and side != str(aggregated.get("side", "NONE") or "NONE").upper():
            suppression_reason = "opposite_side_offset"
        normalized.append(
            {
                "instanceId": instance_id,
                "strategyId": str(row.get("strategy_id", "") or ""),
                "action": action,
                "side": side,
                "weight": round(float(row.get("weight", 0.0) or 0.0), 4),
                "confidence": round(float(row.get("confidence", 0.0) or 0.0), 4),
                "signedScore": round(signed_score, 6),
                "absoluteScore": round(abs(signed_score), 6),
                "classification": classification,
                "wasSelected": instance_id == str(aggregated.get("selected_instance_id", "") or ""),
                "wasSuppressed": bool(suppression_reason),
                "suppressionReason": suppression_reason,
                "rawReason": _signal_reason_summary(raw_signal) if raw_signal else str(row.get("reason", "") or ""),
                "rawRejectReason": str(raw_signal.get("reject_reason", "") or "") if raw_signal else "",
                "paramOverrides": raw_signal.get("param_overrides", {}) if isinstance(raw_signal.get("param_overrides", {}), dict) else {},
            }
        )
    normalized.sort(
        key=lambda item: (
            -float(item.get("absoluteScore", 0.0) or 0.0),
            not bool(item.get("wasSelected", False)),
            str(item.get("instanceId", "")),
        )
    )
    final_action = str(aggregated.get("action", signal.get("action", "UNKNOWN")) or "UNKNOWN").upper()
    final_side = str(aggregated.get("side", signal.get("side", "NONE")) or "NONE").upper()
    hold_mode = _hold_mode_label(final_action, contributor_rows, blocked_reasons)
    payload = {
        "generatedAt": str(
            latest_envelope.get("timestamp")
            or latest_envelope.get("generated_at")
            or ""
        ),
        "scheduler": {
            "mode": str(scheduler.get("mode", "") or ""),
            "arbiter": str(scheduler.get("arbiter", "") or ""),
            "reason": str(scheduler.get("reason", "") or ""),
        },
        "summary": {
            "finalAction": final_action,
            "finalSide": final_side,
            "netScore": round(float(aggregated.get("net_score", 0.0) or 0.0), 6),
            "confidence": round(float(aggregated.get("confidence", 0.0) or 0.0), 4),
            "selectedInstanceId": str(aggregated.get("selected_instance_id", "") or ""),
            "selectedStrategyId": str(aggregated.get("selected_strategy_id", "") or ""),
            "holdMode": hold_mode,
            "reason": _signal_reason_summary(signal),
            "blockedReasons": blocked_reasons,
        },
        "contributors": normalized,
    }
    if stat is not None:
        _RUNTIME_ARBITRATION_CACHE.update(
            {
                "path": str(log_path),
                "mtime": float(stat.st_mtime),
                "size": int(stat.st_size),
                "value": dict(payload),
            }
        )
    return payload


def _build_instance_execution_stats() -> dict[str, dict[str, Any]]:
    ledger_path = _resolve_existing_file(_runtime_log_candidates("trading_ledger.ndjson"))
    if ledger_path is None or not ledger_path.exists():
        return {}
    today = datetime.datetime.now(datetime.timezone.utc).date()
    success_statuses = {"ACKED", "FILLED"}
    reject_statuses = {"REJECTED", "TIMEOUT", "RETRY_EXHAUSTED", "INIT_FAILED"}
    stats: dict[str, dict[str, Any]] = {}
    latest_events: dict[tuple[str, str], dict[str, Any]] = {}
    try:
        lines = ledger_path.read_text(encoding="utf-8").splitlines()
    except Exception:
        return {}
    for raw in lines:
        raw = str(raw or "").strip()
        if not raw:
            continue
        try:
            record = json.loads(raw)
        except Exception:
            continue
        if not isinstance(record, dict):
            continue
        action = str(record.get("action", "") or "").upper()
        if action not in {"OPEN", "CLOSE"}:
            continue
        instance_id = str(
            record.get("instance_id")
            or record.get("selected_instance_id")
            or record.get("attributed_instance_id")
            or ""
        ).strip()
        if not instance_id:
            continue
        ts_raw = str(record.get("ts", record.get("timestamp", "")) or "").strip()
        ts_value: Optional[datetime.datetime] = None
        if ts_raw:
            try:
                ts_value = datetime.datetime.fromisoformat(ts_raw.replace("Z", "+00:00")).astimezone(datetime.timezone.utc)
            except Exception:
                ts_value = None
        status = str(record.get("status", "UNKNOWN") or "UNKNOWN").upper()
        event_id = str(
            record.get("intent_id")
            or record.get("decision_id")
            or record.get("signal_id")
            or record.get("ticket")
            or record.get("order_id")
            or record.get("deal_id")
            or ""
        ).strip()
        if not event_id:
            event_id = f"{instance_id}:{action}:{ts_raw}:{len(latest_events)}"
        event_key = (instance_id, f"{action}:{event_id}")
        existing = latest_events.get(event_key)
        if existing is None:
            latest_events[event_key] = {
                "status": status,
                "ts_raw": ts_raw,
                "ts_value": ts_value,
                "action": action,
            }
            continue
        existing_ts_raw = str(existing.get("ts_raw", "") or "")
        if ts_raw and (not existing_ts_raw or ts_raw >= existing_ts_raw):
            latest_events[event_key] = {
                "status": status,
                "ts_raw": ts_raw,
                "ts_value": ts_value,
                "action": action,
            }

    for (instance_id, _), event in latest_events.items():
        bucket = stats.setdefault(
            instance_id,
            {
                "today": 0,
                "reject": 0,
                "latestFilledAt": "",
                "latestAction": "",
                "latestStatus": "",
            },
        )
        status = str(event.get("status", "UNKNOWN") or "UNKNOWN").upper()
        action = str(event.get("action", "") or "").upper()
        ts_raw = str(event.get("ts_raw", "") or "")
        ts_value = event.get("ts_value")
        if ts_value is not None and ts_value.date() == today:
            if status in success_statuses:
                bucket["today"] = int(bucket.get("today", 0) or 0) + 1
            elif status in reject_statuses:
                bucket["reject"] = int(bucket.get("reject", 0) or 0) + 1
        if status in success_statuses and ts_raw:
            latest_filled_at = str(bucket.get("latestFilledAt", "") or "")
            if not latest_filled_at or ts_raw >= latest_filled_at:
                bucket["latestFilledAt"] = ts_raw
                bucket["latestAction"] = action
                bucket["latestStatus"] = status
    return stats


def _trade_event_timestamp(record: dict[str, Any]) -> float:
    for key in ("ts", "timestamp"):
        parsed = _parse_timestamp_value(record.get(key))
        if parsed:
            return float(parsed)
    return 0.0


def _event_identifier_candidates(record: dict[str, Any]) -> list[str]:
    keys: list[str] = []
    for field in ("ticket", "order_id", "deal_id", "signal_id", "decision_id", "intent_id"):
        value = record.get(field)
        if value is None:
            continue
        text = str(value).strip()
        if text and text not in keys and text != "0":
            keys.append(text)
    return keys


def _position_identifier_candidates(position: dict[str, Any]) -> list[str]:
    keys: list[str] = []
    for field in ("ticket", "order_id", "deal_id", "signal_id", "decision_id", "intent_id"):
        value = position.get(field)
        if value is None:
            continue
        text = str(value).strip()
        if text and text not in keys and text != "0":
            keys.append(text)
    return keys


def _normalize_position_side(position: dict[str, Any]) -> str:
    side = str(position.get("side", position.get("type", "")) or "").upper().strip()
    if side in {"BUY", "SELL"}:
        return side
    if side in {"LONG"}:
        return "BUY"
    if side in {"SHORT"}:
        return "SELL"
    return side or "UNKNOWN"


def _load_instance_trade_events() -> list[dict[str, Any]]:
    ledger_path = _resolve_existing_file(_runtime_log_candidates("trading_ledger.ndjson"))
    if ledger_path is None or not ledger_path.exists():
        return []
    now = time.time()
    try:
        stat = ledger_path.stat()
        mtime = float(stat.st_mtime)
        size = int(stat.st_size)
    except Exception:
        mtime = 0.0
        size = 0
    cached_value = _TRADE_EVENTS_CACHE.get("value")
    if (
        isinstance(cached_value, list)
        and (now - float(_TRADE_EVENTS_CACHE.get("ts", 0.0) or 0.0)) < 2.0
        and float(_TRADE_EVENTS_CACHE.get("mtime", 0.0) or 0.0) == mtime
        and int(_TRADE_EVENTS_CACHE.get("size", 0) or 0) == size
        and str(_TRADE_EVENTS_CACHE.get("path", "") or "") == str(ledger_path)
    ):
        return cached_value

    records: list[dict[str, Any]] = []
    try:
        lines: list[str]
        if size > 1024 * 1024:
            chunk_size = 1024 * 1024
            start = max(0, size - chunk_size)
            raw_bytes = b""
            try:
                with ledger_path.open("rb") as f:
                    f.seek(start)
                    raw_bytes = f.read()
            except Exception:
                raw_bytes = b""
            text = raw_bytes.decode("utf-8", errors="ignore")
            lines = text.splitlines()
            if len(lines) > 6000:
                lines = lines[-6000:]
        else:
            lines = ledger_path.read_text(encoding="utf-8", errors="ignore").splitlines()

        for raw in lines:
            raw = str(raw or "").strip()
            if not raw:
                continue
            try:
                payload = json.loads(raw)
            except Exception:
                continue
            if isinstance(payload, dict):
                records.append(payload)
    except Exception:
        return cached_value if isinstance(cached_value, list) else []

    records.sort(key=_trade_event_timestamp)
    _TRADE_EVENTS_CACHE.update(
        {
            "ts": now,
            "mtime": mtime,
            "size": size,
            "path": str(ledger_path),
            "value": records,
        }
    )
    return records


def _enrich_positions_with_attribution(positions: list[dict[str, Any]]) -> list[dict[str, Any]]:
    if not isinstance(positions, list) or not positions:
        return []
    normalized_positions = [item for item in positions if isinstance(item, dict)]
    if not normalized_positions:
        return []
    events = _load_instance_trade_events()
    lifecycle_payload = load_position_lifecycle(DATA_DIR)
    lifecycle_current = (
        lifecycle_payload.get("current", {})
        if isinstance(lifecycle_payload.get("current", {}), dict)
        else {}
    )

    success_statuses = {"ACKED", "FILLED"}
    open_events_by_key: dict[str, dict[str, Any]] = {}
    latest_close_events_by_key: dict[str, dict[str, Any]] = {}
    unmatched_open_events: list[dict[str, Any]] = []
    for record in events:
        if not isinstance(record, dict):
            continue
        action = str(record.get("action", "") or "").upper()
        status = str(record.get("status", "UNKNOWN") or "UNKNOWN").upper()
        keys = _event_identifier_candidates(record)
        if action == "OPEN" and status in success_statuses:
            if keys:
                for key in keys:
                    open_events_by_key[key] = record
            else:
                unmatched_open_events.append(record)
        elif action == "CLOSE":
            if keys:
                for key in keys:
                    latest_close_events_by_key[key] = record

    fallback_events_by_side: dict[str, list[dict[str, Any]]] = {}
    for record in unmatched_open_events:
        side = str(record.get("side", "UNKNOWN") or "UNKNOWN").upper()
        fallback_events_by_side.setdefault(side, []).append(record)
    for rows in fallback_events_by_side.values():
        rows.sort(key=_trade_event_timestamp, reverse=True)

    enriched: list[dict[str, Any]] = []
    used_fallback_signal_ids: set[str] = set()
    lifecycle_symbol = str(lifecycle_current.get("symbol", "") or "").strip().upper()
    lifecycle_ticket = str(lifecycle_current.get("ticket", "") or "").strip()
    lifecycle_has_owner = bool(
        str(lifecycle_current.get("entry_owner_instance_id", "") or "").strip()
        or str(lifecycle_current.get("current_manager_instance_id", "") or "").strip()
    )
    for position in normalized_positions:
        keys = _position_identifier_candidates(position)
        matched_open: Optional[dict[str, Any]] = None
        matched_close: Optional[dict[str, Any]] = None
        for key in keys:
            candidate = open_events_by_key.get(key)
            if candidate is not None and (
                matched_open is None or _trade_event_timestamp(candidate) >= _trade_event_timestamp(matched_open)
            ):
                matched_open = candidate
            close_candidate = latest_close_events_by_key.get(key)
            if close_candidate is not None and (
                matched_close is None or _trade_event_timestamp(close_candidate) >= _trade_event_timestamp(matched_close)
            ):
                matched_close = close_candidate
        if matched_open is None:
            side = _normalize_position_side(position)
            for candidate in fallback_events_by_side.get(side, []):
                signal_id = str(candidate.get("signal_id", "") or "")
                if signal_id and signal_id in used_fallback_signal_ids:
                    continue
                matched_open = candidate
                if signal_id:
                    used_fallback_signal_ids.add(signal_id)
                break

        entry_instance_id = str(
            (matched_open or {}).get("instance_id")
            or (matched_open or {}).get("selected_instance_id")
            or (matched_open or {}).get("attributed_instance_id")
            or ""
        ).strip()
        entry_strategy_id = str(
            (matched_open or {}).get("strategy_id")
            or (matched_open or {}).get("selected_strategy_id")
            or ""
        ).strip()
        exit_instance_id = str(
            (matched_close or {}).get("instance_id")
            or (matched_close or {}).get("selected_instance_id")
            or (matched_close or {}).get("attributed_instance_id")
            or ""
        ).strip()
        exit_strategy_id = str(
            (matched_close or {}).get("strategy_id")
            or (matched_close or {}).get("selected_strategy_id")
            or ""
        ).strip()
        contributors = (
            (matched_open or {}).get("contributors", [])
            if isinstance((matched_open or {}).get("contributors", []), list)
            else []
        )
        position_ticket = str(position.get("ticket", "") or "").strip()
        lifecycle_match = bool(
            lifecycle_current
            and lifecycle_ticket
            and position_ticket
            and lifecycle_ticket == position_ticket
        )
        position_symbol = str(position.get("symbol", "") or "").strip().upper()
        lifecycle_symbol_match = not lifecycle_symbol or not position_symbol or lifecycle_symbol == position_symbol
        lifecycle_fallback = bool(
            not lifecycle_match
            and lifecycle_has_owner
            and lifecycle_symbol_match
            and len(normalized_positions) == 1
        )
        enriched.append(
            {
                **position,
                "entry_owner_instance_id": str(
                    lifecycle_current.get("entry_owner_instance_id", "") if (lifecycle_match or lifecycle_fallback) else ""
                ).strip() or entry_instance_id,
                "entry_owner_strategy_id": str(
                    lifecycle_current.get("entry_owner_strategy_id", "") if (lifecycle_match or lifecycle_fallback) else ""
                ).strip() or entry_strategy_id,
                "current_manager_instance_id": str(
                    lifecycle_current.get("current_manager_instance_id", "") if (lifecycle_match or lifecycle_fallback) else ""
                ).strip(),
                "current_manager_strategy_id": str(
                    lifecycle_current.get("current_manager_strategy_id", "") if (lifecycle_match or lifecycle_fallback) else ""
                ).strip(),
                "expected_exit_owner_instance_id": str(
                    lifecycle_current.get("expected_exit_owner_instance_id", "") if (lifecycle_match or lifecycle_fallback) else ""
                ).strip(),
                "expected_exit_owner_strategy_id": str(
                    lifecycle_current.get("expected_exit_owner_strategy_id", "") if (lifecycle_match or lifecycle_fallback) else ""
                ).strip(),
                "exit_owner_instance_id": str(
                    lifecycle_current.get("exit_owner_instance_id", "") if (lifecycle_match or lifecycle_fallback) else ""
                ).strip() or exit_instance_id,
                "exit_owner_strategy_id": str(
                    lifecycle_current.get("exit_owner_strategy_id", "") if (lifecycle_match or lifecycle_fallback) else ""
                ).strip() or exit_strategy_id,
                "contributors": (
                    lifecycle_current.get("contributors", [])
                    if (lifecycle_match or lifecycle_fallback)
                    and isinstance(lifecycle_current.get("contributors", []), list)
                    else contributors
                ),
                "entry_signal_id": str((matched_open or {}).get("signal_id", "") or ""),
                "entry_decision_id": str((matched_open or {}).get("decision_id", "") or ""),
                "exit_signal_id": str((matched_close or {}).get("signal_id", "") or ""),
                "attribution_status": (
                    "matched" if matched_open else "lifecycle_fallback" if lifecycle_fallback else "unattributed"
                ),
            }
        )
    return enriched


def _build_instance_position_runtime(
    runtime_instances: list[dict[str, Any]],
    positions: list[dict[str, Any]],
) -> dict[str, Any]:
    execution_state = _runtime_execution_state_payload()
    lifecycle_payload = load_position_lifecycle(DATA_DIR)
    lifecycle_current = (
        lifecycle_payload.get("current", {})
        if isinstance(lifecycle_payload.get("current", {}), dict)
        else {}
    )
    signals = execution_state.get("signals", {}) if isinstance(execution_state.get("signals", {}), dict) else {}
    terminal_states = {"FILLED", "REJECTED", "VETOED", "TIMEOUT", "RECONCILED"}
    current_positions_by_instance: dict[str, list[dict[str, Any]]] = {}
    for position in positions:
        if not isinstance(position, dict):
            continue
        instance_id = str(position.get("entry_owner_instance_id", "") or "").strip()
        if not instance_id:
            continue
        current_positions_by_instance.setdefault(instance_id, []).append(position)

    active_signals_by_instance: dict[str, list[dict[str, Any]]] = {}
    for signal_id, entry in signals.items():
        if not isinstance(entry, dict):
            continue
        state = str(entry.get("state", "CREATED") or "CREATED").upper()
        if state in terminal_states:
            continue
        metadata = entry.get("metadata", {}) if isinstance(entry.get("metadata", {}), dict) else {}
        instance_id = str(
            metadata.get("attributed_instance_id")
            or metadata.get("selected_instance_id")
            or metadata.get("instance_id")
            or ""
        ).strip()
        if not instance_id:
            continue
        active_signals_by_instance.setdefault(instance_id, []).append(
            {
                "signalId": str(entry.get("signal_id", signal_id) or signal_id),
                "state": state,
                "action": str(metadata.get("action", "") or "").upper(),
                "side": str(metadata.get("side", "NONE") or "NONE").upper(),
                "volumeLots": float(metadata.get("volume_lots", 0.0) or 0.0),
            }
        )

    execution_stats = _build_instance_execution_stats()
    runtime_map: dict[str, dict[str, Any]] = {}
    current_holders: list[dict[str, Any]] = []
    lifecycle_status = str(lifecycle_current.get("status", "") or "").strip().lower()
    lifecycle_entry_owner = str(lifecycle_current.get("entry_owner_instance_id", "") or "").strip()
    lifecycle_manager = str(lifecycle_current.get("current_manager_instance_id", "") or "").strip()
    lifecycle_side = str(lifecycle_current.get("side", "NONE") or "NONE").upper()
    lifecycle_volume = float(lifecycle_current.get("volume", 0.0) or 0.0)
    lifecycle_ticket = str(lifecycle_current.get("ticket", "") or "").strip()
    terminal_positions_count = _read_terminal_status_positions_count()
    lifecycle_can_represent_live_position = terminal_positions_count != 0
    for instance in runtime_instances:
        if not isinstance(instance, dict):
            continue
        instance_id = str(instance.get("instance_id", "") or "").strip()
        if not instance_id:
            continue
        positions_for_instance = current_positions_by_instance.get(instance_id, [])
        active_signals = active_signals_by_instance.get(instance_id, [])
        opening_signals = [item for item in active_signals if str(item.get("action", "")).upper() == "OPEN"]
        closing_signals = [item for item in active_signals if str(item.get("action", "")).upper() in {"CLOSE", "REDUCE"}]
        position_sides = {
            _normalize_position_side(item)
            for item in positions_for_instance
            if _normalize_position_side(item) not in {"", "UNKNOWN"}
        }
        if len(position_sides) == 1:
            current_side = next(iter(position_sides))
        elif len(position_sides) > 1:
            current_side = "MIXED"
        elif opening_signals:
            current_side = str(opening_signals[0].get("side", "NONE") or "NONE").upper()
        else:
            current_side = "NONE"
        current_lots = round(
            sum(float(item.get("volume", item.get("lots", 0.0)) or 0.0) for item in positions_for_instance),
            4,
        )
        pending_open_lots = round(
            sum(float(item.get("volumeLots", 0.0) or 0.0) for item in opening_signals),
            4,
        )
        last_execution = execution_stats.get(instance_id, {})
        latest_action = str(last_execution.get("latestAction", "") or "")
        latest_filled_at = str(last_execution.get("latestFilledAt", "") or "")
        latest_status = str(last_execution.get("latestStatus", "") or "")
        if positions_for_instance:
            state = "holding"
            state_label = "持仓中"
        elif (
            lifecycle_status == "open"
            and lifecycle_entry_owner == instance_id
            and lifecycle_ticket
            and lifecycle_volume > 0
            and lifecycle_can_represent_live_position
        ):
            state = "holding"
            state_label = "持仓中"
            if current_side == "NONE":
                current_side = lifecycle_side
            if current_lots <= 0:
                current_lots = round(lifecycle_volume, 4)
        elif lifecycle_status == "closing" and lifecycle_manager == instance_id:
            state = "closing"
            state_label = "平仓中"
            if current_side == "NONE":
                current_side = lifecycle_side
            if current_lots <= 0:
                current_lots = round(lifecycle_volume, 4)
        elif opening_signals:
            state = "opening"
            state_label = "开仓中"
        elif closing_signals:
            state = "closing"
            state_label = "平仓中"
        elif latest_action == "CLOSE":
            state = "just_closed"
            state_label = "刚平仓"
        else:
            state = "flat"
            state_label = "空闲"
        runtime_entry = {
            "state": state,
            "stateLabel": state_label,
            "currentSide": current_side,
            "currentLots": current_lots,
            "pendingOpenLots": pending_open_lots,
            "positionsCount": len(positions_for_instance),
            "tickets": [
                str(item.get("ticket", "") or "")
                for item in positions_for_instance
                if str(item.get("ticket", "") or "").strip()
            ] or ([lifecycle_ticket] if lifecycle_ticket and state in {"holding", "closing"} else []),
            "activeSignals": active_signals,
            "latestFilledAt": latest_filled_at,
            "latestAction": latest_action,
            "latestStatus": latest_status,
            "currentManagerInstanceId": lifecycle_manager if lifecycle_manager == instance_id else "",
            "currentManagerStrategyId": (
                str(lifecycle_current.get("current_manager_strategy_id", "") or "").strip()
                if lifecycle_manager == instance_id
                else ""
            ),
        }
        runtime_map[instance_id] = runtime_entry
        if state == "holding":
            current_holders.append(
                {
                    "instanceId": instance_id,
                    "strategyId": str(instance.get("strategy_id", "") or ""),
                    "side": current_side,
                    "lots": current_lots,
                    "positionsCount": len(positions_for_instance),
                }
            )
    current_holders.sort(
        key=lambda item: (-float(item.get("lots", 0.0) or 0.0), str(item.get("instanceId", "")))
    )
    return {
        "byInstance": runtime_map,
        "currentHolders": current_holders,
        "holdingInstanceIds": [str(item.get("instanceId", "")) for item in current_holders],
    }


def _build_strategy_marketplace_payload(
    status_payload: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
    runtime_payload_path, runtime_payload = _read_json_from_candidates(
        _runtime_scope_candidates("logs", "strategy_runtime_status.json")
    )
    effective_params_path, effective_payload = _read_json_from_candidates(
        _runtime_scope_candidates("logs", "effective_params.json")
    )
    runtime_scheduler_path, runtime_scheduler_snapshot = _read_json_from_candidates(
        _runtime_scope_candidates("logs", "runtime_scheduler_snapshot.json")
    )
    strategy_file, strategy_rules = _load_strategy_rules_for_update()
    market_state_path = _resolve_runtime_artifact("config", "strategy_market_state.json")
    market_state = _load_strategy_market_state()
    market_strategies = (
        market_state.get("strategies", {})
        if isinstance(market_state.get("strategies", {}), dict)
        else {}
    )
    runtime_instances = (
        market_state.get("runtime_instances", [])
        if isinstance(market_state.get("runtime_instances", []), list)
        else []
    )
    status_payload = status_payload or get_status()
    current_positions = (
        status_payload.get("positions", [])
        if isinstance(status_payload.get("positions", []), list)
        else []
    )
    position_runtime = _build_instance_position_runtime(runtime_instances, current_positions)
    active = runtime_payload.get("active", {}) if isinstance(runtime_payload.get("active", {}), dict) else {}
    manifests = runtime_payload.get("manifests", []) if isinstance(runtime_payload.get("manifests", []), list) else []
    manifest_map = {
        str(item.get("strategy_id", "") or ""): item
        for item in manifests
        if isinstance(item, dict)
    }
    health_map = runtime_payload.get("health", {}) if isinstance(runtime_payload.get("health", {}), dict) else {}
    loaded_set = {
        str(item)
        for item in runtime_payload.get("loaded", [])
        if isinstance(item, (str, int))
    }
    scheduler_payload = (
        effective_payload.get("runtime_scheduler", {})
        if isinstance(effective_payload.get("runtime_scheduler", {}), dict)
        else {}
    )
    decision_policy = _normalize_decision_policy(
        scheduler_payload.get("decision_policy", market_state.get("decision_policy"))
    )
    catalog = _strategy_catalog_from_state(market_state)
    config_revision = str(
        active.get("config_revision")
        or effective_payload.get("config_revision")
        or effective_payload.get("revision")
        or ""
    )
    effective_params_file = effective_params_path or (LOGS_DIR / "effective_params.json")
    effective_updated_at = _parse_timestamp_value(effective_payload.get("strategy_switched_at"))
    if effective_updated_at is None and effective_params_file.exists():
        effective_updated_at = effective_params_file.stat().st_mtime
    runtime_payload_updated_at = _utc_iso_from_ts(_file_mtime_ts(runtime_payload_path))
    runtime_scheduler_updated_at = _utc_iso_from_ts(_file_mtime_ts(runtime_scheduler_path))
    market_state_updated_at = _utc_iso_from_ts(_file_mtime_ts(market_state_path))
    strategy_projection = _normalize_strategy_projection(
        catalog=catalog,
        effective_payload=effective_payload,
        runtime_active=active,
        runtime_scheduler=runtime_scheduler_snapshot,
        scheduler_fallback=scheduler_payload,
        config_revision=config_revision,
        effective_updated_at=effective_updated_at,
        loaded_set=loaded_set,
    )
    configured_projection = (
        strategy_projection.get("configured", {})
        if isinstance(strategy_projection.get("configured", {}), dict)
        else {}
    )
    effective_projection = (
        strategy_projection.get("effective", {})
        if isinstance(strategy_projection.get("effective", {}), dict)
        else {}
    )
    fallback_projection = (
        strategy_projection.get("fallback", {})
        if isinstance(strategy_projection.get("fallback", {}), dict)
        else {}
    )
    active_strategy_ids_runtime = {
        strategy_id
        for strategy_id in _clean_str_list(effective_projection.get("activeStrategyIds"))
        if strategy_id
    }
    strategies: list[dict[str, Any]] = []
    for strategy_id, fallback_name in catalog.items():
        manifest = manifest_map.get(strategy_id, {})
        health = health_map.get(strategy_id, {}) if isinstance(health_map.get(strategy_id, {}), dict) else {}
        market_row = market_strategies.get(strategy_id, {}) if isinstance(market_strategies.get(strategy_id, {}), dict) else {}
        versions = market_row.get("versions", []) if isinstance(market_row.get("versions", []), list) else []
        template = market_row.get("template", {}) if isinstance(market_row.get("template", {}), dict) else {}
        strategy_instances = [
            item for item in runtime_instances
            if isinstance(item, dict) and str(item.get("strategy_id", "")) == strategy_id
        ]
        name = str(
            market_row.get("display_name")
            or manifest.get("name")
            or fallback_name
        )
        version = str(
            market_row.get("current_version")
            or manifest.get("version")
            or health.get("strategy_version")
            or ("legacy" if strategy_id == "ema_macd" else "builtin")
        )
        healthy = bool(health.get("healthy", False)) if health else False
        loaded = strategy_id in loaded_set or bool(health.get("loaded", False))
        strategies.append(
            {
                "id": strategy_id,
                "name": name,
                "version": version,
                "kind": str(manifest.get("kind") or "signal"),
                "description": str(manifest.get("description") or fallback_name),
                "components": (
                    market_row.get("components", [])
                    if isinstance(market_row.get("components", []), list)
                    else []
                ),
                "isComposite": bool(market_row.get("is_composite", False)),
                "enabled": bool(market_row.get("enabled", True)),
                "active": strategy_id in active_strategy_ids_runtime,
                "loaded": loaded,
                "healthy": healthy,
                "configRevision": str(health.get("config_revision") or config_revision or ""),
                "lastLatencyMs": health.get("last_latency_ms"),
                "lastRunAtMs": health.get("last_run_at_ms"),
                "lastError": str(health.get("last_error") or ""),
                "consecutiveFailures": int(health.get("consecutive_failures", 0) or 0),
                "versionsCount": len(versions),
                "template": template,
                "componentsCount": len(template.keys()),
                "instances": strategy_instances,
            }
        )
    strategies.sort(
        key=lambda item: (
            not bool(item.get("active", False)),
            not bool(item.get("loaded", False)),
            str(item.get("name", "")),
        )
    )
    risk_governor = strategy_rules.get("risk_governor", {}) if isinstance(strategy_rules.get("risk_governor", {}), dict) else {}
    position_sizing = strategy_rules.get("position_sizing", {}) if isinstance(strategy_rules.get("position_sizing", {}), dict) else {}
    compact_blackboard = _compact_blackboard_for_status(_get_blackboard_data())
    compact_risk_protocol = (
        compact_blackboard.get("Risk_Protocol", {})
        if isinstance(compact_blackboard.get("Risk_Protocol", {}), dict)
        else {}
    )
    compact_arbitration = (
        compact_blackboard.get("Execution_Arbitration", {})
        if isinstance(compact_blackboard.get("Execution_Arbitration", {}), dict)
        else {}
    )
    today_orders = int(status_payload.get("todayTrades", 0) or 0)
    risk_state = "blocked" if bool(status_payload.get("account_total_loss_stop_active", False)) else "normal"
    instance_execution_stats = _build_instance_execution_stats()
    runtime_instances_enriched: list[dict[str, Any]] = []
    for item in runtime_instances:
        if not isinstance(item, dict):
            continue
        sid = str(item.get("strategy_id", "")).strip()
        instance_id = str(item.get("instance_id", "")).strip()
        execution_stats = (
            instance_execution_stats.get(instance_id, {})
            if instance_id
            else {}
        )
        position_state = (
            position_runtime.get("byInstance", {}).get(instance_id, {})
            if isinstance(position_runtime.get("byInstance", {}), dict)
            else {}
        )
        health = health_map.get(sid, {}) if isinstance(health_map.get(sid, {}), dict) else {}
        healthy = bool(health.get("healthy", False))
        consecutive_failures = int(health.get("consecutive_failures", 0) or 0)
        health_score = max(45, 95 - min(40, consecutive_failures * 8))
        if healthy and consecutive_failures == 0:
            health_score = 92
        runtime_instances_enriched.append(
            {
                **item,
                "schedulerState": str(item.get("status", "running") or "running"),
                "instanceWeight": float(
                    effective_projection.get("instanceWeights", {}).get(str(item.get("instance_id", "")).strip(), 0.0)
                    if isinstance(effective_projection.get("instanceWeights", {}), dict)
                    else 0.0
                ),
                "health": {
                    "status": (
                        str(
                            compact_risk_protocol.get("instance_risk_state", {}).get(instance_id, "")
                            if isinstance(compact_risk_protocol.get("instance_risk_state", {}), dict)
                            else ""
                        )
                        or ("ok" if healthy or consecutive_failures == 0 else "warn")
                    ),
                    "score": float(
                        compact_risk_protocol.get("instance_health_score", {}).get(instance_id, health_score)
                        if isinstance(compact_risk_protocol.get("instance_health_score", {}), dict)
                        else health_score
                    ),
                },
                "risk": {
                    "status": str(
                        compact_risk_protocol.get("instance_risk_state", {}).get(instance_id, risk_state)
                        if isinstance(compact_risk_protocol.get("instance_risk_state", {}), dict)
                        else risk_state
                    ),
                    "decision": str(compact_risk_protocol.get("decision", "") or ""),
                    "riskCode": str(compact_risk_protocol.get("risk_code", "") or ""),
                },
                "positionState": position_state,
                "orders": {
                    "today": int(execution_stats.get("today", 0) or 0),
                    "reject": int(execution_stats.get("reject", 0) or 0),
                    "latestFilledAt": str(execution_stats.get("latestFilledAt", "") or ""),
                    "latestAction": str(execution_stats.get("latestAction", "") or ""),
                    "latestStatus": str(execution_stats.get("latestStatus", "") or ""),
                    "fallbackTodayTrades": today_orders,
                },
            }
        )
    runtime_instances_enriched.sort(
        key=lambda row: (str(row.get("schedulerState", "")) != "running", str(row.get("strategy_name", "")))
    )
    final_reject = status_payload.get("final_reject", {}) if isinstance(status_payload.get("final_reject", {}), dict) else {}
    governor_blockers: list[str] = _normalize_blocker_reasons(
        compact_risk_protocol.get("governor_blockers", [])
    )
    if bool(status_payload.get("account_total_loss_stop_active", False)):
        governor_blockers.append("account_total_loss_stop_active")
    final_reject_reason = str(final_reject.get("reason", "")).strip()
    if final_reject_reason:
        governor_blockers.append(final_reject_reason)
    governor_blockers = _normalize_blocker_reasons(governor_blockers)
    strategy_weights = (
        effective_projection.get("strategyWeights", {})
        if isinstance(effective_projection.get("strategyWeights", {}), dict)
        else {}
    )
    active_strategy_ids = _clean_str_list(effective_projection.get("activeStrategyIds"))
    active_instance_ids = _clean_str_list(effective_projection.get("activeInstanceIds"))
    instance_weights = (
        effective_projection.get("instanceWeights", {})
        if isinstance(effective_projection.get("instanceWeights", {}), dict)
        else {}
    )
    instance_health_score: dict[str, float] = {}
    instance_risk_state: dict[str, str] = {}
    strategy_budget_usage_rows: dict[str, dict[str, Any]] = {}
    for row in runtime_instances_enriched:
        if not isinstance(row, dict):
            continue
        instance_id = str(row.get("instance_id", "")).strip()
        strategy_id = str(row.get("strategy_id", "")).strip()
        allocation_pct = round(max(0.0, float(row.get("allocation", 0.0) or 0.0)) * 100.0, 2)
        if instance_id:
            instance_health_score[instance_id] = round(
                float(row.get("health", {}).get("score", 0.0) or 0.0)
                if isinstance(row.get("health", {}), dict)
                else 0.0,
                2,
            )
            instance_risk_state[instance_id] = str(
                row.get("risk", {}).get("status", risk_state)
                if isinstance(row.get("risk", {}), dict)
                else risk_state
            )
        if not strategy_id:
            continue
        bucket = strategy_budget_usage_rows.setdefault(
            strategy_id,
            {
                "strategy_id": strategy_id,
                "instance_count": 0,
                "allocation_pct": 0.0,
            },
        )
        bucket["instance_count"] = int(bucket.get("instance_count", 0) or 0) + 1
        bucket["allocation_pct"] = round(
            float(bucket.get("allocation_pct", 0.0) or 0.0) + allocation_pct,
            2,
        )
    strategy_ids_for_budget = sorted(
        {
            str(item)
            for item in list(strategy_budget_usage_rows.keys()) + list(strategy_weights.keys()) + active_strategy_ids
            if str(item).strip()
        }
    )
    strategy_budget_usage: list[dict[str, Any]] = []
    for strategy_id in strategy_ids_for_budget:
        bucket = strategy_budget_usage_rows.get(strategy_id, {})
        weight_pct = round(max(0.0, float(strategy_weights.get(strategy_id, 0.0) or 0.0)) * 100.0, 2)
        allocation_pct = round(float(bucket.get("allocation_pct", 0.0) or 0.0), 2)
        strategy_budget_usage.append(
            {
                "strategy_id": strategy_id,
                "instance_count": int(bucket.get("instance_count", 0) or 0),
                "allocation_pct": allocation_pct,
                "weight_pct": weight_pct,
                "risk_budget_pct": max(weight_pct, allocation_pct),
                "state": "blocked" if risk_state == "blocked" else "normal",
            }
        )
    global_loss_limit_pct = float(
        status_payload.get(
            "account_total_loss_stop_pct",
            strategy_rules.get("risk_governor", {}).get("account_total_loss_stop_pct", 0.0),
        )
        or 0.0
    )
    global_loss_current_pct = float(status_payload.get("account_total_loss_current_pct", 0.0) or 0.0)
    risk_budget_usage = {
        "global": {
            "limit_pct": round(global_loss_limit_pct, 2),
            "current_pct": round(global_loss_current_pct, 2),
            "remaining_pct": round(max(0.0, global_loss_limit_pct - global_loss_current_pct), 2),
            "state": risk_state,
        },
        "strategy": strategy_budget_usage,
        "instance": [
            {
                "instance_id": str(item.get("instance_id", "")).strip(),
                "strategy_id": str(item.get("strategy_id", "")).strip(),
                "allocation_pct": round(max(0.0, float(item.get("allocation", 0.0) or 0.0)) * 100.0, 2),
                "health_score": instance_health_score.get(str(item.get("instance_id", "")).strip(), 0.0),
                "risk_state": instance_risk_state.get(str(item.get("instance_id", "")).strip(), risk_state),
            }
            for item in runtime_instances_enriched
            if isinstance(item, dict)
        ],
    }
    risk_protocol = {
        "version": "v1",
        "generated_at": datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
        "active_strategy_ids": active_strategy_ids,
        "active_instance_ids": active_instance_ids,
        "instance_weights": instance_weights,
        "strategy_weights": strategy_weights,
        "risk_budget_usage": risk_budget_usage,
        "governor_blockers": governor_blockers,
        "instance_health_score": instance_health_score,
        "instance_risk_state": instance_risk_state,
        "per_instance_health_details": (
            compact_risk_protocol.get("per_instance_health_details", {})
            if isinstance(compact_risk_protocol.get("per_instance_health_details", {}), dict)
            else {}
        ),
        "cross_strategy_correlation": (
            compact_risk_protocol.get("cross_strategy_correlation", {})
            if isinstance(compact_risk_protocol.get("cross_strategy_correlation", {}), dict)
            else {}
        ),
        "voting_weights": (
            compact_risk_protocol.get("voting_weights", {})
            if isinstance(compact_risk_protocol.get("voting_weights", {}), dict)
            else {}
        ),
        "decision": str(compact_risk_protocol.get("decision", "") or ""),
        "risk_code": str(compact_risk_protocol.get("risk_code", "") or ""),
        "risk_layer": str(compact_risk_protocol.get("risk_layer", "") or ""),
        "risk_source": str(compact_risk_protocol.get("risk_source", "") or ""),
        "blocking_rule_id": str(compact_risk_protocol.get("blocking_rule_id", "") or ""),
        "execution_owner": (
            compact_risk_protocol.get("execution_owner", {})
            if isinstance(compact_risk_protocol.get("execution_owner", {}), dict)
            else {}
        ),
        "source_layer": str(compact_risk_protocol.get("source_layer", "") or "derived"),
    }
    arbitration = _build_runtime_arbitration_payload()
    arbitration["entryOwnerInstanceId"] = str(compact_arbitration.get("entry_owner_instance_id", "") or "")
    arbitration["entryOwnerStrategyId"] = str(compact_arbitration.get("entry_owner_strategy_id", "") or "")
    arbitration["selectedInstanceId"] = str(compact_arbitration.get("selected_instance_id", "") or "")
    arbitration["selectedStrategyId"] = str(compact_arbitration.get("selected_strategy_id", "") or "")
    arbitration["sourceLayer"] = str(compact_arbitration.get("source_layer", "") or "derived")

    return {
        "current": {
            **(
                strategy_projection.get("current", {})
                if isinstance(strategy_projection.get("current", {}), dict)
                else {}
            ),
            "registeredCount": len(strategies),
        },
        "manifestSource": {
            "runtimeStatusFile": str(LOGS_DIR / "strategy_runtime_status.json"),
            "effectiveParamsFile": str(LOGS_DIR / "effective_params.json"),
            "strategyRulesFile": str(strategy_file),
        },
        "switching": {
            "canSwitch": True,
            "persistByDefault": True,
            "registered": runtime_payload.get("registered", []),
        },
        "strategyRules": {
            "riskGovernor": risk_governor,
            "positionSizing": position_sizing,
        },
        "strategyProjection": {
            "configured": configured_projection,
            "runtime": (
                strategy_projection.get("runtime", {})
                if isinstance(strategy_projection.get("runtime", {}), dict)
                else {}
            ),
            "fallback": fallback_projection,
            "effective": effective_projection,
        },
        "runtimeScheduler": {
            "mode": str(effective_projection.get("mode", "single_active") or "single_active"),
            "arbiter": str(effective_projection.get("arbiter", "allocation-weighted") or "allocation-weighted"),
            "activeInstances": (
                scheduler_payload.get("active_instances", [])
                if isinstance(scheduler_payload.get("active_instances", []), list)
                else []
            ),
            "activeInstanceIds": active_instance_ids,
            "activeStrategyIds": active_strategy_ids,
            "instanceWeights": instance_weights,
            "weights": strategy_weights,
            "strategyInstanceMap": (
                scheduler_payload.get("strategy_instance_map", {})
                if isinstance(scheduler_payload.get("strategy_instance_map", {}), dict)
                else {}
            ),
            "selectedStrategyId": str(effective_projection.get("selectedStrategyId", "") or ""),
            "selectedStrategyName": str(effective_projection.get("selectedStrategyName", "") or ""),
            "selectedInstanceId": str(effective_projection.get("selectedInstanceId", "") or ""),
            "updatedAt": str(
                effective_projection.get("generatedAt")
                or runtime_scheduler_snapshot.get("generated_at", "")
                or fallback_projection.get("updatedAt", "")
                or runtime_payload_updated_at
                or runtime_scheduler_updated_at
                or market_state_updated_at
                or ""
            ),
            "reason": str(effective_projection.get("reason", "allocation-weighted") or "allocation-weighted"),
            "sourceLayer": str(effective_projection.get("sourceLayer", "configured") or "configured"),
            "source": str(
                (
                    strategy_projection.get("current", {}).get("runtimeSource", "")
                    if isinstance(strategy_projection.get("current", {}), dict)
                    else ""
                )
                or "none"
            ),
            "fieldSources": (
                effective_projection.get("fieldSources", {})
                if isinstance(effective_projection.get("fieldSources", {}), dict)
                else {}
            ),
            "snapshotId": str(effective_projection.get("snapshotId", "") or ""),
            "generatedAt": str(effective_projection.get("generatedAt", "") or ""),
            "sourceVersions": (
                effective_projection.get("sourceVersions", {})
                if isinstance(effective_projection.get("sourceVersions", {}), dict)
                else {}
            ),
            "configuredStrategyId": str(configured_projection.get("strategyId", "") or ""),
            "configuredStrategyName": str(configured_projection.get("strategyName", "") or ""),
            "entryOwnerInstanceId": str(compact_arbitration.get("entry_owner_instance_id", "") or ""),
            "entryOwnerStrategyId": str(compact_arbitration.get("entry_owner_strategy_id", "") or ""),
            "executionOwner": (
                compact_risk_protocol.get("execution_owner", {})
                if isinstance(compact_risk_protocol.get("execution_owner", {}), dict)
                else {}
            ),
            "runtime": (
                strategy_projection.get("runtime", {})
                if isinstance(strategy_projection.get("runtime", {}), dict)
                else {}
            ),
            "fallback": fallback_projection,
        },
        "riskProtocol": risk_protocol,
        "positionRuntime": position_runtime,
        "arbitration": arbitration,
        "decisionPolicy": decision_policy,
        "runtimeInstances": runtime_instances_enriched,
        "strategies": strategies,
    }


def _build_template_diff(
    market_template: dict[str, Any],
    instance_row: dict[str, Any],
) -> dict[str, Any]:
    market_default = market_template if isinstance(market_template, dict) else {}
    template_snapshot = (
        instance_row.get("template_snapshot", {})
        if isinstance(instance_row.get("template_snapshot", {}), dict)
        else {}
    )
    param_overrides = (
        instance_row.get("param_overrides", {})
        if isinstance(instance_row.get("param_overrides", {}), dict)
        else {}
    )
    keys = sorted(
        {
            str(key)
            for key in list(market_default.keys())
            + list(template_snapshot.keys())
            + list(param_overrides.keys())
            if str(key).strip()
        }
    )
    entries: list[dict[str, Any]] = []
    changed_from_market = 0
    override_count = 0
    snapshot_drift_count = 0
    for key in keys:
        market_value = market_default.get(key)
        snapshot_value = template_snapshot.get(key, market_value)
        effective_value = param_overrides.get(key, snapshot_value)
        has_override = key in param_overrides
        drift_from_market = effective_value != market_value
        snapshot_drift = snapshot_value != market_value
        if has_override:
            override_count += 1
        if snapshot_drift:
            snapshot_drift_count += 1
        if drift_from_market:
            changed_from_market += 1
        if not (has_override or drift_from_market or snapshot_drift):
            continue
        entries.append(
            {
                "key": key,
                "marketDefault": market_value,
                "instanceSnapshot": snapshot_value,
                "effectiveValue": effective_value,
                "overrideValue": param_overrides.get(key),
                "changedFromMarket": drift_from_market,
                "changedFromSnapshot": effective_value != snapshot_value,
                "hasOverride": has_override,
                "snapshotDrift": snapshot_drift,
            }
        )
    return {
        "summary": {
            "overrideCount": override_count,
            "changedKeys": changed_from_market,
            "snapshotDriftCount": snapshot_drift_count,
            "isDirty": bool(entries),
        },
        "entries": entries,
        "marketDefault": market_default,
        "instanceOverrides": param_overrides,
        "templateSnapshot": template_snapshot,
    }


def _build_bridge_runtime_snapshot(
    status_payload: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
    status_payload = status_payload or get_status()
    execution_rows = _collect_execution_signal_rows(limit=24)
    active_rows = execution_rows.get("active", []) if isinstance(execution_rows.get("active", []), list) else []
    recent_rows = execution_rows.get("recent", []) if isinstance(execution_rows.get("recent", []), list) else []
    blocked_rows = [
        row for row in recent_rows if str(row.get("state", "")).upper() in {"REJECTED", "VETOED"}
    ]
    timed_out_rows = [
        row for row in recent_rows if str(row.get("state", "")).upper() == "TIMEOUT"
    ]
    retrying_rows = [
        row for row in active_rows if "retry" in str(row.get("reason", "") or "").lower()
    ]
    delayed_rows = [
        row
        for row in active_rows
        if float(time.time() - float(row.get("latestTs", 0.0) or 0.0)) >= 45.0
    ]
    pending_exit_rows = [
        row
        for row in active_rows
        if str(row.get("action", "")).upper() in {"CLOSE", "REDUCE"}
    ]

    terminal_status_file = None
    heartbeat_age_sec = None
    to_ea_queue_depth = 0
    from_ea_queue_depth = 0
    try:
        mt5_dir = config_manager.get_mt5_dir()
        if mt5_dir:
            terminal_status_file = _resolve_terminal_status_file(mt5_dir)
            if terminal_status_file and terminal_status_file.exists():
                heartbeat_age_sec = round(time.time() - terminal_status_file.stat().st_mtime, 1)
    except Exception:
        terminal_status_file = None

    request_file = DATA_DIR / "inbox" / "request.json"
    report_file = DATA_DIR / "inbox" / "execution_report.json"
    if request_file.exists():
        to_ea_queue_depth += 1
    if report_file.exists():
        from_ea_queue_depth += 1

    ledger_path = _resolve_existing_file(_runtime_log_candidates("trading_ledger.ndjson"))
    request_summary = {
        "acked": 0,
        "filled": 0,
        "rejected": 0,
        "timeout": 0,
        "pending": len(active_rows),
    }
    recent_requests: list[dict[str, Any]] = []
    if ledger_path is not None:
        for payload in _read_ndjson_tail(ledger_path, limit=10):
            status_text = str(payload.get("status", "") or "").upper()
            if status_text == "ACKED":
                request_summary["acked"] += 1
            elif status_text == "FILLED":
                request_summary["filled"] += 1
            elif status_text in {"REJECTED", "RETRY_EXHAUSTED", "INIT_FAILED"}:
                request_summary["rejected"] += 1
            elif status_text == "TIMEOUT":
                request_summary["timeout"] += 1
            recent_requests.append(
                {
                    "ts": str(payload.get("ts", "") or ""),
                    "instanceId": str(payload.get("instance_id", "") or ""),
                    "strategyId": str(payload.get("strategy", payload.get("strategy_id", "")) or ""),
                    "action": str(payload.get("action", "") or "").upper(),
                    "side": str(payload.get("side", "") or "").upper(),
                    "status": status_text,
                    "ticket": str(payload.get("ticket", "") or ""),
                    "volume": float(payload.get("volume", 0.0) or 0.0),
                    "price": payload.get("price"),
                    "reason": str(payload.get("reason", payload.get("message", "")) or ""),
                }
            )

    focus_rows = (
        [{"kind": "blocked", "count": len(blocked_rows), "items": blocked_rows[:3]}]
        + [{"kind": "delayed", "count": len(delayed_rows), "items": delayed_rows[:3]}]
        + [{"kind": "retrying", "count": len(retrying_rows), "items": retrying_rows[:3]}]
        + [{"kind": "timed_out", "count": len(timed_out_rows), "items": timed_out_rows[:3]}]
        + [{"kind": "pending_exit", "count": len(pending_exit_rows), "items": pending_exit_rows[:3]}]
    )
    focus_rows = [item for item in focus_rows if int(item.get("count", 0) or 0) > 0]

    return {
        "focus": focus_rows,
        "queue": {
            "toEaDepth": to_ea_queue_depth,
            "fromEaDepth": from_ea_queue_depth,
            "activeSignals": len(active_rows),
            "pendingExit": len(pending_exit_rows),
        },
        "health": {
            "bridgeOk": bool(status_payload.get("bridgeOk", False)),
            "mt5Online": bool(status_payload.get("mt5Online", False)),
            "heartbeatAgeSec": heartbeat_age_sec,
            "terminalStatusFile": str(terminal_status_file) if terminal_status_file else "",
            "requestSummary": request_summary,
        },
        "requests": recent_requests,
    }


def _build_agent_diagnostics(
    runtime_instances: list[dict[str, Any]],
    marketplace_payload: dict[str, Any],
    arbitration_payload: dict[str, Any],
    runtime_observability: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
    decision_policy = (
        marketplace_payload.get("decisionPolicy", {})
        if isinstance(marketplace_payload.get("decisionPolicy", {}), dict)
        else {}
    )
    current = marketplace_payload.get("current", {}) if isinstance(marketplace_payload.get("current", {}), dict) else {}
    runtime_gateway = (
        runtime_observability.get("gateway", {})
        if isinstance(runtime_observability, dict) and isinstance(runtime_observability.get("gateway", {}), dict)
        else {}
    )
    decision_contract = (
        runtime_gateway.get("decisionContract", {})
        if isinstance(runtime_gateway.get("decisionContract", {}), dict)
        else {}
    )
    lineage = (
        decision_contract.get("lineage", {})
        if isinstance(decision_contract.get("lineage", {}), dict)
        else {}
    )
    contributors = (
        arbitration_payload.get("contributors", [])
        if isinstance(arbitration_payload.get("contributors", []), list)
        else []
    )
    suppressions = [
        {
            "instanceId": str(item.get("instanceId", "") or ""),
            "strategyId": str(item.get("strategyId", "") or ""),
            "reason": str(item.get("suppressionReason", "") or "suppressed"),
            "rawReason": str(item.get("rawReason", "") or ""),
        }
        for item in contributors
        if isinstance(item, dict) and bool(item.get("wasSuppressed", False))
    ]

    profile_warnings: list[dict[str, Any]] = []
    if not current.get("activeInstanceIds"):
        profile_warnings.append(
            {
                "kind": "scheduler",
                "message": "当前没有运行实例，调度层无法产出实盘候选",
            }
        )
    if str(decision_policy.get("profile", "") or "").strip() != "agent_guided_multi_strategy":
        profile_warnings.append(
            {
                "kind": "policy_profile",
                "message": f"当前 profile 为 `{decision_policy.get('profile', 'unknown')}`，未使用多 agent 多策略统一协议",
            }
        )
    for key in ("macro", "interpreter", "decision_policy"):
        if not str(lineage.get(key, "") or "").strip():
            profile_warnings.append(
                {
                    "kind": "contract_lineage",
                    "message": f"统一合同缺少 `{key}` 来源，可能仍有旧链路回退",
                }
            )

    override_warnings: list[dict[str, Any]] = []
    for item in runtime_instances:
        if not isinstance(item, dict):
            continue
        diff = item.get("templateDiff", {}) if isinstance(item.get("templateDiff", {}), dict) else {}
        summary = diff.get("summary", {}) if isinstance(diff.get("summary", {}), dict) else {}
        if int(summary.get("overrideCount", 0) or 0) <= 0 and int(summary.get("changedKeys", 0) or 0) <= 0:
            continue
        override_warnings.append(
            {
                "instanceId": str(item.get("instanceId", "") or ""),
                "strategyId": str(item.get("strategyId", "") or ""),
                "overrideCount": int(summary.get("overrideCount", 0) or 0),
                "changedKeys": int(summary.get("changedKeys", 0) or 0),
                "message": "实例已偏离市场默认模板，需确认是否保留覆写",
            }
        )

    return {
        "summary": {
            "suppressionCount": len(suppressions),
            "profileWarningCount": len(profile_warnings),
            "overrideWarningCount": len(override_warnings),
        },
        "suppressions": suppressions,
        "profileWarnings": profile_warnings,
        "overrideWarnings": override_warnings,
    }


def _build_instance_runtime_view(
    instance_row: dict[str, Any],
    market_template: dict[str, Any],
    risk_protocol: dict[str, Any],
    arbitration_payload: dict[str, Any],
    decision_policy: dict[str, Any],
    bridge_requests: list[dict[str, Any]],
) -> dict[str, Any]:
    instance_id = str(instance_row.get("instance_id", "") or "")
    strategy_id = str(instance_row.get("strategy_id", "") or "")
    contributor_map = {
        str(item.get("instanceId", "") or ""): item
        for item in (
            arbitration_payload.get("contributors", [])
            if isinstance(arbitration_payload.get("contributors", []), list)
            else []
        )
        if isinstance(item, dict)
    }
    contributor = contributor_map.get(instance_id, {})
    position_state = (
        instance_row.get("positionState", {})
        if isinstance(instance_row.get("positionState", {}), dict)
        else {}
    )
    orders = instance_row.get("orders", {}) if isinstance(instance_row.get("orders", {}), dict) else {}
    health = instance_row.get("health", {}) if isinstance(instance_row.get("health", {}), dict) else {}
    template_diff = _build_template_diff(market_template, instance_row)
    instance_risk_state = (
        risk_protocol.get("instance_risk_state", {})
        if isinstance(risk_protocol.get("instance_risk_state", {}), dict)
        else {}
    )
    instance_health_score = (
        risk_protocol.get("instance_health_score", {})
        if isinstance(risk_protocol.get("instance_health_score", {}), dict)
        else {}
    )
    per_instance_health_details = (
        risk_protocol.get("per_instance_health_details", {})
        if isinstance(risk_protocol.get("per_instance_health_details", {}), dict)
        else {}
    )

    def _get_health_detail(key: str, default: Any = None) -> Any:
        inst_detail = per_instance_health_details.get(instance_id, {}) if isinstance(per_instance_health_details.get(instance_id, {}), dict) else {}
        return inst_detail.get(key, default)
    selected_instance_id = str(
        arbitration_payload.get("summary", {}).get("selectedInstanceId", "")
        if isinstance(arbitration_payload.get("summary", {}), dict)
        else ""
    )
    blocked_reason = (
        str(contributor.get("rawRejectReason", "") or "")
        or str(contributor.get("suppressionReason", "") or "")
    )
    timeline = [
        {"label": "deployed", "value": str(instance_row.get("deployed_at", "") or "")},
        {"label": "updated", "value": str(instance_row.get("updated_at", "") or "")},
        {"label": "latest_fill", "value": str(orders.get("latestFilledAt", "") or "")},
        {"label": "latest_status", "value": str(orders.get("latestStatus", "") or "")},
    ]
    timeline = [item for item in timeline if str(item.get("value", "") or "").strip()]
    instance_requests = [
        item
        for item in bridge_requests
        if isinstance(item, dict)
        and (
            str(item.get("instanceId", "") or "") == instance_id
            or str(item.get("strategyId", "") or "") == strategy_id
        )
    ][:4]
    return {
        "instanceId": instance_id,
        "strategyId": strategy_id,
        "strategyName": str(instance_row.get("strategy_name", strategy_id) or strategy_id),
        "strategyVersion": str(instance_row.get("strategy_version", "legacy") or "legacy"),
        "role": str(instance_row.get("role", "live_support") or "live_support"),
        "allocationPct": round(max(0.0, float(instance_row.get("allocation", 0.0) or 0.0)) * 100.0, 2),
        "active": str(instance_row.get("schedulerState", "running") or "running") == "running",
        "selected": instance_id == selected_instance_id,
        "scheduler": {
            "state": str(instance_row.get("schedulerState", "running") or "running"),
            "weight": round(float(instance_row.get("instanceWeight", 0.0) or 0.0), 4),
        },
        "health": {
            "status": str(health.get("status", "warn") or "warn"),
            "score": round(float(health.get("score", 0.0) or 0.0), 2),
            "runtimeScore": round(float(instance_health_score.get(instance_id, 0.0) or 0.0), 2),
            "grade": str(_get_health_detail("grade", "") or ""),
            "factorScores": {
                "winRate": round(float(_get_health_detail("win_rate_score", 0.0) or 0.0), 1),
                "profitFactor": round(float(_get_health_detail("profit_factor_score", 0.0) or 0.0), 1),
                "consistency": round(float(_get_health_detail("consistency_score", 0.0) or 0.0), 1),
                "regimeFit": round(float(_get_health_detail("regime_fit_score", 0.0) or 0.0), 1),
                "recency": round(float(_get_health_detail("recency_score", 0.0) or 0.0), 1),
            },
            "primaryLossPattern": str(_get_health_detail("primary_loss_pattern", "") or ""),
            "bestRegime": str(_get_health_detail("best_regime", "") or ""),
            "bestRegimeWinRate": round(float(_get_health_detail("best_regime_win_rate", 0.0) or 0.0), 4),
            "currentRegimeFitness": round(float(_get_health_detail("current_regime_fitness", 0.0) or 0.0), 4),
            "rehabilitationProgress": round(float(_get_health_detail("rehabilitation_progress", 0.0) or 0.0), 4),
            "suggestedAction": str(_get_health_detail("suggested_action", "normal") or "normal"),
            "suggestedScale": round(float(_get_health_detail("suggested_scale", 1.0) or 1.0), 4),
            "lossStreak": int(_get_health_detail("loss_streak", 0) or 0),
        },
        "risk": {
            "status": str(instance_risk_state.get(instance_id, instance_row.get("risk", {}).get("status", "normal")) or "normal"),
            "globalBlocked": bool(risk_protocol.get("risk_budget_usage", {}).get("global", {}).get("state") == "blocked")
            if isinstance(risk_protocol.get("risk_budget_usage", {}), dict)
            else False,
            "blockedReason": blocked_reason,
            "governorBlockers": risk_protocol.get("governor_blockers", [])
            if isinstance(risk_protocol.get("governor_blockers", []), list)
            else [],
        },
        "orders": orders,
        "positionState": position_state,
        "signal": {
            "action": str(contributor.get("action", "HOLD") or "HOLD"),
            "side": str(contributor.get("side", "NONE") or "NONE"),
            "confidence": float(contributor.get("confidence", 0.0) or 0.0),
            "contributionScore": float(contributor.get("signedScore", 0.0) or 0.0),
            "reason": str(contributor.get("rawReason", "") or ""),
            "wasSelected": bool(contributor.get("wasSelected", False)),
            "wasSuppressed": bool(contributor.get("wasSuppressed", False)),
            "suppressionReason": str(contributor.get("suppressionReason", "") or ""),
        },
        "agentProfile": {
            "decisionProfile": str(decision_policy.get("profile", "unknown") or "unknown"),
            "macroRole": str(
                decision_policy.get("macro", {}).get("role", "unknown")
                if isinstance(decision_policy.get("macro", {}), dict)
                else "unknown"
            ),
            "interpreterRole": str(
                decision_policy.get("interpreter", {}).get("role", "unknown")
                if isinstance(decision_policy.get("interpreter", {}), dict)
                else "unknown"
            ),
            "riskRole": str(
                decision_policy.get("risk", {}).get("role", "unknown")
                if isinstance(decision_policy.get("risk", {}), dict)
                else "unknown"
            ),
        },
        "templateDiff": template_diff,
        "bridge": {
            "activeSignals": position_state.get("activeSignals", [])
            if isinstance(position_state.get("activeSignals", []), list)
            else [],
            "recentRequests": instance_requests,
        },
        "timeline": timeline,
    }


def _build_runtime_operations_payload(
    status_payload: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
    status_payload = status_payload or get_status()
    runtime_observability = get_runtime_observability()
    marketplace = _build_strategy_marketplace_payload(status_payload=status_payload)
    arbitration = _build_runtime_arbitration_payload()
    bridge_runtime = _build_bridge_runtime_snapshot(status_payload=status_payload)
    decision_policy = (
        marketplace.get("decisionPolicy", {})
        if isinstance(marketplace.get("decisionPolicy", {}), dict)
        else {}
    )
    risk_protocol = (
        marketplace.get("riskProtocol", {})
        if isinstance(marketplace.get("riskProtocol", {}), dict)
        else {}
    )
    market_state = _load_strategy_market_state()
    market_templates = {
        str(strategy_id): (
            row.get("template", {})
            if isinstance(row, dict) and isinstance(row.get("template", {}), dict)
            else {}
        )
        for strategy_id, row in (
            market_state.get("strategies", {})
            if isinstance(market_state.get("strategies", {}), dict)
            else {}
        ).items()
    }
    runtime_instances = [
        _build_instance_runtime_view(
            item,
            market_templates.get(str(item.get("strategy_id", "") or ""), {}),
            risk_protocol,
            arbitration,
            decision_policy,
            bridge_runtime.get("requests", []) if isinstance(bridge_runtime.get("requests", []), list) else [],
        )
        for item in (
            marketplace.get("runtimeInstances", [])
            if isinstance(marketplace.get("runtimeInstances", []), list)
            else []
        )
        if isinstance(item, dict)
    ]
    runtime_agent_diagnostics = _build_agent_diagnostics(
        runtime_instances,
        marketplace,
        arbitration,
        runtime_observability if isinstance(runtime_observability, dict) else {},
    )
    risk_governor = _build_risk_governor_snapshot(status_payload=status_payload)
    runtime_status = {
        "gateway": {
            "daemonRunning": bool(status_payload.get("isRunning", False)),
            "mt5Online": bool(status_payload.get("mt5Online", False)),
            "bridgeOk": bool(status_payload.get("bridgeOk", False)),
            "light": str(status_payload.get("light", "RED") or "RED"),
        },
        "market": {
            "bid": float(status_payload.get("bid", 0.0) or 0.0),
            "ask": float(status_payload.get("ask", 0.0) or 0.0),
            "spread": float(status_payload.get("spread", 0.0) or 0.0),
            "regime": str(status_payload.get("regime", "UNKNOWN") or "UNKNOWN"),
        },
        "performance": {
            "todayTrades": int(status_payload.get("todayTrades", 0) or 0),
            "todayPnl": float(status_payload.get("todayPnl", 0.0) or 0.0),
            "todayWinRate": float(status_payload.get("todayWinRate", 0.0) or 0.0),
        },
        "riskGovernor": {
            "summary": risk_governor.get("summary", {}),
            "governor": risk_governor.get("governor", {}),
        },
    }
    runtime_scheduler = marketplace.get("runtimeScheduler", {}) if isinstance(marketplace.get("runtimeScheduler", {}), dict) else {}
    current = marketplace.get("current", {}) if isinstance(marketplace.get("current", {}), dict) else {}
    active_instance_ids = (
        runtime_scheduler.get("activeInstanceIds", [])
        if isinstance(runtime_scheduler.get("activeInstanceIds", []), list)
        else []
    )
    active_strategy_ids = (
        runtime_scheduler.get("activeStrategyIds", [])
        if isinstance(runtime_scheduler.get("activeStrategyIds", []), list)
        else []
    )
    runtime_scheduler["summary"] = {
        "activeInstanceCount": len(active_instance_ids),
        "activeStrategyCount": len(active_strategy_ids),
        "candidateStrategyCount": len(marketplace.get("strategies", []))
        if isinstance(marketplace.get("strategies", []), list)
        else 0,
        "suppressedCount": int(runtime_agent_diagnostics.get("summary", {}).get("suppressionCount", 0) or 0),
        "maxConcurrency": int(
            risk_governor.get("governor", {}).get("maxOpenPositions", 0)
            if isinstance(risk_governor.get("governor", {}), dict)
            else 0
        ),
    }
    runtime_explain = {
        "arbitration": arbitration,
        "current": current,
        "decisionPolicy": decision_policy,
        "riskProtocol": risk_protocol,
    }
    market_state_updated_ts = _parse_timestamp_value(market_state.get("updated_at")) or _file_mtime_ts(
        _resolve_runtime_artifact("config", "strategy_market_state.json")
    )
    runtime_status_updated_ts = _file_mtime_ts(_resolve_runtime_artifact("logs", "strategy_runtime_status.json"))
    orchestration_snapshot = {
        "updatedAt": _utc_iso_from_ts(max(market_state_updated_ts, runtime_status_updated_ts)),
        "configRevision": str(current.get("configRevision", "") or ""),
        "strategyVersions": {
            str(item.get("id", "") or ""): str(item.get("version", "") or "")
            for item in (
                marketplace.get("strategies", [])
                if isinstance(marketplace.get("strategies", []), list)
                else []
            )
            if isinstance(item, dict)
        },
        "supportsRollback": True,
        "runtimeInstanceCount": len(runtime_instances),
    }
    return {
        "success": True,
        "runtimeInstances": runtime_instances,
        "runtimeScheduler": runtime_scheduler,
        "runtimeExplain": runtime_explain,
        "runtimeAgentDiagnostics": runtime_agent_diagnostics,
        "runtimeBridgeQueue": bridge_runtime.get("queue", {}),
        "runtimeBridgeRequests": bridge_runtime.get("requests", []),
        "runtimeExecutionFocus": bridge_runtime.get("focus", []),
        "runtimeBridgeHealth": bridge_runtime.get("health", {}),
        "runtimeStatus": runtime_status,
        "orchestrationSnapshot": orchestration_snapshot,
    }


def _build_runtime_ai_evaluation(
    goal: str,
    *,
    status_payload: Optional[dict[str, Any]] = None,
    runtime_payload: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
    status_payload = status_payload or get_status()
    runtime_payload = runtime_payload or _build_runtime_operations_payload(status_payload=status_payload)
    runtime_status = (
        runtime_payload.get("runtimeStatus", {})
        if isinstance(runtime_payload.get("runtimeStatus", {}), dict)
        else {}
    )
    gateway = runtime_status.get("gateway", {}) if isinstance(runtime_status.get("gateway", {}), dict) else {}
    market = runtime_status.get("market", {}) if isinstance(runtime_status.get("market", {}), dict) else {}
    performance = (
        runtime_status.get("performance", {})
        if isinstance(runtime_status.get("performance", {}), dict)
        else {}
    )
    risk_governor = (
        runtime_status.get("riskGovernor", {})
        if isinstance(runtime_status.get("riskGovernor", {}), dict)
        else {}
    )
    risk_summary = (
        risk_governor.get("summary", {})
        if isinstance(risk_governor.get("summary", {}), dict)
        else {}
    )
    risk_state = (
        risk_governor.get("governor", {})
        if isinstance(risk_governor.get("governor", {}), dict)
        else {}
    )
    runtime_scheduler = (
        runtime_payload.get("runtimeScheduler", {})
        if isinstance(runtime_payload.get("runtimeScheduler", {}), dict)
        else {}
    )
    scheduler_summary = (
        runtime_scheduler.get("summary", {})
        if isinstance(runtime_scheduler.get("summary", {}), dict)
        else {}
    )
    runtime_explain = (
        runtime_payload.get("runtimeExplain", {})
        if isinstance(runtime_payload.get("runtimeExplain", {}), dict)
        else {}
    )
    arbitration = (
        runtime_explain.get("arbitration", {})
        if isinstance(runtime_explain.get("arbitration", {}), dict)
        else {}
    )
    arbitration_summary = (
        arbitration.get("summary", {})
        if isinstance(arbitration.get("summary", {}), dict)
        else {}
    )
    runtime_instances = (
        runtime_payload.get("runtimeInstances", [])
        if isinstance(runtime_payload.get("runtimeInstances", []), list)
        else []
    )
    bridge_health = (
        runtime_payload.get("runtimeBridgeHealth", {})
        if isinstance(runtime_payload.get("runtimeBridgeHealth", {}), dict)
        else {}
    )
    execution_focus = (
        runtime_payload.get("runtimeExecutionFocus", [])
        if isinstance(runtime_payload.get("runtimeExecutionFocus", []), list)
        else []
    )
    diagnostics = (
        runtime_payload.get("runtimeAgentDiagnostics", {})
        if isinstance(runtime_payload.get("runtimeAgentDiagnostics", {}), dict)
        else {}
    )
    diagnostics_summary = (
        diagnostics.get("summary", {})
        if isinstance(diagnostics.get("summary", {}), dict)
        else {}
    )

    candidates: list[dict[str, Any]] = []
    for item in runtime_instances:
        if not isinstance(item, dict):
            continue
        signal = item.get("signal", {}) if isinstance(item.get("signal", {}), dict) else {}
        risk = item.get("risk", {}) if isinstance(item.get("risk", {}), dict) else {}
        health = item.get("health", {}) if isinstance(item.get("health", {}), dict) else {}
        if str(risk.get("status", "normal") or "normal") == "blocked":
            continue
        candidates.append(
            {
                "strategyName": str(item.get("strategyName", item.get("strategyId", "")) or ""),
                "instanceId": str(item.get("instanceId", "") or ""),
                "score": float(signal.get("contributionScore", 0.0) or 0.0),
                "confidence": float(signal.get("confidence", 0.0) or 0.0),
                "healthScore": float(health.get("runtimeScore", health.get("score", 0.0)) or 0.0),
                "selected": bool(item.get("selected", False)),
            }
        )
    candidates.sort(
        key=lambda row: (
            row.get("score", 0.0),
            row.get("confidence", 0.0),
            row.get("healthScore", 0.0),
        ),
        reverse=True,
    )
    recommended_candidates = [row for row in candidates if float(row.get("score", 0.0)) > 0][:2]
    if not recommended_candidates:
        recommended_candidates = [row for row in candidates if bool(row.get("selected", False))][:2]
    recommended_combo_label = (
        " + ".join(
            [
                str(row.get("strategyName", "") or row.get("instanceId", "") or "unknown")
                for row in recommended_candidates
            ]
        )
        if recommended_candidates
        else "待评估"
    )

    final_action = str(arbitration_summary.get("finalAction", "UNKNOWN") or "UNKNOWN").upper()
    final_side = str(arbitration_summary.get("finalSide", "NONE") or "NONE").upper()
    confidence = float(arbitration_summary.get("confidence", 0.0) or 0.0)
    net_score = float(arbitration_summary.get("netScore", 0.0) or 0.0)
    spread = float(market.get("spread", 0.0) or 0.0)
    blocker_count = int(risk_summary.get("blockerCount", 0) or 0)
    suppressed_count = int(scheduler_summary.get("suppressedCount", 0) or 0)

    verdict = "pass"
    verdict_label = "可继续评估推进"
    if not bool(gateway.get("daemonRunning", False)) or not bool(gateway.get("mt5Online", False)):
        verdict = "blocked"
        verdict_label = "运行链未就绪"
    elif not bool(bridge_health.get("bridgeOk", False)):
        verdict = "blocked"
        verdict_label = "执行桥异常"
    elif bool(risk_summary.get("blocked", False)):
        verdict = "blocked"
        verdict_label = "全局风控阻断"
    elif final_action == "HOLD" or confidence < 0.2:
        verdict = "watch"
        verdict_label = "当前组合仍需观察"
    elif suppressed_count > 0 or len(execution_focus) > 0:
        verdict = "caution"
        verdict_label = "组合存在互搏或阻塞"

    goal_text = str(goal or "").strip() or "提高胜率并减少互搏"
    conclusion_parts: list[str] = []
    if verdict == "blocked":
        conclusion_parts.append("当前不建议直接调整活跃组合，先处理运行或风控阻断。")
    elif verdict == "watch":
        conclusion_parts.append("当前组合没有形成稳定可执行优势，暂不建议贸然扩大组合。")
    elif verdict == "caution":
        conclusion_parts.append("当前组合可继续运行，但要先压缩互搏和执行阻塞。")
    else:
        conclusion_parts.append("当前组合具备继续运行基础，可以围绕高贡献实例做微调。")
    conclusion_parts.append(f"本轮评估目标：{goal_text}。")

    recommendations: list[str] = []
    if recommended_combo_label != "待评估":
        recommendations.append(f"建议活跃组合优先保持 `{recommended_combo_label}`。")
    if suppressed_count > 0:
        recommendations.append(f"当前存在 {suppressed_count} 条压制记录，建议减少互相对冲的实例同时在线。")
    if final_action == "HOLD":
        recommendations.append("当前聚合结果仍是 HOLD，建议先提升高贡献实例的一致性，而不是继续叠加组合。")
    if len(execution_focus) > 0:
        recommendations.append("执行层已有异常聚焦，建议先清理执行堵点，再决定是否重组组合。")
    if not recommendations:
        recommendations.append("当前没有必须立刻调整的组合动作，可继续观察真实运行反馈。")

    risk_warnings: list[str] = []
    if blocker_count > 0:
        risk_warnings.append(f"全局风险总闸当前已有 {blocker_count} 条阻断信号。")
    if spread > 80:
        risk_warnings.append(f"当前点差 {spread:.2f} 偏高，重组组合也未必能立刻改善执行质量。")
    isolated_ids = (
        risk_state.get("isolatedStrategyIds", [])
        if isinstance(risk_state.get("isolatedStrategyIds", []), list)
        else []
    )
    if isolated_ids:
        risk_warnings.append(f"存在被隔离策略: {', '.join(str(item) for item in isolated_ids[:4])}。")
    if not bool(gateway.get("daemonRunning", False)) or not bool(gateway.get("mt5Online", False)):
        risk_warnings.append("运行链路未完全在线，评估结论仅供恢复链路前参考。")
    if not risk_warnings:
        risk_warnings.append("当前没有额外风控风险提示。")

    rationale_parts = [
        f"调度模式为 {str(runtime_scheduler.get('mode', 'idle') or 'idle')}，活跃实例 {int(scheduler_summary.get('activeInstanceCount', 0) or 0)} 个。",
        f"当前最终动作为 {final_action}/{final_side}，聚合置信度 {confidence * 100:.1f}%，加权净分 {net_score:.4f}。",
        f"今日成交 {int(performance.get('todayTrades', 0) or 0)} 笔，今日盈亏 {float(performance.get('todayPnl', 0.0) or 0.0):+.2f}。",
    ]
    if int(diagnostics_summary.get("suppressionCount", 0) or 0) > 0:
        rationale_parts.append(
            f"配置诊断里还有 {int(diagnostics_summary.get('suppressionCount', 0) or 0)} 条 suppression，需要关注是否仍在左右互博。"
        )
    now_iso = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
    history_entry = {
        "ts": now_iso,
        "goal": goal_text,
        "verdict": verdict,
        "verdictLabel": verdict_label,
        "recommendedComboLabel": recommended_combo_label,
        "conclusion": " ".join(conclusion_parts),
    }
    return {
        "target": goal_text,
        "verdict": verdict,
        "verdictLabel": verdict_label,
        "currentConclusion": " ".join(conclusion_parts),
        "recommendedComboLabel": recommended_combo_label,
        "finalAction": final_action,
        "finalSide": final_side,
        "confidence": round(confidence, 4),
        "netScore": round(net_score, 4),
        "recommendations": recommendations[:4],
        "riskWarnings": risk_warnings[:4],
        "rationale": " ".join(rationale_parts),
        "applySuggestionAvailable": False,
        "applySuggestionReason": "运行中自动应用组合建议暂未开放，先由运营确认评估结果。",
        "updatedAt": now_iso,
        "historyEntry": history_entry,
    }


def _build_risk_governor_snapshot(status_payload: Optional[dict[str, Any]] = None) -> dict[str, Any]:
    status_payload = status_payload or get_status()
    params_payload = get_params()
    params = params_payload.get("params", {}) if isinstance(params_payload.get("params", {}), dict) else {}
    lifecycle = get_governance_lifecycle()
    runtime_observability = get_runtime_observability()
    blackboard = _compact_blackboard_for_status(_get_blackboard_data())
    risk_block = status_payload.get("risk_block", {}) if isinstance(status_payload.get("risk_block", {}), dict) else {}
    final_reject = status_payload.get("final_reject", {}) if isinstance(status_payload.get("final_reject", {}), dict) else {}
    risk_protocol = (
        blackboard.get("Risk_Protocol", {})
        if isinstance(blackboard.get("Risk_Protocol", {}), dict)
        else {}
    )
    blockers: list[str] = _normalize_blocker_reasons(risk_protocol.get("governor_blockers", []))
    if bool(status_payload.get("account_total_loss_stop_active", False)):
        blockers.append("account_total_loss_stop_active")
    final_reject_reason = str(final_reject.get("reason", "")).strip()
    if final_reject_reason:
        blockers.append(final_reject_reason)
    blockers = _normalize_blocker_reasons(blockers)
    return {
        "summary": {
            "riskLevel": str(params.get("risk_level", "balanced")),
            "positionMultiplier": float(
                blackboard.get("Risk_Commissar", {}).get("position_multiplier", 1.0)
                if isinstance(blackboard.get("Risk_Commissar", {}), dict)
                else 1.0
            ),
            "blocked": len(blockers) > 0,
            "blockerCount": len(blockers),
            "accountLossCurrentPct": float(status_payload.get("account_total_loss_current_pct", 0.0) or 0.0),
            "accountLossStopPct": float(status_payload.get("account_total_loss_stop_pct", params.get("account_total_loss_stop_pct", 0.0)) or 0.0),
        },
        "governor": {
            "riskLevel": str(params.get("risk_level", "balanced")),
            "maxPositionSize": float(params.get("max_position_size", 0.1) or 0.1),
            "maxOpenPositions": int(params.get("max_open_positions", 1) or 1),
            "minConfidencePct": float(params.get("min_confidence", 70.0) or 70.0),
            "slPips": int(params.get("sl_pips", 0) or 0),
            "tpPips": int(params.get("tp_pips", 0) or 0),
            "accountTotalLossStopPct": float(params.get("account_total_loss_stop_pct", 0.0) or 0.0),
            "humanInTheLoop": bool(params.get("human_in_the_loop", True)),
            "accountTotalLossStopActive": bool(status_payload.get("account_total_loss_stop_active", False)),
            "accountTotalLossStopSource": str(status_payload.get("account_total_loss_stop_source", "NONE") or "NONE"),
            "riskBlock": risk_block,
            "finalReject": final_reject,
            "softLimits": status_payload.get("soft_limits", []),
            "blockers": blockers,
        },
        "blackboard": blackboard,
        "trainingLifecycle": lifecycle.get("trainingLifecycle", {}),
        "rollbackEvidence": lifecycle.get("rollbackEvidence", {}),
        "autoGovernance": lifecycle.get("autoGovernance", {}),
        "phaseReadiness": lifecycle.get("phaseReadiness", {}),
        "sessionRiskLens": runtime_observability.get("sessionRiskLens", {}),
        "macroPolling": runtime_observability.get("macroPolling", {}),
        "confidenceCalibration": runtime_observability.get("confidenceCalibration", {}),
    }


def _build_lifecycle_open_position_fallback() -> list[dict[str, Any]]:
    lifecycle_payload = load_position_lifecycle(DATA_DIR)
    current = (
        lifecycle_payload.get("current", {})
        if isinstance(lifecycle_payload.get("current", {}), dict)
        else {}
    )
    lifecycle_status = str(current.get("status", "") or "").strip().lower()
    side = str(current.get("side", "NONE") or "NONE").upper()
    volume = float(current.get("volume", current.get("volume_lots", 0.0)) or 0.0)
    entry_price = float(current.get("entry_price", 0.0) or 0.0)
    ticket = int(current.get("ticket", 0) or 0)
    terminal_positions_count = _read_terminal_status_positions_count()
    if lifecycle_status not in {"open", "opening", "closing"}:
        return []
    if side not in {"BUY", "SELL"} or volume <= 0 or entry_price <= 0 or ticket <= 0:
        return []
    if terminal_positions_count == 0:
        return []
    return [
        {
            "ticket": ticket,
            "side": side,
            "volume": volume,
            "entry": entry_price,
            "sl": float(current.get("sl", 0.0) or 0.0),
            "tp": float(current.get("tp", 0.0) or 0.0),
            "pnl": float(current.get("realized_pnl", 0.0) or 0.0),
            "symbol": str(current.get("symbol", "XAUUSD") or "XAUUSD"),
            "entry_owner_instance_id": str(current.get("entry_owner_instance_id", "") or ""),
            "entry_owner_strategy_id": str(current.get("entry_owner_strategy_id", "") or ""),
            "current_manager_instance_id": str(current.get("current_manager_instance_id", "") or ""),
            "current_manager_strategy_id": str(current.get("current_manager_strategy_id", "") or ""),
            "expected_exit_owner_instance_id": str(current.get("expected_exit_owner_instance_id", "") or ""),
            "expected_exit_owner_strategy_id": str(current.get("expected_exit_owner_strategy_id", "") or ""),
            "exit_owner_instance_id": str(current.get("exit_owner_instance_id", "") or ""),
            "exit_owner_strategy_id": str(current.get("exit_owner_strategy_id", "") or ""),
            "contributors": (
                current.get("contributors", [])
                if isinstance(current.get("contributors", []), list)
                else []
            ),
            "entry_signal_id": str(current.get("signal_id", "") or ""),
            "entry_decision_id": str(current.get("decision_id", "") or ""),
            "exit_signal_id": "",
            "attribution_status": "lifecycle_fallback",
            "lifecycle_status": lifecycle_status,
            "source": "position_lifecycle",
        }
    ]


def _build_execution_chain_snapshot(
    status_payload: Optional[dict[str, Any]] = None,
    marketplace_payload: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
    status_payload = status_payload or get_status()
    terminal_positions_count = _read_terminal_status_positions_count()
    open_positions = (
        status_payload.get("positions", [])
        if isinstance(status_payload.get("positions", []), list)
        else []
    )
    if terminal_positions_count == 0:
        open_positions = []
    if not open_positions and terminal_positions_count != 0:
        open_positions = _build_lifecycle_open_position_fallback()
    pending_payload = get_pending_signals()
    pending_signals = pending_payload.get("signals", []) if isinstance(pending_payload.get("signals", []), list) else []
    signal_rows = _collect_execution_signal_rows(limit=10)
    marketplace = (
        marketplace_payload
        if isinstance(marketplace_payload, dict)
        else _build_strategy_marketplace_payload(status_payload=status_payload)
    )
    current = marketplace.get("current", {}) if isinstance(marketplace.get("current", {}), dict) else {}
    strategy_projection = (
        marketplace.get("strategyProjection", {})
        if isinstance(marketplace.get("strategyProjection", {}), dict)
        else {}
    )
    configured_projection = (
        strategy_projection.get("configured", {})
        if isinstance(strategy_projection.get("configured", {}), dict)
        else {}
    )
    effective_projection = (
        strategy_projection.get("effective", {})
        if isinstance(strategy_projection.get("effective", {}), dict)
        else {}
    )
    runtime_scheduler = (
        marketplace.get("runtimeScheduler", {})
        if isinstance(marketplace.get("runtimeScheduler", {}), dict)
        else {}
    )
    strategy_rows = (
        marketplace.get("strategies", [])
        if isinstance(marketplace.get("strategies", []), list)
        else []
    )
    strategy_name_map = {
        str(item.get("id", "") or "").strip(): str(item.get("name", "") or str(item.get("id", "") or "")).strip()
        for item in strategy_rows
        if isinstance(item, dict) and str(item.get("id", "") or "").strip()
    }
    runtime_selected_strategy_id = _first_non_empty_str(
        runtime_scheduler.get("selectedStrategyId"),
        effective_projection.get("selectedStrategyId"),
        current.get("selectedStrategyId"),
        current.get("strategyId"),
    )
    runtime_active_strategy_ids = _clean_str_list(
        runtime_scheduler.get("activeStrategyIds")
        or effective_projection.get("activeStrategyIds")
        or current.get("activeStrategyIds")
    )
    runtime_selected_strategy_name = (
        str(runtime_scheduler.get("selectedStrategyName", "") or "").strip()
        or str(effective_projection.get("selectedStrategyName", "") or "").strip()
        or strategy_name_map.get(runtime_selected_strategy_id)
        or str(current.get("strategyName", "") or "").strip()
        or str(configured_projection.get("strategyName", "") or "").strip()
        or "未知策略"
    )
    return {
        "summary": {
            "openPositions": len(open_positions),
            "pendingReviews": len(pending_signals),
            "signalReviewPendingCount": len(pending_signals),
            "activeExecutions": len(signal_rows["active"]),
            "configuredStrategy": str(
                current.get("strategyId")
                or configured_projection.get("strategyId")
                or "ema_macd"
            ),
            "configuredStrategyName": str(
                current.get("strategyName")
                or configured_projection.get("strategyName")
                or "未知策略"
            ),
            "runtimeSelectedStrategyId": runtime_selected_strategy_id,
            "runtimeSelectedStrategyName": runtime_selected_strategy_name,
            "runtimeActiveStrategyIds": runtime_active_strategy_ids,
            "runtimeMode": str(
                runtime_scheduler.get("mode")
                or effective_projection.get("mode")
                or current.get("mode", "single_active")
                or "single_active"
            ),
            "runtimeArbiter": str(
                runtime_scheduler.get("arbiter")
                or effective_projection.get("arbiter")
                or current.get("arbiter", "allocation-weighted")
                or "allocation-weighted"
            ),
            "runtimeLoaded": bool(
                effective_projection.get("runtimeLoaded", current.get("runtimeLoaded", False))
            ),
            "runtimeSourceLayer": str(
                effective_projection.get("sourceLayer")
                or runtime_scheduler.get("sourceLayer")
                or current.get("sourceLayer", "configured")
                or "configured"
            ),
            "runtimeFieldSources": (
                effective_projection.get("fieldSources", {})
                if isinstance(effective_projection.get("fieldSources", {}), dict)
                else runtime_scheduler.get("fieldSources", {})
                if isinstance(runtime_scheduler.get("fieldSources", {}), dict)
                else {}
            ),
        },
        "openPositions": open_positions,
        "pendingSignals": pending_signals[:8],
        "signalReviewSignals": pending_signals[:8],
        "signalReviewPendingCount": len(pending_signals),
        "activeSignals": signal_rows["active"],
        "recentSignals": signal_rows["recent"],
    }


def _build_timeline_payload(limit: int = 40) -> dict[str, Any]:
    events: list[dict[str, Any]] = []
    source_counts: dict[str, int] = {}

    def push(event: dict[str, Any]) -> None:
        events.append(event)
        source = str(event.get("source", "other") or "other")
        source_counts[source] = source_counts.get(source, 0) + 1

    try:
        agent_dir = LOGS_DIR / "agent_decisions"
        if agent_dir.exists():
            for file_path in sorted(agent_dir.glob("direction_*.json"), key=os.path.getmtime, reverse=True)[:8]:
                payload = _read_json_file(file_path)
                if not payload:
                    continue
                file_mtime = file_path.stat().st_mtime
                parsed_ts = _parse_timestamp_value(payload.get("timestamp")) or _parse_timestamp_value(payload.get("updated"))
                # Prefer filesystem mtime as stable truth; only trust payload ts when it is close.
                ts = file_mtime
                if isinstance(parsed_ts, (int, float)) and abs(float(parsed_ts) - float(file_mtime)) <= 3600.0:
                    ts = float(parsed_ts)
                if ts is not None and (time.time() - float(ts)) > _TIMELINE_DIRECTION_MAX_AGE_SEC:
                    continue
                confidence = payload.get("confidence")
                if isinstance(confidence, (int, float)) and float(confidence) <= 1.0:
                    confidence_text = f"{float(confidence) * 100:.1f}%"
                else:
                    confidence_text = f"{float(confidence or 0):.1f}%"
                push(
                    _timeline_event(
                        source="direction",
                        ts=ts,
                        role="Data_Analyst",
                        event_type="analyze",
                        title=f"生成趋势预测: {payload.get('direction', '未知')}",
                        content=str(payload.get("reason", "") or "无趋势解读"),
                        data={"Confidence": confidence_text},
                    )
                )
    except Exception:
        pass

    try:
        review_dir = LOGS_DIR / "review_pending"
        if review_dir.exists():
            for file_path in sorted(review_dir.glob("*.review.json"), key=os.path.getmtime, reverse=True)[:8]:
                payload = _read_json_file(file_path)
                if not payload:
                    continue
                final_conf = float(payload.get("requested_confidence", payload.get("confidence", 0.0)) or 0.0)
                raw_conf = float(payload.get("confidence", 0.0) or 0.0)
                ts = file_path.stat().st_mtime
                push(
                    _timeline_event(
                        source="review_pending",
                        ts=ts,
                        role="Risk_Manager",
                        event_type="warning",
                        title="触发风控拦截",
                        content=(
                            f"系统拦截了一笔待执行请求，最终置信度 {final_conf * 100:.1f}% / "
                            f"原始 {raw_conf * 100:.1f}%。原因: {payload.get('reason', '未知')}"
                        ),
                        data={
                            "Action": payload.get("action"),
                            "Side": payload.get("side"),
                            "Entry": payload.get("entry"),
                        },
                    )
                )
    except Exception:
        pass

    try:
        signal_rows = _collect_execution_signal_rows(limit=10)
        for row in signal_rows["recent"]:
            push(
                _timeline_event(
                    source="execution_state",
                    ts=float(row.get("latestTs", 0.0) or 0.0),
                    role="Execution_State",
                    event_type="trade" if str(row.get("state", "")).upper() == "FILLED" else "analyze",
                    title=f"执行链状态更新: {row.get('state', 'UNKNOWN')}",
                    content=(
                        f"Signal {row.get('signalId', '-')}"
                        f" · {row.get('side', 'UNKNOWN')}"
                        f" · strategy={row.get('strategyId', 'unknown') or 'unknown'}"
                    ),
                    data={
                        "TraceId": row.get("traceId"),
                        "Reason": row.get("reason"),
                        "Action": row.get("action"),
                        "Entry": row.get("entry"),
                    },
                )
            )
    except Exception:
        pass

    try:
        archive_dir = DATA_DIR / "bridge" / "archive"
        if archive_dir.exists():
            for file_path in sorted(archive_dir.glob("*.report.json"), key=os.path.getmtime, reverse=True)[:8]:
                payload = _read_json_file(file_path)
                if not payload:
                    continue
                ts = file_path.stat().st_mtime
                push(
                    _timeline_event(
                        source="bridge_archive",
                        ts=ts,
                        role="Trend_Strategy_v2",
                        event_type="trade",
                        title=f"执行交易动作: {payload.get('action', '未知')}",
                        content=f"执行状态: {payload.get('status', '未知')}。返回信息: {payload.get('message', '')}",
                        data={
                            "Action": payload.get("action"),
                            "Ticket": payload.get("ticket"),
                            "Volume": payload.get("volume"),
                        },
                    )
                )
        ledger_file = _resolve_existing_file(_runtime_log_candidates("trading_ledger.ndjson"))
        if ledger_file is not None:
            for payload in _read_ndjson_tail(ledger_file, limit=8):
                ts = _parse_timestamp_value(payload.get("ts")) or ledger_file.stat().st_mtime
                push(
                    _timeline_event(
                        source="trading_ledger",
                        ts=ts,
                        role="Trade_Ledger",
                        event_type="trade",
                        title=f"交易账本记录: {payload.get('side', 'UNKNOWN')} {payload.get('volume', '')}",
                        content=f"Ticket: {payload.get('ticket', '')}, 策略: {payload.get('strategy', 'unknown')}",
                        data={
                            "Price": payload.get("price"),
                            "SL": payload.get("sl"),
                            "TP": payload.get("tp"),
                        },
                    )
                )
    except Exception:
        pass

    try:
        risk_file = LOGS_DIR / "risk_events.ndjson"
        for payload in _read_ndjson_tail(risk_file, limit=8):
            ts = _parse_timestamp_value(payload.get("ts")) or _parse_timestamp_value(payload.get("timestamp")) or risk_file.stat().st_mtime
            event_type = "warning"
            event_name = str(payload.get("event_type", "risk_event") or "risk_event")
            if "pass" in event_name:
                event_type = "analyze"
            push(
                _timeline_event(
                    source="risk_events",
                    ts=ts,
                    role="Risk_Commissar",
                    event_type=event_type,
                    title=f"风险事件: {event_name}",
                    content=str(payload.get("reason") or payload.get("summary") or payload.get("message") or "risk event"),
                    data={
                        "Action": payload.get("action"),
                        "Side": payload.get("side"),
                        "TraceId": payload.get("trace_id"),
                    },
                )
            )
    except Exception:
        pass

    try:
        closures_file = APP_DIR / "learning" / "events" / "closures.jsonl"
        for payload in _read_ndjson_tail(closures_file, limit=8):
            body = payload.get("payload", {}) if isinstance(payload.get("payload", {}), dict) else {}
            ts = _parse_timestamp_value(payload.get("event_time")) or closures_file.stat().st_mtime
            push(
                _timeline_event(
                    source="closure_events",
                    ts=ts,
                    role="Closure_Audit",
                    event_type="trade",
                    title=f"平仓归档: {body.get('done_reason', 'closed')}",
                    content=(
                        f"ticket={body.get('ticket', 0)}"
                        f" · pnl={body.get('realized_pnl', 0.0)}"
                        f" · recovered={bool(body.get('recovered', False))}"
                    ),
                    data={
                        "DecisionId": payload.get("decision_id"),
                        "RecoverySource": body.get("recovery_source"),
                    },
                )
            )
    except Exception:
        pass

    try:
        macro_status = _read_macro_refresh_status()
        if macro_status:
            ts = _parse_timestamp_value(macro_status.get("updated_at")) or _parse_timestamp_value(macro_status.get("started_at")) or _parse_timestamp_value(macro_status.get("finished_at"))
            push(
                _timeline_event(
                    source="macro_refresh",
                    ts=ts,
                    role="Macro_Commander",
                    event_type="analyze",
                    title=f"宏观刷新状态: {macro_status.get('status', 'unknown')}",
                    content=str(macro_status.get("message", "") or "宏观方向刷新状态更新"),
                    data={
                        "Pid": macro_status.get("pid"),
                        "Status": macro_status.get("status"),
                    },
                )
            )
    except Exception:
        pass

    try:
        runtime_status = _runtime_strategy_status_payload()
        active = runtime_status.get("active", {}) if isinstance(runtime_status.get("active", {}), dict) else {}
        if active:
            ts_ms = runtime_status.get("ts_ms")
            ts = float(ts_ms) / 1000.0 if isinstance(ts_ms, (int, float)) else None
            push(
                _timeline_event(
                    source="strategy_runtime",
                    ts=ts,
                    role="Strategy_Runtime",
                    event_type="analyze",
                    title=f"策略运行时快照: {active.get('strategy_id', 'unknown')}",
                    content=(
                        f"loaded={len(runtime_status.get('loaded', []))}"
                        f" · registered={len(runtime_status.get('registered', []))}"
                        f" · revision={active.get('config_revision', '')}"
                    ),
                    data={
                        "Loaded": runtime_status.get("loaded", []),
                        "Registered": runtime_status.get("registered", []),
                    },
                )
            )
    except Exception:
        pass

    try:
        autonomy_loop = _read_json_file(LOGS_DIR / "autonomy_loop_latest.json")
        if autonomy_loop:
            ts = _parse_timestamp_value(autonomy_loop.get("ts")) or (LOGS_DIR / "autonomy_loop_latest.json").stat().st_mtime
            push(
                _timeline_event(
                    source="autonomy_loop",
                    ts=ts,
                    role="Autonomy_Loop",
                    event_type="analyze",
                    title=f"自治闭环动作: {autonomy_loop.get('next_action', 'UNKNOWN')}",
                    content=str(autonomy_loop.get("notice") or autonomy_loop.get("governance_action") or "自治闭环已刷新"),
                    data={
                        "GovernanceAction": autonomy_loop.get("governance_action"),
                        "TenantId": autonomy_loop.get("tenant_id"),
                    },
                )
            )
    except Exception:
        pass

    try:
        briefing_file, content = _read_text_from_candidates(_runtime_log_candidates("morning_briefing_latest.md"))
        if briefing_file is not None:
            ts = briefing_file.stat().st_mtime
            dt = datetime.datetime.fromtimestamp(ts)
            if dt.date() == datetime.datetime.now().date():
                push(
                    _timeline_event(
                        source="morning_briefing",
                        ts=ts,
                        role="Agent",
                        event_type="analyze",
                        title="早间简报 (Morning Briefing)",
                        content=content,
                        data={"Status": "已送达"},
                    )
                )
    except Exception:
        pass

    try:
        report_file, content = _read_text_from_candidates(_runtime_log_candidates("evolution_llm_report_latest.txt"))
        if report_file is not None:
            ts = report_file.stat().st_mtime
            summary = content[:300] + "..." if len(content) > 300 else content
            push(
                _timeline_event(
                    source="evolution_report",
                    ts=ts,
                    role="Evolution_Agent",
                    event_type="analyze",
                    title="系统进化与训练诊断报告",
                    content=summary,
                    data={"FullReport": "已生成"},
                )
            )
    except Exception:
        pass

    events.sort(key=lambda item: float(item.get("timestamp", 0.0) or 0.0), reverse=True)
    if not events:
        events = [
            _timeline_event(
                source="system",
                ts=time.time(),
                role="System",
                event_type="analyze",
                title="系统启动并等待数据",
                content="正在监控行情和 AI Agent 的分析报告，稍后将在这里展示复盘流...",
            )
        ]
    source_meta = [
        {"id": source, "count": count}
        for source, count in sorted(source_counts.items(), key=lambda item: (-item[1], item[0]))
    ]
    return {"success": True, "events": events[:limit], "sources": source_meta, "totalEvents": len(events)}


def _get_timeline_payload_cached(limit: int = 40) -> dict[str, Any]:
    now = time.time()
    cached = _TIMELINE_CACHE.get("payload")
    cached_ts = float(_TIMELINE_CACHE.get("ts", 0.0) or 0.0)
    if isinstance(cached, dict) and (now - cached_ts) <= _TIMELINE_CACHE_TTL_SECONDS:
        return cached
    payload = _build_timeline_payload(limit=limit)
    _TIMELINE_CACHE["payload"] = payload
    _TIMELINE_CACHE["ts"] = now
    return payload


def _top_bucket_preview(group: Any, limit: int = 3) -> list[dict[str, Any]]:
    if not isinstance(group, dict):
        return []
    rows: list[dict[str, Any]] = []
    for name, stats in group.items():
        if not isinstance(stats, dict):
            continue
        rows.append(
            {
                "name": str(name),
                "trades": int(stats.get("trades", 0) or 0),
                "winRatePct": float(stats.get("win_rate_pct", 0.0) or 0.0),
                "avgPnl": float(stats.get("avg_pnl", 0.0) or 0.0),
                "totalPnl": float(stats.get("total_pnl", 0.0) or 0.0),
                "block": bool(stats.get("block", False)),
                "usable": bool(stats.get("usable", False)),
                "liveMultiplier": float(stats.get("live_multiplier", 1.0) or 1.0),
            }
        )
    rows.sort(
        key=lambda item: (
            item["trades"],
            item["winRatePct"],
            item["totalPnl"],
        ),
        reverse=True,
    )
    return rows[:limit]


def _training_status_defaults() -> Dict[str, Any]:
    return {
        "isTraining": False,
        "progress": 0.0,
        "currentBar": 0,
        "totalBars": 0,
        "currentEpoch": 0,
        "totalEpochs": 0,
        "winRate": 0.0,
        "pnl": 0.0,
        "totalTrades": 0,
        "activeDataset": _default_training_dataset_label(),
        "activeModel": DEFAULT_ANALYSIS_MODEL,
        "analysisModel": DEFAULT_ANALYSIS_MODEL,
        "trainingEngine": dict(TRAINING_RUNTIME_CONFIG),
        "pnlHistory": [],
        "qualityGate": {},
        "readyForTraining": False,
        "blockedReasons": [],
        "closureCoveragePct": 0.0,
        "criticalAnomalies": 0,
        "recoveredClosedRatioPct": 0.0,
        "naturalClosedRatioPct": 0.0,
        "naturalClosedTransitions": 0,
        "recoveredClosedTransitions": 0,
        "usableTradeCount": 0,
        "excludedIncompleteCount": 0,
        "excludedRecoveredCount": 0,
        "samplePolicy": {},
        "qualitySnapshotId": "",
        "qualitySnapshotAt": "",
        "errorDispatch": {},
        "repairCycle": {},
        "autoPilotEnabled": False,
        "autoPilotStage": "idle",
        "autoPilotMessage": "自动推进未开启",
        "autoPilotLastActionAt": "",
        "autoPilotLastError": "",
    }


def _read_training_quality_snapshot(
    *,
    force_refresh: bool = False,
    repair_cycle: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    fallback_path = _resolve_runtime_artifact("data", "offline", "training_quality_snapshot.json")
    fallback_payload = _read_json_file(fallback_path) if fallback_path is not None else {}
    try:
        from src.training_quality_snapshot import (
            ensure_training_quality_snapshot,
            read_training_quality_snapshot,
        )

        if force_refresh:
            refreshed = ensure_training_quality_snapshot(
                force_refresh=True,
                repair_cycle=repair_cycle,
                source="gui_backend",
            )
            return refreshed if isinstance(refreshed, dict) and refreshed else fallback_payload
        snapshot = read_training_quality_snapshot()
        snapshot_ts = _parse_timestamp_value(snapshot.get("generated_at")) if isinstance(snapshot, dict) else None
        if snapshot and snapshot_ts and (time.time() - snapshot_ts) <= 900:
            return snapshot
        refreshed = ensure_training_quality_snapshot(
            force_refresh=True,
            repair_cycle=repair_cycle,
            source="gui_backend",
        )
        if refreshed:
            return refreshed
        if snapshot:
            return snapshot
    except Exception:
        pass
    return fallback_payload if isinstance(fallback_payload, dict) else {}


def _read_training_candidate_value() -> Dict[str, Any]:
    try:
        from src.training_candidate_value import evaluate_training_candidate_value

        return evaluate_training_candidate_value(
            quality_snapshot=_read_training_quality_snapshot(force_refresh=False)
        )
    except Exception:
        return {}


def _dispatch_training_error_to_openclaw(
    approval: Dict[str, Any],
    status: Dict[str, Any],
) -> Dict[str, Any]:
    try:
        from src.training_error_dispatch import dispatch_training_error

        return dispatch_training_error(approval, status)
    except Exception as exc:
        return {
            "success": False,
            "message": f"OpenClaw 错误派发失败: {exc}",
            "continue_mode": "wait_new_evidence",
            "executed_actions": [],
        }


def _read_training_error_dispatch_state(
    approval: Optional[Dict[str, Any]] = None,
    status: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    try:
        from src.training_error_dispatch import (
            read_training_error_dispatch_state,
            reconcile_training_error_dispatch_state,
        )

        if isinstance(approval, dict) and isinstance(status, dict):
            return reconcile_training_error_dispatch_state(approval, status)
        return read_training_error_dispatch_state()
    except Exception:
        return {}


def _read_training_status_payload() -> Dict[str, Any]:
    status_file = _resolve_runtime_artifact("data", "offline", "training_status.json") or (
        DATA_DIR / "offline" / "training_status.json"
    )
    payload = _training_status_defaults()
    if status_file.exists():
        try:
            loaded = json.loads(status_file.read_text(encoding="utf-8"))
            if isinstance(loaded, dict):
                payload.update(loaded)
        except Exception as exc:
            _recover_corrupt_json_file(
                status_file,
                payload,
                reason=f"training_status_invalid_json:{type(exc).__name__}",
            )
    payload["activeDataset"] = _normalize_training_dataset(str(payload.get("activeDataset", "")))
    payload["analysisModel"] = _normalize_analysis_model(str(payload.get("analysisModel", payload.get("activeModel", ""))))
    payload["activeModel"] = _normalize_analysis_model(str(payload.get("activeModel", payload["analysisModel"])))
    payload["trainingEngine"] = {
        **TRAINING_RUNTIME_CONFIG,
        **(payload.get("trainingEngine", {}) if isinstance(payload.get("trainingEngine"), dict) else {}),
    }
    return payload


def _compact_training_pnl_history(rows: Any, limit: int = _TRAINING_PNL_HISTORY_MAX_POINTS) -> list[dict[str, Any]]:
    if not isinstance(rows, list):
        return []
    normalized: list[dict[str, Any]] = []
    for item in rows:
        if not isinstance(item, dict):
            continue
        try:
            epoch = int(item.get("epoch", 0) or 0)
            loss = round(float(item.get("loss", 0.0) or 0.0), 2)
        except Exception:
            continue
        normalized.append({"epoch": epoch, "loss": loss})
    if len(normalized) <= limit:
        return normalized
    if limit <= 2:
        return normalized[-limit:]
    keep_indexes = {0, len(normalized) - 1}
    step = (len(normalized) - 1) / float(limit - 1)
    for idx in range(1, limit - 1):
        keep_indexes.add(int(round(idx * step)))
    return [normalized[idx] for idx in sorted(keep_indexes)[:limit]]


def _load_best_params_pnl_history(best_file: pathlib.Path) -> list[dict[str, Any]]:
    try:
        mtime = float(best_file.stat().st_mtime)
    except Exception:
        return []
    cached_mtime = float(_TRAINING_PNL_HISTORY_CACHE.get("mtime", 0.0) or 0.0)
    cached_value = _TRAINING_PNL_HISTORY_CACHE.get("value", [])
    if mtime == cached_mtime and isinstance(cached_value, list):
        return list(cached_value)
    history: list[dict[str, Any]] = []
    try:
        best_data = json.loads(best_file.read_text(encoding="utf-8"))
        trades = best_data.get("stats", {}).get("trades_list", []) if isinstance(best_data, dict) else []
        cum_pnl = 0.0
        for i, trade in enumerate(trades):
            if not isinstance(trade, dict):
                continue
            cum_pnl += float(trade.get("pnl", 0.0) or 0.0)
            history.append({"epoch": i + 1, "loss": round(cum_pnl, 2)})
    except Exception:
        history = []
    compacted = _compact_training_pnl_history(history)
    _TRAINING_PNL_HISTORY_CACHE["mtime"] = mtime
    _TRAINING_PNL_HISTORY_CACHE["value"] = compacted
    return list(compacted)


def _read_live_training_metrics() -> Dict[str, Any]:
    auto_governance_file = _resolve_runtime_artifact("logs", "auto_governance_status_latest.json") or (
        LOGS_DIR / "auto_governance_status_latest.json"
    )
    shadow_summary_file = _resolve_runtime_artifact("logs", "shadow_learner_summary_latest.json") or (
        LOGS_DIR / "shadow_learner_summary_latest.json"
    )
    live_summary_file = _resolve_runtime_artifact("logs", "live_learner_summary_latest.json") or (
        LOGS_DIR / "live_learner_summary_latest.json"
    )

    metrics: Dict[str, Any] = {
        "source": "training_status",
        "updatedAt": "",
        "updatedTs": 0.0,
        "totalTrades": 0,
        "winRate": 0.0,
        "drawdown": 0.0,
        "shadowClosedTrades": 0,
        "shadowWinRatePct": 0.0,
        "shadowMaxDrawdownAbs": 0.0,
        "learnerLiveSelections": 0,
        "baselineSelections": 0,
        "lastReason": "",
    }

    def _apply_mtime(path: pathlib.Path) -> None:
        try:
            ts = float(path.stat().st_mtime)
            if ts > float(metrics["updatedTs"]):
                metrics["updatedTs"] = ts
                metrics["updatedAt"] = datetime.datetime.fromtimestamp(ts).isoformat()
        except Exception:
            pass

    if auto_governance_file.exists():
        try:
            payload = json.loads(auto_governance_file.read_text(encoding="utf-8"))
            aggregate = (
                ((payload.get("evidence") or {}).get("aggregateMetrics", {}))
                or (((payload.get("goal_targets") or {}).get("window_month", {})))
            )
            if isinstance(aggregate, dict):
                metrics["source"] = "auto_governance"
                metrics["totalTrades"] = int(aggregate.get("closed_trades", 0) or 0)
                metrics["winRate"] = float(aggregate.get("win_rate_pct", 0.0) or 0.0)
                metrics["drawdown"] = float(aggregate.get("max_drawdown_pct", 0.0) or 0.0)
            _apply_mtime(auto_governance_file)
        except Exception:
            pass

    if shadow_summary_file.exists():
        try:
            payload = json.loads(shadow_summary_file.read_text(encoding="utf-8"))
            metrics["shadowClosedTrades"] = int(payload.get("shadowClosedTrades", 0) or 0)
            metrics["shadowWinRatePct"] = float(payload.get("shadowWinRatePct", 0.0) or 0.0)
            metrics["shadowMaxDrawdownAbs"] = float(payload.get("shadowMaxDrawdownAbs", 0.0) or 0.0)
            _apply_mtime(shadow_summary_file)
        except Exception:
            pass

    if live_summary_file.exists():
        try:
            payload = json.loads(live_summary_file.read_text(encoding="utf-8"))
            metrics["learnerLiveSelections"] = int(payload.get("learnerLiveSelections", 0) or 0)
            metrics["baselineSelections"] = int(payload.get("baselineSelections", 0) or 0)
            metrics["lastReason"] = str(payload.get("lastReason", "") or "")
            _apply_mtime(live_summary_file)
        except Exception:
            pass

    return metrics


def _write_training_status_payload(payload: Dict[str, Any]) -> pathlib.Path:
    status_file = DATA_DIR / "offline" / "training_status.json"
    status_file.parent.mkdir(parents=True, exist_ok=True)
    merged = _training_status_defaults()
    merged.update(payload)
    merged["activeDataset"] = _normalize_training_dataset(str(merged.get("activeDataset", "")))
    merged["analysisModel"] = _normalize_analysis_model(str(merged.get("analysisModel", merged.get("activeModel", ""))))
    merged["activeModel"] = _normalize_analysis_model(str(merged.get("activeModel", merged["analysisModel"])))
    merged["trainingEngine"] = {
        **TRAINING_RUNTIME_CONFIG,
        **(merged.get("trainingEngine", {}) if isinstance(merged.get("trainingEngine"), dict) else {}),
    }
    quality_gate = merged.get("qualityGate", {}) if isinstance(merged.get("qualityGate", {}), dict) else {}
    if quality_gate:
        merged["usableTradeCount"] = int(quality_gate.get("usable_trade_count", merged.get("usableTradeCount", 0)) or 0)
        merged["excludedIncompleteCount"] = int(quality_gate.get("excluded_incomplete_count", merged.get("excludedIncompleteCount", 0)) or 0)
        merged["excludedRecoveredCount"] = int(quality_gate.get("excluded_recovered_count", merged.get("excludedRecoveredCount", 0)) or 0)
        merged["samplePolicy"] = quality_gate.get("sample_policy", merged.get("samplePolicy", {}))
        merged["qualitySnapshotId"] = str(quality_gate.get("snapshot_id", merged.get("qualitySnapshotId", "")) or "")
        merged["qualitySnapshotAt"] = str(quality_gate.get("generated_at", merged.get("qualitySnapshotAt", "")) or "")
    status_file.write_text(json.dumps(merged, indent=2, ensure_ascii=False))
    return status_file


def _mark_training_autopilot(
    *,
    stage: str,
    message: str,
    enabled: Optional[bool] = None,
    error: str = "",
) -> Dict[str, Any]:
    status = _read_training_status_payload()
    if enabled is not None:
        status["autoPilotEnabled"] = enabled
    status["autoPilotStage"] = stage
    status["autoPilotMessage"] = message
    status["autoPilotLastActionAt"] = datetime.datetime.now().isoformat()
    status["autoPilotLastError"] = error
    _write_training_status_payload(status)
    return status


def _training_autopilot_ready(status: Dict[str, Any], min_interval_sec: int = 8) -> bool:
    last_action_at = str(status.get("autoPilotLastActionAt", "")).strip()
    if not last_action_at:
        return True
    try:
        last_dt = datetime.datetime.fromisoformat(last_action_at)
    except Exception:
        return True
    return (datetime.datetime.now() - last_dt).total_seconds() >= min_interval_sec


def _run_training_autopilot_step():
    status = _read_training_status_payload()
    approval = None
    if not status.get("autoPilotEnabled", False):
        approval = get_training_approval_chain()
        if not _should_enable_training_autopilot(status, approval):
            decision = approval.get("decision", {}) if isinstance(approval.get("decision", {}), dict) else {}
            recommended_actions = decision.get("recommendedActions", []) if isinstance(decision.get("recommendedActions", []), list) else []
            review_pending = bool(decision.get("reviewPending", False))
            can_promote = bool(decision.get("canPromote", False))
            wait_only_actions = [
                action for action in recommended_actions
                if isinstance(action, str) and action.startswith("wait_or_improve:")
            ]
            if review_pending:
                _mark_training_autopilot(
                    stage="await_manual_review",
                    message="自动流程已推进到人工复核闸门，请人工确认后再决定是否正式推进",
                    enabled=False,
                )
            elif can_promote:
                _mark_training_autopilot(
                    stage="await_manual_promote",
                    message=f"自动流程已完成分析/修复/校验，可人工决定是否推进到 {decision.get('promotionTarget', '下一阶段')}",
                    enabled=False,
                )
            elif wait_only_actions:
                _mark_training_autopilot(
                    stage="waiting_gates",
                    message=str(decision.get("nextStep", "当前只剩门槛等待项，自动流程暂不执行动作")),
                    enabled=False,
                )
            return
        _mark_training_autopilot(
            stage="reactivated",
            message="检测到待处理训练闭环，已自动恢复后台推进",
            enabled=True,
        )
        status = _read_training_status_payload()
    if status.get("isTraining", False):
        if status.get("autoPilotStage") != "training_running":
            _mark_training_autopilot(
                stage="training_running",
                message="训练进行中，等待离线训练完成后自动进入分析/修复/校验链路",
                enabled=True,
            )
        return
    if not _training_autopilot_ready(status):
        return

    approval = approval if isinstance(approval, dict) else get_training_approval_chain()
    _read_training_error_dispatch_state(approval, status)
    decision = approval.get("decision", {}) if isinstance(approval, dict) else {}
    recommended_actions = decision.get("recommendedActions", []) if isinstance(decision, dict) else []
    can_promote = bool(decision.get("canPromote", False))
    review_pending = bool(decision.get("reviewPending", False))
    decision_status = str(decision.get("status", "") or "")
    candidate_value = decision.get("candidateValue", {}) if isinstance(decision.get("candidateValue", {}), dict) else {}
    candidate_invalid = not bool(candidate_value.get("ready_for_manual_review", True))
    suggestion_stage = str(approval.get("stage", "NO_SUGGESTION"))
    has_training_output = bool(status.get("currentEpoch", 0)) or (DATA_DIR / "offline" / "best_params.json").exists()

    requires_error_dispatch = (
        decision_status == "ROLLBACK_REQUIRED"
        or any(action in {"rollback_invalid_candidate", "reject_invalid_candidate"} for action in recommended_actions)
        or (suggestion_stage == "NO_SUGGESTION" and has_training_output and candidate_invalid)
    )
    if requires_error_dispatch:
        _mark_training_autopilot(
            stage="dispatching_openclaw_error",
            message="检测到训练错误候选，正在派发给 OpenClaw 处理",
            enabled=True,
        )
        dispatch_result = _dispatch_training_error_to_openclaw(approval, status)
        if not dispatch_result.get("success", False):
            _mark_training_autopilot(
                stage="error",
                message="训练错误已阻断，但派发给 OpenClaw 失败",
                enabled=False,
                error=str(dispatch_result.get("message", "未知错误")),
            )
            return
        continue_mode = str(dispatch_result.get("continue_mode", "wait_new_evidence") or "wait_new_evidence")
        if continue_mode == "retry_after_repair":
            _mark_training_autopilot(
                stage="retry_after_openclaw_repair",
                message=str(dispatch_result.get("message", "OpenClaw 已处理错误，准备基于新快照继续推进")),
                enabled=True,
            )
        else:
            _mark_training_autopilot(
                stage="waiting_new_training_evidence",
                message=str(dispatch_result.get("message", "错误已派发给 OpenClaw，等待新证据后继续推进")),
                enabled=False,
            )
        return

    if suggestion_stage == "NO_SUGGESTION" and has_training_output:
        _mark_training_autopilot(
            stage="generating_suggestion",
            message="训练已完成，正在自动生成新的进化建议",
            enabled=True,
        )
        result = evolve_training_candidate(
            TrainingEvolveRequest(model=status.get("analysisModel", DEFAULT_ANALYSIS_MODEL))
        )
        if not result.get("success", False):
            _mark_training_autopilot(
                stage="error",
                message="自动生成进化建议失败，请查看错误信息",
                enabled=True,
                error=str(result.get("error", result.get("message", "未知错误"))),
            )
        return

    wait_only_actions = [
        action for action in recommended_actions
        if isinstance(action, str) and action.startswith("wait_or_improve:")
    ]
    has_repair_actions = any(
        isinstance(action, str)
        and (action.startswith("repair:") or action == "rebuild_shadow_params")
        for action in recommended_actions
    )
    needs_repair = any(
        isinstance(action, str)
        and (
            action.startswith("repair:")
            or action.startswith("wait_or_improve:")
            or action == "rebuild_shadow_params"
        )
        for action in recommended_actions
    )
    if needs_repair and has_repair_actions:
        _mark_training_autopilot(
            stage="repairing",
            message="检测到阻断项，正在自动执行修复与影子参数重建",
            enabled=True,
        )
        result = repair_training_blockers()
        if not result.get("success", False):
            _mark_training_autopilot(
                stage="error",
                message="自动修复阻断项失败",
                enabled=True,
                error=str(result.get("message", result.get("error", "未知错误"))),
            )
            return
        run_training_promotion_check()
        return

    # 仅剩等待型门槛时，不再反复触发 repair_blockers 死循环。
    if wait_only_actions and not has_repair_actions:
        _mark_training_autopilot(
            stage="waiting_gates",
            message=f"等待型门槛未达成（{len(wait_only_actions)}项），暂停自动修复，等待新交易样本/收益因子",
            enabled=True,
        )
        return

    if suggestion_stage == "PENDING_APPROVAL" and not review_pending and "approve_to_shadow" in recommended_actions:
        _mark_training_autopilot(
            stage="approving_observe",
            message="已生成新的训练建议，正在自动推进到可回滚观察态",
            enabled=True,
        )
        result = approve_evolution_action()
        if not result.get("success", False):
            _mark_training_autopilot(
                stage="error",
                message="自动推进观察态失败",
                enabled=True,
                error=str(result.get("message", result.get("error", "未知错误"))),
            )
        return

    if review_pending:
        _mark_training_autopilot(
            stage="await_manual_review",
            message="自动流程已推进到人工复核闸门，请人工确认后再决定是否正式推进",
            enabled=False,
        )
        return

    if can_promote:
        _mark_training_autopilot(
            stage="await_manual_promote",
            message=f"自动流程已完成分析/修复/校验，可人工决定是否推进到 {decision.get('promotionTarget', '下一阶段')}",
            enabled=False,
        )
        return

    _mark_training_autopilot(
        stage="waiting_gates",
        message=str(decision.get("nextStep", "自动流程已完成当前可执行动作，等待新的门槛变化")),
        enabled=False,
    )


def _extract_param_map(payload: Dict[str, Any]) -> Dict[str, Any]:
    if not isinstance(payload, dict):
        return {}
    try:
        from src.param_flow import extract_param_map as _extract_runtime_param_map
        return _extract_runtime_param_map(payload)
    except Exception:
        return {}

@app.get("/api/params")
def get_params():
    """获取风控与执行参数"""
    config_file = CONFIG_DIR / "risk_governor_rules.json"
    raw_data: dict[str, Any] = {}
    if config_file.exists():
        try:
            loaded = json.loads(config_file.read_text())
            if isinstance(loaded, dict):
                raw_data = loaded
        except Exception:
            raw_data = {}

    def _saved_or_effective(key: str, effective_key: str, default: Any) -> Any:
        if key in raw_data:
            return raw_data.get(key)
        return config_manager.get_effective_param(effective_key, default)

    effective_max_position_size = float(_saved_or_effective("max_position_size", "max_position_size", 0.10) or 0.10)
    effective_risk_level = _normalize_risk_level(
        raw_data.get("risk_level") or _risk_level_from_max_position_size(effective_max_position_size)
    )
    profile = _risk_profile_for_level(effective_risk_level)
    effective_max_open_positions = int(
        _saved_or_effective("max_open_positions", "max_positions", raw_data.get("max_open_positions", profile["max_open_positions"]))
        or profile["max_open_positions"]
    )
    effective_sl_pips = int(
        _saved_or_effective("sl_pips", "sl_pips", raw_data.get("sl_pips", profile["sl_pips"]))
        or profile["sl_pips"]
    )
    effective_tp_pips = int(
        _saved_or_effective("tp_pips", "tp_pips", raw_data.get("tp_pips", profile["tp_pips"]))
        or profile["tp_pips"]
    )
    effective_human_in_the_loop = bool(raw_data.get("human_in_the_loop", True))
    effective_min_confidence_raw = float(
        _saved_or_effective("min_confidence", "min_confidence_threshold", 0.70) or 0.70
    )
    effective_min_confidence_pct = round(
        effective_min_confidence_raw if effective_min_confidence_raw > 1.0 else effective_min_confidence_raw * 100.0,
        1,
    )
    effective_account_loss_stop_pct = float(
        _saved_or_effective("account_total_loss_stop_pct", "account_total_loss_stop_pct", 30.0) or 30.0
    )
    params = {
        "risk_level": effective_risk_level,
        "max_position_size": effective_max_position_size,
        "max_open_positions": max(1, effective_max_open_positions),
        "sl_pips": max(1, effective_sl_pips),
        "tp_pips": max(1, effective_tp_pips),
        "human_in_the_loop": effective_human_in_the_loop,
        "min_confidence": effective_min_confidence_pct,
        "account_total_loss_stop_pct": effective_account_loss_stop_pct,
    }

    for key, value in raw_data.items():
        if key in {"max_position_size", "min_confidence", "min_confidence_threshold", "risk_level"}:
            continue
        params[key] = value

    params["risk_level"] = effective_risk_level

    return {"success": True, "params": params}

@app.post("/api/params")
def save_params(params: RiskParams):
    """保存风控与执行参数"""
    config_file = CONFIG_DIR / "risk_governor_rules.json"

    try:
        data = {}
        if config_file.exists():
            try:
                data = json.loads(config_file.read_text())
            except:
                pass

        payload = params.model_dump()
        selected_risk_level = _normalize_risk_level(payload.get("risk_level"))
        payload["risk_level"] = selected_risk_level
        payload["max_position_size"] = round(max(0.01, float(payload.get("max_position_size", 0.10) or 0.10)), 2)
        payload["max_open_positions"] = max(1, int(payload.get("max_open_positions", 1) or 1))
        payload["sl_pips"] = max(1, int(payload.get("sl_pips", 1) or 1))
        payload["tp_pips"] = max(1, int(payload.get("tp_pips", 1) or 1))
        payload["min_confidence"] = round(min(100.0, max(0.0, float(payload.get("min_confidence", 70.0) or 70.0))), 1)
        payload["account_total_loss_stop_pct"] = round(
            min(100.0, max(0.0, float(payload.get("account_total_loss_stop_pct", 30.0) or 0.0))),
            2,
        )
        payload["min_confidence_threshold"] = round(payload["min_confidence"] / 100.0, 4)
        payload["max_positions"] = payload["max_open_positions"]

        data.update(payload)
        config_file.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")

        strategy_file, strategy_data = _load_strategy_rules_for_update()
        position_sizing = strategy_data.setdefault("position_sizing", {})
        risk_governor = strategy_data.setdefault("risk_governor", {})
        position_sizing["base_lots"] = _DEFAULT_BASE_LOTS
        position_sizing["max_lots"] = payload["max_position_size"]
        position_sizing["sim_stage_max"] = payload["max_position_size"]
        risk_governor["min_confidence"] = payload["min_confidence_threshold"]
        risk_governor["max_positions"] = payload["max_open_positions"]
        risk_governor["sl_pips"] = payload["sl_pips"]
        risk_governor["tp_pips"] = payload["tp_pips"]
        strategy_data["last_updated"] = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
        strategy_file.parent.mkdir(parents=True, exist_ok=True)
        strategy_file.write_text(json.dumps(strategy_data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        refreshed = get_params()
        return {
            "success": True,
            "message": "参数已成功保存",
            "params": refreshed.get("params", payload) if isinstance(refreshed, dict) else payload,
        }
    except Exception as e:
        return {"success": False, "error": str(e)}

@app.post("/api/params/reset")
def reset_params():
    """重置风控与执行参数(删除手动配置文件,回归系统自动模式)"""
    config_file = CONFIG_DIR / "risk_governor_rules.json"
    try:
        strategy_file, strategy_data = _load_strategy_rules_for_update()
        position_sizing = strategy_data.get("position_sizing", {}) if isinstance(strategy_data.get("position_sizing", {}), dict) else {}
        risk_governor = strategy_data.get("risk_governor", {}) if isinstance(strategy_data.get("risk_governor", {}), dict) else {}
        baseline_max_position_size = float(position_sizing.get("max_lots", 0.10) or 0.10)
        baseline_risk_level = _risk_level_from_max_position_size(baseline_max_position_size)
        baseline_profile = _risk_profile_for_level(baseline_risk_level)
        baseline_min_conf = float(risk_governor.get("min_confidence", float(baseline_profile["min_confidence"]) / 100.0) or float(baseline_profile["min_confidence"]) / 100.0)
        baseline = {
            "risk_level": baseline_risk_level,
            "max_position_size": baseline_max_position_size,
            "max_open_positions": int(baseline_profile["max_open_positions"]),
            "sl_pips": int(baseline_profile["sl_pips"]),
            "tp_pips": int(baseline_profile["tp_pips"]),
            "human_in_the_loop": True,
            "min_confidence": round(baseline_min_conf * 100.0, 1),
            "min_confidence_threshold": round(baseline_min_conf, 4),
            "account_total_loss_stop_pct": 30.0,
        }
        config_file.write_text(json.dumps(baseline, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        return {"success": True, "message": "已成功清除手动配置,回归系统自动参数模式"}
    except Exception as e:
        return {"success": False, "error": str(e)}

@app.post("/api/params/apply_best")
def apply_best_params():
    """将离线训练最优参数(best_params.json)写入effective_params.json,立即生效"""
    import time
    best_file = DATA_DIR / "offline" / "best_params.json"
    eff_file  = LOGS_DIR / "effective_params.json"
    try:
        if not best_file.exists():
            return {"success": False, "error": "离线训练结果不存在,请先运行训练"}
        best = json.loads(best_file.read_text())
        bp   = best.get("best_params", {})
        stats = best.get("stats", {})
        suggestions = []
        for param, val in bp.items():
            suggestions.append({
                "id": "TRAINING_BEST",
                "param": param,
                "applied_value": val,
                "note": f"来源:离线训练({stats.get('total_trades',0)}笔,胜率{stats.get('win_rate',0):.1f}%)"
            })
        eff_data = {
            "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S.000000+00:00"),
            "source": "training_best",
            "approved_count": len(suggestions),
            "suggestions": suggestions,
            "offline_params": {
                **bp,
                "review_pending": False,
            },
            "quality_context": {
                "source": "training_best",
                "total_trades": stats.get("total_trades", 0),
                "win_rate": stats.get("win_rate", 0),
                "total_pnl": stats.get("total_pnl", 0),
                "avg_trade_pnl": stats.get("avg_trade_pnl", 0),
            },
        }
        eff_file.write_text(json.dumps(eff_data, indent=2, ensure_ascii=False))
        return {
            "success": True,
            "message": f"已应用训练最优参数({len(suggestions)}项)",
            "params": bp,
            "stats": {"total_trades": stats.get("total_trades"), "win_rate": stats.get("win_rate"), "total_pnl": stats.get("total_pnl")}
        }
    except Exception as e:
        return {"success": False, "error": str(e)}


# ── Strategy Hot-Reload ────────────────────────────────────────────────────

VALID_STRATEGIES = {
    "ema_macd":     "EMA+MACD+RSI+BB 综合趋势策略",
    "elliott_wave": "艾略特波浪理论（Wave1-5 / A-B-C）",
    "chanlun":      "缠论中枢（笔 + 中枢 + 三类买卖点）",
    "wyckoff":      "Wyckoff 量价结构（Spring / SOS / SOW）",
    "composite":    "多策略加权聚合（4大策略共振）",
}

_TEMPLATE_RESERVED_KEYS = {"strategy_id", "version", "runtime_scheduler"}
_TEMPLATE_TIMEFRAME_ALLOWED = {"M1", "M5", "M15", "H1"}


def _sanitize_strategy_template_payload(template: Any) -> dict[str, Any]:
    if not isinstance(template, dict):
        return {}
    sanitized: dict[str, Any] = {}
    for key, value in template.items():
        key_text = str(key)
        if key_text in _TEMPLATE_RESERVED_KEYS:
            continue
        sanitized[key_text] = value
    return sanitized


def _normalize_strategy_template_payload(template: Any) -> dict[str, Any]:
    payload = _sanitize_strategy_template_payload(template)
    normalized = dict(payload)

    def _coerce_float(value: Any) -> Optional[float]:
        try:
            number = float(value)
        except Exception:
            return None
        return number if math.isfinite(number) else None

    if "risk_per_trade" not in normalized:
        legacy_risk_multiplier = _coerce_float(payload.get("risk_multiplier"))
        if legacy_risk_multiplier is not None:
            normalized["risk_per_trade"] = round(0.01 * legacy_risk_multiplier, 4)

    if "atr_sl_mult" not in normalized:
        legacy_sl_multiplier = _coerce_float(payload.get("sl_multiplier"))
        if legacy_sl_multiplier is not None:
            normalized["atr_sl_mult"] = round(1.8 * legacy_sl_multiplier, 4)

    if "tp_rr" not in normalized:
        legacy_tp_multiplier = _coerce_float(payload.get("tp_multiplier"))
        if legacy_tp_multiplier is not None:
            normalized["tp_rr"] = round(2.0 * legacy_tp_multiplier, 4)

    return normalized


def _strategy_market_state_file() -> pathlib.Path:
    return CONFIG_DIR / "strategy_market_state.json"


def _strategy_market_state_seed_candidates() -> list[pathlib.Path]:
    state_file = _strategy_market_state_file()
    candidates: list[pathlib.Path] = []
    seen: set[str] = set()
    for root in (RELEASE_ROOT, REPO_ROOT):
        candidate = pathlib.Path(root) / "config" / state_file.name
        key = str(candidate.resolve()) if candidate.exists() else str(candidate)
        if key in seen or candidate == state_file:
            continue
        seen.add(key)
        candidates.append(candidate)
    return candidates


def _bootstrap_missing_strategy_market_state(
    state_file: pathlib.Path,
) -> Optional[dict[str, Any]]:
    for candidate in _strategy_market_state_seed_candidates():
        if not candidate.exists():
            continue
        try:
            payload = json.loads(candidate.read_text(encoding="utf-8"))
        except Exception:
            continue
        if not isinstance(payload, dict):
            continue
        try:
            _write_json_atomic(state_file, payload)
        except Exception:
            pass
        _record_system_message(
            kind="file_repair",
            title="策略市场状态已回填",
            desc=f"检测到 `{state_file.name}` 缺失，系统已从当前运行版本回填策略市场状态。",
            color="warning",
            source="state_bootstrap",
            metadata={
                "file": str(state_file),
                "filename": state_file.name,
                "seed_file": str(candidate),
                "reason": "missing_strategy_market_state",
            },
        )
        return payload
    return None


def _default_strategy_template(strategy_id: str) -> dict[str, Any]:
    return {
        "contract_version": "1",
        "template_kind": "strategy_runtime_contract",
        "timeframe": "M5",
        "risk_per_trade": 0.01,
        "risk_multiplier": 1.0,
        "atr_sl_mult": 1.8,
        "sl_multiplier": 1.0,
        "tp_rr": 2.0,
        "tp_multiplier": 1.0,
        "max_positions": 1,
        "reduce_only": False,
        "strategy_style": "trend",
        "execution_role": "primary",
        "applicable_markets": ["TRENDING"],
        "applicable_sessions": ["LONDON", "NEW_YORK"],
        "conflict_groups": ["trend"],
        "coordination_tags": ["TREND"],
        "max_active_strategies": 2,
        "conflict_hold_threshold": 0.35,
        "allow_same_group_stack": False,
        "allow_counter_style_mix": False,
        "execution_baseline": {
            "timeframe": "M5",
            "risk_per_trade": 0.01,
            "atr_sl_mult": 1.8,
            "tp_rr": 2.0,
            "max_positions": 1,
            "reduce_only": False,
        },
        "runtime_context": {
            "applicable_markets": ["TRENDING"],
            "applicable_sessions": ["LONDON", "NEW_YORK"],
        },
        "coordination_contract": {
            "strategy_style": "trend",
            "execution_role": "primary",
            "conflict_groups": ["trend"],
            "coordination_tags": ["TREND"],
            "max_active_strategies": 2,
            "conflict_hold_threshold": 0.35,
            "allow_same_group_stack": False,
            "allow_counter_style_mix": False,
        },
    }


def _clamp_allocation(value: Any) -> float:
    try:
        number = float(value)
    except Exception:
        number = 0.0
    return round(min(1.0, max(0.0, number)), 4)


def _load_effective_params_payload() -> dict[str, Any]:
    eff_file = LOGS_DIR / "effective_params.json"
    if not eff_file.exists():
        return {}
    try:
        payload = json.loads(eff_file.read_text(encoding="utf-8"))
        return payload if isinstance(payload, dict) else {}
    except Exception:
        return {}


def _effective_config_revision(payload: Optional[dict[str, Any]] = None) -> str:
    effective_payload = payload if isinstance(payload, dict) else _load_effective_params_payload()
    for key in ("config_revision", "revision", "synced_at", "generated_at"):
        value = effective_payload.get(key)
        if value:
            return str(value)
    return "manual"


def _normalize_runtime_instance(
    item: dict[str, Any],
    strategy_templates: Optional[dict[str, dict[str, Any]]] = None,
    config_revision: str = "manual",
) -> dict[str, Any]:
    strategy_id = str(item.get("strategy_id", "")).strip()
    template_map = strategy_templates if isinstance(strategy_templates, dict) else {}
    template_snapshot = (
        template_map.get(strategy_id, {})
        if isinstance(template_map.get(strategy_id, {}), dict)
        else {}
    )
    instance_id = str(item.get("instance_id", "")).strip() or f"inst_{strategy_id}_{int(time.time())}"
    status = str(item.get("status", "running") or "running").lower()
    if status not in {"running", "paused"}:
        status = "running"
    allocation = _clamp_allocation(item.get("allocation", 0.0))
    param_overrides = item.get("param_overrides", {})
    metadata = item.get("metadata", {})
    normalized = {
        "instance_id": instance_id,
        "strategy_id": strategy_id,
        "strategy_name": str(item.get("strategy_name", strategy_id) or strategy_id),
        "strategy_version": str(item.get("strategy_version", item.get("version", "legacy")) or "legacy"),
        "allocation": allocation,
        "status": status,
        "role": str(item.get("role", "live_support") or "live_support"),
        "config_revision": str(item.get("config_revision", config_revision) or config_revision),
        "param_overrides": param_overrides if isinstance(param_overrides, dict) else {},
        "template_snapshot": (
            item.get("template_snapshot", template_snapshot)
            if isinstance(item.get("template_snapshot", template_snapshot), dict)
            else template_snapshot
        ),
        "deployed_at": str(item.get("deployed_at", "") or ""),
        "updated_at": str(item.get("updated_at", "") or ""),
        "metadata": metadata if isinstance(metadata, dict) else {},
    }
    return normalized


def _validate_strategy_template_payload(template: dict[str, Any]) -> Optional[str]:
    _style_allowed = {"trend", "breakout", "mean_reversion", "momentum"}
    _execution_role_allowed = {"primary", "support", "confirm", "auto_infer"}
    _section_keys = {
        "execution_baseline",
        "runtime_context",
        "coordination_contract",
    }

    def _normalized_list(value: Any) -> Optional[list[str]]:
        if value is None:
            return None
        if not isinstance(value, list):
            raise ValueError("must_be_list")
        normalized = [str(item).strip() for item in value if str(item).strip()]
        return normalized

    def _bounded_number(value: Any, *, field: str, low: float, high: float) -> float:
        try:
            number = float(value)
        except Exception:
            raise ValueError(f"{field} 必须是数值")
        if number < low or number > high:
            raise ValueError(f"{field} 超出允许范围 [{low}, {high}]")
        return number

    def _optional_bool(value: Any, *, field: str) -> None:
        if value is not None and not isinstance(value, bool):
            raise ValueError(f"{field} 必须是布尔值")

    def _check_section(section_name: str) -> dict[str, Any]:
        value = template.get(section_name)
        if value is None:
            return {}
        if not isinstance(value, dict):
            raise ValueError(f"{section_name} 必须是对象")
        return value

    def _ensure_consistent(top_key: str, nested_value: Any, *, label: str) -> None:
        if top_key in template and nested_value is not None and template.get(top_key) != nested_value:
            raise ValueError(f"{top_key} 与 {label} 不一致")

    def _ensure_list_consistent(top_key: str, nested_value: Optional[list[str]], *, label: str) -> None:
        if top_key not in template or nested_value is None:
            return
        top_value = _normalized_list(template.get(top_key))
        if top_value != nested_value:
            raise ValueError(f"{top_key} 与 {label} 不一致")

    for key in template.keys():
        if str(key) in _TEMPLATE_RESERVED_KEYS:
            return f"模板包含保留键: {key}"
    try:
        for section_key in _section_keys:
            _check_section(section_key)

        execution_baseline = _check_section("execution_baseline")
        runtime_context = _check_section("runtime_context")
        coordination_contract = _check_section("coordination_contract")

        timeframe = template.get("timeframe", execution_baseline.get("timeframe"))
        if timeframe is not None and str(timeframe) not in _TEMPLATE_TIMEFRAME_ALLOWED:
            return "timeframe 仅支持 M1/M5/M15/H1"
        _ensure_consistent("timeframe", execution_baseline.get("timeframe"), label="execution_baseline.timeframe")

        numeric_bounds: list[tuple[str, float, float]] = [
            ("risk_per_trade", 0.001, 0.05),
            ("atr_sl_mult", 0.8, 4.0),
            ("tp_rr", 0.5, 5.0),
            ("max_positions", 1.0, 10.0),
            ("max_active_strategies", 1.0, 10.0),
            ("conflict_hold_threshold", 0.0, 1.0),
        ]
        for key, low, high in numeric_bounds:
            nested_value = None
            if key in {"risk_per_trade", "atr_sl_mult", "tp_rr", "max_positions"}:
                nested_value = execution_baseline.get(key)
            elif key in {"max_active_strategies", "conflict_hold_threshold"}:
                nested_value = coordination_contract.get(key)
            if key in template:
                _bounded_number(template.get(key), field=key, low=low, high=high)
            if nested_value is not None:
                _bounded_number(nested_value, field=f"{key}", low=low, high=high)
            if nested_value is not None and key in template and float(template.get(key)) != float(nested_value):
                raise ValueError(f"{key} 与 {('execution_baseline' if key in {'risk_per_trade', 'atr_sl_mult', 'tp_rr', 'max_positions'} else 'coordination_contract')}.{key} 不一致")

        _optional_bool(template.get("reduce_only"), field="reduce_only")
        _optional_bool(execution_baseline.get("reduce_only"), field="execution_baseline.reduce_only")
        _ensure_consistent("reduce_only", execution_baseline.get("reduce_only"), label="execution_baseline.reduce_only")
        _optional_bool(template.get("allow_same_group_stack"), field="allow_same_group_stack")
        _optional_bool(coordination_contract.get("allow_same_group_stack"), field="coordination_contract.allow_same_group_stack")
        _ensure_consistent(
            "allow_same_group_stack",
            coordination_contract.get("allow_same_group_stack"),
            label="coordination_contract.allow_same_group_stack",
        )
        _optional_bool(template.get("allow_counter_style_mix"), field="allow_counter_style_mix")
        _optional_bool(coordination_contract.get("allow_counter_style_mix"), field="coordination_contract.allow_counter_style_mix")
        _ensure_consistent(
            "allow_counter_style_mix",
            coordination_contract.get("allow_counter_style_mix"),
            label="coordination_contract.allow_counter_style_mix",
        )

        style = str(template.get("strategy_style", coordination_contract.get("strategy_style", "")) or "").strip()
        if style:
            if style not in _style_allowed:
                return "strategy_style 仅支持 trend/breakout/mean_reversion/momentum"
            _ensure_consistent("strategy_style", coordination_contract.get("strategy_style"), label="coordination_contract.strategy_style")

        execution_role = str(template.get("execution_role", coordination_contract.get("execution_role", "")) or "").strip()
        if execution_role:
            if execution_role not in _execution_role_allowed:
                return "execution_role 仅支持 primary/support/confirm/auto_infer"
            _ensure_consistent("execution_role", coordination_contract.get("execution_role"), label="coordination_contract.execution_role")

        applicable_markets = _normalized_list(
            template.get("applicable_markets", runtime_context.get("applicable_markets"))
        )
        if applicable_markets is not None and len(applicable_markets) == 0:
            return "applicable_markets 至少包含一个市场标签"
        runtime_markets = _normalized_list(runtime_context.get("applicable_markets"))
        _ensure_list_consistent("applicable_markets", runtime_markets, label="runtime_context.applicable_markets")

        applicable_sessions = _normalized_list(
            template.get("applicable_sessions", runtime_context.get("applicable_sessions"))
        )
        if applicable_sessions is not None and len(applicable_sessions) == 0:
            return "applicable_sessions 至少包含一个时段标签"
        runtime_sessions = _normalized_list(runtime_context.get("applicable_sessions"))
        _ensure_list_consistent("applicable_sessions", runtime_sessions, label="runtime_context.applicable_sessions")

        conflict_groups = _normalized_list(
            template.get("conflict_groups", coordination_contract.get("conflict_groups"))
        )
        if conflict_groups is not None and len(conflict_groups) == 0:
            return "conflict_groups 至少包含一个冲突组"
        contract_conflicts = _normalized_list(coordination_contract.get("conflict_groups"))
        _ensure_list_consistent("conflict_groups", contract_conflicts, label="coordination_contract.conflict_groups")

        coordination_tags = _normalized_list(
            template.get("coordination_tags", coordination_contract.get("coordination_tags"))
        )
        if coordination_tags is not None and len(coordination_tags) == 0:
            return "coordination_tags 至少包含一个协同标签"
        contract_tags = _normalized_list(coordination_contract.get("coordination_tags"))
        _ensure_list_consistent("coordination_tags", contract_tags, label="coordination_contract.coordination_tags")
    except ValueError as exc:
        return str(exc)
    return None


def _default_decision_policy() -> dict[str, Any]:
    return {
        "policy_version": "1",
        "profile": "agent_guided_multi_strategy",
        "macro": {
            "role": "directional_gate",
            "degraded_policy": "soften_to_neutral",
            "aligned_scale": 1.1,
            "countertrend_scale": 0.35,
            "neutral_scale": 1.0,
            "direction_loss_guard": {
                "enabled": True,
                "lookback": 8,
                "min_samples": 3,
                "aligned_loss_streak_soft": 2,
                "aligned_loss_streak_hard": 3,
                "counter_win_bias_delta": 0.15,
                "soft_scale": 0.55,
                "hard_scale": 0.2,
                "neutralize_on_hard": True,
            },
        },
        "interpreter": {
            "role": "execution_bias_only",
            "buy_scale": 1.0,
            "sell_scale": 1.0,
            "limit_countertrend_scale": 0.5,
            "block_countertrend_scale": 0.0,
        },
        "risk": {
            "role": "active_governor",
            "per_instance": {
                "health_scoring": {
                    "enabled": True,
                    "min_trades_for_scoring": 3,
                    "score_ttl_sec": 30,
                },
                "loss_guard": {
                    "enabled": True,
                    "soft_loss_streak": 2,
                    "hard_loss_streak": 4,
                    "shadow_loss_streak": 5,
                    "soft_scale": 0.55,
                    "hard_scale": 0.25,
                    "rehab_min_trades": 5,
                    "rehab_min_win_rate": 0.50,
                },
                "regime_fitness": {
                    "enabled": True,
                    "min_samples_per_regime": 2,
                    "unfamiliar_regime_scale": 0.6,
                },
            },
            "cross_strategy": {
                "correlation_tracking": {
                    "enabled": True,
                    "divergence_confidence_penalty": 0.4,
                    "divergence_volume_penalty": 0.6,
                    "divergence_cooldown_sec": 120,
                    "alignment_volume_bonus": 0.05,
                },
            },
            "risk_budget": {
                "allocation_method": "health_squared",
                "min_lot_per_instance": 0.01,
                "max_lot_per_instance": 0.20,
            },
        },
        "execution": {
            "aggregation": "weighted_net_confidence",
            "min_confidence": 0.15,
            "entry_policy": "health_weighted_vote",
        },
        "router": {
            "trend": {
                "primary_strategy_id": "ema_macd",
                "support_strategy_ids": ["composite", "elliott_wave"],
                "rank_weights": [0.6, 0.25, 0.15],
            },
            "range": {
                "primary_strategy_id": "wyckoff",
                "support_strategy_ids": ["composite", "chanlun"],
                "rank_weights": [0.6, 0.25, 0.15],
            },
            "uncertain": {
                "primary_strategy_id": "composite",
                "support_strategy_ids": ["ema_macd", "wyckoff"],
                "rank_weights": [0.6, 0.25, 0.15],
            },
        },
    }


def _normalize_decision_policy(raw: Any) -> dict[str, Any]:
    base = _default_decision_policy()
    payload = raw if isinstance(raw, dict) else {}
    macro = payload.get("macro", {}) if isinstance(payload.get("macro", {}), dict) else {}
    interpreter = payload.get("interpreter", {}) if isinstance(payload.get("interpreter", {}), dict) else {}
    risk = payload.get("risk", {}) if isinstance(payload.get("risk", {}), dict) else {}
    execution = payload.get("execution", {}) if isinstance(payload.get("execution", {}), dict) else {}
    router = payload.get("router", {}) if isinstance(payload.get("router", {}), dict) else {}
    macro_guard = (
        macro.get("direction_loss_guard", {})
        if isinstance(macro.get("direction_loss_guard", {}), dict)
        else {}
    )

    def _bounded_float(value: Any, default: float, *, low: float = 0.0, high: float = 2.0) -> float:
        try:
            return round(max(low, min(high, float(value))), 4)
        except Exception:
            return default

    def _coerce_bool(value: Any, default: bool) -> bool:
        if isinstance(value, bool):
            return value
        if isinstance(value, str):
            lowered = value.strip().lower()
            if lowered in {"1", "true", "yes", "on"}:
                return True
            if lowered in {"0", "false", "no", "off"}:
                return False
        return default

    def _normalize_router_family(raw_family: Any, base_family: dict[str, Any]) -> dict[str, Any]:
        family = raw_family if isinstance(raw_family, dict) else {}
        primary = str(
            family.get("primary_strategy_id", base_family["primary_strategy_id"])
            or base_family["primary_strategy_id"]
        ).strip() or base_family["primary_strategy_id"]
        supports_raw = family.get("support_strategy_ids", base_family["support_strategy_ids"])
        supports = (
            [str(item).strip() for item in supports_raw if str(item).strip()]
            if isinstance(supports_raw, list)
            else list(base_family["support_strategy_ids"])
        )
        candidate_count = max(1, len([primary, *supports]))
        weights_raw = family.get("rank_weights", base_family["rank_weights"])
        if isinstance(weights_raw, list):
            weights = [
                _bounded_float(
                    weights_raw[idx] if idx < len(weights_raw) else base_family["rank_weights"][idx],
                    float(base_family["rank_weights"][idx] if idx < len(base_family["rank_weights"]) else 0.0),
                    low=0.0,
                    high=1.0,
                )
                for idx in range(candidate_count)
            ]
        else:
            weights = list(base_family["rank_weights"])
        total = sum(weights)
        if total > 0:
            weights = [round(weight / total, 4) for weight in weights]
        return {
            "primary_strategy_id": primary,
            "support_strategy_ids": supports,
            "rank_weights": weights,
        }

    return {
        "policy_version": str(payload.get("policy_version", base["policy_version"]) or base["policy_version"]),
        "profile": str(payload.get("profile", base["profile"]) or base["profile"]),
        "macro": {
            "role": str(macro.get("role", base["macro"]["role"]) or base["macro"]["role"]),
            "degraded_policy": str(
                macro.get("degraded_policy", base["macro"]["degraded_policy"])
                or base["macro"]["degraded_policy"]
            ),
            "aligned_scale": _bounded_float(
                macro.get("aligned_scale", base["macro"]["aligned_scale"]),
                base["macro"]["aligned_scale"],
            ),
            "countertrend_scale": _bounded_float(
                macro.get("countertrend_scale", base["macro"]["countertrend_scale"]),
                base["macro"]["countertrend_scale"],
            ),
            "neutral_scale": _bounded_float(
                macro.get("neutral_scale", base["macro"]["neutral_scale"]),
                base["macro"]["neutral_scale"],
            ),
            "direction_loss_guard": {
                "enabled": _coerce_bool(
                    macro_guard.get("enabled", base["macro"]["direction_loss_guard"]["enabled"]),
                    base["macro"]["direction_loss_guard"]["enabled"],
                ),
                "lookback": int(
                    max(
                        1,
                        min(
                            40,
                            float(
                                macro_guard.get(
                                    "lookback",
                                    base["macro"]["direction_loss_guard"]["lookback"],
                                )
                                or base["macro"]["direction_loss_guard"]["lookback"]
                            ),
                        ),
                    )
                ),
                "min_samples": int(
                    max(
                        1,
                        min(
                            20,
                            float(
                                macro_guard.get(
                                    "min_samples",
                                    base["macro"]["direction_loss_guard"]["min_samples"],
                                )
                                or base["macro"]["direction_loss_guard"]["min_samples"]
                            ),
                        ),
                    )
                ),
                "aligned_loss_streak_soft": int(
                    max(
                        1,
                        min(
                            10,
                            float(
                                macro_guard.get(
                                    "aligned_loss_streak_soft",
                                    base["macro"]["direction_loss_guard"]["aligned_loss_streak_soft"],
                                )
                                or base["macro"]["direction_loss_guard"]["aligned_loss_streak_soft"]
                            ),
                        ),
                    )
                ),
                "aligned_loss_streak_hard": int(
                    max(
                        1,
                        min(
                            10,
                            float(
                                macro_guard.get(
                                    "aligned_loss_streak_hard",
                                    base["macro"]["direction_loss_guard"]["aligned_loss_streak_hard"],
                                )
                                or base["macro"]["direction_loss_guard"]["aligned_loss_streak_hard"]
                            ),
                        ),
                    )
                ),
                "counter_win_bias_delta": _bounded_float(
                    macro_guard.get(
                        "counter_win_bias_delta",
                        base["macro"]["direction_loss_guard"]["counter_win_bias_delta"],
                    ),
                    base["macro"]["direction_loss_guard"]["counter_win_bias_delta"],
                    low=0.0,
                    high=1.0,
                ),
                "soft_scale": _bounded_float(
                    macro_guard.get(
                        "soft_scale",
                        base["macro"]["direction_loss_guard"]["soft_scale"],
                    ),
                    base["macro"]["direction_loss_guard"]["soft_scale"],
                    low=0.0,
                    high=1.0,
                ),
                "hard_scale": _bounded_float(
                    macro_guard.get(
                        "hard_scale",
                        base["macro"]["direction_loss_guard"]["hard_scale"],
                    ),
                    base["macro"]["direction_loss_guard"]["hard_scale"],
                    low=0.0,
                    high=1.0,
                ),
                "neutralize_on_hard": _coerce_bool(
                    macro_guard.get(
                        "neutralize_on_hard",
                        base["macro"]["direction_loss_guard"]["neutralize_on_hard"],
                    ),
                    base["macro"]["direction_loss_guard"]["neutralize_on_hard"],
                ),
            },
        },
        "interpreter": {
            "role": str(interpreter.get("role", base["interpreter"]["role"]) or base["interpreter"]["role"]),
            "buy_scale": _bounded_float(
                interpreter.get("buy_scale", base["interpreter"]["buy_scale"]),
                base["interpreter"]["buy_scale"],
            ),
            "sell_scale": _bounded_float(
                interpreter.get("sell_scale", base["interpreter"]["sell_scale"]),
                base["interpreter"]["sell_scale"],
            ),
            "limit_countertrend_scale": _bounded_float(
                interpreter.get(
                    "limit_countertrend_scale",
                    base["interpreter"]["limit_countertrend_scale"],
                ),
                base["interpreter"]["limit_countertrend_scale"],
            ),
            "block_countertrend_scale": _bounded_float(
                interpreter.get(
                    "block_countertrend_scale",
                    base["interpreter"]["block_countertrend_scale"],
                ),
                base["interpreter"]["block_countertrend_scale"],
            ),
        },
        "risk": {
            "role": str(risk.get("role", base["risk"]["role"]) or base["risk"]["role"]),
            "per_instance": {
                "health_scoring": {
                    "enabled": _coerce_bool(
                        risk.get("per_instance", {}).get("health_scoring", {}).get("enabled", True),
                        True,
                    ),
                    "min_trades_for_scoring": int(float(
                        risk.get("per_instance", {}).get("health_scoring", {}).get("min_trades_for_scoring", 3) or 3
                    )),
                    "score_ttl_sec": int(float(
                        risk.get("per_instance", {}).get("health_scoring", {}).get("score_ttl_sec", 30) or 30
                    )),
                },
                "loss_guard": {
                    "enabled": _coerce_bool(
                        risk.get("per_instance", {}).get("loss_guard", {}).get("enabled", True),
                        True,
                    ),
                    "soft_loss_streak": int(float(
                        risk.get("per_instance", {}).get("loss_guard", {}).get("soft_loss_streak", 2) or 2
                    )),
                    "hard_loss_streak": int(float(
                        risk.get("per_instance", {}).get("loss_guard", {}).get("hard_loss_streak", 4) or 4
                    )),
                    "shadow_loss_streak": int(float(
                        risk.get("per_instance", {}).get("loss_guard", {}).get("shadow_loss_streak", 5) or 5
                    )),
                    "soft_scale": _bounded_float(
                        risk.get("per_instance", {}).get("loss_guard", {}).get("soft_scale", 0.55),
                        0.55, low=0.0, high=1.0,
                    ),
                    "hard_scale": _bounded_float(
                        risk.get("per_instance", {}).get("loss_guard", {}).get("hard_scale", 0.25),
                        0.25, low=0.0, high=1.0,
                    ),
                    "rehab_min_trades": int(float(
                        risk.get("per_instance", {}).get("loss_guard", {}).get("rehab_min_trades", 5) or 5
                    )),
                    "rehab_min_win_rate": _bounded_float(
                        risk.get("per_instance", {}).get("loss_guard", {}).get("rehab_min_win_rate", 0.50),
                        0.50, low=0.0, high=1.0,
                    ),
                },
                "regime_fitness": {
                    "enabled": _coerce_bool(
                        risk.get("per_instance", {}).get("regime_fitness", {}).get("enabled", True),
                        True,
                    ),
                    "min_samples_per_regime": int(float(
                        risk.get("per_instance", {}).get("regime_fitness", {}).get("min_samples_per_regime", 2) or 2
                    )),
                    "unfamiliar_regime_scale": _bounded_float(
                        risk.get("per_instance", {}).get("regime_fitness", {}).get("unfamiliar_regime_scale", 0.6),
                        0.6, low=0.0, high=1.0,
                    ),
                },
            },
            "cross_strategy": {
                "correlation_tracking": {
                    "enabled": _coerce_bool(
                        risk.get("cross_strategy", {}).get("correlation_tracking", {}).get("enabled", True),
                        True,
                    ),
                    "divergence_confidence_penalty": _bounded_float(
                        risk.get("cross_strategy", {}).get("correlation_tracking", {}).get("divergence_confidence_penalty", 0.4),
                        0.4, low=0.0, high=1.0,
                    ),
                    "divergence_volume_penalty": _bounded_float(
                        risk.get("cross_strategy", {}).get("correlation_tracking", {}).get("divergence_volume_penalty", 0.6),
                        0.6, low=0.0, high=1.0,
                    ),
                    "divergence_cooldown_sec": int(float(
                        risk.get("cross_strategy", {}).get("correlation_tracking", {}).get("divergence_cooldown_sec", 120) or 120
                    )),
                    "alignment_volume_bonus": _bounded_float(
                        risk.get("cross_strategy", {}).get("correlation_tracking", {}).get("alignment_volume_bonus", 0.05),
                        0.05, low=0.0, high=0.5,
                    ),
                },
            },
            "risk_budget": {
                "allocation_method": str(
                    risk.get("risk_budget", {}).get("allocation_method", "health_squared") or "health_squared"
                ),
                "min_lot_per_instance": _bounded_float(
                    risk.get("risk_budget", {}).get("min_lot_per_instance", 0.01),
                    0.01, low=0.0, high=0.10,
                ),
                "max_lot_per_instance": _bounded_float(
                    risk.get("risk_budget", {}).get("max_lot_per_instance", 0.20),
                    0.20, low=0.0, high=10.0,
                ),
            },
        },
        "execution": {
            "aggregation": str(
                execution.get("aggregation", base["execution"]["aggregation"])
                or base["execution"]["aggregation"]
            ),
            "min_confidence": _bounded_float(
                execution.get("min_confidence", base["execution"]["min_confidence"]),
                base["execution"]["min_confidence"],
                low=0.0,
                high=1.0,
            ),
            "entry_policy": str(
                execution.get("entry_policy", base["execution"]["entry_policy"])
                or base["execution"]["entry_policy"]
            ),
        },
        "router": {
            "trend": _normalize_router_family(router.get("trend", {}), base["router"]["trend"]),
            "range": _normalize_router_family(router.get("range", {}), base["router"]["range"]),
            "uncertain": _normalize_router_family(
                router.get("uncertain", {}),
                base["router"]["uncertain"],
            ),
        },
    }


def _default_strategy_market_state() -> dict[str, Any]:
    now_iso = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
    strategies: dict[str, Any] = {}
    for strategy_id in VALID_STRATEGIES:
        strategies[strategy_id] = {
            "enabled": True,
            "template": _default_strategy_template(strategy_id),
            "versions": [
                {
                    "version": "1.0.0",
                    "note": "initial release",
                    "published_at": now_iso,
                }
            ],
            "current_version": "1.0.0",
        }
    return {
        "strategies": strategies,
        "runtime_instances": [],
        "decision_policy": _default_decision_policy(),
        "updated_at": now_iso,
    }


def _strategy_catalog_from_state(state: Optional[dict[str, Any]] = None) -> dict[str, str]:
    payload = state if isinstance(state, dict) else _load_strategy_market_state()
    catalog: dict[str, str] = dict(VALID_STRATEGIES)
    strategies = payload.get("strategies", {}) if isinstance(payload.get("strategies", {}), dict) else {}
    for strategy_id, row in strategies.items():
        if not isinstance(row, dict):
            continue
        sid = str(strategy_id or "").strip()
        if not sid:
            continue
        display_name = str(row.get("display_name") or row.get("name") or sid)
        if sid not in catalog:
            catalog[sid] = display_name
    return catalog


def _load_strategy_market_state() -> dict[str, Any]:
    state_file = _strategy_market_state_file()
    data = _default_strategy_market_state()
    if state_file.exists():
        try:
            loaded = json.loads(state_file.read_text(encoding="utf-8"))
            if isinstance(loaded, dict):
                data.update(loaded)
        except Exception as exc:
            _recover_corrupt_json_file(
                state_file,
                data,
                reason=f"strategy_market_state_invalid_json:{type(exc).__name__}",
            )
    else:
        bootstrapped = _bootstrap_missing_strategy_market_state(state_file)
        if isinstance(bootstrapped, dict):
            data.update(bootstrapped)
    strategies = data.get("strategies", {}) if isinstance(data.get("strategies", {}), dict) else {}
    for strategy_id in VALID_STRATEGIES:
        row = strategies.get(strategy_id, {}) if isinstance(strategies.get(strategy_id, {}), dict) else {}
        default_row = _default_strategy_market_state()["strategies"][strategy_id]
        merged = {
            "enabled": bool(row.get("enabled", default_row["enabled"])),
            "template": _normalize_strategy_template_payload(
                row.get("template", default_row["template"])
                if isinstance(row.get("template", default_row["template"]), dict)
                else default_row["template"]
            ),
            "versions": row.get("versions", default_row["versions"]) if isinstance(row.get("versions", default_row["versions"]), list) else default_row["versions"],
            "current_version": str(row.get("current_version", default_row["current_version"]) or default_row["current_version"]),
        }
        strategies[strategy_id] = merged
    data["strategies"] = strategies
    template_map = {
        str(strategy_id): (
            row.get("template", {})
            if isinstance(row.get("template", {}), dict)
            else {}
        )
        for strategy_id, row in strategies.items()
        if isinstance(row, dict)
    }
    effective_payload = _load_effective_params_payload()
    config_revision = _effective_config_revision(effective_payload)
    raw_instances = data.get("runtime_instances", []) if isinstance(data.get("runtime_instances", []), list) else []
    data["runtime_instances"] = [
        _normalize_runtime_instance(item, strategy_templates=template_map, config_revision=config_revision)
        for item in raw_instances
        if isinstance(item, dict)
    ]
    data["decision_policy"] = _normalize_decision_policy(data.get("decision_policy"))
    data["updated_at"] = str(data.get("updated_at", ""))
    return data


def _save_strategy_market_state(data: dict[str, Any]) -> pathlib.Path:
    state_file = _strategy_market_state_file()
    data["updated_at"] = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
    return _write_json_atomic(state_file, data)


def _save_effective_params_payload(payload: dict[str, Any]) -> pathlib.Path:
    eff_file = LOGS_DIR / "effective_params.json"
    return _write_json_atomic(eff_file, payload)


def _sync_runtime_scheduler_to_effective(state: Optional[dict[str, Any]] = None) -> dict[str, Any]:
    payload = state if isinstance(state, dict) else _load_strategy_market_state()
    instances = payload.get("runtime_instances", []) if isinstance(payload.get("runtime_instances", []), list) else []
    running = [
        item for item in instances
        if isinstance(item, dict) and str(item.get("status", "running")).lower() == "running"
    ]
    strategy_weights: dict[str, float] = {}
    active_ids: list[str] = []
    active_instance_ids: list[str] = []
    strategy_instance_map: dict[str, list[str]] = {}
    instance_allocations: dict[str, float] = {}
    for item in running:
        sid = str(item.get("strategy_id", "")).strip()
        instance_id = str(item.get("instance_id", "")).strip()
        if not sid:
            continue
        alloc = float(item.get("allocation", 0.0) or 0.0)
        strategy_weights[sid] = round(float(strategy_weights.get(sid, 0.0)) + max(0.0, alloc), 4)
        if sid not in active_ids:
            active_ids.append(sid)
        if instance_id:
            active_instance_ids.append(instance_id)
            instance_allocations[instance_id] = max(0.0, alloc)
            strategy_instance_map.setdefault(sid, []).append(instance_id)
    total_alloc = sum(instance_allocations.values())
    instance_weights: dict[str, float] = {}
    if active_instance_ids:
        if total_alloc > 0:
            for instance_id in active_instance_ids:
                instance_weights[instance_id] = round(instance_allocations.get(instance_id, 0.0) / total_alloc, 4)
        else:
            equal_weight = round(1.0 / max(1, len(active_instance_ids)), 4)
            for instance_id in active_instance_ids:
                instance_weights[instance_id] = equal_weight
    strategy_weights = {
        sid: round(
            sum(instance_weights.get(instance_id, 0.0) for instance_id in strategy_instance_map.get(sid, [])),
            4,
        )
        for sid in active_ids
    }
    selected_instance_id = ""
    selected_strategy_id = ""
    if active_instance_ids:
        top_instances = sorted(
            instance_weights.items(),
            key=lambda item: (-float(item[1]), item[0]),
        )
        if top_instances:
            selected_instance_id = str(top_instances[0][0] or "")
            for sid, mapped_instance_ids in strategy_instance_map.items():
                if selected_instance_id in mapped_instance_ids:
                    selected_strategy_id = sid
                    break
    if not selected_strategy_id and active_ids:
        selected_strategy_id = active_ids[0]
    mode = "idle"
    if len(active_instance_ids) == 1:
        mode = "single_active"
    elif len(active_instance_ids) > 1:
        mode = "multi_active"
    if not active_instance_ids:
        reason = "no_running_instances"
    elif len(active_instance_ids) == 1:
        reason = f"single_running_instance:{active_instance_ids[0]}"
    else:
        top_instances = sorted(
            instance_weights.items(),
            key=lambda item: (-float(item[1]), item[0]),
        )
        top_text = ",".join(f"{instance_id}={weight:.2f}" for instance_id, weight in top_instances[:3])
        reason = f"allocation_weighted_multi_instance:{top_text}"
    config_revision = _effective_config_revision()
    decision_policy = _normalize_decision_policy(payload.get("decision_policy"))
    scheduler = {
        "mode": mode,
        "arbiter": "allocation-weighted",
        "reason": reason,
        "active_instances": active_instance_ids,
        "active_instance_ids": active_instance_ids,
        "active_strategy_ids": active_ids,
        "selected_instance_id": selected_instance_id,
        "selected_strategy_id": selected_strategy_id,
        "instance_weights": instance_weights,
        "strategy_weights": strategy_weights,
        "strategy_instance_map": strategy_instance_map,
        "decision_policy": decision_policy,
        "config_revision": config_revision,
        "updated_at": datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
    }
    eff_data = _load_effective_params_payload()
    eff_data["runtime_scheduler"] = scheduler
    if len(active_ids) == 1:
        eff_data["strategy_type"] = active_ids[0]
    elif len(active_ids) > 1:
        eff_data["strategy_type"] = "composite"
    eff_data["strategy_switched_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
    _save_effective_params_payload(eff_data)
    return scheduler


def _next_patch_version(version: str) -> str:
    text = str(version or "1.0.0").strip()
    parts = text.split(".")
    if len(parts) != 3:
        return "1.0.1"
    try:
        major = int(parts[0])
        minor = int(parts[1])
        patch = int(parts[2])
    except Exception:
        return "1.0.1"
    return f"{major}.{minor}.{patch + 1}"


class StrategyDeployRequest(BaseModel):
    strategy_id: str
    allocation: float = 1.0
    activate: bool = True
    role: str = "live_support"
    param_overrides: Dict[str, Any] = Field(default_factory=dict)


class StrategyDisableRequest(BaseModel):
    strategy_id: str
    disabled: bool = True


class StrategyPublishRequest(BaseModel):
    strategy_id: str
    note: str = ""


class StrategyRollbackRequest(BaseModel):
    strategy_id: str
    target_version: Optional[str] = None


class StrategyTemplateRequest(BaseModel):
    strategy_id: str
    template: Dict[str, Any]


class StrategyInstanceActionRequest(BaseModel):
    instance_id: str
    action: str
    allocation: Optional[float] = None
    role: Optional[str] = None
    param_overrides: Dict[str, Any] = Field(default_factory=dict)


class StrategyDecisionPolicyRequest(BaseModel):
    profile: Optional[str] = None
    macro: Dict[str, Any] = Field(default_factory=dict)
    interpreter: Dict[str, Any] = Field(default_factory=dict)
    risk: Dict[str, Any] = Field(default_factory=dict)
    execution: Dict[str, Any] = Field(default_factory=dict)
    router: Dict[str, Any] = Field(default_factory=dict)


class StrategyCompositeCreateRequest(BaseModel):
    name: str
    components: List[str]


class StrategyDraftSaveRequest(BaseModel):
    name: str
    document: str
    optimized: Dict[str, Any] = {}
    evaluation: Dict[str, Any] = {}


class StrategyDraftOptimizeRequest(BaseModel):
    name: str
    document: str


class StrategyDraftEvaluateRequest(BaseModel):
    name: str
    document: str
    optimized: Dict[str, Any] = {}


class RuntimeStrategyEvaluationRequest(BaseModel):
    goal: str = ""


class StrategyDraftGenerateRequest(BaseModel):
    name: str
    document: str
    optimized: Dict[str, Any] = {}
    evaluation: Dict[str, Any] = {}


def _strategy_custom_state_file() -> pathlib.Path:
    return CONFIG_DIR / "strategy_custom_state.json"


def _runtime_ai_evaluation_state_file() -> pathlib.Path:
    return DATA_DIR / "runtime_ai_evaluation_history.json"


def _load_strategy_custom_state() -> dict[str, Any]:
    state_file = _strategy_custom_state_file()
    data: dict[str, Any] = {
        "draft": {
            "name": "",
            "document": "",
            "optimized": {},
            "evaluation": {},
            "saveState": "saved",
            "lastSavedAt": "",
        },
        "generatedHistory": [],
    }
    if state_file.exists():
        try:
            loaded = json.loads(state_file.read_text(encoding="utf-8"))
            if isinstance(loaded, dict):
                data.update(loaded)
        except Exception as exc:
            _recover_corrupt_json_file(
                state_file,
                data,
                reason=f"strategy_custom_state_invalid_json:{type(exc).__name__}",
            )
    if not isinstance(data.get("draft", {}), dict):
        data["draft"] = {
            "name": "",
            "document": "",
            "optimized": {},
            "evaluation": {},
            "saveState": "saved",
            "lastSavedAt": "",
        }
    if not isinstance(data.get("generatedHistory", []), list):
        data["generatedHistory"] = []
    return data


def _save_strategy_custom_state(payload: dict[str, Any]) -> pathlib.Path:
    state_file = _strategy_custom_state_file()
    state_file.parent.mkdir(parents=True, exist_ok=True)
    state_file.write_text(
        json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    return state_file


def _load_runtime_ai_evaluation_state() -> dict[str, Any]:
    state_file = _runtime_ai_evaluation_state_file()
    data: dict[str, Any] = {
        "lastEvaluation": {},
        "history": [],
        "updatedAt": "",
    }
    if state_file.exists():
        try:
            loaded = json.loads(state_file.read_text(encoding="utf-8"))
            if isinstance(loaded, dict):
                data.update(loaded)
        except Exception:
            pass
    if not isinstance(data.get("lastEvaluation", {}), dict):
        data["lastEvaluation"] = {}
    if not isinstance(data.get("history", []), list):
        data["history"] = []
    if not isinstance(data.get("updatedAt", ""), str):
        data["updatedAt"] = ""
    return data


def _save_runtime_ai_evaluation_state(payload: dict[str, Any]) -> pathlib.Path:
    data = _load_runtime_ai_evaluation_state()
    if isinstance(payload, dict):
        data.update(payload)
    data["updatedAt"] = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
    return _write_json_atomic(_runtime_ai_evaluation_state_file(), data)


def _infer_base_engine_from_doc(document: str) -> str:
    text = str(document or "").lower()
    if any(token in text for token in ["wave", "波浪", "elliott"]):
        return "elliott_wave"
    if any(token in text for token in ["缠论", "中枢", "chanlun"]):
        return "chanlun"
    if any(token in text for token in ["wyckoff", "spring", "sos", "sow"]):
        return "wyckoff"
    if any(token in text for token in ["组合", "多策略", "composite"]):
        return "composite"
    return "ema_macd"

@app.get("/api/strategy/list")
def list_strategies():
    """返回所有可用策略列表"""
    state = _load_strategy_market_state()
    catalog = _strategy_catalog_from_state(state)
    return {"strategies": [{"id": k, "name": v} for k, v in catalog.items()]}


@app.get("/api/strategy/status")
def get_strategy_status():
    """返回当前策略状态（不触发加载）"""
    try:
        from bridge.trader_api import get_signal_generator_status
        status = get_signal_generator_status()
        state = _load_strategy_market_state()
        catalog = _strategy_catalog_from_state(state)

        # 补充：从 effective_params.json 读取当前配置的 strategy_type
        eff_file = LOGS_DIR / "effective_params.json"
        configured_type = "ema_macd"
        if eff_file.exists():
            try:
                eff = json.loads(eff_file.read_text())
                configured_type = eff.get("strategy_type", "ema_macd")
            except Exception:
                pass

        return {
            "success": True,
            "configured_strategy": configured_type,
            "configured_strategy_name": catalog.get(configured_type) or VALID_STRATEGIES.get(configured_type, "未知"),
            "runtime_status": status,
            "valid_strategies": list(catalog.keys()),
        }
    except Exception as e:
        return {"success": False, "error": str(e)}


def _clear_strategy_asset_selection(eff_data: dict[str, Any]) -> None:
    for key in (
        "strategy_asset_id",
        "strategy_asset_name",
        "strategy_base_engine",
        "strategy_combo_id",
        "strategy_combo_name",
        "strategy_combo_components",
        "strategy_combo_template",
        "strategy_custom_id",
        "strategy_custom_name",
        "strategy_custom_template",
    ):
        eff_data.pop(key, None)


def _apply_strategy_selection_to_effective(
    eff_data: dict[str, Any],
    strategy_id: str,
    row: Optional[dict[str, Any]],
) -> None:
    _clear_strategy_asset_selection(eff_data)
    eff_data["strategy_type"] = strategy_id
    if not isinstance(row, dict):
        return
    if strategy_id in VALID_STRATEGIES:
        return
    eff_data["strategy_asset_id"] = strategy_id
    eff_data["strategy_asset_name"] = str(row.get("display_name") or strategy_id)
    base_engine = str(
        row.get("base_engine")
        or ("composite" if bool(row.get("is_composite", False)) else "ema_macd")
        or "ema_macd"
    )
    eff_data["strategy_base_engine"] = base_engine
    if bool(row.get("is_composite", False)):
        eff_data["strategy_combo_id"] = strategy_id
        eff_data["strategy_combo_name"] = str(row.get("display_name") or strategy_id)
        eff_data["strategy_combo_components"] = (
            row.get("components", [])
            if isinstance(row.get("components", []), list)
            else []
        )
        eff_data["strategy_combo_template"] = (
            row.get("template", {})
            if isinstance(row.get("template", {}), dict)
            else {}
        )
        return
    if bool(row.get("is_custom", False)) or strategy_id.startswith("custom_"):
        eff_data["strategy_custom_id"] = strategy_id
        eff_data["strategy_custom_name"] = str(row.get("display_name") or strategy_id)
        eff_data["strategy_custom_template"] = (
            row.get("template", {})
            if isinstance(row.get("template", {}), dict)
            else {}
        )


class StrategySwitchRequest(BaseModel):
    strategy_type: str
    persist: bool = True  # 是否写入 effective_params.json（持久化）


@app.post("/api/strategy/switch")
def switch_strategy(req: StrategySwitchRequest):
    """
    热切换策略（无需重启，立即生效）。
    请求体：{"strategy_type": "composite", "persist": true}
    """
    state = _load_strategy_market_state()
    catalog = _strategy_catalog_from_state(state)
    row = state.get("strategies", {}).get(req.strategy_type, {}) if isinstance(state.get("strategies", {}), dict) else {}
    if req.strategy_type not in catalog:
        return {
            "success": False,
            "error": f"未知策略类型: {req.strategy_type}",
            "valid": list(catalog.keys()),
        }

    try:
        from bridge.trader_api import (
            set_auto_select_enabled,
            set_strategy_override,
            clear_strategy_cache,
            reset_signal_generator,
            warm_signal_generator,
        )

        # 关闭自动切换（手动指定策略时禁用市场感知自动选）
        set_auto_select_enabled(False)
        clear_strategy_cache()
        # persist=true 才落盘；persist=false 只做热切换
        if req.persist:
            with _strategy_market_state_transaction():
                eff_data = _load_effective_params_payload()
                _apply_strategy_selection_to_effective(eff_data, req.strategy_type, row)
                eff_data["strategy_switched_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
                _save_effective_params_payload(eff_data)
            set_strategy_override(None)
        else:
            set_strategy_override(req.strategy_type)

        # 2. 热重置并预热
        old_gen = reset_signal_generator()

        # 3. 触发一次生成器预热（立即加载新策略）
        new_status = warm_signal_generator()

        return {
            "success": True,
            "message": f"策略已切换: {old_gen} → {req.strategy_type}",
            "previous_generator": old_gen,
            "new_strategy_type": req.strategy_type,
            "new_strategy_name": catalog.get(req.strategy_type),
            "runtime_status": new_status,
            "persist": req.persist,
            "persisted": bool(req.persist),
        }
    except Exception as e:
        import traceback
        traceback.print_exc()
        return {"success": False, "error": str(e)}


@app.post("/api/strategy/deploy")
def deploy_strategy(req: StrategyDeployRequest):
    strategy_id = str(req.strategy_id or "").strip()
    with _strategy_market_state_transaction():
        state = _load_strategy_market_state()
        catalog = _strategy_catalog_from_state(state)
        if strategy_id not in catalog:
            return {"success": False, "error": f"未知策略: {strategy_id}"}
        row = state["strategies"].get(strategy_id, {})
        if not bool(row.get("enabled", True)):
            return {"success": False, "error": "策略已停用，无法部署"}

        instances = state.get("runtime_instances", [])
        now_iso = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
        instances.append(
            _normalize_runtime_instance(
                {
                    "instance_id": f"inst_{strategy_id}_{int(time.time() * 1000)}",
                    "strategy_id": strategy_id,
                    "strategy_name": catalog.get(strategy_id, strategy_id),
                    "strategy_version": str(row.get("current_version", "1.0.0") or "1.0.0"),
                    "allocation": _clamp_allocation(req.allocation),
                    "status": "running" if bool(req.activate) else "paused",
                    "role": str(req.role or "live_support"),
                    "config_revision": _effective_config_revision(),
                    "param_overrides": req.param_overrides if isinstance(req.param_overrides, dict) else {},
                    "template_snapshot": row.get("template", {}) if isinstance(row.get("template", {}), dict) else {},
                    "deployed_at": now_iso,
                    "updated_at": now_iso,
                }
            )
        )
        state["runtime_instances"] = instances
        _sync_runtime_scheduler_to_effective(state)
        _save_strategy_market_state(state)

    switch_result: dict[str, Any] = {"success": True}
    if req.activate:
        switch_result = switch_strategy(
            StrategySwitchRequest(strategy_type=strategy_id, persist=True)
        )
    if not bool(switch_result.get("success", False)):
        return {"success": False, "error": str(switch_result.get("error", "部署后激活失败"))}
    _record_system_message(
        kind="deploy",
        title="策略已部署",
        desc=f"`{strategy_id}` 已进入运行实例，当前实例数 {len(state.get('runtime_instances', []))}",
        color="primary",
        source="strategy_marketplace",
        metadata={
            "strategy_id": strategy_id,
            "instance_count": len(state.get("runtime_instances", [])),
            "allocation": _clamp_allocation(req.allocation),
            "role": str(req.role or "live_support"),
            "activate": bool(req.activate),
        },
    )
    return {
        "success": True,
        "message": f"策略 {strategy_id} 已部署并进入运行实例",
        "instance_count": len(state.get("runtime_instances", [])),
        "scheduler": _load_effective_params_payload().get("runtime_scheduler", {}),
        "runtime_status": switch_result.get("runtime_status"),
    }


@app.post("/api/strategy/disable")
def disable_strategy(req: StrategyDisableRequest):
    strategy_id = str(req.strategy_id or "").strip()
    with _strategy_market_state_transaction():
        state = _load_strategy_market_state()
        catalog = _strategy_catalog_from_state(state)
        if strategy_id not in catalog:
            return {"success": False, "error": f"未知策略: {strategy_id}"}
        row = state["strategies"].get(strategy_id, {})
        row["enabled"] = not bool(req.disabled)
        state["strategies"][strategy_id] = row
        if req.disabled:
            runtime_instances = state.get("runtime_instances", []) if isinstance(state.get("runtime_instances", []), list) else []
            state["runtime_instances"] = [
                item for item in runtime_instances
                if not (isinstance(item, dict) and str(item.get("strategy_id", "")).strip() == strategy_id)
            ]
        scheduler = _sync_runtime_scheduler_to_effective(state)
        _save_strategy_market_state(state)
    _record_system_message(
        kind="disable" if bool(req.disabled) else "enable",
        title="策略状态已更新",
        desc=f"`{strategy_id}` 已{'停用' if bool(req.disabled) else '重新启用'}",
        color="warning" if bool(req.disabled) else "success",
        source="strategy_marketplace",
        metadata={
            "strategy_id": strategy_id,
            "enabled": bool(row["enabled"]),
        },
    )
    return {
        "success": True,
        "message": "策略已停用" if req.disabled else "策略已重新启用",
        "strategy_id": strategy_id,
        "enabled": row["enabled"],
        "scheduler": scheduler,
        "runtime_instances": state.get("runtime_instances", []),
    }


@app.get("/api/strategy/decision_policy")
def get_strategy_decision_policy():
    state = _load_strategy_market_state()
    contract_payload = load_agent_decision_contract()
    policy = state.get("decision_policy")
    if not isinstance(policy, dict) or not policy:
        policy = contract_payload.get("decision_policy", {})
    return {
        "success": True,
        "decision_policy": _normalize_decision_policy(policy),
        "scheduler": _load_effective_params_payload().get("runtime_scheduler", {}),
    }


@app.post("/api/strategy/decision_policy")
def save_strategy_decision_policy(req: StrategyDecisionPolicyRequest):
    if hasattr(req, "model_dump"):
        updates = req.model_dump(exclude_none=True)
    else:
        updates = req.dict(exclude_none=True)
    with _strategy_market_state_transaction():
        state = _load_strategy_market_state()
        current = state.get("decision_policy", {})
        merged = current if isinstance(current, dict) else {}
        for key in ("profile", "macro", "interpreter", "risk", "execution", "router"):
            value = updates.get(key)
            if value is None:
                continue
            if isinstance(value, dict):
                existing = merged.get(key, {})
                next_dict = existing.copy() if isinstance(existing, dict) else {}
                next_dict.update(value)
                merged[key] = next_dict
            else:
                merged[key] = value
        state["decision_policy"] = _normalize_decision_policy(merged)
        scheduler = _sync_runtime_scheduler_to_effective(state)
        _save_strategy_market_state(state)
    update_decision_policy_contract(state["decision_policy"], source="gui_backend")
    return {
        "success": True,
        "decision_policy": state["decision_policy"],
        "scheduler": scheduler,
        "message": "多 agent 与多策略协同配置已更新",
    }


@app.post("/api/strategy/instance/action")
def action_strategy_instance(req: StrategyInstanceActionRequest):
    instance_id = str(req.instance_id or "").strip()
    action = str(req.action or "").strip().lower()
    if not instance_id:
        return {"success": False, "error": "缺少实例 ID"}
    if action not in {
        "pause",
        "resume",
        "remove",
        "allocation",
        "role",
        "overrides",
        "clear_overrides",
        "restore_defaults",
    }:
        return {"success": False, "error": f"不支持的动作: {action}"}
    with _strategy_market_state_transaction():
        state = _load_strategy_market_state()
        instances = state.get("runtime_instances", []) if isinstance(state.get("runtime_instances", []), list) else []
        strategies = state.get("strategies", {}) if isinstance(state.get("strategies", {}), dict) else {}
        now_iso = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
        target_index = -1
        for idx, item in enumerate(instances):
            if isinstance(item, dict) and str(item.get("instance_id", "")).strip() == instance_id:
                target_index = idx
                break
        if target_index < 0:
            return {"success": False, "error": f"未找到实例: {instance_id}"}
        target = instances[target_index]
        if action == "pause":
            target["status"] = "paused"
        elif action == "resume":
            target["status"] = "running"
        elif action == "remove":
            instances.pop(target_index)
        elif action == "allocation":
            target["allocation"] = _clamp_allocation(req.allocation)
        elif action == "role":
            target["role"] = str(req.role or target.get("role", "live_support") or "live_support")
        elif action == "overrides":
            current_overrides = target.get("param_overrides", {})
            merged_overrides = current_overrides if isinstance(current_overrides, dict) else {}
            merged_overrides.update(req.param_overrides if isinstance(req.param_overrides, dict) else {})
            target["param_overrides"] = merged_overrides
        elif action == "clear_overrides":
            target["param_overrides"] = {}
        elif action == "restore_defaults":
            strategy_id = str(target.get("strategy_id", "") or "").strip()
            strategy_row = strategies.get(strategy_id, {}) if strategy_id else {}
            template = strategy_row.get("template", {}) if isinstance(strategy_row.get("template", {}), dict) else {}
            target["param_overrides"] = {}
            if template:
                target["template_snapshot"] = template
        if action != "remove":
            target["updated_at"] = now_iso
            instances[target_index] = target
        state["runtime_instances"] = instances
        scheduler = _sync_runtime_scheduler_to_effective(state)
        _save_strategy_market_state(state)
    return {
        "success": True,
        "message": f"实例操作已执行: {action}",
        "instance_id": instance_id,
        "scheduler": scheduler,
        "runtime_instances": instances,
    }


@app.post("/api/strategy/publish")
def publish_strategy(req: StrategyPublishRequest):
    strategy_id = str(req.strategy_id or "").strip()
    with _strategy_market_state_transaction():
        state = _load_strategy_market_state()
        catalog = _strategy_catalog_from_state(state)
        if strategy_id not in catalog:
            return {"success": False, "error": f"未知策略: {strategy_id}"}
        row = state["strategies"].get(strategy_id, {})
        versions = row.get("versions", []) if isinstance(row.get("versions", []), list) else []
        current_version = str(row.get("current_version", "1.0.0") or "1.0.0")
        next_version = _next_patch_version(current_version)
        versions.append(
            {
                "version": next_version,
                "note": str(req.note or "manual publish"),
                "published_at": datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
            }
        )
        row["versions"] = versions
        row["current_version"] = next_version
        state["strategies"][strategy_id] = row
        _save_strategy_market_state(state)
    _record_system_message(
        kind="publish",
        title="策略发布完成",
        desc=f"`{strategy_id}` 已发布到版本 `{next_version}`",
        color="success",
        source="strategy_marketplace",
        metadata={
            "strategy_id": strategy_id,
            "version": next_version,
            "note": str(req.note or "manual publish"),
        },
    )
    return {
        "success": True,
        "message": f"策略已发布新版本 {next_version}",
        "strategy_id": strategy_id,
        "version": next_version,
        "versions_count": len(versions),
    }


@app.post("/api/strategy/rollback")
def rollback_strategy(req: StrategyRollbackRequest):
    strategy_id = str(req.strategy_id or "").strip()
    with _strategy_market_state_transaction():
        state = _load_strategy_market_state()
        catalog = _strategy_catalog_from_state(state)
        if strategy_id not in catalog:
            return {"success": False, "error": f"未知策略: {strategy_id}"}
        row = state["strategies"].get(strategy_id, {})
        versions = row.get("versions", []) if isinstance(row.get("versions", []), list) else []
        if len(versions) <= 1:
            return {"success": False, "error": "没有可回滚的历史版本"}
        target_version = str(req.target_version or "").strip()
        if target_version:
            exists = [v for v in versions if isinstance(v, dict) and str(v.get("version", "")) == target_version]
            if not exists:
                return {"success": False, "error": f"未找到目标版本: {target_version}"}
            row["current_version"] = target_version
        else:
            row["current_version"] = str(versions[-2].get("version", "1.0.0"))
        state["strategies"][strategy_id] = row
        _save_strategy_market_state(state)
    _record_system_message(
        kind="rollback",
        title="策略版本已回滚",
        desc=f"`{strategy_id}` 已回滚到 `{row['current_version']}`",
        color="warning",
        source="strategy_marketplace",
        metadata={
            "strategy_id": strategy_id,
            "version": row["current_version"],
            "target_version": target_version,
        },
    )
    return {
        "success": True,
        "message": f"策略已回滚到 {row['current_version']}",
        "strategy_id": strategy_id,
        "version": row["current_version"],
    }


@app.post("/api/strategy/template")
def upsert_strategy_template(req: StrategyTemplateRequest):
    strategy_id = str(req.strategy_id or "").strip()
    with _strategy_market_state_transaction():
        state = _load_strategy_market_state()
        catalog = _strategy_catalog_from_state(state)
        if strategy_id not in catalog:
            return {"success": False, "error": f"未知策略: {strategy_id}"}
        template = _normalize_strategy_template_payload(req.template)
        if not template:
            return {"success": False, "error": "模板不能为空"}
        validation_error = _validate_strategy_template_payload(template)
        if validation_error:
            return {"success": False, "error": validation_error}
        row = state["strategies"].get(strategy_id, {})
        runtime_instances = state.get("runtime_instances", []) if isinstance(state.get("runtime_instances", []), list) else []
        running_instance_count = sum(
            1
            for item in runtime_instances
            if isinstance(item, dict)
            and str(item.get("strategy_id", "")).strip() == strategy_id
            and str(item.get("status", "running")).strip().lower() == "running"
        )
        refreshed_instance_ids: list[str] = []
        refreshed_instances: list[dict[str, Any]] = []
        now_iso = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
        for item in runtime_instances:
            if not isinstance(item, dict):
                refreshed_instances.append(item)
                continue
            if str(item.get("strategy_id", "")).strip() != strategy_id:
                refreshed_instances.append(item)
                continue
            updated_item = dict(item)
            updated_item["template_snapshot"] = template
            updated_item["updated_at"] = now_iso
            refreshed_instances.append(updated_item)
            refreshed_instance_ids.append(str(updated_item.get("instance_id", "") or ""))
        row["template"] = template
        state["strategies"][strategy_id] = row
        state["runtime_instances"] = refreshed_instances
        _save_strategy_market_state(state)
    _record_system_message(
        kind="template_hot_update" if running_instance_count > 0 else "template",
        title="参数模板已更新",
        desc=(
            f"`{strategy_id}` 模板已保存"
            + (f"，并对 {running_instance_count} 个运行实例完成热更新登记" if running_instance_count > 0 else "")
        ),
        color="primary" if running_instance_count > 0 else "success",
        source="strategy_marketplace",
        metadata={
            "strategy_id": strategy_id,
            "template_keys": sorted(template.keys()),
            "running_instance_count": running_instance_count,
            "refreshed_instance_ids": [item for item in refreshed_instance_ids if item],
        },
    )
    return {
        "success": True,
        "message": "参数模板已保存",
        "strategy_id": strategy_id,
        "template": template,
        "refreshed_instance_ids": [item for item in refreshed_instance_ids if item],
    }


@app.post("/api/strategy/composite/create")
def create_strategy_composite(req: StrategyCompositeCreateRequest):
    name = str(req.name or "").strip()
    if len(name) < 2:
        return {"success": False, "error": "组合名称至少 2 个字符"}
    components = [
        str(item).strip()
        for item in (req.components or [])
        if str(item).strip()
    ]
    dedup_components = list(dict.fromkeys(components))
    if len(dedup_components) < 2:
        return {"success": False, "error": "请至少选择 2 个策略组件"}
    state = _load_strategy_market_state()
    catalog = _strategy_catalog_from_state(state)
    existed_names = {
        str((row.get("display_name") if isinstance(row, dict) else "") or "").strip().lower()
        for row in (state.get("strategies", {}) if isinstance(state.get("strategies", {}), dict) else {}).values()
    }
    if name.lower() in existed_names:
        return {"success": False, "error": "组合名称已存在，请使用唯一名称"}
    unknown = [item for item in dedup_components if item not in catalog]
    if unknown:
        return {"success": False, "error": f"存在未知组件: {', '.join(unknown)}"}
    slug_raw = "".join(ch.lower() if ch.isalnum() else "_" for ch in name).strip("_")
    slug = slug_raw[:28] if slug_raw else str(int(time.time()))
    strategy_id = f"combo_{slug}"
    if strategy_id in state.get("strategies", {}):
        strategy_id = f"{strategy_id}_{int(time.time())}"
    now_iso = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
    row = {
        "display_name": name,
        "enabled": True,
        "is_composite": True,
        "components": dedup_components,
        "base_engine": "composite",
        "template": {
            **_default_strategy_template(strategy_id),
            "components": dedup_components,
            "weights": {item: round(1.0 / len(dedup_components), 4) for item in dedup_components},
        },
        "versions": [
            {
                "version": "1.0.0",
                "note": "composite created",
                "published_at": now_iso,
            }
        ],
        "current_version": "1.0.0",
        "created_at": now_iso,
    }
    state["strategies"][strategy_id] = row
    _save_strategy_market_state(state)
    return {
        "success": True,
        "message": f"组合策略已创建: {name}",
        "strategy_id": strategy_id,
        "components": dedup_components,
        "version": "1.0.0",
    }


@app.get("/api/strategy/custom/state")
def get_strategy_custom_state():
    payload = _load_strategy_custom_state()
    return {"success": True, **payload}


@app.post("/api/strategy/custom/save")
def save_strategy_custom_draft(req: StrategyDraftSaveRequest):
    payload = _load_strategy_custom_state()
    now_iso = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
    payload["draft"] = {
        "name": str(req.name or ""),
        "document": str(req.document or ""),
        "optimized": req.optimized if isinstance(req.optimized, dict) else {},
        "evaluation": req.evaluation if isinstance(req.evaluation, dict) else {},
        "saveState": "saved",
        "lastSavedAt": now_iso,
    }
    _save_strategy_custom_state(payload)
    return {"success": True, "saveState": "saved", "lastSavedAt": now_iso}


@app.post("/api/strategy/custom/optimize")
def optimize_strategy_custom_draft(req: StrategyDraftOptimizeRequest):
    document = str(req.document or "").strip()
    if len(document) < 12:
        return {"success": False, "error": "策略文档过短，请补充市场假设、入场、出场和风控约束"}
    base_engine = _infer_base_engine_from_doc(document)
    optimized = {
        "strategy_name": str(req.name or "").strip(),
        "market_hypothesis": document[:180],
        "entry_rule": "signal_confirmed_and_spread_safe",
        "exit_rule": "exit_optimizer_or_sl_tp",
        "risk_constraints": {
            "max_position_size": 0.1,
            "max_open_positions": 1,
            "min_confidence": 70,
        },
        "base_engine": base_engine,
        "params": {
            "timeframe": "M5",
            "risk_multiplier": 1.0,
            "sl_multiplier": 1.0,
            "tp_multiplier": 1.0,
        },
    }
    return {"success": True, "optimized": optimized}


@app.post("/api/strategy/custom/evaluate")
def evaluate_strategy_custom_draft(req: StrategyDraftEvaluateRequest):
    document = str(req.document or "").strip()
    optimized = req.optimized if isinstance(req.optimized, dict) else {}
    if not document:
        return {"success": False, "error": "请先填写策略文档"}
    completeness = min(100, max(40, len(document) // 6))
    has_risk = any(token in document.lower() for token in ["风控", "止损", "风险", "loss", "risk"])
    risk_exposure = "medium"
    if has_risk and completeness >= 70:
        risk_exposure = "normal"
    if completeness < 55:
        risk_exposure = "high"
    evaluation = {
        "score": {
            "executability": int(min(95, completeness + (10 if bool(optimized) else 0))),
            "robustness": int(min(90, completeness)),
            "risk_control": 78 if has_risk else 52,
        },
        "riskExposure": risk_exposure,
        "verdict": "pass" if has_risk and completeness >= 60 else "needs_improve",
        "advice": (
            "结构可执行，建议进入生成闸门"
            if has_risk and completeness >= 60
            else "建议补充风控阈值与出场边界后再生成策略"
        ),
    }
    return {"success": True, "evaluation": evaluation}


@app.post("/api/strategy/custom/generate")
def generate_strategy_from_custom_draft(req: StrategyDraftGenerateRequest):
    name = str(req.name or "").strip()
    document = str(req.document or "").strip()
    if len(name) < 2:
        return {"success": False, "error": "策略名称至少 2 个字符"}
    if len(document) < 12:
        return {"success": False, "error": "策略文档过短，无法生成策略"}
    optimized = req.optimized if isinstance(req.optimized, dict) else {}
    evaluation = req.evaluation if isinstance(req.evaluation, dict) else {}
    if not optimized:
        optimized = optimize_strategy_custom_draft(
            StrategyDraftOptimizeRequest(name=name, document=document)
        ).get("optimized", {})
    if not evaluation:
        evaluation = evaluate_strategy_custom_draft(
            StrategyDraftEvaluateRequest(name=name, document=document, optimized=optimized)
        ).get("evaluation", {})
    verdict = str(evaluation.get("verdict", "")).strip().lower()
    if verdict != "pass":
        return {"success": False, "error": "评估未通过，请先补充风控约束并通过 AI 评估"}
    state = _load_strategy_market_state()
    existed_names = {
        str((row.get("display_name") if isinstance(row, dict) else "") or "").strip().lower()
        for row in (state.get("strategies", {}) if isinstance(state.get("strategies", {}), dict) else {}).values()
    }
    if name.lower() in existed_names:
        return {"success": False, "error": "同名策略已存在，请更换策略名称"}
    slug_raw = "".join(ch.lower() if ch.isalnum() else "_" for ch in name).strip("_")
    strategy_id = f"custom_{(slug_raw[:28] if slug_raw else int(time.time()))}"
    if strategy_id in state.get("strategies", {}):
        strategy_id = f"{strategy_id}_{int(time.time())}"
    now_iso = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
    base_engine = str(optimized.get("base_engine") or _infer_base_engine_from_doc(document))
    template_params = (
        optimized.get("params", {})
        if isinstance(optimized.get("params", {}), dict)
        else {}
    )
    state["strategies"][strategy_id] = {
        "display_name": name,
        "enabled": True,
        "is_custom": True,
        "base_engine": base_engine if base_engine in VALID_STRATEGIES else "ema_macd",
        "source_document": document,
        "optimized": optimized,
        "evaluation": evaluation,
        "template": {
            **_default_strategy_template(strategy_id),
            **template_params,
        },
        "versions": [
            {
                "version": "1.0.0",
                "note": "generated_from_strategy_custom",
                "published_at": now_iso,
            }
        ],
        "current_version": "1.0.0",
        "created_at": now_iso,
    }
    _save_strategy_market_state(state)

    custom_state = _load_strategy_custom_state()
    history = custom_state.get("generatedHistory", [])
    if not isinstance(history, list):
        history = []
    history.insert(
        0,
        {
            "strategy_id": strategy_id,
            "name": name,
            "created_at": now_iso,
            "base_engine": base_engine,
        },
    )
    custom_state["generatedHistory"] = history[:30]
    custom_state["draft"] = {
        "name": name,
        "document": document,
        "optimized": optimized,
        "evaluation": evaluation,
        "saveState": "saved",
        "lastSavedAt": now_iso,
    }
    _save_strategy_custom_state(custom_state)
    return {
        "success": True,
        "message": f"策略已生成并入库: {name}",
        "strategy_id": strategy_id,
        "base_engine": base_engine,
        "version": "1.0.0",
    }


def _read_allowed_models() -> List[str]:
    config_path = resolve_openclaw_config_path() or (config_manager.get_llm_config_dir() / "openclaw.json")
    if not config_path.exists():
        return []
    try:
        config_data = json.loads(config_path.read_text())
    except Exception:
        return []
    providers = config_data.get("models", {}).get("providers", {})
    model_ids: List[str] = []
    seen: Set[str] = set()
    for provider_name, provider_info in providers.items():
        if not isinstance(provider_info, dict):
            continue
        provider_models = provider_info.get("models", [])
        if not isinstance(provider_models, list):
            continue
        for model in provider_models:
            raw_id = ""
            if isinstance(model, dict):
                raw_id = str(model.get("id", "")).strip()
            elif isinstance(model, str):
                raw_id = model.strip()
            if not raw_id:
                continue
            model_id = raw_id if "/" in raw_id else f"{provider_name}/{raw_id}"
            if model_id in seen:
                continue
            seen.add(model_id)
            model_ids.append(model_id)
    return model_ids


def _normalize_model_id(model_value: str, allowed_models: List[str]) -> str:
    value = (model_value or "").strip()
    if not value or not allowed_models:
        return value
    if value in allowed_models:
        return value
    if "/" in value:
        suffix = value.split("/", 1)[1].lower()
        for allowed in allowed_models:
            if allowed.split("/", 1)[1].lower() == suffix:
                return allowed
        return value
    lower_value = value.lower()
    suffix_matches = [m for m in allowed_models if m.split("/", 1)[1].lower() == lower_value]
    if len(suffix_matches) == 1:
        return suffix_matches[0]
    return value


def _default_agents(available_models: List[str]) -> List[Dict[str, str]]:
    if available_models:
        return [
            {"role": "趋势分析师", "model": available_models[0], "status": "ready"},
            {
                "role": "风险管理员",
                "model": available_models[1] if len(available_models) > 1 else available_models[0],
                "status": "ready",
            },
            {
                "role": "M2 决策主脑",
                "model": available_models[2] if len(available_models) > 2 else available_models[0],
                "status": "ready",
            },
            {"role": "本地巡查(兜底)", "model": available_models[-1], "status": "ready"},
        ]
    return [
        {"role": "趋势分析师", "model": "deepseek/deepseek-v4-pro", "status": "ready"},
        {"role": "风险管理员", "model": "minimax/MiniMax-M2.7", "status": "ready"},
        {"role": "M2 决策主脑", "model": "deepseek/deepseek-v4-flash", "status": "ready"},
        {"role": "本地巡查(兜底)", "model": "omlx/Qwen3.5-4B-MLX-4bit", "status": "ready"},
    ]


@app.get("/api/agent_models")
def get_agent_models():
    local_config_file = CONFIG_DIR / "agent_models.json"
    local_agents = []
    if local_config_file.exists():
        try:
            local_agents = json.loads(local_config_file.read_text())
        except Exception:
            local_agents = []

    available_models = _read_allowed_models()
    default_agents = _default_agents(available_models)

    if local_agents and isinstance(local_agents, list):
        local_map = {}
        for agent in local_agents:
            if not isinstance(agent, dict):
                continue
            role = agent.get("role")
            if not role:
                continue
            patched_agent = dict(agent)
            patched_agent["model"] = _normalize_model_id(
                str(patched_agent.get("model", "")),
                available_models,
            )
            local_map[role] = patched_agent
        for agent in default_agents:
            if agent["role"] in local_map:
                agent.update(local_map[agent["role"]])
        return {"success": True, "agents": default_agents, "available_models": available_models}

    return {"success": True, "agents": default_agents, "available_models": available_models}

class AgentModelConfig(BaseModel):
    role: str
    model: str
    status: str

@app.post("/api/agent_models")
def save_agent_models(agents: List[AgentModelConfig]):
    config_file = CONFIG_DIR / "agent_models.json"
    allowed_models = _read_allowed_models()

    try:
        data_to_save = []
        for agent in agents:
            item = agent.model_dump()
            normalized_model = _normalize_model_id(item.get("model", ""), allowed_models)
            if allowed_models and normalized_model not in allowed_models:
                return {
                    "success": False,
                    "error": f"模型不在系统允许列表中: {item.get('model', '')}",
                }
            item["model"] = normalized_model
            data_to_save.append(item)
        config_file.write_text(json.dumps(data_to_save, indent=2))
        return {"success": True, "message": "模型配置已保存"}
    except Exception as e:
        return {"success": False, "error": str(e)}

def _build_training_status_payload():
    _kick_training_autopilot()
    best_file = _resolve_runtime_artifact("data", "offline", "best_params.json") or (DATA_DIR / "offline" / "best_params.json")
    status_file = _resolve_runtime_artifact("data", "offline", "training_status.json") or (
        DATA_DIR / "offline" / "training_status.json"
    )
    quality_snapshot_file = _resolve_runtime_artifact("data", "offline", "training_quality_snapshot.json") or (
        DATA_DIR / "offline" / "training_quality_snapshot.json"
    )
    effective_path = _resolve_runtime_artifact("logs", "effective_params.json") or (
        pathlib.Path(__file__).resolve().parent / "logs" / "effective_params.json"
    )
    data = _read_training_status_payload()
    data["pnlHistory"] = _compact_training_pnl_history(data.get("pnlHistory", []))

    if not data.get("isTraining", False) and best_file.exists():
        try:
            data["pnlHistory"] = _load_best_params_pnl_history(best_file)
        except Exception:
            pass

    quality_gate = _read_training_quality_snapshot(force_refresh=False)
    if quality_gate:
        data["qualityGate"] = quality_gate
    data["readyForTraining"] = bool(quality_gate.get("ready_for_training", False))
    data["blockedReasons"] = quality_gate.get("blocked_reasons", [])
    data["ledgerFallback"] = bool(quality_gate.get("ledger_fallback", False))
    data["closureCoveragePct"] = quality_gate.get("closure_coverage_pct", 0.0)
    data["criticalAnomalies"] = quality_gate.get("critical_anomalies", 0)
    data["recoveredClosedRatioPct"] = quality_gate.get("recovered_closed_ratio_pct", 0.0)
    data["usableTradeCount"] = int(quality_gate.get("usable_trade_count", data.get("usableTradeCount", 0)) or 0)
    data["excludedIncompleteCount"] = int(
        quality_gate.get("excluded_incomplete_count", data.get("excludedIncompleteCount", 0)) or 0
    )
    data["excludedRecoveredCount"] = int(
        quality_gate.get("excluded_recovered_count", data.get("excludedRecoveredCount", 0)) or 0
    )
    data["samplePolicy"] = quality_gate.get("sample_policy", data.get("samplePolicy", {}))
    data["qualitySnapshotId"] = str(quality_gate.get("snapshot_id", data.get("qualitySnapshotId", "")) or "")
    data["qualitySnapshotAt"] = str(quality_gate.get("generated_at", data.get("qualitySnapshotAt", "")) or "")
    recovery = quality_gate.get("recovery", {}) if isinstance(quality_gate.get("recovery"), dict) else {}
    data["naturalClosedRatioPct"] = recovery.get("natural_closed_ratio_pct", 0.0)
    data["naturalClosedTransitions"] = recovery.get("natural_closed_transitions", 0)
    data["recoveredClosedTransitions"] = recovery.get("recovered_closed_transitions", 0)
    data["repairCycle"] = quality_gate.get("repair_cycle", {})
    data["activeModel"] = _normalize_analysis_model(str(data.get("activeModel", "")))
    data["analysisModel"] = _normalize_analysis_model(str(data.get("analysisModel", data["activeModel"])))
    data["activeDataset"] = _normalize_training_dataset(str(data.get("activeDataset", "")))

    try:
        effective_payload = json.loads(effective_path.read_text(encoding="utf-8")) if effective_path.exists() else {}
        has_effective_params = bool(effective_payload.get("offline_params")) or bool(effective_payload.get("suggestions"))
        candidate_value = _read_training_candidate_value()
        review_state = build_training_review_state(effective_payload, candidate_value)
        data["approvalSummary"] = {
            **public_training_review_state(review_state),
            "hasEffectiveParams": has_effective_params or bool(review_state.get("hasEffectiveParams", False)),
        }
    except Exception:
        data["approvalSummary"] = data.get("approvalSummary", {})
    data["autoPilotEnabled"] = bool(data.get("autoPilotEnabled", False))
    data["errorDispatch"] = _read_training_error_dispatch_state()
    data["autoPilotStage"] = str(data.get("autoPilotStage", "idle") or "idle")
    data["autoPilotMessage"] = str(data.get("autoPilotMessage", "自动推进未开启") or "自动推进未开启")
    data["autoPilotLastError"] = str(data.get("autoPilotLastError", "") or "")
    data["trainingEngine"] = {
        **TRAINING_RUNTIME_CONFIG,
        **(data.get("trainingEngine", {}) if isinstance(data.get("trainingEngine"), dict) else {}),
    }

    training_status_updated_ts = 0.0
    try:
        if status_file.exists():
            training_status_updated_ts = float(status_file.stat().st_mtime)
    except Exception:
        training_status_updated_ts = 0.0
    live_metrics = _read_live_training_metrics()
    live_updated_ts = float(live_metrics.get("updatedTs", 0.0) or 0.0)
    status_stale = (
        not bool(data.get("isTraining", False))
        and (
            training_status_updated_ts <= 0
            or (time.time() - training_status_updated_ts) > 900
            or (
                float(data.get("winRate", 0.0) or 0.0) == 0.0
                and int(data.get("totalTrades", 0) or 0) == 0
            )
        )
    )
    if live_updated_ts > 0 and (status_stale or live_updated_ts > training_status_updated_ts):
        if int(live_metrics.get("totalTrades", 0) or 0) > 0:
            data["totalTrades"] = int(live_metrics.get("totalTrades", 0) or 0)
        if float(live_metrics.get("winRate", 0.0) or 0.0) > 0:
            data["winRate"] = float(live_metrics.get("winRate", 0.0) or 0.0)
        if float(live_metrics.get("drawdown", 0.0) or 0.0) > 0:
            data["drawdown"] = float(live_metrics.get("drawdown", 0.0) or 0.0)
        data["shadowClosedTrades"] = int(live_metrics.get("shadowClosedTrades", 0) or 0)
        data["shadowWinRatePct"] = float(live_metrics.get("shadowWinRatePct", 0.0) or 0.0)
        data["shadowMaxDrawdownAbs"] = float(live_metrics.get("shadowMaxDrawdownAbs", 0.0) or 0.0)
        data["learnerLiveSelections"] = int(live_metrics.get("learnerLiveSelections", 0) or 0)
        data["baselineSelections"] = int(live_metrics.get("baselineSelections", 0) or 0)
        data["metricsSource"] = str(live_metrics.get("source", "auto_governance") or "auto_governance")
        data["metricsUpdatedAt"] = str(live_metrics.get("updatedAt", "") or "")
        data["metricsStale"] = False
    else:
        data["metricsSource"] = "training_status"
        data["metricsUpdatedAt"] = data.get("updatedAt", "")
        data["metricsStale"] = bool(status_stale)

    return _attach_projection_metadata(
        data,
        snapshot_name="training_status",
        source_paths={
            "training_status": status_file,
            "best_params": best_file,
            "training_quality_snapshot": quality_snapshot_file,
            "effective_params": effective_path,
        },
    )


@app.get("/api/training_status")
def get_training_status(force_refresh: bool = False):
    warming_payload = _training_status_defaults()
    warming_payload.update(
        {
            "success": True,
            "pnlHistory": [],
            "qualityGate": {},
            "repairCycle": {},
            "approvalSummary": {},
        }
    )
    return _get_cached_aux_payload(
        _TRAINING_STATUS_RESPONSE_CACHE,
        _TRAINING_STATUS_CACHE_TTL_SEC,
        _build_training_status_payload,
        "gui-training-status-refresh",
        warming_payload,
        force_refresh=force_refresh,
    )


@app.get("/api/training/datasets")
def get_training_datasets():
    presets = _get_training_dataset_presets()
    return {
        "success": True,
        "datasets": presets,
        "defaultDataset": _default_training_dataset_label(),
    }

@app.post("/api/training/start")
def start_training(req: Optional[TrainingStartRequest] = None):
    _kick_training_autopilot()
    if TRAINING_PID_FILE.exists():
        try:
            pid = int(TRAINING_PID_FILE.read_text().strip())
            os.kill(pid, 0)
            return {"success": False, "message": "Training already running"}
        except OSError:
            pass # Process not running

    try:
        from src.offline_train import evaluate_training_readiness, write_training_blocked_status

        gate = evaluate_training_readiness()
        if not gate.get("ready_for_training", False):
            status_file = write_training_blocked_status(DATA_DIR / "offline", gate)
            return {
                "success": False,
                "message": "Training blocked by quality gate",
                "blockedReasons": gate.get("blocked_reasons", []),
                "qualityGate": gate,
                "statusFile": str(status_file),
            }
    except Exception as e:
        return {"success": False, "message": f"Training gate check failed: {e}"}

    previous_status = _read_training_status_payload()
    selected_model = _normalize_analysis_model(req.model if req and req.model else previous_status.get("analysisModel", DEFAULT_ANALYSIS_MODEL))
    dataset_config = _resolve_training_dataset(
        req.dataset if req and req.dataset else previous_status.get("activeDataset", _default_training_dataset_label())
    )
    selected_dataset = str(dataset_config.get("label", _default_training_dataset_label()))
    selected_symbol = str(dataset_config.get("symbol", TRAINING_RUNTIME_CONFIG["symbol"]))
    selected_days = int(dataset_config.get("lookbackDays", TRAINING_RUNTIME_CONFIG["lookbackDays"]))
    selected_interval = str(dataset_config.get("interval", TRAINING_RUNTIME_CONFIG["interval"]))
    if getattr(sys, 'frozen', False):
        cmd = [
            sys.executable,
            "--auto",
            "--train",
            "--days",
            str(selected_days),
            "--interval",
            selected_interval,
            "--symbol",
            selected_symbol,
            "--mode",
            "train",
        ]
    else:
        cmd = [
            "python3",
            str(_SRC_DIR / "offline_train.py"),
            "--days",
            str(selected_days),
            "--interval",
            selected_interval,
            "--symbol",
            selected_symbol,
            "--mode",
            "train",
        ]
    proc = subprocess.Popen(cmd)
    TRAINING_PID_FILE.parent.mkdir(parents=True, exist_ok=True)
    TRAINING_PID_FILE.write_text(str(proc.pid))

    _write_training_status_payload({
        **previous_status,
        "isTraining": True,
        "progress": 0,
        "currentBar": 0,
        "totalBars": 0,
        "currentEpoch": 0,
        "totalEpochs": 81,
        "winRate": 0.0,
        "pnl": 0.0,
        "totalTrades": 0,
        "activeDataset": selected_dataset,
        "activeModel": selected_model,
        "analysisModel": selected_model,
        "qualityGate": gate,
        "readyForTraining": gate.get("ready_for_training", False),
        "blockedReasons": gate.get("blocked_reasons", []),
        "qualitySnapshotId": gate.get("snapshot_id", ""),
        "qualitySnapshotAt": gate.get("generated_at", ""),
        "trainingEngine": {
            **TRAINING_RUNTIME_CONFIG,
            "symbol": selected_symbol,
            "lookbackDays": selected_days,
            "interval": selected_interval,
            "datasetId": dataset_config.get("id", "custom"),
        },
        "autoPilotEnabled": True,
        "autoPilotStage": "training_running",
        "autoPilotMessage": "自动推进已开启：训练完成后会自动生成建议、自动修复阻断项，并自动推进到审批闸门前",
        "autoPilotLastActionAt": datetime.datetime.now().isoformat(),
        "autoPilotLastError": "",
        "updatedAt": datetime.datetime.now().isoformat(),
    })

    return {
        "success": True,
        "message": "Training started",
        "pid": proc.pid,
        "analysisModel": selected_model,
        "activeDataset": selected_dataset,
    }

@app.post("/api/training/stop")
def stop_training():
    if TRAINING_PID_FILE.exists():
        try:
            pid = int(TRAINING_PID_FILE.read_text().strip())
            os.kill(pid, signal.SIGTERM)
            TRAINING_PID_FILE.unlink()

            status_data = _read_training_status_payload()
            status_data["isTraining"] = False
            status_data["autoPilotEnabled"] = False
            status_data["autoPilotStage"] = "stopped"
            status_data["autoPilotMessage"] = "训练已手动停止，自动推进同步停止"
            status_data["autoPilotLastActionAt"] = datetime.datetime.now().isoformat()
            status_data["updatedAt"] = datetime.datetime.now().isoformat()
            _write_training_status_payload(status_data)

            return {"success": True, "message": "Training stopped"}
        except Exception as e:
            return {"success": False, "error": str(e)}
    return {"success": True, "message": "Training not running"}

@app.post("/api/training/analyze")
def analyze_training(req: Optional[TrainingAnalyzeRequest] = None):
    """调用大模型分析最新的训练结果并给出报告"""
    try:
        selected_model = _normalize_analysis_model(req.model if req and req.model else DEFAULT_ANALYSIS_MODEL)
        if getattr(sys, 'frozen', False):
            cmd = [sys.executable, "--auto", "--analyze"]
        else:
            cmd = ["python3", str(_SRC_DIR / "strategy_evolution.py"), "--analyze-only"]
        cmd.extend(["--model", selected_model])

        proc = subprocess.run(cmd, capture_output=True, text=True)
        if proc.returncode == 0:
            content = proc.stdout
            if "LLM 分析报告:" in content:
                content = content.split("LLM 分析报告:")[1].strip()

            return {
                "success": True,
                "model": selected_model,
                "report": content,
                "timestamp": __import__('datetime').datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            }
        else:
            return {
                "success": False,
                "model": selected_model,
                "error": proc.stderr.strip() or "暂无足够的数据供模型分析,请先运行回测训练。",
                "report": proc.stdout.strip(),
            }
    except Exception as e:
        return {"success": False, "error": str(e)}


@app.post("/api/training/evolve")
def evolve_training_candidate(req: Optional[TrainingEvolveRequest] = None):
    try:
        selected_model = _normalize_analysis_model(req.model if req and req.model else DEFAULT_ANALYSIS_MODEL)
        if getattr(sys, 'frozen', False):
            cmd = [sys.executable, "--auto", "--model", selected_model]
        else:
            cmd = ["python3", str(_SRC_DIR / "strategy_evolution.py"), "--model", selected_model]
        proc = subprocess.run(cmd, capture_output=True, text=True)
        if proc.returncode != 0:
            return {
                "success": False,
                "model": selected_model,
                "error": proc.stderr.strip() or "策略进化执行失败",
                "report": proc.stdout.strip(),
            }
        approval = get_training_approval_chain()
        governance = get_training_governance()
        return {
            "success": True,
            "model": selected_model,
            "message": "训练进化建议已生成",
            "report": proc.stdout.strip(),
            "approvalChainStage": approval.get("stage"),
            "governanceSummary": governance.get("summary", {}),
        }
    except Exception as e:
        return {"success": False, "error": str(e)}


def _build_training_governance_payload():
    report_file = _resolve_existing_file(_runtime_log_candidates("evolution_report_latest.json"))
    llm_report_file = _resolve_existing_file(_runtime_log_candidates("evolution_llm_report_latest.txt"))
    learner_report_file = _resolve_runtime_artifact("data", "offline", "param_learner_report_latest.json") or (
        DATA_DIR / "offline" / "param_learner_report_latest.json"
    )
    data = {
        "success": True,
        "qualityGate": {},
        "governanceContext": {},
        "governanceAdvice": "",
        "paramLearner": {},
    }

    current_quality_gate = _read_training_quality_snapshot(force_refresh=False)
    try:
        from src.hermes_bridge import load_system_design_contract
        data["systemDesignContract"] = load_system_design_contract()
    except Exception:
        data["systemDesignContract"] = {}

    if report_file is not None:
        try:
            report = json.loads(report_file.read_text(encoding="utf-8"))
            data["qualityGate"] = current_quality_gate or report.get("quality_context", report.get("quality_gate", {}))
            data["governanceContext"] = report.get("governance_context", {})
        except Exception:
            pass
    elif current_quality_gate:
        data["qualityGate"] = current_quality_gate

    if llm_report_file is not None:
        try:
            data["governanceAdvice"] = llm_report_file.read_text(encoding="utf-8")
        except Exception:
            pass

    if learner_report_file.exists():
        try:
            data["paramLearner"] = json.loads(learner_report_file.read_text(encoding="utf-8"))
        except Exception:
            pass

    recovery = data["qualityGate"].get("recovery", {}) if isinstance(data["qualityGate"], dict) else {}
    data["metrics"] = {
        "readyForTraining": data["qualityGate"].get("ready_for_training", False) if isinstance(data["qualityGate"], dict) else False,
        "blockedReasons": data["qualityGate"].get("blocked_reasons", []) if isinstance(data["qualityGate"], dict) else [],
        "closureCoveragePct": data["qualityGate"].get("closure_coverage_pct", 0.0) if isinstance(data["qualityGate"], dict) else 0.0,
        "criticalAnomalies": data["qualityGate"].get("critical_anomalies", 0) if isinstance(data["qualityGate"], dict) else 0,
        "recoveredClosedRatioPct": data["qualityGate"].get("recovered_closed_ratio_pct", 0.0) if isinstance(data["qualityGate"], dict) else 0.0,
        "naturalClosedRatioPct": recovery.get("natural_closed_ratio_pct", 0.0),
        "paramLearnerReady": bool(isinstance(data["paramLearner"], dict) and data["paramLearner"].get("status") == "READY"),
    }

    return data


@app.get("/api/training/governance")
def get_training_governance(force_refresh: bool = False):
    warming_payload = {
        "success": True,
        "qualityGate": {},
        "governanceContext": {},
        "governanceAdvice": "",
        "paramLearner": {},
        "metrics": {},
    }
    return _get_cached_aux_payload(
        _TRAINING_GOVERNANCE_CACHE,
        _TRAINING_GOVERNANCE_CACHE_TTL_SEC,
        _build_training_governance_payload,
        "gui-training-governance-refresh",
        warming_payload,
        force_refresh=force_refresh,
    )


def _build_training_approval_chain_payload():
    _kick_training_autopilot()
    src_path = str(pathlib.Path(__file__).resolve().parent / "src")
    if src_path not in sys.path:
        sys.path.insert(0, src_path)
    from src.metrics_agg import aggregate_exec_stats
    from src.param_flow import get_effective_rollback_state
    from src.rollout_manager import get_promotion_mode, evaluate_promotion_gates

    config_dir = CONFIG_DIR
    suggestion_file, suggestion_payload = _read_json_from_candidates(
        _runtime_scope_candidates("config", "params_suggestion.json")
    )
    if suggestion_file is None:
        suggestion_file = config_dir / "params_suggestion.json"
    shadow_file = _resolve_runtime_artifact("config", "params_shadow.json") or (config_dir / "params_shadow.json")
    applied_files = sorted(config_dir.glob("params_applied_*.json"))
    latest_applied = applied_files[-1] if applied_files else None
    effective_file = _resolve_runtime_artifact("logs", "effective_params.json") or (LOGS_DIR / "effective_params.json")
    report_file = _resolve_runtime_artifact("logs", "evolution_report_latest.json") or (LOGS_DIR / "evolution_report_latest.json")
    learner_report_file = _resolve_runtime_artifact("data", "offline", "param_learner_report_latest.json") or (
        DATA_DIR / "offline" / "param_learner_report_latest.json"
    )
    quality_snapshot_file = _resolve_runtime_artifact("data", "offline", "training_quality_snapshot.json") or (
        DATA_DIR / "offline" / "training_quality_snapshot.json"
    )
    shadow_summary_file = _resolve_runtime_artifact("logs", "shadow_learner_summary_latest.json") or (
        LOGS_DIR / "shadow_learner_summary_latest.json"
    )
    live_summary_file = _resolve_runtime_artifact("logs", "live_learner_summary_latest.json") or (
        LOGS_DIR / "live_learner_summary_latest.json"
    )
    tenant_id = "default"
    from src.auto_governance import load_auto_governance_snapshot
    from src.phase_readiness import build_phase_readiness

    data = {
        "success": True,
        "stage": "NO_SUGGESTION",
        "suggestion": {},
        "appliedSuggestion": {},
        "effectiveParams": {},
        "evolutionReport": {},
        "paramLearner": {},
        "shadowLearner": {},
        "liveLearner": {},
        "autoGovernance": {},
        "phaseReadiness": {},
        "rollbackState": {},
        "decision": {},
    }

    if suggestion_payload:
        data["suggestion"] = suggestion_payload
        data["stage"] = "PENDING_APPROVAL"

    if latest_applied and latest_applied.exists():
        try:
            payload = json.loads(latest_applied.read_text(encoding="utf-8"))
            payload["archive_file"] = str(latest_applied)
            data["appliedSuggestion"] = payload
            if data["stage"] == "NO_SUGGESTION":
                data["stage"] = "APPLIED_TO_SHADOW"
        except Exception:
            pass

    if effective_file.exists():
        try:
            effective = json.loads(effective_file.read_text(encoding="utf-8"))
            data["effectiveParams"] = effective
            if isinstance(effective.get("offline_params"), dict):
                data["effectiveOfflineParams"] = effective.get("offline_params", {})
            if bool(effective.get("review_pending", False)) or bool(
                isinstance(effective.get("offline_params"), dict)
                and effective.get("offline_params", {}).get("review_pending", False)
            ):
                data["stage"] = "REVIEW_PENDING"
            elif str(effective.get("rollout_state", "") or "") == "rolled_back" or bool(effective.get("rolled_back_at")):
                data["stage"] = "NO_SUGGESTION"
        except Exception:
            pass

    suggestion_updated_at = _file_mtime_ts(suggestion_file)
    effective_updated_at = (
        _parse_timestamp_value(data["effectiveParams"].get("updated_at"))
        or _parse_timestamp_value(data["effectiveParams"].get("strategy_switched_at"))
        or _file_mtime_ts(effective_file)
    )

    if report_file.exists():
        try:
            report = json.loads(report_file.read_text(encoding="utf-8"))
            data["evolutionReport"] = {
                "timestamp": report.get("timestamp"),
                "status": report.get("status"),
                "quality_context": report.get("quality_context", report.get("quality_gate", {})),
                "param_learner": report.get("param_learner", {}),
            }
        except Exception:
            pass

    if learner_report_file.exists():
        try:
            data["paramLearner"] = json.loads(learner_report_file.read_text(encoding="utf-8"))
        except Exception:
            pass

    if shadow_summary_file.exists():
        try:
            data["shadowLearner"] = json.loads(shadow_summary_file.read_text(encoding="utf-8"))
        except Exception:
            pass

    if live_summary_file.exists():
        try:
            data["liveLearner"] = json.loads(live_summary_file.read_text(encoding="utf-8"))
        except Exception:
            pass
    try:
        data["autoGovernance"] = load_auto_governance_snapshot(APP_DIR)
    except Exception:
        pass
    try:
        data["phaseReadiness"] = build_phase_readiness(APP_DIR)
    except Exception:
        pass
    try:
        data["rollbackState"] = get_effective_rollback_state()
    except Exception:
        data["rollbackState"] = {}
    candidate_value = _read_training_candidate_value()
    review_state = build_training_review_state(data["effectiveParams"], candidate_value)

    current_quality_gate = _read_training_quality_snapshot(force_refresh=False)

    quality_context = current_quality_gate if current_quality_gate else {}
    if not quality_context and isinstance(data["effectiveParams"], dict) and isinstance(data["effectiveParams"].get("quality_context"), dict):
        quality_context = data["effectiveParams"].get("quality_context", {})
    elif not quality_context and isinstance(data["suggestion"], dict) and isinstance(data["suggestion"].get("quality_context"), dict):
        quality_context = data["suggestion"].get("quality_context", {})
    elif not quality_context and isinstance(data["appliedSuggestion"], dict) and isinstance(data["appliedSuggestion"].get("quality_context"), dict):
        quality_context = data["appliedSuggestion"].get("quality_context", {})
    elif not quality_context and isinstance(data["evolutionReport"], dict) and isinstance(data["evolutionReport"].get("quality_context"), dict):
        quality_context = data["evolutionReport"].get("quality_context", {})

    effective_param_map = review_state.get("effectiveParamMap", {})
    training_review_pending = bool(review_state.get("trainingReviewPendingRaw", False))
    suggestion_age_sec = _age_seconds_from_ts(suggestion_updated_at)
    stale_suggestion_reason = ""
    if data["suggestion"]:
        if (
            suggestion_updated_at > 0
            and effective_updated_at is not None
            and effective_updated_at >= (suggestion_updated_at + 300.0)
        ):
            stale_suggestion_reason = "superseded_by_effective_params"
        elif (
            suggestion_age_sec is not None
            and suggestion_age_sec >= _TRAINING_APPROVAL_PENDING_MAX_AGE_SEC
            and not training_review_pending
        ):
            # Expire abandoned approval files so stale manual suggestions do not pin the hall in PENDING_APPROVAL.
            stale_suggestion_reason = "expired_pending_suggestion"
    if stale_suggestion_reason:
        data["staleSuggestion"] = {
            "reason": stale_suggestion_reason,
            "updatedAt": _utc_iso_from_ts(suggestion_updated_at),
            "ageSec": round(float(suggestion_age_sec or 0.0), 1) if suggestion_age_sec is not None else None,
            "sourceFile": str(suggestion_file),
        }
        data["suggestion"] = {}
        if data["stage"] == "PENDING_APPROVAL":
            data["stage"] = "NO_SUGGESTION"
    shadow_exists = shadow_file.exists()
    ready_for_training = bool(quality_context.get("ready_for_training", False)) if isinstance(quality_context, dict) else False
    blocked_reasons = quality_context.get("blocked_reasons", []) if isinstance(quality_context, dict) else []
    promotion_mode = get_promotion_mode(APP_DIR, tenant_id)
    exec_stats = aggregate_exec_stats(LOGS_DIR)
    # Cold-start bridge: when paper trade metrics are not yet populated, fallback to real closed trades.
    try:
        from src.metrics_agg import aggregate_goal_targets

        goal_targets = aggregate_goal_targets(APP_DIR, tenant_id)
        month_window = goal_targets.get("window_month", {}) if isinstance(goal_targets, dict) else {}
        closed_trade_count = int(month_window.get("closed_trade_count", 0) or 0)
        profit_factor = float(month_window.get("profit_factor", 0.0) or 0.0)
        if float(exec_stats.get("min_paper_trades", 0) or 0) <= 0 and closed_trade_count > 0:
            exec_stats["min_paper_trades"] = closed_trade_count
        if float(exec_stats.get("min_profit_factor", 0.0) or 0.0) <= 0 and profit_factor > 0:
            exec_stats["min_profit_factor"] = round(profit_factor, 4)
    except Exception:
        pass

    gate_readiness = evaluate_promotion_gates(APP_DIR, tenant_id, exec_stats)
    unmet_gates = [g for g in gate_readiness if not g.get("ready", False)]
    next_mode = gate_readiness[0].get("next_mode") if gate_readiness else None

    missing_conditions = []
    if data["stage"] == "NO_SUGGESTION":
        missing_conditions.append("no_suggestion")
    if not shadow_exists:
        missing_conditions.append("shadow_params_missing")
    if not ready_for_training:
        missing_conditions.extend([f"quality_gate:{r}" for r in blocked_reasons] or ["quality_gate_blocked"])
    if training_review_pending:
        missing_conditions.append("manual_review_pending")
    if not bool(candidate_value.get("ready_for_manual_review", False)):
        missing_conditions.extend(
            [f"candidate_value:{reason}" for reason in candidate_value.get("blocked_reasons", [])]
        )
    if unmet_gates:
        missing_conditions.extend([f"promotion_gate:{g.get('gate')}" for g in unmet_gates])

    if data["stage"] == "NO_SUGGESTION":
        decision_status = "NO_ACTION"
    elif training_review_pending and not bool(candidate_value.get("ready_for_manual_review", False)):
        decision_status = "ROLLBACK_REQUIRED"
    elif data["stage"] == "PENDING_APPROVAL" and not training_review_pending:
        decision_status = "WAIT_APPROVAL"
    elif training_review_pending:
        decision_status = "WAIT_HUMAN_REVIEW"
    elif missing_conditions:
        decision_status = "BLOCKED"
    else:
        decision_status = "READY_TO_PROMOTE"

    recommended_actions = []
    forbidden_actions = []
    next_step = "等待新的参数建议"
    has_pending_suggestion = bool(data.get("suggestion"))

    if data["stage"] == "NO_SUGGESTION":
        recommended_actions.append("generate_or_review_new_suggestion")
        forbidden_actions.extend(["promote_to_live", "replace_effective_params"])
        next_step = "先生成新的参数建议，再进入 shadow/review 链路"
    elif training_review_pending and not bool(candidate_value.get("ready_for_manual_review", False)):
        recommended_actions.append("rollback_invalid_candidate")
        forbidden_actions.extend(["complete_manual_review", "promote_to_live", "advance_promotion_mode"])
        next_step = "当前 review 候选未通过价值门槛，禁止继续人工复核，必须先回滚再重新生成高价值 suggestion"
    elif not bool(candidate_value.get("ready_for_manual_review", False)):
        if has_pending_suggestion:
            recommended_actions.append("reject_invalid_candidate")
            forbidden_actions.extend(["approve_to_shadow", "promote_to_live", "advance_promotion_mode"])
            next_step = "当前 suggestion 未通过价值门槛，禁止进入观察态，需丢弃后重新生成"
        else:
            recommended_actions.append("generate_or_review_new_suggestion")
            forbidden_actions.extend(["promote_to_live", "advance_promotion_mode"])
            next_step = "当前没有可进入人工复核的高价值候选，需重新生成 suggestion"
    elif data["stage"] == "PENDING_APPROVAL" and not training_review_pending:
        if bool(candidate_value.get("ready_for_manual_review", False)):
            recommended_actions.append("approve_to_shadow")
            forbidden_actions.extend(["promote_to_live", "advance_promotion_mode"])
            next_step = (
                "当前已有待批建议；批准后进入可回滚观察态，再进入人工复核"
                if shadow_exists
                else "先将 suggestion 批准进入 shadow/effective 链路，再进入人工复核"
            )
        else:
            recommended_actions.append("reject_invalid_candidate")
            forbidden_actions.extend(["approve_to_shadow", "promote_to_live", "advance_promotion_mode"])
            next_step = "当前 suggestion 未通过价值门槛，禁止进入观察态，需丢弃后重新生成"
    elif training_review_pending:
        recommended_actions.append("complete_manual_review")
        forbidden_actions.extend(["promote_to_live", "advance_promotion_mode"])
        next_step = "当前处于人工复核阶段，先完成 review 再决定是否推进"
    elif not shadow_exists:
        recommended_actions.append("rebuild_shadow_params")
        forbidden_actions.append("promote_to_live")
        next_step = "缺少 shadow 参数文件，先重建影子参数"
    elif not ready_for_training:
        recommended_actions.extend([f"repair:{r}" for r in blocked_reasons] or ["repair_training_quality"])
        forbidden_actions.extend(["promote_to_live", "run_offline_training"])
        next_step = "先修复 quality gate 阻断项，再考虑 promotion 或训练"
    elif unmet_gates:
        recommended_actions.extend([f"wait_or_improve:{g.get('gate')}" for g in unmet_gates])
        forbidden_actions.append(f"promote_to_{next_mode or 'next'}")
        next_step = f"当前仍在 {promotion_mode} 阶段，需先满足 promotion gates 再进入 {next_mode or '下一阶段'}"
    else:
        recommended_actions.append(f"promote_to_{next_mode or 'next_mode'}")
        next_step = f"已满足当前条件，可推进到 {next_mode or '下一阶段'}"

    data["decision"] = {
        "status": decision_status,
        "canPromote": decision_status == "READY_TO_PROMOTE",
        "canRollback": bool((data.get("rollbackState", {}) or {}).get("available", False) and data.get("effectiveParams")),
        "reviewPending": training_review_pending,
        "trainingReviewPending": training_review_pending,
        "shadowExists": shadow_exists,
        "promotionMode": promotion_mode,
        "promotionTarget": next_mode,
        "gateReadiness": gate_readiness,
        "missingConditions": missing_conditions,
        "recommendedActions": recommended_actions,
        "forbiddenActions": forbidden_actions,
        "nextStep": next_step,
        "qualityContext": quality_context,
        "candidateValue": candidate_value,
        "paramLearnerReady": bool(isinstance(data["paramLearner"], dict) and data["paramLearner"].get("status") == "READY"),
        "shadowLearnerStatus": str((data["shadowLearner"] or {}).get("status", "EMPTY")),
        "shadowDivergenceCount": int((data["shadowLearner"] or {}).get("divergenceCount", 0) or 0),
        "liveLearnerStatus": str((data["liveLearner"] or {}).get("status", "EMPTY")),
        "liveLearnerRate": float((data["liveLearner"] or {}).get("learnerLiveRate", 0.0) or 0.0),
        "autoGovernanceStatus": str((data["autoGovernance"] or {}).get("status", "NOOP")),
        "autoGovernanceReason": str((data["autoGovernance"] or {}).get("lastReason", "")),
        "phaseReadiness": str((data["phaseReadiness"] or {}).get("status", "BLOCKED")),
    }
    data["approvalSummary"] = {
        **public_training_review_state(review_state),
        "hasEffectiveParams": bool(review_state.get("hasEffectiveParams", False) or data.get("effectiveParams")),
    }
    data["effectiveOfflineParams"] = effective_param_map
    try:
        from src.hermes_bridge import load_governance_brief, load_runtime_rules, load_system_design_contract, schedule_governance_brief_refresh
        data["hermesBrief"] = load_governance_brief("approval_chain")
        data["hermesRuntimeRules"] = load_runtime_rules()
        data["systemDesignContract"] = load_system_design_contract()
        schedule_governance_brief_refresh("approval_chain", data)
    except Exception:
        data["hermesBrief"] = {}
        data["hermesRuntimeRules"] = {}
        data["systemDesignContract"] = {}

    return _attach_projection_metadata(
        data,
        snapshot_name="training_approval_chain",
        source_paths={
            "params_suggestion": suggestion_file,
            "params_shadow": shadow_file,
            "effective_params": effective_file,
            "evolution_report": report_file,
            "param_learner_report": learner_report_file,
            "training_quality_snapshot": quality_snapshot_file,
            "shadow_learner_summary": shadow_summary_file,
            "live_learner_summary": live_summary_file,
        },
    )


@app.get("/api/training/approval_chain")
def get_training_approval_chain(force_refresh: bool = False):
    warming_payload = {
        "success": True,
        "stage": "NO_SUGGESTION",
        "suggestion": {},
        "appliedSuggestion": {},
        "effectiveParams": {},
        "evolutionReport": {},
        "paramLearner": {},
        "shadowLearner": {},
        "liveLearner": {},
        "autoGovernance": {},
        "phaseReadiness": {},
        "rollbackState": {},
        "decision": {},
        "approvalSummary": {},
        "effectiveOfflineParams": {},
    }
    return _get_cached_aux_payload(
        _TRAINING_APPROVAL_CHAIN_CACHE,
        _TRAINING_APPROVAL_CHAIN_CACHE_TTL_SEC,
        _build_training_approval_chain_payload,
        "gui-training-approval-refresh",
        warming_payload,
        force_refresh=force_refresh,
    )


def _build_governance_lifecycle_payload():
    execution_state_file = _resolve_runtime_artifact("data", "execution_state.json") or (DATA_DIR / "execution_state.json")
    audit_file = _resolve_runtime_artifact("data", "learning", "audit", "transition_health.latest.json") or (
        APP_DIR / "learning" / "audit" / "transition_health.latest.json"
    )
    repair_file = _resolve_runtime_artifact("data", "learning", "audit", "repair_cycle.latest.json") or (
        APP_DIR / "learning" / "audit" / "repair_cycle.latest.json"
    )
    anomaly_file = _resolve_runtime_artifact("data", "learning", "audit", "anomaly_ledger.latest.jsonl") or (
        APP_DIR / "learning" / "audit" / "anomaly_ledger.latest.jsonl"
    )
    closures_file = _resolve_runtime_artifact("data", "learning", "events", "closures.jsonl") or (
        APP_DIR / "learning" / "events" / "closures.jsonl"
    )
    training_status_file = _resolve_runtime_artifact("data", "offline", "training_status.json") or (
        DATA_DIR / "offline" / "training_status.json"
    )
    shadow_summary_file = _resolve_runtime_artifact("logs", "shadow_learner_summary_latest.json") or (
        LOGS_DIR / "shadow_learner_summary_latest.json"
    )
    live_summary_file = _resolve_runtime_artifact("logs", "live_learner_summary_latest.json") or (
        LOGS_DIR / "live_learner_summary_latest.json"
    )
    data = {
        "success": True,
        "activeSignals": [],
        "recentSignals": [],
        "audit": {},
        "repairCycle": {},
        "closureLifecycle": {
            "recentClosures": [],
            "normalClosedCount": 0,
            "recoveredClosedCount": 0,
            "pendingRepairCount": 0,
            "pendingRepairDecisions": [],
        },
        "trainingLifecycle": {
            "readyForTraining": False,
            "blockedReasons": [],
            "closureCoveragePct": 0.0,
            "criticalAnomalies": 0,
            "recoveredClosedRatioPct": 0.0,
            "repairRecovered": 0,
            "transitionCountBeforeRepair": 0,
            "transitionCountAfterRepair": 0,
        },
        "shadowLearner": {
            "status": "EMPTY",
            "total": 0,
            "actionMatchRate": 0.0,
            "directionMatchRate": 0.0,
            "baselineApprovedRate": 0.0,
            "learnerApprovedRate": 0.0,
            "divergenceCount": 0,
            "recentDecisions": [],
        },
        "liveLearner": {
            "status": "EMPTY",
            "totalSelections": 0,
            "learnerLiveSelections": 0,
            "baselineSelections": 0,
            "blockedSelections": 0,
            "learnerLiveRate": 0.0,
            "currentMode": "shadow",
            "lastDecisionId": "",
            "lastSelectedSource": "baseline",
            "lastReason": "",
            "lastRollbackTriggers": [],
            "recentSelections": [],
        },
        "rollbackEvidence": {
            "triggered": [],
            "killSwitch": False,
        },
        "autoGovernance": {
            "status": "NOOP",
            "enabled": True,
            "currentMode": "shadow",
            "nextMode": "",
            "lastReason": "",
            "lastAction": "NOOP",
            "recentActions": [],
        },
        "phaseReadiness": {
            "status": "BLOCKED",
            "nextClosure": "",
            "overallReady": False,
            "phases": {},
        },
    }
    audit_mtime_ts = _file_mtime_ts(audit_file)
    repair_mtime_ts = _file_mtime_ts(repair_file)
    latest_governance_audit_ts = max(audit_mtime_ts, repair_mtime_ts)
    if latest_governance_audit_ts <= 0 or (time.time() - latest_governance_audit_ts) > 900:
        try:
            from src.learning.transition_audit import run_repair_cycle

            run_repair_cycle(auto_recover=False)
            audit_file = _resolve_runtime_artifact("data", "learning", "audit", "transition_health.latest.json") or audit_file
            repair_file = _resolve_runtime_artifact("data", "learning", "audit", "repair_cycle.latest.json") or repair_file
            anomaly_file = _resolve_runtime_artifact("data", "learning", "audit", "anomaly_ledger.latest.jsonl") or anomaly_file
        except Exception:
            pass
    try:
        from src.auto_governance import load_auto_governance_snapshot
        from src.phase_readiness import build_phase_readiness
    except Exception as exc:
        reason = f"DEPENDENCY_UNAVAILABLE: {exc}"
        data["autoGovernance"]["status"] = "UNAVAILABLE"
        data["autoGovernance"]["lastReason"] = reason
        data["phaseReadiness"]["status"] = "UNAVAILABLE"
        data["phaseReadiness"]["phases"] = {"training": {"ready": False, "reason": reason}}
        return data

    if execution_state_file.exists():
        try:
            state = json.loads(execution_state_file.read_text(encoding="utf-8"))
            signals = list((state.get("signals", {}) or {}).values())

            def latest_ts(entry: dict) -> str:
                history = entry.get("history", []) if isinstance(entry, dict) else []
                if not history:
                    return ""
                return str(history[-1].get("ts", "") or "")

            normalized = []
            for entry in signals:
                history = entry.get("history", []) if isinstance(entry, dict) else []
                normalized.append(
                    {
                        "signalId": entry.get("signal_id"),
                        "traceId": entry.get("trace_id", entry.get("signal_id")),
                        "state": entry.get("state", "CREATED"),
                        "latestTs": latest_ts(entry),
                        "reason": history[-1].get("reason") if history else "",
                        "history": history[-6:],
                        "metadata": entry.get("metadata", {}),
                    }
                )
            normalized.sort(key=lambda item: item.get("latestTs", ""), reverse=True)
            data["recentSignals"] = normalized[:8]
            data["activeSignals"] = [
                item
                for item in normalized
                if str(item.get("state", "")).upper() not in {"FILLED", "REJECTED", "VETOED", "TIMEOUT", "RECONCILED"}
            ][:6]
        except Exception:
            pass

    if audit_file.exists():
        try:
            data["audit"] = json.loads(audit_file.read_text(encoding="utf-8"))
        except Exception:
            pass

    if repair_file.exists():
        try:
            data["repairCycle"] = json.loads(repair_file.read_text(encoding="utf-8"))
        except Exception:
            pass

    if anomaly_file.exists():
        try:
            pending_repairs = []
            for raw in anomaly_file.read_text(encoding="utf-8").splitlines():
                if not raw.strip():
                    continue
                row = json.loads(raw)
                if row.get("anomaly_type") != "missing_closure":
                    continue
                pending_repairs.append(
                    {
                        "decisionId": row.get("decision_id"),
                        "ticket": row.get("ticket"),
                        "severity": row.get("severity", "unknown"),
                    }
                )
            data["closureLifecycle"]["pendingRepairCount"] = len(pending_repairs)
            data["closureLifecycle"]["pendingRepairDecisions"] = pending_repairs[:6]
        except Exception:
            pass

    if closures_file.exists():
        try:
            closure_rows = []
            recovered_count = 0
            normal_count = 0
            for raw in closures_file.read_text(encoding="utf-8").splitlines():
                if not raw.strip():
                    continue
                row = json.loads(raw)
                payload = row.get("payload", {}) if isinstance(row, dict) else {}
                recovered = bool(payload.get("recovered", False))
                if recovered:
                    recovered_count += 1
                else:
                    normal_count += 1
                closure_rows.append(
                    {
                        "decisionId": row.get("decision_id"),
                        "eventTime": row.get("event_time"),
                        "ticket": payload.get("ticket", 0),
                        "realizedPnl": payload.get("realized_pnl", 0.0),
                        "doneReason": payload.get("done_reason", "closed"),
                        "recovered": recovered,
                        "recoverySource": payload.get("recovery_source", ""),
                    }
                )
            closure_rows.sort(key=lambda item: str(item.get("eventTime", "")), reverse=True)
            data["closureLifecycle"]["recentClosures"] = closure_rows[:8]
            data["closureLifecycle"]["normalClosedCount"] = normal_count
            data["closureLifecycle"]["recoveredClosedCount"] = recovered_count
        except Exception:
            pass

    quality_gate = _read_training_quality_snapshot(force_refresh=False)
    if not quality_gate and training_status_file.exists():
        try:
            training_status = json.loads(training_status_file.read_text(encoding="utf-8"))
            quality_gate = training_status.get("qualityGate", {}) if isinstance(training_status, dict) else {}
        except Exception:
            quality_gate = {}
    if quality_gate:
        repair_cycle = quality_gate.get("repair_cycle", {}) if isinstance(quality_gate, dict) else {}
        recovery = repair_cycle.get("recovery", {}) if isinstance(repair_cycle, dict) else {}
        transition_counts = repair_cycle.get("transition_counts", {}) if isinstance(repair_cycle, dict) else {}
        data["trainingLifecycle"] = {
            "readyForTraining": bool(quality_gate.get("ready_for_training", False)),
            "blockedReasons": quality_gate.get("blocked_reasons", []),
            "closureCoveragePct": quality_gate.get("closure_coverage_pct", 0.0),
            "criticalAnomalies": quality_gate.get("critical_anomalies", 0),
            "recoveredClosedRatioPct": quality_gate.get("recovered_closed_ratio_pct", 0.0),
            "repairRecovered": recovery.get("recovered", 0),
            "transitionCountBeforeRepair": transition_counts.get("before", 0),
            "transitionCountAfterRepair": transition_counts.get("after", 0),
            "qualitySnapshotId": str(quality_gate.get("snapshot_id", "") or ""),
        }

    if shadow_summary_file.exists():
        try:
            data["shadowLearner"] = json.loads(shadow_summary_file.read_text(encoding="utf-8"))
        except Exception:
            pass

    if live_summary_file.exists():
        try:
            data["liveLearner"] = json.loads(live_summary_file.read_text(encoding="utf-8"))
        except Exception:
            pass
    try:
        data["autoGovernance"] = load_auto_governance_snapshot(APP_DIR)
    except Exception:
        pass
    try:
        data["phaseReadiness"] = build_phase_readiness(APP_DIR)
    except Exception:
        pass

    try:
        from src.metrics_agg import aggregate_runtime_state, evaluate_rollback_gates
        runtime = aggregate_runtime_state(APP_DIR, "default")
        data["rollbackEvidence"] = {
            "triggered": evaluate_rollback_gates(runtime),
            "killSwitch": bool(runtime.get("kill_switch", False)),
        }
    except Exception:
        pass

    try:
        from src.hermes_bridge import load_governance_brief, load_runtime_rules, load_system_design_contract, schedule_governance_brief_refresh
        data["hermesBrief"] = load_governance_brief("governance_lifecycle")
        data["hermesRuntimeRules"] = load_runtime_rules()
        data["systemDesignContract"] = load_system_design_contract()
        schedule_governance_brief_refresh("governance_lifecycle", data)
    except Exception:
        data["hermesBrief"] = {}
        data["hermesRuntimeRules"] = {}
        data["systemDesignContract"] = {}

    return data


@app.get("/api/governance/lifecycle")
def get_governance_lifecycle(force_refresh: bool = False):
    warming_payload = {
        "success": True,
        "activeSignals": [],
        "recentSignals": [],
        "audit": {},
        "repairCycle": {},
        "closureLifecycle": {},
        "trainingLifecycle": {},
        "shadowLearner": {},
        "liveLearner": {},
        "rollbackEvidence": {},
        "autoGovernance": {},
        "phaseReadiness": {},
    }
    return _get_cached_aux_payload(
        _GOVERNANCE_LIFECYCLE_CACHE,
        _GOVERNANCE_LIFECYCLE_CACHE_TTL_SEC,
        _build_governance_lifecycle_payload,
        "gui-governance-lifecycle-refresh",
        warming_payload,
        force_refresh=force_refresh,
    )


def _build_direction_quality(runtime_state: Dict[str, Any]) -> dict:
    rows = runtime_state.get("recent_closures", []) if isinstance(runtime_state, dict) else []
    if not isinstance(rows, list):
        rows = []
    rows = [r for r in rows if isinstance(r, dict)][-40:]
    total = len(rows)
    aligned_rows = [r for r in rows if bool(r.get("agent_aligned", False))]
    counter_rows = [r for r in rows if not bool(r.get("agent_aligned", False))]
    aligned_hits = sum(1 for r in aligned_rows if float(r.get("pnl", 0.0) or 0.0) > 0)
    counter_hits = sum(1 for r in counter_rows if float(r.get("pnl", 0.0) or 0.0) > 0)
    aligned_avg = (
        sum(float(r.get("pnl", 0.0) or 0.0) for r in aligned_rows) / len(aligned_rows)
        if aligned_rows
        else 0.0
    )
    counter_avg = (
        sum(float(r.get("pnl", 0.0) or 0.0) for r in counter_rows) / len(counter_rows)
        if counter_rows
        else 0.0
    )
    range_modes = {"RANGING", "RANGE", "SIDEWAYS", "NEUTRAL", "UNCERTAIN"}
    counter_in_range = sum(
        1
        for r in counter_rows
        if str(r.get("session_mode", "UNKNOWN") or "UNKNOWN").upper() in range_modes
    )
    return {
        "sampleCount": total,
        "alignedCount": len(aligned_rows),
        "counterCount": len(counter_rows),
        "alignedHitRatePct": round(100.0 * aligned_hits / max(1, len(aligned_rows)), 2),
        "counterHitRatePct": round(100.0 * counter_hits / max(1, len(counter_rows)), 2),
        "directionEdgePnl": round(aligned_avg - counter_avg, 4),
        "counterInRangePct": round(100.0 * counter_in_range / max(1, len(counter_rows)), 2),
        "consecutiveLosses": int(runtime_state.get("consecutive_losses", 0) or 0),
    }


def _build_macro_poll_status(runtime_state: Dict[str, Any]) -> dict:
    hold_stats = runtime_state.get("hold_stats", {}) if isinstance(runtime_state, dict) else {}
    if not isinstance(hold_stats, dict):
        hold_stats = {}
    now_ts = time.time()
    first_hold_ts = float(hold_stats.get("first_hold_ts", 0.0) or 0.0)
    last_poll_ts = float(hold_stats.get("last_macro_poll_ts", 0.0) or 0.0)
    return {
        "holdStreak": int(hold_stats.get("streak", 0) or 0),
        "holdDurationSec": int(max(0.0, (now_ts - first_hold_ts)) if first_hold_ts > 0 else 0),
        "lastMacroPollAgoSec": int(max(0.0, now_ts - last_poll_ts)) if last_poll_ts > 0 else -1,
        "lastReason": str(hold_stats.get("last_reason", "") or ""),
        "lastRegime": str(hold_stats.get("last_regime", "UNKNOWN") or "UNKNOWN"),
    }


def _build_runtime_observability_payload():
    try:
        from src.auto_governance import load_auto_governance_snapshot
        from src.live_performance_factors import load_live_performance_factors
    except Exception as exc:
        return {"success": False, "error": f"runtime import failed: {exc}"}

    status = get_status()
    raw_training_status = _read_training_status_payload()
    quality_gate = _read_training_quality_snapshot(force_refresh=False)
    if not quality_gate:
        quality_gate = (
            raw_training_status.get("qualityGate", {})
            if isinstance(raw_training_status.get("qualityGate", {}), dict)
            else {}
        )
    recovery = quality_gate.get("recovery", {}) if isinstance(quality_gate.get("recovery", {}), dict) else {}
    training_status = {
        "readyForTraining": bool(quality_gate.get("ready_for_training", False)),
        "blockedReasons": quality_gate.get("blocked_reasons", []),
        "criticalAnomalies": int(quality_gate.get("critical_anomalies", 0) or 0),
        "closureCoveragePct": float(quality_gate.get("closure_coverage_pct", 0.0) or 0.0),
        "naturalClosedRatioPct": float(recovery.get("natural_closed_ratio_pct", 0.0) or 0.0),
        "recoveredClosedRatioPct": float(quality_gate.get("recovered_closed_ratio_pct", 0.0) or 0.0),
    }
    approval_chain = {"decision": {}}
    governance_lifecycle = {"trainingLifecycle": {}, "closureLifecycle": {}}
    hermes_runtime_rules = {}
    try:
        from src.hermes_bridge import load_runtime_rules, load_system_design_contract
        hermes_runtime_rules = load_runtime_rules()
        system_design_contract = load_system_design_contract()
    except Exception:
        hermes_runtime_rules = {}
        system_design_contract = {}

    decision_contract = load_agent_decision_contract()
    direction_snapshot = load_decision_snapshot()
    decision_snapshot_macro = (
        direction_snapshot.get("macro", {})
        if isinstance(direction_snapshot.get("macro", {}), dict)
        else {}
    )
    decision_snapshot_policy = (
        direction_snapshot.get("decision_policy", {})
        if isinstance(direction_snapshot.get("decision_policy", {}), dict)
        else {}
    )
    decision_snapshot_execution = (
        direction_snapshot.get("execution", {})
        if isinstance(direction_snapshot.get("execution", {}), dict)
        else {}
    )
    interpreter_path = DATA_DIR / "agent_signal_override.json"
    interpreter_payload = (
        decision_contract.get("interpreter", {})
        if isinstance(decision_contract.get("interpreter", {}), dict)
        and decision_contract.get("interpreter", {})
        else _read_json_file(interpreter_path)
    )
    interpreter_ts = _parse_timestamp_value(interpreter_payload.get("timestamp")) or (
        interpreter_path.stat().st_mtime if interpreter_path.exists() else None
    )
    interpreter_valid_until = _parse_timestamp_value(interpreter_payload.get("valid_until"))
    interpreter_age = _age_seconds_from_ts(interpreter_ts)
    interpreter_status = "MISSING"
    if interpreter_payload:
        if interpreter_valid_until and time.time() <= interpreter_valid_until:
            interpreter_status = "ACTIVE"
        elif interpreter_age is not None and interpreter_age <= 900:
            interpreter_status = "ACTIVE"
        else:
            interpreter_status = "STALE"

    try:
        performance_artifact = load_live_performance_factors()
    except Exception:
        performance_artifact = {}
    performance_quality = (
        performance_artifact.get("quality", {})
        if isinstance(performance_artifact.get("quality"), dict)
        else {}
    )
    performance_ts = _parse_timestamp_value(performance_artifact.get("updated_at"))
    auto_governance = load_auto_governance_snapshot(APP_DIR)
    auto_governance_ts = _parse_timestamp_value(auto_governance.get("timestamp")) or _parse_timestamp_value(
        auto_governance.get("last_action_ts")
    )
    training_lifecycle = {}
    try:
        from src.market_adaptive_profile import build_market_adaptive_profile
        adaptive_profile = build_market_adaptive_profile(interpreter_payload)
    except Exception:
        adaptive_profile = {
            "market_structure": str(interpreter_payload.get("market_structure", "UNCERTAIN") or "UNCERTAIN"),
            "regime_subtype": str(interpreter_payload.get("regime_subtype", "STRUCTURE_UNCLEAR") or "STRUCTURE_UNCLEAR"),
            "volatility_state": str(interpreter_payload.get("volatility_state", "NORMAL") or "NORMAL"),
            "liquidity_state": str(interpreter_payload.get("liquidity_state", "NORMAL") or "NORMAL"),
            "continuation_bias": str(interpreter_payload.get("continuation_bias", "NEUTRAL") or "NEUTRAL"),
            "entry_timing_hint": str(interpreter_payload.get("entry_timing_hint", "WAIT_CLEAR_SIGNAL") or "WAIT_CLEAR_SIGNAL"),
            "llm_confidence": float(interpreter_payload.get("llm_confidence", 0.55) or 0.55),
        }
    approval_decision = (
        approval_chain.get("decision", {})
        if isinstance(approval_chain.get("decision"), dict)
        else {}
    )
    goal_targets = _load_goal_targets_cached()
    try:
        runtime_state = load_state()
    except Exception:
        runtime_state = {}
    try:
        proc = psutil.Process(os.getpid())
        process_rss_mb = round(proc.memory_info().rss / 1024 / 1024, 1)
    except Exception:
        process_rss_mb = 0.0
    try:
        vm = psutil.virtual_memory()
        system_memory = {
            "usedMb": int(vm.used / (1024 * 1024)),
            "availableMb": int(vm.available / (1024 * 1024)),
            "percent": float(vm.percent),
            "warning": bool(vm.percent >= 85.0 or process_rss_mb >= 1024.0),
        }
    except Exception:
        system_memory = {
            "usedMb": 0,
            "availableMb": 0,
            "percent": 0.0,
            "warning": False,
        }
    direction_quality = _build_direction_quality(runtime_state)
    macro_poll_status = _build_macro_poll_status(runtime_state)
    confidence_calibration = (
        runtime_state.get("confidence_calibration", {})
        if isinstance(runtime_state.get("confidence_calibration", {}), dict)
        else {}
    )
    defense_metrics = (
        runtime_state.get("defense_metrics", {})
        if isinstance(runtime_state.get("defense_metrics", {}), dict)
        else {}
    )
    trailing_telemetry = (
        runtime_state.get("trailing_telemetry", {})
        if isinstance(runtime_state.get("trailing_telemetry", {}), dict)
        else {}
    )
    evolution_evidence = _read_json_file(APP_DIR / "reports" / "evolution_evidence_latest.json")
    autonomy_loop = _read_json_file(LOGS_DIR / "autonomy_loop_latest.json")
    session_features = (
        hermes_runtime_rules.get("session_offline_features", {})
        if isinstance(hermes_runtime_rules.get("session_offline_features", {}), dict)
        else {}
    )
    session_risk_lens = {
        "asiaLondonOverlap": session_features.get("ASIA_LONDON_OVERLAP", {}),
        "londonNyOverlap": session_features.get("LONDON_NY_OVERLAP", {}),
    }
    recent_actions = (
        auto_governance.get("recentActions", [])
        if isinstance(auto_governance.get("recentActions"), list)
        else []
    )
    latest_goal_action = None
    for action_item in reversed(recent_actions):
        if not isinstance(action_item, dict):
            continue
        action_name = str(action_item.get("action", "")).upper()
        if action_name in {"AUTO_FREEZE", "GOAL_GATE_BLOCK"}:
            latest_goal_action = action_item
            break

    goal_snapshot = {}
    if isinstance(latest_goal_action, dict):
        details = latest_goal_action.get("details", {})
        if isinstance(details, dict) and isinstance(details.get("goal_gates"), dict):
            goal_snapshot = details.get("goal_gates", {})
    if not goal_snapshot and isinstance(goal_targets, dict):
        goal_snapshot = (
            goal_targets.get("gates", {})
            if isinstance(goal_targets.get("gates"), dict)
            else {}
        )

    try:
        technical_trend_direction, technical_trend_data = get_technical_trend_bias()
    except Exception:
        technical_trend_direction, technical_trend_data = "NEUTRAL", {}

    decision_contract_lineage = (
        decision_contract.get("lineage", {})
        if isinstance(decision_contract.get("lineage", {}), dict)
        else {}
    )
    missing_lineage = [
        key for key in ("macro", "interpreter", "decision_policy")
        if not str(decision_contract_lineage.get(key, "") or "").strip()
    ]
    legacy_fallback = str(decision_contract_lineage.get("macro", "") or "") == "legacy_direction_registry"
    decision_contract_health = "READY"
    decision_contract_health_label = "统一合同生效中"
    if missing_lineage:
        decision_contract_health = "MISSING_LINEAGE"
        decision_contract_health_label = "合同元数据待修复"
    elif legacy_fallback:
        decision_contract_health = "LEGACY_FALLBACK"
        decision_contract_health_label = "仍有旧链路回退"

    return {
        "success": True,
        "gateway": {
            "daemonRunning": bool(status.get("isRunning", False)),
            "mt5Online": bool(status.get("mt5Online", False)),
            "bridgeOk": bool(status.get("bridgeOk", False)),
            "light": str(status.get("light", "RED") or "RED"),
            "agentDirection": str(
                (
                    status.get("agent_direction_data", {})
                    if isinstance(status.get("agent_direction_data", {}), dict)
                    else {}
                ).get("binding_direction", status.get("agent_dir", "NEUTRAL"))
                or "NEUTRAL"
            ).upper(),
            "bindingDirection": str(
                (
                    status.get("agent_direction_data", {})
                    if isinstance(status.get("agent_direction_data", {}), dict)
                    else {}
                ).get("binding_direction", status.get("agent_dir", "NEUTRAL"))
                or "NEUTRAL"
            ).upper(),
            "effectiveDirection": str(
                (
                    status.get("agent_direction_data", {})
                    if isinstance(status.get("agent_direction_data", {}), dict)
                    else {}
                ).get("effective_direction", "NEUTRAL")
                or "NEUTRAL"
            ).upper(),
            "allowedSide": str(
                (
                    status.get("agent_direction_data", {})
                    if isinstance(status.get("agent_direction_data", {}), dict)
                    else {}
                ).get("allowed_side", "BOTH")
                or "BOTH"
            ).upper(),
            "macroState": str(
                (
                    status.get("agent_direction_data", {})
                    if isinstance(status.get("agent_direction_data", {}), dict)
                    else {}
                ).get("state", "UNKNOWN")
                or "UNKNOWN"
            ).upper(),
            "technicalTrendBias": str(
                status.get("technical_trend_bias", technical_trend_direction) or technical_trend_direction
            ),
            "technicalTrendData": status.get("technical_trend_data", technical_trend_data)
            if isinstance(status.get("technical_trend_data", technical_trend_data), dict)
            else technical_trend_data,
            "macroDirectionData": status.get("agent_direction_data", {})
            if isinstance(status.get("agent_direction_data", {}), dict)
            else {},
            "decisionContract": {
                "updatedAt": str(decision_contract.get("updated_at", "") or ""),
                "lineage": decision_contract_lineage,
                "healthStatus": decision_contract_health,
                "healthLabel": decision_contract_health_label,
                "missingLineage": missing_lineage,
                "legacyFallback": legacy_fallback,
                "profile": str(
                    decision_snapshot_policy.get("profile", "")
                    or (
                        decision_contract.get("decision_policy", {})
                        if isinstance(decision_contract.get("decision_policy", {}), dict)
                        else {}
                    ).get("profile", "")
                    or "unknown"
                ),
                "bindingDirection": str(
                    decision_snapshot_macro.get("binding_direction", status.get("agent_dir", "NEUTRAL"))
                    or "NEUTRAL"
                ).upper(),
                "effectiveDirection": str(
                    decision_snapshot_macro.get("effective_direction", "NEUTRAL") or "NEUTRAL"
                ).upper(),
                "allowedSide": str(
                    decision_snapshot_execution.get("allowed_side", "BOTH") or "BOTH"
                ).upper(),
                "executionAdvice": str(
                    interpreter_payload.get("execution_advice", "UNKNOWN") or "UNKNOWN"
                ).upper(),
            },
            "regime": str(status.get("regime", "UNKNOWN") or "UNKNOWN"),
            "priceAgeSec": float(status.get("priceAge", 0.0) or 0.0),
            "spread": float(status.get("spread", 0.0) or 0.0),
        },
        "interpreter": {
            "status": interpreter_status,
            "running": bool(interpreter_payload),
            "updatedAt": _utc_iso_from_ts(interpreter_ts),
            "ageSec": interpreter_age,
            "validUntil": _utc_iso_from_ts(interpreter_valid_until),
            "view": str(interpreter_payload.get("view", "UNKNOWN") or "UNKNOWN"),
            "marketStructure": str(interpreter_payload.get("market_structure", "UNKNOWN") or "UNKNOWN"),
            "sessionName": str(interpreter_payload.get("session_name", "UNKNOWN") or "UNKNOWN"),
            "executionAdvice": str(interpreter_payload.get("execution_advice", "UNKNOWN") or "UNKNOWN"),
            "dominantDirection": str(interpreter_payload.get("dominant_direction", "UNKNOWN") or "UNKNOWN"),
            "regimeSubtype": str(interpreter_payload.get("regime_subtype", "STRUCTURE_UNCLEAR") or "STRUCTURE_UNCLEAR"),
            "volatilityState": str(interpreter_payload.get("volatility_state", "NORMAL") or "NORMAL"),
            "liquidityState": str(interpreter_payload.get("liquidity_state", "NORMAL") or "NORMAL"),
            "continuationBias": str(interpreter_payload.get("continuation_bias", "NEUTRAL") or "NEUTRAL"),
            "entryTimingHint": str(interpreter_payload.get("entry_timing_hint", "WAIT_CLEAR_SIGNAL") or "WAIT_CLEAR_SIGNAL"),
            "llmConfidence": float(interpreter_payload.get("llm_confidence", 0.55) or 0.55),
            "buyMultiplier": float(interpreter_payload.get("buy_multiplier", 1.0) or 1.0),
            "sellMultiplier": float(interpreter_payload.get("sell_multiplier", 1.0) or 1.0),
            "reason": str(interpreter_payload.get("reason", "") or ""),
        },
        "adaptiveProfile": adaptive_profile,
        "performance": {
            "status": "READY"
            if bool(performance_quality.get("ready", False))
            else ("WARMING_UP" if performance_artifact else "MISSING"),
            "running": bool(performance_artifact),
            "updatedAt": performance_artifact.get("updated_at", ""),
            "ageSec": _age_seconds_from_ts(performance_ts),
            "usableTradeCount": int(performance_quality.get("usable_trade_count", 0) or 0),
            "excludedIncompleteCount": int(performance_quality.get("excluded_incomplete_count", 0) or 0),
            "excludedRecoveredCount": int(performance_quality.get("excluded_recovered_count", 0) or 0),
            "minBucketTrades": int(performance_quality.get("min_bucket_trades", 0) or 0),
            "ready": bool(performance_quality.get("ready", False)),
            "topBuckets": {
                "session": _top_bucket_preview(performance_artifact.get("session", {})),
                "regime": _top_bucket_preview(performance_artifact.get("regime", {})),
                "strategy": _top_bucket_preview(performance_artifact.get("strategy", {})),
                "interpreter": _top_bucket_preview(performance_artifact.get("interpreter", {})),
            },
            "hermesRuntimeRules": hermes_runtime_rules,
            "systemDesignContract": system_design_contract,
        },
        "trainingGate": {
            "status": "READY" if bool(training_status.get("readyForTraining", False)) else "BLOCKED",
            "readyForTraining": bool(training_status.get("readyForTraining", False)),
            "blockedReasons": training_status.get("blockedReasons", []),
            "criticalAnomalies": int(training_status.get("criticalAnomalies", 0) or 0),
            "closureCoveragePct": float(training_status.get("closureCoveragePct", 0.0) or 0.0),
            "naturalClosedRatioPct": float(training_status.get("naturalClosedRatioPct", 0.0) or 0.0),
            "recoveredClosedRatioPct": float(training_status.get("recoveredClosedRatioPct", 0.0) or 0.0),
            "pendingRepairCount": int(
                (
                    governance_lifecycle.get("closureLifecycle", {})
                    if isinstance(governance_lifecycle.get("closureLifecycle"), dict)
                    else {}
                ).get("pendingRepairCount", 0)
                or 0
            ),
            "repairRecovered": int(training_lifecycle.get("repairRecovered", 0) or 0),
        },
        "autoGovernance": {
            "status": str(auto_governance.get("status", auto_governance.get("last_action", "NOOP")) or "NOOP"),
            "enabled": bool(auto_governance.get("enabled", True)),
            "currentMode": str(auto_governance.get("currentMode", auto_governance.get("current_mode", auto_governance.get("last_mode", "shadow"))) or "shadow"),
            "nextMode": str(auto_governance.get("nextMode", auto_governance.get("next_mode", "")) or ""),
            "lastAction": str(auto_governance.get("lastAction", auto_governance.get("last_action", auto_governance.get("action", "NOOP"))) or "NOOP"),
            "lastReason": str(auto_governance.get("lastReason", auto_governance.get("last_reason", auto_governance.get("reason", ""))) or ""),
            "updatedAt": _utc_iso_from_ts(auto_governance_ts),
            "ageSec": _age_seconds_from_ts(auto_governance_ts),
            "canPromote": bool(approval_decision.get("canPromote", False)),
            "decisionStatus": str(approval_decision.get("status", "UNKNOWN") or "UNKNOWN"),
            "missingConditions": approval_decision.get("missingConditions", []),
            "latestGoalAction": latest_goal_action or {},
            "goalGateSnapshot": goal_snapshot,
            "goalRecoveryHint": "月回撤<=10% 且 月自治率>=85% 后自动解除冻结",
        },
        "goalTargets": goal_targets,
        "evolutionEvidence": evolution_evidence,
        "autonomyLoop": autonomy_loop,
        "sessionRiskLens": session_risk_lens,
        "directionQuality": direction_quality,
        "macroPolling": macro_poll_status,
        "memory": {
            "processRssMb": process_rss_mb,
            "system": system_memory,
            "debug": get_memory_debug(deep=0),
        },
        "confidenceCalibration": confidence_calibration,
        "defenseMetrics": defense_metrics,
        "trailingTelemetry": trailing_telemetry,
    }


def _refresh_runtime_observability_cache_worker() -> None:
    try:
        payload = _build_runtime_observability_payload()
        if isinstance(payload, dict) and bool(payload.get("success", False)):
            with _RUNTIME_OBSERVABILITY_CACHE_LOCK:
                _RUNTIME_OBSERVABILITY_CACHE.update({"ts": time.time(), "value": payload})
    finally:
        with _RUNTIME_OBSERVABILITY_CACHE_LOCK:
            _RUNTIME_OBSERVABILITY_CACHE["refreshing"] = False
            _RUNTIME_OBSERVABILITY_CACHE["thread"] = None
            _RUNTIME_OBSERVABILITY_CACHE["refresh_started_ts"] = 0.0


def _schedule_runtime_observability_refresh() -> bool:
    with _RUNTIME_OBSERVABILITY_CACHE_LOCK:
        if bool(_RUNTIME_OBSERVABILITY_CACHE.get("refreshing", False)):
            return False
        thread = threading.Thread(
            target=_refresh_runtime_observability_cache_worker,
            daemon=True,
            name="gui-runtime-observability-refresh",
        )
        _RUNTIME_OBSERVABILITY_CACHE["refreshing"] = True
        _RUNTIME_OBSERVABILITY_CACHE["thread"] = thread
        _RUNTIME_OBSERVABILITY_CACHE["refresh_started_ts"] = time.time()
        thread.start()
        return True


@app.get("/api/runtime/observability")
def get_runtime_observability(force_refresh: bool = False):
    if force_refresh:
        payload = _build_runtime_observability_payload()
        if isinstance(payload, dict) and bool(payload.get("success", False)):
            with _RUNTIME_OBSERVABILITY_CACHE_LOCK:
                _RUNTIME_OBSERVABILITY_CACHE.update(
                    {
                        "ts": time.time(),
                        "value": payload,
                        "refreshing": False,
                        "thread": None,
                        "refresh_started_ts": 0.0,
                    }
                )
            return payload
    cache_now = time.time()
    schedule_refresh_after_unlock = False
    refresh_scheduled = False
    with _RUNTIME_OBSERVABILITY_CACHE_LOCK:
        cached_payload = _RUNTIME_OBSERVABILITY_CACHE.get("value")
        cache_age = cache_now - float(_RUNTIME_OBSERVABILITY_CACHE.get("ts", 0.0) or 0.0)
        refreshing = bool(_RUNTIME_OBSERVABILITY_CACHE.get("refreshing", False))
        if refreshing:
            thread = _RUNTIME_OBSERVABILITY_CACHE.get("thread")
            started_ts = float(_RUNTIME_OBSERVABILITY_CACHE.get("refresh_started_ts", 0.0) or 0.0)
            thread_alive = bool(getattr(thread, "is_alive", lambda: False)()) if thread is not None else False
            stuck = started_ts > 0 and (cache_now - started_ts) >= _RUNTIME_OBSERVABILITY_REFRESH_STUCK_SEC
            if (thread is not None and not thread_alive) or stuck:
                _RUNTIME_OBSERVABILITY_CACHE["refreshing"] = False
                _RUNTIME_OBSERVABILITY_CACHE["thread"] = None
                _RUNTIME_OBSERVABILITY_CACHE["refresh_started_ts"] = 0.0
                refreshing = False
                schedule_refresh_after_unlock = True

    if schedule_refresh_after_unlock:
        refresh_scheduled = _schedule_runtime_observability_refresh()

    if cached_payload is not None and cache_age < _RUNTIME_OBSERVABILITY_CACHE_TTL_SEC:
        return cached_payload
    if cached_payload is not None:
        if not refreshing and not refresh_scheduled:
            _schedule_runtime_observability_refresh()
        stale_payload = dict(cached_payload)
        stale_payload["stale"] = True
        stale_payload["cacheAgeSec"] = round(cache_age, 1)
        return stale_payload

    if not refreshing:
        _schedule_runtime_observability_refresh()
    return {
        "success": True,
        "warming": True,
        "stale": True,
        "warning": "runtime observability cache warming",
        "gateway": {},
        "interpreter": {},
        "adaptiveProfile": {},
        "performance": {},
        "trainingGate": {},
        "autoGovernance": {},
        "goalTargets": {},
        "evolutionEvidence": {},
        "autonomyLoop": {},
        "sessionRiskLens": {},
        "directionQuality": {},
        "macroPolling": {},
        "memory": {},
        "confidenceCalibration": {},
        "defenseMetrics": {},
        "trailingTelemetry": {},
    }


def _build_strategy_workbench_response():
    status_payload = get_status()
    marketplace_payload = _build_strategy_marketplace_payload(status_payload=status_payload)
    return _attach_projection_metadata(
        {
        "success": True,
        "execution": _build_execution_chain_snapshot(
            status_payload=status_payload,
            marketplace_payload=marketplace_payload,
        ),
        "risk": _build_risk_governor_snapshot(status_payload=status_payload),
        "marketplace": marketplace_payload,
        "timeline": _get_timeline_payload_cached(limit=40),
        },
        snapshot_name="strategy_workbench",
        source_paths={
            "strategy_market_state": _resolve_runtime_artifact("config", "strategy_market_state.json"),
            "effective_params": _resolve_runtime_artifact("logs", "effective_params.json"),
            "execution_state": _resolve_runtime_artifact("data", "execution_state.json"),
            "position_lifecycle": _resolve_runtime_artifact("data", "position_lifecycle.json"),
            "blackboard_state": _resolve_runtime_root_artifact("blackboard_state.json"),
        },
    )


@app.get("/api/workbench/strategy")
def get_strategy_workbench(force_refresh: bool = False):
    warming_payload = {
        "success": True,
        "execution": {"summary": {}, "openPositions": [], "pendingSignals": [], "activeSignals": [], "recentSignals": []},
        "risk": {},
        "marketplace": {},
        "timeline": {"success": True, "events": [], "sources": []},
    }
    return _get_cached_aux_payload(
        _STRATEGY_WORKBENCH_CACHE,
        _STRATEGY_WORKBENCH_CACHE_TTL_SEC,
        _build_strategy_workbench_response,
        "gui-strategy-workbench-refresh",
        warming_payload,
        force_refresh=force_refresh,
    )


def _build_strategy_workbench_marketplace_response():
    status_payload = get_status()
    return _attach_projection_metadata(
        {
            "success": True,
            "marketplace": _build_strategy_marketplace_payload(status_payload=status_payload),
        },
        snapshot_name="strategy_marketplace",
        source_paths=_runtime_projection_source_paths(),
    )


@app.get("/api/workbench/strategy/marketplace")
def get_strategy_workbench_marketplace(force_refresh: bool = False):
    warming_payload = {
        "success": True,
        "marketplace": {
            "strategyTemplates": [],
            "runtimeInstances": [],
            "orchestration": {},
            "current": {},
        },
    }
    return _get_cached_aux_payload(
        _STRATEGY_MARKETPLACE_CACHE,
        _STRATEGY_MARKETPLACE_CACHE_TTL_SEC,
        _build_strategy_workbench_marketplace_response,
        "gui-strategy-marketplace-refresh",
        warming_payload,
        force_refresh=force_refresh,
    )


def _build_runtime_instances_response():
    status_payload = get_status()
    return _attach_projection_metadata(
        _build_runtime_operations_payload(status_payload=status_payload),
        snapshot_name="runtime_instances",
        source_paths=_runtime_projection_source_paths(),
    )


@app.get("/api/strategy/runtime/instances")
def list_runtime_instances(force_refresh: bool = False):
    warming_payload = {
        "success": True,
        "runtimeInstances": [],
        "runtimeScheduler": {},
        "runtimeExplain": {},
        "runtimeAgentDiagnostics": {},
        "runtimeBridgeQueue": {},
        "runtimeBridgeRequests": [],
        "runtimeExecutionFocus": [],
        "runtimeBridgeHealth": {},
        "runtimeStatus": {},
        "orchestrationSnapshot": {},
    }
    return _get_cached_aux_payload(
        _RUNTIME_INSTANCES_CACHE,
        _RUNTIME_INSTANCES_CACHE_TTL_SEC,
        _build_runtime_instances_response,
        "gui-runtime-instances-refresh",
        warming_payload,
        force_refresh=force_refresh,
    )


@app.get("/api/strategy/runtime/evaluate")
def get_runtime_ai_evaluation():
    state = _load_runtime_ai_evaluation_state()
    history = state.get("history", []) if isinstance(state.get("history", []), list) else []
    return {
        "success": True,
        "evaluation": state.get("lastEvaluation", {}) if isinstance(state.get("lastEvaluation", {}), dict) else {},
        "history": history[:8],
        "updatedAt": str(state.get("updatedAt", "") or ""),
    }


@app.post("/api/strategy/runtime/evaluate")
def evaluate_runtime_ai_combination(req: RuntimeStrategyEvaluationRequest):
    status_payload = get_status()
    runtime_payload = _build_runtime_operations_payload(status_payload=status_payload)
    evaluation = _build_runtime_ai_evaluation(
        str(req.goal or "").strip(),
        status_payload=status_payload,
        runtime_payload=runtime_payload,
    )
    state = _load_runtime_ai_evaluation_state()
    history = state.get("history", []) if isinstance(state.get("history", []), list) else []
    history_entry = evaluation.get("historyEntry", {}) if isinstance(evaluation.get("historyEntry", {}), dict) else {}
    if history_entry:
        history = [history_entry, *[item for item in history if isinstance(item, dict)]][:8]
    _save_runtime_ai_evaluation_state(
        {
            "lastEvaluation": evaluation,
            "history": history,
        }
    )
    return {
        "success": True,
        "evaluation": evaluation,
        "history": history,
    }


@app.post("/api/training/promotion_check")
def run_training_promotion_check():
    payload = get_training_approval_chain()
    decision = payload.get("decision", {})
    return {
        "success": True,
        "stage": payload.get("stage", "NO_SUGGESTION"),
        "decision": decision,
        "message": decision.get("nextStep", "审批链校验完成"),
    }


@app.post("/api/training/manual_review/complete")
def complete_training_manual_review():
    try:
        from src.param_flow import complete_effective_review
        complete_effective_review()
    except FileNotFoundError:
        return {"success": False, "message": "当前没有待复核的生效参数"}
    except Exception as e:
        return {"success": False, "message": f"完成人工复核失败: {e}"}

    approval = get_training_approval_chain()
    return {
        "success": True,
        "message": "人工复核已完成，当前参数已解除 review_pending",
        "decision": approval.get("decision", {}),
    }


@app.post("/api/training/manual_review/rollback")
def rollback_training_manual_review():
    try:
        from src.param_flow import rollback_effective_params

        result = rollback_effective_params(
            source="manual_training_review",
            reason="user_requested_training_rollback",
        )
    except FileNotFoundError:
        return {"success": False, "message": "当前没有可回滚的稳定参数快照"}
    except Exception as e:
        return {"success": False, "message": f"训练参数回滚失败: {e}"}

    approval = get_training_approval_chain()
    return {
        "success": True,
        "message": "已回滚到上一版稳定实盘参数",
        "rollback": result,
        "decision": approval.get("decision", {}),
    }


@app.post("/api/training/repair_blockers")
def repair_training_blockers():
    actions: List[str] = []
    status_updates = _read_training_status_payload()

    try:
        from src.offline_train import evaluate_training_readiness
        from src.learning.transition_audit import run_repair_cycle

        repair_cycle = run_repair_cycle(auto_recover=True)
        actions.append("repair_cycle")
        gate = evaluate_training_readiness(repair_cycle=repair_cycle)
        status_updates["qualityGate"] = gate
        status_updates["readyForTraining"] = gate.get("ready_for_training", False)
        status_updates["blockedReasons"] = gate.get("blocked_reasons", [])
        status_updates["closureCoveragePct"] = gate.get("closure_coverage_pct", 0.0)
        status_updates["criticalAnomalies"] = gate.get("critical_anomalies", 0)
        status_updates["recoveredClosedRatioPct"] = gate.get("recovered_closed_ratio_pct", 0.0)
        recovery = gate.get("recovery", {}) if isinstance(gate.get("recovery"), dict) else {}
        status_updates["naturalClosedRatioPct"] = recovery.get("natural_closed_ratio_pct", 0.0)
        status_updates["naturalClosedTransitions"] = recovery.get("natural_closed_transitions", 0)
        status_updates["recoveredClosedTransitions"] = recovery.get("recovered_closed_transitions", 0)
        status_updates["usableTradeCount"] = int(gate.get("usable_trade_count", 0) or 0)
        status_updates["excludedIncompleteCount"] = int(gate.get("excluded_incomplete_count", 0) or 0)
        status_updates["excludedRecoveredCount"] = int(gate.get("excluded_recovered_count", 0) or 0)
        status_updates["samplePolicy"] = gate.get("sample_policy", {})
        status_updates["qualitySnapshotId"] = gate.get("snapshot_id", "")
        status_updates["qualitySnapshotAt"] = gate.get("generated_at", "")
        status_updates["repairCycle"] = gate.get("repair_cycle", {})
        status_updates["blocked"] = not gate.get("ready_for_training", False)
    except Exception as e:
        return {"success": False, "message": f"修复训练阻断项失败: {e}"}

    shadow_file = APP_DIR / "config" / "params_shadow.json"
    if not shadow_file.exists():
        approval = get_training_approval_chain()
        candidate = (
            _extract_param_map(approval.get("suggestion", {}))
            or _extract_param_map(approval.get("appliedSuggestion", {}))
            or _extract_param_map(approval.get("effectiveParams", {}))
        )
        if candidate:
            from src.param_flow import build_shadow_payload, write_json
            shadow_payload = build_shadow_payload({"suggested_params": candidate, "source": "repair_training_blockers"})
            write_json(shadow_file, shadow_payload)
            actions.append("rebuild_shadow_params")

    status_updates["updatedAt"] = datetime.datetime.now().isoformat()
    _write_training_status_payload(status_updates)
    approval = get_training_approval_chain()
    return {
        "success": True,
        "message": "阻断项修复已执行",
        "actions": actions,
        "decision": approval.get("decision", {}),
    }


@app.post("/api/training/promote")
def promote_training_candidate():
    try:
        from src.rollout_manager import PROMOTION_ORDER, get_promotion_mode, set_promotion_mode
        from src.param_flow import promote_suggestion_to_effective, SUGGESTION_FILE

        approval = get_training_approval_chain()
        decision = approval.get("decision", {}) if isinstance(approval, dict) else {}
        if not decision.get("canPromote"):
            return {"success": False, "message": "当前未满足推进条件", "decision": decision}

        tenant_id = "default"
        current_mode = get_promotion_mode(APP_DIR, tenant_id)
        if current_mode not in PROMOTION_ORDER:
            return {"success": False, "message": f"未知推进模式: {current_mode}"}
        next_index = PROMOTION_ORDER.index(current_mode) + 1
        if next_index >= len(PROMOTION_ORDER):
            return {"success": False, "message": "已经处于最终推进阶段"}

        next_mode = PROMOTION_ORDER[next_index]
        promotion_files = {}
        if SUGGESTION_FILE.exists():
            promotion_files = promote_suggestion_to_effective(source="training_promote")
        audit = set_promotion_mode(APP_DIR, tenant_id, next_mode)
        return {
            "success": True,
            "message": f"已推进到 {next_mode}",
            "promotion": audit,
            "files": promotion_files,
            "decision": decision,
        }
    except Exception as e:
        return {"success": False, "error": str(e)}


@app.post("/api/action/approve_evolution")
def approve_evolution_action():
    try:
        from src.param_flow import promote_suggestion_to_effective
        result = promote_suggestion_to_effective(source="mobile_approve")
        approval = get_training_approval_chain()
        return {
            "success": True,
            "message": "演化建议已进入可回滚观察态，待人工复核后再继续推进",
            "files": result,
            "decision": approval.get("decision", {}),
        }
    except FileNotFoundError:
        return {"success": False, "message": "当前没有待审批的演化建议"}
    except Exception as e:
        return {"success": False, "error": str(e)}


@app.post("/api/action/reject_evolution")
def reject_evolution_action():
    try:
        from src.param_flow import reject_current_suggestion
        result = reject_current_suggestion(source="mobile_reject")
        approval = get_training_approval_chain()
        return {
            "success": True,
            "message": "演化建议已驳回",
            "files": result,
            "decision": approval.get("decision", {}),
        }
    except FileNotFoundError:
        return {"success": False, "message": "当前没有待审批的演化建议"}
    except Exception as e:
        return {"success": False, "error": str(e)}


@app.post("/api/governance/auto_tick")
def run_auto_governance_tick():
    try:
        from src.auto_governance import run_auto_governance_cycle
        result = run_auto_governance_cycle(APP_DIR, "default", source="gui_backend")
        return {"success": True, "result": result}
    except Exception as e:
        return {"success": False, "error": str(e)}

@app.post("/api/chat")
def chat_with_agent(payload: dict):
    """与龙虾 Agent 的交互对话"""
    user_text = payload.get("text", "")
    if not user_text:
        return {"success": False, "reply": "咕噜咕噜..."}

    # 简单的正则或关键词匹配,结合情感化回复
    reply = ""

    # 尝试调用 chat_tuning 来解析一些正式的指令
    try:
        # 确保能 import src 里的模块
        src_path = str(_SRC_DIR)
        if src_path not in sys.path:
            sys.path.insert(0, src_path)

        from src.chat_tuning import parse_command
        parsed = parse_command(user_text)
        if parsed:
            intent, value = parsed
            # 假装执行了并给一个可爱的反馈
            if intent == "SET_MAX_RISK_PER_TRADE":
                reply = f"收到老板!已经把单笔风险调整到 {value}% 啦!🦞 冲冲冲!"
                # 实际可以更新 config
                config_file = CONFIG_DIR / "risk_governor_rules.json"
                if config_file.exists():
                    data = json.loads(config_file.read_text())
                    data["max_position_size"] = float(value)
                    config_file.write_text(json.dumps(data, indent=2))
            elif intent == "EMERGENCY_STOP":
                reply = "🚨 紧急刹车!我已经把交易网关停了!老板放心!"
            else:
                reply = f"好的老板,执行指令: {intent} = {value} 🦞"
    except Exception as e:
        pass

    if not reply:
        if "行情" in user_text or "看看" in user_text:
            bid, ask, spr, price_source, price_age = get_price()
            reply = f"正在盯盘呢!当前黄金价格 {bid},点差 {spr},随时准备出击!✂️"
        elif "降低仓位" in user_text or "保守" in user_text:
            reply = "没问题,我已经把风控调到【保守模式】啦!安全第一!🛡️"
        elif "激进" in user_text or "干" in user_text:
            reply = "收到!风控已调至【激进模式】!今天我们要大赚一笔!💰"
        else:
            # 调用 DeepSeek LLM
            api_key = os.environ.get("DEEPSEEK_API_KEY", "")
            if not api_key:
                app_config = config_manager.load_app_config()
                api_key = app_config.get("DEEPSEEK_API_KEY", "")

            if not api_key:
                reply = "咕噜咕噜... 老板,我没有配置大模型 API Key 呢,请先在配置中设置 DEEPSEEK_API_KEY!"
            else:
                try:
                    import requests
                    resp = requests.post(
                        "https://api.deepseek.com/v1/chat/completions",
                        headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
                        json={
                            "model": "deepseek-v4-flash",
                            "messages": [
                                {"role": "system", "content": "你是 OpenClaw 量化交易系统的 AI 助手(龙虾Agent),你的性格是专业、机智且带有一点幽默感。你可以回答关于黄金交易、当前系统状态的问题。你的回答应该简明扼要。"},
                                {"role": "user", "content": user_text}
                            ],
                            "max_tokens": 500,
                            "temperature": 0.7,
                        },
                        timeout=15,
                    )
                    resp.raise_for_status()
                    data = resp.json()
                    reply = data["choices"][0]["message"]["content"].strip()
                except Exception as e:
                    reply = f"调用大模型失败啦:{str(e)}"

    return {
        "success": True,
        "reply": reply,
        "timestamp": datetime.datetime.now().strftime("%H:%M:%S")
    }

# ======== 挂载前端静态页面 (Web GUI) ========
# 判断是否有编译好的前端 dist 目录
# 兼容 PyInstaller 的 _MEIPASS 机制；非打包环境优先读取当前运行副本旁边的前端产物，
# 避免 launchd 后台进程再去访问 Documents 下的源码目录而触发 TCC 权限拦截。
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
    FRONTEND_DIST = pathlib.Path(sys._MEIPASS) / "gui_react" / "dist"
else:
    frontend_candidates = [
        RELEASE_ROOT / "gui_react" / "dist",
        APP_DIR / "gui_react" / "dist",
        pathlib.Path(__file__).resolve().parent / "gui_react" / "dist",
    ]
    FRONTEND_DIST = next((path for path in frontend_candidates if _is_readable_dir(path)), frontend_candidates[0])


def _frontend_warming_response() -> HTMLResponse:
    return HTMLResponse(
        content="""<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="refresh" content="2" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>OpenClaw AutoTrade</title>
    <style>
      body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #0b1020; color: #e5e7eb; display: grid; place-items: center; min-height: 100vh; margin: 0; }
      main { max-width: 520px; padding: 24px; text-align: center; }
      h1 { font-size: 20px; margin-bottom: 12px; }
      p { line-height: 1.5; color: #94a3b8; }
    </style>
  </head>
  <body>
    <main>
      <h1>OpenClaw AutoTrade is starting</h1>
      <p>The web UI is warming up. This page will retry automatically.</p>
    </main>
  </body>
</html>""",
        status_code=503,
    )


def _safe_frontend_file_response(path: pathlib.Path) -> Union[HTMLResponse, FileResponse]:
    if _is_readable_file(path):
        headers = None
        if path.suffix.lower() in {".html", ".htm"}:
            # Force desktop WebKit/browser shells to reload the latest index shell after each deploy.
            headers = {
                "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
                "Pragma": "no-cache",
                "Expires": "0",
            }
        return FileResponse(str(path), headers=headers)
    return _frontend_warming_response()


if FRONTEND_DIST.exists():
    # 挂载静态资源文件 (js, css 等)
    frontend_assets_dir = FRONTEND_DIST / "assets"
    if _is_readable_dir(frontend_assets_dir):
        app.mount("/assets", StaticFiles(directory=str(frontend_assets_dir)), name="assets")
    prototype_index = pathlib.Path(__file__).resolve().parent / "gui_prototype" / "index.html"

    # 将根路径 / 映射到 index.html
    @app.get("/")
    def serve_frontend():
        return _safe_frontend_file_response(FRONTEND_DIST / "index.html")

    @app.get("/ops-console")
    def serve_ops_console():
        if _is_readable_file(prototype_index):
            return FileResponse(str(prototype_index))
        return _safe_frontend_file_response(FRONTEND_DIST / "index.html")

    # 处理 React Router 的前端路由及 public 目录下的静态文件
    @app.get("/{catchall:path}")
    def serve_frontend_fallback(catchall: str):
        if not catchall.startswith("api/"):
            # 如果请求的是真实存在的文件(如 /office_bg.png),则直接返回该文件
            file_path = FRONTEND_DIST / catchall
            if _is_readable_file(file_path):
                return FileResponse(str(file_path))
            # 否则返回 index.html 交给前端路由处理
            return _safe_frontend_file_response(FRONTEND_DIST / "index.html")
# =======================================

if __name__ == "__main__":
    import uvicorn
    import multiprocessing
    import sys

    # CRITICAL: must be called before ANY module that might fork (import config_manager etc.)
    if getattr(sys, 'frozen', False):
        multiprocessing.freeze_support()

    # Auto-mode: launched by the main GUI binary as a subprocess (daemon / macro / train).
    # The main GUI process passes --auto so we know we're in a spawned subprocess context.
    _is_auto_spawned = "--auto" in sys.argv

    # 拦截被 subprocess 拉起的实盘守护命令（统一入口为 pipeline）
    if "--daemon-pipeline" in sys.argv:
        import subprocess
        from pathlib import Path
        app_dir = Path(__file__).resolve().parent
        cmd = _build_live_pipeline_cmd(app_dir)
        env = os.environ.copy()
        existing_pythonpath = str(env.get("PYTHONPATH", "") or "").strip()
        app_dir_str = str(app_dir)
        if existing_pythonpath:
            env["PYTHONPATH"] = f"{app_dir_str}:{existing_pythonpath}"
        else:
            env["PYTHONPATH"] = app_dir_str

        proc = subprocess.Popen(cmd, start_new_session=True, env=env)
        try:
            PID_FILE.write_text(str(proc.pid), encoding="utf-8")
        except Exception:
            pass
        sys.exit(0)

    if "--run-macro" in sys.argv:
        from src.deepseek_agent import run_macro_analysis
        pid = os.getpid()
        force_refresh = os.environ.get("OPENCLAW_FORCE_MACRO_REFRESH", "0") == "1"
        try:
            run_macro_analysis(force_refresh=force_refresh)
            _finish_macro_refresh_status(
                "completed",
                "宏观方向刷新完成",
                pid=pid,
                force=force_refresh,
            )
        except Exception as exc:
            _finish_macro_refresh_status(
                "failed",
                f"宏观方向刷新失败: {exc}",
                pid=pid,
                force=force_refresh,
            )
            raise
        sys.exit(0)
        
    if "--run-notify" in sys.argv:
        from bridge.review_notify import run as notify_run, run_text_notify
        if "--text" in sys.argv:
            idx = sys.argv.index("--text")
            msg = " ".join(sys.argv[idx+1:])
            run_text_notify(msg)
        else:
            notify_run()
        sys.exit(0)
        
    if "--run-briefing" in sys.argv:
        from bridge.morning_briefing import generate_morning_briefing
        generate_morning_briefing()
        sys.exit(0)
        
    if "--run-evo" in sys.argv:
        from src.strategy_evolution import main as evo_main
        evo_main()
        sys.exit(0)

    # 拦截被 subprocess 拉起的 --train 命令
    if "--train" in sys.argv:
        from src.offline_train import main as train_main
        # 移除前面的参数,保留 argparse 需要的
        idx = sys.argv.index("--train")
        sys.argv = [sys.argv[0]] + sys.argv[idx+1:]
        train_main()
        sys.exit(0)

    # 拦截被 subprocess 拉起的 --analyze 命令
    if "--analyze" in sys.argv:
        from src.strategy_evolution import run_analyze_only
        run_analyze_only()
        sys.exit(0)

    # 为了打包后能正常找到静态文件或绑定端口
    os.environ["PATH"] += os.pathsep + os.path.dirname(sys.executable)
    # 将默认 host 从 127.0.0.1 改为 0.0.0.0，彻底解决 Mac/Windows 网络堆栈限制导致的 Not Found 问题
    host = os.getenv("GUI_HOST", "0.0.0.0")
    port = int(os.getenv("GUI_PORT", "18790"))
    _acquire_gui_singleton_lock()
    atexit.register(_release_gui_singleton_lock)

    # 去掉 reload=True,因为它会导致在有些环境中被外部信号误杀

    # 自动打开浏览器 (macOS: 优先用 open 命令通过系统默认浏览器打开)
    def open_browser():
        import webbrowser
        import subprocess
        url = f"http://127.0.0.1:{port}"
        # 等待 Uvicorn 完全绑定端口
        for _ in range(30):  # 最多等 3 秒
            try:
                import socket
                s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
                s.settimeout(0.5)
                s.connect((host, port))
                s.close()
                break
            except Exception:
                time.sleep(0.1)
        try:
            # macOS: 用 open 命令唤起系统默认浏览器（支持 Chrome/Safari/Waterfox 等）
            if sys.platform == "darwin":
                subprocess.run(["open", url], capture_output=True, timeout=10)
            else:
                webbrowser.open(url)
        except Exception:
            pass

    import threading
    threading.Thread(target=open_browser, daemon=True).start()

    uvicorn.run(app, host=host, port=port, access_log=False)
