feat: 添加新闻增量刷新和前端组件修复

- 新增 refresh_news_incremental/refresh_news_for_symbols 函数支持增量新闻获取
- 在 live cycle 中集成新闻刷新逻辑
- AgentFeed 支持 agentProfilesByAgent 显示模型信息
- StatisticsView 修复 stats 计算逻辑,使用 portfolioData 作为 fallback
- StockExplainView 修复 useEffect 依赖项问题
- AppShell/RoomView 传递 agentProfilesByAgent 属性
- start-dev.sh 调整日志级别为 warning 减少噪音

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 10:50:45 +08:00
parent 16bb3c4211
commit 7e7a58769a
9 changed files with 196 additions and 21 deletions

View File

@@ -466,6 +466,7 @@ export default function LiveTradingApp() {
currentDate={currentDate}
stockRequests={stockRequests}
agentRequests={agentRequests}
agentProfilesByAgent={agentProfilesByAgent}
leftWidth={leftWidth}
isResizing={isResizing}
onMouseDown={() => useUIStore.getState().setIsResizing(true)}

View File

@@ -35,14 +35,22 @@ const stripMarkdown = (text) => {
.replace(/^[-=]+$/gm, '');
};
const AgentFeed = forwardRef(({ feed, leaderboard }, ref) => {
const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref) => {
const feedContentRef = useRef(null);
const [highlightedId, setHighlightedId] = useState(null);
const [selectedAgent, setSelectedAgent] = useState('all');
const [dropdownOpen, setDropdownOpen] = useState(false);
const getAgentModelInfo = (agentId) => {
if (!leaderboard || !agentId) return { modelName: null, modelProvider: null };
if (!agentId) return { modelName: null, modelProvider: null };
const profile = agentProfilesByAgent?.[agentId];
if (profile?.model_name) {
return {
modelName: profile.model_name,
modelProvider: profile.model_provider
};
}
if (!leaderboard) return { modelName: null, modelProvider: null };
const agentData = leaderboard.find(lb => lb.id === agentId || lb.agentId === agentId);
return {
modelName: agentData?.modelName,
@@ -52,7 +60,17 @@ const AgentFeed = forwardRef(({ feed, leaderboard }, ref) => {
// Get agent info by name
const getAgentInfoByName = (agentName) => {
if (!leaderboard || !agentName) return null;
if (!agentName) return null;
const agentConfig = AGENTS.find((agent) => agent.name === agentName);
const profile = agentConfig ? agentProfilesByAgent?.[agentConfig.id] : null;
if (agentConfig && profile?.model_name) {
return {
agentId: agentConfig.id,
modelName: profile.model_name,
modelProvider: profile.model_provider
};
}
if (!leaderboard) return null;
const agentData = leaderboard.find(lb => lb.name === agentName || lb.agentName === agentName);
if (!agentData) return null;
return {

View File

@@ -128,6 +128,7 @@ export default function AppShell({
stockRequests,
// Agent request handlers
agentRequests,
agentProfilesByAgent,
// Layout
leftWidth,
isResizing,
@@ -440,6 +441,7 @@ export default function AppShell({
bubbles={bubbles}
bubbleFor={bubbleFor}
leaderboard={leaderboard}
agentProfilesByAgent={agentProfilesByAgent}
feed={feed}
onJumpToMessage={handleJumpToMessage}
onOpenLaunchConfig={() => setIsRuntimeSettingsOpen(true)}
@@ -518,6 +520,7 @@ export default function AppShell({
trades={trades}
holdings={holdings}
stats={stats}
portfolioData={portfolioData}
baseline_vw={portfolioData.baseline_vw}
equity={portfolioData.equity}
leaderboard={leaderboard}
@@ -535,7 +538,7 @@ export default function AppShell({
{/* Right Panel: Agent Feed */}
<div className="right-panel" style={{ width: `${100 - leftWidth}%` }}>
<Suspense fallback={<ViewLoadingFallback label="加载消息流..." />}>
<AgentFeed ref={agentFeedRef} feed={feed} leaderboard={leaderboard} />
<AgentFeed ref={agentFeedRef} feed={feed} leaderboard={leaderboard} agentProfilesByAgent={agentProfilesByAgent} />
</Suspense>
</div>
</div>

View File

@@ -47,7 +47,7 @@ function getRankMedal(rank) {
* Supports click and hover (1.5s) to show agent performance cards
* Supports replay mode - completely independent from live mode
*/
export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJumpToMessage, onOpenLaunchConfig }) {
export default function RoomView({ bubbles, bubbleFor, leaderboard, agentProfilesByAgent, feed, onJumpToMessage, onOpenLaunchConfig }) {
const canvasRef = useRef(null);
const containerRef = useRef(null);
@@ -162,11 +162,14 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJump
const getAgentData = (agentId) => {
const agent = AGENTS.find(a => a.id === agentId);
if (!agent) return null;
const profile = agentProfilesByAgent?.[agentId] || null;
// If no leaderboard data, return agent with default stats
if (!leaderboard || !Array.isArray(leaderboard)) {
return {
...agent,
modelName: profile?.model_name || null,
modelProvider: profile?.model_provider || null,
bull: { n: 0, win: 0, unknown: 0 },
bear: { n: 0, win: 0, unknown: 0 },
winRate: null,
@@ -181,6 +184,8 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJump
if (!leaderboardData) {
return {
...agent,
modelName: profile?.model_name || null,
modelProvider: profile?.model_provider || null,
bull: { n: 0, win: 0, unknown: 0 },
bear: { n: 0, win: 0, unknown: 0 },
winRate: null,
@@ -193,6 +198,8 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJump
return {
...agent,
...leaderboardData,
modelName: profile?.model_name || leaderboardData.modelName || null,
modelProvider: profile?.model_provider || leaderboardData.modelProvider || null,
avatar: agent.avatar // Always use the frontend's avatar URL
};
};

View File

@@ -8,12 +8,36 @@ import { formatNumber, formatDateTime } from '../utils/formatters';
* Left: Performance Overview (35%) | Right: Holdings + Trades (65%)
* No scrolling - content fits within viewport with pagination
*/
export default function StatisticsView({ trades, holdings, stats, baseline_vw, equity, leaderboard }) {
export default function StatisticsView({ trades, holdings, stats, baseline_vw, equity, leaderboard, portfolioData }) {
const [holdingsPage, setHoldingsPage] = useState(1);
const [tradesPage, setTradesPage] = useState(1);
const holdingsPerPage = 5;
const tradesPerPage = 8;
const effectiveStats = React.useMemo(() => {
const base = stats && typeof stats === 'object' ? stats : {};
const netValue = Number(portfolioData?.netValue ?? 0);
const pnl = Number(portfolioData?.pnl ?? 0);
const hasPortfolioValue = Number.isFinite(netValue) && netValue > 0;
const hasMeaningfulStats = Number(base?.totalAssetValue ?? 0) > 0;
if (hasMeaningfulStats || !hasPortfolioValue) {
return base;
}
const cashHolding = Array.isArray(holdings)
? holdings.find((item) => String(item?.ticker || '').toUpperCase() === 'CASH')
: null;
return {
...base,
totalAssetValue: netValue,
totalReturn: pnl,
cashPosition: Number(cashHolding?.marketValue ?? cashHolding?.currentPrice ?? 0),
totalTrades: Array.isArray(trades) ? trades.length : 0,
};
}, [holdings, portfolioData, stats, trades]);
// Calculate pagination for holdings
const totalHoldingsPages = Math.ceil(holdings.length / holdingsPerPage);
const holdingsStartIndex = (holdingsPage - 1) * holdingsPerPage;
@@ -28,12 +52,12 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
// Calculate excess return (Evatraders return - benchmark value-weighted return)
const calculateExcessReturn = () => {
if (!stats || !baseline_vw || baseline_vw.length === 0) {
if (!effectiveStats || !baseline_vw || baseline_vw.length === 0) {
return null;
}
// Get Evatraders return from stats
const evatradersReturn = stats.totalReturn || 0; // Already in percentage
const evatradersReturn = effectiveStats.totalReturn || 0; // Already in percentage
// Calculate benchmark return from baseline_vw
// baseline_vw format: [{t: timestamp, v: value}, ...] or [value, ...]
@@ -130,7 +154,7 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
borderRight: '2px solid #e0e0e0',
overflow: 'hidden'
}}>
{stats ? (
{effectiveStats ? (
<div style={{
padding: '24px',
display: 'flex',
@@ -179,7 +203,7 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
fontFamily: '"Courier New", monospace',
lineHeight: 1
}}>
${formatNumber(stats.totalAssetValue || 0)}
${formatNumber(effectiveStats.totalAssetValue || 0)}
</div>
</div>
@@ -272,10 +296,10 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
<div style={{
fontSize: 28,
fontWeight: 700,
color: (stats.totalReturn || 0) >= 0 ? '#00C853' : '#FF1744',
color: (effectiveStats.totalReturn || 0) >= 0 ? '#00C853' : '#FF1744',
fontFamily: '"Courier New", monospace'
}}>
{(stats.totalReturn || 0) >= 0 ? '+' : ''}{(stats.totalReturn || 0).toFixed(2)}%
{(effectiveStats.totalReturn || 0) >= 0 ? '+' : ''}{(effectiveStats.totalReturn || 0).toFixed(2)}%
</div>
</div>
</div>
@@ -304,7 +328,7 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
color: '#000000',
fontFamily: '"Courier New", monospace'
}}>
${formatNumber(stats.cashPosition || 0)}
${formatNumber(effectiveStats.cashPosition || 0)}
</div>
</div>
@@ -330,7 +354,7 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
color: '#000000',
fontFamily: '"Courier New", monospace'
}}>
{stats.totalTrades || 0}
{effectiveStats.totalTrades || 0}
</div>
</div>
</div>

View File

@@ -175,7 +175,7 @@ export default function StockExplainView({
if (!selectedSymbol || !selectedEventDate || !onRequestNewsForDate) {
return;
}
if (Array.isArray(newsSnapshot?.byDate?.[selectedEventDate]) && newsSnapshot.byDate[selectedEventDate].length > 0) {
if (Object.prototype.hasOwnProperty.call(newsSnapshot?.byDate || {}, selectedEventDate)) {
return;
}
onRequestNewsForDate(selectedSymbol, selectedEventDate);
@@ -185,21 +185,21 @@ export default function StockExplainView({
if (!selectedSymbol || !onRequestStory || !currentDate) {
return;
}
if (selectedStory?.story) {
if (Object.prototype.hasOwnProperty.call(newsSnapshot?.storyCache || {}, currentDate)) {
return;
}
onRequestStory(selectedSymbol, currentDate);
}, [currentDate, onRequestStory, selectedStory, selectedSymbol]);
}, [currentDate, newsSnapshot, onRequestStory, selectedStory, selectedSymbol]);
useEffect(() => {
if (!selectedSymbol || !selectedEventDate || !onRequestSimilarDays) {
return;
}
if (selectedSimilarDays?.items?.length) {
if (Object.prototype.hasOwnProperty.call(newsSnapshot?.similarDaysCache || {}, selectedEventDate)) {
return;
}
onRequestSimilarDays(selectedSymbol, selectedEventDate);
}, [onRequestSimilarDays, selectedEventDate, selectedSimilarDays, selectedSymbol]);
}, [newsSnapshot, onRequestSimilarDays, selectedEventDate, selectedSimilarDays, selectedSymbol]);
useEffect(() => {
if (!selectedSymbol || !onRequestTechnicalIndicators) {