feat: scaffold gold_monitor web hub, API, PM2 deploy and docs

This commit is contained in:
dekun
2026-10-17 17:43:17 +08:00
commit fb35968a1f
35 changed files with 2080 additions and 0 deletions
+9
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
* text=auto
*.sh text eol=lf
*.py text eol=lf
*.md text eol=lf
*.cjs text eol=lf
+21
View File
@@ -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/
+76
View File
@@ -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) 。
+4
View File
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from app.factory import create_app
__all__ = ["create_app"]
+35
View File
@@ -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"
+275
View File
@@ -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()),
)
+19
View File
@@ -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
+345
View File
@@ -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/<int:aid>/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/<int:mid>/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/<int:tid>")
@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/<int:tid>/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})
+142
View File
@@ -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;
}
+58
View File
@@ -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);
}
});
})();
+46
View File
@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}账户 · {{ app_name }}{% endblock %}
{% block content %}
<h2 style="margin:0 0 14px">账户标注</h2>
<div class="grid cols-2">
<div class="card">
<h3>新增账户</h3>
<form method="post" action="{{ url_for('accounts_create') }}">
<div class="form-row"><label>名称</label><input name="name" required placeholder="如:模拟盘-A"></div>
<div class="form-row"><label>券商</label><input name="broker" placeholder="MT5 券商名"></div>
<div class="form-row"><label>登录号</label><input name="login_id" placeholder="可选"></div>
<div class="form-row">
<label>类型</label>
<select name="account_type">
<option value="demo">模拟</option>
<option value="cent">美分</option>
<option value="real">实盘</option>
</select>
</div>
<div class="form-row"><label>标注</label><textarea name="note" rows="2" placeholder="用途、纪律备注"></textarea></div>
<button class="btn" type="submit">添加</button>
</form>
</div>
<div class="card">
<h3>已有账户</h3>
<table>
<thead><tr><th>名称</th><th>类型</th><th>标注</th><th></th></tr></thead>
<tbody>
{% for a in items %}
<tr>
<td>{{ a.name }}<div class="muted">{{ a.broker }} {{ a.login_id }}</div></td>
<td><span class="tag gray">{{ a.account_type }}</span></td>
<td style="min-width:180px">
<form method="post" action="{{ url_for('accounts_note', aid=a.id) }}">
<input name="note" value="{{ a.note or '' }}" placeholder="标注">
<button class="btn secondary" style="margin-top:6px" type="submit">保存</button>
</form>
</td>
<td>#{{ a.id }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ app_name }}{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
</head>
<body>
{% if session.get('user') %}
<header class="topbar">
<div class="brand">{{ app_name }}</div>
<nav class="nav">
<a href="{{ url_for('dashboard') }}" class="{% if request.endpoint=='dashboard' %}active{% endif %}">总览</a>
<a href="{{ url_for('records_page') }}" class="{% if request.endpoint and request.endpoint.startswith('record') %}active{% endif %}">交易复盘</a>
<a href="{{ url_for('download_page') }}" class="{% if request.endpoint and 'download' in request.endpoint %}active{% endif %}">下载中心</a>
<a href="{{ url_for('accounts_page') }}" class="{% if request.endpoint and 'account' in request.endpoint %}active{% endif %}">账户</a>
<a href="{{ url_for('machines_page') }}" class="{% if request.endpoint and 'machine' in request.endpoint %}active{% endif %}">机器</a>
</nav>
<div class="right">{{ session.get('user') }} · <a href="{{ url_for('logout') }}">退出</a></div>
</header>
{% endif %}
<main class="wrap">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="flash {{ cat }}">{{ msg }}</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</main>
{% block scripts %}{% endblock %}
</body>
</html>
+50
View File
@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}总览 · {{ app_name }}{% endblock %}
{% block content %}
<h2 style="margin:0 0 14px">总览</h2>
<div class="grid cols-3">
<div class="card"><div class="muted">账户</div><div class="stat">{{ accounts|length }}</div></div>
<div class="card"><div class="muted">机器</div><div class="stat">{{ machines|length }}</div></div>
<div class="card"><div class="muted">最近成交(展示)</div><div class="stat">{{ trades|length }}</div></div>
</div>
<div class="grid cols-2" style="margin-top:14px">
<div class="card">
<h3>快速入口</h3>
<p class="muted">登录后下载通用辅助包,用激活码绑定机器与账户;成交自动上报,网页复盘四周期 K 线。</p>
<p style="margin-top:12px">
<a class="btn" href="{{ url_for('download_page') }}">下载中心</a>
<a class="btn secondary" href="{{ url_for('records_page') }}">交易复盘</a>
</p>
</div>
<div class="card">
<h3>服务信息</h3>
<p class="muted">对外地址:{{ public_base }}</p>
<p class="muted">端口:5800 · 健康检查:<a href="/health">/health</a></p>
</div>
</div>
<div class="card" style="margin-top:14px">
<h3>最近记录</h3>
<table>
<thead>
<tr><th>时间</th><th>品种</th><th>方向</th><th>盈亏</th><th>账户</th><th>入场形式</th><th></th></tr>
</thead>
<tbody>
{% for t in trades %}
<tr>
<td class="muted">{{ t.close_time or t.open_time or t.created_at }}</td>
<td>{{ t.symbol }}</td>
<td><span class="tag {% if t.side=='buy' %}green{% else %}red{% endif %}">{{ t.side }}</span></td>
<td>{{ t.profit if t.profit is not none else '—' }}</td>
<td>{{ t.account_name or '—' }}</td>
<td>{{ t.entry_style or '—' }}</td>
<td><a href="{{ url_for('record_detail', tid=t.id) }}">复盘</a></td>
</tr>
{% else %}
<tr><td colspan="7" class="muted">暂无成交。激活客户端并上报后显示。</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+62
View File
@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}下载中心 · {{ app_name }}{% endblock %}
{% block content %}
<h2 style="margin:0 0 14px">下载中心</h2>
<div class="grid cols-2">
<div class="card">
<h3>通用辅助包</h3>
<p class="muted">所有机器下载同一 exe。首次启动输入激活码,绑定账户与机器标识。</p>
{% if exe_ready %}
<p><span class="tag green">已上传 {{ exe_name }}</span></p>
<a class="btn" href="{{ url_for('download_file') }}">下载 exe</a>
{% else %}
<p><span class="tag gray">尚未放置 exe</span></p>
<p class="muted">请把打包文件放到服务器 <code>releases/黄金市价辅助.exe</code>。当前可下载说明文件。</p>
<a class="btn secondary" href="{{ url_for('download_file') }}">下载说明</a>
{% endif %}
</div>
<div class="card">
<h3>生成激活码</h3>
<form method="post" action="{{ url_for('download_create_activation') }}">
<div class="form-row">
<label>绑定账户</label>
<select name="account_id" required>
{% for a in accounts %}
<option value="{{ a.id }}">#{{ a.id }} {{ a.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-row">
<label>预绑定机器(可选)</label>
<select name="machine_id">
<option value="">激活时自动创建</option>
{% for m in machines %}
<option value="{{ m.id }}">#{{ m.id }} {{ m.name }}</option>
{% endfor %}
</select>
</div>
<button class="btn" type="submit">生成激活码</button>
</form>
</div>
</div>
<div class="card" style="margin-top:14px">
<h3>激活码列表</h3>
<table>
<thead><tr><th>激活码</th><th>账户</th><th>机器</th><th>状态</th><th>创建时间</th></tr></thead>
<tbody>
{% for a in activations %}
<tr>
<td><code>{{ a.code }}</code></td>
<td>{{ a.account_name or a.account_id }}</td>
<td>{{ a.machine_name or a.machine_id or '—' }}</td>
<td><span class="tag {% if a.status=='active' %}green{% else %}gray{% endif %}">{{ a.status }}</span></td>
<td class="muted">{{ a.created_at }}</td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">暂无激活码</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+41
View File
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登录 · {{ app_name }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
<style>
body { display:flex; align-items:center; justify-content:center; }
.login-box { width:100%; max-width:400px; }
.login-box h2 {
text-align:center; margin:0 0 8px;
background: linear-gradient(90deg, #4cc2ff, #7b42ff);
-webkit-background-clip:text; -webkit-text-fill-color:transparent;
}
</style>
</head>
<body>
<div class="card login-box">
<h2>{{ app_name }}</h2>
<p class="muted" style="text-align:center;margin-bottom:18px">MT5 黄金 · 下单辅助 · 网页复盘</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="flash {{ cat }}">{{ msg }}</div>
{% endfor %}
{% endwith %}
<form method="post">
<div class="form-row">
<label>用户名</label>
<input name="username" autocomplete="username" value="admin" required>
</div>
<div class="form-row">
<label>密码</label>
<input name="password" type="password" autocomplete="current-password" required>
</div>
<button class="btn" style="width:100%" type="submit">登录</button>
</form>
<p class="muted" style="margin-top:14px;text-align:center">默认 admin / admin123(请部署后修改 .env)</p>
</div>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}机器 · {{ app_name }}{% endblock %}
{% block content %}
<h2 style="margin:0 0 14px">机器标注</h2>
<div class="grid cols-2">
<div class="card">
<h3>预登记机器</h3>
<p class="muted">也可不预登记:激活时自动创建机器并绑定指纹。</p>
<form method="post" action="{{ url_for('machines_create') }}">
<div class="form-row"><label>名称</label><input name="name" placeholder="如:书房台式机"></div>
<div class="form-row"><label>标注</label><textarea name="note" rows="2"></textarea></div>
<button class="btn" type="submit">添加</button>
</form>
</div>
<div class="card">
<h3>已有机器</h3>
<table>
<thead><tr><th>名称 / 指纹</th><th>最近在线</th><th>标注</th></tr></thead>
<tbody>
{% for m in items %}
<tr>
<td>
<form method="post" action="{{ url_for('machines_note', mid=m.id) }}">
<input name="name" value="{{ m.name }}" style="margin-bottom:6px">
<div class="muted">{{ m.device_fingerprint or '未绑定' }}</div>
<textarea name="note" rows="2" style="margin-top:6px">{{ m.note or '' }}</textarea>
<button class="btn secondary" style="margin-top:6px" type="submit">保存</button>
</form>
</td>
<td class="muted">{{ m.last_seen_at or '—' }}</td>
<td>#{{ m.id }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+63
View File
@@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}复盘 #{{ trade.id }} · {{ app_name }}{% endblock %}
{% block content %}
<p><a href="{{ url_for('records_page') }}">← 返回列表</a></p>
<h2 style="margin:8px 0 14px">复盘 #{{ trade.id }} · {{ trade.symbol }} · {{ trade.side }}</h2>
<div class="grid cols-2">
<div class="card">
<h3>成交信息</h3>
<table>
<tr><th>账户</th><td>{{ trade.account_name or '—' }}</td></tr>
<tr><th>机器</th><td>{{ trade.machine_name or '—' }}</td></tr>
<tr><th>手数</th><td>{{ trade.volume }}</td></tr>
<tr><th>开仓</th><td>{{ trade.open_price }} / {{ trade.open_time }}</td></tr>
<tr><th>平仓</th><td>{{ trade.close_price }} / {{ trade.close_time }}</td></tr>
<tr><th>盈亏</th><td>{{ trade.profit }}</td></tr>
<tr><th>SL / TP</th><td>{{ trade.sl }} / {{ trade.tp }}</td></tr>
<tr><th>单号</th><td>{{ trade.ticket or '—' }}</td></tr>
</table>
</div>
<div class="card">
<h3>入场形式标注</h3>
<form method="post" action="{{ url_for('record_annotate', tid=trade.id) }}">
<div class="form-row">
<label>入场形式</label>
<select name="entry_style">
{% set styles = ['', 'M15关键位+M1强吞没', '回撤接力', '突破延续', '逆势试错', '其它'] %}
{% for s in styles %}
<option value="{{ s }}" {% if trade.entry_style==s %}selected{% endif %}>{{ s or '(未选)' }}</option>
{% endfor %}
</select>
</div>
<div class="form-row">
<label>复盘备注</label>
<textarea name="review_note" rows="5" placeholder="位不对 / 形不对 / 出场违纪 …">{{ trade.review_note or '' }}</textarea>
</div>
<button class="btn" type="submit">保存标注</button>
</form>
</div>
</div>
<div class="card" style="margin-top:14px">
<h3>四周期 K 线(M1 / M5 / M15 / H1)</h3>
<p class="muted">数据来自服务器缓存;由已激活客户端通过 API 上报 bars。无数据时显示占位。</p>
<div class="chart-grid" style="margin-top:10px">
{% for tf in ['M1','M5','M15','H1'] %}
<div class="chart-box">
<h4>{{ tf }} · {{ charts[tf]|length }} 根</h4>
{% if charts[tf] %}
<canvas class="chart-canvas" id="chart-{{ tf }}"
data-bars='{{ charts[tf]|tojson }}'></canvas>
{% else %}
<div class="empty-chart">暂无 {{ tf }} K 线缓存</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/charts.js') }}"></script>
{% endblock %}
+37
View File
@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}交易复盘 · {{ app_name }}{% endblock %}
{% block content %}
<h2 style="margin:0 0 6px">交易记录与复盘</h2>
<p class="muted" style="margin:0 0 14px">参考 crypto_monitor 复盘流程:列表筛选 → 点开详情 → 四周期 K 线 + 入场形式标注。</p>
<div class="card">
<table>
<thead>
<tr>
<th>ID</th><th>品种</th><th>方向</th><th>手数</th><th>开仓价</th><th>平仓价</th>
<th>盈亏</th><th>账户</th><th>机器</th><th>入场形式</th><th></th>
</tr>
</thead>
<tbody>
{% for t in trades %}
<tr>
<td>{{ t.id }}</td>
<td>{{ t.symbol }}</td>
<td><span class="tag {% if t.side=='buy' %}green{% else %}red{% endif %}">{{ t.side }}</span></td>
<td>{{ t.volume }}</td>
<td>{{ t.open_price }}</td>
<td>{{ t.close_price if t.close_price is not none else '—' }}</td>
<td style="color:{% if t.profit and t.profit>0 %}var(--green){% elif t.profit and t.profit<0 %}var(--red){% else %}inherit{% endif %}">
{{ t.profit if t.profit is not none else '—' }}
</td>
<td>{{ t.account_name or '—' }}</td>
<td>{{ t.machine_name or '—' }}</td>
<td>{{ t.entry_style or '—' }}</td>
<td><a href="{{ url_for('record_detail', tid=t.id) }}">打开复盘</a></td>
</tr>
{% else %}
<tr><td colspan="11" class="muted">暂无数据。客户端上报成交后出现在此。</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+36
View File
@@ -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`
+24
View File
@@ -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",
},
},
],
};
+33
View File
@@ -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
}
+36
View File
@@ -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 ""
+24
View File
@@ -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
+26
View File
@@ -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
+133
View File
@@ -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" "$@" </dev/tty
}
show_banner() {
local ip path
path="${INSTALL_ROOT}"
if repo_ready "${REPO_ROOT}"; then
path="${REPO_ROOT}"
fi
if [[ -f "${LIB_DIR}/common.sh" ]]; then
# shellcheck source=/dev/null
source "${LIB_DIR}/common.sh"
ip="$(detect_server_ip)"
else
ip="?"
fi
echo ""
echo "╔══════════════════════════════════════╗"
echo "║ gold_monitor 部署管理器 ║"
echo "║ 路径: ${path}"
echo "║ 端口: 5800 IP: ${ip}"
echo "╚══════════════════════════════════════╝"
echo ""
}
show_menu() {
echo " 1) 一键部署(Web + API + PM2)"
echo " 2) 一键卸载"
echo " 3) 更新"
echo " 0) 退出"
echo ""
}
main_menu() {
bootstrap_repo
export REPO_ROOT DEPLOY_DIR LIB_DIR INSTALL_ROOT GIT_URL GIT_BRANCH
# shellcheck source=/dev/null
source "${LIB_DIR}/common.sh"
show_banner
while true; do
show_menu
read -r -p "请选择: " choice
case "${choice}" in
1) bash "${LIB_DIR}/install.sh" ;;
2) bash "${LIB_DIR}/uninstall.sh" ;;
3) bash "${LIB_DIR}/update.sh" ;;
0) exit 0 ;;
*) echo "无效选项" ;;
esac
echo ""
done
}
main_menu "$@"
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "${ROOT}"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y python3 python3-venv python3-pip git ca-certificates curl
if [[ ! -d .venv ]]; then
python3 -m venv .venv
fi
# shellcheck disable=SC1091
source .venv/bin/activate
pip install -U pip
pip install -r requirements.txt
if [[ ! -f .env ]]; then
cp .env.example .env
# 生成随机 SECRET
SECRET="$(python3 - <<'PY'
import secrets
print(secrets.token_urlsafe(32))
PY
)"
IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
IP="${IP:-127.0.0.1}"
sed -i "s|^FLASK_SECRET_KEY=.*|FLASK_SECRET_KEY=${SECRET}|" .env
sed -i "s|^PUBLIC_BASE_URL=.*|PUBLIC_BASE_URL=http://${IP}:5800|" .env
echo "已生成 .env (PUBLIC_BASE_URL=http://${IP}:5800)"
fi
mkdir -p data releases
echo "setup_env 完成"
+31
View File
@@ -0,0 +1,31 @@
# desktop · MT5 黄金辅助客户端
## 定位
- **通用包**:所有机器下载同一个 exe(由网页「下载中心」分发)。
- **首次激活**:启动后输入网页生成的激活码,调用 `POST /api/v1/activate`,本地保存 `token`。
- **运行期**:本机连接已登录的 MT5;成交/快照 `POST /api/v1/trades`;可选上报 K 线 `POST /api/v1/bars`。
## 与现有代码
当前可从 `E:\仓库\MT5黄金方案\Desktop\gold_order_app.py` 迁移下单逻辑。
本目录后续接入:
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"
}
```
成功后请求头携带:`X-Device-Token: <token>`
+85
View File
@@ -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_<时间戳>/`。
+123
View File
@@ -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: <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
+60
View File
@@ -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 即可,不必在交易机上开网页服务。
+58
View File
@@ -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. 每日网页复盘:标注亏单共性(位不对 / 形不对 / 违纪)
+5
View File
@@ -0,0 +1,5 @@
请将打包好的通用辅助 exe 放到本目录,命名为:
黄金市价辅助.exe
客户端首次启动使用网页「下载中心」生成的激活码,绑定机器与账户。
+4
View File
@@ -0,0 +1,4 @@
Flask>=3.0,<4
Werkzeug>=3.0,<4
gunicorn>=22.0,<24
python-dotenv>=1.0,<2
+9
View File
@@ -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)