feat: add leader research agent

This commit is contained in:
codyhhchen
2026-05-05 18:17:51 +08:00
committed by codychen123
parent 82ecf31867
commit a3f74b8567
64 changed files with 8023 additions and 12 deletions
+2
View File
@@ -15,6 +15,7 @@ import AccountDetail from './pages/AccountDetail'
import AccountEdit from './pages/AccountEdit'
import LeaderList from './pages/LeaderList'
import LeaderPool from './pages/LeaderPool'
import LeaderResearch from './pages/LeaderResearch'
import LeaderAdd from './pages/LeaderAdd'
import LeaderEdit from './pages/LeaderEdit'
import ConfigPage from './pages/ConfigPage'
@@ -267,6 +268,7 @@ function App() {
<Route path="/accounts/edit" element={<ProtectedRoute><AccountEdit /></ProtectedRoute>} />
<Route path="/leaders" element={<ProtectedRoute><LeaderList /></ProtectedRoute>} />
<Route path="/leader-pool" element={<ProtectedRoute><LeaderPool /></ProtectedRoute>} />
<Route path="/leader-research" element={<ProtectedRoute><LeaderResearch /></ProtectedRoute>} />
<Route path="/leaders/add" element={<ProtectedRoute><LeaderAdd /></ProtectedRoute>} />
<Route path="/leaders/edit" element={<ProtectedRoute><LeaderEdit /></ProtectedRoute>} />
<Route path="/templates" element={<ProtectedRoute><TemplateList /></ProtectedRoute>} />
+9 -3
View File
@@ -23,7 +23,8 @@ import {
NotificationOutlined,
LineChartOutlined,
RocketOutlined,
DashboardOutlined
DashboardOutlined,
ExperimentOutlined
} from '@ant-design/icons'
import type { MenuProps } from 'antd'
import type { ReactNode } from 'react'
@@ -74,7 +75,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
const getInitialOpenKeys = (): string[] => {
const path = location.pathname
const keys: string[] = []
if (path.startsWith('/leaders') || path.startsWith('/leader-pool') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
if (path.startsWith('/leaders') || path.startsWith('/leader-pool') || path.startsWith('/leader-research') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
keys.push('/copy-trading-management')
}
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) {
@@ -92,7 +93,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
useEffect(() => {
const path = location.pathname
const keys: string[] = []
if (path.startsWith('/leaders') || path.startsWith('/leader-pool') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
if (path.startsWith('/leaders') || path.startsWith('/leader-pool') || path.startsWith('/leader-research') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
keys.push('/copy-trading-management')
}
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) {
@@ -153,6 +154,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
icon: <TeamOutlined />,
label: t('menu.leaderPool')
},
{
key: '/leader-research',
icon: <ExperimentOutlined />,
label: t('menu.leaderResearch')
},
{
key: '/leaders',
icon: <UserOutlined />,
+67 -1
View File
@@ -4,6 +4,9 @@
"save": "Save",
"cancel": "Cancel",
"edit": "Edit",
"detail": "Detail",
"overview": "Overview",
"time": "Time",
"viewDetail": "View Details",
"delete": "Delete",
"add": "Add",
@@ -315,6 +318,7 @@
"templates": "Templates",
"copyTradingConfig": "Copy Trading Config",
"leaderPool": "Leader Pool",
"leaderResearch": "Leader Research",
"cryptoSpreadStrategy": "Crypto Spread Strategy",
"cryptoTailStrategy": "Strategy Config",
"cryptoTailMonitor": "Real-time Monitor",
@@ -623,7 +627,69 @@
"goCopyTrading": "View Copy Trading",
"removeConfirm": "This only removes the pool item. It will not delete the Leader address or existing copy trading configs. Continue?",
"removeSuccess": "Pool item removed",
"removeFailed": "Failed to remove pool item"
"removeFailed": "Failed to remove pool item",
"viewResearch": "View Research"
},
"leaderResearch": {
"title": "Leader Research",
"subtitle": "Automatically discover, paper trade, and score strong leaders. Real-money copying still requires your manual approval and enablement.",
"safetyTitle": "The research agent never auto-enables real-money copy trading",
"safetyDesc": "The agent only advances research state, maintains the paper ledger, and recommends trials. Approval creates a disabled config by default.",
"fetchFailed": "Failed to fetch Leader Research data",
"runNow": "Run Research",
"runStarted": "Research run completed",
"runFailed": "Research run failed",
"sourceLimitations": "Source Limitations",
"sourceHealth": "Source Health",
"filterState": "Filter by research state",
"searchPlaceholder": "Search wallet or reason",
"empty": "No research candidates yet. Configure a watchlist or wait for persisted activity events.",
"wallet": "Wallet",
"score": "Score",
"paper": "Paper",
"source": "Source",
"lastSeen": "Last Seen",
"copyablePnl": "Copyable PnL",
"trades": "Trades",
"filtered": "Filtered",
"candidates": "Candidates",
"detailTitle": "Research Detail",
"reason": "Reason",
"riskFlags": "Risk Flags",
"sourceEvidence": "Source Evidence",
"scoreBreakdown": "Score Breakdown",
"paperTrades": "Paper Trades",
"paperPositions": "Paper Positions",
"events": "Events",
"eventType": "Event Type",
"createDisabledTrial": "Create Disabled Trial",
"approvalSafetyTitle": "This only creates a disabled config",
"approvalSafetyDesc": "You must manually enable it on the copy trading config page before any real-money copy trading happens.",
"approvalCreated": "Disabled trial config created",
"approvalFailed": "Failed to create disabled trial",
"trialReadyHint": "Suggested small trial, awaiting your confirmation",
"runStatus": "Run Status",
"lastRun": "Last Run",
"duration": "Duration",
"sourceCounts": "Source Counts",
"candidateCounts": "Candidate Counts",
"noRuns": "No research runs yet",
"pendingDecisions": "Pending Decisions",
"noPendingDecisions": "No candidates awaiting approval",
"approvalPreview": "Conservative config to be created",
"fixedAmount": "Fixed Amount",
"maxDailyLoss": "Max Daily Loss",
"maxDailyOrders": "Max Daily Orders",
"priceRange": "Price Range",
"maxPositionValue": "Max Position Value",
"states": {
"DISCOVERED": "Discovered",
"CANDIDATE": "Candidate",
"PAPER": "Paper",
"TRIAL_READY": "Trial Ready",
"COOLDOWN": "Cooldown",
"RETIRED": "Retired"
}
},
"leaderAdd": {
"title": "Add Leader",
+67 -1
View File
@@ -7,6 +7,9 @@
"later": "稍后",
"delete": "删除",
"edit": "编辑",
"detail": "详情",
"overview": "概览",
"time": "时间",
"viewDetail": "查看详情",
"add": "添加",
"refresh": "刷新",
@@ -315,6 +318,7 @@
"templates": "跟单模板",
"copyTradingConfig": "跟单配置",
"leaderPool": "Leader 池",
"leaderResearch": "Leader 研究",
"cryptoSpreadStrategy": "加密价差策略",
"cryptoTailStrategy": "策略配置",
"cryptoTailMonitor": "实时监控",
@@ -623,7 +627,69 @@
"goCopyTrading": "查看跟单配置",
"removeConfirm": "只移除池子项,不会删除 Leader 地址或已有跟单配置。确定继续?",
"removeSuccess": "池子项已移除",
"removeFailed": "移除池子项失败"
"removeFailed": "移除池子项失败",
"viewResearch": "查看研究"
},
"leaderResearch": {
"title": "Leader 研究",
"subtitle": "自动发现、纸上跟单和评分优秀 leader;真钱跟单必须由你手动审批和启用。",
"safetyTitle": "研究 Agent 不会自动真钱跟单",
"safetyDesc": "Agent 只会推进研究状态、维护纸跟账本、给出试跟建议。审批时也只创建默认禁用的跟单配置。",
"fetchFailed": "获取 Leader 研究数据失败",
"runNow": "立即运行研究",
"runStarted": "研究运行完成",
"runFailed": "运行研究失败",
"sourceLimitations": "来源限制",
"sourceHealth": "来源健康",
"filterState": "按研究状态筛选",
"searchPlaceholder": "搜索钱包或原因",
"empty": "暂无研究候选。可以先配置 watchlist 或等待 activity 事件持久化。",
"wallet": "钱包",
"score": "评分",
"paper": "纸跟",
"source": "来源",
"lastSeen": "最后发现",
"copyablePnl": "可归因 PnL",
"trades": "成交",
"filtered": "过滤",
"candidates": "候选",
"detailTitle": "研究详情",
"reason": "原因",
"riskFlags": "风险标记",
"sourceEvidence": "来源证据",
"scoreBreakdown": "评分拆解",
"paperTrades": "纸跟交易",
"paperPositions": "纸跟仓位",
"events": "事件",
"eventType": "事件类型",
"createDisabledTrial": "创建禁用试跟",
"approvalSafetyTitle": "只会创建禁用配置",
"approvalSafetyDesc": "创建后你需要到跟单配置页手动启用,才会发生真钱跟单。",
"approvalCreated": "已创建禁用试跟配置",
"approvalFailed": "创建禁用试跟失败",
"trialReadyHint": "建议小额试跟,待你确认",
"runStatus": "运行状态",
"lastRun": "最近运行",
"duration": "耗时",
"sourceCounts": "来源统计",
"candidateCounts": "候选统计",
"noRuns": "暂无运行记录",
"pendingDecisions": "待处理决策",
"noPendingDecisions": "暂无待审批候选",
"approvalPreview": "将创建的保守配置",
"fixedAmount": "固定金额",
"maxDailyLoss": "每日最大亏损",
"maxDailyOrders": "每日最大订单数",
"priceRange": "价格范围",
"maxPositionValue": "最大仓位金额",
"states": {
"DISCOVERED": "已发现",
"CANDIDATE": "候选",
"PAPER": "纸跟中",
"TRIAL_READY": "建议试跟",
"COOLDOWN": "冷却",
"RETIRED": "淘汰"
}
},
"leaderAdd": {
"title": "添加 Leader",
+67 -1
View File
@@ -4,6 +4,9 @@
"save": "保存",
"cancel": "取消",
"edit": "編輯",
"detail": "詳情",
"overview": "概覽",
"time": "時間",
"viewDetail": "查看詳情",
"delete": "刪除",
"add": "添加",
@@ -315,6 +318,7 @@
"templates": "跟單模板",
"copyTradingConfig": "跟單配置",
"leaderPool": "Leader 池",
"leaderResearch": "Leader 研究",
"cryptoSpreadStrategy": "加密價差策略",
"cryptoTailStrategy": "策略配置",
"cryptoTailMonitor": "即時監控",
@@ -623,7 +627,69 @@
"goCopyTrading": "查看跟單配置",
"removeConfirm": "只移除池子項,不會刪除 Leader 地址或已有跟單配置。確定繼續?",
"removeSuccess": "池子項已移除",
"removeFailed": "移除池子項失敗"
"removeFailed": "移除池子項失敗",
"viewResearch": "查看研究"
},
"leaderResearch": {
"title": "Leader 研究",
"subtitle": "自動發現、紙上跟單和評分優秀 leader;真錢跟單必須由你手動審批和啟用。",
"safetyTitle": "研究 Agent 不會自動真錢跟單",
"safetyDesc": "Agent 只會推進研究狀態、維護紙跟帳本、給出試跟建議。審批時也只建立預設停用的跟單配置。",
"fetchFailed": "取得 Leader 研究資料失敗",
"runNow": "立即運行研究",
"runStarted": "研究運行完成",
"runFailed": "運行研究失敗",
"sourceLimitations": "來源限制",
"sourceHealth": "來源健康",
"filterState": "按研究狀態篩選",
"searchPlaceholder": "搜尋錢包或原因",
"empty": "暫無研究候選。可以先配置 watchlist 或等待 activity 事件持久化。",
"wallet": "錢包",
"score": "評分",
"paper": "紙跟",
"source": "來源",
"lastSeen": "最後發現",
"copyablePnl": "可歸因 PnL",
"trades": "成交",
"filtered": "過濾",
"candidates": "候選",
"detailTitle": "研究詳情",
"reason": "原因",
"riskFlags": "風險標記",
"sourceEvidence": "來源證據",
"scoreBreakdown": "評分拆解",
"paperTrades": "紙跟交易",
"paperPositions": "紙跟倉位",
"events": "事件",
"eventType": "事件類型",
"createDisabledTrial": "建立停用試跟",
"approvalSafetyTitle": "只會建立停用配置",
"approvalSafetyDesc": "建立後你需要到跟單配置頁手動啟用,才會發生真錢跟單。",
"approvalCreated": "已建立停用試跟配置",
"approvalFailed": "建立停用試跟失敗",
"trialReadyHint": "建議小額試跟,待你確認",
"runStatus": "運行狀態",
"lastRun": "最近運行",
"duration": "耗時",
"sourceCounts": "來源統計",
"candidateCounts": "候選統計",
"noRuns": "暫無運行記錄",
"pendingDecisions": "待處理決策",
"noPendingDecisions": "暫無待審批候選",
"approvalPreview": "將建立的保守配置",
"fixedAmount": "固定金額",
"maxDailyLoss": "每日最大虧損",
"maxDailyOrders": "每日最大訂單數",
"priceRange": "價格範圍",
"maxPositionValue": "最大倉位金額",
"states": {
"DISCOVERED": "已發現",
"CANDIDATE": "候選",
"PAPER": "紙跟中",
"TRIAL_READY": "建議試跟",
"COOLDOWN": "冷卻",
"RETIRED": "淘汰"
}
},
"leaderAdd": {
"title": "添加 Leader",
+11
View File
@@ -269,6 +269,17 @@ const LeaderPool: React.FC = () => {
<Text copyable style={{ fontSize: 12, fontFamily: 'monospace' }} type="secondary">
{item.leaderAddress}
</Text>
{item.researchBadge && (
<Space size={4}>
<Tag color={item.researchState === 'TRIAL_READY' ? 'green' : 'blue'}>{t(`leaderResearch.states.${item.researchState}`, { defaultValue: item.researchState })}</Tag>
{item.researchScore && <Text type="secondary">{t('leaderResearch.score')}: {item.researchScore}</Text>}
</Space>
)}
{item.researchCandidateId && (
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate('/leader-research')}>
{t('leaderPool.viewResearch')}
</Button>
)}
</Space>
)
},
+577
View File
@@ -0,0 +1,577 @@
import { useEffect, useState } from 'react'
import {
Alert,
Badge,
Button,
Card,
Col,
Descriptions,
Drawer,
Empty,
Form,
Input,
Modal,
Row,
Select,
Space,
Statistic,
Table,
Tabs,
Tag,
Typography,
message
} from 'antd'
import {
ExperimentOutlined,
PlayCircleOutlined,
ReloadOutlined,
SafetyCertificateOutlined
} from '@ant-design/icons'
import dayjs from 'dayjs'
import { useTranslation } from 'react-i18next'
import { apiService } from '../services/api'
import type {
Account,
LeaderPaperPosition,
LeaderPaperTrade,
LeaderResearchCandidate,
LeaderResearchCandidateDetail,
LeaderResearchCandidateListResponse,
LeaderResearchSourceState,
LeaderResearchState,
LeaderResearchSummary
} from '../types'
const { Paragraph, Text, Title } = Typography
const STATE_COLORS: Record<LeaderResearchState, string> = {
DISCOVERED: 'default',
CANDIDATE: 'blue',
PAPER: 'geekblue',
TRIAL_READY: 'green',
COOLDOWN: 'orange',
RETIRED: 'red'
}
const VALUATION_COLORS: Record<string, string> = {
AVAILABLE: 'green',
CONFIRMED_ZERO: 'purple',
UNKNOWN: 'orange',
UNAVAILABLE: 'red',
NO_MATCH: 'volcano'
}
const formatDate = (timestamp?: number) => {
if (!timestamp) return '-'
return dayjs(timestamp).format('YYYY-MM-DD HH:mm')
}
const usdc = (value?: string) => value ? `${value} USDC` : '-'
const approvalPreview = (candidate?: LeaderResearchCandidate | null) => ({
fixedAmount: usdc(candidate?.suggestedFixedAmount),
maxDailyLoss: usdc(candidate?.suggestedMaxDailyLoss),
maxDailyOrders: candidate?.suggestedMaxDailyOrders ?? '-',
priceRange: candidate?.suggestedMinPrice || candidate?.suggestedMaxPrice
? `${candidate?.suggestedMinPrice ?? '-'} - ${candidate?.suggestedMaxPrice ?? '-'}`
: '-',
maxPositionValue: usdc(candidate?.suggestedMaxPositionValue)
})
const valuationTag = (status?: string) => {
if (!status) return <Tag>-</Tag>
return <Tag color={VALUATION_COLORS[status] || 'default'}>{status}</Tag>
}
const LeaderResearch: React.FC = () => {
const { t } = useTranslation()
const [summary, setSummary] = useState<LeaderResearchSummary | null>(null)
const [candidates, setCandidates] = useState<LeaderResearchCandidateListResponse>({ list: [], total: 0, summary: summaryFallback })
const [sourceHealth, setSourceHealth] = useState<LeaderResearchSourceState[]>([])
const [accounts, setAccounts] = useState<Account[]>([])
const [stateFilter, setStateFilter] = useState<LeaderResearchState | undefined>()
const [query, setQuery] = useState('')
const [loading, setLoading] = useState(false)
const [running, setRunning] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
const [detail, setDetail] = useState<LeaderResearchCandidateDetail | null>(null)
const [approvalCandidate, setApprovalCandidate] = useState<LeaderResearchCandidate | null>(null)
const [approvalLoading, setApprovalLoading] = useState(false)
const [approvalForm] = Form.useForm()
const loadAll = async () => {
setLoading(true)
try {
const [candidateResp, summaryResp, sourceResp, accountResp] = await Promise.all([
apiService.leaderResearch.listCandidates({ page: 0, size: 50, state: stateFilter, query: query || undefined }),
apiService.leaderResearch.summary(),
apiService.leaderResearch.sourceHealth(),
apiService.accounts.list()
])
if (candidateResp.data.code === 0 && candidateResp.data.data) {
setCandidates(candidateResp.data.data)
} else {
message.error(candidateResp.data.msg || t('leaderResearch.fetchFailed'))
}
if (summaryResp.data.code === 0 && summaryResp.data.data) {
setSummary(summaryResp.data.data)
}
if (sourceResp.data.code === 0 && sourceResp.data.data) {
setSourceHealth(sourceResp.data.data)
}
if (accountResp.data.code === 0 && accountResp.data.data) {
setAccounts(accountResp.data.data.list || [])
}
} catch (error: any) {
message.error(error.message || t('leaderResearch.fetchFailed'))
} finally {
setLoading(false)
}
}
useEffect(() => {
loadAll()
}, [stateFilter])
const runAgent = async () => {
setRunning(true)
try {
const response = await apiService.leaderResearch.run({ dryRun: false, triggerType: 'MANUAL' })
if (response.data.code === 0) {
message.success(t('leaderResearch.runStarted'))
await loadAll()
} else {
message.error(response.data.msg || t('leaderResearch.runFailed'))
}
} catch (error: any) {
message.error(error.message || t('leaderResearch.runFailed'))
} finally {
setRunning(false)
}
}
const openDetail = async (candidate: LeaderResearchCandidate) => {
setDetailLoading(true)
try {
const response = await apiService.leaderResearch.detail({ candidateId: candidate.id })
if (response.data.code === 0 && response.data.data) {
setDetail(response.data.data)
} else {
message.error(response.data.msg || t('leaderResearch.fetchFailed'))
}
} finally {
setDetailLoading(false)
}
}
const openApproval = (candidate: LeaderResearchCandidate) => {
setApprovalCandidate(candidate)
approvalForm.setFieldsValue({ accountId: accounts[0]?.id })
}
const submitApproval = async () => {
if (!approvalCandidate) return
const values = await approvalForm.validateFields()
setApprovalLoading(true)
try {
const response = await apiService.leaderResearch.createDisabledTrialConfig({
candidateId: approvalCandidate.id,
accountId: values.accountId,
confirm: true
})
if (response.data.code === 0) {
message.success(t('leaderResearch.approvalCreated'))
setApprovalCandidate(null)
await loadAll()
} else {
message.error(response.data.msg || t('leaderResearch.approvalFailed'))
}
} catch (error: any) {
message.error(error.message || t('leaderResearch.approvalFailed'))
} finally {
setApprovalLoading(false)
}
}
const activeSummary = summary || candidates.summary || summaryFallback
const pendingDecisions = candidates.list.filter(candidate => candidate.researchState === 'TRIAL_READY')
const lastRun = activeSummary.lastRun
const activeApprovalPreview = approvalPreview(approvalCandidate)
const columns = [
{
title: t('leaderResearch.wallet'),
key: 'wallet',
width: 260,
render: (_: unknown, item: LeaderResearchCandidate) => (
<Space direction="vertical" size={0}>
<Text strong>{item.leaderName || item.normalizedWallet.slice(0, 10)}</Text>
<Text copyable type="secondary" style={{ fontSize: 12, fontFamily: 'monospace' }}>
{item.normalizedWallet}
</Text>
</Space>
)
},
{
title: t('common.status'),
dataIndex: 'researchState',
width: 130,
render: (state: LeaderResearchState) => (
<Space direction="vertical" size={0}>
<Tag color={STATE_COLORS[state]}>{t(`leaderResearch.states.${state}`, { defaultValue: state })}</Tag>
{state === 'TRIAL_READY' && (
<Text type="secondary" style={{ fontSize: 12 }}>{t('leaderResearch.trialReadyHint')}</Text>
)}
</Space>
)
},
{
title: t('leaderResearch.score'),
dataIndex: 'score',
width: 100,
render: (score?: string) => <Text strong>{score || '-'}</Text>
},
{
title: t('leaderResearch.paper'),
key: 'paper',
width: 220,
render: (_: unknown, item: LeaderResearchCandidate) => {
const session = item.latestPaperSession
if (!session) return <Text type="secondary">-</Text>
return (
<Space direction="vertical" size={0}>
<Text>{t('leaderResearch.copyablePnl')}: {session.copyablePnl}</Text>
<Text type="secondary">{t('leaderResearch.trades')}: {session.tradeCount} / {t('leaderResearch.filtered')}: {session.filteredCount}</Text>
</Space>
)
}
},
{
title: t('leaderResearch.source'),
dataIndex: 'source',
width: 160
},
{
title: t('leaderResearch.lastSeen'),
dataIndex: 'lastSourceSeenAt',
width: 160,
render: (value?: number) => formatDate(value)
},
{
title: t('common.actions'),
key: 'actions',
fixed: 'right' as const,
width: 230,
render: (_: unknown, item: LeaderResearchCandidate) => (
<Space wrap>
<Button size="small" onClick={() => openDetail(item)}>
{t('common.detail')}
</Button>
<Button
size="small"
type="primary"
icon={<SafetyCertificateOutlined />}
disabled={item.researchState !== 'TRIAL_READY'}
onClick={() => openApproval(item)}
>
{t('leaderResearch.createDisabledTrial')}
</Button>
</Space>
)
}
]
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Card>
<Space direction="vertical" style={{ width: '100%' }}>
<Space align="start" style={{ justifyContent: 'space-between', width: '100%' }}>
<div>
<Title level={3} style={{ marginBottom: 4 }}>{t('leaderResearch.title')}</Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>{t('leaderResearch.subtitle')}</Paragraph>
</div>
<Space>
<Button icon={<ReloadOutlined />} onClick={loadAll}>{t('common.refresh')}</Button>
<Button type="primary" icon={<PlayCircleOutlined />} loading={running} onClick={runAgent}>
{t('leaderResearch.runNow')}
</Button>
</Space>
</Space>
<Alert
type="info"
showIcon
icon={<ExperimentOutlined />}
message={t('leaderResearch.safetyTitle')}
description={t('leaderResearch.safetyDesc')}
/>
{activeSummary.sourceLimitations?.length > 0 && (
<Alert
type="warning"
showIcon
message={t('leaderResearch.sourceLimitations')}
description={activeSummary.sourceLimitations.join(' | ')}
/>
)}
</Space>
</Card>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={4}><Card><Statistic title={t('leaderResearch.states.DISCOVERED')} value={activeSummary.discoveredCount} /></Card></Col>
<Col xs={24} sm={12} lg={4}><Card><Statistic title={t('leaderResearch.states.CANDIDATE')} value={activeSummary.candidateCount} /></Card></Col>
<Col xs={24} sm={12} lg={4}><Card><Statistic title={t('leaderResearch.states.PAPER')} value={activeSummary.paperCount} /></Card></Col>
<Col xs={24} sm={12} lg={4}><Card><Statistic title={t('leaderResearch.states.TRIAL_READY')} value={activeSummary.trialReadyCount} /></Card></Col>
<Col xs={24} sm={12} lg={4}><Card><Statistic title={t('leaderResearch.states.COOLDOWN')} value={activeSummary.cooldownCount} /></Card></Col>
<Col xs={24} sm={12} lg={4}><Card><Statistic title={t('leaderResearch.states.RETIRED')} value={activeSummary.retiredCount} /></Card></Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} lg={12}>
<Card title={t('leaderResearch.runStatus')}>
{lastRun ? (
<Descriptions size="small" column={1}>
<Descriptions.Item label={t('common.status')}>
<Tag color={lastRun.partialFailure ? 'orange' : lastRun.status === 'SUCCESS' ? 'green' : 'default'}>{lastRun.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('leaderResearch.lastRun')}>{formatDate(lastRun.startedAt)}</Descriptions.Item>
<Descriptions.Item label={t('leaderResearch.duration')}>{lastRun.durationMs ?? '-'} ms</Descriptions.Item>
<Descriptions.Item label={t('leaderResearch.sourceCounts')}>{lastRun.sourceCountsJson || '-'}</Descriptions.Item>
<Descriptions.Item label={t('leaderResearch.candidateCounts')}>{lastRun.candidateCountsJson || '-'}</Descriptions.Item>
{(lastRun.errorMessage || lastRun.skippedReason) && (
<Descriptions.Item label={t('leaderResearch.reason')}>{lastRun.errorMessage || lastRun.skippedReason}</Descriptions.Item>
)}
</Descriptions>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('leaderResearch.noRuns')} />
)}
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title={t('leaderResearch.pendingDecisions')}>
{pendingDecisions.length > 0 ? (
<Space direction="vertical" style={{ width: '100%' }}>
{pendingDecisions.slice(0, 5).map(candidate => (
<Card key={candidate.id} size="small">
<Space style={{ justifyContent: 'space-between', width: '100%' }} wrap>
<Space direction="vertical" size={0}>
<Text strong>{candidate.leaderName || candidate.normalizedWallet.slice(0, 10)}</Text>
<Text type="secondary">{t('leaderResearch.trialReadyHint')}</Text>
</Space>
<Button size="small" type="primary" loading={approvalLoading && approvalCandidate?.id === candidate.id} onClick={() => openApproval(candidate)}>
{t('leaderResearch.createDisabledTrial')}
</Button>
</Space>
</Card>
))}
</Space>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('leaderResearch.noPendingDecisions')} />
)}
</Card>
</Col>
</Row>
<Card>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space wrap>
<Select
allowClear
style={{ width: 220 }}
placeholder={t('leaderResearch.filterState')}
value={stateFilter}
onChange={setStateFilter}
options={Object.keys(STATE_COLORS).map(state => ({
value: state,
label: t(`leaderResearch.states.${state}`, { defaultValue: state })
}))}
/>
<Input.Search
allowClear
style={{ width: 320 }}
placeholder={t('leaderResearch.searchPlaceholder')}
value={query}
onChange={event => setQuery(event.target.value)}
onSearch={loadAll}
/>
</Space>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={candidates.list}
scroll={{ x: 1300 }}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('leaderResearch.empty')} /> }}
/>
</Space>
</Card>
<Card title={t('leaderResearch.sourceHealth')}>
<Row gutter={[12, 12]}>
{sourceHealth.map(source => (
<Col xs={24} md={12} lg={6} key={source.sourceType}>
<Card size="small">
<Space direction="vertical" size={4}>
<Badge status={source.status === 'SUCCESS' ? 'success' : source.status === 'DISABLED' ? 'default' : 'warning'} text={source.sourceType} />
<Tag>{source.status}</Tag>
<Text type="secondary">{t('leaderResearch.candidates')}: {source.lastCandidateCount}</Text>
<Text type="secondary">{formatDate(source.lastRunAt)}</Text>
{(source.disabledReason || source.errorMessage) && <Text type="secondary">{source.disabledReason || source.errorMessage}</Text>}
</Space>
</Card>
</Col>
))}
</Row>
</Card>
<Drawer
width={880}
open={!!detail}
title={t('leaderResearch.detailTitle')}
onClose={() => setDetail(null)}
loading={detailLoading}
>
{detail && (
<Tabs
items={[
{
key: 'overview',
label: t('common.overview'),
children: (
<Space direction="vertical" style={{ width: '100%' }}>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label={t('leaderResearch.wallet')}>{detail.candidate.normalizedWallet}</Descriptions.Item>
<Descriptions.Item label={t('common.status')}>{detail.candidate.researchState}</Descriptions.Item>
<Descriptions.Item label={t('leaderResearch.score')}>{detail.candidate.score || '-'}</Descriptions.Item>
<Descriptions.Item label={t('leaderResearch.reason')}>{detail.candidate.reason || '-'}</Descriptions.Item>
<Descriptions.Item label={t('leaderResearch.riskFlags')}>{detail.candidate.riskFlags.join(', ') || '-'}</Descriptions.Item>
<Descriptions.Item label={t('leaderResearch.sourceEvidence')}>{detail.candidate.sourceEvidence || '-'}</Descriptions.Item>
</Descriptions>
{detail.latestScore && (
<Descriptions bordered size="small" column={2} title={t('leaderResearch.scoreBreakdown')}>
<Descriptions.Item label="profit">{detail.latestScore.profitSignal}</Descriptions.Item>
<Descriptions.Item label="repeatability">{detail.latestScore.repeatability}</Descriptions.Item>
<Descriptions.Item label="liquidity">{detail.latestScore.liquidityFit}</Descriptions.Item>
<Descriptions.Item label="entry">{detail.latestScore.entryPriceFit}</Descriptions.Item>
<Descriptions.Item label="slippage">{detail.latestScore.slippageRisk}</Descriptions.Item>
<Descriptions.Item label="drawdown">{detail.latestScore.drawdownRisk}</Descriptions.Item>
</Descriptions>
)}
</Space>
)
},
{
key: 'trades',
label: t('leaderResearch.paperTrades'),
children: <PaperTradeTable trades={detail.paperTrades} />
},
{
key: 'positions',
label: t('leaderResearch.paperPositions'),
children: <PaperPositionTable positions={detail.paperPositions} />
},
{
key: 'events',
label: t('leaderResearch.events'),
children: (
<Table
rowKey="id"
size="small"
dataSource={detail.events}
columns={[
{ title: t('common.time'), dataIndex: 'createdAt', render: formatDate },
{ title: t('leaderResearch.eventType'), dataIndex: 'eventType' },
{ title: t('leaderResearch.reason'), dataIndex: 'reason' }
]}
/>
)
}
]}
/>
)}
</Drawer>
<Modal
open={!!approvalCandidate}
title={t('leaderResearch.createDisabledTrial')}
onCancel={() => setApprovalCandidate(null)}
onOk={submitApproval}
confirmLoading={approvalLoading}
>
<Space direction="vertical" style={{ width: '100%' }}>
<Alert
type="warning"
showIcon
message={t('leaderResearch.approvalSafetyTitle')}
description={t('leaderResearch.approvalSafetyDesc')}
/>
<Form form={approvalForm} layout="vertical">
<Descriptions bordered size="small" column={1} title={t('leaderResearch.approvalPreview')}>
<Descriptions.Item label={t('leaderResearch.fixedAmount')}>{activeApprovalPreview.fixedAmount}</Descriptions.Item>
<Descriptions.Item label={t('leaderResearch.maxDailyLoss')}>{activeApprovalPreview.maxDailyLoss}</Descriptions.Item>
<Descriptions.Item label={t('leaderResearch.maxDailyOrders')}>{activeApprovalPreview.maxDailyOrders}</Descriptions.Item>
<Descriptions.Item label={t('leaderResearch.priceRange')}>{activeApprovalPreview.priceRange}</Descriptions.Item>
<Descriptions.Item label={t('leaderResearch.maxPositionValue')}>{activeApprovalPreview.maxPositionValue}</Descriptions.Item>
</Descriptions>
<Form.Item name="accountId" label={t('leaderPool.account')} rules={[{ required: true, message: t('leaderPool.selectAccount') }]}>
<Select
options={accounts.map(account => ({
value: account.id,
label: `${account.accountName || account.walletAddress} (${account.proxyAddress?.slice(0, 8)}...)`
}))}
/>
</Form.Item>
</Form>
</Space>
</Modal>
</Space>
)
}
const PaperTradeTable: React.FC<{ trades: LeaderPaperTrade[] }> = ({ trades }) => (
<Table
rowKey="id"
size="small"
dataSource={trades}
columns={[
{ title: 'Time', dataIndex: 'eventTime', render: formatDate },
{ title: 'Side', dataIndex: 'side' },
{ title: 'Market', dataIndex: 'marketTitle', render: (value?: string, item?: LeaderPaperTrade) => value || item?.marketId },
{ title: 'Leader Price', dataIndex: 'leaderPrice' },
{ title: 'Sim Amount', dataIndex: 'simulatedAmount' },
{ title: 'Filter', dataIndex: 'filterResult' },
{ title: 'Quote', dataIndex: 'quoteConfidence' },
{ title: 'Valuation', dataIndex: 'valuationStatus', render: valuationTag }
]}
/>
)
const PaperPositionTable: React.FC<{ positions: LeaderPaperPosition[] }> = ({ positions }) => (
<Table
rowKey="id"
size="small"
dataSource={positions}
columns={[
{ title: 'Market', dataIndex: 'marketId' },
{ title: 'Outcome', dataIndex: 'outcome' },
{ title: 'Qty', dataIndex: 'quantity' },
{ title: 'Cost', dataIndex: 'cost' },
{ title: 'Value', dataIndex: 'currentValue' },
{ title: 'PnL', dataIndex: 'unrealizedPnl' },
{ title: 'Quote', dataIndex: 'quoteConfidence' },
{ title: 'Valuation', dataIndex: 'valuationStatus', render: valuationTag }
]}
/>
)
const summaryFallback: LeaderResearchSummary = {
discoveredCount: 0,
candidateCount: 0,
paperCount: 0,
trialReadyCount: 0,
cooldownCount: 0,
retiredCount: 0,
activePaperSessions: 0,
pendingRiskCount: 0,
sourceLimitations: []
}
export default LeaderResearch
+33
View File
@@ -8,6 +8,16 @@ import type {
LeaderPoolListResponse,
LeaderPoolUpdatePlanRequest,
LeaderPoolUpdateStatusRequest,
LeaderResearchApprovalRequest,
LeaderResearchApprovalResponse,
LeaderResearchCandidateDetail,
LeaderResearchCandidateListRequest,
LeaderResearchCandidateListResponse,
LeaderResearchEvent,
LeaderResearchRun,
LeaderResearchRunRequest,
LeaderResearchSourceState,
LeaderResearchSummary,
NotificationConfig,
NotificationConfigRequest,
NotificationConfigUpdateRequest,
@@ -396,6 +406,29 @@ export const apiService = {
remove: (data: { poolId: number }) =>
apiClient.post<ApiResponse<void>>('/copy-trading/leader-pool/remove', data)
},
leaderResearch: {
run: (data: LeaderResearchRunRequest = {}) =>
apiClient.post<ApiResponse<LeaderResearchRun>>('/copy-trading/leader-research/run', data),
summary: () =>
apiClient.post<ApiResponse<LeaderResearchSummary>>('/copy-trading/leader-research/summary', {}),
listCandidates: (data: LeaderResearchCandidateListRequest = {}) =>
apiClient.post<ApiResponse<LeaderResearchCandidateListResponse>>('/copy-trading/leader-research/candidates/list', data),
detail: (data: { candidateId: number }) =>
apiClient.post<ApiResponse<LeaderResearchCandidateDetail>>('/copy-trading/leader-research/candidates/detail', data),
sourceHealth: () =>
apiClient.post<ApiResponse<LeaderResearchSourceState[]>>('/copy-trading/leader-research/source-health', {}),
events: (data: { page?: number; size?: number } = {}) =>
apiClient.post<ApiResponse<LeaderResearchEvent[]>>('/copy-trading/leader-research/events/list', data),
createDisabledTrialConfig: (data: LeaderResearchApprovalRequest) =>
apiClient.post<ApiResponse<LeaderResearchApprovalResponse>>('/copy-trading/leader-research/approval/create-disabled-trial-config', data)
},
/**
* API
+231
View File
@@ -344,6 +344,12 @@ export interface LeaderPoolItem {
lastPromotedAt?: number
cooldownUntil?: number
locked: boolean
researchCandidateId?: number
researchState?: LeaderResearchState
researchBadge?: string
researchSummary?: string
researchScore?: string
researchUpdatedAt?: number
createdAt: number
updatedAt: number
}
@@ -391,6 +397,231 @@ export interface LeaderPoolCreateTrialConfigRequest {
confirm?: boolean
}
export type LeaderResearchState = 'DISCOVERED' | 'CANDIDATE' | 'PAPER' | 'TRIAL_READY' | 'COOLDOWN' | 'RETIRED'
export interface LeaderResearchRunRequest {
dryRun?: boolean
triggerType?: 'MANUAL' | 'SCHEDULED' | 'PREVIEW'
}
export interface LeaderResearchRun {
id: number
status: string
triggerType: string
dryRun: boolean
startedAt: number
finishedAt?: number
durationMs?: number
sourceCountsJson?: string
candidateCountsJson?: string
partialFailure: boolean
skippedReason?: string
errorClass?: string
errorMessage?: string
}
export interface LeaderResearchSummary {
discoveredCount: number
candidateCount: number
paperCount: number
trialReadyCount: number
cooldownCount: number
retiredCount: number
activePaperSessions: number
pendingRiskCount: number
lastRun?: LeaderResearchRun
sourceLimitations: string[]
}
export interface LeaderResearchCandidateListRequest {
page?: number
size?: number
state?: LeaderResearchState
query?: string
}
export interface LeaderResearchCandidateListResponse {
list: LeaderResearchCandidate[]
total: number
summary: LeaderResearchSummary
}
export interface LeaderResearchCandidate {
id: number
normalizedWallet: string
leaderId?: number
leaderName?: string
poolId?: number
poolStatus?: string
suggestedFixedAmount?: string
suggestedMaxDailyLoss?: string
suggestedMaxDailyOrders?: number
suggestedMinPrice?: string
suggestedMaxPrice?: string
suggestedMaxPositionValue?: string
researchState: LeaderResearchState
source: string
sourceRank?: number
score?: string
scoreVersion?: string
reason?: string
riskFlags: string[]
locked: boolean
agentOwned: boolean
provenance: string
sourceEvidence?: string
firstSeenAt: number
lastSourceSeenAt?: number
lastScoredAt?: number
cooldownUntil?: number
cooldownCount: number
trialReadyAt?: number
retiredAt?: number
lastPaperSessionId?: number
latestPaperSession?: LeaderPaperSession
}
export interface LeaderResearchCandidateDetail {
candidate: LeaderResearchCandidate
latestScore?: LeaderResearchScore
paperSessions: LeaderPaperSession[]
paperTrades: LeaderPaperTrade[]
paperPositions: LeaderPaperPosition[]
events: LeaderResearchEvent[]
}
export interface LeaderResearchScore {
id: number
candidateId: number
runId?: number
scoreVersion: string
totalScore: string
profitSignal: string
repeatability: string
liquidityFit: string
entryPriceFit: string
slippageRisk: string
holdingPeriodFit: string
marketTypeRisk: string
drawdownRisk: string
exitLiquidityRisk: string
dataFreshness: string
filterPassRate: string
sampleTradeCount: number
reason?: string
createdAt: number
}
export interface LeaderPaperSession {
id: number
candidateId: number
status: string
startedAt: number
endedAt?: number
tradeCount: number
filteredCount: number
openExposure: string
totalRealizedPnl: string
totalUnrealizedPnl: string
copyablePnl: string
maxDrawdown: string
unknownValuationExposure: string
confirmedZeroExposure: string
filteredRatio: string
lastProcessedEventTime?: number
scoreSnapshot?: string
}
export interface LeaderPaperTrade {
id: number
sessionId: number
candidateId: number
activityEventId?: number
leaderTradeId: string
marketId: string
marketTitle?: string
marketSlug?: string
side: string
outcome?: string
outcomeIndex?: number
leaderPrice?: string
leaderSize?: string
simulatedPrice?: string
simulatedSize?: string
simulatedAmount?: string
fillAssumption: string
quoteConfidence: string
quoteSource?: string
quoteTimestamp?: number
filterResult: string
filterReason?: string
valuationStatus: string
realizedPnl?: string
eventTime: number
createdAt: number
}
export interface LeaderPaperPosition {
id: number
sessionId: number
candidateId: number
marketId: string
outcome?: string
outcomeIndex?: number
quantity: string
cost: string
avgPrice: string
currentPrice?: string
currentValue: string
realizedPnl: string
unrealizedPnl: string
valuationStatus: string
quoteConfidence: string
quoteSource?: string
quoteTimestamp?: number
updatedAt: number
}
export interface LeaderResearchSourceState {
sourceType: string
status: string
lastSuccessAt?: number
lastFailureAt?: number
lastRunAt?: number
lastCandidateCount: number
errorClass?: string
errorMessage?: string
stale: boolean
disabledReason?: string
lastCursor?: string
updatedAt: number
}
export interface LeaderResearchEvent {
id: number
candidateId?: number
runId?: number
eventType: string
reason?: string
payloadSummary?: string
notificationStatus: string
notificationError?: string
dedupeKey?: string
createdAt: number
notifiedAt?: number
}
export interface LeaderResearchApprovalRequest {
candidateId: number
accountId: number
confirm?: boolean
}
export interface LeaderResearchApprovalResponse {
copyTrading: CopyTrading
warning: string
}
/**
*
*