依据《实现方案-风控追加需求v1.1-C4C6.md》§2;不改表结构(alert_type/status 复用 payload 承载,audit_log.event_type 为 VARCHAR 可直接扩)。 1. settings.py + .env.example:一次性加齐风控追加 v1.1 共 11 项配置(C4~C6 共用)。 2. core_ro.concentration_profile(customer_id, limit=500):一次 SQL 取明细 (LIMIT limit+1 探测截断)+ Python 端按 min_risk_code in (R4,R5) 聚合; 收口挂账 #1(PRD 字面为 list_holdings,改聚合封装,docstring 注明偏离)。 3. rules.py:RULE_SCORES/RULE_ALERT_TYPES 加 RISK-006=60/pattern;RuleHit 加 alert_subtype;RiskThresholds 加 concentration_threshold 且 from_settings 必须补读(评审 P1-2:漏读会让 conftest monkeypatch 失效打穿现有断言); 新增纯函数 rule_concentration——空仓不触发、截断视同达标(保守告警)、 阈值边界 79.9% 不触发 / 80% 触发、R4+R5 为 0 不触发。 4. engine.process_trade_event:run_rules 之后、record_trade_alerts 之前并入 集中度命中(不动 run_rules 签名);命中后 L3 打 high_risk_concentration 标签 + 写 risk_concentration 审计(金额只落合计与前 5 条摘要)。 5. risk_repository:find_pending_event_alert 改候选 LIMIT 50 + Python 过滤掉 payload.alert_subtype 含 agent_behavior 的单(评审 P0-1:代理人维度行为链单 不得充当客户维度事件单的聚合锚点);append_alert_event 加 extra_subtypes 合并进 payload.alert_subtype(不传时行为与原先一致,向后兼容)。 6. alert_service:subtypes 集合维护(空集不注入 payload,评审 P2-3); 追加时 alert_type 按「老单规则 ∪ 本批规则」重算(评审 P1-3,修掉既有 large_amount 单被本批仅 RISK-006(60) 翻转为 pattern 的缺陷); _publish_alert 加 notify_role/extra 可选参数(C5/C6 复用)。 7. 对话线:chat_tools.customer_context 加 profile(concentration_ratio/ r45_value/total_value/holdings_truncated),tool_service.summarize 加 「高风险持仓占比 X%(仅供参考)」;不新增意图词。 8. 02-redis-keys.md 增补 alert_subtype / escalation_level 附加推送字段。 测试:conftest 加 autouse _disable_concentration_rule(阈值推 1.01 做回归隔离, 现有用例断言零改动);test_risk_rules 加 RISK-006 纯函数 6 例;新建 tests/test_concentration_c4.py 11 例(与 RISK-001 同单聚合、score max=70、 L3 tag、risk_concentration 审计、仅集中度也出单、subtype 合并、P0-1 回归、 alert_type 不翻转、对话线 ratio)。全量 453 绿(436 + 17)。
200 lines
8.0 KiB
Python
200 lines
8.0 KiB
Python
"""风控事件引擎(B4 · 架构 §3.1 ④ / PRD FR-3)。
|
||
|
||
编排(网关 B5 在 core_trade 落库后**同步调用**,不用消息队列):
|
||
当日流水上下文(core_ro)→ RISK-001~005 纯函数 → AML 姓名匹配 → 预警落库
|
||
(alert_service:聚合/去重/审计/推送)→ L3 upsert(profile_l3)。
|
||
审计 pass(未命中)与命中审计均由 alert_service 完成,本层不重复落审计。
|
||
|
||
RISK-004 窗口以 trade["traded_at"] 为事件时点(非墙钟 now):rebuild_alerts
|
||
幂等重放可复现窗口判定,演示脚本不受执行时刻影响。
|
||
|
||
客户事件钩子 on_customer_created/on_customer_updated 为 FR-5 预留(本期 no-op,
|
||
模拟环境无开户流程)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime, timedelta
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
|
||
from app.repository.core_ro import CoreReadOnlyRepository
|
||
from app.repository.risk_repository import RiskRepository
|
||
from app.service.risk.alert_service import record_aml_alert, record_trade_alerts
|
||
from app.service.risk.aml_service import match_customer
|
||
from app.service.risk.profile_l3 import upsert_profile_l3
|
||
from app.service.risk.rules import RiskThresholds, rule_concentration, run_rules
|
||
from app.utils.trace import current_trace, ensure_trace, new_trace
|
||
|
||
|
||
def _as_datetime(value: Any) -> datetime:
|
||
if isinstance(value, datetime):
|
||
return value
|
||
if isinstance(value, str):
|
||
return datetime.fromisoformat(value)
|
||
raise TypeError(f"traded_at must be datetime/str, got {type(value)!r}")
|
||
|
||
|
||
def _normalize_trades(trades: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
"""驱动差异防御:sqlite text 查询返回 str 时间,统一转 datetime(MySQL 驱动本就返回 datetime)。"""
|
||
for t in trades:
|
||
if isinstance(t.get("traded_at"), str):
|
||
t["traded_at"] = datetime.fromisoformat(t["traded_at"])
|
||
return trades
|
||
|
||
|
||
def _build_customer_context(
|
||
core: CoreReadOnlyRepository, customer_id: str, day_start: datetime
|
||
) -> dict[str, Any]:
|
||
"""预警 payload 客户上下文(PRD FR-4:L0 事实 + 近 30 天交易统计;B4 评审 P1-1 补组装)。
|
||
|
||
display_name 为脱敏展示名口径,可直存 payload;core_cash_flow 上下文一期
|
||
未接(core_ro 无对应查询,挂账见开发计划 B4 行)。
|
||
"""
|
||
l0 = core.get_customer_l0(customer_id) or {}
|
||
month_ago = day_start - timedelta(days=30)
|
||
day_end = day_start + timedelta(days=1) # 近 30 天含当日(本笔在内)
|
||
trades_30d = core.list_trades_range(customer_id, month_ago, day_end)
|
||
total = sum((Decimal(str(t["amount"])) for t in trades_30d), Decimal(0))
|
||
return {
|
||
"l0": {
|
||
k: l0[k]
|
||
for k in ("customer_id", "display_name", "age", "risk_code")
|
||
if l0.get(k) is not None
|
||
},
|
||
"trades_30d": {"count": len(trades_30d), "total_amount": str(total)},
|
||
}
|
||
|
||
|
||
def _audit_concentration(
|
||
repo: RiskRepository,
|
||
trade: dict[str, Any],
|
||
profile: dict[str, Any],
|
||
hit: Any,
|
||
alert: dict[str, Any] | None,
|
||
) -> None:
|
||
"""RISK-006 专属审计(event_type='risk_concentration')。
|
||
|
||
审计表只 INSERT(红线);金额按 DESENS-005 口径只落**合计与前 5 条摘要**,
|
||
不把全量持仓明细写进审计(明细已在 payload 侧由事件承载)。
|
||
"""
|
||
rows = profile.get("rows") or []
|
||
top_holdings = [
|
||
{
|
||
"market_value": str(r.get("market_value")),
|
||
"min_risk_code": r.get("min_risk_code"),
|
||
}
|
||
for r in rows[:5]
|
||
]
|
||
repo.insert_audit_log(
|
||
{
|
||
"trace_id": current_trace() or new_trace(),
|
||
"event_type": "risk_concentration",
|
||
"agent_type": "risk",
|
||
"actor_id": "SYSTEM",
|
||
"customer_id": trade["customer_id"],
|
||
"rule_id": hit.rule_id,
|
||
"input_summary": {
|
||
"trade_id": trade.get("trade_id"),
|
||
"r45_value": str(profile.get("r45_value")),
|
||
"total_value": str(profile.get("total_value")),
|
||
"ratio": round(float(profile.get("ratio") or 0), 4),
|
||
"holdings_truncated": bool(profile.get("holdings_truncated")),
|
||
"top_holdings": top_holdings,
|
||
},
|
||
"decision": "alert_created" if alert else "alert_appended",
|
||
"risk_score": hit.risk_score,
|
||
"handler_id": None,
|
||
"handler_result": None,
|
||
"handler_comment": None,
|
||
}
|
||
)
|
||
|
||
|
||
def process_trade_event(
|
||
trade: dict[str, Any],
|
||
core_ro: CoreReadOnlyRepository | None = None,
|
||
risk_repo: RiskRepository | None = None,
|
||
thresholds: RiskThresholds | None = None,
|
||
) -> dict[str, Any]:
|
||
"""处理一笔已落库交易(PRD FR-1 ②③b 之后)。
|
||
|
||
返回 {"triggered_rules": [...], "alert_ids": [...], "aml_hit": bool},
|
||
网关据此拼装响应(FR-1 ⑤:blocked=false + trade_id + 触发规则列表)。
|
||
"""
|
||
core = core_ro or CoreReadOnlyRepository()
|
||
repo = risk_repo or RiskRepository()
|
||
th = thresholds or RiskThresholds.from_settings()
|
||
ensure_trace() # 脚本/重放入口兜底归因(中间件场景保留现有 trace,评审 P3-8)
|
||
|
||
event_at = _as_datetime(trade["traded_at"])
|
||
day_start = event_at.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
day_end = day_start + timedelta(days=1)
|
||
trades = _normalize_trades(
|
||
core.list_trades_range(trade["customer_id"], day_start, day_end)
|
||
)
|
||
|
||
result: dict[str, Any] = {"triggered_rules": [], "alert_ids": [], "aml_hit": False}
|
||
|
||
hits = run_rules(trades, th, now=event_at)
|
||
|
||
# FR-8 RISK-006 持仓集中度:输入是持仓画像而非流水,故不并入 run_rules
|
||
# (实现方案 §2.3/§2.4:避免改动现有 13 处 run_rules 调用与断言)。
|
||
# 命中后并入 hits,走既有 record_trade_alerts 聚合/审计/推送,不新增出单路径。
|
||
concentration_profile = core.concentration_profile(trade["customer_id"])
|
||
conc = rule_concentration(concentration_profile, th)
|
||
if conc:
|
||
hits = [*hits, conc]
|
||
|
||
if hits:
|
||
alert = record_trade_alerts(
|
||
trade,
|
||
hits,
|
||
risk_repo=repo,
|
||
customer_context=_build_customer_context(core, trade["customer_id"], day_start),
|
||
)
|
||
result["triggered_rules"] = sorted({h.rule_id for h in hits})
|
||
if alert:
|
||
result["alert_ids"].append(alert["alert_id"])
|
||
best = max(hits, key=lambda h: h.risk_score)
|
||
upsert_profile_l3(
|
||
trade["customer_id"],
|
||
best.alert_type,
|
||
# FR-8:命中集中度即给 L3 打标签(后续画像/台账可筛高风险集中度客户)
|
||
monitor_tags=["high_risk_concentration"] if conc else None,
|
||
last_alert_id=alert["alert_id"] if alert else None,
|
||
risk_repo=repo,
|
||
)
|
||
if conc:
|
||
_audit_concentration(repo, trade, concentration_profile, conc, alert)
|
||
else:
|
||
# 未命中分支:pass 审计由 alert_service 统一落库(架构 §3.1 ④)
|
||
record_trade_alerts(trade, [], risk_repo=repo)
|
||
|
||
aml_hits = match_customer(trade["customer_id"], core_ro=core, risk_repo=repo)
|
||
if aml_hits:
|
||
result["aml_hit"] = True
|
||
alert = record_aml_alert(
|
||
trade["customer_id"],
|
||
{
|
||
"trigger": "trade",
|
||
"trade_id": trade.get("trade_id"),
|
||
"product_id": trade.get("product_id"), # 评审 P3-6:payload/审计透传
|
||
"matches": aml_hits,
|
||
},
|
||
risk_repo=repo,
|
||
)
|
||
result["alert_ids"].append(alert["alert_id"])
|
||
upsert_profile_l3(
|
||
trade["customer_id"], "aml", last_alert_id=alert["alert_id"], risk_repo=repo
|
||
)
|
||
|
||
return result
|
||
|
||
|
||
def on_customer_created(customer_id: str) -> None:
|
||
"""AML 开户触发预留(本期 no-op;模拟环境无开户流程,PRD FR-5)。"""
|
||
|
||
def on_customer_updated(customer_id: str) -> None:
|
||
"""客户信息变更触发预留(本期 no-op;PRD FR-5)。"""
|