Files
PolyHermes/docs/copy-trading-order-tracking-design.md
T

534 lines
16 KiB
Markdown
Raw Normal View History

2025-12-02 22:22:39 +08:00
# 跟单订单跟踪与统计设计文档
## 1. 方案概述
采用**订单跟踪匹配方案**,精确追踪每笔买入订单,当 Leader 卖出时进行精确匹配,实现:
- 精确的买入-卖出匹配关系
- 准确的盈亏计算(已实现/未实现)
- 完整的订单统计信息
- 多维度数据统计
## 2. 核心思路
### 2.1 事件监听
**当前监听的事件类型****交易事件(trade**
- **事件来源**
- WebSocket User Channel`event_type = "trade"`
- 轮询 CLOB API`GET /trades?user={leaderAddress}`
- **触发时机**:交易已成交
- **数据字段**`id`trade_id)、`market``side`BUY/SELL)、`price``size``timestamp`
- **去重标识**`leader_id + trade_id`trade.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_record``sell_match_detail`
### 2.4 匹配策略
- **FIFO(先进先出)**:按买入时间顺序匹配
- **部分匹配**:支持一个买入订单被多次卖出匹配
- **状态管理**`filled``partially_matched``fully_matched`
## 3. 数据模型
### 3.1 订单跟踪表(copy_order_tracking
```sql
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
```sql
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
```sql
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 跟单关系统计
**统计维度**
- 总买入数量/金额/订单数
- 总卖出数量/金额/订单数
- 当前持仓数量
- 平均买入价格
- 总已实现盈亏
- 总未实现盈亏(持仓盈亏)
- 总盈亏及百分比
**计算方式**
```kotlin
// 使用 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()`: 比较运算
**示例**
```kotlin
// 计算匹配数量
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 买入数量计算
```kotlin
// 买入时根据模式计算
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 卖出匹配算法
```kotlin
// 卖出时统一按比例计算(不区分模式)
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 状态更新
```kotlin
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
```sql
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 去重流程
```kotlin
/**
* 处理交易事件(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 和轮询同时收到同一笔交易:
- 第一个请求:成功处理并插入记录
- 第二个请求:插入失败(唯一约束),跳过处理
**或者使用分布式锁**
```kotlin
// 使用 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 清理策略
**定期清理过期记录**
```kotlin
@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 天)