Merge remote-tracking branch 'origin/qyqy_develop' into RM2_develop

This commit is contained in:
zhangshy
2026-09-13 23:52:17 +08:00
14 changed files with 480 additions and 24 deletions
+17 -1
View File
@@ -1,6 +1,6 @@
from typing import Any, Literal
from fastapi import APIRouter, Depends, Header
from fastapi import APIRouter, Depends, Header, Query
from pydantic import Field
from app.api.dependencies.auth import build_request_context
@@ -134,3 +134,19 @@ async def list_products(
且不带权限,与 `/api/v1/conversations`、`/api/v1/agent-runs` 的访客口径一致。
"""
return await PublicProductService().list_products(context)
@router.get("/products/{product_code}/nav-history")
async def product_nav_history(
product_code: str,
days: int = Query(default=90, ge=1, le=365),
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
"""产品历史净值序列(编号 `P002`):产品详情页净值走势图的数据源。
数据来自 `fin_nav_history`,由 `tools/sync_nav_history.py` 从东财净值接口同步。
鉴权口径与 P001 相同:**要求有效令牌但不校验权限码**(访客令牌可用)。
表为空时返回 `count=0` 与空数组,**不是错误** —— 前端据此显示"尚未接入"。
"""
return await PublicProductService().nav_history(product_code, context, days=days)
+71
View File
@@ -47,6 +47,14 @@ FEE_FIELD_PATTERNS: dict[str, str] = {
}
def _as_int(value: Any) -> int | None:
"""把接口返回的计数字段转成 int;拿不到就返回 None(不编造)。"""
try:
return int(value) if value is not None else None
except (TypeError, ValueError):
return None
class FundMarketAdapter(Protocol):
async def fetch_names(self, codes: list[str]) -> dict[str, str]: ...
@@ -54,6 +62,10 @@ class FundMarketAdapter(Protocol):
async def fetch_history(self, code: str, target_date: date) -> dict[str, Any]: ...
async def fetch_nav_history(
self, code: str, *, start_date: date, end_date: date
) -> list[dict[str, Any]]: ...
async def fetch_fees(self, codes: list[str]) -> dict[str, dict[str, Any]]: ...
async def fetch_kline(
@@ -136,6 +148,65 @@ class EastmoneyFundAdapter:
"degraded": False,
}
async def fetch_nav_history(
self, code: str, *, start_date: date, end_date: date, max_pages: int = 8
) -> list[dict[str, Any]]:
"""取一只基金在区间内的**历史单位净值序列**(东财 `f10/lsjz` 分页)。
与 `fetch_history` 的区别:那个只返回**一天**(命中所需日期的那条);
本方法返回**整段序列**,供产品详情页画净值走势图。
⚠️ 为什么不复用 `fetch_kline`:它走 `push2his.eastmoney.com`,
而该域名在本环境实测**连接被拒**(`RemoteProtocolError`)——
历史走势只剩净值接口这一条可用路径。
⚠️ 为什么不复用 `hq.get_southern_fund_nav_history`:那个函数**校验南方基金
白名单**,而 `fin_product` 里有非南方基金的产品(510300 是华泰柏瑞的),
调它会直接 `ValueError`。这里只按代码取数,不做归属判断。
分页按 `TotalCount` 推算页数;`max_pages` 是**兜底上限**(防止接口给出异常大的
总页数时把所有页拉一遍),默认 8 页≈160 个交易日,够画大半年走势。
取不到就结束,不抛异常 —— 历史净值缺失不该让整页失败。
"""
collected: dict[str, Decimal] = {}
page_size = 0
page_count = max_pages
for page_index in range(1, page_count + 1):
try:
response = await self._request(
"GET", NAV_API,
params={
"fundCode": code,
"pageIndex": page_index,
"pageSize": 30,
"startDate": start_date.isoformat(),
"endDate": end_date.isoformat(),
},
)
data = response.json().get("Data") or {}
items = data.get("LSJZList") or []
total = _as_int(data.get("TotalCount"))
except (RecoverableAgentError, ValueError, TypeError):
break
if not items:
break
if page_index == 1:
# ⚠️ 接口**会忽略请求里的 `pageSize`**(实测固定每次返回 20 条),
# 所以页大小必须取首屏的**实际条数**、并按 `TotalCount` 推算总页数。
# 曾经这里写死 30,于是 `len(items) < 30` 永远成立、只取到第一页:
# 详情页的走势图看上去"有数据",其实只有最近 20 天,且毫无报错。
page_size = len(items)
if total:
page_count = min((total + page_size - 1) // page_size, max_pages)
for item in items:
day = str(item.get("FSRQ") or "")
nav = self._decimal(item.get("DWJZ"))
if day and nav is not None and day not in collected:
collected[day] = nav
if len(items) < page_size:
break
return [{"nav_date": day, "nav": collected[day]} for day in sorted(collected)]
async def fetch_fees(self, codes: list[str]) -> dict[str, dict[str, Any]]:
"""取每只基金的费率(管理费/托管费/销售服务费/申购费/赎回费)与基金全称。
+49 -1
View File
@@ -36,8 +36,9 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.contracts import RequestContext
from app.core.errors import GenericResourceNotFoundError
from app.infrastructure.db import SessionFactory
from app.model.fund import FundMarketPrice, FundProduct
from app.model.fund import FundMarketPrice, FundNavHistory, FundProduct
from app.service.admin_service import public
#: 只暴露在售产品(`fin_product.status`)
@@ -67,6 +68,53 @@ class PublicProductService:
"meta": {"trace_id": context.trace_id},
}
async def nav_history(
self, product_code: str, context: RequestContext, *, days: int = 90
) -> dict[str, Any]:
"""一只产品的历史净值序列,供产品详情页画走势图。
数据来自 `fin_nav_history`(由 `tools/sync_nav_history.py` 从东财净值接口同步)。
表里没数据时返回**空序列而不是报错** —— 详情页据此显示"尚未接入",
而不是整页失败。
"""
async with SessionFactory() as session:
product = (
await session.execute(
select(FundProduct).where(
FundProduct.product_code == product_code,
FundProduct.status == LISTED_STATUS,
)
)
).scalar_one_or_none()
if product is None:
raise GenericResourceNotFoundError("产品不存在或未上市")
rows = (
(
await session.execute(
select(FundNavHistory)
.where(FundNavHistory.product_id == product.id)
.order_by(FundNavHistory.nav_date.desc())
.limit(days)
)
)
.scalars()
.all()
)
# 倒序取最近 N 条,再翻回**日期升序**给前端画图
points = [
{"nav_date": row.nav_date.isoformat(), "nav": public(row.nav, "nav")}
for row in reversed(rows)
]
return {
"data": {
"product_code": product.product_code,
"product_name": product.product_name,
"points": points,
"count": len(points),
},
"meta": {"trace_id": context.trace_id},
}
@staticmethod
async def _latest_quotes(
session: AsyncSession, product_ids: list[int]
+1
View File
@@ -4,6 +4,7 @@ const ENDPOINTS = Object.freeze({
A034: { method: 'POST', path: '/api/v1/auth/tokens', auth: false },
V001: { method: 'POST', path: '/api/v1/visitor-tokens', auth: false, raw: true },
P001: { method: 'GET', path: '/api/v1/products' },
P002: { method: 'GET', path: '/api/v1/products/{productCode}/nav-history' },
C001: { method: 'POST', path: '/api/v1/conversations', idempotent: true },
C002: { method: 'GET', path: '/api/v1/conversations/{sessionId}' },
C003: { method: 'GET', path: '/api/v1/conversations/{sessionId}/messages' },
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>基金详情 · 南方财富</title>
<link rel="stylesheet" href="/static/portal/common/base.css">
<link rel="stylesheet" href="/static/portal/guest/product-detail/product-detail.css?v=20260913-6">
<link rel="stylesheet" href="/static/portal/guest/product-detail/product-detail.css?v=20260913-7">
</head>
<body>
<main id="main-content" class="page-shell" data-detail-root>
@@ -13,11 +13,11 @@
<div class="source-notice" role="note" data-source-notice></div>
<div class="product-note" role="note" data-product-note hidden></div>
<section class="detail-grid">
<article class="panel detail-chart"><div class="panel__header"><h2 class="panel__title">历史净值</h2><span class="section-heading__meta">近十二期</span></div><div class="panel__body"><div class="detail-chart__quote"><strong data-current-nav>--</strong><span data-change>--</span></div><svg class="detail-chart__svg" viewBox="0 0 760 280" role="img" aria-label="基金历史净值走势" data-chart></svg><div class="detail-chart__axis"><span data-start-date></span><span data-end-date></span></div></div></article>
<article class="panel detail-chart"><div class="panel__header"><h2 class="panel__title">历史净值</h2><span class="section-heading__meta" data-chart-range>近 120 个交易日</span></div><div class="panel__body"><div class="detail-chart__quote"><strong data-current-nav>--</strong><span data-change>--</span></div><svg class="detail-chart__svg" viewBox="0 0 760 280" role="img" aria-label="基金历史净值走势" data-chart></svg><div class="detail-chart__axis"><span data-start-date></span><span data-end-date></span></div></div></article>
<aside class="panel"><div class="panel__header"><h2 class="panel__title">产品要素</h2></div><div class="panel__body detail-facts" data-product-facts></div></aside>
</section>
<section class="panel risk-disclosure"><div class="panel__header"><h2 class="panel__title">风险与交易说明</h2></div><div class="panel__body"><div class="risk-disclosure__grid"><div><strong>风险等级</strong><span data-risk-level></span></div><div><strong>最小金额</strong><span data-min-amount></span></div><div><strong>交易方式</strong><span>场内市价模拟成交</span></div></div><p>基金净值会随市场变化。历史数据不代表未来表现,交易前应结合自身风险承受能力判断。</p></div></section>
</main>
<script type="module" src="/static/portal/guest/product-detail/product-detail.js?v=20260913-6"></script>
<script type="module" src="/static/portal/guest/product-detail/product-detail.js?v=20260913-7"></script>
</body>
</html>
@@ -8,6 +8,8 @@
.detail-chart__grid { fill: none; stroke: var(--line); stroke-width: 1; }
.detail-chart__line { fill: none; stroke: var(--brand); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
.detail-chart__axis { display: flex; justify-content: space-between; color: var(--muted); font-size: var(--fs-small); }
/* 无历史净值时的占位:撑到与图表相近的高度,避免整块塌陷成一行小字。 */
.detail-chart__empty { display: flex; align-items: center; justify-content: center; min-height: 240px; margin: var(--space-4) 0 0; padding: var(--space-4); color: var(--muted); background: var(--surface); border: 1px dashed var(--line); border-radius: var(--radius-sm); font-size: var(--fs-small); text-align: center; }
.detail-facts { display: grid; padding-top: var(--space-2); padding-bottom: var(--space-2); }
.detail-facts__row { min-height: var(--control-height); display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); border-bottom: 1px solid var(--line); }
.detail-facts__row span { color: var(--muted); }
@@ -16,7 +16,7 @@ function orDash(value, render) {
return value === null || value === undefined || value === '' ? '--' : render(value);
}
function render(product) {
function render(product, points) {
document.title = `${product.product_name} · 南方财富`;
document.querySelector('[data-product-name]').textContent = product.product_name;
document.querySelector('[data-product-meta]').textContent =
@@ -65,11 +65,45 @@ function render(product) {
].map(([label, value]) =>
`<div class="detail-facts__row"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`).join('');
// 没有真实历史净值时不绘制虚构曲线,避免把 mock 数据误认为行情。
document.querySelector('[data-chart]').outerHTML =
'<p class="detail-chart__empty">历史净值数据尚未接入,暂不展示走势图。</p>';
document.querySelector('[data-start-date]').textContent = '';
document.querySelector('[data-end-date]').textContent = product.latest_trade_date || '';
renderChart(points);
}
/**
* 画历史净值走势。数据来自 `GET /api/v1/products/{code}/nav-history`(P002),
* 即 `fin_nav_history` 表 —— 由 `tools/sync_nav_history.py` 从东财净值接口同步。
*
* ⚠️ 取不到数据时**不画任何曲线**:此前那条线是演示数据里编的 12 个点位,
* 而走势图最容易被当成真实业绩,宁可不画,也要把"尚未接入"说清楚。
*/
function renderChart(points) {
const chart = document.querySelector('[data-chart]');
const startNode = document.querySelector('[data-start-date]');
const endNode = document.querySelector('[data-end-date]');
const rangeNode = document.querySelector('[data-chart-range]');
if (!points.length) {
chart.outerHTML =
'<p class="detail-chart__empty">历史净值数据尚未接入,暂不展示走势图。</p>';
startNode.textContent = '';
endNode.textContent = '';
if (rangeNode) rangeNode.textContent = '';
return;
}
const values = points.map((item) => Number(item.nav));
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const step = values.length > 1 ? 740 / (values.length - 1) : 0;
const coords = values
.map((value, index) => {
const x = index * step + 10;
const y = 250 - ((value - min) / range) * 210;
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
chart.innerHTML = `<path class="detail-chart__grid" d="M10 40H750M10 110H750M10 180H750M10 250H750"/><polyline class="detail-chart__line" points="${coords}"/>`;
startNode.textContent = points[0].nav_date;
endNode.textContent = points[points.length - 1].nav_date;
if (rangeNode) rangeNode.textContent = `近 ${points.length} 个交易日`;
}
async function load() {
@@ -78,7 +112,23 @@ async function load() {
const { data } = await apiClient.get('P001', { headers });
const products = data?.products || [];
if (!products.length) throw new Error('产品库暂时没有可展示的产品');
render(products.find((item) => item.product_code === requestedCode) || products[0]);
const product = products.find((item) => item.product_code === requestedCode) || products[0];
// 历史净值单独取,且**失败不影响产品信息展示**(走势图会显示"尚未接入"):
// 产品要素是主内容,不该因为一张图取不到就整页报错。
let points = [];
try {
const history = await apiClient.get('P002', {
headers,
pathParams: { productCode: product.product_code },
query: { days: 120 },
});
points = history.data?.points || [];
} catch {
points = [];
}
render(product, points);
} catch (error) {
renderError(root, error, load);
}
+17 -4
View File
@@ -1163,6 +1163,7 @@ GET /internal/metrics
| T008 | `GET /api/v1/users/me/transactions/{txn_no}` | `trade:txn:read`(资源所有者) | 否 | `200` | 成交详情 |
| T009 | `GET /api/v1/users/me/cash-ledger` | `account:read:self`(已登录) | 否 | `200` | 资金账本(按 id 倒序游标分页) |
| P001 | `GET /api/v1/products` | 仅要求有效令牌(访客令牌即可,不校验权限码) | 否 | `200` | 否 |
| P002 | `GET /api/v1/products/{product_code}/nav-history` | 同上;`days` 取 1–365,默认 90 | 否 | `200` | 否 |
> **T001 – T009 的四点说明**:
>
@@ -1208,10 +1209,22 @@ GET /internal/metrics
> - **`change_pct` 可能为 `null`,调用方必须显示"暂无"而不得当成 `0`。**
> 当日涨跌幅需要**两个交易日**的收盘价,而行情可能只同步过一天。
> 把 `null` 读成 `0` 等于对客户说"今天平盘",那是编出来的结论。
> - **首版不提供历史净值/走势曲线**:`fin_nav_history` 目前为空,走势图数据源尚未接入。
> 首页推荐位、产品列表页、产品详情页共用本端点;此前它们读的是前端手写的
> `app/static/portal/common/mock-data.js`(8 只演示数据,其中 6 只不在
> `fin_product` 里),该文件已随本次接入删除。
> - **首页推荐位、产品列表页、产品详情页共用本端点**;产品详情页的净值走势图另走 P002。
> 此前这三页读的是前端手写的 `app/static/portal/common/mock-data.js`
> (8 只演示数据,其中 6 只不在 `fin_product` 里),该文件已随本次接入删除。
>
> **P002(历史净值序列)的三点说明**:
>
> - **用途**:产品详情页的净值走势图。数据来自 `fin_nav_history`,
> 由 `tools/sync_nav_history.py` 从东方财富历史净值接口
> (`api.fund.eastmoney.com/f10/lsjz`)同步 —— 注意该域名与**行情**域名不同:
> 东财的 `push2` / `push2his` 在本环境连接被拒,所以历史走势走净值这条路。
> - **表为空时返回 `count=0` 与空数组,不是错误**:调用方据此显示"尚未接入",
> 而**不得回退到编造的曲线**。此前详情页那条线是演示数据里 12 个假点位,
> 走势图最容易被当成真实业绩。
> - **不校验基金归属**:`fin_product` 里有非南方基金的产品(510300 是华泰柏瑞的),
> 所以这里不套 `hq.py` 的南方基金白名单,只按 `product_code` 取数。
> 产品不存在或未上市 → `404`。
## 20. 变更流程
+4 -2
View File
@@ -92,8 +92,10 @@ python -m app.worker
> 1. `change_pct` **可能是 `null`**(行情只同步过一个交易日时算不出涨跌)。
> 页面必须显示"暂无"—— `formatPercent` 收到 `null` 会渲染成 `+0.00%`,
> 那等于告诉客户"今天平盘"。
> 2. **历史净值走势图没有数据源**:`fin_nav_history` 目前 0 行,详情页**不画曲线**并显式说明。
> 此前那条曲线是 mock 里 12 个编造点位 —— 走势图最容易被当成真数据。
> 2. **历史净值走势图**:`fin_nav_history` 由 `tools/sync_nav_history.py` 同步
> (东财净值接口,近 120 个交易日,实测 20 只 / 2513 行)。
> **表为空时不画曲线**、显式显示"尚未接入",绝不回退到编造点位 ——
> 走势图最容易被当成真实业绩。
### 2.5 访客智能客服浮窗 ⭐ 本轮重点
+3 -2
View File
@@ -88,8 +88,9 @@ python tools/e2e_smoke_test.py --read-only
> ⚠️ 两个**可能被问到、但其实不是故障**的地方:
> - 产品列表的「最近涨跌」列当前显示 **"暂无"** —— 当日涨跌要**两个交易日**的收盘价才算得出,
> 而行情库目前只有一个交易日。跑过第二次行情同步后自然会显示。我们**不编这个数**。
> - 产品详情的**历史净值走势图显示"尚未接入"** —— `fin_nav_history` 目前是空的。
> 此前那条曲线是演示数据里编的 12 个点位,已随接口接入一并去掉。
> - 产品详情的**净值走势图现在是真实数据**:`fin_nav_history` 由
> `tools/sync_nav_history.py`(演示数据第 5 步)从东财净值接口同步,近 **120 个交易日**。
> 若图上显示"尚未接入",说明第 5 步没跑 —— 补跑即可,不用重启服务。
### 场景 2 · 客户资产(1 min)
@@ -63,3 +63,23 @@ async def test_products_endpoint_is_read_only(method: str) -> None:
assert response.status_code in (401, 405), (
f"{method} {PRODUCTS_PATH} -> {response.status_code},只读端点不应接受写方法"
)
NAV_HISTORY_PATH = "/api/v1/products/510300/nav-history"
async def test_nav_history_endpoint_is_registered_and_protected() -> None:
"""P002 路由必须存在;缺 token 时 401(鉴权层拦下)而不是 404(漏注册)。"""
response = await send("GET", NAV_HISTORY_PATH)
assert response.status_code == 401, (
f"GET {NAV_HISTORY_PATH} 未授权应 401,实际 {response.status_code}(路由可能漏注册)"
)
async def test_nav_history_rejects_out_of_range_days() -> None:
"""`days` 有界(1–365):越界应被校验挡下,不能让调用方一次拉全表。"""
response = await send("GET", f"{NAV_HISTORY_PATH}?days=100000")
# 未带令牌时鉴权先失败也是可接受的;关键是**不能**是 200
assert response.status_code in (401, 422), (
f"days=100000 -> {response.status_code},越界应被拒绝"
)
@@ -73,3 +73,57 @@ async def test_visitor_token_can_read_listed_products() -> None:
# 价格类字段一律是字符串(与既有接口口径一致)
assert isinstance(first["current_nav"], str)
async def test_visitor_token_can_read_nav_history() -> None:
"""P002:访客能取到历史净值序列(详情页走势图的数据源)。
`fin_nav_history` 为空时返回 `count=0` 与空数组 —— 这是**合法响应**,
不是错误:前端据此显示"尚未接入",而不得回退到编造的曲线。
"""
app = create_app()
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport, base_url="http://test", timeout=30
) as client:
issued = await client.post("/api/v1/visitor-tokens")
token = issued.json()["access_token"]
auth = {"Authorization": f"Bearer {token}"}
listed = await client.get("/api/v1/products", headers=auth)
products = listed.json()["data"]["products"]
assert products, "产品库为空,净值用例无从下手"
code = products[0]["product_code"]
response = await client.get(
f"/api/v1/products/{code}/nav-history", params={"days": 30}, headers=auth
)
assert response.status_code == 200, response.text
data = response.json()["data"]
assert data["product_code"] == code
points = data["points"]
assert data["count"] == len(points)
if not points:
# 还没跑 `tools/sync_nav_history.py` —— 允许,但必须是"干净的空"
return
assert points[0]["nav_date"] <= points[-1]["nav_date"], "净值序列必须按日期升序"
assert all(isinstance(item["nav"], str) for item in points)
assert len(points) <= 30
async def test_nav_history_returns_404_for_unknown_product() -> None:
"""不存在的产品必须是 404,而不是空数组 —— 否则前端分不清"没有数据"和"没有这只"。"""
app = create_app()
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport, base_url="http://test", timeout=30
) as client:
issued = await client.post("/api/v1/visitor-tokens")
token = issued.json()["access_token"]
response = await client.get(
"/api/v1/products/999999/nav-history",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 404, response.text
+6 -4
View File
@@ -14,10 +14,11 @@
| 2 | 演示口令 | 依赖 1 建出的账号 |
| 3 | 客户账户与持仓 | 客户页面与下单的前提 |
| 4 | 场内行情 | **下单的硬前置**;必须在演示前跑,行情会过期 |
| 5 | 风控预警样本 | 风控页面要有东西可看 |
| 6 | 投顾演示数据 | 投顾工作台要有方案可看 |
| 7-9 | 各类发布配置 | Agent 工具白名单与提示词,缺了客服/风控会"失败关闭" |
| 10 | 知识库素材 | 客服答得出问题的前提 |
| 5 | 历史净值 | 产品详情页净值走势图的数据源(`fin_nav_history`) |
| 6 | 风控预警样本 | 风控页面要有东西可看 |
| 7 | 投顾演示数据 | 投顾工作台要有方案可看 |
| 8-10 | 各类发布配置 | Agent 工具白名单与提示词,缺了客服/风控会"失败关闭" |
| 11 | 知识库素材 | 客服答得出问题的前提 |
## ⚠️ 两个必须知道的点
@@ -54,6 +55,7 @@ STEPS: tuple[tuple[str, str, str], ...] = (
("演示口令", "tools/set_user_password.py", "⚠️ 非幂等:重跑等于重设密码"),
("客户账户与持仓", "tools/seed_sim_account_demo.py", "客户 9001 开 10 万虚拟资金 + 持仓"),
("场内行情", "tools/sync_market_prices.py", "⚠️ 下单硬前置;行情会过期,演示前必跑"),
("历史净值", "tools/sync_nav_history.py", "产品详情页走势图的数据源(fin_nav_history)"),
("风控预警样本", "tools/seed_risk_alert_demo_data.py", "三条不同状态的演示预警"),
("投顾演示数据", "tools/seed_advisor_demo.py", "投顾工作台要展示的方案与归属"),
("风控 Agent 白名单", "tools/publish_risk_agent_config.py", "缺了风控助手工具会失败关闭"),
+176
View File
@@ -0,0 +1,176 @@
"""把真实历史净值同步进 `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()))