## 问题 产品列表与排行页的「最近涨跌」全是"暂无"。原因是它只能由**两个交易日的收盘价** 现算,而 `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。
474 lines
21 KiB
Python
474 lines
21 KiB
Python
"""东方财富基金行情适配器。
|
||
|
||
该模块只负责外部 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
|