diff --git a/backend/app/services/depth_service.py b/backend/app/services/depth_service.py index dd24faf..51e503f 100644 --- a/backend/app/services/depth_service.py +++ b/backend/app/services/depth_service.py @@ -429,10 +429,10 @@ class DepthService: """盘中轮询: 按 capset 自适应间隔拉 depth, 更新内存缓存。""" while self._running: try: - if self._is_trading_hours(): + if self._is_continuous_trading(): self._poll_once() else: - logger.debug("depth sealed: 非交易时段, 跳过") + logger.debug("depth sealed: 非连续竞价时段, 跳过(避免集合竞价盘口覆盖 11:30 定格值)") except Exception as e: # noqa: BLE001 logger.warning("depth sealed 轮询异常: %s", e) @@ -583,3 +583,20 @@ class DepthService: morning = dt_time(9, 25) <= t <= dt_time(11, 35) afternoon = dt_time(12, 55) <= t <= dt_time(15, 5) return now.weekday() < 5 and (morning or afternoon) + + @staticmethod + def _is_continuous_trading() -> bool: + """A股连续竞价时段(北京时间): 9:30-11:30 / 13:00-15:00, 仅工作日。 + + 比 _is_trading_hours 严格: 排除午间休市前后(11:30-13:00)。 + depth sealed 轮询用此窗口而非宽窗口, 关键原因: + 12:55-13:00 午后集合竞价准备期, ask1/bid1 盘口语义与连续竞价不同, + 「涨停价上卖一==0」的真封判定在此期间失效, 会用竞价盘口覆盖 11:30 + 已定格的正确 sealed 值, 导致涨停股误判为 sealed=False(假涨停)被错误扣减。 + 与 quote_service._is_continuous_trading 窗口定义保持一致。 + """ + now = cn_now() + t = now.time() + morning = dt_time(9, 30) <= t <= dt_time(11, 30) + afternoon = dt_time(13, 0) <= t <= dt_time(15, 0) + return now.weekday() < 5 and (morning or afternoon) diff --git a/backend/tests/test_depth_continuous_trading.py b/backend/tests/test_depth_continuous_trading.py new file mode 100644 index 0000000..a934203 --- /dev/null +++ b/backend/tests/test_depth_continuous_trading.py @@ -0,0 +1,131 @@ +"""depth_service 连续竞价窗口测试 (午间休市涨跌停误判回归). + +回归点: _poll_loop 原用 _is_trading_hours() (宽窗口 9:25-11:35 / 12:55-15:05), +12:55-13:00 午后集合竞价准备期会恢复拉 depth, 此时 ask1/bid1 竞价盘口语义使 +「涨停价上卖一==0」真封判定失效, 覆盖 11:30 已定格的正确 sealed 值, 导致 +涨停股误判为 sealed=False (假涨停) 被错误扣减。 + +修复: depth sealed 轮询改用 _is_continuous_trading() (9:30-11:30 / 13:00-15:00), +午间 11:30-13:00 停止轮询, 缓存保持 11:30 正确值。窗口定义须与 +quote_service._is_continuous_trading 一致。 +""" +from __future__ import annotations + +from datetime import datetime +from unittest.mock import patch + +from app.market_time import CN_TZ +from app.services import depth_service +from app.services.depth_service import DepthService + + +def _cn_now_at(hour: int, minute: int, weekday: int = 0): + """构造一个北京时间固定时刻的 cn_now 替身。weekday: 0=周一 ... 6=周日。""" + # 2024-01-01 是周一, 据此定位目标 weekday 的日期 + day = 1 + ((weekday - datetime(2024, 1, 1).weekday()) % 7) + fixed = datetime(2024, 1, day, hour, minute, tzinfo=CN_TZ) + + def _fn(): + return fixed + + return _fn + + +def _set_cn_now(hour: int, minute: int, weekday: int = 0): + """patch depth_service 模块内的 cn_now 到固定时刻, 返回 patcher。""" + return patch.object(depth_service, "cn_now", _cn_now_at(hour, minute, weekday)) + + +# ── 连续竞价窗口边界 (应返回 True) ─────────────────────────────────── +def test_morning_open_930_is_continuous(): + with _set_cn_now(9, 30, weekday=0): + assert DepthService._is_continuous_trading() is True + + +def test_morning_close_1130_is_continuous(): + """11:30 早盘最后一刻, 仍连续竞价, 应拉 depth 定格正确值。""" + with _set_cn_now(11, 30, weekday=0): + assert DepthService._is_continuous_trading() is True + + +def test_afternoon_open_1300_is_continuous(): + with _set_cn_now(13, 0, weekday=0): + assert DepthService._is_continuous_trading() is True + + +def test_afternoon_close_1500_is_continuous(): + with _set_cn_now(15, 0, weekday=0): + assert DepthService._is_continuous_trading() is True + + +# ── 回归关键点: 午间休市 + 集合竞价准备期必须停止轮询 (应返回 False) ── +def test_lunch_break_1200_not_continuous(): + """12:00 午间休市, 必须停止轮询。""" + with _set_cn_now(12, 0, weekday=0): + assert DepthService._is_continuous_trading() is False + + +def test_auction_prep_1255_not_continuous(): + """12:55 午后集合竞价准备期, 必须停止轮询 (最关键回归点)。 + + 这是原 bug 的根因时刻: 宽窗口在此恢复拉 depth, 竞价盘口覆盖 11:30 正确值。 + """ + with _set_cn_now(12, 55, weekday=0): + assert DepthService._is_continuous_trading() is False + + +def test_just_before_open_1259_not_continuous(): + """12:59, 13:00 开盘前 1 分钟, 仍属集合竞价准备期, 不可拉。""" + with _set_cn_now(12, 59, weekday=0): + assert DepthService._is_continuous_trading() is False + + +def test_just_after_morning_close_1131_not_continuous(): + """11:31 早盘刚收盘, 不可拉 (定格 11:30)。""" + with _set_cn_now(11, 31, weekday=0): + assert DepthService._is_continuous_trading() is False + + +def test_morning_auction_925_not_continuous(): + """9:25 开盘集合竞价, 非连续竞价, 不可拉 (避免指示价误判)。""" + with _set_cn_now(9, 25, weekday=0): + assert DepthService._is_continuous_trading() is False + + +def test_after_close_1505_not_continuous(): + """15:05 收盘后, 不可拉。""" + with _set_cn_now(15, 5, weekday=0): + assert DepthService._is_continuous_trading() is False + + +# ── 周末全天不交易 ──────────────────────────────────────────────────── +def test_weekend_saturday_not_trading(): + """10:30 周六, 落在连续竞价时间区间内, 但周末不交易。""" + with _set_cn_now(10, 30, weekday=5): + assert DepthService._is_continuous_trading() is False + + +def test_weekend_sunday_not_trading(): + """14:00 周日, 落在连续竞价时间区间内, 但周末不交易。""" + with _set_cn_now(14, 0, weekday=6): + assert DepthService._is_continuous_trading() is False + + +# ── 与 quote_service 窗口定义一致性 ────────────────────────────────── +def test_window_matches_quote_service(): + """depth_service._is_continuous_trading 必须与 quote_service 同名方法逐点一致。 + + 同时 patch 两个模块各自的 cn_now 引用, 确保比较的是同一时刻下的窗口判定。 + """ + from app.services import quote_service + from app.services.quote_service import QuoteService + + for weekday in range(7): + for hour in range(0, 24): + for minute in (0, 30): + fn = _cn_now_at(hour, minute, weekday) + with patch.object(depth_service, "cn_now", fn), \ + patch.object(quote_service, "cn_now", fn): + assert DepthService._is_continuous_trading() == QuoteService._is_continuous_trading(), ( + f"窗口不一致 @ weekday={weekday} {hour:02d}:{minute:02d}" + )