335 lines
15 KiB
Python
335 lines
15 KiB
Python
"""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]
|