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=新增回測
|
||||
|
||||
@@ -1270,8 +1270,13 @@
|
||||
"createFailed": "Failed to create",
|
||||
"deleteSuccess": "Deleted successfully",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"stop": "Stop",
|
||||
"stopSuccess": "Stopped successfully",
|
||||
"stopFailed": "Failed to stop",
|
||||
"retry": "Retry",
|
||||
"retrySuccess": "Retry successfully",
|
||||
"retryFailed": "Failed to retry",
|
||||
"retryConfirm": "Are you sure you want to retry this backtest task? It will continue from the breakpoint and preserve processed trades.",
|
||||
"deleteConfirm": "Are you sure you want to delete this backtest task?",
|
||||
"stopConfirm": "Are you sure you want to stop this backtest task?",
|
||||
"noTasks": "No backtest tasks",
|
||||
@@ -1297,7 +1302,7 @@
|
||||
"maxDailyOrders": "Max Daily Orders",
|
||||
"priceTolerance": "Price Tolerance",
|
||||
"delaySeconds": "Delay Seconds",
|
||||
"supportSell": "Support Sell",
|
||||
"supportSell": "Copy Sell",
|
||||
"minOrderDepth": "Min Order Depth",
|
||||
"maxSpread": "Max Spread",
|
||||
"minPrice": "Min Price",
|
||||
@@ -1342,10 +1347,10 @@
|
||||
"copyRatioTooltip": "Copy ratio represents the percentage of copy amount relative to Leader order amount. For example: 100% means 1:1 copy, 50% means half position copy, 200% means double copy",
|
||||
"fixedAmountRequired": "Please enter fixed amount",
|
||||
"fixedAmountInvalid": "Fixed amount must be greater than 0",
|
||||
"advancedFilters": "Advanced Filters",
|
||||
"priceFilters": "Price Filters",
|
||||
"keywordsPlaceholder": "Please enter keywords, press Enter to add",
|
||||
"delaySecondsHint": "Delay execution to simulate real copy trading delay",
|
||||
"supportSellHint": "Whether to follow Leader's sell operations",
|
||||
"supportSellHint": "Whether to follow Leader sell orders",
|
||||
"sortBy": "Sort By",
|
||||
"sortOrder": "Sort Order",
|
||||
"createdAt": "Created At"
|
||||
|
||||
@@ -1270,8 +1270,13 @@
|
||||
"createFailed": "创建失败",
|
||||
"deleteSuccess": "删除成功",
|
||||
"deleteFailed": "删除失败",
|
||||
"stop": "停止",
|
||||
"stopSuccess": "停止成功",
|
||||
"stopFailed": "停止失败",
|
||||
"retry": "重试",
|
||||
"retrySuccess": "重试成功",
|
||||
"retryFailed": "重试失败",
|
||||
"retryConfirm": "确定重新运行此回测任务吗?将从断点继续执行,保留已处理的交易记录。",
|
||||
"deleteConfirm": "确定删除此回测任务吗?",
|
||||
"stopConfirm": "确定停止此回测任务吗?",
|
||||
"noTasks": "暂无回测任务",
|
||||
@@ -1297,7 +1302,7 @@
|
||||
"maxDailyOrders": "最大每日订单数",
|
||||
"priceTolerance": "价格容忍度",
|
||||
"delaySeconds": "延迟秒数",
|
||||
"supportSell": "支持卖出",
|
||||
"supportSell": "跟单卖出",
|
||||
"minOrderDepth": "最小订单深度",
|
||||
"maxSpread": "最大价差",
|
||||
"minPrice": "最低价格",
|
||||
@@ -1342,10 +1347,10 @@
|
||||
"copyRatioTooltip": "跟单比例表示跟单金额相对于 Leader 订单金额的百分比。例如:100% 表示 1:1 跟单,50% 表示半仓跟单,200% 表示双倍跟单",
|
||||
"fixedAmountRequired": "请输入固定金额",
|
||||
"fixedAmountInvalid": "固定金额必须大于 0",
|
||||
"advancedFilters": "高级过滤",
|
||||
"priceFilters": "价格过滤",
|
||||
"keywordsPlaceholder": "请输入关键字,按回车添加",
|
||||
"delaySecondsHint": "延迟执行模拟真实跟单延迟",
|
||||
"supportSellHint": "是否跟随 Leader 的卖出操作",
|
||||
"supportSellHint": "是否跟随 Leader 卖出",
|
||||
"sortBy": "排序字段",
|
||||
"sortOrder": "排序顺序",
|
||||
"createdAt": "创建时间"
|
||||
|
||||
@@ -1270,8 +1270,13 @@
|
||||
"createFailed": "創建失敗",
|
||||
"deleteSuccess": "刪除成功",
|
||||
"deleteFailed": "刪除失敗",
|
||||
"stop": "停止",
|
||||
"stopSuccess": "停止成功",
|
||||
"stopFailed": "停止失敗",
|
||||
"retry": "重試",
|
||||
"retrySuccess": "重試成功",
|
||||
"retryFailed": "重試失敗",
|
||||
"retryConfirm": "確定重新運行此回測任務嗎?將從斷點繼續執行,保留已處理的交易記錄。",
|
||||
"deleteConfirm": "確定刪除此回測任務嗎?",
|
||||
"stopConfirm": "確定停止此回測任務嗎?",
|
||||
"noTasks": "暫無回測任務",
|
||||
@@ -1297,7 +1302,7 @@
|
||||
"maxDailyOrders": "最大每日訂單數",
|
||||
"priceTolerance": "價格容忍度",
|
||||
"delaySeconds": "延遲秒數",
|
||||
"supportSell": "支持賣出",
|
||||
"supportSell": "跟單賣出",
|
||||
"minOrderDepth": "最小訂單深度",
|
||||
"maxSpread": "最大價差",
|
||||
"minPrice": "最低價格",
|
||||
@@ -1342,10 +1347,10 @@
|
||||
"copyRatioTooltip": "跟單比例表示跟單金額相對於 Leader 訂單金額的百分比。例如:100% 表示 1:1 跟單,50% 表示半倉跟單,200% 表示雙倍跟單",
|
||||
"fixedAmountRequired": "請輸入固定金額",
|
||||
"fixedAmountInvalid": "固定金額必須大於 0",
|
||||
"advancedFilters": "進階過濾",
|
||||
"priceFilters": "價格過濾",
|
||||
"keywordsPlaceholder": "請輸入關鍵字,按回車添加",
|
||||
"delaySecondsHint": "延遲執行模擬真實跟單延遲",
|
||||
"supportSellHint": "是否跟隨 Leader 的賣出操作",
|
||||
"supportSellHint": "是否跟隨 Leader 賣出",
|
||||
"sortBy": "排序欄位",
|
||||
"sortOrder": "排序順序",
|
||||
"createdAt": "創建時間"
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, Form, Button, Input, InputNumber, Select, Switch, message, Space, Row, Col } from 'antd'
|
||||
import { ArrowLeftOutlined, SaveOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { backtestService } from '../services/api'
|
||||
import { apiService } from '../services/api'
|
||||
import type { Leader } from '../types'
|
||||
import type { BacktestCreateRequest } from '../types/backtest'
|
||||
|
||||
const { Option } = Select
|
||||
|
||||
const BacktestCreate: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [leaders, setLeaders] = useState<Leader[]>([])
|
||||
const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO')
|
||||
|
||||
// 获取 Leader 列表
|
||||
useEffect(() => {
|
||||
const fetchLeaders = async () => {
|
||||
try {
|
||||
const response = await apiService.leaders.list({})
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setLeaders(response.data.data.list || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch leaders:', error)
|
||||
}
|
||||
}
|
||||
fetchLeaders()
|
||||
}, [])
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setLoading(true)
|
||||
|
||||
const request: BacktestCreateRequest = {
|
||||
taskName: values.taskName,
|
||||
leaderId: values.leaderId,
|
||||
initialBalance: values.initialBalance,
|
||||
backtestDays: values.backtestDays,
|
||||
copyMode: values.copyMode || 'RATIO',
|
||||
copyRatio: values.copyMode === 'RATIO' && values.copyRatio ? (values.copyRatio / 100).toString() : undefined,
|
||||
fixedAmount: values.copyMode === 'FIXED' ? values.fixedAmount : undefined,
|
||||
maxOrderSize: values.maxOrderSize,
|
||||
minOrderSize: values.minOrderSize,
|
||||
maxDailyLoss: values.maxDailyLoss,
|
||||
maxDailyOrders: values.maxDailyOrders,
|
||||
priceTolerance: values.priceTolerance,
|
||||
delaySeconds: values.delaySeconds,
|
||||
supportSell: values.supportSell,
|
||||
minOrderDepth: values.minOrderDepth,
|
||||
maxSpread: values.maxSpread,
|
||||
minPrice: values.minPrice,
|
||||
maxPrice: values.maxPrice,
|
||||
maxPositionValue: values.maxPositionValue,
|
||||
keywordFilterMode: values.keywordFilterMode,
|
||||
keywords: values.keywords,
|
||||
maxMarketEndDate: values.maxMarketEndDate
|
||||
}
|
||||
|
||||
const response = await backtestService.create(request)
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('backtest.createSuccess'))
|
||||
navigate('/backtest/list')
|
||||
} else {
|
||||
message.error(response.data.msg || t('backtest.createFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create backtest task:', error)
|
||||
message.error(t('backtest.createFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 返回
|
||||
const handleBack = () => {
|
||||
navigate('/backtest/list')
|
||||
}
|
||||
|
||||
// 初始化表单默认值
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({
|
||||
copyMode: 'RATIO',
|
||||
copyRatio: 100, // 默认 100%(显示为百分比)
|
||||
maxOrderSize: 1000,
|
||||
minOrderSize: 1,
|
||||
maxDailyLoss: 500,
|
||||
maxDailyOrders: 50,
|
||||
priceTolerance: 5,
|
||||
delaySeconds: 0,
|
||||
supportSell: true,
|
||||
keywordFilterMode: 'DISABLED',
|
||||
backtestDays: 7
|
||||
})
|
||||
}, [form])
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, maxWidth: 1200, margin: '0 auto' }}>
|
||||
<Card
|
||||
title={t('backtest.createTask')}
|
||||
extra={
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={handleBack}>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
initialValues={{
|
||||
copyMode: 'RATIO',
|
||||
copyRatio: 100, // 默认 100%(显示为百分比)
|
||||
maxOrderSize: 1000,
|
||||
minOrderSize: 1,
|
||||
maxDailyLoss: 500,
|
||||
maxDailyOrders: 50,
|
||||
priceTolerance: 5,
|
||||
delaySeconds: 0,
|
||||
supportSell: true,
|
||||
keywordFilterMode: 'DISABLED',
|
||||
backtestDays: 7
|
||||
}}
|
||||
>
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.taskName')}
|
||||
name="taskName"
|
||||
rules={[{ required: true, message: t('backtest.taskNameRequired') || '请输入任务名称' }]}
|
||||
>
|
||||
<Input placeholder={t('backtest.taskName')} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.leader')}
|
||||
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}>
|
||||
{leader.leaderName || leader.leaderAddress}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.initialBalance') + ' (USDC)'}
|
||||
name="initialBalance"
|
||||
rules={[
|
||||
{ required: true, message: t('backtest.initialBalanceRequired') || '请输入初始资金' },
|
||||
{ type: 'number', min: 1, message: t('backtest.initialBalanceInvalid') || '初始资金必须大于 0' }
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('backtest.initialBalance')}
|
||||
precision={2}
|
||||
min={1}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.backtestDays') + ` (1-15 ${t('common.day')})`}
|
||||
name="backtestDays"
|
||||
rules={[
|
||||
{ required: true, message: t('backtest.backtestDaysRequired') || '请输入回测天数' },
|
||||
{ type: 'number', min: 1, max: 15, message: t('backtest.backtestDaysInvalid') || '回测天数必须在 1-15 之间' }
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('backtest.backtestDays')}
|
||||
precision={0}
|
||||
min={1}
|
||||
max={15}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 跟单配置 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h3 style={{ marginBottom: 16 }}>{t('backtest.config')}</h3>
|
||||
|
||||
<Form.Item
|
||||
label={t('backtest.copyMode')}
|
||||
name="copyMode"
|
||||
>
|
||||
<Select onChange={(value) => setCopyMode(value)}>
|
||||
<Option value="RATIO">{t('backtest.copyModeRatio')}</Option>
|
||||
<Option value="FIXED">{t('backtest.copyModeFixed')}</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{copyMode === 'RATIO' && (
|
||||
<Form.Item
|
||||
label={t('backtest.copyRatio')}
|
||||
name="copyRatio"
|
||||
tooltip={t('backtest.copyRatioTooltip') || '跟单比例表示跟单金额相对于 Leader 订单金额的百分比。例如:100% 表示 1:1 跟单,50% 表示半仓跟单,200% 表示双倍跟单'}
|
||||
rules={[
|
||||
{ required: true, message: t('backtest.copyRatioRequired') || '请输入跟单比例' },
|
||||
{ type: 'number', min: 0.01, max: 10000, message: t('backtest.copyRatioInvalid') || '跟单比例必须在 0.01-10000 之间' }
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
max={10000}
|
||||
step={0.01}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="%"
|
||||
placeholder={t('backtest.copyRatioPlaceholder') || '例如:100 表示 100%(1:1 跟单),默认 100%'}
|
||||
parser={(value) => {
|
||||
const parsed = parseFloat(value || '0')
|
||||
if (parsed > 10000) return 10000
|
||||
return parsed
|
||||
}}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
if (num > 10000) return '10000'
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{copyMode === 'FIXED' && (
|
||||
<Form.Item
|
||||
label={t('backtest.fixedAmount') + ' (USDC)'}
|
||||
name="fixedAmount"
|
||||
rules={[
|
||||
{ required: true, message: t('backtest.fixedAmountRequired') || '请输入固定金额' },
|
||||
{ type: 'number', min: 1, message: t('backtest.fixedAmountInvalid') || '固定金额必须大于 0' }
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('backtest.fixedAmount')}
|
||||
precision={2}
|
||||
min={1}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.maxOrderSize') + ' (USDC)'}
|
||||
name="maxOrderSize"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} precision={2} min={1} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.minOrderSize') + ' (USDC)'}
|
||||
name="minOrderSize"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} precision={2} min={1} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.maxDailyLoss') + ' (USDC)'}
|
||||
name="maxDailyLoss"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} precision={2} min={0} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.maxDailyOrders')}
|
||||
name="maxDailyOrders"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} precision={0} min={1} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
label={t('backtest.priceTolerance') + ' (%)'}
|
||||
name="priceTolerance"
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} precision={2} min={0} max={100} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('backtest.delaySeconds')}
|
||||
name="delaySeconds"
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} precision={0} min={0} />
|
||||
<span style={{ fontSize: 12, color: '#888' }}>{t('backtest.delaySecondsHint') || '延迟执行模拟真实跟单延迟'}</span>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('backtest.supportSell')}
|
||||
name="supportSell"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
<span style={{ fontSize: 12, color: '#888', marginLeft: 8 }}>{t('backtest.supportSellHint') || '是否跟随 Leader 的卖出操作'}</span>
|
||||
</Form.Item>
|
||||
|
||||
<h4 style={{ marginTop: 24, marginBottom: 16 }}>{t('backtest.advancedFilters')}</h4>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.minOrderDepth') + ' (USDC)'}
|
||||
name="minOrderDepth"
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} precision={2} min={0} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.maxSpread') + ' (%)'}
|
||||
name="maxSpread"
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} precision={2} min={0} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.minPrice')}
|
||||
name="minPrice"
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} precision={4} min={0} max={1} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.maxPrice')}
|
||||
name="maxPrice"
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} precision={4} min={0} max={1} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
label={t('backtest.maxPositionValue') + ' (USDC)'}
|
||||
name="maxPositionValue"
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} precision={2} min={0} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('backtest.keywordFilterMode')}
|
||||
name="keywordFilterMode"
|
||||
>
|
||||
<Select>
|
||||
<Option value="DISABLED">{t('backtest.keywordFilterModeDisabled')}</Option>
|
||||
<Option value="WHITELIST">{t('backtest.keywordFilterModeWhitelist')}</Option>
|
||||
<Option value="BLACKLIST">{t('backtest.keywordFilterModeBlacklist')}</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('backtest.keywords')}
|
||||
name="keywords"
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('backtest.keywordsPlaceholder') || '请输入关键字,按回车添加'}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button onClick={handleBack}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
icon={<SaveOutlined />}
|
||||
loading={loading}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BacktestCreate
|
||||
|
||||
@@ -6,12 +6,14 @@ import { useTranslation } from 'react-i18next'
|
||||
import { formatUSDC } from '../utils'
|
||||
import { backtestService } from '../services/api'
|
||||
import type { BacktestTaskDto, BacktestConfigDto, BacktestStatisticsDto, BacktestTradeDto } from '../types/backtest'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import BacktestChart from './BacktestChart'
|
||||
|
||||
const BacktestDetail: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [task, setTask] = useState<BacktestTaskDto | null>(null)
|
||||
const [, setConfig] = useState<BacktestConfigDto | null>(null)
|
||||
@@ -66,33 +68,30 @@ const BacktestDetail: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 初始加载任务详情和交易记录
|
||||
useEffect(() => {
|
||||
fetchTaskDetail()
|
||||
fetchTrades(tradesPage)
|
||||
}, [id])
|
||||
|
||||
// 如果任务正在运行,启动轮询
|
||||
const startPolling = () => {
|
||||
// 根据任务状态控制轮询
|
||||
useEffect(() => {
|
||||
// 停止之前的轮询
|
||||
stopPolling()
|
||||
|
||||
// 只有任务正在运行或待处理时才启动轮询
|
||||
if (task?.status === 'RUNNING' || task?.status === 'PENDING') {
|
||||
const timer = setInterval(() => {
|
||||
fetchTaskDetail()
|
||||
// 如果任务已完成或失败,停止轮询
|
||||
if (task?.status === 'COMPLETED' || task?.status === 'FAILED' || task?.status === 'STOPPED') {
|
||||
stopPolling()
|
||||
}
|
||||
}, 3000) // 每3秒轮询一次
|
||||
setPolling(timer)
|
||||
}
|
||||
|
||||
// 延迟启动轮询,等待任务状态加载完成
|
||||
setTimeout(() => {
|
||||
if (task?.status === 'RUNNING' || task?.status === 'PENDING') {
|
||||
startPolling()
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
// 组件卸载或状态变化时清理定时器
|
||||
return () => {
|
||||
stopPolling()
|
||||
}
|
||||
}, [id, task?.status])
|
||||
}, [task?.status])
|
||||
|
||||
const stopPolling = () => {
|
||||
if (polling) {
|
||||
@@ -137,6 +136,7 @@ const BacktestDetail: React.FC = () => {
|
||||
const response = await backtestService.delete({ id: parseInt(id!) })
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('backtest.deleteSuccess'))
|
||||
stopPolling() // 停止轮询
|
||||
navigate('/backtest')
|
||||
} else {
|
||||
message.error(response.data.msg || t('backtest.deleteFailed'))
|
||||
@@ -275,23 +275,23 @@ const BacktestDetail: React.FC = () => {
|
||||
<Card>
|
||||
{/* 头部操作栏 */}
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||
<Space>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={handleBack}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={handleBack} size={isMobile ? 'middle' : 'large'}>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleRefresh} loading={loading}>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleRefresh} loading={loading} size={isMobile ? 'middle' : 'large'}>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</Space>
|
||||
<Space>
|
||||
{(task.status === 'RUNNING' || task.status === 'PENDING') && (
|
||||
<Button danger icon={<StopOutlined />} onClick={handleStop}>
|
||||
{t('backtest.statusStopped')}
|
||||
<Button danger icon={<StopOutlined />} onClick={handleStop} size={isMobile ? 'middle' : 'large'}>
|
||||
{t('backtest.stop')}
|
||||
</Button>
|
||||
)}
|
||||
{(task.status === 'COMPLETED' || task.status === 'STOPPED' || task.status === 'FAILED') && (
|
||||
<Button danger icon={<DeleteOutlined />} onClick={handleDelete}>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={handleDelete} size={isMobile ? 'middle' : 'large'}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)}
|
||||
@@ -300,7 +300,7 @@ const BacktestDetail: React.FC = () => {
|
||||
|
||||
{/* 任务基本信息 */}
|
||||
<Card title={t('backtest.taskDetail')} size="small">
|
||||
<Descriptions column={2} bordered size="small">
|
||||
<Descriptions column={isMobile ? 1 : 2} bordered size="small">
|
||||
<Descriptions.Item label={t('backtest.taskName')}>{task.taskName}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('backtest.leader')}>
|
||||
{task.leaderName || task.leaderAddress}
|
||||
@@ -348,7 +348,7 @@ const BacktestDetail: React.FC = () => {
|
||||
{/* 统计信息 */}
|
||||
{statistics && (
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Col xs={24} sm={12} md={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={t('backtest.buyTrades')}
|
||||
@@ -356,7 +356,7 @@ const BacktestDetail: React.FC = () => {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Col xs={24} sm={12} md={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={t('backtest.sellTrades')}
|
||||
@@ -364,7 +364,7 @@ const BacktestDetail: React.FC = () => {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Col xs={24} sm={12} md={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={t('backtest.winTrades')}
|
||||
@@ -373,7 +373,7 @@ const BacktestDetail: React.FC = () => {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Col xs={24} sm={12} md={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={t('backtest.lossTrades')}
|
||||
@@ -382,7 +382,7 @@ const BacktestDetail: React.FC = () => {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Col xs={24} sm={12} md={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={t('backtest.winRate')}
|
||||
@@ -392,7 +392,7 @@ const BacktestDetail: React.FC = () => {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Col xs={24} sm={12} md={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={t('backtest.maxProfit')}
|
||||
@@ -401,7 +401,7 @@ const BacktestDetail: React.FC = () => {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Col xs={24} sm={12} md={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={t('backtest.maxLoss')}
|
||||
@@ -410,7 +410,7 @@ const BacktestDetail: React.FC = () => {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Col xs={24} sm={12} md={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={t('backtest.maxDrawdown')}
|
||||
@@ -420,7 +420,7 @@ const BacktestDetail: React.FC = () => {
|
||||
</Card>
|
||||
</Col>
|
||||
{statistics.avgHoldingTime && (
|
||||
<Col span={6}>
|
||||
<Col xs={24} sm={12} md={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={t('backtest.avgHoldingTime')}
|
||||
@@ -458,7 +458,7 @@ const BacktestDetail: React.FC = () => {
|
||||
fetchTrades(newPage)
|
||||
}
|
||||
}}
|
||||
scroll={{ x: 1800 }}
|
||||
scroll={isMobile ? { x: 1200 } : { x: 1800 }}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
|
||||
@@ -2,17 +2,19 @@ import { useState, useEffect } from 'react'
|
||||
import { Table, Card, Button, Select, Tag, Space, Modal, message, Row, Col, Form, Input, InputNumber, Switch } from 'antd'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { PlusOutlined, ReloadOutlined, DeleteOutlined, StopOutlined, EyeOutlined } from '@ant-design/icons'
|
||||
import { PlusOutlined, ReloadOutlined, DeleteOutlined, StopOutlined, EyeOutlined, RedoOutlined } from '@ant-design/icons'
|
||||
import { formatUSDC } from '../utils'
|
||||
import { backtestService, apiService } from '../services/api'
|
||||
import type { BacktestTaskDto, BacktestListRequest, BacktestCreateRequest } from '../types/backtest'
|
||||
import type { Leader } from '../types'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
|
||||
const { Option } = Select
|
||||
|
||||
const BacktestList: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [tasks, setTasks] = useState<BacktestTaskDto[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
@@ -22,7 +24,7 @@ const BacktestList: React.FC = () => {
|
||||
const [leaderIdFilter] = useState<number | undefined>()
|
||||
const [sortBy, setSortBy] = useState<'profitAmount' | 'profitRate' | 'createdAt'>('createdAt')
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
|
||||
|
||||
|
||||
// 创建回测 modal 相关状态
|
||||
const [createModalVisible, setCreateModalVisible] = useState(false)
|
||||
const [createForm] = Form.useForm()
|
||||
@@ -112,6 +114,29 @@ const BacktestList: React.FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// 重试任务
|
||||
const handleRetry = (id: number) => {
|
||||
Modal.confirm({
|
||||
title: t('backtest.retryConfirm'),
|
||||
okText: t('common.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
onOk: async () => {
|
||||
try {
|
||||
const response = await backtestService.retry({ id })
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('backtest.retrySuccess'))
|
||||
fetchTasks()
|
||||
} else {
|
||||
message.error(response.data.msg || t('backtest.retryFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to retry backtest task:', error)
|
||||
message.error(t('backtest.retryFailed'))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取 Leader 列表
|
||||
useEffect(() => {
|
||||
if (createModalVisible) {
|
||||
@@ -145,8 +170,6 @@ const BacktestList: React.FC = () => {
|
||||
minOrderSize: 1,
|
||||
maxDailyLoss: 500,
|
||||
maxDailyOrders: 50,
|
||||
priceTolerance: 5,
|
||||
delaySeconds: 0,
|
||||
supportSell: true,
|
||||
keywordFilterMode: 'DISABLED',
|
||||
backtestDays: 7
|
||||
@@ -172,17 +195,9 @@ const BacktestList: React.FC = () => {
|
||||
minOrderSize: values.minOrderSize,
|
||||
maxDailyLoss: values.maxDailyLoss,
|
||||
maxDailyOrders: values.maxDailyOrders,
|
||||
priceTolerance: values.priceTolerance,
|
||||
delaySeconds: values.delaySeconds,
|
||||
supportSell: values.supportSell,
|
||||
minOrderDepth: values.minOrderDepth,
|
||||
maxSpread: values.maxSpread,
|
||||
minPrice: values.minPrice,
|
||||
maxPrice: values.maxPrice,
|
||||
maxPositionValue: values.maxPositionValue,
|
||||
keywordFilterMode: values.keywordFilterMode,
|
||||
keywords: values.keywords,
|
||||
maxMarketEndDate: values.maxMarketEndDate
|
||||
keywords: values.keywords
|
||||
}
|
||||
|
||||
const response = await backtestService.create(request)
|
||||
@@ -231,14 +246,14 @@ const BacktestList: React.FC = () => {
|
||||
title: t('backtest.taskName'),
|
||||
dataIndex: 'taskName',
|
||||
key: 'taskName',
|
||||
width: 150
|
||||
width: isMobile ? 120 : 150
|
||||
},
|
||||
{
|
||||
title: t('backtest.leader'),
|
||||
dataIndex: 'leaderName',
|
||||
key: 'leaderName',
|
||||
width: 150,
|
||||
render: (_: any, record: BacktestTaskDto) => record.leaderName || record.leaderAddress || '-'
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (_: any, record: BacktestTaskDto) => record.leaderName || record.leaderAddress?.substring(0, 8) + '...' || '-'
|
||||
},
|
||||
{
|
||||
title: t('backtest.initialBalance'),
|
||||
@@ -324,14 +339,14 @@ const BacktestList: React.FC = () => {
|
||||
title: t('backtest.createdAt'),
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
width: isMobile ? 150 : 180,
|
||||
render: (timestamp: number) => new Date(timestamp).toLocaleString()
|
||||
},
|
||||
{
|
||||
title: t('common.actions'),
|
||||
key: 'actions',
|
||||
fixed: 'right' as const,
|
||||
width: 150,
|
||||
fixed: isMobile ? false : ('right' as const),
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (_: any, record: BacktestTaskDto) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
@@ -342,7 +357,7 @@ const BacktestList: React.FC = () => {
|
||||
>
|
||||
{t('common.viewDetail')}
|
||||
</Button>
|
||||
{(record.status === 'RUNNING' || record.status === 'PENDING') && (
|
||||
{record.status === 'RUNNING' && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -350,10 +365,20 @@ const BacktestList: React.FC = () => {
|
||||
icon={<StopOutlined />}
|
||||
onClick={() => handleStop(record.id)}
|
||||
>
|
||||
{t('backtest.statusStopped')}
|
||||
{t('backtest.stop')}
|
||||
</Button>
|
||||
)}
|
||||
{(record.status === 'COMPLETED' || record.status === 'STOPPED' || record.status === 'FAILED') && (
|
||||
{(record.status === 'STOPPED' || record.status === 'FAILED') && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<RedoOutlined />}
|
||||
onClick={() => handleRetry(record.id)}
|
||||
>
|
||||
{t('backtest.retry')}
|
||||
</Button>
|
||||
)}
|
||||
{(record.status === 'PENDING' || record.status === 'COMPLETED') && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -374,11 +399,11 @@ const BacktestList: React.FC = () => {
|
||||
<Card>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{/* 头部操作栏 */}
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col>
|
||||
<Space size="middle">
|
||||
<Row justify="space-between" align="middle" gutter={[16, 16]}>
|
||||
<Col xs={24} sm={24} md={12} lg={16}>
|
||||
<Space size="middle" direction={isMobile ? 'vertical' : 'horizontal'} style={{ width: isMobile ? '100%' : 'auto' }}>
|
||||
<Select
|
||||
style={{ width: 150 }}
|
||||
style={{ width: isMobile ? '100%' : 150 }}
|
||||
placeholder={t('backtest.status')}
|
||||
allowClear
|
||||
onChange={(value) => setStatusFilter(value)}
|
||||
@@ -391,7 +416,7 @@ const BacktestList: React.FC = () => {
|
||||
<Select.Option value="FAILED">{t('backtest.statusFailed')}</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
style={{ width: 150 }}
|
||||
style={{ width: isMobile ? '100%' : 150 }}
|
||||
placeholder={t('backtest.sortBy')}
|
||||
onChange={(value) => setSortBy(value)}
|
||||
value={sortBy}
|
||||
@@ -401,7 +426,7 @@ const BacktestList: React.FC = () => {
|
||||
<Select.Option value="createdAt">{t('backtest.createdAt')}</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
style={{ width: 120 }}
|
||||
style={{ width: isMobile ? '100%' : 120 }}
|
||||
placeholder={t('backtest.sortOrder')}
|
||||
onChange={(value) => setSortOrder(value)}
|
||||
value={sortOrder}
|
||||
@@ -411,19 +436,21 @@ const BacktestList: React.FC = () => {
|
||||
</Select>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Col xs={24} sm={24} md={12} lg={8} style={{ textAlign: isMobile ? 'left' : 'right' }}>
|
||||
<Space style={{ width: isMobile ? '100%' : 'auto' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleCreate}
|
||||
style={{ flex: isMobile ? 1 : undefined }}
|
||||
>
|
||||
{t('backtest.createTask')}
|
||||
{isMobile ? t('common.create') : t('backtest.createTask')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRefresh}
|
||||
loading={loading}
|
||||
style={{ flex: isMobile ? 1 : undefined }}
|
||||
>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
@@ -443,9 +470,10 @@ const BacktestList: React.FC = () => {
|
||||
total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `${t('common.total')} ${total} ${t('common.items')}`,
|
||||
onChange: (newPage) => setPage(newPage)
|
||||
onChange: (newPage) => setPage(newPage),
|
||||
simple: isMobile
|
||||
}}
|
||||
scroll={{ x: 1400 }}
|
||||
scroll={isMobile ? { x: 1200 } : { x: 1400 }}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
@@ -461,31 +489,25 @@ const BacktestList: React.FC = () => {
|
||||
onOk={handleCreateSubmit}
|
||||
okText={t('common.save')}
|
||||
cancelText={t('common.cancel')}
|
||||
width={800}
|
||||
width={isMobile ? '95%' : 800}
|
||||
confirmLoading={createLoading}
|
||||
destroyOnClose
|
||||
style={{ top: 20 }}
|
||||
bodyStyle={{ maxHeight: 'calc(100vh - 200px)', overflowY: 'auto' }}
|
||||
style={{ top: isMobile ? 10 : 20 }}
|
||||
bodyStyle={{ maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(100vh - 200px)', overflowY: 'auto' }}
|
||||
>
|
||||
<Form
|
||||
form={createForm}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
copyMode: 'RATIO',
|
||||
copyRatio: 100, // 默认 100%(显示为百分比)
|
||||
maxOrderSize: 1000,
|
||||
minOrderSize: 1,
|
||||
maxDailyLoss: 500,
|
||||
maxDailyOrders: 50,
|
||||
priceTolerance: 5,
|
||||
delaySeconds: 0,
|
||||
supportSell: true,
|
||||
keywordFilterMode: 'DISABLED',
|
||||
backtestDays: 7
|
||||
}}
|
||||
>
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Col xs={24} sm={24} md={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.taskName')}
|
||||
name="taskName"
|
||||
@@ -494,7 +516,7 @@ const BacktestList: React.FC = () => {
|
||||
<Input placeholder={t('backtest.taskName')} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Col xs={24} sm={24} md={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.leader')}
|
||||
name="leaderId"
|
||||
@@ -512,7 +534,7 @@ const BacktestList: React.FC = () => {
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Col xs={24} sm={24} md={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.initialBalance') + ' (USDC)'}
|
||||
name="initialBalance"
|
||||
@@ -529,7 +551,7 @@ const BacktestList: React.FC = () => {
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Col xs={24} sm={24} md={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.backtestDays') + ` (1-15 ${t('common.day')})`}
|
||||
name="backtestDays"
|
||||
@@ -616,7 +638,7 @@ const BacktestList: React.FC = () => {
|
||||
)}
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Col xs={24} sm={24} md={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.maxOrderSize') + ' (USDC)'}
|
||||
name="maxOrderSize"
|
||||
@@ -625,7 +647,7 @@ const BacktestList: React.FC = () => {
|
||||
<InputNumber style={{ width: '100%' }} precision={2} min={1} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Col xs={24} sm={24} md={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.minOrderSize') + ' (USDC)'}
|
||||
name="minOrderSize"
|
||||
@@ -637,7 +659,7 @@ const BacktestList: React.FC = () => {
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Col xs={24} sm={24} md={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.maxDailyLoss') + ' (USDC)'}
|
||||
name="maxDailyLoss"
|
||||
@@ -646,7 +668,7 @@ const BacktestList: React.FC = () => {
|
||||
<InputNumber style={{ width: '100%' }} precision={2} min={0} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Col xs={24} sm={24} md={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.maxDailyOrders')}
|
||||
name="maxDailyOrders"
|
||||
@@ -657,75 +679,13 @@ const BacktestList: React.FC = () => {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
label={t('backtest.priceTolerance') + ' (%)'}
|
||||
name="priceTolerance"
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} precision={2} min={0} max={100} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('backtest.delaySeconds')}
|
||||
name="delaySeconds"
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} precision={0} min={0} />
|
||||
<span style={{ fontSize: 12, color: '#888' }}>{t('backtest.delaySecondsHint') || '延迟执行模拟真实跟单延迟'}</span>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('backtest.supportSell')}
|
||||
name="supportSell"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
<span style={{ fontSize: 12, color: '#888', marginLeft: 8 }}>{t('backtest.supportSellHint') || '是否跟随 Leader 的卖出操作'}</span>
|
||||
</Form.Item>
|
||||
|
||||
<h4 style={{ marginTop: 24, marginBottom: 16 }}>{t('backtest.advancedFilters')}</h4>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.minOrderDepth') + ' (USDC)'}
|
||||
name="minOrderDepth"
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} precision={2} min={0} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.maxSpread') + ' (%)'}
|
||||
name="maxSpread"
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} precision={2} min={0} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.minPrice')}
|
||||
name="minPrice"
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} precision={4} min={0} max={1} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.maxPrice')}
|
||||
name="maxPrice"
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} precision={4} min={0} max={1} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
label={t('backtest.maxPositionValue') + ' (USDC)'}
|
||||
name="maxPositionValue"
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} precision={2} min={0} />
|
||||
<span style={{ fontSize: 12, color: '#888', marginLeft: 8 }}>{t('backtest.supportSellHint') || '是否跟随 Leader 卖出'}</span>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
@@ -752,9 +712,8 @@ const BacktestList: React.FC = () => {
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
export default BacktestList
|
||||
|
||||
|
||||
@@ -792,6 +792,11 @@ export const backtestService = {
|
||||
/**
|
||||
* 删除回测任务
|
||||
*/
|
||||
delete: (data: { id: number }) => apiClient.post('/backtest/tasks/delete', data)
|
||||
delete: (data: { id: number }) => apiClient.post('/backtest/tasks/delete', data),
|
||||
|
||||
/**
|
||||
* 重试回测任务
|
||||
*/
|
||||
retry: (data: { id: number }) => apiClient.post('/backtest/tasks/retry', data)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface BacktestCreateRequest {
|
||||
keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST'
|
||||
keywords?: string[]
|
||||
maxMarketEndDate?: number | null
|
||||
pageForResume?: number // 用于恢复中断任务,从指定页码开始获取历史数据(从1开始)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,6 +42,7 @@ export interface BacktestListRequest {
|
||||
sortOrder?: 'asc' | 'desc'
|
||||
page: number
|
||||
size: number
|
||||
pageForResume?: number // 恢复时从指定页码开始
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,6 +82,13 @@ export interface BacktestDeleteRequest {
|
||||
id: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 回测任务重试请求
|
||||
*/
|
||||
export interface BacktestRetryRequest {
|
||||
id: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 回测任务列表响应
|
||||
*/
|
||||
@@ -88,6 +97,7 @@ export interface BacktestListResponse {
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
processedTradeCount?: number // 已处理的交易数量(用于显示真实进度)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,6 +107,9 @@ export interface BacktestDetailResponse {
|
||||
task: BacktestTaskDto
|
||||
config: BacktestConfigDto
|
||||
statistics: BacktestStatisticsDto
|
||||
lastProcessedTradeTime?: number // 最后处理的交易时间(用于中断恢复)
|
||||
lastProcessedTradeIndex?: number // 最后处理的交易索引(用于中断恢复)
|
||||
processedTradeCount?: number // 已处理的交易数量(用于显示真实进度)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user