落地第十五条豁免额度校验;登记三处业务裁定
一、第十五条豁免规则(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 的豁免额度条件与研判口径。
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
"""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)
|
||||
@@ -473,6 +473,80 @@ async def test_suitability_mismatch_rejects_complete_trace_and_matching_level()
|
||||
assert await RiskRuleEngine(session)._suitability_mismatch() == []
|
||||
|
||||
|
||||
def _complete_trace() -> RiskWorkOrder:
|
||||
return work_order(
|
||||
disclosure_at=datetime(2026, 9, 1),
|
||||
confirmation_at=datetime(2026, 9, 1),
|
||||
recording_reference="REC-1",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suitability_exemption_limit_breach_raises_alert() -> None:
|
||||
"""C3→R4 属第十五条允许的越级购买,但单只持仓超过总资产 20% 仍要预警。
|
||||
|
||||
原先扫描侧只看"留痕是否齐全":签了揭示书、留痕齐全就直接不报 —— 于是"签了字
|
||||
但买超额度"这种明确违规反而没有预警。
|
||||
"""
|
||||
session = FakeSession(
|
||||
rows=[transaction()],
|
||||
get_values=[risk_user("C3"), product("R4"), _complete_trace()],
|
||||
scalar_values=[None, Decimal("100000.00"), Decimal("30000.00")],
|
||||
)
|
||||
|
||||
alerts = await RiskRuleEngine(session)._suitability_mismatch()
|
||||
|
||||
assert len(alerts) == 1
|
||||
assert alerts[0].alert_level == "中"
|
||||
assert "20%" in alerts[0].evidence_summary
|
||||
assert alerts[0].evidence_snapshot["exemption_ratio"] == "0.3"
|
||||
assert alerts[0].evidence_snapshot["exemption_limit"] == "0.20"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suitability_exemption_within_limit_is_not_an_alert() -> None:
|
||||
"""留痕齐全且占比未超额度 → 豁免成立,不该产生预警。"""
|
||||
session = FakeSession(
|
||||
rows=[transaction()],
|
||||
get_values=[risk_user("C3"), product("R4"), _complete_trace()],
|
||||
scalar_values=[None, Decimal("100000.00"), Decimal("10000.00")],
|
||||
)
|
||||
|
||||
assert await RiskRuleEngine(session)._suitability_mismatch() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suitability_exemption_missing_asset_data_does_not_create_alerts() -> None:
|
||||
"""总资产为 0(上游未落地)时不能算成"超限"。
|
||||
|
||||
本项目的画像与持仓由本项目之外的流程写入,拿 0 去算会让每一笔 C3→R4 都变成
|
||||
违规 —— 豁免规则就成了新的误报源。缺失只如实记录,交给研判侧提示人工确认。
|
||||
"""
|
||||
session = FakeSession(
|
||||
rows=[transaction()],
|
||||
get_values=[risk_user("C3"), product("R4"), _complete_trace()],
|
||||
scalar_values=[None, Decimal("0"), Decimal("10000.00")],
|
||||
)
|
||||
|
||||
assert await RiskRuleEngine(session)._suitability_mismatch() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suitability_missing_trace_still_alerts_without_exemption_data() -> None:
|
||||
"""留痕不完整这条老语义不能被新增的额度逻辑冲掉。"""
|
||||
session = FakeSession(
|
||||
rows=[transaction()],
|
||||
get_values=[risk_user("C3"), product("R4"), work_order()],
|
||||
scalar_values=[None, Decimal("0"), None],
|
||||
)
|
||||
|
||||
alerts = await RiskRuleEngine(session)._suitability_mismatch()
|
||||
|
||||
assert len(alerts) == 1
|
||||
assert "交易留痕不完整" in alerts[0].evidence_summary
|
||||
assert alerts[0].evidence_snapshot["exemption_data_missing"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_elderly_redemption_requires_age_amount_average_and_uncommon_device() -> None:
|
||||
login = RiskLoginRecord(
|
||||
|
||||
Reference in New Issue
Block a user