386 lines
14 KiB
Python
386 lines
14 KiB
Python
from __future__ import annotations
|
||
|
||
import copy
|
||
import json
|
||
import threading
|
||
import time
|
||
import urllib.error
|
||
import urllib.request
|
||
from datetime import datetime, timedelta
|
||
from typing import Any
|
||
|
||
|
||
class IfindError(RuntimeError):
|
||
pass
|
||
|
||
|
||
class IfindHttpClient:
|
||
BASE_URL = "https://quantapi.51ifind.com/api/v1"
|
||
AUTH_ENDPOINT = "get_access_token"
|
||
AUTH_ERROR_CODES = {-1302, -1303, -1304, -4302, -4303}
|
||
|
||
def __init__(
|
||
self,
|
||
refresh_token: str = "",
|
||
access_token: str = "",
|
||
timeout: int = 15,
|
||
) -> None:
|
||
self.timeout = max(3, int(timeout))
|
||
self._refresh_token = str(refresh_token or "").strip()
|
||
self._access_token = str(access_token or "").strip()
|
||
self._access_expires_at: datetime | None = None
|
||
self._token_lock = threading.Lock()
|
||
self._cache_lock = threading.Lock()
|
||
self._cache: dict[str, dict[str, Any]] = {}
|
||
|
||
@property
|
||
def configured(self) -> bool:
|
||
return bool(self._refresh_token or self._access_token)
|
||
|
||
def set_credentials(self, refresh_token: str, access_token: str = "") -> None:
|
||
refresh_token = str(refresh_token or "").strip()
|
||
access_token = str(access_token or "").strip()
|
||
with self._token_lock:
|
||
refresh_changed = refresh_token != self._refresh_token
|
||
self._refresh_token = refresh_token
|
||
if access_token or refresh_changed:
|
||
self._access_token = access_token
|
||
self._access_expires_at = None
|
||
if refresh_changed:
|
||
with self._cache_lock:
|
||
self._cache.clear()
|
||
|
||
def status(self) -> dict[str, Any]:
|
||
return {
|
||
"configured": self.configured,
|
||
"access_ready": bool(self._access_token),
|
||
"access_expires_at": (
|
||
self._access_expires_at.isoformat(timespec="seconds")
|
||
if self._access_expires_at
|
||
else ""
|
||
),
|
||
}
|
||
|
||
def test_connection(self) -> dict[str, Any]:
|
||
payload = self.real_time(
|
||
"000001.SH",
|
||
["open", "high", "low", "latest", "preClose"],
|
||
cache_ttl=0,
|
||
)
|
||
return {
|
||
"ok": bool(payload),
|
||
"sample_time": str(payload[0].get("time") or "") if payload else "",
|
||
}
|
||
|
||
def real_time(
|
||
self,
|
||
codes: str | list[str],
|
||
indicators: list[str],
|
||
cache_ttl: int = 10,
|
||
) -> list[dict[str, Any]]:
|
||
code_text = self._codes(codes)
|
||
payload = self._request(
|
||
"real_time_quotation",
|
||
{"codes": code_text, "indicators": ",".join(indicators)},
|
||
cache_key=f"rq:{code_text}:{','.join(indicators)}",
|
||
cache_ttl=cache_ttl,
|
||
)
|
||
return self._table_rows(payload)
|
||
|
||
def history(
|
||
self,
|
||
codes: str | list[str],
|
||
indicators: list[str],
|
||
start_date: str,
|
||
end_date: str,
|
||
cache_ttl: int = 300,
|
||
) -> list[dict[str, Any]]:
|
||
code_text = self._codes(codes)
|
||
payload = self._request(
|
||
"cmd_history_quotation",
|
||
{
|
||
"codes": code_text,
|
||
"indicators": ",".join(indicators),
|
||
"startdate": self._display_date(start_date),
|
||
"enddate": self._display_date(end_date),
|
||
"functionpara": {"CPS": "forward1", "Fill": "Omit"},
|
||
},
|
||
cache_key=f"hq:{code_text}:{start_date}:{end_date}:{','.join(indicators)}",
|
||
cache_ttl=cache_ttl,
|
||
)
|
||
return self._table_rows(payload)
|
||
|
||
def intraday(
|
||
self,
|
||
code: str,
|
||
start_time: str,
|
||
end_time: str,
|
||
cache_ttl: int = 20,
|
||
) -> list[dict[str, Any]]:
|
||
indicators = ["open", "high", "low", "close", "volume", "amount", "avgPrice"]
|
||
payload = self._request(
|
||
"high_frequency",
|
||
{
|
||
"codes": self._codes(code),
|
||
"indicators": ",".join(indicators),
|
||
"starttime": start_time,
|
||
"endtime": end_time,
|
||
"functionpara": {
|
||
"CPS": "forward1",
|
||
"Fill": "Previous",
|
||
"Timeformat": "LocalTime",
|
||
"Interval": "1",
|
||
"Limitstart": "09:30:00",
|
||
"Limitend": "15:00:00",
|
||
},
|
||
},
|
||
cache_key=f"hf:{code}:{start_time}:{end_time}",
|
||
cache_ttl=cache_ttl,
|
||
)
|
||
return self._table_rows(payload)
|
||
|
||
def snapshots(
|
||
self,
|
||
codes: str | list[str],
|
||
indicators: list[str],
|
||
start_time: str,
|
||
end_time: str,
|
||
cache_ttl: int = 8,
|
||
) -> list[dict[str, Any]]:
|
||
code_text = self._codes(codes)
|
||
payload = self._request(
|
||
"snap_shot",
|
||
{
|
||
"codes": code_text,
|
||
"indicators": ",".join(indicators),
|
||
"starttime": start_time,
|
||
"endtime": end_time,
|
||
},
|
||
cache_key=f"ss:{code_text}:{start_time}:{end_time}:{','.join(indicators)}",
|
||
cache_ttl=cache_ttl,
|
||
)
|
||
return self._table_rows(payload)
|
||
|
||
def wencai(self, query: str, search_type: str = "stock", cache_ttl: int = 300) -> list[dict[str, Any]]:
|
||
normalized = " ".join(str(query or "").split())
|
||
if not normalized:
|
||
raise IfindError("问财查询不能为空。")
|
||
payload = self._request(
|
||
"smart_stock_picking",
|
||
{"searchstring": normalized, "searchtype": search_type},
|
||
cache_key=f"wc:{search_type}:{normalized}",
|
||
cache_ttl=cache_ttl,
|
||
)
|
||
return self._table_rows(payload)
|
||
|
||
def report_query(
|
||
self,
|
||
codes: str | list[str],
|
||
begin_date: str,
|
||
end_date: str,
|
||
cache_ttl: int = 300,
|
||
) -> list[dict[str, Any]]:
|
||
code_text = self._codes(codes)
|
||
payload = self._request(
|
||
"report_query",
|
||
{
|
||
"codes": code_text,
|
||
"beginrDate": self._display_date(begin_date),
|
||
"endrDate": self._display_date(end_date),
|
||
"outputpara": (
|
||
"reportDate:Y,thscode:Y,secName:Y,ctime:Y,"
|
||
"reportTitle:Y,pdfURL:Y,seq:Y"
|
||
),
|
||
},
|
||
cache_key=f"report:{code_text}:{begin_date}:{end_date}",
|
||
cache_ttl=cache_ttl,
|
||
)
|
||
return self._table_rows(payload)
|
||
|
||
def _request(
|
||
self,
|
||
endpoint: str,
|
||
body: dict[str, Any],
|
||
cache_key: str = "",
|
||
cache_ttl: int = 0,
|
||
) -> dict[str, Any]:
|
||
if not self.configured:
|
||
raise IfindError("iFinD 尚未配置。")
|
||
if cache_key and cache_ttl > 0:
|
||
cached = self._cached(cache_key, cache_ttl)
|
||
if cached is not None:
|
||
return cached
|
||
|
||
payload = self._post(endpoint, body, self._ensure_access_token())
|
||
if self._is_auth_error(payload) and self._refresh_token:
|
||
self._invalidate_access_token()
|
||
payload = self._post(endpoint, body, self._ensure_access_token(force=True))
|
||
self._validate_payload(payload)
|
||
if cache_key and cache_ttl > 0:
|
||
with self._cache_lock:
|
||
self._cache[cache_key] = {
|
||
"created_at": time.time(),
|
||
"payload": copy.deepcopy(payload),
|
||
}
|
||
return payload
|
||
|
||
def _ensure_access_token(self, force: bool = False) -> str:
|
||
with self._token_lock:
|
||
now = datetime.now().astimezone().replace(tzinfo=None)
|
||
token_valid = bool(self._access_token) and (
|
||
self._access_expires_at is None
|
||
or self._access_expires_at > now + timedelta(minutes=2)
|
||
)
|
||
if token_valid and not force:
|
||
return self._access_token
|
||
if not self._refresh_token:
|
||
if self._access_token:
|
||
return self._access_token
|
||
raise IfindError("iFinD Refresh Token 尚未配置。")
|
||
payload = self._post(self.AUTH_ENDPOINT, {}, "", self._refresh_token)
|
||
self._validate_payload(payload)
|
||
data = payload.get("data") or {}
|
||
token = str(data.get("access_token") or "").strip()
|
||
if not token:
|
||
raise IfindError("iFinD 未返回 Access Token。")
|
||
expires_at = self._parse_datetime(data.get("expired_time"))
|
||
self._access_token = token
|
||
self._access_expires_at = expires_at
|
||
return token
|
||
|
||
def _post(
|
||
self,
|
||
endpoint: str,
|
||
body: dict[str, Any],
|
||
access_token: str,
|
||
refresh_token: str = "",
|
||
) -> dict[str, Any]:
|
||
headers = {
|
||
"Accept": "application/json",
|
||
"Content-Type": "application/json",
|
||
"User-Agent": "XiaobaiReviewWeb/1.0",
|
||
"ifindlang": "cn",
|
||
}
|
||
if access_token:
|
||
headers["access_token"] = access_token
|
||
if refresh_token:
|
||
headers["refresh_token"] = refresh_token
|
||
request = urllib.request.Request(
|
||
f"{self.BASE_URL}/{endpoint}",
|
||
data=json.dumps(body, ensure_ascii=False, separators=(",", ":")).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.HTTPError as exc:
|
||
detail = ""
|
||
try:
|
||
detail_payload = json.loads(exc.read().decode("utf-8", errors="replace"))
|
||
detail = str(detail_payload.get("errmsg") or detail_payload.get("message") or "")
|
||
except (json.JSONDecodeError, OSError):
|
||
pass
|
||
raise IfindError(f"iFinD HTTP {exc.code}{f':{detail[:160]}' if detail else ''}") from exc
|
||
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
|
||
raise IfindError("iFinD 数据请求失败。") from exc
|
||
if not isinstance(payload, dict):
|
||
raise IfindError("iFinD 返回格式不正确。")
|
||
return payload
|
||
|
||
def _cached(self, key: str, ttl: int) -> dict[str, Any] | None:
|
||
with self._cache_lock:
|
||
cached = self._cache.get(key)
|
||
if not cached:
|
||
return None
|
||
if time.time() - float(cached.get("created_at") or 0) > ttl:
|
||
self._cache.pop(key, None)
|
||
return None
|
||
return copy.deepcopy(cached["payload"])
|
||
|
||
def _invalidate_access_token(self) -> None:
|
||
with self._token_lock:
|
||
self._access_token = ""
|
||
self._access_expires_at = None
|
||
|
||
@classmethod
|
||
def _validate_payload(cls, payload: dict[str, Any]) -> None:
|
||
try:
|
||
error_code = int(payload.get("errorcode") or 0)
|
||
except (TypeError, ValueError):
|
||
error_code = -1
|
||
if error_code != 0:
|
||
message = str(payload.get("errmsg") or "未知错误")
|
||
raise IfindError(f"iFinD 返回错误:{message[:200]}")
|
||
|
||
@classmethod
|
||
def _is_auth_error(cls, payload: dict[str, Any]) -> bool:
|
||
try:
|
||
error_code = int(payload.get("errorcode") or 0)
|
||
except (TypeError, ValueError):
|
||
error_code = 0
|
||
message = str(payload.get("errmsg") or "").casefold()
|
||
return error_code in cls.AUTH_ERROR_CODES or "token" in message or "鉴权" in message
|
||
|
||
@staticmethod
|
||
def _table_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||
tables = payload.get("tables") or []
|
||
if isinstance(tables, dict):
|
||
tables = [tables]
|
||
rows: list[dict[str, Any]] = []
|
||
for block in tables if isinstance(tables, list) else []:
|
||
if not isinstance(block, dict):
|
||
continue
|
||
table = block.get("table") or {}
|
||
if not isinstance(table, dict):
|
||
continue
|
||
times = block.get("time") or []
|
||
codes = block.get("thscode") or block.get("thscodes") or []
|
||
if isinstance(codes, str):
|
||
codes = [codes]
|
||
lengths = [len(value) for value in table.values() if isinstance(value, list)]
|
||
row_count = max(lengths or [len(times) if isinstance(times, list) else 0, 1 if table else 0])
|
||
for index in range(row_count):
|
||
row: dict[str, Any] = {}
|
||
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]
|
||
for field, values in table.items():
|
||
if isinstance(values, list):
|
||
row[field] = values[index] if index < len(values) else None
|
||
elif index == 0:
|
||
row[field] = values
|
||
rows.append(row)
|
||
return rows
|
||
|
||
@staticmethod
|
||
def _codes(codes: str | list[str]) -> str:
|
||
if isinstance(codes, list):
|
||
values = [str(code or "").strip().upper() for code in codes]
|
||
else:
|
||
values = [part.strip().upper() for part in str(codes or "").split(",")]
|
||
values = [value for value in values if value]
|
||
if not values:
|
||
raise IfindError("iFinD 证券代码不能为空。")
|
||
if len(values) > 100:
|
||
raise IfindError("iFinD 单次证券代码过多。")
|
||
return ",".join(values)
|
||
|
||
@staticmethod
|
||
def _display_date(value: str) -> str:
|
||
compact = str(value or "").replace("-", "")
|
||
if len(compact) != 8 or not compact.isdigit():
|
||
raise IfindError("iFinD 日期格式不正确。")
|
||
return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}"
|
||
|
||
@staticmethod
|
||
def _parse_datetime(value: Any) -> datetime | None:
|
||
text = str(value or "").strip()
|
||
if not text:
|
||
return None
|
||
try:
|
||
return datetime.fromisoformat(text)
|
||
except ValueError:
|
||
return None
|