更新ios
This commit is contained in:
@@ -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",
|
||||
|
||||
152
MeAgent/src/components/MiniKlineChart.js
Normal file
152
MeAgent/src/components/MiniKlineChart.js
Normal file
@@ -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 (
|
||||
<View style={[styles.container, { width, height }]}>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { width, height }]}>
|
||||
<Svg width={width} height={height}>
|
||||
{candles.map((candle, index) => (
|
||||
<G key={index}>
|
||||
{/* 上下影线 */}
|
||||
<Line
|
||||
x1={candle.x + candle.candleWidth / 2}
|
||||
y1={candle.wickTop}
|
||||
x2={candle.x + candle.candleWidth / 2}
|
||||
y2={candle.wickBottom}
|
||||
stroke={candle.color}
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
{/* K线实体 */}
|
||||
<Rect
|
||||
x={candle.x}
|
||||
y={candle.bodyTop}
|
||||
width={candle.candleWidth}
|
||||
height={candle.bodyHeight}
|
||||
fill={candle.isUp ? candle.color : candle.color}
|
||||
stroke={candle.color}
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
</G>
|
||||
))}
|
||||
</Svg>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
152
MeAgent/src/components/MiniTimelineChart.js
Normal file
152
MeAgent/src/components/MiniTimelineChart.js
Normal file
@@ -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 (
|
||||
<View style={[styles.container, { width, height }]}>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const gradientId = `gradient-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { width, height }]}>
|
||||
<Svg width={width} height={height}>
|
||||
<Defs>
|
||||
<LinearGradient id={gradientId} x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<Stop offset="0%" stopColor={lineColor} stopOpacity={0.3} />
|
||||
<Stop offset="100%" stopColor={lineColor} stopOpacity={0.05} />
|
||||
</LinearGradient>
|
||||
</Defs>
|
||||
|
||||
{/* 填充区域 */}
|
||||
<Path
|
||||
d={areaPath}
|
||||
fill={`url(#${gradientId})`}
|
||||
/>
|
||||
|
||||
{/* 基准线(虚线) */}
|
||||
{showBaseline && (
|
||||
<Line
|
||||
x1={0}
|
||||
y1={baselineY}
|
||||
x2={width}
|
||||
y2={baselineY}
|
||||
stroke="rgba(255,255,255,0.2)"
|
||||
strokeWidth={0.5}
|
||||
strokeDasharray="2,2"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 折线 */}
|
||||
<Path
|
||||
d={linePath}
|
||||
stroke={lineColor}
|
||||
strokeWidth={1.5}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
70
MeAgent/src/hooks/useOrderBook.js
Normal file
70
MeAgent/src/hooks/useOrderBook.js
Normal file
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <ConceptsPanel stockCode={stockCode} />;
|
||||
case 'announcements':
|
||||
return <AnnouncementsPanel stockCode={stockCode} />;
|
||||
case 'analysis':
|
||||
return (
|
||||
<RiseAnalysisPanel
|
||||
data={riseAnalysisData}
|
||||
loading={loading.detail}
|
||||
onItemPress={handleAnalysisPress}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -246,24 +255,24 @@ const StockDetailScreen = () => {
|
||||
loading={isChartLoading}
|
||||
/>
|
||||
) : (
|
||||
<KlineChart
|
||||
<ProfessionalKlineChart
|
||||
data={currentChartData}
|
||||
type={chartType}
|
||||
loading={isChartLoading}
|
||||
riseAnalysisData={riseAnalysisData}
|
||||
onAnalysisPress={handleAnalysisPress}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 5档盘口(可选,放在分时图右侧效果更好,这里简化) */}
|
||||
{chartType === 'minute' && orderBook.bidPrices?.length > 0 && (
|
||||
{/* 5档盘口 - 使用 WebSocket 实时推送的五档数据 */}
|
||||
{chartType === 'minute' && (
|
||||
<OrderBook
|
||||
askPrices={orderBook.askPrices}
|
||||
askVolumes={orderBook.askVolumes}
|
||||
bidPrices={orderBook.bidPrices}
|
||||
bidVolumes={orderBook.bidVolumes}
|
||||
preClose={quote.pre_close}
|
||||
askPrices={realtimeQuote?.ask_prices || orderBook.askPrices || []}
|
||||
askVolumes={realtimeQuote?.ask_volumes || orderBook.askVolumes || []}
|
||||
bidPrices={realtimeQuote?.bid_prices || orderBook.bidPrices || []}
|
||||
bidVolumes={realtimeQuote?.bid_volumes || orderBook.bidVolumes || []}
|
||||
preClose={realtimeQuote?.pre_close || quote.pre_close}
|
||||
updateTime={realtimeQuote?.update_time}
|
||||
isConnected={wsConnected}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
<Text color="gray.500" fontSize={14} textAlign="center" py={6}>
|
||||
暂无盘口数据
|
||||
<HStack alignItems="center" justifyContent="center" space={2} py={6}>
|
||||
<Icon as={Ionicons} name={isConnected ? "pulse-outline" : "cloud-offline-outline"} size="sm" color="gray.500" />
|
||||
<Text color="gray.500" fontSize={14}>
|
||||
{isConnected ? '等待盘口数据...' : '未连接行情服务'}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -177,11 +184,26 @@ const OrderBook = memo(({
|
||||
<Text color="white" fontSize={14} fontWeight="bold">
|
||||
五档盘口
|
||||
</Text>
|
||||
{isConnected && (
|
||||
<HStack alignItems="center" space={1}>
|
||||
<Box w={1.5} h={1.5} bg="#22C55E" borderRadius="full" />
|
||||
<Text color="#22C55E" fontSize={9}>实时</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
<HStack space={2} alignItems="center">
|
||||
{updateTime && (
|
||||
<Text color="gray.600" fontSize={9}>
|
||||
{typeof updateTime === 'string' && updateTime.includes(' ')
|
||||
? updateTime.split(' ')[1]?.substring(0, 8)
|
||||
: updateTime?.substring(11, 19) || ''}
|
||||
</Text>
|
||||
)}
|
||||
<Text color="gray.500" fontSize={10}>
|
||||
单位: 手
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<Box px={4} pb={4}>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Box
|
||||
mx={4}
|
||||
bg="rgba(30, 41, 59, 0.6)"
|
||||
borderRadius={16}
|
||||
borderWidth={1}
|
||||
borderColor="rgba(255,255,255,0.08)"
|
||||
height={CHART_HEIGHT}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Spinner size="lg" color="#3B82F6" />
|
||||
<Text color="gray.500" fontSize={12} mt={2}>加载K线数据...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 无数据状态
|
||||
if (!optionList || processedData.length === 0) {
|
||||
return (
|
||||
<Box
|
||||
mx={4}
|
||||
bg="rgba(30, 41, 59, 0.6)"
|
||||
borderRadius={16}
|
||||
borderWidth={1}
|
||||
borderColor="rgba(255,255,255,0.08)"
|
||||
height={CHART_HEIGHT}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text color="gray.500" fontSize={14}>暂无K线数据</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box mx={4}>
|
||||
{/* 指标切换栏 */}
|
||||
<HStack
|
||||
mb={2}
|
||||
space={2}
|
||||
alignItems="center"
|
||||
bg="rgba(30, 41, 59, 0.4)"
|
||||
px={3}
|
||||
py={2}
|
||||
borderRadius={10}
|
||||
>
|
||||
<Pressable onPress={toggleMainIndicator}>
|
||||
<Box
|
||||
bg={mainIndicator === 'ma' ? 'rgba(59, 130, 246, 0.2)' : 'rgba(255,255,255,0.05)'}
|
||||
px={3}
|
||||
py={1.5}
|
||||
borderRadius={6}
|
||||
borderWidth={1}
|
||||
borderColor={mainIndicator === 'ma' ? 'rgba(59, 130, 246, 0.3)' : 'transparent'}
|
||||
>
|
||||
<Text color={mainIndicator === 'ma' ? '#3B82F6' : 'gray.400'} fontSize={11} fontWeight="medium">
|
||||
{mainIndicator === 'ma' ? 'MA' : 'BOLL'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Pressable>
|
||||
|
||||
<Pressable onPress={toggleSubIndicator}>
|
||||
<Box
|
||||
bg={subIndicator !== 'none' ? 'rgba(59, 130, 246, 0.2)' : 'rgba(255,255,255,0.05)'}
|
||||
px={3}
|
||||
py={1.5}
|
||||
borderRadius={6}
|
||||
borderWidth={1}
|
||||
borderColor={subIndicator !== 'none' ? 'rgba(59, 130, 246, 0.3)' : 'transparent'}
|
||||
>
|
||||
<Text color={subIndicator !== 'none' ? '#3B82F6' : 'gray.400'} fontSize={11} fontWeight="medium">
|
||||
{subIndicator === 'none' ? '副图' : subIndicator.toUpperCase()}
|
||||
</Text>
|
||||
</Box>
|
||||
</Pressable>
|
||||
|
||||
<Box flex={1} />
|
||||
|
||||
<Text color="gray.500" fontSize={10}>触摸查看详情</Text>
|
||||
</HStack>
|
||||
|
||||
{/* K 线图 */}
|
||||
<Box
|
||||
bg="rgba(30, 41, 59, 0.6)"
|
||||
borderRadius={16}
|
||||
borderWidth={1}
|
||||
borderColor="rgba(255,255,255,0.08)"
|
||||
overflow="hidden"
|
||||
height={CHART_HEIGHT}
|
||||
>
|
||||
<RNKLineView
|
||||
ref={kLineViewRef}
|
||||
style={{ flex: 1, backgroundColor: 'transparent' }}
|
||||
optionList={optionList}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
ProfessionalKlineChart.displayName = 'ProfessionalKlineChart';
|
||||
|
||||
export default ProfessionalKlineChart;
|
||||
@@ -11,6 +11,7 @@ const INFO_TABS = [
|
||||
{ key: 'events', label: '相关事件' },
|
||||
{ key: 'concepts', label: '相关概念' },
|
||||
{ key: 'announcements', label: '公司公告' },
|
||||
{ key: 'analysis', label: '异动分析' },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
218
MeAgent/src/screens/StockDetail/components/RiseAnalysisPanel.js
Normal file
218
MeAgent/src/screens/StockDetail/components/RiseAnalysisPanel.js
Normal file
@@ -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 (
|
||||
<Pressable onPress={() => onPress?.(item)}>
|
||||
{({ pressed }) => (
|
||||
<Box
|
||||
mx={4}
|
||||
mb={2}
|
||||
p={3}
|
||||
bg={pressed ? 'rgba(59, 130, 246, 0.1)' : 'rgba(30, 41, 59, 0.4)'}
|
||||
borderRadius={12}
|
||||
borderWidth={1}
|
||||
borderColor="rgba(255,255,255,0.06)"
|
||||
>
|
||||
<HStack alignItems="flex-start" justifyContent="space-between">
|
||||
{/* 左侧:日期和涨幅 */}
|
||||
<VStack space={1} minW={70}>
|
||||
<Text color="white" fontSize={13} fontWeight="bold">
|
||||
{formatDate(item.trade_date)}
|
||||
</Text>
|
||||
<Text color={changeColor} fontSize={15} fontWeight="bold">
|
||||
{formatChange(item.change_pct)}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* 右侧:异动原因 */}
|
||||
<VStack flex={1} ml={3} space={1}>
|
||||
{/* 主要原因 */}
|
||||
{item.main_reason && (
|
||||
<HStack alignItems="center" space={1} flexWrap="wrap">
|
||||
<Icon
|
||||
as={Ionicons}
|
||||
name="flash"
|
||||
size="xs"
|
||||
color="#D4AF37"
|
||||
/>
|
||||
<Text color="white" fontSize={13} flex={1}>
|
||||
{item.main_reason}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* 相关概念 */}
|
||||
{item.related_concepts && item.related_concepts.length > 0 && (
|
||||
<HStack flexWrap="wrap" space={1} mt={1}>
|
||||
{item.related_concepts.slice(0, 3).map((concept, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
bg="rgba(59, 130, 246, 0.15)"
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius={4}
|
||||
mb={1}
|
||||
>
|
||||
<Text color="#3B82F6" fontSize={10}>
|
||||
{concept}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
{item.related_concepts.length > 3 && (
|
||||
<Text color="gray.500" fontSize={10}>
|
||||
+{item.related_concepts.length - 3}
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* 详细描述 */}
|
||||
{item.description && (
|
||||
<Text
|
||||
color="gray.400"
|
||||
fontSize={11}
|
||||
numberOfLines={2}
|
||||
mt={1}
|
||||
>
|
||||
{item.description}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* 箭头 */}
|
||||
<Icon
|
||||
as={Ionicons}
|
||||
name="chevron-forward"
|
||||
size="sm"
|
||||
color="rgba(255,255,255,0.3)"
|
||||
ml={2}
|
||||
/>
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 涨幅分析列表
|
||||
* @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 = () => (
|
||||
<Box flex={1} alignItems="center" justifyContent="center" py={10}>
|
||||
<Icon
|
||||
as={Ionicons}
|
||||
name="analytics-outline"
|
||||
size="4xl"
|
||||
color="gray.600"
|
||||
mb={3}
|
||||
/>
|
||||
<Text color="gray.500" fontSize={14}>
|
||||
暂无异动分析数据
|
||||
</Text>
|
||||
<Text color="gray.600" fontSize={12} mt={1}>
|
||||
当股票发生明显涨跌时会显示分析
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
// 渲染列表头部
|
||||
const renderHeader = () => (
|
||||
<Box px={4} py={2}>
|
||||
<HStack alignItems="center" space={2}>
|
||||
<Icon as={Ionicons} name="analytics" size="sm" color="#D4AF37" />
|
||||
<Text color="white" fontSize={14} fontWeight="bold">
|
||||
异动分析记录
|
||||
</Text>
|
||||
<Text color="gray.500" fontSize={11}>
|
||||
({sortedData.length} 条)
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flex={1}>
|
||||
{sortedData.length > 0 ? (
|
||||
<FlatList
|
||||
data={sortedData}
|
||||
keyExtractor={(item, index) => `${item.trade_date}-${index}`}
|
||||
renderItem={({ item }) => (
|
||||
<AnalysisItem item={item} onPress={onItemPress} />
|
||||
)}
|
||||
ListHeaderComponent={renderHeader}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.listContainer}
|
||||
/>
|
||||
) : (
|
||||
renderEmptyState()
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listContainer: {
|
||||
paddingBottom: 20,
|
||||
},
|
||||
});
|
||||
|
||||
RiseAnalysisPanel.displayName = 'RiseAnalysisPanel';
|
||||
|
||||
export default RiseAnalysisPanel;
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = () => {
|
||||
<WatchlistStockItem
|
||||
stock={item}
|
||||
quote={item.quote}
|
||||
charts={item.charts}
|
||||
onPress={handleStockPress}
|
||||
onRemove={handleRemoveStock}
|
||||
isEditing={isEditing}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* 自选股列表项组件
|
||||
* 显示单只股票的行情信息
|
||||
* 显示单只股票的行情信息和 mini 图表
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import React, { memo, useState } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import {
|
||||
Box,
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
} from 'native-base';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import MiniTimelineChart from '../../components/MiniTimelineChart';
|
||||
import MiniKlineChart from '../../components/MiniKlineChart';
|
||||
|
||||
// 涨跌颜色
|
||||
const getChangeColor = (change) => {
|
||||
@@ -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 (
|
||||
<Pressable onPress={() => onPress?.(stock)} disabled={isEditing}>
|
||||
{({ pressed }) => (
|
||||
@@ -107,7 +123,8 @@ const WatchlistStockItem = memo(({
|
||||
borderRadius={16}
|
||||
>
|
||||
<HStack alignItems="center" justifyContent="space-between">
|
||||
{/* 左侧:删除按钮(编辑模式)或涨跌指示条 */}
|
||||
{/* 左侧:删除按钮(编辑模式)或股票信息 */}
|
||||
<HStack alignItems="center" flex={1}>
|
||||
{isEditing ? (
|
||||
<Pressable
|
||||
onPress={() => onRemove?.(stock_code)}
|
||||
@@ -138,7 +155,7 @@ const WatchlistStockItem = memo(({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 中间:股票信息 */}
|
||||
{/* 股票信息 */}
|
||||
<VStack flex={1} space={0.5}>
|
||||
<Text
|
||||
color="white"
|
||||
@@ -152,41 +169,94 @@ const WatchlistStockItem = memo(({
|
||||
<Text color="gray.500" fontSize={11}>
|
||||
{stock_code}
|
||||
</Text>
|
||||
{volume > 0 && (
|
||||
<Text color="gray.600" fontSize={10}>
|
||||
成交 {formatVolume(volume)}
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* 右侧:价格和涨跌幅 */}
|
||||
<VStack alignItems="flex-end" space={0.5}>
|
||||
{/* 价格和涨跌幅 */}
|
||||
<HStack alignItems="center" space={2} mt={1}>
|
||||
<Text
|
||||
color={changeColor}
|
||||
fontSize={18}
|
||||
fontSize={16}
|
||||
fontWeight="bold"
|
||||
>
|
||||
{formatPrice(current_price)}
|
||||
</Text>
|
||||
<HStack alignItems="center" space={1}>
|
||||
{change_percent !== undefined && change_percent !== 0 && (
|
||||
<Icon
|
||||
as={Ionicons}
|
||||
name={change_percent > 0 ? 'caret-up' : 'caret-down'}
|
||||
size="xs"
|
||||
color={changeColor}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
color={changeColor}
|
||||
fontSize={13}
|
||||
fontWeight="medium"
|
||||
>
|
||||
{formatChange(change_percent)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
{/* 中间:Mini 图表区域 */}
|
||||
{!isEditing && (
|
||||
<HStack space={2} alignItems="center">
|
||||
{/* 分时图按钮 */}
|
||||
<Pressable onPress={() => setChartType('timeline')}>
|
||||
<Box
|
||||
bg={chartType === 'timeline' ? 'rgba(59, 130, 246, 0.15)' : 'rgba(255,255,255,0.05)'}
|
||||
borderRadius={8}
|
||||
p={2}
|
||||
borderWidth={1}
|
||||
borderColor={chartType === 'timeline' ? 'rgba(59, 130, 246, 0.3)' : 'transparent'}
|
||||
>
|
||||
<VStack alignItems="center" space={1}>
|
||||
<HStack alignItems="center" space={1}>
|
||||
<Icon
|
||||
as={Ionicons}
|
||||
name="trending-up"
|
||||
size="xs"
|
||||
color={chartType === 'timeline' ? '#3B82F6' : 'gray.500'}
|
||||
/>
|
||||
<Text
|
||||
color={chartType === 'timeline' ? '#3B82F6' : 'gray.500'}
|
||||
fontSize={9}
|
||||
fontWeight="medium"
|
||||
>
|
||||
分时
|
||||
</Text>
|
||||
</HStack>
|
||||
<MiniTimelineChart
|
||||
data={timelineData}
|
||||
width={70}
|
||||
height={32}
|
||||
showBaseline={true}
|
||||
/>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Pressable>
|
||||
|
||||
{/* 日K图按钮 */}
|
||||
<Pressable onPress={() => setChartType('kline')}>
|
||||
<Box
|
||||
bg={chartType === 'kline' ? 'rgba(59, 130, 246, 0.15)' : 'rgba(255,255,255,0.05)'}
|
||||
borderRadius={8}
|
||||
p={2}
|
||||
borderWidth={1}
|
||||
borderColor={chartType === 'kline' ? 'rgba(59, 130, 246, 0.3)' : 'transparent'}
|
||||
>
|
||||
<VStack alignItems="center" space={1}>
|
||||
<HStack alignItems="center" space={1}>
|
||||
<Icon
|
||||
as={Ionicons}
|
||||
name="bar-chart"
|
||||
size="xs"
|
||||
color={chartType === 'kline' ? '#3B82F6' : 'gray.500'}
|
||||
/>
|
||||
<Text
|
||||
color={chartType === 'kline' ? '#3B82F6' : 'gray.500'}
|
||||
fontSize={9}
|
||||
fontWeight="medium"
|
||||
>
|
||||
日线
|
||||
</Text>
|
||||
</HStack>
|
||||
<MiniKlineChart
|
||||
data={klineData}
|
||||
width={70}
|
||||
height={32}
|
||||
candleCount={15}
|
||||
/>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Pressable>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* 最右侧:箭头(非编辑模式) */}
|
||||
{!isEditing && (
|
||||
@@ -199,20 +269,6 @@ const WatchlistStockItem = memo(({
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 额外信息行:最高/最低价 */}
|
||||
{(high > 0 || low > 0) && !isEditing && (
|
||||
<HStack mt={2} pt={2} borderTopWidth={1} borderTopColor="rgba(255,255,255,0.05)">
|
||||
<HStack flex={1} space={1} alignItems="center">
|
||||
<Text color="gray.600" fontSize={10}>最高</Text>
|
||||
<Text color="#EF4444" fontSize={11}>{formatPrice(high)}</Text>
|
||||
</HStack>
|
||||
<HStack flex={1} space={1} alignItems="center" justifyContent="flex-end">
|
||||
<Text color="gray.600" fontSize={10}>最低</Text>
|
||||
<Text color="#22C55E" fontSize={11}>{formatPrice(low)}</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -480,6 +480,97 @@ export const stockDetailService = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取股票最新五档盘口数据(高频快照)
|
||||
* @param {string} code - 股票代码
|
||||
* @returns {Promise<object>} { 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<object>} { 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返回的不同字段名统一为组件期望的格式
|
||||
|
||||
@@ -106,6 +106,29 @@ export const watchlistService = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取自选股 mini 图表数据(分时线 + 日K线)
|
||||
* @returns {Promise<object>}
|
||||
* { 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 - 股票代码
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
461
app.py
461
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/<string:stock_code>', 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/<stock_code>/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/<stock_code>/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/<stock_code>/kline')
|
||||
def get_stock_kline(stock_code):
|
||||
chart_type = request.args.get('type', 'minute')
|
||||
|
||||
Reference in New Issue
Block a user