up
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
"""腾讯财经 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) < 38:
|
||||
return None
|
||||
# 字段索引(1-based):1=名称, 3=当前价, 4=昨收, 5=今开, 6=成交量(手)
|
||||
# 7=外盘, 8=内盘, 31=涨跌额, 32=涨跌幅%, 33=最高, 34=最低, 37=成交额(万)
|
||||
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
|
||||
|
||||
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),
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_history(code: str, days: int = 90) -> List[dict]:
|
||||
"""获取历史K线(前复权日K),主数据源"""
|
||||
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},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 = []
|
||||
for record in raw:
|
||||
if not isinstance(record, list) or len(record) < 6:
|
||||
continue
|
||||
result.append({
|
||||
"date": record[0],
|
||||
"open": float(record[1]) if record[1] else 0,
|
||||
"close": float(record[2]) if record[2] else 0,
|
||||
"high": float(record[3]) if record[3] else 0,
|
||||
"low": float(record[4]) if record[4] else 0,
|
||||
"volume": int(record[5]) if record[5] else 0,
|
||||
})
|
||||
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
|
||||
Reference in New Issue
Block a user