feat(backtest): 回测结算持久化、按配置重新测试、执行时以当前时间为窗口基准

- 持久化: 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 <cursoragent@cursor.com>
This commit is contained in:
WrBug
2026-02-09 01:08:40 +08:00
co-authored by Cursor
parent 01a1836659
commit b1ad6f02d2
20 changed files with 526 additions and 199 deletions
@@ -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<ApiResponse<BacktestTaskDto>> {
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))
}
}
}
@@ -80,6 +80,14 @@ data class BacktestRetryRequest(
val id: Long // 回测任务ID
)
/**
* 按当前配置重新测试请求(仅支持已完成任务)
*/
data class BacktestRerunRequest(
val id: Long, // 源回测任务ID
val taskName: String? = null // 新任务名称,为空时使用「原名称 (副本)」
)
/**
* 回测任务列表响应
*/
@@ -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, // 回测结束时间(历史时间)
@@ -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 {
/**
@@ -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<TradeData>,
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<TradeData> {
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)
}
@@ -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<String, Position>()
val trades = mutableListOf<BacktestTrade>()
// 每日订单数缓存:key为日期字符串(yyyy-MM-dd)value为当天的 BUY 订单数
val dailyOrderCountCache = mutableMapOf<String, Int>()
// 每日亏损缓存:key为日期字符串(yyyy-MM-dd)value为当天的累计亏损金额
val dailyLossCache = mutableMapOf<String, BigDecimal>()
val seenTradeIds = mutableSetOf<String>()
// 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<BacktestTrade>()
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<BacktestTrade>()
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<String, Position>,
currentBalance: BigDecimal,
trades: MutableList<BacktestTrade>,
currentTime: Long
currentTime: Long,
batchTradesToSave: MutableList<BacktestTrade>
): 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<String, Position>,
currentBalance: BigDecimal,
trades: MutableList<BacktestTrade>,
currentTime: Long
currentTime: Long,
settlementsToSave: MutableList<BacktestTrade>
): 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()
@@ -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)
@@ -343,6 +343,50 @@ class BacktestService(
Result.failure(e)
}
}
/**
* 按当前配置重新测试基于已完成的回测任务创建一份相同配置的新任务名称可修改
*/
@Transactional
fun rerunBacktestTask(request: BacktestRerunRequest): Result<BacktestTaskDto> {
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)
}
}
}
/**
@@ -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
@@ -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=新增回测
@@ -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=新增回測
@@ -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」这一秒重叠,必须按 **tradeIdtransactionHash)去重**
## 快速验证(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` 去重。
+67
View File
@@ -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<LeaderSelectProps> = ({
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 (
<Select
value={value}
onChange={handleChange}
placeholder={placeholder}
disabled={disabled}
showSearch={showSearch}
allowClear={allowClear}
notFoundContent={notFoundContent}
optionFilterProp="label"
optionLabelProp="label"
>
{leaders.map(leader => {
const label = leader.leaderName || `Leader ${leader.id}`
return (
<Option key={leader.id} value={leader.id} label={label}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span>{label}</span>
<span style={{ fontSize: '12px', color: '#999' }}>{leader.leaderAddress}</span>
</div>
</Option>
)
})}
</Select>
)
}
export default LeaderSelect
+6 -1
View File
@@ -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"
}
}
+6 -1
View File
@@ -1363,6 +1363,11 @@
"supportSellHint": "是否跟随 Leader 卖出",
"sortBy": "排序字段",
"sortOrder": "排序顺序",
"createdAt": "创建时间"
"createdAt": "创建时间",
"rerun": "重新测试",
"rerunConfirm": "将按当前配置创建新的回测任务,是否继续?",
"rerunTaskNamePlaceholder": "新任务名称(留空使用「原名称 (副本)」)",
"rerunSuccess": "已创建新回测任务",
"rerunFailed": "重新测试失败"
}
}
+6 -1
View File
@@ -1363,6 +1363,11 @@
"supportSellHint": "是否跟隨 Leader 賣出",
"sortBy": "排序欄位",
"sortOrder": "排序順序",
"createdAt": "創建時間"
"createdAt": "創建時間",
"rerun": "重新測試",
"rerunConfirm": "將按當前配置創建新的回測任務,是否繼續?",
"rerunTaskNamePlaceholder": "新任務名稱(留空使用「原名稱 (副本)」)",
"rerunSuccess": "已創建新回測任務",
"rerunFailed": "重新測試失敗"
}
}
+92 -21
View File
@@ -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<any>(null)
// 重新测试 Modal 相关状态
const [rerunModalVisible, setRerunModalVisible] = useState(false)
const [rerunTask, setRerunTask] = useState<BacktestTaskDto | null>(null)
const [rerunTaskName, setRerunTaskName] = useState('')
const [rerunLoading, setRerunLoading] = useState(false)
// 任务详情 Modal 相关状态
const [detailModalVisible, setDetailModalVisible] = useState(false)
const [detailTask, setDetailTask] = useState<BacktestTaskDto | null>(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')}
</Button>
{record.status === 'COMPLETED' && (
<Button
type="link"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCreateCopyTrading(record)}
>
{t('backtest.createCopyTrading')}
</Button>
<>
<Button
type="link"
size="small"
icon={<SyncOutlined />}
onClick={() => handleRerun(record)}
>
{t('backtest.rerun')}
</Button>
<Button
type="link"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCreateCopyTrading(record)}
>
{t('backtest.createCopyTrading')}
</Button>
</>
)}
{record.status === 'RUNNING' && (
<Button
@@ -675,6 +722,30 @@ const BacktestList: React.FC = () => {
</Space>
</Card>
{/* 重新测试 Modal */}
<Modal
title={t('backtest.rerun')}
open={rerunModalVisible}
onCancel={() => {
setRerunModalVisible(false)
setRerunTask(null)
setRerunTaskName('')
}}
onOk={handleRerunSubmit}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
confirmLoading={rerunLoading}
destroyOnClose
>
<p style={{ marginBottom: 8 }}>{t('backtest.rerunConfirm')}</p>
<Input
value={rerunTaskName}
onChange={(e) => setRerunTaskName(e.target.value)}
placeholder={t('backtest.rerunTaskNamePlaceholder')}
maxLength={100}
/>
</Modal>
{/* 创建回测任务 Modal */}
<Modal
title={t('backtest.createTask')}
@@ -719,16 +790,10 @@ const BacktestList: React.FC = () => {
name="leaderId"
rules={[{ required: true, message: t('backtest.leaderRequired') || '请选择 Leader' }]}
>
<Select placeholder={t('backtest.leader')} showSearch>
{leaders.map((leader) => (
<Option key={leader.id} value={leader.id}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span>{leader.leaderName || `Leader ${leader.id}`}</span>
<span style={{ fontSize: '12px', color: '#999' }}>{leader.leaderAddress}</span>
</div>
</Option>
))}
</Select>
<LeaderSelect
leaders={leaders}
placeholder={t('backtest.leader')}
/>
</Form.Item>
</Col>
</Row>
@@ -945,6 +1010,12 @@ const BacktestList: React.FC = () => {
footer={
detailTask && detailTask.status === 'COMPLETED' && detailConfig ? (
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button icon={<SyncOutlined />} onClick={() => {
setDetailModalVisible(false)
handleRerun(detailTask)
}}>
{t('backtest.rerun')}
</Button>
<Button type="primary" icon={<CopyOutlined />} onClick={() => {
const preFilled = {
leaderId: detailTask.leaderId,
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive'
import AccountImportForm from '../../components/AccountImportForm'
import LeaderAddForm from '../../components/LeaderAddForm'
import LeaderSelect from '../../components/LeaderSelect'
const { Option } = Select
@@ -467,8 +468,10 @@ const AddModal: React.FC<AddModalProps> = ({
name="leaderId"
rules={[{ required: true, message: t('copyTradingAdd.leaderRequired') || '请选择 Leader' }]}
>
<Select
<LeaderSelect
leaders={leaders}
placeholder={t('copyTradingAdd.selectLeaderPlaceholder') || '请选择 Leader'}
onSelectChange={(value) => fetchLeaderAssetInfo(value)}
notFoundContent={
leaders.length === 0 ? (
<div style={{ textAlign: 'center', padding: '12px' }}>
@@ -484,14 +487,7 @@ const AddModal: React.FC<AddModalProps> = ({
</div>
) : null
}
onChange={(value) => fetchLeaderAssetInfo(value)}
>
{leaders.map(leader => (
<Option key={leader.id} value={leader.id}>
{leader.leaderName || `Leader ${leader.id}`} ({leader.leaderAddress.slice(0, 6)}...{leader.leaderAddress.slice(-4)})
</Option>
))}
</Select>
/>
</Form.Item>
{/* Leader 资产信息 */}
@@ -323,7 +323,10 @@ const EditModal: React.FC<EditModalProps> = ({
>
<Select disabled>
<Option value={copyTrading.leaderId}>
{copyTrading.leaderName || `Leader ${copyTrading.leaderId}`} ({copyTrading.leaderAddress.slice(0, 6)}...{copyTrading.leaderAddress.slice(-4)})
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span>{copyTrading.leaderName || `Leader ${copyTrading.leaderId}`}</span>
<span style={{ fontSize: '12px', color: '#999' }}>{copyTrading.leaderAddress}</span>
</div>
</Option>
</Select>
</Form.Item>
+6 -1
View File
@@ -797,6 +797,11 @@ export const backtestService = {
/**
*
*/
retry: (data: { id: number }) => apiClient.post('/backtest/tasks/retry', data)
retry: (data: { id: number }) => apiClient.post('/backtest/tasks/retry', data),
/**
*
*/
rerun: (data: { id: number; taskName?: string }) => apiClient.post('/backtest/tasks/rerun', data)
}