update pay ui
This commit is contained in:
112
app.py
112
app.py
@@ -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,17 +12065,54 @@ def search_stocks():
|
|||||||
'error': '请输入搜索关键词'
|
'error': '请输入搜索关键词'
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
with engine.connect() as conn:
|
results = []
|
||||||
test_sql = text("""
|
|
||||||
SELECT SECCODE, SECNAME, F001V, F003V, F010V, F011V
|
|
||||||
FROM ea_stocklist
|
|
||||||
WHERE SECCODE = '300750'
|
|
||||||
OR F001V LIKE '%ndsd%' LIMIT 5
|
|
||||||
""")
|
|
||||||
test_result = conn.execute(test_sql).fetchall()
|
|
||||||
|
|
||||||
# 构建搜索SQL - 支持股票代码、股票简称、拼音简称搜索
|
with engine.connect() as conn:
|
||||||
search_sql = text("""
|
# 搜索指数(优先显示指数,因为通常用户搜索代码时指数更常用)
|
||||||
|
if search_type in ('all', 'index'):
|
||||||
|
index_sql = text("""
|
||||||
|
SELECT DISTINCT
|
||||||
|
INDEXCODE as stock_code,
|
||||||
|
SECNAME as stock_name,
|
||||||
|
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
|
||||||
|
""")
|
||||||
|
|
||||||
|
index_result = conn.execute(index_sql, {
|
||||||
|
'query_pattern': f'%{query}%',
|
||||||
|
'exact_query': query,
|
||||||
|
'prefix_pattern': f'{query}%',
|
||||||
|
'limit': limit
|
||||||
|
}).fetchall()
|
||||||
|
|
||||||
|
for row in index_result:
|
||||||
|
results.append({
|
||||||
|
'stock_code': row.stock_code,
|
||||||
|
'stock_name': row.stock_name,
|
||||||
|
'full_name': row.full_name,
|
||||||
|
'exchange': row.exchange,
|
||||||
|
'isIndex': True,
|
||||||
|
'security_type': '指数'
|
||||||
|
})
|
||||||
|
|
||||||
|
# 搜索股票
|
||||||
|
if search_type in ('all', 'stock'):
|
||||||
|
stock_sql = text("""
|
||||||
SELECT DISTINCT SECCODE as stock_code,
|
SELECT DISTINCT SECCODE as stock_code,
|
||||||
SECNAME as stock_name,
|
SECNAME as stock_name,
|
||||||
F001V as pinyin_abbr,
|
F001V as pinyin_abbr,
|
||||||
@@ -12087,9 +12125,8 @@ def search_stocks():
|
|||||||
OR UPPER(SECNAME) LIKE UPPER(:query_pattern)
|
OR UPPER(SECNAME) LIKE UPPER(:query_pattern)
|
||||||
OR UPPER(F001V) LIKE UPPER(:query_pattern)
|
OR UPPER(F001V) LIKE UPPER(:query_pattern)
|
||||||
)
|
)
|
||||||
-- 基本过滤条件:只搜索正常的A股和B股
|
AND (F011V = '正常上市' OR F010V = '013001')
|
||||||
AND (F011V = '正常上市' OR F010V = '013001') -- 正常上市状态
|
AND F003V IN ('A股', 'B股')
|
||||||
AND F003V IN ('A股', 'B股') -- 只搜索A股和B股
|
|
||||||
ORDER BY CASE
|
ORDER BY CASE
|
||||||
WHEN UPPER(SECCODE) = UPPER(:exact_query) THEN 1
|
WHEN UPPER(SECCODE) = UPPER(:exact_query) THEN 1
|
||||||
WHEN UPPER(SECNAME) = UPPER(:exact_query) THEN 2
|
WHEN UPPER(SECNAME) = UPPER(:exact_query) THEN 2
|
||||||
@@ -12099,38 +12136,61 @@ def search_stocks():
|
|||||||
WHEN UPPER(F001V) LIKE UPPER(:prefix_pattern) THEN 6
|
WHEN UPPER(F001V) LIKE UPPER(:prefix_pattern) THEN 6
|
||||||
ELSE 7
|
ELSE 7
|
||||||
END,
|
END,
|
||||||
SECCODE LIMIT :limit
|
SECCODE
|
||||||
|
LIMIT :limit
|
||||||
""")
|
""")
|
||||||
|
|
||||||
result = conn.execute(search_sql, {
|
stock_result = conn.execute(stock_sql, {
|
||||||
'query_pattern': f'%{query}%',
|
'query_pattern': f'%{query}%',
|
||||||
'exact_query': query,
|
'exact_query': query,
|
||||||
'prefix_pattern': f'{query}%',
|
'prefix_pattern': f'{query}%',
|
||||||
'limit': limit
|
'limit': limit
|
||||||
}).fetchall()
|
}).fetchall()
|
||||||
|
|
||||||
stocks = []
|
for row in stock_result:
|
||||||
for row in result:
|
results.append({
|
||||||
# 获取当前价格
|
|
||||||
current_price, _ = get_latest_price_from_clickhouse(row.stock_code)
|
|
||||||
|
|
||||||
stocks.append({
|
|
||||||
'stock_code': row.stock_code,
|
'stock_code': row.stock_code,
|
||||||
'stock_name': row.stock_name,
|
'stock_name': row.stock_name,
|
||||||
'current_price': current_price or 0, # 添加当前价格
|
|
||||||
'pinyin_abbr': row.pinyin_abbr,
|
'pinyin_abbr': row.pinyin_abbr,
|
||||||
'security_type': row.security_type,
|
'security_type': row.security_type,
|
||||||
'exchange': row.exchange,
|
'exchange': row.exchange,
|
||||||
'listing_status': row.listing_status
|
'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)
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
<HStack spacing={2}>
|
||||||
<Text fontWeight="medium" color={textColor}>
|
<Text fontWeight="medium" color={textColor}>
|
||||||
{stock.stock_name}
|
{stock.stock_name}
|
||||||
</Text>
|
</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>
|
||||||
|
|||||||
@@ -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,7 +193,10 @@ 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) => {
|
||||||
|
const changePct = stock.change_pct;
|
||||||
|
const hasChange = changePct != null && !isNaN(changePct);
|
||||||
|
return (
|
||||||
<Tr
|
<Tr
|
||||||
key={idx}
|
key={idx}
|
||||||
cursor="pointer"
|
cursor="pointer"
|
||||||
@@ -202,7 +205,7 @@ const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => {
|
|||||||
>
|
>
|
||||||
<Td px={2} py={1.5}>
|
<Td px={2} py={1.5}>
|
||||||
<Text fontSize="xs" color="cyan.400" fontWeight="medium">
|
<Text fontSize="xs" color="cyan.400" fontWeight="medium">
|
||||||
{stock.name || stock.stock_name}
|
{stock.name || stock.stock_name || '-'}
|
||||||
</Text>
|
</Text>
|
||||||
</Td>
|
</Td>
|
||||||
<Td px={2} py={1.5} isNumeric>
|
<Td px={2} py={1.5} isNumeric>
|
||||||
@@ -210,12 +213,12 @@ const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => {
|
|||||||
fontSize="xs"
|
fontSize="xs"
|
||||||
fontWeight="bold"
|
fontWeight="bold"
|
||||||
color={
|
color={
|
||||||
stock.change_pct > 0 ? 'red.400' :
|
hasChange && changePct > 0 ? 'red.400' :
|
||||||
stock.change_pct < 0 ? 'green.400' : 'gray.400'
|
hasChange && changePct < 0 ? 'green.400' : 'gray.400'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{stock.change_pct !== undefined
|
{hasChange
|
||||||
? `${stock.change_pct > 0 ? '+' : ''}${stock.change_pct.toFixed(2)}%`
|
? `${changePct > 0 ? '+' : ''}${changePct.toFixed(2)}%`
|
||||||
: '-'
|
: '-'
|
||||||
}
|
}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -226,7 +229,8 @@ const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
</Td>
|
</Td>
|
||||||
</Tr>
|
</Tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</Tbody>
|
</Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
{stocks.length > 10 && (
|
{stocks.length > 10 && (
|
||||||
|
|||||||
@@ -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';
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user