`fin_nav_history` 一直是**空表**,所以产品详情页画不出走势图 —— 此前那条曲线是
前端 `mock-data.js` 里编的 12 个点位,接入公开产品接口时把它去掉了
(走势图最容易被当成真实业绩),页面改为显示"尚未接入"。本次补上完整链路。
## 1. 取数(app/infrastructure/fund_market_adapter.py)
新增 `fetch_nav_history()`:东方财富历史净值接口(`api.fund.eastmoney.com/f10/lsjz`)
分页取序列。
- **为什么不复用 `fetch_kline`**:它走 `push2his.eastmoney.com`,
该域名在本环境实测连接被拒(`RemoteProtocolError`);
- **为什么不复用 `hq.get_southern_fund_nav_history`**:那个函数校验**南方基金白名单**,
而 `fin_product` 里有非南方基金的产品(510300 是华泰柏瑞的),调它会直接 `ValueError`。
⚠️ 实现里踩到一个坑:接口**会忽略请求中的 `pageSize`**(实测固定每次返回 20 条)。
最初按硬编码的 30 判断"是否最后一页",于是 `len(items) < 30` 永远成立、只取到第一页 ——
走势图看上去"有数据",其实只有最近 20 天,而且毫无报错。
改为按首屏**实际条数** + `TotalCount` 推算页数后,同样区间取到 124 条。
## 2. 落库(tools/sync_nav_history.py,新增)
写入 `fin_nav_history`,按 `(product_id, nav_date)` 幂等 upsert。
实测:20 只产品 / **2513 行** / 2026-03-17 ~ 09-13;重跑**新写入 0 行**。
## 3. 接口(P002)
`GET /api/v1/products/{product_code}/nav-history`,编号 **P002**,已登记 `docs/05` §19。
鉴权口径与 P001 相同(要求有效令牌、不校验权限码,访客令牌可用);`days` 有界 1–365。
**表为空时返回 `count=0` 与空数组,而不是报错** —— 调用方据此显示"尚未接入",
**不得回退到编造曲线**。产品不存在或未上市 → `404`(否则前端分不清"没有数据"
和"没有这只产品")。
## 4. 前端
详情页按序列画 SVG 折线,期数标题改为动态("近 N 个交易日")。
表为空时仍显示"尚未接入"占位,并补上此前缺失的 `.detail-chart__empty` 样式。
`product-detail.js` / `.css` / `index.html` 的缓存版本参数一并 bump 到 `-7`。
## 5. 演示数据
`tools/seed_demo_data.py` 增加第 5 步「历史净值」(现 **11 步**),
否则换台机器演示时走势图又会是空的。
验证:P002 实测 515450 / 510300 各 120 个净值点;ruff 通过;mypy 251 文件 0 错;
unit+contract 1391 passed;integration 108 passed;e2e 冒烟 40/40。
177 lines
6.4 KiB
Python
177 lines
6.4 KiB
Python
"""把真实历史净值同步进 `fin_nav_history`(产品详情页净值走势图的数据源)。
|
||
|
||
## 为什么需要它
|
||
|
||
`fin_nav_history` 一直是**空表**(0 行),所以产品详情页画不出净值走势。
|
||
此前那条曲线来自前端 `common/mock-data.js` 里编的 12 个点位 ——
|
||
2026-09-13 接入公开产品接口时把它去掉了(走势图最容易被当成真数据),
|
||
页面改为显式显示"历史净值数据尚未接入"。本脚本补上这条数据链路。
|
||
|
||
## 数据源
|
||
|
||
东方财富历史净值接口(`api.fund.eastmoney.com/f10/lsjz`)。
|
||
注意它与**行情**源不同:东财的 `push2` / `push2his` 两个行情域名在本环境实测
|
||
连接被拒,而这个净值域名可用 —— 所以历史走势走净值这条路。
|
||
|
||
不校验"南方基金白名单":`fin_product` 里有非南方基金的产品(510300 是华泰柏瑞的),
|
||
走 `hq.py` 的 `get_southern_fund_nav_history` 会被白名单直接拒绝。
|
||
|
||
## 幂等
|
||
|
||
按 `(product_id, nav_date)` upsert(表上有复合唯一键
|
||
`uk_fin_nav_history_product_id_nav_date`),重复跑不会产生重复行。
|
||
|
||
## 用法
|
||
|
||
python tools/sync_nav_history.py # 全部在售产品,近 180 天
|
||
python tools/sync_nav_history.py --days 365 # 取更长的历史
|
||
python tools/sync_nav_history.py --codes 510300,515450
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import asyncio
|
||
import sys
|
||
from datetime import UTC, date, datetime, timedelta
|
||
from decimal import Decimal
|
||
from pathlib import Path
|
||
|
||
from sqlalchemy import func, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
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
|
||
from app.model.fund import FundNavHistory, FundProduct # noqa: E402
|
||
|
||
if hasattr(sys.stdout, "reconfigure"):
|
||
sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
|
||
|
||
LISTED_STATUS = "上市"
|
||
|
||
|
||
async def _next_id(session: AsyncSession) -> int:
|
||
"""`fin_nav_history` 没有 AUTO_INCREMENT(`fin_*` 表一贯如此),自己算主键。"""
|
||
result = await session.execute(select(func.coalesce(func.max(FundNavHistory.id), 0)))
|
||
return int(result.scalar_one()) + 1
|
||
|
||
|
||
async def sync(codes: tuple[str, ...], days: int) -> dict[str, object]:
|
||
end = date.today()
|
||
start = end - timedelta(days=days)
|
||
now = datetime.now(UTC).replace(tzinfo=None)
|
||
adapter = EastmoneyFundAdapter()
|
||
|
||
written = 0
|
||
skipped: list[dict[str, str]] = []
|
||
per_product: list[dict[str, object]] = []
|
||
|
||
async with SessionFactory() as session:
|
||
query = select(FundProduct).where(FundProduct.status == LISTED_STATUS)
|
||
if codes:
|
||
query = query.where(FundProduct.product_code.in_(codes))
|
||
products = (
|
||
(await session.execute(query.order_by(FundProduct.product_code))).scalars().all()
|
||
)
|
||
|
||
next_id = await _next_id(session)
|
||
for product in products:
|
||
rows = await adapter.fetch_nav_history(
|
||
product.product_code, start_date=start, end_date=end
|
||
)
|
||
if not rows:
|
||
skipped.append({
|
||
"product_code": product.product_code,
|
||
"reason": "净值源没有返回数据",
|
||
})
|
||
continue
|
||
# 该产品已有的日期,避免重复插入(幂等)
|
||
existing = set(
|
||
(
|
||
await session.execute(
|
||
select(FundNavHistory.nav_date).where(
|
||
FundNavHistory.product_id == product.id
|
||
)
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
added = 0
|
||
for row in rows:
|
||
nav_date = date.fromisoformat(str(row["nav_date"]))
|
||
if nav_date in existing:
|
||
continue
|
||
session.add(FundNavHistory(
|
||
id=next_id,
|
||
product_id=int(product.id),
|
||
nav_date=nav_date,
|
||
nav=Decimal(str(row["nav"])),
|
||
created_at=now,
|
||
))
|
||
next_id += 1
|
||
added += 1
|
||
written += added
|
||
per_product.append({
|
||
"product_code": product.product_code,
|
||
"fetched": len(rows),
|
||
"inserted": added,
|
||
})
|
||
|
||
await session.commit()
|
||
|
||
return {
|
||
"requested": len(products),
|
||
"written": written,
|
||
"per_product": per_product,
|
||
"skipped": skipped,
|
||
"window": f"{start} ~ {end}",
|
||
}
|
||
|
||
|
||
def summarize(result: dict[str, object]) -> str:
|
||
lines = [
|
||
f"区间:{result['window']}",
|
||
f"产品 {result['requested']} 只;新写入 {result['written']} 行",
|
||
]
|
||
per_product = result.get("per_product") or []
|
||
if per_product:
|
||
lines.append("")
|
||
lines.append("每只产品(取到 / 新写入):")
|
||
for item in per_product: # type: ignore[union-attr]
|
||
lines.append(
|
||
f" · {item['product_code']} {item['fetched']} 条 / 新增 {item['inserted']} 行"
|
||
)
|
||
skipped = result.get("skipped") or []
|
||
if skipped:
|
||
lines.append("")
|
||
lines.append("没取到数据的产品:")
|
||
for item in skipped: # type: ignore[union-attr]
|
||
lines.append(f" · {item['product_code']} {item['reason']}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
async def main() -> int:
|
||
parser = argparse.ArgumentParser(description="同步历史净值到 fin_nav_history")
|
||
parser.add_argument("--codes", default="", help="只同步这些产品代码(逗号分隔)")
|
||
parser.add_argument("--days", type=int, default=180, help="回溯天数,默认 180")
|
||
args = parser.parse_args()
|
||
|
||
codes = tuple(code.strip() for code in args.codes.split(",") if code.strip())
|
||
result = await sync(codes, args.days)
|
||
print(summarize(result))
|
||
|
||
if not result.get("written") and not result.get("per_product"):
|
||
print("\n[警告] 一行都没写 —— 详情页走势图仍然没有数据。")
|
||
return 1
|
||
print("\n完成。产品详情页的净值走势图现在有数据了。")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(asyncio.run(main()))
|