Compare commits

..

17 Commits

Author SHA1 Message Date
WrBug 8f52b5016a feat: 将回测任务详情改为 Modal 方式展示
- 将任务详情从页面跳转改为 Modal 弹窗展示
- 在详情 Modal 中添加完整的统计信息、配置信息、图表和交易记录
- 修复统计数据显示:添加 totalTrades 字段,修复空值处理
- 将'创建跟单'按钮固定在 Modal 底部 footer
- 修复 Modal 滚动问题,确保内容可以正常滚动
- 修复编译错误:删除未使用的 navigate 导入
- 添加调试日志便于问题排查
2026-01-31 22:57:07 +08:00
WrBug 5c0808c2cb feat: 添加从回测任务创建跟单配置功能
- 在回测任务列表页和详情页添加'创建跟单'按钮
- 实现从回测任务配置自动填充到跟单配置表单
- 复用 AddModal 组件,支持 preFilledConfig 参数预填充
- 修复配置字段从 BacktestConfigDto 获取的问题
- 列表页点击按钮时先获取任务详情以获取配置信息
- 添加调试日志便于问题排查
- 更新多语言文案:'一键创建跟单配置' -> '创建跟单'
2026-01-31 22:23:44 +08:00
WrBug 9d01c120e5 fix: 修复回测任务恢复逻辑和分页问题
- 修复新建任务被误判为恢复任务的问题:将 lastProcessedTradeIndex 默认值从 0 改为 null
- 修复页码计算错误:统一页码从 0 开始,确保新任务的 offset 为 0
- 修复恢复任务时跳过已处理条目的逻辑
- 添加数据库迁移文件 V30,将现有新任务的索引值改为 NULL
2026-01-31 21:45:02 +08:00
WrBug ec8cfeac77 fix: 修复回测分页起始位置错误
- 修复新回测任务从第1页开始,导致 offset=100
- 应该从第0页开始,offset=0
- 修复后不会跳过前100条交易数据

Bug原因:
- BacktestExecutionService startPage 初始化为 1
- offset = page * size = 1 * 100 = 100
- 导致跳过前100条交易
2026-01-31 21:25:56 +08:00
WrBug 7c833f1e9b fix: 资金变化图始终显示所有交易数据
- 添加 allTrades 状态存储所有交易数据
- 添加 fetchAllTrades 方法获取所有数据(size=10000)
- 图表使用 allTrades 而不是分页的 trades
- 修复图表因分页只显示部分数据的问题
2026-01-31 21:15:09 +08:00
WrBug 08cc30a90f feat: 移除回测交易记录的盈亏列
- 移除盈亏列,进一步简化交易记录表格
- 盈亏信息已在统计卡片和图表中展示
2026-01-31 21:13:39 +08:00
WrBug 8288cd7579 feat: 移除回测交易记录的手续费列
- 移除手续费列,简化交易记录表格
- 手续费已包含在盈亏计算中,无需单独显示
2026-01-31 21:12:49 +08:00
WrBug 55da551971 fix: 修复回测统计计算空列表异常
- 修复 calculateStatistics 在 trades 为空时的 IndexOutOfBoundsException
- 添加空列表检查,避免访问 trades[0]
- 确保回测任务即使没有交易记录也能正常完成
2026-01-31 21:10:10 +08:00
WrBug ff3b24b50e fix: 优化回测交易记录显示
- 盈亏颜色逻辑:仅盈利(>0)显示绿色,亏损(<0)显示红色,持平(=0)无颜色
- 手续费显示:确保null值显示为 $0.00
- 遵守绿涨红跌原则
2026-01-31 20:58:13 +08:00
WrBug 88cae4018a fix: 修复 V29 migration SQL 语法兼容性
- 移除 DROP COLUMN IF EXISTS(仅 MySQL 8.0.29+ 支持)
- 使用标准 DROP COLUMN 语法(MySQL 5.7+ 兼容)
- 添加 Flyway 清理脚本
2026-01-31 20:51:26 +08:00
WrBug 96fbc3f720 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秒)
- 移动端用户体验显著提升
2026-01-31 20:45:40 +08:00
WrBug fd25821e39 fix: 优化回测功能UI和修复问题
## 主要修改

### UI 优化
- 将回测功能移到跟单交易菜单下作为子菜单
- 创建回测改为使用 Modal 方式(移除独立页面)
- 优化导航路径,使用 useNavigate 替代 window.location.href

### 功能修复
- 修复 API 路径问题:移除重复的 /api 前缀(/api/backtest -> /backtest)
- 修复 copyRatio 输入框单位:改为百分比输入(100 表示 100%),与跟单配置保持一致
- 修复回测列表中 leader 列显示为空的问题:优先显示 leaderName,为空时显示 leaderAddress

### 多语言完善
- 补充缺失的多语言 key:
  - common.ascending / common.descending / common.day
  - backtest.sortBy / backtest.sortOrder / backtest.createdAt
  - backtest.copyRatioTooltip / backtest.copyRatioPlaceholder
  - 以及其他表单验证相关的 key
- 为中文简体、中文繁体、英文三种语言都添加了完整的翻译

## 修改的文件
- frontend/src/App.tsx - 移除 BacktestCreate 路由
- frontend/src/components/Layout.tsx - 调整菜单结构
- frontend/src/pages/BacktestList.tsx - Modal 创建、修复 leader 显示
- frontend/src/pages/BacktestDetail.tsx - 优化导航
- frontend/src/pages/BacktestCreate.tsx - 同步 copyRatio 修改
- frontend/src/services/api.ts - 修复 API 路径
- frontend/src/locales/* - 补充多语言配置
2026-01-31 07:46:16 +08:00
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
WrBug fabbd81f22 Merge branch 'main' into backtest 2026-01-31 01:06:54 +08:00
WrBug ace32b37cf Merge pull request #26 from WrBug/remove-telegram-notify-workflow
chore: 移除 Telegram PR 合并通知工作流
2026-01-31 01:01:23 +08:00
WrBug f1f809f54b docs: 新增跟单回测功能设计文档
- 新增产品需求文档 (BACKTEST_PRD.md)
- 新增技术设计文档 (BACKTEST_TECHNICAL_DESIGN.md)
- 新增设计审查清单 (BACKTEST_REVIEW_CHECKLIST.md)
- 新增文档总览 (README.md)

关键设计:
- 实时市场结算机制(按endDate检查)
- 严格余额检查(不计入未实现持仓)
- 停止条件优化(余额不足且无持仓时停止)
- 不计算手续费(简化逻辑)
2026-01-31 00:26:00 +08:00
WrBug cccc829cef Merge pull request #25 from WrBug/dev
feat: 优化构建流程和通知机制
2026-01-29 04:09:44 +08:00
40 changed files with 7588 additions and 56 deletions
+59
View File
@@ -0,0 +1,59 @@
#!/bin/bash
# 清理 Flyway V29 失败记录的脚本
echo "=== 清理 Flyway V29 失败记录 ==="
echo ""
echo "请确保 MySQL 正在运行,然后输入数据库密码"
echo ""
# 数据库配置
DB_HOST="localhost"
DB_PORT="3306"
DB_NAME="polymarket_bot"
DB_USER="root"
# 检查 MySQL 命令是否可用
if ! command -v mysql &> /dev/null; then
echo "❌ 错误: 未找到 mysql 命令"
echo ""
echo "请使用数据库客户端(如 Navicat、DataGrip 等)执行以下 SQL"
echo ""
echo "-- 1. 查看 Flyway 历史记录"
echo "SELECT version, description, installed_on, success "
echo "FROM flyway_schema_history "
echo "WHERE version >= 28"
echo "ORDER BY installed_rank;"
echo ""
echo "-- 2. 删除 V29 的失败记录"
echo "DELETE FROM flyway_schema_history WHERE version = '29';"
echo ""
exit 1
fi
# 执行清理
echo "正在连接数据库..."
mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p "$DB_NAME" << 'EOF'
-- 查看当前状态
SELECT '=== 当前 Flyway 历史记录 ===' as '';
SELECT version, description, installed_on, success
FROM flyway_schema_history
WHERE version >= 28
ORDER BY installed_rank;
-- 删除 V29 失败记录
SELECT '=== 删除 V29 记录 ===' as '';
DELETE FROM flyway_schema_history WHERE version = '29';
-- 确认删除结果
SELECT CONCAT('已删除 ', ROW_COUNT(), ' 条记录') as result;
-- 再次查看状态
SELECT '=== 清理后的 Flyway 历史记录 ===' as '';
SELECT version, description, installed_on, success
FROM flyway_schema_history
WHERE version >= 28
ORDER BY installed_rank;
EOF
echo ""
echo "✅ 清理完成!现在可以重启应用了"
@@ -0,0 +1,225 @@
package com.wrbug.polymarketbot.controller.backtest
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.backtest.BacktestService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
/**
* 回测管理控制器
*/
@RestController
@RequestMapping("/api/backtest")
class BacktestController(
private val backtestService: BacktestService,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(BacktestController::class.java)
/**
* 创建回测任务
*/
@PostMapping("/tasks")
fun createBacktestTask(@RequestBody request: BacktestCreateRequest): ResponseEntity<ApiResponse<BacktestTaskDto>> {
return try {
logger.info("创建回测任务: taskName=${request.taskName}, leaderId=${request.leaderId}")
val result = runBlocking {
backtestService.createBacktestTask(request)
}
result.fold(
onSuccess = { dto ->
logger.info("回测任务创建成功: taskId=${dto.id}")
ResponseEntity.ok(ApiResponse.success(dto))
},
onFailure = { e ->
logger.error("创建回测任务失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.PARAM_ERROR
else -> ErrorCode.SERVER_BACKTEST_CREATE_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("创建回测任务异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_CREATE_FAILED, e.message, messageSource))
}
}
/**
* 查询回测任务列表
*/
@PostMapping("/tasks/list")
fun getBacktestTaskList(@RequestBody request: BacktestListRequest): ResponseEntity<ApiResponse<BacktestListResponse>> {
return try {
val result = backtestService.getBacktestTaskList(request)
result.fold(
onSuccess = { response ->
logger.info("查询回测任务列表成功: total=${response.total}")
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("查询回测任务列表失败", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_LIST_FETCH_FAILED, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("查询回测任务列表异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_LIST_FETCH_FAILED, e.message, messageSource))
}
}
/**
* 查询回测任务详情
*/
@PostMapping("/tasks/detail")
fun getBacktestTaskDetail(@RequestBody request: BacktestDetailRequest): ResponseEntity<ApiResponse<BacktestDetailResponse>> {
return try {
val result = backtestService.getBacktestTaskDetail(request)
result.fold(
onSuccess = { response ->
logger.info("查询回测任务详情成功: taskId=${request.id}")
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("查询回测任务详情失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
else -> ErrorCode.SERVER_BACKTEST_DETAIL_FETCH_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("查询回测任务详情异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_DETAIL_FETCH_FAILED, e.message, messageSource))
}
}
/**
* 查询回测交易记录
*/
@PostMapping("/tasks/trades")
fun getBacktestTrades(@RequestBody request: BacktestTradeListRequest): ResponseEntity<ApiResponse<BacktestTradeListResponse>> {
return try {
val result = backtestService.getBacktestTrades(request)
result.fold(
onSuccess = { response ->
logger.info("查询回测交易记录成功: taskId=${request.taskId}")
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("查询回测交易记录失败", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_TRADES_FETCH_FAILED, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("查询回测交易记录异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_TRADES_FETCH_FAILED, e.message, messageSource))
}
}
/**
* 删除回测任务
*/
@PostMapping("/tasks/delete")
fun deleteBacktestTask(@RequestBody request: BacktestDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
logger.info("删除回测任务: taskId=${request.id}")
val result = backtestService.deleteBacktestTask(request)
result.fold(
onSuccess = {
logger.info("回测任务删除成功: taskId=${request.id}")
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("删除回测任务失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
else -> ErrorCode.SERVER_BACKTEST_DELETE_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("删除回测任务异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_DELETE_FAILED, e.message, messageSource))
}
}
/**
* 停止回测任务
*/
@PostMapping("/tasks/stop")
fun stopBacktestTask(@RequestBody request: BacktestStopRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
logger.info("停止回测任务: taskId=${request.id}")
val result = backtestService.stopBacktestTask(request)
result.fold(
onSuccess = {
logger.info("回测任务停止成功: taskId=${request.id}")
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("停止回测任务失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
else -> ErrorCode.SERVER_BACKTEST_STOP_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("停止回测任务异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_STOP_FAILED, e.message, messageSource))
}
}
/**
* 重试回测任务
*/
@PostMapping("/tasks/retry")
fun retryBacktestTask(@RequestBody request: BacktestRetryRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
logger.info("重试回测任务: taskId=${request.id}")
val result = backtestService.retryBacktestTask(request)
result.fold(
onSuccess = {
logger.info("回测任务重试成功: taskId=${request.id}")
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("重试回测任务失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
else -> ErrorCode.SERVER_BACKTEST_RETRY_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("重试回测任务异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_RETRY_FAILED, e.message, messageSource))
}
}
}
@@ -0,0 +1,197 @@
package com.wrbug.polymarketbot.dto
import java.math.BigDecimal
/**
* 回测任务创建请求
*/
data class BacktestCreateRequest(
val taskName: String, // 回测任务名称
val leaderId: Long, // Leader ID
val initialBalance: String, // 初始资金
val backtestDays: Int, // 回测天数 (1-30)
// 跟单配置(与 CopyTrading 一致,但不包含 max_position_count
val copyMode: String? = null, // "RATIO" 或 "FIXED"
val copyRatio: String? = null, // 仅在 copyMode="RATIO" 时生效
val fixedAmount: String? = null, // 仅在 copyMode="FIXED" 时生效
val maxOrderSize: String? = null,
val minOrderSize: String? = null,
val maxDailyLoss: String? = null,
val maxDailyOrders: Int? = null,
val supportSell: Boolean? = null,
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表
val pageForResume: Int? = null // 用于恢复中断任务,从指定页码开始获取历史数据(从1开始)
)
/**
* 回测任务列表请求
*/
data class BacktestListRequest(
val leaderId: Long? = null, // Leader ID(可选)
val status: String? = null, // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
val sortBy: String? = null, // profitAmount / profitRate / createdAt
val sortOrder: String? = null, // asc / desc
val page: Int = 1, // 页码,从1开始
val size: Int = 20 // 每页数量
)
/**
* 回测任务详情请求
*/
data class BacktestDetailRequest(
val id: Long // 回测任务ID
)
/**
* 回测交易记录请求
*/
data class BacktestTradeListRequest(
val taskId: Long, // 回测任务ID
val page: Int = 1, // 页码,从1开始
val size: Int = 20 // 每页数量
)
/**
* 回测进度查询请求
*/
data class BacktestProgressRequest(
val id: Long // 回测任务ID
)
/**
* 回测任务停止请求
*/
data class BacktestStopRequest(
val id: Long // 回测任务ID
)
/**
* 回测任务删除请求
*/
data class BacktestDeleteRequest(
val id: Long // 回测任务ID
)
/**
* 回测任务重试请求
*/
data class BacktestRetryRequest(
val id: Long // 回测任务ID
)
/**
* 回测任务列表响应
*/
data class BacktestListResponse(
val list: List<BacktestTaskDto>,
val total: Long,
val page: Int,
val size: Int
)
/**
* 回测任务详情响应
*/
data class BacktestDetailResponse(
val task: BacktestTaskDto,
val config: BacktestConfigDto,
val statistics: BacktestStatisticsDto
)
/**
* 回测交易记录列表响应
*/
data class BacktestTradeListResponse(
val list: List<BacktestTradeDto>,
val total: Long,
val page: Int,
val size: Int
)
/**
* 回测进度响应
*/
data class BacktestProgressResponse(
val progress: Int, // 执行进度 (0-100)
val currentBalance: String, // 当前余额
val totalTrades: Int, // 总交易笔数
val status: String // 任务状态
)
/**
* 回测任务 DTO
*/
data class BacktestTaskDto(
val id: Long,
val taskName: String,
val leaderId: Long,
val leaderName: String?,
val leaderAddress: String?,
val initialBalance: String,
val finalBalance: String?,
val profitAmount: String?,
val profitRate: String?,
val backtestDays: Int,
val startTime: Long,
val endTime: Long?,
val status: String, // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
val progress: Int,
val totalTrades: Int,
val createdAt: Long,
val executionStartedAt: Long?,
val executionFinishedAt: Long?
)
/**
* 回测配置 DTO
*/
data class BacktestConfigDto(
val copyMode: String,
val copyRatio: String,
val fixedAmount: String?,
val maxOrderSize: String,
val minOrderSize: String,
val maxDailyLoss: String,
val maxDailyOrders: Int,
val supportSell: Boolean,
val keywordFilterMode: String?,
val keywords: List<String>?
)
/**
* 回测统计信息 DTO
*/
data class BacktestStatisticsDto(
val totalTrades: Int, // 总交易笔数
val buyTrades: Int, // 买入笔数
val sellTrades: Int, // 卖出笔数
val winTrades: Int, // 盈利交易笔数
val lossTrades: Int, // 亏损交易笔数
val winRate: String, // 胜率(%)
val maxProfit: String, // 最大单笔盈利
val maxLoss: String, // 最大单笔亏损
val maxDrawdown: String, // 最大回撤
val avgHoldingTime: Long? // 平均持仓时间(毫秒)
)
/**
* 回测交易记录 DTO
*/
data class BacktestTradeDto(
val id: Long,
val tradeTime: Long,
val marketId: String,
val marketTitle: String?,
val side: String, // BUY/SELL/SETTLEMENT
val outcome: String,
val outcomeIndex: Int?,
val quantity: String,
val price: String,
val amount: String,
val fee: String,
val profitLoss: String?,
val balanceAfter: String,
val leaderTradeId: String?
)
@@ -0,0 +1,32 @@
package com.wrbug.polymarketbot.dto
import java.math.BigDecimal
/**
* 用户交易数据
* 用于回测功能,从 Polymarket API 获取的用户交易历史
*/
data class TradeData(
val tradeId: String, // 交易 ID
val marketId: String, // 市场 ID
val marketTitle: String?, // 市场标题
val marketSlug: String?, // 市场 Slug
val side: String, // 交易方向: BUY/SELL
val outcome: String, // 结果: YES/NO 或 outcomeIndex
val outcomeIndex: Int?, // 结果索引
val price: BigDecimal, // 成交价格
val size: BigDecimal, // 成交数量
val amount: BigDecimal, // 成交金额
val timestamp: Long // 交易时间戳
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is TradeData) return false
return tradeId == other.tradeId
}
override fun hashCode(): Int {
return tradeId.hashCode()
}
}
@@ -0,0 +1,142 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
import com.wrbug.polymarketbot.util.toSafeBigDecimal
/**
* 回测任务实体
*/
@Entity
@Table(name = "backtest_task")
data class BacktestTask(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "task_name", nullable = false, length = 100)
val taskName: String,
@Column(name = "leader_id", nullable = false)
val leaderId: Long,
// 回测参数
@Column(name = "initial_balance", nullable = false, precision = 20, scale = 8)
val initialBalance: BigDecimal,
@Column(name = "final_balance", precision = 20, scale = 8)
var finalBalance: BigDecimal? = null,
@Column(name = "profit_amount", precision = 20, scale = 8)
var profitAmount: BigDecimal? = null,
@Column(name = "profit_rate", precision = 10, scale = 4)
var profitRate: BigDecimal? = null, // 收益率(%)
@Column(name = "backtest_days", nullable = false)
val backtestDays: Int,
@Column(name = "start_time", nullable = false)
val startTime: Long, // 回测开始时间(历史时间)
@Column(name = "end_time")
var endTime: Long? = null, // 回测结束时间(历史时间)
// 跟单配置 (复制CopyTrading表结构,但不包含 max_position_count)
@Column(name = "copy_mode", nullable = false, length = 10)
val copyMode: String = "RATIO", // "RATIO" 或 "FIXED"
@Column(name = "copy_ratio", nullable = false, precision = 20, scale = 8)
val copyRatio: BigDecimal = BigDecimal.ONE,
@Column(name = "fixed_amount", precision = 20, scale = 8)
val fixedAmount: BigDecimal? = null,
@Column(name = "max_order_size", nullable = false, precision = 20, scale = 8)
val maxOrderSize: BigDecimal = "1000".toSafeBigDecimal(),
@Column(name = "min_order_size", nullable = false, precision = 20, scale = 8)
val minOrderSize: BigDecimal = "1".toSafeBigDecimal(),
@Column(name = "max_daily_loss", nullable = false, precision = 20, scale = 8)
val maxDailyLoss: BigDecimal = "10000".toSafeBigDecimal(),
@Column(name = "max_daily_orders", nullable = false)
val maxDailyOrders: Int = 100,
@Column(name = "support_sell", nullable = false)
val supportSell: Boolean = true,
@Column(name = "keyword_filter_mode", nullable = false, length = 20)
val keywordFilterMode: String = "DISABLED", // DISABLED/WHITELIST/BLACKLIST
@Column(name = "keywords", columnDefinition = "JSON")
val keywords: String? = null,
// 统计字段
@Column(name = "avg_holding_time")
var avgHoldingTime: Long? = null, // 平均持仓时间(毫秒)
@Column(name = "data_source", length = 50)
var dataSource: String = "MIXED", // INTERNAL/API/MIXED
// 执行状态
@Column(name = "status", nullable = false, length = 20)
var status: String = "PENDING", // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
@Column(name = "progress", nullable = false)
var progress: Int = 0, // 执行进度(0-100)
@Column(name = "total_trades", nullable = false)
var totalTrades: Int = 0,
@Column(name = "buy_trades", nullable = false)
var buyTrades: Int = 0,
@Column(name = "sell_trades", nullable = false)
var sellTrades: Int = 0,
@Column(name = "win_trades", nullable = false)
var winTrades: Int = 0,
@Column(name = "loss_trades", nullable = false)
var lossTrades: Int = 0,
@Column(name = "win_rate", precision = 5, scale = 2)
var winRate: BigDecimal? = null, // 胜率(%)
@Column(name = "max_profit", precision = 20, scale = 8)
var maxProfit: BigDecimal? = null, // 最大单笔盈利
@Column(name = "max_loss", precision = 20, scale = 8)
var maxLoss: BigDecimal? = null, // 最大单笔亏损
@Column(name = "max_drawdown", precision = 20, scale = 8)
var maxDrawdown: BigDecimal? = null, // 最大回撤
@Column(name = "error_message", columnDefinition = "TEXT")
var errorMessage: String? = null,
// 时间字段
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "execution_started_at")
var executionStartedAt: Long? = null,
@Column(name = "execution_finished_at")
var executionFinishedAt: Long? = null,
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis(),
@Column(name = "last_processed_trade_time")
var lastProcessedTradeTime: Long? = null,
@Column(name = "last_processed_trade_index")
var lastProcessedTradeIndex: Int? = null,
@Column(name = "processed_trade_count")
var processedTradeCount: Int = 0
)
@@ -0,0 +1,62 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
/**
* 回测交易记录实体
* 用于记录回测过程中的每笔模拟交易
*/
@Entity
@Table(name = "backtest_trade")
data class BacktestTrade(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "backtest_task_id", nullable = false)
val backtestTaskId: Long,
@Column(name = "trade_time", nullable = false)
val tradeTime: Long,
@Column(name = "market_id", nullable = false, length = 100)
val marketId: String,
@Column(name = "market_title", length = 500)
val marketTitle: String? = null,
@Column(name = "side", nullable = false, length = 20)
val side: String, // BUY/SELL/SETTLEMENT
@Column(name = "outcome", nullable = false, length = 50)
val outcome: String, // YES/NO 或 outcomeIndex
@Column(name = "outcome_index")
val outcomeIndex: Int? = null, // 结果索引(0, 1, 2, ...),支持多元市场
@Column(name = "quantity", nullable = false, precision = 20, scale = 8)
val quantity: BigDecimal,
@Column(name = "price", nullable = false, precision = 20, scale = 8)
val price: BigDecimal,
@Column(name = "amount", nullable = false, precision = 20, scale = 8)
val amount: BigDecimal,
@Column(name = "fee", nullable = false, precision = 20, scale = 8)
val fee: BigDecimal = BigDecimal.ZERO, // 手续费(回测不计算,默认为0
@Column(name = "profit_loss", precision = 20, scale = 8)
val profitLoss: BigDecimal? = null, // 盈亏(仅卖出时)
@Column(name = "balance_after", nullable = false, precision = 20, scale = 8)
val balanceAfter: BigDecimal, // 交易后余额
@Column(name = "leader_trade_id", length = 100)
val leaderTradeId: String? = null, // Leader 原始交易ID
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis()
)
@@ -231,7 +231,24 @@ enum class ErrorCode(
SERVER_ORDER_TRACKING_PROCESS_FAILED(5901, "处理订单跟踪失败", "error.server.order_tracking_process_failed"),
SERVER_ORDER_TRACKING_BUY_FAILED(5902, "处理买入订单失败", "error.server.order_tracking_buy_failed"),
SERVER_ORDER_TRACKING_SELL_FAILED(5903, "处理卖出订单失败", "error.server.order_tracking_sell_failed"),
SERVER_ORDER_TRACKING_MATCH_FAILED(5904, "订单匹配失败", "error.server.order_tracking_match_failed");
SERVER_ORDER_TRACKING_MATCH_FAILED(5904, "订单匹配失败", "error.server.order_tracking_match_failed"),
// 回测服务错误 (4601-4699)
BACKTEST_TASK_NOT_FOUND(4601, "回测任务不存在", "error.backtest.task_not_found"),
BACKTEST_LEADER_NOT_FOUND(4602, "Leader不存在", "error.backtest.leader_not_found"),
BACKTEST_DAYS_INVALID(4603, "回测天数超出限制", "error.backtest.days_invalid"),
BACKTEST_INITIAL_BALANCE_INVALID(4604, "初始金额无效", "error.backtest.initial_balance_invalid"),
BACKTEST_TASK_RUNNING(4605, "回测任务正在运行,无法删除", "error.backtest.task_running"),
SERVER_BACKTEST_CREATE_FAILED(5603, "创建回测任务失败", "error.server.backtest_create_failed"),
SERVER_BACKTEST_UPDATE_FAILED(5604, "更新回测任务失败", "error.server.backtest_update_failed"),
SERVER_BACKTEST_DELETE_FAILED(5605, "删除回测任务失败", "error.server.backtest_delete_failed"),
SERVER_BACKTEST_LIST_FETCH_FAILED(5606, "查询回测列表失败", "error.server.backtest_list_fetch_failed"),
SERVER_BACKTEST_DETAIL_FETCH_FAILED(5607, "查询回测详情失败", "error.server.backtest_detail_fetch_failed"),
SERVER_BACKTEST_TRADES_FETCH_FAILED(5608, "查询回测交易记录失败", "error.server.backtest_trades_fetch_failed"),
SERVER_BACKTEST_EXECUTE_FAILED(5609, "回测执行失败", "error.server.backtest_execute_failed"),
SERVER_BACKTEST_HISTORICAL_DATA_FETCH_FAILED(5610, "历史数据获取失败", "error.server.backtest_historical_data_fetch_failed"),
SERVER_BACKTEST_STOP_FAILED(5611, "停止回测任务失败", "error.server.backtest_stop_failed"),
SERVER_BACKTEST_RETRY_FAILED(5612, "重试回测任务失败", "error.server.backtest_retry_failed");
companion object {
/**
@@ -0,0 +1,63 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.BacktestTask
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
/**
* 回测任务Repository
*/
@Repository
interface BacktestTaskRepository : JpaRepository<BacktestTask, Long> {
/**
* 根据 Leader ID 查询回测任务
*/
fun findByLeaderId(leaderId: Long): List<BacktestTask>
/**
* 根据状态查询回测任务
*/
fun findByStatus(status: String): List<BacktestTask>
/**
* 根据 Leader ID 和状态查询回测任务
*/
fun findByLeaderIdAndStatus(leaderId: Long, status: String): List<BacktestTask>
/**
* 根据 Leader ID、收益率排序查询
*/
@Query("SELECT t FROM BacktestTask t WHERE t.leaderId = :leaderId AND t.status = :status ORDER BY t.profitRate DESC")
fun findByLeaderIdAndStatusOrderByProfitRateDesc(leaderId: Long, status: String): List<BacktestTask>
/**
* 根据状态和创建时间倒序查询
*/
@Query("SELECT t FROM BacktestTask t WHERE t.status = :status ORDER BY t.createdAt DESC")
fun findByStatusOrderByCreatedAtDesc(status: String): List<BacktestTask>
/**
* 更新回测任务状态
*/
@Modifying
@Query("UPDATE BacktestTask t SET t.status = :status, t.updatedAt = :updatedAt WHERE t.id = :id")
fun updateStatus(id: Long, status: String, updatedAt: Long = System.currentTimeMillis())
/**
* 更新回测任务状态和错误信息
*/
@Modifying
@Query("UPDATE BacktestTask t SET t.status = :status, t.errorMessage = :errorMessage, t.updatedAt = :updatedAt WHERE t.id = :id")
fun updateStatusAndError(id: Long, status: String, errorMessage: String?, updatedAt: Long = System.currentTimeMillis())
/**
* 更新回测任务进度
*/
@Modifying
@Query("UPDATE BacktestTask t SET t.progress = :progress, t.updatedAt = :updatedAt WHERE t.id = :id")
fun updateProgress(id: Long, progress: Int, updatedAt: Long = System.currentTimeMillis())
}
@@ -0,0 +1,38 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.BacktestTrade
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
/**
* 回测交易记录Repository
*/
@Repository
interface BacktestTradeRepository : JpaRepository<BacktestTrade, Long> {
/**
* 根据回测任务ID查询所有交易记录
*/
fun findByBacktestTaskIdOrderByTradeTime(backtestTaskId: Long): List<BacktestTrade>
/**
* 根据回测任务ID分页查询交易记录
*/
@Query("SELECT t FROM BacktestTrade t WHERE t.backtestTaskId = :backtestTaskId ORDER BY t.tradeTime")
fun findByBacktestTaskId(
backtestTaskId: Long,
pageable: org.springframework.data.domain.Pageable
): org.springframework.data.domain.Page<BacktestTrade>
/**
* 根据回测任务ID统计交易数量
*/
fun countByBacktestTaskId(backtestTaskId: Long): Long
/**
* 删除回测任务的所有交易记录(由级联删除处理)
*/
fun deleteByBacktestTaskId(backtestTaskId: Long)
}
@@ -0,0 +1,131 @@
package com.wrbug.polymarketbot.service.backtest
import com.wrbug.polymarketbot.api.PolymarketDataApi
import com.wrbug.polymarketbot.api.UserActivityResponse
import com.wrbug.polymarketbot.dto.TradeData
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import kotlinx.coroutines.delay
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
/**
* 回测数据服务
* 直接从 Polymarket Data API 获取 Leader 历史交易
*/
@Service
class BacktestDataService(
private val leaderRepository: LeaderRepository,
private val retrofitFactory: RetrofitFactory
) {
private val logger = LoggerFactory.getLogger(BacktestDataService::class.java)
/**
* 分页获取 Leader 历史交易(用于回测恢复)
* 支持重试机制:最多重试5次,每次间隔1秒
*
* @param leaderId Leader ID
* @param startTime 开始时间(毫秒时间戳)
* @param endTime 结束时间(毫秒时间戳)
* @param page 页码 (从 0 开始)
* @param size 每页数量
* @return 历史交易列表
* @throws Exception 重试5次后仍然失败时抛出异常
*/
suspend fun getLeaderHistoricalTradesForPage(
leaderId: Long,
startTime: Long,
endTime: Long,
page: Int,
size: Int
): List<TradeData> {
logger.info("分页获取 Leader 历史交易: leaderId=$leaderId, timeRange=$startTime - $endTime, page=$page, size=$size")
// 1. 验证 Leader 是否存在
val leader = leaderRepository.findById(leaderId).orElse(null)
?: throw IllegalArgumentException("Leader 不存在: $leaderId")
val dataApi = retrofitFactory.createDataApi()
val offset = page * size
val maxRetries = 5
val retryDelay = 1000L // 1秒
// 2. 重试机制:最多重试5次
var lastException: Exception? = null
for (attempt in 1..maxRetries) {
try {
val response = dataApi.getUserActivity(
user = leader.leaderAddress,
type = listOf("TRADE"),
start = startTime / 1000,
end = endTime / 1000,
limit = size,
offset = offset,
sortBy = "timestamp",
sortDirection = "asc"
)
if (!response.isSuccessful || response.body() == null) {
throw Exception("从 Data API 获取用户活动失败: code=${response.code()}, message=${response.message()}")
}
val activities = response.body()!!
logger.info("成功获取第 $page 页数据,共 ${activities.size} 条交易(第 $attempt 次尝试)")
return activities.mapNotNull { activity ->
try {
if (activity.type != "TRADE") {
return@mapNotNull null
}
if (activity.side == null || activity.price == null || activity.size == null || activity.usdcSize == null) {
logger.warn("活动数据缺少必要字段,跳过: activity=$activity")
return@mapNotNull null
}
val tradeTimestamp = activity.timestamp * 1000
if (tradeTimestamp < startTime || tradeTimestamp > endTime) {
logger.debug("交易时间超出范围,跳过: timestamp=$tradeTimestamp, range=$startTime - $endTime")
return@mapNotNull null
}
TradeData(
tradeId = activity.transactionHash ?: "${activity.timestamp}_${activity.conditionId}_${activity.side}",
marketId = activity.conditionId,
marketTitle = activity.title,
marketSlug = activity.slug,
side = activity.side.uppercase(),
outcome = activity.outcome ?: activity.outcomeIndex?.toString() ?: "",
outcomeIndex = activity.outcomeIndex,
price = activity.price.toSafeBigDecimal(),
size = activity.size.toSafeBigDecimal(),
amount = activity.usdcSize.toSafeBigDecimal(),
timestamp = tradeTimestamp
)
} catch (e: Exception) {
logger.warn("转换活动数据失败: activity=$activity, error=${e.message}", e)
null
}
}
} catch (e: Exception) {
lastException = e
logger.warn("$attempt/$maxRetries 次尝试获取第 $page 页数据失败: ${e.message}")
// 如果不是最后一次尝试,则等待后重试
if (attempt < maxRetries) {
logger.info("等待 $retryDelay 毫秒后重试...")
delay(retryDelay)
}
}
}
// 重试5次后仍然失败,抛出异常
val errorMsg = "重试 $maxRetries 次后仍然失败获取第 $page 页数据"
logger.error(errorMsg, lastException)
throw Exception(errorMsg, lastException)
}
}
@@ -0,0 +1,715 @@
package com.wrbug.polymarketbot.service.backtest
import com.wrbug.polymarketbot.dto.TradeData
import com.wrbug.polymarketbot.dto.BacktestStatisticsDto
import com.wrbug.polymarketbot.entity.BacktestTask
import com.wrbug.polymarketbot.entity.BacktestTrade
import com.wrbug.polymarketbot.entity.CopyTrading
import com.wrbug.polymarketbot.repository.BacktestTradeRepository
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
import com.wrbug.polymarketbot.service.common.MarketPriceService
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingFilterService
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.max
@Service
class BacktestExecutionService(
private val backtestTaskRepository: BacktestTaskRepository,
private val backtestTradeRepository: BacktestTradeRepository,
private val backtestDataService: BacktestDataService,
private val marketPriceService: MarketPriceService,
private val copyTradingFilterService: CopyTradingFilterService
) {
private val logger = LoggerFactory.getLogger(BacktestExecutionService::class.java)
/**
* 持仓数据结构
*/
data class Position(
val marketId: String,
val outcome: String,
val outcomeIndex: Int?,
var quantity: BigDecimal,
val avgPrice: BigDecimal,
val leaderBuyQuantity: BigDecimal?
)
/**
* 将回测任务转换为虚拟的 CopyTrading 配置用于执行
* 注意:回测场景使用历史数据,不需要实时跟单的相关配置
*/
private fun taskToCopyTrading(task: BacktestTask): CopyTrading {
return CopyTrading(
id = task.id,
accountId = 0L,
leaderId = task.leaderId,
enabled = true,
copyMode = task.copyMode,
copyRatio = task.copyRatio,
fixedAmount = null,
maxOrderSize = task.maxOrderSize,
minOrderSize = task.minOrderSize,
maxDailyLoss = task.maxDailyLoss,
maxDailyOrders = task.maxDailyOrders,
priceTolerance = BigDecimal.ZERO, // 回测使用历史价格,不需要容忍度
delaySeconds = 0, // 回测按时间线执行,无需延迟
pollIntervalSeconds = 5,
useWebSocket = false,
websocketReconnectInterval = 5000,
websocketMaxRetries = 10,
supportSell = task.supportSell,
minOrderDepth = null, // 回测无实时订单簿数据
maxSpread = null, // 回测无实时价差数据
keywordFilterMode = task.keywordFilterMode,
keywords = task.keywords,
configName = null,
pushFailedOrders = false,
pushFilteredOrders = false,
createdAt = task.createdAt,
updatedAt = task.updatedAt
)
}
/**
* 执行回测任务(支持分页和恢复)
* 自动处理所有页面的数据,支持中断恢复
*/
@Transactional
suspend fun executeBacktest(task: BacktestTask, page: Int = 1, size: Int = 100) {
try {
logger.info("开始执行回测任务: taskId=${task.id}, taskName=${task.taskName}, startPage=$page, pageSize=$size")
// 1. 更新任务状态为 RUNNING
task.status = "RUNNING"
task.executionStartedAt = System.currentTimeMillis()
task.updatedAt = System.currentTimeMillis()
backtestTaskRepository.save(task)
// 2. 初始化
var currentBalance = task.initialBalance
val positions = mutableMapOf<String, Position>()
val trades = mutableListOf<BacktestTrade>()
// 每日订单数缓存:key为日期字符串(yyyy-MM-dd)value为当天的 BUY 订单数
val dailyOrderCountCache = mutableMapOf<String, Int>()
// 每日亏损缓存:key为日期字符串(yyyy-MM-dd)value为当天的累计亏损金额
val dailyLossCache = mutableMapOf<String, BigDecimal>()
// 3. 计算回测时间范围
val endTime = System.currentTimeMillis()
val startTime = task.startTime
logger.info("回测时间范围: ${formatTimestamp(startTime)} - ${formatTimestamp(endTime)}, " +
"初始余额: ${task.initialBalance.toPlainString()}")
// 4. 恢复机制:如果有恢复点,计算从哪一页开始(页码从 0 开始)
val startPage = if (task.lastProcessedTradeIndex != null) {
val lastProcessedIndex = task.lastProcessedTradeIndex!!
// 计算已处理的页码(从 0 开始)
val processedPage = lastProcessedIndex / size
// 特殊情况:如果lastProcessedTradeIndex刚好是100的倍数减1(比如99,199,299...
// 说明该页已经完全处理,应该从下一页开始
val nextPage = if (lastProcessedIndex % size == size - 1) {
processedPage + 1
} else {
processedPage
}
logger.info("恢复任务:已处理索引=$lastProcessedIndex, 计算页码=$nextPage, size=$size")
nextPage
} else {
logger.info("新任务:从第0页开始")
0
}
// 5. 分页获取和处理交易数据
var currentPage = maxOf(startPage, page)
// 计算下一个要处理的全局索引(用于日志和统计)
val nextGlobalIndex = if (task.lastProcessedTradeIndex != null) {
task.lastProcessedTradeIndex!! + 1
} else {
0
}
logger.info("开始分页处理:起始页=$currentPage, 下一个要处理的索引=$nextGlobalIndex")
while (true) {
// 定期从数据库重新加载任务状态,确保能及时响应停止操作
val currentTaskStatus = backtestTaskRepository.findById(task.id!!).orElse(null)
if (currentTaskStatus == null || currentTaskStatus.status != "RUNNING") {
logger.info("回测任务状态已变更: ${currentTaskStatus?.status},停止执行")
break
}
logger.info("正在获取第 $currentPage 页数据...")
// 每页使用独立的交易列表,避免跨页重复保存
val currentPageTrades = mutableListOf<BacktestTrade>()
try {
// 获取当前页的交易数据(支持重试5次)
val pageTrades = backtestDataService.getLeaderHistoricalTradesForPage(
task.leaderId,
startTime,
endTime,
currentPage,
size
)
if (pageTrades.isEmpty()) {
logger.info("$currentPage 页无数据,所有数据处理完成")
break
}
logger.info("$currentPage 页获取到 ${pageTrades.size} 条交易")
// 处理当前页的交易
var lastProcessedIndexInPage: Int? = null
for (localIndex in pageTrades.indices) {
val leaderTrade = pageTrades[localIndex]
// 计算当前交易在全局数据中的索引(从 0 开始)
val index = currentPage * size + localIndex
// 如果是恢复任务,跳过已处理的条目
if (task.lastProcessedTradeIndex != null && index <= task.lastProcessedTradeIndex!!) {
logger.debug("跳过已处理的交易: index=$index, lastProcessedIndex=${task.lastProcessedTradeIndex}")
continue
}
// 记录当前处理的索引
lastProcessedIndexInPage = index
// 更新进度
val progress = if (pageTrades.size > 0) {
(localIndex * 100) / pageTrades.size
} else {
0
}
if (progress > task.progress) {
task.progress = progress
task.processedTradeCount = index + 1
backtestTaskRepository.save(task)
}
try {
// 5.1 实时检查并结算已到期的市场
currentBalance = settleExpiredPositions(task, positions, currentBalance, trades, leaderTrade.timestamp)
// 5.2 检查余额和持仓状态
if (currentBalance < BigDecimal.ZERO) {
logger.info("余额已为负,直接终止回测: $currentBalance")
break
}
if (currentBalance < BigDecimal.ONE && positions.isEmpty()) {
logger.info("余额不足且无持仓,停止回测: $currentBalance")
break
}
// 5.3 应用过滤规则
val copyTrading = taskToCopyTrading(task)
val filterResult = copyTradingFilterService.checkFilters(
copyTrading,
tokenId = "",
tradePrice = leaderTrade.price,
copyOrderAmount = null,
marketId = leaderTrade.marketId,
marketTitle = leaderTrade.marketTitle,
marketEndDate = null,
outcomeIndex = leaderTrade.outcomeIndex
)
if (!filterResult.isPassed) {
logger.debug("交易被过滤: ${leaderTrade.tradeId}")
continue
}
// 5.4 每日订单数检查 - 使用缓存,只统计 BUY 订单
val tradeDate = formatDate(leaderTrade.timestamp)
val dailyOrderCount = dailyOrderCountCache.getOrDefault(tradeDate, 0)
if (dailyOrderCount >= task.maxDailyOrders) {
logger.info("已达到每日最大 BUY 订单数限制: $dailyOrderCount / ${task.maxDailyOrders}")
continue
}
// 5.6 计算跟单金额
val followAmount = calculateFollowAmount(task, leaderTrade)
// 5.6.1 检查订单大小限制
val finalFollowAmount = if (followAmount > task.maxOrderSize) {
logger.info("跟单金额超过最大限制: $followAmount > ${task.maxOrderSize},调整为最大值")
task.maxOrderSize
} else if (followAmount < task.minOrderSize) {
logger.info("跟单金额低于最小限制: $followAmount < ${task.minOrderSize},调整为最小值")
task.minOrderSize
} else {
followAmount
}
// 5.6.2 检查每日最大亏损(买入订单)- 使用缓存
val dailyLoss = dailyLossCache.getOrDefault(tradeDate, BigDecimal.ZERO)
if (dailyLoss > task.maxDailyLoss) {
logger.info("已达到每日最大亏损限制: $dailyLoss / ${task.maxDailyLoss},跳过买入订单")
continue
}
// 5.7 处理买卖逻辑
if (leaderTrade.side == "BUY") {
// 买入逻辑
val quantity = finalFollowAmount.divide(leaderTrade.price, 8, java.math.RoundingMode.DOWN)
val totalCost = finalFollowAmount
// 更新余额和持仓
currentBalance -= totalCost
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
positions[positionKey] = Position(
marketId = leaderTrade.marketId,
outcome = leaderTrade.outcome ?: "",
outcomeIndex = leaderTrade.outcomeIndex,
quantity = quantity,
avgPrice = leaderTrade.price.toSafeBigDecimal(),
leaderBuyQuantity = leaderTrade.size.toSafeBigDecimal()
)
// 记录交易到当前页列表
currentPageTrades.add(BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = leaderTrade.timestamp,
marketId = leaderTrade.marketId,
marketTitle = leaderTrade.marketTitle,
side = "BUY",
outcome = leaderTrade.outcome ?: leaderTrade.outcomeIndex.toString(),
outcomeIndex = leaderTrade.outcomeIndex,
quantity = quantity,
price = leaderTrade.price.toSafeBigDecimal(),
amount = finalFollowAmount,
fee = BigDecimal.ZERO,
profitLoss = null,
balanceAfter = currentBalance,
leaderTradeId = leaderTrade.tradeId
))
// 更新每日订单数缓存
dailyOrderCountCache[tradeDate] = dailyOrderCount + 1
} else {
// SELL 逻辑
if (!task.supportSell) {
continue
}
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
val position = positions[positionKey] ?: continue
// 计算卖出数量
val sellQuantity = if (task.copyMode == "RATIO") {
if (position.leaderBuyQuantity != null && position.leaderBuyQuantity > BigDecimal.ZERO) {
position.quantity.multiply(
leaderTrade.size.divide(position.leaderBuyQuantity, 8, java.math.RoundingMode.DOWN)
)
} else {
position.quantity
}
} else {
position.quantity
}
val actualSellQuantity = if (sellQuantity > position.quantity) {
position.quantity
} else {
sellQuantity
}
// 计算卖出金额
val sellAmount = actualSellQuantity.multiply(leaderTrade.price.toSafeBigDecimal())
// 5.6.2 检查卖出金额限制
val finalSellAmount = if (sellAmount > task.maxOrderSize) {
logger.info("卖出金额超过最大限制: $sellAmount > ${task.maxOrderSize},调整为最大值")
task.maxOrderSize
} else if (sellAmount < task.minOrderSize) {
logger.info("卖出金额低于最小限制: $sellAmount < ${task.minOrderSize},调整为最小值")
task.minOrderSize
} else {
sellAmount
}
val netAmount = finalSellAmount
// 计算盈亏
val cost = actualSellQuantity.multiply(position.avgPrice)
val profitLoss = netAmount.subtract(cost)
// 更新余额和持仓
currentBalance += netAmount
if (position.quantity <= BigDecimal.ZERO) {
positions.remove(positionKey)
}
// 记录交易到当前页列表
currentPageTrades.add(BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = leaderTrade.timestamp,
marketId = leaderTrade.marketId,
marketTitle = leaderTrade.marketTitle,
side = "SELL",
outcome = leaderTrade.outcome ?: leaderTrade.outcomeIndex.toString(),
outcomeIndex = leaderTrade.outcomeIndex,
quantity = actualSellQuantity,
price = leaderTrade.price.toSafeBigDecimal(),
amount = finalSellAmount,
fee = BigDecimal.ZERO,
profitLoss = profitLoss,
balanceAfter = currentBalance,
leaderTradeId = leaderTrade.tradeId
))
// SELL 订单不计入每日订单数限制
// 更新每日亏损缓存(只累加亏损,不累加盈利)
if (profitLoss < BigDecimal.ZERO) {
val currentDailyLoss = dailyLossCache.getOrDefault(tradeDate, BigDecimal.ZERO)
dailyLossCache[tradeDate] = currentDailyLoss + profitLoss.negate()
}
}
} catch (e: Exception) {
logger.error("处理交易失败: tradeId=${leaderTrade.tradeId}", e)
}
}
// 保存当前页的所有交易(每页处理完成后保存,避免重复插入)
if (currentPageTrades.isNotEmpty()) {
logger.info("保存第 $currentPage 页的交易数据,共 ${currentPageTrades.size}")
// 批量保存当前页的交易
backtestTradeRepository.saveAll(currentPageTrades)
// 更新当前页的最后处理信息
val lastTradeInPage = currentPageTrades.lastOrNull()
if (lastTradeInPage != null && lastProcessedIndexInPage != null) {
task.lastProcessedTradeTime = lastTradeInPage.tradeTime
task.lastProcessedTradeIndex = lastProcessedIndexInPage
task.processedTradeCount = lastProcessedIndexInPage + 1
task.finalBalance = currentBalance
backtestTaskRepository.save(task)
logger.info("$currentPage 页处理完成,更新索引: ${task.lastProcessedTradeIndex}, 总处理数: ${task.processedTradeCount}")
}
} else {
logger.info("$currentPage 页没有交易需要保存")
}
// 将当前页交易添加到全局列表(用于最终统计)
trades.addAll(currentPageTrades)
// 准备处理下一页
currentPage++
} catch (e: Exception) {
logger.error("获取或处理第 $currentPage 页数据失败: ${e.message}", e)
// 重试失败,标记任务为 FAILED
throw e
}
}
// 6. 处理回测结束时仍未到期的持仓
currentBalance = settleRemainingPositions(task, positions, currentBalance, trades, endTime)
// 7. 计算最终统计数据
val statistics = calculateStatistics(trades)
// 8. 更新任务状态
val profitAmount = currentBalance.subtract(task.initialBalance)
val profitRate = if (task.initialBalance > BigDecimal.ZERO) {
profitAmount.divide(task.initialBalance, 4, java.math.RoundingMode.HALF_UP).multiply(BigDecimal("100"))
} else {
BigDecimal.ZERO
}
val finalStatus = if (task.status == "STOPPED") "STOPPED" else "COMPLETED"
task.finalBalance = currentBalance
task.profitAmount = profitAmount
task.profitRate = profitRate
task.endTime = endTime
task.status = finalStatus
task.progress = 100
task.totalTrades = trades.size
task.buyTrades = trades.count { it.side == "BUY" }
task.sellTrades = trades.count { it.side == "SELL" }
task.winTrades = statistics.winTrades
task.lossTrades = statistics.lossTrades
task.winRate = statistics.winRate.toSafeBigDecimal()
task.maxProfit = statistics.maxProfit.toSafeBigDecimal()
task.maxLoss = statistics.maxLoss.toSafeBigDecimal()
task.maxDrawdown = statistics.maxDrawdown.toSafeBigDecimal()
task.avgHoldingTime = statistics.avgHoldingTime
task.executionFinishedAt = System.currentTimeMillis()
task.updatedAt = System.currentTimeMillis()
backtestTaskRepository.save(task)
logger.info("回测任务执行完成: taskId=${task.id}, " +
"最终余额=${currentBalance.toPlainString()}, " +
"收益额=${task.profitAmount?.toPlainString()}, " +
"收益率=${task.profitRate?.toPlainString()}%, " +
"总交易数=${trades.size}, " +
"盈利率=${task.winRate?.toPlainString()}%")
} catch (e: Exception) {
logger.error("回测任务执行失败: taskId=${task.id}", e)
task.status = "FAILED"
task.errorMessage = e.message
task.executionFinishedAt = System.currentTimeMillis()
task.updatedAt = System.currentTimeMillis()
backtestTaskRepository.save(task)
throw e
}
}
/**
* 结算已到期的市场
*/
private suspend fun settleExpiredPositions(
task: BacktestTask,
positions: MutableMap<String, Position>,
currentBalance: BigDecimal,
trades: MutableList<BacktestTrade>,
currentTime: Long
): BigDecimal {
var balance = currentBalance
for ((positionKey, position) in positions.toList()) {
try {
// 获取市场当前价格
val marketPrice = marketPriceService.getCurrentMarketPrice(
position.marketId,
position.outcomeIndex ?: 0
)
val price = marketPrice.toSafeBigDecimal()
// 通过市场价格判断结算价格
val settlementPrice = when {
price >= BigDecimal("0.95") -> BigDecimal.ONE
price <= BigDecimal("0.05") -> BigDecimal.ZERO
else -> position.avgPrice
}
val settlementValue = position.quantity.multiply(settlementPrice)
val profitLoss = settlementValue.subtract(position.quantity.multiply(position.avgPrice))
balance += settlementValue
// 记录结算交易
trades.add(BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = currentTime,
marketId = position.marketId,
marketTitle = "",
side = "SETTLEMENT",
outcome = when {
settlementPrice == BigDecimal.ONE -> "WIN"
settlementPrice == BigDecimal.ZERO -> "LOSE"
else -> "UNKNOWN"
},
outcomeIndex = position.outcomeIndex,
quantity = position.quantity,
price = settlementPrice,
amount = settlementValue,
fee = BigDecimal.ZERO,
profitLoss = profitLoss,
balanceAfter = balance,
leaderTradeId = null
))
// 移除已结算的持仓
positions.remove(positionKey)
} catch (e: Exception) {
logger.error("结算市场失败: marketId=${position.marketId}, outcomeIndex=${position.outcomeIndex}", e)
}
}
return balance
}
/**
* 结算未到期持仓
*/
private suspend fun settleRemainingPositions(
task: BacktestTask,
positions: MutableMap<String, Position>,
currentBalance: BigDecimal,
trades: MutableList<BacktestTrade>,
currentTime: Long
): BigDecimal {
var balance = currentBalance
for ((positionKey, position) in positions.toList()) {
val quantity = position.quantity
val avgPrice = position.avgPrice
val settlementPrice = avgPrice
val settlementValue = quantity.multiply(settlementPrice)
val profitLoss = settlementValue.negate()
balance += settlementValue
// 记录平仓交易
trades.add(BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = currentTime,
marketId = position.marketId,
marketTitle = "",
side = "SETTLEMENT",
outcome = "CLOSED",
outcomeIndex = position.outcomeIndex,
quantity = quantity,
price = avgPrice,
amount = settlementValue,
fee = BigDecimal.ZERO,
profitLoss = profitLoss,
balanceAfter = balance,
leaderTradeId = null
))
}
positions.clear()
return balance
}
/**
* 计算统计数据
*/
private fun calculateStatistics(trades: List<BacktestTrade>): BacktestStatisticsDto {
val buyTrades = trades.count { it.side == "BUY" }
val sellTrades = trades.count { it.side == "SELL" }
val winTrades = trades.count { it.profitLoss != null && it.profitLoss > BigDecimal.ZERO }
val lossTrades = trades.count { it.profitLoss != null && it.profitLoss < BigDecimal.ZERO }
var totalProfit = BigDecimal.ZERO
var totalLoss = BigDecimal.ZERO
var maxProfit = BigDecimal.ZERO
var maxLoss = BigDecimal.ZERO
// 计算最大回撤
var runningBalance = if (trades.isNotEmpty()) {
trades[0].balanceAfter?.toSafeBigDecimal() ?: BigDecimal.ZERO
} else {
BigDecimal.ZERO
}
var peakBalance = runningBalance
var maxDrawdown = BigDecimal.ZERO
for (i in trades.indices) {
val trade = trades[i]
val balance = trade.balanceAfter?.toSafeBigDecimal() ?: continue
if (trade.profitLoss != null) {
val pnl = trade.profitLoss.toSafeBigDecimal()
if (pnl > BigDecimal.ZERO) {
totalProfit += pnl
if (pnl > maxProfit) maxProfit = pnl
} else {
totalLoss += pnl
if (pnl < maxLoss) maxLoss = pnl
}
}
if (balance > peakBalance) {
peakBalance = balance
}
val drawdown = peakBalance - runningBalance
if (drawdown > maxDrawdown) {
maxDrawdown = drawdown
}
runningBalance = balance
}
// 计算平均持仓时间
var avgHoldingTime: Long? = null
if (trades.size > 1) {
var totalHoldingTime = 0L
var count = 0
for (i in 0 until trades.size - 1) {
val currentTrade = trades[i]
val nextTrade = trades[i + 1]
if (currentTrade.side == "BUY" && nextTrade.side == "SELL") {
val holdingTime = nextTrade.tradeTime - currentTrade.tradeTime
totalHoldingTime += holdingTime
count++
}
}
if (count > 0) {
avgHoldingTime = totalHoldingTime / count
}
}
return BacktestStatisticsDto(
totalTrades = trades.size,
buyTrades = buyTrades,
sellTrades = sellTrades,
winTrades = winTrades,
lossTrades = lossTrades,
winRate = if (buyTrades + sellTrades > 0) {
(winTrades.toBigDecimal().divide((buyTrades + sellTrades).toBigDecimal(), 4, java.math.RoundingMode.HALF_UP))
.multiply(BigDecimal("100"))
.toPlainString()
} else {
BigDecimal.ZERO.toPlainString()
},
maxProfit = maxProfit.toPlainString(),
maxLoss = maxLoss.toPlainString(),
maxDrawdown = maxDrawdown.toPlainString(),
avgHoldingTime = avgHoldingTime
)
}
/**
* 计算跟单金额
*/
private fun calculateFollowAmount(task: BacktestTask, leaderTrade: TradeData): BigDecimal {
return if (task.copyMode == "RATIO") {
// 比例模式:Leader 成交金额 × 跟单比例
leaderTrade.amount.toSafeBigDecimal().multiply(task.copyRatio)
} else {
// 固定金额模式:使用配置的固定金额
task.fixedAmount ?: leaderTrade.amount.toSafeBigDecimal()
}
}
/**
* 判断是否同一天
*/
private fun isSameDay(timestamp1: Long, timestamp2: Long): Boolean {
val cal1 = Calendar.getInstance().apply { timeInMillis = timestamp1 }
val cal2 = Calendar.getInstance().apply { timeInMillis = timestamp2 }
return cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR) &&
cal1.get(Calendar.DAY_OF_YEAR) == cal2.get(Calendar.DAY_OF_YEAR)
}
/**
* 格式化时间戳
*/
private fun formatTimestamp(timestamp: Long): String {
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
return sdf.format(Date(timestamp))
}
/**
* 格式化日期(用于缓存key)
*/
private fun formatDate(timestamp: Long): String {
val sdf = SimpleDateFormat("yyyy-MM-dd")
return sdf.format(Date(timestamp))
}
}
@@ -0,0 +1,139 @@
package com.wrbug.polymarketbot.service.backtest
import com.wrbug.polymarketbot.entity.BacktestTask
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.ThreadPoolExecutor
import kotlinx.coroutines.runBlocking
/**
* 回测轮询服务
* 定时获取待执行的回测任务并执行
*/
@Service
class BacktestPollingService(
private val backtestTaskRepository: BacktestTaskRepository,
private val executionService: BacktestExecutionService
) {
private val logger = LoggerFactory.getLogger(BacktestPollingService::class.java)
// 线程池:同一时刻只执行一个任务
private val executor: ExecutorService = Executors.newFixedThreadPool(1) as ThreadPoolExecutor
/**
* 轮询待执行的回测任务
* 每 10 秒执行一次
* 规则:同一时刻只执行一个任务,如果有多个待执行任务,按创建时间先后执行最早创建的
*/
@Scheduled(fixedDelay = 10000) // 10 秒
fun pollPendingTasks() {
try {
logger.debug("开始轮询待执行的回测任务")
// 1. 检查是否有长时间处于 RUNNING 状态的任务(可能是应用重启导致的)
val runningTasks = backtestTaskRepository.findByStatus("RUNNING")
if (runningTasks.isNotEmpty()) {
val activeQueueSize = (executor as ThreadPoolExecutor).queue.size
val activeCount = (executor as ThreadPoolExecutor).activeCount
// 如果有线程池中没有活跃任务但有 RUNNING 状态的任务,说明是应用重启导致的
// 重置这些任务的状态为 PENDING,以便恢复执行
if (activeCount == 0 && runningTasks.isNotEmpty()) {
logger.info("检测到应用重启导致的异常 RUNNING 任务,重置为 PENDING 以便恢复")
runningTasks.forEach { task ->
val now = System.currentTimeMillis()
val executionStartedAt = task.executionStartedAt
val executionDuration = if (executionStartedAt != null) {
now - executionStartedAt
} else {
0L
}
// 如果任务执行时间超过 1 分钟,认为是异常状态
if (executionDuration > 60000) {
logger.info("重置异常 RUNNING 任务: taskId=${task.id}, executionStartedAt=$executionStartedAt, duration=${executionDuration}ms")
task.status = "PENDING"
task.updatedAt = now
backtestTaskRepository.save(task)
}
}
} else {
logger.debug("${runningTasks.size} 个任务正在执行,跳过本次轮询")
return
}
}
// 2. 查询所有 PENDING 状态的任务,按创建时间升序排序
val pendingTasks = backtestTaskRepository.findByStatus("PENDING")
.sortedBy { it.createdAt }
if (pendingTasks.isEmpty()) {
logger.debug("没有待执行的回测任务")
return
}
// 3. 只执行最早创建的任务
val taskToExecute = pendingTasks.first()
logger.info("找到 ${pendingTasks.size} 个待执行的回测任务,执行最早创建的任务: taskId=${taskToExecute.id}, createdAt=${taskToExecute.createdAt}")
// 4. 提交任务到线程池执行
executor.submit {
try {
// 执行前再次检查任务状态(防止并发执行)
val currentTask = backtestTaskRepository.findById(taskToExecute.id!!).orElse(null)
if (currentTask == null || currentTask.status != "PENDING") {
logger.debug("任务状态已变更,跳过执行: taskId=${taskToExecute.id}, currentStatus=${currentTask?.status}")
return@submit
}
runBlocking {
// 支持恢复:如果有恢复点,计算从哪一页开始
val pageSize = 100
val page = if (currentTask.lastProcessedTradeIndex != null) {
// 从第几页开始(页码从 0 开始)
// 例如:已处理了99笔,lastProcessedTradeIndex=99,应从第1页开始(offset=100
val lastProcessedIndex = currentTask.lastProcessedTradeIndex!!
// 计算已处理的页码(从 0 开始)
val processedPage = lastProcessedIndex / pageSize
// 特殊情况:如果lastProcessedTradeIndex刚好是100的倍数减1(比如99,199,299...
// 说明该页已经完全处理,应该从下一页开始
val nextPage = if (lastProcessedIndex % pageSize == pageSize - 1) {
processedPage + 1
} else {
processedPage
}
logger.info("恢复任务:已处理索引=$lastProcessedIndex, 计算页码=$nextPage, size=$pageSize")
nextPage
} else {
logger.info("新任务:从第0页开始")
0 // 从第0页开始(offset=0
}
logger.info("执行回测任务: taskId=${currentTask.id}, page=$page, size=$pageSize")
executionService.executeBacktest(currentTask, page = page, size = pageSize)
}
} catch (e: Exception) {
logger.error("回测任务执行失败: taskId=${taskToExecute.id}", e)
// 更新任务状态为 FAILED
val failedTask = backtestTaskRepository.findById(taskToExecute.id!!).orElse(null)
if (failedTask != null) {
failedTask.status = "FAILED"
failedTask.errorMessage = e.message
failedTask.updatedAt = System.currentTimeMillis()
backtestTaskRepository.save(failedTask)
}
}
}
} catch (e: Exception) {
logger.error("轮询回测任务失败", e)
}
}
}
@@ -0,0 +1,373 @@
package com.wrbug.polymarketbot.service.backtest
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.BacktestTask
import com.wrbug.polymarketbot.entity.BacktestTrade
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
import com.wrbug.polymarketbot.repository.BacktestTradeRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.toJson
import com.wrbug.polymarketbot.util.fromJson
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
/**
* 回测任务服务
*/
@Service
class BacktestService(
private val backtestTaskRepository: BacktestTaskRepository,
private val backtestTradeRepository: BacktestTradeRepository,
private val leaderRepository: LeaderRepository,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(BacktestService::class.java)
/**
* 创建回测任务
*/
@Transactional
fun createBacktestTask(request: BacktestCreateRequest): Result<BacktestTaskDto> {
return try {
// 1. 验证 Leader 是否存在
val leader = leaderRepository.findById(request.leaderId).orElse(null)
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
// 2. 验证回测天数
if (request.backtestDays < 1 || request.backtestDays > 15) {
return Result.failure(IllegalArgumentException("回测天数必须在 1-15 之间"))
}
// 3. 验证恢复页码(如果提供)
if (request.pageForResume != null && request.pageForResume < 1) {
return Result.failure(IllegalArgumentException("恢复页码必须大于 0"))
}
// 4. 验证初始金额
val initialBalance = request.initialBalance.toSafeBigDecimal()
if (initialBalance <= BigDecimal.ZERO) {
return Result.failure(IllegalArgumentException("初始金额必须大于 0"))
}
// 4. 创建回测任务
val task = BacktestTask(
taskName = request.taskName.trim(),
leaderId = request.leaderId,
initialBalance = initialBalance,
backtestDays = request.backtestDays,
startTime = System.currentTimeMillis() - (request.backtestDays * 24 * 3600 * 1000),
status = "PENDING",
// 跟单配置(不包含 max_position_count
copyMode = request.copyMode ?: "RATIO",
copyRatio = request.copyRatio?.toSafeBigDecimal() ?: BigDecimal.ONE,
fixedAmount = request.fixedAmount?.toSafeBigDecimal(),
maxOrderSize = request.maxOrderSize?.toSafeBigDecimal() ?: "1000".toSafeBigDecimal(),
minOrderSize = request.minOrderSize?.toSafeBigDecimal() ?: "1".toSafeBigDecimal(),
maxDailyLoss = request.maxDailyLoss?.toSafeBigDecimal() ?: "10000".toSafeBigDecimal(),
maxDailyOrders = request.maxDailyOrders ?: 100,
supportSell = request.supportSell ?: true,
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
keywords = if (request.keywords != null && request.keywords.isNotEmpty()) {
request.keywords.toJson()
} else {
null
}
)
backtestTaskRepository.save(task)
// 5. 转换为 DTO 返回
Result.success(task.toDto(leader))
} catch (e: Exception) {
logger.error("创建回测任务失败", e)
Result.failure(e)
}
}
/**
* 查询回测任务列表
*/
fun getBacktestTaskList(request: BacktestListRequest): Result<BacktestListResponse> {
return try {
// 获取所有符合条件的任务
val allTasks = when {
request.leaderId != null && request.status != null -> {
backtestTaskRepository.findByLeaderIdAndStatus(request.leaderId, request.status)
}
request.leaderId != null -> {
backtestTaskRepository.findByLeaderId(request.leaderId)
.filter { request.status == null || it.status == request.status }
}
request.status != null -> {
backtestTaskRepository.findByStatus(request.status)
}
else -> {
backtestTaskRepository.findAll()
}
}
// 排序
val sortedTasks = when (request.sortBy) {
"profitAmount" -> {
if (request.sortOrder == "asc") {
allTasks.sortedBy { it.profitAmount }
} else {
allTasks.sortedByDescending { it.profitAmount }
}
}
"profitRate" -> {
if (request.sortOrder == "asc") {
allTasks.sortedBy { it.profitRate }
} else {
allTasks.sortedByDescending { it.profitRate }
}
}
else -> {
if (request.sortOrder == "asc") {
allTasks.sortedBy { it.createdAt }
} else {
allTasks.sortedByDescending { it.createdAt }
}
}
}
// 分页
val total = sortedTasks.size
val pagedTasks = sortedTasks
.drop((request.page - 1) * request.size)
.take(request.size)
val list = pagedTasks.map { task ->
val leader = leaderRepository.findById(task.leaderId).orElse(null)
task.toDto(leader)
}
Result.success(
BacktestListResponse(
list = list,
total = total.toLong(),
page = request.page,
size = request.size
)
)
} catch (e: Exception) {
logger.error("查询回测任务列表失败", e)
Result.failure(e)
}
}
/**
* 查询回测任务详情
*/
fun getBacktestTaskDetail(request: BacktestDetailRequest): Result<BacktestDetailResponse> {
return try {
val task = backtestTaskRepository.findById(request.id).orElse(null)
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
val leader = leaderRepository.findById(task.leaderId).orElse(null)
val config = BacktestConfigDto(
copyMode = task.copyMode,
copyRatio = task.copyRatio.toPlainString(),
fixedAmount = task.fixedAmount?.toPlainString(),
maxOrderSize = task.maxOrderSize.toPlainString(),
minOrderSize = task.minOrderSize.toPlainString(),
maxDailyLoss = task.maxDailyLoss.toPlainString(),
maxDailyOrders = task.maxDailyOrders,
supportSell = task.supportSell,
keywordFilterMode = task.keywordFilterMode,
keywords = if (task.keywords != null) {
task.keywords.fromJson<List<String>>()
} else {
emptyList()
}
)
val statistics = BacktestStatisticsDto(
totalTrades = task.totalTrades,
buyTrades = task.buyTrades,
sellTrades = task.sellTrades,
winTrades = task.winTrades,
lossTrades = task.lossTrades,
winRate = task.winRate?.toPlainString() ?: "0.00",
maxProfit = task.maxProfit?.toPlainString() ?: "0.00",
maxLoss = task.maxLoss?.toPlainString() ?: "0.00",
maxDrawdown = task.maxDrawdown?.toPlainString() ?: "0.00",
avgHoldingTime = task.avgHoldingTime
)
val taskDto = task.toDto(leader)
Result.success(
BacktestDetailResponse(
task = taskDto,
config = config,
statistics = statistics
)
)
} catch (e: Exception) {
logger.error("查询回测任务详情失败", e)
Result.failure(e)
}
}
/**
* 查询回测交易记录
*/
fun getBacktestTrades(request: BacktestTradeListRequest): Result<BacktestTradeListResponse> {
return try {
val pageRequest = PageRequest.of(
request.page - 1,
request.size,
Sort.by(Sort.Order.asc("tradeTime"))
)
val tradesPage = backtestTradeRepository.findByBacktestTaskId(
request.taskId,
pageRequest
)
val list = tradesPage.content.map { trade ->
BacktestTradeDto(
id = trade.id!!,
tradeTime = trade.tradeTime,
marketId = trade.marketId,
marketTitle = trade.marketTitle,
side = trade.side,
outcome = trade.outcome,
outcomeIndex = trade.outcomeIndex,
quantity = trade.quantity.toPlainString(),
price = trade.price.toPlainString(),
amount = trade.amount.toPlainString(),
fee = trade.fee.toPlainString(),
profitLoss = trade.profitLoss?.toPlainString(),
balanceAfter = trade.balanceAfter.toPlainString(),
leaderTradeId = trade.leaderTradeId
)
}
Result.success(
BacktestTradeListResponse(
list = list,
total = tradesPage.totalElements,
page = request.page,
size = request.size
)
)
} catch (e: Exception) {
logger.error("查询回测交易记录失败", e)
Result.failure(e)
}
}
/**
* 删除回测任务
*/
@Transactional
fun deleteBacktestTask(request: BacktestDeleteRequest): Result<Unit> {
return try {
val task = backtestTaskRepository.findById(request.id).orElse(null)
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
if (task.status == "RUNNING") {
return Result.failure(IllegalStateException("回测任务正在运行,无法删除"))
}
backtestTaskRepository.deleteById(request.id)
Result.success(Unit)
} catch (e: Exception) {
logger.error("删除回测任务失败", e)
Result.failure(e)
}
}
/**
* 停止回测任务
*/
@Transactional
fun stopBacktestTask(request: BacktestStopRequest): Result<Unit> {
return try {
val task = backtestTaskRepository.findById(request.id).orElse(null)
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
if (task.status != "RUNNING") {
return Result.failure(IllegalArgumentException("回测任务未在运行中"))
}
task.status = "STOPPED"
task.updatedAt = System.currentTimeMillis()
backtestTaskRepository.save(task)
Result.success(Unit)
} catch (e: Exception) {
logger.error("停止回测任务失败", e)
Result.failure(e)
}
}
/**
* 重试回测任务
* 从断点继续执行,保留已处理的交易记录
*/
@Transactional
fun retryBacktestTask(request: BacktestRetryRequest): Result<Unit> {
return try {
val task = backtestTaskRepository.findById(request.id).orElse(null)
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
if (task.status == "RUNNING") {
return Result.failure(IllegalArgumentException("回测任务正在运行中,无需重试"))
}
// 重置任务状态为 PENDING,进度保持不变
task.status = "PENDING"
task.errorMessage = null
task.updatedAt = System.currentTimeMillis()
// 不清理已处理的交易记录,保留恢复点
backtestTaskRepository.save(task)
Result.success(Unit)
} catch (e: Exception) {
logger.error("重试回测任务失败", e)
Result.failure(e)
}
}
}
/**
* 扩展函数:BacktestTask 转 DTO
*/
private fun BacktestTask.toDto(leader: Leader?): BacktestTaskDto {
return BacktestTaskDto(
id = this.id!!,
taskName = this.taskName,
leaderId = this.leaderId,
leaderName = leader?.leaderName,
leaderAddress = leader?.leaderAddress,
initialBalance = this.initialBalance.toPlainString(),
finalBalance = this.finalBalance?.toPlainString(),
profitAmount = this.profitAmount?.toPlainString(),
profitRate = this.profitRate?.toPlainString(),
backtestDays = this.backtestDays,
startTime = this.startTime,
endTime = this.endTime,
status = this.status,
progress = this.progress,
totalTrades = this.totalTrades,
createdAt = this.createdAt,
executionStartedAt = this.executionStartedAt,
executionFinishedAt = this.executionFinishedAt
)
}
@@ -9,6 +9,8 @@ import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.math.BigInteger
import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
/**
* 市场价格服务
@@ -27,6 +29,21 @@ class MarketPriceService(
private val logger = LoggerFactory.getLogger(MarketPriceService::class.java)
/**
* 已结算市场的价格缓存
* Key: "marketId:outcomeIndex"
* Value: BigDecimal (1.0 或 0.0)
*
* 缓存策略:
* - 最大缓存 10,000 个已结算市场
* - 永不过期(已结算的市场状态永不改变)
* - 内存占用约: 10,000 * ~100 bytes = ~1MB
*/
private val settledMarketCache: Cache<String, BigDecimal> = Caffeine.newBuilder()
.maximumSize(10_000)
.recordStats() // 启用统计信息
.build()
/**
* 获取当前市场最新价
* 优先级:
@@ -83,9 +100,20 @@ class MarketPriceService(
* - payout == 0(输了)→ 返回 0.0
* 如果市场未结算或查询失败,返回 null
*
* 使用缓存优化:已结算的市场结果会被缓存,避免重复 RPC 调用
*
* @return Pair<BigDecimal?, Boolean> 第一个值是价格(如果已结算),第二个值表示是否发生了 RPC 错误(execution reverted
*/
private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): Pair<BigDecimal?, Boolean> {
// 1. 先检查缓存
val cacheKey = "$marketId:$outcomeIndex"
val cachedPrice = settledMarketCache.getIfPresent(cacheKey)
if (cachedPrice != null) {
logger.debug("从缓存获取已结算市场价格: marketId=$marketId, outcomeIndex=$outcomeIndex, price=$cachedPrice")
return Pair(cachedPrice, false)
}
// 2. 缓存未命中,发起 RPC 查询
return try {
val chainResult = blockchainService.getCondition(marketId)
chainResult.fold(
@@ -96,11 +124,17 @@ class MarketPriceService(
when {
payout > BigInteger.ZERO -> {
logger.info("从链上查询到市场已结算,该 outcome 赢了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
return Pair(BigDecimal.ONE, false)
val price = BigDecimal.ONE
// 缓存已结算的结果
settledMarketCache.put(cacheKey, price)
return Pair(price, false)
}
payout == BigInteger.ZERO -> {
logger.info("从链上查询到市场已结算,该 outcome 输了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
return Pair(BigDecimal.ZERO, false)
val price = BigDecimal.ZERO
// 缓存已结算的结果
settledMarketCache.put(cacheKey, price)
return Pair(price, false)
}
else -> {
logger.warn("从链上查询到异常的 payout 值: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
@@ -109,7 +143,7 @@ class MarketPriceService(
}
} else {
logger.debug("从链上查询到市场尚未结算: marketId=$marketId, payouts=${payouts.size}")
Pair(null, false)
Pair(null, false) // 未结算的市场不缓存
}
},
onFailure = { e ->
@@ -290,5 +324,29 @@ class MarketPriceService(
}
}
/**
* 获取缓存统计信息
* 用于监控缓存命中率和性能
*/
fun getCacheStats(): String {
val stats = settledMarketCache.stats()
return """
已结算市场缓存统计:
- 缓存条目数: ${settledMarketCache.estimatedSize()}
- 命中次数: ${stats.hitCount()}
- 未命中次数: ${stats.missCount()}
- 命中率: ${"%.2f".format(stats.hitRate() * 100)}%
- 总请求次数: ${stats.requestCount()}
""".trimIndent()
}
/**
* 清空缓存(测试或管理用)
*/
fun clearSettledMarketCache() {
settledMarketCache.invalidateAll()
logger.info("已清空已结算市场缓存")
}
}
@@ -185,6 +185,7 @@ open class CopyOrderTrackingService(
processedAt = System.currentTimeMillis()
)
processedTradeRepository.save(processed)
} catch (e: Exception) {
// 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底)
if (isUniqueConstraintViolation(e)) {
@@ -0,0 +1,97 @@
-- ============================================
-- 回测功能表创建
-- ============================================
-- ============================================
-- 2. 创建回测任务表
-- ============================================
CREATE TABLE IF NOT EXISTS backtest_task (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '回测任务ID',
task_name VARCHAR(100) NOT NULL COMMENT '回测任务名称',
leader_id BIGINT NOT NULL COMMENT 'Leader ID',
initial_balance DECIMAL(20, 8) NOT NULL COMMENT '初始资金',
final_balance DECIMAL(20, 8) DEFAULT NULL COMMENT '最终资金',
profit_amount DECIMAL(20, 8) DEFAULT NULL COMMENT '收益金额',
profit_rate DECIMAL(10, 4) DEFAULT NULL COMMENT '收益率(%)',
backtest_days INT NOT NULL COMMENT '回测天数',
start_time BIGINT NOT NULL COMMENT '回测开始时间(历史时间)',
end_time BIGINT DEFAULT NULL COMMENT '回测结束时间(历史时间)',
-- 跟单配置 (复制CopyTrading表结构)
copy_mode VARCHAR(10) NOT NULL DEFAULT 'RATIO' COMMENT '跟单模式: RATIO/FIXED',
copy_ratio DECIMAL(20, 8) NOT NULL DEFAULT 1.0 COMMENT '跟单比例',
fixed_amount DECIMAL(20, 8) DEFAULT NULL COMMENT '固定金额',
max_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1000.0 COMMENT '最大单笔订单',
min_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1.0 COMMENT '最小单笔订单',
max_daily_loss DECIMAL(20, 8) NOT NULL DEFAULT 10000.0 COMMENT '最大每日亏损',
max_daily_orders INT NOT NULL DEFAULT 100 COMMENT '最大每日订单数',
price_tolerance DECIMAL(5, 2) NOT NULL DEFAULT 5.0 COMMENT '价格容忍度(%)',
delay_seconds INT NOT NULL DEFAULT 0 COMMENT '延迟秒数',
support_sell BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否支持卖出',
min_order_depth DECIMAL(20, 8) DEFAULT NULL COMMENT '最小订单深度',
max_spread DECIMAL(20, 8) DEFAULT NULL COMMENT '最大价差',
min_price DECIMAL(20, 8) DEFAULT NULL COMMENT '最低价格',
max_price DECIMAL(20, 8) DEFAULT NULL COMMENT '最高价格',
max_position_value DECIMAL(20, 8) DEFAULT NULL COMMENT '最大仓位金额',
keyword_filter_mode VARCHAR(20) NOT NULL DEFAULT 'DISABLED' COMMENT '关键字过滤模式',
keywords JSON DEFAULT NULL COMMENT '关键字列表',
max_market_end_date BIGINT DEFAULT NULL COMMENT '市场截止时间限制',
-- 统计字段
avg_holding_time BIGINT DEFAULT NULL COMMENT '平均持仓时间(毫秒)',
data_source VARCHAR(50) DEFAULT 'MIXED' COMMENT '数据源: INTERNAL/API/MIXED',
-- 执行状态
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '状态: PENDING/RUNNING/COMPLETED/STOPPED/FAILED',
progress INT DEFAULT 0 COMMENT '执行进度(0-100)',
total_trades INT DEFAULT 0 COMMENT '总交易笔数',
buy_trades INT DEFAULT 0 COMMENT '买入笔数',
sell_trades INT DEFAULT 0 COMMENT '卖出笔数',
win_trades INT DEFAULT 0 COMMENT '盈利交易笔数',
loss_trades INT DEFAULT 0 COMMENT '亏损交易笔数',
win_rate DECIMAL(5, 2) DEFAULT NULL COMMENT '胜率(%)',
max_profit DECIMAL(20, 8) DEFAULT NULL COMMENT '最大单笔盈利',
max_loss DECIMAL(20, 8) DEFAULT NULL COMMENT '最大单笔亏损',
max_drawdown DECIMAL(20, 8) DEFAULT NULL COMMENT '最大回撤',
error_message TEXT DEFAULT NULL COMMENT '错误信息',
created_at BIGINT NOT NULL COMMENT '创建时间',
execution_started_at BIGINT DEFAULT NULL COMMENT '执行开始时间(系统时间)',
execution_finished_at BIGINT DEFAULT NULL COMMENT '执行完成时间(系统时间)',
updated_at BIGINT NOT NULL COMMENT '更新时间',
INDEX idx_leader_id (leader_id),
INDEX idx_status (status),
INDEX idx_created_at (created_at),
INDEX idx_leader_profit (leader_id, profit_rate DESC),
INDEX idx_status_created (status, created_at DESC),
FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='回测任务表';
-- ============================================
-- 3. 创建回测交易记录表
-- ============================================
CREATE TABLE IF NOT EXISTS backtest_trade (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '交易记录ID',
backtest_task_id BIGINT NOT NULL COMMENT '回测任务ID',
trade_time BIGINT NOT NULL COMMENT '交易时间',
market_id VARCHAR(100) NOT NULL COMMENT '市场ID',
market_title VARCHAR(500) DEFAULT NULL COMMENT '市场标题',
side VARCHAR(20) NOT NULL COMMENT '方向: BUY/SELL/SETTLEMENT',
outcome VARCHAR(50) NOT NULL COMMENT '结果: YES/NO或outcomeIndex',
outcome_index INT DEFAULT NULL COMMENT '结果索引(0, 1, 2, ...),支持多元市场',
quantity DECIMAL(20, 8) NOT NULL COMMENT '数量',
price DECIMAL(20, 8) NOT NULL COMMENT '价格',
amount DECIMAL(20, 8) NOT NULL COMMENT '金额',
fee DECIMAL(20, 8) NOT NULL DEFAULT 0.0 COMMENT '手续费',
profit_loss DECIMAL(20, 8) DEFAULT NULL COMMENT '盈亏(仅卖出时)',
balance_after DECIMAL(20, 8) NOT NULL COMMENT '交易后余额',
leader_trade_id VARCHAR(100) DEFAULT NULL COMMENT 'Leader原始交易ID',
created_at BIGINT NOT NULL COMMENT '创建时间',
INDEX idx_backtest_task_id (backtest_task_id),
INDEX idx_trade_time (trade_time),
FOREIGN KEY (backtest_task_id) REFERENCES backtest_task(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='回测交易记录表';
@@ -0,0 +1,14 @@
-- ============================================
-- 回测功能恢复字段添加
-- ============================================
-- 添加恢复相关字段到回测任务表
ALTER TABLE backtest_task
ADD COLUMN last_processed_trade_time BIGINT DEFAULT NULL COMMENT '最后处理的交易时间(用于中断恢复)',
ADD COLUMN last_processed_trade_index INT DEFAULT 0 COMMENT '最后处理的交易索引(用于中断恢复)',
ADD COLUMN processed_trade_count INT DEFAULT 0 COMMENT '已处理的交易数量(用于显示真实进度)';
-- 添加索引以优化查询性能
ALTER TABLE backtest_task
ADD INDEX idx_last_processed_trade_time (last_processed_trade_time);
@@ -0,0 +1,13 @@
-- Drop unused columns from backtest_task table
-- These fields are not needed for backtest scenarios as they use historical data
-- Note: Using standard SQL syntax compatible with MySQL 5.7+
-- Check if columns exist before dropping (using standard approach)
ALTER TABLE backtest_task DROP COLUMN price_tolerance;
ALTER TABLE backtest_task DROP COLUMN delay_seconds;
ALTER TABLE backtest_task DROP COLUMN min_order_depth;
ALTER TABLE backtest_task DROP COLUMN max_spread;
ALTER TABLE backtest_task DROP COLUMN min_price;
ALTER TABLE backtest_task DROP COLUMN max_price;
ALTER TABLE backtest_task DROP COLUMN max_position_value;
ALTER TABLE backtest_task DROP COLUMN max_market_end_date;
@@ -0,0 +1,15 @@
-- ============================================
-- 修复回测恢复逻辑:将 last_processed_trade_index 默认值改为 NULL
-- ============================================
-- 问题:新建任务的 last_processed_trade_index 默认值为 0,导致被误判为恢复任务
-- 解决:将默认值改为 NULL,并将现有新任务的 0 值改为 NULL
-- 1. 将现有新任务(status='PENDING' 且 last_processed_trade_index=0)的索引值改为 NULL
UPDATE backtest_task
SET last_processed_trade_index = NULL
WHERE status = 'PENDING' AND last_processed_trade_index = 0;
-- 2. 修改字段定义,允许 NULL 并设置默认值为 NULL
ALTER TABLE backtest_task
MODIFY COLUMN last_processed_trade_index INT DEFAULT NULL COMMENT '最后处理的交易索引(用于中断恢复)';
@@ -255,3 +255,54 @@ error.server.order_tracking_process_failed=Failed to process order tracking
error.server.order_tracking_buy_failed=Failed to process buy order
error.server.order_tracking_sell_failed=Failed to process sell order
error.server.order_tracking_match_failed=Order matching failed
# Backtest service errors
error.backtest.task_not_found=Backtest task not found
error.backtest.leader_not_found=Leader not found
error.backtest.days_invalid=Backtest days must be between 1-15 days
error.backtest.initial_balance_invalid=Invalid initial balance
error.backtest.task_running=Backtest task is running, cannot delete
error.server.backtest_create_failed=Failed to create backtest task
error.server.backtest_update_failed=Failed to update backtest task
error.server.backtest_delete_failed=Failed to delete backtest task
error.server.backtest_list_fetch_failed=Failed to fetch backtest list
error.server.backtest_detail_fetch_failed=Failed to fetch backtest detail
error.server.backtest_trades_fetch_failed=Failed to fetch backtest trades
error.server.backtest_execute_failed=Failed to execute backtest
error.server.backtest_historical_data_fetch_failed=Failed to fetch historical data
error.server.backtest_stop_failed=Failed to stop backtest task
error.server.backtest_retry_failed=Failed to retry backtest task
# Backtest Management
backtest.title=Backtest Management
backtest.create_task=Create Backtest
backtest.task_name=Task Name
backtest.leader=Leader
backtest.initial_balance=Initial Balance
backtest.backtest_days=Backtest Days
backtest.profit_amount=Profit Amount
backtest.profit_rate=Profit Rate
backtest.backtest_days_range=Backtest Days Range (1-15 days)
backtest.total_trades=Total Trades
backtest.buy_trades=Buy Trades
backtest.sell_trades=Sell Trades
backtest.win_trades=Win Trades
backtest.loss_trades=Loss Trades
backtest.win_rate=Win Rate
backtest.max_profit=Max Profit
backtest.max_loss=Max Loss
backtest.max_drawdown=Max Drawdown
backtest.avg_holding_time=Avg Holding Time
# Backtest Status
backtest.status.pending=Pending
backtest.status.running=Running
backtest.status.completed=Completed
backtest.status.stopped=Stopped
backtest.status.failed=Failed
# Backtest Config
backtest.copy_mode.ratio=Ratio Mode
backtest.copy_mode.fixed=Fixed Amount
backtest.price_tolerance=Price Tolerance
backtest.delay_seconds=Delay Seconds
backtest.support_sell=Support Sell
@@ -255,3 +255,60 @@ error.server.order_tracking_process_failed=处理订单跟踪失败
error.server.order_tracking_buy_failed=处理买入订单失败
error.server.order_tracking_sell_failed=处理卖出订单失败
error.server.order_tracking_match_failed=订单匹配失败
# 回测服务错误
error.backtest.task_not_found=回测任务不存在
error.backtest.leader_not_found=Leader不存在
error.backtest.days_invalid=回测天数必须在 1-15 天之间
error.backtest.initial_balance_invalid=初始金额无效
error.backtest.task_running=回测任务正在运行,无法删除
error.server.backtest_create_failed=创建回测任务失败
error.server.backtest_update_failed=更新回测任务失败
error.server.backtest_delete_failed=删除回测任务失败
error.server.backtest_list_fetch_failed=查询回测列表失败
error.server.backtest_detail_fetch_failed=查询回测详情失败
error.server.backtest_trades_fetch_failed=查询回测交易记录失败
error.server.backtest_execute_failed=回测执行失败
error.server.backtest_historical_data_fetch_failed=历史数据获取失败
error.server.backtest_stop_failed=停止回测任务失败
error.server.backtest_retry_failed=重试回测任务失败
# 回测管理
backtest.title=回测管理
backtest.create_task=新增回测
backtest.task_name=回测名称
backtest.leader=Leader
backtest.initial_balance=初始金额
backtest.backtest_days=回测天数
backtest.profit_amount=收益额
backtest.profit_rate=收益率
backtest.backtest_days_range=回测天数范围(1-15天)
backtest.total_trades=总交易笔数
backtest.buy_trades=买入笔数
backtest.sell_trades=卖出笔数
backtest.win_trades=盈利交易笔数
backtest.loss_trades=亏损交易笔数
backtest.win_rate=胜率
backtest.max_profit=最大单笔盈利
backtest.max_loss=最大单笔亏损
backtest.max_drawdown=最大回撤
backtest.avg_holding_time=平均持仓时间
# 回测状态
backtest.status.pending=待执行
backtest.status.running=运行中
backtest.status.completed=已完成
backtest.status.stopped=已停止
backtest.status.failed=失败
# 回测配置
backtest.copy_mode.ratio=比例模式
backtest.copy_mode.fixed=固定金额
backtest.price_tolerance=价格容忍度
backtest.delay_seconds=延迟秒数
backtest.support_sell=支持卖出
# 订单跟踪服务错误
error.server.order_tracking_process_failed=处理订单跟踪失败
error.server.order_tracking_buy_failed=处理买入订单失败
error.server.order_tracking_sell_failed=处理卖出订单失败
error.server.order_tracking_match_failed=订单匹配失败
@@ -255,3 +255,54 @@ error.server.order_tracking_process_failed=處理訂單跟蹤失敗
error.server.order_tracking_buy_failed=處理買入訂單失敗
error.server.order_tracking_sell_failed=處理賣出訂單失敗
error.server.order_tracking_match_failed=訂單匹配失敗
# 回測服務錯誤
error.backtest.task_not_found=回測任務不存在
error.backtest.leader_not_found=Leader不存在
error.backtest.days_invalid=回測天數必須在 1-15 天之間
error.backtest.initial_balance_invalid=初始金額無效
error.backtest.task_running=回測任務正在運行,無法刪除
error.server.backtest_create_failed=創建回測任務失敗
error.server.backtest_update_failed=更新回測任務失敗
error.server.backtest_delete_failed=刪除回測任務失敗
error.server.backtest_list_fetch_failed=查詢回測列表失敗
error.server.backtest_detail_fetch_failed=查詢回測詳情失敗
error.server.backtest_trades_fetch_failed=查詢回測交易記錄失敗
error.server.backtest_execute_failed=回測執行失敗
error.server.backtest_historical_data_fetch_failed=歷史數據獲取失敗
error.server.backtest_stop_failed=停止回測任務失敗
error.server.backtest_retry_failed=重試回測任務失敗
# 回測管理
backtest.title=回測管理
backtest.create_task=新增回測
backtest.task_name=回測名稱
backtest.leader=Leader
backtest.initial_balance=初始金額
backtest.backtest_days=回測天數
backtest.profit_amount=收益額
backtest.profit_rate=收益率
backtest.backtest_days_range=回測天數範圍(1-15天)
backtest.total_trades=總交易筆數
backtest.buy_trades=買入筆數
backtest.sell_trades=賣出筆數
backtest.win_trades=盈利交易筆數
backtest.loss_trades=虧損交易筆數
backtest.win_rate=勝率
backtest.max_profit=最大單筆盈利
backtest.max_loss=最大單筆虧損
backtest.max_drawdown=最大回撤
backtest.avg_holding_time=平均持倉時間
# 回測狀態
backtest.status.pending=待執行
backtest.status.running=運行中
backtest.status.completed=已完成
backtest.status.stopped=已停止
backtest.status.failed=失敗
# 回測配置
backtest.copy_mode.ratio=比例模式
backtest.copy_mode.fixed=固定金額
backtest.price_tolerance=價格容忍度
backtest.delay_seconds=延遲秒數
backtest.support_sell=支持賣出
+323
View File
@@ -0,0 +1,323 @@
# 跟单回测功能产品需求文档 (PRD)
## 一、功能概述
跟单回测功能允许用户对历史数据进行模拟跟单交易,评估不同跟单策略的收益表现,帮助用户在实际投入资金前验证策略的有效性。
## 二、用户故事
### 主要用户场景
1. **作为用户**,我希望能够创建回测任务,选择特定的 Leader 和跟单配置,以便评估在过去一段时间内使用该策略的收益情况
2. **作为用户**,我希望能够查看所有历史回测结果,并按收益额或收益率排序,以便找到最优策略
3. **作为用户**,我希望能够按 Leader 筛选回测记录,以便比较不同 Leader 的表现
4. **作为用户**,我希望回测结果能够展示详细的交易记录和收益变化,以便深入分析策略表现
5. **作为用户**,我希望回测在资金不足时能够自动停止,模拟真实交易场景
## 三、功能需求
### 3.1 回测管理页面
#### 3.1.1 页面布局
- **页面位置**: 在跟单管理模块下新增"回测管理"菜单项
- **页面标题**: "回测管理" / "Backtest Management"
#### 3.1.2 列表功能
**筛选功能**:
- Leader 筛选下拉框,支持按 Leader 过滤回测记录
- 状态筛选: 全部 / 运行中 / 已完成 / 已停止
**排序功能**:
- 按收益额排序 (升序/降序)
- 按收益率排序 (升序/降序)
- 按创建时间排序 (默认降序)
**列表字段**:
| 字段名 | 说明 | 示例 |
|-------|------|------|
| 回测ID | 唯一标识 | #12345 |
| 配置名称 | 回测任务名称 | "激进策略-Leader A" |
| Leader名称 | 跟单的Leader | "Smart Trader" |
| 初始金额 | 回测起始资金 | $1000 |
| 最终金额 | 回测结束时资金 | $1250 |
| 收益额 | 最终金额 - 初始金额 | +$250 |
| 收益率 | (收益额/初始金额) × 100% | +25% |
| 回测天数 | 回测的时间跨度 | 30天 |
| 交易笔数 | 回测期间执行的交易数量 | 45笔 |
| 状态 | 运行中/已完成/已停止 | 已完成 |
| 开始时间 | 回测开始时间 | 2026-01-01 10:00 |
| 结束时间 | 回测结束时间 | 2026-01-31 15:30 |
| 操作 | 查看详情/删除 | - |
#### 3.1.3 列表操作
- **查看详情**: 点击后展开详细信息,包括:
- 回测配置参数
- 详细交易记录
- 资金变化曲线图
- 收益统计
- **删除**: 删除回测记录(确认后不可恢复)
### 3.2 新增回测任务
#### 3.2.1 创建入口
- 列表页面右上角"新增回测"按钮
- 点击后弹出创建对话框或跳转到创建页面
#### 3.2.2 配置表单
**基本配置**:
- **回测名称** (必填): 用户自定义名称,方便识别
- **选择Leader** (必填): 下拉框选择已添加的Leader
- **初始投入金额** (必填): 模拟起始资金,单位USDC,范围: 1 - 1,000,000
- **回测天数** (必填): 选择回测的历史天数,范围: 1 - 30天
**跟单配置** (参照现有跟单配置参数):
| 配置项 | 字段名 | 说明 | 默认值 |
|-------|-------|------|-------|
| 跟单模式 | copyMode | RATIO(比例模式) / FIXED(固定金额) | RATIO |
| 跟单比例 | copyRatio | 比例模式下生效,相对Leader订单金额的比例 | 1.0 |
| 固定金额 | fixedAmount | 固定金额模式下生效,每笔固定投入金额 | null |
| 最大单笔订单 | maxOrderSize | 单笔订单最大金额限制 | 1000 |
| 最小单笔订单 | minOrderSize | 单笔订单最小金额限制 | 1 |
| 最大每日亏损 | maxDailyLoss | 每日最大亏损限制 | 10000 |
| 最大每日订单数 | maxDailyOrders | 每日最大订单数量限制 | 100 |
| 价格容忍度 | priceTolerance | 价格偏差容忍百分比 | 5% |
| 延迟秒数 | delaySeconds | 跟单延迟时间 | 0 |
| 支持卖出 | supportSell | 是否跟随卖出 | true |
| 最小订单深度 | minOrderDepth | 订单簿深度要求 | null |
| 最大价差 | maxSpread | 买卖价差限制 | null |
| 最低价格 | minPrice | 最低价格限制 | null |
| 最高价格 | maxPrice | 最高价格限制 | null |
| 最大仓位金额 | maxPositionValue | 最大持仓总金额 | null |
| 关键字过滤模式 | keywordFilterMode | DISABLED/WHITELIST/BLACKLIST | DISABLED |
| 关键字列表 | keywords | 关键字数组 | [] |
| 市场截止时间限制 | maxMarketEndDate | 市场结束时间限制 | null |
> [!IMPORTANT]
> 跟单配置表单应完全复用现有的跟单配置组件,保持参数一致性
#### 3.2.3 表单验证
- 回测名称: 不能为空,长度1-100字符
- Leader: 必须选择有效的Leader
- 初始金额: 必须大于0
- 回测天数: 必须在1-30之间
- 其他配置参数: 遵循现有跟单配置的验证规则
#### 3.2.4 提交逻辑
1. 表单验证通过后,提交到后端API
2. 后端保存回测任务到数据库,状态设置为"待执行"
3. 前端显示创建成功提示,自动跳转到列表页面
4. 回测任务由后端轮询服务自动获取并执行
### 3.3 回测详情页面
#### 3.3.1 页面布局
**顶部概览卡片**:
- 回测名称
- Leader信息
- 初始金额 / 最终金额
- 收益额 / 收益率
- 回测时间范围
- 总交易笔数
- 状态
**资金变化图表**:
- X轴: 时间
- Y轴: 账户余额
- 折线图展示资金随时间的变化
**交易记录表格**:
| 时间 | 市场 | 方向 | 数量 | 价格 | 金额 | 盈亏 | 余额 |
|-----|------|-----|------|------|------|------|------|
| 2026-01-01 10:05 | BTC > $100k | 买入 YES | 100 | 0.65 | 65 | - | 935.00 |
| 2026-01-01 14:20 | BTC > $100k | 卖出 YES | 100 | 0.72 | 72 | +7.00 | 942.00 |
| 2026-01-05 16:00 | ETH > $5k | 买入 YES | 50 | 0.80 | 40 | - | 902.00 |
| 2026-01-10 00:00 | ETH > $5k | 市场结算 YES | 50 | 1.00 | 50 | +10.00 | 952.00 |
> [!NOTE]
> **交易类型说明**:
> - **买入**: 跟随Leader买入
> - **卖出**: 跟随Leader卖出
> - **市场结算**: 市场到期自动结算(赎回)
>
> **手续费**: 回测不计算手续费,简化计算逻辑
**统计数据**:
- 总交易笔数
- 买入笔数 / 卖出笔数
- 胜率 (盈利交易 / 总交易)
- 平均收益
- 最大单笔盈利
- 最大单笔亏损
- 最大回撤
## 四、业务规则
### 4.1 回测执行规则
#### 4.1.1 资金检查
- **停止条件**: 当账户余额 < $1 **且无任何持仓**时,回测自动停止
- **继续条件**: 如果余额 < $1 但仍有持仓,继续处理后续交易
- 原因: Leader 可能卖出,或市场到期结算,释放资金
- 处理: 跳过无法执行的买入订单,继续处理卖出和结算
- **订单检查**: 每次下单前检查余额是否充足
- **不足处理**: 余额不足时跳过该买入订单,记录日志,继续监听后续交易
> [!IMPORTANT]
> 只要有持仓存在,就不应停止回测,因为后续可能通过卖出或市场结算回收资金
#### 4.1.2 历史数据获取
- 从 Polymarket API 获取 Leader 的历史交易记录
- 根据回测天数计算起始时间: `startTime = now - (backtestDays × 24 × 3600 × 1000)`
- 按时间顺序回放交易
#### 4.1.3 交易执行模拟
- 按照配置的跟单规则计算跟单金额
- 应用所有过滤条件 (价格、深度、关键字等)
- 模拟订单成交(不计算手续费)
- 更新账户余额
> [!NOTE]
> 回测不计算手续费,简化计算逻辑,避免过于复杂的精度问题
#### 4.1.3.1 余额不足的处理 ⚠️
**场景说明**:
- 当前余额不足以执行买入订单
- 但有持仓未卖出(相当于"待赎回资产")
**处理策略**:
**方案A: 严格模式**(推荐)
- ✅ 仅使用当前可用余额(`currentBalance`
- ✅ 余额不足时跳过该订单,不考虑持仓价值
- ✅ 理由: 更保守,模拟真实场景(持仓未卖出前资金不可用)
**方案B: 宽松模式**(可选)
- 计算"潜在可用资金" = `currentBalance + 持仓市值`
- 允许"透支"买入,后续通过卖出或结算平衡
- 风险: 可能产生不切实际的回测结果
**推荐实现**:
```kotlin
// 严格检查余额
if (totalCost > currentBalance) {
logger.info("余额不足以执行买入订单: 需要 $totalCost, 可用 $currentBalance")
logger.debug("当前持仓价值: ${calculatePositionValue(positions)}, 但不计入可用余额")
continue // 跳过该订单
}
```
**特殊情况: 市场即将结算**
- 如果持仓市场在接下来很短时间内会结算,可以提前释放资金
- 实现: 在每次交易前先执行市场结算检查(已在4.1.5实现)
> [!IMPORTANT]
> 采用**严格模式**更符合真实跟单场景,避免回测结果过于乐观
#### 4.1.4 卖出跟随
- 如果 `supportSell = true`,跟随 Leader 的卖出操作
- 按照买入时的比例卖出持仓
- 计算盈亏并更新余额
#### 4.1.5 市场结算处理 ⭐
- **触发时机**:
- **实时检查**: 在处理每笔Leader交易前,检查所有持仓市场的`endDate`
- **到期即结算**: 如果市场结束时间 ≤ 当前交易时间,立即结算该持仓
- **兜底处理**: 回测结束时,结算所有剩余持仓
- **结算规则**:
- 获取市场最终结果 (通过Polymarket API)
- 持仓方向为胜出方: 按 **1.0** 价格结算
- 持仓方向为失败方: 按 **0.0** 价格结算
- 市场未结算或无法获取结果: 按**成本价**结算 (保守估计)
- **资金流转**: 结算后的资金立即计入余额,可用于后续交易
- **交易记录**: 生成"市场结算"类型的交易记录,用于详情展示
> [!IMPORTANT]
> **实时结算的优势**:
> - ✅ 模拟真实场景: 市场结束时资金会自动返还
> - ✅ 提高资金利用率: 结算后的资金可以参与后续交易
> - ✅ 更准确的收益计算: 反映实际的资金周转情况
### 4.2 数据持久化
#### 4.2.1 回测任务表
- 保存回测基本信息和配置
- 记录执行状态和结果
#### 4.2.2 回测交易记录表
- 保存每笔模拟交易的详细信息
- 用于详情页面展示和分析
### 4.3 并发控制
- 同一时间最多支持 5 个回测任务并发执行
- 新任务排队等待,FIFO策略
- 前端显示任务队列位置
## 五、UI/UX 要求
### 5.1 响应式设计
- 支持桌面和移动端浏览
- 表格在小屏幕上支持横向滚动
### 5.2 国际化
- 支持中文和英文
- 所有文案提供双语版本
### 5.3 交互体验
- 创建回测: 表单提交时显示Loading状态
- 回测执行中: 显示进度条或百分比
- 数据加载: Skeleton占位符
- 操作反馈: Toast提示 (成功/失败/警告)
### 5.4 数据可视化
- 资金变化图表使用 ECharts 或 Recharts
- 支持图表缩放和数据点Tooltip
- 图表颜色: 盈利绿色,亏损红色
## 六、非功能需求
### 6.1 性能要求
- 回测列表页面加载时间 < 2秒
- 单个回测任务执行时间 < 5分钟 (30天数据)
- 详情页图表渲染时间 < 1秒
### 6.2 数据准确性
- 回测结果误差 < 0.1%
- 余额计算使用 BigDecimal 避免精度丢失
- 价格和数量精确到小数点后8位
### 6.3 安全性
- 回测数据仅用户本人可见
- API接口需要身份认证
- 防止SQL注入和XSS攻击
## 七、后续迭代规划
### Phase 2 (可选)
- 支持批量创建回测任务
- 回测结果对比功能
- 导出回测报告 (PDF/Excel)
- AI策略推荐
### Phase 3 (可选)
- 实时回测 (边交易边回测)
- 社区策略分享
- 策略市场
---
## 附录: 页面路由规划
- 回测列表: `/copy-trading/backtest`
- 新增回测: `/copy-trading/backtest/create`
- 回测详情: `/copy-trading/backtest/:id`
@@ -0,0 +1,669 @@
# 回测功能设计审查清单
## 一、设计审查要点
### 1.1 产品需求完整性 ✅
**已覆盖的核心功能**:
- ✅ 回测任务的创建、查询、删除
- ✅ 按Leader筛选和排序功能
- ✅ 回测配置参数复用现有跟单配置
- ✅ 回测详情展示 (交易记录、资金曲线图、统计数据)
- ✅ 资金不足时自动停止机制
- ✅ 回测天数限制 (1-30天)
**潜在遗漏点**:
> [!WARNING]
> **需要确认的问题**:
> 1. **回测结果的可见性**: 是否需要支持多用户? 当前设计未涉及权限控制
> 2. **回测任务的生命周期管理**: 是否需要自动清理过期的回测记录?
> 3. **回测进度的实时展示**: 前端如何获取运行中任务的进度? (考虑WebSocket或轮询)
### 1.2 技术设计合理性 ✅
**优点**:
- ✅ 数据库设计规范,索引合理
- ✅ API设计符合RESTful规范
- ✅ 复用现有的 `CopyTradingFilterService`,减少代码冗余
- ✅ 使用 BigDecimal 保证计算精度
- ✅ 异步执行回测任务,不阻塞主线程
**可能的改进点**:
> [!NOTE]
> **建议优化的地方**:
> 1. **历史数据获取**: 当前设计依赖Polymarket API,需要考虑API限流和数据缺失的情况
> 2. **缓存策略**: 建议对Leader历史交易数据使用分层缓存 (内存 + Redis)
> 3. **回测结果的序列化**: 考虑将详细交易记录存储为JSON,减少表的大小
### 1.3 业务逻辑准确性 ✅
**已完善的关键逻辑**:
#### 1.3.1 历史数据获取 ⭐ (已修正)
> [!NOTE]
> **问题**: 现有 `ProcessedTrade` 表字段有限,无法满足回测需求。
>
> **解决方案**: 创建独立的 `backtest_historical_trades` 表
> - ✅ 存储完整的交易信息(marketId, price, quantity, outcomeIndex 等)
> - ✅ 支持实时数据同步(跟单时同时写入)
> - ✅ 支持通过 API 补充历史数据
> - ✅ 不影响现有跟单功能
#### 1.3.2 卖出匹配逻辑 ⭐ (已修正)
> [!NOTE]
> **改进**: 使用 `outcomeIndex` 支持多元市场
>
> **实现方案**:
> - 持仓键: `marketId + outcomeIndex`(支持多元市场)
> - 比例模式: 按 Leader 卖出比例计算
> - 固定金额模式: 全部卖出
> - 参考 `CopyOrderTracking` 的逻辑
**伪代码**:
```kotlin
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
val position = positions[positionKey] ?: continue
val sellQuantity = if (task.copyMode == "RATIO") {
if (position.leaderBuyQuantity != null && position.leaderBuyQuantity > BigDecimal.ZERO) {
position.quantity * (leaderTrade.quantity / position.leaderBuyQuantity)
} else {
position.quantity // 全部卖出
}
} else {
position.quantity // 固定金额模式全部卖出
}
```
#### 1.3.3 价格滑点模拟 ✅ (已决策)
> [!NOTE]
> **用户决策**: 暂不模拟价格滑点
>
> **理由**:
> - 简化回测逻辑
> - 减少复杂度
> - 后续可以作为可选项添加
>
> **实现**: 使用 Leader 的成交价,不进行滑点调整
#### 1.3.2 价格滑点模拟
> [!NOTE]
> **关键问题**: 是否需要模拟价格滑点?
>
> **当前设计**: 不模拟价格滑点,直接使用Leader的成交价
> - 优点: 简化逻辑,回测速度快
> - 缺点: 可能高估收益(实际跟单可能有滑点)
>
> **可选方案**: 增加可配置的滑点参数
> - 买入时: 价格 × (1 + 滑点%)
> - 卖出时: 价格 × (1 - 滑点%)
> - 增加可选的滑点模拟参数 (例如: ±0.5%)
> - 在PRD中补充此配置项
#### 1.3.3 手续费计算 ✅ (已移除)
> [!NOTE]
> **用户决策**: 回测不计算手续费
>
> **理由**:
> - 简化计算逻辑
> - 避免精度问题
> - 降低复杂度
>
> **实现**:
> - 所有交易的 `fee` 字段均为 `0`
> - 买入成本 = 数量 × 价格
> - 卖出收入 = 数量 × 价格
> - 结算收入 = 数量 × 结算价
#### 1.3.4 市场结算处理 ⭐ (已优化)
> [!NOTE]
> **问题**: 市场结束时,未平仓位如何自动结算?
>
> **优化方案** (采纳用户建议):
> - ✅ **实时检查**: 在回测循环中,每处理一笔Leader交易前,检查所有持仓的市场`endDate`
> - ✅ **到期即结算**: 如果 `marketEndDate <= currentTradeTime`,立即结算该持仓
> - ✅ **资金可用**: 结算后的资金立即计入余额,可以用于后续交易
> - ✅ **兜底处理**: 回测结束时,结算所有剩余未到期持仓
>
> **结算价格判断** (通过市场价格):
> - 价格 >= 0.95: 胜出 (按 1.0 结算)
> - 价格 <= 0.05: 失败 (按 0.0 结算)
> - 其他情况: 按成本价保守估计
>
> **实现要点**:
> ```kotlin
> // 在交易循环中实时检查市场到期
> for (leaderTrade in leaderTrades.sortedBy { it.timestamp }) {
>
> // 1. 检查并结算已到期的市场
> val expiredPositions = positions.filter { (_, position) ->
> val marketInfo = getMarketInfo(position.marketId)
> marketInfo.endDate <= leaderTrade.timestamp
> }
>
> for ((positionKey, position) in expiredPositions) {
> val marketPrice = marketPriceService.getCurrentMarketPrice(
> marketId = position.marketId,
> outcomeIndex = position.outcomeIndex ?: 0
> )
>
> val settlementPrice = when {
> marketPrice >= BigDecimal("0.95") -> BigDecimal.ONE // 胜出
> marketPrice <= BigDecimal("0.05") -> BigDecimal.ZERO // 失败
> else -> position.avgPrice // 未结算,按成本价
> }
>
> val settlementValue = position.quantity * settlementPrice
> currentBalance += settlementValue
> positions.remove(positionKey)
> }
>
> // 2. 处理当前Leader交易
> // ...
> }
> ```
>
> **优势**:
> - 更符合真实场景 (市场结束时自动返还资金)
> - 提高资金利用率 (结算资金可参与后续交易)
> - 更准确的收益计算
> - 通过市场价格判断,无需依赖可能不存在的 `winner` 字段
#### 1.3.5 余额不足与持仓处理 ⚠️ (边缘场景) - 已修正
> [!WARNING]
> **关键问题**: 当余额不足但有未卖出持仓时,如何处理?
>
> **场景示例**:
> - 初始余额: $1000
> - 已买入持仓市值: $800(未卖出)
> - 当前余额: $200
> - 新买入订单需要: $300
> - **问题**: 是否允许买入?虽然持仓市值足够,但资金被占用
>
> **推荐方案: 严格模式**
> - ❌ 不允许买入(余额不足)
> - ✅ 仅使用 `currentBalance` 判断
> - ✅ 不计入持仓市值(因为持仓未实现)
> - ✅ 理由: 更真实,避免过于乐观的回测结果
>
> **替代方案: 宽松模式**
> - ✅ 计算"虚拟可用资金" = `currentBalance + 持仓估值`
> - ⚠️ 允许"透支"买入
> - ❌ 风险: 可能产生不切实际的收益
>
> **实际影响**:
> - 严格模式下,资金周转率是限制因素
> - 鼓励快进快出的策略
> - 长期持仓策略会因资金占用而错过后续机会
>
> **已在文档中采用**: 严格模式
#### 1.3.6 每日订单数限制 ✅ (已补充)
> [!NOTE]
> **问题**: 文档提到了 `maxDailyOrders` 参数,但未在算法中实现
>
> **解决方案**: 在回测循环中添加每日订单数统计
>
> **实现**:
> ```kotlin
> // 统计当前交易时间当天已有的订单数
> val dailyOrderCount = trades.count { isSameDay(it.tradeTime, leaderTrade.timestamp) }
>
> if (dailyOrderCount >= task.maxDailyOrders) {
> logger.info("已达到每日最大订单数限制: $dailyOrderCount / ${task.maxDailyOrders}")
> continue
> }
> ```
>
> **优势**:
> - 符合实际跟单的风险控制逻辑
> - 避免回测结果过于激进
#### 1.3.7 价格容忍度检查 ✅ (已补充)
> [!NOTE]
> **问题**: 文档提到了 `priceTolerance` 参数,但未在算法中实现
>
> **解决方案**: 在执行交易前检查当前市场价格是否在容忍范围内
>
> **实现**:
> ```kotlin
> if (task.priceTolerance > BigDecimal.ZERO) {
> val tolerance = task.priceTolerance.divide(BigDecimal("100"))
> val minPrice = leaderTrade.price.multiply(BigDecimal.ONE.subtract(tolerance))
> val maxPrice = leaderTrade.price.multiply(BigDecimal.ONE.add(tolerance))
>
> val currentPrice = marketPriceService.getCurrentMarketPrice(
> marketId = leaderTrade.marketId,
> outcomeIndex = leaderTrade.outcomeIndex ?: 0
> )
>
> if (currentPrice < minPrice || currentPrice > maxPrice) {
> logger.info("价格超出容忍度范围: 当前=$currentPrice, 可用范围=[$minPrice, $maxPrice]")
> continue
> }
> }
> ```
#### 1.3.8 回测停止条件 ✅ (已修正)
> [!NOTE]
> **修正**: 基于用户反馈,修正了停止逻辑
>
> **错误设计**:
> ```kotlin
> if (currentBalance < $1) {
> break // ❌ 直接停止,忽略持仓
> }
> ```
>
> **正确设计**:
> ```kotlin
> // 只有"余额不足 且 无持仓"时才停止
> if (currentBalance < $1 && positions.isEmpty()) {
> break // ✅ 确保无持仓时才停止
> }
>
> // 有持仓时继续处理(等待卖出或结算)
> if (currentBalance < $1 && positions.isNotEmpty()) {
> // 继续处理,跳过买入,但执行卖出和结算
> }
> ```
>
> **理由**:
> - 持仓存在意味着可能有后续卖出或市场结算
> - 这些操作会释放资金
> - 过早停止会导致资金无法回收,回测不准确
### 1.4 性能和可扩展性 ✅
**已考虑的优化**:
- ✅ 异步执行,线程池限制并发
- ✅ 分页查询
- ✅ 数据库索引优化
- ✅ 前端虚拟滚动
**需要进一步考虑**:
> [!TIP]
> **性能优化建议**:
> 1. **批量插入交易记录**: 使用 `saveAll()` 而非逐条 `save()`
> 2. **进度更新频率**: 避免每笔交易都更新数据库,改为每100笔或每10秒更新一次
> 3. **历史数据预加载**: 在任务开始前一次性加载所有历史交易,避免多次API调用
## 二、数据库设计补充
### 2.1 新增回测历史交易表
**问题**: 现有 `ProcessedTrade` 表字段有限,无法满足回测需求。
**解决方案**: 创建独立的 `backtest_historical_trades` 表,存储完整的 Leader 历史交易数据。
```sql
CREATE TABLE backtest_historical_trades (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID',
leader_id BIGINT NOT NULL COMMENT 'Leader ID',
trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 交易ID(唯一标识)',
market_id VARCHAR(100) NOT NULL COMMENT '市场ID',
market_title VARCHAR(500) DEFAULT NULL COMMENT '市场标题',
market_slug VARCHAR(200) DEFAULT NULL COMMENT '市场 slug(用于生成链接)',
side VARCHAR(10) NOT NULL COMMENT '交易方向: BUY/SELL',
outcome VARCHAR(50) DEFAULT NULL COMMENT '市场方向(如 YES, NO 等)',
outcome_index INT DEFAULT NULL COMMENT '结果索引(0, 1, 2, ...),支持多元市场',
price DECIMAL(20, 8) NOT NULL COMMENT '交易价格',
size DECIMAL(20, 8) NOT NULL COMMENT '交易数量',
amount DECIMAL(20, 8) NOT NULL COMMENT '交易金额(price × size',
trade_timestamp BIGINT NOT NULL COMMENT '交易时间戳(毫秒)',
-- 元数据
source VARCHAR(20) NOT NULL DEFAULT 'POLLING' COMMENT '数据来源: WEBSOCKET/POLLING/API',
fetched_at BIGINT NOT NULL COMMENT '数据获取时间(毫秒)',
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒)',
UNIQUE INDEX uk_leader_trade (leader_id, trade_id),
INDEX idx_leader_id (leader_id),
INDEX idx_trade_timestamp (trade_timestamp),
INDEX idx_market_id (market_id)
) COMMENT='回测历史交易表';
```
**优势**:
- ✅ 不影响现有跟单系统的 `ProcessedTrade`
- ✅ 存储完整的交易信息,满足回测需求
- ✅ 支持实时数据同步(跟单时同时写入)
- ✅ 支持通过 API 补充历史数据
- ✅ 唯一索引自动去重
### 2.2 移除 max_position_count 字段
**问题**: 文档中包含 `max_position_count` 字段,但 V26 迁移已删除该字段。
**解决方案**: 从 `backtest_task` 表和相关 API 中移除该字段。
### 2.3 其他字段建议
#### `backtest_task` 表
建议新增以下字段:
```sql
-- 用于计算平均持仓时间
avg_holding_time BIGINT DEFAULT NULL COMMENT '平均持仓时间(毫秒)',
-- 用于记录回测使用的数据源
data_source VARCHAR(50) DEFAULT 'MIXED' COMMENT '数据源: INTERNAL/API/MIXED',
-- 用于记录回测执行的详细日志
execution_log TEXT DEFAULT NULL COMMENT '执行日志(JSON格式)'
```
### 2.4 索引优化
建议添加复合索引:
```sql
-- 用于按Leader和收益率查询
CREATE INDEX idx_leader_profit ON backtest_task(leader_id, profit_rate DESC);
-- 用于按状态和创建时间查询
CREATE INDEX idx_status_created ON backtest_task(status, created_at DESC);
```
## 三、API设计补充
### 3.1 API 规范修正
**问题**: 文档中使用 GET/DELETE 方法,违反项目统一使用 POST 的规范。
**修正方案**:
```bash
# ❌ 错误(使用 GET/DELETE
GET /api/backtest/tasks
GET /api/backtest/tasks/{id}
DELETE /api/backtest/tasks/{id}
# ✅ 正确(统一使用 POST
POST /api/backtest/tasks/list
POST /api/backtest/tasks/detail
POST /api/backtest/tasks/delete
```
**完整的 API 列表**:
| 功能 | 方法 | 路径 | 说明 |
|-----|------|------|------|
| 创建回测 | POST | /api/backtest/tasks | 创建新的回测任务 |
| 查询列表 | POST | /api/backtest/tasks/list | 分页查询回测任务列表 |
| 查询详情 | POST | /api/backtest/tasks/detail | 查询单个回测任务详情 |
| 查询交易 | POST | /api/backtest/tasks/trades | 查询回测的交易记录 |
| 删除任务 | POST | /api/backtest/tasks/delete | 删除回测任务 |
| 停止任务 | POST | /api/backtest/tasks/stop | 停止运行中的回测 |
| 查询进度 | POST | /api/backtest/tasks/progress | 查询回测执行进度 |
### 3.2 缺失的API
建议新增以下API:
#### 3.2.1 查询回测进度 (实时更新)
```
POST /api/backtest/tasks/progress
```
**Request Body**:
```json
{
"id": 12345
}
```
**Response**:
```json
{
"success": true,
"data": {
"progress": 65,
"currentBalance": "1150.00",
"totalTrades": 30,
"status": "RUNNING"
}
}
```
#### 3.2.2 批量删除回测任务
```
POST /api/backtest/tasks/batch-delete
```
**Request Body**:
```json
{
"taskIds": [12345, 12346, 12347]
}
```
#### 3.2.3 导出回测报告
```
POST /api/backtest/tasks/export
```
**Request Body**:
```json
{
"id": 12345,
"format": "csv" // 或 "pdf"
}
```
### 3.2 API错误码规范
建议统一错误码:
| 错误码 | 说明 |
|-------|------|
| 40001 | 回测任务不存在 |
| 40002 | Leader不存在 |
| 40003 | 回测天数超出限制 |
| 40004 | 初始金额无效 |
| 40005 | 回测任务正在运行,无法删除 |
| 50001 | 历史数据获取失败 |
| 50002 | 回测执行失败 |
## 四、前端实现补充
### 4.1 状态轮询
对于运行中的回测任务,前端需要定时轮询进度:
```typescript
useEffect(() => {
if (task.status === 'RUNNING') {
const interval = setInterval(async () => {
const progress = await backtestService.getProgress(task.id);
setTask({ ...task, ...progress });
}, 3000); // 每3秒轮询一次
return () => clearInterval(interval);
}
}, [task.status]);
```
### 4.2 图表数据压缩
当交易记录过多时,图表数据需要压缩:
```typescript
// 将数据按时间聚合为最多200个点
const compressChartData = (trades: BacktestTrade[], maxPoints: number = 200) => {
if (trades.length <= maxPoints) return trades;
const interval = Math.floor(trades.length / maxPoints);
return trades.filter((_, index) => index % interval === 0);
};
```
### 4.3 国际化文案
需要在 `locales/` 目录下补充以下文案:
**zh-CN.json**:
```json
{
"backtest": {
"title": "回测管理",
"createTask": "新增回测",
"taskName": "回测名称",
"leader": "Leader",
"initialBalance": "初始金额",
"backtestDays": "回测天数",
"profitAmount": "收益额",
"profitRate": "收益率",
"status": {
"pending": "待执行",
"running": "运行中",
"completed": "已完成",
"stopped": "已停止",
"failed": "失败"
}
}
}
```
## 五、测试计划补充
### 5.1 单元测试
**需要测试的核心方法**:
- `BacktestExecutionService.executeBacktest()` - 回测算法准确性
- `BacktestExecutionService.calculateStatistics()` - 统计数据计算
- `BacktestDataService.getLeaderHistoricalTrades()` - 历史数据获取
**测试用例示例**:
```kotlin
@Test
fun `test backtest with simple buy-sell scenario`() {
// Given: 初始余额1000, Leader买入100@0.5, 卖出100@0.6
val task = createTestTask(initialBalance = 1000.toBigDecimal())
val trades = listOf(
createBuyTrade(quantity = 100.toBigDecimal(), price = 0.5.toBigDecimal()),
createSellTrade(quantity = 100.toBigDecimal(), price = 0.6.toBigDecimal())
)
// When: 执行回测
val result = executionService.executeBacktest(task)
// Then: 验证收益
// 买入: 100 * 0.5 = 50, 手续费0.1, 总成本50.1
// 卖出: 100 * 0.6 = 60, 手续费0.12, 净收入59.88
// 盈利: 59.88 - 50.1 = 9.78
// 最终余额: 1000 - 50.1 + 59.88 = 1009.78
assertEquals(1009.78.toBigDecimal(), result.finalBalance)
assertEquals(9.78.toBigDecimal(), result.profitAmount)
}
```
### 5.2 集成测试
**测试场景**:
1. 端到端测试: 创建任务 → 执行回测 → 查询结果
2. 异常场景: 历史数据为空、API调用失败
3. 边界条件: 余额刚好为0、单笔交易耗尽余额
### 5.3 性能测试
**测试指标**:
- 30天历史数据 (假设1000笔交易) 的回测执行时间 < 5分钟
- 并发5个回测任务时的系统资源占用
- 查询包含10000笔交易的回测详情页面加载时间 < 2秒
## 六、风险评估和缓解方案
### 6.1 数据准确性风险
**风险**: 历史数据不完整或API返回数据有误
**缓解方案**:
1. 数据验证: 检查返回数据的完整性 (是否有时间断层)
2. 数据对比: 使用多个数据源交叉验证
3. 错误标记: 回测结果标注数据质量等级
### 6.2 计算精度风险
**风险**: BigDecimal计算中的舍入误差累积
**缓解方案**:
1. 统一舍入模式: 使用 `RoundingMode.HALF_UP`
2. 精度测试: 编写专门的精度测试用例
3. 误差补偿: 最终余额与理论值的误差 < 0.01 USDC
### 6.3 性能风险
**风险**: 大量回测任务导致系统负载过高
**缓解方案**:
1. 任务队列: 使用异步任务队列 (可选: Redis Queue 或 RabbitMQ)
2. 资源限流: 限制单用户最多创建10个待执行任务
3. 自动清理: 定期清理30天前的回测记录
## 七、需要与用户确认的问题
> [!IMPORTANT]
> **关键决策点 - 需要用户反馈**:
### 7.1 卖出匹配策略
**问题**: 当用户多次买入同一市场时,卖出应该匹配哪笔买入?
**选项**:
- **选项A**: FIFO (先进先出) - 先卖出最早的买入
- **选项B**: 加权平均 - 按平均成本价计算盈亏
- **选项C**: 完全跟随Leader - Leader卖多少比例,我们也卖多少比例
**建议**: 选项C (完全跟随),与实际跟单逻辑保持一致
### 7.2 价格滑点模拟
**问题**: 是否需要在回测中模拟价格滑点?
**选项**:
- **选项A**: 不模拟,使用Leader成交价 (乐观估计)
- **选项B**: 固定滑点 (如买入+0.5%, 卖出-0.5%)
- **选项C**: 可配置滑点,用户自定义
**建议**: 选项C,增加灵活性
### 7.3 数据源选择
**问题**: 历史数据来源?
**选项**:
- **选项A**: 仅使用 Polymarket API
- **选项B**: 优先使用系统记录的 `ProcessedTrade` 表,不足时调用API
- **选项C**: 仅使用 `ProcessedTrade` 表 (限制回测范围为系统运行期间)
**建议**: 选项B,兼顾数据完整性和性能
### 7.4 回测结果保留时长
**问题**: 回测记录保留多久?
**选项**:
- **选项A**: 永久保留
- **选项B**: 保留30天,自动清理
- **选项C**: 用户手动删除,无自动清理
**建议**: 选项B,避免数据库膨胀
## 八、文档总结
### 已完成的文档
1.**BACKTEST_PRD.md** - 产品需求文档
2.**BACKTEST_TECHNICAL_DESIGN.md** - 技术设计文档
3.**BACKTEST_REVIEW_CHECKLIST.md** - 设计审查清单 (本文档)
### 建议补充的文档 (可选)
1. **BACKTEST_API_SPEC.md** - API接口规范 (从技术设计文档提取)
2. **BACKTEST_DATABASE_MIGRATION.md** - 数据库迁移脚本
3. **BACKTEST_TEST_PLAN.md** - 详细测试计划
### 下一步行动
1. **用户Review**: 请用户审查以上文档,确认关键设计点
2. **补充遗漏**: 根据用户反馈补充缺失部分
3. **进入执行**: 用户确认后开始实施开发
---
**审查日期**: 2026-01-30
**审查人**: AI Assistant
**状态**: 待用户确认
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
# 跟单回测功能文档
## 📚 文档清单
本目录包含跟单回测功能的完整设计文档:
### 核心文档
1. **[BACKTEST_PRD.md](./BACKTEST_PRD.md)** - 产品需求文档
- 功能概述与用户故事
- UI/UX设计详细说明
- 业务规则与数据要求
2. **[BACKTEST_TECHNICAL_DESIGN.md](./BACKTEST_TECHNICAL_DESIGN.md)** - 技术设计文档
- 数据库表结构设计
- RESTful API接口规范
- 后端服务架构
- 前端组件设计
- 回测算法实现
3. **[BACKTEST_REVIEW_CHECKLIST.md](./BACKTEST_REVIEW_CHECKLIST.md)** - 设计审查清单
- 设计完整性检查
- 边缘场景处理
- 风险评估与缓解
## 🎯 核心特性
- ✅ 完全复用现有跟单配置参数
- ✅ 实时市场结算(按 `endDate` 检查)
- ✅ 严格余额检查(避免过于乐观的回测)
- ✅ 支持按Leader筛选、按收益排序
- ✅ 详细的交易记录和资金曲线图
## 📖 阅读建议
**产品经理**: 先阅读 PRD,再查看审查清单中的关键决策点
**技术负责人**: 先阅读技术设计文档,再查看审查清单评估风险
**开发工程师**: 按顺序阅读所有文档,重点关注技术设计的实现细节
## 🔄 文档版本
- **创建日期**: 2026-01-30
- **最后更新**: 2026-01-30
- **当前版本**: v1.0
## 📝 关键设计决策
### 1. 市场结算机制
- 采用**实时检查**方式:每笔交易前检查市场 `endDate`
- 到期即结算,资金立即释放可用于后续交易
### 2. 余额检查策略
- 采用**严格模式**:仅使用 `currentBalance`,不计入持仓市值
- 停止条件:余额 < $1 **且** 无任何持仓
### 3. 数据源选择
- 优先使用系统记录的 `ProcessedTrade`
- 不足时调用 Polymarket API 补充历史数据
### 4. 代码复用策略
- 后端:完全复用 `CopyTradingFilterService` 的所有过滤逻辑
- 前端:复用跟单配置表单组件
- 数据库:配置字段与 `CopyTrading` 表保持一致
## 🚀 下一步
完成文档审查后,可以:
1. 创建 `implementation_plan.md` 详细规划实施步骤
2. 开始开发(数据库表 → API → 前端页面)
3. 单元测试和集成测试
---
**文档位置**: `/Users/wrbug/polyhermes/docs/zh/backtest/`
+33 -13
View File
@@ -11,6 +11,7 @@
"antd": "^5.12.0",
"antd-mobile": "^5.34.0",
"axios": "^1.6.2",
"echarts": "^6.0.0",
"ethers": "^6.16.0",
"i18next": "^25.7.1",
"react": "^18.2.0",
@@ -158,7 +159,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1677,7 +1677,6 @@
"version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -1743,7 +1742,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@@ -1940,7 +1938,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2259,7 +2256,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@@ -2471,8 +2467,7 @@
"node_modules/dayjs": {
"version": "1.11.19",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
"peer": true
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="
},
"node_modules/debug": {
"version": "4.4.3",
@@ -2581,6 +2576,22 @@
"node": ">= 0.4"
}
},
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/electron-to-chromium": {
"version": "1.5.258",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.258.tgz",
@@ -2693,7 +2704,6 @@
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -3372,7 +3382,6 @@
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@@ -5441,7 +5450,6 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -5453,7 +5461,6 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -6021,7 +6028,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6194,7 +6200,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -6325,6 +6330,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+1
View File
@@ -12,6 +12,7 @@
"antd": "^5.12.0",
"antd-mobile": "^5.34.0",
"axios": "^1.6.2",
"echarts": "^6.0.0",
"ethers": "^6.16.0",
"i18next": "^25.7.1",
"react": "^18.2.0",
+4
View File
@@ -32,6 +32,8 @@ import SystemSettings from './pages/SystemSettings'
import ApiHealthStatus from './pages/ApiHealthStatus'
import RpcNodeSettings from './pages/RpcNodeSettings'
import Announcements from './pages/Announcements'
import BacktestList from './pages/BacktestList'
import BacktestDetail from './pages/BacktestDetail'
import { wsManager } from './services/websocket'
import type { OrderPushMessage } from './types'
import { apiService } from './services/api'
@@ -254,6 +256,8 @@ function App() {
<Route path="/copy-trading/orders/sell/:copyTradingId" element={<ProtectedRoute><CopyTradingSellOrders /></ProtectedRoute>} />
<Route path="/copy-trading/orders/matched/:copyTradingId" element={<ProtectedRoute><CopyTradingMatchedOrders /></ProtectedRoute>} />
<Route path="/copy-trading/filtered-orders/:id" element={<ProtectedRoute><FilteredOrdersList /></ProtectedRoute>} />
<Route path="/backtest" element={<ProtectedRoute><BacktestList /></ProtectedRoute>} />
<Route path="/backtest/detail/:id" element={<ProtectedRoute><BacktestDetail /></ProtectedRoute>} />
<Route path="/config" element={<ProtectedRoute><ConfigPage /></ProtectedRoute>} />
<Route path="/positions" element={<ProtectedRoute><PositionList /></ProtectedRoute>} />
<Route path="/statistics" element={<ProtectedRoute><Statistics /></ProtectedRoute>} />
+9 -3
View File
@@ -20,7 +20,8 @@ import {
CheckCircleOutlined,
SendOutlined,
ApiOutlined,
NotificationOutlined
NotificationOutlined,
LineChartOutlined
} from '@ant-design/icons'
import type { MenuProps } from 'antd'
import type { ReactNode } from 'react'
@@ -71,7 +72,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
const getInitialOpenKeys = (): string[] => {
const path = location.pathname
const keys: string[] = []
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading')) {
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
keys.push('/copy-trading-management')
}
if (path.startsWith('/system-settings')) {
@@ -86,7 +87,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
useEffect(() => {
const path = location.pathname
const keys: string[] = []
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading')) {
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
keys.push('/copy-trading-management')
}
if (path.startsWith('/system-settings')) {
@@ -148,6 +149,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
key: '/templates',
icon: <FileTextOutlined />,
label: t('menu.templates')
},
{
key: '/backtest',
icon: <LineChartOutlined />,
label: t('menu.backtest') || '回测'
}
]
},
+131 -1
View File
@@ -34,7 +34,10 @@
"previous": "Previous",
"next": "Next",
"page": "Page",
"pageOf": "Page"
"pageOf": "Page",
"ascending": "Ascending",
"descending": "Descending",
"day": "day"
},
"account": {
"title": "Account Management",
@@ -240,6 +243,7 @@
"templates": "Templates",
"copyTradingConfig": "Copy Trading Config",
"positions": "Position Management",
"backtest": "Backtest",
"statistics": "Statistics",
"announcements": "Announcements",
"users": "User Management",
@@ -1226,5 +1230,131 @@
"providerQuickNode": "QuickNode",
"providerChainstack": "Chainstack",
"providerGetBlock": "GetBlock"
},
"backtest": {
"title": "Backtest",
"taskName": "Task Name",
"leader": "Leader",
"initialBalance": "Initial Balance",
"backtestDays": "Backtest Days",
"status": "Status",
"progress": "Progress",
"startTime": "Start Time",
"endTime": "End Time",
"finalBalance": "Final Balance",
"profitAmount": "Profit Amount",
"profitRate": "Profit Rate",
"totalTrades": "Total Trades",
"buyTrades": "Buy Trades",
"sellTrades": "Sell Trades",
"winTrades": "Win Trades",
"lossTrades": "Loss Trades",
"winRate": "Win Rate",
"maxProfit": "Max Profit",
"maxLoss": "Max Loss",
"maxDrawdown": "Max Drawdown",
"avgHoldingTime": "Avg Holding Time",
"statusPending": "Pending",
"statusRunning": "Running",
"statusCompleted": "Completed",
"statusStopped": "Stopped",
"statusFailed": "Failed",
"createTask": "Create Backtest Task",
"taskList": "Backtest Task List",
"taskDetail": "Backtest Task Detail",
"tradeRecords": "Trade Records",
"config": "Configuration",
"statistics": "Statistics",
"chart": "Balance Chart",
"createSuccess": "Created successfully",
"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?",
"createCopyTrading": "Create Copy Trading",
"createCopyTradingSuccess": "Copy trading config created successfully",
"noTasks": "No backtest tasks",
"noTrades": "No trade records",
"fetchTasksFailed": "Failed to fetch task list",
"fetchTaskDetailFailed": "Failed to fetch task detail",
"fetchTradesFailed": "Failed to fetch trade records",
"balanceChart": "Balance Change",
"pnlChart": "PnL Change",
"executionTime": "Execution Time",
"runningDuration": "Running Duration",
"estimatedRemaining": "Estimated Remaining",
"leaderAddress": "Leader Address",
"leaderName": "Leader Name",
"copyMode": "Copy Mode",
"copyModeRatio": "Ratio",
"copyModeFixed": "Fixed Amount",
"copyRatio": "Copy Ratio",
"fixedAmount": "Fixed Amount",
"maxOrderSize": "Max Order Size",
"minOrderSize": "Min Order Size",
"maxDailyLoss": "Max Daily Loss",
"maxDailyOrders": "Max Daily Orders",
"priceTolerance": "Price Tolerance",
"delaySeconds": "Delay Seconds",
"supportSell": "Copy Sell",
"minOrderDepth": "Min Order Depth",
"maxSpread": "Max Spread",
"minPrice": "Min Price",
"maxPrice": "Max Price",
"maxPositionValue": "Max Position Value",
"keywordFilterMode": "Keyword Filter Mode",
"keywordFilterModeDisabled": "Disabled",
"keywordFilterModeWhitelist": "Whitelist",
"keywordFilterModeBlacklist": "Blacklist",
"keywords": "Keywords",
"maxMarketEndDate": "Market End Date Limit",
"tradeTime": "Trade Time",
"marketId": "Market ID",
"marketTitle": "Market Title",
"side": "Side",
"sideBuy": "Buy",
"sideSell": "Sell",
"sideSettlement": "Settlement",
"outcome": "Outcome",
"quantity": "Quantity",
"price": "Price",
"amount": "Amount",
"fee": "Fee",
"profitLoss": "Profit/Loss",
"balanceAfter": "Balance After",
"leaderTradeId": "Leader Trade ID",
"loading": "Loading...",
"refreshing": "Refreshing...",
"starting": "Starting...",
"stopping": "Stopping...",
"errorOccurred": "Error occurred",
"retry": "Retry",
"taskNameRequired": "Please enter task name",
"leaderRequired": "Please select Leader",
"initialBalanceRequired": "Please enter initial balance",
"initialBalanceInvalid": "Initial balance must be greater than 0",
"backtestDaysRequired": "Please enter backtest days",
"backtestDaysInvalid": "Backtest days must be between 1-15",
"copyRatioRequired": "Please enter copy ratio",
"copyRatioInvalid": "Copy ratio must be between 0.01-10000",
"copyRatioPlaceholder": "For example: 100 means 100% (1:1 copy), default 100%",
"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",
"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 sell orders",
"sortBy": "Sort By",
"sortOrder": "Sort Order",
"createdAt": "Created At"
}
}
+131 -1
View File
@@ -34,7 +34,10 @@
"pageOf": "第",
"success": "成功",
"failed": "失败",
"close": "关闭"
"close": "关闭",
"ascending": "升序",
"descending": "降序",
"day": "天"
},
"login": {
"title": "登录",
@@ -240,6 +243,7 @@
"templates": "跟单模板",
"copyTradingConfig": "跟单配置",
"positions": "仓位管理",
"backtest": "回测",
"statistics": "统计信息",
"announcements": "公告",
"users": "用户管理",
@@ -1226,5 +1230,131 @@
"providerQuickNode": "QuickNode",
"providerChainstack": "Chainstack",
"providerGetBlock": "GetBlock"
},
"backtest": {
"title": "回测",
"taskName": "任务名称",
"leader": "Leader",
"initialBalance": "初始资金",
"backtestDays": "回测天数",
"status": "状态",
"progress": "进度",
"startTime": "开始时间",
"endTime": "结束时间",
"finalBalance": "最终资金",
"profitAmount": "收益金额",
"profitRate": "收益率",
"totalTrades": "总交易数",
"buyTrades": "买入笔数",
"sellTrades": "卖出笔数",
"winTrades": "盈利笔数",
"lossTrades": "亏损笔数",
"winRate": "胜率",
"maxProfit": "最大单笔盈利",
"maxLoss": "最大单笔亏损",
"maxDrawdown": "最大回撤",
"avgHoldingTime": "平均持仓时间",
"statusPending": "等待中",
"statusRunning": "运行中",
"statusCompleted": "已完成",
"statusStopped": "已停止",
"statusFailed": "失败",
"createTask": "创建回测任务",
"taskList": "回测任务列表",
"taskDetail": "回测任务详情",
"tradeRecords": "交易记录",
"config": "配置",
"statistics": "统计",
"chart": "资金曲线",
"createSuccess": "创建成功",
"createFailed": "创建失败",
"deleteSuccess": "删除成功",
"deleteFailed": "删除失败",
"stop": "停止",
"stopSuccess": "停止成功",
"stopFailed": "停止失败",
"retry": "重试",
"retrySuccess": "重试成功",
"retryFailed": "重试失败",
"retryConfirm": "确定重新运行此回测任务吗?将从断点继续执行,保留已处理的交易记录。",
"deleteConfirm": "确定删除此回测任务吗?",
"stopConfirm": "确定停止此回测任务吗?",
"createCopyTrading": "创建跟单",
"createCopyTradingSuccess": "跟单配置创建成功",
"noTasks": "暂无回测任务",
"noTrades": "暂无交易记录",
"fetchTasksFailed": "获取任务列表失败",
"fetchTaskDetailFailed": "获取任务详情失败",
"fetchTradesFailed": "获取交易记录失败",
"balanceChart": "资金变化",
"pnlChart": "盈亏变化",
"executionTime": "执行时间",
"runningDuration": "运行时长",
"estimatedRemaining": "预计剩余",
"leaderAddress": "Leader 地址",
"leaderName": "Leader 名称",
"copyMode": "跟单模式",
"copyModeRatio": "比例",
"copyModeFixed": "固定金额",
"copyRatio": "跟单比例",
"fixedAmount": "固定金额",
"maxOrderSize": "最大单笔订单",
"minOrderSize": "最小单笔订单",
"maxDailyLoss": "最大每日亏损",
"maxDailyOrders": "最大每日订单数",
"priceTolerance": "价格容忍度",
"delaySeconds": "延迟秒数",
"supportSell": "跟单卖出",
"minOrderDepth": "最小订单深度",
"maxSpread": "最大价差",
"minPrice": "最低价格",
"maxPrice": "最高价格",
"maxPositionValue": "最大仓位金额",
"keywordFilterMode": "关键字过滤模式",
"keywordFilterModeDisabled": "禁用",
"keywordFilterModeWhitelist": "白名单",
"keywordFilterModeBlacklist": "黑名单",
"keywords": "关键字",
"maxMarketEndDate": "市场截止时间限制",
"tradeTime": "交易时间",
"marketId": "市场ID",
"marketTitle": "市场标题",
"side": "方向",
"sideBuy": "买入",
"sideSell": "卖出",
"sideSettlement": "结算",
"outcome": "结果",
"quantity": "数量",
"price": "价格",
"amount": "金额",
"fee": "手续费",
"profitLoss": "盈亏",
"balanceAfter": "交易后余额",
"leaderTradeId": "Leader 交易ID",
"loading": "加载中...",
"refreshing": "刷新中...",
"starting": "启动中...",
"stopping": "停止中...",
"errorOccurred": "发生错误",
"retry": "重试",
"taskNameRequired": "请输入任务名称",
"leaderRequired": "请选择 Leader",
"initialBalanceRequired": "请输入初始资金",
"initialBalanceInvalid": "初始资金必须大于 0",
"backtestDaysRequired": "请输入回测天数",
"backtestDaysInvalid": "回测天数必须在 1-15 之间",
"copyRatioRequired": "请输入跟单比例",
"copyRatioInvalid": "跟单比例必须在 0.01-10000 之间",
"copyRatioPlaceholder": "例如:100 表示 100%1:1 跟单),默认 100%",
"copyRatioTooltip": "跟单比例表示跟单金额相对于 Leader 订单金额的百分比。例如:100% 表示 1:1 跟单,50% 表示半仓跟单,200% 表示双倍跟单",
"fixedAmountRequired": "请输入固定金额",
"fixedAmountInvalid": "固定金额必须大于 0",
"priceFilters": "价格过滤",
"keywordsPlaceholder": "请输入关键字,按回车添加",
"delaySecondsHint": "延迟执行模拟真实跟单延迟",
"supportSellHint": "是否跟随 Leader 卖出",
"sortBy": "排序字段",
"sortOrder": "排序顺序",
"createdAt": "创建时间"
}
}
+131 -1
View File
@@ -34,7 +34,10 @@
"previous": "上一頁",
"next": "下一頁",
"page": "頁",
"pageOf": "第"
"pageOf": "第",
"ascending": "升序",
"descending": "降序",
"day": "天"
},
"account": {
"title": "賬戶管理",
@@ -240,6 +243,7 @@
"templates": "跟單模板",
"copyTradingConfig": "跟單配置",
"positions": "倉位管理",
"backtest": "回測",
"statistics": "統計信息",
"announcements": "公告",
"users": "用戶管理",
@@ -1226,5 +1230,131 @@
"providerQuickNode": "QuickNode",
"providerChainstack": "Chainstack",
"providerGetBlock": "GetBlock"
},
"backtest": {
"title": "回測",
"taskName": "任務名稱",
"leader": "Leader",
"initialBalance": "初始資金",
"backtestDays": "回測天數",
"status": "狀態",
"progress": "進度",
"startTime": "開始時間",
"endTime": "結束時間",
"finalBalance": "最終資金",
"profitAmount": "收益金額",
"profitRate": "收益率",
"totalTrades": "總交易數",
"buyTrades": "買入筆數",
"sellTrades": "賣出筆數",
"winTrades": "盈利筆數",
"lossTrades": "虧損筆數",
"winRate": "勝率",
"maxProfit": "最大單筆盈利",
"maxLoss": "最大單筆虧損",
"maxDrawdown": "最大回撤",
"avgHoldingTime": "平均持倉時間",
"statusPending": "等待中",
"statusRunning": "運行中",
"statusCompleted": "已完成",
"statusStopped": "已停止",
"statusFailed": "失敗",
"createTask": "創建回測任務",
"taskList": "回測任務列表",
"taskDetail": "回測任務詳情",
"tradeRecords": "交易記錄",
"config": "配置",
"statistics": "統計",
"chart": "資金曲線",
"createSuccess": "創建成功",
"createFailed": "創建失敗",
"deleteSuccess": "刪除成功",
"deleteFailed": "刪除失敗",
"stop": "停止",
"stopSuccess": "停止成功",
"stopFailed": "停止失敗",
"retry": "重試",
"retrySuccess": "重試成功",
"retryFailed": "重試失敗",
"retryConfirm": "確定重新運行此回測任務嗎?將從斷點繼續執行,保留已處理的交易記錄。",
"deleteConfirm": "確定刪除此回測任務嗎?",
"stopConfirm": "確定停止此回測任務嗎?",
"createCopyTrading": "創建跟單",
"createCopyTradingSuccess": "跟單配置創建成功",
"noTasks": "暫無回測任務",
"noTrades": "暫無交易記錄",
"fetchTasksFailed": "獲取任務列表失敗",
"fetchTaskDetailFailed": "獲取任務詳情失敗",
"fetchTradesFailed": "獲取交易記錄失敗",
"balanceChart": "資金變化",
"pnlChart": "盈虧變化",
"executionTime": "執行時間",
"runningDuration": "運行時長",
"estimatedRemaining": "預計剩餘",
"leaderAddress": "Leader 地址",
"leaderName": "Leader 名稱",
"copyMode": "跟單模式",
"copyModeRatio": "比例",
"copyModeFixed": "固定金額",
"copyRatio": "跟單比例",
"fixedAmount": "固定金額",
"maxOrderSize": "最大單筆訂單",
"minOrderSize": "最小單筆訂單",
"maxDailyLoss": "最大每日虧損",
"maxDailyOrders": "最大每日訂單數",
"priceTolerance": "價格容忍度",
"delaySeconds": "延遲秒數",
"supportSell": "跟單賣出",
"minOrderDepth": "最小訂單深度",
"maxSpread": "最大價差",
"minPrice": "最低價格",
"maxPrice": "最高價格",
"maxPositionValue": "最大倉位金額",
"keywordFilterMode": "關鍵字過濾模式",
"keywordFilterModeDisabled": "禁用",
"keywordFilterModeWhitelist": "白名單",
"keywordFilterModeBlacklist": "黑名單",
"keywords": "關鍵字",
"maxMarketEndDate": "市場截止時間限制",
"tradeTime": "交易時間",
"marketId": "市場ID",
"marketTitle": "市場標題",
"side": "方向",
"sideBuy": "買入",
"sideSell": "賣出",
"sideSettlement": "結算",
"outcome": "結果",
"quantity": "數量",
"price": "價格",
"amount": "金額",
"fee": "手續費",
"profitLoss": "盈虧",
"balanceAfter": "交易後餘額",
"leaderTradeId": "Leader 交易ID",
"loading": "加載中...",
"refreshing": "刷新中...",
"starting": "啟動中...",
"stopping": "停止中...",
"errorOccurred": "發生錯誤",
"retry": "重試",
"taskNameRequired": "請輸入任務名稱",
"leaderRequired": "請選擇 Leader",
"initialBalanceRequired": "請輸入初始資金",
"initialBalanceInvalid": "初始資金必須大於 0",
"backtestDaysRequired": "請輸入回測天數",
"backtestDaysInvalid": "回測天數必須在 1-15 之間",
"copyRatioRequired": "請輸入跟單比例",
"copyRatioInvalid": "跟單比例必須在 0.01-10000 之間",
"copyRatioPlaceholder": "例如:100 表示 100%1:1 跟單),預設 100%",
"copyRatioTooltip": "跟單比例表示跟單金額相對於 Leader 訂單金額的百分比。例如:100% 表示 1:1 跟單,50% 表示半倉跟單,200% 表示雙倍跟單",
"fixedAmountRequired": "請輸入固定金額",
"fixedAmountInvalid": "固定金額必須大於 0",
"priceFilters": "價格過濾",
"keywordsPlaceholder": "請輸入關鍵字,按回車添加",
"delaySecondsHint": "延遲執行模擬真實跟單延遲",
"supportSellHint": "是否跟隨 Leader 賣出",
"sortBy": "排序欄位",
"sortOrder": "排序順序",
"createdAt": "創建時間"
}
}
+216
View File
@@ -0,0 +1,216 @@
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
+526
View File
@@ -0,0 +1,526 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Card, Descriptions, Button, Tag, Space, Table, message, Row, Col, Statistic, Spin } from 'antd'
import { ArrowLeftOutlined, ReloadOutlined, DeleteOutlined, StopOutlined, CopyOutlined } from '@ant-design/icons'
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'
import AddCopyTradingModal from './CopyTradingOrders/AddModal'
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 [config, setConfig] = useState<BacktestConfigDto | null>(null)
const [statistics, setStatistics] = useState<BacktestStatisticsDto | null>(null)
const [trades, setTrades] = useState<BacktestTradeDto[]>([])
const [allTrades, setAllTrades] = useState<BacktestTradeDto[]>([]) // 用于图表显示的所有交易数据
const [tradesLoading, setTradesLoading] = useState(false)
const [tradesTotal, setTradesTotal] = useState(0)
const [tradesPage, setTradesPage] = useState(1)
const [tradesSize] = useState(20)
const [polling, setPolling] = useState<NodeJS.Timeout | null>(null)
// 创建跟单配置 Modal
const [addCopyTradingModalVisible, setAddCopyTradingModalVisible] = useState(false)
const [preFilledConfig, setPreFilledConfig] = useState<any>(null)
// 获取回测任务详情
const fetchTaskDetail = async () => {
setLoading(true)
try {
const response = await backtestService.detail({ id: parseInt(id!) })
if (response.data.code === 0 && response.data.data) {
setTask(response.data.data.task)
setConfig(response.data.data.config)
setStatistics(response.data.data.statistics)
} 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'))
} finally {
setLoading(false)
}
}
// 获取交易记录
const fetchTrades = async (page: number) => {
setTradesLoading(true)
try {
const response = await backtestService.trades({
taskId: parseInt(id!),
page,
size: tradesSize
})
if (response.data.code === 0 && response.data.data) {
setTrades(response.data.data.list)
setTradesTotal(response.data.data.total)
} else {
message.error(response.data.msg || t('backtest.fetchTradesFailed'))
}
} catch (error) {
console.error('Failed to fetch backtest trades:', error)
message.error(t('backtest.fetchTradesFailed'))
} finally {
setTradesLoading(false)
}
}
// 获取所有交易记录(用于图表显示)
const fetchAllTrades = async () => {
try {
const response = await backtestService.trades({
taskId: parseInt(id!),
page: 1,
size: 10000 // 获取所有数据
})
if (response.data.code === 0 && response.data.data) {
setAllTrades(response.data.data.list)
}
} catch (error) {
console.error('Failed to fetch all trades for chart:', error)
}
}
// 初始加载任务详情和交易记录
useEffect(() => {
fetchTaskDetail()
fetchTrades(tradesPage)
fetchAllTrades() // 加载所有交易数据用于图表
}, [id])
// 根据任务状态控制轮询
useEffect(() => {
// 停止之前的轮询
stopPolling()
// 只有任务正在运行或待处理时才启动轮询
if (task?.status === 'RUNNING' || task?.status === 'PENDING') {
const timer = setInterval(() => {
fetchTaskDetail()
}, 3000) // 每3秒轮询一次
setPolling(timer)
}
// 组件卸载或状态变化时清理定时器
return () => {
stopPolling()
}
}, [task?.status])
const stopPolling = () => {
if (polling) {
clearInterval(polling)
setPolling(null)
}
}
// 返回
const handleBack = () => {
navigate('/backtest')
}
// 停止任务
const handleStop = () => {
if (!window.confirm(t('backtest.stopConfirm'))) return
const stop = async () => {
try {
const response = await backtestService.stop({ id: parseInt(id!) })
if (response.data.code === 0) {
message.success(t('backtest.stopSuccess'))
fetchTaskDetail()
stopPolling()
} else {
message.error(response.data.msg || t('backtest.stopFailed'))
}
} catch (error) {
console.error('Failed to stop backtest task:', error)
message.error(t('backtest.stopFailed'))
}
}
stop()
}
// 删除任务
const handleDelete = () => {
if (!window.confirm(t('backtest.deleteConfirm'))) return
const del = async () => {
try {
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'))
}
} catch (error) {
console.error('Failed to delete backtest task:', error)
message.error(t('backtest.deleteFailed'))
}
}
del()
}
// 刷新
const handleRefresh = () => {
fetchTaskDetail()
fetchTrades(tradesPage)
}
// 一键创建跟单配置
const handleCreateCopyTrading = () => {
console.log('[BacktestDetail] handleCreateCopyTrading called, task:', task, 'config:', config)
if (!task || !config) {
console.log('[BacktestDetail] No task or config available')
return
}
// 预填充回测任务的配置参数(从 config 中获取)
const preFilled = {
leaderId: task.leaderId,
copyMode: config.copyMode,
copyRatio: config.copyMode === 'RATIO' ? parseFloat(config.copyRatio) * 100 : undefined,
fixedAmount: config.copyMode === 'FIXED' ? config.fixedAmount : undefined,
maxOrderSize: parseFloat(config.maxOrderSize),
minOrderSize: parseFloat(config.minOrderSize),
maxDailyLoss: parseFloat(config.maxDailyLoss),
maxDailyOrders: config.maxDailyOrders,
supportSell: config.supportSell,
keywordFilterMode: config.keywordFilterMode || 'DISABLED',
keywords: config.keywords || [],
configName: `回测任务-${task.taskName}`
}
console.log('[BacktestDetail] Generated preFilled config:', preFilled)
console.log('[BacktestDetail] Setting preFilledConfig and opening modal')
setPreFilledConfig(preFilled)
setAddCopyTradingModalVisible(true)
}
// 状态标签颜色
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 columns = [
{
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) => (
<Tag color={side === 'BUY' ? 'green' : side === 'SELL' ? 'orange' : 'blue'}>
{side === 'BUY' ? t('backtest.sideBuy') : side === 'SELL' ? t('backtest.sideSell') : t('backtest.sideSettlement')}
</Tag>
)
},
{
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') + ' (USDC)',
dataIndex: 'amount',
key: 'amount',
width: 120,
render: (value: string) => formatUSDC(value)
},
{
title: t('backtest.balanceAfter') + ' (USDC)',
dataIndex: 'balanceAfter',
key: 'balanceAfter',
width: 120,
render: (value: string) => formatUSDC(value)
},
{
title: t('backtest.leaderTradeId'),
dataIndex: 'leaderTradeId',
key: 'leaderTradeId',
width: 150,
ellipsis: true
}
]
if (!task) {
return <div style={{ padding: 24, textAlign: 'center' }}><Spin /></div>
}
return (
<div style={{ padding: 24 }}>
<Card>
{/* 头部操作栏 */}
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={handleBack} size={isMobile ? 'middle' : 'large'}>
{t('common.back')}
</Button>
<Button icon={<ReloadOutlined />} onClick={handleRefresh} loading={loading} size={isMobile ? 'middle' : 'large'}>
{t('common.refresh')}
</Button>
</Space>
<Space>
{task.status === 'COMPLETED' && (
<Button type="primary" icon={<CopyOutlined />} onClick={handleCreateCopyTrading} size={isMobile ? 'middle' : 'large'}>
{t('backtest.createCopyTrading')}
</Button>
)}
{(task.status === 'RUNNING' || task.status === 'PENDING') && (
<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} size={isMobile ? 'middle' : 'large'}>
{t('common.delete')}
</Button>
)}
</Space>
</Space>
{/* 任务基本信息 */}
<Card title={t('backtest.taskDetail')} 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}
</Descriptions.Item>
<Descriptions.Item label={t('backtest.initialBalance')}>
{formatUSDC(task.initialBalance)} USDC
</Descriptions.Item>
<Descriptions.Item label={t('backtest.finalBalance')}>
{task.finalBalance ? formatUSDC(task.finalBalance) + ' USDC' : '-'}
</Descriptions.Item>
<Descriptions.Item label={t('backtest.profitAmount')}>
<span style={{ color: task.profitAmount && parseFloat(task.profitAmount) >= 0 ? '#52c41a' : '#ff4d4f' }}>
{task.profitAmount ? formatUSDC(task.profitAmount) + ' USDC' : '-'}
</span>
</Descriptions.Item>
<Descriptions.Item label={t('backtest.profitRate')}>
<span style={{ color: task.profitRate && parseFloat(task.profitRate) >= 0 ? '#52c41a' : '#ff4d4f' }}>
{task.profitRate ? task.profitRate + '%' : '-'}
</span>
</Descriptions.Item>
<Descriptions.Item label={t('backtest.backtestDays')}>
{task.backtestDays} {t('common.day')}
</Descriptions.Item>
<Descriptions.Item label={t('backtest.status')}>
<Tag color={getStatusColor(task.status)}>{getStatusText(task.status)}</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('backtest.progress')}>
{task.progress}%
</Descriptions.Item>
<Descriptions.Item label={t('backtest.totalTrades')}>
{task.totalTrades}
</Descriptions.Item>
<Descriptions.Item label={t('backtest.startTime')}>
{new Date(task.startTime).toLocaleString()}
</Descriptions.Item>
<Descriptions.Item label={t('backtest.endTime')}>
{task.endTime ? new Date(task.endTime).toLocaleString() : '-'}
</Descriptions.Item>
<Descriptions.Item label={t('backtest.createdAt')}>
{new Date(task.createdAt).toLocaleString()}
</Descriptions.Item>
</Descriptions>
</Card>
{/* 统计信息 */}
{statistics && (
<Row gutter={16}>
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.buyTrades')}
value={statistics.buyTrades}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.sellTrades')}
value={statistics.sellTrades}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.winTrades')}
value={statistics.winTrades}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.lossTrades')}
value={statistics.lossTrades}
valueStyle={{ color: '#ff4d4f' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.winRate')}
value={statistics.winRate}
suffix="%"
valueStyle={{ color: parseFloat(statistics.winRate) >= 50 ? '#52c41a' : '#ff4d4f' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.maxProfit')}
value={formatUSDC(statistics.maxProfit)}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.maxLoss')}
value={formatUSDC(statistics.maxLoss)}
valueStyle={{ color: '#ff4d4f' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.maxDrawdown')}
value={formatUSDC(statistics.maxDrawdown)}
valueStyle={{ color: '#ff4d4f' }}
/>
</Card>
</Col>
{statistics.avgHoldingTime && (
<Col xs={24} sm={12} md={12} lg={6}>
<Card>
<Statistic
title={t('backtest.avgHoldingTime')}
value={(statistics.avgHoldingTime / 1000 / 60).toFixed(2)}
suffix=" min"
/>
</Card>
</Col>
)}
</Row>
)}
{/* 资金变化图表 */}
{allTrades.length > 0 && (
<Card title={t('backtest.balanceChart')}>
<BacktestChart trades={allTrades} />
</Card>
)}
{/* 交易记录 */}
<Card title={t('backtest.tradeRecords')}>
<Table
columns={columns}
dataSource={trades}
rowKey="id"
loading={tradesLoading}
pagination={{
current: tradesPage,
pageSize: tradesSize,
total: tradesTotal,
showSizeChanger: false,
showTotal: (total) => `${t('common.total')} ${total} ${t('common.items')}`,
onChange: (newPage) => {
setTradesPage(newPage)
fetchTrades(newPage)
}
}}
scroll={isMobile ? { x: 1200 } : { x: 1800 }}
/>
</Card>
</Space>
</Card>
{/* 创建跟单配置 Modal */}
<AddCopyTradingModal
open={addCopyTradingModalVisible}
onClose={() => {
setAddCopyTradingModalVisible(false)
setPreFilledConfig(null)
}}
onSuccess={() => {
message.success(t('backtest.createCopyTradingSuccess'))
setAddCopyTradingModalVisible(false)
setPreFilledConfig(null)
}}
preFilledConfig={preFilledConfig}
/>
</div>
)
}
export default BacktestDetail
File diff suppressed because it is too large Load Diff
+108 -32
View File
@@ -16,12 +16,27 @@ interface AddModalProps {
open: boolean
onClose: () => void
onSuccess?: () => void
preFilledConfig?: {
leaderId?: number
copyMode?: 'RATIO' | 'FIXED'
copyRatio?: number
fixedAmount?: string
maxOrderSize?: number
minOrderSize?: number
maxDailyLoss?: number
maxDailyOrders?: number
supportSell?: boolean
keywordFilterMode?: string
keywords?: string[]
configName?: string
}
}
const AddModal: React.FC<AddModalProps> = ({
open,
onClose,
onSuccess
onSuccess,
preFilledConfig
}) => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
@@ -64,25 +79,112 @@ const AddModal: React.FC<AddModalProps> = ({
return `跟单配置-${dateStr}-${timeStr}`
}
// 获取 Leader 资产信息
const fetchLeaderAssetInfo = async (leaderId: number) => {
if (!leaderId) return
setLoadingAssetInfo(true)
setLeaderAssetInfo(null)
try {
const response = await apiService.leaders.balance({ leaderId })
if (response.data.code === 0 && response.data.data) {
const balance = response.data.data
setLeaderAssetInfo({
total: balance.totalBalance || '0',
available: balance.availableBalance || '0',
position: balance.positionBalance || '0'
})
} else {
message.error(response.data.msg || t('copyTradingAdd.fetchAssetInfoFailed') || '获取资产信息失败')
}
} catch (error: any) {
console.error('获取 Leader 资产失败:', error)
message.error(error.message || t('copyTradingAdd.fetchAssetInfoFailed') || '获取资产信息失败')
} finally {
setLoadingAssetInfo(false)
}
}
// 填充预配置数据到表单(复用模板填充逻辑)
const fillPreFilledConfig = (config: typeof preFilledConfig) => {
console.log('[AddModal] fillPreFilledConfig called with config:', config)
if (!config) {
console.log('[AddModal] fillPreFilledConfig: config is null/undefined')
return
}
const formValues = {
configName: config.configName || generateDefaultConfigName(),
leaderId: config.leaderId,
copyMode: config.copyMode || 'RATIO',
copyRatio: config.copyRatio,
fixedAmount: config.fixedAmount,
maxOrderSize: config.maxOrderSize,
minOrderSize: config.minOrderSize,
maxDailyLoss: config.maxDailyLoss,
maxDailyOrders: config.maxDailyOrders,
supportSell: config.supportSell,
keywordFilterMode: config.keywordFilterMode || 'DISABLED'
}
console.log('[AddModal] fillPreFilledConfig: setting form values:', formValues)
form.setFieldsValue(formValues)
setCopyMode(config.copyMode || 'RATIO')
setKeywords(config.keywords || [])
console.log('[AddModal] fillPreFilledConfig: form values set, copyMode:', config.copyMode, 'keywords:', config.keywords)
// 自动获取 Leader 资产信息
if (config.leaderId) {
console.log('[AddModal] fillPreFilledConfig: fetching leader asset info for leaderId:', config.leaderId)
fetchLeaderAssetInfo(config.leaderId)
}
}
// 处理 Modal 打开/关闭
useEffect(() => {
console.log('[AddModal] useEffect triggered, open:', open, 'preFilledConfig:', preFilledConfig)
if (open) {
console.log('[AddModal] Modal opened, fetching accounts, leaders, templates')
fetchAccounts()
fetchLeaders()
fetchTemplates()
// 生成默认配置名
// 如果有预填充配置,填充表单(延迟执行确保数据已加载)
if (preFilledConfig) {
console.log('[AddModal] preFilledConfig exists, will fill form after 100ms')
// 使用 setTimeout 确保在下一个事件循环执行,此时 Modal 已完全打开
setTimeout(() => {
console.log('[AddModal] setTimeout callback executed, calling fillPreFilledConfig')
fillPreFilledConfig(preFilledConfig)
}, 100)
} else {
console.log('[AddModal] No preFilledConfig, using default values')
// 没有预填充配置时,生成默认配置名
const defaultConfigName = generateDefaultConfigName()
form.setFieldsValue({ configName: defaultConfigName })
// 重置关键字列表
form.setFieldsValue({
configName: defaultConfigName,
copyMode: 'RATIO',
copyRatio: 100,
maxOrderSize: 1000,
minOrderSize: 1,
maxDailyLoss: 10000,
maxDailyOrders: 100,
supportSell: true,
keywordFilterMode: 'DISABLED'
})
setCopyMode('RATIO')
setKeywords([])
}
} else {
console.log('[AddModal] Modal closed, resetting form')
// 关闭时重置表单
form.resetFields()
setKeywords([])
setCopyMode('RATIO')
setLeaderAssetInfo(null)
}
}, [open])
}, [open, preFilledConfig])
const fetchLeaders = async () => {
try {
@@ -133,32 +235,6 @@ const AddModal: React.FC<AddModalProps> = ({
setCopyMode(mode)
}
// 获取 Leader 资产信息
const fetchLeaderAssetInfo = async (leaderId: number) => {
if (!leaderId) return
setLoadingAssetInfo(true)
setLeaderAssetInfo(null)
try {
const response = await apiService.leaders.balance({ leaderId })
if (response.data.code === 0 && response.data.data) {
const balance = response.data.data
setLeaderAssetInfo({
total: balance.totalBalance || '0',
available: balance.availableBalance || '0',
position: balance.positionBalance || '0'
})
} else {
message.error(response.data.msg || t('copyTradingAdd.fetchAssetInfoFailed') || '获取资产信息失败')
}
} catch (error: any) {
console.error('获取 Leader 资产失败:', error)
message.error(error.message || t('copyTradingAdd.fetchAssetInfoFailed') || '获取资产信息失败')
} finally {
setLoadingAssetInfo(false)
}
}
// 处理导入账户成功
const handleAccountImportSuccess = async (accountId: number) => {
message.success(t('accountImport.importSuccess'))
+74
View File
@@ -726,3 +726,77 @@ export { apiClient }
export default apiService
/**
*
*/
export const backtestService = {
/**
*
*/
create: (data: {
taskName: string
leaderId: number
initialBalance: string
backtestDays: number
copyMode?: 'RATIO' | 'FIXED'
copyRatio?: string
fixedAmount?: string
maxOrderSize?: string
minOrderSize?: string
maxDailyLoss?: string
maxDailyOrders?: number
priceTolerance?: string
delaySeconds?: number
supportSell?: boolean
minOrderDepth?: string
maxSpread?: string
minPrice?: string
maxPrice?: string
maxPositionValue?: string
keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST'
keywords?: string[]
maxMarketEndDate?: number | null
}) => apiClient.post('/backtest/tasks', data),
/**
*
*/
list: (data: {
leaderId?: number
status?: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'STOPPED' | 'FAILED'
sortBy?: 'profitAmount' | 'profitRate' | 'createdAt'
sortOrder?: 'asc' | 'desc'
page: number
size: number
}) => apiClient.post('/backtest/tasks/list', data),
/**
*
*/
detail: (data: { id: number }) => apiClient.post('/backtest/tasks/detail', data),
/**
*
*/
trades: (data: {
taskId: number
page: number
size: number
}) => apiClient.post('/backtest/tasks/trades', data),
/**
*
*/
stop: (data: { id: number }) => apiClient.post('/backtest/tasks/stop', data),
/**
*
*/
delete: (data: { id: number }) => apiClient.post('/backtest/tasks/delete', data),
/**
*
*/
retry: (data: { id: number }) => apiClient.post('/backtest/tasks/retry', data)
}
+218
View File
@@ -0,0 +1,218 @@
/**
*
*/
/**
*
*/
export interface BacktestCreateRequest {
taskName: string
leaderId: number
initialBalance: string
backtestDays: number // 1-30
// 跟单配置
copyMode?: 'RATIO' | 'FIXED'
copyRatio?: string
fixedAmount?: string
maxOrderSize?: string
minOrderSize?: string
maxDailyLoss?: string
maxDailyOrders?: number
priceTolerance?: string // 百分比
delaySeconds?: number
supportSell?: boolean
minOrderDepth?: string
maxSpread?: string
minPrice?: string
maxPrice?: string
maxPositionValue?: string
keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST'
keywords?: string[]
maxMarketEndDate?: number | null
pageForResume?: number // 用于恢复中断任务,从指定页码开始获取历史数据(从1开始)
}
/**
*
*/
export interface BacktestListRequest {
leaderId?: number
status?: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'STOPPED' | 'FAILED'
sortBy?: 'profitAmount' | 'profitRate' | 'createdAt'
sortOrder?: 'asc' | 'desc'
page: number
size: number
pageForResume?: number // 恢复时从指定页码开始
}
/**
*
*/
export interface BacktestDetailRequest {
id: number
}
/**
*
*/
export interface BacktestTradeListRequest {
taskId: number
page: number
size: number
}
/**
*
*/
export interface BacktestProgressRequest {
id: number
}
/**
*
*/
export interface BacktestStopRequest {
id: number
}
/**
*
*/
export interface BacktestDeleteRequest {
id: number
}
/**
*
*/
export interface BacktestRetryRequest {
id: number
}
/**
*
*/
export interface BacktestListResponse {
list: BacktestTaskDto[]
total: number
page: number
size: number
processedTradeCount?: number // 已处理的交易数量(用于显示真实进度)
}
/**
*
*/
export interface BacktestDetailResponse {
task: BacktestTaskDto
config: BacktestConfigDto
statistics: BacktestStatisticsDto
lastProcessedTradeTime?: number // 最后处理的交易时间(用于中断恢复)
lastProcessedTradeIndex?: number // 最后处理的交易索引(用于中断恢复)
processedTradeCount?: number // 已处理的交易数量(用于显示真实进度)
}
/**
*
*/
export interface BacktestTradeListResponse {
list: BacktestTradeDto[]
total: number
page: number
size: number
}
/**
*
*/
export interface BacktestProgressResponse {
progress: number // 0-100
currentBalance: string
totalTrades: number
status: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'STOPPED' | 'FAILED'
}
/**
* DTO
*/
export interface BacktestTaskDto {
id: number
taskName: string
leaderId: number
leaderName: string | null
leaderAddress: string | null
initialBalance: string
finalBalance: string | null
profitAmount: string | null
profitRate: string | null // 百分比
backtestDays: number
startTime: number
endTime: number | null
status: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'STOPPED' | 'FAILED'
progress: number // 0-100
totalTrades: number
createdAt: number
executionStartedAt: number | null
executionFinishedAt: number | null
}
/**
* DTO
*/
export interface BacktestConfigDto {
copyMode: 'RATIO' | 'FIXED'
copyRatio: string
fixedAmount: string | null
maxOrderSize: string
minOrderSize: string
maxDailyLoss: string
maxDailyOrders: number
priceTolerance: string // 百分比
delaySeconds: number
supportSell: boolean
minOrderDepth: string | null
maxSpread: string | null
minPrice: string | null
maxPrice: string | null
maxPositionValue: string | null
keywordFilterMode: 'DISABLED' | 'WHITELIST' | 'BLACKLIST' | null
keywords: string[] | null
maxMarketEndDate: number | null
}
/**
* DTO
*/
export interface BacktestStatisticsDto {
totalTrades: number // 总交易笔数
buyTrades: number // 买入笔数
sellTrades: number // 卖出笔数
winTrades: number // 盈利交易笔数
lossTrades: number // 亏损交易笔数
winRate: string // 胜率 (百分比)
maxProfit: string // 最大单笔盈利
maxLoss: string // 最大单笔亏损
maxDrawdown: string // 最大回撤
avgHoldingTime: number | null // 平均持仓时间 (毫秒)
}
/**
* DTO
*/
export interface BacktestTradeDto {
id: number
tradeTime: number
marketId: string
marketTitle: string | null
side: 'BUY' | 'SELL' | 'SETTLEMENT'
outcome: string // YES/NO 或 outcomeIndex
outcomeIndex: number | null
quantity: string
price: string
amount: string
fee: string
profitLoss: string | null // 仅卖出和结算时有值
balanceAfter: string
leaderTradeId: string | null
}
+2 -1
View File
@@ -4,7 +4,8 @@
"description": "Utility scripts for Polyhermes",
"type": "module",
"scripts": {
"get-order-detail": "node get-order-detail.js"
"get-order-detail": "node get-order-detail.js",
"verify-backtest-data": "node verify-backtest-data.js"
},
"dependencies": {
"@ethersproject/wallet": "^5.7.0",