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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user