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 @@ 基金详情 · 南方财富 - +
@@ -13,11 +13,11 @@
-

历史净值

近十二期
----
+

历史净值

近 120 个交易日
----

风险与交易说明

风险等级
最小金额
交易方式场内市价模拟成交

基金净值会随市场变化。历史数据不代表未来表现,交易前应结合自身风险承受能力判断。

- + diff --git a/app/static/portal/guest/product-detail/product-detail.css b/app/static/portal/guest/product-detail/product-detail.css index 89b6dcc..8ae9202 100644 --- a/app/static/portal/guest/product-detail/product-detail.css +++ b/app/static/portal/guest/product-detail/product-detail.css @@ -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); } diff --git a/app/static/portal/guest/product-detail/product-detail.js b/app/static/portal/guest/product-detail/product-detail.js index 6a841fa..785e154 100644 --- a/app/static/portal/guest/product-detail/product-detail.js +++ b/app/static/portal/guest/product-detail/product-detail.js @@ -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]) => `
${escapeHtml(label)}${escapeHtml(value)}
`).join(''); - // 没有真实历史净值时不绘制虚构曲线,避免把 mock 数据误认为行情。 - document.querySelector('[data-chart]').outerHTML = - '

历史净值数据尚未接入,暂不展示走势图。

'; - 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 = + '

历史净值数据尚未接入,暂不展示走势图。

'; + 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 = ``; + 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); } diff --git a/docs/05-接口文档.md b/docs/05-接口文档.md index 17937dd..1388287 100644 --- a/docs/05-接口文档.md +++ b/docs/05-接口文档.md @@ -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. 变更流程 diff --git a/docs/40-前端验收清单.md b/docs/40-前端验收清单.md index 24d3926..fc38c70 100644 --- a/docs/40-前端验收清单.md +++ b/docs/40-前端验收清单.md @@ -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 访客智能客服浮窗 ⭐ 本轮重点 diff --git a/docs/44-演示流程.md b/docs/44-演示流程.md index f0f73e6..e9c23d3 100644 --- a/docs/44-演示流程.md +++ b/docs/44-演示流程.md @@ -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) diff --git a/tests/contract/test_public_products_endpoint_contract.py b/tests/contract/test_public_products_endpoint_contract.py index c82d704..4cb90a1 100644 --- a/tests/contract/test_public_products_endpoint_contract.py +++ b/tests/contract/test_public_products_endpoint_contract.py @@ -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},越界应被拒绝" + ) diff --git a/tests/integration/test_public_products_endpoint_mysql.py b/tests/integration/test_public_products_endpoint_mysql.py index 0b1c2cc..fc57663 100644 --- a/tests/integration/test_public_products_endpoint_mysql.py +++ b/tests/integration/test_public_products_endpoint_mysql.py @@ -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 diff --git a/tools/seed_demo_data.py b/tools/seed_demo_data.py index d8116d0..5862a51 100644 --- a/tools/seed_demo_data.py +++ b/tools/seed_demo_data.py @@ -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", "缺了风控助手工具会失败关闭"), diff --git a/tools/sync_nav_history.py b/tools/sync_nav_history.py new file mode 100644 index 0000000..878fbee --- /dev/null +++ b/tools/sync_nav_history.py @@ -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()))