fix(limit-ladder): 五档修正后实时刷新连板梯队封单 + 组内按封单排序

问题: 开启「修正」后后台 depth 轮询持续更新封单缓存, 但页面封单/个数不实时
变化。根因是 depth 轮询 (depth_service._poll_loop) 与行情 SSE 刷新是两条
断开链路 — depth 修正后不广播任何事件, 前端刷新完全依赖 quotes_updated
(仅行情轮询发送), 封单数据延迟或读旧值。

修复: 新增独立的 depth_updated SSE 通道。depth 轮询每次 _fetch_and_seal
成功后经 quote_service.notify_depth_updated 触发信号, intraday SSE
event_generator 并行等待三类信号 (行情/告警/修正), 推送 depth_updated;
前端 useQuoteStream 监听后 invalidate limit-ladder + overview-market。
该通道不受实时行情开关限制 (修正轮询独立于行情轮询)。

另: 连板梯队组内排序增强 — 涨停/跌停状态股票按封单从高到低排
(vol/amount 跟随 sealMode), 封单为 null 排末尾, 其他状态不动。
This commit is contained in:
shy3130
2026-06-23 14:00:29 +08:00
parent 3e79f94da8
commit 3bedd3e209
5 changed files with 91 additions and 24 deletions
+41 -23
View File
@@ -1,9 +1,10 @@
"""行情状态 / SSE 推送 API。
盘中选股相关端点已迁移至策略页面,此处仅保留全局行情基础设施。
SSE 推送种事件 (使用标准 SSE event 字段):
SSE 推送种事件 (使用标准 SSE event 字段):
- quotes_updated: 行情数据刷新,前端 invalidate 对应 query
- strategy_alert: 策略监控/告警触发,前端弹通知
- depth_updated: 五档盘口修正完成,前端刷新连板梯队/看板封单数据
"""
from __future__ import annotations
@@ -113,7 +114,7 @@ def index_quotes(
@router.get("/stream")
async def quote_stream(request: Request):
"""SSE 端点: 行情更新 + 告警推送。
"""SSE 端点: 行情更新 + 告警推送 + 五档修正
使用 sse-starlette EventSourceResponse:
- 标准 SSE event 字段,前端按 event name 监听
@@ -124,16 +125,21 @@ async def quote_stream(request: Request):
async def event_generator():
while True:
# 同时等待行情更新和告警
update_task = asyncio.ensure_future(
asyncio.to_thread(qs.wait_for_update, timeout=5.0) if qs else asyncio.sleep(5)
)
alert_task = asyncio.ensure_future(
asyncio.to_thread(qs.wait_for_alert, timeout=5.0) if qs else asyncio.sleep(5)
)
# 同时等待三类信号: 行情更新 / 告警 / 五档修正
tasks: dict[str, asyncio.Future] = {
"quote": asyncio.ensure_future(
asyncio.to_thread(qs.wait_for_update, timeout=5.0) if qs else asyncio.sleep(5)
),
"alert": asyncio.ensure_future(
asyncio.to_thread(qs.wait_for_alert, timeout=5.0) if qs else asyncio.sleep(5)
),
"depth": asyncio.ensure_future(
asyncio.to_thread(qs.wait_for_depth_update, timeout=5.0) if qs else asyncio.sleep(5)
),
}
done, pending = await asyncio.wait(
[update_task, alert_task],
list(tasks.values()),
timeout=30.0,
return_when=asyncio.FIRST_COMPLETED,
)
@@ -154,22 +160,34 @@ async def quote_stream(request: Request):
}, ensure_ascii=False),
}
# 推送行情更新
update_result = None
for t in done:
# 推送行情更新 (行情信号触发)
if tasks["quote"] in done:
try:
update_result = t.result()
update_result = tasks["quote"].result()
except Exception: # noqa: BLE001
pass
update_result = False
if update_result:
yield {
"event": "quotes_updated",
"data": json.dumps({
"ts": int(time.time() * 1000),
"symbol_count": qs._symbol_count if qs else 0,
}),
}
if update_result:
yield {
"event": "quotes_updated",
"data": json.dumps({
"ts": int(time.time() * 1000),
"symbol_count": qs._symbol_count if qs else 0,
}),
}
# 推送五档修正完成 (depth 信号触发) — 前端刷新连板梯队封单数据
if tasks["depth"] in done:
try:
depth_result = tasks["depth"].result()
except Exception: # noqa: BLE001
depth_result = False
if depth_result:
yield {
"event": "depth_updated",
"data": json.dumps({
"ts": int(time.time() * 1000),
}),
}
return EventSourceResponse(event_generator())
+15
View File
@@ -255,6 +255,9 @@ class DepthService:
len(new_cache), len(syms_up), len(syms_down),
enriched_date, " → 落盘" if persist else "")
# 缓存已更新: 通知 SSE 推 depth_updated, 触发连板梯队刷新封单数据。
self._notify_depth_updated(len(new_cache))
if persist and enriched_date:
self._persist(enriched_date)
@@ -533,6 +536,18 @@ class DepthService:
except Exception as e: # noqa: BLE001
logger.debug("depth 接管通知推送失败: %s", e)
def _notify_depth_updated(self, count: int) -> None:
"""修正完成通知: set quote_service._depth_update_event, SSE 推 depth_updated 刷新连板梯队。"""
if not self._app_state:
return
qs = getattr(self._app_state, "quote_service", None)
if not qs:
return
try:
qs.notify_depth_updated()
except Exception as e: # noqa: BLE001
logger.debug("depth 更新通知推送失败: %s", e)
# ================================================================
# 工具
# ================================================================
+13
View File
@@ -56,6 +56,7 @@ class QuoteService:
self._repo = None # 延迟注入, 避免循环导入
self._update_event = threading.Event() # SSE 通知: 行情更新后 set
self._alert_event = threading.Event() # SSE 通知: 有告警时 set
self._depth_update_event = threading.Event() # SSE 通知: depth 五档修正后 set (刷新连板梯队)
self._pending_alerts: list[dict] = [] # 待推送的告警
self._max_pending_alerts: int = 1000 # 背压上限: 超出丢弃最旧
self._strategy_monitor = None # 延迟注入
@@ -152,6 +153,18 @@ class QuoteService:
self._alert_event.clear()
return self._alert_event.wait(timeout=timeout)
def notify_depth_updated(self) -> None:
"""五档盘口修正完成后调用: 通知 SSE 推送 depth_updated, 触发连板梯队刷新。
与行情/告警通道独立 — 只刷新连板梯队, 不连带刷新 watchlist 等。
"""
self._depth_update_event.set()
def wait_for_depth_update(self, timeout: float = 30.0) -> bool:
"""阻塞等待 depth 修正 (供 SSE 线程使用)。"""
self._depth_update_event.clear()
return self._depth_update_event.wait(timeout=timeout)
def pop_alerts(self) -> list[dict]:
"""取走所有待推送的告警 (线程安全)。"""
with self._lock:
+7
View File
@@ -88,6 +88,13 @@ export function useQuoteStream(
}
})
es.addEventListener('depth_updated', () => {
// 五档修正完成: 刷新连板梯队 + 看板封单数据。
// 不受实时行情开关限制 — 修正轮询独立于行情轮询, 用户开了修正就想看实时封单。
qc.invalidateQueries({ queryKey: ['limit-ladder'] })
qc.invalidateQueries({ queryKey: ['overview-market'] })
})
es.addEventListener('strategy_alert', (e: MessageEvent) => {
try {
const data = JSON.parse(e.data)
+15 -1
View File
@@ -762,7 +762,21 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
if (s === 'broken' || s === 'recovery') return 1
return 2
}
return ord(a.status ?? '') - ord(b.status ?? '')
const oa = ord(a.status ?? '')
const ob = ord(b.status ?? '')
if (oa !== ob) return oa - ob
// 同状态(主状态=涨停/跌停)内: 按封单从高到低排, 无封单排末尾。
// 封单额 = sealed_vol(手) × 100 × close, 与展示口径一致。
if (oa === 0) {
const sealVal = (s: typeof a) => {
if (s.sealed_vol == null) return -1
return sealMode === 'amount' && s.close
? s.sealed_vol * 100 * s.close
: s.sealed_vol
}
return sealVal(b) - sealVal(a)
}
return 0
}).map(s => (
<StockCard
key={`${s.symbol}-${s.status}`}