Files
group_fqcd_jr/tests/unit/service/test_risk_scan_service.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +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() == []