"""风控事件规则 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]