From ae480cb3e7ab39651d37a8ce55c06359ba55581b Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 15 Jun 2026 11:04:00 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E6=9C=9F=E8=B4=A7=E7=9B=91?= =?UTF-8?q?=E6=8E=A7=E7=B3=BB=E7=BB=9F=EF=BC=9A=E5=A4=9A=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=AF=BC=E8=88=AA=E3=80=81=E5=BC=80=E5=8D=95=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E3=80=81Ubuntu=20PM2=20=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .env.example | 14 ++ .gitignore | 10 + README.md | 156 ++++++++++++ app.py | 519 ++++++++++++++++++++++++++++++++++++++++ deploy.sh | 88 +++++++ ecosystem.config.cjs | 20 ++ requirements.txt | 4 + static/js/symbol.js | 87 +++++++ symbols.py | 95 ++++++++ templates/base.html | 87 +++++++ templates/keys.html | 51 ++++ templates/login.html | 40 ++++ templates/plans.html | 68 ++++++ templates/records.html | 47 ++++ templates/settings.html | 39 +++ templates/stats.html | 91 +++++++ 16 files changed, 1416 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.py create mode 100644 deploy.sh create mode 100644 ecosystem.config.cjs create mode 100644 requirements.txt create mode 100644 static/js/symbol.js create mode 100644 symbols.py create mode 100644 templates/base.html create mode 100644 templates/keys.html create mode 100644 templates/login.html create mode 100644 templates/plans.html create mode 100644 templates/records.html create mode 100644 templates/settings.html create mode 100644 templates/stats.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..507fdb1 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# 服务配置 +HOST=0.0.0.0 +PORT=6600 +DEBUG=false + +# Flask Session 密钥(部署时请改为随机字符串) +SECRET_KEY=change-this-to-a-random-secret-key + +# 初始管理员账号(仅首次初始化数据库时使用) +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me-on-first-login + +# 企业微信 Webhook(也可在系统设置页面修改) +WECHAT_WEBHOOK= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5110bc3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +*.db +__pycache__/ +*.py[cod] +*$py.class +.Python +venv/ +.venv/ +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5910dc --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +# 国内期货交易监控复盘系统 + +基于 Flask 的国内期货监控与复盘 Web 应用,支持开单计划、关键位监控、止盈止损自动跟踪、企业微信推送与统计分析。 + +## 功能模块 + +| 模块 | 说明 | +|------|------| +| **开单计划** | 品种、方向、决策区间、止损/止盈;价格进入区间后激活并推送 | +| **关键位监控** | 箱体/收敛突破、阻力/支撑位突破提醒(触发后去重) | +| **交易记录与复盘** | 自动记录止盈/止损结果 | +| **统计分析** | 总交易、胜率,按品种/类型/方向统计 | +| **系统设置** | 修改密码、配置企业微信 Webhook | + +## 品种输入 + +在各表单中输入中文品种名(如「白银」「螺纹钢」),自动联想新浪行情代码及主力合约标识。 + +## 环境要求 + +- Ubuntu 20.04+(推荐) +- Python 3.10+ +- Node.js(仅 PM2 进程守护需要) +- 网络可访问 `hq.sinajs.cn`(行情)及企业微信 API + +## 快速部署(Ubuntu + /opt) + +```bash +# 克隆仓库后,在项目目录执行一键部署 +sudo bash deploy.sh +``` + +默认安装路径:`/opt/qihuo`,服务端口:`6600`。 + +部署完成后访问:`http://服务器IP:6600` + +## 手动部署 + +### 1. 安装系统依赖 + +```bash +sudo apt update +sudo apt install -y python3 python3-venv python3-pip git +# PM2(进程守护) +curl -fsSL https://get.pnpm.io/install.sh | sh - # 或直接用 npm +sudo npm install -g pm2 +``` + +### 2. 克隆到 /opt + +```bash +sudo mkdir -p /opt +sudo git clone https://git.bz121.com/dekun/qihuo.git /opt/qihuo +sudo chown -R $USER:$USER /opt/qihuo +cd /opt/qihuo +``` + +### 3. 虚拟环境与依赖 + +```bash +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +### 4. 配置环境变量 + +```bash +cp .env.example .env +# 编辑 .env,至少修改 SECRET_KEY 和 ADMIN_PASSWORD +nano .env +``` + +`.env` 主要字段: + +```env +HOST=0.0.0.0 +PORT=6600 +SECRET_KEY=随机长字符串 +ADMIN_USERNAME=admin +ADMIN_PASSWORD=首次登录密码 +WECHAT_WEBHOOK=企业微信机器人地址(可选,也可在页面配置) +``` + +> 管理员密码首次从 `.env` 写入数据库并哈希存储,之后请在「系统设置」中修改。 + +### 5. PM2 启动 + +```bash +pm2 start ecosystem.config.cjs +pm2 save +pm2 startup # 按提示执行生成的命令,实现开机自启 +``` + +### 6. 常用 PM2 命令 + +```bash +pm2 status +pm2 logs qihuo +pm2 restart qihuo +pm2 stop qihuo +``` + +## 本地开发 + +```bash +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +python app.py +``` + +## 目录结构 + +``` +qihuo/ +├── app.py # 主程序 +├── symbols.py # 期货品种映射 +├── requirements.txt +├── .env.example +├── deploy.sh # Ubuntu 一键部署 +├── ecosystem.config.cjs # PM2 配置 +├── static/js/symbol.js # 品种联想 +├── templates/ # 页面模板 +└── futures.db # SQLite 数据库(运行后生成) +``` + +## 监控逻辑说明 + +### 开单计划 + +1. **待触发**:当前价进入「决策区间 [下限, 上限]」→ 企业微信通知,状态变为「已激活」 +2. **已激活**:监控止盈止损直至触发,写入交易记录,计划关闭 + +### 关键位监控 + +- 箱体/收敛:突破上沿或跌破下沿各推送一次 +- 阻力/支撑:单向突破推送一次 + +后台线程每 3 秒轮询行情。 + +## 安全建议 + +- 部署后立即修改默认密码 +- 勿将 `.env` 提交到仓库 +- 生产环境建议用 Nginx 反代并配置 HTTPS +- 限制 6600 端口仅内网或 VPN 访问 + +## 仓库地址 + +https://git.bz121.com/dekun/qihuo.git + +## License + +Private / 个人使用 diff --git a/app.py b/app.py new file mode 100644 index 0000000..0d6db03 --- /dev/null +++ b/app.py @@ -0,0 +1,519 @@ +import os +import sqlite3 +import time +import threading +import requests +from datetime import datetime +from typing import Optional +from functools import wraps + +from dotenv import load_dotenv +from flask import ( + Flask, render_template, request, redirect, url_for, + flash, session, jsonify, +) +from werkzeug.security import check_password_hash, generate_password_hash + +from symbols import search_symbols, get_by_code + +load_dotenv() + +app = Flask(__name__) +app.secret_key = os.getenv("SECRET_KEY", "futures_monitor_default_secret") + +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", "6600")) +DEBUG = os.getenv("DEBUG", "false").lower() in ("1", "true", "yes") + +DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "futures.db") + +# —————————————— 设置读写 —————————————— + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def get_setting(key: str, default: str = "") -> str: + conn = get_db() + row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone() + conn.close() + return row["value"] if row else default + + +def set_setting(key: str, value: str): + conn = get_db() + conn.execute( + "INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=?", + (key, value, value), + ) + conn.commit() + conn.close() + + +def init_db(): + conn = get_db() + c = conn.cursor() + c.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)") + c.execute('''CREATE TABLE IF NOT EXISTS order_plans + (id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT, symbol_name TEXT, direction TEXT, + zone_upper REAL, zone_lower REAL, + stop_loss REAL, take_profit REAL, + status TEXT DEFAULT "planned", + triggered_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + c.execute('''CREATE TABLE IF NOT EXISTS key_monitors + (id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT, symbol_name TEXT, monitor_type TEXT, direction TEXT, + upper REAL, lower REAL, + upper_triggered INTEGER DEFAULT 0, + lower_triggered INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + c.execute('''CREATE TABLE IF NOT EXISTS trade_records + (id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT, symbol_name TEXT, monitor_type TEXT, direction TEXT, + trigger_price REAL, stop_loss REAL, take_profit REAL, + result TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + migrations = [ + "ALTER TABLE key_monitors ADD COLUMN symbol_name TEXT", + "ALTER TABLE key_monitors ADD COLUMN upper_triggered INTEGER DEFAULT 0", + "ALTER TABLE key_monitors ADD COLUMN lower_triggered INTEGER DEFAULT 0", + "ALTER TABLE trade_records ADD COLUMN symbol_name TEXT", + ] + for sql in migrations: + try: + c.execute(sql) + except sqlite3.OperationalError: + pass + conn.commit() + conn.close() + + if not get_setting("admin_username"): + username = os.getenv("ADMIN_USERNAME", "admin") + password = os.getenv("ADMIN_PASSWORD", "admin123") + set_setting("admin_username", username) + set_setting("admin_password_hash", generate_password_hash(password)) + + if not get_setting("wechat_webhook") and os.getenv("WECHAT_WEBHOOK"): + set_setting("wechat_webhook", os.getenv("WECHAT_WEBHOOK")) + + +init_db() + +# —————————————— 推送 —————————————— + +def send_wechat_msg(content: str): + webhook = get_setting("wechat_webhook") + if not webhook: + return + full = f"【国内期货】\n{content}" + data = {"msgtype": "text", "text": {"content": full}} + try: + requests.post(webhook, json=data, timeout=10) + except Exception: + pass + +# —————————————— 行情 —————————————— + +def get_price(symbol: str) -> Optional[float]: + try: + url = f"https://hq.sinajs.cn/list={symbol}" + headers = {"Referer": "https://finance.sina.com.cn"} + resp = requests.get(url, headers=headers, timeout=5) + text = resp.text + if "=" not in text: + return None + data = text.split("=")[1].strip().strip('"').split(",") + if len(data) < 9: + return None + return float(data[8]) + except Exception: + return None + +# —————————————— 监控逻辑 —————————————— + +def check_order_plans(): + conn = get_db() + rows = conn.execute( + "SELECT * FROM order_plans WHERE status IN ('planned', 'active')" + ).fetchall() + + for r in rows: + sym = r["symbol"] + p = get_price(sym) + if not p: + continue + + direction = r["direction"] + zone_upper = r["zone_upper"] + zone_lower = r["zone_lower"] + stop_loss = r["stop_loss"] + take_profit = r["take_profit"] + status = r["status"] + pid = r["id"] + name = r["symbol_name"] or sym + + # 计划状态:价格进入决策区间则激活并通知 + if status == "planned": + in_zone = zone_lower <= p <= zone_upper + if in_zone: + msg = ( + f"【开单计划触发】{name} ({sym})\n" + f"方向:{'做多' if direction == 'long' else '做空'}\n" + f"决策区间:{zone_lower} ~ {zone_upper}\n" + f"当前价:{p}\n" + f"止损:{stop_loss} 止盈:{take_profit}" + ) + send_wechat_msg(msg) + conn.execute( + "UPDATE order_plans SET status='active', triggered_at=? WHERE id=?", + (datetime.now().isoformat(), pid), + ) + status = "active" + + # 激活状态:监控止盈止损 + if status == "active": + res = None + if direction == "long": + if p >= take_profit: + res = "止盈" + elif p <= stop_loss: + res = "止损" + elif direction == "short": + if p <= take_profit: + res = "止盈" + elif p >= stop_loss: + res = "止损" + + if res: + msg = ( + f"[{'做多' if direction == 'long' else '做空'}] {name} 已{res}\n" + f"决策区间:{zone_lower} ~ {zone_upper}\n" + f"止损:{stop_loss} 止盈:{take_profit}\n" + f"当前价:{p}" + ) + send_wechat_msg(msg) + conn.execute( + """INSERT INTO trade_records + (symbol, symbol_name, monitor_type, direction, + trigger_price, stop_loss, take_profit, result) + VALUES (?,?,?,?,?,?,?,?)""", + (sym, name, "开单计划", direction, p, stop_loss, take_profit, res), + ) + conn.execute( + "UPDATE order_plans SET status='closed' WHERE id=?", (pid,) + ) + + conn.commit() + conn.close() + + +def check_key_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors").fetchall() + + for r in rows: + sym = r["symbol"] + typ = r["monitor_type"] + up = r["upper"] + low = r["lower"] + up_trig = r["upper_triggered"] + low_trig = r["lower_triggered"] + name = r["symbol_name"] or sym + pid = r["id"] + + p = get_price(sym) + if not p: + continue + + if typ in ("箱体突破", "收敛突破"): + if p > up and not up_trig: + send_wechat_msg(f"{name} 突破{typ}上沿 {up}\n当前价:{p}") + conn.execute( + "UPDATE key_monitors SET upper_triggered=1 WHERE id=?", (pid,) + ) + if p < low and not low_trig: + send_wechat_msg(f"{name} 跌破{typ}下沿 {low}\n当前价:{p}") + conn.execute( + "UPDATE key_monitors SET lower_triggered=1 WHERE id=?", (pid,) + ) + elif typ == "关键阻力位" and p > up and not up_trig: + send_wechat_msg(f"{name} 突破阻力位 {up}\n当前价:{p}") + conn.execute( + "UPDATE key_monitors SET upper_triggered=1 WHERE id=?", (pid,) + ) + elif typ == "关键支撑位" and p < low and not low_trig: + send_wechat_msg(f"{name} 跌破支撑位 {low}\n当前价:{p}") + conn.execute( + "UPDATE key_monitors SET lower_triggered=1 WHERE id=?", (pid,) + ) + + conn.commit() + conn.close() + + +def background_task(): + while True: + try: + check_key_monitors() + check_order_plans() + except Exception: + pass + time.sleep(3) + +# —————————————— 登录 —————————————— + +def login_required(f): + @wraps(f) + def wrap(*args, **kwargs): + if not session.get("logged_in"): + return redirect(url_for("login")) + return f(*args, **kwargs) + return wrap + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + u = request.form.get("username", "").strip() + p = request.form.get("password", "") + admin_u = get_setting("admin_username") + admin_hash = get_setting("admin_password_hash") + if u == admin_u and check_password_hash(admin_hash, p): + session["logged_in"] = True + session["username"] = u + return redirect(url_for("plans")) + flash("账号或密码错误") + return render_template("login.html") + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + +# —————————————— API —————————————— + +@app.route("/api/symbols/search") +@login_required +def api_symbol_search(): + q = request.args.get("q", "") + return jsonify(search_symbols(q)) + +# —————————————— 页面路由 —————————————— + +@app.route("/") +@login_required +def index(): + return redirect(url_for("plans")) + + +@app.route("/plans") +@login_required +def plans(): + conn = get_db() + plan_list = conn.execute( + "SELECT * FROM order_plans WHERE status != 'closed' ORDER BY id DESC" + ).fetchall() + closed = conn.execute( + "SELECT * FROM order_plans WHERE status='closed' ORDER BY id DESC LIMIT 20" + ).fetchall() + conn.close() + return render_template("plans.html", plans=plan_list, closed=closed) + + +@app.route("/add_plan", methods=["POST"]) +@login_required +def add_plan(): + d = request.form + direction = d.get("direction") + symbol = d.get("symbol", "").strip() + symbol_name = d.get("symbol_name", "").strip() + if not direction: + flash("请选择多空方向") + return redirect(url_for("plans")) + if not symbol: + flash("请选择有效品种") + return redirect(url_for("plans")) + conn = get_db() + conn.execute( + """INSERT INTO order_plans + (symbol, symbol_name, direction, zone_upper, zone_lower, stop_loss, take_profit) + VALUES (?,?,?,?,?,?,?)""", + ( + symbol, symbol_name, direction, + float(d["zone_upper"]), float(d["zone_lower"]), + float(d["stop_loss"]), float(d["take_profit"]), + ), + ) + conn.commit() + conn.close() + flash("开单计划已添加") + return redirect(url_for("plans")) + + +@app.route("/del_plan/") +@login_required +def del_plan(pid): + conn = get_db() + conn.execute("DELETE FROM order_plans WHERE id=?", (pid,)) + conn.commit() + conn.close() + flash("已删除") + return redirect(url_for("plans")) + + +@app.route("/keys") +@login_required +def keys(): + conn = get_db() + key_list = conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall() + conn.close() + return render_template("keys.html", keys=key_list) + + +@app.route("/add_key", methods=["POST"]) +@login_required +def add_key(): + d = request.form + direction = d.get("direction") + symbol = d.get("symbol", "").strip() + symbol_name = d.get("symbol_name", "").strip() + if not direction: + flash("请选择多空方向") + return redirect(url_for("keys")) + if not symbol: + flash("请选择有效品种") + return redirect(url_for("keys")) + conn = get_db() + conn.execute( + """INSERT INTO key_monitors + (symbol, symbol_name, monitor_type, direction, upper, lower) + VALUES (?,?,?,?,?,?)""", + (symbol, symbol_name, d["type"], direction, float(d["upper"]), float(d["lower"])), + ) + conn.commit() + conn.close() + flash("关键位监控已添加") + return redirect(url_for("keys")) + + +@app.route("/del_key/") +@login_required +def del_key(pid): + conn = get_db() + conn.execute("DELETE FROM key_monitors WHERE id=?", (pid,)) + conn.commit() + conn.close() + flash("已删除") + return redirect(url_for("keys")) + + +@app.route("/records") +@login_required +def records(): + conn = get_db() + record_list = conn.execute( + "SELECT * FROM trade_records ORDER BY id DESC" + ).fetchall() + conn.close() + return render_template("records.html", records=record_list) + + +@app.route("/del_record/") +@login_required +def del_record(rid): + conn = get_db() + conn.execute("DELETE FROM trade_records WHERE id=?", (rid,)) + conn.commit() + conn.close() + flash("已删除") + return redirect(url_for("records")) + + +@app.route("/stats") +@login_required +def stats(): + conn = get_db() + total = conn.execute( + "SELECT COUNT(*) FROM trade_records WHERE result IN ('止盈','止损')" + ).fetchone()[0] + win = conn.execute( + "SELECT COUNT(*) FROM trade_records WHERE result='止盈'" + ).fetchone()[0] + loss = conn.execute( + "SELECT COUNT(*) FROM trade_records WHERE result='止损'" + ).fetchone()[0] + rate = round(win / total * 100, 2) if total else 0 + + by_symbol = conn.execute( + """SELECT symbol_name, symbol, COUNT(*) as cnt, + SUM(CASE WHEN result='止盈' THEN 1 ELSE 0 END) as wins + FROM trade_records WHERE result IN ('止盈','止损') + GROUP BY symbol ORDER BY cnt DESC""" + ).fetchall() + + by_type = conn.execute( + """SELECT monitor_type, COUNT(*) as cnt, + SUM(CASE WHEN result='止盈' THEN 1 ELSE 0 END) as wins + FROM trade_records WHERE result IN ('止盈','止损') + GROUP BY monitor_type ORDER BY cnt DESC""" + ).fetchall() + + by_direction = conn.execute( + """SELECT direction, COUNT(*) as cnt, + SUM(CASE WHEN result='止盈' THEN 1 ELSE 0 END) as wins + FROM trade_records WHERE result IN ('止盈','止损') + GROUP BY direction""" + ).fetchall() + + recent = conn.execute( + "SELECT * FROM trade_records ORDER BY id DESC LIMIT 10" + ).fetchall() + conn.close() + + return render_template( + "stats.html", + total=total, win=win, loss=loss, rate=rate, + by_symbol=by_symbol, by_type=by_type, by_direction=by_direction, + recent=recent, + ) + + +@app.route("/settings", methods=["GET", "POST"]) +@login_required +def settings(): + if request.method == "POST": + action = request.form.get("action") + if action == "wechat": + webhook = request.form.get("wechat_webhook", "").strip() + set_setting("wechat_webhook", webhook) + flash("企业微信配置已保存") + elif action == "password": + old_p = request.form.get("old_password", "") + new_p = request.form.get("new_password", "") + new_p2 = request.form.get("new_password2", "") + admin_hash = get_setting("admin_password_hash") + if not check_password_hash(admin_hash, old_p): + flash("原密码错误") + elif len(new_p) < 6: + flash("新密码至少 6 位") + elif new_p != new_p2: + flash("两次新密码不一致") + else: + set_setting("admin_password_hash", generate_password_hash(new_p)) + flash("密码修改成功") + return redirect(url_for("settings")) + + webhook = get_setting("wechat_webhook") + username = get_setting("admin_username") + return render_template("settings.html", webhook=webhook, username=username) + +# —————————————— 启动 —————————————— + +if __name__ == "__main__": + threading.Thread(target=background_task, daemon=True).start() + app.run(host=HOST, port=PORT, debug=DEBUG) diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..687db1b --- /dev/null +++ b/deploy.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# 国内期货监控系统 - Ubuntu 一键部署脚本 +# 安装路径: /opt/qihuo 端口: 6600 进程守护: PM2 + +set -euo pipefail + +APP_DIR="/opt/qihuo" +REPO_URL="https://git.bz121.com/dekun/qihuo.git" +SERVICE_NAME="qihuo" + +echo "==> 检查系统依赖..." + +install_pkg() { + if ! command -v "$1" &>/dev/null; then + echo "安装 $1..." + sudo apt-get install -y "$2" + fi +} + +sudo apt-get update -qq +install_pkg python3 python3 +install_pkg python3-venv python3-venv +install_pkg git git + +if ! command -v pm2 &>/dev/null; then + echo "==> 安装 PM2..." + if command -v npm &>/dev/null; then + sudo npm install -g pm2 + else + install_pkg nodejs nodejs + install_pkg npm npm + sudo npm install -g pm2 + fi +fi + +echo "==> 准备应用目录 ${APP_DIR}..." +sudo mkdir -p "$(dirname "$APP_DIR")" + +if [ -d "$APP_DIR/.git" ]; then + echo "==> 更新已有仓库..." + cd "$APP_DIR" + git pull origin main || git pull origin master || true +else + if [ -d "$APP_DIR" ] && [ "$(ls -A "$APP_DIR" 2>/dev/null)" ]; then + echo "目录 ${APP_DIR} 已存在且非 git 仓库,请手动处理后重试" + exit 1 + fi + echo "==> 克隆仓库..." + sudo git clone "$REPO_URL" "$APP_DIR" + cd "$APP_DIR" +fi + +# 确保当前用户可写(若以 root 克隆则 chown) +if [ "$(id -u)" -ne 0 ]; then + sudo chown -R "$(whoami):$(whoami)" "$APP_DIR" +fi + +echo "==> 创建 Python 虚拟环境..." +python3 -m venv "$APP_DIR/venv" +source "$APP_DIR/venv/bin/activate" +pip install --upgrade pip -q +pip install -r "$APP_DIR/requirements.txt" -q + +if [ ! -f "$APP_DIR/.env" ]; then + echo "==> 生成 .env(请稍后编辑 SECRET_KEY 和密码)..." + cp "$APP_DIR/.env.example" "$APP_DIR/.env" + # 生成随机 SECRET_KEY + RAND_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))") + sed -i "s/change-this-to-a-random-secret-key/${RAND_KEY}/" "$APP_DIR/.env" + echo "已创建 $APP_DIR/.env ,请编辑 ADMIN_PASSWORD 后重启服务" +fi + +echo "==> PM2 启动/重启服务..." +cd "$APP_DIR" +mkdir -p "$APP_DIR/logs" +pm2 delete "$SERVICE_NAME" 2>/dev/null || true +pm2 start ecosystem.config.cjs +pm2 save + +echo "" +echo "==========================================" +echo " 部署完成" +echo " 目录: ${APP_DIR}" +echo " 端口: 6600" +echo " 访问: http://<服务器IP>:6600" +echo " 日志: pm2 logs ${SERVICE_NAME}" +echo " 开机自启: pm2 startup && pm2 save" +echo "==========================================" diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 0000000..8f26aeb --- /dev/null +++ b/ecosystem.config.cjs @@ -0,0 +1,20 @@ +module.exports = { + apps: [ + { + name: "qihuo", + script: "app.py", + cwd: "/opt/qihuo", + interpreter: "/opt/qihuo/venv/bin/python", + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: "300M", + env: { + NODE_ENV: "production", + }, + error_file: "/opt/qihuo/logs/pm2-error.log", + out_file: "/opt/qihuo/logs/pm2-out.log", + time: true, + }, + ], +}; diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..563b72a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Flask==3.0.3 +requests==2.32.3 +python-dotenv==1.0.1 +Werkzeug==3.0.3 diff --git a/static/js/symbol.js b/static/js/symbol.js new file mode 100644 index 0000000..0178b0e --- /dev/null +++ b/static/js/symbol.js @@ -0,0 +1,87 @@ +(function () { + function initSymbolInput(wrapper) { + const input = wrapper.querySelector('.symbol-input'); + const hiddenCode = wrapper.querySelector('input[name="symbol"]'); + const hiddenName = wrapper.querySelector('input[name="symbol_name"]'); + const dropdown = wrapper.querySelector('.symbol-dropdown'); + const selectedEl = wrapper.querySelector('.symbol-selected'); + let timer = null; + + function hideDropdown() { + dropdown.classList.remove('show'); + } + + function selectItem(item) { + input.value = item.name; + hiddenCode.value = item.code; + hiddenName.value = item.name; + selectedEl.textContent = item.display + ' | ' + item.code; + hideDropdown(); + } + + function renderItems(items) { + dropdown.innerHTML = ''; + if (!items.length) { + dropdown.innerHTML = '
无匹配品种
'; + } else { + items.forEach(function (item) { + const div = document.createElement('div'); + div.className = 'symbol-option'; + div.innerHTML = item.display + '
' + item.code + ' · ' + item.exchange + '
'; + div.addEventListener('mousedown', function (e) { + e.preventDefault(); + selectItem(item); + }); + dropdown.appendChild(div); + }); + } + dropdown.classList.add('show'); + } + + input.addEventListener('input', function () { + hiddenCode.value = ''; + hiddenName.value = ''; + selectedEl.textContent = ''; + const q = input.value.trim(); + if (!q) { + hideDropdown(); + return; + } + clearTimeout(timer); + timer = setTimeout(function () { + fetch('/api/symbols/search?q=' + encodeURIComponent(q)) + .then(function (r) { return r.json(); }) + .then(renderItems) + .catch(function () { hideDropdown(); }); + }, 200); + }); + + input.addEventListener('blur', function () { + setTimeout(hideDropdown, 150); + }); + + input.addEventListener('focus', function () { + const q = input.value.trim(); + if (q && !hiddenCode.value) { + fetch('/api/symbols/search?q=' + encodeURIComponent(q)) + .then(function (r) { return r.json(); }) + .then(renderItems); + } + }); + } + + document.addEventListener('DOMContentLoaded', function () { + document.querySelectorAll('.symbol-wrap').forEach(initSymbolInput); + + document.querySelectorAll('form').forEach(function (form) { + if (!form.querySelector('.symbol-wrap')) return; + form.addEventListener('submit', function (e) { + const hidden = form.querySelector('input[name="symbol"]'); + if (hidden && !hidden.value.trim()) { + e.preventDefault(); + alert('请从下拉列表中选择品种'); + } + }); + }); + }); +})(); diff --git a/symbols.py b/symbols.py new file mode 100644 index 0000000..553f05e --- /dev/null +++ b/symbols.py @@ -0,0 +1,95 @@ +# 国内期货品种映射:中文名 -> 新浪行情代码(0 表示主力连续) +SYMBOLS = [ + {"name": "白银", "code": "nf_AG0", "exchange": "上期所"}, + {"name": "黄金", "code": "nf_AU0", "exchange": "上期所"}, + {"name": "铜", "code": "nf_CU0", "exchange": "上期所"}, + {"name": "铝", "code": "nf_AL0", "exchange": "上期所"}, + {"name": "锌", "code": "nf_ZN0", "exchange": "上期所"}, + {"name": "铅", "code": "nf_PB0", "exchange": "上期所"}, + {"name": "镍", "code": "nf_NI0", "exchange": "上期所"}, + {"name": "锡", "code": "nf_SN0", "exchange": "上期所"}, + {"name": "螺纹钢", "code": "nf_RB0", "exchange": "上期所"}, + {"name": "热卷", "code": "nf_HC0", "exchange": "上期所"}, + {"name": "不锈钢", "code": "nf_SS0", "exchange": "上期所"}, + {"name": "原油", "code": "nf_SC0", "exchange": "上期所"}, + {"name": "燃油", "code": "nf_FU0", "exchange": "上期所"}, + {"name": "沥青", "code": "nf_BU0", "exchange": "上期所"}, + {"name": "橡胶", "code": "nf_RU0", "exchange": "上期所"}, + {"name": "纸浆", "code": "nf_SP0", "exchange": "上期所"}, + {"name": "铁矿石", "code": "nf_I0", "exchange": "大商所"}, + {"name": "焦炭", "code": "nf_J0", "exchange": "大商所"}, + {"name": "焦煤", "code": "nf_JM0", "exchange": "大商所"}, + {"name": "豆粕", "code": "nf_M0", "exchange": "大商所"}, + {"name": "豆油", "code": "nf_Y0", "exchange": "大商所"}, + {"name": "棕榈油", "code": "nf_P0", "exchange": "大商所"}, + {"name": "玉米", "code": "nf_C0", "exchange": "大商所"}, + {"name": "淀粉", "code": "nf_CS0", "exchange": "大商所"}, + {"name": "鸡蛋", "code": "nf_JD0", "exchange": "大商所"}, + {"name": "生猪", "code": "nf_LH0", "exchange": "大商所"}, + {"name": "聚乙烯", "code": "nf_L0", "exchange": "大商所"}, + {"name": "聚丙烯", "code": "nf_PP0", "exchange": "大商所"}, + {"name": "PVC", "code": "nf_V0", "exchange": "大商所"}, + {"name": "乙二醇", "code": "nf_EG0", "exchange": "大商所"}, + {"name": "苯乙烯", "code": "nf_EB0", "exchange": "大商所"}, + {"name": "液化气", "code": "nf_PG0", "exchange": "大商所"}, + {"name": "菜粕", "code": "nf_RM0", "exchange": "郑商所"}, + {"name": "菜油", "code": "nf_OI0", "exchange": "郑商所"}, + {"name": "白糖", "code": "nf_SR0", "exchange": "郑商所"}, + {"name": "棉花", "code": "nf_CF0", "exchange": "郑商所"}, + {"name": "甲醇", "code": "nf_MA0", "exchange": "郑商所"}, + {"name": "PTA", "code": "nf_TA0", "exchange": "郑商所"}, + {"name": "玻璃", "code": "nf_FG0", "exchange": "郑商所"}, + {"name": "纯碱", "code": "nf_SA0", "exchange": "郑商所"}, + {"name": "尿素", "code": "nf_UR0", "exchange": "郑商所"}, + {"name": "硅铁", "code": "nf_SF0", "exchange": "郑商所"}, + {"name": "锰硅", "code": "nf_SM0", "exchange": "郑商所"}, + {"name": "苹果", "code": "nf_AP0", "exchange": "郑商所"}, + {"name": "红枣", "code": "nf_CJ0", "exchange": "郑商所"}, + {"name": "花生", "code": "nf_PK0", "exchange": "郑商所"}, + {"name": "沪深300", "code": "CFF_RE_IF0", "exchange": "中金所"}, + {"name": "上证50", "code": "CFF_RE_IH0", "exchange": "中金所"}, + {"name": "中证500", "code": "CFF_RE_IC0", "exchange": "中金所"}, + {"name": "中证1000", "code": "CFF_RE_IM0", "exchange": "中金所"}, +] + + +def _contract_label(code: str) -> str: + """从新浪代码提取品种字母部分,用于展示主力合约标识。""" + raw = code.replace("nf_", "").replace("CFF_RE_", "") + letters = "".join(c for c in raw if c.isalpha()) + return f"{letters}主力" + + +def search_symbols(query: str) -> list: + q = query.strip().lower() + if not q: + return [] + results = [] + for s in SYMBOLS: + name = s["name"] + code = s["code"] + contract = _contract_label(code) + if q in name.lower() or q in code.lower() or q in contract.lower(): + results.append({ + "name": name, + "code": code, + "exchange": s["exchange"], + "contract": contract, + "display": f"{name} ({contract})", + }) + return results[:12] + + +from typing import Optional + + +def get_by_code(code: str) -> Optional[dict]: + for s in SYMBOLS: + if s["code"] == code: + return { + "name": s["name"], + "code": s["code"], + "exchange": s["exchange"], + "contract": _contract_label(s["code"]), + } + return None diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..d56200f --- /dev/null +++ b/templates/base.html @@ -0,0 +1,87 @@ + + + + + + {% block title %}国内期货监控系统{% endblock %} + + {% block extra_css %}{% endblock %} + + +
+ +
+ {% with msg=get_flashed_messages() %}{% if msg %}
{{ msg[0] }}
{% endif %}{% endwith %} + {% block content %}{% endblock %} +
+
+ + {% block extra_js %}{% endblock %} + + diff --git a/templates/keys.html b/templates/keys.html new file mode 100644 index 0000000..3b81e15 --- /dev/null +++ b/templates/keys.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}关键位监控 - 国内期货监控系统{% endblock %} +{% block content %} +

关键位监控

+ +
+

新增监控

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

监控列表

+
+ {% for k in keys %} +
+
+ {{ k.symbol_name or k.symbol }} | {{ k.monitor_type }} + {{ '做多' if k.direction == 'long' else '做空' }} +
+
上: {{ k.upper }} | 下: {{ k.lower }}
+
{{ k.symbol }}
+ 删除 +
+ {% else %} +
暂无关键位监控
+ {% endfor %} +
+
+{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..70688e2 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,40 @@ + + + + + + 系统登录 + + + + + + diff --git a/templates/plans.html b/templates/plans.html new file mode 100644 index 0000000..5a68f13 --- /dev/null +++ b/templates/plans.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% block title %}开单计划 - 国内期货监控系统{% endblock %} +{% block content %} +

开单计划

+ +
+

新增计划

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

进行中计划

+
+ {% for p in plans %} +
+
+ {{ p.symbol_name or p.symbol }} + {{ '做多' if p.direction == 'long' else '做空' }} + {% if p.status == 'planned' %} + 待触发 + {% else %} + 已激活 + {% endif %} +
+
区间: {{ p.zone_lower }} ~ {{ p.zone_upper }}
+
止损: {{ p.stop_loss }} | 止盈: {{ p.take_profit }}
+
{{ p.symbol }}
+ 删除 +
+ {% else %} +
暂无进行中的开单计划
+ {% endfor %} +
+
+ +{% if closed %} +
+

最近已完成

+
+ {% for p in closed %} +
+
{{ p.symbol_name or p.symbol }} {{ '做多' if p.direction == 'long' else '做空' }}
+
区间: {{ p.zone_lower }} ~ {{ p.zone_upper }} | 损: {{ p.stop_loss }} 盈: {{ p.take_profit }}
+ 删除 +
+ {% endfor %} +
+
+{% endif %} +{% endblock %} diff --git a/templates/records.html b/templates/records.html new file mode 100644 index 0000000..83aee73 --- /dev/null +++ b/templates/records.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% block title %}交易记录与复盘 - 国内期货监控系统{% endblock %} +{% block content %} +

交易记录与复盘

+ +
+

全部记录

+ + + + + + + + + + + + + + + + {% for r in records %} + + + + + + + + + + + + {% else %} + + {% endfor %} + +
品种类型方向触发价止损止盈结果时间
{{ r.symbol_name or r.symbol }}{{ r.monitor_type }}{{ '做多' if r.direction == 'long' else '做空' }}{{ r.trigger_price }}{{ r.stop_loss }}{{ r.take_profit }} + {% if r.result == '止盈' %} + 止盈 + {% else %} + 止损 + {% endif %} + {{ r.created_at[:16] if r.created_at else '' }}
暂无交易记录
+
+{% endblock %} diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..c8ae897 --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block title %}系统设置 - 国内期货监控系统{% endblock %} +{% block content %} +

系统设置

+ +
+

企业微信推送

+
+ + + +
+

在企业微信群中添加机器人后,将 Webhook 地址粘贴到上方保存即可。

+
+ +
+

修改密码

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+{% endblock %} diff --git a/templates/stats.html b/templates/stats.html new file mode 100644 index 0000000..29b58bf --- /dev/null +++ b/templates/stats.html @@ -0,0 +1,91 @@ +{% extends "base.html" %} +{% block title %}统计分析 - 国内期货监控系统{% endblock %} +{% block content %} +

统计分析

+ +
+
总交易
{{ total }}
+
止盈
{{ win }}
+
止损
{{ loss }}
+
胜率
{{ rate }}%
+
+ +
+

按品种统计

+ + + + {% for s in by_symbol %} + + + + + + + {% else %} + + {% endfor %} + +
品种交易次数止盈次数胜率
{{ s.symbol_name or s.symbol }}{{ s.cnt }}{{ s.wins }}{{ round(s.wins / s.cnt * 100, 2) if s.cnt else 0 }}%
暂无数据
+
+ +
+

按类型统计

+ + + + {% for t in by_type %} + + + + + + + {% else %} + + {% endfor %} + +
类型交易次数止盈次数胜率
{{ t.monitor_type }}{{ t.cnt }}{{ t.wins }}{{ round(t.wins / t.cnt * 100, 2) if t.cnt else 0 }}%
暂无数据
+
+ +
+

按方向统计

+ + + + {% for d in by_direction %} + + + + + + + {% else %} + + {% endfor %} + +
方向交易次数止盈次数胜率
{{ '做多' if d.direction == 'long' else '做空' }}{{ d.cnt }}{{ d.wins }}{{ round(d.wins / d.cnt * 100, 2) if d.cnt else 0 }}%
暂无数据
+
+ +
+

最近 10 笔交易

+ + + + {% for r in recent %} + + + + + + + {% else %} + + {% endfor %} + +
品种方向结果时间
{{ r.symbol_name or r.symbol }}{{ '做多' if r.direction == 'long' else '做空' }} + {% if r.result == '止盈' %}止盈 + {% else %}止损{% endif %} + {{ r.created_at[:16] if r.created_at else '' }}
暂无数据
+
+{% endblock %}