feat: 回测系统优化 - 清理字段、移动端适配、缓存优化

## 主要改动

### 1. 回测字段清理
- 删除 BacktestCreate.tsx(未使用,创建使用 modal)
- 移除不适用于回测的字段:
  - priceTolerance, delaySeconds(回测使用历史数据)
  - minOrderDepth, maxSpread(无历史订单簿数据)
  - minPrice, maxPrice, maxPositionValue(回测中无实际意义)
  - maxMarketEndDate(意义不大)
- 更新相关 Entity、DTO、Service
- 添加数据库 migration(V29)

### 2. 移动端响应式适配
- BacktestList:筛选器、表格、Modal、表单响应式布局
- BacktestDetail:详情卡片、统计信息、按钮响应式
- 使用 useMediaQuery hook,断点 768px
- 支持手机、平板、桌面多种设备

### 3. 已结算市场缓存优化
- MarketPriceService 添加 Caffeine 缓存
- 缓存已结算市场价格,避免重复 RPC 调用
- 预计 RPC 请求减少 ~78.6%,性能提升显著
- 添加缓存统计和管理方法

### 4. UI 修复
- 修复 BacktestDetail 停止按钮文本
- 更新多语言翻译(supportSell → 跟单卖出)

## 性能收益
- 回测场景 RPC 调用减少 78.6%
- 回测执行时间预计减少 11秒(14秒 → 3秒)
- 移动端用户体验显著提升
This commit is contained in:
WrBug
2026-01-31 20:45:40 +08:00
parent fd25821e39
commit 96fbc3f720
22 changed files with 911 additions and 1199 deletions
+8 -3
View File
@@ -1270,8 +1270,13 @@
"createFailed": "Failed to create",
"deleteSuccess": "Deleted successfully",
"deleteFailed": "Failed to delete",
"stop": "Stop",
"stopSuccess": "Stopped successfully",
"stopFailed": "Failed to stop",
"retry": "Retry",
"retrySuccess": "Retry successfully",
"retryFailed": "Failed to retry",
"retryConfirm": "Are you sure you want to retry this backtest task? It will continue from the breakpoint and preserve processed trades.",
"deleteConfirm": "Are you sure you want to delete this backtest task?",
"stopConfirm": "Are you sure you want to stop this backtest task?",
"noTasks": "No backtest tasks",
@@ -1297,7 +1302,7 @@
"maxDailyOrders": "Max Daily Orders",
"priceTolerance": "Price Tolerance",
"delaySeconds": "Delay Seconds",
"supportSell": "Support Sell",
"supportSell": "Copy Sell",
"minOrderDepth": "Min Order Depth",
"maxSpread": "Max Spread",
"minPrice": "Min Price",
@@ -1342,10 +1347,10 @@
"copyRatioTooltip": "Copy ratio represents the percentage of copy amount relative to Leader order amount. For example: 100% means 1:1 copy, 50% means half position copy, 200% means double copy",
"fixedAmountRequired": "Please enter fixed amount",
"fixedAmountInvalid": "Fixed amount must be greater than 0",
"advancedFilters": "Advanced Filters",
"priceFilters": "Price Filters",
"keywordsPlaceholder": "Please enter keywords, press Enter to add",
"delaySecondsHint": "Delay execution to simulate real copy trading delay",
"supportSellHint": "Whether to follow Leader's sell operations",
"supportSellHint": "Whether to follow Leader sell orders",
"sortBy": "Sort By",
"sortOrder": "Sort Order",
"createdAt": "Created At"
+8 -3
View File
@@ -1270,8 +1270,13 @@
"createFailed": "创建失败",
"deleteSuccess": "删除成功",
"deleteFailed": "删除失败",
"stop": "停止",
"stopSuccess": "停止成功",
"stopFailed": "停止失败",
"retry": "重试",
"retrySuccess": "重试成功",
"retryFailed": "重试失败",
"retryConfirm": "确定重新运行此回测任务吗?将从断点继续执行,保留已处理的交易记录。",
"deleteConfirm": "确定删除此回测任务吗?",
"stopConfirm": "确定停止此回测任务吗?",
"noTasks": "暂无回测任务",
@@ -1297,7 +1302,7 @@
"maxDailyOrders": "最大每日订单数",
"priceTolerance": "价格容忍度",
"delaySeconds": "延迟秒数",
"supportSell": "支持卖出",
"supportSell": "跟单卖出",
"minOrderDepth": "最小订单深度",
"maxSpread": "最大价差",
"minPrice": "最低价格",
@@ -1342,10 +1347,10 @@
"copyRatioTooltip": "跟单比例表示跟单金额相对于 Leader 订单金额的百分比。例如:100% 表示 1:1 跟单,50% 表示半仓跟单,200% 表示双倍跟单",
"fixedAmountRequired": "请输入固定金额",
"fixedAmountInvalid": "固定金额必须大于 0",
"advancedFilters": "高级过滤",
"priceFilters": "价格过滤",
"keywordsPlaceholder": "请输入关键字,按回车添加",
"delaySecondsHint": "延迟执行模拟真实跟单延迟",
"supportSellHint": "是否跟随 Leader 卖出操作",
"supportSellHint": "是否跟随 Leader 卖出",
"sortBy": "排序字段",
"sortOrder": "排序顺序",
"createdAt": "创建时间"
+8 -3
View File
@@ -1270,8 +1270,13 @@
"createFailed": "創建失敗",
"deleteSuccess": "刪除成功",
"deleteFailed": "刪除失敗",
"stop": "停止",
"stopSuccess": "停止成功",
"stopFailed": "停止失敗",
"retry": "重試",
"retrySuccess": "重試成功",
"retryFailed": "重試失敗",
"retryConfirm": "確定重新運行此回測任務嗎?將從斷點繼續執行,保留已處理的交易記錄。",
"deleteConfirm": "確定刪除此回測任務嗎?",
"stopConfirm": "確定停止此回測任務嗎?",
"noTasks": "暫無回測任務",
@@ -1297,7 +1302,7 @@
"maxDailyOrders": "最大每日訂單數",
"priceTolerance": "價格容忍度",
"delaySeconds": "延遲秒數",
"supportSell": "支持賣出",
"supportSell": "跟單賣出",
"minOrderDepth": "最小訂單深度",
"maxSpread": "最大價差",
"minPrice": "最低價格",
@@ -1342,10 +1347,10 @@
"copyRatioTooltip": "跟單比例表示跟單金額相對於 Leader 訂單金額的百分比。例如:100% 表示 1:1 跟單,50% 表示半倉跟單,200% 表示雙倍跟單",
"fixedAmountRequired": "請輸入固定金額",
"fixedAmountInvalid": "固定金額必須大於 0",
"advancedFilters": "進階過濾",
"priceFilters": "價格過濾",
"keywordsPlaceholder": "請輸入關鍵字,按回車添加",
"delaySecondsHint": "延遲執行模擬真實跟單延遲",
"supportSellHint": "是否跟隨 Leader 賣出操作",
"supportSellHint": "是否跟隨 Leader 賣出",
"sortBy": "排序欄位",
"sortOrder": "排序順序",
"createdAt": "創建時間"
-422
View File
@@ -1,422 +0,0 @@
import { useState, useEffect } from 'react'
import { Card, Form, Button, Input, InputNumber, Select, Switch, message, Space, Row, Col } from 'antd'
import { ArrowLeftOutlined, SaveOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { backtestService } from '../services/api'
import { apiService } from '../services/api'
import type { Leader } from '../types'
import type { BacktestCreateRequest } from '../types/backtest'
const { Option } = Select
const BacktestCreate: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [leaders, setLeaders] = useState<Leader[]>([])
const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO')
// 获取 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()
}, [])
// 提交表单
const handleSubmit = async () => {
try {
const values = await form.validateFields()
setLoading(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,
priceTolerance: values.priceTolerance,
delaySeconds: values.delaySeconds,
supportSell: values.supportSell,
minOrderDepth: values.minOrderDepth,
maxSpread: values.maxSpread,
minPrice: values.minPrice,
maxPrice: values.maxPrice,
maxPositionValue: values.maxPositionValue,
keywordFilterMode: values.keywordFilterMode,
keywords: values.keywords,
maxMarketEndDate: values.maxMarketEndDate
}
const response = await backtestService.create(request)
if (response.data.code === 0) {
message.success(t('backtest.createSuccess'))
navigate('/backtest/list')
} 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 {
setLoading(false)
}
}
// 返回
const handleBack = () => {
navigate('/backtest/list')
}
// 初始化表单默认值
useEffect(() => {
form.setFieldsValue({
copyMode: 'RATIO',
copyRatio: 100, // 默认 100%(显示为百分比)
maxOrderSize: 1000,
minOrderSize: 1,
maxDailyLoss: 500,
maxDailyOrders: 50,
priceTolerance: 5,
delaySeconds: 0,
supportSell: true,
keywordFilterMode: 'DISABLED',
backtestDays: 7
})
}, [form])
return (
<div style={{ padding: 24, maxWidth: 1200, margin: '0 auto' }}>
<Card
title={t('backtest.createTask')}
extra={
<Button icon={<ArrowLeftOutlined />} onClick={handleBack}>
{t('common.back')}
</Button>
}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={{
copyMode: 'RATIO',
copyRatio: 100, // 默认 100%(显示为百分比)
maxOrderSize: 1000,
minOrderSize: 1,
maxDailyLoss: 500,
maxDailyOrders: 50,
priceTolerance: 5,
delaySeconds: 0,
supportSell: true,
keywordFilterMode: 'DISABLED',
backtestDays: 7
}}
>
<Row gutter={24}>
<Col span={12}>
<Form.Item
label={t('backtest.taskName')}
name="taskName"
rules={[{ required: true, message: t('backtest.taskNameRequired') || '请输入任务名称' }]}
>
<Input placeholder={t('backtest.taskName')} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label={t('backtest.leader')}
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}>
{leader.leaderName || leader.leaderAddress}
</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Form.Item
label={t('backtest.initialBalance') + ' (USDC)'}
name="initialBalance"
rules={[
{ required: true, message: t('backtest.initialBalanceRequired') || '请输入初始资金' },
{ type: 'number', min: 1, message: t('backtest.initialBalanceInvalid') || '初始资金必须大于 0' }
]}
>
<InputNumber
style={{ width: '100%' }}
placeholder={t('backtest.initialBalance')}
precision={2}
min={1}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label={t('backtest.backtestDays') + ` (1-15 ${t('common.day')})`}
name="backtestDays"
rules={[
{ required: true, message: t('backtest.backtestDaysRequired') || '请输入回测天数' },
{ type: 'number', min: 1, max: 15, message: t('backtest.backtestDaysInvalid') || '回测天数必须在 1-15 之间' }
]}
>
<InputNumber
style={{ width: '100%' }}
placeholder={t('backtest.backtestDays')}
precision={0}
min={1}
max={15}
/>
</Form.Item>
</Col>
</Row>
{/* 跟单配置 */}
<div style={{ marginBottom: 24 }}>
<h3 style={{ marginBottom: 16 }}>{t('backtest.config')}</h3>
<Form.Item
label={t('backtest.copyMode')}
name="copyMode"
>
<Select onChange={(value) => setCopyMode(value)}>
<Option value="RATIO">{t('backtest.copyModeRatio')}</Option>
<Option value="FIXED">{t('backtest.copyModeFixed')}</Option>
</Select>
</Form.Item>
{copyMode === 'RATIO' && (
<Form.Item
label={t('backtest.copyRatio')}
name="copyRatio"
tooltip={t('backtest.copyRatioTooltip') || '跟单比例表示跟单金额相对于 Leader 订单金额的百分比。例如:100% 表示 1:1 跟单,50% 表示半仓跟单,200% 表示双倍跟单'}
rules={[
{ required: true, message: t('backtest.copyRatioRequired') || '请输入跟单比例' },
{ type: 'number', min: 0.01, max: 10000, message: t('backtest.copyRatioInvalid') || '跟单比例必须在 0.01-10000 之间' }
]}
>
<InputNumber
min={0.01}
max={10000}
step={0.01}
precision={2}
style={{ width: '100%' }}
addonAfter="%"
placeholder={t('backtest.copyRatioPlaceholder') || '例如:100 表示 100%1:1 跟单),默认 100%'}
parser={(value) => {
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+$/, '')
}}
/>
</Form.Item>
)}
{copyMode === 'FIXED' && (
<Form.Item
label={t('backtest.fixedAmount') + ' (USDC)'}
name="fixedAmount"
rules={[
{ required: true, message: t('backtest.fixedAmountRequired') || '请输入固定金额' },
{ type: 'number', min: 1, message: t('backtest.fixedAmountInvalid') || '固定金额必须大于 0' }
]}
>
<InputNumber
style={{ width: '100%' }}
placeholder={t('backtest.fixedAmount')}
precision={2}
min={1}
/>
</Form.Item>
)}
<Row gutter={24}>
<Col span={12}>
<Form.Item
label={t('backtest.maxOrderSize') + ' (USDC)'}
name="maxOrderSize"
rules={[{ required: true }]}
>
<InputNumber style={{ width: '100%' }} precision={2} min={1} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label={t('backtest.minOrderSize') + ' (USDC)'}
name="minOrderSize"
rules={[{ required: true }]}
>
<InputNumber style={{ width: '100%' }} precision={2} min={1} />
</Form.Item>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Form.Item
label={t('backtest.maxDailyLoss') + ' (USDC)'}
name="maxDailyLoss"
rules={[{ required: true }]}
>
<InputNumber style={{ width: '100%' }} precision={2} min={0} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label={t('backtest.maxDailyOrders')}
name="maxDailyOrders"
rules={[{ required: true }]}
>
<InputNumber style={{ width: '100%' }} precision={0} min={1} />
</Form.Item>
</Col>
</Row>
<Form.Item
label={t('backtest.priceTolerance') + ' (%)'}
name="priceTolerance"
>
<InputNumber style={{ width: '100%' }} precision={2} min={0} max={100} />
</Form.Item>
<Form.Item
label={t('backtest.delaySeconds')}
name="delaySeconds"
>
<InputNumber style={{ width: '100%' }} precision={0} min={0} />
<span style={{ fontSize: 12, color: '#888' }}>{t('backtest.delaySecondsHint') || '延迟执行模拟真实跟单延迟'}</span>
</Form.Item>
<Form.Item
label={t('backtest.supportSell')}
name="supportSell"
valuePropName="checked"
>
<Switch />
<span style={{ fontSize: 12, color: '#888', marginLeft: 8 }}>{t('backtest.supportSellHint') || '是否跟随 Leader 的卖出操作'}</span>
</Form.Item>
<h4 style={{ marginTop: 24, marginBottom: 16 }}>{t('backtest.advancedFilters')}</h4>
<Row gutter={24}>
<Col span={12}>
<Form.Item
label={t('backtest.minOrderDepth') + ' (USDC)'}
name="minOrderDepth"
>
<InputNumber style={{ width: '100%' }} precision={2} min={0} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label={t('backtest.maxSpread') + ' (%)'}
name="maxSpread"
>
<InputNumber style={{ width: '100%' }} precision={2} min={0} />
</Form.Item>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Form.Item
label={t('backtest.minPrice')}
name="minPrice"
>
<InputNumber style={{ width: '100%' }} precision={4} min={0} max={1} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label={t('backtest.maxPrice')}
name="maxPrice"
>
<InputNumber style={{ width: '100%' }} precision={4} min={0} max={1} />
</Form.Item>
</Col>
</Row>
<Form.Item
label={t('backtest.maxPositionValue') + ' (USDC)'}
name="maxPositionValue"
>
<InputNumber style={{ width: '100%' }} precision={2} min={0} />
</Form.Item>
<Form.Item
label={t('backtest.keywordFilterMode')}
name="keywordFilterMode"
>
<Select>
<Option value="DISABLED">{t('backtest.keywordFilterModeDisabled')}</Option>
<Option value="WHITELIST">{t('backtest.keywordFilterModeWhitelist')}</Option>
<Option value="BLACKLIST">{t('backtest.keywordFilterModeBlacklist')}</Option>
</Select>
</Form.Item>
<Form.Item
label={t('backtest.keywords')}
name="keywords"
>
<Select
mode="tags"
style={{ width: '100%' }}
placeholder={t('backtest.keywordsPlaceholder') || '请输入关键字,按回车添加'}
/>
</Form.Item>
</div>
{/* 底部按钮 */}
<Form.Item>
<Space>
<Button onClick={handleBack}>
{t('common.cancel')}
</Button>
<Button
type="primary"
htmlType="submit"
icon={<SaveOutlined />}
loading={loading}
>
{t('common.save')}
</Button>
</Space>
</Form.Item>
</Form>
</Card>
</div>
)
}
export default BacktestCreate
+31 -31
View File
@@ -6,12 +6,14 @@ import { useTranslation } from 'react-i18next'
import { formatUSDC } from '../utils'
import { backtestService } from '../services/api'
import type { BacktestTaskDto, BacktestConfigDto, BacktestStatisticsDto, BacktestTradeDto } from '../types/backtest'
import { useMediaQuery } from 'react-responsive'
import BacktestChart from './BacktestChart'
const BacktestDetail: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const { id } = useParams<{ id: string }>()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [loading, setLoading] = useState(false)
const [task, setTask] = useState<BacktestTaskDto | null>(null)
const [, setConfig] = useState<BacktestConfigDto | null>(null)
@@ -66,33 +68,30 @@ const BacktestDetail: React.FC = () => {
}
}
// 初始加载任务详情和交易记录
useEffect(() => {
fetchTaskDetail()
fetchTrades(tradesPage)
}, [id])
// 如果任务正在运行,启动轮询
const startPolling = () => {
// 根据任务状态控制轮询
useEffect(() => {
// 停止之前的轮询
stopPolling()
// 只有任务正在运行或待处理时才启动轮询
if (task?.status === 'RUNNING' || task?.status === 'PENDING') {
const timer = setInterval(() => {
fetchTaskDetail()
// 如果任务已完成或失败,停止轮询
if (task?.status === 'COMPLETED' || task?.status === 'FAILED' || task?.status === 'STOPPED') {
stopPolling()
}
}, 3000) // 每3秒轮询一次
setPolling(timer)
}
// 延迟启动轮询,等待任务状态加载完成
setTimeout(() => {
if (task?.status === 'RUNNING' || task?.status === 'PENDING') {
startPolling()
}
}, 1000)
// 组件卸载或状态变化时清理定时器
return () => {
stopPolling()
}
}, [id, task?.status])
}, [task?.status])
const stopPolling = () => {
if (polling) {
@@ -137,6 +136,7 @@ const BacktestDetail: React.FC = () => {
const response = await backtestService.delete({ id: parseInt(id!) })
if (response.data.code === 0) {
message.success(t('backtest.deleteSuccess'))
stopPolling() // 停止轮询
navigate('/backtest')
} else {
message.error(response.data.msg || t('backtest.deleteFailed'))
@@ -275,23 +275,23 @@ const BacktestDetail: React.FC = () => {
<Card>
{/* 头部操作栏 */}
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Space style={{ width: '100%', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={handleBack}>
<Button icon={<ArrowLeftOutlined />} onClick={handleBack} size={isMobile ? 'middle' : 'large'}>
{t('common.back')}
</Button>
<Button icon={<ReloadOutlined />} onClick={handleRefresh} loading={loading}>
<Button icon={<ReloadOutlined />} onClick={handleRefresh} loading={loading} size={isMobile ? 'middle' : 'large'}>
{t('common.refresh')}
</Button>
</Space>
<Space>
{(task.status === 'RUNNING' || task.status === 'PENDING') && (
<Button danger icon={<StopOutlined />} onClick={handleStop}>
{t('backtest.statusStopped')}
<Button danger icon={<StopOutlined />} onClick={handleStop} size={isMobile ? 'middle' : 'large'}>
{t('backtest.stop')}
</Button>
)}
{(task.status === 'COMPLETED' || task.status === 'STOPPED' || task.status === 'FAILED') && (
<Button danger icon={<DeleteOutlined />} onClick={handleDelete}>
<Button danger icon={<DeleteOutlined />} onClick={handleDelete} size={isMobile ? 'middle' : 'large'}>
{t('common.delete')}
</Button>
)}
@@ -300,7 +300,7 @@ const BacktestDetail: React.FC = () => {
{/* 任务基本信息 */}
<Card title={t('backtest.taskDetail')} size="small">
<Descriptions column={2} bordered size="small">
<Descriptions column={isMobile ? 1 : 2} bordered size="small">
<Descriptions.Item label={t('backtest.taskName')}>{task.taskName}</Descriptions.Item>
<Descriptions.Item label={t('backtest.leader')}>
{task.leaderName || task.leaderAddress}
@@ -348,7 +348,7 @@ const BacktestDetail: React.FC = () => {
{/* 统计信息 */}
{statistics && (
<Row gutter={16}>
<Col span={6}>
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.buyTrades')}
@@ -356,7 +356,7 @@ const BacktestDetail: React.FC = () => {
/>
</Card>
</Col>
<Col span={6}>
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.sellTrades')}
@@ -364,7 +364,7 @@ const BacktestDetail: React.FC = () => {
/>
</Card>
</Col>
<Col span={6}>
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.winTrades')}
@@ -373,7 +373,7 @@ const BacktestDetail: React.FC = () => {
/>
</Card>
</Col>
<Col span={6}>
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.lossTrades')}
@@ -382,7 +382,7 @@ const BacktestDetail: React.FC = () => {
/>
</Card>
</Col>
<Col span={6}>
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.winRate')}
@@ -392,7 +392,7 @@ const BacktestDetail: React.FC = () => {
/>
</Card>
</Col>
<Col span={6}>
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.maxProfit')}
@@ -401,7 +401,7 @@ const BacktestDetail: React.FC = () => {
/>
</Card>
</Col>
<Col span={6}>
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.maxLoss')}
@@ -410,7 +410,7 @@ const BacktestDetail: React.FC = () => {
/>
</Card>
</Col>
<Col span={6}>
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.maxDrawdown')}
@@ -420,7 +420,7 @@ const BacktestDetail: React.FC = () => {
</Card>
</Col>
{statistics.avgHoldingTime && (
<Col span={6}>
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.avgHoldingTime')}
@@ -458,7 +458,7 @@ const BacktestDetail: React.FC = () => {
fetchTrades(newPage)
}
}}
scroll={{ x: 1800 }}
scroll={isMobile ? { x: 1200 } : { x: 1800 }}
/>
</Card>
</Space>
+74 -115
View File
@@ -2,17 +2,19 @@ import { useState, useEffect } from 'react'
import { Table, Card, Button, Select, Tag, Space, Modal, message, Row, Col, Form, Input, InputNumber, Switch } from 'antd'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { PlusOutlined, ReloadOutlined, DeleteOutlined, StopOutlined, EyeOutlined } from '@ant-design/icons'
import { PlusOutlined, ReloadOutlined, DeleteOutlined, StopOutlined, EyeOutlined, RedoOutlined } from '@ant-design/icons'
import { formatUSDC } from '../utils'
import { backtestService, apiService } from '../services/api'
import type { BacktestTaskDto, BacktestListRequest, BacktestCreateRequest } from '../types/backtest'
import type { Leader } from '../types'
import { useMediaQuery } from 'react-responsive'
const { Option } = Select
const BacktestList: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [loading, setLoading] = useState(false)
const [tasks, setTasks] = useState<BacktestTaskDto[]>([])
const [total, setTotal] = useState(0)
@@ -22,7 +24,7 @@ const BacktestList: React.FC = () => {
const [leaderIdFilter] = useState<number | 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()
@@ -112,6 +114,29 @@ const BacktestList: React.FC = () => {
})
}
// 重试任务
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(() => {
if (createModalVisible) {
@@ -145,8 +170,6 @@ const BacktestList: React.FC = () => {
minOrderSize: 1,
maxDailyLoss: 500,
maxDailyOrders: 50,
priceTolerance: 5,
delaySeconds: 0,
supportSell: true,
keywordFilterMode: 'DISABLED',
backtestDays: 7
@@ -172,17 +195,9 @@ const BacktestList: React.FC = () => {
minOrderSize: values.minOrderSize,
maxDailyLoss: values.maxDailyLoss,
maxDailyOrders: values.maxDailyOrders,
priceTolerance: values.priceTolerance,
delaySeconds: values.delaySeconds,
supportSell: values.supportSell,
minOrderDepth: values.minOrderDepth,
maxSpread: values.maxSpread,
minPrice: values.minPrice,
maxPrice: values.maxPrice,
maxPositionValue: values.maxPositionValue,
keywordFilterMode: values.keywordFilterMode,
keywords: values.keywords,
maxMarketEndDate: values.maxMarketEndDate
keywords: values.keywords
}
const response = await backtestService.create(request)
@@ -231,14 +246,14 @@ const BacktestList: React.FC = () => {
title: t('backtest.taskName'),
dataIndex: 'taskName',
key: 'taskName',
width: 150
width: isMobile ? 120 : 150
},
{
title: t('backtest.leader'),
dataIndex: 'leaderName',
key: 'leaderName',
width: 150,
render: (_: any, record: BacktestTaskDto) => record.leaderName || record.leaderAddress || '-'
width: isMobile ? 100 : 150,
render: (_: any, record: BacktestTaskDto) => record.leaderName || record.leaderAddress?.substring(0, 8) + '...' || '-'
},
{
title: t('backtest.initialBalance'),
@@ -324,14 +339,14 @@ const BacktestList: React.FC = () => {
title: t('backtest.createdAt'),
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
width: isMobile ? 150 : 180,
render: (timestamp: number) => new Date(timestamp).toLocaleString()
},
{
title: t('common.actions'),
key: 'actions',
fixed: 'right' as const,
width: 150,
fixed: isMobile ? false : ('right' as const),
width: isMobile ? 100 : 150,
render: (_: any, record: BacktestTaskDto) => (
<Space size="small">
<Button
@@ -342,7 +357,7 @@ const BacktestList: React.FC = () => {
>
{t('common.viewDetail')}
</Button>
{(record.status === 'RUNNING' || record.status === 'PENDING') && (
{record.status === 'RUNNING' && (
<Button
type="link"
size="small"
@@ -350,10 +365,20 @@ const BacktestList: React.FC = () => {
icon={<StopOutlined />}
onClick={() => handleStop(record.id)}
>
{t('backtest.statusStopped')}
{t('backtest.stop')}
</Button>
)}
{(record.status === 'COMPLETED' || record.status === 'STOPPED' || record.status === 'FAILED') && (
{(record.status === 'STOPPED' || record.status === 'FAILED') && (
<Button
type="link"
size="small"
icon={<RedoOutlined />}
onClick={() => handleRetry(record.id)}
>
{t('backtest.retry')}
</Button>
)}
{(record.status === 'PENDING' || record.status === 'COMPLETED') && (
<Button
type="link"
size="small"
@@ -374,11 +399,11 @@ const BacktestList: React.FC = () => {
<Card>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{/* 头部操作栏 */}
<Row justify="space-between" align="middle">
<Col>
<Space size="middle">
<Row justify="space-between" align="middle" gutter={[16, 16]}>
<Col xs={24} sm={24} md={12} lg={16}>
<Space size="middle" direction={isMobile ? 'vertical' : 'horizontal'} style={{ width: isMobile ? '100%' : 'auto' }}>
<Select
style={{ width: 150 }}
style={{ width: isMobile ? '100%' : 150 }}
placeholder={t('backtest.status')}
allowClear
onChange={(value) => setStatusFilter(value)}
@@ -391,7 +416,7 @@ const BacktestList: React.FC = () => {
<Select.Option value="FAILED">{t('backtest.statusFailed')}</Select.Option>
</Select>
<Select
style={{ width: 150 }}
style={{ width: isMobile ? '100%' : 150 }}
placeholder={t('backtest.sortBy')}
onChange={(value) => setSortBy(value)}
value={sortBy}
@@ -401,7 +426,7 @@ const BacktestList: React.FC = () => {
<Select.Option value="createdAt">{t('backtest.createdAt')}</Select.Option>
</Select>
<Select
style={{ width: 120 }}
style={{ width: isMobile ? '100%' : 120 }}
placeholder={t('backtest.sortOrder')}
onChange={(value) => setSortOrder(value)}
value={sortOrder}
@@ -411,19 +436,21 @@ const BacktestList: React.FC = () => {
</Select>
</Space>
</Col>
<Col>
<Space>
<Col xs={24} sm={24} md={12} lg={8} style={{ textAlign: isMobile ? 'left' : 'right' }}>
<Space style={{ width: isMobile ? '100%' : 'auto' }}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleCreate}
style={{ flex: isMobile ? 1 : undefined }}
>
{t('backtest.createTask')}
{isMobile ? t('common.create') : t('backtest.createTask')}
</Button>
<Button
icon={<ReloadOutlined />}
onClick={handleRefresh}
loading={loading}
style={{ flex: isMobile ? 1 : undefined }}
>
{t('common.refresh')}
</Button>
@@ -443,9 +470,10 @@ const BacktestList: React.FC = () => {
total,
showSizeChanger: false,
showTotal: (total) => `${t('common.total')} ${total} ${t('common.items')}`,
onChange: (newPage) => setPage(newPage)
onChange: (newPage) => setPage(newPage),
simple: isMobile
}}
scroll={{ x: 1400 }}
scroll={isMobile ? { x: 1200 } : { x: 1400 }}
/>
</Space>
</Card>
@@ -461,31 +489,25 @@ const BacktestList: React.FC = () => {
onOk={handleCreateSubmit}
okText={t('common.save')}
cancelText={t('common.cancel')}
width={800}
width={isMobile ? '95%' : 800}
confirmLoading={createLoading}
destroyOnClose
style={{ top: 20 }}
bodyStyle={{ maxHeight: 'calc(100vh - 200px)', overflowY: 'auto' }}
style={{ top: isMobile ? 10 : 20 }}
bodyStyle={{ maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(100vh - 200px)', overflowY: 'auto' }}
>
<Form
form={createForm}
layout="vertical"
initialValues={{
copyMode: 'RATIO',
copyRatio: 100, // 默认 100%(显示为百分比)
maxOrderSize: 1000,
minOrderSize: 1,
maxDailyLoss: 500,
maxDailyOrders: 50,
priceTolerance: 5,
delaySeconds: 0,
supportSell: true,
keywordFilterMode: 'DISABLED',
backtestDays: 7
}}
>
<Row gutter={24}>
<Col span={12}>
<Col xs={24} sm={24} md={12}>
<Form.Item
label={t('backtest.taskName')}
name="taskName"
@@ -494,7 +516,7 @@ const BacktestList: React.FC = () => {
<Input placeholder={t('backtest.taskName')} />
</Form.Item>
</Col>
<Col span={12}>
<Col xs={24} sm={24} md={12}>
<Form.Item
label={t('backtest.leader')}
name="leaderId"
@@ -512,7 +534,7 @@ const BacktestList: React.FC = () => {
</Row>
<Row gutter={24}>
<Col span={12}>
<Col xs={24} sm={24} md={12}>
<Form.Item
label={t('backtest.initialBalance') + ' (USDC)'}
name="initialBalance"
@@ -529,7 +551,7 @@ const BacktestList: React.FC = () => {
/>
</Form.Item>
</Col>
<Col span={12}>
<Col xs={24} sm={24} md={12}>
<Form.Item
label={t('backtest.backtestDays') + ` (1-15 ${t('common.day')})`}
name="backtestDays"
@@ -616,7 +638,7 @@ const BacktestList: React.FC = () => {
)}
<Row gutter={24}>
<Col span={12}>
<Col xs={24} sm={24} md={12}>
<Form.Item
label={t('backtest.maxOrderSize') + ' (USDC)'}
name="maxOrderSize"
@@ -625,7 +647,7 @@ const BacktestList: React.FC = () => {
<InputNumber style={{ width: '100%' }} precision={2} min={1} />
</Form.Item>
</Col>
<Col span={12}>
<Col xs={24} sm={24} md={12}>
<Form.Item
label={t('backtest.minOrderSize') + ' (USDC)'}
name="minOrderSize"
@@ -637,7 +659,7 @@ const BacktestList: React.FC = () => {
</Row>
<Row gutter={24}>
<Col span={12}>
<Col xs={24} sm={24} md={12}>
<Form.Item
label={t('backtest.maxDailyLoss') + ' (USDC)'}
name="maxDailyLoss"
@@ -646,7 +668,7 @@ const BacktestList: React.FC = () => {
<InputNumber style={{ width: '100%' }} precision={2} min={0} />
</Form.Item>
</Col>
<Col span={12}>
<Col xs={24} sm={24} md={12}>
<Form.Item
label={t('backtest.maxDailyOrders')}
name="maxDailyOrders"
@@ -657,75 +679,13 @@ const BacktestList: React.FC = () => {
</Col>
</Row>
<Form.Item
label={t('backtest.priceTolerance') + ' (%)'}
name="priceTolerance"
>
<InputNumber style={{ width: '100%' }} precision={2} min={0} max={100} />
</Form.Item>
<Form.Item
label={t('backtest.delaySeconds')}
name="delaySeconds"
>
<InputNumber style={{ width: '100%' }} precision={0} min={0} />
<span style={{ fontSize: 12, color: '#888' }}>{t('backtest.delaySecondsHint') || '延迟执行模拟真实跟单延迟'}</span>
</Form.Item>
<Form.Item
label={t('backtest.supportSell')}
name="supportSell"
valuePropName="checked"
>
<Switch />
<span style={{ fontSize: 12, color: '#888', marginLeft: 8 }}>{t('backtest.supportSellHint') || '是否跟随 Leader 卖出操作'}</span>
</Form.Item>
<h4 style={{ marginTop: 24, marginBottom: 16 }}>{t('backtest.advancedFilters')}</h4>
<Row gutter={24}>
<Col span={12}>
<Form.Item
label={t('backtest.minOrderDepth') + ' (USDC)'}
name="minOrderDepth"
>
<InputNumber style={{ width: '100%' }} precision={2} min={0} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label={t('backtest.maxSpread') + ' (%)'}
name="maxSpread"
>
<InputNumber style={{ width: '100%' }} precision={2} min={0} />
</Form.Item>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Form.Item
label={t('backtest.minPrice')}
name="minPrice"
>
<InputNumber style={{ width: '100%' }} precision={4} min={0} max={1} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label={t('backtest.maxPrice')}
name="maxPrice"
>
<InputNumber style={{ width: '100%' }} precision={4} min={0} max={1} />
</Form.Item>
</Col>
</Row>
<Form.Item
label={t('backtest.maxPositionValue') + ' (USDC)'}
name="maxPositionValue"
>
<InputNumber style={{ width: '100%' }} precision={2} min={0} />
<span style={{ fontSize: 12, color: '#888', marginLeft: 8 }}>{t('backtest.supportSellHint') || '是否跟随 Leader 卖出'}</span>
</Form.Item>
<Form.Item
@@ -752,9 +712,8 @@ const BacktestList: React.FC = () => {
</div>
</Form>
</Modal>
</div>
</div >
)
}
export default BacktestList
+6 -1
View File
@@ -792,6 +792,11 @@ export const backtestService = {
/**
* 删除回测任务
*/
delete: (data: { id: number }) => apiClient.post('/backtest/tasks/delete', data)
delete: (data: { id: number }) => apiClient.post('/backtest/tasks/delete', data),
/**
* 重试回测任务
*/
retry: (data: { id: number }) => apiClient.post('/backtest/tasks/retry', data)
}
+13
View File
@@ -29,6 +29,7 @@ export interface BacktestCreateRequest {
keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST'
keywords?: string[]
maxMarketEndDate?: number | null
pageForResume?: number // 用于恢复中断任务,从指定页码开始获取历史数据(从1开始)
}
/**
@@ -41,6 +42,7 @@ export interface BacktestListRequest {
sortOrder?: 'asc' | 'desc'
page: number
size: number
pageForResume?: number // 恢复时从指定页码开始
}
/**
@@ -80,6 +82,13 @@ export interface BacktestDeleteRequest {
id: number
}
/**
* 回测任务重试请求
*/
export interface BacktestRetryRequest {
id: number
}
/**
* 回测任务列表响应
*/
@@ -88,6 +97,7 @@ export interface BacktestListResponse {
total: number
page: number
size: number
processedTradeCount?: number // 已处理的交易数量(用于显示真实进度)
}
/**
@@ -97,6 +107,9 @@ export interface BacktestDetailResponse {
task: BacktestTaskDto
config: BacktestConfigDto
statistics: BacktestStatisticsDto
lastProcessedTradeTime?: number // 最后处理的交易时间(用于中断恢复)
lastProcessedTradeIndex?: number // 最后处理的交易索引(用于中断恢复)
processedTradeCount?: number // 已处理的交易数量(用于显示真实进度)
}
/**