#!/usr/bin/env python3
"""
Commander Client — direct DeepSeek API caller for macro direction decisions.

Called by gold_trader._spawn_macro_direction_process() to get AI-driven
macro direction using configured LLM provider.
"""

import json
import logging
import math
import os
import random
import time
from pathlib import Path
from typing import Any, Optional

logger = logging.getLogger(__name__)

from bridge.voting_aggregator import aggregate_votes

import config_manager

try:
    from src.agent_role_router import ROLE_TREND_ANALYST
except ModuleNotFoundError:
    import sys, pathlib
    sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
    from src.agent_role_router import ROLE_TREND_ANALYST

_MAX_RETRIES = 3
_RETRY_BASE_DELAY = 1.0
_RETRY_MAX_DELAY = 9.0

# Track last successfully resolved direction for fallback persistence
_LAST_GOOD_DIRECTION: dict[str, Any] = {
    "direction": "NEUTRAL",
    "confidence": 0.55,
    "reason": "startup_default",
    "updated_ts": 0.0,
}

from src.json_utils import extract_json_block as _extract_json

def _is_transient_error(error: str) -> bool:
    """判断是否为可重试的临时性错误。"""
    el = error.lower()
    if "timeout" in el or "timed out" in el:
        return True
    if "connection" in el or "dns" in el or "name resolution" in el:
        return True
    if "rate limit" in el or "429" in el:
        return True
    if "500" in el or "502" in el or "503" in el:
        return True
    if "no json found" in el or "json.decoder" in el:
        return True
    if "empty response" in el or "content" in el:
        return True
    if "eof" in el or "reset" in el:
        return True
    return False

def _recent_direction_performance(lookback_trades: int = 20) -> dict[str, Any]:
    """Read trading ledger and compute recent win rate by direction."""
    result = {"long_win_rate": None, "short_win_rate": None, "total_trades": 0, "summary": ""}
    ledger_paths = [
        Path(__file__).resolve().parent.parent / "logs" / "trading_ledger.ndjson",
    ]
    ledger_path = None
    for lp in ledger_paths:
        if lp.exists():
            ledger_path = lp
            break
    if not ledger_path:
        return result

    try:
        lines = ledger_path.read_text(encoding="utf-8").strip().split("\n")
        trades = []
        for line in lines[-lookback_trades * 3:]:  # over-fetch, filter CLOSE only
            if not line.strip():
                continue
            try:
                entry = json.loads(line)
                if entry.get("action") == "CLOSE" and entry.get("pnl") is not None:
                    trades.append(entry)
            except json.JSONDecodeError:
                continue

        recent = trades[-lookback_trades:]
        if not recent:
            return result

        buys = [t for t in recent if str(t.get("side", "")).upper() == "BUY"]
        sells = [t for t in recent if str(t.get("side", "")).upper() == "SELL"]
        buy_wins = [t for t in buys if float(t.get("pnl", 0)) > 0]
        sell_wins = [t for t in sells if float(t.get("pnl", 0)) > 0]

        long_wr = len(buy_wins) / len(buys) if buys else None
        short_wr = len(sell_wins) / len(sells) if sells else None

        buy_total_pnl = sum(float(t.get("pnl", 0)) for t in buys)
        sell_total_pnl = sum(float(t.get("pnl", 0)) for t in sells)

        lines_summary = []
        if long_wr is not None:
            lines_summary.append(
                f"LONG (BUY): {len(buy_wins)}/{len(buys)} wins ({long_wr:.0%}), total PnL={buy_total_pnl:+.1f}"
            )
        if short_wr is not None:
            lines_summary.append(
                f"SHORT (SELL): {len(sell_wins)}/{len(sells)} wins ({short_wr:.0%}), total PnL={sell_total_pnl:+.1f}"
            )

        result = {
            "long_win_rate": long_wr,
            "short_win_rate": short_wr,
            "total_trades": len(recent),
            "buy_trades": len(buys),
            "sell_trades": len(sells),
            "buy_total_pnl": buy_total_pnl,
            "sell_total_pnl": sell_total_pnl,
            "summary": " | ".join(lines_summary) if lines_summary else "",
        }
    except Exception:
        pass
    return result

def _build_brain_context(current_regime: str, current_session: str = "") -> str:
    """Build a context string from learning_brain for LLM prompt injection."""
    try:
        from src.learning.trading_brain import get_brain
        brain = get_brain()
        stats = brain.query_recent_trades(30)
        if not stats:
            return ""

        total = len(stats)
        wins = sum(1 for t in stats if t.get("is_win"))
        wr = wins / max(1, total)

        aligned = [t for t in stats if t.get("agent_aligned")]
        counter = [t for t in stats if not t.get("agent_aligned")]
        aligned_wr = sum(1 for t in aligned if t.get("is_win")) / max(1, len(aligned))
        counter_wr = sum(1 for t in counter if t.get("is_win")) / max(1, len(counter))

        lines = [
            f"\n[Learning Brain — Recent {total} Trades]",
            f"Overall win rate: {wr:.0%} ({wins}/{total})",
            f"Agent-aligned win rate: {aligned_wr:.0%} (n={len(aligned)})",
            f"Counter-trend win rate: {counter_wr:.0%} (n={len(counter)})",
        ]

        # Regime-specific
        if current_regime:
            rs = brain.query_by_regime_session()
            for g in rs:
                if g.get("regime") == current_regime:
                    lines.append(
                        f"Current regime ({current_regime}) win rate: "
                        f"{g.get('win_rate', 0):.0%} (n={g.get('n', 0)})"
                    )

        # Hermes context
        try:
            import json
            hpath = config_manager.get_logs_dir() / "hermes_macro_context.json"
            if hpath.exists():
                hdata = json.loads(hpath.read_text(encoding="utf-8"))
                recs = hdata.get("recommended_patterns", []) if isinstance(hdata, dict) else []
                avoids = hdata.get("avoid_patterns", []) if isinstance(hdata, dict) else []
                if recs:
                    lines.append(f"Hermes recommends: {'; '.join(str(r) for r in recs[:3])}")
                if avoids:
                    lines.append(f"Hermes warns to avoid: {'; '.join(str(a) for a in avoids[:3])}")
        except Exception:
            pass

        # Judgment accuracy
        try:
            acc = brain.query_judgment_accuracy()
            if acc.get("n", 0) >= 3:
                lines.append(f"Your historical direction accuracy: {acc['accuracy']:.0%} (n={acc['n']})")
        except Exception:
            pass

        # Factor leaderboard (top profit drivers + decay warnings)
        try:
            from src.learning.factor_scorer import score_all_factors
            factors = score_all_factors()
            if factors:
                top3 = [f for f in factors[:3] if f["importance_score"] >= 5.0]
                bottom3 = [f for f in factors[-3:] if f["importance_score"] <= 5.0]
                if top3:
                    names = ", ".join(f"{f['feature']}({f['importance_score']:.0f})" for f in top3)
                    lines.append(f"Profit drivers: {names}")
                if bottom3:
                    names = ", ".join(f"{f['feature']}({f['importance_score']:.0f})" for f in bottom3)
                    lines.append(f"Drag factors: {names}")
                # Decay warnings
                decaying = [f for f in factors if f.get("trend") in ("warning", "critical")]
                if decaying:
                    names = ", ".join(f"{f['feature']}({f.get('change_pct', 0):+.0f}%)" for f in decaying[:3])
                    lines.append(f"Factor decay warning: {names} — reduce reliance on these")
        except Exception:
            pass

        # Recent judgment case history (wrong-answer book)
        try:
            cases = brain.query_recent_judgment_cases(regime=current_regime, limit=5)
            if cases:
                lines.append("\n[Your Recent Judgment History — Learn From Past Mistakes]")
                for c in cases:
                    correct = "CORRECT" if c.get("direction_correct") == 1 else "WRONG"
                    drift = c.get("price_drift_pct", 0) or 0
                    lines.append(
                        f"  {c['judged_at']}: {c['direction']}(conf={c['confidence']:.0%}) "
                        f"in {c.get('regime','?')} → {correct} (drift={drift:+.2f}%)"
                    )
                wrongs = [c for c in cases if c.get("direction_correct") == 0]
                if wrongs:
                    lines.append(
                        f"  WARNING: You were WRONG {len(wrongs)}/{len(cases)} times. "
                        f"Adjust your reasoning — do not repeat the same mistakes."
                    )
        except Exception:
            pass

        return "\n".join(lines) + "\n"
    except Exception:
        return ""

def _wilson_bound(k: int, n: int, z: float, upper: bool = False) -> float:
    """Wilson score interval bound for a binomial proportion.

    More conservative than normal approximation for small samples.
    """
    if n <= 0:
        return 0.0
    p = k / n
    z2 = z * z / n
    denom = 1.0 + z2
    centre = (p + z * z / (2.0 * n)) / denom
    margin = z * math.sqrt((p * (1.0 - p) + z * z / (4.0 * n)) / n) / denom
    if upper:
        return min(1.0, centre + margin)
    return max(0.0, centre - margin)

def _apply_meta_override(llm_decision: dict, current_conditions: dict) -> dict:
    """Override LLM direction/confidence using historical win-rate data.

    Queries trading_brain for past trades matching the current regime+session+direction
    combination. If historical data is reliable and shows poor performance,
    the LLM decision is downgraded or vetoed.
    """
    from src.learning.meta_decision import get_threshold as _gd

    conditions = {
        "regime": current_conditions.get("regime", ""),
        "session": current_conditions.get("session", ""),
        "agent_direction": llm_decision.get("direction", "NEUTRAL"),
    }

    brain = get_brain()
    stats = brain.query_composite_win_rate(conditions, min_samples=10)
    n = stats.get("n", 0)
    wr = stats.get("win_rate", 0.5)
    net_pnl = stats.get("net_pnl", 0.0)
    profit_factor = stats.get("profit_factor", 0.0)

    if n < 10:
        return llm_decision

    decision = dict(llm_decision)
    conf = float(decision.get("confidence", 0.5))

    regime = str(current_conditions.get("regime", "") or "").upper()
    if "TREND" in regime or "STRONG" in regime:
        vmult = _gd("veto_mult_trending")
    elif "RANG" in regime or "SIDE" in regime or "UNCERTAIN" in regime:
        vmult = _gd("veto_mult_ranging")
    else:
        vmult = 1.0

    wins = int(round(n * wr))
    conservative_wr = _wilson_bound(wins, n, z=_gd("num_veto_se"))
    optimistic_wr = _wilson_bound(wins, n, z=_gd("num_boost_se"), upper=True)

    veto_threshold = _gd("veto_win_rate") * vmult
    boost_threshold = _gd("boost_win_rate")
    penalty_win_rate = _gd("penalty_win_rate")

    veto_applied = False
    penalty_applied = False
    boost_applied = False

    if conservative_wr < veto_threshold and net_pnl < 0 and profit_factor < 1.0:
        veto_applied = True
        ov_msg = f"VETO(wr={wr:.0%},n={n},pf={profit_factor},pnl={net_pnl:.1f})"
        decision["direction"] = "NEUTRAL"
        decision["confidence"] = round(min(conf, 0.45), 4)
        decision["meta_override"] = ov_msg
        print(f"[commander_client] {ov_msg} — overriding {llm_decision.get('direction','?')}→NEUTRAL")
    elif conservative_wr < veto_threshold + 0.10:
        penalty_applied = True
        penalty = max(0.5, min(1.0, wr / max(0.01, penalty_win_rate)))
        decision["confidence"] = round(conf * penalty, 4)
        decision["meta_override"] = f"PENALTY(wr={wr:.0%},n={n},pf={profit_factor})"
    elif optimistic_wr >= boost_threshold and profit_factor >= 1.2:
        boost_applied = True
        boost = min(0.05, (wr - 0.70) * 0.20)
        decision["confidence"] = round(min(0.95, conf + boost), 4)
        decision["meta_override"] = f"BOOST(wr={wr:.0%},n={n},pf={profit_factor})"

    return decision

def _record_trade_feedback(trade_outcome: dict) -> None:
    """回溯校准 meta_decision 阈值：用真实交易结果判断 override 是否做对了。

    每笔交易结束后调用，用历史数据回测：
    - 如果当时触发了 veto/penalty/boost，这笔交易赢了还是亏了？
    - 调用 record_outcome 记录真实结果
    - 积累够 10 笔后自动漂移调整阈值
    """
    from src.learning.meta_decision import record_outcome as _ro

    side = str(trade_outcome.get("side", "")).upper()
    pnl = float(trade_outcome.get("pnl", 0) or 0)
    meta_override = str(trade_outcome.get("meta_override", "") or "")

    # 有盈利 = 方向做对了（即使小幅盈利也算正确）
    was_correct = pnl > 0

    # 映射 override 类型到阈值 key
    override_type = None
    upper_meta = meta_override.upper()
    if "VETO" in upper_meta:
        override_type = "veto_win_rate"
    elif "PENALTY" in upper_meta:
        override_type = "penalty_win_rate"
    elif "BOOST" in upper_meta:
        override_type = "boost_win_rate"

    if override_type:
        try:
            _ro(override_type, was_applied=True, was_correct=was_correct)
            logger.debug(
                "[_record_trade_feedback] %s applied=true correct=%s pnl=%.2f",
                override_type, was_correct, pnl,
            )
        except Exception:
            pass
    else:
        # 没有 override 时也要记录"未触发"的样本
        # 这样 threshold 也能从"未干预时方向是否正确"中学习
        for threshold_name in ("veto_win_rate", "penalty_win_rate", "boost_win_rate"):
            try:
                _ro(threshold_name, was_applied=False, was_correct=was_correct)
            except Exception:
                pass

def record_trade_feedback_for_thresholds(trade_outcome: dict) -> None:
    """外部接口：交易平仓后调用，传入交易结果进行阈值回溯校准。

    Args:
        trade_outcome: {
            "side": "BUY" or "SELL",
            "pnl": float,  # 盈亏
            "meta_override": str,  # 从决策记录中读取的 override 信息
        }
    """
    _record_trade_feedback(trade_outcome)

def _build_technical_context(current_regime: str) -> str:
    return ""

def _build_macro_context(current_regime: str) -> str:
    try:
        import json, config_manager
        lines = []
        hpath = config_manager.get_logs_dir() / 'hermes_macro_context.json'
        if hpath.exists():
            hdata = json.loads(hpath.read_text(encoding='utf-8'))
            recs = hdata.get('recommended_patterns', [])
            avoids = hdata.get('avoid_patterns', [])
            if recs:
                lines.append("Hermes: " + "; ".join(str(r) for r in recs[:3]))
            if avoids:
                lines.append("Avoid: " + "; ".join(str(a) for a in avoids[:3]))
        from src.learning.factor_scorer import score_all_factors
        factors = score_all_factors()
        if factors:
            top3 = [f for f in factors[:3] if f["importance_score"] >= 5.0]
            decaying = [f for f in factors if f.get("trend") in ("warning", "critical")]
            if top3:
                names = ', '.join(f['feature'] + '(' + str(round(f['importance_score'])) + ')' for f in top3)
                lines.append("Profit drivers: " + names)
            if decaying:
                names = ', '.join(f['feature'] + '(' + str(round(f.get('change_pct', 0))) + '%)' for f in decaying[:3])
                lines.append("Factor decay: " + names)
        if lines:
            return "[Macro Data]\n" + "\n".join(lines) + "\n"
        return ""
    except Exception:
        return ""

def _call_single_perspective(
    name: str, system_prompt: str, user_prompt: str,
    temperature: float = 0.15, max_tokens: int = 2000,
    timeout: int = 120,
) -> tuple:
    """LLM call with retry. Returns (decision_dict|None, error_str)."""
    from src.agent_role_router import ROLE_TREND_ANALYST
    from src.llm import call_agent_llm
    import random
    for attempt in range(3):
        try:
            result = call_agent_llm(
                role=ROLE_TREND_ANALYST,
                system_prompt=system_prompt,
                user_prompt=user_prompt,
                temperature=temperature,
                max_tokens=max_tokens,
                timeout=timeout,
            )
            if result.get("degraded"):
                raise RuntimeError(result.get("degraded_reason", "degraded"))
            decision = _extract_json(result["content"])
            if decision:
                return decision, ""
            raise ValueError("no json")
        except Exception as e:
            err = str(e)
            if _is_transient_error(err) and attempt < 2:
                time.sleep(min(1.0 * (2 ** attempt), 9.0) + random.uniform(0, 1.0))
                continue
            return None, err
    return None, "retries exhausted"
from src.learning.lesson_reflector import get_active_lessons as _get_lessons, _format_lessons_for_prompt as _fmt_lessons, scan_opportunities as _scan_ops
from src.learning.trading_brain import get_brain as _get_brain
def call_commander(
    session_data: Optional[dict] = None,
    structure_snapshot: Optional[dict] = None,
    current_regime: str = "UNKNOWN",
    vol_ratio: float = 1.0,
    current_position: Optional[dict] = None,
    macro_correlation: Optional[dict] = None,
    force_refresh: bool = False,
    allow_short: bool = False,
    symbol: str = "XAUUSD",
) -> dict[str, Any]:
    """
    Call DeepSeek API to determine macro trading direction.

    Returns a dict with: direction, confidence, reason, degraded,
    conviction, timeframe, risk_appetite, macro_regime, key_levels,
    calibrated_confidence, updated_ts.
    """
    # ── Data guard: refuse to call LLM without market context ──
    if session_data is None or not isinstance(session_data, dict) or not session_data.get("all_sessions"):
        import logging
        logging.warning("[commander_client] BLOCKED: session_data missing or empty, skipping LLM call")
        return {
            "direction": "NEUTRAL", "confidence": 0.3, "reason": "No session data — cannot assess market direction",
            "degraded": True, "degraded_reason": "MISSING_SESSION_DATA",
            "conviction": "low", "timeframe": "H4", "risk_appetite": "defensive",
            "macro_regime": "UNKNOWN", "key_levels": {},
            "calibrated_confidence": 0.3, "updated_ts": time.time(),
        }

    all_sessions = session_data.get("all_sessions", {})
    overall_score = session_data.get("overall_score", 0.0)
    overall_direction = session_data.get("overall_direction", "NEUTRAL")
    # Pre-declare variables used in downstream try/except to avoid UnboundLocalError
    regime_label = "UNKNOWN"
    regime_conf = 0.0
    regime_aware_dir = "NEUTRAL"

        # Symbol-aware prompt adaptation
    from src.symbol_resolver import classify_symbol
    sym_meta = classify_symbol(symbol)
    sym_name = str(symbol or "XAUUSD").strip().upper()
    sym_display = sym_meta.get("display_name", sym_name)
    try:
        from src.trading_context import get_trading_context as _get_cmd_ctx
        _cmd_ctx = _get_cmd_ctx()
        sym_display = _cmd_ctx.display_name or sym_name
    except Exception:
        pass
    sym_category = sym_meta.get("category_cn", "")
    sym_characteristics = sym_meta.get("characteristics", "")
    sym_macro_drivers = sym_meta.get("macro_drivers", "")
    sym_dxy_relevant = sym_meta.get("dxy_relevant", False)

    pos_info = ""
    if isinstance(current_position, dict) and current_position.get("entry_price", 0) > 0:
        side = str(current_position.get("side", "BUY")).upper()
        entry = float(current_position.get("entry_price", 0))
        age_sec = float(current_position.get("age_sec", 0))
        sl = float(current_position.get("sl", 0))
        tp = float(current_position.get("tp", 0))
        cur_bid = float(current_position.get("current_bid", 0))
        cur_ask = float(current_position.get("current_ask", 0))
        fp_points = float(current_position.get("floating_pnl_points", 0))
        fp_pct = float(current_position.get("floating_pnl_pct", 0))
        fp_atr = float(current_position.get("floating_pnl_atr", 0))
        age_h = age_sec / 3600
        age_min = age_sec / 60
        pnl_status = "PROFIT" if fp_points >= 0 else "LOSS"
        pos_info = f"""
Current Open Position (IMPORTANT — factor into your direction decision):
  Side: {side}
  Entry: {entry:.2f}
  Current Bid: {cur_bid:.2f} / Ask: {cur_ask:.2f}
  Floating PnL: {fp_points:+.2f} pts ({fp_pct:+.4f}%) = {pnl_status}
  PnL in ATR: {fp_atr:+.2f}x ATR (1 ATR ≈ normal hourly range)
  SL: {sl:.2f} | TP: {tp:.2f}
  Age: {age_h:.1f}h ({age_min:.0f}min)
Strategic Hint: {"Position is in profit — the trend is working. Consider maintaining/raising confidence." if fp_points >= 0 else "Position is underwater — trend may be against us. Consider reducing confidence or going NEUTRAL." if fp_points < 0 else ""}
"""
    elif isinstance(current_position, dict) and current_position:
        pos_info = f"\nCurrent Position (limited data): {json.dumps(current_position, default=str)}"

    # Build macro correlation section for the prompt
    corr_section = ""
    if isinstance(macro_correlation, dict) and macro_correlation.get("source"):
        corr = macro_correlation
        corr_section = f"""
Macro Correlation Data (DXY USD Index vs {sym_display}):
  DXY Price: {corr.get('dxy_price', 'N/A')}
  DXY Direction: {corr.get('dxy_direction', 'N/A')} (ROC: {corr.get('dxy_roc_pct', 0):.4f}%, Z-score: {corr.get('dxy_roc_zscore', 0):.2f})
  DXY-{sym_name} Correlation: {corr.get('correlation_dxy_gold', 0):.3f} (active: {corr.get('correlation_active', False)})
  {sym_display} Implication: {corr.get('gold_implication', 'neutral')} (headwind = USD strength = pressure, tailwind = USD weakness = supportive)
  Signal Confidence: {corr.get('confidence', 0):.2f}, Noise Level: {corr.get('noise_level', 'high')}
  Session Weight: {corr.get('session_weight', 1.0):.1f}
"""

    # 根据 allow_short 开关构建不同的方向指令
    if allow_short:
        direction_rule = """IMPORTANT: You are a macro strategist. You may output LONG, NEUTRAL, or SHORT.
When the data shows a clear bearish trend (negative scores, bearish EMA alignment, headwind), output SHORT with conviction — do not default to NEUTRAL.
When macro correlation shows "headwind" with high confidence and low noise, increase SHORT conviction.
When macro correlation shows "tailwind" with high confidence and low noise, increase LONG conviction.
Provide your strategic direction (LONG, NEUTRAL, or SHORT), a confidence score (0.0 to 1.0),"""
    else:
        direction_rule = """IMPORTANT: You are a LONG-ONLY macro strategist. Output LONG when bullish evidence is clear, or NEUTRAL otherwise.
When the data is clearly bearish, output NEUTRAL with low confidence (0.50-0.55).
When the data is mixed or uncertain, output NEUTRAL with moderate confidence (0.50-0.60) — do not force LONG without clear bullish evidence.
Provide your strategic direction (LONG or NEUTRAL), a confidence score (0.0 to 1.0),"""

    # DXY confidence floor boost: when noise is LOW and DXY signal confidence is high, raise minimum
    dxy_confidence_floor = ""
    if isinstance(macro_correlation, dict) and macro_correlation.get("source"):
        noise_lvl = str(macro_correlation.get("noise_level", "")).upper()
        dxy_conf = float(macro_correlation.get("confidence", 0))
        dxy_active = bool(macro_correlation.get("correlation_active", False))
        if noise_lvl == "LOW" and dxy_conf >= 0.7 and dxy_active:
            implication = str(macro_correlation.get("gold_implication", ""))
            min_conf = max(0.65, dxy_conf * 0.9)
            dxy_confidence_floor = f"""CONFIDENCE FLOOR: The DXY correlation signal is CLEAN (noise=LOW, conf={dxy_conf:.2f}).
    Your minimum confidence MUST be >= {min_conf:.2f}. Do NOT output confidence below {min_conf:.2f}.
    {sym_name} Implication: {implication} — bias your direction accordingly.\n"""

    # Multi-dimensional market factor validation (New optimization)
    factor_validation_section = ""
    if session_data:
        v_match = session_data.get("volume_price_match", {})
        v_div = session_data.get("volume_divergence", {})
        factor_validation_section = f"""
Market Factor Cross-Validation:
  Volume-Price Match: {v_match.get('match_quality', 'N/A')} (score: {v_match.get('match_score', 0):.2f})
  Volume Divergence: {v_div.get('divergence_type', 'NONE')} (score: {v_div.get('divergence_score', 0):.2f})
  RULE: If Volume Divergence is BEARISH and score < -0.4, you MUST NOT output LONG.
  RULE: If Volume-Price Match is BULLISH_VOLUME_LEADING, favor LONG.
"""

    # Build ADX-weighted session ranking for prioritization
    adx_ranking_lines = []
    session_rankings = []
    for sk, sd in (all_sessions or {}).items():
        if not isinstance(sd, dict):
            continue
        adx_v = float(sd.get("adx", 0) or 0)
        direction = str(sd.get("direction", "NEUTRAL") or "NEUTRAL")
        score_v = float(sd.get("score", 0) or 0)
        session_rankings.append((adx_v, sk, direction, score_v))
    session_rankings.sort(key=lambda x: x[0], reverse=True)

    if session_rankings:
        max_adx = max(r[0] for r in session_rankings) or 1.0
        for rank_idx, (adx_v, sk, direction, score_v) in enumerate(session_rankings, 1):
            weight = adx_v / max_adx if max_adx > 0 else 1.0
            adx_ranking_lines.append(
                f"  #{rank_idx} {sk}: ADX={adx_v:.1f} (weight={weight:.2f}), "
                f"Direction={direction}, Score={score_v:+.1f}"
            )

    adx_ranking_section = ""
    trend_strength_block = ""
    if session_rankings:
        max_adx = max(r[0] for r in session_rankings) or 1.0
        for rank_idx, (adx_v, sk, direction, score_v) in enumerate(session_rankings, 1):
            weight = adx_v / max_adx if max_adx > 0 else 1.0
            adx_ranking_lines.append(
                f"  #{rank_idx} {sk}: ADX={adx_v:.1f} (weight={weight:.2f}), "
                f"Direction={direction}, Score={score_v:+.1f}"
            )

        strongest = session_rankings[0]
        strongest_adx = strongest[0]
        strongest_dir = strongest[2].upper()
        strongest_score = strongest[3]
        if strongest_adx >= 30.0 and strongest_dir in ("BULLISH", "BEARISH", "SLIGHTLY_BULLISH", "SLIGHTLY_BEARISH"):
            allowed = "ONLY LONG" if strongest_dir in ("BULLISH", "SLIGHTLY_BULLISH") else "ONLY SHORT"
            trend_strength_block = f"""
TREND STRENGTH LOCK (MUST OBEY — NON-NEGOTIABLE):
  Strongest timeframe ADX: {strongest_adx:.1f} (>= 30 = STRONG TREND)
  Trend direction: {strongest_dir} (score: {strongest_score:+.1f})
  RULE: In a strong trend (ADX >= 30), you MUST go with the trend — NO counter-trend signals.
  ALLOWED: {allowed} or NEUTRAL
  FORBIDDEN: {"SHORT" if "LONG" in allowed else "LONG"} (counter-trend)
  If you output a counter-trend direction, you are WRONG — the market will punish you.
"""
        elif strongest_adx >= 20.0 and strongest_dir in ("BULLISH", "BEARISH", "SLIGHTLY_BULLISH", "SLIGHTLY_BEARISH"):
            allowed = "ONLY LONG" if strongest_dir in ("BULLISH", "SLIGHTLY_BULLISH") else "ONLY SHORT"
            trend_strength_block = f"""
TREND STRENGTH CAUTION:
  Strongest timeframe ADX: {strongest_adx:.1f} (20-30 = developing trend)
  Trend direction: {strongest_dir} (score: {strongest_score:+.1f})
  RULE: Favor the trend direction. Counter-trend requires very strong evidence and low confidence.
  BIAS: {allowed} preferred
"""

    if adx_ranking_lines:
        adx_ranking_section = (
            "ADX-Weighted Session Ranking (higher ADX = stronger trend = higher decision weight):\n"
            + "\n".join(adx_ranking_lines)
            + "\n\n"
        )

    # Recent direction performance feedback
    perf = _recent_direction_performance(lookback_trades=20)
    perf_section = ""
    if perf.get("summary"):
        perf_lines = [f"Recent Trade Performance (last {perf['total_trades']} closed trades):"]
        if perf.get("long_win_rate") is not None:
            w = "STRONG" if (perf['long_win_rate'] or 0) >= 0.6 else "WEAK" if (perf['long_win_rate'] or 0) < 0.4 else "NEUTRAL"
            perf_lines.append(
                f"  LONG trades: {perf['long_win_rate']:.0%} win ({perf['buy_trades']} trades, PnL={perf['buy_total_pnl']:+.1f}) — {w}"
            )
        if perf.get("short_win_rate") is not None:
            w = "STRONG" if (perf['short_win_rate'] or 0) >= 0.6 else "WEAK" if (perf['short_win_rate'] or 0) < 0.4 else "NEUTRAL"
            perf_lines.append(
                f"  SHORT trades: {perf['short_win_rate']:.0%} win ({perf['sell_trades']} trades, PnL={perf['sell_total_pnl']:+.1f}) — {w}"
            )
        hint = "Use this performance data to calibrate your direction confidence."
        if (perf.get('long_win_rate') or 0) >= 0.6 and (perf.get('buy_total_pnl') or 0) > 0:
            hint = "The trend is rewarding LONG positions — favor LONG."
        elif (perf.get('long_win_rate') or 1) < 0.4 and (perf.get('short_win_rate') or 1) < 0.4:
            hint = "Both directions are struggling — consider NEUTRAL or reduce confidence."
        perf_lines.append(f"  Hint: {hint}")
        perf_section = "\n".join(perf_lines) + "\n\n"
        # Lessons from reflection engine
        lessons_section = ""
        try:
            _lr = str(session_data.get("regime", current_regime) or current_regime) if isinstance(session_data, dict) else current_regime
            _ls = str(session_data.get("current_session", "") or "") if isinstance(session_data, dict) else ""
            _ld = str(session_data.get("overall_direction", "NEUTRAL") or "NEUTRAL") if isinstance(session_data, dict) else "NEUTRAL"
            _la = _get_lessons(limit=4, regime=_lr, session=_ls, side=_ld)
            _ops = _scan_ops(limit=3)
            if _la or _ops:
                lessons_section = _fmt_lessons(_la or [], opportunities=_ops or [])
            if _la:
                lessons_section = _fmt_lessons(_la)
        except Exception:
            pass

    # ── Regime context for the LLM ──
    regime_label = str(session_data.get("regime", current_regime) or current_regime)
    regime_detail = session_data.get("regime_detail", {}) if isinstance(session_data, dict) else {}
    regime_aware_dir = str(session_data.get("regime_aware_direction", overall_direction) or overall_direction)
    regime_override_reason = str(session_data.get("regime_override_reason", "") or "")
    regime_conf = float(regime_detail.get("confidence", 0.0) or 0.0)
    regime_allowed_strategies: list[str] = []
    try:
        from src.strategy_registry import list_compatible_strategies

        session_mode = str(
            (
                session_data.get("session_profile", {})
                if isinstance(session_data.get("session_profile", {}), dict)
                else session_data.get("current_session_profile", {})
                if isinstance(session_data.get("current_session_profile", {}), dict)
                else {}
            ).get("mode", "")
            or ""
        ).upper()
        regime_allowed_strategies = list_compatible_strategies(
            regime_label,
            regime_confidence=regime_conf,
            session_mode=session_mode,
            brain=get_brain(),
        )
    except Exception:
        regime_allowed_strategies = []
    regime_flags_list = regime_detail.get("flags", []) if isinstance(regime_detail, dict) else []

    regime_context_block = ""
    if regime_label != "UNKNOWN":
        regime_context_block = f"""
Regime Classification (Technical Ground Truth — respect this):
  Regime: {regime_label} (confidence={regime_conf:.0%})
  Regime-Corrected Direction: {regime_aware_dir}{' (' + regime_override_reason + ')' if regime_override_reason else ''}
  Allowed Strategies: {', '.join(regime_allowed_strategies) if regime_allowed_strategies else 'NONE (stay out)'}
  Flags: {', '.join(regime_flags_list[:5])}
  RULE: If regime confidence >= 70% and regime is STRONG_TREND/WEAK_TREND/STEALTH_TREND,
        you MUST output the regime's direction. Do NOT contradict a high-confidence regime.
  RULE: If regime is TRANSITION, EXTREME, or FAKE_TREND, output NEUTRAL with low confidence (0.45-0.50).
"""

    prompt = f"""
You are the Commander (Macro Strategic Agent) for a {sym_name} ({sym_category}) algorithmic trading system. {sym_name} characteristics: {sym_characteristics}. Key macro drivers: {sym_macro_drivers}.
Your job is to determine the macro trading direction for the current market phase.

{adx_ranking_section}{trend_strength_block}{perf_section}{lessons_section}{regime_context_block}{factor_validation_section}Session Data (EMA biases, RSI, Volatility, Direction):
{json.dumps(all_sessions, indent=2)}

Overall Technical Score: {overall_score} (Positive = BULLISH, Negative = BEARISH)
Overall Technical Direction: {overall_direction}
Regime-Corrected Direction: {regime_aware_dir}

Market Structure Snapshot: {json.dumps(structure_snapshot or {}, default=str)}
Current Regime: {regime_label}
Volatility Ratio: {vol_ratio:.2f}{pos_info}{corr_section}
{dxy_confidence_floor}{direction_rule}
a conviction level (low/medium/high), risk appetite (conservative/balanced/aggressive),
macro regime classification, and a concise reason.

Output exactly in JSON format:
{{
  "direction": "LONG",
  "confidence": 0.8,
  "reason": "...",
  "conviction": "medium",
  "risk_appetite": "balanced",
  "macro_regime": "bullish_trend",
  "timeframe": "H4",
  "key_levels": {{"support": 2900, "resistance": 3000}}
}}
"""

    last_error = ""
    attempt = 0
    while attempt < _MAX_RETRIES:
        attempt += 1
        try:
            from src.llm import call_agent_llm

            # ── 3-perspective voting ──
            from src.learning.trading_brain import get_brain
            import uuid as _uuid
            voting_round_id = str(_uuid.uuid4())[:8]
            p_tech = _build_technical_context(current_regime)
            p_macro = _build_macro_context(current_regime)
            p_fb = _build_brain_context(current_regime)
            perspectives = {
                "technical": (
                    f"You are a technical analyst for {sym_name}. Focus on technical indicators. Ignore macro" + p_tech,
                    prompt,
                ),
                "macro": (
                    f"You are a macro strategist for {sym_name}. Combine technical indicators with DXY, macro drivers." + p_macro,
                    prompt,
                ),
                "feedback": (
                    f"You are a learning strategist for {sym_name}. Recent trade outcomes give you feedback." + p_fb,
                    prompt,
                ),
            }
            raw_votes = []
            for p_name, (sys_p, usr_p) in perspectives.items():
                pr, err = _call_single_perspective(p_name, sys_p, usr_p, temperature=0.15)
                if pr:
                    d = _normalize_direction(str(pr.get("direction", "NEUTRAL")))
                    raw_votes.append({
                        "direction": d, "confidence": float(pr.get("confidence", 0.5)),
                        "perspective": p_name, "reason": str(pr.get("reason", ""))[:300],
                    })
            if len(raw_votes) >= 2:
                weights = get_brain().get_perspective_weights(current_regime)
                aggregated = aggregate_votes(raw_votes, weights)
                direction = aggregated["direction"]
                decision = aggregated
                raw_conf = aggregated["confidence"]
            elif raw_votes:
                direction = raw_votes[0]["direction"]
                raw_conf = raw_votes[0]["confidence"]
                decision = raw_votes[0]
            else:
                raise RuntimeError("all perspectives failed")
            # Record all perspective judgments
            for v in raw_votes:
                try:
                    jf = extract_judgment_features(v, {"regime": current_regime, "vol_ratio": vol_ratio})
                    get_brain().record_judgment_vote(jf, v["perspective"], voting_round_id)
                except Exception:
                    pass
            # LONG-only mode check
            if not allow_short and direction == "SHORT":
                direction = "NEUTRAL"

            direction = _normalize_direction(str(decision.get("direction", "NEUTRAL")))

            # LONG-only mode: map SHORT → NEUTRAL (don't force LONG on bearish markets)
            if not allow_short and direction == "SHORT":
                direction = "NEUTRAL"

            # ─ ADX trend strength hard filter ─
            if session_rankings:
                strongest = session_rankings[0]
                strongest_adx = strongest[0]
                strongest_dir = strongest[2].upper()
                if strongest_adx >= 30.0 and strongest_dir in ("BULLISH", "BEARISH", "SLIGHTLY_BULLISH", "SLIGHTLY_BEARISH"):
                    trend_dir = "LONG" if strongest_dir in ("BULLISH", "SLIGHTLY_BULLISH") else "SHORT"
                    if direction not in (trend_dir, "NEUTRAL"):
                        print(f"[commander_client] ADX TREND LOCK ({strongest_adx:.1f}): {direction}→{trend_dir}")
                        direction = trend_dir
                        try: raw_conf = max(0.50, raw_conf * 0.90)
                        except: pass

            # ─ Dynamic trust: regime accuracy vs LLM accuracy ─
            regime_veto_applied = False
            try:
                _tr = get_brain().get_regime_llm_trust(regime_label)
            except Exception:
                _tr = {"sufficient_data": False}

            if _tr.get("sufficient_data", False):
                if regime_conf >= 0.70 and regime_label in ("STRONG_TREND", "WEAK_TREND", "STEALTH_TREND"):
                    rdm = "LONG" if regime_aware_dir in ("BULLISH", "SLIGHTLY_BULLISH") else "SHORT" if regime_aware_dir in ("BEARISH", "SLIGHTLY_BEARISH") else None
                    if rdm and direction != rdm:
                        if _tr.get("trust_regime") and not _tr.get("trust_llm"):
                            print(f"[commander_client] TRUST REGIME: LLM={direction}→{rdm} (regime_acc={_tr['regime_accuracy']:.0%}>llm_acc={_tr['llm_accuracy']:.0%})")
                            direction = rdm
                            regime_veto_applied = True
                        elif _tr.get("trust_llm") and not _tr.get("trust_regime"):
                            print(f"[commander_client] TRUST LLM: {direction} kept (llm_acc={_tr['llm_accuracy']:.0%}>reg_acc={_tr['regime_accuracy']:.0%})")
                        else:
                            print(f"[commander_client] BLEND: regime={regime_label} says {rdm}, LLM={direction}, -15% conf")
                            try: raw_conf = max(0.45, raw_conf * 0.85)
                            except: pass
                elif regime_label in ("TRANSITION", "EXTREME", "FAKE_TREND"):
                    if direction not in ("NEUTRAL",):
                        if _tr.get("trust_llm") and _tr["llm_accuracy"] > 0.55:
                            print(f"[commander_client] LLM ALLOWED in {regime_label}: {direction} kept (llm_acc={_tr['llm_accuracy']:.0%})")
                            try: raw_conf = max(0.45, raw_conf * 0.85)
                            except: pass
                        else:
                            print(f"[commander_client] REGIME LOCK: {regime_label}→NEUTRAL")
                            direction = "NEUTRAL"
                            regime_veto_applied = True
            else:
                if regime_conf >= 0.70 and regime_label in ("STRONG_TREND", "WEAK_TREND", "STEALTH_TREND"):
                    rdm = "LONG" if regime_aware_dir in ("BULLISH", "SLIGHTLY_BULLISH") else "SHORT" if regime_aware_dir in ("BEARISH", "SLIGHTLY_BEARISH") else None
                    if rdm and direction != rdm:
                        print(f"[commander_client] REGIME VETO (no data): LLM={direction}→{rdm}")
                        direction = rdm
                        regime_veto_applied = True
                elif regime_label in ("TRANSITION", "EXTREME", "FAKE_TREND"):
                    if direction not in ("NEUTRAL",):
                        print(f"[commander_client] REGIME VETO (no data): {regime_label}→NEUTRAL")
                        direction = "NEUTRAL"
                        regime_veto_applied = True

            # ── LONG-only re-check after regime veto (veto may re-introduce SHORT) ──
            if direction == "SHORT" and not allow_short:
                print(f"[commander_client] LONG-only mode: regime veto SHORT → NEUTRAL")
                direction = "NEUTRAL"

            # 更新最后成功方向缓存
            _LAST_GOOD_DIRECTION.update({
                "direction": direction,
                "confidence": float(decision.get("confidence", 0.5)),
                "reason": str(decision.get("reason", ""))[:300],
                "updated_ts": time.time(),
            })

            # ── Record to learning brain ──
            try:
                from src.learning.trading_brain import get_brain
                from src.learning.feature_extractor import extract_judgment_features
                jf = extract_judgment_features(
                    decision,
                    {"regime": current_regime, "vol_ratio": vol_ratio},
                )
                get_brain().record_judgment(jf)
            except Exception:
                pass

            # ── Confidence calibration ──
            raw_conf = float(decision.get("confidence", 0.5))
            calibrated_conf = raw_conf
            try:
                from src.learning.llm_calibrator import calibrate_llm_confidence
                cal = calibrate_llm_confidence(raw_conf)
                calibrated_conf = cal["calibrated"]
                if not cal.get("used_fallback"):
                    print(f"[commander_client] confidence calibrated: {raw_conf:.2f}→{calibrated_conf:.2f} "
                          f"(bin_n={cal['bin_n']}, bin_accuracy={cal['bin_accuracy']})")
            except Exception:
                pass

            # -- Meta override (historical data overrides LLM)
            try:
                from bridge.commander_client import _apply_meta_override as _amo
                _override = _apply_meta_override(
                    {"direction": direction, "confidence": calibrated_conf},
                    {"regime": current_regime, "session": str(session_data.get("current_session", "") or "")},
                )
                if _override.get("meta_override"):
                    direction = _override["direction"]
                    calibrated_conf = _override["confidence"]
                    print(f"[commander_client] meta_override: {_override['meta_override']}")
            except Exception:
                logger.warning("Meta override failed for regime=%s", current_regime, exc_info=True)

            return {
                "direction": direction,
                "confidence": raw_conf,
                "reason": str(decision.get("reason", ""))[:300],
                "degraded": False,
                "conviction": decision.get("conviction"),
                "timeframe": decision.get("timeframe"),
                "risk_appetite": decision.get("risk_appetite", "balanced"),
                "macro_regime": decision.get("macro_regime", current_regime),
                "key_levels": decision.get("key_levels"),
                "calibrated_confidence": calibrated_conf,
                "updated_ts": time.time(),
                "_latency_ms": 0,
                "_regime_veto": regime_veto_applied,
                "_regime_label": regime_label,
                "_model": "",
            }

        except Exception as e:
            last_error = str(e)
            is_transient = _is_transient_error(last_error)
            if is_transient and attempt < _MAX_RETRIES:
                delay = min(_RETRY_BASE_DELAY * (2 ** (attempt - 1)), _RETRY_MAX_DELAY)
                jitter = random.uniform(0.5, delay * 0.2)
                total_delay = delay + jitter
                print(f"[commander_client] retry {attempt}/{_MAX_RETRIES} after {total_delay:.1f}s: {last_error[:120]}")
                time.sleep(total_delay)
                continue
            # 永久性错误或已达最大重试次数
            if not is_transient and attempt == 1:
                print(f"[commander_client] permanent error (no retry): {last_error[:200]}")
            break

    # 所有重试耗尽 → fallback
    print(f"[commander_client] API call failed after {attempt} attempt(s): {last_error[:200]}")
    return _fallback_response(session_data, last_error, allow_short)

def _classify_error(error: str) -> str:
    """将原始异常消息归类为可操作的错误标签。"""
    el = error.lower()
    if "api key not configured" in el or "api key" in el:
        return "API_KEY_MISSING"
    if "not configured" in el or "provider" in el:
        return "PROVIDER_NOT_CONFIGURED"
    if "401" in el or "unauthorized" in el or "403" in el:
        return "AUTH_FAILED"
    if "timeout" in el or "timed out" in el:
        return "API_TIMEOUT"
    if "connection" in el or "dns" in el or "name resolution" in el:
        return "NETWORK_ERROR"
    if "rate limit" in el or "429" in el:
        return "RATE_LIMITED"
    return "API_ERROR"

def _normalize_direction(raw: str) -> str:
    """将 LLM 返回的任意方向字符串标准化为 LONG / SHORT / NEUTRAL。

    处理常见变异：大小写、空格、同义词（BULLISH→LONG, BEARISH→SHORT 等）。
    任何无法识别的输入 → NEUTRAL（安全回退）。
    """
    v = str(raw or "").strip().upper()
    # 精确匹配
    if v in {"LONG", "SHORT", "NEUTRAL"}:
        return v
    # 同义词 → LONG
    if v in {"BULLISH", "BULL", "BUY", "UP", "CALL"}:
        return "LONG"
    # 同义词 → SHORT
    if v in {"BEARISH", "BEAR", "SELL", "DOWN", "PUT"}:
        return "SHORT"
    # 含糊/未知 → NEUTRAL
    return "NEUTRAL"

def _fallback_response(session_data: Optional[dict], error: str, allow_short: bool = False) -> dict[str, Any]:
    """Degraded fallback based on technical signals + last known good direction.

    When LLM API is unavailable:
    1. Use last successful direction (if available and not too old) with decayed confidence
    2. If no recent good direction, compute confidence from technical resonance strength
    3. Downstream systems still have a meaningful signal instead of flat NEUTRAL
    """
    overall_direction = "NEUTRAL"
    overall_score = 0.0
    if isinstance(session_data, dict):
        overall_direction = session_data.get("overall_direction", "NEUTRAL")
        overall_score = abs(float(session_data.get("overall_score", 0.0) or 0.0))

    # Use last known good direction if it's still fresh (< 4 hours old)
    last_good = _LAST_GOOD_DIRECTION
    last_age = time.time() - last_good.get("updated_ts", 0.0) if last_good.get("updated_ts", 0.0) > 0 else 99999.0
    use_last_good = last_age < 600 and last_good.get("direction", "NEUTRAL") in ("LONG", "SHORT", "NEUTRAL")

    if use_last_good:
        base_direction = last_good["direction"]
        base_confidence = last_good["confidence"]
        # 置信度随时间衰减：30 分钟 → 衰减到 50%
        decay = max(0.50, 1.0 - (last_age / 1800) * 0.50)
        fallback_conf = round(base_confidence * decay, 4)
        fallback_conf = min(fallback_conf, base_confidence)  # never inflate above original
        direction = base_direction
        direction_source = f"last_good_{base_direction}"
    else:
        # 无有效历史方向时，从技术面推导
        if overall_direction in ("BULLISH", "SLIGHTLY_BULLISH"):
            direction = "LONG"
        elif overall_direction in ("BEARISH", "SLIGHTLY_BEARISH"):
            direction = "SHORT" if allow_short else "NEUTRAL"
        else:
            direction = "NEUTRAL"

        if overall_direction in ("BULLISH", "BEARISH"):
            base_conf = 0.62
        elif overall_direction in ("SLIGHTLY_BULLISH", "SLIGHTLY_BEARISH"):
            base_conf = 0.58
        else:
            base_conf = 0.55
        score_bonus = min(0.06, overall_score * 0.03)
        fallback_conf = round(base_conf + score_bonus, 4)
        direction_source = f"technical_{overall_direction}"

    error_label = _classify_error(error)
    return {
        "direction": direction,
        "confidence": fallback_conf,
        "reason": f"[Fallback:{error_label}] {direction_source} (conf={fallback_conf:.4f}). {error[:100]}",
        "degraded": True,
        "conviction": "low",
        "timeframe": None,
        "risk_appetite": "conservative",
        "macro_regime": "UNKNOWN",
        "key_levels": None,
        "calibrated_confidence": fallback_conf,
        "updated_ts": time.time(),
    }
