diff --git a/app/infrastructure/fund_market_adapter.py b/app/infrastructure/fund_market_adapter.py index eeb1856..54b7020 100644 --- a/app/infrastructure/fund_market_adapter.py +++ b/app/infrastructure/fund_market_adapter.py @@ -17,8 +17,22 @@ 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]: ... @@ -27,6 +41,8 @@ class FundMarketAdapter(Protocol): 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__( @@ -101,6 +117,45 @@ class EastmoneyFundAdapter: "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: diff --git a/docs/42-场内基金知识条目草稿.md b/docs/42-场内基金知识条目草稿.md index 81a3912..eec21e2 100644 --- a/docs/42-场内基金知识条目草稿.md +++ b/docs/42-场内基金知识条目草稿.md @@ -33,10 +33,18 @@ 问:买场内基金要交哪些费用?费率是多少? -答:场内基金的交易费用主要是**券商佣金**,费率由您开户的券商决定,本平台不代收、 -也不设定该费率。基金本身的**管理费**与**托管费**从基金资产中按日计提、 -体现在净值里,不向您单独收取。 -各产品的管理费与托管费**以产品详情页披露为准**;本平台产品库目前只有部分产品登记了这两项。 +答:场内基金涉及两类费用 —— + +**(1)基金自身的费用**,从基金资产中按日计提、体现在净值里,不向您单独收取: +本平台 20 只场内基金的管理费率为 **0.15%~1.20%/年**、托管费率为 **0.05%~0.20%/年**, +每只产品的具体费率见下一节的产品清单。**ETF 通常较低**(如沪深300ETF 管理费 0.15%/年、 +托管费 0.05%/年),**LOF 与主动管理型较高**(如南方积极配置混合 1.20%/年、托管 0.20%/年)。 +货币 ETF 另收销售服务费(如货币ETF南方 0.25%/年)。 + +**(2)交易费用**,即您买卖时产生的**券商佣金**,费率由您开户的券商决定, +本平台不代收、也不设定该费率。 + +具体以产品详情页披露与交易页面展示为准。 ### 1.3 价格最小变动单位 @@ -110,7 +118,7 @@ ## 三、重点产品条目 -### 3.1 沪深300ETF(510300)—— 字段最全,建议作为演示主产品 +### 3.1 沪深300ETF(510300)—— ⚠️ 这只的身份存疑,见下方红字 问:沪深300ETF 的起投金额是多少?南方沪深300ETF 怎么买? @@ -118,32 +126,47 @@ 风险等级 **R3**。它按"手"交易,**1 手 = 100 份**, 按最新净值 **4.5794 元**(2026-09-11)估算,买入 1 手约需 **458 元**; 产品库中登记的最小交易金额为 **100.00 元**。 -该产品的管理费率为 **0.50%/年**、托管费率为 **0.10%/年**(从基金资产中计提,不单独向您收取)。 +该产品的管理费率为 **0.15%/年**、托管费率为 **0.05%/年**(从基金资产中计提,不单独向您收取)。 实际成交金额、可买数量与费用请以交易页面的实时报价为准。 -> ⚠️ **两处待你确认**: +> 🔴 **这只不是南方基金的产品**。数据源返回的基金全称是 +> 「**华泰柏瑞**沪深300交易型开放式指数证券投资基金」,而库里 `fund_manager` 写的是 +> **「南方基金」**。其余 19 只的全称都带"南方"前缀,只有它例外。 > -> 1. **净值口径**:这条用的是数据源真实值 **4.5794**,而库里 `fin_product.current_nav` 是 -> **4.500000**,且快照时间是 **2026-09-13 08:19:37**(就是今天)—— 其余 19 只的快照 -> 都停在 `2026-09-11 15:00:00`。**说明这条是被人为改过的演示值**(整数好算"450 元")。 -> 知识条目该用真实值还是演示值,请你定;如果交易链路读的是库里那个值, -> 那么"知识说 4.5794、下单按 4.5 成交"会不一致。 -> 2. **"1 手约 458 元"与"最小交易金额 100.00 元"仍不相等** —— 前者是 `lot_size=100 × 真实净值`, -> 后者是 `fin_product.min_amount` 原值。请确认该以哪个为准,或说明 `min_amount` -> 对场内产品不参与校验。**未确认前建议回答里只保留"1 手 = 100 份、金额随市价变动"。** +> 这解释了为什么**唯独它**的数据处处特殊: +> +> | 字段 | 库里 | 真实(东方财富) | +> |---|---|---| +> | `fund_manager` | 南方基金 | **华泰柏瑞** | +> | `current_nav` | 4.500000(快照时间**当天**) | 4.5794 | +> | `management_fee_rate` | 0.5000 | **0.15** | +> | `custodian_fee_rate` | 0.1000 | **0.05** | +> +> 而且 `0.50/0.10` 恰好是另外几只 ETF(`159329`/`159382`/`159511`/`588890`)的真实费率, +> 看起来是**照抄过来的值**。 +> +> 影响面比"知识库答不准"更大:`MarketQuoteSyncService` 按 `fund_manager = '南方基金'` +> 筛选同步对象,所以这只**会被当成南方产品一起同步、一起显示**。 +> **请你决定**:是把它的 `fund_manager` 改回"华泰柏瑞"(那它就会退出同步范围、 +> 投顾与知识条目也不该再把它算作自家产品),还是保留现状作为演示数据 —— +> 若保留,建议在演示口径里说清楚"这只是同指数产品、不是本公司产品"。 +> +> ⚠️ 另外两点仍需你定:**(a)**净值该用真实值 4.5794 还是库里的演示值 4.500000; +> **(b)**"1 手约 458 元"与"最小交易金额 100.00 元"仍不相等,请确认以哪个为准。 ### 3.2 其他产品(简版模板) -其余 19 只按同一模板生成,字段取自第二节的表格: +其余 19 只按同一模板生成,净值取第二节的表、费率取 4.3 的表: -问:<产品名> 是什么?代码是多少?风险等级? +问:<产品名> 是什么?代码是多少?风险等级?费率多少? 答:<产品名>(代码 )在<上交所/深交所>挂牌,属于 , -风险等级 。按"手"交易,1 手 = 100 份。 -最新净值 (<净值日期>)。交易费用与费率以产品详情页和交易页面为准。 +风险等级 。按"手"交易,**1 手 = 100 份**。最新净值 <净值>(<净值日期>)。 +管理费率 %/年、托管费率 %/年,从基金资产中计提、不单独向您收取; +买卖时的券商佣金由您开户的券商决定,本平台不代收。 +具体以产品详情页与交易页面为准。 -> ⚠️ 这 19 只**只有 `510300` 登记了管理费与托管费**,其余全部为空 -> (`transaction_fee_rate` 更是 20 只全空),所以模板里不写任何费率数字。 +> 费率数值直接取 4.3 —— **20 只都有真实值**,不必再写"以页面为准"这种模糊话术。 --- @@ -151,9 +174,8 @@ | 字段 | 现状 | 影响 | |---|---|---| -| `transaction_fee_rate` | **20/20 全空** | 无法回答"交易费率是多少",只能指向券商 | -| `management_fee_rate` | 仅 `510300` 有(0.5000) | 其余 19 只无法回答管理费 | -| `custodian_fee_rate` | 仅 `510300` 有(0.1000) | 同上 | +| ~~`management_fee_rate`~~ / ~~`custodian_fee_rate`~~ | **已补齐**:20 只真实费率全部取到,见 4.3 | 库里 19 只为空;`510300` 的值与真实冲突(0.5000/0.1000 vs 0.15/0.05) | +| `transaction_fee_rate` | **20/20 全空** | 场内交易佣金由券商收取,本就不该由平台登记,**保留为空是合理的** | | `min_amount` | 19 只为 `0.00`,`510300` 为 `100.00` | 场内产品的"最小金额"语义待确认 | | `risk_disclosure_required` 等三项 | 全部为 `0` | 与"严格风险等级匹配"的业务口径是否需要区分产品,待确认 | | 产品"起投金额" | **表里没有这个字段** | 场内按手交易,本就没有固定起投金额;建议统一按 1.1 的口径回答 | @@ -182,9 +204,44 @@ **差三个数量级** —— 两个接口对这只的含义不同,**需要人工核实该用哪个口径** - 建议:核实之前,知识条目**不要写这只的具体净值** -> 复现:`python tools/fetch_live_quotes.py`(默认表格对比,`--json` 输出结构化结果)。 -> 该脚本只报告差异、**不写库** —— `fin_product.current_nav` 的刷新属于 -> `ProductHistorySyncService` / `MarketQuoteSyncService` 的职责。 +> 复现:`python tools/fetch_live_quotes.py`(默认表格对比,`--json` 输出结构化结果, +> `--no-fees` 可跳过费率抓取)。该脚本只报告差异、**不写库** —— `fin_product.current_nav` +> 的刷新属于 `ProductHistorySyncService` / `MarketQuoteSyncService` 的职责。 + +### 4.3 真实费率(2026-09-13 取自东方财富 f10 基金概况页) + +**20 只全部取到**,库里 19 只为空、1 只与真实冲突。费率是**长期稳定**字段, +下列数值可放心写进知识条目: + +| 代码 | 名称 | 管理费 %/年 | 托管费 %/年 | 销售服务费 %/年 | +|---|---|---|---|---| +| 159329 | 沙特ETF南方 | 0.50 | 0.10 | — | +| 159382 | 创业板人工智能ETF南方 | 0.50 | 0.10 | — | +| 159511 | 通信ETF南方 | 0.50 | 0.10 | — | +| 159615 | 恒生生物科技ETF南方 | 0.50 | 0.15 | — | +| 159687 | 亚太精选ETF南方 | 0.20 | 0.05 | — | +| 159700 | 科创债ETF南方 | 0.15 | 0.05 | — | +| 159948 | 创业板ETF南方 | 0.15 | 0.05 | — | +| 160105 | 南方积极配置混合(LOF) | 1.20 | 0.20 | — | +| 160127 | 南方新兴消费增长股票(LOF)A | 1.20 | 0.20 | — | +| 160128 | 南方金利定开债券A | 0.50 | 0.15 | — | +| 160129 | 南方金利定开债券C | 0.50 | 0.15 | — | +| 160142 | 南方优势产业(LOF) | 1.20 | 0.20 | — | +| 160143 | 南方创业板2年定期开放混合 | 1.20 | 0.20 | — | +| 501018 | 南方原油A | 1.00 | 0.20 | — | +| 501062 | 南方瑞合定开混合(LOF) | 1.20 | 0.20 | — | +| **510300** | 沪深300ETF | **0.15** | **0.05** | — | +| 510500 | 中证500ETF南方 | 0.15 | 0.05 | — | +| 511070 | 公司债ETF南方 | 0.15 | 0.05 | — | +| 511810 | 货币ETF南方 | 0.30 | 0.05 | **0.25** | +| 588890 | 科创芯片ETF南方 | 0.50 | 0.10 | — | + +**规律**:宽基/债券 ETF 多为 0.15% 管理费,行业主题 ETF 多为 0.50%, +主动管理型 LOF 为 1.00%~1.20%。只有货币 ETF 收销售服务费。 + +> 📌 **一处提醒**:数据源没有给出**场内交易的佣金费率**(`transaction_fee_rate` 20 只全空), +> 这是**正常的** —— 佣金由客户开户的券商决定,平台不该也不能登记它。 +> 所以"交易费率是多少"这个问题,标准答法是引到券商,而不是从产品库取值。 --- diff --git a/tools/backfill_product_fees.py b/tools/backfill_product_fees.py new file mode 100644 index 0000000..dc1ba2f --- /dev/null +++ b/tools/backfill_product_fees.py @@ -0,0 +1,144 @@ +"""把真实费率回填到 `fin_product`(默认 dry-run,只有 `--apply` 才写库)。 + +## 为什么需要它 + +`fin_product.management_fee_rate` / `custodian_fee_rate` 在本机 **20 只里 19 只为空**, +客户问"管理费多少"只能转人工。费率可以从东方财富 f10 基金概况页取到 +(`EastmoneyFundAdapter.fetch_fees`),本脚本负责把它落到库里。 + +## 安全设计 + +1. **默认 dry-run**:只打印将执行的 UPDATE,不写库。要真正写入必须显式给 `--apply`。 +2. **默认只补空值**:`NULL` 才写,**已有值一律不动** —— 因为已有值可能是人为设定 + (本机 `510300` 就是:库里 0.5000/0.1000,真实 0.15/0.05)。要覆盖必须再给 `--overwrite`。 +3. **`--overwrite` 也只改这一只这一类**:它不会顺手把别的东西一起改掉。 +4. 写入走单个事务,任一失败整体回滚。 + +## 用法 + + python tools/backfill_product_fees.py # 看将要改什么 + python tools/backfill_product_fees.py --apply # 只补空值 + python tools/backfill_product_fees.py --apply --overwrite # 连已有值一起改成真实值 +""" + +from __future__ import annotations + +import argparse +import asyncio +import sys +from datetime import UTC, datetime +from pathlib import Path + +from sqlalchemy import text + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app.infrastructure.db import SessionFactory # noqa: E402 +from app.infrastructure.fund_market_adapter import EastmoneyFundAdapter # noqa: E402 + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr] + +#: 与 `MarketQuoteSyncService.sync` 同一批产品。 +PRODUCT_QUERY = """ + SELECT id, product_code, product_name, management_fee_rate, custodian_fee_rate + FROM fin_product + WHERE fund_manager = '南方基金' + AND exchange_code IN ('SSE', 'SZSE') + AND status = '上市' + ORDER BY product_code +""" + +#: 库字段 ↔ 数据源字段。 +FEE_FIELDS = ( + ("management_fee_rate", "management_fee_rate"), + ("custodian_fee_rate", "custodian_fee_rate"), +) + + +def _same(left: object, right: object) -> bool: + if left is None or right is None: + return False + try: + return abs(float(str(left)) - float(str(right))) < 1e-9 + except (TypeError, ValueError): + return str(left) == str(right) + + +async def main() -> int: + parser = argparse.ArgumentParser(description="把真实费率回填到 fin_product") + parser.add_argument("--apply", action="store_true", help="真正写库(默认只看不改)") + parser.add_argument( + "--overwrite", action="store_true", + help="连已有值也改(默认只补 NULL,不动人为设定过的值)", + ) + args = parser.parse_args() + + async with SessionFactory() as session: + rows = (await session.execute(text(PRODUCT_QUERY))).mappings().all() + + adapter = EastmoneyFundAdapter() + fees = await adapter.fetch_fees([str(row["product_code"]) for row in rows]) + print(f"产品 {len(rows)} 只;取到真实费率 {len(fees)} 只\n") + + updates: list[tuple[int, str, str, object]] = [] + conflicts: list[str] = [] + for row in rows: + code = str(row["product_code"]) + live = fees.get(code) + if not live: + continue + for column, key in FEE_FIELDS: + live_value = live.get(key) + if live_value is None: + continue + stored = row[column] + if stored is not None and not _same(stored, live_value): + conflicts.append( + f"{code} {row['product_name']} 的 {column}:库里 {stored} → 真实 {live_value}" + ) + if not args.overwrite: + continue + if stored is not None and not args.overwrite: + continue + if _same(stored, live_value): + continue + updates.append((int(row["id"]), code, column, live_value)) + + if conflicts: + print("与库里已有值冲突(默认不动,需要 --overwrite 才覆盖):") + for line in conflicts: + print(f" * {line}") + print() + + if not updates: + print("没有需要回填的字段。") + return 0 + + print(f"将执行 {len(updates)} 条 UPDATE:") + for _, code, column, value in updates: + print(f" · {code} {column} = {value}") + + if not args.apply: + print("\n[dry-run] 未写库。加 --apply 执行。") + return 0 + + now = datetime.now(UTC).replace(tzinfo=None) + async with SessionFactory() as session, session.begin(): + for product_id, _, column, value in updates: + # 列名来自本模块常量,不是外部输入;值走参数绑定。 + await session.execute( + text( + f"UPDATE fin_product SET {column} = :value, updated_at = :now " + "WHERE id = :product_id" + ), + {"value": value, "now": now, "product_id": product_id}, + ) + print(f"\n已写入 {len(updates)} 个字段。") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/tools/fetch_live_quotes.py b/tools/fetch_live_quotes.py index 28a80e0..cf2831c 100644 --- a/tools/fetch_live_quotes.py +++ b/tools/fetch_live_quotes.py @@ -1,27 +1,34 @@ -"""拉取场内基金的真实行情并核对库里的快照。 +"""拉取场内基金的真实行情与费率,并核对库里的快照。 ## 数据源 走项目自带的 `EastmoneyFundAdapter`(`app/infrastructure/fund_market_adapter.py`), 不自己拼 HTTP、不引第三方行情库: - · 实时/收盘价 `https://push2.eastmoney.com/api/qt/ulist.np/get` - · 历史净值 `https://api.fund.eastmoney.com/f10/lsjz` + · 历史净值 `api.fund.eastmoney.com/f10/lsjz` + · 费率 `fundf10.eastmoney.com/jbgk_{code}.html`(管理费/托管费/销售服务费/申赎费) + · 实时行情 `push2.eastmoney.com/api/qt/ulist.np/get`(见适配器 `fetch_quotes`) 同一套适配器也是下单链路(`TradeService` 取 `quote_price`/`quote_source`) 与 `MarketQuoteSyncService`(东财 + 腾讯双源)在用的,所以这里拿到的值与成交价同源。 ## 用法 - python tools/fetch_live_quotes.py # 与库里快照逐只对比 - python tools/fetch_live_quotes.py --json # 只输出 JSON(供生成知识条目等) + python tools/fetch_live_quotes.py # 净值 + 费率,与库里逐只对比 + python tools/fetch_live_quotes.py --no-fees # 跳过费率(费率要串行抓 45KB/只,慢) + python tools/fetch_live_quotes.py --json # 输出 JSON(供生成知识条目等) ## 为什么要做对比而不是直接覆盖 -`fin_product.current_nav` 是**快照缓存**,正常情况下与数据源一致(本机 20 只里 18 只 -完全一致,只是小数位表示不同)。**不一致的那两只才是重点**:它们要么被人为改过 -(演示需要),要么字段口径本身有问题 —— 直接覆盖会把线索一起抹掉。 -所以本脚本只**报告**差异,不写库;要不要刷新由 `ProductHistorySyncService` 决定。 +`fin_product.current_nav` / `management_fee_rate` 都是**快照缓存**。多数情况下与数据源 +一致,**不一致的那几只才是重点**:它们要么被人为改过(演示需要),要么字段本身填错。 +直接覆盖会把线索一起抹掉。所以本脚本只**报告**差异、不写库 +(刷新快照属于 `ProductHistorySyncService` / `MarketQuoteSyncService` 的职责)。 + +本机实测的两处不一致(2026-09-13),都是这么发现的: + · `510300` 库里净值 4.500000、真实 4.5794(且库里快照时间是当天,其余 19 只停在 09-11) + · `510300` 库里管理费 0.5000 / 托管费 0.1000、真实 0.15 / 0.05 + —— 而 `0.50/0.10` 恰好是另外几只 ETF 的真实值,像是照抄过来的 """ from __future__ import annotations @@ -49,7 +56,7 @@ if hasattr(sys.stdout, "reconfigure"): PRODUCT_QUERY = """ SELECT product_code, product_name, exchange_code, product_category, risk_level, lot_size, min_amount, current_nav, current_nav_at, - management_fee_rate, custodian_fee_rate + management_fee_rate, custodian_fee_rate, transaction_fee_rate FROM fin_product WHERE fund_manager = '南方基金' AND exchange_code IN ('SSE', 'SZSE') @@ -57,8 +64,28 @@ PRODUCT_QUERY = """ ORDER BY product_code """ +#: 库里已有值的费率字段 ↔ 数据源字段(用于逐项核对)。 +FEE_STORED_TO_LIVE = { + "management_fee_rate": "management_fee_rate", + "custodian_fee_rate": "custodian_fee_rate", +} -async def collect() -> list[dict[str, object]]: + +def _text(value: object) -> str | None: + return None if value is None else str(value) + + +def _same(left: object, right: object) -> bool: + """比较两个费率/净值文本,容忍 `0.5` 与 `0.5000` 这种表示差异。""" + if left is None or right is None: + return False + try: + return abs(float(str(left)) - float(str(right))) < 1e-9 + except (TypeError, ValueError): + return str(left) == str(right) + + +async def collect(*, with_fees: bool = True) -> list[dict[str, object]]: async with SessionFactory() as session: rows = (await session.execute(text(PRODUCT_QUERY))).mappings().all() @@ -68,41 +95,44 @@ async def collect() -> list[dict[str, object]]: *(adapter.fetch_history(code, date.today()) for code in codes), return_exceptions=True, ) + # 费率走 f10 概况页:约 45KB/只、且适配器内部是串行抓取(不对公开接口并发施压), + # 因此只在需要时拉 —— `--no-fees` 可跳过。 + fees: dict[str, dict[str, object]] = {} + if with_fees: + fees = await adapter.fetch_fees(codes) records: list[dict[str, object]] = [] for row, history in zip(rows, histories, strict=True): + code = str(row["product_code"]) if isinstance(history, BaseException): history = {"degraded": True, "error": type(history).__name__} live_nav = history.get("nav") stored_nav = row["current_nav"] - records.append({ - "product_code": str(row["product_code"]), + live_fee = fees.get(code) or {} + record: dict[str, object] = { + "product_code": code, "product_name": str(row["product_name"]), "exchange_code": str(row["exchange_code"]), "product_category": str(row["product_category"]), "risk_level": str(row["risk_level"]), "lot_size": str(row["lot_size"]), "min_amount": str(row["min_amount"]), - "management_fee_rate": ( - None if row["management_fee_rate"] is None else str(row["management_fee_rate"]) - ), - "custodian_fee_rate": ( - None if row["custodian_fee_rate"] is None else str(row["custodian_fee_rate"]) - ), - "live_nav": None if live_nav is None else str(live_nav), - "live_nav_date": ( - None if history.get("nav_date") is None else str(history["nav_date"]) - ), - "live_change_pct": ( - None if history.get("daily_change") is None else str(history["daily_change"]) - ), + "live_nav": _text(live_nav), + "live_nav_date": _text(history.get("nav_date")), + "live_change_pct": _text(history.get("daily_change")), "stored_nav": str(stored_nav), "stored_nav_at": str(row["current_nav_at"]), - "matches_stored": ( - live_nav is not None and float(live_nav) == float(stored_nav) - ), + "matches_stored": _same(live_nav, stored_nav), "degraded": bool(history.get("degraded")), - }) + } + for stored_field, live_field in FEE_STORED_TO_LIVE.items(): + record[f"stored_{stored_field}"] = _text(row[stored_field]) + record[f"live_{live_field}"] = _text(live_fee.get(live_field)) + record["live_service_fee_rate"] = _text(live_fee.get("service_fee_rate")) + record["live_subscribe_fee_rate"] = _text(live_fee.get("subscribe_fee_rate")) + record["live_redeem_fee_rate"] = _text(live_fee.get("redeem_fee_rate")) + record["live_full_name"] = _text(live_fee.get("full_name")) + records.append(record) return records @@ -110,9 +140,13 @@ def render(records: list[dict[str, object]]) -> None: ok = [item for item in records if not item["degraded"]] same = [item for item in ok if item["matches_stored"]] diff = [item for item in ok if not item["matches_stored"]] + print("=" * 100) + print("净值核对(数据源 vs 库里快照)") + print("=" * 100) print(f"场内产品 {len(records)} 只;取到真实行情 {len(ok)} 只" f"(与库里一致 {len(same)}、不一致 {len(diff)})\n") - header = f"{'代码':<8}{'名称':<26}{'真实净值':>10}{'涨跌%':>8}{'净值日期':>12} {'库里净值':>11} 核对" + header = (f"{'代码':<8}{'名称':<26}{'真实净值':>10}{'涨跌%':>8}{'净值日期':>12}" + f" {'库里净值':>11} 核对") print(header) print("-" * len(header)) for item in records: @@ -128,24 +162,71 @@ def render(records: list[dict[str, object]]) -> None: f"{str(item['live_nav_date']):>12} {str(item['stored_nav']):>11} {verdict}" ) - if diff: - print("\n不一致的条目(需要人工判断,不要直接覆盖):") - for item in diff: - print(f" · {item['product_code']} {item['product_name']}") - print(f" 真实 {item['live_nav']}({item['live_nav_date']})" - f" vs 库里 {item['stored_nav']}(快照于 {item['stored_nav_at']})") + fee_rows = [item for item in records if item.get("live_management_fee_rate") is not None] + if fee_rows: + print() + print("=" * 100) + print("费率核对(数据源 vs 库里字段)") + print("=" * 100) + fee_header = (f"{'代码':<8}{'名称':<24}{'真实管理费':>11}{'库里':>8}" + f"{'真实托管费':>11}{'库里':>8} 核对") + print(fee_header) + print("-" * len(fee_header)) + fee_diff = [] + for item in fee_rows: + live_manage = item["live_management_fee_rate"] + stored_manage = item["stored_management_fee_rate"] + if stored_manage is None: + verdict = "库里为空" + elif _same(live_manage, stored_manage) and _same( + item["live_custodian_fee_rate"], item["stored_custodian_fee_rate"] + ): + verdict = "一致" + else: + verdict = "**不一致**" + fee_diff.append(item) + print( + f"{str(item['product_code']):<8}{str(item['product_name'])[:22]:<24}" + f"{str(live_manage):>11}{str(stored_manage):>8}" + f"{str(item['live_custodian_fee_rate']):>11}" + f"{str(item['stored_custodian_fee_rate']):>8} {verdict}" + ) + empty = [item for item in fee_rows if item["stored_management_fee_rate"] is None] + print(f"\n真实费率全部取到 {len(fee_rows)} 只;库里为空 {len(empty)} 只、" + f"与真实值冲突 {len(fee_diff)} 只") - missing = [item["product_code"] for item in records if item["degraded"]] - if missing: - print(f"\n取数失败:{missing}") + if diff or fee_diff: + print() + print("需要人工判断的条目(不要直接覆盖):") + for item in diff: + print(f" · 净值 {item['product_code']} {item['product_name']}:" + f"真实 {item['live_nav']}({item['live_nav_date']}) vs " + f"库里 {item['stored_nav']}({item['stored_nav_at']})") + for item in fee_diff: + print(f" · 费率 {item['product_code']} {item['product_name']}:" + f"真实 管理 {item['live_management_fee_rate']} / 托管 " + f"{item['live_custodian_fee_rate']} vs 库里 管理 " + f"{item['stored_management_fee_rate']} / 托管 " + f"{item['stored_custodian_fee_rate']}") + + full_names = [ + (str(item["product_code"]), item["live_full_name"]) + for item in records if item.get("live_full_name") + ] + if full_names: + print() + print("数据源返回的基金全称(可用于核对 fund_manager 标注是否正确):") + for code, name in full_names: + print(f" · {code} {name}") async def main() -> int: - parser = argparse.ArgumentParser(description="拉取场内基金真实行情并核对库里快照") + parser = argparse.ArgumentParser(description="拉取场内基金真实行情与费率,核对库里快照") parser.add_argument("--json", action="store_true", help="只输出 JSON") + parser.add_argument("--no-fees", action="store_true", help="跳过费率抓取(较快)") args = parser.parse_args() - records = await collect() + records = await collect(with_fees=not args.no_fees) if args.json: print(json.dumps(records, ensure_ascii=False, indent=1)) else: