414 lines
16 KiB
Python
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
|