Files
group_xinghuo_jinrong/tests/test_convert_engine.py
T
GaoYiYuan_0626 57f16a50a1 基金转换 T+1 模型:T-8 引擎时机收口(调用点唯一性守护 + 受理不触引擎 + 真库链路切 T+1)
【范围澄清】
引擎调用点在 T-7 即已迁到确认事务 commit 之后(engine_call.run_convert_engine 唯一出口),
受理段 accept_convert 自实现起就不含引擎调用 → R-7 已满足。本轮补齐**守护与端到端验证**。
注意 F-10 已明确:T-8 **不动**去重口径(amount_view 三处消费方原样不动,那是 T-11 的范围)。

【新增测试】
- test_convert_engine.py:test_process_convert_event_has_single_call_site —— AST 统计
  app/ 下 process_convert_event( 的 Call 节点**恰 1 处**且宿主为 run_convert_engine。
  用 AST 而非字符串匹配:engine_call.py 的说明段含该字样,会被误判。
  **已做突变验证**(临时加第二处调用 → 断言变红,随后还原)。
- test_convert_accept.py:受理无副作用用例补「不触引擎」断言 —— 受理后 risk_alert 0 行、
  无 pass / engine_error 审计(验收 29 前半)。
- test_convert_confirm.py:test_real_engine_on_confirm_dedupes_daily_total —— 确认链路接
  **真引擎**(不注入 hook):正证(阈值夹逼 → RISK-002 不命中,去重生效)+ 反证(阈值下调
  → 命中,证明确认段确实跑过引擎),并断言一张单 / payload.events 两条 / 去重不删行(验收 5/6/7)。
  **已做突变验证**(禁用引擎调用 → 变红,随后还原)。

【真库脚本】
verify_convert_engine.py 整体切换 T+1 链路(此前走 v1.0 convert_fund):
受理 accept_convert → 确认 confirm_one;新增 A0 组「受理不触引擎」;A~F 组保留原断言
(引擎出单 / RISK-002 夹逼 / 去重不删行 / 幂等重放 / 无命中 / 清理自检)。真库 39/39 一致。
- 折算期望改用 T 日净值(未知价法),不再沿用批次买入净值 alloc.nav;
- 基准日取真库日历中位开市日,T+1 = 下一交易日;
- 阈值必须显式传给 confirm_one —— 缺省退回 settings 默认会把 RISK-002 误判为命中,
  症状像「去重失效」,实为口径没传(首轮实跑被 B 组断言捕获,已钉进注释)。

【回归】
全量 829 passed / 10 skipped(827 → +2);复跑 T-6 verify_convert_accept 37/37、
T-7 verify_convert_confirm 83/83、v1.0 链路 verify_convert_service 35/35,均零失败。
2026-09-11 19:48:53 +08:00

356 lines
16 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
import ast
from datetime import datetime, timedelta
from decimal import Decimal
from pathlib import Path
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(),
)
# ── 4. 引擎调用点唯一性(T-8 DoD 1 / R-7)────────────────────────────
def test_process_convert_event_has_single_call_site():
"""`process_convert_event(` 在 `app/` 下**恰 1 处调用**,且位于 `engine_call.run_convert_engine`。
T-8(FR-C28)把引擎时机从「受理后」挪到「确认事务提交后」,并要求**全仓唯一调用点** ——
两处调用会让 RISK-002 当日累计翻倍(PRD 验收 5/7)。用 AST 统计 `Call` 节点,
**不靠字符串匹配**:docstring / 注释里提到函数名不算调用(`engine_call.py` 的说明段
就含该字样),定义点(`def`)与 `import` 也不是 `Call`。
"""
root = Path(__file__).resolve().parents[1]
hits: list[str] = []
for path in sorted((root / "app").rglob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
name = getattr(func, "id", None) or getattr(func, "attr", None)
if name == "process_convert_event":
hits.append(f"{path.relative_to(root).as_posix()}:{node.lineno}")
assert len(hits) == 1, (
"`process_convert_event(` 应恰有 1 处调用点(engine_call.run_convert_engine);"
f"实际 {len(hits)} 处:{hits}"
)
assert hits[0].startswith("app/service/convert/engine_call.py:"), (
f"唯一调用点必须落在 engine_call.py,实际:{hits[0]}"
)
# 调用所在函数体应是 `run_convert_engine`(防「文件对但函数被搬走」)
tree = ast.parse(
(root / "app/service/convert/engine_call.py").read_text(encoding="utf-8")
)
owner = None
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
for child in ast.walk(node):
if isinstance(child, ast.Call):
name = getattr(child.func, "id", None) or getattr(child.func, "attr", None)
if name == "process_convert_event":
owner = node.name
assert owner == "run_convert_engine", f"调用点宿主函数应为 run_convert_engine,实际 {owner!r}"