Files
DinQuant/backend_api_python/app/data_sources/tencent.py
T
Dinger 8563e4ea53 v3.0.1
Signed-off-by: Dinger <quantdinger@gmail.com>
2026-04-07 22:47:07 +08:00

239 lines
6.8 KiB
Python

"""
Tencent market data helpers (no API key).
Provides:
- Quote: https://qt.gtimg.cn/q=sh600519 / sz000001 / hk00700
- Kline: https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=CODE,PERIOD,,,COUNT,ADJ
This is used as a stable alternative when Yahoo/yfinance gets rate-limited.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
import requests
from app.data_sources.rate_limiter import get_request_headers, retry_with_backoff, get_tencent_limiter
from app.utils.logger import get_logger
logger = get_logger(__name__)
def normalize_cn_code(symbol: str) -> str:
"""
Normalize A-share symbol to Tencent code: sh600519 / sz000001.
Accepts:
- 600519 / 600519.SH / 600519.SS
- 000001 / 000001.SZ
"""
s = (symbol or "").strip().upper()
if not s:
return s
if s.endswith(".SH"):
s = s[:-3]
return f"SH{s}"
if s.endswith(".SS"):
s = s[:-3]
return f"SH{s}"
if s.endswith(".SZ"):
s = s[:-3]
return f"SZ{s}"
if s.isdigit() and len(s) == 6:
return ("SH" + s) if s.startswith("6") else ("SZ" + s)
return s
def normalize_hk_code(symbol: str) -> str:
"""
Normalize HK stock symbol to Tencent code: hk00700 (5 digits).
Accepts:
- 700 / 0700 / 00700.HK / 0700.HK
"""
s = (symbol or "").strip().upper()
if not s:
return s
if s.endswith(".HK"):
s = s[:-3]
if s.isdigit():
return "HK" + s.zfill(5)
# If user already passed HKxxxxx
if s.startswith("HK") and s[2:].isdigit():
return "HK" + s[2:].zfill(5)
return s
def _lower_code(code: str) -> str:
return (code or "").strip().lower()
@retry_with_backoff(max_attempts=3, base_delay=1.2, max_delay=8.0, exceptions=(Exception,))
def fetch_quote(code: str, timeout: int = 8) -> Optional[List[str]]:
"""
Returns the raw '~' split array from qt.gtimg.cn, or None.
"""
c = _lower_code(code)
if not c:
return None
limiter = get_tencent_limiter()
limiter.wait()
url = f"https://qt.gtimg.cn/q={c}"
resp = requests.get(url, headers=get_request_headers(referer="https://qt.gtimg.cn/"), timeout=timeout)
# Tencent quote is often GBK encoded
try:
resp.encoding = "gbk"
except Exception:
pass
text = (resp.text or "").strip()
if not text or "~" not in text:
return None
# Format: v_sh600519="1~NAME~CODE~LAST~PREV~OPEN~..."
try:
start = text.index('="') + 2
end = text.rindex('"')
payload = text[start:end]
except Exception:
return None
parts = payload.split("~")
return parts if len(parts) > 5 else None
def parse_quote_to_ticker(parts: List[str]) -> Dict[str, Any]:
"""
Best-effort conversion to a unified ticker dict.
"""
def _f(i: int, default: float = 0.0) -> float:
try:
v = parts[i]
if v is None or v == "":
return default
return float(v)
except Exception:
return default
name = (parts[1] or "").strip() if len(parts) > 1 else ""
symbol = (parts[2] or "").strip() if len(parts) > 2 else ""
last_ = _f(3, 0.0)
prev = _f(4, 0.0)
open_ = _f(5, 0.0)
change = round(last_ - prev, 4) if prev else 0.0
change_pct = round(change / prev * 100, 2) if prev else 0.0
# Indices are not fully consistent across markets; keep conservative.
high = _f(33, last_) if len(parts) > 33 else last_
low = _f(34, last_) if len(parts) > 34 else last_
return {
"symbol": symbol,
"name": name,
"last": last_,
"change": change,
"changePercent": change_pct,
"high": high,
"low": low,
"open": open_ or last_,
"previousClose": prev,
"raw": parts,
}
def parse_tencent_kline_time(ds: str) -> Optional[int]:
"""Parse Tencent fqkline first column to Unix seconds (local parse, matches prior chart behavior)."""
raw = str(ds or "").strip()
if not raw:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d", "%Y/%m/%d"):
try:
return int(datetime.strptime(raw, fmt).timestamp())
except ValueError:
continue
try:
ts = int(float(raw))
if ts > 10**12:
ts = int(ts / 1000)
return ts
except Exception:
return None
def tencent_kline_rows_to_dicts(rows: List[Any]) -> List[Dict[str, Any]]:
"""Convert raw fqkline rows to chart dicts; ignores corporate-action tail objects on HK rows."""
out: List[Dict[str, Any]] = []
for r in rows:
if not isinstance(r, (list, tuple)) or len(r) < 6:
continue
ts = parse_tencent_kline_time(r[0])
if ts is None:
continue
try:
o, c, h, low, vol = float(r[1]), float(r[2]), float(r[3]), float(r[4]), float(r[5])
except (TypeError, ValueError):
continue
out.append(
{
"time": ts,
"open": round(o, 4),
"high": round(h, 4),
"low": round(low, 4),
"close": round(c, 4),
"volume": round(vol, 2),
}
)
return out
@retry_with_backoff(max_attempts=3, base_delay=1.2, max_delay=8.0, exceptions=(Exception,))
def fetch_kline(code: str, period: str, count: int = 300, adj: str = "qfq", timeout: int = 10) -> List[List[str]]:
"""
Fetch kline arrays from Tencent.
period examples:
- day, week, month (supported by Tencent fqkline)
Note: Minute periods (m1/m5/…) return **bad params** on this endpoint; use AkShare in ``asia_stock_kline``.
"""
c = _lower_code(code)
if not c:
return []
limiter = get_tencent_limiter()
limiter.wait()
url = "https://web.ifzq.gtimg.cn/appstock/app/fqkline/get"
params = {"param": f"{c},{period},,,{int(count)},{adj}"}
resp = requests.get(url, headers=get_request_headers(referer="https://gu.qq.com/"), params=params, timeout=timeout)
data = resp.json() if resp.text else {}
if not isinstance(data, dict) or int(data.get("code", 0)) != 0:
return []
root = (data.get("data") or {}).get(c)
if not isinstance(root, dict):
return []
# Data key variants:
# - A-share: qfqday / qfqweek / qfqm1 ...
# - HK: day / week / m1 ...
candidates = []
if adj:
candidates.append(f"{adj}{period}")
candidates.append(period)
for key in candidates:
arr = root.get(key)
if isinstance(arr, list) and arr:
return arr
# Fallback: search any key that endswith period and is a list
for k, v in root.items():
if isinstance(v, list) and v and str(k).lower().endswith(str(period).lower()):
return v
return []