Files
xiaobaifupan/next/backend/data/providers/ifind.py
T

239 lines
8.6 KiB
Python

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 _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