"""风控规则引擎(纯代码,不调用 LLM)。 读 risk_rule 表(表驱动参数)→ 按 rule_id 路由到判断函数 → 传入当前订单 + 客户画像/账户/注册时间 + 历史成交流水 → 输出命中规则列表。 设计约定: - 聚合规则统计「历史成交流水」(当前订单尚未成交,不计入历史),阈值即历史累计; 当前这笔的金额/类型由单笔规则(R001~R004、R015~R018)单独判断。 - 阈值来自 risk_rule.threshold(JSON),代码只存判断逻辑,参数可运营调整。 """ from __future__ import annotations from collections import defaultdict from dataclasses import dataclass from datetime import datetime, timedelta from decimal import Decimal from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from model.fin_customer_profile import FinCustomerProfile from model.fin_holdings import FinHoldings from model.fin_product import FinProduct from model.fin_transaction import FinTransaction from model.risk_rule import RiskRule from model.sys_user import SysUser from model.trade_order import TradeOrder from repositories.fin_customer_profile import FinCustomerProfileRepo from repositories.fin_holdings import FinHoldingsRepo from repositories.fin_product import FinProductRepo from repositories.fin_transaction import FinTransactionRepo from repositories.risk_rule import RiskRuleRepo # 风险等级 → 序号(兼容 R1~R5 与 保守~激进 两套口径)。 _RISK_RANK = { "R1": 1, "R2": 2, "R3": 3, "R4": 4, "R5": 5, "保守": 1, "稳健": 2, "平衡": 3, "进取": 4, "激进": 5, } _LEVEL_RANK = {"低": 1, "中": 2, "高": 3} def _risk_rank(level: str | None) -> int | None: if not level: return None return _RISK_RANK.get(level.strip()) def _int(threshold: dict[str, Any] | None, key: str, default: int) -> int: if not threshold or key not in threshold: return default return int(threshold[key]) def _dec(threshold: dict[str, Any] | None, key: str, default: Decimal) -> Decimal: if not threshold or key not in threshold: return default return Decimal(str(threshold[key])) @dataclass class RuleHit: """单条规则命中结果。""" rule: RiskRule detail: str transaction_ids: list[int] @dataclass class AlertSummary: """命中规则列表聚合出的预警摘要。""" alert_type: str alert_level: str trigger_detail: str transaction_ids: list[int] confidence: Decimal @dataclass class _Ctx: """一次检测预查的上下文(避免每条规则重复查库)。""" product: FinProduct | None profile: FinCustomerProfile | None holdings: FinHoldings | None history: list[FinTransaction] last_tx_time: datetime | None now: datetime def summarize(hits: list[RuleHit]) -> AlertSummary: """聚合命中列表:级别取最高档,置信度按权重合成,详情拼接全部命中规则。 confidence = 1 − Π(1 − weight_i)。 """ if not hits: raise ValueError("无命中规则,无法聚合预警") top = max(hits, key=lambda h: _LEVEL_RANK[h.rule.risk_level]) conf = Decimal("1") for h in hits: conf *= Decimal("1") - h.rule.weight confidence = (Decimal("1") - conf).quantize(Decimal("0.01")) detail = ";".join( f"{h.rule.rule_id} {h.rule.rule_name}:{h.detail}" for h in hits ) tx_ids: list[int] = [] seen: set[int] = set() for h in hits: for tid in h.transaction_ids: if tid not in seen: seen.add(tid) tx_ids.append(tid) return AlertSummary( alert_type=top.rule.rule_name, alert_level=top.rule.risk_level, trigger_detail=detail, transaction_ids=tx_ids, confidence=confidence, ) class RiskEngine: """规则引擎:detect(order, customer) -> 命中规则列表。""" def __init__(self, db: AsyncSession): self.db = db async def detect(self, order: TradeOrder, customer: SysUser) -> list[RuleHit]: rules = await RiskRuleRepo(self.db).list_enabled() now = datetime.now() history = await FinTransactionRepo(self.db).list_since( customer.id, now - timedelta(days=90) ) ctx = _Ctx( product=await FinProductRepo(self.db).get(order.product_id), profile=await FinCustomerProfileRepo(self.db).get_by_customer_id(customer.id), holdings=await FinHoldingsRepo(self.db).get_by_customer_product( customer.id, order.product_id ), history=history, last_tx_time=await FinTransactionRepo(self.db).get_last_transaction_time( customer.id ), now=now, ) hits: list[RuleHit] = [] for rule in rules: hit = await self._dispatch(rule, order, customer, ctx) if hit is not None: hits.append(hit) return hits async def _dispatch( self, rule: RiskRule, order: TradeOrder, customer: SysUser, ctx: _Ctx ) -> RuleHit | None: fn = getattr(self, f"_check_{rule.rule_id.lower()}", None) if fn is None: return None return await fn(rule, order, customer, ctx) # ------------------------------------------------------------------ A 类单笔 async def _check_r001(self, rule, order, customer, ctx) -> RuleHit | None: if order.order_type != "申购" or order.amount is None: return None threshold = _dec(rule.threshold, "amount", Decimal("1000000")) if order.amount >= threshold: return RuleHit(rule, f"单笔申购金额 {order.amount} ≥ {threshold}", []) return None async def _check_r002(self, rule, order, customer, ctx) -> RuleHit | None: if order.order_type != "赎回" or order.amount is None: return None threshold = _dec(rule.threshold, "amount", Decimal("1000000")) if order.amount >= threshold: return RuleHit(rule, f"单笔赎回金额 {order.amount} ≥ {threshold}", []) return None async def _check_r003(self, rule, order, customer, ctx) -> RuleHit | None: if order.order_type != "申购" or order.amount is None: return None threshold = _dec(rule.threshold, "amount", Decimal("500000")) if order.amount >= threshold: return RuleHit(rule, f"单笔申购金额 {order.amount} ≥ {threshold}", []) return None async def _check_r004(self, rule, order, customer, ctx) -> RuleHit | None: if order.order_type != "赎回" or order.amount is None: return None threshold = _dec(rule.threshold, "amount", Decimal("500000")) if order.amount >= threshold: return RuleHit(rule, f"单笔赎回金额 {order.amount} ≥ {threshold}", []) return None async def _check_r014(self, rule, order, customer, ctx) -> RuleHit | None: if order.order_type != "申购": return None customer_rank = _risk_rank(ctx.profile.risk_level if ctx.profile else None) product_rank = _risk_rank(ctx.product.risk_level if ctx.product else None) if customer_rank is not None and product_rank is not None and customer_rank < product_rank: return RuleHit( rule, f"客户风险等级({ctx.profile.risk_level})低于产品({ctx.product.risk_level})", [], ) return None async def _check_r020(self, rule, order, customer, ctx) -> RuleHit | None: start = (rule.threshold or {}).get("start", "00:00") end = (rule.threshold or {}).get("end", "06:00") start_hour = int(start.split(":")[0]) end_hour = int(end.split(":")[0]) if start_hour <= ctx.now.hour < end_hour: return RuleHit(rule, f"下单时间 {ctx.now:%H:%M} 落在 {start}–{end}", []) return None # ------------------------------------------------------------------ B 类聚合 async def _check_r005(self, rule, order, customer, ctx) -> RuleHit | None: days = _int(rule.threshold, "days", 7) count = _int(rule.threshold, "count", 10) recent = [t for t in ctx.history if t.create_time >= ctx.now - timedelta(days=days)] if len(recent) >= count: return RuleHit(rule, f"近{days}天申赎 {len(recent)} 笔 ≥ {count}", [t.id for t in recent]) return None async def _check_r006(self, rule, order, customer, ctx) -> RuleHit | None: days = _int(rule.threshold, "days", 1) count = _int(rule.threshold, "count", 5) recent = [t for t in ctx.history if t.create_time >= ctx.now - timedelta(days=days)] if len(recent) >= count: return RuleHit(rule, f"近{days}天申赎 {len(recent)} 笔 ≥ {count}", [t.id for t in recent]) return None async def _check_r007(self, rule, order, customer, ctx) -> RuleHit | None: if order.order_type != "申购": return None days = _int(rule.threshold, "days", 3) count = _int(rule.threshold, "count", 5) max_amount = _dec(rule.threshold, "max_amount", Decimal("10000")) small = [ t for t in ctx.history if t.create_time >= ctx.now - timedelta(days=days) and t.transaction_type == "申购" and t.amount < max_amount ] if len(small) >= count: return RuleHit(rule, f"近{days}天小额申购 {len(small)} 笔 ≥ {count}", [t.id for t in small]) return None async def _check_r008(self, rule, order, customer, ctx) -> RuleHit | None: if order.order_type != "赎回": return None days = _int(rule.threshold, "days", 7) bought = [ t for t in ctx.history if t.product_id == order.product_id and t.transaction_type == "申购" and t.create_time >= ctx.now - timedelta(days=days) ] if bought: first = min(t.create_time for t in bought) return RuleHit(rule, f"同产品 {first:%Y-%m-%d} 申购后 {days} 天内赎回", [t.id for t in bought]) return None async def _check_r009(self, rule, order, customer, ctx) -> RuleHit | None: if order.order_type != "赎回": return None days = _int(rule.threshold, "days", 3) bought = [ t for t in ctx.history if t.product_id == order.product_id and t.transaction_type == "申购" and t.create_time >= ctx.now - timedelta(days=days) ] if bought and order.shares is not None: bought_shares = sum( ((t.shares or Decimal("0")) for t in bought), Decimal("0") ) if order.shares >= bought_shares: return RuleHit(rule, f"同产品近{days}天申购 {bought_shares} 份后全额赎回", [t.id for t in bought]) return None async def _check_r010(self, rule, order, customer, ctx) -> RuleHit | None: if order.order_type != "赎回": return None hours = _int(rule.threshold, "hours", 24) bought = [ t for t in ctx.history if t.transaction_type == "申购" and t.create_time >= ctx.now - timedelta(hours=hours) ] if bought: return RuleHit(rule, f"入账后 {hours} 小时内赎回", [t.id for t in bought]) return None async def _check_r011(self, rule, order, customer, ctx) -> RuleHit | None: days = _int(rule.threshold, "days", 7) amount = _dec(rule.threshold, "amount", Decimal("2000000")) recent = [t for t in ctx.history if t.create_time >= ctx.now - timedelta(days=days)] total = sum((t.amount for t in recent), Decimal("0")) if total >= amount: return RuleHit(rule, f"近{days}天累计申赎 {total} ≥ {amount}", [t.id for t in recent]) return None async def _check_r012(self, rule, order, customer, ctx) -> RuleHit | None: days = _int(rule.threshold, "days", 30) amount = _dec(rule.threshold, "amount", Decimal("5000000")) recent = [t for t in ctx.history if t.create_time >= ctx.now - timedelta(days=days)] total = sum((t.amount for t in recent), Decimal("0")) if total >= amount: return RuleHit(rule, f"近{days}天累计申赎 {total} ≥ {amount}", [t.id for t in recent]) return None async def _check_r013(self, rule, order, customer, ctx) -> RuleHit | None: if order.amount is None: return None days = _int(rule.threshold, "days", 1) total_th = _dec(rule.threshold, "total", Decimal("1000000")) each_lt = _dec(rule.threshold, "each_lt", Decimal("500000")) today = [t for t in ctx.history if t.create_time >= ctx.now - timedelta(days=days)] amounts = [t.amount for t in today] + [order.amount] if ( len(amounts) >= 2 and all(a < each_lt for a in amounts) and sum(amounts, Decimal("0")) >= total_th ): return RuleHit( rule, f"当日 {len(amounts)} 笔合计 {sum(amounts, Decimal('0'))} ≥ {total_th},单笔均 < {each_lt}", [t.id for t in today], ) return None async def _check_r018(self, rule, order, customer, ctx) -> RuleHit | None: if order.order_type != "赎回" or order.amount is None: return None amount = _dec(rule.threshold, "amount", Decimal("100000")) holding = ctx.holdings if ( holding is not None and holding.shares > 0 and order.shares is not None and order.shares >= holding.shares and order.amount >= amount ): return RuleHit(rule, f"赎回 {order.shares} 份清仓且金额 {order.amount} ≥ {amount}", []) return None async def _check_r019(self, rule, order, customer, ctx) -> RuleHit | None: days = _int(rule.threshold, "days", 7) by_product: dict[int, dict[str, bool]] = defaultdict( lambda: {"申购": False, "赎回": False} ) for t in ctx.history: if t.create_time >= ctx.now - timedelta(days=days): by_product[t.product_id][t.transaction_type] = True mutual = [pid for pid, types in by_product.items() if types["申购"] and types["赎回"]] if len(mutual) >= 2: return RuleHit(rule, f"近{days}天 {len(mutual)} 个产品互买互卖", []) return None # ------------------------------------------------------------------ C 类客户 async def _check_r015(self, rule, order, customer, ctx) -> RuleHit | None: if order.amount is None: return None days = _int(rule.threshold, "days", 30) amount = _dec(rule.threshold, "amount", Decimal("500000")) registered_days = (ctx.now - customer.create_time).days if registered_days < days and order.amount >= amount: return RuleHit(rule, f"注册 {registered_days} 天(< {days})且金额 {order.amount} ≥ {amount}", []) return None async def _check_r016(self, rule, order, customer, ctx) -> RuleHit | None: if order.amount is None: return None days = _int(rule.threshold, "days", 90) amount = _dec(rule.threshold, "amount", Decimal("100000")) dormant = ctx.last_tx_time is None or ctx.last_tx_time < ctx.now - timedelta(days=days) if dormant and order.amount >= amount: return RuleHit(rule, f"{days} 天无交易后首笔 {order.amount} ≥ {amount}", []) return None async def _check_r017(self, rule, order, customer, ctx) -> RuleHit | None: if order.amount is None: return None amount = _dec(rule.threshold, "amount", Decimal("100000")) if order.amount >= amount and order.amount % Decimal("10000") == 0: return RuleHit(rule, f"金额 {order.amount} 为整万且 ≥ {amount}", []) return None