578 lines
17 KiB
Python
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() == []
|