Add explain analysis workflow and UI

This commit is contained in:
2026-03-16 22:28:41 +08:00
parent 3a5558b576
commit 1f5ee3698e
49 changed files with 8888 additions and 1476 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,7 @@ export default function WatchlistPanel({
onSave
}) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, position: 'relative' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, position: 'relative', marginLeft: -6 }}>
<button
onClick={onToggle}
style={{
@@ -36,7 +36,7 @@ export default function WatchlistPanel({
whiteSpace: 'nowrap'
}}
>
WATCHLIST
自选股
</button>
{isOpen && (

View File

@@ -0,0 +1,157 @@
import React from 'react';
import { formatDateTime } from '../../utils/formatters';
export default function ExplainEventsSection({
explainTimeline,
isOpen,
onToggle,
availableEventDates,
selectedEventDate,
onSelectEventDate,
eventCategoryCounts,
activeEventCategory,
onSelectEventCategory,
eventCategoryMeta,
visibleExplainEvents,
}) {
return (
<div className="section">
<div className="section-header">
<h2 className="section-title">关键事件时间线</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
图上点击事件点可切换对应日期
</div>
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起关键事件' : `展开关键事件 ${explainTimeline.length}`}
</button>
</div>
</div>
{explainTimeline.length === 0 ? (
<div className="empty-state">当前还没有可以串起来看的关键事件</div>
) : !isOpen ? (
<div className="empty-state">关键事件默认收起需要时再展开查看和筛选</div>
) : (
<div style={{ display: 'grid', gap: 14 }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{availableEventDates.map((dateKey) => {
const isActive = dateKey === selectedEventDate;
return (
<button
key={dateKey}
onClick={() => onSelectEventDate(dateKey)}
style={{
border: '1px solid #111111',
background: isActive ? '#111111' : '#ffffff',
color: isActive ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{dateKey}
</button>
);
})}
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{Object.entries(eventCategoryMeta)
.filter(([key]) => (eventCategoryCounts[key] || 0) > 0 || key === 'all')
.map(([key, meta]) => {
const isActive = key === activeEventCategory;
return (
<button
key={key}
onClick={() => onSelectEventCategory(key)}
style={{
border: `1px solid ${meta.color}`,
background: isActive ? meta.color : '#ffffff',
color: isActive ? '#ffffff' : meta.color,
padding: '8px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{meta.label} {eventCategoryCounts[key] || 0}
</button>
);
})}
</div>
{visibleExplainEvents.length === 0 ? (
<div className="empty-state">当前日期下没有符合筛选条件的事件</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
{visibleExplainEvents.map((event) => {
const accent = event.tone === 'positive' ? '#00C853' : event.tone === 'negative' ? '#FF1744' : '#000000';
const categoryMeta = eventCategoryMeta[event.category] || eventCategoryMeta.other;
return (
<div
key={event.id}
style={{
border: '1px solid #000000',
background: '#ffffff',
padding: 14,
minHeight: 180
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{
display: 'inline-flex',
padding: '2px 6px',
border: `1px solid ${categoryMeta.color}`,
color: categoryMeta.color,
fontSize: 10,
fontWeight: 700
}}>
{categoryMeta.label}
</span>
<strong style={{ fontSize: 13 }}>{event.title}</strong>
</div>
<span style={{ fontSize: 10, color: '#666666', whiteSpace: 'nowrap' }}>
{formatDateTime(event.timestamp)}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<span style={{
width: 8,
height: 8,
borderRadius: '50%',
background: accent
}} />
<span style={{ fontSize: 10, color: '#666666', textTransform: 'uppercase', letterSpacing: 0.6 }}>
{event.meta}
</span>
</div>
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#000000', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{event.body}
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,249 @@
import React from 'react';
function toggleButtonStyle(active, accent = '#111111') {
return {
border: `1px solid ${accent}`,
background: active ? accent : '#ffffff',
color: active ? '#ffffff' : accent,
padding: '6px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
};
}
export default function ExplainMaintenanceSection({
selectedSymbol,
enrichStartDate,
enrichEndDate,
onChangeStartDate,
onChangeEndDate,
forceEnrich,
onToggleForce,
onlyLocalToLlm,
onToggleOnlyLocalToLlm,
rebuildStory,
onToggleRebuildStory,
rebuildSimilarDays,
onToggleRebuildSimilarDays,
isRunning,
onRunEnrich,
maintenanceStatus,
maintenanceHistory,
onSelectHistory,
onReplayHistory,
isOpen,
onToggle,
}) {
const stats = maintenanceStatus?.stats || null;
const summary = stats?.execution_summary || null;
return (
<div className="section">
<div className="section-header">
<h2 className="section-title">分析数据维护</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
当前标的 {selectedSymbol || '-'}
</div>
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起刷新工具' : '展开刷新工具'}
</button>
</div>
</div>
{!isOpen ? (
<div className="empty-state">刷新工具默认收起需要时再展开重新分析数据或查看历史</div>
) : (
<div
style={{
border: '1px solid #000000',
background: '#ffffff',
padding: 14,
display: 'grid',
gap: 14,
}}
>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
<label style={{ display: 'grid', gap: 6, fontSize: 11, fontWeight: 700 }}>
开始日期
<input type="date" value={enrichStartDate} onChange={(e) => onChangeStartDate(e.target.value)} style={{ border: '1px solid #111111', padding: '8px 10px', fontFamily: 'inherit' }} />
</label>
<label style={{ display: 'grid', gap: 6, fontSize: 11, fontWeight: 700 }}>
结束日期
<input type="date" value={enrichEndDate} onChange={(e) => onChangeEndDate(e.target.value)} style={{ border: '1px solid #111111', padding: '8px 10px', fontFamily: 'inherit' }} />
</label>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button onClick={onToggleForce} style={toggleButtonStyle(forceEnrich, '#b91c1c')}>
{forceEnrich ? '覆盖已有分析' : '仅补缺失'}
</button>
<button onClick={onToggleOnlyLocalToLlm} style={toggleButtonStyle(onlyLocalToLlm, '#7c3aed')}>
{onlyLocalToLlm ? '仅将规则分析升级为 LLM分析' : '不限制分析来源'}
</button>
<button onClick={onToggleRebuildStory} style={toggleButtonStyle(rebuildStory, '#2563eb')}>
{rebuildStory ? '重建主线叙事' : '跳过主线叙事'}
</button>
<button onClick={onToggleRebuildSimilarDays} style={toggleButtonStyle(rebuildSimilarDays, '#15803d')}>
{rebuildSimilarDays ? '重建相似交易日' : '跳过相似交易日'}
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<button
onClick={onRunEnrich}
disabled={isRunning || !selectedSymbol || !enrichStartDate || !enrichEndDate}
style={{
border: '1px solid #111111',
background: isRunning ? '#d1d5db' : '#111111',
color: '#ffffff',
padding: '9px 14px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: isRunning ? 'wait' : 'pointer'
}}
>
{isRunning ? '执行中...' : '重新分析当前区间'}
</button>
{maintenanceStatus?.updatedAt ? (
<span style={{ fontSize: 11, color: '#666666' }}>
最近一次执行: {maintenanceStatus.updatedAt}
</span>
) : null}
</div>
{maintenanceStatus?.error ? (
<div style={{ fontSize: 11, color: '#991b1b', lineHeight: 1.7 }}>
执行失败: {maintenanceStatus.error}
</div>
) : null}
{stats ? (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 10 }}>
{[
['新闻总数', stats.news_count],
['待处理', stats.queued_count],
['已分析', stats.analyzed],
['已跳过', stats.skipped_existing_count],
['去重数', stats.deduped_count],
['LLM分析', stats.llm_count],
['规则分析', stats.local_count],
['升级数', stats.upgraded_local_to_llm_count],
].map(([label, value]) => (
<div key={label} style={{ border: '1px solid #111111', padding: 10 }}>
<div style={{ fontSize: 10, color: '#666666' }}>{label}</div>
<div style={{ fontSize: 18, fontWeight: 700 }}>{value ?? '-'}</div>
</div>
))}
</div>
{summary ? (
<div style={{ border: '1px solid #111111', padding: 12, fontSize: 11, lineHeight: 1.8 }}>
{summary.upgraded_dates?.length ? (
<div><strong>升级日期:</strong> {summary.upgraded_dates.join(', ')}</div>
) : null}
{summary.remaining_local_titles?.length ? (
<div><strong>仍为规则分析:</strong> {summary.remaining_local_titles.join(' / ')}</div>
) : null}
{typeof summary.skipped_non_local_count === 'number' ? (
<div><strong>跳过非规则分析:</strong> {summary.skipped_non_local_count}</div>
) : null}
{typeof summary.skipped_missing_analysis_count === 'number' ? (
<div><strong>跳过无历史分析:</strong> {summary.skipped_missing_analysis_count}</div>
) : null}
</div>
) : null}
</>
) : null}
{Array.isArray(maintenanceHistory) && maintenanceHistory.length > 0 ? (
<div style={{ border: '1px solid #111111', padding: 12, display: 'grid', gap: 8 }}>
<div style={{ fontSize: 11, fontWeight: 700 }}>最近刷新历史</div>
{maintenanceHistory.slice(0, 5).map((item, index) => (
<div
key={`${item.timestamp || 'history'}-${index}`}
style={{
borderTop: index === 0 ? 'none' : '1px solid #e5e7eb',
paddingTop: index === 0 ? 0 : 8,
fontSize: 11,
lineHeight: 1.8,
}}
>
<div>
<strong>{item.startDate || '-'}</strong> ~ <strong>{item.endDate || '-'}</strong>
{' · '}
{item.onlyLocalToLlm ? '规则分析→LLM分析' : item.force ? '覆盖重跑' : '补缺失'}
{item.storyStatus ? ' · 主线叙事' : ''}
{item.similarStatus ? ' · 相似交易日' : ''}
</div>
<div style={{ color: item.error ? '#991b1b' : '#4b5563' }}>
{item.timestamp || '-'}
{item.error
? ` · 失败: ${item.error}`
: ` · 已分析 ${item.stats?.analyzed ?? 0},已升级 ${item.stats?.upgraded_local_to_llm_count ?? 0}`}
</div>
<div style={{ marginTop: 4 }}>
<button
onClick={() => onSelectHistory?.(item)}
style={{
border: '1px solid #111111',
background: '#ffffff',
color: '#111111',
padding: '4px 8px',
fontFamily: 'inherit',
fontSize: 10,
fontWeight: 700,
cursor: 'pointer'
}}
>
回填到表单
</button>
<button
onClick={() => onReplayHistory?.(item)}
style={{
marginLeft: 8,
border: '1px solid #111111',
background: '#111111',
color: '#ffffff',
padding: '4px 8px',
fontFamily: 'inherit',
fontSize: 10,
fontWeight: 700,
cursor: 'pointer'
}}
>
直接重跑
</button>
{!item.error ? (
<span style={{ marginLeft: 8, fontSize: 10, color: '#666666' }}>
{item.stats?.execution_summary?.upgraded_dates?.length
? `升级日 ${item.stats.execution_summary.upgraded_dates.join(', ')}`
: '无升级日期摘要'}
</span>
) : null}
</div>
</div>
))}
</div>
) : null}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,77 @@
import React from 'react';
import { formatDateTime } from '../../utils/formatters';
export default function ExplainMentionsSection({
recentMentions,
isOpen,
onToggle,
}) {
return (
<div className="section">
<div className="section-header">
<h2 className="section-title">讨论提及</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
从交易讨论和分析 feed 提取
</div>
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起讨论摘录' : `展开讨论摘录 ${recentMentions.length}`}
</button>
</div>
</div>
{recentMentions.length === 0 ? (
<div className="empty-state">最近没有在讨论里提到这只股票</div>
) : !isOpen ? (
<div className="empty-state">讨论摘录默认收起需要时再展开查看</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
{recentMentions.map((message, index) => (
<div
key={`${message.feedId || message.id}-${index}`}
style={{
border: '1px solid #000000',
background: '#fafafa',
padding: 14,
minHeight: 150
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 10 }}>
<div>
<div style={{ fontWeight: 700, color: '#000000' }}>{message.agent || '未知角色'}</div>
<div style={{ fontSize: 10, color: '#666666' }}>
{message.conferenceTitle || (message.feedType === 'conference' ? '投资讨论' : '即时消息')}
</div>
</div>
<div style={{ fontSize: 10, color: '#666666', whiteSpace: 'nowrap' }}>
{formatDateTime(message.timestamp)}
</div>
</div>
<div style={{
fontSize: 12,
lineHeight: 1.7,
color: '#000000',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}>
{String(message.content || '')}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,308 @@
import React from 'react';
import { formatDateTime } from '../../utils/formatters';
function categoryLabel(value) {
const normalized = String(value || '').trim().toLowerCase();
const labels = {
market: '市场交易',
policy: '政策监管',
earnings: '业绩财报',
product_tech: '产品技术',
competition: '竞争格局',
management: '管理层动态',
};
return labels[normalized] || value || '';
}
function relevanceLabel(value) {
const normalized = String(value || '').trim().toLowerCase();
const labels = {
high: '高相关',
medium: '中相关',
low: '低相关',
relevant: '高相关',
};
return labels[normalized] || value || '';
}
function analysisSourceLabel(value) {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'llm') return 'LLM分析';
if (normalized === 'local') return '规则分析';
return value || '';
}
function sentimentStyle(sentiment) {
const normalized = String(sentiment || '').trim().toLowerCase();
if (normalized === 'positive') {
return { border: '#16a34a', background: '#f0fdf4', color: '#166534', label: '利多' };
}
if (normalized === 'negative') {
return { border: '#dc2626', background: '#fef2f2', color: '#991b1b', label: '利空' };
}
return { border: '#6b7280', background: '#f9fafb', color: '#4b5563', label: '中性' };
}
export default function ExplainNewsSection({
newsSnapshot,
visibleNewsByCategory,
visibleNews,
activeNewsCategory,
onSelectNewsCategory,
activeNewsSentiment,
onSelectNewsSentiment,
newsCategories,
tickerNews,
isOpen,
onToggle,
}) {
return (
<div className="section">
<div className="section-header">
<h2 className="section-title">新闻面板</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
{newsSnapshot?.source ? `最近 ${visibleNewsByCategory.length} 条 · ${newsSnapshot.source}` : `最近 ${visibleNewsByCategory.length} 条真实新闻`}
</div>
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起新闻面板' : '展开新闻面板'}
</button>
</div>
</div>
{!isOpen ? (
<div className="empty-state">新闻面板已收起需要时再展开查看分类情绪和新闻卡片</div>
) : (
<>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 14 }}>
<button
onClick={() => onSelectNewsCategory('all')}
style={{
border: '1px solid #111111',
background: activeNewsCategory === 'all' ? '#111111' : '#ffffff',
color: activeNewsCategory === 'all' ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
全部 {visibleNews.length}
</button>
{Object.entries(newsCategories)
.filter(([, meta]) => Number(meta?.count || 0) > 0)
.map(([key, meta]) => {
const isActive = activeNewsCategory === key;
const pos = Number(meta?.positive_ids?.length || 0);
const neg = Number(meta?.negative_ids?.length || 0);
return (
<button
key={key}
onClick={() => onSelectNewsCategory(key)}
style={{
border: '1px solid #2563eb',
background: isActive ? '#2563eb' : '#ffffff',
color: isActive ? '#ffffff' : '#2563eb',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{categoryLabel(meta.label || key)} {meta.count}{pos || neg ? ` · +${pos}/-${neg}` : ''}
</button>
);
})}
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 14 }}>
{[
{ key: 'all', label: '全部情绪' },
{ key: 'positive', label: '利多' },
{ key: 'negative', label: '利空' },
{ key: 'neutral', label: '中性' }
].map((item) => {
const isActive = activeNewsSentiment === item.key;
return (
<button
key={item.key}
onClick={() => onSelectNewsSentiment(item.key)}
style={{
border: '1px solid #111111',
background: isActive ? '#111111' : '#ffffff',
color: isActive ? '#ffffff' : '#111111',
padding: '6px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{item.label}
</button>
);
})}
</div>
{tickerNews.length === 0 ? (
<div className="empty-state">当前数据源没有返回相关新闻</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
{visibleNewsByCategory.map((item) => (
<div
key={item.id}
style={{
border: '1px solid #000000',
background: '#ffffff',
padding: 14,
minHeight: 180
}}
>
{(() => {
const sentimentMeta = sentimentStyle(item.sentiment);
return (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 10 }}>
<span style={{
display: 'inline-flex',
padding: '2px 6px',
border: `1px solid ${sentimentMeta.border}`,
background: sentimentMeta.background,
color: sentimentMeta.color,
fontSize: 10,
fontWeight: 700
}}>
{sentimentMeta.label}
</span>
{item.relevance ? (
<span style={{
display: 'inline-flex',
padding: '2px 6px',
border: '1px solid #111111',
color: '#111111',
fontSize: 10,
fontWeight: 700
}}>
{relevanceLabel(item.relevance)}
</span>
) : null}
{item.analysisSource ? (
<span style={{
display: 'inline-flex',
padding: '2px 6px',
border: '1px solid #6b7280',
color: '#4b5563',
fontSize: 10,
fontWeight: 700
}}>
{analysisSourceLabel(item.analysisSource)}
</span>
) : null}
{item.analysisModelLabel ? (
<span style={{
display: 'inline-flex',
padding: '2px 6px',
border: '1px solid #9ca3af',
color: '#374151',
fontSize: 10,
fontWeight: 700
}}>
{item.analysisModelLabel}
</span>
) : null}
{typeof item.retT0 === 'number' ? (
<span style={{ fontSize: 10, color: item.retT0 >= 0 ? '#15803d' : '#b91c1c', fontWeight: 700 }}>
T0 {item.retT0 >= 0 ? '+' : ''}{(item.retT0 * 100).toFixed(2)}%
</span>
) : null}
</div>
);
})()}
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{item.category ? (
<span style={{
display: 'inline-flex',
padding: '2px 6px',
border: '1px solid #111111',
color: '#111111',
fontSize: 10,
fontWeight: 700
}}>
{categoryLabel(item.category)}
</span>
) : null}
<strong style={{ fontSize: 13 }}>{item.title}</strong>
</div>
<span style={{ fontSize: 10, color: '#666666', whiteSpace: 'nowrap' }}>
{formatDateTime(item.date)}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
<span style={{ fontSize: 10, color: '#666666', textTransform: 'uppercase', letterSpacing: 0.6 }}>
{item.source}
</span>
{item.related ? (
<span style={{ fontSize: 10, color: '#666666' }}>
关联: {item.related}
</span>
) : null}
</div>
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#000000', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{item.summary || '该新闻没有可用摘要。'}
</div>
{item.keyDiscussion ? (
<div style={{ marginTop: 10, fontSize: 11, lineHeight: 1.7, color: '#374151' }}>
<strong>核心讨论:</strong> {item.keyDiscussion}
</div>
) : null}
{item.reasonGrowth ? (
<div style={{ marginTop: 8, fontSize: 11, lineHeight: 1.7, color: '#166534' }}>
<strong>利多逻辑:</strong> {item.reasonGrowth}
</div>
) : null}
{item.reasonDecrease ? (
<div style={{ marginTop: 8, fontSize: 11, lineHeight: 1.7, color: '#991b1b' }}>
<strong>利空逻辑:</strong> {item.reasonDecrease}
</div>
) : null}
{item.url ? (
<div style={{ marginTop: 12 }}>
<a
href={item.url}
target="_blank"
rel="noreferrer"
style={{ fontSize: 11, fontWeight: 700, color: '#111111', textDecoration: 'underline' }}
>
查看原文
</a>
</div>
) : null}
</div>
))}
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,155 @@
import React from 'react';
import { formatTickerPrice } from '../../utils/formatters';
export default function ExplainPriceSection({
ohlcSeries,
priceSeries,
selectedHistorySource,
chartModel,
selectedTicker,
onSelectEventDate,
isOpen,
onToggle,
}) {
return (
<div className="section">
<div className="section-header">
<h2 className="section-title">价格与事件</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
{ohlcSeries.length > 1
? `最近 ${ohlcSeries.length} 根日线K线${selectedHistorySource ? ` · ${selectedHistorySource}` : ''}`
: `最近 ${priceSeries.length} 个价格点聚合为 ${chartModel.bucketCount || 0} 根简化K线`}
</div>
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起价格区' : '展开价格区'}
</button>
</div>
</div>
{ohlcSeries.length === 0 && priceSeries.length === 0 ? (
<div className="empty-state">当前还没有可绘制的价格历史</div>
) : !isOpen ? (
<div className="empty-state">价格区已收起需要时再展开查看图表和事件点</div>
) : (
<div style={{ border: '1px solid #000000', background: '#ffffff', padding: 16 }}>
<svg
viewBox={`0 0 ${chartModel.width} ${chartModel.height}`}
style={{ width: '100%', height: '220px', display: 'block', overflow: 'visible' }}
>
<defs>
<linearGradient id="stockExplainFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="rgba(0,0,0,0.18)" />
<stop offset="100%" stopColor="rgba(0,0,0,0.02)" />
</linearGradient>
</defs>
<rect x="0" y="0" width={chartModel.width} height={chartModel.height} fill="#fafafa" />
<line
x1={chartModel.padding}
y1={chartModel.height - chartModel.padding}
x2={chartModel.width - chartModel.padding}
y2={chartModel.height - chartModel.padding}
stroke="#000000"
strokeWidth="1"
/>
{chartModel.candles.length > 1 ? chartModel.candles.map((candle) => {
const rising = candle.close >= candle.open;
const stroke = rising ? '#00C853' : '#FF1744';
const fill = rising ? 'rgba(0, 200, 83, 0.16)' : 'rgba(255, 23, 68, 0.16)';
return (
<g key={candle.id}>
<line
x1={candle.centerX}
y1={candle.highY}
x2={candle.centerX}
y2={candle.lowY}
stroke={stroke}
strokeWidth="1.4"
/>
<rect
x={candle.x}
y={candle.bodyY}
width={candle.width}
height={candle.bodyHeight}
fill={fill}
stroke={stroke}
strokeWidth="1.4"
/>
</g>
);
}) : chartModel.path && (
<>
<path d={`${chartModel.path} L${chartModel.width - chartModel.padding},${chartModel.height - chartModel.padding} L${chartModel.padding},${chartModel.height - chartModel.padding} Z`} fill="url(#stockExplainFill)" />
<path d={chartModel.path} fill="none" stroke="#000000" strokeWidth="2.5" />
</>
)}
{chartModel.markers.map((marker) => {
const fill = marker.tone === 'positive'
? '#00C853'
: marker.tone === 'negative'
? '#FF1744'
: marker.tone === 'news'
? '#2563eb'
: '#000000';
return (
<g
key={marker.id}
onClick={() => onSelectEventDate(marker.dateKey)}
style={{ cursor: 'pointer' }}
>
<line x1={marker.x} y1={marker.y} x2={marker.x} y2={chartModel.height - chartModel.padding} stroke={fill} strokeDasharray="3 3" strokeWidth="1" />
<circle
cx={marker.x}
cy={marker.y}
r={marker.markerType === 'news'
? (marker.isSelected ? '5.5' : '4')
: (marker.isSelected ? '6' : '4.5')}
fill={fill}
stroke={marker.isSelected ? '#111111' : '#ffffff'}
strokeWidth={marker.isSelected ? '2.5' : '2'}
/>
<title>{`${marker.title} · ${marker.dateKey || ''}${marker.count ? ` · ${marker.count} 条新闻` : ''}`}</title>
</g>
);
})}
<text x={chartModel.padding} y="14" fontSize="11" fill="#666666">
{chartModel.maxPrice != null ? `高点 $${formatTickerPrice(chartModel.maxPrice)}` : ''}
</text>
<text x={chartModel.padding} y={chartModel.height - 6} fontSize="11" fill="#666666">
{chartModel.minPrice != null ? `低点 $${formatTickerPrice(chartModel.minPrice)}` : ''}
</text>
<text x={chartModel.width - chartModel.padding} y="14" fontSize="11" fill="#666666" textAnchor="end">
{selectedTicker?.price != null ? `现价 $${formatTickerPrice(selectedTicker.price)}` : ''}
</text>
</svg>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginTop: 12 }}>
<div style={{ fontSize: 11, color: '#666666' }}>
图表说明{ohlcSeries.length > 1 ? '历史日线K线' : '基于盘中价格点聚合的简化K线'}
</div>
<div style={{ fontSize: 11, color: '#2563eb' }}>蓝点新闻日期</div>
<div style={{ fontSize: 11, color: '#666666' }}>黑点讨论提及</div>
<div style={{ fontSize: 11, color: '#00C853' }}>绿点偏多信号或做多成交</div>
<div style={{ fontSize: 11, color: '#FF1744' }}>红点偏空信号或做空成交</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,220 @@
import React from 'react';
import { formatTickerPrice } from '../../utils/formatters';
function renderSentimentLabel(value) {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'positive') return '利多';
if (normalized === 'negative') return '利空';
if (normalized === 'neutral') return '中性';
return value || '';
}
function renderCategoryLabel(value) {
const normalized = String(value || '').trim().toLowerCase();
const labels = {
market: '市场交易',
policy: '政策监管',
earnings: '业绩财报',
product_tech: '产品技术',
competition: '竞争格局',
management: '管理层动态',
};
return labels[normalized] || value || '';
}
function renderAnalysisSourceLabel(value) {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'llm') return 'LLM分析';
if (normalized === 'local') return '规则分析';
return value || '';
}
function MetricRow({ label, value, valueColor = '#111111' }) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, gap: 12 }}>
<span style={{ color: '#4b5563' }}>{label}</span>
<strong style={{ color: valueColor, textAlign: 'right' }}>{value}</strong>
</div>
);
}
function TagList({ items, tone = 'neutral', emptyText }) {
const palette = {
positive: { border: '#86efac', background: '#f0fdf4', color: '#166534' },
negative: { border: '#fca5a5', background: '#fef2f2', color: '#991b1b' },
neutral: { border: '#d1d5db', background: '#f9fafb', color: '#374151' },
};
const colors = palette[tone] || palette.neutral;
if (!Array.isArray(items) || items.length === 0) {
return <div style={{ fontSize: 12, lineHeight: 1.7, color: '#6b7280' }}>{emptyText}</div>;
}
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{items.map((item, index) => (
<div
key={`${tone}-${index}-${item}`}
style={{
border: `1px solid ${colors.border}`,
background: colors.background,
color: colors.color,
padding: '6px 10px',
fontSize: 11,
lineHeight: 1.6,
}}
>
{item}
</div>
))}
</div>
);
}
export default function ExplainRangeSection({
selectedRangeWindow,
selectedRangeExplain,
isOpen,
onToggle,
}) {
return (
<div className="section">
<div className="section-header">
<h2 className="section-title">区间涨跌分析</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
{selectedRangeWindow
? `${selectedRangeWindow.startDate} ~ ${selectedRangeWindow.endDate}`
: '先在图上选择一个事件日期'}
</div>
{selectedRangeExplain?.analysis?.analysis_source ? (
<div style={{ fontSize: 11, color: '#666666' }}>
{selectedRangeExplain.analysis.analysis_source === 'llm'
? `分析来源 · ${renderAnalysisSourceLabel(selectedRangeExplain.analysis.analysis_source)} · ${selectedRangeExplain.analysis.analysis_model_label || 'LLM'}`
: `分析来源 · ${renderAnalysisSourceLabel(selectedRangeExplain.analysis.analysis_source)}`}
</div>
) : null}
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起区间涨跌分析' : '展开区间涨跌分析'}
</button>
</div>
</div>
{!selectedRangeWindow ? (
<div className="empty-state">选择图上的日期后会自动生成最近 7 天的区间涨跌分析</div>
) : !isOpen ? (
<div className="empty-state">区间涨跌分析已收起需要时再展开查看摘要和快照</div>
) : !selectedRangeExplain ? (
<div className="empty-state">正在生成区间涨跌分析...</div>
) : selectedRangeExplain.error ? (
<div className="empty-state">{selectedRangeExplain.error}</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: 16 }}>
<div style={{ border: '1px solid #000000', background: '#ffffff', padding: 16 }}>
<div style={{ fontSize: 11, color: '#666666', marginBottom: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
区间摘要
</div>
<div style={{ fontSize: 13, lineHeight: 1.8 }}>
{selectedRangeExplain.analysis?.summary || '暂无区间摘要'}
</div>
{selectedRangeExplain.analysis?.trend_analysis ? (
<div style={{ marginTop: 10, fontSize: 12, lineHeight: 1.7, color: '#4b5563' }}>
<strong>趋势拆解:</strong> {selectedRangeExplain.analysis.trend_analysis}
</div>
) : null}
<div style={{ marginTop: 14, display: 'grid', gap: 8 }}>
{(selectedRangeExplain.analysis?.key_events || []).slice(0, 6).map((event, index) => (
<div key={`${event.id || event.title}-${index}`} style={{ borderTop: index === 0 ? 'none' : '1px solid #e5e7eb', paddingTop: index === 0 ? 0 : 8 }}>
<div style={{ fontSize: 11, color: '#666666', marginBottom: 4 }}>
{event.date || '-'} {event.category ? `· ${renderCategoryLabel(event.category)}` : ''} {event.sentiment ? `· ${renderSentimentLabel(event.sentiment)}` : ''}
</div>
<div style={{ fontSize: 12, fontWeight: 700, marginBottom: 4 }}>{event.title}</div>
<div style={{ fontSize: 12, lineHeight: 1.6 }}>{event.summary || '暂无摘要'}</div>
</div>
))}
</div>
</div>
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 16 }}>
<div style={{ fontSize: 11, color: '#666666', marginBottom: 12, textTransform: 'uppercase', letterSpacing: 1 }}>
区间快照
</div>
<div style={{ display: 'grid', gap: 10 }}>
<div style={{ border: '1px solid #e5e7eb', background: '#ffffff', padding: 12, display: 'grid', gap: 10 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#374151' }}>事实概览</div>
<MetricRow
label="区间涨跌"
value={`${Number(selectedRangeExplain.price_change_pct) >= 0 ? '+' : ''}${Number(selectedRangeExplain.price_change_pct || 0).toFixed(2)}%`}
valueColor={Number(selectedRangeExplain.price_change_pct) >= 0 ? '#00C853' : '#FF1744'}
/>
<MetricRow label="关联新闻" value={selectedRangeExplain.news_count || 0} />
<MetricRow label="区间高点" value={`$${formatTickerPrice(selectedRangeExplain.high_price)}`} />
<MetricRow label="区间低点" value={`$${formatTickerPrice(selectedRangeExplain.low_price)}`} />
<MetricRow label="交易日数" value={selectedRangeExplain.trading_days || 0} />
</div>
<div style={{ border: '1px solid #e5e7eb', background: '#ffffff', padding: 12, display: 'grid', gap: 10 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#374151' }}>主题分布</div>
{(selectedRangeExplain.dominant_categories || []).length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{selectedRangeExplain.dominant_categories.map((item) => (
<div
key={`${item.category}-${item.count}`}
style={{
border: '1px solid #d1d5db',
background: '#f9fafb',
color: '#374151',
padding: '6px 10px',
fontSize: 11,
lineHeight: 1.5,
}}
>
{renderCategoryLabel(item.category)} · {item.count}
</div>
))}
</div>
) : (
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#6b7280' }}>
当前没有识别出明显的主题聚类
</div>
)}
</div>
<div style={{ border: '1px solid #e5e7eb', background: '#ffffff', padding: 12, display: 'grid', gap: 10 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#374151' }}>驱动因素</div>
<div style={{ display: 'grid', gap: 8 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#166534' }}>利多因素</div>
<TagList
items={selectedRangeExplain.analysis?.bullish_factors || []}
tone="positive"
emptyText="当前区间内未提炼出明确的利多因素。"
/>
</div>
<div style={{ display: 'grid', gap: 8 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#991b1b' }}>利空因素</div>
<TagList
items={selectedRangeExplain.analysis?.bearish_factors || []}
tone="negative"
emptyText="当前区间内未提炼出明确的利空因素。"
/>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,123 @@
import React from 'react';
export default function ExplainSignalsSection({
tickerSignals,
signalSummary,
latestSignal,
eventDateKey,
isOpen,
onToggle,
}) {
return (
<div className="section">
<div className="section-header">
<h2 className="section-title">分析师观点</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
最近 {tickerSignals.length} 条相关信号
</div>
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起分析师观点' : '展开分析师观点'}
</button>
</div>
</div>
{!isOpen ? (
<div className="empty-state">分析师观点已收起需要时再展开查看信号统计和明细</div>
) : (
<>
<div className="stats-grid" style={{ marginBottom: 16 }}>
<div className="stat-card">
<div className="stat-card-label">看涨</div>
<div className="stat-card-value positive">{signalSummary.bullish}</div>
</div>
<div className="stat-card">
<div className="stat-card-label">看跌</div>
<div className="stat-card-value negative">{signalSummary.bearish}</div>
</div>
<div className="stat-card">
<div className="stat-card-label">中性</div>
<div className="stat-card-value">{signalSummary.neutral}</div>
</div>
<div className="stat-card">
<div className="stat-card-label">最新结论</div>
<div className="stat-card-value" style={{ fontSize: 22 }}>
{latestSignal
? latestSignal.normalizedDirection === 'bullish'
? '偏多'
: latestSignal.normalizedDirection === 'bearish'
? '偏空'
: '观望'
: '暂无'}
</div>
<div style={{ marginTop: 8, fontSize: 11, color: '#666666' }}>
{latestSignal ? `${latestSignal.agentName} · ${latestSignal.date || eventDateKey(latestSignal.timestamp)}` : '还没有历史信号'}
</div>
</div>
</div>
{tickerSignals.length === 0 ? (
<div className="empty-state">该股票还没有分析师信号记录</div>
) : (
<div className="table-wrapper">
<table className="data-table">
<thead>
<tr>
<th>日期</th>
<th>分析师</th>
<th>方向</th>
<th>实际收益</th>
<th>结果</th>
</tr>
</thead>
<tbody>
{tickerSignals.slice(0, 8).map((signal, index) => {
const realReturn = typeof signal.real_return === 'number'
? `${signal.real_return >= 0 ? '+' : ''}${(signal.real_return * 100).toFixed(2)}%`
: '未判定';
const status = signal.is_correct === true ? '命中' : signal.is_correct === false ? '未命中' : '待判定';
const directionText = signal.normalizedDirection === 'bullish'
? '看涨'
: signal.normalizedDirection === 'bearish'
? '看跌'
: '中性';
const directionColor = signal.normalizedDirection === 'bullish'
? '#00C853'
: signal.normalizedDirection === 'bearish'
? '#FF1744'
: '#666666';
return (
<tr key={signal.id || `${signal.agentId}-${signal.date}-${index}`}>
<td>{signal.date || eventDateKey(signal.timestamp) || '-'}</td>
<td>
<div style={{ fontWeight: 700 }}>{signal.agentName}</div>
<div style={{ fontSize: 10, color: '#666666' }}>{signal.role}</div>
</td>
<td style={{ color: directionColor, fontWeight: 700 }}>{directionText}</td>
<td>{realReturn}</td>
<td>{status}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,111 @@
import React from 'react';
export default function ExplainSimilarDaysSection({
selectedSimilarDays,
selectedEventDate,
onSelectSimilarDate,
isOpen,
onToggle,
}) {
return (
<div className="section">
<div className="section-header">
<h2 className="section-title">历史相似交易日</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
{selectedEventDate || '先选择一个事件日期'}
</div>
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起相似交易日' : '展开相似交易日'}
</button>
</div>
</div>
{!selectedEventDate ? (
<div className="empty-state">选择图上的日期后会检索这只股票历史上的相似交易日</div>
) : !isOpen ? (
<div className="empty-state">相似交易日默认收起需要时再展开查看</div>
) : !selectedSimilarDays ? (
<div className="empty-state">正在检索相似交易日...</div>
) : selectedSimilarDays.error ? (
<div className="empty-state">{selectedSimilarDays.error}</div>
) : !Array.isArray(selectedSimilarDays.items) || selectedSimilarDays.items.length === 0 ? (
<div className="empty-state">当前没有足够历史样本来计算相似交易日</div>
) : (
<div style={{ display: 'grid', gap: 16 }}>
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 16 }}>
<div style={{ fontSize: 11, color: '#666666', marginBottom: 12, textTransform: 'uppercase', letterSpacing: 1 }}>
目标日快照
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12 }}>
<div style={{ fontSize: 12 }}>
<div style={{ color: '#666666', marginBottom: 4 }}>新闻数量</div>
<strong>{selectedSimilarDays.target_features?.n_articles ?? 0}</strong>
</div>
<div style={{ fontSize: 12 }}>
<div style={{ color: '#666666', marginBottom: 4 }}>情绪分数</div>
<strong>{Number(selectedSimilarDays.target_features?.sentiment_score ?? 0).toFixed(2)}</strong>
</div>
<div style={{ fontSize: 12 }}>
<div style={{ color: '#666666', marginBottom: 4 }}>前一日涨跌</div>
<strong>{Number(selectedSimilarDays.target_features?.ret_1d ?? 0).toFixed(2)}%</strong>
</div>
<div style={{ fontSize: 12 }}>
<div style={{ color: '#666666', marginBottom: 4 }}>高相关新闻</div>
<strong>{selectedSimilarDays.target_features?.high_relevance_count ?? 0}</strong>
</div>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 16 }}>
{selectedSimilarDays.items.map((item) => (
<button
key={item.date}
onClick={() => onSelectSimilarDate?.(item.date)}
style={{
border: '1px solid #000000',
background: '#ffffff',
padding: 14,
textAlign: 'left',
cursor: 'pointer',
fontFamily: 'inherit'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, marginBottom: 8 }}>
<strong style={{ fontSize: 13 }}>{item.date}</strong>
<span style={{ fontSize: 11, color: '#666666' }}>
相似度 {(Number(item.score || 0) * 100).toFixed(0)}%
</span>
</div>
<div style={{ display: 'grid', gap: 6, fontSize: 12, marginBottom: 10 }}>
<div>新闻数 {item.n_articles ?? 0}</div>
<div>情绪分数 {Number(item.sentiment_score ?? 0).toFixed(2)}</div>
<div>前一日涨跌 {Number(item.ret_1d ?? 0).toFixed(2)}%</div>
<div>次日表现 {item.ret_t1_after != null ? `${item.ret_t1_after >= 0 ? '+' : ''}${Number(item.ret_t1_after).toFixed(2)}%` : '-'}</div>
<div>三日表现 {item.ret_t3_after != null ? `${item.ret_t3_after >= 0 ? '+' : ''}${Number(item.ret_t3_after).toFixed(2)}%` : '-'}</div>
</div>
{(item.top_reasons || []).length > 0 ? (
<div style={{ fontSize: 11, lineHeight: 1.7, color: '#4b5563' }}>
<strong>主要线索:</strong> {item.top_reasons.join(' / ')}
</div>
) : null}
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,58 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
export default function ExplainStorySection({
selectedStory,
selectedSymbol,
currentDate,
isOpen,
onToggle,
}) {
return (
<div className="section">
<div className="section-header">
<h2 className="section-title">主线叙事</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
{selectedStory?.asOfDate || currentDate || '按当前解释窗口生成'}
</div>
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起主线叙事' : '展开主线叙事'}
</button>
</div>
</div>
{!selectedSymbol ? (
<div className="empty-state">先选择一只股票</div>
) : !isOpen ? (
<div className="empty-state">主线叙事默认收起需要时再展开查看完整叙事</div>
) : !selectedStory?.story ? (
<div className="empty-state">正在生成主线叙事...</div>
) : (
<div style={{ border: '1px solid #000000', background: '#ffffff', padding: 18 }}>
<div style={{ fontSize: 11, color: '#666666', marginBottom: 12, textTransform: 'uppercase', letterSpacing: 1 }}>
{selectedStory?.source ? `来源 · ${selectedStory.source}` : '自动生成'}
</div>
<div style={{ fontSize: 13, lineHeight: 1.8 }}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{selectedStory.story}
</ReactMarkdown>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,87 @@
import React from 'react';
export default function ExplainSummarySection({
explainSummary,
tickerSignals,
recentMentions,
tickerTrades,
tickerNews,
selectedSymbol,
isOpen,
onToggle,
}) {
return (
<div className="section">
<div className="section-header">
<h2 className="section-title">分析摘要</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
基于当前持仓信号和讨论自动汇总
</div>
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起分析摘要' : '展开分析摘要'}
</button>
</div>
</div>
{!isOpen ? (
<div className="empty-state">分析摘要已收起需要时再展开查看概览和密度信息</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: 16 }}>
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 16 }}>
<div style={{ fontSize: 11, color: '#666666', marginBottom: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
当前解释
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{explainSummary.map((line, index) => (
<div key={`${selectedSymbol}-summary-${index}`} style={{ fontSize: 13, lineHeight: 1.7, color: '#000000' }}>
{line}
</div>
))}
</div>
</div>
<div style={{ border: '1px solid #000000', background: '#ffffff', padding: 16 }}>
<div style={{ fontSize: 11, color: '#666666', marginBottom: 12, textTransform: 'uppercase', letterSpacing: 1 }}>
信号密度
</div>
<div style={{ display: 'grid', gap: 10 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
<span>分析师信号</span>
<strong>{tickerSignals.length}</strong>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
<span>讨论提及</span>
<strong>{recentMentions.length}</strong>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
<span>成交记录</span>
<strong>{tickerTrades.length}</strong>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
<span>新闻条目</span>
<strong>{tickerNews.length}</strong>
</div>
<div style={{ height: 1, background: '#e0e0e0', margin: '4px 0' }} />
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#666666' }}>
当前分析优先读取已落库的历史记录缺失时再回退到本次运行中的实时事件
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { formatDateTime } from '../../utils/formatters';
export default function ExplainTradesSection({
tickerTrades,
selectedSymbol,
isOpen,
onToggle,
}) {
const sideLabel = (value) => {
if (value === 'LONG') return '做多';
if (value === 'SHORT') return '做空';
return value || '-';
};
return (
<div className="section">
<div className="section-header">
<h2 className="section-title">成交记录</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
{tickerTrades.length} 笔与 {selectedSymbol} 相关的交易
</div>
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起成交记录' : `展开成交记录 ${tickerTrades.length}`}
</button>
</div>
</div>
{tickerTrades.length === 0 ? (
<div className="empty-state">该股票暂无成交记录</div>
) : !isOpen ? (
<div className="empty-state">成交记录默认收起需要时再展开查看</div>
) : (
<div className="table-wrapper">
<table className="data-table">
<thead>
<tr>
<th>时间</th>
<th>方向</th>
<th>数量</th>
<th>价格</th>
</tr>
</thead>
<tbody>
{tickerTrades.slice(0, 10).map((trade, index) => (
<tr key={trade.id || `${trade.ticker}-${trade.timestamp}-${index}`}>
<td>{formatDateTime(trade.timestamp)}</td>
<td style={{ fontWeight: 700, color: trade.side === 'LONG' ? '#00C853' : trade.side === 'SHORT' ? '#FF1744' : '#000000' }}>
{sideLabel(trade.side)}
</td>
<td>{trade.qty}</td>
<td>${Number(trade.price).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,281 @@
export function normalizeSignalDirection(signal) {
const value = String(signal || '').trim().toLowerCase();
if (!value) return 'neutral';
if (value.includes('bull') || value === 'long' || value === 'buy') return 'bullish';
if (value.includes('bear') || value === 'short' || value === 'sell') return 'bearish';
return 'neutral';
}
export function includesTicker(content, ticker) {
if (!ticker || typeof content !== 'string') return false;
const normalized = ticker.trim().toUpperCase();
if (!normalized) return false;
return new RegExp(`\\b${normalized}\\b`, 'i').test(content);
}
export function flattenFeedMessages(feed) {
if (!Array.isArray(feed)) return [];
const items = [];
feed.forEach((item) => {
if (!item || !item.type || !item.data) return;
if (item.type === 'message' || item.type === 'memory') {
items.push({ ...item.data, feedType: item.type, feedId: item.id });
return;
}
if (item.type === 'conference' && Array.isArray(item.data.messages)) {
item.data.messages.forEach((message) => {
items.push({
...message,
feedType: 'conference',
feedId: item.id,
conferenceTitle: item.data.title
});
});
}
});
return items;
}
export function snippetText(content, ticker) {
const raw = String(content || '').replace(/\s+/g, ' ').trim();
if (!raw) return '';
const normalizedTicker = String(ticker || '').trim().toUpperCase();
if (!normalizedTicker) {
return raw.length > 220 ? `${raw.slice(0, 220)}...` : raw;
}
const upper = raw.toUpperCase();
const idx = upper.indexOf(normalizedTicker);
if (idx === -1) {
return raw.length > 220 ? `${raw.slice(0, 220)}...` : raw;
}
const start = Math.max(0, idx - 90);
const end = Math.min(raw.length, idx + normalizedTicker.length + 130);
const snippet = raw.slice(start, end).trim();
return `${start > 0 ? '...' : ''}${snippet}${end < raw.length ? '...' : ''}`;
}
export function buildLinePath(points, width, height, padding) {
if (!Array.isArray(points) || points.length === 0) {
return '';
}
const prices = points.map((point) => Number(point.price)).filter(Number.isFinite);
if (!prices.length) {
return '';
}
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
const span = maxPrice - minPrice || 1;
const innerWidth = width - padding * 2;
const innerHeight = height - padding * 2;
return points.map((point, index) => {
const x = padding + (innerWidth * index) / Math.max(points.length - 1, 1);
const y = height - padding - ((Number(point.price) - minPrice) / span) * innerHeight;
return `${index === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`;
}).join(' ');
}
export function parsePointTime(point) {
const raw = point?.timestamp ?? point?.label;
if (!raw) return NaN;
const direct = new Date(raw).getTime();
if (Number.isFinite(direct)) return direct;
return new Date(`${raw}T00:00:00`).getTime();
}
export function aggregatePriceSeriesToCandles(points) {
if (!Array.isArray(points) || points.length === 0) {
return [];
}
const bucketTarget = points.length >= 36 ? 12 : points.length >= 18 ? 8 : 4;
const bucketSize = Math.max(1, Math.ceil(points.length / bucketTarget));
const candles = [];
for (let index = 0; index < points.length; index += bucketSize) {
const bucket = points.slice(index, index + bucketSize);
const prices = bucket.map((point) => Number(point.price)).filter(Number.isFinite);
if (!prices.length) {
continue;
}
candles.push({
id: `${bucket[0]?.timestamp || index}-${bucket[bucket.length - 1]?.timestamp || index + bucket.length}`,
open: Number(bucket[0].price),
high: Math.max(...prices),
low: Math.min(...prices),
close: Number(bucket[bucket.length - 1].price),
startTimestamp: parsePointTime(bucket[0]),
endTimestamp: parsePointTime(bucket[bucket.length - 1]),
startLabel: bucket[0]?.label || bucket[0]?.timestamp || '',
endLabel: bucket[bucket.length - 1]?.label || bucket[bucket.length - 1]?.timestamp || ''
});
}
return candles;
}
export function eventDateKey(timestamp) {
if (!timestamp) return '';
const parsed = new Date(timestamp);
if (!Number.isNaN(parsed.getTime())) {
return parsed.toISOString().slice(0, 10);
}
return String(timestamp).slice(0, 10);
}
export function resolveEventCategory(event) {
if (!event) return 'other';
if (event.type === 'trade') return 'trade';
if (event.type === 'mention') return 'discussion';
if (event.type !== 'signal') return 'other';
const role = String(event.meta || '').toLowerCase();
if (role.includes('technical')) return 'technical';
if (role.includes('fundamental')) return 'fundamental';
if (role.includes('sentiment')) return 'sentiment';
if (role.includes('valuation')) return 'valuation';
if (role.includes('risk')) return 'risk';
if (role.includes('portfolio')) return 'portfolio';
return 'signal';
}
export function normalizeTradeRow(row, fallbackIndex = 0) {
if (!row || typeof row !== 'object') return null;
const timestamp = row.timestamp || row.ts || row.created_at || null;
const ticker = row.ticker || '';
const side = row.side || '';
const qtyValue = Number(row.qty ?? row.quantity ?? 0);
const priceValue = Number(row.price ?? 0);
return {
id: row.id || `trade-${ticker}-${timestamp || fallbackIndex}-${fallbackIndex}`,
timestamp,
trading_date: row.trading_date || row.trade_date || null,
ticker,
side,
qty: Number.isFinite(qtyValue) ? qtyValue : 0,
price: Number.isFinite(priceValue) ? priceValue : 0
};
}
export function normalizeSignalRow(row, fallbackIndex = 0) {
if (!row || typeof row !== 'object') return null;
const timestamp = row.timestamp || row.created_at || null;
const date = row.date || row.trade_date || eventDateKey(timestamp) || '';
const rawSignal = row.signal || row.title || '';
const normalizedDirection = normalizeSignalDirection(rawSignal);
const confidenceValue = Number(row.confidence);
const realReturnValue = Number(row.real_return);
const parsedCorrect = typeof row.is_correct === 'string'
? row.is_correct.toLowerCase() === 'true'
? true
: row.is_correct.toLowerCase() === 'false'
? false
: null
: typeof row.is_correct === 'boolean'
? row.is_correct
: null;
return {
id: row.id || `signal-${row.agent_id || row.agentId || 'agent'}-${date || fallbackIndex}-${fallbackIndex}`,
timestamp,
date,
ticker: row.ticker || '',
signal: rawSignal,
confidence: Number.isFinite(confidenceValue) ? confidenceValue : null,
real_return: Number.isFinite(realReturnValue) ? realReturnValue : null,
is_correct: parsedCorrect,
agentId: row.agent_id || row.agentId || '',
agentName: row.agent_name || row.agentName || row.meta || '未知分析师',
role: row.role || row.meta || '',
normalizedDirection
};
}
export function normalizeMentionRow(row, fallbackIndex = 0) {
if (!row || typeof row !== 'object') return null;
return {
id: row.id || `mention-${fallbackIndex}`,
feedId: row.id || `mention-${fallbackIndex}`,
timestamp: row.timestamp || null,
agent: row.agent || row.agentName || '未知角色',
content: row.body || row.content || '',
conferenceTitle: row.meta || '',
feedType: 'sqlite'
};
}
export function normalizeNewsRow(row, fallbackIndex = 0) {
if (!row || typeof row !== 'object') return null;
const date = row.date || row.published_utc || row.timestamp || null;
const source = row.source || row.publisher || '新闻源';
const title = row.title || '未命名新闻';
const summary = row.summary || row.description || '';
return {
id: row.id || row.url || `news-${fallbackIndex}`,
date,
dateKey: eventDateKey(date),
ticker: row.ticker || '',
title,
source,
category: row.category || '',
related: row.related || '',
summary,
url: row.url || row.article_url || '',
tradeDate: row.trade_date || null,
relevance: row.relevance || '',
sentiment: row.sentiment || '',
keyDiscussion: row.key_discussion || '',
reasonGrowth: row.reason_growth || '',
reasonDecrease: row.reason_decrease || '',
retT0: Number.isFinite(Number(row.ret_t0)) ? Number(row.ret_t0) : null,
retT1: Number.isFinite(Number(row.ret_t1)) ? Number(row.ret_t1) : null,
retT3: Number.isFinite(Number(row.ret_t3)) ? Number(row.ret_t3) : null,
retT5: Number.isFinite(Number(row.ret_t5)) ? Number(row.ret_t5) : null,
retT10: Number.isFinite(Number(row.ret_t10)) ? Number(row.ret_t10) : null,
analysisSource: row.analysis_source || '',
analysisModelLabel: row.analysis_model_label || ''
};
}
export function normalizeNewsTimelineRow(row, fallbackIndex = 0) {
if (!row || typeof row !== 'object') return null;
const date = row.date || row.trade_date || null;
if (!date) return null;
const countValue = Number(row.count ?? 0);
const sourceCountValue = Number(row.source_count ?? 0);
return {
id: row.id || `news-timeline-${date}-${fallbackIndex}`,
date,
dateKey: eventDateKey(date),
count: Number.isFinite(countValue) ? countValue : 0,
sourceCount: Number.isFinite(sourceCountValue) ? sourceCountValue : 0,
topTitle: row.top_title || '',
positiveCount: Number.isFinite(Number(row.positive_count)) ? Number(row.positive_count) : 0,
negativeCount: Number.isFinite(Number(row.negative_count)) ? Number(row.negative_count) : 0,
neutralCount: Number.isFinite(Number(row.neutral_count)) ? Number(row.neutral_count) : 0,
highRelevanceCount: Number.isFinite(Number(row.high_relevance_count)) ? Number(row.high_relevance_count) : 0
};
}
export const EVENT_CATEGORY_META = {
all: { label: '全部事件', color: '#111111' },
discussion: { label: '讨论', color: '#555555' },
signal: { label: '信号', color: '#0f766e' },
technical: { label: '技术', color: '#2563eb' },
fundamental: { label: '基本面', color: '#059669' },
sentiment: { label: '情绪', color: '#7c3aed' },
valuation: { label: '估值', color: '#d97706' },
risk: { label: '风控', color: '#dc2626' },
portfolio: { label: '组合', color: '#111827' },
trade: { label: '成交', color: '#b91c1c' },
other: { label: '其他', color: '#6b7280' }
};

View File

@@ -0,0 +1,664 @@
import { useMemo } from 'react';
import { formatDateTime } from '../../utils/formatters';
import {
aggregatePriceSeriesToCandles,
buildLinePath,
eventDateKey,
flattenFeedMessages,
includesTicker,
normalizeMentionRow,
normalizeNewsRow,
normalizeNewsTimelineRow,
normalizeSignalDirection,
normalizeSignalRow,
normalizeTradeRow,
parsePointTime,
resolveEventCategory,
snippetText
} from './explainUtils';
function tradeSideLabel(value) {
if (value === 'LONG') return '做多';
if (value === 'SHORT') return '做空';
return value || '交易';
}
export default function useExplainModel({
tickers,
holdings,
trades,
leaderboard,
feed,
priceHistoryByTicker,
ohlcHistoryByTicker,
selectedSymbol,
explainEventsSnapshot,
newsSnapshot,
selectedEventDate,
activeEventCategory,
activeNewsCategory,
activeNewsSentiment = 'all'
}) {
const availableSymbols = useMemo(() => (
Array.isArray(tickers)
? tickers.map((ticker) => ticker?.symbol).filter((symbol) => typeof symbol === 'string' && symbol.trim())
: []
), [tickers]);
const selectedTicker = useMemo(
() => tickers.find((ticker) => ticker.symbol === selectedSymbol) || null,
[selectedSymbol, tickers]
);
const holding = useMemo(
() => holdings.find((item) => item.ticker === selectedSymbol) || null,
[holdings, selectedSymbol]
);
const fallbackTrades = useMemo(
() => trades
.filter((trade) => trade.ticker === selectedSymbol)
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()),
[selectedSymbol, trades]
);
const tickerSignals = useMemo(() => {
const snapshotSignals = Array.isArray(explainEventsSnapshot?.signals)
? explainEventsSnapshot.signals.map((signal, index) => normalizeSignalRow(signal, index)).filter(Boolean)
: [];
if (snapshotSignals.length > 0) {
return snapshotSignals.sort((a, b) => new Date(b.timestamp || b.date).getTime() - new Date(a.timestamp || a.date).getTime());
}
if (!selectedSymbol) return [];
return (Array.isArray(leaderboard) ? leaderboard : []).flatMap((agent) => {
const signals = Array.isArray(agent.signals) ? agent.signals : [];
return signals
.filter((signal) => signal.ticker === selectedSymbol)
.map((signal) => ({
agentId: agent.agentId,
agentName: agent.name,
role: agent.role,
...signal,
normalizedDirection: normalizeSignalDirection(signal.signal)
}));
}).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}, [explainEventsSnapshot, leaderboard, selectedSymbol]);
const signalSummary = useMemo(() => {
const summary = { bullish: 0, bearish: 0, neutral: 0 };
tickerSignals.forEach((signal) => {
summary[signal.normalizedDirection] += 1;
});
return summary;
}, [tickerSignals]);
const fallbackRecentMentions = useMemo(() => {
const flattened = flattenFeedMessages(feed);
return flattened
.filter((message) => message.agent !== 'System' && includesTicker(message.content, selectedSymbol))
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 8);
}, [feed, selectedSymbol]);
const tickerTrades = useMemo(() => {
const snapshotTrades = Array.isArray(explainEventsSnapshot?.trades)
? explainEventsSnapshot.trades.map((trade, index) => normalizeTradeRow(trade, index)).filter(Boolean)
: [];
if (snapshotTrades.length > 0) {
return snapshotTrades.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
}
return fallbackTrades;
}, [explainEventsSnapshot, fallbackTrades]);
const recentMentions = useMemo(() => {
const snapshotMentions = Array.isArray(explainEventsSnapshot?.events)
? explainEventsSnapshot.events
.map((event, index) => normalizeMentionRow(event, index))
.filter(Boolean)
.slice(0, 8)
: [];
if (snapshotMentions.length > 0) {
return snapshotMentions.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
}
return fallbackRecentMentions;
}, [explainEventsSnapshot, fallbackRecentMentions]);
const tickerNews = useMemo(() => {
const items = Array.isArray(newsSnapshot?.items)
? newsSnapshot.items.map((item, index) => normalizeNewsRow(item, index)).filter(Boolean)
: [];
return items.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}, [newsSnapshot]);
const dateScopedNews = useMemo(() => {
if (!selectedEventDate || !newsSnapshot?.byDate || typeof newsSnapshot.byDate !== 'object') {
return [];
}
const rows = Array.isArray(newsSnapshot.byDate[selectedEventDate])
? newsSnapshot.byDate[selectedEventDate]
: [];
return rows.map((item, index) => normalizeNewsRow(item, index)).filter(Boolean);
}, [newsSnapshot, selectedEventDate]);
const visibleNews = useMemo(() => {
if (!selectedEventDate) {
return tickerNews;
}
if (dateScopedNews.length > 0) {
return dateScopedNews.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}
const scoped = tickerNews.filter((item) => item.dateKey === selectedEventDate);
return scoped.length > 0 ? scoped : tickerNews;
}, [dateScopedNews, selectedEventDate, tickerNews]);
const tickerNewsTimeline = useMemo(() => {
const items = Array.isArray(newsSnapshot?.timeline)
? newsSnapshot.timeline.map((item, index) => normalizeNewsTimelineRow(item, index)).filter(Boolean)
: [];
return items.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
}, [newsSnapshot]);
const newsCategories = useMemo(() => (
newsSnapshot?.categories && typeof newsSnapshot.categories === 'object'
? newsSnapshot.categories
: {}
), [newsSnapshot]);
const visibleNewsByCategory = useMemo(() => {
let scopedNews = visibleNews;
if (activeNewsCategory !== 'all') {
const categoryMeta = newsCategories?.[activeNewsCategory];
const allowedIds = Array.isArray(categoryMeta?.article_ids)
? new Set(categoryMeta.article_ids)
: null;
if (allowedIds && allowedIds.size > 0) {
scopedNews = scopedNews.filter((item) => allowedIds.has(item.id));
}
}
if (activeNewsSentiment === 'all') {
return scopedNews;
}
return scopedNews.filter((item) => {
const sentiment = String(item.sentiment || '').trim().toLowerCase() || 'neutral';
return sentiment === activeNewsSentiment;
});
}, [activeNewsCategory, activeNewsSentiment, newsCategories, visibleNews]);
const selectedRangeWindow = useMemo(() => {
if (!selectedEventDate) return null;
const endDate = new Date(`${selectedEventDate}T00:00:00`);
if (Number.isNaN(endDate.getTime())) return null;
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - 6);
return {
startDate: startDate.toISOString().slice(0, 10),
endDate: selectedEventDate
};
}, [selectedEventDate]);
const selectedRangeExplain = useMemo(() => {
if (!selectedRangeWindow) return null;
const key = `${selectedRangeWindow.startDate}:${selectedRangeWindow.endDate}`;
return newsSnapshot?.rangeExplainCache?.[key] || null;
}, [newsSnapshot, selectedRangeWindow]);
const selectedStory = useMemo(() => {
const storyCache = newsSnapshot?.storyCache;
if (!storyCache || typeof storyCache !== 'object') {
return null;
}
const keys = Object.keys(storyCache).sort();
if (!keys.length) {
return null;
}
return storyCache[keys[keys.length - 1]] || null;
}, [newsSnapshot]);
const selectedSimilarDays = useMemo(() => {
if (!selectedEventDate) {
return null;
}
const similarCache = newsSnapshot?.similarDaysCache;
if (!similarCache || typeof similarCache !== 'object') {
return null;
}
return similarCache[selectedEventDate] || null;
}, [newsSnapshot, selectedEventDate]);
const latestSignal = tickerSignals[0] || null;
const priceColor = selectedTicker?.change > 0 ? '#00C853' : selectedTicker?.change < 0 ? '#FF1744' : '#000000';
const exposureWeight = holding && Number.isFinite(Number(holding.weight)) ? Number(holding.weight) * 100 : null;
const recentTrade = tickerTrades[0] || null;
const ohlcSeries = useMemo(() => {
const raw = ohlcHistoryByTicker?.[selectedSymbol];
return Array.isArray(raw) ? raw.filter((candle) => Number.isFinite(Number(candle.close))).slice(-60) : [];
}, [ohlcHistoryByTicker, selectedSymbol]);
const priceSeries = useMemo(() => {
const raw = priceHistoryByTicker?.[selectedSymbol];
return Array.isArray(raw) ? raw.filter((point) => Number.isFinite(Number(point.price))).slice(-60) : [];
}, [priceHistoryByTicker, selectedSymbol]);
const explainSummary = useMemo(() => {
if (!selectedSymbol) return [];
const lines = [];
if (latestSignal) {
const directionText = latestSignal.normalizedDirection === 'bullish'
? '偏多'
: latestSignal.normalizedDirection === 'bearish'
? '偏空'
: '观望';
lines.push(`最新分析师结论为${directionText},来自${latestSignal.agentName}`);
} else {
lines.push('当前还没有形成结构化分析师信号,更多依赖讨论内容和持仓状态。');
}
if (holding) {
lines.push(`组合当前持有 ${selectedSymbol},权重约 ${exposureWeight != null ? `${exposureWeight.toFixed(2)}%` : '0.00%'}`);
} else {
lines.push(`组合当前未持有 ${selectedSymbol},仍处于观察阶段。`);
}
if (recentTrade) {
lines.push(`最近一次相关交易为${tradeSideLabel(recentTrade.side)},时间是 ${formatDateTime(recentTrade.timestamp)}`);
}
if (recentMentions.length > 0) {
lines.push(`最近讨论中共有 ${recentMentions.length} 条直接提及 ${selectedSymbol} 的观点。`);
}
return lines;
}, [exposureWeight, holding, latestSignal, recentMentions.length, recentTrade, selectedSymbol]);
const explainTimeline = useMemo(() => {
const signalEvents = tickerSignals.slice(0, 12).map((signal, index) => ({
id: `signal-${signal.agentId}-${signal.date}-${index}`,
type: 'signal',
timestamp: new Date(`${signal.date}T08:00:00`).toISOString(),
title: `${signal.agentName} 给出${signal.normalizedDirection === 'bullish' ? '看涨' : signal.normalizedDirection === 'bearish' ? '看跌' : '中性'}信号`,
meta: signal.role,
body: typeof signal.real_return === 'number'
? `后验收益 ${signal.real_return >= 0 ? '+' : ''}${(signal.real_return * 100).toFixed(2)}%`
: '该信号暂未完成后验评估',
tone: signal.normalizedDirection === 'bullish' ? 'positive' : signal.normalizedDirection === 'bearish' ? 'negative' : 'neutral'
}));
const mentionEvents = recentMentions.slice(0, 12).map((message, index) => ({
id: `mention-${message.feedId || message.id}-${index}`,
type: 'mention',
timestamp: message.timestamp,
title: `${message.agent || '未知角色'}${message.conferenceTitle || '讨论流'}中提及 ${selectedSymbol}`,
meta: message.conferenceTitle || (message.feedType === 'conference' ? '投资讨论' : '即时消息'),
body: snippetText(message.content, selectedSymbol),
tone: 'neutral'
}));
const tradeEvents = tickerTrades.slice(0, 12).map((trade, index) => ({
id: `trade-${trade.id || `${trade.ticker}-${trade.timestamp}-${index}`}`,
type: 'trade',
timestamp: trade.timestamp,
title: `${tradeSideLabel(trade.side)} ${trade.qty}`,
meta: '交易执行',
body: `成交价 $${Number(trade.price).toFixed(2)}`,
tone: trade.side === 'LONG' ? 'positive' : trade.side === 'SHORT' ? 'negative' : 'neutral'
}));
const fallbackTimeline = [...signalEvents, ...mentionEvents, ...tradeEvents]
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 24)
.map((event) => ({
...event,
dateKey: eventDateKey(event.timestamp),
category: resolveEventCategory(event)
}));
if (!explainEventsSnapshot) {
return fallbackTimeline;
}
const dbSignalEvents = (Array.isArray(explainEventsSnapshot.signals) ? explainEventsSnapshot.signals : [])
.map((signal, index) => {
if (signal?.type === 'signal' && signal?.timestamp) {
return signal;
}
const normalized = normalizeSignalRow(signal, index);
if (!normalized) return null;
return {
id: normalized.id,
type: 'signal',
timestamp: normalized.timestamp || (normalized.date ? new Date(`${normalized.date}T08:00:00`).toISOString() : null),
title: `${normalized.agentName} 给出${
normalized.normalizedDirection === 'bullish'
? '看涨'
: normalized.normalizedDirection === 'bearish'
? '看跌'
: '中性'
}信号`,
meta: normalized.role,
body: typeof normalized.real_return === 'number'
? `后验收益 ${normalized.real_return >= 0 ? '+' : ''}${(normalized.real_return * 100).toFixed(2)}%`
: '该信号暂未完成后验评估',
tone: normalized.normalizedDirection === 'bullish'
? 'positive'
: normalized.normalizedDirection === 'bearish'
? 'negative'
: 'neutral'
};
})
.filter(Boolean);
const dbMentionEvents = (Array.isArray(explainEventsSnapshot.events) ? explainEventsSnapshot.events : [])
.map((event, index) => {
if (event?.type === 'mention' && event?.timestamp) {
return event;
}
const normalized = normalizeMentionRow(event, index);
if (!normalized) return null;
return {
id: normalized.id,
type: 'mention',
timestamp: normalized.timestamp,
title: `${normalized.agent || '未知角色'}${normalized.conferenceTitle || '讨论流'}中提及 ${selectedSymbol}`,
meta: normalized.conferenceTitle || (normalized.feedType === 'conference' ? '投资讨论' : '即时消息'),
body: snippetText(normalized.content, selectedSymbol),
tone: 'neutral'
};
})
.filter(Boolean);
const dbTradeEvents = (Array.isArray(explainEventsSnapshot.trades) ? explainEventsSnapshot.trades : [])
.map((trade, index) => {
if (trade?.type === 'trade' && trade?.timestamp) {
return trade;
}
const normalized = normalizeTradeRow(trade, index);
if (!normalized) return null;
return {
id: normalized.id,
type: 'trade',
timestamp: normalized.timestamp,
title: `${tradeSideLabel(normalized.side)} ${normalized.qty}`,
meta: '交易执行',
body: `成交价 $${Number(normalized.price).toFixed(2)}`,
tone: normalized.side === 'LONG' ? 'positive' : normalized.side === 'SHORT' ? 'negative' : 'neutral'
};
})
.filter(Boolean);
const dbEvents = [
...dbSignalEvents,
...dbMentionEvents,
...dbTradeEvents
]
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 24)
.map((event) => ({
...event,
dateKey: eventDateKey(event.timestamp),
category: resolveEventCategory(event)
}));
return dbEvents.length > 0 ? dbEvents : fallbackTimeline;
}, [explainEventsSnapshot, recentMentions, selectedSymbol, tickerSignals, tickerTrades]);
const availableEventDates = useMemo(
() => Array.from(new Set(explainTimeline.map((event) => event.dateKey).filter(Boolean))),
[explainTimeline]
);
const eventCategoryCounts = useMemo(() => {
const scopedEvents = selectedEventDate
? explainTimeline.filter((event) => event.dateKey === selectedEventDate)
: explainTimeline;
const counts = { all: scopedEvents.length };
scopedEvents.forEach((event) => {
counts[event.category] = (counts[event.category] || 0) + 1;
});
return counts;
}, [explainTimeline, selectedEventDate]);
const visibleExplainEvents = useMemo(() => explainTimeline.filter((event) => {
if (selectedEventDate && event.dateKey !== selectedEventDate) {
return false;
}
if (activeEventCategory !== 'all' && event.category !== activeEventCategory) {
return false;
}
return true;
}), [activeEventCategory, explainTimeline, selectedEventDate]);
const chartModel = useMemo(() => {
const width = 720;
const height = 220;
const padding = 18;
if (!ohlcSeries.length && !priceSeries.length) {
return {
width,
height,
padding,
path: '',
minPrice: null,
maxPrice: null,
markers: [],
candles: [],
linePoints: [],
bucketCount: 0
};
}
if (ohlcSeries.length > 1) {
const prices = ohlcSeries.flatMap((candle) => [Number(candle.low), Number(candle.high)]);
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
const span = maxPrice - minPrice || 1;
const innerWidth = width - padding * 2;
const innerHeight = height - padding * 2;
const candleWidth = Math.max(8, Math.min(18, (innerWidth / ohlcSeries.length) * 0.55));
const startTime = parsePointTime({ timestamp: ohlcSeries[0]?.time });
const endTime = parsePointTime({ timestamp: ohlcSeries[ohlcSeries.length - 1]?.time });
const timeSpan = Math.max(endTime - startTime, 1);
const candles = ohlcSeries.map((candle, index) => {
const centerX = padding + ((index + 0.5) * innerWidth) / Math.max(ohlcSeries.length, 1);
const openY = height - padding - ((Number(candle.open) - minPrice) / span) * innerHeight;
const closeY = height - padding - ((Number(candle.close) - minPrice) / span) * innerHeight;
const highY = height - padding - ((Number(candle.high) - minPrice) / span) * innerHeight;
const lowY = height - padding - ((Number(candle.low) - minPrice) / span) * innerHeight;
return {
...candle,
id: `${candle.time || index}`,
centerX,
x: centerX - candleWidth / 2,
width: candleWidth,
openY,
closeY,
highY,
lowY,
bodyY: Math.min(openY, closeY),
bodyHeight: Math.max(Math.abs(closeY - openY), 2)
};
});
const explainMarkers = explainTimeline.slice(0, 8).map((event) => {
const timestamp = new Date(event.timestamp).getTime();
if (!Number.isFinite(timestamp)) return null;
const ratio = Math.min(1, Math.max(0, (timestamp - startTime) / timeSpan));
const nearestCandleIndex = candles.length <= 1
? 0
: Math.min(candles.length - 1, Math.max(0, Math.round(ratio * Math.max(candles.length - 1, 1))));
const nearestCandle = candles[nearestCandleIndex] || null;
const x = nearestCandle ? nearestCandle.centerX : padding + ratio * innerWidth;
const price = nearestCandle ? Number(nearestCandle.close) : Number(ohlcSeries[ohlcSeries.length - 1]?.close ?? maxPrice);
const y = height - padding - ((price - minPrice) / span) * innerHeight;
return { ...event, x, y, isSelected: event.dateKey === selectedEventDate, markerType: 'event' };
}).filter(Boolean);
const newsMarkers = tickerNewsTimeline.slice(-20).map((item, index) => {
const timestamp = new Date(`${item.date}T12:00:00`).getTime();
if (!Number.isFinite(timestamp)) return null;
const ratio = Math.min(1, Math.max(0, (timestamp - startTime) / timeSpan));
const nearestCandleIndex = candles.length <= 1
? 0
: Math.min(candles.length - 1, Math.max(0, Math.round(ratio * Math.max(candles.length - 1, 1))));
const nearestCandle = candles[nearestCandleIndex] || null;
const x = nearestCandle ? nearestCandle.centerX : padding + ratio * innerWidth;
const price = nearestCandle ? Number(nearestCandle.close) : Number(ohlcSeries[ohlcSeries.length - 1]?.close ?? maxPrice);
const y = height - padding - ((price - minPrice) / span) * innerHeight;
return {
id: item.id || `news-marker-${index}`,
title: item.topTitle || `当日 ${item.count} 条新闻`,
dateKey: item.dateKey,
tone: 'news',
x,
y,
isSelected: item.dateKey === selectedEventDate,
markerType: 'news',
count: item.count
};
}).filter(Boolean);
return {
width,
height,
padding,
path: '',
minPrice,
maxPrice,
markers: [...newsMarkers, ...explainMarkers],
candles,
linePoints: [],
bucketCount: candles.length
};
}
const prices = priceSeries.map((point) => Number(point.price));
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
const span = maxPrice - minPrice || 1;
const innerWidth = width - padding * 2;
const innerHeight = height - padding * 2;
const startTime = parsePointTime(priceSeries[0]);
const endTime = parsePointTime(priceSeries[priceSeries.length - 1]);
const timeSpan = Math.max(endTime - startTime, 1);
const candles = aggregatePriceSeriesToCandles(priceSeries);
const linePoints = priceSeries.map((point, index) => {
const x = padding + (innerWidth * index) / Math.max(priceSeries.length - 1, 1);
const y = height - padding - ((Number(point.price) - minPrice) / span) * innerHeight;
return { x, y };
});
const candleWidth = candles.length > 1
? Math.max(8, Math.min(24, (innerWidth / candles.length) * 0.58))
: 14;
const mappedCandles = candles.map((candle, index) => {
const centerX = padding + ((index + 0.5) * innerWidth) / Math.max(candles.length, 1);
const openY = height - padding - ((candle.open - minPrice) / span) * innerHeight;
const closeY = height - padding - ((candle.close - minPrice) / span) * innerHeight;
const highY = height - padding - ((candle.high - minPrice) / span) * innerHeight;
const lowY = height - padding - ((candle.low - minPrice) / span) * innerHeight;
return {
...candle,
centerX,
x: centerX - candleWidth / 2,
width: candleWidth,
openY,
closeY,
highY,
lowY,
bodyY: Math.min(openY, closeY),
bodyHeight: Math.max(Math.abs(closeY - openY), 2)
};
});
const explainMarkers = explainTimeline.slice(0, 8).map((event) => {
const timestamp = new Date(event.timestamp).getTime();
if (!Number.isFinite(timestamp)) return null;
const ratio = Math.min(1, Math.max(0, (timestamp - startTime) / timeSpan));
const nearestCandleIndex = mappedCandles.length <= 1
? 0
: Math.min(
mappedCandles.length - 1,
Math.max(0, Math.round(ratio * Math.max(mappedCandles.length - 1, 1)))
);
const nearestCandle = mappedCandles[nearestCandleIndex] || null;
const x = nearestCandle ? nearestCandle.centerX : padding + ratio * innerWidth;
const price = nearestCandle ? nearestCandle.close : Number(priceSeries[priceSeries.length - 1]?.price ?? prices[prices.length - 1]);
const y = height - padding - ((price - minPrice) / span) * innerHeight;
return { ...event, x, y, isSelected: event.dateKey === selectedEventDate, markerType: 'event' };
}).filter(Boolean);
const newsMarkers = tickerNewsTimeline.slice(-20).map((item, index) => {
const timestamp = new Date(`${item.date}T12:00:00`).getTime();
if (!Number.isFinite(timestamp)) return null;
const ratio = Math.min(1, Math.max(0, (timestamp - startTime) / timeSpan));
const nearestCandleIndex = mappedCandles.length <= 1
? 0
: Math.min(
mappedCandles.length - 1,
Math.max(0, Math.round(ratio * Math.max(mappedCandles.length - 1, 1)))
);
const nearestCandle = mappedCandles[nearestCandleIndex] || null;
const x = nearestCandle ? nearestCandle.centerX : padding + ratio * innerWidth;
const price = nearestCandle ? nearestCandle.close : Number(priceSeries[priceSeries.length - 1]?.price ?? prices[prices.length - 1]);
const y = height - padding - ((price - minPrice) / span) * innerHeight;
return {
id: item.id || `news-marker-${index}`,
title: item.topTitle || `当日 ${item.count} 条新闻`,
dateKey: item.dateKey,
tone: 'news',
x,
y,
isSelected: item.dateKey === selectedEventDate,
markerType: 'news',
count: item.count
};
}).filter(Boolean);
return {
width,
height,
padding,
path: buildLinePath(priceSeries, width, height, padding),
minPrice,
maxPrice,
markers: [...newsMarkers, ...explainMarkers],
candles: mappedCandles,
linePoints,
bucketCount: mappedCandles.length
};
}, [explainTimeline, ohlcSeries, priceSeries, selectedEventDate, tickerNewsTimeline]);
return {
availableSymbols,
selectedTicker,
holding,
tickerSignals,
signalSummary,
tickerTrades,
recentMentions,
tickerNews,
visibleNews,
newsCategories,
visibleNewsByCategory,
selectedRangeWindow,
selectedRangeExplain,
selectedStory,
selectedSimilarDays,
latestSignal,
priceColor,
exposureWeight,
recentTrade,
ohlcSeries,
priceSeries,
explainSummary,
explainTimeline,
availableEventDates,
eventCategoryCounts,
visibleExplainEvents,
chartModel
};
}

View File

@@ -0,0 +1,156 @@
import React from 'react';
import { describe, expect, it } from 'vitest';
import { renderHook } from '@testing-library/react';
import useExplainModel from './useExplainModel';
function buildBaseProps() {
return {
tickers: [{ symbol: 'AAPL', price: 105.12, change: 1.34 }],
holdings: [{ ticker: 'AAPL', quantity: 10, weight: 0.2, marketValue: 1051.2, currentPrice: 105.12 }],
trades: [],
leaderboard: [],
feed: [],
priceHistoryByTicker: {
AAPL: [
{ timestamp: '2026-03-08T10:00:00Z', price: 100 },
{ timestamp: '2026-03-09T10:00:00Z', price: 103 },
{ timestamp: '2026-03-10T10:00:00Z', price: 105 }
]
},
ohlcHistoryByTicker: {},
selectedSymbol: 'AAPL',
explainEventsSnapshot: {
signals: [
{
id: 'sig-1',
ticker: 'AAPL',
date: '2026-03-10',
signal: 'bullish',
confidence: 0.88,
agent_id: 'agent-1',
agent_name: 'Alpha',
role: 'technical'
}
],
events: [
{
id: 'mention-1',
timestamp: '2026-03-10T12:00:00Z',
agent: 'Research',
body: 'AAPL momentum remains strong after earnings.',
meta: 'morning note'
}
],
trades: [
{
id: 'trade-1',
timestamp: '2026-03-10T15:00:00Z',
ticker: 'AAPL',
side: 'LONG',
qty: 5,
price: 104.5
}
]
},
newsSnapshot: {
items: [
{
id: 'news-1',
ticker: 'AAPL',
date: '2026-03-10T09:00:00Z',
title: 'Apple earnings beat expectations',
summary: 'Revenue topped consensus estimates.',
source: 'Polygon',
sentiment: 'positive'
},
{
id: 'news-2',
ticker: 'AAPL',
date: '2026-03-09T09:00:00Z',
title: 'Supplier update',
summary: 'Supply chain improves.',
source: 'Polygon',
sentiment: 'negative'
}
],
timeline: [
{ id: 'timeline-1', date: '2026-03-09', count: 1, source_count: 1, top_title: 'Supplier update' },
{ id: 'timeline-2', date: '2026-03-10', count: 1, source_count: 1, top_title: 'Apple earnings beat expectations' }
],
categories: {
earnings: {
count: 1,
article_ids: ['news-1']
}
},
rangeExplainCache: {
'2026-03-03:2026-03-10': {
summary: '区间内主要由财报催化推动。'
}
},
similarDaysCache: {
'2026-03-10': {
target_features: {
sentiment_score: 0.5,
n_articles: 2
},
items: [
{
date: '2026-02-18',
score: 0.92,
n_articles: 2,
sentiment_score: 0.4
}
]
}
}
},
selectedEventDate: '2026-03-10',
activeEventCategory: 'all',
activeNewsCategory: 'earnings',
activeNewsSentiment: 'all'
};
}
describe('useExplainModel', () => {
it('derives visible news and range explain data from snapshots', () => {
const { result } = renderHook(() => useExplainModel(buildBaseProps()));
expect(result.current.availableSymbols).toEqual(['AAPL']);
expect(result.current.visibleNews).toHaveLength(1);
expect(result.current.visibleNewsByCategory).toHaveLength(1);
expect(result.current.visibleNewsByCategory[0].id).toBe('news-1');
expect(result.current.selectedRangeWindow).toEqual({
startDate: '2026-03-03',
endDate: '2026-03-10'
});
expect(result.current.selectedRangeExplain).toEqual({
summary: '区间内主要由财报催化推动。'
});
expect(result.current.selectedSimilarDays?.items).toHaveLength(1);
});
it('builds timeline, counts, and chart markers from explain data', () => {
const { result } = renderHook(() => useExplainModel(buildBaseProps()));
expect(result.current.availableEventDates).toContain('2026-03-10');
expect(result.current.eventCategoryCounts.all).toBe(3);
expect(result.current.eventCategoryCounts.technical).toBe(1);
expect(result.current.eventCategoryCounts.discussion).toBe(1);
expect(result.current.eventCategoryCounts.trade).toBe(1);
expect(result.current.visibleExplainEvents).toHaveLength(3);
expect(result.current.chartModel.markers.length).toBeGreaterThan(0);
expect(result.current.chartModel.path).toMatch(/^M/);
});
it('filters visible news by sentiment when requested', () => {
const props = buildBaseProps();
props.activeNewsCategory = 'all';
props.activeNewsSentiment = 'positive';
const { result } = renderHook(() => useExplainModel(props));
expect(result.current.visibleNewsByCategory).toHaveLength(1);
expect(result.current.visibleNewsByCategory[0].id).toBe('news-1');
});
});