diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/backtest/BacktestController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/backtest/BacktestController.kt new file mode 100644 index 0000000..f7be812 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/backtest/BacktestController.kt @@ -0,0 +1,193 @@ +package com.wrbug.polymarketbot.controller.backtest + +import com.wrbug.polymarketbot.dto.* +import com.wrbug.polymarketbot.enums.ErrorCode +import com.wrbug.polymarketbot.service.backtest.BacktestService +import kotlinx.coroutines.runBlocking +import org.slf4j.LoggerFactory +import org.springframework.context.MessageSource +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +/** + * 回测管理控制器 + */ +@RestController +@RequestMapping("/api/backtest") +class BacktestController( + private val backtestService: BacktestService, + private val messageSource: MessageSource +) { + + private val logger = LoggerFactory.getLogger(BacktestController::class.java) + + /** + * 创建回测任务 + */ + @PostMapping("/tasks") + fun createBacktestTask(@RequestBody request: BacktestCreateRequest): ResponseEntity> { + return try { + logger.info("创建回测任务: taskName=${request.taskName}, leaderId=${request.leaderId}") + + val result = runBlocking { + backtestService.createBacktestTask(request) + } + + result.fold( + onSuccess = { dto -> + logger.info("回测任务创建成功: taskId=${dto.id}") + ResponseEntity.ok(ApiResponse.success(dto)) + }, + onFailure = { e -> + logger.error("创建回测任务失败", e) + val errorCode = when (e) { + is IllegalArgumentException -> ErrorCode.PARAM_ERROR + else -> ErrorCode.SERVER_BACKTEST_CREATE_FAILED + } + ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource)) + } + ) + } catch (e: Exception) { + logger.error("创建回测任务异常", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_CREATE_FAILED, e.message, messageSource)) + } + } + + /** + * 查询回测任务列表 + */ + @PostMapping("/tasks/list") + fun getBacktestTaskList(@RequestBody request: BacktestListRequest): ResponseEntity> { + return try { + val result = backtestService.getBacktestTaskList(request) + + result.fold( + onSuccess = { response -> + logger.info("查询回测任务列表成功: total=${response.total}") + ResponseEntity.ok(ApiResponse.success(response)) + }, + onFailure = { e -> + logger.error("查询回测任务列表失败", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_LIST_FETCH_FAILED, e.message, messageSource)) + } + ) + } catch (e: Exception) { + logger.error("查询回测任务列表异常", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_LIST_FETCH_FAILED, e.message, messageSource)) + } + } + + /** + * 查询回测任务详情 + */ + @PostMapping("/tasks/detail") + fun getBacktestTaskDetail(@RequestBody request: BacktestDetailRequest): ResponseEntity> { + return try { + val result = backtestService.getBacktestTaskDetail(request) + + result.fold( + onSuccess = { response -> + logger.info("查询回测任务详情成功: taskId=${request.id}") + ResponseEntity.ok(ApiResponse.success(response)) + }, + onFailure = { e -> + logger.error("查询回测任务详情失败", e) + val errorCode = when (e) { + is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND + else -> ErrorCode.SERVER_BACKTEST_DETAIL_FETCH_FAILED + } + ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource)) + } + ) + } catch (e: Exception) { + logger.error("查询回测任务详情异常", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_DETAIL_FETCH_FAILED, e.message, messageSource)) + } + } + + /** + * 查询回测交易记录 + */ + @PostMapping("/tasks/trades") + fun getBacktestTrades(@RequestBody request: BacktestTradeListRequest): ResponseEntity> { + return try { + val result = backtestService.getBacktestTrades(request) + + result.fold( + onSuccess = { response -> + logger.info("查询回测交易记录成功: taskId=${request.taskId}") + ResponseEntity.ok(ApiResponse.success(response)) + }, + onFailure = { e -> + logger.error("查询回测交易记录失败", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_TRADES_FETCH_FAILED, e.message, messageSource)) + } + ) + } catch (e: Exception) { + logger.error("查询回测交易记录异常", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_TRADES_FETCH_FAILED, e.message, messageSource)) + } + } + + /** + * 删除回测任务 + */ + @PostMapping("/tasks/delete") + fun deleteBacktestTask(@RequestBody request: BacktestDeleteRequest): ResponseEntity> { + return try { + logger.info("删除回测任务: taskId=${request.id}") + + val result = backtestService.deleteBacktestTask(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 + else -> ErrorCode.SERVER_BACKTEST_DELETE_FAILED + } + ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource)) + } + ) + } catch (e: Exception) { + logger.error("删除回测任务异常", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_DELETE_FAILED, e.message, messageSource)) + } + } + + /** + * 停止回测任务 + */ + @PostMapping("/tasks/stop") + fun stopBacktestTask(@RequestBody request: BacktestStopRequest): ResponseEntity> { + return try { + logger.info("停止回测任务: taskId=${request.id}") + + val result = backtestService.stopBacktestTask(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_STOP_FAILED + } + ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource)) + } + ) + } catch (e: Exception) { + logger.error("停止回测任务异常", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_STOP_FAILED, e.message, messageSource)) + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/BacktestDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/BacktestDto.kt new file mode 100644 index 0000000..6a475c1 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/BacktestDto.kt @@ -0,0 +1,205 @@ +package com.wrbug.polymarketbot.dto + +import java.math.BigDecimal + +/** + * 回测任务创建请求 + */ +data class BacktestCreateRequest( + val taskName: String, // 回测任务名称 + val leaderId: Long, // Leader ID + val initialBalance: String, // 初始资金 + val backtestDays: Int, // 回测天数 (1-30) + // 跟单配置(与 CopyTrading 一致,但不包含 max_position_count) + val copyMode: String? = null, // "RATIO" 或 "FIXED" + val copyRatio: String? = null, // 仅在 copyMode="RATIO" 时生效 + val fixedAmount: String? = null, // 仅在 copyMode="FIXED" 时生效 + val maxOrderSize: String? = null, + val minOrderSize: String? = null, + val maxDailyLoss: String? = null, + val maxDailyOrders: Int? = null, + val priceTolerance: String? = null, // 百分比 + val delaySeconds: Int? = null, + val supportSell: Boolean? = null, + val minOrderDepth: String? = null, + val maxSpread: String? = null, + val minPrice: String? = null, + val maxPrice: String? = null, + val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用 + val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单) + val keywords: List? = null, // 关键字列表 + val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),NULL表示不启用 +) + +/** + * 回测任务列表请求 + */ +data class BacktestListRequest( + val leaderId: Long? = null, // Leader ID(可选) + val status: String? = null, // PENDING/RUNNING/COMPLETED/STOPPED/FAILED + val sortBy: String? = null, // profitAmount / profitRate / createdAt + val sortOrder: String? = null, // asc / desc + val page: Int = 1, // 页码,从1开始 + val size: Int = 20 // 每页数量 +) + +/** + * 回测任务详情请求 + */ +data class BacktestDetailRequest( + val id: Long // 回测任务ID +) + +/** + * 回测交易记录请求 + */ +data class BacktestTradeListRequest( + val taskId: Long, // 回测任务ID + val page: Int = 1, // 页码,从1开始 + val size: Int = 20 // 每页数量 +) + +/** + * 回测进度查询请求 + */ +data class BacktestProgressRequest( + val id: Long // 回测任务ID +) + +/** + * 回测任务停止请求 + */ +data class BacktestStopRequest( + val id: Long // 回测任务ID +) + +/** + * 回测任务删除请求 + */ +data class BacktestDeleteRequest( + val id: Long // 回测任务ID +) + +/** + * 回测任务列表响应 + */ +data class BacktestListResponse( + val list: List, + val total: Long, + val page: Int, + val size: Int +) + +/** + * 回测任务详情响应 + */ +data class BacktestDetailResponse( + val task: BacktestTaskDto, + val config: BacktestConfigDto, + val statistics: BacktestStatisticsDto +) + +/** + * 回测交易记录列表响应 + */ +data class BacktestTradeListResponse( + val list: List, + val total: Long, + val page: Int, + val size: Int +) + +/** + * 回测进度响应 + */ +data class BacktestProgressResponse( + val progress: Int, // 执行进度 (0-100) + val currentBalance: String, // 当前余额 + val totalTrades: Int, // 总交易笔数 + val status: String // 任务状态 +) + +/** + * 回测任务 DTO + */ +data class BacktestTaskDto( + val id: Long, + val taskName: String, + val leaderId: Long, + val leaderName: String?, + val leaderAddress: String?, + val initialBalance: String, + val finalBalance: String?, + val profitAmount: String?, + val profitRate: String?, + val backtestDays: Int, + val startTime: Long, + val endTime: Long?, + val status: String, // PENDING/RUNNING/COMPLETED/STOPPED/FAILED + val progress: Int, + val totalTrades: Int, + val createdAt: Long, + val executionStartedAt: Long?, + val executionFinishedAt: Long? +) + +/** + * 回测配置 DTO + */ +data class BacktestConfigDto( + val copyMode: String, + val copyRatio: String, + val fixedAmount: String?, + val maxOrderSize: String, + val minOrderSize: String, + val maxDailyLoss: String, + val maxDailyOrders: Int, + val priceTolerance: String, + val delaySeconds: Int, + val supportSell: Boolean, + val minOrderDepth: String?, + val maxSpread: String?, + val minPrice: String?, + val maxPrice: String?, + val maxPositionValue: String?, + val keywordFilterMode: String?, + val keywords: List?, + val maxMarketEndDate: Long? +) + +/** + * 回测统计信息 DTO + */ +data class BacktestStatisticsDto( + val totalTrades: Int, // 总交易笔数 + val buyTrades: Int, // 买入笔数 + val sellTrades: Int, // 卖出笔数 + val winTrades: Int, // 盈利交易笔数 + val lossTrades: Int, // 亏损交易笔数 + val winRate: String, // 胜率(%) + val maxProfit: String, // 最大单笔盈利 + val maxLoss: String, // 最大单笔亏损 + val maxDrawdown: String, // 最大回撤 + val avgHoldingTime: Long? // 平均持仓时间(毫秒) +) + +/** + * 回测交易记录 DTO + */ +data class BacktestTradeDto( + val id: Long, + val tradeTime: Long, + val marketId: String, + val marketTitle: String?, + val side: String, // BUY/SELL/SETTLEMENT + val outcome: String, + val outcomeIndex: Int?, + val quantity: String, + val price: String, + val amount: String, + val fee: String, + val profitLoss: String?, + val balanceAfter: String, + val leaderTradeId: String? +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/TradeData.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/TradeData.kt new file mode 100644 index 0000000..554d8f4 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/TradeData.kt @@ -0,0 +1,32 @@ +package com.wrbug.polymarketbot.dto + +import java.math.BigDecimal + +/** + * 用户交易数据 + * 用于回测功能,从 Polymarket API 获取的用户交易历史 + */ +data class TradeData( + val tradeId: String, // 交易 ID + val marketId: String, // 市场 ID + val marketTitle: String?, // 市场标题 + val marketSlug: String?, // 市场 Slug + val side: String, // 交易方向: BUY/SELL + val outcome: String, // 结果: YES/NO 或 outcomeIndex + val outcomeIndex: Int?, // 结果索引 + val price: BigDecimal, // 成交价格 + val size: BigDecimal, // 成交数量 + val amount: BigDecimal, // 成交金额 + val timestamp: Long // 交易时间戳 +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is TradeData) return false + return tradeId == other.tradeId + } + + override fun hashCode(): Int { + return tradeId.hashCode() + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/BacktestTask.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/BacktestTask.kt new file mode 100644 index 0000000..fbd4899 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/BacktestTask.kt @@ -0,0 +1,157 @@ +package com.wrbug.polymarketbot.entity + +import jakarta.persistence.* +import java.math.BigDecimal +import com.wrbug.polymarketbot.util.toSafeBigDecimal + +/** + * 回测任务实体 + */ +@Entity +@Table(name = "backtest_task") +data class BacktestTask( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "task_name", nullable = false, length = 100) + val taskName: String, + + @Column(name = "leader_id", nullable = false) + val leaderId: Long, + + // 回测参数 + @Column(name = "initial_balance", nullable = false, precision = 20, scale = 8) + val initialBalance: BigDecimal, + + @Column(name = "final_balance", precision = 20, scale = 8) + val finalBalance: BigDecimal? = null, + + @Column(name = "profit_amount", precision = 20, scale = 8) + val profitAmount: BigDecimal? = null, + + @Column(name = "profit_rate", precision = 10, scale = 4) + val profitRate: BigDecimal? = null, // 收益率(%) + + @Column(name = "backtest_days", nullable = false) + val backtestDays: Int, + + @Column(name = "start_time", nullable = false) + val startTime: Long, // 回测开始时间(历史时间) + + @Column(name = "end_time") + val endTime: Long? = null, // 回测结束时间(历史时间) + + // 跟单配置 (复制CopyTrading表结构,但不包含 max_position_count) + @Column(name = "copy_mode", nullable = false, length = 10) + val copyMode: String = "RATIO", // "RATIO" 或 "FIXED" + + @Column(name = "copy_ratio", nullable = false, precision = 20, scale = 8) + val copyRatio: BigDecimal = BigDecimal.ONE, + + @Column(name = "fixed_amount", precision = 20, scale = 8) + val fixedAmount: BigDecimal? = null, + + @Column(name = "max_order_size", nullable = false, precision = 20, scale = 8) + val maxOrderSize: BigDecimal = "1000".toSafeBigDecimal(), + + @Column(name = "min_order_size", nullable = false, precision = 20, scale = 8) + val minOrderSize: BigDecimal = "1".toSafeBigDecimal(), + + @Column(name = "max_daily_loss", nullable = false, precision = 20, scale = 8) + val maxDailyLoss: BigDecimal = "10000".toSafeBigDecimal(), + + @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, // 平均持仓时间(毫秒) + + @Column(name = "data_source", length = 50) + val dataSource: String = "MIXED", // INTERNAL/API/MIXED + + // 执行状态 + @Column(name = "status", nullable = false, length = 20) + var status: String = "PENDING", // PENDING/RUNNING/COMPLETED/STOPPED/FAILED + + @Column(name = "progress", nullable = false) + var progress: Int = 0, // 执行进度(0-100) + + @Column(name = "total_trades", nullable = false) + var totalTrades: Int = 0, + + @Column(name = "buy_trades", nullable = false) + var buyTrades: Int = 0, + + @Column(name = "sell_trades", nullable = false) + var sellTrades: Int = 0, + + @Column(name = "win_trades", nullable = false) + var winTrades: Int = 0, + + @Column(name = "loss_trades", nullable = false) + var lossTrades: Int = 0, + + @Column(name = "win_rate", precision = 5, scale = 2) + var winRate: BigDecimal? = null, // 胜率(%) + + @Column(name = "max_profit", precision = 20, scale = 8) + var maxProfit: BigDecimal? = null, // 最大单笔盈利 + + @Column(name = "max_loss", precision = 20, scale = 8) + var maxLoss: BigDecimal? = null, // 最大单笔亏损 + + @Column(name = "max_drawdown", precision = 20, scale = 8) + var maxDrawdown: BigDecimal? = null, // 最大回撤 + + @Column(name = "error_message", columnDefinition = "TEXT") + var errorMessage: String? = null, + + // 时间字段 + @Column(name = "created_at", nullable = false) + val createdAt: Long = System.currentTimeMillis(), + + @Column(name = "execution_started_at") + var executionStartedAt: Long? = null, + + @Column(name = "execution_finished_at") + var executionFinishedAt: Long? = null, + + @Column(name = "updated_at", nullable = false) + var updatedAt: Long = System.currentTimeMillis() +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/BacktestTrade.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/BacktestTrade.kt new file mode 100644 index 0000000..636f75b --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/BacktestTrade.kt @@ -0,0 +1,62 @@ +package com.wrbug.polymarketbot.entity + +import jakarta.persistence.* +import java.math.BigDecimal + +/** + * 回测交易记录实体 + * 用于记录回测过程中的每笔模拟交易 + */ +@Entity +@Table(name = "backtest_trade") +data class BacktestTrade( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "backtest_task_id", nullable = false) + val backtestTaskId: Long, + + @Column(name = "trade_time", nullable = false) + val tradeTime: Long, + + @Column(name = "market_id", nullable = false, length = 100) + val marketId: String, + + @Column(name = "market_title", length = 500) + val marketTitle: String? = null, + + @Column(name = "side", nullable = false, length = 20) + val side: String, // BUY/SELL/SETTLEMENT + + @Column(name = "outcome", nullable = false, length = 50) + val outcome: String, // YES/NO 或 outcomeIndex + + @Column(name = "outcome_index") + val outcomeIndex: Int? = null, // 结果索引(0, 1, 2, ...),支持多元市场 + + @Column(name = "quantity", nullable = false, precision = 20, scale = 8) + val quantity: BigDecimal, + + @Column(name = "price", nullable = false, precision = 20, scale = 8) + val price: BigDecimal, + + @Column(name = "amount", nullable = false, precision = 20, scale = 8) + val amount: BigDecimal, + + @Column(name = "fee", nullable = false, precision = 20, scale = 8) + val fee: BigDecimal = BigDecimal.ZERO, // 手续费(回测不计算,默认为0) + + @Column(name = "profit_loss", precision = 20, scale = 8) + val profitLoss: BigDecimal? = null, // 盈亏(仅卖出时) + + @Column(name = "balance_after", nullable = false, precision = 20, scale = 8) + val balanceAfter: BigDecimal, // 交易后余额 + + @Column(name = "leader_trade_id", length = 100) + val leaderTradeId: String? = null, // Leader 原始交易ID + + @Column(name = "created_at", nullable = false) + val createdAt: Long = System.currentTimeMillis() +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt index 52cf5ff..018f3c4 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt @@ -231,7 +231,23 @@ enum class ErrorCode( SERVER_ORDER_TRACKING_PROCESS_FAILED(5901, "处理订单跟踪失败", "error.server.order_tracking_process_failed"), SERVER_ORDER_TRACKING_BUY_FAILED(5902, "处理买入订单失败", "error.server.order_tracking_buy_failed"), SERVER_ORDER_TRACKING_SELL_FAILED(5903, "处理卖出订单失败", "error.server.order_tracking_sell_failed"), - SERVER_ORDER_TRACKING_MATCH_FAILED(5904, "订单匹配失败", "error.server.order_tracking_match_failed"); + SERVER_ORDER_TRACKING_MATCH_FAILED(5904, "订单匹配失败", "error.server.order_tracking_match_failed"), + + // 回测服务错误 (4601-4699) + BACKTEST_TASK_NOT_FOUND(4601, "回测任务不存在", "error.backtest.task_not_found"), + BACKTEST_LEADER_NOT_FOUND(4602, "Leader不存在", "error.backtest.leader_not_found"), + BACKTEST_DAYS_INVALID(4603, "回测天数超出限制", "error.backtest.days_invalid"), + BACKTEST_INITIAL_BALANCE_INVALID(4604, "初始金额无效", "error.backtest.initial_balance_invalid"), + BACKTEST_TASK_RUNNING(4605, "回测任务正在运行,无法删除", "error.backtest.task_running"), + SERVER_BACKTEST_CREATE_FAILED(5603, "创建回测任务失败", "error.server.backtest_create_failed"), + SERVER_BACKTEST_UPDATE_FAILED(5604, "更新回测任务失败", "error.server.backtest_update_failed"), + SERVER_BACKTEST_DELETE_FAILED(5605, "删除回测任务失败", "error.server.backtest_delete_failed"), + SERVER_BACKTEST_LIST_FETCH_FAILED(5606, "查询回测列表失败", "error.server.backtest_list_fetch_failed"), + SERVER_BACKTEST_DETAIL_FETCH_FAILED(5607, "查询回测详情失败", "error.server.backtest_detail_fetch_failed"), + 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"); companion object { /** diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/BacktestTaskRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/BacktestTaskRepository.kt new file mode 100644 index 0000000..7e0e258 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/BacktestTaskRepository.kt @@ -0,0 +1,63 @@ +package com.wrbug.polymarketbot.repository + +import com.wrbug.polymarketbot.entity.BacktestTask +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository + +/** + * 回测任务Repository + */ +@Repository +interface BacktestTaskRepository : JpaRepository { + + /** + * 根据 Leader ID 查询回测任务 + */ + fun findByLeaderId(leaderId: Long): List + + /** + * 根据状态查询回测任务 + */ + fun findByStatus(status: String): List + + /** + * 根据 Leader ID 和状态查询回测任务 + */ + fun findByLeaderIdAndStatus(leaderId: Long, status: String): List + + /** + * 根据 Leader ID、收益率排序查询 + */ + @Query("SELECT t FROM BacktestTask t WHERE t.leaderId = :leaderId AND t.status = :status ORDER BY t.profitRate DESC") + fun findByLeaderIdAndStatusOrderByProfitRateDesc(leaderId: Long, status: String): List + + /** + * 根据状态和创建时间倒序查询 + */ + @Query("SELECT t FROM BacktestTask t WHERE t.status = :status ORDER BY t.createdAt DESC") + fun findByStatusOrderByCreatedAtDesc(status: String): List + + /** + * 更新回测任务状态 + */ + @Modifying + @Query("UPDATE BacktestTask t SET t.status = :status, t.updatedAt = :updatedAt WHERE t.id = :id") + fun updateStatus(id: Long, status: String, updatedAt: Long = System.currentTimeMillis()) + + /** + * 更新回测任务状态和错误信息 + */ + @Modifying + @Query("UPDATE BacktestTask t SET t.status = :status, t.errorMessage = :errorMessage, t.updatedAt = :updatedAt WHERE t.id = :id") + fun updateStatusAndError(id: Long, status: String, errorMessage: String?, updatedAt: Long = System.currentTimeMillis()) + + /** + * 更新回测任务进度 + */ + @Modifying + @Query("UPDATE BacktestTask t SET t.progress = :progress, t.updatedAt = :updatedAt WHERE t.id = :id") + fun updateProgress(id: Long, progress: Int, updatedAt: Long = System.currentTimeMillis()) +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/BacktestTradeRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/BacktestTradeRepository.kt new file mode 100644 index 0000000..b7493d5 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/BacktestTradeRepository.kt @@ -0,0 +1,38 @@ +package com.wrbug.polymarketbot.repository + +import com.wrbug.polymarketbot.entity.BacktestTrade +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository + +/** + * 回测交易记录Repository + */ +@Repository +interface BacktestTradeRepository : JpaRepository { + + /** + * 根据回测任务ID查询所有交易记录 + */ + fun findByBacktestTaskIdOrderByTradeTime(backtestTaskId: Long): List + + /** + * 根据回测任务ID分页查询交易记录 + */ + @Query("SELECT t FROM BacktestTrade t WHERE t.backtestTaskId = :backtestTaskId ORDER BY t.tradeTime") + fun findByBacktestTaskId( + backtestTaskId: Long, + pageable: org.springframework.data.domain.Pageable + ): org.springframework.data.domain.Page + + /** + * 根据回测任务ID统计交易数量 + */ + fun countByBacktestTaskId(backtestTaskId: Long): Long + + /** + * 删除回测任务的所有交易记录(由级联删除处理) + */ + fun deleteByBacktestTaskId(backtestTaskId: Long) +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestDataService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestDataService.kt new file mode 100644 index 0000000..8ddd646 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestDataService.kt @@ -0,0 +1,213 @@ +package com.wrbug.polymarketbot.service.backtest + +import com.wrbug.polymarketbot.api.UserActivityResponse +import com.wrbug.polymarketbot.entity.Leader +import com.wrbug.polymarketbot.repository.LeaderRepository +import com.wrbug.polymarketbot.util.RetrofitFactory +import com.wrbug.polymarketbot.util.toSafeBigDecimal +import kotlinx.coroutines.delay +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.math.BigDecimal + +/** + * 回测数据服务 + * 直接从 Polymarket Data API 获取 Leader 历史交易 + */ +@Service +class BacktestDataService( + private val leaderRepository: LeaderRepository, + private val retrofitFactory: RetrofitFactory +) { + private val logger = LoggerFactory.getLogger(BacktestDataService::class.java) + + /** + * 获取 Leader 历史交易(用于回测) + * + * 策略:直接从 Polymarket Data API 的 activity 接口获取 + * + * @param leaderId Leader ID + * @param startTime 开始时间(毫秒时间戳) + * @param endTime 结束时间(毫秒时间戳) + * @return 历史交易列表 + */ + suspend fun getLeaderHistoricalTrades( + leaderId: Long, + startTime: Long, + endTime: Long + ): List { + return try { + logger.info("获取 Leader 历史交易: leaderId=$leaderId, startTime=$startTime, endTime=$endTime") + + // 1. 验证 Leader 是否存在 + val leader = leaderRepository.findById(leaderId).orElse(null) + ?: throw IllegalArgumentException("Leader 不存在: $leaderId") + + // 2. 从 Data API 的 activity 接口获取 + val apiTrades = fetchFromActivityApi(leader, startTime, endTime) + + logger.info("共获取 ${apiTrades.size} 条历史交易") + return apiTrades + } catch (e: Exception) { + logger.error("获取 Leader 历史交易失败", e) + throw e + } + } + + /** + * 从 Data API 的 activity 接口获取历史交易 + * 实现完整的分页逻辑,获取所有历史交易记录 + * + * @param leader Leader 实体 + * @param startTime 开始时间(毫秒时间戳) + * @param endTime 结束时间(毫秒时间戳) + * @return 历史交易列表 + */ + private suspend fun fetchFromActivityApi( + leader: Leader, + startTime: Long, + endTime: Long + ): List { + logger.info("从 Data API activity 接口获取 Leader 历史交易: leaderId=${leader.id}, timeRange=${startTime} - $endTime") + + val dataApi = retrofitFactory.createDataApi() + val allTrades = mutableListOf() + val seenTradeKeys = mutableSetOf() // 用于内存去重 + var offset = 0 + val pageSize = 100 // 每页最多 100 条 + var hasMore = true + val MAX_OFFSET = 10000 // 最大偏移量(防止无限循环,15天通常不会超过) + + // 分页获取所有交易记录 + while (hasMore && offset < MAX_OFFSET) { + 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 使用秒级时间戳 + end = endTime / 1000, + limit = pageSize, + offset = offset, + sortBy = "timestamp", + sortDirection = "asc" + ) + + if (!response.isSuccessful || response.body() == null) { + logger.error("从 Data API 获取用户活动失败: code=${response.code()}, message=${response.message()}") + break + } + + val activities = response.body()!! + + // 如果返回的数据少于 pageSize,说明没有更多数据了 + if (activities.isEmpty() || activities.size < pageSize) { + hasMore = false + } + + // 转换为 LeaderTrade + val trades = 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 // 转换为毫秒时间戳 + 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 + marketTitle = activity.title, + marketSlug = activity.slug, + side = activity.side.uppercase(), + outcome = activity.outcome ?: activity.outcomeIndex?.toString() ?: "", + outcomeIndex = activity.outcomeIndex, + price = activity.price.toSafeBigDecimal(), + size = activity.size.toSafeBigDecimal(), + amount = activity.usdcSize.toSafeBigDecimal(), + tradeTimestamp = tradeTimestamp + ) + } catch (e: Exception) { + logger.warn("转换活动数据失败: activity=$activity, error=${e.message}", e) + null + } + } + + 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 + } + } + + logger.info("分页获取完成,共获取 ${allTrades.size} 条历史交易") + return allTrades + } +} + +/** + * Leader 历史交易数据(回测使用) + */ +data class LeaderTrade( + val leaderId: Long, + val tradeId: String, // 交易唯一标识 + val marketId: String, + val marketTitle: String?, + val marketSlug: String?, + val side: String, // BUY 或 SELL + val outcome: String?, + val outcomeIndex: Int?, + val price: BigDecimal, + val size: BigDecimal, + val amount: BigDecimal, // 交易金额(price × size) + val tradeTimestamp: Long // 交易时间戳(毫秒) +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestExecutionService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestExecutionService.kt new file mode 100644 index 0000000..36f18c0 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestExecutionService.kt @@ -0,0 +1,639 @@ +package com.wrbug.polymarketbot.service.backtest + +import com.wrbug.polymarketbot.entity.BacktestTask +import com.wrbug.polymarketbot.entity.BacktestTrade +import com.wrbug.polymarketbot.entity.CopyTrading +import com.wrbug.polymarketbot.repository.BacktestTradeRepository +import com.wrbug.polymarketbot.repository.BacktestTaskRepository +import com.wrbug.polymarketbot.service.common.MarketPriceService +import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingFilterService +import com.wrbug.polymarketbot.service.copytrading.configs.FilterResult +import com.wrbug.polymarketbot.service.backtest.BacktestDataService +import com.wrbug.polymarketbot.service.backtest.LeaderTrade +import com.wrbug.polymarketbot.util.toSafeBigDecimal +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.math.BigDecimal +import java.text.SimpleDateFormat +import java.util.* + +/** + * 回测执行服务 + * 执行回测任务的核心算法 + */ +@Service +class BacktestExecutionService( + private val backtestTaskRepository: BacktestTaskRepository, + private val backtestTradeRepository: BacktestTradeRepository, + private val backtestDataService: BacktestDataService, + private val marketPriceService: MarketPriceService, + private val copyTradingFilterService: CopyTradingFilterService +) { + private val logger = LoggerFactory.getLogger(BacktestExecutionService::class.java) + + /** + * 持仓数据结构 + */ + data class Position( + val marketId: String, + val outcome: String, + val outcomeIndex: Int?, + var quantity: BigDecimal, + val avgPrice: BigDecimal, + val leaderBuyQuantity: BigDecimal? + ) + + /** + * 将 BacktestTask 转换为 CopyTrading 对象(用于过滤检查) + */ + private fun taskToCopyTrading(task: BacktestTask): CopyTrading { + return CopyTrading( + id = task.id, + accountId = 0L, // 回测不需要账户ID + leaderId = task.leaderId, + enabled = true, + copyMode = task.copyMode, + copyRatio = task.copyRatio, + fixedAmount = null, + maxOrderSize = task.maxOrderSize, + minOrderSize = task.minOrderSize, + maxDailyLoss = task.maxDailyLoss, + maxDailyOrders = task.maxDailyOrders, + priceTolerance = task.priceTolerance, + delaySeconds = task.delaySeconds, + pollIntervalSeconds = 5, + useWebSocket = false, + websocketReconnectInterval = 5000, + websocketMaxRetries = 10, + supportSell = task.supportSell, + minOrderDepth = task.minOrderDepth, + maxSpread = task.maxSpread, + minPrice = task.minPrice, + maxPrice = task.maxPrice, + maxPositionValue = task.maxPositionValue, + keywordFilterMode = task.keywordFilterMode, + keywords = task.keywords, + configName = null, + pushFailedOrders = false, + pushFilteredOrders = false, + maxMarketEndDate = task.maxMarketEndDate, + createdAt = task.createdAt, + updatedAt = task.updatedAt + ) + } + + /** + * 执行回测任务 + */ + @Transactional + suspend fun executeBacktest(task: BacktestTask) { + return try { + logger.info("开始执行回测任务: taskId=${task.id}, taskName=${task.taskName}") + + // 1. 更新任务状态为 RUNNING + task.status = "RUNNING" + task.executionStartedAt = System.currentTimeMillis() + task.updatedAt = System.currentTimeMillis() + backtestTaskRepository.save(task) + + // 2. 初始化 + var currentBalance = task.initialBalance + val positions = mutableMapOf() // marketId + outcomeIndex -> Position + val trades = mutableListOf() + + // 3. 计算回测时间范围 + val endTime = System.currentTimeMillis() + val startTime = task.startTime + + logger.info("回测时间范围: ${formatTimestamp(startTime)} - ${formatTimestamp(endTime)}, " + + "初始余额: ${task.initialBalance.toPlainString()}") + + // 4. 获取 Leader 历史交易 + val leaderTrades = backtestDataService.getLeaderHistoricalTrades( + task.leaderId, + startTime, + endTime + ).sortedBy { it.tradeTimestamp } + + logger.info("获取到 ${leaderTrades.size} 条历史交易") + + // 5. 按时间顺序回放交易 + var processedCount = 0 + val totalTrades = leaderTrades.size + + for (leaderTrade in leaderTrades) { + // 检查是否需要停止 + if (task.status == "STOPPED") { + logger.info("回测任务已被停止") + break + } + + processedCount++ + val progress = (processedCount * 100) / totalTrades + if (progress >= task.progress + 5) { + task.progress = progress + backtestTaskRepository.save(task) + } + + try { + // 5.1 实时检查并结算已到期的市场 + currentBalance = settleExpiredPositions(task, positions, currentBalance, trades, leaderTrade.tradeTimestamp) + + // 5.2 检查余额和持仓状态 + if (currentBalance < BigDecimal.ONE && positions.isEmpty()) { + logger.info("余额不足且无持仓,停止回测: $currentBalance") + break + } + + // 如果余额不足但有持仓,记录日志但继续处理 + if (currentBalance < BigDecimal.ONE && positions.isNotEmpty()) { + logger.info("余额不足 $currentBalance,但还有 ${positions.size} 个持仓,继续处理") + } + + // 5.3 应用过滤规则 + val copyTrading = taskToCopyTrading(task) + val filterResult = copyTradingFilterService.checkFilters( + copyTrading, + tokenId = "", // 回测不需要 tokenId + tradePrice = leaderTrade.price, + copyOrderAmount = null, + marketId = leaderTrade.marketId, + marketTitle = leaderTrade.marketTitle, + marketEndDate = null, + outcomeIndex = leaderTrade.outcomeIndex + ) + + if (!filterResult.isPassed) { + continue + } + + // 5.4 每日订单数检查 + val dailyOrderCount = trades.count { + isSameDay(it.tradeTime, leaderTrade.tradeTimestamp) + } + + if (dailyOrderCount >= task.maxDailyOrders) { + logger.info("已达到每日最大订单数限制: $dailyOrderCount / ${task.maxDailyOrders}") + continue + } + + // 5.5 价格容忍度检查 + if (task.priceTolerance > BigDecimal.ZERO) { + val tolerance = task.priceTolerance.divide(BigDecimal("100")) + val minPrice = leaderTrade.price.multiply(BigDecimal.ONE.subtract(tolerance)) + val maxPrice = leaderTrade.price.multiply(BigDecimal.ONE.add(tolerance)) + + val currentPrice = marketPriceService.getCurrentMarketPrice( + leaderTrade.marketId, + leaderTrade.outcomeIndex ?: 0 + ) + + val currentPriceDecimal = currentPrice.toSafeBigDecimal() + if (currentPriceDecimal < minPrice || currentPriceDecimal > maxPrice) { + logger.info("价格超出容忍度范围: 当前=$currentPrice, 可用范围=[$minPrice, $maxPrice]") + continue + } + } + + // 5.6 计算跟单金额 + val followAmount = calculateFollowAmount(task, leaderTrade) + + if (leaderTrade.side == "BUY") { + // 买入逻辑 + val quantity = followAmount.divide(leaderTrade.price, 8, java.math.RoundingMode.DOWN) + val totalCost = followAmount // 不计算手续费 + + // 严格模式: 仅检查当前可用余额 + if (totalCost > currentBalance) { + logger.info("余额不足以执行买入订单: 需要 $totalCost, 可用 $currentBalance") + continue + } + + // 更新余额和持仓 + currentBalance -= totalCost + val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}" + positions[positionKey] = Position( + marketId = leaderTrade.marketId, + outcome = leaderTrade.outcome ?: "", + outcomeIndex = leaderTrade.outcomeIndex, + quantity = quantity, + avgPrice = leaderTrade.price.toSafeBigDecimal(), + leaderBuyQuantity = leaderTrade.size.toSafeBigDecimal() + ) + + // 记录交易 + trades.add(BacktestTrade( + backtestTaskId = task.id!!, + tradeTime = leaderTrade.tradeTimestamp, + marketId = leaderTrade.marketId, + marketTitle = leaderTrade.marketTitle, + side = "BUY", + outcome = leaderTrade.outcome ?: leaderTrade.outcomeIndex.toString(), + outcomeIndex = leaderTrade.outcomeIndex, + quantity = quantity, + price = leaderTrade.price.toSafeBigDecimal(), + amount = followAmount, + fee = BigDecimal.ZERO, + profitLoss = null, + balanceAfter = currentBalance, + leaderTradeId = leaderTrade.tradeId + )) + + } else { + // SELL 逻辑 + if (!task.supportSell) { + continue + } + + val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}" + val position = positions[positionKey] ?: continue + + // 计算卖出数量 + val sellQuantity = if (task.copyMode == "RATIO") { + if (position.leaderBuyQuantity != null && position.leaderBuyQuantity > BigDecimal.ZERO) { + position.quantity.multiply( + leaderTrade.size.divide(position.leaderBuyQuantity, 8, java.math.RoundingMode.DOWN) + ) + } else { + position.quantity // 全部卖出 + } + } else { + position.quantity // 固定金额模式全部卖出 + } + + // 确保不超过持仓数量 + val actualSellQuantity = if (sellQuantity > position.quantity) { + position.quantity + } else { + sellQuantity + } + + val sellAmount = actualSellQuantity.multiply(leaderTrade.price.toSafeBigDecimal()) + val netAmount = sellAmount // 不扣除手续费 + + // 计算盈亏 + val cost = actualSellQuantity.multiply(position.avgPrice) + val profitLoss = netAmount.subtract(cost) + + // 更新余额和持仓 + currentBalance += netAmount + position.quantity -= actualSellQuantity + if (position.quantity <= BigDecimal.ZERO) { + positions.remove(positionKey) + } + + // 记录交易 + trades.add(BacktestTrade( + backtestTaskId = task.id!!, + tradeTime = leaderTrade.tradeTimestamp, + marketId = leaderTrade.marketId, + marketTitle = leaderTrade.marketTitle, + side = "SELL", + outcome = leaderTrade.outcome ?: leaderTrade.outcomeIndex.toString(), + outcomeIndex = leaderTrade.outcomeIndex, + quantity = actualSellQuantity, + price = leaderTrade.price.toSafeBigDecimal(), + amount = sellAmount, + fee = BigDecimal.ZERO, + profitLoss = profitLoss, + balanceAfter = currentBalance, + leaderTradeId = leaderTrade.tradeId + )) + } + } catch (e: Exception) { + logger.error("处理交易失败: tradeId=${leaderTrade.tradeId}", e) + } + } + + // 6. 处理回测结束时仍未到期的持仓 (兜底处理) + currentBalance = settleRemainingPositions(task, positions, currentBalance, trades, endTime) + + // 7. 计算最终统计数据 + val statistics = calculateStatistics(trades) + + // 8. 更新任务状态 + val profitAmount = currentBalance.subtract(task.initialBalance) + val profitRate = if (task.initialBalance > BigDecimal.ZERO) { + profitAmount.divide(task.initialBalance, 4, java.math.RoundingMode.HALF_UP).multiply(BigDecimal("100")) + } else { + BigDecimal.ZERO + } + val finalStatus = if (task.status == "STOPPED") "STOPPED" else "COMPLETED" + val updatedTask = task.copy( + finalBalance = currentBalance, + profitAmount = profitAmount, + profitRate = profitRate, + endTime = endTime, + status = finalStatus, + progress = 100, + totalTrades = trades.size, + buyTrades = trades.count { it.side == "BUY" }, + sellTrades = trades.count { it.side == "SELL" }, + winTrades = statistics.winTrades, + lossTrades = statistics.lossTrades, + winRate = statistics.winRate, + maxProfit = statistics.maxProfit, + maxLoss = statistics.maxLoss, + maxDrawdown = statistics.maxDrawdown, + avgHoldingTime = statistics.avgHoldingTime, + executionFinishedAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + + backtestTaskRepository.save(updatedTask) + + // 9. 批量保存交易记录 + backtestTradeRepository.saveAll(trades) + + logger.info("回测任务执行完成: taskId=${task.id}, " + + "最终余额=${currentBalance.toPlainString()}, " + + "收益额=${task.profitAmount?.toPlainString()}, " + + "收益率=${task.profitRate?.toPlainString()}%, " + + "总交易数=${trades.size}, " + + "盈利率=${task.winRate?.toPlainString()}%") + + } catch (e: Exception) { + logger.error("回测任务执行失败: taskId=${task.id}", e) + task.status = "FAILED" + task.errorMessage = e.message + task.updatedAt = System.currentTimeMillis() + backtestTaskRepository.save(task) + throw e + } + } + + /** + * 结算已到期的市场 + */ + private suspend fun settleExpiredPositions( + task: BacktestTask, + positions: MutableMap, + currentBalance: BigDecimal, + trades: MutableList, + currentTime: Long + ): BigDecimal { + var balance = currentBalance + for ((positionKey, position) in positions.toList()) { + try { + // 获取市场当前价格 + val marketPrice = marketPriceService.getCurrentMarketPrice( + position.marketId, + position.outcomeIndex ?: 0 + ) + + val price = marketPrice.toSafeBigDecimal() + + // 通过市场价格判断结算价格 + val settlementPrice = when { + price >= BigDecimal("0.95") -> BigDecimal.ONE // 胜出 + price <= BigDecimal("0.05") -> BigDecimal.ZERO // 失败 + else -> position.avgPrice // 未结算或不确定,按成本价 + } + + val settlementValue = position.quantity.multiply(settlementPrice) + val profitLoss = settlementValue.subtract(position.quantity.multiply(position.avgPrice)) + + balance += settlementValue + + // 记录结算交易 + trades.add(BacktestTrade( + backtestTaskId = task.id!!, + tradeTime = currentTime, + marketId = position.marketId, + marketTitle = null, + side = "SETTLEMENT", + outcome = position.outcome, + outcomeIndex = position.outcomeIndex, + quantity = position.quantity, + price = settlementPrice, + amount = settlementValue, + fee = BigDecimal.ZERO, + profitLoss = profitLoss, + balanceAfter = currentBalance, + leaderTradeId = null + )) + + // 移除已结算的持仓 + positions.remove(positionKey) + + logger.info("市场结算: ${position.marketId}, 结算价=$settlementPrice, 盈亏=$profitLoss") + } catch (e: Exception) { + logger.warn("结算市场失败: ${position.marketId}", e) + } + } + return balance + } + + /** + * 结算剩余持仓 + */ + private suspend fun settleRemainingPositions( + task: BacktestTask, + positions: MutableMap, + currentBalance: BigDecimal, + trades: MutableList, + currentTime: Long + ): BigDecimal { + var balance = currentBalance + for ((positionKey, position) in positions.toList()) { + try { + val marketPrice = marketPriceService.getCurrentMarketPrice( + position.marketId, + position.outcomeIndex ?: 0 + ) + + val price = marketPrice.toSafeBigDecimal() + + val settlementPrice = when { + price >= BigDecimal("0.95") -> BigDecimal.ONE + price <= BigDecimal("0.05") -> BigDecimal.ZERO + else -> position.avgPrice + } + + val settlementValue = position.quantity.multiply(settlementPrice) + val profitLoss = settlementValue.subtract(position.quantity.multiply(position.avgPrice)) + + balance += settlementValue + + trades.add(BacktestTrade( + backtestTaskId = task.id!!, + tradeTime = currentTime, + marketId = position.marketId, + marketTitle = null, + side = "SETTLEMENT", + outcome = position.outcome, + outcomeIndex = position.outcomeIndex, + quantity = position.quantity, + price = settlementPrice, + amount = settlementValue, + fee = BigDecimal.ZERO, + profitLoss = profitLoss, + balanceAfter = balance, + leaderTradeId = null + )) + + logger.info("回测结束时结算剩余持仓: ${position.marketId}, 结算价=$settlementPrice") + } catch (e: Exception) { + logger.warn("结算市场失败: ${position.marketId}", e) + } + } + return balance + } + + /** + * 计算跟单金额 + */ + private fun calculateFollowAmount( + task: BacktestTask, + leaderTrade: LeaderTrade + ): BigDecimal { + return when (task.copyMode) { + "RATIO" -> leaderTrade.amount.multiply(task.copyRatio) + "FIXED" -> { + task.fixedAmount ?: leaderTrade.amount + } + else -> leaderTrade.amount + }.also { + // 应用最大/最小订单限制 + val maxLimit = task.maxOrderSize + val minLimit = task.minOrderSize + if (it > maxLimit) maxLimit + else if (it < minLimit) minLimit + else it + } + } + + /** + * 判断是否同一天 + */ + private fun isSameDay(timestamp1: Long, timestamp2: Long): Boolean { + val calendar1 = Calendar.getInstance().apply { timeInMillis = timestamp1 } + val calendar2 = Calendar.getInstance().apply { timeInMillis = timestamp2 } + return calendar1.get(Calendar.YEAR) == calendar2.get(Calendar.YEAR) && + calendar1.get(Calendar.DAY_OF_YEAR) == calendar2.get(Calendar.DAY_OF_YEAR) + } + + /** + * 格式化时间戳 + */ + private fun formatTimestamp(timestamp: Long): String { + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + return sdf.format(Date(timestamp)) + } + + /** + * 计算统计数据 + */ + private fun calculateStatistics(trades: List): StatisticsData { + val buyTrades = trades.filter { it.side == "BUY" } + val sellTrades = trades.filter { it.side == "SELL" } + val settlementTrades = trades.filter { it.side == "SETTLEMENT" } + + val profitLossList = trades.mapNotNull { it.profitLoss } + val winTrades = profitLossList.count { it > BigDecimal.ZERO } + val lossTrades = profitLossList.count { it < BigDecimal.ZERO } + + val totalTrades = profitLossList.size + val winRate = if (totalTrades > 0) { + winTrades.toBigDecimal() + .divide(totalTrades.toBigDecimal(), 4, java.math.RoundingMode.HALF_UP) + .multiply(BigDecimal("100")) + } else { + BigDecimal.ZERO + } + + val maxProfit = profitLossList.maxOrNull() ?: BigDecimal.ZERO + val maxLoss = profitLossList.minOrNull() ?: BigDecimal.ZERO + + // 计算最大回撤 + var maxBalance = BigDecimal.ZERO + var maxDrawdown = BigDecimal.ZERO + for (trade in trades) { + if (trade.balanceAfter > maxBalance) { + maxBalance = trade.balanceAfter + } + val drawdown = maxBalance.subtract(trade.balanceAfter) + if (drawdown > maxDrawdown) { + maxDrawdown = drawdown + } + } + + // 计算平均持仓时间 + val avgHoldingTime = calculateAvgHoldingTime(buyTrades, sellTrades, settlementTrades) + + return StatisticsData( + winTrades = winTrades, + lossTrades = lossTrades, + winRate = winRate, + maxProfit = maxProfit, + maxLoss = maxLoss, + maxDrawdown = maxDrawdown, + avgHoldingTime = avgHoldingTime + ) + } + + /** + * 计算平均持仓时间 + */ + private fun calculateAvgHoldingTime( + buyTrades: List, + sellTrades: List, + settlementTrades: List + ): Long? { + val marketHoldings = mutableMapOf>() + + // 记录买入时间 + for (buyTrade in buyTrades) { + val key = "${buyTrade.marketId}:${buyTrade.outcomeIndex ?: 0}" + marketHoldings.getOrPut(key) { mutableListOf() }.add(buyTrade.tradeTime) + } + + // 计算持仓时间 + val holdingTimes = mutableListOf() + for (sellTrade in sellTrades) { + val key = "${sellTrade.marketId}:${sellTrade.outcomeIndex ?: 0}" + val buyTimes = marketHoldings[key] ?: continue + if (buyTimes.isNotEmpty()) { + val buyTime = buyTimes.removeFirst() + val holdingTime = sellTrade.tradeTime - buyTime + if (holdingTime > 0) { + holdingTimes.add(holdingTime) + } + } + } + + // 处理结算 + for (settleTrade in settlementTrades) { + val key = "${settleTrade.marketId}:${settleTrade.outcomeIndex ?: 0}" + val buyTimes = marketHoldings[key] ?: continue + if (buyTimes.isNotEmpty()) { + val buyTime = buyTimes.removeFirst() + val holdingTime = settleTrade.tradeTime - buyTime + if (holdingTime > 0) { + holdingTimes.add(holdingTime) + } + } + } + + return if (holdingTimes.isNotEmpty()) { + holdingTimes.sum().toLong() / holdingTimes.size + } else { + null + } + } + + /** + * 统计数据 + */ + data class StatisticsData( + val winTrades: Int, + val lossTrades: Int, + val winRate: BigDecimal, + val maxProfit: BigDecimal, + val maxLoss: BigDecimal, + val maxDrawdown: BigDecimal, + val avgHoldingTime: Long? + ) +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestPollingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestPollingService.kt new file mode 100644 index 0000000..7716705 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestPollingService.kt @@ -0,0 +1,88 @@ +package com.wrbug.polymarketbot.service.backtest + +import com.wrbug.polymarketbot.entity.BacktestTask +import com.wrbug.polymarketbot.repository.BacktestTaskRepository +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.ThreadPoolExecutor +import kotlinx.coroutines.runBlocking + +/** + * 回测轮询服务 + * 定时获取待执行的回测任务并执行 + */ +@Service +class BacktestPollingService( + private val backtestTaskRepository: BacktestTaskRepository, + private val executionService: BacktestExecutionService +) { + private val logger = LoggerFactory.getLogger(BacktestPollingService::class.java) + + // 线程池:同一时刻只执行一个任务 + private val executor: ExecutorService = Executors.newFixedThreadPool(1) as ThreadPoolExecutor + + /** + * 轮询待执行的回测任务 + * 每 10 秒执行一次 + * 规则:同一时刻只执行一个任务,如果有多个待执行任务,按创建时间先后执行最早创建的 + */ + @Scheduled(fixedDelay = 10000) // 10 秒 + fun pollPendingTasks() { + try { + logger.debug("开始轮询待执行的回测任务") + + // 1. 检查是否有正在执行的任务,如果有则跳过本次轮询 + val runningTasks = backtestTaskRepository.findByStatus("RUNNING") + if (runningTasks.isNotEmpty()) { + logger.debug("有 ${runningTasks.size} 个任务正在执行,跳过本次轮询") + return + } + + // 2. 查询所有 PENDING 状态的任务,按创建时间升序排序 + val pendingTasks = backtestTaskRepository.findByStatus("PENDING") + .sortedBy { it.createdAt } + + if (pendingTasks.isEmpty()) { + logger.debug("没有待执行的回测任务") + return + } + + // 3. 只执行最早创建的任务 + val taskToExecute = pendingTasks.first() + logger.info("找到 ${pendingTasks.size} 个待执行的回测任务,执行最早创建的任务: taskId=${taskToExecute.id}, createdAt=${taskToExecute.createdAt}") + + // 4. 提交任务到线程池执行 + executor.submit { + try { + // 执行前再次检查任务状态(防止并发执行) + val currentTask = backtestTaskRepository.findById(taskToExecute.id!!).orElse(null) + if (currentTask == null || currentTask.status != "PENDING") { + logger.debug("任务状态已变更,跳过执行: taskId=${taskToExecute.id}, currentStatus=${currentTask?.status}") + return@submit + } + + runBlocking { + executionService.executeBacktest(currentTask) + } + } catch (e: Exception) { + logger.error("回测任务执行失败: taskId=${taskToExecute.id}", e) + // 更新任务状态为 FAILED + val failedTask = backtestTaskRepository.findById(taskToExecute.id!!).orElse(null) + if (failedTask != null) { + failedTask.status = "FAILED" + failedTask.errorMessage = e.message + failedTask.updatedAt = System.currentTimeMillis() + backtestTaskRepository.save(failedTask) + } + } + } + + } catch (e: Exception) { + logger.error("轮询回测任务失败", e) + } + } + +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestService.kt new file mode 100644 index 0000000..3398bdb --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/backtest/BacktestService.kt @@ -0,0 +1,355 @@ +package com.wrbug.polymarketbot.service.backtest + +import com.wrbug.polymarketbot.dto.* +import com.wrbug.polymarketbot.entity.BacktestTask +import com.wrbug.polymarketbot.entity.BacktestTrade +import com.wrbug.polymarketbot.entity.Leader +import com.wrbug.polymarketbot.enums.ErrorCode +import com.wrbug.polymarketbot.repository.BacktestTaskRepository +import com.wrbug.polymarketbot.repository.BacktestTradeRepository +import com.wrbug.polymarketbot.repository.LeaderRepository +import com.wrbug.polymarketbot.util.toSafeBigDecimal +import com.wrbug.polymarketbot.util.toJson +import com.wrbug.polymarketbot.util.fromJson +import org.slf4j.LoggerFactory +import org.springframework.context.MessageSource +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.math.BigDecimal + +/** + * 回测任务服务 + */ +@Service +class BacktestService( + private val backtestTaskRepository: BacktestTaskRepository, + private val backtestTradeRepository: BacktestTradeRepository, + private val leaderRepository: LeaderRepository, + private val messageSource: MessageSource +) { + private val logger = LoggerFactory.getLogger(BacktestService::class.java) + + /** + * 创建回测任务 + */ + @Transactional + fun createBacktestTask(request: BacktestCreateRequest): Result { + return try { + // 1. 验证 Leader 是否存在 + val leader = leaderRepository.findById(request.leaderId).orElse(null) + ?: return Result.failure(IllegalArgumentException("Leader 不存在")) + + // 2. 验证回测天数 + if (request.backtestDays < 1 || request.backtestDays > 15) { + return Result.failure(IllegalArgumentException("回测天数必须在 1-15 之间")) + } + + // 3. 验证初始金额 + val initialBalance = request.initialBalance.toSafeBigDecimal() + if (initialBalance <= BigDecimal.ZERO) { + return Result.failure(IllegalArgumentException("初始金额必须大于 0")) + } + + // 4. 创建回测任务 + val task = BacktestTask( + taskName = request.taskName.trim(), + leaderId = request.leaderId, + initialBalance = initialBalance, + backtestDays = request.backtestDays, + startTime = System.currentTimeMillis() - (request.backtestDays * 24 * 3600 * 1000), + status = "PENDING", + + // 跟单配置(不包含 max_position_count) + copyMode = request.copyMode ?: "RATIO", + copyRatio = request.copyRatio?.toSafeBigDecimal() ?: BigDecimal.ONE, + fixedAmount = request.fixedAmount?.toSafeBigDecimal(), + maxOrderSize = request.maxOrderSize?.toSafeBigDecimal() ?: "1000".toSafeBigDecimal(), + 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) + + // 5. 转换为 DTO 返回 + Result.success(task.toDto(leader)) + } catch (e: Exception) { + logger.error("创建回测任务失败", e) + Result.failure(e) + } + } + + /** + * 查询回测任务列表 + */ + fun getBacktestTaskList(request: BacktestListRequest): Result { + return try { + // 获取所有符合条件的任务 + val allTasks = when { + request.leaderId != null && request.status != null -> { + backtestTaskRepository.findByLeaderIdAndStatus(request.leaderId, request.status) + } + request.leaderId != null -> { + backtestTaskRepository.findByLeaderId(request.leaderId) + .filter { request.status == null || it.status == request.status } + } + request.status != null -> { + backtestTaskRepository.findByStatus(request.status) + } + else -> { + backtestTaskRepository.findAll() + } + } + + // 排序 + val sortedTasks = when (request.sortBy) { + "profitAmount" -> { + if (request.sortOrder == "asc") { + allTasks.sortedBy { it.profitAmount } + } else { + allTasks.sortedByDescending { it.profitAmount } + } + } + "profitRate" -> { + if (request.sortOrder == "asc") { + allTasks.sortedBy { it.profitRate } + } else { + allTasks.sortedByDescending { it.profitRate } + } + } + else -> { + if (request.sortOrder == "asc") { + allTasks.sortedBy { it.createdAt } + } else { + allTasks.sortedByDescending { it.createdAt } + } + } + } + + // 分页 + val total = sortedTasks.size + val pagedTasks = sortedTasks + .drop((request.page - 1) * request.size) + .take(request.size) + + val list = pagedTasks.map { task -> + val leader = leaderRepository.findById(task.leaderId).orElse(null) + task.toDto(leader) + } + + Result.success( + BacktestListResponse( + list = list, + total = total.toLong(), + page = request.page, + size = request.size + ) + ) + } catch (e: Exception) { + logger.error("查询回测任务列表失败", e) + Result.failure(e) + } + } + + /** + * 查询回测任务详情 + */ + fun getBacktestTaskDetail(request: BacktestDetailRequest): Result { + return try { + val task = backtestTaskRepository.findById(request.id).orElse(null) + ?: return Result.failure(IllegalArgumentException("回测任务不存在")) + + val leader = leaderRepository.findById(task.leaderId).orElse(null) + + val config = BacktestConfigDto( + copyMode = task.copyMode, + copyRatio = task.copyRatio.toPlainString(), + fixedAmount = task.fixedAmount?.toPlainString(), + maxOrderSize = task.maxOrderSize.toPlainString(), + minOrderSize = task.minOrderSize.toPlainString(), + maxDailyLoss = task.maxDailyLoss.toPlainString(), + maxDailyOrders = task.maxDailyOrders, + priceTolerance = task.priceTolerance.toPlainString(), + delaySeconds = task.delaySeconds, + supportSell = task.supportSell, + minOrderDepth = task.minOrderDepth?.toPlainString(), + maxSpread = task.maxSpread?.toPlainString(), + minPrice = task.minPrice?.toPlainString(), + maxPrice = task.maxPrice?.toPlainString(), + maxPositionValue = task.maxPositionValue?.toPlainString(), + keywordFilterMode = task.keywordFilterMode, + keywords = if (task.keywords != null) { + task.keywords.fromJson>() + } else { + emptyList() + }, + maxMarketEndDate = task.maxMarketEndDate + ) + + val statistics = BacktestStatisticsDto( + totalTrades = task.totalTrades, + buyTrades = task.buyTrades, + sellTrades = task.sellTrades, + winTrades = task.winTrades, + lossTrades = task.lossTrades, + winRate = task.winRate?.toPlainString() ?: "0.00", + maxProfit = task.maxProfit?.toPlainString() ?: "0.00", + maxLoss = task.maxLoss?.toPlainString() ?: "0.00", + maxDrawdown = task.maxDrawdown?.toPlainString() ?: "0.00", + avgHoldingTime = task.avgHoldingTime + ) + + val taskDto = task.toDto(leader) + + Result.success( + BacktestDetailResponse( + task = taskDto, + config = config, + statistics = statistics + ) + ) + } catch (e: Exception) { + logger.error("查询回测任务详情失败", e) + Result.failure(e) + } + } + + /** + * 查询回测交易记录 + */ + fun getBacktestTrades(request: BacktestTradeListRequest): Result { + return try { + val pageRequest = PageRequest.of( + request.page - 1, + request.size, + Sort.by(Sort.Order.asc("tradeTime")) + ) + + val tradesPage = backtestTradeRepository.findByBacktestTaskId( + request.taskId, + pageRequest + ) + + val list = tradesPage.content.map { trade -> + BacktestTradeDto( + id = trade.id!!, + tradeTime = trade.tradeTime, + marketId = trade.marketId, + marketTitle = trade.marketTitle, + side = trade.side, + outcome = trade.outcome, + outcomeIndex = trade.outcomeIndex, + quantity = trade.quantity.toPlainString(), + price = trade.price.toPlainString(), + amount = trade.amount.toPlainString(), + fee = trade.fee.toPlainString(), + profitLoss = trade.profitLoss?.toPlainString(), + balanceAfter = trade.balanceAfter.toPlainString(), + leaderTradeId = trade.leaderTradeId + ) + } + + Result.success( + BacktestTradeListResponse( + list = list, + total = tradesPage.totalElements, + page = request.page, + size = request.size + ) + ) + } catch (e: Exception) { + logger.error("查询回测交易记录失败", e) + Result.failure(e) + } + } + + /** + * 删除回测任务 + */ + @Transactional + fun deleteBacktestTask(request: BacktestDeleteRequest): Result { + return try { + val task = backtestTaskRepository.findById(request.id).orElse(null) + ?: return Result.failure(IllegalArgumentException("回测任务不存在")) + + if (task.status == "RUNNING") { + return Result.failure(IllegalArgumentException("回测任务正在运行,无法删除")) + } + + backtestTaskRepository.deleteById(request.id) + Result.success(Unit) + } catch (e: Exception) { + logger.error("删除回测任务失败", e) + Result.failure(e) + } + } + + /** + * 停止回测任务 + */ + @Transactional + fun stopBacktestTask(request: BacktestStopRequest): Result { + return try { + val task = backtestTaskRepository.findById(request.id).orElse(null) + ?: return Result.failure(IllegalArgumentException("回测任务不存在")) + + if (task.status != "RUNNING") { + return Result.failure(IllegalArgumentException("回测任务未在运行中")) + } + + task.status = "STOPPED" + task.updatedAt = System.currentTimeMillis() + backtestTaskRepository.save(task) + + Result.success(Unit) + } catch (e: Exception) { + logger.error("停止回测任务失败", e) + Result.failure(e) + } + } +} + +/** + * 扩展函数:BacktestTask 转 DTO + */ +private fun BacktestTask.toDto(leader: Leader?): BacktestTaskDto { + return BacktestTaskDto( + id = this.id!!, + taskName = this.taskName, + leaderId = this.leaderId, + leaderName = leader?.leaderName, + leaderAddress = leader?.leaderAddress, + initialBalance = this.initialBalance.toPlainString(), + finalBalance = this.finalBalance?.toPlainString(), + profitAmount = this.profitAmount?.toPlainString(), + profitRate = this.profitRate?.toPlainString(), + backtestDays = this.backtestDays, + startTime = this.startTime, + endTime = this.endTime, + status = this.status, + progress = this.progress, + totalTrades = this.totalTrades, + createdAt = this.createdAt, + executionStartedAt = this.executionStartedAt, + executionFinishedAt = this.executionFinishedAt + ) +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt index e2a38e0..1b1f4cc 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt @@ -185,6 +185,7 @@ open class CopyOrderTrackingService( processedAt = System.currentTimeMillis() ) processedTradeRepository.save(processed) + } catch (e: Exception) { // 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底) if (isUniqueConstraintViolation(e)) { diff --git a/backend/src/main/resources/db/migration/V27__create_backtest_tables.sql b/backend/src/main/resources/db/migration/V27__create_backtest_tables.sql new file mode 100644 index 0000000..4a12ff7 --- /dev/null +++ b/backend/src/main/resources/db/migration/V27__create_backtest_tables.sql @@ -0,0 +1,97 @@ +-- ============================================ +-- 回测功能表创建 +-- ============================================ + +-- ============================================ +-- 2. 创建回测任务表 +-- ============================================ +CREATE TABLE IF NOT EXISTS backtest_task ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '回测任务ID', + task_name VARCHAR(100) NOT NULL COMMENT '回测任务名称', + leader_id BIGINT NOT NULL COMMENT 'Leader ID', + initial_balance DECIMAL(20, 8) NOT NULL COMMENT '初始资金', + final_balance DECIMAL(20, 8) DEFAULT NULL COMMENT '最终资金', + profit_amount DECIMAL(20, 8) DEFAULT NULL COMMENT '收益金额', + profit_rate DECIMAL(10, 4) DEFAULT NULL COMMENT '收益率(%)', + backtest_days INT NOT NULL COMMENT '回测天数', + start_time BIGINT NOT NULL COMMENT '回测开始时间(历史时间)', + end_time BIGINT DEFAULT NULL COMMENT '回测结束时间(历史时间)', + + -- 跟单配置 (复制CopyTrading表结构) + copy_mode VARCHAR(10) NOT NULL DEFAULT 'RATIO' COMMENT '跟单模式: RATIO/FIXED', + copy_ratio DECIMAL(20, 8) NOT NULL DEFAULT 1.0 COMMENT '跟单比例', + fixed_amount DECIMAL(20, 8) DEFAULT NULL COMMENT '固定金额', + max_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1000.0 COMMENT '最大单笔订单', + min_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1.0 COMMENT '最小单笔订单', + max_daily_loss DECIMAL(20, 8) NOT NULL DEFAULT 10000.0 COMMENT '最大每日亏损', + max_daily_orders INT NOT NULL DEFAULT 100 COMMENT '最大每日订单数', + price_tolerance DECIMAL(5, 2) NOT NULL DEFAULT 5.0 COMMENT '价格容忍度(%)', + delay_seconds INT NOT NULL DEFAULT 0 COMMENT '延迟秒数', + support_sell BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否支持卖出', + min_order_depth DECIMAL(20, 8) DEFAULT NULL COMMENT '最小订单深度', + max_spread DECIMAL(20, 8) DEFAULT NULL COMMENT '最大价差', + min_price DECIMAL(20, 8) DEFAULT NULL COMMENT '最低价格', + max_price DECIMAL(20, 8) DEFAULT NULL COMMENT '最高价格', + max_position_value DECIMAL(20, 8) DEFAULT NULL COMMENT '最大仓位金额', + keyword_filter_mode VARCHAR(20) NOT NULL DEFAULT 'DISABLED' COMMENT '关键字过滤模式', + keywords JSON DEFAULT NULL COMMENT '关键字列表', + max_market_end_date BIGINT DEFAULT NULL COMMENT '市场截止时间限制', + + -- 统计字段 + avg_holding_time BIGINT DEFAULT NULL COMMENT '平均持仓时间(毫秒)', + data_source VARCHAR(50) DEFAULT 'MIXED' COMMENT '数据源: INTERNAL/API/MIXED', + + -- 执行状态 + status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '状态: PENDING/RUNNING/COMPLETED/STOPPED/FAILED', + progress INT DEFAULT 0 COMMENT '执行进度(0-100)', + total_trades INT DEFAULT 0 COMMENT '总交易笔数', + buy_trades INT DEFAULT 0 COMMENT '买入笔数', + sell_trades INT DEFAULT 0 COMMENT '卖出笔数', + win_trades INT DEFAULT 0 COMMENT '盈利交易笔数', + loss_trades INT DEFAULT 0 COMMENT '亏损交易笔数', + win_rate DECIMAL(5, 2) DEFAULT NULL COMMENT '胜率(%)', + max_profit DECIMAL(20, 8) DEFAULT NULL COMMENT '最大单笔盈利', + max_loss DECIMAL(20, 8) DEFAULT NULL COMMENT '最大单笔亏损', + max_drawdown DECIMAL(20, 8) DEFAULT NULL COMMENT '最大回撤', + error_message TEXT DEFAULT NULL COMMENT '错误信息', + + created_at BIGINT NOT NULL COMMENT '创建时间', + execution_started_at BIGINT DEFAULT NULL COMMENT '执行开始时间(系统时间)', + execution_finished_at BIGINT DEFAULT NULL COMMENT '执行完成时间(系统时间)', + updated_at BIGINT NOT NULL COMMENT '更新时间', + + INDEX idx_leader_id (leader_id), + INDEX idx_status (status), + INDEX idx_created_at (created_at), + INDEX idx_leader_profit (leader_id, profit_rate DESC), + INDEX idx_status_created (status, created_at DESC), + FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='回测任务表'; + +-- ============================================ +-- 3. 创建回测交易记录表 +-- ============================================ +CREATE TABLE IF NOT EXISTS backtest_trade ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '交易记录ID', + backtest_task_id BIGINT NOT NULL COMMENT '回测任务ID', + trade_time BIGINT NOT NULL COMMENT '交易时间', + market_id VARCHAR(100) NOT NULL COMMENT '市场ID', + market_title VARCHAR(500) DEFAULT NULL COMMENT '市场标题', + side VARCHAR(20) NOT NULL COMMENT '方向: BUY/SELL/SETTLEMENT', + outcome VARCHAR(50) NOT NULL COMMENT '结果: YES/NO或outcomeIndex', + outcome_index INT DEFAULT NULL COMMENT '结果索引(0, 1, 2, ...),支持多元市场', + quantity DECIMAL(20, 8) NOT NULL COMMENT '数量', + price DECIMAL(20, 8) NOT NULL COMMENT '价格', + amount DECIMAL(20, 8) NOT NULL COMMENT '金额', + fee DECIMAL(20, 8) NOT NULL DEFAULT 0.0 COMMENT '手续费', + profit_loss DECIMAL(20, 8) DEFAULT NULL COMMENT '盈亏(仅卖出时)', + balance_after DECIMAL(20, 8) NOT NULL COMMENT '交易后余额', + leader_trade_id VARCHAR(100) DEFAULT NULL COMMENT 'Leader原始交易ID', + + created_at BIGINT NOT NULL COMMENT '创建时间', + + INDEX idx_backtest_task_id (backtest_task_id), + INDEX idx_trade_time (trade_time), + FOREIGN KEY (backtest_task_id) REFERENCES backtest_task(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='回测交易记录表'; + diff --git a/backend/src/main/resources/i18n/messages_en.properties b/backend/src/main/resources/i18n/messages_en.properties index 93783ef..c8119ce 100644 --- a/backend/src/main/resources/i18n/messages_en.properties +++ b/backend/src/main/resources/i18n/messages_en.properties @@ -255,3 +255,54 @@ error.server.order_tracking_process_failed=Failed to process order tracking error.server.order_tracking_buy_failed=Failed to process buy order error.server.order_tracking_sell_failed=Failed to process sell order error.server.order_tracking_match_failed=Order matching failed + +# Backtest service errors +error.backtest.task_not_found=Backtest task not found +error.backtest.leader_not_found=Leader not found +error.backtest.days_invalid=Backtest days must be between 1-15 days +error.backtest.initial_balance_invalid=Invalid initial balance +error.backtest.task_running=Backtest task is running, cannot delete +error.server.backtest_create_failed=Failed to create backtest task +error.server.backtest_update_failed=Failed to update backtest task +error.server.backtest_delete_failed=Failed to delete backtest task +error.server.backtest_list_fetch_failed=Failed to fetch backtest list +error.server.backtest_detail_fetch_failed=Failed to fetch backtest detail +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 + +# Backtest Management +backtest.title=Backtest Management +backtest.create_task=Create Backtest +backtest.task_name=Task Name +backtest.leader=Leader +backtest.initial_balance=Initial Balance +backtest.backtest_days=Backtest Days +backtest.profit_amount=Profit Amount +backtest.profit_rate=Profit Rate +backtest.backtest_days_range=Backtest Days Range (1-15 days) +backtest.total_trades=Total Trades +backtest.buy_trades=Buy Trades +backtest.sell_trades=Sell Trades +backtest.win_trades=Win Trades +backtest.loss_trades=Loss Trades +backtest.win_rate=Win Rate +backtest.max_profit=Max Profit +backtest.max_loss=Max Loss +backtest.max_drawdown=Max Drawdown +backtest.avg_holding_time=Avg Holding Time + +# Backtest Status +backtest.status.pending=Pending +backtest.status.running=Running +backtest.status.completed=Completed +backtest.status.stopped=Stopped +backtest.status.failed=Failed + +# Backtest Config +backtest.copy_mode.ratio=Ratio Mode +backtest.copy_mode.fixed=Fixed Amount +backtest.price_tolerance=Price Tolerance +backtest.delay_seconds=Delay Seconds +backtest.support_sell=Support Sell diff --git a/backend/src/main/resources/i18n/messages_zh_CN.properties b/backend/src/main/resources/i18n/messages_zh_CN.properties index 6132bc5..6ad087a 100644 --- a/backend/src/main/resources/i18n/messages_zh_CN.properties +++ b/backend/src/main/resources/i18n/messages_zh_CN.properties @@ -255,3 +255,60 @@ error.server.order_tracking_process_failed=处理订单跟踪失败 error.server.order_tracking_buy_failed=处理买入订单失败 error.server.order_tracking_sell_failed=处理卖出订单失败 error.server.order_tracking_match_failed=订单匹配失败 + +# 回测服务错误 +error.backtest.task_not_found=回测任务不存在 +error.backtest.leader_not_found=Leader不存在 +error.backtest.days_invalid=回测天数必须在 1-15 天之间 +error.backtest.initial_balance_invalid=初始金额无效 +error.backtest.task_running=回测任务正在运行,无法删除 +error.server.backtest_create_failed=创建回测任务失败 +error.server.backtest_update_failed=更新回测任务失败 +error.server.backtest_delete_failed=删除回测任务失败 +error.server.backtest_list_fetch_failed=查询回测列表失败 +error.server.backtest_detail_fetch_failed=查询回测详情失败 +error.server.backtest_trades_fetch_failed=查询回测交易记录失败 +error.server.backtest_execute_failed=回测执行失败 +error.server.backtest_historical_data_fetch_failed=历史数据获取失败 +error.server.backtest_stop_failed=停止回测任务失败 + +# 回测管理 +backtest.title=回测管理 +backtest.create_task=新增回测 +backtest.task_name=回测名称 +backtest.leader=Leader +backtest.initial_balance=初始金额 +backtest.backtest_days=回测天数 +backtest.profit_amount=收益额 +backtest.profit_rate=收益率 +backtest.backtest_days_range=回测天数范围(1-15天) +backtest.total_trades=总交易笔数 +backtest.buy_trades=买入笔数 +backtest.sell_trades=卖出笔数 +backtest.win_trades=盈利交易笔数 +backtest.loss_trades=亏损交易笔数 +backtest.win_rate=胜率 +backtest.max_profit=最大单笔盈利 +backtest.max_loss=最大单笔亏损 +backtest.max_drawdown=最大回撤 +backtest.avg_holding_time=平均持仓时间 + +# 回测状态 +backtest.status.pending=待执行 +backtest.status.running=运行中 +backtest.status.completed=已完成 +backtest.status.stopped=已停止 +backtest.status.failed=失败 + +# 回测配置 +backtest.copy_mode.ratio=比例模式 +backtest.copy_mode.fixed=固定金额 +backtest.price_tolerance=价格容忍度 +backtest.delay_seconds=延迟秒数 +backtest.support_sell=支持卖出 + +# 订单跟踪服务错误 +error.server.order_tracking_process_failed=处理订单跟踪失败 +error.server.order_tracking_buy_failed=处理买入订单失败 +error.server.order_tracking_sell_failed=处理卖出订单失败 +error.server.order_tracking_match_failed=订单匹配失败 diff --git a/backend/src/main/resources/i18n/messages_zh_TW.properties b/backend/src/main/resources/i18n/messages_zh_TW.properties index 1d96df8..82ffc2b 100644 --- a/backend/src/main/resources/i18n/messages_zh_TW.properties +++ b/backend/src/main/resources/i18n/messages_zh_TW.properties @@ -255,3 +255,54 @@ error.server.order_tracking_process_failed=處理訂單跟蹤失敗 error.server.order_tracking_buy_failed=處理買入訂單失敗 error.server.order_tracking_sell_failed=處理賣出訂單失敗 error.server.order_tracking_match_failed=訂單匹配失敗 + +# 回測服務錯誤 +error.backtest.task_not_found=回測任務不存在 +error.backtest.leader_not_found=Leader不存在 +error.backtest.days_invalid=回測天數必須在 1-15 天之間 +error.backtest.initial_balance_invalid=初始金額無效 +error.backtest.task_running=回測任務正在運行,無法刪除 +error.server.backtest_create_failed=創建回測任務失敗 +error.server.backtest_update_failed=更新回測任務失敗 +error.server.backtest_delete_failed=刪除回測任務失敗 +error.server.backtest_list_fetch_failed=查詢回測列表失敗 +error.server.backtest_detail_fetch_failed=查詢回測詳情失敗 +error.server.backtest_trades_fetch_failed=查詢回測交易記錄失敗 +error.server.backtest_execute_failed=回測執行失敗 +error.server.backtest_historical_data_fetch_failed=歷史數據獲取失敗 +error.server.backtest_stop_failed=停止回測任務失敗 + +# 回測管理 +backtest.title=回測管理 +backtest.create_task=新增回測 +backtest.task_name=回測名稱 +backtest.leader=Leader +backtest.initial_balance=初始金額 +backtest.backtest_days=回測天數 +backtest.profit_amount=收益額 +backtest.profit_rate=收益率 +backtest.backtest_days_range=回測天數範圍(1-15天) +backtest.total_trades=總交易筆數 +backtest.buy_trades=買入筆數 +backtest.sell_trades=賣出筆數 +backtest.win_trades=盈利交易筆數 +backtest.loss_trades=虧損交易筆數 +backtest.win_rate=勝率 +backtest.max_profit=最大單筆盈利 +backtest.max_loss=最大單筆虧損 +backtest.max_drawdown=最大回撤 +backtest.avg_holding_time=平均持倉時間 + +# 回測狀態 +backtest.status.pending=待執行 +backtest.status.running=運行中 +backtest.status.completed=已完成 +backtest.status.stopped=已停止 +backtest.status.failed=失敗 + +# 回測配置 +backtest.copy_mode.ratio=比例模式 +backtest.copy_mode.fixed=固定金額 +backtest.price_tolerance=價格容忍度 +backtest.delay_seconds=延遲秒數 +backtest.support_sell=支持賣出 diff --git a/docs/zh/backtest/BACKTEST_PRD.md b/docs/zh/backtest/BACKTEST_PRD.md index 905afff..014d324 100644 --- a/docs/zh/backtest/BACKTEST_PRD.md +++ b/docs/zh/backtest/BACKTEST_PRD.md @@ -94,7 +94,6 @@ | 最低价格 | minPrice | 最低价格限制 | null | | 最高价格 | maxPrice | 最高价格限制 | null | | 最大仓位金额 | maxPositionValue | 最大持仓总金额 | null | -| 最大仓位数量 | maxPositionCount | 最大持仓数量 | null | | 关键字过滤模式 | keywordFilterMode | DISABLED/WHITELIST/BLACKLIST | DISABLED | | 关键字列表 | keywords | 关键字数组 | [] | | 市场截止时间限制 | maxMarketEndDate | 市场结束时间限制 | null | diff --git a/docs/zh/backtest/BACKTEST_REVIEW_CHECKLIST.md b/docs/zh/backtest/BACKTEST_REVIEW_CHECKLIST.md index aeedf0d..860a431 100644 --- a/docs/zh/backtest/BACKTEST_REVIEW_CHECKLIST.md +++ b/docs/zh/backtest/BACKTEST_REVIEW_CHECKLIST.md @@ -35,23 +35,56 @@ > 2. **缓存策略**: 建议对Leader历史交易数据使用分层缓存 (内存 + Redis) > 3. **回测结果的序列化**: 考虑将详细交易记录存储为JSON,减少表的大小 -### 1.3 业务逻辑准确性 ⚠️ +### 1.3 业务逻辑准确性 ✅ -**需要验证的关键逻辑**: +**已完善的关键逻辑**: -#### 1.3.1 卖出匹配逻辑 -> [!CAUTION] -> **潜在问题**: 当前设计中,卖出时如何匹配对应的买入持仓? -> -> **当前方案**: 使用 `positionKey = marketId + outcome` 匹配 -> -> **问题场景**: -> - 同一市场同一方向多次买入,价格不同 (需要先进先出FIFO吗?) -> - Leader部分卖出时,如何计算跟单的卖出比例? -> -> **建议**: -> - 明确卖出匹配策略: FIFO (先进先出) 或 加权平均价 -> - 在技术设计文档中补充详细说明 +#### 1.3.1 历史数据获取 ⭐ (已修正) +> [!NOTE] +> **问题**: 现有 `ProcessedTrade` 表字段有限,无法满足回测需求。 +> +> **解决方案**: 创建独立的 `backtest_historical_trades` 表 +> - ✅ 存储完整的交易信息(marketId, price, quantity, outcomeIndex 等) +> - ✅ 支持实时数据同步(跟单时同时写入) +> - ✅ 支持通过 API 补充历史数据 +> - ✅ 不影响现有跟单功能 + +#### 1.3.2 卖出匹配逻辑 ⭐ (已修正) +> [!NOTE] +> **改进**: 使用 `outcomeIndex` 支持多元市场 +> +> **实现方案**: +> - 持仓键: `marketId + outcomeIndex`(支持多元市场) +> - 比例模式: 按 Leader 卖出比例计算 +> - 固定金额模式: 全部卖出 +> - 参考 `CopyOrderTracking` 的逻辑 + +**伪代码**: +```kotlin +val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}" +val position = positions[positionKey] ?: continue + +val sellQuantity = if (task.copyMode == "RATIO") { + if (position.leaderBuyQuantity != null && position.leaderBuyQuantity > BigDecimal.ZERO) { + position.quantity * (leaderTrade.quantity / position.leaderBuyQuantity) + } else { + position.quantity // 全部卖出 + } +} else { + position.quantity // 固定金额模式全部卖出 +} +``` + +#### 1.3.3 价格滑点模拟 ✅ (已决策) +> [!NOTE] +> **用户决策**: 暂不模拟价格滑点 +> +> **理由**: +> - 简化回测逻辑 +> - 减少复杂度 +> - 后续可以作为可选项添加 +> +> **实现**: 使用 Leader 的成交价,不进行滑点调整 #### 1.3.2 价格滑点模拟 > [!NOTE] @@ -84,40 +117,57 @@ #### 1.3.4 市场结算处理 ⭐ (已优化) > [!NOTE] -> **关键问题**: 市场结束时,未平仓位如何自动结算? -> +> **问题**: 市场结束时,未平仓位如何自动结算? +> > **优化方案** (采纳用户建议): > - ✅ **实时检查**: 在回测循环中,每处理一笔Leader交易前,检查所有持仓的市场`endDate` > - ✅ **到期即结算**: 如果 `marketEndDate <= currentTradeTime`,立即结算该持仓 > - ✅ **资金可用**: 结算后的资金立即计入余额,可以用于后续交易 > - ✅ **兜底处理**: 回测结束时,结算所有剩余未到期持仓 -> +> +> **结算价格判断** (通过市场价格): +> - 价格 >= 0.95: 胜出 (按 1.0 结算) +> - 价格 <= 0.05: 失败 (按 0.0 结算) +> - 其他情况: 按成本价保守估计 +> > **实现要点**: > ```kotlin > // 在交易循环中实时检查市场到期 > for (leaderTrade in leaderTrades.sortedBy { it.timestamp }) { -> +> > // 1. 检查并结算已到期的市场 > val expiredPositions = positions.filter { (_, position) -> > val marketInfo = getMarketInfo(position.marketId) > marketInfo.endDate <= leaderTrade.timestamp > } -> +> > for ((positionKey, position) in expiredPositions) { -> // 结算逻辑... +> val marketPrice = marketPriceService.getCurrentMarketPrice( +> marketId = position.marketId, +> outcomeIndex = position.outcomeIndex ?: 0 +> ) +> +> val settlementPrice = when { +> marketPrice >= BigDecimal("0.95") -> BigDecimal.ONE // 胜出 +> marketPrice <= BigDecimal("0.05") -> BigDecimal.ZERO // 失败 +> else -> position.avgPrice // 未结算,按成本价 +> } +> +> val settlementValue = position.quantity * settlementPrice > currentBalance += settlementValue > positions.remove(positionKey) > } -> +> > // 2. 处理当前Leader交易 > // ... > } > ``` -> +> > **优势**: > - 更符合真实场景 (市场结束时自动返还资金) > - 提高资金利用率 (结算资金可参与后续交易) > - 更准确的收益计算 +> - 通过市场价格判断,无需依赖可能不存在的 `winner` 字段 #### 1.3.5 余额不足与持仓处理 ⚠️ (边缘场景) - 已修正 > [!WARNING] @@ -148,7 +198,53 @@ > > **已在文档中采用**: 严格模式 -#### 1.3.6 回测停止条件 ✅ (已修正) +#### 1.3.6 每日订单数限制 ✅ (已补充) +> [!NOTE] +> **问题**: 文档提到了 `maxDailyOrders` 参数,但未在算法中实现 +> +> **解决方案**: 在回测循环中添加每日订单数统计 +> +> **实现**: +> ```kotlin +> // 统计当前交易时间当天已有的订单数 +> val dailyOrderCount = trades.count { isSameDay(it.tradeTime, leaderTrade.timestamp) } +> +> if (dailyOrderCount >= task.maxDailyOrders) { +> logger.info("已达到每日最大订单数限制: $dailyOrderCount / ${task.maxDailyOrders}") +> continue +> } +> ``` +> +> **优势**: +> - 符合实际跟单的风险控制逻辑 +> - 避免回测结果过于激进 + +#### 1.3.7 价格容忍度检查 ✅ (已补充) +> [!NOTE] +> **问题**: 文档提到了 `priceTolerance` 参数,但未在算法中实现 +> +> **解决方案**: 在执行交易前检查当前市场价格是否在容忍范围内 +> +> **实现**: +> ```kotlin +> if (task.priceTolerance > BigDecimal.ZERO) { +> val tolerance = task.priceTolerance.divide(BigDecimal("100")) +> val minPrice = leaderTrade.price.multiply(BigDecimal.ONE.subtract(tolerance)) +> val maxPrice = leaderTrade.price.multiply(BigDecimal.ONE.add(tolerance)) +> +> val currentPrice = marketPriceService.getCurrentMarketPrice( +> marketId = leaderTrade.marketId, +> outcomeIndex = leaderTrade.outcomeIndex ?: 0 +> ) +> +> if (currentPrice < minPrice || currentPrice > maxPrice) { +> logger.info("价格超出容忍度范围: 当前=$currentPrice, 可用范围=[$minPrice, $maxPrice]") +> continue +> } +> } +> ``` + +#### 1.3.8 回测停止条件 ✅ (已修正) > [!NOTE] > **修正**: 基于用户反馈,修正了停止逻辑 > @@ -194,7 +290,54 @@ ## 二、数据库设计补充 -### 2.1 缺失的字段建议 +### 2.1 新增回测历史交易表 + +**问题**: 现有 `ProcessedTrade` 表字段有限,无法满足回测需求。 + +**解决方案**: 创建独立的 `backtest_historical_trades` 表,存储完整的 Leader 历史交易数据。 + +```sql +CREATE TABLE backtest_historical_trades ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID', + leader_id BIGINT NOT NULL COMMENT 'Leader ID', + trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 交易ID(唯一标识)', + market_id VARCHAR(100) NOT NULL COMMENT '市场ID', + market_title VARCHAR(500) DEFAULT NULL COMMENT '市场标题', + market_slug VARCHAR(200) DEFAULT NULL COMMENT '市场 slug(用于生成链接)', + side VARCHAR(10) NOT NULL COMMENT '交易方向: BUY/SELL', + outcome VARCHAR(50) DEFAULT NULL COMMENT '市场方向(如 YES, NO 等)', + outcome_index INT DEFAULT NULL COMMENT '结果索引(0, 1, 2, ...),支持多元市场', + price DECIMAL(20, 8) NOT NULL COMMENT '交易价格', + size DECIMAL(20, 8) NOT NULL COMMENT '交易数量', + amount DECIMAL(20, 8) NOT NULL COMMENT '交易金额(price × size)', + trade_timestamp BIGINT NOT NULL COMMENT '交易时间戳(毫秒)', + + -- 元数据 + source VARCHAR(20) NOT NULL DEFAULT 'POLLING' COMMENT '数据来源: WEBSOCKET/POLLING/API', + fetched_at BIGINT NOT NULL COMMENT '数据获取时间(毫秒)', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒)', + + UNIQUE INDEX uk_leader_trade (leader_id, trade_id), + INDEX idx_leader_id (leader_id), + INDEX idx_trade_timestamp (trade_timestamp), + INDEX idx_market_id (market_id) +) COMMENT='回测历史交易表'; +``` + +**优势**: +- ✅ 不影响现有跟单系统的 `ProcessedTrade` 表 +- ✅ 存储完整的交易信息,满足回测需求 +- ✅ 支持实时数据同步(跟单时同时写入) +- ✅ 支持通过 API 补充历史数据 +- ✅ 唯一索引自动去重 + +### 2.2 移除 max_position_count 字段 + +**问题**: 文档中包含 `max_position_count` 字段,但 V26 迁移已删除该字段。 + +**解决方案**: 从 `backtest_task` 表和相关 API 中移除该字段。 + +### 2.3 其他字段建议 #### `backtest_task` 表 建议新增以下字段: @@ -204,13 +347,13 @@ avg_holding_time BIGINT DEFAULT NULL COMMENT '平均持仓时间(毫秒)', -- 用于记录回测使用的数据源 -data_source VARCHAR(50) DEFAULT 'API' COMMENT '数据源: INTERNAL/API/MIXED', +data_source VARCHAR(50) DEFAULT 'MIXED' COMMENT '数据源: INTERNAL/API/MIXED', -- 用于记录回测执行的详细日志 execution_log TEXT DEFAULT NULL COMMENT '执行日志(JSON格式)' ``` -### 2.2 索引优化 +### 2.4 索引优化 建议添加复合索引: ```sql @@ -223,13 +366,50 @@ CREATE INDEX idx_status_created ON backtest_task(status, created_at DESC); ## 三、API设计补充 -### 3.1 缺失的API +### 3.1 API 规范修正 + +**问题**: 文档中使用 GET/DELETE 方法,违反项目统一使用 POST 的规范。 + +**修正方案**: + +```bash +# ❌ 错误(使用 GET/DELETE) +GET /api/backtest/tasks +GET /api/backtest/tasks/{id} +DELETE /api/backtest/tasks/{id} + +# ✅ 正确(统一使用 POST) +POST /api/backtest/tasks/list +POST /api/backtest/tasks/detail +POST /api/backtest/tasks/delete +``` + +**完整的 API 列表**: + +| 功能 | 方法 | 路径 | 说明 | +|-----|------|------|------| +| 创建回测 | POST | /api/backtest/tasks | 创建新的回测任务 | +| 查询列表 | POST | /api/backtest/tasks/list | 分页查询回测任务列表 | +| 查询详情 | POST | /api/backtest/tasks/detail | 查询单个回测任务详情 | +| 查询交易 | POST | /api/backtest/tasks/trades | 查询回测的交易记录 | +| 删除任务 | POST | /api/backtest/tasks/delete | 删除回测任务 | +| 停止任务 | POST | /api/backtest/tasks/stop | 停止运行中的回测 | +| 查询进度 | POST | /api/backtest/tasks/progress | 查询回测执行进度 | + +### 3.2 缺失的API 建议新增以下API: -#### 3.1.1 查询回测进度 (实时更新) +#### 3.2.1 查询回测进度 (实时更新) ``` -GET /api/backtest/tasks/{id}/progress +POST /api/backtest/tasks/progress +``` + +**Request Body**: +```json +{ + "id": 12345 +} ``` **Response**: @@ -245,9 +425,9 @@ GET /api/backtest/tasks/{id}/progress } ``` -#### 3.1.2 批量删除回测任务 +#### 3.2.2 批量删除回测任务 ``` -DELETE /api/backtest/tasks +POST /api/backtest/tasks/batch-delete ``` **Request Body**: @@ -257,9 +437,17 @@ DELETE /api/backtest/tasks } ``` -#### 3.1.3 导出回测报告 +#### 3.2.3 导出回测报告 ``` -GET /api/backtest/tasks/{id}/export?format=csv|pdf +POST /api/backtest/tasks/export +``` + +**Request Body**: +```json +{ + "id": 12345, + "format": "csv" // 或 "pdf" +} ``` ### 3.2 API错误码规范 diff --git a/docs/zh/backtest/BACKTEST_TECHNICAL_DESIGN.md b/docs/zh/backtest/BACKTEST_TECHNICAL_DESIGN.md index 25abe8a..708dddf 100644 --- a/docs/zh/backtest/BACKTEST_TECHNICAL_DESIGN.md +++ b/docs/zh/backtest/BACKTEST_TECHNICAL_DESIGN.md @@ -74,7 +74,6 @@ CREATE TABLE backtest_task ( min_price DECIMAL(20, 8) DEFAULT NULL COMMENT '最低价格', max_price DECIMAL(20, 8) DEFAULT NULL COMMENT '最高价格', max_position_value DECIMAL(20, 8) DEFAULT NULL COMMENT '最大仓位金额', - max_position_count INT DEFAULT NULL COMMENT '最大仓位数量', keyword_filter_mode VARCHAR(20) DEFAULT 'DISABLED' COMMENT '关键字过滤模式', keywords JSON DEFAULT NULL COMMENT '关键字列表', max_market_end_date BIGINT DEFAULT NULL COMMENT '市场截止时间限制', @@ -131,7 +130,53 @@ CREATE TABLE backtest_trade ( ) COMMENT='回测交易记录表'; ``` -### 2.3 索引优化建议 +### 2.3 回测历史交易表 (backtest_historical_trades) + +**说明**: 用于存储 Leader 的历史交易数据,供回测使用。独立于 `ProcessedTrade` 表,避免影响现有跟单功能。 + +```sql +CREATE TABLE backtest_historical_trades ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID', + leader_id BIGINT NOT NULL COMMENT 'Leader ID', + trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 交易ID(唯一标识)', + market_id VARCHAR(100) NOT NULL COMMENT '市场ID', + market_title VARCHAR(500) DEFAULT NULL COMMENT '市场标题', + market_slug VARCHAR(200) DEFAULT NULL COMMENT '市场 slug(用于生成链接)', + side VARCHAR(10) NOT NULL COMMENT '交易方向: BUY/SELL', + outcome VARCHAR(50) DEFAULT NULL COMMENT '市场方向(如 YES, NO 等)', + outcome_index INT DEFAULT NULL COMMENT '结果索引(0, 1, 2, ...),支持多元市场', + price DECIMAL(20, 8) NOT NULL COMMENT '交易价格', + size DECIMAL(20, 8) NOT NULL COMMENT '交易数量', + amount DECIMAL(20, 8) NOT NULL COMMENT '交易金额(price × size)', + trade_timestamp BIGINT NOT NULL COMMENT '交易时间戳(毫秒)', + + -- 元数据 + source VARCHAR(20) NOT NULL DEFAULT 'POLLING' COMMENT '数据来源: WEBSOCKET/POLLING/API', + fetched_at BIGINT NOT NULL COMMENT '数据获取时间(毫秒)', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒)', + + UNIQUE INDEX uk_leader_trade (leader_id, trade_id), + INDEX idx_leader_id (leader_id), + INDEX idx_trade_timestamp (trade_timestamp), + INDEX idx_market_id (market_id) +) COMMENT='回测历史交易表'; +``` + +**字段说明**: +- `trade_id`: Leader 的交易唯一标识符,用于去重 +- `market_id`, `market_title`, `market_slug`: 市场信息,用于回测时显示和链接 +- `side`, `outcome`, `outcome_index`: 交易方向和结果,支持二元和多元市场 +- `price`, `size`, `amount`: 交易的价格、数量和金额 +- `trade_timestamp`: 交易发生的历史时间,用于按时间回放 +- `source`: 数据来源,区分 WebSocket 实时推送、轮询或 API 查询 +- `fetched_at`: 系统获取该交易数据的时间 + +**数据获取策略**: +1. **优先从现有 ProcessedTrade 扩展**: 在跟单系统处理交易时,同时写入此表 +2. **补充历史数据**: 调用 Polymarket API 获取更早的历史交易 +3. **去重机制**: 使用 `leader_id + trade_id` 唯一索引避免重复 + +### 2.4 索引优化建议 - `backtest_task`: - 主查询索引: `idx_leader_id`, `idx_status` @@ -139,6 +184,10 @@ CREATE TABLE backtest_trade ( - `backtest_trade`: - 关联查询索引: `idx_backtest_task_id` - 时间序列索引: `idx_trade_time` +- `backtest_historical_trades`: + - 去重索引: `uk_leader_trade` + - 主查询索引: `idx_leader_id` + - 时间序列索引: `idx_trade_timestamp` ## 三、API设计 @@ -172,7 +221,6 @@ POST /api/backtest/tasks "minPrice": null, "maxPrice": null, "maxPositionValue": null, - "maxPositionCount": null, "keywordFilterMode": "DISABLED", "keywords": [], "maxMarketEndDate": null @@ -196,10 +244,22 @@ POST /api/backtest/tasks #### 3.1.2 查询回测任务列表 ``` -GET /api/backtest/tasks?leaderId={leaderId}&status={status}&sortBy={field}&sortOrder={asc|desc}&page={page}&size={size} +POST /api/backtest/tasks/list ``` -**Query Parameters**: +**Request Body**: +```json +{ + "leaderId": null, + "status": null, + "sortBy": "createdAt", + "sortOrder": "desc", + "page": 1, + "size": 20 +} +``` + +**Request Parameters**: - `leaderId` (可选): Leader ID - `status` (可选): PENDING/RUNNING/COMPLETED/STOPPED/FAILED - `sortBy` (可选): profitAmount / profitRate / createdAt (默认: createdAt) @@ -241,7 +301,14 @@ GET /api/backtest/tasks?leaderId={leaderId}&status={status}&sortBy={field}&sortO #### 3.1.3 查询回测任务详情 ``` -GET /api/backtest/tasks/{id} +POST /api/backtest/tasks/detail +``` + +**Request Body**: +```json +{ + "id": 12345 +} ``` **Response**: @@ -286,7 +353,16 @@ GET /api/backtest/tasks/{id} #### 3.1.4 查询回测交易记录 ``` -GET /api/backtest/tasks/{id}/trades?page={page}&size={size} +POST /api/backtest/tasks/trades +``` + +**Request Body**: +```json +{ + "taskId": 12345, + "page": 1, + "size": 20 +} ``` **Response**: @@ -319,7 +395,14 @@ GET /api/backtest/tasks/{id}/trades?page={page}&size={size} #### 3.1.5 删除回测任务 ``` -DELETE /api/backtest/tasks/{id} +POST /api/backtest/tasks/delete +``` + +**Request Body**: +```json +{ + "id": 12345 +} ``` **Response**: @@ -333,7 +416,14 @@ DELETE /api/backtest/tasks/{id} #### 3.1.6 停止运行中的回测 ``` -POST /api/backtest/tasks/{id}/stop +POST /api/backtest/tasks/stop +``` + +**Request Body**: +```json +{ + "id": 12345 +} ``` **Response**: @@ -452,13 +542,94 @@ sequenceDiagram **职责**: 获取Leader历史数据 **数据源**: -1. **优先使用**: `ProcessedTrade` 表 (系统已记录的交易) +1. **优先使用**: `BacktestHistoricalTrade` 表 (系统已记录的完整交易数据) 2. **补充数据**: Polymarket API (获取更早的历史数据) -**API调用**: +**数据获取策略**: + ```kotlin -// Polymarket Trade History API -// GET https://data-api.polymarket.com/trades?maker={address}&start_ts={startTime}&end_ts={endTime} +suspend fun getLeaderHistoricalTrades( + leaderId: Long, + startTime: Long, + endTime: Long +): List { + // 1. 优先从 backtest_historical_trades 表查询 + val existingTrades = backtestHistoricalTradeRepository + .findByLeaderIdAndTradeTimestampBetween(leaderId, startTime, endTime) + + if (existingTrades.isNotEmpty()) { + return existingTrades.map { it.toHistoricalTrade() } + } + + // 2. 如果表中没有数据,调用 Polymarket API 获取 + val leader = leaderRepository.findById(leaderId) + ?: throw IllegalArgumentException("Leader not found") + + val apiTrades = polymarketDataService.getTradeHistory( + makerAddress = leader.address, + startTime = startTime, + endTime = endTime + ) + + // 3. 将 API 数据保存到 backtest_historical_trades 表 + val entities = apiTrades.map { trade -> + BacktestHistoricalTrade( + leaderId = leaderId, + tradeId = trade.id, + marketId = trade.marketId, + marketTitle = trade.marketTitle, + marketSlug = trade.marketSlug, + side = trade.side.uppercase(), + outcome = trade.outcome, + outcomeIndex = trade.outcomeIndex, + price = trade.price.toSafeBigDecimal(), + size = trade.size.toSafeBigDecimal(), + amount = trade.amount.toSafeBigDecimal(), + tradeTimestamp = trade.timestamp, + source = "API", + fetchedAt = System.currentTimeMillis(), + createdAt = System.currentTimeMillis() + ) + } + + // 批量保存(去重由唯一索引处理) + backtestHistoricalTradeRepository.saveAll(entities) + + return apiTrades +} +``` + +**实时数据同步**: + +在跟单系统处理交易时,同时写入 `BacktestHistoricalTrade` 表: + +```kotlin +// 在 CopyOrderTrackingService.processTrade() 中 +@Async +fun syncToBacktestHistorical(trade: Trade, leaderId: Long) { + try { + val historicalTrade = BacktestHistoricalTrade( + leaderId = leaderId, + tradeId = trade.id, + marketId = trade.marketId, + marketTitle = trade.marketTitle, // 从 API 获取 + marketSlug = trade.marketSlug, // 从 API 获取 + side = trade.side.uppercase(), + outcome = trade.outcome, + outcomeIndex = trade.outcomeIndex, + price = trade.price.toSafeBigDecimal(), + size = trade.size.toSafeBigDecimal(), + amount = trade.amount.toSafeBigDecimal(), + tradeTimestamp = trade.timestamp, + source = "WEBSOCKET", + fetchedAt = System.currentTimeMillis(), + createdAt = System.currentTimeMillis() + ) + backtestHistoricalTradeRepository.save(historicalTrade) + } catch (e: Exception) { + logger.warn("同步回测历史数据失败: ${e.message}") + } +} ``` **缓存策略**: @@ -508,10 +679,20 @@ class BacktestPollingService( #### 4.2.1 回测算法伪代码 ```kotlin +// 持仓数据结构 +data class Position( + val marketId: String, + val outcome: String, + val outcomeIndex: Int? = null, // 支持 outcomeIndex + var quantity: BigDecimal, + val avgPrice: BigDecimal, + val leaderBuyQuantity: BigDecimal? // Leader 买入数量(用于比例模式) +) + fun executeBacktest(task: BacktestTask) { // 1. 初始化 var currentBalance = task.initialBalance - val positions = mutableMapOf() // marketId + outcome -> Position + val positions = mutableMapOf() // marketId + outcomeIndex -> Position val trades = mutableListOf() val marketInfoCache = mutableMapOf() // 缓存市场信息 @@ -589,12 +770,43 @@ fun executeBacktest(task: BacktestTask) { logger.info("余额不足 $currentBalance,但还有 ${positions.size} 个持仓,继续处理后续交易(等待卖出或结算)") } - // 4.3 应用过滤规则 - if (!passFilters(task, leaderTrade)) { + // 4.3 每日订单数检查 + // 统计当前交易时间当天已有的订单数 + val dailyOrderCount = trades.count { isSameDay(it.tradeTime, leaderTrade.timestamp) } + if (dailyOrderCount >= task.maxDailyOrders) { + logger.info("已达到每日最大订单数限制: $dailyOrderCount / ${task.maxDailyOrders}") continue } - // 4.4 计算跟单金额 + // 4.4 价格容忍度检查 + if (task.priceTolerance > BigDecimal.ZERO) { + val tolerance = task.priceTolerance.toSafeBigDecimal().divide(BigDecimal("100")) + val minPrice = leaderTrade.price.multiply(BigDecimal.ONE.subtract(tolerance)) + val maxPrice = leaderTrade.price.multiply(BigDecimal.ONE.add(tolerance)) + + // 获取当前市场价格(从市场服务或缓存) + val currentPrice = marketPriceService.getCurrentMarketPrice( + leaderTrade.marketId, + leaderTrade.outcomeIndex ?: 0 + ) + + if (currentPrice < minPrice || currentPrice > maxPrice) { + logger.info("价格超出容忍度范围: 当前=$currentPrice, 可用范围=[$minPrice, $maxPrice]") + continue + } + } + + // 4.5 应用其他过滤规则 + // 复用 CopyTradingFilterService 的方法 + if (!copyTradingFilterService.passAllFilters( + task = task, + trade = leaderTrade, + currentPositionValue = positions.values.sumOf { it.quantity * it.avgPrice } + )) { + continue + } + + // 4.6 计算跟单金额 val followAmount = calculateFollowAmount(task, leaderTrade) if (leaderTrade.side == "BUY") { @@ -619,10 +831,11 @@ fun executeBacktest(task: BacktestTask) { // 更新余额和持仓 currentBalance -= totalCost - val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcome}" + val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}" positions[positionKey] = Position( marketId = leaderTrade.marketId, outcome = leaderTrade.outcome, + outcomeIndex = leaderTrade.outcomeIndex, quantity = quantity, avgPrice = leaderTrade.price, leaderBuyQuantity = leaderTrade.quantity @@ -645,13 +858,21 @@ fun executeBacktest(task: BacktestTask) { } else { // SELL if (!task.supportSell) continue - val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcome}" + // 使用 outcomeIndex 构建持仓键(支持多元市场) + val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}" val position = positions[positionKey] ?: continue - // 计算卖出数量 (按比例) + // 计算卖出数量 val sellQuantity = if (task.copyMode == "RATIO") { - // 比例模式: 按Leader卖出比例 + // 比例模式: 按 Leader 卖出比例 + // 如果 position.leaderBuyQuantity 为 null,则按持仓比例计算 + if (position.leaderBuyQuantity != null && position.leaderBuyQuantity > BigDecimal.ZERO) { position.quantity * (leaderTrade.quantity / position.leaderBuyQuantity) + } else { + // 按比例卖出:卖出持仓的 (leaderTrade.quantity / 当前总持仓) + // 但这种情况下无法获取 Leader 的总持仓,所以简化为全部卖出 + position.quantity + } } else { // 固定金额模式: 全部卖出 position.quantity @@ -695,11 +916,20 @@ fun executeBacktest(task: BacktestTask) { marketService.getMarketInfo(position.marketId) } - // 如果市场已结算但endDate晚于回测结束时间,或市场信息获取失败 + // 获取市场结算结果 + // 方案: 通过市场价格判断 + // - 价格 >= 0.95: 胜出 (按 1.0 结算) + // - 价格 <= 0.05: 失败 (按 0.0 结算) + // - 其他情况: 按成本价保守估计 + val marketPrice = marketPriceService.getCurrentMarketPrice( + marketId = position.marketId, + outcomeIndex = position.outcomeIndex ?: 0 + ) + val settlementPrice = when { - marketInfo?.winner == position.outcome -> BigDecimal.ONE - marketInfo?.winner != null -> BigDecimal.ZERO - else -> position.avgPrice // 未结算或无法获取,按成本价 + marketPrice >= BigDecimal("0.95") -> BigDecimal.ONE // 胜出 + marketPrice <= BigDecimal("0.05") -> BigDecimal.ZERO // 失败 + else -> position.avgPrice // 未结算或不确定,按成本价 } val settlementValue = position.quantity * settlementPrice diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 544f8c8..421d9ff 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "antd": "^5.12.0", "antd-mobile": "^5.34.0", "axios": "^1.6.2", + "echarts": "^6.0.0", "ethers": "^6.16.0", "i18next": "^25.7.1", "react": "^18.2.0", @@ -158,7 +159,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1677,7 +1677,6 @@ "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1743,7 +1742,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -1940,7 +1938,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2259,7 +2256,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -2471,8 +2467,7 @@ "node_modules/dayjs": { "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", - "peer": true + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==" }, "node_modules/debug": { "version": "4.4.3", @@ -2581,6 +2576,22 @@ "node": ">= 0.4" } }, + "node_modules/echarts": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz", + "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.0.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/electron-to-chromium": { "version": "1.5.258", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.258.tgz", @@ -2693,7 +2704,6 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3372,7 +3382,6 @@ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" } ], - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -5441,7 +5450,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5453,7 +5461,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6021,7 +6028,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6194,7 +6200,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -6325,6 +6330,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zrender": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz", + "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/zustand": { "version": "4.5.7", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", diff --git a/frontend/package.json b/frontend/package.json index acd692e..68cfaa8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "antd": "^5.12.0", "antd-mobile": "^5.34.0", "axios": "^1.6.2", + "echarts": "^6.0.0", "ethers": "^6.16.0", "i18next": "^25.7.1", "react": "^18.2.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 34fe9ce..1fbc712 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,6 +32,9 @@ import SystemSettings from './pages/SystemSettings' import ApiHealthStatus from './pages/ApiHealthStatus' import RpcNodeSettings from './pages/RpcNodeSettings' import Announcements from './pages/Announcements' +import BacktestList from './pages/BacktestList' +import BacktestCreate from './pages/BacktestCreate' +import BacktestDetail from './pages/BacktestDetail' import { wsManager } from './services/websocket' import type { OrderPushMessage } from './types' import { apiService } from './services/api' @@ -254,6 +257,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index f165733..38d34b9 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -20,7 +20,8 @@ import { CheckCircleOutlined, SendOutlined, ApiOutlined, - NotificationOutlined + NotificationOutlined, + LineChartOutlined } from '@ant-design/icons' import type { MenuProps } from 'antd' import type { ReactNode } from 'react' @@ -156,6 +157,11 @@ const Layout: React.FC = ({ children }) => { icon: , label: t('menu.positions') }, + { + key: '/backtest', + icon: , + label: t('menu.backtest') || '回测' + }, { key: '/statistics', icon: , diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 0862472..5b38b59 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -240,6 +240,7 @@ "templates": "Templates", "copyTradingConfig": "Copy Trading Config", "positions": "Position Management", + "backtest": "Backtest", "statistics": "Statistics", "announcements": "Announcements", "users": "User Management", @@ -1226,5 +1227,105 @@ "providerQuickNode": "QuickNode", "providerChainstack": "Chainstack", "providerGetBlock": "GetBlock" + }, + "backtest": { + "title": "Backtest", + "taskName": "Task Name", + "leader": "Leader", + "initialBalance": "Initial Balance", + "backtestDays": "Backtest Days", + "status": "Status", + "progress": "Progress", + "startTime": "Start Time", + "endTime": "End Time", + "finalBalance": "Final Balance", + "profitAmount": "Profit Amount", + "profitRate": "Profit Rate", + "totalTrades": "Total Trades", + "buyTrades": "Buy Trades", + "sellTrades": "Sell Trades", + "winTrades": "Win Trades", + "lossTrades": "Loss Trades", + "winRate": "Win Rate", + "maxProfit": "Max Profit", + "maxLoss": "Max Loss", + "maxDrawdown": "Max Drawdown", + "avgHoldingTime": "Avg Holding Time", + "statusPending": "Pending", + "statusRunning": "Running", + "statusCompleted": "Completed", + "statusStopped": "Stopped", + "statusFailed": "Failed", + "createTask": "Create Backtest Task", + "taskList": "Backtest Task List", + "taskDetail": "Backtest Task Detail", + "tradeRecords": "Trade Records", + "config": "Configuration", + "statistics": "Statistics", + "chart": "Balance Chart", + "createSuccess": "Created successfully", + "createFailed": "Failed to create", + "deleteSuccess": "Deleted successfully", + "deleteFailed": "Failed to delete", + "stopSuccess": "Stopped successfully", + "stopFailed": "Failed to stop", + "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", + "noTrades": "No trade records", + "fetchTasksFailed": "Failed to fetch task list", + "fetchTaskDetailFailed": "Failed to fetch task detail", + "fetchTradesFailed": "Failed to fetch trade records", + "balanceChart": "Balance Change", + "pnlChart": "PnL Change", + "executionTime": "Execution Time", + "runningDuration": "Running Duration", + "estimatedRemaining": "Estimated Remaining", + "leaderAddress": "Leader Address", + "leaderName": "Leader Name", + "copyMode": "Copy Mode", + "copyModeRatio": "Ratio", + "copyModeFixed": "Fixed Amount", + "copyRatio": "Copy Ratio", + "fixedAmount": "Fixed Amount", + "maxOrderSize": "Max Order Size", + "minOrderSize": "Min Order Size", + "maxDailyLoss": "Max Daily Loss", + "maxDailyOrders": "Max Daily Orders", + "priceTolerance": "Price Tolerance", + "delaySeconds": "Delay Seconds", + "supportSell": "Support Sell", + "minOrderDepth": "Min Order Depth", + "maxSpread": "Max Spread", + "minPrice": "Min Price", + "maxPrice": "Max Price", + "maxPositionValue": "Max Position Value", + "keywordFilterMode": "Keyword Filter Mode", + "keywordFilterModeDisabled": "Disabled", + "keywordFilterModeWhitelist": "Whitelist", + "keywordFilterModeBlacklist": "Blacklist", + "keywords": "Keywords", + "maxMarketEndDate": "Market End Date Limit", + "tradeTime": "Trade Time", + "marketId": "Market ID", + "marketTitle": "Market Title", + "side": "Side", + "sideBuy": "Buy", + "sideSell": "Sell", + "sideSettlement": "Settlement", + "outcome": "Outcome", + "quantity": "Quantity", + "price": "Price", + "amount": "Amount", + "fee": "Fee", + "profitLoss": "Profit/Loss", + "balanceAfter": "Balance After", + "leaderTradeId": "Leader Trade ID", + "loading": "Loading...", + "refreshing": "Refreshing...", + "starting": "Starting...", + "stopping": "Stopping...", + "errorOccurred": "Error occurred", + "retry": "Retry" } } \ No newline at end of file diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index d87e7e1..7688396 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -240,6 +240,7 @@ "templates": "跟单模板", "copyTradingConfig": "跟单配置", "positions": "仓位管理", + "backtest": "回测", "statistics": "统计信息", "announcements": "公告", "users": "用户管理", @@ -1226,5 +1227,105 @@ "providerQuickNode": "QuickNode", "providerChainstack": "Chainstack", "providerGetBlock": "GetBlock" + }, + "backtest": { + "title": "回测", + "taskName": "任务名称", + "leader": "Leader", + "initialBalance": "初始资金", + "backtestDays": "回测天数", + "status": "状态", + "progress": "进度", + "startTime": "开始时间", + "endTime": "结束时间", + "finalBalance": "最终资金", + "profitAmount": "收益金额", + "profitRate": "收益率", + "totalTrades": "总交易数", + "buyTrades": "买入笔数", + "sellTrades": "卖出笔数", + "winTrades": "盈利笔数", + "lossTrades": "亏损笔数", + "winRate": "胜率", + "maxProfit": "最大单笔盈利", + "maxLoss": "最大单笔亏损", + "maxDrawdown": "最大回撤", + "avgHoldingTime": "平均持仓时间", + "statusPending": "等待中", + "statusRunning": "运行中", + "statusCompleted": "已完成", + "statusStopped": "已停止", + "statusFailed": "失败", + "createTask": "创建回测任务", + "taskList": "回测任务列表", + "taskDetail": "回测任务详情", + "tradeRecords": "交易记录", + "config": "配置", + "statistics": "统计", + "chart": "资金曲线", + "createSuccess": "创建成功", + "createFailed": "创建失败", + "deleteSuccess": "删除成功", + "deleteFailed": "删除失败", + "stopSuccess": "停止成功", + "stopFailed": "停止失败", + "deleteConfirm": "确定删除此回测任务吗?", + "stopConfirm": "确定停止此回测任务吗?", + "noTasks": "暂无回测任务", + "noTrades": "暂无交易记录", + "fetchTasksFailed": "获取任务列表失败", + "fetchTaskDetailFailed": "获取任务详情失败", + "fetchTradesFailed": "获取交易记录失败", + "balanceChart": "资金变化", + "pnlChart": "盈亏变化", + "executionTime": "执行时间", + "runningDuration": "运行时长", + "estimatedRemaining": "预计剩余", + "leaderAddress": "Leader 地址", + "leaderName": "Leader 名称", + "copyMode": "跟单模式", + "copyModeRatio": "比例", + "copyModeFixed": "固定金额", + "copyRatio": "跟单比例", + "fixedAmount": "固定金额", + "maxOrderSize": "最大单笔订单", + "minOrderSize": "最小单笔订单", + "maxDailyLoss": "最大每日亏损", + "maxDailyOrders": "最大每日订单数", + "priceTolerance": "价格容忍度", + "delaySeconds": "延迟秒数", + "supportSell": "支持卖出", + "minOrderDepth": "最小订单深度", + "maxSpread": "最大价差", + "minPrice": "最低价格", + "maxPrice": "最高价格", + "maxPositionValue": "最大仓位金额", + "keywordFilterMode": "关键字过滤模式", + "keywordFilterModeDisabled": "禁用", + "keywordFilterModeWhitelist": "白名单", + "keywordFilterModeBlacklist": "黑名单", + "keywords": "关键字", + "maxMarketEndDate": "市场截止时间限制", + "tradeTime": "交易时间", + "marketId": "市场ID", + "marketTitle": "市场标题", + "side": "方向", + "sideBuy": "买入", + "sideSell": "卖出", + "sideSettlement": "结算", + "outcome": "结果", + "quantity": "数量", + "price": "价格", + "amount": "金额", + "fee": "手续费", + "profitLoss": "盈亏", + "balanceAfter": "交易后余额", + "leaderTradeId": "Leader 交易ID", + "loading": "加载中...", + "refreshing": "刷新中...", + "starting": "启动中...", + "stopping": "停止中...", + "errorOccurred": "发生错误", + "retry": "重试" } } \ No newline at end of file diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index bb660a6..b2dec19 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -240,6 +240,7 @@ "templates": "跟單模板", "copyTradingConfig": "跟單配置", "positions": "倉位管理", + "backtest": "回測", "statistics": "統計信息", "announcements": "公告", "users": "用戶管理", @@ -1226,5 +1227,105 @@ "providerQuickNode": "QuickNode", "providerChainstack": "Chainstack", "providerGetBlock": "GetBlock" + }, + "backtest": { + "title": "回測", + "taskName": "任務名稱", + "leader": "Leader", + "initialBalance": "初始資金", + "backtestDays": "回測天數", + "status": "狀態", + "progress": "進度", + "startTime": "開始時間", + "endTime": "結束時間", + "finalBalance": "最終資金", + "profitAmount": "收益金額", + "profitRate": "收益率", + "totalTrades": "總交易數", + "buyTrades": "買入筆數", + "sellTrades": "賣出筆數", + "winTrades": "盈利筆數", + "lossTrades": "虧損筆數", + "winRate": "勝率", + "maxProfit": "最大單筆盈利", + "maxLoss": "最大單筆虧損", + "maxDrawdown": "最大回撤", + "avgHoldingTime": "平均持倉時間", + "statusPending": "等待中", + "statusRunning": "運行中", + "statusCompleted": "已完成", + "statusStopped": "已停止", + "statusFailed": "失敗", + "createTask": "創建回測任務", + "taskList": "回測任務列表", + "taskDetail": "回測任務詳情", + "tradeRecords": "交易記錄", + "config": "配置", + "statistics": "統計", + "chart": "資金曲線", + "createSuccess": "創建成功", + "createFailed": "創建失敗", + "deleteSuccess": "刪除成功", + "deleteFailed": "刪除失敗", + "stopSuccess": "停止成功", + "stopFailed": "停止失敗", + "deleteConfirm": "確定刪除此回測任務嗎?", + "stopConfirm": "確定停止此回測任務嗎?", + "noTasks": "暫無回測任務", + "noTrades": "暫無交易記錄", + "fetchTasksFailed": "獲取任務列表失敗", + "fetchTaskDetailFailed": "獲取任務詳情失敗", + "fetchTradesFailed": "獲取交易記錄失敗", + "balanceChart": "資金變化", + "pnlChart": "盈虧變化", + "executionTime": "執行時間", + "runningDuration": "運行時長", + "estimatedRemaining": "預計剩餘", + "leaderAddress": "Leader 地址", + "leaderName": "Leader 名稱", + "copyMode": "跟單模式", + "copyModeRatio": "比例", + "copyModeFixed": "固定金額", + "copyRatio": "跟單比例", + "fixedAmount": "固定金額", + "maxOrderSize": "最大單筆訂單", + "minOrderSize": "最小單筆訂單", + "maxDailyLoss": "最大每日虧損", + "maxDailyOrders": "最大每日訂單數", + "priceTolerance": "價格容忍度", + "delaySeconds": "延遲秒數", + "supportSell": "支持賣出", + "minOrderDepth": "最小訂單深度", + "maxSpread": "最大價差", + "minPrice": "最低價格", + "maxPrice": "最高價格", + "maxPositionValue": "最大倉位金額", + "keywordFilterMode": "關鍵字過濾模式", + "keywordFilterModeDisabled": "禁用", + "keywordFilterModeWhitelist": "白名單", + "keywordFilterModeBlacklist": "黑名單", + "keywords": "關鍵字", + "maxMarketEndDate": "市場截止時間限制", + "tradeTime": "交易時間", + "marketId": "市場ID", + "marketTitle": "市場標題", + "side": "方向", + "sideBuy": "買入", + "sideSell": "賣出", + "sideSettlement": "結算", + "outcome": "結果", + "quantity": "數量", + "price": "價格", + "amount": "金額", + "fee": "手續費", + "profitLoss": "盈虧", + "balanceAfter": "交易後餘額", + "leaderTradeId": "Leader 交易ID", + "loading": "加載中...", + "refreshing": "刷新中...", + "starting": "啟動中...", + "stopping": "停止中...", + "errorOccurred": "發生錯誤", + "retry": "重試" } } \ No newline at end of file diff --git a/frontend/src/pages/BacktestChart.tsx b/frontend/src/pages/BacktestChart.tsx new file mode 100644 index 0000000..8e9b874 --- /dev/null +++ b/frontend/src/pages/BacktestChart.tsx @@ -0,0 +1,216 @@ +import { useEffect, useRef } from 'react' +import * as echarts from 'echarts' +import type { EChartsOption } from 'echarts' +import { useTranslation } from 'react-i18next' + +interface BacktestChartProps { + trades: { + tradeTime: number + balanceAfter: string + }[] +} + +const BacktestChart: React.FC = ({ trades }) => { + const { t } = useTranslation() + const chartRef = useRef(null) + const chartInstance = useRef(null) + + useEffect(() => { + if (!chartRef.current) return + + // 初始化图表 + chartInstance.current = echarts.init(chartRef.current) + + // 监听窗口大小变化 + const handleResize = () => { + chartInstance.current?.resize() + } + window.addEventListener('resize', handleResize) + + return () => { + window.removeEventListener('resize', handleResize) + chartInstance.current?.dispose() + } + }, []) + + useEffect(() => { + if (!chartInstance.current || trades.length === 0) return + + // 准备数据 + const data = trades.map((trade) => ({ + time: new Date(trade.tradeTime).toLocaleString(), + value: parseFloat(trade.balanceAfter) + })) + + // 初始余额(第一笔交易前的余额) + const initialBalance = data[0]?.value || 0 + + // 数据压缩:如果数据点太多,进行采样 + const maxPoints = 500 // 最多显示500个点 + let compressedData = data + if (data.length > maxPoints) { + const step = Math.ceil(data.length / maxPoints) + compressedData = data.filter((_, index) => index % step === 0) + // 确保最后一个点被包含 + if (compressedData[compressedData.length - 1] !== data[data.length - 1]) { + compressedData.push(data[data.length - 1]) + } + } + + const times = compressedData.map(item => item.time) + const values = compressedData.map(item => item.value) + + const option: EChartsOption = { + tooltip: { + trigger: 'axis', + formatter: (params: any) => { + const param = params[0] + const value = parseFloat(param.value).toFixed(2) + const diffValue = param.value - initialBalance + const diff = diffValue.toFixed(2) + const diffPercent = (diffValue / initialBalance * 100).toFixed(2) + const color = diffValue >= 0 ? '#52c41a' : '#ff4d4f' + return ` +
+
${t('backtest.tradeTime')}: ${param.name}
+
${t('backtest.balanceAfter')}: ${value} USDC
+
+ ${t('backtest.profitLoss')}: ${diff} USDC (${diffPercent}%) +
+
+ ` + } + }, + grid: { + left: '3%', + right: '4%', + bottom: '3%', + top: '8%', + containLabel: true + }, + xAxis: { + type: 'category', + data: times, + axisLabel: { + rotate: 45, + formatter: (value: string) => { + // 简化时间显示,只显示 HH:mm + const parts = value.split(' ') + if (parts.length > 1) { + const timeParts = parts[1].split(':') + if (timeParts.length >= 2) { + return `${timeParts[0]}:${timeParts[1]}` + } + } + return value + } + }, + axisLine: { + lineStyle: { + color: '#e0e0e0' + } + }, + axisTick: { + alignWithLabel: true, + lineStyle: { + color: '#e0e0e0' + } + } + }, + yAxis: { + type: 'value', + name: 'USDC', + nameLocation: 'end', + nameGap: 10, + axisLabel: { + formatter: (value: number) => value.toFixed(2) + }, + splitLine: { + lineStyle: { + color: '#f0f0f0' + } + }, + axisLine: { + lineStyle: { + color: '#e0e0e0' + } + } + }, + series: [ + { + name: t('backtest.balanceAfter'), + type: 'line', + data: values, + smooth: true, + symbol: 'circle', + symbolSize: 4, + lineStyle: { + width: 2, + color: '#1890ff' + }, + itemStyle: { + color: '#1890ff' + }, + areaStyle: { + color: { + type: 'linear', + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { offset: 0, color: 'rgba(24, 144, 255, 0.3)' }, + { offset: 1, color: 'rgba(24, 144, 255, 0.05)' } + ] + } + }, + markLine: { + data: [ + { + name: t('backtest.initialBalance'), + yAxis: initialBalance, + label: { + formatter: `${t('backtest.initialBalance')}: ${initialBalance.toFixed(2)}` + }, + lineStyle: { + type: 'dashed', + color: '#999', + width: 1 + } + } + ] + } + } + ], + dataZoom: [ + { + type: 'inside', + start: 0, + end: 100 + }, + { + type: 'slider', + start: 0, + end: 100, + height: 20, + bottom: 20 + } + ] + } + + chartInstance.current.setOption(option) + }, [trades, t]) + + return ( +
+ ) +} + +export default BacktestChart + diff --git a/frontend/src/pages/BacktestCreate.tsx b/frontend/src/pages/BacktestCreate.tsx new file mode 100644 index 0000000..76e0540 --- /dev/null +++ b/frontend/src/pages/BacktestCreate.tsx @@ -0,0 +1,408 @@ +import { useState, useEffect } from 'react' +import { Card, Form, Button, Input, InputNumber, Select, Switch, message, Space, Row, Col } from 'antd' +import { ArrowLeftOutlined, SaveOutlined } from '@ant-design/icons' +import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router-dom' +import { backtestService } from '../services/api' +import { apiService } from '../services/api' +import type { Leader } from '../types' +import type { BacktestCreateRequest } from '../types/backtest' + +const { Option } = Select + +const BacktestCreate: React.FC = () => { + const { t } = useTranslation() + const navigate = useNavigate() + const [form] = Form.useForm() + const [loading, setLoading] = useState(false) + const [leaders, setLeaders] = useState([]) + const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO') + + // 获取 Leader 列表 + useEffect(() => { + const fetchLeaders = async () => { + try { + const response = await apiService.leaders.list({}) + if (response.data.code === 0 && response.data.data) { + setLeaders(response.data.data.list || []) + } + } catch (error) { + console.error('Failed to fetch leaders:', error) + } + } + fetchLeaders() + }, []) + + // 提交表单 + const handleSubmit = async () => { + try { + const values = await form.validateFields() + setLoading(true) + + const request: BacktestCreateRequest = { + taskName: values.taskName, + leaderId: values.leaderId, + initialBalance: values.initialBalance, + backtestDays: values.backtestDays, + copyMode: values.copyMode || 'RATIO', + copyRatio: values.copyMode === 'RATIO' ? values.copyRatio : 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: 1.0, + maxOrderSize: 1000, + minOrderSize: 1, + maxDailyLoss: 500, + maxDailyOrders: 50, + priceTolerance: 5, + delaySeconds: 0, + supportSell: true, + keywordFilterMode: 'DISABLED', + backtestDays: 7 + }) + }, [form]) + + return ( +
+ } onClick={handleBack}> + {t('common.back')} + + } + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + {/* 跟单配置 */} +
+

{t('backtest.config')}

+ + + + + + {copyMode === 'RATIO' && ( + + + + )} + + {copyMode === 'FIXED' && ( + + + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {t('backtest.delaySecondsHint') || '延迟执行模拟真实跟单延迟'} + + + + + {t('backtest.supportSellHint') || '是否跟随 Leader 的卖出操作'} + + +

{t('backtest.advancedFilters')}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + setStatusFilter(value)} + value={statusFilter} + > + {t('backtest.statusPending')} + {t('backtest.statusRunning')} + {t('backtest.statusCompleted')} + {t('backtest.statusStopped')} + {t('backtest.statusFailed')} + + + + + + + + + + + + + + {/* 数据表格 */} + `${t('common.total')} ${total} ${t('common.items')}`, + onChange: (newPage) => setPage(newPage) + }} + scroll={{ x: 1400 }} + /> + + + + ) +} + +export default BacktestList + diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6ed1900..d64b294 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -726,3 +726,72 @@ export { apiClient } export default apiService +/** + * 回测服务 + */ +export const backtestService = { + /** + * 创建回测任务 + */ + create: (data: { + taskName: string + leaderId: number + initialBalance: string + backtestDays: number + copyMode?: 'RATIO' | 'FIXED' + copyRatio?: string + fixedAmount?: string + maxOrderSize?: string + minOrderSize?: string + maxDailyLoss?: string + maxDailyOrders?: number + priceTolerance?: string + delaySeconds?: number + supportSell?: boolean + minOrderDepth?: string + maxSpread?: string + minPrice?: string + maxPrice?: string + maxPositionValue?: string + keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST' + keywords?: string[] + maxMarketEndDate?: number | null + }) => apiClient.post('/api/backtest/tasks', data), + + /** + * 查询回测任务列表 + */ + list: (data: { + leaderId?: number + status?: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'STOPPED' | 'FAILED' + sortBy?: 'profitAmount' | 'profitRate' | 'createdAt' + sortOrder?: 'asc' | 'desc' + page: number + size: number + }) => apiClient.post('/api/backtest/tasks/list', data), + + /** + * 查询回测任务详情 + */ + detail: (data: { id: number }) => apiClient.post('/api/backtest/tasks/detail', data), + + /** + * 查询回测交易记录 + */ + trades: (data: { + taskId: number + page: number + size: number + }) => apiClient.post('/api/backtest/tasks/trades', data), + + /** + * 停止回测任务 + */ + stop: (data: { id: number }) => apiClient.post('/api/backtest/tasks/stop', data), + + /** + * 删除回测任务 + */ + delete: (data: { id: number }) => apiClient.post('/api/backtest/tasks/delete', data) +} + diff --git a/frontend/src/types/backtest.ts b/frontend/src/types/backtest.ts new file mode 100644 index 0000000..10a15ff --- /dev/null +++ b/frontend/src/types/backtest.ts @@ -0,0 +1,205 @@ +/** + * 回测相关类型定义 + */ + +/** + * 回测任务创建请求 + */ +export interface BacktestCreateRequest { + taskName: string + leaderId: number + initialBalance: string + backtestDays: number // 1-30 + // 跟单配置 + copyMode?: 'RATIO' | 'FIXED' + copyRatio?: string + fixedAmount?: string + maxOrderSize?: string + minOrderSize?: string + maxDailyLoss?: string + maxDailyOrders?: number + priceTolerance?: string // 百分比 + delaySeconds?: number + supportSell?: boolean + minOrderDepth?: string + maxSpread?: string + minPrice?: string + maxPrice?: string + maxPositionValue?: string + keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST' + keywords?: string[] + maxMarketEndDate?: number | null +} + +/** + * 回测任务列表请求 + */ +export interface BacktestListRequest { + leaderId?: number + status?: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'STOPPED' | 'FAILED' + sortBy?: 'profitAmount' | 'profitRate' | 'createdAt' + sortOrder?: 'asc' | 'desc' + page: number + size: number +} + +/** + * 回测任务详情请求 + */ +export interface BacktestDetailRequest { + id: number +} + +/** + * 回测交易记录列表请求 + */ +export interface BacktestTradeListRequest { + taskId: number + page: number + size: number +} + +/** + * 回测进度查询请求 + */ +export interface BacktestProgressRequest { + id: number +} + +/** + * 回测任务停止请求 + */ +export interface BacktestStopRequest { + id: number +} + +/** + * 回测任务删除请求 + */ +export interface BacktestDeleteRequest { + id: number +} + +/** + * 回测任务列表响应 + */ +export interface BacktestListResponse { + list: BacktestTaskDto[] + total: number + page: number + size: number +} + +/** + * 回测任务详情响应 + */ +export interface BacktestDetailResponse { + task: BacktestTaskDto + config: BacktestConfigDto + statistics: BacktestStatisticsDto +} + +/** + * 回测交易记录列表响应 + */ +export interface BacktestTradeListResponse { + list: BacktestTradeDto[] + total: number + page: number + size: number +} + +/** + * 回测进度响应 + */ +export interface BacktestProgressResponse { + progress: number // 0-100 + currentBalance: string + totalTrades: number + status: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'STOPPED' | 'FAILED' +} + +/** + * 回测任务 DTO + */ +export interface BacktestTaskDto { + id: number + taskName: string + leaderId: number + leaderName: string | null + leaderAddress: string | null + initialBalance: string + finalBalance: string | null + profitAmount: string | null + profitRate: string | null // 百分比 + backtestDays: number + startTime: number + endTime: number | null + status: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'STOPPED' | 'FAILED' + progress: number // 0-100 + totalTrades: number + createdAt: number + executionStartedAt: number | null + executionFinishedAt: number | null +} + +/** + * 回测配置 DTO + */ +export interface BacktestConfigDto { + copyMode: 'RATIO' | 'FIXED' + copyRatio: string + fixedAmount: string | null + maxOrderSize: string + minOrderSize: string + maxDailyLoss: string + maxDailyOrders: number + priceTolerance: string // 百分比 + delaySeconds: number + supportSell: boolean + minOrderDepth: string | null + maxSpread: string | null + minPrice: string | null + maxPrice: string | null + maxPositionValue: string | null + keywordFilterMode: 'DISABLED' | 'WHITELIST' | 'BLACKLIST' | null + keywords: string[] | null + maxMarketEndDate: number | null +} + +/** + * 回测统计 DTO + */ +export interface BacktestStatisticsDto { + totalTrades: number // 总交易笔数 + buyTrades: number // 买入笔数 + sellTrades: number // 卖出笔数 + winTrades: number // 盈利交易笔数 + lossTrades: number // 亏损交易笔数 + winRate: string // 胜率 (百分比) + maxProfit: string // 最大单笔盈利 + maxLoss: string // 最大单笔亏损 + maxDrawdown: string // 最大回撤 + avgHoldingTime: number | null // 平均持仓时间 (毫秒) +} + +/** + * 回测交易记录 DTO + */ +export interface BacktestTradeDto { + id: number + tradeTime: number + marketId: string + marketTitle: string | null + side: 'BUY' | 'SELL' | 'SETTLEMENT' + outcome: string // YES/NO 或 outcomeIndex + outcomeIndex: number | null + quantity: string + price: string + amount: string + fee: string + profitLoss: string | null // 仅卖出和结算时有值 + balanceAfter: string + leaderTradeId: string | null +} + diff --git a/scripts/package.json b/scripts/package.json index a990256..fdbdc7f 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -4,7 +4,8 @@ "description": "Utility scripts for Polyhermes", "type": "module", "scripts": { - "get-order-detail": "node get-order-detail.js" + "get-order-detail": "node get-order-detail.js", + "verify-backtest-data": "node verify-backtest-data.js" }, "dependencies": { "@ethersproject/wallet": "^5.7.0",