commit ae480cb3e7ab39651d37a8ce55c06359ba55581b Author: dekun Date: Mon Jun 15 11:04:00 2026 +0800 重构期货监控系统:多页面导航、开单计划、Ubuntu PM2 部署 Co-authored-by: Cursor 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 %}