Files
PolyHermes/docs/copy-trading-order-tracking-design.md
T
WrBug 792ddb8635 删除后端无用日志
- 删除所有 logger.debug 调试日志
- 删除过于详细的 logger.info 常规操作日志
- 保留 logger.error 和 logger.warn 重要错误和警告日志
- 优化日志输出,减少生产环境日志噪音
2025-12-02 22:22:39 +08:00

16 KiB
Raw Blame History

跟单订单跟踪与统计设计文档

1. 方案概述

采用订单跟踪匹配方案,精确追踪每笔买入订单,当 Leader 卖出时进行精确匹配,实现:

  • 精确的买入-卖出匹配关系
  • 准确的盈亏计算(已实现/未实现)
  • 完整的订单统计信息
  • 多维度数据统计

2. 核心思路

2.1 事件监听

当前监听的事件类型交易事件(trade

  • 事件来源
    • WebSocket User Channelevent_type = "trade"
    • 轮询 CLOB APIGET /trades?user={leaderAddress}
  • 触发时机:交易已成交
  • 数据字段idtrade_id)、marketsideBUY/SELL)、pricesizetimestamp
  • 去重标识leader_id + trade_idtrade.id

说明

  • 只监听已成交的交易事件,不监听订单创建事件
  • 交易事件表示 Leader 已经完成买入或卖出操作
  • 通过 trade.id 进行去重,确保同一笔交易只处理一次

2.2 买入订单跟踪

当 Leader 买入时(通过交易事件):

  1. 检测到 side = "BUY" 的交易事件
  2. 创建跟单买入订单
  3. 记录到 copy_order_tracking
  4. 记录买入数量、价格、状态等信息

2.3 卖出订单匹配

当 Leader 卖出时(通过交易事件):

  1. 检测到 side = "SELL" 的交易事件
  2. 查找未匹配的买入订单(FIFO 策略)
  3. 按比例匹配卖出数量
  4. 更新买入订单的匹配状态
  5. 记录匹配关系到 sell_match_recordsell_match_detail

2.4 匹配策略

  • FIFO(先进先出):按买入时间顺序匹配
  • 部分匹配:支持一个买入订单被多次卖出匹配
  • 状态管理filledpartially_matchedfully_matched

3. 数据模型

3.1 订单跟踪表(copy_order_tracking

CREATE TABLE copy_order_tracking (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    copy_trading_id BIGINT NOT NULL,  -- 跟单关系ID
    account_id BIGINT NOT NULL,
    leader_id BIGINT NOT NULL,
    template_id BIGINT NOT NULL,
    market_id VARCHAR(100) NOT NULL,
    side VARCHAR(10) NOT NULL,  -- YES/NO
    buy_order_id VARCHAR(100) NOT NULL,  -- 跟单买入订单ID
    leader_buy_trade_id VARCHAR(100) NOT NULL,  -- Leader 买入交易ID
    quantity DECIMAL(20, 8) NOT NULL,  -- 买入数量
    price DECIMAL(20, 8) NOT NULL,  -- 买入价格
    matched_quantity DECIMAL(20, 8) NOT NULL DEFAULT 0,  -- 已匹配卖出数量
    remaining_quantity DECIMAL(20, 8) NOT NULL,  -- 剩余未匹配数量
    status VARCHAR(20) NOT NULL,  -- filled, fully_matched, partially_matched
    created_at BIGINT NOT NULL,
    updated_at BIGINT NOT NULL,
    INDEX idx_copy_trading (copy_trading_id),
    INDEX idx_remaining (remaining_quantity, status)
);

3.2 卖出匹配记录表(sell_match_record

CREATE TABLE sell_match_record (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    copy_trading_id BIGINT NOT NULL,
    sell_order_id VARCHAR(100) NOT NULL,  -- 跟单卖出订单ID
    leader_sell_trade_id VARCHAR(100) NOT NULL,  -- Leader 卖出交易ID
    market_id VARCHAR(100) NOT NULL,
    side VARCHAR(10) NOT NULL,
    total_matched_quantity DECIMAL(20, 8) NOT NULL,  -- 总匹配数量
    sell_price DECIMAL(20, 8) NOT NULL,  -- 卖出价格
    total_realized_pnl DECIMAL(20, 8) NOT NULL,  -- 总已实现盈亏
    created_at BIGINT NOT NULL,
    INDEX idx_copy_trading (copy_trading_id)
);

3.3 匹配明细表(sell_match_detail

CREATE TABLE sell_match_detail (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    match_record_id BIGINT NOT NULL,  -- 关联 sell_match_record.id
    tracking_id BIGINT NOT NULL,  -- 关联 copy_order_tracking.id
    buy_order_id VARCHAR(100) NOT NULL,
    matched_quantity DECIMAL(20, 8) NOT NULL,  -- 匹配的数量
    buy_price DECIMAL(20, 8) NOT NULL,
    sell_price DECIMAL(20, 8) NOT NULL,
    realized_pnl DECIMAL(20, 8) NOT NULL,  -- 盈亏 = (sell_price - buy_price) * matched_quantity
    created_at BIGINT NOT NULL,
    FOREIGN KEY (match_record_id) REFERENCES sell_match_record(id),
    FOREIGN KEY (tracking_id) REFERENCES copy_order_tracking(id)
);

4. 核心流程

4.1 买入订单跟踪流程

Leader 买入交易
  ↓
检测到交易,计算跟单数量
  ↓
根据模板模式计算:
  - RATIO 模式: 数量 = Leader 数量 × copyRatio
  - FIXED 模式: 数量 = fixedAmount / 买入价格
  ↓
创建跟单买入订单
  ↓
记录到 copy_order_tracking
  - quantity: 买入数量
  - price: 买入价格
  - remaining_quantity: 初始等于 quantity
  - status: "filled"

4.2 卖出订单匹配流程

Leader 卖出交易
  ↓
查找未匹配的买入订单(remaining_quantity > 0
  ↓
按 FIFO 顺序匹配
  ↓
计算匹配数量(统一按比例,不区分模式)
  - 需要匹配数量 = Leader 卖出数量 × copyRatio
  - 实际匹配数量 = min(需要匹配数量, 剩余持仓数量)
  ↓
更新买入订单状态
  - matched_quantity += 匹配数量
  - remaining_quantity -= 匹配数量
  - status: 根据剩余数量更新
  ↓
记录匹配关系
  - sell_match_record: 卖出订单记录
  - sell_match_detail: 匹配明细(每笔买入订单的匹配)

重要说明

  • 买入时:根据模板的 copyMode 计算(RATIO 按比例,FIXED 按固定金额)
  • 卖出时:统一按比例计算(Leader 卖出数量 × copyRatio),不区分模式
  • 固定金额模式:只影响买入时的计算,卖出时仍然按比例

4.3 匹配计算示例

示例1:比例模式

场景(比例模式,copyRatio = 100%):
- 买入订单1: quantity=100, remaining=100
- 买入订单2: quantity=50, remaining=50
- Leader 卖出: 120

匹配过程:
1. 计算需要匹配:120 × 100% = 120
2. 订单1: 匹配 min(100, 120) = 100,剩余需匹配 = 20
3. 订单2: 匹配 min(50, 20) = 20,剩余需匹配 = 0

结果:
- 订单1: remaining = 0, status = "fully_matched"
- 订单2: remaining = 30, status = "partially_matched"
- 跟单卖出: 120

示例2:固定金额模式

场景(固定金额模式,fixedAmount = 15 USDCcopyRatio = 100%):
- Leader 买入: 100 数量,价格 0.5
- 跟单买入: 15 / 0.5 = 30 数量(固定金额)
- Leader 卖出: 50 数量,价格 0.7

匹配过程:
1. 计算需要匹配:50 × 100% = 50(按比例,不按固定金额)
2. 订单1: 匹配 min(30, 50) = 30,剩余需匹配 = 20

结果:
- 订单1: remaining = 0, status = "fully_matched"
- 跟单卖出: 30(不超过持仓)
- 注意:虽然买入时是固定金额,但卖出时按比例计算

示例3:部分比例模式

场景(比例模式,copyRatio = 30%):
- Leader 买入: 100 数量
- 跟单买入: 100 × 30% = 30 数量
- Leader 卖出: 50 数量

匹配过程:
1. 计算需要匹配:50 × 30% = 15
2. 订单1: 匹配 min(30, 15) = 15,剩余需匹配 = 0

结果:
- 订单1: remaining = 15, status = "partially_matched"
- 跟单卖出: 15

5. 统计功能

5.1 跟单关系统计

统计维度

  • 总买入数量/金额/订单数
  • 总卖出数量/金额/订单数
  • 当前持仓数量
  • 平均买入价格
  • 总已实现盈亏
  • 总未实现盈亏(持仓盈亏)
  • 总盈亏及百分比

计算方式

// 使用 util 方法进行数值计算
val totalBuyQuantity = buyOrders.sumOf { it.quantity.toSafeBigDecimal() }
val totalSellQuantity = sellOrders.sumOf { it.quantity.toSafeBigDecimal() }
val currentPosition = buyOrders.sumOf { it.remainingQuantity.toSafeBigDecimal() }

// 已实现盈亏
val totalRealizedPnl = matchDetails.sumOf { it.realizedPnl.toSafeBigDecimal() }

// 未实现盈亏(需要当前市场价格)
val currentPrice = getMarketCurrentPrice(marketId)
val avgBuyPrice = totalBuyAmount.div(totalBuyQuantity)
val unrealizedPnl = currentPosition.multi(currentPrice.subtract(avgBuyPrice))

// 总盈亏
val totalPnl = totalRealizedPnl.add(totalUnrealizedPnl)

5.2 订单信息

买入订单列表

  • 订单ID、Leader 交易ID
  • 市场、方向、数量、价格
  • 已匹配数量、剩余数量
  • 订单状态

卖出订单列表

  • 订单ID、Leader 交易ID
  • 市场、方向、数量、价格
  • 已实现盈亏

匹配关系列表

  • 卖出订单ID
  • 匹配的买入订单ID
  • 匹配数量
  • 买入价格、卖出价格
  • 盈亏

6. 数值计算规范

使用 util 扩展方法

  • toSafeBigDecimal(): 安全转换为 BigDecimal
  • multi(): 乘法运算
  • div(): 除法运算
  • eq(), lt(), gt(), gte(), lte(): 比较运算

示例

// 计算匹配数量
val matchedQty = min(remainingQty.toSafeBigDecimal(), needMatchQty.toSafeBigDecimal())

// 计算盈亏
val pnl = sellPrice.toSafeBigDecimal()
    .subtract(buyPrice.toSafeBigDecimal())
    .multi(matchedQty)

// 比较数量
if (remainingQty.toSafeBigDecimal().gt(BigDecimal.ZERO)) {
    // 还有剩余
}

7. 关键实现点

7.1 买入数量计算

// 买入时根据模式计算
fun calculateBuyQuantity(leaderTrade: Trade, template: CopyTradingTemplate): BigDecimal {
    return when (template.copyMode) {
        "RATIO" -> {
            // 比例模式:Leader 数量 × 比例
            leaderTrade.size.toSafeBigDecimal()
                .multi(template.copyRatio)
        }
        "FIXED" -> {
            // 固定金额模式:固定金额 / 买入价格
            val fixedAmount = template.fixedAmount?.toSafeBigDecimal()
                ?: throw IllegalStateException("固定金额模式下 fixedAmount 不能为空")
            val buyPrice = leaderTrade.price.toSafeBigDecimal()
            fixedAmount.div(buyPrice)
        }
        else -> throw IllegalArgumentException("不支持的 copyMode: ${template.copyMode}")
    }
}

7.2 卖出匹配算法

// 卖出时统一按比例计算(不区分模式)
fun matchSellOrder(leaderSellTrade: Trade, copyTrading: CopyTrading, template: CopyTradingTemplate): BigDecimal {
    // 统一按比例计算,不区分 RATIO 或 FIXED 模式
    val needMatch = leaderSellTrade.size.toSafeBigDecimal()
        .multi(template.copyRatio)
    
    val unmatchedOrders = findUnmatchedBuyOrders(copyTrading.id, leaderSellTrade.market, leaderSellTrade.side)
    var totalMatched = BigDecimal.ZERO
    var remaining = needMatch
    
    for (order in unmatchedOrders) {
        if (remaining.lte(BigDecimal.ZERO)) break
        
        val matchQty = min(order.remainingQuantity.toSafeBigDecimal(), remaining)
        totalMatched = totalMatched.add(matchQty)
        remaining = remaining.subtract(matchQty)
        
        updateOrderTracking(order, matchQty)
        recordMatchDetail(order, matchQty, leaderSellTrade)
    }
    
    return totalMatched
}

7.3 状态更新

fun updateOrderStatus(tracking: CopyOrderTracking) {
    when {
        tracking.remainingQuantity.toSafeBigDecimal().eq(BigDecimal.ZERO) -> {
            tracking.status = "fully_matched"
        }
        tracking.matchedQuantity.toSafeBigDecimal().gt(BigDecimal.ZERO) -> {
            tracking.status = "partially_matched"
        }
        else -> {
            tracking.status = "filled"
        }
    }
}

8. API 设计

8.1 查询跟单统计

POST /api/copy-trading/statistics/detail
Request: { copyTradingId: Long }
Response: CopyTradingStatisticsResponse

8.2 查询订单列表

POST /api/copy-trading/orders/tracking
Request: { copyTradingId: Long, type: "buy" | "sell" | "matched" }
Response: OrderListResponse

9. 优势

  1. 精确匹配:每笔卖出都能追溯到对应的买入订单
  2. 准确盈亏:可以精确计算每笔交易的盈亏
  3. 完整统计:支持多维度数据统计和分析
  4. 可追溯性:完整的买入-卖出匹配关系,便于审计

10. WebSocket 与轮询去重机制

10.1 同时运行策略

WebSocket 和轮询可以同时运行

  • WebSocket:作为主要数据源,实时接收交易推送
  • 轮询:作为补充数据源,定期查询确保不遗漏
  • 去重机制:通过 trade_id 确保同一笔交易只处理一次

10.2 去重数据模型

已处理交易表(processed_trade

CREATE TABLE processed_trade (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    leader_id BIGINT NOT NULL,
    leader_trade_id VARCHAR(100) NOT NULL,  -- Leader 的交易IDtrade.id,唯一标识)
    trade_type VARCHAR(10) NOT NULL,  -- BUY 或 SELL
    source VARCHAR(20) NOT NULL,  -- 'websocket' 或 'polling'
    processed_at BIGINT NOT NULL,
    created_at BIGINT NOT NULL,
    UNIQUE KEY uk_leader_trade (leader_id, leader_trade_id),
    INDEX idx_processed_at (processed_at)
);

唯一标识leader_id + leader_trade_id 组合作为唯一键

重要说明

  • leader_trade_id 对应 TradeResponse.id(交易ID
  • 交易事件(trade)只有 id 字段,没有 order_id 字段
  • 通过 trade.id 进行去重,确保同一笔交易只处理一次

10.3 去重流程

/**
 * 处理交易事件(WebSocket 或轮询)
 */
suspend fun processTrade(leaderId: Long, trade: TradeResponse, source: String) {
    // 1. 检查是否已处理(去重)
    // 使用 trade.id 作为唯一标识(TradeResponse 只有 id 字段,没有 order_id
    val isProcessed = processedTradeRepository.existsByLeaderIdAndLeaderTradeId(
        leaderId, 
        trade.id  // trade.id 是交易ID,用于去重
    )
    
    if (isProcessed) {
        logger.debug("交易已处理,跳过: leaderId=$leaderId, tradeId=${trade.id}, source=$source")
        return
    }
    
    // 2. 处理交易逻辑
    try {
        // 根据 side 判断是买入还是卖出
        when (trade.side.uppercase()) {
            "BUY" -> processBuyTrade(leaderId, trade)
            "SELL" -> processSellTrade(leaderId, trade)
            else -> {
                logger.warn("未知的交易方向: ${trade.side}")
                return
            }
        }
        
        // 3. 标记为已处理
        val processed = ProcessedTrade(
            leaderId = leaderId,
            leaderTradeId = trade.id,  // 使用 trade.id 作为唯一标识
            tradeType = trade.side,
            source = source,
            processedAt = System.currentTimeMillis()
        )
        processedTradeRepository.save(processed)
        
        logger.info("成功处理交易: leaderId=$leaderId, tradeId=${trade.id}, source=$source, side=${trade.side}")
    } catch (e: Exception) {
        logger.error("处理交易失败: leaderId=$leaderId, tradeId=${trade.id}", e)
        // 失败时不标记为已处理,允许重试
    }
}

10.4 并发安全

使用数据库唯一约束保证并发安全

  • 数据库唯一约束:UNIQUE KEY uk_leader_trade (leader_id, leader_trade_id)
  • 如果 WebSocket 和轮询同时收到同一笔交易:
    • 第一个请求:成功处理并插入记录
    • 第二个请求:插入失败(唯一约束),跳过处理

或者使用分布式锁

// 使用 Redis 分布式锁
val lockKey = "trade:${leaderId}:${trade.id}"
if (redisLock.tryLock(lockKey, 5, TimeUnit.SECONDS)) {
    try {
        if (!isProcessed(leaderId, trade.id)) {
            processTrade(leaderId, trade)
            markAsProcessed(leaderId, trade.id)
        }
    } finally {
        redisLock.unlock(lockKey)
    }
}

10.5 清理策略

定期清理过期记录

@Scheduled(cron = "0 0 2 * * ?")  // 每天凌晨 2 点
fun cleanupProcessedTrades() {
    val expireTime = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7)  // 保留 7 天
    processedTradeRepository.deleteByProcessedAtBefore(expireTime)
}

10.6 优势

  1. 高可用性:WebSocket 断开时,轮询继续工作
  2. 数据完整性:轮询确保不遗漏任何交易
  3. 实时性WebSocket 提供实时推送
  4. 去重保证:通过唯一标识确保不重复处理

11. 注意事项

  1. 匹配策略:默认使用 FIFO,可根据需求调整
  2. 部分匹配:支持一个买入订单被多次卖出匹配
  3. 数量计算:使用 util 方法确保数值计算安全
  4. 状态同步:及时更新订单状态,确保数据一致性
  5. 模式区别
    • 买入时RATIO 模式按比例计算,FIXED 模式按固定金额计算
    • 卖出时:统一按比例计算(Leader 卖出数量 × copyRatio),不区分模式
    • 固定金额模式:只影响买入时的计算,卖出时仍然按比例
  6. 去重机制
    • WebSocket 和轮询可以同时运行
    • 使用 leader_id + leader_trade_id 作为唯一标识去重
    • 数据库唯一约束保证并发安全
    • 定期清理过期记录(建议保留 7 天)