234 lines
8.8 KiB
Python
234 lines
8.8 KiB
Python
"""腾讯实时行情 API 客户端
|
||
|
||
通过 qt.gtimg.cn 获取 A 股实时报价数据,无需认证。
|
||
用于补充 Tushare 的实时行情数据。
|
||
"""
|
||
|
||
import logging
|
||
import httpx
|
||
from app.data.cache import cache
|
||
from app.config import settings
|
||
from app.data.models import StockQuote
|
||
from app.db.error_logger import log_error
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
TENCENT_API_URL = "http://qt.gtimg.cn/q="
|
||
HEADERS = {
|
||
"Referer": "http://finance.qq.com",
|
||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||
}
|
||
|
||
|
||
def _ts_code_to_tencent(ts_code: str) -> str:
|
||
"""600519.SH -> sh600519"""
|
||
code, market = ts_code.split(".")
|
||
return f"{market.lower()}{code}"
|
||
|
||
|
||
def _parse_tencent_response(raw: str) -> dict | None:
|
||
"""解析腾讯行情接口返回的 GBK 编码、~分隔字段"""
|
||
try:
|
||
data_start = raw.find('"')
|
||
data_end = raw.rfind('"')
|
||
if data_start == -1 or data_end == -1:
|
||
return None
|
||
content = raw[data_start + 1:data_end]
|
||
fields = content.split("~")
|
||
if len(fields) < 48:
|
||
return None
|
||
return {
|
||
"name": fields[1],
|
||
"code": fields[2],
|
||
"price": float(fields[3]) if fields[3] else 0,
|
||
"pre_close": float(fields[4]) if fields[4] else 0,
|
||
"open": float(fields[5]) if fields[5] else 0,
|
||
"volume": float(fields[6]) if fields[6] else 0, # 手
|
||
"bid_volume": float(fields[7]) if fields[7] else 0, # 外盘
|
||
"ask_volume": float(fields[8]) if fields[8] else 0, # 内盘
|
||
"pct_chg": float(fields[32]) if fields[32] else 0, # %
|
||
"high": float(fields[33]) if fields[33] else 0,
|
||
"low": float(fields[34]) if fields[34] else 0,
|
||
"amount": float(fields[37]) if fields[37] else 0, # 万元
|
||
"turnover_rate": float(fields[38]) if fields[38] else 0, # %
|
||
"pe": float(fields[39]) if fields[39] else None,
|
||
"amplitude": float(fields[43]) if fields[43] else 0, # %
|
||
"circ_mv": float(fields[44]) if fields[44] else None, # 亿
|
||
"total_mv": float(fields[45]) if fields[45] else None, # 亿
|
||
"pb": float(fields[46]) if fields[46] else None,
|
||
"limit_up": float(fields[47]) if fields[47] else None,
|
||
"limit_down": float(fields[48]) if fields[48] else None,
|
||
"volume_ratio": float(fields[49]) if len(fields) > 49 and fields[49] else None,
|
||
}
|
||
except (IndexError, ValueError) as e:
|
||
logger.warning(f"腾讯行情解析失败: {e}")
|
||
return None
|
||
|
||
|
||
async def get_realtime_quote(ts_code: str) -> StockQuote | None:
|
||
"""获取单只股票实时行情"""
|
||
cache_key = f"rt_quote:{ts_code}"
|
||
cached = cache.get(cache_key)
|
||
if cached is not None:
|
||
return cached
|
||
|
||
symbol = _ts_code_to_tencent(ts_code)
|
||
try:
|
||
async with httpx.AsyncClient() as client:
|
||
resp = await client.get(
|
||
f"{TENCENT_API_URL}{symbol}",
|
||
headers=HEADERS,
|
||
timeout=10
|
||
)
|
||
resp.encoding = "gbk"
|
||
data = _parse_tencent_response(resp.text)
|
||
if not data or data["price"] == 0:
|
||
return None
|
||
|
||
quote = StockQuote(
|
||
ts_code=ts_code,
|
||
name=data["name"],
|
||
price=data["price"],
|
||
pct_chg=data["pct_chg"],
|
||
volume=data["volume"],
|
||
amount=data["amount"],
|
||
turnover_rate=data["turnover_rate"],
|
||
pe=data["pe"],
|
||
pb=data["pb"],
|
||
circ_mv=data["circ_mv"],
|
||
total_mv=data["total_mv"],
|
||
volume_ratio=data["volume_ratio"],
|
||
high=data["high"],
|
||
low=data["low"],
|
||
open=data["open"],
|
||
pre_close=data["pre_close"],
|
||
limit_up=data["limit_up"],
|
||
limit_down=data["limit_down"],
|
||
amplitude=data["amplitude"],
|
||
)
|
||
cache.set(cache_key, quote, settings.cache_ttl_realtime)
|
||
return quote
|
||
except Exception as e:
|
||
logger.error(f"腾讯行情获取失败 {ts_code}: {e}")
|
||
await log_error("tencent", f"腾讯行情获取失败 {ts_code}: {e}")
|
||
return None
|
||
|
||
|
||
async def get_realtime_quotes_batch(ts_codes: list[str]) -> dict[str, StockQuote]:
|
||
"""批量获取实时行情(腾讯支持逗号拼接多只)"""
|
||
results = {}
|
||
uncached = []
|
||
|
||
for code in ts_codes:
|
||
cached = cache.get(f"rt_quote:{code}")
|
||
if cached is not None:
|
||
results[code] = cached
|
||
else:
|
||
uncached.append(code)
|
||
|
||
if not uncached:
|
||
return results
|
||
|
||
# 腾讯支持批量查询,但单次不宜太多,分批(每批最多50只)
|
||
batch_size = 50
|
||
for i in range(0, len(uncached), batch_size):
|
||
batch = uncached[i:i + batch_size]
|
||
symbols = ",".join(_ts_code_to_tencent(c) for c in batch)
|
||
try:
|
||
async with httpx.AsyncClient() as client:
|
||
resp = await client.get(
|
||
f"{TENCENT_API_URL}{symbols}",
|
||
headers=HEADERS,
|
||
timeout=15
|
||
)
|
||
resp.encoding = "gbk"
|
||
lines = resp.text.strip().split("\n")
|
||
for j, line in enumerate(lines):
|
||
if j >= len(batch):
|
||
break
|
||
data = _parse_tencent_response(line)
|
||
if data and data["price"] > 0:
|
||
ts_code = batch[j]
|
||
quote = StockQuote(
|
||
ts_code=ts_code,
|
||
name=data["name"],
|
||
price=data["price"],
|
||
pct_chg=data["pct_chg"],
|
||
volume=data["volume"],
|
||
amount=data["amount"],
|
||
turnover_rate=data["turnover_rate"],
|
||
pe=data["pe"],
|
||
pb=data["pb"],
|
||
circ_mv=data["circ_mv"],
|
||
total_mv=data["total_mv"],
|
||
volume_ratio=data["volume_ratio"],
|
||
high=data["high"],
|
||
low=data["low"],
|
||
open=data["open"],
|
||
pre_close=data["pre_close"],
|
||
limit_up=data["limit_up"],
|
||
limit_down=data["limit_down"],
|
||
amplitude=data["amplitude"],
|
||
)
|
||
cache.set(f"rt_quote:{ts_code}", quote, settings.cache_ttl_realtime)
|
||
results[ts_code] = quote
|
||
except Exception as e:
|
||
logger.error(f"腾讯批量行情获取失败: {e}")
|
||
await log_error(
|
||
"tencent",
|
||
f"腾讯批量行情获取失败: {e}",
|
||
detail=f"batch_size={len(batch)}",
|
||
)
|
||
|
||
return results
|
||
|
||
|
||
async def get_index_realtime(index_codes: list[str] = None) -> dict[str, dict]:
|
||
"""获取指数实时行情
|
||
|
||
index_codes: Tushare 格式的指数代码,如 ["000001.SH", "399001.SZ"]
|
||
默认返回上证、深证、创业板
|
||
"""
|
||
if not index_codes:
|
||
index_codes = ["000001.SH", "399001.SZ", "399006.SZ"]
|
||
|
||
results = {}
|
||
symbols = ",".join(_ts_code_to_tencent(c) for c in index_codes)
|
||
|
||
try:
|
||
async with httpx.AsyncClient() as client:
|
||
resp = await client.get(
|
||
f"{TENCENT_API_URL}{symbols}",
|
||
headers=HEADERS,
|
||
timeout=10
|
||
)
|
||
resp.encoding = "gbk"
|
||
lines = resp.text.strip().split("\n")
|
||
for i, line in enumerate(lines):
|
||
if i >= len(index_codes):
|
||
break
|
||
data = _parse_tencent_response(line)
|
||
if data and data["price"] > 0:
|
||
ts_code = index_codes[i]
|
||
results[ts_code] = {
|
||
"ts_code": ts_code,
|
||
"name": data["name"],
|
||
"price": data["price"],
|
||
"pct_chg": data["pct_chg"],
|
||
"volume": data["volume"],
|
||
"amount": data["amount"],
|
||
"high": data["high"],
|
||
"low": data["low"],
|
||
"open": data["open"],
|
||
"pre_close": data["pre_close"],
|
||
}
|
||
except Exception as e:
|
||
logger.error(f"腾讯指数行情获取失败: {e}")
|
||
await log_error(
|
||
"tencent",
|
||
f"腾讯指数行情获取失败: {e}",
|
||
detail=f"indices={','.join(index_codes)}",
|
||
)
|
||
|
||
return results
|