feat: scaffold gold_monitor web hub, API, PM2 deploy and docs
This commit is contained in:
@@ -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 %}
|
||||
Reference in New Issue
Block a user