Files
gold_monitor/desktop/gold_order_app.py
T

1768 lines
68 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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 # 同时最多持仓笔数,满则买卖置灰
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),
]
pos_wrap = tk.Frame(root, bg="#2b2b2b", highlightthickness=0)
pos_wrap.pack(fill=tk.BOTH, expand=True)
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")
body.pack(fill=tk.BOTH, expand=True)
self.pos_canvas = tk.Canvas(body, bg="#2b2b2b", highlightthickness=0, bd=0)
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(
"<Configure>",
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)
self.pos_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scroll.pack(side=tk.RIGHT, fill=tk.Y)
self.pos_canvas.bind("<Configure>", self._on_pos_canvas_configure)
self.pos_canvas.bind("<Enter>", lambda e: self.pos_canvas.bind_all("<MouseWheel>", self._on_pos_mousewheel))
self.pos_canvas.bind("<Leave>", lambda e: self.pos_canvas.unbind_all("<MouseWheel>"))
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("<Button-1>", 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("<Button-1>", 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