"""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)