diff --git a/README.md b/README.md index e447516..f3bcfb5 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![GitHub](https://img.shields.io/badge/GitHub-WrBug%2FPolyHermes-blue?logo=github)](https://github.com/WrBug/PolyHermes) [![Twitter](https://img.shields.io/badge/Twitter-@polyhermes-blue?logo=twitter)](https://x.com/polyhermes) +[![Docker](https://img.shields.io/docker/v/wrbug/polyhermes?label=Docker&logo=docker)](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) diff --git a/README_EN.md b/README_EN.md index 4c72814..fb316df 100644 --- a/README_EN.md +++ b/README_EN.md @@ -2,6 +2,7 @@ [![GitHub](https://img.shields.io/badge/GitHub-WrBug%2FPolyHermes-blue?logo=github)](https://github.com/WrBug/PolyHermes) [![Twitter](https://img.shields.io/badge/Twitter-@polyhermes-blue?logo=twitter)](https://x.com/polyhermes) +[![Docker](https://img.shields.io/docker/v/wrbug/polyhermes?label=Docker&logo=docker)](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) diff --git a/RELEASE.md b/RELEASE.md index c89d23e..5e9a596 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -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`,第二个值表示是否发生 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 --- diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt index 01612b9..bff12b2 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt @@ -149,6 +149,19 @@ interface PolymarketClobApi { @GET("/auth/derive-api-key") suspend fun deriveApiKey(): Response + /** + * 获取费率 + * 文档: 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 + /** * 获取服务器时间 * 端点: /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%) +) + /** * 最新价响应(从订单表获取) */ diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt index a031cb8..f2466b9 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt @@ -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) { diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/PositionCheckService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/PositionCheckService.kt index 4cfff21..ee25e64 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/PositionCheckService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/PositionCheckService.kt @@ -398,7 +398,7 @@ class PositionCheckService( } else { // 有仓位,按订单下单顺序(FIFO)更新状态 // 计算逻辑: - // 1. 总订单数量 = 所有未卖出订单的剩余数量总和 + // 1. 总订单数量 = 所有未卖出订单的剩余数量总和 // 2. 已成交数量 = 总订单数量 - 仓位数量(因为还有仓位,说明部分订单已卖出) // 3. 如果已成交数量 = 0,说明订单还没有卖出,不修改订单状态 // 4. 如果已成交数量 > 0,按FIFO顺序匹配订单 diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt index 3276a5d..b94750a 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt @@ -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) { diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketPriceService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketPriceService.kt index 7a49a97..6bf32b6 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketPriceService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketPriceService.kt @@ -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 第一个值是价格(如果已结算),第二个值表示是否发生了 RPC 错误(execution reverted) */ - private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): BigDecimal? { + private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): Pair { 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) } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/PolymarketClobService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/PolymarketClobService.kt index 30f1551..685bd93 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/PolymarketClobService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/PolymarketClobService.kt @@ -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 { + 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) + } + } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt index 88fa255..3da2842 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt @@ -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 { 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" ) diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index ef73a28..68f0b3a 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -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 配置 diff --git a/deploy.sh b/deploy.sh index 3191b1e..d622803 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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 "生产环境建议修改以下参数:" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 6ecb88c..12d1765 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index d7f94c8..c1efeb3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/zh/smart-money-analysis.md b/docs/zh/smart-money-analysis.md deleted file mode 100644 index 2d78f51..0000000 --- a/docs/zh/smart-money-analysis.md +++ /dev/null @@ -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 { - 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> { - 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, - positions: List - ): 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, - sellTrades: List - ): 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, - sellTrades: List - ): Double { - // 类似胜率计算,分别计算盈利和亏损的平均金额 - val buyByMarket = buyTrades.groupBy { it.conditionId } - val sellByMarket = sellTrades.groupBy { it.conditionId } - - val profits = mutableListOf() - val losses = mutableListOf() - - 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, - days: Int = 90 - ): Result> { - 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> { - 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 { - // 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> { - // 实现逻辑 -} -``` - -**请求参数**: -```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> { - // 实现逻辑 -} -``` - -**请求参数**: -```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. **多维度分析**:增加更多分析维度(如市场类型、时间分布等) - - diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index fff50fe..2198672 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -252,7 +252,7 @@ const Layout: React.FC = ({ children }) => { = ({ children }) => {