update pay ui

This commit is contained in:
2025-12-11 11:18:12 +08:00
parent 6c26f6dabc
commit dae1a539ac
2 changed files with 239 additions and 75 deletions

View File

@@ -79,14 +79,100 @@ const handleSSEMessage = (
};
/**
* 处理深交所实时消息 (realtime)
* 深交所数据中提取盘口价格和量
* 新 API 格式:直接使用 bid_prices/bid_volumes/ask_prices/ask_volumes 数组
* 旧 API 格式:使用 bids/asks 对象数组
*/
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 格式type='stock'
*/
const handleSZSEStockMessage = (
data: SZSEStockData,
timestamp: string,
subscribedCodes: Set<string>,
prevQuotes: QuotesMap
): QuotesMap | null => {
const rawCode = data.security_id;
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
if (!subscribedCodes.has(rawCode) && !subscribedCodes.has(fullCode)) {
return null;
}
const { bidPrices, bidVolumes, askPrices, askVolumes } = extractSZSEOrderBook(data);
const { prevClose, volume, amount, upperLimit, lowerLimit, tradingPhase } = extractSZSEPrices(data);
const updated: QuotesMap = { ...prevQuotes };
updated[fullCode] = {
code: fullCode,
name: prevQuotes[fullCode]?.name || '',
price: data.last_px,
prevClose,
open: data.open_px,
high: data.high_px,
low: data.low_px,
volume,
amount,
numTrades: data.num_trades,
upperLimit,
lowerLimit,
change: data.last_px - prevClose,
changePct: calcChangePct(data.last_px, prevClose),
bidPrices,
bidVolumes,
askPrices,
askVolumes,
tradingPhase,
updateTime: data.update_time || timestamp,
exchange: 'SZSE',
} as QuoteData;
return updated;
};
/**
* 处理深交所实时消息 (兼容新旧 API)
* 新 API: type='stock'/'bond'/'fund', data 直接是行情对象
* 旧 API: type='realtime', category='stock'/'bond'/etc
*/
const handleSZSERealtimeMessage = (
msg: SZSERealtimeMessage,
subscribedCodes: Set<string>,
prevQuotes: QuotesMap
): QuotesMap | null => {
const { category, data, timestamp } = msg;
const { type, category, data, timestamp } = msg;
const rawCode = data.security_id;
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
@@ -96,33 +182,36 @@ const handleSZSERealtimeMessage = (
const updated: QuotesMap = { ...prevQuotes };
switch (category) {
// 确定实际的类别:新 API 直接用 type旧 API 用 category
const actualCategory = (type === 'realtime' ? category : type) as string;
switch (actualCategory) {
case 'stock': {
const stockData = data as SZSEStockData;
const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(stockData.bids);
const { prices: askPrices, volumes: askVolumes } = extractOrderBook(stockData.asks);
const { bidPrices, bidVolumes, askPrices, askVolumes } = extractSZSEOrderBook(stockData);
const { prevClose, volume, amount, upperLimit, lowerLimit, tradingPhase } = extractSZSEPrices(stockData);
updated[fullCode] = {
code: fullCode,
name: prevQuotes[fullCode]?.name || '',
price: stockData.last_px,
prevClose: stockData.prev_close,
prevClose,
open: stockData.open_px,
high: stockData.high_px,
low: stockData.low_px,
volume: stockData.volume,
amount: stockData.amount,
volume,
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),
upperLimit,
lowerLimit,
change: stockData.last_px - prevClose,
changePct: calcChangePct(stockData.last_px, prevClose),
bidPrices,
bidVolumes,
askPrices,
askVolumes,
tradingPhase: stockData.trading_phase,
updateTime: timestamp,
tradingPhase,
updateTime: stockData.update_time || timestamp,
exchange: 'SZSE',
} as QuoteData;
break;
@@ -155,13 +244,15 @@ const handleSZSERealtimeMessage = (
break;
}
case 'bond': {
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 || '',
price: bondData.last_px,
prevClose: bondData.prev_close,
prevClose,
open: bondData.open_px,
high: bondData.high_px,
low: bondData.low_px,
@@ -169,8 +260,8 @@ const handleSZSERealtimeMessage = (
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),
change: bondData.last_px - prevClose,
changePct: calcChangePct(bondData.last_px, prevClose),
bidPrices: [],
bidVolumes: [],
askPrices: [],
@@ -280,45 +371,90 @@ const handleSZSERealtimeMessage = (
/**
* 处理深交所快照消息 (snapshot)
* 订阅后首次返回的批量数据
* 新 API: data 是单个股票对象 { security_id, last_px, ... }
* 旧 API: data 是批量数据 { stocks: [...], indexes: [...], bonds: [...] }
*/
const handleSZSESnapshotMessage = (
msg: SZSESnapshotMessage,
subscribedCodes: Set<string>,
prevQuotes: QuotesMap
): QuotesMap | null => {
const { stocks = [], indexes = [], bonds = [] } = msg.data || {};
const updated: QuotesMap = { ...prevQuotes };
let hasUpdate = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = msg.data as any;
// 新 API 格式data 直接是单个股票对象
if (data && data.security_id) {
const stockData = data as SZSEStockData;
const rawCode = stockData.security_id;
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);
updated[fullCode] = {
code: fullCode,
name: '',
price: stockData.last_px,
prevClose,
open: stockData.open_px,
high: stockData.high_px,
low: stockData.low_px,
volume,
amount,
numTrades: stockData.num_trades,
upperLimit,
lowerLimit,
change: stockData.last_px - prevClose,
changePct: calcChangePct(stockData.last_px, prevClose),
bidPrices,
bidVolumes,
askPrices,
askVolumes,
tradingPhase,
updateTime: stockData.update_time || msg.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 { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(s.bids);
const { prices: askPrices, volumes: askVolumes } = extractOrderBook(s.asks);
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: s.prev_close,
prevClose,
open: s.open_px,
high: s.high_px,
low: s.low_px,
volume: s.volume,
amount: s.amount,
volume,
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),
upperLimit,
lowerLimit,
change: s.last_px - prevClose,
changePct: calcChangePct(s.last_px, prevClose),
bidPrices,
bidVolumes,
askPrices,
askVolumes,
tradingPhase,
exchange: 'SZSE',
} as QuoteData;
}
@@ -358,18 +494,19 @@ const handleSZSESnapshotMessage = (
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: b.prev_close,
prevClose,
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),
change: b.last_px - prevClose,
changePct: calcChangePct(b.last_px, prevClose),
bidPrices: [],
bidVolumes: [],
askPrices: [],
@@ -419,27 +556,25 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
heartbeatRefs.current[exchange] = setInterval(() => {
const ws = wsRefs.current[exchange];
if (ws && ws.readyState === WebSocket.OPEN) {
// 上交所使用 action: 'ping',深交所使用 type: 'ping'
const pingMsg = exchange === 'SSE'
? { action: 'ping' }
: { type: 'ping' };
ws.send(JSON.stringify(pingMsg));
// 上交所和深交所(新 API使用 action: 'ping'
ws.send(JSON.stringify({ action: 'ping' }));
}
}, HEARTBEAT_INTERVAL);
}, [stopHeartbeat]);
/**
* 发送深交所订阅请求
* 格式:{ type: 'subscribe', securities: ['000001', '000002'] }
* 发送深交所订阅请求(新 API 格式)
* 格式:{ action: 'subscribe', channels: ['stock'], codes: ['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,
action: 'subscribe',
channels: ['stock'], // 订阅股票频道
codes: baseCodes,
}));
logger.info('FlexScreen', `SZSE 发送订阅请求`, { securities: baseCodes });
logger.info('FlexScreen', `SZSE 发送订阅请求`, { codes: baseCodes });
}
}, []);
@@ -473,7 +608,7 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
setQuotes(prev => ({ ...prev, ...result }));
}
} else {
// 深交所消息处理
// 深交所消息处理(支持新旧两种 API 格式)
switch (msg.type) {
case 'welcome':
// 收到欢迎消息,深交所 WebSocket 就绪
@@ -494,8 +629,10 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
break;
case 'subscribed':
// 订阅成功确认
// 订阅成功确认(兼容新旧格式)
logger.info('FlexScreen', 'SZSE 订阅成功', {
channels: anyMsg.channels,
codes: anyMsg.codes,
securities: anyMsg.securities,
categories: anyMsg.categories,
});
@@ -519,7 +656,21 @@ 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' 作为消息类型
case 'stock':
case 'bond':
case 'fund':
setQuotes(prev => {
const result = handleSZSERealtimeMessage(
msg as SZSERealtimeMessage,
@@ -700,12 +851,12 @@ 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) {
ws.send(JSON.stringify({
type: 'unsubscribe',
securities: [baseCode],
action: 'unsubscribe',
codes: [baseCode],
}));
}

View File

@@ -139,36 +139,44 @@ export interface SSEMessage {
}
// ==================== 深交所 WebSocket 消息类型 ====================
// API 文档: SZSE_WEBSOCKET_API.md
// 与上交所 API 保持一致的设计
/** 深交所数据类别 */
export type SZSECategory =
| 'stock' // 300111 股票快照
| 'bond' // 300211 债券快照
| 'afterhours_block' // 300611 盘后定价大宗交易
| 'afterhours_trading' // 303711 盘后定价交易
| 'hk_stock' // 306311 港股快照
| 'index' // 309011 指数快照
| 'volume_stats' // 309111 成交量统计
| 'fund_nav'; // 309211 基金净值
/** 深交所数据类别(对应 channels */
export type SZSECategory = 'stock' | 'bond' | 'fund';
/** 深交所股票行情数据 */
/** 深交所股票行情数据(新 API 格式) */
export interface SZSEStockData {
security_id: string;
md_stream_id?: string; // MDStreamID: 010
orig_time?: number;
channel_no?: number;
trading_phase?: string;
last_px: number;
trading_phase_code?: string; // 新字段名
trading_phase?: string; // 兼容旧字段名
prev_close_px: number; // 新字段名
prev_close?: number; // 兼容旧字段名
open_px: number;
high_px: number;
low_px: number;
prev_close: number;
volume: number;
amount: number;
last_px: number;
upper_limit_px?: number; // 新字段名
upper_limit?: number; // 兼容旧字段名
lower_limit_px?: number; // 新字段名
lower_limit?: number; // 兼容旧字段名
num_trades?: number;
upper_limit?: number;
lower_limit?: 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[];
update_time?: string;
}
/** 深交所指数行情数据 */
@@ -245,10 +253,10 @@ export interface SZSEAfterhoursData {
num_trades?: number;
}
/** 深交所实时消息 */
/** 深交所实时消息(新 API 格式type 直接是 'stock' | 'bond' | 'fund' */
export interface SZSERealtimeMessage {
type: 'realtime';
category: SZSECategory;
type: 'stock' | 'bond' | 'fund' | 'realtime'; // 新 API 直接用 type='stock' 等
category?: SZSECategory; // 旧 API 使用 category
msg_type?: number;
timestamp: string;
data: SZSEStockData | SZSEIndexData | SZSEBondData | SZSEHKStockData | SZSEAfterhoursData;
@@ -257,8 +265,9 @@ export interface SZSERealtimeMessage {
/** 深交所快照消息 */
export interface SZSESnapshotMessage {
type: 'snapshot';
timestamp: string;
data: {
timestamp?: string;
data: SZSEStockData | {
// 兼容旧格式的批量快照
stocks?: SZSEStockData[];
indexes?: SZSEIndexData[];
bonds?: SZSEBondData[];
@@ -277,8 +286,10 @@ export interface SZSEWelcomeMessage {
/** 深交所订阅确认消息 */
export interface SZSESubscribedMessage {
type: 'subscribed';
securities?: string[];
categories?: string[];
channels?: string[]; // 新 API 格式
codes?: string[]; // 新 API 格式
securities?: string[]; // 兼容旧格式
categories?: string[]; // 兼容旧格式
all?: boolean;
incremental_only?: boolean;
message?: string;
@@ -287,8 +298,10 @@ export interface SZSESubscribedMessage {
/** 深交所取消订阅确认消息 */
export interface SZSEUnsubscribedMessage {
type: 'unsubscribed';
securities?: string[];
categories?: string[];
channels?: string[]; // 新 API 格式
codes?: string[]; // 新 API 格式
securities?: string[]; // 兼容旧格式
categories?: string[]; // 兼容旧格式
remaining_securities?: string[];
remaining_categories?: string[];
}