"""腾讯实时行情 API 客户端 通过 qt.gtimg.cn 获取 A 股实时报价数据,无需认证。 用于补充 Tushare 的实时行情数据。 """ import logging import httpx from app.data.cache import cache from app.config import settings from app.data.code_utils import normalize_ts_code, split_ts_code 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 = split_ts_code(ts_code) 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: """获取单只股票实时行情""" ts_code = normalize_ts_code(ts_code) 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 = [] ts_codes = [normalize_ts_code(code) for code in ts_codes if normalize_ts_code(code)] 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"] index_codes = [normalize_ts_code(code) for code in index_codes if normalize_ts_code(code)] 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