Files
group_xinghuo_jinrong/tests/test_core_tools.py
T

335 lines
15 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.
"""core_tools 查询口径单测(T-11 · FR-C15 + 归零行过滤)。
自建 sqlite 完整种子(`core_product` / `core_holding` / `core_trade`),直调 Tool 函数。
覆盖 T-11 两条 DoD:
1. `query_recent_trades` 的 `sum_amount` 不因 convert 两条流水而**翻倍**
(走 `amount_view`;明细与 `total_count` 仍为全量),
并与 `core_ro.sum_trades_on_date` 做**跨口径一致性**断言(自检第 13 问:
同一口径不得有两份实现漂移);
2. `query_holdings` 不返回 `qty = 0` 的**归零行**(convert 转出全部份额后的台账留痕行)。
种子时间刻意取**同一个 `now`**:既落在 `query_recent_trades` 的 [now−30d, now) 窗内,
又保证属于 `now.date()` 这一天,使跨口径断言不受运行时刻影响。
"""
from __future__ import annotations
import datetime as _dt
from decimal import Decimal
import pytest
from sqlalchemy import text
from _ddl import create_sqlite_engine, seed_suitability_matrix
from app.gateway.convert_core_repository import ConvertCoreRepository
from app.repository.convert_repository import ConvertRepository
from app.repository.convert_request_repository import ConvertRequestRepository
from app.repository.core_ro import CoreReadOnlyRepository
from app.repository.risk_repository import RiskRepository
from app.service.convert.confirm_service import confirm_one
from app.service.convert.convert_service import accept_convert
from app.service.risk.rules import RiskThresholds
from app.tool.core_tools import query_holdings, query_recent_trades
CUST = "CUST-T11"
@pytest.fixture()
def seed():
engine = create_sqlite_engine()
now = _dt.datetime.now()
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_product (product_id, product_name, min_risk_code, product_type,"
" min_subscribe_amount, term_days) VALUES"
" ('PA', '甲基金', 'R3', 'mixed', 100, 0),"
" ('PB', '乙基金', 'R3', 'mixed', 100, 0)"
)
)
# PA 正常持有;PB 为 convert 转出全部后的**归零行**(qty = 0,行保留)
conn.execute(
text(
"INSERT INTO core_holding (customer_id, product_id, qty, cost_amount,"
" market_value, pnl_pct, as_of) VALUES"
" (:cid, 'PA', 1000.00, 1000.00, 1200.00, 0.2000, :d),"
" (:cid, 'PB', 0.00, 0.00, 0.00, 0.0000, :d)"
),
{"cid": CUST, "d": now.date()},
)
# 一次 convert 落两条(转出 redeem 300000 + 转入 subscribe 300000,共享 G1)
# + 1 笔普通赎回 100000(convert_group_id = NULL)
conn.execute(
text(
"INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type, amount,"
" qty, convert_group_id, trade_status, traded_at) VALUES"
" ('TX-OUT', :cid, 'PA', 'redeem', 300000, 250.00, 'G1', 'confirmed', :t),"
" ('TX-IN', :cid, 'PB', 'subscribe', 300000, 240.00, 'G1', 'confirmed', :t),"
" ('TX-PLAIN', :cid, 'PA', 'redeem', 100000, 80.00, NULL, 'confirmed', :t)"
),
{"cid": CUST, "t": now},
)
yield engine
engine.dispose()
def test_query_recent_trades_sum_not_doubled(seed):
"""FR-C15:汇总只计转出端;**明细与条数保持全量**(一次转换两条是真实的)。"""
repo = CoreReadOnlyRepository(engine=seed)
res = query_recent_trades(CUST, days=30, core_ro=repo)
# 明细全量:convert 两条 + 普通赎回 1 条
assert res["total_count"] == 3
assert sorted(r["trade_type"] for r in res["items"]) == ["redeem", "redeem", "subscribe"]
# 汇总去重:300000(转出端)+ 100000(普通赎回)= 400000
# 若未去重则为 700000(TX-OUT + TX-IN 双计)—— 用具体值才区分得开
assert res["sum_amount"] == 400000.0
def test_query_recent_trades_sum_matches_sum_trades_on_date(seed):
"""跨口径一致性:Tool 汇总(Python 侧 amount_view)== 仓储 SQL 汇总(IS NULL OR ='')。
两处是同一口径的两种落地(一个 Python 一个 SQL),此处用断言锁死等价性,
任一侧口径漂移都会在此变红。
"""
repo = CoreReadOnlyRepository(engine=seed)
tool_sum = query_recent_trades(CUST, days=30, core_ro=repo)["sum_amount"]
sql_sum = repo.sum_trades_on_date(CUST, _dt.date.today())
assert Decimal(str(tool_sum)) == sql_sum
assert sql_sum == Decimal("400000")
def test_query_holdings_excludes_zero_qty(seed):
"""归零行(convert 转出全部份额,`qty = 0` 台账留痕)不得作为持仓返回。"""
repo = CoreReadOnlyRepository(engine=seed)
res = query_holdings(CUST, core_ro=repo)
assert res["total_count"] == 1
assert [r["product_id"] for r in res["items"]] == ["PA"]
# 合计不含归零行(PB 的 market_value 为 0,即使计入也不变;
# 故断言**条数**才是真正能区分对错的判据)
assert res["sum_market_value"] == 1200.0
# ── T+1 确认段场景(v2.0 T-11 · 开发计划 R-回归 8 / R-回归 13 的补用例项)────────
# 既有 3 条用例以直接插 SQL 的方式模拟 convert 流水,锁定**口径本身**;
# 下面两条改走真实链路(accept_convert → confirm_one),锁定「确认事务真实落库
# 之后」口径依然成立 —— T+1 模型下流水与持仓**只在确认段产生**,这是两条新用例
# 相对既有用例的独立价值(不是重复覆盖):
# ① 确认落两条同 gid 流水 → sum_amount 只计转出端一次(验收 11)
# ② 全转归零 → core_holding.qty=0 行保留(台账留痕)但不在持仓查询结果(F-12)
PROD_T11_OUT = "PROD-T11-OUT"
PROD_T11_IN = "PROD-T11-IN"
COMPANY = "华夏模拟基金"
TA = "TA-CN-001"
T_DAY = _dt.date(2026, 9, 4) # 受理日 T(周五)
T1_DAY = _dt.date(2026, 9, 7) # 确认业务日 T+1(下周一)
SUBMIT_AT = _dt.datetime(2026, 9, 4, 10, 0)
CONFIRM_AT = _dt.datetime(2026, 9, 7, 9, 0)
OUT_NAV = Decimal("1.3604")
IN_NAV = Decimal("1.9194")
FEE_TIERS = [
(0, 7, "0.0150"), (7, 30, "0.0100"), (30, 180, "0.0050"),
(180, 365, "0.0025"), (365, None, "0.0000"),
]
# 全转 30000 份的折算锚点:转出金额 30000×1.3604 = 40812.00(费前,与 confirm 返回
# out_amount 同源),持有期落 (30,180) 档 → 赎回费 40812.00×0.50% = 204.06 单列 redeem_fee。
# core_trade 转出端流水 amount = 费前 40812.00(R-b 口径,与 PRD §5.3.2 示例 68020.00 同语义)。
OUT_AMOUNT = Decimal("40812.00")
REDEEM_FEE = Decimal("204.06")
PLAIN_AMOUNT = Decimal("100000")
@pytest.fixture()
def t1_env():
"""T+1 链路最小种子:C3 客户、转出 R2 债基(1 批 30000 份 + 持仓 30000)、转入 R4 股基。
两端同管理人同 TA(受理段硬约束);批次 confirmed_at=2026-07-01 → 持有期落
(30,180) 档 0.50%,赎回费数值确定(见 OUT_AMOUNT 注释),断言可逐字节对账。
"""
engine = create_sqlite_engine()
seed_suitability_matrix(engine) # C×R 矩阵是 check_suitability 的 L0 权威,缺失即全量 forbidden
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_customer (customer_id, display_name, age, is_active)"
" VALUES (:c, 'T11客户', 40, 1)"
),
{"c": CUST},
)
conn.execute(
text(
"INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at, expires_at)"
" VALUES (:c, 'C3', :t, :e)" # C3 → 转入 R4 放行;有效期覆盖 T 与 T+1(D25 复核)
),
{"c": CUST, "t": SUBMIT_AT - _dt.timedelta(days=30), "e": SUBMIT_AT + _dt.timedelta(days=300)},
)
conn.execute(
text(
"INSERT INTO core_product (product_id, product_name, min_risk_code, product_type,"
" can_subscribe, can_redeem, subscribe_fee_rate, min_redeem_qty, min_hold_qty,"
" fund_company, ta_code) VALUES"
" (:po, 'T11转出债基', 'R2', 'bond', 1, 1, 0.0030, 0, 0, :co, :ta),"
" (:pi, 'T11转入股基', 'R4', 'stock', 1, 1, 0.0080, 0, 0, :co, :ta)"
),
{"po": PROD_T11_OUT, "pi": PROD_T11_IN, "co": COMPANY, "ta": TA},
)
for lo, hi, rate in FEE_TIERS:
conn.execute(
text(
"INSERT INTO core_fee_rule (product_id, fee_type, min_hold_days,"
" max_hold_days, rate) VALUES (:p, 'redeem', :a, :b, :r)"
),
{"p": PROD_T11_OUT, "a": lo, "b": hi, "r": float(rate)},
)
conn.execute(
text(
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) VALUES"
" (:po, :no, 0, :d), (:pi, :ni, 0, :d)"
),
{"po": PROD_T11_OUT, "no": float(OUT_NAV), "pi": PROD_T11_IN, "ni": float(IN_NAV), "d": T_DAY},
)
conn.execute(
text(
"INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty, remain_qty,"
" nav, confirmed_at) VALUES (:l, :c, :p, 30000, 30000, 1.0300, :cat)"
),
{"l": "LOT-T11-1", "c": CUST, "p": PROD_T11_OUT, "cat": _dt.datetime(2026, 7, 1, 10, 0, 0)},
)
conn.execute(
text(
"INSERT INTO core_holding (customer_id, product_id, qty, cost_amount,"
" market_value, pnl_pct, as_of) VALUES (:c, :p, 30000, 30000, 40812.0, 0, :d)"
),
{"c": CUST, "p": PROD_T11_OUT, "d": T_DAY},
)
cursor = _dt.date(2026, 9, 1)
for _ in range(60):
if cursor.weekday() < 5: # 周一~周五开市;周末不插行 = 休市
conn.execute(
text("INSERT INTO core_trade_calendar (cal_date, is_open, remark) VALUES (:d, 1, 't')"),
{"d": cursor},
)
cursor += _dt.timedelta(days=1)
yield engine
engine.dispose()
def _t11_services(engine) -> dict:
return dict(
core_ro=CoreReadOnlyRepository(engine=engine),
risk_repo=RiskRepository(engine=engine),
convert_repo=ConvertRepository(engine=engine),
request_repo=ConvertRequestRepository(engine=engine),
)
def _quiet_th() -> RiskThresholds:
"""全阈值推到不可达:本组用例只关心 core_tools 查询口径,不让引擎出单添噪。"""
big = Decimal("999999999")
return RiskThresholds(
large_amount=big, daily_total=big, freq_count=999, probe_window_minutes=5,
probe_count=999, probe_amount=big, small_amount=Decimal("0.01"), small_count=999,
concentration_threshold=1.01,
)
def _accept_full(engine) -> dict:
"""受理**全转**(qty = 批次总量 30000):确认后转出端持仓必然归零(F-12 场景)。"""
return accept_convert(
{
"customer_id": CUST,
"from_product_id": PROD_T11_OUT,
"to_product_id": PROD_T11_IN,
"qty": Decimal("30000"),
"client_request_id": "T11-REQ-1",
},
now=SUBMIT_AT,
**_t11_services(engine),
)
def _confirm(engine, gid: str) -> dict:
return confirm_one(
gid,
now=CONFIRM_AT,
as_of=T1_DAY,
core_writer=ConvertCoreRepository(engine=engine),
thresholds=_quiet_th(),
**_t11_services(engine),
)
def _t11_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 test_confirm_then_sum_amount_counts_once(t1_env):
"""验收 11(确认段场景):真实受理→确认落两条同 gid 流水后,sum_amount 只计转出端。"""
engine = t1_env
gid = _accept_full(engine)["convert_group_id"]
res = _confirm(engine, gid)
assert res["status"] == "confirmed"
assert res["out_amount"] == "40812.00" # 折算锚点逐字节对账(30000×1.3604,费前)
assert res["redeem_fee"] == "204.06" # 持有期落 (30,180) 档 0.50% 的证明
# 同一确认日再落一笔无组普通赎回(无 convert_group_id → 金额全额计入)
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type, amount,"
" qty, convert_group_id, trade_status, traded_at) VALUES"
" ('TRD-T11-PLAIN', :cid, :pid, 'redeem', 100000.0, 80.0, NULL, 'confirmed', :t)"
),
{"cid": CUST, "pid": PROD_T11_OUT, "t": CONFIRM_AT},
)
repo = CoreReadOnlyRepository(engine=engine)
out = query_recent_trades(CUST, days=30, core_ro=repo)
# 明细全量:convert 两条(redeem+subscribe)+ 普通赎回 1 条 —— 一次转换两条是真实的
assert out["total_count"] == 3
assert sorted(r["trade_type"] for r in out["items"]) == ["redeem", "redeem", "subscribe"]
# 汇总只计转出端:40812.00 + 100000 = 140812.00;
# 转入端 in_amount 若被计入即翻倍(差额恰为一条转入流水)—— 用具体值才区分得开
assert Decimal(str(out["sum_amount"])) == OUT_AMOUNT + PLAIN_AMOUNT
# 跨口径一致性(T+1 确认场景):SQL 侧同日累计与 Tool 汇总一致(流水 traded_at = T+1)
assert repo.sum_trades_on_date(CUST, T1_DAY) == OUT_AMOUNT + PLAIN_AMOUNT
def test_confirm_full_transfer_holding_zero_row_excluded(t1_env):
"""F-12 / R-回归 13(确认段场景):全转后 `core_holding.qty=0` 行保留但不是持仓。"""
engine = t1_env
gid = _accept_full(engine)["convert_group_id"]
res = _confirm(engine, gid)
assert res["status"] == "confirmed"
# 台账留痕行仍在(D 决策:转出归零保留 qty=0 行,不物理删除)——
# 先证明行存在,后面的「不在持仓结果」断言才有区分度(否则可能是行被删了)
rows = _t11_rows(
engine,
"SELECT qty FROM core_holding WHERE customer_id = :c AND product_id = :p",
c=CUST, p=PROD_T11_OUT,
)
assert len(rows) == 1 and Decimal(str(rows[0]["qty"])) == 0
repo = CoreReadOnlyRepository(engine=engine)
# SQL 层(core_ro.list_holdings,`qty > 0` 过滤):只剩转入端持仓
listed = repo.list_holdings(CUST)
assert [r["product_id"] for r in listed] == [PROD_T11_IN]
assert Decimal(str(listed[0]["qty"])) == Decimal(res["in_qty"]) # 确认段两端持仓同事务落对
# Tool 层(core_tools.query_holdings)与 SQL 层同口径
holdings = query_holdings(CUST, core_ro=repo)
assert holdings["total_count"] == 1
assert [r["product_id"] for r in holdings["items"]] == [PROD_T11_IN]