Files
group_xinghuo_jinrong/tests/conftest.py
T
GaoYiYuan_0626 fe4801bc0a feat: C4 FR-8 持仓集中度预警(RISK-006)
依据《实现方案-风控追加需求v1.1-C4C6.md》§2;不改表结构(alert_type/status
复用 payload 承载,audit_log.event_type 为 VARCHAR 可直接扩)。

1. settings.py + .env.example:一次性加齐风控追加 v1.1 共 11 项配置(C4~C6 共用)。
2. core_ro.concentration_profile(customer_id, limit=500):一次 SQL 取明细
   (LIMIT limit+1 探测截断)+ Python 端按 min_risk_code in (R4,R5) 聚合;
   收口挂账 #1(PRD 字面为 list_holdings,改聚合封装,docstring 注明偏离)。
3. rules.py:RULE_SCORES/RULE_ALERT_TYPES 加 RISK-006=60/pattern;RuleHit 加
   alert_subtype;RiskThresholds 加 concentration_threshold 且 from_settings
   必须补读(评审 P1-2:漏读会让 conftest monkeypatch 失效打穿现有断言);
   新增纯函数 rule_concentration——空仓不触发、截断视同达标(保守告警)、
   阈值边界 79.9% 不触发 / 80% 触发、R4+R5 为 0 不触发。
4. engine.process_trade_event:run_rules 之后、record_trade_alerts 之前并入
   集中度命中(不动 run_rules 签名);命中后 L3 打 high_risk_concentration
   标签 + 写 risk_concentration 审计(金额只落合计与前 5 条摘要)。
5. risk_repository:find_pending_event_alert 改候选 LIMIT 50 + Python 过滤掉
   payload.alert_subtype 含 agent_behavior 的单(评审 P0-1:代理人维度行为链单
   不得充当客户维度事件单的聚合锚点);append_alert_event 加 extra_subtypes
   合并进 payload.alert_subtype(不传时行为与原先一致,向后兼容)。
6. alert_service:subtypes 集合维护(空集不注入 payload,评审 P2-3);
   追加时 alert_type 按「老单规则 ∪ 本批规则」重算(评审 P1-3,修掉既有
   large_amount 单被本批仅 RISK-006(60) 翻转为 pattern 的缺陷);
   _publish_alert 加 notify_role/extra 可选参数(C5/C6 复用)。
7. 对话线:chat_tools.customer_context 加 profile(concentration_ratio/
   r45_value/total_value/holdings_truncated),tool_service.summarize 加
   「高风险持仓占比 X%(仅供参考)」;不新增意图词。
8. 02-redis-keys.md 增补 alert_subtype / escalation_level 附加推送字段。

测试:conftest 加 autouse _disable_concentration_rule(阈值推 1.01 做回归隔离,
现有用例断言零改动);test_risk_rules 加 RISK-006 纯函数 6 例;新建
tests/test_concentration_c4.py 11 例(与 RISK-001 同单聚合、score max=70、
L3 tag、risk_concentration 审计、仅集中度也出单、subtype 合并、P0-1 回归、
alert_type 不翻转、对话线 ratio)。全量 453 绿(436 + 17)。
2026-09-07 19:05:02 +08:00

148 lines
7.0 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.
"""pytest 全局 fixture(B8 · 开发计划 B8 行)。
- sqlite_engine:内存库 + tests/_ddl.py 单一事实源建表(单测共用)。
- 集成测试(test_integration_risk.py)收集期先调 ensure_risk_demo_ready():
本机演示数据未就位(未跑 FLOW §0 ③④)时整模块 skip 并给提示,单测不受影响。
- risk_demo_env(session):真 MySQL(jinrong_agent/jinrong_core)集成环境,
setup 幂等代跑 prepare_risk_demo.sql(UPDATE 语义)+ L3 快照,teardown 按
TRD-TEST- 前缀 + session 时间窗清理测试交易及其预警/校验/审计行,并还原 L3。
注意:时间窗清理假定测试期间无他人向演示库写入(学习项目单机约定)。
"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
import pytest
from sqlalchemy import text
from _ddl import create_sqlite_engine, seed_suitability_matrix
ROOT = Path(__file__).resolve().parent.parent
DEMO_SQL = ROOT / "scripts" / "demo" / "prepare_risk_demo.sql"
@pytest.fixture(autouse=True)
def _disable_concentration_rule(monkeypatch):
"""RISK-006 回归隔离(C4 起):默认把集中度阈值推到不可达,现有用例断言零改动。
RISK-006 在引擎里对**每一笔**交易都会查持仓画像,若沿用默认 0.80,
现有 436 条用例中断言「命中规则集合 / 单日一张单」的会被新规则打穿。
故全局默认禁用;RISK-006 专属测试内再显式传 `RiskThresholds(...)` 或
再次 monkeypatch 回真实阈值 0.80(实现方案 §6 全量回归口径,评审 P1-2)。
前置条件:`RiskThresholds.from_settings()` 必须补读 concentration_threshold,
否则此处 monkeypatch 不生效(rules.py 已补读并注明)。
"""
from app.config.settings import settings
monkeypatch.setattr(settings, "risk_concentration_threshold", 1.01)
@pytest.fixture()
def sqlite_engine():
"""内存 sqlite 全表引擎(B8 前 DDL 散落各测试文件,收敛后统一走这里)。
AL-05 起统一灌 C×R 矩阵种子(check_suitability 以矩阵表为 L0 权威,
缺失即全量 forbidden,见 _ddl.seed_suitability_matrix)。
"""
engine = create_sqlite_engine()
seed_suitability_matrix(engine)
yield engine
engine.dispose()
# ---------- 真库集成环境(B8 集成测试专用) ----------
def ensure_risk_demo_ready() -> None:
"""演示数据就位校验(开发计划 B8-a):缺失则 skip 集成模块并提示 bootstrap。
在集成测试模块收集期调用;失败 pytest.skip(allow_module_level=True) 只跳过
该模块,sqlite 单测不受影响。校验口径:CUST-4001 测评 <365 天、AML 名单 ≥8。
"""
from app.config.settings import settings
from app.utils.db import get_engine
hint = "先跑 FLOW §0 ③④:scripts/core/reset.ps1 → 01-mysql-共用底座.sql →" " 02-mysql-agent专用.sql → scripts/agent/seed-aml-list.sql → prepare_risk_demo.sql"
try:
core = get_engine(settings.mysql_core_database)
agent = get_engine(settings.mysql_database)
with core.connect() as conn:
# FM-03 口径(AL-05 换核):expires_at 未过期即就位(原 evaluated_at+365 天口径退役)
days = conn.execute(
text(
"SELECT DATEDIFF(expires_at, CURDATE()) FROM core_customer_risk"
" WHERE customer_id = 'CUST-4001'"
)
).scalar_one_or_none()
if days is None or days <= 0:
pytest.skip(f"演示数据未就位(CUST-4001 风评 expires_at 剩余 {days} 天):{hint}", allow_module_level=True)
with agent.connect() as conn:
aml_count = conn.execute(
text("SELECT COUNT(*) FROM risk_aml_list WHERE is_active = 1")
).scalar_one()
if aml_count < 8:
pytest.skip(f"演示数据未就位(AML 名单仅 {aml_count} 条):{hint}", allow_module_level=True)
except Exception as exc:
# pytest.skip 抛出的 Skipped 继承 BaseException,不会被此处吞掉,直接向上传播
pytest.skip(f"本机 MySQL 不可用({exc}):{hint}", allow_module_level=True)
def _run_demo_sql(core_engine) -> None:
"""幂等代跑 prepare_risk_demo.sql(开发计划 B8-b):文件须保持无存储过程/DELIMITER。"""
statements = [s.strip() for s in DEMO_SQL.read_text(encoding="utf-8").split(";") if s.strip()]
with core_engine.begin() as conn:
for stmt in statements:
if stmt.upper().startswith("USE "):
continue # engine 默认库已由 URL 指定
conn.execute(text(stmt))
def _cleanup_test_rows(agent_engine, core_engine, started_at: datetime) -> None:
"""TRD-TEST- teardown(开发计划 B8-c):测试交易及其关联行 + 时间窗兜底 + L3 还原。"""
with core_engine.begin() as conn:
conn.execute(text("DELETE FROM core_trade WHERE trade_id LIKE 'TRD-TEST-%'"))
with agent_engine.begin() as conn:
conn.execute(text("DELETE FROM risk_alert WHERE trade_id LIKE 'TRD-TEST-%' OR created_at >= :ts"), {"ts": started_at})
conn.execute(text("DELETE FROM risk_suitability_log WHERE request_ref LIKE 'TRD-TEST-%' OR created_at >= :ts"), {"ts": started_at})
conn.execute(text("DELETE FROM audit_log WHERE created_at >= :ts"), {"ts": started_at})
@pytest.fixture(scope="session")
def risk_demo_env():
"""真 MySQL 集成环境:setup 快照 L3 → yield engines → teardown 清理 + 引擎 dispose。"""
from app.config.settings import settings
from app.utils.db import dispose_engines, get_engine
core = get_engine(settings.mysql_core_database)
agent = get_engine(settings.mysql_database)
started_at = datetime.now()
l3_snapshot: list = [] # 哨兵:setup 失败时 teardown 不因未绑定变量掩盖原始异常
try:
with agent.begin() as conn:
l3_snapshot = conn.execute(
text("SELECT * FROM customer_profile_l3")
).mappings().all()
_run_demo_sql(core)
yield {"agent": agent, "core": core, "started_at": started_at}
finally:
try:
_cleanup_test_rows(agent, core, started_at)
with agent.begin() as conn:
conn.execute(text("DELETE FROM customer_profile_l3"))
for row in l3_snapshot:
conn.execute(
text(
"INSERT INTO customer_profile_l3 (customer_id, monitor_tier,"
" risk_score, score_dimensions, monitor_tags, last_alert_id,"
" computed_at, updated_at) VALUES (:customer_id, :monitor_tier,"
" :risk_score, :score_dimensions, :monitor_tags, :last_alert_id,"
" :computed_at, :updated_at)"
),
dict(row),
)
finally:
dispose_engines()