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:
WrBug
2025-12-02 03:34:43 +08:00
parent 844cd686eb
commit dffbc5124f
33 changed files with 2916 additions and 80 deletions
@@ -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 orderAPI 返回字段名为 orderID
@SerializedName("transactionsHashes")
val transactionsHashes: List<String>? = null, // transaction hashesAPI 返回字段名为 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
)
@@ -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 的交易IDtrade.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()
)
@@ -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
}
@@ -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
}
@@ -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>
}
@@ -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中提取sideYES/NO
*
* 说明:
* - 根据设计文档,系统只支持sports和crypto分类,这些通常是二元市场(YES/NO)
* - TradeResponse中的side是BUY/SELL(订单方向),不是YES/NOoutcome
* - 在二元市场中:
* - 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())
// 存储需要监听的LeaderleaderId -> 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)
@@ -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
)
}
@@ -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 的交易IDtrade.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='失败交易记录表';