Files
group_fqcd_jr/app/infrastructure/fund_market_adapter.py
T
lzf_0626 9a5259d787 feat(market): 存下行情源给的涨跌幅,产品列表与排行页不再显示"暂无"
## 问题

产品列表与排行页的「最近涨跌」全是"暂无"。原因是它只能由**两个交易日的收盘价**
现算,而 `fin_market_price` 每只产品每天只有一行 —— 库里头一天只有一天数据时
根本算不出来。

## 但行情源本来就给了这个数

腾讯行情接口的第 32 位就是**当日涨跌幅**(适配器此前只解析了开高低收量额,没取它)。
所以不必等第二个交易日去算,把源给的数存下来即可。

## 改动

- **迁移** `20260913_market_price_change_pct`:给 `fin_market_price` **新增一个可空列**
  `change_pct DECIMAL(10,4)`。只加列、不改任何已有字段(AGENTS.md 规则 2 允许),
  可空且不回填,既有读写方全部不受影响。
- **适配器**:`_parse_tencent` 增解析位置 4(昨收)与位置 32(涨跌幅)。
- **同步服务**:写入 `change_pct`;降级路径(净值兜底)没有这个数就写 **NULL**。
- **接口**:`_resolve_change_pct` **优先用存下来的值**;迁移前的历史行没有该列的值,
  才回退到"今日收盘 vs 昨收"现算。仍为 `null` 时前端显示"暂无" ——
  **不得当成 0**(`formatPercent(null)` 会渲染成 `+0.00%`,那等于说"今天平盘")。

## 契约测试同步

两处契约断言因结构演进需要跟进 —— 它们的作用正是拦住这类变更:

- `test_fund_readonly_contract.py`:`fin_market_price` 列数 **13 → 14**
- `test_advisor_migration_contract.py`:迁移 head 更新为新版本
  (核心断言仍是"链收敛到唯一 head",此处只是钉住末端)

验证:实测 20 只产品**全部有真实涨跌幅**(如 515450 −0.43%、159511 +1.23%);
unit+contract **1397 passed**;integration **110 passed**;ruff 通过;
mypy 251 文件 0 错;`audit_schema` 与 `migration_state_check` 均通过;e2e 冒烟 40/40。
2026-09-14 00:30:06 +08:00

474 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,
# 昨收(位置 4)与**当日涨跌幅 %**(位置 32)。腾讯直接给出涨跌幅,
# 所以"最近涨跌"不需要等第二个交易日才显示 —— 见
# `fin_market_price.change_pct`(20260913 迁移新增)。
"previous_close": EastmoneyFundAdapter._decimal(parts[4]),
"change_pct": EastmoneyFundAdapter._decimal(parts[32]),
"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