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 (
+
+
+
+ );
+});
+
+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 (
+
+
+
+ );
+});
+
+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