From b1ad6f02d252a56806a8b2239443dfe9e60bff23 Mon Sep 17 00:00:00 2001 From: WrBug Date: Mon, 9 Feb 2026 01:08:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(backtest):=20=E5=9B=9E=E6=B5=8B=E7=BB=93?= =?UTF-8?q?=E7=AE=97=E6=8C=81=E4=B9=85=E5=8C=96=E3=80=81=E6=8C=89=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=87=8D=E6=96=B0=E6=B5=8B=E8=AF=95=E3=80=81=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E4=BB=A5=E5=BD=93=E5=89=8D=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E4=B8=BA=E7=AA=97=E5=8F=A3=E5=9F=BA=E5=87=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 持久化: BUY/SELL 与 SETTLEMENT(WIN/LOSE/UNKNOWN/CLOSED) 均写入 backtest_trade - 重新测试: 已完成任务支持「按当前配置重新测试」,新任务名称可编辑,后端 POST /tasks/rerun + 前端按钮与确认弹窗 - 回测窗口: 首次执行以当前时间为终点、startTime = endTime - backtestDays(局部变量,不修改实体) - 新增错误码 BACKTEST_TASK_NOT_COMPLETED、SERVER_BACKTEST_RERUN_FAILED 及多语言 Co-authored-by: Cursor --- .../controller/backtest/BacktestController.kt | 31 +++ .../wrbug/polymarketbot/dto/BacktestDto.kt | 8 + .../polymarketbot/entity/BacktestTask.kt | 2 +- .../wrbug/polymarketbot/enums/ErrorCode.kt | 4 +- .../service/backtest/BacktestDataService.kt | 84 +++--- .../backtest/BacktestExecutionService.kt | 245 +++++++++++------- .../backtest/BacktestPollingService.kt | 29 +-- .../service/backtest/BacktestService.kt | 44 ++++ .../resources/i18n/messages_en.properties | 2 + .../resources/i18n/messages_zh_CN.properties | 2 + .../resources/i18n/messages_zh_TW.properties | 2 + .../zh/backtest/DATA_API_CURSOR_PAGINATION.md | 45 ++++ frontend/src/components/LeaderSelect.tsx | 67 +++++ frontend/src/locales/en/common.json | 7 +- frontend/src/locales/zh-CN/common.json | 7 +- frontend/src/locales/zh-TW/common.json | 7 +- frontend/src/pages/BacktestList.tsx | 113 ++++++-- .../src/pages/CopyTradingOrders/AddModal.tsx | 14 +- .../src/pages/CopyTradingOrders/EditModal.tsx | 5 +- frontend/src/services/api.ts | 7 +- 20 files changed, 526 insertions(+), 199 deletions(-) create mode 100644 docs/zh/backtest/DATA_API_CURSOR_PAGINATION.md create mode 100644 frontend/src/components/LeaderSelect.tsx diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/backtest/BacktestController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/backtest/BacktestController.kt index ff2429b..827f510 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/backtest/BacktestController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/backtest/BacktestController.kt @@ -221,5 +221,36 @@ class BacktestController( ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_RETRY_FAILED, e.message, messageSource)) } } + + /** + * 按当前配置重新测试:基于已完成的回测任务创建相同配置的新任务(仅支持已完成任务) + */ + @PostMapping("/tasks/rerun") + fun rerunBacktestTask(@RequestBody request: BacktestRerunRequest): ResponseEntity> { + return try { + logger.info("按配置重新测试: sourceTaskId=${request.id}, newTaskName=${request.taskName}") + + val result = backtestService.rerunBacktestTask(request) + + result.fold( + onSuccess = { dto -> + logger.info("重新测试任务创建成功: newTaskId=${dto.id}") + ResponseEntity.ok(ApiResponse.success(dto)) + }, + onFailure = { e -> + logger.error("按配置重新测试失败", e) + val errorCode = when (e) { + is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND + is IllegalStateException -> ErrorCode.BACKTEST_TASK_NOT_COMPLETED + else -> ErrorCode.SERVER_BACKTEST_RERUN_FAILED + } + ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource)) + } + ) + } catch (e: Exception) { + logger.error("按配置重新测试异常", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_RERUN_FAILED, e.message, messageSource)) + } + } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/BacktestDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/BacktestDto.kt index 1b9e19d..539c5ad 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/BacktestDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/BacktestDto.kt @@ -80,6 +80,14 @@ data class BacktestRetryRequest( val id: Long // 回测任务ID ) +/** + * 按当前配置重新测试请求(仅支持已完成任务) + */ +data class BacktestRerunRequest( + val id: Long, // 源回测任务ID + val taskName: String? = null // 新任务名称,为空时使用「原名称 (副本)」 +) + /** * 回测任务列表响应 */ diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/BacktestTask.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/BacktestTask.kt index 1c405bf..da2dfe2 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/BacktestTask.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/BacktestTask.kt @@ -37,7 +37,7 @@ data class BacktestTask( val backtestDays: Int, @Column(name = "start_time", nullable = false) - val startTime: Long, // 回测开始时间(历史时间) + val startTime: Long, // 回测开始时间(历史时间),创建时计算;执行时以当前时间为基准用局部变量重算窗口 @Column(name = "end_time") var endTime: Long? = null, // 回测结束时间(历史时间) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt index ed1b5d0..bc54129 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt @@ -239,6 +239,7 @@ enum class ErrorCode( 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"), + BACKTEST_TASK_NOT_COMPLETED(4606, "仅支持对已完成的回测任务重新测试", "error.backtest.task_not_completed"), 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"), @@ -248,7 +249,8 @@ enum class ErrorCode( 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"); + SERVER_BACKTEST_RETRY_FAILED(5612, "重试回测任务失败", "error.server.backtest_retry_failed"), + SERVER_BACKTEST_RERUN_FAILED(5613, "按配置重新测试失败", "error.server.backtest_rerun_failed"); companion object { /** diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestDataService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestDataService.kt index 1ac5709..1c7dbcc 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestDataService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestDataService.kt @@ -12,9 +12,19 @@ import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.math.BigDecimal +/** + * 基于 start 游标的一批历史交易结果 + * @param trades 本批交易列表(已按时间升序) + * @param nextCursorSeconds 下一页游标(API 的 start 参数,秒级);若本批不足 limit 条则为 null 表示最后一页 + */ +data class LeaderTradesBatchResult( + val trades: List, + val nextCursorSeconds: Long? +) + /** * 回测数据服务 - * 直接从 Polymarket Data API 获取 Leader 历史交易 + * 直接从 Polymarket Data API 获取 Leader 历史交易,使用 start 游标分页(避免 offset 过大报错) */ @Service class BacktestDataService( @@ -24,48 +34,45 @@ class BacktestDataService( private val logger = LoggerFactory.getLogger(BacktestDataService::class.java) /** - * 分页获取 Leader 历史交易(用于回测恢复) - * 支持重试机制:最多重试5次,每次间隔1秒 + * 按 start 游标获取一批 Leader 历史交易 + * 规则:limit 固定为 500;若返回 500 条则取本批最大时间戳(秒)作为下一页 start,不加 1(同一秒可能多笔订单,由下游按 tradeId 去重);不足 500 则为最后一页 * * @param leaderId Leader ID - * @param startTime 开始时间(毫秒时间戳) - * @param endTime 结束时间(毫秒时间戳) - * @param page 页码 (从 0 开始) - * @param size 每页数量 - * @return 历史交易列表 - * @throws Exception 重试5次后仍然失败时抛出异常 + * @param startTime 回测开始时间(毫秒) + * @param endTime 回测结束时间(毫秒) + * @param cursorStartSeconds 本页游标(API 的 start,秒);首次传 startTime/1000 + * @param limit 每批条数,建议 500 + * @return 本批交易与下一页游标(null 表示没有下一页) */ - suspend fun getLeaderHistoricalTradesForPage( + suspend fun getLeaderHistoricalTradesBatch( leaderId: Long, startTime: Long, endTime: Long, - page: Int, - size: Int - ): List { - logger.info("分页获取 Leader 历史交易: leaderId=$leaderId, timeRange=$startTime - $endTime, page=$page, size=$size") + cursorStartSeconds: Long, + limit: Int + ): LeaderTradesBatchResult { + logger.info("获取 Leader 历史交易批次: leaderId=$leaderId, cursorStart=$cursorStartSeconds, limit=$limit") - // 1. 验证 Leader 是否存在 val leader = leaderRepository.findById(leaderId).orElse(null) ?: throw IllegalArgumentException("Leader 不存在: $leaderId") val dataApi = retrofitFactory.createDataApi() - val offset = page * size + val endSeconds = endTime / 1000 val maxRetries = 5 - val retryDelay = 1000L // 1秒 + val retryDelay = 1000L - // 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" + start = cursorStartSeconds, + end = endSeconds, + limit = limit, + offset = null, + sortBy = "TIMESTAMP", + sortDirection = "ASC" ) if (!response.isSuccessful || response.body() == null) { @@ -73,25 +80,20 @@ class BacktestDataService( } val activities = response.body()!! - logger.info("成功获取第 $page 页数据,共 ${activities.size} 条交易(第 $attempt 次尝试)") + logger.info("本批获取 ${activities.size} 条活动(第 $attempt 次尝试)") - return activities.mapNotNull { activity -> + val trades = activities.mapNotNull { activity -> try { - if (activity.type != "TRADE") { - return@mapNotNull null - } - + 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") + logger.debug("交易时间超出范围,跳过: timestamp=$tradeTimestamp") return@mapNotNull null } - TradeData( tradeId = activity.transactionHash ?: "${activity.timestamp}_${activity.conditionId}_${activity.side}", marketId = activity.conditionId, @@ -111,20 +113,24 @@ class BacktestDataService( } } + // 下一页 start 用本批最大 timestamp(秒),不加 1:同一秒可能有多笔订单,依赖下游按 tradeId 去重 + val nextCursorSeconds: Long? = if (trades.size < limit) { + null + } else { + val maxTs = trades.maxOf { it.timestamp } + maxTs / 1000 + } + return LeaderTradesBatchResult(trades = trades, nextCursorSeconds = nextCursorSeconds) } catch (e: Exception) { lastException = e - logger.warn("第 $attempt/$maxRetries 次尝试获取第 $page 页数据失败: ${e.message}") - - // 如果不是最后一次尝试,则等待后重试 + logger.warn("第 $attempt/$maxRetries 次获取批次失败: ${e.message}") if (attempt < maxRetries) { logger.info("等待 $retryDelay 毫秒后重试...") delay(retryDelay) } } } - - // 重试5次后仍然失败,抛出异常 - val errorMsg = "重试 $maxRetries 次后仍然失败获取第 $page 页数据" + val errorMsg = "重试 $maxRetries 次后仍然失败,cursorStart=$cursorStartSeconds" logger.error(errorMsg, lastException) throw Exception(errorMsg, lastException) } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestExecutionService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestExecutionService.kt index 2ff45d6..9bca058 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestExecutionService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestExecutionService.kt @@ -8,6 +8,7 @@ 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.common.MarketService import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingFilterService import com.wrbug.polymarketbot.util.toSafeBigDecimal import org.slf4j.LoggerFactory @@ -17,6 +18,7 @@ import java.math.BigDecimal import java.text.SimpleDateFormat import java.util.* import kotlin.math.max +import kotlin.math.min @Service class BacktestExecutionService( @@ -24,12 +26,14 @@ class BacktestExecutionService( private val backtestTradeRepository: BacktestTradeRepository, private val backtestDataService: BacktestDataService, private val marketPriceService: MarketPriceService, + private val marketService: MarketService, private val copyTradingFilterService: CopyTradingFilterService ) { private val logger = LoggerFactory.getLogger(BacktestExecutionService::class.java) /** * 持仓数据结构 + * @param marketEndDate 市场结束时间(毫秒),用于到期结算判断,null 表示未知 */ data class Position( val marketId: String, @@ -37,7 +41,8 @@ class BacktestExecutionService( val outcomeIndex: Int?, var quantity: BigDecimal, val avgPrice: BigDecimal, - val leaderBuyQuantity: BigDecimal? + val leaderBuyQuantity: BigDecimal?, + val marketEndDate: Long? = null ) /** @@ -80,10 +85,13 @@ class BacktestExecutionService( * 执行回测任务(支持分页和恢复) * 自动处理所有页面的数据,支持中断恢复 */ + /** 每批请求 API 的条数(基于 start 游标分页,避免 offset 过大) */ + private val backtestBatchLimit = 500 + @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") + logger.info("开始执行回测任务: taskId=${task.id}, taskName=${task.taskName}, batchLimit=$backtestBatchLimit") // 1. 更新任务状态为 RUNNING task.status = "RUNNING" @@ -95,99 +103,89 @@ class BacktestExecutionService( var currentBalance = task.initialBalance val positions = mutableMapOf() val trades = mutableListOf() - // 每日订单数缓存:key为日期字符串(yyyy-MM-dd),value为当天的 BUY 订单数 val dailyOrderCountCache = mutableMapOf() - // 每日亏损缓存:key为日期字符串(yyyy-MM-dd),value为当天的累计亏损金额 val dailyLossCache = mutableMapOf() + val seenTradeIds = mutableSetOf() - // 3. 计算回测时间范围 + // 3. 回测时间范围:首次执行以当前时间为基准取最近 backtestDays 天;断点续跑保留原 startTime,仅 endTime 延到当前 val endTime = System.currentTimeMillis() - val startTime = task.startTime + val startTime = if (task.lastProcessedTradeTime == null) { + endTime - (task.backtestDays * 24L * 3600 * 1000) + } else { + task.startTime + } - logger.info("回测时间范围: ${formatTimestamp(startTime)} - ${formatTimestamp(endTime)}, " + + logger.info("回测时间范围: ${formatTimestamp(startTime)} - ${formatTimestamp(endTime)} (${task.backtestDays} 天), " + "初始余额: ${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 + // 4. 游标分页:恢复时也从 lastProcessedTradeTime 所在秒开始拉(不加 1),与分页规则一致;已处理的通过 timestamp 跳过,不依赖内存 seenTradeIds + var cursorSeconds = if (task.lastProcessedTradeTime != null) { + task.lastProcessedTradeTime!! / 1000 } else { - logger.info("新任务:从第0页开始") - 0 + startTime / 1000 } + val endSeconds = endTime / 1000 + val resumeThresholdMs = task.lastProcessedTradeTime ?: 0L - // 5. 分页获取和处理交易数据 - var currentPage = maxOf(startPage, page) - // 计算下一个要处理的全局索引(用于日志和统计) - val nextGlobalIndex = if (task.lastProcessedTradeIndex != null) { - task.lastProcessedTradeIndex!! + 1 - } else { - 0 - } - - logger.info("开始分页处理:起始页=$currentPage, 下一个要处理的索引=$nextGlobalIndex") + logger.info("开始游标分页:cursorStart=$cursorSeconds(恢复则跳过 timestamp<=${resumeThresholdMs}ms)") + var terminateBacktest = false while (true) { - // 定期从数据库重新加载任务状态,确保能及时响应停止操作 + if (terminateBacktest) { + logger.info("余额已为负或不足,终止回测循环") + break + } val currentTaskStatus = backtestTaskRepository.findById(task.id!!).orElse(null) if (currentTaskStatus == null || currentTaskStatus.status != "RUNNING") { logger.info("回测任务状态已变更: ${currentTaskStatus?.status},停止执行") break } - logger.info("正在获取第 $currentPage 页数据...") + logger.info("正在获取批次数据 cursorStart=$cursorSeconds (${formatTimestamp(cursorSeconds * 1000)}) ...") - // 每页使用独立的交易列表,避免跨页重复保存 val currentPageTrades = mutableListOf() try { - // 获取当前页的交易数据(支持重试5次) - val pageTrades = backtestDataService.getLeaderHistoricalTradesForPage( + val batch = backtestDataService.getLeaderHistoricalTradesBatch( task.leaderId, startTime, endTime, - currentPage, - size + cursorSeconds, + backtestBatchLimit ) + val pageTrades = batch.trades if (pageTrades.isEmpty()) { - logger.info("第 $currentPage 页无数据,所有数据处理完成") + logger.info("本批无数据,所有数据处理完成") break } - logger.info("第 $currentPage 页获取到 ${pageTrades.size} 条交易") + logger.info("本批获取 ${pageTrades.size} 条交易,是否有下一页: ${batch.nextCursorSeconds != null}") - // 处理当前页的交易 + val countAtBatchStart = task.processedTradeCount var lastProcessedIndexInPage: Int? = null + var processedInBatch = 0 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}") + if (leaderTrade.tradeId in seenTradeIds) { + logger.debug("跳过重复交易: ${leaderTrade.tradeId}") continue } + if (resumeThresholdMs > 0 && leaderTrade.timestamp <= resumeThresholdMs) { + logger.debug("恢复时跳过已处理时间戳: tradeId=${leaderTrade.tradeId}, timestamp=${leaderTrade.timestamp}") + continue + } + seenTradeIds.add(leaderTrade.tradeId) - // 记录当前处理的索引 + val index = countAtBatchStart + processedInBatch lastProcessedIndexInPage = index + processedInBatch++ - // 更新进度 - val progress = if (pageTrades.size > 0) { - (localIndex * 100) / pageTrades.size + // 进度按时间比例:(当前订单时间 - 开始时间) / (结束时间 - 开始时间) * 100,运行中上限 99 + val timeRange = endTime - startTime + val progress = if (timeRange > 0) { + val elapsed = (leaderTrade.timestamp - startTime).coerceIn(0L, timeRange) + min(99, ((elapsed * 100) / timeRange).toInt()) } else { 0 } @@ -199,15 +197,15 @@ class BacktestExecutionService( try { // 5.1 实时检查并结算已到期的市场 - currentBalance = settleExpiredPositions(task, positions, currentBalance, trades, leaderTrade.timestamp) + currentBalance = settleExpiredPositions(task, positions, currentBalance, trades, leaderTrade.timestamp, currentPageTrades) // 5.2 检查余额和持仓状态 - if (currentBalance < BigDecimal.ZERO) { - logger.info("余额已为负,直接终止回测: $currentBalance") - break - } - if (currentBalance < BigDecimal.ONE && positions.isEmpty()) { - logger.info("余额不足且无持仓,停止回测: $currentBalance") + if (currentBalance <= BigDecimal.ONE) { + logger.info( + if (currentBalance < BigDecimal.ZERO) "余额已为负,直接终止回测: $currentBalance" + else "余额<=1,停止回测: $currentBalance" + ) + terminateBacktest = true break } @@ -262,21 +260,60 @@ class BacktestExecutionService( // 5.7 处理买卖逻辑 if (leaderTrade.side == "BUY") { - // 买入逻辑 - val quantity = finalFollowAmount.divide(leaderTrade.price, 8, java.math.RoundingMode.DOWN) - val totalCost = finalFollowAmount + // 余额不足时按最大可用余额交易,仍须满足最小订单金额 + val actualBuyAmount = if (currentBalance < finalFollowAmount) { + logger.debug("余额不足,按最大余额买入: balance=$currentBalance, 原需=$finalFollowAmount, marketId=${leaderTrade.marketId}") + currentBalance + } else { + finalFollowAmount + } + if (actualBuyAmount < task.minOrderSize) { + logger.debug("可用金额低于最小订单限制跳过: actual=$actualBuyAmount, minOrderSize=${task.minOrderSize}") + continue + } + val quantity = actualBuyAmount.divide(leaderTrade.price, 8, java.math.RoundingMode.DOWN) + if (quantity <= BigDecimal.ZERO) { + logger.debug("计算数量为0跳过: actualBuyAmount=$actualBuyAmount, price=${leaderTrade.price}") + continue + } + val totalCost = actualBuyAmount - // 更新余额和持仓 + // 更新余额和持仓(同市场同 outcome 多次买入合并:数量相加、加权均价、leaderBuyQuantity 相加) 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() - ) + val price = leaderTrade.price.toSafeBigDecimal() + val leaderSize = leaderTrade.size.toSafeBigDecimal() + val existing = positions[positionKey] + positions[positionKey] = if (existing != null) { + val newQuantity = existing.quantity.add(quantity) + val newAvgPrice = if (newQuantity > BigDecimal.ZERO) { + existing.quantity.multiply(existing.avgPrice).add(quantity.multiply(price)) + .divide(newQuantity, 8, java.math.RoundingMode.HALF_UP) + } else { + price + } + val newLeaderBuyQuantity = (existing.leaderBuyQuantity ?: BigDecimal.ZERO).add(leaderSize) + Position( + marketId = leaderTrade.marketId, + outcome = leaderTrade.outcome ?: "", + outcomeIndex = leaderTrade.outcomeIndex, + quantity = newQuantity, + avgPrice = newAvgPrice, + leaderBuyQuantity = newLeaderBuyQuantity, + marketEndDate = existing.marketEndDate + ) + } else { + val market = marketService.getMarket(leaderTrade.marketId) + Position( + marketId = leaderTrade.marketId, + outcome = leaderTrade.outcome ?: "", + outcomeIndex = leaderTrade.outcomeIndex, + quantity = quantity, + avgPrice = price, + leaderBuyQuantity = leaderSize, + marketEndDate = market?.endDate + ) + } // 记录交易到当前页列表 currentPageTrades.add(BacktestTrade( @@ -289,7 +326,7 @@ class BacktestExecutionService( outcomeIndex = leaderTrade.outcomeIndex, quantity = quantity, price = leaderTrade.price.toSafeBigDecimal(), - amount = finalFollowAmount, + amount = actualBuyAmount, fee = BigDecimal.ZERO, profitLoss = null, balanceAfter = currentBalance, @@ -384,14 +421,11 @@ class BacktestExecutionService( } } - // 保存当前页的所有交易(每页处理完成后保存,避免重复插入) + // 保存本批交易 if (currentPageTrades.isNotEmpty()) { - logger.info("保存第 $currentPage 页的交易数据,共 ${currentPageTrades.size} 笔") - - // 批量保存当前页的交易 + logger.info("保存本批交易,共 ${currentPageTrades.size} 笔") backtestTradeRepository.saveAll(currentPageTrades) - // 更新当前页的最后处理信息 val lastTradeInPage = currentPageTrades.lastOrNull() if (lastTradeInPage != null && lastProcessedIndexInPage != null) { task.lastProcessedTradeTime = lastTradeInPage.tradeTime @@ -399,28 +433,34 @@ class BacktestExecutionService( task.processedTradeCount = lastProcessedIndexInPage + 1 task.finalBalance = currentBalance backtestTaskRepository.save(task) - - logger.info("第 $currentPage 页处理完成,更新索引: ${task.lastProcessedTradeIndex}, 总处理数: ${task.processedTradeCount}") + logger.info("本批处理完成,lastProcessedTradeIndex=${task.lastProcessedTradeIndex}, 总处理数=${task.processedTradeCount}") } } else { - logger.info("第 $currentPage 页没有交易需要保存") + logger.info("本批没有交易需要保存") } - // 将当前页交易添加到全局列表(用于最终统计) trades.addAll(currentPageTrades) - // 准备处理下一页 - currentPage++ + if (batch.nextCursorSeconds == null) { + logger.info("本批不足 $backtestBatchLimit 条,已是最后一页") + break + } + cursorSeconds = batch.nextCursorSeconds!! } catch (e: Exception) { - logger.error("获取或处理第 $currentPage 页数据失败: ${e.message}", e) + logger.error("获取或处理本批数据失败: ${e.message}", e) // 重试失败,标记任务为 FAILED throw e } } // 6. 处理回测结束时仍未到期的持仓 - currentBalance = settleRemainingPositions(task, positions, currentBalance, trades, endTime) + val remainingSettlements = mutableListOf() + currentBalance = settleRemainingPositions(task, positions, currentBalance, trades, endTime, remainingSettlements) + if (remainingSettlements.isNotEmpty()) { + backtestTradeRepository.saveAll(remainingSettlements) + logger.info("回测结束结算剩余持仓,持久化 ${remainingSettlements.size} 笔 SETTLEMENT(CLOSED)") + } // 7. 计算最终统计数据 val statistics = calculateStatistics(trades) @@ -475,18 +515,25 @@ class BacktestExecutionService( /** * 结算已到期的市场 + * @param batchTradesToSave 本批要持久化的交易列表,到期结算(赎回/输)会追加到此列表并随本批一起落库 */ private suspend fun settleExpiredPositions( task: BacktestTask, positions: MutableMap, currentBalance: BigDecimal, trades: MutableList, - currentTime: Long + currentTime: Long, + batchTradesToSave: MutableList ): BigDecimal { var balance = currentBalance for ((positionKey, position) in positions.toList()) { try { + // 仅当市场已到期(结束时间 <= 当前回测时间)时才结算,避免未到期持仓被误结算 + if (position.marketEndDate == null || position.marketEndDate!! > currentTime) { + logger.debug("持仓未到期跳过结算: marketId=${position.marketId}, endDate=${position.marketEndDate}, currentTime=$currentTime") + continue + } // 获取市场当前价格 val marketPrice = marketPriceService.getCurrentMarketPrice( position.marketId, @@ -507,8 +554,7 @@ class BacktestExecutionService( balance += settlementValue - // 记录结算交易 - trades.add(BacktestTrade( + val settlementTrade = BacktestTrade( backtestTaskId = task.id!!, tradeTime = currentTime, marketId = position.marketId, @@ -527,7 +573,9 @@ class BacktestExecutionService( profitLoss = profitLoss, balanceAfter = balance, leaderTradeId = null - )) + ) + trades.add(settlementTrade) + batchTradesToSave.add(settlementTrade) // 移除已结算的持仓 positions.remove(positionKey) @@ -540,14 +588,16 @@ class BacktestExecutionService( } /** - * 结算未到期持仓 + * 结算未到期持仓(回测结束时剩余持仓按均价平仓) + * @param settlementsToSave 本批结算记录会追加到此列表,调用方需落库 */ private suspend fun settleRemainingPositions( task: BacktestTask, positions: MutableMap, currentBalance: BigDecimal, trades: MutableList, - currentTime: Long + currentTime: Long, + settlementsToSave: MutableList ): BigDecimal { var balance = currentBalance @@ -561,8 +611,7 @@ class BacktestExecutionService( balance += settlementValue - // 记录平仓交易 - trades.add(BacktestTrade( + val closedTrade = BacktestTrade( backtestTaskId = task.id!!, tradeTime = currentTime, marketId = position.marketId, @@ -577,7 +626,9 @@ class BacktestExecutionService( profitLoss = profitLoss, balanceAfter = balance, leaderTradeId = null - )) + ) + trades.add(closedTrade) + settlementsToSave.add(closedTrade) } positions.clear() diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestPollingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestPollingService.kt index 3937b21..8d55a1d 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestPollingService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestPollingService.kt @@ -91,32 +91,9 @@ class BacktestPollingService( } 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) + // 使用 start 游标分页,恢复时由 lastProcessedTradeTime 决定从何时开始拉取 + logger.info("执行回测任务: taskId=${currentTask.id}(游标分页,limit=500)") + executionService.executeBacktest(currentTask, page = 0, size = 500) } } catch (e: Exception) { logger.error("回测任务执行失败: taskId=${taskToExecute.id}", e) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestService.kt index 56a36d2..4ebc039 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestService.kt @@ -343,6 +343,50 @@ class BacktestService( Result.failure(e) } } + + /** + * 按当前配置重新测试:基于已完成的回测任务创建一份相同配置的新任务(名称可修改) + */ + @Transactional + fun rerunBacktestTask(request: BacktestRerunRequest): Result { + return try { + val source = backtestTaskRepository.findById(request.id).orElse(null) + ?: return Result.failure(IllegalArgumentException("回测任务不存在")) + + if (source.status != "COMPLETED") { + return Result.failure(IllegalStateException("仅支持对已完成的回测任务重新测试")) + } + + val newTaskName = request.taskName?.trim()?.takeIf { it.isNotEmpty() } + ?: "${source.taskName} (副本)" + + val newTask = BacktestTask( + taskName = newTaskName, + leaderId = source.leaderId, + initialBalance = source.initialBalance, + backtestDays = source.backtestDays, + startTime = source.startTime, + status = "PENDING", + copyMode = source.copyMode, + copyRatio = source.copyRatio, + fixedAmount = source.fixedAmount, + maxOrderSize = source.maxOrderSize, + minOrderSize = source.minOrderSize, + maxDailyLoss = source.maxDailyLoss, + maxDailyOrders = source.maxDailyOrders, + supportSell = source.supportSell, + keywordFilterMode = source.keywordFilterMode, + keywords = source.keywords + ) + + backtestTaskRepository.save(newTask) + val leader = leaderRepository.findById(newTask.leaderId).orElse(null) + Result.success(newTask.toDto(leader)) + } catch (e: Exception) { + logger.error("按配置重新测试失败", e) + Result.failure(e) + } + } } /** diff --git a/backend/src/main/resources/i18n/messages_en.properties b/backend/src/main/resources/i18n/messages_en.properties index 5870bf8..ca6c696 100644 --- a/backend/src/main/resources/i18n/messages_en.properties +++ b/backend/src/main/resources/i18n/messages_en.properties @@ -262,6 +262,7 @@ 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.backtest.task_not_completed=Re-run is only supported for completed backtest tasks 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 @@ -272,6 +273,7 @@ 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 +error.server.backtest_rerun_failed=Failed to re-run backtest with same config # Backtest Management backtest.title=Backtest Management backtest.create_task=Create Backtest diff --git a/backend/src/main/resources/i18n/messages_zh_CN.properties b/backend/src/main/resources/i18n/messages_zh_CN.properties index 4a960a0..53ba493 100644 --- a/backend/src/main/resources/i18n/messages_zh_CN.properties +++ b/backend/src/main/resources/i18n/messages_zh_CN.properties @@ -262,6 +262,7 @@ error.backtest.leader_not_found=Leader不存在 error.backtest.days_invalid=回测天数必须在 1-15 天之间 error.backtest.initial_balance_invalid=初始金额无效 error.backtest.task_running=回测任务正在运行,无法删除 +error.backtest.task_not_completed=仅支持对已完成的回测任务重新测试 error.server.backtest_create_failed=创建回测任务失败 error.server.backtest_update_failed=更新回测任务失败 error.server.backtest_delete_failed=删除回测任务失败 @@ -272,6 +273,7 @@ error.server.backtest_execute_failed=回测执行失败 error.server.backtest_historical_data_fetch_failed=历史数据获取失败 error.server.backtest_stop_failed=停止回测任务失败 error.server.backtest_retry_failed=重试回测任务失败 +error.server.backtest_rerun_failed=按配置重新测试失败 # 回测管理 backtest.title=回测管理 backtest.create_task=新增回测 diff --git a/backend/src/main/resources/i18n/messages_zh_TW.properties b/backend/src/main/resources/i18n/messages_zh_TW.properties index 836ef33..8dafb38 100644 --- a/backend/src/main/resources/i18n/messages_zh_TW.properties +++ b/backend/src/main/resources/i18n/messages_zh_TW.properties @@ -262,6 +262,7 @@ error.backtest.leader_not_found=Leader不存在 error.backtest.days_invalid=回測天數必須在 1-15 天之間 error.backtest.initial_balance_invalid=初始金額無效 error.backtest.task_running=回測任務正在運行,無法刪除 +error.backtest.task_not_completed=僅支援對已完成的回測任務重新測試 error.server.backtest_create_failed=創建回測任務失敗 error.server.backtest_update_failed=更新回測任務失敗 error.server.backtest_delete_failed=刪除回測任務失敗 @@ -272,6 +273,7 @@ error.server.backtest_execute_failed=回測執行失敗 error.server.backtest_historical_data_fetch_failed=歷史數據獲取失敗 error.server.backtest_stop_failed=停止回測任務失敗 error.server.backtest_retry_failed=重試回測任務失敗 +error.server.backtest_rerun_failed=依配置重新測試失敗 # 回測管理 backtest.title=回測管理 backtest.create_task=新增回測 diff --git a/docs/zh/backtest/DATA_API_CURSOR_PAGINATION.md b/docs/zh/backtest/DATA_API_CURSOR_PAGINATION.md new file mode 100644 index 0000000..13b7622 --- /dev/null +++ b/docs/zh/backtest/DATA_API_CURSOR_PAGINATION.md @@ -0,0 +1,45 @@ +# Polymarket Data API 游标分页验证 + +回测拉取 Leader 历史交易改用 **start 游标分页**(不再使用 offset),避免 `offset` 过大(如 3100+)时 API 报错。 + +## 规则 + +- `limit` 固定为 500(快速验证可用 50)。 +- 首次请求:`start` = 回测开始时间(秒),`end` = 回测结束时间(秒)。 +- 若本批返回 **500 条**:取本批中**最大 timestamp**,下一页 `start = max_timestamp`(**不加 1**:同一秒可能有多笔订单,会漏单)。 +- 若本批 **不足 500 条**:视为最后一页,不再请求。 +- 下一页会与上一页在「最大 timestamp」这一秒重叠,必须按 **tradeId(transactionHash)去重**。 + +## 快速验证(limit=50) + +```bash +# 环境变量(替换为实际值) +USER="0x1979ae6b7e6534de9c4539d0c205e582ca637c9d" +START=1769961432 +END=1770566207 +LIMIT=50 + +# 第 1 页(游标分页不传 offset) +curl -s "https://data-api.polymarket.com/activity?user=${USER}&limit=${LIMIT}&type=TRADE&start=${START}&end=${END}&sortBy=TIMESTAMP&sortDirection=ASC" | jq 'length' +# 若输出 50,则取本批最大 timestamp 作为下一页 start(不加 1,同一秒可能多笔) +curl -s "https://data-api.polymarket.com/activity?user=${USER}&limit=${LIMIT}&type=TRADE&start=${START}&end=${END}&sortBy=TIMESTAMP&sortDirection=ASC" | jq 'max_by(.timestamp) | .timestamp' +# 假设得到 1770000000,则下一页 start=1770000000(与上一批重叠,需按 tradeId 去重) + +# 第 2 页(游标分页,不使用 offset) +NEXT_START=1770000000 +curl -s "https://data-api.polymarket.com/activity?user=${USER}&limit=${LIMIT}&type=TRADE&start=${NEXT_START}&end=${END}&sortBy=TIMESTAMP&sortDirection=ASC" | jq 'length' +# 若输出 < 50,则为最后一页 +``` + +## 单条命令示例(第 1 页,limit=50) + +```bash +curl -s "https://data-api.polymarket.com/activity?user=0x1979ae6b7e6534de9c4539d0c205e582ca637c9d&limit=50&type=TRADE&start=1769961432&end=1770566207&sortBy=TIMESTAMP&sortDirection=ASC" +``` + +注意:**不要传 offset**,下一页 `start = 上一批最大 timestamp`(不加 1),同一秒多笔订单不丢,重叠记录按 tradeId 去重。 + +## 代码位置 + +- 拉取批次:`BacktestDataService.getLeaderHistoricalTradesBatch()`,返回 `LeaderTradesBatchResult(trades, nextCursorSeconds)`。 +- 执行循环:`BacktestExecutionService.executeBacktest()`,按 `cursorSeconds` 循环,并用 `seenTradeIds` 去重。 diff --git a/frontend/src/components/LeaderSelect.tsx b/frontend/src/components/LeaderSelect.tsx new file mode 100644 index 0000000..099ce87 --- /dev/null +++ b/frontend/src/components/LeaderSelect.tsx @@ -0,0 +1,67 @@ +import React from 'react' +import { Select } from 'antd' +import type { Leader } from '../types' + +const { Option } = Select + +interface LeaderSelectProps { + value?: number + onChange?: (value: number | undefined) => void + onSelectChange?: (value: number | undefined) => void // 选择变化时的回调 + leaders: Leader[] + placeholder?: string + disabled?: boolean + showSearch?: boolean + allowClear?: boolean + notFoundContent?: React.ReactNode +} + +const LeaderSelect: React.FC = ({ + value, + onChange, + onSelectChange, + leaders, + placeholder, + disabled, + showSearch = true, + allowClear = false, + notFoundContent +}) => { + // 处理选择变化 + const handleChange = (val: number | undefined) => { + if (onChange) { + onChange(val) + } + if (onSelectChange) { + onSelectChange(val) + } + } + + return ( + + ) +} + +export default LeaderSelect diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 7da1381..bb8f0b6 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -1363,6 +1363,11 @@ "supportSellHint": "Whether to follow Leader sell orders", "sortBy": "Sort By", "sortOrder": "Sort Order", - "createdAt": "Created At" + "createdAt": "Created At", + "rerun": "Re-run", + "rerunConfirm": "Create a new backtest task with the same config?", + "rerunTaskNamePlaceholder": "New task name (leave empty for \"Original name (copy)\")", + "rerunSuccess": "New backtest task created", + "rerunFailed": "Re-run failed" } } \ No newline at end of file diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 2b6e185..3255910 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -1363,6 +1363,11 @@ "supportSellHint": "是否跟随 Leader 卖出", "sortBy": "排序字段", "sortOrder": "排序顺序", - "createdAt": "创建时间" + "createdAt": "创建时间", + "rerun": "重新测试", + "rerunConfirm": "将按当前配置创建新的回测任务,是否继续?", + "rerunTaskNamePlaceholder": "新任务名称(留空使用「原名称 (副本)」)", + "rerunSuccess": "已创建新回测任务", + "rerunFailed": "重新测试失败" } } \ No newline at end of file diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index f1e9bd5..f36aa06 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -1363,6 +1363,11 @@ "supportSellHint": "是否跟隨 Leader 賣出", "sortBy": "排序欄位", "sortOrder": "排序順序", - "createdAt": "創建時間" + "createdAt": "創建時間", + "rerun": "重新測試", + "rerunConfirm": "將按當前配置創建新的回測任務,是否繼續?", + "rerunTaskNamePlaceholder": "新任務名稱(留空使用「原名稱 (副本)」)", + "rerunSuccess": "已創建新回測任務", + "rerunFailed": "重新測試失敗" } } \ No newline at end of file diff --git a/frontend/src/pages/BacktestList.tsx b/frontend/src/pages/BacktestList.tsx index 997ab5c..a9ed723 100644 --- a/frontend/src/pages/BacktestList.tsx +++ b/frontend/src/pages/BacktestList.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react' import { Table, Card, Button, Select, Tag, Space, Modal, message, Row, Col, Form, Input, InputNumber, Switch, Statistic, Descriptions } from 'antd' import { useTranslation } from 'react-i18next' -import { PlusOutlined, ReloadOutlined, DeleteOutlined, StopOutlined, EyeOutlined, RedoOutlined, CopyOutlined } from '@ant-design/icons' +import { PlusOutlined, ReloadOutlined, DeleteOutlined, StopOutlined, EyeOutlined, RedoOutlined, CopyOutlined, SyncOutlined } from '@ant-design/icons' import { formatUSDC } from '../utils' import { backtestService, apiService } from '../services/api' import type { BacktestTaskDto, BacktestListRequest, BacktestCreateRequest, BacktestTradeDto } from '../types/backtest' @@ -9,8 +9,7 @@ import type { Leader } from '../types' import { useMediaQuery } from 'react-responsive' import AddCopyTradingModal from './CopyTradingOrders/AddModal' import BacktestChart from './BacktestChart' - -const { Option } = Select +import LeaderSelect from '../components/LeaderSelect' const BacktestList: React.FC = () => { const { t } = useTranslation() @@ -36,6 +35,12 @@ const BacktestList: React.FC = () => { const [addCopyTradingModalVisible, setAddCopyTradingModalVisible] = useState(false) const [preFilledConfig, setPreFilledConfig] = useState(null) + // 重新测试 Modal 相关状态 + const [rerunModalVisible, setRerunModalVisible] = useState(false) + const [rerunTask, setRerunTask] = useState(null) + const [rerunTaskName, setRerunTaskName] = useState('') + const [rerunLoading, setRerunLoading] = useState(false) + // 任务详情 Modal 相关状态 const [detailModalVisible, setDetailModalVisible] = useState(false) const [detailTask, setDetailTask] = useState(null) @@ -130,6 +135,38 @@ const BacktestList: React.FC = () => { }) } + // 按配置重新测试(仅已完成任务) + const handleRerun = (task: BacktestTaskDto) => { + setRerunTask(task) + setRerunTaskName(`${task.taskName} (副本)`) + setRerunModalVisible(true) + } + + const handleRerunSubmit = async () => { + if (!rerunTask) return + setRerunLoading(true) + try { + const response = await backtestService.rerun({ + id: rerunTask.id, + taskName: rerunTaskName.trim() || undefined + }) + if (response.data.code === 0) { + message.success(t('backtest.rerunSuccess')) + setRerunModalVisible(false) + setRerunTask(null) + setRerunTaskName('') + fetchTasks() + } else { + message.error(response.data.msg || t('backtest.rerunFailed')) + } + } catch (error) { + console.error('Rerun backtest failed:', error) + message.error(t('backtest.rerunFailed')) + } finally { + setRerunLoading(false) + } + } + // 重试任务 const handleRetry = (id: number) => { Modal.confirm({ @@ -545,14 +582,24 @@ const BacktestList: React.FC = () => { {t('common.viewDetail')} {record.status === 'COMPLETED' && ( - + <> + + + )} {record.status === 'RUNNING' && (