Files
group_fqcd_jr/tests/unit/service/test_risk_scan_service.py
T
lzf_0626 b3da1b65ed fix(risk): 修正风控的时区缺陷——凌晨规则与日报日界
docs/25 P1 #6。根因是"库内存 UTC naive"这个约定在**业务判断层**没被遵守,
而展示层其实已经是对的(risk_daily_report_service.py:418 按配置时区转换)。

1. 新增 app/core/timeutil.py 作为统一换算入口:约定库内 UTC naive,提供
   local_hour / local_date / local_day_bounds(后两者用于查库时必须返回 UTC naive,
   否则区间与库内值整体错开 8 小时),带时区的入参按其自身时区解释。
2. risk_scan_service.py:248:0 <= confirmed_at.hour < 6 → local_hour(...)。
   原先 [0,6) UTC 被当成"凌晨",实际是北京时间 08:00-14:00,整条
   「凌晨时段小额操作」规则判的是上午。
3. risk_judgement_service.py:238/243:判断**与展示**都换算。展示不改的话,
   风控专员看到的时刻与直觉差 8 小时,无法与客户核对。
4. risk_daily_report_service.py:89:日界改用 local_day_bounds(按北京时间自然日,
   再折回 UTC naive)。原先按 UTC 日期切日,北京 08:00 前生成的日报统计窗口跨零点。

测试:
- 新增 tests/unit/core/test_timeutil.py(8 条),含"UTC 凌晨 0-6 点不是北京凌晨"
  这一缺陷复现,以及"日界必须返回 UTC naive"。
- 改写 test_risk_scan_service.py::test_night_small_trade_boundaries:它原本就拿
  UTC 小时构造数据(写 0 点/6 点),等于在测北京 08:00/14:00;语义一并修正为
  北京 00:00(含)与 06:00(不含)两个边界,三个边界场景保持不变。

ruff / mypy(135 文件) / 603 unit+contract 全绿。
2026-09-11 12:50:52 +08:00

582 lines
18 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() == []
@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() == []