Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 662aa47de6 | |||
| c3d9d10d5d | |||
| 9926533049 | |||
| 4d72017b97 | |||
| 45734c051e | |||
| db8471bb16 | |||
| 0dc6f5894f | |||
| c9769aa17a | |||
| 07b4d654b4 | |||
| b65827038f | |||
| d768da72c6 | |||
| 7385efff1a | |||
| 3e2e97e572 | |||
| ae68a33c1e | |||
| deea59fdbf | |||
| b90f86b081 |
+4
-8
@@ -75,15 +75,11 @@ interface CopyOrderTrackingRepository : JpaRepository<CopyOrderTracking, Long> {
|
||||
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.remainingQuantity > 0")
|
||||
fun sumCurrentPositionValueByMarket(copyTradingId: Long, marketId: String): BigDecimal?
|
||||
@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?
|
||||
|
||||
/**
|
||||
* 查询指定跟单配置下,创建时间超过指定时间点的未匹配订单(FIFO顺序)
|
||||
|
||||
@@ -89,8 +89,6 @@ class MarketPollingService(
|
||||
*/
|
||||
private suspend fun checkAndUpdateMissingMarkets() {
|
||||
try {
|
||||
logger.debug("开始检查缺失的市场信息...")
|
||||
|
||||
// 1. 获取所有买入订单的市场ID(去重)
|
||||
val allOrders = copyOrderTrackingRepository.findAll()
|
||||
val marketIds = allOrders.map { it.marketId }.distinct()
|
||||
@@ -99,9 +97,6 @@ class MarketPollingService(
|
||||
logger.debug("没有找到任何订单,跳过市场信息检查")
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug("找到 ${marketIds.size} 个不同的市场ID")
|
||||
|
||||
// 2. 检查哪些市场信息在数据库中缺失
|
||||
val existingMarkets = marketService.marketRepository.findByMarketIdIn(marketIds)
|
||||
val existingMarketIds = existingMarkets.map { it.marketId }.toSet()
|
||||
@@ -113,7 +108,6 @@ class MarketPollingService(
|
||||
}
|
||||
|
||||
if (validMissingMarketIds.isEmpty()) {
|
||||
logger.debug("所有市场信息都已存在,无需更新")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+49
-31
@@ -45,7 +45,8 @@ class CopyTradingFilterService(
|
||||
copyOrderAmount: BigDecimal? = null, // 跟单金额(USDC),用于仓位检查
|
||||
marketId: String? = null, // 市场ID,用于仓位检查(按市场过滤仓位)
|
||||
marketTitle: String? = null, // 市场标题,用于关键字过滤
|
||||
marketEndDate: Long? = null // 市场截止时间,用于市场截止时间检查
|
||||
marketEndDate: Long? = null, // 市场截止时间,用于市场截止时间检查
|
||||
outcomeIndex: Int? = null // 方向索引(0, 1, 2, ...),用于按市场+方向检查仓位
|
||||
): FilterResult {
|
||||
// 1. 关键字过滤检查(如果配置了关键字过滤)
|
||||
if (copyTrading.keywordFilterMode != null && copyTrading.keywordFilterMode != "DISABLED") {
|
||||
@@ -79,7 +80,7 @@ class CopyTradingFilterService(
|
||||
if (!needOrderbook) {
|
||||
// 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
|
||||
if (copyOrderAmount != null && marketId != null) {
|
||||
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId)
|
||||
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId, outcomeIndex)
|
||||
if (!positionCheck.isPassed) {
|
||||
return positionCheck
|
||||
}
|
||||
@@ -116,7 +117,7 @@ class CopyTradingFilterService(
|
||||
|
||||
// 7. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
|
||||
if (copyOrderAmount != null && marketId != null) {
|
||||
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId)
|
||||
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId, outcomeIndex)
|
||||
if (!positionCheck.isPassed) {
|
||||
return positionCheck
|
||||
}
|
||||
@@ -291,87 +292,104 @@ class CopyTradingFilterService(
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查仓位限制(按市场检查)
|
||||
* 检查仓位限制(按市场+方向检查)
|
||||
* @param copyTrading 跟单配置
|
||||
* @param copyOrderAmount 跟单金额(USDC)
|
||||
* @param marketId 市场ID,用于过滤该市场的仓位
|
||||
* @param outcomeIndex 方向索引(0, 1, 2, ...),用于按市场+方向检查仓位
|
||||
* @return 过滤结果
|
||||
*/
|
||||
private suspend fun checkPositionLimits(
|
||||
copyTrading: CopyTrading,
|
||||
copyOrderAmount: BigDecimal,
|
||||
marketId: String
|
||||
marketId: String,
|
||||
outcomeIndex: Int?
|
||||
): FilterResult {
|
||||
// 如果未配置仓位限制,直接通过
|
||||
if (copyTrading.maxPositionValue == null && copyTrading.maxPositionCount == null) {
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// 获取账户的所有仓位信息
|
||||
val positionsResult = accountService.getAllPositions()
|
||||
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("获取仓位信息失败,无法进行仓位检查")
|
||||
}
|
||||
|
||||
|
||||
val positions = positionsResult.getOrNull() ?: return FilterResult.maxPositionValueFailed("仓位信息为空")
|
||||
|
||||
|
||||
// 过滤出当前账户且该市场的仓位
|
||||
val marketPositions = positions.currentPositions.filter {
|
||||
val marketPositions = positions.currentPositions.filter {
|
||||
it.accountId == copyTrading.accountId && it.marketId == marketId
|
||||
}
|
||||
|
||||
|
||||
// 检查最大仓位金额(如果配置了)
|
||||
if (copyTrading.maxPositionValue != null) {
|
||||
// 比较数据库成本价(本地订单记录)和外部持仓市值(可能来自其他终端的操作),取最大值
|
||||
val dbValue = copyOrderTrackingRepository.sumCurrentPositionValueByMarket(copyTrading.id!!, marketId) ?: BigDecimal.ZERO
|
||||
val extValue = marketPositions.sumOf { it.currentValue.toSafeBigDecimal() }
|
||||
if (copyTrading.maxPositionValue != null && outcomeIndex != null) {
|
||||
// 按市场+方向(outcomeIndex)分别计算数据库成本价
|
||||
val dbValue = copyOrderTrackingRepository.sumCurrentPositionValueByMarketAndOutcomeIndex(
|
||||
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 totalValueAfterOrder = currentPositionValue.add(copyOrderAmount)
|
||||
|
||||
|
||||
if (totalValueAfterOrder.gt(copyTrading.maxPositionValue)) {
|
||||
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) {
|
||||
// 使用数据库中的订单记录计算活跃仓位数量(解决延迟问题)
|
||||
val dbCount = copyOrderTrackingRepository.countActivePositions(copyTrading.id!!)
|
||||
|
||||
|
||||
// 计算外部持仓中的唯一市场数量(防止遗漏非本项目创建的仓位)
|
||||
val extCount = positions.currentPositions
|
||||
.filter { it.accountId == copyTrading.accountId }
|
||||
.map { it.marketId }
|
||||
.distinct()
|
||||
.size
|
||||
|
||||
|
||||
val currentPositionCount = maxOf(dbCount, extCount)
|
||||
|
||||
// 检查:如果当前没有该市场的活跃仓位,且总仓位数量已达到限制,则不允许开新仓
|
||||
// 判断当前市场是否已有活跃仓位(数据库或外部持仓)
|
||||
val hasDbPosition = copyOrderTrackingRepository.existsByCopyTradingIdAndMarketIdAndRemainingQuantityGreaterThan(
|
||||
copyTrading.id, marketId, BigDecimal.ZERO
|
||||
)
|
||||
|
||||
// 检查:如果当前没有该市场该方向的活跃仓位,且总仓位数量已达到限制,则不允许开新仓
|
||||
// 判断当前市场该方向是否已有活跃仓位(数据库)
|
||||
val hasDbPosition = if (outcomeIndex != null) {
|
||||
copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
|
||||
copyTrading.id, marketId, outcomeIndex
|
||||
).isNotEmpty()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
val hasExtPosition = marketPositions.isNotEmpty()
|
||||
val hasCurrentMarketPosition = hasDbPosition || hasExtPosition
|
||||
|
||||
|
||||
if (!hasCurrentMarketPosition && currentPositionCount >= copyTrading.maxPositionCount) {
|
||||
return FilterResult.maxPositionCountFailed(
|
||||
"超过最大仓位数量限制: 当前活跃仓位总数(取最大值)=${currentPositionCount} (DB=${dbCount}, Ext=${extCount}) >= 最大限制=${copyTrading.maxPositionCount}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return FilterResult.passed()
|
||||
} 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}")
|
||||
}
|
||||
|
||||
+19
-3
@@ -14,6 +14,8 @@ import com.wrbug.polymarketbot.util.IllegalBigDecimal
|
||||
import com.wrbug.polymarketbot.util.JsonUtils
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.ApplicationContext
|
||||
import org.springframework.context.ApplicationContextAware
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
@@ -30,10 +32,24 @@ class CopyTradingService(
|
||||
private val monitorService: CopyTradingMonitorService,
|
||||
private val jsonUtils: JsonUtils,
|
||||
private val gson: Gson
|
||||
) {
|
||||
|
||||
) : ApplicationContextAware {
|
||||
|
||||
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
|
||||
fun updateCopyTradingStatus(request: CopyTradingUpdateStatusRequest): Result<CopyTradingDto> {
|
||||
return updateCopyTrading(
|
||||
return getSelf().updateCopyTrading(
|
||||
CopyTradingUpdateRequest(
|
||||
copyTradingId = request.copyTradingId,
|
||||
enabled = request.enabled
|
||||
|
||||
+31
-13
@@ -23,6 +23,8 @@ import com.wrbug.polymarketbot.service.common.MarketService
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import org.springframework.context.ApplicationContext
|
||||
import org.springframework.context.ApplicationContextAware
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
@@ -51,12 +53,26 @@ open class CopyOrderTrackingService(
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val marketService: MarketService, // 市场信息服务
|
||||
private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖
|
||||
) {
|
||||
) : ApplicationContextAware {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CopyOrderTrackingService::class.java)
|
||||
|
||||
// 协程作用域(用于异步发送通知)
|
||||
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锁定)
|
||||
private val tradeMutexMap = ConcurrentHashMap<String, Mutex>()
|
||||
@@ -138,10 +154,11 @@ open class CopyOrderTrackingService(
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
|
||||
// 2. 处理交易逻辑
|
||||
// 2. 处理交易逻辑(通过代理对象调用,确保 @Transactional 生效)
|
||||
val self = getSelf()
|
||||
val result = when (trade.side.uppercase()) {
|
||||
"BUY" -> processBuyTrade(leaderId, trade, source)
|
||||
"SELL" -> processSellTrade(leaderId, trade)
|
||||
"BUY" -> self.processBuyTrade(leaderId, trade, source)
|
||||
"SELL" -> self.processSellTrade(leaderId, trade)
|
||||
else -> {
|
||||
logger.warn("未知的交易方向: ${trade.side}")
|
||||
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
|
||||
@@ -285,7 +302,7 @@ open class CopyOrderTrackingService(
|
||||
|
||||
// 过滤条件检查(在计算订单参数之前)
|
||||
// 传入 Leader 交易价格,用于价格区间检查
|
||||
// 传入跟单金额和市场ID,用于仓位检查(按市场检查仓位)
|
||||
// 传入跟单金额和市场ID,用于仓位检查(按市场+方向检查仓位)
|
||||
// 传入市场标题,用于关键字过滤
|
||||
// 传入市场截止时间,用于市场截止时间检查
|
||||
// 订单簿只请求一次,返回给后续逻辑使用
|
||||
@@ -296,7 +313,8 @@ open class CopyOrderTrackingService(
|
||||
copyOrderAmount = copyOrderAmount,
|
||||
marketId = trade.market,
|
||||
marketTitle = marketTitle,
|
||||
marketEndDate = marketEndDate
|
||||
marketEndDate = marketEndDate,
|
||||
outcomeIndex = trade.outcomeIndex
|
||||
)
|
||||
val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
|
||||
if (!filterResult.isPassed) {
|
||||
@@ -686,8 +704,8 @@ open class CopyOrderTrackingService(
|
||||
private fun calculateBuyQuantity(trade: TradeResponse, copyTrading: CopyTrading): BigDecimal {
|
||||
return when (copyTrading.copyMode) {
|
||||
"RATIO" -> {
|
||||
// 比例模式:Leader 数量 × (比例 / 100)
|
||||
trade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
||||
// 比例模式:Leader 数量 × 比例倍数(copyRatio 已经是倍数值,如 1.3 表示 130%)
|
||||
trade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||
}
|
||||
|
||||
"FIXED" -> {
|
||||
@@ -719,7 +737,7 @@ open class CopyOrderTrackingService(
|
||||
val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null)
|
||||
?: run {
|
||||
logger.warn("Leader 不存在,使用默认比例: leaderId=${copyTrading.leaderId}")
|
||||
return leaderSellQuantity.multi(copyTrading.copyRatio.div(100))
|
||||
return leaderSellQuantity.multi(copyTrading.copyRatio)
|
||||
}
|
||||
|
||||
// 创建不需要认证的 CLOB API 客户端(用于查询公开的交易数据)
|
||||
@@ -790,7 +808,7 @@ open class CopyOrderTrackingService(
|
||||
// 如果无法计算总比例(查询失败),使用默认比例
|
||||
if (totalLeaderQuantity.lte(BigDecimal.ZERO)) {
|
||||
logger.warn("无法计算总比例(Leader 买入数量为 0),使用默认比例: copyTradingId=${copyTrading.id}")
|
||||
return leaderSellQuantity.multi(copyTrading.copyRatio.div(100))
|
||||
return leaderSellQuantity.multi(copyTrading.copyRatio)
|
||||
}
|
||||
|
||||
// 计算实际比例:跟单买入数量 / Leader 买入数量
|
||||
@@ -866,13 +884,13 @@ open class CopyOrderTrackingService(
|
||||
}
|
||||
|
||||
"RATIO" -> {
|
||||
// 比例模式:直接使用配置的 copyRatio (需要除以100)
|
||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
||||
// 比例模式:直接使用配置的 copyRatio(已经是倍数值,如 1.3 表示 130%)
|
||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||
}
|
||||
|
||||
else -> {
|
||||
logger.warn("不支持的 copyMode: ${copyTrading.copyMode},使用默认比例模式")
|
||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -799,7 +799,15 @@ class CopyTradingStatisticsService(
|
||||
stats = stats,
|
||||
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. 分页
|
||||
val page = (request.page ?: 1)
|
||||
|
||||
+123
-15
@@ -12,6 +12,8 @@ import com.wrbug.polymarketbot.util.multi
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||
import org.springframework.context.ApplicationContext
|
||||
import org.springframework.context.ApplicationContextAware
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.context.i18n.LocaleContextHolder
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
@@ -37,11 +39,29 @@ class OrderStatusUpdateService(
|
||||
private val trackingService: CopyOrderTrackingService,
|
||||
private val marketService: MarketService, // 市场信息服务
|
||||
private val telegramNotificationService: TelegramNotificationService?
|
||||
) {
|
||||
) : ApplicationContextAware {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(OrderStatusUpdateService::class.java)
|
||||
|
||||
private val updateScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
// 跟踪上一次更新任务的 Job,防止并发执行
|
||||
@Volatile
|
||||
private var updateJob: Job? = null
|
||||
|
||||
private var applicationContext: ApplicationContext? = null
|
||||
|
||||
override fun setApplicationContext(applicationContext: ApplicationContext) {
|
||||
this.applicationContext = applicationContext
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取代理对象,用于解决 @Transactional 自调用问题
|
||||
*/
|
||||
private fun getSelf(): OrderStatusUpdateService {
|
||||
return applicationContext?.getBean(OrderStatusUpdateService::class.java)
|
||||
?: throw IllegalStateException("ApplicationContext not initialized")
|
||||
}
|
||||
|
||||
// 缓存首次检测到订单详情为 null 的时间戳(订单ID -> 首次检测时间)
|
||||
private val orderNullDetectionTime = ConcurrentHashMap<String, Long>()
|
||||
@@ -60,24 +80,38 @@ class OrderStatusUpdateService(
|
||||
/**
|
||||
* 定时更新订单状态
|
||||
* 每5秒执行一次
|
||||
* 如果上一次任务还在执行,则跳过本次执行,避免并发问题
|
||||
*/
|
||||
@Scheduled(fixedDelay = 5000)
|
||||
fun updateOrderStatus() {
|
||||
updateScope.launch {
|
||||
// 检查上一次任务是否还在执行
|
||||
val previousJob = updateJob
|
||||
if (previousJob != null && previousJob.isActive) {
|
||||
logger.debug("上一次订单状态更新任务还在执行,跳过本次执行")
|
||||
return
|
||||
}
|
||||
|
||||
// 启动新任务并记录 Job
|
||||
updateJob = updateScope.launch {
|
||||
try {
|
||||
// 1. 清理已删除账户的订单
|
||||
cleanupDeletedAccountOrders()
|
||||
val self = getSelf()
|
||||
|
||||
// 1. 清理已删除账户的订单(通过代理对象调用,确保事务生效)
|
||||
self.cleanupDeletedAccountOrders()
|
||||
|
||||
// 2. 检查30秒前创建的订单,如果未成交则删除
|
||||
checkAndDeleteUnfilledOrders()
|
||||
// 2. 检查30秒前创建的订单,如果未成交则删除(通过代理对象调用,确保事务生效)
|
||||
self.checkAndDeleteUnfilledOrders()
|
||||
|
||||
// 3. 更新卖出订单的实际成交价并发送通知(priceUpdated 共用字段)
|
||||
updatePendingSellOrderPrices()
|
||||
// 3. 更新卖出订单的实际成交价并发送通知(priceUpdated 共用字段)(通过代理对象调用,确保事务生效)
|
||||
self.updatePendingSellOrderPrices()
|
||||
|
||||
// 4. 更新买入订单的实际数据并发送通知
|
||||
updatePendingBuyOrders()
|
||||
// 4. 更新买入订单的实际数据并发送通知(通过代理对象调用,确保事务生效)
|
||||
self.updatePendingBuyOrders()
|
||||
} catch (e: Exception) {
|
||||
logger.error("订单状态更新异常: ${e.message}", e)
|
||||
} finally {
|
||||
// 任务完成后清除 Job 引用
|
||||
updateJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,7 +140,7 @@ class OrderStatusUpdateService(
|
||||
* 清理已删除账户的订单
|
||||
*/
|
||||
@Transactional
|
||||
private suspend fun cleanupDeletedAccountOrders() {
|
||||
suspend fun cleanupDeletedAccountOrders() {
|
||||
try {
|
||||
// 查询所有卖出记录
|
||||
val allRecords = sellMatchRecordRepository.findAll()
|
||||
@@ -150,7 +184,7 @@ class OrderStatusUpdateService(
|
||||
* 首次检测但加入缓存中30s后还没有成交,则删除
|
||||
*/
|
||||
@Transactional
|
||||
private suspend fun checkAndDeleteUnfilledOrders() {
|
||||
suspend fun checkAndDeleteUnfilledOrders() {
|
||||
try {
|
||||
// 计算30秒前的时间戳
|
||||
val thirtySecondsAgo = System.currentTimeMillis() - 30000
|
||||
@@ -235,6 +269,43 @@ class OrderStatusUpdateService(
|
||||
orderNullDetectionTime.getOrPut(order.buyOrderId) { System.currentTimeMillis() }
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
// 检查订单是否已经通过订单详情更正过数据并发送过通知
|
||||
if (order.notificationSent) {
|
||||
// 检查是否超过重试时间窗口
|
||||
if (currentTime - firstDetectionTime >= ORDER_NULL_RETRY_WINDOW_MS) {
|
||||
// 超过60秒,将订单状态改为 fully_matched,不再查询
|
||||
logger.info("订单已发送通知且详情为 null 超过60秒,标记为 fully_matched: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
|
||||
try {
|
||||
val updatedOrder = CopyOrderTracking(
|
||||
id = order.id,
|
||||
copyTradingId = order.copyTradingId,
|
||||
accountId = order.accountId,
|
||||
leaderId = order.leaderId,
|
||||
marketId = order.marketId,
|
||||
side = order.side,
|
||||
outcomeIndex = order.outcomeIndex,
|
||||
buyOrderId = order.buyOrderId,
|
||||
leaderBuyTradeId = order.leaderBuyTradeId,
|
||||
quantity = order.quantity,
|
||||
price = order.price,
|
||||
matchedQuantity = order.matchedQuantity,
|
||||
remainingQuantity = order.remainingQuantity,
|
||||
status = "fully_matched", // 标记为完全匹配
|
||||
notificationSent = order.notificationSent,
|
||||
source = order.source,
|
||||
createdAt = order.createdAt,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
copyOrderTrackingRepository.save(updatedOrder)
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新订单状态失败: orderId=${order.buyOrderId}, error=${e.message}", e)
|
||||
}
|
||||
}
|
||||
// 清除缓存,下次重新检测
|
||||
orderNullDetectionTime.remove(order.buyOrderId)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查订单是否已部分卖出,如果已部分卖出则保留订单用于统计
|
||||
val hasMatchedDetails =
|
||||
sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
|
||||
@@ -323,7 +394,7 @@ class OrderStatusUpdateService(
|
||||
* 注意:priceUpdated 现在同时表示价格已更新和通知已发送(共用字段)
|
||||
*/
|
||||
@Transactional
|
||||
private suspend fun updatePendingSellOrderPrices() {
|
||||
suspend fun updatePendingSellOrderPrices() {
|
||||
try {
|
||||
// 查询所有价格未更新的卖出记录(priceUpdated = false 表示未处理)
|
||||
val pendingRecords = sellMatchRecordRepository.findByPriceUpdatedFalse()
|
||||
@@ -566,7 +637,7 @@ class OrderStatusUpdateService(
|
||||
* 查询订单详情获取实际价格和数量,然后发送通知并更新数据库
|
||||
*/
|
||||
@Transactional
|
||||
private suspend fun updatePendingBuyOrders() {
|
||||
suspend fun updatePendingBuyOrders() {
|
||||
try {
|
||||
// 查询所有未发送通知的买入订单
|
||||
val pendingOrders = copyOrderTrackingRepository.findByNotificationSentFalse()
|
||||
@@ -676,6 +747,43 @@ class OrderStatusUpdateService(
|
||||
orderNullDetectionTime.getOrPut(order.buyOrderId) { System.currentTimeMillis() }
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
// 检查订单是否已经通过订单详情更正过数据并发送过通知
|
||||
if (order.notificationSent) {
|
||||
// 检查是否超过重试时间窗口
|
||||
if (currentTime - firstDetectionTime >= ORDER_NULL_RETRY_WINDOW_MS) {
|
||||
// 超过60秒,将订单状态改为 fully_matched,不再查询
|
||||
logger.info("订单已发送通知且详情为 null 超过60秒,标记为 fully_matched: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
|
||||
try {
|
||||
val updatedOrder = CopyOrderTracking(
|
||||
id = order.id,
|
||||
copyTradingId = order.copyTradingId,
|
||||
accountId = order.accountId,
|
||||
leaderId = order.leaderId,
|
||||
marketId = order.marketId,
|
||||
side = order.side,
|
||||
outcomeIndex = order.outcomeIndex,
|
||||
buyOrderId = order.buyOrderId,
|
||||
leaderBuyTradeId = order.leaderBuyTradeId,
|
||||
quantity = order.quantity,
|
||||
price = order.price,
|
||||
matchedQuantity = order.matchedQuantity,
|
||||
remainingQuantity = order.remainingQuantity,
|
||||
status = "fully_matched", // 标记为完全匹配
|
||||
notificationSent = order.notificationSent,
|
||||
source = order.source,
|
||||
createdAt = order.createdAt,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
copyOrderTrackingRepository.save(updatedOrder)
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新订单状态失败: orderId=${order.buyOrderId}, error=${e.message}", e)
|
||||
}
|
||||
}
|
||||
// 清除缓存,下次重新检测
|
||||
orderNullDetectionTime.remove(order.buyOrderId)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查订单是否已部分卖出,如果已部分卖出则保留订单用于统计
|
||||
val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
|
||||
if (hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO) {
|
||||
@@ -712,7 +820,7 @@ class OrderStatusUpdateService(
|
||||
}
|
||||
|
||||
// 超过重试窗口,删除本地订单
|
||||
logger.warn("订单详情为 null 超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=$((currentTime - firstDetectionTime) / 1000)s")
|
||||
logger.warn("订单详情为 null 超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=$((currentTime - firstDetectionTime) / 1000}s")
|
||||
try {
|
||||
copyOrderTrackingRepository.deleteById(order.id!!)
|
||||
logger.info("已删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
|
||||
|
||||
@@ -1099,6 +1099,7 @@
|
||||
"price": "Price",
|
||||
"amount": "Amount",
|
||||
"filterMarketId": "Filter Market ID",
|
||||
"filterMarketTitle": "Filter Market Title",
|
||||
"filterSide": "Filter Side",
|
||||
"filterStatus": "Filter Status",
|
||||
"filterSellOrderId": "Filter Sell Order ID",
|
||||
@@ -1109,12 +1110,15 @@
|
||||
"groupByMarket": "Group by Market",
|
||||
"expandAll": "Expand All",
|
||||
"collapseAll": "Collapse All",
|
||||
"allFullyMatched": "All Fully Matched",
|
||||
"partiallyMatched": "Partially Matched",
|
||||
"allFullySold": "All Fully Sold",
|
||||
"notSold": "Not Sold",
|
||||
"partiallySold": "Partially Sold",
|
||||
"orderCount": "Order Count",
|
||||
"totalAmount": "Total Amount",
|
||||
"totalPnl": "Total PnL",
|
||||
"statusBreakdown": "Status",
|
||||
"allFullyMatched": "All Fully Sold",
|
||||
"partiallyMatched": "Partially Sold",
|
||||
"totalPnl": "Total PnL",
|
||||
"markets": "markets",
|
||||
"fetchBuyOrdersFailed": "Failed to fetch buy orders",
|
||||
"fetchSellOrdersFailed": "Failed to fetch sell orders",
|
||||
@@ -1126,7 +1130,6 @@
|
||||
"totalMatchedOrders": "Total Matched Orders",
|
||||
"totalBuyAmount": "Total Buy Amount",
|
||||
"totalSellAmount": "Total Sell Amount",
|
||||
"totalPnl": "Total PnL",
|
||||
"totalRealizedPnl": "Total Realized PnL",
|
||||
"totalUnrealizedPnl": "Total Unrealized PnL",
|
||||
"winRate": "Win Rate",
|
||||
@@ -1195,4 +1198,4 @@
|
||||
"providerChainstack": "Chainstack",
|
||||
"providerGetBlock": "GetBlock"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1080,8 +1080,8 @@
|
||||
"sellStatus": "卖出状态",
|
||||
"status": "状态",
|
||||
"statusFilled": "未成交",
|
||||
"statusPartiallySold": "部分成交",
|
||||
"statusFullySold": "全部成交",
|
||||
"statusPartiallySold": "部分卖出",
|
||||
"statusFullySold": "全部卖出",
|
||||
"realizedPnl": "已实现盈亏",
|
||||
"createdAt": "创建时间",
|
||||
"matchedAt": "匹配时间",
|
||||
@@ -1110,8 +1110,9 @@
|
||||
"groupByMarket": "按市场分组",
|
||||
"expandAll": "展开全部",
|
||||
"collapseAll": "折叠全部",
|
||||
"allFullyMatched": "全部成交",
|
||||
"partiallyMatched": "部分成交",
|
||||
"allFullySold": "全部卖出",
|
||||
"notSold": "未卖出",
|
||||
"partiallySold": "部分卖出",
|
||||
"orderCount": "订单数",
|
||||
"totalAmount": "总金额",
|
||||
"statusBreakdown": "状态",
|
||||
@@ -1195,4 +1196,4 @@
|
||||
"providerChainstack": "Chainstack",
|
||||
"providerGetBlock": "GetBlock"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1018,7 +1018,7 @@
|
||||
"telegramConfig": {
|
||||
"title": "Telegram 配置說明",
|
||||
"step1": "通過 <strong>@BotFather</strong> 創建 Telegram 機器人,獲取 Bot Token",
|
||||
"step2": "填寫 Bot Token 後,點擊\"獲取 Chat ID\"按鈕自動獲取(需要先向機器人發送消息)",
|
||||
"step2": "填寫 Bot Token 後,點擊\"獲取 Chat ID\"按鈕自動獲取(需要先向機器人發送消息)",
|
||||
"step3": "或通過 <strong>@userinfobot</strong> 手動獲取 Chat ID",
|
||||
"step4": "支持配置多個 Chat ID(用逗號分隔),所有配置的用戶都會收到通知",
|
||||
"step5": "訂單成功或失敗時會自動發送 Telegram 消息",
|
||||
@@ -1080,8 +1080,8 @@
|
||||
"sellStatus": "賣出狀態",
|
||||
"status": "狀態",
|
||||
"statusFilled": "已完成",
|
||||
"statusPartiallySold": "部分成交",
|
||||
"statusFullySold": "全部成交",
|
||||
"statusPartiallySold": "部分賣出",
|
||||
"statusFullySold": "全部賣出",
|
||||
"realizedPnl": "已實現盈虧",
|
||||
"createdAt": "創建時間",
|
||||
"matchedAt": "匹配時間",
|
||||
@@ -1099,6 +1099,7 @@
|
||||
"price": "價格",
|
||||
"amount": "金額",
|
||||
"filterMarketId": "篩選市場ID",
|
||||
"filterMarketTitle": "篩選市場標題",
|
||||
"filterSide": "篩選方向",
|
||||
"filterStatus": "篩選狀態",
|
||||
"filterSellOrderId": "篩選賣出訂單ID",
|
||||
@@ -1109,8 +1110,9 @@
|
||||
"groupByMarket": "按市場分組",
|
||||
"expandAll": "展開全部",
|
||||
"collapseAll": "折疊全部",
|
||||
"allFullyMatched": "全部成交",
|
||||
"partiallyMatched": "部分成交",
|
||||
"allFullySold": "全部賣出",
|
||||
"notSold": "未賣出",
|
||||
"partiallySold": "部分賣出",
|
||||
"orderCount": "訂單數",
|
||||
"totalAmount": "總金額",
|
||||
"totalPnl": "總盈虧",
|
||||
@@ -1126,7 +1128,6 @@
|
||||
"totalMatchedOrders": "總匹配訂單數",
|
||||
"totalBuyAmount": "總買入金額",
|
||||
"totalSellAmount": "總賣出金額",
|
||||
"totalPnl": "總盈虧",
|
||||
"totalRealizedPnl": "總已實現盈虧",
|
||||
"totalUnrealizedPnl": "總未實現盈虧",
|
||||
"winRate": "勝率",
|
||||
@@ -1195,4 +1196,4 @@
|
||||
"providerChainstack": "Chainstack",
|
||||
"providerGetBlock": "GetBlock"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -373,10 +373,12 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
||||
</span>
|
||||
)}
|
||||
{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">
|
||||
{t('copyTradingOrders.partiallyMatched') || '部分成交'} ({group.stats.fullyMatchedCount}/{group.stats.count})
|
||||
{t('copyTradingOrders.partiallySold') || '部分卖出'} ({group.stats.fullyMatchedCount}/{group.stats.count})
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
@@ -384,10 +386,10 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
||||
<span>{t('copyTradingOrders.orderCount') || '订单数'}: {group.stats.count}</span>
|
||||
<span>{t('copyTradingOrders.totalAmount') || '总金额'}: {formatUSDC(group.stats.totalAmount)} USDC</span>
|
||||
<span>
|
||||
{t('copyTradingOrders.statusBreakdown') || '状态'}:
|
||||
{group.stats.fullyMatchedCount > 0 && ` ${t('copyTradingOrders.statusFullySold') || '全部成交'} ${group.stats.fullyMatchedCount}`}
|
||||
{group.stats.partiallyMatchedCount > 0 && ` ${t('copyTradingOrders.statusPartiallySold') || '部分成交'} ${group.stats.partiallyMatchedCount}`}
|
||||
{group.stats.filledCount > 0 && ` ${t('copyTradingOrders.statusFilled') || '未成交'} ${group.stats.filledCount}`}
|
||||
{t('copyTradingOrders.statusBreakdown') || '状态'}:
|
||||
{group.stats.fullyMatchedCount > 0 && ` ${t('copyTradingOrders.allFullySold') || '全部卖出'} ${group.stats.fullyMatchedCount}`}
|
||||
{group.stats.partiallyMatchedCount > 0 && ` ${t('copyTradingOrders.partiallySold') || '部分卖出'} ${group.stats.partiallyMatchedCount}`}
|
||||
{group.stats.filledCount > 0 && ` ${t('copyTradingOrders.notSold') || '未卖出'} ${group.stats.filledCount}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -668,9 +670,9 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
||||
value={filters.status}
|
||||
onChange={(value) => setFilters({ ...filters, status: value || undefined })}
|
||||
>
|
||||
<Option value="filled">{t('copyTradingOrders.statusFilled') || '未成交'}</Option>
|
||||
<Option value="partially_matched">{t('copyTradingOrders.statusPartiallySold') || '部分成交'}</Option>
|
||||
<Option value="fully_matched">{t('copyTradingOrders.statusFullySold') || '全部成交'}</Option>
|
||||
<Option value="filled">{t('copyTradingOrders.notSold') || '未卖出'}</Option>
|
||||
<Option value="partially_matched">{t('copyTradingOrders.partiallySold') || '部分卖出'}</Option>
|
||||
<Option value="fully_matched">{t('copyTradingOrders.allFullySold') || '全部卖出'}</Option>
|
||||
</Select>
|
||||
|
||||
<Space>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Table, Input, Button, Card, Divider, Spin, message } from 'antd'
|
||||
import { apiService } from '../../services/api'
|
||||
import { formatUSDC, isAutoGeneratedOrderId, copyToClipboard } from '../../utils'
|
||||
import { formatUSDC, isAutoGeneratedOrderId, copyToClipboard, getPolymarketUrl } from '../../utils'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { MatchedOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types'
|
||||
@@ -91,59 +91,101 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('copyTradingOrders.sellOrderId') || '卖出订单ID',
|
||||
dataIndex: 'sellOrderId',
|
||||
key: 'sellOrderId',
|
||||
width: isMobile ? 120 : 180,
|
||||
render: (text: string) => {
|
||||
const isAuto = isAutoGeneratedOrderId(text)
|
||||
title: t('copyTradingOrders.market') || '市场',
|
||||
dataIndex: 'marketId',
|
||||
key: 'marketId',
|
||||
width: isMobile ? 120 : 200,
|
||||
render: (text: string, record: MatchedOrderInfo) => {
|
||||
const marketUrl = getPolymarketUrl(record.marketSlug, record.eventSlug, record.marketCategory, record.marketId)
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||
{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
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('copyTradingOrders.buyOrderId') || '买入订单ID',
|
||||
dataIndex: 'buyOrderId',
|
||||
key: 'buyOrderId',
|
||||
width: isMobile ? 120 : 180,
|
||||
render: (text: string) => {
|
||||
const isAuto = isAutoGeneratedOrderId(text)
|
||||
title: t('copyTradingOrders.orderId') || '订单ID',
|
||||
dataIndex: 'orderId',
|
||||
key: 'orderId',
|
||||
width: isMobile ? 150 : 200,
|
||||
render: (_: any, record: MatchedOrderInfo) => {
|
||||
const buyOrderId = record.buyOrderId
|
||||
const sellOrderId = record.sellOrderId
|
||||
const isBuyAuto = isAutoGeneratedOrderId(buyOrderId)
|
||||
const isSellAuto = isAutoGeneratedOrderId(sellOrderId)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
</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 style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
|
||||
<span style={{ fontSize: isMobile ? 11 : 12, color: '#999' }}>
|
||||
{t('copyTradingOrders.buy') || '买入'}:
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
? `${buyOrderId.slice(0, 6)}...${buyOrderId.slice(-4)}`
|
||||
: `${buyOrderId.slice(0, 8)}...${buyOrderId.slice(-6)}`
|
||||
}
|
||||
</span>
|
||||
{!isBuyAuto && (
|
||||
<Button
|
||||
type="text"
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -233,7 +275,7 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
|
||||
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>
|
||||
|
||||
{isMobile ? (
|
||||
@@ -273,9 +315,31 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.market') || '市场'}</div>
|
||||
{order.marketTitle ? (
|
||||
<div style={{ fontSize: '13px', fontWeight: '500', marginBottom: '4px' }}>
|
||||
{order.marketTitle}
|
||||
</div>
|
||||
(() => {
|
||||
const marketUrl = getPolymarketUrl(order.marketSlug, order.eventSlug, order.marketCategory, order.marketId)
|
||||
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}
|
||||
{order.marketId && (
|
||||
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
|
||||
@@ -284,50 +348,42 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.sellOrderId') || '卖出订单ID'}</div>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
fontFamily: 'monospace',
|
||||
marginBottom: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<span>{order.sellOrderId.slice(0, 8)}...{order.sellOrderId.slice(-6)}</span>
|
||||
{!isAutoGeneratedOrderId(order.sellOrderId) && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => handleCopyOrderId(order.sellOrderId)}
|
||||
style={{ padding: 0, height: 'auto', fontSize: '12px' }}
|
||||
title={t('common.copy') || '复制'}
|
||||
/>
|
||||
)}
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.orderId') || '订单ID'}</div>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<div style={{ marginBottom: '4px' }}>
|
||||
<div style={{ fontSize: '11px', color: '#999', marginBottom: '2px' }}>{t('copyTradingOrders.buy') || '买入'}:</div>
|
||||
<div style={{ fontSize: '13px', fontWeight: '500', fontFamily: 'monospace', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span>{order.buyOrderId.slice(0, 8)}...{order.buyOrderId.slice(-6)}</span>
|
||||
{!isAutoGeneratedOrderId(order.buyOrderId) && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => handleCopyOrderId(order.buyOrderId)}
|
||||
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 style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
fontFamily: 'monospace',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<span>{order.buyOrderId.slice(0, 8)}...{order.buyOrderId.slice(-6)}</span>
|
||||
{!isAutoGeneratedOrderId(order.buyOrderId) && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => handleCopyOrderId(order.buyOrderId)}
|
||||
style={{ padding: 0, height: 'auto', fontSize: '12px' }}
|
||||
title={t('common.copy') || '复制'}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div style={{ fontSize: '11px', color: '#999', marginBottom: '2px' }}>{t('copyTradingOrders.sell') || '卖出'}:</div>
|
||||
<div style={{ fontSize: '13px', fontWeight: '500', fontFamily: 'monospace', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span>{order.sellOrderId.slice(0, 8)}...{order.sellOrderId.slice(-6)}</span>
|
||||
{!isAutoGeneratedOrderId(order.sellOrderId) && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => handleCopyOrderId(order.sellOrderId)}
|
||||
style={{ padding: 0, height: 'auto', fontSize: '12px' }}
|
||||
title={t('common.copy') || '复制'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
|
||||
@@ -358,7 +358,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
||||
{marketDisplayName}
|
||||
</span>
|
||||
)}
|
||||
<Tag color="success">{t('copyTradingOrders.allFullyMatched') || '全部成交'}</Tag>
|
||||
<Tag color="success">{t('copyTradingOrders.allFullySold') || '全部卖出'}</Tag>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap', fontSize: isMobile ? '12px' : '13px', color: '#666' }}>
|
||||
<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 })}
|
||||
>
|
||||
<Option value="filled">{t('copyTradingOrders.statusFilled') || '未成交'}</Option>
|
||||
<Option value="partially_matched">{t('copyTradingOrders.statusPartiallyMatched') || '部分成交'}</Option>
|
||||
<Option value="fully_matched">{t('copyTradingOrders.statusFullyMatched') || '完全成交'}</Option>
|
||||
<Option value="partially_matched">{t('copyTradingOrders.partiallySold') || '部分卖出'}</Option>
|
||||
<Option value="fully_matched">{t('copyTradingOrders.allFullySold') || '全部卖出'}</Option>
|
||||
</Select>
|
||||
|
||||
<Space>
|
||||
|
||||
@@ -40,6 +40,8 @@ const PositionList: React.FC = () => {
|
||||
const [redeemableSummary, setRedeemableSummary] = useState<RedeemablePositionsSummary | null>(null)
|
||||
const [loadingRedeemableSummary, setLoadingRedeemableSummary] = useState(false)
|
||||
const [redeeming, setRedeeming] = useState(false)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts()
|
||||
@@ -66,6 +68,11 @@ const PositionList: React.FC = () => {
|
||||
fetchRedeemableSummary()
|
||||
}
|
||||
}, [currentPositions, selectedAccountId])
|
||||
|
||||
// 当筛选条件或搜索关键词变化时,重置分页到第一页
|
||||
useEffect(() => {
|
||||
setCurrentPage(1)
|
||||
}, [positionFilter, selectedAccountId, searchKeyword])
|
||||
|
||||
// 获取可赎回仓位统计
|
||||
const fetchRedeemableSummary = async () => {
|
||||
@@ -265,12 +272,12 @@ const PositionList: React.FC = () => {
|
||||
// 本地搜索和筛选过滤
|
||||
const filteredPositions = useMemo(() => {
|
||||
let filtered = basePositions
|
||||
|
||||
|
||||
// 1. 先按账户筛选
|
||||
if (selectedAccountId !== undefined) {
|
||||
filtered = filtered.filter(p => p.accountId === selectedAccountId)
|
||||
}
|
||||
|
||||
|
||||
// 2. 最后按关键词搜索
|
||||
if (searchKeyword.trim()) {
|
||||
const keyword = searchKeyword.trim().toLowerCase()
|
||||
@@ -302,9 +309,16 @@ const PositionList: React.FC = () => {
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return filtered
|
||||
}, [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) => {
|
||||
return side === 'YES' ? 'green' : 'red'
|
||||
@@ -349,14 +363,32 @@ const PositionList: React.FC = () => {
|
||||
if (!isNaN(initialValue)) {
|
||||
totalInitialValue += initialValue
|
||||
}
|
||||
|
||||
// 当前仓位:统计持仓价值
|
||||
// 历史仓位:currentValue 应该为 0(已平仓)
|
||||
if (!isNaN(currentValue)) {
|
||||
totalCurrentValue += currentValue
|
||||
}
|
||||
if (!isNaN(pnl)) {
|
||||
totalPnl += pnl
|
||||
}
|
||||
if (!isNaN(realizedPnl)) {
|
||||
totalRealizedPnl += realizedPnl
|
||||
|
||||
// 对于当前仓位:
|
||||
// - pnl:未实现盈亏(浮动盈亏)
|
||||
// - 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 = () => {
|
||||
if (filteredPositions.length === 0) {
|
||||
if (paginatedPositions.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
description="暂无仓位数据"
|
||||
<Empty
|
||||
description="暂无仓位数据"
|
||||
style={{ padding: '60px 0' }}
|
||||
/>
|
||||
)
|
||||
@@ -527,7 +559,7 @@ const PositionList: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Row gutter={[16, 16]}>
|
||||
{filteredPositions.map((position, index) => {
|
||||
{paginatedPositions.map((position, index) => {
|
||||
const pnlNum = parseFloat(position.pnl || '0')
|
||||
const isProfit = pnlNum >= 0
|
||||
// 只有当前仓位才根据盈亏显示边框颜色
|
||||
@@ -1235,8 +1267,8 @@ const PositionList: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 合计信息:开仓价值、当前价值、盈亏、已实现盈亏(基于当前筛选后的仓位) */}
|
||||
{filteredPositions.length > 0 && (
|
||||
{/* 合计信息:开仓价值、当前价值、盈亏、已实现盈亏(仅当前仓位显示) */}
|
||||
{filteredPositions.length > 0 && positionFilter === 'current' && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
@@ -1259,13 +1291,11 @@ const PositionList: React.FC = () => {
|
||||
<span>
|
||||
当前价值合计:{' '}
|
||||
<span style={{ fontWeight: 600 }}>
|
||||
{positionFilter === 'current'
|
||||
? `${formatUSDC(positionTotals.totalCurrentValue.toString())} USDC`
|
||||
: '-'}
|
||||
{formatUSDC(positionTotals.totalCurrentValue.toString())} USDC
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
盈亏合计:{' '}
|
||||
浮动盈亏合计:{' '}
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
@@ -1295,32 +1325,87 @@ const PositionList: React.FC = () => {
|
||||
{(isMobile || viewMode === 'card') ? (
|
||||
<Card loading={loading}>
|
||||
{renderCardView()}
|
||||
{/* 移动端分页 */}
|
||||
{filteredPositions.length > 0 && (
|
||||
<div style={{
|
||||
marginTop: '24px',
|
||||
textAlign: 'center',
|
||||
color: '#999',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
共 {filteredPositions.length} 个仓位{searchKeyword ? `(已过滤)` : ''}
|
||||
</div>
|
||||
<>
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
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>
|
||||
<Table
|
||||
dataSource={filteredPositions}
|
||||
columns={columns}
|
||||
rowKey={(record, index) => `${record.accountId}-${record.marketId}-${index}`}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
pageSize: 20,
|
||||
showSizeChanger: !isMobile,
|
||||
showTotal: (total) => `共 ${total} 个仓位${searchKeyword ? `(已过滤)` : ''}`
|
||||
}}
|
||||
scroll={isMobile ? { x: 1500 } : undefined}
|
||||
/>
|
||||
</Card>
|
||||
<Card>
|
||||
<Table
|
||||
dataSource={filteredPositions}
|
||||
columns={columns}
|
||||
rowKey={(record, index) => `${record.accountId}-${record.marketId}-${index}`}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: filteredPositions.length,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['10', '20', '50'],
|
||||
showTotal: (total) => `共 ${total} 个仓位${searchKeyword ? `(已过滤)` : ''}`,
|
||||
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