Files
PolyHermes/frontend/src/pages/BacktestChart.tsx
T
WrBug cdd02e9f3d feat: 实现回测功能
## 功能概述
实现完整的回测功能,支持基于历史数据模拟跟单策略的执行效果。

## 后端实现
- 数据库:新增 backtest_task 和 backtest_trade 表(V27迁移脚本)
- 实体类:BacktestTask、BacktestTrade
- Repository:BacktestTaskRepository、BacktestTradeRepository
- Service:
  - BacktestService:回测任务管理(CRUD)
  - BacktestDataService:从 Polymarket Data API 获取历史交易数据
  - BacktestExecutionService:回测算法核心实现
  - BacktestPollingService:定时轮询执行回测任务
- Controller:BacktestController(6个API接口)
- DTO:BacktestDto、TradeData
- 错误码:新增回测相关错误码和国际化消息

## 前端实现
- 页面组件:
  - BacktestList:回测任务列表
  - BacktestCreate:创建回测任务
  - BacktestDetail:回测详情(含图表)
  - BacktestChart:资金曲线图表(使用 ECharts)
- 类型定义:backtest.ts
- API 服务:集成所有回测接口
- 国际化:支持中英文

## 核心特性
- 回测天数限制:1-15 天
- 数据获取:直接从 Polymarket Data API 获取历史交易(不使用缓存表)
- 任务执行:同一时刻只执行一个任务,按创建时间顺序执行最早创建的任务
- 回测算法:完整实现市场结算、卖出匹配、价格容忍度、每日订单限制等规则
- 实时进度:支持任务进度更新和实时轮询

## 文档更新
- BACKTEST_PRD.md:产品需求文档
- BACKTEST_TECHNICAL_DESIGN.md:技术设计文档
- BACKTEST_REVIEW_CHECKLIST.md:设计评审检查清单

## 其他修改
- 移除 max_position_count 配置(V26迁移脚本)
- 移除 BacktestSyncService(不再需要实时同步)
- 修复前后端编译错误
2026-01-31 07:27:36 +08:00

217 lines
5.5 KiB
TypeScript

import { useEffect, useRef } from 'react'
import * as echarts from 'echarts'
import type { EChartsOption } from 'echarts'
import { useTranslation } from 'react-i18next'
interface BacktestChartProps {
trades: {
tradeTime: number
balanceAfter: string
}[]
}
const BacktestChart: React.FC<BacktestChartProps> = ({ trades }) => {
const { t } = useTranslation()
const chartRef = useRef<HTMLDivElement>(null)
const chartInstance = useRef<echarts.ECharts | null>(null)
useEffect(() => {
if (!chartRef.current) return
// 初始化图表
chartInstance.current = echarts.init(chartRef.current)
// 监听窗口大小变化
const handleResize = () => {
chartInstance.current?.resize()
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
chartInstance.current?.dispose()
}
}, [])
useEffect(() => {
if (!chartInstance.current || trades.length === 0) return
// 准备数据
const data = trades.map((trade) => ({
time: new Date(trade.tradeTime).toLocaleString(),
value: parseFloat(trade.balanceAfter)
}))
// 初始余额(第一笔交易前的余额)
const initialBalance = data[0]?.value || 0
// 数据压缩:如果数据点太多,进行采样
const maxPoints = 500 // 最多显示500个点
let compressedData = data
if (data.length > maxPoints) {
const step = Math.ceil(data.length / maxPoints)
compressedData = data.filter((_, index) => index % step === 0)
// 确保最后一个点被包含
if (compressedData[compressedData.length - 1] !== data[data.length - 1]) {
compressedData.push(data[data.length - 1])
}
}
const times = compressedData.map(item => item.time)
const values = compressedData.map(item => item.value)
const option: EChartsOption = {
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const param = params[0]
const value = parseFloat(param.value).toFixed(2)
const diffValue = param.value - initialBalance
const diff = diffValue.toFixed(2)
const diffPercent = (diffValue / initialBalance * 100).toFixed(2)
const color = diffValue >= 0 ? '#52c41a' : '#ff4d4f'
return `
<div>
<div>${t('backtest.tradeTime')}: ${param.name}</div>
<div>${t('backtest.balanceAfter')}: ${value} USDC</div>
<div style="color: ${color}">
${t('backtest.profitLoss')}: ${diff} USDC (${diffPercent}%)
</div>
</div>
`
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '8%',
containLabel: true
},
xAxis: {
type: 'category',
data: times,
axisLabel: {
rotate: 45,
formatter: (value: string) => {
// 简化时间显示,只显示 HH:mm
const parts = value.split(' ')
if (parts.length > 1) {
const timeParts = parts[1].split(':')
if (timeParts.length >= 2) {
return `${timeParts[0]}:${timeParts[1]}`
}
}
return value
}
},
axisLine: {
lineStyle: {
color: '#e0e0e0'
}
},
axisTick: {
alignWithLabel: true,
lineStyle: {
color: '#e0e0e0'
}
}
},
yAxis: {
type: 'value',
name: 'USDC',
nameLocation: 'end',
nameGap: 10,
axisLabel: {
formatter: (value: number) => value.toFixed(2)
},
splitLine: {
lineStyle: {
color: '#f0f0f0'
}
},
axisLine: {
lineStyle: {
color: '#e0e0e0'
}
}
},
series: [
{
name: t('backtest.balanceAfter'),
type: 'line',
data: values,
smooth: true,
symbol: 'circle',
symbolSize: 4,
lineStyle: {
width: 2,
color: '#1890ff'
},
itemStyle: {
color: '#1890ff'
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(24, 144, 255, 0.3)' },
{ offset: 1, color: 'rgba(24, 144, 255, 0.05)' }
]
}
},
markLine: {
data: [
{
name: t('backtest.initialBalance'),
yAxis: initialBalance,
label: {
formatter: `${t('backtest.initialBalance')}: ${initialBalance.toFixed(2)}`
},
lineStyle: {
type: 'dashed',
color: '#999',
width: 1
}
}
]
}
}
],
dataZoom: [
{
type: 'inside',
start: 0,
end: 100
},
{
type: 'slider',
start: 0,
end: 100,
height: 20,
bottom: 20
}
]
}
chartInstance.current.setOption(option)
}, [trades, t])
return (
<div
ref={chartRef}
style={{
width: '100%',
height: 400
}}
/>
)
}
export default BacktestChart