7de33ffed6
Co-authored-by: Cursor <cursoragent@cursor.com>
2209 lines
86 KiB
Python
2209 lines
86 KiB
Python
# -*- 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
|
||
ATR_FLOOR_USD = 10.0 # ATR 低于该值时,止损/止盈按 10 美金计;高于则用实际 ATR
|
||
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$
|
||
SPREAD_WARN_USD = 0.50 # 点差超过该美金时,买卖按钮区提示(不禁开)
|
||
|
||
|
||
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 _as_bjt(now: Optional[datetime] = None) -> datetime:
|
||
dt = now or datetime.now(BJT)
|
||
if dt.tzinfo is None:
|
||
return dt.replace(tzinfo=BJT)
|
||
return dt.astimezone(BJT)
|
||
|
||
|
||
def current_session_label(now: Optional[datetime] = None, market_open: bool = True) -> str:
|
||
"""北京时间盘段:亚盘/欧盘/美盘(可重叠显示);休市单独标注。"""
|
||
if not market_open:
|
||
return "休市"
|
||
dt = _as_bjt(now)
|
||
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)
|
||
|
||
|
||
# 黄金常见时段(一律按北京时间 UTC+8):周一 07:00 开 → 周六 05:00 收;日内约 05:00–07:00
|
||
GOLD_OPEN_HOUR = 7
|
||
GOLD_OPEN_MINUTE = 0
|
||
GOLD_CLOSE_HOUR = 5
|
||
GOLD_CLOSE_MINUTE = 0
|
||
|
||
|
||
def fmt_bjt(dt: datetime, fmt: str = "%m-%d %H:%M") -> str:
|
||
"""格式化为北京时间字符串(不依赖系统时区)。"""
|
||
return _as_bjt(dt).strftime(fmt)
|
||
|
||
|
||
def next_market_open(now: Optional[datetime] = None) -> datetime:
|
||
"""估算下一次开盘时刻,返回带 BJT 时区的 datetime。"""
|
||
dt = _as_bjt(now)
|
||
open_hm = (GOLD_OPEN_HOUR, GOLD_OPEN_MINUTE, 0)
|
||
close_hm = (GOLD_CLOSE_HOUR, GOLD_CLOSE_MINUTE, 0)
|
||
t = (dt.hour, dt.minute, dt.second)
|
||
wd = dt.weekday() # Mon=0 … Sun=6
|
||
|
||
# 周末休市:周六 05:00(北京)→ 下周一 07:00(北京)
|
||
if wd == 5 and t >= close_hm:
|
||
days = 2
|
||
elif wd == 6:
|
||
days = 1
|
||
elif wd == 0 and t < open_hm:
|
||
days = 0
|
||
# 工作日日内休市窗口 05:00–07:00(北京)
|
||
elif wd <= 4 and close_hm <= t < open_hm:
|
||
days = 0
|
||
else:
|
||
# 非预期休市(节假日等):推到下一个可交易日的北京时间 07:00
|
||
days = 1
|
||
while True:
|
||
cand_date = (dt + timedelta(days=days)).date()
|
||
cwd = datetime(cand_date.year, cand_date.month, cand_date.day, tzinfo=BJT).weekday()
|
||
if cwd <= 4:
|
||
break
|
||
days += 1
|
||
if days > 10:
|
||
break
|
||
|
||
target = (dt + timedelta(days=days)).date()
|
||
return datetime(
|
||
target.year, target.month, target.day,
|
||
GOLD_OPEN_HOUR, GOLD_OPEN_MINUTE, 0, tzinfo=BJT,
|
||
)
|
||
|
||
|
||
def format_countdown(seconds: int) -> str:
|
||
seconds = max(0, int(seconds))
|
||
days, rem = divmod(seconds, 86400)
|
||
hours, rem = divmod(rem, 3600)
|
||
mins, secs = divmod(rem, 60)
|
||
if days > 0:
|
||
return f"距开盘 {days}天{hours}小时{mins}分"
|
||
if hours > 0:
|
||
return f"距开盘 {hours}小时{mins}分"
|
||
if mins > 0:
|
||
return f"距开盘 {mins}分{secs}秒"
|
||
return f"距开盘 {secs}秒"
|
||
|
||
|
||
def format_open_elapsed(open_ts: float, market_open: bool, now: Optional[datetime] = None) -> str:
|
||
"""开盘后已过多久;休市时显示距下次开盘倒计时。"""
|
||
if not market_open:
|
||
nxt = next_market_open(now)
|
||
left = max(0, int((nxt - _as_bjt(now)).total_seconds()))
|
||
return format_countdown(left)
|
||
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, 3)
|
||
if rates is None or len(rates) < 1:
|
||
raise TradeError("无法读取日线,请确认品种有 D1 数据")
|
||
# MT5 返回数组从旧到新:[-1] 才是当前日线
|
||
bar = rates[-1]
|
||
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)
|
||
open_dt = datetime.fromtimestamp(open_ts, BJT)
|
||
bar_day = open_dt.strftime("%m-%d")
|
||
nxt = None if market_open else next_market_open()
|
||
return {
|
||
"open": open_px,
|
||
"high": high,
|
||
"low": low,
|
||
"open_time": open_dt,
|
||
"open_ts": open_ts,
|
||
"bar_day": bar_day,
|
||
"market_open": market_open,
|
||
"elapsed_text": format_open_elapsed(float(open_ts), market_open),
|
||
"next_open": nxt,
|
||
"next_open_text": fmt_bjt(nxt) if nxt else "",
|
||
}
|
||
|
||
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
|
||
# 持仓单号可能与 order 不同,优先用持仓 ticket,避免平仓对不上仍显示「持仓中」
|
||
pos_ticket = int(result.order)
|
||
open_px = float(result.price)
|
||
open_ts = time.time()
|
||
for _ in range(10):
|
||
time.sleep(0.05)
|
||
for p in self.positions(symbol):
|
||
if int(p.ticket) == int(result.order) or abs(float(p.price_open) - open_px) < 0.05:
|
||
pos_ticket = int(p.ticket)
|
||
open_px = float(p.price_open)
|
||
open_ts = float(p.time)
|
||
sl = float(p.sl) if float(p.sl) > 0 else sl
|
||
tp = float(p.tp) if float(p.tp) > 0 else tp
|
||
break
|
||
else:
|
||
continue
|
||
break
|
||
side = "买入" if order_type == mt5.ORDER_TYPE_BUY else "卖出"
|
||
msg = f"{side}成功 手数={lots:.2f} 成交价={open_px} 单号={pos_ticket}"
|
||
report = {
|
||
"ticket": str(pos_ticket),
|
||
"symbol": symbol,
|
||
"side": "buy" if order_type == mt5.ORDER_TYPE_BUY else "sell",
|
||
"volume": lots,
|
||
"open_price": open_px,
|
||
"close_price": None,
|
||
"open_time": open_ts,
|
||
"close_time": None,
|
||
"profit": 0.0,
|
||
"sl": sl,
|
||
"tp": tp,
|
||
"snapshot": {"event": "open", "order": int(result.order)},
|
||
}
|
||
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 # 绝对值累加
|
||
# 按持仓单号汇总当日平仓盈亏,用于胜率(部分平仓合并为一笔)
|
||
pos_pnl: dict = {}
|
||
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
|
||
pid = int(getattr(d, "position_id", 0) or 0) or int(d.ticket)
|
||
pos_pnl[pid] = pos_pnl.get(pid, 0.0) + 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)
|
||
wins = sum(1 for p in pos_pnl.values() if p > 1e-8)
|
||
losses = sum(1 for p in pos_pnl.values() if p < -1e-8)
|
||
closed_n = len(pos_pnl)
|
||
if closed_n > 0:
|
||
win_rate = wins / closed_n
|
||
win_rate_txt = f"{win_rate * 100:.0f}%({wins}胜{losses}负)"
|
||
else:
|
||
win_rate_txt = "--"
|
||
_, _, 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,
|
||
"win_rate_txt": win_rate_txt,
|
||
"wins": wins,
|
||
"losses": losses,
|
||
"closed_trades": closed_n,
|
||
}
|
||
|
||
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 recent_close_reports(self, symbol: str, hours: float = 72) -> list:
|
||
"""从 MT5 历史成交还原已平仓,用于补报/纠错仍显示「持仓中」的记录。"""
|
||
self.ensure_symbol(symbol)
|
||
now = datetime.now()
|
||
deals = mt5.history_deals_get(now - timedelta(hours=hours), now + timedelta(hours=1))
|
||
if not deals:
|
||
return []
|
||
out_entries = {
|
||
mt5.DEAL_ENTRY_OUT,
|
||
mt5.DEAL_ENTRY_OUT_BY,
|
||
mt5.DEAL_ENTRY_INOUT,
|
||
}
|
||
by_pos: dict = {}
|
||
for d in deals:
|
||
if str(d.symbol) != symbol:
|
||
continue
|
||
if int(d.entry) not in out_entries:
|
||
continue
|
||
pid = int(d.position_id or 0)
|
||
if pid <= 0:
|
||
continue
|
||
g = by_pos.get(pid)
|
||
if g is None:
|
||
# 平仓方向:卖出平仓 → 原多单
|
||
side = "buy" if int(d.type) == mt5.DEAL_TYPE_SELL else "sell"
|
||
g = {
|
||
"ticket": str(pid),
|
||
"symbol": symbol,
|
||
"side": side,
|
||
"volume": 0.0,
|
||
"open_price": 0.0,
|
||
"close_price": float(d.price),
|
||
"open_time": None,
|
||
"close_time": float(d.time),
|
||
"profit": 0.0,
|
||
"sl": None,
|
||
"tp": None,
|
||
"snapshot": {"event": "history_close"},
|
||
}
|
||
by_pos[pid] = g
|
||
g["profit"] += float(d.profit) + float(d.swap) + float(d.commission)
|
||
g["volume"] += float(d.volume)
|
||
g["close_price"] = float(d.price)
|
||
g["close_time"] = max(float(g["close_time"]), float(d.time))
|
||
for d in deals:
|
||
if str(d.symbol) != symbol:
|
||
continue
|
||
pid = int(d.position_id or 0)
|
||
if pid not in by_pos:
|
||
continue
|
||
if int(d.entry) in (mt5.DEAL_ENTRY_IN, mt5.DEAL_ENTRY_INOUT):
|
||
by_pos[pid]["open_price"] = float(d.price)
|
||
prev = by_pos[pid].get("open_time")
|
||
ts = float(d.time)
|
||
by_pos[pid]["open_time"] = ts if prev is None else min(float(prev), ts)
|
||
return list(by_pos.values())
|
||
|
||
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 _sltp_retcode_ok(self, retcode: int) -> bool:
|
||
ok = {
|
||
mt5.TRADE_RETCODE_DONE,
|
||
getattr(mt5, "TRADE_RETCODE_DONE_PARTIAL", -1),
|
||
getattr(mt5, "TRADE_RETCODE_PLACED", -1),
|
||
}
|
||
return int(retcode) in ok
|
||
|
||
def modify_position_sltp(self, ticket: int, sl: float, tp: Optional[float] = None) -> float:
|
||
"""修改持仓止损/止盈;成功返回实际止损价,失败抛 TradeError。"""
|
||
p = self.get_position(int(ticket))
|
||
symbol = str(p.symbol)
|
||
self.ensure_symbol(symbol)
|
||
term = mt5.terminal_info()
|
||
if term is None or not term.connected:
|
||
raise TradeError("终端未连接到交易服务器")
|
||
if not getattr(term, "trade_allowed", True):
|
||
raise TradeError("终端未开启自动交易(请勾选 Algo Trading)")
|
||
acc = mt5.account_info()
|
||
if acc is None or not getattr(acc, "trade_allowed", True):
|
||
raise TradeError("账户不允许交易")
|
||
|
||
digits = int(self.symbol_info(symbol).digits)
|
||
sl = round(float(sl), digits)
|
||
if tp is None:
|
||
tp_val = float(p.tp)
|
||
else:
|
||
tp_val = float(tp)
|
||
tp_val = round(tp_val, digits) if tp_val > 1e-12 else 0.0
|
||
old_sl = float(p.sl)
|
||
|
||
# 多组请求:部分券商带 magic / 不带 magic 表现不同;TP=0 与保留原 TP 也分开试
|
||
variants: list[dict] = []
|
||
base = {
|
||
"action": mt5.TRADE_ACTION_SLTP,
|
||
"symbol": symbol,
|
||
"position": int(ticket),
|
||
"sl": sl,
|
||
}
|
||
variants.append({**base, "tp": tp_val})
|
||
variants.append({**base, "tp": tp_val, "magic": MAGIC})
|
||
if tp_val > 1e-12:
|
||
variants.append({**base, "tp": 0.0}) # 仅改 SL
|
||
# 去重
|
||
seen = set()
|
||
uniq: list[dict] = []
|
||
for req in variants:
|
||
key = (req.get("sl"), req.get("tp"), req.get("magic"))
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
uniq.append(req)
|
||
|
||
last_detail = ""
|
||
for request in uniq:
|
||
check = mt5.order_check(request)
|
||
if check is not None and int(getattr(check, "retcode", 0)) not in (
|
||
0,
|
||
mt5.TRADE_RETCODE_DONE,
|
||
):
|
||
last_detail = f"校验失败 {check.comment} ({check.retcode})"
|
||
# 校验失败仍尝试 send,部分终端 check 偏严
|
||
result = mt5.order_send(request)
|
||
if result is None:
|
||
last_detail = str(mt5.last_error())
|
||
continue
|
||
if not self._sltp_retcode_ok(result.retcode):
|
||
last_detail = f"{result.comment} ({result.retcode})"
|
||
continue
|
||
# 回读确认(券商可能延迟)
|
||
for _ in range(8):
|
||
time.sleep(0.2)
|
||
try:
|
||
p2 = self.get_position(int(ticket))
|
||
except TradeError:
|
||
continue
|
||
if abs(float(p2.sl) - sl) <= max(10 ** (-digits), 0.01):
|
||
return float(p2.sl)
|
||
last_detail = (
|
||
f"回报成功但止损未变(原 {old_sl:.{digits}f} → 目标 {sl:.{digits}f},"
|
||
f"当前仍为 {float(self.get_position(int(ticket)).sl):.{digits}f})"
|
||
)
|
||
raise TradeError(f"#{ticket} {last_detail or '改止损失败'}")
|
||
|
||
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 = []
|
||
details: 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)
|
||
digits = int(self.symbol_info(symbol).digits)
|
||
min_dist = self.min_stop_distance(symbol)
|
||
t = self.tick(symbol)
|
||
old_sl = float(p.sl)
|
||
|
||
if pos_type == mt5.POSITION_TYPE_BUY:
|
||
sl = round(open_price + offset_usd, digits)
|
||
ref = float(t.bid)
|
||
move = ref - open_price
|
||
if move + 1e-12 < BE_MIN_MOVE_USD:
|
||
raise TradeError(
|
||
f"#{ticket} 有利方向仅 {move:.2f}$,需 ≥ {BE_MIN_MOVE_USD:.0f}$"
|
||
)
|
||
if sl + max(min_dist, 0.0) > ref + 1e-12:
|
||
raise TradeError(
|
||
f"#{ticket} 现价 {ref:.{digits}f} 距保本价 {sl:.{digits}f} 过近"
|
||
)
|
||
else:
|
||
sl = round(open_price - offset_usd, digits)
|
||
ref = float(t.ask)
|
||
move = open_price - ref
|
||
if move + 1e-12 < BE_MIN_MOVE_USD:
|
||
raise TradeError(
|
||
f"#{ticket} 有利方向仅 {move:.2f}$,需 ≥ {BE_MIN_MOVE_USD:.0f}$"
|
||
)
|
||
if ref > sl - max(min_dist, 0.0) + 1e-12:
|
||
raise TradeError(
|
||
f"#{ticket} 现价 {ref:.{digits}f} 距保本价 {sl:.{digits}f} 过近"
|
||
)
|
||
|
||
new_sl = self.modify_position_sltp(int(ticket), sl, float(p.tp))
|
||
ok_tickets.append(int(ticket))
|
||
details.append(
|
||
f"#{ticket} SL {old_sl:.{digits}f}→{new_sl:.{digits}f}"
|
||
)
|
||
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)} 笔:" + ",".join(details)
|
||
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._last_spread: float = 0.0
|
||
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._metric_box(metrics, "range", "振幅")
|
||
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) 做止损/止盈(ATR<10 按 10)",
|
||
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._be_ready = False
|
||
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(
|
||
"<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, 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("<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} 手 · "
|
||
f"点差>{SPREAD_WARN_USD:.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
|
||
|
||
@staticmethod
|
||
def _atr_for_stops(atr: float) -> float:
|
||
"""止损/止盈用的 ATR:小于 10 按 10,大于等于 10 用实际值。"""
|
||
return max(float(atr), ATR_FLOOR_USD)
|
||
|
||
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
|
||
base = self._atr_for_stops(atr) if atr > 1e-12 else 0.0
|
||
return base * sl_mult, base * 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)
|
||
|
||
spread_wide = self._last_spread > SPREAD_WARN_USD + 1e-9
|
||
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"
|
||
elif spread_wide:
|
||
badge = f"点差偏大 {self._last_spread:.2f}$"
|
||
banner = (
|
||
f"⚠ 点差过大 {self._last_spread:.2f}$(阈值 {SPREAD_WARN_USD:.2f})|"
|
||
"建议暂缓开仓"
|
||
)
|
||
color = COLOR_WARN
|
||
bg = "#3a3220"
|
||
else:
|
||
badge = ""
|
||
banner = ""
|
||
color = COLOR_NEUTRAL
|
||
bg = COLOR_BG
|
||
|
||
# 买卖按钮文案:可开且点差大时直接写在按钮上
|
||
if can_buy:
|
||
self.btn_buy.configure(text="买入 ⚠点差大" if spread_wide else "买入")
|
||
else:
|
||
self.btn_buy.configure(text="买入")
|
||
if can_sell:
|
||
self.btn_sell.configure(text="卖出 ⚠点差大" if spread_wide else "卖出")
|
||
else:
|
||
self.btn_sell.configure(text="卖出")
|
||
|
||
self.lbl_day_lock.configure(text=badge, fg=color, bg=bg if badge else COLOR_CARD)
|
||
self.lbl_open_block.configure(text=banner, fg=color, bg=bg if banner else COLOR_BG)
|
||
|
||
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:
|
||
try:
|
||
tick = self.bridge.tick(str(positions[0].symbol))
|
||
except Exception:
|
||
return tickets
|
||
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) + 1e-12 >= BE_MIN_MOVE_USD:
|
||
tickets.append(ticket)
|
||
return tickets
|
||
|
||
def _be_status_hint(self, positions, tick=None) -> str:
|
||
"""保本按钮不可用时的说明(含还差多少美金)。"""
|
||
if not positions:
|
||
return "当前无持仓,无法保本"
|
||
symbol = str(positions[0].symbol)
|
||
try:
|
||
if tick is None:
|
||
tick = self.bridge.tick(symbol)
|
||
except Exception as exc:
|
||
return f"无法读报价:{exc}"
|
||
best = -1e9
|
||
for p in positions:
|
||
ticket = int(p.ticket)
|
||
if ticket in self._be_done_tickets or self._is_sl_at_breakeven(p):
|
||
continue
|
||
best = max(best, self._favorable_move_usd(p, tick))
|
||
if best < -1e8:
|
||
return "持仓已保本或无需再拉"
|
||
lack = BE_MIN_MOVE_USD - best
|
||
if lack > 1e-8:
|
||
return (
|
||
f"有利方向仅 {best:.2f}$,保本需 ≥ {BE_MIN_MOVE_USD:.0f}$"
|
||
f"(之后止损拉到开仓价±{BE_OFFSET_USD:.0f}$),还差 {lack:.2f}$"
|
||
)
|
||
return "可保本"
|
||
|
||
def _set_be_button_enabled(self, enabled: bool) -> None:
|
||
"""外观区分可点/未就绪;始终 NORMAL,避免灰态点不到。"""
|
||
if not hasattr(self, "btn_break_even"):
|
||
return
|
||
self._be_ready = bool(enabled)
|
||
if enabled:
|
||
self.btn_break_even.configure(
|
||
state=tk.NORMAL,
|
||
bg=COLOR_BE,
|
||
fg="white",
|
||
activebackground=COLOR_BE,
|
||
activeforeground="white",
|
||
cursor="hand2",
|
||
)
|
||
else:
|
||
self.btn_break_even.configure(
|
||
state=tk.NORMAL,
|
||
bg=COLOR_BE_OFF,
|
||
fg="#aaaaaa",
|
||
activebackground="#666666",
|
||
activeforeground="#dddddd",
|
||
cursor="hand2",
|
||
)
|
||
|
||
def _sync_be_button(self, positions) -> None:
|
||
try:
|
||
ok = bool(self._be_candidate_tickets(positions))
|
||
except Exception:
|
||
ok = False
|
||
self._set_be_button_enabled(ok)
|
||
|
||
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], with_bars: bool = True) -> 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)
|
||
if with_bars:
|
||
sym = str(item.get("symbol") or self._symbol())
|
||
self._hub_upload_bars(sym)
|
||
except Exception:
|
||
pass
|
||
|
||
threading.Thread(target=run, daemon=True).start()
|
||
|
||
def _hub_sync_floating(self, symbol: str) -> None:
|
||
"""浮盈上报 + 历史平仓回填 + 对账清掉已不在仓的「假持仓中」。"""
|
||
if hub_client is None or not is_activated():
|
||
return
|
||
now = time.time()
|
||
last = float(getattr(self, "_last_float_sync", 0) or 0)
|
||
if now - last < 2.0:
|
||
return
|
||
self._last_float_sync = now
|
||
try:
|
||
poss = self.bridge.positions(symbol)
|
||
except Exception:
|
||
return
|
||
open_tickets = [str(p.ticket) for p in poss]
|
||
reports: list = []
|
||
# 1) 用成交历史补平仓(修复旧数据 / 漏报)
|
||
try:
|
||
reports.extend(self.bridge.recent_close_reports(symbol, hours=72))
|
||
except Exception:
|
||
pass
|
||
# 2) 当前持仓浮盈
|
||
for p in poss:
|
||
reports.append({
|
||
"ticket": str(p.ticket),
|
||
"symbol": str(p.symbol),
|
||
"side": "buy" if int(p.type) == mt5.POSITION_TYPE_BUY else "sell",
|
||
"volume": float(p.volume),
|
||
"open_price": float(p.price_open),
|
||
"close_price": None,
|
||
"open_time": float(p.time),
|
||
"close_time": None,
|
||
"profit": float(p.profit) + float(p.swap),
|
||
"sl": float(p.sl),
|
||
"tp": float(p.tp),
|
||
"snapshot": {"event": "float"},
|
||
})
|
||
if reports:
|
||
self._hub_report(reports, with_bars=False)
|
||
# 3) 告诉服务器:不在 open_tickets 里的未平仓记录应结束「持仓中」
|
||
try:
|
||
hub_client.reconcile_trades(open_tickets, symbol)
|
||
except Exception:
|
||
pass
|
||
|
||
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
|
||
self._last_spread = spread
|
||
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)
|
||
base = self._atr_for_stops(atr)
|
||
atr_txt = f"{atr:.2f}" + (f"→{base:.0f}" if base > atr + 1e-9 else "")
|
||
sl_m = float(self.var_atr_mult.get().replace(",", ".") or "0")
|
||
tp_m = float(self.var_tp.get().replace(",", ".") or "0")
|
||
sl_txt = f"{base * sl_m:.2f}"
|
||
tp_txt = f"{base * 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)
|
||
if not market_open:
|
||
sess_txt = f"休市 · {format_open_elapsed(0, False)}"
|
||
else:
|
||
sess_txt = f"时段 {sess}"
|
||
self.lbl_session.configure(
|
||
text=sess_txt,
|
||
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 --")
|
||
spread_wide = spread > SPREAD_WARN_USD + 1e-9
|
||
self.lbl_info.configure(
|
||
text=(
|
||
f"点差 {spread:.{digits}f}"
|
||
f"{' ⚠偏大' if spread_wide else ''} ATR {atr_txt} "
|
||
f"SL${sl_txt} TP${tp_txt} 持仓 {pos} 浮盈 {profit:.2f}"
|
||
),
|
||
fg=COLOR_WARN if spread_wide else COLOR_MUTED,
|
||
)
|
||
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)
|
||
amp = float(day["high"]) - float(day["low"])
|
||
base = float(day["open"]) or float(day["low"]) or 1.0
|
||
amp_pct = (amp / base) * 100.0 if base else 0.0
|
||
self._set_metric(
|
||
"range",
|
||
f"{amp:.{digits}f} ({amp_pct:.2f}%)",
|
||
COLOR_ACCENT,
|
||
)
|
||
ot = fmt_bjt(day["open_time"])
|
||
if day["market_open"]:
|
||
tag = "交易中"
|
||
else:
|
||
nxt_txt = day.get("next_open_text") or ""
|
||
tag = f"休市 · {day['elapsed_text']}"
|
||
if nxt_txt:
|
||
tag += f"(预计开盘 {nxt_txt} 北京时间)"
|
||
self.lbl_day_ohlc.configure(text=f"{tag} · 日线开盘 {ot} 北京时间")
|
||
except Exception:
|
||
left = format_open_elapsed(0, False) if not market_open else "--"
|
||
self._set_metric("elapsed", left, COLOR_WARN)
|
||
self._set_metric("open", "--")
|
||
self._set_metric("high", "--")
|
||
self._set_metric("low", "--")
|
||
self._set_metric("range", "--")
|
||
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)
|
||
self._hub_sync_floating(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)
|
||
amp = float(day["high"]) - float(day["low"])
|
||
base = float(day["open"]) or float(day["low"]) or 1.0
|
||
amp_pct = (amp / base) * 100.0 if base else 0.0
|
||
self._set_metric(
|
||
"range",
|
||
f"{amp:.{digits}f} ({amp_pct:.2f}%)",
|
||
COLOR_ACCENT,
|
||
)
|
||
self.lbl_session.configure(
|
||
text=f"休市 · {day['elapsed_text']}",
|
||
bg="#3a2820",
|
||
fg=COLOR_WARN,
|
||
)
|
||
nxt = day.get("next_open_text") or ""
|
||
tip = f"休市 · {day['elapsed_text']}"
|
||
if nxt:
|
||
tip += f"(预计开盘 {nxt} 北京时间)"
|
||
self.lbl_day_ohlc.configure(text=f"{tip} · 显示最近交易日日线")
|
||
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}%"
|
||
f" | 胜率 {st['win_rate_txt']}"
|
||
),
|
||
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:
|
||
"""点击一次:对可保本持仓改止损到开仓价±1$。"""
|
||
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)
|
||
selected = self._selected_tickets()
|
||
if selected:
|
||
picked = [t for t in tickets if t in selected]
|
||
tickets = picked if picked else list(selected)
|
||
if not tickets:
|
||
raise TradeError(self._be_status_hint(positions, tick))
|
||
msg, done = self.bridge.break_even_tickets(tickets, BE_OFFSET_USD)
|
||
self._be_done_tickets.update(done)
|
||
return msg
|
||
|
||
def after_ok(msg: str) -> None:
|
||
self.set_status(msg, ok=True)
|
||
# 强制重绘持仓行,避免止损列仍显示旧值
|
||
self._pos_sig = None
|
||
try:
|
||
digits = int(self.bridge.symbol_info(symbol).digits)
|
||
except Exception:
|
||
digits = 3
|
||
self._refresh_positions(symbol, digits)
|
||
|
||
def runner():
|
||
try:
|
||
msg = work()
|
||
self.after(0, lambda m=msg: after_ok(m))
|
||
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)
|
||
|
||
if self._busy:
|
||
self.set_status("上一次操作还未完成", ok=False)
|
||
return
|
||
self._busy = True
|
||
threading.Thread(target=runner, daemon=True).start()
|
||
|
||
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:
|
||
"""用本机激活 token 免密打开网页复盘。"""
|
||
def work() -> None:
|
||
try:
|
||
if hub_client is None:
|
||
raise RuntimeError("未打包中控客户端")
|
||
url = hub_client.open_review_url("/records")
|
||
self.after(0, lambda: self._open_review_done(url, None))
|
||
except Exception as exc:
|
||
self.after(0, lambda: self._open_review_done(None, exc))
|
||
|
||
self.set_status("正在获取复盘免密链接…", ok=True)
|
||
threading.Thread(target=work, daemon=True).start()
|
||
|
||
def _open_review_done(self, url: Optional[str], exc: Optional[BaseException]) -> None:
|
||
if exc is not None or not url:
|
||
msg = str(exc) if exc else "无法打开复盘"
|
||
self.set_status(msg, ok=False)
|
||
return
|
||
try:
|
||
webbrowser.open(url)
|
||
self.set_status("已免密打开复盘(本机 token)", ok=True)
|
||
except Exception as open_exc:
|
||
self.set_status(f"无法打开浏览器:{open_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
|