feat(portal): 接入历史净值,产品详情页恢复真实走势图

`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。
This commit is contained in:
2026-09-13 23:38:13 +08:00
parent 5929eb151b
commit 7208713c17
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);
}