更新ios

This commit is contained in:
2026-01-19 13:56:53 +08:00
parent 142ee52a3e
commit 90f633790f
5 changed files with 319 additions and 125 deletions

View File

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

View File

@@ -41,6 +41,31 @@ const formatTime = (time) => {
return '';
};
// 将时间字符串转换为分钟数(用于 X 轴计算)
// A股交易时间9:30-11:30120分钟+ 13:00-15:00120分钟= 总共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)')}
/>
);

View File

@@ -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,50 +471,98 @@ class RealtimeQuoteService {
// ============ 私有方法 ============
/**
* 根据股票代码判断交易所
* 处理行情消息(参考 Web 端格式)
* 消息格式: { type: 'stock' | 'index', data: { code: quote, ... } }
*/
_getExchange(code) {
// 提取纯数字代码
const numericCode = String(code).replace(/\D/g, '');
// 上交所: 6开头
// 深交所: 0、3开头
if (numericCode.startsWith('6')) {
return 'sse';
_handleQuoteMessage(message, exchange) {
// 调试:打印收到的原始消息
if (this._msgLogCount < 5) {
console.log(`[RealtimeQuote] 收到${exchange}消息:`, JSON.stringify(message).substring(0, 800));
this._msgLogCount++;
}
// 心跳响应
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);
}
}
return 'szse';
}
/**
* 处理行情消息
* 解析行情数据
* data 格式: { '603199': { security_name, last_price, prev_close, ... } }
*/
_handleQuoteMessage(message, exchange) {
// 消息格式转换
let quotes = {};
_parseQuoteData(data, exchange) {
const quotes = {};
const suffix = exchange === 'sse' ? '.SH' : '.SZ';
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);
}
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;
});
} else if (message.stock_code || message.code) {
// 直接是行情数据
const code = message.stock_code || message.code;
quotes[code] = this._normalizeQuote(message);
return quotes;
}
// 通知处理器
if (Object.keys(quotes).length > 0) {
_notifyQuoteHandlers(quotes) {
this.quoteHandlers.forEach(handler => {
try {
handler(quotes);
@@ -509,31 +571,6 @@ class RealtimeQuoteService {
}
});
}
}
/**
* 标准化行情数据
*/
_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(),
};
}
_notifyStateChange() {
const state = this.getConnectionState();

Binary file not shown.

93
app.py
View File

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