"""
===============================================================
Stochastic Lightspeed — 自动化交易系统
===============================================================
策略: Stochastic Oscillator 均值回归
参数: K=5, D=3, OB=65, OS=30, SL=0.2xATR, TP=1.5xATR, Cooldown=8 bars
周期: M15, 7 货币对
===============================================================
"""
import MetaTrader5 as mt5
import pandas as pd
import numpy as np
import tkinter as tk
from tkinter import scrolledtext, ttk
import threading
import time
import datetime
import sys
import os
import json
import logging
from logging.handlers import RotatingFileHandler

# ================================================================
# 策略参数
# ================================================================
SYMBOLS       = ["EURUSD", "GBPUSD", "USDJPY", "USDCHF", "AUDUSD", "NZDUSD", "USDCAD"]
TIMEFRAME     = mt5.TIMEFRAME_M15
MAGIC         = 24387783
LOT           = 0.1
K_PERIOD      = 5
D_PERIOD      = 3
OB_LEVEL      = 65.0
OS_LEVEL      = 30.0
ATR_PERIOD    = 14
ATR_SL        = 0.2
ATR_TP        = 1.5
COOLDOWN_BARS = 8
MAX_BARS_HOLD = 25       # 最大持仓 bars
DEVIATION     = 20        # 滑点 points

# 时区
TZ_BJ  = datetime.timezone(datetime.timedelta(hours=8))   # 北京 UTC+8
TZ_MT5 = datetime.timezone(datetime.timedelta(hours=3))   # MT5 服务器 UTC+3

# 日志路径
LOG_DIR  = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
LOG_FILE = os.path.join(LOG_DIR, "stochastic_lightspeed.log")
os.makedirs(LOG_DIR, exist_ok=True)

# ================================================================
# 日志设置（兼容 Python < 3.9）
# ================================================================
class BJFormatter(logging.Formatter):
    """北京时间格式化器"""
    def formatTime(self, record, datefmt=None):
        dt = datetime.datetime.fromtimestamp(record.created, tz=TZ_BJ)
        if datefmt:
            return dt.strftime(datefmt)
        return dt.strftime("%Y-%m-%d %H:%M:%S")

logger = logging.getLogger("Lightspeed")
logger.setLevel(logging.DEBUG)
fh = RotatingFileHandler(LOG_FILE, maxBytes=5*1024*1024, backupCount=3, encoding="utf-8")
fh.setLevel(logging.DEBUG)
ch = logging.StreamHandler(sys.stdout)
ch.setLevel(logging.INFO)
fmt = BJFormatter("%(asctime)s [%(levelname)s] %(message)s")
fh.setFormatter(fmt)
ch.setFormatter(fmt)
logger.addHandler(fh)
logger.addHandler(ch)

def log_info(msg):  logger.info(msg)
def log_warn(msg):  logger.warning(msg)
def log_error(msg): logger.error(msg)
def log_debug(msg):  logger.debug(msg)

# ================================================================
# 工具函数
# ================================================================
def pip_size(sym):
    return 0.01 if sym in ["USDJPY", "GBPJPY", "EURJPY"] else 0.0001

def pip_factor(sym):
    return 10.0  # $10 per pip per 0.1 lot

def server_now():
    """MT5 服务器当前时间（UTC+3，转为北京时间显示）"""
    return datetime.datetime.now(TZ_MT5)

def bar_open_time(srv_time=None):
    """当前 M15 柱的开盘时间（UTC+3）"""
    if srv_time is None:
        srv_time = server_now()
    minute = (srv_time.minute // 15) * 15
    return srv_time.replace(minute=minute, second=0, microsecond=0)

def next_bar_open(srv_time=None):
    """下一根 M15 柱的开盘时间"""
    bt = bar_open_time(srv_time)
    return bt + datetime.timedelta(minutes=15)

def seconds_to_next_bar(srv_time=None):
    """距下一根 M15 柱的秒数"""
    if srv_time is None:
        srv_time = server_now()
    nbo = next_bar_open(srv_time)
    delta = nbo - srv_time
    return max(0, int(delta.total_seconds()))

def fmt_countdown(secs):
    m, s = divmod(secs, 60)
    return f"{m:02d}:{s:02d}"

def bj_time(ts=None):
    """转为北京时间字符串"""
    if ts is None:
        ts = datetime.datetime.now(TZ_BJ)
    elif isinstance(ts, (int, float)):
        ts = datetime.datetime.fromtimestamp(ts, tz=TZ_BJ)
    return ts.strftime("%H:%M:%S")

def srv_time_str(ts=None):
    """转为服务器时间字符串"""
    if ts is None:
        ts = server_now()
    return ts.strftime("%H:%M:%S")

# ================================================================
# 指标计算
# ================================================================
def calc_indicators(df):
    """计算所有指标（严格 shift(1) 无泄露）"""
    df = df.copy()
    # Stochastic K=5
    low5  = df["low"].rolling(K_PERIOD).min().shift(1)
    high5 = df["high"].rolling(K_PERIOD).max().shift(1)
    k_raw = 100.0 * (df["close"].shift(1) - low5) / (high5 - low5 + 1e-9)
    df["stoch_k"] = k_raw.rolling(D_PERIOD, min_periods=1).mean()
    df["stoch_d"] = df["stoch_k"].rolling(D_PERIOD, min_periods=1).mean()
    # ATR
    tr = np.maximum(df["high"] - df["low"],
                    np.maximum(np.abs(df["high"] - df["close"].shift(1)),
                               np.abs(df["low"]  - df["close"].shift(1))))
    df["atr"] = tr.rolling(ATR_PERIOD).mean().shift(1)
    return df

# ================================================================
# 信号生成
# ================================================================
def gen_signal(df, cooldown_dict, sym):
    """
    生成交易信号（基于最新已完成 K 线 i-1，判断是否在 bar i 入场）
    返回: None 或 (direction, entry, sl, tp)
    direction: 1=做多, -1=做空
    """
    if len(df) < K_PERIOD + D_PERIOD + 3:
        return None

    n = len(df)
    i = n - 1  # 最新 K 线

    # 检查 cooldown
    last_bar_time = df["time"].iloc[i].value
    last_sig = cooldown_dict.get(sym)
    if last_sig:
        last_time, last_dir = last_sig
        bars_since = (last_bar_time - last_time) // (15 * 60 * 1_000_000_000)
        if bars_since < COOLDOWN_BARS:
            return None

    k  = df["stoch_k"].iloc[i]
    k0 = df["stoch_k"].iloc[i-1]
    d  = df["stoch_d"].iloc[i]
    d0 = df["stoch_d"].iloc[i-1]
    atr = df["atr"].iloc[i]

    if pd.isna(k) or pd.isna(d) or pd.isna(atr) or atr <= 0:
        return None

    entry = df["open"].iloc[i]  # 当前柱开盘价（入场价）
    sl_dist = ATR_SL * atr
    tp_dist = ATR_TP * atr

    # 做多：K 在超卖区上穿 D
    if (k0 <= d0) and (k > d) and (k < OS_LEVEL) and (d < OS_LEVEL):
        sl = entry - sl_dist
        tp = entry + tp_dist
        log_debug(f"  BUY  signal: k={k:.1f} crossed above d={d:.1f} (OS<{OS_LEVEL})  entry={entry:.5f}  sl={sl:.5f}  tp={tp:.5f}")
        return (1, entry, sl, tp)

    # 做空：K 在超买区下穿 D
    if (k0 >= d0) and (k < d) and (k > OB_LEVEL) and (d > OB_LEVEL):
        sl = entry + sl_dist
        tp = entry - tp_dist
        log_debug(f"  SELL signal: k={k:.1f} crossed below d={d:.1f} (OB>{OB_LEVEL})  entry={entry:.5f}  sl={sl:.5f}  tp={tp:.5f}")
        return (-1, entry, sl, tp)

    return None

# ================================================================
# MT5 订单管理
# ================================================================
class OrderManager:
    def __init__(self, magic):
        self.magic = magic

    def has_open_position(self, sym, direction):
        """检查是否有同向同品种持仓"""
        positions = mt5.positions_get(symbol=sym)
        if positions is None:
            return False
        for pos in positions:
            if pos.magic == self.magic and pos.volume > 0:
                if direction == 1 and pos.type == mt5.POSITION_TYPE_BUY:
                    return True
                if direction == -1 and pos.type == mt5.POSITION_TYPE_SELL:
                    return True
        return False

    def send_order(self, sym, direction, lot, sl, tp, comment=""):
        """发送市价单"""
        tick = mt5.symbol_info_tick(sym)
        if tick is None:
            log_error(f"获取 {sym} tick 失败")
            return None

        price = tick.ask if direction == 1 else tick.bid
        action = mt5.TRADE_ACTION_DEAL
        order_type = mt5.ORDER_TYPE_BUY if direction == 1 else mt5.ORDER_TYPE_SELL

        request = {
            "action":     action,
            "symbol":     sym,
            "volume":     lot,
            "type":       order_type,
            "price":      price,
            "sl":         sl,
            "tp":         tp,
            "deviation":  DEVIATION,
            "magic":      self.magic,
            "comment":    comment,
            "type_filling": mt5.ORDER_FILLING_RETURN,
        }

        result = mt5.order_send(request)
        if result is None:
            log_error(f"order_send 返回 None: {mt5.last_error()}")
            return None
        if result.retcode != mt5.TRADE_RETCODE_DONE:
            log_error(f"下单失败 [{sym}] {direction} {lot}lots: retcode={result.retcode} {result.comment}")
            return None

        order_id = result.order
        log_info(f"✅ 下单成功 [{sym}] {'BUY' if direction==1 else 'SELL'} {lot}lots @ {price:.5f}  SL={sl:.5f}  TP={tp:.5f}  ID={order_id}")
        return order_id

    def get_positions(self):
        """获取所有相关持仓"""
        all_pos = mt5.positions_get()
        if all_pos is None:
            return []
        return [p for p in all_pos if p.magic == self.magic]

    def close_position(self, ticket):
        """市价平仓"""
        pos = mt5.positions_get(ticket=ticket)
        if pos is None or len(pos) == 0:
            log_error(f"未找到持仓 Ticket={ticket}")
            return False
        
        pos = pos[0]
        tick = mt5.symbol_info_tick(pos.symbol)
        if tick is None:
            log_error(f"获取 {pos.symbol} tick 失败")
            return False
            
        direction = mt5.ORDER_TYPE_SELL if pos.type == mt5.POSITION_TYPE_BUY else mt5.ORDER_TYPE_BUY
        price = tick.bid if direction == mt5.ORDER_TYPE_SELL else tick.ask
        
        request = {
            "action":   mt5.TRADE_ACTION_DEAL,
            "symbol":   pos.symbol,
            "volume":   pos.volume,
            "type":     direction,
            "position": ticket,
            "price":    price,
            "deviation": DEVIATION,
            "magic":    self.magic,
            "comment":  "手动平仓",
            "type_filling": mt5.ORDER_FILLING_RETURN,
        }
        result = mt5.order_send(request)
        if result and result.retcode == mt5.TRADE_RETCODE_DONE:
            log_info(f"✅ 平仓成功 ID={ticket}")
            return True
        log_error(f"平仓失败 ID={ticket}: {result.comment if result else 'None'}")
        return False

# ================================================================
# 数据管理器
# ================================================================
class DataManager:
    def __init__(self, symbols):
        self.symbols = symbols
        self.data = {}      # {sym: df}
        self.bar_count = 200  # 保留最近 N 根

    def fetch(self, sym):
        """从 MT5 获取最新数据并计算指标"""
        rates = mt5.copy_rates_from_pos(sym, TIMEFRAME, 0, self.bar_count)
        if rates is None or len(rates) < 50:
            log_warn(f"获取 {sym} 数据失败: {mt5.last_error()}")
            return None
        df = pd.DataFrame(rates)
        df["time"] = pd.to_datetime(df["time"], unit="s")
        df = calc_indicators(df)
        self.data[sym] = df
        return df

    def fetch_all(self):
        for sym in self.symbols:
            self.fetch(sym)

    def get_latest_bar_time(self, sym):
        """获取最新一根 K 线的时间（UTC+3）"""
        if sym in self.data and len(self.data[sym]) > 0:
            return self.data[sym]["time"].iloc[-1].to_pydatetime()
        return None

# ================================================================
# GUI
# ================================================================
DARK_BG    = "#0d1117"
DARK_FG    = "#c9d1d9"
DARK_PANEL = "#161b22"
DARK_BORDER= "#30363d"
GREEN      = "#3fb950"
RED        = "#f85149"
YELLOW     = "#d29922"
BLUE       = "#58a6ff"
GRAY       = "#8b949e"

FONT_MAIN  = ("Consolas", 9)
FONT_BOLD  = ("Consolas", 9, "bold")
FONT_COUNT = ("Consolas", 24, "bold")
FONT_TITLE = ("Consolas", 11, "bold")

class TradingGUI:
    def __init__(self, root, data_mgr, order_mgr, cooldown_dict):
        self.root = root
        self.data_mgr = data_mgr
        self.order_mgr = order_mgr
        self.cooldown_dict = cooldown_dict
        self.running = True
        self.last_bar_times = {}  # {sym: last_bar_time_str}

        root.title("📡 Stochastic Lightspeed — 自动化交易系统")
        root.geometry("1400x800")
        root.configure(bg=DARK_BG)
        root.protocol("WM_DELETE_WINDOW", self.on_close)

        self._build_header()
        self._build_signal_panel()
        self._build_positions_panel()
        self._build_log_panel()
        self._build_statusbar()

        self._update_loop()

    # ──────────────────────────────────────────────────────────
    def _build_header(self):
        hf = tk.Frame(self.root, bg=DARK_BG)
        hf.pack(fill="x", padx=10, pady=(10, 5))

        # 标题
        title = tk.Label(hf, text="📡  Stochastic Lightspeed", font=FONT_TITLE,
                          fg=BLUE, bg=DARK_BG)
        title.pack(side="left")

        # 倒计时
        self.countdown_var = tk.StringVar(value="--:--")
        cf = tk.Frame(hf, bg=DARK_PANEL, padx=12, pady=4)
        cf.pack(side="right")
        tk.Label(cf, text="下一根 M15", font=FONT_MAIN, fg=GRAY, bg=DARK_PANEL).pack()
        tk.Label(cf, textvariable=self.countdown_var, font=FONT_COUNT, fg=YELLOW, bg=DARK_PANEL).pack()

        # 账户信息
        acc = mt5.account_info()
        if acc:
            self.account_var = tk.StringVar(value=f"账户: {acc.login}  服务器: {acc.server}")
        else:
            self.account_var = tk.StringVar(value="账户: 未连接")
        tk.Label(hf, textvariable=self.account_var, font=FONT_MAIN, fg=GRAY, bg=DARK_BG).pack(side="right", padx=20)

    # ──────────────────────────────────────────────────────────
    def _build_signal_panel(self):
        pf = tk.LabelFrame(self.root, text="📊 当前信号 & 数据",
                           font=FONT_BOLD, fg=BLUE, bg=DARK_BG,
                           padx=10, pady=5)
        pf.pack(fill="both", padx=10, pady=(5, 5), ipady=5)

        # 表头
        hdrs = ["货币对","K值","D值","OB/OS","ATR(pips)","最新收盘","最新时间","信号状态"]
        for i, h in enumerate(hdrs):
            tk.Label(pf, text=h, font=FONT_BOLD, fg=GRAY, bg=DARK_PANEL,
                     width=12, anchor="center", relief="groove",
                     bd=1).grid(row=0, column=i, padx=1, pady=1)

        self.sym_rows = {}
        for row_i, sym in enumerate(SYMBOLS, 1):
            self.sym_rows[sym] = {}
            for col_i in range(len(hdrs)):
                var = tk.StringVar(value="—")
                fg = DARK_FG
                if col_i == 0:
                    lbl = tk.Label(pf, text=sym, font=FONT_BOLD, fg=GREEN, bg=DARK_PANEL,
                                    width=12, anchor="center", relief="groove", bd=1)
                else:
                    lbl = tk.Label(pf, textvariable=var, font=FONT_MAIN, fg=fg, bg=DARK_PANEL,
                                    width=12, anchor="center", relief="groove", bd=1)
                lbl.grid(row=row_i, column=col_i, padx=1, pady=1)
                self.sym_rows[sym][col_i] = (var, lbl)

    # ──────────────────────────────────────────────────────────
    def _build_positions_panel(self):
        posf = tk.LabelFrame(self.root, text="💼 当前持仓",
                             font=FONT_BOLD, fg=YELLOW, bg=DARK_BG,
                             padx=10, pady=5)
        posf.pack(fill="both", padx=10, pady=(5, 5), ipady=5)

        hdrs = ["Ticket","品种","方向","手数","开仓价","当前价","SL","TP","浮盈(Pips)","浮盈($)"]
        for i, h in enumerate(hdrs):
            tk.Label(posf, text=h, font=FONT_BOLD, fg=GRAY, bg=DARK_PANEL,
                     width=11, anchor="center", relief="groove", bd=1).grid(row=0, column=i, padx=1, pady=1)

        self.pos_rows = []
        for r in range(1, 8):
            row_data = {}
            for c in range(len(hdrs)):
                var = tk.StringVar(value="")
                lbl = tk.Label(posf, textvariable=var, font=FONT_MAIN,
                                fg=DARK_FG, bg=DARK_PANEL, width=11,
                                anchor="center", relief="groove", bd=1)
                lbl.grid(row=r, column=c, padx=1, pady=1)
                row_data[c] = var
            self.pos_rows.append(row_data)

    # ──────────────────────────────────────────────────────────
    def _build_log_panel(self):
        logf = tk.LabelFrame(self.root, text="📋 日志",
                             font=FONT_BOLD, fg=GREEN, bg=DARK_BG,
                             padx=5, pady=5)
        logf.pack(fill="both", expand=True, padx=10, pady=(5, 5))

        self.log_text = scrolledtext.ScrolledText(
            logf, font=("Consolas", 8), fg=DARK_FG, bg=DARK_PANEL,
            insertbackground=DARK_FG, relief="flat", state="disabled",
            width=200, height=12
        )
        self.log_text.pack(fill="both", expand=True)

        # 高亮 tag
        self.log_text.tag_configure("BUY",  foreground=GREEN)
        self.log_text.tag_configure("SELL", foreground=RED)
        self.log_text.tag_configure("INFO", foreground=DARK_FG)
        self.log_text.tag_configure("WARN", foreground=YELLOW)
        self.log_text.tag_configure("ERROR",foreground=RED)
        self.log_text.tag_configure("DATA", foreground=GRAY)

        btn_f = tk.Frame(logf, bg=DARK_BG)
        btn_f.pack(fill="x", pady=(3,0))
        tk.Button(btn_f, text="清空日志", font=FONT_MAIN, command=self.clear_log,
                  bg=DARK_PANEL, fg=DARK_FG, relief="groove").pack(side="left")

    def append_log(self, msg, tag="INFO"):
        dt = datetime.datetime.now(TZ_BJ).strftime("%H:%M:%S")
        line = f"[{dt}] {msg}\n"
        self.log_text.configure(state="normal")
        self.log_text.insert("end", line, tag)
        self.log_text.see("end")
        self.log_text.configure(state="disabled")

    def clear_log(self):
        self.log_text.configure(state="normal")
        self.log_text.delete("1.0", "end")
        self.log_text.configure(state="disabled")

    # ──────────────────────────────────────────────────────────
    def _build_statusbar(self):
        self.status_var = tk.StringVar(value="系统就绪")
        sb = tk.Label(self.root, textvariable=self.status_var, font=FONT_MAIN,
                      fg=GRAY, bg=DARK_PANEL, anchor="w", padx=10)
        sb.pack(fill="x", side="bottom")

    # ──────────────────────────────────────────────────────────
    def _update_loop(self):
        if not self.running:
            return
        try:
            self._update()
        except Exception as e:
            self.append_log(f"GUI更新异常: {e}", "ERROR")
        self.root.after(1000, self._update_loop)

    def _update(self):
        srv_now = server_now()

        # 1. 倒计时
        secs = seconds_to_next_bar(srv_now)
        self.countdown_var.set(fmt_countdown(secs))

        # 2. 信号数据
        for sym in SYMBOLS:
            df = self.data_mgr.data.get(sym)
            if df is None or len(df) < K_PERIOD + D_PERIOD + 2:
                for c in range(1, 8):
                    if c in self.sym_rows[sym]:
                        self.sym_rows[sym][c][0].set("—")
                continue

            n = len(df)
            i = n - 1
            k  = df["stoch_k"].iloc[i]
            k0 = df["stoch_k"].iloc[i-1]
            d  = df["stoch_d"].iloc[i]
            d0 = df["stoch_d"].iloc[i-1]
            atr = df["atr"].iloc[i]
            close = df["close"].iloc[i]
            t = df["time"].iloc[i]

            # 状态判断
            k_ok = not pd.isna(k) and not pd.isna(d)
            if not k_ok:
                status = "⚙️ 加载中"
                color = GRAY
            elif k < OS_LEVEL and d < OS_LEVEL:
                status = "🟢 超卖"
                color = GREEN
            elif k > OB_LEVEL and d > OB_LEVEL:
                status = "🔴 超买"
                color = RED
            elif k < OS_LEVEL:
                status = "🟡 偏弱"
                color = YELLOW
            elif k > OB_LEVEL:
                status = "🟡 偏强"
                color = YELLOW
            else:
                status = "⚪ 中性"
                color = GRAY

            # K/D 交叉信号
            cross = ""
            cross_color = GRAY
            if k_ok and k0 <= d0 and k > d and k < OS_LEVEL and d < OS_LEVEL:
                cross = "↑ 金叉"
                cross_color = GREEN
            elif k_ok and k0 >= d0 and k < d and k > OB_LEVEL and d > OB_LEVEL:
                cross = "↓ 死叉"
                cross_color = RED

            atr_pips = (atr / pip_size(sym)) if (not pd.isna(atr) and atr > 0) else 0

            row = self.sym_rows[sym]
            row[1][0].set(f"{k:.1f}" if not pd.isna(k) else "—")
            row[2][0].set(f"{d:.1f}" if not pd.isna(d) else "—")
            row[3][0].set(f"{k:.1f}/{d:.1f} {cross}")
            row[4][0].set(f"{atr_pips:.1f}")
            row[5][0].set(f"{close:.5f}")
            row[6][0].set(t.strftime("%H:%M"))
            row[7][0].set(status)
            row[7][1].config(fg=color)

        # 3. 持仓
        positions = self.order_mgr.get_positions()
        for r in range(7):
            row = self.pos_rows[r]
            if r < len(positions):
                pos = positions[r]
                sym = pos.symbol
                tick = mt5.symbol_info_tick(sym)
                if tick:
                    cur = tick.bid if pos.type == mt5.POSITION_TYPE_BUY else tick.ask
                    pnl_pips = (cur - pos.price_open) / pip_size(sym) if pos.type == mt5.POSITION_TYPE_BUY \
                               else (pos.price_open - cur) / pip_size(sym)
                    pnl_dol = pnl_pips * pip_factor(sym) * pos.volume / 0.1
                    dir_str = "BUY 🟢" if pos.type == mt5.POSITION_TYPE_BUY else "SELL 🔴"
                    
                    row[0].set(str(pos.ticket))
                    row[1].set(sym)
                    row[2].set(dir_str)
                    row[3].set(f"{pos.volume:.2f}")
                    row[4].set(f"{pos.price_open:.5f}")
                    row[5].set(f"{cur:.5f}")
                    row[6].set(f"{pos.sl:.5f}")
                    row[7].set(f"{pos.tp:.5f}")
                    row[8].set(f"{pnl_pips:+.1f}")
                    row[9].set(f"{pnl_dol:+.2f}")
            else:
                for c in range(10):
                    row[c].set("")

        # 4. 状态栏
        self.status_var.set(
            f"MT5连接: OK  |  服务器: {srv_now.strftime('%Y-%m-%d %H:%M:%S')}  |  "
            f"策略: Stoch({K_PERIOD},{D_PERIOD}) OB={OB_LEVEL} OS={OS_LEVEL} "
            f"SL={ATR_SL}xA TP={ATR_TP}xA  |  持仓: {len(positions)}笔"
        )

    def on_close(self):
        self.running = False
        self.root.destroy()

# ================================================================
# 主控制器
# ================================================================
class TradingController:
    def __init__(self):
        log_info("=" * 60)
        log_info("Stochastic Lightspeed 启动")
        log_info("=" * 60)

        # MT5 初始化
        if not mt5.initialize():
            log_error(f"MT5 初始化失败: {mt5.last_error()}")
            sys.exit(1)
        log_info(f"MT5 初始化成功: {mt5.account_info().login} @ {mt5.account_info().server}")

        self.dm  = DataManager(SYMBOLS)
        self.om  = OrderManager(MAGIC)
        self.cd  = {}  # cooldown: {sym: (last_bar_time_ns, last_direction)}
        self.last_bar_times = {}

        # 预加载数据
        log_info("预加载数据...")
        self.dm.fetch_all()
        for sym in SYMBOLS:
            df = self.dm.data.get(sym)
            if df is not None:
                bt = self.dm.get_latest_bar_time(sym)
                self.last_bar_times[sym] = bt
                log_info(f"  {sym}: {len(df)} bars, 最新 {bt}")

        # GUI
        self.root = tk.Tk()
        self.gui = TradingGUI(self.root, self.dm, self.om, self.cd)

        # 主循环线程
        self.loop_running = True
        self.last_processed_bar = {}  # {sym: bar_time}

        self.loop_thread = threading.Thread(target=self._run_loop, daemon=True)
        self.loop_thread.start()

        log_info("主循环已启动")
        self.root.mainloop()
        self._shutdown()

    # ──────────────────────────────────────────────────────────
    def _run_loop(self):
        log_info("主循环线程开始")
        while self.loop_running:
            try:
                self._check_and_trade()
            except Exception as e:
                log_error(f"主循环异常: {e}")
            time.sleep(10)  # 每 10 秒检查一次

    def _check_and_trade(self):
        srv_now = server_now()
        bars_changed = False

        for sym in SYMBOLS:
            df = self.dm.fetch(sym)
            if df is None:
                continue

            latest_bar_time = df["time"].iloc[-1].to_pydatetime()
            last_processed = self.last_processed_bar.get(sym)

            # 新柱到达 → 检查信号
            if last_processed is None or latest_bar_time > last_processed:
                self.last_processed_bar[sym] = latest_bar_time
                bars_changed = True
                log_debug(f"[{sym}] 新柱 {latest_bar_time.strftime('%H:%M')} — 检查信号...")

                signal = gen_signal(df, self.cd, sym)

                if signal:
                    direction, entry, sl, tp = signal
                    comment = f"stoch_L{K_PERIOD}_{OB_LEVEL}_{OS_LEVEL}_sl{ATR_SL}_tp{ATR_TP}"

                    # 检查是否已有同向持仓
                    if self.om.has_open_position(sym, direction):
                        log_info(f"[{sym}] ⏭ 已有同向持仓，跳过信号")
                        continue

                    # 下单
                    order_id = self.om.send_order(sym, direction, LOT, sl, tp, comment)
                    if order_id:
                        self.cd[sym] = (latest_bar_time.value, direction)
                        self.gui.append_log(
                            f"[{sym}] {'🟢 BUY' if direction==1 else '🔴 SELL'} "
                            f"entry={entry:.5f} SL={sl:.5f}({ATR_SL}xA) TP={tp:.5f}({ATR_TP}xA) "
                            f"K={df['stoch_k'].iloc[-1]:.1f} D={df['stoch_d'].iloc[-1]:.1f} "
                            f"ATR={df['atr'].iloc[-1]:.5f}({df['atr'].iloc[-1]/pip_size(sym):.1f}pips) "
                            f"@ {srv_now.strftime('%H:%M:%S')}",
                            "BUY" if direction == 1 else "SELL"
                        )
            else:
                log_debug(f"[{sym}] 无新柱，继续等待")

        if bars_changed:
            log_info(f"[{srv_now.strftime('%H:%M:%S')}] 数据扫描完成")

    # ──────────────────────────────────────────────────────────
    def _shutdown(self):
        log_info("系统关闭中...")
        self.loop_running = False
        mt5.shutdown()
        log_info("MT5 已断开")

# ================================================================
# 入口
# ================================================================
if __name__ == "__main__":
    try:
        ctrl = TradingController()
    except Exception as e:
        log_error(f"启动失败: {e}")
        import traceback
        traceback.print_exc()