Add personal license agreement and rename product section to tradable symbols.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,243 +1,248 @@
|
||||
"""
|
||||
行情拉取:默认新浪(免费,普通用户可用)。
|
||||
同花顺 iFinD HTTP 仅面向机构用户,需单独申请 token,可选开启。
|
||||
"""
|
||||
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", "sina").strip().lower()
|
||||
|
||||
|
||||
def _has_ths_token() -> bool:
|
||||
return bool(_get_refresh_token())
|
||||
|
||||
|
||||
def get_quote_source_label(*, ctp_connected: bool = False) -> str:
|
||||
"""界面展示用行情源说明。"""
|
||||
if ctp_connected:
|
||||
return "CTP 柜台(已连接)"
|
||||
source = _quote_source()
|
||||
if source == "sina":
|
||||
return "新浪(CTP 未连接时备用)"
|
||||
if source == "ths":
|
||||
return "同花顺 iFinD" if _has_ths_token() else "同花顺(未配置 token)"
|
||||
if _has_ths_token():
|
||||
return "同花顺优先,失败回退新浪"
|
||||
return "新浪(CTP 未连接时备用)"
|
||||
|
||||
|
||||
def _sina_headers() -> dict:
|
||||
return {"Referer": "https://finance.sina.com.cn"}
|
||||
|
||||
|
||||
def _parse_sina_futures_quote(parts: list) -> Optional[dict]:
|
||||
"""解析新浪 nf_/CFF_RE_ 期货行情字段。"""
|
||||
if len(parts) < 9:
|
||||
return None
|
||||
price = None
|
||||
for idx in (8, 7, 6, 5):
|
||||
if len(parts) > idx and parts[idx]:
|
||||
try:
|
||||
val = float(parts[idx])
|
||||
if val > 0:
|
||||
price = val
|
||||
break
|
||||
except ValueError:
|
||||
pass
|
||||
if price is None:
|
||||
price = 0.0
|
||||
|
||||
open_interest = 0.0
|
||||
volume = 0.0
|
||||
if len(parts) > 13 and parts[13]:
|
||||
try:
|
||||
open_interest = float(parts[13])
|
||||
except ValueError:
|
||||
pass
|
||||
if len(parts) > 14 and parts[14]:
|
||||
try:
|
||||
volume = float(parts[14])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
prev_close = None
|
||||
if len(parts) > 9 and parts[9]:
|
||||
try:
|
||||
prev_close = float(parts[9])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"name": parts[0],
|
||||
"price": price,
|
||||
"volume": volume,
|
||||
"open_interest": open_interest,
|
||||
"prev_close": prev_close,
|
||||
}
|
||||
|
||||
|
||||
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(",")
|
||||
return _parse_sina_futures_quote(parts)
|
||||
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]:
|
||||
"""
|
||||
统一取价入口。
|
||||
sina_fallback: 新浪代码 nf_AG2608(普通用户默认使用)
|
||||
market_code: 同花顺完整代码 ag2608.SHFE(仅机构 token 可用时)
|
||||
"""
|
||||
source = _quote_source()
|
||||
|
||||
# 仅在有 token 且配置为 ths/auto 时才尝试同花顺
|
||||
use_ths = source == "ths" or (source == "auto" and _has_ths_token())
|
||||
if use_ths 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)
|
||||
|
||||
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)
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""
|
||||
行情拉取:默认新浪(免费,普通用户可用)。
|
||||
同花顺 iFinD HTTP 仅面向机构用户,需单独申请 token,可选开启。
|
||||
"""
|
||||
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", "sina").strip().lower()
|
||||
|
||||
|
||||
def _has_ths_token() -> bool:
|
||||
return bool(_get_refresh_token())
|
||||
|
||||
|
||||
def get_quote_source_label(*, ctp_connected: bool = False) -> str:
|
||||
"""界面展示用行情源说明。"""
|
||||
if ctp_connected:
|
||||
return "CTP 柜台(已连接)"
|
||||
source = _quote_source()
|
||||
if source == "sina":
|
||||
return "新浪(CTP 未连接时备用)"
|
||||
if source == "ths":
|
||||
return "同花顺 iFinD" if _has_ths_token() else "同花顺(未配置 token)"
|
||||
if _has_ths_token():
|
||||
return "同花顺优先,失败回退新浪"
|
||||
return "新浪(CTP 未连接时备用)"
|
||||
|
||||
|
||||
def _sina_headers() -> dict:
|
||||
return {"Referer": "https://finance.sina.com.cn"}
|
||||
|
||||
|
||||
def _parse_sina_futures_quote(parts: list) -> Optional[dict]:
|
||||
"""解析新浪 nf_/CFF_RE_ 期货行情字段。"""
|
||||
if len(parts) < 9:
|
||||
return None
|
||||
price = None
|
||||
for idx in (8, 7, 6, 5):
|
||||
if len(parts) > idx and parts[idx]:
|
||||
try:
|
||||
val = float(parts[idx])
|
||||
if val > 0:
|
||||
price = val
|
||||
break
|
||||
except ValueError:
|
||||
pass
|
||||
if price is None:
|
||||
price = 0.0
|
||||
|
||||
open_interest = 0.0
|
||||
volume = 0.0
|
||||
if len(parts) > 13 and parts[13]:
|
||||
try:
|
||||
open_interest = float(parts[13])
|
||||
except ValueError:
|
||||
pass
|
||||
if len(parts) > 14 and parts[14]:
|
||||
try:
|
||||
volume = float(parts[14])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
prev_close = None
|
||||
if len(parts) > 9 and parts[9]:
|
||||
try:
|
||||
prev_close = float(parts[9])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"name": parts[0],
|
||||
"price": price,
|
||||
"volume": volume,
|
||||
"open_interest": open_interest,
|
||||
"prev_close": prev_close,
|
||||
}
|
||||
|
||||
|
||||
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(",")
|
||||
return _parse_sina_futures_quote(parts)
|
||||
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]:
|
||||
"""
|
||||
统一取价入口。
|
||||
sina_fallback: 新浪代码 nf_AG2608(普通用户默认使用)
|
||||
market_code: 同花顺完整代码 ag2608.SHFE(仅机构 token 可用时)
|
||||
"""
|
||||
source = _quote_source()
|
||||
|
||||
# 仅在有 token 且配置为 ths/auto 时才尝试同花顺
|
||||
use_ths = source == "ths" or (source == "auto" and _has_ths_token())
|
||||
if use_ths 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)
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user