595 lines
17 KiB
TypeScript
595 lines
17 KiB
TypeScript
/**
|
|
* 实时行情 Hook
|
|
* 管理上交所和深交所 WebSocket 连接,获取实时行情数据
|
|
*
|
|
* 上交所 (SSE): ws://49.232.185.254:8765 - 需主动订阅,提供五档行情
|
|
* 深交所 (SZSE): ws://222.128.1.157:8765 - 自动推送,提供十档行情
|
|
*/
|
|
|
|
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<string>,
|
|
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<string>,
|
|
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<string>,
|
|
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<QuotesMap>({});
|
|
const [connected, setConnected] = useState<ConnectionStatus>({ SSE: false, SZSE: false });
|
|
|
|
const wsRefs = useRef<Record<Exchange, WebSocket | null>>({ SSE: null, SZSE: null });
|
|
const heartbeatRefs = useRef<Record<Exchange, NodeJS.Timeout | null>>({ SSE: null, SZSE: null });
|
|
const reconnectRefs = useRef<Record<Exchange, NodeJS.Timeout | null>>({ SSE: null, SZSE: null });
|
|
const subscribedCodes = useRef<Record<Exchange, Set<string>>>({
|
|
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) => {
|
|
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,
|
|
}));
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
// 自动重连
|
|
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);
|
|
}
|
|
};
|
|
}, [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<string>();
|
|
const newSzseCodes = new Set<string>();
|
|
|
|
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;
|