Files
group_fqcd_jr/app/service/public_product_service.py
T
lzf_0626 9a5259d787 feat(market): 存下行情源给的涨跌幅,产品列表与排行页不再显示"暂无"
## 问题

产品列表与排行页的「最近涨跌」全是"暂无"。原因是它只能由**两个交易日的收盘价**
现算,而 `fin_market_price` 每只产品每天只有一行 —— 库里头一天只有一天数据时
根本算不出来。

## 但行情源本来就给了这个数

腾讯行情接口的第 32 位就是**当日涨跌幅**(适配器此前只解析了开高低收量额,没取它)。
所以不必等第二个交易日去算,把源给的数存下来即可。

## 改动

- **迁移** `20260913_market_price_change_pct`:给 `fin_market_price` **新增一个可空列**
  `change_pct DECIMAL(10,4)`。只加列、不改任何已有字段(AGENTS.md 规则 2 允许),
  可空且不回填,既有读写方全部不受影响。
- **适配器**:`_parse_tencent` 增解析位置 4(昨收)与位置 32(涨跌幅)。
- **同步服务**:写入 `change_pct`;降级路径(净值兜底)没有这个数就写 **NULL**。
- **接口**:`_resolve_change_pct` **优先用存下来的值**;迁移前的历史行没有该列的值,
  才回退到"今日收盘 vs 昨收"现算。仍为 `null` 时前端显示"暂无" ——
  **不得当成 0**(`formatPercent(null)` 会渲染成 `+0.00%`,那等于说"今天平盘")。

## 契约测试同步

两处契约断言因结构演进需要跟进 —— 它们的作用正是拦住这类变更:

- `test_fund_readonly_contract.py`:`fin_market_price` 列数 **13 → 14**
- `test_advisor_migration_contract.py`:迁移 head 更新为新版本
  (核心断言仍是"链收敛到唯一 head",此处只是钉住末端)

验证:实测 20 只产品**全部有真实涨跌幅**(如 515450 −0.43%、159511 +1.23%);
unit+contract **1397 passed**;integration **110 passed**;ruff 通过;
mypy 251 文件 0 错;`audit_schema` 与 `migration_state_check` 均通过;e2e 冒烟 40/40。
2026-09-14 00:30:06 +08:00

210 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""公开产品列表:在售的场内基金,访客无需登录即可浏览。
## 为什么需要它
访客的三个页面(首页推荐、产品列表、产品详情)此前只能读前端手写的
`app/static/portal/common/mock-data.js` —— 那份数据只有 8 只,而且大半**不是本平台
的产品**(例如把海富通的 `511360` 标成"南方短融ETF"、产品代码 `159915`/`512100`/
`513100` 在 `fin_product` 里根本不存在),净值也是编的。
`app/static/portal/README.md` 把它记为"公开产品 HTTP 接口尚未实现"的临时方案。
本服务提供真实数据:产品来自 `fin_product`(`status='上市'`),最新价来自
`fin_market_price`(由 `tools/sync_market_prices.py` 从行情源同步)。
## 鉴权口径
**要求有效令牌,但不检查任何权限码。** 访客令牌的上下文只有 `roles=("visitor",)`、
不带权限(见 `app/core/security.py`),这与 `/api/v1/conversations`、
`/api/v1/agent-runs` 给访客用的方式一致 —— 产品信息本身是公开信息,
要求令牌只是为了复用统一的两段式入口与限流,而不是为了授权。
## 字段与前端的关系
返回的字段名**刻意与 `mock-data.js` 的 `MOCK_PRODUCTS` 对齐**
(`product_code`/`product_name`/`product_category`/`risk_level`/`current_nav`/
`current_nav_at`/`exchange_code`),这样前端只需换数据源,渲染与筛选逻辑一行都不用改。
⚠️ **不返回涨跌幅**:`fin_market_price` 每只产品每个交易日只有一行,**没有昨收**,
算不出当日涨跌幅。mock 里那个 `MOCK_RANKING_CHANGE` 是编的,不能照搬成"真实涨跌幅"。
"""
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import Any
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, FundNavHistory, FundProduct
from app.service.admin_service import public
#: 只暴露在售产品(`fin_product.status`)
LISTED_STATUS = "上市"
class PublicProductService:
async def list_products(self, context: RequestContext) -> dict[str, Any]:
"""返回全部在售场内产品,附带各自的最新行情(可能为空)。"""
async with SessionFactory() as session:
products = (
(
await session.execute(
select(FundProduct)
.where(FundProduct.status == LISTED_STATUS)
.order_by(FundProduct.product_code)
)
)
.scalars()
.all()
)
quotes = await self._latest_quotes(session, [int(item.id) for item in products])
items = [self._view(product, quotes.get(int(product.id))) for product in products]
return {
"data": {"products": items, "count": len(items)},
"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]
) -> dict[int, list[FundMarketPrice]]:
"""每只产品取 `trade_date` 最大的**两行**,**一次查询**拿回全部产品。
为什么要两行:当日涨跌幅 = (最新收盘 − 上一交易日收盘) / 上一交易日收盘,
没有昨收就算不出来。`fin_market_price` 按 `(product_id, trade_date)` upsert,
每天跑一次同步就会自然累积出昨收。
不按产品逐个查:20 只就是 20 次往返,而列表页每次打开都要调。
"""
if not product_ids:
return {}
rows = (
(
await session.execute(
select(FundMarketPrice)
.where(FundMarketPrice.product_id.in_(product_ids))
.order_by(FundMarketPrice.product_id, FundMarketPrice.trade_date.desc())
)
)
.scalars()
.all()
)
grouped: dict[int, list[FundMarketPrice]] = {}
for row in rows:
bucket = grouped.setdefault(int(row.product_id), [])
# 已按 trade_date 倒序,每只产品只留最新的两行
if len(bucket) < 2:
bucket.append(row)
return grouped
@staticmethod
def _view(product: FundProduct, quotes: list[FundMarketPrice] | None) -> dict[str, Any]:
latest = quotes[0] if quotes else None
previous = quotes[1] if quotes and len(quotes) > 1 else None
return {
"product_code": product.product_code,
"product_name": product.product_name,
"exchange_code": product.exchange_code,
"product_category": product.product_category,
"risk_level": product.risk_level,
"fund_manager": product.fund_manager,
"current_nav": public(product.current_nav, "current_nav"),
"current_nav_at": public(product.current_nav_at),
"status": product.status,
# 产品详情页要用的静态字段(原先由前端 mock 提供,现改为真实列)
"currency": product.currency,
"lot_size": public(product.lot_size, "lot_size"),
"price_tick": public(product.price_tick, "price_tick"),
"min_amount": public(product.min_amount, "min_amount"),
"management_fee_rate": public(product.management_fee_rate, "management_fee_rate"),
"custodian_fee_rate": public(product.custodian_fee_rate, "custodian_fee_rate"),
# 行情可能还没同步(新上架产品、或行情源没有覆盖),此时为 null,
# 前端要能显示"暂无行情"而不是显示 0。
"latest_close": public(latest.close_price, "latest_close") if latest else None,
"latest_trade_date": _iso(latest.trade_date) if latest else None,
"quote_source": latest.source if latest else None,
# 当日涨跌幅:**优先用行情源写入的 `change_pct`**(腾讯直接提供),
# 历史行没有该字段时才回退到"最新收盘 vs 上一交易日收盘"现算。
# 见 `_resolve_change_pct`。
"change_pct": _resolve_change_pct(latest, previous),
}
def _resolve_change_pct(
latest: FundMarketPrice | None, previous: FundMarketPrice | None
) -> float | None:
"""当日涨跌幅(%);都拿不到就返回 None。
⚠️ 返回 None 时调用方必须显示"暂无",**不得当成 0** —— `0` 会被读成"今天平盘",
那是编出来的结论。`formatPercent(null)` 恰好会渲染成 `+0.00%`,所以前端要显式判空。
"""
if latest is None:
return None
if latest.change_pct is not None:
return float(latest.change_pct)
# 回退:需要两个交易日的收盘价(行情源没给涨跌幅、或该行由降级路径写入)
if previous is None:
return None
try:
base = Decimal(previous.close_price)
if base <= 0:
return None
return float((Decimal(latest.close_price) - base) / base * 100)
except (InvalidOperation, TypeError, ValueError):
return None
def _iso(value: date | None) -> str | None:
return value.isoformat() if value is not None else None