Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f52b5016a | |||
| 5c0808c2cb | |||
| 9d01c120e5 | |||
| ec8cfeac77 | |||
| 7c833f1e9b | |||
| 08cc30a90f | |||
| 8288cd7579 | |||
| 55da551971 | |||
| ff3b24b50e | |||
| 88cae4018a | |||
| 96fbc3f720 | |||
| fd25821e39 | |||
| cdd02e9f3d | |||
| fabbd81f22 | |||
| ace32b37cf | |||
| f1f809f54b | |||
| cccc829cef |
Executable
+59
@@ -0,0 +1,59 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 清理 Flyway V29 失败记录的脚本
|
||||||
|
|
||||||
|
echo "=== 清理 Flyway V29 失败记录 ==="
|
||||||
|
echo ""
|
||||||
|
echo "请确保 MySQL 正在运行,然后输入数据库密码"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 数据库配置
|
||||||
|
DB_HOST="localhost"
|
||||||
|
DB_PORT="3306"
|
||||||
|
DB_NAME="polymarket_bot"
|
||||||
|
DB_USER="root"
|
||||||
|
|
||||||
|
# 检查 MySQL 命令是否可用
|
||||||
|
if ! command -v mysql &> /dev/null; then
|
||||||
|
echo "❌ 错误: 未找到 mysql 命令"
|
||||||
|
echo ""
|
||||||
|
echo "请使用数据库客户端(如 Navicat、DataGrip 等)执行以下 SQL:"
|
||||||
|
echo ""
|
||||||
|
echo "-- 1. 查看 Flyway 历史记录"
|
||||||
|
echo "SELECT version, description, installed_on, success "
|
||||||
|
echo "FROM flyway_schema_history "
|
||||||
|
echo "WHERE version >= 28"
|
||||||
|
echo "ORDER BY installed_rank;"
|
||||||
|
echo ""
|
||||||
|
echo "-- 2. 删除 V29 的失败记录"
|
||||||
|
echo "DELETE FROM flyway_schema_history WHERE version = '29';"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 执行清理
|
||||||
|
echo "正在连接数据库..."
|
||||||
|
mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p "$DB_NAME" << 'EOF'
|
||||||
|
-- 查看当前状态
|
||||||
|
SELECT '=== 当前 Flyway 历史记录 ===' as '';
|
||||||
|
SELECT version, description, installed_on, success
|
||||||
|
FROM flyway_schema_history
|
||||||
|
WHERE version >= 28
|
||||||
|
ORDER BY installed_rank;
|
||||||
|
|
||||||
|
-- 删除 V29 失败记录
|
||||||
|
SELECT '=== 删除 V29 记录 ===' as '';
|
||||||
|
DELETE FROM flyway_schema_history WHERE version = '29';
|
||||||
|
|
||||||
|
-- 确认删除结果
|
||||||
|
SELECT CONCAT('已删除 ', ROW_COUNT(), ' 条记录') as result;
|
||||||
|
|
||||||
|
-- 再次查看状态
|
||||||
|
SELECT '=== 清理后的 Flyway 历史记录 ===' as '';
|
||||||
|
SELECT version, description, installed_on, success
|
||||||
|
FROM flyway_schema_history
|
||||||
|
WHERE version >= 28
|
||||||
|
ORDER BY installed_rank;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ 清理完成!现在可以重启应用了"
|
||||||
+225
@@ -0,0 +1,225 @@
|
|||||||
|
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
|
||||||
|
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重试回测任务
|
||||||
|
*/
|
||||||
|
@PostMapping("/tasks/retry")
|
||||||
|
fun retryBacktestTask(@RequestBody request: BacktestRetryRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||||
|
return try {
|
||||||
|
logger.info("重试回测任务: taskId=${request.id}")
|
||||||
|
|
||||||
|
val result = backtestService.retryBacktestTask(request)
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
onSuccess = {
|
||||||
|
logger.info("回测任务重试成功: taskId=${request.id}")
|
||||||
|
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("重试回测任务失败", e)
|
||||||
|
val errorCode = when (e) {
|
||||||
|
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
|
||||||
|
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
|
||||||
|
else -> ErrorCode.SERVER_BACKTEST_RETRY_FAILED
|
||||||
|
}
|
||||||
|
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("重试回测任务异常", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_RETRY_FAILED, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
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 supportSell: Boolean? = null,
|
||||||
|
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||||
|
val keywords: List<String>? = null, // 关键字列表
|
||||||
|
val pageForResume: Int? = null // 用于恢复中断任务,从指定页码开始获取历史数据(从1开始)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测任务列表请求
|
||||||
|
*/
|
||||||
|
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 BacktestRetryRequest(
|
||||||
|
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 supportSell: Boolean,
|
||||||
|
val keywordFilterMode: String?,
|
||||||
|
val keywords: List<String>?
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测统计信息 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,142 @@
|
|||||||
|
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)
|
||||||
|
var finalBalance: BigDecimal? = null,
|
||||||
|
|
||||||
|
@Column(name = "profit_amount", precision = 20, scale = 8)
|
||||||
|
var profitAmount: BigDecimal? = null,
|
||||||
|
|
||||||
|
@Column(name = "profit_rate", precision = 10, scale = 4)
|
||||||
|
var 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")
|
||||||
|
var 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 = "support_sell", nullable = false)
|
||||||
|
val supportSell: Boolean = true,
|
||||||
|
|
||||||
|
@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 = "avg_holding_time")
|
||||||
|
var avgHoldingTime: Long? = null, // 平均持仓时间(毫秒)
|
||||||
|
|
||||||
|
@Column(name = "data_source", length = 50)
|
||||||
|
var 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(),
|
||||||
|
|
||||||
|
@Column(name = "last_processed_trade_time")
|
||||||
|
var lastProcessedTradeTime: Long? = null,
|
||||||
|
|
||||||
|
@Column(name = "last_processed_trade_index")
|
||||||
|
var lastProcessedTradeIndex: Int? = null,
|
||||||
|
|
||||||
|
@Column(name = "processed_trade_count")
|
||||||
|
var processedTradeCount: Int = 0
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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,24 @@ enum class ErrorCode(
|
|||||||
SERVER_ORDER_TRACKING_PROCESS_FAILED(5901, "处理订单跟踪失败", "error.server.order_tracking_process_failed"),
|
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_BUY_FAILED(5902, "处理买入订单失败", "error.server.order_tracking_buy_failed"),
|
||||||
SERVER_ORDER_TRACKING_SELL_FAILED(5903, "处理卖出订单失败", "error.server.order_tracking_sell_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"),
|
||||||
|
SERVER_BACKTEST_RETRY_FAILED(5612, "重试回测任务失败", "error.server.backtest_retry_failed");
|
||||||
|
|
||||||
companion object {
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
package com.wrbug.polymarketbot.service.backtest
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||||
|
import com.wrbug.polymarketbot.api.UserActivityResponse
|
||||||
|
import com.wrbug.polymarketbot.dto.TradeData
|
||||||
|
import com.wrbug.polymarketbot.entity.Leader
|
||||||
|
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||||
|
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||||
|
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 历史交易(用于回测恢复)
|
||||||
|
* 支持重试机制:最多重试5次,每次间隔1秒
|
||||||
|
*
|
||||||
|
* @param leaderId Leader ID
|
||||||
|
* @param startTime 开始时间(毫秒时间戳)
|
||||||
|
* @param endTime 结束时间(毫秒时间戳)
|
||||||
|
* @param page 页码 (从 0 开始)
|
||||||
|
* @param size 每页数量
|
||||||
|
* @return 历史交易列表
|
||||||
|
* @throws Exception 重试5次后仍然失败时抛出异常
|
||||||
|
*/
|
||||||
|
suspend fun getLeaderHistoricalTradesForPage(
|
||||||
|
leaderId: Long,
|
||||||
|
startTime: Long,
|
||||||
|
endTime: Long,
|
||||||
|
page: Int,
|
||||||
|
size: Int
|
||||||
|
): List<TradeData> {
|
||||||
|
logger.info("分页获取 Leader 历史交易: leaderId=$leaderId, timeRange=$startTime - $endTime, page=$page, size=$size")
|
||||||
|
|
||||||
|
// 1. 验证 Leader 是否存在
|
||||||
|
val leader = leaderRepository.findById(leaderId).orElse(null)
|
||||||
|
?: throw IllegalArgumentException("Leader 不存在: $leaderId")
|
||||||
|
|
||||||
|
val dataApi = retrofitFactory.createDataApi()
|
||||||
|
val offset = page * size
|
||||||
|
val maxRetries = 5
|
||||||
|
val retryDelay = 1000L // 1秒
|
||||||
|
|
||||||
|
// 2. 重试机制:最多重试5次
|
||||||
|
var lastException: Exception? = null
|
||||||
|
for (attempt in 1..maxRetries) {
|
||||||
|
try {
|
||||||
|
val response = dataApi.getUserActivity(
|
||||||
|
user = leader.leaderAddress,
|
||||||
|
type = listOf("TRADE"),
|
||||||
|
start = startTime / 1000,
|
||||||
|
end = endTime / 1000,
|
||||||
|
limit = size,
|
||||||
|
offset = offset,
|
||||||
|
sortBy = "timestamp",
|
||||||
|
sortDirection = "asc"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.isSuccessful || response.body() == null) {
|
||||||
|
throw Exception("从 Data API 获取用户活动失败: code=${response.code()}, message=${response.message()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val activities = response.body()!!
|
||||||
|
logger.info("成功获取第 $page 页数据,共 ${activities.size} 条交易(第 $attempt 次尝试)")
|
||||||
|
|
||||||
|
return activities.mapNotNull { activity ->
|
||||||
|
try {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
val tradeTimestamp = activity.timestamp * 1000
|
||||||
|
if (tradeTimestamp < startTime || tradeTimestamp > endTime) {
|
||||||
|
logger.debug("交易时间超出范围,跳过: timestamp=$tradeTimestamp, range=$startTime - $endTime")
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
|
||||||
|
TradeData(
|
||||||
|
tradeId = activity.transactionHash ?: "${activity.timestamp}_${activity.conditionId}_${activity.side}",
|
||||||
|
marketId = activity.conditionId,
|
||||||
|
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(),
|
||||||
|
timestamp = tradeTimestamp
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.warn("转换活动数据失败: activity=$activity, error=${e.message}", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
lastException = e
|
||||||
|
logger.warn("第 $attempt/$maxRetries 次尝试获取第 $page 页数据失败: ${e.message}")
|
||||||
|
|
||||||
|
// 如果不是最后一次尝试,则等待后重试
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
logger.info("等待 $retryDelay 毫秒后重试...")
|
||||||
|
delay(retryDelay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重试5次后仍然失败,抛出异常
|
||||||
|
val errorMsg = "重试 $maxRetries 次后仍然失败获取第 $page 页数据"
|
||||||
|
logger.error(errorMsg, lastException)
|
||||||
|
throw Exception(errorMsg, lastException)
|
||||||
|
}
|
||||||
|
}
|
||||||
+715
@@ -0,0 +1,715 @@
|
|||||||
|
package com.wrbug.polymarketbot.service.backtest
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.dto.TradeData
|
||||||
|
import com.wrbug.polymarketbot.dto.BacktestStatisticsDto
|
||||||
|
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.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.*
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
@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?
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将回测任务转换为虚拟的 CopyTrading 配置用于执行
|
||||||
|
* 注意:回测场景使用历史数据,不需要实时跟单的相关配置
|
||||||
|
*/
|
||||||
|
private fun taskToCopyTrading(task: BacktestTask): CopyTrading {
|
||||||
|
return CopyTrading(
|
||||||
|
id = task.id,
|
||||||
|
accountId = 0L,
|
||||||
|
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 = BigDecimal.ZERO, // 回测使用历史价格,不需要容忍度
|
||||||
|
delaySeconds = 0, // 回测按时间线执行,无需延迟
|
||||||
|
pollIntervalSeconds = 5,
|
||||||
|
useWebSocket = false,
|
||||||
|
websocketReconnectInterval = 5000,
|
||||||
|
websocketMaxRetries = 10,
|
||||||
|
supportSell = task.supportSell,
|
||||||
|
minOrderDepth = null, // 回测无实时订单簿数据
|
||||||
|
maxSpread = null, // 回测无实时价差数据
|
||||||
|
keywordFilterMode = task.keywordFilterMode,
|
||||||
|
keywords = task.keywords,
|
||||||
|
configName = null,
|
||||||
|
pushFailedOrders = false,
|
||||||
|
pushFilteredOrders = false,
|
||||||
|
createdAt = task.createdAt,
|
||||||
|
updatedAt = task.updatedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行回测任务(支持分页和恢复)
|
||||||
|
* 自动处理所有页面的数据,支持中断恢复
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
suspend fun executeBacktest(task: BacktestTask, page: Int = 1, size: Int = 100) {
|
||||||
|
try {
|
||||||
|
logger.info("开始执行回测任务: taskId=${task.id}, taskName=${task.taskName}, startPage=$page, pageSize=$size")
|
||||||
|
|
||||||
|
// 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>()
|
||||||
|
val trades = mutableListOf<BacktestTrade>()
|
||||||
|
// 每日订单数缓存:key为日期字符串(yyyy-MM-dd),value为当天的 BUY 订单数
|
||||||
|
val dailyOrderCountCache = mutableMapOf<String, Int>()
|
||||||
|
// 每日亏损缓存:key为日期字符串(yyyy-MM-dd),value为当天的累计亏损金额
|
||||||
|
val dailyLossCache = mutableMapOf<String, BigDecimal>()
|
||||||
|
|
||||||
|
// 3. 计算回测时间范围
|
||||||
|
val endTime = System.currentTimeMillis()
|
||||||
|
val startTime = task.startTime
|
||||||
|
|
||||||
|
logger.info("回测时间范围: ${formatTimestamp(startTime)} - ${formatTimestamp(endTime)}, " +
|
||||||
|
"初始余额: ${task.initialBalance.toPlainString()}")
|
||||||
|
|
||||||
|
// 4. 恢复机制:如果有恢复点,计算从哪一页开始(页码从 0 开始)
|
||||||
|
val startPage = if (task.lastProcessedTradeIndex != null) {
|
||||||
|
val lastProcessedIndex = task.lastProcessedTradeIndex!!
|
||||||
|
// 计算已处理的页码(从 0 开始)
|
||||||
|
val processedPage = lastProcessedIndex / size
|
||||||
|
|
||||||
|
// 特殊情况:如果lastProcessedTradeIndex刚好是100的倍数减1(比如99,199,299...)
|
||||||
|
// 说明该页已经完全处理,应该从下一页开始
|
||||||
|
val nextPage = if (lastProcessedIndex % size == size - 1) {
|
||||||
|
processedPage + 1
|
||||||
|
} else {
|
||||||
|
processedPage
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("恢复任务:已处理索引=$lastProcessedIndex, 计算页码=$nextPage, size=$size")
|
||||||
|
nextPage
|
||||||
|
} else {
|
||||||
|
logger.info("新任务:从第0页开始")
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 分页获取和处理交易数据
|
||||||
|
var currentPage = maxOf(startPage, page)
|
||||||
|
// 计算下一个要处理的全局索引(用于日志和统计)
|
||||||
|
val nextGlobalIndex = if (task.lastProcessedTradeIndex != null) {
|
||||||
|
task.lastProcessedTradeIndex!! + 1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("开始分页处理:起始页=$currentPage, 下一个要处理的索引=$nextGlobalIndex")
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
// 定期从数据库重新加载任务状态,确保能及时响应停止操作
|
||||||
|
val currentTaskStatus = backtestTaskRepository.findById(task.id!!).orElse(null)
|
||||||
|
if (currentTaskStatus == null || currentTaskStatus.status != "RUNNING") {
|
||||||
|
logger.info("回测任务状态已变更: ${currentTaskStatus?.status},停止执行")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("正在获取第 $currentPage 页数据...")
|
||||||
|
|
||||||
|
// 每页使用独立的交易列表,避免跨页重复保存
|
||||||
|
val currentPageTrades = mutableListOf<BacktestTrade>()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取当前页的交易数据(支持重试5次)
|
||||||
|
val pageTrades = backtestDataService.getLeaderHistoricalTradesForPage(
|
||||||
|
task.leaderId,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
currentPage,
|
||||||
|
size
|
||||||
|
)
|
||||||
|
|
||||||
|
if (pageTrades.isEmpty()) {
|
||||||
|
logger.info("第 $currentPage 页无数据,所有数据处理完成")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("第 $currentPage 页获取到 ${pageTrades.size} 条交易")
|
||||||
|
|
||||||
|
// 处理当前页的交易
|
||||||
|
var lastProcessedIndexInPage: Int? = null
|
||||||
|
for (localIndex in pageTrades.indices) {
|
||||||
|
val leaderTrade = pageTrades[localIndex]
|
||||||
|
// 计算当前交易在全局数据中的索引(从 0 开始)
|
||||||
|
val index = currentPage * size + localIndex
|
||||||
|
|
||||||
|
// 如果是恢复任务,跳过已处理的条目
|
||||||
|
if (task.lastProcessedTradeIndex != null && index <= task.lastProcessedTradeIndex!!) {
|
||||||
|
logger.debug("跳过已处理的交易: index=$index, lastProcessedIndex=${task.lastProcessedTradeIndex}")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录当前处理的索引
|
||||||
|
lastProcessedIndexInPage = index
|
||||||
|
|
||||||
|
// 更新进度
|
||||||
|
val progress = if (pageTrades.size > 0) {
|
||||||
|
(localIndex * 100) / pageTrades.size
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
if (progress > task.progress) {
|
||||||
|
task.progress = progress
|
||||||
|
task.processedTradeCount = index + 1
|
||||||
|
backtestTaskRepository.save(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 5.1 实时检查并结算已到期的市场
|
||||||
|
currentBalance = settleExpiredPositions(task, positions, currentBalance, trades, leaderTrade.timestamp)
|
||||||
|
|
||||||
|
// 5.2 检查余额和持仓状态
|
||||||
|
if (currentBalance < BigDecimal.ZERO) {
|
||||||
|
logger.info("余额已为负,直接终止回测: $currentBalance")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (currentBalance < BigDecimal.ONE && positions.isEmpty()) {
|
||||||
|
logger.info("余额不足且无持仓,停止回测: $currentBalance")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5.3 应用过滤规则
|
||||||
|
val copyTrading = taskToCopyTrading(task)
|
||||||
|
val filterResult = copyTradingFilterService.checkFilters(
|
||||||
|
copyTrading,
|
||||||
|
tokenId = "",
|
||||||
|
tradePrice = leaderTrade.price,
|
||||||
|
copyOrderAmount = null,
|
||||||
|
marketId = leaderTrade.marketId,
|
||||||
|
marketTitle = leaderTrade.marketTitle,
|
||||||
|
marketEndDate = null,
|
||||||
|
outcomeIndex = leaderTrade.outcomeIndex
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!filterResult.isPassed) {
|
||||||
|
logger.debug("交易被过滤: ${leaderTrade.tradeId}")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5.4 每日订单数检查 - 使用缓存,只统计 BUY 订单
|
||||||
|
val tradeDate = formatDate(leaderTrade.timestamp)
|
||||||
|
val dailyOrderCount = dailyOrderCountCache.getOrDefault(tradeDate, 0)
|
||||||
|
|
||||||
|
if (dailyOrderCount >= task.maxDailyOrders) {
|
||||||
|
logger.info("已达到每日最大 BUY 订单数限制: $dailyOrderCount / ${task.maxDailyOrders}")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 5.6 计算跟单金额
|
||||||
|
val followAmount = calculateFollowAmount(task, leaderTrade)
|
||||||
|
|
||||||
|
// 5.6.1 检查订单大小限制
|
||||||
|
val finalFollowAmount = if (followAmount > task.maxOrderSize) {
|
||||||
|
logger.info("跟单金额超过最大限制: $followAmount > ${task.maxOrderSize},调整为最大值")
|
||||||
|
task.maxOrderSize
|
||||||
|
} else if (followAmount < task.minOrderSize) {
|
||||||
|
logger.info("跟单金额低于最小限制: $followAmount < ${task.minOrderSize},调整为最小值")
|
||||||
|
task.minOrderSize
|
||||||
|
} else {
|
||||||
|
followAmount
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5.6.2 检查每日最大亏损(买入订单)- 使用缓存
|
||||||
|
val dailyLoss = dailyLossCache.getOrDefault(tradeDate, BigDecimal.ZERO)
|
||||||
|
if (dailyLoss > task.maxDailyLoss) {
|
||||||
|
logger.info("已达到每日最大亏损限制: $dailyLoss / ${task.maxDailyLoss},跳过买入订单")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5.7 处理买卖逻辑
|
||||||
|
if (leaderTrade.side == "BUY") {
|
||||||
|
// 买入逻辑
|
||||||
|
val quantity = finalFollowAmount.divide(leaderTrade.price, 8, java.math.RoundingMode.DOWN)
|
||||||
|
val totalCost = finalFollowAmount
|
||||||
|
|
||||||
|
// 更新余额和持仓
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
|
||||||
|
// 记录交易到当前页列表
|
||||||
|
currentPageTrades.add(BacktestTrade(
|
||||||
|
backtestTaskId = task.id!!,
|
||||||
|
tradeTime = leaderTrade.timestamp,
|
||||||
|
marketId = leaderTrade.marketId,
|
||||||
|
marketTitle = leaderTrade.marketTitle,
|
||||||
|
side = "BUY",
|
||||||
|
outcome = leaderTrade.outcome ?: leaderTrade.outcomeIndex.toString(),
|
||||||
|
outcomeIndex = leaderTrade.outcomeIndex,
|
||||||
|
quantity = quantity,
|
||||||
|
price = leaderTrade.price.toSafeBigDecimal(),
|
||||||
|
amount = finalFollowAmount,
|
||||||
|
fee = BigDecimal.ZERO,
|
||||||
|
profitLoss = null,
|
||||||
|
balanceAfter = currentBalance,
|
||||||
|
leaderTradeId = leaderTrade.tradeId
|
||||||
|
))
|
||||||
|
|
||||||
|
// 更新每日订单数缓存
|
||||||
|
dailyOrderCountCache[tradeDate] = dailyOrderCount + 1
|
||||||
|
|
||||||
|
} 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())
|
||||||
|
|
||||||
|
// 5.6.2 检查卖出金额限制
|
||||||
|
val finalSellAmount = if (sellAmount > task.maxOrderSize) {
|
||||||
|
logger.info("卖出金额超过最大限制: $sellAmount > ${task.maxOrderSize},调整为最大值")
|
||||||
|
task.maxOrderSize
|
||||||
|
} else if (sellAmount < task.minOrderSize) {
|
||||||
|
logger.info("卖出金额低于最小限制: $sellAmount < ${task.minOrderSize},调整为最小值")
|
||||||
|
task.minOrderSize
|
||||||
|
} else {
|
||||||
|
sellAmount
|
||||||
|
}
|
||||||
|
|
||||||
|
val netAmount = finalSellAmount
|
||||||
|
|
||||||
|
// 计算盈亏
|
||||||
|
val cost = actualSellQuantity.multiply(position.avgPrice)
|
||||||
|
val profitLoss = netAmount.subtract(cost)
|
||||||
|
|
||||||
|
// 更新余额和持仓
|
||||||
|
currentBalance += netAmount
|
||||||
|
if (position.quantity <= BigDecimal.ZERO) {
|
||||||
|
positions.remove(positionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录交易到当前页列表
|
||||||
|
currentPageTrades.add(BacktestTrade(
|
||||||
|
backtestTaskId = task.id!!,
|
||||||
|
tradeTime = leaderTrade.timestamp,
|
||||||
|
marketId = leaderTrade.marketId,
|
||||||
|
marketTitle = leaderTrade.marketTitle,
|
||||||
|
side = "SELL",
|
||||||
|
outcome = leaderTrade.outcome ?: leaderTrade.outcomeIndex.toString(),
|
||||||
|
outcomeIndex = leaderTrade.outcomeIndex,
|
||||||
|
quantity = actualSellQuantity,
|
||||||
|
price = leaderTrade.price.toSafeBigDecimal(),
|
||||||
|
amount = finalSellAmount,
|
||||||
|
fee = BigDecimal.ZERO,
|
||||||
|
profitLoss = profitLoss,
|
||||||
|
balanceAfter = currentBalance,
|
||||||
|
leaderTradeId = leaderTrade.tradeId
|
||||||
|
))
|
||||||
|
// SELL 订单不计入每日订单数限制
|
||||||
|
|
||||||
|
// 更新每日亏损缓存(只累加亏损,不累加盈利)
|
||||||
|
if (profitLoss < BigDecimal.ZERO) {
|
||||||
|
val currentDailyLoss = dailyLossCache.getOrDefault(tradeDate, BigDecimal.ZERO)
|
||||||
|
dailyLossCache[tradeDate] = currentDailyLoss + profitLoss.negate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("处理交易失败: tradeId=${leaderTrade.tradeId}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存当前页的所有交易(每页处理完成后保存,避免重复插入)
|
||||||
|
if (currentPageTrades.isNotEmpty()) {
|
||||||
|
logger.info("保存第 $currentPage 页的交易数据,共 ${currentPageTrades.size} 笔")
|
||||||
|
|
||||||
|
// 批量保存当前页的交易
|
||||||
|
backtestTradeRepository.saveAll(currentPageTrades)
|
||||||
|
|
||||||
|
// 更新当前页的最后处理信息
|
||||||
|
val lastTradeInPage = currentPageTrades.lastOrNull()
|
||||||
|
if (lastTradeInPage != null && lastProcessedIndexInPage != null) {
|
||||||
|
task.lastProcessedTradeTime = lastTradeInPage.tradeTime
|
||||||
|
task.lastProcessedTradeIndex = lastProcessedIndexInPage
|
||||||
|
task.processedTradeCount = lastProcessedIndexInPage + 1
|
||||||
|
task.finalBalance = currentBalance
|
||||||
|
backtestTaskRepository.save(task)
|
||||||
|
|
||||||
|
logger.info("第 $currentPage 页处理完成,更新索引: ${task.lastProcessedTradeIndex}, 总处理数: ${task.processedTradeCount}")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info("第 $currentPage 页没有交易需要保存")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将当前页交易添加到全局列表(用于最终统计)
|
||||||
|
trades.addAll(currentPageTrades)
|
||||||
|
|
||||||
|
// 准备处理下一页
|
||||||
|
currentPage++
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("获取或处理第 $currentPage 页数据失败: ${e.message}", e)
|
||||||
|
// 重试失败,标记任务为 FAILED
|
||||||
|
throw 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"
|
||||||
|
|
||||||
|
task.finalBalance = currentBalance
|
||||||
|
task.profitAmount = profitAmount
|
||||||
|
task.profitRate = profitRate
|
||||||
|
task.endTime = endTime
|
||||||
|
task.status = finalStatus
|
||||||
|
task.progress = 100
|
||||||
|
task.totalTrades = trades.size
|
||||||
|
task.buyTrades = trades.count { it.side == "BUY" }
|
||||||
|
task.sellTrades = trades.count { it.side == "SELL" }
|
||||||
|
task.winTrades = statistics.winTrades
|
||||||
|
task.lossTrades = statistics.lossTrades
|
||||||
|
task.winRate = statistics.winRate.toSafeBigDecimal()
|
||||||
|
task.maxProfit = statistics.maxProfit.toSafeBigDecimal()
|
||||||
|
task.maxLoss = statistics.maxLoss.toSafeBigDecimal()
|
||||||
|
task.maxDrawdown = statistics.maxDrawdown.toSafeBigDecimal()
|
||||||
|
task.avgHoldingTime = statistics.avgHoldingTime
|
||||||
|
task.executionFinishedAt = System.currentTimeMillis()
|
||||||
|
task.updatedAt = System.currentTimeMillis()
|
||||||
|
|
||||||
|
backtestTaskRepository.save(task)
|
||||||
|
|
||||||
|
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.executionFinishedAt = System.currentTimeMillis()
|
||||||
|
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 = "",
|
||||||
|
side = "SETTLEMENT",
|
||||||
|
outcome = when {
|
||||||
|
settlementPrice == BigDecimal.ONE -> "WIN"
|
||||||
|
settlementPrice == BigDecimal.ZERO -> "LOSE"
|
||||||
|
else -> "UNKNOWN"
|
||||||
|
},
|
||||||
|
outcomeIndex = position.outcomeIndex,
|
||||||
|
quantity = position.quantity,
|
||||||
|
price = settlementPrice,
|
||||||
|
amount = settlementValue,
|
||||||
|
fee = BigDecimal.ZERO,
|
||||||
|
profitLoss = profitLoss,
|
||||||
|
balanceAfter = balance,
|
||||||
|
leaderTradeId = null
|
||||||
|
))
|
||||||
|
|
||||||
|
// 移除已结算的持仓
|
||||||
|
positions.remove(positionKey)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("结算市场失败: marketId=${position.marketId}, outcomeIndex=${position.outcomeIndex}", 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()) {
|
||||||
|
val quantity = position.quantity
|
||||||
|
val avgPrice = position.avgPrice
|
||||||
|
val settlementPrice = avgPrice
|
||||||
|
|
||||||
|
val settlementValue = quantity.multiply(settlementPrice)
|
||||||
|
val profitLoss = settlementValue.negate()
|
||||||
|
|
||||||
|
balance += settlementValue
|
||||||
|
|
||||||
|
// 记录平仓交易
|
||||||
|
trades.add(BacktestTrade(
|
||||||
|
backtestTaskId = task.id!!,
|
||||||
|
tradeTime = currentTime,
|
||||||
|
marketId = position.marketId,
|
||||||
|
marketTitle = "",
|
||||||
|
side = "SETTLEMENT",
|
||||||
|
outcome = "CLOSED",
|
||||||
|
outcomeIndex = position.outcomeIndex,
|
||||||
|
quantity = quantity,
|
||||||
|
price = avgPrice,
|
||||||
|
amount = settlementValue,
|
||||||
|
fee = BigDecimal.ZERO,
|
||||||
|
profitLoss = profitLoss,
|
||||||
|
balanceAfter = balance,
|
||||||
|
leaderTradeId = null
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
positions.clear()
|
||||||
|
return balance
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算统计数据
|
||||||
|
*/
|
||||||
|
private fun calculateStatistics(trades: List<BacktestTrade>): BacktestStatisticsDto {
|
||||||
|
val buyTrades = trades.count { it.side == "BUY" }
|
||||||
|
val sellTrades = trades.count { it.side == "SELL" }
|
||||||
|
val winTrades = trades.count { it.profitLoss != null && it.profitLoss > BigDecimal.ZERO }
|
||||||
|
val lossTrades = trades.count { it.profitLoss != null && it.profitLoss < BigDecimal.ZERO }
|
||||||
|
|
||||||
|
var totalProfit = BigDecimal.ZERO
|
||||||
|
var totalLoss = BigDecimal.ZERO
|
||||||
|
var maxProfit = BigDecimal.ZERO
|
||||||
|
var maxLoss = BigDecimal.ZERO
|
||||||
|
|
||||||
|
// 计算最大回撤
|
||||||
|
var runningBalance = if (trades.isNotEmpty()) {
|
||||||
|
trades[0].balanceAfter?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||||
|
} else {
|
||||||
|
BigDecimal.ZERO
|
||||||
|
}
|
||||||
|
var peakBalance = runningBalance
|
||||||
|
var maxDrawdown = BigDecimal.ZERO
|
||||||
|
|
||||||
|
for (i in trades.indices) {
|
||||||
|
val trade = trades[i]
|
||||||
|
val balance = trade.balanceAfter?.toSafeBigDecimal() ?: continue
|
||||||
|
|
||||||
|
if (trade.profitLoss != null) {
|
||||||
|
val pnl = trade.profitLoss.toSafeBigDecimal()
|
||||||
|
if (pnl > BigDecimal.ZERO) {
|
||||||
|
totalProfit += pnl
|
||||||
|
if (pnl > maxProfit) maxProfit = pnl
|
||||||
|
} else {
|
||||||
|
totalLoss += pnl
|
||||||
|
if (pnl < maxLoss) maxLoss = pnl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (balance > peakBalance) {
|
||||||
|
peakBalance = balance
|
||||||
|
}
|
||||||
|
val drawdown = peakBalance - runningBalance
|
||||||
|
if (drawdown > maxDrawdown) {
|
||||||
|
maxDrawdown = drawdown
|
||||||
|
}
|
||||||
|
|
||||||
|
runningBalance = balance
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算平均持仓时间
|
||||||
|
var avgHoldingTime: Long? = null
|
||||||
|
if (trades.size > 1) {
|
||||||
|
var totalHoldingTime = 0L
|
||||||
|
var count = 0
|
||||||
|
for (i in 0 until trades.size - 1) {
|
||||||
|
val currentTrade = trades[i]
|
||||||
|
val nextTrade = trades[i + 1]
|
||||||
|
|
||||||
|
if (currentTrade.side == "BUY" && nextTrade.side == "SELL") {
|
||||||
|
val holdingTime = nextTrade.tradeTime - currentTrade.tradeTime
|
||||||
|
totalHoldingTime += holdingTime
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
avgHoldingTime = totalHoldingTime / count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return BacktestStatisticsDto(
|
||||||
|
totalTrades = trades.size,
|
||||||
|
buyTrades = buyTrades,
|
||||||
|
sellTrades = sellTrades,
|
||||||
|
winTrades = winTrades,
|
||||||
|
lossTrades = lossTrades,
|
||||||
|
winRate = if (buyTrades + sellTrades > 0) {
|
||||||
|
(winTrades.toBigDecimal().divide((buyTrades + sellTrades).toBigDecimal(), 4, java.math.RoundingMode.HALF_UP))
|
||||||
|
.multiply(BigDecimal("100"))
|
||||||
|
.toPlainString()
|
||||||
|
} else {
|
||||||
|
BigDecimal.ZERO.toPlainString()
|
||||||
|
},
|
||||||
|
maxProfit = maxProfit.toPlainString(),
|
||||||
|
maxLoss = maxLoss.toPlainString(),
|
||||||
|
maxDrawdown = maxDrawdown.toPlainString(),
|
||||||
|
avgHoldingTime = avgHoldingTime
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算跟单金额
|
||||||
|
*/
|
||||||
|
private fun calculateFollowAmount(task: BacktestTask, leaderTrade: TradeData): BigDecimal {
|
||||||
|
return if (task.copyMode == "RATIO") {
|
||||||
|
// 比例模式:Leader 成交金额 × 跟单比例
|
||||||
|
leaderTrade.amount.toSafeBigDecimal().multiply(task.copyRatio)
|
||||||
|
} else {
|
||||||
|
// 固定金额模式:使用配置的固定金额
|
||||||
|
task.fixedAmount ?: leaderTrade.amount.toSafeBigDecimal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否同一天
|
||||||
|
*/
|
||||||
|
private fun isSameDay(timestamp1: Long, timestamp2: Long): Boolean {
|
||||||
|
val cal1 = Calendar.getInstance().apply { timeInMillis = timestamp1 }
|
||||||
|
val cal2 = Calendar.getInstance().apply { timeInMillis = timestamp2 }
|
||||||
|
return cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR) &&
|
||||||
|
cal1.get(Calendar.DAY_OF_YEAR) == cal2.get(Calendar.DAY_OF_YEAR)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化时间戳
|
||||||
|
*/
|
||||||
|
private fun formatTimestamp(timestamp: Long): String {
|
||||||
|
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
|
||||||
|
return sdf.format(Date(timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化日期(用于缓存key)
|
||||||
|
*/
|
||||||
|
private fun formatDate(timestamp: Long): String {
|
||||||
|
val sdf = SimpleDateFormat("yyyy-MM-dd")
|
||||||
|
return sdf.format(Date(timestamp))
|
||||||
|
}
|
||||||
|
}
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
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. 检查是否有长时间处于 RUNNING 状态的任务(可能是应用重启导致的)
|
||||||
|
val runningTasks = backtestTaskRepository.findByStatus("RUNNING")
|
||||||
|
if (runningTasks.isNotEmpty()) {
|
||||||
|
val activeQueueSize = (executor as ThreadPoolExecutor).queue.size
|
||||||
|
val activeCount = (executor as ThreadPoolExecutor).activeCount
|
||||||
|
|
||||||
|
// 如果有线程池中没有活跃任务但有 RUNNING 状态的任务,说明是应用重启导致的
|
||||||
|
// 重置这些任务的状态为 PENDING,以便恢复执行
|
||||||
|
if (activeCount == 0 && runningTasks.isNotEmpty()) {
|
||||||
|
logger.info("检测到应用重启导致的异常 RUNNING 任务,重置为 PENDING 以便恢复")
|
||||||
|
runningTasks.forEach { task ->
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val executionStartedAt = task.executionStartedAt
|
||||||
|
val executionDuration = if (executionStartedAt != null) {
|
||||||
|
now - executionStartedAt
|
||||||
|
} else {
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果任务执行时间超过 1 分钟,认为是异常状态
|
||||||
|
if (executionDuration > 60000) {
|
||||||
|
logger.info("重置异常 RUNNING 任务: taskId=${task.id}, executionStartedAt=$executionStartedAt, duration=${executionDuration}ms")
|
||||||
|
task.status = "PENDING"
|
||||||
|
task.updatedAt = now
|
||||||
|
backtestTaskRepository.save(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.debug("有 ${runningTasks.size} 个任务正在执行,跳过本次轮询")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 查询所有 PENDING 状态的任务,按创建时间升序排序
|
||||||
|
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 {
|
||||||
|
// 支持恢复:如果有恢复点,计算从哪一页开始
|
||||||
|
val pageSize = 100
|
||||||
|
val page = if (currentTask.lastProcessedTradeIndex != null) {
|
||||||
|
// 从第几页开始(页码从 0 开始)
|
||||||
|
// 例如:已处理了99笔,lastProcessedTradeIndex=99,应从第1页开始(offset=100)
|
||||||
|
val lastProcessedIndex = currentTask.lastProcessedTradeIndex!!
|
||||||
|
// 计算已处理的页码(从 0 开始)
|
||||||
|
val processedPage = lastProcessedIndex / pageSize
|
||||||
|
|
||||||
|
// 特殊情况:如果lastProcessedTradeIndex刚好是100的倍数减1(比如99,199,299...)
|
||||||
|
// 说明该页已经完全处理,应该从下一页开始
|
||||||
|
val nextPage = if (lastProcessedIndex % pageSize == pageSize - 1) {
|
||||||
|
processedPage + 1
|
||||||
|
} else {
|
||||||
|
processedPage
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("恢复任务:已处理索引=$lastProcessedIndex, 计算页码=$nextPage, size=$pageSize")
|
||||||
|
nextPage
|
||||||
|
} else {
|
||||||
|
logger.info("新任务:从第0页开始")
|
||||||
|
0 // 从第0页开始(offset=0)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("执行回测任务: taskId=${currentTask.id}, page=$page, size=$pageSize")
|
||||||
|
executionService.executeBacktest(currentTask, page = page, size = pageSize)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("回测任务执行失败: taskId=${taskToExecute.id}", e)
|
||||||
|
// 更新任务状态为 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,373 @@
|
|||||||
|
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. 验证恢复页码(如果提供)
|
||||||
|
if (request.pageForResume != null && request.pageForResume < 1) {
|
||||||
|
return Result.failure(IllegalArgumentException("恢复页码必须大于 0"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 验证初始金额
|
||||||
|
val initialBalance = request.initialBalance.toSafeBigDecimal()
|
||||||
|
if (initialBalance <= BigDecimal.ZERO) {
|
||||||
|
return Result.failure(IllegalArgumentException("初始金额必须大于 0"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
supportSell = request.supportSell ?: true,
|
||||||
|
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
||||||
|
keywords = if (request.keywords != null && request.keywords.isNotEmpty()) {
|
||||||
|
request.keywords.toJson()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
supportSell = task.supportSell,
|
||||||
|
keywordFilterMode = task.keywordFilterMode,
|
||||||
|
keywords = if (task.keywords != null) {
|
||||||
|
task.keywords.fromJson<List<String>>()
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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(IllegalStateException("回测任务正在运行,无法删除"))
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重试回测任务
|
||||||
|
* 从断点继续执行,保留已处理的交易记录
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
fun retryBacktestTask(request: BacktestRetryRequest): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
val task = backtestTaskRepository.findById(request.id).orElse(null)
|
||||||
|
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
|
||||||
|
|
||||||
|
if (task.status == "RUNNING") {
|
||||||
|
return Result.failure(IllegalArgumentException("回测任务正在运行中,无需重试"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置任务状态为 PENDING,进度保持不变
|
||||||
|
task.status = "PENDING"
|
||||||
|
task.errorMessage = null
|
||||||
|
task.updatedAt = System.currentTimeMillis()
|
||||||
|
|
||||||
|
// 不清理已处理的交易记录,保留恢复点
|
||||||
|
backtestTaskRepository.save(task)
|
||||||
|
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("重试回测任务失败", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扩展函数: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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
+61
-3
@@ -9,6 +9,8 @@ import org.slf4j.LoggerFactory
|
|||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.math.BigInteger
|
import java.math.BigInteger
|
||||||
|
import com.github.benmanes.caffeine.cache.Cache
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 市场价格服务
|
* 市场价格服务
|
||||||
@@ -27,6 +29,21 @@ class MarketPriceService(
|
|||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(MarketPriceService::class.java)
|
private val logger = LoggerFactory.getLogger(MarketPriceService::class.java)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已结算市场的价格缓存
|
||||||
|
* Key: "marketId:outcomeIndex"
|
||||||
|
* Value: BigDecimal (1.0 或 0.0)
|
||||||
|
*
|
||||||
|
* 缓存策略:
|
||||||
|
* - 最大缓存 10,000 个已结算市场
|
||||||
|
* - 永不过期(已结算的市场状态永不改变)
|
||||||
|
* - 内存占用约: 10,000 * ~100 bytes = ~1MB
|
||||||
|
*/
|
||||||
|
private val settledMarketCache: Cache<String, BigDecimal> = Caffeine.newBuilder()
|
||||||
|
.maximumSize(10_000)
|
||||||
|
.recordStats() // 启用统计信息
|
||||||
|
.build()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前市场最新价
|
* 获取当前市场最新价
|
||||||
* 优先级:
|
* 优先级:
|
||||||
@@ -83,9 +100,20 @@ class MarketPriceService(
|
|||||||
* - payout == 0(输了)→ 返回 0.0
|
* - payout == 0(输了)→ 返回 0.0
|
||||||
* 如果市场未结算或查询失败,返回 null
|
* 如果市场未结算或查询失败,返回 null
|
||||||
*
|
*
|
||||||
|
* 使用缓存优化:已结算的市场结果会被缓存,避免重复 RPC 调用
|
||||||
|
*
|
||||||
* @return Pair<BigDecimal?, Boolean> 第一个值是价格(如果已结算),第二个值表示是否发生了 RPC 错误(execution reverted)
|
* @return Pair<BigDecimal?, Boolean> 第一个值是价格(如果已结算),第二个值表示是否发生了 RPC 错误(execution reverted)
|
||||||
*/
|
*/
|
||||||
private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): Pair<BigDecimal?, Boolean> {
|
private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): Pair<BigDecimal?, Boolean> {
|
||||||
|
// 1. 先检查缓存
|
||||||
|
val cacheKey = "$marketId:$outcomeIndex"
|
||||||
|
val cachedPrice = settledMarketCache.getIfPresent(cacheKey)
|
||||||
|
if (cachedPrice != null) {
|
||||||
|
logger.debug("从缓存获取已结算市场价格: marketId=$marketId, outcomeIndex=$outcomeIndex, price=$cachedPrice")
|
||||||
|
return Pair(cachedPrice, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 缓存未命中,发起 RPC 查询
|
||||||
return try {
|
return try {
|
||||||
val chainResult = blockchainService.getCondition(marketId)
|
val chainResult = blockchainService.getCondition(marketId)
|
||||||
chainResult.fold(
|
chainResult.fold(
|
||||||
@@ -96,11 +124,17 @@ class MarketPriceService(
|
|||||||
when {
|
when {
|
||||||
payout > BigInteger.ZERO -> {
|
payout > BigInteger.ZERO -> {
|
||||||
logger.info("从链上查询到市场已结算,该 outcome 赢了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
logger.info("从链上查询到市场已结算,该 outcome 赢了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||||
return Pair(BigDecimal.ONE, false)
|
val price = BigDecimal.ONE
|
||||||
|
// 缓存已结算的结果
|
||||||
|
settledMarketCache.put(cacheKey, price)
|
||||||
|
return Pair(price, false)
|
||||||
}
|
}
|
||||||
payout == BigInteger.ZERO -> {
|
payout == BigInteger.ZERO -> {
|
||||||
logger.info("从链上查询到市场已结算,该 outcome 输了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
logger.info("从链上查询到市场已结算,该 outcome 输了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||||
return Pair(BigDecimal.ZERO, false)
|
val price = BigDecimal.ZERO
|
||||||
|
// 缓存已结算的结果
|
||||||
|
settledMarketCache.put(cacheKey, price)
|
||||||
|
return Pair(price, false)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
logger.warn("从链上查询到异常的 payout 值: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
logger.warn("从链上查询到异常的 payout 值: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||||
@@ -109,7 +143,7 @@ class MarketPriceService(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.debug("从链上查询到市场尚未结算: marketId=$marketId, payouts=${payouts.size}")
|
logger.debug("从链上查询到市场尚未结算: marketId=$marketId, payouts=${payouts.size}")
|
||||||
Pair(null, false)
|
Pair(null, false) // 未结算的市场不缓存
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
@@ -290,5 +324,29 @@ class MarketPriceService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存统计信息
|
||||||
|
* 用于监控缓存命中率和性能
|
||||||
|
*/
|
||||||
|
fun getCacheStats(): String {
|
||||||
|
val stats = settledMarketCache.stats()
|
||||||
|
return """
|
||||||
|
已结算市场缓存统计:
|
||||||
|
- 缓存条目数: ${settledMarketCache.estimatedSize()}
|
||||||
|
- 命中次数: ${stats.hitCount()}
|
||||||
|
- 未命中次数: ${stats.missCount()}
|
||||||
|
- 命中率: ${"%.2f".format(stats.hitRate() * 100)}%
|
||||||
|
- 总请求次数: ${stats.requestCount()}
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空缓存(测试或管理用)
|
||||||
|
*/
|
||||||
|
fun clearSettledMarketCache() {
|
||||||
|
settledMarketCache.invalidateAll()
|
||||||
|
logger.info("已清空已结算市场缓存")
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
@@ -185,6 +185,7 @@ open class CopyOrderTrackingService(
|
|||||||
processedAt = System.currentTimeMillis()
|
processedAt = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
processedTradeRepository.save(processed)
|
processedTradeRepository.save(processed)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底)
|
// 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底)
|
||||||
if (isUniqueConstraintViolation(e)) {
|
if (isUniqueConstraintViolation(e)) {
|
||||||
|
|||||||
@@ -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='回测交易记录表';
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- 回测功能恢复字段添加
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 添加恢复相关字段到回测任务表
|
||||||
|
ALTER TABLE backtest_task
|
||||||
|
ADD COLUMN last_processed_trade_time BIGINT DEFAULT NULL COMMENT '最后处理的交易时间(用于中断恢复)',
|
||||||
|
ADD COLUMN last_processed_trade_index INT DEFAULT 0 COMMENT '最后处理的交易索引(用于中断恢复)',
|
||||||
|
ADD COLUMN processed_trade_count INT DEFAULT 0 COMMENT '已处理的交易数量(用于显示真实进度)';
|
||||||
|
|
||||||
|
-- 添加索引以优化查询性能
|
||||||
|
ALTER TABLE backtest_task
|
||||||
|
ADD INDEX idx_last_processed_trade_time (last_processed_trade_time);
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Drop unused columns from backtest_task table
|
||||||
|
-- These fields are not needed for backtest scenarios as they use historical data
|
||||||
|
-- Note: Using standard SQL syntax compatible with MySQL 5.7+
|
||||||
|
|
||||||
|
-- Check if columns exist before dropping (using standard approach)
|
||||||
|
ALTER TABLE backtest_task DROP COLUMN price_tolerance;
|
||||||
|
ALTER TABLE backtest_task DROP COLUMN delay_seconds;
|
||||||
|
ALTER TABLE backtest_task DROP COLUMN min_order_depth;
|
||||||
|
ALTER TABLE backtest_task DROP COLUMN max_spread;
|
||||||
|
ALTER TABLE backtest_task DROP COLUMN min_price;
|
||||||
|
ALTER TABLE backtest_task DROP COLUMN max_price;
|
||||||
|
ALTER TABLE backtest_task DROP COLUMN max_position_value;
|
||||||
|
ALTER TABLE backtest_task DROP COLUMN max_market_end_date;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- 修复回测恢复逻辑:将 last_processed_trade_index 默认值改为 NULL
|
||||||
|
-- ============================================
|
||||||
|
-- 问题:新建任务的 last_processed_trade_index 默认值为 0,导致被误判为恢复任务
|
||||||
|
-- 解决:将默认值改为 NULL,并将现有新任务的 0 值改为 NULL
|
||||||
|
|
||||||
|
-- 1. 将现有新任务(status='PENDING' 且 last_processed_trade_index=0)的索引值改为 NULL
|
||||||
|
UPDATE backtest_task
|
||||||
|
SET last_processed_trade_index = NULL
|
||||||
|
WHERE status = 'PENDING' AND last_processed_trade_index = 0;
|
||||||
|
|
||||||
|
-- 2. 修改字段定义,允许 NULL 并设置默认值为 NULL
|
||||||
|
ALTER TABLE backtest_task
|
||||||
|
MODIFY COLUMN last_processed_trade_index INT DEFAULT NULL COMMENT '最后处理的交易索引(用于中断恢复)';
|
||||||
|
|
||||||
@@ -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_buy_failed=Failed to process buy order
|
||||||
error.server.order_tracking_sell_failed=Failed to process sell order
|
error.server.order_tracking_sell_failed=Failed to process sell order
|
||||||
error.server.order_tracking_match_failed=Order matching failed
|
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
|
||||||
|
error.server.backtest_retry_failed=Failed to retry 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
|
||||||
|
|||||||
@@ -255,3 +255,60 @@ error.server.order_tracking_process_failed=处理订单跟踪失败
|
|||||||
error.server.order_tracking_buy_failed=处理买入订单失败
|
error.server.order_tracking_buy_failed=处理买入订单失败
|
||||||
error.server.order_tracking_sell_failed=处理卖出订单失败
|
error.server.order_tracking_sell_failed=处理卖出订单失败
|
||||||
error.server.order_tracking_match_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=停止回测任务失败
|
||||||
|
error.server.backtest_retry_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=订单匹配失败
|
||||||
|
|||||||
@@ -255,3 +255,54 @@ error.server.order_tracking_process_failed=處理訂單跟蹤失敗
|
|||||||
error.server.order_tracking_buy_failed=處理買入訂單失敗
|
error.server.order_tracking_buy_failed=處理買入訂單失敗
|
||||||
error.server.order_tracking_sell_failed=處理賣出訂單失敗
|
error.server.order_tracking_sell_failed=處理賣出訂單失敗
|
||||||
error.server.order_tracking_match_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=停止回測任務失敗
|
||||||
|
error.server.backtest_retry_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=支持賣出
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
# 跟单回测功能产品需求文档 (PRD)
|
||||||
|
|
||||||
|
## 一、功能概述
|
||||||
|
|
||||||
|
跟单回测功能允许用户对历史数据进行模拟跟单交易,评估不同跟单策略的收益表现,帮助用户在实际投入资金前验证策略的有效性。
|
||||||
|
|
||||||
|
## 二、用户故事
|
||||||
|
|
||||||
|
### 主要用户场景
|
||||||
|
|
||||||
|
1. **作为用户**,我希望能够创建回测任务,选择特定的 Leader 和跟单配置,以便评估在过去一段时间内使用该策略的收益情况
|
||||||
|
2. **作为用户**,我希望能够查看所有历史回测结果,并按收益额或收益率排序,以便找到最优策略
|
||||||
|
3. **作为用户**,我希望能够按 Leader 筛选回测记录,以便比较不同 Leader 的表现
|
||||||
|
4. **作为用户**,我希望回测结果能够展示详细的交易记录和收益变化,以便深入分析策略表现
|
||||||
|
5. **作为用户**,我希望回测在资金不足时能够自动停止,模拟真实交易场景
|
||||||
|
|
||||||
|
## 三、功能需求
|
||||||
|
|
||||||
|
### 3.1 回测管理页面
|
||||||
|
|
||||||
|
#### 3.1.1 页面布局
|
||||||
|
|
||||||
|
- **页面位置**: 在跟单管理模块下新增"回测管理"菜单项
|
||||||
|
- **页面标题**: "回测管理" / "Backtest Management"
|
||||||
|
|
||||||
|
#### 3.1.2 列表功能
|
||||||
|
|
||||||
|
**筛选功能**:
|
||||||
|
- Leader 筛选下拉框,支持按 Leader 过滤回测记录
|
||||||
|
- 状态筛选: 全部 / 运行中 / 已完成 / 已停止
|
||||||
|
|
||||||
|
**排序功能**:
|
||||||
|
- 按收益额排序 (升序/降序)
|
||||||
|
- 按收益率排序 (升序/降序)
|
||||||
|
- 按创建时间排序 (默认降序)
|
||||||
|
|
||||||
|
**列表字段**:
|
||||||
|
| 字段名 | 说明 | 示例 |
|
||||||
|
|-------|------|------|
|
||||||
|
| 回测ID | 唯一标识 | #12345 |
|
||||||
|
| 配置名称 | 回测任务名称 | "激进策略-Leader A" |
|
||||||
|
| Leader名称 | 跟单的Leader | "Smart Trader" |
|
||||||
|
| 初始金额 | 回测起始资金 | $1000 |
|
||||||
|
| 最终金额 | 回测结束时资金 | $1250 |
|
||||||
|
| 收益额 | 最终金额 - 初始金额 | +$250 |
|
||||||
|
| 收益率 | (收益额/初始金额) × 100% | +25% |
|
||||||
|
| 回测天数 | 回测的时间跨度 | 30天 |
|
||||||
|
| 交易笔数 | 回测期间执行的交易数量 | 45笔 |
|
||||||
|
| 状态 | 运行中/已完成/已停止 | 已完成 |
|
||||||
|
| 开始时间 | 回测开始时间 | 2026-01-01 10:00 |
|
||||||
|
| 结束时间 | 回测结束时间 | 2026-01-31 15:30 |
|
||||||
|
| 操作 | 查看详情/删除 | - |
|
||||||
|
|
||||||
|
#### 3.1.3 列表操作
|
||||||
|
|
||||||
|
- **查看详情**: 点击后展开详细信息,包括:
|
||||||
|
- 回测配置参数
|
||||||
|
- 详细交易记录
|
||||||
|
- 资金变化曲线图
|
||||||
|
- 收益统计
|
||||||
|
- **删除**: 删除回测记录(确认后不可恢复)
|
||||||
|
|
||||||
|
### 3.2 新增回测任务
|
||||||
|
|
||||||
|
#### 3.2.1 创建入口
|
||||||
|
|
||||||
|
- 列表页面右上角"新增回测"按钮
|
||||||
|
- 点击后弹出创建对话框或跳转到创建页面
|
||||||
|
|
||||||
|
#### 3.2.2 配置表单
|
||||||
|
|
||||||
|
**基本配置**:
|
||||||
|
- **回测名称** (必填): 用户自定义名称,方便识别
|
||||||
|
- **选择Leader** (必填): 下拉框选择已添加的Leader
|
||||||
|
- **初始投入金额** (必填): 模拟起始资金,单位USDC,范围: 1 - 1,000,000
|
||||||
|
- **回测天数** (必填): 选择回测的历史天数,范围: 1 - 30天
|
||||||
|
|
||||||
|
**跟单配置** (参照现有跟单配置参数):
|
||||||
|
|
||||||
|
| 配置项 | 字段名 | 说明 | 默认值 |
|
||||||
|
|-------|-------|------|-------|
|
||||||
|
| 跟单模式 | copyMode | RATIO(比例模式) / FIXED(固定金额) | RATIO |
|
||||||
|
| 跟单比例 | copyRatio | 比例模式下生效,相对Leader订单金额的比例 | 1.0 |
|
||||||
|
| 固定金额 | fixedAmount | 固定金额模式下生效,每笔固定投入金额 | null |
|
||||||
|
| 最大单笔订单 | maxOrderSize | 单笔订单最大金额限制 | 1000 |
|
||||||
|
| 最小单笔订单 | minOrderSize | 单笔订单最小金额限制 | 1 |
|
||||||
|
| 最大每日亏损 | maxDailyLoss | 每日最大亏损限制 | 10000 |
|
||||||
|
| 最大每日订单数 | maxDailyOrders | 每日最大订单数量限制 | 100 |
|
||||||
|
| 价格容忍度 | priceTolerance | 价格偏差容忍百分比 | 5% |
|
||||||
|
| 延迟秒数 | delaySeconds | 跟单延迟时间 | 0 |
|
||||||
|
| 支持卖出 | supportSell | 是否跟随卖出 | true |
|
||||||
|
| 最小订单深度 | minOrderDepth | 订单簿深度要求 | null |
|
||||||
|
| 最大价差 | maxSpread | 买卖价差限制 | null |
|
||||||
|
| 最低价格 | minPrice | 最低价格限制 | null |
|
||||||
|
| 最高价格 | maxPrice | 最高价格限制 | null |
|
||||||
|
| 最大仓位金额 | maxPositionValue | 最大持仓总金额 | null |
|
||||||
|
| 关键字过滤模式 | keywordFilterMode | DISABLED/WHITELIST/BLACKLIST | DISABLED |
|
||||||
|
| 关键字列表 | keywords | 关键字数组 | [] |
|
||||||
|
| 市场截止时间限制 | maxMarketEndDate | 市场结束时间限制 | null |
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> 跟单配置表单应完全复用现有的跟单配置组件,保持参数一致性
|
||||||
|
|
||||||
|
#### 3.2.3 表单验证
|
||||||
|
|
||||||
|
- 回测名称: 不能为空,长度1-100字符
|
||||||
|
- Leader: 必须选择有效的Leader
|
||||||
|
- 初始金额: 必须大于0
|
||||||
|
- 回测天数: 必须在1-30之间
|
||||||
|
- 其他配置参数: 遵循现有跟单配置的验证规则
|
||||||
|
|
||||||
|
#### 3.2.4 提交逻辑
|
||||||
|
|
||||||
|
1. 表单验证通过后,提交到后端API
|
||||||
|
2. 后端保存回测任务到数据库,状态设置为"待执行"
|
||||||
|
3. 前端显示创建成功提示,自动跳转到列表页面
|
||||||
|
4. 回测任务由后端轮询服务自动获取并执行
|
||||||
|
|
||||||
|
### 3.3 回测详情页面
|
||||||
|
|
||||||
|
#### 3.3.1 页面布局
|
||||||
|
|
||||||
|
**顶部概览卡片**:
|
||||||
|
- 回测名称
|
||||||
|
- Leader信息
|
||||||
|
- 初始金额 / 最终金额
|
||||||
|
- 收益额 / 收益率
|
||||||
|
- 回测时间范围
|
||||||
|
- 总交易笔数
|
||||||
|
- 状态
|
||||||
|
|
||||||
|
**资金变化图表**:
|
||||||
|
- X轴: 时间
|
||||||
|
- Y轴: 账户余额
|
||||||
|
- 折线图展示资金随时间的变化
|
||||||
|
|
||||||
|
**交易记录表格**:
|
||||||
|
| 时间 | 市场 | 方向 | 数量 | 价格 | 金额 | 盈亏 | 余额 |
|
||||||
|
|-----|------|-----|------|------|------|------|------|
|
||||||
|
| 2026-01-01 10:05 | BTC > $100k | 买入 YES | 100 | 0.65 | 65 | - | 935.00 |
|
||||||
|
| 2026-01-01 14:20 | BTC > $100k | 卖出 YES | 100 | 0.72 | 72 | +7.00 | 942.00 |
|
||||||
|
| 2026-01-05 16:00 | ETH > $5k | 买入 YES | 50 | 0.80 | 40 | - | 902.00 |
|
||||||
|
| 2026-01-10 00:00 | ETH > $5k | 市场结算 YES | 50 | 1.00 | 50 | +10.00 | 952.00 |
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> **交易类型说明**:
|
||||||
|
> - **买入**: 跟随Leader买入
|
||||||
|
> - **卖出**: 跟随Leader卖出
|
||||||
|
> - **市场结算**: 市场到期自动结算(赎回)
|
||||||
|
>
|
||||||
|
> **手续费**: 回测不计算手续费,简化计算逻辑
|
||||||
|
|
||||||
|
**统计数据**:
|
||||||
|
- 总交易笔数
|
||||||
|
- 买入笔数 / 卖出笔数
|
||||||
|
- 胜率 (盈利交易 / 总交易)
|
||||||
|
- 平均收益
|
||||||
|
- 最大单笔盈利
|
||||||
|
- 最大单笔亏损
|
||||||
|
- 最大回撤
|
||||||
|
|
||||||
|
## 四、业务规则
|
||||||
|
|
||||||
|
### 4.1 回测执行规则
|
||||||
|
|
||||||
|
#### 4.1.1 资金检查
|
||||||
|
- **停止条件**: 当账户余额 < $1 **且无任何持仓**时,回测自动停止
|
||||||
|
- **继续条件**: 如果余额 < $1 但仍有持仓,继续处理后续交易
|
||||||
|
- 原因: Leader 可能卖出,或市场到期结算,释放资金
|
||||||
|
- 处理: 跳过无法执行的买入订单,继续处理卖出和结算
|
||||||
|
- **订单检查**: 每次下单前检查余额是否充足
|
||||||
|
- **不足处理**: 余额不足时跳过该买入订单,记录日志,继续监听后续交易
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> 只要有持仓存在,就不应停止回测,因为后续可能通过卖出或市场结算回收资金
|
||||||
|
|
||||||
|
#### 4.1.2 历史数据获取
|
||||||
|
- 从 Polymarket API 获取 Leader 的历史交易记录
|
||||||
|
- 根据回测天数计算起始时间: `startTime = now - (backtestDays × 24 × 3600 × 1000)`
|
||||||
|
- 按时间顺序回放交易
|
||||||
|
|
||||||
|
#### 4.1.3 交易执行模拟
|
||||||
|
- 按照配置的跟单规则计算跟单金额
|
||||||
|
- 应用所有过滤条件 (价格、深度、关键字等)
|
||||||
|
- 模拟订单成交(不计算手续费)
|
||||||
|
- 更新账户余额
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 回测不计算手续费,简化计算逻辑,避免过于复杂的精度问题
|
||||||
|
|
||||||
|
#### 4.1.3.1 余额不足的处理 ⚠️
|
||||||
|
|
||||||
|
**场景说明**:
|
||||||
|
- 当前余额不足以执行买入订单
|
||||||
|
- 但有持仓未卖出(相当于"待赎回资产")
|
||||||
|
|
||||||
|
**处理策略**:
|
||||||
|
|
||||||
|
**方案A: 严格模式**(推荐)
|
||||||
|
- ✅ 仅使用当前可用余额(`currentBalance`)
|
||||||
|
- ✅ 余额不足时跳过该订单,不考虑持仓价值
|
||||||
|
- ✅ 理由: 更保守,模拟真实场景(持仓未卖出前资金不可用)
|
||||||
|
|
||||||
|
**方案B: 宽松模式**(可选)
|
||||||
|
- 计算"潜在可用资金" = `currentBalance + 持仓市值`
|
||||||
|
- 允许"透支"买入,后续通过卖出或结算平衡
|
||||||
|
- 风险: 可能产生不切实际的回测结果
|
||||||
|
|
||||||
|
**推荐实现**:
|
||||||
|
```kotlin
|
||||||
|
// 严格检查余额
|
||||||
|
if (totalCost > currentBalance) {
|
||||||
|
logger.info("余额不足以执行买入订单: 需要 $totalCost, 可用 $currentBalance")
|
||||||
|
logger.debug("当前持仓价值: ${calculatePositionValue(positions)}, 但不计入可用余额")
|
||||||
|
continue // 跳过该订单
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**特殊情况: 市场即将结算**
|
||||||
|
- 如果持仓市场在接下来很短时间内会结算,可以提前释放资金
|
||||||
|
- 实现: 在每次交易前先执行市场结算检查(已在4.1.5实现)
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> 采用**严格模式**更符合真实跟单场景,避免回测结果过于乐观
|
||||||
|
|
||||||
|
#### 4.1.4 卖出跟随
|
||||||
|
- 如果 `supportSell = true`,跟随 Leader 的卖出操作
|
||||||
|
- 按照买入时的比例卖出持仓
|
||||||
|
- 计算盈亏并更新余额
|
||||||
|
|
||||||
|
#### 4.1.5 市场结算处理 ⭐
|
||||||
|
- **触发时机**:
|
||||||
|
- **实时检查**: 在处理每笔Leader交易前,检查所有持仓市场的`endDate`
|
||||||
|
- **到期即结算**: 如果市场结束时间 ≤ 当前交易时间,立即结算该持仓
|
||||||
|
- **兜底处理**: 回测结束时,结算所有剩余持仓
|
||||||
|
- **结算规则**:
|
||||||
|
- 获取市场最终结果 (通过Polymarket API)
|
||||||
|
- 持仓方向为胜出方: 按 **1.0** 价格结算
|
||||||
|
- 持仓方向为失败方: 按 **0.0** 价格结算
|
||||||
|
- 市场未结算或无法获取结果: 按**成本价**结算 (保守估计)
|
||||||
|
- **资金流转**: 结算后的资金立即计入余额,可用于后续交易
|
||||||
|
- **交易记录**: 生成"市场结算"类型的交易记录,用于详情展示
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **实时结算的优势**:
|
||||||
|
> - ✅ 模拟真实场景: 市场结束时资金会自动返还
|
||||||
|
> - ✅ 提高资金利用率: 结算后的资金可以参与后续交易
|
||||||
|
> - ✅ 更准确的收益计算: 反映实际的资金周转情况
|
||||||
|
|
||||||
|
### 4.2 数据持久化
|
||||||
|
|
||||||
|
#### 4.2.1 回测任务表
|
||||||
|
- 保存回测基本信息和配置
|
||||||
|
- 记录执行状态和结果
|
||||||
|
|
||||||
|
#### 4.2.2 回测交易记录表
|
||||||
|
- 保存每笔模拟交易的详细信息
|
||||||
|
- 用于详情页面展示和分析
|
||||||
|
|
||||||
|
### 4.3 并发控制
|
||||||
|
|
||||||
|
- 同一时间最多支持 5 个回测任务并发执行
|
||||||
|
- 新任务排队等待,FIFO策略
|
||||||
|
- 前端显示任务队列位置
|
||||||
|
|
||||||
|
## 五、UI/UX 要求
|
||||||
|
|
||||||
|
### 5.1 响应式设计
|
||||||
|
- 支持桌面和移动端浏览
|
||||||
|
- 表格在小屏幕上支持横向滚动
|
||||||
|
|
||||||
|
### 5.2 国际化
|
||||||
|
- 支持中文和英文
|
||||||
|
- 所有文案提供双语版本
|
||||||
|
|
||||||
|
### 5.3 交互体验
|
||||||
|
- 创建回测: 表单提交时显示Loading状态
|
||||||
|
- 回测执行中: 显示进度条或百分比
|
||||||
|
- 数据加载: Skeleton占位符
|
||||||
|
- 操作反馈: Toast提示 (成功/失败/警告)
|
||||||
|
|
||||||
|
### 5.4 数据可视化
|
||||||
|
- 资金变化图表使用 ECharts 或 Recharts
|
||||||
|
- 支持图表缩放和数据点Tooltip
|
||||||
|
- 图表颜色: 盈利绿色,亏损红色
|
||||||
|
|
||||||
|
## 六、非功能需求
|
||||||
|
|
||||||
|
### 6.1 性能要求
|
||||||
|
- 回测列表页面加载时间 < 2秒
|
||||||
|
- 单个回测任务执行时间 < 5分钟 (30天数据)
|
||||||
|
- 详情页图表渲染时间 < 1秒
|
||||||
|
|
||||||
|
### 6.2 数据准确性
|
||||||
|
- 回测结果误差 < 0.1%
|
||||||
|
- 余额计算使用 BigDecimal 避免精度丢失
|
||||||
|
- 价格和数量精确到小数点后8位
|
||||||
|
|
||||||
|
### 6.3 安全性
|
||||||
|
- 回测数据仅用户本人可见
|
||||||
|
- API接口需要身份认证
|
||||||
|
- 防止SQL注入和XSS攻击
|
||||||
|
|
||||||
|
## 七、后续迭代规划
|
||||||
|
|
||||||
|
### Phase 2 (可选)
|
||||||
|
- 支持批量创建回测任务
|
||||||
|
- 回测结果对比功能
|
||||||
|
- 导出回测报告 (PDF/Excel)
|
||||||
|
- AI策略推荐
|
||||||
|
|
||||||
|
### Phase 3 (可选)
|
||||||
|
- 实时回测 (边交易边回测)
|
||||||
|
- 社区策略分享
|
||||||
|
- 策略市场
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录: 页面路由规划
|
||||||
|
|
||||||
|
- 回测列表: `/copy-trading/backtest`
|
||||||
|
- 新增回测: `/copy-trading/backtest/create`
|
||||||
|
- 回测详情: `/copy-trading/backtest/:id`
|
||||||
@@ -0,0 +1,669 @@
|
|||||||
|
# 回测功能设计审查清单
|
||||||
|
|
||||||
|
## 一、设计审查要点
|
||||||
|
|
||||||
|
### 1.1 产品需求完整性 ✅
|
||||||
|
|
||||||
|
**已覆盖的核心功能**:
|
||||||
|
- ✅ 回测任务的创建、查询、删除
|
||||||
|
- ✅ 按Leader筛选和排序功能
|
||||||
|
- ✅ 回测配置参数复用现有跟单配置
|
||||||
|
- ✅ 回测详情展示 (交易记录、资金曲线图、统计数据)
|
||||||
|
- ✅ 资金不足时自动停止机制
|
||||||
|
- ✅ 回测天数限制 (1-30天)
|
||||||
|
|
||||||
|
**潜在遗漏点**:
|
||||||
|
> [!WARNING]
|
||||||
|
> **需要确认的问题**:
|
||||||
|
> 1. **回测结果的可见性**: 是否需要支持多用户? 当前设计未涉及权限控制
|
||||||
|
> 2. **回测任务的生命周期管理**: 是否需要自动清理过期的回测记录?
|
||||||
|
> 3. **回测进度的实时展示**: 前端如何获取运行中任务的进度? (考虑WebSocket或轮询)
|
||||||
|
|
||||||
|
### 1.2 技术设计合理性 ✅
|
||||||
|
|
||||||
|
**优点**:
|
||||||
|
- ✅ 数据库设计规范,索引合理
|
||||||
|
- ✅ API设计符合RESTful规范
|
||||||
|
- ✅ 复用现有的 `CopyTradingFilterService`,减少代码冗余
|
||||||
|
- ✅ 使用 BigDecimal 保证计算精度
|
||||||
|
- ✅ 异步执行回测任务,不阻塞主线程
|
||||||
|
|
||||||
|
**可能的改进点**:
|
||||||
|
> [!NOTE]
|
||||||
|
> **建议优化的地方**:
|
||||||
|
> 1. **历史数据获取**: 当前设计依赖Polymarket API,需要考虑API限流和数据缺失的情况
|
||||||
|
> 2. **缓存策略**: 建议对Leader历史交易数据使用分层缓存 (内存 + Redis)
|
||||||
|
> 3. **回测结果的序列化**: 考虑将详细交易记录存储为JSON,减少表的大小
|
||||||
|
|
||||||
|
### 1.3 业务逻辑准确性 ✅
|
||||||
|
|
||||||
|
**已完善的关键逻辑**:
|
||||||
|
|
||||||
|
#### 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]
|
||||||
|
> **关键问题**: 是否需要模拟价格滑点?
|
||||||
|
>
|
||||||
|
> **当前设计**: 不模拟价格滑点,直接使用Leader的成交价
|
||||||
|
> - 优点: 简化逻辑,回测速度快
|
||||||
|
> - 缺点: 可能高估收益(实际跟单可能有滑点)
|
||||||
|
>
|
||||||
|
> **可选方案**: 增加可配置的滑点参数
|
||||||
|
> - 买入时: 价格 × (1 + 滑点%)
|
||||||
|
> - 卖出时: 价格 × (1 - 滑点%)
|
||||||
|
> - 增加可选的滑点模拟参数 (例如: ±0.5%)
|
||||||
|
> - 在PRD中补充此配置项
|
||||||
|
|
||||||
|
#### 1.3.3 手续费计算 ✅ (已移除)
|
||||||
|
> [!NOTE]
|
||||||
|
> **用户决策**: 回测不计算手续费
|
||||||
|
>
|
||||||
|
> **理由**:
|
||||||
|
> - 简化计算逻辑
|
||||||
|
> - 避免精度问题
|
||||||
|
> - 降低复杂度
|
||||||
|
>
|
||||||
|
> **实现**:
|
||||||
|
> - 所有交易的 `fee` 字段均为 `0`
|
||||||
|
> - 买入成本 = 数量 × 价格
|
||||||
|
> - 卖出收入 = 数量 × 价格
|
||||||
|
> - 结算收入 = 数量 × 结算价
|
||||||
|
|
||||||
|
#### 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]
|
||||||
|
> **关键问题**: 当余额不足但有未卖出持仓时,如何处理?
|
||||||
|
>
|
||||||
|
> **场景示例**:
|
||||||
|
> - 初始余额: $1000
|
||||||
|
> - 已买入持仓市值: $800(未卖出)
|
||||||
|
> - 当前余额: $200
|
||||||
|
> - 新买入订单需要: $300
|
||||||
|
> - **问题**: 是否允许买入?虽然持仓市值足够,但资金被占用
|
||||||
|
>
|
||||||
|
> **推荐方案: 严格模式**
|
||||||
|
> - ❌ 不允许买入(余额不足)
|
||||||
|
> - ✅ 仅使用 `currentBalance` 判断
|
||||||
|
> - ✅ 不计入持仓市值(因为持仓未实现)
|
||||||
|
> - ✅ 理由: 更真实,避免过于乐观的回测结果
|
||||||
|
>
|
||||||
|
> **替代方案: 宽松模式**
|
||||||
|
> - ✅ 计算"虚拟可用资金" = `currentBalance + 持仓估值`
|
||||||
|
> - ⚠️ 允许"透支"买入
|
||||||
|
> - ❌ 风险: 可能产生不切实际的收益
|
||||||
|
>
|
||||||
|
> **实际影响**:
|
||||||
|
> - 严格模式下,资金周转率是限制因素
|
||||||
|
> - 鼓励快进快出的策略
|
||||||
|
> - 长期持仓策略会因资金占用而错过后续机会
|
||||||
|
>
|
||||||
|
> **已在文档中采用**: 严格模式
|
||||||
|
|
||||||
|
#### 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]
|
||||||
|
> **修正**: 基于用户反馈,修正了停止逻辑
|
||||||
|
>
|
||||||
|
> **错误设计**:
|
||||||
|
> ```kotlin
|
||||||
|
> if (currentBalance < $1) {
|
||||||
|
> break // ❌ 直接停止,忽略持仓
|
||||||
|
> }
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> **正确设计**:
|
||||||
|
> ```kotlin
|
||||||
|
> // 只有"余额不足 且 无持仓"时才停止
|
||||||
|
> if (currentBalance < $1 && positions.isEmpty()) {
|
||||||
|
> break // ✅ 确保无持仓时才停止
|
||||||
|
> }
|
||||||
|
>
|
||||||
|
> // 有持仓时继续处理(等待卖出或结算)
|
||||||
|
> if (currentBalance < $1 && positions.isNotEmpty()) {
|
||||||
|
> // 继续处理,跳过买入,但执行卖出和结算
|
||||||
|
> }
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> **理由**:
|
||||||
|
> - 持仓存在意味着可能有后续卖出或市场结算
|
||||||
|
> - 这些操作会释放资金
|
||||||
|
> - 过早停止会导致资金无法回收,回测不准确
|
||||||
|
|
||||||
|
### 1.4 性能和可扩展性 ✅
|
||||||
|
|
||||||
|
**已考虑的优化**:
|
||||||
|
- ✅ 异步执行,线程池限制并发
|
||||||
|
- ✅ 分页查询
|
||||||
|
- ✅ 数据库索引优化
|
||||||
|
- ✅ 前端虚拟滚动
|
||||||
|
|
||||||
|
**需要进一步考虑**:
|
||||||
|
> [!TIP]
|
||||||
|
> **性能优化建议**:
|
||||||
|
> 1. **批量插入交易记录**: 使用 `saveAll()` 而非逐条 `save()`
|
||||||
|
> 2. **进度更新频率**: 避免每笔交易都更新数据库,改为每100笔或每10秒更新一次
|
||||||
|
> 3. **历史数据预加载**: 在任务开始前一次性加载所有历史交易,避免多次API调用
|
||||||
|
|
||||||
|
## 二、数据库设计补充
|
||||||
|
|
||||||
|
### 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` 表
|
||||||
|
建议新增以下字段:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 用于计算平均持仓时间
|
||||||
|
avg_holding_time BIGINT DEFAULT NULL COMMENT '平均持仓时间(毫秒)',
|
||||||
|
|
||||||
|
-- 用于记录回测使用的数据源
|
||||||
|
data_source VARCHAR(50) DEFAULT 'MIXED' COMMENT '数据源: INTERNAL/API/MIXED',
|
||||||
|
|
||||||
|
-- 用于记录回测执行的详细日志
|
||||||
|
execution_log TEXT DEFAULT NULL COMMENT '执行日志(JSON格式)'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 索引优化
|
||||||
|
|
||||||
|
建议添加复合索引:
|
||||||
|
```sql
|
||||||
|
-- 用于按Leader和收益率查询
|
||||||
|
CREATE INDEX idx_leader_profit ON backtest_task(leader_id, profit_rate DESC);
|
||||||
|
|
||||||
|
-- 用于按状态和创建时间查询
|
||||||
|
CREATE INDEX idx_status_created ON backtest_task(status, created_at DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 三、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.2.1 查询回测进度 (实时更新)
|
||||||
|
```
|
||||||
|
POST /api/backtest/tasks/progress
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 12345
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"progress": 65,
|
||||||
|
"currentBalance": "1150.00",
|
||||||
|
"totalTrades": 30,
|
||||||
|
"status": "RUNNING"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2.2 批量删除回测任务
|
||||||
|
```
|
||||||
|
POST /api/backtest/tasks/batch-delete
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"taskIds": [12345, 12346, 12347]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2.3 导出回测报告
|
||||||
|
```
|
||||||
|
POST /api/backtest/tasks/export
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 12345,
|
||||||
|
"format": "csv" // 或 "pdf"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 API错误码规范
|
||||||
|
|
||||||
|
建议统一错误码:
|
||||||
|
|
||||||
|
| 错误码 | 说明 |
|
||||||
|
|-------|------|
|
||||||
|
| 40001 | 回测任务不存在 |
|
||||||
|
| 40002 | Leader不存在 |
|
||||||
|
| 40003 | 回测天数超出限制 |
|
||||||
|
| 40004 | 初始金额无效 |
|
||||||
|
| 40005 | 回测任务正在运行,无法删除 |
|
||||||
|
| 50001 | 历史数据获取失败 |
|
||||||
|
| 50002 | 回测执行失败 |
|
||||||
|
|
||||||
|
## 四、前端实现补充
|
||||||
|
|
||||||
|
### 4.1 状态轮询
|
||||||
|
|
||||||
|
对于运行中的回测任务,前端需要定时轮询进度:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
if (task.status === 'RUNNING') {
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
const progress = await backtestService.getProgress(task.id);
|
||||||
|
setTask({ ...task, ...progress });
|
||||||
|
}, 3000); // 每3秒轮询一次
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [task.status]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 图表数据压缩
|
||||||
|
|
||||||
|
当交易记录过多时,图表数据需要压缩:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 将数据按时间聚合为最多200个点
|
||||||
|
const compressChartData = (trades: BacktestTrade[], maxPoints: number = 200) => {
|
||||||
|
if (trades.length <= maxPoints) return trades;
|
||||||
|
|
||||||
|
const interval = Math.floor(trades.length / maxPoints);
|
||||||
|
return trades.filter((_, index) => index % interval === 0);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 国际化文案
|
||||||
|
|
||||||
|
需要在 `locales/` 目录下补充以下文案:
|
||||||
|
|
||||||
|
**zh-CN.json**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"backtest": {
|
||||||
|
"title": "回测管理",
|
||||||
|
"createTask": "新增回测",
|
||||||
|
"taskName": "回测名称",
|
||||||
|
"leader": "Leader",
|
||||||
|
"initialBalance": "初始金额",
|
||||||
|
"backtestDays": "回测天数",
|
||||||
|
"profitAmount": "收益额",
|
||||||
|
"profitRate": "收益率",
|
||||||
|
"status": {
|
||||||
|
"pending": "待执行",
|
||||||
|
"running": "运行中",
|
||||||
|
"completed": "已完成",
|
||||||
|
"stopped": "已停止",
|
||||||
|
"failed": "失败"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 五、测试计划补充
|
||||||
|
|
||||||
|
### 5.1 单元测试
|
||||||
|
|
||||||
|
**需要测试的核心方法**:
|
||||||
|
- `BacktestExecutionService.executeBacktest()` - 回测算法准确性
|
||||||
|
- `BacktestExecutionService.calculateStatistics()` - 统计数据计算
|
||||||
|
- `BacktestDataService.getLeaderHistoricalTrades()` - 历史数据获取
|
||||||
|
|
||||||
|
**测试用例示例**:
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `test backtest with simple buy-sell scenario`() {
|
||||||
|
// Given: 初始余额1000, Leader买入100@0.5, 卖出100@0.6
|
||||||
|
val task = createTestTask(initialBalance = 1000.toBigDecimal())
|
||||||
|
val trades = listOf(
|
||||||
|
createBuyTrade(quantity = 100.toBigDecimal(), price = 0.5.toBigDecimal()),
|
||||||
|
createSellTrade(quantity = 100.toBigDecimal(), price = 0.6.toBigDecimal())
|
||||||
|
)
|
||||||
|
|
||||||
|
// When: 执行回测
|
||||||
|
val result = executionService.executeBacktest(task)
|
||||||
|
|
||||||
|
// Then: 验证收益
|
||||||
|
// 买入: 100 * 0.5 = 50, 手续费0.1, 总成本50.1
|
||||||
|
// 卖出: 100 * 0.6 = 60, 手续费0.12, 净收入59.88
|
||||||
|
// 盈利: 59.88 - 50.1 = 9.78
|
||||||
|
// 最终余额: 1000 - 50.1 + 59.88 = 1009.78
|
||||||
|
assertEquals(1009.78.toBigDecimal(), result.finalBalance)
|
||||||
|
assertEquals(9.78.toBigDecimal(), result.profitAmount)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 集成测试
|
||||||
|
|
||||||
|
**测试场景**:
|
||||||
|
1. 端到端测试: 创建任务 → 执行回测 → 查询结果
|
||||||
|
2. 异常场景: 历史数据为空、API调用失败
|
||||||
|
3. 边界条件: 余额刚好为0、单笔交易耗尽余额
|
||||||
|
|
||||||
|
### 5.3 性能测试
|
||||||
|
|
||||||
|
**测试指标**:
|
||||||
|
- 30天历史数据 (假设1000笔交易) 的回测执行时间 < 5分钟
|
||||||
|
- 并发5个回测任务时的系统资源占用
|
||||||
|
- 查询包含10000笔交易的回测详情页面加载时间 < 2秒
|
||||||
|
|
||||||
|
## 六、风险评估和缓解方案
|
||||||
|
|
||||||
|
### 6.1 数据准确性风险
|
||||||
|
|
||||||
|
**风险**: 历史数据不完整或API返回数据有误
|
||||||
|
|
||||||
|
**缓解方案**:
|
||||||
|
1. 数据验证: 检查返回数据的完整性 (是否有时间断层)
|
||||||
|
2. 数据对比: 使用多个数据源交叉验证
|
||||||
|
3. 错误标记: 回测结果标注数据质量等级
|
||||||
|
|
||||||
|
### 6.2 计算精度风险
|
||||||
|
|
||||||
|
**风险**: BigDecimal计算中的舍入误差累积
|
||||||
|
|
||||||
|
**缓解方案**:
|
||||||
|
1. 统一舍入模式: 使用 `RoundingMode.HALF_UP`
|
||||||
|
2. 精度测试: 编写专门的精度测试用例
|
||||||
|
3. 误差补偿: 最终余额与理论值的误差 < 0.01 USDC
|
||||||
|
|
||||||
|
### 6.3 性能风险
|
||||||
|
|
||||||
|
**风险**: 大量回测任务导致系统负载过高
|
||||||
|
|
||||||
|
**缓解方案**:
|
||||||
|
1. 任务队列: 使用异步任务队列 (可选: Redis Queue 或 RabbitMQ)
|
||||||
|
2. 资源限流: 限制单用户最多创建10个待执行任务
|
||||||
|
3. 自动清理: 定期清理30天前的回测记录
|
||||||
|
|
||||||
|
## 七、需要与用户确认的问题
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **关键决策点 - 需要用户反馈**:
|
||||||
|
|
||||||
|
### 7.1 卖出匹配策略
|
||||||
|
**问题**: 当用户多次买入同一市场时,卖出应该匹配哪笔买入?
|
||||||
|
|
||||||
|
**选项**:
|
||||||
|
- **选项A**: FIFO (先进先出) - 先卖出最早的买入
|
||||||
|
- **选项B**: 加权平均 - 按平均成本价计算盈亏
|
||||||
|
- **选项C**: 完全跟随Leader - Leader卖多少比例,我们也卖多少比例
|
||||||
|
|
||||||
|
**建议**: 选项C (完全跟随),与实际跟单逻辑保持一致
|
||||||
|
|
||||||
|
### 7.2 价格滑点模拟
|
||||||
|
**问题**: 是否需要在回测中模拟价格滑点?
|
||||||
|
|
||||||
|
**选项**:
|
||||||
|
- **选项A**: 不模拟,使用Leader成交价 (乐观估计)
|
||||||
|
- **选项B**: 固定滑点 (如买入+0.5%, 卖出-0.5%)
|
||||||
|
- **选项C**: 可配置滑点,用户自定义
|
||||||
|
|
||||||
|
**建议**: 选项C,增加灵活性
|
||||||
|
|
||||||
|
### 7.3 数据源选择
|
||||||
|
**问题**: 历史数据来源?
|
||||||
|
|
||||||
|
**选项**:
|
||||||
|
- **选项A**: 仅使用 Polymarket API
|
||||||
|
- **选项B**: 优先使用系统记录的 `ProcessedTrade` 表,不足时调用API
|
||||||
|
- **选项C**: 仅使用 `ProcessedTrade` 表 (限制回测范围为系统运行期间)
|
||||||
|
|
||||||
|
**建议**: 选项B,兼顾数据完整性和性能
|
||||||
|
|
||||||
|
### 7.4 回测结果保留时长
|
||||||
|
**问题**: 回测记录保留多久?
|
||||||
|
|
||||||
|
**选项**:
|
||||||
|
- **选项A**: 永久保留
|
||||||
|
- **选项B**: 保留30天,自动清理
|
||||||
|
- **选项C**: 用户手动删除,无自动清理
|
||||||
|
|
||||||
|
**建议**: 选项B,避免数据库膨胀
|
||||||
|
|
||||||
|
## 八、文档总结
|
||||||
|
|
||||||
|
### 已完成的文档
|
||||||
|
1. ✅ **BACKTEST_PRD.md** - 产品需求文档
|
||||||
|
2. ✅ **BACKTEST_TECHNICAL_DESIGN.md** - 技术设计文档
|
||||||
|
3. ✅ **BACKTEST_REVIEW_CHECKLIST.md** - 设计审查清单 (本文档)
|
||||||
|
|
||||||
|
### 建议补充的文档 (可选)
|
||||||
|
1. **BACKTEST_API_SPEC.md** - API接口规范 (从技术设计文档提取)
|
||||||
|
2. **BACKTEST_DATABASE_MIGRATION.md** - 数据库迁移脚本
|
||||||
|
3. **BACKTEST_TEST_PLAN.md** - 详细测试计划
|
||||||
|
|
||||||
|
### 下一步行动
|
||||||
|
1. **用户Review**: 请用户审查以上文档,确认关键设计点
|
||||||
|
2. **补充遗漏**: 根据用户反馈补充缺失部分
|
||||||
|
3. **进入执行**: 用户确认后开始实施开发
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**审查日期**: 2026-01-30
|
||||||
|
**审查人**: AI Assistant
|
||||||
|
**状态**: 待用户确认
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
|||||||
|
# 跟单回测功能文档
|
||||||
|
|
||||||
|
## 📚 文档清单
|
||||||
|
|
||||||
|
本目录包含跟单回测功能的完整设计文档:
|
||||||
|
|
||||||
|
### 核心文档
|
||||||
|
|
||||||
|
1. **[BACKTEST_PRD.md](./BACKTEST_PRD.md)** - 产品需求文档
|
||||||
|
- 功能概述与用户故事
|
||||||
|
- UI/UX设计详细说明
|
||||||
|
- 业务规则与数据要求
|
||||||
|
|
||||||
|
2. **[BACKTEST_TECHNICAL_DESIGN.md](./BACKTEST_TECHNICAL_DESIGN.md)** - 技术设计文档
|
||||||
|
- 数据库表结构设计
|
||||||
|
- RESTful API接口规范
|
||||||
|
- 后端服务架构
|
||||||
|
- 前端组件设计
|
||||||
|
- 回测算法实现
|
||||||
|
|
||||||
|
3. **[BACKTEST_REVIEW_CHECKLIST.md](./BACKTEST_REVIEW_CHECKLIST.md)** - 设计审查清单
|
||||||
|
- 设计完整性检查
|
||||||
|
- 边缘场景处理
|
||||||
|
- 风险评估与缓解
|
||||||
|
|
||||||
|
## 🎯 核心特性
|
||||||
|
|
||||||
|
- ✅ 完全复用现有跟单配置参数
|
||||||
|
- ✅ 实时市场结算(按 `endDate` 检查)
|
||||||
|
- ✅ 严格余额检查(避免过于乐观的回测)
|
||||||
|
- ✅ 支持按Leader筛选、按收益排序
|
||||||
|
- ✅ 详细的交易记录和资金曲线图
|
||||||
|
|
||||||
|
## 📖 阅读建议
|
||||||
|
|
||||||
|
**产品经理**: 先阅读 PRD,再查看审查清单中的关键决策点
|
||||||
|
|
||||||
|
**技术负责人**: 先阅读技术设计文档,再查看审查清单评估风险
|
||||||
|
|
||||||
|
**开发工程师**: 按顺序阅读所有文档,重点关注技术设计的实现细节
|
||||||
|
|
||||||
|
## 🔄 文档版本
|
||||||
|
|
||||||
|
- **创建日期**: 2026-01-30
|
||||||
|
- **最后更新**: 2026-01-30
|
||||||
|
- **当前版本**: v1.0
|
||||||
|
|
||||||
|
## 📝 关键设计决策
|
||||||
|
|
||||||
|
### 1. 市场结算机制
|
||||||
|
- 采用**实时检查**方式:每笔交易前检查市场 `endDate`
|
||||||
|
- 到期即结算,资金立即释放可用于后续交易
|
||||||
|
|
||||||
|
### 2. 余额检查策略
|
||||||
|
- 采用**严格模式**:仅使用 `currentBalance`,不计入持仓市值
|
||||||
|
- 停止条件:余额 < $1 **且** 无任何持仓
|
||||||
|
|
||||||
|
### 3. 数据源选择
|
||||||
|
- 优先使用系统记录的 `ProcessedTrade` 表
|
||||||
|
- 不足时调用 Polymarket API 补充历史数据
|
||||||
|
|
||||||
|
### 4. 代码复用策略
|
||||||
|
- 后端:完全复用 `CopyTradingFilterService` 的所有过滤逻辑
|
||||||
|
- 前端:复用跟单配置表单组件
|
||||||
|
- 数据库:配置字段与 `CopyTrading` 表保持一致
|
||||||
|
|
||||||
|
## 🚀 下一步
|
||||||
|
|
||||||
|
完成文档审查后,可以:
|
||||||
|
1. 创建 `implementation_plan.md` 详细规划实施步骤
|
||||||
|
2. 开始开发(数据库表 → API → 前端页面)
|
||||||
|
3. 单元测试和集成测试
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档位置**: `/Users/wrbug/polyhermes/docs/zh/backtest/`
|
||||||
Generated
+33
-13
@@ -11,6 +11,7 @@
|
|||||||
"antd": "^5.12.0",
|
"antd": "^5.12.0",
|
||||||
"antd-mobile": "^5.34.0",
|
"antd-mobile": "^5.34.0",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
"ethers": "^6.16.0",
|
"ethers": "^6.16.0",
|
||||||
"i18next": "^25.7.1",
|
"i18next": "^25.7.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@@ -158,7 +159,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -1677,7 +1677,6 @@
|
|||||||
"version": "18.3.27",
|
"version": "18.3.27",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -1743,7 +1742,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
|
||||||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "6.21.0",
|
"@typescript-eslint/scope-manager": "6.21.0",
|
||||||
"@typescript-eslint/types": "6.21.0",
|
"@typescript-eslint/types": "6.21.0",
|
||||||
@@ -1940,7 +1938,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2259,7 +2256,6 @@
|
|||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.25",
|
"baseline-browser-mapping": "^2.8.25",
|
||||||
"caniuse-lite": "^1.0.30001754",
|
"caniuse-lite": "^1.0.30001754",
|
||||||
@@ -2471,8 +2467,7 @@
|
|||||||
"node_modules/dayjs": {
|
"node_modules/dayjs": {
|
||||||
"version": "1.11.19",
|
"version": "1.11.19",
|
||||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
||||||
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
|
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
@@ -2581,6 +2576,22 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.258",
|
"version": "1.5.258",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.258.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.258.tgz",
|
||||||
@@ -2693,7 +2704,6 @@
|
|||||||
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@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"
|
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4"
|
"@babel/runtime": "^7.28.4"
|
||||||
},
|
},
|
||||||
@@ -5441,7 +5450,6 @@
|
|||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -5453,7 +5461,6 @@
|
|||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -6021,7 +6028,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -6194,7 +6200,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
@@ -6325,6 +6330,21 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/zustand": {
|
||||||
"version": "4.5.7",
|
"version": "4.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"antd": "^5.12.0",
|
"antd": "^5.12.0",
|
||||||
"antd-mobile": "^5.34.0",
|
"antd-mobile": "^5.34.0",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
"ethers": "^6.16.0",
|
"ethers": "^6.16.0",
|
||||||
"i18next": "^25.7.1",
|
"i18next": "^25.7.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import SystemSettings from './pages/SystemSettings'
|
|||||||
import ApiHealthStatus from './pages/ApiHealthStatus'
|
import ApiHealthStatus from './pages/ApiHealthStatus'
|
||||||
import RpcNodeSettings from './pages/RpcNodeSettings'
|
import RpcNodeSettings from './pages/RpcNodeSettings'
|
||||||
import Announcements from './pages/Announcements'
|
import Announcements from './pages/Announcements'
|
||||||
|
import BacktestList from './pages/BacktestList'
|
||||||
|
import BacktestDetail from './pages/BacktestDetail'
|
||||||
import { wsManager } from './services/websocket'
|
import { wsManager } from './services/websocket'
|
||||||
import type { OrderPushMessage } from './types'
|
import type { OrderPushMessage } from './types'
|
||||||
import { apiService } from './services/api'
|
import { apiService } from './services/api'
|
||||||
@@ -254,6 +256,8 @@ function App() {
|
|||||||
<Route path="/copy-trading/orders/sell/:copyTradingId" element={<ProtectedRoute><CopyTradingSellOrders /></ProtectedRoute>} />
|
<Route path="/copy-trading/orders/sell/:copyTradingId" element={<ProtectedRoute><CopyTradingSellOrders /></ProtectedRoute>} />
|
||||||
<Route path="/copy-trading/orders/matched/:copyTradingId" element={<ProtectedRoute><CopyTradingMatchedOrders /></ProtectedRoute>} />
|
<Route path="/copy-trading/orders/matched/:copyTradingId" element={<ProtectedRoute><CopyTradingMatchedOrders /></ProtectedRoute>} />
|
||||||
<Route path="/copy-trading/filtered-orders/:id" element={<ProtectedRoute><FilteredOrdersList /></ProtectedRoute>} />
|
<Route path="/copy-trading/filtered-orders/:id" element={<ProtectedRoute><FilteredOrdersList /></ProtectedRoute>} />
|
||||||
|
<Route path="/backtest" element={<ProtectedRoute><BacktestList /></ProtectedRoute>} />
|
||||||
|
<Route path="/backtest/detail/:id" element={<ProtectedRoute><BacktestDetail /></ProtectedRoute>} />
|
||||||
<Route path="/config" element={<ProtectedRoute><ConfigPage /></ProtectedRoute>} />
|
<Route path="/config" element={<ProtectedRoute><ConfigPage /></ProtectedRoute>} />
|
||||||
<Route path="/positions" element={<ProtectedRoute><PositionList /></ProtectedRoute>} />
|
<Route path="/positions" element={<ProtectedRoute><PositionList /></ProtectedRoute>} />
|
||||||
<Route path="/statistics" element={<ProtectedRoute><Statistics /></ProtectedRoute>} />
|
<Route path="/statistics" element={<ProtectedRoute><Statistics /></ProtectedRoute>} />
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import {
|
|||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
SendOutlined,
|
SendOutlined,
|
||||||
ApiOutlined,
|
ApiOutlined,
|
||||||
NotificationOutlined
|
NotificationOutlined,
|
||||||
|
LineChartOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import type { MenuProps } from 'antd'
|
import type { MenuProps } from 'antd'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
@@ -71,7 +72,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
const getInitialOpenKeys = (): string[] => {
|
const getInitialOpenKeys = (): string[] => {
|
||||||
const path = location.pathname
|
const path = location.pathname
|
||||||
const keys: string[] = []
|
const keys: string[] = []
|
||||||
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading')) {
|
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
|
||||||
keys.push('/copy-trading-management')
|
keys.push('/copy-trading-management')
|
||||||
}
|
}
|
||||||
if (path.startsWith('/system-settings')) {
|
if (path.startsWith('/system-settings')) {
|
||||||
@@ -86,7 +87,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const path = location.pathname
|
const path = location.pathname
|
||||||
const keys: string[] = []
|
const keys: string[] = []
|
||||||
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading')) {
|
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
|
||||||
keys.push('/copy-trading-management')
|
keys.push('/copy-trading-management')
|
||||||
}
|
}
|
||||||
if (path.startsWith('/system-settings')) {
|
if (path.startsWith('/system-settings')) {
|
||||||
@@ -148,6 +149,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
key: '/templates',
|
key: '/templates',
|
||||||
icon: <FileTextOutlined />,
|
icon: <FileTextOutlined />,
|
||||||
label: t('menu.templates')
|
label: t('menu.templates')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '/backtest',
|
||||||
|
icon: <LineChartOutlined />,
|
||||||
|
label: t('menu.backtest') || '回测'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,7 +34,10 @@
|
|||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"page": "Page",
|
"page": "Page",
|
||||||
"pageOf": "Page"
|
"pageOf": "Page",
|
||||||
|
"ascending": "Ascending",
|
||||||
|
"descending": "Descending",
|
||||||
|
"day": "day"
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"title": "Account Management",
|
"title": "Account Management",
|
||||||
@@ -240,6 +243,7 @@
|
|||||||
"templates": "Templates",
|
"templates": "Templates",
|
||||||
"copyTradingConfig": "Copy Trading Config",
|
"copyTradingConfig": "Copy Trading Config",
|
||||||
"positions": "Position Management",
|
"positions": "Position Management",
|
||||||
|
"backtest": "Backtest",
|
||||||
"statistics": "Statistics",
|
"statistics": "Statistics",
|
||||||
"announcements": "Announcements",
|
"announcements": "Announcements",
|
||||||
"users": "User Management",
|
"users": "User Management",
|
||||||
@@ -1226,5 +1230,131 @@
|
|||||||
"providerQuickNode": "QuickNode",
|
"providerQuickNode": "QuickNode",
|
||||||
"providerChainstack": "Chainstack",
|
"providerChainstack": "Chainstack",
|
||||||
"providerGetBlock": "GetBlock"
|
"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",
|
||||||
|
"stop": "Stop",
|
||||||
|
"stopSuccess": "Stopped successfully",
|
||||||
|
"stopFailed": "Failed to stop",
|
||||||
|
"retry": "Retry",
|
||||||
|
"retrySuccess": "Retry successfully",
|
||||||
|
"retryFailed": "Failed to retry",
|
||||||
|
"retryConfirm": "Are you sure you want to retry this backtest task? It will continue from the breakpoint and preserve processed trades.",
|
||||||
|
"deleteConfirm": "Are you sure you want to delete this backtest task?",
|
||||||
|
"stopConfirm": "Are you sure you want to stop this backtest task?",
|
||||||
|
"createCopyTrading": "Create Copy Trading",
|
||||||
|
"createCopyTradingSuccess": "Copy trading config created successfully",
|
||||||
|
"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": "Copy 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",
|
||||||
|
"taskNameRequired": "Please enter task name",
|
||||||
|
"leaderRequired": "Please select Leader",
|
||||||
|
"initialBalanceRequired": "Please enter initial balance",
|
||||||
|
"initialBalanceInvalid": "Initial balance must be greater than 0",
|
||||||
|
"backtestDaysRequired": "Please enter backtest days",
|
||||||
|
"backtestDaysInvalid": "Backtest days must be between 1-15",
|
||||||
|
"copyRatioRequired": "Please enter copy ratio",
|
||||||
|
"copyRatioInvalid": "Copy ratio must be between 0.01-10000",
|
||||||
|
"copyRatioPlaceholder": "For example: 100 means 100% (1:1 copy), default 100%",
|
||||||
|
"copyRatioTooltip": "Copy ratio represents the percentage of copy amount relative to Leader order amount. For example: 100% means 1:1 copy, 50% means half position copy, 200% means double copy",
|
||||||
|
"fixedAmountRequired": "Please enter fixed amount",
|
||||||
|
"fixedAmountInvalid": "Fixed amount must be greater than 0",
|
||||||
|
"priceFilters": "Price Filters",
|
||||||
|
"keywordsPlaceholder": "Please enter keywords, press Enter to add",
|
||||||
|
"delaySecondsHint": "Delay execution to simulate real copy trading delay",
|
||||||
|
"supportSellHint": "Whether to follow Leader sell orders",
|
||||||
|
"sortBy": "Sort By",
|
||||||
|
"sortOrder": "Sort Order",
|
||||||
|
"createdAt": "Created At"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,10 @@
|
|||||||
"pageOf": "第",
|
"pageOf": "第",
|
||||||
"success": "成功",
|
"success": "成功",
|
||||||
"failed": "失败",
|
"failed": "失败",
|
||||||
"close": "关闭"
|
"close": "关闭",
|
||||||
|
"ascending": "升序",
|
||||||
|
"descending": "降序",
|
||||||
|
"day": "天"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "登录",
|
"title": "登录",
|
||||||
@@ -240,6 +243,7 @@
|
|||||||
"templates": "跟单模板",
|
"templates": "跟单模板",
|
||||||
"copyTradingConfig": "跟单配置",
|
"copyTradingConfig": "跟单配置",
|
||||||
"positions": "仓位管理",
|
"positions": "仓位管理",
|
||||||
|
"backtest": "回测",
|
||||||
"statistics": "统计信息",
|
"statistics": "统计信息",
|
||||||
"announcements": "公告",
|
"announcements": "公告",
|
||||||
"users": "用户管理",
|
"users": "用户管理",
|
||||||
@@ -1226,5 +1230,131 @@
|
|||||||
"providerQuickNode": "QuickNode",
|
"providerQuickNode": "QuickNode",
|
||||||
"providerChainstack": "Chainstack",
|
"providerChainstack": "Chainstack",
|
||||||
"providerGetBlock": "GetBlock"
|
"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": "删除失败",
|
||||||
|
"stop": "停止",
|
||||||
|
"stopSuccess": "停止成功",
|
||||||
|
"stopFailed": "停止失败",
|
||||||
|
"retry": "重试",
|
||||||
|
"retrySuccess": "重试成功",
|
||||||
|
"retryFailed": "重试失败",
|
||||||
|
"retryConfirm": "确定重新运行此回测任务吗?将从断点继续执行,保留已处理的交易记录。",
|
||||||
|
"deleteConfirm": "确定删除此回测任务吗?",
|
||||||
|
"stopConfirm": "确定停止此回测任务吗?",
|
||||||
|
"createCopyTrading": "创建跟单",
|
||||||
|
"createCopyTradingSuccess": "跟单配置创建成功",
|
||||||
|
"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": "重试",
|
||||||
|
"taskNameRequired": "请输入任务名称",
|
||||||
|
"leaderRequired": "请选择 Leader",
|
||||||
|
"initialBalanceRequired": "请输入初始资金",
|
||||||
|
"initialBalanceInvalid": "初始资金必须大于 0",
|
||||||
|
"backtestDaysRequired": "请输入回测天数",
|
||||||
|
"backtestDaysInvalid": "回测天数必须在 1-15 之间",
|
||||||
|
"copyRatioRequired": "请输入跟单比例",
|
||||||
|
"copyRatioInvalid": "跟单比例必须在 0.01-10000 之间",
|
||||||
|
"copyRatioPlaceholder": "例如:100 表示 100%(1:1 跟单),默认 100%",
|
||||||
|
"copyRatioTooltip": "跟单比例表示跟单金额相对于 Leader 订单金额的百分比。例如:100% 表示 1:1 跟单,50% 表示半仓跟单,200% 表示双倍跟单",
|
||||||
|
"fixedAmountRequired": "请输入固定金额",
|
||||||
|
"fixedAmountInvalid": "固定金额必须大于 0",
|
||||||
|
"priceFilters": "价格过滤",
|
||||||
|
"keywordsPlaceholder": "请输入关键字,按回车添加",
|
||||||
|
"delaySecondsHint": "延迟执行模拟真实跟单延迟",
|
||||||
|
"supportSellHint": "是否跟随 Leader 卖出",
|
||||||
|
"sortBy": "排序字段",
|
||||||
|
"sortOrder": "排序顺序",
|
||||||
|
"createdAt": "创建时间"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,10 @@
|
|||||||
"previous": "上一頁",
|
"previous": "上一頁",
|
||||||
"next": "下一頁",
|
"next": "下一頁",
|
||||||
"page": "頁",
|
"page": "頁",
|
||||||
"pageOf": "第"
|
"pageOf": "第",
|
||||||
|
"ascending": "升序",
|
||||||
|
"descending": "降序",
|
||||||
|
"day": "天"
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"title": "賬戶管理",
|
"title": "賬戶管理",
|
||||||
@@ -240,6 +243,7 @@
|
|||||||
"templates": "跟單模板",
|
"templates": "跟單模板",
|
||||||
"copyTradingConfig": "跟單配置",
|
"copyTradingConfig": "跟單配置",
|
||||||
"positions": "倉位管理",
|
"positions": "倉位管理",
|
||||||
|
"backtest": "回測",
|
||||||
"statistics": "統計信息",
|
"statistics": "統計信息",
|
||||||
"announcements": "公告",
|
"announcements": "公告",
|
||||||
"users": "用戶管理",
|
"users": "用戶管理",
|
||||||
@@ -1226,5 +1230,131 @@
|
|||||||
"providerQuickNode": "QuickNode",
|
"providerQuickNode": "QuickNode",
|
||||||
"providerChainstack": "Chainstack",
|
"providerChainstack": "Chainstack",
|
||||||
"providerGetBlock": "GetBlock"
|
"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": "刪除失敗",
|
||||||
|
"stop": "停止",
|
||||||
|
"stopSuccess": "停止成功",
|
||||||
|
"stopFailed": "停止失敗",
|
||||||
|
"retry": "重試",
|
||||||
|
"retrySuccess": "重試成功",
|
||||||
|
"retryFailed": "重試失敗",
|
||||||
|
"retryConfirm": "確定重新運行此回測任務嗎?將從斷點繼續執行,保留已處理的交易記錄。",
|
||||||
|
"deleteConfirm": "確定刪除此回測任務嗎?",
|
||||||
|
"stopConfirm": "確定停止此回測任務嗎?",
|
||||||
|
"createCopyTrading": "創建跟單",
|
||||||
|
"createCopyTradingSuccess": "跟單配置創建成功",
|
||||||
|
"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": "重試",
|
||||||
|
"taskNameRequired": "請輸入任務名稱",
|
||||||
|
"leaderRequired": "請選擇 Leader",
|
||||||
|
"initialBalanceRequired": "請輸入初始資金",
|
||||||
|
"initialBalanceInvalid": "初始資金必須大於 0",
|
||||||
|
"backtestDaysRequired": "請輸入回測天數",
|
||||||
|
"backtestDaysInvalid": "回測天數必須在 1-15 之間",
|
||||||
|
"copyRatioRequired": "請輸入跟單比例",
|
||||||
|
"copyRatioInvalid": "跟單比例必須在 0.01-10000 之間",
|
||||||
|
"copyRatioPlaceholder": "例如:100 表示 100%(1:1 跟單),預設 100%",
|
||||||
|
"copyRatioTooltip": "跟單比例表示跟單金額相對於 Leader 訂單金額的百分比。例如:100% 表示 1:1 跟單,50% 表示半倉跟單,200% 表示雙倍跟單",
|
||||||
|
"fixedAmountRequired": "請輸入固定金額",
|
||||||
|
"fixedAmountInvalid": "固定金額必須大於 0",
|
||||||
|
"priceFilters": "價格過濾",
|
||||||
|
"keywordsPlaceholder": "請輸入關鍵字,按回車添加",
|
||||||
|
"delaySecondsHint": "延遲執行模擬真實跟單延遲",
|
||||||
|
"supportSellHint": "是否跟隨 Leader 賣出",
|
||||||
|
"sortBy": "排序欄位",
|
||||||
|
"sortOrder": "排序順序",
|
||||||
|
"createdAt": "創建時間"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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<BacktestChartProps> = ({ trades }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const chartRef = useRef<HTMLDivElement>(null)
|
||||||
|
const chartInstance = useRef<echarts.ECharts | null>(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 `
|
||||||
|
<div>
|
||||||
|
<div>${t('backtest.tradeTime')}: ${param.name}</div>
|
||||||
|
<div>${t('backtest.balanceAfter')}: ${value} USDC</div>
|
||||||
|
<div style="color: ${color}">
|
||||||
|
${t('backtest.profitLoss')}: ${diff} USDC (${diffPercent}%)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={chartRef}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: 400
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BacktestChart
|
||||||
|
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { Card, Descriptions, Button, Tag, Space, Table, message, Row, Col, Statistic, Spin } from 'antd'
|
||||||
|
import { ArrowLeftOutlined, ReloadOutlined, DeleteOutlined, StopOutlined, CopyOutlined } from '@ant-design/icons'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { formatUSDC } from '../utils'
|
||||||
|
import { backtestService } from '../services/api'
|
||||||
|
import type { BacktestTaskDto, BacktestConfigDto, BacktestStatisticsDto, BacktestTradeDto } from '../types/backtest'
|
||||||
|
import { useMediaQuery } from 'react-responsive'
|
||||||
|
import BacktestChart from './BacktestChart'
|
||||||
|
import AddCopyTradingModal from './CopyTradingOrders/AddModal'
|
||||||
|
|
||||||
|
const BacktestDetail: React.FC = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [task, setTask] = useState<BacktestTaskDto | null>(null)
|
||||||
|
const [config, setConfig] = useState<BacktestConfigDto | null>(null)
|
||||||
|
const [statistics, setStatistics] = useState<BacktestStatisticsDto | null>(null)
|
||||||
|
const [trades, setTrades] = useState<BacktestTradeDto[]>([])
|
||||||
|
const [allTrades, setAllTrades] = useState<BacktestTradeDto[]>([]) // 用于图表显示的所有交易数据
|
||||||
|
const [tradesLoading, setTradesLoading] = useState(false)
|
||||||
|
const [tradesTotal, setTradesTotal] = useState(0)
|
||||||
|
const [tradesPage, setTradesPage] = useState(1)
|
||||||
|
const [tradesSize] = useState(20)
|
||||||
|
const [polling, setPolling] = useState<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
|
// 创建跟单配置 Modal
|
||||||
|
const [addCopyTradingModalVisible, setAddCopyTradingModalVisible] = useState(false)
|
||||||
|
const [preFilledConfig, setPreFilledConfig] = useState<any>(null)
|
||||||
|
|
||||||
|
// 获取回测任务详情
|
||||||
|
const fetchTaskDetail = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await backtestService.detail({ id: parseInt(id!) })
|
||||||
|
if (response.data.code === 0 && response.data.data) {
|
||||||
|
setTask(response.data.data.task)
|
||||||
|
setConfig(response.data.data.config)
|
||||||
|
setStatistics(response.data.data.statistics)
|
||||||
|
} else {
|
||||||
|
message.error(response.data.msg || t('backtest.fetchTaskDetailFailed'))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch backtest task detail:', error)
|
||||||
|
message.error(t('backtest.fetchTaskDetailFailed'))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取交易记录
|
||||||
|
const fetchTrades = async (page: number) => {
|
||||||
|
setTradesLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await backtestService.trades({
|
||||||
|
taskId: parseInt(id!),
|
||||||
|
page,
|
||||||
|
size: tradesSize
|
||||||
|
})
|
||||||
|
if (response.data.code === 0 && response.data.data) {
|
||||||
|
setTrades(response.data.data.list)
|
||||||
|
setTradesTotal(response.data.data.total)
|
||||||
|
} else {
|
||||||
|
message.error(response.data.msg || t('backtest.fetchTradesFailed'))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch backtest trades:', error)
|
||||||
|
message.error(t('backtest.fetchTradesFailed'))
|
||||||
|
} finally {
|
||||||
|
setTradesLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有交易记录(用于图表显示)
|
||||||
|
const fetchAllTrades = async () => {
|
||||||
|
try {
|
||||||
|
const response = await backtestService.trades({
|
||||||
|
taskId: parseInt(id!),
|
||||||
|
page: 1,
|
||||||
|
size: 10000 // 获取所有数据
|
||||||
|
})
|
||||||
|
if (response.data.code === 0 && response.data.data) {
|
||||||
|
setAllTrades(response.data.data.list)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch all trades for chart:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始加载任务详情和交易记录
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTaskDetail()
|
||||||
|
fetchTrades(tradesPage)
|
||||||
|
fetchAllTrades() // 加载所有交易数据用于图表
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
// 根据任务状态控制轮询
|
||||||
|
useEffect(() => {
|
||||||
|
// 停止之前的轮询
|
||||||
|
stopPolling()
|
||||||
|
|
||||||
|
// 只有任务正在运行或待处理时才启动轮询
|
||||||
|
if (task?.status === 'RUNNING' || task?.status === 'PENDING') {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
fetchTaskDetail()
|
||||||
|
}, 3000) // 每3秒轮询一次
|
||||||
|
setPolling(timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件卸载或状态变化时清理定时器
|
||||||
|
return () => {
|
||||||
|
stopPolling()
|
||||||
|
}
|
||||||
|
}, [task?.status])
|
||||||
|
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (polling) {
|
||||||
|
clearInterval(polling)
|
||||||
|
setPolling(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回
|
||||||
|
const handleBack = () => {
|
||||||
|
navigate('/backtest')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止任务
|
||||||
|
const handleStop = () => {
|
||||||
|
if (!window.confirm(t('backtest.stopConfirm'))) return
|
||||||
|
|
||||||
|
const stop = async () => {
|
||||||
|
try {
|
||||||
|
const response = await backtestService.stop({ id: parseInt(id!) })
|
||||||
|
if (response.data.code === 0) {
|
||||||
|
message.success(t('backtest.stopSuccess'))
|
||||||
|
fetchTaskDetail()
|
||||||
|
stopPolling()
|
||||||
|
} else {
|
||||||
|
message.error(response.data.msg || t('backtest.stopFailed'))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to stop backtest task:', error)
|
||||||
|
message.error(t('backtest.stopFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除任务
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (!window.confirm(t('backtest.deleteConfirm'))) return
|
||||||
|
|
||||||
|
const del = async () => {
|
||||||
|
try {
|
||||||
|
const response = await backtestService.delete({ id: parseInt(id!) })
|
||||||
|
if (response.data.code === 0) {
|
||||||
|
message.success(t('backtest.deleteSuccess'))
|
||||||
|
stopPolling() // 停止轮询
|
||||||
|
navigate('/backtest')
|
||||||
|
} else {
|
||||||
|
message.error(response.data.msg || t('backtest.deleteFailed'))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete backtest task:', error)
|
||||||
|
message.error(t('backtest.deleteFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
del()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新
|
||||||
|
const handleRefresh = () => {
|
||||||
|
fetchTaskDetail()
|
||||||
|
fetchTrades(tradesPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一键创建跟单配置
|
||||||
|
const handleCreateCopyTrading = () => {
|
||||||
|
console.log('[BacktestDetail] handleCreateCopyTrading called, task:', task, 'config:', config)
|
||||||
|
if (!task || !config) {
|
||||||
|
console.log('[BacktestDetail] No task or config available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预填充回测任务的配置参数(从 config 中获取)
|
||||||
|
const preFilled = {
|
||||||
|
leaderId: task.leaderId,
|
||||||
|
copyMode: config.copyMode,
|
||||||
|
copyRatio: config.copyMode === 'RATIO' ? parseFloat(config.copyRatio) * 100 : undefined,
|
||||||
|
fixedAmount: config.copyMode === 'FIXED' ? config.fixedAmount : undefined,
|
||||||
|
maxOrderSize: parseFloat(config.maxOrderSize),
|
||||||
|
minOrderSize: parseFloat(config.minOrderSize),
|
||||||
|
maxDailyLoss: parseFloat(config.maxDailyLoss),
|
||||||
|
maxDailyOrders: config.maxDailyOrders,
|
||||||
|
supportSell: config.supportSell,
|
||||||
|
keywordFilterMode: config.keywordFilterMode || 'DISABLED',
|
||||||
|
keywords: config.keywords || [],
|
||||||
|
configName: `回测任务-${task.taskName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[BacktestDetail] Generated preFilled config:', preFilled)
|
||||||
|
console.log('[BacktestDetail] Setting preFilledConfig and opening modal')
|
||||||
|
setPreFilledConfig(preFilled)
|
||||||
|
setAddCopyTradingModalVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态标签颜色
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'PENDING': return 'blue'
|
||||||
|
case 'RUNNING': return 'processing'
|
||||||
|
case 'COMPLETED': return 'success'
|
||||||
|
case 'STOPPED': return 'warning'
|
||||||
|
case 'FAILED': return 'error'
|
||||||
|
default: return 'default'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态标签文本
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'PENDING': return t('backtest.statusPending')
|
||||||
|
case 'RUNNING': return t('backtest.statusRunning')
|
||||||
|
case 'COMPLETED': return t('backtest.statusCompleted')
|
||||||
|
case 'STOPPED': return t('backtest.statusStopped')
|
||||||
|
case 'FAILED': return t('backtest.statusFailed')
|
||||||
|
default: return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: t('backtest.tradeTime'),
|
||||||
|
dataIndex: 'tradeTime',
|
||||||
|
key: 'tradeTime',
|
||||||
|
width: 180,
|
||||||
|
render: (timestamp: number) => new Date(timestamp).toLocaleString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('backtest.marketTitle'),
|
||||||
|
dataIndex: 'marketTitle',
|
||||||
|
key: 'marketTitle',
|
||||||
|
width: 250,
|
||||||
|
ellipsis: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('backtest.side'),
|
||||||
|
dataIndex: 'side',
|
||||||
|
key: 'side',
|
||||||
|
width: 100,
|
||||||
|
render: (side: string) => (
|
||||||
|
<Tag color={side === 'BUY' ? 'green' : side === 'SELL' ? 'orange' : 'blue'}>
|
||||||
|
{side === 'BUY' ? t('backtest.sideBuy') : side === 'SELL' ? t('backtest.sideSell') : t('backtest.sideSettlement')}
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('backtest.outcome'),
|
||||||
|
dataIndex: 'outcome',
|
||||||
|
key: 'outcome',
|
||||||
|
width: 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('backtest.quantity'),
|
||||||
|
dataIndex: 'quantity',
|
||||||
|
key: 'quantity',
|
||||||
|
width: 100,
|
||||||
|
render: (value: string) => parseFloat(value).toFixed(4)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('backtest.price'),
|
||||||
|
dataIndex: 'price',
|
||||||
|
key: 'price',
|
||||||
|
width: 100,
|
||||||
|
render: (value: string) => parseFloat(value).toFixed(4)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('backtest.amount') + ' (USDC)',
|
||||||
|
dataIndex: 'amount',
|
||||||
|
key: 'amount',
|
||||||
|
width: 120,
|
||||||
|
render: (value: string) => formatUSDC(value)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('backtest.balanceAfter') + ' (USDC)',
|
||||||
|
dataIndex: 'balanceAfter',
|
||||||
|
key: 'balanceAfter',
|
||||||
|
width: 120,
|
||||||
|
render: (value: string) => formatUSDC(value)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('backtest.leaderTradeId'),
|
||||||
|
dataIndex: 'leaderTradeId',
|
||||||
|
key: 'leaderTradeId',
|
||||||
|
width: 150,
|
||||||
|
ellipsis: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return <div style={{ padding: 24, textAlign: 'center' }}><Spin /></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Card>
|
||||||
|
{/* 头部操作栏 */}
|
||||||
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||||
|
<Space style={{ width: '100%', justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||||
|
<Space>
|
||||||
|
<Button icon={<ArrowLeftOutlined />} onClick={handleBack} size={isMobile ? 'middle' : 'large'}>
|
||||||
|
{t('common.back')}
|
||||||
|
</Button>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={handleRefresh} loading={loading} size={isMobile ? 'middle' : 'large'}>
|
||||||
|
{t('common.refresh')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
{task.status === 'COMPLETED' && (
|
||||||
|
<Button type="primary" icon={<CopyOutlined />} onClick={handleCreateCopyTrading} size={isMobile ? 'middle' : 'large'}>
|
||||||
|
{t('backtest.createCopyTrading')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(task.status === 'RUNNING' || task.status === 'PENDING') && (
|
||||||
|
<Button danger icon={<StopOutlined />} onClick={handleStop} size={isMobile ? 'middle' : 'large'}>
|
||||||
|
{t('backtest.stop')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(task.status === 'COMPLETED' || task.status === 'STOPPED' || task.status === 'FAILED') && (
|
||||||
|
<Button danger icon={<DeleteOutlined />} onClick={handleDelete} size={isMobile ? 'middle' : 'large'}>
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{/* 任务基本信息 */}
|
||||||
|
<Card title={t('backtest.taskDetail')} size="small">
|
||||||
|
<Descriptions column={isMobile ? 1 : 2} bordered size="small">
|
||||||
|
<Descriptions.Item label={t('backtest.taskName')}>{task.taskName}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label={t('backtest.leader')}>
|
||||||
|
{task.leaderName || task.leaderAddress}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label={t('backtest.initialBalance')}>
|
||||||
|
{formatUSDC(task.initialBalance)} USDC
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label={t('backtest.finalBalance')}>
|
||||||
|
{task.finalBalance ? formatUSDC(task.finalBalance) + ' USDC' : '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label={t('backtest.profitAmount')}>
|
||||||
|
<span style={{ color: task.profitAmount && parseFloat(task.profitAmount) >= 0 ? '#52c41a' : '#ff4d4f' }}>
|
||||||
|
{task.profitAmount ? formatUSDC(task.profitAmount) + ' USDC' : '-'}
|
||||||
|
</span>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label={t('backtest.profitRate')}>
|
||||||
|
<span style={{ color: task.profitRate && parseFloat(task.profitRate) >= 0 ? '#52c41a' : '#ff4d4f' }}>
|
||||||
|
{task.profitRate ? task.profitRate + '%' : '-'}
|
||||||
|
</span>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label={t('backtest.backtestDays')}>
|
||||||
|
{task.backtestDays} {t('common.day')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label={t('backtest.status')}>
|
||||||
|
<Tag color={getStatusColor(task.status)}>{getStatusText(task.status)}</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label={t('backtest.progress')}>
|
||||||
|
{task.progress}%
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label={t('backtest.totalTrades')}>
|
||||||
|
{task.totalTrades}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label={t('backtest.startTime')}>
|
||||||
|
{new Date(task.startTime).toLocaleString()}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label={t('backtest.endTime')}>
|
||||||
|
{task.endTime ? new Date(task.endTime).toLocaleString() : '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label={t('backtest.createdAt')}>
|
||||||
|
{new Date(task.createdAt).toLocaleString()}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 统计信息 */}
|
||||||
|
{statistics && (
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} sm={12} md={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={t('backtest.buyTrades')}
|
||||||
|
value={statistics.buyTrades}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={t('backtest.sellTrades')}
|
||||||
|
value={statistics.sellTrades}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={t('backtest.winTrades')}
|
||||||
|
value={statistics.winTrades}
|
||||||
|
valueStyle={{ color: '#52c41a' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={t('backtest.lossTrades')}
|
||||||
|
value={statistics.lossTrades}
|
||||||
|
valueStyle={{ color: '#ff4d4f' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={t('backtest.winRate')}
|
||||||
|
value={statistics.winRate}
|
||||||
|
suffix="%"
|
||||||
|
valueStyle={{ color: parseFloat(statistics.winRate) >= 50 ? '#52c41a' : '#ff4d4f' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={t('backtest.maxProfit')}
|
||||||
|
value={formatUSDC(statistics.maxProfit)}
|
||||||
|
valueStyle={{ color: '#52c41a' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={t('backtest.maxLoss')}
|
||||||
|
value={formatUSDC(statistics.maxLoss)}
|
||||||
|
valueStyle={{ color: '#ff4d4f' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={t('backtest.maxDrawdown')}
|
||||||
|
value={formatUSDC(statistics.maxDrawdown)}
|
||||||
|
valueStyle={{ color: '#ff4d4f' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
{statistics.avgHoldingTime && (
|
||||||
|
<Col xs={24} sm={12} md={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={t('backtest.avgHoldingTime')}
|
||||||
|
value={(statistics.avgHoldingTime / 1000 / 60).toFixed(2)}
|
||||||
|
suffix=" min"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 资金变化图表 */}
|
||||||
|
{allTrades.length > 0 && (
|
||||||
|
<Card title={t('backtest.balanceChart')}>
|
||||||
|
<BacktestChart trades={allTrades} />
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 交易记录 */}
|
||||||
|
<Card title={t('backtest.tradeRecords')}>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={trades}
|
||||||
|
rowKey="id"
|
||||||
|
loading={tradesLoading}
|
||||||
|
pagination={{
|
||||||
|
current: tradesPage,
|
||||||
|
pageSize: tradesSize,
|
||||||
|
total: tradesTotal,
|
||||||
|
showSizeChanger: false,
|
||||||
|
showTotal: (total) => `${t('common.total')} ${total} ${t('common.items')}`,
|
||||||
|
onChange: (newPage) => {
|
||||||
|
setTradesPage(newPage)
|
||||||
|
fetchTrades(newPage)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
scroll={isMobile ? { x: 1200 } : { x: 1800 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 创建跟单配置 Modal */}
|
||||||
|
<AddCopyTradingModal
|
||||||
|
open={addCopyTradingModalVisible}
|
||||||
|
onClose={() => {
|
||||||
|
setAddCopyTradingModalVisible(false)
|
||||||
|
setPreFilledConfig(null)
|
||||||
|
}}
|
||||||
|
onSuccess={() => {
|
||||||
|
message.success(t('backtest.createCopyTradingSuccess'))
|
||||||
|
setAddCopyTradingModalVisible(false)
|
||||||
|
setPreFilledConfig(null)
|
||||||
|
}}
|
||||||
|
preFilledConfig={preFilledConfig}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BacktestDetail
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -16,12 +16,27 @@ interface AddModalProps {
|
|||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSuccess?: () => void
|
onSuccess?: () => void
|
||||||
|
preFilledConfig?: {
|
||||||
|
leaderId?: number
|
||||||
|
copyMode?: 'RATIO' | 'FIXED'
|
||||||
|
copyRatio?: number
|
||||||
|
fixedAmount?: string
|
||||||
|
maxOrderSize?: number
|
||||||
|
minOrderSize?: number
|
||||||
|
maxDailyLoss?: number
|
||||||
|
maxDailyOrders?: number
|
||||||
|
supportSell?: boolean
|
||||||
|
keywordFilterMode?: string
|
||||||
|
keywords?: string[]
|
||||||
|
configName?: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const AddModal: React.FC<AddModalProps> = ({
|
const AddModal: React.FC<AddModalProps> = ({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
onSuccess
|
onSuccess,
|
||||||
|
preFilledConfig
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||||
@@ -64,25 +79,112 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
return `跟单配置-${dateStr}-${timeStr}`
|
return `跟单配置-${dateStr}-${timeStr}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取 Leader 资产信息
|
||||||
|
const fetchLeaderAssetInfo = async (leaderId: number) => {
|
||||||
|
if (!leaderId) return
|
||||||
|
|
||||||
|
setLoadingAssetInfo(true)
|
||||||
|
setLeaderAssetInfo(null)
|
||||||
|
try {
|
||||||
|
const response = await apiService.leaders.balance({ leaderId })
|
||||||
|
if (response.data.code === 0 && response.data.data) {
|
||||||
|
const balance = response.data.data
|
||||||
|
setLeaderAssetInfo({
|
||||||
|
total: balance.totalBalance || '0',
|
||||||
|
available: balance.availableBalance || '0',
|
||||||
|
position: balance.positionBalance || '0'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
message.error(response.data.msg || t('copyTradingAdd.fetchAssetInfoFailed') || '获取资产信息失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('获取 Leader 资产失败:', error)
|
||||||
|
message.error(error.message || t('copyTradingAdd.fetchAssetInfoFailed') || '获取资产信息失败')
|
||||||
|
} finally {
|
||||||
|
setLoadingAssetInfo(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充预配置数据到表单(复用模板填充逻辑)
|
||||||
|
const fillPreFilledConfig = (config: typeof preFilledConfig) => {
|
||||||
|
console.log('[AddModal] fillPreFilledConfig called with config:', config)
|
||||||
|
if (!config) {
|
||||||
|
console.log('[AddModal] fillPreFilledConfig: config is null/undefined')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const formValues = {
|
||||||
|
configName: config.configName || generateDefaultConfigName(),
|
||||||
|
leaderId: config.leaderId,
|
||||||
|
copyMode: config.copyMode || 'RATIO',
|
||||||
|
copyRatio: config.copyRatio,
|
||||||
|
fixedAmount: config.fixedAmount,
|
||||||
|
maxOrderSize: config.maxOrderSize,
|
||||||
|
minOrderSize: config.minOrderSize,
|
||||||
|
maxDailyLoss: config.maxDailyLoss,
|
||||||
|
maxDailyOrders: config.maxDailyOrders,
|
||||||
|
supportSell: config.supportSell,
|
||||||
|
keywordFilterMode: config.keywordFilterMode || 'DISABLED'
|
||||||
|
}
|
||||||
|
console.log('[AddModal] fillPreFilledConfig: setting form values:', formValues)
|
||||||
|
|
||||||
|
form.setFieldsValue(formValues)
|
||||||
|
setCopyMode(config.copyMode || 'RATIO')
|
||||||
|
setKeywords(config.keywords || [])
|
||||||
|
|
||||||
|
console.log('[AddModal] fillPreFilledConfig: form values set, copyMode:', config.copyMode, 'keywords:', config.keywords)
|
||||||
|
|
||||||
|
// 自动获取 Leader 资产信息
|
||||||
|
if (config.leaderId) {
|
||||||
|
console.log('[AddModal] fillPreFilledConfig: fetching leader asset info for leaderId:', config.leaderId)
|
||||||
|
fetchLeaderAssetInfo(config.leaderId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 Modal 打开/关闭
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
console.log('[AddModal] useEffect triggered, open:', open, 'preFilledConfig:', preFilledConfig)
|
||||||
if (open) {
|
if (open) {
|
||||||
|
console.log('[AddModal] Modal opened, fetching accounts, leaders, templates')
|
||||||
fetchAccounts()
|
fetchAccounts()
|
||||||
fetchLeaders()
|
fetchLeaders()
|
||||||
fetchTemplates()
|
fetchTemplates()
|
||||||
|
|
||||||
// 生成默认配置名
|
// 如果有预填充配置,填充表单(延迟执行确保数据已加载)
|
||||||
|
if (preFilledConfig) {
|
||||||
|
console.log('[AddModal] preFilledConfig exists, will fill form after 100ms')
|
||||||
|
// 使用 setTimeout 确保在下一个事件循环执行,此时 Modal 已完全打开
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('[AddModal] setTimeout callback executed, calling fillPreFilledConfig')
|
||||||
|
fillPreFilledConfig(preFilledConfig)
|
||||||
|
}, 100)
|
||||||
|
} else {
|
||||||
|
console.log('[AddModal] No preFilledConfig, using default values')
|
||||||
|
// 没有预填充配置时,生成默认配置名
|
||||||
const defaultConfigName = generateDefaultConfigName()
|
const defaultConfigName = generateDefaultConfigName()
|
||||||
form.setFieldsValue({ configName: defaultConfigName })
|
form.setFieldsValue({
|
||||||
|
configName: defaultConfigName,
|
||||||
// 重置关键字列表
|
copyMode: 'RATIO',
|
||||||
|
copyRatio: 100,
|
||||||
|
maxOrderSize: 1000,
|
||||||
|
minOrderSize: 1,
|
||||||
|
maxDailyLoss: 10000,
|
||||||
|
maxDailyOrders: 100,
|
||||||
|
supportSell: true,
|
||||||
|
keywordFilterMode: 'DISABLED'
|
||||||
|
})
|
||||||
|
setCopyMode('RATIO')
|
||||||
setKeywords([])
|
setKeywords([])
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
console.log('[AddModal] Modal closed, resetting form')
|
||||||
// 关闭时重置表单
|
// 关闭时重置表单
|
||||||
form.resetFields()
|
form.resetFields()
|
||||||
setKeywords([])
|
setKeywords([])
|
||||||
setCopyMode('RATIO')
|
setCopyMode('RATIO')
|
||||||
|
setLeaderAssetInfo(null)
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [open, preFilledConfig])
|
||||||
|
|
||||||
const fetchLeaders = async () => {
|
const fetchLeaders = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -133,32 +235,6 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
setCopyMode(mode)
|
setCopyMode(mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取 Leader 资产信息
|
|
||||||
const fetchLeaderAssetInfo = async (leaderId: number) => {
|
|
||||||
if (!leaderId) return
|
|
||||||
|
|
||||||
setLoadingAssetInfo(true)
|
|
||||||
setLeaderAssetInfo(null)
|
|
||||||
try {
|
|
||||||
const response = await apiService.leaders.balance({ leaderId })
|
|
||||||
if (response.data.code === 0 && response.data.data) {
|
|
||||||
const balance = response.data.data
|
|
||||||
setLeaderAssetInfo({
|
|
||||||
total: balance.totalBalance || '0',
|
|
||||||
available: balance.availableBalance || '0',
|
|
||||||
position: balance.positionBalance || '0'
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
message.error(response.data.msg || t('copyTradingAdd.fetchAssetInfoFailed') || '获取资产信息失败')
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('获取 Leader 资产失败:', error)
|
|
||||||
message.error(error.message || t('copyTradingAdd.fetchAssetInfoFailed') || '获取资产信息失败')
|
|
||||||
} finally {
|
|
||||||
setLoadingAssetInfo(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理导入账户成功
|
// 处理导入账户成功
|
||||||
const handleAccountImportSuccess = async (accountId: number) => {
|
const handleAccountImportSuccess = async (accountId: number) => {
|
||||||
message.success(t('accountImport.importSuccess'))
|
message.success(t('accountImport.importSuccess'))
|
||||||
|
|||||||
@@ -726,3 +726,77 @@ export { apiClient }
|
|||||||
|
|
||||||
export default apiService
|
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('/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('/backtest/tasks/list', data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询回测任务详情
|
||||||
|
*/
|
||||||
|
detail: (data: { id: number }) => apiClient.post('/backtest/tasks/detail', data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询回测交易记录
|
||||||
|
*/
|
||||||
|
trades: (data: {
|
||||||
|
taskId: number
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
}) => apiClient.post('/backtest/tasks/trades', data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止回测任务
|
||||||
|
*/
|
||||||
|
stop: (data: { id: number }) => apiClient.post('/backtest/tasks/stop', data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除回测任务
|
||||||
|
*/
|
||||||
|
delete: (data: { id: number }) => apiClient.post('/backtest/tasks/delete', data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重试回测任务
|
||||||
|
*/
|
||||||
|
retry: (data: { id: number }) => apiClient.post('/backtest/tasks/retry', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* 回测相关类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测任务创建请求
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
pageForResume?: number // 用于恢复中断任务,从指定页码开始获取历史数据(从1开始)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测任务列表请求
|
||||||
|
*/
|
||||||
|
export interface BacktestListRequest {
|
||||||
|
leaderId?: number
|
||||||
|
status?: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'STOPPED' | 'FAILED'
|
||||||
|
sortBy?: 'profitAmount' | 'profitRate' | 'createdAt'
|
||||||
|
sortOrder?: 'asc' | 'desc'
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
pageForResume?: 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 BacktestRetryRequest {
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测任务列表响应
|
||||||
|
*/
|
||||||
|
export interface BacktestListResponse {
|
||||||
|
list: BacktestTaskDto[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
processedTradeCount?: number // 已处理的交易数量(用于显示真实进度)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测任务详情响应
|
||||||
|
*/
|
||||||
|
export interface BacktestDetailResponse {
|
||||||
|
task: BacktestTaskDto
|
||||||
|
config: BacktestConfigDto
|
||||||
|
statistics: BacktestStatisticsDto
|
||||||
|
lastProcessedTradeTime?: number // 最后处理的交易时间(用于中断恢复)
|
||||||
|
lastProcessedTradeIndex?: number // 最后处理的交易索引(用于中断恢复)
|
||||||
|
processedTradeCount?: number // 已处理的交易数量(用于显示真实进度)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测交易记录列表响应
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
"description": "Utility scripts for Polyhermes",
|
"description": "Utility scripts for Polyhermes",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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": {
|
"dependencies": {
|
||||||
"@ethersproject/wallet": "^5.7.0",
|
"@ethersproject/wallet": "^5.7.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user