import { useState, useEffect } from 'react' import { useSearchParams } from 'react-router-dom' import { Table, Card, Button, Select, Tag, Space, Modal, message, Row, Col, Form, Input, InputNumber, Switch, Statistic, Descriptions, List, Empty, Spin, Tooltip, Popconfirm } from 'antd' import { useTranslation } from 'react-i18next' import { PlusOutlined, ReloadOutlined, DeleteOutlined, StopOutlined, EyeOutlined, RedoOutlined, CopyOutlined, SyncOutlined } from '@ant-design/icons' import { formatUSDC } from '../utils' import { backtestService, apiService } from '../services/api' import type { BacktestTaskDto, BacktestListRequest, BacktestCreateRequest, BacktestTradeDto } from '../types/backtest' import type { Leader } from '../types' import { useMediaQuery } from 'react-responsive' import AddCopyTradingModal from './CopyTradingOrders/AddModal' import BacktestChart from './BacktestChart' import LeaderSelect from '../components/LeaderSelect' const BacktestList: React.FC = () => { const { t } = useTranslation() const [searchParams] = useSearchParams() const isMobile = useMediaQuery({ maxWidth: 768 }) const [loading, setLoading] = useState(false) const [tasks, setTasks] = useState([]) const [total, setTotal] = useState(0) const [page, setPage] = useState(1) const [size] = useState(10) const [statusFilter, setStatusFilter] = useState() const [leaderIdFilter, setLeaderIdFilter] = useState(() => { const leaderIdParam = searchParams.get('leaderId') if (leaderIdParam) { const id = parseInt(leaderIdParam, 10) return isNaN(id) ? undefined : id } return undefined }) const [sortBy, setSortBy] = useState<'profitAmount' | 'profitRate' | 'createdAt'>('createdAt') const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc') // 创建回测 modal 相关状态 const [createModalVisible, setCreateModalVisible] = useState(false) const [createForm] = Form.useForm() const [createLoading, setCreateLoading] = useState(false) const [leaders, setLeaders] = useState([]) const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO') // 创建跟单配置 Modal const [addCopyTradingModalVisible, setAddCopyTradingModalVisible] = useState(false) const [preFilledConfig, setPreFilledConfig] = useState(null) // 重新测试 Modal 相关状态 const [rerunModalVisible, setRerunModalVisible] = useState(false) const [rerunTask, setRerunTask] = useState(null) const [rerunTaskName, setRerunTaskName] = useState('') const [rerunLoading, setRerunLoading] = useState(false) // 任务详情 Modal 相关状态 const [detailModalVisible, setDetailModalVisible] = useState(false) const [detailTask, setDetailTask] = useState(null) const [detailConfig, setDetailConfig] = useState(null) const [detailStatistics, setDetailStatistics] = useState(null) const [detailTrades, setDetailTrades] = useState([]) const [detailAllTrades, setDetailAllTrades] = useState([]) const [detailTradesLoading, setDetailTradesLoading] = useState(false) const [detailTradesTotal, setDetailTradesTotal] = useState(0) const [detailTradesPage, setDetailTradesPage] = useState(1) const [detailTradesSize] = useState(20) // 获取回测任务列表(silent 为 true 时不显示 loading,用于轮询刷新) const fetchTasks = async (silent = false) => { if (!silent) setLoading(true) try { const request: BacktestListRequest = { leaderId: leaderIdFilter, status: statusFilter as any, sortBy, sortOrder, page, size } const response = await backtestService.list(request) if (response.data.code === 0 && response.data.data) { setTasks(response.data.data.list) setTotal(response.data.data.total) } else if (!silent) { message.error(response.data.msg || t('backtest.fetchTasksFailed')) } } catch (error) { console.error('Failed to fetch backtest tasks:', error) if (!silent) message.error(t('backtest.fetchTasksFailed')) } finally { if (!silent) setLoading(false) } } // 从 URL 读取 leaderId 并应用筛选(如从 Leader 管理页跳转过来) useEffect(() => { const leaderIdParam = searchParams.get('leaderId') if (leaderIdParam) { const id = parseInt(leaderIdParam, 10) setLeaderIdFilter(isNaN(id) ? undefined : id) } }, [searchParams]) useEffect(() => { fetchTasks() }, [page, statusFilter, leaderIdFilter, sortBy, sortOrder]) // 存在非终态任务(PENDING/RUNNING)时每 3s 轮询刷新进度 const hasNonTerminalTask = tasks.some( (task) => task.status === 'PENDING' || task.status === 'RUNNING' ) useEffect(() => { if (!hasNonTerminalTask) return const timer = setInterval(() => fetchTasks(true), 3000) return () => clearInterval(timer) }, [hasNonTerminalTask, page, statusFilter, leaderIdFilter, sortBy, sortOrder]) // 刷新 const handleRefresh = () => { fetchTasks() } // 删除任务 const handleDelete = async (id: number) => { try { const response = await backtestService.delete({ id }) if (response.data.code === 0) { message.success(t('backtest.deleteSuccess')) fetchTasks() } else { message.error(response.data.msg || t('backtest.deleteFailed')) } } catch (error) { console.error('Failed to delete backtest task:', error) message.error(t('backtest.deleteFailed')) } } // 停止任务 const handleStop = async (id: number) => { try { const response = await backtestService.stop({ id }) if (response.data.code === 0) { message.success(t('backtest.stopSuccess')) fetchTasks() } else { message.error(response.data.msg || t('backtest.stopFailed')) } } catch (error) { console.error('Failed to stop backtest task:', error) message.error(t('backtest.stopFailed')) } } // 按配置重新测试(仅已完成任务) const handleRerun = (task: BacktestTaskDto) => { setRerunTask(task) setRerunTaskName(`${task.taskName} (副本)`) setRerunModalVisible(true) } const handleRerunSubmit = async () => { if (!rerunTask) return setRerunLoading(true) try { const response = await backtestService.rerun({ id: rerunTask.id, taskName: rerunTaskName.trim() || undefined }) if (response.data.code === 0) { message.success(t('backtest.rerunSuccess')) setRerunModalVisible(false) setRerunTask(null) setRerunTaskName('') fetchTasks() } else { message.error(response.data.msg || t('backtest.rerunFailed')) } } catch (error) { console.error('Rerun backtest failed:', error) message.error(t('backtest.rerunFailed')) } finally { setRerunLoading(false) } } // 重试任务 const handleRetry = (id: number) => { Modal.confirm({ title: t('backtest.retryConfirm'), okText: t('common.confirm'), cancelText: t('common.cancel'), onOk: async () => { try { const response = await backtestService.retry({ id }) if (response.data.code === 0) { message.success(t('backtest.retrySuccess')) fetchTasks() } else { message.error(response.data.msg || t('backtest.retryFailed')) } } catch (error) { console.error('Failed to retry backtest task:', error) message.error(t('backtest.retryFailed')) } } }) } // 获取 Leader 列表(页面加载时请求,供筛选和创建任务使用) useEffect(() => { const fetchLeaders = async () => { try { const response = await apiService.leaders.list({}) if (response.data.code === 0 && response.data.data) { setLeaders(response.data.data.list || []) } } catch (error) { console.error('Failed to fetch leaders:', error) } } fetchLeaders() }, []) // 打开创建 modal const handleCreate = () => { setCreateModalVisible(true) createForm.resetFields() createForm.setFieldsValue({ copyMode: 'RATIO', copyRatio: 100, // 默认 100%(显示为百分比) maxOrderSize: 1000, minOrderSize: 1, maxDailyLoss: 500, maxDailyOrders: 50, supportSell: true, keywordFilterMode: 'DISABLED', backtestDays: 7 }) setCopyMode('RATIO') } // 提交创建回测任务 const handleCreateSubmit = async () => { try { const values = await createForm.validateFields() setCreateLoading(true) const request: BacktestCreateRequest = { taskName: values.taskName, leaderId: values.leaderId, initialBalance: values.initialBalance, backtestDays: values.backtestDays, copyMode: values.copyMode || 'RATIO', copyRatio: values.copyMode === 'RATIO' && values.copyRatio ? (values.copyRatio / 100).toString() : undefined, fixedAmount: values.copyMode === 'FIXED' ? values.fixedAmount : undefined, maxOrderSize: values.maxOrderSize, minOrderSize: values.minOrderSize, maxDailyLoss: values.maxDailyLoss, maxDailyOrders: values.maxDailyOrders, supportSell: values.supportSell, keywordFilterMode: values.keywordFilterMode, keywords: values.keywords, maxPositionValue: values.maxPositionValue, minPrice: values.minPrice, maxPrice: values.maxPrice } const response = await backtestService.create(request) if (response.data.code === 0) { message.success(t('backtest.createSuccess')) setCreateModalVisible(false) createForm.resetFields() fetchTasks() } else { message.error(response.data.msg || t('backtest.createFailed')) } } catch (error) { console.error('Failed to create backtest task:', error) message.error(t('backtest.createFailed')) } finally { setCreateLoading(false) } } // 一键创建跟单配置 const handleCreateCopyTrading = async (task: BacktestTaskDto) => { console.log('[BacktestList] handleCreateCopyTrading called, task:', task) try { // 先获取任务详情以获取配置信息 const response = await backtestService.detail({ id: task.id }) if (response.data.code === 0 && response.data.data) { const taskDetail = response.data.data.task const taskConfig = response.data.data.config console.log('[BacktestList] Fetched task detail, config:', taskConfig) if (!taskConfig) { message.error(t('backtest.fetchTaskDetailFailed') || '获取任务配置失败') return } // 预填充回测任务的配置参数(从 config 中获取) const preFilled = { leaderId: taskDetail.leaderId, copyMode: taskConfig.copyMode, copyRatio: taskConfig.copyMode === 'RATIO' ? parseFloat(taskConfig.copyRatio) * 100 : undefined, fixedAmount: taskConfig.copyMode === 'FIXED' ? taskConfig.fixedAmount : undefined, maxOrderSize: parseFloat(taskConfig.maxOrderSize), minOrderSize: parseFloat(taskConfig.minOrderSize), maxDailyLoss: parseFloat(taskConfig.maxDailyLoss), maxDailyOrders: taskConfig.maxDailyOrders, supportSell: taskConfig.supportSell, keywordFilterMode: taskConfig.keywordFilterMode || 'DISABLED', keywords: taskConfig.keywords || [], maxPositionValue: taskConfig.maxPositionValue, minPrice: taskConfig.minPrice, maxPrice: taskConfig.maxPrice, configName: `回测任务-${taskDetail.taskName}` } console.log('[BacktestList] Generated preFilled config:', preFilled) console.log('[BacktestList] Setting preFilledConfig and opening modal') setPreFilledConfig(preFilled) setAddCopyTradingModalVisible(true) } else { message.error(response.data.msg || t('backtest.fetchTaskDetailFailed') || '获取任务详情失败') } } catch (error) { console.error('[BacktestList] Failed to fetch task detail:', error) message.error(t('backtest.fetchTaskDetailFailed') || '获取任务详情失败') } } // 状态标签颜色 const getStatusColor = (status: string) => { switch (status) { case 'PENDING': return 'blue' case 'RUNNING': return 'processing' case 'COMPLETED': return 'success' case 'STOPPED': return 'warning' case 'FAILED': return 'error' default: return 'default' } } // 状态标签文本 const getStatusText = (status: string) => { switch (status) { case 'PENDING': return t('backtest.statusPending') case 'RUNNING': return t('backtest.statusRunning') case 'COMPLETED': return t('backtest.statusCompleted') case 'STOPPED': return t('backtest.statusStopped') case 'FAILED': return t('backtest.statusFailed') default: return status } } // 获取交易记录 const fetchDetailTrades = async (taskId: number, page: number) => { setDetailTradesLoading(true) try { const response = await backtestService.trades({ taskId, page, size: detailTradesSize }) if (response.data.code === 0 && response.data.data) { setDetailTrades(response.data.data.list) setDetailTradesTotal(response.data.data.total) } } catch (error) { console.error('Failed to fetch trades:', error) } finally { setDetailTradesLoading(false) } } // 获取所有交易记录(用于图表显示) const fetchDetailAllTrades = async (taskId: number) => { try { const response = await backtestService.trades({ taskId, page: 1, size: 10000 // 获取所有数据 }) if (response.data.code === 0 && response.data.data) { setDetailAllTrades(response.data.data.list) } } catch (error) { console.error('Failed to fetch all trades for chart:', error) } } // 查看详情 - 打开 Modal 而不是跳转页面 const handleViewDetail = async (id: number) => { try { const response = await backtestService.detail({ id }) if (response.data.code === 0 && response.data.data) { console.log('[BacktestList] Fetched task detail:', response.data.data) console.log('[BacktestList] Statistics:', response.data.data.statistics) setDetailTask(response.data.data.task) setDetailConfig(response.data.data.config) setDetailStatistics(response.data.data.statistics) setDetailModalVisible(true) // 获取交易记录 fetchDetailTrades(id, 1) fetchDetailAllTrades(id) } else { message.error(response.data.msg || t('backtest.fetchTaskDetailFailed')) } } catch (error) { console.error('Failed to fetch backtest task detail:', error) message.error(t('backtest.fetchTaskDetailFailed')) } } // 交易记录表格列定义 const tradeColumns = [ { title: t('backtest.tradeTime'), dataIndex: 'tradeTime', key: 'tradeTime', width: 180, render: (timestamp: number) => new Date(timestamp).toLocaleString() }, { title: t('backtest.marketTitle'), dataIndex: 'marketTitle', key: 'marketTitle', width: 250, ellipsis: true }, { title: t('backtest.side'), dataIndex: 'side', key: 'side', width: 100, render: (side: string) => ( {side === 'BUY' ? t('backtest.sideBuy') : side === 'SELL' ? t('backtest.sideSell') : t('backtest.sideSettlement')} ) }, { title: t('backtest.outcome'), dataIndex: 'outcome', key: 'outcome', width: 100 }, { title: t('backtest.quantity'), dataIndex: 'quantity', key: 'quantity', width: 100, render: (value: string) => parseFloat(value).toFixed(4) }, { title: t('backtest.price'), dataIndex: 'price', key: 'price', width: 100, render: (value: string) => parseFloat(value).toFixed(4) }, { title: t('backtest.amount') + ' ($)', dataIndex: 'amount', key: 'amount', width: 120, render: (value: string) => formatUSDC(value) }, { title: t('backtest.balanceAfter') + ' ($)', dataIndex: 'balanceAfter', key: 'balanceAfter', width: 120, render: (value: string) => formatUSDC(value) }, { title: t('backtest.leaderTradeId'), dataIndex: 'leaderTradeId', key: 'leaderTradeId', width: 150, ellipsis: true } ] const columns = [ { title: t('backtest.taskName'), dataIndex: 'taskName', key: 'taskName', width: isMobile ? 120 : 140, ellipsis: true }, { title: t('backtest.leader'), dataIndex: 'leaderName', key: 'leaderName', width: isMobile ? 100 : 120, ellipsis: true, render: (_: any, record: BacktestTaskDto) => record.leaderName || `Leader ${record.leaderId}` }, { title: t('backtest.balance'), key: 'balance', width: 160, render: (_: any, record: BacktestTaskDto) => (
{formatUSDC(record.initialBalance)}
{record.finalBalance ? formatUSDC(record.finalBalance) : '-'}
) }, { title: t('backtest.profit'), key: 'profit', width: 140, render: (_: any, record: BacktestTaskDto) => { const profitAmount = record.profitAmount ? parseFloat(record.profitAmount) : null const profitRate = record.profitRate ? parseFloat(record.profitRate) : null const color = profitAmount !== null ? (profitAmount >= 0 ? '#52c41a' : '#ff4d4f') : undefined return (
{profitAmount !== null ? formatUSDC(record.profitAmount) : '-'}
{profitRate !== null ? `${record.profitRate}%` : '-'}
) } }, { title: t('backtest.status'), dataIndex: 'status', key: 'status', width: 130, render: (status: string, record: BacktestTaskDto) => { const isRunning = status === 'RUNNING' || status === 'PENDING' return (
{getStatusText(status)} {isRunning && record.progress !== undefined && (
{record.progress}%
)}
) } }, { title: t('backtest.totalTrades'), dataIndex: 'totalTrades', key: 'totalTrades', width: 80, align: 'center' as const }, { title: t('backtest.createdAt'), dataIndex: 'createdAt', key: 'createdAt', width: isMobile ? 150 : 150, render: (timestamp: number) => new Date(timestamp).toLocaleString() }, { title: t('common.actions'), key: 'actions', fixed: isMobile ? false : ('right' as const), width: isMobile ? 100 : 180, render: (_: any, record: BacktestTaskDto) => (
handleViewDetail(record.id)} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '32px', height: '32px', cursor: 'pointer', borderRadius: '6px', transition: 'background-color 0.2s' }} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} >
{record.status === 'COMPLETED' && ( <>
handleRerun(record)} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '32px', height: '32px', cursor: 'pointer', borderRadius: '6px', transition: 'background-color 0.2s' }} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} >
handleCreateCopyTrading(record)} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '32px', height: '32px', cursor: 'pointer', borderRadius: '6px', transition: 'background-color 0.2s' }} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} >
)} {record.status === 'RUNNING' && ( handleStop(record.id)} okText={t('common.confirm')} cancelText={t('common.cancel')} >
e.currentTarget.style.backgroundColor = '#f0f0f0'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} >
)} {(record.status === 'STOPPED' || record.status === 'FAILED') && (
handleRetry(record.id)} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '32px', height: '32px', cursor: 'pointer', borderRadius: '6px', transition: 'background-color 0.2s' }} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} >
)} {(record.status === 'PENDING' || record.status === 'COMPLETED' || record.status === 'STOPPED' || record.status === 'FAILED') && ( handleDelete(record.id)} okText={t('common.confirm')} cancelText={t('common.cancel')} >
e.currentTarget.style.backgroundColor = '#fff1f0'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} >
)}
) } ] return (

{t('backtest.title')}

{/* 筛选栏 */} setLeaderIdFilter(value)} leaders={leaders} /> {/* 数据:移动端卡片 / 桌面端表格 */} {isMobile ? ( <> {loading ? (
) : tasks.length === 0 ? ( ) : ( (
{task.taskName}
{task.leaderName || `Leader ${task.leaderId}`}
{t('backtest.profitAmount')}
{task.profitAmount != null ? (
= 0 ? '#52c41a' : '#ff4d4f' }}>${formatUSDC(task.profitAmount)}
) : (
-
)}
{t('backtest.profitRate')}
{task.profitRate != null ? (
= 0 ? '#52c41a' : '#ff4d4f' }}>{task.profitRate}%
) : (
-
)}
{t('backtest.status')}
{getStatusText(task.status)}
{t('backtest.initialBalance')}: {formatUSDC(task.initialBalance)} · {t('backtest.backtestDays')}: {task.backtestDays} {t('common.day')} · {t('backtest.progress')}: {task.progress}%
handleViewDetail(task.id)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}> {t('common.viewDetail')}
{task.status === 'COMPLETED' && ( <>
handleRerun(task)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}> {t('backtest.rerun')}
handleCreateCopyTrading(task)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}> {t('backtest.createCopyTrading')}
)} {task.status === 'RUNNING' && (
handleStop(task.id)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}> {t('backtest.stop')}
)} {(task.status === 'STOPPED' || task.status === 'FAILED') && (
handleRetry(task.id)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}> {t('backtest.retry')}
)} {(task.status === 'PENDING' || task.status === 'COMPLETED' || task.status === 'STOPPED' || task.status === 'FAILED') && (
handleDelete(task.id)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}> {t('common.delete')}
)}
)} /> )} {/* 移动端分页 */} {!loading && tasks.length > 0 && total > size && (
{page} / {Math.ceil(total / size)}
)} ) : ( `${t('common.total')} ${totalCount} ${t('common.items')}`, onChange: (newPage) => setPage(newPage), simple: isMobile }} scroll={{ x: 1000 }} /> )} {/* 重新测试 Modal */} { setRerunModalVisible(false) setRerunTask(null) setRerunTaskName('') }} onOk={handleRerunSubmit} okText={t('common.confirm')} cancelText={t('common.cancel')} confirmLoading={rerunLoading} destroyOnClose >

{t('backtest.rerunConfirm')}

setRerunTaskName(e.target.value)} placeholder={t('backtest.rerunTaskNamePlaceholder')} maxLength={100} />
{/* 创建回测任务 Modal */} { setCreateModalVisible(false) createForm.resetFields() }} onOk={handleCreateSubmit} okText={t('common.save')} cancelText={t('common.cancel')} width={isMobile ? '95%' : 800} confirmLoading={createLoading} destroyOnClose style={{ top: isMobile ? 10 : 20 }} bodyStyle={{ maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(100vh - 200px)', overflowY: 'auto' }} >
{/* 跟单配置 */}

{t('backtest.config')}

{copyMode === 'RATIO' && ( { const parsed = parseFloat(value || '0') if (parsed > 10000) return 10000 return parsed }} formatter={(value) => { if (!value && value !== 0) return '' const num = parseFloat(value.toString()) if (isNaN(num)) return '' if (num > 10000) return '10000' return num.toString().replace(/\.0+$/, '') }} /> )} {copyMode === 'FIXED' && ( )}
{ if (!value && value !== 0) return '' const num = parseFloat(value.toString()) if (isNaN(num)) return '' return num.toString().replace(/\.0+$/, '') }} /> { if (!value && value !== 0) return '' const num = parseFloat(value.toString()) if (isNaN(num)) return '' return num.toString().replace(/\.0+$/, '') }} /> {t('backtest.supportSellHint') || '是否跟随 Leader 卖出'}
`${t('common.total')} ${total} ${t('common.items')}`, onChange: (newPage) => { setDetailTradesPage(newPage) if (detailTask) { fetchDetailTrades(detailTask.id, newPage) } } }} scroll={isMobile ? { x: 1200 } : { x: 1800 }} size="small" /> )} ) } export default BacktestList