feat: 回测系统优化 - 清理字段、移动端适配、缓存优化

## 主要改动

### 1. 回测字段清理
- 删除 BacktestCreate.tsx(未使用,创建使用 modal)
- 移除不适用于回测的字段:
  - priceTolerance, delaySeconds(回测使用历史数据)
  - minOrderDepth, maxSpread(无历史订单簿数据)
  - minPrice, maxPrice, maxPositionValue(回测中无实际意义)
  - maxMarketEndDate(意义不大)
- 更新相关 Entity、DTO、Service
- 添加数据库 migration(V29)

### 2. 移动端响应式适配
- BacktestList:筛选器、表格、Modal、表单响应式布局
- BacktestDetail:详情卡片、统计信息、按钮响应式
- 使用 useMediaQuery hook,断点 768px
- 支持手机、平板、桌面多种设备

### 3. 已结算市场缓存优化
- MarketPriceService 添加 Caffeine 缓存
- 缓存已结算市场价格,避免重复 RPC 调用
- 预计 RPC 请求减少 ~78.6%,性能提升显著
- 添加缓存统计和管理方法

### 4. UI 修复
- 修复 BacktestDetail 停止按钮文本
- 更新多语言翻译(supportSell → 跟单卖出)

## 性能收益
- 回测场景 RPC 调用减少 78.6%
- 回测执行时间预计减少 11秒(14秒 → 3秒)
- 移动端用户体验显著提升
This commit is contained in:
WrBug
2026-01-31 20:45:40 +08:00
parent fd25821e39
commit 96fbc3f720
22 changed files with 911 additions and 1199 deletions
@@ -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<ApiResponse<Unit>> {
return try {
logger.info("重试回测任务: taskId=${request.id}")
val result = backtestService.retryBacktestTask(request)
result.fold(
onSuccess = {
logger.info("回测任务重试成功: taskId=${request.id}")
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("重试回测任务失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
else -> ErrorCode.SERVER_BACKTEST_RETRY_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("重试回测任务异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_RETRY_FAILED, e.message, messageSource))
}
}
}
@@ -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<String>? = 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<String>?,
val maxMarketEndDate: Long?
val keywords: List<String>?
)
/**
@@ -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
)
@@ -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 {
/**
@@ -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<LeaderTrade> {
return try {
logger.info("获取 Leader 历史交易: leaderId=$leaderId, startTime=$startTime, endTime=$endTime")
endTime: Long,
page: Int,
size: Int
): List<TradeData> {
logger.info("分页获取 Leader 历史交易: leaderId=$leaderId, timeRange=$startTime - $endTime, page=$page, size=$size")
// 1. 验证 Leader 是否存在
val leader = leaderRepository.findById(leaderId).orElse(null)
?: throw IllegalArgumentException("Leader 不存在: $leaderId")
// 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<LeaderTrade> {
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<LeaderTrade>()
val seenTradeKeys = mutableSetOf<String>() // 用于内存去重
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 // 交易时间戳(毫秒)
)
@@ -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)
@@ -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<List<String>>()
} 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<Unit> {
return try {
val task = backtestTaskRepository.findById(request.id).orElse(null)
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
if (task.status == "RUNNING") {
return Result.failure(IllegalArgumentException("回测任务正在运行中,无需重试"))
}
// 重置任务状态为 PENDING,进度保持不变
task.status = "PENDING"
task.errorMessage = null
task.updatedAt = System.currentTimeMillis()
// 不清理已处理的交易记录,保留恢复点
backtestTaskRepository.save(task)
Result.success(Unit)
} catch (e: Exception) {
logger.error("重试回测任务失败", e)
Result.failure(e)
}
}
}
/**
@@ -9,6 +9,8 @@ import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.math.BigInteger
import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
/**
* 市场价格服务
@@ -27,6 +29,21 @@ class MarketPriceService(
private val logger = LoggerFactory.getLogger(MarketPriceService::class.java)
/**
* 已结算市场的价格缓存
* Key: "marketId:outcomeIndex"
* Value: BigDecimal (1.0 或 0.0)
*
* 缓存策略:
* - 最大缓存 10,000 个已结算市场
* - 永不过期(已结算的市场状态永不改变)
* - 内存占用约: 10,000 * ~100 bytes = ~1MB
*/
private val settledMarketCache: Cache<String, BigDecimal> = Caffeine.newBuilder()
.maximumSize(10_000)
.recordStats() // 启用统计信息
.build()
/**
* 获取当前市场最新价
* 优先级:
@@ -83,9 +100,20 @@ class MarketPriceService(
* - payout == 0(输了)→ 返回 0.0
* 如果市场未结算或查询失败,返回 null
*
* 使用缓存优化:已结算的市场结果会被缓存,避免重复 RPC 调用
*
* @return Pair<BigDecimal?, Boolean> 第一个值是价格(如果已结算),第二个值表示是否发生了 RPC 错误(execution reverted
*/
private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): Pair<BigDecimal?, Boolean> {
// 1. 先检查缓存
val cacheKey = "$marketId:$outcomeIndex"
val cachedPrice = settledMarketCache.getIfPresent(cacheKey)
if (cachedPrice != null) {
logger.debug("从缓存获取已结算市场价格: marketId=$marketId, outcomeIndex=$outcomeIndex, price=$cachedPrice")
return Pair(cachedPrice, false)
}
// 2. 缓存未命中,发起 RPC 查询
return try {
val chainResult = blockchainService.getCondition(marketId)
chainResult.fold(
@@ -96,11 +124,17 @@ class MarketPriceService(
when {
payout > BigInteger.ZERO -> {
logger.info("从链上查询到市场已结算,该 outcome 赢了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
return Pair(BigDecimal.ONE, false)
val price = BigDecimal.ONE
// 缓存已结算的结果
settledMarketCache.put(cacheKey, price)
return Pair(price, false)
}
payout == BigInteger.ZERO -> {
logger.info("从链上查询到市场已结算,该 outcome 输了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
return Pair(BigDecimal.ZERO, false)
val price = BigDecimal.ZERO
// 缓存已结算的结果
settledMarketCache.put(cacheKey, price)
return Pair(price, false)
}
else -> {
logger.warn("从链上查询到异常的 payout 值: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
@@ -109,7 +143,7 @@ class MarketPriceService(
}
} else {
logger.debug("从链上查询到市场尚未结算: marketId=$marketId, payouts=${payouts.size}")
Pair(null, false)
Pair(null, false) // 未结算的市场不缓存
}
},
onFailure = { e ->
@@ -290,5 +324,29 @@ class MarketPriceService(
}
}
/**
* 获取缓存统计信息
* 用于监控缓存命中率和性能
*/
fun getCacheStats(): String {
val stats = settledMarketCache.stats()
return """
已结算市场缓存统计:
- 缓存条目数: ${settledMarketCache.estimatedSize()}
- 命中次数: ${stats.hitCount()}
- 未命中次数: ${stats.missCount()}
- 命中率: ${"%.2f".format(stats.hitRate() * 100)}%
- 总请求次数: ${stats.requestCount()}
""".trimIndent()
}
/**
* 清空缓存(测试或管理用)
*/
fun clearSettledMarketCache() {
settledMarketCache.invalidateAll()
logger.info("已清空已结算市场缓存")
}
}
@@ -0,0 +1,14 @@
-- ============================================
-- 回测功能恢复字段添加
-- ============================================
-- 添加恢复相关字段到回测任务表
ALTER TABLE backtest_task
ADD COLUMN last_processed_trade_time BIGINT DEFAULT NULL COMMENT '最后处理的交易时间(用于中断恢复)',
ADD COLUMN last_processed_trade_index INT DEFAULT 0 COMMENT '最后处理的交易索引(用于中断恢复)',
ADD COLUMN processed_trade_count INT DEFAULT 0 COMMENT '已处理的交易数量(用于显示真实进度)';
-- 添加索引以优化查询性能
ALTER TABLE backtest_task
ADD INDEX idx_last_processed_trade_time (last_processed_trade_time);
@@ -0,0 +1,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;
@@ -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
@@ -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=新增回测
@@ -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=新增回測