from __future__ import annotations import copy import json import threading import time import urllib.error import urllib.request from datetime import datetime, timedelta from typing import Any class IfindError(RuntimeError): pass class IfindHttpClient: BASE_URL = "https://quantapi.51ifind.com/api/v1" AUTH_ENDPOINT = "get_access_token" AUTH_ERROR_CODES = {-1302, -1303, -1304, -4302, -4303} def __init__( self, refresh_token: str = "", access_token: str = "", timeout: int = 15, ) -> None: self.timeout = max(3, int(timeout)) self._refresh_token = str(refresh_token or "").strip() self._access_token = str(access_token or "").strip() self._access_expires_at: datetime | None = None self._token_lock = threading.Lock() self._cache_lock = threading.Lock() self._cache: dict[str, dict[str, Any]] = {} @property def configured(self) -> bool: return bool(self._refresh_token or self._access_token) def set_credentials(self, refresh_token: str, access_token: str = "") -> None: refresh_token = str(refresh_token or "").strip() access_token = str(access_token or "").strip() with self._token_lock: refresh_changed = refresh_token != self._refresh_token self._refresh_token = refresh_token if access_token or refresh_changed: self._access_token = access_token self._access_expires_at = None if refresh_changed: with self._cache_lock: self._cache.clear() def status(self) -> dict[str, Any]: return { "configured": self.configured, "access_ready": bool(self._access_token), "access_expires_at": ( self._access_expires_at.isoformat(timespec="seconds") if self._access_expires_at else "" ), } def test_connection(self) -> dict[str, Any]: payload = self.real_time( "000001.SH", ["open", "high", "low", "latest", "preClose"], cache_ttl=0, ) return { "ok": bool(payload), "sample_time": str(payload[0].get("time") or "") if payload else "", } def real_time( self, codes: str | list[str], indicators: list[str], cache_ttl: int = 10, ) -> list[dict[str, Any]]: code_text = self._codes(codes) payload = self._request( "real_time_quotation", {"codes": code_text, "indicators": ",".join(indicators)}, cache_key=f"rq:{code_text}:{','.join(indicators)}", cache_ttl=cache_ttl, ) return self._table_rows(payload) def history( self, codes: str | list[str], indicators: list[str], start_date: str, end_date: str, cache_ttl: int = 300, ) -> list[dict[str, Any]]: code_text = self._codes(codes) payload = self._request( "cmd_history_quotation", { "codes": code_text, "indicators": ",".join(indicators), "startdate": self._display_date(start_date), "enddate": self._display_date(end_date), "functionpara": {"CPS": "forward1", "Fill": "Omit"}, }, cache_key=f"hq:{code_text}:{start_date}:{end_date}:{','.join(indicators)}", cache_ttl=cache_ttl, ) return self._table_rows(payload) def intraday( self, code: str, start_time: str, end_time: str, cache_ttl: int = 20, ) -> list[dict[str, Any]]: indicators = ["open", "high", "low", "close", "volume", "amount", "avgPrice"] payload = self._request( "high_frequency", { "codes": self._codes(code), "indicators": ",".join(indicators), "starttime": start_time, "endtime": end_time, "functionpara": { "CPS": "forward1", "Fill": "Previous", "Timeformat": "LocalTime", "Interval": "1", "Limitstart": "09:30:00", "Limitend": "15:00:00", }, }, cache_key=f"hf:{code}:{start_time}:{end_time}", cache_ttl=cache_ttl, ) return self._table_rows(payload) def snapshots( self, codes: str | list[str], indicators: list[str], start_time: str, end_time: str, cache_ttl: int = 8, ) -> list[dict[str, Any]]: code_text = self._codes(codes) payload = self._request( "snap_shot", { "codes": code_text, "indicators": ",".join(indicators), "starttime": start_time, "endtime": end_time, }, cache_key=f"ss:{code_text}:{start_time}:{end_time}:{','.join(indicators)}", cache_ttl=cache_ttl, ) return self._table_rows(payload) def wencai(self, query: str, search_type: str = "stock", cache_ttl: int = 300) -> list[dict[str, Any]]: normalized = " ".join(str(query or "").split()) if not normalized: raise IfindError("问财查询不能为空。") payload = self._request( "smart_stock_picking", {"searchstring": normalized, "searchtype": search_type}, cache_key=f"wc:{search_type}:{normalized}", cache_ttl=cache_ttl, ) return self._table_rows(payload) def report_query( self, codes: str | list[str], begin_date: str, end_date: str, cache_ttl: int = 300, ) -> list[dict[str, Any]]: code_text = self._codes(codes) payload = self._request( "report_query", { "codes": code_text, "beginrDate": self._display_date(begin_date), "endrDate": self._display_date(end_date), "outputpara": ( "reportDate:Y,thscode:Y,secName:Y,ctime:Y," "reportTitle:Y,pdfURL:Y,seq:Y" ), }, cache_key=f"report:{code_text}:{begin_date}:{end_date}", cache_ttl=cache_ttl, ) return self._table_rows(payload) def _request( self, endpoint: str, body: dict[str, Any], cache_key: str = "", cache_ttl: int = 0, ) -> dict[str, Any]: if not self.configured: raise IfindError("iFinD 尚未配置。") if cache_key and cache_ttl > 0: cached = self._cached(cache_key, cache_ttl) if cached is not None: return cached payload = self._post(endpoint, body, self._ensure_access_token()) if self._is_auth_error(payload) and self._refresh_token: self._invalidate_access_token() payload = self._post(endpoint, body, self._ensure_access_token(force=True)) self._validate_payload(payload) if cache_key and cache_ttl > 0: with self._cache_lock: self._cache[cache_key] = { "created_at": time.time(), "payload": copy.deepcopy(payload), } return payload def _ensure_access_token(self, force: bool = False) -> str: with self._token_lock: now = datetime.now().astimezone().replace(tzinfo=None) token_valid = bool(self._access_token) and ( self._access_expires_at is None or self._access_expires_at > now + timedelta(minutes=2) ) if token_valid and not force: return self._access_token if not self._refresh_token: if self._access_token: return self._access_token raise IfindError("iFinD Refresh Token 尚未配置。") payload = self._post(self.AUTH_ENDPOINT, {}, "", self._refresh_token) self._validate_payload(payload) data = payload.get("data") or {} token = str(data.get("access_token") or "").strip() if not token: raise IfindError("iFinD 未返回 Access Token。") expires_at = self._parse_datetime(data.get("expired_time")) self._access_token = token self._access_expires_at = expires_at return token def _post( self, endpoint: str, body: dict[str, Any], access_token: str, refresh_token: str = "", ) -> dict[str, Any]: headers = { "Accept": "application/json", "Content-Type": "application/json", "User-Agent": "XiaobaiReviewWeb/1.0", "ifindlang": "cn", } if access_token: headers["access_token"] = access_token if refresh_token: headers["refresh_token"] = refresh_token request = urllib.request.Request( f"{self.BASE_URL}/{endpoint}", data=json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8"), headers=headers, method="POST", ) try: with urllib.request.urlopen(request, timeout=self.timeout) as response: payload = json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: detail = "" try: detail_payload = json.loads(exc.read().decode("utf-8", errors="replace")) detail = str(detail_payload.get("errmsg") or detail_payload.get("message") or "") except (json.JSONDecodeError, OSError): pass raise IfindError(f"iFinD HTTP {exc.code}{f':{detail[:160]}' if detail else ''}") from exc except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc: raise IfindError("iFinD 数据请求失败。") from exc if not isinstance(payload, dict): raise IfindError("iFinD 返回格式不正确。") return payload def _cached(self, key: str, ttl: int) -> dict[str, Any] | None: with self._cache_lock: cached = self._cache.get(key) if not cached: return None if time.time() - float(cached.get("created_at") or 0) > ttl: self._cache.pop(key, None) return None return copy.deepcopy(cached["payload"]) def _invalidate_access_token(self) -> None: with self._token_lock: self._access_token = "" self._access_expires_at = None @classmethod def _validate_payload(cls, payload: dict[str, Any]) -> None: try: error_code = int(payload.get("errorcode") or 0) except (TypeError, ValueError): error_code = -1 if error_code != 0: message = str(payload.get("errmsg") or "未知错误") raise IfindError(f"iFinD 返回错误:{message[:200]}") @classmethod def _is_auth_error(cls, payload: dict[str, Any]) -> bool: try: error_code = int(payload.get("errorcode") or 0) except (TypeError, ValueError): error_code = 0 message = str(payload.get("errmsg") or "").casefold() return error_code in cls.AUTH_ERROR_CODES or "token" in message or "鉴权" in message @staticmethod def _table_rows(payload: dict[str, Any]) -> list[dict[str, Any]]: tables = payload.get("tables") or [] if isinstance(tables, dict): tables = [tables] rows: list[dict[str, Any]] = [] for block in tables if isinstance(tables, list) else []: if not isinstance(block, dict): continue table = block.get("table") or {} if not isinstance(table, dict): continue times = block.get("time") or [] codes = block.get("thscode") or block.get("thscodes") or [] if isinstance(codes, str): codes = [codes] lengths = [len(value) for value in table.values() if isinstance(value, list)] row_count = max(lengths or [len(times) if isinstance(times, list) else 0, 1 if table else 0]) for index in range(row_count): row: dict[str, Any] = {} if isinstance(times, list) and index < len(times): row["time"] = times[index] if codes: row["thscode"] = codes[index] if index < len(codes) else codes[0] for field, values in table.items(): if isinstance(values, list): row[field] = values[index] if index < len(values) else None elif index == 0: row[field] = values rows.append(row) return rows @staticmethod def _codes(codes: str | list[str]) -> str: if isinstance(codes, list): values = [str(code or "").strip().upper() for code in codes] else: values = [part.strip().upper() for part in str(codes or "").split(",")] values = [value for value in values if value] if not values: raise IfindError("iFinD 证券代码不能为空。") if len(values) > 100: raise IfindError("iFinD 单次证券代码过多。") return ",".join(values) @staticmethod def _display_date(value: str) -> str: compact = str(value or "").replace("-", "") if len(compact) != 8 or not compact.isdigit(): raise IfindError("iFinD 日期格式不正确。") return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}" @staticmethod def _parse_datetime(value: Any) -> datetime | None: text = str(value or "").strip() if not text: return None try: return datetime.fromisoformat(text) except ValueError: return None