Merge remote-tracking branch 'origin/qyqy_develop' into RM2_develop
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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]]:
|
||||
"""取每只基金的费率(管理费/托管费/销售服务费/申购费/赎回费)与基金全称。
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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", "缺了风控助手工具会失败关闭"),
|
||||
|
||||
@@ -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()))
|
||||
Reference in New Issue
Block a user