# -*- coding: utf-8 -*- """黄金市价辅助下单窗:连接本机已登录的 MT5;可选激活 gold_monitor 上报复盘。""" from __future__ import annotations import math import sys import threading import time import webbrowser import tkinter as tk from datetime import datetime, timedelta, timezone from tkinter import messagebox, simpledialog, ttk from typing import Any, Optional, Tuple, Union BJT = timezone(timedelta(hours=8)) # 顶层导入,避免打包 exe 漏依赖 import numpy # noqa: F401 import MetaTrader5 as mt5 try: import hub_client from activation_dialog import ensure_activated from device_store import is_activated, load_device except ImportError: # 允许单独拷贝本文件调试 hub_client = None # type: ignore ensure_activated = None # type: ignore is_activated = lambda: False # type: ignore load_device = lambda: {} # type: ignore APP_TITLE = "黄金市价辅助" DEFAULT_SYMBOL = "XAUUSDm" DEFAULT_LOT = 0.10 DEFAULT_ATR_MULT = 1.0 DEFAULT_TP_ATR_MULT = 1.5 DEFAULT_TP_USD = 12.0 DEFAULT_PARTIAL = 0.10 ATR_PERIOD = 14 ATR_TIMEFRAME = None MAGIC = 20261016 COMMENT = "GoldPanel" DEVIATION = 100 MUST_CONFIRM = False # 开仓/平仓默认不二次确认 REFRESH_MS = 500 BALANCE_HIGH = 10000.0 BALANCE_LOW = 1000.0 LOT_HIGH = 0.10 LOT_MID = 0.05 LOT_LOW = 0.01 MAX_SIDE_LOTS = 0.50 MAX_OPEN_POSITIONS = 5 # 同时最多持仓笔数,满则买卖置灰 POS_ROW_HEIGHT = 46 # 持仓列表单行约高(含内边距) POS_LIST_HEIGHT = POS_ROW_HEIGHT * MAX_OPEN_POSITIONS # 默认可视 5 条持仓高度 DAY_LOSS_LOCK = 0.05 # 当日合计亏损达余额 5% 禁止开仓 DAY_PROFIT_LOCK = 0.10 # 当日合计盈利达余额 10% 禁止开仓 # 币圈习惯:绿涨红跌 COLOR_PROFIT = "#0ecb81" COLOR_LOSS = "#f6465d" COLOR_DANGER = "#f6465d" # 平仓按钮边框 COLOR_NEUTRAL = "#c0c0c0" COLOR_BE = "#3a5a80" COLOR_BE_OFF = "#555555" COLOR_BUY = "#108040" COLOR_SELL = "#a02828" COLOR_BTN_OFF = "#555555" COLOR_WARN = "#e6a23c" COLOR_BG = "#121218" COLOR_CARD = "#1a1a22" COLOR_CARD2 = "#22222e" COLOR_MUTED = "#8a8a9c" COLOR_TEXT = "#f0f0f5" COLOR_ACCENT = "#4cc2ff" BE_MIN_MOVE_USD = 3.0 # 价格向有利方向走出该美金距离才可保本(非账户浮盈) BE_OFFSET_USD = 1.0 # 保本止损 = 开仓价 ± 1$ def simpledialog_ask_server(parent: tk.Misc) -> str: """未激活或本地无中控地址时,让用户输入复盘服务器 URL。""" value = simpledialog.askstring( "打开复盘", "请输入中控地址(例如 http://192.168.1.10:5800)", parent=parent, initialvalue="http://127.0.0.1:5800", ) return (value or "").strip() def enable_dpi_awareness() -> None: """避免高分屏下 Tk 窗口被系统拉伸发虚。""" if sys.platform != "win32": return try: import ctypes try: ctypes.windll.shcore.SetProcessDpiAwareness(1) # SYSTEM_DPI_AWARE except Exception: ctypes.windll.user32.SetProcessDPIAware() except Exception: pass def _init_mt5_constants() -> None: global ATR_TIMEFRAME ATR_TIMEFRAME = mt5.TIMEFRAME_M15 def suggest_lot_by_balance(balance: float) -> float: """按余额档位手数:≥10000→0.10;1000~10000→0.05;<1000→0.01(点按钮即可)。""" if balance >= BALANCE_HIGH: return LOT_HIGH if balance < BALANCE_LOW: return LOT_LOW return LOT_MID def current_session_label(now: Optional[datetime] = None, market_open: bool = True) -> str: """北京时间盘段:亚盘/欧盘/美盘(可重叠显示);休市单独标注。""" if not market_open: return "休市" dt = now or datetime.now(BJT) if dt.tzinfo is None: dt = dt.replace(tzinfo=BJT) else: dt = dt.astimezone(BJT) h = dt.hour + dt.minute / 60.0 asia = 8.0 <= h < 16.0 euro = 15.0 <= h < 24.0 us = h >= 20.0 or h < 5.0 parts = [] if asia: parts.append("亚盘") if euro: parts.append("欧盘") if us: parts.append("美盘") if not parts: return "过渡时段" return "+".join(parts) def format_open_elapsed(open_ts: float, market_open: bool) -> str: """开盘后已过多久;休市时显示休市(仍可看下方 OHLC)。""" if not market_open: return "休市" elapsed = max(0, int(time.time() - open_ts)) days, rem = divmod(elapsed, 86400) hours, rem = divmod(rem, 3600) mins, _ = divmod(rem, 60) if days > 0: return f"已开盘 {days}天{hours}小时{mins}分" if hours > 0: return f"已开盘 {hours}小时{mins}分" return f"已开盘 {mins}分" class TradeError(Exception): pass class Mt5Bridge: def __init__(self) -> None: self.connected = False def connect(self) -> None: if self.connected: return if not mt5.initialize(): raise TradeError(f"连接 MT5 失败:{mt5.last_error()}(请先打开并登录 MT5)") self.connected = True def shutdown(self) -> None: if self.connected: mt5.shutdown() self.connected = False def account_balance(self) -> float: acc = mt5.account_info() if acc is None: raise TradeError("无法读取账户信息") return float(acc.balance) def ensure_symbol(self, symbol: str) -> None: info = mt5.symbol_info(symbol) if info is None: raise TradeError(f"找不到品种 {symbol}") if not info.visible and not mt5.symbol_select(symbol, True): raise TradeError(f"无法选择品种 {symbol}") def tick(self, symbol: str): self.ensure_symbol(symbol) t = mt5.symbol_info_tick(symbol) if t is None: raise TradeError("无法读取报价") return t def symbol_info(self, symbol: str): self.ensure_symbol(symbol) info = mt5.symbol_info(symbol) if info is None: raise TradeError(f"无法读取品种信息 {symbol}") return info def market_is_open(self, symbol: str) -> bool: """根据最新 tick 是否新鲜判断是否在交易(休市也仍可读日线)。""" self.ensure_symbol(symbol) info = mt5.symbol_info(symbol) if info is not None and hasattr(info, "trade_mode"): # 完全禁止交易 if int(info.trade_mode) == 0: return False tick = mt5.symbol_info_tick(symbol) if tick is None: return False age = time.time() - float(tick.time) return age < 180 # 3 分钟内有报价视为开市 def day_ohlc(self, symbol: str) -> dict[str, Any]: """最近交易日日线开高低(休市仍显示);开市时用 tick 修正最高/最低。""" self.ensure_symbol(symbol) rates = mt5.copy_rates_from_pos(symbol, mt5.TIMEFRAME_D1, 0, 2) if rates is None or len(rates) < 1: raise TradeError("无法读取日线,请确认品种有 D1 数据") bar = rates[0] open_px = float(bar["open"]) high = float(bar["high"]) low = float(bar["low"]) open_ts = int(bar["time"]) market_open = self.market_is_open(symbol) tick = mt5.symbol_info_tick(symbol) if market_open and tick is not None: bid = float(tick.bid) ask = float(tick.ask) high = max(high, bid, ask) low = min(low, bid, ask) bar_day = datetime.fromtimestamp(open_ts).strftime("%m-%d") return { "open": open_px, "high": high, "low": low, "open_time": datetime.fromtimestamp(open_ts), "open_ts": open_ts, "bar_day": bar_day, "market_open": market_open, "elapsed_text": format_open_elapsed(float(open_ts), market_open), } def atr(self, symbol: str, period: int = ATR_PERIOD) -> float: self.ensure_symbol(symbol) rates = mt5.copy_rates_from_pos(symbol, ATR_TIMEFRAME, 1, period + 1) if rates is None or len(rates) < period: rates = mt5.copy_rates_from_pos(symbol, ATR_TIMEFRAME, 0, period + 1) if rates is None or len(rates) < period: raise TradeError("无法读取 M15 ATR,请确认已加载 15 分钟数据") trs = [] for i in range(1, len(rates)): high = float(rates[i]["high"]) low = float(rates[i]["low"]) prev_close = float(rates[i - 1]["close"]) trs.append(max(high - low, abs(high - prev_close), abs(low - prev_close))) if len(trs) < period: raise TradeError("ATR 数据不足") return sum(trs[-period:]) / period def normalize_volume(self, symbol: str, volume: float) -> float: info = self.symbol_info(symbol) step = float(info.volume_step) vmin = float(info.volume_min) vmax = float(info.volume_max) if step <= 0 or vmin <= 0: raise TradeError("无法读取手数规则") volume = math.floor(volume / step + 1e-8) * step volume = round(volume, 8) if volume < vmin - 1e-8: raise TradeError(f"手数过小,最小 {vmin:.2f}") if volume > vmax + 1e-8: raise TradeError(f"手数过大,最大 {vmax:.2f}") return volume def min_stop_distance(self, symbol: str) -> float: info = self.symbol_info(symbol) level = max(int(info.trade_stops_level), int(info.trade_freeze_level)) return level * float(info.point) def build_stops( self, symbol: str, order_type: int, price: float, sl_usd: float, tp_usd: float ) -> Tuple[float, float]: if sl_usd < -1e-8 or tp_usd < -1e-8: raise TradeError("SL/TP 不能为负数") info = self.symbol_info(symbol) digits = int(info.digits) min_dist = self.min_stop_distance(symbol) sl = 0.0 tp = 0.0 if sl_usd > 1e-8: sl = price - sl_usd if order_type == mt5.ORDER_TYPE_BUY else price + sl_usd sl = round(sl, digits) if abs(price - sl) + 1e-12 < min_dist: raise TradeError(f"止损过近,至少需要 {min_dist:.2f} 美元(含券商最小距离)") if tp_usd > 1e-8: tp = price + tp_usd if order_type == mt5.ORDER_TYPE_BUY else price - tp_usd tp = round(tp, digits) if abs(tp - price) + 1e-12 < min_dist: raise TradeError(f"止盈过近,至少需要 {min_dist:.2f} 美元(含券商最小距离)") return sl, tp def filling_candidates(self, symbol: str) -> list: info = self.symbol_info(symbol) mode = int(info.filling_mode) order = [] if mode & 2: order.append(mt5.ORDER_FILLING_IOC) if mode & 1: order.append(mt5.ORDER_FILLING_FOK) order.append(mt5.ORDER_FILLING_RETURN) # 去重保序 seen = set() out = [] for f in order: if f not in seen: seen.add(f) out.append(f) return out def _check_trade_allowed(self, symbol: str, order_type: int) -> None: term = mt5.terminal_info() if term is None or not term.connected: raise TradeError("终端未连接到交易服务器") if not term.trade_allowed: raise TradeError("终端未开启自动交易") acc = mt5.account_info() if acc is None or not acc.trade_allowed: raise TradeError("账户不允许交易") info = self.symbol_info(symbol) mode = int(info.trade_mode) if mode == mt5.SYMBOL_TRADE_MODE_DISABLED: raise TradeError("该品种禁止交易") if mode == mt5.SYMBOL_TRADE_MODE_CLOSEONLY: raise TradeError("该品种仅允许平仓") if order_type == mt5.ORDER_TYPE_BUY and mode == mt5.SYMBOL_TRADE_MODE_SHORTONLY: raise TradeError("该品种只允许做空") if order_type == mt5.ORDER_TYPE_SELL and mode == mt5.SYMBOL_TRADE_MODE_LONGONLY: raise TradeError("该品种只允许做多") def check_max_positions(self, symbol: str) -> None: n = len(self.positions(symbol)) if n >= MAX_OPEN_POSITIONS: raise TradeError(f"持仓已满 {MAX_OPEN_POSITIONS} 笔,请先平仓后再开") def check_max_addon(self, symbol: str, order_type: int, lots: float) -> None: buy, sell, _ = self.exposure(symbol) if order_type == mt5.ORDER_TYPE_BUY: total = buy + lots side = "多" cur = buy else: total = sell + lots side = "空" cur = sell if total > MAX_SIDE_LOTS + 1e-8: remain = max(0.0, MAX_SIDE_LOTS - cur) raise TradeError( f"同向加仓上限 {MAX_SIDE_LOTS:.2f} 手:当前{side}单 {cur:.2f}," f"再开 {lots:.2f} 会超限(还可加 {remain:.2f})" ) def day_lock_kind(self, symbol: str) -> Tuple[Optional[str], float]: """返回 (lock_kind, rate)。lock_kind: 'loss' | 'profit' | None。""" st = self.today_stats(symbol) rate = float(st["profit_rate"]) if rate <= -DAY_LOSS_LOCK + 1e-12: return "loss", rate if rate >= DAY_PROFIT_LOCK - 1e-12: return "profit", rate return None, rate def _send_deal(self, request: dict) -> object: last_detail = "" for filling in self.filling_candidates(request["symbol"]): req = dict(request) req["type_filling"] = filling result = mt5.order_send(req) if result is None: last_detail = str(mt5.last_error()) continue if result.retcode == mt5.TRADE_RETCODE_DONE: return result last_detail = f"{result.comment} ({result.retcode})" # 成交模式不对才继续试 if result.retcode not in ( mt5.TRADE_RETCODE_INVALID_FILL, 10030, # INVALID_FILL ): break raise TradeError(last_detail or "下单失败") def market_order( self, symbol: str, order_type: int, lots: float, sl_usd: float, tp_usd: float ) -> str: self._check_trade_allowed(symbol, order_type) self.check_day_trade_lock(symbol) self.check_max_positions(symbol) lots = self.normalize_volume(symbol, lots) self.check_max_addon(symbol, order_type, lots) t = self.tick(symbol) price = float(t.ask if order_type == mt5.ORDER_TYPE_BUY else t.bid) if price <= 0: raise TradeError("无法读取报价") sl, tp = self.build_stops(symbol, order_type, price, sl_usd, tp_usd) request = { "action": mt5.TRADE_ACTION_DEAL, "symbol": symbol, "volume": lots, "type": order_type, "price": price, "sl": sl, "tp": tp, "deviation": DEVIATION, "magic": MAGIC, "comment": COMMENT, "type_time": mt5.ORDER_TIME_GTC, } try: result = self._send_deal(request) except TradeError as exc: raise TradeError(f"下单失败:{exc}") from exc side = "买入" if order_type == mt5.ORDER_TYPE_BUY else "卖出" msg = f"{side}成功 手数={lots:.2f} 成交价={result.price} 单号={result.order}" report = { "ticket": str(result.order), "symbol": symbol, "side": "buy" if order_type == mt5.ORDER_TYPE_BUY else "sell", "volume": lots, "open_price": float(result.price), "close_price": None, "open_time": time.time(), "close_time": None, "profit": None, "sl": sl, "tp": tp, "snapshot": {"event": "open"}, } return msg, report def positions(self, symbol: str, pos_type: Optional[int] = None): self.ensure_symbol(symbol) poss = mt5.positions_get(symbol=symbol) if poss is None: return [] items = list(poss) if pos_type is not None: items = [p for p in items if int(p.type) == pos_type] items.sort(key=lambda p: (int(p.time), int(p.ticket))) return items def get_position(self, ticket: int): poss = mt5.positions_get(ticket=ticket) if not poss: raise TradeError(f"找不到持仓 #{ticket}") return poss[0] def exposure(self, symbol: str) -> Tuple[float, float, float]: buy = sell = profit = 0.0 for p in self.positions(symbol): profit += float(p.profit) + float(p.swap) if int(p.type) == mt5.POSITION_TYPE_BUY: buy += float(p.volume) else: sell += float(p.volume) return buy, sell, profit def today_stats(self, symbol: str) -> dict: """今日开仓次数、已平盈亏、浮盈、盈亏比、盈利率等。""" self.ensure_symbol(symbol) now = datetime.now() day0 = now.replace(hour=0, minute=0, second=0, microsecond=0) deals = mt5.history_deals_get(day0 - timedelta(hours=12), now + timedelta(hours=1)) open_count = 0 closed_pnl = 0.0 win_sum = 0.0 loss_sum = 0.0 # 绝对值累加 if deals: for d in deals: if d.symbol != symbol: continue deal_time = datetime.fromtimestamp(int(d.time)) if deal_time < day0: continue entry = int(d.entry) if entry in (mt5.DEAL_ENTRY_IN, mt5.DEAL_ENTRY_INOUT): open_count += 1 pnl = float(d.profit) + float(d.swap) + float(d.commission) if entry in (mt5.DEAL_ENTRY_OUT, mt5.DEAL_ENTRY_INOUT, mt5.DEAL_ENTRY_OUT_BY): closed_pnl += pnl if pnl > 1e-8: win_sum += pnl elif pnl < -1e-8: loss_sum += -pnl elif entry == mt5.DEAL_ENTRY_IN: closed_pnl += float(d.commission) if float(d.commission) < -1e-8: loss_sum += -float(d.commission) _, _, floating = self.exposure(symbol) total = closed_pnl + floating balance = self.account_balance() profit_rate = (total / balance) if balance > 1e-8 else 0.0 if loss_sum > 1e-8: pl_ratio = win_sum / loss_sum pl_ratio_txt = f"{pl_ratio:.2f}" elif win_sum > 1e-8: pl_ratio_txt = "∞" else: pl_ratio_txt = "--" return { "opens": open_count, "closed_pnl": closed_pnl, "floating": floating, "total": total, "balance": balance, "profit_rate": profit_rate, "pl_ratio_txt": pl_ratio_txt, } def check_day_trade_lock(self, symbol: str) -> None: kind, rate = self.day_lock_kind(symbol) pct = rate * 100.0 if kind == "loss": raise TradeError(f"当日亏损已达 {pct:.2f}%(上限 -{DAY_LOSS_LOCK*100:.0f}%),禁止开仓") if kind == "profit": raise TradeError(f"当日盈利已达 {pct:.2f}%(上限 +{DAY_PROFIT_LOCK*100:.0f}%),禁止开仓") def close_ticket(self, ticket: int) -> str: p = self.get_position(ticket) symbol = str(p.symbol) pos_type = int(p.type) volume = float(p.volume) order_type = mt5.ORDER_TYPE_SELL if pos_type == mt5.POSITION_TYPE_BUY else mt5.ORDER_TYPE_BUY t = self.tick(symbol) price = float(t.bid if order_type == mt5.ORDER_TYPE_SELL else t.ask) request = { "action": mt5.TRADE_ACTION_DEAL, "symbol": symbol, "volume": volume, "type": order_type, "position": int(ticket), "price": price, "deviation": DEVIATION, "magic": MAGIC, "comment": COMMENT, "type_time": mt5.ORDER_TIME_GTC, } try: self._send_deal(request) except TradeError as exc: raise TradeError(f"平仓失败 #{ticket}:{exc}") from exc # 估算平仓盈亏(含 swap) profit = float(p.profit) + float(p.swap) report = { "ticket": str(ticket), "symbol": symbol, "side": "buy" if pos_type == mt5.POSITION_TYPE_BUY else "sell", "volume": volume, "open_price": float(p.price_open), "close_price": price, "open_time": float(p.time), "close_time": time.time(), "profit": profit, "sl": float(p.sl), "tp": float(p.tp), "snapshot": {"event": "close"}, } return f"已平 #{ticket}({volume:.2f} 手)", report def break_even_tickets(self, tickets: list, offset_usd: float = 1.0) -> Tuple[str, list]: """保本:多单 SL=开仓价+offset,空单 SL=开仓价-offset。返回 (消息, 成功单号列表)。""" if not tickets: raise TradeError("没有可保本的持仓") ok_tickets: list = [] errors: list = [] for ticket in tickets: try: p = self.get_position(int(ticket)) symbol = str(p.symbol) pos_type = int(p.type) open_price = float(p.price_open) tp = float(p.tp) digits = int(self.symbol_info(symbol).digits) min_dist = self.min_stop_distance(symbol) t = self.tick(symbol) if pos_type == mt5.POSITION_TYPE_BUY: sl = round(open_price + offset_usd, digits) ref = float(t.bid) if sl >= ref - 1e-12: raise TradeError( f"#{ticket} 多单现价 {ref:.{digits}f} 尚未高于保本价 {sl:.{digits}f}" ) if abs(ref - sl) + 1e-12 < min_dist: raise TradeError(f"#{ticket} 保本止损过近,至少 {min_dist:.2f} 美元") else: sl = round(open_price - offset_usd, digits) ref = float(t.ask) if sl <= ref + 1e-12: raise TradeError( f"#{ticket} 空单现价 {ref:.{digits}f} 尚未低于保本价 {sl:.{digits}f}" ) if abs(sl - ref) + 1e-12 < min_dist: raise TradeError(f"#{ticket} 保本止损过近,至少 {min_dist:.2f} 美元") request = { "action": mt5.TRADE_ACTION_SLTP, "symbol": symbol, "position": int(ticket), "sl": sl, "tp": tp, } result = mt5.order_send(request) if result is None or result.retcode != mt5.TRADE_RETCODE_DONE: detail = mt5.last_error() if result is None else f"{result.comment} ({result.retcode})" raise TradeError(f"#{ticket} {detail}") ok_tickets.append(int(ticket)) except TradeError as exc: errors.append(str(exc)) if not ok_tickets: raise TradeError("保本失败:" + (";".join(errors[:3]) if errors else "无可用持仓")) msg = f"已保本 {len(ok_tickets)} 笔(开仓价 ± {offset_usd:.0f} 美金)" if errors: msg += f";跳过 {len(errors)}:{errors[0]}" return msg, ok_tickets def close_positions(self, symbol: str, pos_type: Optional[int] = None): items = self.positions(symbol, pos_type) if not items: raise TradeError("没有可平的持仓") closed = 0 reports = [] for p in reversed(items): _msg, report = self.close_ticket(int(p.ticket)) reports.append(report) closed += 1 return f"已平 {closed} 笔", reports def partial_close(self, symbol: str, pos_type: int, volume: float) -> str: volume = self.normalize_volume(symbol, volume) items = self.positions(symbol, pos_type) if not items: raise TradeError("没有可平的持仓") total = sum(float(p.volume) for p in items) if volume > total + 1e-8: raise TradeError(f"部分平手数过大,当前可平 {total:.2f}") info = self.symbol_info(symbol) vmin = float(info.volume_min) step = float(info.volume_step) remaining = volume for p in items: if remaining <= 1e-8: break pos_vol = float(p.volume) close_vol = math.floor(min(remaining, pos_vol) / step + 1e-8) * step close_vol = round(close_vol, 8) if close_vol + 1e-8 < vmin: raise TradeError("剩余手数小于最小手数,无法继续部分平仓") order_type = ( mt5.ORDER_TYPE_SELL if int(p.type) == mt5.POSITION_TYPE_BUY else mt5.ORDER_TYPE_BUY ) t = self.tick(symbol) price = float(t.bid if order_type == mt5.ORDER_TYPE_SELL else t.ask) request = { "action": mt5.TRADE_ACTION_DEAL, "symbol": symbol, "volume": close_vol, "type": order_type, "position": int(p.ticket), "price": price, "deviation": DEVIATION, "magic": MAGIC, "comment": COMMENT, "type_time": mt5.ORDER_TIME_GTC, } try: self._send_deal(request) except TradeError as exc: raise TradeError(f"部分平仓失败 #{p.ticket}:{exc}") from exc remaining = round(remaining - close_vol, 8) return f"已部分平仓 {volume:.2f} 手" class OrderApp(tk.Tk): def __init__(self) -> None: super().__init__() _init_mt5_constants() self.title(APP_TITLE) self.geometry("1100x980") self.minsize(1000, 900) self.configure(bg=COLOR_BG) try: self.tk.call("tk", "scaling", 1.45) except tk.TclError: pass self.bridge = Mt5Bridge() self._busy = False self._lot_auto_done = False self._selected_tickets_set: set = set() self._be_done_tickets: set = set() # 已拉过保本的单号,不再判断 self._pos_sig = None self._pos_profit_labels: dict = {} self._metric_values: dict = {} self._build_ui() self.after(200, self._try_connect_and_refresh) self.after(3000, self._hub_heartbeat) def _card(self, parent: tk.Misc, pad: int = 12) -> tk.Frame: outer = tk.Frame(parent, bg=COLOR_BG) card = tk.Frame( outer, bg=COLOR_CARD, highlightthickness=1, highlightbackground="#2a2a38", highlightcolor="#2a2a38", ) card.pack(fill=tk.BOTH, expand=True, padx=0, pady=0) inner = tk.Frame(card, bg=COLOR_CARD) inner.pack(fill=tk.BOTH, expand=True, padx=pad, pady=pad) outer._inner = inner # type: ignore[attr-defined] return outer def _metric_box(self, parent: tk.Misc, key: str, title: str) -> None: box = tk.Frame(parent, bg=COLOR_CARD2, highlightthickness=0) box.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=4) tk.Label( box, text=title, bg=COLOR_CARD2, fg=COLOR_MUTED, font=("Microsoft YaHei UI", 10), ).pack(anchor="w", padx=10, pady=(8, 0)) val = tk.Label( box, text="--", bg=COLOR_CARD2, fg=COLOR_TEXT, font=("Microsoft YaHei UI", 13, "bold"), ) val.pack(anchor="w", padx=10, pady=(2, 8)) self._metric_values[key] = val def _build_ui(self) -> None: style = ttk.Style(self) try: style.theme_use("clam") except tk.TclError: pass style.configure("TLabel", background=COLOR_BG, foreground=COLOR_TEXT, font=("Microsoft YaHei UI", 12)) style.configure("Status.TLabel", background=COLOR_BG, foreground=COLOR_MUTED, font=("Microsoft YaHei UI", 11)) style.configure("TFrame", background=COLOR_BG) style.configure("Card.TFrame", background=COLOR_CARD) style.configure("TEntry", fieldbackground="#2a2a36", foreground="#ffffff", font=("Microsoft YaHei UI", 12)) style.configure("TCheckbutton", background=COLOR_CARD, foreground=COLOR_TEXT, font=("Microsoft YaHei UI", 11)) style.map("TCheckbutton", background=[("active", COLOR_CARD)]) root = tk.Frame(self, bg=COLOR_BG) root.pack(fill=tk.BOTH, expand=True, padx=16, pady=14) # —— 顶栏 —— head_outer = self._card(root, pad=12) head_outer.pack(fill=tk.X, pady=(0, 10)) head = head_outer._inner # type: ignore[attr-defined] left = tk.Frame(head, bg=COLOR_CARD) left.pack(side=tk.LEFT) tk.Label(left, text=APP_TITLE, bg=COLOR_CARD, fg=COLOR_ACCENT, font=("Microsoft YaHei UI", 14, "bold")).pack(side=tk.LEFT) tk.Label(left, text=" 品种", bg=COLOR_CARD, fg=COLOR_MUTED, font=("Microsoft YaHei UI", 11)).pack(side=tk.LEFT, padx=(16, 4)) self.var_symbol = tk.StringVar(value=DEFAULT_SYMBOL) ttk.Entry(left, textvariable=self.var_symbol, width=14).pack(side=tk.LEFT) self.lbl_account = tk.Label( left, text="余额 --", bg=COLOR_CARD, fg=COLOR_TEXT, font=("Microsoft YaHei UI", 12, "bold"), ) self.lbl_account.pack(side=tk.LEFT, padx=16) self.lbl_session = tk.Label( head, text="时段 --", bg="#243044", fg=COLOR_ACCENT, font=("Microsoft YaHei UI", 11, "bold"), padx=12, pady=4, ) self.lbl_session.pack(side=tk.RIGHT) # —— 报价 + 日线指标 —— quote_outer = self._card(root, pad=12) quote_outer.pack(fill=tk.X, pady=(0, 10)) q = quote_outer._inner # type: ignore[attr-defined] self.lbl_quote = tk.Label( q, text="Bid -- Ask --", bg=COLOR_CARD, fg=COLOR_TEXT, font=("Microsoft YaHei UI", 18, "bold"), ) self.lbl_quote.pack(anchor="w") self.lbl_info = tk.Label( q, text="点差 -- ATR -- SL$ -- TP$ -- 持仓 --", bg=COLOR_CARD, fg=COLOR_MUTED, font=("Microsoft YaHei UI", 11), ) self.lbl_info.pack(anchor="w", pady=(4, 10)) metrics = tk.Frame(q, bg=COLOR_CARD) metrics.pack(fill=tk.X) self._metric_box(metrics, "elapsed", "开盘时长") self._metric_box(metrics, "open", "开盘价") self._metric_box(metrics, "high", "最高价") self._metric_box(metrics, "low", "最低价") self.lbl_day_ohlc = tk.Label( q, text="", bg=COLOR_CARD, fg=COLOR_MUTED, font=("Microsoft YaHei UI", 10), ) self.lbl_day_ohlc.pack(anchor="w", pady=(8, 0)) # —— 今日战绩 —— today_outer = self._card(root, pad=10) today_outer.pack(fill=tk.X, pady=(0, 10)) today_row = today_outer._inner # type: ignore[attr-defined] self.lbl_today_base = tk.Label( today_row, text="今日开单 -- 次", bg=COLOR_CARD, fg=COLOR_MUTED, font=("Microsoft YaHei UI", 11), ) self.lbl_today_base.pack(side=tk.LEFT) self.lbl_today_closed = tk.Label( today_row, text=" 已平 --", bg=COLOR_CARD, fg=COLOR_NEUTRAL, font=("Microsoft YaHei UI", 11, "bold"), ) self.lbl_today_closed.pack(side=tk.LEFT) self.lbl_today_mid = tk.Label( today_row, text=" 浮盈 -- 合计 ", bg=COLOR_CARD, fg=COLOR_MUTED, font=("Microsoft YaHei UI", 11), ) self.lbl_today_mid.pack(side=tk.LEFT) self.lbl_today_total = tk.Label( today_row, text="--", bg=COLOR_CARD, fg=COLOR_NEUTRAL, font=("Microsoft YaHei UI", 11, "bold"), ) self.lbl_today_total.pack(side=tk.LEFT) self.lbl_today_ratio = tk.Label( today_row, text=" 盈亏比 -- 盈利率 --", bg=COLOR_CARD, fg=COLOR_MUTED, font=("Microsoft YaHei UI", 11, "bold"), ) self.lbl_today_ratio.pack(side=tk.LEFT) self.lbl_day_lock = tk.Label( today_row, text="", bg=COLOR_CARD, fg=COLOR_WARN, font=("Microsoft YaHei UI", 11, "bold"), padx=8, pady=2, ) self.lbl_day_lock.pack(side=tk.RIGHT) # —— 下单参数 —— form_outer = self._card(root, pad=12) form_outer.pack(fill=tk.X, pady=(0, 10)) form_wrap = form_outer._inner # type: ignore[attr-defined] form = ttk.Frame(form_wrap) form.pack(fill=tk.X) for i in range(3): form.columnconfigure(i, weight=1) style.configure("Form.TLabel", background=COLOR_CARD, foreground=COLOR_MUTED, font=("Microsoft YaHei UI", 11)) ttk.Label(form, text="手数", style="Form.TLabel").grid(row=0, column=0, sticky="w") self.lbl_sl_title = ttk.Label(form, text="SL倍", style="Form.TLabel") self.lbl_sl_title.grid(row=0, column=1, sticky="w", padx=6) self.lbl_tp_title = ttk.Label(form, text="TP倍", style="Form.TLabel") self.lbl_tp_title.grid(row=0, column=2, sticky="w") self.var_lot = tk.StringVar(value=f"{DEFAULT_LOT:.2f}") self.var_atr_mult = tk.StringVar(value=f"{DEFAULT_ATR_MULT:.2f}") self.var_tp = tk.StringVar(value=f"{DEFAULT_TP_ATR_MULT:.2f}") ttk.Entry(form, textvariable=self.var_lot, width=12).grid(row=1, column=0, sticky="ew", pady=6, ipady=5) ttk.Entry(form, textvariable=self.var_atr_mult, width=12).grid(row=1, column=1, sticky="ew", padx=8, pady=6, ipady=5) ttk.Entry(form, textvariable=self.var_tp, width=12).grid(row=1, column=2, sticky="ew", pady=6, ipady=5) opt = tk.Frame(form_wrap, bg=COLOR_CARD) opt.pack(fill=tk.X, pady=(6, 0)) self.var_use_atr = tk.BooleanVar(value=True) ttk.Checkbutton( opt, text="用 M15 ATR(14) 做止损/止盈", variable=self.var_use_atr, command=self._sync_sl_label, ).pack(side=tk.LEFT) self._mk_btn(opt, "按资金手数", "#3a5a80", self.on_auto_lot, h=34, w=110).pack(side=tk.LEFT, padx=12) tk.Label( opt, text=f"小资金点一下→{LOT_LOW:.2f}手 · 同向≤{MAX_SIDE_LOTS:.2f} · 最多{MAX_OPEN_POSITIONS}笔", bg=COLOR_CARD, fg=COLOR_MUTED, font=("Microsoft YaHei UI", 10), ).pack(side=tk.LEFT) self.lbl_open_block = tk.Label( root, text="", bg=COLOR_BG, fg=COLOR_WARN, font=("Microsoft YaHei UI", 12, "bold"), anchor="center", ) self.lbl_open_block.pack(fill=tk.X, pady=(0, 4)) btns = tk.Frame(root, bg=COLOR_BG) btns.pack(fill=tk.X, pady=4) btns.columnconfigure(0, weight=1) btns.columnconfigure(1, weight=1) self.btn_buy = self._mk_btn(btns, "买入", COLOR_BUY, self.on_buy, h=48) self.btn_buy.grid(row=0, column=0, sticky="ew", padx=(0, 6), ipady=8) self.btn_sell = self._mk_btn(btns, "卖出", COLOR_SELL, self.on_sell, h=48) self.btn_sell.grid(row=0, column=1, sticky="ew", padx=(6, 0), ipady=8) close = tk.Frame(root, bg=COLOR_BG) close.pack(fill=tk.X, pady=8) for i in range(3): close.columnconfigure(i, weight=1) self._mk_btn(close, "平多", "#285032", self.on_close_buy, h=38).grid(row=0, column=0, sticky="ew", padx=(0, 4), ipady=4) self._mk_btn(close, "平空", "#5a3232", self.on_close_sell, h=38).grid(row=0, column=1, sticky="ew", padx=4, ipady=4) self._mk_btn(close, "全平", "#464628", self.on_close_all, h=38).grid(row=0, column=2, sticky="ew", padx=(4, 0), ipady=4) self.lbl_status = tk.Label( root, text="就绪", bg=COLOR_BG, fg=COLOR_MUTED, font=("Microsoft YaHei UI", 11), anchor="w", wraplength=960, ) self.lbl_status.pack(fill=tk.X, pady=(8, 4)) pos_bar = tk.Frame(root, bg=COLOR_BG) pos_bar.pack(fill=tk.X, pady=(8, 6)) tk.Label( pos_bar, text="持仓记录", bg=COLOR_BG, fg=COLOR_TEXT, font=("Microsoft YaHei UI", 13, "bold"), ).pack(side=tk.LEFT) self.btn_break_even = self._mk_btn(pos_bar, "保本", COLOR_BE, self.on_break_even, h=34, w=90) self.btn_break_even.pack(side=tk.RIGHT, padx=4) self._mk_btn(pos_bar, "打开复盘", "#3a5a80", self.on_open_review, h=34, w=100).pack( side=tk.RIGHT, padx=4 ) self._set_be_button_enabled(False) # 持仓列表:无竖线;列宽按权重拉满右侧;平仓红色边框按钮 self._pos_col_specs = [ ("单号", 12), ("方向", 5), ("手数", 6), ("开仓价", 12), ("止损幅度", 9), ("止损", 12), ("止盈", 12), ("浮盈", 9), ("操作", 10), ] # 持仓区固定为 5 行可视高度,超出可滚动 pos_wrap = tk.Frame( root, bg="#2b2b2b", highlightthickness=1, highlightbackground="#2a2a38", ) pos_wrap.pack(fill=tk.X, expand=False, pady=(0, 0)) self.pos_head = tk.Frame(pos_wrap, bg="#333333") self.pos_head.pack(fill=tk.X) self._config_pos_columns(self.pos_head) for i, (text, _) in enumerate(self._pos_col_specs): tk.Label( self.pos_head, text=text, bg="#333333", fg="#ffffff", font=("Microsoft YaHei UI", 11, "bold"), anchor="center", ).grid(row=0, column=i, sticky="nsew", padx=2, pady=6) body = tk.Frame(pos_wrap, bg="#2b2b2b", height=POS_LIST_HEIGHT) body.pack(fill=tk.X, expand=False) body.pack_propagate(False) self.pos_canvas = tk.Canvas( body, bg="#2b2b2b", highlightthickness=0, bd=0, height=POS_LIST_HEIGHT ) scroll = ttk.Scrollbar(body, orient=tk.VERTICAL, command=self.pos_canvas.yview) self.pos_inner = tk.Frame(self.pos_canvas, bg="#2b2b2b") self.pos_inner.bind( "", lambda e: self.pos_canvas.configure(scrollregion=self.pos_canvas.bbox("all")), ) self._pos_window = self.pos_canvas.create_window((0, 0), window=self.pos_inner, anchor="nw") self.pos_canvas.configure(yscrollcommand=scroll.set, height=POS_LIST_HEIGHT) self.pos_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) scroll.pack(side=tk.RIGHT, fill=tk.Y) self.pos_canvas.bind("", self._on_pos_canvas_configure) self.pos_canvas.bind("", lambda e: self.pos_canvas.bind_all("", self._on_pos_mousewheel)) self.pos_canvas.bind("", lambda e: self.pos_canvas.unbind_all("")) tip = tk.Label( root, text=( f"最多同时 {MAX_OPEN_POSITIONS} 笔 · 同向≤{MAX_SIDE_LOTS:.2f} 手 · " "亏≥5%或盈≥10%禁止开仓 · 保本:有利方向>3$ 后止损到开仓价±1$" ), bg=COLOR_BG, fg=COLOR_MUTED, font=("Microsoft YaHei UI", 10), wraplength=960, justify=tk.LEFT, anchor="w", ) tip.pack(anchor="w", pady=(8, 0)) def _mk_btn(self, parent, text, color, cmd, h=36, w=None): btn = tk.Button( parent, text=text, command=cmd, bg=color, fg="white", activebackground=color, activeforeground="white", relief=tk.FLAT, height=1, font=("Microsoft YaHei UI", 13, "bold"), padx=10, pady=4, ) if w: btn.configure(width=max(8, w // 10)) return btn def _sync_sl_label(self) -> None: if self.var_use_atr.get(): self.lbl_sl_title.configure(text="SL倍") self.lbl_tp_title.configure(text="TP倍") # 从美元模式切回来时,给合理默认倍 try: v = float(self.var_tp.get().replace(",", ".") or "0") if v >= 5: self.var_tp.set(f"{DEFAULT_TP_ATR_MULT:.2f}") except ValueError: self.var_tp.set(f"{DEFAULT_TP_ATR_MULT:.2f}") else: self.lbl_sl_title.configure(text="SL$") self.lbl_tp_title.configure(text="TP$") if self.var_atr_mult.get() in ("1", "1.0", "1.00"): self.var_atr_mult.set("8.0") try: v = float(self.var_tp.get().replace(",", ".") or "0") if v <= 3: self.var_tp.set(f"{DEFAULT_TP_USD:.1f}") except ValueError: self.var_tp.set(f"{DEFAULT_TP_USD:.1f}") def _symbol(self) -> str: s = self.var_symbol.get().strip() return s or DEFAULT_SYMBOL def set_status(self, text: str, ok: bool = True) -> None: self.lbl_status.configure(text=text, fg="#7CFC00" if ok else "#FF7F50") def _parse_float(self, value: str, name: str) -> float: s = value.strip().replace(",", ".") if s == "": raise TradeError(f"{name} 不能为空") try: return float(s) except ValueError as exc: raise TradeError(f"{name} 不是有效数字") from exc def _resolve_stops(self, symbol: str) -> Tuple[float, float, float, float, float]: """返回 sl_usd, tp_usd, atr, sl_mult, tp_mult""" if not self.var_use_atr.get(): sl = self._parse_float(self.var_atr_mult.get(), "SL$") tp = self._parse_float(self.var_tp.get(), "TP$") return sl, tp, 0.0, 0.0, 0.0 sl_mult = self._parse_float(self.var_atr_mult.get(), "SL倍") tp_mult = self._parse_float(self.var_tp.get(), "TP倍") if sl_mult < 0 or tp_mult < 0: raise TradeError("ATR 倍数不能为负") atr = self.bridge.atr(symbol) if (sl_mult > 1e-8 or tp_mult > 1e-8) else 0.0 return atr * sl_mult, atr * tp_mult, atr, sl_mult, tp_mult def _confirm(self, title: str, body: str) -> bool: if not MUST_CONFIRM: return True return messagebox.askokcancel(title, body, parent=self) def _on_pos_mousewheel(self, event) -> None: if not hasattr(self, "pos_canvas"): return self.pos_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") def _on_pos_canvas_configure(self, event) -> None: self.pos_canvas.itemconfigure(self._pos_window, width=event.width) def _config_pos_columns(self, frame: tk.Frame) -> None: for i, (_, weight) in enumerate(self._pos_col_specs): frame.columnconfigure(i, weight=weight, uniform="poscol") def _set_trade_btn(self, btn: tk.Button, enabled: bool, on_color: str) -> None: if enabled: btn.configure( state=tk.NORMAL, bg=on_color, fg="white", activebackground=on_color, activeforeground="white", disabledforeground="white", cursor="hand2", ) else: btn.configure( state=tk.DISABLED, bg=COLOR_BTN_OFF, fg="#888888", activebackground=COLOR_BTN_OFF, activeforeground="#888888", disabledforeground="#888888", cursor="arrow", ) def _sync_open_buttons(self, symbol: str) -> None: """满仓 / 同向上限 / 日内达标 → 买卖置灰,并显示禁止开仓标识。""" if not hasattr(self, "btn_buy"): return try: positions = self.bridge.positions(symbol) buy_lots, sell_lots, _ = self.bridge.exposure(symbol) lock_kind, rate = self.bridge.day_lock_kind(symbol) except TradeError: self._set_trade_btn(self.btn_buy, False, COLOR_BUY) self._set_trade_btn(self.btn_sell, False, COLOR_SELL) return try: lot = float(self.var_lot.get().replace(",", ".") or "0") except ValueError: lot = 0.0 n = len(positions) full = n >= MAX_OPEN_POSITIONS day_locked = lock_kind is not None can_buy = (not day_locked) and (not full) and lot > 1e-8 and (buy_lots + lot <= MAX_SIDE_LOTS + 1e-8) can_sell = (not day_locked) and (not full) and lot > 1e-8 and (sell_lots + lot <= MAX_SIDE_LOTS + 1e-8) self._set_trade_btn(self.btn_buy, can_buy, COLOR_BUY) self._set_trade_btn(self.btn_sell, can_sell, COLOR_SELL) pct = rate * 100.0 if lock_kind == "loss": badge = f"亏损达标 {pct:.2f}% · 禁止开仓" banner = f"⚠ 当日亏损已达 {pct:.2f}%(上限 -{DAY_LOSS_LOCK*100:.0f}%)|禁止开仓" color = COLOR_LOSS bg = "#3a1e1e" elif lock_kind == "profit": badge = f"盈利达标 {pct:.2f}% · 禁止开仓" banner = f"⚠ 当日盈利已达 {pct:.2f}%(上限 +{DAY_PROFIT_LOCK*100:.0f}%)|禁止开仓" color = COLOR_PROFIT bg = "#1e3a2a" elif full: badge = f"持仓已满 {n}/{MAX_OPEN_POSITIONS} · 禁止开仓" banner = f"持仓已满 {MAX_OPEN_POSITIONS} 笔,平仓后再开" color = COLOR_WARN bg = "#3a3220" else: badge = "" banner = "" color = COLOR_NEUTRAL bg = "#1e1e1e" self.lbl_day_lock.configure(text=badge, fg=color, bg=bg if badge else "#1e1e1e") self.lbl_open_block.configure(text=banner, fg=color, bg=bg if banner else "#1e1e1e") def _toggle_ticket_select(self, ticket: int) -> None: if ticket in self._selected_tickets_set: self._selected_tickets_set.discard(ticket) else: self._selected_tickets_set.add(ticket) # 刷新选中底色 try: symbol = self._symbol() digits = int(self.bridge.symbol_info(symbol).digits) self._refresh_positions(symbol, digits) except Exception: pass def _mk_close_btn(self, parent, ticket: int) -> tk.Frame: """红色边框的平仓按钮。""" border = tk.Frame(parent, bg=COLOR_DANGER, padx=1, pady=1) btn = tk.Button( border, text="平仓", command=lambda t=ticket: self._close_ticket_now(t), fg=COLOR_DANGER, bg="#2b2b2b", activeforeground="#ffffff", activebackground="#a02828", relief=tk.FLAT, bd=0, font=("Microsoft YaHei UI", 10, "bold"), padx=10, pady=2, cursor="hand2", ) btn.pack() return border @staticmethod def _pos_profit(p) -> float: return float(p.profit) + float(p.swap) @staticmethod def _is_sl_at_breakeven(p, offset_usd: float = BE_OFFSET_USD, tol: float = 0.05) -> bool: """止损是否已在保本位(或更优)。""" sl = float(p.sl) if sl <= 0: return False open_price = float(p.price_open) if int(p.type) == mt5.POSITION_TYPE_BUY: return sl + 1e-12 >= open_price + offset_usd - tol return sl - 1e-12 <= open_price - offset_usd + tol @staticmethod def _favorable_move_usd(p, tick) -> float: """价格相对开仓价朝有利方向走出的美金距离。多=现价-开仓,空=开仓-现价。""" open_price = float(p.price_open) if int(p.type) == mt5.POSITION_TYPE_BUY: return float(tick.bid) - open_price return open_price - float(tick.ask) def _be_candidate_tickets(self, positions, tick=None) -> list: """价格朝有利方向>3$ 且尚未保本的单号;已拉过保本的不再进入。""" tickets = [] if not positions: return tickets if tick is None: tick = self.bridge.tick(str(positions[0].symbol)) for p in positions: ticket = int(p.ticket) if ticket in self._be_done_tickets: continue if self._is_sl_at_breakeven(p): self._be_done_tickets.add(ticket) continue if self._favorable_move_usd(p, tick) > BE_MIN_MOVE_USD + 1e-8: tickets.append(ticket) return tickets def _set_be_button_enabled(self, enabled: bool) -> None: if not hasattr(self, "btn_break_even"): return if enabled: self.btn_break_even.configure( state=tk.NORMAL, bg=COLOR_BE, fg="white", activebackground=COLOR_BE, activeforeground="white", disabledforeground="white", cursor="hand2", ) else: self.btn_break_even.configure( state=tk.DISABLED, bg=COLOR_BE_OFF, fg="#888888", activebackground=COLOR_BE_OFF, activeforeground="#888888", disabledforeground="#888888", cursor="arrow", ) def _sync_be_button(self, positions) -> None: self._set_be_button_enabled(bool(self._be_candidate_tickets(positions))) def _hub_upload_bars(self, symbol: str) -> None: if hub_client is None or not is_activated(): return tf_map = { "M1": mt5.TIMEFRAME_M1, "M5": mt5.TIMEFRAME_M5, "M15": mt5.TIMEFRAME_M15, "H1": mt5.TIMEFRAME_H1, } for name, tf in tf_map.items(): rates = mt5.copy_rates_from_pos(symbol, tf, 0, 400) if rates is None or len(rates) == 0: continue bars = [ { "ts": float(r["time"]), "open": float(r["open"]), "high": float(r["high"]), "low": float(r["low"]), "close": float(r["close"]), "volume": float(r["tick_volume"]), } for r in rates ] hub_client.report_bars(symbol, name, bars) def _hub_report(self, payload: Union[dict, list, None]) -> None: if hub_client is None or not is_activated() or not payload: return def run(): try: items = payload if isinstance(payload, list) else [payload] for item in items: if not isinstance(item, dict): continue hub_client.report_trade(item) sym = str(item.get("symbol") or self._symbol()) self._hub_upload_bars(sym) except Exception: pass threading.Thread(target=run, daemon=True).start() def _run_async(self, work) -> None: if self._busy: self.set_status("上一次操作还未完成", ok=False) return self._busy = True def runner(): try: result = work() report: Any = None if isinstance(result, tuple) and len(result) == 2: msg, second = result if isinstance(second, dict) or ( isinstance(second, list) and second and isinstance(second[0], dict) ): report = second else: msg = result self.after(0, lambda m=msg: self.set_status(m, ok=True)) if report is not None: self.after(0, lambda r=report: self._hub_report(r)) except TradeError as exc: self.after(0, lambda: self.set_status(str(exc), ok=False)) except Exception as exc: # noqa: BLE001 self.after(0, lambda: self.set_status(f"异常:{exc}", ok=False)) finally: self.after(0, self._done) threading.Thread(target=runner, daemon=True).start() def _done(self) -> None: self._busy = False self.refresh_quotes() def on_auto_lot(self) -> None: """任意资金都可点:小资金(<1000)会设为 0.01 手。""" try: self.bridge.connect() bal = self.bridge.account_balance() lot = suggest_lot_by_balance(bal) self.var_lot.set(f"{lot:.2f}") if bal < BALANCE_LOW: tip = f"小资金档:余额 {bal:.2f} → 手数 {lot:.2f}" elif bal >= BALANCE_HIGH: tip = f"大资金档:余额 {bal:.2f} → 手数 {lot:.2f}" else: tip = f"中资金档:余额 {bal:.2f} → 手数 {lot:.2f}" self.set_status(tip, ok=True) except TradeError as exc: self.set_status(str(exc), ok=False) def _apply_auto_lot_once(self) -> None: if self._lot_auto_done: return try: bal = self.bridge.account_balance() lot = suggest_lot_by_balance(bal) self.var_lot.set(f"{lot:.2f}") self._lot_auto_done = True self.set_status(f"已连接 MT5;余额 {bal:.2f},默认手数 {lot:.2f}", ok=True) except TradeError: pass def _try_connect_and_refresh(self) -> None: try: self.bridge.connect() self._apply_auto_lot_once() if not self._lot_auto_done: self.set_status("已连接 MT5", ok=True) except TradeError as exc: self.set_status(str(exc), ok=False) self.refresh_quotes() self.after(REFRESH_MS, self._try_connect_and_refresh) def _set_metric(self, key: str, text: str, fg: str = COLOR_TEXT) -> None: lbl = self._metric_values.get(key) if lbl is not None: lbl.configure(text=text, fg=fg) def refresh_quotes(self) -> None: symbol = self._symbol() try: if not self.bridge.connected: self.bridge.connect() info = self.bridge.symbol_info(symbol) digits = int(info.digits) market_open = False bid = ask = 0.0 try: t = self.bridge.tick(symbol) bid = float(t.bid) ask = float(t.ask) market_open = self.bridge.market_is_open(symbol) except TradeError: bid = float(getattr(info, "bid", 0) or getattr(info, "last", 0) or 0) ask = float(getattr(info, "ask", 0) or bid) market_open = False spread = ask - bid if ask and bid else 0.0 try: buy, sell, profit = self.bridge.exposure(symbol) except TradeError: buy = sell = profit = 0.0 net = buy - sell pos = f"{net:.2f}" if buy > 0 and sell > 0: pos = f"多{buy:.2f} 空{sell:.2f}" atr_txt = sl_txt = tp_txt = "--" if self.var_use_atr.get(): try: atr = self.bridge.atr(symbol) atr_txt = f"{atr:.2f}" sl_m = float(self.var_atr_mult.get().replace(",", ".") or "0") tp_m = float(self.var_tp.get().replace(",", ".") or "0") sl_txt = f"{atr * sl_m:.2f}" tp_txt = f"{atr * tp_m:.2f}" except Exception: pass else: sl_txt = self.var_atr_mult.get() tp_txt = self.var_tp.get() try: bal = self.bridge.account_balance() self.lbl_account.configure(text=f"余额 {bal:.2f}") except TradeError: self.lbl_account.configure(text="余额 --") sess = current_session_label(market_open=market_open) self.lbl_session.configure( text=f"时段 {sess}", bg="#3a2820" if not market_open else "#243044", fg=COLOR_WARN if not market_open else COLOR_ACCENT, ) if bid > 0: self.lbl_quote.configure(text=f"Bid {bid:.{digits}f} Ask {ask:.{digits}f}") else: self.lbl_quote.configure(text="Bid -- Ask --") self.lbl_info.configure( text=( f"点差 {spread:.{digits}f} ATR {atr_txt} " f"SL${sl_txt} TP${tp_txt} 持仓 {pos} 浮盈 {profit:.2f}" ) ) try: day = self.bridge.day_ohlc(symbol) elapsed_fg = COLOR_WARN if not day["market_open"] else COLOR_ACCENT self._set_metric("elapsed", day["elapsed_text"], elapsed_fg) self._set_metric("open", f"{day['open']:.{digits}f}") self._set_metric("high", f"{day['high']:.{digits}f}", COLOR_PROFIT) self._set_metric("low", f"{day['low']:.{digits}f}", COLOR_LOSS) ot = day["open_time"].strftime("%m-%d %H:%M") tag = "交易中" if day["market_open"] else "休市 · 显示最近交易日" self.lbl_day_ohlc.configure(text=f"{tag} · 日线开盘 {ot}") except Exception: self._set_metric("elapsed", "休市" if not market_open else "--", COLOR_WARN) self._set_metric("open", "--") self._set_metric("high", "--") self._set_metric("low", "--") self.lbl_day_ohlc.configure(text="日线暂不可用(休市也会尽量显示最近交易日)") try: self._refresh_today_stats(symbol) except Exception: self.lbl_today_base.configure(text="今日开单 -- 次") self.lbl_today_closed.configure(text=" 已平 --", fg=COLOR_NEUTRAL) self.lbl_today_mid.configure(text=" 浮盈 -- 合计 ") self.lbl_today_total.configure(text="--", fg=COLOR_NEUTRAL) self.lbl_today_ratio.configure(text=" 盈亏比 -- 盈利率 --", fg=COLOR_NEUTRAL) self._refresh_positions(symbol, digits) self._sync_open_buttons(symbol) except TradeError: # 断连时仍尝试刷新日线卡片 try: day = self.bridge.day_ohlc(symbol) digits = 3 self._set_metric("elapsed", day["elapsed_text"], COLOR_WARN) self._set_metric("open", f"{day['open']:.{digits}f}") self._set_metric("high", f"{day['high']:.{digits}f}", COLOR_PROFIT) self._set_metric("low", f"{day['low']:.{digits}f}", COLOR_LOSS) self.lbl_session.configure(text="时段 休市", bg="#3a2820", fg=COLOR_WARN) self.lbl_day_ohlc.configure(text="休市 · 显示最近交易日日线") except Exception: pass except Exception: pass @staticmethod def _pnl_color(value: float) -> str: if value > 1e-8: return COLOR_PROFIT if value < -1e-8: return COLOR_LOSS return COLOR_NEUTRAL def _refresh_today_stats(self, symbol: str) -> None: st = self.bridge.today_stats(symbol) closed = st["closed_pnl"] floating = st["floating"] total = st["total"] rate = st["profit_rate"] * 100.0 self.lbl_today_base.configure(text=f"今日开单 {st['opens']} 次") self.lbl_today_closed.configure(text=f" 已平 {closed:+.2f}", fg=self._pnl_color(closed)) self.lbl_today_mid.configure(text=f" 浮盈 {floating:+.2f} 合计 ") self.lbl_today_total.configure(text=f"{total:+.2f}", fg=self._pnl_color(total)) self.lbl_today_ratio.configure( text=f" | 盈亏比 {st['pl_ratio_txt']} | 盈利率 {rate:+.2f}%", fg=self._pnl_color(total), ) def _refresh_positions(self, symbol: str, digits: int) -> None: positions = self.bridge.positions(symbol) live = {int(p.ticket) for p in positions} self._selected_tickets_set = {t for t in self._selected_tickets_set if t in live} self._be_done_tickets = {t for t in self._be_done_tickets if t in live} sig = tuple( ( int(p.ticket), int(p.type), round(float(p.volume), 4), round(float(p.price_open), 5), round(float(p.sl), 5), round(float(p.tp), 5), int(p.ticket) in self._selected_tickets_set, ) for p in positions ) # 结构未变时只更新浮盈,避免按钮闪烁点不中 if sig == self._pos_sig and self._pos_profit_labels: for p in positions: ticket = int(p.ticket) profit = self._pos_profit(p) lbl = self._pos_profit_labels.get(ticket) if lbl is not None and lbl.winfo_exists(): lbl.configure(text=f"{profit:.2f}", fg=self._pnl_color(profit)) self._sync_be_button(positions) return self._pos_sig = sig self._pos_profit_labels = {} for child in self.pos_inner.winfo_children(): child.destroy() for p in positions: ticket = int(p.ticket) is_buy = int(p.type) == mt5.POSITION_TYPE_BUY side = "多" if is_buy else "空" sl = float(p.sl) tp = float(p.tp) open_price = float(p.price_open) sl_dist = f"{abs(open_price - sl):.2f}u" if sl > 0 else "--" profit = self._pos_profit(p) selected = ticket in self._selected_tickets_set bg = "#3a4a5a" if selected else "#2b2b2b" row = tk.Frame(self.pos_inner, bg=bg) row.pack(fill=tk.X, pady=1) self._config_pos_columns(row) cells = [ str(ticket), side, f"{float(p.volume):.2f}", f"{open_price:.{digits}f}", sl_dist, ("--" if sl <= 0 else f"{sl:.{digits}f}"), ("--" if tp <= 0 else f"{tp:.{digits}f}"), f"{profit:.2f}", ] for i, text in enumerate(cells): title = self._pos_col_specs[i][0] if title == "浮盈": fg = self._pnl_color(profit) elif title == "方向": fg = COLOR_PROFIT if is_buy else COLOR_LOSS else: fg = "#f0f0f0" lbl = tk.Label( row, text=text, bg=bg, fg=fg, font=("Microsoft YaHei UI", 11), anchor="center", ) lbl.grid(row=0, column=i, sticky="nsew", padx=2, pady=5) lbl.bind("", lambda e, t=ticket: self._toggle_ticket_select(t)) if title == "浮盈": self._pos_profit_labels[ticket] = lbl btn_wrap = tk.Frame(row, bg=bg) btn_wrap.grid(row=0, column=len(cells), sticky="nsew", padx=4, pady=3) self._mk_close_btn(btn_wrap, ticket).pack(anchor="center") row.bind("", lambda e, t=ticket: self._toggle_ticket_select(t)) if not live: tk.Label( self.pos_inner, text="当前无持仓", bg="#2b2b2b", fg="#888888", font=("Microsoft YaHei UI", 11), ).pack(fill=tk.X, pady=12) self._sync_be_button(positions) def _selected_tickets(self) -> list: return sorted(self._selected_tickets_set) def _close_ticket_now(self, ticket: int) -> None: def work(): self.bridge.connect() return self.bridge.close_ticket(ticket) self._run_async(work) def on_buy(self) -> None: self._order(mt5.ORDER_TYPE_BUY, "买入") def on_sell(self) -> None: self._order(mt5.ORDER_TYPE_SELL, "卖出") def _order(self, order_type: int, side: str) -> None: symbol = self._symbol() def work(): self.bridge.connect() lot = self._parse_float(self.var_lot.get(), "手数") if lot <= 0: raise TradeError("手数必须大于 0") self.bridge.check_max_positions(symbol) self.bridge.check_max_addon(symbol, order_type, lot) sl_usd, tp_usd, atr, sl_m, tp_m = self._resolve_stops(symbol) return self.bridge.market_order(symbol, order_type, lot, sl_usd, tp_usd) self._run_async(work) def on_close_buy(self) -> None: self._close(mt5.POSITION_TYPE_BUY, "平多") def on_close_sell(self) -> None: self._close(mt5.POSITION_TYPE_SELL, "平空") def on_close_all(self) -> None: self._close(None, "全平") def _close(self, pos_type: Optional[int], title: str) -> None: symbol = self._symbol() def work(): self.bridge.connect() return self.bridge.close_positions(symbol, pos_type) self._run_async(work) def on_break_even(self) -> None: """点击一次:寻找价格朝有利方向走出>3$ 且未保本的单,改止损到开仓价±1$。""" if str(self.btn_break_even["state"]) == str(tk.DISABLED): self.set_status( f"没有价格朝有利方向走出超过 {BE_MIN_MOVE_USD:.0f} 美金的可保本持仓", ok=False, ) return symbol = self._symbol() def work(): self.bridge.connect() positions = self.bridge.positions(symbol) tick = self.bridge.tick(symbol) tickets = self._be_candidate_tickets(positions, tick) if not tickets: raise TradeError( f"没有价格朝有利方向走出超过 {BE_MIN_MOVE_USD:.0f} 美金的可保本持仓" ) msg, done = self.bridge.break_even_tickets(tickets, BE_OFFSET_USD) self._be_done_tickets.update(done) return msg self._run_async(work) def _hub_heartbeat(self) -> None: """约每 2 秒 ping 中控,供总览秒级在线状态。""" interval_ms = 2000 if hub_client is None or not is_activated(): self.after(interval_ms, self._hub_heartbeat) return def work(): try: return bool(hub_client.ping()) except Exception: return False def done(ok: bool): if not ok: self.set_status("中控离线:请检查服务器,客户端需保持在线", ok=False) self.after(interval_ms, self._hub_heartbeat) def run(): ok = work() self.after(0, lambda: done(ok)) threading.Thread(target=run, daemon=True).start() def on_open_review(self) -> None: """打开中控复盘登录页(浏览器),使用已保存地址。""" server = "" try: server = (load_device().get("server") or "").rstrip("/") except Exception: server = "" if not server: self.set_status("未激活:请先完成中控激活", ok=False) return url = server.rstrip("/") + "/login" try: webbrowser.open(url) self.set_status(f"已打开复盘登录页:{url}", ok=True) except Exception as exc: self.set_status(f"无法打开浏览器:{exc}", ok=False) def destroy(self) -> None: try: self.bridge.shutdown() finally: super().destroy() def main() -> None: enable_dpi_awareness() # 激活窗自管可见性(勿对隐藏根窗挂 Toplevel,Windows 上会双击无界面) if ensure_activated is not None: if not ensure_activated(): return app = OrderApp() if is_activated(): try: dev = load_device() app.set_status( f"已连接中控 {dev.get('server')} · 账户#{dev.get('account_id')} 机器#{dev.get('machine_id')}", ok=True, ) except Exception: pass app.mainloop() if __name__ == "__main__": try: main() except Exception as exc: try: import traceback from pathlib import Path log = Path.home() / ".gold_monitor" / "crash.log" log.parent.mkdir(parents=True, exist_ok=True) log.write_text(traceback.format_exc(), encoding="utf-8") except Exception: pass try: root = tk.Tk() root.withdraw() messagebox.showerror("黄金市价辅助", f"启动失败:{exc}\n详情见 %USERPROFILE%\\.gold_monitor\\crash.log") root.destroy() except Exception: pass raise