Files

139 lines
5.1 KiB
Python

"""东方财富基金行情适配器。
该模块只负责外部 HTTP 调用和供应商响应解析,不依赖 Agent、Controller 或数据库。
"""
import asyncio
import re
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import Any, Protocol
import httpx
from app.core.errors import RecoverableAgentError
NAV_API = "https://api.fund.eastmoney.com/f10/lsjz"
RETURN_API = "https://api.fund.eastmoney.com/pinzhong/LJSYLZS"
QUOTE_API = "https://push2.eastmoney.com/api/qt/ulist.np/get"
DETAIL_API = "https://fund.eastmoney.com/pingzhongdata/{code}.js"
HEADERS = {"User-Agent": "Mozilla/5.0", "Referer": "https://fund.eastmoney.com/"}
class FundMarketAdapter(Protocol):
async def fetch_names(self, codes: list[str]) -> dict[str, str]: ...
async def fetch_quotes(self, codes: list[str]) -> dict[str, dict[str, Any]]: ...
async def fetch_history(self, code: str, target_date: date) -> dict[str, Any]: ...
class EastmoneyFundAdapter:
def __init__(
self,
client: httpx.AsyncClient | None = None,
*,
timeout_seconds: float = 12.0,
retries: int = 2,
) -> None:
self._client = client
self._owns_client = client is None
self.timeout_seconds = timeout_seconds
self.retries = max(0, retries)
async def fetch_names(self, codes: list[str]) -> dict[str, str]:
result: dict[str, str] = {}
for code in codes:
try:
response = await self._request("GET", DETAIL_API.format(code=code))
match = re.search(r"var\s+fS_name\s*=\s*[\"']([^\"']+)", response.text)
result[code] = match.group(1).strip() if match else f"基金 {code}"
except RecoverableAgentError:
result[code] = f"基金 {code}"
return result
async def fetch_quotes(self, codes: list[str]) -> dict[str, dict[str, Any]]:
if not codes:
return {}
secids = ",".join(
("1." if code.startswith(("5", "6", "9")) else "0.") + code
for code in codes
)
try:
response = await self._request(
"GET", QUOTE_API,
params={"fltt": 2, "invt": 2, "fields": "f12,f2,f3", "secids": secids},
)
items = ((response.json().get("data") or {}).get("diff") or [])
except (RecoverableAgentError, ValueError, TypeError):
return {}
result: dict[str, dict[str, Any]] = {}
for item in items:
code = str(item.get("f12")) if item.get("f12") else ""
if code:
result[code] = {
"nav": self._decimal(item.get("f2")),
"daily_change": self._decimal(item.get("f3")),
}
return result
async def fetch_history(self, code: str, target_date: date) -> dict[str, Any]:
try:
response = await self._request(
"GET", NAV_API,
params={"fundCode": code, "pageIndex": 1, "pageSize": 30,
"startDate": "", "endDate": ""},
)
records = ((response.json().get("Data") or {}).get("LSJZList") or [])
except (RecoverableAgentError, ValueError, TypeError):
return {"degraded": True}
selected = next(
(item for item in records if item.get("FSRQ") == target_date.isoformat()), None
)
if selected is None and records:
selected = records[0]
if not selected:
return {"degraded": True}
return {
"nav": self._decimal(selected.get("DWJZ")),
"nav_date": self._date(selected.get("FSRQ")),
"daily_change": self._decimal(selected.get("JZZZL")),
"degraded": False,
}
async def _request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
client = self._client or httpx.AsyncClient(headers=HEADERS)
try:
last_error: Exception | None = None
for attempt in range(self.retries + 1):
try:
response = await client.request(
method, url, timeout=self.timeout_seconds, **kwargs
)
response.raise_for_status()
return response
except (httpx.HTTPError, TimeoutError) as exc:
last_error = exc
if attempt < self.retries:
await asyncio.sleep(0.2 * (2**attempt))
raise RecoverableAgentError("基金行情数据源不可用") from last_error
finally:
if self._owns_client:
await client.aclose()
@staticmethod
def _decimal(value: Any) -> Decimal | None:
if value in (None, "", "-"):
return None
try:
return Decimal(str(value).replace("%", ""))
except (InvalidOperation, ValueError):
return None
@staticmethod
def _date(value: Any) -> date | None:
try:
return date.fromisoformat(str(value))
except (TypeError, ValueError):
return None