Files
group_fqcd_jr/tools/backfill_product_snapshot.py
T

193 lines
7.6 KiB
Python
Raw Normal View History

"""把真实行情快照(净值 + 费率)回填到 `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()))