Compare commits
6 Commits
v1.1.6
...
release/v1.1.7
| Author | SHA1 | Date | |
|---|---|---|---|
| 018697935a | |||
| 53ec252609 | |||
| 6a0fcdaef2 | |||
| 9a00bb19dc | |||
| 3d603cac32 | |||
| f257457ad3 |
@@ -2,6 +2,7 @@
|
||||
|
||||
[](https://github.com/WrBug/PolyHermes)
|
||||
[](https://x.com/polyhermes)
|
||||
[](https://hub.docker.com/r/wrbug/polyhermes)
|
||||
|
||||
> 🌐 **Language**: [English](README_EN.md) | 中文
|
||||
|
||||
@@ -432,6 +433,7 @@ cd frontend
|
||||
|
||||
- [GitHub 仓库](https://github.com/WrBug/PolyHermes)
|
||||
- [Twitter](https://x.com/polyhermes)
|
||||
- [Telegram 群组](https://t.me/polyhermes)
|
||||
- [Polymarket 官网](https://polymarket.com)
|
||||
- [Polymarket API 文档](https://docs.polymarket.com)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
[](https://github.com/WrBug/PolyHermes)
|
||||
[](https://x.com/polyhermes)
|
||||
[](https://hub.docker.com/r/wrbug/polyhermes)
|
||||
|
||||
> 🌐 **Language**: English | [中文](README.md)
|
||||
|
||||
@@ -432,6 +433,7 @@ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file
|
||||
|
||||
- [GitHub Repository](https://github.com/WrBug/PolyHermes)
|
||||
- [Twitter](https://x.com/polyhermes)
|
||||
- [Telegram Group](https://t.me/polyhermes)
|
||||
- [Polymarket Official Website](https://polymarket.com)
|
||||
- [Polymarket API Documentation](https://docs.polymarket.com)
|
||||
|
||||
|
||||
+150
-2
@@ -1,3 +1,151 @@
|
||||
# v1.1.7
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### 💰 Polymarket Maker Rebates Program 费率支持
|
||||
|
||||
- **新增费率查询 API 接口** (`getFeeRate`)
|
||||
- 支持动态查询 Maker Rebates Program 费率
|
||||
- 修正 API 返回字段名:使用 `base_fee` 而非 `fee_rate_bps`(与 TypeScript clob-client 一致)
|
||||
|
||||
- **动态费率获取**
|
||||
- 在所有订单创建处动态获取费率:
|
||||
* 跟单买入订单 (`processBuyTrade`)
|
||||
* 跟单卖出订单 (`matchSellOrder`)
|
||||
* 账户卖出订单 (`sellPosition`)
|
||||
- 费率获取失败时降级到默认值 "0",确保系统可用性
|
||||
- 添加详细的日志记录,便于监控和调试
|
||||
|
||||
- **参考文档**: https://docs.polymarket.com/developers/market-makers/maker-rebates-program
|
||||
|
||||
### 🔧 Docker 部署优化
|
||||
|
||||
- **日志级别环境变量支持**
|
||||
- 在 `application.properties` 中支持通过 `LOG_LEVEL_ROOT` 和 `LOG_LEVEL_APP` 环境变量配置日志级别
|
||||
- 在 `docker-compose.yml` 和 `docker-compose.prod.yml` 中添加日志级别环境变量配置
|
||||
- 在 `deploy.sh` 的 `.env` 模板中添加日志级别配置说明
|
||||
- 支持通过环境变量动态配置日志级别,无需修改配置文件
|
||||
- 默认值:`root=INFO`, `app=DEBUG`
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复市场条件查询的 RPC 调用错误
|
||||
|
||||
- **问题**:使用错误的函数签名 `conditions(bytes32)` 导致 RPC 调用失败(execution reverted)
|
||||
- **修复**:
|
||||
- 将错误的 `conditions(bytes32)` 函数调用改为正确的 `getOutcomeSlotCount(bytes32)` 和 `payoutDenominator(bytes32)` 函数调用
|
||||
- 修复 `BlockchainService.getCondition` 方法,使用正确的 ConditionalTokens 合约函数签名
|
||||
- 改进 `MarketPriceService` 的错误处理:当链上查询出现 RPC 错误时,降级到 CLOB API 或 Gamma API 查询,而不是直接抛出异常,提高容错性
|
||||
|
||||
### 修复 RPC 错误时误创建自动卖出记录的问题
|
||||
|
||||
- **问题**:当链上查询市场条件出现 RPC 错误(execution reverted)时,系统会误判为市场已卖出,创建错误的自动卖出记录
|
||||
- **修复**:
|
||||
- 修改 `getPriceFromChainCondition` 返回 `Pair<BigDecimal?, Boolean>`,第二个值表示是否发生 RPC 错误
|
||||
- 在 `getCurrentMarketPrice` 中检测到 RPC 错误时抛出异常,`PositionCheckService` 会捕获并跳过该市场的处理
|
||||
- 避免在市场不存在或尚未创建时误判为已卖出
|
||||
|
||||
## 📝 文档更新
|
||||
|
||||
### 更新 Telegram 群链接
|
||||
|
||||
- 将所有 Telegram 群链接统一更新为 `t.me/polyhermes`
|
||||
- 更新了以下文件:
|
||||
- `frontend/src/components/Layout.tsx` - 桌面端和移动端导航链接
|
||||
- `RELEASE.md` - 相关链接
|
||||
- `README.md` 和 `README_EN.md` - 相关链接部分
|
||||
|
||||
### 添加 Docker 版本徽章
|
||||
|
||||
- 在 README 和 README_EN.md 中添加动态 Docker 版本徽章
|
||||
- 使用 shields.io 自动显示 Docker Hub 上 `wrbug/polyhermes` 镜像的最新版本
|
||||
- 版本信息自动更新,无需手动维护
|
||||
|
||||
## 📊 变更统计
|
||||
|
||||
- **提交数量**:5 个提交
|
||||
- **文件变更**:16 个文件
|
||||
- **代码变更**:+205 行 / -886 行(净减少 681 行)
|
||||
|
||||
### 详细文件变更
|
||||
|
||||
**后端变更**:
|
||||
- `PolymarketClobApi.kt` - 添加费率查询接口(+25 行)
|
||||
- `AccountService.kt` - 在订单创建处添加动态费率获取(+11 行)
|
||||
- `BlockchainService.kt` - 修复市场条件查询的 RPC 调用错误(+84 行)
|
||||
- `MarketPriceService.kt` - 改进错误处理,支持降级到其他数据源(+36 行)
|
||||
- `PolymarketClobService.kt` - 添加费率查询服务(+32 行)
|
||||
- `CopyOrderTrackingService.kt` - 在跟单订单创建处添加费率获取(+34 行)
|
||||
- `PositionCheckService.kt` - 修复 RPC 错误处理逻辑(+2 行)
|
||||
- `application.properties` - 添加日志级别环境变量支持(+6 行)
|
||||
|
||||
**前端变更**:
|
||||
- `Layout.tsx` - 更新 Telegram 群链接(+4 行)
|
||||
|
||||
**配置文件变更**:
|
||||
- `docker-compose.yml` - 添加日志级别环境变量(+4 行)
|
||||
- `docker-compose.prod.yml` - 添加日志级别环境变量(+4 行)
|
||||
- `deploy.sh` - 添加日志级别配置说明(+5 行)
|
||||
|
||||
**文档变更**:
|
||||
- `README.md` - 更新 Telegram 链接,添加 Docker 版本徽章(+2 行)
|
||||
- `README_EN.md` - 更新 Telegram 链接,添加 Docker 版本徽章(+2 行)
|
||||
- `RELEASE.md` - 更新 Telegram 链接(+4 行)
|
||||
- `docs/zh/smart-money-analysis.md` - 删除文档(-836 行)
|
||||
|
||||
## 🔧 技术细节
|
||||
|
||||
### API 变更
|
||||
|
||||
- **新增接口**:
|
||||
- `POST /api/clob/fee-rate` - 获取 Maker Rebates Program 费率(内部使用)
|
||||
- **无移除接口**
|
||||
|
||||
### 环境变量变更
|
||||
|
||||
- **新增环境变量**:
|
||||
- `LOG_LEVEL_ROOT` - Root 日志级别(默认:INFO)
|
||||
- `LOG_LEVEL_APP` - 应用日志级别(默认:DEBUG)
|
||||
|
||||
### 合约调用修复
|
||||
|
||||
- **修复的函数调用**:
|
||||
- 从 `conditions(bytes32)` 改为 `getOutcomeSlotCount(bytes32)` 和 `payoutDenominator(bytes32)`
|
||||
- 使用正确的 ConditionalTokens 合约函数签名
|
||||
- 参考:https://polygonscan.com/address/0x4d97dcd97ec945f40cf65f87097ace5ea0476045#code
|
||||
|
||||
## 📝 升级说明
|
||||
|
||||
### 数据库升级
|
||||
|
||||
- **无需数据库迁移**:本次更新不涉及数据库结构变更
|
||||
|
||||
### 配置更新
|
||||
|
||||
- **可选配置**:新增日志级别环境变量,如不配置将使用默认值
|
||||
- `LOG_LEVEL_ROOT=INFO`(默认)
|
||||
- `LOG_LEVEL_APP=DEBUG`(默认)
|
||||
|
||||
### Docker 部署
|
||||
|
||||
- **推荐更新**:使用 Docker Hub 镜像部署的用户,建议更新到最新版本
|
||||
```bash
|
||||
docker pull wrbug/polyhermes:latest
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- **GitHub 仓库**:https://github.com/WrBug/PolyHermes
|
||||
- **Twitter**:@polyhermes
|
||||
- **Telegram 群组**:https://t.me/polyhermes
|
||||
|
||||
---
|
||||
|
||||
**发布日期**:2026-01-07
|
||||
|
||||
---
|
||||
|
||||
# v1.1.5
|
||||
|
||||
## 🔧 功能优化与改进
|
||||
@@ -232,7 +380,7 @@ docker pull wrbug/polyhermes:v1.1.2
|
||||
|
||||
* **GitHub 仓库**:https://github.com/WrBug/PolyHermes
|
||||
* **Twitter**:@polyhermes
|
||||
* **Telegram 群组**:加入群组
|
||||
* **Telegram 群组**:https://t.me/polyhermes
|
||||
|
||||
---
|
||||
|
||||
@@ -399,7 +547,7 @@ docker pull wrbug/polyhermes:v1.1.1
|
||||
|
||||
* **GitHub 仓库**:https://github.com/WrBug/PolyHermes
|
||||
* **Twitter**:@polyhermes
|
||||
* **Telegram 群组**:加入群组
|
||||
* **Telegram 群组**:https://t.me/polyhermes
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -149,6 +149,19 @@ interface PolymarketClobApi {
|
||||
@GET("/auth/derive-api-key")
|
||||
suspend fun deriveApiKey(): Response<ApiKeyResponse>
|
||||
|
||||
/**
|
||||
* 获取费率
|
||||
* 文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program#1-fetch-the-fee-rate
|
||||
* 端点: GET /fee-rate
|
||||
*
|
||||
* @param tokenId Token ID
|
||||
* @return 费率响应
|
||||
*/
|
||||
@GET("/fee-rate")
|
||||
suspend fun getFeeRate(
|
||||
@Query("token_id") tokenId: String
|
||||
): Response<FeeRateResponse>
|
||||
|
||||
/**
|
||||
* 获取服务器时间
|
||||
* 端点: /time
|
||||
@@ -357,6 +370,18 @@ data class ServerTimeResponse(
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 费率响应
|
||||
* 文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program#1-fetch-the-fee-rate
|
||||
*
|
||||
* 注意:根据 TypeScript clob-client 源码,API 返回的字段名是 base_fee,而不是文档中的 fee_rate_bps
|
||||
* 参考: clob-client/src/client.ts:312
|
||||
*/
|
||||
data class FeeRateResponse(
|
||||
@SerializedName("base_fee")
|
||||
val baseFee: Int // 费率基点(0 表示无费率,1000 表示 10%)
|
||||
)
|
||||
|
||||
/**
|
||||
* 最新价响应(从订单表获取)
|
||||
*/
|
||||
|
||||
@@ -863,6 +863,15 @@ class AccountService(
|
||||
// 7. 解密私钥
|
||||
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||
|
||||
// 获取费率(根据 Polymarket Maker Rebates Program 要求)
|
||||
val feeRateResult = clobService.getFeeRate(tokenId)
|
||||
val feeRateBps = if (feeRateResult.isSuccess) {
|
||||
feeRateResult.getOrNull()?.toString() ?: "0"
|
||||
} else {
|
||||
logger.warn("获取费率失败,使用默认值 0: tokenId=$tokenId, error=${feeRateResult.exceptionOrNull()?.message}")
|
||||
"0"
|
||||
}
|
||||
|
||||
// 11. 创建并签名订单(使用计算后的卖出数量)
|
||||
val signedOrder = try {
|
||||
orderSigningService.createAndSignOrder(
|
||||
@@ -874,7 +883,7 @@ class AccountService(
|
||||
size = sellQuantity.toPlainString(), // 使用计算后的卖出数量
|
||||
signatureType = 2, // Browser Wallet(与正确订单数据一致)
|
||||
nonce = "0",
|
||||
feeRateBps = "0",
|
||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||
expiration = expiration
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
|
||||
+1
-1
@@ -398,7 +398,7 @@ class PositionCheckService(
|
||||
} else {
|
||||
// 有仓位,按订单下单顺序(FIFO)更新状态
|
||||
// 计算逻辑:
|
||||
// 1. 总订单数量 = 所有未卖出订单的剩余数量总和
|
||||
// 1. 总订单数量 = 所有未卖出订单的剩余数量总和
|
||||
// 2. 已成交数量 = 总订单数量 - 仓位数量(因为还有仓位,说明部分订单已卖出)
|
||||
// 3. 如果已成交数量 = 0,说明订单还没有卖出,不修改订单状态
|
||||
// 4. 如果已成交数量 > 0,按FIFO顺序匹配订单
|
||||
|
||||
+57
-27
@@ -734,54 +734,84 @@ class BlockchainService(
|
||||
|
||||
val rpcApi = polygonRpcApi
|
||||
|
||||
// 1. 调用 conditions(bytes32) 获取 outcomeSlotCount 和 payoutDenominator
|
||||
// 注意:这是一个公开的 mapping,Solidity 自动生成的 getter
|
||||
// 函数签名: conditions(bytes32) returns (uint outcomeSlotCount, uint payoutDenominator)
|
||||
val conditionsFunctionSelector = EthereumUtils.getFunctionSelector("conditions(bytes32)")
|
||||
// 1. 调用 getOutcomeSlotCount(bytes32) 获取结果槽位数量
|
||||
// 函数签名: getOutcomeSlotCount(bytes32) returns (uint)
|
||||
val getOutcomeSlotCountSelector = EthereumUtils.getFunctionSelector("getOutcomeSlotCount(bytes32)")
|
||||
val encodedConditionId = EthereumUtils.encodeBytes32(conditionId)
|
||||
val conditionsData = conditionsFunctionSelector + encodedConditionId
|
||||
val outcomeSlotCountData = getOutcomeSlotCountSelector + encodedConditionId
|
||||
|
||||
// 构建 JSON-RPC 请求
|
||||
val conditionsRequest = JsonRpcRequest(
|
||||
val outcomeSlotCountRequest = JsonRpcRequest(
|
||||
method = "eth_call",
|
||||
params = listOf(
|
||||
mapOf(
|
||||
"to" to conditionalTokensAddress,
|
||||
"data" to conditionsData
|
||||
"data" to outcomeSlotCountData
|
||||
),
|
||||
"latest"
|
||||
)
|
||||
)
|
||||
|
||||
// 发送 RPC 请求
|
||||
val conditionsResponse = rpcApi.call(conditionsRequest)
|
||||
val outcomeSlotCountResponse = rpcApi.call(outcomeSlotCountRequest)
|
||||
|
||||
if (!conditionsResponse.isSuccessful || conditionsResponse.body() == null) {
|
||||
return Result.failure(Exception("RPC 请求失败 (conditions): ${conditionsResponse.code()} ${conditionsResponse.message()}"))
|
||||
if (!outcomeSlotCountResponse.isSuccessful || outcomeSlotCountResponse.body() == null) {
|
||||
return Result.failure(Exception("RPC 请求失败 (getOutcomeSlotCount): ${outcomeSlotCountResponse.code()} ${outcomeSlotCountResponse.message()}"))
|
||||
}
|
||||
|
||||
val conditionsRpcResponse = conditionsResponse.body()!!
|
||||
val outcomeSlotCountRpcResponse = outcomeSlotCountResponse.body()!!
|
||||
|
||||
// 检查错误
|
||||
if (conditionsRpcResponse.error != null) {
|
||||
// 记录完整的错误信息,包括 code 和 data
|
||||
val errorMsg = "RPC 错误 (code=${conditionsRpcResponse.error.code}): ${conditionsRpcResponse.error.message}, data=${conditionsRpcResponse.error.data}"
|
||||
logger.error("查询市场条件(conditions)出现RPC错误: conditionId=$conditionId, $errorMsg")
|
||||
logger.debug("RPC 请求详情: to=$conditionalTokensAddress, data=$conditionsData")
|
||||
if (outcomeSlotCountRpcResponse.error != null) {
|
||||
val errorMsg = "RPC 错误 (code=${outcomeSlotCountRpcResponse.error.code}): ${outcomeSlotCountRpcResponse.error.message}, data=${outcomeSlotCountRpcResponse.error.data}"
|
||||
logger.warn("查询市场条件(getOutcomeSlotCount)出现RPC错误: conditionId=$conditionId, $errorMsg")
|
||||
logger.debug("RPC 请求详情: to=$conditionalTokensAddress, data=$outcomeSlotCountData")
|
||||
return Result.failure(Exception(errorMsg))
|
||||
}
|
||||
|
||||
// 使用 Gson 解析 result(JsonElement)
|
||||
val hexResult = conditionsRpcResponse.result?.asString
|
||||
val outcomeSlotCountHex = outcomeSlotCountRpcResponse.result?.asString
|
||||
?: return Result.failure(Exception("RPC 响应格式错误: result 为空"))
|
||||
|
||||
// 解析返回的 (outcomeSlotCount, payoutDenominator)
|
||||
val cleanHex = hexResult.removePrefix("0x")
|
||||
val outcomeSlotCountHex = cleanHex.substring(0, 64)
|
||||
val payoutDenominatorHex = cleanHex.substring(64, 128)
|
||||
val outcomeSlotCount = EthereumUtils.decodeUint256(outcomeSlotCountHex).toInt()
|
||||
|
||||
val outcomeSlotCount = BigInteger(outcomeSlotCountHex, 16).toInt()
|
||||
val payoutDenominator = BigInteger(payoutDenominatorHex, 16)
|
||||
// 如果 outcomeSlotCount 为 0,说明市场尚未创建或不存在
|
||||
if (outcomeSlotCount <= 0) {
|
||||
logger.debug("市场尚未创建或不存在: conditionId=$conditionId, outcomeSlotCount=$outcomeSlotCount")
|
||||
return Result.success(Pair(BigInteger.ZERO, emptyList()))
|
||||
}
|
||||
|
||||
// 2. 调用 payoutDenominator(bytes32) 获取分母
|
||||
// 函数签名: payoutDenominator(bytes32) returns (uint)
|
||||
val payoutDenominatorSelector = EthereumUtils.getFunctionSelector("payoutDenominator(bytes32)")
|
||||
val payoutDenominatorData = payoutDenominatorSelector + encodedConditionId
|
||||
|
||||
val payoutDenominatorRequest = JsonRpcRequest(
|
||||
method = "eth_call",
|
||||
params = listOf(
|
||||
mapOf(
|
||||
"to" to conditionalTokensAddress,
|
||||
"data" to payoutDenominatorData
|
||||
),
|
||||
"latest"
|
||||
)
|
||||
)
|
||||
|
||||
val payoutDenominatorResponse = rpcApi.call(payoutDenominatorRequest)
|
||||
|
||||
if (!payoutDenominatorResponse.isSuccessful || payoutDenominatorResponse.body() == null) {
|
||||
return Result.failure(Exception("RPC 请求失败 (payoutDenominator): ${payoutDenominatorResponse.code()} ${payoutDenominatorResponse.message()}"))
|
||||
}
|
||||
|
||||
val payoutDenominatorRpcResponse = payoutDenominatorResponse.body()!!
|
||||
|
||||
if (payoutDenominatorRpcResponse.error != null) {
|
||||
val errorMsg = "RPC 错误 (code=${payoutDenominatorRpcResponse.error.code}): ${payoutDenominatorRpcResponse.error.message}, data=${payoutDenominatorRpcResponse.error.data}"
|
||||
logger.warn("查询市场条件(payoutDenominator)出现RPC错误: conditionId=$conditionId, $errorMsg")
|
||||
logger.debug("RPC 请求详情: to=$conditionalTokensAddress, data=$payoutDenominatorData")
|
||||
return Result.failure(Exception(errorMsg))
|
||||
}
|
||||
|
||||
val payoutDenominatorHex = payoutDenominatorRpcResponse.result?.asString
|
||||
?: return Result.failure(Exception("RPC 响应格式错误: result 为空"))
|
||||
|
||||
val payoutDenominator = EthereumUtils.decodeUint256(payoutDenominatorHex)
|
||||
|
||||
// 如果 outcomeSlotCount 为 0,说明市场尚未创建或不存在
|
||||
if (outcomeSlotCount <= 0) {
|
||||
|
||||
+28
-8
@@ -43,12 +43,19 @@ class MarketPriceService(
|
||||
*/
|
||||
suspend fun getCurrentMarketPrice(marketId: String, outcomeIndex: Int): BigDecimal {
|
||||
// 1. 优先从链上查询市场结算结果
|
||||
val chainPrice = getPriceFromChainCondition(marketId, outcomeIndex)
|
||||
val (chainPrice, hasRpcError) = getPriceFromChainCondition(marketId, outcomeIndex)
|
||||
if (chainPrice != null) {
|
||||
// 截位到 4 位小数(向下截断,不四舍五入)
|
||||
return chainPrice.setScale(4, java.math.RoundingMode.DOWN)
|
||||
}
|
||||
|
||||
// 如果链上查询出现 RPC 错误(execution reverted),说明市场可能不存在或尚未创建
|
||||
// 在这种情况下,降级到其他数据源(CLOB API 或 Gamma API),而不是直接抛出异常
|
||||
// 因为 marketId 可能在 API 中存在,但在链上尚未创建
|
||||
if (hasRpcError) {
|
||||
logger.debug("链上查询市场条件出现 RPC 错误(execution reverted),降级到 API 查询: marketId=$marketId, outcomeIndex=$outcomeIndex")
|
||||
}
|
||||
|
||||
// 2. 从 CLOB API 查询订单簿价格(最准确,优先使用)
|
||||
val orderbookPrice = getPriceFromClobOrderbook(marketId, outcomeIndex)
|
||||
if (orderbookPrice != null) {
|
||||
@@ -75,8 +82,10 @@ class MarketPriceService(
|
||||
* - payout > 0(赢了)→ 返回 1.0
|
||||
* - payout == 0(输了)→ 返回 0.0
|
||||
* 如果市场未结算或查询失败,返回 null
|
||||
*
|
||||
* @return Pair<BigDecimal?, Boolean> 第一个值是价格(如果已结算),第二个值表示是否发生了 RPC 错误(execution reverted)
|
||||
*/
|
||||
private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): BigDecimal? {
|
||||
private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): Pair<BigDecimal?, Boolean> {
|
||||
return try {
|
||||
val chainResult = blockchainService.getCondition(marketId)
|
||||
chainResult.fold(
|
||||
@@ -87,30 +96,41 @@ class MarketPriceService(
|
||||
when {
|
||||
payout > BigInteger.ZERO -> {
|
||||
logger.info("从链上查询到市场已结算,该 outcome 赢了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||
return BigDecimal.ONE
|
||||
return Pair(BigDecimal.ONE, false)
|
||||
}
|
||||
payout == BigInteger.ZERO -> {
|
||||
logger.info("从链上查询到市场已结算,该 outcome 输了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||
return BigDecimal.ZERO
|
||||
return Pair(BigDecimal.ZERO, false)
|
||||
}
|
||||
else -> {
|
||||
logger.warn("从链上查询到异常的 payout 值: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||
null
|
||||
Pair(null, false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.debug("从链上查询到市场尚未结算: marketId=$marketId, payouts=${payouts.size}")
|
||||
null
|
||||
Pair(null, false)
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
// 检查是否是 execution reverted 错误
|
||||
val isRpcError = e.message?.contains("execution reverted", ignoreCase = true) == true
|
||||
if (isRpcError) {
|
||||
logger.warn("链上查询市场条件出现 RPC 错误(execution reverted),可能市场不存在或尚未创建: marketId=$marketId, error=${e.message}")
|
||||
} else {
|
||||
logger.debug("链上查询市场条件失败,降级到 API 查询: marketId=$marketId, error=${e.message}")
|
||||
null
|
||||
}
|
||||
Pair(null, isRpcError)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
val isRpcError = e.message?.contains("execution reverted", ignoreCase = true) == true
|
||||
if (isRpcError) {
|
||||
logger.warn("链上查询市场条件异常(execution reverted): marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
|
||||
} else {
|
||||
logger.debug("链上查询市场条件异常: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
|
||||
null
|
||||
}
|
||||
Pair(null, isRpcError)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+32
@@ -401,5 +401,37 @@ class PolymarketClobService(
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取费率
|
||||
* 文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program#1-fetch-the-fee-rate
|
||||
*
|
||||
* 注意:根据 TypeScript clob-client 源码,API 返回的字段名是 base_fee,而不是文档中的 fee_rate_bps
|
||||
* 参考: clob-client/src/client.ts:312
|
||||
*
|
||||
* @param tokenId Token ID
|
||||
* @return 费率基点(0 表示无费率,1000 表示 10%)
|
||||
*/
|
||||
suspend fun getFeeRate(tokenId: String): Result<Int> {
|
||||
return try {
|
||||
val response = clobApi.getFeeRate(tokenId)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val baseFee = response.body()!!.baseFee
|
||||
logger.debug("获取费率成功: tokenId=$tokenId, baseFee=$baseFee")
|
||||
Result.success(baseFee)
|
||||
} else {
|
||||
val errorBody = try {
|
||||
response.errorBody()?.string()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
logger.error("获取费率失败: tokenId=$tokenId, code=${response.code()}, errorBody=$errorBody")
|
||||
Result.failure(Exception("获取费率失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取费率异常: tokenId=$tokenId, error=${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+28
-6
@@ -515,7 +515,16 @@ open class CopyOrderTrackingService(
|
||||
// 解密私钥
|
||||
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||
|
||||
logger.info("准备创建买入订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}, calculatedPrice=$buyPrice, quantity=$finalBuyQuantity")
|
||||
// 获取费率(根据 Polymarket Maker Rebates Program 要求)
|
||||
val feeRateResult = clobService.getFeeRate(tokenId)
|
||||
val feeRateBps = if (feeRateResult.isSuccess) {
|
||||
feeRateResult.getOrNull()?.toString() ?: "0"
|
||||
} else {
|
||||
logger.warn("获取费率失败,使用默认值 0: tokenId=$tokenId, error=${feeRateResult.exceptionOrNull()?.message}")
|
||||
"0"
|
||||
}
|
||||
|
||||
logger.info("准备创建买入订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}, calculatedPrice=$buyPrice, quantity=$finalBuyQuantity, baseFee=$feeRateBps")
|
||||
|
||||
// 调用API创建订单(带重试机制)
|
||||
// 重试策略:最多重试 MAX_RETRY_ATTEMPTS 次,每次重试前等待 RETRY_DELAY_MS 毫秒
|
||||
@@ -530,7 +539,8 @@ open class CopyOrderTrackingService(
|
||||
size = finalBuyQuantity.toString(),
|
||||
owner = account.apiKey,
|
||||
copyTradingId = copyTrading.id!!,
|
||||
tradeId = trade.id
|
||||
tradeId = trade.id,
|
||||
feeRateBps = feeRateBps
|
||||
)
|
||||
|
||||
// 处理订单创建失败
|
||||
@@ -959,6 +969,15 @@ open class CopyOrderTrackingService(
|
||||
// 8. 解密私钥(在方法开始时解密一次,后续复用)
|
||||
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||
|
||||
// 获取费率(根据 Polymarket Maker Rebates Program 要求)
|
||||
val feeRateResult = clobService.getFeeRate(tokenId)
|
||||
val feeRateBps = if (feeRateResult.isSuccess) {
|
||||
feeRateResult.getOrNull()?.toString() ?: "0"
|
||||
} else {
|
||||
logger.warn("获取费率失败,使用默认值 0: tokenId=$tokenId, error=${feeRateResult.exceptionOrNull()?.message}")
|
||||
"0"
|
||||
}
|
||||
|
||||
// 9. 创建并签名卖出订单
|
||||
val signedOrder = try {
|
||||
orderSigningService.createAndSignOrder(
|
||||
@@ -970,7 +989,7 @@ open class CopyOrderTrackingService(
|
||||
size = totalMatched.toString(),
|
||||
signatureType = 2, // Browser Wallet
|
||||
nonce = "0",
|
||||
feeRateBps = "0",
|
||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||
expiration = "0"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
@@ -1008,7 +1027,8 @@ open class CopyOrderTrackingService(
|
||||
size = totalMatched.toString(),
|
||||
owner = account.apiKey,
|
||||
copyTradingId = copyTrading.id,
|
||||
tradeId = leaderSellTrade.id
|
||||
tradeId = leaderSellTrade.id,
|
||||
feeRateBps = feeRateBps
|
||||
)
|
||||
|
||||
if (createOrderResult.isFailure) {
|
||||
@@ -1100,6 +1120,7 @@ open class CopyOrderTrackingService(
|
||||
* @param owner API Key(用于owner字段)
|
||||
* @param copyTradingId 跟单配置ID(用于日志)
|
||||
* @param tradeId Leader 交易ID(用于日志)
|
||||
* @param feeRateBps 费率基点(从API动态获取)
|
||||
* @return 成功返回订单ID,失败返回异常
|
||||
*/
|
||||
private suspend fun createOrderWithRetry(
|
||||
@@ -1112,7 +1133,8 @@ open class CopyOrderTrackingService(
|
||||
size: String,
|
||||
owner: String,
|
||||
copyTradingId: Long,
|
||||
tradeId: String
|
||||
tradeId: String,
|
||||
feeRateBps: String
|
||||
): Result<String> {
|
||||
var lastError: Exception? = null
|
||||
|
||||
@@ -1129,7 +1151,7 @@ open class CopyOrderTrackingService(
|
||||
size = size,
|
||||
signatureType = 2, // Browser Wallet
|
||||
nonce = "0",
|
||||
feeRateBps = "0",
|
||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||
expiration = "0"
|
||||
)
|
||||
|
||||
|
||||
@@ -29,9 +29,9 @@ server.port=${SERVER_PORT:8000}
|
||||
# 默认使用 dev 环境,可通过 --spring.profiles.active=prod 切换
|
||||
spring.profiles.active=${SPRING_PROFILES_ACTIVE:dev}
|
||||
|
||||
# 日志配置
|
||||
logging.level.root=INFO
|
||||
logging.level.com.wrbug.polyhermes=DEBUG
|
||||
# 日志配置(支持通过环境变量配置)
|
||||
logging.level.root=${LOG_LEVEL_ROOT:INFO}
|
||||
logging.level.com.wrbug.polymarketbot=${LOG_LEVEL_APP:DEBUG}
|
||||
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n
|
||||
|
||||
# Polymarket API 配置
|
||||
|
||||
@@ -76,6 +76,11 @@ JWT_SECRET=${JWT_SECRET}
|
||||
|
||||
# 管理员密码重置密钥(已自动生成随机值,生产环境建议修改)
|
||||
ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_KEY}
|
||||
|
||||
# 日志级别配置(可选,默认值:root=INFO, app=DEBUG)
|
||||
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
|
||||
# LOG_LEVEL_ROOT=INFO
|
||||
# LOG_LEVEL_APP=DEBUG
|
||||
EOF
|
||||
info ".env 文件已创建,已自动生成随机密码和密钥"
|
||||
warn "生产环境建议修改以下参数:"
|
||||
|
||||
@@ -29,6 +29,10 @@ services:
|
||||
# 生成随机密钥:openssl rand -hex 32 (ADMIN_RESET_PASSWORD_KEY) 或 openssl rand -hex 64 (JWT_SECRET)
|
||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
|
||||
# 日志级别配置(可选,默认值:root=INFO, app=DEBUG)
|
||||
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
|
||||
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-INFO}
|
||||
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-DEBUG}
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -27,6 +27,10 @@ services:
|
||||
# 生成随机密钥:openssl rand -hex 32 (ADMIN_RESET_PASSWORD_KEY) 或 openssl rand -hex 64 (JWT_SECRET)
|
||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
|
||||
# 日志级别配置(可选,默认值:root=INFO, app=DEBUG)
|
||||
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
|
||||
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-INFO}
|
||||
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-DEBUG}
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -1,836 +0,0 @@
|
||||
# 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. **多维度分析**:增加更多分析维度(如市场类型、时间分布等)
|
||||
|
||||
|
||||
@@ -252,7 +252,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
<TwitterOutlined />
|
||||
</a>
|
||||
<a
|
||||
href="https://t.me/+5BwdYvvvuf9iZGZl"
|
||||
href="https://t.me/polyhermes"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#fff', fontSize: '16px', display: 'flex', alignItems: 'center' }}
|
||||
@@ -376,7 +376,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
<TwitterOutlined />
|
||||
</a>
|
||||
<a
|
||||
href="https://t.me/+5BwdYvvvuf9iZGZl"
|
||||
href="https://t.me/polyhermes"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#fff', fontSize: '18px', display: 'flex', alignItems: 'center' }}
|
||||
|
||||
Reference in New Issue
Block a user