287 lines
10 KiB
Python
287 lines
10 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 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 _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
|