From d6d4bb8a12c22042b2d4a5228f3c53f6006b9e4e Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Wed, 10 Dec 2025 13:23:49 +0800 Subject: [PATCH] update pay ui --- app.py | 98 ++++++++++++++++--- .../FlexScreen/hooks/useRealtimeQuote.ts | 83 +++++++++++++++- .../components/FlexScreen/hooks/utils.ts | 57 +++++++++-- 3 files changed, 213 insertions(+), 25 deletions(-) diff --git a/app.py b/app.py index 6adbdfbb..cdfdbe86 100755 --- a/app.py +++ b/app.py @@ -12832,8 +12832,61 @@ def get_concept_stocks(concept_id): }) stock_codes.append(code) - # 2. 从 ClickHouse 获取最新涨跌幅 - change_map = {} + if not stock_codes: + return jsonify({ + 'success': True, + 'data': { + 'concept_id': concept_id, + 'concept_name': concept_name, + 'stocks': stocks_info + } + }) + + # 2. 获取最新交易日和前一交易日 + today = datetime.now().date() + trading_day = None + prev_trading_day = None + + with engine.connect() as conn: + # 获取最新交易日 + result = conn.execute(text(""" + SELECT EXCHANGE_DATE FROM trading_days + WHERE EXCHANGE_DATE <= :today + ORDER BY EXCHANGE_DATE DESC LIMIT 1 + """), {"today": today}).fetchone() + if result: + trading_day = result[0].date() if hasattr(result[0], 'date') else result[0] + + # 获取前一交易日 + if trading_day: + result = conn.execute(text(""" + SELECT EXCHANGE_DATE FROM trading_days + WHERE EXCHANGE_DATE < :date + ORDER BY EXCHANGE_DATE DESC LIMIT 1 + """), {"date": trading_day}).fetchone() + if result: + prev_trading_day = result[0].date() if hasattr(result[0], 'date') else result[0] + + # 3. 从 MySQL ea_trade 获取前一交易日收盘价(F007N) + prev_close_map = {} + if prev_trading_day and stock_codes: + with engine.connect() as conn: + placeholders = ','.join([f':code{i}' for i in range(len(stock_codes))]) + params = {f'code{i}': code for i, code in enumerate(stock_codes)} + params['trade_date'] = prev_trading_day + + result = conn.execute(text(f""" + SELECT SECCODE, F007N + FROM ea_trade + WHERE SECCODE IN ({placeholders}) + AND TRADEDATE = :trade_date + AND F007N > 0 + """), params).fetchall() + + prev_close_map = {row[0]: float(row[1]) for row in result if row[1]} + + # 4. 从 ClickHouse 获取最新价格 + current_price_map = {} if stock_codes: try: ch_client = Client( @@ -12859,35 +12912,48 @@ def get_concept_stocks(concept_id): ch_codes_str = "','".join(ch_codes) - # 查询最新分钟数据 + # 查询当天最新价格 query = f""" - SELECT code, close, pre_close + SELECT code, close FROM stock_minute WHERE code IN ('{ch_codes_str}') - AND timestamp >= today() + AND toDate(timestamp) = today() ORDER BY timestamp DESC LIMIT 1 BY code """ result = ch_client.execute(query) for row in result: - ch_code, close_price, pre_close = row - if ch_code in code_mapping and pre_close and pre_close > 0: + ch_code, close_price = row + if ch_code in code_mapping and close_price: original_code = code_mapping[ch_code] - change_pct = (float(close_price) - float(pre_close)) / float(pre_close) * 100 - change_map[original_code] = round(change_pct, 2) + current_price_map[original_code] = float(close_price) except Exception as ch_err: - app.logger.warning(f"ClickHouse 获取涨跌幅失败: {ch_err}") + app.logger.warning(f"ClickHouse 获取价格失败: {ch_err}") - # 3. 合并数据 + # 5. 计算涨跌幅并合并数据 result_stocks = [] for stock in stocks_info: - stock['change_pct'] = change_map.get(stock['code']) - result_stocks.append(stock) + code = stock['code'] + prev_close = prev_close_map.get(code) + current_price = current_price_map.get(code) + + change_pct = None + if prev_close and current_price and prev_close > 0: + change_pct = round((current_price - prev_close) / prev_close * 100, 2) + + result_stocks.append({ + 'code': code, + 'name': stock['name'], + 'reason': stock['reason'], + 'change_pct': change_pct, + 'price': current_price, + 'prev_close': prev_close + }) # 按涨跌幅排序(涨停优先) - result_stocks.sort(key=lambda x: x.get('change_pct') or -999, reverse=True) + result_stocks.sort(key=lambda x: x.get('change_pct') if x.get('change_pct') is not None else -999, reverse=True) return jsonify({ 'success': True, @@ -12895,12 +12961,14 @@ def get_concept_stocks(concept_id): 'concept_id': concept_id, 'concept_name': concept_name, 'stock_count': len(result_stocks), + 'trading_day': str(trading_day) if trading_day else None, 'stocks': result_stocks } }) except Exception as e: - app.logger.error(f"获取概念股票失败: {e}") + import traceback + app.logger.error(f"获取概念股票失败: {traceback.format_exc()}") return jsonify({ 'success': False, 'error': str(e) diff --git a/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts b/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts index 7ba99be8..09687696 100644 --- a/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts +++ b/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts @@ -106,8 +106,48 @@ const handleSZSERealtimeMessage = ( switch (category) { case 'stock': { const stockData = data as SZSEStockData; - const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(stockData.bids); - const { prices: askPrices, volumes: askVolumes } = extractOrderBook(stockData.asks); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rawData = data as any; // 用于检查替代字段名 + + // 调试日志:检查深交所返回的盘口原始数据(临时使用 warn 级别方便调试) + if (!stockData.bids || stockData.bids.length === 0) { + logger.warn('FlexScreen', `SZSE股票数据无盘口 ${fullCode}`, { + hasBids: !!stockData.bids, + hasAsks: !!stockData.asks, + bidsLength: stockData.bids?.length || 0, + asksLength: stockData.asks?.length || 0, + // 检查替代字段名 + hasBidPrices: !!rawData.bid_prices, + hasAskPrices: !!rawData.ask_prices, + dataKeys: Object.keys(stockData), // 查看服务端实际返回了哪些字段 + }); + } + + // 优先使用 bids/asks 对象数组格式,如果不存在则尝试 bid_prices/ask_prices 分离数组格式 + let bidPrices: number[] = []; + let bidVolumes: number[] = []; + let askPrices: number[] = []; + let askVolumes: number[] = []; + + if (stockData.bids && stockData.bids.length > 0) { + const extracted = extractOrderBook(stockData.bids); + bidPrices = extracted.prices; + bidVolumes = extracted.volumes; + } else if (rawData.bid_prices && Array.isArray(rawData.bid_prices)) { + // 替代格式:bid_prices 和 bid_volumes 分离 + bidPrices = rawData.bid_prices; + bidVolumes = rawData.bid_volumes || []; + } + + if (stockData.asks && stockData.asks.length > 0) { + const extracted = extractOrderBook(stockData.asks); + askPrices = extracted.prices; + askVolumes = extracted.volumes; + } else if (rawData.ask_prices && Array.isArray(rawData.ask_prices)) { + // 替代格式:ask_prices 和 ask_volumes 分离 + askPrices = rawData.ask_prices; + askVolumes = rawData.ask_volumes || []; + } updated[fullCode] = { code: fullCode, @@ -270,8 +310,43 @@ const handleSZSESnapshotMessage = ( 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); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rawData = s as any; // 用于检查替代字段名 + + // 调试日志:检查快照消息中的盘口数据(无盘口时警告) + if (!s.bids || s.bids.length === 0) { + logger.warn('FlexScreen', `SZSE快照股票数据无盘口 ${fullCode}`, { + hasBids: !!s.bids, + hasAsks: !!s.asks, + hasBidPrices: !!rawData.bid_prices, + hasAskPrices: !!rawData.ask_prices, + dataKeys: Object.keys(s), + }); + } + + // 优先使用 bids/asks 对象数组格式,如果不存在则尝试 bid_prices/ask_prices 分离数组格式 + let bidPrices: number[] = []; + let bidVolumes: number[] = []; + let askPrices: number[] = []; + let askVolumes: number[] = []; + + if (s.bids && s.bids.length > 0) { + const extracted = extractOrderBook(s.bids); + bidPrices = extracted.prices; + bidVolumes = extracted.volumes; + } else if (rawData.bid_prices && Array.isArray(rawData.bid_prices)) { + bidPrices = rawData.bid_prices; + bidVolumes = rawData.bid_volumes || []; + } + + if (s.asks && s.asks.length > 0) { + const extracted = extractOrderBook(s.asks); + askPrices = extracted.prices; + askVolumes = extracted.volumes; + } else if (rawData.ask_prices && Array.isArray(rawData.ask_prices)) { + askPrices = rawData.ask_prices; + askVolumes = rawData.ask_volumes || []; + } updated[fullCode] = { code: fullCode, diff --git a/src/views/StockOverview/components/FlexScreen/hooks/utils.ts b/src/views/StockOverview/components/FlexScreen/hooks/utils.ts index 28712193..dcc54890 100644 --- a/src/views/StockOverview/components/FlexScreen/hooks/utils.ts +++ b/src/views/StockOverview/components/FlexScreen/hooks/utils.ts @@ -75,20 +75,65 @@ export const normalizeCode = (code: string): string => { return code.split('.')[0]; }; +/** + * 盘口数据可能的格式(根据不同的 WebSocket 服务端实现) + */ +type OrderBookInput = + | OrderBookLevel[] // 格式1: [{price, volume}, ...] + | Array<[number, number]> // 格式2: [[price, volume], ...] + | { prices: number[]; volumes: number[] } // 格式3: {prices: [...], volumes: [...]} + | undefined; + /** * 从深交所 bids/asks 数组提取价格和量数组 - * @param orderBook - 盘口数组 [{price, volume}, ...] + * 支持多种可能的数据格式 + * @param orderBook - 盘口数据,支持多种格式 * @returns { prices, volumes } */ export const extractOrderBook = ( - orderBook: OrderBookLevel[] | undefined + orderBook: OrderBookInput ): { prices: number[]; volumes: number[] } => { - if (!orderBook || !Array.isArray(orderBook)) { + if (!orderBook) { return { prices: [], volumes: [] }; } - const prices = orderBook.map(item => item.price || 0); - const volumes = orderBook.map(item => item.volume || 0); - return { prices, volumes }; + + // 格式3: 已经是 {prices, volumes} 结构 + if (!Array.isArray(orderBook) && 'prices' in orderBook && 'volumes' in orderBook) { + return { + prices: orderBook.prices || [], + volumes: orderBook.volumes || [], + }; + } + + // 必须是数组才能继续 + if (!Array.isArray(orderBook) || orderBook.length === 0) { + return { prices: [], volumes: [] }; + } + + const firstItem = orderBook[0]; + + // 格式2: [[price, volume], ...] + if (Array.isArray(firstItem)) { + const prices = orderBook.map((item: unknown) => { + const arr = item as [number, number]; + return arr[0] || 0; + }); + const volumes = orderBook.map((item: unknown) => { + const arr = item as [number, number]; + return arr[1] || 0; + }); + return { prices, volumes }; + } + + // 格式1: [{price, volume}, ...] (标准格式) + if (typeof firstItem === 'object' && firstItem !== null) { + const typedBook = orderBook as OrderBookLevel[]; + const prices = typedBook.map(item => item.price || 0); + const volumes = typedBook.map(item => item.volume || 0); + return { prices, volumes }; + } + + return { prices: [], volumes: [] }; }; /**