T-0 / T-0b(门禁 · 2026-09-10) - T-0:sqlite 与 MySQL 结构对齐 —— core_holding 统一为 qty/cost_amount/as_of/pnl_pct + PK + UNIQUE(customer_id, product_id);补 core_product_nav;新增建库自校验 _assert_ddl_aligned()(R-g);test_db.py 增 3 条门禁用例(含反向验证门禁失效) - T-0b:DB 账号分离(D20)—— 新增 scripts/core/00-grant.sql(三账号逐表授权); settings.py 增 3 组账号;db.py 改 get_engine(db, role),缓存键改为 (库名, 角色), 账号未配置回退单账号;core_ro→ro / gateway_repository→rw / risk·session_repository→rw; tests/conftest.py 四处显式 role="admin"(R-e) T-1(数据层) - scripts/core/01-ddl.sql:新建 core_fee_rule / core_share_lot / core_convert_lot_detail; core_trade 加 convert_group_id + idx_convert_group;core_product 加 8 列 + fee_rate 补 COMMENT - 新增 07-seed-fee-rule.sql(赎回费 5 档 × 14 产品,按 22 号文 §10)/ 08-seed-share-lot.sql (58 行持仓 → 61 行批次,Σ remain_qty 恒等于 qty)/ 09-seed-org.sql(管理人 + TA + 申购费率 + 最低持有余额,v1.1 按「管理人全产品线」重排) - reset.ps1 追加 07/08/09;02-mysql-agent专用.sql 追加 risk_convert_detail - tests/_ddl.py 同步 4 表 + 新增 REQUIRED_CONVERT_TABLES 建库门禁 - 新增 scripts/dev/verify_convert_seed.py(pymysql 等价 reset 流程 + 8 条 DoD 断言, 含断言 ⑧「费率档 ↔ product_type 匹配」,越档即 FAIL) T-2 / T-2b(纯函数包 + 示例实算回填) - 新增 app/service/convert/ 7 文件:__init__ / types / calc / fee / nav / lot_bootstrap / errors (纯函数,不查库、不碰 SQL;所有量化显式 ROUND_HALF_UP;lot_bootstrap 用 zlib.crc32 保证 D18 跨进程同源) - 新增 tests/test_convert_calc.py 93 用例(12 类:HALF_UP 反向自证 / 分档边界 / FIFO 含同 confirmed_at 兜底 / 双口径 / 强制全转与强制赎回 / PRD §5.3 全链自证 / 纯函数零 IO 依赖断言) - 重写 scripts/dev/calc_convert_demo.py:去掉脚本内公式副本,改为调用生产 calc.py, 末尾与 PRD §5.3 逐项比对(不一致即退出码 1),兼作一致性门禁 验证 - pytest 609 passed / 3 skipped(516 → +93,零回归) - verify_convert_seed.py 8/8 PASS;calc_convert_demo.py 15/15 与 PRD §5.3 一致 文档:PRD v0.9.1(费率分类修正)· 架构 §7 签名回填 / §8.3 错误码注 / §15 T-2 完成 · 开发计划 §1.5 新增 R-h + §4.2·§4.3 执行记录 · AGENTS.md · docs/memory
315 lines
11 KiB
Python
315 lines
11 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_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
|