diff --git a/app.py b/app.py index ec33fc87..4266207c 100755 --- a/app.py +++ b/app.py @@ -6412,6 +6412,10 @@ def get_stock_kline(stock_code): except ValueError: return jsonify({'error': 'Invalid event_time format'}), 400 + # 确保股票代码包含后缀(ClickHouse 中数据带后缀) + if '.' not in stock_code: + stock_code = f"{stock_code}.SH" if stock_code.startswith('6') else f"{stock_code}.SZ" + # 获取股票名称 with engine.connect() as conn: result = conn.execute(text( @@ -7837,8 +7841,13 @@ def get_index_kline(index_code): except ValueError: return jsonify({'error': 'Invalid event_time format'}), 400 + # 确保指数代码包含后缀(ClickHouse 中数据带后缀) + # 399xxx -> 深交所, 其他(000xxx等)-> 上交所 + if '.' not in index_code: + index_code = f"{index_code}.SZ" if index_code.startswith('39') else f"{index_code}.SH" + # 指数名称(暂无索引表,先返回代码本身) - index_name = index_code + index_name = index_code.split('.')[0] if chart_type == 'minute': return get_index_minute_kline(index_code, event_datetime, index_name) @@ -12710,6 +12719,134 @@ def get_hotspot_overview(): }), 500 +@app.route('/api/concept//stocks', methods=['GET']) +def get_concept_stocks(concept_id): + """ + 获取概念的相关股票列表(带实时涨跌幅) + + Args: + concept_id: 概念 ID(来自 ES concept_library_v3) + + Returns: + - stocks: 股票列表 [{code, name, reason, change_pct}, ...] + """ + try: + from elasticsearch import Elasticsearch + from clickhouse_driver import Client + + # 1. 从 ES 获取概念的股票列表 + es_client = Elasticsearch(["http://222.128.1.157:19200"]) + es_result = es_client.get(index='concept_library_v3', id=concept_id) + + if not es_result.get('found'): + return jsonify({ + 'success': False, + 'error': f'概念 {concept_id} 不存在' + }), 404 + + source = es_result.get('_source', {}) + concept_name = source.get('concept', concept_id) + raw_stocks = source.get('stocks', []) + + if not raw_stocks: + return jsonify({ + 'success': True, + 'data': { + 'concept_id': concept_id, + 'concept_name': concept_name, + 'stocks': [] + } + }) + + # 提取股票代码和原因 + stocks_info = [] + stock_codes = [] + for s in raw_stocks: + if isinstance(s, dict): + code = s.get('code', '') + if code and len(code) == 6: + stocks_info.append({ + 'code': code, + 'name': s.get('name', ''), + 'reason': s.get('reason', '') + }) + stock_codes.append(code) + + # 2. 从 ClickHouse 获取最新涨跌幅 + change_map = {} + if stock_codes: + try: + ch_client = Client( + host='127.0.0.1', + port=9000, + user='default', + password='Zzl33818!', + database='stock' + ) + + # 转换为 ClickHouse 格式 + ch_codes = [] + code_mapping = {} + for code in stock_codes: + if code.startswith('6'): + ch_code = f"{code}.SH" + elif code.startswith('0') or code.startswith('3'): + ch_code = f"{code}.SZ" + else: + ch_code = f"{code}.BJ" + ch_codes.append(ch_code) + code_mapping[ch_code] = code + + ch_codes_str = "','".join(ch_codes) + + # 查询最新分钟数据 + query = f""" + SELECT code, close, pre_close + FROM stock_minute + WHERE code IN ('{ch_codes_str}') + AND 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: + 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) + + except Exception as ch_err: + app.logger.warning(f"ClickHouse 获取涨跌幅失败: {ch_err}") + + # 3. 合并数据 + result_stocks = [] + for stock in stocks_info: + stock['change_pct'] = change_map.get(stock['code']) + result_stocks.append(stock) + + # 按涨跌幅排序(涨停优先) + result_stocks.sort(key=lambda x: x.get('change_pct') or -999, reverse=True) + + return jsonify({ + 'success': True, + 'data': { + 'concept_id': concept_id, + 'concept_name': concept_name, + 'stock_count': len(result_stocks), + 'stocks': result_stocks + } + }) + + except Exception as e: + app.logger.error(f"获取概念股票失败: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + @app.route('/api/market/concept-alerts', methods=['GET']) def get_concept_alerts(): """ diff --git a/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts b/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts index 73f243c2..7ba99be8 100644 --- a/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts +++ b/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts @@ -34,6 +34,8 @@ import type { /** * 处理上交所消息 + * 注意:上交所返回的 code 不带后缀,但通过 msg.type 区分 'stock' 和 'index' + * 存储时使用带后缀的完整代码作为 key(如 000001.SH) */ const handleSSEMessage = ( msg: SSEMessage, @@ -47,12 +49,16 @@ const handleSSEMessage = ( const data = msg.data || {}; const updated: QuotesMap = { ...prevQuotes }; let hasUpdate = false; + const isIndex = msg.type === 'index'; Object.entries(data).forEach(([code, quote]: [string, SSEQuoteItem]) => { - if (subscribedCodes.has(code)) { + // 生成带后缀的完整代码(上交所统一用 .SH) + const fullCode = code.includes('.') ? code : `${code}.SH`; + + if (subscribedCodes.has(code) || subscribedCodes.has(fullCode)) { hasUpdate = true; - updated[code] = { - code: quote.security_id, + updated[fullCode] = { + code: fullCode, name: quote.security_name, price: quote.last_price, prevClose: quote.prev_close, @@ -78,6 +84,8 @@ const handleSSEMessage = ( /** * 处理深交所实时消息 + * 注意:深交所返回的 security_id 可能带后缀也可能不带 + * 存储时统一使用带后缀的完整代码作为 key(如 000001.SZ) */ const handleSZSERealtimeMessage = ( msg: SZSERealtimeMessage, @@ -85,9 +93,11 @@ const handleSZSERealtimeMessage = ( prevQuotes: QuotesMap ): QuotesMap | null => { const { category, data, timestamp } = msg; - const code = data.security_id; + const rawCode = data.security_id; + // 生成带后缀的完整代码(深交所统一用 .SZ) + const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`; - if (!subscribedCodes.has(code)) { + if (!subscribedCodes.has(rawCode) && !subscribedCodes.has(fullCode)) { return null; } @@ -99,9 +109,9 @@ const handleSZSERealtimeMessage = ( const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(stockData.bids); const { prices: askPrices, volumes: askVolumes } = extractOrderBook(stockData.asks); - updated[code] = { - code, - name: prevQuotes[code]?.name || '', + updated[fullCode] = { + code: fullCode, + name: prevQuotes[fullCode]?.name || '', price: stockData.last_px, prevClose: stockData.prev_close, open: stockData.open_px, @@ -127,9 +137,9 @@ const handleSZSERealtimeMessage = ( case 'index': { const indexData = data as SZSEIndexData; - updated[code] = { - code, - name: prevQuotes[code]?.name || '', + updated[fullCode] = { + code: fullCode, + name: prevQuotes[fullCode]?.name || '', price: indexData.current_index, prevClose: indexData.prev_close, open: indexData.open_index, @@ -154,9 +164,9 @@ const handleSZSERealtimeMessage = ( case 'bond': { const bondData = data as SZSEBondData; - updated[code] = { - code, - name: prevQuotes[code]?.name || '', + updated[fullCode] = { + code: fullCode, + name: prevQuotes[fullCode]?.name || '', price: bondData.last_px, prevClose: bondData.prev_close, open: bondData.open_px, @@ -185,9 +195,9 @@ const handleSZSERealtimeMessage = ( const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(hkData.bids); const { prices: askPrices, volumes: askVolumes } = extractOrderBook(hkData.asks); - updated[code] = { - code, - name: prevQuotes[code]?.name || '', + updated[fullCode] = { + code: fullCode, + name: prevQuotes[fullCode]?.name || '', price: hkData.last_px, prevClose: hkData.prev_close, open: hkData.open_px, @@ -215,9 +225,9 @@ const handleSZSERealtimeMessage = ( case 'afterhours_block': case 'afterhours_trading': { const afterhoursData = data as SZSEAfterhoursData; - const existing = prevQuotes[code]; + const existing = prevQuotes[fullCode]; if (existing) { - updated[code] = { + updated[fullCode] = { ...existing, afterhours: { bidPx: afterhoursData.bid_px, @@ -243,6 +253,7 @@ const handleSZSERealtimeMessage = ( /** * 处理深交所快照消息 + * 存储时统一使用带后缀的完整代码作为 key */ const handleSZSESnapshotMessage = ( msg: SZSESnapshotMessage, @@ -254,13 +265,16 @@ const handleSZSESnapshotMessage = ( let hasUpdate = false; stocks.forEach((s: SZSEStockData) => { - if (subscribedCodes.has(s.security_id)) { + 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); - updated[s.security_id] = { - code: s.security_id, + updated[fullCode] = { + code: fullCode, name: '', price: s.last_px, prevClose: s.prev_close, @@ -284,10 +298,13 @@ const handleSZSESnapshotMessage = ( }); indexes.forEach((i: SZSEIndexData) => { - if (subscribedCodes.has(i.security_id)) { + const rawCode = i.security_id; + const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`; + + if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) { hasUpdate = true; - updated[i.security_id] = { - code: i.security_id, + updated[fullCode] = { + code: fullCode, name: '', price: i.current_index, prevClose: i.prev_close, @@ -309,10 +326,13 @@ const handleSZSESnapshotMessage = ( }); bonds.forEach((b: SZSEBondData) => { - if (subscribedCodes.has(b.security_id)) { + const rawCode = b.security_id; + const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`; + + if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) { hasUpdate = true; - updated[b.security_id] = { - code: b.security_id, + updated[fullCode] = { + code: fullCode, name: '', price: b.last_px, prevClose: b.prev_close, @@ -433,12 +453,14 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = setConnected(prev => ({ ...prev, [exchange]: true })); if (exchange === 'SSE') { - const codes = Array.from(subscribedCodes.current.SSE); - if (codes.length > 0) { + // subscribedCodes 存的是带后缀的完整代码,发送给 WS 需要去掉后缀 + const fullCodes = Array.from(subscribedCodes.current.SSE); + const baseCodes = fullCodes.map(c => normalizeCode(c)); + if (baseCodes.length > 0) { ws.send(JSON.stringify({ action: 'subscribe', channels: ['stock', 'index'], - codes, + codes: baseCodes, })); } } @@ -481,17 +503,19 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = }, [startHeartbeat, stopHeartbeat, handleMessage]); const subscribe = useCallback((code: string) => { - const baseCode = normalizeCode(code); const exchange = getExchange(code); + // 确保使用带后缀的完整代码 + const fullCode = code.includes('.') ? code : `${code}.${exchange === 'SSE' ? 'SH' : 'SZ'}`; + const baseCode = normalizeCode(code); - subscribedCodes.current[exchange].add(baseCode); + subscribedCodes.current[exchange].add(fullCode); const ws = wsRefs.current[exchange]; if (exchange === 'SSE' && ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ action: 'subscribe', channels: ['stock', 'index'], - codes: [baseCode], + codes: [baseCode], // 发送给 WS 用不带后缀的代码 })); } @@ -501,14 +525,15 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = }, [createConnection]); const unsubscribe = useCallback((code: string) => { - const baseCode = normalizeCode(code); const exchange = getExchange(code); + // 确保使用带后缀的完整代码 + const fullCode = code.includes('.') ? code : `${code}.${exchange === 'SSE' ? 'SH' : 'SZ'}`; - subscribedCodes.current[exchange].delete(baseCode); + subscribedCodes.current[exchange].delete(fullCode); setQuotes(prev => { const updated = { ...prev }; - delete updated[baseCode]; + delete updated[fullCode]; // 删除时也用带后缀的 key return updated; }); @@ -522,35 +547,40 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = }, []); // 初始化和 codes 变化处理 + // 注意:codes 现在是带后缀的完整代码(如 000001.SH) useEffect(() => { if (!codes || codes.length === 0) return; + // 使用带后缀的完整代码作为内部 key const newSseCodes = new Set(); const newSzseCodes = new Set(); codes.forEach(code => { - const baseCode = normalizeCode(code); const exchange = getExchange(code); + // 确保代码带后缀 + const fullCode = code.includes('.') ? code : `${code}.${exchange === 'SSE' ? 'SH' : 'SZ'}`; if (exchange === 'SSE') { - newSseCodes.add(baseCode); + newSseCodes.add(fullCode); } else { - newSzseCodes.add(baseCode); + newSzseCodes.add(fullCode); } }); // 更新上交所订阅 const oldSseCodes = subscribedCodes.current.SSE; const sseToAdd = [...newSseCodes].filter(c => !oldSseCodes.has(c)); + // 发送给 WebSocket 的代码需要去掉后缀 + const sseToAddBase = sseToAdd.map(c => normalizeCode(c)); if (sseToAdd.length > 0 || newSseCodes.size !== oldSseCodes.size) { subscribedCodes.current.SSE = newSseCodes; const ws = wsRefs.current.SSE; - if (ws && ws.readyState === WebSocket.OPEN && sseToAdd.length > 0) { + if (ws && ws.readyState === WebSocket.OPEN && sseToAddBase.length > 0) { ws.send(JSON.stringify({ action: 'subscribe', channels: ['stock', 'index'], - codes: sseToAdd, + codes: sseToAddBase, })); } @@ -582,7 +612,7 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = } } - // 清理已取消订阅的 quotes + // 清理已取消订阅的 quotes(使用带后缀的完整代码) const allNewCodes = new Set([...newSseCodes, ...newSzseCodes]); setQuotes(prev => { const updated: QuotesMap = {}; diff --git a/src/views/StockOverview/components/FlexScreen/hooks/utils.ts b/src/views/StockOverview/components/FlexScreen/hooks/utils.ts index 8f71de36..28712193 100644 --- a/src/views/StockOverview/components/FlexScreen/hooks/utils.ts +++ b/src/views/StockOverview/components/FlexScreen/hooks/utils.ts @@ -7,19 +7,25 @@ import type { Exchange, OrderBookLevel } from '../types'; /** * 判断证券代码属于哪个交易所 * @param code - 证券代码(可带或不带后缀) + * @param isIndex - 是否为指数(用于区分同代码的指数和股票,如 000001) * @returns 交易所标识 */ -export const getExchange = (code: string): Exchange => { - const baseCode = code.split('.')[0]; +export const getExchange = (code: string, isIndex?: boolean): Exchange => { + // 如果已带后缀,直接判断 + if (code.includes('.')) { + return code.endsWith('.SH') ? 'SSE' : 'SZSE'; + } + + const baseCode = code; // 6开头为上海股票 if (baseCode.startsWith('6')) { return 'SSE'; } - // 000开头的6位数是深圳股票(如平安银行000001) - if (baseCode.startsWith('000') && baseCode.length === 6) { - return 'SZSE'; + // 5开头是上海 ETF + if (baseCode.startsWith('5')) { + return 'SSE'; } // 399开头是深证指数 @@ -27,16 +33,16 @@ export const getExchange = (code: string): Exchange => { return 'SZSE'; } + // 000开头:如果是指数则为上交所(上证指数000001),否则为深交所(平安银行000001) + if (baseCode.startsWith('000')) { + return isIndex ? 'SSE' : 'SZSE'; + } + // 0、3开头是深圳股票 if (baseCode.startsWith('0') || baseCode.startsWith('3')) { return 'SZSE'; } - // 5开头是上海 ETF - if (baseCode.startsWith('5')) { - return 'SSE'; - } - // 1开头是深圳 ETF/债券 if (baseCode.startsWith('1')) { return 'SZSE'; @@ -46,6 +52,20 @@ export const getExchange = (code: string): Exchange => { return 'SSE'; }; +/** + * 获取证券代码的完整格式(带交易所后缀) + * @param code - 原始代码 + * @param isIndex - 是否为指数 + * @returns 带后缀的代码 + */ +export const getFullCode = (code: string, isIndex?: boolean): string => { + if (code.includes('.')) { + return code; // 已带后缀 + } + const exchange = getExchange(code, isIndex); + return `${code}.${exchange === 'SSE' ? 'SH' : 'SZ'}`; +}; + /** * 标准化证券代码为无后缀格式 * @param code - 原始代码 diff --git a/src/views/StockOverview/components/FlexScreen/index.tsx b/src/views/StockOverview/components/FlexScreen/index.tsx index 59132aa9..57b4d189 100644 --- a/src/views/StockOverview/components/FlexScreen/index.tsx +++ b/src/views/StockOverview/components/FlexScreen/index.tsx @@ -60,6 +60,7 @@ import { } from 'react-icons/fa'; import { useRealtimeQuote } from './hooks'; +import { getFullCode } from './hooks/utils'; import QuoteTile from './components/QuoteTile'; import { logger } from '@utils/logger'; import type { WatchlistItem, ConnectionStatus } from './types'; @@ -131,9 +132,9 @@ const FlexScreen: React.FC = () => { const searchBg = useColorModeValue('gray.50', '#2a2a2a'); const hoverBg = useColorModeValue('gray.100', '#333'); - // 获取订阅的证券代码列表 + // 获取订阅的证券代码列表(带后缀,用于区分上证指数000001.SH和平安银行000001.SZ) const subscribedCodes = useMemo(() => { - return watchlist.map(item => item.code); + return watchlist.map(item => getFullCode(item.code, item.isIndex)); }, [watchlist]); // WebSocket 实时行情 @@ -463,16 +464,19 @@ const FlexScreen: React.FC = () => { {/* 自选列表 */} {watchlist.length > 0 ? ( - {watchlist.map(item => ( - - ))} + {watchlist.map(item => { + const fullCode = getFullCode(item.code, item.isIndex); + return ( + + ); + })} ) : (
diff --git a/src/views/StockOverview/components/HotspotOverview/components/ConceptAlertList.js b/src/views/StockOverview/components/HotspotOverview/components/ConceptAlertList.js index 1c375bce..75d0f7cf 100644 --- a/src/views/StockOverview/components/HotspotOverview/components/ConceptAlertList.js +++ b/src/views/StockOverview/components/HotspotOverview/components/ConceptAlertList.js @@ -1,8 +1,8 @@ /** - * 概念异动列表组件 - * 展示当日的概念异动记录 + * 概念异动列表组件 - V2 + * 展示当日的概念异动记录,点击可展开显示相关股票 */ -import React from 'react'; +import React, { useState, useCallback } from 'react'; import { Box, VStack, @@ -13,222 +13,304 @@ import { Tooltip, useColorModeValue, Flex, - Divider, + Collapse, + Spinner, + Progress, + Table, + Thead, + Tbody, + Tr, + Th, + Td, + TableContainer, } from '@chakra-ui/react'; -import { FaBolt, FaArrowUp, FaArrowDown, FaChartLine, FaFire, FaVolumeUp } from 'react-icons/fa'; +import { FaArrowUp, FaArrowDown, FaFire, FaChevronDown, FaChevronRight } from 'react-icons/fa'; +import { useNavigate } from 'react-router-dom'; +import axios from 'axios'; import { getAlertTypeLabel, formatScore, getScoreColor } from '../utils/chartHelpers'; /** - * Z-Score 指示器组件 + * 紧凑型异动卡片 */ -const ZScoreIndicator = ({ value, label, tooltip }) => { - if (value === null || value === undefined) return null; - - // Z-Score 颜色:越大越红,越小越绿 - const getZScoreColor = (z) => { - const absZ = Math.abs(z); - if (absZ >= 3) return z > 0 ? 'red.600' : 'green.600'; - if (absZ >= 2) return z > 0 ? 'red.500' : 'green.500'; - if (absZ >= 1) return z > 0 ? 'orange.400' : 'teal.400'; - return 'gray.400'; - }; - - // Z-Score 强度条宽度(最大 5σ) - const barWidth = Math.min(Math.abs(value) / 5 * 100, 100); - - return ( - - - {label} - - = 0 ? '50%' : `${50 - barWidth / 2}%`} - w={`${barWidth / 2}%`} - h="100%" - bg={getZScoreColor(value)} - borderRadius="full" - /> - - - {value >= 0 ? '+' : ''}{value.toFixed(1)} - - - - ); -}; - -/** - * 持续确认率指示器 - */ -const ConfirmRatioIndicator = ({ ratio }) => { - if (ratio === null || ratio === undefined) return null; - - const percent = Math.round(ratio * 100); - const color = percent >= 80 ? 'green' : percent >= 60 ? 'orange' : 'red'; - - return ( - - - - - - - {percent}% - - - - ); -}; - -/** - * 单个异动项组件 - */ -const AlertItem = ({ alert, onClick, isSelected }) => { +const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => { + const navigate = useNavigate(); const bgColor = useColorModeValue('white', '#1a1a1a'); - const hoverBg = useColorModeValue('gray.50', '#2a2a2a'); + const hoverBg = useColorModeValue('gray.50', '#252525'); const borderColor = useColorModeValue('gray.200', '#333'); - const selectedBg = useColorModeValue('purple.50', '#2a2a3a'); + const expandedBg = useColorModeValue('purple.50', '#1e1e2e'); const isUp = alert.alert_type !== 'surge_down'; const typeColor = isUp ? 'red' : 'green'; const isV2 = alert.is_v2; - // 获取异动类型图标 - const getTypeIcon = (type) => { - switch (type) { - case 'surge_up': - case 'surge': - return FaArrowUp; - case 'surge_down': - return FaArrowDown; - case 'limit_up': - return FaFire; - case 'volume_spike': - return FaVolumeUp; - case 'rank_jump': - return FaChartLine; - default: - return FaBolt; - } + // 点击股票跳转 + const handleStockClick = (e, stockCode) => { + e.stopPropagation(); + navigate(`/company?scode=${stockCode}`); }; return ( onClick?.(alert)} + _hover={{ borderColor: 'purple.300' }} > - - {/* 左侧:概念名称和时间 */} - - - - + {/* 主卡片 - 点击展开 */} + + + {/* 左侧:名称 + 类型 */} + + + + {alert.concept_name} {isV2 && ( - + V2 )} - + + {/* 右侧:分数 */} + + {formatScore(alert.final_score)}分 + + + + {/* 第二行:时间 + 关键指标 */} + + {alert.time} {getAlertTypeLabel(alert.alert_type)} - {/* V2: 持续确认率 */} + {/* 确认率 */} {isV2 && alert.confirm_ratio !== undefined && ( - + + + = 0.8 ? 'green.500' : 'orange.500'} + /> + + {Math.round(alert.confirm_ratio * 100)}% + )} - {/* V2: Z-Score 指标行 */} - {isV2 && (alert.alpha_zscore !== undefined || alert.amt_zscore !== undefined) && ( - - - + {/* Alpha + Z-Score 简化显示 */} + + {alert.alpha !== undefined && ( + = 0 ? 'red.500' : 'green.500'} fontWeight="medium"> + α {alert.alpha >= 0 ? '+' : ''}{alert.alpha.toFixed(2)}% + + )} + {isV2 && alert.alpha_zscore !== undefined && ( + + + + = 0 ? '50%' : undefined} + right={alert.alpha_zscore < 0 ? '50%' : undefined} + w={`${Math.min(Math.abs(alert.alpha_zscore) / 5 * 50, 50)}%`} + h="100%" + bg={alert.alpha_zscore >= 0 ? 'red.500' : 'green.500'} + /> + + = 0 ? 'red.400' : 'green.400'}> + {alert.alpha_zscore >= 0 ? '+' : ''}{alert.alpha_zscore.toFixed(1)}σ + + + + )} + {alert.limit_up_ratio > 0.05 && ( + + + {Math.round(alert.limit_up_ratio * 100)}% + + )} + + + + + {/* 展开的股票列表 */} + + + {loadingStocks ? ( + + + 加载相关股票... - )} - - - {/* 右侧:分数和关键指标 */} - - {/* 综合得分 */} - {alert.final_score !== undefined && ( - - - {formatScore(alert.final_score)}分 - - - )} - - {/* Alpha 值 */} - {alert.alpha !== undefined && ( - = 0 ? 'red.500' : 'green.500'} fontWeight="medium"> - α {alert.alpha >= 0 ? '+' : ''}{alert.alpha.toFixed(2)}% + ) : stocks && stocks.length > 0 ? ( + + + + + + + + + + + {stocks.slice(0, 10).map((stock, idx) => ( + handleStockClick(e, stock.code || stock.stock_code)} + > + + + + + ))} + +
股票涨跌原因
+ + {stock.name || stock.stock_name} + + + 0 ? 'red.400' : + stock.change_pct < 0 ? 'green.400' : 'gray.400' + } + > + {stock.change_pct !== undefined + ? `${stock.change_pct > 0 ? '+' : ''}${stock.change_pct.toFixed(2)}%` + : '-' + } + + + + {stock.reason || '-'} + +
+ {stocks.length > 10 && ( + + 共 {stocks.length} 只相关股票,显示前 10 只 + + )} +
+ ) : ( + + 暂无相关股票数据 )} - - {/* V2: 动量指标 */} - {isV2 && alert.momentum_5m !== undefined && Math.abs(alert.momentum_5m) > 0.3 && ( - - 0 ? FaArrowUp : FaArrowDown} - color={alert.momentum_5m > 0 ? 'red.400' : 'green.400'} - boxSize={3} - /> - 0 ? 'red.400' : 'green.400'}> - 动量 {alert.momentum_5m > 0 ? '+' : ''}{alert.momentum_5m.toFixed(2)} - - - )} - - {/* 涨停数量 / 涨停比例 */} - {(alert.limit_up_count > 0 || (alert.limit_up_ratio > 0.05 && isV2)) && ( - - - - {alert.limit_up_count > 0 - ? `涨停 ${alert.limit_up_count}` - : `涨停 ${Math.round(alert.limit_up_ratio * 100)}%` - } - - - )} -
-
+
+ ); }; /** * 概念异动列表 - * @param {Object} props - * @param {Array} props.alerts - 异动数据数组 - * @param {Function} props.onAlertClick - 点击异动的回调 - * @param {Object} props.selectedAlert - 当前选中的异动 - * @param {number} props.maxHeight - 最大高度 */ const ConceptAlertList = ({ alerts = [], onAlertClick, selectedAlert, maxHeight = '400px' }) => { - const textColor = useColorModeValue('gray.800', 'white'); + const [expandedId, setExpandedId] = useState(null); + const [conceptStocks, setConceptStocks] = useState({}); + const [loadingConcepts, setLoadingConcepts] = useState({}); + const subTextColor = useColorModeValue('gray.500', 'gray.400'); + // 获取概念相关股票 + const fetchConceptStocks = useCallback(async (conceptId) => { + if (conceptStocks[conceptId] || loadingConcepts[conceptId]) { + return; + } + + setLoadingConcepts(prev => ({ ...prev, [conceptId]: true })); + + try { + // 调用后端 API 获取概念股票 + const response = await axios.get(`/api/concept/${conceptId}/stocks`); + if (response.data?.success && response.data?.data?.stocks) { + setConceptStocks(prev => ({ + ...prev, + [conceptId]: response.data.data.stocks + })); + } + } catch (error) { + console.error('获取概念股票失败:', error); + // 如果 API 失败,尝试从 ES 直接获取 + try { + const esResponse = await axios.get(`/api/es/concept/${conceptId}`); + if (esResponse.data?.stocks) { + setConceptStocks(prev => ({ + ...prev, + [conceptId]: esResponse.data.stocks + })); + } + } catch (esError) { + console.error('ES 获取也失败:', esError); + setConceptStocks(prev => ({ ...prev, [conceptId]: [] })); + } + } finally { + setLoadingConcepts(prev => ({ ...prev, [conceptId]: false })); + } + }, [conceptStocks, loadingConcepts]); + + // 切换展开状态 + const handleToggle = useCallback((alert) => { + const alertKey = `${alert.concept_id}-${alert.time}`; + + if (expandedId === alertKey) { + setExpandedId(null); + } else { + setExpandedId(alertKey); + // 获取股票数据 + if (alert.concept_id) { + fetchConceptStocks(alert.concept_id); + } + } + + // 通知父组件 + onAlertClick?.(alert); + }, [expandedId, fetchConceptStocks, onAlertClick]); + if (!alerts || alerts.length === 0) { return ( @@ -239,51 +321,29 @@ const ConceptAlertList = ({ alerts = [], onAlertClick, selectedAlert, maxHeight ); } - // 按时间分组 - const groupedAlerts = alerts.reduce((acc, alert) => { - const time = alert.time || '未知时间'; - if (!acc[time]) { - acc[time] = []; - } - acc[time].push(alert); - return acc; - }, {}); - - // 按时间排序 - const sortedTimes = Object.keys(groupedAlerts).sort(); + // 按时间倒序排列 + const sortedAlerts = [...alerts].sort((a, b) => { + const timeA = a.time || '00:00'; + const timeB = b.time || '00:00'; + return timeB.localeCompare(timeA); + }); return ( - - - {sortedTimes.map((time, timeIndex) => ( - - {/* 时间分隔线 */} - {timeIndex > 0 && } - - {/* 时间标签 */} - - - - {time} - - - ({groupedAlerts[time].length}个异动) - - - - {/* 该时间点的异动 */} - - {groupedAlerts[time].map((alert, idx) => ( - - ))} - - - ))} + + + {sortedAlerts.map((alert, idx) => { + const alertKey = `${alert.concept_id}-${alert.time}`; + return ( + handleToggle(alert)} + stocks={conceptStocks[alert.concept_id]} + loadingStocks={loadingConcepts[alert.concept_id]} + /> + ); + })} );