390 lines
16 KiB
Python
390 lines
16 KiB
Python
"""风控规则引擎(纯代码,不调用 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}
|
||
_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
|