Files
XingHuo/app/service/risk/rules.py
T

173 lines
6.4 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.
"""风控事件规则 RISK-001~005(纯函数 · 规则权威 docs/PRD/附-风控规则表.md §2)。
输入约定:trades 为**当日 confirmed 的 subscribe/redeem 明细**(引擎组装;函数内再做防御过滤),
amount 为 Decimal,traded_at 为 datetime;输出命中规则列表(RuleHit)。
阈值由 RiskThresholds 打包(默认来自 settings,可 .env 覆盖)。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Any
from app.config.settings import settings
# 规则 → 预警类型/静态风险分(PRD FR-3 静态映射;R-05 时替换动态评分)
RULE_SCORES: dict[str, int] = {
"RISK-001": 70,
"RISK-002": 70,
"RISK-003": 50,
"RISK-004": 80,
"RISK-005": 80,
}
RULE_ALERT_TYPES: dict[str, str] = {
"RISK-001": "large_amount",
"RISK-002": "large_amount",
"RISK-003": "freq_trade",
"RISK-004": "pattern",
"RISK-005": "pattern",
}
@dataclass(frozen=True)
class RiskThresholds:
large_amount: Decimal = Decimal("500000")
daily_total: Decimal = Decimal("500000")
freq_count: int = 3
probe_window_minutes: int = 5
probe_count: int = 3
probe_amount: Decimal = Decimal("400000")
small_amount: Decimal = Decimal("10000")
small_count: int = 3
@classmethod
def from_settings(cls) -> "RiskThresholds":
s = settings
return cls(
large_amount=s.risk_large_amount,
daily_total=s.risk_daily_total,
freq_count=s.risk_freq_count,
probe_window_minutes=s.risk_probe_window_minutes,
probe_count=s.risk_probe_count,
probe_amount=s.risk_probe_amount,
small_amount=s.risk_small_amount,
small_count=s.risk_small_count,
)
@dataclass(frozen=True)
class RuleHit:
rule_id: str
alert_type: str
risk_score: int
detail: str # 触发说明(进预警单 payload / audit)
def _eligible(trade: dict[str, Any]) -> bool:
"""防御过滤:仅 confirmed 的 subscribe/redeem 计入(PRD FR-1 convert 不进事件线)。"""
return (
trade.get("trade_status", "confirmed") == "confirmed"
and trade.get("trade_type") in ("subscribe", "redeem")
)
def _amount(trade: dict[str, Any]) -> Decimal:
v = trade["amount"]
return v if isinstance(v, Decimal) else Decimal(str(v))
def rule_large_amount(trades: list[dict[str, Any]], th: RiskThresholds) -> RuleHit | None:
"""RISK-001 单笔大额:amount ≥ large_amount。"""
for t in trades:
if _amount(t) >= th.large_amount:
return RuleHit(
"RISK-001", RULE_ALERT_TYPES["RISK-001"], RULE_SCORES["RISK-001"],
f"单笔交易 {_amount(t)} 元 ≥ 阈值 {th.large_amount} 元(trade_id={t['trade_id']})",
)
return None
def rule_daily_total(trades: list[dict[str, Any]], th: RiskThresholds) -> RuleHit | None:
"""RISK-002 单日累计大额:当日申赎合计 ≥ daily_total(含本笔)。"""
total = sum((_amount(t) for t in trades), Decimal(0))
if total >= th.daily_total:
return RuleHit(
"RISK-002", RULE_ALERT_TYPES["RISK-002"], RULE_SCORES["RISK-002"],
f"当日申赎累计 {total} 元 ≥ 阈值 {th.daily_total} 元(共 {len(trades)} 笔)",
)
return None
def rule_freq_trade(trades: list[dict[str, Any]], th: RiskThresholds) -> RuleHit | None:
"""RISK-003 频繁交易:同一客户同一产品当日申赎合计 ≥ freq_count 笔。"""
by_product: dict[str, list[dict[str, Any]]] = {}
for t in trades:
by_product.setdefault(t["product_id"], []).append(t)
for product_id, pts in by_product.items():
if len(pts) >= th.freq_count:
return RuleHit(
"RISK-003", RULE_ALERT_TYPES["RISK-003"], RULE_SCORES["RISK-003"],
f"产品 {product_id} 当日申赎 {len(pts)} 笔 ≥ {th.freq_count} 笔",
)
return None
def rule_probe_pattern(
trades: list[dict[str, Any]], th: RiskThresholds, now: datetime
) -> RuleHit | None:
"""RISK-004 接近阈值试探:now 前 probe_window_minutes 内 ≥probe_count 笔且每笔 ≥probe_amount。"""
window_start = now - timedelta(minutes=th.probe_window_minutes)
in_window = [
t for t in trades
if window_start <= t["traded_at"] <= now and _amount(t) >= th.probe_amount
]
if len(in_window) >= th.probe_count:
return RuleHit(
"RISK-004", RULE_ALERT_TYPES["RISK-004"], RULE_SCORES["RISK-004"],
f"近 {th.probe_window_minutes} 分钟内 {len(in_window)} 笔每笔 ≥ {th.probe_amount} 元,"
f"接近大额阈值试探模式",
)
return None
def rule_small_then_large(trades: list[dict[str, Any]], th: RiskThresholds) -> RuleHit | None:
"""RISK-005 先小后大:当日时间序上首次大额之前已存在 ≥small_count 笔 ≤small_amount
(不要求连续、可穿插其他金额,PRD FR-3)。"""
ordered = sorted(trades, key=lambda t: t["traded_at"])
small_count = 0
for t in ordered:
if _amount(t) >= th.large_amount:
if small_count >= th.small_count:
return RuleHit(
"RISK-005", RULE_ALERT_TYPES["RISK-005"], RULE_SCORES["RISK-005"],
f"当日首次大额前已存在 {small_count} 笔 ≤{th.small_amount} 元小额交易,"
f"先小后大模式(trade_id={t['trade_id']})",
)
return None # 首次大额即判定结束
if _amount(t) <= th.small_amount:
small_count += 1
return None
def run_rules(
trades: list[dict[str, Any]],
thresholds: RiskThresholds | None = None,
now: datetime | None = None,
) -> list[RuleHit]:
"""引擎入口的规则编排:对单笔交易事件后的当日流水跑全部规则,返回全部命中。"""
th = thresholds or RiskThresholds.from_settings()
now = now or datetime.now()
eligible = [t for t in trades if _eligible(t)]
if not eligible:
return []
hits: list[RuleHit | None] = [
rule_large_amount(eligible, th),
rule_daily_total(eligible, th),
rule_freq_trade(eligible, th),
rule_probe_pattern(eligible, th, now),
rule_small_then_large(eligible, th),
]
return [h for h in hits if h is not None]