from __future__ import annotations import json import re 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 realtime_market_inputs( self, trade_date: str, previous_trade_date: str, identifiers: tuple[str, ...], ) -> dict[str, ProviderResult | dict[str, Any]]: raise ProviderError("iFinD不承担全市场盘中快照计算") def sector_members(self, representative: str, trade_date: str) -> ProviderResult: raise ProviderError("iFinD is not the Shenwan constituent authority") def heaven_inputs( self, representative: str, trade_date: str ) -> dict[str, ProviderResult | None]: raise ProviderError("iFinD deterministic Heaven inputs are not enabled") def heaven_realtime_inputs( self, representative: str, trade_date: str, previous_trade_date: str ) -> dict[str, ProviderResult | None]: raise ProviderError("iFinD deterministic Heaven inputs are not enabled") 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 event_reasons(self, trade_date: str) -> ProviderResult: date = datetime.fromisoformat(_display(trade_date)).date() display = f"{date.year}年{date.month}月{date.day}日" queries = { "limit_up": ( f"{display}涨停股票,股票代码、股票简称、涨停原因、" "首次涨停时间、最终涨停时间、开板次数" ), "broken": ( f"{display}曾涨停但收盘未涨停的股票,股票代码、股票简称、" "涨停原因、首次涨停时间、开板次数" ), "limit_down": f"{display}跌停股票,股票代码、股票简称、跌停原因", } rows: list[dict[str, Any]] = [] completed = 0 for event_type, query in queries.items(): try: payload = self._request( "smart_stock_picking", {"searchstring": query, "searchtype": "stock"}, ) except ProviderError: continue completed += 1 for raw in _result( payload, "event", "not_applicable", SnapshotState.FINAL ).rows: identifier = _event_identifier(raw) if not identifier: continue reason_tokens = ( ("跌停原因", "风险线索", "原因") if event_type == "limit_down" else ("涨停原因类别", "涨停原因", "触板逻辑", "原因") ) rows.append( { "event_type": event_type, "identifier": identifier, "reason": str(_event_field(raw, reason_tokens) or "").strip(), "first_time": _event_time( _event_field( raw, ("首次涨停时间", "首次触板时间", "首次封板时间") ) ), "last_time": _event_time( _event_field( raw, ("最终涨停时间", "最后涨停时间", "最后封板时间") ) ), "open_times": _event_integer( _event_field(raw, ("开板次数", "打开涨停次数")) ), } ) return ProviderResult( tuple(rows), ObservationMetadata( source=self.source, observed_at=datetime.now(SHANGHAI), unit="event", adjustment="not_applicable", freshness_seconds=0, coverage=completed / len(queries), state=SnapshotState.FINAL, 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 def _event_field(row: dict[str, Any], tokens: tuple[str, ...]) -> Any: for token in tokens: for key, value in row.items(): if token in str(key) and value not in (None, ""): return value return None def _event_identifier(row: dict[str, Any]) -> str: value = _event_field(row, ("股票代码", "证券代码", "代码", "thscode")) text = str(value or "").strip().upper() match = re.search(r"(? str: text = str(value or "").strip() match = re.search(r"(? int | None: try: return max(0, int(float(value))) if value not in (None, "") else None except (TypeError, ValueError): return None