Files
group_fqcd_jr/app/service/fund_trend_service.py
T
张胜宇 3e24033f72 feat(W27): L0 表层判定层 + 出口 E6 行情 + 收益过滤槽位白名单 + 免责声明分档(设计与代码同轮完成)
设计与依据:新增 D3.9-客服Agent智能路由与行情出口设计-2026-09-21.md(CS-ARCH-2026-024)
DEC-W27-1~12 全部批准并落地。

## 新增(3 个源文件 + 3 个测试文件)
- app/core/exit_codes.py:出口码注册表(E0/E1/E2a~E2e/E3/E4/E5a~E5c/E6/E8/CHAT/LOGIN/CONTACT)
  + NON_BUSINESS_EXIT_CODES 白名单(免责分档的判据面)
- app/service/fund_trend_service.py(E6):summarize_trend() 纯函数 + query_fund_trend 工具
  读 fin_nav_history;按净值日个数(5/20/60/120)给区间涨跌 / 区间高低 / 来源与区间
- tests/unit/{service,tools} 新增 45 条守卫单测(E6 出口 / L0 表层判定 / 工具配置)

## 改动(重点)
- customer_service.py:
  · _route_surface()(L0-a 闲聊 / L0-b 行情 / L0-e 无信息量),位置在安全路由之后
  · drop_yield_claims() 重写为「入口闸 × 槽位白名单」+ fail-closed
    (旧黑名单会误删权重:A-06 业绩基准公式整行消失)
  · allowed_tools 补 query_fund_trend —— 修掉发布被拒 422「配置超出 Agent 工具上限」
    (该 422 是**子集**校验而非数量上限,根因即此项漏配)
  · 25 处 CoreResult 全部补 exit_code(AST 守卫保证零遗漏)
- customer_service_rules.py:闲聊判定重写(业务实体边界 + 词表 + 特征串 + 语气词)
  + is_low_information_message() + is_contact_inquiry()
- governance.py:免责声明按 exit_code 分档(业务档完整 / 非业务档轻型)
- actor.py:VISITOR_PERMISSIONS 补 fund:quote:read(E6 对访客开放,DEC-W27-11)
- tools/:seed_compliance_baseline 新增 TPL_DISCLAIMER_LIGHT;publish 工具改为
  「从生效版本派生原列表再追加」(_inherit_tools 取不到返回 None,防静默改窄)

## 实测
- 金标扩容 46 → 55(新增 Q 组行情 5 条 + C 组闲聊 4 条)
- M-1 55/55、M-4 55/55;M-5 与四项零容忍(M-7/M-8/M-9/M-10)全 0
- 原 46 条可比基线逐项不变(46/46);M-6 9.1%(5 条全在转人工白名单内)
- 全量 pytest 2094 passed / 3 skipped;ruff 零新增(余 5 条与 HEAD 逐条对应)
- 配置版本 244(cs-tools-75813de45421)已发布并激活

## 文档
- 新增 D3.9(设计专册);D1.1 §34 补「代码实施已同轮完成」并把旧表述作废
- D3.7:新增 Q 组判据表 + §6.4 第四次实测 + M-3/M-9 口径澄清
- D2.9:新增 §2.11(9 条新增用例的实测答复原文)+ 汇总表改双列口径
- D2.1:新增 v6.41 执行条目
- 权威副本(D:\桌面\金融\)→ 仓库镜像 全量比对一致(0 缺失 / 0 不一致)

## 诚实未做项
- 知识出口(E3/E4)侧的实体锚点闸门未做(故金标扩容 9 条而非 12 条,A-11~A-13 未成集)
- 阈值未重标(D3.9 §3.4 只给方法);M-3 分母未改(仅明示口径);
  D1.1 §0 与 §4.0 历史计数偏移(差 1)仍沿用
2026-09-21 22:32:39 +08:00

196 lines
8.4 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.
"""`E6` 行情走势出口的数据层:把 `fin_nav_history` 的净值序列折成区间涨跌与区间高低。
## 为什么单独成工具,而不是复用 `query_fund_quote`
`query_fund_quote`(投顾线在用)走**外部行情源**(东财),返回的是"当前一档" —— 最新净值
与各期回报。答辩/演示现场一旦外网不通,整条出口降级;而"走势"这种问题,客户真正要看的是
**区间口径**(近 N 个净值日涨了多少、区间高低在哪)。
本工具只读**库内净值序列**(`fin_nav_history`,由 `tools/sync_nav_history.py` 从净值接口
同步):离线可复现、可逐行核对,且时间口径由 `nav_date` 直接给出 —— 不依赖任何外部服务。
## 数据边界(不变量 `INV-7`:数据边界必须自陈)
- `fin_product` 里**只有场内 `ETF` / `LOF`**(`status='上市'`)。产品手册里的示例产品
(如 `南方稳健增利债券A`)**不在其中、没有净值序列** ⇒ 本工具返回 `found=False`,
由 Agent 侧如实告知"暂无公开净值序列",**不得**拿手册里的虚构数字充作行情。
- 区间按**净值日个数**(5 / 20 / 60 / 120)而非自然日:净值按交易日发布,自然日口径会把
周末与停牌日算进去,客户无法核对。文档与答复必须写同一个口径。
- 表里没数据时返回**空结果而不是报错**(与 `PublicProductService.nav_history` 同一取向)。
## 数值口径
`change_pct` = (末净值 − 初净值) / 初净值 × 100,保留 2 位小数(`Decimal`,不用浮点)。
起点取 `points[-n]`(含端点共 n 个净值点 ⇒ 跨越 n−1 个净值日间隔),答复里写"近 n 个净值日"。
"""
from __future__ import annotations
from collections.abc import Sequence
from datetime import date
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from typing import Any
from sqlalchemy import select
from app.core.contracts import RequestContext
from app.core.fund_contracts import FundTrendQuery
from app.infrastructure.db import SessionFactory
from app.model.fund import FundNavHistory, FundProduct
#: 只查在售产品(`fin_product.status`)。**故意不 import**
#: `public_product_service.LISTED_STATUS`:那条链是
#: `public_product_service → admin_service → agent.bootstrap`,而 bootstrap 又要 import
#: 本模块,会形成**循环导入**(实测 `partially initialized module`)。
#: 常量取值与 `public_product_service` 逐字相同,两处都不得单独改动;
#: `tests/unit/service/test_fund_trend_service.py` 有断言钉住两者相等。
LISTED_STATUS = "上市"
#: 净值来源标识:答复与审计都写它,避免"数字从哪来"说不清(`INV-6`)。
NAV_SOURCE = "fin_nav_history"
#: 区间口径(净值日个数)
TREND_INTERVALS: tuple[int, ...] = (5, 20, 60, 120)
#: 名称模糊匹配的最短长度:太短会误配(如「南方」一次命中多只)
MIN_NAME_MATCH_CHARS = 4
def _to_decimal(value: object) -> Decimal | None:
try:
return Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
return None
def _pct(start: Decimal, end: Decimal) -> Decimal:
if start == 0:
return Decimal("0.00")
return ((end - start) / start * 100).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def summarize_trend(
points: Sequence[dict[str, Any]], *, intervals: tuple[int, ...] = TREND_INTERVALS
) -> dict[str, Any]:
"""把净值点折成「最新一档 + 各区间涨跌 + 区间高低」。**纯函数**:不碰库、不调模型。
入参 `points` 每项形如 `{"nav_date": "2026-09-11", "nav": "1.2345"}`(顺序不限)。
出参稳定契约(调用方与测试都依赖):
```
{found, latest_nav, latest_nav_date, from_date, to_date, series_points,
intervals: [{name, trading_days, start_date, start_nav, end_date, end_nav, change_pct}],
high: {nav, nav_date}, low: {nav, nav_date}, source}
```
"""
parsed: list[tuple[date, Decimal]] = []
for item in points or ():
if not isinstance(item, dict):
continue
raw_date, nav = item.get("nav_date"), _to_decimal(item.get("nav"))
if nav is None or raw_date is None:
continue
try:
day = date.fromisoformat(str(raw_date)[:10])
except ValueError:
continue
parsed.append((day, nav))
if not parsed:
return {"found": False, "reason": "no_nav_series", "source": NAV_SOURCE}
parsed.sort(key=lambda pair: pair[0])
latest_date, latest_nav = parsed[-1]
first_date = parsed[0][0]
high_date, high_nav = max(parsed, key=lambda pair: pair[1])
low_date, low_nav = min(parsed, key=lambda pair: pair[1])
rows: list[dict[str, Any]] = []
for window in intervals:
if window < 2 or len(parsed) < window:
continue
start_date, start_nav = parsed[-window]
rows.append(
{
"name": f"近 {window} 个净值日",
"trading_days": window - 1,
"start_date": start_date.isoformat(),
"start_nav": str(start_nav),
"end_date": latest_date.isoformat(),
"end_nav": str(latest_nav),
"change_pct": str(_pct(start_nav, latest_nav)),
}
)
return {
"found": True,
"latest_nav": str(latest_nav),
"latest_nav_date": latest_date.isoformat(),
"from_date": first_date.isoformat(),
"to_date": latest_date.isoformat(),
"series_points": len(parsed),
"intervals": rows,
"high": {"nav": str(high_nav), "nav_date": high_date.isoformat()},
"low": {"nav": str(low_nav), "nav_date": low_date.isoformat()},
"source": NAV_SOURCE,
}
async def _resolve_product(session: Any, arguments: FundTrendQuery) -> tuple[Any, str]:
"""按代码或名称定位产品。返回 `(product, reason)`;`reason` 非空表示查不到的原因。"""
base = select(FundProduct).where(FundProduct.status == LISTED_STATUS)
if arguments.fund_code:
product = (
await session.execute(base.where(FundProduct.product_code == arguments.fund_code))
).scalars().first()
return (product, "") if product is not None else (None, "product_not_found")
name = (arguments.fund_name or "").strip()
exact = (await session.execute(base.where(FundProduct.product_name == name))).scalars().first()
if exact is not None:
return exact, ""
if len(name) < MIN_NAME_MATCH_CHARS:
return None, "product_not_found"
# 模糊匹配**必须唯一**:命中多只时宁可要客户给代码,也不能替他挑一只(`INV-2` 取向)
matches = (
await session.execute(base.where(FundProduct.product_name.contains(name)).limit(3))
).scalars().all()
if len(matches) == 1:
return matches[0], ""
if len(matches) > 1:
# 再收一次:把「名称片段」当子串比较,仍多解就交回澄清
tight = [item for item in matches if name in item.product_name]
if len(tight) == 1:
return tight[0], ""
return None, "ambiguous"
return None, "product_not_found"
async def query_fund_trend_tool(
arguments: FundTrendQuery, context: RequestContext
) -> dict[str, Any]:
"""`E6` 行情出口的工具处理器:只读、无写权限、不调模型。"""
del context # 权限、审计与来源由 ToolExecutor 统一处理
async with SessionFactory() as session:
product, reason = await _resolve_product(session, arguments)
if product is None:
return {"found": False, "reason": reason, "source": NAV_SOURCE}
rows = (
(
await session.execute(
select(FundNavHistory)
.where(FundNavHistory.product_id == product.id)
.order_by(FundNavHistory.nav_date.desc())
.limit(max(5, min(arguments.days, 365)))
)
)
.scalars()
.all()
)
points = [
{"nav_date": row.nav_date.isoformat(), "nav": str(row.nav)} for row in reversed(rows)
]
summary = summarize_trend(points)
return {
**summary,
"fund_code": str(product.product_code),
"fund_name": str(product.product_name),
"product_category": str(product.product_category or ""),
"risk_level": str(product.risk_level or ""),
"exchange_code": str(product.exchange_code or ""),
}