落地第十五条豁免额度校验;登记三处业务裁定
一、第十五条豁免规则(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:
@@ -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,
|
||||
"高",
|
||||
|
||||
@@ -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(
|
||||
|
||||
+57
-3
@@ -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` 需要同步更新
|
||||
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -27,12 +27,17 @@
|
||||
|
||||
| 项目 | 说明 |
|
||||
|---|---|
|
||||
| 场景 | 客户风险承受等级低于产品风险等级,且交易留痕不完整 |
|
||||
| 核心条件 | 产品风险等级高于客户等级;缺少风险揭示、二次确认或录音留痕 |
|
||||
| 场景 | 客户风险承受等级低于产品风险等级,且交易留痕不完整或超出豁免额度 |
|
||||
| 核心条件 | 产品风险等级高于客户等级;且(缺少风险揭示、二次确认或录音留痕,**或**属第十五条可豁免情形但单只持仓占比超过额度) |
|
||||
| 风险等级 | 等级差大于等于 2 时高风险;等级差为 1 时中风险 |
|
||||
| 关键证据 | 客户等级、产品等级、风险揭示、二次确认和录音编号 |
|
||||
| 风险结论 | 存在等级差且留痕缺失时支持风险判断 |
|
||||
| 误报关注 | 当前等级不再错配,或要求的留痕已完整存在 |
|
||||
| 关键证据 | 客户等级、产品等级、风险揭示、二次确认、录音编号、豁免额度与单只持仓占比 |
|
||||
| 风险结论 | 存在等级差且(留痕缺失或超出豁免额度)时支持风险判断 |
|
||||
| 误报关注 | 当前等级不再错配;或留痕完整**且**单只持仓在豁免额度内 |
|
||||
|
||||
**第十五条豁免额度**(《个人投资者适当性管理指南》):C3 买 R4 单只持仓不超过总资产
|
||||
20%,C4 买 R5 不超过 10%。越级购买本身不是违规,**超出额度**才是。额度需要总资产与
|
||||
单只持仓快照才能核算;数据未落地时扫描侧不判超限,研判侧返回"继续复核"提示人工补查,
|
||||
不会凭缺失数据直接定案。
|
||||
|
||||
## RW-012 老年客户异常大额赎回
|
||||
|
||||
|
||||
@@ -24,10 +24,14 @@
|
||||
|
||||
## RW-007 研判
|
||||
|
||||
- 风险成立:客户风险等级低于产品风险等级,且所需留痕存在缺失。
|
||||
- 疑似误报:当前客户等级与产品等级不再错配,或风险揭示、二次确认和录音留痕完整。
|
||||
- 继续复核:缺少客户等级或产品等级。
|
||||
- 复核动作:核对最新风险测评、留痕时间和录音编号真实性。
|
||||
- 风险成立:客户风险等级低于产品风险等级,且所需留痕存在缺失;或留痕完整但单只持仓
|
||||
占比超过第十五条豁免额度(C3→R4 为 20%,C4→R5 为 10%)。
|
||||
- 疑似误报:当前客户等级与产品等级不再错配,或风险揭示、二次确认和录音留痕完整**且**
|
||||
单只持仓在豁免额度内。
|
||||
- 继续复核:缺少客户等级或产品等级;或属可豁免情形、留痕完整但缺少总资产/持仓快照,
|
||||
无法核算豁免额度。
|
||||
- 复核动作:核对最新风险测评、留痕时间和录音编号真实性;必要时补查客户总资产与单只
|
||||
产品持仓市值。
|
||||
|
||||
## RW-012 研判
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user