Files
group_fqcd_jr/app/infrastructure/fund_market_adapter.py
T
lzf_0626 02ba1befc0 feat(data): 取真实费率并回填 fin_product;发现 510300 的身份问题
## 取费率

项目原有的行情适配器只有 names/quotes/history,没有费率。东财也没有可用的 JSON 接口
(`FundArchivesDatas.aspx?type=jjfl` 实测正文只有一句 `var apidata=`),
所以给 EastmoneyFundAdapter 加了 fetch_fees:抓 f10 基金概况页
(fundf10.eastmoney.com/jbgk_{code}.html)去标签后匹配字段名,取管理费/托管费/
销售服务费/申赎费,并顺带带回基金全称。取不到的基金不进返回(而不是给全 None),
免得调用方把"没抓到"当成"该基金确实没有费率"。

实测 20/20 全部取到。

## 回填

新增 tools/backfill_product_fees.py,默认 dry-run、默认只补 NULL:
- 只补空值:写入 38 个字段(19 只 × 管理费/托管费),已执行
- 已有值一律不动 —— `510300` 库里的 0.5000/0.1000 保持原样,要改需显式 --overwrite
- 写入走单事务

回填前 grep 确认:`fin_product.management_fee_rate` / `custodian_fee_rate`
**目前没有任何业务代码读取**(advisor 域那两个 fee 字段属于另一张表
advisor_product_contract),所以这次回填不改变任何现有行为。
要让客户真的看到费率,还需要接口与前端读它 —— 那不在本次范围。

## 两个数据问题(都指向 510300)

1) **它不是南方基金的产品**。数据源返回的基金全称是
   「华泰柏瑞沪深300交易型开放式指数证券投资基金」,而库里 fund_manager 写「南方基金」。
   其余 19 只全称都是"南方…"开头,只有它例外。
   影响面不只是知识库:MarketQuoteSyncService 按 fund_manager='南方基金' 筛选同步对象,
   所以它会被当成自家产品一起同步、一起展示。

2) **它的费率是照抄的**。库里 0.5000/0.1000,真实 0.15/0.05;而 0.50/0.10 恰好是
   159329/159382/159511/588890 这四只的真实费率。结合它的净值快照时间是当天
   (其余 19 只停在 09-11)、净值取整数 4.500000,判断是演示用途的人为设定。

这两条都由用户决定怎么处理,本次只记录、不擅自改(510300 的 fund_manager、净值、费率
三处原值都没动)。

## 其他

- tools/fetch_live_quotes.py 增加费率核对段落与 --no-fees(费率要串行抓 45KB/只,较慢)
- docs/42 用真实费率重写 1.2 节与新增 4.3 真实费率清单,3.1 节标出 510300 的身份问题
- docs/42 的数据缺口表更新:费率不再是缺口

门禁:ruff 通过 / mypy 249 文件 0 错 / 单元+契约 1377 passed 2 skipped。
2026-09-13 19:39:16 +08:00

194 lines
7.9 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"
#: 费率只能从 f10 基金概况页抓。**没有可用的 JSON 接口** ——
#: `FundArchivesDatas.aspx?type=jjfl` 实测返回空(正文就一句 `var apidata=`),
#: 所以这里按 HTML 去标签后匹配字段名。
FEE_API = "https://fundf10.eastmoney.com/jbgk_{code}.html"
HEADERS = {"User-Agent": "Mozilla/5.0", "Referer": "https://fund.eastmoney.com/"}
#: 费率字段 → 概况页里的标签与取值模式。值形如「0.15」(百分号已剥离),
#: 页面写 `---` 表示该项不适用(如 ETF 没有申购费),统一映射成 `None`。
FEE_FIELD_PATTERNS: dict[str, str] = {
"management_fee_rate": r"管理费率\s*([\d.]+)\s*%",
"custodian_fee_rate": r"托管费率\s*([\d.]+)\s*%",
"service_fee_rate": r"销售服务费率\s*([\d.]+)\s*%",
"subscribe_fee_rate": r"最高申购费率\s*(--|[\d.]+)",
"redeem_fee_rate": r"最高赎回费率\s*(--|[\d.]+)",
}
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]: ...
async def fetch_fees(self, codes: list[str]) -> dict[str, 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 fetch_fees(self, codes: list[str]) -> dict[str, dict[str, Any]]:
"""取每只基金的费率(管理费/托管费/销售服务费/申购费/赎回费)与基金全称。
为什么单独留一个入口:费率是**长期稳定**字段,而概况页约 45KB/只,
所以调用方应当按需刷新、不要每次问答都抓 —— `tools/fetch_live_quotes.py`
就是按"需要时才拉"来用它的。
取数失败的基金**不会出现在返回里**(而不是返回一个全 None 的 dict),
免得调用方把"没抓到"误当成"该基金确实没有费率"。
"""
results: dict[str, dict[str, Any]] = {}
for code in codes:
try:
response = await self._request("GET", FEE_API.format(code=code))
except RecoverableAgentError:
continue
parsed = self._parse_fees(response.text)
if parsed is not None:
results[code] = parsed
return results
@staticmethod
def _parse_fees(html: str) -> dict[str, Any] | None:
text = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html))
fees: dict[str, Any] = {}
for field, pattern in FEE_FIELD_PATTERNS.items():
match = re.search(pattern, text)
value = match.group(1) if match else None
fees[field] = (
None if value in (None, "", "--") else EastmoneyFundAdapter._decimal(value)
)
# 一个字段都没解析出来,说明页面结构变了或该代码不存在 —— 返回 None 让调用方
# 与"该基金确实没有费率"区分开。
if all(value is None for value in fees.values()):
return None
name = re.search(r"基金全称\s*(\S{4,60}?(?:基金|集合资产管理计划))", text)
fees["full_name"] = name.group(1) if name else None
return fees
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