diff --git a/README.md b/README.md
index f25ab3a..b9bddd6 100644
--- a/README.md
+++ b/README.md
@@ -40,11 +40,13 @@ bash deploy/manage.sh
| 功能 | 说明 |
|------|------|
| **登录** | 网页账号(默认 admin) |
+| **系统设置** | 前端修改登录密码 |
| **下载中心** | 通用辅助包 + 生成激活码 |
| **账户 / 机器** | 标注名称、备注、类型 |
| **首次激活** | exe 调用 API 绑定指纹与 token |
-| **成交上报** | 已激活客户端 POST 成交与可选 K 线 |
+| **成交上报** | 已激活客户端 POST 成交与四周期 K 线 |
| **交易复盘** | 列表 + 详情四周期(M1/M5/M15/H1)+ 入场形式标注 |
+| **desktop/** | 已迁入下单辅助,支持激活与上报 |
---
diff --git a/app/db.py b/app/db.py
index ec7168b..1edef12 100644
--- a/app/db.py
+++ b/app/db.py
@@ -101,6 +101,11 @@ def init_db() -> None:
open REAL, high REAL, low REAL, close REAL, volume REAL,
UNIQUE(symbol, timeframe, ts)
);
+
+ CREATE TABLE IF NOT EXISTS settings (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+ );
"""
)
@@ -259,7 +264,23 @@ def get_bars(conn: sqlite3.Connection, symbol: str, timeframe: str, start_ts: fl
return [row_to_dict(r) for r in cur.fetchall()]
+def get_setting(conn: sqlite3.Connection, key: str, default: str = "") -> str:
+ row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
+ return row["value"] if row else default
+
+
+def set_setting(conn: sqlite3.Connection, key: str, value: str) -> None:
+ conn.execute(
+ "INSERT INTO settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
+ (key, value),
+ )
+
+
def seed_if_empty() -> None:
+ from werkzeug.security import generate_password_hash
+
+ from app.config import Config
+
with get_db() as conn:
n = conn.execute("SELECT COUNT(*) AS c FROM accounts").fetchone()["c"]
if n == 0:
@@ -273,3 +294,44 @@ def seed_if_empty() -> None:
"INSERT INTO machines(name, device_fingerprint, note, enabled, created_at) VALUES(?,?,?,?,?)",
("未绑定机器", "", "下载通用包后用激活码绑定", 1, now()),
)
+ if not get_setting(conn, "admin_user"):
+ set_setting(conn, "admin_user", Config.ADMIN_USER)
+ if not get_setting(conn, "admin_password_hash"):
+ set_setting(
+ conn,
+ "admin_password_hash",
+ generate_password_hash(Config.ADMIN_PASSWORD),
+ )
+
+
+def verify_admin(username: str, password: str) -> bool:
+ from werkzeug.security import check_password_hash
+
+ with get_db() as conn:
+ user = get_setting(conn, "admin_user", "admin")
+ pwd_hash = get_setting(conn, "admin_password_hash")
+ if username != user:
+ return False
+ if not pwd_hash:
+ from app.config import Config
+
+ return password == Config.ADMIN_PASSWORD
+ return check_password_hash(pwd_hash, password)
+
+
+def change_admin_password(old_password: str, new_password: str) -> None:
+ from werkzeug.security import check_password_hash, generate_password_hash
+
+ if len(new_password) < 6:
+ raise ValueError("新密码至少 6 位")
+ with get_db() as conn:
+ pwd_hash = get_setting(conn, "admin_password_hash")
+ if pwd_hash:
+ if not check_password_hash(pwd_hash, old_password):
+ raise ValueError("当前密码不正确")
+ else:
+ from app.config import Config
+
+ if old_password != Config.ADMIN_PASSWORD:
+ raise ValueError("当前密码不正确")
+ set_setting(conn, "admin_password_hash", generate_password_hash(new_password))
diff --git a/app/routes.py b/app/routes.py
index b95f767..8adf5a7 100644
--- a/app/routes.py
+++ b/app/routes.py
@@ -50,7 +50,7 @@ def register_routes(app: Flask) -> None:
def login_post():
user = (request.form.get("username") or "").strip()
pwd = request.form.get("password") or ""
- if user == Config.ADMIN_USER and pwd == Config.ADMIN_PASSWORD:
+ if database.verify_admin(user, pwd):
session["user"] = user
return redirect(request.args.get("next") or url_for("dashboard"))
flash("用户名或密码错误", "error")
@@ -61,6 +61,27 @@ def register_routes(app: Flask) -> None:
session.clear()
return redirect(url_for("login"))
+ @app.get("/settings")
+ @login_required
+ def settings_page():
+ return render_template("settings.html")
+
+ @app.post("/settings/password")
+ @login_required
+ def settings_password():
+ old_pwd = request.form.get("old_password") or ""
+ new_pwd = request.form.get("new_password") or ""
+ new_pwd2 = request.form.get("new_password2") or ""
+ if new_pwd != new_pwd2:
+ flash("两次输入的新密码不一致", "error")
+ return redirect(url_for("settings_page"))
+ try:
+ database.change_admin_password(old_pwd, new_pwd)
+ flash("密码已更新,请牢记新密码", "ok")
+ except ValueError as exc:
+ flash(str(exc), "error")
+ return redirect(url_for("settings_page"))
+
@app.get("/")
@login_required
def dashboard():
diff --git a/app/templates/base.html b/app/templates/base.html
index 30d044d..86549f3 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -16,6 +16,7 @@
下载中心
账户
机器
+ 系统设置
{{ session.get('user') }} ·
退出
diff --git a/app/templates/settings.html b/app/templates/settings.html
new file mode 100644
index 0000000..77f691d
--- /dev/null
+++ b/app/templates/settings.html
@@ -0,0 +1,33 @@
+{% extends "base.html" %}
+{% block title %}系统设置 · {{ app_name }}{% endblock %}
+{% block content %}
+系统设置
+
+
+
修改登录密码
+
修改后立即生效,无需重启服务。请使用足够强度的密码。
+
+
+
+
服务信息
+
对外地址:{{ public_base }}
+
端口:5800
+
当前登录用户:{{ session.get('user') }}
+
通用 exe 放置目录:releases/黄金市价辅助.exe
+
+
+{% endblock %}
diff --git a/desktop/README.md b/desktop/README.md
index 604065a..729fc2f 100644
--- a/desktop/README.md
+++ b/desktop/README.md
@@ -1,31 +1,32 @@
# desktop · MT5 黄金辅助客户端
-## 定位
+## 功能
-- **通用包**:所有机器下载同一个 exe(由网页「下载中心」分发)。
-- **首次激活**:启动后输入网页生成的激活码,调用 `POST /api/v1/activate`,本地保存 `token`。
-- **运行期**:本机连接已登录的 MT5;成交/快照 `POST /api/v1/trades`;可选上报 K 线 `POST /api/v1/bars`。
+- 连接本机已登录 MT5,市价开平、ATR 止损止盈、保本、满仓灰显等(由原 `MT5黄金方案/Desktop` 迁入)
+- **通用包 + 首次激活**:启动时填写中控地址与激活码
+- **自动上报**:开仓/平仓成功后 `POST /api/v1/trades`,并上传 M1/M5/M15/H1 K 线供网页复盘
-## 与现有代码
+## 本地配置
-当前可从 `E:\仓库\MT5黄金方案\Desktop\gold_order_app.py` 迁移下单逻辑。
-本目录后续接入:
+激活信息保存在:`%USERPROFILE%\.gold_monitor\device.json`
-1. 激活窗 / 本地 `device.json`(token、account_id、machine_id、server)
-2. 配置服务器地址(默认 `http://:5800`)
-3. 成交与四周期 bars 上报
+## 开发运行
-## 激活 API 示例
-
-```http
-POST /api/v1/activate
-Content-Type: application/json
-
-{
- "code": "激活码",
- "fingerprint": "机器唯一指纹",
- "machine_name": "书房PC"
-}
+```bat
+cd desktop
+py -3 gold_order_app.py
```
-成功后请求头携带:`X-Device-Token: `
+需本机已安装 `MetaTrader5`、`numpy`,并打开登录 MT5。
+
+## 打包
+
+```bat
+build_exe.bat
+```
+
+产物:`dist\黄金市价辅助.exe`,复制到仓库 `releases\黄金市价辅助.exe` 后由网页下载中心分发。
+
+## 离线模式
+
+激活窗可点「离线使用」:正常下单,但不上报复盘中心。
diff --git a/desktop/activation_dialog.py b/desktop/activation_dialog.py
new file mode 100644
index 0000000..4a39f2a
--- /dev/null
+++ b/desktop/activation_dialog.py
@@ -0,0 +1,77 @@
+# -*- coding: utf-8 -*-
+"""首次激活对话框:服务器地址 + 激活码。"""
+from __future__ import annotations
+
+import tkinter as tk
+from tkinter import messagebox, ttk
+
+import hub_client
+from device_store import is_activated, load_device, machine_fingerprint, save_device
+
+
+def ensure_activated(root: tk.Tk) -> bool:
+ """已激活且能 ping 通则通过;否则弹出激活窗。返回是否可继续。"""
+ device = load_device()
+ if device.get("token") and device.get("server"):
+ if hub_client.ping():
+ return True
+ # token 失效时允许重新激活
+ if not messagebox.askyesno(
+ "激活校验",
+ "无法连接中控或令牌失效,是否重新激活?\n选「否」将以离线模式继续(不上报复盘)。",
+ parent=root,
+ ):
+ return True
+
+ win = tk.Toplevel(root)
+ win.title("激活黄金辅助")
+ win.geometry("460x280")
+ win.transient(root)
+ win.grab_set()
+ result = {"ok": False}
+
+ frm = ttk.Frame(win, padding=16)
+ frm.pack(fill=tk.BOTH, expand=True)
+
+ ttk.Label(frm, text="首次使用请输入中控地址与激活码(网页下载中心生成)").pack(anchor="w")
+ ttk.Label(frm, text="服务器地址(例:http://192.168.1.10:5800)").pack(anchor="w", pady=(12, 4))
+ var_server = tk.StringVar(value=device.get("server") or "http://127.0.0.1:5800")
+ ttk.Entry(frm, textvariable=var_server, width=48).pack(fill=tk.X)
+
+ ttk.Label(frm, text="激活码").pack(anchor="w", pady=(12, 4))
+ var_code = tk.StringVar()
+ ttk.Entry(frm, textvariable=var_code, width=48).pack(fill=tk.X)
+
+ ttk.Label(frm, text="本机名称(可改)").pack(anchor="w", pady=(12, 4))
+ var_name = tk.StringVar(value=device.get("machine_name") or "")
+ ttk.Entry(frm, textvariable=var_name, width=48).pack(fill=tk.X)
+
+ status = ttk.Label(frm, text="", foreground="#888")
+ status.pack(anchor="w", pady=(10, 0))
+
+ def do_skip():
+ result["ok"] = True
+ win.destroy()
+
+ def do_ok():
+ server = var_server.get().strip()
+ code = var_code.get().strip()
+ if not server or not code:
+ status.configure(text="请填写服务器与激活码", foreground="#f6465d")
+ return
+ try:
+ hub_client.activate(server, code, machine_fingerprint(), var_name.get().strip() or "PC")
+ result["ok"] = True
+ messagebox.showinfo("激活成功", "已绑定账户与机器,成交将上报复盘中心。", parent=win)
+ win.destroy()
+ except hub_client.HubError as exc:
+ status.configure(text=str(exc), foreground="#f6465d")
+
+ btns = ttk.Frame(frm)
+ btns.pack(fill=tk.X, pady=(16, 0))
+ ttk.Button(btns, text="离线使用", command=do_skip).pack(side=tk.LEFT)
+ ttk.Button(btns, text="激活", command=do_ok).pack(side=tk.RIGHT)
+
+ win.protocol("WM_DELETE_WINDOW", do_skip)
+ root.wait_window(win)
+ return result["ok"]
diff --git a/desktop/build_exe.bat b/desktop/build_exe.bat
new file mode 100644
index 0000000..6f79972
--- /dev/null
+++ b/desktop/build_exe.bat
@@ -0,0 +1,30 @@
+@echo off
+chcp 65001 >nul
+cd /d "%~dp0"
+
+set "HTTP_PROXY="
+set "HTTPS_PROXY="
+set "http_proxy="
+set "https_proxy="
+set "ALL_PROXY="
+set "all_proxy="
+set "NO_PROXY=*"
+set "no_proxy=*"
+
+echo [1/2] 打包通用辅助 exe ...
+py -3 -m PyInstaller --noconfirm --clean --onefile --windowed --name "黄金市价辅助" --hidden-import=MetaTrader5 --hidden-import=numpy --collect-all MetaTrader5 --collect-all numpy gold_order_app.py
+if errorlevel 1 goto fail
+
+echo [2/2] 复制到 releases(若存在上级目录)
+if exist "..\releases" copy /Y "dist\黄金市价辅助.exe" "..\releases\黄金市价辅助.exe" >nul
+
+echo.
+echo 完成: %cd%\dist\黄金市价辅助.exe
+echo 请放到服务器 /opt/gold_monitor/releases/ 供网页下载。
+pause
+exit /b 0
+
+:fail
+echo 打包失败
+pause
+exit /b 1
diff --git a/desktop/device_store.py b/desktop/device_store.py
new file mode 100644
index 0000000..2ba7196
--- /dev/null
+++ b/desktop/device_store.py
@@ -0,0 +1,64 @@
+# -*- coding: utf-8 -*-
+"""本机激活信息:通用包首次激活后写入 device.json。"""
+from __future__ import annotations
+
+import json
+import os
+import platform
+import uuid
+from pathlib import Path
+from typing import Any, Optional
+
+APP_DIR = Path(os.environ.get("GOLD_MONITOR_DATA", Path.home() / ".gold_monitor"))
+DEVICE_FILE = APP_DIR / "device.json"
+
+
+def ensure_dir() -> None:
+ APP_DIR.mkdir(parents=True, exist_ok=True)
+
+
+def machine_fingerprint() -> str:
+ """相对稳定的本机指纹(非绝对防伪,够用区分机器)。"""
+ node = uuid.getnode()
+ name = platform.node() or "pc"
+ return f"{name}-{node:012x}"
+
+
+def load_device() -> dict[str, Any]:
+ ensure_dir()
+ if not DEVICE_FILE.is_file():
+ return {
+ "server": "",
+ "token": "",
+ "account_id": None,
+ "machine_id": None,
+ "fingerprint": machine_fingerprint(),
+ "machine_name": platform.node() or "PC",
+ }
+ try:
+ data = json.loads(DEVICE_FILE.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ data = {}
+ data.setdefault("fingerprint", machine_fingerprint())
+ data.setdefault("machine_name", platform.node() or "PC")
+ data.setdefault("server", "")
+ data.setdefault("token", "")
+ return data
+
+
+def save_device(data: dict[str, Any]) -> None:
+ ensure_dir()
+ DEVICE_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+def is_activated() -> bool:
+ d = load_device()
+ return bool(d.get("token") and d.get("server"))
+
+
+def clear_activation() -> None:
+ d = load_device()
+ d["token"] = ""
+ d["account_id"] = None
+ d["machine_id"] = None
+ save_device(d)
diff --git a/desktop/gold_order_app.py b/desktop/gold_order_app.py
new file mode 100644
index 0000000..0b380bb
--- /dev/null
+++ b/desktop/gold_order_app.py
@@ -0,0 +1,1465 @@
+# -*- coding: utf-8 -*-
+"""黄金市价辅助下单窗:连接本机已登录的 MT5;可选激活 gold_monitor 上报复盘。"""
+
+from __future__ import annotations
+
+import math
+import sys
+import threading
+import time
+import tkinter as tk
+from datetime import datetime, timedelta
+from tkinter import messagebox, ttk
+from typing import Any, Optional, Tuple, Union
+
+# 顶层导入,避免打包 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"
+BE_MIN_MOVE_USD = 3.0 # 价格向有利方向走出该美金距离才可保本(非账户浮盈)
+BE_OFFSET_USD = 1.0 # 保本止损 = 开仓价 ± 1$
+
+
+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:
+ if balance >= BALANCE_HIGH:
+ return LOT_HIGH
+ if balance < BALANCE_LOW:
+ return LOT_LOW
+ return LOT_MID
+
+
+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 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("1080x900")
+ self.minsize(980, 820)
+ self.configure(bg="#1e1e1e")
+ 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._build_ui()
+ self.after(200, self._try_connect_and_refresh)
+
+ def _build_ui(self) -> None:
+ style = ttk.Style(self)
+ try:
+ style.theme_use("clam")
+ except tk.TclError:
+ pass
+ style.configure("TLabel", background="#1e1e1e", foreground="#f0f0f0", font=("Microsoft YaHei UI", 12))
+ style.configure("Status.TLabel", background="#1e1e1e", foreground="#c0c0c0", font=("Microsoft YaHei UI", 11))
+ style.configure("TFrame", background="#1e1e1e")
+ style.configure("TEntry", fieldbackground="#2b2b2b", foreground="#ffffff", font=("Microsoft YaHei UI", 12))
+ style.configure("TCheckbutton", background="#1e1e1e", foreground="#f0f0f0", font=("Microsoft YaHei UI", 11))
+ root = ttk.Frame(self, padding=18)
+ root.pack(fill=tk.BOTH, expand=True)
+
+ head = ttk.Frame(root)
+ head.pack(fill=tk.X)
+ ttk.Label(head, text="品种", font=("Microsoft YaHei UI", 13, "bold")).pack(side=tk.LEFT)
+ self.var_symbol = tk.StringVar(value=DEFAULT_SYMBOL)
+ ttk.Entry(head, textvariable=self.var_symbol, width=16).pack(side=tk.LEFT, padx=8)
+ self.lbl_account = ttk.Label(head, text="余额 --", font=("Microsoft YaHei UI", 13, "bold"))
+ self.lbl_account.pack(side=tk.LEFT, padx=12)
+
+ self.lbl_quote = ttk.Label(root, text="Bid -- Ask --", font=("Microsoft YaHei UI", 14, "bold"))
+ self.lbl_quote.pack(anchor="w", pady=(10, 0))
+ self.lbl_info = ttk.Label(root, text="点差 -- ATR -- SL$ -- TP$ -- 持仓 --", font=("Microsoft YaHei UI", 12))
+ self.lbl_info.pack(anchor="w", pady=(4, 2))
+ today_row = ttk.Frame(root)
+ today_row.pack(fill=tk.X, pady=(0, 12))
+ self.lbl_today_base = tk.Label(
+ today_row, text="今日开单 -- 次 | ", bg="#1e1e1e", fg=COLOR_NEUTRAL,
+ font=("Microsoft YaHei UI", 11),
+ )
+ self.lbl_today_base.pack(side=tk.LEFT)
+ self.lbl_today_closed = tk.Label(
+ today_row, text="已平 --", bg="#1e1e1e", 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="#1e1e1e", fg=COLOR_NEUTRAL,
+ font=("Microsoft YaHei UI", 11),
+ )
+ self.lbl_today_mid.pack(side=tk.LEFT)
+ self.lbl_today_total = tk.Label(
+ today_row, text="--", bg="#1e1e1e", 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="#1e1e1e", fg=COLOR_NEUTRAL,
+ font=("Microsoft YaHei UI", 11, "bold"),
+ )
+ self.lbl_today_ratio.pack(side=tk.LEFT)
+ self.lbl_day_lock = tk.Label(
+ today_row,
+ text="",
+ bg="#1e1e1e",
+ fg=COLOR_WARN,
+ font=("Microsoft YaHei UI", 11, "bold"),
+ padx=8,
+ pady=2,
+ )
+ self.lbl_day_lock.pack(side=tk.RIGHT)
+
+ form = ttk.Frame(root)
+ form.pack(fill=tk.X)
+ for i in range(3):
+ form.columnconfigure(i, weight=1)
+
+ ttk.Label(form, text="手数").grid(row=0, column=0, sticky="w")
+ self.lbl_sl_title = ttk.Label(form, text="SL倍")
+ self.lbl_sl_title.grid(row=0, column=1, sticky="w", padx=6)
+ self.lbl_tp_title = ttk.Label(form, text="TP倍")
+ 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=8, ipady=4)
+ ttk.Entry(form, textvariable=self.var_atr_mult, width=12).grid(row=1, column=1, sticky="ew", padx=8, pady=8, ipady=4)
+ ttk.Entry(form, textvariable=self.var_tp, width=12).grid(row=1, column=2, sticky="ew", pady=8, ipady=4)
+
+ opt = ttk.Frame(root)
+ opt.pack(fill=tk.X, pady=(0, 10))
+ 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=10)
+ ttk.Label(
+ opt,
+ text=f"同向加仓上限 {MAX_SIDE_LOTS:.2f} 手 · 最多同时 {MAX_OPEN_POSITIONS} 笔",
+ style="Status.TLabel",
+ ).pack(side=tk.LEFT)
+
+ self.lbl_open_block = tk.Label(
+ root,
+ text="",
+ bg="#1e1e1e",
+ fg=COLOR_WARN,
+ font=("Microsoft YaHei UI", 12, "bold"),
+ anchor="center",
+ )
+ self.lbl_open_block.pack(fill=tk.X, pady=(0, 4))
+
+ btns = ttk.Frame(root)
+ btns.pack(fill=tk.X, pady=6)
+ btns.columnconfigure(0, weight=1)
+ btns.columnconfigure(1, weight=1)
+ self.btn_buy = self._mk_btn(btns, "买入", COLOR_BUY, self.on_buy, h=46)
+ 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=46)
+ self.btn_sell.grid(row=0, column=1, sticky="ew", padx=(6, 0), ipady=8)
+
+ close = ttk.Frame(root)
+ 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 = ttk.Label(root, text="就绪", style="Status.TLabel", wraplength=900)
+ self.lbl_status.pack(anchor="w", pady=(12, 6))
+
+ pos_bar = ttk.Frame(root)
+ pos_bar.pack(fill=tk.X, pady=(10, 6))
+ ttk.Label(pos_bar, 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._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(
+ "",
+ 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("", self._on_pos_canvas_configure)
+ self.pos_canvas.bind("", lambda e: self.pos_canvas.bind_all("", self._on_pos_mousewheel))
+ self.pos_canvas.bind("", lambda e: self.pos_canvas.unbind_all(""))
+
+ tip = ttk.Label(
+ root,
+ text=(
+ f"最多同时 {MAX_OPEN_POSITIONS} 笔持仓;同向≤{MAX_SIDE_LOTS:.2f} 手。"
+ "亏≥5%或盈≥10%达标后禁止开仓。保本:价格朝有利方向走出>3$ 后改止损到开仓价±1$。"
+ ),
+ style="Status.TLabel",
+ wraplength=920,
+ )
+ 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, foreground="#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:
+ try:
+ self.bridge.connect()
+ bal = self.bridge.account_balance()
+ lot = suggest_lot_by_balance(bal)
+ self.var_lot.set(f"{lot:.2f}")
+ self.set_status(f"按资金设置手数:余额 {bal:.2f} → {lot:.2f}", 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 refresh_quotes(self) -> None:
+ symbol = self._symbol()
+ try:
+ if not self.bridge.connected:
+ self.bridge.connect()
+ t = self.bridge.tick(symbol)
+ info = self.bridge.symbol_info(symbol)
+ digits = int(info.digits)
+ bid = float(t.bid)
+ ask = float(t.ask)
+ spread = ask - bid
+ buy, sell, profit = self.bridge.exposure(symbol)
+ 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="余额 --")
+
+ self.lbl_quote.configure(text=f"Bid {bid:.{digits}f} Ask {ask:.{digits}f}")
+ self.lbl_info.configure(
+ text=(
+ f"点差 {spread:.{digits}f} ATRM15 {atr_txt} "
+ f"SL${sl_txt} TP${tp_txt} 持仓 {pos} 浮盈 {profit:.2f}"
+ )
+ )
+ 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:
+ pass
+ except Exception:
+ pass
+
+ @staticmethod
+ def _pnl_color(value: float) -> str:
+ if value > 1e-8:
+ return COLOR_PROFIT
+ if value < -1e-8:
+ return COLOR_LOSS
+ return COLOR_NEUTRAL
+
+ def _refresh_today_stats(self, symbol: str) -> None:
+ st = self.bridge.today_stats(symbol)
+ closed = st["closed_pnl"]
+ floating = st["floating"]
+ total = st["total"]
+ rate = st["profit_rate"] * 100.0
+ self.lbl_today_base.configure(text=f"今日开单 {st['opens']} 次 | ")
+ self.lbl_today_closed.configure(text=f"已平 {closed:+.2f}", fg=self._pnl_color(closed))
+ self.lbl_today_mid.configure(text=f" | 浮盈 {floating:+.2f} | 合计 ")
+ self.lbl_today_total.configure(text=f"{total:+.2f}", fg=self._pnl_color(total))
+ self.lbl_today_ratio.configure(
+ text=f" | 盈亏比 {st['pl_ratio_txt']} | 盈利率 {rate:+.2f}%",
+ fg=self._pnl_color(total),
+ )
+
+ def _refresh_positions(self, symbol: str, digits: int) -> None:
+ positions = self.bridge.positions(symbol)
+ live = {int(p.ticket) for p in positions}
+ self._selected_tickets_set = {t for t in self._selected_tickets_set if t in live}
+ self._be_done_tickets = {t for t in self._be_done_tickets if t in live}
+
+ sig = tuple(
+ (
+ int(p.ticket),
+ int(p.type),
+ round(float(p.volume), 4),
+ round(float(p.price_open), 5),
+ round(float(p.sl), 5),
+ round(float(p.tp), 5),
+ int(p.ticket) in self._selected_tickets_set,
+ )
+ for p in positions
+ )
+ # 结构未变时只更新浮盈,避免按钮闪烁点不中
+ if sig == self._pos_sig and self._pos_profit_labels:
+ for p in positions:
+ ticket = int(p.ticket)
+ profit = self._pos_profit(p)
+ lbl = self._pos_profit_labels.get(ticket)
+ if lbl is not None and lbl.winfo_exists():
+ lbl.configure(text=f"{profit:.2f}", fg=self._pnl_color(profit))
+ self._sync_be_button(positions)
+ return
+
+ self._pos_sig = sig
+ self._pos_profit_labels = {}
+ for child in self.pos_inner.winfo_children():
+ child.destroy()
+
+ for p in positions:
+ ticket = int(p.ticket)
+ is_buy = int(p.type) == mt5.POSITION_TYPE_BUY
+ side = "多" if is_buy else "空"
+ sl = float(p.sl)
+ tp = float(p.tp)
+ open_price = float(p.price_open)
+ sl_dist = f"{abs(open_price - sl):.2f}u" if sl > 0 else "--"
+ profit = self._pos_profit(p)
+ selected = ticket in self._selected_tickets_set
+ bg = "#3a4a5a" if selected else "#2b2b2b"
+ row = tk.Frame(self.pos_inner, bg=bg)
+ row.pack(fill=tk.X, pady=1)
+ self._config_pos_columns(row)
+
+ cells = [
+ str(ticket),
+ side,
+ f"{float(p.volume):.2f}",
+ f"{open_price:.{digits}f}",
+ sl_dist,
+ ("--" if sl <= 0 else f"{sl:.{digits}f}"),
+ ("--" if tp <= 0 else f"{tp:.{digits}f}"),
+ f"{profit:.2f}",
+ ]
+ for i, text in enumerate(cells):
+ title = self._pos_col_specs[i][0]
+ if title == "浮盈":
+ fg = self._pnl_color(profit)
+ elif title == "方向":
+ fg = COLOR_PROFIT if is_buy else COLOR_LOSS
+ else:
+ fg = "#f0f0f0"
+ lbl = tk.Label(
+ row,
+ text=text,
+ bg=bg,
+ fg=fg,
+ font=("Microsoft YaHei UI", 11),
+ anchor="center",
+ )
+ lbl.grid(row=0, column=i, sticky="nsew", padx=2, pady=5)
+ lbl.bind("", lambda e, t=ticket: self._toggle_ticket_select(t))
+ if title == "浮盈":
+ self._pos_profit_labels[ticket] = lbl
+
+ btn_wrap = tk.Frame(row, bg=bg)
+ btn_wrap.grid(row=0, column=len(cells), sticky="nsew", padx=4, pady=3)
+ self._mk_close_btn(btn_wrap, ticket).pack(anchor="center")
+ row.bind("", lambda e, t=ticket: self._toggle_ticket_select(t))
+
+ if not live:
+ tk.Label(
+ self.pos_inner,
+ text="当前无持仓",
+ bg="#2b2b2b",
+ fg="#888888",
+ font=("Microsoft YaHei UI", 11),
+ ).pack(fill=tk.X, pady=12)
+
+ self._sync_be_button(positions)
+
+ def _selected_tickets(self) -> list:
+ return sorted(self._selected_tickets_set)
+
+ def _close_ticket_now(self, ticket: int) -> None:
+ def work():
+ self.bridge.connect()
+ return self.bridge.close_ticket(ticket)
+
+ self._run_async(work)
+
+ def on_buy(self) -> None:
+ self._order(mt5.ORDER_TYPE_BUY, "买入")
+
+ def on_sell(self) -> None:
+ self._order(mt5.ORDER_TYPE_SELL, "卖出")
+
+ def _order(self, order_type: int, side: str) -> None:
+ symbol = self._symbol()
+
+ def work():
+ self.bridge.connect()
+ lot = self._parse_float(self.var_lot.get(), "手数")
+ if lot <= 0:
+ raise TradeError("手数必须大于 0")
+ self.bridge.check_max_positions(symbol)
+ self.bridge.check_max_addon(symbol, order_type, lot)
+ sl_usd, tp_usd, atr, sl_m, tp_m = self._resolve_stops(symbol)
+ return self.bridge.market_order(symbol, order_type, lot, sl_usd, tp_usd)
+
+ self._run_async(work)
+
+ def on_close_buy(self) -> None:
+ self._close(mt5.POSITION_TYPE_BUY, "平多")
+
+ def on_close_sell(self) -> None:
+ self._close(mt5.POSITION_TYPE_SELL, "平空")
+
+ def on_close_all(self) -> None:
+ self._close(None, "全平")
+
+ def _close(self, pos_type: Optional[int], title: str) -> None:
+ symbol = self._symbol()
+
+ def work():
+ self.bridge.connect()
+ return self.bridge.close_positions(symbol, pos_type)
+
+ self._run_async(work)
+
+ def on_break_even(self) -> None:
+ """点击一次:寻找价格朝有利方向走出>3$ 且未保本的单,改止损到开仓价±1$。"""
+ if str(self.btn_break_even["state"]) == str(tk.DISABLED):
+ self.set_status(
+ f"没有价格朝有利方向走出超过 {BE_MIN_MOVE_USD:.0f} 美金的可保本持仓",
+ ok=False,
+ )
+ return
+ symbol = self._symbol()
+
+ def work():
+ self.bridge.connect()
+ positions = self.bridge.positions(symbol)
+ tick = self.bridge.tick(symbol)
+ tickets = self._be_candidate_tickets(positions, tick)
+ if not tickets:
+ raise TradeError(
+ f"没有价格朝有利方向走出超过 {BE_MIN_MOVE_USD:.0f} 美金的可保本持仓"
+ )
+ msg, done = self.bridge.break_even_tickets(tickets, BE_OFFSET_USD)
+ self._be_done_tickets.update(done)
+ return msg
+
+ self._run_async(work)
+
+ def destroy(self) -> None:
+ try:
+ self.bridge.shutdown()
+ finally:
+ super().destroy()
+
+
+def main() -> None:
+ enable_dpi_awareness()
+ # 先建隐藏根窗完成激活,再开主界面
+ boot = tk.Tk()
+ boot.withdraw()
+ if ensure_activated is not None:
+ if not ensure_activated(boot):
+ boot.destroy()
+ return
+ boot.destroy()
+
+ 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__":
+ main()
diff --git a/desktop/hub_client.py b/desktop/hub_client.py
new file mode 100644
index 0000000..e370bc7
--- /dev/null
+++ b/desktop/hub_client.py
@@ -0,0 +1,123 @@
+# -*- coding: utf-8 -*-
+"""与 gold_monitor 中控通信:激活、成交上报、K 线上报。"""
+from __future__ import annotations
+
+import json
+import urllib.error
+import urllib.request
+from typing import Any, Optional
+
+from device_store import load_device, save_device
+
+
+class HubError(Exception):
+ pass
+
+
+def _url(server: str, path: str) -> str:
+ base = (server or "").rstrip("/")
+ if not base:
+ raise HubError("未配置服务器地址")
+ if not path.startswith("/"):
+ path = "/" + path
+ return base + path
+
+
+def _request(
+ method: str,
+ server: str,
+ path: str,
+ body: Optional[dict] = None,
+ token: str = "",
+ timeout: float = 15.0,
+) -> dict[str, Any]:
+ data = None
+ headers = {"Accept": "application/json"}
+ if body is not None:
+ raw = json.dumps(body, ensure_ascii=False).encode("utf-8")
+ data = raw
+ headers["Content-Type"] = "application/json"
+ if token:
+ headers["X-Device-Token"] = token
+ req = urllib.request.Request(_url(server, path), data=data, headers=headers, method=method)
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ text = resp.read().decode("utf-8", errors="replace")
+ return json.loads(text) if text else {}
+ except urllib.error.HTTPError as exc:
+ try:
+ detail = exc.read().decode("utf-8", errors="replace")
+ payload = json.loads(detail) if detail else {}
+ raise HubError(payload.get("error") or detail or str(exc)) from exc
+ except HubError:
+ raise
+ except Exception:
+ raise HubError(f"HTTP {exc.code}") from exc
+ except urllib.error.URLError as exc:
+ raise HubError(f"无法连接服务器:{exc.reason}") from exc
+
+
+def activate(server: str, code: str, fingerprint: str, machine_name: str) -> dict[str, Any]:
+ result = _request(
+ "POST",
+ server,
+ "/api/v1/activate",
+ {
+ "code": code.strip(),
+ "fingerprint": fingerprint,
+ "machine_name": machine_name,
+ },
+ )
+ if not result.get("ok"):
+ raise HubError(result.get("error") or "激活失败")
+ device = load_device()
+ device["server"] = server.rstrip("/")
+ device["token"] = result["token"]
+ device["account_id"] = result.get("account_id")
+ device["machine_id"] = result.get("machine_id")
+ device["fingerprint"] = fingerprint
+ device["machine_name"] = machine_name
+ save_device(device)
+ return result
+
+
+def ping() -> bool:
+ d = load_device()
+ if not d.get("token") or not d.get("server"):
+ return False
+ try:
+ r = _request("GET", d["server"], "/api/v1/ping", token=d["token"], timeout=8)
+ return bool(r.get("ok"))
+ except HubError:
+ return False
+
+
+def report_trade(payload: dict[str, Any]) -> Optional[int]:
+ d = load_device()
+ if not d.get("token") or not d.get("server"):
+ return None
+ try:
+ r = _request("POST", d["server"], "/api/v1/trades", body=payload, token=d["token"])
+ if r.get("ok"):
+ return int(r.get("id") or 0)
+ except HubError:
+ return None
+ return None
+
+
+def report_bars(symbol: str, timeframe: str, bars: list[dict]) -> int:
+ d = load_device()
+ if not d.get("token") or not d.get("server") or not bars:
+ return 0
+ try:
+ r = _request(
+ "POST",
+ d["server"],
+ "/api/v1/bars",
+ body={"symbol": symbol, "timeframe": timeframe, "bars": bars},
+ token=d["token"],
+ timeout=30,
+ )
+ return int(r.get("count") or 0)
+ except HubError:
+ return 0
diff --git a/desktop/requirements.txt b/desktop/requirements.txt
new file mode 100644
index 0000000..a29754e
--- /dev/null
+++ b/desktop/requirements.txt
@@ -0,0 +1,3 @@
+MetaTrader5>=5.0.45
+numpy>=1.24
+pyinstaller>=6.0
diff --git a/docs/technical.md b/docs/technical.md
index 306f18d..f47aa38 100644
--- a/docs/technical.md
+++ b/docs/technical.md
@@ -115,9 +115,25 @@ Header:`X-Device-Token`
| `deploy/ecosystem.config.cjs` | PM2 |
| `deploy/manage.sh` | 交互部署 |
-## 7. 后续扩展(规划)
+## 7. 管理员密码
+
+- 表 `settings`:`admin_user` / `admin_password_hash`(Werkzeug hash)
+- 首次启动用 `.env` 的 `ADMIN_PASSWORD` 写入 hash
+- 网页「系统设置」可改密,**不写回 .env**,以数据库为准
+
+## 8. 客户端(desktop/)
+
+| 文件 | 说明 |
+|------|------|
+| `gold_order_app.py` | 下单主程序(已迁入) |
+| `activation_dialog.py` | 首次激活 UI |
+| `hub_client.py` | HTTP 激活/上报 |
+| `device_store.py` | `~/.gold_monitor/device.json` |
+
+开仓/平仓成功返回 `(消息, report_dict)`,后台线程上报 trades + 四周期 bars。
+
+## 9. 后续扩展(规划)
-- 客户端完整激活窗与成交/Bars 自动上报(对接 `desktop/`)
- 复盘筛选、统计面板、AI 点评(可对齐 crypto_monitor)
- PostgreSQL 替换 SQLite(多写高并发时)
- Nginx 反代与 HTTPS
diff --git a/docs/usage.md b/docs/usage.md
index a66e274..cec6f87 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -11,6 +11,15 @@
- **交易复盘**:列表与详情
- **下载中心**:通用 exe + 激活码
- **账户 / 机器**:标注管理
+- **系统设置**:修改网页登录密码(立即生效,无需改 `.env` / 重启)
+
+### 修改密码
+
+1. 登录后打开 **系统设置**
+2. 输入当前密码与新密码(至少 6 位)→ 保存
+3. 下次登录使用新密码
+
+> 初始密码来自部署时的 `.env`(默认 admin123);修改后以系统设置中的密码为准。
## 2. 标注账户与机器