Files
PolyHermes/docs/zh/backtest/BACKTEST_REVIEW_CHECKLIST.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

20 KiB
Raw Permalink Blame History

回测功能设计审查清单

一、设计审查要点

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 的逻辑

伪代码:

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 结算)
  • 其他情况: 按成本价保守估计

实现要点:

// 在交易循环中实时检查市场到期
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 参数,但未在算法中实现

解决方案: 在回测循环中添加每日订单数统计

实现:

// 统计当前交易时间当天已有的订单数
val dailyOrderCount = trades.count { isSameDay(it.tradeTime, leaderTrade.timestamp) }

if (dailyOrderCount >= task.maxDailyOrders) {
    logger.info("已达到每日最大订单数限制: $dailyOrderCount / ${task.maxDailyOrders}")
    continue
}

优势:

  • 符合实际跟单的风险控制逻辑
  • 避免回测结果过于激进

1.3.7 价格容忍度检查 (已补充)

Note

问题: 文档提到了 priceTolerance 参数,但未在算法中实现

解决方案: 在执行交易前检查当前市场价格是否在容忍范围内

实现:

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

修正: 基于用户反馈,修正了停止逻辑

错误设计:

if (currentBalance < $1) {
    break  // ❌ 直接停止,忽略持仓
}

正确设计:

// 只有"余额不足 且 无持仓"时才停止
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 历史交易数据。

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

建议新增以下字段:

-- 用于计算平均持仓时间
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 索引优化

建议添加复合索引:

-- 用于按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 的规范。

修正方案:

# ❌ 错误(使用 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:

{
  "id": 12345
}

Response:

{
  "success": true,
  "data": {
    "progress": 65,
    "currentBalance": "1150.00",
    "totalTrades": 30,
    "status": "RUNNING"
  }
}

3.2.2 批量删除回测任务

POST /api/backtest/tasks/batch-delete

Request Body:

{
  "taskIds": [12345, 12346, 12347]
}

3.2.3 导出回测报告

POST /api/backtest/tasks/export

Request Body:

{
  "id": 12345,
  "format": "csv"  // 或 "pdf"
}

3.2 API错误码规范

建议统一错误码:

错误码 说明
40001 回测任务不存在
40002 Leader不存在
40003 回测天数超出限制
40004 初始金额无效
40005 回测任务正在运行,无法删除
50001 历史数据获取失败
50002 回测执行失败

四、前端实现补充

4.1 状态轮询

对于运行中的回测任务,前端需要定时轮询进度:

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 图表数据压缩

当交易记录过多时,图表数据需要压缩:

// 将数据按时间聚合为最多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:

{
  "backtest": {
    "title": "回测管理",
    "createTask": "新增回测",
    "taskName": "回测名称",
    "leader": "Leader",
    "initialBalance": "初始金额",
    "backtestDays": "回测天数",
    "profitAmount": "收益额",
    "profitRate": "收益率",
    "status": {
      "pending": "待执行",
      "running": "运行中",
      "completed": "已完成",
      "stopped": "已停止",
      "failed": "失败"
    }
  }
}

五、测试计划补充

5.1 单元测试

需要测试的核心方法:

  • BacktestExecutionService.executeBacktest() - 回测算法准确性
  • BacktestExecutionService.calculateStatistics() - 统计数据计算
  • BacktestDataService.getLeaderHistoricalTrades() - 历史数据获取

测试用例示例:

@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
状态: 待用户确认