community增加事件详情
This commit is contained in:
182
app.py
182
app.py
@@ -10998,6 +10998,188 @@ def get_zt_theme_scatter():
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/v1/events/effectiveness-stats', methods=['GET'])
|
||||
def get_events_effectiveness_stats():
|
||||
"""获取事件有效性统计数据
|
||||
|
||||
按交易日统计事件数据,展示事件预测的有效性
|
||||
|
||||
参数:
|
||||
date: 指定日期(YYYY-MM-DD格式,可选,默认今天)
|
||||
days: 统计天数(默认7天)
|
||||
|
||||
返回:
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
currentDate: "2026-01-08",
|
||||
summary: {
|
||||
totalEvents: 150, // 总事件数
|
||||
avgChg: 2.35, // 平均涨幅
|
||||
maxChg: 15.8, // 最大涨幅
|
||||
positiveRate: 68.5, // 正收益率
|
||||
avgInvestScore: 65, // 平均投资评分
|
||||
avgSurpriseScore: 58 // 平均超预期得分
|
||||
},
|
||||
dailyStats: [
|
||||
{
|
||||
date: "2026-01-08",
|
||||
eventCount: 25,
|
||||
avgChg: 1.85,
|
||||
maxChg: 8.5,
|
||||
positiveCount: 18,
|
||||
topEvents: [...] // 当日表现最好的事件
|
||||
},
|
||||
...
|
||||
],
|
||||
topPerformers: [...] // 近期表现最好的事件
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
date_str = request.args.get('date', '')
|
||||
days = request.args.get('days', 7, type=int)
|
||||
days = min(max(days, 1), 30) # 限制1-30天
|
||||
|
||||
# 确定查询日期范围
|
||||
if date_str:
|
||||
try:
|
||||
end_date = datetime.strptime(date_str, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
end_date = datetime.now()
|
||||
else:
|
||||
end_date = datetime.now()
|
||||
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
# 查询事件数据
|
||||
events_query = db.session.query(Event).filter(
|
||||
Event.created_at >= start_date,
|
||||
Event.created_at <= end_date + timedelta(days=1),
|
||||
Event.status == 'active'
|
||||
).order_by(Event.created_at.desc()).all()
|
||||
|
||||
if not events_query:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'currentDate': end_date.strftime('%Y-%m-%d'),
|
||||
'summary': {
|
||||
'totalEvents': 0,
|
||||
'avgChg': 0,
|
||||
'maxChg': 0,
|
||||
'positiveRate': 0,
|
||||
'avgInvestScore': 0,
|
||||
'avgSurpriseScore': 0
|
||||
},
|
||||
'dailyStats': [],
|
||||
'topPerformers': []
|
||||
}
|
||||
})
|
||||
|
||||
# 计算汇总统计
|
||||
total_events = len(events_query)
|
||||
avg_chg_list = [e.related_avg_chg for e in events_query if e.related_avg_chg is not None]
|
||||
max_chg_list = [e.related_max_chg for e in events_query if e.related_max_chg is not None]
|
||||
invest_scores = [e.invest_score for e in events_query if e.invest_score is not None]
|
||||
surprise_scores = [e.expectation_surprise_score for e in events_query if e.expectation_surprise_score is not None]
|
||||
|
||||
positive_count = sum(1 for chg in avg_chg_list if chg > 0)
|
||||
|
||||
summary = {
|
||||
'totalEvents': total_events,
|
||||
'avgChg': round(sum(avg_chg_list) / len(avg_chg_list), 2) if avg_chg_list else 0,
|
||||
'maxChg': round(max(max_chg_list), 2) if max_chg_list else 0,
|
||||
'positiveRate': round(positive_count / len(avg_chg_list) * 100, 1) if avg_chg_list else 0,
|
||||
'avgInvestScore': round(sum(invest_scores) / len(invest_scores)) if invest_scores else 0,
|
||||
'avgSurpriseScore': round(sum(surprise_scores) / len(surprise_scores)) if surprise_scores else 0
|
||||
}
|
||||
|
||||
# 按日期分组统计
|
||||
daily_data = {}
|
||||
for event in events_query:
|
||||
date_key = event.created_at.strftime('%Y-%m-%d')
|
||||
if date_key not in daily_data:
|
||||
daily_data[date_key] = {
|
||||
'date': date_key,
|
||||
'events': [],
|
||||
'avgChgList': [],
|
||||
'maxChgList': []
|
||||
}
|
||||
|
||||
daily_data[date_key]['events'].append({
|
||||
'id': event.id,
|
||||
'title': event.title[:50] if event.title else '',
|
||||
'eventType': event.event_type,
|
||||
'avgChg': event.related_avg_chg,
|
||||
'maxChg': event.related_max_chg,
|
||||
'investScore': event.invest_score,
|
||||
'surpriseScore': event.expectation_surprise_score,
|
||||
'indType': event.ind_type,
|
||||
})
|
||||
|
||||
if event.related_avg_chg is not None:
|
||||
daily_data[date_key]['avgChgList'].append(event.related_avg_chg)
|
||||
if event.related_max_chg is not None:
|
||||
daily_data[date_key]['maxChgList'].append(event.related_max_chg)
|
||||
|
||||
# 处理每日统计
|
||||
daily_stats = []
|
||||
for date_key in sorted(daily_data.keys(), reverse=True):
|
||||
day = daily_data[date_key]
|
||||
avg_list = day['avgChgList']
|
||||
max_list = day['maxChgList']
|
||||
|
||||
# 找出当日表现最好的事件(按最大涨幅排序)
|
||||
top_events = sorted(day['events'], key=lambda x: x['maxChg'] or 0, reverse=True)[:3]
|
||||
|
||||
daily_stats.append({
|
||||
'date': date_key,
|
||||
'eventCount': len(day['events']),
|
||||
'avgChg': round(sum(avg_list) / len(avg_list), 2) if avg_list else 0,
|
||||
'maxChg': round(max(max_list), 2) if max_list else 0,
|
||||
'positiveCount': sum(1 for chg in avg_list if chg > 0),
|
||||
'topEvents': top_events
|
||||
})
|
||||
|
||||
# 找出表现最好的事件(全局)
|
||||
top_performers = sorted(
|
||||
[e for e in events_query if e.related_max_chg is not None],
|
||||
key=lambda x: x.related_max_chg,
|
||||
reverse=True
|
||||
)[:10]
|
||||
|
||||
top_performers_list = [{
|
||||
'id': e.id,
|
||||
'title': e.title[:60] if e.title else '',
|
||||
'eventType': e.event_type,
|
||||
'avgChg': e.related_avg_chg,
|
||||
'maxChg': e.related_max_chg,
|
||||
'investScore': e.invest_score,
|
||||
'surpriseScore': e.expectation_surprise_score,
|
||||
'indType': e.ind_type,
|
||||
'createdAt': e.created_at.strftime('%Y-%m-%d %H:%M') if e.created_at else '',
|
||||
} for e in top_performers]
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'currentDate': end_date.strftime('%Y-%m-%d'),
|
||||
'summary': summary,
|
||||
'dailyStats': daily_stats,
|
||||
'topPerformers': top_performers_list
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/v1/calendar/events', methods=['GET'])
|
||||
def get_calendar_events():
|
||||
"""获取指定日期的事件列表"""
|
||||
|
||||
362
src/views/Community/components/EventEffectivenessStats.js
Normal file
362
src/views/Community/components/EventEffectivenessStats.js
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* EventEffectivenessStats - 事件有效性统计
|
||||
* 展示事件中心的事件有效性数据,证明系统推荐价值
|
||||
*/
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Spinner,
|
||||
Center,
|
||||
useToast,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
StatArrow,
|
||||
Progress,
|
||||
Badge,
|
||||
Divider,
|
||||
Tooltip,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
TrophyOutlined,
|
||||
RiseOutlined,
|
||||
FireOutlined,
|
||||
CheckCircleOutlined,
|
||||
ThunderboltOutlined,
|
||||
StarOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
|
||||
/**
|
||||
* 格式化涨跌幅
|
||||
*/
|
||||
const formatChg = (val) => {
|
||||
if (val === null || val === undefined) return '-';
|
||||
const num = parseFloat(val);
|
||||
if (isNaN(num)) return '-';
|
||||
return (num >= 0 ? '+' : '') + num.toFixed(2) + '%';
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取涨跌幅颜色
|
||||
*/
|
||||
const getChgColor = (val) => {
|
||||
if (val === null || val === undefined) return 'gray.400';
|
||||
const num = parseFloat(val);
|
||||
if (isNaN(num)) return 'gray.400';
|
||||
if (num > 0) return '#FF4D4F';
|
||||
if (num < 0) return '#52C41A';
|
||||
return 'gray.400';
|
||||
};
|
||||
|
||||
/**
|
||||
* 数据卡片组件
|
||||
*/
|
||||
const StatCard = ({ label, value, icon, color = '#FFD700', subText, trend, progress }) => (
|
||||
<Box
|
||||
bg="rgba(0,0,0,0.3)"
|
||||
borderRadius="lg"
|
||||
p={3}
|
||||
border="1px solid"
|
||||
borderColor="rgba(255,215,0,0.15)"
|
||||
_hover={{ borderColor: 'rgba(255,215,0,0.3)', transform: 'translateY(-2px)' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<HStack spacing={2} mb={1}>
|
||||
<Box color={color} fontSize="md">
|
||||
{icon}
|
||||
</Box>
|
||||
<Text fontSize="xs" color="gray.400" fontWeight="medium">
|
||||
{label}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontSize="xl" fontWeight="bold" color={color}>
|
||||
{value}
|
||||
</Text>
|
||||
{subText && (
|
||||
<Text fontSize="xs" color="gray.500" mt={1}>
|
||||
{subText}
|
||||
</Text>
|
||||
)}
|
||||
{trend !== undefined && (
|
||||
<HStack spacing={1} mt={1}>
|
||||
<StatArrow type={trend >= 0 ? 'increase' : 'decrease'} />
|
||||
<Text fontSize="xs" color={trend >= 0 ? '#FF4D4F' : '#52C41A'}>
|
||||
{Math.abs(trend).toFixed(1)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
{progress !== undefined && (
|
||||
<Progress
|
||||
value={progress}
|
||||
size="xs"
|
||||
colorScheme={progress >= 60 ? 'green' : progress >= 40 ? 'yellow' : 'red'}
|
||||
mt={2}
|
||||
borderRadius="full"
|
||||
bg="rgba(255,255,255,0.1)"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* 热门事件列表项
|
||||
*/
|
||||
const TopEventItem = ({ event, rank }) => (
|
||||
<HStack
|
||||
spacing={2}
|
||||
py={1.5}
|
||||
px={2}
|
||||
bg="rgba(0,0,0,0.2)"
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'rgba(255,215,0,0.1)' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Badge
|
||||
colorScheme={rank === 1 ? 'yellow' : rank === 2 ? 'gray' : 'orange'}
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
borderRadius="full"
|
||||
>
|
||||
{rank}
|
||||
</Badge>
|
||||
<Tooltip label={event.title} placement="top" hasArrow>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="gray.200"
|
||||
flex="1"
|
||||
noOfLines={1}
|
||||
cursor="default"
|
||||
>
|
||||
{event.title}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color={getChgColor(event.max_chg)}
|
||||
>
|
||||
{formatChg(event.max_chg)}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
|
||||
const EventEffectivenessStats = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const toast = useToast();
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const apiBase = getApiBase();
|
||||
const response = await fetch(`${apiBase}/api/v1/events/effectiveness-stats?days=30`);
|
||||
if (!response.ok) throw new Error('获取数据失败');
|
||||
const data = await response.json();
|
||||
if (data.code === 200) {
|
||||
setStats(data.data);
|
||||
} else {
|
||||
throw new Error(data.message || '数据格式错误');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取事件有效性统计失败:', err);
|
||||
setError(err.message);
|
||||
toast({
|
||||
title: '获取统计数据失败',
|
||||
description: err.message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, [fetchStats]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box
|
||||
bg="linear-gradient(180deg, rgba(25, 32, 55, 0.95) 0%, rgba(17, 24, 39, 0.98) 100%)"
|
||||
borderRadius="xl"
|
||||
p={4}
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 215, 0, 0.15)"
|
||||
minH="400px"
|
||||
>
|
||||
<Center h="350px">
|
||||
<Spinner size="lg" color="yellow.400" thickness="3px" />
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !stats) {
|
||||
return (
|
||||
<Box
|
||||
bg="linear-gradient(180deg, rgba(25, 32, 55, 0.95) 0%, rgba(17, 24, 39, 0.98) 100%)"
|
||||
borderRadius="xl"
|
||||
p={4}
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 215, 0, 0.15)"
|
||||
minH="400px"
|
||||
>
|
||||
<Center h="350px">
|
||||
<VStack spacing={2}>
|
||||
<Text color="gray.400">暂无数据</Text>
|
||||
<Text fontSize="xs" color="gray.500">{error}</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const { summary, topPerformers = [] } = stats;
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg="linear-gradient(180deg, rgba(25, 32, 55, 0.95) 0%, rgba(17, 24, 39, 0.98) 100%)"
|
||||
borderRadius="xl"
|
||||
p={4}
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 215, 0, 0.15)"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
h="100%"
|
||||
>
|
||||
{/* 背景装饰 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="-50%"
|
||||
right="-30%"
|
||||
w="300px"
|
||||
h="300px"
|
||||
bg="radial-gradient(circle, rgba(255,215,0,0.08) 0%, transparent 70%)"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
{/* 标题 */}
|
||||
<HStack spacing={2} mb={4}>
|
||||
<Box
|
||||
w="4px"
|
||||
h="20px"
|
||||
bg="linear-gradient(180deg, #FFD700, #FFA500)"
|
||||
borderRadius="full"
|
||||
/>
|
||||
<Text
|
||||
fontSize="md"
|
||||
fontWeight="bold"
|
||||
color="white"
|
||||
letterSpacing="wide"
|
||||
>
|
||||
事件有效性统计
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme="yellow"
|
||||
variant="subtle"
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
>
|
||||
近30天
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* 核心指标 - 2列网格 */}
|
||||
<Box
|
||||
display="grid"
|
||||
gridTemplateColumns="repeat(2, 1fr)"
|
||||
gap={3}
|
||||
>
|
||||
<StatCard
|
||||
label="事件总数"
|
||||
value={summary?.totalEvents || 0}
|
||||
icon={<FireOutlined />}
|
||||
color="#FFD700"
|
||||
subText="活跃事件"
|
||||
/>
|
||||
<StatCard
|
||||
label="正向率"
|
||||
value={`${(summary?.positiveRate || 0).toFixed(1)}%`}
|
||||
icon={<CheckCircleOutlined />}
|
||||
color={summary?.positiveRate >= 50 ? '#52C41A' : '#FF4D4F'}
|
||||
progress={summary?.positiveRate || 0}
|
||||
/>
|
||||
<StatCard
|
||||
label="平均涨幅"
|
||||
value={formatChg(summary?.avgChg)}
|
||||
icon={<RiseOutlined />}
|
||||
color={getChgColor(summary?.avgChg)}
|
||||
subText="关联股票"
|
||||
/>
|
||||
<StatCard
|
||||
label="最大涨幅"
|
||||
value={formatChg(summary?.maxChg)}
|
||||
icon={<ThunderboltOutlined />}
|
||||
color="#FF4D4F"
|
||||
subText="单事件最佳"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 评分指标 */}
|
||||
<Box
|
||||
display="grid"
|
||||
gridTemplateColumns="repeat(2, 1fr)"
|
||||
gap={3}
|
||||
>
|
||||
<StatCard
|
||||
label="投资价值"
|
||||
value={(summary?.avgInvestScore || 0).toFixed(0)}
|
||||
icon={<StarOutlined />}
|
||||
color="#F59E0B"
|
||||
progress={summary?.avgInvestScore || 0}
|
||||
subText="平均评分"
|
||||
/>
|
||||
<StatCard
|
||||
label="超预期"
|
||||
value={(summary?.avgSurpriseScore || 0).toFixed(0)}
|
||||
icon={<TrophyOutlined />}
|
||||
color="#8B5CF6"
|
||||
progress={summary?.avgSurpriseScore || 0}
|
||||
subText="惊喜程度"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 分割线 */}
|
||||
<Divider borderColor="rgba(255,215,0,0.1)" />
|
||||
|
||||
{/* TOP表现事件 */}
|
||||
<Box>
|
||||
<HStack spacing={2} mb={3}>
|
||||
<TrophyOutlined style={{ color: '#FFD700', fontSize: '14px' }} />
|
||||
<Text fontSize="sm" fontWeight="bold" color="gray.300">
|
||||
TOP 表现事件
|
||||
</Text>
|
||||
</HStack>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{topPerformers.slice(0, 5).map((event, idx) => (
|
||||
<TopEventItem key={event.id || idx} event={event} rank={idx + 1} />
|
||||
))}
|
||||
{topPerformers.length === 0 && (
|
||||
<Text fontSize="xs" color="gray.500" textAlign="center" py={2}>
|
||||
暂无数据
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventEffectivenessStats;
|
||||
Reference in New Issue
Block a user