feat: scaffold gold_monitor web hub, API, PM2 deploy and docs
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
* text=auto
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.py text eol=lf
|
||||||
|
*.md text eol=lf
|
||||||
|
*.cjs text eol=lf
|
||||||
+21
@@ -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/
|
||||||
@@ -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) 。
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from app.factory import create_app
|
||||||
|
|
||||||
|
__all__ = ["create_app"]
|
||||||
@@ -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"
|
||||||
@@ -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()),
|
||||||
|
)
|
||||||
@@ -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
@@ -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})
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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`
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 "$@"
|
||||||
@@ -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 完成"
|
||||||
@@ -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>`
|
||||||
@@ -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_<时间戳>/`。
|
||||||
@@ -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
|
||||||
@@ -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 即可,不必在交易机上开网页服务。
|
||||||
@@ -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. 每日网页复盘:标注亏单共性(位不对 / 形不对 / 违纪)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
请将打包好的通用辅助 exe 放到本目录,命名为:
|
||||||
|
|
||||||
|
黄金市价辅助.exe
|
||||||
|
|
||||||
|
客户端首次启动使用网页「下载中心」生成的激活码,绑定机器与账户。
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Flask>=3.0,<4
|
||||||
|
Werkzeug>=3.0,<4
|
||||||
|
gunicorn>=22.0,<24
|
||||||
|
python-dotenv>=1.0,<2
|
||||||
Reference in New Issue
Block a user