update pay ui
This commit is contained in:
139
app.py
139
app.py
@@ -6412,6 +6412,10 @@ def get_stock_kline(stock_code):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return jsonify({'error': 'Invalid event_time format'}), 400
|
return jsonify({'error': 'Invalid event_time format'}), 400
|
||||||
|
|
||||||
|
# 确保股票代码包含后缀(ClickHouse 中数据带后缀)
|
||||||
|
if '.' not in stock_code:
|
||||||
|
stock_code = f"{stock_code}.SH" if stock_code.startswith('6') else f"{stock_code}.SZ"
|
||||||
|
|
||||||
# 获取股票名称
|
# 获取股票名称
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
result = conn.execute(text(
|
result = conn.execute(text(
|
||||||
@@ -7837,8 +7841,13 @@ def get_index_kline(index_code):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return jsonify({'error': 'Invalid event_time format'}), 400
|
return jsonify({'error': 'Invalid event_time format'}), 400
|
||||||
|
|
||||||
|
# 确保指数代码包含后缀(ClickHouse 中数据带后缀)
|
||||||
|
# 399xxx -> 深交所, 其他(000xxx等)-> 上交所
|
||||||
|
if '.' not in index_code:
|
||||||
|
index_code = f"{index_code}.SZ" if index_code.startswith('39') else f"{index_code}.SH"
|
||||||
|
|
||||||
# 指数名称(暂无索引表,先返回代码本身)
|
# 指数名称(暂无索引表,先返回代码本身)
|
||||||
index_name = index_code
|
index_name = index_code.split('.')[0]
|
||||||
|
|
||||||
if chart_type == 'minute':
|
if chart_type == 'minute':
|
||||||
return get_index_minute_kline(index_code, event_datetime, index_name)
|
return get_index_minute_kline(index_code, event_datetime, index_name)
|
||||||
@@ -12710,6 +12719,134 @@ def get_hotspot_overview():
|
|||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/concept/<concept_id>/stocks', methods=['GET'])
|
||||||
|
def get_concept_stocks(concept_id):
|
||||||
|
"""
|
||||||
|
获取概念的相关股票列表(带实时涨跌幅)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
concept_id: 概念 ID(来自 ES concept_library_v3)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- stocks: 股票列表 [{code, name, reason, change_pct}, ...]
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from elasticsearch import Elasticsearch
|
||||||
|
from clickhouse_driver import Client
|
||||||
|
|
||||||
|
# 1. 从 ES 获取概念的股票列表
|
||||||
|
es_client = Elasticsearch(["http://222.128.1.157:19200"])
|
||||||
|
es_result = es_client.get(index='concept_library_v3', id=concept_id)
|
||||||
|
|
||||||
|
if not es_result.get('found'):
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': f'概念 {concept_id} 不存在'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
source = es_result.get('_source', {})
|
||||||
|
concept_name = source.get('concept', concept_id)
|
||||||
|
raw_stocks = source.get('stocks', [])
|
||||||
|
|
||||||
|
if not raw_stocks:
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': {
|
||||||
|
'concept_id': concept_id,
|
||||||
|
'concept_name': concept_name,
|
||||||
|
'stocks': []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# 提取股票代码和原因
|
||||||
|
stocks_info = []
|
||||||
|
stock_codes = []
|
||||||
|
for s in raw_stocks:
|
||||||
|
if isinstance(s, dict):
|
||||||
|
code = s.get('code', '')
|
||||||
|
if code and len(code) == 6:
|
||||||
|
stocks_info.append({
|
||||||
|
'code': code,
|
||||||
|
'name': s.get('name', ''),
|
||||||
|
'reason': s.get('reason', '')
|
||||||
|
})
|
||||||
|
stock_codes.append(code)
|
||||||
|
|
||||||
|
# 2. 从 ClickHouse 获取最新涨跌幅
|
||||||
|
change_map = {}
|
||||||
|
if stock_codes:
|
||||||
|
try:
|
||||||
|
ch_client = Client(
|
||||||
|
host='127.0.0.1',
|
||||||
|
port=9000,
|
||||||
|
user='default',
|
||||||
|
password='Zzl33818!',
|
||||||
|
database='stock'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 转换为 ClickHouse 格式
|
||||||
|
ch_codes = []
|
||||||
|
code_mapping = {}
|
||||||
|
for code in stock_codes:
|
||||||
|
if code.startswith('6'):
|
||||||
|
ch_code = f"{code}.SH"
|
||||||
|
elif code.startswith('0') or code.startswith('3'):
|
||||||
|
ch_code = f"{code}.SZ"
|
||||||
|
else:
|
||||||
|
ch_code = f"{code}.BJ"
|
||||||
|
ch_codes.append(ch_code)
|
||||||
|
code_mapping[ch_code] = code
|
||||||
|
|
||||||
|
ch_codes_str = "','".join(ch_codes)
|
||||||
|
|
||||||
|
# 查询最新分钟数据
|
||||||
|
query = f"""
|
||||||
|
SELECT code, close, pre_close
|
||||||
|
FROM stock_minute
|
||||||
|
WHERE code IN ('{ch_codes_str}')
|
||||||
|
AND timestamp >= today()
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 1 BY code
|
||||||
|
"""
|
||||||
|
result = ch_client.execute(query)
|
||||||
|
|
||||||
|
for row in result:
|
||||||
|
ch_code, close_price, pre_close = row
|
||||||
|
if ch_code in code_mapping and pre_close and pre_close > 0:
|
||||||
|
original_code = code_mapping[ch_code]
|
||||||
|
change_pct = (float(close_price) - float(pre_close)) / float(pre_close) * 100
|
||||||
|
change_map[original_code] = round(change_pct, 2)
|
||||||
|
|
||||||
|
except Exception as ch_err:
|
||||||
|
app.logger.warning(f"ClickHouse 获取涨跌幅失败: {ch_err}")
|
||||||
|
|
||||||
|
# 3. 合并数据
|
||||||
|
result_stocks = []
|
||||||
|
for stock in stocks_info:
|
||||||
|
stock['change_pct'] = change_map.get(stock['code'])
|
||||||
|
result_stocks.append(stock)
|
||||||
|
|
||||||
|
# 按涨跌幅排序(涨停优先)
|
||||||
|
result_stocks.sort(key=lambda x: x.get('change_pct') or -999, reverse=True)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': {
|
||||||
|
'concept_id': concept_id,
|
||||||
|
'concept_name': concept_name,
|
||||||
|
'stock_count': len(result_stocks),
|
||||||
|
'stocks': result_stocks
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f"获取概念股票失败: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/market/concept-alerts', methods=['GET'])
|
@app.route('/api/market/concept-alerts', methods=['GET'])
|
||||||
def get_concept_alerts():
|
def get_concept_alerts():
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import type {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理上交所消息
|
* 处理上交所消息
|
||||||
|
* 注意:上交所返回的 code 不带后缀,但通过 msg.type 区分 'stock' 和 'index'
|
||||||
|
* 存储时使用带后缀的完整代码作为 key(如 000001.SH)
|
||||||
*/
|
*/
|
||||||
const handleSSEMessage = (
|
const handleSSEMessage = (
|
||||||
msg: SSEMessage,
|
msg: SSEMessage,
|
||||||
@@ -47,12 +49,16 @@ const handleSSEMessage = (
|
|||||||
const data = msg.data || {};
|
const data = msg.data || {};
|
||||||
const updated: QuotesMap = { ...prevQuotes };
|
const updated: QuotesMap = { ...prevQuotes };
|
||||||
let hasUpdate = false;
|
let hasUpdate = false;
|
||||||
|
const isIndex = msg.type === 'index';
|
||||||
|
|
||||||
Object.entries(data).forEach(([code, quote]: [string, SSEQuoteItem]) => {
|
Object.entries(data).forEach(([code, quote]: [string, SSEQuoteItem]) => {
|
||||||
if (subscribedCodes.has(code)) {
|
// 生成带后缀的完整代码(上交所统一用 .SH)
|
||||||
|
const fullCode = code.includes('.') ? code : `${code}.SH`;
|
||||||
|
|
||||||
|
if (subscribedCodes.has(code) || subscribedCodes.has(fullCode)) {
|
||||||
hasUpdate = true;
|
hasUpdate = true;
|
||||||
updated[code] = {
|
updated[fullCode] = {
|
||||||
code: quote.security_id,
|
code: fullCode,
|
||||||
name: quote.security_name,
|
name: quote.security_name,
|
||||||
price: quote.last_price,
|
price: quote.last_price,
|
||||||
prevClose: quote.prev_close,
|
prevClose: quote.prev_close,
|
||||||
@@ -78,6 +84,8 @@ const handleSSEMessage = (
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理深交所实时消息
|
* 处理深交所实时消息
|
||||||
|
* 注意:深交所返回的 security_id 可能带后缀也可能不带
|
||||||
|
* 存储时统一使用带后缀的完整代码作为 key(如 000001.SZ)
|
||||||
*/
|
*/
|
||||||
const handleSZSERealtimeMessage = (
|
const handleSZSERealtimeMessage = (
|
||||||
msg: SZSERealtimeMessage,
|
msg: SZSERealtimeMessage,
|
||||||
@@ -85,9 +93,11 @@ const handleSZSERealtimeMessage = (
|
|||||||
prevQuotes: QuotesMap
|
prevQuotes: QuotesMap
|
||||||
): QuotesMap | null => {
|
): QuotesMap | null => {
|
||||||
const { category, data, timestamp } = msg;
|
const { category, data, timestamp } = msg;
|
||||||
const code = data.security_id;
|
const rawCode = data.security_id;
|
||||||
|
// 生成带后缀的完整代码(深交所统一用 .SZ)
|
||||||
|
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
|
||||||
|
|
||||||
if (!subscribedCodes.has(code)) {
|
if (!subscribedCodes.has(rawCode) && !subscribedCodes.has(fullCode)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,9 +109,9 @@ const handleSZSERealtimeMessage = (
|
|||||||
const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(stockData.bids);
|
const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(stockData.bids);
|
||||||
const { prices: askPrices, volumes: askVolumes } = extractOrderBook(stockData.asks);
|
const { prices: askPrices, volumes: askVolumes } = extractOrderBook(stockData.asks);
|
||||||
|
|
||||||
updated[code] = {
|
updated[fullCode] = {
|
||||||
code,
|
code: fullCode,
|
||||||
name: prevQuotes[code]?.name || '',
|
name: prevQuotes[fullCode]?.name || '',
|
||||||
price: stockData.last_px,
|
price: stockData.last_px,
|
||||||
prevClose: stockData.prev_close,
|
prevClose: stockData.prev_close,
|
||||||
open: stockData.open_px,
|
open: stockData.open_px,
|
||||||
@@ -127,9 +137,9 @@ const handleSZSERealtimeMessage = (
|
|||||||
|
|
||||||
case 'index': {
|
case 'index': {
|
||||||
const indexData = data as SZSEIndexData;
|
const indexData = data as SZSEIndexData;
|
||||||
updated[code] = {
|
updated[fullCode] = {
|
||||||
code,
|
code: fullCode,
|
||||||
name: prevQuotes[code]?.name || '',
|
name: prevQuotes[fullCode]?.name || '',
|
||||||
price: indexData.current_index,
|
price: indexData.current_index,
|
||||||
prevClose: indexData.prev_close,
|
prevClose: indexData.prev_close,
|
||||||
open: indexData.open_index,
|
open: indexData.open_index,
|
||||||
@@ -154,9 +164,9 @@ const handleSZSERealtimeMessage = (
|
|||||||
|
|
||||||
case 'bond': {
|
case 'bond': {
|
||||||
const bondData = data as SZSEBondData;
|
const bondData = data as SZSEBondData;
|
||||||
updated[code] = {
|
updated[fullCode] = {
|
||||||
code,
|
code: fullCode,
|
||||||
name: prevQuotes[code]?.name || '',
|
name: prevQuotes[fullCode]?.name || '',
|
||||||
price: bondData.last_px,
|
price: bondData.last_px,
|
||||||
prevClose: bondData.prev_close,
|
prevClose: bondData.prev_close,
|
||||||
open: bondData.open_px,
|
open: bondData.open_px,
|
||||||
@@ -185,9 +195,9 @@ const handleSZSERealtimeMessage = (
|
|||||||
const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(hkData.bids);
|
const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(hkData.bids);
|
||||||
const { prices: askPrices, volumes: askVolumes } = extractOrderBook(hkData.asks);
|
const { prices: askPrices, volumes: askVolumes } = extractOrderBook(hkData.asks);
|
||||||
|
|
||||||
updated[code] = {
|
updated[fullCode] = {
|
||||||
code,
|
code: fullCode,
|
||||||
name: prevQuotes[code]?.name || '',
|
name: prevQuotes[fullCode]?.name || '',
|
||||||
price: hkData.last_px,
|
price: hkData.last_px,
|
||||||
prevClose: hkData.prev_close,
|
prevClose: hkData.prev_close,
|
||||||
open: hkData.open_px,
|
open: hkData.open_px,
|
||||||
@@ -215,9 +225,9 @@ const handleSZSERealtimeMessage = (
|
|||||||
case 'afterhours_block':
|
case 'afterhours_block':
|
||||||
case 'afterhours_trading': {
|
case 'afterhours_trading': {
|
||||||
const afterhoursData = data as SZSEAfterhoursData;
|
const afterhoursData = data as SZSEAfterhoursData;
|
||||||
const existing = prevQuotes[code];
|
const existing = prevQuotes[fullCode];
|
||||||
if (existing) {
|
if (existing) {
|
||||||
updated[code] = {
|
updated[fullCode] = {
|
||||||
...existing,
|
...existing,
|
||||||
afterhours: {
|
afterhours: {
|
||||||
bidPx: afterhoursData.bid_px,
|
bidPx: afterhoursData.bid_px,
|
||||||
@@ -243,6 +253,7 @@ const handleSZSERealtimeMessage = (
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理深交所快照消息
|
* 处理深交所快照消息
|
||||||
|
* 存储时统一使用带后缀的完整代码作为 key
|
||||||
*/
|
*/
|
||||||
const handleSZSESnapshotMessage = (
|
const handleSZSESnapshotMessage = (
|
||||||
msg: SZSESnapshotMessage,
|
msg: SZSESnapshotMessage,
|
||||||
@@ -254,13 +265,16 @@ const handleSZSESnapshotMessage = (
|
|||||||
let hasUpdate = false;
|
let hasUpdate = false;
|
||||||
|
|
||||||
stocks.forEach((s: SZSEStockData) => {
|
stocks.forEach((s: SZSEStockData) => {
|
||||||
if (subscribedCodes.has(s.security_id)) {
|
const rawCode = s.security_id;
|
||||||
|
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
|
||||||
|
|
||||||
|
if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) {
|
||||||
hasUpdate = true;
|
hasUpdate = true;
|
||||||
const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(s.bids);
|
const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(s.bids);
|
||||||
const { prices: askPrices, volumes: askVolumes } = extractOrderBook(s.asks);
|
const { prices: askPrices, volumes: askVolumes } = extractOrderBook(s.asks);
|
||||||
|
|
||||||
updated[s.security_id] = {
|
updated[fullCode] = {
|
||||||
code: s.security_id,
|
code: fullCode,
|
||||||
name: '',
|
name: '',
|
||||||
price: s.last_px,
|
price: s.last_px,
|
||||||
prevClose: s.prev_close,
|
prevClose: s.prev_close,
|
||||||
@@ -284,10 +298,13 @@ const handleSZSESnapshotMessage = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
indexes.forEach((i: SZSEIndexData) => {
|
indexes.forEach((i: SZSEIndexData) => {
|
||||||
if (subscribedCodes.has(i.security_id)) {
|
const rawCode = i.security_id;
|
||||||
|
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
|
||||||
|
|
||||||
|
if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) {
|
||||||
hasUpdate = true;
|
hasUpdate = true;
|
||||||
updated[i.security_id] = {
|
updated[fullCode] = {
|
||||||
code: i.security_id,
|
code: fullCode,
|
||||||
name: '',
|
name: '',
|
||||||
price: i.current_index,
|
price: i.current_index,
|
||||||
prevClose: i.prev_close,
|
prevClose: i.prev_close,
|
||||||
@@ -309,10 +326,13 @@ const handleSZSESnapshotMessage = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
bonds.forEach((b: SZSEBondData) => {
|
bonds.forEach((b: SZSEBondData) => {
|
||||||
if (subscribedCodes.has(b.security_id)) {
|
const rawCode = b.security_id;
|
||||||
|
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
|
||||||
|
|
||||||
|
if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) {
|
||||||
hasUpdate = true;
|
hasUpdate = true;
|
||||||
updated[b.security_id] = {
|
updated[fullCode] = {
|
||||||
code: b.security_id,
|
code: fullCode,
|
||||||
name: '',
|
name: '',
|
||||||
price: b.last_px,
|
price: b.last_px,
|
||||||
prevClose: b.prev_close,
|
prevClose: b.prev_close,
|
||||||
@@ -433,12 +453,14 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
|||||||
setConnected(prev => ({ ...prev, [exchange]: true }));
|
setConnected(prev => ({ ...prev, [exchange]: true }));
|
||||||
|
|
||||||
if (exchange === 'SSE') {
|
if (exchange === 'SSE') {
|
||||||
const codes = Array.from(subscribedCodes.current.SSE);
|
// subscribedCodes 存的是带后缀的完整代码,发送给 WS 需要去掉后缀
|
||||||
if (codes.length > 0) {
|
const fullCodes = Array.from(subscribedCodes.current.SSE);
|
||||||
|
const baseCodes = fullCodes.map(c => normalizeCode(c));
|
||||||
|
if (baseCodes.length > 0) {
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
action: 'subscribe',
|
action: 'subscribe',
|
||||||
channels: ['stock', 'index'],
|
channels: ['stock', 'index'],
|
||||||
codes,
|
codes: baseCodes,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -481,17 +503,19 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
|||||||
}, [startHeartbeat, stopHeartbeat, handleMessage]);
|
}, [startHeartbeat, stopHeartbeat, handleMessage]);
|
||||||
|
|
||||||
const subscribe = useCallback((code: string) => {
|
const subscribe = useCallback((code: string) => {
|
||||||
const baseCode = normalizeCode(code);
|
|
||||||
const exchange = getExchange(code);
|
const exchange = getExchange(code);
|
||||||
|
// 确保使用带后缀的完整代码
|
||||||
|
const fullCode = code.includes('.') ? code : `${code}.${exchange === 'SSE' ? 'SH' : 'SZ'}`;
|
||||||
|
const baseCode = normalizeCode(code);
|
||||||
|
|
||||||
subscribedCodes.current[exchange].add(baseCode);
|
subscribedCodes.current[exchange].add(fullCode);
|
||||||
|
|
||||||
const ws = wsRefs.current[exchange];
|
const ws = wsRefs.current[exchange];
|
||||||
if (exchange === 'SSE' && ws && ws.readyState === WebSocket.OPEN) {
|
if (exchange === 'SSE' && ws && ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
action: 'subscribe',
|
action: 'subscribe',
|
||||||
channels: ['stock', 'index'],
|
channels: ['stock', 'index'],
|
||||||
codes: [baseCode],
|
codes: [baseCode], // 发送给 WS 用不带后缀的代码
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,14 +525,15 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
|||||||
}, [createConnection]);
|
}, [createConnection]);
|
||||||
|
|
||||||
const unsubscribe = useCallback((code: string) => {
|
const unsubscribe = useCallback((code: string) => {
|
||||||
const baseCode = normalizeCode(code);
|
|
||||||
const exchange = getExchange(code);
|
const exchange = getExchange(code);
|
||||||
|
// 确保使用带后缀的完整代码
|
||||||
|
const fullCode = code.includes('.') ? code : `${code}.${exchange === 'SSE' ? 'SH' : 'SZ'}`;
|
||||||
|
|
||||||
subscribedCodes.current[exchange].delete(baseCode);
|
subscribedCodes.current[exchange].delete(fullCode);
|
||||||
|
|
||||||
setQuotes(prev => {
|
setQuotes(prev => {
|
||||||
const updated = { ...prev };
|
const updated = { ...prev };
|
||||||
delete updated[baseCode];
|
delete updated[fullCode]; // 删除时也用带后缀的 key
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -522,35 +547,40 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 初始化和 codes 变化处理
|
// 初始化和 codes 变化处理
|
||||||
|
// 注意:codes 现在是带后缀的完整代码(如 000001.SH)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!codes || codes.length === 0) return;
|
if (!codes || codes.length === 0) return;
|
||||||
|
|
||||||
|
// 使用带后缀的完整代码作为内部 key
|
||||||
const newSseCodes = new Set<string>();
|
const newSseCodes = new Set<string>();
|
||||||
const newSzseCodes = new Set<string>();
|
const newSzseCodes = new Set<string>();
|
||||||
|
|
||||||
codes.forEach(code => {
|
codes.forEach(code => {
|
||||||
const baseCode = normalizeCode(code);
|
|
||||||
const exchange = getExchange(code);
|
const exchange = getExchange(code);
|
||||||
|
// 确保代码带后缀
|
||||||
|
const fullCode = code.includes('.') ? code : `${code}.${exchange === 'SSE' ? 'SH' : 'SZ'}`;
|
||||||
if (exchange === 'SSE') {
|
if (exchange === 'SSE') {
|
||||||
newSseCodes.add(baseCode);
|
newSseCodes.add(fullCode);
|
||||||
} else {
|
} else {
|
||||||
newSzseCodes.add(baseCode);
|
newSzseCodes.add(fullCode);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 更新上交所订阅
|
// 更新上交所订阅
|
||||||
const oldSseCodes = subscribedCodes.current.SSE;
|
const oldSseCodes = subscribedCodes.current.SSE;
|
||||||
const sseToAdd = [...newSseCodes].filter(c => !oldSseCodes.has(c));
|
const sseToAdd = [...newSseCodes].filter(c => !oldSseCodes.has(c));
|
||||||
|
// 发送给 WebSocket 的代码需要去掉后缀
|
||||||
|
const sseToAddBase = sseToAdd.map(c => normalizeCode(c));
|
||||||
|
|
||||||
if (sseToAdd.length > 0 || newSseCodes.size !== oldSseCodes.size) {
|
if (sseToAdd.length > 0 || newSseCodes.size !== oldSseCodes.size) {
|
||||||
subscribedCodes.current.SSE = newSseCodes;
|
subscribedCodes.current.SSE = newSseCodes;
|
||||||
const ws = wsRefs.current.SSE;
|
const ws = wsRefs.current.SSE;
|
||||||
|
|
||||||
if (ws && ws.readyState === WebSocket.OPEN && sseToAdd.length > 0) {
|
if (ws && ws.readyState === WebSocket.OPEN && sseToAddBase.length > 0) {
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
action: 'subscribe',
|
action: 'subscribe',
|
||||||
channels: ['stock', 'index'],
|
channels: ['stock', 'index'],
|
||||||
codes: sseToAdd,
|
codes: sseToAddBase,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,7 +612,7 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理已取消订阅的 quotes
|
// 清理已取消订阅的 quotes(使用带后缀的完整代码)
|
||||||
const allNewCodes = new Set([...newSseCodes, ...newSzseCodes]);
|
const allNewCodes = new Set([...newSseCodes, ...newSzseCodes]);
|
||||||
setQuotes(prev => {
|
setQuotes(prev => {
|
||||||
const updated: QuotesMap = {};
|
const updated: QuotesMap = {};
|
||||||
|
|||||||
@@ -7,19 +7,25 @@ import type { Exchange, OrderBookLevel } from '../types';
|
|||||||
/**
|
/**
|
||||||
* 判断证券代码属于哪个交易所
|
* 判断证券代码属于哪个交易所
|
||||||
* @param code - 证券代码(可带或不带后缀)
|
* @param code - 证券代码(可带或不带后缀)
|
||||||
|
* @param isIndex - 是否为指数(用于区分同代码的指数和股票,如 000001)
|
||||||
* @returns 交易所标识
|
* @returns 交易所标识
|
||||||
*/
|
*/
|
||||||
export const getExchange = (code: string): Exchange => {
|
export const getExchange = (code: string, isIndex?: boolean): Exchange => {
|
||||||
const baseCode = code.split('.')[0];
|
// 如果已带后缀,直接判断
|
||||||
|
if (code.includes('.')) {
|
||||||
|
return code.endsWith('.SH') ? 'SSE' : 'SZSE';
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseCode = code;
|
||||||
|
|
||||||
// 6开头为上海股票
|
// 6开头为上海股票
|
||||||
if (baseCode.startsWith('6')) {
|
if (baseCode.startsWith('6')) {
|
||||||
return 'SSE';
|
return 'SSE';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 000开头的6位数是深圳股票(如平安银行000001)
|
// 5开头是上海 ETF
|
||||||
if (baseCode.startsWith('000') && baseCode.length === 6) {
|
if (baseCode.startsWith('5')) {
|
||||||
return 'SZSE';
|
return 'SSE';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 399开头是深证指数
|
// 399开头是深证指数
|
||||||
@@ -27,16 +33,16 @@ export const getExchange = (code: string): Exchange => {
|
|||||||
return 'SZSE';
|
return 'SZSE';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 000开头:如果是指数则为上交所(上证指数000001),否则为深交所(平安银行000001)
|
||||||
|
if (baseCode.startsWith('000')) {
|
||||||
|
return isIndex ? 'SSE' : 'SZSE';
|
||||||
|
}
|
||||||
|
|
||||||
// 0、3开头是深圳股票
|
// 0、3开头是深圳股票
|
||||||
if (baseCode.startsWith('0') || baseCode.startsWith('3')) {
|
if (baseCode.startsWith('0') || baseCode.startsWith('3')) {
|
||||||
return 'SZSE';
|
return 'SZSE';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5开头是上海 ETF
|
|
||||||
if (baseCode.startsWith('5')) {
|
|
||||||
return 'SSE';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1开头是深圳 ETF/债券
|
// 1开头是深圳 ETF/债券
|
||||||
if (baseCode.startsWith('1')) {
|
if (baseCode.startsWith('1')) {
|
||||||
return 'SZSE';
|
return 'SZSE';
|
||||||
@@ -46,6 +52,20 @@ export const getExchange = (code: string): Exchange => {
|
|||||||
return 'SSE';
|
return 'SSE';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取证券代码的完整格式(带交易所后缀)
|
||||||
|
* @param code - 原始代码
|
||||||
|
* @param isIndex - 是否为指数
|
||||||
|
* @returns 带后缀的代码
|
||||||
|
*/
|
||||||
|
export const getFullCode = (code: string, isIndex?: boolean): string => {
|
||||||
|
if (code.includes('.')) {
|
||||||
|
return code; // 已带后缀
|
||||||
|
}
|
||||||
|
const exchange = getExchange(code, isIndex);
|
||||||
|
return `${code}.${exchange === 'SSE' ? 'SH' : 'SZ'}`;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标准化证券代码为无后缀格式
|
* 标准化证券代码为无后缀格式
|
||||||
* @param code - 原始代码
|
* @param code - 原始代码
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
|
|
||||||
import { useRealtimeQuote } from './hooks';
|
import { useRealtimeQuote } from './hooks';
|
||||||
|
import { getFullCode } from './hooks/utils';
|
||||||
import QuoteTile from './components/QuoteTile';
|
import QuoteTile from './components/QuoteTile';
|
||||||
import { logger } from '@utils/logger';
|
import { logger } from '@utils/logger';
|
||||||
import type { WatchlistItem, ConnectionStatus } from './types';
|
import type { WatchlistItem, ConnectionStatus } from './types';
|
||||||
@@ -131,9 +132,9 @@ const FlexScreen: React.FC = () => {
|
|||||||
const searchBg = useColorModeValue('gray.50', '#2a2a2a');
|
const searchBg = useColorModeValue('gray.50', '#2a2a2a');
|
||||||
const hoverBg = useColorModeValue('gray.100', '#333');
|
const hoverBg = useColorModeValue('gray.100', '#333');
|
||||||
|
|
||||||
// 获取订阅的证券代码列表
|
// 获取订阅的证券代码列表(带后缀,用于区分上证指数000001.SH和平安银行000001.SZ)
|
||||||
const subscribedCodes = useMemo(() => {
|
const subscribedCodes = useMemo(() => {
|
||||||
return watchlist.map(item => item.code);
|
return watchlist.map(item => getFullCode(item.code, item.isIndex));
|
||||||
}, [watchlist]);
|
}, [watchlist]);
|
||||||
|
|
||||||
// WebSocket 实时行情
|
// WebSocket 实时行情
|
||||||
@@ -463,16 +464,19 @@ const FlexScreen: React.FC = () => {
|
|||||||
{/* 自选列表 */}
|
{/* 自选列表 */}
|
||||||
{watchlist.length > 0 ? (
|
{watchlist.length > 0 ? (
|
||||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
||||||
{watchlist.map(item => (
|
{watchlist.map(item => {
|
||||||
|
const fullCode = getFullCode(item.code, item.isIndex);
|
||||||
|
return (
|
||||||
<QuoteTile
|
<QuoteTile
|
||||||
key={item.code}
|
key={fullCode}
|
||||||
code={item.code}
|
code={item.code}
|
||||||
name={item.name}
|
name={item.name}
|
||||||
quote={quotes[item.code] || {}}
|
quote={quotes[fullCode] || {}}
|
||||||
isIndex={item.isIndex}
|
isIndex={item.isIndex}
|
||||||
onRemove={removeSecurity}
|
onRemove={removeSecurity}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
) : (
|
) : (
|
||||||
<Center py={8}>
|
<Center py={8}>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* 概念异动列表组件
|
* 概念异动列表组件 - V2
|
||||||
* 展示当日的概念异动记录
|
* 展示当日的概念异动记录,点击可展开显示相关股票
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
VStack,
|
VStack,
|
||||||
@@ -13,222 +13,304 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
useColorModeValue,
|
useColorModeValue,
|
||||||
Flex,
|
Flex,
|
||||||
Divider,
|
Collapse,
|
||||||
|
Spinner,
|
||||||
|
Progress,
|
||||||
|
Table,
|
||||||
|
Thead,
|
||||||
|
Tbody,
|
||||||
|
Tr,
|
||||||
|
Th,
|
||||||
|
Td,
|
||||||
|
TableContainer,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { FaBolt, FaArrowUp, FaArrowDown, FaChartLine, FaFire, FaVolumeUp } from 'react-icons/fa';
|
import { FaArrowUp, FaArrowDown, FaFire, FaChevronDown, FaChevronRight } from 'react-icons/fa';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import axios from 'axios';
|
||||||
import { getAlertTypeLabel, formatScore, getScoreColor } from '../utils/chartHelpers';
|
import { getAlertTypeLabel, formatScore, getScoreColor } from '../utils/chartHelpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Z-Score 指示器组件
|
* 紧凑型异动卡片
|
||||||
*/
|
*/
|
||||||
const ZScoreIndicator = ({ value, label, tooltip }) => {
|
const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => {
|
||||||
if (value === null || value === undefined) return null;
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Z-Score 颜色:越大越红,越小越绿
|
|
||||||
const getZScoreColor = (z) => {
|
|
||||||
const absZ = Math.abs(z);
|
|
||||||
if (absZ >= 3) return z > 0 ? 'red.600' : 'green.600';
|
|
||||||
if (absZ >= 2) return z > 0 ? 'red.500' : 'green.500';
|
|
||||||
if (absZ >= 1) return z > 0 ? 'orange.400' : 'teal.400';
|
|
||||||
return 'gray.400';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Z-Score 强度条宽度(最大 5σ)
|
|
||||||
const barWidth = Math.min(Math.abs(value) / 5 * 100, 100);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip label={tooltip || `${label}: ${value.toFixed(2)}σ`} placement="left">
|
|
||||||
<HStack spacing={1} fontSize="xs">
|
|
||||||
<Text color="gray.500" w="20px">{label}</Text>
|
|
||||||
<Box position="relative" w="40px" h="6px" bg="gray.200" borderRadius="full" overflow="hidden">
|
|
||||||
<Box
|
|
||||||
position="absolute"
|
|
||||||
left={value >= 0 ? '50%' : `${50 - barWidth / 2}%`}
|
|
||||||
w={`${barWidth / 2}%`}
|
|
||||||
h="100%"
|
|
||||||
bg={getZScoreColor(value)}
|
|
||||||
borderRadius="full"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Text color={getZScoreColor(value)} fontWeight="medium" w="28px" textAlign="right">
|
|
||||||
{value >= 0 ? '+' : ''}{value.toFixed(1)}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 持续确认率指示器
|
|
||||||
*/
|
|
||||||
const ConfirmRatioIndicator = ({ ratio }) => {
|
|
||||||
if (ratio === null || ratio === undefined) return null;
|
|
||||||
|
|
||||||
const percent = Math.round(ratio * 100);
|
|
||||||
const color = percent >= 80 ? 'green' : percent >= 60 ? 'orange' : 'red';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip label={`持续确认率: ${percent}%(5分钟窗口内超标比例)`}>
|
|
||||||
<HStack spacing={1}>
|
|
||||||
<Box position="relative" w="32px" h="6px" bg="gray.200" borderRadius="full" overflow="hidden">
|
|
||||||
<Box w={`${percent}%`} h="100%" bg={`${color}.500`} borderRadius="full" />
|
|
||||||
</Box>
|
|
||||||
<Text fontSize="xs" color={`${color}.500`} fontWeight="medium">
|
|
||||||
{percent}%
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 单个异动项组件
|
|
||||||
*/
|
|
||||||
const AlertItem = ({ alert, onClick, isSelected }) => {
|
|
||||||
const bgColor = useColorModeValue('white', '#1a1a1a');
|
const bgColor = useColorModeValue('white', '#1a1a1a');
|
||||||
const hoverBg = useColorModeValue('gray.50', '#2a2a2a');
|
const hoverBg = useColorModeValue('gray.50', '#252525');
|
||||||
const borderColor = useColorModeValue('gray.200', '#333');
|
const borderColor = useColorModeValue('gray.200', '#333');
|
||||||
const selectedBg = useColorModeValue('purple.50', '#2a2a3a');
|
const expandedBg = useColorModeValue('purple.50', '#1e1e2e');
|
||||||
|
|
||||||
const isUp = alert.alert_type !== 'surge_down';
|
const isUp = alert.alert_type !== 'surge_down';
|
||||||
const typeColor = isUp ? 'red' : 'green';
|
const typeColor = isUp ? 'red' : 'green';
|
||||||
const isV2 = alert.is_v2;
|
const isV2 = alert.is_v2;
|
||||||
|
|
||||||
// 获取异动类型图标
|
// 点击股票跳转
|
||||||
const getTypeIcon = (type) => {
|
const handleStockClick = (e, stockCode) => {
|
||||||
switch (type) {
|
e.stopPropagation();
|
||||||
case 'surge_up':
|
navigate(`/company?scode=${stockCode}`);
|
||||||
case 'surge':
|
|
||||||
return FaArrowUp;
|
|
||||||
case 'surge_down':
|
|
||||||
return FaArrowDown;
|
|
||||||
case 'limit_up':
|
|
||||||
return FaFire;
|
|
||||||
case 'volume_spike':
|
|
||||||
return FaVolumeUp;
|
|
||||||
case 'rank_jump':
|
|
||||||
return FaChartLine;
|
|
||||||
default:
|
|
||||||
return FaBolt;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
p={3}
|
bg={isExpanded ? expandedBg : bgColor}
|
||||||
bg={isSelected ? selectedBg : bgColor}
|
borderRadius="lg"
|
||||||
borderRadius="md"
|
|
||||||
borderWidth="1px"
|
borderWidth="1px"
|
||||||
borderColor={isSelected ? 'purple.400' : borderColor}
|
borderColor={isExpanded ? 'purple.400' : borderColor}
|
||||||
cursor="pointer"
|
overflow="hidden"
|
||||||
transition="all 0.2s"
|
transition="all 0.2s"
|
||||||
_hover={{ bg: hoverBg, transform: 'translateX(4px)' }}
|
_hover={{ borderColor: 'purple.300' }}
|
||||||
onClick={() => onClick?.(alert)}
|
|
||||||
>
|
>
|
||||||
<Flex justify="space-between" align="flex-start">
|
{/* 主卡片 - 点击展开 */}
|
||||||
{/* 左侧:概念名称和时间 */}
|
<Box
|
||||||
<VStack align="start" spacing={1} flex={1}>
|
p={3}
|
||||||
<HStack spacing={2}>
|
cursor="pointer"
|
||||||
<Icon as={getTypeIcon(alert.alert_type)} color={`${typeColor}.500`} boxSize={4} />
|
onClick={onToggle}
|
||||||
<Text fontWeight="bold" fontSize="sm" noOfLines={1}>
|
_hover={{ bg: hoverBg }}
|
||||||
|
>
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
{/* 左侧:名称 + 类型 */}
|
||||||
|
<HStack spacing={2} flex={1} minW={0}>
|
||||||
|
<Icon
|
||||||
|
as={isExpanded ? FaChevronDown : FaChevronRight}
|
||||||
|
color="gray.400"
|
||||||
|
boxSize={3}
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
as={isUp ? FaArrowUp : FaArrowDown}
|
||||||
|
color={`${typeColor}.500`}
|
||||||
|
boxSize={3}
|
||||||
|
/>
|
||||||
|
<Text fontWeight="bold" fontSize="sm" noOfLines={1} flex={1}>
|
||||||
{alert.concept_name}
|
{alert.concept_name}
|
||||||
</Text>
|
</Text>
|
||||||
{isV2 && (
|
{isV2 && (
|
||||||
<Badge colorScheme="purple" size="xs" variant="subtle" fontSize="10px">
|
<Badge colorScheme="purple" size="xs" variant="solid" fontSize="9px" px={1}>
|
||||||
V2
|
V2
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack spacing={2} fontSize="xs" color="gray.500">
|
|
||||||
<Text>{alert.time}</Text>
|
|
||||||
<Badge colorScheme={typeColor} size="sm" variant="subtle">
|
|
||||||
{getAlertTypeLabel(alert.alert_type)}
|
|
||||||
</Badge>
|
|
||||||
{/* V2: 持续确认率 */}
|
|
||||||
{isV2 && alert.confirm_ratio !== undefined && (
|
|
||||||
<ConfirmRatioIndicator ratio={alert.confirm_ratio} />
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{/* V2: Z-Score 指标行 */}
|
{/* 右侧:分数 */}
|
||||||
{isV2 && (alert.alpha_zscore !== undefined || alert.amt_zscore !== undefined) && (
|
|
||||||
<HStack spacing={3} mt={1}>
|
|
||||||
<ZScoreIndicator value={alert.alpha_zscore} label="α" tooltip={`Alpha Z-Score: ${alert.alpha_zscore?.toFixed(2)}σ(相对于历史同时段)`} />
|
|
||||||
<ZScoreIndicator value={alert.amt_zscore} label="量" tooltip={`成交额 Z-Score: ${alert.amt_zscore?.toFixed(2)}σ`} />
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
{/* 右侧:分数和关键指标 */}
|
|
||||||
<VStack align="end" spacing={1}>
|
|
||||||
{/* 综合得分 */}
|
|
||||||
{alert.final_score !== undefined && (
|
|
||||||
<Tooltip label={`规则: ${formatScore(alert.rule_score)} / ML: ${formatScore(alert.ml_score)}`}>
|
|
||||||
<Badge
|
<Badge
|
||||||
px={2}
|
px={2}
|
||||||
py={1}
|
py={0.5}
|
||||||
borderRadius="full"
|
borderRadius="full"
|
||||||
bg={getScoreColor(alert.final_score)}
|
bg={getScoreColor(alert.final_score)}
|
||||||
color="white"
|
color="white"
|
||||||
fontSize="xs"
|
fontSize="xs"
|
||||||
fontWeight="bold"
|
fontWeight="bold"
|
||||||
|
ml={2}
|
||||||
>
|
>
|
||||||
{formatScore(alert.final_score)}分
|
{formatScore(alert.final_score)}分
|
||||||
</Badge>
|
</Badge>
|
||||||
</Tooltip>
|
</Flex>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Alpha 值 */}
|
{/* 第二行:时间 + 关键指标 */}
|
||||||
|
<Flex mt={2} justify="space-between" align="center" fontSize="xs">
|
||||||
|
<HStack spacing={2} color="gray.500">
|
||||||
|
<Text>{alert.time}</Text>
|
||||||
|
<Badge colorScheme={typeColor} size="sm" variant="subtle">
|
||||||
|
{getAlertTypeLabel(alert.alert_type)}
|
||||||
|
</Badge>
|
||||||
|
{/* 确认率 */}
|
||||||
|
{isV2 && alert.confirm_ratio !== undefined && (
|
||||||
|
<HStack spacing={1}>
|
||||||
|
<Box w="30px" h="4px" bg="gray.200" borderRadius="full" overflow="hidden">
|
||||||
|
<Box
|
||||||
|
w={`${alert.confirm_ratio * 100}%`}
|
||||||
|
h="100%"
|
||||||
|
bg={alert.confirm_ratio >= 0.8 ? 'green.500' : 'orange.500'}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Text>{Math.round(alert.confirm_ratio * 100)}%</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* Alpha + Z-Score 简化显示 */}
|
||||||
|
<HStack spacing={3}>
|
||||||
{alert.alpha !== undefined && (
|
{alert.alpha !== undefined && (
|
||||||
<Text fontSize="xs" color={alert.alpha >= 0 ? 'red.500' : 'green.500'} fontWeight="medium">
|
<Text color={alert.alpha >= 0 ? 'red.500' : 'green.500'} fontWeight="medium">
|
||||||
α {alert.alpha >= 0 ? '+' : ''}{alert.alpha.toFixed(2)}%
|
α {alert.alpha >= 0 ? '+' : ''}{alert.alpha.toFixed(2)}%
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{isV2 && alert.alpha_zscore !== undefined && (
|
||||||
{/* V2: 动量指标 */}
|
<Tooltip label={`Alpha Z-Score: ${alert.alpha_zscore.toFixed(2)}σ`}>
|
||||||
{isV2 && alert.momentum_5m !== undefined && Math.abs(alert.momentum_5m) > 0.3 && (
|
<HStack spacing={0.5}>
|
||||||
<HStack spacing={1}>
|
<Box
|
||||||
<Icon
|
w="24px"
|
||||||
as={alert.momentum_5m > 0 ? FaArrowUp : FaArrowDown}
|
h="4px"
|
||||||
color={alert.momentum_5m > 0 ? 'red.400' : 'green.400'}
|
bg="gray.200"
|
||||||
boxSize={3}
|
borderRadius="full"
|
||||||
|
overflow="hidden"
|
||||||
|
position="relative"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
left={alert.alpha_zscore >= 0 ? '50%' : undefined}
|
||||||
|
right={alert.alpha_zscore < 0 ? '50%' : undefined}
|
||||||
|
w={`${Math.min(Math.abs(alert.alpha_zscore) / 5 * 50, 50)}%`}
|
||||||
|
h="100%"
|
||||||
|
bg={alert.alpha_zscore >= 0 ? 'red.500' : 'green.500'}
|
||||||
/>
|
/>
|
||||||
<Text fontSize="xs" color={alert.momentum_5m > 0 ? 'red.400' : 'green.400'}>
|
</Box>
|
||||||
动量 {alert.momentum_5m > 0 ? '+' : ''}{alert.momentum_5m.toFixed(2)}
|
<Text color={alert.alpha_zscore >= 0 ? 'red.400' : 'green.400'}>
|
||||||
|
{alert.alpha_zscore >= 0 ? '+' : ''}{alert.alpha_zscore.toFixed(1)}σ
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{alert.limit_up_ratio > 0.05 && (
|
||||||
|
<HStack spacing={0.5} color="orange.500">
|
||||||
|
<Icon as={FaFire} boxSize={3} />
|
||||||
|
<Text>{Math.round(alert.limit_up_ratio * 100)}%</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* 涨停数量 / 涨停比例 */}
|
{/* 展开的股票列表 */}
|
||||||
{(alert.limit_up_count > 0 || (alert.limit_up_ratio > 0.05 && isV2)) && (
|
<Collapse in={isExpanded} animateOpacity>
|
||||||
<HStack spacing={1}>
|
<Box
|
||||||
<Icon as={FaFire} color="orange.500" boxSize={3} />
|
borderTopWidth="1px"
|
||||||
<Text fontSize="xs" color="orange.500">
|
borderColor={borderColor}
|
||||||
{alert.limit_up_count > 0
|
p={3}
|
||||||
? `涨停 ${alert.limit_up_count}`
|
bg={useColorModeValue('gray.50', '#151520')}
|
||||||
: `涨停 ${Math.round(alert.limit_up_ratio * 100)}%`
|
>
|
||||||
|
{loadingStocks ? (
|
||||||
|
<HStack justify="center" py={4}>
|
||||||
|
<Spinner size="sm" color="purple.500" />
|
||||||
|
<Text fontSize="sm" color="gray.500">加载相关股票...</Text>
|
||||||
|
</HStack>
|
||||||
|
) : stocks && stocks.length > 0 ? (
|
||||||
|
<TableContainer maxH="200px" overflowY="auto">
|
||||||
|
<Table size="sm" variant="simple">
|
||||||
|
<Thead position="sticky" top={0} bg={useColorModeValue('gray.50', '#151520')} zIndex={1}>
|
||||||
|
<Tr>
|
||||||
|
<Th px={2} py={1} fontSize="xs" color="gray.500">股票</Th>
|
||||||
|
<Th px={2} py={1} fontSize="xs" color="gray.500" isNumeric>涨跌</Th>
|
||||||
|
<Th px={2} py={1} fontSize="xs" color="gray.500" maxW="120px">原因</Th>
|
||||||
|
</Tr>
|
||||||
|
</Thead>
|
||||||
|
<Tbody>
|
||||||
|
{stocks.slice(0, 10).map((stock, idx) => (
|
||||||
|
<Tr
|
||||||
|
key={idx}
|
||||||
|
cursor="pointer"
|
||||||
|
_hover={{ bg: hoverBg }}
|
||||||
|
onClick={(e) => handleStockClick(e, stock.code || stock.stock_code)}
|
||||||
|
>
|
||||||
|
<Td px={2} py={1.5}>
|
||||||
|
<Text fontSize="xs" color="cyan.400" fontWeight="medium">
|
||||||
|
{stock.name || stock.stock_name}
|
||||||
|
</Text>
|
||||||
|
</Td>
|
||||||
|
<Td px={2} py={1.5} isNumeric>
|
||||||
|
<Text
|
||||||
|
fontSize="xs"
|
||||||
|
fontWeight="bold"
|
||||||
|
color={
|
||||||
|
stock.change_pct > 0 ? 'red.400' :
|
||||||
|
stock.change_pct < 0 ? 'green.400' : 'gray.400'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{stock.change_pct !== undefined
|
||||||
|
? `${stock.change_pct > 0 ? '+' : ''}${stock.change_pct.toFixed(2)}%`
|
||||||
|
: '-'
|
||||||
}
|
}
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</Td>
|
||||||
|
<Td px={2} py={1.5} maxW="120px">
|
||||||
|
<Text fontSize="xs" color="gray.500" noOfLines={1}>
|
||||||
|
{stock.reason || '-'}
|
||||||
|
</Text>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
))}
|
||||||
|
</Tbody>
|
||||||
|
</Table>
|
||||||
|
{stocks.length > 10 && (
|
||||||
|
<Text fontSize="xs" color="gray.500" textAlign="center" mt={2}>
|
||||||
|
共 {stocks.length} 只相关股票,显示前 10 只
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
</VStack>
|
</TableContainer>
|
||||||
</Flex>
|
) : (
|
||||||
|
<Text fontSize="sm" color="gray.500" textAlign="center" py={2}>
|
||||||
|
暂无相关股票数据
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 概念异动列表
|
* 概念异动列表
|
||||||
* @param {Object} props
|
|
||||||
* @param {Array} props.alerts - 异动数据数组
|
|
||||||
* @param {Function} props.onAlertClick - 点击异动的回调
|
|
||||||
* @param {Object} props.selectedAlert - 当前选中的异动
|
|
||||||
* @param {number} props.maxHeight - 最大高度
|
|
||||||
*/
|
*/
|
||||||
const ConceptAlertList = ({ alerts = [], onAlertClick, selectedAlert, maxHeight = '400px' }) => {
|
const ConceptAlertList = ({ alerts = [], onAlertClick, selectedAlert, maxHeight = '400px' }) => {
|
||||||
const textColor = useColorModeValue('gray.800', 'white');
|
const [expandedId, setExpandedId] = useState(null);
|
||||||
|
const [conceptStocks, setConceptStocks] = useState({});
|
||||||
|
const [loadingConcepts, setLoadingConcepts] = useState({});
|
||||||
|
|
||||||
const subTextColor = useColorModeValue('gray.500', 'gray.400');
|
const subTextColor = useColorModeValue('gray.500', 'gray.400');
|
||||||
|
|
||||||
|
// 获取概念相关股票
|
||||||
|
const fetchConceptStocks = useCallback(async (conceptId) => {
|
||||||
|
if (conceptStocks[conceptId] || loadingConcepts[conceptId]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingConcepts(prev => ({ ...prev, [conceptId]: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用后端 API 获取概念股票
|
||||||
|
const response = await axios.get(`/api/concept/${conceptId}/stocks`);
|
||||||
|
if (response.data?.success && response.data?.data?.stocks) {
|
||||||
|
setConceptStocks(prev => ({
|
||||||
|
...prev,
|
||||||
|
[conceptId]: response.data.data.stocks
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取概念股票失败:', error);
|
||||||
|
// 如果 API 失败,尝试从 ES 直接获取
|
||||||
|
try {
|
||||||
|
const esResponse = await axios.get(`/api/es/concept/${conceptId}`);
|
||||||
|
if (esResponse.data?.stocks) {
|
||||||
|
setConceptStocks(prev => ({
|
||||||
|
...prev,
|
||||||
|
[conceptId]: esResponse.data.stocks
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (esError) {
|
||||||
|
console.error('ES 获取也失败:', esError);
|
||||||
|
setConceptStocks(prev => ({ ...prev, [conceptId]: [] }));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoadingConcepts(prev => ({ ...prev, [conceptId]: false }));
|
||||||
|
}
|
||||||
|
}, [conceptStocks, loadingConcepts]);
|
||||||
|
|
||||||
|
// 切换展开状态
|
||||||
|
const handleToggle = useCallback((alert) => {
|
||||||
|
const alertKey = `${alert.concept_id}-${alert.time}`;
|
||||||
|
|
||||||
|
if (expandedId === alertKey) {
|
||||||
|
setExpandedId(null);
|
||||||
|
} else {
|
||||||
|
setExpandedId(alertKey);
|
||||||
|
// 获取股票数据
|
||||||
|
if (alert.concept_id) {
|
||||||
|
fetchConceptStocks(alert.concept_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知父组件
|
||||||
|
onAlertClick?.(alert);
|
||||||
|
}, [expandedId, fetchConceptStocks, onAlertClick]);
|
||||||
|
|
||||||
if (!alerts || alerts.length === 0) {
|
if (!alerts || alerts.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Box p={4} textAlign="center">
|
<Box p={4} textAlign="center">
|
||||||
@@ -239,51 +321,29 @@ const ConceptAlertList = ({ alerts = [], onAlertClick, selectedAlert, maxHeight
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按时间分组
|
// 按时间倒序排列
|
||||||
const groupedAlerts = alerts.reduce((acc, alert) => {
|
const sortedAlerts = [...alerts].sort((a, b) => {
|
||||||
const time = alert.time || '未知时间';
|
const timeA = a.time || '00:00';
|
||||||
if (!acc[time]) {
|
const timeB = b.time || '00:00';
|
||||||
acc[time] = [];
|
return timeB.localeCompare(timeA);
|
||||||
}
|
});
|
||||||
acc[time].push(alert);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
// 按时间排序
|
|
||||||
const sortedTimes = Object.keys(groupedAlerts).sort();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box maxH={maxHeight} overflowY="auto" pr={2}>
|
<Box maxH={maxHeight} overflowY="auto" pr={1}>
|
||||||
<VStack spacing={3} align="stretch">
|
<VStack spacing={2} align="stretch">
|
||||||
{sortedTimes.map((time, timeIndex) => (
|
{sortedAlerts.map((alert, idx) => {
|
||||||
<Box key={time}>
|
const alertKey = `${alert.concept_id}-${alert.time}`;
|
||||||
{/* 时间分隔线 */}
|
return (
|
||||||
{timeIndex > 0 && <Divider my={2} />}
|
<AlertCard
|
||||||
|
key={alertKey || idx}
|
||||||
{/* 时间标签 */}
|
|
||||||
<HStack spacing={2} mb={2}>
|
|
||||||
<Box w={2} h={2} borderRadius="full" bg="purple.500" />
|
|
||||||
<Text fontSize="xs" fontWeight="bold" color={subTextColor}>
|
|
||||||
{time}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="xs" color={subTextColor}>
|
|
||||||
({groupedAlerts[time].length}个异动)
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{/* 该时间点的异动 */}
|
|
||||||
<VStack spacing={2} align="stretch" pl={4}>
|
|
||||||
{groupedAlerts[time].map((alert, idx) => (
|
|
||||||
<AlertItem
|
|
||||||
key={`${alert.concept_id || alert.concept_name}-${idx}`}
|
|
||||||
alert={alert}
|
alert={alert}
|
||||||
onClick={onAlertClick}
|
isExpanded={expandedId === alertKey}
|
||||||
isSelected={selectedAlert?.concept_id === alert.concept_id && selectedAlert?.time === alert.time}
|
onToggle={() => handleToggle(alert)}
|
||||||
|
stocks={conceptStocks[alert.concept_id]}
|
||||||
|
loadingStocks={loadingConcepts[alert.concept_id]}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
</VStack>
|
})}
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</VStack>
|
</VStack>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user