From 1d4d5b06df0de246d7b2ed3cf029a8aafcb06819 Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 17 Oct 2026 17:50:42 +0800 Subject: [PATCH] feat: settings password change, migrate desktop client with hub activation/report --- README.md | 4 +- app/db.py | 62 ++ app/routes.py | 23 +- app/templates/base.html | 1 + app/templates/settings.html | 33 + desktop/README.md | 45 +- desktop/activation_dialog.py | 77 ++ desktop/build_exe.bat | 30 + desktop/device_store.py | 64 ++ desktop/gold_order_app.py | 1465 ++++++++++++++++++++++++++++++++++ desktop/hub_client.py | 123 +++ desktop/requirements.txt | 3 + docs/technical.md | 20 +- docs/usage.md | 9 + 14 files changed, 1933 insertions(+), 26 deletions(-) create mode 100644 app/templates/settings.html create mode 100644 desktop/activation_dialog.py create mode 100644 desktop/build_exe.bat create mode 100644 desktop/device_store.py create mode 100644 desktop/gold_order_app.py create mode 100644 desktop/hub_client.py create mode 100644 desktop/requirements.txt 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. 标注账户与机器