feat: settings password change, migrate desktop client with hub activation/report
This commit is contained in:
+23
-22
@@ -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://<ubuntu>: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: <token>`
|
||||
需本机已安装 `MetaTrader5`、`numpy`,并打开登录 MT5。
|
||||
|
||||
## 打包
|
||||
|
||||
```bat
|
||||
build_exe.bat
|
||||
```
|
||||
|
||||
产物:`dist\黄金市价辅助.exe`,复制到仓库 `releases\黄金市价辅助.exe` 后由网页下载中心分发。
|
||||
|
||||
## 离线模式
|
||||
|
||||
激活窗可点「离线使用」:正常下单,但不上报复盘中心。
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
MetaTrader5>=5.0.45
|
||||
numpy>=1.24
|
||||
pyinstaller>=6.0
|
||||
Reference in New Issue
Block a user