update pay ui
This commit is contained in:
@@ -7,15 +7,18 @@
|
||||
* - 开发环境 (HTTP): 直连 ws://
|
||||
*
|
||||
* 上交所 (SSE): 需主动订阅,提供五档行情
|
||||
* 深交所 (SZSE): v4.0 API - 需主动订阅,提供十档行情
|
||||
* 深交所 (SZSE): v4.0 API - 批量推送模式,提供五档行情
|
||||
*
|
||||
* API 文档: SZSE_WEBSOCKET_API.md
|
||||
*/
|
||||
|
||||
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 { getExchange, normalizeCode, calcChangePct } from './utils';
|
||||
import type {
|
||||
Exchange,
|
||||
SZSEChannel,
|
||||
ConnectionStatus,
|
||||
QuotesMap,
|
||||
QuoteData,
|
||||
@@ -28,10 +31,12 @@ import type {
|
||||
SZSEIndexData,
|
||||
SZSEBondData,
|
||||
SZSEHKStockData,
|
||||
SZSEAfterhoursData,
|
||||
UseRealtimeQuoteReturn,
|
||||
} from '../types';
|
||||
|
||||
/** 最大重连次数 */
|
||||
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||
|
||||
/**
|
||||
* 处理上交所消息
|
||||
*/
|
||||
@@ -79,69 +84,27 @@ const handleSSEMessage = (
|
||||
};
|
||||
|
||||
/**
|
||||
* 从深交所数据中提取盘口价格和量
|
||||
* 新 API 格式:直接使用 bid_prices/bid_volumes/ask_prices/ask_volumes 数组
|
||||
* 旧 API 格式:使用 bids/asks 对象数组
|
||||
* 处理深交所批量行情消息 (新 API 格式)
|
||||
* 格式:{ type: 'stock'/'index'/'bond'/'hkstock', data: { code: quote, ... }, timestamp }
|
||||
*/
|
||||
const extractSZSEOrderBook = (
|
||||
stockData: SZSEStockData
|
||||
): { bidPrices: number[]; bidVolumes: number[]; askPrices: number[]; askVolumes: number[] } => {
|
||||
// 新 API 格式:直接是数组
|
||||
if (stockData.bid_prices && Array.isArray(stockData.bid_prices)) {
|
||||
return {
|
||||
bidPrices: stockData.bid_prices,
|
||||
bidVolumes: stockData.bid_volumes || [],
|
||||
askPrices: stockData.ask_prices || [],
|
||||
askVolumes: stockData.ask_volumes || [],
|
||||
};
|
||||
}
|
||||
// 旧 API 格式:对象数组
|
||||
const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(stockData.bids);
|
||||
const { prices: askPrices, volumes: askVolumes } = extractOrderBook(stockData.asks);
|
||||
return { bidPrices, bidVolumes, askPrices, askVolumes };
|
||||
};
|
||||
|
||||
/**
|
||||
* 从深交所数据中提取价格字段(兼容新旧 API)
|
||||
*/
|
||||
const extractSZSEPrices = (stockData: SZSEStockData) => {
|
||||
return {
|
||||
prevClose: stockData.prev_close_px ?? stockData.prev_close ?? 0,
|
||||
volume: stockData.total_volume_trade ?? stockData.volume ?? 0,
|
||||
amount: stockData.total_value_trade ?? stockData.amount ?? 0,
|
||||
upperLimit: stockData.upper_limit_px ?? stockData.upper_limit,
|
||||
lowerLimit: stockData.lower_limit_px ?? stockData.lower_limit,
|
||||
tradingPhase: stockData.trading_phase_code ?? stockData.trading_phase,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理深交所批量行情消息(新 API 格式,与 SSE 一致)
|
||||
* 格式:{ type: 'stock'/'index', data: { '000001': {...}, '399001': {...} }, timestamp: '...' }
|
||||
*/
|
||||
const handleSZSEBatchMessage = (
|
||||
const handleSZSERealtimeMessage = (
|
||||
msg: SZSERealtimeMessage,
|
||||
subscribedCodes: Set<string>,
|
||||
prevQuotes: QuotesMap
|
||||
): QuotesMap | null => {
|
||||
const { type, data, timestamp } = msg;
|
||||
|
||||
// 新 API 格式:data 是 { code: quote, ... } 的字典
|
||||
if (!data || typeof data !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updated: QuotesMap = { ...prevQuotes };
|
||||
let hasUpdate = false;
|
||||
const isIndexType = type === 'index';
|
||||
|
||||
// 遍历所有数据
|
||||
Object.entries(data).forEach(([code, quote]) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const quoteData = quote as any;
|
||||
if (!quoteData || typeof quoteData !== 'object') return;
|
||||
if (!quote || typeof quote !== 'object') return;
|
||||
|
||||
const rawCode = quoteData.security_id || code;
|
||||
const rawCode = (quote as { security_id?: string }).security_id || code;
|
||||
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
|
||||
|
||||
// 只处理已订阅的代码
|
||||
@@ -151,109 +114,39 @@ const handleSZSEBatchMessage = (
|
||||
|
||||
hasUpdate = true;
|
||||
|
||||
if (isIndexType) {
|
||||
// 指数数据格式(兼容多种字段名)
|
||||
const prevClose = quoteData.prev_close_px ?? quoteData.prev_close ?? quoteData.prevClose ?? 0;
|
||||
const currentIndex = quoteData.current_index ?? quoteData.last_px ?? 0;
|
||||
switch (type) {
|
||||
case 'index': {
|
||||
const indexData = quote as SZSEIndexData;
|
||||
const prevClose = indexData.prev_close || 0;
|
||||
const currentIndex = indexData.current_index || 0;
|
||||
|
||||
updated[fullCode] = {
|
||||
code: fullCode,
|
||||
name: prevQuotes[fullCode]?.name || '',
|
||||
price: currentIndex,
|
||||
prevClose,
|
||||
open: quoteData.open_index ?? quoteData.open_px ?? 0,
|
||||
high: quoteData.high_index ?? quoteData.high_px ?? 0,
|
||||
low: quoteData.low_index ?? quoteData.low_px ?? 0,
|
||||
close: quoteData.close_index,
|
||||
volume: quoteData.total_volume_trade ?? quoteData.volume ?? 0,
|
||||
amount: quoteData.total_value_trade ?? quoteData.amount ?? 0,
|
||||
numTrades: quoteData.num_trades,
|
||||
open: indexData.open_index || 0,
|
||||
high: indexData.high_index || 0,
|
||||
low: indexData.low_index || 0,
|
||||
close: indexData.close_index,
|
||||
volume: indexData.volume || 0,
|
||||
amount: indexData.amount || 0,
|
||||
numTrades: indexData.num_trades,
|
||||
change: currentIndex - prevClose,
|
||||
changePct: calcChangePct(currentIndex, prevClose),
|
||||
bidPrices: [],
|
||||
bidVolumes: [],
|
||||
askPrices: [],
|
||||
askVolumes: [],
|
||||
updateTime: quoteData.update_time || timestamp,
|
||||
updateTime: indexData.update_time || timestamp,
|
||||
exchange: 'SZSE',
|
||||
} as QuoteData;
|
||||
} else {
|
||||
// 股票/基金/债券数据格式
|
||||
const { bidPrices, bidVolumes, askPrices, askVolumes } = extractSZSEOrderBook(quoteData);
|
||||
const { prevClose, volume, amount, upperLimit, lowerLimit, tradingPhase } = extractSZSEPrices(quoteData);
|
||||
|
||||
updated[fullCode] = {
|
||||
code: fullCode,
|
||||
name: prevQuotes[fullCode]?.name || '',
|
||||
price: quoteData.last_px,
|
||||
prevClose,
|
||||
open: quoteData.open_px,
|
||||
high: quoteData.high_px,
|
||||
low: quoteData.low_px,
|
||||
volume,
|
||||
amount,
|
||||
numTrades: quoteData.num_trades,
|
||||
upperLimit,
|
||||
lowerLimit,
|
||||
change: quoteData.last_px - prevClose,
|
||||
changePct: calcChangePct(quoteData.last_px, prevClose),
|
||||
bidPrices,
|
||||
bidVolumes,
|
||||
askPrices,
|
||||
askVolumes,
|
||||
tradingPhase,
|
||||
updateTime: quoteData.update_time || timestamp,
|
||||
exchange: 'SZSE',
|
||||
} as QuoteData;
|
||||
}
|
||||
});
|
||||
|
||||
return hasUpdate ? updated : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理深交所实时消息 (兼容新旧 API)
|
||||
* 新 API (批量模式): type='stock'/'bond'/'fund'/'index', data = { code: quote, ... }
|
||||
* 旧 API (单条模式): type='realtime', category='stock', data = { security_id, ... }
|
||||
*/
|
||||
const handleSZSERealtimeMessage = (
|
||||
msg: SZSERealtimeMessage,
|
||||
subscribedCodes: Set<string>,
|
||||
prevQuotes: QuotesMap
|
||||
): QuotesMap | null => {
|
||||
const { type, category, data, timestamp } = msg;
|
||||
|
||||
// 新 API 批量格式检测:data 是字典 { code: quote, ... },没有 security_id 在顶层
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const anyData = data as any;
|
||||
const isBatchFormat = anyData && typeof anyData === 'object' && !anyData.security_id;
|
||||
|
||||
if (isBatchFormat && (type === 'stock' || type === 'bond' || type === 'fund' || type === 'index')) {
|
||||
return handleSZSEBatchMessage(msg, subscribedCodes, prevQuotes);
|
||||
break;
|
||||
}
|
||||
|
||||
// 旧 API 单条格式:data 直接是行情对象 { security_id, last_px, ... }
|
||||
const rawCode = anyData?.security_id;
|
||||
if (!rawCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
|
||||
|
||||
if (!subscribedCodes.has(rawCode) && !subscribedCodes.has(fullCode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updated: QuotesMap = { ...prevQuotes };
|
||||
|
||||
// 确定实际的类别:新 API 直接用 type,旧 API 用 category
|
||||
const actualCategory = (type === 'realtime' ? category : type) as string;
|
||||
|
||||
switch (actualCategory) {
|
||||
case 'stock': {
|
||||
const stockData = data as SZSEStockData;
|
||||
const { bidPrices, bidVolumes, askPrices, askVolumes } = extractSZSEOrderBook(stockData);
|
||||
const { prevClose, volume, amount, upperLimit, lowerLimit, tradingPhase } = extractSZSEPrices(stockData);
|
||||
const stockData = quote as SZSEStockData;
|
||||
const prevClose = stockData.prev_close_px || 0;
|
||||
|
||||
updated[fullCode] = {
|
||||
code: fullCode,
|
||||
@@ -263,60 +156,28 @@ const handleSZSERealtimeMessage = (
|
||||
open: stockData.open_px,
|
||||
high: stockData.high_px,
|
||||
low: stockData.low_px,
|
||||
volume,
|
||||
amount,
|
||||
volume: stockData.total_volume_trade,
|
||||
amount: stockData.total_value_trade,
|
||||
numTrades: stockData.num_trades,
|
||||
upperLimit,
|
||||
lowerLimit,
|
||||
upperLimit: stockData.upper_limit_px,
|
||||
lowerLimit: stockData.lower_limit_px,
|
||||
change: stockData.last_px - prevClose,
|
||||
changePct: calcChangePct(stockData.last_px, prevClose),
|
||||
bidPrices,
|
||||
bidVolumes,
|
||||
askPrices,
|
||||
askVolumes,
|
||||
tradingPhase,
|
||||
bidPrices: stockData.bid_prices || [],
|
||||
bidVolumes: stockData.bid_volumes || [],
|
||||
askPrices: stockData.ask_prices || [],
|
||||
askVolumes: stockData.ask_volumes || [],
|
||||
tradingPhase: stockData.trading_phase_code,
|
||||
updateTime: stockData.update_time || timestamp,
|
||||
exchange: 'SZSE',
|
||||
} as QuoteData;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'index': {
|
||||
const indexData = data as SZSEIndexData;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const anyIndexData = indexData as any;
|
||||
const prevClose = anyIndexData.prev_close_px ?? indexData.prev_close ?? 0;
|
||||
const currentIndex = anyIndexData.current_index ?? anyIndexData.last_px ?? 0;
|
||||
case 'bond': {
|
||||
const bondData = quote as SZSEBondData;
|
||||
const prevClose = bondData.prev_close || 0;
|
||||
|
||||
updated[fullCode] = {
|
||||
code: fullCode,
|
||||
name: prevQuotes[fullCode]?.name || '',
|
||||
price: currentIndex,
|
||||
prevClose,
|
||||
open: indexData.open_index ?? anyIndexData.open_px ?? 0,
|
||||
high: indexData.high_index ?? anyIndexData.high_px ?? 0,
|
||||
low: indexData.low_index ?? anyIndexData.low_px ?? 0,
|
||||
close: indexData.close_index,
|
||||
volume: anyIndexData.total_volume_trade ?? indexData.volume ?? 0,
|
||||
amount: anyIndexData.total_value_trade ?? indexData.amount ?? 0,
|
||||
numTrades: indexData.num_trades,
|
||||
change: currentIndex - prevClose,
|
||||
changePct: calcChangePct(currentIndex, prevClose),
|
||||
bidPrices: [],
|
||||
bidVolumes: [],
|
||||
askPrices: [],
|
||||
askVolumes: [],
|
||||
tradingPhase: indexData.trading_phase,
|
||||
updateTime: timestamp,
|
||||
exchange: 'SZSE',
|
||||
} as QuoteData;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'bond':
|
||||
case 'fund': {
|
||||
const bondData = data as SZSEBondData;
|
||||
const prevClose = (bondData as SZSEStockData).prev_close_px ?? bondData.prev_close ?? 0;
|
||||
updated[fullCode] = {
|
||||
code: fullCode,
|
||||
name: prevQuotes[fullCode]?.name || '',
|
||||
@@ -335,24 +196,23 @@ const handleSZSERealtimeMessage = (
|
||||
bidVolumes: [],
|
||||
askPrices: [],
|
||||
askVolumes: [],
|
||||
tradingPhase: bondData.trading_phase,
|
||||
updateTime: timestamp,
|
||||
tradingPhase: bondData.trading_phase_code,
|
||||
updateTime: bondData.update_time || 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);
|
||||
case 'hkstock': {
|
||||
const hkData = quote as SZSEHKStockData;
|
||||
const prevClose = hkData.prev_close || 0;
|
||||
|
||||
updated[fullCode] = {
|
||||
code: fullCode,
|
||||
name: prevQuotes[fullCode]?.name || '',
|
||||
price: hkData.last_px,
|
||||
prevClose: hkData.prev_close,
|
||||
prevClose,
|
||||
open: hkData.open_px,
|
||||
high: hkData.high_px,
|
||||
low: hkData.low_px,
|
||||
@@ -360,233 +220,77 @@ const handleSZSERealtimeMessage = (
|
||||
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,
|
||||
change: hkData.last_px - prevClose,
|
||||
changePct: calcChangePct(hkData.last_px, prevClose),
|
||||
bidPrices: hkData.bid_prices || [],
|
||||
bidVolumes: hkData.bid_volumes || [],
|
||||
askPrices: hkData.ask_prices || [],
|
||||
askVolumes: hkData.ask_volumes || [],
|
||||
tradingPhase: hkData.trading_phase_code,
|
||||
updateTime: hkData.update_time || timestamp,
|
||||
exchange: 'SZSE',
|
||||
isHK: true,
|
||||
} as QuoteData;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'afterhours_block':
|
||||
case 'afterhours_trading': {
|
||||
const afterhoursData = data as SZSEAfterhoursData;
|
||||
const existing = prevQuotes[fullCode];
|
||||
const afterhoursInfo = {
|
||||
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,
|
||||
};
|
||||
|
||||
// 盘后数据的当前价格:优先使用 bid_px(买入价),否则使用 offer_px(卖出价)
|
||||
const afterhoursPrice = afterhoursData.bid_px || afterhoursData.offer_px || 0;
|
||||
const prevClose = afterhoursData.prev_close || existing?.prevClose || 0;
|
||||
|
||||
if (existing) {
|
||||
// 合并到现有数据,同时更新价格(如果盘后价格有效)
|
||||
const newPrice = afterhoursPrice > 0 ? afterhoursPrice : existing.price;
|
||||
updated[fullCode] = {
|
||||
...existing,
|
||||
price: newPrice,
|
||||
change: newPrice - prevClose,
|
||||
changePct: calcChangePct(newPrice, prevClose),
|
||||
afterhours: afterhoursInfo,
|
||||
updateTime: timestamp,
|
||||
} as QuoteData;
|
||||
} else {
|
||||
// 盘后首次收到数据(刷新页面后),使用盘后价格创建行情结构
|
||||
updated[fullCode] = {
|
||||
code: fullCode,
|
||||
name: '',
|
||||
price: afterhoursPrice,
|
||||
prevClose: prevClose,
|
||||
open: 0,
|
||||
high: 0,
|
||||
low: 0,
|
||||
volume: 0,
|
||||
amount: 0,
|
||||
change: afterhoursPrice - prevClose,
|
||||
changePct: calcChangePct(afterhoursPrice, prevClose),
|
||||
bidPrices: afterhoursPrice > 0 ? [afterhoursPrice] : [],
|
||||
bidVolumes: [],
|
||||
askPrices: afterhoursData.offer_px > 0 ? [afterhoursData.offer_px] : [],
|
||||
askVolumes: [],
|
||||
tradingPhase: afterhoursData.trading_phase,
|
||||
afterhours: afterhoursInfo,
|
||||
updateTime: timestamp,
|
||||
exchange: 'SZSE',
|
||||
} as QuoteData;
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return updated;
|
||||
return hasUpdate ? updated : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理深交所快照消息 (snapshot)
|
||||
* 新 API: data 是单个股票对象 { security_id, last_px, ... }
|
||||
* 旧 API: data 是批量数据 { stocks: [...], indexes: [...], bonds: [...] }
|
||||
* 处理深交所快照消息
|
||||
*/
|
||||
const handleSZSESnapshotMessage = (
|
||||
msg: SZSESnapshotMessage,
|
||||
subscribedCodes: Set<string>,
|
||||
prevQuotes: QuotesMap
|
||||
): QuotesMap | null => {
|
||||
const updated: QuotesMap = { ...prevQuotes };
|
||||
let hasUpdate = false;
|
||||
const { data, timestamp } = msg;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = msg.data as any;
|
||||
if (!data || typeof data !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 新 API 格式:data 直接是单个股票对象
|
||||
if (data && data.security_id) {
|
||||
const stockData = data as SZSEStockData;
|
||||
const rawCode = stockData.security_id;
|
||||
if (!rawCode) return null;
|
||||
|
||||
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
|
||||
|
||||
if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) {
|
||||
const { bidPrices, bidVolumes, askPrices, askVolumes } = extractSZSEOrderBook(stockData);
|
||||
const { prevClose, volume, amount, upperLimit, lowerLimit, tradingPhase } = extractSZSEPrices(stockData);
|
||||
if (!subscribedCodes.has(rawCode) && !subscribedCodes.has(fullCode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prevClose = stockData.prev_close_px || 0;
|
||||
|
||||
const updated: QuotesMap = { ...prevQuotes };
|
||||
updated[fullCode] = {
|
||||
code: fullCode,
|
||||
name: '',
|
||||
name: prevQuotes[fullCode]?.name || '',
|
||||
price: stockData.last_px,
|
||||
prevClose,
|
||||
open: stockData.open_px,
|
||||
high: stockData.high_px,
|
||||
low: stockData.low_px,
|
||||
volume,
|
||||
amount,
|
||||
volume: stockData.total_volume_trade,
|
||||
amount: stockData.total_value_trade,
|
||||
numTrades: stockData.num_trades,
|
||||
upperLimit,
|
||||
lowerLimit,
|
||||
upperLimit: stockData.upper_limit_px,
|
||||
lowerLimit: stockData.lower_limit_px,
|
||||
change: stockData.last_px - prevClose,
|
||||
changePct: calcChangePct(stockData.last_px, prevClose),
|
||||
bidPrices,
|
||||
bidVolumes,
|
||||
askPrices,
|
||||
askVolumes,
|
||||
tradingPhase,
|
||||
updateTime: stockData.update_time || msg.timestamp,
|
||||
bidPrices: stockData.bid_prices || [],
|
||||
bidVolumes: stockData.bid_volumes || [],
|
||||
askPrices: stockData.ask_prices || [],
|
||||
askVolumes: stockData.ask_volumes || [],
|
||||
tradingPhase: stockData.trading_phase_code,
|
||||
updateTime: stockData.update_time || timestamp,
|
||||
exchange: 'SZSE',
|
||||
} as QuoteData;
|
||||
|
||||
return updated;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 旧 API 格式:批量数据
|
||||
const { stocks = [], indexes = [], bonds = [] } = data || {};
|
||||
|
||||
stocks.forEach((s: SZSEStockData) => {
|
||||
const rawCode = s.security_id;
|
||||
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
|
||||
|
||||
if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) {
|
||||
hasUpdate = true;
|
||||
const { bidPrices, bidVolumes, askPrices, askVolumes } = extractSZSEOrderBook(s);
|
||||
const { prevClose, volume, amount, upperLimit, lowerLimit, tradingPhase } = extractSZSEPrices(s);
|
||||
|
||||
updated[fullCode] = {
|
||||
code: fullCode,
|
||||
name: '',
|
||||
price: s.last_px,
|
||||
prevClose,
|
||||
open: s.open_px,
|
||||
high: s.high_px,
|
||||
low: s.low_px,
|
||||
volume,
|
||||
amount,
|
||||
numTrades: s.num_trades,
|
||||
upperLimit,
|
||||
lowerLimit,
|
||||
change: s.last_px - prevClose,
|
||||
changePct: calcChangePct(s.last_px, prevClose),
|
||||
bidPrices,
|
||||
bidVolumes,
|
||||
askPrices,
|
||||
askVolumes,
|
||||
tradingPhase,
|
||||
exchange: 'SZSE',
|
||||
} as QuoteData;
|
||||
}
|
||||
});
|
||||
|
||||
indexes.forEach((i: SZSEIndexData) => {
|
||||
const rawCode = i.security_id;
|
||||
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
|
||||
|
||||
if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) {
|
||||
hasUpdate = true;
|
||||
updated[fullCode] = {
|
||||
code: fullCode,
|
||||
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) => {
|
||||
const rawCode = b.security_id;
|
||||
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
|
||||
|
||||
if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) {
|
||||
hasUpdate = true;
|
||||
const prevClose = (b as SZSEStockData).prev_close_px ?? b.prev_close ?? 0;
|
||||
updated[fullCode] = {
|
||||
code: fullCode,
|
||||
name: '',
|
||||
price: b.last_px,
|
||||
prevClose,
|
||||
open: b.open_px,
|
||||
high: b.high_px,
|
||||
low: b.low_px,
|
||||
volume: b.volume,
|
||||
amount: b.amount,
|
||||
change: b.last_px - prevClose,
|
||||
changePct: calcChangePct(b.last_px, prevClose),
|
||||
bidPrices: [],
|
||||
bidVolumes: [],
|
||||
askPrices: [],
|
||||
askVolumes: [],
|
||||
exchange: 'SZSE',
|
||||
isBond: true,
|
||||
} as QuoteData;
|
||||
}
|
||||
});
|
||||
|
||||
return hasUpdate ? updated : null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -600,13 +304,7 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
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 reconnectCountRef = useRef<Record<Exchange, number>>({ SSE: 0, SZSE: 0 });
|
||||
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||
// 深交所 WebSocket 就绪状态(收到 welcome 消息后才能订阅)
|
||||
const szseReadyRef = useRef<boolean>(false);
|
||||
// 待发送的深交所订阅队列(在 welcome 之前收到的订阅请求)
|
||||
const szsePendingSubscribeRef = useRef<string[]>([]);
|
||||
|
||||
const subscribedCodes = useRef<Record<Exchange, Set<string>>>({
|
||||
SSE: new Set(),
|
||||
@@ -625,25 +323,45 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
heartbeatRefs.current[exchange] = setInterval(() => {
|
||||
const ws = wsRefs.current[exchange];
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
// 上交所和深交所(新 API)都使用 action: 'ping'
|
||||
ws.send(JSON.stringify({ action: 'ping' }));
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL);
|
||||
}, [stopHeartbeat]);
|
||||
|
||||
/**
|
||||
* 发送深交所订阅请求(新 API 格式)
|
||||
* 格式:{ action: 'subscribe', channels: ['stock', 'index'], codes: ['000001', '000002'] }
|
||||
* 发送深交所订阅请求 (新 API 格式)
|
||||
*/
|
||||
const sendSZSESubscribe = useCallback((baseCodes: string[]) => {
|
||||
const ws = wsRefs.current.SZSE;
|
||||
if (ws && ws.readyState === WebSocket.OPEN && baseCodes.length > 0) {
|
||||
const channels: SZSEChannel[] = ['stock', 'index'];
|
||||
ws.send(JSON.stringify({
|
||||
action: 'subscribe',
|
||||
channels: ['stock', 'index'], // 订阅股票和指数频道
|
||||
channels,
|
||||
codes: baseCodes,
|
||||
}));
|
||||
logger.info('FlexScreen', `SZSE 发送订阅请求`, { codes: baseCodes });
|
||||
logger.info('FlexScreen', `SZSE 发送订阅请求`, { channels, codes: baseCodes });
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 获取深交所订阅状态
|
||||
*/
|
||||
const getStatus = useCallback(() => {
|
||||
const ws = wsRefs.current.SZSE;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ action: 'status' }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 获取深交所股票快照
|
||||
*/
|
||||
const getSnapshot = useCallback((code: string) => {
|
||||
const ws = wsRefs.current.SZSE;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
const baseCode = normalizeCode(code);
|
||||
ws.send(JSON.stringify({ action: 'snapshot', code: baseCode }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -651,20 +369,19 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
* 处理消息
|
||||
*/
|
||||
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' || anyMsg.action === 'pong') return;
|
||||
// 心跳响应
|
||||
if (msg.type === 'pong') return;
|
||||
|
||||
if (exchange === 'SSE') {
|
||||
// 上交所消息处理
|
||||
if (msg.type === 'subscribed') {
|
||||
logger.info('FlexScreen', 'SSE 订阅成功', { channels: anyMsg.channels });
|
||||
const sseMsg = msg as SSEMessage;
|
||||
logger.info('FlexScreen', 'SSE 订阅成功', { channels: sseMsg.channels });
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'error') {
|
||||
logger.error('FlexScreen', 'SSE WebSocket 错误', { message: anyMsg.message });
|
||||
const sseMsg = msg as SSEMessage;
|
||||
logger.error('FlexScreen', 'SSE WebSocket 错误', { message: sseMsg.message });
|
||||
return;
|
||||
}
|
||||
// 处理行情数据
|
||||
@@ -677,46 +394,36 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
setQuotes(prev => ({ ...prev, ...result }));
|
||||
}
|
||||
} else {
|
||||
// 深交所消息处理(支持新旧两种 API 格式)
|
||||
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;
|
||||
// 深交所消息处理 (v4.0 API)
|
||||
const szseMsg = msg as SZSEMessage;
|
||||
|
||||
switch (szseMsg.type) {
|
||||
case 'subscribed':
|
||||
// 订阅成功确认(兼容新旧格式)
|
||||
logger.info('FlexScreen', 'SZSE 订阅成功', {
|
||||
channels: anyMsg.channels,
|
||||
codes: anyMsg.codes,
|
||||
securities: anyMsg.securities,
|
||||
categories: anyMsg.categories,
|
||||
channels: szseMsg.channels,
|
||||
codes: szseMsg.codes,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'unsubscribed':
|
||||
// 取消订阅确认
|
||||
logger.info('FlexScreen', 'SZSE 取消订阅成功');
|
||||
logger.info('FlexScreen', 'SZSE 取消订阅成功', {
|
||||
channels: szseMsg.channels,
|
||||
codes: szseMsg.codes,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'status':
|
||||
logger.info('FlexScreen', 'SZSE 订阅状态', {
|
||||
channels: szseMsg.channels,
|
||||
codes: szseMsg.codes,
|
||||
filter_active: szseMsg.filter_active,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'snapshot':
|
||||
// 快照消息(订阅后首次返回的批量数据)
|
||||
setQuotes(prev => {
|
||||
const result = handleSZSESnapshotMessage(
|
||||
msg as SZSESnapshotMessage,
|
||||
szseMsg as SZSESnapshotMessage,
|
||||
subscribedCodes.current.SZSE,
|
||||
prev
|
||||
);
|
||||
@@ -724,26 +431,13 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
});
|
||||
break;
|
||||
|
||||
case 'realtime':
|
||||
// 旧 API:实时行情推送 (type='realtime' + category)
|
||||
setQuotes(prev => {
|
||||
const result = handleSZSERealtimeMessage(
|
||||
msg as SZSERealtimeMessage,
|
||||
subscribedCodes.current.SZSE,
|
||||
prev
|
||||
);
|
||||
return result || prev;
|
||||
});
|
||||
break;
|
||||
|
||||
// 新 API:直接使用 type='stock'/'bond'/'fund'/'index' 作为消息类型
|
||||
case 'stock':
|
||||
case 'bond':
|
||||
case 'fund':
|
||||
case 'index':
|
||||
case 'bond':
|
||||
case 'hkstock':
|
||||
setQuotes(prev => {
|
||||
const result = handleSZSERealtimeMessage(
|
||||
msg as SZSERealtimeMessage,
|
||||
szseMsg as SZSERealtimeMessage,
|
||||
subscribedCodes.current.SZSE,
|
||||
prev
|
||||
);
|
||||
@@ -751,21 +445,20 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
});
|
||||
break;
|
||||
|
||||
case 'query_result':
|
||||
case 'query_batch_result':
|
||||
// 查询结果(目前不处理)
|
||||
case 'codes_list':
|
||||
// 代码列表响应(调试用)
|
||||
logger.info('FlexScreen', 'SZSE 可用代码列表', szseMsg);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
logger.error('FlexScreen', 'SZSE WebSocket 错误', { message: anyMsg.message });
|
||||
logger.error('FlexScreen', 'SZSE WebSocket 错误', { message: szseMsg.message });
|
||||
break;
|
||||
|
||||
default:
|
||||
// 未知消息类型
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [sendSZSESubscribe]);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 创建 WebSocket 连接
|
||||
@@ -787,11 +480,6 @@ 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;
|
||||
@@ -799,14 +487,21 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
ws.onopen = () => {
|
||||
logger.info('FlexScreen', `${exchange} WebSocket 已连接`, { url: wsUrl });
|
||||
setConnected(prev => ({ ...prev, [exchange]: true }));
|
||||
// 连接成功,重置重连计数
|
||||
reconnectCountRef.current[exchange] = 0;
|
||||
|
||||
if (exchange === 'SSE') {
|
||||
// 上交所:连接后立即发送订阅
|
||||
const fullCodes = Array.from(subscribedCodes.current.SSE);
|
||||
// 连接后立即发送订阅
|
||||
const fullCodes = Array.from(subscribedCodes.current[exchange]);
|
||||
const baseCodes = fullCodes.map(c => normalizeCode(c));
|
||||
|
||||
if (baseCodes.length > 0) {
|
||||
if (exchange === 'SSE') {
|
||||
ws.send(JSON.stringify({
|
||||
action: 'subscribe',
|
||||
channels: ['stock', 'index'],
|
||||
codes: baseCodes,
|
||||
}));
|
||||
} else {
|
||||
// 深交所:新 API 格式,连接后直接订阅
|
||||
ws.send(JSON.stringify({
|
||||
action: 'subscribe',
|
||||
channels: ['stock', 'index'],
|
||||
@@ -814,7 +509,6 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
}));
|
||||
}
|
||||
}
|
||||
// 深交所:等待 welcome 消息后再订阅
|
||||
|
||||
startHeartbeat(exchange);
|
||||
};
|
||||
@@ -828,10 +522,9 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error: Event) => {
|
||||
ws.onerror = () => {
|
||||
logger.error('FlexScreen', `${exchange} WebSocket 连接失败`, {
|
||||
url: wsUrl,
|
||||
readyState: ws.readyState,
|
||||
hint: '请检查:1) 后端服务是否启动 2) Nginx 代理是否配置正确',
|
||||
});
|
||||
};
|
||||
@@ -841,12 +534,7 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
setConnected(prev => ({ ...prev, [exchange]: false }));
|
||||
stopHeartbeat(exchange);
|
||||
|
||||
// 重置深交所就绪状态
|
||||
if (exchange === 'SZSE') {
|
||||
szseReadyRef.current = false;
|
||||
}
|
||||
|
||||
// 自动重连(有次数限制,避免刷屏)
|
||||
// 自动重连(指数退避)
|
||||
const currentAttempts = reconnectCountRef.current[exchange];
|
||||
if (
|
||||
!reconnectRefs.current[exchange] &&
|
||||
@@ -854,7 +542,6 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
currentAttempts < MAX_RECONNECT_ATTEMPTS
|
||||
) {
|
||||
reconnectCountRef.current[exchange] = currentAttempts + 1;
|
||||
// 指数退避:3秒、6秒、12秒、24秒、48秒
|
||||
const delay = RECONNECT_INTERVAL * Math.pow(2, currentAttempts);
|
||||
logger.info('FlexScreen', `${exchange} 将在 ${delay / 1000} 秒后重连 (${currentAttempts + 1}/${MAX_RECONNECT_ATTEMPTS})`);
|
||||
|
||||
@@ -865,9 +552,7 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
}
|
||||
}, delay);
|
||||
} else if (currentAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
logger.warn('FlexScreen', `${exchange} 达到最大重连次数,停止重连。请检查 WebSocket 服务是否正常。`, {
|
||||
url: wsUrl,
|
||||
});
|
||||
logger.warn('FlexScreen', `${exchange} 达到最大重连次数,停止重连`, { url: wsUrl });
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
@@ -887,26 +572,17 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
subscribedCodes.current[exchange].add(fullCode);
|
||||
|
||||
const ws = wsRefs.current[exchange];
|
||||
|
||||
if (exchange === 'SSE') {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
if (exchange === 'SSE') {
|
||||
ws.send(JSON.stringify({
|
||||
action: 'subscribe',
|
||||
channels: ['stock', 'index'],
|
||||
codes: [baseCode],
|
||||
}));
|
||||
} else {
|
||||
sendSZSESubscribe([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, sendSZSESubscribe]);
|
||||
@@ -921,9 +597,8 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
|
||||
subscribedCodes.current[exchange].delete(fullCode);
|
||||
|
||||
// 发送取消订阅请求(深交所新 API 格式)
|
||||
const ws = wsRefs.current[exchange];
|
||||
if (exchange === 'SZSE' && ws && ws.readyState === WebSocket.OPEN) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
action: 'unsubscribe',
|
||||
codes: [baseCode],
|
||||
@@ -936,12 +611,10 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
return updated;
|
||||
});
|
||||
|
||||
if (subscribedCodes.current[exchange].size === 0) {
|
||||
if (ws) {
|
||||
if (subscribedCodes.current[exchange].size === 0 && ws) {
|
||||
ws.close();
|
||||
wsRefs.current[exchange] = null;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 初始化和 codes 变化处理
|
||||
@@ -998,14 +671,9 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
const ws = wsRefs.current.SZSE;
|
||||
|
||||
if (szseToAdd.length > 0) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN && szseReadyRef.current) {
|
||||
// WebSocket 已就绪,直接发送订阅
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
sendSZSESubscribe(szseToAddBase);
|
||||
} else if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
// WebSocket 已连接但未就绪,加入待处理队列
|
||||
szsePendingSubscribeRef.current.push(...szseToAddBase);
|
||||
} else {
|
||||
// WebSocket 未连接,创建连接
|
||||
createConnection('SZSE');
|
||||
}
|
||||
}
|
||||
@@ -1045,7 +713,7 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
};
|
||||
}, [stopHeartbeat]);
|
||||
|
||||
return { quotes, connected, subscribe, unsubscribe };
|
||||
return { quotes, connected, subscribe, unsubscribe, getStatus, getSnapshot };
|
||||
};
|
||||
|
||||
export default useRealtimeQuote;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 实时行情相关工具函数
|
||||
*/
|
||||
|
||||
import type { Exchange, OrderBookLevel } from '../types';
|
||||
import type { Exchange } from '../types';
|
||||
|
||||
/**
|
||||
* 判断证券代码属于哪个交易所
|
||||
@@ -75,23 +75,6 @@ export const normalizeCode = (code: string): string => {
|
||||
return code.split('.')[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* 从深交所 bids/asks 数组提取价格和量数组
|
||||
* 格式:[{price, volume}, ...]
|
||||
* @param orderBook - 盘口数组
|
||||
* @returns { prices, volumes }
|
||||
*/
|
||||
export const extractOrderBook = (
|
||||
orderBook: OrderBookLevel[] | undefined
|
||||
): { prices: number[]; volumes: number[] } => {
|
||||
if (!orderBook || !Array.isArray(orderBook) || orderBook.length === 0) {
|
||||
return { prices: [], volumes: [] };
|
||||
}
|
||||
const prices = orderBook.map(item => item.price || 0);
|
||||
const volumes = orderBook.map(item => item.volume || 0);
|
||||
return { prices, volumes };
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算涨跌幅
|
||||
* @param price - 当前价
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* 灵活屏组件类型定义
|
||||
* 基于深交所 WebSocket API v4.0 (SZSE_WEBSOCKET_API.md)
|
||||
*/
|
||||
|
||||
// ==================== WebSocket 相关类型 ====================
|
||||
@@ -7,18 +8,15 @@
|
||||
/** 交易所标识 */
|
||||
export type Exchange = 'SSE' | 'SZSE';
|
||||
|
||||
/** 深交所频道类型 */
|
||||
export type SZSEChannel = 'stock' | 'index' | 'bond' | 'hkstock';
|
||||
|
||||
/** WebSocket 连接状态 */
|
||||
export interface ConnectionStatus {
|
||||
SSE: boolean;
|
||||
SZSE: boolean;
|
||||
}
|
||||
|
||||
/** 盘口档位数据 */
|
||||
export interface OrderBookLevel {
|
||||
price: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
// ==================== 行情数据类型 ====================
|
||||
|
||||
/** 盘后交易数据 */
|
||||
@@ -59,7 +57,7 @@ export interface StockQuoteData extends BaseQuoteData {
|
||||
askPrices: number[];
|
||||
askVolumes: number[];
|
||||
tradingPhase?: string;
|
||||
afterhours?: AfterhoursData; // 盘后交易数据
|
||||
afterhours?: AfterhoursData;
|
||||
}
|
||||
|
||||
/** 指数行情数据 */
|
||||
@@ -140,191 +138,180 @@ export interface SSEMessage {
|
||||
|
||||
// ==================== 深交所 WebSocket 消息类型 ====================
|
||||
// API 文档: SZSE_WEBSOCKET_API.md
|
||||
// 与上交所 API 保持一致的设计
|
||||
|
||||
/** 深交所数据类别(对应 channels) */
|
||||
export type SZSECategory = 'stock' | 'bond' | 'fund';
|
||||
|
||||
/** 深交所股票行情数据(新 API 格式) */
|
||||
/**
|
||||
* 深交所股票行情数据 (消息类型 300111)
|
||||
* 字段名与 API 文档保持一致
|
||||
*/
|
||||
export interface SZSEStockData {
|
||||
security_id: string;
|
||||
md_stream_id?: string; // MDStreamID: 010
|
||||
md_stream_id?: string;
|
||||
orig_time?: number;
|
||||
channel_no?: number;
|
||||
trading_phase_code?: string; // 新字段名
|
||||
trading_phase?: string; // 兼容旧字段名
|
||||
prev_close_px: number; // 新字段名
|
||||
prev_close?: number; // 兼容旧字段名
|
||||
trading_phase_code?: string;
|
||||
prev_close_px: number;
|
||||
open_px: number;
|
||||
high_px: number;
|
||||
low_px: number;
|
||||
last_px: number;
|
||||
upper_limit_px?: number; // 新字段名
|
||||
upper_limit?: number; // 兼容旧字段名
|
||||
lower_limit_px?: number; // 新字段名
|
||||
lower_limit?: number; // 兼容旧字段名
|
||||
upper_limit_px?: number;
|
||||
lower_limit_px?: number;
|
||||
num_trades?: number;
|
||||
total_volume_trade?: number; // 新字段名 (成交量)
|
||||
total_value_trade?: number; // 新字段名 (成交额)
|
||||
volume?: number; // 兼容旧字段名
|
||||
amount?: number; // 兼容旧字段名
|
||||
// 新 API 格式:直接是数组
|
||||
bid_prices?: number[];
|
||||
bid_volumes?: number[];
|
||||
ask_prices?: number[];
|
||||
ask_volumes?: number[];
|
||||
// 兼容旧格式
|
||||
bids?: OrderBookLevel[];
|
||||
asks?: OrderBookLevel[];
|
||||
total_volume_trade: number;
|
||||
total_value_trade: number;
|
||||
bid_prices: number[];
|
||||
bid_volumes: number[];
|
||||
ask_prices: number[];
|
||||
ask_volumes: number[];
|
||||
update_time?: string;
|
||||
}
|
||||
|
||||
/** 深交所指数行情数据 */
|
||||
/**
|
||||
* 深交所指数行情数据 (消息类型 309011)
|
||||
*/
|
||||
export interface SZSEIndexData {
|
||||
security_id: string;
|
||||
orig_time?: number;
|
||||
channel_no?: number;
|
||||
trading_phase?: string;
|
||||
md_stream_id?: string;
|
||||
prev_close: number;
|
||||
num_trades?: number;
|
||||
volume: number;
|
||||
amount: number;
|
||||
current_index: number;
|
||||
open_index: number;
|
||||
high_index: number;
|
||||
low_index: number;
|
||||
close_index?: number;
|
||||
prev_close: number;
|
||||
volume: number;
|
||||
amount: number;
|
||||
num_trades?: number;
|
||||
update_time?: string;
|
||||
}
|
||||
|
||||
/** 深交所债券行情数据 */
|
||||
/**
|
||||
* 深交所债券行情数据 (消息类型 300211)
|
||||
*/
|
||||
export interface SZSEBondData {
|
||||
security_id: string;
|
||||
orig_time?: number;
|
||||
channel_no?: number;
|
||||
trading_phase?: string;
|
||||
last_px: number;
|
||||
md_stream_id?: string;
|
||||
trading_phase_code?: string;
|
||||
prev_close: number;
|
||||
open_px: number;
|
||||
high_px: number;
|
||||
low_px: number;
|
||||
prev_close: number;
|
||||
last_px: number;
|
||||
weighted_avg_px?: number;
|
||||
num_trades?: number;
|
||||
volume: number;
|
||||
amount: number;
|
||||
num_trades?: number;
|
||||
auction_volume?: number;
|
||||
auction_amount?: number;
|
||||
update_time?: string;
|
||||
}
|
||||
|
||||
/** 深交所港股行情数据 */
|
||||
/**
|
||||
* 深交所港股行情数据 (消息类型 306311)
|
||||
*/
|
||||
export interface SZSEHKStockData {
|
||||
security_id: string;
|
||||
orig_time?: number;
|
||||
channel_no?: number;
|
||||
trading_phase?: string;
|
||||
last_px: number;
|
||||
md_stream_id?: string;
|
||||
trading_phase_code?: string;
|
||||
prev_close: number;
|
||||
open_px: number;
|
||||
high_px: number;
|
||||
low_px: number;
|
||||
prev_close: number;
|
||||
nominal_px?: number;
|
||||
reference_px?: number;
|
||||
last_px: number;
|
||||
nominal_px?: number; // 按盘价
|
||||
num_trades?: number;
|
||||
volume: number;
|
||||
amount: number;
|
||||
num_trades?: number;
|
||||
vcm_start_time?: number;
|
||||
vcm_end_time?: number;
|
||||
bids?: OrderBookLevel[];
|
||||
asks?: OrderBookLevel[];
|
||||
bid_prices: number[];
|
||||
bid_volumes: number[];
|
||||
ask_prices: number[];
|
||||
ask_volumes: number[];
|
||||
update_time?: string;
|
||||
}
|
||||
|
||||
/** 深交所盘后交易数据 */
|
||||
export interface SZSEAfterhoursData {
|
||||
security_id: string;
|
||||
orig_time?: number;
|
||||
channel_no?: number;
|
||||
trading_phase?: string;
|
||||
prev_close: number;
|
||||
bid_px: number;
|
||||
bid_size: number;
|
||||
offer_px: number;
|
||||
offer_size: number;
|
||||
volume: number;
|
||||
amount: number;
|
||||
num_trades?: number;
|
||||
}
|
||||
|
||||
/** 深交所实时消息(新 API 格式:type 直接是 'stock' | 'index' | 'bond' | 'fund') */
|
||||
/**
|
||||
* 深交所实时推送消息 (批量格式)
|
||||
* type 直接表示频道类型
|
||||
*/
|
||||
export interface SZSERealtimeMessage {
|
||||
type: 'stock' | 'index' | 'bond' | 'fund' | 'hkstock' | 'realtime'; // 新 API 直接用 type='stock' 等
|
||||
category?: SZSECategory; // 旧 API 使用 category
|
||||
msg_type?: number;
|
||||
type: 'stock' | 'index' | 'bond' | 'hkstock';
|
||||
data: Record<string, SZSEStockData | SZSEIndexData | SZSEBondData | SZSEHKStockData>;
|
||||
timestamp: string;
|
||||
// 新 API 批量格式:data 是 { code: quote, ... } 字典
|
||||
// 旧 API 单条格式:data 是单个行情对象
|
||||
data: SZSEStockData | SZSEIndexData | SZSEBondData | SZSEHKStockData | SZSEAfterhoursData | Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 深交所快照消息 */
|
||||
/**
|
||||
* 深交所快照响应消息
|
||||
*/
|
||||
export interface SZSESnapshotMessage {
|
||||
type: 'snapshot';
|
||||
data: SZSEStockData | SZSEIndexData | SZSEBondData | SZSEHKStockData;
|
||||
timestamp?: string;
|
||||
data: SZSEStockData | {
|
||||
// 兼容旧格式的批量快照
|
||||
stocks?: SZSEStockData[];
|
||||
indexes?: SZSEIndexData[];
|
||||
bonds?: SZSEBondData[];
|
||||
};
|
||||
}
|
||||
|
||||
/** 深交所欢迎消息 */
|
||||
export interface SZSEWelcomeMessage {
|
||||
type: 'welcome';
|
||||
message: string;
|
||||
timestamp: string;
|
||||
usage?: Record<string, unknown>;
|
||||
categories?: string[];
|
||||
}
|
||||
|
||||
/** 深交所订阅确认消息 */
|
||||
/**
|
||||
* 深交所订阅确认消息
|
||||
*/
|
||||
export interface SZSESubscribedMessage {
|
||||
type: 'subscribed';
|
||||
channels?: string[]; // 新 API 格式
|
||||
codes?: string[]; // 新 API 格式
|
||||
securities?: string[]; // 兼容旧格式
|
||||
categories?: string[]; // 兼容旧格式
|
||||
all?: boolean;
|
||||
incremental_only?: boolean;
|
||||
message?: string;
|
||||
channels: SZSEChannel[];
|
||||
codes: string[];
|
||||
}
|
||||
|
||||
/** 深交所取消订阅确认消息 */
|
||||
/**
|
||||
* 深交所取消订阅确认消息
|
||||
*/
|
||||
export interface SZSEUnsubscribedMessage {
|
||||
type: 'unsubscribed';
|
||||
channels?: string[]; // 新 API 格式
|
||||
codes?: string[]; // 新 API 格式
|
||||
securities?: string[]; // 兼容旧格式
|
||||
categories?: string[]; // 兼容旧格式
|
||||
remaining_securities?: string[];
|
||||
remaining_categories?: string[];
|
||||
channels: SZSEChannel[];
|
||||
codes: string[];
|
||||
}
|
||||
|
||||
/** 深交所错误消息 */
|
||||
/**
|
||||
* 深交所订阅状态响应
|
||||
*/
|
||||
export interface SZSEStatusMessage {
|
||||
type: 'status';
|
||||
channels: SZSEChannel[];
|
||||
codes: string[];
|
||||
filter_active: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 深交所代码列表响应 (单频道)
|
||||
*/
|
||||
export interface SZSECodesListSingleMessage {
|
||||
type: 'codes_list';
|
||||
category: SZSEChannel;
|
||||
codes: string[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 深交所代码列表响应 (全部频道)
|
||||
*/
|
||||
export interface SZSECodesListAllMessage {
|
||||
type: 'codes_list';
|
||||
data: Record<SZSEChannel, { codes: string[]; count: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 深交所错误消息
|
||||
*/
|
||||
export interface SZSEErrorMessage {
|
||||
type: 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** 深交所消息类型 */
|
||||
/**
|
||||
* 深交所消息联合类型
|
||||
*/
|
||||
export type SZSEMessage =
|
||||
| SZSERealtimeMessage
|
||||
| SZSESnapshotMessage
|
||||
| SZSEWelcomeMessage
|
||||
| SZSESubscribedMessage
|
||||
| SZSEUnsubscribedMessage
|
||||
| SZSEStatusMessage
|
||||
| SZSECodesListSingleMessage
|
||||
| SZSECodesListAllMessage
|
||||
| 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<string, unknown> };
|
||||
| { type: 'pong' };
|
||||
|
||||
// ==================== 组件 Props 类型 ====================
|
||||
|
||||
@@ -377,4 +364,8 @@ export interface UseRealtimeQuoteReturn {
|
||||
connected: ConnectionStatus;
|
||||
subscribe: (code: string) => void;
|
||||
unsubscribe: (code: string) => void;
|
||||
/** 获取订阅状态 (仅深交所) */
|
||||
getStatus: () => void;
|
||||
/** 获取单只股票快照 (仅深交所) */
|
||||
getSnapshot: (code: string) => void;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user