430 lines
15 KiB
Python
430 lines
15 KiB
Python
"""风控预警只读研判草案。
|
|
|
|
本模块只根据现有规则命中条件和证据字段给出复核方向,不执行误报关闭、
|
|
放行、结案或升级等人工处置动作。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from datetime import date, datetime
|
|
from decimal import Decimal, InvalidOperation
|
|
from typing import Any
|
|
|
|
VERDICT_RELEASE = "可考虑放行"
|
|
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]:
|
|
if rule == "RW-018":
|
|
return _assessment(
|
|
VERDICT_RELEASE,
|
|
"高",
|
|
["命中低优先级频繁交易初筛,现有摘要显示交易来自有效定投工单。"],
|
|
["核验定投工单状态、交易周期和客户授权记录。"],
|
|
)
|
|
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 _assess_detail_rule(rule: str, detail: dict[str, Any]) -> dict[str, Any]:
|
|
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')} 已不存在风险等级差。",
|
|
],
|
|
["核对预警生成时和当前测评记录是否发生变更。"],
|
|
)
|
|
|
|
missing_traces = _missing_traces(product, work_order)
|
|
if not missing_traces:
|
|
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} 元已不满足规则门槛。",
|
|
],
|
|
["核对证据快照是否来自已变更或已冲正的交易。"],
|
|
)
|
|
|
|
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,
|
|
"中",
|
|
["缺少交易金额或成交时间。"],
|
|
["补查交易明细和成交时间。"],
|
|
)
|
|
if amount <= Decimal("10000") and 0 <= confirmed_at.hour < 6:
|
|
return _assessment(
|
|
VERDICT_RELEASE,
|
|
"中",
|
|
[
|
|
f"交易金额 {amount:.2f} 元较小,并发生在 {confirmed_at.hour} 时。",
|
|
"该规则本身属于低优先级运营特征初筛。",
|
|
],
|
|
["核验设备、交易地点和客户操作意图后人工决定是否放行。"],
|
|
)
|
|
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 == "定投":
|
|
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
|
|
today = date.today()
|
|
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
|