feat: 实现回测功能

## 功能概述
实现完整的回测功能,支持基于历史数据模拟跟单策略的执行效果。

## 后端实现
- 数据库:新增 backtest_task 和 backtest_trade 表(V27迁移脚本)
- 实体类:BacktestTask、BacktestTrade
- Repository:BacktestTaskRepository、BacktestTradeRepository
- Service:
  - BacktestService:回测任务管理(CRUD)
  - BacktestDataService:从 Polymarket Data API 获取历史交易数据
  - BacktestExecutionService:回测算法核心实现
  - BacktestPollingService:定时轮询执行回测任务
- Controller:BacktestController(6个API接口)
- DTO:BacktestDto、TradeData
- 错误码:新增回测相关错误码和国际化消息

## 前端实现
- 页面组件:
  - BacktestList:回测任务列表
  - BacktestCreate:创建回测任务
  - BacktestDetail:回测详情(含图表)
  - BacktestChart:资金曲线图表(使用 ECharts)
- 类型定义:backtest.ts
- API 服务:集成所有回测接口
- 国际化:支持中英文

## 核心特性
- 回测天数限制:1-15 天
- 数据获取:直接从 Polymarket Data API 获取历史交易(不使用缓存表)
- 任务执行:同一时刻只执行一个任务,按创建时间顺序执行最早创建的任务
- 回测算法:完整实现市场结算、卖出匹配、价格容忍度、每日订单限制等规则
- 实时进度:支持任务进度更新和实时轮询

## 文档更新
- BACKTEST_PRD.md:产品需求文档
- BACKTEST_TECHNICAL_DESIGN.md:技术设计文档
- BACKTEST_REVIEW_CHECKLIST.md:设计评审检查清单

## 其他修改
- 移除 max_position_count 配置(V26迁移脚本)
- 移除 BacktestSyncService(不再需要实时同步)
- 修复前后端编译错误
This commit is contained in:
WrBug
2026-01-31 07:27:36 +08:00
parent fabbd81f22
commit cdd02e9f3d
34 changed files with 4880 additions and 76 deletions
@@ -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<ApiResponse<BacktestTaskDto>> {
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<ApiResponse<BacktestListResponse>> {
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<ApiResponse<BacktestDetailResponse>> {
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<ApiResponse<BacktestTradeListResponse>> {
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<ApiResponse<Unit>> {
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<ApiResponse<Unit>> {
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))
}
}
}
@@ -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<String>? = 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<BacktestTaskDto>,
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<BacktestTradeDto>,
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<String>?,
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?
)
@@ -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()
}
}
@@ -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()
)
@@ -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()
)
@@ -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 {
/**
@@ -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<BacktestTask, Long> {
/**
* 根据 Leader ID 查询回测任务
*/
fun findByLeaderId(leaderId: Long): List<BacktestTask>
/**
* 根据状态查询回测任务
*/
fun findByStatus(status: String): List<BacktestTask>
/**
* 根据 Leader ID 和状态查询回测任务
*/
fun findByLeaderIdAndStatus(leaderId: Long, status: String): List<BacktestTask>
/**
* 根据 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<BacktestTask>
/**
* 根据状态和创建时间倒序查询
*/
@Query("SELECT t FROM BacktestTask t WHERE t.status = :status ORDER BY t.createdAt DESC")
fun findByStatusOrderByCreatedAtDesc(status: String): List<BacktestTask>
/**
* 更新回测任务状态
*/
@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())
}
@@ -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<BacktestTrade, Long> {
/**
* 根据回测任务ID查询所有交易记录
*/
fun findByBacktestTaskIdOrderByTradeTime(backtestTaskId: Long): List<BacktestTrade>
/**
* 根据回测任务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<BacktestTrade>
/**
* 根据回测任务ID统计交易数量
*/
fun countByBacktestTaskId(backtestTaskId: Long): Long
/**
* 删除回测任务的所有交易记录(由级联删除处理)
*/
fun deleteByBacktestTaskId(backtestTaskId: Long)
}
@@ -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<LeaderTrade> {
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<LeaderTrade> {
logger.info("从 Data API activity 接口获取 Leader 历史交易: leaderId=${leader.id}, timeRange=${startTime} - $endTime")
val dataApi = retrofitFactory.createDataApi()
val allTrades = mutableListOf<LeaderTrade>()
val seenTradeKeys = mutableSetOf<String>() // 用于内存去重
var offset = 0
val pageSize = 100 // 每页最多 100 条
var hasMore = true
val MAX_OFFSET = 10000 // 最大偏移量(防止无限循环,15天通常不会超过)
// 分页获取所有交易记录
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 // 交易时间戳(毫秒)
)
@@ -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<String, Position>() // marketId + outcomeIndex -> Position
val trades = mutableListOf<BacktestTrade>()
// 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<String, Position>,
currentBalance: BigDecimal,
trades: MutableList<BacktestTrade>,
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<String, Position>,
currentBalance: BigDecimal,
trades: MutableList<BacktestTrade>,
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<BacktestTrade>): 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<BacktestTrade>,
sellTrades: List<BacktestTrade>,
settlementTrades: List<BacktestTrade>
): Long? {
val marketHoldings = mutableMapOf<String, MutableList<Long>>()
// 记录买入时间
for (buyTrade in buyTrades) {
val key = "${buyTrade.marketId}:${buyTrade.outcomeIndex ?: 0}"
marketHoldings.getOrPut(key) { mutableListOf() }.add(buyTrade.tradeTime)
}
// 计算持仓时间
val holdingTimes = mutableListOf<Long>()
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?
)
}
@@ -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)
}
}
}
@@ -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<BacktestTaskDto> {
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<BacktestListResponse> {
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<BacktestDetailResponse> {
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<List<String>>()
} 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<BacktestTradeListResponse> {
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<Unit> {
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<Unit> {
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
)
}
@@ -185,6 +185,7 @@ open class CopyOrderTrackingService(
processedAt = System.currentTimeMillis()
)
processedTradeRepository.save(processed)
} catch (e: Exception) {
// 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底)
if (isUniqueConstraintViolation(e)) {