Files
group_fqcd_jr/tools/backfill_product_snapshot.py
lzf_0626 6f397ab513 feat(data): 510300 回填真实值并在产品页披露;511810 口径查清
按项目方决定处理三件事。

## 510300(沪深300ETF)

决定:保留 fund_manager = "南方基金" 作演示口径,**不**改成"华泰柏瑞"
(改了它会退出 MarketQuoteSyncService 的同步范围,投顾与知识条目也不该再算自家产品),
但净值与费率改成真实值,并在产品页显式披露它不是本公司产品。

- 回填(tools/backfill_product_snapshot.py --codes 510300 --apply --overwrite):
  current_nav 4.500000 → 4.5794、current_nav_at 同步为 2026-09-11 15:00:00、
  management_fee_rate 0.5000 → 0.15、custodian_fee_rate 0.1000 → 0.05
- 产品页披露:mock-data.js 给这只加 product_note,详情页新增提示块渲染它
  ("本产品为同指数参考产品,非本公司发行的基金,仅用于功能演示")。
  同时把 mock 里这只的净值/费率也对齐真实值,避免出现"页面显示 mock 值、
  库里是真实值"两套数字。
- 产品详情页的 css/js 版本参数提到 20260913-5,避免旧缓存。

## 511810(货币ETF南方)

之前说它"三个数量级不一致"是**我比错了接口**:QUOTE_API 返的是**场内交易价格**
(货币 ETF 约 100 元/份),而 current_nav 的口径是 DWJZ 单位净值。
查证结果:库里 0.2661 正是 09-11 的真实 DWJZ,快照时间也对得上 —— 不是脏数据,是旧值。
口径由其余 19 只的一致性确定(current_nav == DWJZ)。
要不要刷新到最新(0.2332)属运维节奏问题,**本次未执行**。

## 最小交易金额

按项目方口径:一律**以产品字段为准**(100.00 元),不用「1 手 × 净值」推算金额 ——
后者会与字段值不一致,客户会看到两套数字。docs/42 的 1.1 节据此重写。

## 工具改名

tools/backfill_product_fees.py → tools/backfill_product_snapshot.py:扩展为
净值 + 费率统一入口,新增 --codes 限定范围(避免顺手把全部快照刷成最新,
全量刷新是 ProductHistorySyncService / MarketQuoteSyncService 的职责)。
安全设计不变:默认 dry-run、默认只补 NULL(净值永不为空,所以刷净值需显式
--overwrite)、写入走单事务。

门禁:ruff 通过 / mypy 249 文件 0 错 / 单元+契约 1377 passed 2 skipped。
2026-09-13 19:47:10 +08:00

193 lines
7.6 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.
"""把真实行情快照(净值 + 费率)回填到 `fin_product`(默认 dry-run,只有 `--apply` 才写库)。
## 为什么需要它
`fin_product` 里两个费率字段在本机 **20 只里 19 只为空**,客户问"管理费多少"只能转人工;
`current_nav` 则有几只被人为改成演示值。这些都能从东方财富取到
(`EastmoneyFundAdapter.fetch_history` / `fetch_fees`),本脚本负责落到库里。
## 口径
- **净值取 `DWJZ`(单位净值)**,不是场内交易价。这条口径由既有数据确定:
库里 19 只的 `current_nav` 与 `fetch_history` 的 `DWJZ` 逐只一致。
货币 ETF 是唯一容易搞混的 —— 它 `QUOTE_API` 的 `f2` 约 100 元(场内价),
而 `DWJZ` 是 0.2332 这种量级,**两者不是一回事,不要混用**。
- 费率取 f10 基金概况页(管理费/托管费),单位是「%/年」。
## 安全设计
1. **默认 dry-run**:只打印将执行的 UPDATE,不写库。真写要显式 `--apply`。
2. **默认只补空值**:`NULL` 才写,**已有值一律不动** —— 已有值可能是人为设定。
净值字段**从来不为空**,所以刷新净值必须显式给 `--overwrite`。
3. **`--codes` 限定范围**:只回填指定产品,避免"顺手"把全部快照刷成最新
(全量刷新是 `ProductHistorySyncService` / `MarketQuoteSyncService` 的职责)。
4. 写入走单个事务,任一失败整体回滚。
## 用法
python tools/backfill_product_snapshot.py # 看将改什么
python tools/backfill_product_snapshot.py --apply # 只补空值(费率)
python tools/backfill_product_snapshot.py --apply --overwrite --codes 510300
# 连净值和已有费率一起改
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from datetime import UTC, date, datetime, time
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, current_nav, current_nav_at,
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"),
)
#: 净值快照写回时的时间部分:与库里既有记录的 `15:00:00` 保持一致(收盘时刻)。
NAV_AT_TIME = time(15, 0, 0)
def _same(left: object, right: object) -> bool:
"""比较两个数值文本,容忍 `0.5` 与 `0.5000`、`4.5` 与 `4.500000` 这种表示差异。"""
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,不动人为设定过的值)",
)
parser.add_argument(
"--codes", default="",
help="只处理这些产品代码(逗号分隔);留空表示全部",
)
args = parser.parse_args()
wanted = {code.strip() for code in args.codes.split(",") if code.strip()}
async with SessionFactory() as session:
rows = (await session.execute(text(PRODUCT_QUERY))).mappings().all()
if wanted:
rows = [row for row in rows if str(row["product_code"]) in wanted]
if not rows:
print("没有匹配的产品。")
return 1
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,
)
fees = await adapter.fetch_fees(codes)
print(f"产品 {len(rows)} 只;取到净值 {sum(1 for h in histories if isinstance(h, dict) and not h.get('degraded'))} 只"
f"、费率 {len(fees)} 只\n")
updates: list[tuple[int, str, str, object]] = []
conflicts: list[str] = []
for row, history in zip(rows, histories, strict=True):
code = str(row["product_code"])
live_nav: object = None
nav_at: datetime | None = None
if isinstance(history, dict) and not history.get("degraded"):
live_nav = history.get("nav")
nav_date = history.get("nav_date")
if isinstance(nav_date, date):
nav_at = datetime.combine(nav_date, NAV_AT_TIME)
if live_nav is not None:
stored_nav = row["current_nav"]
if _same(stored_nav, live_nav):
pass
elif stored_nav is not None and not args.overwrite:
conflicts.append(
f"{code} {row['product_name']} 的 current_nav:"
f"库里 {stored_nav} → 真实 {live_nav}"
)
else:
updates.append((int(row["id"]), code, "current_nav", live_nav))
if nav_at is not None:
updates.append((int(row["id"]), code, "current_nav_at", nav_at))
live_fee = fees.get(code) or {}
for column, key in FEE_FIELDS:
live_value = live_fee.get(key)
if live_value is None:
continue
stored = row[column]
if _same(stored, live_value):
continue
if stored is not None and not args.overwrite:
conflicts.append(
f"{code} {row['product_name']} 的 {column}:库里 {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()))