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

414 lines
16 KiB
Python

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"(?<!\d)(\d{6})(?!\d)", text)
if not match:
return ""
code = match.group(1)
if re.fullmatch(r"\d{6}\.(?:SH|SZ|BJ)", text):
return text
suffix = "BJ" if code.startswith(("4", "8", "9")) else "SH" if code.startswith("6") else "SZ"
return f"{code}.{suffix}"
def _event_time(value: Any) -> str:
text = str(value or "").strip()
match = re.search(r"(?<!\d)(\d{1,2}):(\d{2})(?::\d{2})?(?!\d)", text)
if match:
return f"{int(match.group(1)):02d}:{match.group(2)}"
compact = re.search(r"(?<!\d)(\d{2})(\d{2})(\d{2})(?!\d)", text)
return f"{compact.group(1)}:{compact.group(2)}" if compact else ""
def _event_integer(value: Any) -> int | None:
try:
return max(0, int(float(value))) if value not in (None, "") else None
except (TypeError, ValueError):
return None