""" 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 []