#!/usr/bin/env python3
"""
offline_train.py — 离线训练数据生成 + 参数网格搜索 (Grid Search) + 逐笔交易模拟

用法：
  python3 src/offline_train.py --days 30 --interval 5m --mode once
  python3 src/offline_train.py --days 30 --interval 5m --mode train
"""
import argparse, json, os, sys, time, traceback, itertools
from datetime import datetime
from typing import Any, Optional
from pathlib import Path

SRC_DIR = Path(__file__).parent
PROJECT_ROOT = SRC_DIR.parent
sys.path.insert(0, str(PROJECT_ROOT))
import config_manager
from src.market_context import is_trend_regime

import yfinance as yf
import pandas as pd
import numpy as np

LOGS_DIR = config_manager.get_logs_dir()
DATA_DIR = config_manager.get_data_dir()
LEDGER_FILE = LOGS_DIR / "trading_ledger.ndjson"


def _atomic_write_json(path: Path, payload: Any) -> None:
    """Write JSON atomically via tmp + os.replace to prevent corruption on crash.

    Retries on transient PermissionError (Windows file locks from concurrent readers)."""
    tmp_path = path.with_suffix(path.suffix + ".tmp")
    json_text = json.dumps(payload, indent=2, default=str, ensure_ascii=False)
    for attempt in range(5):
        try:
            tmp_path.write_text(json_text, encoding="utf-8")
            os.replace(tmp_path, path)
            break
        except (PermissionError, OSError):
            if attempt < 4:
                time.sleep(0.05 * (2 ** attempt))
                continue
            try:
                tmp_path.unlink(missing_ok=True)
            except Exception:
                pass
        except Exception:
            try:
                tmp_path.unlink(missing_ok=True)
            except Exception:
                pass
            break


def _compact_stats(stats: dict[str, Any] | None) -> dict[str, Any]:
    stats = stats or {}
    total_trades = int(stats.get("total_trades", 0) or 0)
    total_pnl = float(stats.get("total_pnl", 0.0) or 0.0)
    return {
        "total_trades": total_trades,
        "wins": int(stats.get("wins", 0) or 0),
        "losses": int(stats.get("losses", 0) or 0),
        "total_pnl": round(total_pnl, 2),
        "win_rate": float(stats.get("win_rate", 0.0) or 0.0),
        "score": float(stats.get("score", 0.0) or 0.0),
        "avg_trade_pnl": round(total_pnl / total_trades, 2) if total_trades > 0 else 0.0,
        "by_regime": stats.get("by_regime", {}),
        "by_side": stats.get("by_side", {}),
        "ledger_factor": float(stats.get("ledger_factor", 1.0) or 1.0),
        "raw_score": float(stats.get("raw_score", 0.0) or 0.0),
        "last_updated": _now_iso(),
    }


def _write_offline_stats(output_dir: Path, stats: dict[str, Any] | None) -> Path:
    payload = _compact_stats(stats)
    stats_file = output_dir / "offline_stats.json"
    _atomic_write_json(stats_file, payload)
    return stats_file


def evaluate_training_readiness(
    *,
    force_refresh: bool = True,
    repair_cycle: dict[str, Any] | None = None,
    source: str = "offline_train",
) -> dict[str, Any]:
    """Compatibility wrapper for GUI training gate checks."""
    from src.training_quality_snapshot import ensure_training_quality_snapshot

    return ensure_training_quality_snapshot(
        force_refresh=force_refresh,
        repair_cycle=repair_cycle,
        source=source,
    )


def write_training_blocked_status(output_dir: Path, gate: dict[str, Any]) -> Path:
    """Persist a blocked training status snapshot for GUI/autopilot consumers."""
    output_dir.mkdir(parents=True, exist_ok=True)
    status_file = output_dir / "training_status.json"
    existing: dict[str, Any] = {}
    if status_file.exists():
        try:
            existing = json.loads(status_file.read_text(encoding="utf-8"))
        except Exception:
            existing = {}
    payload = {
        **existing,
        "isTraining": False,
        "blocked": True,
        "progress": float(existing.get("progress", 0.0) or 0.0),
        "qualityGate": gate,
        "blockedReasons": list(gate.get("blocked_reasons", [])) if isinstance(gate, dict) else [],
        "updatedAt": _now_iso(),
    }
    _atomic_write_json(status_file, payload)
    return status_file


# ═══════════════════════════════════════════════════════════════
# 反馈回路1: 实盘修正因子
# ═══════════════════════════════════════════════════════════════

def _load_ledger_correction_factors() -> dict[str, float]:
    """读取 trading_ledger.ndjson，按 strategy_id 分组计算实盘修正因子。

    修正因子 = real_pnl / trade_count，归一化到 1.0 附近。
    >1.0 表示实盘比回测表现更好，<1.0 表示实盘更差。
    默认因子 = 1.0（无修正）。
    """
    if not LEDGER_FILE.exists():
        return {}

    try:
        strategy_pnls: dict[str, list[float]] = {}
        with open(LEDGER_FILE, "r", encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    t = json.loads(line)
                except json.JSONDecodeError:
                    continue
                sid = str(t.get("strategy_id", "") or "default")
                pnl = float(t.get("pnl", 0) or 0)
                strategy_pnls.setdefault(sid, []).append(pnl)
    except Exception:
        return {}

    factors: dict[str, float] = {}
    for sid, pnls in strategy_pnls.items():
        if len(pnls) < 3:
            factors[sid] = 1.0
            continue
        avg_pnl = sum(pnls) / len(pnls)
        # 因子：如果平均亏损，因子 < 1.0；平均盈利，因子 > 1.0
        # 映射到 [0.5, 1.5] 区间
        if avg_pnl >= 0:
            factors[sid] = min(1.5, 1.0 + avg_pnl / 100)
        else:
            factors[sid] = max(0.5, 1.0 + avg_pnl / 100)
    return factors


def _apply_ledger_correction(stats: dict, strategy_id: str = "default") -> float:
    """返回该策略的修正因子，0.5~1.5。"""
    factors = _load_ledger_correction_factors()
    return factors.get(strategy_id, 1.0)


# ═══════════════════════════════════════════════════════════════
# 工具函数
# ═══════════════════════════════════════════════════════════════

def compute_indicators(df: pd.DataFrame, ema_short=9, ema_long=21) -> pd.DataFrame:
    df = df.copy()
    close = df["Close"]
    high, low = df["High"], df["Low"]

    df["ema_short"] = close.ewm(span=ema_short, adjust=False).mean()
    df["ema_long"] = close.ewm(span=ema_long, adjust=False).mean()

    delta = close.diff()
    gain = delta.clip(lower=0).ewm(alpha=1/14, adjust=False).mean()
    loss = (-delta.clip(upper=0)).ewm(alpha=1/14, adjust=False).mean()
    rs = gain / loss.replace(0, np.nan)
    df["rsi14"] = (100 - 100 / (1 + rs)).fillna(50)

    tr1 = high - low
    tr2 = (high - close.shift(1)).abs()
    tr3 = (low - close.shift(1)).abs()
    tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
    df["atr14"] = tr.ewm(alpha=1/14, adjust=False).mean()

    dm_plus = high.diff().clip(lower=0)
    dm_minus = (-low.diff()).clip(lower=0)
    di_plus = 100 * dm_plus.ewm(alpha=1/14, adjust=False).mean() / df["atr14"].replace(0, np.nan)
    di_minus = 100 * dm_minus.ewm(alpha=1/14, adjust=False).mean() / df["atr14"].replace(0, np.nan)
    dx = 100 * (di_plus - di_minus).abs() / (di_plus + di_minus.replace(0, np.nan))
    df["adx"] = dx.ewm(alpha=1/14, adjust=False).mean()

    bb_mid = close.rolling(20).mean()
    df["bb_upper"] = bb_mid + 2 * close.rolling(20).std()
    df["bb_lower"] = bb_mid - 2 * close.rolling(20).std()

    return df


def detect_regime(adx: float) -> str:
    return "WEAK_TREND" if adx >= 25 else "RANGING"


def detect_signal(row: pd.Series, params: dict) -> dict:
    close = float(row["Close"])
    ema_s, ema_l = float(row["ema_short"]), float(row["ema_long"])
    rsi = float(row["rsi14"])
    bb_u, bb_l = float(row.get("bb_upper", 0)), float(row.get("bb_lower", 0))
    adx = float(row.get("adx", 0))
    regime = detect_regime(adx)
    atr = float(row.get("atr14", 3.0))

    sl_mult = params.get("sl_atr", 1.0)
    tp_mult = params.get("tp_atr", 3.0)

    # === 信号逻辑 ===
    if is_trend_regime(regime):
        if ema_s > ema_l and rsi > 40 and rsi < 70 and close >= ema_s:
            return {"action": "OPEN", "side": "BUY", "regime": regime,
                    "entry": round(close, 3), "sl": round(close - sl_mult*atr, 3),
                    "tp": round(close + tp_mult*atr, 3), "confidence": 0.70}
        if ema_s < ema_l and rsi < 60 and rsi > 30 and close <= ema_s:
            return {"action": "OPEN", "side": "SELL", "regime": regime,
                    "entry": round(close, 3), "sl": round(close + sl_mult*atr, 3),
                    "tp": round(close - tp_mult*atr, 3), "confidence": 0.70}
    else:
        if rsi <= 30 and close <= bb_l and bb_l > 0:
            return {"action": "OPEN", "side": "BUY", "regime": regime,
                    "entry": round(close, 3), "sl": round(close - sl_mult*atr, 3),
                    "tp": round(close + tp_mult*atr, 3), "confidence": 0.65}
        if rsi >= 70 and close >= bb_u and bb_u > 0:
            return {"action": "OPEN", "side": "SELL", "regime": regime,
                    "entry": round(close, 3), "sl": round(close + sl_mult*atr, 3),
                    "tp": round(close - tp_mult*atr, 3), "confidence": 0.65}

    return {"action": "HOLD"}


# ═══════════════════════════════════════════════════════════════
# 核心：逐笔交易模拟
# ═══════════════════════════════════════════════════════════════

def backtest_strategy(df: pd.DataFrame, params: dict, verbose=False) -> dict:
    stats = {
        "total_trades": 0, "wins": 0, "losses": 0, "total_pnl": 0.0,
        "by_regime": {"WEAK_TREND": {"wins": 0, "losses": 0, "pnl": 0.0},
                       "RANGING":  {"wins": 0, "losses": 0, "pnl": 0.0}},
        "by_side": {"BUY": {"wins": 0, "losses": 0, "pnl": 0.0},
                     "SELL": {"wins": 0, "losses": 0, "pnl": 0.0}},
    }
    trades = []
    open_trade = None  # 限制为一次只持有一个仓位，避免重复开仓导致数据虚假

    CONTRACT_SIZE = 100
    LOTS = 0.10

    for i, (ts, row) in enumerate(df.iterrows()):
        # 1. 检查是否需要平仓
        if open_trade and i + 1 < len(df):
            next_row = df.iloc[i + 1]
            next_high, next_low = float(next_row["High"]), float(next_row["Low"])
            
            entry, sl, tp = open_trade["entry"], open_trade["sl"], open_trade["tp"]
            side = open_trade["side"]

            pnl, outcome = None, None
            if side == "BUY":
                if next_low <= sl:
                    pnl = (sl - entry) * CONTRACT_SIZE * LOTS
                    outcome = "LOSS"
                elif next_high >= tp:
                    pnl = (tp - entry) * CONTRACT_SIZE * LOTS
                    outcome = "WIN"
            else:  # SELL
                if next_high >= sl:
                    pnl = (entry - sl) * CONTRACT_SIZE * LOTS
                    outcome = "LOSS"
                elif next_low <= tp:
                    pnl = (entry - tp) * CONTRACT_SIZE * LOTS
                    outcome = "WIN"

            if outcome:
                open_trade.update({"outcome": outcome, "pnl": round(pnl, 2), "close_bar": str(ts)})
                stats["total_trades"] += 1
                stats["total_pnl"] += pnl
                if outcome == "WIN": 
                    stats["wins"] += 1
                    stats["by_side"][side]["wins"] += 1
                    stats["by_regime"][open_trade["regime"]]["wins"] += 1
                else: 
                    stats["losses"] += 1
                    stats["by_side"][side]["losses"] += 1
                    stats["by_regime"][open_trade["regime"]]["losses"] += 1
                
                stats["by_side"][side]["pnl"] += pnl
                stats["by_regime"][open_trade["regime"]]["pnl"] += pnl
                
                trades.append(open_trade)
                open_trade = None  # 清空持仓

        # 2. 如果没有持仓，尝试寻找新信号
        if not open_trade:
            sig = detect_signal(row, params)
            if sig["action"] == "OPEN":
                open_trade = {
                    "side": sig["side"],
                    "regime": sig["regime"],
                    "entry": sig["entry"],
                    "sl": sig["sl"],
                    "tp": sig["tp"],
                    "timestamp": str(ts),
                }

    # 汇总
    wr = (stats["wins"] / stats["total_trades"] * 100) if stats["total_trades"] > 0 else 0
    stats["win_rate"] = round(wr, 2)
    stats["total_pnl"] = round(stats["total_pnl"], 2)
    
    # 评分函数：鼓励高胜率和高盈利，同时惩罚交易次数过少
    if stats["total_trades"] < 10:
        stats["score"] = -9999  # 交易次数太少，不具有统计学意义
    else:
        # PnL * (WinRate/100) * log(trades)
        raw_score = stats["total_pnl"] * (stats["win_rate"] / 100.0) * np.log1p(stats["total_trades"])
        # 回路1: 实盘修正因子
        ledger_factor = _apply_ledger_correction(stats, strategy_id=stats.get("strategy_id", "default"))
        stats["ledger_factor"] = round(ledger_factor, 4)
        stats["raw_score"] = round(raw_score, 2)
        stats["score"] = round(raw_score * ledger_factor, 2)
    
    stats["trades_list"] = trades
    return stats

def train_parameters(df: pd.DataFrame, output_dir: Path):
    print(f"=== 开始参数训练 (网格搜索) ===")

    # 定义参数空间
    param_grid = {
        "ema_short": [7, 9, 12],
        "ema_long": [21, 26, 50],
        "sl_atr": [1.0, 1.5, 2.0],
        "tp_atr": [1.5, 2.0, 3.0]
    }

    keys = param_grid.keys()
    combinations = list(itertools.product(*param_grid.values()))

    best_stats = None
    best_params = None

    # 模拟进度写入
    status_file = output_dir / "training_status.json"

    total_combs = len(combinations)
    print(f"参数组合总数: {total_combs}")

    for idx, vals in enumerate(combinations):
        params = dict(zip(keys, vals))

        # 重新计算基于 EMA 参数的指标
        df_eval = compute_indicators(df, ema_short=params["ema_short"], ema_long=params["ema_long"]).dropna()

        stats = backtest_strategy(df_eval, params, verbose=False)

        if best_stats is None or stats["score"] > best_stats["score"]:
            best_stats = stats
            best_params = params
            print(f"[{idx+1}/{total_combs}] 发现新最优参数: {params} -> PnL: ${stats['total_pnl']}, 胜率: {stats['win_rate']}%, 交易数: {stats['total_trades']}")

        # 进度更新
        if idx % 5 == 0 or idx == total_combs - 1:
            status_data = {
                "isTraining": True,
                "progress": round((idx + 1) / total_combs * 100, 1),
                "currentEpoch": idx + 1,
                "totalEpochs": total_combs,
                "winRate": best_stats["win_rate"] if best_stats else 0,
                "pnl": best_stats["total_pnl"] if best_stats else 0,
                "totalTrades": best_stats["total_trades"] if best_stats else 0,
                "activeDataset": "XAUUSD_5m_GridSearch"
            }
            _atomic_write_json(status_file, status_data)

    print("\n=== 训练完成 ===")
    print(f"最优参数: {best_params}")
    print(f"最优表现: 交易数 {best_stats['total_trades']}, 胜率 {best_stats['win_rate']}%, 总盈亏 ${best_stats['total_pnl']}")

    # 训练结束
    if status_file.exists():
        status_data = json.loads(status_file.read_text(encoding="utf-8"))
        status_data["isTraining"] = False
        status_data["progress"] = 100.0
        _atomic_write_json(status_file, status_data)

    compact_stats = _compact_stats(best_stats)

    # 保存最优参数和详细结果
    best_file = output_dir / "best_params.json"
    _atomic_write_json(best_file, {"best_params": best_params, "stats": compact_stats, "last_updated": _now_iso()})
    _write_offline_stats(output_dir, best_stats)

    # 为了与之前的 GUI 兼容，输出一个 offline_train_xxx.json 格式
    out_file = output_dir / f"offline_train_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
    results = {
        "stats": {
            "total_bars": len(df),
            "total_trades": compact_stats["total_trades"],
            "wins": compact_stats["wins"],
            "losses": compact_stats["losses"],
            "total_pnl": compact_stats["total_pnl"],
            "win_rate": compact_stats["win_rate"],
            "by_regime": best_stats["by_regime"],
            "by_side": best_stats["by_side"],
        },
        "trades": best_stats["trades_list"]
    }
    _atomic_write_json(out_file, results)

    # 回路4: 自动写入 effective_params.json
    if best_stats and best_params:
        _write_effective_params(best_params, best_stats, output_dir, method="grid")


def train_parameters_bayesian(df: pd.DataFrame, output_dir: Path, n_trials: int = 200):
    """Bayesian (optuna TPE) hyperparameter optimisation."""
    from bayesian_optimizer import BayesianOptimizer, OptunaStudyParams

    print(f"=== 开始参数训练 (贝叶斯优化, n_trials={n_trials}) ===")
    status_file = output_dir / "training_status.json"

    bayes_params = OptunaStudyParams(n_trials=n_trials)
    opt = BayesianOptimizer(df.dropna(), bayes_params)

    best_params, study = opt.optimize()

    # Evaluate best params fully
    df_eval = compute_indicators(
        df,
        ema_short=int(best_params.get("ema_short", 9)),
        ema_long=int(best_params.get("ema_long", 21)),
    ).dropna()
    best_stats = backtest_strategy(df_eval, best_params)

    print("\n=== 贝叶斯优化完成 ===")
    print(f"最优参数: {best_params}")
    print(f"最优值: {study.best_value}")
    print(f"最优表现: 交易数 {best_stats['total_trades']}, 胜率 {best_stats['win_rate']}%, 总盈亏 ${best_stats['total_pnl']}")

    if status_file.exists():
        # Retry on transient PermissionError (Windows os.replace file locks)
        for attempt in range(5):
            try:
                status_data = json.loads(status_file.read_text(encoding="utf-8"))
                break
            except json.JSONDecodeError:
                status_data = {}
                break
            except (PermissionError, OSError):
                if attempt < 4:
                    time.sleep(0.05 * (2 ** attempt))
                    continue
                status_data = {}
                break
            except Exception:
                status_data = {}
                break
    else:
        status_data = {}
    status_data.update({
        "isTraining": False,
        "progress": 100.0,
        "method": "bayesian",
        "n_trials": n_trials,
        "winRate": best_stats["win_rate"],
        "pnl": best_stats["total_pnl"],
        "totalTrades": best_stats["total_trades"],
        "activeDataset": "XAUUSD_5m_Bayesian",
    })
    _atomic_write_json(status_file, status_data)

    best_file = output_dir / "best_params.json"
    _atomic_write_json(best_file, {
        "best_params": best_params,
        "stats": _compact_stats(best_stats),
        "method": "bayesian",
        "n_trials": n_trials,
        "best_value": study.best_value,
        "last_updated": _now_iso(),
    })
    _write_offline_stats(output_dir, best_stats)

    out_file = output_dir / f"offline_train_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
    results = {
        "method": "bayesian",
        "n_trials": n_trials,
        "best_value": study.best_value,
        "stats": {
            "total_bars": len(df),
            "total_trades": best_stats["total_trades"],
            "wins": best_stats["wins"],
            "losses": best_stats["losses"],
            "total_pnl": best_stats["total_pnl"],
            "win_rate": best_stats["win_rate"],
            "by_regime": best_stats["by_regime"],
            "by_side": best_stats["by_side"],
        },
        "trades": best_stats["trades_list"]
    }
    _atomic_write_json(out_file, results)

    # 回路4: 自动写入 effective_params.json
    _write_effective_params(best_params, best_stats, output_dir, method="bayesian")


# ═══════════════════════════════════════════════════════════════
# 反馈回路2: Regime-Aware 训练
# ═══════════════════════════════════════════════════════════════

def _split_by_regime(df: pd.DataFrame, adx_threshold: int = 25) -> tuple[pd.DataFrame, pd.DataFrame]:
    """按 ADX 将数据拆分为 WEAK_TREND (ADX>=25) 和 RANGING (ADX<25) 两个子集。"""
    df = df.copy()
    adx_col = "adx14" if "adx14" in df.columns else "adx"
    if adx_col not in df.columns:
        raise KeyError("ADX column missing for regime-aware training")
    trending_mask = df[adx_col] >= adx_threshold
    trending = df[trending_mask].copy()
    ranging = df[~trending_mask].copy()
    return trending, ranging


def train_parameters_regime_aware(df: pd.DataFrame, output_dir: Path) -> dict:
    """Regime-aware 参数训练：WEAK_TREND 和 RANGING 各自独立跑网格搜索。

    输出 regime_params.json，包含每个 regime 的最优参数。
    """
    print("=== Regime-Aware 参数训练 ===")
    trending_df, ranging_df = _split_by_regime(df)
    print(f"WEAK_TREND: {len(trending_df)} 根K线, RANGING: {len(ranging_df)} 根K线")

    regime_params: dict[str, Any] = {"trained_at": _now_iso(), "adx_threshold": 25, "regimes": {}}

    # WEAK_TREND
    if len(trending_df) >= 50:
        print("\n--- WEAK_TREND 子集训练 ---")
        best_t, params_t = _grid_search(trending_df)
        if best_t and params_t:
            regime_params["regimes"]["WEAK_TREND"] = {
                "params": params_t,
                "stats": {k: best_t[k] for k in ["total_trades", "win_rate", "total_pnl", "score"]},
                "bars": len(trending_df),
            }
    else:
        print("WEAK_TREND 子集样本不足，跳过")

    # RANGING
    if len(ranging_df) >= 50:
        print("\n--- RANGING 子集训练 ---")
        best_r, params_r = _grid_search(ranging_df)
        if best_r and params_r:
            regime_params["regimes"]["RANGING"] = {
                "params": params_r,
                "stats": {k: best_r[k] for k in ["total_trades", "win_rate", "total_pnl", "score"]},
                "bars": len(ranging_df),
            }
    else:
        print("RANGING 子集样本不足，跳过")

    # 保存
    regime_file = output_dir / "regime_params.json"
    _atomic_write_json(regime_file, regime_params)
    print(f"\nRegime 参数已保存到 {regime_file}")

    # 全局最优（合并训练）
    print("\n--- 全局子集训练（对照） ---")
    df_eval = compute_indicators(df).dropna()
    if len(df_eval) >= 50:
        best_all, params_all = _grid_search(df_eval)
        if best_all and params_all:
            regime_params["GLOBAL"] = {
                "params": params_all,
                "stats": {k: best_all[k] for k in ["total_trades", "win_rate", "total_pnl", "score"]},
                "bars": len(df_eval),
            }
            _atomic_write_json(regime_file, regime_params)

    return regime_params


def _grid_search(df: pd.DataFrame) -> tuple[Optional[dict], Optional[dict]]:
    """单次网格搜索，返回 (best_stats, best_params)。"""
    param_grid = {
        "ema_short": [7, 9, 12],
        "ema_long": [21, 26, 50],
        "sl_atr": [1.0, 1.5, 2.0],
        "tp_atr": [1.5, 2.0, 3.0],
    }
    keys = list(param_grid.keys())
    combinations = list(itertools.product(*param_grid.values()))

    best_stats = None
    best_params = None
    for vals in combinations:
        params = dict(zip(keys, vals))
        df_eval = compute_indicators(df, ema_short=params["ema_short"], ema_long=params["ema_long"]).dropna()
        if len(df_eval) < 10:
            continue
        stats = backtest_strategy(df_eval, params, verbose=False)
        if best_stats is None or stats["score"] > best_stats["score"]:
            best_stats = stats
            best_params = params
    return best_stats, best_params


# ═══════════════════════════════════════════════════════════════
# 反馈回路4: 写入生效参数
# ═══════════════════════════════════════════════════════════════

def _write_effective_params(best_params: dict, best_stats: dict,
                            output_dir: Path, method: str = "grid") -> Path:
    """训练完成后自动把最优参数写入 effective_params.json。

    写入到 config_loader 能读取的 logs/ 目录（而非仅 data/offline/），
    确保 pipeline 运行时能加载到优化结果。

    auto_apply: false 保留人工审批环节（安全门）。
    """
    import config_manager
    logs_dir = config_manager.get_logs_dir()
    effective_file = logs_dir / "effective_params.json"
    existing: dict[str, Any] = {}
    if effective_file.exists():
        try:
            existing = json.loads(effective_file.read_text(encoding="utf-8"))
        except Exception:
            pass

    compact_stats = _compact_stats(best_stats)
    entry = {
        "params": best_params,
        "stats": compact_stats,
        "method": method,
        "applied_at": _now_iso(),
        "auto_apply": False,  # 安全门：等人工审批
        "applied": False,
        "source": "offline_training",
    }

    # 保留历史记录
    history = existing.get("history", [])
    history.append(entry)
    history = history[-20:]  # 最多20条

    suggestions = [
        {
            "id": f"TRAINING_{method.upper()}",
            "param": str(param),
            "applied_value": value,
            "suggested_value": value,
            "applied": False,
            "auto_apply": False,
            "source": "offline_training",
            "note": (
                f"来源:离线训练({compact_stats.get('total_trades', 0)}笔,"
                f"胜率{compact_stats.get('win_rate', 0):.1f}%)"
            ),
        }
        for param, value in best_params.items()
    ]

    payload = {
        "params": dict(best_params),
        "suggestions": suggestions,
        "offline_params": {
            **dict(best_params),
            "review_pending": True,
        },
        "quality_context": {
            "source": "offline_training",
            "total_trades": compact_stats.get("total_trades", 0),
            "win_rate": compact_stats.get("win_rate", 0.0),
            "total_pnl": compact_stats.get("total_pnl", 0.0),
            "avg_trade_pnl": compact_stats.get("avg_trade_pnl", 0.0),
        },
        "current": entry,
        "history": history,
        "updated_at": _now_iso(),
        "source": "offline_training",
    }
    _atomic_write_json(effective_file, payload)
    print(f"[回路4] effective_params → {effective_file}")
    return effective_file


def _auto_apply_effective_params(output_dir: Path) -> None:
    """回路4 CLI 自动应用: 将 effective_params.json 中待审批项设为 applied=True。"""
    effective_file = config_manager.get_logs_dir() / "effective_params.json"
    if not effective_file.exists():
        print("[回路4] effective_params.json 不存在，跳过自动应用")
        return
    try:
        payload = json.loads(effective_file.read_text(encoding="utf-8"))
        current = payload.get("current", {})
        if current.get("applied"):
            print("[回路4] 当前参数已应用，跳过")
            return
        current["auto_apply"] = True
        current["applied"] = True
        current["applied_at"] = _now_iso()
        payload["current"] = current
        suggestions = payload.get("suggestions", [])
        if isinstance(suggestions, list):
            for suggestion in suggestions:
                if isinstance(suggestion, dict):
                    suggestion["auto_apply"] = True
                    suggestion["applied"] = True
                    if suggestion.get("applied_value") is None:
                        suggestion["applied_value"] = suggestion.get("suggested_value")
        offline_params = payload.get("offline_params", {})
        if isinstance(offline_params, dict):
            offline_params["review_pending"] = False
        payload["updated_at"] = _now_iso()
        _atomic_write_json(effective_file, payload)
        print(f"[回路4] --auto-apply: 参数已自动应用 → {effective_file}")
    except Exception as e:
        print(f"[回路4] 自动应用失败: {e}")


def _now_iso() -> str:
    return datetime.now().isoformat()


def main():
    ap = argparse.ArgumentParser(description="离线训练与参数优化")
    ap.add_argument("--days", type=int, default=30)
    ap.add_argument("--interval", type=str, default="5m")
    ap.add_argument("--symbol", type=str, default="GC=F")
    default_out = str(config_manager.get_data_dir() / "offline")
    ap.add_argument("--output-dir", type=str, default=default_out)
    ap.add_argument("--mode", type=str, default="train", choices=["once", "train"])
    ap.add_argument("--method", type=str, default="grid", choices=["grid", "bayesian"])
    ap.add_argument("--bayesian-trials", type=int, default=200)
    ap.add_argument("--regime-aware", action="store_true",
                    help="回路2: 按 WEAK_TREND/RANGING 拆分独立训练")
    ap.add_argument("--auto-apply", action="store_true",
                    help="回路4: 自动应用最优参数 (跳过人工审批)")
    ap.add_argument("--poll-seconds", type=float, default=60)
    args = ap.parse_args()

    out_dir = Path(args.output_dir)
    out_dir.mkdir(parents=True, exist_ok=True)

    print(f"获取 {args.symbol} 近 {args.days} 天 {args.interval} 数据...")
    tk = yf.Ticker(args.symbol)
    df = None
    for attempt in range(3):
        try:
            df = tk.history(period=f"{args.days}d", interval=args.interval)
            if df is not None and len(df) > 0:
                break
        except Exception as e:
            print(f"  获取数据尝试 {attempt+1}/3 失败: {e}")
        if attempt < 2:
            import time; time.sleep(5)
    if df is None or df.empty:
        print(f"错误: 无法获取 {args.symbol} 数据"); sys.exit(1)

    print(f"有效K线: {len(df)} 根")
    df = compute_indicators(df).dropna()

    if args.mode == "train":
        if args.regime_aware:
            train_parameters_regime_aware(df, out_dir)
        elif args.method == "bayesian":
            train_parameters_bayesian(df, out_dir, n_trials=args.bayesian_trials)
        else:
            train_parameters(df, out_dir)

        if args.auto_apply:
            _auto_apply_effective_params(out_dir)
    else:
        # 默认参数单次测试
        stats = backtest_strategy(df, {"ema_short": 9, "ema_long": 21, "sl_atr": 1.0, "tp_atr": 3.0}, verbose=True)
        print(f"\n单次测试结果:")
        print(f"交易数: {stats['total_trades']} | 胜率: {stats['win_rate']}% | 盈亏: ${stats['total_pnl']}")

        out_file = out_dir / f"offline_train_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
        results = {
            "stats": {
                "total_bars": len(df),
                "total_trades": stats["total_trades"],
                "wins": stats["wins"],
                "losses": stats["losses"],
                "total_pnl": stats["total_pnl"],
                "win_rate": stats["win_rate"],
                "by_regime": stats["by_regime"],
                "by_side": stats["by_side"],
            },
            "trades": stats["trades_list"]
        }
        _atomic_write_json(out_file, results)

if __name__ == "__main__":
    main()
