Files
PolyHermes/docs/zh/backtest/BACKTEST_TECHNICAL_DESIGN.md
WrBug cdd02e9f3d feat: 实现回测功能
## 功能概述
实现完整的回测功能,支持基于历史数据模拟跟单策略的执行效果。

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

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

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

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

## 其他修改
- 移除 max_position_count 配置(V26迁移脚本)
- 移除 BacktestSyncService(不再需要实时同步)
- 修复前后端编译错误
2026-01-31 07:27:36 +08:00

1198 lines
38 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 跟单回测功能技术设计文档
## 一、技术架构概览
```mermaid
graph TB
subgraph "Frontend Layer"
A[回测管理页面] --> B[回测创建页面]
A --> C[回测详情页面]
end
subgraph "Backend API Layer"
D[BacktestController]
end
subgraph "Service Layer"
E[BacktestService] --> F[BacktestExecutionService]
E --> G[BacktestDataService]
F --> H[CopyTradingFilterService]
end
subgraph "Scheduled Tasks"
I[BacktestPollingService]
end
subgraph "Data Layer"
J[(Backtest Task Table)]
K[(Backtest Trade Table)]
L[(Leader Historical Data)]
end
A --> D
B --> D
C --> D
D --> E
I --> F
E --> J
F --> K
F --> L
G --> L
F --> H
```
## 二、数据库设计
### 2.1 回测任务表 (backtest_task)
```sql
CREATE TABLE 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 COMMENT '跟单模式: RATIO/FIXED',
copy_ratio DECIMAL(20, 8) DEFAULT 1.0 COMMENT '跟单比例',
fixed_amount DECIMAL(20, 8) DEFAULT NULL COMMENT '固定金额',
max_order_size DECIMAL(20, 8) NOT NULL COMMENT '最大单笔订单',
min_order_size DECIMAL(20, 8) NOT NULL COMMENT '最小单笔订单',
max_daily_loss DECIMAL(20, 8) NOT NULL COMMENT '最大每日亏损',
max_daily_orders INT NOT NULL COMMENT '最大每日订单数',
price_tolerance DECIMAL(5, 2) NOT NULL COMMENT '价格容忍度(%)',
delay_seconds INT DEFAULT 0 COMMENT '延迟秒数',
support_sell BOOLEAN 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) DEFAULT 'DISABLED' COMMENT '关键字过滤模式',
keywords JSON DEFAULT NULL COMMENT '关键字列表',
max_market_end_date BIGINT DEFAULT NULL COMMENT '市场截止时间限制',
-- 执行状态
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)
) COMMENT='回测任务表';
```
### 2.2 回测交易记录表 (backtest_trade)
```sql
CREATE TABLE 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',
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 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
) COMMENT='回测交易记录表';
```
### 2.3 回测历史交易表 (backtest_historical_trades)
**说明**: 用于存储 Leader 的历史交易数据,供回测使用。独立于 `ProcessedTrade` 表,避免影响现有跟单功能。
```sql
CREATE TABLE backtest_historical_trades (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID',
leader_id BIGINT NOT NULL COMMENT 'Leader ID',
trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 交易ID(唯一标识)',
market_id VARCHAR(100) NOT NULL COMMENT '市场ID',
market_title VARCHAR(500) DEFAULT NULL COMMENT '市场标题',
market_slug VARCHAR(200) DEFAULT NULL COMMENT '市场 slug(用于生成链接)',
side VARCHAR(10) NOT NULL COMMENT '交易方向: BUY/SELL',
outcome VARCHAR(50) DEFAULT NULL COMMENT '市场方向(如 YES, NO 等)',
outcome_index INT DEFAULT NULL COMMENT '结果索引(0, 1, 2, ...),支持多元市场',
price DECIMAL(20, 8) NOT NULL COMMENT '交易价格',
size DECIMAL(20, 8) NOT NULL COMMENT '交易数量',
amount DECIMAL(20, 8) NOT NULL COMMENT '交易金额(price × size',
trade_timestamp BIGINT NOT NULL COMMENT '交易时间戳(毫秒)',
-- 元数据
source VARCHAR(20) NOT NULL DEFAULT 'POLLING' COMMENT '数据来源: WEBSOCKET/POLLING/API',
fetched_at BIGINT NOT NULL COMMENT '数据获取时间(毫秒)',
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒)',
UNIQUE INDEX uk_leader_trade (leader_id, trade_id),
INDEX idx_leader_id (leader_id),
INDEX idx_trade_timestamp (trade_timestamp),
INDEX idx_market_id (market_id)
) COMMENT='回测历史交易表';
```
**字段说明**:
- `trade_id`: Leader 的交易唯一标识符,用于去重
- `market_id`, `market_title`, `market_slug`: 市场信息,用于回测时显示和链接
- `side`, `outcome`, `outcome_index`: 交易方向和结果,支持二元和多元市场
- `price`, `size`, `amount`: 交易的价格、数量和金额
- `trade_timestamp`: 交易发生的历史时间,用于按时间回放
- `source`: 数据来源,区分 WebSocket 实时推送、轮询或 API 查询
- `fetched_at`: 系统获取该交易数据的时间
**数据获取策略**:
1. **优先从现有 ProcessedTrade 扩展**: 在跟单系统处理交易时,同时写入此表
2. **补充历史数据**: 调用 Polymarket API 获取更早的历史交易
3. **去重机制**: 使用 `leader_id + trade_id` 唯一索引避免重复
### 2.4 索引优化建议
- `backtest_task`:
- 主查询索引: `idx_leader_id`, `idx_status`
- 排序索引: `idx_created_at`
- `backtest_trade`:
- 关联查询索引: `idx_backtest_task_id`
- 时间序列索引: `idx_trade_time`
- `backtest_historical_trades`:
- 去重索引: `uk_leader_trade`
- 主查询索引: `idx_leader_id`
- 时间序列索引: `idx_trade_timestamp`
## 三、API设计
### 3.1 RESTful API规范
#### 3.1.1 创建回测任务
```
POST /api/backtest/tasks
```
**Request Body**:
```json
{
"taskName": "激进策略-Leader A",
"leaderId": 123,
"initialBalance": "1000.00",
"backtestDays": 30,
"copyMode": "RATIO",
"copyRatio": "1.0",
"fixedAmount": null,
"maxOrderSize": "1000.00",
"minOrderSize": "1.00",
"maxDailyLoss": "10000.00",
"maxDailyOrders": 100,
"priceTolerance": "5.00",
"delaySeconds": 0,
"supportSell": true,
"minOrderDepth": null,
"maxSpread": null,
"minPrice": null,
"maxPrice": null,
"maxPositionValue": null,
"keywordFilterMode": "DISABLED",
"keywords": [],
"maxMarketEndDate": null
}
```
**Response**:
```json
{
"success": true,
"data": {
"id": 12345,
"taskName": "激进策略-Leader A",
"status": "PENDING",
"createdAt": 1738238400000
},
"message": "回测任务创建成功"
}
```
#### 3.1.2 查询回测任务列表
```
POST /api/backtest/tasks/list
```
**Request Body**:
```json
{
"leaderId": null,
"status": null,
"sortBy": "createdAt",
"sortOrder": "desc",
"page": 1,
"size": 20
}
```
**Request Parameters**:
- `leaderId` (可选): Leader ID
- `status` (可选): PENDING/RUNNING/COMPLETED/STOPPED/FAILED
- `sortBy` (可选): profitAmount / profitRate / createdAt (默认: createdAt)
- `sortOrder` (可选): asc / desc (默认: desc)
- `page` (可选): 页码,从1开始 (默认: 1)
- `size` (可选): 每页数量 (默认: 20)
**Response**:
```json
{
"success": true,
"data": {
"list": [
{
"id": 12345,
"taskName": "激进策略-Leader A",
"leaderId": 123,
"leaderName": "Smart Trader",
"leaderAddress": "0x123...",
"initialBalance": "1000.00",
"finalBalance": "1250.00",
"profitAmount": "250.00",
"profitRate": "25.00",
"backtestDays": 30,
"totalTrades": 45,
"status": "COMPLETED",
"startTime": 1735646400000,
"endTime": 1738238400000,
"createdAt": 1738238400000
}
],
"total": 100,
"page": 1,
"size": 20
}
}
```
#### 3.1.3 查询回测任务详情
```
POST /api/backtest/tasks/detail
```
**Request Body**:
```json
{
"id": 12345
}
```
**Response**:
```json
{
"success": true,
"data": {
"id": 12345,
"taskName": "激进策略-Leader A",
"leaderId": 123,
"leaderName": "Smart Trader",
"initialBalance": "1000.00",
"finalBalance": "1250.00",
"profitAmount": "250.00",
"profitRate": "25.00",
"backtestDays": 30,
"startTime": 1735646400000,
"endTime": 1738238400000,
"config": {
"copyMode": "RATIO",
"copyRatio": "1.0",
// ... 其他配置
},
"statistics": {
"totalTrades": 45,
"buyTrades": 23,
"sellTrades": 22,
"winTrades": 30,
"lossTrades": 15,
"winRate": "66.67",
"maxProfit": "50.00",
"maxLoss": "-20.00",
"maxDrawdown": "100.00"
},
"status": "COMPLETED",
"progress": 100,
"createdAt": 1738238400000
}
}
```
#### 3.1.4 查询回测交易记录
```
POST /api/backtest/tasks/trades
```
**Request Body**:
```json
{
"taskId": 12345,
"page": 1,
"size": 20
}
```
**Response**:
```json
{
"success": true,
"data": {
"list": [
{
"id": 1,
"tradeTime": 1735646400000,
"marketTitle": "BTC > $100k",
"side": "BUY",
"outcome": "YES",
"quantity": "100.00",
"price": "0.65",
"amount": "65.00",
"fee": "0.13",
"profitLoss": null,
"balanceAfter": "934.87"
}
],
"total": 45,
"page": 1,
"size": 20
}
}
```
#### 3.1.5 删除回测任务
```
POST /api/backtest/tasks/delete
```
**Request Body**:
```json
{
"id": 12345
}
```
**Response**:
```json
{
"success": true,
"message": "回测任务删除成功"
}
```
#### 3.1.6 停止运行中的回测
```
POST /api/backtest/tasks/stop
```
**Request Body**:
```json
{
"id": 12345
}
```
**Response**:
```json
{
"success": true,
"message": "回测任务已停止"
}
```
## 四、后端服务设计
### 4.1 Service层架构
#### 4.1.1 BacktestService
**职责**: 回测任务的CRUD操作
**核心方法**:
```kotlin
interface BacktestService {
// 创建回测任务
fun createBacktestTask(request: BacktestCreateRequest): Result<BacktestTaskDto>
// 查询回测任务列表
fun getBacktestTaskList(request: BacktestListRequest): Result<BacktestListResponse>
// 查询回测任务详情
fun getBacktestTaskDetail(taskId: Long): Result<BacktestTaskDetailDto>
// 删除回测任务
fun deleteBacktestTask(taskId: Long): Result<Unit>
// 停止回测任务
fun stopBacktestTask(taskId: Long): Result<Unit>
// 查询回测交易记录
fun getBacktestTrades(taskId: Long, page: Int, size: Int): Result<BacktestTradeListResponse>
}
```
**实现要点**:
- 复用 `CopyTradingFilterService` 的参数验证逻辑
- 使用 `@Transactional` 保证数据一致性
- 返回值使用 `Result<T>` 统一错误处理
#### 4.1.2 BacktestExecutionService
**职责**: 执行回测任务
**核心方法**:
```kotlin
interface BacktestExecutionService {
// 执行回测任务
suspend fun executeBacktest(task: BacktestTask)
// 获取Leader历史交易
suspend fun getLeaderHistoricalTrades(
leaderId: Long,
startTime: Long,
endTime: Long
): List<HistoricalTrade>
// 模拟交易执行
suspend fun simulateTrade(
task: BacktestTask,
trade: HistoricalTrade,
currentBalance: BigDecimal,
positions: MutableMap<String, Position>
): TradeResult
// 计算收益统计
fun calculateStatistics(trades: List<BacktestTrade>): BacktestStatistics
}
```
**执行流程**:
```mermaid
sequenceDiagram
participant P as BacktestPollingService
participant E as BacktestExecutionService
participant D as BacktestDataService
participant F as CopyTradingFilterService
participant DB as Database
P->>DB: 查询PENDING状态任务
DB-->>P: 返回待执行任务
P->>E: executeBacktest(task)
E->>DB: 更新状态为RUNNING
E->>D: getLeaderHistoricalTrades()
D-->>E: 返回历史交易列表
loop 遍历每笔交易
E->>E: 检查余额是否充足
alt 余额 >= $1
E->>F: 应用过滤规则
F-->>E: 返回是否通过
alt 通过过滤
E->>E: 计算跟单金额
E->>E: 模拟成交,扣除手续费
E->>DB: 保存交易记录
E->>E: 更新余额和持仓
end
else 余额 < $1
E->>E: 停止回测
end
end
E->>E: calculateStatistics()
E->>DB: 更新任务状态和统计数据
E-->>P: 执行完成
```
#### 4.1.3 BacktestDataService
**职责**: 获取Leader历史数据
**数据源**:
1. **优先使用**: `BacktestHistoricalTrade` 表 (系统已记录的完整交易数据)
2. **补充数据**: Polymarket API (获取更早的历史数据)
**数据获取策略**:
```kotlin
suspend fun getLeaderHistoricalTrades(
leaderId: Long,
startTime: Long,
endTime: Long
): List<HistoricalTrade> {
// 1. 优先从 backtest_historical_trades 表查询
val existingTrades = backtestHistoricalTradeRepository
.findByLeaderIdAndTradeTimestampBetween(leaderId, startTime, endTime)
if (existingTrades.isNotEmpty()) {
return existingTrades.map { it.toHistoricalTrade() }
}
// 2. 如果表中没有数据,调用 Polymarket API 获取
val leader = leaderRepository.findById(leaderId)
?: throw IllegalArgumentException("Leader not found")
val apiTrades = polymarketDataService.getTradeHistory(
makerAddress = leader.address,
startTime = startTime,
endTime = endTime
)
// 3. 将 API 数据保存到 backtest_historical_trades 表
val entities = apiTrades.map { trade ->
BacktestHistoricalTrade(
leaderId = leaderId,
tradeId = trade.id,
marketId = trade.marketId,
marketTitle = trade.marketTitle,
marketSlug = trade.marketSlug,
side = trade.side.uppercase(),
outcome = trade.outcome,
outcomeIndex = trade.outcomeIndex,
price = trade.price.toSafeBigDecimal(),
size = trade.size.toSafeBigDecimal(),
amount = trade.amount.toSafeBigDecimal(),
tradeTimestamp = trade.timestamp,
source = "API",
fetchedAt = System.currentTimeMillis(),
createdAt = System.currentTimeMillis()
)
}
// 批量保存(去重由唯一索引处理)
backtestHistoricalTradeRepository.saveAll(entities)
return apiTrades
}
```
**实时数据同步**:
在跟单系统处理交易时,同时写入 `BacktestHistoricalTrade` 表:
```kotlin
// 在 CopyOrderTrackingService.processTrade() 中
@Async
fun syncToBacktestHistorical(trade: Trade, leaderId: Long) {
try {
val historicalTrade = BacktestHistoricalTrade(
leaderId = leaderId,
tradeId = trade.id,
marketId = trade.marketId,
marketTitle = trade.marketTitle, // 从 API 获取
marketSlug = trade.marketSlug, // 从 API 获取
side = trade.side.uppercase(),
outcome = trade.outcome,
outcomeIndex = trade.outcomeIndex,
price = trade.price.toSafeBigDecimal(),
size = trade.size.toSafeBigDecimal(),
amount = trade.amount.toSafeBigDecimal(),
tradeTimestamp = trade.timestamp,
source = "WEBSOCKET",
fetchedAt = System.currentTimeMillis(),
createdAt = System.currentTimeMillis()
)
backtestHistoricalTradeRepository.save(historicalTrade)
} catch (e: Exception) {
logger.warn("同步回测历史数据失败: ${e.message}")
}
}
```
**缓存策略**:
- 使用 Redis 缓存历史交易数据,TTL = 1小时
- Key格式: `backtest:leader:{leaderId}:trades:{startTime}:{endTime}`
#### 4.1.4 BacktestPollingService
**职责**: 轮询待执行的回测任务
**实现方式**:
```kotlin
@Service
class BacktestPollingService(
private val backtestTaskRepository: BacktestTaskRepository,
private val executionService: BacktestExecutionService
) {
private val logger = LoggerFactory.getLogger(BacktestPollingService::class.java)
private val executor = Executors.newFixedThreadPool(5) // 最多5个并发任务
@Scheduled(fixedDelay = 10000) // 每10秒轮询一次
fun pollPendingTasks() {
val pendingTasks = backtestTaskRepository.findByStatus("PENDING")
pendingTasks.forEach { task ->
executor.submit {
try {
runBlocking {
executionService.executeBacktest(task)
}
} catch (e: Exception) {
logger.error("回测任务执行失败: ${task.id}", e)
backtestTaskRepository.updateStatus(task.id!!, "FAILED", e.message)
}
}
}
}
}
```
**并发控制**:
- 线程池大小: 5
- 超出并发数的任务保持 `PENDING` 状态,下次轮询继续执行
### 4.2 核心算法
#### 4.2.1 回测算法伪代码
```kotlin
// 持仓数据结构
data class Position(
val marketId: String,
val outcome: String,
val outcomeIndex: Int? = null, // 支持 outcomeIndex
var quantity: BigDecimal,
val avgPrice: BigDecimal,
val leaderBuyQuantity: BigDecimal? // Leader 买入数量(用于比例模式)
)
fun executeBacktest(task: BacktestTask) {
// 1. 初始化
var currentBalance = task.initialBalance
val positions = mutableMapOf<String, Position>() // marketId + outcomeIndex -> Position
val trades = mutableListOf<BacktestTrade>()
val marketInfoCache = mutableMapOf<String, MarketInfo>() // 缓存市场信息
// 2. 计算回测时间范围
val endTime = System.currentTimeMillis()
val startTime = endTime - (task.backtestDays * 24 * 3600 * 1000)
// 3. 获取Leader历史交易
val leaderTrades = getLeaderHistoricalTrades(task.leaderId, startTime, endTime)
// 4. 按时间顺序回放交易
for (leaderTrade in leaderTrades.sortedBy { it.timestamp }) {
// ✨ 4.1 实时检查并结算已到期的市场
val expiredPositions = positions.filter { (positionKey, position) ->
val marketInfo = marketInfoCache.getOrPut(position.marketId) {
try {
marketService.getMarketInfo(position.marketId)
} catch (e: Exception) {
logger.warn("无法获取市场${position.marketId}信息", e)
null
}
}
// 检查市场是否已到结束时间
marketInfo?.endDate != null && marketInfo.endDate <= leaderTrade.timestamp
}
// 结算已到期的市场
for ((positionKey, position) in expiredPositions) {
val marketInfo = marketInfoCache[position.marketId]!!
val settlementPrice = when {
marketInfo.winner == position.outcome -> BigDecimal.ONE // 胜出方
marketInfo.winner != null -> BigDecimal.ZERO // 失败方
else -> position.avgPrice // 未结算,按成本价保守估计
}
val settlementValue = position.quantity * settlementPrice
val profitLoss = settlementValue - (position.quantity * position.avgPrice)
currentBalance += settlementValue
// 记录结算交易
trades.add(BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = marketInfo.endDate,
marketId = position.marketId,
side = "SETTLEMENT",
outcome = position.outcome,
quantity = position.quantity,
price = settlementPrice,
amount = settlementValue,
fee = BigDecimal.ZERO,
profitLoss = profitLoss,
balanceAfter = currentBalance
))
// 移除已结算的持仓
positions.remove(positionKey)
logger.info("市场到期结算: ${position.marketId}, 时间: ${marketInfo.endDate}, 结算价: $settlementPrice, 盈亏: $profitLoss")
}
// 4.2 检查余额和持仓状态
// ✨ 修正: 只有当"余额不足 且 无持仓"时才停止回测
// 如果有持仓,继续处理(可能有卖出或市场结算释放资金)
if (currentBalance < BigDecimal.ONE && positions.isEmpty()) {
logger.info("余额不足且无持仓,停止回测: $currentBalance")
break
}
// 如果余额不足但有持仓,记录日志但继续处理
if (currentBalance < BigDecimal.ONE && positions.isNotEmpty()) {
logger.info("余额不足 $currentBalance,但还有 ${positions.size} 个持仓,继续处理后续交易(等待卖出或结算)")
}
// 4.3 每日订单数检查
// 统计当前交易时间当天已有的订单数
val dailyOrderCount = trades.count { isSameDay(it.tradeTime, leaderTrade.timestamp) }
if (dailyOrderCount >= task.maxDailyOrders) {
logger.info("已达到每日最大订单数限制: $dailyOrderCount / ${task.maxDailyOrders}")
continue
}
// 4.4 价格容忍度检查
if (task.priceTolerance > BigDecimal.ZERO) {
val tolerance = task.priceTolerance.toSafeBigDecimal().divide(BigDecimal("100"))
val minPrice = leaderTrade.price.multiply(BigDecimal.ONE.subtract(tolerance))
val maxPrice = leaderTrade.price.multiply(BigDecimal.ONE.add(tolerance))
// 获取当前市场价格(从市场服务或缓存)
val currentPrice = marketPriceService.getCurrentMarketPrice(
leaderTrade.marketId,
leaderTrade.outcomeIndex ?: 0
)
if (currentPrice < minPrice || currentPrice > maxPrice) {
logger.info("价格超出容忍度范围: 当前=$currentPrice, 可用范围=[$minPrice, $maxPrice]")
continue
}
}
// 4.5 应用其他过滤规则
// 复用 CopyTradingFilterService 的方法
if (!copyTradingFilterService.passAllFilters(
task = task,
trade = leaderTrade,
currentPositionValue = positions.values.sumOf { it.quantity * it.avgPrice }
)) {
continue
}
// 4.6 计算跟单金额
val followAmount = calculateFollowAmount(task, leaderTrade)
if (leaderTrade.side == "BUY") {
// 买入逻辑
val quantity = followAmount / leaderTrade.price
val totalCost = followAmount // 不计算手续费
// ✨ 严格模式: 仅检查当前可用余额,不考虑持仓市值
// 理由: 持仓未卖出前资金不可用,这更符合真实场景
// 注意: 市场到期结算会在步骤4.1中提前释放资金
if (totalCost > currentBalance) {
logger.info("余额不足以执行买入订单: 需要 ${totalCost.toPlainString()}, 可用 ${currentBalance.toPlainString()}")
// 记录持仓价值用于分析(但不计入可用余额)
if (logger.isDebugEnabled) {
val positionValue = positions.values.sumOf { it.quantity * it.avgPrice }
logger.debug("当前持仓市值: ${positionValue.toPlainString()}, 但资金被占用")
}
continue
}
// 更新余额和持仓
currentBalance -= totalCost
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
positions[positionKey] = Position(
marketId = leaderTrade.marketId,
outcome = leaderTrade.outcome,
outcomeIndex = leaderTrade.outcomeIndex,
quantity = quantity,
avgPrice = leaderTrade.price,
leaderBuyQuantity = leaderTrade.quantity
)
// 记录交易
trades.add(BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = leaderTrade.timestamp,
marketId = leaderTrade.marketId,
side = "BUY",
outcome = leaderTrade.outcome,
quantity = quantity,
price = leaderTrade.price,
amount = followAmount,
fee = BigDecimal.ZERO, // 不计算手续费
balanceAfter = currentBalance
))
} else { // SELL
if (!task.supportSell) continue
// 使用 outcomeIndex 构建持仓键(支持多元市场)
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
val position = positions[positionKey] ?: continue
// 计算卖出数量
val sellQuantity = if (task.copyMode == "RATIO") {
// 比例模式: 按 Leader 卖出比例
// 如果 position.leaderBuyQuantity 为 null,则按持仓比例计算
if (position.leaderBuyQuantity != null && position.leaderBuyQuantity > BigDecimal.ZERO) {
position.quantity * (leaderTrade.quantity / position.leaderBuyQuantity)
} else {
// 按比例卖出:卖出持仓的 (leaderTrade.quantity / 当前总持仓)
// 但这种情况下无法获取 Leader 的总持仓,所以简化为全部卖出
position.quantity
}
} else {
// 固定金额模式: 全部卖出
position.quantity
}
val sellAmount = sellQuantity * leaderTrade.price
val netAmount = sellAmount // 不扣除手续费
// 计算盈亏
val cost = sellQuantity * position.avgPrice
val profitLoss = netAmount - cost
// 更新余额和持仓
currentBalance += netAmount
position.quantity -= sellQuantity
if (position.quantity <= BigDecimal.ZERO) {
positions.remove(positionKey)
}
// 记录交易
trades.add(BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = leaderTrade.timestamp,
marketId = leaderTrade.marketId,
side = "SELL",
outcome = leaderTrade.outcome,
quantity = sellQuantity,
price = leaderTrade.price,
amount = sellAmount,
fee = BigDecimal.ZERO, // 不计算手续费
profitLoss = profitLoss,
balanceAfter = currentBalance
))
}
}
// 5. 处理回测结束时仍未到期的持仓 (兜底处理)
for ((positionKey, position) in positions) {
try {
val marketInfo = marketInfoCache.getOrPut(position.marketId) {
marketService.getMarketInfo(position.marketId)
}
// 获取市场结算结果
// 方案: 通过市场价格判断
// - 价格 >= 0.95: 胜出 (按 1.0 结算)
// - 价格 <= 0.05: 失败 (按 0.0 结算)
// - 其他情况: 按成本价保守估计
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
val profitLoss = settlementValue - (position.quantity * position.avgPrice)
currentBalance += settlementValue
trades.add(BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = marketInfo?.endDate ?: endTime,
marketId = position.marketId,
side = "SETTLEMENT",
outcome = position.outcome,
quantity = position.quantity,
price = settlementPrice,
amount = settlementValue,
fee = BigDecimal.ZERO,
profitLoss = profitLoss,
balanceAfter = currentBalance
))
logger.info("回测结束时结算剩余持仓: ${position.marketId}, 结算价: $settlementPrice")
} catch (e: Exception) {
logger.warn("无法获取市场${position.marketId}结算信息,按成本价计算", e)
currentBalance += position.quantity * position.avgPrice
}
}
// 6. 计算最终统计数据
val statistics = calculateStatistics(trades)
// 7. 更新任务状态
task.finalBalance = currentBalance
task.profitAmount = currentBalance - task.initialBalance
task.profitRate = (task.profitAmount!! / task.initialBalance) * BigDecimal(100)
task.status = "COMPLETED"
task.totalTrades = trades.size
// ... 更新其他统计字段
// 8. 保存数据
backtestTaskRepository.save(task)
backtestTradeRepository.saveAll(trades)
}
```
#### 4.2.2 过滤规则复用
直接调用 `CopyTradingFilterService` 的方法:
- `checkPriceFilter()`
- `checkDepthFilter()`
- `checkSpreadFilter()`
- `checkPositionLimits()`
- `checkKeywordFilter()`
- `checkMarketEndDate()`
**适配要点**:
- 回测模式下,持仓数据来自内存 `positions` Map,而非实时API
- 订单簿数据可能不可用 (历史数据),需要容错处理
## 五、前端实现方案
### 5.1 页面组件结构
```
src/pages/
├── BacktestList.tsx # 回测列表页
├── BacktestCreate.tsx # 创建回测页
└── BacktestDetail.tsx # 回测详情页
src/components/Backtest/
├── BacktestTable.tsx # 回测列表表格
├── BacktestForm.tsx # 回测创建表单
├── BacktestChart.tsx # 资金变化图表
├── BacktestTradeTable.tsx # 交易记录表格
└── BacktestStatistics.tsx # 统计数据卡片
```
### 5.2 状态管理
使用 React Context 或 Redux:
```typescript
interface BacktestState {
tasks: BacktestTask[]
currentTask: BacktestTaskDetail | null
trades: BacktestTrade[]
loading: boolean
error: string | null
}
```
### 5.3 API Service
```typescript
// src/services/backtestService.ts
export const backtestService = {
createTask: (data: BacktestCreateRequest) =>
api.post('/api/backtest/tasks', data),
getTaskList: (params: BacktestListParams) =>
api.get('/api/backtest/tasks', { params }),
getTaskDetail: (id: number) =>
api.get(`/api/backtest/tasks/${id}`),
getTrades: (id: number, page: number, size: number) =>
api.get(`/api/backtest/tasks/${id}/trades`, { params: { page, size } }),
deleteTask: (id: number) =>
api.delete(`/api/backtest/tasks/${id}`),
stopTask: (id: number) =>
api.post(`/api/backtest/tasks/${id}/stop`)
}
```
### 5.4 复用现有组件
**跟单配置表单**:
- 直接复用 `CopyTradingForm.tsx` 或相关组件
- 提取配置参数部分为独立组件 `CopyTradingConfigFields.tsx`
- 回测创建页面引入该组件
**好处**:
- 减少重复代码
- 保证参数一致性
- 降低维护成本
### 5.5 图表实现
使用 **ECharts** (项目可能已使用) 或 **Recharts**:
```tsx
import ReactECharts from 'echarts-for-react';
const BacktestChart: React.FC<{ trades: BacktestTrade[] }> = ({ trades }) => {
const option = {
title: { text: '资金变化曲线' },
xAxis: { type: 'time' },
yAxis: { type: 'value', name: '余额 (USDC)' },
series: [{
type: 'line',
data: trades.map(t => [t.tradeTime, t.balanceAfter]),
smooth: true,
itemStyle: { color: '#00b96b' }
}],
tooltip: { trigger: 'axis' }
};
return <ReactECharts option={option} />;
};
```
## 六、技术实现要点
### 6.1 代码复用策略
| 模块 | 复用内容 | 新增内容 |
|-----|---------|---------|
| 后端Entity | `CopyTrading` 参数字段 | `BacktestTask`, `BacktestTrade` |
| 后端Service | `CopyTradingFilterService` 全部方法 | `BacktestService`, `BacktestExecutionService` |
| 后端Repository | JPA通用方法 | 自定义查询方法 |
| 前端组件 | 跟单配置表单组件 | 回测列表、详情、图表组件 |
| 前端Service | API请求封装 | 回测相关API |
### 6.2 性能优化
#### 6.2.1 数据库优化
- 合理使用索引 (见2.3节)
- 分页查询,避免一次加载大量数据
- `BacktestTrade` 使用级联删除 (`ON DELETE CASCADE`)
#### 6.2.2 API优化
- Leader历史交易数据缓存 (Redis, TTL=1h)
- 回测详情页使用懒加载: 先加载任务信息,再加载交易记录
#### 6.2.3 前端优化
- 虚拟滚动: 交易记录表格使用 `react-window``react-virtualized`
- 图表按需渲染: 首次加载最近100条数据,支持分页加载更多
- 防抖: 搜索和筛选操作使用 `debounce`
### 6.3 错误处理
#### 6.3.1 后端异常处理
```kotlin
try {
executeBacktest(task)
} catch (e: Exception) {
logger.error("回测执行失败", e)
task.status = "FAILED"
task.errorMessage = e.message
backtestTaskRepository.save(task)
}
```
#### 6.3.2 前端错误处理
- API调用失败: Toast提示错误信息
- 数据加载失败: 显示错误状态,提供重试按钮
- 表单验证: 实时验证,显示错误提示
### 6.4 数据精度处理
**使用 BigDecimal**:
- 后端: 所有金额计算使用 `BigDecimal`
- 数据库: `DECIMAL(20, 8)` 精度
- 前端: 显示时格式化为2位小数,计算时保持原始精度
## 七、开发流程建议
### Phase 1: 数据库和API (2天)
1. 创建数据表 `backtest_task`, `backtest_trade`
2. 创建 Entity, Repository
3. 实现 `BacktestService` (CRUD操作)
4. 实现 `BacktestController` (API接口)
5. 使用 Postman 测试API
### Phase 2: 回测执行引擎 (3天)
1. 实现 `BacktestDataService` (获取历史数据)
2. 实现 `BacktestExecutionService` (核心算法)
3. 实现 `BacktestPollingService` (定时轮询)
4. 单元测试: 回测算法准确性测试
### Phase 3: 前端页面 (3天)
1. 创建回测列表页 (`BacktestList.tsx`)
2. 创建回测创建页 (`BacktestCreate.tsx`)
3. 创建回测详情页 (`BacktestDetail.tsx`)
4. 集成API,调试交互
### Phase 4: 测试和优化 (2天)
1. 端到端测试
2. 性能测试和优化
3. Bug修复
4. 文档完善
## 八、文档目录建议
根据用户要求,可以将文档拆分为:
1. **BACKTEST_PRD.md** - 产品需求文档 (已完成)
2. **BACKTEST_TECHNICAL_DESIGN.md** - 技术设计文档 (本文档)
3. **BACKTEST_API_SPEC.md** - API接口规范 (可选,从本文档第三节提取)
4. **BACKTEST_DATABASE_SCHEMA.md** - 数据库设计 (可选,从本文档第二节提取)
## 九、风险和注意事项
### 9.1 数据准确性
- **风险**: 历史数据可能不完整或不准确
- **缓解**: 从多个数据源验证,添加数据完整性检查
### 9.2 性能瓶颈
- **风险**: 30天历史数据可能有数千笔交易,执行时间过长
- **缓解**: 异步执行,显示进度条,优化算法
### 9.3 兼容性
- **风险**: 跟单配置参数未来可能变更
- **缓解**: 使用版本化配置,`backtest_task` 表独立存储配置快照
### 9.4 并发控制
- **风险**: 大量回测任务同时执行导致资源耗尽
- **缓解**: 线程池限制并发数,任务队列管理
---
**文档版本**: v1.0
**最后更新**: 2026-01-30