update pay ui

This commit is contained in:
2025-12-10 12:22:40 +08:00
parent 92458a8705
commit 1adbeda168
4 changed files with 197 additions and 120 deletions

174
app.py
View File

@@ -12053,10 +12053,11 @@ def get_market_summary(seccode):
@app.route('/api/stocks/search', methods=['GET']) @app.route('/api/stocks/search', methods=['GET'])
def search_stocks(): def search_stocks():
"""搜索股票(支持股票代码、股票简称、拼音首字母""" """搜索股票和指数(支持代码、名称搜索"""
try: try:
query = request.args.get('q', '').strip() query = request.args.get('q', '').strip()
limit = request.args.get('limit', 20, type=int) limit = request.args.get('limit', 20, type=int)
search_type = request.args.get('type', 'all') # all, stock, index
if not query: if not query:
return jsonify({ return jsonify({
@@ -12064,73 +12065,132 @@ def search_stocks():
'error': '请输入搜索关键词' 'error': '请输入搜索关键词'
}), 400 }), 400
results = []
with engine.connect() as conn: with engine.connect() as conn:
test_sql = text(""" # 搜索指数(优先显示指数,因为通常用户搜索代码时指数更常用)
SELECT SECCODE, SECNAME, F001V, F003V, F010V, F011V if search_type in ('all', 'index'):
FROM ea_stocklist index_sql = text("""
WHERE SECCODE = '300750' SELECT DISTINCT
OR F001V LIKE '%ndsd%' LIMIT 5 INDEXCODE as stock_code,
""") SECNAME as stock_name,
test_result = conn.execute(test_sql).fetchall() INDEXNAME as full_name,
F018V as exchange
FROM ea_exchangeindex
WHERE (
UPPER(INDEXCODE) LIKE UPPER(:query_pattern)
OR UPPER(SECNAME) LIKE UPPER(:query_pattern)
OR UPPER(INDEXNAME) LIKE UPPER(:query_pattern)
)
ORDER BY CASE
WHEN UPPER(INDEXCODE) = UPPER(:exact_query) THEN 1
WHEN UPPER(SECNAME) = UPPER(:exact_query) THEN 2
WHEN UPPER(INDEXCODE) LIKE UPPER(:prefix_pattern) THEN 3
WHEN UPPER(SECNAME) LIKE UPPER(:prefix_pattern) THEN 4
ELSE 5
END,
INDEXCODE
LIMIT :limit
""")
# 构建搜索SQL - 支持股票代码、股票简称、拼音简称搜索 index_result = conn.execute(index_sql, {
search_sql = text(""" 'query_pattern': f'%{query}%',
SELECT DISTINCT SECCODE as stock_code, 'exact_query': query,
SECNAME as stock_name, 'prefix_pattern': f'{query}%',
F001V as pinyin_abbr, 'limit': limit
F003V as security_type, }).fetchall()
F005V as exchange,
F011V as listing_status
FROM ea_stocklist
WHERE (
UPPER(SECCODE) LIKE UPPER(:query_pattern)
OR UPPER(SECNAME) LIKE UPPER(:query_pattern)
OR UPPER(F001V) LIKE UPPER(:query_pattern)
)
-- 基本过滤条件只搜索正常的A股和B股
AND (F011V = '正常上市' OR F010V = '013001') -- 正常上市状态
AND F003V IN ('A股', 'B股') -- 只搜索A股和B股
ORDER BY CASE
WHEN UPPER(SECCODE) = UPPER(:exact_query) THEN 1
WHEN UPPER(SECNAME) = UPPER(:exact_query) THEN 2
WHEN UPPER(F001V) = UPPER(:exact_query) THEN 3
WHEN UPPER(SECCODE) LIKE UPPER(:prefix_pattern) THEN 4
WHEN UPPER(SECNAME) LIKE UPPER(:prefix_pattern) THEN 5
WHEN UPPER(F001V) LIKE UPPER(:prefix_pattern) THEN 6
ELSE 7
END,
SECCODE LIMIT :limit
""")
result = conn.execute(search_sql, { for row in index_result:
'query_pattern': f'%{query}%', results.append({
'exact_query': query, 'stock_code': row.stock_code,
'prefix_pattern': f'{query}%', 'stock_name': row.stock_name,
'limit': limit 'full_name': row.full_name,
}).fetchall() 'exchange': row.exchange,
'isIndex': True,
'security_type': '指数'
})
stocks = [] # 搜索股票
for row in result: if search_type in ('all', 'stock'):
# 获取当前价格 stock_sql = text("""
current_price, _ = get_latest_price_from_clickhouse(row.stock_code) SELECT DISTINCT SECCODE as stock_code,
SECNAME as stock_name,
F001V as pinyin_abbr,
F003V as security_type,
F005V as exchange,
F011V as listing_status
FROM ea_stocklist
WHERE (
UPPER(SECCODE) LIKE UPPER(:query_pattern)
OR UPPER(SECNAME) LIKE UPPER(:query_pattern)
OR UPPER(F001V) LIKE UPPER(:query_pattern)
)
AND (F011V = '正常上市' OR F010V = '013001')
AND F003V IN ('A股', 'B股')
ORDER BY CASE
WHEN UPPER(SECCODE) = UPPER(:exact_query) THEN 1
WHEN UPPER(SECNAME) = UPPER(:exact_query) THEN 2
WHEN UPPER(F001V) = UPPER(:exact_query) THEN 3
WHEN UPPER(SECCODE) LIKE UPPER(:prefix_pattern) THEN 4
WHEN UPPER(SECNAME) LIKE UPPER(:prefix_pattern) THEN 5
WHEN UPPER(F001V) LIKE UPPER(:prefix_pattern) THEN 6
ELSE 7
END,
SECCODE
LIMIT :limit
""")
stocks.append({ stock_result = conn.execute(stock_sql, {
'stock_code': row.stock_code, 'query_pattern': f'%{query}%',
'stock_name': row.stock_name, 'exact_query': query,
'current_price': current_price or 0, # 添加当前价格 'prefix_pattern': f'{query}%',
'pinyin_abbr': row.pinyin_abbr, 'limit': limit
'security_type': row.security_type, }).fetchall()
'exchange': row.exchange,
'listing_status': row.listing_status for row in stock_result:
}) results.append({
'stock_code': row.stock_code,
'stock_name': row.stock_name,
'pinyin_abbr': row.pinyin_abbr,
'security_type': row.security_type,
'exchange': row.exchange,
'listing_status': row.listing_status,
'isIndex': False
})
# 如果搜索全部,按相关性重新排序(精确匹配优先)
if search_type == 'all':
def sort_key(item):
code = item['stock_code'].upper()
name = item['stock_name'].upper()
q = query.upper()
# 精确匹配代码优先
if code == q:
return (0, not item['isIndex'], code) # 指数优先
# 精确匹配名称
if name == q:
return (1, not item['isIndex'], code)
# 前缀匹配代码
if code.startswith(q):
return (2, not item['isIndex'], code)
# 前缀匹配名称
if name.startswith(q):
return (3, not item['isIndex'], code)
return (4, not item['isIndex'], code)
results.sort(key=sort_key)
# 限制总数
results = results[:limit]
return jsonify({ return jsonify({
'success': True, 'success': True,
'data': stocks, 'data': results,
'count': len(stocks) 'count': len(results)
}) })
except Exception as e: except Exception as e:
app.logger.error(f"搜索股票/指数错误: {e}")
return jsonify({ return jsonify({
'success': False, 'success': False,
'error': str(e) 'error': str(e)

View File

@@ -209,11 +209,14 @@ const FlexScreen: React.FC = () => {
(security: SearchResultItem | WatchlistItem): void => { (security: SearchResultItem | WatchlistItem): void => {
const code = 'stock_code' in security ? security.stock_code : security.code; const code = 'stock_code' in security ? security.stock_code : security.code;
const name = 'stock_name' in security ? security.stock_name : security.name; const name = 'stock_name' in security ? security.stock_name : security.name;
const isIndex = // 优先使用 API 返回的 isIndex 字段
security.isIndex || code.startsWith('000') || code.startsWith('399'); const isIndex = security.isIndex === true;
// 检查是否已存在 // 生成唯一标识(带后缀的完整代码)
if (watchlist.some(item => item.code === code)) { const fullCode = getFullCode(code, isIndex);
// 检查是否已存在(使用带后缀的代码比较,避免上证指数和平安银行冲突)
if (watchlist.some(item => getFullCode(item.code, item.isIndex) === fullCode)) {
toast({ toast({
title: '已在自选列表中', title: '已在自选列表中',
status: 'info', status: 'info',
@@ -227,7 +230,7 @@ const FlexScreen: React.FC = () => {
setWatchlist(prev => [...prev, { code, name, isIndex }]); setWatchlist(prev => [...prev, { code, name, isIndex }]);
toast({ toast({
title: `已添加 ${name}`, title: `已添加 ${name}${isIndex ? '(指数)' : ''}`,
status: 'success', status: 'success',
duration: 2000, duration: 2000,
isClosable: true, isClosable: true,
@@ -397,7 +400,7 @@ const FlexScreen: React.FC = () => {
<List spacing={0}> <List spacing={0}>
{searchResults.map((stock, index) => ( {searchResults.map((stock, index) => (
<ListItem <ListItem
key={stock.stock_code} key={`${stock.stock_code}-${stock.isIndex ? 'index' : 'stock'}`}
px={4} px={4}
py={2} py={2}
cursor="pointer" cursor="pointer"
@@ -408,9 +411,18 @@ const FlexScreen: React.FC = () => {
> >
<HStack justify="space-between"> <HStack justify="space-between">
<VStack align="start" spacing={0}> <VStack align="start" spacing={0}>
<Text fontWeight="medium" color={textColor}> <HStack spacing={2}>
{stock.stock_name} <Text fontWeight="medium" color={textColor}>
</Text> {stock.stock_name}
</Text>
<Badge
colorScheme={stock.isIndex ? 'purple' : 'blue'}
fontSize="xs"
variant="subtle"
>
{stock.isIndex ? '指数' : '股票'}
</Badge>
</HStack>
<Text fontSize="xs" color={subTextColor}> <Text fontSize="xs" color={subTextColor}>
{stock.stock_code} {stock.stock_code}
</Text> </Text>

View File

@@ -112,29 +112,29 @@ const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => {
{getAlertTypeLabel(alert.alert_type)} {getAlertTypeLabel(alert.alert_type)}
</Badge> </Badge>
{/* 确认率 */} {/* 确认率 */}
{isV2 && alert.confirm_ratio !== undefined && ( {isV2 && alert.confirm_ratio != null && (
<HStack spacing={1}> <HStack spacing={1}>
<Box w="30px" h="4px" bg="gray.200" borderRadius="full" overflow="hidden"> <Box w="30px" h="4px" bg="gray.200" borderRadius="full" overflow="hidden">
<Box <Box
w={`${alert.confirm_ratio * 100}%`} w={`${(alert.confirm_ratio || 0) * 100}%`}
h="100%" h="100%"
bg={alert.confirm_ratio >= 0.8 ? 'green.500' : 'orange.500'} bg={(alert.confirm_ratio || 0) >= 0.8 ? 'green.500' : 'orange.500'}
/> />
</Box> </Box>
<Text>{Math.round(alert.confirm_ratio * 100)}%</Text> <Text>{Math.round((alert.confirm_ratio || 0) * 100)}%</Text>
</HStack> </HStack>
)} )}
</HStack> </HStack>
{/* Alpha + Z-Score 简化显示 */} {/* Alpha + Z-Score 简化显示 */}
<HStack spacing={3}> <HStack spacing={3}>
{alert.alpha !== undefined && ( {alert.alpha != null && (
<Text color={alert.alpha >= 0 ? 'red.500' : 'green.500'} fontWeight="medium"> <Text color={(alert.alpha || 0) >= 0 ? 'red.500' : 'green.500'} fontWeight="medium">
α {alert.alpha >= 0 ? '+' : ''}{alert.alpha.toFixed(2)}% α {(alert.alpha || 0) >= 0 ? '+' : ''}{(alert.alpha || 0).toFixed(2)}%
</Text> </Text>
)} )}
{isV2 && alert.alpha_zscore !== undefined && ( {isV2 && alert.alpha_zscore != null && (
<Tooltip label={`Alpha Z-Score: ${alert.alpha_zscore.toFixed(2)}σ`}> <Tooltip label={`Alpha Z-Score: ${(alert.alpha_zscore || 0).toFixed(2)}σ`}>
<HStack spacing={0.5}> <HStack spacing={0.5}>
<Box <Box
w="24px" w="24px"
@@ -146,23 +146,23 @@ const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => {
> >
<Box <Box
position="absolute" position="absolute"
left={alert.alpha_zscore >= 0 ? '50%' : undefined} left={(alert.alpha_zscore || 0) >= 0 ? '50%' : undefined}
right={alert.alpha_zscore < 0 ? '50%' : undefined} right={(alert.alpha_zscore || 0) < 0 ? '50%' : undefined}
w={`${Math.min(Math.abs(alert.alpha_zscore) / 5 * 50, 50)}%`} w={`${Math.min(Math.abs(alert.alpha_zscore || 0) / 5 * 50, 50)}%`}
h="100%" h="100%"
bg={alert.alpha_zscore >= 0 ? 'red.500' : 'green.500'} bg={(alert.alpha_zscore || 0) >= 0 ? 'red.500' : 'green.500'}
/> />
</Box> </Box>
<Text color={alert.alpha_zscore >= 0 ? 'red.400' : 'green.400'}> <Text color={(alert.alpha_zscore || 0) >= 0 ? 'red.400' : 'green.400'}>
{alert.alpha_zscore >= 0 ? '+' : ''}{alert.alpha_zscore.toFixed(1)}σ {(alert.alpha_zscore || 0) >= 0 ? '+' : ''}{(alert.alpha_zscore || 0).toFixed(1)}σ
</Text> </Text>
</HStack> </HStack>
</Tooltip> </Tooltip>
)} )}
{alert.limit_up_ratio > 0.05 && ( {(alert.limit_up_ratio || 0) > 0.05 && (
<HStack spacing={0.5} color="orange.500"> <HStack spacing={0.5} color="orange.500">
<Icon as={FaFire} boxSize={3} /> <Icon as={FaFire} boxSize={3} />
<Text>{Math.round(alert.limit_up_ratio * 100)}%</Text> <Text>{Math.round((alert.limit_up_ratio || 0) * 100)}%</Text>
</HStack> </HStack>
)} )}
</HStack> </HStack>
@@ -193,40 +193,44 @@ const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => {
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody>
{stocks.slice(0, 10).map((stock, idx) => ( {stocks.slice(0, 10).map((stock, idx) => {
<Tr const changePct = stock.change_pct;
key={idx} const hasChange = changePct != null && !isNaN(changePct);
cursor="pointer" return (
_hover={{ bg: hoverBg }} <Tr
onClick={(e) => handleStockClick(e, stock.code || stock.stock_code)} key={idx}
> cursor="pointer"
<Td px={2} py={1.5}> _hover={{ bg: hoverBg }}
<Text fontSize="xs" color="cyan.400" fontWeight="medium"> onClick={(e) => handleStockClick(e, stock.code || stock.stock_code)}
{stock.name || stock.stock_name} >
</Text> <Td px={2} py={1.5}>
</Td> <Text fontSize="xs" color="cyan.400" fontWeight="medium">
<Td px={2} py={1.5} isNumeric> {stock.name || stock.stock_name || '-'}
<Text </Text>
fontSize="xs" </Td>
fontWeight="bold" <Td px={2} py={1.5} isNumeric>
color={ <Text
stock.change_pct > 0 ? 'red.400' : fontSize="xs"
stock.change_pct < 0 ? 'green.400' : 'gray.400' fontWeight="bold"
} color={
> hasChange && changePct > 0 ? 'red.400' :
{stock.change_pct !== undefined hasChange && changePct < 0 ? 'green.400' : 'gray.400'
? `${stock.change_pct > 0 ? '+' : ''}${stock.change_pct.toFixed(2)}%` }
: '-' >
} {hasChange
</Text> ? `${changePct > 0 ? '+' : ''}${changePct.toFixed(2)}%`
</Td> : '-'
<Td px={2} py={1.5} maxW="120px"> }
<Text fontSize="xs" color="gray.500" noOfLines={1}> </Text>
{stock.reason || '-'} </Td>
</Text> <Td px={2} py={1.5} maxW="120px">
</Td> <Text fontSize="xs" color="gray.500" noOfLines={1}>
</Tr> {stock.reason || '-'}
))} </Text>
</Td>
</Tr>
);
})}
</Tbody> </Tbody>
</Table> </Table>
{stocks.length > 10 && ( {stocks.length > 10 && (

View File

@@ -152,8 +152,9 @@ export const formatScore = (score) => {
* @returns {string} 颜色代码 * @returns {string} 颜色代码
*/ */
export const getScoreColor = (score) => { export const getScoreColor = (score) => {
if (score >= 80) return '#ff4757'; const s = score || 0;
if (score >= 60) return '#ff6348'; if (s >= 80) return '#ff4757';
if (score >= 40) return '#ffa502'; if (s >= 60) return '#ff6348';
if (s >= 40) return '#ffa502';
return '#747d8c'; return '#747d8c';
}; };