Files
group_fqcd_jr/tests/unit/service/test_risk_scan_service.py
T
lzf_0626 ff681df143 落地第十五条豁免额度校验;登记三处业务裁定
一、第十五条豁免规则(docs/25 第七节 #2,业务裁定:实现)

政策原文:C3→R4 签署风险揭示书后**可买**,但单只 R4 持仓不超过总资产 20%;
C4→R5 同理,上限 10%。越级购买本身不是违规,超出额度才是 —— 原先扫描侧只看
"留痕是否齐全",于是"签了字但买超额度"这种明确违规没有预警;研判侧也把豁免的
前提条件(留痕齐全)当成了结论,直接判"疑似误报"。

- 扫描侧:新增 EXEMPTION_LIMITS 与 RiskRuleEngine._exemption_state,核算
  "单只持仓 / 总资产"并写进证据快照;触发条件改为
  gap > 0 and (missing_trace or 超出额度)。
- 研判侧:_assess_rw007 先判额度再判留痕。超限 → 证据支持风险;留痕齐全且在额度
  内 → 疑似误报;留痕齐全但快照缺总资产/持仓 → 继续复核。

数据前提(tools/probe_exemption_data.py,证据见 docs/evidence/exemption-data-probe.json):
库内 fin_customer_profile 仅 1 行且 total_asset = 0.00、fin_holding 0 行、无任何申购
交易 —— 这条规则当前不会被触发,与 behavior_score 同源(画像与持仓由本项目之外的
流程写入)。因此刻意不把"算不出来"当成"超限":拿 0 去算会让每一笔 C3→R4 都变成违规,
豁免规则反倒成了误报源。上游把数据写入后无需再改代码即可生效。

二、三处业务裁定(此前挂在"待裁定")

- 模型网关 chat + tools 入口:本轮不补,按基座能力缺口记录。它要贯穿
  ModelGateway → … → BaseAgent 整条链路,属公共契约变更,演示联调期影响面大于收益。
- exclude(关闭误报)是否必须先"调查中":保持现状,不加门禁。
- 政策冲突:以第十四条 C ≥ R 为准;客服侧 check_suitability 复核后确认本来就按
  C ≥ R 实现,无需改动。

三、其他

- 新增 tests/unit/service/test_risk_judgement_rw007.py(6 例)与扫描侧 4 例。
- 风控文档 03/05 同步 RW-007 的豁免额度条件与研判口径。
2026-09-11 14:24:45 +08:00

656 lines
20 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.
from datetime import date, datetime
from decimal import Decimal
import pytest
from app.core.contracts import RequestContext
from app.model.fund import (
FundCapitalFlow,
FundCustomerProfile,
FundProduct,
FundRiskAlert,
FundTransaction,
)
from app.model.risk import RiskLoginRecord, RiskUser, RiskWorkOrder
from app.service.risk_scan_service import (
RiskRuleEngine,
RiskScanService,
_new_alert_id,
)
class ScalarRows:
def __init__(self, rows):
self.rows = rows
def all(self):
return list(self.rows)
class FakeSession:
def __init__(self, *, scalar_values=None, rows=None, get_values=None):
self.scalar_values = list(scalar_values or [])
self.rows = list(rows or [])
self.get_values = list(get_values or [])
self.statements = []
self.committed = False
self.rolled_back = False
async def scalar(self, statement):
self.statements.append(statement)
return self.scalar_values.pop(0) if self.scalar_values else None
async def scalars(self, statement):
self.statements.append(statement)
return ScalarRows(self.rows)
async def get(self, _model, _identifier):
return self.get_values.pop(0) if self.get_values else None
def add(self, _value):
return None
async def flush(self):
return None
async def commit(self):
self.committed = True
async def rollback(self):
self.rolled_back = True
def begin_nested(self):
return _NestedTransaction()
class _NestedTransaction:
async def __aenter__(self):
return self
async def __aexit__(self, _exc_type, _exc, _traceback):
return False
def transaction() -> FundTransaction:
return FundTransaction(
id=100,
transaction_no="TX-100",
order_id=200,
work_order_id=300,
customer_id=1,
account_id=2,
product_id=3,
order_side="sell",
transaction_type="赎回",
executed_price=Decimal("1.000000"),
nav=Decimal("1.000000"),
executed_quantity=Decimal("750000.0000"),
shares=Decimal("750000.0000"),
gross_amount=Decimal("750000.00"),
amount=Decimal("750000.00"),
fee_rate_snapshot=Decimal("0.000000"),
fee_amount=Decimal("0.00"),
net_amount=Decimal("750000.00"),
quote_at=datetime(2026, 9, 10, 8, 0),
quote_source="test",
executed_at=datetime(2026, 9, 10, 8, 0),
confirmed_at=datetime(2026, 9, 10, 8, 0),
auto_confirmed=0,
created_at=datetime(2026, 9, 10, 8, 0),
)
def capital_flow() -> FundCapitalFlow:
return FundCapitalFlow(
id=400,
flow_no="FLOW-400",
customer_id=1,
flow_type="入金",
amount=Decimal("800000.00"),
status="成功",
settled_at=datetime(2026, 9, 8, 8, 0),
occurred_at=datetime(2026, 9, 8, 8, 0),
source_type="银行转入",
match_status="已匹配",
created_at=datetime(2026, 9, 8, 8, 0),
updated_at=datetime(2026, 9, 8, 8, 0),
)
def risk_user(investor_type: str = "C2") -> RiskUser:
return RiskUser(
id=1,
user_no="CUST-001",
username="customer001",
user_type="CUSTOMER",
investor_type=investor_type,
is_professional_investor=0,
professional_investor_status="未申请",
fund_account_status="已开户",
status="正常",
created_at=datetime(2026, 1, 1),
updated_at=datetime(2026, 9, 10),
)
def product(
risk_level: str = "R5",
*,
disclosure: int = 1,
confirmation: int = 1,
recording: int = 1,
) -> FundProduct:
return FundProduct(
id=3,
product_code="P-001",
product_name="测试产品",
exchange_code="159999",
product_category="股票型",
risk_level=risk_level,
currency="CNY",
lot_size=Decimal("100.0000"),
price_tick=Decimal("0.000100"),
min_amount=Decimal("100.00"),
single_investor_max_holding_ratio=Decimal("100.0000"),
risk_disclosure_required=disclosure,
second_confirmation_required=confirmation,
recording_required=recording,
status="在售",
created_at=datetime(2026, 1, 1),
updated_at=datetime(2026, 9, 10),
)
def work_order(
*,
disclosure_at: datetime | None = None,
confirmation_at: datetime | None = None,
recording_reference: str | None = None,
channel: str | None = "手机应用",
) -> RiskWorkOrder:
return RiskWorkOrder(
id=300,
work_order_no="WO-300",
customer_id=1,
product_id=3,
channel=channel,
risk_disclosure_ack_at=disclosure_at,
second_confirmation_at=confirmation_at,
recording_reference=recording_reference,
status="已提交",
created_at=datetime(2026, 9, 1),
updated_at=datetime(2026, 9, 10),
)
def customer_profile(birth_date: date = date(1954, 1, 1)) -> FundCustomerProfile:
return FundCustomerProfile(
customer_id=1,
trade_account="ACC-001",
real_name="张三",
birth_date=birth_date,
investor_type="C2",
total_asset=Decimal("1000000.00"),
behavior_score=20,
updated_at=datetime(2026, 9, 10),
)
@pytest.mark.asyncio
async def test_fast_in_fast_out_builds_fact_based_alert() -> None:
session = FakeSession(
rows=[transaction()],
scalar_values=[capital_flow(), None],
)
alerts = await RiskRuleEngine(session)._fast_in_fast_out()
assert len(alerts) == 1
assert alerts[0].trigger_rule_codes == ["RW-003"]
assert alerts[0].alert_level == "高"
assert "800000" in alerts[0].evidence_summary
assert alerts[0].evidence_snapshot["ratio"] == "0.9375"
@pytest.mark.asyncio
async def test_duplicate_rule_hit_is_suppressed() -> None:
session = FakeSession(
rows=[transaction()],
scalar_values=[capital_flow(), 1],
)
alerts = await RiskRuleEngine(session)._fast_in_fast_out()
assert alerts == []
def test_same_transaction_alerts_are_merged() -> None:
first = FundRiskAlert(
id=1,
alert_no="AL-1",
customer_id=1,
related_transaction_id=100,
alert_type="大额快进快出",
alert_level="高",
trigger_rule_codes=["RW-003"],
evidence_summary="摘要一",
evidence_snapshot={"product_id": 3},
priority_score=98,
event_status="正在发生",
status="待处理",
ack_status="未确认",
is_escalated=0,
created_at=datetime(2026, 9, 10),
updated_at=datetime(2026, 9, 10),
)
second = FundRiskAlert(
id=2,
alert_no="AL-2",
customer_id=1,
related_transaction_id=100,
alert_type="老年客户异常大额赎回",
alert_level="中",
trigger_rule_codes=["RW-012"],
evidence_summary="摘要二",
evidence_snapshot={"age": 72},
priority_score=96,
event_status="正在发生",
status="待处理",
ack_status="未确认",
is_escalated=0,
created_at=datetime(2026, 9, 10),
updated_at=datetime(2026, 9, 10),
)
merged = RiskRuleEngine._merge_same_transaction_alerts([first, second])
assert len(merged) == 1
assert merged[0].trigger_rule_codes == ["RW-003", "RW-012"]
assert merged[0].alert_level == "高"
assert merged[0].evidence_summary == "摘要一;摘要二"
assert len(merged[0].evidence_snapshot["merged_alerts"]) == 2
@pytest.mark.asyncio
async def test_scan_service_uses_transaction_boundary() -> None:
class FakeRuleEngine:
async def refresh_alerts(self):
return [
FundRiskAlert(
id=1,
alert_no="AL-1",
customer_id=1,
alert_type="大额快进快出",
alert_level="高",
trigger_rule_codes=["RW-003"],
evidence_summary="摘要",
evidence_snapshot={},
priority_score=98,
event_status="正在发生",
status="待处理",
ack_status="未确认",
is_escalated=0,
created_at=datetime(2026, 9, 10),
updated_at=datetime(2026, 9, 10),
)
]
session = FakeSession()
context = RequestContext(
user_id="990000002",
trace_id="scan-trace",
permissions=("risk:alert:scan",),
data_scope="all",
)
result = await RiskScanService(
session,
rule_engine=FakeRuleEngine(),
notification_enabled=False,
).scan(context)
assert result == {
"message": "规则扫描完成",
"created_count": 1,
"high_risk_count": 1,
"notification_count": 0,
}
assert session.committed is True
assert session.rolled_back is False
def test_alert_id_is_nonzero_positive() -> None:
alert_id = _new_alert_id()
assert 0 < alert_id < 2**63
@pytest.mark.asyncio
async def test_scan_creates_high_risk_notifications_in_savepoint() -> None:
class FakeRuleEngine:
async def refresh_alerts(self):
return [
FundRiskAlert(
id=1,
alert_no="AL-1",
customer_id=1,
alert_type="大额快进快出",
alert_level="高",
trigger_rule_codes=["RW-003"],
evidence_summary="摘要",
evidence_snapshot={},
priority_score=98,
event_status="正在发生",
status="待处理",
ack_status="未确认",
handler_id=990000002,
is_escalated=0,
created_at=datetime(2026, 9, 10),
updated_at=datetime(2026, 9, 10),
)
]
class FakeNotificationService:
def __init__(self):
self.calls = []
def create_in_app(self, alert, **kwargs):
self.calls.append(("in_app", alert.alert_no, kwargs))
def create_mail_record(self, alert, **kwargs):
self.calls.append(("mail", alert.alert_no, kwargs))
notifier = FakeNotificationService()
context_value = RequestContext(
user_id="990000002",
trace_id="scan-trace",
permissions=("risk:alert:scan",),
data_scope="all",
)
result = await RiskScanService(
FakeSession(),
rule_engine=FakeRuleEngine(),
notification_service=notifier,
notification_email="risk@example.com",
).scan(context_value)
assert result["notification_count"] == 2
assert [call[0] for call in notifier.calls] == ["in_app", "mail"]
@pytest.mark.asyncio
async def test_notification_failure_does_not_rollback_scan() -> None:
class FakeRuleEngine:
async def refresh_alerts(self):
return [
FundRiskAlert(
id=1,
alert_no="AL-1",
customer_id=1,
alert_type="大额快进快出",
alert_level="高",
trigger_rule_codes=["RW-003"],
evidence_summary="摘要",
evidence_snapshot={},
priority_score=98,
event_status="正在发生",
status="待处理",
ack_status="未确认",
is_escalated=0,
created_at=datetime(2026, 9, 10),
updated_at=datetime(2026, 9, 10),
)
]
class FailingNotificationService:
def create_in_app(self, *_args, **_kwargs):
raise RuntimeError("notification failed")
session = FakeSession()
context_value = RequestContext(
user_id="990000002",
trace_id="scan-trace",
permissions=("risk:alert:scan",),
data_scope="all",
)
result = await RiskScanService(
session,
rule_engine=FakeRuleEngine(),
notification_service=FailingNotificationService(),
).scan(context_value)
assert result["created_count"] == 1
assert result["notification_count"] == 0
assert session.committed is True
assert session.rolled_back is False
@pytest.mark.asyncio
async def test_suitability_mismatch_high_and_medium_boundaries() -> None:
session = FakeSession(
rows=[transaction()],
get_values=[risk_user("C2"), product("R5"), work_order()],
scalar_values=[None],
)
alerts = await RiskRuleEngine(session)._suitability_mismatch()
assert len(alerts) == 1
assert alerts[0].alert_level == "高"
session = FakeSession(
rows=[transaction()],
get_values=[risk_user("C4"), product("R5"), work_order()],
scalar_values=[None],
)
alerts = await RiskRuleEngine(session)._suitability_mismatch()
assert len(alerts) == 1
assert alerts[0].alert_level == "中"
@pytest.mark.asyncio
async def test_suitability_mismatch_rejects_complete_trace_and_matching_level() -> None:
session = FakeSession(
rows=[transaction()],
get_values=[
risk_user("C2"),
product("R5"),
work_order(
disclosure_at=datetime(2026, 9, 1),
confirmation_at=datetime(2026, 9, 1),
recording_reference="REC-1",
),
],
scalar_values=[None],
)
assert await RiskRuleEngine(session)._suitability_mismatch() == []
session = FakeSession(
rows=[transaction()],
get_values=[risk_user("C5"), product("R5"), work_order()],
scalar_values=[None],
)
assert await RiskRuleEngine(session)._suitability_mismatch() == []
def _complete_trace() -> RiskWorkOrder:
return work_order(
disclosure_at=datetime(2026, 9, 1),
confirmation_at=datetime(2026, 9, 1),
recording_reference="REC-1",
)
@pytest.mark.asyncio
async def test_suitability_exemption_limit_breach_raises_alert() -> None:
"""C3→R4 属第十五条允许的越级购买,但单只持仓超过总资产 20% 仍要预警。
原先扫描侧只看"留痕是否齐全":签了揭示书、留痕齐全就直接不报 —— 于是"签了字
但买超额度"这种明确违规反而没有预警。
"""
session = FakeSession(
rows=[transaction()],
get_values=[risk_user("C3"), product("R4"), _complete_trace()],
scalar_values=[None, Decimal("100000.00"), Decimal("30000.00")],
)
alerts = await RiskRuleEngine(session)._suitability_mismatch()
assert len(alerts) == 1
assert alerts[0].alert_level == "中"
assert "20%" in alerts[0].evidence_summary
assert alerts[0].evidence_snapshot["exemption_ratio"] == "0.3"
assert alerts[0].evidence_snapshot["exemption_limit"] == "0.20"
@pytest.mark.asyncio
async def test_suitability_exemption_within_limit_is_not_an_alert() -> None:
"""留痕齐全且占比未超额度 → 豁免成立,不该产生预警。"""
session = FakeSession(
rows=[transaction()],
get_values=[risk_user("C3"), product("R4"), _complete_trace()],
scalar_values=[None, Decimal("100000.00"), Decimal("10000.00")],
)
assert await RiskRuleEngine(session)._suitability_mismatch() == []
@pytest.mark.asyncio
async def test_suitability_exemption_missing_asset_data_does_not_create_alerts() -> None:
"""总资产为 0(上游未落地)时不能算成"超限"。
本项目的画像与持仓由本项目之外的流程写入,拿 0 去算会让每一笔 C3→R4 都变成
违规 —— 豁免规则就成了新的误报源。缺失只如实记录,交给研判侧提示人工确认。
"""
session = FakeSession(
rows=[transaction()],
get_values=[risk_user("C3"), product("R4"), _complete_trace()],
scalar_values=[None, Decimal("0"), Decimal("10000.00")],
)
assert await RiskRuleEngine(session)._suitability_mismatch() == []
@pytest.mark.asyncio
async def test_suitability_missing_trace_still_alerts_without_exemption_data() -> None:
"""留痕不完整这条老语义不能被新增的额度逻辑冲掉。"""
session = FakeSession(
rows=[transaction()],
get_values=[risk_user("C3"), product("R4"), work_order()],
scalar_values=[None, Decimal("0"), None],
)
alerts = await RiskRuleEngine(session)._suitability_mismatch()
assert len(alerts) == 1
assert "交易留痕不完整" in alerts[0].evidence_summary
assert alerts[0].evidence_snapshot["exemption_data_missing"] is True
@pytest.mark.asyncio
async def test_elderly_redemption_requires_age_amount_average_and_uncommon_device() -> None:
login = RiskLoginRecord(
id=500,
user_id=1,
login_at=datetime(2026, 9, 9),
login_result="成功",
device_id="DEVICE-NEW",
is_common_device=0,
created_at=datetime(2026, 9, 9),
)
session = FakeSession(
rows=[transaction()],
get_values=[customer_profile()],
scalar_values=[Decimal("100000.00"), login, None],
)
alerts = await RiskRuleEngine(session)._elderly_redemption()
assert len(alerts) == 1
assert alerts[0].trigger_rule_codes == ["RW-012"]
session = FakeSession(
rows=[transaction()],
get_values=[customer_profile()],
scalar_values=[Decimal("250000.00"), login, None],
)
boundary_alerts = await RiskRuleEngine(session)._elderly_redemption()
assert len(boundary_alerts) == 1
session = FakeSession(
rows=[transaction()],
get_values=[customer_profile()],
scalar_values=[Decimal("260000.00")],
)
assert await RiskRuleEngine(session)._elderly_redemption() == []
@pytest.mark.asyncio
async def test_elderly_redemption_rejects_common_device() -> None:
login = RiskLoginRecord(
id=500,
user_id=1,
login_at=datetime(2026, 9, 9),
login_result="成功",
device_id="DEVICE-COMMON",
is_common_device=1,
created_at=datetime(2026, 9, 9),
)
session = FakeSession(
rows=[transaction()],
get_values=[customer_profile()],
scalar_values=[Decimal("100000.00"), login],
)
assert await RiskRuleEngine(session)._elderly_redemption() == []
@pytest.mark.asyncio
async def test_night_small_trade_boundaries() -> None:
# confirmed_at 在库里是 **UTC**(见 app/infrastructure/db.py:15-21),而"凌晨"是
# **北京时间**概念:北京 00:00–06:00 对应 UTC 的前一日 16:00–22:00。
# 原先这里直接拿 UTC 小时当北京时间(写 0 点 / 6 点),等于在测"北京 08:00 / 14:00",
# 规则修正后这些用例的语义也一并修正(docs/25 P1 #6)。
night = transaction()
night.confirmed_at = datetime(2026, 9, 9, 16, 0) # 北京 09-10 00:00,凌晨起点(含)
night.amount = Decimal("10000.00")
session = FakeSession(rows=[night], scalar_values=[None])
alerts = await RiskRuleEngine(session)._low_risk_night_trade()
assert len(alerts) == 1
regular = transaction()
regular.confirmed_at = datetime(2026, 9, 9, 22, 0) # 北京 09-10 06:00,凌晨终点(不含)
session = FakeSession(rows=[regular], scalar_values=[None])
assert await RiskRuleEngine(session)._low_risk_night_trade() == []
too_large = transaction()
too_large.confirmed_at = datetime(2026, 9, 9, 18, 0) # 北京 09-10 02:00,在凌晨但金额超限
too_large.amount = Decimal("10000.01")
session = FakeSession(rows=[too_large], scalar_values=[None])
assert await RiskRuleEngine(session)._low_risk_night_trade() == []
@pytest.mark.asyncio
async def test_auto_investment_false_positive_uses_valid_work_order() -> None:
tx = transaction()
tx.work_order_id = 300
session = FakeSession(
rows=[tx],
get_values=[work_order(channel="自动定投")],
scalar_values=[None],
)
alerts = await RiskRuleEngine(session)._auto_investment_false_positive()
assert len(alerts) == 1
assert alerts[0].trigger_rule_codes == ["RW-018"]
session = FakeSession(
rows=[tx],
get_values=[work_order(channel="定投")],
scalar_values=[None],
)
assert len(await RiskRuleEngine(session)._auto_investment_false_positive()) == 1
session = FakeSession(
rows=[tx],
get_values=[work_order(channel="手机应用")],
scalar_values=[None],
)
assert await RiskRuleEngine(session)._auto_investment_false_positive() == []