Files
Mutual_Fund/service/risk/engine.py
T

393 lines
16 KiB
Python
Raw Normal View History

"""风控规则引擎(纯代码,不调用 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