from __future__ import annotations import json import threading import urllib.error import urllib.request from collections.abc import Callable from datetime import datetime from typing import Any from zoneinfo import ZoneInfo from backend.data.contracts import ( DataSource, DataUsage, ObservationMetadata, ProviderResult, SnapshotState, ) from backend.data.providers.base import ProviderError SHANGHAI = ZoneInfo("Asia/Shanghai") class IfindProvider: source = DataSource.IFIND base_url = "https://quantapi.51ifind.com/api/v1" auth_error_codes = {-1302, -1303, -1304, -4302, -4303} def __init__( self, refresh_token: str | None | Callable[[], str | None], access_token: str | None | Callable[[], str | None], timeout: int = 15, ) -> None: self._refresh_provider = refresh_token if callable(refresh_token) else lambda: refresh_token self._access_provider = access_token if callable(access_token) else lambda: access_token self._issued_access = "" self._timeout = timeout self._lock = threading.Lock() @property def configured(self) -> bool: return bool(self._refresh() or self._configured_access()) def calendar(self, start_date: str, end_date: str) -> ProviderResult: raise ProviderError("iFinD is not the calendar authority") def entities(self) -> ProviderResult: raise ProviderError("iFinD is not the entity-directory authority") def daily(self, entity_type: str, identifier: str, end_date: str) -> ProviderResult: end = datetime.strptime(_compact(end_date), "%Y%m%d") start = end.replace(year=end.year - 1).strftime("%Y-%m-%d") payload = self._request( "cmd_history_quotation", { "codes": identifier, "indicators": "open,high,low,close,volume,amount", "startdate": start, "enddate": end.strftime("%Y-%m-%d"), "functionpara": {"CPS": "forward1", "Fill": "Omit"}, }, ) return _result(payload, "yuan/share", "forward", SnapshotState.ARCHIVE) def minute(self, entity_type: str, identifier: str, trade_date: str) -> ProviderResult: date = _display(trade_date) payload = self._request( "high_frequency", { "codes": identifier, "indicators": "open,high,low,close,volume,amount,avgPrice", "starttime": f"{date} 09:30:00", "endtime": f"{date} 15:00:00", "functionpara": { "CPS": "forward1", "Fill": "Previous", "Timeformat": "LocalTime", "Interval": "1", "Limitstart": "09:30:00", "Limitend": "15:00:00", }, }, ) return _result(payload, "yuan/share", "forward", SnapshotState.REALTIME) def snapshot_inputs( self, trade_date: str, previous_trade_date: str ) -> dict[str, ProviderResult | dict[str, Any]]: raise ProviderError("iFinD is not the post-close snapshot authority") def sector_members(self, representative: str, trade_date: str) -> ProviderResult: raise ProviderError("iFinD is not the Shenwan constituent authority") def market_insight( self, kind: str, trade_date: str, previous_trade_date: str = "", identifier: str = "", ) -> dict[str, ProviderResult | None]: raise ProviderError("iFinD只承担许可范围内的动态竞价快照") def realtime_snapshots( self, identifiers: tuple[str, ...], start_time: str, end_time: str ) -> ProviderResult: rows: list[dict[str, Any]] = [] for offset in range(0, len(identifiers), 80): batch = identifiers[offset : offset + 80] if not batch: continue payload = self._request( "snap_shot", { "codes": ",".join(batch), "indicators": "latest,volume,amount,preClose,turnoverRatio,volumeRatio," "bid1,bidSize1,ask1,askSize1", "starttime": start_time, "endtime": end_time, }, ) rows.extend(_result(payload, "mixed", "not_applicable", SnapshotState.REALTIME).rows) covered = {str(row.get("thscode") or "") for row in rows if row.get("thscode")} return ProviderResult( tuple(rows), ObservationMetadata( source=self.source, observed_at=datetime.now(SHANGHAI), unit="mixed", adjustment="not_applicable", freshness_seconds=0, coverage=min(len(covered) / max(len(identifiers), 1), 1), state=SnapshotState.REALTIME, usage=DataUsage.CALCULATION, ), ) def screener_inputs(self, trade_dates: tuple[str, ...]) -> dict[str, ProviderResult | None]: raise ProviderError("iFinD尚未批准用于盘后因子批量计算") def _request(self, endpoint: str, body: dict[str, Any]) -> dict[str, Any]: if not self.configured: raise ProviderError("实时行情服务尚未配置") payload = self._post(endpoint, body, self._access()) code = _error_code(payload) if self._auth_error(payload) and self._refresh(): payload = self._post(endpoint, body, self._refresh_access()) code = _error_code(payload) if code != 0: raise ProviderError( str(payload.get("errmsg") or payload.get("message") or "实时行情服务拒绝请求") ) return payload def _access(self) -> str: with self._lock: if self._issued_access: return self._issued_access configured = self._configured_access() if configured: return configured refresh = self._refresh() if not refresh: raise ProviderError("实时行情服务尚未配置") payload = self._post("get_access_token", {}, "", refresh) token = str((payload.get("data") or {}).get("access_token") or "").strip() if not token: raise ProviderError("实时行情服务授权失败") self._issued_access = token return token def _refresh_access(self) -> str: refresh = self._refresh() if not refresh: raise ProviderError("实时行情服务授权失败") with self._lock: payload = self._post("get_access_token", {}, "", refresh) token = str((payload.get("data") or {}).get("access_token") or "").strip() if not token: raise ProviderError("实时行情服务授权失败") self._issued_access = token return token def _auth_error(self, payload: dict[str, Any]) -> bool: message = str(payload.get("errmsg") or payload.get("message") or "").casefold() return ( _error_code(payload) in self.auth_error_codes or "token" in message or "鉴权" in message ) def _refresh(self) -> str: return str(self._refresh_provider() or "").strip() def _configured_access(self) -> str: return str(self._access_provider() or "").strip() def _post( self, endpoint: str, body: dict[str, Any], access: str, refresh: str = "" ) -> dict[str, Any]: headers = { "Accept": "application/json", "Content-Type": "application/json", "User-Agent": "XiaobaiReview/2", "ifindlang": "cn", } if access: headers["access_token"] = access if refresh: headers["refresh_token"] = refresh request = urllib.request.Request( f"{self.base_url}/{endpoint}", data=json.dumps(body, ensure_ascii=False).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.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc: raise ProviderError("实时行情服务请求失败") from exc if not isinstance(payload, dict): raise ProviderError("实时行情服务返回格式无效") return payload def _result( payload: dict[str, Any], unit: str, adjustment: str, state: SnapshotState ) -> ProviderResult: tables = payload.get("tables") or (payload.get("data") or {}).get("tables") or [] if isinstance(tables, dict): tables = [tables] rows: list[dict[str, Any]] = [] for block in tables: columns = block.get("table") or {} if not columns: continue times = block.get("time") or [] codes = block.get("thscode") or block.get("thscodes") or [] if isinstance(codes, str): codes = [codes] size = max( (len(value) for value in columns.values() if isinstance(value, list)), default=len(times) if isinstance(times, list) else 1, ) for index in range(size): row = { key: values[index] if isinstance(values, list) and index < len(values) else values if index == 0 else None for key, values in columns.items() } 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] rows.append(row) metadata = ObservationMetadata( source=DataSource.IFIND, observed_at=datetime.now(SHANGHAI), unit=unit, adjustment=adjustment, freshness_seconds=0, coverage=1 if rows else 0, state=state, usage=DataUsage.DISPLAY, ) return ProviderResult(tuple(rows), metadata) def _compact(value: str) -> str: normalized = value.replace("-", "") if len(normalized) != 8 or not normalized.isdigit(): raise ProviderError("日期格式无效") return normalized def _display(value: str) -> str: compact = _compact(value) return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}" def _error_code(payload: dict[str, Any]) -> int: try: return int(payload.get("errorcode", payload.get("code", 0)) or 0) except (TypeError, ValueError): return -1