更新ios
This commit is contained in:
@@ -167,8 +167,30 @@ export const useSingleQuote = (code) => {
|
|||||||
updateRedux: false,
|
updateRedux: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 查找行情(WebSocket 服务已经同时用纯代码和完整代码作为 key)
|
||||||
|
const findQuote = (stockCode) => {
|
||||||
|
if (!stockCode || Object.keys(quotes).length === 0) return null;
|
||||||
|
|
||||||
|
// 直接匹配
|
||||||
|
if (quotes[stockCode]) return quotes[stockCode];
|
||||||
|
|
||||||
|
// 提取纯数字代码
|
||||||
|
const pureCode = stockCode.replace(/\D/g, '');
|
||||||
|
if (quotes[pureCode]) return quotes[pureCode];
|
||||||
|
|
||||||
|
// 尝试带后缀的格式
|
||||||
|
const withSH = `${pureCode}.SH`;
|
||||||
|
const withSZ = `${pureCode}.SZ`;
|
||||||
|
if (quotes[withSH]) return quotes[withSH];
|
||||||
|
if (quotes[withSZ]) return quotes[withSZ];
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const quote = code ? findQuote(code) : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
quote: code ? quotes[code] : null,
|
quote,
|
||||||
isConnected,
|
isConnected,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,6 +41,31 @@ const formatTime = (time) => {
|
|||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 将时间字符串转换为分钟数(用于 X 轴计算)
|
||||||
|
// A股交易时间:9:30-11:30(120分钟)+ 13:00-15:00(120分钟)= 总共240分钟
|
||||||
|
const timeToMinutes = (timeStr) => {
|
||||||
|
if (!timeStr) return 0;
|
||||||
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||||
|
const totalMinutes = hours * 60 + minutes;
|
||||||
|
|
||||||
|
// 上午时段:9:30-11:30 -> 0-120
|
||||||
|
if (totalMinutes >= 570 && totalMinutes <= 690) { // 9:30=570, 11:30=690
|
||||||
|
return totalMinutes - 570;
|
||||||
|
}
|
||||||
|
// 下午时段:13:00-15:00 -> 120-240
|
||||||
|
if (totalMinutes >= 780 && totalMinutes <= 900) { // 13:00=780, 15:00=900
|
||||||
|
return 120 + (totalMinutes - 780);
|
||||||
|
}
|
||||||
|
// 午休时间,返回上午收盘位置
|
||||||
|
if (totalMinutes > 690 && totalMinutes < 780) {
|
||||||
|
return 120;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 总交易分钟数
|
||||||
|
const TOTAL_TRADING_MINUTES = 240;
|
||||||
|
|
||||||
// 格式化价格
|
// 格式化价格
|
||||||
const formatPrice = (price) => {
|
const formatPrice = (price) => {
|
||||||
if (price === undefined || price === null || isNaN(price)) return '--';
|
if (price === undefined || price === null || isNaN(price)) return '--';
|
||||||
@@ -148,31 +173,39 @@ const MinuteChart = memo(({ data = [], preClose, loading }) => {
|
|||||||
const drawWidth = CHART_WIDTH - PADDING.left - PADDING.right;
|
const drawWidth = CHART_WIDTH - PADDING.left - PADDING.right;
|
||||||
const drawHeight = CHART_HEIGHT - PADDING.top - PADDING.bottom;
|
const drawHeight = CHART_HEIGHT - PADDING.top - PADDING.bottom;
|
||||||
|
|
||||||
// 坐标转换函数
|
// 坐标转换函数 - 使用实际时间位置而不是索引
|
||||||
const xScale = (index) => PADDING.left + (index / (data.length - 1 || 1)) * drawWidth;
|
const xScaleByTime = (timeStr) => {
|
||||||
|
const minutes = timeToMinutes(timeStr);
|
||||||
|
return PADDING.left + (minutes / TOTAL_TRADING_MINUTES) * drawWidth;
|
||||||
|
};
|
||||||
|
// 保留索引版本用于成交量等
|
||||||
|
const xScaleByIndex = (index) => PADDING.left + (index / (data.length - 1 || 1)) * drawWidth;
|
||||||
const yScale = (price) => PADDING.top + ((maxPrice - price) / priceRange) * drawHeight;
|
const yScale = (price) => PADDING.top + ((maxPrice - price) / priceRange) * drawHeight;
|
||||||
|
|
||||||
// 分时线点位
|
// 分时线点位 - 使用时间来计算 X 坐标
|
||||||
const pricePoints = data.map((d, i) => {
|
const pricePoints = data.map((d, i) => {
|
||||||
const price = d.price || d.current_price || d.close || effectivePreClose;
|
const price = d.price || d.current_price || d.close || effectivePreClose;
|
||||||
|
const time = d.time || '';
|
||||||
return {
|
return {
|
||||||
x: xScale(i),
|
x: xScaleByTime(time),
|
||||||
y: yScale(price),
|
y: yScale(price),
|
||||||
price,
|
price,
|
||||||
time: d.time,
|
time,
|
||||||
volume: d.volume,
|
volume: d.volume,
|
||||||
avgPrice: d.avg_price || d.average_price,
|
avgPrice: d.avg_price || d.average_price,
|
||||||
changePct: d.change_pct, // 保存 API 返回的涨跌幅
|
changePct: d.change_pct, // 保存 API 返回的涨跌幅
|
||||||
|
index: i, // 保留索引用于成交量
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// 均价线点位
|
// 均价线点位 - 使用时间来计算 X 坐标
|
||||||
const avgPoints = data
|
const avgPoints = data
|
||||||
.map((d, i) => {
|
.map((d, i) => {
|
||||||
const avgPrice = d.avg_price || d.average_price;
|
const avgPrice = d.avg_price || d.average_price;
|
||||||
if (!avgPrice || avgPrice <= 0) return null;
|
if (!avgPrice || avgPrice <= 0) return null;
|
||||||
|
const time = d.time || '';
|
||||||
return {
|
return {
|
||||||
x: xScale(i),
|
x: xScaleByTime(time),
|
||||||
y: yScale(avgPrice),
|
y: yScale(avgPrice),
|
||||||
price: avgPrice,
|
price: avgPrice,
|
||||||
};
|
};
|
||||||
@@ -196,25 +229,32 @@ const MinuteChart = memo(({ data = [], preClose, loading }) => {
|
|||||||
drawWidth,
|
drawWidth,
|
||||||
drawHeight,
|
drawHeight,
|
||||||
yScale,
|
yScale,
|
||||||
xScale,
|
xScaleByTime,
|
||||||
|
xScaleByIndex,
|
||||||
priceRange,
|
priceRange,
|
||||||
};
|
};
|
||||||
}, [data, preClose]);
|
}, [data, preClose]);
|
||||||
|
|
||||||
// 处理触控
|
// 处理触控 - 根据触摸位置找到最近的数据点
|
||||||
const handleTouch = useCallback((event) => {
|
const handleTouch = useCallback((event) => {
|
||||||
if (!chartData || !data || data.length === 0) return;
|
if (!chartData || !chartData.pricePoints || chartData.pricePoints.length === 0) return;
|
||||||
|
|
||||||
const { locationX } = event.nativeEvent;
|
const { locationX } = event.nativeEvent;
|
||||||
const drawWidth = CHART_WIDTH - PADDING.left - PADDING.right;
|
|
||||||
|
|
||||||
// 计算最近的数据点索引
|
// 找到最接近触摸位置的数据点
|
||||||
const relativeX = locationX - PADDING.left;
|
let closestIndex = 0;
|
||||||
const index = Math.round((relativeX / drawWidth) * (data.length - 1));
|
let closestDistance = Infinity;
|
||||||
const clampedIndex = Math.max(0, Math.min(data.length - 1, index));
|
|
||||||
|
|
||||||
setActiveIndex(clampedIndex);
|
chartData.pricePoints.forEach((point, i) => {
|
||||||
}, [chartData, data]);
|
const distance = Math.abs(point.x - locationX);
|
||||||
|
if (distance < closestDistance) {
|
||||||
|
closestDistance = distance;
|
||||||
|
closestIndex = i;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setActiveIndex(closestIndex);
|
||||||
|
}, [chartData]);
|
||||||
|
|
||||||
// 处理触控结束
|
// 处理触控结束
|
||||||
const handleTouchEnd = useCallback(() => {
|
const handleTouchEnd = useCallback(() => {
|
||||||
@@ -507,7 +547,7 @@ const MinuteChart = memo(({ data = [], preClose, loading }) => {
|
|||||||
))}
|
))}
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
{/* 成交量图 */}
|
{/* 成交量图 - 使用时间来计算位置 */}
|
||||||
<Box mx={4} mt={1}>
|
<Box mx={4} mt={1}>
|
||||||
<Svg width={CHART_WIDTH} height={VOLUME_HEIGHT}>
|
<Svg width={CHART_WIDTH} height={VOLUME_HEIGHT}>
|
||||||
{data.map((item, i) => {
|
{data.map((item, i) => {
|
||||||
@@ -518,8 +558,10 @@ const MinuteChart = memo(({ data = [], preClose, loading }) => {
|
|||||||
const isUp = price >= prevPrice;
|
const isUp = price >= prevPrice;
|
||||||
const isActive = activeIndex === i;
|
const isActive = activeIndex === i;
|
||||||
|
|
||||||
const x = PADDING.left + (i / (data.length - 1 || 1)) * chartData.drawWidth;
|
// 使用时间来计算 X 坐标
|
||||||
const barWidth = Math.max(1, chartData.drawWidth / data.length - 1);
|
const x = chartData.xScaleByTime(item.time || '');
|
||||||
|
// 固定柱宽度(每分钟约 1 个像素)
|
||||||
|
const barWidth = Math.max(1, chartData.drawWidth / TOTAL_TRADING_MINUTES - 0.5);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Rect
|
<Rect
|
||||||
@@ -527,7 +569,7 @@ const MinuteChart = memo(({ data = [], preClose, loading }) => {
|
|||||||
x={x - barWidth / 2}
|
x={x - barWidth / 2}
|
||||||
y={VOLUME_HEIGHT - barHeight - 5}
|
y={VOLUME_HEIGHT - barHeight - 5}
|
||||||
width={barWidth}
|
width={barWidth}
|
||||||
height={barHeight}
|
height={Math.max(0, barHeight)}
|
||||||
fill={isActive ? '#F59E0B' : (isUp ? 'rgba(239, 68, 68, 0.6)' : 'rgba(34, 197, 94, 0.6)')}
|
fill={isActive ? '#F59E0B' : (isUp ? 'rgba(239, 68, 68, 0.6)' : 'rgba(34, 197, 94, 0.6)')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* WebSocket 实时行情服务
|
* WebSocket 实时行情服务
|
||||||
|
* 参考 Web 端 FlexScreen 的实现
|
||||||
* 支持上交所(SSE)和深交所(SZSE)双通道
|
* 支持上交所(SSE)和深交所(SZSE)双通道
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { AppState } from 'react-native';
|
import { AppState } from 'react-native';
|
||||||
|
|
||||||
// WebSocket 服务器地址
|
// WebSocket 服务器地址(通过 Nginx 代理)
|
||||||
const WS_ENDPOINTS = {
|
const WS_ENDPOINTS = {
|
||||||
sse: 'wss://api.valuefrontier.cn/ws/sse', // 上交所
|
sse: 'wss://api.valuefrontier.cn/ws/sse', // 上交所
|
||||||
szse: 'wss://api.valuefrontier.cn/ws/szse', // 深交所
|
szse: 'wss://api.valuefrontier.cn/ws/szse', // 深交所
|
||||||
@@ -25,6 +26,34 @@ const ConnectionState = {
|
|||||||
RECONNECTING: 'reconnecting',
|
RECONNECTING: 'reconnecting',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准化证券代码为无后缀格式
|
||||||
|
*/
|
||||||
|
const normalizeCode = (code) => {
|
||||||
|
return String(code).split('.')[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断证券代码属于哪个交易所
|
||||||
|
*/
|
||||||
|
const getExchange = (code) => {
|
||||||
|
const baseCode = normalizeCode(code);
|
||||||
|
// 6开头、5开头为上海
|
||||||
|
if (baseCode.startsWith('6') || baseCode.startsWith('5')) {
|
||||||
|
return 'sse';
|
||||||
|
}
|
||||||
|
// 其他为深圳(0、3、1开头)
|
||||||
|
return 'szse';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算涨跌幅
|
||||||
|
*/
|
||||||
|
const calcChangePct = (price, prevClose) => {
|
||||||
|
if (!prevClose || prevClose === 0) return 0;
|
||||||
|
return ((price - prevClose) / prevClose) * 100;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WebSocket 连接管理器
|
* WebSocket 连接管理器
|
||||||
*/
|
*/
|
||||||
@@ -55,6 +84,7 @@ class WebSocketManager {
|
|||||||
this._notifyStateChange();
|
this._notifyStateChange();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log(`[WS-${this.exchange}] 正在连接: ${this.url}`);
|
||||||
this.ws = new WebSocket(this.url);
|
this.ws = new WebSocket(this.url);
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
@@ -108,8 +138,7 @@ class WebSocketManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 订阅股票行情
|
* 订阅股票行情(纯数字代码)
|
||||||
* @param {string[]} codes - 股票代码列表
|
|
||||||
*/
|
*/
|
||||||
subscribe(codes) {
|
subscribe(codes) {
|
||||||
if (!Array.isArray(codes)) {
|
if (!Array.isArray(codes)) {
|
||||||
@@ -125,7 +154,6 @@ class WebSocketManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 取消订阅
|
* 取消订阅
|
||||||
* @param {string[]} codes - 股票代码列表
|
|
||||||
*/
|
*/
|
||||||
unsubscribe(codes) {
|
unsubscribe(codes) {
|
||||||
if (!Array.isArray(codes)) {
|
if (!Array.isArray(codes)) {
|
||||||
@@ -141,7 +169,6 @@ class WebSocketManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加消息处理器
|
* 添加消息处理器
|
||||||
* @param {function} handler - 消息处理函数
|
|
||||||
*/
|
*/
|
||||||
addMessageHandler(handler) {
|
addMessageHandler(handler) {
|
||||||
this.messageHandlers.add(handler);
|
this.messageHandlers.add(handler);
|
||||||
@@ -150,23 +177,16 @@ class WebSocketManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加状态变化处理器
|
* 添加状态变化处理器
|
||||||
* @param {function} handler - 状态处理函数
|
|
||||||
*/
|
*/
|
||||||
addStateHandler(handler) {
|
addStateHandler(handler) {
|
||||||
this.stateHandlers.add(handler);
|
this.stateHandlers.add(handler);
|
||||||
return () => this.stateHandlers.delete(handler);
|
return () => this.stateHandlers.delete(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前连接状态
|
|
||||||
*/
|
|
||||||
getState() {
|
getState() {
|
||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否已连接
|
|
||||||
*/
|
|
||||||
isConnected() {
|
isConnected() {
|
||||||
return this.state === ConnectionState.CONNECTED;
|
return this.state === ConnectionState.CONNECTED;
|
||||||
}
|
}
|
||||||
@@ -176,8 +196,10 @@ class WebSocketManager {
|
|||||||
_sendSubscribe(codes) {
|
_sendSubscribe(codes) {
|
||||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
// 参考 Web 端的订阅格式
|
||||||
const message = JSON.stringify({
|
const message = JSON.stringify({
|
||||||
action: 'subscribe',
|
action: 'subscribe',
|
||||||
|
channels: ['stock', 'index'],
|
||||||
codes: codes,
|
codes: codes,
|
||||||
});
|
});
|
||||||
this.ws.send(message);
|
this.ws.send(message);
|
||||||
@@ -221,7 +243,7 @@ class WebSocketManager {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[WS-${this.exchange}] 解析消息失败:`, error);
|
console.error(`[WS-${this.exchange}] 解析消息失败:`, error, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +251,7 @@ class WebSocketManager {
|
|||||||
this._stopHeartbeat();
|
this._stopHeartbeat();
|
||||||
this.heartbeatTimer = setInterval(() => {
|
this.heartbeatTimer = setInterval(() => {
|
||||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
this.ws.send('ping');
|
this.ws.send(JSON.stringify({ action: 'ping' }));
|
||||||
}
|
}
|
||||||
}, HEARTBEAT_INTERVAL);
|
}, HEARTBEAT_INTERVAL);
|
||||||
}
|
}
|
||||||
@@ -288,6 +310,7 @@ class RealtimeQuoteService {
|
|||||||
this.stateHandlers = new Set();
|
this.stateHandlers = new Set();
|
||||||
this.appStateSubscription = null;
|
this.appStateSubscription = null;
|
||||||
this._initialized = false;
|
this._initialized = false;
|
||||||
|
this._msgLogCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -337,26 +360,29 @@ class RealtimeQuoteService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 订阅股票行情
|
* 订阅股票行情
|
||||||
* @param {string[]} codes - 股票代码列表
|
|
||||||
*/
|
*/
|
||||||
subscribe(codes) {
|
subscribe(codes) {
|
||||||
if (!Array.isArray(codes)) {
|
if (!Array.isArray(codes)) {
|
||||||
codes = [codes];
|
codes = [codes];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按交易所分类
|
// 按交易所分类,并转换为纯数字格式
|
||||||
const sseCodes = [];
|
const sseCodes = [];
|
||||||
const szseCodes = [];
|
const szseCodes = [];
|
||||||
|
|
||||||
codes.forEach(code => {
|
codes.forEach(code => {
|
||||||
const exchange = this._getExchange(code);
|
const exchange = getExchange(code);
|
||||||
|
const pureCode = normalizeCode(code);
|
||||||
|
|
||||||
if (exchange === 'sse') {
|
if (exchange === 'sse') {
|
||||||
sseCodes.push(code);
|
sseCodes.push(pureCode);
|
||||||
} else {
|
} else {
|
||||||
szseCodes.push(code);
|
szseCodes.push(pureCode);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('[RealtimeQuote] 订阅股票:', { original: codes, sse: sseCodes, szse: szseCodes });
|
||||||
|
|
||||||
if (sseCodes.length > 0) {
|
if (sseCodes.length > 0) {
|
||||||
this.managers.sse.subscribe(sseCodes);
|
this.managers.sse.subscribe(sseCodes);
|
||||||
}
|
}
|
||||||
@@ -367,7 +393,6 @@ class RealtimeQuoteService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 取消订阅
|
* 取消订阅
|
||||||
* @param {string[]} codes - 股票代码列表
|
|
||||||
*/
|
*/
|
||||||
unsubscribe(codes) {
|
unsubscribe(codes) {
|
||||||
if (!Array.isArray(codes)) {
|
if (!Array.isArray(codes)) {
|
||||||
@@ -378,11 +403,13 @@ class RealtimeQuoteService {
|
|||||||
const szseCodes = [];
|
const szseCodes = [];
|
||||||
|
|
||||||
codes.forEach(code => {
|
codes.forEach(code => {
|
||||||
const exchange = this._getExchange(code);
|
const exchange = getExchange(code);
|
||||||
|
const pureCode = normalizeCode(code);
|
||||||
|
|
||||||
if (exchange === 'sse') {
|
if (exchange === 'sse') {
|
||||||
sseCodes.push(code);
|
sseCodes.push(pureCode);
|
||||||
} else {
|
} else {
|
||||||
szseCodes.push(code);
|
szseCodes.push(pureCode);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -396,8 +423,6 @@ class RealtimeQuoteService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加行情数据处理器
|
* 添加行情数据处理器
|
||||||
* @param {function} handler - 处理函数 (quotes) => void
|
|
||||||
* @returns {function} 取消订阅函数
|
|
||||||
*/
|
*/
|
||||||
addQuoteHandler(handler) {
|
addQuoteHandler(handler) {
|
||||||
this.quoteHandlers.add(handler);
|
this.quoteHandlers.add(handler);
|
||||||
@@ -406,17 +431,12 @@ class RealtimeQuoteService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加连接状态处理器
|
* 添加连接状态处理器
|
||||||
* @param {function} handler - 处理函数 (state) => void
|
|
||||||
* @returns {function} 取消订阅函数
|
|
||||||
*/
|
*/
|
||||||
addStateHandler(handler) {
|
addStateHandler(handler) {
|
||||||
this.stateHandlers.add(handler);
|
this.stateHandlers.add(handler);
|
||||||
return () => this.stateHandlers.delete(handler);
|
return () => this.stateHandlers.delete(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取连接状态
|
|
||||||
*/
|
|
||||||
getConnectionState() {
|
getConnectionState() {
|
||||||
const sseState = this.managers.sse.getState();
|
const sseState = this.managers.sse.getState();
|
||||||
const szseState = this.managers.szse.getState();
|
const szseState = this.managers.szse.getState();
|
||||||
@@ -433,16 +453,10 @@ class RealtimeQuoteService {
|
|||||||
return 'disconnected';
|
return 'disconnected';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否已连接
|
|
||||||
*/
|
|
||||||
isConnected() {
|
isConnected() {
|
||||||
return this.managers.sse.isConnected() || this.managers.szse.isConnected();
|
return this.managers.sse.isConnected() || this.managers.szse.isConnected();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 销毁服务
|
|
||||||
*/
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this.disconnect();
|
this.disconnect();
|
||||||
if (this.appStateSubscription) {
|
if (this.appStateSubscription) {
|
||||||
@@ -457,82 +471,105 @@ class RealtimeQuoteService {
|
|||||||
// ============ 私有方法 ============
|
// ============ 私有方法 ============
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据股票代码判断交易所
|
* 处理行情消息(参考 Web 端格式)
|
||||||
*/
|
* 消息格式: { type: 'stock' | 'index', data: { code: quote, ... } }
|
||||||
_getExchange(code) {
|
|
||||||
// 提取纯数字代码
|
|
||||||
const numericCode = String(code).replace(/\D/g, '');
|
|
||||||
|
|
||||||
// 上交所: 6开头
|
|
||||||
// 深交所: 0、3开头
|
|
||||||
if (numericCode.startsWith('6')) {
|
|
||||||
return 'sse';
|
|
||||||
}
|
|
||||||
return 'szse';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理行情消息
|
|
||||||
*/
|
*/
|
||||||
_handleQuoteMessage(message, exchange) {
|
_handleQuoteMessage(message, exchange) {
|
||||||
// 消息格式转换
|
// 调试:打印收到的原始消息
|
||||||
let quotes = {};
|
if (this._msgLogCount < 5) {
|
||||||
|
console.log(`[RealtimeQuote] 收到${exchange}消息:`, JSON.stringify(message).substring(0, 800));
|
||||||
if (message.type === 'quote' && message.data) {
|
this._msgLogCount++;
|
||||||
// 单条行情
|
|
||||||
const data = message.data;
|
|
||||||
const code = data.stock_code || data.code;
|
|
||||||
if (code) {
|
|
||||||
quotes[code] = this._normalizeQuote(data);
|
|
||||||
}
|
|
||||||
} else if (message.type === 'quotes' && Array.isArray(message.data)) {
|
|
||||||
// 批量行情
|
|
||||||
message.data.forEach(item => {
|
|
||||||
const code = item.stock_code || item.code;
|
|
||||||
if (code) {
|
|
||||||
quotes[code] = this._normalizeQuote(item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (message.stock_code || message.code) {
|
|
||||||
// 直接是行情数据
|
|
||||||
const code = message.stock_code || message.code;
|
|
||||||
quotes[code] = this._normalizeQuote(message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通知处理器
|
// 心跳响应
|
||||||
if (Object.keys(quotes).length > 0) {
|
if (message.type === 'pong') return;
|
||||||
this.quoteHandlers.forEach(handler => {
|
|
||||||
try {
|
// 订阅确认
|
||||||
handler(quotes);
|
if (message.type === 'subscribed') {
|
||||||
} catch (error) {
|
console.log(`[RealtimeQuote] ${exchange} 订阅成功:`, message.channels, message.codes);
|
||||||
console.error('[RealtimeQuote] 处理器错误:', error);
|
return;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
// 错误消息
|
||||||
|
if (message.type === 'error') {
|
||||||
|
console.error(`[RealtimeQuote] ${exchange} 错误:`, message.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理行情数据
|
||||||
|
// 格式: { type: 'stock' | 'index', data: { '603199': {...}, ... } }
|
||||||
|
if ((message.type === 'stock' || message.type === 'index') && message.data) {
|
||||||
|
const quotes = this._parseQuoteData(message.data, exchange);
|
||||||
|
if (Object.keys(quotes).length > 0) {
|
||||||
|
console.log('[RealtimeQuote] 处理行情:', Object.keys(quotes).length, '只股票');
|
||||||
|
this._notifyQuoteHandlers(quotes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标准化行情数据
|
* 解析行情数据
|
||||||
|
* data 格式: { '603199': { security_name, last_price, prev_close, ... } }
|
||||||
*/
|
*/
|
||||||
_normalizeQuote(data) {
|
_parseQuoteData(data, exchange) {
|
||||||
return {
|
const quotes = {};
|
||||||
stock_code: data.stock_code || data.code,
|
const suffix = exchange === 'sse' ? '.SH' : '.SZ';
|
||||||
stock_name: data.stock_name || data.name,
|
|
||||||
current_price: parseFloat(data.current_price || data.price || data.current || 0),
|
Object.entries(data).forEach(([code, quote]) => {
|
||||||
change_percent: parseFloat(data.change_percent || data.pct_chg || data.change_pct || 0),
|
if (!quote || typeof quote !== 'object') return;
|
||||||
change_amount: parseFloat(data.change_amount || data.change || 0),
|
|
||||||
volume: parseInt(data.volume || data.vol || 0, 10),
|
// 生成完整代码(带后缀)
|
||||||
amount: parseFloat(data.amount || data.turnover || 0),
|
const fullCode = code.includes('.') ? code : `${code}${suffix}`;
|
||||||
open: parseFloat(data.open || data.open_price || 0),
|
const pureCode = normalizeCode(code);
|
||||||
high: parseFloat(data.high || data.high_price || 0),
|
|
||||||
low: parseFloat(data.low || data.low_price || 0),
|
// 获取当前价和昨收价
|
||||||
pre_close: parseFloat(data.pre_close || data.prev_close || 0),
|
const currentPrice = parseFloat(
|
||||||
bid_prices: data.bid_prices || data.bidPrices || [],
|
quote.last_price || quote.last_px || quote.price || quote.current_price || 0
|
||||||
bid_volumes: data.bid_volumes || data.bidVolumes || [],
|
);
|
||||||
ask_prices: data.ask_prices || data.askPrices || [],
|
const prevClose = parseFloat(
|
||||||
ask_volumes: data.ask_volumes || data.askVolumes || [],
|
quote.prev_close || quote.prev_close_px || quote.pre_close || 0
|
||||||
update_time: data.update_time || data.time || new Date().toISOString(),
|
);
|
||||||
};
|
|
||||||
|
// 计算涨跌
|
||||||
|
const change = currentPrice - prevClose;
|
||||||
|
const changePct = calcChangePct(currentPrice, prevClose);
|
||||||
|
|
||||||
|
// 标准化数据
|
||||||
|
const normalized = {
|
||||||
|
stock_code: fullCode,
|
||||||
|
stock_name: quote.security_name || quote.name || '',
|
||||||
|
current_price: currentPrice,
|
||||||
|
pre_close: prevClose,
|
||||||
|
open: parseFloat(quote.open_price || quote.open_px || quote.open || 0),
|
||||||
|
high: parseFloat(quote.high_price || quote.high_px || quote.high || 0),
|
||||||
|
low: parseFloat(quote.low_price || quote.low_px || quote.low || 0),
|
||||||
|
volume: parseInt(quote.volume || quote.total_volume_trade || 0, 10),
|
||||||
|
amount: parseFloat(quote.amount || quote.total_value_trade || 0),
|
||||||
|
change_amount: change,
|
||||||
|
change_percent: changePct,
|
||||||
|
bid_prices: quote.bid_prices || [],
|
||||||
|
bid_volumes: quote.bid_volumes || [],
|
||||||
|
ask_prices: quote.ask_prices || [],
|
||||||
|
ask_volumes: quote.ask_volumes || [],
|
||||||
|
update_time: quote.trade_time || quote.update_time || new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 同时用纯代码和完整代码作为 key,方便匹配
|
||||||
|
quotes[fullCode] = normalized;
|
||||||
|
quotes[pureCode] = normalized;
|
||||||
|
});
|
||||||
|
|
||||||
|
return quotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
_notifyQuoteHandlers(quotes) {
|
||||||
|
this.quoteHandlers.forEach(handler => {
|
||||||
|
try {
|
||||||
|
handler(quotes);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[RealtimeQuote] 处理器错误:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_notifyStateChange() {
|
_notifyStateChange() {
|
||||||
|
|||||||
Binary file not shown.
93
app.py
93
app.py
@@ -10221,6 +10221,99 @@ def get_stock_quote_detail(stock_code):
|
|||||||
result_data['main_inflow_ratio'] = float(cf.get('main_inflow_ratio') or 0) if cf.get('main_inflow_ratio') is not None else None
|
result_data['main_inflow_ratio'] = float(cf.get('main_inflow_ratio') or 0) if cf.get('main_inflow_ratio') is not None else None
|
||||||
result_data['net_active_buy_ratio'] = float(cf.get('net_active_buy_ratio') or 0) if cf.get('net_active_buy_ratio') is not None else None
|
result_data['net_active_buy_ratio'] = float(cf.get('net_active_buy_ratio') or 0) if cf.get('net_active_buy_ratio') is not None else None
|
||||||
|
|
||||||
|
# 4. 交易时间内从 stock_minute 获取实时价格(覆盖 ea_trade 的日终数据)
|
||||||
|
now = beijing_now()
|
||||||
|
current_date = now.date()
|
||||||
|
current_time = now.time()
|
||||||
|
|
||||||
|
# 判断是否在交易时间内
|
||||||
|
morning_start = dt_time(9, 30)
|
||||||
|
morning_end = dt_time(11, 30)
|
||||||
|
afternoon_start = dt_time(13, 0)
|
||||||
|
afternoon_end = dt_time(15, 0)
|
||||||
|
|
||||||
|
is_trading_time = (
|
||||||
|
current_date in trading_days_set and
|
||||||
|
((morning_start <= current_time <= morning_end) or
|
||||||
|
(afternoon_start <= current_time <= afternoon_end) or
|
||||||
|
(morning_end < current_time < afternoon_start)) # 午休时间也显示上午最新
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_trading_time:
|
||||||
|
try:
|
||||||
|
client = get_clickhouse_client()
|
||||||
|
# 标准化股票代码
|
||||||
|
if base_code.startswith('6'):
|
||||||
|
full_code = f"{base_code}.SH"
|
||||||
|
elif base_code.startswith(('8', '9', '4')):
|
||||||
|
full_code = f"{base_code}.BJ"
|
||||||
|
else:
|
||||||
|
full_code = f"{base_code}.SZ"
|
||||||
|
|
||||||
|
# 查询当天最新的分时数据
|
||||||
|
realtime_query = """
|
||||||
|
SELECT
|
||||||
|
close as current_price,
|
||||||
|
high,
|
||||||
|
low,
|
||||||
|
volume,
|
||||||
|
amt as amount,
|
||||||
|
change_pct,
|
||||||
|
timestamp
|
||||||
|
FROM stock.stock_minute
|
||||||
|
WHERE code = %(code)s
|
||||||
|
AND timestamp >= %(start)s
|
||||||
|
AND timestamp <= %(end)s
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
realtime_data = client.execute(realtime_query, {
|
||||||
|
'code': full_code,
|
||||||
|
'start': datetime.combine(current_date, dt_time(9, 30)),
|
||||||
|
'end': datetime.combine(current_date, dt_time(15, 0))
|
||||||
|
})
|
||||||
|
|
||||||
|
if realtime_data and len(realtime_data) > 0:
|
||||||
|
rt = realtime_data[0]
|
||||||
|
realtime_price = float(rt[0]) if rt[0] else None
|
||||||
|
|
||||||
|
if realtime_price and realtime_price > 0:
|
||||||
|
# 使用昨收价计算涨跌
|
||||||
|
yesterday_close = result_data.get('yesterday_close') or 0
|
||||||
|
if yesterday_close > 0:
|
||||||
|
change_amount = realtime_price - yesterday_close
|
||||||
|
change_percent = (change_amount / yesterday_close) * 100
|
||||||
|
else:
|
||||||
|
change_amount = 0
|
||||||
|
change_percent = float(rt[5]) if rt[5] else 0
|
||||||
|
|
||||||
|
# 覆盖价格相关字段
|
||||||
|
result_data['current_price'] = realtime_price
|
||||||
|
result_data['change_amount'] = round(change_amount, 2)
|
||||||
|
result_data['change_percent'] = round(change_percent, 2)
|
||||||
|
|
||||||
|
# 更新当日高低(取较大/较小值)
|
||||||
|
rt_high = float(rt[1]) if rt[1] else 0
|
||||||
|
rt_low = float(rt[2]) if rt[2] else 0
|
||||||
|
if rt_high > 0:
|
||||||
|
result_data['today_high'] = max(result_data.get('today_high') or 0, rt_high)
|
||||||
|
if rt_low > 0:
|
||||||
|
if result_data.get('today_low') and result_data['today_low'] > 0:
|
||||||
|
result_data['today_low'] = min(result_data['today_low'], rt_low)
|
||||||
|
else:
|
||||||
|
result_data['today_low'] = rt_low
|
||||||
|
|
||||||
|
# 更新成交量和成交额(累计值)
|
||||||
|
# 注意:这里应该从今天的累计数据获取,暂时保留
|
||||||
|
result_data['update_time'] = rt[6].strftime('%Y-%m-%d %H:%M:%S') if rt[6] else now.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
result_data['is_realtime'] = True
|
||||||
|
|
||||||
|
print(f"[quote-detail] 实时价格更新: {full_code} -> {realtime_price} ({change_percent:+.2f}%)")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[quote-detail] 获取实时价格失败: {e}")
|
||||||
|
# 失败时保持使用 ea_trade 的数据
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'data': result_data
|
'data': result_data
|
||||||
|
|||||||
Reference in New Issue
Block a user