diff --git a/__pycache__/app.cpython-310.pyc b/__pycache__/app.cpython-310.pyc index 4f57ded3..9733ad91 100644 Binary files a/__pycache__/app.cpython-310.pyc and b/__pycache__/app.cpython-310.pyc differ diff --git a/app.py b/app.py index cdfdbe86..e54f3e4d 100755 --- a/app.py +++ b/app.py @@ -6519,6 +6519,22 @@ def get_batch_kline_data(): results = {} if chart_type == 'timeline': + # 批量获取昨收价(从 MySQL ea_trade 表) + prev_close_map = {} + target_date_str = target_date.strftime('%Y%m%d') + with engine.connect() as conn: + base_codes = list(set([code.split('.')[0] for code in codes])) + if base_codes: + placeholders = ','.join([f':code{i}' for i in range(len(base_codes))]) + params = {f'code{i}': code for i, code in enumerate(base_codes)} + params['trade_date'] = target_date_str + result = conn.execute(text(f""" + SELECT SECCODE, F007N FROM ea_trade + WHERE SECCODE IN ({placeholders}) AND TRADEDATE = :trade_date AND F007N > 0 + """), params).fetchall() + for row in result: + prev_close_map[row[0]] = float(row[1]) + # 批量查询分时数据(使用标准化代码查询 ClickHouse) batch_data = client.execute(""" SELECT code, timestamp, close, volume @@ -6532,16 +6548,35 @@ def get_batch_kline_data(): 'end': end_time }) - # 按股票代码分组(标准化代码 -> 数据列表) + # 按股票代码分组,同时计算均价和涨跌幅 stock_data = {} + stock_accum = {} # 用于计算均价的累计值 for row in batch_data: norm_code = row[0] + base_code = norm_code.split('.')[0] + price = float(row[2]) + volume = float(row[3]) + if norm_code not in stock_data: stock_data[norm_code] = [] + stock_accum[norm_code] = {'total_amount': 0, 'total_volume': 0} + + # 累计计算均价 + stock_accum[norm_code]['total_amount'] += price * volume + stock_accum[norm_code]['total_volume'] += volume + total_vol = stock_accum[norm_code]['total_volume'] + avg_price = stock_accum[norm_code]['total_amount'] / total_vol if total_vol > 0 else price + + # 计算涨跌幅 + prev_close = prev_close_map.get(base_code) + change_percent = ((price - prev_close) / prev_close * 100) if prev_close and prev_close > 0 else 0 + stock_data[norm_code].append({ 'time': row[1].strftime('%H:%M'), - 'price': float(row[2]), - 'volume': float(row[3]) + 'price': price, + 'avg_price': round(avg_price, 2), + 'volume': volume, + 'change_percent': round(change_percent, 2) }) # 组装结果(使用原始代码作为 key 返回) @@ -6550,13 +6585,15 @@ def get_batch_kline_data(): base_code = orig_code.split('.')[0] stock_name = stock_names.get(base_code, f'股票{base_code}') data_list = stock_data.get(norm_code, []) + prev_close = prev_close_map.get(base_code) results[orig_code] = { 'code': orig_code, 'name': stock_name, 'data': data_list, 'trade_date': target_date.strftime('%Y-%m-%d'), - 'type': 'timeline' + 'type': 'timeline', + 'prev_close': prev_close } elif chart_type == 'daily': @@ -7634,23 +7671,36 @@ def get_timeline_data(stock_code, event_datetime, stock_name): 'type': 'timeline' }) - # 获取昨收盘价 - prev_close_query = """ - SELECT close - FROM stock_minute - WHERE code = %(code)s - AND timestamp \ - < %(start)s - ORDER BY timestamp DESC - LIMIT 1 \ - """ + # 获取昨收盘价 - 优先从 MySQL ea_trade 表获取(更可靠) + prev_close = None + base_code = stock_code.split('.')[0] + target_date_str = target_date.strftime('%Y%m%d') - prev_close_result = client.execute(prev_close_query, { - 'code': stock_code, - 'start': datetime.combine(target_date, dt_time(9, 30)) - }) + try: + with engine.connect() as conn: + # F007N 是昨收价字段 + result = conn.execute(text(""" + SELECT F007N FROM ea_trade + WHERE SECCODE = :code AND TRADEDATE = :trade_date AND F007N > 0 + """), {'code': base_code, 'trade_date': target_date_str}).fetchone() + if result and result[0]: + prev_close = float(result[0]) + except Exception as e: + logger.warning(f"从 ea_trade 获取昨收价失败: {e}") - prev_close = float(prev_close_result[0][0]) if prev_close_result else None + # 如果 MySQL 没有数据,回退到 ClickHouse + if prev_close is None: + prev_close_query = """ + SELECT close FROM stock_minute + WHERE code = %(code)s AND timestamp < %(start)s + ORDER BY timestamp DESC LIMIT 1 + """ + prev_close_result = client.execute(prev_close_query, { + 'code': stock_code, + 'start': datetime.combine(target_date, dt_time(9, 30)) + }) + if prev_close_result: + prev_close = float(prev_close_result[0][0]) data = client.execute( """ diff --git a/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts b/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts index 09687696..1b093826 100644 --- a/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts +++ b/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts @@ -3,11 +3,11 @@ * 管理上交所和深交所 WebSocket 连接,获取实时行情数据 * * 连接方式: - * - 生产环境 (HTTPS): 通过 Nginx 代理使用 wss:// (如 wss://valuefrontier.cn/ws/sse) + * - 生产环境 (HTTPS): 通过 Nginx 代理使用 wss:// * - 开发环境 (HTTP): 直连 ws:// * * 上交所 (SSE): 需主动订阅,提供五档行情 - * 深交所 (SZSE): 自动推送,提供十档行情 + * 深交所 (SZSE): v4.0 API - 需主动订阅,提供十档行情 */ import { useState, useEffect, useRef, useCallback } from 'react'; @@ -34,8 +34,6 @@ import type { /** * 处理上交所消息 - * 注意:上交所返回的 code 不带后缀,但通过 msg.type 区分 'stock' 和 'index' - * 存储时使用带后缀的完整代码作为 key(如 000001.SH) */ const handleSSEMessage = ( msg: SSEMessage, @@ -49,10 +47,8 @@ const handleSSEMessage = ( const data = msg.data || {}; const updated: QuotesMap = { ...prevQuotes }; let hasUpdate = false; - const isIndex = msg.type === 'index'; Object.entries(data).forEach(([code, quote]: [string, SSEQuoteItem]) => { - // 生成带后缀的完整代码(上交所统一用 .SH) const fullCode = code.includes('.') ? code : `${code}.SH`; if (subscribedCodes.has(code) || subscribedCodes.has(fullCode)) { @@ -83,9 +79,7 @@ const handleSSEMessage = ( }; /** - * 处理深交所实时消息 - * 注意:深交所返回的 security_id 可能带后缀也可能不带 - * 存储时统一使用带后缀的完整代码作为 key(如 000001.SZ) + * 处理深交所实时消息 (realtime) */ const handleSZSERealtimeMessage = ( msg: SZSERealtimeMessage, @@ -94,7 +88,6 @@ const handleSZSERealtimeMessage = ( ): QuotesMap | null => { const { category, data, timestamp } = msg; const rawCode = data.security_id; - // 生成带后缀的完整代码(深交所统一用 .SZ) const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`; if (!subscribedCodes.has(rawCode) && !subscribedCodes.has(fullCode)) { @@ -106,48 +99,8 @@ const handleSZSERealtimeMessage = ( switch (category) { case 'stock': { const stockData = data as SZSEStockData; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const rawData = data as any; // 用于检查替代字段名 - - // 调试日志:检查深交所返回的盘口原始数据(临时使用 warn 级别方便调试) - if (!stockData.bids || stockData.bids.length === 0) { - logger.warn('FlexScreen', `SZSE股票数据无盘口 ${fullCode}`, { - hasBids: !!stockData.bids, - hasAsks: !!stockData.asks, - bidsLength: stockData.bids?.length || 0, - asksLength: stockData.asks?.length || 0, - // 检查替代字段名 - hasBidPrices: !!rawData.bid_prices, - hasAskPrices: !!rawData.ask_prices, - dataKeys: Object.keys(stockData), // 查看服务端实际返回了哪些字段 - }); - } - - // 优先使用 bids/asks 对象数组格式,如果不存在则尝试 bid_prices/ask_prices 分离数组格式 - let bidPrices: number[] = []; - let bidVolumes: number[] = []; - let askPrices: number[] = []; - let askVolumes: number[] = []; - - if (stockData.bids && stockData.bids.length > 0) { - const extracted = extractOrderBook(stockData.bids); - bidPrices = extracted.prices; - bidVolumes = extracted.volumes; - } else if (rawData.bid_prices && Array.isArray(rawData.bid_prices)) { - // 替代格式:bid_prices 和 bid_volumes 分离 - bidPrices = rawData.bid_prices; - bidVolumes = rawData.bid_volumes || []; - } - - if (stockData.asks && stockData.asks.length > 0) { - const extracted = extractOrderBook(stockData.asks); - askPrices = extracted.prices; - askVolumes = extracted.volumes; - } else if (rawData.ask_prices && Array.isArray(rawData.ask_prices)) { - // 替代格式:ask_prices 和 ask_volumes 分离 - askPrices = rawData.ask_prices; - askVolumes = rawData.ask_volumes || []; - } + const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(stockData.bids); + const { prices: askPrices, volumes: askVolumes } = extractOrderBook(stockData.asks); updated[fullCode] = { code: fullCode, @@ -292,8 +245,8 @@ const handleSZSERealtimeMessage = ( }; /** - * 处理深交所快照消息 - * 存储时统一使用带后缀的完整代码作为 key + * 处理深交所快照消息 (snapshot) + * 订阅后首次返回的批量数据 */ const handleSZSESnapshotMessage = ( msg: SZSESnapshotMessage, @@ -310,43 +263,8 @@ const handleSZSESnapshotMessage = ( if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) { hasUpdate = true; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const rawData = s as any; // 用于检查替代字段名 - - // 调试日志:检查快照消息中的盘口数据(无盘口时警告) - if (!s.bids || s.bids.length === 0) { - logger.warn('FlexScreen', `SZSE快照股票数据无盘口 ${fullCode}`, { - hasBids: !!s.bids, - hasAsks: !!s.asks, - hasBidPrices: !!rawData.bid_prices, - hasAskPrices: !!rawData.ask_prices, - dataKeys: Object.keys(s), - }); - } - - // 优先使用 bids/asks 对象数组格式,如果不存在则尝试 bid_prices/ask_prices 分离数组格式 - let bidPrices: number[] = []; - let bidVolumes: number[] = []; - let askPrices: number[] = []; - let askVolumes: number[] = []; - - if (s.bids && s.bids.length > 0) { - const extracted = extractOrderBook(s.bids); - bidPrices = extracted.prices; - bidVolumes = extracted.volumes; - } else if (rawData.bid_prices && Array.isArray(rawData.bid_prices)) { - bidPrices = rawData.bid_prices; - bidVolumes = rawData.bid_volumes || []; - } - - if (s.asks && s.asks.length > 0) { - const extracted = extractOrderBook(s.asks); - askPrices = extracted.prices; - askVolumes = extracted.volumes; - } else if (rawData.ask_prices && Array.isArray(rawData.ask_prices)) { - askPrices = rawData.ask_prices; - askVolumes = rawData.ask_volumes || []; - } + const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(s.bids); + const { prices: askPrices, volumes: askVolumes } = extractOrderBook(s.asks); updated[fullCode] = { code: fullCode, @@ -433,7 +351,7 @@ const handleSZSESnapshotMessage = ( /** * 实时行情 Hook - * @param codes - 订阅的证券代码列表 + * @param codes - 订阅的证券代码列表(带后缀,如 000001.SZ) */ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn => { const [quotes, setQuotes] = useState({}); @@ -442,6 +360,11 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = const wsRefs = useRef>({ SSE: null, SZSE: null }); const heartbeatRefs = useRef>({ SSE: null, SZSE: null }); const reconnectRefs = useRef>({ SSE: null, SZSE: null }); + // 深交所 WebSocket 就绪状态(收到 welcome 消息后才能订阅) + const szseReadyRef = useRef(false); + // 待发送的深交所订阅队列(在 welcome 之前收到的订阅请求) + const szsePendingSubscribeRef = useRef([]); + const subscribedCodes = useRef>>({ SSE: new Set(), SZSE: new Set(), @@ -459,50 +382,123 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = 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)); + ws.send(JSON.stringify({ type: 'ping' })); } }, HEARTBEAT_INTERVAL); }, [stopHeartbeat]); + /** + * 发送深交所订阅请求 + * 格式:{ type: 'subscribe', securities: ['000001', '000002'] } + */ + const sendSZSESubscribe = useCallback((baseCodes: string[]) => { + const ws = wsRefs.current.SZSE; + if (ws && ws.readyState === WebSocket.OPEN && baseCodes.length > 0) { + ws.send(JSON.stringify({ + type: 'subscribe', + securities: baseCodes, + })); + logger.info('FlexScreen', `SZSE 发送订阅请求`, { securities: baseCodes }); + } + }, []); + + /** + * 处理消息 + */ const handleMessage = useCallback((exchange: Exchange, msg: SSEMessage | SZSEMessage) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const anyMsg = msg as any; + + // 心跳响应 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; - }); + // 深交所消息处理 + switch (msg.type) { + case 'welcome': + // 收到欢迎消息,深交所 WebSocket 就绪 + logger.info('FlexScreen', 'SZSE WebSocket 就绪,可以订阅'); + szseReadyRef.current = true; + + // 发送之前待处理的订阅请求 + if (szsePendingSubscribeRef.current.length > 0) { + sendSZSESubscribe(szsePendingSubscribeRef.current); + szsePendingSubscribeRef.current = []; + } else { + // 如果已有订阅的代码,立即发送订阅 + const currentCodes = Array.from(subscribedCodes.current.SZSE).map(c => normalizeCode(c)); + if (currentCodes.length > 0) { + sendSZSESubscribe(currentCodes); + } + } + break; + + case 'subscribed': + // 订阅成功确认 + logger.info('FlexScreen', 'SZSE 订阅成功', { + securities: anyMsg.securities, + categories: anyMsg.categories, + }); + break; + + case 'unsubscribed': + // 取消订阅确认 + logger.info('FlexScreen', 'SZSE 取消订阅成功'); + break; + + case 'snapshot': + // 快照消息(订阅后首次返回的批量数据) + setQuotes(prev => { + const result = handleSZSESnapshotMessage( + msg as SZSESnapshotMessage, + subscribedCodes.current.SZSE, + prev + ); + return result || prev; + }); + break; + + case 'realtime': + // 实时行情推送 + setQuotes(prev => { + const result = handleSZSERealtimeMessage( + msg as SZSERealtimeMessage, + subscribedCodes.current.SZSE, + prev + ); + return result || prev; + }); + break; + + case 'query_result': + case 'query_batch_result': + // 查询结果(目前不处理) + break; + + case 'error': + logger.error('FlexScreen', 'SZSE WebSocket 错误', { message: anyMsg.message }); + break; + + default: + // 未知消息类型 + break; } } - }, []); + }, [sendSZSESubscribe]); + /** + * 创建 WebSocket 连接 + */ 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://'); @@ -519,6 +515,11 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = wsRefs.current[exchange]!.close(); } + // 重置深交所就绪状态 + if (exchange === 'SZSE') { + szseReadyRef.current = false; + } + try { const ws = new WebSocket(wsUrl); wsRefs.current[exchange] = ws; @@ -528,7 +529,7 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = setConnected(prev => ({ ...prev, [exchange]: true })); if (exchange === 'SSE') { - // subscribedCodes 存的是带后缀的完整代码,发送给 WS 需要去掉后缀 + // 上交所:连接后立即发送订阅 const fullCodes = Array.from(subscribedCodes.current.SSE); const baseCodes = fullCodes.map(c => normalizeCode(c)); if (baseCodes.length > 0) { @@ -539,6 +540,7 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = })); } } + // 深交所:等待 welcome 消息后再订阅 startHeartbeat(exchange); }; @@ -561,7 +563,12 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = setConnected(prev => ({ ...prev, [exchange]: false })); stopHeartbeat(exchange); - // 自动重连(仅在非 HTTPS + ws:// 场景下) + // 重置深交所就绪状态 + if (exchange === 'SZSE') { + szseReadyRef.current = false; + } + + // 自动重连 if (!reconnectRefs.current[exchange] && subscribedCodes.current[exchange].size > 0) { reconnectRefs.current[exchange] = setTimeout(() => { reconnectRefs.current[exchange] = null; @@ -577,43 +584,67 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = } }, [startHeartbeat, stopHeartbeat, handleMessage]); + /** + * 订阅单个证券 + */ const subscribe = useCallback((code: string) => { const exchange = getExchange(code); - // 确保使用带后缀的完整代码 const fullCode = code.includes('.') ? code : `${code}.${exchange === 'SSE' ? 'SH' : 'SZ'}`; const baseCode = normalizeCode(code); subscribedCodes.current[exchange].add(fullCode); const ws = wsRefs.current[exchange]; - if (exchange === 'SSE' && ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ - action: 'subscribe', - channels: ['stock', 'index'], - codes: [baseCode], // 发送给 WS 用不带后缀的代码 - })); + + if (exchange === 'SSE') { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + action: 'subscribe', + channels: ['stock', 'index'], + codes: [baseCode], + })); + } + } else { + // 深交所 + if (ws && ws.readyState === WebSocket.OPEN && szseReadyRef.current) { + sendSZSESubscribe([baseCode]); + } else if (ws && ws.readyState === WebSocket.OPEN) { + // WebSocket 已连接但未收到 welcome,加入待处理队列 + szsePendingSubscribeRef.current.push(baseCode); + } } if (!ws || ws.readyState !== WebSocket.OPEN) { createConnection(exchange); } - }, [createConnection]); + }, [createConnection, sendSZSESubscribe]); + /** + * 取消订阅 + */ const unsubscribe = useCallback((code: string) => { const exchange = getExchange(code); - // 确保使用带后缀的完整代码 const fullCode = code.includes('.') ? code : `${code}.${exchange === 'SSE' ? 'SH' : 'SZ'}`; + const baseCode = normalizeCode(code); subscribedCodes.current[exchange].delete(fullCode); + // 发送取消订阅请求(深交所) + const ws = wsRefs.current[exchange]; + if (exchange === 'SZSE' && ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + type: 'unsubscribe', + securities: [baseCode], + })); + } + setQuotes(prev => { const updated = { ...prev }; - delete updated[fullCode]; // 删除时也用带后缀的 key + delete updated[fullCode]; return updated; }); if (subscribedCodes.current[exchange].size === 0) { - const ws = wsRefs.current[exchange]; if (ws) { ws.close(); wsRefs.current[exchange] = null; @@ -622,17 +653,14 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = }, []); // 初始化和 codes 变化处理 - // 注意:codes 现在是带后缀的完整代码(如 000001.SH) useEffect(() => { if (!codes || codes.length === 0) return; - // 使用带后缀的完整代码作为内部 key const newSseCodes = new Set(); const newSzseCodes = new Set(); codes.forEach(code => { const exchange = getExchange(code); - // 确保代码带后缀 const fullCode = code.includes('.') ? code : `${code}.${exchange === 'SSE' ? 'SH' : 'SZ'}`; if (exchange === 'SSE') { newSseCodes.add(fullCode); @@ -644,7 +672,6 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = // 更新上交所订阅 const oldSseCodes = subscribedCodes.current.SSE; const sseToAdd = [...newSseCodes].filter(c => !oldSseCodes.has(c)); - // 发送给 WebSocket 的代码需要去掉后缀 const sseToAddBase = sseToAdd.map(c => normalizeCode(c)); if (sseToAdd.length > 0 || newSseCodes.size !== oldSseCodes.size) { @@ -672,13 +699,23 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = // 更新深交所订阅 const oldSzseCodes = subscribedCodes.current.SZSE; const szseToAdd = [...newSzseCodes].filter(c => !oldSzseCodes.has(c)); + const szseToAddBase = szseToAdd.map(c => normalizeCode(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 (szseToAdd.length > 0) { + if (ws && ws.readyState === WebSocket.OPEN && szseReadyRef.current) { + // WebSocket 已就绪,直接发送订阅 + sendSZSESubscribe(szseToAddBase); + } else if (ws && ws.readyState === WebSocket.OPEN) { + // WebSocket 已连接但未就绪,加入待处理队列 + szsePendingSubscribeRef.current.push(...szseToAddBase); + } else { + // WebSocket 未连接,创建连接 + createConnection('SZSE'); + } } if (newSzseCodes.size === 0 && ws) { @@ -687,7 +724,7 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = } } - // 清理已取消订阅的 quotes(使用带后缀的完整代码) + // 清理已取消订阅的 quotes const allNewCodes = new Set([...newSseCodes, ...newSzseCodes]); setQuotes(prev => { const updated: QuotesMap = {}; @@ -698,7 +735,7 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = }); return updated; }); - }, [codes, createConnection]); + }, [codes, createConnection, sendSZSESubscribe]); // 清理 useEffect(() => { diff --git a/src/views/StockOverview/components/FlexScreen/hooks/utils.ts b/src/views/StockOverview/components/FlexScreen/hooks/utils.ts index dcc54890..ca45dd49 100644 --- a/src/views/StockOverview/components/FlexScreen/hooks/utils.ts +++ b/src/views/StockOverview/components/FlexScreen/hooks/utils.ts @@ -75,65 +75,21 @@ export const normalizeCode = (code: string): string => { return code.split('.')[0]; }; -/** - * 盘口数据可能的格式(根据不同的 WebSocket 服务端实现) - */ -type OrderBookInput = - | OrderBookLevel[] // 格式1: [{price, volume}, ...] - | Array<[number, number]> // 格式2: [[price, volume], ...] - | { prices: number[]; volumes: number[] } // 格式3: {prices: [...], volumes: [...]} - | undefined; - /** * 从深交所 bids/asks 数组提取价格和量数组 - * 支持多种可能的数据格式 - * @param orderBook - 盘口数据,支持多种格式 + * 格式:[{price, volume}, ...] + * @param orderBook - 盘口数组 * @returns { prices, volumes } */ export const extractOrderBook = ( - orderBook: OrderBookInput + orderBook: OrderBookLevel[] | undefined ): { prices: number[]; volumes: number[] } => { - if (!orderBook) { + if (!orderBook || !Array.isArray(orderBook) || orderBook.length === 0) { return { prices: [], volumes: [] }; } - - // 格式3: 已经是 {prices, volumes} 结构 - if (!Array.isArray(orderBook) && 'prices' in orderBook && 'volumes' in orderBook) { - return { - prices: orderBook.prices || [], - volumes: orderBook.volumes || [], - }; - } - - // 必须是数组才能继续 - if (!Array.isArray(orderBook) || orderBook.length === 0) { - return { prices: [], volumes: [] }; - } - - const firstItem = orderBook[0]; - - // 格式2: [[price, volume], ...] - if (Array.isArray(firstItem)) { - const prices = orderBook.map((item: unknown) => { - const arr = item as [number, number]; - return arr[0] || 0; - }); - const volumes = orderBook.map((item: unknown) => { - const arr = item as [number, number]; - return arr[1] || 0; - }); - return { prices, volumes }; - } - - // 格式1: [{price, volume}, ...] (标准格式) - if (typeof firstItem === 'object' && firstItem !== null) { - const typedBook = orderBook as OrderBookLevel[]; - const prices = typedBook.map(item => item.price || 0); - const volumes = typedBook.map(item => item.volume || 0); - return { prices, volumes }; - } - - return { prices: [], volumes: [] }; + const prices = orderBook.map(item => item.price || 0); + const volumes = orderBook.map(item => item.volume || 0); + return { prices, volumes }; }; /** diff --git a/src/views/StockOverview/components/FlexScreen/types.ts b/src/views/StockOverview/components/FlexScreen/types.ts index 796290f3..7430b842 100644 --- a/src/views/StockOverview/components/FlexScreen/types.ts +++ b/src/views/StockOverview/components/FlexScreen/types.ts @@ -265,8 +265,51 @@ export interface SZSESnapshotMessage { }; } +/** 深交所欢迎消息 */ +export interface SZSEWelcomeMessage { + type: 'welcome'; + message: string; + timestamp: string; + usage?: Record; + categories?: string[]; +} + +/** 深交所订阅确认消息 */ +export interface SZSESubscribedMessage { + type: 'subscribed'; + securities?: string[]; + categories?: string[]; + all?: boolean; + incremental_only?: boolean; + message?: string; +} + +/** 深交所取消订阅确认消息 */ +export interface SZSEUnsubscribedMessage { + type: 'unsubscribed'; + securities?: string[]; + categories?: string[]; + remaining_securities?: string[]; + remaining_categories?: string[]; +} + +/** 深交所错误消息 */ +export interface SZSEErrorMessage { + type: 'error'; + message: string; +} + /** 深交所消息类型 */ -export type SZSEMessage = SZSERealtimeMessage | SZSESnapshotMessage | { type: 'pong' }; +export type SZSEMessage = + | SZSERealtimeMessage + | SZSESnapshotMessage + | SZSEWelcomeMessage + | SZSESubscribedMessage + | SZSEUnsubscribedMessage + | SZSEErrorMessage + | { type: 'pong'; timestamp?: string } + | { type: 'query_result'; security_id: string; found: boolean; data: unknown } + | { type: 'query_batch_result'; count: number; found: number; data: Record }; // ==================== 组件 Props 类型 ====================