Files
group_fqcd_jr/app/infrastructure/fund_market_adapter.py
T
lzf_0626 7208713c17 feat(portal): 接入历史净值,产品详情页恢复真实走势图
`fin_nav_history` 一直是**空表**,所以产品详情页画不出走势图 —— 此前那条曲线是
前端 `mock-data.js` 里编的 12 个点位,接入公开产品接口时把它去掉了
(走势图最容易被当成真实业绩),页面改为显示"尚未接入"。本次补上完整链路。

## 1. 取数(app/infrastructure/fund_market_adapter.py)

新增 `fetch_nav_history()`:东方财富历史净值接口(`api.fund.eastmoney.com/f10/lsjz`)
分页取序列。

- **为什么不复用 `fetch_kline`**:它走 `push2his.eastmoney.com`,
  该域名在本环境实测连接被拒(`RemoteProtocolError`);
- **为什么不复用 `hq.get_southern_fund_nav_history`**:那个函数校验**南方基金白名单**,
  而 `fin_product` 里有非南方基金的产品(510300 是华泰柏瑞的),调它会直接 `ValueError`。

⚠️ 实现里踩到一个坑:接口**会忽略请求中的 `pageSize`**(实测固定每次返回 20 条)。
最初按硬编码的 30 判断"是否最后一页",于是 `len(items) < 30` 永远成立、只取到第一页 ——
走势图看上去"有数据",其实只有最近 20 天,而且毫无报错。
改为按首屏**实际条数** + `TotalCount` 推算页数后,同样区间取到 124 条。

## 2. 落库(tools/sync_nav_history.py,新增)

写入 `fin_nav_history`,按 `(product_id, nav_date)` 幂等 upsert。
实测:20 只产品 / **2513 行** / 2026-03-17 ~ 09-13;重跑**新写入 0 行**。

## 3. 接口(P002)

`GET /api/v1/products/{product_code}/nav-history`,编号 **P002**,已登记 `docs/05` §19。
鉴权口径与 P001 相同(要求有效令牌、不校验权限码,访客令牌可用);`days` 有界 1–365。

**表为空时返回 `count=0` 与空数组,而不是报错** —— 调用方据此显示"尚未接入",
**不得回退到编造曲线**。产品不存在或未上市 → `404`(否则前端分不清"没有数据"
和"没有这只产品")。

## 4. 前端

详情页按序列画 SVG 折线,期数标题改为动态("近 N 个交易日")。
表为空时仍显示"尚未接入"占位,并补上此前缺失的 `.detail-chart__empty` 样式。
`product-detail.js` / `.css` / `index.html` 的缓存版本参数一并 bump 到 `-7`。

## 5. 演示数据

`tools/seed_demo_data.py` 增加第 5 步「历史净值」(现 **11 步**),
否则换台机器演示时走势图又会是空的。

验证:P002 实测 515450 / 510300 各 120 个净值点;ruff 通过;mypy 251 文件 0 错;
unit+contract 1391 passed;integration 108 passed;e2e 冒烟 40/40。
2026-09-13 23:38:13 +08:00

469 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""东方财富基金行情适配器。
该模块只负责外部 HTTP 调用和供应商响应解析,不依赖 Agent、Controller 或数据库。
"""
import asyncio
import json
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"
#: 日 K 线。**这是本环境唯一可用的行情源**:`QUOTE_API`(push2 实时快照)实测
#: `Server disconnected`(连接被拒),而 push2his 稳定返回。它给的
#: 开/收/高/低/成交量/成交额正好是 `fin_market_price` 这张**日行情**表需要的字段。
KLINE_API = "https://push2his.eastmoney.com/api/qt/stock/kline/get"
#: 基金规模(亿份的"亿元"口径)与净值走势所在的 JS 文件,用于推算总份额。
PINGZHONG_API = "https://fund.eastmoney.com/pingzhongdata/{code}.js"
#: 腾讯行情。**本环境唯一可用的行情源**:东财的两个行情域名(push2 / push2his)
#: 实测一律 `Server disconnected`,而它的净值域名(api.fund)与概况域名(fundf10)正常 ——
#: 即东财只有"行情类"接口不可达。腾讯一次请求可带多只,给
#: 今开/最高/最低/现价/成交量(手)/成交额(万元)/总市值(亿元)。
TENCENT_QUOTE_API = "https://qt.gtimg.cn/q="
TENCENT_HEADERS = {"User-Agent": "Mozilla/5.0", "Referer": "https://gu.qq.com/"}
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.]+)",
}
def _as_int(value: Any) -> int | None:
"""把接口返回的计数字段转成 int;拿不到就返回 None(不编造)。"""
try:
return int(value) if value is not None else None
except (TypeError, ValueError):
return None
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_nav_history(
self, code: str, *, start_date: date, end_date: date
) -> list[dict[str, Any]]: ...
async def fetch_fees(self, codes: list[str]) -> dict[str, dict[str, Any]]: ...
async def fetch_kline(
self, codes: list[str], *, limit: int = 5
) -> dict[str, list[dict[str, Any]]]: ...
async def fetch_fund_scale(self, codes: list[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_nav_history(
self, code: str, *, start_date: date, end_date: date, max_pages: int = 8
) -> list[dict[str, Any]]:
"""取一只基金在区间内的**历史单位净值序列**(东财 `f10/lsjz` 分页)。
与 `fetch_history` 的区别:那个只返回**一天**(命中所需日期的那条);
本方法返回**整段序列**,供产品详情页画净值走势图。
⚠️ 为什么不复用 `fetch_kline`:它走 `push2his.eastmoney.com`,
而该域名在本环境实测**连接被拒**(`RemoteProtocolError`)——
历史走势只剩净值接口这一条可用路径。
⚠️ 为什么不复用 `hq.get_southern_fund_nav_history`:那个函数**校验南方基金
白名单**,而 `fin_product` 里有非南方基金的产品(510300 是华泰柏瑞的),
调它会直接 `ValueError`。这里只按代码取数,不做归属判断。
分页按 `TotalCount` 推算页数;`max_pages` 是**兜底上限**(防止接口给出异常大的
总页数时把所有页拉一遍),默认 8 页≈160 个交易日,够画大半年走势。
取不到就结束,不抛异常 —— 历史净值缺失不该让整页失败。
"""
collected: dict[str, Decimal] = {}
page_size = 0
page_count = max_pages
for page_index in range(1, page_count + 1):
try:
response = await self._request(
"GET", NAV_API,
params={
"fundCode": code,
"pageIndex": page_index,
"pageSize": 30,
"startDate": start_date.isoformat(),
"endDate": end_date.isoformat(),
},
)
data = response.json().get("Data") or {}
items = data.get("LSJZList") or []
total = _as_int(data.get("TotalCount"))
except (RecoverableAgentError, ValueError, TypeError):
break
if not items:
break
if page_index == 1:
# ⚠️ 接口**会忽略请求里的 `pageSize`**(实测固定每次返回 20 条),
# 所以页大小必须取首屏的**实际条数**、并按 `TotalCount` 推算总页数。
# 曾经这里写死 30,于是 `len(items) < 30` 永远成立、只取到第一页:
# 详情页的走势图看上去"有数据",其实只有最近 20 天,且毫无报错。
page_size = len(items)
if total:
page_count = min((total + page_size - 1) // page_size, max_pages)
for item in items:
day = str(item.get("FSRQ") or "")
nav = self._decimal(item.get("DWJZ"))
if day and nav is not None and day not in collected:
collected[day] = nav
if len(items) < page_size:
break
return [{"nav_date": day, "nav": collected[day]} for day in sorted(collected)]
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
@staticmethod
def _secid(code: str) -> str:
"""东财的 secid:沪市前缀 `1.`、深市 `0.`(与 `hq.py` 同一口径)。"""
return ("1." if code.startswith(("5", "6", "9")) else "0.") + code
async def fetch_kline(
self, codes: list[str], *, limit: int = 5
) -> dict[str, list[dict[str, Any]]]:
"""取每只基金最近若干交易日的日 K 线。
返回 `{code: [{trade_date, open, close, high, low, volume, turnover}, ...]}`
(按日期升序,最后一条是最新交易日)。取不到的代码**不出现**在返回里。
为什么用它而不是 `fetch_quotes`:`push2` 实时快照在本环境实测连接被拒,
而 `push2his` 的日 K 线稳定可用;且这张表本来就是**日行情**,
开/收/高/低/量/额比"实时快照"更贴合语义。
"""
results: dict[str, list[dict[str, Any]]] = {}
for code in codes:
# ⚠️ 这条请求**刻意走同步 httpx**(`asyncio.to_thread` 包装):实测
# `AsyncClient` 请求 push2his 会被对端直接断连
# (`RemoteProtocolError: Server disconnected without sending a response`),
# 而同参数、同 header 的同步客户端稳定成功 —— 排查时一度以为是解析问题。
try:
klines = await asyncio.to_thread(self._fetch_kline_sync, code, limit)
except Exception: # noqa: BLE001 - 单只失败不影响其余
continue
rows = [self._parse_kline(line) for line in klines]
usable = [row for row in rows if row is not None]
if usable:
results[code] = usable
return results
@staticmethod
def _fetch_kline_sync(code: str, limit: int) -> list[Any]:
"""同步版 K 线请求(为什么不用 `AsyncClient` 见 `fetch_kline` 的注释)。"""
response = httpx.get(
KLINE_API,
params={
"secid": EastmoneyFundAdapter._secid(code),
"fields1": "f1,f2,f3,f4,f5,f6",
# f51 日期 f52 开 f53 收 f54 高 f55 低 f56 成交量 f57 成交额
"fields2": "f51,f52,f53,f54,f55,f56,f57",
"klt": "101", "fqt": "0", "end": "20500101", "lmt": str(limit),
},
headers=HEADERS,
timeout=20.0,
)
response.raise_for_status()
return list((response.json().get("data") or {}).get("klines") or [])
@staticmethod
def _parse_kline(line: Any) -> dict[str, Any] | None:
"""`"2026-09-11,4.592,4.579,4.592,4.532,9666652,4409407029.000"` → dict。"""
if not isinstance(line, str):
return None
parts = line.split(",")
if len(parts) < 7:
return None
try:
trade_date = date.fromisoformat(parts[0])
except ValueError:
return None
return {
"trade_date": trade_date,
"open": EastmoneyFundAdapter._decimal(parts[1]),
"close": EastmoneyFundAdapter._decimal(parts[2]),
"high": EastmoneyFundAdapter._decimal(parts[3]),
"low": EastmoneyFundAdapter._decimal(parts[4]),
"volume": EastmoneyFundAdapter._decimal(parts[5]),
"turnover": EastmoneyFundAdapter._decimal(parts[6]),
}
async def fetch_fund_scale(self, codes: list[str]) -> dict[str, Any]:
"""取基金规模(**亿元**)与规模所属报告期,用于推算总份额。
⚠️ 规模是**季度披露值**(`Data_fluctuationScale` 的最后一期),不是实时值,
所以推算出的总份额只当量级用 —— 它服务的是"单一投资者持仓占比不超过 X%"
这类校验,不是对外披露数据。
"""
results: dict[str, Any] = {}
for code in codes:
try:
response = await self._request("GET", PINGZHONG_API.format(code=code))
except RecoverableAgentError:
continue
scale = self._parse_scale(response.text)
if scale is not None:
results[code] = scale
return results
@staticmethod
def _parse_scale(text: str) -> dict[str, Any] | None:
"""从 pingzhongdata 取最后一期规模(亿元)。"""
match = re.search(r"var\s+Data_fluctuationScale\s*=\s*(\{.*?\});", text, re.S)
if match is None:
return None
try:
payload = json.loads(match.group(1))
except (ValueError, TypeError):
return None
series = payload.get("series") or []
categories = payload.get("categories") or []
if not series:
return None
last = series[-1]
value = last.get("y") if isinstance(last, dict) else None
try:
scale_yi = Decimal(str(value))
except (InvalidOperation, TypeError):
return None
return {
"scale_yi": scale_yi,
"scale_date": categories[-1] if categories else None,
}
@staticmethod
def _tencent_symbol(code: str) -> str:
"""腾讯的代码前缀:沪市 `sh`、深市 `sz`。"""
return ("sh" if code.startswith(("5", "6", "9")) else "sz") + code
async def fetch_tencent_quotes(self, codes: list[str]) -> dict[str, dict[str, Any]]:
"""腾讯当日行情快照(一次请求可带多只)。
**本环境唯一可用的行情源**:东财的 `push2`/`push2his` 实测连接被拒,
而腾讯这个源稳定返回。它给的字段正好够写一行 `fin_market_price`:
今开 / 最高 / 最低 / 现价 / 成交量(手) / 成交额(万元) / 总市值(亿元)。
取不到的代码不出现在返回里。
"""
if not codes:
return {}
symbols = ",".join(self._tencent_symbol(code) for code in codes)
try:
text = await asyncio.to_thread(self._fetch_tencent_sync, symbols)
except Exception: # noqa: BLE001 - 整批失败就返回空,由调用方按"未同步"处理
return {}
return self._parse_tencent(text)
@staticmethod
def _fetch_tencent_sync(symbols: str) -> str:
response = httpx.get(
TENCENT_QUOTE_API + symbols, headers=TENCENT_HEADERS, timeout=20.0
)
response.raise_for_status()
return response.text
@staticmethod
def _parse_tencent(text: str) -> dict[str, dict[str, Any]]:
"""解析 `v_sh510300="1~名称~代码~现价~昨收~今开~成交量~…"` 形式的返回。
字段是**位置约定**(腾讯没有自描述),所以这里只取前 46 个位置里语义明确的那些,
并对结果做合理性校验(价格必须为正),拿不到就丢弃该只而不是给出错值。
"""
results: dict[str, dict[str, Any]] = {}
for line in text.splitlines():
if "=" not in line or "~" not in line:
continue
_, _, body = line.partition("=")
parts = body.strip().strip('";').split("~")
if len(parts) < 46:
continue
code = parts[2].strip()
if not code:
continue
close = EastmoneyFundAdapter._decimal(parts[3])
if close is None or close <= 0:
continue
turnover_wan = EastmoneyFundAdapter._decimal(parts[37])
results[code] = {
"code": code,
"name": parts[1] or None,
"open": EastmoneyFundAdapter._decimal(parts[5]),
"close": close,
"high": EastmoneyFundAdapter._decimal(parts[33]),
"low": EastmoneyFundAdapter._decimal(parts[34]),
# 成交量单位是**手**(与东财 K 线口径一致,实测同为 9666652)。
"volume": EastmoneyFundAdapter._decimal(parts[6]),
# 成交额单位是**万元**,统一换算成元。
"turnover": None if turnover_wan is None else turnover_wan * 10000,
# 总市值(亿元),用于推算 `total_fund_shares`。
"total_market_value_yi": EastmoneyFundAdapter._decimal(parts[45]),
"quoted_at": parts[30] or None,
}
return results
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