## 取费率
项目原有的行情适配器只有 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。
145 lines
5.2 KiB
Python
145 lines
5.2 KiB
Python
"""把真实费率回填到 `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()))
|