Files
auv/backend/services/tencent.py
T
Sakurasan b1f216b2a4 feat: K线增加分钟/小时周期切换,拆分独立KLineCard组件
- 后端:腾讯 mkline 新增 /api/stock/history-minute(m1/m5/m15/m30/m60)
- 新组件 kline-card.tsx:自包含周期/折线蜡烛/指标开关与K线数据加载,
  切周期只重拉K线,不刷新页面其他模块
- 详情页瘦身为独立模块:K线图 / 今开最高最低昨收 / 每日行情明细互不耦合
- 时间统一按 UTC 解析传 Unix 秒,修复日线 invalid date/N/A 与分钟线时区问题
- 资金流向失败降级为非致命,不再导致整页报错
- vite 构建拆分 recharts/lightweight-charts/router 独立 chunk
2026-08-28 17:43:11 +08:00

359 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""腾讯财经 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_history_minute(code: str, period: str = "60", count: int = 320) -> List[dict]:
"""获取分钟级 K 线(腾讯 mkline 接口)
period: m1/m5/m15/m30/m60(60=小时线)
返回格式与日K一致,date 为 'YYYY-MM-DD HH:MM'(北京时间)。
"""
market = get_market_prefix(code)
stock_code = f"{market}{code}"
# 腾讯 mkline:param=代码,周期,,数量
url = f"https://ifzq.gtimg.cn/appstock/app/kline/mkline?param={stock_code},{period},,{count}"
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(period) 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
# record[0] = 'YYYYMMDDHHMM',转成 'YYYY-MM-DD HH:MM'
raw_dt = str(record[0])
try:
dt_str = f"{raw_dt[0:4]}-{raw_dt[4:6]}-{raw_dt[6:8]} {raw_dt[8:10]}:{raw_dt[10:12]}"
except Exception:
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": dt_str,
"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 涨跌幅失真)
if len(result) > 1:
result = result[1:]
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