按项目方决定处理三件事。 ## 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。
193 lines
7.6 KiB
Python
193 lines
7.6 KiB
Python
"""把真实行情快照(净值 + 费率)回填到 `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()))
|