Merge branch 'pre_release' into feature_nba_quant

# Conflicts:
#	backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaApi.kt
#	frontend/src/components/Layout.tsx
#	frontend/src/types/index.ts
This commit is contained in:
WrBug
2026-01-03 19:53:03 +08:00
108 changed files with 9922 additions and 1680 deletions
+32 -3
View File
@@ -104,6 +104,38 @@ export IMAGE_TAG=v1.0.0
# In docker-compose.prod.yml use: image: wrbug/polyhermes:${IMAGE_TAG:-latest}
```
**Update Docker Version**:
When a new version is released, you can update using the following steps:
```bash
# 1. Stop currently running containers
docker-compose -f docker-compose.prod.yml down
# 2. Pull the latest version image (or specific version)
# Update to latest version
docker pull wrbug/polyhermes:latest
# Or update to specific version (e.g., v1.0.1)
docker pull wrbug/polyhermes:v1.0.1
# 3. If using a specific version, modify the image tag in docker-compose.prod.yml
# Edit docker-compose.prod.yml, change image to:
# image: wrbug/polyhermes:v1.0.1
# 4. Restart services
docker-compose -f docker-compose.prod.yml up -d
# 5. Check logs to confirm services started normally
docker-compose -f docker-compose.prod.yml logs -f
```
**Notes**:
- ⚠️ It is recommended to backup the database before updating (if using MySQL in Docker Compose)
- ⚠️ Service will be briefly interrupted during update, recommend updating during off-peak hours
- ✅ Using `docker-compose pull` can automatically pull the latest image and update (if using `latest` tag)
- ✅ View available versions: Visit [Docker Hub](https://hub.docker.com/r/wrbug/polyhermes/tags) or [GitHub Releases](https://github.com/WrBug/PolyHermes/releases)
2. **Local Build Deployment (Development Environment)**
Suitable for development environments or scenarios requiring custom builds.
@@ -131,7 +163,6 @@ DB_USERNAME=root
DB_PASSWORD=your_password_here
SPRING_PROFILES_ACTIVE=prod
SERVER_PORT=80
POLYGON_RPC_URL=https://polygon-rpc.com
JWT_SECRET=your-jwt-secret-key-change-in-production
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
EOF
@@ -361,7 +392,6 @@ DB_USERNAME=root
DB_PASSWORD=your_password_here
SPRING_PROFILES_ACTIVE=prod
SERVER_PORT=8000
POLYGON_RPC_URL=https://polygon-rpc.com
JWT_SECRET=your-jwt-secret-key-change-in-production
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
EOF
@@ -522,7 +552,6 @@ serve -s dist -l 3000
| `DB_USERNAME` | Database username | `root` | Yes (Production) |
| `DB_PASSWORD` | Database password | - | Yes (Production) |
| `SERVER_PORT` | Server port | `8000` | No |
| `POLYGON_RPC_URL` | Polygon RPC address | `https://polygon-rpc.com` | No |
| `JWT_SECRET` | JWT secret key | - | Yes (Production) |
| `ADMIN_RESET_PASSWORD_KEY` | Admin password reset key | - | Yes (Production) |
-3
View File
@@ -100,9 +100,6 @@ spring.datasource.password=${DB_PASSWORD:password}
# Server port
server.port=${SERVER_PORT:8000}
# Polygon RPC
polygon.rpc.url=${POLYGON_RPC_URL:https://polygon-rpc.com}
# JWT secret
jwt.secret=${JWT_SECRET:change-me-in-production}
+36 -3
View File
@@ -102,6 +102,42 @@ export IMAGE_TAG=v1.0.0
# 在 docker-compose.prod.yml 中使用: image: wrbug/polyhermes:${IMAGE_TAG:-latest}
```
**更新 Docker 版本**
当有新版本发布时,可以通过以下步骤更新:
```bash
# 1. 停止当前运行的容器
docker-compose -f docker-compose.prod.yml down
# 2. 拉取最新版本的镜像(或指定版本)
# 更新到最新版本
docker pull wrbug/polyhermes:latest
# 或更新到特定版本(例如 v1.0.1)
docker pull wrbug/polyhermes:v1.0.1
# 3. 如果使用特定版本,需要修改 docker-compose.prod.yml 中的镜像标签
# 编辑 docker-compose.prod.yml,将 image 改为:
# image: wrbug/polyhermes:v1.0.1
# 4. 重新启动服务
docker-compose -f docker-compose.prod.yml up -d
# 5. 查看日志确认服务正常启动
docker-compose -f docker-compose.prod.yml logs -f
```
**注意事项**
- ⚠️ **备份数据库(强烈推荐)**
- 备份不是必须的,但强烈推荐,特别是生产环境
- Docker 更新不会删除数据(数据存储在独立的数据卷中)
- 但数据库结构可能会变更,如果迁移失败,备份可以帮助恢复
- 备份命令:`docker exec polyhermes-mysql mysqldump -u root -p polyhermes > backup_$(date +%Y%m%d_%H%M%S).sql`
- ⚠️ 更新过程中服务会短暂中断,建议在低峰期进行
- ✅ 使用 `docker-compose pull` 可以自动拉取最新镜像并更新(如果使用 `latest` 标签)
- ✅ 查看可用版本:访问 [Docker Hub](https://hub.docker.com/r/wrbug/polyhermes/tags) 或 [GitHub Releases](https://github.com/WrBug/PolyHermes/releases)
2. **本地构建部署(开发环境)**
适用于开发环境或需要自定义构建的场景。
@@ -129,7 +165,6 @@ DB_USERNAME=root
DB_PASSWORD=your_password_here
SPRING_PROFILES_ACTIVE=prod
SERVER_PORT=80
POLYGON_RPC_URL=https://polygon-rpc.com
JWT_SECRET=your-jwt-secret-key-change-in-production
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
EOF
@@ -361,7 +396,6 @@ DB_USERNAME=root
DB_PASSWORD=your_password_here
SPRING_PROFILES_ACTIVE=prod
SERVER_PORT=8000
POLYGON_RPC_URL=https://polygon-rpc.com
JWT_SECRET=your-jwt-secret-key-change-in-production
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
EOF
@@ -522,7 +556,6 @@ serve -s dist -l 3000
| `DB_USERNAME` | 数据库用户名 | `root` | 是(生产) |
| `DB_PASSWORD` | 数据库密码 | - | 是(生产) |
| `SERVER_PORT` | 服务器端口 | `8000` | 否 |
| `POLYGON_RPC_URL` | Polygon RPC 地址 | `https://polygon-rpc.com` | 否 |
| `JWT_SECRET` | JWT 密钥 | - | 是(生产) |
| `ADMIN_RESET_PASSWORD_KEY` | 管理员密码重置密钥 | - | 是(生产) |
-3
View File
@@ -93,9 +93,6 @@ spring.datasource.password=${DB_PASSWORD:password}
# 服务器端口
server.port=${SERVER_PORT:8000}
# Polygon RPC
polygon.rpc.url=${POLYGON_RPC_URL:https://polygon-rpc.com}
# JWT 密钥
jwt.secret=${JWT_SECRET:change-me-in-production}
+289
View File
@@ -0,0 +1,289 @@
# 跟单买卖逻辑简易文档
## 一、跟单信号检测
### 1.1 监控方式
系统使用**轮询方式**监控 Leader 的交易活动:
- **数据源**Polymarket Data API 的 `/activity` 接口
- **轮询间隔**:默认 2 秒(可配置)
- **查询参数**
- `user`: Leader 钱包地址
- `type`: `["TRADE"]`(只查询交易类型)
- `limit`: 100(每次最多查询 100 条)
- `sortBy`: `TIMESTAMP`
- `sortDirection`: `DESC`(按时间戳降序,最新的在前)
### 1.2 增量检测机制
通过 **diff 算法**检测新增交易:
1. **首次轮询**
- 查询最近 100 条交易
- 缓存所有交易 ID(不处理)
- 标记首次轮询完成
2. **后续轮询**
- 查询最近 100 条交易
- 与缓存的交易 ID 集合进行 diff
- 找出新增的交易 ID
- 处理新增交易
- 更新缓存(添加新增的交易 ID
3. **去重机制**
- 使用 `leaderId + tradeId` 作为唯一标识
-`processed_trade` 表中记录已处理的交易
- 避免重复处理同一笔交易
### 1.3 交易数据转换
`UserActivityResponse` 转换为 `TradeResponse`
```kotlin
TradeResponse(
id = activity.transactionHash, // 交易ID(用于去重)
market = activity.conditionId, // 市场ID
side = activity.side, // "BUY" 或 "SELL"
price = activity.price, // 交易价格
size = activity.size, // 交易数量
timestamp = activity.timestamp, // 时间戳(秒)
user = activity.proxyWallet, // 用户钱包地址
outcomeIndex = activity.outcomeIndex, // 结果索引(0=第一个outcome1=第二个outcome
outcome = activity.outcome // 结果名称
)
```
### 1.4 信号触发流程
```
轮询任务启动
定期轮询所有 Leader(每 2 秒)
查询 Leader 活动(/activity 接口)
转换为 TradeResponse
diff 检测新增交易
调用 processTrade() 处理交易
根据 side 字段判断:
- "BUY" → processBuyTrade()
- "SELL" → processSellTrade()
```
## 二、订单构建
### 2.1 买入订单构建流程
#### 2.1.1 前置检查
1. **查找跟单关系**
- 查询所有启用且支持该 Leader 的跟单配置
- 验证账户 API 凭证是否配置
- 验证账户是否启用
2. **获取 Token ID**
- 使用 `outcomeIndex``market` 获取 tokenId
- 支持多元市场(不限于 YES/NO)
3. **计算买入数量**
- **RATIO 模式**`买入数量 = Leader 数量 × 跟单比例`
- **FIXED 模式**`买入数量 = 固定金额 / 买入价格`
4. **过滤条件检查**
- 价格区间检查
- 仓位限制检查
- 市场分类检查
- 订单簿检查(获取最佳卖单价格)
5. **价格调整**
- 应用价格容忍度(`priceTolerance`
- 买入价格 = Leader 价格 × (1 + 容忍度)
- 确保调整后的价格不低于最佳卖单价格
#### 2.1.2 订单签名
使用 `OrderSigningService.createAndSignOrder()` 创建并签名订单:
1. **计算订单金额**
- **BUY 订单**
- `makerAmount = price × size`USDC 金额,最多 2 位小数)
- `takerAmount = size`(shares 数量,最多 4 位小数)
- 转换为 wei6 位小数)
2. **生成订单参数**
- `salt`: 时间戳(毫秒)
- `maker`: 代理钱包地址(proxyAddress
- `signer`: 从私钥推导的签名地址
- `taker`: 零地址(0x0000...
- `tokenId`: 从 outcomeIndex 获取
- `makerAmount`: 计算出的 maker 金额(wei
- `takerAmount`: 计算出的 taker 金额(wei
- `expiration`: "0"(永不过期)
- `nonce`: "0"
- `feeRateBps`: "0"
- `side`: "BUY"
- `signatureType`: 2Browser Wallet
3. **EIP-712 签名**
- 编码域分隔符(Exchange Contract + Chain ID
- 编码订单消息哈希
- 计算结构化数据哈希
- 使用私钥签名(r + s + v
#### 2.1.3 创建订单请求
构建 `NewOrderRequest`
```kotlin
NewOrderRequest(
order = signedOrder, // 签名的订单对象
owner = account.apiKey, // API Key
orderType = "FAK", // Fill-And-Kill(允许部分成交,未成交部分立即取消)
deferExec = false // 立即执行
)
```
#### 2.1.4 提交订单
1. **创建 CLOB API 客户端**(带认证):
- 使用账户的 API Key、Secret、Passphrase
- 解密 API 凭证
2. **调用 API 创建订单**
- `POST /orders`
- 带重试机制(最多重试 2 次)
- 每次重试都重新生成 salt 并重新签名
3. **记录订单跟踪**
- 保存到 `copy_order_tracking`
- 记录买入订单 ID、数量、价格等信息
- 状态:`filled`
### 2.2 卖出订单构建流程
#### 2.2.1 前置检查
1. **查找跟单关系**
- 查询所有启用且支持该 Leader 的跟单配置
- 验证是否支持卖出(`supportSell = true`
2. **计算需要匹配的数量**
- `需要匹配数量 = Leader 卖出数量 × 跟单比例`
3. **查找未匹配的买入订单**
- 使用 `outcomeIndex` 匹配(支持多元市场)
- 按 FIFO 顺序(先进先出)
- 查询 `copy_order_tracking` 表中未匹配的订单
4. **计算实际可卖出数量**
- 按 FIFO 顺序匹配
- 支持部分匹配(一个买入订单可以被多次卖出匹配)
#### 2.2.2 价格计算
1. **优先使用订单簿 bestBid**
- 查询订单簿(`getOrderbookByTokenId`
- 获取最佳买单价格(bestBid
- 使用 bestBid 作为卖出价格
2. **备选方案**
- 如果获取订单簿失败,使用 Leader 价格
- 卖出价格 = Leader 价格 × 0.9(固定按 90% 计算)
#### 2.2.3 订单签名
与买入订单类似,但:
- `side`: "SELL"
- **SELL 订单金额计算**
- `makerAmount = size`(shares 数量,最多 4 位小数)
- `takerAmount = price × size`USDC 金额,使用原始价格计算)
#### 2.2.4 创建订单请求
与买入订单相同:
- `orderType`: "FAK"
- `deferExec`: false
#### 2.2.5 提交订单
1. **调用 API 创建卖出订单**(带重试机制)
2. **更新买入订单状态**
- 更新 `copy_order_tracking` 表中的 `remainingQuantity`
- 如果完全匹配,状态更新为 `fully_matched`
- 如果部分匹配,状态更新为 `partially_matched`
3. **记录匹配关系**
- 保存到 `sell_match_record` 表(卖出匹配记录)
- 保存到 `sell_match_detail` 表(匹配明细,包含盈亏计算)
## 三、关键配置
### 3.1 跟单配置参数
- `copyMode`: 跟单模式("RATIO" 或 "FIXED"
- `copyRatio`: 跟单比例(RATIO 模式)
- `fixedAmount`: 固定金额(FIXED 模式)
- `priceTolerance`: 价格容忍度(买入时使用)
- `supportSell`: 是否支持卖出
- `enabled`: 是否启用
### 3.2 订单类型
- **FAK (Fill-And-Kill)**
- 允许部分成交
- 未成交部分立即取消
- 快速响应 Leader 交易,避免订单长期挂单导致价格不匹配
### 3.3 重试机制
- **最多重试次数**:2 次(首次 + 1 次重试)
- **重试延迟**3 秒
- **重试策略**:每次重试都重新生成 salt 并重新签名,确保签名唯一性
## 四、数据流向
```
Leader 交易(链上)
Polymarket Data API (/activity)
轮询服务(CopyTradingPollingService
交易处理服务(CopyOrderTrackingService
订单签名服务(OrderSigningService
CLOB API (POST /orders)
订单跟踪表(copy_order_tracking
```
## 五、注意事项
1. **去重机制**
- 使用 `leaderId + tradeId` 作为唯一标识
-`processed_trade` 表中记录已处理的交易
- 避免重复处理同一笔交易
2. **价格调整**
- 买入时应用价格容忍度(提高买入价格)
- 卖出时优先使用订单簿 bestBid,失败则使用 Leader 价格的 90%
3. **订单簿检查**
- 买入前检查订单簿中是否有可匹配的卖单
- 确保调整后的买入价格不低于最佳卖单价格
4. **FIFO 匹配**
- 卖出时按买入时间顺序匹配(先进先出)
- 支持部分匹配
5. **错误处理**
- 订单创建失败时记录到 `failed_trade`
- 支持重试机制(最多 2 次)
- 发送失败通知(如果配置了 `pushFailedOrders`
+653
View File
@@ -0,0 +1,653 @@
# 跟单监听策略:链上 WebSocket + 轮询并行方案
## 一、方案概述
系统**同时运行**两种监听方式,并行处理,哪个数据先返回就用哪个:
1. **链上 WebSocket 监听**:通过 Polygon RPC 的 `eth_subscribe` 实时监听链上交易
2. **轮询监听**:通过 Polymarket Data API 定期轮询交易记录
**核心特点**
- 两种方式**并行运行**,不互相排斥
- WS 断开时**不断重试连接**,不停止轮询
- 哪个数据先返回就用哪个,确保最快响应
- 通过去重机制确保同一笔交易只处理一次
## 二、关键流程图
```
系统启动
同时启动两种监听方式
├─→ 链上 WS 监听(并行)
│ ↓
│ 尝试连接 WS RPC
│ ↓
│ 连接成功?
│ ├─→ 是 → 订阅 Leader 钱包地址
│ │ (eth_subscribe: USDC Transfer + ERC1155 Transfer)
│ │ ↓
│ │ 实时接收交易日志
│ │ ↓
│ │ 解析交易 receipt
│ │ ↓
│ │ 转换为 TradeResponse
│ │ ↓
│ │ 调用 processTrade()(去重检查)
│ │ ↓
│ │ 连接断开?
│ │ ├─→ 是 → 等待重连延迟 → 重试连接(循环)
│ │ └─→ 否 → 继续监听
│ │
│ └─→ 否 → 等待重连延迟 → 重试连接(循环)
└─→ 轮询监听(并行)
定期轮询 (每 2 秒)
查询 /activity 接口
diff 检测新增交易
调用 processTrade()(去重检查)
继续轮询(循环)
```
## 三、并行处理流程
```
交易发生
├─→ WS 监听(实时,秒级)
│ ↓
│ 收到链上日志
│ ↓
│ 解析并转换为 TradeResponse
│ ↓
│ 调用 processTrade()
│ ↓
│ 去重检查(processed_trade 表)
│ ├─→ 已处理 → 跳过
│ └─→ 未处理 → 处理交易
└─→ 轮询监听(延迟,2秒间隔)
轮询到新交易
转换为 TradeResponse
调用 processTrade()
去重检查(processed_trade 表)
├─→ 已处理 → 跳过(WS 已处理)
└─→ 未处理 → 处理交易
```
## 三、实现方案
### 3.1 服务架构
```
CopyTradingMonitorService (主服务)
├─→ OnChainWsService (链上 WS 监听,独立运行)
└─→ CopyTradingPollingService (轮询监听,独立运行)
```
**关键点**
- 两个服务**独立运行**,互不影响
- 主服务负责启动和协调两个服务
- 两个服务都调用同一个 `processTrade()` 方法
- 去重由 `processTrade()` 内部处理
### 3.2 WS 重连机制
**重连策略**
1. WS 连接断开时,**不停止轮询服务**
2. 等待重连延迟(如 3 秒)
3. 自动重试连接
4. 连接成功后重新订阅所有 Leader
5. 如果连接失败,继续重试(无限重试)
**重连流程**
```
WS 连接断开
记录断开日志
等待重连延迟(3秒)
尝试重新连接
连接成功?
├─→ 是 → 重新订阅所有 Leader → 继续监听
└─→ 否 → 等待重连延迟 → 继续重试(循环)
```
**关键实现**
- 使用协程或后台线程持续重试
- 不阻塞主流程
- 轮询服务继续运行,不受 WS 状态影响
### 3.3 去重机制
**去重标识**
- 使用 `leaderId + transactionHash` 作为唯一标识
-`processed_trade` 表中记录已处理的交易
**去重流程**
```
收到交易数据(WS 或轮询)
调用 processTrade(leaderId, trade, source)
检查 processed_trade 表
├─→ 已存在 → 跳过处理(返回成功)
└─→ 不存在 → 继续处理
处理交易(创建订单等)
保存到 processed_trade 表
├─→ leaderId
├─→ leaderTradeId (transactionHash)
├─→ tradeType (BUY/SELL)
├─→ source (onchain-ws / polling)
└─→ status (SUCCESS/FAILED)
```
**并发安全**
- 使用数据库唯一约束(`leaderId + leaderTradeId`
- 处理唯一约束冲突(并发情况下可能多个请求同时处理同一笔交易)
- 如果冲突,再次查询确认状态
### 3.4 链上 WS 监听实现要点
**订阅参数**
- 钱包地址:Leader 的 `leaderAddress`
- 订阅类型:
- USDC Transfer`from``to` 为钱包地址)
- ERC1155 TransferSingle/Batch`from``to` 为钱包地址)
**消息处理流程**
1. 接收 `eth_subscription` 消息
2. 提取 `transactionHash`
3. 调用 RPC 获取交易 receipt
4. 解析 USDC Transfer 和 ERC1155 Transfer 日志
5. 计算交易方向(BUY/SELL)、数量、价格
6. 调用 Gamma API 补齐市场元数据(conditionId、outcomeIndex 等)
7. 转换为 `TradeResponse`
8. 调用 `processTrade(leaderId, trade, "onchain-ws")` 处理交易(去重由内部处理)
**重连实现**
```kotlin
// 伪代码示例
while (isActive) {
try {
// 尝试连接
val ws = connectWebSocket()
// 订阅所有 Leader
subscribeAllLeaders(ws)
// 监听消息
ws.listen { message ->
handleMessage(message)
}
// 连接断开,等待重连
waitReconnectDelay()
} catch (e: Exception) {
logger.warn("WS 连接失败,等待重连: ${e.message}")
waitReconnectDelay()
}
}
```
### 3.5 轮询监听实现要点
**轮询流程**
1. 定期查询 `/activity` 接口(每 2 秒)
2. 通过 diff 检测新增交易
3. 转换为 `TradeResponse`
4. 调用 `processTrade(leaderId, trade, "polling")` 处理交易(去重由内部处理)
**关键点**
- 轮询服务**独立运行**,不受 WS 状态影响
- 即使 WS 正常工作,轮询也继续运行(作为备份)
- 去重机制确保不会重复处理同一笔交易
## 四、配置参数
### 4.1 RPC 配置获取
**RPC 配置从后台配置中读取**,通过 `RpcNodeService` 获取:
```kotlin
// 获取 HTTP RPC URL
val httpUrl = rpcNodeService.getHttpUrl()
// 获取 WebSocket RPC URL
val wsUrl = rpcNodeService.getWsUrl()
// 获取可用节点配置(包含完整信息)
val nodeResult = rpcNodeService.getAvailableNode()
if (nodeResult.isSuccess) {
val node = nodeResult.getOrNull()
val httpUrl = node?.httpUrl
val wsUrl = node?.wsUrl
}
```
**配置管理**
- **RPC 节点配置存储在数据库**(`rpc_node_config` 表),不从配置文件读取
- 支持多个节点配置,按优先级选择
- 支持健康检查,自动选择可用节点
- 前端可以通过系统设置页面配置 RPC 节点
- 配置变更后,WS 重连时会自动使用新配置
**配置字段**
- `httpUrl`: HTTP RPC 地址
- `wsUrl`: WebSocket RPC 地址(可选)
- `enabled`: 是否启用
- `priority`: 优先级(数字越小优先级越高)
- `lastCheckStatus`: 最后检查状态(HEALTHY/UNHEALTHY/UNKNOWN
### 4.2 WS 连接配置
```properties
# WS 连接超时(毫秒)
polygen.ws.connect.timeout=5000
# WS 重连延迟(毫秒)
polygen.ws.reconnect.delay=3000
```
### 4.3 WS 重连配置
```properties
# WS 重连延迟(毫秒)
polygen.ws.reconnect.delay=3000
# WS 重连最大延迟(毫秒,指数退避上限)
polygen.ws.reconnect.max.delay=60000
# WS 重连是否启用指数退避
polygen.ws.reconnect.exponential.backoff=true
```
### 4.4 合约地址配置
```properties
# USDC 合约地址
usdc.contract.address=0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174
# ERC1155 合约地址(Polymarket
erc1155.contract.address=0x4d97dcd97ec945f40cf65f87097ace5ea0476045
```
## 五、并行方案优势
### 5.1 双重保障
- **实时性**:WS 提供秒级实时通知
- **可靠性**:轮询作为备份,确保不遗漏交易
- **容错性**:WS 断开时轮询继续工作,WS 恢复后自动继续
### 5.2 性能优化
- **最快响应**:哪个数据先返回就用哪个
- **负载均衡**:两种方式并行,减少单点压力
- **去重保护**:确保同一笔交易只处理一次
### 5.3 实现简单
- **无需切换逻辑**:两种方式始终运行
- **独立管理**:WS 和轮询各自管理自己的状态
- **易于维护**:逻辑清晰,易于调试
## 六、注意事项
1. **WS 重连策略**
- WS 断开时**不断重试**,不停止轮询
- 使用指数退避策略,避免频繁重连
- 记录重连日志,便于监控
2. **去重机制**
- 使用 `leaderId + transactionHash` 作为唯一标识
-`processTrade()` 方法内部统一处理去重
- 使用数据库唯一约束确保并发安全
3. **性能考虑**
- WS 模式下需要为每个 Leader 订阅日志
- 大量 Leader 时可能需要优化订阅策略(批量订阅)
- 轮询间隔可以适当调整(默认 2 秒)
4. **错误处理**
- WS 连接失败不影响轮询服务
- 记录详细的错误日志,便于排查问题
- 两种方式的错误独立处理,互不影响
5. **元数据补齐**
- 链上数据不包含市场元数据(conditionId、outcomeIndex 等)
- 需要通过 Gamma API 或内部元数据服务补齐
- 轮询数据已包含元数据,无需额外补齐
6. **数据源标识**
- WS 数据:`source = "onchain-ws"`
- 轮询数据:`source = "polling"`
- 便于统计和分析不同数据源的处理情况
## 七、实现要点
### 7.1 主服务启动
```kotlin
@Service
class CopyTradingMonitorService(
private val rpcNodeService: RpcNodeService,
private val onChainWsService: OnChainWsService,
private val pollingService: CopyTradingPollingService
) {
@PostConstruct
fun init() {
scope.launch {
// 同时启动两种监听方式
launch { onChainWsService.start() } // WS 监听(独立协程)
launch { pollingService.start() } // 轮询监听(独立协程)
}
}
}
```
### 7.2 WS 服务实现(从后台配置获取 RPC)
```kotlin
@Service
class OnChainWsService(
private val rpcNodeService: RpcNodeService,
private val copyOrderTrackingService: CopyOrderTrackingService
) {
suspend fun start() {
while (isActive) {
try {
// 从后台配置获取 WS RPC URL
val wsUrl = rpcNodeService.getWsUrl()
val httpUrl = rpcNodeService.getHttpUrl()
// 连接并订阅
connectAndSubscribe(wsUrl, httpUrl)
// 连接成功后持续监听
waitForDisconnect()
} catch (e: Exception) {
logger.warn("WS 连接失败,等待重连: ${e.message}")
delay(reconnectDelay)
}
}
}
private suspend fun connectAndSubscribe(wsUrl: String, httpUrl: String) {
// 使用 wsUrl 和 httpUrl 连接和订阅
// ...
}
}
```
**关键点**
- 每次重连时都从 `RpcNodeService` 获取最新的 RPC 配置
- 如果配置变更,下次重连时会自动使用新配置
- 支持多个节点配置,自动选择可用节点
### 7.3 RPC 配置变更处理
**配置变更时的处理**
- WS 连接断开时,下次重连会从 `RpcNodeService` 获取最新配置
- 如果当前使用的节点不可用,`getAvailableNode()` 会自动选择下一个可用节点
- 支持动态切换节点,无需重启服务
**节点选择策略**
1. 优先使用 `lastCheckStatus = HEALTHY` 的节点
2.`priority` 排序,数字越小优先级越高
3. 如果所有节点都不可用,返回失败(不降级到默认节点,因为默认节点可能也不可用)
### 7.4 线程安全保证(单实例推荐方案)
`processTrade` 方法需要保证线程安全,因为:
- WS 和轮询可能同时处理同一笔交易
- 多个协程可能并发调用 `processTrade`
- 需要确保同一笔交易只处理一次
**推荐方案:使用 Mutex(应用级锁)**
对于单实例部署,**最轻量的方案是使用 Kotlin 协程的 Mutex**
- ✅ 无需额外依赖(不需要 Redis)
- ✅ 性能开销小(内存锁,无网络开销)
- ✅ 实现简单(Kotlin 标准库)
- ✅ 协程友好(支持 suspend 函数)
**实现代码**
```kotlin
@Service
open class CopyOrderTrackingService(
// ... 其他依赖
) {
// 使用 Mutex 保证线程安全(按交易ID锁定)
private val tradeMutexMap = ConcurrentHashMap<String, Mutex>()
/**
* 获取或创建 Mutex(按交易ID)
*/
private fun getMutex(leaderId: Long, tradeId: String): Mutex {
val key = "${leaderId}_${tradeId}"
return tradeMutexMap.getOrPut(key) { Mutex() }
}
/**
* 清理不再使用的 Mutex(可选,避免内存泄漏)
*/
private fun cleanupMutex(leaderId: Long, tradeId: String) {
val key = "${leaderId}_${tradeId}"
// 延迟清理,避免频繁创建/删除
// 可以定期清理或使用 WeakReference
}
/**
* 处理交易事件(WebSocket 或轮询)
* 使用 Mutex 保证线程安全
*/
@Transactional
suspend fun processTrade(leaderId: Long, trade: TradeResponse, source: String): Result<Unit> {
// 获取该交易的 Mutex
val mutex = getMutex(leaderId, trade.id)
return mutex.withLock {
try {
// 1. 检查是否已处理(去重)
val existingProcessed = processedTradeRepository.findByLeaderIdAndLeaderTradeId(
leaderId,
trade.id
)
if (existingProcessed != null) {
if (existingProcessed.status == "FAILED") {
return@withLock Result.success(Unit)
}
return@withLock Result.success(Unit)
}
// 检查是否已记录为失败交易
val failedTrade = failedTradeRepository.findByLeaderIdAndLeaderTradeId(
leaderId,
trade.id
)
if (failedTrade != null) {
return@withLock Result.success(Unit)
}
// 2. 处理交易逻辑
val result = when (trade.side.uppercase()) {
"BUY" -> processBuyTrade(leaderId, trade)
"SELL" -> processSellTrade(leaderId, trade)
else -> {
logger.warn("未知的交易方向: ${trade.side}")
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
}
}
if (result.isFailure) {
logger.error(
"处理交易失败: leaderId=$leaderId, tradeId=${trade.id}, side=${trade.side}",
result.exceptionOrNull()
)
return@withLock result
}
// 3. 标记为已处理(成功状态)
// 由于使用了 Mutex,这里不会出现并发冲突
try {
val processed = ProcessedTrade(
leaderId = leaderId,
leaderTradeId = trade.id,
tradeType = trade.side.uppercase(),
source = source,
status = "SUCCESS",
processedAt = System.currentTimeMillis()
)
processedTradeRepository.save(processed)
} catch (e: Exception) {
// 理论上不会发生,但保留异常处理作为兜底
if (isUniqueConstraintViolation(e)) {
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(
leaderId,
trade.id
)
if (existing != null) {
logger.debug("交易已处理(并发检测): leaderId=$leaderId, tradeId=${trade.id}")
return@withLock Result.success(Unit)
}
} else {
throw e
}
}
Result.success(Unit)
} catch (e: Exception) {
logger.error("处理交易异常: leaderId=$leaderId, tradeId=${trade.id}", e)
Result.failure(e)
}
}
}
}
```
**关键点**
1. **按交易ID锁定**:每个交易使用独立的 Mutex,不同交易可以并行处理
2. **Mutex 复用**:使用 `ConcurrentHashMap` 缓存 Mutex,避免频繁创建
3. **协程友好**`Mutex.withLock` 是 suspend 函数,不会阻塞线程
4. **性能优化**:只锁定同一笔交易,不影响其他交易的并发处理
**当前实现(基于数据库唯一约束,作为兜底)**
```kotlin
@Transactional
suspend fun processTrade(leaderId: Long, trade: TradeResponse, source: String): Result<Unit> {
// 1. 检查是否已处理(去重)
val existingProcessed = processedTradeRepository.findByLeaderIdAndLeaderTradeId(
leaderId,
trade.id
)
if (existingProcessed != null) {
return Result.success(Unit) // 已处理,跳过
}
// 2. 处理交易逻辑
val result = when (trade.side.uppercase()) {
"BUY" -> processBuyTrade(leaderId, trade)
"SELL" -> processSellTrade(leaderId, trade)
else -> Result.failure(IllegalArgumentException("未知的交易方向"))
}
if (result.isFailure) {
return result
}
// 3. 标记为已处理(使用数据库唯一约束保证并发安全)
try {
val processed = ProcessedTrade(
leaderId = leaderId,
leaderTradeId = trade.id,
tradeType = trade.side.uppercase(),
source = source,
status = "SUCCESS",
processedAt = System.currentTimeMillis()
)
processedTradeRepository.save(processed)
} catch (e: Exception) {
// 处理唯一约束冲突(并发情况下可能发生)
if (isUniqueConstraintViolation(e)) {
// 再次检查确认状态
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(
leaderId,
trade.id
)
if (existing != null) {
logger.debug("交易已处理(并发检测): leaderId=$leaderId, tradeId=${trade.id}")
return Result.success(Unit)
}
} else {
throw e
}
}
return Result.success(Unit)
}
```
**线程安全机制**
1. **数据库唯一约束**
- `UNIQUE KEY uk_leader_trade (leader_id, leader_trade_id)`
- 确保同一笔交易只能插入一次
- 即使两个线程同时通过检查,也只有一个能成功保存
2. **事务隔离**
- 使用 `@Transactional` 注解
- 默认隔离级别(通常是 READ_COMMITTED
- 保证事务内的数据一致性
3. **异常处理**
- 捕获唯一约束冲突异常
- 重新查询确认状态
- 避免重复处理
**潜在问题**
虽然数据库唯一约束可以防止重复插入,但在检查 `existingProcessed` 和保存 `processed` 之间存在时间窗口(TOCTOU),可能导致:
- 两个线程同时通过检查
- 两个线程都执行 `processBuyTrade``processSellTrade`
- 虽然只有一个能成功保存 `processed`,但可能创建了重复的订单
**其他方案(不推荐,仅作参考)**
**方案 1:使用数据库锁(SELECT FOR UPDATE**
- ❌ 需要修改 Repository 方法
- ❌ 增加数据库负载
- ❌ 不适合高并发场景
**方案 2:使用分布式锁(Redis,仅适用于多实例)**
- ❌ 需要 Redis 依赖
- ❌ 有网络开销
- ❌ 单实例场景不需要
**总结**
-**单实例部署推荐**:使用 **Mutex(应用级锁)**
- 最轻量:无需额外依赖,性能开销小
- 最简单:Kotlin 标准库,实现简单
- 最高效:内存锁,无网络开销,支持协程并发
-**不推荐**:仅依赖数据库唯一约束(存在 TOCTOU 问题,可能创建重复订单)
-**不推荐**:数据库锁或分布式锁(单实例场景过于复杂)
+835
View File
@@ -0,0 +1,835 @@
# Polymarket 聪明钱分析方案
## 1. 概述
聪明钱(Smart Money)分析是指识别和跟踪在 Polymarket 平台上表现优异的交易者,通过分析他们的交易行为、持仓和盈亏表现,来辅助投资决策。
## 2. 核心分析维度
### 2.1 交易表现指标
#### 2.1.1 胜率(Win Rate
- **定义**:盈利交易数 / 总交易数
- **计算方式**
- 通过 `getUserActivity` API 获取用户历史交易
- 筛选 `type = "TRADE"` 的活动
- 计算每笔交易的盈亏(通过买入价和卖出价)
- 统计盈利交易数和总交易数
#### 2.1.2 平均盈亏比(Average PnL Ratio
- **定义**:平均盈利金额 / 平均亏损金额
- **计算方式**
- 分别计算盈利交易和亏损交易的平均金额
- 计算比值
#### 2.1.3 总盈亏(Total PnL
- **定义**:所有已实现盈亏的总和
- **数据来源**
- 通过 `getPositions` API 获取 `realizedPnl`
- 或通过 `getUserActivity` API 计算历史交易的累计盈亏
#### 2.1.4 未实现盈亏(Unrealized PnL
- **定义**:当前持仓的浮动盈亏
- **数据来源**
- 通过 `getPositions` API 获取 `cashPnl`(当前盈亏)
- 或通过 `currentValue - initialValue` 计算
#### 2.1.5 收益率(Return Rate
- **定义**:总盈亏 / 总投入
- **计算方式**
- 总投入 = 所有买入交易的总金额
- 总盈亏 = 已实现盈亏 + 未实现盈亏
- 收益率 = 总盈亏 / 总投入
### 2.2 交易行为指标
#### 2.2.1 交易频率(Trading Frequency
- **定义**:单位时间内的交易次数
- **计算方式**
- 通过 `getUserActivity` API 获取指定时间范围内的交易数
- 计算日均/周均交易次数
#### 2.2.2 持仓周期(Holding Period
- **定义**:平均持仓时间
- **计算方式**
- 跟踪每笔买入和对应的卖出时间
- 计算平均持仓天数
#### 2.2.3 市场偏好(Market Preference
- **定义**:交易者偏好的市场类型
- **计算方式**
- 统计交易者在不同分类(sports、crypto)的交易分布
- 统计交易者偏好的市场主题
#### 2.2.4 仓位规模(Position Size
- **定义**:平均单笔交易金额
- **计算方式**
- 通过 `getUserActivity` API 获取 `usdcSize`
- 计算平均交易金额
### 2.3 风险指标
#### 2.3.1 最大回撤(Maximum Drawdown
- **定义**:从峰值到谷值的最大跌幅
- **计算方式**
- 跟踪账户价值的时序变化
- 计算每个峰值的回撤幅度
- 取最大值
#### 2.3.2 夏普比率(Sharpe Ratio
- **定义**:风险调整后的收益率
- **计算方式**
- 收益率标准差 / 平均收益率
- 需要足够的历史数据
#### 2.3.3 胜率稳定性(Win Rate Stability
- **定义**:不同时间段胜率的一致性
- **计算方式**
- 按时间段(如每月)计算胜率
- 计算胜率的方差或标准差
## 3. 数据收集方法
### 3.1 使用 Polymarket Data API
#### 3.1.1 获取用户仓位
```kotlin
// 接口:GET /positions
// 参数:
// - user: 用户钱包地址(必需)
// - market: 市场ID(可选)
// - limit: 限制数量(可选)
// - offset: 偏移量(可选)
// - sortBy: 排序字段(可选,如 "currentValue"
// - sortDirection: 排序方向(可选,如 "desc"
val positions = dataApi.getPositions(
user = walletAddress,
limit = 100,
sortBy = "currentValue",
sortDirection = "desc"
)
```
**返回数据包含**
- `currentValue`: 当前仓位价值
- `cashPnl`: 当前盈亏(未实现)
- `realizedPnl`: 已实现盈亏
- `percentPnl`: 盈亏百分比
- `avgPrice`: 平均买入价
- `curPrice`: 当前价格
#### 3.1.2 获取用户活动(交易历史)
```kotlin
// 接口:GET /activity
// 参数:
// - user: 用户钱包地址(必需)
// - type: 活动类型(可选,如 ["TRADE"]
// - side: 交易方向(可选,如 "BUY" 或 "SELL"
// - start: 开始时间戳(可选)
// - end: 结束时间戳(可选)
// - limit: 限制数量(可选)
// - offset: 偏移量(可选)
val activities = dataApi.getUserActivity(
user = walletAddress,
type = listOf("TRADE"),
side = "BUY",
start = startTimestamp,
end = endTimestamp,
limit = 1000
)
```
**返回数据包含**
- `type`: 活动类型(TRADE、SPLIT、MERGE、REDEEM等)
- `side`: 交易方向(BUY、SELL
- `size`: 交易数量
- `usdcSize`: 交易金额(USDC
- `price`: 交易价格
- `timestamp`: 交易时间戳
- `title`: 市场标题
- `slug`: 市场标识
#### 3.1.3 获取仓位总价值
```kotlin
// 接口:GET /value
// 参数:
// - user: 用户钱包地址(必需)
// - market: 市场ID列表(可选)
val totalValue = dataApi.getTotalValue(
user = walletAddress,
market = listOf("market1", "market2")
)
```
### 3.2 使用 Polymarket CLOB API
#### 3.2.1 获取交易记录
```kotlin
// 接口:GET /data/trades
// 参数:
// - maker_address: 交易者地址(可选)
// - market: 市场ID(可选)
// - before: 之前的时间戳(可选,用于分页)
// - after: 之后的时间戳(可选,用于分页)
// - next_cursor: 分页游标(可选)
val trades = clobApi.getTrades(
maker_address = walletAddress,
market = marketId,
after = startTimestamp.toString()
)
```
**返回数据包含**
- `id`: 交易ID
- `market`: 市场ID
- `side`: 交易方向(BUY、SELL
- `price`: 交易价格
- `size`: 交易数量
- `timestamp`: 交易时间戳
- `user`: 交易者地址
## 4. 聪明钱识别算法
### 4.1 基础筛选条件
#### 4.1.1 最低交易次数
- **条件**:总交易数 >= 50
- **目的**:确保有足够的数据进行统计分析
#### 4.1.2 最低胜率
- **条件**:胜率 >= 55%
- **目的**:筛选出表现优于随机交易者
#### 4.1.3 最低总盈亏
- **条件**:总盈亏 >= 1000 USDC
- **目的**:筛选出有实际盈利能力的交易者
#### 4.1.4 最低收益率
- **条件**:收益率 >= 20%
- **目的**:筛选出有良好回报的交易者
### 4.2 综合评分算法
```kotlin
// 聪明钱评分算法
fun calculateSmartMoneyScore(
winRate: Double, // 胜率(0-1
totalPnl: Double, // 总盈亏(USDC
returnRate: Double, // 收益率(0-1
tradeCount: Int, // 交易次数
avgPnlRatio: Double // 平均盈亏比
): Double {
// 权重配置
val winRateWeight = 0.3
val totalPnlWeight = 0.25
val returnRateWeight = 0.25
val tradeCountWeight = 0.1
val avgPnlRatioWeight = 0.1
// 归一化处理
val normalizedWinRate = winRate * 100 // 转换为百分比
val normalizedTotalPnl = min(totalPnl / 10000, 1.0) * 100 // 归一化到0-100
val normalizedReturnRate = returnRate * 100 // 转换为百分比
val normalizedTradeCount = min(tradeCount / 200, 1.0) * 100 // 归一化到0-100
val normalizedAvgPnlRatio = min(avgPnlRatio / 3.0, 1.0) * 100 // 归一化到0-100
// 加权求和
val score = normalizedWinRate * winRateWeight +
normalizedTotalPnl * totalPnlWeight +
normalizedReturnRate * returnRateWeight +
normalizedTradeCount * tradeCountWeight +
normalizedAvgPnlRatio * avgPnlRatioWeight
return score
}
```
### 4.3 排名算法
1. **按综合评分排序**:计算所有候选交易者的综合评分,按降序排列
2. **按分类排名**:分别计算 sports 和 crypto 分类的排名
3. **按时间段排名**:分别计算最近7天、30天、90天的排名
## 5. 实时监控方案
### 5.1 监控目标
1. **新交易**:监控聪明钱交易者的新买入/卖出交易
2. **持仓变化**:监控聪明钱交易者的持仓变化
3. **市场关注**:监控聪明钱交易者关注的新市场
### 5.2 实现方式
#### 5.2.1 使用 WebSocket(推荐)
- 订阅 Polymarket WebSocket 的 User Channel
- 监听 `event_type = "trade"` 事件
- 过滤出聪明钱交易者的交易
#### 5.2.2 使用轮询
- 定期调用 `getUserActivity` API(如每5分钟)
- 比较时间戳,识别新交易
- 使用 `after` 参数只获取新数据
### 5.3 跟单集成
聪明钱分析可以与现有的跟单系统集成:
1. **自动添加 Leader**:识别到聪明钱交易者后,自动添加到 Leader 列表
2. **智能跟单**:根据聪明钱交易者的表现,动态调整跟单比例
3. **风险控制**:根据聪明钱交易者的风险指标,设置跟单限制
## 6. 实现示例
### 6.1 聪明钱分析服务
```kotlin
@Service
class SmartMoneyAnalysisService(
private val retrofitFactory: RetrofitFactory,
private val blockchainService: BlockchainService
) {
private val logger = LoggerFactory.getLogger(SmartMoneyAnalysisService::class.java)
private val dataApi = retrofitFactory.createDataApi()
/**
* 分析单个交易者的表现
*/
suspend fun analyzeTrader(walletAddress: String, days: Int = 90): Result<TraderAnalysis> {
return try {
val endTime = System.currentTimeMillis()
val startTime = endTime - (days * 24 * 60 * 60 * 1000L)
// 1. 获取交易历史
val activitiesResult = getTradeActivities(walletAddress, startTime, endTime)
if (activitiesResult.isFailure) {
return Result.failure(activitiesResult.exceptionOrNull() ?: Exception("获取交易历史失败"))
}
val activities = activitiesResult.getOrNull() ?: emptyList()
// 2. 获取当前仓位
val positionsResult = blockchainService.getPositions(walletAddress)
val positions = if (positionsResult.isSuccess) {
positionsResult.getOrNull() ?: emptyList()
} else {
emptyList()
}
// 3. 计算指标
val metrics = calculateMetrics(activities, positions)
// 4. 计算综合评分
val score = calculateSmartMoneyScore(
winRate = metrics.winRate,
totalPnl = metrics.totalPnl,
returnRate = metrics.returnRate,
tradeCount = metrics.tradeCount,
avgPnlRatio = metrics.avgPnlRatio
)
Result.success(
TraderAnalysis(
walletAddress = walletAddress,
metrics = metrics,
score = score,
positions = positions.size,
lastTradeTime = activities.maxByOrNull { it.timestamp }?.timestamp
)
)
} catch (e: Exception) {
logger.error("分析交易者失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 获取交易活动
*/
private suspend fun getTradeActivities(
walletAddress: String,
startTime: Long,
endTime: Long
): Result<List<UserActivityResponse>> {
return try {
val response = dataApi.getUserActivity(
user = walletAddress,
type = listOf("TRADE"),
start = startTime,
end = endTime,
limit = 1000,
sortBy = "timestamp",
sortDirection = "desc"
)
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("获取交易活动失败: ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.error("获取交易活动异常: ${e.message}", e)
Result.failure(e)
}
}
/**
* 计算交易指标
*/
private fun calculateMetrics(
activities: List<UserActivityResponse>,
positions: List<PositionResponse>
): TraderMetrics {
// 分离买入和卖出交易
val buyTrades = activities.filter { it.side == "BUY" }
val sellTrades = activities.filter { it.side == "SELL" }
// 计算总交易数
val tradeCount = activities.size
// 计算总投入(买入金额总和)
val totalInvested = buyTrades.sumOf { it.usdcSize ?: 0.0 }
// 计算已实现盈亏(从仓位数据)
val realizedPnl = positions.sumOf { it.realizedPnl ?: 0.0 }
// 计算未实现盈亏(从仓位数据)
val unrealizedPnl = positions.sumOf { it.cashPnl ?: 0.0 }
// 计算总盈亏
val totalPnl = realizedPnl + unrealizedPnl
// 计算收益率
val returnRate = if (totalInvested > 0) {
totalPnl / totalInvested
} else {
0.0
}
// 计算胜率(需要匹配买入和卖出交易)
val winRate = calculateWinRate(buyTrades, sellTrades)
// 计算平均盈亏比
val avgPnlRatio = calculateAvgPnlRatio(buyTrades, sellTrades)
return TraderMetrics(
tradeCount = tradeCount,
totalInvested = totalInvested,
totalPnl = totalPnl,
realizedPnl = realizedPnl,
unrealizedPnl = unrealizedPnl,
returnRate = returnRate,
winRate = winRate,
avgPnlRatio = avgPnlRatio
)
}
/**
* 计算胜率
* 通过匹配买入和卖出交易来计算
*/
private fun calculateWinRate(
buyTrades: List<UserActivityResponse>,
sellTrades: List<UserActivityResponse>
): Double {
// 按市场分组买入和卖出交易
val buyByMarket = buyTrades.groupBy { it.conditionId }
val sellByMarket = sellTrades.groupBy { it.conditionId }
var winCount = 0
var totalCount = 0
// 遍历每个市场
buyByMarket.forEach { (marketId, buys) ->
val sells = sellByMarket[marketId] ?: emptyList()
// 简单匹配:按时间顺序匹配买入和卖出
// 实际应该使用更精确的匹配算法(如 FIFO)
var buyIndex = 0
var sellIndex = 0
while (buyIndex < buys.size && sellIndex < sells.size) {
val buy = buys[buyIndex]
val sell = sells[sellIndex]
// 计算盈亏
val buyPrice = buy.price ?: 0.0
val sellPrice = sell.price ?: 0.0
val pnl = (sellPrice - buyPrice) * (buy.size ?: 0.0)
if (pnl > 0) {
winCount++
}
totalCount++
buyIndex++
sellIndex++
}
}
return if (totalCount > 0) {
winCount.toDouble() / totalCount
} else {
0.0
}
}
/**
* 计算平均盈亏比
*/
private fun calculateAvgPnlRatio(
buyTrades: List<UserActivityResponse>,
sellTrades: List<UserActivityResponse>
): Double {
// 类似胜率计算,分别计算盈利和亏损的平均金额
val buyByMarket = buyTrades.groupBy { it.conditionId }
val sellByMarket = sellTrades.groupBy { it.conditionId }
val profits = mutableListOf<Double>()
val losses = mutableListOf<Double>()
buyByMarket.forEach { (marketId, buys) ->
val sells = sellByMarket[marketId] ?: emptyList()
var buyIndex = 0
var sellIndex = 0
while (buyIndex < buys.size && sellIndex < sells.size) {
val buy = buys[buyIndex]
val sell = sells[sellIndex]
val buyPrice = buy.price ?: 0.0
val sellPrice = sell.price ?: 0.0
val pnl = (sellPrice - buyPrice) * (buy.size ?: 0.0)
if (pnl > 0) {
profits.add(pnl)
} else if (pnl < 0) {
losses.add(-pnl)
}
buyIndex++
sellIndex++
}
}
val avgProfit = if (profits.isNotEmpty()) {
profits.average()
} else {
0.0
}
val avgLoss = if (losses.isNotEmpty()) {
losses.average()
} else {
0.0
}
return if (avgLoss > 0) {
avgProfit / avgLoss
} else {
if (avgProfit > 0) Double.MAX_VALUE else 0.0
}
}
/**
* 计算聪明钱评分
*/
private fun calculateSmartMoneyScore(
winRate: Double,
totalPnl: Double,
returnRate: Double,
tradeCount: Int,
avgPnlRatio: Double
): Double {
val winRateWeight = 0.3
val totalPnlWeight = 0.25
val returnRateWeight = 0.25
val tradeCountWeight = 0.1
val avgPnlRatioWeight = 0.1
val normalizedWinRate = winRate * 100
val normalizedTotalPnl = min(totalPnl / 10000, 1.0) * 100
val normalizedReturnRate = returnRate * 100
val normalizedTradeCount = min(tradeCount / 200.0, 1.0) * 100
val normalizedAvgPnlRatio = min(avgPnlRatio / 3.0, 1.0) * 100
val score = normalizedWinRate * winRateWeight +
normalizedTotalPnl * totalPnlWeight +
normalizedReturnRate * returnRateWeight +
normalizedTradeCount * tradeCountWeight +
normalizedAvgPnlRatio * avgPnlRatioWeight
return score
}
/**
* 批量分析交易者
*/
suspend fun analyzeTraders(
walletAddresses: List<String>,
days: Int = 90
): Result<List<TraderAnalysis>> {
return try {
val analyses = walletAddresses.mapNotNull { address ->
analyzeTrader(address, days).getOrNull()
}
Result.success(analyses.sortedByDescending { it.score })
} catch (e: Exception) {
logger.error("批量分析交易者失败: ${e.message}", e)
Result.failure(e)
}
}
}
/**
* 交易者分析结果
*/
data class TraderAnalysis(
val walletAddress: String,
val metrics: TraderMetrics,
val score: Double,
val positions: Int,
val lastTradeTime: Long?
)
/**
* 交易者指标
*/
data class TraderMetrics(
val tradeCount: Int,
val totalInvested: Double,
val totalPnl: Double,
val realizedPnl: Double,
val unrealizedPnl: Double,
val returnRate: Double,
val winRate: Double,
val avgPnlRatio: Double
)
```
### 6.2 聪明钱排名服务
```kotlin
@Service
class SmartMoneyRankingService(
private val smartMoneyAnalysisService: SmartMoneyAnalysisService
) {
private val logger = LoggerFactory.getLogger(SmartMoneyRankingService::class.java)
/**
* 获取聪明钱排名
*/
suspend fun getRankings(
category: String? = null, // sports 或 crypto
days: Int = 90,
limit: Int = 100
): Result<List<TraderRanking>> {
return try {
// 1. 获取候选交易者列表
// 这里需要从某个数据源获取(如数据库、API等)
val candidates = getCandidateTraders(category)
// 2. 批量分析交易者
val analysesResult = smartMoneyAnalysisService.analyzeTraders(candidates, days)
if (analysesResult.isFailure) {
return Result.failure(analysesResult.exceptionOrNull() ?: Exception("分析失败"))
}
val analyses = analysesResult.getOrNull() ?: emptyList()
// 3. 筛选和排序
val rankings = analyses
.filter { it.metrics.tradeCount >= 50 } // 最低交易次数
.filter { it.metrics.winRate >= 0.55 } // 最低胜率
.filter { it.metrics.totalPnl >= 1000 } // 最低总盈亏
.sortedByDescending { it.score }
.take(limit)
.mapIndexed { index, analysis ->
TraderRanking(
rank = index + 1,
walletAddress = analysis.walletAddress,
score = analysis.score,
metrics = analysis.metrics,
positions = analysis.positions,
lastTradeTime = analysis.lastTradeTime
)
}
Result.success(rankings)
} catch (e: Exception) {
logger.error("获取排名失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 获取候选交易者列表
* 这里需要实现具体的获取逻辑(如从数据库、API等)
*/
private suspend fun getCandidateTraders(category: String?): List<String> {
// TODO: 实现获取候选交易者的逻辑
// 可以从以下来源获取:
// 1. 数据库中的 Leader 列表
// 2. Polymarket 的公开数据
// 3. 用户提交的交易者地址
return emptyList()
}
}
/**
* 交易者排名
*/
data class TraderRanking(
val rank: Int,
val walletAddress: String,
val score: Double,
val metrics: TraderMetrics,
val positions: Int,
val lastTradeTime: Long?
)
```
## 7. 数据存储建议
### 7.1 数据库表设计
```sql
-- 聪明钱交易者表
CREATE TABLE smart_money_traders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
wallet_address VARCHAR(42) NOT NULL UNIQUE,
score DOUBLE NOT NULL,
win_rate DOUBLE NOT NULL,
total_pnl DECIMAL(20, 8) NOT NULL,
return_rate DOUBLE NOT NULL,
trade_count INT NOT NULL,
category VARCHAR(20), -- sports 或 crypto
last_analysis_time BIGINT NOT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
INDEX idx_score (score DESC),
INDEX idx_category (category),
INDEX idx_last_analysis_time (last_analysis_time)
);
-- 交易者历史指标表(用于追踪指标变化)
CREATE TABLE trader_metrics_history (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
wallet_address VARCHAR(42) NOT NULL,
win_rate DOUBLE NOT NULL,
total_pnl DECIMAL(20, 8) NOT NULL,
return_rate DOUBLE NOT NULL,
trade_count INT NOT NULL,
recorded_at BIGINT NOT NULL,
INDEX idx_wallet_address (wallet_address),
INDEX idx_recorded_at (recorded_at)
);
```
### 7.2 缓存策略
- **Redis 缓存**:缓存聪明钱排名列表,减少数据库查询
- **缓存过期时间**:建议 1 小时
- **缓存键**`smart_money:rankings:{category}:{days}`
## 8. API 接口设计
### 8.1 获取聪明钱排名
```kotlin
@PostMapping("/smart-money/rankings")
fun getRankings(@RequestBody request: SmartMoneyRankingsRequest): ResponseEntity<ApiResponse<SmartMoneyRankingsResponse>> {
// 实现逻辑
}
```
**请求参数**
```json
{
"category": "sports", // 可选:sports 或 crypto
"days": 90, // 可选:分析时间范围(天)
"limit": 100, // 可选:返回数量
"minScore": 50 // 可选:最低评分
}
```
**响应数据**
```json
{
"code": 0,
"data": {
"rankings": [
{
"rank": 1,
"walletAddress": "0x...",
"score": 85.5,
"metrics": {
"tradeCount": 150,
"winRate": 0.65,
"totalPnl": 5000.0,
"returnRate": 0.35,
"avgPnlRatio": 2.5
},
"positions": 10,
"lastTradeTime": 1234567890
}
],
"total": 100
},
"msg": ""
}
```
### 8.2 分析单个交易者
```kotlin
@PostMapping("/smart-money/analyze")
fun analyzeTrader(@RequestBody request: SmartMoneyAnalyzeRequest): ResponseEntity<ApiResponse<TraderAnalysisDto>> {
// 实现逻辑
}
```
**请求参数**
```json
{
"walletAddress": "0x...",
"days": 90
}
```
## 9. 注意事项
### 9.1 API 限制
- **Data API 速率限制**:注意 API 调用频率,避免触发限流
- **数据延迟**:Data API 的数据可能有延迟,不是实时的
- **数据完整性**:某些历史数据可能不完整,需要处理缺失数据
### 9.2 计算精度
- **价格精度**Polymarket 使用 0.01-0.99 的价格范围,注意精度问题
- **金额精度**:使用 `BigDecimal` 进行金额计算,避免浮点数误差
- **时间精度**:注意时间戳的精度(毫秒 vs 秒)
### 9.3 性能优化
- **批量查询**:尽量批量查询多个交易者的数据
- **缓存策略**:缓存分析结果,避免重复计算
- **异步处理**:使用异步任务处理大量数据分析
### 9.4 数据质量
- **数据验证**:验证 API 返回的数据完整性
- **异常处理**:处理 API 调用失败的情况
- **数据清洗**:清洗异常数据(如价格为 0、数量为负数等)
## 10. 后续优化方向
1. **机器学习模型**:使用机器学习模型预测交易者未来表现
2. **实时监控**:集成 WebSocket 实现实时监控聪明钱交易
3. **跟单推荐**:根据聪明钱分析结果,推荐适合跟单的交易者
4. **风险预警**:监控聪明钱交易者的风险指标,及时预警
5. **多维度分析**:增加更多分析维度(如市场类型、时间分布等)