feat: 实现跟单订单跟踪和统计功能
- 添加订单跟踪实体和Repository(CopyOrderTracking, SellMatchRecord, SellMatchDetail) - 实现订单跟踪服务(CopyOrderTrackingService),支持买入/卖出订单的创建和匹配 - 实现跟单统计服务(CopyTradingStatisticsService),支持盈亏统计和订单列表查询 - 添加失败交易记录(FailedTrade)和已处理交易去重(ProcessedTrade) - 实现轮询服务(CopyTradingPollingService),使用Data API /activity接口轮询Leader交易 - 支持多元市场(使用outcomeIndex而不是YES/NO) - 重试时重新生成salt并重新签名 - 添加风险控制检查(每日订单数、每日亏损、单笔订单大小限制) - 支持价格容忍度和延迟跟单 - 添加数据库迁移文件(V8, V9, V10) - 前端:账户管理显示代理钱包地址,添加复制功能,移除默认账户逻辑 - 修复API响应字段映射问题(orderID, transactionsHashes) - 缩短轮询间隔到2秒
This commit is contained in:
@@ -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<String>? = 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<String>? = 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")
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,6 +39,25 @@ interface PolymarketDataApi {
|
||||
@Query("user") user: String,
|
||||
@Query("market") market: List<String>? = null
|
||||
): Response<List<ValueResponse>>
|
||||
|
||||
/**
|
||||
* 获取用户活动(包括交易)
|
||||
* 文档: 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<String>? = null,
|
||||
@Query("eventId") eventId: List<Int>? = null,
|
||||
@Query("type") type: List<String>? = 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<List<UserActivityResponse>>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
+106
@@ -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<ApiResponse<CopyTradingStatisticsResponse>> {
|
||||
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<ApiResponse<OrderListResponse>> {
|
||||
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}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, // 是否启用(用于订单推送等功能的开关)
|
||||
|
||||
@@ -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<Any>, // 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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
+49
@@ -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<CopyOrderTracking, Long> {
|
||||
|
||||
/**
|
||||
* 根据跟单关系ID查询所有买入订单
|
||||
*/
|
||||
fun findByCopyTradingId(copyTradingId: Long): List<CopyOrderTracking>
|
||||
|
||||
/**
|
||||
* 根据跟单关系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<CopyOrderTracking>
|
||||
|
||||
/**
|
||||
* 根据跟单关系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<CopyOrderTracking>
|
||||
|
||||
/**
|
||||
* 根据跟单关系ID和状态查询订单
|
||||
*/
|
||||
fun findByCopyTradingIdAndStatus(copyTradingId: Long, status: String): List<CopyOrderTracking>
|
||||
|
||||
/**
|
||||
* 根据跟单关系ID和市场ID查询订单
|
||||
*/
|
||||
fun findByCopyTradingIdAndMarketId(copyTradingId: Long, marketId: String): List<CopyOrderTracking>
|
||||
|
||||
/**
|
||||
* 根据Leader交易ID查询订单
|
||||
*/
|
||||
fun findByLeaderBuyTradeId(leaderBuyTradeId: String): CopyOrderTracking?
|
||||
}
|
||||
|
||||
@@ -49,6 +49,11 @@ interface CopyTradingRepository : JpaRepository<CopyTrading, Long> {
|
||||
*/
|
||||
fun findByAccountIdAndEnabledTrue(accountId: Long): List<CopyTrading>
|
||||
|
||||
/**
|
||||
* 根据Leader ID查找启用的跟单
|
||||
*/
|
||||
fun findByLeaderIdAndEnabledTrue(leaderId: Long): List<CopyTrading>
|
||||
|
||||
/**
|
||||
* 统计使用指定模板的跟单数量
|
||||
*/
|
||||
|
||||
@@ -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<FailedTrade, Long> {
|
||||
|
||||
/**
|
||||
* 根据Leader ID和交易ID查询
|
||||
*/
|
||||
fun findByLeaderIdAndLeaderTradeId(leaderId: Long, leaderTradeId: String): FailedTrade?
|
||||
|
||||
/**
|
||||
* 检查是否存在失败的交易
|
||||
*/
|
||||
fun existsByLeaderIdAndLeaderTradeId(leaderId: Long, leaderTradeId: String): Boolean
|
||||
}
|
||||
|
||||
+32
@@ -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<ProcessedTrade, Long> {
|
||||
|
||||
/**
|
||||
* 检查交易是否已处理
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
+41
@@ -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<SellMatchDetail, Long> {
|
||||
|
||||
/**
|
||||
* 根据匹配记录ID查询所有明细
|
||||
*/
|
||||
fun findByMatchRecordId(matchRecordId: Long): List<SellMatchDetail>
|
||||
|
||||
/**
|
||||
* 根据跟踪ID查询所有明细
|
||||
*/
|
||||
fun findByTrackingId(trackingId: Long): List<SellMatchDetail>
|
||||
|
||||
/**
|
||||
* 根据买入订单ID查询所有明细
|
||||
*/
|
||||
fun findByBuyOrderId(buyOrderId: String): List<SellMatchDetail>
|
||||
|
||||
/**
|
||||
* 根据卖出订单ID查询所有明细(通过匹配记录关联)
|
||||
*/
|
||||
@Query("SELECT d FROM SellMatchDetail d JOIN SellMatchRecord r ON d.matchRecordId = r.id WHERE r.sellOrderId = :sellOrderId")
|
||||
fun findBySellOrderId(sellOrderId: String): List<SellMatchDetail>
|
||||
|
||||
/**
|
||||
* 根据跟单关系ID查询所有明细(通过匹配记录关联)
|
||||
*/
|
||||
@Query("SELECT d FROM SellMatchDetail d JOIN SellMatchRecord r ON d.matchRecordId = r.id WHERE r.copyTradingId = :copyTradingId")
|
||||
fun findByCopyTradingId(copyTradingId: Long): List<SellMatchDetail>
|
||||
}
|
||||
|
||||
+28
@@ -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<SellMatchRecord, Long> {
|
||||
|
||||
/**
|
||||
* 根据跟单关系ID查询所有卖出记录
|
||||
*/
|
||||
fun findByCopyTradingId(copyTradingId: Long): List<SellMatchRecord>
|
||||
|
||||
/**
|
||||
* 根据卖出订单ID查询记录
|
||||
*/
|
||||
fun findBySellOrderId(sellOrderId: String): SellMatchRecord?
|
||||
|
||||
/**
|
||||
* 根据Leader卖出交易ID查询记录
|
||||
*/
|
||||
fun findByLeaderSellTradeId(leaderSellTradeId: String): SellMatchRecord?
|
||||
}
|
||||
|
||||
@@ -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 ?: "",
|
||||
|
||||
@@ -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<Unit> {
|
||||
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<Unit> {
|
||||
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<Unit> {
|
||||
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<SellMatchDetail>()
|
||||
|
||||
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<String> {
|
||||
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<Boolean, String> {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Long, Leader>()
|
||||
|
||||
// 存储每个Leader已缓存的交易ID集合:leaderId -> Set<tradeId>
|
||||
private val cachedTradeIds = ConcurrentHashMap<Long, MutableSet<String>>()
|
||||
|
||||
// 存储每个Leader是否首次轮询:leaderId -> isFirstPoll
|
||||
private val isFirstPoll = ConcurrentHashMap<Long, Boolean>()
|
||||
|
||||
// 轮询任务
|
||||
private var pollingJob: Job? = null
|
||||
|
||||
/**
|
||||
* 启动轮询监听
|
||||
*/
|
||||
fun start(leaders: List<Leader>) {
|
||||
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<List<UserActivityResponse>> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+411
@@ -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<CopyTradingStatisticsResponse> {
|
||||
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<OrderListResponse> {
|
||||
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<List<BuyOrderInfo>, 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<List<SellOrderInfo>, 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<List<MatchedOrderInfo>, 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<CopyOrderTracking>,
|
||||
sellRecords: List<SellMatchRecord>,
|
||||
matchDetails: List<SellMatchDetail>
|
||||
): 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<CopyOrderTracking>): Map<String, String> {
|
||||
val prices = mutableMapOf<String, String>()
|
||||
|
||||
// 获取所有不同的市场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<CopyOrderTracking>,
|
||||
currentPrices: Map<String, String>
|
||||
): 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, String>): 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
|
||||
)
|
||||
}
|
||||
|
||||
+233
@@ -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<Long, PolymarketWebSocketClient>()
|
||||
|
||||
// 存储每个Leader的地址:leaderId -> leaderAddress
|
||||
private val leaderAddresses = ConcurrentHashMap<Long, String>()
|
||||
|
||||
/**
|
||||
* 启动WebSocket监听
|
||||
*/
|
||||
fun start(leaders: List<Leader>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<List<TradeResponse>> {
|
||||
return try {
|
||||
val response = clobApi.getTrades(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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='已处理交易表(用于去重)';
|
||||
|
||||
@@ -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='失败交易记录表';
|
||||
|
||||
Reference in New Issue
Block a user