部署改回/opt;接入同花顺iFinD HTTP行情,新浪作回退

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-15 11:14:10 +08:00
parent bd7f0da1ca
commit fb61153a4d
11 changed files with 403 additions and 158 deletions
+6
View File
@@ -12,3 +12,9 @@ ADMIN_PASSWORD=change-me-on-first-login
# 企业微信 Webhook(也可在系统设置页面修改) # 企业微信 Webhook(也可在系统设置页面修改)
WECHAT_WEBHOOK= WECHAT_WEBHOOK=
# 行情数据源: auto(优先同花顺,失败回退新浪)| ths | sina
QUOTE_SOURCE=auto
# 同花顺 iFinD HTTP refresh_token(也可在系统设置页面修改)
THS_REFRESH_TOKEN=
+15 -9
View File
@@ -22,23 +22,24 @@
| 郑商所 | `SR609``MA606` | 大写品种 + 3 位年月 | | 郑商所 | `SR609``MA606` | 大写品种 + 3 位年月 |
| 中金所 | `IF2606``IH2606` | 大写品种 + 4 位年月 | | 中金所 | `IF2606``IH2606` | 大写品种 + 4 位年月 |
界面展示同花顺代码;行情在后台通过新浪 API 拉取(内部自动转换,无需手动填写新浪代码) 界面展示同花顺代码;行情优先走**同花顺 iFinD HTTP API**,未配置或失败时自动回退新浪
## 快速部署(Ubuntu root + /root/qihuo ## 快速部署(Ubuntu root + /opt/qihuo
```bash ```bash
# root 登录后,在项目目录执行 # root 登录后执行
cd /opt/qihuo # 或先 git clone 再 bash deploy.sh
bash deploy.sh bash deploy.sh
``` ```
默认安装路径:`/root/qihuo`,服务端口:`6600` 默认安装路径:`/opt/qihuo`,服务端口:`6600`
部署完成后访问:`http://服务器IP:6600` 部署完成后访问:`http://服务器IP:6600`
## 环境要求 ## 环境要求
- Ubuntu 20.04+(推荐) - Ubuntu 20.04+(推荐)
- **root 用户**运行(部署目录 `/root/qihuo` - **root 用户**运行(部署目录 `/opt/qihuo`
- Python 3.10+ - Python 3.10+
- Node.js + PM2(进程守护) - Node.js + PM2(进程守护)
- 网络可访问 `hq.sinajs.cn`(行情)及企业微信 API - 网络可访问 `hq.sinajs.cn`(行情)及企业微信 API
@@ -53,11 +54,11 @@ apt install -y python3 python3-venv python3-pip git nodejs npm
npm install -g pm2 npm install -g pm2
``` ```
### 2. 克隆到 /root/qihuo ### 2. 克隆到 /opt/qihuo
```bash ```bash
git clone https://git.bz121.com/dekun/qihuo.git /root/qihuo git clone https://git.bz121.com/dekun/qihuo.git /opt/qihuo
cd /root/qihuo cd /opt/qihuo
``` ```
### 3. 虚拟环境与依赖 ### 3. 虚拟环境与依赖
@@ -82,8 +83,12 @@ SECRET_KEY=随机长字符串
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD=首次登录密码 ADMIN_PASSWORD=首次登录密码
WECHAT_WEBHOOK=企业微信机器人地址(可选,也可在页面配置) WECHAT_WEBHOOK=企业微信机器人地址(可选,也可在页面配置)
QUOTE_SOURCE=auto
THS_REFRESH_TOKEN=同花顺 refresh_token(可选,也可在页面配置)
``` ```
> 同花顺行情需在 [同花顺数据接口](https://quantapi.10jqka.com.cn/) 申请 iFinD HTTP 权限,在「超级命令 → 工具」获取 `refresh_token`。未配置时自动使用新浪行情。
> 管理员密码首次从 `.env` 写入数据库并哈希存储,之后请在「系统设置」中修改。 > 管理员密码首次从 `.env` 写入数据库并哈希存储,之后请在「系统设置」中修改。
### 5. PM2 启动 ### 5. PM2 启动
@@ -118,7 +123,8 @@ python app.py
``` ```
qihuo/ qihuo/
├── app.py # 主程序 ├── app.py # 主程序
├── symbols.py # 期货品种映射 ├── market.py # 同花顺/新浪行情拉取
├── symbols.py # 期货品种与同花顺代码映射
├── requirements.txt ├── requirements.txt
├── .env.example ├── .env.example
├── deploy.sh # Ubuntu 一键部署 ├── deploy.sh # Ubuntu 一键部署
+58 -23
View File
@@ -14,7 +14,8 @@ from flask import (
) )
from werkzeug.security import check_password_hash, generate_password_hash 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() load_dotenv()
@@ -83,8 +84,9 @@ def init_db():
"ALTER TABLE key_monitors ADD COLUMN lower_triggered INTEGER DEFAULT 0", "ALTER TABLE key_monitors ADD COLUMN lower_triggered INTEGER DEFAULT 0",
"ALTER TABLE trade_records ADD COLUMN symbol_name TEXT", "ALTER TABLE trade_records ADD COLUMN symbol_name TEXT",
"ALTER TABLE order_plans ADD COLUMN sina_code TEXT", "ALTER TABLE order_plans ADD COLUMN sina_code TEXT",
"ALTER TABLE key_monitors ADD COLUMN sina_code TEXT", "ALTER TABLE order_plans ADD COLUMN market_code TEXT",
"ALTER TABLE trade_records ADD COLUMN sina_code TEXT", "ALTER TABLE key_monitors ADD COLUMN market_code TEXT",
"ALTER TABLE trade_records ADD COLUMN market_code TEXT",
] ]
for sql in migrations: for sql in migrations:
try: try:
@@ -103,9 +105,19 @@ def init_db():
if not get_setting("wechat_webhook") and os.getenv("WECHAT_WEBHOOK"): if not get_setting("wechat_webhook") and os.getenv("WECHAT_WEBHOOK"):
set_setting("wechat_webhook", 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() init_db()
def sync_ths_token():
set_ths_refresh_token(get_setting("ths_refresh_token"))
sync_ths_token()
# —————————————— 推送 —————————————— # —————————————— 推送 ——————————————
def send_wechat_msg(content: str): 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]: def resolve_market_codes(ths_code: str, market_code: str = "", sina_code: str = "") -> tuple[str, str]:
"""同花顺代码 -> 新浪行情代码;兼容旧数据中的新浪格式""" """返回 (market_code, sina_code) 用于行情拉取"""
if sina_code: if market_code:
return sina_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_"): if ths_code.startswith("nf_") or ths_code.startswith("CFF_RE_"):
return ths_code return ths_code, ths_code
return ths_to_sina_code(ths_code) return "", sina_code or ""
def fetch_price(ths_code: str, sina_code: str = "") -> Optional[float]: def fetch_price(ths_code: str, market_code: str = "", sina_code: str = "") -> Optional[float]:
code = resolve_sina_code(ths_code, sina_code) mc, sc = resolve_market_codes(ths_code, market_code, sina_code)
if not code: if not mc and not sc:
return None return None
return get_price(code) return market_get_price(mc, sc)
# —————————————— 监控逻辑 —————————————— # —————————————— 监控逻辑 ——————————————
@@ -147,7 +164,8 @@ def check_order_plans():
for r in rows: for r in rows:
sym = r["symbol"] sym = r["symbol"]
sina = r["sina_code"] if "sina_code" in r.keys() else "" 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: if not p:
continue continue
@@ -229,8 +247,9 @@ def check_key_monitors():
name = r["symbol_name"] or sym name = r["symbol_name"] or sym
pid = r["id"] pid = r["id"]
sina = r["sina_code"] if "sina_code" in r.keys() else "" 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: if not p:
continue continue
@@ -337,20 +356,22 @@ def add_plan():
direction = d.get("direction") direction = d.get("direction")
symbol = d.get("symbol", "").strip() symbol = d.get("symbol", "").strip()
symbol_name = d.get("symbol_name", "").strip() symbol_name = d.get("symbol_name", "").strip()
market_code = d.get("market_code", "").strip()
sina_code = d.get("sina_code", "").strip() sina_code = d.get("sina_code", "").strip()
if not direction: if not direction:
flash("请选择多空方向") flash("请选择多空方向")
return redirect(url_for("plans")) return redirect(url_for("plans"))
if not symbol or not sina_code: if not symbol or not market_code:
flash("请从下拉列表选择品种(同花顺合约代码)") flash("请从下拉列表选择品种(同花顺合约代码)")
return redirect(url_for("plans")) return redirect(url_for("plans"))
conn = get_db() conn = get_db()
conn.execute( conn.execute(
"""INSERT INTO order_plans """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 (?,?,?,?,?,?,?,?)""", 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["zone_upper"]), float(d["zone_lower"]),
float(d["stop_loss"]), float(d["take_profit"]), float(d["stop_loss"]), float(d["take_profit"]),
), ),
@@ -388,19 +409,20 @@ def add_key():
direction = d.get("direction") direction = d.get("direction")
symbol = d.get("symbol", "").strip() symbol = d.get("symbol", "").strip()
symbol_name = d.get("symbol_name", "").strip() symbol_name = d.get("symbol_name", "").strip()
market_code = d.get("market_code", "").strip()
sina_code = d.get("sina_code", "").strip() sina_code = d.get("sina_code", "").strip()
if not direction: if not direction:
flash("请选择多空方向") flash("请选择多空方向")
return redirect(url_for("keys")) return redirect(url_for("keys"))
if not symbol or not sina_code: if not symbol or not market_code:
flash("请从下拉列表选择品种(同花顺合约代码)") flash("请从下拉列表选择品种(同花顺合约代码)")
return redirect(url_for("keys")) return redirect(url_for("keys"))
conn = get_db() conn = get_db()
conn.execute( conn.execute(
"""INSERT INTO key_monitors """INSERT INTO key_monitors
(symbol, symbol_name, sina_code, monitor_type, direction, upper, lower) (symbol, symbol_name, market_code, sina_code, monitor_type, direction, upper, lower)
VALUES (?,?,?,?,?,?,?)""", VALUES (?,?,?,?,?,?,?,?)""",
(symbol, symbol_name, sina_code, d["type"], direction, float(d["upper"]), float(d["lower"])), (symbol, symbol_name, market_code, sina_code, d["type"], direction, float(d["upper"]), float(d["lower"])),
) )
conn.commit() conn.commit()
conn.close() conn.close()
@@ -499,6 +521,11 @@ def settings():
webhook = request.form.get("wechat_webhook", "").strip() webhook = request.form.get("wechat_webhook", "").strip()
set_setting("wechat_webhook", webhook) set_setting("wechat_webhook", webhook)
flash("企业微信配置已保存") 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": elif action == "password":
old_p = request.form.get("old_password", "") old_p = request.form.get("old_password", "")
new_p = request.form.get("new_password", "") new_p = request.form.get("new_password", "")
@@ -516,8 +543,16 @@ def settings():
return redirect(url_for("settings")) return redirect(url_for("settings"))
webhook = get_setting("wechat_webhook") webhook = get_setting("wechat_webhook")
ths_token = get_setting("ths_refresh_token")
username = get_setting("admin_username") 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,
)
# —————————————— 启动 —————————————— # —————————————— 启动 ——————————————
+4 -4
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# 国内期货监控系统 - Ubuntu 一键部署root 用户,/root/qihuo # 国内期货监控系统 - Ubuntu 一键部署
# 端口: 6600 进程守护: PM2 # root 用户 | 目录 /opt/qihuo | 端口 6600 | PM2
set -euo pipefail set -euo pipefail
APP_DIR="/root/qihuo" APP_DIR="/opt/qihuo"
REPO_URL="https://git.bz121.com/dekun/qihuo.git" REPO_URL="https://git.bz121.com/dekun/qihuo.git"
SERVICE_NAME="qihuo" SERVICE_NAME="qihuo"
@@ -58,7 +58,7 @@ pip install --upgrade pip -q
pip install -r "$APP_DIR/requirements.txt" -q pip install -r "$APP_DIR/requirements.txt" -q
if [ ! -f "$APP_DIR/.env" ]; then 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" cp "$APP_DIR/.env.example" "$APP_DIR/.env"
RAND_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))") 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" sed -i "s/change-this-to-a-random-secret-key/${RAND_KEY}/" "$APP_DIR/.env"
+4 -4
View File
@@ -3,8 +3,8 @@ module.exports = {
{ {
name: "qihuo", name: "qihuo",
script: "app.py", script: "app.py",
cwd: "/root/qihuo", cwd: "/opt/qihuo",
interpreter: "/root/qihuo/venv/bin/python", interpreter: "/opt/qihuo/venv/bin/python",
instances: 1, instances: 1,
autorestart: true, autorestart: true,
watch: false, watch: false,
@@ -12,8 +12,8 @@ module.exports = {
env: { env: {
NODE_ENV: "production", NODE_ENV: "production",
}, },
error_file: "/root/qihuo/logs/pm2-error.log", error_file: "/opt/qihuo/logs/pm2-error.log",
out_file: "/root/qihuo/logs/pm2-out.log", out_file: "/opt/qihuo/logs/pm2-out.log",
time: true, time: true,
}, },
], ],
+182
View File
@@ -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)
+13 -7
View File
@@ -3,6 +3,7 @@
const input = wrapper.querySelector('.symbol-input'); const input = wrapper.querySelector('.symbol-input');
const hiddenThs = wrapper.querySelector('input[name="symbol"]'); const hiddenThs = wrapper.querySelector('input[name="symbol"]');
const hiddenName = wrapper.querySelector('input[name="symbol_name"]'); 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 hiddenSina = wrapper.querySelector('input[name="sina_code"]');
const dropdown = wrapper.querySelector('.symbol-dropdown'); const dropdown = wrapper.querySelector('.symbol-dropdown');
const selectedEl = wrapper.querySelector('.symbol-selected'); const selectedEl = wrapper.querySelector('.symbol-selected');
@@ -16,21 +17,25 @@
input.value = item.name; input.value = item.name;
hiddenThs.value = item.ths_code; hiddenThs.value = item.ths_code;
hiddenName.value = item.name; hiddenName.value = item.name;
if (hiddenSina) hiddenSina.value = item.sina_code; if (hiddenMarket) hiddenMarket.value = item.market_code || '';
selectedEl.textContent = '同花顺: ' + item.ths_code + ' | 主力 ' + (item.contract || item.ths_code); if (hiddenSina) hiddenSina.value = item.sina_code || '';
selectedEl.textContent = '同花顺: ' + item.ths_code +
(item.market_code ? ' (' + item.market_code + ')' : '');
hideDropdown(); hideDropdown();
} }
function renderItems(items) { function renderItems(items) {
dropdown.innerHTML = ''; dropdown.innerHTML = '';
if (!items.length) { if (!items.length) {
dropdown.innerHTML = '<div class="symbol-option">无匹配品种,可输入同花顺合约如 ag2606</div>'; dropdown.innerHTML = '<div class="symbol-option">无匹配,可输入同花顺代码如 ag2608</div>';
} else { } else {
items.forEach(function (item) { items.forEach(function (item) {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'symbol-option'; div.className = 'symbol-option';
div.innerHTML = item.display + div.innerHTML = item.display +
'<div class="sub">同花顺 ' + item.ths_code + ' · ' + item.exchange + '</div>'; '<div class="sub">同花顺 ' + item.ths_code +
(item.market_code ? ' · ' + item.market_code : '') +
' · ' + item.exchange + '</div>';
div.addEventListener('mousedown', function (e) { div.addEventListener('mousedown', function (e) {
e.preventDefault(); e.preventDefault();
selectItem(item); selectItem(item);
@@ -44,6 +49,7 @@
input.addEventListener('input', function () { input.addEventListener('input', function () {
hiddenThs.value = ''; hiddenThs.value = '';
hiddenName.value = ''; hiddenName.value = '';
if (hiddenMarket) hiddenMarket.value = '';
if (hiddenSina) hiddenSina.value = ''; if (hiddenSina) hiddenSina.value = '';
selectedEl.textContent = ''; selectedEl.textContent = '';
const q = input.value.trim(); const q = input.value.trim();
@@ -81,15 +87,15 @@
if (!form.querySelector('.symbol-wrap')) return; if (!form.querySelector('.symbol-wrap')) return;
form.addEventListener('submit', function (e) { form.addEventListener('submit', function (e) {
const ths = form.querySelector('input[name="symbol"]'); const ths = form.querySelector('input[name="symbol"]');
const sina = form.querySelector('input[name="sina_code"]'); const market = form.querySelector('input[name="market_code"]');
if (ths && !ths.value.trim()) { if (ths && !ths.value.trim()) {
e.preventDefault(); e.preventDefault();
alert('请从下拉列表选择品种'); alert('请从下拉列表选择品种');
return; return;
} }
if (sina && !sina.value.trim()) { if (market && !market.value.trim()) {
e.preventDefault(); e.preventDefault();
alert('请从下拉列表选择品种(需含同花顺合约代码)'); alert('请从下拉列表选择品种(需含同花顺行情代码)');
} }
}); });
}); });
+93 -100
View File
@@ -1,15 +1,14 @@
""" """
期货品种与同花顺代码映射。 期货品种与同花顺代码映射。
界面展示同花顺格式(如 ag2606、SR609、IF2606),行情通过新浪 API(内部 sina_code)获取 展示同花顺合约代码(ag2608);行情优先走同花顺 iFinD HTTP,回退新浪
""" """
import re import re
import time import time
from datetime import date from datetime import date
from typing import Optional from typing import Optional
import requests from market import fetch_raw_for_volume, get_price as market_get_price, THS_EX_SUFFIX
# 品种字母:ths=同花顺展示用,sina=新浪 nf_ 前缀后字母(通常大写)
PRODUCTS = [ PRODUCTS = [
{"name": "白银", "ths": "ag", "sina": "AG", "exchange": "上期所", "ex": "SHFE"}, {"name": "白银", "ths": "ag", "sina": "AG", "exchange": "上期所", "ex": "SHFE"},
{"name": "黄金", "ths": "au", "sina": "AU", "exchange": "上期所", "ex": "SHFE"}, {"name": "黄金", "ths": "au", "sina": "AU", "exchange": "上期所", "ex": "SHFE"},
@@ -64,35 +63,11 @@ PRODUCTS = [
] ]
_MAIN_CACHE: dict[str, tuple[float, dict]] = {} _MAIN_CACHE: dict[str, tuple[float, dict]] = {}
_CACHE_TTL = 300 # 5 分钟 _CACHE_TTL = 300
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, "parts": parts}
except Exception:
return None
def build_ths_code(product: dict, year: int, month: int) -> str: def build_ths_code(product: dict, year: int, month: int) -> str:
"""同花顺合约代码。""" """同花顺软件内显示的合约代码。"""
ex = product["ex"] ex = product["ex"]
letters = product["ths"] letters = product["ths"]
if ex == "CZCE": if ex == "CZCE":
@@ -100,67 +75,108 @@ def build_ths_code(product: dict, year: int, month: int) -> str:
return f"{letters}{year % 100:02d}{month:02d}" return f"{letters}{year % 100:02d}{month:02d}"
def build_ths_full_code(product: dict, year: int, month: int) -> str:
"""同花顺 iFinD HTTP API 代码,如 ag2608.SHFE"""
ths = build_ths_code(product, year, month)
suffix = THS_EX_SUFFIX.get(product["ex"], product["ex"])
return f"{ths}.{suffix}"
def build_sina_code(product: dict, year: int, month: int) -> str: def build_sina_code(product: dict, year: int, month: int) -> str:
"""新浪行情代码(用于拉价)。"""
ex = product["ex"]
letters = product["sina"] letters = product["sina"]
suffix = f"{year % 100:02d}{month:02d}" suffix = f"{year % 100:02d}{month:02d}"
if ex == "CFFEX": if product["ex"] == "CFFEX":
return f"CFF_RE_{letters}{suffix}" return f"CFF_RE_{letters}{suffix}"
return f"nf_{letters}{suffix}" return f"nf_{letters}{suffix}"
def build_sina_main_code(product: dict) -> str: def build_sina_main_code(product: dict) -> str:
"""新浪主力连续代码。"""
ex = product["ex"]
letters = product["sina"] letters = product["sina"]
if ex == "CFFEX": if product["ex"] == "CFFEX":
return f"CFF_RE_{letters}0" return f"CFF_RE_{letters}0"
return f"nf_{letters}0" return f"nf_{letters}0"
def ths_to_sina_code(ths_code: str) -> Optional[str]: def _find_product_by_letters(letters: str) -> Optional[dict]:
"""将同花顺代码转为新浪代码(用户直接输入合约时使用)。""" letters_up = letters.upper()
for p in PRODUCTS:
if p["ths"].upper() == letters_up or p["sina"] == letters_up:
return p
return None
def ths_to_codes(ths_code: str) -> Optional[dict]:
"""同花顺合约代码 -> ths_full + sina 回退代码。"""
code = ths_code.strip() code = ths_code.strip()
if not code: if not code:
return None return None
# CFFEX / 四位月份: IF2606, ag2606 m4 = re.match(r"^([A-Za-z]+)(\d{4})$", code)
m = re.match(r"^([A-Za-z]+)(\d{4})$", code) if m4:
if m: letters, digits = m4.group(1), m4.group(2)
letters, digits = m.group(1), m.group(2)
letters_up = letters.upper()
for p in PRODUCTS:
if p["ths"].upper() == letters_up or p["sina"] == letters_up:
year = 2000 + int(digits[:2]) year = 2000 + int(digits[:2])
month = int(digits[2:]) month = int(digits[2:])
if 1 <= month <= 12: if not 1 <= month <= 12:
return build_sina_code(p, year, month) return None
product = _find_product_by_letters(letters)
if product:
return {
"ths_code": build_ths_code(product, year, month),
"market_code": build_ths_full_code(product, year, month),
"sina_code": build_sina_code(product, year, month),
}
letters_up = letters.upper()
if letters_up in ("IF", "IH", "IC", "IM", "T", "TF", "TS"): if letters_up in ("IF", "IH", "IC", "IM", "T", "TF", "TS"):
return f"CFF_RE_{letters_up}{digits}" ths = f"{letters_up}{digits}"
return {
"ths_code": ths,
"market_code": f"{ths}.CFFEX",
"sina_code": f"CFF_RE_{letters_up}{digits}",
}
# CZCE 3-digit: SR609
m3 = re.match(r"^([A-Za-z]+)(\d{3})$", code) m3 = re.match(r"^([A-Za-z]+)(\d{3})$", code)
if m3: if m3:
letters, digits = m3.group(1), m3.group(2) letters, digits = m3.group(1), m3.group(2)
letters_up = letters.upper()
y_digit = int(digits[0]) y_digit = int(digits[0])
month = int(digits[1:]) month = int(digits[1:])
if 1 <= month <= 12: if not 1 <= month <= 12:
return None
year = date.today().year year = date.today().year
decade = year // 10 * 10 decade = year // 10 * 10
candidate = decade + y_digit candidate = decade + y_digit
if candidate < year - 1: if candidate < year - 1:
candidate += 10 candidate += 10
for p in PRODUCTS: product = _find_product_by_letters(letters)
if p["ths"].upper() == letters_up or p["sina"] == letters_up: if product:
return build_sina_code(p, candidate, month) return {
"ths_code": build_ths_code(product, candidate, month),
"market_code": build_ths_full_code(product, candidate, month),
"sina_code": build_sina_code(product, candidate, month),
}
return None return None
def ths_to_sina_code(ths_code: str) -> Optional[str]:
codes = ths_to_codes(ths_code)
return codes["sina_code"] if codes else None
def _make_symbol_item(product: dict, year: int, month: int, volume: float) -> dict:
ths = build_ths_code(product, year, month)
return {
"name": product["name"],
"ths_code": ths,
"market_code": build_ths_full_code(product, year, month),
"sina_code": build_sina_code(product, year, month),
"exchange": product["exchange"],
"contract": f"主力 {ths}",
"display": f"{product['name']} 主力 {ths}",
"volume": volume,
}
def resolve_main_contract(product: dict) -> Optional[dict]: def resolve_main_contract(product: dict) -> Optional[dict]:
"""按成交量选取当前主力月份合约。"""
cache_key = product["sina"] cache_key = product["sina"]
now = time.time() now = time.time()
cached = _MAIN_CACHE.get(cache_key) cached = _MAIN_CACHE.get(cache_key)
@@ -177,30 +193,27 @@ def resolve_main_contract(product: dict) -> Optional[dict]:
cm -= 12 cm -= 12
cy += 1 cy += 1
sina = build_sina_code(product, cy, cm) sina = build_sina_code(product, cy, cm)
raw = _fetch_sina_raw(sina) raw = fetch_raw_for_volume(sina)
if raw and raw["volume"] > 0: if raw and raw["volume"] > 0:
ths = build_ths_code(product, cy, cm) item = _make_symbol_item(product, cy, cm, raw["volume"])
item = {
"name": product["name"],
"ths_code": ths,
"sina_code": sina,
"exchange": product["exchange"],
"contract": f"主力 {ths}",
"display": f"{product['name']} 主力 {ths}",
"volume": raw["volume"],
}
if best is None or raw["volume"] > best["volume"]: if best is None or raw["volume"] > best["volume"]:
best = item best = item
if best is None: if best is None:
sina_main = build_sina_main_code(product) sina_main = build_sina_main_code(product)
raw = _fetch_sina_raw(sina_main) raw = fetch_raw_for_volume(sina_main)
if raw: if raw:
ths_letters = product["ths"] ths_letters = product["ths"]
ths_main = f"{ths_letters}888" if product["ex"] != "CFFEX" else f"{ths_letters.upper()}888" ths_main = (
f"{ths_letters}888"
if product["ex"] != "CFFEX"
else f"{ths_letters.upper()}888"
)
suffix = THS_EX_SUFFIX.get(product["ex"], product["ex"])
best = { best = {
"name": product["name"], "name": product["name"],
"ths_code": ths_main, "ths_code": ths_main,
"market_code": f"{ths_main}.{suffix}",
"sina_code": sina_main, "sina_code": sina_main,
"exchange": product["exchange"], "exchange": product["exchange"],
"contract": f"主力连续 {ths_main}", "contract": f"主力连续 {ths_main}",
@@ -227,44 +240,24 @@ def search_symbols(query: str) -> list:
if main: if main:
results.append(main) results.append(main)
# 用户直接输入同花顺合约代码
if not results and len(q) >= 3: if not results and len(q) >= 3:
sina = ths_to_sina_code(query.strip()) codes = ths_to_codes(query.strip())
if sina: if codes:
raw = _fetch_sina_raw(sina) raw = fetch_raw_for_volume(codes["sina_code"])
if raw: name = raw["name"] if raw else query.strip()
results.append({ results.append({
"name": raw["name"], "name": name,
"ths_code": query.strip(), "ths_code": codes["ths_code"],
"sina_code": sina, "market_code": codes["market_code"],
"sina_code": codes["sina_code"],
"exchange": "", "exchange": "",
"contract": query.strip(), "contract": codes["ths_code"],
"display": f"{raw['name']} ({query.strip()})", "display": f"{name} ({codes['ths_code']})",
"volume": raw.get("volume", 0), "volume": raw.get("volume", 0) if raw else 0,
}) })
return results[:12] return results[:12]
def get_price(sina_code: str) -> Optional[float]: def get_price(market_code: str, sina_code: str = "") -> Optional[float]:
raw = _fetch_sina_raw(sina_code) return market_get_price(market_code, sina_code)
return raw["price"] if raw else None
def get_by_ths_code(ths_code: str) -> Optional[dict]:
for p in PRODUCTS:
main = resolve_main_contract(p)
if main and main["ths_code"].lower() == ths_code.lower():
return main
sina = ths_to_sina_code(ths_code)
if sina:
raw = _fetch_sina_raw(sina)
if raw:
return {
"name": raw["name"],
"ths_code": ths_code,
"sina_code": sina,
"exchange": "",
"contract": ths_code,
}
return None
+2 -1
View File
@@ -10,7 +10,8 @@
<input type="text" class="symbol-input" placeholder="输入中文名或同花顺代码" autocomplete="off" required> <input type="text" class="symbol-input" placeholder="输入中文名或同花顺代码" autocomplete="off" required>
<input type="hidden" name="symbol" required> <input type="hidden" name="symbol" required>
<input type="hidden" name="symbol_name"> <input type="hidden" name="symbol_name">
<input type="hidden" name="sina_code" required> <input type="hidden" name="market_code" required>
<input type="hidden" name="sina_code">
<div class="symbol-dropdown"></div> <div class="symbol-dropdown"></div>
<div class="symbol-selected"></div> <div class="symbol-selected"></div>
</div> </div>
+2 -1
View File
@@ -10,7 +10,8 @@
<input type="text" class="symbol-input" placeholder="输入中文名或同花顺代码" autocomplete="off" required> <input type="text" class="symbol-input" placeholder="输入中文名或同花顺代码" autocomplete="off" required>
<input type="hidden" name="symbol" required> <input type="hidden" name="symbol" required>
<input type="hidden" name="symbol_name"> <input type="hidden" name="symbol_name">
<input type="hidden" name="sina_code" required> <input type="hidden" name="market_code" required>
<input type="hidden" name="sina_code">
<div class="symbol-dropdown"></div> <div class="symbol-dropdown"></div>
<div class="symbol-selected"></div> <div class="symbol-selected"></div>
</div> </div>
+15
View File
@@ -3,6 +3,21 @@
{% block content %} {% block content %}
<h1 class="page-title">系统设置</h1> <h1 class="page-title">系统设置</h1>
<div class="card">
<h2>同花顺行情(iFinD HTTP</h2>
<form action="{{ url_for('settings') }}" method="post" class="form-row">
<input type="hidden" name="action" value="ths">
<input name="ths_refresh_token" type="text" placeholder="refresh_token" value="{{ ths_token }}" style="flex:1;min-width:300px">
<button type="submit" class="btn-primary">保存</button>
</form>
<p style="font-size:.8rem;color:#888;margin-top:.75rem">
当前行情源:<strong>{{ quote_source }}</strong>(auto=优先同花顺,失败回退新浪)。
在 iFinD 接口包「超级命令 → 工具」查询 refresh_token
或前往 <a href="https://quantapi.10jqka.com.cn/" target="_blank" style="color:#4cc2ff">同花顺数据接口</a> 申请试用。
未配置 token 时自动使用新浪行情。
</p>
</div>
<div class="card"> <div class="card">
<h2>企业微信推送</h2> <h2>企业微信推送</h2>
<form action="{{ url_for('settings') }}" method="post" class="form-row"> <form action="{{ url_for('settings') }}" method="post" class="form-row">