一、第十五条豁免规则(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 的豁免额度条件与研判口径。
656 lines
20 KiB
Python
656 lines
20 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() == []
|
||
|
||
|
||
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() == []
|