AIcoding 第 5 步 todo 开发(开发计划 v2.0)前半段: - T-1 3 张新表 DDL(core_convert_request 6 态 ENUM / core_trade_calendar / core_share_rule)+ 种子 + sqlite 单一事实源同步 + 列清单断言 - T-2/T-2b calc 扩展(product_round/redeem_amount/partial_qty)+ 真实净值实算回填 - T-3 convert_request_repository(6 态 + 条件 UPDATE 守卫)+ core_ro 三读方法 + share_lot_repository.available_qty_with_inflight(R-3 在途占用推导) - T-4 convert_repository.sync_mirror 成为 risk_convert_detail 唯一进度镜像写入口 (旧三方法标 Deprecated,T-7 后删) - T-5 locks.py 锁键构造器 convert_req_lock_key / convert_confirm_lock_key - T-6 convert_service.accept_convert 受理事务(八步:锁→幂等→校验→受理日顺延 →在途占用校验→落单→镜像+审计→202;不扣份额/不折算/不写流水) + tests/test_convert_accept.py(16 用例) + scripts/dev/verify_convert_accept.py(真库 36/36 一致) + trading_calendar.py 纯函数包(R-5)+ 21 用例 T-6 真库实测暴露并修复:confirm_eta 在日历数据边界抛 ValueError,会让已落库 的受理单在调用方眼里变 500;改为展示性字段容错 + 单测守护。 基线:798 passed / 10 skipped,零回归。
158 lines
5.9 KiB
Python
158 lines
5.9 KiB
Python
"""T-6 交易日历纯函数单测(R-5 · 开发计划 T-6)。
|
|
|
|
**不依赖 DB**:日历以谓词注入(`is_open: Callable[[date], bool]`),
|
|
用一个内存日期集合构造,穷举边界(周末/长假/截点/顺延)。
|
|
|
|
覆盖:
|
|
1. `is_biz_day` 谓词透传
|
|
2. `next_biz_day` 跨周末/跨长假;找不到 → ValueError
|
|
3. `previous_biz_day` 反向
|
|
4. `before_cutoff` 严格小于(15:00:00 整视为超时)
|
|
5. `resolve_accept_date` 四种组合(交易日/非交易日 × 截点前/后)
|
|
6. `parse_cutoff` 合法两式 + 非法格式/越界报错
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, datetime, time
|
|
|
|
import pytest
|
|
|
|
from app.service.convert import trading_calendar as tc
|
|
|
|
|
|
def _cal(open_dates: set[date]):
|
|
"""构造日历谓词(闭包,仅命中集合内日期)。"""
|
|
return lambda d: d in open_dates
|
|
|
|
|
|
#: 2026-09 交易日(周一~周五,剔除 9/6 周末等;手工枚举,与断言自洽)
|
|
SEP = {
|
|
date(2026, 9, 1), date(2026, 9, 2), date(2026, 9, 3), date(2026, 9, 4),
|
|
date(2026, 9, 7), date(2026, 9, 8), date(2026, 9, 9), date(2026, 9, 10),
|
|
date(2026, 9, 11),
|
|
}
|
|
|
|
|
|
# ── 1. is_biz_day ─────────────────────────────────────────────────────
|
|
|
|
def test_is_biz_day_passthrough():
|
|
cal = _cal(SEP)
|
|
assert tc.is_biz_day(date(2026, 9, 9), cal) is True
|
|
assert tc.is_biz_day(date(2026, 9, 5), cal) is False # 周六(不在集合)
|
|
|
|
|
|
# ── 2. next_biz_day ───────────────────────────────────────────────────
|
|
|
|
def test_next_biz_day_within_week():
|
|
cal = _cal(SEP)
|
|
# 周中 → 次日
|
|
assert tc.next_biz_day(date(2026, 9, 8), cal) == date(2026, 9, 9)
|
|
|
|
|
|
def test_next_biz_day_crosses_weekend():
|
|
cal = _cal(SEP)
|
|
# 周五 9/4 → 下周一 9/7(跳过 9/5 周六 / 9/6 周日)
|
|
assert tc.next_biz_day(date(2026, 9, 4), cal) == date(2026, 9, 7)
|
|
|
|
|
|
def test_next_biz_day_crosses_long_holiday():
|
|
"""跨长假:9/30 → 10/9(国庆 10/1~10/8 连休,全部不在集合)。"""
|
|
holiday = {date(2026, 9, 30), date(2026, 10, 9), date(2026, 10, 12)}
|
|
cal = _cal(holiday)
|
|
assert tc.next_biz_day(date(2026, 9, 30), cal) == date(2026, 10, 9)
|
|
|
|
|
|
def test_next_biz_day_not_found_raises():
|
|
cal = _cal(set()) # 空日历
|
|
with pytest.raises(ValueError, match="未找到交易日"):
|
|
tc.next_biz_day(date(2026, 9, 9), cal)
|
|
|
|
|
|
# ── 3. previous_biz_day ───────────────────────────────────────────────
|
|
|
|
def test_previous_biz_day_crosses_weekend():
|
|
cal = _cal(SEP)
|
|
# 周一 9/7 → 上周五 9/4
|
|
assert tc.previous_biz_day(date(2026, 9, 7), cal) == date(2026, 9, 4)
|
|
|
|
|
|
def test_previous_biz_day_not_found_raises():
|
|
cal = _cal(set())
|
|
with pytest.raises(ValueError, match="未找到交易日"):
|
|
tc.previous_biz_day(date(2026, 9, 9), cal)
|
|
|
|
|
|
# ── 4. before_cutoff ──────────────────────────────────────────────────
|
|
|
|
def test_before_cutoff_strict_less_than():
|
|
cutoff = time(15, 0)
|
|
assert tc.before_cutoff(datetime(2026, 9, 9, 14, 59, 59), cutoff) is True
|
|
# 15:00:00 整 → False(严格小于,超时边界)
|
|
assert tc.before_cutoff(datetime(2026, 9, 9, 15, 0, 0), cutoff) is False
|
|
assert tc.before_cutoff(datetime(2026, 9, 9, 15, 0, 1), cutoff) is False
|
|
assert tc.before_cutoff(datetime(2026, 9, 9, 9, 30), cutoff) is True
|
|
|
|
|
|
def test_before_cutoff_default_is_1500():
|
|
assert tc.before_cutoff(datetime(2026, 9, 9, 14, 0)) is True
|
|
assert tc.before_cutoff(datetime(2026, 9, 9, 16, 0)) is False
|
|
|
|
|
|
# ── 5. resolve_accept_date ────────────────────────────────────────────
|
|
|
|
def test_resolve_accept_date_biz_day_before_cutoff():
|
|
"""交易日 + 截点前 → 当日 T。"""
|
|
cal = _cal(SEP)
|
|
got = tc.resolve_accept_date(datetime(2026, 9, 9, 10, 12), cal)
|
|
assert got == date(2026, 9, 9)
|
|
|
|
|
|
def test_resolve_accept_date_biz_day_after_cutoff():
|
|
"""交易日 + 过截点 → 下一交易日。"""
|
|
cal = _cal(SEP)
|
|
got = tc.resolve_accept_date(datetime(2026, 9, 9, 15, 30), cal)
|
|
assert got == date(2026, 9, 10)
|
|
|
|
|
|
def test_resolve_accept_date_non_biz_day():
|
|
"""非交易日(周六)→ 下一交易日(下周一)。"""
|
|
cal = _cal(SEP)
|
|
got = tc.resolve_accept_date(datetime(2026, 9, 5, 10, 0), cal)
|
|
assert got == date(2026, 9, 7)
|
|
|
|
|
|
def test_resolve_accept_date_friday_after_cutoff():
|
|
"""周五过截点 → 下周一(跨周末)。"""
|
|
cal = _cal(SEP)
|
|
got = tc.resolve_accept_date(datetime(2026, 9, 4, 16, 0), cal)
|
|
assert got == date(2026, 9, 7)
|
|
|
|
|
|
def test_resolve_accept_date_custom_cutoff():
|
|
"""截点可配置(B1:值取自 settings.convert_cutoff_time)。"""
|
|
cal = _cal(SEP)
|
|
# 截点 12:00 时,14:00 提交 → 顺延次日
|
|
got = tc.resolve_accept_date(datetime(2026, 9, 9, 14, 0), cal, cutoff=time(12, 0))
|
|
assert got == date(2026, 9, 10)
|
|
|
|
|
|
# ── 6. parse_cutoff ───────────────────────────────────────────────────
|
|
|
|
def test_parse_cutoff_forms():
|
|
assert tc.parse_cutoff("15:00") == time(15, 0)
|
|
assert tc.parse_cutoff("15:00:00") == time(15, 0, 0)
|
|
assert tc.parse_cutoff(" 14:30 ") == time(14, 30) # 容忍空白
|
|
|
|
|
|
def test_parse_cutoff_invalid_format():
|
|
for bad in ("", "15", "15:00:00:00", "abc"):
|
|
with pytest.raises(ValueError):
|
|
tc.parse_cutoff(bad)
|
|
|
|
|
|
def test_parse_cutoff_out_of_range():
|
|
for bad in ("25:00", "12:99"):
|
|
with pytest.raises(ValueError):
|
|
tc.parse_cutoff(bad)
|