Files
tick-stock-panel/frontend/src/lib/useQuoteStream.ts
T
shy3130 697c27bb02 feat(v0.2): 市场阶段与主线识别 + 因子挖掘全链路 + 数据层完善
- 市场环境: 新增情绪周期6阶段(冰点/启动/主升/高潮/退潮/修复, 连板梯队驱动,
  EMA平滑+2日确认+弱档否决, 平均段长9.7天)与概念/行业主线排名(涨停梯队聚合,
  可配置宽基/风格标签过滤); 市场环境页重构, regime 透明加列, 与5档state并存
- 挖掘: 因子与策略挖掘全链路(API/worker/进程锁/候选库/前端工作台/文档),
  周度调度默认关闭且永不自动发布
- 回测: 财务快照因子(点时口径), 批量回测预计算共享下期收益,
  信号路径矩阵列依赖展开修复(consecutive_limit_ups 缺列报错)
- 数据/性能: enriched 生成与预热治理, 重任务限流, 行情/K线缓存复用, 时区修复
- 测试: 后端全量 914 通过; GUI 黑盒验证截图存证 gui-test-screenshots/
2026-08-16 23:39:07 +08:00

242 lines
8.9 KiB
TypeScript

import { useEffect, useRef, useCallback, useSyncExternalStore } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { SSE_INVALIDATE_PREFIXES, QK } from './queryKeys'
import { getQueryConfig } from './useQueryConfig'
import { toast } from '@/components/Toast'
import { pushAlertToasts } from '@/components/AlertToast'
import { feedReviewEvent } from './reviewStore'
import type { StrategyAlertEvent } from './api'
// ===== 全局 SSE 连接状态 (模块级 store, 仿 AlertToast.tsx 模式) =====
// 实时行情 SSE 断开时 UI 无感知 → 会漏掉策略告警。这里暴露连接状态,
// 供 Layout 顶部渲染徽标、连续失败 N 次后弹一次 toast。
export type QuoteStreamStatus = 'connected' | 'reconnecting' | 'disconnected'
let _streamStatus: QuoteStreamStatus = 'disconnected'
const _statusListeners = new Set<() => void>()
// 连续失败到达该阈值后弹一次 toast (只弹一次, 恢复后重置)
const FAILS_BEFORE_TOAST = 3
// 指数退避上限
const BACKOFF_CAP_MS = 60_000
function _emitStatus() {
_statusListeners.forEach((fn) => fn())
}
function _setStatus(s: QuoteStreamStatus) {
if (_streamStatus === s) return
_streamStatus = s
_emitStatus()
}
function _subscribeStatus(fn: () => void) {
_statusListeners.add(fn)
return () => {
_statusListeners.delete(fn)
}
}
function _getStatus() {
return _streamStatus
}
/** React hook: 读取全局实时行情 SSE 连接状态 (供 Layout 徽标使用) */
export function useQuoteStreamStatus(): QuoteStreamStatus {
return useSyncExternalStore(_subscribeStatus, _getStatus, () => 'disconnected' as const)
}
// ===== 焦点股票注册表 (个股对话框用) =====
// 个股对话框打开时注册当前 symbol, SSE quotes_updated 推送时精准 invalidate
// 该 symbol 的日K查询 (['kline', symbol]), 让日K最后一根蜡烛随实时价变化。
// 不加进 SSE_INVALIDATE_PREFIXES 全局列表 —— 避免回测弹窗等也每秒重拉。
let _focusSymbol: string | null = null
/** 注册当前焦点股票 (个股对话框打开时调用)。 */
export function setFocusSymbol(symbol: string): void {
_focusSymbol = symbol
}
/** 清除焦点股票 (个股对话框关闭时调用)。 */
export function clearFocusSymbol(): void {
_focusSymbol = null
}
/**
* 全局 SSE hook: 监听后端行情更新推送 + 策略监控通知。
*
* - 行情更新 (quotes_updated): 根据 sseRefreshPages 配置过滤 invalidation
* - 策略监控通知 (strategy_alert): 通过 onAlert 回调弹 toast
*
* 应在顶层 Layout 中调用一次。
*/
export function useQuoteStream(
enabled: boolean,
sseRefreshPages: Record<string, boolean> | undefined,
onAlert?: (alerts: StrategyAlertEvent[]) => void,
) {
const qc = useQueryClient()
const esRef = useRef<EventSource | null>(null)
const retryRef = useRef<ReturnType<typeof setTimeout>>()
const pagesRef = useRef(sseRefreshPages)
pagesRef.current = sseRefreshPages
const handleAlerts = useCallback((alerts: StrategyAlertEvent[]) => {
// depth 系统接管通知: 单独处理, 不走 strategy 回调
const depthAlerts = alerts.filter(a => a.source === 'depth')
const strategyAlerts = alerts.filter(a => a.source !== 'depth')
// depth 通知直接 toast(防刷屏: 后端已在状态切换时才推)
for (const a of depthAlerts.slice(0, 1)) {
toast(a.message, 'success')
}
// 监控告警: 用专用 AlertToast (整批只响一声, 每条都弹, 受 maxVisible 上限保护)
if (strategyAlerts.length > 0) {
// 有 onAlert 回调时走回调, 否则弹 AlertToast
if (onAlert) {
onAlert(strategyAlerts)
}
// 批量弹通知 (去掉了 slice(0,2) 截断, 让每只新命中都弹 toast; 声音整批只响一次)
pushAlertToasts(strategyAlerts as any)
}
}, [onAlert])
const enabledRef = useRef(enabled)
enabledRef.current = enabled
useEffect(() => {
// SSE 始终连接 — 监控告警不依赖实时行情开关
// (quotes_updated 行情刷新受 enabled 控制, strategy_alert 始终处理)
// 连续失败计数 (用于指数退避 + 到阈值弹一次 toast)
let failCount = 0
let toastFired = false
const connect = () => {
_setStatus(failCount > 0 ? 'reconnecting' : _streamStatus)
const es = new EventSource('/api/intraday/stream')
esRef.current = es
es.onopen = () => {
// 连接成功: 重置退避与 toast 标记
failCount = 0
toastFired = false
_setStatus('connected')
}
// sse-starlette ping 心跳走 SSE comment,不会到达这里
es.addEventListener('quotes_updated', () => {
// 实时行情未开启时不处理行情刷新
if (!enabledRef.current) return
// 根据用户配置过滤 invalidation
const pages = pagesRef.current
if (pages) {
// 只 invalidate 开启的页面对应的 prefix
const activePrefixes = SSE_INVALIDATE_PREFIXES.filter((p) => {
// 'quote-status' 始终刷新 (全局状态)
if (p === 'quote-status') return true
// 兼容旧配置: 'watchlist' 拆成两个精确前缀后, 未单独设置时沿用旧 'watchlist' 开关
if (
(p === 'watchlist-quotes' || p === 'watchlist-enriched') &&
pages[p] === undefined
) {
return pages['watchlist'] !== false
}
return pages[p] !== false
})
qc.invalidateQueries({
predicate: (query) =>
activePrefixes.some(
(prefix) => String(query.queryKey[0]).startsWith(prefix),
),
})
} else {
// 无配置时全部刷新 (向后兼容)
qc.invalidateQueries({
predicate: (query) =>
SSE_INVALIDATE_PREFIXES.some(
(prefix) => String(query.queryKey[0]).startsWith(prefix),
),
})
}
// 焦点股票日K精准刷新: 个股对话框打开时, 日K最后一根蜡烛随实时价变化。
// 后端 _maybe_inject_live_candle 只读内存缓存, 不调 TickFlow, 秒级重拉零额外成本。
if (_focusSymbol) {
qc.invalidateQueries({ queryKey: ['kline', _focusSymbol] })
}
})
es.addEventListener('strategy_results_updated', () => {
// 策略监控完成后只刷新策略结果缓存,不扩散到其他行情页面。
qc.invalidateQueries({ queryKey: ['screener-cached'] })
})
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)
const alerts: StrategyAlertEvent[] = data.alerts || []
if (alerts.length > 0) {
handleAlerts(alerts)
// 实时刷新触发记录列表 + 监控中心徽标
qc.invalidateQueries({ queryKey: ['alerts'] })
qc.invalidateQueries({ queryKey: ['alerts-total'] })
}
} catch {
// 忽略解析错误
}
})
// 定时复盘流式进度: 后端到点生成时把 meta/delta/done 推来, 喂进 reviewStore
// 开着复盘页可看到「边生成边显示」, 切走再回来也能看到生成中/已生成
es.addEventListener('review_progress', (e: MessageEvent) => {
try {
const evt = JSON.parse(e.data)
feedReviewEvent(evt)
// done(后端已归档) → 刷新历史列表, 让新报告出现并可查看
if (evt.type === 'done') {
qc.invalidateQueries({ queryKey: QK.reviewReports })
}
} catch {
// 忽略解析错误
}
})
es.onerror = () => {
es.close()
esRef.current = null
failCount += 1
_setStatus('reconnecting')
// 连续失败到阈值 → 弹一次 toast (漏行情=可能漏策略告警, 需明确告知)
if (failCount >= FAILS_BEFORE_TOAST && !toastFired) {
toastFired = true
toast('实时连接已断开,正在重连…', 'error')
}
// 指数退避 (base * 2^(n-1), 上限 60s), 替代原来固定 5s
const base = getQueryConfig().sse.reconnectDelay
const delay = Math.min(base * 2 ** (failCount - 1), BACKOFF_CAP_MS)
retryRef.current = setTimeout(connect, delay)
}
}
connect()
return () => {
clearTimeout(retryRef.current)
if (esRef.current) {
esRef.current.close()
esRef.current = null
}
_setStatus('disconnected')
}
}, [qc, handleAlerts])
}