From 96fbc3f7205904b65351744597b73435c882002b Mon Sep 17 00:00:00 2001 From: WrBug Date: Sat, 31 Jan 2026 20:45:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9B=9E=E6=B5=8B=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E4=BC=98=E5=8C=96=20-=20=E6=B8=85=E7=90=86=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E3=80=81=E7=A7=BB=E5=8A=A8=E7=AB=AF=E9=80=82=E9=85=8D=E3=80=81?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 主要改动 ### 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秒) - 移动端用户体验显著提升 --- .../controller/backtest/BacktestController.kt | 32 + .../wrbug/polymarketbot/dto/BacktestDto.kt | 26 +- .../polymarketbot/entity/BacktestTask.kt | 47 +- .../wrbug/polymarketbot/enums/ErrorCode.kt | 3 +- .../service/backtest/BacktestDataService.kt | 172 +--- .../backtest/BacktestExecutionService.kt | 897 ++++++++++-------- .../backtest/BacktestPollingService.kt | 54 +- .../service/backtest/BacktestService.kt | 58 +- .../service/common/MarketPriceService.kt | 64 +- .../V28__add_backtest_resume_fields.sql | 14 + .../V29__drop_unused_backtest_fields.sql | 11 + .../resources/i18n/messages_en.properties | 2 +- .../resources/i18n/messages_zh_CN.properties | 2 +- .../resources/i18n/messages_zh_TW.properties | 2 +- frontend/src/locales/en/common.json | 11 +- frontend/src/locales/zh-CN/common.json | 11 +- frontend/src/locales/zh-TW/common.json | 11 +- frontend/src/pages/BacktestCreate.tsx | 422 -------- frontend/src/pages/BacktestDetail.tsx | 62 +- frontend/src/pages/BacktestList.tsx | 189 ++-- frontend/src/services/api.ts | 7 +- frontend/src/types/backtest.ts | 13 + 22 files changed, 911 insertions(+), 1199 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V28__add_backtest_resume_fields.sql create mode 100644 backend/src/main/resources/db/migration/V29__drop_unused_backtest_fields.sql delete mode 100644 frontend/src/pages/BacktestCreate.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 f7be812..ff2429b 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 @@ -148,6 +148,7 @@ class BacktestController( 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)) @@ -189,5 +190,36 @@ class BacktestController( ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_STOP_FAILED, e.message, messageSource)) } } + + /** + * 重试回测任务 + */ + @PostMapping("/tasks/retry") + fun retryBacktestTask(@RequestBody request: BacktestRetryRequest): ResponseEntity> { + 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)) + } + } } 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 6a475c1..1b9e19d 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/BacktestDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/BacktestDto.kt @@ -18,17 +18,10 @@ data class BacktestCreateRequest( val minOrderSize: String? = null, val maxDailyLoss: String? = null, val maxDailyOrders: Int? = null, - val priceTolerance: String? = null, // 百分比 - val delaySeconds: Int? = null, val supportSell: Boolean? = null, - val minOrderDepth: String? = null, - val maxSpread: String? = null, - val minPrice: String? = null, - val maxPrice: String? = null, - val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用 val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单) val keywords: List? = null, // 关键字列表 - val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),NULL表示不启用 + val pageForResume: Int? = null // 用于恢复中断任务,从指定页码开始获取历史数据(从1开始) ) /** @@ -80,6 +73,13 @@ data class BacktestDeleteRequest( val id: Long // 回测任务ID ) +/** + * 回测任务重试请求 + */ +data class BacktestRetryRequest( + val id: Long // 回测任务ID +) + /** * 回测任务列表响应 */ @@ -154,17 +154,9 @@ data class BacktestConfigDto( val minOrderSize: String, val maxDailyLoss: String, val maxDailyOrders: Int, - val priceTolerance: String, - val delaySeconds: Int, val supportSell: Boolean, - val minOrderDepth: String?, - val maxSpread: String?, - val minPrice: String?, - val maxPrice: String?, - val maxPositionValue: String?, val keywordFilterMode: String?, - val keywords: List?, - val maxMarketEndDate: Long? + val keywords: List? ) /** 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 fbd4899..667f9c9 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/BacktestTask.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/BacktestTask.kt @@ -25,13 +25,13 @@ data class BacktestTask( val initialBalance: BigDecimal, @Column(name = "final_balance", precision = 20, scale = 8) - val finalBalance: BigDecimal? = null, + var finalBalance: BigDecimal? = null, @Column(name = "profit_amount", precision = 20, scale = 8) - val profitAmount: BigDecimal? = null, + var profitAmount: BigDecimal? = null, @Column(name = "profit_rate", precision = 10, scale = 4) - val profitRate: BigDecimal? = null, // 收益率(%) + var profitRate: BigDecimal? = null, // 收益率(%) @Column(name = "backtest_days", nullable = false) val backtestDays: Int, @@ -40,7 +40,7 @@ data class BacktestTask( val startTime: Long, // 回测开始时间(历史时间) @Column(name = "end_time") - val endTime: Long? = null, // 回测结束时间(历史时间) + var endTime: Long? = null, // 回测结束时间(历史时间) // 跟单配置 (复制CopyTrading表结构,但不包含 max_position_count) @Column(name = "copy_mode", nullable = false, length = 10) @@ -64,45 +64,21 @@ data class BacktestTask( @Column(name = "max_daily_orders", nullable = false) val maxDailyOrders: Int = 100, - @Column(name = "price_tolerance", nullable = false, precision = 5, scale = 2) - val priceTolerance: BigDecimal = "5".toSafeBigDecimal(), // 百分比 - - @Column(name = "delay_seconds", nullable = false) - val delaySeconds: Int = 0, - @Column(name = "support_sell", nullable = false) val supportSell: Boolean = true, - @Column(name = "min_order_depth", precision = 20, scale = 8) - val minOrderDepth: BigDecimal? = null, - - @Column(name = "max_spread", precision = 20, scale = 8) - val maxSpread: BigDecimal? = null, - - @Column(name = "min_price", precision = 20, scale = 8) - val minPrice: BigDecimal? = null, - - @Column(name = "max_price", precision = 20, scale = 8) - val maxPrice: BigDecimal? = null, - - @Column(name = "max_position_value", precision = 20, scale = 8) - val maxPositionValue: BigDecimal? = null, - @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 = "max_market_end_date") - val maxMarketEndDate: Long? = null, - // 统计字段 @Column(name = "avg_holding_time") - val avgHoldingTime: Long? = null, // 平均持仓时间(毫秒) + var avgHoldingTime: Long? = null, // 平均持仓时间(毫秒) @Column(name = "data_source", length = 50) - val dataSource: String = "MIXED", // INTERNAL/API/MIXED + var dataSource: String = "MIXED", // INTERNAL/API/MIXED // 执行状态 @Column(name = "status", nullable = false, length = 20) @@ -152,6 +128,15 @@ data class BacktestTask( var executionFinishedAt: Long? = null, @Column(name = "updated_at", nullable = false) - var updatedAt: Long = System.currentTimeMillis() + var updatedAt: Long = System.currentTimeMillis(), + + @Column(name = "last_processed_trade_time") + var lastProcessedTradeTime: Long? = null, + + @Column(name = "last_processed_trade_index") + var lastProcessedTradeIndex: Int = 0, + + @Column(name = "processed_trade_count") + var processedTradeCount: Int = 0 ) 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 018f3c4..ed1b5d0 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt @@ -247,7 +247,8 @@ enum class ErrorCode( 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_STOP_FAILED(5611, "停止回测任务失败", "error.server.backtest_stop_failed"), + SERVER_BACKTEST_RETRY_FAILED(5612, "重试回测任务失败", "error.server.backtest_retry_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 8ddd646..1ac5709 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 @@ -1,6 +1,8 @@ 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 @@ -22,129 +24,77 @@ class BacktestDataService( private val logger = LoggerFactory.getLogger(BacktestDataService::class.java) /** - * 获取 Leader 历史交易(用于回测) - * - * 策略:直接从 Polymarket Data API 的 activity 接口获取 + * 分页获取 Leader 历史交易(用于回测恢复) + * 支持重试机制:最多重试5次,每次间隔1秒 * * @param leaderId Leader ID * @param startTime 开始时间(毫秒时间戳) * @param endTime 结束时间(毫秒时间戳) + * @param page 页码 (从 0 开始) + * @param size 每页数量 * @return 历史交易列表 + * @throws Exception 重试5次后仍然失败时抛出异常 */ - suspend fun getLeaderHistoricalTrades( + suspend fun getLeaderHistoricalTradesForPage( leaderId: Long, startTime: Long, - endTime: Long - ): List { - return try { - logger.info("获取 Leader 历史交易: leaderId=$leaderId, startTime=$startTime, endTime=$endTime") + endTime: Long, + page: Int, + size: Int + ): List { + 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") - - // 2. 从 Data API 的 activity 接口获取 - val apiTrades = fetchFromActivityApi(leader, startTime, endTime) - - logger.info("共获取 ${apiTrades.size} 条历史交易") - return apiTrades - } catch (e: Exception) { - logger.error("获取 Leader 历史交易失败", e) - throw e - } - } - - /** - * 从 Data API 的 activity 接口获取历史交易 - * 实现完整的分页逻辑,获取所有历史交易记录 - * - * @param leader Leader 实体 - * @param startTime 开始时间(毫秒时间戳) - * @param endTime 结束时间(毫秒时间戳) - * @return 历史交易列表 - */ - private suspend fun fetchFromActivityApi( - leader: Leader, - startTime: Long, - endTime: Long - ): List { - logger.info("从 Data API activity 接口获取 Leader 历史交易: leaderId=${leader.id}, timeRange=${startTime} - $endTime") + // 1. 验证 Leader 是否存在 + val leader = leaderRepository.findById(leaderId).orElse(null) + ?: throw IllegalArgumentException("Leader 不存在: $leaderId") val dataApi = retrofitFactory.createDataApi() - val allTrades = mutableListOf() - val seenTradeKeys = mutableSetOf() // 用于内存去重 - var offset = 0 - val pageSize = 100 // 每页最多 100 条 - var hasMore = true - val MAX_OFFSET = 10000 // 最大偏移量(防止无限循环,15天通常不会超过) + val offset = page * size + val maxRetries = 5 + val retryDelay = 1000L // 1秒 - // 分页获取所有交易记录 - while (hasMore && offset < MAX_OFFSET) { + // 2. 重试机制:最多重试5次 + var lastException: Exception? = null + for (attempt in 1..maxRetries) { try { - logger.debug("获取第 ${offset / pageSize + 1} 页数据,offset=$offset, limit=$pageSize") - val response = dataApi.getUserActivity( user = leader.leaderAddress, - type = listOf("TRADE"), // 只获取交易类型 - start = startTime / 1000, // Data API 使用秒级时间戳 + type = listOf("TRADE"), + start = startTime / 1000, end = endTime / 1000, - limit = pageSize, + limit = size, offset = offset, sortBy = "timestamp", sortDirection = "asc" ) if (!response.isSuccessful || response.body() == null) { - logger.error("从 Data API 获取用户活动失败: code=${response.code()}, message=${response.message()}") - break + throw Exception("从 Data API 获取用户活动失败: code=${response.code()}, message=${response.message()}") } val activities = response.body()!! + logger.info("成功获取第 $page 页数据,共 ${activities.size} 条交易(第 $attempt 次尝试)") - // 如果返回的数据少于 pageSize,说明没有更多数据了 - if (activities.isEmpty() || activities.size < pageSize) { - hasMore = false - } - - // 转换为 LeaderTrade - val trades = activities.mapNotNull { activity -> + return activities.mapNotNull { activity -> try { - // 只处理 TRADE 类型 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 } - // 验证时间范围(API 可能返回超出范围的数据) - val tradeTimestamp = activity.timestamp * 1000 // 转换为毫秒时间戳 + val tradeTimestamp = activity.timestamp * 1000 if (tradeTimestamp < startTime || tradeTimestamp > endTime) { logger.debug("交易时间超出范围,跳过: timestamp=$tradeTimestamp, range=$startTime - $endTime") return@mapNotNull null } - // 生成唯一键用于去重(transactionHash + conditionId + timestamp + side) - val tradeKey = if (activity.transactionHash != null) { - "${activity.transactionHash}_${activity.conditionId}_${activity.timestamp}_${activity.side}" - } else { - "${activity.timestamp}_${activity.conditionId}_${activity.side}_${activity.price}_${activity.size}" - } - - // 内存去重 - if (seenTradeKeys.contains(tradeKey)) { - logger.debug("发现重复交易,跳过: tradeKey=$tradeKey") - return@mapNotNull null - } - seenTradeKeys.add(tradeKey) - - LeaderTrade( - leaderId = leader.id ?: throw IllegalStateException("Leader ID 不能为空"), - tradeId = activity.transactionHash ?: "${activity.timestamp}_${activity.conditionId}_${activity.side}", // 使用交易哈希或组合键作为 tradeId - marketId = activity.conditionId, // conditionId 就是市场 ID + TradeData( + tradeId = activity.transactionHash ?: "${activity.timestamp}_${activity.conditionId}_${activity.side}", + marketId = activity.conditionId, marketTitle = activity.title, marketSlug = activity.slug, side = activity.side.uppercase(), @@ -153,7 +103,7 @@ class BacktestDataService( price = activity.price.toSafeBigDecimal(), size = activity.size.toSafeBigDecimal(), amount = activity.usdcSize.toSafeBigDecimal(), - tradeTimestamp = tradeTimestamp + timestamp = tradeTimestamp ) } catch (e: Exception) { logger.warn("转换活动数据失败: activity=$activity, error=${e.message}", e) @@ -161,53 +111,21 @@ class BacktestDataService( } } - allTrades.addAll(trades) - logger.debug("已获取 ${trades.size} 条交易,累计 ${allTrades.size} 条") - - // 如果返回的数据少于 pageSize,说明没有更多数据了 - if (activities.size < pageSize) { - hasMore = false - } else { - // 继续获取下一页 - offset += pageSize - } - - // 防止无限循环(最多获取 MAX_OFFSET 条) - if (offset >= MAX_OFFSET) { - logger.warn("已达到最大分页限制(${MAX_OFFSET} 条),停止获取") - break - } - - // 添加延迟,避免请求过快 - if (hasMore) { - delay(200) // 200ms 延迟 - } - } catch (e: Exception) { - logger.error("从 Data API 获取用户活动失败: ${e.message}", e) - break + lastException = e + logger.warn("第 $attempt/$maxRetries 次尝试获取第 $page 页数据失败: ${e.message}") + + // 如果不是最后一次尝试,则等待后重试 + if (attempt < maxRetries) { + logger.info("等待 $retryDelay 毫秒后重试...") + delay(retryDelay) + } } } - logger.info("分页获取完成,共获取 ${allTrades.size} 条历史交易") - return allTrades + // 重试5次后仍然失败,抛出异常 + val errorMsg = "重试 $maxRetries 次后仍然失败获取第 $page 页数据" + logger.error(errorMsg, lastException) + throw Exception(errorMsg, lastException) } } - -/** - * Leader 历史交易数据(回测使用) - */ -data class LeaderTrade( - val leaderId: Long, - val tradeId: String, // 交易唯一标识 - val marketId: String, - val marketTitle: String?, - val marketSlug: String?, - val side: String, // BUY 或 SELL - val outcome: String?, - val outcomeIndex: Int?, - val price: BigDecimal, - val size: BigDecimal, - val amount: BigDecimal, // 交易金额(price × size) - val tradeTimestamp: Long // 交易时间戳(毫秒) -) 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 36f18c0..e70ad20 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 @@ -1,5 +1,7 @@ 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 @@ -7,9 +9,6 @@ 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.service.copytrading.configs.FilterResult -import com.wrbug.polymarketbot.service.backtest.BacktestDataService -import com.wrbug.polymarketbot.service.backtest.LeaderTrade import com.wrbug.polymarketbot.util.toSafeBigDecimal import org.slf4j.LoggerFactory import org.springframework.stereotype.Service @@ -17,11 +16,8 @@ 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, @@ -45,12 +41,13 @@ class BacktestExecutionService( ) /** - * 将 BacktestTask 转换为 CopyTrading 对象(用于过滤检查) + * 将回测任务转换为虚拟的 CopyTrading 配置用于执行 + * 注意:回测场景使用历史数据,不需要实时跟单的相关配置 */ private fun taskToCopyTrading(task: BacktestTask): CopyTrading { return CopyTrading( id = task.id, - accountId = 0L, // 回测不需要账户ID + accountId = 0L, leaderId = task.leaderId, enabled = true, copyMode = task.copyMode, @@ -60,36 +57,33 @@ class BacktestExecutionService( minOrderSize = task.minOrderSize, maxDailyLoss = task.maxDailyLoss, maxDailyOrders = task.maxDailyOrders, - priceTolerance = task.priceTolerance, - delaySeconds = task.delaySeconds, + priceTolerance = BigDecimal.ZERO, // 回测使用历史价格,不需要容忍度 + delaySeconds = 0, // 回测按时间线执行,无需延迟 pollIntervalSeconds = 5, useWebSocket = false, websocketReconnectInterval = 5000, websocketMaxRetries = 10, supportSell = task.supportSell, - minOrderDepth = task.minOrderDepth, - maxSpread = task.maxSpread, - minPrice = task.minPrice, - maxPrice = task.maxPrice, - maxPositionValue = task.maxPositionValue, + minOrderDepth = null, // 回测无实时订单簿数据 + maxSpread = null, // 回测无实时价差数据 keywordFilterMode = task.keywordFilterMode, keywords = task.keywords, configName = null, pushFailedOrders = false, pushFilteredOrders = false, - maxMarketEndDate = task.maxMarketEndDate, createdAt = task.createdAt, updatedAt = task.updatedAt ) } /** - * 执行回测任务 + * 执行回测任务(支持分页和恢复) + * 自动处理所有页面的数据,支持中断恢复 */ @Transactional - suspend fun executeBacktest(task: BacktestTask) { - return try { - logger.info("开始执行回测任务: taskId=${task.id}, taskName=${task.taskName}") + 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" @@ -99,8 +93,12 @@ class BacktestExecutionService( // 2. 初始化 var currentBalance = task.initialBalance - val positions = mutableMapOf() // marketId + outcomeIndex -> Position + val positions = mutableMapOf() val trades = mutableListOf() + // 每日订单数缓存:key为日期字符串(yyyy-MM-dd),value为当天的 BUY 订单数 + val dailyOrderCountCache = mutableMapOf() + // 每日亏损缓存:key为日期字符串(yyyy-MM-dd),value为当天的累计亏损金额 + val dailyLossCache = mutableMapOf() // 3. 计算回测时间范围 val endTime = System.currentTimeMillis() @@ -109,204 +107,310 @@ class BacktestExecutionService( logger.info("回测时间范围: ${formatTimestamp(startTime)} - ${formatTimestamp(endTime)}, " + "初始余额: ${task.initialBalance.toPlainString()}") - // 4. 获取 Leader 历史交易 - val leaderTrades = backtestDataService.getLeaderHistoricalTrades( - task.leaderId, - startTime, - endTime - ).sortedBy { it.tradeTimestamp } + // 4. 恢复机制:如果有恢复点,计算从哪一页开始 + val startPage = if (task.lastProcessedTradeIndex != null && task.lastProcessedTradeIndex >= 0) { + val lastProcessedIndex = task.lastProcessedTradeIndex + val calculatedPage = (lastProcessedIndex / size) + 1 - logger.info("获取到 ${leaderTrades.size} 条历史交易") + // 特殊情况:如果lastProcessedTradeIndex刚好是100的倍数减1(比如99,199,299...) + // 说明该页已经完全处理,应该从下一页开始 + val nextPage = if (lastProcessedIndex % size == size - 1) { + calculatedPage + 1 + } else { + calculatedPage + } - // 5. 按时间顺序回放交易 - var processedCount = 0 - val totalTrades = leaderTrades.size + logger.info("恢复任务:已处理索引=$lastProcessedIndex, 从第 $nextPage 页开始") + nextPage + } else { + logger.info("新任务:从第1页开始") + 1 + } - for (leaderTrade in leaderTrades) { - // 检查是否需要停止 - if (task.status == "STOPPED") { - logger.info("回测任务已被停止") + // 5. 分页获取和处理交易数据 + var currentPage = maxOf(startPage, page) + var globalIndex = if (currentPage > 1 && task.lastProcessedTradeIndex != null) { + task.lastProcessedTradeIndex + 1 + } else { + 0 + } + + logger.info("开始分页处理:起始页=$currentPage, 起始索引=$globalIndex") + + while (true) { + // 定期从数据库重新加载任务状态,确保能及时响应停止操作 + val currentTaskStatus = backtestTaskRepository.findById(task.id!!).orElse(null) + if (currentTaskStatus == null || currentTaskStatus.status != "RUNNING") { + logger.info("回测任务状态已变更: ${currentTaskStatus?.status},停止执行") break } - processedCount++ - val progress = (processedCount * 100) / totalTrades - if (progress >= task.progress + 5) { - task.progress = progress - backtestTaskRepository.save(task) - } + logger.info("正在获取第 $currentPage 页数据...") + + // 每页使用独立的交易列表,避免跨页重复保存 + val currentPageTrades = mutableListOf() try { - // 5.1 实时检查并结算已到期的市场 - currentBalance = settleExpiredPositions(task, positions, currentBalance, trades, leaderTrade.tradeTimestamp) + // 获取当前页的交易数据(支持重试5次) + val pageTrades = backtestDataService.getLeaderHistoricalTradesForPage( + task.leaderId, + startTime, + endTime, + currentPage, + size + ) - // 5.2 检查余额和持仓状态 - if (currentBalance < BigDecimal.ONE && positions.isEmpty()) { - logger.info("余额不足且无持仓,停止回测: $currentBalance") + if (pageTrades.isEmpty()) { + logger.info("第 $currentPage 页无数据,所有数据处理完成") break } - // 如果余额不足但有持仓,记录日志但继续处理 - if (currentBalance < BigDecimal.ONE && positions.isNotEmpty()) { - logger.info("余额不足 $currentBalance,但还有 ${positions.size} 个持仓,继续处理") - } + logger.info("第 $currentPage 页获取到 ${pageTrades.size} 条交易") - // 5.3 应用过滤规则 - val copyTrading = taskToCopyTrading(task) - val filterResult = copyTradingFilterService.checkFilters( - copyTrading, - tokenId = "", // 回测不需要 tokenId - tradePrice = leaderTrade.price, - copyOrderAmount = null, - marketId = leaderTrade.marketId, - marketTitle = leaderTrade.marketTitle, - marketEndDate = null, - outcomeIndex = leaderTrade.outcomeIndex - ) + // 处理当前页的交易 + for (localIndex in pageTrades.indices) { + val leaderTrade = pageTrades[localIndex] + val index = globalIndex + localIndex - if (!filterResult.isPassed) { - continue - } - - // 5.4 每日订单数检查 - val dailyOrderCount = trades.count { - isSameDay(it.tradeTime, leaderTrade.tradeTimestamp) - } - - if (dailyOrderCount >= task.maxDailyOrders) { - logger.info("已达到每日最大订单数限制: $dailyOrderCount / ${task.maxDailyOrders}") - continue - } - - // 5.5 价格容忍度检查 - 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( - leaderTrade.marketId, - leaderTrade.outcomeIndex ?: 0 - ) - - val currentPriceDecimal = currentPrice.toSafeBigDecimal() - if (currentPriceDecimal < minPrice || currentPriceDecimal > maxPrice) { - logger.info("价格超出容忍度范围: 当前=$currentPrice, 可用范围=[$minPrice, $maxPrice]") - continue + // 更新进度 + val progress = if (pageTrades.size > 0) { + (localIndex * 100) / pageTrades.size + } else { + 0 } - } - - // 5.6 计算跟单金额 - val followAmount = calculateFollowAmount(task, leaderTrade) - - if (leaderTrade.side == "BUY") { - // 买入逻辑 - val quantity = followAmount.divide(leaderTrade.price, 8, java.math.RoundingMode.DOWN) - val totalCost = followAmount // 不计算手续费 - - // 严格模式: 仅检查当前可用余额 - if (totalCost > currentBalance) { - logger.info("余额不足以执行买入订单: 需要 $totalCost, 可用 $currentBalance") - continue + if (progress > task.progress) { + task.progress = progress + task.processedTradeCount = index + 1 + backtestTaskRepository.save(task) } - // 更新余额和持仓 - 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() - ) + try { + // 5.1 实时检查并结算已到期的市场 + currentBalance = settleExpiredPositions(task, positions, currentBalance, trades, leaderTrade.timestamp) - // 记录交易 - trades.add(BacktestTrade( - backtestTaskId = task.id!!, - tradeTime = leaderTrade.tradeTimestamp, - marketId = leaderTrade.marketId, - marketTitle = leaderTrade.marketTitle, - side = "BUY", - outcome = leaderTrade.outcome ?: leaderTrade.outcomeIndex.toString(), - outcomeIndex = leaderTrade.outcomeIndex, - quantity = quantity, - price = leaderTrade.price.toSafeBigDecimal(), - amount = followAmount, - fee = BigDecimal.ZERO, - profitLoss = null, - balanceAfter = currentBalance, - leaderTradeId = leaderTrade.tradeId - )) - - } 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 // 全部卖出 + // 5.2 检查余额和持仓状态 + if (currentBalance < BigDecimal.ZERO) { + logger.info("余额已为负,直接终止回测: $currentBalance") + break } - } else { - position.quantity // 固定金额模式全部卖出 + 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) } - - // 确保不超过持仓数量 - val actualSellQuantity = if (sellQuantity > position.quantity) { - position.quantity - } else { - sellQuantity - } - - val sellAmount = actualSellQuantity.multiply(leaderTrade.price.toSafeBigDecimal()) - val netAmount = sellAmount // 不扣除手续费 - - // 计算盈亏 - val cost = actualSellQuantity.multiply(position.avgPrice) - val profitLoss = netAmount.subtract(cost) - - // 更新余额和持仓 - currentBalance += netAmount - position.quantity -= actualSellQuantity - if (position.quantity <= BigDecimal.ZERO) { - positions.remove(positionKey) - } - - // 记录交易 - trades.add(BacktestTrade( - backtestTaskId = task.id!!, - tradeTime = leaderTrade.tradeTimestamp, - marketId = leaderTrade.marketId, - marketTitle = leaderTrade.marketTitle, - side = "SELL", - outcome = leaderTrade.outcome ?: leaderTrade.outcomeIndex.toString(), - outcomeIndex = leaderTrade.outcomeIndex, - quantity = actualSellQuantity, - price = leaderTrade.price.toSafeBigDecimal(), - amount = sellAmount, - fee = BigDecimal.ZERO, - profitLoss = profitLoss, - balanceAfter = currentBalance, - leaderTradeId = leaderTrade.tradeId - )) } + + // 保存当前页的所有交易(每页处理完成后保存,避免重复插入) + if (currentPageTrades.isNotEmpty()) { + logger.info("保存第 $currentPage 页的交易数据,共 ${currentPageTrades.size} 笔") + + // 批量保存当前页的交易 + backtestTradeRepository.saveAll(currentPageTrades) + + // 更新当前页的最后处理信息 + val lastTradeInPage = currentPageTrades.lastOrNull() + if (lastTradeInPage != null) { + task.lastProcessedTradeTime = lastTradeInPage.tradeTime + task.lastProcessedTradeIndex = globalIndex + pageTrades.size - 1 + task.processedTradeCount = task.lastProcessedTradeIndex + 1 + task.finalBalance = currentBalance + backtestTaskRepository.save(task) + + logger.info("第 $currentPage 页处理完成,更新索引: ${task.lastProcessedTradeIndex}, 总处理数: ${task.processedTradeCount}") + } + } else { + logger.info("第 $currentPage 页没有交易需要保存") + } + + // 将当前页交易添加到全局列表(用于最终统计) + trades.addAll(currentPageTrades) + + // 将当前页交易添加到全局列表(用于最终统计) + trades.addAll(currentPageTrades) + + // 更新全局索引,准备处理下一页 + globalIndex += pageTrades.size + currentPage++ + } catch (e: Exception) { - logger.error("处理交易失败: tradeId=${leaderTrade.tradeId}", e) + logger.error("获取或处理第 $currentPage 页数据失败: ${e.message}", e) + // 重试失败,标记任务为 FAILED + throw e } } - // 6. 处理回测结束时仍未到期的持仓 (兜底处理) + // 6. 处理回测结束时仍未到期的持仓 currentBalance = settleRemainingPositions(task, positions, currentBalance, trades, endTime) // 7. 计算最终统计数据 @@ -320,31 +424,27 @@ class BacktestExecutionService( BigDecimal.ZERO } val finalStatus = if (task.status == "STOPPED") "STOPPED" else "COMPLETED" - val updatedTask = task.copy( - finalBalance = currentBalance, - profitAmount = profitAmount, - profitRate = profitRate, - endTime = endTime, - status = finalStatus, - progress = 100, - totalTrades = trades.size, - buyTrades = trades.count { it.side == "BUY" }, - sellTrades = trades.count { it.side == "SELL" }, - winTrades = statistics.winTrades, - lossTrades = statistics.lossTrades, - winRate = statistics.winRate, - maxProfit = statistics.maxProfit, - maxLoss = statistics.maxLoss, - maxDrawdown = statistics.maxDrawdown, - avgHoldingTime = statistics.avgHoldingTime, - executionFinishedAt = System.currentTimeMillis(), - updatedAt = System.currentTimeMillis() - ) - backtestTaskRepository.save(updatedTask) + 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() - // 9. 批量保存交易记录 - backtestTradeRepository.saveAll(trades) + backtestTaskRepository.save(task) logger.info("回测任务执行完成: taskId=${task.id}, " + "最终余额=${currentBalance.toPlainString()}, " + @@ -357,6 +457,7 @@ class BacktestExecutionService( 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 @@ -374,6 +475,7 @@ class BacktestExecutionService( currentTime: Long ): BigDecimal { var balance = currentBalance + for ((positionKey, position) in positions.toList()) { try { // 获取市场当前价格 @@ -386,9 +488,9 @@ class BacktestExecutionService( // 通过市场价格判断结算价格 val settlementPrice = when { - price >= BigDecimal("0.95") -> BigDecimal.ONE // 胜出 - price <= BigDecimal("0.05") -> BigDecimal.ZERO // 失败 - else -> position.avgPrice // 未结算或不确定,按成本价 + price >= BigDecimal("0.95") -> BigDecimal.ONE + price <= BigDecimal("0.05") -> BigDecimal.ZERO + else -> position.avgPrice } val settlementValue = position.quantity.multiply(settlementPrice) @@ -401,68 +503,13 @@ class BacktestExecutionService( backtestTaskId = task.id!!, tradeTime = currentTime, marketId = position.marketId, - marketTitle = null, + marketTitle = "", side = "SETTLEMENT", - outcome = position.outcome, - outcomeIndex = position.outcomeIndex, - quantity = position.quantity, - price = settlementPrice, - amount = settlementValue, - fee = BigDecimal.ZERO, - profitLoss = profitLoss, - balanceAfter = currentBalance, - leaderTradeId = null - )) - - // 移除已结算的持仓 - positions.remove(positionKey) - - logger.info("市场结算: ${position.marketId}, 结算价=$settlementPrice, 盈亏=$profitLoss") - } catch (e: Exception) { - logger.warn("结算市场失败: ${position.marketId}", e) - } - } - return balance - } - - /** - * 结算剩余持仓 - */ - private suspend fun settleRemainingPositions( - task: BacktestTask, - positions: MutableMap, - currentBalance: BigDecimal, - trades: MutableList, - 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 = null, - side = "SETTLEMENT", - outcome = position.outcome, + outcome = when { + settlementPrice == BigDecimal.ONE -> "WIN" + settlementPrice == BigDecimal.ZERO -> "LOSE" + else -> "UNKNOWN" + }, outcomeIndex = position.outcomeIndex, quantity = position.quantity, price = settlementPrice, @@ -473,34 +520,157 @@ class BacktestExecutionService( leaderTradeId = null )) - logger.info("回测结束时结算剩余持仓: ${position.marketId}, 结算价=$settlementPrice") + // 移除已结算的持仓 + positions.remove(positionKey) } catch (e: Exception) { - logger.warn("结算市场失败: ${position.marketId}", e) + logger.error("结算市场失败: marketId=${position.marketId}, outcomeIndex=${position.outcomeIndex}", e) } } + return balance } + /** + * 结算未到期持仓 + */ + private suspend fun settleRemainingPositions( + task: BacktestTask, + positions: MutableMap, + currentBalance: BigDecimal, + trades: MutableList, + 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): 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 = trades[0]?.balanceAfter?.toSafeBigDecimal() ?: 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: LeaderTrade - ): BigDecimal { - return when (task.copyMode) { - "RATIO" -> leaderTrade.amount.multiply(task.copyRatio) - "FIXED" -> { - task.fixedAmount ?: leaderTrade.amount - } - else -> leaderTrade.amount - }.also { - // 应用最大/最小订单限制 - val maxLimit = task.maxOrderSize - val minLimit = task.minOrderSize - if (it > maxLimit) maxLimit - else if (it < minLimit) minLimit - else it + 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() } } @@ -508,132 +678,25 @@ class BacktestExecutionService( * 判断是否同一天 */ private fun isSameDay(timestamp1: Long, timestamp2: Long): Boolean { - val calendar1 = Calendar.getInstance().apply { timeInMillis = timestamp1 } - val calendar2 = Calendar.getInstance().apply { timeInMillis = timestamp2 } - return calendar1.get(Calendar.YEAR) == calendar2.get(Calendar.YEAR) && - calendar1.get(Calendar.DAY_OF_YEAR) == calendar2.get(Calendar.DAY_OF_YEAR) + 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", Locale.getDefault()) + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss") return sdf.format(Date(timestamp)) } /** - * 计算统计数据 + * 格式化日期(用于缓存key) */ - private fun calculateStatistics(trades: List): StatisticsData { - val buyTrades = trades.filter { it.side == "BUY" } - val sellTrades = trades.filter { it.side == "SELL" } - val settlementTrades = trades.filter { it.side == "SETTLEMENT" } - - val profitLossList = trades.mapNotNull { it.profitLoss } - val winTrades = profitLossList.count { it > BigDecimal.ZERO } - val lossTrades = profitLossList.count { it < BigDecimal.ZERO } - - val totalTrades = profitLossList.size - val winRate = if (totalTrades > 0) { - winTrades.toBigDecimal() - .divide(totalTrades.toBigDecimal(), 4, java.math.RoundingMode.HALF_UP) - .multiply(BigDecimal("100")) - } else { - BigDecimal.ZERO - } - - val maxProfit = profitLossList.maxOrNull() ?: BigDecimal.ZERO - val maxLoss = profitLossList.minOrNull() ?: BigDecimal.ZERO - - // 计算最大回撤 - var maxBalance = BigDecimal.ZERO - var maxDrawdown = BigDecimal.ZERO - for (trade in trades) { - if (trade.balanceAfter > maxBalance) { - maxBalance = trade.balanceAfter - } - val drawdown = maxBalance.subtract(trade.balanceAfter) - if (drawdown > maxDrawdown) { - maxDrawdown = drawdown - } - } - - // 计算平均持仓时间 - val avgHoldingTime = calculateAvgHoldingTime(buyTrades, sellTrades, settlementTrades) - - return StatisticsData( - winTrades = winTrades, - lossTrades = lossTrades, - winRate = winRate, - maxProfit = maxProfit, - maxLoss = maxLoss, - maxDrawdown = maxDrawdown, - avgHoldingTime = avgHoldingTime - ) + private fun formatDate(timestamp: Long): String { + val sdf = SimpleDateFormat("yyyy-MM-dd") + return sdf.format(Date(timestamp)) } - - /** - * 计算平均持仓时间 - */ - private fun calculateAvgHoldingTime( - buyTrades: List, - sellTrades: List, - settlementTrades: List - ): Long? { - val marketHoldings = mutableMapOf>() - - // 记录买入时间 - for (buyTrade in buyTrades) { - val key = "${buyTrade.marketId}:${buyTrade.outcomeIndex ?: 0}" - marketHoldings.getOrPut(key) { mutableListOf() }.add(buyTrade.tradeTime) - } - - // 计算持仓时间 - val holdingTimes = mutableListOf() - for (sellTrade in sellTrades) { - val key = "${sellTrade.marketId}:${sellTrade.outcomeIndex ?: 0}" - val buyTimes = marketHoldings[key] ?: continue - if (buyTimes.isNotEmpty()) { - val buyTime = buyTimes.removeFirst() - val holdingTime = sellTrade.tradeTime - buyTime - if (holdingTime > 0) { - holdingTimes.add(holdingTime) - } - } - } - - // 处理结算 - for (settleTrade in settlementTrades) { - val key = "${settleTrade.marketId}:${settleTrade.outcomeIndex ?: 0}" - val buyTimes = marketHoldings[key] ?: continue - if (buyTimes.isNotEmpty()) { - val buyTime = buyTimes.removeFirst() - val holdingTime = settleTrade.tradeTime - buyTime - if (holdingTime > 0) { - holdingTimes.add(holdingTime) - } - } - } - - return if (holdingTimes.isNotEmpty()) { - holdingTimes.sum().toLong() / holdingTimes.size - } else { - null - } - } - - /** - * 统计数据 - */ - data class StatisticsData( - val winTrades: Int, - val lossTrades: Int, - val winRate: BigDecimal, - val maxProfit: BigDecimal, - val maxLoss: BigDecimal, - val maxDrawdown: BigDecimal, - val avgHoldingTime: Long? - ) } - 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 7716705..b1ffa15 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 @@ -34,11 +34,37 @@ class BacktestPollingService( try { logger.debug("开始轮询待执行的回测任务") - // 1. 检查是否有正在执行的任务,如果有则跳过本次轮询 + // 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 状态的任务,按创建时间升序排序 @@ -65,7 +91,31 @@ class BacktestPollingService( } runBlocking { - executionService.executeBacktest(currentTask) + // 支持恢复:如果有恢复点,计算从哪一页开始 + val pageSize = 100 + val page = if (currentTask.lastProcessedTradeIndex != null && currentTask.lastProcessedTradeIndex >= 0) { + // 从第几页开始(页码从 1 开始) + // 例如:已处理了99笔,lastProcessedTradeIndex=99,应从第2页开始 + // 例如:已处理了0笔,lastProcessedTradeIndex=0,应从第1页开始(因为第1页的第1笔已经处理) + val lastProcessedIndex = currentTask.lastProcessedTradeIndex + val calculatedPage = (lastProcessedIndex / pageSize) + 1 + + // 特殊情况:如果lastProcessedTradeIndex刚好是100的倍数减1(比如99,199,299...) + // 说明该页已经完全处理,应该从下一页开始 + val nextPage = if (lastProcessedIndex % pageSize == pageSize - 1) { + calculatedPage + 1 + } else { + calculatedPage + } + + logger.info("恢复任务:已处理索引=$lastProcessedIndex, 计算页码=$nextPage, size=$pageSize") + nextPage + } else { + 1 // 从第一页开始 + } + + 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) 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 3398bdb..56a36d2 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 @@ -47,7 +47,12 @@ class BacktestService( return Result.failure(IllegalArgumentException("回测天数必须在 1-15 之间")) } - // 3. 验证初始金额 + // 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")) @@ -70,21 +75,13 @@ class BacktestService( minOrderSize = request.minOrderSize?.toSafeBigDecimal() ?: "1".toSafeBigDecimal(), maxDailyLoss = request.maxDailyLoss?.toSafeBigDecimal() ?: "10000".toSafeBigDecimal(), maxDailyOrders = request.maxDailyOrders ?: 100, - priceTolerance = request.priceTolerance?.toSafeBigDecimal() ?: "5".toSafeBigDecimal(), - delaySeconds = request.delaySeconds ?: 0, supportSell = request.supportSell ?: true, - minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(), - maxSpread = request.maxSpread?.toSafeBigDecimal(), - minPrice = request.minPrice?.toSafeBigDecimal(), - maxPrice = request.maxPrice?.toSafeBigDecimal(), - maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(), keywordFilterMode = request.keywordFilterMode ?: "DISABLED", keywords = if (request.keywords != null && request.keywords.isNotEmpty()) { request.keywords.toJson() } else { null - }, - maxMarketEndDate = request.maxMarketEndDate + } ) backtestTaskRepository.save(task) @@ -187,21 +184,13 @@ class BacktestService( minOrderSize = task.minOrderSize.toPlainString(), maxDailyLoss = task.maxDailyLoss.toPlainString(), maxDailyOrders = task.maxDailyOrders, - priceTolerance = task.priceTolerance.toPlainString(), - delaySeconds = task.delaySeconds, supportSell = task.supportSell, - minOrderDepth = task.minOrderDepth?.toPlainString(), - maxSpread = task.maxSpread?.toPlainString(), - minPrice = task.minPrice?.toPlainString(), - maxPrice = task.maxPrice?.toPlainString(), - maxPositionValue = task.maxPositionValue?.toPlainString(), keywordFilterMode = task.keywordFilterMode, keywords = if (task.keywords != null) { task.keywords.fromJson>() } else { emptyList() - }, - maxMarketEndDate = task.maxMarketEndDate + } ) val statistics = BacktestStatisticsDto( @@ -291,7 +280,7 @@ class BacktestService( ?: return Result.failure(IllegalArgumentException("回测任务不存在")) if (task.status == "RUNNING") { - return Result.failure(IllegalArgumentException("回测任务正在运行,无法删除")) + return Result.failure(IllegalStateException("回测任务正在运行,无法删除")) } backtestTaskRepository.deleteById(request.id) @@ -325,6 +314,35 @@ class BacktestService( Result.failure(e) } } + + /** + * 重试回测任务 + * 从断点继续执行,保留已处理的交易记录 + */ + @Transactional + fun retryBacktestTask(request: BacktestRetryRequest): Result { + 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) + } + } } /** diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketPriceService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketPriceService.kt index 6bf32b6..10271b5 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketPriceService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketPriceService.kt @@ -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 = Caffeine.newBuilder() + .maximumSize(10_000) + .recordStats() // 启用统计信息 + .build() + /** * 获取当前市场最新价 * 优先级: @@ -83,9 +100,20 @@ class MarketPriceService( * - payout == 0(输了)→ 返回 0.0 * 如果市场未结算或查询失败,返回 null * + * 使用缓存优化:已结算的市场结果会被缓存,避免重复 RPC 调用 + * * @return Pair 第一个值是价格(如果已结算),第二个值表示是否发生了 RPC 错误(execution reverted) */ private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): Pair { + // 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("已清空已结算市场缓存") + } + } diff --git a/backend/src/main/resources/db/migration/V28__add_backtest_resume_fields.sql b/backend/src/main/resources/db/migration/V28__add_backtest_resume_fields.sql new file mode 100644 index 0000000..9f31884 --- /dev/null +++ b/backend/src/main/resources/db/migration/V28__add_backtest_resume_fields.sql @@ -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); + diff --git a/backend/src/main/resources/db/migration/V29__drop_unused_backtest_fields.sql b/backend/src/main/resources/db/migration/V29__drop_unused_backtest_fields.sql new file mode 100644 index 0000000..62741a1 --- /dev/null +++ b/backend/src/main/resources/db/migration/V29__drop_unused_backtest_fields.sql @@ -0,0 +1,11 @@ +-- Drop unused columns from backtest_task table +-- These fields are not needed for backtest scenarios as they use historical data + +ALTER TABLE backtest_task DROP COLUMN IF EXISTS price_tolerance; +ALTER TABLE backtest_task DROP COLUMN IF EXISTS delay_seconds; +ALTER TABLE backtest_task DROP COLUMN IF EXISTS min_order_depth; +ALTER TABLE backtest_task DROP COLUMN IF EXISTS max_spread; +ALTER TABLE backtest_task DROP COLUMN IF EXISTS min_price; +ALTER TABLE backtest_task DROP COLUMN IF EXISTS max_price; +ALTER TABLE backtest_task DROP COLUMN IF EXISTS max_position_value; +ALTER TABLE backtest_task DROP COLUMN IF EXISTS max_market_end_date; diff --git a/backend/src/main/resources/i18n/messages_en.properties b/backend/src/main/resources/i18n/messages_en.properties index c8119ce..5870bf8 100644 --- a/backend/src/main/resources/i18n/messages_en.properties +++ b/backend/src/main/resources/i18n/messages_en.properties @@ -271,7 +271,7 @@ 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 diff --git a/backend/src/main/resources/i18n/messages_zh_CN.properties b/backend/src/main/resources/i18n/messages_zh_CN.properties index 6ad087a..4a960a0 100644 --- a/backend/src/main/resources/i18n/messages_zh_CN.properties +++ b/backend/src/main/resources/i18n/messages_zh_CN.properties @@ -271,7 +271,7 @@ 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=新增回测 diff --git a/backend/src/main/resources/i18n/messages_zh_TW.properties b/backend/src/main/resources/i18n/messages_zh_TW.properties index 82ffc2b..836ef33 100644 --- a/backend/src/main/resources/i18n/messages_zh_TW.properties +++ b/backend/src/main/resources/i18n/messages_zh_TW.properties @@ -271,7 +271,7 @@ 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=新增回測 diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 7bd22cb..808ef5d 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -1270,8 +1270,13 @@ "createFailed": "Failed to create", "deleteSuccess": "Deleted successfully", "deleteFailed": "Failed to delete", + "stop": "Stop", "stopSuccess": "Stopped successfully", "stopFailed": "Failed to stop", + "retry": "Retry", + "retrySuccess": "Retry successfully", + "retryFailed": "Failed to retry", + "retryConfirm": "Are you sure you want to retry this backtest task? It will continue from the breakpoint and preserve processed trades.", "deleteConfirm": "Are you sure you want to delete this backtest task?", "stopConfirm": "Are you sure you want to stop this backtest task?", "noTasks": "No backtest tasks", @@ -1297,7 +1302,7 @@ "maxDailyOrders": "Max Daily Orders", "priceTolerance": "Price Tolerance", "delaySeconds": "Delay Seconds", - "supportSell": "Support Sell", + "supportSell": "Copy Sell", "minOrderDepth": "Min Order Depth", "maxSpread": "Max Spread", "minPrice": "Min Price", @@ -1342,10 +1347,10 @@ "copyRatioTooltip": "Copy ratio represents the percentage of copy amount relative to Leader order amount. For example: 100% means 1:1 copy, 50% means half position copy, 200% means double copy", "fixedAmountRequired": "Please enter fixed amount", "fixedAmountInvalid": "Fixed amount must be greater than 0", - "advancedFilters": "Advanced Filters", + "priceFilters": "Price Filters", "keywordsPlaceholder": "Please enter keywords, press Enter to add", "delaySecondsHint": "Delay execution to simulate real copy trading delay", - "supportSellHint": "Whether to follow Leader's sell operations", + "supportSellHint": "Whether to follow Leader sell orders", "sortBy": "Sort By", "sortOrder": "Sort Order", "createdAt": "Created At" diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 8bee86c..a9392f8 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -1270,8 +1270,13 @@ "createFailed": "创建失败", "deleteSuccess": "删除成功", "deleteFailed": "删除失败", + "stop": "停止", "stopSuccess": "停止成功", "stopFailed": "停止失败", + "retry": "重试", + "retrySuccess": "重试成功", + "retryFailed": "重试失败", + "retryConfirm": "确定重新运行此回测任务吗?将从断点继续执行,保留已处理的交易记录。", "deleteConfirm": "确定删除此回测任务吗?", "stopConfirm": "确定停止此回测任务吗?", "noTasks": "暂无回测任务", @@ -1297,7 +1302,7 @@ "maxDailyOrders": "最大每日订单数", "priceTolerance": "价格容忍度", "delaySeconds": "延迟秒数", - "supportSell": "支持卖出", + "supportSell": "跟单卖出", "minOrderDepth": "最小订单深度", "maxSpread": "最大价差", "minPrice": "最低价格", @@ -1342,10 +1347,10 @@ "copyRatioTooltip": "跟单比例表示跟单金额相对于 Leader 订单金额的百分比。例如:100% 表示 1:1 跟单,50% 表示半仓跟单,200% 表示双倍跟单", "fixedAmountRequired": "请输入固定金额", "fixedAmountInvalid": "固定金额必须大于 0", - "advancedFilters": "高级过滤", + "priceFilters": "价格过滤", "keywordsPlaceholder": "请输入关键字,按回车添加", "delaySecondsHint": "延迟执行模拟真实跟单延迟", - "supportSellHint": "是否跟随 Leader 的卖出操作", + "supportSellHint": "是否跟随 Leader 卖出", "sortBy": "排序字段", "sortOrder": "排序顺序", "createdAt": "创建时间" diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index 42d8b8b..a4ffea2 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -1270,8 +1270,13 @@ "createFailed": "創建失敗", "deleteSuccess": "刪除成功", "deleteFailed": "刪除失敗", + "stop": "停止", "stopSuccess": "停止成功", "stopFailed": "停止失敗", + "retry": "重試", + "retrySuccess": "重試成功", + "retryFailed": "重試失敗", + "retryConfirm": "確定重新運行此回測任務嗎?將從斷點繼續執行,保留已處理的交易記錄。", "deleteConfirm": "確定刪除此回測任務嗎?", "stopConfirm": "確定停止此回測任務嗎?", "noTasks": "暫無回測任務", @@ -1297,7 +1302,7 @@ "maxDailyOrders": "最大每日訂單數", "priceTolerance": "價格容忍度", "delaySeconds": "延遲秒數", - "supportSell": "支持賣出", + "supportSell": "跟單賣出", "minOrderDepth": "最小訂單深度", "maxSpread": "最大價差", "minPrice": "最低價格", @@ -1342,10 +1347,10 @@ "copyRatioTooltip": "跟單比例表示跟單金額相對於 Leader 訂單金額的百分比。例如:100% 表示 1:1 跟單,50% 表示半倉跟單,200% 表示雙倍跟單", "fixedAmountRequired": "請輸入固定金額", "fixedAmountInvalid": "固定金額必須大於 0", - "advancedFilters": "進階過濾", + "priceFilters": "價格過濾", "keywordsPlaceholder": "請輸入關鍵字,按回車添加", "delaySecondsHint": "延遲執行模擬真實跟單延遲", - "supportSellHint": "是否跟隨 Leader 的賣出操作", + "supportSellHint": "是否跟隨 Leader 賣出", "sortBy": "排序欄位", "sortOrder": "排序順序", "createdAt": "創建時間" diff --git a/frontend/src/pages/BacktestCreate.tsx b/frontend/src/pages/BacktestCreate.tsx deleted file mode 100644 index 72b6c6e..0000000 --- a/frontend/src/pages/BacktestCreate.tsx +++ /dev/null @@ -1,422 +0,0 @@ -import { useState, useEffect } from 'react' -import { Card, Form, Button, Input, InputNumber, Select, Switch, message, Space, Row, Col } from 'antd' -import { ArrowLeftOutlined, SaveOutlined } from '@ant-design/icons' -import { useTranslation } from 'react-i18next' -import { useNavigate } from 'react-router-dom' -import { backtestService } from '../services/api' -import { apiService } from '../services/api' -import type { Leader } from '../types' -import type { BacktestCreateRequest } from '../types/backtest' - -const { Option } = Select - -const BacktestCreate: React.FC = () => { - const { t } = useTranslation() - const navigate = useNavigate() - const [form] = Form.useForm() - const [loading, setLoading] = useState(false) - const [leaders, setLeaders] = useState([]) - const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO') - - // 获取 Leader 列表 - useEffect(() => { - const fetchLeaders = async () => { - try { - const response = await apiService.leaders.list({}) - if (response.data.code === 0 && response.data.data) { - setLeaders(response.data.data.list || []) - } - } catch (error) { - console.error('Failed to fetch leaders:', error) - } - } - fetchLeaders() - }, []) - - // 提交表单 - const handleSubmit = async () => { - try { - const values = await form.validateFields() - setLoading(true) - - const request: BacktestCreateRequest = { - taskName: values.taskName, - leaderId: values.leaderId, - initialBalance: values.initialBalance, - backtestDays: values.backtestDays, - copyMode: values.copyMode || 'RATIO', - copyRatio: values.copyMode === 'RATIO' && values.copyRatio ? (values.copyRatio / 100).toString() : undefined, - fixedAmount: values.copyMode === 'FIXED' ? values.fixedAmount : undefined, - maxOrderSize: values.maxOrderSize, - minOrderSize: values.minOrderSize, - maxDailyLoss: values.maxDailyLoss, - maxDailyOrders: values.maxDailyOrders, - priceTolerance: values.priceTolerance, - delaySeconds: values.delaySeconds, - supportSell: values.supportSell, - minOrderDepth: values.minOrderDepth, - maxSpread: values.maxSpread, - minPrice: values.minPrice, - maxPrice: values.maxPrice, - maxPositionValue: values.maxPositionValue, - keywordFilterMode: values.keywordFilterMode, - keywords: values.keywords, - maxMarketEndDate: values.maxMarketEndDate - } - - const response = await backtestService.create(request) - if (response.data.code === 0) { - message.success(t('backtest.createSuccess')) - navigate('/backtest/list') - } else { - message.error(response.data.msg || t('backtest.createFailed')) - } - } catch (error) { - console.error('Failed to create backtest task:', error) - message.error(t('backtest.createFailed')) - } finally { - setLoading(false) - } - } - - // 返回 - const handleBack = () => { - navigate('/backtest/list') - } - - // 初始化表单默认值 - useEffect(() => { - form.setFieldsValue({ - copyMode: 'RATIO', - copyRatio: 100, // 默认 100%(显示为百分比) - maxOrderSize: 1000, - minOrderSize: 1, - maxDailyLoss: 500, - maxDailyOrders: 50, - priceTolerance: 5, - delaySeconds: 0, - supportSell: true, - keywordFilterMode: 'DISABLED', - backtestDays: 7 - }) - }, [form]) - - return ( -
- } onClick={handleBack}> - {t('common.back')} - - } - > -
- - - - - - - - - - - - - - - - - - - - - - - - - - - {/* 跟单配置 */} -
-

{t('backtest.config')}

- - - - - - {copyMode === 'RATIO' && ( - - { - const parsed = parseFloat(value || '0') - if (parsed > 10000) return 10000 - return parsed - }} - formatter={(value) => { - if (!value && value !== 0) return '' - const num = parseFloat(value.toString()) - if (isNaN(num)) return '' - if (num > 10000) return '10000' - return num.toString().replace(/\.0+$/, '') - }} - /> - - )} - - {copyMode === 'FIXED' && ( - - - - )} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {t('backtest.delaySecondsHint') || '延迟执行模拟真实跟单延迟'} - - - - - {t('backtest.supportSellHint') || '是否跟随 Leader 的卖出操作'} - - -

{t('backtest.advancedFilters')}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - setStatusFilter(value)} @@ -391,7 +416,7 @@ const BacktestList: React.FC = () => { {t('backtest.statusFailed')} - - + + @@ -443,9 +470,10 @@ const BacktestList: React.FC = () => { total, showSizeChanger: false, showTotal: (total) => `${t('common.total')} ${total} ${t('common.items')}`, - onChange: (newPage) => setPage(newPage) + onChange: (newPage) => setPage(newPage), + simple: isMobile }} - scroll={{ x: 1400 }} + scroll={isMobile ? { x: 1200 } : { x: 1400 }} /> @@ -461,31 +489,25 @@ const BacktestList: React.FC = () => { onOk={handleCreateSubmit} okText={t('common.save')} cancelText={t('common.cancel')} - width={800} + width={isMobile ? '95%' : 800} confirmLoading={createLoading} destroyOnClose - style={{ top: 20 }} - bodyStyle={{ maxHeight: 'calc(100vh - 200px)', overflowY: 'auto' }} + style={{ top: isMobile ? 10 : 20 }} + bodyStyle={{ maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(100vh - 200px)', overflowY: 'auto' }} > - + { - + { - + { /> - + { )} - + { - + { - + { - + { - - - - - - - {t('backtest.delaySecondsHint') || '延迟执行模拟真实跟单延迟'} - - - {t('backtest.supportSellHint') || '是否跟随 Leader 的卖出操作'} - - -

{t('backtest.advancedFilters')}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - + {t('backtest.supportSellHint') || '是否跟随 Leader 卖出'} {
-
+ ) } export default BacktestList - diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 2efab76..08ea2f3 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -792,6 +792,11 @@ export const backtestService = { /** * 删除回测任务 */ - delete: (data: { id: number }) => apiClient.post('/backtest/tasks/delete', data) + delete: (data: { id: number }) => apiClient.post('/backtest/tasks/delete', data), + + /** + * 重试回测任务 + */ + retry: (data: { id: number }) => apiClient.post('/backtest/tasks/retry', data) } diff --git a/frontend/src/types/backtest.ts b/frontend/src/types/backtest.ts index 10a15ff..f021d55 100644 --- a/frontend/src/types/backtest.ts +++ b/frontend/src/types/backtest.ts @@ -29,6 +29,7 @@ export interface BacktestCreateRequest { keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST' keywords?: string[] maxMarketEndDate?: number | null + pageForResume?: number // 用于恢复中断任务,从指定页码开始获取历史数据(从1开始) } /** @@ -41,6 +42,7 @@ export interface BacktestListRequest { sortOrder?: 'asc' | 'desc' page: number size: number + pageForResume?: number // 恢复时从指定页码开始 } /** @@ -80,6 +82,13 @@ export interface BacktestDeleteRequest { id: number } +/** + * 回测任务重试请求 + */ +export interface BacktestRetryRequest { + id: number +} + /** * 回测任务列表响应 */ @@ -88,6 +97,7 @@ export interface BacktestListResponse { total: number page: number size: number + processedTradeCount?: number // 已处理的交易数量(用于显示真实进度) } /** @@ -97,6 +107,9 @@ export interface BacktestDetailResponse { task: BacktestTaskDto config: BacktestConfigDto statistics: BacktestStatisticsDto + lastProcessedTradeTime?: number // 最后处理的交易时间(用于中断恢复) + lastProcessedTradeIndex?: number // 最后处理的交易索引(用于中断恢复) + processedTradeCount?: number // 已处理的交易数量(用于显示真实进度) } /**