From 90f633790f9af448d46ea90647f9d8e1a55f59fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BB=B7=E5=B0=8F=E5=89=8D?= Date: Mon, 19 Jan 2026 13:56:53 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0ios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MeAgent/src/hooks/useRealtimeQuote.js | 24 +- .../StockDetail/components/MinuteChart.js | 84 ++++-- MeAgent/src/services/websocketService.js | 243 ++++++++++-------- __pycache__/app.cpython-314.pyc | Bin 947887 -> 951579 bytes app.py | 93 +++++++ 5 files changed, 319 insertions(+), 125 deletions(-) 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 461ac8ff776ba8c66de2e8bbd7f8e6d5a470d050..e28c261a7ae81a168c46549298d187f837ada71f 100644 GIT binary patch delta 14009 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 DV>sxH delta 11243 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 S)G81D|8{Lc9(0^zfA&At(!H?& 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