## 取费率
项目原有的行情适配器只有 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。
194 lines
7.9 KiB
Python
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
|