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