历史净值
风险与交易说明
基金净值会随市场变化。历史数据不代表未来表现,交易前应结合自身风险承受能力判断。
From 7208713c17fd7dc3271d79eb6642973f5ba3dfcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com> Date: Sun, 13 Sep 2026 23:38:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(portal):=20=E6=8E=A5=E5=85=A5=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E5=87=80=E5=80=BC=EF=BC=8C=E4=BA=A7=E5=93=81=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=A1=B5=E6=81=A2=E5=A4=8D=E7=9C=9F=E5=AE=9E=E8=B5=B0?= =?UTF-8?q?=E5=8A=BF=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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。 --- app/api/controllers/public_platform.py | 18 +- app/infrastructure/fund_market_adapter.py | 71 +++++++ app/service/public_product_service.py | 50 ++++- app/static/portal/common/api-client.js | 1 + .../portal/guest/product-detail/index.html | 6 +- .../guest/product-detail/product-detail.css | 2 + .../guest/product-detail/product-detail.js | 64 ++++++- docs/05-接口文档.md | 21 ++- docs/40-前端验收清单.md | 6 +- docs/44-演示流程.md | 5 +- .../test_public_products_endpoint_contract.py | 20 ++ .../test_public_products_endpoint_mysql.py | 54 ++++++ tools/seed_demo_data.py | 10 +- tools/sync_nav_history.py | 176 ++++++++++++++++++ 14 files changed, 480 insertions(+), 24 deletions(-) create mode 100644 tools/sync_nav_history.py diff --git a/app/api/controllers/public_platform.py b/app/api/controllers/public_platform.py index 88992d5..21e5bfb 100644 --- a/app/api/controllers/public_platform.py +++ b/app/api/controllers/public_platform.py @@ -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) diff --git a/app/infrastructure/fund_market_adapter.py b/app/infrastructure/fund_market_adapter.py index 55e9c63..88867d2 100644 --- a/app/infrastructure/fund_market_adapter.py +++ b/app/infrastructure/fund_market_adapter.py @@ -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]]: """取每只基金的费率(管理费/托管费/销售服务费/申购费/赎回费)与基金全称。 diff --git a/app/service/public_product_service.py b/app/service/public_product_service.py index fc91c56..a29b384 100644 --- a/app/service/public_product_service.py +++ b/app/service/public_product_service.py @@ -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] diff --git a/app/static/portal/common/api-client.js b/app/static/portal/common/api-client.js index a50e29c..6bf92ad 100644 --- a/app/static/portal/common/api-client.js +++ b/app/static/portal/common/api-client.js @@ -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' }, diff --git a/app/static/portal/guest/product-detail/index.html b/app/static/portal/guest/product-detail/index.html index 04163bb..c59258d 100644 --- a/app/static/portal/guest/product-detail/index.html +++ b/app/static/portal/guest/product-detail/index.html @@ -5,7 +5,7 @@
基金净值会随市场变化。历史数据不代表未来表现,交易前应结合自身风险承受能力判断。