更新ios
This commit is contained in:
@@ -167,8 +167,30 @@ export const useSingleQuote = (code) => {
|
||||
updateRedux: false,
|
||||
});
|
||||
|
||||
// 查找行情(WebSocket 服务已经同时用纯代码和完整代码作为 key)
|
||||
const findQuote = (stockCode) => {
|
||||
if (!stockCode || Object.keys(quotes).length === 0) return null;
|
||||
|
||||
// 直接匹配
|
||||
if (quotes[stockCode]) return quotes[stockCode];
|
||||
|
||||
// 提取纯数字代码
|
||||
const pureCode = stockCode.replace(/\D/g, '');
|
||||
if (quotes[pureCode]) return quotes[pureCode];
|
||||
|
||||
// 尝试带后缀的格式
|
||||
const withSH = `${pureCode}.SH`;
|
||||
const withSZ = `${pureCode}.SZ`;
|
||||
if (quotes[withSH]) return quotes[withSH];
|
||||
if (quotes[withSZ]) return quotes[withSZ];
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const quote = code ? findQuote(code) : null;
|
||||
|
||||
return {
|
||||
quote: code ? quotes[code] : null,
|
||||
quote,
|
||||
isConnected,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -41,6 +41,31 @@ const formatTime = (time) => {
|
||||
return '';
|
||||
};
|
||||
|
||||
// 将时间字符串转换为分钟数(用于 X 轴计算)
|
||||
// A股交易时间:9:30-11:30(120分钟)+ 13:00-15:00(120分钟)= 总共240分钟
|
||||
const timeToMinutes = (timeStr) => {
|
||||
if (!timeStr) return 0;
|
||||
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||
const totalMinutes = hours * 60 + minutes;
|
||||
|
||||
// 上午时段:9:30-11:30 -> 0-120
|
||||
if (totalMinutes >= 570 && totalMinutes <= 690) { // 9:30=570, 11:30=690
|
||||
return totalMinutes - 570;
|
||||
}
|
||||
// 下午时段:13:00-15:00 -> 120-240
|
||||
if (totalMinutes >= 780 && totalMinutes <= 900) { // 13:00=780, 15:00=900
|
||||
return 120 + (totalMinutes - 780);
|
||||
}
|
||||
// 午休时间,返回上午收盘位置
|
||||
if (totalMinutes > 690 && totalMinutes < 780) {
|
||||
return 120;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
// 总交易分钟数
|
||||
const TOTAL_TRADING_MINUTES = 240;
|
||||
|
||||
// 格式化价格
|
||||
const formatPrice = (price) => {
|
||||
if (price === undefined || price === null || isNaN(price)) return '--';
|
||||
@@ -148,31 +173,39 @@ const MinuteChart = memo(({ data = [], preClose, loading }) => {
|
||||
const drawWidth = CHART_WIDTH - PADDING.left - PADDING.right;
|
||||
const drawHeight = CHART_HEIGHT - PADDING.top - PADDING.bottom;
|
||||
|
||||
// 坐标转换函数
|
||||
const xScale = (index) => PADDING.left + (index / (data.length - 1 || 1)) * drawWidth;
|
||||
// 坐标转换函数 - 使用实际时间位置而不是索引
|
||||
const xScaleByTime = (timeStr) => {
|
||||
const minutes = timeToMinutes(timeStr);
|
||||
return PADDING.left + (minutes / TOTAL_TRADING_MINUTES) * drawWidth;
|
||||
};
|
||||
// 保留索引版本用于成交量等
|
||||
const xScaleByIndex = (index) => PADDING.left + (index / (data.length - 1 || 1)) * drawWidth;
|
||||
const yScale = (price) => PADDING.top + ((maxPrice - price) / priceRange) * drawHeight;
|
||||
|
||||
// 分时线点位
|
||||
// 分时线点位 - 使用时间来计算 X 坐标
|
||||
const pricePoints = data.map((d, i) => {
|
||||
const price = d.price || d.current_price || d.close || effectivePreClose;
|
||||
const time = d.time || '';
|
||||
return {
|
||||
x: xScale(i),
|
||||
x: xScaleByTime(time),
|
||||
y: yScale(price),
|
||||
price,
|
||||
time: d.time,
|
||||
time,
|
||||
volume: d.volume,
|
||||
avgPrice: d.avg_price || d.average_price,
|
||||
changePct: d.change_pct, // 保存 API 返回的涨跌幅
|
||||
index: i, // 保留索引用于成交量
|
||||
};
|
||||
});
|
||||
|
||||
// 均价线点位
|
||||
// 均价线点位 - 使用时间来计算 X 坐标
|
||||
const avgPoints = data
|
||||
.map((d, i) => {
|
||||
const avgPrice = d.avg_price || d.average_price;
|
||||
if (!avgPrice || avgPrice <= 0) return null;
|
||||
const time = d.time || '';
|
||||
return {
|
||||
x: xScale(i),
|
||||
x: xScaleByTime(time),
|
||||
y: yScale(avgPrice),
|
||||
price: avgPrice,
|
||||
};
|
||||
@@ -196,25 +229,32 @@ const MinuteChart = memo(({ data = [], preClose, loading }) => {
|
||||
drawWidth,
|
||||
drawHeight,
|
||||
yScale,
|
||||
xScale,
|
||||
xScaleByTime,
|
||||
xScaleByIndex,
|
||||
priceRange,
|
||||
};
|
||||
}, [data, preClose]);
|
||||
|
||||
// 处理触控
|
||||
// 处理触控 - 根据触摸位置找到最近的数据点
|
||||
const handleTouch = useCallback((event) => {
|
||||
if (!chartData || !data || data.length === 0) return;
|
||||
if (!chartData || !chartData.pricePoints || chartData.pricePoints.length === 0) return;
|
||||
|
||||
const { locationX } = event.nativeEvent;
|
||||
const drawWidth = CHART_WIDTH - PADDING.left - PADDING.right;
|
||||
|
||||
// 计算最近的数据点索引
|
||||
const relativeX = locationX - PADDING.left;
|
||||
const index = Math.round((relativeX / drawWidth) * (data.length - 1));
|
||||
const clampedIndex = Math.max(0, Math.min(data.length - 1, index));
|
||||
// 找到最接近触摸位置的数据点
|
||||
let closestIndex = 0;
|
||||
let closestDistance = Infinity;
|
||||
|
||||
setActiveIndex(clampedIndex);
|
||||
}, [chartData, data]);
|
||||
chartData.pricePoints.forEach((point, i) => {
|
||||
const distance = Math.abs(point.x - locationX);
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestIndex = i;
|
||||
}
|
||||
});
|
||||
|
||||
setActiveIndex(closestIndex);
|
||||
}, [chartData]);
|
||||
|
||||
// 处理触控结束
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
@@ -507,7 +547,7 @@ const MinuteChart = memo(({ data = [], preClose, loading }) => {
|
||||
))}
|
||||
</HStack>
|
||||
|
||||
{/* 成交量图 */}
|
||||
{/* 成交量图 - 使用时间来计算位置 */}
|
||||
<Box mx={4} mt={1}>
|
||||
<Svg width={CHART_WIDTH} height={VOLUME_HEIGHT}>
|
||||
{data.map((item, i) => {
|
||||
@@ -518,8 +558,10 @@ const MinuteChart = memo(({ data = [], preClose, loading }) => {
|
||||
const isUp = price >= prevPrice;
|
||||
const isActive = activeIndex === i;
|
||||
|
||||
const x = PADDING.left + (i / (data.length - 1 || 1)) * chartData.drawWidth;
|
||||
const barWidth = Math.max(1, chartData.drawWidth / data.length - 1);
|
||||
// 使用时间来计算 X 坐标
|
||||
const x = chartData.xScaleByTime(item.time || '');
|
||||
// 固定柱宽度(每分钟约 1 个像素)
|
||||
const barWidth = Math.max(1, chartData.drawWidth / TOTAL_TRADING_MINUTES - 0.5);
|
||||
|
||||
return (
|
||||
<Rect
|
||||
@@ -527,7 +569,7 @@ const MinuteChart = memo(({ data = [], preClose, loading }) => {
|
||||
x={x - barWidth / 2}
|
||||
y={VOLUME_HEIGHT - barHeight - 5}
|
||||
width={barWidth}
|
||||
height={barHeight}
|
||||
height={Math.max(0, barHeight)}
|
||||
fill={isActive ? '#F59E0B' : (isUp ? 'rgba(239, 68, 68, 0.6)' : 'rgba(34, 197, 94, 0.6)')}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* WebSocket 实时行情服务
|
||||
* 参考 Web 端 FlexScreen 的实现
|
||||
* 支持上交所(SSE)和深交所(SZSE)双通道
|
||||
*/
|
||||
|
||||
import { AppState } from 'react-native';
|
||||
|
||||
// WebSocket 服务器地址
|
||||
// WebSocket 服务器地址(通过 Nginx 代理)
|
||||
const WS_ENDPOINTS = {
|
||||
sse: 'wss://api.valuefrontier.cn/ws/sse', // 上交所
|
||||
szse: 'wss://api.valuefrontier.cn/ws/szse', // 深交所
|
||||
@@ -25,6 +26,34 @@ const ConnectionState = {
|
||||
RECONNECTING: 'reconnecting',
|
||||
};
|
||||
|
||||
/**
|
||||
* 标准化证券代码为无后缀格式
|
||||
*/
|
||||
const normalizeCode = (code) => {
|
||||
return String(code).split('.')[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断证券代码属于哪个交易所
|
||||
*/
|
||||
const getExchange = (code) => {
|
||||
const baseCode = normalizeCode(code);
|
||||
// 6开头、5开头为上海
|
||||
if (baseCode.startsWith('6') || baseCode.startsWith('5')) {
|
||||
return 'sse';
|
||||
}
|
||||
// 其他为深圳(0、3、1开头)
|
||||
return 'szse';
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算涨跌幅
|
||||
*/
|
||||
const calcChangePct = (price, prevClose) => {
|
||||
if (!prevClose || prevClose === 0) return 0;
|
||||
return ((price - prevClose) / prevClose) * 100;
|
||||
};
|
||||
|
||||
/**
|
||||
* WebSocket 连接管理器
|
||||
*/
|
||||
@@ -55,6 +84,7 @@ class WebSocketManager {
|
||||
this._notifyStateChange();
|
||||
|
||||
try {
|
||||
console.log(`[WS-${this.exchange}] 正在连接: ${this.url}`);
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
@@ -108,8 +138,7 @@ class WebSocketManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅股票行情
|
||||
* @param {string[]} codes - 股票代码列表
|
||||
* 订阅股票行情(纯数字代码)
|
||||
*/
|
||||
subscribe(codes) {
|
||||
if (!Array.isArray(codes)) {
|
||||
@@ -125,7 +154,6 @@ class WebSocketManager {
|
||||
|
||||
/**
|
||||
* 取消订阅
|
||||
* @param {string[]} codes - 股票代码列表
|
||||
*/
|
||||
unsubscribe(codes) {
|
||||
if (!Array.isArray(codes)) {
|
||||
@@ -141,7 +169,6 @@ class WebSocketManager {
|
||||
|
||||
/**
|
||||
* 添加消息处理器
|
||||
* @param {function} handler - 消息处理函数
|
||||
*/
|
||||
addMessageHandler(handler) {
|
||||
this.messageHandlers.add(handler);
|
||||
@@ -150,23 +177,16 @@ class WebSocketManager {
|
||||
|
||||
/**
|
||||
* 添加状态变化处理器
|
||||
* @param {function} handler - 状态处理函数
|
||||
*/
|
||||
addStateHandler(handler) {
|
||||
this.stateHandlers.add(handler);
|
||||
return () => this.stateHandlers.delete(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前连接状态
|
||||
*/
|
||||
getState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已连接
|
||||
*/
|
||||
isConnected() {
|
||||
return this.state === ConnectionState.CONNECTED;
|
||||
}
|
||||
@@ -176,8 +196,10 @@ class WebSocketManager {
|
||||
_sendSubscribe(codes) {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
// 参考 Web 端的订阅格式
|
||||
const message = JSON.stringify({
|
||||
action: 'subscribe',
|
||||
channels: ['stock', 'index'],
|
||||
codes: codes,
|
||||
});
|
||||
this.ws.send(message);
|
||||
@@ -221,7 +243,7 @@ class WebSocketManager {
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[WS-${this.exchange}] 解析消息失败:`, error);
|
||||
console.error(`[WS-${this.exchange}] 解析消息失败:`, error, data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +251,7 @@ class WebSocketManager {
|
||||
this._stopHeartbeat();
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send('ping');
|
||||
this.ws.send(JSON.stringify({ action: 'ping' }));
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL);
|
||||
}
|
||||
@@ -288,6 +310,7 @@ class RealtimeQuoteService {
|
||||
this.stateHandlers = new Set();
|
||||
this.appStateSubscription = null;
|
||||
this._initialized = false;
|
||||
this._msgLogCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -337,26 +360,29 @@ class RealtimeQuoteService {
|
||||
|
||||
/**
|
||||
* 订阅股票行情
|
||||
* @param {string[]} codes - 股票代码列表
|
||||
*/
|
||||
subscribe(codes) {
|
||||
if (!Array.isArray(codes)) {
|
||||
codes = [codes];
|
||||
}
|
||||
|
||||
// 按交易所分类
|
||||
// 按交易所分类,并转换为纯数字格式
|
||||
const sseCodes = [];
|
||||
const szseCodes = [];
|
||||
|
||||
codes.forEach(code => {
|
||||
const exchange = this._getExchange(code);
|
||||
const exchange = getExchange(code);
|
||||
const pureCode = normalizeCode(code);
|
||||
|
||||
if (exchange === 'sse') {
|
||||
sseCodes.push(code);
|
||||
sseCodes.push(pureCode);
|
||||
} else {
|
||||
szseCodes.push(code);
|
||||
szseCodes.push(pureCode);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[RealtimeQuote] 订阅股票:', { original: codes, sse: sseCodes, szse: szseCodes });
|
||||
|
||||
if (sseCodes.length > 0) {
|
||||
this.managers.sse.subscribe(sseCodes);
|
||||
}
|
||||
@@ -367,7 +393,6 @@ class RealtimeQuoteService {
|
||||
|
||||
/**
|
||||
* 取消订阅
|
||||
* @param {string[]} codes - 股票代码列表
|
||||
*/
|
||||
unsubscribe(codes) {
|
||||
if (!Array.isArray(codes)) {
|
||||
@@ -378,11 +403,13 @@ class RealtimeQuoteService {
|
||||
const szseCodes = [];
|
||||
|
||||
codes.forEach(code => {
|
||||
const exchange = this._getExchange(code);
|
||||
const exchange = getExchange(code);
|
||||
const pureCode = normalizeCode(code);
|
||||
|
||||
if (exchange === 'sse') {
|
||||
sseCodes.push(code);
|
||||
sseCodes.push(pureCode);
|
||||
} else {
|
||||
szseCodes.push(code);
|
||||
szseCodes.push(pureCode);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -396,8 +423,6 @@ class RealtimeQuoteService {
|
||||
|
||||
/**
|
||||
* 添加行情数据处理器
|
||||
* @param {function} handler - 处理函数 (quotes) => void
|
||||
* @returns {function} 取消订阅函数
|
||||
*/
|
||||
addQuoteHandler(handler) {
|
||||
this.quoteHandlers.add(handler);
|
||||
@@ -406,17 +431,12 @@ class RealtimeQuoteService {
|
||||
|
||||
/**
|
||||
* 添加连接状态处理器
|
||||
* @param {function} handler - 处理函数 (state) => void
|
||||
* @returns {function} 取消订阅函数
|
||||
*/
|
||||
addStateHandler(handler) {
|
||||
this.stateHandlers.add(handler);
|
||||
return () => this.stateHandlers.delete(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接状态
|
||||
*/
|
||||
getConnectionState() {
|
||||
const sseState = this.managers.sse.getState();
|
||||
const szseState = this.managers.szse.getState();
|
||||
@@ -433,16 +453,10 @@ class RealtimeQuoteService {
|
||||
return 'disconnected';
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已连接
|
||||
*/
|
||||
isConnected() {
|
||||
return this.managers.sse.isConnected() || this.managers.szse.isConnected();
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁服务
|
||||
*/
|
||||
destroy() {
|
||||
this.disconnect();
|
||||
if (this.appStateSubscription) {
|
||||
@@ -457,82 +471,105 @@ class RealtimeQuoteService {
|
||||
// ============ 私有方法 ============
|
||||
|
||||
/**
|
||||
* 根据股票代码判断交易所
|
||||
*/
|
||||
_getExchange(code) {
|
||||
// 提取纯数字代码
|
||||
const numericCode = String(code).replace(/\D/g, '');
|
||||
|
||||
// 上交所: 6开头
|
||||
// 深交所: 0、3开头
|
||||
if (numericCode.startsWith('6')) {
|
||||
return 'sse';
|
||||
}
|
||||
return 'szse';
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理行情消息
|
||||
* 处理行情消息(参考 Web 端格式)
|
||||
* 消息格式: { type: 'stock' | 'index', data: { code: quote, ... } }
|
||||
*/
|
||||
_handleQuoteMessage(message, exchange) {
|
||||
// 消息格式转换
|
||||
let quotes = {};
|
||||
|
||||
if (message.type === 'quote' && message.data) {
|
||||
// 单条行情
|
||||
const data = message.data;
|
||||
const code = data.stock_code || data.code;
|
||||
if (code) {
|
||||
quotes[code] = this._normalizeQuote(data);
|
||||
}
|
||||
} else if (message.type === 'quotes' && Array.isArray(message.data)) {
|
||||
// 批量行情
|
||||
message.data.forEach(item => {
|
||||
const code = item.stock_code || item.code;
|
||||
if (code) {
|
||||
quotes[code] = this._normalizeQuote(item);
|
||||
}
|
||||
});
|
||||
} else if (message.stock_code || message.code) {
|
||||
// 直接是行情数据
|
||||
const code = message.stock_code || message.code;
|
||||
quotes[code] = this._normalizeQuote(message);
|
||||
// 调试:打印收到的原始消息
|
||||
if (this._msgLogCount < 5) {
|
||||
console.log(`[RealtimeQuote] 收到${exchange}消息:`, JSON.stringify(message).substring(0, 800));
|
||||
this._msgLogCount++;
|
||||
}
|
||||
|
||||
// 通知处理器
|
||||
if (Object.keys(quotes).length > 0) {
|
||||
this.quoteHandlers.forEach(handler => {
|
||||
try {
|
||||
handler(quotes);
|
||||
} catch (error) {
|
||||
console.error('[RealtimeQuote] 处理器错误:', error);
|
||||
}
|
||||
});
|
||||
// 心跳响应
|
||||
if (message.type === 'pong') return;
|
||||
|
||||
// 订阅确认
|
||||
if (message.type === 'subscribed') {
|
||||
console.log(`[RealtimeQuote] ${exchange} 订阅成功:`, message.channels, message.codes);
|
||||
return;
|
||||
}
|
||||
|
||||
// 错误消息
|
||||
if (message.type === 'error') {
|
||||
console.error(`[RealtimeQuote] ${exchange} 错误:`, message.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理行情数据
|
||||
// 格式: { type: 'stock' | 'index', data: { '603199': {...}, ... } }
|
||||
if ((message.type === 'stock' || message.type === 'index') && message.data) {
|
||||
const quotes = this._parseQuoteData(message.data, exchange);
|
||||
if (Object.keys(quotes).length > 0) {
|
||||
console.log('[RealtimeQuote] 处理行情:', Object.keys(quotes).length, '只股票');
|
||||
this._notifyQuoteHandlers(quotes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化行情数据
|
||||
* 解析行情数据
|
||||
* data 格式: { '603199': { security_name, last_price, prev_close, ... } }
|
||||
*/
|
||||
_normalizeQuote(data) {
|
||||
return {
|
||||
stock_code: data.stock_code || data.code,
|
||||
stock_name: data.stock_name || data.name,
|
||||
current_price: parseFloat(data.current_price || data.price || data.current || 0),
|
||||
change_percent: parseFloat(data.change_percent || data.pct_chg || data.change_pct || 0),
|
||||
change_amount: parseFloat(data.change_amount || data.change || 0),
|
||||
volume: parseInt(data.volume || data.vol || 0, 10),
|
||||
amount: parseFloat(data.amount || data.turnover || 0),
|
||||
open: parseFloat(data.open || data.open_price || 0),
|
||||
high: parseFloat(data.high || data.high_price || 0),
|
||||
low: parseFloat(data.low || data.low_price || 0),
|
||||
pre_close: parseFloat(data.pre_close || data.prev_close || 0),
|
||||
bid_prices: data.bid_prices || data.bidPrices || [],
|
||||
bid_volumes: data.bid_volumes || data.bidVolumes || [],
|
||||
ask_prices: data.ask_prices || data.askPrices || [],
|
||||
ask_volumes: data.ask_volumes || data.askVolumes || [],
|
||||
update_time: data.update_time || data.time || new Date().toISOString(),
|
||||
};
|
||||
_parseQuoteData(data, exchange) {
|
||||
const quotes = {};
|
||||
const suffix = exchange === 'sse' ? '.SH' : '.SZ';
|
||||
|
||||
Object.entries(data).forEach(([code, quote]) => {
|
||||
if (!quote || typeof quote !== 'object') return;
|
||||
|
||||
// 生成完整代码(带后缀)
|
||||
const fullCode = code.includes('.') ? code : `${code}${suffix}`;
|
||||
const pureCode = normalizeCode(code);
|
||||
|
||||
// 获取当前价和昨收价
|
||||
const currentPrice = parseFloat(
|
||||
quote.last_price || quote.last_px || quote.price || quote.current_price || 0
|
||||
);
|
||||
const prevClose = parseFloat(
|
||||
quote.prev_close || quote.prev_close_px || quote.pre_close || 0
|
||||
);
|
||||
|
||||
// 计算涨跌
|
||||
const change = currentPrice - prevClose;
|
||||
const changePct = calcChangePct(currentPrice, prevClose);
|
||||
|
||||
// 标准化数据
|
||||
const normalized = {
|
||||
stock_code: fullCode,
|
||||
stock_name: quote.security_name || quote.name || '',
|
||||
current_price: currentPrice,
|
||||
pre_close: prevClose,
|
||||
open: parseFloat(quote.open_price || quote.open_px || quote.open || 0),
|
||||
high: parseFloat(quote.high_price || quote.high_px || quote.high || 0),
|
||||
low: parseFloat(quote.low_price || quote.low_px || quote.low || 0),
|
||||
volume: parseInt(quote.volume || quote.total_volume_trade || 0, 10),
|
||||
amount: parseFloat(quote.amount || quote.total_value_trade || 0),
|
||||
change_amount: change,
|
||||
change_percent: changePct,
|
||||
bid_prices: quote.bid_prices || [],
|
||||
bid_volumes: quote.bid_volumes || [],
|
||||
ask_prices: quote.ask_prices || [],
|
||||
ask_volumes: quote.ask_volumes || [],
|
||||
update_time: quote.trade_time || quote.update_time || new Date().toISOString(),
|
||||
};
|
||||
|
||||
// 同时用纯代码和完整代码作为 key,方便匹配
|
||||
quotes[fullCode] = normalized;
|
||||
quotes[pureCode] = normalized;
|
||||
});
|
||||
|
||||
return quotes;
|
||||
}
|
||||
|
||||
_notifyQuoteHandlers(quotes) {
|
||||
this.quoteHandlers.forEach(handler => {
|
||||
try {
|
||||
handler(quotes);
|
||||
} catch (error) {
|
||||
console.error('[RealtimeQuote] 处理器错误:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_notifyStateChange() {
|
||||
|
||||
Binary file not shown.
93
app.py
93
app.py
@@ -10221,6 +10221,99 @@ def get_stock_quote_detail(stock_code):
|
||||
result_data['main_inflow_ratio'] = float(cf.get('main_inflow_ratio') or 0) if cf.get('main_inflow_ratio') is not None else None
|
||||
result_data['net_active_buy_ratio'] = float(cf.get('net_active_buy_ratio') or 0) if cf.get('net_active_buy_ratio') is not None else None
|
||||
|
||||
# 4. 交易时间内从 stock_minute 获取实时价格(覆盖 ea_trade 的日终数据)
|
||||
now = beijing_now()
|
||||
current_date = now.date()
|
||||
current_time = now.time()
|
||||
|
||||
# 判断是否在交易时间内
|
||||
morning_start = dt_time(9, 30)
|
||||
morning_end = dt_time(11, 30)
|
||||
afternoon_start = dt_time(13, 0)
|
||||
afternoon_end = dt_time(15, 0)
|
||||
|
||||
is_trading_time = (
|
||||
current_date in trading_days_set and
|
||||
((morning_start <= current_time <= morning_end) or
|
||||
(afternoon_start <= current_time <= afternoon_end) or
|
||||
(morning_end < current_time < afternoon_start)) # 午休时间也显示上午最新
|
||||
)
|
||||
|
||||
if is_trading_time:
|
||||
try:
|
||||
client = get_clickhouse_client()
|
||||
# 标准化股票代码
|
||||
if base_code.startswith('6'):
|
||||
full_code = f"{base_code}.SH"
|
||||
elif base_code.startswith(('8', '9', '4')):
|
||||
full_code = f"{base_code}.BJ"
|
||||
else:
|
||||
full_code = f"{base_code}.SZ"
|
||||
|
||||
# 查询当天最新的分时数据
|
||||
realtime_query = """
|
||||
SELECT
|
||||
close as current_price,
|
||||
high,
|
||||
low,
|
||||
volume,
|
||||
amt as amount,
|
||||
change_pct,
|
||||
timestamp
|
||||
FROM stock.stock_minute
|
||||
WHERE code = %(code)s
|
||||
AND timestamp >= %(start)s
|
||||
AND timestamp <= %(end)s
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
realtime_data = client.execute(realtime_query, {
|
||||
'code': full_code,
|
||||
'start': datetime.combine(current_date, dt_time(9, 30)),
|
||||
'end': datetime.combine(current_date, dt_time(15, 0))
|
||||
})
|
||||
|
||||
if realtime_data and len(realtime_data) > 0:
|
||||
rt = realtime_data[0]
|
||||
realtime_price = float(rt[0]) if rt[0] else None
|
||||
|
||||
if realtime_price and realtime_price > 0:
|
||||
# 使用昨收价计算涨跌
|
||||
yesterday_close = result_data.get('yesterday_close') or 0
|
||||
if yesterday_close > 0:
|
||||
change_amount = realtime_price - yesterday_close
|
||||
change_percent = (change_amount / yesterday_close) * 100
|
||||
else:
|
||||
change_amount = 0
|
||||
change_percent = float(rt[5]) if rt[5] else 0
|
||||
|
||||
# 覆盖价格相关字段
|
||||
result_data['current_price'] = realtime_price
|
||||
result_data['change_amount'] = round(change_amount, 2)
|
||||
result_data['change_percent'] = round(change_percent, 2)
|
||||
|
||||
# 更新当日高低(取较大/较小值)
|
||||
rt_high = float(rt[1]) if rt[1] else 0
|
||||
rt_low = float(rt[2]) if rt[2] else 0
|
||||
if rt_high > 0:
|
||||
result_data['today_high'] = max(result_data.get('today_high') or 0, rt_high)
|
||||
if rt_low > 0:
|
||||
if result_data.get('today_low') and result_data['today_low'] > 0:
|
||||
result_data['today_low'] = min(result_data['today_low'], rt_low)
|
||||
else:
|
||||
result_data['today_low'] = rt_low
|
||||
|
||||
# 更新成交量和成交额(累计值)
|
||||
# 注意:这里应该从今天的累计数据获取,暂时保留
|
||||
result_data['update_time'] = rt[6].strftime('%Y-%m-%d %H:%M:%S') if rt[6] else now.strftime('%Y-%m-%d %H:%M:%S')
|
||||
result_data['is_realtime'] = True
|
||||
|
||||
print(f"[quote-detail] 实时价格更新: {full_code} -> {realtime_price} ({change_percent:+.2f}%)")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[quote-detail] 获取实时价格失败: {e}")
|
||||
# 失败时保持使用 ea_trade 的数据
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': result_data
|
||||
|
||||
Reference in New Issue
Block a user