From 5b2c88c93e306383c42f367299f6f80aa5e09598 Mon Sep 17 00:00:00 2001 From: wshy Date: Wed, 8 Jul 2026 13:42:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(depth):=20=E5=8D=88=E9=97=B4=E4=BC=91?= =?UTF-8?q?=E5=B8=82=E5=81=9C=E6=AD=A2=20sealed=20=E8=BD=AE=E8=AF=A2,=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=9B=86=E5=90=88=E7=AB=9E=E4=BB=B7=E7=9B=98?= =?UTF-8?q?=E5=8F=A3=E8=A6=86=E7=9B=96=E8=87=B4=E6=B6=A8=E5=81=9C=E8=AF=AF?= =?UTF-8?q?=E5=88=A4=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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, 仅工作日), 与 quote_service 同名方法窗口一致. 午间 11:30-13:00 停止轮询, _sealed_cache 定格在 11:30 的正确值供正常显示, quote_service 的价格轮询保持宽窗口 (合理, 盘前预热/收盘捕捉) 不受影响. is_sealed_ready 不改动: 数据已落盘/缓存, 午间正常连续运行时本就不会降级, 直接复用定格值即可. 新增 13 个测试覆盖连续竞价窗口边界 + 午间休市/集合竞价准备期/周末回归点, 并逐点校验与 quote_service._is_continuous_trading 一致. --- backend/app/services/depth_service.py | 21 ++- .../tests/test_depth_continuous_trading.py | 131 ++++++++++++++++++ 2 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_depth_continuous_trading.py 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}" + )