From ff681df14318cf0f3ee7e4c005882ce5ca05313d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com> Date: Fri, 11 Sep 2026 14:24:45 +0800 Subject: [PATCH] =?UTF-8?q?=E8=90=BD=E5=9C=B0=E7=AC=AC=E5=8D=81=E4=BA=94?= =?UTF-8?q?=E6=9D=A1=E8=B1=81=E5=85=8D=E9=A2=9D=E5=BA=A6=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=EF=BC=9B=E7=99=BB=E8=AE=B0=E4=B8=89=E5=A4=84=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E8=A3=81=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、第十五条豁免规则(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 的豁免额度条件与研判口径。 --- app/service/risk_judgement_service.py | 30 +++++ app/service/risk_scan_service.py | 97 ++++++++++++++- docs/25-风控模块代码评审报告.md | 60 ++++++++- docs/evidence/exemption-data-probe.json | 28 +++++ .../03-风控业务规则与研判手册.md | 15 ++- .../05-误报可放行与疑似误判标准.md | 12 +- .../unit/service/test_risk_judgement_rw007.py | 117 ++++++++++++++++++ tests/unit/service/test_risk_scan_service.py | 74 +++++++++++ tools/probe_exemption_data.py | 101 +++++++++++++++ 9 files changed, 518 insertions(+), 16 deletions(-) create mode 100644 docs/evidence/exemption-data-probe.json create mode 100644 tests/unit/service/test_risk_judgement_rw007.py create mode 100644 tools/probe_exemption_data.py diff --git a/app/service/risk_judgement_service.py b/app/service/risk_judgement_service.py index 89cd7a8..9ec0cf4 100644 --- a/app/service/risk_judgement_service.py +++ b/app/service/risk_judgement_service.py @@ -203,8 +203,38 @@ def _assess_rw007(detail: dict[str, Any]) -> dict[str, Any]: ["核对预警生成时和当前测评记录是否发生变更。"], ) + # 第十五条豁免规则:C3→R4、C4→R5 是**允许**的越级购买,前提是签署风险揭示书 + # 且单只持仓不超过总资产的 20% / 10%。所以"留痕齐全"只是豁免成立的一半条件, + # 另一半是额度 —— 只判留痕会把"签了字但买超了额度"的违规判成误报。 + snapshot = _mapping(detail.get("evidence_snapshot")) + limit = _decimal(snapshot.get("exemption_limit")) + ratio = _decimal(snapshot.get("exemption_ratio")) + if limit is not None and ratio is not None and ratio > limit: + return _assessment( + VERDICT_RISK_SUPPORTED, + "高", + [ + f"客户等级与产品等级相差 {level_gap} 级,属第十五条可豁免情形。", + f"但单只持仓占比 {ratio:.2%} 已超过豁免上限 {limit:.0%}。", + ], + ["核实风险揭示书签署情况,并查明持仓占比突破豁免额度的原因。"], + ) + missing_traces = _missing_traces(product, work_order) if not missing_traces: + if limit is not None and snapshot.get("exemption_data_missing"): + # 额度要靠总资产与持仓快照才能核算。本项目的画像与持仓由上游流程写入, + # 数据没落地时既不能默认"没超"判误报,也不能默认"超了"判风险。 + return _assessment( + VERDICT_CONTINUE_REVIEW, + "中", + [ + f"客户等级与产品等级相差 {level_gap} 级,属第十五条可豁免情形," + "且产品要求的交易留痕已具备。", + "但缺少总资产或单只持仓快照,无法核算豁免额度是否被突破。", + ], + ["补查客户总资产与单只产品持仓市值后,再判定豁免是否成立。"], + ) return _assessment( VERDICT_SUSPECTED_FALSE_POSITIVE, "高", diff --git a/app/service/risk_scan_service.py b/app/service/risk_scan_service.py index 1ad0094..9a203da 100644 --- a/app/service/risk_scan_service.py +++ b/app/service/risk_scan_service.py @@ -23,6 +23,7 @@ from app.model.audit import InteractionAudit from app.model.fund import ( FundCapitalFlow, FundCustomerProfile, + FundHolding, FundProduct, FundRiskAlert, FundTransaction, @@ -35,6 +36,38 @@ HIGH_RISK = "高" MEDIUM_RISK = "中" LOW_RISK = "低" RISK_ORDER = {LOW_RISK: 0, MEDIUM_RISK: 1, HIGH_RISK: 2} +# 《个人投资者适当性管理指南》第十五条豁免规则:这两组越级购买是**允许**的, +# 但单只产品持仓有额度上限。键是(客户等级, 产品等级),值是"单只持仓 / 总资产"上限。 +EXEMPTION_LIMITS: dict[tuple[str, str], Decimal] = { + ("C3", "R4"): Decimal("0.20"), + ("C4", "R5"): Decimal("0.10"), +} + + +def _exemption_exceeded(exemption: dict[str, Any]) -> bool: + """豁免额度是否被突破。占比算不出来时**不算突破**(取向见 `_exemption_state`)。""" + ratio = exemption.get("exemption_ratio") + limit = exemption.get("exemption_limit") + if ratio is None or limit is None: + return False + return Decimal(ratio) > Decimal(limit) + + +def _suitability_summary( + customer_level: str, + product_level: str, + missing_trace: bool, + exemption: dict[str, Any], +) -> str: + parts = [f"{customer_level} 客户购买 {product_level} 产品"] + if _exemption_exceeded(exemption): + parts.append( + f"单只持仓占比 {Decimal(exemption['exemption_ratio']):.2%} " + f"超过第十五条豁免上限 {Decimal(exemption['exemption_limit']):.0%}" + ) + if missing_trace: + parts.append("交易留痕不完整") + return ",".join(parts) + "。" _scan_lock = asyncio.Lock() logger = logging.getLogger(__name__) @@ -161,6 +194,7 @@ class RiskRuleEngine: ) continue gap = product_level - customer_level + exemption = await self._exemption_state(transaction, customer, product) missing_trace = ( ( product.risk_disclosure_required @@ -181,16 +215,20 @@ class RiskRuleEngine: and (work_order is None or not work_order.recording_reference) ) ) - if gap > 0 and missing_trace: + exceeded = _exemption_exceeded(exemption) + missing_trace = bool(missing_trace) + if gap > 0 and (missing_trace or exceeded): level = HIGH_RISK if gap >= 2 else MEDIUM_RISK alerts.append(self._build_alert( transaction=transaction, alert_type="适当性错配", level=level, rules=["RW-007"], - summary=( - f"{customer.investor_type} 客户购买 " - f"{product.risk_level} 产品,交易留痕不完整。" + summary=_suitability_summary( + customer.investor_type or "", + product.risk_level, + missing_trace, + exemption, ), priority=90 if level == HIGH_RISK else 70, event_status="刚刚发生", @@ -198,10 +236,61 @@ class RiskRuleEngine: "product_id": product.id, "level_gap": gap, "work_order_id": transaction.work_order_id, + **exemption, }, )) return alerts + async def _exemption_state( + self, + transaction: FundTransaction, + customer: RiskUser, + product: FundProduct, + ) -> dict[str, Any]: + """按第十五条核算豁免额度,返回要写进证据快照的字段。 + + 政策原文:C3 买 R4 需签《产品风险超越投资者风险承受能力揭示书》且**单只 R4 持仓 + 不超过总资产 20%**;C4 买 R5 同理,上限 10%。所以越级购买本身不是违规, + **超出额度**才是 —— 原先扫描侧只看"留痕是否齐全",完全没有额度这一维。 + + 数据缺失时的取向:**不把"算不出来"当成"超限"**。实测本项目库里 + `fin_customer_profile.total_asset` 全为 0、`fin_holding` 为空 —— 画像与持仓由本 + 项目之外的流程写入(与 `behavior_score` 同源),拿 0 去算会让每一笔 C3→R4 都变成 + "超限",等于把豁免规则变成新的误报源。因此这里只如实写出缺失, + 由研判侧提示人工确认。 + """ + limit = EXEMPTION_LIMITS.get((customer.investor_type or "", product.risk_level)) + if limit is None: + return {} + total_asset = await self.session.scalar( + select(FundCustomerProfile.total_asset).where( + FundCustomerProfile.customer_id == transaction.customer_id + ) + ) + holding_value = await self.session.scalar( + select(FundHolding.current_value) + .where( + FundHolding.customer_id == transaction.customer_id, + FundHolding.product_id == transaction.product_id, + ) + .order_by(FundHolding.id.desc()) + .limit(1) + ) + state: dict[str, Any] = {"exemption_limit": str(limit)} + if ( + total_asset is None + or holding_value is None + or Decimal(total_asset) <= 0 + ): + state["exemption_ratio"] = None + state["exemption_data_missing"] = True + return state + state["exemption_ratio"] = str(Decimal(holding_value) / Decimal(total_asset)) + state["exemption_data_missing"] = False + state["holding_value"] = str(holding_value) + state["total_asset"] = str(total_asset) + return state + async def _elderly_redemption(self) -> list[FundRiskAlert]: alerts: list[FundRiskAlert] = [] transactions = await self._scalars( diff --git a/docs/25-风控模块代码评审报告.md b/docs/25-风控模块代码评审报告.md index 0602bb1..6e9f9a6 100644 --- a/docs/25-风控模块代码评审报告.md +++ b/docs/25-风控模块代码评审报告.md @@ -97,6 +97,18 @@ agent_intent_config 中 agent_type='risk':0 条 `ModelGenerationService` → `BaseAgent`),再让风控改用它。因为**只是新增方法、不改现有行为**, 对已有 Agent 零影响,但涉及基座核心链路,**是否做需要项目方定**(本轮未实施)。 +**业务裁定(2026-09-11):本轮不补,按基座能力缺口记录。** + +理由:风控已经跑通,收益只在风控一侧;而 `chat(messages, tools)` 要贯穿 +`ModelGateway` → `OpenAICompatibleGateway` → `ModelDispatchService` → +`ModelGenerationService` → `BaseAgent` **整条链路**,属于公共契约变更 —— 在演示与联调期间 +改它,影响面大于收益。留待基座排期时一并做,届时风控改用它是替换 +`RiskAgentModelClient` 一个类的事。 + +上面"仍然成立的两点"里,第 1 点(`bootstrap.py:262-265` 的 lambda 让注入点形同虚设)本轮 +同样**不改**:它只在注入替身时才看得出差别,不影响任何行为,等基座补 `chat` 入口时一起处理。 +第 2 点(HTTP 逻辑重复一份)随第 1 点一起消失。 + ### 3. 能力过滤失效,当前能工作只是巧合 🔁 ✅ `risk_agent_model_client.py:51-54` 传 `task_type="risk_agent_chat"`,不在 `TASK_CAPABILITY` 里 → @@ -186,6 +198,10 @@ Asia/Shanghai 展示。北京 08:00 前生成时,统计窗口是"前一日 08: 规则**:把明显误报(如系统重复触发)也强制走一遍调查,未必是想要的效果。代码里两道门是有意 设置的,看不出实现偏差。**需要业务方裁定**,不由技术侧单方面加门禁。 +**业务裁定(2026-09-11):保持现状,不加门禁。** `exclude` 继续只要求"已确认接收 + +未闭环"。理由与复核时的判断一致:强制"调查中"会让明显误报(如系统重复触发)也必须走 +一遍调查流程,代价大于收益;`exclude` 仍会写审计、仍要求已确认接收,不是无声关闭。 + **b. `min(20, score_before)` —— 误报。** `BEHAVIOR_SCORE_INITIAL = 20` 是**满分**(`:18`),扣分表 `{"低":3, "中":5, "高":20}`(`:19`) @@ -287,10 +303,48 @@ C1(1) 与 R2(2) 相比 `1 < 2`:**按矩阵可以买,按第十四条不能买 **这不是代码问题,是制度文本冲突**,需要业务方定一条为准。客服侧的适当性裁决走的是 `check_suitability`(按档案等级与匹配规则),两边口径也需要对齐。 -### 2. 第十五条豁免规则未落地 ⚠️ +**业务裁定(2026-09-11):以第十四条 `C ≥ R` 为准。** -C3→R4(单只 ≤ 总资产 20%)、C4→R5(≤10%)的**持仓占比校验完全没有实现**; -`risk_judgement_service.py:157` 只要留痕齐全就判"疑似误报"。是暂不实现还是漏了,需确认。 +**客服侧口径复核(同日):本来就一致,无需改动。** 复核 `SuitabilityService._decide` +(`suitability_service.py:195`)后确认,它的判定是 +`if profile.customer_risk_level < request.product_risk_level: 拒绝` —— **同样是第十四条的 +`C ≥ R`**,并没有使用第十二条的匹配矩阵。原文"按档案等级与匹配规则"是评审时的推测, +不成立。 + +两侧看上去的差异只有两点,且都不构成口径冲突: + +1. **专业投资者**:客服侧豁免等级匹配,但强制 `required_disclosure` / + `requires_confirmation` / `requires_recording`(`suitability_service.py:183-194`); + 风控扫描不做等级豁免,而是直接检查"该有的揭示、二次确认、录音留痕有没有"。 + 两者合起来是同一句话:豁免等级不等于豁免留痕。 +2. **触发条件**:客服是**事前拦截**(`C < R` 直接不许买),风控是**事后发现** + (`risk_scan_service.py:184` 的 `gap > 0 and missing_trace`)。这是职责差异,不是口径 + 差异 —— RW-007 的语义是"错配**且**留痕不全",不是"所有错配"。留痕完整却仍然成交, + 那是客服没能拦住,属另一个问题。 + +### 2. 第十五条豁免规则未落地 ✅(业务裁定:实现,2026-09-11 已完成) + +C3→R4(单只 ≤ 总资产 20%)、C4→R5(≤ 10%)的**持仓占比校验原先完全没有实现**; +`risk_judgement_service.py` 只要留痕齐全就判"疑似误报" —— 把豁免的**前提条件**当成了 +结论。业务方裁定实现,两侧一起改: + +- **扫描侧**:新增 `EXEMPTION_LIMITS` 与 `RiskRuleEngine._exemption_state`,核算 + "单只持仓 / 总资产"并写进证据快照(`exemption_limit`、`exemption_ratio`、 + `exemption_data_missing`、`holding_value`、`total_asset`);触发条件由 + `gap > 0 and missing_trace` 改为 `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` 同源:画像与持仓由本项目之外的流程写入。 + +因此刻意**不**把"算不出来"当成"超限"。拿 `total_asset = 0` 去算,每一笔 C3→R4 都会变成 +违规,豁免规则反而成了新的误报源。数据缺失时扫描侧不产生预警,研判侧返回"继续复核"并 +要求补查总资产与持仓快照 —— 由人工定案,而不是用缺失数据假装有结论。 + +上游把总资产与持仓写入之后,这条链路**无需再改代码**即可生效。 ### 3. `docs/24` 需要同步更新 diff --git a/docs/evidence/exemption-data-probe.json b/docs/evidence/exemption-data-probe.json new file mode 100644 index 0000000..5877507 --- /dev/null +++ b/docs/evidence/exemption-data-probe.json @@ -0,0 +1,28 @@ +{ + "profile_total_asset": [ + { + "rows_count": 1, + "positive_assets": "0", + "zero_assets": "1", + "min_asset": "0.00", + "max_asset": "0.00" + } + ], + "profile_investor_type": [ + { + "investor_type": "C2", + "rows_count": 1 + } + ], + "holding_shape": [ + { + "rows_count": 0, + "null_market_value": null, + "positive_current_value": null, + "min_current_value": null, + "max_current_value": null + } + ], + "subscription_pairs": [], + "exemptible_pairs_detail": [] +} \ No newline at end of file diff --git a/docs/风控业务演示文档/03-风控业务规则与研判手册.md b/docs/风控业务演示文档/03-风控业务规则与研判手册.md index c1c8551..83a2099 100644 --- a/docs/风控业务演示文档/03-风控业务规则与研判手册.md +++ b/docs/风控业务演示文档/03-风控业务规则与研判手册.md @@ -27,12 +27,17 @@ | 项目 | 说明 | |---|---| -| 场景 | 客户风险承受等级低于产品风险等级,且交易留痕不完整 | -| 核心条件 | 产品风险等级高于客户等级;缺少风险揭示、二次确认或录音留痕 | +| 场景 | 客户风险承受等级低于产品风险等级,且交易留痕不完整或超出豁免额度 | +| 核心条件 | 产品风险等级高于客户等级;且(缺少风险揭示、二次确认或录音留痕,**或**属第十五条可豁免情形但单只持仓占比超过额度) | | 风险等级 | 等级差大于等于 2 时高风险;等级差为 1 时中风险 | -| 关键证据 | 客户等级、产品等级、风险揭示、二次确认和录音编号 | -| 风险结论 | 存在等级差且留痕缺失时支持风险判断 | -| 误报关注 | 当前等级不再错配,或要求的留痕已完整存在 | +| 关键证据 | 客户等级、产品等级、风险揭示、二次确认、录音编号、豁免额度与单只持仓占比 | +| 风险结论 | 存在等级差且(留痕缺失或超出豁免额度)时支持风险判断 | +| 误报关注 | 当前等级不再错配;或留痕完整**且**单只持仓在豁免额度内 | + +**第十五条豁免额度**(《个人投资者适当性管理指南》):C3 买 R4 单只持仓不超过总资产 +20%,C4 买 R5 不超过 10%。越级购买本身不是违规,**超出额度**才是。额度需要总资产与 +单只持仓快照才能核算;数据未落地时扫描侧不判超限,研判侧返回"继续复核"提示人工补查, +不会凭缺失数据直接定案。 ## RW-012 老年客户异常大额赎回 diff --git a/docs/风控业务演示文档/05-误报可放行与疑似误判标准.md b/docs/风控业务演示文档/05-误报可放行与疑似误判标准.md index 104816c..ee6179c 100644 --- a/docs/风控业务演示文档/05-误报可放行与疑似误判标准.md +++ b/docs/风控业务演示文档/05-误报可放行与疑似误判标准.md @@ -24,10 +24,14 @@ ## RW-007 研判 -- 风险成立:客户风险等级低于产品风险等级,且所需留痕存在缺失。 -- 疑似误报:当前客户等级与产品等级不再错配,或风险揭示、二次确认和录音留痕完整。 -- 继续复核:缺少客户等级或产品等级。 -- 复核动作:核对最新风险测评、留痕时间和录音编号真实性。 +- 风险成立:客户风险等级低于产品风险等级,且所需留痕存在缺失;或留痕完整但单只持仓 + 占比超过第十五条豁免额度(C3→R4 为 20%,C4→R5 为 10%)。 +- 疑似误报:当前客户等级与产品等级不再错配,或风险揭示、二次确认和录音留痕完整**且** + 单只持仓在豁免额度内。 +- 继续复核:缺少客户等级或产品等级;或属可豁免情形、留痕完整但缺少总资产/持仓快照, + 无法核算豁免额度。 +- 复核动作:核对最新风险测评、留痕时间和录音编号真实性;必要时补查客户总资产与单只 + 产品持仓市值。 ## RW-012 研判 diff --git a/tests/unit/service/test_risk_judgement_rw007.py b/tests/unit/service/test_risk_judgement_rw007.py new file mode 100644 index 0000000..ad2b550 --- /dev/null +++ b/tests/unit/service/test_risk_judgement_rw007.py @@ -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) diff --git a/tests/unit/service/test_risk_scan_service.py b/tests/unit/service/test_risk_scan_service.py index 6607288..4e83283 100644 --- a/tests/unit/service/test_risk_scan_service.py +++ b/tests/unit/service/test_risk_scan_service.py @@ -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( diff --git a/tools/probe_exemption_data.py b/tools/probe_exemption_data.py new file mode 100644 index 0000000..e3a29ae --- /dev/null +++ b/tools/probe_exemption_data.py @@ -0,0 +1,101 @@ +"""只读探查:第十五条豁免规则(持仓占比)所需的数据是否齐备。 + +只做 SELECT。结果写 `docs/evidence/exemption-data-probe.json`: + + python tools/probe_exemption_data.py + +要回答的问题: +1. `fin_customer_profile.total_asset` 有没有值、是否为正; +2. `fin_holding` 有没有行、`market_value` / `current_value` 是否可用; +3. 库内是否存在 C3→R4 / C4→R5 的申购交易 —— 即这条豁免规则是否真的会被触发。 +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any + +from sqlalchemy import text + +from app.infrastructure.db import SessionFactory + +OUTPUT = Path("docs/evidence/exemption-data-probe.json") + +QUERIES: dict[str, str] = { + "profile_total_asset": """ + SELECT COUNT(*) AS rows_count, + SUM(total_asset > 0) AS positive_assets, + SUM(total_asset = 0) AS zero_assets, + MIN(total_asset) AS min_asset, + MAX(total_asset) AS max_asset + FROM fin_customer_profile + """, + "profile_investor_type": """ + SELECT investor_type, COUNT(*) AS rows_count + FROM fin_customer_profile GROUP BY investor_type ORDER BY investor_type + """, + "holding_shape": """ + SELECT COUNT(*) AS rows_count, + SUM(market_value IS NULL) AS null_market_value, + SUM(current_value > 0) AS positive_current_value, + MIN(current_value) AS min_current_value, + MAX(current_value) AS max_current_value + FROM fin_holding + """, + "subscription_pairs": """ + SELECT c.investor_type AS customer_level, + p.risk_level AS product_level, + COUNT(*) AS transactions + FROM fin_transaction t + JOIN fin_customer_profile c ON c.customer_id = t.customer_id + JOIN fin_product p ON p.id = t.product_id + WHERE t.transaction_type = '申购' + GROUP BY c.investor_type, p.risk_level + ORDER BY c.investor_type, p.risk_level + """, + "exemptible_pairs_detail": """ + SELECT t.transaction_no, + c.investor_type AS customer_level, + p.risk_level AS product_level, + c.total_asset, + ( + SELECT h.current_value FROM fin_holding h + WHERE h.customer_id = t.customer_id AND h.product_id = t.product_id + ORDER BY h.id DESC LIMIT 1 + ) AS holding_current_value + FROM fin_transaction t + JOIN fin_customer_profile c ON c.customer_id = t.customer_id + JOIN fin_product p ON p.id = t.product_id + WHERE t.transaction_type = '申购' + AND ( + (c.investor_type = 'C3' AND p.risk_level = 'R4') + OR (c.investor_type = 'C4' AND p.risk_level = 'R5') + ) + ORDER BY t.id + """, +} + + +async def collect() -> dict[str, Any]: + report: dict[str, Any] = {} + async with SessionFactory() as session: + for name, sql in QUERIES.items(): + rows = (await session.execute(text(sql))).mappings().all() + report[name] = [dict(row) for row in rows] + return report + + +async def main() -> None: + report = await collect() + OUTPUT.parent.mkdir(parents=True, exist_ok=True) + OUTPUT.write_text( + json.dumps(report, ensure_ascii=False, indent=2, default=str), + encoding="utf-8", + ) + print(f"wrote {OUTPUT}") + + +if __name__ == "__main__": + asyncio.run(main())