feat(backtest): 回测结算持久化、按配置重新测试、执行时以当前时间为窗口基准

- 持久化: BUY/SELL 与 SETTLEMENT(WIN/LOSE/UNKNOWN/CLOSED) 均写入 backtest_trade
- 重新测试: 已完成任务支持「按当前配置重新测试」,新任务名称可编辑,后端 POST /tasks/rerun + 前端按钮与确认弹窗
- 回测窗口: 首次执行以当前时间为终点、startTime = endTime - backtestDays(局部变量,不修改实体)
- 新增错误码 BACKTEST_TASK_NOT_COMPLETED、SERVER_BACKTEST_RERUN_FAILED 及多语言

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WrBug
2026-02-09 01:08:40 +08:00
co-authored by Cursor
parent 01a1836659
commit b1ad6f02d2
20 changed files with 526 additions and 199 deletions
+67
View File
@@ -0,0 +1,67 @@
import React from 'react'
import { Select } from 'antd'
import type { Leader } from '../types'
const { Option } = Select
interface LeaderSelectProps {
value?: number
onChange?: (value: number | undefined) => void
onSelectChange?: (value: number | undefined) => void // 选择变化时的回调
leaders: Leader[]
placeholder?: string
disabled?: boolean
showSearch?: boolean
allowClear?: boolean
notFoundContent?: React.ReactNode
}
const LeaderSelect: React.FC<LeaderSelectProps> = ({
value,
onChange,
onSelectChange,
leaders,
placeholder,
disabled,
showSearch = true,
allowClear = false,
notFoundContent
}) => {
// 处理选择变化
const handleChange = (val: number | undefined) => {
if (onChange) {
onChange(val)
}
if (onSelectChange) {
onSelectChange(val)
}
}
return (
<Select
value={value}
onChange={handleChange}
placeholder={placeholder}
disabled={disabled}
showSearch={showSearch}
allowClear={allowClear}
notFoundContent={notFoundContent}
optionFilterProp="label"
optionLabelProp="label"
>
{leaders.map(leader => {
const label = leader.leaderName || `Leader ${leader.id}`
return (
<Option key={leader.id} value={leader.id} label={label}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span>{label}</span>
<span style={{ fontSize: '12px', color: '#999' }}>{leader.leaderAddress}</span>
</div>
</Option>
)
})}
</Select>
)
}
export default LeaderSelect
+6 -1
View File
@@ -1363,6 +1363,11 @@
"supportSellHint": "Whether to follow Leader sell orders",
"sortBy": "Sort By",
"sortOrder": "Sort Order",
"createdAt": "Created At"
"createdAt": "Created At",
"rerun": "Re-run",
"rerunConfirm": "Create a new backtest task with the same config?",
"rerunTaskNamePlaceholder": "New task name (leave empty for \"Original name (copy)\")",
"rerunSuccess": "New backtest task created",
"rerunFailed": "Re-run failed"
}
}
+6 -1
View File
@@ -1363,6 +1363,11 @@
"supportSellHint": "是否跟随 Leader 卖出",
"sortBy": "排序字段",
"sortOrder": "排序顺序",
"createdAt": "创建时间"
"createdAt": "创建时间",
"rerun": "重新测试",
"rerunConfirm": "将按当前配置创建新的回测任务,是否继续?",
"rerunTaskNamePlaceholder": "新任务名称(留空使用「原名称 (副本)」)",
"rerunSuccess": "已创建新回测任务",
"rerunFailed": "重新测试失败"
}
}
+6 -1
View File
@@ -1363,6 +1363,11 @@
"supportSellHint": "是否跟隨 Leader 賣出",
"sortBy": "排序欄位",
"sortOrder": "排序順序",
"createdAt": "創建時間"
"createdAt": "創建時間",
"rerun": "重新測試",
"rerunConfirm": "將按當前配置創建新的回測任務,是否繼續?",
"rerunTaskNamePlaceholder": "新任務名稱(留空使用「原名稱 (副本)」)",
"rerunSuccess": "已創建新回測任務",
"rerunFailed": "重新測試失敗"
}
}
+92 -21
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { Table, Card, Button, Select, Tag, Space, Modal, message, Row, Col, Form, Input, InputNumber, Switch, Statistic, Descriptions } from 'antd'
import { useTranslation } from 'react-i18next'
import { PlusOutlined, ReloadOutlined, DeleteOutlined, StopOutlined, EyeOutlined, RedoOutlined, CopyOutlined } from '@ant-design/icons'
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'
@@ -9,8 +9,7 @@ import type { Leader } from '../types'
import { useMediaQuery } from 'react-responsive'
import AddCopyTradingModal from './CopyTradingOrders/AddModal'
import BacktestChart from './BacktestChart'
const { Option } = Select
import LeaderSelect from '../components/LeaderSelect'
const BacktestList: React.FC = () => {
const { t } = useTranslation()
@@ -36,6 +35,12 @@ const BacktestList: React.FC = () => {
const [addCopyTradingModalVisible, setAddCopyTradingModalVisible] = useState(false)
const [preFilledConfig, setPreFilledConfig] = useState<any>(null)
// 重新测试 Modal 相关状态
const [rerunModalVisible, setRerunModalVisible] = useState(false)
const [rerunTask, setRerunTask] = useState<BacktestTaskDto | null>(null)
const [rerunTaskName, setRerunTaskName] = useState('')
const [rerunLoading, setRerunLoading] = useState(false)
// 任务详情 Modal 相关状态
const [detailModalVisible, setDetailModalVisible] = useState(false)
const [detailTask, setDetailTask] = useState<BacktestTaskDto | null>(null)
@@ -130,6 +135,38 @@ const BacktestList: React.FC = () => {
})
}
// 按配置重新测试(仅已完成任务)
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({
@@ -545,14 +582,24 @@ const BacktestList: React.FC = () => {
{t('common.viewDetail')}
</Button>
{record.status === 'COMPLETED' && (
<Button
type="link"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCreateCopyTrading(record)}
>
{t('backtest.createCopyTrading')}
</Button>
<>
<Button
type="link"
size="small"
icon={<SyncOutlined />}
onClick={() => handleRerun(record)}
>
{t('backtest.rerun')}
</Button>
<Button
type="link"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCreateCopyTrading(record)}
>
{t('backtest.createCopyTrading')}
</Button>
</>
)}
{record.status === 'RUNNING' && (
<Button
@@ -675,6 +722,30 @@ const BacktestList: React.FC = () => {
</Space>
</Card>
{/* 重新测试 Modal */}
<Modal
title={t('backtest.rerun')}
open={rerunModalVisible}
onCancel={() => {
setRerunModalVisible(false)
setRerunTask(null)
setRerunTaskName('')
}}
onOk={handleRerunSubmit}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
confirmLoading={rerunLoading}
destroyOnClose
>
<p style={{ marginBottom: 8 }}>{t('backtest.rerunConfirm')}</p>
<Input
value={rerunTaskName}
onChange={(e) => setRerunTaskName(e.target.value)}
placeholder={t('backtest.rerunTaskNamePlaceholder')}
maxLength={100}
/>
</Modal>
{/* 创建回测任务 Modal */}
<Modal
title={t('backtest.createTask')}
@@ -719,16 +790,10 @@ const BacktestList: React.FC = () => {
name="leaderId"
rules={[{ required: true, message: t('backtest.leaderRequired') || '请选择 Leader' }]}
>
<Select placeholder={t('backtest.leader')} showSearch>
{leaders.map((leader) => (
<Option key={leader.id} value={leader.id}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span>{leader.leaderName || `Leader ${leader.id}`}</span>
<span style={{ fontSize: '12px', color: '#999' }}>{leader.leaderAddress}</span>
</div>
</Option>
))}
</Select>
<LeaderSelect
leaders={leaders}
placeholder={t('backtest.leader')}
/>
</Form.Item>
</Col>
</Row>
@@ -945,6 +1010,12 @@ const BacktestList: React.FC = () => {
footer={
detailTask && detailTask.status === 'COMPLETED' && detailConfig ? (
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button icon={<SyncOutlined />} onClick={() => {
setDetailModalVisible(false)
handleRerun(detailTask)
}}>
{t('backtest.rerun')}
</Button>
<Button type="primary" icon={<CopyOutlined />} onClick={() => {
const preFilled = {
leaderId: detailTask.leaderId,
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive'
import AccountImportForm from '../../components/AccountImportForm'
import LeaderAddForm from '../../components/LeaderAddForm'
import LeaderSelect from '../../components/LeaderSelect'
const { Option } = Select
@@ -467,8 +468,10 @@ const AddModal: React.FC<AddModalProps> = ({
name="leaderId"
rules={[{ required: true, message: t('copyTradingAdd.leaderRequired') || '请选择 Leader' }]}
>
<Select
<LeaderSelect
leaders={leaders}
placeholder={t('copyTradingAdd.selectLeaderPlaceholder') || '请选择 Leader'}
onSelectChange={(value) => fetchLeaderAssetInfo(value)}
notFoundContent={
leaders.length === 0 ? (
<div style={{ textAlign: 'center', padding: '12px' }}>
@@ -484,14 +487,7 @@ const AddModal: React.FC<AddModalProps> = ({
</div>
) : null
}
onChange={(value) => fetchLeaderAssetInfo(value)}
>
{leaders.map(leader => (
<Option key={leader.id} value={leader.id}>
{leader.leaderName || `Leader ${leader.id}`} ({leader.leaderAddress.slice(0, 6)}...{leader.leaderAddress.slice(-4)})
</Option>
))}
</Select>
/>
</Form.Item>
{/* Leader 资产信息 */}
@@ -323,7 +323,10 @@ const EditModal: React.FC<EditModalProps> = ({
>
<Select disabled>
<Option value={copyTrading.leaderId}>
{copyTrading.leaderName || `Leader ${copyTrading.leaderId}`} ({copyTrading.leaderAddress.slice(0, 6)}...{copyTrading.leaderAddress.slice(-4)})
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span>{copyTrading.leaderName || `Leader ${copyTrading.leaderId}`}</span>
<span style={{ fontSize: '12px', color: '#999' }}>{copyTrading.leaderAddress}</span>
</div>
</Option>
</Select>
</Form.Item>
+6 -1
View File
@@ -797,6 +797,11 @@ export const backtestService = {
/**
*
*/
retry: (data: { id: number }) => apiClient.post('/backtest/tasks/retry', data)
retry: (data: { id: number }) => apiClient.post('/backtest/tasks/retry', data),
/**
*
*/
rerun: (data: { id: number; taskName?: string }) => apiClient.post('/backtest/tasks/rerun', data)
}