Files
group_xinghuo_jinrong/tests/test_concentration_c4.py
T
GaoYiYuan_0626 22f2a41192 基金转换 T-12:补偿脚本(详情 + 预警)
阶段二失败(或阶段 1.5 引擎失败)后,仅凭 Core 侧数据把「详情 + 预警」两件事补回来
(PRD §7.1 / 架构 §5.4)。

落地(改 3 + 新增 2 脚本 + 测试 2 文件)
- convert_service:新增公开 compensate_convert(group_id, ...) —— 补偿的服务端单点入口
  · 锁键 convert:rerun:{gid},与 convert_fund 幂等重试路径同一个键
  · 详情侧:非 completed 才补写,复用 _finalize_from_core(不另写第二份阶段二)
  · 预警侧:幂等锚点 = 转出端 out_trade_id,复用 find_alerts_by_trade;
    命中即 skipped,否则跑 process_convert_event
- risk_repository:has_engine_error_audit 加 decision 参数(默认值不变)
  · convert 线阶段 1.5 失败审计用 engine_error,普通交易用 risk_engine_error,不是同一个码
- rebuild_alerts.py:新增 --convert-group(与 location 参数 trade_ids 互斥),薄封装
- 新增 scripts/agent/cleanup_pending_convert.py(架构 §2 与开发计划 §9 指定路径):
  超 convert_compensate_sla_hours 的 pending 占位 → status='expired'(标记不硬删,S2)
- 新增 scripts/dev/verify_convert_compensate.py:真库验证脚本(MySQL 8.0.46)
- 测试 +12:test_convert_service +4(补写 / 幂等 / missing / locked)、
  test_demo_scripts +8(--convert-group 分派与接线 + cleanup 脚本)

state 四态与 CLI 退出码
- rebuilt(0) / skipped(0 幂等) / missing(1 零写入) / locked(3)
- 退出码 2 保留给 argparse 用法错误,故 locked 取 3

真库专属证据(sqlite 单测给不了的,本任务核心增量)
- status='expired' 在 MySQL ENUM 上被接受(sqlite 该列是 VARCHAR,写什么都收)
- created_at < cutoff 在 DATETIME(3) 上的时间边界正确(超时进候选 / 未超时不进 / 复跑幂等)
- input_summary 是真 JSON 列,而 has_engine_error_audit 用 LIKE 判定:脚本先断言
  information_schema 的 DATA_TYPE='json' 再验命中,并反向断言决策码不匹配则不命中

顺带收口(用户指示)
- core_ro.concentration_profile 补 h.qty > 0,与 list_holdings 真正同口径
  · ratio 不变(归零行市值为 0),但 rows 不再多出已清仓产品、不虚占截断判定位
  · 新用例含跨出口一致性断言;突变验证:去掉 qty > 0 → 精准 1 条红

验证
- pytest -q → 731 passed / 3 skipped(基线 719 加 12,零回归)
- 突变验证 3 组精准命中:去掉幂等锚点(1 红)/ 去掉 status 过滤(2 红)/ 补偿无视锁(1 红)
- 真库 verify_convert_compensate.py 34/34,隔离数据零残留
- 全套 7 个真库脚本复跑零回归:seed 全 PASS / apply 24 / service 35 / engine 31 / lots 20 / tools 14 / compensate 34
2026-09-10 18:59:42 +08:00

352 lines
13 KiB
Python

"""C4 / FR-8 · RISK-006 持仓集中度:引擎接入 + 出单聚合 + 对话线专项测试。
覆盖实现方案 §6.2 中 C4 相关用例:
- 引擎:RISK-006 与 RISK-001 同单聚合、risk_score 取 max、payload.alert_subtype
含 concentration、L3 打 high_risk_concentration 标签、risk_concentration 审计;
- 出单:alert_subtype 集合维护(空集不注入 / 追加时合并)、
**P0-1 回归**:当日已有 agent_behavior 单后再触发 RISK-006 应出第二张客户维度单、
**P1-3 修正**:既有 large_amount 单追加仅 RISK-006 时 alert_type 不翻转;
- 对话线:customer_context 带 concentration_ratio。
阈值说明:conftest 的 autouse fixture 把 `risk_concentration_threshold` 推到 1.01
(回归隔离),本模块内统一 monkeypatch 回真实阈值 0.80。
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
import pytest
from sqlalchemy import text
from _ddl import create_sqlite_engine
from app.repository.core_ro import CoreReadOnlyRepository
from app.repository.risk_repository import RiskRepository
from app.service.risk import alert_service
from app.service.risk.chat_tools import customer_context
from app.service.risk.engine import process_trade_event
from app.service.risk.rules import RiskThresholds, rule_concentration
NOW = datetime(2026, 9, 6, 14, 0, 0)
class FakePublisher:
def __init__(self):
self.messages: list = []
def publish(self, channel, payload):
self.messages.append((channel, payload))
def delete(self, *keys):
pass
@pytest.fixture()
def env(monkeypatch):
"""sqlite 环境:R3 产品(走交易)+ R5 产品(持仓主体,构成 90% 集中度)。
持仓口径:P2(R5) 900000 + P1(R3) 100000 → R4+R5 占比 90% ≥ 0.80。
"""
from app.config.settings import settings
monkeypatch.setattr(settings, "risk_concentration_threshold", 0.80)
engine = create_sqlite_engine()
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_customer (customer_id, display_name, age, is_active)"
" VALUES ('C1', '张某某', 40, 1)"
)
)
conn.execute(
text(
"INSERT INTO core_product (product_id, product_name, min_risk_code, product_type)"
" VALUES ('P1', '测试混合基金', 'R3', 'mixed'),"
" ('P2', '测试股票基金', 'R5', 'equity')"
)
)
conn.execute(
text(
"INSERT INTO core_holding (customer_id, product_id, market_value, qty,"
" cost_amount, pnl_pct, as_of)"
" VALUES ('C1', 'P2', 900000, 1000, 900000, 0, '2026-09-04'),"
" ('C1', 'P1', 100000, 500, 100000, 0, '2026-09-04')"
)
)
core = CoreReadOnlyRepository(engine=engine)
repo = RiskRepository(engine=engine)
pub = FakePublisher()
alert_service.set_publisher(pub)
yield core, repo, pub, engine
alert_service.set_publisher(None)
engine.dispose()
def _trade(trade_id, amount="600000", at=NOW):
return {
"trade_id": trade_id,
"customer_id": "C1",
"product_id": "P1",
"trade_type": "subscribe",
"amount": Decimal(amount),
"traded_at": at,
}
def _seed_trade(engine, trade):
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type,"
" amount, trade_status, traded_at)"
" VALUES (:tid, :cid, :pid, :tt, :amt, 'confirmed', :at)"
),
{
"tid": trade["trade_id"],
"cid": trade["customer_id"],
"pid": trade["product_id"],
"tt": trade["trade_type"],
"amt": float(trade["amount"]), # sqlite 不支持绑定 Decimal,转 float
"at": trade["traded_at"],
},
)
def _insert_alert(engine, alert_id, alert_type, risk_score, rules, payload):
import json
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO risk_alert (alert_id, trace_id, customer_id, trade_id,"
" alert_type, triggered_rules, risk_score, status, payload)"
" VALUES (:aid, 'TRACE-TEST', 'C1', 'TRD-TEST-0', :atype, :rules,"
" :score, 'pending_review', :payload)"
),
{
"aid": alert_id,
"atype": alert_type,
"rules": json.dumps(rules),
"score": risk_score,
"payload": json.dumps(payload, ensure_ascii=False),
},
)
# ---------- 仓储聚合:concentration_profile ----------
def test_concentration_profile_aggregates_r4_r5(env):
core, repo, pub, engine = env
profile = core.concentration_profile("C1")
assert profile["r45_value"] == Decimal(900000)
assert profile["total_value"] == Decimal(1000000)
assert profile["ratio"] == 0.9
assert profile["holdings_truncated"] is False
def test_concentration_profile_empty_customer(env):
core, repo, pub, engine = env
profile = core.concentration_profile("NOT-EXIST")
assert profile["total_value"] == Decimal(0)
assert profile["ratio"] == 0.0
assert rule_concentration(profile, RiskThresholds()) is None
def test_concentration_profile_filters_zero_qty(env):
"""归零行(convert 转出全部份额后保留的 qty=0 行)不构成持仓,须过滤。
T-11 收尾:原实现走独立 SQL、漏了 `qty > 0`,与 `list_holdings` 口径落差 ——
ratio 数值不受影响(归零行市值 0),但 `rows` 会多出已清仓产品、虚占截断位。
"""
core, repo, pub, engine = env
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_product (product_id, product_name, min_risk_code,"
" product_type) VALUES ('P3', '已清仓基金', 'R5', 'equity')"
)
)
conn.execute(
text(
"INSERT INTO core_holding (customer_id, product_id, market_value, qty,"
" cost_amount, pnl_pct, as_of)"
" VALUES ('C1', 'P3', 0, 0, 800000, 0, '2026-09-05')"
)
)
profile = core.concentration_profile("C1")
assert profile["total_value"] == Decimal(1000000) # 不含归零行
assert profile["r45_value"] == Decimal(900000)
assert profile["ratio"] == 0.9
assert len(profile["rows"]) == 2 # 归零行 P3 未进明细
assert profile["holdings_truncated"] is False
# 与 list_holdings 同口径(T-11 收尾的核心诉求):持仓集合与市值一致
listed = core.list_holdings("C1")
assert len(listed) == len(profile["rows"])
assert {(r["product_id"], r["market_value"]) for r in listed} == {
("P2", Decimal(900000)),
("P1", Decimal(100000)),
}
assert {r["product_id"] for r in listed} == {"P1", "P2"}
# ---------- 引擎接入 ----------
def test_engine_merges_concentration_with_large_amount(env):
"""RISK-001(70) + RISK-006(60) 同单聚合:score 取 max=70,类型随最高分规则。"""
core, repo, pub, engine = env
trade = _trade("TRD-TEST-1")
_seed_trade(engine, trade)
result = process_trade_event(trade, core_ro=core, risk_repo=repo)
# 600000 同时触发 RISK-002(≥ 单日累计 500000),故断言"包含"而非全等
assert "RISK-001" in result["triggered_rules"]
assert "RISK-006" in result["triggered_rules"]
assert len(result["alert_ids"]) == 1 # 聚合成一张单
alert = repo.get_alert(result["alert_ids"][0])
assert alert["risk_score"] == 70
assert alert["alert_type"] == "large_amount" # 不被 RISK-006 翻转
assert alert["payload"]["alert_subtype"] == ["concentration"]
def test_engine_writes_concentration_audit_and_l3_tag(env):
core, repo, pub, engine = env
trade = _trade("TRD-TEST-2")
_seed_trade(engine, trade)
result = process_trade_event(trade, core_ro=core, risk_repo=repo)
with engine.connect() as conn:
audit = conn.execute(
text(
"SELECT event_type, rule_id, risk_score FROM audit_log"
" WHERE event_type = 'risk_concentration'"
)
).mappings().all()
assert len(audit) == 1
assert audit[0]["rule_id"] == "RISK-006"
l3 = repo.get_l3("C1")
assert "high_risk_concentration" in (l3.get("monitor_tags") or [])
def test_engine_concentration_only_still_creates_alert(env):
"""仅命中集中度(未达大额)也要出单——无需为「仅 RISK-006」写独立分支。"""
core, repo, pub, engine = env
trade = _trade("TRD-TEST-3", amount="10000")
_seed_trade(engine, trade)
result = process_trade_event(trade, core_ro=core, risk_repo=repo)
assert result["triggered_rules"] == ["RISK-006"]
alert = repo.get_alert(result["alert_ids"][0])
assert alert["risk_score"] == 60
assert alert["alert_type"] == "pattern"
def test_engine_disabled_when_threshold_unreachable(env, monkeypatch):
"""回归隔离口径:阈值推到 1.01 后 RISK-006 不触发(conftest autouse 同款行为)。"""
from app.config.settings import settings
monkeypatch.setattr(settings, "risk_concentration_threshold", 1.01)
core, repo, pub, engine = env
trade = _trade("TRD-TEST-4", amount="10000")
_seed_trade(engine, trade)
result = process_trade_event(trade, core_ro=core, risk_repo=repo)
assert result["triggered_rules"] == []
# ---------- 出单:alert_subtype 与聚合锚点 ----------
def test_append_merges_subtypes(env):
"""追加到老单时,extra_subtypes 合并进 payload.alert_subtype(老单原本无该字段)。"""
core, repo, pub, engine = env
_insert_alert(
engine, "ALT-TEST-OLD", "large_amount", 70, ["RISK-001"], {"product_id": "P1", "events": []}
)
trade = _trade("TRD-TEST-5", amount="10000")
_seed_trade(engine, trade)
result = process_trade_event(trade, core_ro=core, risk_repo=repo)
# 老单被追加,不新建
assert result["alert_ids"] == ["ALT-TEST-OLD"]
alert = repo.get_alert("ALT-TEST-OLD")
assert alert["payload"]["alert_subtype"] == ["concentration"]
assert alert["risk_score"] == 70 # max(70, 60)
def test_alert_type_not_flipped_by_lower_score_rule(env):
"""评审 P1-3:既有 large_amount(70) 单追加仅 RISK-006(60) 时,类型保持 large_amount。"""
core, repo, pub, engine = env
_insert_alert(
engine, "ALT-TEST-KEEP", "large_amount", 70, ["RISK-001"], {"product_id": "P1", "events": []}
)
from app.service.risk.alert_service import record_trade_alerts
profile = core.concentration_profile("C1")
hit = rule_concentration(profile, RiskThresholds.from_settings())
assert hit is not None
trade = _trade("TRD-TEST-6", amount="10000")
updated = record_trade_alerts(trade, [hit], risk_repo=repo)
assert updated["alert_type"] == "large_amount"
def test_p0_1_agent_behavior_alert_is_not_anchor(env):
"""评审 P0-1 回归:当日已有 agent_behavior 单 → RISK-006 应新建客户维度单,不并入。"""
core, repo, pub, engine = env
_insert_alert(
engine,
"ALT-TEST-AGENT",
"pattern",
70,
["RISK-008"],
{"product_id": "P1", "events": [], "alert_subtype": ["agent_behavior"]},
)
trade = _trade("TRD-TEST-7", amount="10000")
_seed_trade(engine, trade)
result = process_trade_event(trade, core_ro=core, risk_repo=repo)
assert result["alert_ids"] and result["alert_ids"][0] != "ALT-TEST-AGENT"
new_alert = repo.get_alert(result["alert_ids"][0])
assert new_alert["payload"]["alert_subtype"] == ["concentration"]
agent_alert = repo.get_alert("ALT-TEST-AGENT")
assert agent_alert["payload"]["alert_subtype"] == ["agent_behavior"] # 未被污染
# ---------- 对话线 ----------
def test_customer_context_includes_concentration_ratio(env):
core, repo, pub, engine = env
data = customer_context("C1", core_ro=core, risk_repo=repo)
assert data["found"] is True
assert data["profile"]["concentration_ratio"] == 0.9
assert data["profile"]["holdings_truncated"] is False
def test_customer_context_zero_holdings(env):
core, repo, pub, engine = env
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_customer (customer_id, display_name, age, is_active)"
" VALUES ('C9', '空仓客户', 30, 1)"
)
)
data = customer_context("C9", core_ro=core, risk_repo=repo)
assert data["found"] is True
assert data["profile"]["concentration_ratio"] == 0.0