更新ios

This commit is contained in:
2026-01-18 17:52:48 +08:00
parent 7d9e1174e4
commit d5aebd3b3d
17 changed files with 1943 additions and 113 deletions

View File

@@ -41,6 +41,7 @@
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-native": "0.74.5", "react-native": "0.74.5",
"react-native-gesture-handler": "~2.16.1", "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-modal-dropdown": "1.0.2",
"react-native-reanimated": "~3.10.1", "react-native-reanimated": "~3.10.1",
"react-native-safe-area-context": "4.10.5", "react-native-safe-area-context": "4.10.5",

View 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;

View 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;

View 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;

View File

@@ -9,12 +9,14 @@ import {
fetchWatchlist, fetchWatchlist,
fetchWatchlistRealtime, fetchWatchlistRealtime,
fetchFollowingEvents, fetchFollowingEvents,
fetchMiniCharts,
addToWatchlist, addToWatchlist,
removeFromWatchlist, removeFromWatchlist,
toggleEventFollow, toggleEventFollow,
selectWatchlistStocks, selectWatchlistStocks,
selectWatchlistEvents, selectWatchlistEvents,
selectRealtimeQuotes, selectRealtimeQuotes,
selectMiniCharts,
selectWatchlistLoading, selectWatchlistLoading,
selectWatchlistError, selectWatchlistError,
selectIsInWatchlist, selectIsInWatchlist,
@@ -42,6 +44,7 @@ export const useWatchlist = (options = {}) => {
const stocks = useSelector(selectWatchlistStocks); const stocks = useSelector(selectWatchlistStocks);
const events = useSelector(selectWatchlistEvents); const events = useSelector(selectWatchlistEvents);
const realtimeQuotes = useSelector(selectRealtimeQuotes); const realtimeQuotes = useSelector(selectRealtimeQuotes);
const miniCharts = useSelector(selectMiniCharts);
const loading = useSelector(selectWatchlistLoading); const loading = useSelector(selectWatchlistLoading);
const error = useSelector(selectWatchlistError); const error = useSelector(selectWatchlistError);
@@ -60,6 +63,11 @@ export const useWatchlist = (options = {}) => {
return dispatch(fetchFollowingEvents()); return dispatch(fetchFollowingEvents());
}, [dispatch]); }, [dispatch]);
// 加载 mini 图表数据
const loadMiniCharts = useCallback(() => {
return dispatch(fetchMiniCharts());
}, [dispatch]);
// 添加股票到自选 // 添加股票到自选
const handleAddStock = useCallback(async (stockCode, stockName = '') => { const handleAddStock = useCallback(async (stockCode, stockName = '') => {
// 乐观更新 // 乐观更新
@@ -139,12 +147,26 @@ export const useWatchlist = (options = {}) => {
null; null;
}, [realtimeQuotes]); }, [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 stocksWithQuotes = stocks.map(stock => {
const quote = getStockQuote(stock.stock_code); const quote = getStockQuote(stock.stock_code);
const charts = getStockCharts(stock.stock_code);
return { return {
...stock, ...stock,
quote, quote,
charts,
}; };
}); });
@@ -154,8 +176,9 @@ export const useWatchlist = (options = {}) => {
loadWatchlist(), loadWatchlist(),
loadRealtimeQuotes(), loadRealtimeQuotes(),
loadFollowingEvents(), loadFollowingEvents(),
loadMiniCharts(),
]); ]);
}, [loadWatchlist, loadRealtimeQuotes, loadFollowingEvents]); }, [loadWatchlist, loadRealtimeQuotes, loadFollowingEvents, loadMiniCharts]);
// 启动定时刷新 // 启动定时刷新
const startRefreshTimer = useCallback(() => { const startRefreshTimer = useCallback(() => {
@@ -180,8 +203,9 @@ export const useWatchlist = (options = {}) => {
loadWatchlist(); loadWatchlist();
loadRealtimeQuotes(); loadRealtimeQuotes();
loadFollowingEvents(); loadFollowingEvents();
loadMiniCharts();
} }
}, [autoLoad, loadWatchlist, loadRealtimeQuotes, loadFollowingEvents]); }, [autoLoad, loadWatchlist, loadRealtimeQuotes, loadFollowingEvents, loadMiniCharts]);
// 启动/停止定时刷新 // 启动/停止定时刷新
useEffect(() => { useEffect(() => {
@@ -199,6 +223,7 @@ export const useWatchlist = (options = {}) => {
stocks, stocks,
events, events,
realtimeQuotes, realtimeQuotes,
miniCharts,
stocksWithQuotes, stocksWithQuotes,
// 加载状态 // 加载状态
@@ -207,11 +232,13 @@ export const useWatchlist = (options = {}) => {
isLoadingStocks: loading.stocks, isLoadingStocks: loading.stocks,
isLoadingEvents: loading.events, isLoadingEvents: loading.events,
isLoadingRealtime: loading.realtime, isLoadingRealtime: loading.realtime,
isLoadingMiniCharts: loading.miniCharts,
// 操作方法 // 操作方法
loadWatchlist, loadWatchlist,
loadRealtimeQuotes, loadRealtimeQuotes,
loadFollowingEvents, loadFollowingEvents,
loadMiniCharts,
refreshAll, refreshAll,
// 自选股操作 // 自选股操作
@@ -220,6 +247,7 @@ export const useWatchlist = (options = {}) => {
toggleStock, toggleStock,
isInWatchlist, isInWatchlist,
getStockQuote, getStockQuote,
getStockCharts,
// 事件操作 // 事件操作
toggleEventFollow: handleToggleEventFollow, toggleEventFollow: handleToggleEventFollow,

View File

@@ -14,13 +14,14 @@ import {
PriceHeader, PriceHeader,
ChartTypeTabs, ChartTypeTabs,
MinuteChart, MinuteChart,
KlineChart, ProfessionalKlineChart,
OrderBook, OrderBook,
RelatedInfoTabs, RelatedInfoTabs,
EventsPanel, EventsPanel,
ConceptsPanel, ConceptsPanel,
AnnouncementsPanel, AnnouncementsPanel,
RiseAnalysisModal, RiseAnalysisModal,
RiseAnalysisPanel,
} from './components'; } from './components';
import { stockDetailService } from '../../services/stockService'; import { stockDetailService } from '../../services/stockService';
@@ -71,8 +72,8 @@ const StockDetailScreen = () => {
const orderBook = useSelector(selectOrderBook); const orderBook = useSelector(selectOrderBook);
const loading = useSelector(selectStockLoading); const loading = useSelector(selectStockLoading);
// WebSocket 实时行情 // WebSocket 实时行情(包含五档盘口数据)
const { quote: realtimeQuote } = useSingleQuote(stockCode); const { quote: realtimeQuote, isConnected: wsConnected } = useSingleQuote(stockCode);
// 自选股操作 // 自选股操作
const { isInWatchlist, toggleStock } = useWatchlist({ autoLoad: false }); const { isInWatchlist, toggleStock } = useWatchlist({ autoLoad: false });
@@ -214,6 +215,14 @@ const StockDetailScreen = () => {
return <ConceptsPanel stockCode={stockCode} />; return <ConceptsPanel stockCode={stockCode} />;
case 'announcements': case 'announcements':
return <AnnouncementsPanel stockCode={stockCode} />; return <AnnouncementsPanel stockCode={stockCode} />;
case 'analysis':
return (
<RiseAnalysisPanel
data={riseAnalysisData}
loading={loading.detail}
onItemPress={handleAnalysisPress}
/>
);
default: default:
return null; return null;
} }
@@ -246,24 +255,24 @@ const StockDetailScreen = () => {
loading={isChartLoading} loading={isChartLoading}
/> />
) : ( ) : (
<KlineChart <ProfessionalKlineChart
data={currentChartData} data={currentChartData}
type={chartType} type={chartType}
loading={isChartLoading} loading={isChartLoading}
riseAnalysisData={riseAnalysisData}
onAnalysisPress={handleAnalysisPress}
/> />
)} )}
</Box> </Box>
{/* 5档盘口(可选,放在分时图右侧效果更好,这里简化) */} {/* 5档盘口 - 使用 WebSocket 实时推送的五档数据 */}
{chartType === 'minute' && orderBook.bidPrices?.length > 0 && ( {chartType === 'minute' && (
<OrderBook <OrderBook
askPrices={orderBook.askPrices} askPrices={realtimeQuote?.ask_prices || orderBook.askPrices || []}
askVolumes={orderBook.askVolumes} askVolumes={realtimeQuote?.ask_volumes || orderBook.askVolumes || []}
bidPrices={orderBook.bidPrices} bidPrices={realtimeQuote?.bid_prices || orderBook.bidPrices || []}
bidVolumes={orderBook.bidVolumes} bidVolumes={realtimeQuote?.bid_volumes || orderBook.bidVolumes || []}
preClose={quote.pre_close} preClose={realtimeQuote?.pre_close || quote.pre_close}
updateTime={realtimeQuote?.update_time}
isConnected={wsConnected}
/> />
)} )}

View File

@@ -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.bidPrices - 买盘价格 [买1, 买2, 买3, 买4, 买5]
* @param {array} props.bidVolumes - 买盘数量 * @param {array} props.bidVolumes - 买盘数量
* @param {number} props.preClose - 昨收价 * @param {number} props.preClose - 昨收价
* @param {string} props.updateTime - 更新时间
* @param {boolean} props.isConnected - WebSocket 是否已连接
*/ */
const OrderBook = memo(({ const OrderBook = memo(({
askPrices = [], askPrices = [],
@@ -106,6 +108,8 @@ const OrderBook = memo(({
bidPrices = [], bidPrices = [],
bidVolumes = [], bidVolumes = [],
preClose, preClose,
updateTime,
isConnected,
}) => { }) => {
// 计算最大成交量(用于背景条宽度) // 计算最大成交量(用于背景条宽度)
const maxVolume = useMemo(() => { const maxVolume = useMemo(() => {
@@ -153,9 +157,12 @@ const OrderBook = memo(({
borderWidth={1} borderWidth={1}
borderColor="rgba(255,255,255,0.08)" 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> <Text color="gray.500" fontSize={14}>
{isConnected ? '等待盘口数据...' : '未连接行情服务'}
</Text>
</HStack>
</Box> </Box>
); );
} }
@@ -177,10 +184,25 @@ const OrderBook = memo(({
<Text color="white" fontSize={14} fontWeight="bold"> <Text color="white" fontSize={14} fontWeight="bold">
五档盘口 五档盘口
</Text> </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>
<Text color="gray.500" fontSize={10}>
单位:
</Text>
</HStack> </HStack>
<Box px={4} pb={4}> <Box px={4} pb={4}>

View File

@@ -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;

View File

@@ -11,6 +11,7 @@ const INFO_TABS = [
{ key: 'events', label: '相关事件' }, { key: 'events', label: '相关事件' },
{ key: 'concepts', label: '相关概念' }, { key: 'concepts', label: '相关概念' },
{ key: 'announcements', label: '公司公告' }, { key: 'announcements', label: '公司公告' },
{ key: 'analysis', label: '异动分析' },
]; ];
/** /**

View 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;

View File

@@ -6,9 +6,11 @@ export { default as PriceHeader } from './PriceHeader';
export { default as ChartTypeTabs } from './ChartTypeTabs'; export { default as ChartTypeTabs } from './ChartTypeTabs';
export { default as MinuteChart } from './MinuteChart'; export { default as MinuteChart } from './MinuteChart';
export { default as KlineChart } from './KlineChart'; export { default as KlineChart } from './KlineChart';
export { default as ProfessionalKlineChart } from './ProfessionalKlineChart';
export { default as OrderBook } from './OrderBook'; export { default as OrderBook } from './OrderBook';
export { default as RelatedInfoTabs } from './RelatedInfoTabs'; export { default as RelatedInfoTabs } from './RelatedInfoTabs';
export { default as EventsPanel } from './EventsPanel'; export { default as EventsPanel } from './EventsPanel';
export { default as ConceptsPanel } from './ConceptsPanel'; export { default as ConceptsPanel } from './ConceptsPanel';
export { default as AnnouncementsPanel } from './AnnouncementsPanel'; export { default as AnnouncementsPanel } from './AnnouncementsPanel';
export { default as RiseAnalysisModal } from './RiseAnalysisModal'; export { default as RiseAnalysisModal } from './RiseAnalysisModal';
export { default as RiseAnalysisPanel } from './RiseAnalysisPanel';

View File

@@ -58,6 +58,7 @@ const WatchlistScreen = () => {
loadWatchlist, loadWatchlist,
loadRealtimeQuotes, loadRealtimeQuotes,
loadFollowingEvents, loadFollowingEvents,
loadMiniCharts,
} = useWatchlist(); } = useWatchlist();
// WebSocket 实时行情 // WebSocket 实时行情
@@ -73,7 +74,8 @@ const WatchlistScreen = () => {
loadWatchlist(); loadWatchlist();
loadRealtimeQuotes(); loadRealtimeQuotes();
loadFollowingEvents(); loadFollowingEvents();
}, [loadWatchlist, loadRealtimeQuotes, loadFollowingEvents]) loadMiniCharts();
}, [loadWatchlist, loadRealtimeQuotes, loadFollowingEvents, loadMiniCharts])
); );
// 当股票列表变化时更新 WebSocket 订阅 // 当股票列表变化时更新 WebSocket 订阅
@@ -338,6 +340,7 @@ const WatchlistScreen = () => {
<WatchlistStockItem <WatchlistStockItem
stock={item} stock={item}
quote={item.quote} quote={item.quote}
charts={item.charts}
onPress={handleStockPress} onPress={handleStockPress}
onRemove={handleRemoveStock} onRemove={handleRemoveStock}
isEditing={isEditing} isEditing={isEditing}

View File

@@ -1,9 +1,9 @@
/** /**
* 自选股列表项组件 * 自选股列表项组件
* 显示单只股票的行情信息 * 显示单只股票的行情信息和 mini 图表
*/ */
import React, { memo } from 'react'; import React, { memo, useState } from 'react';
import { StyleSheet } from 'react-native'; import { StyleSheet } from 'react-native';
import { import {
Box, Box,
@@ -15,6 +15,8 @@ import {
} from 'native-base'; } from 'native-base';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import MiniTimelineChart from '../../components/MiniTimelineChart';
import MiniKlineChart from '../../components/MiniKlineChart';
// 涨跌颜色 // 涨跌颜色
const getChangeColor = (change) => { const getChangeColor = (change) => {
@@ -60,6 +62,7 @@ const formatVolume = (volume) => {
* @param {object} props * @param {object} props
* @param {object} props.stock - 股票信息 { stock_code, stock_name } * @param {object} props.stock - 股票信息 { stock_code, stock_name }
* @param {object} props.quote - 实时行情 { current_price, change_percent, volume, ... } * @param {object} props.quote - 实时行情 { current_price, change_percent, volume, ... }
* @param {object} props.charts - mini 图表数据 { timeline: [...], kline: [...] }
* @param {function} props.onPress - 点击回调 * @param {function} props.onPress - 点击回调
* @param {function} props.onRemove - 删除回调 * @param {function} props.onRemove - 删除回调
* @param {boolean} props.isEditing - 是否处于编辑模式 * @param {boolean} props.isEditing - 是否处于编辑模式
@@ -67,6 +70,7 @@ const formatVolume = (volume) => {
const WatchlistStockItem = memo(({ const WatchlistStockItem = memo(({
stock, stock,
quote, quote,
charts,
onPress, onPress,
onRemove, onRemove,
isEditing = false, isEditing = false,
@@ -80,9 +84,21 @@ const WatchlistStockItem = memo(({
low, low,
} = quote || {}; } = quote || {};
// 图表类型切换:'timeline' | 'kline'
const [chartType, setChartType] = useState('timeline');
// 获取图表数据
const timelineData = charts?.timeline || [];
const klineData = charts?.kline || [];
const changeColor = getChangeColor(change_percent); const changeColor = getChangeColor(change_percent);
const gradientColors = getGradientColors(change_percent); const gradientColors = getGradientColors(change_percent);
// 切换图表类型
const toggleChartType = () => {
setChartType(prev => prev === 'timeline' ? 'kline' : 'timeline');
};
return ( return (
<Pressable onPress={() => onPress?.(stock)} disabled={isEditing}> <Pressable onPress={() => onPress?.(stock)} disabled={isEditing}>
{({ pressed }) => ( {({ pressed }) => (
@@ -107,86 +123,140 @@ const WatchlistStockItem = memo(({
borderRadius={16} borderRadius={16}
> >
<HStack alignItems="center" justifyContent="space-between"> <HStack alignItems="center" justifyContent="space-between">
{/* 左侧:删除按钮(编辑模式)或涨跌指示条 */} {/* 左侧:删除按钮(编辑模式)或股票信息 */}
{isEditing ? ( <HStack alignItems="center" flex={1}>
<Pressable {isEditing ? (
onPress={() => onRemove?.(stock_code)} <Pressable
hitSlop={10} onPress={() => onRemove?.(stock_code)}
mr={3} hitSlop={10}
> mr={3}
>
<Box
bg="rgba(239, 68, 68, 0.2)"
p={1.5}
borderRadius={8}
>
<Icon
as={Ionicons}
name="remove-circle"
size="sm"
color="#EF4444"
/>
</Box>
</Pressable>
) : (
<Box <Box
bg="rgba(239, 68, 68, 0.2)" w={1}
p={1.5} h={12}
borderRadius={8} bg={changeColor}
> borderRadius={2}
<Icon mr={3}
as={Ionicons} opacity={0.8}
name="remove-circle" />
size="sm" )}
color="#EF4444"
/>
</Box>
</Pressable>
) : (
<Box
w={1}
h={12}
bg={changeColor}
borderRadius={2}
mr={3}
opacity={0.8}
/>
)}
{/* 中间:股票信息 */} {/* 股票信息 */}
<VStack flex={1} space={0.5}> <VStack flex={1} space={0.5}>
<Text
color="white"
fontSize={15}
fontWeight="bold"
numberOfLines={1}
>
{stock_name || stock_code}
</Text>
<HStack alignItems="center" space={2}>
<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}>
<Text
color={changeColor}
fontSize={18}
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 <Text
color={changeColor} color="white"
fontSize={13} fontSize={15}
fontWeight="medium" fontWeight="bold"
numberOfLines={1}
> >
{formatChange(change_percent)} {stock_name || stock_code}
</Text> </Text>
<HStack alignItems="center" space={2}>
<Text color="gray.500" fontSize={11}>
{stock_code}
</Text>
</HStack>
{/* 价格和涨跌幅 */}
<HStack alignItems="center" space={2} mt={1}>
<Text
color={changeColor}
fontSize={16}
fontWeight="bold"
>
{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> </HStack>
</VStack> )}
{/* 最右侧:箭头(非编辑模式) */} {/* 最右侧:箭头(非编辑模式) */}
{!isEditing && ( {!isEditing && (
@@ -199,20 +269,6 @@ const WatchlistStockItem = memo(({
/> />
)} )}
</HStack> </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>
</Box> </Box>
)} )}

View File

@@ -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线数据字段名称 * 标准化K线数据字段名称
* 将API返回的不同字段名统一为组件期望的格式 * 将API返回的不同字段名统一为组件期望的格式

View File

@@ -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 - 股票代码 * @param {string} stockCode - 股票代码

View File

@@ -14,6 +14,8 @@ const initialState = {
events: [], events: [],
// 实时行情 { code: QuoteData } // 实时行情 { code: QuoteData }
realtimeQuotes: {}, realtimeQuotes: {},
// mini 图表数据 { code: { timeline: [...], kline: [...] } }
miniCharts: {},
// 加载状态 // 加载状态
loading: { loading: {
stocks: false, stocks: false,
@@ -21,6 +23,7 @@ const initialState = {
realtime: false, realtime: false,
adding: false, adding: false,
removing: false, removing: false,
miniCharts: false,
}, },
// 错误信息 // 错误信息
error: null, 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); 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 selectWatchlistStocks = (state) => state.watchlist.stocks;
export const selectWatchlistEvents = (state) => state.watchlist.events; export const selectWatchlistEvents = (state) => state.watchlist.events;
export const selectRealtimeQuotes = (state) => state.watchlist.realtimeQuotes; export const selectRealtimeQuotes = (state) => state.watchlist.realtimeQuotes;
export const selectMiniCharts = (state) => state.watchlist.miniCharts;
export const selectWatchlistLoading = (state) => state.watchlist.loading; export const selectWatchlistLoading = (state) => state.watchlist.loading;
export const selectWatchlistError = (state) => state.watchlist.error; export const selectWatchlistError = (state) => state.watchlist.error;

461
app.py
View File

@@ -6188,6 +6188,144 @@ def get_watchlist_realtime():
return jsonify({'success': False, 'error': '获取实时行情失败'}), 500 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']) @app.route('/api/account/watchlist/<string:stock_code>', methods=['DELETE'])
def remove_from_watchlist(stock_code): def remove_from_watchlist(stock_code):
"""从自选股移除""" """从自选股移除"""
@@ -8294,6 +8432,48 @@ def get_clickhouse_client():
return _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']) @app.route('/api/account/calendar/events', methods=['GET', 'POST'])
def account_calendar_events(): def account_calendar_events():
"""返回当前用户的投资计划与关注的未来事件(合并)。 """返回当前用户的投资计划与关注的未来事件(合并)。
@@ -8832,6 +9012,287 @@ def get_flex_screen_quotes():
return jsonify({'success': False, 'error': str(e)}), 500 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') @app.route('/api/stock/<stock_code>/kline')
def get_stock_kline(stock_code): def get_stock_kline(stock_code):
chart_type = request.args.get('type', 'minute') chart_type = request.args.get('type', 'minute')