依据《实现方案-风控追加需求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)。
204 lines
7.8 KiB
Python
204 lines
7.8 KiB
Python
"""risk rules 单测(B1 · RISK-001~005 命中/不命中/边界 + 防御过滤 + 多规则聚合)。"""
|
|
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
|
|
from app.service.risk.rules import RiskThresholds, rule_concentration, run_rules
|
|
|
|
NOW = datetime(2026, 9, 6, 14, 0, 0)
|
|
TH = RiskThresholds() # 默认冻结阈值
|
|
|
|
|
|
def _t(trade_id, amount, at, product="P1", ttype="subscribe", status="confirmed"):
|
|
return {
|
|
"trade_id": trade_id,
|
|
"product_id": product,
|
|
"trade_type": ttype,
|
|
"trade_status": status,
|
|
"amount": Decimal(str(amount)),
|
|
"traded_at": at,
|
|
}
|
|
|
|
|
|
def _ids(hits):
|
|
return sorted(h.rule_id for h in hits)
|
|
|
|
|
|
class TestRISK001LargeAmount:
|
|
def test_hit_at_threshold(self):
|
|
hits = run_rules([_t("T1", 500000, NOW)], TH, NOW)
|
|
hit = next(h for h in hits if h.rule_id == "RISK-001")
|
|
assert hit.alert_type == "large_amount" and hit.risk_score == 70
|
|
assert "T1" in hit.detail
|
|
|
|
def test_miss_below_threshold(self):
|
|
assert run_rules([_t("T1", 499999.99, NOW)], TH, NOW) == [] or all(
|
|
h.rule_id != "RISK-001" for h in run_rules([_t("T1", 499999.99, NOW)], TH, NOW)
|
|
)
|
|
|
|
|
|
class TestRISK002DailyTotal:
|
|
def test_hit_by_accumulation(self):
|
|
hits = run_rules([_t("T1", 300000, NOW - timedelta_min(1)), _t("T2", 200000, NOW)], TH, NOW)
|
|
assert "RISK-002" in _ids(hits)
|
|
|
|
def test_miss_below_total(self):
|
|
hits = run_rules([_t("T1", 249999.99, NOW), _t("T2", 249999.99, NOW)], TH, NOW)
|
|
assert "RISK-002" not in _ids(hits)
|
|
|
|
|
|
def timedelta_min(m):
|
|
from datetime import timedelta
|
|
|
|
return timedelta(minutes=m)
|
|
|
|
|
|
class TestRISK003Freq:
|
|
def test_hit_same_product_3(self):
|
|
trades = [_t(f"T{i}", 10000, NOW - timedelta_min(i)) for i in range(3)]
|
|
hits = run_rules(trades, TH, NOW)
|
|
hit = next(h for h in hits if h.rule_id == "RISK-003")
|
|
assert hit.alert_type == "freq_trade" and hit.risk_score == 50
|
|
|
|
def test_miss_2_trades(self):
|
|
trades = [_t(f"T{i}", 10000, NOW - timedelta_min(i)) for i in range(2)]
|
|
assert "RISK-003" not in _ids(run_rules(trades, TH, NOW))
|
|
|
|
def test_miss_different_products(self):
|
|
trades = [_t(f"T{i}", 10000, NOW - timedelta_min(i), product=f"P{i}") for i in range(3)]
|
|
assert "RISK-003" not in _ids(run_rules(trades, TH, NOW))
|
|
|
|
|
|
class TestRISK004Probe:
|
|
def test_hit_in_window(self):
|
|
trades = [_t(f"T{i}", 450000, NOW - timedelta_min(i)) for i in range(3)] # 0/1/2 分钟前
|
|
hits = run_rules(trades, TH, NOW)
|
|
hit = next(h for h in hits if h.rule_id == "RISK-004")
|
|
assert hit.alert_type == "pattern" and hit.risk_score == 80
|
|
|
|
def test_miss_outside_window(self):
|
|
trades = [
|
|
_t("T0", 450000, NOW - timedelta_min(6)), # 出 5 分钟窗口
|
|
_t("T1", 450000, NOW - timedelta_min(1)),
|
|
_t("T2", 450000, NOW - timedelta_min(2)),
|
|
]
|
|
assert "RISK-004" not in _ids(run_rules(trades, TH, NOW))
|
|
|
|
def test_miss_amount_below_probe(self):
|
|
trades = [_t(f"T{i}", 399999, NOW - timedelta_min(i)) for i in range(3)]
|
|
assert "RISK-004" not in _ids(run_rules(trades, TH, NOW))
|
|
|
|
|
|
class TestRISK005SmallThenLarge:
|
|
def test_hit_with_interleaved_medium(self):
|
|
"""3 笔小额 + 穿插中额 + 首次大额 → 命中(不要求连续)。"""
|
|
trades = [
|
|
_t("T1", 5000, NOW - timedelta_min(10)),
|
|
_t("T2", 30000, NOW - timedelta_min(9)), # 中额穿插,不影响计数
|
|
_t("T3", 5000, NOW - timedelta_min(8)),
|
|
_t("T4", 5000, NOW - timedelta_min(7)),
|
|
_t("T5", 500000, NOW - timedelta_min(1)), # 首次大额
|
|
]
|
|
hits = run_rules(trades, TH, NOW)
|
|
hit = next(h for h in hits if h.rule_id == "RISK-005")
|
|
assert "T5" in hit.detail
|
|
|
|
def test_miss_only_2_small(self):
|
|
trades = [
|
|
_t("T1", 5000, NOW - timedelta_min(5)),
|
|
_t("T2", 5000, NOW - timedelta_min(4)),
|
|
_t("T3", 500000, NOW),
|
|
]
|
|
assert "RISK-005" not in _ids(run_rules(trades, TH, NOW))
|
|
|
|
def test_miss_small_after_large(self):
|
|
"""小额出现在首次大额之后 → 不命中(时间序判定)。"""
|
|
trades = [
|
|
_t("T1", 500000, NOW - timedelta_min(5)),
|
|
_t("T2", 5000, NOW - timedelta_min(4)),
|
|
_t("T3", 5000, NOW - timedelta_min(3)),
|
|
_t("T4", 5000, NOW - timedelta_min(2)),
|
|
_t("T5", 500000, NOW),
|
|
]
|
|
assert "RISK-005" not in _ids(run_rules(trades, TH, NOW))
|
|
|
|
def test_miss_no_small_prefix(self):
|
|
trades = [_t("T1", 500000, NOW)]
|
|
assert "RISK-005" not in _ids(run_rules(trades, TH, NOW))
|
|
|
|
|
|
class TestDefenseAndAggregation:
|
|
def test_filters_pending_and_convert(self):
|
|
trades = [
|
|
_t("T1", 500000, NOW, status="pending"), # 未确认不计
|
|
_t("T2", 500000, NOW, ttype="convert"), # convert 不进事件线
|
|
]
|
|
assert run_rules(trades, TH, NOW) == []
|
|
|
|
def test_empty_trades(self):
|
|
assert run_rules([], TH, NOW) == []
|
|
|
|
def test_multi_rule_aggregation_single_large(self):
|
|
"""单笔 50 万:RISK-001 + RISK-002 同时命中(单事件聚合出单的输入)。"""
|
|
hits = run_rules([_t("T1", 500000, NOW)], TH, NOW)
|
|
assert _ids(hits) == ["RISK-001", "RISK-002"]
|
|
|
|
def test_multi_rule_full_stack(self):
|
|
"""3 小额 + 1 大额(同产品共 4 笔):001+002+003+005 四规则,pattern 80 最高分。"""
|
|
trades = [
|
|
_t("T1", 5000, NOW - timedelta_min(4)),
|
|
_t("T2", 5000, NOW - timedelta_min(3)),
|
|
_t("T3", 5000, NOW - timedelta_min(2)),
|
|
_t("T4", 500000, NOW),
|
|
]
|
|
hits = run_rules(trades, TH, NOW)
|
|
assert _ids(hits) == ["RISK-001", "RISK-002", "RISK-003", "RISK-005"]
|
|
assert max(h.risk_score for h in hits) == 80 # pattern
|
|
|
|
|
|
class TestRISK006Concentration:
|
|
"""FR-8 RISK-006 持仓集中度(纯函数;输入为 core_ro.concentration_profile 画像)。
|
|
|
|
注意:run_rules 不含 RISK-006(输入域不同),由引擎单独调用后并入 hits。
|
|
"""
|
|
|
|
@staticmethod
|
|
def _profile(r45, total, truncated=False):
|
|
return {
|
|
"r45_value": Decimal(str(r45)),
|
|
"total_value": Decimal(str(total)),
|
|
"ratio": float(Decimal(str(r45)) / Decimal(str(total))) if total else 0.0,
|
|
"holdings_truncated": truncated,
|
|
"rows": [],
|
|
}
|
|
|
|
def test_hit_90_percent_r5(self):
|
|
hit = rule_concentration(self._profile(900000, 1000000), TH)
|
|
assert hit is not None
|
|
assert hit.rule_id == "RISK-006"
|
|
assert hit.alert_type == "pattern" and hit.risk_score == 60
|
|
assert hit.alert_subtype == "concentration"
|
|
|
|
def test_miss_when_all_r1(self):
|
|
"""全是 R1 持仓(R4+R5 为 0)→ 不触发。"""
|
|
assert rule_concentration(self._profile(0, 1000000), TH) is None
|
|
|
|
def test_miss_on_empty_holdings(self):
|
|
"""空仓(total=0)不触发——无持仓无从谈集中度。"""
|
|
assert rule_concentration(self._profile(0, 0), TH) is None
|
|
|
|
def test_truncated_counts_as_hit(self):
|
|
"""明细被截断时占比仅 10%,仍按保守口径视同达标(PRD FR-8 截断防护)。"""
|
|
hit = rule_concentration(self._profile(100000, 1000000, truncated=True), TH)
|
|
assert hit is not None
|
|
assert "保守" in hit.detail
|
|
|
|
def test_threshold_boundary(self):
|
|
"""阈值边界:79.9% 不触发,80%(等于阈值)触发。"""
|
|
assert rule_concentration(self._profile(799, 1000), TH) is None
|
|
assert rule_concentration(self._profile(800, 1000), TH) is not None
|
|
|
|
def test_detail_carries_values(self):
|
|
hit = rule_concentration(self._profile(900000, 1000000), TH)
|
|
assert "900000" in hit.detail and "1000000" in hit.detail
|