## 问题 产品列表与排行页的「最近涨跌」全是"暂无"。原因是它只能由**两个交易日的收盘价** 现算,而 `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。
210 lines
9.2 KiB
Python
210 lines
9.2 KiB
Python
"""公开产品列表:在售的场内基金,访客无需登录即可浏览。
|
||
|
||
## 为什么需要它
|
||
|
||
访客的三个页面(首页推荐、产品列表、产品详情)此前只能读前端手写的
|
||
`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
|