Files
group_fqcd_jr/tests/unit/service/test_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

240 lines
9.1 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.
from datetime import date
from decimal import Decimal
from types import SimpleNamespace
from typing import Any
import pytest
from app.core.contracts import RequestContext
from app.core.fund_contracts import FundTrendQuery
from app.model.fund import FundNavHistory
from app.service import fund_trend_service
from app.service.fund_trend_service import (
LISTED_STATUS,
NAV_SOURCE,
query_fund_trend_tool,
summarize_trend,
)
def _series(days: int, *, start: str = "1.0000", step: str = "0.0100") -> list[dict[str, str]]:
points: list[dict[str, str]] = []
nav = Decimal(start)
day = date(2026, 1, 2)
for _ in range(days):
points.append({"nav_date": day.isoformat(), "nav": str(nav)})
nav += Decimal(step)
day = date.fromordinal(day.toordinal() + 1)
return points
# --------------------------------------------------------------------------- #
# 一、纯函数:`summarize_trend`
# --------------------------------------------------------------------------- #
def test_summarize_trend_reports_no_series_for_empty_input() -> None:
"""**没有净值序列是一种结论,不是一次失败**(`INV-7` 靠它说"无可奉告")。"""
assert summarize_trend([]) == {
"found": False, "reason": "no_nav_series", "source": NAV_SOURCE,
}
def test_summarize_trend_skips_unparsable_points() -> None:
"""脏行(日期解析不了 / 值是 `--`)**跳过**,不能让一条脏数据毁掉整条序列。"""
result = summarize_trend([
{"nav_date": "not-a-date", "nav": "1.0"},
{"nav_date": "2026-03-02", "nav": "--"},
{"nav_date": "2026-03-02", "nav": "1.5000"},
])
assert result["found"] is True
assert result["series_points"] == 1
assert result["latest_nav"] == "1.5000"
def test_summarize_trend_orders_points_regardless_of_input_order() -> None:
"""入参顺序不限:**最新一档取日期最大的那条**,不是数组的最后一个。"""
result = summarize_trend([
{"nav_date": "2026-03-05", "nav": "1.2000"},
{"nav_date": "2026-03-01", "nav": "1.0000"},
{"nav_date": "2026-03-03", "nav": "1.1000"},
])
assert result["latest_nav_date"] == "2026-03-05"
assert result["from_date"] == "2026-03-01"
def test_summarize_trend_computes_intervals_high_and_low() -> None:
result = summarize_trend(_series(6))
assert result["found"] is True
assert result["source"] == NAV_SOURCE
names = [row["name"] for row in result["intervals"]]
# 只有 6 个点:够到 5 日窗口,够不到 20/60/120 —— 不够的窗口**不编**。
assert names == ["近 5 个净值日"]
row = result["intervals"][0]
assert row["trading_days"] == 4
assert row["start_nav"] == "1.0100"
assert row["end_nav"] == "1.0500"
assert row["change_pct"] == "3.96"
assert result["high"] == {"nav": "1.0500", "nav_date": "2026-01-07"}
assert result["low"] == {"nav": "1.0000", "nav_date": "2026-01-02"}
def test_summarize_trend_change_pct_is_signed_for_down_moves() -> None:
result = summarize_trend(_series(6, step="-0.0100"))
# 5 日窗口:起点 0.9900 → 终点 0.9500 ⇒ (0.95-0.99)/0.99 = -4.04%
assert result["intervals"][0]["change_pct"] == "-4.04"
def test_listed_status_matches_public_product_service() -> None:
"""`LISTED_STATUS` 与 `public_product_service` **必须逐字相同**。
本模块**刻意不 import** 那个常量(会形成
`public_product_service → admin_service → agent.bootstrap → 本模块` 的循环导入,
实测 `partially initialized module`)。于是"两处同值"这件事只能靠测试钉住 ——
否则哪天有人改了其中一处,行情出口会静默查不到任何产品(返回"无公开净值序列"),
而那看起来像数据问题、不像代码问题。
"""
from app.service.public_product_service import LISTED_STATUS as public_listed_status
assert LISTED_STATUS == public_listed_status
# --------------------------------------------------------------------------- #
# 二、工具处理器:`query_fund_trend_tool`(用假 session 走真实 `_resolve_product`)
# --------------------------------------------------------------------------- #
class _FakeScalars:
def __init__(self, items: list[Any]) -> None:
self._items = items
def all(self) -> list[Any]:
return list(self._items)
def first(self) -> Any:
return self._items[0] if self._items else None
class _FakeResult:
def __init__(self, items: list[Any]) -> None:
self._items = items
def scalars(self) -> _FakeScalars:
return _FakeScalars(self._items)
class _FakeSession:
"""按**实体类型**分发假结果,并记录产品查询次数。
`_resolve_product` 的产品查询最多两次(先精确、后 `contains` 模糊),
用计数器模拟这两步,而不是去复刻 SQL 语义。
"""
def __init__(self, *, products: list[list[Any]], navs: list[Any]) -> None:
self._products = list(products)
self._navs = list(navs)
self.product_queries = 0
async def execute(self, statement: Any) -> _FakeResult:
entity = statement.column_descriptions[0]["entity"]
if entity is FundNavHistory:
return _FakeResult(self._navs)
self.product_queries += 1
index = min(self.product_queries, len(self._products) + 1) - 1
if index >= len(self._products):
return _FakeResult([])
return _FakeResult(self._products[index])
def _patch_session(monkeypatch: pytest.MonkeyPatch, session: _FakeSession) -> None:
class _Factory:
def __call__(self) -> "_Factory": # pragma: no cover - 仅用于类型直觉
return self
async def __aenter__(self) -> _FakeSession:
return session
async def __aexit__(self, *args: object) -> None:
return None
monkeypatch.setattr(fund_trend_service, "SessionFactory", _Factory())
def _product(**overrides: Any) -> SimpleNamespace:
base = dict(
id=1, product_code="159382", product_name="创业板人工智能ETF南方",
product_category="ETF", risk_level="R4", exchange_code="SZSE",
status=LISTED_STATUS,
)
base.update(overrides)
return SimpleNamespace(**base)
@pytest.mark.asyncio
async def test_query_fund_trend_tool_returns_series_and_keeps_risk_level_as_text(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""`risk_level` 必须**原样透传字符串**(`'R4'`)。
回归钉子:`fin_product.risk_level` 列存的是 `R1`—`R5` 这种**字符串**,不是整数。
本工具最初写成 `int(product.risk_level)`,冒烟直接抛
`ValueError: invalid literal for int() with base 10: 'R4'` —— 且只在**真的有产品**
的路径上才炸(查不到产品的路径不碰这个字段),属于"最像成功的那种失败"。
口径与 `public_product_service._view()` 一致:原样透传,不在这一层做数值转换。
"""
session = _FakeSession(
products=[[_product()]],
navs=[
SimpleNamespace(nav_date=date(2026, 3, 2), nav=Decimal("1.0000")),
SimpleNamespace(nav_date=date(2026, 3, 3), nav=Decimal("1.5000")),
],
)
_patch_session(monkeypatch, session)
result = await query_fund_trend_tool(
FundTrendQuery(fund_code="159382"), RequestContext(user_id="1", trace_id="t")
)
assert result["found"] is True
assert result["fund_code"] == "159382"
assert result["fund_name"] == "创业板人工智能ETF南方"
assert result["risk_level"] == "R4"
assert result["source"] == NAV_SOURCE
assert result["latest_nav"] == "1.5000"
@pytest.mark.asyncio
async def test_query_fund_trend_tool_reports_missing_product(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""查不到产品时**如实返回 `found=False`**,不抛异常、不猜一只相近的。"""
session = _FakeSession(products=[[]], navs=[])
_patch_session(monkeypatch, session)
result = await query_fund_trend_tool(
FundTrendQuery(fund_name="南方稳健增利债券A"),
RequestContext(user_id="1", trace_id="t"),
)
assert result == {"found": False, "reason": "product_not_found", "source": NAV_SOURCE}
@pytest.mark.asyncio
async def test_query_fund_trend_tool_reports_ambiguous_name(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""名称命中多只时返回 `ambiguous` —— **替客户挑一只**才是这里最坏的行为(`INV-2`)。"""
session = _FakeSession(
products=[
[],
[_product(id=1, product_code="159700"), _product(id=2, product_code="159701")],
],
navs=[],
)
_patch_session(monkeypatch, session)
result = await query_fund_trend_tool(
FundTrendQuery(fund_name="科创债ETF南方"), RequestContext(user_id="1", trace_id="t")
)
assert result["found"] is False
assert result["reason"] == "ambiguous"
def test_trend_query_requires_at_least_one_entity() -> None:
"""契约层就挡住"两个实体都不给"的调用(否则工具会去扫全表)。"""
with pytest.raises(ValueError):
FundTrendQuery()