Files
evotraders/frontend/src/components/AgentCard.jsx

595 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import { ASSETS } from '../config/constants';
import { getModelIcon, getShortModelName } from '../utils/modelIcons';
import LobeModelLogo from './LobeModelLogo.jsx';
/**
* Get rank medal/trophy
*/
function getRankMedal(rank) {
if (rank === 1) return { emoji: '🏆', color: '#FFD700', label: '金牌' };
if (rank === 2) return { emoji: '🥈', color: '#C0C0C0', label: '银牌' };
if (rank === 3) return { emoji: '🥉', color: '#CD7F32', label: '铜牌' };
return { emoji: `#${rank}`, color: '#333333', label: `#${rank}` };
}
/**
* Agent Performance Card Component
* Horizontal dropdown panel displayed below the agent indicator bar
*/
export default function AgentCard({ agent, onClose, isClosing }) {
if (!agent) return null;
const bullTotal = agent.bull?.n || 0;
const bullWins = agent.bull?.win || 0;
const bullUnknown = agent.bull?.unknown || 0;
const bearTotal = agent.bear?.n || 0;
const bearWins = agent.bear?.win || 0;
const bearUnknown = agent.bear?.unknown || 0;
const totalSignals = bullTotal + bearTotal;
const evaluatedBull = Math.max(bullTotal - bullUnknown, 0);
const evaluatedBear = Math.max(bearTotal - bearUnknown, 0);
const evaluatedTotal = evaluatedBull + evaluatedBear;
const bullWinRate = evaluatedBull > 0 ? (bullWins / evaluatedBull) : null;
const bearWinRate = evaluatedBear > 0 ? (bearWins / evaluatedBear) : null;
const overallWinRate = agent.winRate != null
? agent.winRate
: (evaluatedTotal > 0 ? ((bullWins + bearWins) / evaluatedTotal) : null);
const overallColor = overallWinRate != null
? (overallWinRate >= 0.5 ? '#00C853' : '#FF1744')
: '#555555';
const rankMedal = agent.rank ? getRankMedal(agent.rank) : null;
const isPortfolioManager = agent.id === 'portfolio_manager';
const isRiskManager = agent.id === 'risk_manager';
const isValuationAnalyst = agent.id === 'valuation_analyst';
const displayName = isPortfolioManager ? '团队' : agent.name;
// Get model icon configuration
const modelInfo = getModelIcon(agent.modelName, agent.modelProvider);
const shortModelName = getShortModelName(agent.modelName);
return (
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
background: '#ffffff',
borderBottom: '2px solid #000000',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
zIndex: 800,
animation: isClosing ? 'slideUp 0.2s ease-out forwards' : 'slideDown 0.25s ease-out'
}}>
{/* Horizontal scrollable content */}
<div style={{
overflowX: 'auto',
overflowY: 'hidden',
padding: '12px',
/* Hide scrollbar for all browsers */
scrollbarWidth: 'none', /* Firefox */
msOverflowStyle: 'none', /* IE and Edge */
}}>
<style>
{`
div::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
`}
</style>
<div style={{
display: 'flex',
gap: '12px',
minWidth: 'max-content'
}}>
{/* Agent Info with Rank */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 12px',
background: '#fafafa',
border: '2px solid #000000',
minWidth: 200
}}>
{isPortfolioManager ? (
<img
src={ASSETS.teamLogo}
alt="Team"
style={{
height: 50,
width: 50,
objectFit: 'contain'
}}
/>
) : agent.avatar ? (
<img
src={agent.avatar}
alt={agent.name}
style={{
height: 50,
width: 50,
objectFit: 'contain'
}}
/>
) : null}
<div>
<div style={{
fontSize: 16,
fontWeight: 700,
color: '#000000',
marginBottom: 2
}}>
{displayName}
</div>
{rankMedal && !isPortfolioManager && (
<div style={{ fontSize: 18 }}>
{rankMedal.emoji} {agent.rank}
</div>
)}
</div>
</div>
{/* Risk Manager Note */}
{isRiskManager && (
<div style={{
padding: '8px 12px',
background: '#FFF9E6',
border: '2px solid #FFA726',
minWidth: 220,
maxWidth: 280,
display: 'flex',
alignItems: 'center'
}}>
<div style={{
fontSize: 12,
color: '#E65100',
fontStyle: 'italic',
lineHeight: 1.5,
whiteSpace: 'normal',
wordWrap: 'break-word'
}}>
风控经理专注于风险管理不参与预测准确率排名
</div>
</div>
)}
{/* Portfolio Manager Note */}
{isPortfolioManager && (
<div style={{
padding: '8px 12px',
background: '#E8F5E9',
border: '2px solid #66BB6A',
minWidth: 220,
maxWidth: 280,
display: 'flex',
alignItems: 'center'
}}>
<div style={{
fontSize: 12,
color: '#2E7D32',
fontStyle: 'italic',
lineHeight: 1.5,
whiteSpace: 'normal',
wordWrap: 'break-word'
}}>
投资经理综合所有分析师建议提供团队最终交易信号不参与排名
</div>
</div>
)}
{/* Model Info Card */}
{agent.modelName && (
<div style={{
padding: '8px 12px',
background: '#ffffff',
border: `2px solid ${modelInfo.color}`,
minWidth: 140,
position: 'relative',
cursor: 'help'
}}
title={`模型:${agent.modelName}\n提供方:${modelInfo.provider}`}>
<div style={{
fontSize: 10,
fontWeight: 700,
color: modelInfo.color,
letterSpacing: 1,
marginBottom: 4,
textTransform: 'uppercase'
}}>
模型
</div>
<div style={{
height: 40,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 4
}}>
{agent.modelName || modelInfo.logoPath ? (
<LobeModelLogo
model={agent.modelName}
provider={agent.modelProvider}
fallbackSrc={modelInfo.logoPath}
alt={modelInfo.provider}
size={36}
type="color"
shape="square"
style={{
maxHeight: '100%',
maxWidth: '100%',
}}
/>
) : (
<div style={{
fontSize: 28,
lineHeight: 1
}}>
🤖
</div>
)}
</div>
<div style={{
fontSize: 11,
fontWeight: 600,
color: modelInfo.color,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
{shortModelName}
</div>
<div style={{
fontSize: 8,
color: '#666666',
marginTop: 2
}}>
{modelInfo.provider}
</div>
</div>
)}
{/* Overall Win Rate */}
{!isRiskManager && !isPortfolioManager && (
<div style={{
padding: '8px 14px',
background: '#fafafa',
border: '2px solid #e0e0e0',
textAlign: 'center',
minWidth: 160
}}>
<div style={{
fontSize: 10,
color: '#333333',
fontWeight: 700,
letterSpacing: 1,
marginBottom: 4,
textTransform: 'uppercase'
}}>
胜率
</div>
<div style={{
fontSize: 36,
fontWeight: 700,
color: overallColor,
fontFamily: '"Courier New", monospace',
lineHeight: 1,
marginBottom: 2
}}>
{overallWinRate != null ? `${(overallWinRate * 100).toFixed(1)}%` : '暂无'}
</div>
<div style={{
fontSize: 9,
color: '#555555'
}}>
{bullWins + bearWins} / {evaluatedTotal}
</div>
<div style={{
fontSize: 8,
color: '#888888',
marginTop: 4,
fontStyle: 'italic',
lineHeight: 1.2,
whiteSpace: 'pre-line'
}}>
评估: 总评估多空信号数{'\n'}胜率 = 正确信号 / 总评估信号
</div>
</div>
)}
{/* Bull Stats */}
{!isRiskManager && !isPortfolioManager && (
<div style={{
padding: '8px 12px',
background: '#F0FFF4',
border: '2px solid #00C853',
minWidth: 140
}}>
<div style={{
fontSize: 10,
fontWeight: 700,
color: '#00C853',
letterSpacing: 1,
marginBottom: 4,
textTransform: 'uppercase'
}}>
牛市胜率
</div>
<div style={{
fontSize: 28,
fontWeight: 700,
color: bullWinRate != null ? (bullWinRate >= 0.5 ? '#00C853' : '#333333') : '#555555',
marginBottom: 2,
lineHeight: 1
}}>
{bullWinRate != null ? `${(bullWinRate * 100).toFixed(1)}%` : '暂无'}
</div>
<div style={{
fontSize: 9,
color: '#333333'
}}>
{bullWins} / {evaluatedBull}
{bullUnknown > 0 && ` / ${bullUnknown}P`}
</div>
</div>
)}
{/* Bear Stats */}
{!isRiskManager && !isPortfolioManager && (
<div style={{
padding: '8px 12px',
background: '#FFF5F5',
border: '2px solid #FF1744',
minWidth: 140
}}>
<div style={{
fontSize: 10,
fontWeight: 700,
color: '#FF1744',
letterSpacing: 1,
marginBottom: 4,
textTransform: 'uppercase'
}}>
熊市胜率
</div>
<div style={{
fontSize: 28,
fontWeight: 700,
color: bearWinRate != null ? (bearWinRate >= 0.5 ? '#00C853' : '#333333') : '#555555',
marginBottom: 2,
lineHeight: 1
}}>
{bearWinRate != null ? `${(bearWinRate * 100).toFixed(1)}%` : '暂无'}
</div>
<div style={{
fontSize: 9,
color: '#333333'
}}>
{bearWins} / {evaluatedBear}
{bearUnknown > 0 && ` / ${bearUnknown}P`}
</div>
</div>
)}
{/* Recent Signals - Horizontal scroll */}
{agent.signals && agent.signals.length > 0 && (
<div style={{
display: 'flex',
gap: 6,
padding: '8px 12px',
background: '#fafafa',
border: '2px solid #e0e0e0'
}}>
{[...agent.signals]
.filter(signal => signal && signal.signal)
.sort((a, b) => {
// Sort by date descending (newest first)
const dateA = a.date || '';
const dateB = b.date || '';
return dateB.localeCompare(dateA);
})
.slice(0, 35)
.map((signal, idx) => {
const signalType = signal.signal.toLowerCase();
const isBull = signalType.includes('bull') || signalType === 'long';
const isBear = signalType.includes('bear') || signalType === 'short';
const isNeutral = (!isBull && !isBear) || signalType.includes('neutral') || signalType === 'hold';
const isCorrect = signal.is_correct === true;
const isUnknown = signal.is_correct === 'unknown' || signal.is_correct === null;
// Determine result symbol/text and color: unknown has priority over neutral
let resultDisplay;
let resultColor = '#555555';
let resultFontSize = 18;
if (isNeutral) {
resultDisplay = '-';
resultColor = '#555555'; // Gray for neutral
} else if (isUnknown) {
resultDisplay = '?';
resultColor = '#FFA726'; // Orange for unknown
resultFontSize = 14; // Smaller font for text
} else {
resultDisplay = isCorrect ? '✓' : '✗';
resultColor = isCorrect ? '#00C853' : '#FF1744'; // Green for correct, Red for wrong
}
return (
<div key={idx} style={{
fontSize: 9,
fontFamily: '"Courier New", monospace',
padding: '6px 8px',
background: '#ffffff',
border: '1px solid #e0e0e0',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 3,
minWidth: 70
}}>
<div style={{
fontWeight: 700,
color: isBull ? '#00C853' : isBear ? '#FF1744' : '#555555'
}}>
{signal.ticker}
</div>
<div style={{
fontSize: 16,
color: isBull ? '#00C853' : isBear ? '#FF1744' : '#555555'
}}>
{isBull ? '看涨' : isBear ? '看跌' : '中性'}
</div>
<div style={{
fontSize: 8,
color: '#555555'
}}>
{signal.date?.substring(5, 10) || '暂无'}
</div>
<div style={{
fontSize: resultFontSize,
fontWeight: 700,
color: resultColor
}}>
{resultDisplay}
</div>
</div>
);
})}
{/* Info card explaining signal display */}
<div style={{
fontSize: 9,
fontFamily: '"Courier New", monospace',
padding: '6px 8px',
background: '#E3F2FD',
border: '1px solid #90CAF9',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
minWidth: 70,
textAlign: 'center'
}}>
<div style={{
fontSize: 10,
fontWeight: 700,
color: '#1976D2'
}}>
说明
</div>
<div style={{
fontSize: 8,
color: '#1976D2',
lineHeight: 1.2
}}>
仅显示最近5个交易日(1)的信号
</div>
</div>
</div>
)}
{/* Valuation Results Card - Only show for valuation_analyst */}
{isValuationAnalyst && agent.signals && agent.signals.length > 0 && (
<div style={{
display: 'flex',
gap: 6,
padding: '8px 12px',
background: '#f5f5f5',
border: '2px solid #7B1FA2'
}}>
<div style={{
fontSize: 10,
fontWeight: 700,
color: '#7B1FA2',
minWidth: 80,
textAlign: 'center'
}}>
估值分析
</div>
{agent.signals
.filter(signal => signal && signal.intrinsic_value != null)
.slice(0, 5)
.map((signal, idx) => {
const fairValue = signal.fair_value_range;
const hasValuation = signal.intrinsic_value || fairValue;
if (!hasValuation) return null;
return (
<div key={idx} style={{
fontSize: 9,
fontFamily: '"Courier New", monospace',
padding: '6px 8px',
background: '#ffffff',
border: '1px solid #7B1FA2',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 2,
minWidth: 90
}}>
<div style={{ fontWeight: 700, color: '#333' }}>
{signal.ticker}
</div>
{signal.intrinsic_value && (
<div style={{ color: '#00C853', fontSize: 10 }}>
内在 ${signal.intrinsic_value.toFixed(2)}
</div>
)}
{signal.value_gap_pct != null && (
<div style={{
color: signal.value_gap_pct > 0 ? '#00C853' : '#FF1744',
fontSize: 9
}}>
{signal.value_gap_pct > 0 ? '+' : ''}{signal.value_gap_pct.toFixed(1)}%
</div>
)}
{fairValue && (
<div style={{ fontSize: 8, color: '#666' }}>
区间 ${fairValue.bear?.toFixed(0) || '?'}-
${fairValue.bull?.toFixed(0) || '?'}
</div>
)}
{signal.valuation_methods && signal.valuation_methods.length > 0 && (
<div style={{ fontSize: 7, color: '#999' }}>
{signal.valuation_methods[0]}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
<style>
{`
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideUp {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-20px);
}
}
`}
</style>
</div>
);
}