diff --git a/MeAgent/src/hooks/useRealtimeQuote.js b/MeAgent/src/hooks/useRealtimeQuote.js index 8a37fe14..2a8258be 100644 --- a/MeAgent/src/hooks/useRealtimeQuote.js +++ b/MeAgent/src/hooks/useRealtimeQuote.js @@ -167,8 +167,30 @@ export const useSingleQuote = (code) => { 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 { - quote: code ? quotes[code] : null, + quote, isConnected, }; }; diff --git a/MeAgent/src/screens/StockDetail/components/MinuteChart.js b/MeAgent/src/screens/StockDetail/components/MinuteChart.js index 24b42f09..6f9188ea 100644 --- a/MeAgent/src/screens/StockDetail/components/MinuteChart.js +++ b/MeAgent/src/screens/StockDetail/components/MinuteChart.js @@ -41,6 +41,31 @@ const formatTime = (time) => { 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) => { 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 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; - // 分时线点位 + // 分时线点位 - 使用时间来计算 X 坐标 const pricePoints = data.map((d, i) => { const price = d.price || d.current_price || d.close || effectivePreClose; + const time = d.time || ''; return { - x: xScale(i), + x: xScaleByTime(time), y: yScale(price), price, - time: d.time, + time, volume: d.volume, avgPrice: d.avg_price || d.average_price, changePct: d.change_pct, // 保存 API 返回的涨跌幅 + index: i, // 保留索引用于成交量 }; }); - // 均价线点位 + // 均价线点位 - 使用时间来计算 X 坐标 const avgPoints = data .map((d, i) => { const avgPrice = d.avg_price || d.average_price; if (!avgPrice || avgPrice <= 0) return null; + const time = d.time || ''; return { - x: xScale(i), + x: xScaleByTime(time), y: yScale(avgPrice), price: avgPrice, }; @@ -196,25 +229,32 @@ const MinuteChart = memo(({ data = [], preClose, loading }) => { drawWidth, drawHeight, yScale, - xScale, + xScaleByTime, + xScaleByIndex, priceRange, }; }, [data, preClose]); - // 处理触控 + // 处理触控 - 根据触摸位置找到最近的数据点 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 drawWidth = CHART_WIDTH - PADDING.left - PADDING.right; - // 计算最近的数据点索引 - const relativeX = locationX - PADDING.left; - const index = Math.round((relativeX / drawWidth) * (data.length - 1)); - const clampedIndex = Math.max(0, Math.min(data.length - 1, index)); + // 找到最接近触摸位置的数据点 + let closestIndex = 0; + let closestDistance = Infinity; - setActiveIndex(clampedIndex); - }, [chartData, data]); + chartData.pricePoints.forEach((point, i) => { + const distance = Math.abs(point.x - locationX); + if (distance < closestDistance) { + closestDistance = distance; + closestIndex = i; + } + }); + + setActiveIndex(closestIndex); + }, [chartData]); // 处理触控结束 const handleTouchEnd = useCallback(() => { @@ -507,7 +547,7 @@ const MinuteChart = memo(({ data = [], preClose, loading }) => { ))} - {/* 成交量图 */} + {/* 成交量图 - 使用时间来计算位置 */} {data.map((item, i) => { @@ -518,8 +558,10 @@ const MinuteChart = memo(({ data = [], preClose, loading }) => { const isUp = price >= prevPrice; const isActive = activeIndex === i; - const x = PADDING.left + (i / (data.length - 1 || 1)) * chartData.drawWidth; - const barWidth = Math.max(1, chartData.drawWidth / data.length - 1); + // 使用时间来计算 X 坐标 + const x = chartData.xScaleByTime(item.time || ''); + // 固定柱宽度(每分钟约 1 个像素) + const barWidth = Math.max(1, chartData.drawWidth / TOTAL_TRADING_MINUTES - 0.5); return ( { x={x - barWidth / 2} y={VOLUME_HEIGHT - barHeight - 5} 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)')} /> ); diff --git a/MeAgent/src/services/websocketService.js b/MeAgent/src/services/websocketService.js index 0c8e8cf7..2336a635 100644 --- a/MeAgent/src/services/websocketService.js +++ b/MeAgent/src/services/websocketService.js @@ -1,11 +1,12 @@ /** * WebSocket 实时行情服务 + * 参考 Web 端 FlexScreen 的实现 * 支持上交所(SSE)和深交所(SZSE)双通道 */ import { AppState } from 'react-native'; -// WebSocket 服务器地址 +// WebSocket 服务器地址(通过 Nginx 代理) const WS_ENDPOINTS = { sse: 'wss://api.valuefrontier.cn/ws/sse', // 上交所 szse: 'wss://api.valuefrontier.cn/ws/szse', // 深交所 @@ -25,6 +26,34 @@ const ConnectionState = { 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 连接管理器 */ @@ -55,6 +84,7 @@ class WebSocketManager { this._notifyStateChange(); try { + console.log(`[WS-${this.exchange}] 正在连接: ${this.url}`); this.ws = new WebSocket(this.url); this.ws.onopen = () => { @@ -108,8 +138,7 @@ class WebSocketManager { } /** - * 订阅股票行情 - * @param {string[]} codes - 股票代码列表 + * 订阅股票行情(纯数字代码) */ subscribe(codes) { if (!Array.isArray(codes)) { @@ -125,7 +154,6 @@ class WebSocketManager { /** * 取消订阅 - * @param {string[]} codes - 股票代码列表 */ unsubscribe(codes) { if (!Array.isArray(codes)) { @@ -141,7 +169,6 @@ class WebSocketManager { /** * 添加消息处理器 - * @param {function} handler - 消息处理函数 */ addMessageHandler(handler) { this.messageHandlers.add(handler); @@ -150,23 +177,16 @@ class WebSocketManager { /** * 添加状态变化处理器 - * @param {function} handler - 状态处理函数 */ addStateHandler(handler) { this.stateHandlers.add(handler); return () => this.stateHandlers.delete(handler); } - /** - * 获取当前连接状态 - */ getState() { return this.state; } - /** - * 是否已连接 - */ isConnected() { return this.state === ConnectionState.CONNECTED; } @@ -176,8 +196,10 @@ class WebSocketManager { _sendSubscribe(codes) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + // 参考 Web 端的订阅格式 const message = JSON.stringify({ action: 'subscribe', + channels: ['stock', 'index'], codes: codes, }); this.ws.send(message); @@ -221,7 +243,7 @@ class WebSocketManager { } }); } catch (error) { - console.error(`[WS-${this.exchange}] 解析消息失败:`, error); + console.error(`[WS-${this.exchange}] 解析消息失败:`, error, data); } } @@ -229,7 +251,7 @@ class WebSocketManager { this._stopHeartbeat(); this.heartbeatTimer = setInterval(() => { if (this.ws && this.ws.readyState === WebSocket.OPEN) { - this.ws.send('ping'); + this.ws.send(JSON.stringify({ action: 'ping' })); } }, HEARTBEAT_INTERVAL); } @@ -288,6 +310,7 @@ class RealtimeQuoteService { this.stateHandlers = new Set(); this.appStateSubscription = null; this._initialized = false; + this._msgLogCount = 0; } /** @@ -337,26 +360,29 @@ class RealtimeQuoteService { /** * 订阅股票行情 - * @param {string[]} codes - 股票代码列表 */ subscribe(codes) { if (!Array.isArray(codes)) { codes = [codes]; } - // 按交易所分类 + // 按交易所分类,并转换为纯数字格式 const sseCodes = []; const szseCodes = []; codes.forEach(code => { - const exchange = this._getExchange(code); + const exchange = getExchange(code); + const pureCode = normalizeCode(code); + if (exchange === 'sse') { - sseCodes.push(code); + sseCodes.push(pureCode); } else { - szseCodes.push(code); + szseCodes.push(pureCode); } }); + console.log('[RealtimeQuote] 订阅股票:', { original: codes, sse: sseCodes, szse: szseCodes }); + if (sseCodes.length > 0) { this.managers.sse.subscribe(sseCodes); } @@ -367,7 +393,6 @@ class RealtimeQuoteService { /** * 取消订阅 - * @param {string[]} codes - 股票代码列表 */ unsubscribe(codes) { if (!Array.isArray(codes)) { @@ -378,11 +403,13 @@ class RealtimeQuoteService { const szseCodes = []; codes.forEach(code => { - const exchange = this._getExchange(code); + const exchange = getExchange(code); + const pureCode = normalizeCode(code); + if (exchange === 'sse') { - sseCodes.push(code); + sseCodes.push(pureCode); } else { - szseCodes.push(code); + szseCodes.push(pureCode); } }); @@ -396,8 +423,6 @@ class RealtimeQuoteService { /** * 添加行情数据处理器 - * @param {function} handler - 处理函数 (quotes) => void - * @returns {function} 取消订阅函数 */ addQuoteHandler(handler) { this.quoteHandlers.add(handler); @@ -406,17 +431,12 @@ class RealtimeQuoteService { /** * 添加连接状态处理器 - * @param {function} handler - 处理函数 (state) => void - * @returns {function} 取消订阅函数 */ addStateHandler(handler) { this.stateHandlers.add(handler); return () => this.stateHandlers.delete(handler); } - /** - * 获取连接状态 - */ getConnectionState() { const sseState = this.managers.sse.getState(); const szseState = this.managers.szse.getState(); @@ -433,16 +453,10 @@ class RealtimeQuoteService { return 'disconnected'; } - /** - * 是否已连接 - */ isConnected() { return this.managers.sse.isConnected() || this.managers.szse.isConnected(); } - /** - * 销毁服务 - */ destroy() { this.disconnect(); if (this.appStateSubscription) { @@ -457,82 +471,105 @@ class RealtimeQuoteService { // ============ 私有方法 ============ /** - * 根据股票代码判断交易所 - */ - _getExchange(code) { - // 提取纯数字代码 - const numericCode = String(code).replace(/\D/g, ''); - - // 上交所: 6开头 - // 深交所: 0、3开头 - if (numericCode.startsWith('6')) { - return 'sse'; - } - return 'szse'; - } - - /** - * 处理行情消息 + * 处理行情消息(参考 Web 端格式) + * 消息格式: { type: 'stock' | 'index', data: { code: quote, ... } } */ _handleQuoteMessage(message, exchange) { - // 消息格式转换 - let quotes = {}; - - if (message.type === 'quote' && message.data) { - // 单条行情 - 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 (this._msgLogCount < 5) { + console.log(`[RealtimeQuote] 收到${exchange}消息:`, JSON.stringify(message).substring(0, 800)); + this._msgLogCount++; } - // 通知处理器 - if (Object.keys(quotes).length > 0) { - this.quoteHandlers.forEach(handler => { - try { - handler(quotes); - } catch (error) { - console.error('[RealtimeQuote] 处理器错误:', error); - } - }); + // 心跳响应 + if (message.type === 'pong') return; + + // 订阅确认 + if (message.type === 'subscribed') { + console.log(`[RealtimeQuote] ${exchange} 订阅成功:`, message.channels, message.codes); + 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) { - return { - stock_code: data.stock_code || data.code, - stock_name: data.stock_name || data.name, - current_price: parseFloat(data.current_price || data.price || data.current || 0), - change_percent: parseFloat(data.change_percent || data.pct_chg || data.change_pct || 0), - change_amount: parseFloat(data.change_amount || data.change || 0), - volume: parseInt(data.volume || data.vol || 0, 10), - amount: parseFloat(data.amount || data.turnover || 0), - open: parseFloat(data.open || data.open_price || 0), - 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), - bid_prices: data.bid_prices || data.bidPrices || [], - bid_volumes: data.bid_volumes || data.bidVolumes || [], - ask_prices: data.ask_prices || data.askPrices || [], - ask_volumes: data.ask_volumes || data.askVolumes || [], - update_time: data.update_time || data.time || new Date().toISOString(), - }; + _parseQuoteData(data, exchange) { + const quotes = {}; + const suffix = exchange === 'sse' ? '.SH' : '.SZ'; + + Object.entries(data).forEach(([code, quote]) => { + if (!quote || typeof quote !== 'object') return; + + // 生成完整代码(带后缀) + const fullCode = code.includes('.') ? code : `${code}${suffix}`; + const pureCode = normalizeCode(code); + + // 获取当前价和昨收价 + const currentPrice = parseFloat( + quote.last_price || quote.last_px || quote.price || quote.current_price || 0 + ); + const prevClose = parseFloat( + quote.prev_close || quote.prev_close_px || quote.pre_close || 0 + ); + + // 计算涨跌 + 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() { diff --git a/__pycache__/app.cpython-314.pyc b/__pycache__/app.cpython-314.pyc index 461ac8ff..e28c261a 100644 Binary files a/__pycache__/app.cpython-314.pyc and b/__pycache__/app.cpython-314.pyc differ diff --git a/app.py b/app.py index d979bfe6..abdaef6c 100755 --- a/app.py +++ b/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['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({ 'success': True, 'data': result_data