Files
group_xinghuo_jinrong/tests/test_convert_engine.py
T
GaoYiYuan_0626 a6ac39edd6 基金转换 T-11:工具与 SQL 汇总去重(FR-C15 / R-d)
convert 在 core_trade 落两条流水(转出 redeem + 转入 subscribe,共享 convert_group_id),
所有金额聚合读取方必须只计一次,否则同笔转换金额翻倍。

落地(改码 3 处 + 新增 1 脚本 + 测试 3 文件)
- rules.py:_amount_view → 公开 amount_view(提升而非复制别名,全仓唯一金额聚合口径);
  docstring 补「跨模块共用」说明
- core_tools.py:query_recent_trades 的 sum_amount 改走 amount_view(FR-C15);
  items / total_count 保持全量不变——两条流水是真实的两笔权益变动,读到两条是对的
- core_ro.py:list_holdings 加 h.qty > 0(convert 转出全部份额留下的 qty=0 归零行不是持仓);
  sum_trades_on_date 加 convert 去重条件(R-d),组内只计转出端
- 新增 tests/test_core_tools.py(3 条:汇总不翻倍 / 跨口径一致性 / 持仓不返回 qty=0 行)
- tests/test_core_ro_sum.py 追加 1 条 convert 去重用例(既有断言零改动)
- 新增 scripts/dev/verify_convert_tools.py:真库验证脚本(MySQL 8.0.46)

执行期裁定 2 条(已留痕)
- _amount_view 提升为公开而非复制:一个函数两个名字会漂移(自检第 13 问)
- SQL 条件从 IS NULL 扩为 IS NULL OR = '':Python 的 if not gid 把空串当无组,
  而 SQL 里 '' IS NULL 恒假。真库实证:只写 IS NULL → 合计数 400000(正确 450000,漏算 50000)。
  不加则 RISK-002 漏算,且 sqlite 单测若只造 NULL 数据永远发现不了
- 等价性边界:组内无 redeem 时 SQL 丢整组、amount_view 保留首条;由 R-b 保证不可达

验证
- pytest -q → 718 passed / 3 skipped(基线 714 加 4,零回归)
- 突变验证 3 组均精准命中:去掉去重条件(2 红)/ 去掉 qty>0(1 红)/ 汇总不走 amount_view(2 红)
- 真库 verify_convert_tools.py 14/14,隔离数据零残留
- 复跑受影响真库脚本零回归:T-8 31/31、T-7 35/35、T-10 20/20
2026-09-10 18:40:27 +08:00

311 lines
13 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.
"""T-8 规则引擎改造单测(开发计划 §7.1 DoD / 验收 5·6·7)。
覆盖三层:
1. `rules.amount_view` 纯函数 —— 同组只留转出端 / 无 gid 恒等 / 组内无 redeem 防御 / 顺序保持
2. `run_rules` 视图分流 —— RISK-002 与 RISK-005 走金额视图(**不翻倍**),
RISK-001 / RISK-003 仍看**全量**(证明去重未删行,验收 6)
3. `engine.process_convert_event` —— **一张单 + `payload.events` 两条**(验收 7)、
两条流水只跑一次、D19 `on_error_hook` 容错(hook 自身抛异常不得反噬主流程)
sqlite 内存库(`conftest.sqlite_engine`),数据自建。阈值一律**显式传入**,
不读 settings(避免与 conftest 的 autouse 隔离相互干扰,也让去重差异可精确归因)。
"""
from __future__ import annotations
from datetime import datetime, timedelta
from decimal import Decimal
import pytest
from sqlalchemy import text
from app.repository.core_ro import CoreReadOnlyRepository
from app.repository.risk_repository import RiskRepository
from app.service.risk.engine import process_convert_event, process_trade_event
from app.service.risk.rules import RiskThresholds, amount_view, run_rules
CUST = "CUST-T8"
PROD_A = "PROD-T8A" # 转出方
PROD_B = "PROD-T8B" # 转入方
GID = "CNV-T8-0001"
NOW = datetime(2026, 9, 4, 10, 0, 0)
def _th(**over) -> RiskThresholds:
"""显式阈值(不让用例依赖 settings;concentration 推到不可达避免 RISK-006 干扰)。"""
base = dict(
large_amount=Decimal("200000"),
daily_total=Decimal("500000"),
freq_count=3,
probe_window_minutes=5,
probe_count=3,
probe_amount=Decimal("400000"),
small_amount=Decimal("10000"),
small_count=3,
concentration_threshold=1.01,
)
base.update(over)
return RiskThresholds(**base)
def _trade(
trade_id: str,
trade_type: str,
amount: str,
at: datetime = NOW,
*,
gid: str | None = None,
product: str = PROD_A,
) -> dict:
"""构造一条规则引擎口径的流水(等价 core_trade 行)。"""
return {
"trade_id": trade_id,
"customer_id": CUST,
"product_id": product,
"trade_type": trade_type,
"amount": Decimal(amount),
"qty": Decimal("1"),
"trade_status": "confirmed",
"traded_at": at,
"convert_group_id": gid,
}
def _convert_legs(out_amount: str = "300000", in_amount: str = "299000") -> tuple[dict, dict]:
"""一次转换的两条流水(同组,转出在前)。"""
out = _trade("TRD-T8-OUT", "redeem", out_amount, gid=GID, product=PROD_A)
inn = _trade(
"TRD-T8-IN", "subscribe", in_amount, NOW + timedelta(seconds=1), gid=GID, product=PROD_B
)
return out, inn
def _ids(hits) -> set[str]:
return {h.rule_id for h in hits}
# ── 1. `amount_view` 纯函数 ─────────────────────────────────────────
def test_amount_view_identity_without_group():
"""无 convert_group_id 的交易**原样通过**(非 convert 场景恒等 → 既有断言零影响)。"""
raw = [
_trade("T-1", "redeem", "1000"),
_trade("T-2", "subscribe", "900", NOW + timedelta(seconds=1), product=PROD_B),
]
view = amount_view(raw)
assert view == raw, "无 gid 输入必须恒等(内容与顺序都不变)"
assert view is not raw, "返回独立列表,不得共享可变状态"
def test_amount_view_keeps_redeem_when_subscribe_comes_first():
out, inn = _convert_legs()
view = amount_view([inn, out])
assert [t["trade_id"] for t in view] == ["TRD-T8-OUT"], "同组只留转出端,且位置不变"
def test_amount_view_keeps_redeem_when_redeem_comes_first():
out, inn = _convert_legs()
view = amount_view([out, inn])
assert [t["trade_id"] for t in view] == ["TRD-T8-OUT"], "转入端不得覆盖已占位的转出端"
def test_amount_view_group_without_redeem_keeps_first_row():
"""防御分支:组内无 redeem 时保留首条(R-b 规定 convert 必有转出端,理论不可达)。"""
a = _trade("T-IN-1", "subscribe", "100", gid="CNV-T8-X", product=PROD_B)
b = _trade("T-IN-2", "subscribe", "200", NOW + timedelta(seconds=1), gid="CNV-T8-X",
product=PROD_B)
assert amount_view([a, b]) == [a]
def test_amount_view_mixes_groups_and_plain_trades_in_order():
"""多组 + 无 gid 混合:顺序保持,各组各留一条。"""
n1 = _trade("T-N1", "redeem", "1000")
n2 = _trade("T-N2", "subscribe", "2000", NOW + timedelta(seconds=3), product=PROD_B)
out1, in1 = _convert_legs()
out2 = _trade("TRD-T8-OUT2", "redeem", "500", NOW + timedelta(seconds=4), gid="CNV-T8-0002")
in2 = _trade("TRD-T8-IN2", "subscribe", "480", NOW + timedelta(seconds=5),
gid="CNV-T8-0002", product=PROD_B)
view = amount_view([n1, in1, out1, n2, in2, out2])
assert [t["trade_id"] for t in view] == ["T-N1", "TRD-T8-OUT", "T-N2", "TRD-T8-OUT2"]
# ── 2. `run_rules` 视图分流(验收 5/6)───────────────────────────────
def test_daily_total_counts_convert_only_once():
"""RISK-002 走金额视图:一次转换只计转出一端,不翻倍(验收 5)。"""
out, inn = _convert_legs()
# 阈值 500000:全量口径 300000+299000=599000 会命中;去重后 300000 不命中
assert "RISK-002" not in _ids(run_rules([out, inn], _th(), now=NOW))
# 反证:同一对流水若**没有 gid**(视为两笔独立交易)→ 命中,证明差异来自去重而非阈值
loose = [_trade("TRD-T8-OUT", "redeem", "300000"), _trade("TRD-T8-IN", "subscribe", "299000")]
assert "RISK-002" in _ids(run_rules(loose, _th(), now=NOW))
def test_daily_total_detail_reflects_deduped_amount():
"""合计金额与笔数都只算转出端(阈值下调到 290000 让 RISK-002 命中以便读 detail)。"""
out, inn = _convert_legs()
hits = run_rules([out, inn], _th(daily_total=Decimal("290000")), now=NOW)
hit = next(h for h in hits if h.rule_id == "RISK-002")
assert "300000" in hit.detail
assert "599000" not in hit.detail, "detail 不得出现两条流水之和"
assert "共 1 笔" in hit.detail
def test_freq_trade_still_sees_both_legs():
"""RISK-003 看全量(去重未删行,验收 6):转入端也参与分产品计数。"""
out, inn = _convert_legs("1000", "900")
extra = _trade("TRD-T8-B2", "subscribe", "800", NOW + timedelta(seconds=2), product=PROD_B)
hits = run_rules([out, inn, extra], _th(freq_count=2), now=NOW)
assert "RISK-003" in _ids(hits), "产品 B 应有 2 笔(convert 转入端 + 普通申购)"
assert any(PROD_B in h.detail and "2 笔" in h.detail for h in hits if h.rule_id == "RISK-003")
def test_large_amount_still_sees_both_legs():
"""RISK-001 看全量:转入端大额也能命中(逐笔判定,不走去重视图)。"""
out, inn = _convert_legs("100000", "250000") # 仅转入端 ≥ 200000
assert "RISK-001" in _ids(run_rules([out, inn], _th(), now=NOW))
def test_small_then_large_ignores_converted_leg_as_buildup():
"""RISK-005 走金额视图:转入端不得被当成「铺垫」小额(否则虚增 small_count 误报)。"""
small1 = _trade("T-S1", "redeem", "5000", product=PROD_A)
small2 = _trade("T-S2", "redeem", "6000", NOW + timedelta(seconds=1), product=PROD_A)
# 转入端本身是小额(5000)且时间早于转出端 → 若全量参与,会被算作第 3 笔铺垫
inn = _trade("TRD-T8-IN", "subscribe", "5000", NOW + timedelta(seconds=2),
gid=GID, product=PROD_B)
out = _trade("TRD-T8-OUT", "redeem", "300000", NOW + timedelta(seconds=3),
gid=GID, product=PROD_A)
assert "RISK-005" not in _ids(run_rules([small1, small2, inn, out], _th(), now=NOW))
# 反证:去掉 gid(转入端成为独立小额)→ 铺垫凑够 3 笔 → 命中
inn_loose = _trade("TRD-T8-IN", "subscribe", "5000", NOW + timedelta(seconds=2),
product=PROD_B)
assert "RISK-005" in _ids(run_rules([small1, small2, inn_loose, out], _th(), now=NOW))
# ── 3. `process_convert_event` 集成(验收 7)─────────────────────────
def _exec(engine, sql: str, **params) -> None:
with engine.begin() as conn:
conn.execute(text(sql), params)
def _rows(engine, sql: str, **params) -> list[dict]:
with engine.connect() as conn:
return [dict(r) for r in conn.execute(text(sql), params).mappings()]
def _seed_trades(engine, *trades: dict) -> None:
for i, t in enumerate(trades):
_exec(
engine,
"INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type, amount, "
"qty, convert_group_id, trade_status, traded_at) "
"VALUES (:tid, :c, :p, :tt, :amt, 1, :gid, :st, :at)",
tid=t["trade_id"], c=t["customer_id"], p=t["product_id"], tt=t["trade_type"],
amt=float(t["amount"]), gid=t["convert_group_id"], st=t["trade_status"],
at=t["traded_at"],
)
def _services(engine) -> dict:
return dict(
core_ro=CoreReadOnlyRepository(engine=engine),
risk_repo=RiskRepository(engine=engine),
)
def test_process_convert_event_creates_one_alert_with_two_events(sqlite_engine):
"""一次转换 → 一张单、`payload.events` 两条、RISK-002 不翻倍(验收 5/7)。"""
out, inn = _convert_legs()
_seed_trades(sqlite_engine, out, inn)
th = _th(daily_total=Decimal("400000")) # 全量 599000 会命中;去重后 300000 不命中
result = process_convert_event(out, inn, thresholds=th, **_services(sqlite_engine))
assert result["triggered_rules"] == ["RISK-001"], "只应命中单笔大额,累计不翻倍"
assert result["aml_hit"] is False
assert len(result["alert_ids"]) == 1, "一次转换只出一张单"
alerts = _rows(sqlite_engine, "SELECT * FROM risk_alert")
assert len(alerts) == 1
import json
payload = json.loads(alerts[0]["payload"])
assert len(payload["events"]) == 2, "一张单承载两条事件"
assert [e["trade_id"] for e in payload["events"]] == ["TRD-T8-OUT", "TRD-T8-IN"]
assert payload["events"][0]["trade_type"] == "redeem"
assert payload["events"][1]["trade_type"] == "subscribe"
# 主流水口径:单主体与 payload.product_id 取转出端
assert alerts[0]["trade_id"] == "TRD-T8-OUT"
assert payload["product_id"] == PROD_A
# 去重不删行:两条流水仍在
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 2
def test_process_convert_event_no_hit_writes_pass_audit_only(sqlite_engine):
"""无命中 → 不建单,仅落 pass 审计(events 参数不参与该分支)。"""
out, inn = _convert_legs("1000", "900")
_seed_trades(sqlite_engine, out, inn)
result = process_convert_event(out, inn, thresholds=_th(), **_services(sqlite_engine))
assert result["alert_ids"] == []
assert _rows(sqlite_engine, "SELECT * FROM risk_alert") == []
rows = _rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'pass'")
assert len(rows) == 1
def test_process_trade_event_still_writes_single_event(sqlite_engine):
"""回归保护:`process_trade_event` 签名与行为不变(payload.events 仍为 1 条)。"""
t = _trade("TRD-T8-PLAIN", "redeem", "300000")
_seed_trades(sqlite_engine, t)
result = process_trade_event(t, thresholds=_th(), **_services(sqlite_engine))
assert result["triggered_rules"] == ["RISK-001"]
import json
alerts = _rows(sqlite_engine, "SELECT * FROM risk_alert")
payload = json.loads(alerts[0]["payload"])
assert len(payload["events"]) == 1
assert payload["events"][0]["trade_id"] == "TRD-T8-PLAIN"
# ── 4. D19 `on_error_hook` 容错 ──────────────────────────────────────
def _boom(*_args, **_kwargs):
raise ZeroDivisionError("引擎内部炸了")
def test_error_hook_receives_exception_and_swallows_its_own(sqlite_engine, monkeypatch):
"""hook 被调用一次;**hook 自身抛异常必须被吞掉**,原始异常照常上抛。"""
out, inn = _convert_legs()
core = CoreReadOnlyRepository(engine=sqlite_engine)
monkeypatch.setattr(core, "list_trades_range", _boom)
calls: list[tuple[dict, Exception]] = []
def hook(out_trade: dict, exc: Exception) -> None:
calls.append((out_trade, exc))
raise RuntimeError("hook 自己也炸了")
with pytest.raises(ZeroDivisionError, match="引擎内部炸了"):
process_convert_event(
out, inn, core_ro=core, risk_repo=RiskRepository(engine=sqlite_engine),
thresholds=_th(), on_error_hook=hook,
)
assert len(calls) == 1, "hook 必须被调用一次"
assert calls[0][0]["trade_id"] == "TRD-T8-OUT", "hook 收到主流水(转出端)"
assert isinstance(calls[0][1], ZeroDivisionError)
def test_error_hook_none_keeps_original_exception(sqlite_engine, monkeypatch):
"""一期默认 `on_error_hook=None`:异常原样上抛(由阶段 1.5 落 engine_error)。"""
out, inn = _convert_legs()
core = CoreReadOnlyRepository(engine=sqlite_engine)
monkeypatch.setattr(core, "list_trades_range", _boom)
with pytest.raises(ZeroDivisionError):
process_convert_event(
out, inn, core_ro=core, risk_repo=RiskRepository(engine=sqlite_engine),
thresholds=_th(),
)