Files
group_xinghuo_jinrong/scripts/core/fetch_nav.py
T

214 lines
11 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
"""联网拉取**真实**基金历史净值,生成 `core_product_nav` 种子 SQL。
背景(为什么要有这个脚本)
------------------------------------------------------------------
旧种子 `06-seed-nav.sql` 只写了**一天**、且是人造数值(`nav_date` 写死 2026-09-04,
`PROD-110022` 净值 1.0300)。T+1 受理/确认分离模型要求「T 日受理、T+1 按 **T 日净值**确认」,
净值只有一天 ⇒ 只要跑批日期不是那一天,T 日净值必然缺失,确认流程直接卡死。
因此本脚本做两件事:
1. **联网**从天天基金(东方财富)公开接口拉取真实历史净值,生成多日种子;
2. 提供**增量补拉**能力:真实净值只到「今天」,明后天再跑演示仍会缺 T 日净值,
重跑本脚本即可把最新净值补进种子(不必再人造数据)。
数据来源(可复核)
------------------------------------------------------------------
- 接口:`https://fund.eastmoney.com/pingzhongdata/{基金代码}.js`
- 内容:该基金自成立以来的 `Data_netWorthTrend`(每日单位净值 + 日涨幅)。
- 用法:纯标准库 `urllib`,无第三方依赖(本项目红线:不新增第三方依赖)。
产品 ↔ 真实净值来源的映射(**按类型 + 风险等级配对**,不是按产品 ID 字面代码)
------------------------------------------------------------------
项目里 `PROD-110022` 这类 ID 只是长得像基金代码,实际名字与类型是**虚构**的
(如 `PROD-110022` 叫「稳健债基 A」/ bond / R1,但真实 110022 是「易方达消费行业股票」,
日波动 −1.63%,拿它当债基净值会让风控演示结论失真)。
故本脚本为每个产品**单独指定一只类型匹配的真实基金**作为净值数值来源,
只借用其数值与波动特征,基金名称照写在生成 SQL 的注释里,便于复核。
货币基金(`PROD-000001` 现金宝货币)
------------------------------------------------------------------
真实业务中货币基金**单位净值恒为 1.0000 元**,收益以份额结转,不存在净值序列
(实测:天弘余额宝 000198、广发钱袋子 000509 在该接口均无 `Data_netWorthTrend`)。
故货基不联网,直接按真实业务生成恒定 1.0000、日涨幅 0。
用法
------------------------------------------------------------------
python scripts/core/fetch_nav.py # 拉最近 60 个交易日,写 06-seed-nav.sql
python scripts/core/fetch_nav.py --days 120 # 拉更长区间
python scripts/core/fetch_nav.py --stdout # 只打印、不落盘
python scripts/core/fetch_nav.py --cache-dir .navcache # 复用/保存原始 js,离线可重放
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import re
import sys
import urllib.request
from pathlib import Path
# ── 配置 ────────────────────────────────────────────────────────────────
PING_URL = "https://fund.eastmoney.com/pingzhongdata/{code}.js"
HEADERS = {
"User-Agent": "Mozilla/5.0",
"Referer": "https://fund.eastmoney.com/",
}
# product_id -> (真实基金代码, 真实基金名称, 配对理由)
NAV_SOURCE: dict[str, tuple[str, str, str]] = {
"PROD-110022": ("000191", "富国信用债债券A/B", "bond/R1:纯债,日波动 0.02%"),
"PROD-110023": ("000032", "易方达信用债债券A", "bond/R2:信用债,日波动 0.09%"),
"PROD-005827": ("005827", "易方达蓝筹精选混合", "mixed/R3:代码即真身,股债平衡"),
"PROD-005828": ("001182", "易方达安心回馈混合A", "mixed/R2:低波混合,日波动 0.73%"),
"PROD-161725": ("161631", "融通人工智能指数(LOF)A", "stock/R4:科技主题,日波动 2.57%"),
"PROD-161726": ("110022", "易方达消费行业股票", "stock/R4:消费主题,代码即真身"),
"PROD-003095": ("003095", "中欧医疗健康混合A", "stock/R4:医药主题,代码即真身"),
"PROD-510300": ("510300", "沪深300ETF华泰柏瑞", "index/R3:宽基指数,代码即真身"),
"PROD-510500": ("510500", "中证500ETF南方", "index/R4:宽基指数,代码即真身"),
"PROD-XYZ999": ("320007", "诺安成长混合A", "stock/R5:高波动成长,日波动 1.76%"),
"PROD-000002": ("014437", "鹏华中证同业存单AAA指数7天持有", "bond/R1:同业存单,日波动 0.01%"),
"PROD-WMG001": ("004155", "中信保诚至泰中短债A", "wealth_mgmt/R2:中短债,日波动 0.02%"),
"PROD-PRIV01": ("519062", "海富通阿尔法对冲混合A", "private_fund/R4:量化对冲,日波动 0.33%"),
}
# 货币基金:净值恒为 1.0000(真实业务,不联网)
MONEY_FUNDS: dict[str, str] = {
"PROD-000001": "现金宝货币(money/R1:货基单位净值恒为 1 元,收益以份额结转)",
}
DEFAULT_OUT = Path(__file__).with_name("06-seed-nav.sql")
DEFAULT_DAYS = 60
TIMEOUT = 30
# ── 抓取与解析 ───────────────────────────────────────────────────────────
def fetch_js(code: str, cache_dir: Path | None) -> str:
"""拉取(或读缓存)某只基金的 pingzhongdata js 文本。"""
dest = cache_dir / f"{code}.js" if cache_dir else None
if dest and dest.exists():
return dest.read_text(encoding="utf-8", errors="ignore")
req = urllib.request.Request(PING_URL.format(code=code), headers=HEADERS)
text = urllib.request.urlopen(req, timeout=TIMEOUT).read().decode("utf-8", errors="ignore")
if dest:
cache_dir.mkdir(parents=True, exist_ok=True)
dest.write_text(text, encoding="utf-8")
return text
def parse_series(text: str) -> list[tuple[dt.date, float, float]]:
"""从 js 里解析 `(nav_date, nav, 日涨幅%)` 序列,按日期升序。"""
match = re.search(r"var\s+Data_netWorthTrend\s*=\s*(\[.*?\]);", text, re.S)
if not match:
return []
raw = json.loads(match.group(1))
out: list[tuple[dt.date, float, float]] = []
for item in raw:
day = dt.datetime.fromtimestamp(item["x"] / 1000).date()
out.append((day, float(item["y"]), float(item.get("equityReturn") or 0)))
out.sort(key=lambda r: r[0])
return out
# ── 生成 SQL ─────────────────────────────────────────────────────────────
def build_sql(rows: list[tuple[str, dt.date, float, float]], fetched_at: str) -> str:
"""rows = (product_id, nav_date, nav, chg_pct)"""
sources = "\n".join(
f"-- {pid:<13} <- {code} {name}({why})"
for pid, (code, name, why) in sorted(NAV_SOURCE.items())
)
money = "\n".join(f"-- {pid:<13} <- 恒定 1.0000({why})" for pid, why in sorted(MONEY_FUNDS.items()))
dates = [r[1] for r in rows]
head = (
"USE jinrong_core;\n\n"
"-- 基金净值种子:**真实历史净值,非人造数值**\n"
"-- 生成方式:python scripts/core/fetch_nav.py --days {n}\n"
"-- 数据来源:天天基金(东方财富)公开接口 https://fund.eastmoney.com/pingzhongdata/{{代码}}.js\n"
"-- 抓取时间:{fetched}\n"
"-- 区间:{start} ~ {end}(共 {days} 个净值发布日)\n"
"--\n"
"-- 产品 ↔ 净值来源映射(产品 ID 为虚构,数值借用下列真实基金,按类型/风险等级配对):\n"
"{sources}\n"
"{money}\n"
"--\n"
"-- ⚠️ 真实净值只到抓取当日。之后运行需重跑 fetch_nav.py 增量补拉,\n"
"-- 否则 T+1 确认会取不到 T 日净值(真实业务同样是「净值 T+1 才公告」)。\n\n"
).format(
n=DEFAULT_DAYS,
fetched=fetched_at,
start=min(dates),
end=max(dates),
days=len(set(dates)),
sources=sources,
money=money,
)
lines: list[str] = []
cur_pid = None
for pid, day, nav, chg in rows:
if pid != cur_pid:
if cur_pid is not None:
lines[-1] = lines[-1].rstrip(",") + ";" # 上一组收尾
lines.append("INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) VALUES")
cur_pid = pid
lines.append(f"('{pid}', {nav:.4f}, {chg:.4f}, '{day}'),")
if lines:
lines[-1] = lines[-1].rstrip(",") + ";"
return head + "\n".join(lines) + "\n"
# ── 主流程 ───────────────────────────────────────────────────────────────
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="联网拉取真实基金净值并生成 core_product_nav 种子 SQL")
ap.add_argument("--days", type=int, default=DEFAULT_DAYS, help="取最近多少个净值发布日(默认 60)")
ap.add_argument("--out", type=Path, default=DEFAULT_OUT, help="输出 SQL 路径")
ap.add_argument("--stdout", action="store_true", help="只打印到标准输出,不写文件")
ap.add_argument("--cache-dir", type=Path, default=None, help="原始 js 缓存目录(可离线重放)")
args = ap.parse_args(argv)
fetched_at = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
rows: list[tuple[str, dt.date, float, float]] = []
failed: list[str] = []
for pid, (code, name, _why) in sorted(NAV_SOURCE.items()):
try:
series = parse_series(fetch_js(code, args.cache_dir))
except Exception as exc: # 网络/解析失败不静默:记下来,最后汇总报错
failed.append(f"{pid}({code} {name}): {exc}")
continue
if not series:
failed.append(f"{pid}({code} {name}): 接口无 Data_netWorthTrend")
continue
for day, nav, chg in series[-args.days:]:
rows.append((pid, day, nav, chg))
print(f"[OK] {pid} <- {code} {name}:{len(series[-args.days:])} 条,最新 {series[-1][0]} {series[-1][1]}")
# 货币基金:净值恒定 1.0000
if rows:
money_days = sorted({r[1] for r in rows})
for pid in sorted(MONEY_FUNDS):
for day in money_days:
rows.append((pid, day, 1.0000, 0.0))
print(f"[OK] {pid} <- 货基恒定净值 1.0000:{len(money_days)} 条")
if failed:
print("\n[FAIL] 以下产品拉取失败:", file=sys.stderr)
for item in failed:
print(" - " + item, file=sys.stderr)
return 1
rows.sort(key=lambda r: (r[0], r[1]))
sql = build_sql(rows, fetched_at)
if args.stdout:
print(sql)
else:
args.out.write_text(sql, encoding="utf-8")
print(f"\n已写入 {args.out}({len(rows)} 行)")
return 0
if __name__ == "__main__":
raise SystemExit(main())