Files
group_fqcd_jr/tests/unit/service/test_risk_judgement_rw007.py
T
lzf_0626 ff681df143 落地第十五条豁免额度校验;登记三处业务裁定
一、第十五条豁免规则(docs/25 第七节 #2,业务裁定:实现)

政策原文:C3→R4 签署风险揭示书后**可买**,但单只 R4 持仓不超过总资产 20%;
C4→R5 同理,上限 10%。越级购买本身不是违规,超出额度才是 —— 原先扫描侧只看
"留痕是否齐全",于是"签了字但买超额度"这种明确违规没有预警;研判侧也把豁免的
前提条件(留痕齐全)当成了结论,直接判"疑似误报"。

- 扫描侧:新增 EXEMPTION_LIMITS 与 RiskRuleEngine._exemption_state,核算
  "单只持仓 / 总资产"并写进证据快照;触发条件改为
  gap > 0 and (missing_trace or 超出额度)。
- 研判侧:_assess_rw007 先判额度再判留痕。超限 → 证据支持风险;留痕齐全且在额度
  内 → 疑似误报;留痕齐全但快照缺总资产/持仓 → 继续复核。

数据前提(tools/probe_exemption_data.py,证据见 docs/evidence/exemption-data-probe.json):
库内 fin_customer_profile 仅 1 行且 total_asset = 0.00、fin_holding 0 行、无任何申购
交易 —— 这条规则当前不会被触发,与 behavior_score 同源(画像与持仓由本项目之外的
流程写入)。因此刻意不把"算不出来"当成"超限":拿 0 去算会让每一笔 C3→R4 都变成违规,
豁免规则反倒成了误报源。上游把数据写入后无需再改代码即可生效。

二、三处业务裁定(此前挂在"待裁定")

- 模型网关 chat + tools 入口:本轮不补,按基座能力缺口记录。它要贯穿
  ModelGateway → … → BaseAgent 整条链路,属公共契约变更,演示联调期影响面大于收益。
- exclude(关闭误报)是否必须先"调查中":保持现状,不加门禁。
- 政策冲突:以第十四条 C ≥ R 为准;客服侧 check_suitability 复核后确认本来就按
  C ≥ R 实现,无需改动。

三、其他

- 新增 tests/unit/service/test_risk_judgement_rw007.py(6 例)与扫描侧 4 例。
- 风控文档 03/05 同步 RW-007 的豁免额度条件与研判口径。
2026-09-11 14:24:45 +08:00

118 lines
4.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.
"""RW-007 研判必须复核第十五条豁免额度(`docs/25` 第七节 #2)。
政策原文(`knowledge/policy/个人投资者适当性管理指南.md:334-341`):
- C3 买 R4:签署《产品风险超越投资者风险承受能力揭示书》后**可买**,但单只 R4 持仓
不超过总资产 **20%**;
- C4 买 R5:同理,上限 **10%**。
也就是说越级购买本身不是违规,**超出额度**才是。原先 `_assess_rw007` 只看留痕:
只要揭示书、二次确认、录音都齐,就判"疑似误报" —— 而这三样恰恰是豁免的**前提条件**,
把前提当成结论,于是"签了字但买超了额度"被静默放过。
数据缺失的情况单独处理:额度要靠总资产与持仓快照才能核算,本项目的画像与持仓由上游
流程写入(实测 `total_asset` 全为 0、`fin_holding` 为空),既不能默认"没超"判误报,
也不能默认"超了"判风险 —— 那会把豁免规则变成新的误报源。
"""
from typing import Any
from app.service.risk_judgement_service import (
VERDICT_CONTINUE_REVIEW,
VERDICT_RISK_SUPPORTED,
VERDICT_SUSPECTED_FALSE_POSITIVE,
_assess_rw007,
)
COMPLETE_WORK_ORDER: dict[str, Any] = {
"risk_disclosure_ack_at": "2026-09-01T00:00:00",
"second_confirmation_at": "2026-09-01T00:00:00",
"recording_reference": "REC-1",
}
REQUIRING_PRODUCT: dict[str, Any] = {
"risk_level": "R4",
"risk_disclosure_required": True,
"second_confirmation_required": True,
"recording_required": True,
}
def _detail(
snapshot: dict[str, Any],
*,
work_order: dict[str, Any] | None = None,
investor_type: str = "C3",
) -> dict[str, Any]:
"""一条 C3→R4(相差 1 级、属豁免情形)的 RW-007 详情。"""
return {
"customer": {"investor_type": investor_type},
"product": dict(REQUIRING_PRODUCT),
"work_order": dict(COMPLETE_WORK_ORDER if work_order is None else work_order),
"evidence_snapshot": snapshot,
}
def test_limit_breach_is_risk_supported_even_with_complete_trace() -> None:
"""这是本次补上的判断:留痕齐全 ≠ 豁免成立,额度超了就是违规。"""
result = _assess_rw007(
_detail({"exemption_limit": "0.20", "exemption_ratio": "0.35"})
)
assert VERDICT_RISK_SUPPORTED in str(result)
assert "35.00%" in str(result)
assert "20%" in str(result)
def test_within_limit_with_complete_trace_is_a_false_positive() -> None:
result = _assess_rw007(
_detail({"exemption_limit": "0.20", "exemption_ratio": "0.10"})
)
assert VERDICT_SUSPECTED_FALSE_POSITIVE in str(result)
def test_exact_limit_is_not_a_breach() -> None:
"""政策写的是"不超过",恰好等于上限属于合规。"""
result = _assess_rw007(
_detail({"exemption_limit": "0.20", "exemption_ratio": "0.2"})
)
assert VERDICT_SUSPECTED_FALSE_POSITIVE in str(result)
def test_missing_snapshot_data_asks_for_manual_review() -> None:
"""额度核算不出结果时,既不判误报也不判风险。"""
result = _assess_rw007(
_detail(
{
"exemption_limit": "0.20",
"exemption_ratio": None,
"exemption_data_missing": True,
}
)
)
assert VERDICT_CONTINUE_REVIEW in str(result)
assert "总资产" in str(result)
def test_missing_trace_still_outranks_the_exemption_path() -> None:
"""留痕不完整仍然直接判风险 —— 豁免的**前提**没满足,谈额度没有意义。"""
result = _assess_rw007(
_detail(
{"exemption_limit": "0.20", "exemption_ratio": "0.01"},
work_order={},
)
)
assert VERDICT_RISK_SUPPORTED in str(result)
assert "缺少交易留痕" in str(result)
def test_pairs_outside_the_exemption_table_keep_the_old_behaviour() -> None:
"""C2→R5 不属豁免情形,快照里也不会有额度字段:行为必须与改动前一致。"""
result = _assess_rw007(_detail({}, investor_type="C2"))
assert VERDICT_SUSPECTED_FALSE_POSITIVE in str(result)