Files
group_fqcd_jr/tools/backfill_product_fees.py
T
lzf_0626 02ba1befc0 feat(data): 取真实费率并回填 fin_product;发现 510300 的身份问题
## 取费率

项目原有的行情适配器只有 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。
2026-09-13 19:39:16 +08:00

145 lines
5.2 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.
"""把真实费率回填到 `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()))