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 = '
无匹配品种,可输入同花顺合约如 ag2606
'; + dropdown.innerHTML = '
无匹配,可输入同花顺代码如 ag2608
'; } else { items.forEach(function (item) { const div = document.createElement('div'); div.className = 'symbol-option'; div.innerHTML = item.display + - '
同花顺 ' + item.ths_code + ' · ' + item.exchange + '
'; + '
同花顺 ' + item.ths_code + + (item.market_code ? ' · ' + item.market_code : '') + + ' · ' + item.exchange + '
'; div.addEventListener('mousedown', function (e) { e.preventDefault(); selectItem(item); @@ -44,6 +49,7 @@ input.addEventListener('input', function () { hiddenThs.value = ''; hiddenName.value = ''; + if (hiddenMarket) hiddenMarket.value = ''; if (hiddenSina) hiddenSina.value = ''; selectedEl.textContent = ''; const q = input.value.trim(); @@ -81,15 +87,15 @@ if (!form.querySelector('.symbol-wrap')) return; form.addEventListener('submit', function (e) { 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()) { e.preventDefault(); alert('请从下拉列表选择品种'); return; } - if (sina && !sina.value.trim()) { + if (market && !market.value.trim()) { e.preventDefault(); - alert('请从下拉列表选择品种(需含同花顺合约代码)'); + alert('请从下拉列表选择品种(需含同花顺行情代码)'); } }); }); diff --git a/symbols.py b/symbols.py index e8e937b..da94a00 100644 --- a/symbols.py +++ b/symbols.py @@ -1,15 +1,14 @@ """ 期货品种与同花顺代码映射。 -界面展示同花顺格式(如 ag2606、SR609、IF2606),行情通过新浪 API(内部 sina_code)获取。 +展示同花顺合约代码(ag2608);行情优先走同花顺 iFinD HTTP,回退新浪。 """ import re import time from datetime import date 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 = [ {"name": "白银", "ths": "ag", "sina": "AG", "exchange": "上期所", "ex": "SHFE"}, {"name": "黄金", "ths": "au", "sina": "AU", "exchange": "上期所", "ex": "SHFE"}, @@ -64,35 +63,11 @@ PRODUCTS = [ ] _MAIN_CACHE: dict[str, tuple[float, dict]] = {} -_CACHE_TTL = 300 # 5 分钟 - - -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 +_CACHE_TTL = 300 def build_ths_code(product: dict, year: int, month: int) -> str: - """同花顺合约代码。""" + """同花顺软件内显示的合约代码。""" ex = product["ex"] letters = product["ths"] 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}" +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: - """新浪行情代码(用于拉价)。""" - ex = product["ex"] letters = product["sina"] suffix = f"{year % 100:02d}{month:02d}" - if ex == "CFFEX": + if product["ex"] == "CFFEX": return f"CFF_RE_{letters}{suffix}" return f"nf_{letters}{suffix}" def build_sina_main_code(product: dict) -> str: - """新浪主力连续代码。""" - ex = product["ex"] letters = product["sina"] - if ex == "CFFEX": + if product["ex"] == "CFFEX": return f"CFF_RE_{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() if not code: return None - # CFFEX / 四位月份: IF2606, ag2606 - m = re.match(r"^([A-Za-z]+)(\d{4})$", code) - if m: - letters, digits = m.group(1), m.group(2) + m4 = re.match(r"^([A-Za-z]+)(\d{4})$", code) + if m4: + letters, digits = m4.group(1), m4.group(2) + year = 2000 + int(digits[:2]) + month = int(digits[2:]) + if not 1 <= month <= 12: + 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() - for p in PRODUCTS: - if p["ths"].upper() == letters_up or p["sina"] == letters_up: - year = 2000 + int(digits[:2]) - month = int(digits[2:]) - if 1 <= month <= 12: - return build_sina_code(p, year, month) 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) if m3: letters, digits = m3.group(1), m3.group(2) - letters_up = letters.upper() y_digit = int(digits[0]) month = int(digits[1:]) - if 1 <= month <= 12: - year = date.today().year - decade = year // 10 * 10 - candidate = decade + y_digit - if candidate < year - 1: - candidate += 10 - for p in PRODUCTS: - if p["ths"].upper() == letters_up or p["sina"] == letters_up: - return build_sina_code(p, candidate, month) + if not 1 <= month <= 12: + return None + year = date.today().year + decade = year // 10 * 10 + candidate = decade + y_digit + if candidate < year - 1: + candidate += 10 + product = _find_product_by_letters(letters) + if product: + 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 +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]: - """按成交量选取当前主力月份合约。""" cache_key = product["sina"] now = time.time() cached = _MAIN_CACHE.get(cache_key) @@ -177,30 +193,27 @@ def resolve_main_contract(product: dict) -> Optional[dict]: cm -= 12 cy += 1 sina = build_sina_code(product, cy, cm) - raw = _fetch_sina_raw(sina) + raw = fetch_raw_for_volume(sina) if raw and raw["volume"] > 0: - ths = build_ths_code(product, cy, cm) - item = { - "name": product["name"], - "ths_code": ths, - "sina_code": sina, - "exchange": product["exchange"], - "contract": f"主力 {ths}", - "display": f"{product['name']} 主力 {ths}", - "volume": raw["volume"], - } + item = _make_symbol_item(product, cy, cm, raw["volume"]) if best is None or raw["volume"] > best["volume"]: best = item if best is None: sina_main = build_sina_main_code(product) - raw = _fetch_sina_raw(sina_main) + raw = fetch_raw_for_volume(sina_main) if raw: 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 = { "name": product["name"], "ths_code": ths_main, + "market_code": f"{ths_main}.{suffix}", "sina_code": sina_main, "exchange": product["exchange"], "contract": f"主力连续 {ths_main}", @@ -227,44 +240,24 @@ def search_symbols(query: str) -> list: if main: results.append(main) - # 用户直接输入同花顺合约代码 if not results and len(q) >= 3: - sina = ths_to_sina_code(query.strip()) - if sina: - raw = _fetch_sina_raw(sina) - if raw: - results.append({ - "name": raw["name"], - "ths_code": query.strip(), - "sina_code": sina, - "exchange": "", - "contract": query.strip(), - "display": f"{raw['name']} ({query.strip()})", - "volume": raw.get("volume", 0), - }) + codes = ths_to_codes(query.strip()) + if codes: + raw = fetch_raw_for_volume(codes["sina_code"]) + name = raw["name"] if raw else query.strip() + results.append({ + "name": name, + "ths_code": codes["ths_code"], + "market_code": codes["market_code"], + "sina_code": codes["sina_code"], + "exchange": "", + "contract": codes["ths_code"], + "display": f"{name} ({codes['ths_code']})", + "volume": raw.get("volume", 0) if raw else 0, + }) return results[:12] -def get_price(sina_code: str) -> Optional[float]: - raw = _fetch_sina_raw(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 +def get_price(market_code: str, sina_code: str = "") -> Optional[float]: + return market_get_price(market_code, sina_code) diff --git a/templates/keys.html b/templates/keys.html index 993592f..c6c6f68 100644 --- a/templates/keys.html +++ b/templates/keys.html @@ -10,7 +10,8 @@ - + +
diff --git a/templates/plans.html b/templates/plans.html index 20fdb50..1d05ead 100644 --- a/templates/plans.html +++ b/templates/plans.html @@ -10,7 +10,8 @@ - + +
diff --git a/templates/settings.html b/templates/settings.html index c8ae897..0b1e62e 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -3,6 +3,21 @@ {% block content %}

系统设置

+
+

同花顺行情(iFinD HTTP)

+
+ + + +
+

+ 当前行情源:{{ quote_source }}(auto=优先同花顺,失败回退新浪)。 + 在 iFinD 接口包「超级命令 → 工具」查询 refresh_token, + 或前往 同花顺数据接口 申请试用。 + 未配置 token 时自动使用新浪行情。 +

+
+

企业微信推送