落地第十五条豁免额度校验;登记三处业务裁定

一、第十五条豁免规则(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:
2026-09-11 14:24:45 +08:00
parent df96275ab5
commit ff681df143
9 changed files with 518 additions and 16 deletions
+30
View File
@@ -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,
"高",
+93 -4
View File
@@ -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
View File
@@ -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` 需要同步更新
+28
View File
@@ -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(
+101
View File
@@ -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())