历史净值
风险与交易说明
基金净值会随市场变化。历史数据不代表未来表现,交易前应结合自身风险承受能力判断。
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 @@
基金净值会随市场变化。历史数据不代表未来表现,交易前应结合自身风险承受能力判断。