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:
+32
@@ -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 {
|
||||
/**
|
||||
|
||||
+45
-127
@@ -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 // 交易时间戳(毫秒)
|
||||
)
|
||||
|
||||
+480
-417
File diff suppressed because it is too large
Load Diff
+52
-2
@@ -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)
|
||||
|
||||
+38
-20
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+61
-3
@@ -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=新增回測
|
||||
|
||||
Reference in New Issue
Block a user