Files
group_fqcd_jr/tests/unit/service/test_risk_scan_service.py
T

578 lines
17 KiB
Python

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() == []
@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:
night = transaction()
night.confirmed_at = datetime(2026, 9, 10, 0, 0)
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, 10, 6, 0)
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, 10, 2, 0)
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() == []