Files
Mutual_Fund/service/risk/engine.py
T

393 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""风控规则引擎(纯代码,不调用 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