173 lines
6.4 KiB
Python
173 lines
6.4 KiB
Python
"""风控事件规则 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]
|