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, Callable from datahub.adapters.base import AdapterError, MarketAdapter UrlOpen = Callable[..., Any] class IfindAdapter(MarketAdapter): """Licensed iFinD source used only inside the data hub.""" name = "ifind" 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, urlopen: UrlOpen = urllib.request.urlopen, ) -> None: self.timeout = max(3, int(timeout)) self._urlopen = urlopen 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 probe(self) -> dict[str, Any]: started = time.perf_counter() if not self.configured: return { "provider": self.name, "configured": False, "state": "unconfigured", "message": "iFinD token 未配置", "latency_ms": round((time.perf_counter() - started) * 1000), } try: rows = self.real_time("000001.SH", ["latest"], cache_ttl=0) state = "ok" if rows else "empty" return { "provider": self.name, "configured": True, "state": state, "latency_ms": round((time.perf_counter() - started) * 1000), } except AdapterError as exc: return { "provider": self.name, "configured": True, "state": "error", "message": str(exc), "latency_ms": round((time.perf_counter() - started) * 1000), } def fetch(self, dataset: str, params: dict[str, Any]) -> list[dict[str, Any]]: if dataset == "wencai": return self.wencai( str(params.get("query") or params.get("searchstring") or ""), str(params.get("search_type") or params.get("searchtype") or "stock"), int(params.get("cache_ttl") or 300), ) if dataset == "snapshots": return self.snapshots( params.get("codes") or "", _indicators(params.get("indicators")), str(params.get("start_time") or ""), str(params.get("end_time") or ""), int(params.get("cache_ttl") or 8), ) if dataset == "history": return self.history( params.get("codes") or "", _indicators(params.get("indicators") or ["close", "volume", "amount"]), str(params.get("start_date") or ""), str(params.get("end_date") or ""), int(params.get("cache_ttl") or 300), ) if dataset == "realtime": return self.real_time( params.get("codes") or "", _indicators(params.get("indicators") or ["latest"]), int(params.get("cache_ttl") or 10), ) if dataset == "intraday": return self.intraday( str(params.get("code") or params.get("codes") or ""), str(params.get("start_time") or ""), str(params.get("end_time") or ""), int(params.get("cache_ttl") or 20), ) raise AdapterError(f"{self.name} unsupported dataset: {dataset}") def normalize(self, dataset: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: return list(rows) 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 AdapterError("问财查询不能为空。") 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 _request( self, endpoint: str, body: dict[str, Any], cache_key: str = "", cache_ttl: int = 0, ) -> dict[str, Any]: if not self.configured: raise AdapterError("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 AdapterError("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 AdapterError("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": "XiaobaiDatahub/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 self._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 AdapterError(f"iFinD HTTP {exc.code}{f':{detail[:160]}' if detail else ''}") from exc except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc: raise AdapterError("iFinD 数据请求失败。") from exc if not isinstance(payload, dict): raise AdapterError("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 AdapterError(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 AdapterError("iFinD 证券代码不能为空。") if len(values) > 100: raise AdapterError("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 AdapterError("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 def _indicators(value: Any) -> list[str]: if isinstance(value, list): return [str(item).strip() for item in value if str(item).strip()] return [part.strip() for part in str(value or "").split(",") if part.strip()] ADAPTER = IfindAdapter()