"""东方财富基金行情适配器。 该模块只负责外部 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