Files
group_fqcd_jr/app/service/risk_judgement_service.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

537 lines
22 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.
"""风控预警只读研判草案。
本模块只根据现有规则命中条件和证据字段给出复核方向,不执行误报关闭、
放行、结案或升级等人工处置动作。
"""
from __future__ import annotations
import re
from datetime import UTC, date, datetime
from decimal import Decimal, InvalidOperation
from typing import Any
from app.core.timeutil import local_date, local_hour
VERDICT_RELEASE = "可考虑放行"
# 定投类工单渠道。扫描侧按"有效定投工单"生成 RW-018 预警时接受这两个值
# (`risk_scan_service.py:294` 的 `work_order.channel in {"定投", "自动定投"}`)。
# 研判侧必须用**同一份口径**:原先详情级只认 `"定投"`,于是"自动定投"的预警
# 会出现"扫描按有效工单生成、详情却说未确认有效定投工单"的自相矛盾。
DIRECT_INVESTMENT_CHANNELS = frozenset({"定投", "自动定投"})
VERDICT_SUSPECTED_FALSE_POSITIVE = "疑似误报"
VERDICT_CONTINUE_REVIEW = "继续复核"
VERDICT_RISK_SUPPORTED = "证据支持风险"
_VERDICT_SEVERITY = {
VERDICT_RISK_SUPPORTED: 4,
VERDICT_CONTINUE_REVIEW: 3,
VERDICT_SUSPECTED_FALSE_POSITIVE: 2,
VERDICT_RELEASE: 1,
}
_CONFIDENCE_SEVERITY = {"低": 1, "中": 2, "高": 3}
def assess_alert_list_item(item: dict[str, Any]) -> dict[str, Any]:
"""根据预警列表字段给出初步复核方向。"""
rules = _rule_codes(item)
assessments = [_assess_list_rule(rule, item) for rule in rules]
return _combine_assessments(item, rules, assessments)
def assess_alert_detail(detail: dict[str, Any]) -> dict[str, Any]:
"""根据完整证据详情给出规则级只读研判草案。"""
alert = _mapping(detail.get("alert"))
rules = _rule_codes(alert)
assessments = [_assess_detail_rule(rule, detail) for rule in rules]
return _combine_assessments(alert, rules, assessments)
def _assess_list_rule(rule: str, item: dict[str, Any]) -> dict[str, Any]:
item = _flatten_merged_evidence(item)
if rule == "RW-018":
# 原先这里**完全不看 item**,无条件返回"可考虑放行",而且理由文本是硬编码的
# "现有摘要显示交易来自有效定投工单"。于是列表里那些渠道并不匹配、或证据里
# 根本没有工单信息的 RW-018 预警,也被标成"可考虑放行"——等于把待核实的预警
# 在列表层提前放掉了(详情层另有判断,但列表是风控专员最先看到的一屏)。
#
# 现在按 snapshot 里的渠道判断,并与扫描侧(`risk_scan_service.py:294`)
# 共用同一份渠道口径 —— 两处不一致会造成"扫描认为有效、详情认为未确认"的自相矛盾。
snapshot = _mapping(item.get("evidence_snapshot"))
channel = snapshot.get("channel")
if isinstance(channel, str) and channel in DIRECT_INVESTMENT_CHANNELS:
return _assessment(
VERDICT_RELEASE,
"高",
[f"命中低优先级频繁交易初筛,关联工单渠道为 {channel},属有效定投场景。"],
["核验定投工单状态、交易周期和客户授权记录。"],
)
return _assessment(
VERDICT_SUSPECTED_FALSE_POSITIVE,
"中",
["命中频繁交易初筛,但证据中未确认有效定投工单。"],
["核验定投工单状态与客户授权记录后,由风控专员判断是否放行。"],
)
if rule == "RW-015":
return _assessment(
VERDICT_SUSPECTED_FALSE_POSITIVE,
"中",
["命中低风险非正常时段小额操作,初步更偏向运营特征而非高风险欺诈。"],
["核验登录设备、交易地点和客户当日操作意图。"],
)
if rule == "RW-007":
return _assessment(
VERDICT_CONTINUE_REVIEW,
"中",
["适当性错配需要核对客户风险等级、产品风险等级和交易留痕完整性。"],
["读取预警详情,核验风险等级差和双录、确认、留痕字段。"],
)
if rule in {"RW-003", "RW-012"}:
return _assessment(
VERDICT_CONTINUE_REVIEW,
"中",
[f"规则 {rule} 属于大额资金或老年客户高风险场景,列表不足以判断误报。"],
["读取预警详情并核对资金流、历史均值和登录设备证据。"],
)
return _assessment(
VERDICT_CONTINUE_REVIEW,
"低",
["当前规则没有内置误报豁免判断。"],
["读取完整证据后由风控专员人工复核。"],
)
def _flatten_merged_evidence(detail: dict[str, Any]) -> dict[str, Any]:
"""把"合并预警"的嵌套证据摊回顶层,再交给各条规则的研判函数。
同一笔交易命中多条规则时,扫描器会把它们合并成一条
(`risk_scan_service._merge_same_transaction_alerts`)。合并后的 `evidence_snapshot`
只保留 `product_id` 和 `merged_alerts`,各条**原有的证据键被塞进
`merged_alerts[].evidence`**;而各研判函数读的是**顶层键**(`snapshot.get("ratio")`
之类),于是合并过的预警一律读不到证据、降级成"缺证据无法复核" ——
合并本来是为了少几条噪音,结果把这些预警的研判全废掉了(docs/25 P2)。
在入口统一摊平:顶层已有的键优先(它来自 priority_score 最高的那条主预警),
再按顺序补入各子条目的证据键。所有规则共用这一步,不必各自去认嵌套结构。
"""
snapshot = _mapping(detail.get("evidence_snapshot"))
merged = snapshot.get("merged_alerts")
if not isinstance(merged, list):
return detail
flat = dict(snapshot)
for entry in merged:
for key, value in _mapping(_mapping(entry).get("evidence")).items():
flat.setdefault(key, value)
return {**detail, "evidence_snapshot": flat}
def _assess_detail_rule(rule: str, detail: dict[str, Any]) -> dict[str, Any]:
detail = _flatten_merged_evidence(detail)
if rule == "RW-003":
return _assess_rw003(detail)
if rule == "RW-007":
return _assess_rw007(detail)
if rule == "RW-012":
return _assess_rw012(detail)
if rule == "RW-015":
return _assess_rw015(detail)
if rule == "RW-018":
return _assess_rw018(detail)
return _assessment(
VERDICT_CONTINUE_REVIEW,
"低",
[f"规则 {rule} 尚未配置只读误报研判依据。"],
["由风控专员根据完整证据人工判断。"],
)
def _assess_rw003(detail: dict[str, Any]) -> dict[str, Any]:
transaction = _mapping(detail.get("transaction"))
snapshot = _mapping(detail.get("evidence_snapshot"))
amount = _decimal(transaction.get("amount"))
ratio = _decimal(snapshot.get("ratio"))
if amount is None or ratio is None:
return _assessment(
VERDICT_CONTINUE_REVIEW,
"中",
["缺少赎回金额或赎回比例,无法复核大额快进快出条件。"],
["补查对应入金流水和赎回交易金额。"],
)
if amount >= Decimal("500000") and ratio >= Decimal("0.8"):
return _assessment(
VERDICT_RISK_SUPPORTED,
"高",
[
f"赎回金额 {amount:.2f} 元已达到 500000 元阈值。",
f"赎回比例 {ratio:.2%} 已达到 80% 阈值。",
],
["继续核实资金来源、交易目的和客户风险承受能力。"],
)
return _assessment(
VERDICT_SUSPECTED_FALSE_POSITIVE,
"高",
[
f"当前赎回金额 {amount:.2f} 元或赎回比例 {ratio:.2%} 已不满足规则阈值。",
],
["核对交易是否发生冲正、撤销或证据快照是否过期。"],
)
def _assess_rw007(detail: dict[str, Any]) -> dict[str, Any]:
customer = _mapping(detail.get("customer"))
product = _mapping(detail.get("product"))
work_order = _mapping(detail.get("work_order"))
investor_value = _risk_level_number(customer.get("investor_type"), "C")
product_value = _risk_level_number(product.get("risk_level"), "R")
if investor_value is None or product_value is None:
return _assessment(
VERDICT_CONTINUE_REVIEW,
"中",
["缺少客户风险等级或产品风险等级,无法复核适当性错配。"],
["补查客户最新风险测评和产品风险等级。"],
)
level_gap = product_value - investor_value
if level_gap <= 0:
return _assessment(
VERDICT_SUSPECTED_FALSE_POSITIVE,
"高",
[
f"当前客户等级 {customer.get('investor_type')} 与产品等级 "
f"{product.get('risk_level')} 已不存在风险等级差。",
],
["核对预警生成时和当前测评记录是否发生变更。"],
)
# 第十五条豁免规则: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,
"高",
[
f"当前存在 {level_gap} 级风险等级差,但产品要求的交易留痕均已具备。",
],
["复核留痕时间、录音编号和二次确认记录的真实有效性。"],
)
return _assessment(
VERDICT_RISK_SUPPORTED,
"高",
[
f"客户等级与产品等级相差 {level_gap} 级。",
f"缺少交易留痕:{'、'.join(missing_traces)}。",
],
["继续核实双录、风险揭示确认和二次确认材料。"],
)
def _assess_rw012(detail: dict[str, Any]) -> dict[str, Any]:
customer = _mapping(detail.get("customer"))
transaction = _mapping(detail.get("transaction"))
age = _age(customer.get("birth_date"))
amount = _decimal(transaction.get("amount"))
if age is None or amount is None:
return _assessment(
VERDICT_CONTINUE_REVIEW,
"中",
["缺少客户年龄或赎回金额,无法复核老年客户异常赎回。"],
["补查客户出生日期和赎回交易金额。"],
)
if age < 65 or amount < Decimal("300000"):
return _assessment(
VERDICT_SUSPECTED_FALSE_POSITIVE,
"高",
[
f"当前客户年龄 {age} 岁或赎回金额 {amount:.2f} 元已不满足规则门槛。",
],
["核对证据快照是否来自已变更或已冲正的交易。"],
)
# 复核生成条件里的"≥ 3 倍历史均值"。原先这里没有这一项,快照里也没有均值,
# 于是一笔只达到 2 倍均值的赎回会被判成"证据支持风险" —— 而它的建议文本写着
# "继续核实一年期历史交易均值",说明作者知道该看,只是当时确实没有数据可看。
# 现在扫描侧把 average_amount / ratio 写进了快照,这里就能真正核。
snapshot = _mapping(detail.get("evidence_snapshot"))
ratio = _decimal(snapshot.get("ratio"))
if ratio is None:
return _assessment(
VERDICT_CONTINUE_REVIEW,
"中",
["证据快照中没有历史交易均值,无法复核是否达到规则的 3 倍门槛。"],
["补查该客户近一年历史交易均值;数据缺失时由风控专员人工判断。"],
)
if ratio < Decimal("3"):
return _assessment(
VERDICT_SUSPECTED_FALSE_POSITIVE,
"高",
[f"赎回金额为历史均值的 {ratio:.2f} 倍,未达到规则的 3 倍门槛。"],
["核对历史均值口径与统计窗口;确认后按误报关闭。"],
)
confirmed_at = _datetime(transaction.get("confirmed_at"))
logins = detail.get("login_records")
latest_login = _latest_successful_login(logins, confirmed_at)
if latest_login is None:
return _assessment(
VERDICT_CONTINUE_REVIEW,
"中",
["客户年龄和赎回金额达到门槛,但缺少交易日前的成功登录记录。"],
["补查交易前登录设备、IP 地区和设备常用性。"],
)
if not bool(latest_login.get("is_common_device")):
return _assessment(
VERDICT_RISK_SUPPORTED,
"高",
[
f"{age} 岁客户赎回 {amount:.2f} 元。",
"交易前最近一次成功登录使用非常用设备。",
],
["继续核实一年期历史交易均值、客户本人意愿和设备归属。"],
)
return _assessment(
VERDICT_SUSPECTED_FALSE_POSITIVE,
"中",
["当前交易前最近一次成功登录使用常用设备。"],
["继续核验历史交易均值和客户赎回意图。"],
)
def _assess_rw015(detail: dict[str, Any]) -> dict[str, Any]:
transaction = _mapping(detail.get("transaction"))
amount = _decimal(transaction.get("amount"))
confirmed_at = _datetime(transaction.get("confirmed_at"))
if amount is None or confirmed_at is None:
return _assessment(
VERDICT_CONTINUE_REVIEW,
"中",
["缺少交易金额或成交时间。"],
["补查交易明细和成交时间。"],
)
# 必须按**北京时间**判断"凌晨":库里存 UTC,直接取 .hour 会让 [0,6) UTC 变成
# 北京 08:00–14:00(docs/25 P1 #6)。展示的小时数同样要换算,否则风控专员看到的
# 是 UTC 时刻、与他的直觉差 8 小时,无法核对。
if amount <= Decimal("10000") and 0 <= local_hour(confirmed_at) < 6:
return _assessment(
VERDICT_RELEASE,
"中",
[
f"交易金额 {amount:.2f} 元较小,并发生在 {local_hour(confirmed_at)} 时。",
"该规则本身属于低优先级运营特征初筛。",
],
["核验设备、交易地点和客户操作意图后人工决定是否放行。"],
)
return _assessment(
VERDICT_SUSPECTED_FALSE_POSITIVE,
"中",
["当前金额或成交时段已不满足非正常时段小额操作条件。"],
["核对证据快照和当前交易记录是否一致。"],
)
def _assess_rw018(detail: dict[str, Any]) -> dict[str, Any]:
work_order = _mapping(detail.get("work_order"))
channel = work_order.get("channel")
if channel in DIRECT_INVESTMENT_CHANNELS:
return _assessment(
VERDICT_RELEASE,
"高",
["频繁交易初筛关联有效定投工单,现有证据支持正常定投场景。"],
["人工核验工单状态、签约周期、扣款授权和交易频率后考虑放行。"],
)
return _assessment(
VERDICT_CONTINUE_REVIEW,
"中",
["命中频繁交易初筛,但当前未确认有效定投工单。"],
["补查关联工单渠道、状态和客户授权记录。"],
)
def _combine_assessments(
source: dict[str, Any],
rules: list[str],
assessments: list[dict[str, Any]],
) -> dict[str, Any]:
if not assessments:
assessments = [_assessment(
VERDICT_CONTINUE_REVIEW,
"低",
["预警没有可识别的规则编号。"],
["由风控专员根据完整证据人工复核。"],
)]
primary = max(
assessments,
key=lambda item: (
_VERDICT_SEVERITY[item["verdict"]],
_CONFIDENCE_SEVERITY[item["confidence"]],
),
)
return {
"alert_no": source.get("alert_no"),
"rule_codes": rules,
"verdict": primary["verdict"],
"confidence": primary["confidence"],
"reasons": _deduplicate(
reason for item in assessments for reason in item["reasons"]
),
"review_actions": _deduplicate(
action for item in assessments for action in item["review_actions"]
),
"boundary": "仅为只读研判草案,不能替代风控专员人工复核和正式处置。",
}
def _assessment(
verdict: str,
confidence: str,
reasons: list[str],
review_actions: list[str],
) -> dict[str, Any]:
return {
"verdict": verdict,
"confidence": confidence,
"reasons": reasons,
"review_actions": review_actions,
}
def _missing_traces(
product: dict[str, Any],
work_order: dict[str, Any],
) -> list[str]:
checks = (
("风险揭示确认", "risk_disclosure_required", "risk_disclosure_ack_at"),
("二次确认", "second_confirmation_required", "second_confirmation_at"),
("录音留痕", "recording_required", "recording_reference"),
)
missing: list[str] = []
for label, required_field, evidence_field in checks:
if bool(product.get(required_field)) and not work_order.get(evidence_field):
missing.append(label)
return missing
def _latest_successful_login(
value: Any,
confirmed_at: datetime | None,
) -> dict[str, Any] | None:
if not isinstance(value, list):
return None
candidates = []
for item in value:
if not isinstance(item, dict):
continue
if item.get("login_result") != "成功":
continue
login_at = _datetime(item.get("login_at"))
if login_at is None:
continue
if confirmed_at is not None and login_at > confirmed_at:
continue
candidates.append((login_at, item))
if not candidates:
return None
return max(candidates, key=lambda item: item[0])[1]
def _rule_codes(value: dict[str, Any]) -> list[str]:
raw = value.get("rule_codes") or value.get("trigger_rule_codes") or []
if isinstance(raw, str):
raw = [raw]
if not isinstance(raw, (list, tuple)):
return []
return _deduplicate(str(item).upper() for item in raw if item)
def _risk_level_number(value: Any, prefix: str) -> int | None:
if not isinstance(value, str):
return None
matched = re.fullmatch(rf"{re.escape(prefix)}(\d+)", value.strip().upper())
return int(matched.group(1)) if matched else None
def _age(value: Any) -> int | None:
birth_date = _date(value)
if birth_date is None:
return None
# 用**北京时间**的今天。原先这里用服务器 date.today()(东八区的机器上是北京日期,
# 但不保证),而扫描侧用 UTC 日期——两处口径不一致会让同一客户在生日边界上差一岁
# (docs/25 P1 #6)。统一走 timeutil。
today = local_date(datetime.now(UTC))
return today.year - birth_date.year - (
(today.month, today.day) < (birth_date.month, birth_date.day)
)
def _date(value: Any) -> date | None:
if isinstance(value, date) and not isinstance(value, datetime):
return value
if isinstance(value, datetime):
return value.date()
if not isinstance(value, str) or not value.strip():
return None
try:
return date.fromisoformat(value.strip()[:10])
except ValueError:
return None
def _datetime(value: Any) -> datetime | None:
if isinstance(value, datetime):
return value
if not isinstance(value, str) or not value.strip():
return None
try:
return datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
except ValueError:
return None
def _decimal(value: Any) -> Decimal | None:
if value is None or isinstance(value, bool):
return None
try:
return Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
return None
def _mapping(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _deduplicate(values: Any) -> list[str]:
result: list[str] = []
for value in values:
if value not in result:
result.append(value)
return result