"""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_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