Files
vf_react/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.js
2025-12-10 11:02:09 +08:00

693 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 实时行情 Hook
* 管理上交所和深交所 WebSocket 连接,获取实时行情数据
*
* 上交所 (SSE): ws://49.232.185.254:8765 - 需主动订阅,提供五档行情
* 深交所 (SZSE): ws://222.128.1.157:8765 - 自动推送,提供十档行情
*
* 深交所支持的数据类型 (category):
* - stock (300111): 股票快照含10档买卖盘
* - bond (300211): 债券快照
* - afterhours_block (300611): 盘后定价大宗交易
* - afterhours_trading (303711): 盘后定价交易
* - hk_stock (306311): 港股快照(深港通)
* - index (309011): 指数快照
* - volume_stats (309111): 成交量统计
* - fund_nav (309211): 基金净值
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { logger } from '@utils/logger';
// WebSocket 地址配置
const WS_CONFIG = {
SSE: 'ws://49.232.185.254:8765', // 上交所
SZSE: 'ws://222.128.1.157:8765', // 深交所
};
// 心跳间隔 (ms)
const HEARTBEAT_INTERVAL = 30000;
// 重连间隔 (ms)
const RECONNECT_INTERVAL = 3000;
/**
* 判断证券代码属于哪个交易所
* @param {string} code - 证券代码(可带或不带后缀)
* @returns {'SSE'|'SZSE'} 交易所标识
*/
const getExchange = (code) => {
const baseCode = code.split('.')[0];
// 6开头为上海股票
if (baseCode.startsWith('6')) {
return 'SSE';
}
// 000开头的6位数可能是上证指数或深圳股票
if (baseCode.startsWith('000') && baseCode.length === 6) {
// 000001-000999 是上证指数范围,但 000001 也是平安银行
// 这里需要更精确的判断,暂时把 000 开头当深圳
return 'SZSE';
}
// 399开头是深证指数
if (baseCode.startsWith('399')) {
return 'SZSE';
}
// 0、3开头是深圳股票
if (baseCode.startsWith('0') || baseCode.startsWith('3')) {
return 'SZSE';
}
// 5开头是上海 ETF
if (baseCode.startsWith('5')) {
return 'SSE';
}
// 1开头是深圳 ETF/债券
if (baseCode.startsWith('1')) {
return 'SZSE';
}
// 默认上海
return 'SSE';
};
/**
* 标准化证券代码为无后缀格式
*/
const normalizeCode = (code) => {
return code.split('.')[0];
};
/**
* 从深交所 bids/asks 数组提取价格和量数组
* @param {Array} orderBook - [{price, volume}, ...]
* @returns {{ prices: number[], volumes: number[] }}
*/
const extractOrderBook = (orderBook) => {
if (!orderBook || !Array.isArray(orderBook)) {
return { prices: [], volumes: [] };
}
const prices = orderBook.map(item => item.price || 0);
const volumes = orderBook.map(item => item.volume || 0);
return { prices, volumes };
};
/**
* 实时行情 Hook
* @param {string[]} codes - 订阅的证券代码列表
* @returns {Object} { quotes, connected, subscribe, unsubscribe }
*/
export const useRealtimeQuote = (codes = []) => {
// 行情数据 { [code]: QuoteData }
const [quotes, setQuotes] = useState({});
// 连接状态 { SSE: boolean, SZSE: boolean }
const [connected, setConnected] = useState({ SSE: false, SZSE: false });
// WebSocket 实例引用
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() });
/**
* 创建 WebSocket 连接
*/
const createConnection = useCallback((exchange) => {
// 清理现有连接
if (wsRefs.current[exchange]) {
wsRefs.current[exchange].close();
}
const ws = new WebSocket(WS_CONFIG[exchange]);
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: codes,
}));
}
}
// 启动心跳
startHeartbeat(exchange);
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
handleMessage(exchange, msg);
} catch (e) {
logger.warn('FlexScreen', `${exchange} 消息解析失败`, e);
}
};
ws.onerror = (error) => {
logger.error('FlexScreen', `${exchange} WebSocket 错误`, error);
};
ws.onclose = () => {
logger.info('FlexScreen', `${exchange} WebSocket 断开`);
setConnected(prev => ({ ...prev, [exchange]: false }));
stopHeartbeat(exchange);
// 自动重连
scheduleReconnect(exchange);
};
}, []);
/**
* 处理 WebSocket 消息
*/
const handleMessage = useCallback((exchange, msg) => {
// 处理 pong
if (msg.type === 'pong') {
return;
}
if (exchange === 'SSE') {
// 上交所消息格式
if (msg.type === 'stock' || msg.type === 'index') {
const data = msg.data || {};
setQuotes(prev => {
const updated = { ...prev };
Object.entries(data).forEach(([code, quote]) => {
// 只更新订阅的代码
if (subscribedCodes.current.SSE.has(code)) {
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: quote.prev_close ? ((quote.last_price - quote.prev_close) / quote.prev_close * 100) : 0,
bidPrices: quote.bid_prices || [],
bidVolumes: quote.bid_volumes || [],
askPrices: quote.ask_prices || [],
askVolumes: quote.ask_volumes || [],
updateTime: quote.trade_time,
exchange: 'SSE',
};
}
});
return updated;
});
}
} else if (exchange === 'SZSE') {
// 深交所消息格式(更新后的 API
if (msg.type === 'realtime') {
const { category, data } = msg;
const code = data.security_id;
// 只更新订阅的代码
if (!subscribedCodes.current.SZSE.has(code)) {
return;
}
if (category === 'stock') {
// 股票行情 - 含 10 档买卖盘
const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(data.bids);
const { prices: askPrices, volumes: askVolumes } = extractOrderBook(data.asks);
setQuotes(prev => ({
...prev,
[code]: {
code: code,
name: prev[code]?.name || '',
price: data.last_px,
prevClose: data.prev_close,
open: data.open_px,
high: data.high_px,
low: data.low_px,
volume: data.volume,
amount: data.amount,
numTrades: data.num_trades,
upperLimit: data.upper_limit, // 涨停价
lowerLimit: data.lower_limit, // 跌停价
change: data.last_px - data.prev_close,
changePct: data.prev_close ? ((data.last_px - data.prev_close) / data.prev_close * 100) : 0,
bidPrices,
bidVolumes,
askPrices,
askVolumes,
tradingPhase: data.trading_phase,
updateTime: msg.timestamp,
exchange: 'SZSE',
},
}));
} else if (category === 'index') {
// 指数行情
setQuotes(prev => ({
...prev,
[code]: {
code: code,
name: prev[code]?.name || '',
price: data.current_index,
prevClose: data.prev_close,
open: data.open_index,
high: data.high_index,
low: data.low_index,
close: data.close_index,
volume: data.volume,
amount: data.amount,
numTrades: data.num_trades,
change: data.current_index - data.prev_close,
changePct: data.prev_close ? ((data.current_index - data.prev_close) / data.prev_close * 100) : 0,
bidPrices: [],
bidVolumes: [],
askPrices: [],
askVolumes: [],
tradingPhase: data.trading_phase,
updateTime: msg.timestamp,
exchange: 'SZSE',
},
}));
} else if (category === 'bond') {
// 债券行情
setQuotes(prev => ({
...prev,
[code]: {
code: code,
name: prev[code]?.name || '',
price: data.last_px,
prevClose: data.prev_close,
open: data.open_px,
high: data.high_px,
low: data.low_px,
volume: data.volume,
amount: data.amount,
numTrades: data.num_trades,
weightedAvgPx: data.weighted_avg_px,
change: data.last_px - data.prev_close,
changePct: data.prev_close ? ((data.last_px - data.prev_close) / data.prev_close * 100) : 0,
bidPrices: [],
bidVolumes: [],
askPrices: [],
askVolumes: [],
tradingPhase: data.trading_phase,
updateTime: msg.timestamp,
exchange: 'SZSE',
isBond: true,
},
}));
} else if (category === 'hk_stock') {
// 港股行情(深港通)
const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(data.bids);
const { prices: askPrices, volumes: askVolumes } = extractOrderBook(data.asks);
setQuotes(prev => ({
...prev,
[code]: {
code: code,
name: prev[code]?.name || '',
price: data.last_px,
prevClose: data.prev_close,
open: data.open_px,
high: data.high_px,
low: data.low_px,
volume: data.volume,
amount: data.amount,
numTrades: data.num_trades,
nominalPx: data.nominal_px, // 按盘价
referencePx: data.reference_px, // 参考价
change: data.last_px - data.prev_close,
changePct: data.prev_close ? ((data.last_px - data.prev_close) / data.prev_close * 100) : 0,
bidPrices,
bidVolumes,
askPrices,
askVolumes,
tradingPhase: data.trading_phase,
updateTime: msg.timestamp,
exchange: 'SZSE',
isHK: true,
},
}));
} else if (category === 'afterhours_block' || category === 'afterhours_trading') {
// 盘后交易
setQuotes(prev => ({
...prev,
[code]: {
...prev[code],
afterhours: {
bidPx: data.bid_px,
bidSize: data.bid_size,
offerPx: data.offer_px,
offerSize: data.offer_size,
volume: data.volume,
amount: data.amount,
numTrades: data.num_trades,
},
updateTime: msg.timestamp,
},
}));
}
// fund_nav 和 volume_stats 暂不处理
} else if (msg.type === 'snapshot') {
// 深交所初始快照
const { stocks = [], indexes = [], bonds = [] } = msg.data || {};
setQuotes(prev => {
const updated = { ...prev };
stocks.forEach(s => {
if (subscribedCodes.current.SZSE.has(s.security_id)) {
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: s.security_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: s.prev_close ? ((s.last_px - s.prev_close) / s.prev_close * 100) : 0,
bidPrices,
bidVolumes,
askPrices,
askVolumes,
exchange: 'SZSE',
};
}
});
indexes.forEach(i => {
if (subscribedCodes.current.SZSE.has(i.security_id)) {
updated[i.security_id] = {
code: i.security_id,
name: i.security_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: i.prev_close ? ((i.current_index - i.prev_close) / i.prev_close * 100) : 0,
bidPrices: [],
bidVolumes: [],
askPrices: [],
askVolumes: [],
exchange: 'SZSE',
};
}
});
bonds.forEach(b => {
if (subscribedCodes.current.SZSE.has(b.security_id)) {
updated[b.security_id] = {
code: b.security_id,
name: b.security_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: b.prev_close ? ((b.last_px - b.prev_close) / b.prev_close * 100) : 0,
bidPrices: [],
bidVolumes: [],
askPrices: [],
askVolumes: [],
exchange: 'SZSE',
isBond: true,
};
}
});
return updated;
});
}
}
}, []);
/**
* 启动心跳
*/
const startHeartbeat = useCallback((exchange) => {
stopHeartbeat(exchange);
heartbeatRefs.current[exchange] = setInterval(() => {
const ws = wsRefs.current[exchange];
if (ws && ws.readyState === WebSocket.OPEN) {
if (exchange === 'SSE') {
ws.send(JSON.stringify({ action: 'ping' }));
} else {
ws.send(JSON.stringify({ type: 'ping' }));
}
}
}, HEARTBEAT_INTERVAL);
}, []);
/**
* 停止心跳
*/
const stopHeartbeat = useCallback((exchange) => {
if (heartbeatRefs.current[exchange]) {
clearInterval(heartbeatRefs.current[exchange]);
heartbeatRefs.current[exchange] = null;
}
}, []);
/**
* 安排重连
*/
const scheduleReconnect = useCallback((exchange) => {
if (reconnectRefs.current[exchange]) {
return; // 已有重连计划
}
reconnectRefs.current[exchange] = setTimeout(() => {
reconnectRefs.current[exchange] = null;
// 只有还有订阅的代码才重连
if (subscribedCodes.current[exchange].size > 0) {
createConnection(exchange);
}
}, RECONNECT_INTERVAL);
}, [createConnection]);
/**
* 订阅证券
*/
const subscribe = useCallback((code) => {
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) => {
const baseCode = normalizeCode(code);
const exchange = getExchange(code);
// 从订阅列表移除
subscribedCodes.current[exchange].delete(baseCode);
// 从 quotes 中移除
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;
}
}
}, []);
/**
* 初始化订阅
*/
useEffect(() => {
if (!codes || codes.length === 0) {
return;
}
// 按交易所分组
const sseCodesSet = new Set();
const szseCodesSet = new Set();
codes.forEach(code => {
const baseCode = normalizeCode(code);
const exchange = getExchange(code);
if (exchange === 'SSE') {
sseCodesSet.add(baseCode);
} else {
szseCodesSet.add(baseCode);
}
});
// 更新订阅列表
subscribedCodes.current.SSE = sseCodesSet;
subscribedCodes.current.SZSE = szseCodesSet;
// 建立连接
if (sseCodesSet.size > 0) {
createConnection('SSE');
}
if (szseCodesSet.size > 0) {
createConnection('SZSE');
}
// 清理
return () => {
['SSE', 'SZSE'].forEach(exchange => {
stopHeartbeat(exchange);
if (reconnectRefs.current[exchange]) {
clearTimeout(reconnectRefs.current[exchange]);
}
const ws = wsRefs.current[exchange];
if (ws) {
ws.close();
}
});
};
}, []); // 只在挂载时执行
/**
* 处理 codes 变化
*/
useEffect(() => {
if (!codes) 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 oldSzseCodes = subscribedCodes.current.SZSE;
// 更新上交所订阅
const sseToAdd = [...newSseCodes].filter(c => !oldSseCodes.has(c));
const sseToRemove = [...oldSseCodes].filter(c => !newSseCodes.has(c));
if (sseToAdd.length > 0 || sseToRemove.length > 0) {
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 szseToAdd = [...newSzseCodes].filter(c => !oldSzseCodes.has(c));
const szseToRemove = [...oldSzseCodes].filter(c => !newSzseCodes.has(c));
if (szseToAdd.length > 0 || szseToRemove.length > 0) {
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 removedCodes = [...sseToRemove, ...szseToRemove];
if (removedCodes.length > 0) {
setQuotes(prev => {
const updated = { ...prev };
removedCodes.forEach(code => {
delete updated[code];
});
return updated;
});
}
}, [codes, createConnection]);
return {
quotes,
connected,
subscribe,
unsubscribe,
};
};
export default useRealtimeQuote;