更新ios
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
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,
|
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,
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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: 'events', label: '相关事件' },
|
||||||
{ key: 'concepts', label: '相关概念' },
|
{ key: 'concepts', label: '相关概念' },
|
||||||
{ key: 'announcements', 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 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';
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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返回的不同字段名统一为组件期望的格式
|
||||||
|
|||||||
@@ -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 - 股票代码
|
||||||
|
|||||||
@@ -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
461
app.py
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user