fix(depth): 午间休市停止 sealed 轮询, 修复集合竞价盘口覆盖致涨停误判 (#73)

_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 一致.
This commit is contained in:
wshy
2026-07-08 13:42:53 +08:00
committed by GitHub
parent 0bc5ff1831
commit 5b2c88c93e
2 changed files with 150 additions and 2 deletions
+19 -2
View File
@@ -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)
@@ -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}"
)