Files
group_xinghuo_jinrong/tests/test_share_lot.py
T
GaoYiYuan_0626andWorkBuddy 3244922a55 基金转换 T-3:core_ro 新增 5 个只读方法 + share_lot_repository 读侧
- core_ro 新增 get_nav_as_of(D10) / get_redeem_fee_rules / list_share_lots(S1 确定性排序)
  / sum_remain_qty(仅 >0) / get_holding;不动 get_latest_nav 与 list_trades_range 白名单
- 新增 share_lot_repository:core_share_lot 读侧 FIFO 贪心选批 + 汇总,排序复用 core_ro 的 ORDER BY(D18 单一副本)
- 新增 tests/test_share_lot.py 15 用例;pytest 全量 624 passed / 3 skipped(基线 609,零回归)
- 同步开发计划 §5.1 执行记录与交接文档 §B 状态(v1.5 / T-3 ✅ / 基线 624)

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-09-10 15:42:55 +08:00

318 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-3 `core_ro` 五个新方法 + `share_lot_repository` 单测(开发计划 §5.1 DoD)。
sqlite 内存库(conftest.sqlite_engine 单一事实源建表 + C×R 矩阵种子),
数据由本文件自建,不依赖真 MySQL。
覆盖:
1. get_nav_as_of(D10:nav_date <= 交易日降序取 1)
2. get_redeem_fee_rules(仅 redeem 档、按持有期升序)
3. list_share_lots(S1:同 confirmed_at 以 lot_id 升序兜底、max_lots 截断)
4. sum_remain_qty(仅统计 remain_qty > 0,归零批不计入)
5. get_holding(单行 / 不存在 None)
6. ShareLotRepository.select_for_convert(FIFO 贪心选批、不足额返回全部可用)
"""
from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal
import pytest
from sqlalchemy import text
from app.repository.core_ro import CoreReadOnlyRepository, _as_date
from app.repository.share_lot_repository import ShareLotRepository
def _as_dt(value: object) -> datetime:
"""sqlite TIMESTAMP 读回为字符串、MySQL 为 datetime —— 测试内统一规范化。"""
if isinstance(value, datetime):
return value
return datetime.fromisoformat(str(value)[:19])
CUSTOMER = "CUST-T3"
PRODUCT_A = "PROD-T3A"
PRODUCT_B = "PROD-T3B"
def _seed_customer(engine, cid: str) -> None:
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_customer (customer_id, display_name) "
"VALUES (:cid, '测试客户')"
),
{"cid": cid},
)
def _seed_product(engine, pid: str, ptype: str = "bond") -> None:
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_product (product_id, product_name, min_risk_code, "
"product_type, can_subscribe, can_redeem) "
"VALUES (:pid, '测试产品', 'R2', :ptype, 1, 1)"
),
{"pid": pid, "ptype": ptype},
)
def _seed_lot(
engine, lot_id: str, cid: str, pid: str,
remain: str, nav: str, confirmed_at: datetime, qty: str | None = None,
) -> None:
# sqlite 不直接绑定 Decimal:统一转 float(与既有 Decimal(total) 读回约定一致)
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty, "
"remain_qty, nav, confirmed_at) "
"VALUES (:lot_id, :cid, :pid, :qty, :remain, :nav, :confirmed_at)"
),
{
"lot_id": lot_id, "cid": cid, "pid": pid,
"qty": float(qty or remain), "remain": float(remain),
"nav": float(nav), "confirmed_at": confirmed_at,
},
)
# ── 1. get_nav_as_of(D10) ────────────────────────────────────────────
def test_get_nav_as_of_returns_latest_on_or_before_trade_date(sqlite_engine):
d = date(2026, 9, 4)
repo = CoreReadOnlyRepository(engine=sqlite_engine)
with sqlite_engine.begin() as conn:
for nav_date, nav in [
(date(2026, 9, 2), 1.0100),
(date(2026, 9, 3), 1.0200),
(date(2026, 9, 5), 1.0300),
]:
conn.execute(
text(
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
"VALUES (:pid, :nav, 0, :nd)"
),
{"pid": PRODUCT_A, "nav": nav, "nd": nav_date},
)
# 落在 9-4:应取 9-3(<= 的最近一期)
row = repo.get_nav_as_of(PRODUCT_A, d)
assert row is not None
assert _as_date(row["nav_date"]) == date(2026, 9, 3)
assert row["nav"] == 1.0200
def test_get_nav_as_of_after_latest_returns_newest(sqlite_engine):
d = date(2026, 9, 10)
repo = CoreReadOnlyRepository(engine=sqlite_engine)
with sqlite_engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
"VALUES (:pid, :nav, 0, :nd)"
),
{"pid": PRODUCT_A, "nav": 1.0300, "nd": date(2026, 9, 5)},
)
row = repo.get_nav_as_of(PRODUCT_A, d)
assert row is not None
assert _as_date(row["nav_date"]) == date(2026, 9, 5)
def test_get_nav_as_of_no_earlier_nav_returns_none(sqlite_engine):
d = date(2026, 9, 1)
repo = CoreReadOnlyRepository(engine=sqlite_engine)
with sqlite_engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
"VALUES (:pid, :nav, 0, :nd)"
),
{"pid": PRODUCT_A, "nav": 1.0300, "nd": date(2026, 9, 5)},
)
assert repo.get_nav_as_of(PRODUCT_A, d) is None
# ── 2. get_redeem_fee_rules ────────────────────────────────────────────
def test_get_redeem_fee_rules_only_redeem_and_ordered(sqlite_engine):
repo = CoreReadOnlyRepository(engine=sqlite_engine)
with sqlite_engine.begin() as conn:
# 申购费档(本期不启用)应被排除
conn.execute(
text(
"INSERT INTO core_fee_rule (product_id, fee_type, min_hold_days, "
"max_hold_days, rate) VALUES (:pid, 'subscribe', 0, NULL, :rate)"
),
{"pid": PRODUCT_A, "rate": 0.0080},
)
# 赎回费五档(乱序插入,断言按 min_hold_days 升序返回)
for mh, mh_max, rate in [
(180, 365, "0.0025"), (0, 7, "0.0150"),
(30, 180, "0.0050"), (7, 30, "0.0100"), (365, None, "0.0000"),
]:
conn.execute(
text(
"INSERT INTO core_fee_rule (product_id, fee_type, min_hold_days, "
"max_hold_days, rate) VALUES (:pid, 'redeem', :mh, :mh_max, :rate)"
),
{"pid": PRODUCT_A, "mh": mh, "mh_max": mh_max, "rate": float(rate)},
)
rules = repo.get_redeem_fee_rules(PRODUCT_A)
assert len(rules) == 5
assert [r["min_hold_days"] for r in rules] == [0, 7, 30, 180, 365]
assert all(r["fee_type"] == "redeem" for r in rules)
def test_get_redeem_fee_rules_empty(sqlite_engine):
repo = CoreReadOnlyRepository(engine=sqlite_engine)
assert repo.get_redeem_fee_rules(PRODUCT_B) == []
# ── 3. list_share_lots(S1 确定性 + 截断) ─────────────────────────────
def test_list_share_lots_ordered_by_confirmed_at(sqlite_engine):
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
repo = CoreReadOnlyRepository(engine=sqlite_engine)
# 乱序确认时间插入
_seed_lot(sqlite_engine, "L3", CUSTOMER, PRODUCT_A, "50", "1.03",
datetime(2026, 9, 3, 10, 0, 0))
_seed_lot(sqlite_engine, "L1", CUSTOMER, PRODUCT_A, "100", "1.01",
datetime(2026, 9, 1, 10, 0, 0))
_seed_lot(sqlite_engine, "L2", CUSTOMER, PRODUCT_A, "80", "1.02",
datetime(2026, 9, 2, 10, 0, 0))
lots = repo.list_share_lots(CUSTOMER, PRODUCT_A)
assert [l["lot_id"] for l in lots] == ["L1", "L2", "L3"]
assert [_as_dt(l["confirmed_at"]) for l in lots] == [
datetime(2026, 9, 1, 10, 0, 0),
datetime(2026, 9, 2, 10, 0, 0),
datetime(2026, 9, 3, 10, 0, 0),
]
def test_list_share_lots_same_confirmed_at_tiebreak_by_lot_id(sqlite_engine):
"""S1:同一 confirmed_at 多批次以 lot_id 升序兜底,顺序可复现。"""
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
repo = CoreReadOnlyRepository(engine=sqlite_engine)
same = datetime(2026, 9, 1, 10, 0, 0)
# 故意让 lot_id 顺序与插入顺序相反
_seed_lot(sqlite_engine, "LOT-09", CUSTOMER, PRODUCT_A, "10", "1.00", same)
_seed_lot(sqlite_engine, "LOT-02", CUSTOMER, PRODUCT_A, "10", "1.00", same)
_seed_lot(sqlite_engine, "LOT-05", CUSTOMER, PRODUCT_A, "10", "1.00", same)
lots = repo.list_share_lots(CUSTOMER, PRODUCT_A)
assert [l["lot_id"] for l in lots] == ["LOT-02", "LOT-05", "LOT-09"]
def test_list_share_lots_max_lots_truncates(sqlite_engine):
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
repo = CoreReadOnlyRepository(engine=sqlite_engine)
for i in range(3):
_seed_lot(sqlite_engine, f"L{i}", CUSTOMER, PRODUCT_A, "10", "1.00",
datetime(2026, 9, 1 + i, 10, 0, 0))
assert len(repo.list_share_lots(CUSTOMER, PRODUCT_A, max_lots=2)) == 2
# ── 4. sum_remain_qty(仅 >0) ─────────────────────────────────────────
def test_sum_remain_qty_excludes_zeroed_lots(sqlite_engine):
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
repo = CoreReadOnlyRepository(engine=sqlite_engine)
# 已归零(D9 保留行)与正常批次混放
_seed_lot(sqlite_engine, "L1", CUSTOMER, PRODUCT_A, "100", "1.00",
datetime(2026, 9, 1, 10, 0, 0))
_seed_lot(sqlite_engine, "L2", CUSTOMER, PRODUCT_A, "0", "1.00",
datetime(2026, 9, 2, 10, 0, 0)) # 归零批
_seed_lot(sqlite_engine, "L3", CUSTOMER, PRODUCT_A, "50", "1.00",
datetime(2026, 9, 3, 10, 0, 0))
assert repo.sum_remain_qty(CUSTOMER, PRODUCT_A) == Decimal("150")
def test_sum_remain_qty_only_zeros_returns_zero(sqlite_engine):
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
repo = CoreReadOnlyRepository(engine=sqlite_engine)
_seed_lot(sqlite_engine, "L1", CUSTOMER, PRODUCT_A, "0", "1.00",
datetime(2026, 9, 1, 10, 0, 0))
assert repo.sum_remain_qty(CUSTOMER, PRODUCT_A) == Decimal("0")
# ── 5. get_holding ─────────────────────────────────────────────────────
def test_get_holding_returns_row(sqlite_engine):
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
repo = CoreReadOnlyRepository(engine=sqlite_engine)
with sqlite_engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_holding (customer_id, product_id, qty, cost_amount, "
"market_value, pnl_pct, as_of) VALUES (:cid, :pid, :qty, 0, 0, 0, :as_of)"
),
{"cid": CUSTOMER, "pid": PRODUCT_A, "qty": 100.0,
"as_of": date(2026, 9, 4)},
)
row = repo.get_holding(CUSTOMER, PRODUCT_A)
assert row is not None
assert row["product_id"] == PRODUCT_A
assert row["qty"] == 100.0
def test_get_holding_missing_returns_none(sqlite_engine):
_seed_customer(sqlite_engine, CUSTOMER)
repo = CoreReadOnlyRepository(engine=sqlite_engine)
assert repo.get_holding(CUSTOMER, PRODUCT_B) is None
# ── 6. ShareLotRepository.select_for_convert(FIFO 选批) ────────────────
def test_select_for_convert_fifo_partial_covers_first_lots(sqlite_engine):
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
_seed_lot(sqlite_engine, "L1", CUSTOMER, PRODUCT_A, "100", "1.0000",
datetime(2026, 9, 1, 10, 0, 0))
_seed_lot(sqlite_engine, "L2", CUSTOMER, PRODUCT_A, "50", "1.1000",
datetime(2026, 9, 2, 10, 0, 0))
repo = ShareLotRepository(engine=sqlite_engine)
selected = repo.select_for_convert(CUSTOMER, PRODUCT_A, Decimal("120"))
# 先吃满 L1(100),再从 L2 取 20
assert [s["lot_id"] for s in selected] == ["L1", "L2"]
assert selected[0]["qty"] == 100.0
assert selected[1]["qty"] == Decimal("20")
assert repo.available_qty(CUSTOMER, PRODUCT_A) == Decimal("150")
def test_select_for_convert_shortfall_returns_all_available(sqlite_engine):
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
_seed_lot(sqlite_engine, "L1", CUSTOMER, PRODUCT_A, "100", "1.0000",
datetime(2026, 9, 1, 10, 0, 0))
_seed_lot(sqlite_engine, "L2", CUSTOMER, PRODUCT_A, "50", "1.1000",
datetime(2026, 9, 2, 10, 0, 0))
repo = ShareLotRepository(engine=sqlite_engine)
selected = repo.select_for_convert(CUSTOMER, PRODUCT_A, Decimal("1000"))
# 不足额:返回全部可用批次,差额由调用方校验
assert [s["lot_id"] for s in selected] == ["L1", "L2"]
assert [s["qty"] for s in selected] == [100.0, Decimal("50")]
def test_select_for_convert_zero_qty_returns_empty(sqlite_engine):
repo = ShareLotRepository(engine=sqlite_engine)
assert repo.select_for_convert(CUSTOMER, PRODUCT_A, Decimal("0")) == []