From fb35968a1f3629684994a35dc6f684fc91b04b3d Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 17 Oct 2026 17:43:17 +0800 Subject: [PATCH] feat: scaffold gold_monitor web hub, API, PM2 deploy and docs --- .env.example | 9 + .gitattributes | 5 + .gitignore | 21 ++ README.md | 76 +++++++ app/__init__.py | 4 + app/config.py | 35 +++ app/db.py | 275 +++++++++++++++++++++++ app/factory.py | 19 ++ app/routes.py | 345 +++++++++++++++++++++++++++++ app/static/css/app.css | 142 ++++++++++++ app/static/js/charts.js | 58 +++++ app/templates/accounts.html | 46 ++++ app/templates/base.html | 33 +++ app/templates/dashboard.html | 50 +++++ app/templates/download.html | 62 ++++++ app/templates/login.html | 41 ++++ app/templates/machines.html | 38 ++++ app/templates/record_detail.html | 63 ++++++ app/templates/records.html | 37 ++++ deploy/README.md | 36 +++ deploy/ecosystem.config.cjs | 24 ++ deploy/lib/common.sh | 33 +++ deploy/lib/install.sh | 36 +++ deploy/lib/uninstall.sh | 24 ++ deploy/lib/update.sh | 26 +++ deploy/manage.sh | 133 +++++++++++ deploy/setup_env.sh | 34 +++ desktop/README.md | 31 +++ docs/deploy.md | 85 +++++++ docs/technical.md | 123 ++++++++++ docs/ubuntu-server.md | 60 +++++ docs/usage.md | 58 +++++ releases/README_PLACE_EXE_HERE.txt | 5 + requirements.txt | 4 + wsgi.py | 9 + 35 files changed, 2080 insertions(+) create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/db.py create mode 100644 app/factory.py create mode 100644 app/routes.py create mode 100644 app/static/css/app.css create mode 100644 app/static/js/charts.js create mode 100644 app/templates/accounts.html create mode 100644 app/templates/base.html create mode 100644 app/templates/dashboard.html create mode 100644 app/templates/download.html create mode 100644 app/templates/login.html create mode 100644 app/templates/machines.html create mode 100644 app/templates/record_detail.html create mode 100644 app/templates/records.html create mode 100644 deploy/README.md create mode 100644 deploy/ecosystem.config.cjs create mode 100644 deploy/lib/common.sh create mode 100644 deploy/lib/install.sh create mode 100644 deploy/lib/uninstall.sh create mode 100644 deploy/lib/update.sh create mode 100644 deploy/manage.sh create mode 100644 deploy/setup_env.sh create mode 100644 desktop/README.md create mode 100644 docs/deploy.md create mode 100644 docs/technical.md create mode 100644 docs/ubuntu-server.md create mode 100644 docs/usage.md create mode 100644 releases/README_PLACE_EXE_HERE.txt create mode 100644 requirements.txt create mode 100644 wsgi.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c2cc37b --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# gold_monitor 环境变量示例(复制为 .env 后按需修改) +FLASK_SECRET_KEY=change-me-to-a-long-random-string +ADMIN_USER=admin +ADMIN_PASSWORD=admin123 +HOST=0.0.0.0 +PORT=5800 +DATABASE_URL=sqlite:///data/gold_monitor.db +# 对外访问基址(激活码/下载链接展示用,部署后改成实际 IP 或域名) +PUBLIC_BASE_URL=http://127.0.0.1:5800 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..53645af --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +* text=auto +*.sh text eol=lf +*.py text eol=lf +*.md text eol=lf +*.cjs text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d1dce7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.env +data/*.db +data/*.sqlite +data/*.sqlite3 +*.log +.DS_Store +.idea/ +.vscode/ +releases/*.exe +releases/*.zip +node_modules/ +.pm2/ +backups/ +*.spec +build/ +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f25ab3a --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# gold_monitor + +MT5 **黄金** 下单辅助中控:**网页登录 → 下载通用 exe → 首次激活绑定机器/账户 → 成交上报 → 网页复盘(四周期 K 线 + 入场形式标注)**。 + +**远程仓库**:[https://git.bz121.com/dekun/gold_monitor.git](https://git.bz121.com/dekun/gold_monitor.git) + +复盘 UI / 深色面板风格对齐本组织项目 [crypto_monitor](https://git.bz121.com/dekun/crypto_monitor.git) 的「交易记录与复盘」体验。 + +--- + +## 部署环境(必读) + +| 项 | 约定 | +|----|------| +| 系统 | **Ubuntu 22.04 / 24.04** | +| 用户 | **root** | +| 路径 | **`/opt/gold_monitor`** | +| 进程 | **PM2** | +| 端口 | **5800** | + +```bash +# 新服务器(推荐) +curl -fsSL https://git.bz121.com/dekun/gold_monitor/raw/branch/main/deploy/manage.sh | bash + +# 或手动 +cd /opt +git clone https://git.bz121.com/dekun/gold_monitor.git gold_monitor +cd /opt/gold_monitor +bash deploy/manage.sh +``` + +- 部署文档:[docs/deploy.md](./docs/deploy.md) · [docs/ubuntu-server.md](./docs/ubuntu-server.md) +- 使用文档:[docs/usage.md](./docs/usage.md) +- 技术文档:[docs/technical.md](./docs/technical.md) + +--- + +## 功能导航 + +| 功能 | 说明 | +|------|------| +| **登录** | 网页账号(默认 admin) | +| **下载中心** | 通用辅助包 + 生成激活码 | +| **账户 / 机器** | 标注名称、备注、类型 | +| **首次激活** | exe 调用 API 绑定指纹与 token | +| **成交上报** | 已激活客户端 POST 成交与可选 K 线 | +| **交易复盘** | 列表 + 详情四周期(M1/M5/M15/H1)+ 入场形式标注 | + +--- + +## 仓库目录 + +| 目录 | 说明 | +|------|------| +| `app/` | Flask Web + API | +| `desktop/` | 客户端说明(下单逻辑从既有 Desktop 迁移) | +| `deploy/` | 一键交互部署 / PM2 | +| `docs/` | 部署 / 使用 / 技术文档 | +| `releases/` | 放置通用 `黄金市价辅助.exe` | +| `data/` | SQLite 数据(部署后生成) | + +--- + +## 本地开发(Windows / Linux) + +```bash +cd gold_monitor +python -m venv .venv +# Windows: .venv\Scripts\activate +source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +python wsgi.py +``` + +浏览器打开 [http://127.0.0.1:5800](http://127.0.0.1:5800) 。 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..cd98def --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +from app.factory import create_app + +__all__ = ["create_app"] diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..c1aee97 --- /dev/null +++ b/app/config.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import os +from pathlib import Path + +from dotenv import load_dotenv + +ROOT = Path(__file__).resolve().parent.parent +load_dotenv(ROOT / ".env") + +DATA_DIR = ROOT / "data" +DATA_DIR.mkdir(parents=True, exist_ok=True) +RELEASES_DIR = ROOT / "releases" +RELEASES_DIR.mkdir(parents=True, exist_ok=True) + + +class Config: + SECRET_KEY = os.getenv("FLASK_SECRET_KEY", "gold-monitor-dev-secret-change-me") + ADMIN_USER = os.getenv("ADMIN_USER", "admin") + ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123") + HOST = os.getenv("HOST", "0.0.0.0") + PORT = int(os.getenv("PORT", "5800")) + PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "http://127.0.0.1:5800").rstrip("/") + + _db = os.getenv("DATABASE_URL", f"sqlite:///{DATA_DIR / 'gold_monitor.db'}") + if _db.startswith("sqlite:///"): + # 相对路径转到仓库根 + rel = _db.replace("sqlite:///", "", 1) + p = Path(rel) + if not p.is_absolute(): + p = ROOT / p + DATABASE_PATH = p + else: + DATABASE_PATH = DATA_DIR / "gold_monitor.db" diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..ec7168b --- /dev/null +++ b/app/db.py @@ -0,0 +1,275 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import json +import secrets +import sqlite3 +import time +from contextlib import contextmanager +from typing import Any, Iterator, Optional + +from app.config import Config + + +def _connect() -> sqlite3.Connection: + Config.DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(Config.DATABASE_PATH), check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +@contextmanager +def get_db() -> Iterator[sqlite3.Connection]: + conn = _connect() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def init_db() -> None: + with get_db() as conn: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + broker TEXT DEFAULT '', + login_id TEXT DEFAULT '', + account_type TEXT DEFAULT 'demo', + note TEXT DEFAULT '', + enabled INTEGER DEFAULT 1, + created_at REAL NOT NULL + ); + + CREATE TABLE IF NOT EXISTS machines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + device_fingerprint TEXT DEFAULT '', + note TEXT DEFAULT '', + enabled INTEGER DEFAULT 1, + last_seen_at REAL, + created_at REAL NOT NULL + ); + + CREATE TABLE IF NOT EXISTS activations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + account_id INTEGER, + machine_id INTEGER, + token TEXT, + status TEXT DEFAULT 'pending', + bound_at REAL, + created_at REAL NOT NULL, + FOREIGN KEY(account_id) REFERENCES accounts(id), + FOREIGN KEY(machine_id) REFERENCES machines(id) + ); + + CREATE TABLE IF NOT EXISTS trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ticket TEXT, + symbol TEXT NOT NULL, + side TEXT NOT NULL, + volume REAL, + open_price REAL, + close_price REAL, + open_time REAL, + close_time REAL, + profit REAL, + sl REAL, + tp REAL, + account_id INTEGER, + machine_id INTEGER, + entry_style TEXT DEFAULT '', + review_note TEXT DEFAULT '', + snapshot_json TEXT DEFAULT '{}', + created_at REAL NOT NULL, + FOREIGN KEY(account_id) REFERENCES accounts(id), + FOREIGN KEY(machine_id) REFERENCES machines(id) + ); + + CREATE TABLE IF NOT EXISTS bars ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT NOT NULL, + timeframe TEXT NOT NULL, + ts REAL NOT NULL, + open REAL, high REAL, low REAL, close REAL, volume REAL, + UNIQUE(symbol, timeframe, ts) + ); + """ + ) + + +def now() -> float: + return time.time() + + +def row_to_dict(row: Optional[sqlite3.Row]) -> Optional[dict]: + if row is None: + return None + return {k: row[k] for k in row.keys()} + + +def list_accounts(conn: sqlite3.Connection) -> list[dict]: + cur = conn.execute("SELECT * FROM accounts ORDER BY id DESC") + return [row_to_dict(r) for r in cur.fetchall()] + + +def list_machines(conn: sqlite3.Connection) -> list[dict]: + cur = conn.execute("SELECT * FROM machines ORDER BY id DESC") + return [row_to_dict(r) for r in cur.fetchall()] + + +def create_activation(conn: sqlite3.Connection, account_id: int, machine_id: Optional[int]) -> str: + code = secrets.token_urlsafe(12) + conn.execute( + "INSERT INTO activations(code, account_id, machine_id, status, created_at) VALUES(?,?,?,?,?)", + (code, account_id, machine_id, "pending", now()), + ) + return code + + +def activate_device(conn: sqlite3.Connection, code: str, fingerprint: str, machine_name: str) -> dict[str, Any]: + row = conn.execute("SELECT * FROM activations WHERE code=?", (code,)).fetchone() + if row is None: + raise ValueError("激活码无效") + if row["status"] == "revoked": + raise ValueError("激活码已作废") + + token = secrets.token_urlsafe(32) + machine_id = row["machine_id"] + if machine_id: + conn.execute( + "UPDATE machines SET device_fingerprint=?, last_seen_at=?, name=COALESCE(NULLIF(name,''), ?) WHERE id=?", + (fingerprint, now(), machine_name or f"PC-{fingerprint[:6]}", machine_id), + ) + else: + cur = conn.execute( + "INSERT INTO machines(name, device_fingerprint, note, enabled, last_seen_at, created_at) VALUES(?,?,?,?,?,?)", + (machine_name or f"PC-{fingerprint[:6]}", fingerprint, "激活自动创建", 1, now(), now()), + ) + machine_id = cur.lastrowid + conn.execute("UPDATE activations SET machine_id=? WHERE id=?", (machine_id, row["id"])) + + conn.execute( + "UPDATE activations SET token=?, status='active', bound_at=? WHERE id=?", + (token, now(), row["id"]), + ) + return { + "token": token, + "account_id": row["account_id"], + "machine_id": machine_id, + "public_base": Config.PUBLIC_BASE_URL, + } + + +def auth_token(conn: sqlite3.Connection, token: str) -> Optional[dict]: + if not token: + return None + row = conn.execute( + "SELECT * FROM activations WHERE token=? AND status='active'", + (token,), + ).fetchone() + return row_to_dict(row) + + +def list_trades(conn: sqlite3.Connection, limit: int = 200) -> list[dict]: + cur = conn.execute( + """ + SELECT t.*, a.name AS account_name, m.name AS machine_name + FROM trades t + LEFT JOIN accounts a ON a.id = t.account_id + LEFT JOIN machines m ON m.id = t.machine_id + ORDER BY COALESCE(t.close_time, t.open_time, t.created_at) DESC + LIMIT ? + """, + (limit,), + ) + rows = [] + for r in cur.fetchall(): + d = row_to_dict(r) + try: + d["snapshot"] = json.loads(d.get("snapshot_json") or "{}") + except json.JSONDecodeError: + d["snapshot"] = {} + rows.append(d) + return rows + + +def get_trade(conn: sqlite3.Connection, trade_id: int) -> Optional[dict]: + r = conn.execute( + """ + SELECT t.*, a.name AS account_name, m.name AS machine_name + FROM trades t + LEFT JOIN accounts a ON a.id = t.account_id + LEFT JOIN machines m ON m.id = t.machine_id + WHERE t.id=? + """, + (trade_id,), + ).fetchone() + d = row_to_dict(r) + if d: + try: + d["snapshot"] = json.loads(d.get("snapshot_json") or "{}") + except json.JSONDecodeError: + d["snapshot"] = {} + return d + + +def upsert_bars(conn: sqlite3.Connection, symbol: str, timeframe: str, bars: list[dict]) -> int: + n = 0 + for b in bars: + conn.execute( + """ + INSERT INTO bars(symbol, timeframe, ts, open, high, low, close, volume) + VALUES(?,?,?,?,?,?,?,?) + ON CONFLICT(symbol, timeframe, ts) DO UPDATE SET + open=excluded.open, high=excluded.high, low=excluded.low, + close=excluded.close, volume=excluded.volume + """, + ( + symbol, + timeframe, + float(b["ts"]), + float(b["open"]), + float(b["high"]), + float(b["low"]), + float(b["close"]), + float(b.get("volume") or 0), + ), + ) + n += 1 + return n + + +def get_bars(conn: sqlite3.Connection, symbol: str, timeframe: str, start_ts: float, end_ts: float) -> list[dict]: + cur = conn.execute( + """ + SELECT ts, open, high, low, close, volume FROM bars + WHERE symbol=? AND timeframe=? AND ts>=? AND ts<=? + ORDER BY ts ASC + """, + (symbol, timeframe, start_ts, end_ts), + ) + return [row_to_dict(r) for r in cur.fetchall()] + + +def seed_if_empty() -> None: + with get_db() as conn: + n = conn.execute("SELECT COUNT(*) AS c FROM accounts").fetchone()["c"] + if n == 0: + conn.execute( + "INSERT INTO accounts(name, broker, login_id, account_type, note, enabled, created_at) VALUES(?,?,?,?,?,?,?)", + ("模拟盘-主", "MT5", "", "demo", "默认账户,可改标注", 1, now()), + ) + n2 = conn.execute("SELECT COUNT(*) AS c FROM machines").fetchone()["c"] + if n2 == 0: + conn.execute( + "INSERT INTO machines(name, device_fingerprint, note, enabled, created_at) VALUES(?,?,?,?,?)", + ("未绑定机器", "", "下载通用包后用激活码绑定", 1, now()), + ) diff --git a/app/factory.py b/app/factory.py new file mode 100644 index 0000000..ccccfe1 --- /dev/null +++ b/app/factory.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +from flask import Flask + +from app.config import Config +from app.db import init_db, seed_if_empty +from app.routes import register_routes + + +def create_app() -> Flask: + app = Flask(__name__, template_folder="templates", static_folder="static") + app.config.from_object(Config) + app.secret_key = Config.SECRET_KEY + + init_db() + seed_if_empty() + register_routes(app) + return app diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..b95f767 --- /dev/null +++ b/app/routes.py @@ -0,0 +1,345 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import json +import secrets +from functools import wraps +from pathlib import Path + +from flask import ( + Flask, + flash, + jsonify, + redirect, + render_template, + request, + send_from_directory, + session, + url_for, +) + +from app.config import Config, RELEASES_DIR +from app import db as database + + +def login_required(view): + @wraps(view) + def wrapped(*args, **kwargs): + if not session.get("user"): + return redirect(url_for("login", next=request.path)) + return view(*args, **kwargs) + + return wrapped + + +def register_routes(app: Flask) -> None: + @app.context_processor + def inject_globals(): + return { + "app_name": "Gold Monitor", + "public_base": Config.PUBLIC_BASE_URL, + } + + @app.get("/login") + def login(): + if session.get("user"): + return redirect(url_for("dashboard")) + return render_template("login.html") + + @app.post("/login") + 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: + session["user"] = user + return redirect(request.args.get("next") or url_for("dashboard")) + flash("用户名或密码错误", "error") + return render_template("login.html"), 401 + + @app.get("/logout") + def logout(): + session.clear() + return redirect(url_for("login")) + + @app.get("/") + @login_required + def dashboard(): + with database.get_db() as conn: + accounts = database.list_accounts(conn) + machines = database.list_machines(conn) + trades = database.list_trades(conn, 20) + return render_template( + "dashboard.html", + accounts=accounts, + machines=machines, + trades=trades, + ) + + # ---------- 账户 / 机器 ---------- + @app.get("/accounts") + @login_required + def accounts_page(): + with database.get_db() as conn: + items = database.list_accounts(conn) + return render_template("accounts.html", items=items) + + @app.post("/accounts") + @login_required + def accounts_create(): + name = (request.form.get("name") or "").strip() + if not name: + flash("名称不能为空", "error") + return redirect(url_for("accounts_page")) + with database.get_db() as conn: + conn.execute( + """INSERT INTO accounts(name, broker, login_id, account_type, note, enabled, created_at) + VALUES(?,?,?,?,?,?,?)""", + ( + name, + (request.form.get("broker") or "").strip(), + (request.form.get("login_id") or "").strip(), + (request.form.get("account_type") or "demo").strip(), + (request.form.get("note") or "").strip(), + 1, + database.now(), + ), + ) + flash("账户已添加", "ok") + return redirect(url_for("accounts_page")) + + @app.post("/accounts//note") + @login_required + def accounts_note(aid: int): + note = (request.form.get("note") or "").strip() + with database.get_db() as conn: + conn.execute("UPDATE accounts SET note=? WHERE id=?", (note, aid)) + flash("标注已保存", "ok") + return redirect(url_for("accounts_page")) + + @app.get("/machines") + @login_required + def machines_page(): + with database.get_db() as conn: + items = database.list_machines(conn) + return render_template("machines.html", items=items) + + @app.post("/machines") + @login_required + def machines_create(): + name = (request.form.get("name") or "").strip() or "新机器" + with database.get_db() as conn: + conn.execute( + """INSERT INTO machines(name, device_fingerprint, note, enabled, created_at) + VALUES(?,?,?,?,?)""", + (name, "", (request.form.get("note") or "").strip(), 1, database.now()), + ) + flash("机器已添加", "ok") + return redirect(url_for("machines_page")) + + @app.post("/machines//note") + @login_required + def machines_note(mid: int): + note = (request.form.get("note") or "").strip() + name = (request.form.get("name") or "").strip() + with database.get_db() as conn: + if name: + conn.execute("UPDATE machines SET note=?, name=? WHERE id=?", (note, name, mid)) + else: + conn.execute("UPDATE machines SET note=? WHERE id=?", (note, mid)) + flash("机器标注已保存", "ok") + return redirect(url_for("machines_page")) + + # ---------- 下载 / 激活 ---------- + @app.get("/download") + @login_required + def download_page(): + with database.get_db() as conn: + accounts = database.list_accounts(conn) + machines = database.list_machines(conn) + acts = conn.execute( + """ + SELECT act.*, a.name AS account_name, m.name AS machine_name + FROM activations act + LEFT JOIN accounts a ON a.id = act.account_id + LEFT JOIN machines m ON m.id = act.machine_id + ORDER BY act.id DESC LIMIT 50 + """ + ).fetchall() + activations = [database.row_to_dict(r) for r in acts] + exe_name = "黄金市价辅助.exe" + exe_path = RELEASES_DIR / exe_name + return render_template( + "download.html", + accounts=accounts, + machines=machines, + activations=activations, + exe_ready=exe_path.is_file(), + exe_name=exe_name, + ) + + @app.post("/download/activation") + @login_required + def download_create_activation(): + account_id = int(request.form.get("account_id") or 0) + machine_raw = request.form.get("machine_id") or "" + machine_id = int(machine_raw) if machine_raw.strip().isdigit() else None + with database.get_db() as conn: + if not conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone(): + flash("账户不存在", "error") + return redirect(url_for("download_page")) + code = database.create_activation(conn, account_id, machine_id) + flash(f"已生成激活码:{code}", "ok") + return redirect(url_for("download_page")) + + @app.get("/download/file") + @login_required + def download_file(): + name = "黄金市价辅助.exe" + path = RELEASES_DIR / name + if not path.is_file(): + # 提供占位说明文件,避免空目录无下载 + readme = RELEASES_DIR / "README_PLACE_EXE_HERE.txt" + if not readme.is_file(): + readme.write_text( + "请将打包好的通用辅助 exe 放到本目录,命名为:黄金市价辅助.exe\n" + "客户端首次启动使用网页生成的激活码绑定机器与账户。\n", + encoding="utf-8", + ) + return send_from_directory(RELEASES_DIR, readme.name, as_attachment=True) + return send_from_directory(RELEASES_DIR, name, as_attachment=True) + + @app.post("/api/v1/activate") + def api_activate(): + data = request.get_json(silent=True) or {} + code = (data.get("code") or "").strip() + fingerprint = (data.get("fingerprint") or secrets.token_hex(8)).strip() + machine_name = (data.get("machine_name") or "").strip() + if not code: + return jsonify({"ok": False, "error": "缺少激活码"}), 400 + try: + with database.get_db() as conn: + result = database.activate_device(conn, code, fingerprint, machine_name) + return jsonify({"ok": True, **result}) + except ValueError as exc: + return jsonify({"ok": False, "error": str(exc)}), 400 + + @app.get("/api/v1/ping") + def api_ping(): + token = request.headers.get("X-Device-Token") or request.args.get("token") or "" + with database.get_db() as conn: + act = database.auth_token(conn, token) + if not act: + return jsonify({"ok": False, "error": "未激活"}), 401 + if act.get("machine_id"): + conn.execute( + "UPDATE machines SET last_seen_at=? WHERE id=?", + (database.now(), act["machine_id"]), + ) + return jsonify({"ok": True, "server_time": database.now()}) + + # ---------- 成交上报 / 复盘 ---------- + @app.post("/api/v1/trades") + def api_trades_upsert(): + token = request.headers.get("X-Device-Token") or "" + data = request.get_json(silent=True) or {} + with database.get_db() as conn: + act = database.auth_token(conn, token) + if not act: + return jsonify({"ok": False, "error": "未激活"}), 401 + snapshot = data.get("snapshot") or {} + conn.execute( + """ + INSERT INTO trades( + ticket, symbol, side, volume, open_price, close_price, + open_time, close_time, profit, sl, tp, + account_id, machine_id, entry_style, review_note, snapshot_json, created_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, + ( + str(data.get("ticket") or ""), + (data.get("symbol") or "XAUUSD").upper(), + (data.get("side") or "").lower(), + float(data.get("volume") or 0), + float(data.get("open_price") or 0), + float(data.get("close_price") or 0) if data.get("close_price") is not None else None, + data.get("open_time"), + data.get("close_time"), + float(data.get("profit") or 0) if data.get("profit") is not None else None, + data.get("sl"), + data.get("tp"), + act["account_id"], + act["machine_id"], + (data.get("entry_style") or "").strip(), + (data.get("review_note") or "").strip(), + json.dumps(snapshot, ensure_ascii=False), + database.now(), + ), + ) + tid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + return jsonify({"ok": True, "id": tid}) + + @app.post("/api/v1/bars") + def api_bars_upload(): + token = request.headers.get("X-Device-Token") or "" + data = request.get_json(silent=True) or {} + with database.get_db() as conn: + if not database.auth_token(conn, token): + return jsonify({"ok": False, "error": "未激活"}), 401 + symbol = (data.get("symbol") or "XAUUSD").upper() + timeframe = (data.get("timeframe") or "M1").upper() + bars = data.get("bars") or [] + n = database.upsert_bars(conn, symbol, timeframe, bars) + return jsonify({"ok": True, "count": n}) + + @app.get("/api/v1/bars") + @login_required + def api_bars_get(): + symbol = (request.args.get("symbol") or "XAUUSD").upper() + timeframe = (request.args.get("timeframe") or "M15").upper() + start_ts = float(request.args.get("start") or 0) + end_ts = float(request.args.get("end") or database.now()) + with database.get_db() as conn: + bars = database.get_bars(conn, symbol, timeframe, start_ts, end_ts) + return jsonify({"ok": True, "bars": bars}) + + @app.get("/records") + @login_required + def records_page(): + with database.get_db() as conn: + trades = database.list_trades(conn, 300) + return render_template("records.html", trades=trades) + + @app.get("/records/") + @login_required + def record_detail(tid: int): + with database.get_db() as conn: + trade = database.get_trade(conn, tid) + if not trade: + flash("记录不存在", "error") + return redirect(url_for("records_page")) + # 四周期:以开仓时间为中心取前后窗口 + center = trade.get("open_time") or trade.get("created_at") or database.now() + window = 3600 * 6 + charts = {} + for tf in ("M1", "M5", "M15", "H1"): + charts[tf] = database.get_bars( + conn, trade["symbol"], tf, float(center) - window, float(center) + window + ) + return render_template("record_detail.html", trade=trade, charts=charts) + + @app.post("/records//annotate") + @login_required + def record_annotate(tid: int): + entry_style = (request.form.get("entry_style") or "").strip() + note = (request.form.get("review_note") or "").strip() + with database.get_db() as conn: + conn.execute( + "UPDATE trades SET entry_style=?, review_note=? WHERE id=?", + (entry_style, note, tid), + ) + flash("复盘标注已保存", "ok") + return redirect(url_for("record_detail", tid=tid)) + + @app.get("/health") + def health(): + return jsonify({"ok": True, "service": "gold_monitor", "port": Config.PORT}) diff --git a/app/static/css/app.css b/app/static/css/app.css new file mode 100644 index 0000000..e38c6ed --- /dev/null +++ b/app/static/css/app.css @@ -0,0 +1,142 @@ +:root { + --bg: #0a0a10; + --panel: #12121a; + --panel2: #1a1a29; + --border: #242435; + --text: #f0f0f5; + --muted: #9a9ab0; + --accent: #4cc2ff; + --accent2: #7b42ff; + --green: #0ecb81; + --red: #f6465d; + --warn: #e6a23c; +} + +* { box-sizing: border-box; } +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei UI", sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; +} +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +.topbar { + display: flex; + align-items: center; + gap: 18px; + padding: 12px 20px; + border-bottom: 1px solid var(--border); + background: #0d0d16; + position: sticky; + top: 0; + z-index: 20; +} +.brand { + font-weight: 700; + background: linear-gradient(90deg, var(--accent), var(--accent2)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + margin-right: 8px; +} +.nav a { + color: var(--muted); + margin-right: 14px; + font-size: 14px; +} +.nav a.active, .nav a:hover { color: var(--text); text-decoration: none; } +.topbar .right { margin-left: auto; color: var(--muted); font-size: 13px; } + +.wrap { max-width: 1200px; margin: 0 auto; padding: 20px; } +.grid { display: grid; gap: 14px; } +.grid.cols-3 { grid-template-columns: repeat(3, 1fr); } +.grid.cols-2 { grid-template-columns: repeat(2, 1fr); } +@media (max-width: 900px) { + .grid.cols-3, .grid.cols-2 { grid-template-columns: 1fr; } +} + +.card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 14px; + padding: 16px 18px; + box-shadow: 0 8px 24px rgba(0,0,0,.25); +} +.card h2, .card h3 { margin: 0 0 12px; font-size: 16px; } +.muted { color: var(--muted); font-size: 13px; } +.stat { font-size: 28px; font-weight: 700; } + +.btn { + display: inline-block; + border: 0; + border-radius: 10px; + padding: 10px 14px; + background: linear-gradient(90deg, #3aa8e0, #6a3ad8); + color: #fff; + font-weight: 600; + cursor: pointer; +} +.btn.secondary { background: #2a2a3d; } +.btn.danger { background: #8b2e3c; } +.btn:hover { filter: brightness(1.08); } + +input, select, textarea { + width: 100%; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid #2e2e45; + background: var(--panel2); + color: var(--text); + outline: none; +} +input:focus, select:focus, textarea:focus { border-color: var(--accent); } +label { display: block; margin: 0 0 6px; color: #a9a9ff; font-size: 13px; } +.form-row { margin-bottom: 12px; } + +table { width: 100%; border-collapse: collapse; font-size: 13px; } +th, td { padding: 10px 8px; border-bottom: 1px solid var(--border); text-align: left; } +th { color: var(--muted); font-weight: 600; } +.tag { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + background: #222235; +} +.tag.green { color: var(--green); background: rgba(14,203,129,.12); } +.tag.red { color: var(--red); background: rgba(246,70,93,.12); } +.tag.gray { color: var(--muted); } + +.flash { + padding: 10px 12px; + border-radius: 10px; + margin-bottom: 12px; + font-size: 14px; +} +.flash.ok { background: rgba(14,203,129,.12); color: var(--green); } +.flash.error { background: rgba(246,70,93,.12); color: var(--red); } + +.chart-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} +.chart-box { + background: #0e0e18; + border: 1px solid var(--border); + border-radius: 12px; + padding: 10px; + min-height: 220px; +} +.chart-box h4 { margin: 0 0 8px; font-size: 13px; color: var(--muted); } +.chart-canvas { width: 100%; height: 180px; display: block; } +.empty-chart { + height: 180px; + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); + font-size: 13px; +} diff --git a/app/static/js/charts.js b/app/static/js/charts.js new file mode 100644 index 0000000..7a70d93 --- /dev/null +++ b/app/static/js/charts.js @@ -0,0 +1,58 @@ +/** 轻量蜡烛图(无外部依赖),用于复盘四周期预览 */ +(function () { + function drawCandleChart(canvas, bars) { + if (!canvas || !bars || !bars.length) return; + const dpr = window.devicePixelRatio || 1; + const cssW = canvas.clientWidth || 400; + const cssH = canvas.clientHeight || 180; + canvas.width = cssW * dpr; + canvas.height = cssH * dpr; + const ctx = canvas.getContext("2d"); + ctx.scale(dpr, dpr); + + const pad = 8; + let min = Infinity, max = -Infinity; + bars.forEach((b) => { + min = Math.min(min, b.low); + max = Math.max(max, b.high); + }); + if (!(max > min)) { + max = min + 1; + } + const w = cssW - pad * 2; + const h = cssH - pad * 2; + const n = bars.length; + const slot = w / n; + + ctx.clearRect(0, 0, cssW, cssH); + ctx.fillStyle = "#0e0e18"; + ctx.fillRect(0, 0, cssW, cssH); + + const yOf = (p) => pad + ((max - p) / (max - min)) * h; + + bars.forEach((b, i) => { + const x = pad + i * slot + slot * 0.5; + const up = b.close >= b.open; + ctx.strokeStyle = up ? "#0ecb81" : "#f6465d"; + ctx.fillStyle = ctx.strokeStyle; + ctx.beginPath(); + ctx.moveTo(x, yOf(b.high)); + ctx.lineTo(x, yOf(b.low)); + ctx.stroke(); + const y1 = yOf(b.open); + const y2 = yOf(b.close); + const top = Math.min(y1, y2); + const bh = Math.max(1, Math.abs(y2 - y1)); + ctx.fillRect(x - Math.max(1, slot * 0.3), top, Math.max(2, slot * 0.6), bh); + }); + } + + document.querySelectorAll("canvas.chart-canvas").forEach((el) => { + try { + const bars = JSON.parse(el.dataset.bars || "[]"); + drawCandleChart(el, bars); + } catch (e) { + console.warn(e); + } + }); +})(); diff --git a/app/templates/accounts.html b/app/templates/accounts.html new file mode 100644 index 0000000..99f0060 --- /dev/null +++ b/app/templates/accounts.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block title %}账户 · {{ app_name }}{% endblock %} +{% block content %} +

账户标注

+
+
+

新增账户

+
+
+
+
+
+ + +
+
+ +
+
+
+

已有账户

+ + + + {% for a in items %} + + + + + + + {% endfor %} + +
名称类型标注
{{ a.name }}
{{ a.broker }} {{ a.login_id }}
{{ a.account_type }} +
+ + +
+
#{{ a.id }}
+
+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..30d044d --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,33 @@ + + + + + + {% block title %}{{ app_name }}{% endblock %} + + + + {% if session.get('user') %} +
+
{{ app_name }}
+ +
{{ session.get('user') }} · 退出
+
+ {% endif %} +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for cat, msg in messages %} +
{{ msg }}
+ {% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+ {% block scripts %}{% endblock %} + + diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..70eff90 --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}总览 · {{ app_name }}{% endblock %} +{% block content %} +

总览

+
+
账户
{{ accounts|length }}
+
机器
{{ machines|length }}
+
最近成交(展示)
{{ trades|length }}
+
+ +
+
+

快速入口

+

登录后下载通用辅助包,用激活码绑定机器与账户;成交自动上报,网页复盘四周期 K 线。

+

+ 下载中心 + 交易复盘 +

+
+
+

服务信息

+

对外地址:{{ public_base }}

+

端口:5800 · 健康检查:/health

+
+
+ +
+

最近记录

+ + + + + + {% for t in trades %} + + + + + + + + + + {% else %} + + {% endfor %} + +
时间品种方向盈亏账户入场形式
{{ t.close_time or t.open_time or t.created_at }}{{ t.symbol }}{{ t.side }}{{ t.profit if t.profit is not none else '—' }}{{ t.account_name or '—' }}{{ t.entry_style or '—' }}复盘
暂无成交。激活客户端并上报后显示。
+
+{% endblock %} diff --git a/app/templates/download.html b/app/templates/download.html new file mode 100644 index 0000000..9e32207 --- /dev/null +++ b/app/templates/download.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% block title %}下载中心 · {{ app_name }}{% endblock %} +{% block content %} +

下载中心

+
+
+

通用辅助包

+

所有机器下载同一 exe。首次启动输入激活码,绑定账户与机器标识。

+ {% if exe_ready %} +

已上传 {{ exe_name }}

+ 下载 exe + {% else %} +

尚未放置 exe

+

请把打包文件放到服务器 releases/黄金市价辅助.exe。当前可下载说明文件。

+ 下载说明 + {% endif %} +
+
+

生成激活码

+
+
+ + +
+
+ + +
+ +
+
+
+ +
+

激活码列表

+ + + + {% for a in activations %} + + + + + + + + {% else %} + + {% endfor %} + +
激活码账户机器状态创建时间
{{ a.code }}{{ a.account_name or a.account_id }}{{ a.machine_name or a.machine_id or '—' }}{{ a.status }}{{ a.created_at }}
暂无激活码
+
+{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..aa29f1c --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,41 @@ + + + + + + 登录 · {{ app_name }} + + + + + + + diff --git a/app/templates/machines.html b/app/templates/machines.html new file mode 100644 index 0000000..39bcd16 --- /dev/null +++ b/app/templates/machines.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}机器 · {{ app_name }}{% endblock %} +{% block content %} +

机器标注

+
+
+

预登记机器

+

也可不预登记:激活时自动创建机器并绑定指纹。

+
+
+
+ +
+
+
+

已有机器

+ + + + {% for m in items %} + + + + + + {% endfor %} + +
名称 / 指纹最近在线标注
+
+ +
{{ m.device_fingerprint or '未绑定' }}
+ + +
+
{{ m.last_seen_at or '—' }}#{{ m.id }}
+
+
+{% endblock %} diff --git a/app/templates/record_detail.html b/app/templates/record_detail.html new file mode 100644 index 0000000..e2f075f --- /dev/null +++ b/app/templates/record_detail.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% block title %}复盘 #{{ trade.id }} · {{ app_name }}{% endblock %} +{% block content %} +

← 返回列表

+

复盘 #{{ trade.id }} · {{ trade.symbol }} · {{ trade.side }}

+ +
+
+

成交信息

+ + + + + + + + + +
账户{{ trade.account_name or '—' }}
机器{{ trade.machine_name or '—' }}
手数{{ trade.volume }}
开仓{{ trade.open_price }} / {{ trade.open_time }}
平仓{{ trade.close_price }} / {{ trade.close_time }}
盈亏{{ trade.profit }}
SL / TP{{ trade.sl }} / {{ trade.tp }}
单号{{ trade.ticket or '—' }}
+
+
+

入场形式标注

+
+
+ + +
+
+ + +
+ +
+
+
+ +
+

四周期 K 线(M1 / M5 / M15 / H1)

+

数据来自服务器缓存;由已激活客户端通过 API 上报 bars。无数据时显示占位。

+
+ {% for tf in ['M1','M5','M15','H1'] %} +
+

{{ tf }} · {{ charts[tf]|length }} 根

+ {% if charts[tf] %} + + {% else %} +
暂无 {{ tf }} K 线缓存
+ {% endif %} +
+ {% endfor %} +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/records.html b/app/templates/records.html new file mode 100644 index 0000000..805d228 --- /dev/null +++ b/app/templates/records.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block title %}交易复盘 · {{ app_name }}{% endblock %} +{% block content %} +

交易记录与复盘

+

参考 crypto_monitor 复盘流程:列表筛选 → 点开详情 → 四周期 K 线 + 入场形式标注。

+
+ + + + + + + + + {% for t in trades %} + + + + + + + + + + + + + + {% else %} + + {% endfor %} + +
ID品种方向手数开仓价平仓价盈亏账户机器入场形式
{{ t.id }}{{ t.symbol }}{{ t.side }}{{ t.volume }}{{ t.open_price }}{{ t.close_price if t.close_price is not none else '—' }} + {{ t.profit if t.profit is not none else '—' }} + {{ t.account_name or '—' }}{{ t.machine_name or '—' }}{{ t.entry_style or '—' }}打开复盘
暂无数据。客户端上报成交后出现在此。
+
+{% endblock %} diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..394b3ea --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,36 @@ +# 环境一键部署(Ubuntu / root /opt) + +在 **`/opt/gold_monitor`** 下以 **root** 部署 Flask Web+API,使用 **PM2** 常驻,端口 **5800**。 + +完整说明见 **[docs/ubuntu-server.md](../docs/ubuntu-server.md)** · **[docs/deploy.md](../docs/deploy.md)**。 + +--- + +## 一键部署管理器(推荐) + +新服务器无需先 clone: + +```bash +curl -fsSL https://git.bz121.com/dekun/gold_monitor/raw/branch/main/deploy/manage.sh | bash +``` + +已安装机器: + +```bash +bash /opt/gold_monitor/deploy/manage.sh +``` + +### 菜单 + +| 选项 | 功能 | +|------|------| +| **1) 一键部署** | 依赖 + venv + PM2 + 验收 `/health` | +| **2) 一键卸载** | 备份 `.env`/`data` → 停 PM2 → 可选删目录 | +| **3) 更新** | git pull / 依赖更新 + restart | +| **0) 退出** | | + +### 部署完成后 + +- 地址:`http://<服务器IP>:5800` +- 登录:`admin` / `admin123`(请改 `.env`) +- 将通用 exe 放到:`/opt/gold_monitor/releases/黄金市价辅助.exe` diff --git a/deploy/ecosystem.config.cjs b/deploy/ecosystem.config.cjs new file mode 100644 index 0000000..34ae51d --- /dev/null +++ b/deploy/ecosystem.config.cjs @@ -0,0 +1,24 @@ +/** + * PM2 进程配置 — gold_monitor + * 端口默认 5800 + */ +const path = require("path"); +const root = path.resolve(__dirname, ".."); + +module.exports = { + apps: [ + { + name: "gold_monitor", + cwd: root, + script: path.join(root, ".venv/bin/gunicorn"), + args: "wsgi:app --bind 0.0.0.0:5800 --workers 2 --threads 4 --timeout 120", + interpreter: "none", + autorestart: true, + max_restarts: 20, + min_uptime: "5s", + env: { + PYTHONUNBUFFERED: "1", + }, + }, + ], +}; diff --git a/deploy/lib/common.sh b/deploy/lib/common.sh new file mode 100644 index 0000000..c8f746e --- /dev/null +++ b/deploy/lib/common.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# shellcheck shell=bash + +detect_server_ip() { + local ip + ip="$(hostname -I 2>/dev/null | awk '{print $1}')" + if [[ -z "${ip}" ]]; then + ip="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}')" + fi + echo "${ip:-127.0.0.1}" +} + +need_root() { + if [[ "$(id -u)" -ne 0 ]]; then + echo "错误: 请使用 root 执行" >&2 + exit 1 + fi +} + +ensure_node_pm2() { + if ! command -v node >/dev/null 2>&1; then + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - + apt-get install -y nodejs + fi + if ! command -v pm2 >/dev/null 2>&1; then + npm install -g pm2 + fi +} + +verify_http() { + local url="$1" + curl -fsS --max-time 5 "${url}" >/dev/null 2>&1 +} diff --git a/deploy/lib/install.sh b/deploy/lib/install.sh new file mode 100644 index 0000000..85cf370 --- /dev/null +++ b/deploy/lib/install.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +set -euo pipefail +# shellcheck source=common.sh +source "${LIB_DIR}/common.sh" +need_root + +echo "[1/4] 安装系统依赖 / venv ..." +bash "${REPO_ROOT}/deploy/setup_env.sh" + +echo "[2/4] 安装 Node / PM2 ..." +ensure_node_pm2 + +echo "[3/4] 启动 PM2 ..." +cd "${REPO_ROOT}" +pm2 delete gold_monitor >/dev/null 2>&1 || true +pm2 start "${REPO_ROOT}/deploy/ecosystem.config.cjs" +pm2 save +pm2 startup systemd -u root --hp /root >/dev/null 2>&1 || true + +echo "[4/4] 验收 ..." +sleep 2 +IP="$(detect_server_ip)" +if verify_http "http://127.0.0.1:5800/health"; then + echo "✓ 健康检查通过" +else + echo "✗ 健康检查失败,请执行: pm2 logs gold_monitor" >&2 + exit 1 +fi + +echo "" +echo "部署完成" +echo " 地址: http://${IP}:5800" +echo " 登录: admin / admin123 (请立即修改 .env 中 ADMIN_PASSWORD)" +echo " 管理: bash ${REPO_ROOT}/deploy/manage.sh" +echo "" diff --git a/deploy/lib/uninstall.sh b/deploy/lib/uninstall.sh new file mode 100644 index 0000000..2e7472e --- /dev/null +++ b/deploy/lib/uninstall.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +set -euo pipefail +# shellcheck source=common.sh +source "${LIB_DIR}/common.sh" +need_root + +TS="$(date +%Y%m%d_%H%M%S)" +BACKUP="/opt/gold_monitor_backup_${TS}" +mkdir -p "${BACKUP}" +[[ -f "${REPO_ROOT}/.env" ]] && cp "${REPO_ROOT}/.env" "${BACKUP}/" +[[ -d "${REPO_ROOT}/data" ]] && cp -a "${REPO_ROOT}/data" "${BACKUP}/" || true + +pm2 delete gold_monitor >/dev/null 2>&1 || true +pm2 save >/dev/null 2>&1 || true + +echo "已备份到 ${BACKUP}" +read -r -p "是否删除目录 ${REPO_ROOT}? [y/N] " ans +if [[ "${ans}" == "y" || "${ans}" == "Y" ]]; then + rm -rf "${REPO_ROOT}" + echo "已删除 ${REPO_ROOT}" +else + echo "保留目录,仅停止 PM2 进程" +fi diff --git a/deploy/lib/update.sh b/deploy/lib/update.sh new file mode 100644 index 0000000..7f96a6e --- /dev/null +++ b/deploy/lib/update.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +set -euo pipefail +# shellcheck source=common.sh +source "${LIB_DIR}/common.sh" +need_root + +echo "更新子菜单" +echo " 1) 快速更新 (git pull + pm2 restart)" +echo " 2) 依赖更新 (pull + pip + restart)" +echo " 0) 返回" +read -r -p "选择: " choice +case "${choice}" in + 1) + git -C "${REPO_ROOT}" pull --ff-only + pm2 restart gold_monitor + ;; + 2) + git -C "${REPO_ROOT}" pull --ff-only + bash "${REPO_ROOT}/deploy/setup_env.sh" + pm2 restart gold_monitor + ;; + *) + echo "取消" + ;; +esac diff --git a/deploy/manage.sh b/deploy/manage.sh new file mode 100644 index 0000000..1b12b57 --- /dev/null +++ b/deploy/manage.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# gold_monitor 部署管理器 — 一键部署 / 卸载 / 更新 +# +# 新服务器(免克隆): +# curl -fsSL https://git.bz121.com/dekun/gold_monitor/raw/branch/main/deploy/manage.sh | bash +# +# 已安装: +# bash /opt/gold_monitor/deploy/manage.sh +# +set -e +if [ -n "${BASH_VERSION:-}" ]; then + set -o pipefail +fi + +INSTALL_ROOT="${INSTALL_ROOT:-/opt/gold_monitor}" +GIT_URL="${GIT_URL:-https://git.bz121.com/dekun/gold_monitor.git}" +GIT_BRANCH="${GIT_BRANCH:-main}" + +_script_src="${BASH_SOURCE[0]:-}" +if [[ -n "${_script_src}" && -f "${_script_src}" ]]; then + DEPLOY_DIR="$(cd "$(dirname "${_script_src}")" && pwd)" + REPO_ROOT="$(cd "${DEPLOY_DIR}/.." && pwd)" + LIB_DIR="${DEPLOY_DIR}/lib" +else + DEPLOY_DIR="" + REPO_ROOT="" + LIB_DIR="" +fi +unset _script_src + +set -u + +repo_ready() { + [[ -f "${1}/deploy/setup_env.sh" && -f "${1}/deploy/manage.sh" ]] +} + +sync_repo_if_present() { + local root="$1" + if [[ -d "${root}/.git" ]] && command -v git >/dev/null 2>&1; then + git -C "${root}" pull -q --ff-only 2>/dev/null || true + fi +} + +bootstrap_repo() { + if repo_ready "${INSTALL_ROOT}"; then + REPO_ROOT="${INSTALL_ROOT}" + DEPLOY_DIR="${REPO_ROOT}/deploy" + LIB_DIR="${DEPLOY_DIR}/lib" + sync_repo_if_present "${REPO_ROOT}" + return 0 + fi + if [[ -n "${REPO_ROOT}" ]] && repo_ready "${REPO_ROOT}"; then + DEPLOY_DIR="${REPO_ROOT}/deploy" + LIB_DIR="${DEPLOY_DIR}/lib" + return 0 + fi + + echo "gold_monitor 部署管理器 — 首次自举" + echo "将克隆到: ${INSTALL_ROOT}" + if [[ "$(id -u)" -ne 0 ]]; then + echo "错误: 请使用 root 执行" >&2 + exit 1 + fi + if ! command -v git >/dev/null 2>&1; then + if command -v apt-get >/dev/null 2>&1; then + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y git ca-certificates curl + else + echo "错误: 未找到 git" >&2 + exit 1 + fi + fi + if [[ -d "${INSTALL_ROOT}" ]]; then + echo "错误: ${INSTALL_ROOT} 已存在但不是有效仓库" >&2 + exit 1 + fi + mkdir -p "$(dirname "${INSTALL_ROOT}")" + git clone -b "${GIT_BRANCH}" "${GIT_URL}" "${INSTALL_ROOT}" + exec bash "${INSTALL_ROOT}/deploy/manage.sh" "$@" :5800`) +3. 成交与四周期 bars 上报 + +## 激活 API 示例 + +```http +POST /api/v1/activate +Content-Type: application/json + +{ + "code": "激活码", + "fingerprint": "机器唯一指纹", + "machine_name": "书房PC" +} +``` + +成功后请求头携带:`X-Device-Token: ` diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..fe867b2 --- /dev/null +++ b/docs/deploy.md @@ -0,0 +1,85 @@ +# 部署文档(gold_monitor) + +## 1. 目标 + +在局域网 Ubuntu 上部署 **gold_monitor** 中控: + +- Web 登录、下载中心、账户/机器标注、交易复盘 +- API:激活、成交上报、K 线缓存 +- **PM2** 常驻,端口 **5800** + +## 2. 一键交互部署(推荐) + +新机 root: + +```bash +curl -fsSL https://git.bz121.com/dekun/gold_monitor/raw/branch/main/deploy/manage.sh | bash +``` + +菜单: + +1. 一键部署 +2. 一键卸载 +3. 更新 +0. 退出 + +已安装后再次进入: + +```bash +bash /opt/gold_monitor/deploy/manage.sh +``` + +## 3. 部署后检查 + +```bash +pm2 status +pm2 logs gold_monitor --lines 100 +curl -s http://127.0.0.1:5800/health +``` + +浏览器:`http://<服务器IP>:5800` +默认账号:`admin` / `admin123` → 立即修改 `/opt/gold_monitor/.env` 中 `ADMIN_PASSWORD` 与 `FLASK_SECRET_KEY`,然后: + +```bash +pm2 restart gold_monitor +``` + +## 4. 放置通用 exe + +```bash +# 将打包好的通用客户端拷到 +/opt/gold_monitor/releases/黄金市价辅助.exe +``` + +网页「下载中心」即可提供下载。未放置时,下载接口会给出说明文件。 + +## 5. 防火墙(可选) + +```bash +ufw allow 5800/tcp +ufw reload +``` + +## 6. 更新 + +```bash +bash /opt/gold_monitor/deploy/manage.sh +# 选 3) 更新 +``` + +或: + +```bash +cd /opt/gold_monitor && git pull --ff-only +pm2 restart gold_monitor +``` + +## 7. 备份 + +重要路径: + +- `/opt/gold_monitor/.env` +- `/opt/gold_monitor/data/` +- `/opt/gold_monitor/releases/` + +卸载脚本会尝试备份 `.env` 与 `data` 到 `/opt/gold_monitor_backup_<时间戳>/`。 diff --git a/docs/technical.md b/docs/technical.md new file mode 100644 index 0000000..306f18d --- /dev/null +++ b/docs/technical.md @@ -0,0 +1,123 @@ +# 技术文档(gold_monitor) + +## 1. 架构 + +``` +Browser (复盘/管理) ──HTTP:5800──► Flask(gunicorn) + SQLite + ▲ +Windows exe (通用包+激活) ──API───────────┘ + │ + └── MetaTrader5 (本机) +``` + +- **不直连数据库**:多机只走 HTTP API。 +- **通用包 + 首次激活**:下载同一 exe;激活后获得 `token` 绑定 `account_id` / `machine_id`。 + +## 2. 技术栈 + +| 层 | 选型 | +|----|------| +| Web/API | Flask 3 + Jinja2 | +| 进程 | gunicorn + PM2 | +| 存储 | SQLite(`data/gold_monitor.db`) | +| 前端复盘图 | 自研轻量 canvas 蜡烛图(无 CDN 依赖) | +| 客户端 | Python/Tk + MetaTrader5(见 `desktop/`,逻辑迁移自既有方案) | + +## 3. 主要数据表 + +| 表 | 用途 | +|----|------| +| `accounts` | 交易账户与标注 | +| `machines` | 机器、指纹、在线时间 | +| `activations` | 激活码、token、状态 | +| `trades` | 成交与复盘字段(entry_style / review_note / snapshot_json) | +| `bars` | K 线缓存(symbol+timeframe+ts 唯一) | + +## 4. HTTP API + +### 健康检查 + +`GET /health` → `{ ok, service, port }` + +### 激活 + +`POST /api/v1/activate` + +```json +{ + "code": "xxxx", + "fingerprint": "stable-machine-id", + "machine_name": "书房PC" +} +``` + +响应:`{ ok, token, account_id, machine_id, public_base }` + +### 心跳 + +`GET /api/v1/ping` +Header:`X-Device-Token: ` + +### 上报成交 + +`POST /api/v1/trades` +Header:`X-Device-Token` + +```json +{ + "ticket": "2700219994", + "symbol": "XAUUSDm", + "side": "buy", + "volume": 0.1, + "open_price": 4175.02, + "close_price": 4178.10, + "open_time": 1729000000, + "close_time": 1729001000, + "profit": 12.5, + "sl": 4170.0, + "tp": 4190.0, + "entry_style": "", + "snapshot": { "m15_dir": 1, "m1_engulf": true } +} +``` + +### 上报 / 查询 K 线 + +`POST /api/v1/bars`(需 token) + +```json +{ + "symbol": "XAUUSDm", + "timeframe": "M15", + "bars": [ + { "ts": 1729000000, "open": 1, "high": 2, "low": 0.5, "close": 1.5, "volume": 0 } + ] +} +``` + +`GET /api/v1/bars?symbol=&timeframe=&start=&end=`(需网页登录 Session) + +## 5. 安全建议 + +- 修改默认管理员密码与 `FLASK_SECRET_KEY` +- 仅局域网开放 5800;如需外网务必加 HTTPS 反代与强密码 +- 激活码一次性绑定后状态为 `active`;可在后续版本增加作废接口 +- token 等同设备凭证,泄露需重新签发 + +## 6. 目录与入口 + +| 路径 | 说明 | +|------|------| +| `wsgi.py` | 应用入口 | +| `app/factory.py` | create_app | +| `app/routes.py` | 页面与 API | +| `app/db.py` | SQLite 访问 | +| `deploy/ecosystem.config.cjs` | PM2 | +| `deploy/manage.sh` | 交互部署 | + +## 7. 后续扩展(规划) + +- 客户端完整激活窗与成交/Bars 自动上报(对接 `desktop/`) +- 复盘筛选、统计面板、AI 点评(可对齐 crypto_monitor) +- PostgreSQL 替换 SQLite(多写高并发时) +- Nginx 反代与 HTTPS diff --git a/docs/ubuntu-server.md b/docs/ubuntu-server.md new file mode 100644 index 0000000..ad64f83 --- /dev/null +++ b/docs/ubuntu-server.md @@ -0,0 +1,60 @@ +# Ubuntu 服务器环境说明 + +## 系统要求 + +| 项 | 要求 | +|----|------| +| OS | Ubuntu **22.04 / 24.04** LTS | +| 用户 | **root**(与 crypto_monitor 部署习惯一致) | +| 内存 | 建议 ≥ 1GB | +| 磁盘 | 建议 ≥ 10GB(含 K 线缓存增长) | +| 网络 | 局域网内 Windows 交易机可访问 5800 | + +## 安装路径与进程 + +| 项 | 值 | +|----|-----| +| 代码 | `/opt/gold_monitor` | +| 虚拟环境 | `/opt/gold_monitor/.venv` | +| 数据 | `/opt/gold_monitor/data/gold_monitor.db` | +| 发布包 | `/opt/gold_monitor/releases/` | +| 进程名 | PM2 `gold_monitor` | +| 绑定 | `0.0.0.0:5800` | + +## 依赖 + +一键脚本会安装: + +- `git` `curl` `ca-certificates` +- `python3` `python3-venv` `python3-pip` +- Node.js 20.x + 全局 `pm2` +- Python 包:见仓库根 `requirements.txt`(Flask、gunicorn、dotenv) + +## PM2 + +```bash +pm2 status +pm2 logs gold_monitor +pm2 restart gold_monitor +pm2 save +``` + +开机自启由 `deploy/lib/install.sh` 尝试执行 `pm2 startup`。 + +## 环境变量 + +文件:`/opt/gold_monitor/.env`(由 `.env.example` 生成) + +| 变量 | 说明 | +|------|------| +| `FLASK_SECRET_KEY` | Session 密钥 | +| `ADMIN_USER` / `ADMIN_PASSWORD` | 网页登录 | +| `HOST` / `PORT` | 默认 `0.0.0.0` / `5800` | +| `PUBLIC_BASE_URL` | 对外访问根 URL(激活展示用) | +| `DATABASE_URL` | 默认 SQLite 文件路径 | + +## 与交易机关系 + +- Ubuntu **不替代** Windows 上的 MT5。 +- 交易机运行通用 exe → 连本机 MT5 → API 上报到 Ubuntu。 +- 复盘浏览器访问 Ubuntu:5800 即可,不必在交易机上开网页服务。 diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..a66e274 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,58 @@ +# 使用文档(gold_monitor) + +## 1. 登录网页 + +浏览器打开:`http://<服务器IP>:5800` +默认:`admin` / `admin123`(部署后请修改)。 + +顶栏: + +- **总览**:账户/机器数量、最近成交 +- **交易复盘**:列表与详情 +- **下载中心**:通用 exe + 激活码 +- **账户 / 机器**:标注管理 + +## 2. 标注账户与机器 + +### 账户 + +「账户」页新增:名称、券商、登录号、类型(模拟/美分/实盘)、备注。 +用于激活码绑定与复盘归属。 + +### 机器 + +可预登记机器名称与备注;也可不登记,**激活时自动创建**并写入设备指纹。 + +## 3. 下载通用包并首次激活 + +1. 打开 **下载中心** +2. 选择要绑定的 **账户**(可选预绑定机器)→ **生成激活码** +3. 下载 `黄金市价辅助.exe`(需管理员已放入 `releases/`) +4. 在 Windows 上启动 exe: + - 填写服务器地址,例如 `http://192.168.1.10:5800` + - 输入激活码 + - 成功后本地保存 token(后续请求带 `X-Device-Token`) + +同一通用包可装多台机器;**每台使用各自的激活码**。 + +## 4. 日常交易 + +1. 先登录本机 MT5 +2. 打开已激活的辅助 exe 下单 / 保本 / 平仓 +3. 客户端在成交或平仓时自动上报(接口见技术文档) + +## 5. 网页复盘 + +1. 打开 **交易复盘** +2. 点某笔 **打开复盘** +3. 查看成交信息与 **M1 / M5 / M15 / H1** 四图 +4. 选择 **入场形式**(如「M15关键位+M1强吞没」)并填写备注 → 保存 + +> K 线依赖客户端上报缓存。若四图为空,请确认 exe 已上报 bars,或稍后在客户端补传。 + +## 6. 推荐练手流程 + +1. 美分/模拟账户在「账户」中建好并标注 +2. 每台电脑生成激活码并激活 +3. 按纪律交易,自动上报 +4. 每日网页复盘:标注亏单共性(位不对 / 形不对 / 违纪) diff --git a/releases/README_PLACE_EXE_HERE.txt b/releases/README_PLACE_EXE_HERE.txt new file mode 100644 index 0000000..0f5796f --- /dev/null +++ b/releases/README_PLACE_EXE_HERE.txt @@ -0,0 +1,5 @@ +请将打包好的通用辅助 exe 放到本目录,命名为: + + 黄金市价辅助.exe + +客户端首次启动使用网页「下载中心」生成的激活码,绑定机器与账户。 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..214bcbf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Flask>=3.0,<4 +Werkzeug>=3.0,<4 +gunicorn>=22.0,<24 +python-dotenv>=1.0,<2 diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..a7cfc97 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +"""Gunicorn / 开发启动入口。""" +from app import create_app +from app.config import Config + +app = create_app() + +if __name__ == "__main__": + app.run(host=Config.HOST, port=Config.PORT, debug=True)