Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
662aa47de6 | ||
|
|
c3d9d10d5d | ||
|
|
9926533049 | ||
|
|
4d72017b97 | ||
|
|
45734c051e | ||
|
|
db8471bb16 | ||
|
|
0dc6f5894f | ||
|
|
c9769aa17a | ||
|
|
07b4d654b4 | ||
|
|
b65827038f | ||
|
|
d768da72c6 | ||
|
|
7385efff1a | ||
|
|
3e2e97e572 | ||
|
|
ae68a33c1e | ||
|
|
deea59fdbf | ||
|
|
b90f86b081 | ||
|
|
f6f5866118 | ||
|
|
b1e69135b8 | ||
|
|
0c7f34a28a | ||
|
|
c53fcde5d7 | ||
|
|
81a620af12 | ||
|
|
5f44a0ca20 | ||
|
|
5d2cf945f3 | ||
|
|
227a38fa89 | ||
|
|
abcc004606 | ||
|
|
cc40493ec6 | ||
|
|
692cbd9a80 | ||
|
|
43de0104e2 | ||
|
|
2ccab42894 | ||
|
|
3008cbcb50 | ||
|
|
42a318b501 | ||
|
|
ecb737ec67 | ||
|
|
0f3baec6cb | ||
|
|
764d684846 |
@@ -58,7 +58,10 @@ data class CopyOrderTracking(
|
|||||||
|
|
||||||
@Column(name = "notification_sent", nullable = false)
|
@Column(name = "notification_sent", nullable = false)
|
||||||
var notificationSent: Boolean = false, // 是否已发送通知(从订单详情获取实际数据后发送)
|
var notificationSent: Boolean = false, // 是否已发送通知(从订单详情获取实际数据后发送)
|
||||||
|
|
||||||
|
@Column(name = "source", nullable = false, length = 20)
|
||||||
|
val source: String, // 订单来源:activity-ws(Polymarket WebSocket)、onchain-ws(OnChain WebSocket)
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
val createdAt: Long = System.currentTimeMillis(),
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
|
|
||||||
|
|||||||
+4
-8
@@ -75,15 +75,11 @@ interface CopyOrderTrackingRepository : JpaRepository<CopyOrderTracking, Long> {
|
|||||||
fun countActivePositions(copyTradingId: Long): Int
|
fun countActivePositions(copyTradingId: Long): Int
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查指定市场是否存在活跃仓位
|
* 计算指定跟单配置、市场和方向下的当前持仓总价值 (成本价计算)
|
||||||
|
* 按市场+方向(outcomeIndex)分别统计
|
||||||
*/
|
*/
|
||||||
fun existsByCopyTradingIdAndMarketIdAndRemainingQuantityGreaterThan(copyTradingId: Long, marketId: String, remainingQuantity: BigDecimal): Boolean
|
@Query("SELECT SUM(t.remainingQuantity * t.price) FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.marketId = :marketId AND t.outcomeIndex = :outcomeIndex AND t.remainingQuantity > 0")
|
||||||
|
fun sumCurrentPositionValueByMarketAndOutcomeIndex(copyTradingId: Long, marketId: String, outcomeIndex: Int): BigDecimal?
|
||||||
/**
|
|
||||||
* 计算指定跟单配置和市场下的当前持仓总价值 (成本价计算)
|
|
||||||
*/
|
|
||||||
@Query("SELECT SUM(t.remainingQuantity * t.price) FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.marketId = :marketId AND t.remainingQuantity > 0")
|
|
||||||
fun sumCurrentPositionValueByMarket(copyTradingId: Long, marketId: String): BigDecimal?
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询指定跟单配置下,创建时间超过指定时间点的未匹配订单(FIFO顺序)
|
* 查询指定跟单配置下,创建时间超过指定时间点的未匹配订单(FIFO顺序)
|
||||||
|
|||||||
+16
-18
@@ -21,29 +21,29 @@ import java.util.concurrent.CopyOnWriteArrayList
|
|||||||
class PositionPollingService(
|
class PositionPollingService(
|
||||||
private val accountService: AccountService
|
private val accountService: AccountService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(PositionPollingService::class.java)
|
private val logger = LoggerFactory.getLogger(PositionPollingService::class.java)
|
||||||
|
|
||||||
@Value("\${position.polling.interval:2000}")
|
@Value("\${position.polling.interval:2000}")
|
||||||
private var pollingInterval: Long = 2000 // 轮训间隔(毫秒),默认2秒
|
private var pollingInterval: Long = 2000 // 轮训间隔(毫秒),默认2秒
|
||||||
|
|
||||||
// 订阅者列表(支持多个订阅者)
|
// 订阅者列表(支持多个订阅者)
|
||||||
private val subscribers = CopyOnWriteArrayList<(PositionListResponse) -> Unit>()
|
private val subscribers = CopyOnWriteArrayList<(PositionListResponse) -> Unit>()
|
||||||
|
|
||||||
// 最新仓位数据(用于丢弃机制)
|
// 最新仓位数据(用于丢弃机制)
|
||||||
@Volatile
|
@Volatile
|
||||||
private var latestPositions: PositionListResponse? = null
|
private var latestPositions: PositionListResponse? = null
|
||||||
|
|
||||||
// 协程作用域和任务
|
// 协程作用域和任务
|
||||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
private var pollingJob: Job? = null
|
private var pollingJob: Job? = null
|
||||||
|
|
||||||
// 事件分发协程(使用专门的线程,避免阻塞轮训)
|
// 事件分发协程(使用专门的线程,避免阻塞轮训)
|
||||||
private val eventDispatcherScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val eventDispatcherScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
// 同步锁,确保轮询任务的启动和停止是线程安全的
|
// 同步锁,确保轮询任务的启动和停止是线程安全的
|
||||||
private val lock = Any()
|
private val lock = Any()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化服务(后端启动时直接启动轮训)
|
* 初始化服务(后端启动时直接启动轮训)
|
||||||
*/
|
*/
|
||||||
@@ -52,7 +52,7 @@ class PositionPollingService(
|
|||||||
logger.info("PositionPollingService 初始化,启动仓位轮训任务,轮训间隔: ${pollingInterval}ms")
|
logger.info("PositionPollingService 初始化,启动仓位轮训任务,轮训间隔: ${pollingInterval}ms")
|
||||||
startPolling()
|
startPolling()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清理资源
|
* 清理资源
|
||||||
*/
|
*/
|
||||||
@@ -66,7 +66,7 @@ class PositionPollingService(
|
|||||||
scope.cancel()
|
scope.cancel()
|
||||||
eventDispatcherScope.cancel()
|
eventDispatcherScope.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 订阅仓位事件
|
* 订阅仓位事件
|
||||||
* @param callback 回调函数,接收最新的仓位数据
|
* @param callback 回调函数,接收最新的仓位数据
|
||||||
@@ -78,7 +78,7 @@ class PositionPollingService(
|
|||||||
latestPositions?.let { callback(it) }
|
latestPositions?.let { callback(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取消订阅仓位事件
|
* 取消订阅仓位事件
|
||||||
*/
|
*/
|
||||||
@@ -87,7 +87,7 @@ class PositionPollingService(
|
|||||||
subscribers.remove(callback)
|
subscribers.remove(callback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动轮训任务
|
* 启动轮训任务
|
||||||
*/
|
*/
|
||||||
@@ -95,7 +95,7 @@ class PositionPollingService(
|
|||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
// 如果已经有轮训任务在运行,先取消
|
// 如果已经有轮训任务在运行,先取消
|
||||||
pollingJob?.cancel()
|
pollingJob?.cancel()
|
||||||
|
|
||||||
// 启动新的轮训任务
|
// 启动新的轮训任务
|
||||||
pollingJob = scope.launch {
|
pollingJob = scope.launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
@@ -109,7 +109,7 @@ class PositionPollingService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 轮训仓位数据并发布事件
|
* 轮训仓位数据并发布事件
|
||||||
* 使用专门的线程分发事件,避免阻塞轮训
|
* 使用专门的线程分发事件,避免阻塞轮训
|
||||||
@@ -123,7 +123,7 @@ class PositionPollingService(
|
|||||||
if (positions != null) {
|
if (positions != null) {
|
||||||
// 更新最新数据(丢弃旧数据,只保留最新的)
|
// 更新最新数据(丢弃旧数据,只保留最新的)
|
||||||
latestPositions = positions
|
latestPositions = positions
|
||||||
|
|
||||||
// 在专门的线程中分发事件,避免阻塞轮训
|
// 在专门的线程中分发事件,避免阻塞轮训
|
||||||
eventDispatcherScope.launch {
|
eventDispatcherScope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -131,7 +131,7 @@ class PositionPollingService(
|
|||||||
val currentSubscribers = synchronized(lock) {
|
val currentSubscribers = synchronized(lock) {
|
||||||
subscribers.toList() // 复制列表,避免并发修改
|
subscribers.toList() // 复制列表,避免并发修改
|
||||||
}
|
}
|
||||||
|
|
||||||
currentSubscribers.forEach { callback ->
|
currentSubscribers.forEach { callback ->
|
||||||
try {
|
try {
|
||||||
callback(positions)
|
callback(positions)
|
||||||
@@ -139,8 +139,6 @@ class PositionPollingService(
|
|||||||
logger.error("通知订阅者失败: ${e.message}", e)
|
logger.error("通知订阅者失败: ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("发布仓位数据事件: currentPositions=${positions.currentPositions.size}, historyPositions=${positions.historyPositions.size}, subscribers=${currentSubscribers.size}")
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("分发仓位数据事件失败: ${e.message}", e)
|
logger.error("分发仓位数据事件失败: ${e.message}", e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,8 +89,6 @@ class MarketPollingService(
|
|||||||
*/
|
*/
|
||||||
private suspend fun checkAndUpdateMissingMarkets() {
|
private suspend fun checkAndUpdateMissingMarkets() {
|
||||||
try {
|
try {
|
||||||
logger.debug("开始检查缺失的市场信息...")
|
|
||||||
|
|
||||||
// 1. 获取所有买入订单的市场ID(去重)
|
// 1. 获取所有买入订单的市场ID(去重)
|
||||||
val allOrders = copyOrderTrackingRepository.findAll()
|
val allOrders = copyOrderTrackingRepository.findAll()
|
||||||
val marketIds = allOrders.map { it.marketId }.distinct()
|
val marketIds = allOrders.map { it.marketId }.distinct()
|
||||||
@@ -99,9 +97,6 @@ class MarketPollingService(
|
|||||||
logger.debug("没有找到任何订单,跳过市场信息检查")
|
logger.debug("没有找到任何订单,跳过市场信息检查")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("找到 ${marketIds.size} 个不同的市场ID")
|
|
||||||
|
|
||||||
// 2. 检查哪些市场信息在数据库中缺失
|
// 2. 检查哪些市场信息在数据库中缺失
|
||||||
val existingMarkets = marketService.marketRepository.findByMarketIdIn(marketIds)
|
val existingMarkets = marketService.marketRepository.findByMarketIdIn(marketIds)
|
||||||
val existingMarketIds = existingMarkets.map { it.marketId }.toSet()
|
val existingMarketIds = existingMarkets.map { it.marketId }.toSet()
|
||||||
@@ -113,7 +108,6 @@ class MarketPollingService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (validMissingMarketIds.isEmpty()) {
|
if (validMissingMarketIds.isEmpty()) {
|
||||||
logger.debug("所有市场信息都已存在,无需更新")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+49
-31
@@ -45,7 +45,8 @@ class CopyTradingFilterService(
|
|||||||
copyOrderAmount: BigDecimal? = null, // 跟单金额(USDC),用于仓位检查
|
copyOrderAmount: BigDecimal? = null, // 跟单金额(USDC),用于仓位检查
|
||||||
marketId: String? = null, // 市场ID,用于仓位检查(按市场过滤仓位)
|
marketId: String? = null, // 市场ID,用于仓位检查(按市场过滤仓位)
|
||||||
marketTitle: String? = null, // 市场标题,用于关键字过滤
|
marketTitle: String? = null, // 市场标题,用于关键字过滤
|
||||||
marketEndDate: Long? = null // 市场截止时间,用于市场截止时间检查
|
marketEndDate: Long? = null, // 市场截止时间,用于市场截止时间检查
|
||||||
|
outcomeIndex: Int? = null // 方向索引(0, 1, 2, ...),用于按市场+方向检查仓位
|
||||||
): FilterResult {
|
): FilterResult {
|
||||||
// 1. 关键字过滤检查(如果配置了关键字过滤)
|
// 1. 关键字过滤检查(如果配置了关键字过滤)
|
||||||
if (copyTrading.keywordFilterMode != null && copyTrading.keywordFilterMode != "DISABLED") {
|
if (copyTrading.keywordFilterMode != null && copyTrading.keywordFilterMode != "DISABLED") {
|
||||||
@@ -79,7 +80,7 @@ class CopyTradingFilterService(
|
|||||||
if (!needOrderbook) {
|
if (!needOrderbook) {
|
||||||
// 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
|
// 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
|
||||||
if (copyOrderAmount != null && marketId != null) {
|
if (copyOrderAmount != null && marketId != null) {
|
||||||
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId)
|
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId, outcomeIndex)
|
||||||
if (!positionCheck.isPassed) {
|
if (!positionCheck.isPassed) {
|
||||||
return positionCheck
|
return positionCheck
|
||||||
}
|
}
|
||||||
@@ -116,7 +117,7 @@ class CopyTradingFilterService(
|
|||||||
|
|
||||||
// 7. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
|
// 7. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
|
||||||
if (copyOrderAmount != null && marketId != null) {
|
if (copyOrderAmount != null && marketId != null) {
|
||||||
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId)
|
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId, outcomeIndex)
|
||||||
if (!positionCheck.isPassed) {
|
if (!positionCheck.isPassed) {
|
||||||
return positionCheck
|
return positionCheck
|
||||||
}
|
}
|
||||||
@@ -291,87 +292,104 @@ class CopyTradingFilterService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查仓位限制(按市场检查)
|
* 检查仓位限制(按市场+方向检查)
|
||||||
* @param copyTrading 跟单配置
|
* @param copyTrading 跟单配置
|
||||||
* @param copyOrderAmount 跟单金额(USDC)
|
* @param copyOrderAmount 跟单金额(USDC)
|
||||||
* @param marketId 市场ID,用于过滤该市场的仓位
|
* @param marketId 市场ID,用于过滤该市场的仓位
|
||||||
|
* @param outcomeIndex 方向索引(0, 1, 2, ...),用于按市场+方向检查仓位
|
||||||
* @return 过滤结果
|
* @return 过滤结果
|
||||||
*/
|
*/
|
||||||
private suspend fun checkPositionLimits(
|
private suspend fun checkPositionLimits(
|
||||||
copyTrading: CopyTrading,
|
copyTrading: CopyTrading,
|
||||||
copyOrderAmount: BigDecimal,
|
copyOrderAmount: BigDecimal,
|
||||||
marketId: String
|
marketId: String,
|
||||||
|
outcomeIndex: Int?
|
||||||
): FilterResult {
|
): FilterResult {
|
||||||
// 如果未配置仓位限制,直接通过
|
// 如果未配置仓位限制,直接通过
|
||||||
if (copyTrading.maxPositionValue == null && copyTrading.maxPositionCount == null) {
|
if (copyTrading.maxPositionValue == null && copyTrading.maxPositionCount == null) {
|
||||||
return FilterResult.passed()
|
return FilterResult.passed()
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取账户的所有仓位信息
|
// 获取账户的所有仓位信息
|
||||||
val positionsResult = accountService.getAllPositions()
|
val positionsResult = accountService.getAllPositions()
|
||||||
if (positionsResult.isFailure) {
|
if (positionsResult.isFailure) {
|
||||||
logger.warn("获取仓位信息失败,跳过仓位检查: accountId=${copyTrading.accountId}, marketId=$marketId, error=${positionsResult.exceptionOrNull()?.message}")
|
logger.warn("获取仓位信息失败,跳过仓位检查: accountId=${copyTrading.accountId}, marketId=$marketId, outcomeIndex=$outcomeIndex, error=${positionsResult.exceptionOrNull()?.message}")
|
||||||
// 如果获取仓位失败,为了安全起见,不通过检查
|
// 如果获取仓位失败,为了安全起见,不通过检查
|
||||||
return FilterResult.maxPositionValueFailed("获取仓位信息失败,无法进行仓位检查")
|
return FilterResult.maxPositionValueFailed("获取仓位信息失败,无法进行仓位检查")
|
||||||
}
|
}
|
||||||
|
|
||||||
val positions = positionsResult.getOrNull() ?: return FilterResult.maxPositionValueFailed("仓位信息为空")
|
val positions = positionsResult.getOrNull() ?: return FilterResult.maxPositionValueFailed("仓位信息为空")
|
||||||
|
|
||||||
// 过滤出当前账户且该市场的仓位
|
// 过滤出当前账户且该市场的仓位
|
||||||
val marketPositions = positions.currentPositions.filter {
|
val marketPositions = positions.currentPositions.filter {
|
||||||
it.accountId == copyTrading.accountId && it.marketId == marketId
|
it.accountId == copyTrading.accountId && it.marketId == marketId
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查最大仓位金额(如果配置了)
|
// 检查最大仓位金额(如果配置了)
|
||||||
if (copyTrading.maxPositionValue != null) {
|
if (copyTrading.maxPositionValue != null && outcomeIndex != null) {
|
||||||
// 比较数据库成本价(本地订单记录)和外部持仓市值(可能来自其他终端的操作),取最大值
|
// 按市场+方向(outcomeIndex)分别计算数据库成本价
|
||||||
val dbValue = copyOrderTrackingRepository.sumCurrentPositionValueByMarket(copyTrading.id!!, marketId) ?: BigDecimal.ZERO
|
val dbValue = copyOrderTrackingRepository.sumCurrentPositionValueByMarketAndOutcomeIndex(
|
||||||
val extValue = marketPositions.sumOf { it.currentValue.toSafeBigDecimal() }
|
copyTrading.id!!, marketId, outcomeIndex
|
||||||
|
) ?: BigDecimal.ZERO
|
||||||
|
|
||||||
|
// 外部持仓也需要按方向过滤,但由于外部持仓可能没有 outcomeIndex 信息,这里保守处理:
|
||||||
|
// 如果外部持仓存在,取该市场的所有外部持仓市值(与数据库取最大值)
|
||||||
|
val extValue = if (marketPositions.isNotEmpty()) {
|
||||||
|
marketPositions.sumOf { it.currentValue.toSafeBigDecimal() }
|
||||||
|
} else {
|
||||||
|
BigDecimal.ZERO
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取数据库值和外部持仓值的最大值
|
||||||
val currentPositionValue = dbValue.max(extValue)
|
val currentPositionValue = dbValue.max(extValue)
|
||||||
|
|
||||||
// 检查:该市场的当前仓位 + 跟单金额 <= 最大仓位金额
|
// 检查:该市场该方向的当前仓位 + 跟单金额 <= 最大仓位金额
|
||||||
val totalValueAfterOrder = currentPositionValue.add(copyOrderAmount)
|
val totalValueAfterOrder = currentPositionValue.add(copyOrderAmount)
|
||||||
|
|
||||||
if (totalValueAfterOrder.gt(copyTrading.maxPositionValue)) {
|
if (totalValueAfterOrder.gt(copyTrading.maxPositionValue)) {
|
||||||
return FilterResult.maxPositionValueFailed(
|
return FilterResult.maxPositionValueFailed(
|
||||||
"超过最大仓位金额限制: 当前该市场仓位(取最大值)=${currentPositionValue} USDC (DB=${dbValue}, Ext=${extValue}), 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC"
|
"超过最大仓位金额限制: 市场=$marketId, 方向=$outcomeIndex, 当前仓位(取最大值)=${currentPositionValue} USDC (DB=${dbValue}, Ext=${extValue}), 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查最大仓位数量(如果配置了)
|
// 检查最大仓位数量(如果配置了)
|
||||||
if (copyTrading.maxPositionCount != null) {
|
if (copyTrading.maxPositionCount != null) {
|
||||||
// 使用数据库中的订单记录计算活跃仓位数量(解决延迟问题)
|
// 使用数据库中的订单记录计算活跃仓位数量(解决延迟问题)
|
||||||
val dbCount = copyOrderTrackingRepository.countActivePositions(copyTrading.id!!)
|
val dbCount = copyOrderTrackingRepository.countActivePositions(copyTrading.id!!)
|
||||||
|
|
||||||
// 计算外部持仓中的唯一市场数量(防止遗漏非本项目创建的仓位)
|
// 计算外部持仓中的唯一市场数量(防止遗漏非本项目创建的仓位)
|
||||||
val extCount = positions.currentPositions
|
val extCount = positions.currentPositions
|
||||||
.filter { it.accountId == copyTrading.accountId }
|
.filter { it.accountId == copyTrading.accountId }
|
||||||
.map { it.marketId }
|
.map { it.marketId }
|
||||||
.distinct()
|
.distinct()
|
||||||
.size
|
.size
|
||||||
|
|
||||||
val currentPositionCount = maxOf(dbCount, extCount)
|
val currentPositionCount = maxOf(dbCount, extCount)
|
||||||
|
|
||||||
// 检查:如果当前没有该市场的活跃仓位,且总仓位数量已达到限制,则不允许开新仓
|
// 检查:如果当前没有该市场该方向的活跃仓位,且总仓位数量已达到限制,则不允许开新仓
|
||||||
// 判断当前市场是否已有活跃仓位(数据库或外部持仓)
|
// 判断当前市场该方向是否已有活跃仓位(数据库)
|
||||||
val hasDbPosition = copyOrderTrackingRepository.existsByCopyTradingIdAndMarketIdAndRemainingQuantityGreaterThan(
|
val hasDbPosition = if (outcomeIndex != null) {
|
||||||
copyTrading.id, marketId, BigDecimal.ZERO
|
copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
|
||||||
)
|
copyTrading.id, marketId, outcomeIndex
|
||||||
|
).isNotEmpty()
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
val hasExtPosition = marketPositions.isNotEmpty()
|
val hasExtPosition = marketPositions.isNotEmpty()
|
||||||
val hasCurrentMarketPosition = hasDbPosition || hasExtPosition
|
val hasCurrentMarketPosition = hasDbPosition || hasExtPosition
|
||||||
|
|
||||||
if (!hasCurrentMarketPosition && currentPositionCount >= copyTrading.maxPositionCount) {
|
if (!hasCurrentMarketPosition && currentPositionCount >= copyTrading.maxPositionCount) {
|
||||||
return FilterResult.maxPositionCountFailed(
|
return FilterResult.maxPositionCountFailed(
|
||||||
"超过最大仓位数量限制: 当前活跃仓位总数(取最大值)=${currentPositionCount} (DB=${dbCount}, Ext=${extCount}) >= 最大限制=${copyTrading.maxPositionCount}"
|
"超过最大仓位数量限制: 当前活跃仓位总数(取最大值)=${currentPositionCount} (DB=${dbCount}, Ext=${extCount}) >= 最大限制=${copyTrading.maxPositionCount}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return FilterResult.passed()
|
return FilterResult.passed()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("仓位检查异常: accountId=${copyTrading.accountId}, marketId=$marketId, error=${e.message}", e)
|
logger.error("仓位检查异常: accountId=${copyTrading.accountId}, marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}", e)
|
||||||
// 如果检查异常,为了安全起见,不通过检查
|
// 如果检查异常,为了安全起见,不通过检查
|
||||||
return FilterResult.maxPositionValueFailed("仓位检查异常: ${e.message}")
|
return FilterResult.maxPositionValueFailed("仓位检查异常: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-3
@@ -14,6 +14,8 @@ import com.wrbug.polymarketbot.util.IllegalBigDecimal
|
|||||||
import com.wrbug.polymarketbot.util.JsonUtils
|
import com.wrbug.polymarketbot.util.JsonUtils
|
||||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.context.ApplicationContext
|
||||||
|
import org.springframework.context.ApplicationContextAware
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
@@ -30,10 +32,24 @@ class CopyTradingService(
|
|||||||
private val monitorService: CopyTradingMonitorService,
|
private val monitorService: CopyTradingMonitorService,
|
||||||
private val jsonUtils: JsonUtils,
|
private val jsonUtils: JsonUtils,
|
||||||
private val gson: Gson
|
private val gson: Gson
|
||||||
) {
|
) : ApplicationContextAware {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(CopyTradingService::class.java)
|
private val logger = LoggerFactory.getLogger(CopyTradingService::class.java)
|
||||||
|
|
||||||
|
private var applicationContext: ApplicationContext? = null
|
||||||
|
|
||||||
|
override fun setApplicationContext(applicationContext: ApplicationContext) {
|
||||||
|
this.applicationContext = applicationContext
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取代理对象,用于解决 @Transactional 自调用问题
|
||||||
|
*/
|
||||||
|
private fun getSelf(): CopyTradingService {
|
||||||
|
return applicationContext?.getBean(CopyTradingService::class.java)
|
||||||
|
?: throw IllegalStateException("ApplicationContext not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建跟单配置
|
* 创建跟单配置
|
||||||
* 支持两种方式:
|
* 支持两种方式:
|
||||||
@@ -331,7 +347,7 @@ class CopyTradingService(
|
|||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
fun updateCopyTradingStatus(request: CopyTradingUpdateStatusRequest): Result<CopyTradingDto> {
|
fun updateCopyTradingStatus(request: CopyTradingUpdateStatusRequest): Result<CopyTradingDto> {
|
||||||
return updateCopyTrading(
|
return getSelf().updateCopyTrading(
|
||||||
CopyTradingUpdateRequest(
|
CopyTradingUpdateRequest(
|
||||||
copyTradingId = request.copyTradingId,
|
copyTradingId = request.copyTradingId,
|
||||||
enabled = request.enabled
|
enabled = request.enabled
|
||||||
|
|||||||
+2
-2
@@ -114,10 +114,10 @@ class AccountOnChainMonitorService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val receiptRpcResponse = receiptResponse.body()!!
|
val receiptRpcResponse = receiptResponse.body()!!
|
||||||
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
|
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null || receiptRpcResponse.result.isJsonNull) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 Gson 解析 receipt JSON
|
// 使用 Gson 解析 receipt JSON
|
||||||
val receiptJson = receiptRpcResponse.result.asJsonObject
|
val receiptJson = receiptRpcResponse.result.asJsonObject
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -213,7 +213,7 @@ class CopyTradingWebSocketService(
|
|||||||
// 处理交易
|
// 处理交易
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
copyOrderTrackingService.processTrade(leaderId, trade, "websocket")
|
copyOrderTrackingService.processTrade(leaderId, trade, "activity-ws")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("处理交易失败: leaderId=$leaderId, tradeId=${trade.id}", e)
|
logger.error("处理交易失败: leaderId=$leaderId, tradeId=${trade.id}", e)
|
||||||
}
|
}
|
||||||
|
|||||||
+16
@@ -1,5 +1,7 @@
|
|||||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||||
|
|
||||||
|
import com.github.benmanes.caffeine.cache.Cache
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine
|
||||||
import com.google.gson.JsonNull
|
import com.google.gson.JsonNull
|
||||||
import com.wrbug.polymarketbot.api.*
|
import com.wrbug.polymarketbot.api.*
|
||||||
import com.wrbug.polymarketbot.entity.Leader
|
import com.wrbug.polymarketbot.entity.Leader
|
||||||
@@ -12,6 +14,7 @@ import okhttp3.OkHttpClient
|
|||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 链上 WebSocket 监听服务
|
* 链上 WebSocket 监听服务
|
||||||
@@ -30,6 +33,11 @@ class OnChainWsService(
|
|||||||
// 存储需要监听的Leader:leaderId -> Leader
|
// 存储需要监听的Leader:leaderId -> Leader
|
||||||
private val monitoredLeaders = ConcurrentHashMap<Long, Leader>()
|
private val monitoredLeaders = ConcurrentHashMap<Long, Leader>()
|
||||||
|
|
||||||
|
// 存储已处理的交易哈希,用于去重(LRU 缓存,保留最近 100 条)
|
||||||
|
private val processedTxHashes: Cache<String, Long> = Caffeine.newBuilder()
|
||||||
|
.maximumSize(100)
|
||||||
|
.build()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动链上 WebSocket 监听
|
* 启动链上 WebSocket 监听
|
||||||
* 通过统一服务订阅所有 Leader
|
* 通过统一服务订阅所有 Leader
|
||||||
@@ -95,6 +103,14 @@ class OnChainWsService(
|
|||||||
) {
|
) {
|
||||||
val leader = monitoredLeaders[leaderId] ?: return
|
val leader = monitoredLeaders[leaderId] ?: return
|
||||||
|
|
||||||
|
// 根据 txHash 去重(使用原子操作避免竞态条件)
|
||||||
|
val currentTime = System.currentTimeMillis()
|
||||||
|
val existingTimestamp = processedTxHashes.asMap().putIfAbsent(txHash, currentTime)
|
||||||
|
if (existingTimestamp != null) {
|
||||||
|
logger.debug("交易已处理过,跳过: leaderId=$leaderId, txHash=$txHash, firstProcessedAt=$existingTimestamp")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug("开始处理 Leader 交易: leaderId=$leaderId, txHash=$txHash, leaderAddress=${leader.leaderAddress}")
|
logger.debug("开始处理 Leader 交易: leaderId=$leaderId, txHash=$txHash, leaderAddress=${leader.leaderAddress}")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
+123
-22
@@ -1,5 +1,7 @@
|
|||||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||||
|
|
||||||
|
import com.github.benmanes.caffeine.cache.Cache
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine
|
||||||
import com.wrbug.polymarketbot.api.TradeResponse
|
import com.wrbug.polymarketbot.api.TradeResponse
|
||||||
import com.wrbug.polymarketbot.dto.ActivityTradeMessage
|
import com.wrbug.polymarketbot.dto.ActivityTradeMessage
|
||||||
import com.wrbug.polymarketbot.dto.ActivityTradePayload
|
import com.wrbug.polymarketbot.dto.ActivityTradePayload
|
||||||
@@ -15,10 +17,11 @@ import org.slf4j.LoggerFactory
|
|||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Polymarket Activity WebSocket 监听服务
|
* Polymarket Activity WebSocket 监听服务
|
||||||
* 通过订阅全局 activity 交易流,客户端过滤 Leader 地址,实现实时交易检测
|
* 通过订阅全局 activity 交易流(trades + orders_matched),客户端过滤 Leader 地址,实现实时交易检测
|
||||||
* 延迟 < 100ms,适合快速跟单场景
|
* 延迟 < 100ms,适合快速跟单场景
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@@ -39,6 +42,13 @@ class PolymarketActivityWsService(
|
|||||||
// 要监听的 Leader 地址集合(小写地址 -> leaderId)
|
// 要监听的 Leader 地址集合(小写地址 -> leaderId)
|
||||||
private val monitoredAddresses = ConcurrentHashMap<String, Long>()
|
private val monitoredAddresses = ConcurrentHashMap<String, Long>()
|
||||||
|
|
||||||
|
// 存储已处理的交易哈希,用于去重(LRU 缓存,保留最近 100 条)
|
||||||
|
// 因为同时订阅 trades 和 orders_matched,同一个交易可能被推送两次
|
||||||
|
private val processedTxHashes: Cache<String, Long> = Caffeine.newBuilder()
|
||||||
|
.maximumSize(100)
|
||||||
|
.expireAfterWrite(10, TimeUnit.MINUTES)
|
||||||
|
.build()
|
||||||
|
|
||||||
// 是否已订阅
|
// 是否已订阅
|
||||||
@Volatile
|
@Volatile
|
||||||
private var isSubscribed = false
|
private var isSubscribed = false
|
||||||
@@ -50,6 +60,12 @@ class PolymarketActivityWsService(
|
|||||||
// Activity 消息超时检测任务
|
// Activity 消息超时检测任务
|
||||||
private var activityTimeoutJob: Job? = null
|
private var activityTimeoutJob: Job? = null
|
||||||
|
|
||||||
|
// 性能统计
|
||||||
|
private var totalMessagesProcessed = 0L
|
||||||
|
private var addressMatchMessages = 0L
|
||||||
|
private var jsonParseMessages = 0L
|
||||||
|
private var duplicateTxHashMessages = 0L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动监听
|
* 启动监听
|
||||||
*/
|
*/
|
||||||
@@ -68,7 +84,7 @@ class PolymarketActivityWsService(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("启动 Activity WebSocket 监听,监控 ${monitoredAddresses.size} 个 Leader 地址")
|
logger.info("启动 Activity WebSocket 监听(trades + orders_matched),监控 ${monitoredAddresses.size} 个 Leader 地址")
|
||||||
connectAndSubscribe()
|
connectAndSubscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +181,7 @@ class PolymarketActivityWsService(
|
|||||||
* 订阅全局 activity
|
* 订阅全局 activity
|
||||||
* 根据 @polymarket/real-time-data-client 的协议格式
|
* 根据 @polymarket/real-time-data-client 的协议格式
|
||||||
* 使用 "action": "subscribe" 而不是 "type": "subscribe"
|
* 使用 "action": "subscribe" 而不是 "type": "subscribe"
|
||||||
|
* 同时订阅 trades 和 orders_matched 两种类型
|
||||||
*/
|
*/
|
||||||
private fun subscribeAllActivity() {
|
private fun subscribeAllActivity() {
|
||||||
val client = wsClient
|
val client = wsClient
|
||||||
@@ -176,6 +193,7 @@ class PolymarketActivityWsService(
|
|||||||
try {
|
try {
|
||||||
// 根据 real-time-data-client 的协议格式
|
// 根据 real-time-data-client 的协议格式
|
||||||
// 订阅消息应包含 "action": "subscribe" 和 "subscriptions" 数组
|
// 订阅消息应包含 "action": "subscribe" 和 "subscriptions" 数组
|
||||||
|
// 同时订阅 trades 和 orders_matched 两种类型
|
||||||
val subscribeMessage = """
|
val subscribeMessage = """
|
||||||
{
|
{
|
||||||
"action": "subscribe",
|
"action": "subscribe",
|
||||||
@@ -183,6 +201,10 @@ class PolymarketActivityWsService(
|
|||||||
{
|
{
|
||||||
"topic": "activity",
|
"topic": "activity",
|
||||||
"type": "trades"
|
"type": "trades"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"topic": "activity",
|
||||||
|
"type": "orders_matched"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -193,8 +215,8 @@ class PolymarketActivityWsService(
|
|||||||
// 重置最后一次收到 activity 消息的时间
|
// 重置最后一次收到 activity 消息的时间
|
||||||
lastActivityTime = System.currentTimeMillis()
|
lastActivityTime = System.currentTimeMillis()
|
||||||
// 启动 Activity 消息超时检测
|
// 启动 Activity 消息超时检测
|
||||||
startActivityTimeoutCheck()
|
// startActivityTimeoutCheck()
|
||||||
logger.info("Activity WebSocket 订阅成功(全局交易流)")
|
logger.info("Activity WebSocket 订阅成功(全局交易流: trades + orders_matched)")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("订阅 Activity WebSocket 失败", e)
|
logger.error("订阅 Activity WebSocket 失败", e)
|
||||||
isSubscribed = false
|
isSubscribed = false
|
||||||
@@ -208,24 +230,24 @@ class PolymarketActivityWsService(
|
|||||||
private fun startActivityTimeoutCheck() {
|
private fun startActivityTimeoutCheck() {
|
||||||
// 先停止之前的检测任务
|
// 先停止之前的检测任务
|
||||||
stopActivityTimeoutCheck()
|
stopActivityTimeoutCheck()
|
||||||
|
|
||||||
activityTimeoutJob = scope.launch {
|
activityTimeoutJob = scope.launch {
|
||||||
while (isActive && isSubscribed) {
|
while (isActive && isSubscribed) {
|
||||||
delay(30000) // 每30秒检查一次
|
delay(30000) // 每30秒检查一次
|
||||||
|
|
||||||
// 如果已经取消订阅,停止检测
|
// 如果已经取消订阅,停止检测
|
||||||
if (!isSubscribed) {
|
if (!isSubscribed) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果 lastActivityTime 为 0,说明还没有收到过消息,跳过本次检测
|
// 如果 lastActivityTime 为 0,说明还没有收到过消息,跳过本次检测
|
||||||
if (lastActivityTime == 0L) {
|
if (lastActivityTime == 0L) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentTime = System.currentTimeMillis()
|
val currentTime = System.currentTimeMillis()
|
||||||
val timeSinceLastActivity = currentTime - lastActivityTime
|
val timeSinceLastActivity = currentTime - lastActivityTime
|
||||||
|
|
||||||
// 如果超过30秒没有收到activity消息,触发重连
|
// 如果超过30秒没有收到activity消息,触发重连
|
||||||
if (timeSinceLastActivity >= 30000) {
|
if (timeSinceLastActivity >= 30000) {
|
||||||
logger.warn("超过30秒未收到 Activity 消息,触发重连。距离上次消息: ${timeSinceLastActivity}ms")
|
logger.warn("超过30秒未收到 Activity 消息,触发重连。距离上次消息: ${timeSinceLastActivity}ms")
|
||||||
@@ -240,7 +262,7 @@ class PolymarketActivityWsService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 停止 Activity 消息超时检测
|
* 停止 Activity 消息超时检测
|
||||||
*/
|
*/
|
||||||
@@ -249,42 +271,95 @@ class PolymarketActivityWsService(
|
|||||||
activityTimeoutJob = null
|
activityTimeoutJob = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查消息是否包含监听的 Leader 地址
|
||||||
|
* 快速过滤,避免不必要的 JSON 解析
|
||||||
|
* 只需要检查 "proxyWallet":"0x..." 或 "trader":{"address":"0x..."} 格式
|
||||||
|
*/
|
||||||
|
private fun containsMonitoredAddress(message: String): Boolean {
|
||||||
|
// 快速检查:如果消息很短,不可能包含地址
|
||||||
|
if (message.length < 50) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 遍历所有监听的地址
|
||||||
|
for ((address, leaderId) in monitoredAddresses) {
|
||||||
|
// 检查 proxyWallet:格式为 "proxyWallet":"0x..."
|
||||||
|
if (message.contains("\"proxyWallet\":\"$address\"", ignoreCase = true)) {
|
||||||
|
addressMatchMessages++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 trader.address:格式为 "trader":{"address":"0x..."}
|
||||||
|
if (message.contains("\"trader\"", ignoreCase = true) &&
|
||||||
|
message.contains("\"address\":\"$address\"", ignoreCase = true)
|
||||||
|
) {
|
||||||
|
addressMatchMessages++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理消息
|
* 处理消息
|
||||||
*/
|
*/
|
||||||
private fun handleMessage(message: String) {
|
private fun handleMessage(message: String) {
|
||||||
try {
|
try {
|
||||||
|
totalMessagesProcessed++
|
||||||
|
|
||||||
// 处理 PONG 响应
|
// 处理 PONG 响应
|
||||||
if (message.trim() == "PONG" || message.trim() == "pong") {
|
if (message.trim() == "PONG" || message.trim() == "pong") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用扩展函数解析消息
|
// 快速预检查:检查是否包含监听地址
|
||||||
|
// 绝大部分消息会在这一步被过滤掉,避免不必要的 JSON 解析
|
||||||
|
if (!containsMonitoredAddress(message)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.info("发现leader交易:${message}")
|
||||||
|
// 使用扩展函数解析消息(只对包含监听地址的消息)
|
||||||
val tradeMessage = message.fromJson<ActivityTradeMessage>() ?: run {
|
val tradeMessage = message.fromJson<ActivityTradeMessage>() ?: run {
|
||||||
// 不是有效的 JSON 或格式不匹配,跳过
|
// 不是有效的 JSON 或格式不匹配,跳过
|
||||||
logger.warn("无法解析为 ActivityTradeMessage,可能不是 activity 消息: ${message.take(200)}")
|
logger.warn("无法解析为 ActivityTradeMessage: ${message.take(200)}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否是 activity trade 消息
|
jsonParseMessages++
|
||||||
if (tradeMessage.topic != "activity" || tradeMessage.type != "trades") {
|
|
||||||
// 不是我们关心的消息,直接返回
|
// 检查是否是 activity 消息(trades 或 orders_matched)
|
||||||
|
if (tradeMessage.topic != "activity" ||
|
||||||
|
(tradeMessage.type != "trades" && tradeMessage.type != "orders_matched")) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新最后一次收到 activity 消息的时间(即使不是我们监听的 Leader 的交易)
|
// 更新最后一次收到 activity 消息的时间(即使不是我们监听的 Leader 的交易)
|
||||||
lastActivityTime = System.currentTimeMillis()
|
lastActivityTime = System.currentTimeMillis()
|
||||||
|
|
||||||
val payload = tradeMessage.payload
|
val payload = tradeMessage.payload
|
||||||
|
|
||||||
|
// 根据 txHash 去重(使用原子操作避免竞态条件)
|
||||||
|
val txHash = payload.transactionHash
|
||||||
|
if (txHash != null && txHash.isNotBlank()) {
|
||||||
|
val currentTime = System.currentTimeMillis()
|
||||||
|
val existingTimestamp = processedTxHashes.asMap().putIfAbsent(txHash, currentTime)
|
||||||
|
if (existingTimestamp != null) {
|
||||||
|
duplicateTxHashMessages++
|
||||||
|
logger.debug("交易已处理过,跳过: txHash=$txHash, firstProcessedAt=$existingTimestamp, type=${tradeMessage.type}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 提取交易者地址
|
// 提取交易者地址
|
||||||
val traderAddress = extractTraderAddress(payload) ?: run {
|
val traderAddress = extractTraderAddress(payload) ?: run {
|
||||||
// 没有交易者地址,跳过
|
// 没有交易者地址,跳过
|
||||||
logger.warn("Activity Trade 消息中没有交易者地址: trader=${payload.trader}, proxyWallet=${payload.proxyWallet}, asset=${payload.asset}")
|
logger.warn("Activity Trade 消息中没有交易者地址: trader=${payload.trader}, proxyWallet=${payload.proxyWallet}, asset=${payload.asset}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否是我们监听的 Leader
|
// 二次验证:确认地址匹配
|
||||||
val normalizedAddress = traderAddress.lowercase()
|
val normalizedAddress = traderAddress.lowercase()
|
||||||
val leaderId = monitoredAddresses[normalizedAddress] ?: run {
|
val leaderId = monitoredAddresses[normalizedAddress] ?: run {
|
||||||
return
|
return
|
||||||
@@ -449,6 +524,7 @@ class PolymarketActivityWsService(
|
|||||||
wsClient = null
|
wsClient = null
|
||||||
isSubscribed = false
|
isSubscribed = false
|
||||||
monitoredAddresses.clear()
|
monitoredAddresses.clear()
|
||||||
|
processedTxHashes.invalidateAll() // 清空去重缓存
|
||||||
lastActivityTime = 0
|
lastActivityTime = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,8 +542,33 @@ class PolymarketActivityWsService(
|
|||||||
return monitoredAddresses.size
|
return monitoredAddresses.size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取性能统计信息
|
||||||
|
*/
|
||||||
|
fun getPerformanceStats(): Map<String, Any> {
|
||||||
|
val jsonParseRate = if (totalMessagesProcessed > 0) {
|
||||||
|
(jsonParseMessages.toDouble() / totalMessagesProcessed * 100).toInt()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapOf(
|
||||||
|
"totalMessages" to totalMessagesProcessed,
|
||||||
|
"addressMatches" to addressMatchMessages,
|
||||||
|
"jsonParses" to jsonParseMessages,
|
||||||
|
"duplicateTxHashes" to duplicateTxHashMessages,
|
||||||
|
"jsonParseRate" to "$jsonParseRate%",
|
||||||
|
"filteringEfficiency" to if (totalMessagesProcessed > 0) {
|
||||||
|
((1.0 - jsonParseMessages.toDouble() / totalMessagesProcessed) * 100).toInt()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@PreDestroy
|
@PreDestroy
|
||||||
fun destroy() {
|
fun destroy() {
|
||||||
|
logger.info("Activity WS 性能统计: ${getPerformanceStats()}")
|
||||||
stop()
|
stop()
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-15
@@ -23,6 +23,8 @@ import com.wrbug.polymarketbot.service.common.MarketService
|
|||||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||||
|
import org.springframework.context.ApplicationContext
|
||||||
|
import org.springframework.context.ApplicationContextAware
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
@@ -51,12 +53,26 @@ open class CopyOrderTrackingService(
|
|||||||
private val cryptoUtils: CryptoUtils,
|
private val cryptoUtils: CryptoUtils,
|
||||||
private val marketService: MarketService, // 市场信息服务
|
private val marketService: MarketService, // 市场信息服务
|
||||||
private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖
|
private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖
|
||||||
) {
|
) : ApplicationContextAware {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(CopyOrderTrackingService::class.java)
|
private val logger = LoggerFactory.getLogger(CopyOrderTrackingService::class.java)
|
||||||
|
|
||||||
// 协程作用域(用于异步发送通知)
|
// 协程作用域(用于异步发送通知)
|
||||||
private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
|
private var applicationContext: ApplicationContext? = null
|
||||||
|
|
||||||
|
override fun setApplicationContext(applicationContext: ApplicationContext) {
|
||||||
|
this.applicationContext = applicationContext
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取代理对象,用于解决 @Transactional 自调用问题
|
||||||
|
*/
|
||||||
|
private fun getSelf(): CopyOrderTrackingService {
|
||||||
|
return applicationContext?.getBean(CopyOrderTrackingService::class.java)
|
||||||
|
?: throw IllegalStateException("ApplicationContext not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
// 使用 Mutex 保证线程安全(按交易ID锁定)
|
// 使用 Mutex 保证线程安全(按交易ID锁定)
|
||||||
private val tradeMutexMap = ConcurrentHashMap<String, Mutex>()
|
private val tradeMutexMap = ConcurrentHashMap<String, Mutex>()
|
||||||
@@ -138,10 +154,11 @@ open class CopyOrderTrackingService(
|
|||||||
return@withLock Result.success(Unit)
|
return@withLock Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 处理交易逻辑
|
// 2. 处理交易逻辑(通过代理对象调用,确保 @Transactional 生效)
|
||||||
|
val self = getSelf()
|
||||||
val result = when (trade.side.uppercase()) {
|
val result = when (trade.side.uppercase()) {
|
||||||
"BUY" -> processBuyTrade(leaderId, trade)
|
"BUY" -> self.processBuyTrade(leaderId, trade, source)
|
||||||
"SELL" -> processSellTrade(leaderId, trade)
|
"SELL" -> self.processSellTrade(leaderId, trade)
|
||||||
else -> {
|
else -> {
|
||||||
logger.warn("未知的交易方向: ${trade.side}")
|
logger.warn("未知的交易方向: ${trade.side}")
|
||||||
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
|
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
|
||||||
@@ -213,7 +230,7 @@ open class CopyOrderTrackingService(
|
|||||||
* 创建跟单买入订单并记录到跟踪表
|
* 创建跟单买入订单并记录到跟踪表
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
suspend fun processBuyTrade(leaderId: Long, trade: TradeResponse): Result<Unit> {
|
suspend fun processBuyTrade(leaderId: Long, trade: TradeResponse, source: String): Result<Unit> {
|
||||||
return try {
|
return try {
|
||||||
// 1. 查找所有启用且支持该Leader的跟单关系
|
// 1. 查找所有启用且支持该Leader的跟单关系
|
||||||
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
|
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
|
||||||
@@ -285,7 +302,7 @@ open class CopyOrderTrackingService(
|
|||||||
|
|
||||||
// 过滤条件检查(在计算订单参数之前)
|
// 过滤条件检查(在计算订单参数之前)
|
||||||
// 传入 Leader 交易价格,用于价格区间检查
|
// 传入 Leader 交易价格,用于价格区间检查
|
||||||
// 传入跟单金额和市场ID,用于仓位检查(按市场检查仓位)
|
// 传入跟单金额和市场ID,用于仓位检查(按市场+方向检查仓位)
|
||||||
// 传入市场标题,用于关键字过滤
|
// 传入市场标题,用于关键字过滤
|
||||||
// 传入市场截止时间,用于市场截止时间检查
|
// 传入市场截止时间,用于市场截止时间检查
|
||||||
// 订单簿只请求一次,返回给后续逻辑使用
|
// 订单簿只请求一次,返回给后续逻辑使用
|
||||||
@@ -296,7 +313,8 @@ open class CopyOrderTrackingService(
|
|||||||
copyOrderAmount = copyOrderAmount,
|
copyOrderAmount = copyOrderAmount,
|
||||||
marketId = trade.market,
|
marketId = trade.market,
|
||||||
marketTitle = marketTitle,
|
marketTitle = marketTitle,
|
||||||
marketEndDate = marketEndDate
|
marketEndDate = marketEndDate,
|
||||||
|
outcomeIndex = trade.outcomeIndex
|
||||||
)
|
)
|
||||||
val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
|
val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
|
||||||
if (!filterResult.isPassed) {
|
if (!filterResult.isPassed) {
|
||||||
@@ -622,7 +640,8 @@ open class CopyOrderTrackingService(
|
|||||||
price = buyPrice, // 使用下单价格,临时值
|
price = buyPrice, // 使用下单价格,临时值
|
||||||
remainingQuantity = finalBuyQuantity,
|
remainingQuantity = finalBuyQuantity,
|
||||||
status = "filled",
|
status = "filled",
|
||||||
notificationSent = false // 标记为未发送通知,等待轮询任务获取实际数据后发送
|
notificationSent = false, // 标记为未发送通知,等待轮询任务获取实际数据后发送
|
||||||
|
source = source // 订单来源
|
||||||
)
|
)
|
||||||
|
|
||||||
copyOrderTrackingRepository.save(tracking)
|
copyOrderTrackingRepository.save(tracking)
|
||||||
@@ -685,8 +704,8 @@ open class CopyOrderTrackingService(
|
|||||||
private fun calculateBuyQuantity(trade: TradeResponse, copyTrading: CopyTrading): BigDecimal {
|
private fun calculateBuyQuantity(trade: TradeResponse, copyTrading: CopyTrading): BigDecimal {
|
||||||
return when (copyTrading.copyMode) {
|
return when (copyTrading.copyMode) {
|
||||||
"RATIO" -> {
|
"RATIO" -> {
|
||||||
// 比例模式:Leader 数量 × (比例 / 100)
|
// 比例模式:Leader 数量 × 比例倍数(copyRatio 已经是倍数值,如 1.3 表示 130%)
|
||||||
trade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
trade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
"FIXED" -> {
|
"FIXED" -> {
|
||||||
@@ -718,7 +737,7 @@ open class CopyOrderTrackingService(
|
|||||||
val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null)
|
val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null)
|
||||||
?: run {
|
?: run {
|
||||||
logger.warn("Leader 不存在,使用默认比例: leaderId=${copyTrading.leaderId}")
|
logger.warn("Leader 不存在,使用默认比例: leaderId=${copyTrading.leaderId}")
|
||||||
return leaderSellQuantity.multi(copyTrading.copyRatio.div(100))
|
return leaderSellQuantity.multi(copyTrading.copyRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建不需要认证的 CLOB API 客户端(用于查询公开的交易数据)
|
// 创建不需要认证的 CLOB API 客户端(用于查询公开的交易数据)
|
||||||
@@ -789,7 +808,7 @@ open class CopyOrderTrackingService(
|
|||||||
// 如果无法计算总比例(查询失败),使用默认比例
|
// 如果无法计算总比例(查询失败),使用默认比例
|
||||||
if (totalLeaderQuantity.lte(BigDecimal.ZERO)) {
|
if (totalLeaderQuantity.lte(BigDecimal.ZERO)) {
|
||||||
logger.warn("无法计算总比例(Leader 买入数量为 0),使用默认比例: copyTradingId=${copyTrading.id}")
|
logger.warn("无法计算总比例(Leader 买入数量为 0),使用默认比例: copyTradingId=${copyTrading.id}")
|
||||||
return leaderSellQuantity.multi(copyTrading.copyRatio.div(100))
|
return leaderSellQuantity.multi(copyTrading.copyRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算实际比例:跟单买入数量 / Leader 买入数量
|
// 计算实际比例:跟单买入数量 / Leader 买入数量
|
||||||
@@ -865,13 +884,13 @@ open class CopyOrderTrackingService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
"RATIO" -> {
|
"RATIO" -> {
|
||||||
// 比例模式:直接使用配置的 copyRatio (需要除以100)
|
// 比例模式:直接使用配置的 copyRatio(已经是倍数值,如 1.3 表示 130%)
|
||||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
logger.warn("不支持的 copyMode: ${copyTrading.copyMode},使用默认比例模式")
|
logger.warn("不支持的 copyMode: ${copyTrading.copyMode},使用默认比例模式")
|
||||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-14
@@ -611,22 +611,22 @@ class CopyTradingStatisticsService(
|
|||||||
|
|
||||||
// 4. 转换为分组数据并计算统计信息
|
// 4. 转换为分组数据并计算统计信息
|
||||||
val marketIds = groups.keys.toList()
|
val marketIds = groups.keys.toList()
|
||||||
|
|
||||||
val list = marketIds.map { marketId ->
|
val list = marketIds.map { marketId ->
|
||||||
val marketOrders = groups[marketId] ?: mutableListOf()
|
val marketOrders = groups[marketId] ?: mutableListOf()
|
||||||
|
|
||||||
// 计算统计信息
|
// 计算统计信息
|
||||||
val count = marketOrders.size.toLong()
|
val count = marketOrders.size.toLong()
|
||||||
val totalAmount = marketOrders.sumOf { order ->
|
val totalAmount = marketOrders.sumOf { order ->
|
||||||
order.quantity.toSafeBigDecimal().multi(order.price)
|
order.quantity.toSafeBigDecimal().multi(order.price)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算订单状态统计
|
// 计算订单状态统计
|
||||||
val fullyMatchedCount = marketOrders.count { it.status == "fully_matched" }
|
val fullyMatchedCount = marketOrders.count { it.status == "fully_matched" }
|
||||||
val partiallyMatchedCount = marketOrders.count { it.status == "partially_matched" }
|
val partiallyMatchedCount = marketOrders.count { it.status == "partially_matched" }
|
||||||
val filledCount = marketOrders.count { it.status == "filled" }
|
val filledCount = marketOrders.count { it.status == "filled" }
|
||||||
val fullyMatched = fullyMatchedCount == marketOrders.size
|
val fullyMatched = fullyMatchedCount == marketOrders.size
|
||||||
|
|
||||||
val stats = MarketOrderStats(
|
val stats = MarketOrderStats(
|
||||||
count = count,
|
count = count,
|
||||||
totalAmount = totalAmount.toString(),
|
totalAmount = totalAmount.toString(),
|
||||||
@@ -636,10 +636,10 @@ class CopyTradingStatisticsService(
|
|||||||
partiallyMatchedCount = partiallyMatchedCount.toLong(),
|
partiallyMatchedCount = partiallyMatchedCount.toLong(),
|
||||||
filledCount = filledCount.toLong()
|
filledCount = filledCount.toLong()
|
||||||
)
|
)
|
||||||
|
|
||||||
// 排序(按创建时间倒序)
|
// 排序(按创建时间倒序)
|
||||||
marketOrders.sortByDescending { it.createdAt }
|
marketOrders.sortByDescending { it.createdAt }
|
||||||
|
|
||||||
// 转换为 DTO
|
// 转换为 DTO
|
||||||
val orderDtos = marketOrders.map { order ->
|
val orderDtos = marketOrders.map { order ->
|
||||||
val amount = order.quantity.toSafeBigDecimal().multi(order.price)
|
val amount = order.quantity.toSafeBigDecimal().multi(order.price)
|
||||||
@@ -662,7 +662,7 @@ class CopyTradingStatisticsService(
|
|||||||
createdAt = order.createdAt
|
createdAt = order.createdAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
MarketOrderGroup(
|
MarketOrderGroup(
|
||||||
marketId = marketId,
|
marketId = marketId,
|
||||||
marketTitle = markets[marketId]?.title,
|
marketTitle = markets[marketId]?.title,
|
||||||
@@ -672,27 +672,35 @@ class CopyTradingStatisticsService(
|
|||||||
stats = stats,
|
stats = stats,
|
||||||
orders = orderDtos as List<Any>
|
orders = orderDtos as List<Any>
|
||||||
)
|
)
|
||||||
}.sortedByDescending { it.stats.count }
|
}.sortedByDescending { group ->
|
||||||
|
// 找出该市场最近的买入订单时间
|
||||||
|
group.orders.mapNotNull { order ->
|
||||||
|
when (order) {
|
||||||
|
is BuyOrderInfo -> order.createdAt
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}.maxOrNull() ?: 0L
|
||||||
|
}
|
||||||
|
|
||||||
// 5. 分页
|
// 5. 分页
|
||||||
val page = (request.page ?: 1)
|
val page = (request.page ?: 1)
|
||||||
val limit = request.limit ?: 20
|
val limit = request.limit ?: 20
|
||||||
val total = list.size.toLong()
|
val total = list.size.toLong()
|
||||||
|
|
||||||
val start = (page - 1) * limit
|
val start = (page - 1) * limit
|
||||||
val end = minOf(start + limit, list.size)
|
val end = minOf(start + limit, list.size)
|
||||||
val pagedList = if (start < list.size) list.subList(start, end) else emptyList()
|
val pagedList = if (start < list.size) list.subList(start, end) else emptyList()
|
||||||
|
|
||||||
val response = MarketGroupedOrdersResponse(
|
val response = MarketGroupedOrdersResponse(
|
||||||
list = pagedList,
|
list = pagedList,
|
||||||
total = total,
|
total = total,
|
||||||
page = page,
|
page = page,
|
||||||
limit = limit
|
limit = limit
|
||||||
)
|
)
|
||||||
|
|
||||||
Result.success(response)
|
Result.success(response)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("获取按市场分组的买入订单列表失败: copyTradingId=${request.copyTradingId}", e)
|
logger.error("获取按市场分组的卖出订单列表失败: copyTradingId=${request.copyTradingId}", e)
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -791,7 +799,15 @@ class CopyTradingStatisticsService(
|
|||||||
stats = stats,
|
stats = stats,
|
||||||
orders = orderDtos as List<Any>
|
orders = orderDtos as List<Any>
|
||||||
)
|
)
|
||||||
}.sortedByDescending { it.stats.count }
|
}.sortedByDescending { group ->
|
||||||
|
// 找出该市场最近的卖出订单时间(与买入订单分组排序规则一致)
|
||||||
|
group.orders.mapNotNull { order ->
|
||||||
|
when (order) {
|
||||||
|
is SellOrderInfo -> order.createdAt
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}.maxOrNull() ?: 0L
|
||||||
|
}
|
||||||
|
|
||||||
// 5. 分页
|
// 5. 分页
|
||||||
val page = (request.page ?: 1)
|
val page = (request.page ?: 1)
|
||||||
|
|||||||
+404
-193
File diff suppressed because it is too large
Load Diff
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package com.wrbug.polymarketbot.service.system
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.repository.ProcessedTradeRepository
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已处理交易清理服务
|
||||||
|
* 定期清理过期的去重记录
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class ProcessedTradeCleanupService(
|
||||||
|
private val processedTradeRepository: ProcessedTradeRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val logger = LoggerFactory.getLogger(ProcessedTradeCleanupService::class.java)
|
||||||
|
|
||||||
|
// 保留时间:1小时(3600000毫秒)
|
||||||
|
// 说明:重复订单通常10秒后就不会再出现,保留10分钟是为了安全起见
|
||||||
|
private const val RETENTION_MS = 600_000L
|
||||||
|
|
||||||
|
// 定时清理间隔:10分钟(600000毫秒)
|
||||||
|
private const val CLEANUP_INTERVAL_MS = 600_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时清理过期记录
|
||||||
|
* 每10分钟执行一次
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelay = CLEANUP_INTERVAL_MS)
|
||||||
|
@Transactional
|
||||||
|
fun cleanupExpiredProcessedTrades() {
|
||||||
|
try {
|
||||||
|
val expireTime = System.currentTimeMillis() - RETENTION_MS
|
||||||
|
val deletedCount = processedTradeRepository.deleteByProcessedAtBefore(expireTime)
|
||||||
|
|
||||||
|
if (deletedCount > 0) {
|
||||||
|
logger.info("清理过期已处理交易记录: deletedCount=$deletedCount, expireTime=$expireTime")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("清理过期已处理交易记录失败", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- V25: 添加订单来源字段到跟单订单跟踪表
|
||||||
|
-- 用于记录订单是从哪个数据源接收到的(activity-ws 或 onchain-ws)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 添加订单来源字段
|
||||||
|
ALTER TABLE copy_order_tracking
|
||||||
|
ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT 'unknown' COMMENT '订单来源:activity-ws(Polymarket WebSocket)、onchain-ws(OnChain WebSocket)';
|
||||||
|
|
||||||
|
-- 对于已有数据,设置为默认值 unknown(不影响现有功能)
|
||||||
|
-- 新创建的记录会在创建时自动填充此字段
|
||||||
|
|
||||||
@@ -1099,6 +1099,7 @@
|
|||||||
"price": "Price",
|
"price": "Price",
|
||||||
"amount": "Amount",
|
"amount": "Amount",
|
||||||
"filterMarketId": "Filter Market ID",
|
"filterMarketId": "Filter Market ID",
|
||||||
|
"filterMarketTitle": "Filter Market Title",
|
||||||
"filterSide": "Filter Side",
|
"filterSide": "Filter Side",
|
||||||
"filterStatus": "Filter Status",
|
"filterStatus": "Filter Status",
|
||||||
"filterSellOrderId": "Filter Sell Order ID",
|
"filterSellOrderId": "Filter Sell Order ID",
|
||||||
@@ -1109,12 +1110,15 @@
|
|||||||
"groupByMarket": "Group by Market",
|
"groupByMarket": "Group by Market",
|
||||||
"expandAll": "Expand All",
|
"expandAll": "Expand All",
|
||||||
"collapseAll": "Collapse All",
|
"collapseAll": "Collapse All",
|
||||||
"allFullyMatched": "All Fully Matched",
|
"allFullySold": "All Fully Sold",
|
||||||
"partiallyMatched": "Partially Matched",
|
"notSold": "Not Sold",
|
||||||
|
"partiallySold": "Partially Sold",
|
||||||
"orderCount": "Order Count",
|
"orderCount": "Order Count",
|
||||||
"totalAmount": "Total Amount",
|
"totalAmount": "Total Amount",
|
||||||
"totalPnl": "Total PnL",
|
|
||||||
"statusBreakdown": "Status",
|
"statusBreakdown": "Status",
|
||||||
|
"allFullyMatched": "All Fully Sold",
|
||||||
|
"partiallyMatched": "Partially Sold",
|
||||||
|
"totalPnl": "Total PnL",
|
||||||
"markets": "markets",
|
"markets": "markets",
|
||||||
"fetchBuyOrdersFailed": "Failed to fetch buy orders",
|
"fetchBuyOrdersFailed": "Failed to fetch buy orders",
|
||||||
"fetchSellOrdersFailed": "Failed to fetch sell orders",
|
"fetchSellOrdersFailed": "Failed to fetch sell orders",
|
||||||
@@ -1126,7 +1130,6 @@
|
|||||||
"totalMatchedOrders": "Total Matched Orders",
|
"totalMatchedOrders": "Total Matched Orders",
|
||||||
"totalBuyAmount": "Total Buy Amount",
|
"totalBuyAmount": "Total Buy Amount",
|
||||||
"totalSellAmount": "Total Sell Amount",
|
"totalSellAmount": "Total Sell Amount",
|
||||||
"totalPnl": "Total PnL",
|
|
||||||
"totalRealizedPnl": "Total Realized PnL",
|
"totalRealizedPnl": "Total Realized PnL",
|
||||||
"totalUnrealizedPnl": "Total Unrealized PnL",
|
"totalUnrealizedPnl": "Total Unrealized PnL",
|
||||||
"winRate": "Win Rate",
|
"winRate": "Win Rate",
|
||||||
@@ -1195,4 +1198,4 @@
|
|||||||
"providerChainstack": "Chainstack",
|
"providerChainstack": "Chainstack",
|
||||||
"providerGetBlock": "GetBlock"
|
"providerGetBlock": "GetBlock"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1080,8 +1080,8 @@
|
|||||||
"sellStatus": "卖出状态",
|
"sellStatus": "卖出状态",
|
||||||
"status": "状态",
|
"status": "状态",
|
||||||
"statusFilled": "未成交",
|
"statusFilled": "未成交",
|
||||||
"statusPartiallySold": "部分成交",
|
"statusPartiallySold": "部分卖出",
|
||||||
"statusFullySold": "全部成交",
|
"statusFullySold": "全部卖出",
|
||||||
"realizedPnl": "已实现盈亏",
|
"realizedPnl": "已实现盈亏",
|
||||||
"createdAt": "创建时间",
|
"createdAt": "创建时间",
|
||||||
"matchedAt": "匹配时间",
|
"matchedAt": "匹配时间",
|
||||||
@@ -1110,8 +1110,9 @@
|
|||||||
"groupByMarket": "按市场分组",
|
"groupByMarket": "按市场分组",
|
||||||
"expandAll": "展开全部",
|
"expandAll": "展开全部",
|
||||||
"collapseAll": "折叠全部",
|
"collapseAll": "折叠全部",
|
||||||
"allFullyMatched": "全部成交",
|
"allFullySold": "全部卖出",
|
||||||
"partiallyMatched": "部分成交",
|
"notSold": "未卖出",
|
||||||
|
"partiallySold": "部分卖出",
|
||||||
"orderCount": "订单数",
|
"orderCount": "订单数",
|
||||||
"totalAmount": "总金额",
|
"totalAmount": "总金额",
|
||||||
"statusBreakdown": "状态",
|
"statusBreakdown": "状态",
|
||||||
@@ -1195,4 +1196,4 @@
|
|||||||
"providerChainstack": "Chainstack",
|
"providerChainstack": "Chainstack",
|
||||||
"providerGetBlock": "GetBlock"
|
"providerGetBlock": "GetBlock"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1018,7 +1018,7 @@
|
|||||||
"telegramConfig": {
|
"telegramConfig": {
|
||||||
"title": "Telegram 配置說明",
|
"title": "Telegram 配置說明",
|
||||||
"step1": "通過 <strong>@BotFather</strong> 創建 Telegram 機器人,獲取 Bot Token",
|
"step1": "通過 <strong>@BotFather</strong> 創建 Telegram 機器人,獲取 Bot Token",
|
||||||
"step2": "填寫 Bot Token 後,點擊\"獲取 Chat ID\"按鈕自動獲取(需要先向機器人發送消息)",
|
"step2": "填寫 Bot Token 後,點擊\"獲取 Chat ID\"按鈕自動獲取(需要先向機器人發送消息)",
|
||||||
"step3": "或通過 <strong>@userinfobot</strong> 手動獲取 Chat ID",
|
"step3": "或通過 <strong>@userinfobot</strong> 手動獲取 Chat ID",
|
||||||
"step4": "支持配置多個 Chat ID(用逗號分隔),所有配置的用戶都會收到通知",
|
"step4": "支持配置多個 Chat ID(用逗號分隔),所有配置的用戶都會收到通知",
|
||||||
"step5": "訂單成功或失敗時會自動發送 Telegram 消息",
|
"step5": "訂單成功或失敗時會自動發送 Telegram 消息",
|
||||||
@@ -1080,8 +1080,8 @@
|
|||||||
"sellStatus": "賣出狀態",
|
"sellStatus": "賣出狀態",
|
||||||
"status": "狀態",
|
"status": "狀態",
|
||||||
"statusFilled": "已完成",
|
"statusFilled": "已完成",
|
||||||
"statusPartiallySold": "部分成交",
|
"statusPartiallySold": "部分賣出",
|
||||||
"statusFullySold": "全部成交",
|
"statusFullySold": "全部賣出",
|
||||||
"realizedPnl": "已實現盈虧",
|
"realizedPnl": "已實現盈虧",
|
||||||
"createdAt": "創建時間",
|
"createdAt": "創建時間",
|
||||||
"matchedAt": "匹配時間",
|
"matchedAt": "匹配時間",
|
||||||
@@ -1099,6 +1099,7 @@
|
|||||||
"price": "價格",
|
"price": "價格",
|
||||||
"amount": "金額",
|
"amount": "金額",
|
||||||
"filterMarketId": "篩選市場ID",
|
"filterMarketId": "篩選市場ID",
|
||||||
|
"filterMarketTitle": "篩選市場標題",
|
||||||
"filterSide": "篩選方向",
|
"filterSide": "篩選方向",
|
||||||
"filterStatus": "篩選狀態",
|
"filterStatus": "篩選狀態",
|
||||||
"filterSellOrderId": "篩選賣出訂單ID",
|
"filterSellOrderId": "篩選賣出訂單ID",
|
||||||
@@ -1109,8 +1110,9 @@
|
|||||||
"groupByMarket": "按市場分組",
|
"groupByMarket": "按市場分組",
|
||||||
"expandAll": "展開全部",
|
"expandAll": "展開全部",
|
||||||
"collapseAll": "折疊全部",
|
"collapseAll": "折疊全部",
|
||||||
"allFullyMatched": "全部成交",
|
"allFullySold": "全部賣出",
|
||||||
"partiallyMatched": "部分成交",
|
"notSold": "未賣出",
|
||||||
|
"partiallySold": "部分賣出",
|
||||||
"orderCount": "訂單數",
|
"orderCount": "訂單數",
|
||||||
"totalAmount": "總金額",
|
"totalAmount": "總金額",
|
||||||
"totalPnl": "總盈虧",
|
"totalPnl": "總盈虧",
|
||||||
@@ -1126,7 +1128,6 @@
|
|||||||
"totalMatchedOrders": "總匹配訂單數",
|
"totalMatchedOrders": "總匹配訂單數",
|
||||||
"totalBuyAmount": "總買入金額",
|
"totalBuyAmount": "總買入金額",
|
||||||
"totalSellAmount": "總賣出金額",
|
"totalSellAmount": "總賣出金額",
|
||||||
"totalPnl": "總盈虧",
|
|
||||||
"totalRealizedPnl": "總已實現盈虧",
|
"totalRealizedPnl": "總已實現盈虧",
|
||||||
"totalUnrealizedPnl": "總未實現盈虧",
|
"totalUnrealizedPnl": "總未實現盈虧",
|
||||||
"winRate": "勝率",
|
"winRate": "勝率",
|
||||||
@@ -1195,4 +1196,4 @@
|
|||||||
"providerChainstack": "Chainstack",
|
"providerChainstack": "Chainstack",
|
||||||
"providerGetBlock": "GetBlock"
|
"providerGetBlock": "GetBlock"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,10 +373,12 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{group.stats.fullyMatched ? (
|
{group.stats.fullyMatched ? (
|
||||||
<Tag color="success">{t('copyTradingOrders.allFullyMatched') || '全部成交'}</Tag>
|
<Tag color="success">{t('copyTradingOrders.allFullySold') || '全部卖出'}</Tag>
|
||||||
|
) : group.stats.fullyMatchedCount === 0 ? (
|
||||||
|
<Tag color="default">{t('copyTradingOrders.notSold') || '未卖出'}</Tag>
|
||||||
) : (
|
) : (
|
||||||
<Tag color="warning">
|
<Tag color="warning">
|
||||||
{t('copyTradingOrders.partiallyMatched') || '部分成交'} ({group.stats.fullyMatchedCount}/{group.stats.count})
|
{t('copyTradingOrders.partiallySold') || '部分卖出'} ({group.stats.fullyMatchedCount}/{group.stats.count})
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -384,10 +386,10 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
|||||||
<span>{t('copyTradingOrders.orderCount') || '订单数'}: {group.stats.count}</span>
|
<span>{t('copyTradingOrders.orderCount') || '订单数'}: {group.stats.count}</span>
|
||||||
<span>{t('copyTradingOrders.totalAmount') || '总金额'}: {formatUSDC(group.stats.totalAmount)} USDC</span>
|
<span>{t('copyTradingOrders.totalAmount') || '总金额'}: {formatUSDC(group.stats.totalAmount)} USDC</span>
|
||||||
<span>
|
<span>
|
||||||
{t('copyTradingOrders.statusBreakdown') || '状态'}:
|
{t('copyTradingOrders.statusBreakdown') || '状态'}:
|
||||||
{group.stats.fullyMatchedCount > 0 && ` ${t('copyTradingOrders.statusFullySold') || '全部成交'} ${group.stats.fullyMatchedCount}`}
|
{group.stats.fullyMatchedCount > 0 && ` ${t('copyTradingOrders.allFullySold') || '全部卖出'} ${group.stats.fullyMatchedCount}`}
|
||||||
{group.stats.partiallyMatchedCount > 0 && ` ${t('copyTradingOrders.statusPartiallySold') || '部分成交'} ${group.stats.partiallyMatchedCount}`}
|
{group.stats.partiallyMatchedCount > 0 && ` ${t('copyTradingOrders.partiallySold') || '部分卖出'} ${group.stats.partiallyMatchedCount}`}
|
||||||
{group.stats.filledCount > 0 && ` ${t('copyTradingOrders.statusFilled') || '未成交'} ${group.stats.filledCount}`}
|
{group.stats.filledCount > 0 && ` ${t('copyTradingOrders.notSold') || '未卖出'} ${group.stats.filledCount}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -668,9 +670,9 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
|||||||
value={filters.status}
|
value={filters.status}
|
||||||
onChange={(value) => setFilters({ ...filters, status: value || undefined })}
|
onChange={(value) => setFilters({ ...filters, status: value || undefined })}
|
||||||
>
|
>
|
||||||
<Option value="filled">{t('copyTradingOrders.statusFilled') || '未成交'}</Option>
|
<Option value="filled">{t('copyTradingOrders.notSold') || '未卖出'}</Option>
|
||||||
<Option value="partially_matched">{t('copyTradingOrders.statusPartiallySold') || '部分成交'}</Option>
|
<Option value="partially_matched">{t('copyTradingOrders.partiallySold') || '部分卖出'}</Option>
|
||||||
<Option value="fully_matched">{t('copyTradingOrders.statusFullySold') || '全部成交'}</Option>
|
<Option value="fully_matched">{t('copyTradingOrders.allFullySold') || '全部卖出'}</Option>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Space>
|
<Space>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Table, Input, Button, Card, Divider, Spin, message } from 'antd'
|
import { Table, Input, Button, Card, Divider, Spin, message } from 'antd'
|
||||||
import { apiService } from '../../services/api'
|
import { apiService } from '../../services/api'
|
||||||
import { formatUSDC, isAutoGeneratedOrderId, copyToClipboard } from '../../utils'
|
import { formatUSDC, isAutoGeneratedOrderId, copyToClipboard, getPolymarketUrl } from '../../utils'
|
||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { MatchedOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types'
|
import type { MatchedOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types'
|
||||||
@@ -91,59 +91,101 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
|
|||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: t('copyTradingOrders.sellOrderId') || '卖出订单ID',
|
title: t('copyTradingOrders.market') || '市场',
|
||||||
dataIndex: 'sellOrderId',
|
dataIndex: 'marketId',
|
||||||
key: 'sellOrderId',
|
key: 'marketId',
|
||||||
width: isMobile ? 120 : 180,
|
width: isMobile ? 120 : 200,
|
||||||
render: (text: string) => {
|
render: (text: string, record: MatchedOrderInfo) => {
|
||||||
const isAuto = isAutoGeneratedOrderId(text)
|
const marketUrl = getPolymarketUrl(record.marketSlug, record.eventSlug, record.marketCategory, record.marketId)
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
{record.marketTitle ? (
|
||||||
|
marketUrl ? (
|
||||||
|
<a
|
||||||
|
href={marketUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
fontSize: isMobile ? 11 : 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#1890ff',
|
||||||
|
textDecoration: 'none',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{record.marketTitle}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: isMobile ? 11 : 12, fontWeight: 500 }}>
|
||||||
|
{record.marketTitle}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 10 : 11, color: '#999' }}>
|
||||||
{isMobile
|
{isMobile
|
||||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
{!isAuto && (
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
onClick={() => handleCopyOrderId(text)}
|
|
||||||
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 11 : 12 }}
|
|
||||||
title={t('common.copy') || '复制'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('copyTradingOrders.buyOrderId') || '买入订单ID',
|
title: t('copyTradingOrders.orderId') || '订单ID',
|
||||||
dataIndex: 'buyOrderId',
|
dataIndex: 'orderId',
|
||||||
key: 'buyOrderId',
|
key: 'orderId',
|
||||||
width: isMobile ? 120 : 180,
|
width: isMobile ? 150 : 200,
|
||||||
render: (text: string) => {
|
render: (_: any, record: MatchedOrderInfo) => {
|
||||||
const isAuto = isAutoGeneratedOrderId(text)
|
const buyOrderId = record.buyOrderId
|
||||||
|
const sellOrderId = record.sellOrderId
|
||||||
|
const isBuyAuto = isAutoGeneratedOrderId(buyOrderId)
|
||||||
|
const isSellAuto = isAutoGeneratedOrderId(sellOrderId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div>
|
||||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
|
||||||
{isMobile
|
<span style={{ fontSize: isMobile ? 11 : 12, color: '#999' }}>
|
||||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
{t('copyTradingOrders.buy') || '买入'}:
|
||||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
</span>
|
||||||
}
|
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||||
</span>
|
{isMobile
|
||||||
{!isAuto && (
|
? `${buyOrderId.slice(0, 6)}...${buyOrderId.slice(-4)}`
|
||||||
<Button
|
: `${buyOrderId.slice(0, 8)}...${buyOrderId.slice(-6)}`
|
||||||
type="text"
|
}
|
||||||
size="small"
|
</span>
|
||||||
icon={<CopyOutlined />}
|
{!isBuyAuto && (
|
||||||
onClick={() => handleCopyOrderId(text)}
|
<Button
|
||||||
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 11 : 12 }}
|
type="text"
|
||||||
title={t('common.copy') || '复制'}
|
size="small"
|
||||||
/>
|
icon={<CopyOutlined />}
|
||||||
)}
|
onClick={() => handleCopyOrderId(buyOrderId)}
|
||||||
|
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 11 : 12 }}
|
||||||
|
title={t('common.copy') || '复制'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<span style={{ fontSize: isMobile ? 11 : 12, color: '#999' }}>
|
||||||
|
{t('copyTradingOrders.sell') || '卖出'}:
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||||
|
{isMobile
|
||||||
|
? `${sellOrderId.slice(0, 6)}...${sellOrderId.slice(-4)}`
|
||||||
|
: `${sellOrderId.slice(0, 8)}...${sellOrderId.slice(-6)}`
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
{!isSellAuto && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() => handleCopyOrderId(sellOrderId)}
|
||||||
|
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 11 : 12 }}
|
||||||
|
title={t('common.copy') || '复制'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -233,7 +275,7 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
|
|||||||
onChange={(e) => setFilters({ ...filters, buyOrderId: e.target.value || undefined })}
|
onChange={(e) => setFilters({ ...filters, buyOrderId: e.target.value || undefined })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="primary" onClick={fetchOrders} icon={<ReloadOutlined />}>{t('common.search') || '查询'}</Button>
|
<Button type="primary" onClick={fetchOrders} icon={<ReloadOutlined />}>{t('common.refresh') || '刷新'}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
@@ -273,9 +315,31 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
|
|||||||
<div style={{ marginBottom: '12px' }}>
|
<div style={{ marginBottom: '12px' }}>
|
||||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.market') || '市场'}</div>
|
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.market') || '市场'}</div>
|
||||||
{order.marketTitle ? (
|
{order.marketTitle ? (
|
||||||
<div style={{ fontSize: '13px', fontWeight: '500', marginBottom: '4px' }}>
|
(() => {
|
||||||
{order.marketTitle}
|
const marketUrl = getPolymarketUrl(order.marketSlug, order.eventSlug, order.marketCategory, order.marketId)
|
||||||
</div>
|
return marketUrl ? (
|
||||||
|
<a
|
||||||
|
href={marketUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: '500',
|
||||||
|
marginBottom: '4px',
|
||||||
|
color: '#1890ff',
|
||||||
|
textDecoration: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'block'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{order.marketTitle}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: '13px', fontWeight: '500', marginBottom: '4px' }}>
|
||||||
|
{order.marketTitle}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()
|
||||||
) : null}
|
) : null}
|
||||||
{order.marketId && (
|
{order.marketId && (
|
||||||
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
|
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
|
||||||
@@ -284,50 +348,42 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.sellOrderId') || '卖出订单ID'}</div>
|
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.orderId') || '订单ID'}</div>
|
||||||
<div style={{
|
<div style={{ marginBottom: '8px' }}>
|
||||||
fontSize: '13px',
|
<div style={{ marginBottom: '4px' }}>
|
||||||
fontWeight: '500',
|
<div style={{ fontSize: '11px', color: '#999', marginBottom: '2px' }}>{t('copyTradingOrders.buy') || '买入'}:</div>
|
||||||
fontFamily: 'monospace',
|
<div style={{ fontSize: '13px', fontWeight: '500', fontFamily: 'monospace', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
marginBottom: '8px',
|
<span>{order.buyOrderId.slice(0, 8)}...{order.buyOrderId.slice(-6)}</span>
|
||||||
display: 'flex',
|
{!isAutoGeneratedOrderId(order.buyOrderId) && (
|
||||||
alignItems: 'center',
|
<Button
|
||||||
gap: '8px'
|
type="text"
|
||||||
}}>
|
size="small"
|
||||||
<span>{order.sellOrderId.slice(0, 8)}...{order.sellOrderId.slice(-6)}</span>
|
icon={<CopyOutlined />}
|
||||||
{!isAutoGeneratedOrderId(order.sellOrderId) && (
|
onClick={() => handleCopyOrderId(order.buyOrderId)}
|
||||||
<Button
|
style={{ padding: 0, height: 'auto', fontSize: '12px' }}
|
||||||
type="text"
|
title={t('common.copy') || '复制'}
|
||||||
size="small"
|
/>
|
||||||
icon={<CopyOutlined />}
|
)}
|
||||||
onClick={() => handleCopyOrderId(order.sellOrderId)}
|
</div>
|
||||||
style={{ padding: 0, height: 'auto', fontSize: '12px' }}
|
|
||||||
title={t('common.copy') || '复制'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.buyOrderId') || '买入订单ID'}</div>
|
<div>
|
||||||
<div style={{
|
<div style={{ fontSize: '11px', color: '#999', marginBottom: '2px' }}>{t('copyTradingOrders.sell') || '卖出'}:</div>
|
||||||
fontSize: '13px',
|
<div style={{ fontSize: '13px', fontWeight: '500', fontFamily: 'monospace', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
fontWeight: '500',
|
<span>{order.sellOrderId.slice(0, 8)}...{order.sellOrderId.slice(-6)}</span>
|
||||||
fontFamily: 'monospace',
|
{!isAutoGeneratedOrderId(order.sellOrderId) && (
|
||||||
display: 'flex',
|
<Button
|
||||||
alignItems: 'center',
|
type="text"
|
||||||
gap: '8px'
|
size="small"
|
||||||
}}>
|
icon={<CopyOutlined />}
|
||||||
<span>{order.buyOrderId.slice(0, 8)}...{order.buyOrderId.slice(-6)}</span>
|
onClick={() => handleCopyOrderId(order.sellOrderId)}
|
||||||
{!isAutoGeneratedOrderId(order.buyOrderId) && (
|
style={{ padding: 0, height: 'auto', fontSize: '12px' }}
|
||||||
<Button
|
title={t('common.copy') || '复制'}
|
||||||
type="text"
|
/>
|
||||||
size="small"
|
)}
|
||||||
icon={<CopyOutlined />}
|
</div>
|
||||||
onClick={() => handleCopyOrderId(order.buyOrderId)}
|
|
||||||
style={{ padding: 0, height: 'auto', fontSize: '12px' }}
|
|
||||||
title={t('common.copy') || '复制'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Divider style={{ margin: '12px 0' }} />
|
<Divider style={{ margin: '12px 0' }} />
|
||||||
|
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
|||||||
{marketDisplayName}
|
{marketDisplayName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Tag color="success">{t('copyTradingOrders.allFullyMatched') || '全部成交'}</Tag>
|
<Tag color="success">{t('copyTradingOrders.allFullySold') || '全部卖出'}</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap', fontSize: isMobile ? '12px' : '13px', color: '#666' }}>
|
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap', fontSize: isMobile ? '12px' : '13px', color: '#666' }}>
|
||||||
<span>{t('copyTradingOrders.orderCount') || '订单数'}: {group.stats.count}</span>
|
<span>{t('copyTradingOrders.orderCount') || '订单数'}: {group.stats.count}</span>
|
||||||
@@ -648,8 +648,8 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
|||||||
onChange={(value) => setFilters({ ...filters, status: value || undefined })}
|
onChange={(value) => setFilters({ ...filters, status: value || undefined })}
|
||||||
>
|
>
|
||||||
<Option value="filled">{t('copyTradingOrders.statusFilled') || '未成交'}</Option>
|
<Option value="filled">{t('copyTradingOrders.statusFilled') || '未成交'}</Option>
|
||||||
<Option value="partially_matched">{t('copyTradingOrders.statusPartiallyMatched') || '部分成交'}</Option>
|
<Option value="partially_matched">{t('copyTradingOrders.partiallySold') || '部分卖出'}</Option>
|
||||||
<Option value="fully_matched">{t('copyTradingOrders.statusFullyMatched') || '完全成交'}</Option>
|
<Option value="fully_matched">{t('copyTradingOrders.allFullySold') || '全部卖出'}</Option>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Space>
|
<Space>
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ const PositionList: React.FC = () => {
|
|||||||
const [redeemableSummary, setRedeemableSummary] = useState<RedeemablePositionsSummary | null>(null)
|
const [redeemableSummary, setRedeemableSummary] = useState<RedeemablePositionsSummary | null>(null)
|
||||||
const [loadingRedeemableSummary, setLoadingRedeemableSummary] = useState(false)
|
const [loadingRedeemableSummary, setLoadingRedeemableSummary] = useState(false)
|
||||||
const [redeeming, setRedeeming] = useState(false)
|
const [redeeming, setRedeeming] = useState(false)
|
||||||
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(20)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAccounts()
|
fetchAccounts()
|
||||||
@@ -66,6 +68,11 @@ const PositionList: React.FC = () => {
|
|||||||
fetchRedeemableSummary()
|
fetchRedeemableSummary()
|
||||||
}
|
}
|
||||||
}, [currentPositions, selectedAccountId])
|
}, [currentPositions, selectedAccountId])
|
||||||
|
|
||||||
|
// 当筛选条件或搜索关键词变化时,重置分页到第一页
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1)
|
||||||
|
}, [positionFilter, selectedAccountId, searchKeyword])
|
||||||
|
|
||||||
// 获取可赎回仓位统计
|
// 获取可赎回仓位统计
|
||||||
const fetchRedeemableSummary = async () => {
|
const fetchRedeemableSummary = async () => {
|
||||||
@@ -265,12 +272,12 @@ const PositionList: React.FC = () => {
|
|||||||
// 本地搜索和筛选过滤
|
// 本地搜索和筛选过滤
|
||||||
const filteredPositions = useMemo(() => {
|
const filteredPositions = useMemo(() => {
|
||||||
let filtered = basePositions
|
let filtered = basePositions
|
||||||
|
|
||||||
// 1. 先按账户筛选
|
// 1. 先按账户筛选
|
||||||
if (selectedAccountId !== undefined) {
|
if (selectedAccountId !== undefined) {
|
||||||
filtered = filtered.filter(p => p.accountId === selectedAccountId)
|
filtered = filtered.filter(p => p.accountId === selectedAccountId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 最后按关键词搜索
|
// 2. 最后按关键词搜索
|
||||||
if (searchKeyword.trim()) {
|
if (searchKeyword.trim()) {
|
||||||
const keyword = searchKeyword.trim().toLowerCase()
|
const keyword = searchKeyword.trim().toLowerCase()
|
||||||
@@ -302,9 +309,16 @@ const PositionList: React.FC = () => {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
}, [basePositions, searchKeyword, selectedAccountId])
|
}, [basePositions, searchKeyword, selectedAccountId])
|
||||||
|
|
||||||
|
// 分页后的数据
|
||||||
|
const paginatedPositions = useMemo(() => {
|
||||||
|
const startIndex = (currentPage - 1) * pageSize
|
||||||
|
const endIndex = startIndex + pageSize
|
||||||
|
return filteredPositions.slice(startIndex, endIndex)
|
||||||
|
}, [filteredPositions, currentPage, pageSize])
|
||||||
|
|
||||||
const getSideColor = (side: string) => {
|
const getSideColor = (side: string) => {
|
||||||
return side === 'YES' ? 'green' : 'red'
|
return side === 'YES' ? 'green' : 'red'
|
||||||
@@ -349,14 +363,32 @@ const PositionList: React.FC = () => {
|
|||||||
if (!isNaN(initialValue)) {
|
if (!isNaN(initialValue)) {
|
||||||
totalInitialValue += initialValue
|
totalInitialValue += initialValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 当前仓位:统计持仓价值
|
||||||
|
// 历史仓位:currentValue 应该为 0(已平仓)
|
||||||
if (!isNaN(currentValue)) {
|
if (!isNaN(currentValue)) {
|
||||||
totalCurrentValue += currentValue
|
totalCurrentValue += currentValue
|
||||||
}
|
}
|
||||||
if (!isNaN(pnl)) {
|
|
||||||
totalPnl += pnl
|
// 对于当前仓位:
|
||||||
}
|
// - pnl:未实现盈亏(浮动盈亏)
|
||||||
if (!isNaN(realizedPnl)) {
|
// - realizedPnl:已实现盈亏(部分平仓时产生)
|
||||||
totalRealizedPnl += realizedPnl
|
// 对于历史仓位:
|
||||||
|
// - pnl:总已实现盈亏(包含部分平仓 + 完全平仓)
|
||||||
|
// - realizedPnl:部分平仓的已实现盈亏(可能与 pnl 重复)
|
||||||
|
if (pos.isCurrent) {
|
||||||
|
// 当前仓位:未实现盈亏 + 已实现盈亏
|
||||||
|
if (!isNaN(pnl)) {
|
||||||
|
totalPnl += pnl
|
||||||
|
}
|
||||||
|
if (!isNaN(realizedPnl)) {
|
||||||
|
totalRealizedPnl += realizedPnl
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 历史仓位:pnl 是总已实现盈亏,realizedPnl 可能重复,所以只统计 pnl
|
||||||
|
if (!isNaN(pnl)) {
|
||||||
|
totalRealizedPnl += pnl
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -516,10 +548,10 @@ const PositionList: React.FC = () => {
|
|||||||
|
|
||||||
// 渲染卡片视图
|
// 渲染卡片视图
|
||||||
const renderCardView = () => {
|
const renderCardView = () => {
|
||||||
if (filteredPositions.length === 0) {
|
if (paginatedPositions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Empty
|
<Empty
|
||||||
description="暂无仓位数据"
|
description="暂无仓位数据"
|
||||||
style={{ padding: '60px 0' }}
|
style={{ padding: '60px 0' }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -527,7 +559,7 @@ const PositionList: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
{filteredPositions.map((position, index) => {
|
{paginatedPositions.map((position, index) => {
|
||||||
const pnlNum = parseFloat(position.pnl || '0')
|
const pnlNum = parseFloat(position.pnl || '0')
|
||||||
const isProfit = pnlNum >= 0
|
const isProfit = pnlNum >= 0
|
||||||
// 只有当前仓位才根据盈亏显示边框颜色
|
// 只有当前仓位才根据盈亏显示边框颜色
|
||||||
@@ -1235,8 +1267,8 @@ const PositionList: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 合计信息:开仓价值、当前价值、盈亏、已实现盈亏(基于当前筛选后的仓位) */}
|
{/* 合计信息:开仓价值、当前价值、盈亏、已实现盈亏(仅当前仓位显示) */}
|
||||||
{filteredPositions.length > 0 && (
|
{filteredPositions.length > 0 && positionFilter === 'current' && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: '12px',
|
marginTop: '12px',
|
||||||
@@ -1259,13 +1291,11 @@ const PositionList: React.FC = () => {
|
|||||||
<span>
|
<span>
|
||||||
当前价值合计:{' '}
|
当前价值合计:{' '}
|
||||||
<span style={{ fontWeight: 600 }}>
|
<span style={{ fontWeight: 600 }}>
|
||||||
{positionFilter === 'current'
|
{formatUSDC(positionTotals.totalCurrentValue.toString())} USDC
|
||||||
? `${formatUSDC(positionTotals.totalCurrentValue.toString())} USDC`
|
|
||||||
: '-'}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
盈亏合计:{' '}
|
浮动盈亏合计:{' '}
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
@@ -1295,32 +1325,87 @@ const PositionList: React.FC = () => {
|
|||||||
{(isMobile || viewMode === 'card') ? (
|
{(isMobile || viewMode === 'card') ? (
|
||||||
<Card loading={loading}>
|
<Card loading={loading}>
|
||||||
{renderCardView()}
|
{renderCardView()}
|
||||||
|
{/* 移动端分页 */}
|
||||||
{filteredPositions.length > 0 && (
|
{filteredPositions.length > 0 && (
|
||||||
<div style={{
|
<>
|
||||||
marginTop: '24px',
|
<div style={{
|
||||||
textAlign: 'center',
|
marginTop: '16px',
|
||||||
color: '#999',
|
display: 'flex',
|
||||||
fontSize: '14px'
|
justifyContent: 'space-between',
|
||||||
}}>
|
alignItems: 'center',
|
||||||
共 {filteredPositions.length} 个仓位{searchKeyword ? `(已过滤)` : ''}
|
flexWrap: 'wrap',
|
||||||
</div>
|
gap: '8px'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||||
|
共 {filteredPositions.length} 个仓位{searchKeyword ? `(已过滤)` : ''}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
onClick={() => setCurrentPage(currentPage - 1)}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<span style={{ lineHeight: '32px', fontSize: '14px' }}>
|
||||||
|
{currentPage} / {Math.ceil(filteredPositions.length / pageSize)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
disabled={currentPage >= Math.ceil(filteredPositions.length / pageSize)}
|
||||||
|
onClick={() => setCurrentPage(currentPage + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 每页条数选择器 */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: '8px',
|
||||||
|
textAlign: 'right',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}>
|
||||||
|
<Select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(value) => {
|
||||||
|
setPageSize(value)
|
||||||
|
setCurrentPage(1)
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
style={{ width: '100px' }}
|
||||||
|
>
|
||||||
|
<Select.Option value={10}>10 条/页</Select.Option>
|
||||||
|
<Select.Option value={20}>20 条/页</Select.Option>
|
||||||
|
<Select.Option value={50}>50 条/页</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<Table
|
<Table
|
||||||
dataSource={filteredPositions}
|
dataSource={filteredPositions}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey={(record, index) => `${record.accountId}-${record.marketId}-${index}`}
|
rowKey={(record, index) => `${record.accountId}-${record.marketId}-${index}`}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={{
|
pagination={{
|
||||||
pageSize: 20,
|
current: currentPage,
|
||||||
showSizeChanger: !isMobile,
|
pageSize: pageSize,
|
||||||
showTotal: (total) => `共 ${total} 个仓位${searchKeyword ? `(已过滤)` : ''}`
|
total: filteredPositions.length,
|
||||||
}}
|
showSizeChanger: true,
|
||||||
scroll={isMobile ? { x: 1500 } : undefined}
|
pageSizeOptions: ['10', '20', '50'],
|
||||||
/>
|
showTotal: (total) => `共 ${total} 个仓位${searchKeyword ? `(已过滤)` : ''}`,
|
||||||
</Card>
|
onChange: (page, size) => {
|
||||||
|
setCurrentPage(page)
|
||||||
|
if (size !== pageSize) {
|
||||||
|
setPageSize(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
scroll={isMobile ? { x: 1500 } : undefined}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 出售模态框 */}
|
{/* 出售模态框 */}
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取订单详情脚本
|
||||||
|
*
|
||||||
|
* 使用方法:
|
||||||
|
* node scripts/get-order-detail.js <private_key> <order_id>
|
||||||
|
*
|
||||||
|
* 参数说明:
|
||||||
|
* private_key: 钱包私钥(用于签名)
|
||||||
|
* order_id: 订单 ID
|
||||||
|
*
|
||||||
|
* 示例:
|
||||||
|
* node scripts/get-order-detail.js "0x..." "0x123..."
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Wallet } from '@ethersproject/wallet';
|
||||||
|
import { ClobClient } from '@polymarket/clob-client';
|
||||||
|
|
||||||
|
// Polymarket CLOB 主机地址
|
||||||
|
const HOST = 'https://clob.polymarket.com';
|
||||||
|
const CHAIN_ID = 137; // Polygon 主网
|
||||||
|
|
||||||
|
async function getOrderDetail(privateKey, orderId) {
|
||||||
|
try {
|
||||||
|
console.log('正在初始化钱包...');
|
||||||
|
const wallet = new Wallet(privateKey);
|
||||||
|
console.log(`钱包地址: ${wallet.address}`);
|
||||||
|
|
||||||
|
console.log('\n正在初始化 ClobClient...');
|
||||||
|
const clobClient = new ClobClient(
|
||||||
|
HOST,
|
||||||
|
CHAIN_ID,
|
||||||
|
wallet
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('\n正在获取或创建 API Key...');
|
||||||
|
try {
|
||||||
|
// 尝试 derive API key(如果已存在)
|
||||||
|
const creds = await clobClient.deriveApiKey();
|
||||||
|
console.log(`✅ API Key 已获取`);
|
||||||
|
console.log(` API Key: ${creds.key.substring(0, 10)}...`);
|
||||||
|
console.log(` Passphrase: ${creds.passphrase.substring(0, 10)}...`);
|
||||||
|
|
||||||
|
// 使用 creds 初始化一个新的 ClobClient 实例用于 L2 认证
|
||||||
|
const authenticatedClient = new ClobClient(
|
||||||
|
HOST,
|
||||||
|
CHAIN_ID,
|
||||||
|
wallet,
|
||||||
|
creds
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`\n正在获取订单详情...`);
|
||||||
|
console.log(` 订单 ID: ${orderId}`);
|
||||||
|
|
||||||
|
const orderDetail = await authenticatedClient.getOrder(orderId);
|
||||||
|
|
||||||
|
console.log('\n================ 订单详情 ================');
|
||||||
|
console.log(`订单 ID: ${orderDetail.id}`);
|
||||||
|
console.log(`状态: ${orderDetail.status}`);
|
||||||
|
console.log(`所有者: ${orderDetail.owner}`);
|
||||||
|
console.log(`Maker 地址: ${orderDetail.maker_address}`);
|
||||||
|
console.log(`市场 ID: ${orderDetail.market}`);
|
||||||
|
console.log(`资产 ID: ${orderDetail.asset_id}`);
|
||||||
|
console.log(`方向: ${orderDetail.side}`);
|
||||||
|
console.log(`原始数量: ${orderDetail.original_size}`);
|
||||||
|
console.log(`已匹配数量: ${orderDetail.size_matched}`);
|
||||||
|
console.log(`价格: ${orderDetail.price}`);
|
||||||
|
console.log(`结果: ${orderDetail.outcome}`);
|
||||||
|
console.log(`创建时间: ${new Date(orderDetail.created_at * 1000).toISOString()}`);
|
||||||
|
console.log(`过期时间: ${orderDetail.expiration}`);
|
||||||
|
console.log(`订单类型: ${orderDetail.order_type}`);
|
||||||
|
|
||||||
|
if (orderDetail.associate_trades && orderDetail.associate_trades.length > 0) {
|
||||||
|
console.log(`关联交易数量: ${orderDetail.associate_trades.length}`);
|
||||||
|
console.log(`关联交易 IDs: ${orderDetail.associate_trades.join(', ')}`);
|
||||||
|
}
|
||||||
|
console.log('=========================================\n');
|
||||||
|
|
||||||
|
} catch (apiKeyError) {
|
||||||
|
if (apiKeyError.message && apiKeyError.message.includes('API key does not exist')) {
|
||||||
|
console.log('⚠️ API Key 不存在,正在创建新的 API Key...');
|
||||||
|
|
||||||
|
// 创建新的 API key
|
||||||
|
const creds = await clobClient.createApiKey();
|
||||||
|
console.log(`✅ API Key 已创建`);
|
||||||
|
console.log(` API Key: ${creds.key.substring(0, 10)}...`);
|
||||||
|
console.log(` Passphrase: ${creds.passphrase.substring(0, 10)}...`);
|
||||||
|
|
||||||
|
// 使用 creds 初始化一个新的 ClobClient 实例用于 L2 认证
|
||||||
|
const authenticatedClient = new ClobClient(
|
||||||
|
HOST,
|
||||||
|
CHAIN_ID,
|
||||||
|
wallet,
|
||||||
|
creds
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`\n正在获取订单详情...`);
|
||||||
|
console.log(` 订单 ID: ${orderId}`);
|
||||||
|
|
||||||
|
const orderDetail = await authenticatedClient.getOrder(orderId);
|
||||||
|
|
||||||
|
console.log('\n================ 订单详情 ================');
|
||||||
|
console.log(`订单 ID: ${orderDetail.id}`);
|
||||||
|
console.log(`状态: ${orderDetail.status}`);
|
||||||
|
console.log(`所有者: ${orderDetail.owner}`);
|
||||||
|
console.log(`Maker 地址: ${orderDetail.maker_address}`);
|
||||||
|
console.log(`市场 ID: ${orderDetail.market}`);
|
||||||
|
console.log(`资产 ID: ${orderDetail.asset_id}`);
|
||||||
|
console.log(`方向: ${orderDetail.side}`);
|
||||||
|
console.log(`原始数量: ${orderDetail.original_size}`);
|
||||||
|
console.log(`已匹配数量: ${orderDetail.size_matched}`);
|
||||||
|
console.log(`价格: ${orderDetail.price}`);
|
||||||
|
console.log(`结果: ${orderDetail.outcome}`);
|
||||||
|
console.log(`创建时间: ${new Date(orderDetail.created_at * 1000).toISOString()}`);
|
||||||
|
console.log(`过期时间: ${orderDetail.expiration}`);
|
||||||
|
console.log(`订单类型: ${orderDetail.order_type}`);
|
||||||
|
|
||||||
|
if (orderDetail.associate_trades && orderDetail.associate_trades.length > 0) {
|
||||||
|
console.log(`关联交易数量: ${orderDetail.associate_trades.length}`);
|
||||||
|
console.log(`关联交易 IDs: ${orderDetail.associate_trades.join(', ')}`);
|
||||||
|
}
|
||||||
|
console.log('=========================================\n');
|
||||||
|
} else {
|
||||||
|
throw apiKeyError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ 获取订单详情失败:');
|
||||||
|
console.error(error.message);
|
||||||
|
|
||||||
|
if (error.response) {
|
||||||
|
console.error(`\nHTTP 状态码: ${error.response.status}`);
|
||||||
|
console.error(`响应数据:`, error.response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查命令行参数
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (args.length < 2) {
|
||||||
|
console.error('错误: 缺少必要参数');
|
||||||
|
console.error('\n使用方法:');
|
||||||
|
console.error(' node scripts/get-order-detail.js <private_key> <order_id>');
|
||||||
|
console.error('\n参数说明:');
|
||||||
|
console.error(' private_key: 钱包私钥(用于签名)');
|
||||||
|
console.error(' order_id: 订单 ID');
|
||||||
|
console.error('\n示例:');
|
||||||
|
console.error(' node scripts/get-order-detail.js "0x123..." "0x456..."');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [privateKey, orderId] = args;
|
||||||
|
|
||||||
|
// 验证私钥格式
|
||||||
|
if (!privateKey.startsWith('0x')) {
|
||||||
|
console.error('错误: 私钥格式不正确,必须以 0x 开头');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (privateKey.length !== 66) {
|
||||||
|
console.error('错误: 私钥长度不正确,应为 66 个字符(包括 0x 前缀)');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证订单 ID
|
||||||
|
if (!orderId.startsWith('0x')) {
|
||||||
|
console.error('错误: 订单 ID 格式不正确,必须以 0x 开头');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行主函数
|
||||||
|
getOrderDetail(privateKey, orderId);
|
||||||
Generated
+1352
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "polyhermes-scripts",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Utility scripts for Polyhermes",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"get-order-detail": "node get-order-detail.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ethersproject/wallet": "^5.7.0",
|
||||||
|
"@polymarket/clob-client": "^5.2.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user