Files
group_fqcd_jr/tools/fetch_live_quotes.py
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

239 lines
10 KiB
Python
Raw Permalink 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.
"""拉取场内基金的真实行情与费率,并核对库里的快照。
## 数据源
走项目自带的 `EastmoneyFundAdapter`(`app/infrastructure/fund_market_adapter.py`),
不自己拼 HTTP、不引第三方行情库:
· 历史净值 `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 --no-fees # 跳过费率(费率要串行抓 45KB/只,慢)
python tools/fetch_live_quotes.py --json # 输出 JSON(供生成知识条目等)
## 为什么要做对比而不是直接覆盖
`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
import argparse
import asyncio
import json
import sys
from datetime import date
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 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, transaction_fee_rate
FROM fin_product
WHERE fund_manager = '南方基金'
AND exchange_code IN ('SSE', 'SZSE')
AND status = '上市'
ORDER BY product_code
"""
#: 库里已有值的费率字段 ↔ 数据源字段(用于逐项核对)。
FEE_STORED_TO_LIVE = {
"management_fee_rate": "management_fee_rate",
"custodian_fee_rate": "custodian_fee_rate",
}
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()
adapter = EastmoneyFundAdapter()
codes = [str(row["product_code"]) for row in rows]
histories = await asyncio.gather(
*(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"]
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"]),
"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": _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
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}"
f" {'库里净值':>11} 核对")
print(header)
print("-" * len(header))
for item in records:
if item["degraded"]:
verdict = "!! 取数失败"
elif item["matches_stored"]:
verdict = "一致"
else:
verdict = "**不一致**"
print(
f"{str(item['product_code']):<8}{str(item['product_name'])[:24]:<26}"
f"{str(item['live_nav']):>10}{str(item['live_change_pct']):>8}"
f"{str(item['live_nav_date']):>12} {str(item['stored_nav']):>11} {verdict}"
)
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)} 只")
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.add_argument("--json", action="store_true", help="只输出 JSON")
parser.add_argument("--no-fees", action="store_true", help="跳过费率抓取(较快)")
args = parser.parse_args()
records = await collect(with_fees=not args.no_fees)
if args.json:
print(json.dumps(records, ensure_ascii=False, indent=1))
else:
render(records)
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))