/** * 实时行情 Hook * 管理上交所和深交所 WebSocket 连接,获取实时行情数据 * * 连接方式: * - 生产环境 (HTTPS): 通过 Nginx 代理使用 wss:// (如 wss://valuefrontier.cn/ws/sse) * - 开发环境 (HTTP): 直连 ws:// * * 上交所 (SSE): 需主动订阅,提供五档行情 * 深交所 (SZSE): 自动推送,提供十档行情 */ import { useState, useEffect, useRef, useCallback } from 'react'; import { logger } from '@utils/logger'; import { WS_CONFIG, HEARTBEAT_INTERVAL, RECONNECT_INTERVAL } from './constants'; import { getExchange, normalizeCode, extractOrderBook, calcChangePct } from './utils'; import type { Exchange, ConnectionStatus, QuotesMap, QuoteData, SSEMessage, SSEQuoteItem, SZSEMessage, SZSERealtimeMessage, SZSESnapshotMessage, SZSEStockData, SZSEIndexData, SZSEBondData, SZSEHKStockData, SZSEAfterhoursData, UseRealtimeQuoteReturn, } from '../types'; /** * 处理上交所消息 */ const handleSSEMessage = ( msg: SSEMessage, subscribedCodes: Set, prevQuotes: QuotesMap ): QuotesMap | null => { if (msg.type !== 'stock' && msg.type !== 'index') { return null; } const data = msg.data || {}; const updated: QuotesMap = { ...prevQuotes }; let hasUpdate = false; Object.entries(data).forEach(([code, quote]: [string, SSEQuoteItem]) => { if (subscribedCodes.has(code)) { hasUpdate = true; updated[code] = { code: quote.security_id, name: quote.security_name, price: quote.last_price, prevClose: quote.prev_close, open: quote.open_price, high: quote.high_price, low: quote.low_price, volume: quote.volume, amount: quote.amount, change: quote.last_price - quote.prev_close, changePct: calcChangePct(quote.last_price, quote.prev_close), bidPrices: quote.bid_prices || [], bidVolumes: quote.bid_volumes || [], askPrices: quote.ask_prices || [], askVolumes: quote.ask_volumes || [], updateTime: quote.trade_time, exchange: 'SSE', } as QuoteData; } }); return hasUpdate ? updated : null; }; /** * 处理深交所实时消息 */ const handleSZSERealtimeMessage = ( msg: SZSERealtimeMessage, subscribedCodes: Set, prevQuotes: QuotesMap ): QuotesMap | null => { const { category, data, timestamp } = msg; const code = data.security_id; if (!subscribedCodes.has(code)) { return null; } const updated: QuotesMap = { ...prevQuotes }; switch (category) { case 'stock': { const stockData = data as SZSEStockData; const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(stockData.bids); const { prices: askPrices, volumes: askVolumes } = extractOrderBook(stockData.asks); updated[code] = { code, name: prevQuotes[code]?.name || '', price: stockData.last_px, prevClose: stockData.prev_close, open: stockData.open_px, high: stockData.high_px, low: stockData.low_px, volume: stockData.volume, amount: stockData.amount, numTrades: stockData.num_trades, upperLimit: stockData.upper_limit, lowerLimit: stockData.lower_limit, change: stockData.last_px - stockData.prev_close, changePct: calcChangePct(stockData.last_px, stockData.prev_close), bidPrices, bidVolumes, askPrices, askVolumes, tradingPhase: stockData.trading_phase, updateTime: timestamp, exchange: 'SZSE', } as QuoteData; break; } case 'index': { const indexData = data as SZSEIndexData; updated[code] = { code, name: prevQuotes[code]?.name || '', price: indexData.current_index, prevClose: indexData.prev_close, open: indexData.open_index, high: indexData.high_index, low: indexData.low_index, close: indexData.close_index, volume: indexData.volume, amount: indexData.amount, numTrades: indexData.num_trades, change: indexData.current_index - indexData.prev_close, changePct: calcChangePct(indexData.current_index, indexData.prev_close), bidPrices: [], bidVolumes: [], askPrices: [], askVolumes: [], tradingPhase: indexData.trading_phase, updateTime: timestamp, exchange: 'SZSE', } as QuoteData; break; } case 'bond': { const bondData = data as SZSEBondData; updated[code] = { code, name: prevQuotes[code]?.name || '', price: bondData.last_px, prevClose: bondData.prev_close, open: bondData.open_px, high: bondData.high_px, low: bondData.low_px, volume: bondData.volume, amount: bondData.amount, numTrades: bondData.num_trades, weightedAvgPx: bondData.weighted_avg_px, change: bondData.last_px - bondData.prev_close, changePct: calcChangePct(bondData.last_px, bondData.prev_close), bidPrices: [], bidVolumes: [], askPrices: [], askVolumes: [], tradingPhase: bondData.trading_phase, updateTime: timestamp, exchange: 'SZSE', isBond: true, } as QuoteData; break; } case 'hk_stock': { const hkData = data as SZSEHKStockData; const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(hkData.bids); const { prices: askPrices, volumes: askVolumes } = extractOrderBook(hkData.asks); updated[code] = { code, name: prevQuotes[code]?.name || '', price: hkData.last_px, prevClose: hkData.prev_close, open: hkData.open_px, high: hkData.high_px, low: hkData.low_px, volume: hkData.volume, amount: hkData.amount, numTrades: hkData.num_trades, nominalPx: hkData.nominal_px, referencePx: hkData.reference_px, change: hkData.last_px - hkData.prev_close, changePct: calcChangePct(hkData.last_px, hkData.prev_close), bidPrices, bidVolumes, askPrices, askVolumes, tradingPhase: hkData.trading_phase, updateTime: timestamp, exchange: 'SZSE', isHK: true, } as QuoteData; break; } case 'afterhours_block': case 'afterhours_trading': { const afterhoursData = data as SZSEAfterhoursData; const existing = prevQuotes[code]; if (existing) { updated[code] = { ...existing, afterhours: { bidPx: afterhoursData.bid_px, bidSize: afterhoursData.bid_size, offerPx: afterhoursData.offer_px, offerSize: afterhoursData.offer_size, volume: afterhoursData.volume, amount: afterhoursData.amount, numTrades: afterhoursData.num_trades || 0, }, updateTime: timestamp, } as QuoteData; } break; } default: return null; } return updated; }; /** * 处理深交所快照消息 */ const handleSZSESnapshotMessage = ( msg: SZSESnapshotMessage, subscribedCodes: Set, prevQuotes: QuotesMap ): QuotesMap | null => { const { stocks = [], indexes = [], bonds = [] } = msg.data || {}; const updated: QuotesMap = { ...prevQuotes }; let hasUpdate = false; stocks.forEach((s: SZSEStockData) => { if (subscribedCodes.has(s.security_id)) { hasUpdate = true; const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(s.bids); const { prices: askPrices, volumes: askVolumes } = extractOrderBook(s.asks); updated[s.security_id] = { code: s.security_id, name: '', price: s.last_px, prevClose: s.prev_close, open: s.open_px, high: s.high_px, low: s.low_px, volume: s.volume, amount: s.amount, numTrades: s.num_trades, upperLimit: s.upper_limit, lowerLimit: s.lower_limit, change: s.last_px - s.prev_close, changePct: calcChangePct(s.last_px, s.prev_close), bidPrices, bidVolumes, askPrices, askVolumes, exchange: 'SZSE', } as QuoteData; } }); indexes.forEach((i: SZSEIndexData) => { if (subscribedCodes.has(i.security_id)) { hasUpdate = true; updated[i.security_id] = { code: i.security_id, name: '', price: i.current_index, prevClose: i.prev_close, open: i.open_index, high: i.high_index, low: i.low_index, volume: i.volume, amount: i.amount, numTrades: i.num_trades, change: i.current_index - i.prev_close, changePct: calcChangePct(i.current_index, i.prev_close), bidPrices: [], bidVolumes: [], askPrices: [], askVolumes: [], exchange: 'SZSE', } as QuoteData; } }); bonds.forEach((b: SZSEBondData) => { if (subscribedCodes.has(b.security_id)) { hasUpdate = true; updated[b.security_id] = { code: b.security_id, name: '', price: b.last_px, prevClose: b.prev_close, open: b.open_px, high: b.high_px, low: b.low_px, volume: b.volume, amount: b.amount, change: b.last_px - b.prev_close, changePct: calcChangePct(b.last_px, b.prev_close), bidPrices: [], bidVolumes: [], askPrices: [], askVolumes: [], exchange: 'SZSE', isBond: true, } as QuoteData; } }); return hasUpdate ? updated : null; }; /** * 实时行情 Hook * @param codes - 订阅的证券代码列表 */ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn => { const [quotes, setQuotes] = useState({}); const [connected, setConnected] = useState({ SSE: false, SZSE: false }); const wsRefs = useRef>({ SSE: null, SZSE: null }); const heartbeatRefs = useRef>({ SSE: null, SZSE: null }); const reconnectRefs = useRef>({ SSE: null, SZSE: null }); const subscribedCodes = useRef>>({ SSE: new Set(), SZSE: new Set(), }); const stopHeartbeat = useCallback((exchange: Exchange) => { if (heartbeatRefs.current[exchange]) { clearInterval(heartbeatRefs.current[exchange]!); heartbeatRefs.current[exchange] = null; } }, []); const startHeartbeat = useCallback((exchange: Exchange) => { stopHeartbeat(exchange); heartbeatRefs.current[exchange] = setInterval(() => { const ws = wsRefs.current[exchange]; if (ws && ws.readyState === WebSocket.OPEN) { const msg = exchange === 'SSE' ? { action: 'ping' } : { type: 'ping' }; ws.send(JSON.stringify(msg)); } }, HEARTBEAT_INTERVAL); }, [stopHeartbeat]); const handleMessage = useCallback((exchange: Exchange, msg: SSEMessage | SZSEMessage) => { if (msg.type === 'pong') return; if (exchange === 'SSE') { const result = handleSSEMessage( msg as SSEMessage, subscribedCodes.current.SSE, {} // Will be merged with current state ); if (result) { setQuotes(prev => ({ ...prev, ...result })); } } else { if (msg.type === 'realtime') { setQuotes(prev => { const result = handleSZSERealtimeMessage( msg as SZSERealtimeMessage, subscribedCodes.current.SZSE, prev ); return result || prev; }); } else if (msg.type === 'snapshot') { setQuotes(prev => { const result = handleSZSESnapshotMessage( msg as SZSESnapshotMessage, subscribedCodes.current.SZSE, prev ); return result || prev; }); } } }, []); const createConnection = useCallback((exchange: Exchange) => { // 防御性检查:确保 HTTPS 页面不会意外连接 ws://(Mixed Content 安全错误) // 正常情况下 WS_CONFIG 会自动根据协议返回正确的 URL,这里是备用保护 const isHttps = typeof window !== 'undefined' && window.location.protocol === 'https:'; const wsUrl = WS_CONFIG[exchange]; const isInsecureWs = wsUrl.startsWith('ws://'); if (isHttps && isInsecureWs) { logger.warn( 'FlexScreen', `${exchange} WebSocket 配置错误:HTTPS 页面尝试连接 ws:// 端点,请检查 Nginx 代理配置` ); return; } if (wsRefs.current[exchange]) { wsRefs.current[exchange]!.close(); } try { const ws = new WebSocket(wsUrl); wsRefs.current[exchange] = ws; ws.onopen = () => { logger.info('FlexScreen', `${exchange} WebSocket 已连接`); setConnected(prev => ({ ...prev, [exchange]: true })); if (exchange === 'SSE') { const codes = Array.from(subscribedCodes.current.SSE); if (codes.length > 0) { ws.send(JSON.stringify({ action: 'subscribe', channels: ['stock', 'index'], codes, })); } } startHeartbeat(exchange); }; ws.onmessage = (event: MessageEvent) => { try { const msg = JSON.parse(event.data); handleMessage(exchange, msg); } catch (e) { logger.warn('FlexScreen', `${exchange} 消息解析失败`, e); } }; ws.onerror = (error: Event) => { logger.error('FlexScreen', `${exchange} WebSocket 错误`, error); }; ws.onclose = () => { logger.info('FlexScreen', `${exchange} WebSocket 断开`); setConnected(prev => ({ ...prev, [exchange]: false })); stopHeartbeat(exchange); // 自动重连(仅在非 HTTPS + ws:// 场景下) if (!reconnectRefs.current[exchange] && subscribedCodes.current[exchange].size > 0) { reconnectRefs.current[exchange] = setTimeout(() => { reconnectRefs.current[exchange] = null; if (subscribedCodes.current[exchange].size > 0) { createConnection(exchange); } }, RECONNECT_INTERVAL); } }; } catch (e) { logger.error('FlexScreen', `${exchange} WebSocket 连接失败`, e); setConnected(prev => ({ ...prev, [exchange]: false })); } }, [startHeartbeat, stopHeartbeat, handleMessage]); const subscribe = useCallback((code: string) => { const baseCode = normalizeCode(code); const exchange = getExchange(code); subscribedCodes.current[exchange].add(baseCode); const ws = wsRefs.current[exchange]; if (exchange === 'SSE' && ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ action: 'subscribe', channels: ['stock', 'index'], codes: [baseCode], })); } if (!ws || ws.readyState !== WebSocket.OPEN) { createConnection(exchange); } }, [createConnection]); const unsubscribe = useCallback((code: string) => { const baseCode = normalizeCode(code); const exchange = getExchange(code); subscribedCodes.current[exchange].delete(baseCode); setQuotes(prev => { const updated = { ...prev }; delete updated[baseCode]; return updated; }); if (subscribedCodes.current[exchange].size === 0) { const ws = wsRefs.current[exchange]; if (ws) { ws.close(); wsRefs.current[exchange] = null; } } }, []); // 初始化和 codes 变化处理 useEffect(() => { if (!codes || codes.length === 0) return; const newSseCodes = new Set(); const newSzseCodes = new Set(); codes.forEach(code => { const baseCode = normalizeCode(code); const exchange = getExchange(code); if (exchange === 'SSE') { newSseCodes.add(baseCode); } else { newSzseCodes.add(baseCode); } }); // 更新上交所订阅 const oldSseCodes = subscribedCodes.current.SSE; const sseToAdd = [...newSseCodes].filter(c => !oldSseCodes.has(c)); if (sseToAdd.length > 0 || newSseCodes.size !== oldSseCodes.size) { subscribedCodes.current.SSE = newSseCodes; const ws = wsRefs.current.SSE; if (ws && ws.readyState === WebSocket.OPEN && sseToAdd.length > 0) { ws.send(JSON.stringify({ action: 'subscribe', channels: ['stock', 'index'], codes: sseToAdd, })); } if (sseToAdd.length > 0 && (!ws || ws.readyState !== WebSocket.OPEN)) { createConnection('SSE'); } if (newSseCodes.size === 0 && ws) { ws.close(); wsRefs.current.SSE = null; } } // 更新深交所订阅 const oldSzseCodes = subscribedCodes.current.SZSE; const szseToAdd = [...newSzseCodes].filter(c => !oldSzseCodes.has(c)); if (szseToAdd.length > 0 || newSzseCodes.size !== oldSzseCodes.size) { subscribedCodes.current.SZSE = newSzseCodes; const ws = wsRefs.current.SZSE; if (szseToAdd.length > 0 && (!ws || ws.readyState !== WebSocket.OPEN)) { createConnection('SZSE'); } if (newSzseCodes.size === 0 && ws) { ws.close(); wsRefs.current.SZSE = null; } } // 清理已取消订阅的 quotes const allNewCodes = new Set([...newSseCodes, ...newSzseCodes]); setQuotes(prev => { const updated: QuotesMap = {}; Object.keys(prev).forEach(code => { if (allNewCodes.has(code)) { updated[code] = prev[code]; } }); return updated; }); }, [codes, createConnection]); // 清理 useEffect(() => { return () => { (['SSE', 'SZSE'] as Exchange[]).forEach(exchange => { stopHeartbeat(exchange); if (reconnectRefs.current[exchange]) { clearTimeout(reconnectRefs.current[exchange]!); } const ws = wsRefs.current[exchange]; if (ws) { ws.close(); } }); }; }, [stopHeartbeat]); return { quotes, connected, subscribe, unsubscribe }; }; export default useRealtimeQuote;