diff --git a/MeAgent/package.json b/MeAgent/package.json index 2fe97bbb..da111949 100644 --- a/MeAgent/package.json +++ b/MeAgent/package.json @@ -41,6 +41,7 @@ "react-dom": "18.2.0", "react-native": "0.74.5", "react-native-gesture-handler": "~2.16.1", + "react-native-kline-view": "github:hellohublot/react-native-kline-view", "react-native-modal-dropdown": "1.0.2", "react-native-reanimated": "~3.10.1", "react-native-safe-area-context": "4.10.5", diff --git a/MeAgent/src/components/MiniKlineChart.js b/MeAgent/src/components/MiniKlineChart.js new file mode 100644 index 00000000..3934e5c6 --- /dev/null +++ b/MeAgent/src/components/MiniKlineChart.js @@ -0,0 +1,152 @@ +/** + * Mini K线图组件 + * 轻量级蜡烛图,用于自选股列表显示 + */ + +import React, { memo, useMemo } from 'react'; +import { View, StyleSheet } from 'react-native'; +import Svg, { Rect, Line, G } from 'react-native-svg'; + +// 涨跌颜色 +const COLORS = { + up: '#EF4444', // 红色(涨) + down: '#22C55E', // 绿色(跌) + flat: '#6B7280', // 灰色(平) +}; + +/** + * Mini K线图 + * @param {Array} data - K线数据 [{ open, high, low, close }, ...] + * @param {number} width - 图表宽度 + * @param {number} height - 图表高度 + * @param {number} candleCount - 显示的K线数量(默认显示最近20根) + */ +const MiniKlineChart = memo(({ + data = [], + width = 80, + height = 40, + candleCount = 20, +}) => { + // 计算K线数据 + const candles = useMemo(() => { + if (!data || data.length === 0) { + return []; + } + + // 取最近 N 根K线 + const recentData = data.slice(-candleCount); + if (recentData.length === 0) return []; + + // 计算价格范围 + let minPrice = Infinity; + let maxPrice = -Infinity; + + recentData.forEach(d => { + const high = d.high || d.close || 0; + const low = d.low || d.close || 0; + if (high > maxPrice) maxPrice = high; + if (low < minPrice) minPrice = low; + }); + + if (minPrice === Infinity || maxPrice === -Infinity) { + return []; + } + + const priceRange = maxPrice - minPrice || 1; + const padding = priceRange * 0.1; + const effectiveMin = minPrice - padding; + const effectiveMax = maxPrice + padding; + const effectiveRange = effectiveMax - effectiveMin; + + // K线间距和宽度 + const totalCandles = recentData.length; + const gap = 1; // K线间距 + const candleWidth = Math.max(2, (width - gap * (totalCandles - 1)) / totalCandles); + + // 生成K线数据 + return recentData.map((d, index) => { + const open = d.open || d.close || 0; + const close = d.close || 0; + const high = d.high || Math.max(open, close); + const low = d.low || Math.min(open, close); + + // 判断涨跌 + const isUp = close >= open; + const color = close > open ? COLORS.up : close < open ? COLORS.down : COLORS.flat; + + // 计算坐标 + const x = index * (candleWidth + gap); + const bodyTop = height - ((Math.max(open, close) - effectiveMin) / effectiveRange) * height; + const bodyBottom = height - ((Math.min(open, close) - effectiveMin) / effectiveRange) * height; + const bodyHeight = Math.max(1, bodyBottom - bodyTop); // 最小高度1 + + const wickTop = height - ((high - effectiveMin) / effectiveRange) * height; + const wickBottom = height - ((low - effectiveMin) / effectiveRange) * height; + + return { + x, + bodyTop, + bodyHeight, + wickTop, + wickBottom, + candleWidth, + color, + isUp, + }; + }); + }, [data, width, height, candleCount]); + + if (candles.length === 0) { + // 无数据时显示占位 + return ( + + + + ); + } + + return ( + + + {candles.map((candle, index) => ( + + {/* 上下影线 */} + + {/* K线实体 */} + + + ))} + + + ); +}); + +const styles = StyleSheet.create({ + container: { + overflow: 'hidden', + }, + placeholder: { + flex: 1, + backgroundColor: 'rgba(255,255,255,0.03)', + borderRadius: 4, + }, +}); + +MiniKlineChart.displayName = 'MiniKlineChart'; + +export default MiniKlineChart; diff --git a/MeAgent/src/components/MiniTimelineChart.js b/MeAgent/src/components/MiniTimelineChart.js new file mode 100644 index 00000000..05040a03 --- /dev/null +++ b/MeAgent/src/components/MiniTimelineChart.js @@ -0,0 +1,152 @@ +/** + * Mini 分时图组件 + * 轻量级折线图,用于自选股列表显示 + */ + +import React, { memo, useMemo } from 'react'; +import { View, StyleSheet } from 'react-native'; +import Svg, { Path, Defs, LinearGradient, Stop, Line } from 'react-native-svg'; + +/** + * Mini 分时图 + * @param {Array} data - 分时数据 [{ close, change_pct }, ...] + * @param {number} width - 图表宽度 + * @param {number} height - 图表高度 + * @param {string} color - 线条颜色(可选,根据涨跌自动计算) + * @param {boolean} showBaseline - 是否显示基准线(开盘价位置) + */ +const MiniTimelineChart = memo(({ + data = [], + width = 80, + height = 40, + color, + showBaseline = true, +}) => { + // 计算图表路径 + const { linePath, areaPath, lineColor, baselineY } = useMemo(() => { + if (!data || data.length < 2) { + return { linePath: '', areaPath: '', lineColor: '#6B7280', baselineY: height / 2 }; + } + + // 提取收盘价数据 + const prices = data.map(d => d.close || d.price || 0).filter(p => p > 0); + if (prices.length < 2) { + return { linePath: '', areaPath: '', lineColor: '#6B7280', baselineY: height / 2 }; + } + + // 计算最高最低价(留出边距) + const minPrice = Math.min(...prices); + const maxPrice = Math.max(...prices); + const priceRange = maxPrice - minPrice || 1; + const padding = priceRange * 0.1; // 10% 边距 + + const effectiveMin = minPrice - padding; + const effectiveMax = maxPrice + padding; + const effectiveRange = effectiveMax - effectiveMin; + + // 计算第一个价格(基准线位置) + const firstPrice = prices[0]; + const lastPrice = prices[prices.length - 1]; + + // 根据涨跌确定颜色 + const autoColor = lastPrice >= firstPrice ? '#EF4444' : '#22C55E'; // 红涨绿跌 + const finalColor = color || autoColor; + + // 计算坐标点 + const points = prices.map((price, index) => { + const x = (index / (prices.length - 1)) * width; + const y = height - ((price - effectiveMin) / effectiveRange) * height; + return { x, y }; + }); + + // 生成折线路径 + let pathD = `M ${points[0].x} ${points[0].y}`; + for (let i = 1; i < points.length; i++) { + pathD += ` L ${points[i].x} ${points[i].y}`; + } + + // 生成填充区域路径 + let areaD = pathD; + areaD += ` L ${width} ${height}`; + areaD += ` L 0 ${height}`; + areaD += ' Z'; + + // 计算基准线 Y 坐标 + const baseY = height - ((firstPrice - effectiveMin) / effectiveRange) * height; + + return { + linePath: pathD, + areaPath: areaD, + lineColor: finalColor, + baselineY: baseY, + }; + }, [data, width, height, color]); + + if (!linePath) { + // 无数据时显示占位 + return ( + + + + ); + } + + const gradientId = `gradient-${Math.random().toString(36).substr(2, 9)}`; + + return ( + + + + + + + + + + {/* 填充区域 */} + + + {/* 基准线(虚线) */} + {showBaseline && ( + + )} + + {/* 折线 */} + + + + ); +}); + +const styles = StyleSheet.create({ + container: { + overflow: 'hidden', + }, + placeholder: { + flex: 1, + backgroundColor: 'rgba(255,255,255,0.03)', + borderRadius: 4, + }, +}); + +MiniTimelineChart.displayName = 'MiniTimelineChart'; + +export default MiniTimelineChart; diff --git a/MeAgent/src/hooks/useOrderBook.js b/MeAgent/src/hooks/useOrderBook.js new file mode 100644 index 00000000..22112d9e --- /dev/null +++ b/MeAgent/src/hooks/useOrderBook.js @@ -0,0 +1,70 @@ +/** + * 五档盘口历史数据 Hook + * + * 注意:实时五档数据通过 WebSocket 推送获取(见 useSingleQuote Hook) + * 本 Hook 仅用于查询历史五档数据,用于分时图回放、历史分析等场景 + * + * 数据源:222.128.1.157 ClickHouse (szse_stock_realtime / sse_stock_realtime) + */ + +import { useState, useCallback } from 'react'; +import { stockDetailService } from '../services/stockService'; + +/** + * 五档盘口历史数据 Hook + * @param {string} stockCode - 股票代码 + * @returns {object} { fetchHistory, historyData, loading, error } + */ +export const useOrderBook = (stockCode) => { + const [historyData, setHistoryData] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + /** + * 获取指定日期的五档历史数据 + * @param {object} options - { date, startTime, endTime, limit } + */ + const fetchHistory = useCallback(async (options = {}) => { + if (!stockCode) return; + + try { + setLoading(true); + setError(null); + + const result = await stockDetailService.getOrderBookHistory(stockCode, options); + + if (result.success && result.data) { + setHistoryData(result.data); + console.log('[useOrderBook] 历史数据加载完成:', result.total, '条'); + return result; + } else { + setError(result.error || '获取历史数据失败'); + return result; + } + } catch (err) { + console.error('[useOrderBook] 获取历史数据失败:', err); + setError(err.message); + return { success: false, error: err.message }; + } finally { + setLoading(false); + } + }, [stockCode]); + + /** + * 清空历史数据 + */ + const clearHistory = useCallback(() => { + setHistoryData([]); + setError(null); + }, []); + + return { + fetchHistory, + clearHistory, + historyData, + loading, + error, + }; +}; + +export default useOrderBook; diff --git a/MeAgent/src/hooks/useWatchlist.js b/MeAgent/src/hooks/useWatchlist.js index 4a3b3603..9f56b6a2 100644 --- a/MeAgent/src/hooks/useWatchlist.js +++ b/MeAgent/src/hooks/useWatchlist.js @@ -9,12 +9,14 @@ import { fetchWatchlist, fetchWatchlistRealtime, fetchFollowingEvents, + fetchMiniCharts, addToWatchlist, removeFromWatchlist, toggleEventFollow, selectWatchlistStocks, selectWatchlistEvents, selectRealtimeQuotes, + selectMiniCharts, selectWatchlistLoading, selectWatchlistError, selectIsInWatchlist, @@ -42,6 +44,7 @@ export const useWatchlist = (options = {}) => { const stocks = useSelector(selectWatchlistStocks); const events = useSelector(selectWatchlistEvents); const realtimeQuotes = useSelector(selectRealtimeQuotes); + const miniCharts = useSelector(selectMiniCharts); const loading = useSelector(selectWatchlistLoading); const error = useSelector(selectWatchlistError); @@ -60,6 +63,11 @@ export const useWatchlist = (options = {}) => { return dispatch(fetchFollowingEvents()); }, [dispatch]); + // 加载 mini 图表数据 + const loadMiniCharts = useCallback(() => { + return dispatch(fetchMiniCharts()); + }, [dispatch]); + // 添加股票到自选 const handleAddStock = useCallback(async (stockCode, stockName = '') => { // 乐观更新 @@ -139,12 +147,26 @@ export const useWatchlist = (options = {}) => { null; }, [realtimeQuotes]); - // 合并股票列表和实时行情 + // 获取股票的 mini 图表数据 + const getStockCharts = useCallback((stockCode) => { + const normalizeCode = (code) => String(code).match(/\d{6}/)?.[0] || code; + const normalizedCode = normalizeCode(stockCode); + + return miniCharts[stockCode] || + miniCharts[normalizedCode] || + miniCharts[`${normalizedCode}.SH`] || + miniCharts[`${normalizedCode}.SZ`] || + null; + }, [miniCharts]); + + // 合并股票列表、实时行情和图表数据 const stocksWithQuotes = stocks.map(stock => { const quote = getStockQuote(stock.stock_code); + const charts = getStockCharts(stock.stock_code); return { ...stock, quote, + charts, }; }); @@ -154,8 +176,9 @@ export const useWatchlist = (options = {}) => { loadWatchlist(), loadRealtimeQuotes(), loadFollowingEvents(), + loadMiniCharts(), ]); - }, [loadWatchlist, loadRealtimeQuotes, loadFollowingEvents]); + }, [loadWatchlist, loadRealtimeQuotes, loadFollowingEvents, loadMiniCharts]); // 启动定时刷新 const startRefreshTimer = useCallback(() => { @@ -180,8 +203,9 @@ export const useWatchlist = (options = {}) => { loadWatchlist(); loadRealtimeQuotes(); loadFollowingEvents(); + loadMiniCharts(); } - }, [autoLoad, loadWatchlist, loadRealtimeQuotes, loadFollowingEvents]); + }, [autoLoad, loadWatchlist, loadRealtimeQuotes, loadFollowingEvents, loadMiniCharts]); // 启动/停止定时刷新 useEffect(() => { @@ -199,6 +223,7 @@ export const useWatchlist = (options = {}) => { stocks, events, realtimeQuotes, + miniCharts, stocksWithQuotes, // 加载状态 @@ -207,11 +232,13 @@ export const useWatchlist = (options = {}) => { isLoadingStocks: loading.stocks, isLoadingEvents: loading.events, isLoadingRealtime: loading.realtime, + isLoadingMiniCharts: loading.miniCharts, // 操作方法 loadWatchlist, loadRealtimeQuotes, loadFollowingEvents, + loadMiniCharts, refreshAll, // 自选股操作 @@ -220,6 +247,7 @@ export const useWatchlist = (options = {}) => { toggleStock, isInWatchlist, getStockQuote, + getStockCharts, // 事件操作 toggleEventFollow: handleToggleEventFollow, diff --git a/MeAgent/src/screens/StockDetail/StockDetailScreen.js b/MeAgent/src/screens/StockDetail/StockDetailScreen.js index 6d5b41c3..1a9928c4 100644 --- a/MeAgent/src/screens/StockDetail/StockDetailScreen.js +++ b/MeAgent/src/screens/StockDetail/StockDetailScreen.js @@ -14,13 +14,14 @@ import { PriceHeader, ChartTypeTabs, MinuteChart, - KlineChart, + ProfessionalKlineChart, OrderBook, RelatedInfoTabs, EventsPanel, ConceptsPanel, AnnouncementsPanel, RiseAnalysisModal, + RiseAnalysisPanel, } from './components'; import { stockDetailService } from '../../services/stockService'; @@ -71,8 +72,8 @@ const StockDetailScreen = () => { const orderBook = useSelector(selectOrderBook); const loading = useSelector(selectStockLoading); - // WebSocket 实时行情 - const { quote: realtimeQuote } = useSingleQuote(stockCode); + // WebSocket 实时行情(包含五档盘口数据) + const { quote: realtimeQuote, isConnected: wsConnected } = useSingleQuote(stockCode); // 自选股操作 const { isInWatchlist, toggleStock } = useWatchlist({ autoLoad: false }); @@ -214,6 +215,14 @@ const StockDetailScreen = () => { return ; case 'announcements': return ; + case 'analysis': + return ( + + ); default: return null; } @@ -246,24 +255,24 @@ const StockDetailScreen = () => { loading={isChartLoading} /> ) : ( - )} - {/* 5档盘口(可选,放在分时图右侧效果更好,这里简化) */} - {chartType === 'minute' && orderBook.bidPrices?.length > 0 && ( + {/* 5档盘口 - 使用 WebSocket 实时推送的五档数据 */} + {chartType === 'minute' && ( )} diff --git a/MeAgent/src/screens/StockDetail/components/OrderBook.js b/MeAgent/src/screens/StockDetail/components/OrderBook.js index d7ac3eb1..082661b2 100644 --- a/MeAgent/src/screens/StockDetail/components/OrderBook.js +++ b/MeAgent/src/screens/StockDetail/components/OrderBook.js @@ -99,6 +99,8 @@ const OrderRow = memo(({ label, price, volume, maxVolume, type, preClose }) => { * @param {array} props.bidPrices - 买盘价格 [买1, 买2, 买3, 买4, 买5] * @param {array} props.bidVolumes - 买盘数量 * @param {number} props.preClose - 昨收价 + * @param {string} props.updateTime - 更新时间 + * @param {boolean} props.isConnected - WebSocket 是否已连接 */ const OrderBook = memo(({ askPrices = [], @@ -106,6 +108,8 @@ const OrderBook = memo(({ bidPrices = [], bidVolumes = [], preClose, + updateTime, + isConnected, }) => { // 计算最大成交量(用于背景条宽度) const maxVolume = useMemo(() => { @@ -153,9 +157,12 @@ const OrderBook = memo(({ borderWidth={1} borderColor="rgba(255,255,255,0.08)" > - - 暂无盘口数据 - + + + + {isConnected ? '等待盘口数据...' : '未连接行情服务'} + + ); } @@ -177,10 +184,25 @@ const OrderBook = memo(({ 五档盘口 + {isConnected && ( + + + 实时 + + )} + + + {updateTime && ( + + {typeof updateTime === 'string' && updateTime.includes(' ') + ? updateTime.split(' ')[1]?.substring(0, 8) + : updateTime?.substring(11, 19) || ''} + + )} + + 单位: 手 + - - 单位: 手 - diff --git a/MeAgent/src/screens/StockDetail/components/ProfessionalKlineChart.js b/MeAgent/src/screens/StockDetail/components/ProfessionalKlineChart.js new file mode 100644 index 00000000..9dc37ce8 --- /dev/null +++ b/MeAgent/src/screens/StockDetail/components/ProfessionalKlineChart.js @@ -0,0 +1,505 @@ +/** + * 专业 K 线图组件 + * 基于 react-native-kline-view 实现 + * 支持 MA/BOLL/MACD/KDJ/RSI/WR 技术指标 + */ + +import React, { memo, useMemo, useState, useCallback, useRef, useEffect } from 'react'; +import { Dimensions, Platform, PixelRatio, processColor } from 'react-native'; +import { Box, Text, HStack, Spinner, Pressable } from 'native-base'; +import RNKLineView from 'react-native-kline-view'; + +const { width: SCREEN_WIDTH } = Dimensions.get('window'); +const CHART_HEIGHT = 320; + +// 时间类型常量 +const TimeConstants = { + minute: -1, // 分时 + daily: 9, // 日K + weekly: 10, // 周K + monthly: 11, // 月K +}; + +// 指标类型常量 +const IndicatorTypes = { + main: { + ma: 1, + boll: 2, + none: 0, + }, + sub: { + macd: 3, + kdj: 4, + rsi: 5, + wr: 6, + none: 0, + }, +}; + +// 暗色主题配置 +const DARK_THEME = { + backgroundColor: 'rgb(10, 10, 15)', + textColor: 'rgb(148, 163, 184)', + gridColor: 'rgb(30, 41, 59)', + increaseColor: 'rgb(239, 68, 68)', // 红涨 + decreaseColor: 'rgb(34, 197, 94)', // 绿跌 + minuteLineColor: 'rgb(59, 130, 246)', +}; + +// 格式化价格 +const formatPrice = (price, precision = 2) => { + if (price === null || price === undefined || isNaN(price)) return '--'; + return Number(price).toFixed(precision); +}; + +// 格式化成交量 +const formatVolume = (volume) => { + if (!volume) return '--'; + if (volume >= 100000000) return `${(volume / 100000000).toFixed(2)}亿`; + if (volume >= 10000) return `${(volume / 10000).toFixed(0)}万`; + return String(Math.round(volume)); +}; + +// 格式化日期 +const formatDate = (dateStr) => { + if (!dateStr) return ''; + const cleaned = String(dateStr).replace(/-/g, ''); + if (cleaned.length >= 8) { + return `${cleaned.substring(4, 6)}-${cleaned.substring(6, 8)}`; + } + return dateStr; +}; + +// 计算 BOLL 指标 +const calculateBOLL = (data, n = 20, p = 2) => { + return data.map((item, index) => { + if (index < n - 1) { + return { ...item, bollMb: item.close, bollUp: item.close, bollDn: item.close }; + } + let sum = 0; + for (let i = index - n + 1; i <= index; i++) { + sum += data[i].close; + } + const ma = sum / n; + let variance = 0; + for (let i = index - n + 1; i <= index; i++) { + variance += Math.pow(data[i].close - ma, 2); + } + const std = Math.sqrt(variance / (n - 1)); + return { ...item, bollMb: ma, bollUp: ma + p * std, bollDn: ma - p * std }; + }); +}; + +// 计算 MACD 指标 +const calculateMACD = (data, s = 12, l = 26, m = 9) => { + let ema12 = data[0]?.close || 0; + let ema26 = data[0]?.close || 0; + let dea = 0; + + return data.map((item, index) => { + if (index === 0) { + return { ...item, macdValue: 0, macdDea: 0, macdDif: 0 }; + } + ema12 = (2 * item.close + (s - 1) * ema12) / (s + 1); + ema26 = (2 * item.close + (l - 1) * ema26) / (l + 1); + const dif = ema12 - ema26; + dea = (2 * dif + (m - 1) * dea) / (m + 1); + const macd = 2 * (dif - dea); + return { ...item, macdValue: macd, macdDea: dea, macdDif: dif }; + }); +}; + +// 计算 KDJ 指标 +const calculateKDJ = (data, n = 9, m1 = 3, m2 = 3) => { + let k = 50, d = 50; + + return data.map((item, index) => { + if (index === 0) { + return { ...item, kdjK: k, kdjD: d, kdjJ: 3 * k - 2 * d }; + } + const startIndex = Math.max(0, index - n + 1); + let highest = -Infinity, lowest = Infinity; + for (let i = startIndex; i <= index; i++) { + highest = Math.max(highest, data[i].high); + lowest = Math.min(lowest, data[i].low); + } + const rsv = highest === lowest ? 50 : ((item.close - lowest) / (highest - lowest)) * 100; + k = (rsv + (m1 - 1) * k) / m1; + d = (k + (m1 - 1) * d) / m1; + const j = m2 * k - 2 * d; + return { ...item, kdjK: k, kdjD: d, kdjJ: j }; + }); +}; + +/** + * 专业 K 线图 + * @param {Array} data - K线数据 [{ date, open, high, low, close, volume, amount }] + * @param {string} type - K线类型: daily | weekly | monthly + * @param {boolean} loading - 加载状态 + */ +const ProfessionalKlineChart = memo(({ + data = [], + type = 'daily', + loading = false, +}) => { + const kLineViewRef = useRef(null); + const [mainIndicator, setMainIndicator] = useState('ma'); + const [subIndicator, setSubIndicator] = useState('macd'); + const [optionList, setOptionList] = useState(null); + + // 获取时间类型 + const timeType = useMemo(() => { + switch (type) { + case 'weekly': return TimeConstants.weekly; + case 'monthly': return TimeConstants.monthly; + default: return TimeConstants.daily; + } + }, [type]); + + // 处理 K 线数据 + const processedData = useMemo(() => { + if (!data || data.length === 0) return []; + + const pixelRatio = Platform.select({ android: PixelRatio.get(), ios: 1 }); + + // 转换数据格式 + let processed = data.map((item, index) => { + const open = item.open || item.open_price || 0; + const high = item.high || item.high_price || 0; + const low = item.low || item.low_price || 0; + const close = item.close || item.close_price || 0; + const volume = item.volume || item.vol || 0; + const dateStr = item.date || item.trade_date || ''; + + return { + id: new Date(dateStr.replace(/-/g, '/')).getTime() || Date.now() - (data.length - index) * 86400000, + open, + high, + low, + close, + vol: volume, + dateString: formatDate(dateStr), + }; + }); + + // 计算 MA + processed = processed.map((item, index) => { + const maList = []; + [5, 10, 20].forEach((period, i) => { + if (index < period - 1) { + maList.push({ value: item.close, title: `${period}` }); + } else { + let sum = 0; + for (let j = index - period + 1; j <= index; j++) { + sum += processed[j].close; + } + maList.push({ value: sum / period, title: `${period}` }); + } + }); + + // 成交量 MA + const maVolumeList = []; + [5, 10].forEach((period, i) => { + if (index < period - 1) { + maVolumeList.push({ value: item.vol, title: `${period}` }); + } else { + let sum = 0; + for (let j = index - period + 1; j <= index; j++) { + sum += processed[j].vol; + } + maVolumeList.push({ value: sum / period, title: `${period}` }); + } + }); + + return { ...item, maList, maVolumeList }; + }); + + // 计算 BOLL + if (mainIndicator === 'boll') { + processed = calculateBOLL(processed); + } + + // 计算副图指标 + if (subIndicator === 'macd') { + processed = calculateMACD(processed); + } else if (subIndicator === 'kdj') { + processed = calculateKDJ(processed); + } + + // 添加选中信息 + processed = processed.map((item, index) => { + const preClose = index > 0 ? processed[index - 1].close : item.open; + const change = item.close - preClose; + const changePercent = preClose > 0 ? (change / preClose * 100) : 0; + const isUp = change >= 0; + const color = isUp ? processColor(DARK_THEME.increaseColor) : processColor(DARK_THEME.decreaseColor); + + const selectedItemList = [ + { title: '时间', detail: item.dateString }, + { title: '开', detail: formatPrice(item.open) }, + { title: '高', detail: formatPrice(item.high) }, + { title: '低', detail: formatPrice(item.low) }, + { title: '收', detail: formatPrice(item.close) }, + { title: '涨跌', detail: `${isUp ? '+' : ''}${formatPrice(change)}`, color }, + { title: '涨幅', detail: `${isUp ? '+' : ''}${changePercent.toFixed(2)}%`, color }, + { title: '成交量', detail: formatVolume(item.vol) }, + ]; + + // 添加指标信息 + if (mainIndicator === 'ma' && item.maList) { + item.maList.forEach(ma => { + selectedItemList.push({ title: `MA${ma.title}`, detail: formatPrice(ma.value) }); + }); + } + if (subIndicator === 'macd' && item.macdDif !== undefined) { + selectedItemList.push( + { title: 'DIF', detail: formatPrice(item.macdDif, 4) }, + { title: 'DEA', detail: formatPrice(item.macdDea, 4) }, + { title: 'MACD', detail: formatPrice(item.macdValue, 4) } + ); + } + if (subIndicator === 'kdj' && item.kdjK !== undefined) { + selectedItemList.push( + { title: 'K', detail: formatPrice(item.kdjK) }, + { title: 'D', detail: formatPrice(item.kdjD) }, + { title: 'J', detail: formatPrice(item.kdjJ) } + ); + } + + return { ...item, selectedItemList }; + }); + + return processed; + }, [data, mainIndicator, subIndicator]); + + // 构建配置选项 + const buildOptionList = useCallback(() => { + if (processedData.length === 0) return null; + + const pixelRatio = Platform.select({ android: PixelRatio.get(), ios: 1 }); + + const configList = { + colorList: { + increaseColor: processColor(DARK_THEME.increaseColor), + decreaseColor: processColor(DARK_THEME.decreaseColor), + }, + targetColorList: [ + processColor('rgb(245, 219, 148)'), // MA5 - 金色 + processColor('rgb(97, 210, 191)'), // MA10 - 青色 + processColor('rgb(204, 145, 255)'), // MA20 - 紫色 + processColor('rgb(255, 59, 61)'), // 红 + processColor('rgb(112, 209, 8)'), // 绿 + processColor('rgb(112, 33, 255)'), // 蓝 + ], + minuteLineColor: processColor(DARK_THEME.minuteLineColor), + minuteGradientColorList: [ + processColor('rgba(59, 130, 246, 0.3)'), + processColor('rgba(59, 130, 246, 0.15)'), + processColor('rgba(10, 10, 15, 0)'), + processColor('rgba(10, 10, 15, 0)'), + ], + minuteGradientLocationList: [0, 0.3, 0.6, 1], + backgroundColor: processColor(DARK_THEME.backgroundColor), + textColor: processColor(DARK_THEME.textColor), + gridColor: processColor(DARK_THEME.gridColor), + candleTextColor: processColor('rgb(255, 255, 255)'), + panelBackgroundColor: processColor('rgba(30, 41, 59, 0.95)'), + panelBorderColor: processColor(DARK_THEME.textColor), + panelTextColor: processColor('rgb(255, 255, 255)'), + selectedPointContainerColor: processColor('transparent'), + selectedPointContentColor: processColor('rgb(255, 255, 255)'), + mainFlex: subIndicator === 'none' ? 0.85 : 0.6, + volumeFlex: 0.15, + paddingTop: 20 * pixelRatio, + paddingBottom: 20 * pixelRatio, + paddingRight: 50 * pixelRatio, + itemWidth: 8 * pixelRatio, + candleWidth: 6 * pixelRatio, + minuteVolumeCandleColor: processColor('rgba(59, 130, 246, 0.5)'), + minuteVolumeCandleWidth: 2 * pixelRatio, + macdCandleWidth: 1 * pixelRatio, + headerTextFontSize: 10 * pixelRatio, + rightTextFontSize: 10 * pixelRatio, + candleTextFontSize: 10 * pixelRatio, + panelTextFontSize: 10 * pixelRatio, + panelMinWidth: 130 * pixelRatio, + fontFamily: Platform.select({ ios: 'Helvetica', android: '' }), + closePriceRightLightLottieSource: '', + }; + + const targetList = { + maList: [ + { title: '5', selected: mainIndicator === 'ma', index: 0 }, + { title: '10', selected: mainIndicator === 'ma', index: 1 }, + { title: '20', selected: mainIndicator === 'ma', index: 2 }, + ], + maVolumeList: [ + { title: '5', selected: true, index: 0 }, + { title: '10', selected: true, index: 1 }, + ], + bollN: '20', + bollP: '2', + macdS: '12', + macdL: '26', + macdM: '9', + kdjN: '9', + kdjM1: '3', + kdjM2: '3', + rsiList: [ + { title: '6', selected: subIndicator === 'rsi', index: 0 }, + { title: '12', selected: subIndicator === 'rsi', index: 1 }, + { title: '24', selected: subIndicator === 'rsi', index: 2 }, + ], + wrList: [ + { title: '14', selected: subIndicator === 'wr', index: 0 }, + ], + }; + + const drawList = { + shotBackgroundColor: processColor(DARK_THEME.backgroundColor), + drawType: 0, + shouldReloadDrawItemIndex: -3, + drawShouldContinue: false, + shouldClearDraw: false, + }; + + return { + modelArray: processedData, + shouldScrollToEnd: true, + targetList, + price: 2, + volume: 0, + primary: IndicatorTypes.main[mainIndicator] || 1, + second: IndicatorTypes.sub[subIndicator] || 3, + time: timeType, + configList, + drawList, + }; + }, [processedData, mainIndicator, subIndicator, timeType]); + + // 更新图表 + useEffect(() => { + const options = buildOptionList(); + if (options) { + setOptionList(JSON.stringify(options)); + } + }, [buildOptionList]); + + // 切换主图指标 + const toggleMainIndicator = useCallback(() => { + setMainIndicator(prev => prev === 'ma' ? 'boll' : 'ma'); + }, []); + + // 切换副图指标 + const toggleSubIndicator = useCallback(() => { + const indicators = ['macd', 'kdj', 'none']; + const currentIndex = indicators.indexOf(subIndicator); + setSubIndicator(indicators[(currentIndex + 1) % indicators.length]); + }, [subIndicator]); + + // 加载状态 + if (loading) { + return ( + + + 加载K线数据... + + ); + } + + // 无数据状态 + if (!optionList || processedData.length === 0) { + return ( + + 暂无K线数据 + + ); + } + + return ( + + {/* 指标切换栏 */} + + + + + {mainIndicator === 'ma' ? 'MA' : 'BOLL'} + + + + + + + + {subIndicator === 'none' ? '副图' : subIndicator.toUpperCase()} + + + + + + + 触摸查看详情 + + + {/* K 线图 */} + + + + + ); +}); + +ProfessionalKlineChart.displayName = 'ProfessionalKlineChart'; + +export default ProfessionalKlineChart; diff --git a/MeAgent/src/screens/StockDetail/components/RelatedInfoTabs.js b/MeAgent/src/screens/StockDetail/components/RelatedInfoTabs.js index d662d14e..e770fa69 100644 --- a/MeAgent/src/screens/StockDetail/components/RelatedInfoTabs.js +++ b/MeAgent/src/screens/StockDetail/components/RelatedInfoTabs.js @@ -11,6 +11,7 @@ const INFO_TABS = [ { key: 'events', label: '相关事件' }, { key: 'concepts', label: '相关概念' }, { key: 'announcements', label: '公司公告' }, + { key: 'analysis', label: '异动分析' }, ]; /** diff --git a/MeAgent/src/screens/StockDetail/components/RiseAnalysisPanel.js b/MeAgent/src/screens/StockDetail/components/RiseAnalysisPanel.js new file mode 100644 index 00000000..21920bc7 --- /dev/null +++ b/MeAgent/src/screens/StockDetail/components/RiseAnalysisPanel.js @@ -0,0 +1,218 @@ +/** + * 涨幅分析列表组件 + * 按时间从近到远显示股票异动原因 + */ + +import React, { memo } from 'react'; +import { StyleSheet } from 'react-native'; +import { + Box, + VStack, + HStack, + Text, + FlatList, + Pressable, + Icon, +} from 'native-base'; +import { Ionicons } from '@expo/vector-icons'; + +// 格式化日期 +const formatDate = (dateStr) => { + if (!dateStr) return ''; + const cleaned = String(dateStr).replace(/-/g, ''); + if (cleaned.length >= 8) { + return `${cleaned.substring(0, 4)}-${cleaned.substring(4, 6)}-${cleaned.substring(6, 8)}`; + } + return dateStr; +}; + +// 格式化涨跌幅 +const formatChange = (change) => { + if (change === undefined || change === null) return '--'; + const sign = change > 0 ? '+' : ''; + return `${sign}${Number(change).toFixed(2)}%`; +}; + +// 获取涨跌颜色 +const getChangeColor = (change) => { + if (change > 0) return '#EF4444'; // 红涨 + if (change < 0) return '#22C55E'; // 绿跌 + return '#94A3B8'; +}; + +/** + * 单条分析记录 + */ +const AnalysisItem = memo(({ item, onPress }) => { + const changeColor = getChangeColor(item.change_pct); + + return ( + onPress?.(item)}> + {({ pressed }) => ( + + + {/* 左侧:日期和涨幅 */} + + + {formatDate(item.trade_date)} + + + {formatChange(item.change_pct)} + + + + {/* 右侧:异动原因 */} + + {/* 主要原因 */} + {item.main_reason && ( + + + + {item.main_reason} + + + )} + + {/* 相关概念 */} + {item.related_concepts && item.related_concepts.length > 0 && ( + + {item.related_concepts.slice(0, 3).map((concept, idx) => ( + + + {concept} + + + ))} + {item.related_concepts.length > 3 && ( + + +{item.related_concepts.length - 3} + + )} + + )} + + {/* 详细描述 */} + {item.description && ( + + {item.description} + + )} + + + {/* 箭头 */} + + + + )} + + ); +}); + +/** + * 涨幅分析列表 + * @param {Array} data - 分析数据 [{ trade_date, change_pct, main_reason, ... }] + * @param {boolean} loading - 加载状态 + * @param {function} onItemPress - 点击条目回调 + */ +const RiseAnalysisPanel = memo(({ data = [], loading = false, onItemPress }) => { + // 按日期从近到远排序 + const sortedData = [...data].sort((a, b) => { + const dateA = String(a.trade_date || '').replace(/-/g, ''); + const dateB = String(b.trade_date || '').replace(/-/g, ''); + return dateB.localeCompare(dateA); + }); + + // 渲染空状态 + const renderEmptyState = () => ( + + + + 暂无异动分析数据 + + + 当股票发生明显涨跌时会显示分析 + + + ); + + // 渲染列表头部 + const renderHeader = () => ( + + + + + 异动分析记录 + + + ({sortedData.length} 条) + + + + ); + + return ( + + {sortedData.length > 0 ? ( + `${item.trade_date}-${index}`} + renderItem={({ item }) => ( + + )} + ListHeaderComponent={renderHeader} + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.listContainer} + /> + ) : ( + renderEmptyState() + )} + + ); +}); + +const styles = StyleSheet.create({ + listContainer: { + paddingBottom: 20, + }, +}); + +RiseAnalysisPanel.displayName = 'RiseAnalysisPanel'; + +export default RiseAnalysisPanel; diff --git a/MeAgent/src/screens/StockDetail/components/index.js b/MeAgent/src/screens/StockDetail/components/index.js index cd8f3cd2..375d5206 100644 --- a/MeAgent/src/screens/StockDetail/components/index.js +++ b/MeAgent/src/screens/StockDetail/components/index.js @@ -6,9 +6,11 @@ export { default as PriceHeader } from './PriceHeader'; export { default as ChartTypeTabs } from './ChartTypeTabs'; export { default as MinuteChart } from './MinuteChart'; export { default as KlineChart } from './KlineChart'; +export { default as ProfessionalKlineChart } from './ProfessionalKlineChart'; export { default as OrderBook } from './OrderBook'; export { default as RelatedInfoTabs } from './RelatedInfoTabs'; export { default as EventsPanel } from './EventsPanel'; export { default as ConceptsPanel } from './ConceptsPanel'; export { default as AnnouncementsPanel } from './AnnouncementsPanel'; export { default as RiseAnalysisModal } from './RiseAnalysisModal'; +export { default as RiseAnalysisPanel } from './RiseAnalysisPanel'; diff --git a/MeAgent/src/screens/Watchlist/WatchlistScreen.js b/MeAgent/src/screens/Watchlist/WatchlistScreen.js index c8491b87..bec7d52d 100644 --- a/MeAgent/src/screens/Watchlist/WatchlistScreen.js +++ b/MeAgent/src/screens/Watchlist/WatchlistScreen.js @@ -58,6 +58,7 @@ const WatchlistScreen = () => { loadWatchlist, loadRealtimeQuotes, loadFollowingEvents, + loadMiniCharts, } = useWatchlist(); // WebSocket 实时行情 @@ -73,7 +74,8 @@ const WatchlistScreen = () => { loadWatchlist(); loadRealtimeQuotes(); loadFollowingEvents(); - }, [loadWatchlist, loadRealtimeQuotes, loadFollowingEvents]) + loadMiniCharts(); + }, [loadWatchlist, loadRealtimeQuotes, loadFollowingEvents, loadMiniCharts]) ); // 当股票列表变化时更新 WebSocket 订阅 @@ -338,6 +340,7 @@ const WatchlistScreen = () => { { @@ -60,6 +62,7 @@ const formatVolume = (volume) => { * @param {object} props * @param {object} props.stock - 股票信息 { stock_code, stock_name } * @param {object} props.quote - 实时行情 { current_price, change_percent, volume, ... } + * @param {object} props.charts - mini 图表数据 { timeline: [...], kline: [...] } * @param {function} props.onPress - 点击回调 * @param {function} props.onRemove - 删除回调 * @param {boolean} props.isEditing - 是否处于编辑模式 @@ -67,6 +70,7 @@ const formatVolume = (volume) => { const WatchlistStockItem = memo(({ stock, quote, + charts, onPress, onRemove, isEditing = false, @@ -80,9 +84,21 @@ const WatchlistStockItem = memo(({ low, } = quote || {}; + // 图表类型切换:'timeline' | 'kline' + const [chartType, setChartType] = useState('timeline'); + + // 获取图表数据 + const timelineData = charts?.timeline || []; + const klineData = charts?.kline || []; + const changeColor = getChangeColor(change_percent); const gradientColors = getGradientColors(change_percent); + // 切换图表类型 + const toggleChartType = () => { + setChartType(prev => prev === 'timeline' ? 'kline' : 'timeline'); + }; + return ( onPress?.(stock)} disabled={isEditing}> {({ pressed }) => ( @@ -107,86 +123,140 @@ const WatchlistStockItem = memo(({ borderRadius={16} > - {/* 左侧:删除按钮(编辑模式)或涨跌指示条 */} - {isEditing ? ( - onRemove?.(stock_code)} - hitSlop={10} - mr={3} - > + {/* 左侧:删除按钮(编辑模式)或股票信息 */} + + {isEditing ? ( + onRemove?.(stock_code)} + hitSlop={10} + mr={3} + > + + + + + ) : ( - - - - ) : ( - - )} + w={1} + h={12} + bg={changeColor} + borderRadius={2} + mr={3} + opacity={0.8} + /> + )} - {/* 中间:股票信息 */} - - - {stock_name || stock_code} - - - - {stock_code} - - {volume > 0 && ( - - 成交 {formatVolume(volume)} - - )} - - - - {/* 右侧:价格和涨跌幅 */} - - - {formatPrice(current_price)} - - - {change_percent !== undefined && change_percent !== 0 && ( - 0 ? 'caret-up' : 'caret-down'} - size="xs" - color={changeColor} - /> - )} + {/* 股票信息 */} + - {formatChange(change_percent)} + {stock_name || stock_code} + + + {stock_code} + + + {/* 价格和涨跌幅 */} + + + {formatChange(change_percent)} + + + + + + {/* 中间:Mini 图表区域 */} + {!isEditing && ( + + {/* 分时图按钮 */} + setChartType('timeline')}> + + + + + + 分时 + + + + + + + + {/* 日K图按钮 */} + setChartType('kline')}> + + + + + + 日线 + + + + + + - + )} {/* 最右侧:箭头(非编辑模式) */} {!isEditing && ( @@ -199,20 +269,6 @@ const WatchlistStockItem = memo(({ /> )} - - {/* 额外信息行:最高/最低价 */} - {(high > 0 || low > 0) && !isEditing && ( - - - 最高 - {formatPrice(high)} - - - 最低 - {formatPrice(low)} - - - )} )} diff --git a/MeAgent/src/services/stockService.js b/MeAgent/src/services/stockService.js index 22527824..0e69213b 100644 --- a/MeAgent/src/services/stockService.js +++ b/MeAgent/src/services/stockService.js @@ -480,6 +480,97 @@ export const stockDetailService = { } }, + /** + * 获取股票最新五档盘口数据(高频快照) + * @param {string} code - 股票代码 + * @returns {Promise} { success: true, data: { bid_prices, bid_volumes, ask_prices, ask_volumes, ... } } + */ + getOrderBook: async (code) => { + try { + const formattedCode = stockDetailService.formatStockCode(code); + console.log('[StockDetailService] 获取五档盘口:', formattedCode); + + const response = await apiRequest(`/api/stock/${formattedCode}/orderbook`); + + if (response.success && response.data) { + console.log('[StockDetailService] 五档盘口数据获取成功:', response.data.trade_time); + return { + success: true, + data: { + security_id: response.data.security_id, + trade_time: response.data.trade_time, + last_price: response.data.last_price || 0, + prev_close: response.data.prev_close || 0, + open_price: response.data.open_price || 0, + high_price: response.data.high_price || 0, + low_price: response.data.low_price || 0, + volume: response.data.volume || 0, + amount: response.data.amount || 0, + upper_limit: response.data.upper_limit, + lower_limit: response.data.lower_limit, + bid_prices: response.data.bid_prices || [], + bid_volumes: response.data.bid_volumes || [], + ask_prices: response.data.ask_prices || [], + ask_volumes: response.data.ask_volumes || [], + change: response.data.change || 0, + change_pct: response.data.change_pct || 0, + }, + }; + } + + return { success: false, data: null, error: response.error || '获取五档盘口失败' }; + } catch (error) { + console.error('[StockDetailService] getOrderBook 错误:', error); + return { success: false, data: null, error: error.message }; + } + }, + + /** + * 获取股票高频五档盘口历史数据(用于分时图播放) + * @param {string} code - 股票代码 + * @param {object} options - 可选参数 { date, startTime, endTime, limit } + * @returns {Promise} { success: true, data: [...], total: number } + */ + getOrderBookHistory: async (code, options = {}) => { + try { + const formattedCode = stockDetailService.formatStockCode(code); + const { date, startTime = '09:30:00', endTime = '15:00:00', limit = 500 } = options; + + let url = `/api/stock/${formattedCode}/orderbook/history?`; + const params = []; + if (date) params.push(`date=${date}`); + if (startTime) params.push(`start_time=${startTime}`); + if (endTime) params.push(`end_time=${endTime}`); + if (limit) params.push(`limit=${limit}`); + url += params.join('&'); + + console.log('[StockDetailService] 获取五档历史数据:', url); + + const response = await apiRequest(url); + + if (response.success && response.data) { + console.log('[StockDetailService] 五档历史数据:', response.total, '条'); + return { + success: true, + data: response.data.map(item => ({ + trade_time: item.trade_time, + last_price: item.last_price || 0, + bid_prices: item.bid_prices || [], + bid_volumes: item.bid_volumes || [], + ask_prices: item.ask_prices || [], + ask_volumes: item.ask_volumes || [], + })), + total: response.total || response.data.length, + }; + } + + return { success: false, data: [], total: 0, error: response.error || '获取五档历史失败' }; + } catch (error) { + console.error('[StockDetailService] getOrderBookHistory 错误:', error); + return { success: false, data: [], total: 0, error: error.message }; + } + }, + /** * 标准化K线数据字段名称 * 将API返回的不同字段名统一为组件期望的格式 diff --git a/MeAgent/src/services/watchlistService.js b/MeAgent/src/services/watchlistService.js index 28fcfe03..959f7a7f 100644 --- a/MeAgent/src/services/watchlistService.js +++ b/MeAgent/src/services/watchlistService.js @@ -106,6 +106,29 @@ export const watchlistService = { } }, + /** + * 获取自选股 mini 图表数据(分时线 + 日K线) + * @returns {Promise} + * { success: true, data: { [stockCode]: { timeline: [...], kline: [...] } } } + */ + getMiniCharts: async () => { + try { + console.log('[WatchlistService] 获取 mini 图表数据'); + + const response = await apiRequest('/api/account/watchlist/mini-charts'); + + if (response.success) { + const stockCount = Object.keys(response.data || {}).length; + console.log('[WatchlistService] mini 图表数据股票数:', stockCount); + } + + return response; + } catch (error) { + console.error('[WatchlistService] getMiniCharts 错误:', error); + return { success: false, error: error.message }; + } + }, + /** * 检查股票是否在自选中 * @param {string} stockCode - 股票代码 diff --git a/MeAgent/src/store/slices/watchlistSlice.js b/MeAgent/src/store/slices/watchlistSlice.js index ab8d53f2..dbcaa8ce 100644 --- a/MeAgent/src/store/slices/watchlistSlice.js +++ b/MeAgent/src/store/slices/watchlistSlice.js @@ -14,6 +14,8 @@ const initialState = { events: [], // 实时行情 { code: QuoteData } realtimeQuotes: {}, + // mini 图表数据 { code: { timeline: [...], kline: [...] } } + miniCharts: {}, // 加载状态 loading: { stocks: false, @@ -21,6 +23,7 @@ const initialState = { realtime: false, adding: false, removing: false, + miniCharts: false, }, // 错误信息 error: null, @@ -130,6 +133,26 @@ export const fetchFollowingEvents = createAsyncThunk( } ); +/** + * 获取自选股 mini 图表数据 + */ +export const fetchMiniCharts = createAsyncThunk( + 'watchlist/fetchMiniCharts', + async (_, { rejectWithValue }) => { + try { + const response = await watchlistService.getMiniCharts(); + + if (response.success) { + return response.data || {}; + } else { + return rejectWithValue(response.error || '获取图表数据失败'); + } + } catch (error) { + return rejectWithValue(error.message); + } + } +); + /** * 切换事件关注状态 */ @@ -288,6 +311,18 @@ const watchlistSlice = createSlice({ // 取消关注时从列表移除 state.events = state.events.filter(e => e.id !== eventId); } + }) + // 获取 mini 图表数据 + .addCase(fetchMiniCharts.pending, (state) => { + state.loading.miniCharts = true; + }) + .addCase(fetchMiniCharts.fulfilled, (state, action) => { + state.loading.miniCharts = false; + state.miniCharts = action.payload; + }) + .addCase(fetchMiniCharts.rejected, (state, action) => { + state.loading.miniCharts = false; + state.error = action.payload; }); }, }); @@ -305,6 +340,7 @@ export const { export const selectWatchlistStocks = (state) => state.watchlist.stocks; export const selectWatchlistEvents = (state) => state.watchlist.events; export const selectRealtimeQuotes = (state) => state.watchlist.realtimeQuotes; +export const selectMiniCharts = (state) => state.watchlist.miniCharts; export const selectWatchlistLoading = (state) => state.watchlist.loading; export const selectWatchlistError = (state) => state.watchlist.error; diff --git a/app.py b/app.py index 724e9a0c..85709c1c 100755 --- a/app.py +++ b/app.py @@ -6188,6 +6188,144 @@ def get_watchlist_realtime(): return jsonify({'success': False, 'error': '获取实时行情失败'}), 500 +@app.route('/api/account/watchlist/mini-charts', methods=['GET']) +def get_watchlist_mini_charts(): + """获取自选股 mini 图表数据(分时线 + 日K线)""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + # 获取用户自选股列表 + watchlist = Watchlist.query.filter_by(user_id=session['user_id']).all() + if not watchlist: + return jsonify({'success': True, 'data': {}}) + + # 获取股票代码列表并标准化 + code_mapping = {} # code6 -> full_code 映射 + full_codes = [] + for item in watchlist: + code6, _ = _normalize_stock_input(item.stock_code) + normalized = code6 or str(item.stock_code).strip().upper() + + if '.' in normalized: + full_code = normalized + elif normalized.startswith('6'): + full_code = f"{normalized}.SH" + elif normalized.startswith(('8', '9', '4')): + full_code = f"{normalized}.BJ" + else: + full_code = f"{normalized}.SZ" + + code_mapping[normalized] = full_code + full_codes.append(full_code) + + if not full_codes: + return jsonify({'success': True, 'data': {}}) + + client = get_clickhouse_client() + today = datetime.now().date() + + # 计算当天的交易时间范围 + current_trading_day = None + for td in reversed(trading_days): + if td <= today: + current_trading_day = td + break + + if not current_trading_day: + current_trading_day = today + + # ========== 1. 批量查询当天分时数据(每只股票采样约60个点)========== + # 使用 5 分钟采样,一天约 48 个点 + timeline_start = datetime.combine(current_trading_day, dt_time(9, 30)) + timeline_end = datetime.combine(current_trading_day, dt_time(15, 0)) + + timeline_query = """ + SELECT + code, + toStartOfFiveMinutes(timestamp) as ts_5min, + argMax(close, timestamp) as close, + argMax(change_pct, timestamp) as change_pct + FROM stock.stock_minute + WHERE code IN %(codes)s + AND timestamp >= %(start)s + AND timestamp <= %(end)s + GROUP BY code, ts_5min + ORDER BY code, ts_5min + """ + + timeline_result = client.execute(timeline_query, { + 'codes': full_codes, + 'start': timeline_start, + 'end': timeline_end + }) + + # 按股票代码分组分时数据 + timeline_map = {} + for row in timeline_result: + code, ts, close, change_pct = row + if code not in timeline_map: + timeline_map[code] = [] + timeline_map[code].append({ + 'close': float(close) if close else 0, + 'change_pct': float(change_pct) if change_pct else 0 + }) + + # ========== 2. 批量查询最近20天日K线数据 ========== + kline_start = today - timedelta(days=40) # 多查一些天,确保有20个交易日 + + kline_query = """ + SELECT + code, date, open, high, low, close + FROM stock_daily + WHERE code IN %(codes)s + AND date >= %(start)s + ORDER BY code, date DESC + """ + + kline_result = client.execute(kline_query, { + 'codes': full_codes, + 'start': kline_start + }) + + # 按股票代码分组日K数据(最近20根) + kline_map = {} + for row in kline_result: + code, date, open_p, high, low, close = row + if code not in kline_map: + kline_map[code] = [] + if len(kline_map[code]) < 20: + kline_map[code].append({ + 'open': float(open_p) if open_p else 0, + 'high': float(high) if high else 0, + 'low': float(low) if low else 0, + 'close': float(close) if close else 0 + }) + + # 反转日K数据,使其按时间正序 + for code in kline_map: + kline_map[code] = list(reversed(kline_map[code])) + + # 构建响应数据 + charts_data = {} + for code6, full_code in code_mapping.items(): + charts_data[code6] = { + 'timeline': timeline_map.get(full_code, []), + 'kline': kline_map.get(full_code, []) + } + + return jsonify({ + 'success': True, + 'data': charts_data + }) + + except Exception as e: + print(f"获取 mini 图表数据失败: {str(e)}") + import traceback + traceback.print_exc() + return jsonify({'success': False, 'error': '获取图表数据失败'}), 500 + + @app.route('/api/account/watchlist/', methods=['DELETE']) def remove_from_watchlist(stock_code): """从自选股移除""" @@ -8294,6 +8432,48 @@ def get_clickhouse_client(): return _clickhouse_client +# ==================== 高频五档数据 ClickHouse 连接池(222.128.1.157) ==================== +_realtime_clickhouse_client = None +_realtime_clickhouse_client_lock = threading.Lock() + +def _create_realtime_clickhouse_client(): + """创建高频五档数据 ClickHouse 客户端连接(222.128.1.157)""" + return Cclient( + host='222.128.1.157', + port=9000, # 原生协议端口(非 HTTP 18123) + user='default', + password='Zzl33818!', + database='stock', + settings={ + 'connect_timeout': 10, + 'send_receive_timeout': 60, + } + ) + +def get_realtime_clickhouse_client(): + """获取高频五档数据 ClickHouse 客户端(带健康检查和自动重连)""" + global _realtime_clickhouse_client + + with _realtime_clickhouse_client_lock: + if _realtime_clickhouse_client is None: + _realtime_clickhouse_client = _create_realtime_clickhouse_client() + print("[ClickHouse-Realtime] 创建新连接 (222.128.1.157)") + return _realtime_clickhouse_client + + try: + _realtime_clickhouse_client.execute("SELECT 1") + except Exception as e: + print(f"[ClickHouse-Realtime] 连接失效,正在重连: {e}") + try: + _realtime_clickhouse_client.disconnect() + except Exception: + pass + _realtime_clickhouse_client = _create_realtime_clickhouse_client() + print("[ClickHouse-Realtime] 重连成功") + + return _realtime_clickhouse_client + + @app.route('/api/account/calendar/events', methods=['GET', 'POST']) def account_calendar_events(): """返回当前用户的投资计划与关注的未来事件(合并)。 @@ -8832,6 +9012,287 @@ def get_flex_screen_quotes(): return jsonify({'success': False, 'error': str(e)}), 500 +# ==================== 高频五档盘口数据 API ==================== +@app.route('/api/stock//orderbook') +def get_stock_orderbook(stock_code): + """ + 获取股票最新五档盘口数据(高频快照) + + 参数: + - stock_code: 股票代码(如 000001.SZ、600519.SH) + + 返回: + { + "success": true, + "data": { + "security_id": "000001", + "trade_time": "2026-01-18 14:30:00.123", + "last_price": 10.50, + "prev_close": 10.20, + "bid_prices": [10.49, 10.48, 10.47, 10.46, 10.45], + "bid_volumes": [1000, 2000, 1500, 3000, 2500], + "ask_prices": [10.50, 10.51, 10.52, 10.53, 10.54], + "ask_volumes": [800, 1200, 1500, 1000, 2000], + "change": 0.30, + "change_pct": 2.94 + } + } + """ + try: + # 解析股票代码 + if '.' in stock_code: + base_code = stock_code.split('.')[0] + exchange = stock_code.split('.')[1].upper() + else: + base_code = stock_code + if stock_code.startswith('6'): + exchange = 'SH' + else: + exchange = 'SZ' + + client = get_realtime_clickhouse_client() + + # 根据交易所选择表 + if exchange == 'SH': + query = """ + SELECT + security_id, + trade_time, + last_price, + prev_close, + open_price, + high_price, + low_price, + volume, + amount, + bid_price1, bid_volume1, bid_price2, bid_volume2, bid_price3, bid_volume3, + bid_price4, bid_volume4, bid_price5, bid_volume5, + ask_price1, ask_volume1, ask_price2, ask_volume2, ask_price3, ask_volume3, + ask_price4, ask_volume4, ask_price5, ask_volume5 + FROM stock.sse_stock_realtime + WHERE trade_date = today() + AND security_id = %(code)s + ORDER BY trade_time DESC + LIMIT 1 + """ + else: + query = """ + SELECT + security_id, + trade_time, + last_price, + prev_close, + open_price, + high_price, + low_price, + volume, + amount, + upper_limit_price, + lower_limit_price, + bid_price1, bid_volume1, bid_price2, bid_volume2, bid_price3, bid_volume3, + bid_price4, bid_volume4, bid_price5, bid_volume5, + ask_price1, ask_volume1, ask_price2, ask_volume2, ask_price3, ask_volume3, + ask_price4, ask_volume4, ask_price5, ask_volume5 + FROM stock.szse_stock_realtime + WHERE trade_date = today() + AND security_id = %(code)s + ORDER BY trade_time DESC + LIMIT 1 + """ + + rows = client.execute(query, {'code': base_code}) + + if not rows: + return jsonify({'success': False, 'error': '暂无盘口数据'}), 404 + + row = rows[0] + + if exchange == 'SH': + # 上交所字段顺序 + result = { + 'security_id': row[0], + 'trade_time': str(row[1]) if row[1] else None, + 'last_price': float(row[2]) if row[2] else 0, + 'prev_close': float(row[3]) if row[3] else 0, + 'open_price': float(row[4]) if row[4] else 0, + 'high_price': float(row[5]) if row[5] else 0, + 'low_price': float(row[6]) if row[6] else 0, + 'volume': int(row[7]) if row[7] else 0, + 'amount': float(row[8]) if row[8] else 0, + 'bid_prices': [float(row[i]) if row[i] else 0 for i in range(9, 19, 2)], + 'bid_volumes': [int(row[i]) if row[i] else 0 for i in range(10, 20, 2)], + 'ask_prices': [float(row[i]) if row[i] else 0 for i in range(19, 29, 2)], + 'ask_volumes': [int(row[i]) if row[i] else 0 for i in range(20, 30, 2)], + } + else: + # 深交所字段顺序(多了涨跌停价) + result = { + 'security_id': row[0], + 'trade_time': str(row[1]) if row[1] else None, + 'last_price': float(row[2]) if row[2] else 0, + 'prev_close': float(row[3]) if row[3] else 0, + 'open_price': float(row[4]) if row[4] else 0, + 'high_price': float(row[5]) if row[5] else 0, + 'low_price': float(row[6]) if row[6] else 0, + 'volume': int(row[7]) if row[7] else 0, + 'amount': float(row[8]) if row[8] else 0, + 'upper_limit': float(row[9]) if row[9] else None, + 'lower_limit': float(row[10]) if row[10] else None, + 'bid_prices': [float(row[i]) if row[i] else 0 for i in range(11, 21, 2)], + 'bid_volumes': [int(row[i]) if row[i] else 0 for i in range(12, 22, 2)], + 'ask_prices': [float(row[i]) if row[i] else 0 for i in range(21, 31, 2)], + 'ask_volumes': [int(row[i]) if row[i] else 0 for i in range(22, 32, 2)], + } + + # 计算涨跌幅 + if result['last_price'] and result['prev_close']: + result['change'] = result['last_price'] - result['prev_close'] + result['change_pct'] = (result['change'] / result['prev_close']) * 100 + else: + result['change'] = 0 + result['change_pct'] = 0 + + return jsonify({'success': True, 'data': result}) + + except Exception as e: + print(f"获取五档盘口失败: {e}") + import traceback + traceback.print_exc() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stock//orderbook/history') +def get_stock_orderbook_history(stock_code): + """ + 获取股票高频五档盘口历史数据(用于分时图播放) + + 参数: + - stock_code: 股票代码 + - date: 日期(可选,默认今天) + - start_time: 开始时间(可选,如 09:30:00) + - end_time: 结束时间(可选,如 15:00:00) + - limit: 返回条数限制(默认 500,最大 2000) + + 返回: + { + "success": true, + "data": [ + { + "trade_time": "2026-01-18 09:30:00.123", + "last_price": 10.50, + "bid_prices": [...], + "bid_volumes": [...], + "ask_prices": [...], + "ask_volumes": [...] + }, + ... + ], + "total": 500 + } + """ + try: + # 解析股票代码 + if '.' in stock_code: + base_code = stock_code.split('.')[0] + exchange = stock_code.split('.')[1].upper() + else: + base_code = stock_code + if stock_code.startswith('6'): + exchange = 'SH' + else: + exchange = 'SZ' + + # 获取参数 + date_str = request.args.get('date') + start_time = request.args.get('start_time', '09:30:00') + end_time = request.args.get('end_time', '15:00:00') + limit = min(int(request.args.get('limit', 500)), 2000) + + # 解析日期 + if date_str: + trade_date = datetime.strptime(date_str, '%Y-%m-%d').date() + else: + trade_date = datetime.now().date() + + client = get_realtime_clickhouse_client() + + # 根据交易所选择表和查询 + if exchange == 'SH': + query = """ + SELECT + trade_time, + last_price, + bid_price1, bid_volume1, bid_price2, bid_volume2, bid_price3, bid_volume3, + bid_price4, bid_volume4, bid_price5, bid_volume5, + ask_price1, ask_volume1, ask_price2, ask_volume2, ask_price3, ask_volume3, + ask_price4, ask_volume4, ask_price5, ask_volume5 + FROM stock.sse_stock_realtime + WHERE trade_date = %(date)s + AND security_id = %(code)s + AND toTime(trade_time) >= toTime(%(start_time)s) + AND toTime(trade_time) <= toTime(%(end_time)s) + ORDER BY trade_time ASC + LIMIT %(limit)s + """ + params = { + 'date': trade_date, + 'code': base_code, + 'start_time': f'{trade_date} {start_time}', + 'end_time': f'{trade_date} {end_time}', + 'limit': limit + } + else: + query = """ + SELECT + trade_time, + last_price, + bid_price1, bid_volume1, bid_price2, bid_volume2, bid_price3, bid_volume3, + bid_price4, bid_volume4, bid_price5, bid_volume5, + ask_price1, ask_volume1, ask_price2, ask_volume2, ask_price3, ask_volume3, + ask_price4, ask_volume4, ask_price5, ask_volume5 + FROM stock.szse_stock_realtime + WHERE trade_date = %(date)s + AND security_id = %(code)s + AND toTime(trade_time) >= toTime(%(start_time)s) + AND toTime(trade_time) <= toTime(%(end_time)s) + ORDER BY trade_time ASC + LIMIT %(limit)s + """ + params = { + 'date': trade_date, + 'code': base_code, + 'start_time': f'{trade_date} {start_time}', + 'end_time': f'{trade_date} {end_time}', + 'limit': limit + } + + rows = client.execute(query, params) + + results = [] + for row in rows: + item = { + 'trade_time': str(row[0]) if row[0] else None, + 'last_price': float(row[1]) if row[1] else 0, + 'bid_prices': [float(row[i]) if row[i] else 0 for i in range(2, 12, 2)], + 'bid_volumes': [int(row[i]) if row[i] else 0 for i in range(3, 13, 2)], + 'ask_prices': [float(row[i]) if row[i] else 0 for i in range(12, 22, 2)], + 'ask_volumes': [int(row[i]) if row[i] else 0 for i in range(13, 23, 2)], + } + results.append(item) + + return jsonify({ + 'success': True, + 'data': results, + 'total': len(results) + }) + + except Exception as e: + print(f"获取五档盘口历史失败: {e}") + import traceback + traceback.print_exc() + return jsonify({'success': False, 'error': str(e)}), 500 + + @app.route('/api/stock//kline') def get_stock_kline(stock_code): chart_type = request.args.get('type', 'minute')