Files
auv/backend/services/tencent.py
T
SakurasanandClaude 20b2afb0fc 修复总市值显示错误 & 板块切换数据合并问题
1. 后端: 修正腾讯API字段索引偏移 (total_market_cap [43]→[45],
   pb [45]→[46]),并处理市值单位转换(亿→元)
2. 前端: 板块切换时同步清空旧数据 + 显示加载态,避免新旧
   数据合并闪现

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 20:07:21 +08:00

301 lines
12 KiB
Python

"""腾讯财经 API 客户端"""
import httpx
import re
from urllib.parse import quote
from typing import Optional, List, Dict
# 根据代码推断市场前缀(腾讯格式)
def get_market_prefix(code: str) -> str:
if code.startswith("688") or code.startswith("60"):
return "sh"
if code.startswith("920") or code.startswith("8") or code.startswith("4"):
return "bj"
return "sz"
# 解码 \u 转义的 unicode 字符串
def decode_unicode(s: str) -> str:
try:
return s.encode("utf-8").decode("unicode_escape")
except Exception:
return s
# 解析腾讯报价文本格式(按 ~ 分隔)
def parse_tencent_data(text: str):
eq_idx = text.index('="')
if eq_idx == -1:
return None
start = eq_idx + 2
end = text.rindex('"')
if end <= start:
return None
content = text[start:end]
if not content:
return None
return content.split("~")
async def search_stock(keyword: str) -> List[dict]:
"""腾讯智能搜索:支持名称/代码/拼音模糊匹配"""
url = f"https://smartbox.gtimg.cn/s3/?t=all&q={quote(keyword, safe='')}&v=2"
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://stockapp.finance.qq.com/",
}
results = []
async with httpx.AsyncClient() as client:
try:
resp = await client.get(url, headers=headers, timeout=10)
if resp.status_code != 200:
return results
text = resp.text
eq_idx = text.index('="')
if eq_idx == -1:
return results
start = eq_idx + 2
end = text.rindex('"')
if end <= start:
return results
content = text[start:end]
if not content or content == "N":
return results
records = content.split("^")
for record in records:
if not record:
continue
parts = record.split("~")
if len(parts) < 5:
continue
market = parts[0]
code = parts[1]
name_raw = parts[2]
typ = parts[4]
is_sh_a = market == "sh" and (typ in ("GP-A", "GP-A-KCB"))
is_sz_a = market == "sz" and (typ in ("GP-A", "GP-A-CYB"))
is_bj_a = market == "bj" and (typ in ("GP-A", "GP-A-BJB"))
if (is_sh_a or is_sz_a or is_bj_a) and re.match(r"^\d{6}$", code):
name = decode_unicode(name_raw)
if name:
results.append({
"code": code,
"name": name,
"market": market.upper(),
"type": typ,
})
except Exception:
pass
return results
async def lookup_by_quote(code: str) -> Optional[dict]:
"""通过行情接口直接查询股票(用于搜索接口不支持的股票)"""
market = get_market_prefix(code)
stock_code = f"{market}{code}"
url = f"https://qt.gtimg.cn/q={stock_code}"
headers = {"User-Agent": "Mozilla/5.0"}
async with httpx.AsyncClient() as client:
try:
resp = await client.get(url, headers=headers, timeout=10)
if resp.status_code != 200:
return None
text = resp.content.decode("gbk", errors="replace")
parts = parse_tencent_data(text)
if not parts or len(parts) < 3:
return None
name = parts[1]
current_price = float(parts[3]) if parts[3] else 0
if not name or current_price == 0:
return None
return {
"code": code,
"name": name,
"market": market.upper(),
"type": get_board_type(code),
}
except Exception:
return None
def get_board_type(code: str) -> str:
if code.startswith("688"):
return "GP-A-KCB"
if code.startswith("300") or code.startswith("301"):
return "GP-A-CYB"
if code.startswith("8") or code.startswith("4") or code.startswith("920"):
return "GP-A-BJB"
return "GP-A"
async def fetch_quote(code: str) -> Optional[dict]:
"""获取实时行情"""
market = get_market_prefix(code)
stock_code = f"{market}{code}"
url = f"https://qt.gtimg.cn/q={stock_code}"
headers = {"User-Agent": "Mozilla/5.0"}
async with httpx.AsyncClient() as client:
try:
resp = await client.get(url, headers=headers, timeout=10)
if resp.status_code != 200:
return None
text = resp.content.decode("gbk", errors="replace")
parts = parse_tencent_data(text)
if not parts or len(parts) < 47:
return None
# 字段索引:1=名称, 3=当前价, 4=昨收, 5=今开, 6=成交量(手)
# 7=外盘, 8=内盘, 31=涨跌额, 32=涨跌幅%, 33=最高, 34=最低, 37=成交额(万)
# 38=换手率%, 39=市盈率, 43=振幅%, 44=流通市值(亿), 45=总市值(亿), 46=市净率
name = parts[1] or ""
current_price = float(parts[3]) if parts[3] else 0
yesterday_close = float(parts[4]) if parts[4] else 0
today_open = float(parts[5]) if parts[5] else 0
volume = float(parts[6]) if parts[6] else 0
high = float(parts[33]) if parts[33] else 0
low = float(parts[34]) if parts[34] else 0
amount = float(parts[37]) if parts[37] else 0
change_val = float(parts[31]) if parts[31] else 0
change_pct = float(parts[32]) if parts[32] else 0
outer_disk = float(parts[7]) if parts[7] else 0
inner_disk = float(parts[8]) if parts[8] else 0
turnover_rate = float(parts[38]) if parts[38] else 0 # 换手率%
pe = float(parts[39]) if parts[39] else 0 # 市盈率
# 腾讯API返回的市值单位是亿, formatMoney期望元, 需×1e8
total_market_cap = float(parts[45]) * 1e8 if parts[45] else 0 # 总市值(亿→元)
circulating_market_cap = float(parts[44]) * 1e8 if parts[44] else 0 # 流通市值(亿→元)
pb = float(parts[46]) if parts[46] else 0 # 市净率
if not name or current_price == 0:
return None
change = change_val if change_val != 0 else current_price - yesterday_close
change_percent = change_pct if change_pct != 0 else (
(current_price - yesterday_close) / yesterday_close * 100 if yesterday_close > 0 else 0
)
from datetime import datetime
now = datetime.now()
return {
"code": code,
"market": market.upper(),
"name": name,
"todayOpen": today_open,
"yesterdayClose": yesterday_close,
"currentPrice": current_price,
"high": high,
"low": low,
"volume": volume * 100,
"amount": amount,
"outerDisk": outer_disk,
"innerDisk": inner_disk,
"date": now.strftime("%Y-%m-%d"),
"time": now.strftime("%H:%M:%S"),
"change": round(change, 2),
"changePercent": round(change_percent, 2),
"turnoverRate": round(turnover_rate, 2),
"pe": round(pe, 2) if pe else 0,
"pb": round(pb, 2) if pb else 0,
"totalMarketCap": round(total_market_cap, 2),
"circulatingMarketCap": round(circulating_market_cap, 2),
}
except Exception:
return None
async def fetch_history(code: str, days: int = 90) -> List[dict]:
"""获取历史K线(前复权日K),含涨跌幅
多请求1天以计算第一条的涨跌幅,最终只返回 days 条。
"""
market = get_market_prefix(code)
stock_code = f"{market}{code}"
url = f"https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param={stock_code},day,,,{days + 1},qfq"
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://stockapp.finance.qq.com/",
}
async with httpx.AsyncClient() as client:
try:
resp = await client.get(url, headers=headers, timeout=10)
if resp.status_code != 200:
return []
data = resp.json()
stock_data = data.get("data", {}).get(stock_code, {})
raw = stock_data.get("qfqday") or stock_data.get("day") or []
if not isinstance(raw, list):
return []
result = []
prev_close = 0
for record in raw:
if not isinstance(record, list) or len(record) < 6:
continue
close = float(record[2]) if record[2] else 0
change_pct = 0
if prev_close > 0:
change_pct = (close - prev_close) / prev_close * 100
result.append({
"date": record[0],
"open": float(record[1]) if record[1] else 0,
"close": close,
"high": float(record[3]) if record[3] else 0,
"low": float(record[4]) if record[4] else 0,
"volume": int(float(record[5])) if record[5] else 0,
"changePercent": round(change_pct, 2),
})
prev_close = close
# 丢弃最旧1条(它的 prev_close=0 导致涨跌幅为0),只保留最新 days 条
if len(result) > 1:
result = result[1:]
if len(result) > days:
result = result[-days:]
return result
except Exception:
return []
async def fetch_kline_map(code: str, days: int = 30) -> dict:
"""获取K线数据并返回 { date: { close, changePercent, turnover } } 映射"""
market = get_market_prefix(code)
stock_code = f"{market}{code}"
url = f"https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param={stock_code},day,,,{days + 30},qfq"
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://stockapp.finance.qq.com/",
}
kline_map = {}
async with httpx.AsyncClient() as client:
try:
resp = await client.get(url, headers=headers, timeout=10)
if resp.status_code != 200:
return kline_map
data = resp.json()
stock_data = data.get("data", {}).get(stock_code, {})
raw = stock_data.get("qfqday") or stock_data.get("day") or []
prev_close = 0
for record in raw:
if not isinstance(record, list) or len(record) < 6:
continue
try:
date = record[0]
close = float(record[2]) if record[2] else 0
turnover = 0
if len(record) > 6 and isinstance(record[6], (int, float, str)):
try:
turnover = float(record[6])
except (ValueError, TypeError):
turnover = 0
change_pct = 0
if prev_close > 0:
change_pct = (close - prev_close) / prev_close * 100
kline_map[date] = {"close": close, "changePercent": change_pct, "turnover": turnover}
prev_close = close
except Exception:
continue
except Exception:
pass
return kline_map