318 lines
13 KiB
Python
318 lines
13 KiB
Python
"""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")) == []
|