diff --git a/.env.example b/.env.example index 507fdb1..d101f10 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,9 @@ ADMIN_PASSWORD=change-me-on-first-login # 企业微信 Webhook(也可在系统设置页面修改) WECHAT_WEBHOOK= + +# 行情数据源: auto(优先同花顺,失败回退新浪)| ths | sina +QUOTE_SOURCE=auto + +# 同花顺 iFinD HTTP refresh_token(也可在系统设置页面修改) +THS_REFRESH_TOKEN= diff --git a/README.md b/README.md index 72932c7..e026c57 100644 --- a/README.md +++ b/README.md @@ -22,23 +22,24 @@ | 郑商所 | `SR609`、`MA606` | 大写品种 + 3 位年月 | | 中金所 | `IF2606`、`IH2606` | 大写品种 + 4 位年月 | -界面展示同花顺代码;行情在后台通过新浪 API 拉取(内部自动转换,无需手动填写新浪代码)。 +界面展示同花顺代码;行情优先走**同花顺 iFinD HTTP API**,未配置或失败时自动回退新浪。 -## 快速部署(Ubuntu root + /root/qihuo) +## 快速部署(Ubuntu root + /opt/qihuo) ```bash -# 以 root 登录后,在项目目录执行 +# root 登录后执行 +cd /opt/qihuo # 或先 git clone 再 bash deploy.sh bash deploy.sh ``` -默认安装路径:`/root/qihuo`,服务端口:`6600`。 +默认安装路径:`/opt/qihuo`,服务端口:`6600`。 部署完成后访问:`http://服务器IP:6600` ## 环境要求 - Ubuntu 20.04+(推荐) -- **root 用户**运行(部署目录 `/root/qihuo`) +- **root 用户**运行(部署目录 `/opt/qihuo`) - Python 3.10+ - Node.js + PM2(进程守护) - 网络可访问 `hq.sinajs.cn`(行情)及企业微信 API @@ -53,11 +54,11 @@ apt install -y python3 python3-venv python3-pip git nodejs npm npm install -g pm2 ``` -### 2. 克隆到 /root/qihuo +### 2. 克隆到 /opt/qihuo ```bash -git clone https://git.bz121.com/dekun/qihuo.git /root/qihuo -cd /root/qihuo +git clone https://git.bz121.com/dekun/qihuo.git /opt/qihuo +cd /opt/qihuo ``` ### 3. 虚拟环境与依赖 @@ -82,8 +83,12 @@ SECRET_KEY=随机长字符串 ADMIN_USERNAME=admin ADMIN_PASSWORD=首次登录密码 WECHAT_WEBHOOK=企业微信机器人地址(可选,也可在页面配置) +QUOTE_SOURCE=auto +THS_REFRESH_TOKEN=同花顺 refresh_token(可选,也可在页面配置) ``` +> 同花顺行情需在 [同花顺数据接口](https://quantapi.10jqka.com.cn/) 申请 iFinD HTTP 权限,在「超级命令 → 工具」获取 `refresh_token`。未配置时自动使用新浪行情。 + > 管理员密码首次从 `.env` 写入数据库并哈希存储,之后请在「系统设置」中修改。 ### 5. PM2 启动 @@ -118,7 +123,8 @@ python app.py ``` qihuo/ ├── app.py # 主程序 -├── symbols.py # 期货品种映射 +├── market.py # 同花顺/新浪行情拉取 +├── symbols.py # 期货品种与同花顺代码映射 ├── requirements.txt ├── .env.example ├── deploy.sh # Ubuntu 一键部署 diff --git a/app.py b/app.py index 79d4156..827bb83 100644 --- a/app.py +++ b/app.py @@ -14,7 +14,8 @@ from flask import ( ) from werkzeug.security import check_password_hash, generate_password_hash -from symbols import search_symbols, get_price, ths_to_sina_code +from symbols import search_symbols, ths_to_codes +from market import get_price as market_get_price, set_ths_refresh_token load_dotenv() @@ -83,8 +84,9 @@ def init_db(): "ALTER TABLE key_monitors ADD COLUMN lower_triggered INTEGER DEFAULT 0", "ALTER TABLE trade_records ADD COLUMN symbol_name TEXT", "ALTER TABLE order_plans ADD COLUMN sina_code TEXT", - "ALTER TABLE key_monitors ADD COLUMN sina_code TEXT", - "ALTER TABLE trade_records ADD COLUMN sina_code TEXT", + "ALTER TABLE order_plans ADD COLUMN market_code TEXT", + "ALTER TABLE key_monitors ADD COLUMN market_code TEXT", + "ALTER TABLE trade_records ADD COLUMN market_code TEXT", ] for sql in migrations: try: @@ -103,9 +105,19 @@ def init_db(): if not get_setting("wechat_webhook") and os.getenv("WECHAT_WEBHOOK"): set_setting("wechat_webhook", os.getenv("WECHAT_WEBHOOK")) + if not get_setting("ths_refresh_token") and os.getenv("THS_REFRESH_TOKEN"): + set_setting("ths_refresh_token", os.getenv("THS_REFRESH_TOKEN")) + init_db() + +def sync_ths_token(): + set_ths_refresh_token(get_setting("ths_refresh_token")) + + +sync_ths_token() + # —————————————— 推送 —————————————— def send_wechat_msg(content: str): @@ -121,20 +133,25 @@ def send_wechat_msg(content: str): # —————————————— 行情 —————————————— -def resolve_sina_code(ths_code: str, sina_code: str = "") -> Optional[str]: - """同花顺代码 -> 新浪行情代码;兼容旧数据中的新浪格式。""" - if sina_code: - return sina_code +def resolve_market_codes(ths_code: str, market_code: str = "", sina_code: str = "") -> tuple[str, str]: + """返回 (market_code, sina_code) 用于行情拉取。""" + if market_code: + return market_code, sina_code + if sina_code and "." in sina_code: + return sina_code, "" + codes = ths_to_codes(ths_code) + if codes: + return codes["market_code"], codes["sina_code"] if ths_code.startswith("nf_") or ths_code.startswith("CFF_RE_"): - return ths_code - return ths_to_sina_code(ths_code) + return ths_code, ths_code + return "", sina_code or "" -def fetch_price(ths_code: str, sina_code: str = "") -> Optional[float]: - code = resolve_sina_code(ths_code, sina_code) - if not code: +def fetch_price(ths_code: str, market_code: str = "", sina_code: str = "") -> Optional[float]: + mc, sc = resolve_market_codes(ths_code, market_code, sina_code) + if not mc and not sc: return None - return get_price(code) + return market_get_price(mc, sc) # —————————————— 监控逻辑 —————————————— @@ -147,7 +164,8 @@ def check_order_plans(): for r in rows: sym = r["symbol"] sina = r["sina_code"] if "sina_code" in r.keys() else "" - p = fetch_price(sym, sina) + market = r["market_code"] if "market_code" in r.keys() else "" + p = fetch_price(sym, market, sina) if not p: continue @@ -229,8 +247,9 @@ def check_key_monitors(): name = r["symbol_name"] or sym pid = r["id"] sina = r["sina_code"] if "sina_code" in r.keys() else "" + market = r["market_code"] if "market_code" in r.keys() else "" - p = fetch_price(sym, sina) + p = fetch_price(sym, market, sina) if not p: continue @@ -337,20 +356,22 @@ def add_plan(): direction = d.get("direction") symbol = d.get("symbol", "").strip() symbol_name = d.get("symbol_name", "").strip() + market_code = d.get("market_code", "").strip() sina_code = d.get("sina_code", "").strip() if not direction: flash("请选择多空方向") return redirect(url_for("plans")) - if not symbol or not sina_code: + if not symbol or not market_code: flash("请从下拉列表选择品种(同花顺合约代码)") return redirect(url_for("plans")) conn = get_db() conn.execute( """INSERT INTO order_plans - (symbol, symbol_name, sina_code, direction, zone_upper, zone_lower, stop_loss, take_profit) + (symbol, symbol_name, market_code, sina_code, direction, + zone_upper, zone_lower, stop_loss, take_profit) VALUES (?,?,?,?,?,?,?,?)""", ( - symbol, symbol_name, sina_code, direction, + symbol, symbol_name, market_code, sina_code, direction, float(d["zone_upper"]), float(d["zone_lower"]), float(d["stop_loss"]), float(d["take_profit"]), ), @@ -388,19 +409,20 @@ def add_key(): direction = d.get("direction") symbol = d.get("symbol", "").strip() symbol_name = d.get("symbol_name", "").strip() + market_code = d.get("market_code", "").strip() sina_code = d.get("sina_code", "").strip() if not direction: flash("请选择多空方向") return redirect(url_for("keys")) - if not symbol or not sina_code: + if not symbol or not market_code: flash("请从下拉列表选择品种(同花顺合约代码)") return redirect(url_for("keys")) conn = get_db() conn.execute( """INSERT INTO key_monitors - (symbol, symbol_name, sina_code, monitor_type, direction, upper, lower) - VALUES (?,?,?,?,?,?,?)""", - (symbol, symbol_name, sina_code, d["type"], direction, float(d["upper"]), float(d["lower"])), + (symbol, symbol_name, market_code, sina_code, monitor_type, direction, upper, lower) + VALUES (?,?,?,?,?,?,?,?)""", + (symbol, symbol_name, market_code, sina_code, d["type"], direction, float(d["upper"]), float(d["lower"])), ) conn.commit() conn.close() @@ -499,6 +521,11 @@ def settings(): webhook = request.form.get("wechat_webhook", "").strip() set_setting("wechat_webhook", webhook) flash("企业微信配置已保存") + elif action == "ths": + token = request.form.get("ths_refresh_token", "").strip() + set_setting("ths_refresh_token", token) + sync_ths_token() + flash("同花顺行情配置已保存") elif action == "password": old_p = request.form.get("old_password", "") new_p = request.form.get("new_password", "") @@ -516,8 +543,16 @@ def settings(): return redirect(url_for("settings")) webhook = get_setting("wechat_webhook") + ths_token = get_setting("ths_refresh_token") username = get_setting("admin_username") - return render_template("settings.html", webhook=webhook, username=username) + quote_source = os.getenv("QUOTE_SOURCE", "auto") + return render_template( + "settings.html", + webhook=webhook, + ths_token=ths_token, + username=username, + quote_source=quote_source, + ) # —————————————— 启动 —————————————— diff --git a/deploy.sh b/deploy.sh index 4d39c31..d24ba6d 100644 --- a/deploy.sh +++ b/deploy.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash -# 国内期货监控系统 - Ubuntu 一键部署(root 用户,/root/qihuo) -# 端口: 6600 进程守护: PM2 +# 国内期货监控系统 - Ubuntu 一键部署 +# root 用户 | 目录 /opt/qihuo | 端口 6600 | PM2 set -euo pipefail -APP_DIR="/root/qihuo" +APP_DIR="/opt/qihuo" REPO_URL="https://git.bz121.com/dekun/qihuo.git" SERVICE_NAME="qihuo" @@ -58,7 +58,7 @@ pip install --upgrade pip -q pip install -r "$APP_DIR/requirements.txt" -q if [ ! -f "$APP_DIR/.env" ]; then - echo "==> 生成 .env(请编辑 ADMIN_PASSWORD 后重启)..." + echo "==> 生成 .env(请编辑 ADMIN_PASSWORD、THS_REFRESH_TOKEN)..." cp "$APP_DIR/.env.example" "$APP_DIR/.env" 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" diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index dcaa241..8f26aeb 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -3,8 +3,8 @@ module.exports = { { name: "qihuo", script: "app.py", - cwd: "/root/qihuo", - interpreter: "/root/qihuo/venv/bin/python", + cwd: "/opt/qihuo", + interpreter: "/opt/qihuo/venv/bin/python", instances: 1, autorestart: true, watch: false, @@ -12,8 +12,8 @@ module.exports = { env: { NODE_ENV: "production", }, - error_file: "/root/qihuo/logs/pm2-error.log", - out_file: "/root/qihuo/logs/pm2-out.log", + error_file: "/opt/qihuo/logs/pm2-error.log", + out_file: "/opt/qihuo/logs/pm2-out.log", time: true, }, ], diff --git a/market.py b/market.py new file mode 100644 index 0000000..8d6df44 --- /dev/null +++ b/market.py @@ -0,0 +1,182 @@ +""" +行情拉取:优先同花顺 iFinD HTTP API,失败或未配置时回退新浪。 +""" +import os +import time +import json +import logging +from typing import Optional + +import requests + +logger = logging.getLogger(__name__) + +THS_TOKEN_URL = "https://quantapi.51ifind.com/api/v1/get_access_token" +THS_QUOTE_URL = "https://quantapi.51ifind.com/api/v1/real_time_quotation" + +# iFinD HTTP 期货交易所后缀 +THS_EX_SUFFIX = { + "SHFE": "SHFE", + "DCE": "DCE", + "CZCE": "CZCE", + "CFFEX": "CFFEX", + "INE": "INE", +} + +_token_cache: dict = {"token": "", "expires": 0.0, "refresh": ""} + + +def _quote_source() -> str: + return os.getenv("QUOTE_SOURCE", "auto").strip().lower() + + +def _sina_headers() -> dict: + return {"Referer": "https://finance.sina.com.cn"} + + +def _fetch_sina_raw(sina_code: str) -> Optional[dict]: + try: + url = f"https://hq.sinajs.cn/list={sina_code}" + resp = requests.get(url, headers=_sina_headers(), timeout=5) + resp.encoding = "gbk" + if '"' not in resp.text: + return None + body = resp.text.split('"')[1] + if not body: + return None + parts = body.split(",") + if len(parts) < 9: + return None + price = float(parts[8]) + volume = float(parts[14]) if len(parts) > 14 and parts[14] else 0 + return {"name": parts[0], "price": price, "volume": volume} + except Exception as exc: + logger.debug("sina fetch failed %s: %s", sina_code, exc) + return None + + +def get_sina_price(sina_code: str) -> Optional[float]: + raw = _fetch_sina_raw(sina_code) + return raw["price"] if raw else None + + +_runtime_refresh_token: str = "" + + +def set_ths_refresh_token(token: str): + global _runtime_refresh_token + _runtime_refresh_token = (token or "").strip() + + +def _get_refresh_token() -> str: + if _runtime_refresh_token: + return _runtime_refresh_token + return os.getenv("THS_REFRESH_TOKEN", "").strip() + + +def _get_ths_access_token(refresh_token: str) -> Optional[str]: + if not refresh_token: + return None + now = time.time() + if ( + _token_cache["token"] + and _token_cache["refresh"] == refresh_token + and now < _token_cache["expires"] + ): + return _token_cache["token"] + try: + resp = requests.post( + THS_TOKEN_URL, + headers={"Content-Type": "application/json", "refresh_token": refresh_token}, + timeout=10, + ) + data = resp.json() + if data.get("errorcode") != 0: + logger.warning("THS token error: %s", data.get("errmsg")) + return None + access = data["data"]["access_token"] + _token_cache.update({ + "token": access, + "refresh": refresh_token, + "expires": now + 3600 * 6, + }) + return access + except Exception as exc: + logger.warning("THS token request failed: %s", exc) + return None + + +def _parse_ths_quote(data: dict) -> Optional[float]: + """从同花顺实时行情响应解析最新价。""" + try: + tables = data.get("tables") or [] + for table in tables: + t = table.get("table") or {} + for key in ("latest", "new", "close", "trade", "last"): + val = t.get(key) + if val is None: + continue + if isinstance(val, list) and val: + return float(val[0]) + if isinstance(val, (int, float, str)) and str(val): + return float(val) + # 部分响应嵌套在 data 字段 + if "data" in data and isinstance(data["data"], dict): + return _parse_ths_quote(data["data"]) + except Exception as exc: + logger.debug("parse ths quote failed: %s", exc) + return None + + +def get_ths_price(ths_full_code: str, refresh_token: str = "") -> Optional[float]: + """ths_full_code 如 ag2608.SHFE、IF2606.CFFEX""" + token = refresh_token or _get_refresh_token() + access = _get_ths_access_token(token) + if not access: + return None + try: + resp = requests.post( + THS_QUOTE_URL, + headers={"Content-Type": "application/json", "access_token": access}, + json={"codes": ths_full_code, "indicators": "latest"}, + timeout=10, + ) + data = resp.json() + if data.get("errorcode") != 0: + logger.warning("THS quote error %s: %s", ths_full_code, data.get("errmsg")) + return None + return _parse_ths_quote(data) + except Exception as exc: + logger.warning("THS quote failed %s: %s", ths_full_code, exc) + return None + + +def get_price(market_code: str, sina_fallback: str = "") -> Optional[float]: + """ + 统一取价入口。 + market_code: 同花顺完整代码 ag2608.SHFE(优先) + sina_fallback: 新浪代码 nf_AG2608(回退) + """ + source = _quote_source() + + if source in ("ths", "auto") and market_code and "." in market_code: + price = get_ths_price(market_code) + if price is not None: + return price + + if source == "ths": + return None + + if sina_fallback: + return get_sina_price(sina_fallback) + + # market_code 本身就是新浪格式 + if market_code.startswith("nf_") or market_code.startswith("CFF_RE_"): + return get_sina_price(market_code) + + return None + + +def fetch_raw_for_volume(sina_code: str) -> Optional[dict]: + """主力合约扫描用(成交量),走新浪。""" + return _fetch_sina_raw(sina_code) diff --git a/static/js/symbol.js b/static/js/symbol.js index 89c0309..100bded 100644 --- a/static/js/symbol.js +++ b/static/js/symbol.js @@ -3,6 +3,7 @@ const input = wrapper.querySelector('.symbol-input'); const hiddenThs = wrapper.querySelector('input[name="symbol"]'); const hiddenName = wrapper.querySelector('input[name="symbol_name"]'); + const hiddenMarket = wrapper.querySelector('input[name="market_code"]'); const hiddenSina = wrapper.querySelector('input[name="sina_code"]'); const dropdown = wrapper.querySelector('.symbol-dropdown'); const selectedEl = wrapper.querySelector('.symbol-selected'); @@ -16,21 +17,25 @@ input.value = item.name; hiddenThs.value = item.ths_code; hiddenName.value = item.name; - if (hiddenSina) hiddenSina.value = item.sina_code; - selectedEl.textContent = '同花顺: ' + item.ths_code + ' | 主力 ' + (item.contract || item.ths_code); + if (hiddenMarket) hiddenMarket.value = item.market_code || ''; + if (hiddenSina) hiddenSina.value = item.sina_code || ''; + selectedEl.textContent = '同花顺: ' + item.ths_code + + (item.market_code ? ' (' + item.market_code + ')' : ''); hideDropdown(); } function renderItems(items) { dropdown.innerHTML = ''; if (!items.length) { - dropdown.innerHTML = '
+ 当前行情源:{{ quote_source }}(auto=优先同花顺,失败回退新浪)。 + 在 iFinD 接口包「超级命令 → 工具」查询 refresh_token, + 或前往 同花顺数据接口 申请试用。 + 未配置 token 时自动使用新浪行情。 +
+