From dffbc5124fd5e011f06929dd23d59e37e621ad49 Mon Sep 17 00:00:00 2001 From: WrBug Date: Tue, 2 Dec 2025 03:34:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E8=B7=9F=E5=8D=95?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E8=B7=9F=E8=B8=AA=E5=92=8C=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加订单跟踪实体和Repository(CopyOrderTracking, SellMatchRecord, SellMatchDetail) - 实现订单跟踪服务(CopyOrderTrackingService),支持买入/卖出订单的创建和匹配 - 实现跟单统计服务(CopyTradingStatisticsService),支持盈亏统计和订单列表查询 - 添加失败交易记录(FailedTrade)和已处理交易去重(ProcessedTrade) - 实现轮询服务(CopyTradingPollingService),使用Data API /activity接口轮询Leader交易 - 支持多元市场(使用outcomeIndex而不是YES/NO) - 重试时重新生成salt并重新签名 - 添加风险控制检查(每日订单数、每日亏损、单笔订单大小限制) - 支持价格容忍度和延迟跟单 - 添加数据库迁移文件(V8, V9, V10) - 前端:账户管理显示代理钱包地址,添加复制功能,移除默认账户逻辑 - 修复API响应字段映射问题(orderID, transactionsHashes) - 缩短轮询间隔到2秒 --- .../polymarketbot/api/PolymarketClobApi.kt | 22 +- .../api/PolymarketSubgraphApi.kt | 47 + .../CopyTradingStatisticsController.kt | 106 +++ .../com/wrbug/polymarketbot/dto/AccountDto.kt | 1 + .../dto/CopyTradingStatisticsDto.kt | 114 +++ .../polymarketbot/entity/CopyOrderTracking.kt | 65 ++ .../wrbug/polymarketbot/entity/FailedTrade.kt | 55 ++ .../polymarketbot/entity/ProcessedTrade.kt | 42 + .../polymarketbot/entity/SellMatchDetail.kt | 41 + .../polymarketbot/entity/SellMatchRecord.kt | 47 + .../repository/CopyOrderTrackingRepository.kt | 49 + .../repository/CopyTradingRepository.kt | 5 + .../repository/FailedTradeRepository.kt | 23 + .../repository/ProcessedTradeRepository.kt | 32 + .../repository/SellMatchDetailRepository.kt | 41 + .../repository/SellMatchRecordRepository.kt | 28 + .../polymarketbot/service/AccountService.kt | 3 +- .../service/CopyOrderTrackingService.kt | 860 ++++++++++++++++++ .../service/CopyTradingMonitorService.kt | 124 +++ .../service/CopyTradingPollingService.kt | 284 ++++++ .../service/CopyTradingService.kt | 37 +- .../service/CopyTradingStatisticsService.kt | 411 +++++++++ .../service/CopyTradingWebSocketService.kt | 233 +++++ .../service/PolymarketClobService.kt | 4 +- .../com/wrbug/polymarketbot/util/MathExt.kt | 4 +- .../polymarketbot/util/RetrofitFactory.kt | 26 + .../src/main/resources/application.properties | 6 + ...0__add_outcome_index_to_order_tracking.sql | 18 + .../V8__create_order_tracking_tables.sql | 76 ++ .../V9__add_failed_trade_tracking.sql | 27 + frontend/src/pages/AccountList.tsx | 139 ++- frontend/src/store/accountStore.ts | 24 +- frontend/src/types/index.ts | 2 + 33 files changed, 2916 insertions(+), 80 deletions(-) create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/controller/CopyTradingStatisticsController.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingStatisticsDto.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyOrderTracking.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/entity/FailedTrade.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/entity/ProcessedTrade.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SellMatchDetail.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SellMatchRecord.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyOrderTrackingRepository.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/repository/FailedTradeRepository.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/repository/ProcessedTradeRepository.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SellMatchDetailRepository.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SellMatchRecordRepository.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyOrderTrackingService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingMonitorService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingPollingService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingStatisticsService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingWebSocketService.kt create mode 100644 backend/src/main/resources/db/migration/V10__add_outcome_index_to_order_tracking.sql create mode 100644 backend/src/main/resources/db/migration/V8__create_order_tracking_tables.sql create mode 100644 backend/src/main/resources/db/migration/V9__add_failed_trade_tracking.sql 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 70e1aee..58b6f31 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt @@ -192,12 +192,22 @@ data class NewOrderRequest( /** * 创建订单响应(根据官方文档) + * 注意:API 返回的字段名是 orderID(大写),需要使用 @SerializedName 映射 */ data class NewOrderResponse( val success: Boolean, // boolean indicating if server-side error + @SerializedName("errorMsg") val errorMsg: String? = null, // error message in case of unsuccessful placement - val orderId: String? = null, // id of order - val orderHashes: List? = null // hash of settlement transaction order was marketable and triggered a match + @SerializedName("orderID") + val orderId: String? = null, // id of order(API 返回字段名为 orderID) + @SerializedName("transactionsHashes") + val transactionsHashes: List? = null, // transaction hashes(API 返回字段名为 transactionsHashes) + @SerializedName("status") + val status: String? = null, // order status (matched, pending, etc.) + @SerializedName("takingAmount") + val takingAmount: String? = null, // taking amount + @SerializedName("makingAmount") + val makingAmount: String? = null // making amount ) /** @@ -305,11 +315,13 @@ data class CancelOrdersBatchResponse( data class TradeResponse( val id: String, val market: String, - val side: String, + val side: String, // BUY 或 SELL val price: String, val size: String, - val timestamp: String, // ISO 8601 格式字符串 - val user: String? + val timestamp: String, // ISO 8601 格式字符串或时间戳 + val user: String?, + val outcomeIndex: Int? = null, // 结果索引(0=YES, 1=NO) + val outcome: String? = null // 结果名称(如 "Up", "Down") ) /** diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketSubgraphApi.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketSubgraphApi.kt index 7fbaae9..972584d 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketSubgraphApi.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketSubgraphApi.kt @@ -39,6 +39,25 @@ interface PolymarketDataApi { @Query("user") user: String, @Query("market") market: List? = null ): Response> + + /** + * 获取用户活动(包括交易) + * 文档: https://docs.polymarket.com/api-reference/core/get-user-activity + */ + @GET("/activity") + suspend fun getUserActivity( + @Query("user") user: String, + @Query("limit") limit: Int? = null, + @Query("offset") offset: Int? = null, + @Query("market") market: List? = null, + @Query("eventId") eventId: List? = null, + @Query("type") type: List? = null, + @Query("start") start: Long? = null, + @Query("end") end: Long? = null, + @Query("sortBy") sortBy: String? = null, + @Query("sortDirection") sortDirection: String? = null, + @Query("side") side: String? = null + ): Response> } /** @@ -80,4 +99,32 @@ data class ValueResponse( val value: Double ) +/** + * 用户活动响应(根据 Polymarket Data API 文档) + * 文档: https://docs.polymarket.com/api-reference/core/get-user-activity + */ +data class UserActivityResponse( + val proxyWallet: String, + val timestamp: Long, + val conditionId: String, + val type: String, // TRADE, SPLIT, MERGE, REDEEM, REWARD, CONVERSION + val size: Double? = null, + val usdcSize: Double? = null, + val transactionHash: String? = null, + val price: Double? = null, + val asset: String? = null, + val side: String? = null, // BUY, SELL + val outcomeIndex: Int? = null, + val title: String? = null, + val slug: String? = null, + val icon: String? = null, + val eventSlug: String? = null, + val outcome: String? = null, + val name: String? = null, + val pseudonym: String? = null, + val bio: String? = null, + val profileImage: String? = null, + val profileImageOptimized: String? = null +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/CopyTradingStatisticsController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/CopyTradingStatisticsController.kt new file mode 100644 index 0000000..d2f8394 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/CopyTradingStatisticsController.kt @@ -0,0 +1,106 @@ +package com.wrbug.polymarketbot.controller + +import com.wrbug.polymarketbot.dto.* +import com.wrbug.polymarketbot.service.CopyTradingStatisticsService +import kotlinx.coroutines.runBlocking +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +/** + * 跟单统计控制器 + * 提供统计信息和订单列表查询接口 + */ +@RestController +@RequestMapping("/api/copy-trading/statistics") +class CopyTradingStatisticsController( + private val statisticsService: CopyTradingStatisticsService +) { + + private val logger = LoggerFactory.getLogger(CopyTradingStatisticsController::class.java) + + /** + * 查询跟单统计详情 + * POST /api/copy-trading/statistics/detail + */ + @PostMapping("/detail") + fun getStatisticsDetail(@RequestBody request: StatisticsDetailRequest): ResponseEntity> { + return try { + if (request.copyTradingId <= 0) { + return ResponseEntity.ok(ApiResponse.paramError("跟单关系ID无效")) + } + + val result = runBlocking { statisticsService.getStatistics(request.copyTradingId) } + result.fold( + onSuccess = { response -> + logger.info("成功获取统计信息: copyTradingId=${request.copyTradingId}") + ResponseEntity.ok(ApiResponse.success(response)) + }, + onFailure = { e -> + logger.error("获取统计信息失败: copyTradingId=${request.copyTradingId}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + else -> ResponseEntity.ok(ApiResponse.serverError("获取统计信息失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("获取统计信息异常: copyTradingId=${request.copyTradingId}", e) + ResponseEntity.ok(ApiResponse.serverError("获取统计信息失败: ${e.message}")) + } + } +} + +/** + * 订单跟踪控制器 + * 提供订单列表查询接口 + */ +@RestController +@RequestMapping("/api/copy-trading/orders") +class CopyOrderTrackingController( + private val statisticsService: CopyTradingStatisticsService +) { + + private val logger = LoggerFactory.getLogger(CopyOrderTrackingController::class.java) + + /** + * 查询订单列表(买入/卖出/匹配) + * POST /api/copy-trading/orders/tracking + */ + @PostMapping("/tracking") + fun getOrderList(@RequestBody request: OrderTrackingRequest): ResponseEntity> { + return try { + if (request.copyTradingId <= 0) { + return ResponseEntity.ok(ApiResponse.paramError("跟单关系ID无效")) + } + + if (request.type.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("订单类型不能为空")) + } + + val validTypes = listOf("buy", "sell", "matched") + if (!validTypes.contains(request.type.lowercase())) { + return ResponseEntity.ok(ApiResponse.paramError("订单类型无效,必须是: buy, sell, matched")) + } + + val result = statisticsService.getOrderList(request) + result.fold( + onSuccess = { response -> + logger.info("成功查询订单列表: copyTradingId=${request.copyTradingId}, type=${request.type}, total=${response.total}") + ResponseEntity.ok(ApiResponse.success(response)) + }, + onFailure = { e -> + logger.error("查询订单列表失败: copyTradingId=${request.copyTradingId}, type=${request.type}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + else -> ResponseEntity.ok(ApiResponse.serverError("查询订单列表失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("查询订单列表异常: copyTradingId=${request.copyTradingId}, type=${request.type}", e) + ResponseEntity.ok(ApiResponse.serverError("查询订单列表失败: ${e.message}")) + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt index 230979e..279718c 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt @@ -55,6 +55,7 @@ data class SetDefaultAccountRequest( data class AccountDto( val id: Long, val walletAddress: String, + val proxyAddress: String, // Polymarket 代理钱包地址 val accountName: String?, val isDefault: Boolean, val isEnabled: Boolean, // 是否启用(用于订单推送等功能的开关) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingStatisticsDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingStatisticsDto.kt new file mode 100644 index 0000000..36f02c0 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingStatisticsDto.kt @@ -0,0 +1,114 @@ +package com.wrbug.polymarketbot.dto + +/** + * 跟单关系统计响应 + */ +data class CopyTradingStatisticsResponse( + val copyTradingId: Long, + val accountId: Long, + val accountName: String?, + val leaderId: Long, + val leaderName: String?, + val templateId: Long, + val templateName: String?, + val enabled: Boolean, + + // 买入统计 + val totalBuyQuantity: String, + val totalBuyOrders: Long, + val totalBuyAmount: String, + val avgBuyPrice: String, + + // 卖出统计 + val totalSellQuantity: String, + val totalSellOrders: Long, + val totalSellAmount: String, + + // 持仓统计 + val currentPositionQuantity: String, + val currentPositionValue: String, + + // 盈亏统计 + val totalRealizedPnl: String, + val totalUnrealizedPnl: String, + val totalPnl: String, + val totalPnlPercent: String +) + +/** + * 买入订单信息 + */ +data class BuyOrderInfo( + val orderId: String, + val leaderTradeId: String, + val marketId: String, + val side: String, + val quantity: String, + val price: String, + val amount: String, + val matchedQuantity: String, + val remainingQuantity: String, + val status: String, // filled, partially_matched, fully_matched + val createdAt: Long +) + +/** + * 卖出订单信息 + */ +data class SellOrderInfo( + val orderId: String, + val leaderTradeId: String, + val marketId: String, + val side: String, + val quantity: String, + val price: String, + val amount: String, + val realizedPnl: String, + val createdAt: Long +) + +/** + * 匹配订单信息 + */ +data class MatchedOrderInfo( + val sellOrderId: String, + val buyOrderId: String, + val matchedQuantity: String, + val buyPrice: String, + val sellPrice: String, + val realizedPnl: String, + val matchedAt: Long +) + +/** + * 订单列表响应 + */ +data class OrderListResponse( + val list: List, // BuyOrderInfo, SellOrderInfo 或 MatchedOrderInfo + val total: Long, + val page: Int, + val limit: Int +) + +/** + * 订单跟踪查询请求 + */ +data class OrderTrackingRequest( + val copyTradingId: Long, + val type: String, // buy, sell, matched + val page: Int? = 1, + val limit: Int? = 20, + val marketId: String? = null, + val side: String? = null, + val status: String? = null, + val sellOrderId: String? = null, + val buyOrderId: String? = null +) + +/** + * 统计查询请求 + */ +data class StatisticsDetailRequest( + val copyTradingId: Long +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyOrderTracking.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyOrderTracking.kt new file mode 100644 index 0000000..dbe7e80 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyOrderTracking.kt @@ -0,0 +1,65 @@ +package com.wrbug.polymarketbot.entity + +import jakarta.persistence.* +import java.math.BigDecimal + +/** + * 订单跟踪实体 + * 用于跟踪每笔买入订单的匹配状态 + */ +@Entity +@Table(name = "copy_order_tracking") +data class CopyOrderTracking( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "copy_trading_id", nullable = false) + val copyTradingId: Long, + + @Column(name = "account_id", nullable = false) + val accountId: Long, + + @Column(name = "leader_id", nullable = false) + val leaderId: Long, + + @Column(name = "template_id", nullable = false) + val templateId: Long, + + @Column(name = "market_id", nullable = false, length = 100) + val marketId: String, + + @Column(name = "side", nullable = false, length = 10) + val side: String, // 兼容字段:YES/NO 或 outcomeIndex(字符串) + + @Column(name = "outcome_index", nullable = true) + val outcomeIndex: Int? = null, // 结果索引(0, 1, 2, ...),支持多元市场 + + @Column(name = "buy_order_id", nullable = false, length = 100) + val buyOrderId: String, // 跟单买入订单ID + + @Column(name = "leader_buy_trade_id", nullable = false, length = 100) + val leaderBuyTradeId: String, // Leader 买入交易ID + + @Column(name = "quantity", nullable = false, precision = 20, scale = 8) + val quantity: BigDecimal, // 买入数量 + + @Column(name = "price", nullable = false, precision = 20, scale = 8) + val price: BigDecimal, // 买入价格 + + @Column(name = "matched_quantity", nullable = false, precision = 20, scale = 8) + var matchedQuantity: BigDecimal = BigDecimal.ZERO, // 已匹配卖出数量 + + @Column(name = "remaining_quantity", nullable = false, precision = 20, scale = 8) + var remainingQuantity: BigDecimal, // 剩余未匹配数量 + + @Column(name = "status", nullable = false, length = 20) + var status: String = "filled", // filled, fully_matched, partially_matched + + @Column(name = "created_at", nullable = false) + val createdAt: Long = System.currentTimeMillis(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: Long = System.currentTimeMillis() +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/FailedTrade.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/FailedTrade.kt new file mode 100644 index 0000000..4accb7d --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/FailedTrade.kt @@ -0,0 +1,55 @@ +package com.wrbug.polymarketbot.entity + +import jakarta.persistence.* + +/** + * 失败交易实体 + * 记录处理失败的交易信息 + */ +@Entity +@Table(name = "failed_trade") +data class FailedTrade( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "leader_id", nullable = false) + val leaderId: Long, + + @Column(name = "leader_trade_id", nullable = false, length = 100) + val leaderTradeId: String, // Leader 的交易ID + + @Column(name = "trade_type", nullable = false, length = 10) + val tradeType: String, // BUY 或 SELL + + @Column(name = "copy_trading_id", nullable = false) + val copyTradingId: Long, + + @Column(name = "account_id", nullable = false) + val accountId: Long, + + @Column(name = "market_id", nullable = false, length = 100) + val marketId: String, + + @Column(name = "side", nullable = false, length = 10) + val side: String, // YES/NO + + @Column(name = "price", nullable = false, length = 50) + val price: String, // 价格(字符串格式) + + @Column(name = "size", nullable = false, length = 50) + val size: String, // 数量(字符串格式) + + @Column(name = "error_message", columnDefinition = "TEXT") + val errorMessage: String? = null, // 错误信息 + + @Column(name = "retry_count", nullable = false) + val retryCount: Int = 0, // 重试次数 + + @Column(name = "failed_at", nullable = false) + val failedAt: Long = System.currentTimeMillis(), + + @Column(name = "created_at", nullable = false) + val createdAt: Long = System.currentTimeMillis() +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/ProcessedTrade.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/ProcessedTrade.kt new file mode 100644 index 0000000..1cd149b --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/ProcessedTrade.kt @@ -0,0 +1,42 @@ +package com.wrbug.polymarketbot.entity + +import jakarta.persistence.* + +/** + * 已处理交易实体 + * 用于去重,确保同一笔交易只处理一次 + */ +@Entity +@Table( + name = "processed_trade", + uniqueConstraints = [ + UniqueConstraint(columnNames = ["leader_id", "leader_trade_id"]) + ] +) +data class ProcessedTrade( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "leader_id", nullable = false) + val leaderId: Long, + + @Column(name = "leader_trade_id", nullable = false, length = 100) + val leaderTradeId: String, // Leader 的交易ID(trade.id,唯一标识) + + @Column(name = "trade_type", nullable = false, length = 10) + val tradeType: String, // BUY 或 SELL + + @Column(name = "source", nullable = false, length = 20) + val source: String, // websocket 或 polling + + @Column(name = "status", nullable = false, length = 20) + val status: String = "SUCCESS", // SUCCESS(成功)或 FAILED(失败) + + @Column(name = "processed_at", nullable = false) + val processedAt: Long = System.currentTimeMillis(), + + @Column(name = "created_at", nullable = false) + val createdAt: Long = System.currentTimeMillis() +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SellMatchDetail.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SellMatchDetail.kt new file mode 100644 index 0000000..57d0ed8 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SellMatchDetail.kt @@ -0,0 +1,41 @@ +package com.wrbug.polymarketbot.entity + +import jakarta.persistence.* +import java.math.BigDecimal + +/** + * 匹配明细实体 + * 记录每笔卖出订单与买入订单的匹配明细 + */ +@Entity +@Table(name = "sell_match_detail") +data class SellMatchDetail( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "match_record_id", nullable = false) + val matchRecordId: Long, // 关联 sell_match_record.id + + @Column(name = "tracking_id", nullable = false) + val trackingId: Long, // 关联 copy_order_tracking.id + + @Column(name = "buy_order_id", nullable = false, length = 100) + val buyOrderId: String, // 买入订单ID + + @Column(name = "matched_quantity", nullable = false, precision = 20, scale = 8) + val matchedQuantity: BigDecimal, // 匹配的数量 + + @Column(name = "buy_price", nullable = false, precision = 20, scale = 8) + val buyPrice: BigDecimal, // 买入价格 + + @Column(name = "sell_price", nullable = false, precision = 20, scale = 8) + val sellPrice: BigDecimal, // 卖出价格 + + @Column(name = "realized_pnl", nullable = false, precision = 20, scale = 8) + val realizedPnl: BigDecimal, // 盈亏 = (sell_price - buy_price) * matched_quantity + + @Column(name = "created_at", nullable = false) + val createdAt: Long = System.currentTimeMillis() +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SellMatchRecord.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SellMatchRecord.kt new file mode 100644 index 0000000..048905c --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SellMatchRecord.kt @@ -0,0 +1,47 @@ +package com.wrbug.polymarketbot.entity + +import jakarta.persistence.* +import java.math.BigDecimal + +/** + * 卖出匹配记录实体 + * 记录每笔卖出订单的匹配信息 + */ +@Entity +@Table(name = "sell_match_record") +data class SellMatchRecord( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "copy_trading_id", nullable = false) + val copyTradingId: Long, + + @Column(name = "sell_order_id", nullable = false, length = 100) + val sellOrderId: String, // 跟单卖出订单ID + + @Column(name = "leader_sell_trade_id", nullable = false, length = 100) + val leaderSellTradeId: String, // Leader 卖出交易ID + + @Column(name = "market_id", nullable = false, length = 100) + val marketId: String, + + @Column(name = "side", nullable = false, length = 10) + val side: String, // 兼容字段:YES/NO 或 outcomeIndex(字符串) + + @Column(name = "outcome_index", nullable = true) + val outcomeIndex: Int? = null, // 结果索引(0, 1, 2, ...),支持多元市场 + + @Column(name = "total_matched_quantity", nullable = false, precision = 20, scale = 8) + val totalMatchedQuantity: BigDecimal, // 总匹配数量 + + @Column(name = "sell_price", nullable = false, precision = 20, scale = 8) + val sellPrice: BigDecimal, // 卖出价格 + + @Column(name = "total_realized_pnl", nullable = false, precision = 20, scale = 8) + val totalRealizedPnl: BigDecimal, // 总已实现盈亏 + + @Column(name = "created_at", nullable = false) + val createdAt: Long = System.currentTimeMillis() +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyOrderTrackingRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyOrderTrackingRepository.kt new file mode 100644 index 0000000..6158959 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyOrderTrackingRepository.kt @@ -0,0 +1,49 @@ +package com.wrbug.polymarketbot.repository + +import com.wrbug.polymarketbot.entity.CopyOrderTracking +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository +import java.math.BigDecimal + +/** + * 订单跟踪Repository + */ +@Repository +interface CopyOrderTrackingRepository : JpaRepository { + + /** + * 根据跟单关系ID查询所有买入订单 + */ + fun findByCopyTradingId(copyTradingId: Long): List + + /** + * 根据跟单关系ID、市场ID和方向查询未匹配的买入订单(FIFO顺序) + * @deprecated 使用 findUnmatchedBuyOrdersByOutcomeIndex 替代 + */ + @Query("SELECT t FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.marketId = :marketId AND t.side = :side AND t.remainingQuantity > 0 ORDER BY t.createdAt ASC") + fun findUnmatchedBuyOrders(copyTradingId: Long, marketId: String, side: String): List + + /** + * 根据跟单关系ID、市场ID和outcomeIndex查询未匹配的买入订单(FIFO顺序) + * 支持多元市场(不限于YES/NO) + */ + @Query("SELECT t FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.marketId = :marketId AND t.outcomeIndex = :outcomeIndex AND t.remainingQuantity > 0 ORDER BY t.createdAt ASC") + fun findUnmatchedBuyOrdersByOutcomeIndex(copyTradingId: Long, marketId: String, outcomeIndex: Int): List + + /** + * 根据跟单关系ID和状态查询订单 + */ + fun findByCopyTradingIdAndStatus(copyTradingId: Long, status: String): List + + /** + * 根据跟单关系ID和市场ID查询订单 + */ + fun findByCopyTradingIdAndMarketId(copyTradingId: Long, marketId: String): List + + /** + * 根据Leader交易ID查询订单 + */ + fun findByLeaderBuyTradeId(leaderBuyTradeId: String): CopyOrderTracking? +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyTradingRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyTradingRepository.kt index 9598c84..8350d56 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyTradingRepository.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyTradingRepository.kt @@ -49,6 +49,11 @@ interface CopyTradingRepository : JpaRepository { */ fun findByAccountIdAndEnabledTrue(accountId: Long): List + /** + * 根据Leader ID查找启用的跟单 + */ + fun findByLeaderIdAndEnabledTrue(leaderId: Long): List + /** * 统计使用指定模板的跟单数量 */ diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/FailedTradeRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/FailedTradeRepository.kt new file mode 100644 index 0000000..e99974b --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/FailedTradeRepository.kt @@ -0,0 +1,23 @@ +package com.wrbug.polymarketbot.repository + +import com.wrbug.polymarketbot.entity.FailedTrade +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +/** + * 失败交易Repository + */ +@Repository +interface FailedTradeRepository : JpaRepository { + + /** + * 根据Leader ID和交易ID查询 + */ + fun findByLeaderIdAndLeaderTradeId(leaderId: Long, leaderTradeId: String): FailedTrade? + + /** + * 检查是否存在失败的交易 + */ + fun existsByLeaderIdAndLeaderTradeId(leaderId: Long, leaderTradeId: String): Boolean +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/ProcessedTradeRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/ProcessedTradeRepository.kt new file mode 100644 index 0000000..46d661e --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/ProcessedTradeRepository.kt @@ -0,0 +1,32 @@ +package com.wrbug.polymarketbot.repository + +import com.wrbug.polymarketbot.entity.ProcessedTrade +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository + +/** + * 已处理交易Repository + */ +@Repository +interface ProcessedTradeRepository : JpaRepository { + + /** + * 检查交易是否已处理 + */ + fun existsByLeaderIdAndLeaderTradeId(leaderId: Long, leaderTradeId: String): Boolean + + /** + * 根据Leader ID和交易ID查询 + */ + fun findByLeaderIdAndLeaderTradeId(leaderId: Long, leaderTradeId: String): ProcessedTrade? + + /** + * 删除过期记录 + */ + @Modifying + @Query("DELETE FROM ProcessedTrade p WHERE p.processedAt < :expireTime") + fun deleteByProcessedAtBefore(expireTime: Long): Int +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SellMatchDetailRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SellMatchDetailRepository.kt new file mode 100644 index 0000000..884f599 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SellMatchDetailRepository.kt @@ -0,0 +1,41 @@ +package com.wrbug.polymarketbot.repository + +import com.wrbug.polymarketbot.entity.SellMatchDetail +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository + +/** + * 匹配明细Repository + */ +@Repository +interface SellMatchDetailRepository : JpaRepository { + + /** + * 根据匹配记录ID查询所有明细 + */ + fun findByMatchRecordId(matchRecordId: Long): List + + /** + * 根据跟踪ID查询所有明细 + */ + fun findByTrackingId(trackingId: Long): List + + /** + * 根据买入订单ID查询所有明细 + */ + fun findByBuyOrderId(buyOrderId: String): List + + /** + * 根据卖出订单ID查询所有明细(通过匹配记录关联) + */ + @Query("SELECT d FROM SellMatchDetail d JOIN SellMatchRecord r ON d.matchRecordId = r.id WHERE r.sellOrderId = :sellOrderId") + fun findBySellOrderId(sellOrderId: String): List + + /** + * 根据跟单关系ID查询所有明细(通过匹配记录关联) + */ + @Query("SELECT d FROM SellMatchDetail d JOIN SellMatchRecord r ON d.matchRecordId = r.id WHERE r.copyTradingId = :copyTradingId") + fun findByCopyTradingId(copyTradingId: Long): List +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SellMatchRecordRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SellMatchRecordRepository.kt new file mode 100644 index 0000000..7b8cb17 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SellMatchRecordRepository.kt @@ -0,0 +1,28 @@ +package com.wrbug.polymarketbot.repository + +import com.wrbug.polymarketbot.entity.SellMatchRecord +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +/** + * 卖出匹配记录Repository + */ +@Repository +interface SellMatchRecordRepository : JpaRepository { + + /** + * 根据跟单关系ID查询所有卖出记录 + */ + fun findByCopyTradingId(copyTradingId: Long): List + + /** + * 根据卖出订单ID查询记录 + */ + fun findBySellOrderId(sellOrderId: String): SellMatchRecord? + + /** + * 根据Leader卖出交易ID查询记录 + */ + fun findByLeaderSellTradeId(leaderSellTradeId: String): SellMatchRecord? +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt index 8ccb325..e24b6f4 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt @@ -391,6 +391,7 @@ class AccountService( AccountDto( id = account.id!!, walletAddress = account.walletAddress, + proxyAddress = account.proxyAddress, accountName = account.accountName, isDefault = account.isDefault, isEnabled = account.isEnabled, @@ -813,7 +814,7 @@ class AccountService( if (orderResponse.isSuccessful && orderResponse.body() != null) { val response = orderResponse.body()!! if (response.success) { - logger.info("订单创建成功: orderId=${response.orderId}, orderHashes=${response.orderHashes}") + logger.info("订单创建成功: orderId=${response.orderId}, transactionsHashes=${response.transactionsHashes}") Result.success( PositionSellResponse( orderId = response.orderId ?: "", diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyOrderTrackingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyOrderTrackingService.kt new file mode 100644 index 0000000..1328458 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyOrderTrackingService.kt @@ -0,0 +1,860 @@ +package com.wrbug.polymarketbot.service + +import com.wrbug.polymarketbot.api.NewOrderRequest +import com.wrbug.polymarketbot.api.NewOrderResponse +import com.wrbug.polymarketbot.api.TradeResponse +import com.wrbug.polymarketbot.entity.* +import com.wrbug.polymarketbot.repository.* +import com.wrbug.polymarketbot.util.RetrofitFactory +import com.wrbug.polymarketbot.util.* +import kotlinx.coroutines.delay +import org.slf4j.LoggerFactory +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * 订单跟踪服务 + * 处理买入订单跟踪和卖出订单匹配 + * 实际创建订单并记录跟踪信息 + */ +@Service +class CopyOrderTrackingService( + private val copyOrderTrackingRepository: CopyOrderTrackingRepository, + private val sellMatchRecordRepository: SellMatchRecordRepository, + private val sellMatchDetailRepository: SellMatchDetailRepository, + private val processedTradeRepository: ProcessedTradeRepository, + private val failedTradeRepository: FailedTradeRepository, + private val copyTradingRepository: CopyTradingRepository, + private val templateRepository: CopyTradingTemplateRepository, + private val accountRepository: AccountRepository, + private val leaderRepository: LeaderRepository, + private val orderSigningService: OrderSigningService, + private val blockchainService: BlockchainService, + private val retrofitFactory: RetrofitFactory +) { + + private val logger = LoggerFactory.getLogger(CopyOrderTrackingService::class.java) + + /** + * 处理交易事件(WebSocket 或轮询) + * 根据交易方向调用相应的处理方法 + */ + @Transactional + suspend fun processTrade(leaderId: Long, trade: TradeResponse, source: String): Result { + return try { + // 1. 检查是否已处理(去重,包括失败状态) + val existingProcessed = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id) + + if (existingProcessed != null) { + if (existingProcessed.status == "FAILED") { + logger.debug("交易已标记为失败,跳过: leaderId=$leaderId, tradeId=${trade.id}, source=$source") + return Result.success(Unit) + } + logger.debug("交易已处理,跳过: leaderId=$leaderId, tradeId=${trade.id}, source=$source") + return Result.success(Unit) + } + + // 检查是否已记录为失败交易 + val failedTrade = failedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id) + if (failedTrade != null) { + logger.debug("交易已记录为失败,跳过: leaderId=$leaderId, tradeId=${trade.id}, source=$source") + return 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 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) + logger.info("成功处理交易: leaderId=$leaderId, tradeId=${trade.id}, source=$source, side=${trade.side}") + } catch (e: DataIntegrityViolationException) { + // 唯一约束冲突,说明已经处理过了(可能是并发请求) + // 再次检查确认状态 + val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id) + if (existing != null) { + if (existing.status == "FAILED") { + logger.debug("交易已标记为失败(并发检测): leaderId=$leaderId, tradeId=${trade.id}") + return Result.success(Unit) + } + logger.debug("交易已处理(并发检测): leaderId=$leaderId, tradeId=${trade.id}, source=$source") + return Result.success(Unit) + } else { + // 如果检查不到,说明可能是其他约束冲突,重新抛出异常 + logger.warn("保存ProcessedTrade时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}", e) + throw e + } + } + + Result.success(Unit) + } catch (e: Exception) { + logger.error("处理交易异常: leaderId=$leaderId, tradeId=${trade.id}", e) + Result.failure(e) + } + } + + /** + * 处理买入交易 + * 创建跟单买入订单并记录到跟踪表 + */ + @Transactional + suspend fun processBuyTrade(leaderId: Long, trade: TradeResponse): Result { + return try { + // 1. 查找所有启用且支持该Leader的跟单关系 + val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId) + + if (copyTradings.isEmpty()) { + logger.debug("没有启用的跟单关系: leaderId=$leaderId") + return Result.success(Unit) + } + + // 2. 为每个跟单关系创建买入订单跟踪 + for (copyTrading in copyTradings) { + try { + // 获取模板 + val template = templateRepository.findById(copyTrading.templateId).orElse(null) + ?: continue + + // 获取账户 + val account = accountRepository.findById(copyTrading.accountId).orElse(null) + ?: continue + + // 验证账户API凭证 + if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) { + logger.warn("账户未配置API凭证,跳过创建订单: accountId=${account.id}, copyTradingId=${copyTrading.id}") + continue + } + + // 验证账户是否启用 + if (!account.isEnabled) { + logger.debug("账户未启用,跳过创建订单: accountId=${account.id}") + continue + } + + // 计算买入数量 + val buyQuantity = calculateBuyQuantity(trade, template) + + if (buyQuantity.lte(BigDecimal.ZERO)) { + logger.warn("计算出的买入数量为0或负数,跳过: copyTradingId=${copyTrading.id}, tradeId=${trade.id}") + continue + } + + // 验证订单数量限制(仅比例模式) + var finalBuyQuantity = buyQuantity + if (template.copyMode == "RATIO") { + val orderAmount = buyQuantity.multi(trade.price.toSafeBigDecimal()) + if (orderAmount.lt(template.minOrderSize)) { + logger.warn("订单金额低于最小限制,跳过: copyTradingId=${copyTrading.id}, amount=$orderAmount, min=${template.minOrderSize}") + continue + } + if (orderAmount.gt(template.maxOrderSize)) { + logger.warn("订单金额超过最大限制,调整数量: copyTradingId=${copyTrading.id}, amount=$orderAmount, max=${template.maxOrderSize}") + // 调整数量到最大值 + val adjustedQuantity = template.maxOrderSize.div(trade.price.toSafeBigDecimal()) + if (adjustedQuantity.lte(BigDecimal.ZERO)) { + logger.warn("调整后的数量为0或负数,跳过: copyTradingId=${copyTrading.id}") + continue + } + // 使用调整后的数量 + finalBuyQuantity = adjustedQuantity + } + } + + // 风险控制检查 + val riskCheckResult = checkRiskControls(copyTrading, template, finalBuyQuantity, trade.price.toSafeBigDecimal()) + if (!riskCheckResult.first) { + logger.warn("风险控制检查失败,跳过创建订单: copyTradingId=${copyTrading.id}, reason=${riskCheckResult.second}") + continue + } + + // 延迟跟单(如果配置了延迟) + if (template.delaySeconds > 0) { + logger.info("延迟跟单: copyTradingId=${copyTrading.id}, delaySeconds=${template.delaySeconds}") + delay(template.delaySeconds * 1000L) // 转换为毫秒 + } + + // 直接使用outcomeIndex获取tokenId(支持多元市场) + if (trade.outcomeIndex == null) { + logger.warn("交易缺少outcomeIndex,无法确定tokenId: tradeId=${trade.id}, market=${trade.market}") + continue + } + + // 获取tokenId(直接使用outcomeIndex,不转换为YES/NO) + val tokenIdResult = blockchainService.getTokenId(trade.market, trade.outcomeIndex) + if (tokenIdResult.isFailure) { + logger.error("获取tokenId失败: market=${trade.market}, outcomeIndex=${trade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}") + continue + } + val tokenId = tokenIdResult.getOrNull() ?: continue + + // 计算价格(应用价格容忍度) + val buyPrice = calculateAdjustedPrice(trade.price.toSafeBigDecimal(), template, isBuy = true) + + // 创建带认证的CLOB API客户端 + val clobApi = retrofitFactory.createClobApi( + account.apiKey!!, + account.apiSecret!!, + account.apiPassphrase!!, + account.walletAddress + ) + + // 调用API创建订单(带重试机制,重试时会重新生成salt并重新签名) + val createOrderResult = createOrderWithRetry( + clobApi = clobApi, + privateKey = account.privateKey, + makerAddress = account.proxyAddress, + tokenId = tokenId, + side = "BUY", + price = buyPrice.toString(), + size = finalBuyQuantity.toString(), + owner = account.apiKey!!, + copyTradingId = copyTrading.id!!, + tradeId = trade.id + ) + + if (createOrderResult.isFailure) { + // 创建订单失败,记录到失败表 + val errorMsg = createOrderResult.exceptionOrNull()?.message ?: "未知错误" + recordFailedTrade( + leaderId = leaderId, + trade = trade, + copyTradingId = copyTrading.id!!, + accountId = copyTrading.accountId, + side = "BUY", // 订单方向是BUY + price = buyPrice.toString(), + size = finalBuyQuantity.toString(), + errorMessage = errorMsg, + retryCount = 1 // 已重试一次 + ) + continue + } + + val realOrderId = createOrderResult.getOrNull() ?: continue + + // 创建买入订单跟踪记录(使用真实订单ID,使用outcomeIndex) + val tracking = CopyOrderTracking( + copyTradingId = copyTrading.id!!, + accountId = copyTrading.accountId, + leaderId = copyTrading.leaderId, + templateId = copyTrading.templateId, + marketId = trade.market, + side = trade.outcomeIndex?.toString() ?: "0", // 使用outcomeIndex作为side(兼容旧数据) + outcomeIndex = trade.outcomeIndex, // 新增字段 + buyOrderId = realOrderId, // 使用真实订单ID + leaderBuyTradeId = trade.id, + quantity = finalBuyQuantity, // 使用最终数量(可能已调整) + price = buyPrice, + remainingQuantity = finalBuyQuantity, + status = "filled" + ) + + copyOrderTrackingRepository.save(tracking) + logger.info("成功创建买入订单并记录跟踪: copyTradingId=${copyTrading.id}, orderId=$realOrderId, tradeId=${trade.id}, quantity=$finalBuyQuantity, price=$buyPrice") + } catch (e: Exception) { + logger.error("处理买入交易失败: copyTradingId=${copyTrading.id}, tradeId=${trade.id}", e) + // 继续处理下一个跟单关系 + } + } + + Result.success(Unit) + } catch (e: Exception) { + logger.error("处理买入交易异常: leaderId=$leaderId, tradeId=${trade.id}", e) + Result.failure(e) + } + } + + /** + * 处理卖出交易 + * 查找未匹配的买入订单并进行匹配 + */ + @Transactional + suspend fun processSellTrade(leaderId: Long, trade: TradeResponse): Result { + return try { + // 1. 查找所有启用且支持该Leader的跟单关系 + val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId) + + if (copyTradings.isEmpty()) { + logger.debug("没有启用的跟单关系: leaderId=$leaderId") + return Result.success(Unit) + } + + // 2. 为每个跟单关系处理卖出匹配 + for (copyTrading in copyTradings) { + try { + // 获取模板 + val template = templateRepository.findById(copyTrading.templateId).orElse(null) + ?: continue + + // 检查是否支持卖出 + if (!template.supportSell) { + logger.debug("模板不支持卖出,跳过: copyTradingId=${copyTrading.id}, templateId=${template.id}") + continue + } + + // 执行卖出匹配 + matchSellOrder(copyTrading, trade, template) + } catch (e: Exception) { + logger.error("处理卖出交易失败: copyTradingId=${copyTrading.id}, tradeId=${trade.id}", e) + // 继续处理下一个跟单关系 + } + } + + Result.success(Unit) + } catch (e: Exception) { + logger.error("处理卖出交易异常: leaderId=$leaderId, tradeId=${trade.id}", e) + Result.failure(e) + } + } + + /** + * 计算买入数量 + * 根据模板的copyMode计算 + */ + private fun calculateBuyQuantity(trade: TradeResponse, template: CopyTradingTemplate): BigDecimal { + return when (template.copyMode) { + "RATIO" -> { + // 比例模式:Leader 数量 × 比例 + trade.size.toSafeBigDecimal().multi(template.copyRatio) + } + "FIXED" -> { + // 固定金额模式:固定金额 / 买入价格 + val fixedAmount = template.fixedAmount + ?: throw IllegalStateException("固定金额模式下 fixedAmount 不能为空") + val buyPrice = trade.price.toSafeBigDecimal() + fixedAmount.div(buyPrice) + } + else -> throw IllegalArgumentException("不支持的 copyMode: ${template.copyMode}") + } + } + + /** + * 卖出订单匹配 + * 统一按比例计算,不区分RATIO或FIXED模式 + * 实际创建卖出订单并记录匹配关系 + */ + @Transactional + private suspend fun matchSellOrder( + copyTrading: CopyTrading, + leaderSellTrade: TradeResponse, + template: CopyTradingTemplate + ) { + // 1. 获取账户 + val account = accountRepository.findById(copyTrading.accountId).orElse(null) + ?: run { + logger.warn("账户不存在,跳过卖出匹配: accountId=${copyTrading.accountId}, copyTradingId=${copyTrading.id}") + return + } + + // 验证账户API凭证 + if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) { + logger.warn("账户未配置API凭证,跳过创建卖出订单: accountId=${account.id}, copyTradingId=${copyTrading.id}") + return + } + + // 验证账户是否启用 + if (!account.isEnabled) { + logger.debug("账户未启用,跳过创建卖出订单: accountId=${account.id}") + return + } + + // 2. 计算需要匹配的数量(统一按比例计算) + val needMatch = leaderSellTrade.size.toSafeBigDecimal().multi(template.copyRatio) + + // 3. 查找未匹配的买入订单(FIFO顺序) + // 直接使用outcomeIndex匹配,而不是转换为YES/NO + if (leaderSellTrade.outcomeIndex == null) { + logger.warn("卖出交易缺少outcomeIndex,无法匹配: tradeId=${leaderSellTrade.id}, market=${leaderSellTrade.market}") + return + } + + // 使用outcomeIndex查找匹配的买入订单(存储在CopyOrderTracking中的outcomeIndex) + val unmatchedOrders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex( + copyTrading.id!!, + leaderSellTrade.market, + leaderSellTrade.outcomeIndex + ) + + if (unmatchedOrders.isEmpty()) { + logger.debug("没有未匹配的买入订单: copyTradingId=${copyTrading.id}, market=${leaderSellTrade.market}, outcomeIndex=${leaderSellTrade.outcomeIndex}") + return + } + + // 4. 按FIFO顺序匹配,计算实际可以卖出的数量 + var totalMatched = BigDecimal.ZERO + var remaining = needMatch + val matchDetails = mutableListOf() + + for (order in unmatchedOrders) { + if (remaining.lte(BigDecimal.ZERO)) break + + val matchQty = minOf( + order.remainingQuantity.toSafeBigDecimal(), + remaining + ) + + if (matchQty.lte(BigDecimal.ZERO)) continue + + // 计算盈亏 + val buyPrice = order.price.toSafeBigDecimal() + val sellPrice = leaderSellTrade.price.toSafeBigDecimal() + val realizedPnl = sellPrice.subtract(buyPrice).multi(matchQty) + + // 创建匹配明细(稍后保存) + val detail = SellMatchDetail( + matchRecordId = 0, // 稍后设置 + trackingId = order.id!!, + buyOrderId = order.buyOrderId, + matchedQuantity = matchQty, + buyPrice = buyPrice, + sellPrice = sellPrice, + realizedPnl = realizedPnl + ) + matchDetails.add(detail) + + totalMatched = totalMatched.add(matchQty) + remaining = remaining.subtract(matchQty) + } + + if (totalMatched.lte(BigDecimal.ZERO)) { + logger.debug("没有匹配到任何订单: copyTradingId=${copyTrading.id}, needMatch=$needMatch") + return + } + + // 5. 获取tokenId(直接使用outcomeIndex,支持多元市场) + val tokenIdResult = blockchainService.getTokenId(leaderSellTrade.market, leaderSellTrade.outcomeIndex) + if (tokenIdResult.isFailure) { + logger.error("获取tokenId失败: market=${leaderSellTrade.market}, outcomeIndex=${leaderSellTrade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}") + return + } + val tokenId = tokenIdResult.getOrNull() ?: return + + // 6. 计算卖出价格(应用价格容忍度) + val sellPrice = calculateAdjustedPrice(leaderSellTrade.price.toSafeBigDecimal(), template, isBuy = false) + + // 7. 创建并签名卖出订单 + val signedOrder = try { + orderSigningService.createAndSignOrder( + privateKey = account.privateKey, + makerAddress = account.proxyAddress, + tokenId = tokenId, + side = "SELL", + price = sellPrice.toString(), + size = totalMatched.toString(), + signatureType = 2, // Browser Wallet + nonce = "0", + feeRateBps = "0", + expiration = "0" + ) + } catch (e: Exception) { + logger.error("创建并签名卖出订单失败: copyTradingId=${copyTrading.id}, tradeId=${leaderSellTrade.id}", e) + return + } + + // 8. 构建订单请求 + val orderRequest = NewOrderRequest( + order = signedOrder, + owner = account.apiKey!!, + orderType = "GTC", // Good-Til-Cancelled + deferExec = false + ) + + // 9. 创建带认证的CLOB API客户端 + val clobApi = retrofitFactory.createClobApi( + account.apiKey!!, + account.apiSecret!!, + account.apiPassphrase!!, + account.walletAddress + ) + + // 10. 调用API创建卖出订单(带重试机制,重试时会重新生成salt并重新签名) + val createOrderResult = createOrderWithRetry( + clobApi = clobApi, + privateKey = account.privateKey, + makerAddress = account.proxyAddress, + tokenId = tokenId, + side = "SELL", + price = sellPrice.toString(), + size = totalMatched.toString(), + owner = account.apiKey!!, + copyTradingId = copyTrading.id!!, + tradeId = leaderSellTrade.id + ) + + if (createOrderResult.isFailure) { + // 创建订单失败,记录到失败表 + val errorMsg = createOrderResult.exceptionOrNull()?.message ?: "未知错误" + recordFailedTrade( + leaderId = copyTrading.leaderId, + trade = leaderSellTrade, + copyTradingId = copyTrading.id!!, + accountId = copyTrading.accountId, + side = "SELL", // 订单方向是SELL + price = sellPrice.toString(), + size = totalMatched.toString(), + errorMessage = errorMsg, + retryCount = 1 // 已重试一次 + ) + return + } + + val realSellOrderId = createOrderResult.getOrNull() ?: return + + // 12. 更新买入订单跟踪状态 + for (order in unmatchedOrders) { + val detail = matchDetails.find { it.trackingId == order.id } + if (detail != null) { + order.matchedQuantity = order.matchedQuantity.add(detail.matchedQuantity) + order.remainingQuantity = order.remainingQuantity.subtract(detail.matchedQuantity) + updateOrderStatus(order) + order.updatedAt = System.currentTimeMillis() + copyOrderTrackingRepository.save(order) + + logger.info("匹配买入订单: copyTradingId=${copyTrading.id}, buyOrderId=${order.buyOrderId}, matchQty=${detail.matchedQuantity}, pnl=${detail.realizedPnl}") + } + } + + // 13. 创建卖出匹配记录(使用真实订单ID,使用outcomeIndex) + val totalRealizedPnl = matchDetails.sumOf { it.realizedPnl.toSafeBigDecimal() } + + val matchRecord = SellMatchRecord( + copyTradingId = copyTrading.id!!, + sellOrderId = realSellOrderId, // 使用真实订单ID + leaderSellTradeId = leaderSellTrade.id, + marketId = leaderSellTrade.market, + side = leaderSellTrade.outcomeIndex?.toString() ?: "0", // 使用outcomeIndex作为side(兼容旧数据) + outcomeIndex = leaderSellTrade.outcomeIndex, // 新增字段 + totalMatchedQuantity = totalMatched, + sellPrice = sellPrice, + totalRealizedPnl = totalRealizedPnl + ) + + val savedRecord = sellMatchRecordRepository.save(matchRecord) + + // 14. 保存匹配明细 + for (detail in matchDetails) { + val savedDetail = detail.copy(matchRecordId = savedRecord.id!!) + sellMatchDetailRepository.save(savedDetail) + } + + logger.info("完成卖出匹配并创建订单: copyTradingId=${copyTrading.id}, sellOrderId=$realSellOrderId, totalMatched=$totalMatched, totalPnl=$totalRealizedPnl") + } + + /** + * 创建订单(带重试机制) + * 失败后重试一次,如果仍然失败则返回失败结果 + * 注意:重试时会重新生成salt并重新签名,确保每次重试都是新的订单 + */ + private suspend fun createOrderWithRetry( + clobApi: com.wrbug.polymarketbot.api.PolymarketClobApi, + privateKey: String, + makerAddress: String, + tokenId: String, + side: String, + price: String, + size: String, + owner: String, // API Key,用于owner字段 + copyTradingId: Long, + tradeId: String + ): Result { + var lastError: Exception? = null + + // 最多重试2次(首次 + 1次重试) + for (attempt in 1..2) { + try { + // 每次重试都重新生成salt并重新签名 + val signedOrder = orderSigningService.createAndSignOrder( + privateKey = privateKey, + makerAddress = makerAddress, + tokenId = tokenId, + side = side, + price = price, + size = size, + signatureType = 2, // Browser Wallet + nonce = "0", + feeRateBps = "0", + expiration = "0" + ) + + // 构建订单请求(每次重试都使用新签名的订单) + val orderRequest = NewOrderRequest( + order = signedOrder, + owner = owner, // API Key + orderType = "GTC", // Good-Til-Cancelled + deferExec = false + ) + + val orderResponse = clobApi.createOrder(orderRequest) + + if (!orderResponse.isSuccessful || orderResponse.body() == null) { + lastError = Exception("创建订单失败: code=${orderResponse.code()}, message=${orderResponse.message()}") + if (attempt < 2) { + logger.warn("创建订单失败,准备重试(重新签名): copyTradingId=$copyTradingId, tradeId=$tradeId, attempt=$attempt") + delay(1000) // 重试前等待1秒 + continue + } + return Result.failure(lastError!!) + } + + val response = orderResponse.body()!! + if (!response.success || response.orderId == null) { + lastError = Exception("创建订单失败: errorMsg=${response.errorMsg}") + if (attempt < 2) { + logger.warn("创建订单失败,准备重试(重新签名): copyTradingId=$copyTradingId, tradeId=$tradeId, attempt=$attempt, errorMsg=${response.errorMsg}") + delay(1000) // 重试前等待1秒 + continue + } + return Result.failure(lastError!!) + } + + // 成功 + return Result.success(response.orderId) + } catch (e: Exception) { + lastError = e + if (attempt < 2) { + logger.warn("调用创建订单API异常,准备重试(重新签名): copyTradingId=$copyTradingId, tradeId=$tradeId, attempt=$attempt", e) + delay(1000) // 重试前等待1秒 + continue + } + return Result.failure(e) + } + } + + return Result.failure(lastError ?: Exception("创建订单失败:未知错误")) + } + + /** + * 记录失败交易到数据库 + */ + @Transactional + private fun recordFailedTrade( + leaderId: Long, + trade: TradeResponse, + copyTradingId: Long, + accountId: Long, + side: String, + price: String, + size: String, + errorMessage: String, + retryCount: Int + ) { + try { + val failedTrade = FailedTrade( + leaderId = leaderId, + leaderTradeId = trade.id, + tradeType = trade.side.uppercase(), + copyTradingId = copyTradingId, + accountId = accountId, + marketId = trade.market, + side = side, + price = price, + size = size, + errorMessage = errorMessage, + retryCount = retryCount, + failedAt = System.currentTimeMillis() + ) + failedTradeRepository.save(failedTrade) + + // 标记为已处理(失败状态),避免重复处理 + // 注意:并发情况下可能多个请求同时处理同一笔交易,需要处理唯一约束冲突 + try { + val processed = ProcessedTrade( + leaderId = leaderId, + leaderTradeId = trade.id, + tradeType = trade.side.uppercase(), + source = "polling", + status = "FAILED", + processedAt = System.currentTimeMillis() + ) + processedTradeRepository.save(processed) + } catch (e: DataIntegrityViolationException) { + // 唯一约束冲突,说明已经处理过了(可能是并发请求) + // 检查现有记录的状态 + val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id) + if (existing != null) { + if (existing.status == "SUCCESS") { + logger.warn("交易已成功处理,但尝试记录为失败(并发冲突): leaderId=$leaderId, tradeId=${trade.id}") + } else { + logger.debug("交易已标记为失败(并发检测): leaderId=$leaderId, tradeId=${trade.id}") + } + } else { + logger.warn("保存ProcessedTrade失败记录时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}", e) + } + } + + logger.warn("已记录失败交易: leaderId=$leaderId, tradeId=${trade.id}, error=$errorMessage") + } catch (e: Exception) { + logger.error("记录失败交易异常: leaderId=$leaderId, tradeId=${trade.id}", e) + } + } + + /** + * 更新订单状态 + */ + private fun updateOrderStatus(tracking: CopyOrderTracking) { + when { + tracking.remainingQuantity.toSafeBigDecimal().eq(BigDecimal.ZERO) -> { + tracking.status = "fully_matched" + } + tracking.matchedQuantity.toSafeBigDecimal().gt(BigDecimal.ZERO) -> { + tracking.status = "partially_matched" + } + else -> { + tracking.status = "filled" + } + } + } + + /** + * 风险控制检查 + * 返回 Pair<是否通过, 失败原因> + */ + private fun checkRiskControls( + copyTrading: CopyTrading, + template: CopyTradingTemplate, + quantity: BigDecimal, + price: BigDecimal + ): Pair { + // 1. 检查每日订单数限制 + val todayStart = System.currentTimeMillis() - (System.currentTimeMillis() % 86400000) // 今天0点的时间戳 + val todayBuyOrders = copyOrderTrackingRepository.findByCopyTradingId(copyTrading.id!!) + .filter { it.createdAt >= todayStart } + + if (todayBuyOrders.size >= template.maxDailyOrders) { + return Pair(false, "今日订单数已达上限: ${todayBuyOrders.size}/${template.maxDailyOrders}") + } + + // 2. 检查每日亏损限制(需要计算今日已实现盈亏) + val todaySellRecords = sellMatchRecordRepository.findByCopyTradingId(copyTrading.id!!) + .filter { it.createdAt >= todayStart } + + val todayRealizedPnl = todaySellRecords.sumOf { it.totalRealizedPnl.toSafeBigDecimal() } + if (todayRealizedPnl.lt(BigDecimal.ZERO)) { + val todayLoss = todayRealizedPnl.abs() + if (todayLoss.gte(template.maxDailyLoss)) { + return Pair(false, "今日亏损已达上限: ${todayLoss}/${template.maxDailyLoss}") + } + } + + return Pair(true, "") + } + + /** + * 计算调整后的价格(应用价格容忍度) + */ + private fun calculateAdjustedPrice( + originalPrice: BigDecimal, + template: CopyTradingTemplate, + isBuy: Boolean + ): BigDecimal { + // 如果价格容忍度为0,直接返回原价格 + if (template.priceTolerance.eq(BigDecimal.ZERO)) { + return originalPrice + } + + // 计算价格调整范围(百分比) + val tolerancePercent = template.priceTolerance.div(100) + val adjustment = originalPrice.multi(tolerancePercent) + + return if (isBuy) { + // 买入:可以稍微加价以确保成交(在原价格基础上加容忍度) + originalPrice.add(adjustment).coerceAtMost(BigDecimal("0.99")) + } else { + // 卖出:可以稍微减价以确保成交(在原价格基础上减容忍度) + originalPrice.subtract(adjustment).coerceAtLeast(BigDecimal("0.01")) + } + } + + /** + * 从trade中提取side(YES/NO) + * + * 说明: + * - 根据设计文档,系统只支持sports和crypto分类,这些通常是二元市场(YES/NO) + * - TradeResponse中的side是BUY/SELL(订单方向),不是YES/NO(outcome) + * - 在二元市场中: + * - outcomeIndex 0 = YES token + * - outcomeIndex 1 = NO token + * - 如果Leader买入outcomeIndex=1的结果(如"Down"),应该买入NO token + * - 如果Leader买入outcomeIndex=0的结果(如"Up"),应该买入YES token + * + * 判断逻辑: + * 1. 如果tradeSide已经是YES/NO,直接返回 + * 2. 如果有outcomeIndex,根据outcomeIndex判断:0=YES, 1=NO + * 3. 如果有outcome名称,尝试从名称判断(Up/Yes=YES, Down/No=NO) + * 4. 否则,默认返回YES(兼容旧逻辑) + */ + private fun extractSide( + marketId: String, + tradeSide: String, + outcomeIndex: Int? = null, + outcome: String? = null + ): String { + // 1. 如果tradeSide已经是YES/NO,直接返回 + when (tradeSide.uppercase()) { + "YES" -> return "YES" + "NO" -> return "NO" + } + + // 2. 根据outcomeIndex判断(最准确) + if (outcomeIndex != null) { + return when (outcomeIndex) { + 0 -> "YES" // outcomeIndex 0 = YES token + 1 -> "NO" // outcomeIndex 1 = NO token + else -> { + logger.warn("未知的outcomeIndex,默认返回YES: outcomeIndex=$outcomeIndex, marketId=$marketId") + "YES" + } + } + } + + // 3. 根据outcome名称判断(备用方案) + if (outcome != null) { + val outcomeUpper = outcome.uppercase() + when { + outcomeUpper.contains("UP") || outcomeUpper.contains("YES") -> return "YES" + outcomeUpper.contains("DOWN") || outcomeUpper.contains("NO") -> return "NO" + } + } + + // 4. 根据tradeSide判断(兼容旧逻辑) + return when (tradeSide.uppercase()) { + "BUY" -> { + logger.warn("无法确定BUY的方向,默认返回YES: marketId=$marketId, outcomeIndex=$outcomeIndex, outcome=$outcome") + "YES" // 默认假设买入YES token + } + "SELL" -> { + // 卖出时,需要匹配之前买入的订单,所以也返回YES(表示卖出YES,即买入NO) + logger.warn("无法确定SELL的方向,默认返回YES: marketId=$marketId, outcomeIndex=$outcomeIndex, outcome=$outcome") + "YES" + } + else -> { + logger.warn("未知的交易方向,默认返回YES: tradeSide=$tradeSide, marketId=$marketId") + "YES" // 默认返回YES + } + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingMonitorService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingMonitorService.kt new file mode 100644 index 0000000..22c6508 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingMonitorService.kt @@ -0,0 +1,124 @@ +package com.wrbug.polymarketbot.service + +import com.wrbug.polymarketbot.entity.CopyTrading +import com.wrbug.polymarketbot.entity.Leader +import com.wrbug.polymarketbot.repository.CopyTradingRepository +import com.wrbug.polymarketbot.repository.LeaderRepository +import jakarta.annotation.PostConstruct +import jakarta.annotation.PreDestroy +import kotlinx.coroutines.* +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service + +/** + * 跟单监听服务(主服务) + * 管理所有Leader的交易监听(使用轮询方式) + * 注意:WebSocket 需要认证才能订阅其他用户的交易,因此只使用轮询方式 + */ +@Service +class CopyTradingMonitorService( + private val copyTradingRepository: CopyTradingRepository, + private val leaderRepository: LeaderRepository, + private val pollingService: CopyTradingPollingService +) { + + private val logger = LoggerFactory.getLogger(CopyTradingMonitorService::class.java) + + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + /** + * 系统启动时初始化监听 + */ + @PostConstruct + fun init() { + logger.info("跟单监听服务初始化...") + scope.launch { + try { + startMonitoring() + } catch (e: Exception) { + logger.error("启动跟单监听失败", e) + } + } + } + + /** + * 系统关闭时清理资源 + */ + @PreDestroy + fun destroy() { + logger.info("停止跟单监听服务...") + scope.cancel() + // 只使用轮询,不使用WebSocket + pollingService.stop() + } + + /** + * 启动监听 + */ + suspend fun startMonitoring() { + // 1. 获取所有启用的跟单关系 + val enabledCopyTradings = copyTradingRepository.findByEnabledTrue() + + if (enabledCopyTradings.isEmpty()) { + logger.info("没有启用的跟单关系,等待添加...") + return + } + + // 2. 获取所有需要监听的Leader(去重) + val leaderIds = enabledCopyTradings.map { it.leaderId }.distinct() + val leaders = leaderIds.mapNotNull { leaderId -> + leaderRepository.findById(leaderId).orElse(null) + } + + logger.info("开始监听 ${leaders.size} 个Leader的交易: ${leaders.map { it.leaderAddress }}") + + // 3. 启动轮询监听(使用 /activity 接口,不需要认证) + // 注意:WebSocket 需要认证才能订阅其他用户的交易,因此禁用WebSocket,只使用轮询 + pollingService.start(leaders) + } + + /** + * 添加Leader监听(当创建新的跟单关系时调用) + */ + suspend fun addLeaderMonitoring(leaderId: Long) { + val leader = leaderRepository.findById(leaderId).orElse(null) + ?: return + + val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId) + if (copyTradings.isEmpty()) { + logger.debug("Leader $leaderId 没有启用的跟单关系,不启动监听") + return + } + + logger.info("添加Leader监听: ${leader.leaderAddress}") + // 只使用轮询,不使用WebSocket(需要认证) + pollingService.addLeader(leader) + } + + /** + * 移除Leader监听(当删除跟单关系时调用) + */ + suspend fun removeLeaderMonitoring(leaderId: Long) { + val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId) + if (copyTradings.isNotEmpty()) { + logger.debug("Leader $leaderId 仍有启用的跟单关系,不停止监听") + return + } + + logger.info("移除Leader监听: leaderId=$leaderId") + // 只使用轮询,不使用WebSocket + pollingService.removeLeader(leaderId) + } + + /** + * 重新启动监听(当跟单关系状态改变时调用) + */ + suspend fun restartMonitoring() { + logger.info("重新启动跟单监听...") + // 只使用轮询,不使用WebSocket + pollingService.stop() + delay(1000) // 等待1秒 + startMonitoring() + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingPollingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingPollingService.kt new file mode 100644 index 0000000..d1339b2 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingPollingService.kt @@ -0,0 +1,284 @@ +package com.wrbug.polymarketbot.service + +import com.wrbug.polymarketbot.api.TradeResponse +import com.wrbug.polymarketbot.api.UserActivityResponse +import com.wrbug.polymarketbot.entity.Leader +import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository +import com.wrbug.polymarketbot.util.RetrofitFactory +import jakarta.annotation.PreDestroy +import kotlinx.coroutines.* +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import retrofit2.Response +import java.util.concurrent.ConcurrentHashMap + +/** + * 跟单轮询监听服务 + * 通过定期轮询 Polymarket Data API 的 /activity 接口获取Leader的交易记录 + * 使用 /activity 接口可以查询用户的链上活动,包括交易 + */ +@Service +class CopyTradingPollingService( + private val copyOrderTrackingService: CopyOrderTrackingService, + private val retrofitFactory: RetrofitFactory, + private val templateRepository: CopyTradingTemplateRepository +) { + + private val logger = LoggerFactory.getLogger(CopyTradingPollingService::class.java) + + @Value("\${copy.trading.polling.interval:2000}") + private var pollingInterval: Long = 2000 // 轮询间隔(毫秒),默认2秒 + + @Value("\${copy.trading.polling.enabled:true}") + private var pollingEnabled: Boolean = true // 是否启用轮询 + + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + // 存储需要监听的Leader:leaderId -> Leader + private val monitoredLeaders = ConcurrentHashMap() + + // 存储每个Leader已缓存的交易ID集合:leaderId -> Set + private val cachedTradeIds = ConcurrentHashMap>() + + // 存储每个Leader是否首次轮询:leaderId -> isFirstPoll + private val isFirstPoll = ConcurrentHashMap() + + // 轮询任务 + private var pollingJob: Job? = null + + /** + * 启动轮询监听 + */ + fun start(leaders: List) { + if (!pollingEnabled) { + logger.info("轮询监听已禁用,跳过启动") + return + } + + logger.info("启动轮询监听,Leader数量: ${leaders.size},轮询间隔: ${pollingInterval}ms") + + leaders.forEach { leader -> + addLeader(leader) + } + + // 启动轮询任务 + startPolling() + } + + /** + * 添加Leader监听 + */ + fun addLeader(leader: Leader) { + if (leader.id == null) { + logger.warn("Leader ID为空,跳过: ${leader.leaderAddress}") + return + } + + val leaderId = leader.id!! + monitoredLeaders[leaderId] = leader + // 初始化缓存的交易ID集合 + cachedTradeIds[leaderId] = mutableSetOf() + // 首次轮询标志,用于缓存数据而不处理 + isFirstPoll[leaderId] = true + logger.info("添加轮询监听: leaderId=$leaderId, address=${leader.leaderAddress}, 首次轮询将只缓存数据") + } + + /** + * 移除Leader监听 + */ + fun removeLeader(leaderId: Long) { + monitoredLeaders.remove(leaderId) + cachedTradeIds.remove(leaderId) + isFirstPoll.remove(leaderId) + logger.info("移除轮询监听: leaderId=$leaderId") + } + + /** + * 停止所有监听 + */ + fun stop() { + logger.info("停止所有轮询监听...") + stopPolling() + monitoredLeaders.clear() + cachedTradeIds.clear() + isFirstPoll.clear() + } + + /** + * 启动轮询任务 + */ + private fun startPolling() { + if (pollingJob != null && pollingJob!!.isActive) { + logger.debug("轮询任务已在运行") + return + } + + if (monitoredLeaders.isEmpty()) { + logger.debug("没有需要监听的Leader,不启动轮询") + return + } + + pollingJob = scope.launch { + logger.info("轮询任务已启动,间隔: ${pollingInterval}ms") + + while (isActive) { + try { + // 轮询所有Leader的交易 + pollAllLeaders() + + // 等待下一次轮询 + delay(pollingInterval) + } catch (e: Exception) { + logger.error("轮询任务异常", e) + delay(pollingInterval) // 异常后继续等待 + } + } + } + } + + /** + * 停止轮询任务 + */ + private fun stopPolling() { + pollingJob?.cancel() + pollingJob = null + } + + /** + * 轮询所有Leader的交易 + */ + private suspend fun pollAllLeaders() { + val leaders = monitoredLeaders.values.toList() + + if (leaders.isEmpty()) { + return + } + + // 并发轮询所有Leader(限制并发数) + leaders.chunked(10).forEach { chunk -> + chunk.forEach { leader -> + try { + pollLeaderTrades(leader) + } catch (e: Exception) { + logger.error("轮询Leader交易失败: leaderId=${leader.id}, address=${leader.leaderAddress}", e) + } + } + // 每个chunk之间稍作延迟,避免API限流 + delay(100) + } + } + + /** + * 轮询单个Leader的交易 + * 使用 Polymarket Data API 的 /activity 接口 + * 通过 diff 分析增量数据,不使用 start 字段 + */ + private suspend fun pollLeaderTrades(leader: Leader) { + if (leader.id == null) { + return + } + + val leaderId = leader.id!! + val leaderAddress = leader.leaderAddress + + try { + val firstPoll = isFirstPoll[leaderId] == true + val cachedIds = cachedTradeIds[leaderId] ?: mutableSetOf() + + // 创建 Data API 客户端(不需要认证) + val dataApi = retrofitFactory.createDataApi() + + // 查询用户活动(只查询交易类型,不使用 start 字段) + // 查询最近的数据(limit=100),通过 diff 找出增量 + val response: Response> = dataApi.getUserActivity( + user = leaderAddress, + limit = 100, // 每次最多查询100条 + offset = 0, + type = listOf("TRADE"), // 只查询交易类型 + start = null, // 不使用 start 字段 + sortBy = "TIMESTAMP", + sortDirection = "DESC" // 按时间戳降序,最新的在前 + ) + + if (!response.isSuccessful || response.body() == null) { + logger.warn("获取Leader活动失败: leaderId=$leaderId, address=$leaderAddress, code=${response.code()}, message=${response.message()}") + return + } + + val activities = response.body()!! + + // 将 UserActivityResponse 转换为 TradeResponse + val allTrades = activities.mapNotNull { activity -> + // 只处理交易类型 + if (activity.type != "TRADE" || activity.side == null || activity.price == null || activity.size == null) { + return@mapNotNull null + } + + // 转换为 TradeResponse + TradeResponse( + id = activity.transactionHash ?: "${activity.timestamp}_${activity.conditionId}", + market = activity.conditionId, + side = activity.side, // BUY 或 SELL + price = activity.price.toString(), + size = activity.size.toString(), + timestamp = activity.timestamp.toString(), // 时间戳(秒) + user = activity.proxyWallet, + outcomeIndex = activity.outcomeIndex, // 结果索引(0=YES, 1=NO) + outcome = activity.outcome // 结果名称 + ) + } + + if (firstPoll) { + // 首次轮询:缓存所有查询到的交易ID,不处理 + val tradeIds = allTrades.map { it.id }.toSet() + cachedIds.addAll(tradeIds) + cachedTradeIds[leaderId] = cachedIds + + logger.info("首次轮询,缓存 ${allTrades.size} 笔交易数据,不进行处理: leaderId=$leaderId") + // 标记首次轮询完成 + isFirstPoll[leaderId] = false + } else { + // 后续轮询:通过 diff 找出新增的交易 + val newTradeIds = allTrades.map { it.id }.toSet() + val incrementalTradeIds = newTradeIds - cachedIds + + if (incrementalTradeIds.isNotEmpty()) { + // 找出新增的交易 + val incrementalTrades = allTrades.filter { it.id in incrementalTradeIds } + + logger.debug("通过 diff 发现 ${incrementalTrades.size} 笔新增交易: leaderId=$leaderId") + + // 处理新增的交易 + incrementalTrades.forEach { trade -> + try { + // 检查是否已处理(去重由processTrade内部处理) + copyOrderTrackingService.processTrade(leaderId, trade, "polling") + } catch (e: Exception) { + logger.error("处理交易失败: leaderId=$leaderId, tradeId=${trade.id}", e) + } + } + + // 更新缓存:添加新增的交易ID + cachedIds.addAll(incrementalTradeIds) + cachedTradeIds[leaderId] = cachedIds + + logger.debug("已更新缓存,当前缓存 ${cachedIds.size} 笔交易ID: leaderId=$leaderId") + } else { + logger.debug("未发现新增交易: leaderId=$leaderId") + } + + // 限制缓存大小,避免内存溢出(只保留最近1000条) + if (cachedIds.size > 1000) { + // 保留最新的1000条(由于查询是按时间戳降序,保留前1000条即可) + val sortedTradeIds = allTrades.map { it.id }.take(1000).toSet() + cachedTradeIds[leaderId] = sortedTradeIds.toMutableSet() + logger.debug("缓存已满,清理到1000条: leaderId=$leaderId") + } + } + } catch (e: Exception) { + logger.error("轮询Leader交易异常: leaderId=$leaderId, address=$leaderAddress", e) + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingService.kt index 194bf63..ce33df9 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingService.kt @@ -18,7 +18,8 @@ class CopyTradingService( private val copyTradingRepository: CopyTradingRepository, private val accountRepository: AccountRepository, private val templateRepository: CopyTradingTemplateRepository, - private val leaderRepository: LeaderRepository + private val leaderRepository: LeaderRepository, + private val monitorService: CopyTradingMonitorService ) { private val logger = LoggerFactory.getLogger(CopyTradingService::class.java) @@ -62,6 +63,17 @@ class CopyTradingService( val saved = copyTradingRepository.save(copyTrading) logger.info("成功创建跟单: ${saved.id}, account=${request.accountId}, template=${request.templateId}, leader=${request.leaderId}") + // 如果跟单已启用,启动Leader监听 + if (saved.enabled) { + kotlinx.coroutines.runBlocking { + try { + monitorService.addLeaderMonitoring(saved.leaderId) + } catch (e: Exception) { + logger.error("启动Leader监听失败: leaderId=${saved.leaderId}", e) + } + } + } + Result.success(toDto(saved, account, template, leader)) } catch (e: Exception) { logger.error("创建跟单失败", e) @@ -152,6 +164,19 @@ class CopyTradingService( val saved = copyTradingRepository.save(updated) logger.info("成功更新跟单状态: ${saved.id}, enabled=${saved.enabled}") + // 更新监听状态 + kotlinx.coroutines.runBlocking { + try { + if (saved.enabled) { + monitorService.addLeaderMonitoring(saved.leaderId) + } else { + monitorService.removeLeaderMonitoring(saved.leaderId) + } + } catch (e: Exception) { + logger.error("更新Leader监听状态失败: leaderId=${saved.leaderId}", e) + } + } + val account = accountRepository.findById(saved.accountId).orElse(null) val template = templateRepository.findById(saved.templateId).orElse(null) val leader = leaderRepository.findById(saved.leaderId).orElse(null) @@ -176,9 +201,19 @@ class CopyTradingService( val copyTrading = copyTradingRepository.findById(copyTradingId).orElse(null) ?: return Result.failure(IllegalArgumentException("跟单关系不存在")) + val leaderId = copyTrading.leaderId copyTradingRepository.delete(copyTrading) logger.info("成功删除跟单: $copyTradingId") + // 移除监听(如果该Leader没有其他启用的跟单关系) + kotlinx.coroutines.runBlocking { + try { + monitorService.removeLeaderMonitoring(leaderId) + } catch (e: Exception) { + logger.error("移除Leader监听失败: leaderId=$leaderId", e) + } + } + Result.success(Unit) } catch (e: Exception) { logger.error("删除跟单失败", e) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingStatisticsService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingStatisticsService.kt new file mode 100644 index 0000000..86886b3 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingStatisticsService.kt @@ -0,0 +1,411 @@ +package com.wrbug.polymarketbot.service + +import com.wrbug.polymarketbot.dto.* +import com.wrbug.polymarketbot.entity.* +import com.wrbug.polymarketbot.repository.* +import com.wrbug.polymarketbot.util.toSafeBigDecimal +import com.wrbug.polymarketbot.util.multi +import com.wrbug.polymarketbot.util.div +import com.wrbug.polymarketbot.util.gt +import com.wrbug.polymarketbot.util.eq +import com.wrbug.polymarketbot.util.lte +import org.slf4j.LoggerFactory +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Service +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * 跟单统计服务 + * 提供统计信息和订单列表查询 + */ +@Service +class CopyTradingStatisticsService( + private val copyTradingRepository: CopyTradingRepository, + private val copyOrderTrackingRepository: CopyOrderTrackingRepository, + private val sellMatchRecordRepository: SellMatchRecordRepository, + private val sellMatchDetailRepository: SellMatchDetailRepository, + private val accountRepository: AccountRepository, + private val leaderRepository: LeaderRepository, + private val templateRepository: CopyTradingTemplateRepository, + private val accountService: AccountService +) { + + private val logger = LoggerFactory.getLogger(CopyTradingStatisticsService::class.java) + + /** + * 获取跟单关系统计 + */ + suspend fun getStatistics(copyTradingId: Long): Result { + return try { + // 1. 获取跟单关系 + val copyTrading = copyTradingRepository.findById(copyTradingId).orElse(null) + ?: return Result.failure(IllegalArgumentException("跟单关系不存在: $copyTradingId")) + + // 2. 获取关联信息 + val account = accountRepository.findById(copyTrading.accountId).orElse(null) + val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null) + val template = templateRepository.findById(copyTrading.templateId).orElse(null) + + // 3. 获取买入订单 + val buyOrders = copyOrderTrackingRepository.findByCopyTradingId(copyTradingId) + + // 4. 获取卖出记录 + val sellRecords = sellMatchRecordRepository.findByCopyTradingId(copyTradingId) + + // 5. 获取匹配明细 + val matchDetails = sellMatchDetailRepository.findByCopyTradingId(copyTradingId) + + // 6. 计算统计信息 + val statistics = calculateStatistics(buyOrders, sellRecords, matchDetails) + + // 7. 获取当前市场价格(用于计算未实现盈亏) + val currentPrice = getCurrentMarketPrice(buyOrders) + + // 8. 计算未实现盈亏 + val unrealizedPnl = calculateUnrealizedPnl(buyOrders, currentPrice) + + // 9. 构建响应 + val response = CopyTradingStatisticsResponse( + copyTradingId = copyTradingId, + accountId = copyTrading.accountId, + accountName = account?.accountName, + leaderId = copyTrading.leaderId, + leaderName = leader?.leaderName, + templateId = copyTrading.templateId, + templateName = template?.templateName, + enabled = copyTrading.enabled, + totalBuyQuantity = statistics.totalBuyQuantity, + totalBuyOrders = statistics.totalBuyOrders, + totalBuyAmount = statistics.totalBuyAmount, + avgBuyPrice = statistics.avgBuyPrice, + totalSellQuantity = statistics.totalSellQuantity, + totalSellOrders = statistics.totalSellOrders, + totalSellAmount = statistics.totalSellAmount, + currentPositionQuantity = statistics.currentPositionQuantity, + currentPositionValue = calculatePositionValue(statistics.currentPositionQuantity, currentPrice), + totalRealizedPnl = statistics.totalRealizedPnl, + totalUnrealizedPnl = unrealizedPnl, + totalPnl = (statistics.totalRealizedPnl.toSafeBigDecimal().add(unrealizedPnl.toSafeBigDecimal())).toString(), + totalPnlPercent = calculatePnlPercent(statistics.totalBuyAmount, statistics.totalRealizedPnl, unrealizedPnl) + ) + + Result.success(response) + } catch (e: Exception) { + logger.error("获取统计信息失败: copyTradingId=$copyTradingId", e) + Result.failure(e) + } + } + + /** + * 查询订单列表 + */ + fun getOrderList(request: OrderTrackingRequest): Result { + return try { + // 1. 验证跟单关系 + val copyTrading = copyTradingRepository.findById(request.copyTradingId).orElse(null) + ?: return Result.failure(IllegalArgumentException("跟单关系不存在: ${request.copyTradingId}")) + + // 2. 根据类型查询 + val (list, total) = when (request.type.lowercase()) { + "buy" -> getBuyOrderList(request) + "sell" -> getSellOrderList(request) + "matched" -> getMatchedOrderList(request) + else -> return Result.failure(IllegalArgumentException("不支持的订单类型: ${request.type}")) + } + + // 3. 构建响应 + val response = OrderListResponse( + list = list, + total = total, + page = request.page ?: 1, + limit = request.limit ?: 20 + ) + + Result.success(response) + } catch (e: Exception) { + logger.error("查询订单列表失败: ${request.copyTradingId}, type=${request.type}", e) + Result.failure(e) + } + } + + /** + * 获取买入订单列表 + */ + private fun getBuyOrderList(request: OrderTrackingRequest): Pair, Long> { + var orders = copyOrderTrackingRepository.findByCopyTradingId(request.copyTradingId) + + // 筛选 + if (!request.marketId.isNullOrBlank()) { + orders = orders.filter { it.marketId == request.marketId } + } + if (!request.side.isNullOrBlank()) { + orders = orders.filter { it.side == request.side } + } + if (!request.status.isNullOrBlank()) { + orders = orders.filter { it.status == request.status } + } + + val total = orders.size.toLong() + + // 排序(按创建时间倒序) + orders = orders.sortedByDescending { it.createdAt } + + // 分页 + val page = (request.page ?: 1) - 1 + val limit = request.limit ?: 20 + val start = page * limit + val end = minOf(start + limit, orders.size) + val pagedOrders = if (start < orders.size) orders.subList(start, end) else emptyList() + + // 转换为DTO + val list = pagedOrders.map { order -> + val amount = order.quantity.toSafeBigDecimal().multi(order.price) + BuyOrderInfo( + orderId = order.buyOrderId, + leaderTradeId = order.leaderBuyTradeId, + marketId = order.marketId, + side = order.side, + quantity = order.quantity.toString(), + price = order.price.toString(), + amount = amount.toString(), + matchedQuantity = order.matchedQuantity.toString(), + remainingQuantity = order.remainingQuantity.toString(), + status = order.status, + createdAt = order.createdAt + ) + } + + return Pair(list, total) + } + + /** + * 获取卖出订单列表 + */ + private fun getSellOrderList(request: OrderTrackingRequest): Pair, Long> { + var records = sellMatchRecordRepository.findByCopyTradingId(request.copyTradingId) + + // 筛选 + if (!request.marketId.isNullOrBlank()) { + records = records.filter { it.marketId == request.marketId } + } + if (!request.side.isNullOrBlank()) { + records = records.filter { it.side == request.side } + } + + val total = records.size.toLong() + + // 排序(按创建时间倒序) + records = records.sortedByDescending { it.createdAt } + + // 分页 + val page = (request.page ?: 1) - 1 + val limit = request.limit ?: 20 + val start = page * limit + val end = minOf(start + limit, records.size) + val pagedRecords = if (start < records.size) records.subList(start, end) else emptyList() + + // 转换为DTO + val list = pagedRecords.map { record -> + val amount = record.totalMatchedQuantity.toSafeBigDecimal().multi(record.sellPrice) + SellOrderInfo( + orderId = record.sellOrderId, + leaderTradeId = record.leaderSellTradeId, + marketId = record.marketId, + side = record.side, + quantity = record.totalMatchedQuantity.toString(), + price = record.sellPrice.toString(), + amount = amount.toString(), + realizedPnl = record.totalRealizedPnl.toString(), + createdAt = record.createdAt + ) + } + + return Pair(list, total) + } + + /** + * 获取匹配订单列表 + */ + private fun getMatchedOrderList(request: OrderTrackingRequest): Pair, Long> { + val matchDetails = sellMatchDetailRepository.findByCopyTradingId(request.copyTradingId) + + // 筛选 + var filtered = matchDetails + if (!request.sellOrderId.isNullOrBlank()) { + val sellRecord = sellMatchRecordRepository.findBySellOrderId(request.sellOrderId) + if (sellRecord != null) { + filtered = filtered.filter { it.matchRecordId == sellRecord.id } + } else { + filtered = emptyList() + } + } + if (!request.buyOrderId.isNullOrBlank()) { + filtered = filtered.filter { it.buyOrderId == request.buyOrderId } + } + + val total = filtered.size.toLong() + + // 排序(按创建时间倒序) + filtered = filtered.sortedByDescending { it.createdAt } + + // 分页 + val page = (request.page ?: 1) - 1 + val limit = request.limit ?: 20 + val start = page * limit + val end = minOf(start + limit, filtered.size) + val pagedDetails = if (start < filtered.size) filtered.subList(start, end) else emptyList() + + // 转换为DTO + val list = pagedDetails.map { detail -> + MatchedOrderInfo( + sellOrderId = sellMatchRecordRepository.findById(detail.matchRecordId).orElse(null)?.sellOrderId ?: "", + buyOrderId = detail.buyOrderId, + matchedQuantity = detail.matchedQuantity.toString(), + buyPrice = detail.buyPrice.toString(), + sellPrice = detail.sellPrice.toString(), + realizedPnl = detail.realizedPnl.toString(), + matchedAt = detail.createdAt + ) + } + + return Pair(list, total) + } + + /** + * 计算统计信息 + */ + private fun calculateStatistics( + buyOrders: List, + sellRecords: List, + matchDetails: List + ): StatisticsData { + // 买入统计 + val totalBuyQuantity = buyOrders.sumOf { it.quantity.toSafeBigDecimal() } + val totalBuyAmount = buyOrders.sumOf { it.quantity.toSafeBigDecimal().multi(it.price) } + val totalBuyOrders = buyOrders.size.toLong() + val avgBuyPrice = if (totalBuyQuantity.gt(BigDecimal.ZERO)) { + totalBuyAmount.div(totalBuyQuantity) + } else { + BigDecimal.ZERO + } + + // 卖出统计 + val totalSellQuantity = sellRecords.sumOf { it.totalMatchedQuantity.toSafeBigDecimal() } + val totalSellAmount = sellRecords.sumOf { it.totalMatchedQuantity.toSafeBigDecimal().multi(it.sellPrice) } + val totalSellOrders = sellRecords.size.toLong() + + // 持仓统计 + val currentPositionQuantity = buyOrders.sumOf { it.remainingQuantity.toSafeBigDecimal() } + + // 已实现盈亏 + val totalRealizedPnl = matchDetails.sumOf { it.realizedPnl.toSafeBigDecimal() } + + return StatisticsData( + totalBuyQuantity = totalBuyQuantity.toString(), + totalBuyOrders = totalBuyOrders, + totalBuyAmount = totalBuyAmount.toString(), + avgBuyPrice = avgBuyPrice.toString(), + totalSellQuantity = totalSellQuantity.toString(), + totalSellOrders = totalSellOrders, + totalSellAmount = totalSellAmount.toString(), + currentPositionQuantity = currentPositionQuantity.toString(), + totalRealizedPnl = totalRealizedPnl.toString() + ) + } + + /** + * 获取当前市场价格 + */ + private suspend fun getCurrentMarketPrice(buyOrders: List): Map { + val prices = mutableMapOf() + + // 获取所有不同的市场ID + val marketIds = buyOrders.map { it.marketId }.distinct() + + for (marketId in marketIds) { + try { + val result = accountService.getMarketPrice(marketId) + result.onSuccess { response -> + // 使用中间价,如果没有则使用最后价格 + val price = response.midpoint ?: response.lastPrice + if (price != null) { + prices[marketId] = price + } + } + } catch (e: Exception) { + logger.warn("获取市场价格失败: marketId=$marketId", e) + } + } + + return prices + } + + /** + * 计算未实现盈亏 + */ + private fun calculateUnrealizedPnl( + buyOrders: List, + currentPrices: Map + ): String { + var totalUnrealizedPnl = BigDecimal.ZERO + + for (order in buyOrders) { + val remainingQty = order.remainingQuantity.toSafeBigDecimal() + if (remainingQty.lte(BigDecimal.ZERO)) continue + + val currentPrice = currentPrices[order.marketId]?.toSafeBigDecimal() + ?: continue // 如果没有当前价格,跳过 + + val buyPrice = order.price.toSafeBigDecimal() + val unrealizedPnl = currentPrice.subtract(buyPrice).multi(remainingQty) + totalUnrealizedPnl = totalUnrealizedPnl.add(unrealizedPnl) + } + + return totalUnrealizedPnl.toString() + } + + /** + * 计算持仓价值 + */ + private fun calculatePositionValue(positionQuantity: String, currentPrices: Map): String { + // 这里简化处理,实际应该根据每个市场的持仓分别计算 + // 暂时返回0,因为需要知道每个市场的持仓数量 + return "0" + } + + /** + * 计算盈亏百分比 + */ + private fun calculatePnlPercent( + totalBuyAmount: String, + totalRealizedPnl: String, + totalUnrealizedPnl: String + ): String { + val buyAmount = totalBuyAmount.toSafeBigDecimal() + if (buyAmount.lte(BigDecimal.ZERO)) return "0" + + val totalPnl = totalRealizedPnl.toSafeBigDecimal().add(totalUnrealizedPnl.toSafeBigDecimal()) + val percent = totalPnl.div(buyAmount).multi(100) + + return percent.setScale(2, RoundingMode.HALF_UP).toString() + } + + /** + * 统计数据结构 + */ + private data class StatisticsData( + val totalBuyQuantity: String, + val totalBuyOrders: Long, + val totalBuyAmount: String, + val avgBuyPrice: String, + val totalSellQuantity: String, + val totalSellOrders: Long, + val totalSellAmount: String, + val currentPositionQuantity: String, + val totalRealizedPnl: String + ) +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingWebSocketService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingWebSocketService.kt new file mode 100644 index 0000000..ed8f974 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingWebSocketService.kt @@ -0,0 +1,233 @@ +package com.wrbug.polymarketbot.service + +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.wrbug.polymarketbot.api.TradeResponse +import com.wrbug.polymarketbot.entity.Leader +import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository +import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient +import jakarta.annotation.PreDestroy +import kotlinx.coroutines.* +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.util.concurrent.ConcurrentHashMap + +/** + * 跟单WebSocket监听服务 + * 通过WebSocket订阅Polymarket RTDS的用户交易频道 + */ +@Service +class CopyTradingWebSocketService( + private val copyOrderTrackingService: CopyOrderTrackingService, + private val templateRepository: CopyTradingTemplateRepository +) { + + private val logger = LoggerFactory.getLogger(CopyTradingWebSocketService::class.java) + + @Value("\${polymarket.websocket.url:wss://ws-live-data.polymarket.com}") + private var websocketUrl: String = "wss://ws-live-data.polymarket.com" + + private val gson = Gson() + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + // 存储每个Leader的WebSocket客户端:leaderId -> WebSocketClient + private val leaderClients = ConcurrentHashMap() + + // 存储每个Leader的地址:leaderId -> leaderAddress + private val leaderAddresses = ConcurrentHashMap() + + /** + * 启动WebSocket监听 + */ + fun start(leaders: List) { + logger.info("启动WebSocket监听,Leader数量: ${leaders.size}") + + leaders.forEach { leader -> + try { + addLeader(leader) + } catch (e: Exception) { + logger.error("添加Leader监听失败: leaderId=${leader.id}, address=${leader.leaderAddress}", e) + } + } + } + + /** + * 添加Leader监听 + */ + fun addLeader(leader: Leader) { + if (leader.id == null) { + logger.warn("Leader ID为空,跳过: ${leader.leaderAddress}") + return + } + + if (leaderClients.containsKey(leader.id)) { + logger.debug("Leader ${leader.id} 已经在监听中,跳过") + return + } + + val leaderId = leader.id!! + val leaderAddress = leader.leaderAddress.lowercase() + leaderAddresses[leaderId] = leaderAddress + + // 创建WebSocket客户端 + val client = PolymarketWebSocketClient( + url = websocketUrl, + sessionId = "copy-trading-$leaderId", + onMessage = { message -> handleMessage(leaderId, message) }, + onOpen = { + // 连接建立后订阅用户交易频道 + val wsClient = leaderClients[leaderId] + if (wsClient != null) { + subscribeUserTrades(wsClient, leaderAddress) + } + }, + onReconnect = { + // 重连后重新订阅 + val wsClient = leaderClients[leaderId] + if (wsClient != null) { + subscribeUserTrades(wsClient, leaderAddress) + } + } + ) + + leaderClients[leaderId] = client + + // 连接WebSocket + scope.launch { + try { + client.connect() + logger.info("已启动WebSocket监听: leaderId=$leaderId, address=$leaderAddress") + } catch (e: Exception) { + logger.error("连接WebSocket失败: leaderId=$leaderId", e) + leaderClients.remove(leaderId) + leaderAddresses.remove(leaderId) + } + } + } + + /** + * 移除Leader监听 + */ + fun removeLeader(leaderId: Long) { + val client = leaderClients.remove(leaderId) + leaderAddresses.remove(leaderId) + + if (client != null) { + try { + client.closeConnection() + logger.info("已停止WebSocket监听: leaderId=$leaderId") + } catch (e: Exception) { + logger.error("关闭WebSocket连接失败: leaderId=$leaderId", e) + } + } + } + + /** + * 停止所有监听 + */ + fun stop() { + logger.info("停止所有WebSocket监听...") + val leaderIds = leaderClients.keys.toList() + leaderIds.forEach { leaderId -> + removeLeader(leaderId) + } + } + + /** + * 订阅用户交易频道 + */ + private fun subscribeUserTrades(client: PolymarketWebSocketClient, userAddress: String) { + try { + // 根据Polymarket RTDS API文档,订阅用户交易频道的消息格式 + val subscribeMessage = """ + { + "type": "subscribe", + "channel": "user", + "user": "$userAddress" + } + """.trimIndent() + + client.sendMessage(subscribeMessage) + logger.info("已订阅用户交易频道: $userAddress") + } catch (e: Exception) { + logger.error("订阅用户交易频道失败: $userAddress", e) + } + } + + /** + * 处理WebSocket消息 + */ + private fun handleMessage(leaderId: Long, message: String) { + try { + // 处理PONG响应 + if (message.trim() == "PONG") { + logger.debug("收到PONG响应: leaderId=$leaderId") + return + } + + // 解析JSON消息 + val json = JsonParser.parseString(message).asJsonObject + + // 检查消息类型 + val eventType = json.get("event_type")?.asString + if (eventType != "trade") { + logger.debug("忽略非交易事件: leaderId=$leaderId, eventType=$eventType") + return + } + + // 解析交易数据 + val trade = parseTradeMessage(json) + if (trade != null) { + // 处理交易 + scope.launch { + try { + copyOrderTrackingService.processTrade(leaderId, trade, "websocket") + } catch (e: Exception) { + logger.error("处理交易失败: leaderId=$leaderId, tradeId=${trade.id}", e) + } + } + } + } catch (e: Exception) { + logger.error("处理WebSocket消息失败: leaderId=$leaderId, message=$message", e) + } + } + + /** + * 解析交易消息 + * 根据Polymarket RTDS API的trade事件格式解析 + */ + private fun parseTradeMessage(json: JsonObject): TradeResponse? { + return try { + // 根据实际API响应格式解析 + // 注意:这里需要根据实际的WebSocket消息格式调整 + val id = json.get("id")?.asString ?: json.get("trade_id")?.asString + val market = json.get("market")?.asString + val side = json.get("side")?.asString + val price = json.get("price")?.asString + val size = json.get("size")?.asString + val timestamp = json.get("timestamp")?.asString ?: System.currentTimeMillis().toString() + val user = json.get("user")?.asJsonObject?.get("address")?.asString + + if (id == null || market == null || side == null || price == null || size == null) { + logger.warn("交易消息缺少必需字段: $json") + return null + } + + TradeResponse( + id = id, + market = market, + side = side, + price = price, + size = size, + timestamp = timestamp, + user = user + ) + } catch (e: Exception) { + logger.error("解析交易消息失败: $json", e) + null + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PolymarketClobService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PolymarketClobService.kt index ab5b91e..21f87aa 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PolymarketClobService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PolymarketClobService.kt @@ -340,6 +340,7 @@ class PolymarketClobService( /** * 获取交易记录 + * 注意:CLOB API 不支持 limit 参数,需要通过 next_cursor 分页 */ suspend fun getTrades( id: String? = null, @@ -348,7 +349,8 @@ class PolymarketClobService( asset_id: String? = null, before: String? = null, after: String? = null, - next_cursor: String? = null + next_cursor: String? = null, + limit: Int? = null // 注意:CLOB API 不支持 limit,这里仅用于文档说明 ): Result> { return try { val response = clobApi.getTrades( diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/MathExt.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/MathExt.kt index 8073ac6..826412b 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/MathExt.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/MathExt.kt @@ -72,7 +72,7 @@ fun Any?.gt(target: Any?): Boolean { val thisValue = this.toSafeBigDecimal() val targetValue = target.toSafeBigDecimal() // 使用 compareTo 方法比较,避免 BigDecimal 的 scale 问题 - return thisValue.compareTo(targetValue) > 0 + return thisValue > targetValue } /** @@ -106,7 +106,7 @@ fun Any?.lt(target: Any?): Boolean { val thisValue = this.toSafeBigDecimal() val targetValue = target.toSafeBigDecimal() // 使用 compareTo 方法比较,避免 BigDecimal 的 scale 问题 - return thisValue.compareTo(targetValue) < 0 + return thisValue < targetValue } /** diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt index 5367458..8ea5c5c 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt @@ -4,6 +4,7 @@ import com.google.gson.Gson import com.google.gson.GsonBuilder import com.wrbug.polymarketbot.api.EthereumRpcApi import com.wrbug.polymarketbot.api.PolymarketClobApi +import com.wrbug.polymarketbot.api.PolymarketDataApi import com.wrbug.polymarketbot.api.PolymarketGammaApi import okhttp3.Interceptor import okhttp3.Response @@ -136,6 +137,31 @@ class RetrofitFactory( .build() .create(PolymarketGammaApi::class.java) } + + /** + * 创建 Polymarket Data API 客户端 + * Data API 是公开 API,不需要认证 + * @return PolymarketDataApi 客户端 + */ + fun createDataApi(): PolymarketDataApi { + val baseUrl = "https://data-api.polymarket.com" + val okHttpClient = createClient() + .followRedirects(true) + .followSslRedirects(true) + .build() + + // 创建 lenient 模式的 Gson + val gson = GsonBuilder() + .setLenient() + .create() + + return Retrofit.Builder() + .baseUrl("$baseUrl/") + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + .create(PolymarketDataApi::class.java) + } } /** diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index ae03346..f0a3679 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -47,6 +47,12 @@ position.push.polling-interval=${POSITION_PUSH_POLLING_INTERVAL:3000} # 心跳超时时间(毫秒),默认60秒,超过此时间未收到心跳则清理连接 position.push.heartbeat-timeout=${POSITION_PUSH_HEARTBEAT_TIMEOUT:60000} +# 跟单轮询配置 +# 轮询间隔(毫秒),默认2秒 +copy.trading.polling.interval=${COPY_TRADING_POLLING_INTERVAL:2000} +# 是否启用轮询,默认true +copy.trading.polling.enabled=${COPY_TRADING_POLLING_ENABLED:true} + # WebSocket 配置 websocket.heartbeat-timeout=${WEBSOCKET_HEARTBEAT_TIMEOUT:60000} diff --git a/backend/src/main/resources/db/migration/V10__add_outcome_index_to_order_tracking.sql b/backend/src/main/resources/db/migration/V10__add_outcome_index_to_order_tracking.sql new file mode 100644 index 0000000..b34c6ef --- /dev/null +++ b/backend/src/main/resources/db/migration/V10__add_outcome_index_to_order_tracking.sql @@ -0,0 +1,18 @@ +-- 添加 outcome_index 字段到订单跟踪表和卖出匹配记录表 +-- 支持多元市场(不限于YES/NO) + +-- 1. 添加 outcome_index 字段到 copy_order_tracking 表 +ALTER TABLE copy_order_tracking +ADD COLUMN outcome_index INT NULL COMMENT '结果索引(0, 1, 2, ...),支持多元市场' AFTER side; + +-- 2. 添加 outcome_index 字段到 sell_match_record 表 +ALTER TABLE sell_match_record +ADD COLUMN outcome_index INT NULL COMMENT '结果索引(0, 1, 2, ...),支持多元市场' AFTER side; + +-- 3. 添加索引以优化查询性能 +ALTER TABLE copy_order_tracking +ADD INDEX idx_market_outcome (market_id, outcome_index); + +ALTER TABLE sell_match_record +ADD INDEX idx_market_outcome (market_id, outcome_index); + diff --git a/backend/src/main/resources/db/migration/V8__create_order_tracking_tables.sql b/backend/src/main/resources/db/migration/V8__create_order_tracking_tables.sql new file mode 100644 index 0000000..1f00067 --- /dev/null +++ b/backend/src/main/resources/db/migration/V8__create_order_tracking_tables.sql @@ -0,0 +1,76 @@ +-- 创建订单跟踪表 +CREATE TABLE IF NOT EXISTS copy_order_tracking ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + copy_trading_id BIGINT NOT NULL COMMENT '跟单关系ID', + account_id BIGINT NOT NULL COMMENT '账户ID', + leader_id BIGINT NOT NULL COMMENT 'Leader ID', + template_id BIGINT NOT NULL COMMENT '模板ID', + market_id VARCHAR(100) NOT NULL COMMENT '市场地址', + side VARCHAR(10) NOT NULL COMMENT '方向:YES/NO', + buy_order_id VARCHAR(100) NOT NULL COMMENT '跟单买入订单ID', + leader_buy_trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 买入交易ID', + quantity DECIMAL(20, 8) NOT NULL COMMENT '买入数量', + price DECIMAL(20, 8) NOT NULL COMMENT '买入价格', + matched_quantity DECIMAL(20, 8) NOT NULL DEFAULT 0 COMMENT '已匹配卖出数量', + remaining_quantity DECIMAL(20, 8) NOT NULL COMMENT '剩余未匹配数量', + status VARCHAR(20) NOT NULL COMMENT '状态:filled, fully_matched, partially_matched', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', + updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)', + INDEX idx_copy_trading (copy_trading_id), + INDEX idx_remaining (remaining_quantity, status), + INDEX idx_market_side (market_id, side), + INDEX idx_leader_trade (leader_id, leader_buy_trade_id), + FOREIGN KEY (copy_trading_id) REFERENCES copy_trading(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单跟踪表'; + +-- 创建卖出匹配记录表 +CREATE TABLE IF NOT EXISTS sell_match_record ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + copy_trading_id BIGINT NOT NULL COMMENT '跟单关系ID', + sell_order_id VARCHAR(100) NOT NULL COMMENT '跟单卖出订单ID', + leader_sell_trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 卖出交易ID', + market_id VARCHAR(100) NOT NULL COMMENT '市场地址', + side VARCHAR(10) NOT NULL COMMENT '方向:YES/NO', + total_matched_quantity DECIMAL(20, 8) NOT NULL COMMENT '总匹配数量', + sell_price DECIMAL(20, 8) NOT NULL COMMENT '卖出价格', + total_realized_pnl DECIMAL(20, 8) NOT NULL COMMENT '总已实现盈亏', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', + INDEX idx_copy_trading (copy_trading_id), + INDEX idx_sell_order (sell_order_id), + INDEX idx_leader_trade (leader_sell_trade_id), + FOREIGN KEY (copy_trading_id) REFERENCES copy_trading(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='卖出匹配记录表'; + +-- 创建匹配明细表 +CREATE TABLE IF NOT EXISTS sell_match_detail ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + match_record_id BIGINT NOT NULL COMMENT '关联 sell_match_record.id', + tracking_id BIGINT NOT NULL COMMENT '关联 copy_order_tracking.id', + buy_order_id VARCHAR(100) NOT NULL COMMENT '买入订单ID', + matched_quantity DECIMAL(20, 8) NOT NULL COMMENT '匹配的数量', + buy_price DECIMAL(20, 8) NOT NULL COMMENT '买入价格', + sell_price DECIMAL(20, 8) NOT NULL COMMENT '卖出价格', + realized_pnl DECIMAL(20, 8) NOT NULL COMMENT '盈亏 = (sell_price - buy_price) * matched_quantity', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', + INDEX idx_match_record (match_record_id), + INDEX idx_tracking (tracking_id), + INDEX idx_buy_order (buy_order_id), + FOREIGN KEY (match_record_id) REFERENCES sell_match_record(id) ON DELETE CASCADE, + FOREIGN KEY (tracking_id) REFERENCES copy_order_tracking(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='匹配明细表'; + +-- 创建已处理交易表(用于去重) +CREATE TABLE IF NOT EXISTS processed_trade ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + leader_id BIGINT NOT NULL COMMENT 'Leader ID', + leader_trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 的交易ID(trade.id,唯一标识)', + trade_type VARCHAR(10) NOT NULL COMMENT '交易类型:BUY 或 SELL', + source VARCHAR(20) NOT NULL COMMENT '数据来源:websocket 或 polling', + processed_at BIGINT NOT NULL COMMENT '处理时间(毫秒时间戳)', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', + UNIQUE KEY uk_leader_trade (leader_id, leader_trade_id), + INDEX idx_processed_at (processed_at), + INDEX idx_leader_id (leader_id), + FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='已处理交易表(用于去重)'; + diff --git a/backend/src/main/resources/db/migration/V9__add_failed_trade_tracking.sql b/backend/src/main/resources/db/migration/V9__add_failed_trade_tracking.sql new file mode 100644 index 0000000..70e8a87 --- /dev/null +++ b/backend/src/main/resources/db/migration/V9__add_failed_trade_tracking.sql @@ -0,0 +1,27 @@ +-- 修改已处理交易表,添加状态字段 +ALTER TABLE processed_trade +ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'SUCCESS' COMMENT '处理状态:SUCCESS(成功)、FAILED(失败)' AFTER source; + +-- 创建失败交易记录表 +CREATE TABLE IF NOT EXISTS failed_trade ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + leader_id BIGINT NOT NULL COMMENT 'Leader ID', + leader_trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 的交易ID', + trade_type VARCHAR(10) NOT NULL COMMENT '交易类型:BUY 或 SELL', + copy_trading_id BIGINT NOT NULL COMMENT '跟单关系ID', + account_id BIGINT NOT NULL COMMENT '账户ID', + market_id VARCHAR(100) NOT NULL COMMENT '市场地址', + side VARCHAR(10) NOT NULL COMMENT '方向:YES/NO', + price VARCHAR(50) NOT NULL COMMENT '价格', + size VARCHAR(50) NOT NULL COMMENT '数量', + error_message TEXT COMMENT '错误信息', + retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', + failed_at BIGINT NOT NULL COMMENT '失败时间(毫秒时间戳)', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', + INDEX idx_leader_trade (leader_id, leader_trade_id), + INDEX idx_copy_trading (copy_trading_id), + INDEX idx_failed_at (failed_at), + FOREIGN KEY (copy_trading_id) REFERENCES copy_trading(id) ON DELETE CASCADE, + FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='失败交易记录表'; + diff --git a/frontend/src/pages/AccountList.tsx b/frontend/src/pages/AccountList.tsx index ec08f8a..27ce9cf 100644 --- a/frontend/src/pages/AccountList.tsx +++ b/frontend/src/pages/AccountList.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Spin, Modal, Descriptions, Divider, Form, Input, Checkbox, Alert } from 'antd' -import { PlusOutlined, StarOutlined, StarFilled, ReloadOutlined, EditOutlined } from '@ant-design/icons' +import { PlusOutlined, ReloadOutlined, EditOutlined, CopyOutlined } from '@ant-design/icons' import { useAccountStore } from '../store/accountStore' import type { Account } from '../types' import { useMediaQuery } from 'react-responsive' @@ -12,7 +12,7 @@ const { Title } = Typography const AccountList: React.FC = () => { const navigate = useNavigate() const isMobile = useMediaQuery({ maxWidth: 768 }) - const { accounts, loading, fetchAccounts, deleteAccount, setDefaultAccount, fetchAccountBalance, fetchAccountDetail, updateAccount } = useAccountStore() + const { accounts, loading, fetchAccounts, deleteAccount, fetchAccountBalance, fetchAccountDetail, updateAccount } = useAccountStore() const [balanceMap, setBalanceMap] = useState>({}) const [balanceLoading, setBalanceLoading] = useState>({}) const [detailModalVisible, setDetailModalVisible] = useState(false) @@ -71,13 +71,12 @@ const AccountList: React.FC = () => { } } - const handleSetDefault = async (account: Account) => { - try { - await setDefaultAccount(account.id) - message.success('设置默认账户成功') - } catch (error: any) { - message.error(error.message || '设置默认账户失败') - } + const handleCopy = (text: string, label: string) => { + navigator.clipboard.writeText(text).then(() => { + message.success(`${label}已复制到剪贴板`) + }).catch(() => { + message.error('复制失败') + }) } const handleShowDetail = async (account: Account) => { @@ -222,21 +221,34 @@ const AccountList: React.FC = () => { title: '钱包地址', dataIndex: 'walletAddress', key: 'walletAddress', - render: (address: string) => ( - {address} + render: (text: string) => ( + + {text} + - {!record.isDefault && ( - - )} { {detailAccount.accountName || '-'} - - {detailAccount.walletAddress || '-'} - + + + {detailAccount.walletAddress || '-'} + +