删除后端无用日志
- 删除所有 logger.debug 调试日志 - 删除过于详细的 logger.info 常规操作日志 - 保留 logger.error 和 logger.warn 重要错误和警告日志 - 优化日志输出,减少生产环境日志噪音
This commit is contained in:
@@ -35,7 +35,6 @@ class AccountController(
|
||||
val result = accountService.importAccount(request)
|
||||
result.fold(
|
||||
onSuccess = { account ->
|
||||
logger.info("成功导入账户: ${account.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(account))
|
||||
},
|
||||
onFailure = { e ->
|
||||
@@ -61,7 +60,6 @@ class AccountController(
|
||||
val result = accountService.updateAccount(request)
|
||||
result.fold(
|
||||
onSuccess = { account ->
|
||||
logger.info("成功更新账户: ${account.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(account))
|
||||
},
|
||||
onFailure = { e ->
|
||||
@@ -87,7 +85,6 @@ class AccountController(
|
||||
val result = accountService.deleteAccount(request.accountId)
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
logger.info("成功删除账户: ${request.accountId}")
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
},
|
||||
onFailure = { e ->
|
||||
@@ -114,7 +111,6 @@ class AccountController(
|
||||
val result = accountService.getAccountList()
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
logger.info("成功查询账户列表: ${response.total} 个账户")
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e ->
|
||||
@@ -137,7 +133,6 @@ class AccountController(
|
||||
val result = accountService.getAccountDetail(request.accountId)
|
||||
result.fold(
|
||||
onSuccess = { account ->
|
||||
logger.info("成功查询账户详情: ${account.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(account))
|
||||
},
|
||||
onFailure = { e ->
|
||||
@@ -163,7 +158,6 @@ class AccountController(
|
||||
val result = accountService.getAccountBalance(request.accountId)
|
||||
result.fold(
|
||||
onSuccess = { balance ->
|
||||
logger.info("成功查询账户余额")
|
||||
ResponseEntity.ok(ApiResponse.success(balance))
|
||||
},
|
||||
onFailure = { e ->
|
||||
@@ -189,7 +183,6 @@ class AccountController(
|
||||
val result = accountService.setDefaultAccount(request.accountId)
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
logger.info("成功设置默认账户: ${request.accountId}")
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
},
|
||||
onFailure = { e ->
|
||||
@@ -216,7 +209,6 @@ class AccountController(
|
||||
result.fold(
|
||||
onSuccess = { positionListResponse ->
|
||||
val total = positionListResponse.currentPositions.size + positionListResponse.historyPositions.size
|
||||
logger.info("成功查询仓位列表: 当前仓位 ${positionListResponse.currentPositions.size} 个,历史仓位 ${positionListResponse.historyPositions.size} 个,共 $total 个")
|
||||
ResponseEntity.ok(ApiResponse.success(positionListResponse))
|
||||
},
|
||||
onFailure = { e ->
|
||||
@@ -260,7 +252,6 @@ class AccountController(
|
||||
val result = runBlocking { accountService.sellPosition(request) }
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
logger.info("成功创建卖出订单: 账户=${request.accountId}, 市场=${request.marketId}, 订单ID=${response.orderId}")
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e ->
|
||||
@@ -287,7 +278,6 @@ class AccountController(
|
||||
val result = runBlocking { accountService.getRedeemablePositionsSummary(request.accountId) }
|
||||
result.fold(
|
||||
onSuccess = { summary ->
|
||||
logger.info("获取可赎回仓位统计成功: 账户=${request.accountId}, 数量=${summary.totalCount}, 价值=${summary.totalValue}")
|
||||
ResponseEntity.ok(ApiResponse.success(summary))
|
||||
},
|
||||
onFailure = { e ->
|
||||
@@ -331,7 +321,6 @@ class AccountController(
|
||||
val result = runBlocking { accountService.redeemPositions(request) }
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
logger.info("成功赎回仓位: 账户数=${response.transactions.size}, 交易数=${response.transactions.size}, 总价值=${response.totalRedeemedValue}")
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e ->
|
||||
|
||||
@@ -36,7 +36,6 @@ class CopyTradingController(
|
||||
val result = copyTradingService.createCopyTrading(request)
|
||||
result.fold(
|
||||
onSuccess = { copyTrading ->
|
||||
logger.info("成功创建跟单: ${copyTrading.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(copyTrading))
|
||||
},
|
||||
onFailure = { e ->
|
||||
@@ -88,7 +87,6 @@ class CopyTradingController(
|
||||
val result = copyTradingService.updateCopyTradingStatus(request)
|
||||
result.fold(
|
||||
onSuccess = { copyTrading ->
|
||||
logger.info("成功更新跟单状态: ${copyTrading.id}, enabled=${copyTrading.enabled}")
|
||||
ResponseEntity.ok(ApiResponse.success(copyTrading))
|
||||
},
|
||||
onFailure = { e ->
|
||||
@@ -119,7 +117,6 @@ class CopyTradingController(
|
||||
val result = copyTradingService.deleteCopyTrading(request.copyTradingId)
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
logger.info("成功删除跟单: ${request.copyTradingId}")
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
},
|
||||
onFailure = { e ->
|
||||
|
||||
-2
@@ -33,7 +33,6 @@ class CopyTradingStatisticsController(
|
||||
val result = runBlocking { statisticsService.getStatistics(request.copyTradingId) }
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
logger.info("成功获取统计信息: copyTradingId=${request.copyTradingId}")
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e ->
|
||||
@@ -86,7 +85,6 @@ class CopyOrderTrackingController(
|
||||
val result = statisticsService.getOrderList(request)
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
logger.info("成功查询订单列表: copyTradingId=${request.copyTradingId}, type=${request.type}, total=${response.total}")
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e ->
|
||||
|
||||
-4
@@ -30,7 +30,6 @@ class CopyTradingTemplateController(
|
||||
val result = templateService.createTemplate(request)
|
||||
result.fold(
|
||||
onSuccess = { template ->
|
||||
logger.info("成功创建模板: ${template.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(template))
|
||||
},
|
||||
onFailure = { e ->
|
||||
@@ -60,7 +59,6 @@ class CopyTradingTemplateController(
|
||||
val result = templateService.updateTemplate(request)
|
||||
result.fold(
|
||||
onSuccess = { template ->
|
||||
logger.info("成功更新模板: ${template.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(template))
|
||||
},
|
||||
onFailure = { e ->
|
||||
@@ -90,7 +88,6 @@ class CopyTradingTemplateController(
|
||||
val result = templateService.deleteTemplate(request.templateId)
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
logger.info("成功删除模板: ${request.templateId}")
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
},
|
||||
onFailure = { e ->
|
||||
@@ -124,7 +121,6 @@ class CopyTradingTemplateController(
|
||||
val result = templateService.copyTemplate(request)
|
||||
result.fold(
|
||||
onSuccess = { template ->
|
||||
logger.info("成功复制模板: ${template.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(template))
|
||||
},
|
||||
onFailure = { e ->
|
||||
|
||||
@@ -30,7 +30,6 @@ class LeaderController(
|
||||
val result = leaderService.addLeader(request)
|
||||
result.fold(
|
||||
onSuccess = { leader ->
|
||||
logger.info("成功添加 Leader: ${leader.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(leader))
|
||||
},
|
||||
onFailure = { e ->
|
||||
@@ -61,7 +60,6 @@ class LeaderController(
|
||||
val result = leaderService.updateLeader(request)
|
||||
result.fold(
|
||||
onSuccess = { leader ->
|
||||
logger.info("成功更新 Leader: ${leader.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(leader))
|
||||
},
|
||||
onFailure = { e ->
|
||||
@@ -91,7 +89,6 @@ class LeaderController(
|
||||
val result = leaderService.deleteLeader(request.leaderId)
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
logger.info("成功删除 Leader: ${request.leaderId}")
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
},
|
||||
onFailure = { e ->
|
||||
|
||||
@@ -36,7 +36,6 @@ class MarketController(
|
||||
val result = runBlocking { accountService.getMarketPrice(request.marketId) }
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
logger.info("成功获取市场价格: 市场=${request.marketId}")
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e ->
|
||||
@@ -65,7 +64,6 @@ class MarketController(
|
||||
val result = runBlocking { clobService.getLatestPrice(request.tokenId) }
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
logger.debug("成功获取最新价: tokenId=${request.tokenId}, bestBid=${response.bestBid}, bestAsk=${response.bestAsk}")
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e ->
|
||||
|
||||
@@ -60,7 +60,6 @@ class AccountService(
|
||||
}
|
||||
|
||||
// 4. 自动获取或创建 API Key(必须成功,否则导入失败)
|
||||
logger.info("开始自动获取或创建 API Key: ${request.walletAddress}")
|
||||
val apiKeyCreds = runBlocking {
|
||||
val result = apiKeyService.createOrDeriveApiKey(
|
||||
privateKey = request.privateKey,
|
||||
@@ -71,7 +70,6 @@ class AccountService(
|
||||
if (result.isSuccess) {
|
||||
val creds = result.getOrNull()
|
||||
if (creds != null) {
|
||||
logger.info("成功自动获取 API Key: ${request.walletAddress}")
|
||||
creds
|
||||
} else {
|
||||
logger.error("自动获取 API Key 返回空值")
|
||||
@@ -98,7 +96,6 @@ class AccountService(
|
||||
if (proxyResult.isSuccess) {
|
||||
val address = proxyResult.getOrNull()
|
||||
if (address != null) {
|
||||
logger.info("成功获取代理地址: ${request.walletAddress} -> $address")
|
||||
address
|
||||
} else {
|
||||
logger.error("获取代理地址返回空值")
|
||||
@@ -127,7 +124,6 @@ class AccountService(
|
||||
)
|
||||
|
||||
val saved = accountRepository.save(account)
|
||||
logger.info("成功导入账户: ${saved.id}, ${saved.walletAddress}, 代理地址: ${saved.proxyAddress}, 启用状态: ${saved.isEnabled}")
|
||||
|
||||
// 刷新订单推送订阅(如果账户启用且有 API 凭证)
|
||||
orderPushService.refreshSubscriptions()
|
||||
@@ -171,7 +167,6 @@ class AccountService(
|
||||
)
|
||||
|
||||
val saved = accountRepository.save(updated)
|
||||
logger.info("成功更新账户: ${saved.id}, 启用状态: ${saved.isEnabled}")
|
||||
|
||||
// 刷新订单推送订阅(账户状态变更时)
|
||||
orderPushService.refreshSubscriptions()
|
||||
@@ -212,7 +207,6 @@ class AccountService(
|
||||
}
|
||||
|
||||
accountRepository.delete(account)
|
||||
logger.info("成功删除账户: $accountId")
|
||||
|
||||
// 刷新订单推送订阅(账户删除时)
|
||||
orderPushService.refreshSubscriptions()
|
||||
@@ -373,7 +367,6 @@ class AccountService(
|
||||
val updated = account.copy(isDefault = true, updatedAt = System.currentTimeMillis())
|
||||
accountRepository.save(updated)
|
||||
|
||||
logger.info("成功设置默认账户: $accountId")
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("设置默认账户失败", e)
|
||||
@@ -807,14 +800,12 @@ class AccountService(
|
||||
account.walletAddress
|
||||
)
|
||||
|
||||
logger.info("创建卖出订单: market=${request.marketId}, side=${request.side}, orderType=${request.orderType}, quantity=${request.quantity}, price=$sellPrice, tokenId=$tokenId")
|
||||
|
||||
val orderResponse = clobApi.createOrder(newOrderRequest)
|
||||
|
||||
if (orderResponse.isSuccessful && orderResponse.body() != null) {
|
||||
val response = orderResponse.body()!!
|
||||
if (response.success) {
|
||||
logger.info("订单创建成功: orderId=${response.orderId}, transactionsHashes=${response.transactionsHashes}")
|
||||
Result.success(
|
||||
PositionSellResponse(
|
||||
orderId = response.orderId ?: "",
|
||||
@@ -1067,7 +1058,6 @@ class AccountService(
|
||||
redeemResult.fold(
|
||||
onSuccess = { txHash ->
|
||||
lastTxHash = txHash
|
||||
logger.info("账户 $accountId 市场 $marketId 赎回成功: txHash=$txHash, indexSets=$indexSets")
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("账户 $accountId 市场 $marketId 赎回失败: ${e.message}", e)
|
||||
@@ -1115,7 +1105,6 @@ class AccountService(
|
||||
return try {
|
||||
// 如果账户没有配置 API 凭证,无法查询活跃订单,允许删除
|
||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
||||
logger.debug("账户 ${account.id} 未配置 API 凭证,无法查询活跃订单,允许删除")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1139,7 +1128,6 @@ class AccountService(
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val ordersResponse = response.body()!!
|
||||
val hasOrders = ordersResponse.data.isNotEmpty()
|
||||
logger.debug("账户 ${account.id} 活跃订单检查结果: $hasOrders (订单数: ${ordersResponse.data.size})")
|
||||
hasOrders
|
||||
} else {
|
||||
// 如果查询失败(可能是认证失败或网络问题),记录警告但允许删除
|
||||
|
||||
@@ -129,7 +129,6 @@ class BlockchainService(
|
||||
// 解析代理地址
|
||||
val proxyAddress = EthereumUtils.decodeAddress(hexResult)
|
||||
|
||||
logger.debug("获取代理地址成功: 原始地址=$walletAddress, 代理地址=$proxyAddress")
|
||||
Result.success(proxyAddress)
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取代理地址失败: ${e.message}", e)
|
||||
@@ -158,7 +157,6 @@ class BlockchainService(
|
||||
return Result.failure(IllegalArgumentException("代理地址不能为空"))
|
||||
}
|
||||
|
||||
logger.debug("使用代理地址查询余额: $proxyAddress (原始地址: $walletAddress)")
|
||||
|
||||
// 使用 RPC 查询 USDC 余额(使用代理地址)
|
||||
val balance = queryUsdcBalanceViaRpc(proxyAddress)
|
||||
@@ -235,7 +233,6 @@ class BlockchainService(
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val positions = response.body()!!
|
||||
logger.debug("查询到 ${positions.size} 个仓位")
|
||||
Result.success(positions)
|
||||
} else {
|
||||
val errorMsg = "Data API 请求失败: ${response.code()} ${response.message()}"
|
||||
@@ -388,7 +385,6 @@ class BlockchainService(
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
logger.debug("查询到仓位总价值: $totalValue")
|
||||
Result.success(totalValue.toString())
|
||||
} else {
|
||||
val errorMsg = "Data API 请求失败: ${response.code()} ${response.message()}"
|
||||
@@ -452,7 +448,6 @@ class BlockchainService(
|
||||
val credentials = org.web3j.crypto.Credentials.create(privateKeyBigInt.toString(16))
|
||||
val fromAddress = credentials.address
|
||||
|
||||
logger.debug("赎回仓位: from=$fromAddress, proxy=$proxyAddress, conditionId=$conditionId, indexSets=$indexSets")
|
||||
|
||||
// 1. 构建 ConditionalTokens.redeemPositions 的调用数据
|
||||
val redeemFunctionSelector = EthereumUtils.getFunctionSelector("redeemPositions(address,bytes32,bytes32,uint256[])")
|
||||
@@ -638,7 +633,6 @@ class BlockchainService(
|
||||
val txHashResult = sendTransaction(rpcApi, transaction)
|
||||
txHashResult.fold(
|
||||
onSuccess = { txHash ->
|
||||
logger.info("赎回仓位交易已发送: txHash=$txHash, from=$fromAddress, proxy=$proxyAddress, conditionId=$conditionId, indexSets=$indexSets")
|
||||
Result.success(txHash)
|
||||
},
|
||||
onFailure = { e ->
|
||||
|
||||
@@ -50,17 +50,14 @@ class CopyOrderTrackingService(
|
||||
|
||||
if (existingProcessed != null) {
|
||||
if (existingProcessed.status == "FAILED") {
|
||||
logger.debug("交易已标记为失败,跳过: leaderId=$leaderId, tradeId=${trade.id}, source=$source")
|
||||
return Result.success(Unit)
|
||||
}
|
||||
logger.debug("交易已处理,跳过: leaderId=$leaderId, tradeId=${trade.id}, source=$source")
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
// 检查是否已记录为失败交易
|
||||
val failedTrade = failedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
|
||||
if (failedTrade != null) {
|
||||
logger.debug("交易已记录为失败,跳过: leaderId=$leaderId, tradeId=${trade.id}, source=$source")
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
@@ -91,17 +88,14 @@ class CopyOrderTrackingService(
|
||||
processedAt = System.currentTimeMillis()
|
||||
)
|
||||
processedTradeRepository.save(processed)
|
||||
logger.info("成功处理交易: leaderId=$leaderId, tradeId=${trade.id}, source=$source, side=${trade.side}")
|
||||
} catch (e: DataIntegrityViolationException) {
|
||||
// 唯一约束冲突,说明已经处理过了(可能是并发请求)
|
||||
// 再次检查确认状态
|
||||
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
|
||||
if (existing != null) {
|
||||
if (existing.status == "FAILED") {
|
||||
logger.debug("交易已标记为失败(并发检测): leaderId=$leaderId, tradeId=${trade.id}")
|
||||
return Result.success(Unit)
|
||||
}
|
||||
logger.debug("交易已处理(并发检测): leaderId=$leaderId, tradeId=${trade.id}, source=$source")
|
||||
return Result.success(Unit)
|
||||
} else {
|
||||
// 如果检查不到,说明可能是其他约束冲突,重新抛出异常
|
||||
@@ -128,7 +122,6 @@ class CopyOrderTrackingService(
|
||||
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
|
||||
|
||||
if (copyTradings.isEmpty()) {
|
||||
logger.debug("没有启用的跟单关系: leaderId=$leaderId")
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
@@ -151,7 +144,6 @@ class CopyOrderTrackingService(
|
||||
|
||||
// 验证账户是否启用
|
||||
if (!account.isEnabled) {
|
||||
logger.debug("账户未启用,跳过创建订单: accountId=${account.id}")
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -273,7 +265,6 @@ class CopyOrderTrackingService(
|
||||
)
|
||||
|
||||
copyOrderTrackingRepository.save(tracking)
|
||||
logger.info("成功创建买入订单并记录跟踪: copyTradingId=${copyTrading.id}, orderId=$realOrderId, tradeId=${trade.id}, quantity=$finalBuyQuantity, price=$buyPrice")
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理买入交易失败: copyTradingId=${copyTrading.id}, tradeId=${trade.id}", e)
|
||||
// 继续处理下一个跟单关系
|
||||
@@ -298,7 +289,6 @@ class CopyOrderTrackingService(
|
||||
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
|
||||
|
||||
if (copyTradings.isEmpty()) {
|
||||
logger.debug("没有启用的跟单关系: leaderId=$leaderId")
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
@@ -311,7 +301,6 @@ class CopyOrderTrackingService(
|
||||
|
||||
// 检查是否支持卖出
|
||||
if (!template.supportSell) {
|
||||
logger.debug("模板不支持卖出,跳过: copyTradingId=${copyTrading.id}, templateId=${template.id}")
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -377,7 +366,6 @@ class CopyOrderTrackingService(
|
||||
|
||||
// 验证账户是否启用
|
||||
if (!account.isEnabled) {
|
||||
logger.debug("账户未启用,跳过创建卖出订单: accountId=${account.id}")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -399,7 +387,6 @@ class CopyOrderTrackingService(
|
||||
)
|
||||
|
||||
if (unmatchedOrders.isEmpty()) {
|
||||
logger.debug("没有未匹配的买入订单: copyTradingId=${copyTrading.id}, market=${leaderSellTrade.market}, outcomeIndex=${leaderSellTrade.outcomeIndex}")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -440,7 +427,6 @@ class CopyOrderTrackingService(
|
||||
}
|
||||
|
||||
if (totalMatched.lte(BigDecimal.ZERO)) {
|
||||
logger.debug("没有匹配到任何订单: copyTradingId=${copyTrading.id}, needMatch=$needMatch")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -533,7 +519,6 @@ class CopyOrderTrackingService(
|
||||
order.updatedAt = System.currentTimeMillis()
|
||||
copyOrderTrackingRepository.save(order)
|
||||
|
||||
logger.info("匹配买入订单: copyTradingId=${copyTrading.id}, buyOrderId=${order.buyOrderId}, matchQty=${detail.matchedQuantity}, pnl=${detail.realizedPnl}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,7 +545,6 @@ class CopyOrderTrackingService(
|
||||
sellMatchDetailRepository.save(savedDetail)
|
||||
}
|
||||
|
||||
logger.info("完成卖出匹配并创建订单: copyTradingId=${copyTrading.id}, sellOrderId=$realSellOrderId, totalMatched=$totalMatched, totalPnl=$totalRealizedPnl")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,7 +31,6 @@ class CopyTradingMonitorService(
|
||||
*/
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
logger.info("跟单监听服务初始化...")
|
||||
scope.launch {
|
||||
try {
|
||||
startMonitoring()
|
||||
@@ -46,7 +45,6 @@ class CopyTradingMonitorService(
|
||||
*/
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
logger.info("停止跟单监听服务...")
|
||||
scope.cancel()
|
||||
// 只使用轮询,不使用WebSocket
|
||||
pollingService.stop()
|
||||
@@ -60,7 +58,6 @@ class CopyTradingMonitorService(
|
||||
val enabledCopyTradings = copyTradingRepository.findByEnabledTrue()
|
||||
|
||||
if (enabledCopyTradings.isEmpty()) {
|
||||
logger.info("没有启用的跟单关系,等待添加...")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -70,7 +67,6 @@ class CopyTradingMonitorService(
|
||||
leaderRepository.findById(leaderId).orElse(null)
|
||||
}
|
||||
|
||||
logger.info("开始监听 ${leaders.size} 个Leader的交易: ${leaders.map { it.leaderAddress }}")
|
||||
|
||||
// 3. 启动轮询监听(使用 /activity 接口,不需要认证)
|
||||
// 注意:WebSocket 需要认证才能订阅其他用户的交易,因此禁用WebSocket,只使用轮询
|
||||
@@ -86,11 +82,9 @@ class CopyTradingMonitorService(
|
||||
|
||||
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
|
||||
if (copyTradings.isEmpty()) {
|
||||
logger.debug("Leader $leaderId 没有启用的跟单关系,不启动监听")
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("添加Leader监听: ${leader.leaderAddress}")
|
||||
// 只使用轮询,不使用WebSocket(需要认证)
|
||||
pollingService.addLeader(leader)
|
||||
}
|
||||
@@ -101,11 +95,9 @@ class CopyTradingMonitorService(
|
||||
suspend fun removeLeaderMonitoring(leaderId: Long) {
|
||||
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
|
||||
if (copyTradings.isNotEmpty()) {
|
||||
logger.debug("Leader $leaderId 仍有启用的跟单关系,不停止监听")
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("移除Leader监听: leaderId=$leaderId")
|
||||
// 只使用轮询,不使用WebSocket
|
||||
pollingService.removeLeader(leaderId)
|
||||
}
|
||||
@@ -114,7 +106,6 @@ class CopyTradingMonitorService(
|
||||
* 重新启动监听(当跟单关系状态改变时调用)
|
||||
*/
|
||||
suspend fun restartMonitoring() {
|
||||
logger.info("重新启动跟单监听...")
|
||||
// 只使用轮询,不使用WebSocket
|
||||
pollingService.stop()
|
||||
delay(1000) // 等待1秒
|
||||
|
||||
@@ -52,12 +52,9 @@ class CopyTradingPollingService(
|
||||
*/
|
||||
fun start(leaders: List<Leader>) {
|
||||
if (!pollingEnabled) {
|
||||
logger.info("轮询监听已禁用,跳过启动")
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("启动轮询监听,Leader数量: ${leaders.size},轮询间隔: ${pollingInterval}ms")
|
||||
|
||||
leaders.forEach { leader ->
|
||||
addLeader(leader)
|
||||
}
|
||||
@@ -81,7 +78,6 @@ class CopyTradingPollingService(
|
||||
cachedTradeIds[leaderId] = mutableSetOf()
|
||||
// 首次轮询标志,用于缓存数据而不处理
|
||||
isFirstPoll[leaderId] = true
|
||||
logger.info("添加轮询监听: leaderId=$leaderId, address=${leader.leaderAddress}, 首次轮询将只缓存数据")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,14 +87,12 @@ class CopyTradingPollingService(
|
||||
monitoredLeaders.remove(leaderId)
|
||||
cachedTradeIds.remove(leaderId)
|
||||
isFirstPoll.remove(leaderId)
|
||||
logger.info("移除轮询监听: leaderId=$leaderId")
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止所有监听
|
||||
*/
|
||||
fun stop() {
|
||||
logger.info("停止所有轮询监听...")
|
||||
stopPolling()
|
||||
monitoredLeaders.clear()
|
||||
cachedTradeIds.clear()
|
||||
@@ -110,17 +104,14 @@ class CopyTradingPollingService(
|
||||
*/
|
||||
private fun startPolling() {
|
||||
if (pollingJob != null && pollingJob!!.isActive) {
|
||||
logger.debug("轮询任务已在运行")
|
||||
return
|
||||
}
|
||||
|
||||
if (monitoredLeaders.isEmpty()) {
|
||||
logger.debug("没有需要监听的Leader,不启动轮询")
|
||||
return
|
||||
}
|
||||
|
||||
pollingJob = scope.launch {
|
||||
logger.info("轮询任务已启动,间隔: ${pollingInterval}ms")
|
||||
|
||||
while (isActive) {
|
||||
try {
|
||||
@@ -235,7 +226,6 @@ class CopyTradingPollingService(
|
||||
cachedIds.addAll(tradeIds)
|
||||
cachedTradeIds[leaderId] = cachedIds
|
||||
|
||||
logger.info("首次轮询,缓存 ${allTrades.size} 笔交易数据,不进行处理: leaderId=$leaderId")
|
||||
// 标记首次轮询完成
|
||||
isFirstPoll[leaderId] = false
|
||||
} else {
|
||||
@@ -247,7 +237,6 @@ class CopyTradingPollingService(
|
||||
// 找出新增的交易
|
||||
val incrementalTrades = allTrades.filter { it.id in incrementalTradeIds }
|
||||
|
||||
logger.debug("通过 diff 发现 ${incrementalTrades.size} 笔新增交易: leaderId=$leaderId")
|
||||
|
||||
// 处理新增的交易
|
||||
incrementalTrades.forEach { trade ->
|
||||
@@ -263,9 +252,7 @@ class CopyTradingPollingService(
|
||||
cachedIds.addAll(incrementalTradeIds)
|
||||
cachedTradeIds[leaderId] = cachedIds
|
||||
|
||||
logger.debug("已更新缓存,当前缓存 ${cachedIds.size} 笔交易ID: leaderId=$leaderId")
|
||||
} else {
|
||||
logger.debug("未发现新增交易: leaderId=$leaderId")
|
||||
}
|
||||
|
||||
// 限制缓存大小,避免内存溢出(只保留最近1000条)
|
||||
@@ -273,7 +260,6 @@ class CopyTradingPollingService(
|
||||
// 保留最新的1000条(由于查询是按时间戳降序,保留前1000条即可)
|
||||
val sortedTradeIds = allTrades.map { it.id }.take(1000).toSet()
|
||||
cachedTradeIds[leaderId] = sortedTradeIds.toMutableSet()
|
||||
logger.debug("缓存已满,清理到1000条: leaderId=$leaderId")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -61,7 +61,6 @@ class CopyTradingService(
|
||||
)
|
||||
|
||||
val saved = copyTradingRepository.save(copyTrading)
|
||||
logger.info("成功创建跟单: ${saved.id}, account=${request.accountId}, template=${request.templateId}, leader=${request.leaderId}")
|
||||
|
||||
// 如果跟单已启用,启动Leader监听
|
||||
if (saved.enabled) {
|
||||
@@ -162,7 +161,6 @@ class CopyTradingService(
|
||||
)
|
||||
|
||||
val saved = copyTradingRepository.save(updated)
|
||||
logger.info("成功更新跟单状态: ${saved.id}, enabled=${saved.enabled}")
|
||||
|
||||
// 更新监听状态
|
||||
kotlinx.coroutines.runBlocking {
|
||||
@@ -203,7 +201,6 @@ class CopyTradingService(
|
||||
|
||||
val leaderId = copyTrading.leaderId
|
||||
copyTradingRepository.delete(copyTrading)
|
||||
logger.info("成功删除跟单: $copyTradingId")
|
||||
|
||||
// 移除监听(如果该Leader没有其他启用的跟单关系)
|
||||
kotlinx.coroutines.runBlocking {
|
||||
|
||||
@@ -62,7 +62,6 @@ class CopyTradingTemplateService(
|
||||
)
|
||||
|
||||
val saved = templateRepository.save(template)
|
||||
logger.info("成功创建模板: ${saved.id}, ${saved.templateName}")
|
||||
|
||||
Result.success(toDto(saved))
|
||||
} catch (e: Exception) {
|
||||
@@ -118,7 +117,6 @@ class CopyTradingTemplateService(
|
||||
)
|
||||
|
||||
val saved = templateRepository.save(updated)
|
||||
logger.info("成功更新模板: ${saved.id}")
|
||||
|
||||
Result.success(toDto(saved))
|
||||
} catch (e: Exception) {
|
||||
@@ -143,7 +141,6 @@ class CopyTradingTemplateService(
|
||||
}
|
||||
|
||||
templateRepository.delete(template)
|
||||
logger.info("成功删除模板: $templateId")
|
||||
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
@@ -186,7 +183,6 @@ class CopyTradingTemplateService(
|
||||
)
|
||||
|
||||
val saved = templateRepository.save(newTemplate)
|
||||
logger.info("成功复制模板: ${sourceTemplate.id} -> ${saved.id}")
|
||||
|
||||
Result.success(toDto(saved))
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -42,7 +42,6 @@ class CopyTradingWebSocketService(
|
||||
* 启动WebSocket监听
|
||||
*/
|
||||
fun start(leaders: List<Leader>) {
|
||||
logger.info("启动WebSocket监听,Leader数量: ${leaders.size}")
|
||||
|
||||
leaders.forEach { leader ->
|
||||
try {
|
||||
@@ -63,7 +62,6 @@ class CopyTradingWebSocketService(
|
||||
}
|
||||
|
||||
if (leaderClients.containsKey(leader.id)) {
|
||||
logger.debug("Leader ${leader.id} 已经在监听中,跳过")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -98,7 +96,6 @@ class CopyTradingWebSocketService(
|
||||
scope.launch {
|
||||
try {
|
||||
client.connect()
|
||||
logger.info("已启动WebSocket监听: leaderId=$leaderId, address=$leaderAddress")
|
||||
} catch (e: Exception) {
|
||||
logger.error("连接WebSocket失败: leaderId=$leaderId", e)
|
||||
leaderClients.remove(leaderId)
|
||||
@@ -117,7 +114,6 @@ class CopyTradingWebSocketService(
|
||||
if (client != null) {
|
||||
try {
|
||||
client.closeConnection()
|
||||
logger.info("已停止WebSocket监听: leaderId=$leaderId")
|
||||
} catch (e: Exception) {
|
||||
logger.error("关闭WebSocket连接失败: leaderId=$leaderId", e)
|
||||
}
|
||||
@@ -128,7 +124,6 @@ class CopyTradingWebSocketService(
|
||||
* 停止所有监听
|
||||
*/
|
||||
fun stop() {
|
||||
logger.info("停止所有WebSocket监听...")
|
||||
val leaderIds = leaderClients.keys.toList()
|
||||
leaderIds.forEach { leaderId ->
|
||||
removeLeader(leaderId)
|
||||
@@ -150,7 +145,6 @@ class CopyTradingWebSocketService(
|
||||
""".trimIndent()
|
||||
|
||||
client.sendMessage(subscribeMessage)
|
||||
logger.info("已订阅用户交易频道: $userAddress")
|
||||
} catch (e: Exception) {
|
||||
logger.error("订阅用户交易频道失败: $userAddress", e)
|
||||
}
|
||||
@@ -163,7 +157,6 @@ class CopyTradingWebSocketService(
|
||||
try {
|
||||
// 处理PONG响应
|
||||
if (message.trim() == "PONG") {
|
||||
logger.debug("收到PONG响应: leaderId=$leaderId")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -173,7 +166,6 @@ class CopyTradingWebSocketService(
|
||||
// 检查消息类型
|
||||
val eventType = json.get("event_type")?.asString
|
||||
if (eventType != "trade") {
|
||||
logger.debug("忽略非交易事件: leaderId=$leaderId, eventType=$eventType")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,6 @@ class LeaderService(
|
||||
)
|
||||
|
||||
val saved = leaderRepository.save(leader)
|
||||
logger.info("成功添加 Leader: ${saved.id}, ${saved.leaderAddress}")
|
||||
|
||||
Result.success(toDto(saved))
|
||||
} catch (e: Exception) {
|
||||
@@ -86,7 +85,6 @@ class LeaderService(
|
||||
)
|
||||
|
||||
val saved = leaderRepository.save(updated)
|
||||
logger.info("成功更新 Leader: ${saved.id}")
|
||||
|
||||
Result.success(toDto(saved))
|
||||
} catch (e: Exception) {
|
||||
@@ -111,7 +109,6 @@ class LeaderService(
|
||||
}
|
||||
|
||||
leaderRepository.delete(leader)
|
||||
logger.info("成功删除 Leader: $leaderId")
|
||||
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -48,7 +48,6 @@ class OrderPushService(
|
||||
*/
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
logger.info("订单推送服务已初始化")
|
||||
scope.launch {
|
||||
connectAllAccounts()
|
||||
}
|
||||
@@ -59,7 +58,6 @@ class OrderPushService(
|
||||
*/
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
logger.info("停止订单推送服务")
|
||||
accountConnections.values.forEach { client ->
|
||||
try {
|
||||
if (client.isConnected()) {
|
||||
@@ -90,7 +88,6 @@ class OrderPushService(
|
||||
* 订阅所有启用的账户
|
||||
*/
|
||||
fun subscribeAllEnabled(callback: (OrderPushMessage) -> Unit) {
|
||||
logger.info("订阅所有启用账户的订单推送")
|
||||
val accounts = accountRepository.findAll()
|
||||
accounts.forEach { account ->
|
||||
if (hasApiCredentials(account) && account.isEnabled) {
|
||||
@@ -166,12 +163,10 @@ class OrderPushService(
|
||||
}
|
||||
|
||||
if (!account.isEnabled) {
|
||||
logger.debug("账户 ${account.id} 未启用,跳过连接")
|
||||
return
|
||||
}
|
||||
|
||||
if (accountConnections.containsKey(account.id)) {
|
||||
logger.debug("账户 ${account.id} 已存在连接,跳过")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -193,7 +188,6 @@ class OrderPushService(
|
||||
if (currentClient != null) {
|
||||
try {
|
||||
sendSubscribeMessage(currentClient, account)
|
||||
logger.info("已为账户 ${account.id} (${account.accountName ?: account.walletAddress}) 建立 User Channel 连接并发送订阅消息")
|
||||
} catch (e: Exception) {
|
||||
logger.error("发送订阅消息失败: account=${account.id}, ${e.message}", e)
|
||||
// 如果订阅失败,关闭连接(会触发重连)
|
||||
@@ -210,7 +204,6 @@ class OrderPushService(
|
||||
if (currentClient != null) {
|
||||
try {
|
||||
sendSubscribeMessage(currentClient, account)
|
||||
logger.info("账户 ${account.id} 重连成功,已重新发送订阅消息")
|
||||
} catch (e: Exception) {
|
||||
logger.error("重连后发送订阅消息失败: account=${account.id}, ${e.message}", e)
|
||||
}
|
||||
@@ -257,7 +250,6 @@ class OrderPushService(
|
||||
|
||||
val json = objectMapper.writeValueAsString(subscribeMessage)
|
||||
client.sendMessage(json)
|
||||
logger.info("已发送 User Channel 订阅消息: account=${account.id}, apiKey=${account.apiKey?.take(10)}...")
|
||||
} catch (e: Exception) {
|
||||
logger.error("发送订阅消息失败: account=${account.id}, ${e.message}", e)
|
||||
}
|
||||
@@ -270,7 +262,6 @@ class OrderPushService(
|
||||
try {
|
||||
// 处理心跳响应(PONG),直接返回
|
||||
if (message.trim() == "PONG" || message.trim() == "pong") {
|
||||
logger.debug("收到 PONG 响应: account=${account.id}")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -304,12 +295,10 @@ class OrderPushService(
|
||||
}
|
||||
} else {
|
||||
// 记录其他类型的消息(用于调试)
|
||||
logger.debug("收到非订单消息: account=${account.id}, eventType=$eventType")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 如果解析失败,可能是非 JSON 消息(如 PONG),记录为 debug 级别
|
||||
if (message.trim() == "PONG" || message.trim() == "pong") {
|
||||
logger.debug("收到 PONG 响应: account=${account.id}")
|
||||
} else {
|
||||
logger.error("处理订单消息失败: account=${account.id}, message=${message.take(100)}, ${e.message}", e)
|
||||
}
|
||||
@@ -328,7 +317,6 @@ class OrderPushService(
|
||||
return try {
|
||||
// 检查账户是否有 API 凭证
|
||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
||||
logger.debug("账户 ${account.id} 未配置 API 凭证,无法获取订单详情")
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -395,18 +383,14 @@ class OrderPushService(
|
||||
val markets = response.body()!!
|
||||
if (markets.isNotEmpty()) {
|
||||
val market = markets.first()
|
||||
logger.debug("获取市场信息成功: conditionId=$conditionId, question=${market.question}")
|
||||
return market
|
||||
} else {
|
||||
logger.debug("未找到市场信息: conditionId=$conditionId")
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
logger.debug("获取市场信息失败: conditionId=$conditionId, code=${response.code()}, message=${response.message()}")
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.debug("获取市场信息异常: conditionId=$conditionId, ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -415,7 +399,6 @@ class OrderPushService(
|
||||
* 订阅账户的订单推送(保留用于向后兼容)
|
||||
*/
|
||||
fun subscribe(accountId: Long, callback: (OrderPushMessage) -> Unit) {
|
||||
logger.info("订阅账户订单推送: $accountId")
|
||||
accountCallbacks.getOrPut(accountId) { mutableSetOf() }.add(callback)
|
||||
|
||||
// 如果账户连接不存在,尝试建立连接
|
||||
@@ -431,7 +414,6 @@ class OrderPushService(
|
||||
* 取消订阅账户的订单推送(保留用于向后兼容)
|
||||
*/
|
||||
fun unsubscribe(accountId: Long, callback: (OrderPushMessage) -> Unit) {
|
||||
logger.info("取消订阅账户订单推送: $accountId")
|
||||
accountCallbacks[accountId]?.remove(callback)
|
||||
|
||||
// 如果没有订阅者了,可以考虑关闭连接(但暂时保持连接,以便后续订阅)
|
||||
@@ -441,7 +423,6 @@ class OrderPushService(
|
||||
* 断开指定账户的连接
|
||||
*/
|
||||
fun disconnectAccount(accountId: Long) {
|
||||
logger.info("断开账户连接: $accountId")
|
||||
val client = accountConnections.remove(accountId)
|
||||
client?.let {
|
||||
try {
|
||||
|
||||
@@ -161,7 +161,6 @@ class PolymarketClobService(
|
||||
adjustedPrice > BigDecimal("0.99") -> BigDecimal("0.99")
|
||||
else -> adjustedPrice
|
||||
}
|
||||
logger.debug("从订单表获取最优价(卖单): tokenId=$tokenId, bestBid=$bestBid, adjustedPrice=${finalPrice.toPlainString()}")
|
||||
return finalPrice.toPlainString()
|
||||
} else {
|
||||
// 市价买单:需要 bestAsk(最低卖出价)
|
||||
@@ -186,7 +185,6 @@ class PolymarketClobService(
|
||||
adjustedPrice > BigDecimal("0.99") -> BigDecimal("0.99")
|
||||
else -> adjustedPrice
|
||||
}
|
||||
logger.debug("从订单表获取最优价(买单): tokenId=$tokenId, bestAsk=$bestAsk, adjustedPrice=${finalPrice.toPlainString()}")
|
||||
return finalPrice.toPlainString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ class PositionPushService(
|
||||
*/
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
logger.info("仓位推送服务已初始化,轮询间隔: ${pollingInterval}ms,等待客户端连接...")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,7 +52,6 @@ class PositionPushService(
|
||||
*/
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
logger.info("停止仓位推送服务")
|
||||
synchronized(lock) {
|
||||
pollingJob?.cancel()
|
||||
pollingJob = null
|
||||
@@ -65,7 +63,6 @@ class PositionPushService(
|
||||
* 订阅仓位推送(新接口)
|
||||
*/
|
||||
fun subscribe(sessionId: String, callback: (PositionPushMessage) -> Unit) {
|
||||
logger.info("订阅仓位推送: $sessionId")
|
||||
registerSession(sessionId, callback)
|
||||
}
|
||||
|
||||
@@ -73,7 +70,6 @@ class PositionPushService(
|
||||
* 取消订阅仓位推送(新接口)
|
||||
*/
|
||||
fun unsubscribe(sessionId: String) {
|
||||
logger.info("取消订阅仓位推送: $sessionId")
|
||||
unregisterSession(sessionId)
|
||||
}
|
||||
|
||||
@@ -136,7 +132,6 @@ class PositionPushService(
|
||||
|
||||
// 发送给指定客户端
|
||||
clientCallbacks[sessionId]?.invoke(message)
|
||||
logger.debug("已发送全量仓位数据给客户端: $sessionId")
|
||||
}
|
||||
} else {
|
||||
logger.warn("获取仓位数据失败,无法发送全量数据: ${result.exceptionOrNull()?.message}")
|
||||
@@ -156,7 +151,6 @@ class PositionPushService(
|
||||
|
||||
// 启动新的轮询任务
|
||||
pollingJob = scope.launch {
|
||||
logger.info("轮询任务已启动,间隔: ${pollingInterval}ms")
|
||||
while (isActive) {
|
||||
try {
|
||||
pollAndPush()
|
||||
@@ -176,7 +170,6 @@ class PositionPushService(
|
||||
synchronized(lock) {
|
||||
pollingJob?.cancel()
|
||||
pollingJob = null
|
||||
logger.info("轮询任务已停止")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +179,6 @@ class PositionPushService(
|
||||
private suspend fun pollAndPush() {
|
||||
// 双重检查:如果没有客户端连接,跳过轮询(虽然理论上不应该发生,但作为安全措施)
|
||||
if (clientCallbacks.isEmpty()) {
|
||||
logger.debug("没有客户端连接,跳过本次轮询")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -220,7 +212,6 @@ class PositionPushService(
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("已推送仓位增量更新,当前仓位变化: ${incremental.currentPositions.size}, 历史仓位变化: ${incremental.historyPositions.size}, 删除: ${incremental.removedKeys.size}")
|
||||
}
|
||||
|
||||
// 更新快照
|
||||
|
||||
-8
@@ -40,7 +40,6 @@ class WebSocketSubscriptionService(
|
||||
* 注册会话
|
||||
*/
|
||||
fun registerSession(sessionId: String, callback: (WsMessage) -> Unit) {
|
||||
logger.info("注册 WebSocket 会话: $sessionId")
|
||||
sessionCallbacks[sessionId] = callback
|
||||
sessionSubscriptions[sessionId] = mutableSetOf()
|
||||
}
|
||||
@@ -49,7 +48,6 @@ class WebSocketSubscriptionService(
|
||||
* 注销会话
|
||||
*/
|
||||
fun unregisterSession(sessionId: String) {
|
||||
logger.info("注销 WebSocket 会话: $sessionId")
|
||||
|
||||
// 取消所有订阅
|
||||
val channels = sessionSubscriptions.remove(sessionId) ?: emptySet()
|
||||
@@ -67,12 +65,10 @@ class WebSocketSubscriptionService(
|
||||
* 订阅频道
|
||||
*/
|
||||
fun subscribe(sessionId: String, channel: String, payload: Map<*, *>?) {
|
||||
logger.info("订阅频道: $sessionId -> $channel")
|
||||
|
||||
// 检查是否已经订阅
|
||||
val sessionChannels = sessionSubscriptions.getOrPut(sessionId) { mutableSetOf() }
|
||||
if (sessionChannels.contains(channel)) {
|
||||
logger.debug("会话 $sessionId 已经订阅了频道 $channel,跳过重复订阅")
|
||||
sendSubscribeAck(sessionId, channel, true)
|
||||
return
|
||||
}
|
||||
@@ -94,7 +90,6 @@ class WebSocketSubscriptionService(
|
||||
scope.launch {
|
||||
try {
|
||||
positionPushService.sendFullData(sessionId)
|
||||
logger.info("已发送仓位首推数据给会话: $sessionId")
|
||||
} catch (e: Exception) {
|
||||
logger.error("发送仓位首推数据失败: $sessionId, ${e.message}", e)
|
||||
}
|
||||
@@ -107,7 +102,6 @@ class WebSocketSubscriptionService(
|
||||
}
|
||||
orderChannelCallbacks[sessionId] = callback
|
||||
orderPushService.subscribeAllEnabled(callback)
|
||||
logger.info("已订阅所有启用账户的订单推送: $sessionId")
|
||||
}
|
||||
else -> {
|
||||
logger.warn("未知的频道: $channel")
|
||||
@@ -120,7 +114,6 @@ class WebSocketSubscriptionService(
|
||||
* 取消订阅
|
||||
*/
|
||||
fun unsubscribe(sessionId: String, channel: String) {
|
||||
logger.info("取消订阅频道: $sessionId -> $channel")
|
||||
|
||||
// 移除订阅关系
|
||||
sessionSubscriptions[sessionId]?.remove(channel)
|
||||
@@ -134,7 +127,6 @@ class WebSocketSubscriptionService(
|
||||
val callback = orderChannelCallbacks.remove(sessionId)
|
||||
if (callback != null) {
|
||||
orderPushService.unsubscribeAll(callback)
|
||||
logger.debug("已取消订阅订单推送: $sessionId -> $channel")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,9 +74,6 @@ class PolymarketAuthInterceptor(
|
||||
val signature = generateSignature(signString, apiSecret)
|
||||
|
||||
// 调试日志(仅在 DEBUG 级别输出)
|
||||
logger.debug("L2 认证签名生成: method=$method, path=$requestPath, bodyLength=${bodyString?.length ?: 0}, timestamp=$timestamp")
|
||||
logger.debug("签名字符串: $signString")
|
||||
logger.debug("签名结果: ${signature.take(20)}...")
|
||||
|
||||
// 重新创建请求体(如果原始请求有请求体)
|
||||
val newRequestBody = originalRequest.body?.let { requestBody ->
|
||||
|
||||
@@ -196,7 +196,6 @@ class ResponseLoggingInterceptor : Interceptor {
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.debug("读取响应体失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-13
@@ -39,7 +39,6 @@ class PolymarketWebSocketClient(
|
||||
// 如果启用了代理,配置代理
|
||||
if (proxy != null) {
|
||||
builder.proxy(proxy)
|
||||
logger.info("已配置 WebSocket 代理: ${proxy.address()}")
|
||||
}
|
||||
|
||||
builder.build()
|
||||
@@ -50,7 +49,6 @@ class PolymarketWebSocketClient(
|
||||
*/
|
||||
fun connect() {
|
||||
if (webSocket != null && isConnected) {
|
||||
logger.debug("WebSocket 已连接: $sessionId")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -61,7 +59,6 @@ class PolymarketWebSocketClient(
|
||||
|
||||
webSocket = okHttpClient.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||
logger.info("已成功连接到 Polymarket RTDS: $sessionId")
|
||||
isConnected = true
|
||||
|
||||
// 重置重连延迟(连接成功后重置为初始值)
|
||||
@@ -85,17 +82,14 @@ class PolymarketWebSocketClient(
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
logger.debug("收到 Polymarket 消息: $sessionId, $text")
|
||||
onMessage(text)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
||||
logger.debug("收到 Polymarket 二进制消息: $sessionId")
|
||||
onMessage(bytes.utf8())
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
logger.info("Polymarket 连接正在关闭: $sessionId, code: $code, reason: $reason")
|
||||
isConnected = false
|
||||
stopPing()
|
||||
// 如果不是正常关闭(code != 1000),尝试重连
|
||||
@@ -105,7 +99,6 @@ class PolymarketWebSocketClient(
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
logger.info("Polymarket 连接已关闭: $sessionId, code: $code, reason: $reason")
|
||||
isConnected = false
|
||||
stopPing()
|
||||
// 如果不是正常关闭(code != 1000),尝试重连
|
||||
@@ -138,7 +131,6 @@ class PolymarketWebSocketClient(
|
||||
}
|
||||
})
|
||||
|
||||
logger.info("正在连接到 Polymarket RTDS: $sessionId, URL: $url")
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建 WebSocket 连接失败: $sessionId, ${e.message}", e)
|
||||
throw e
|
||||
@@ -158,7 +150,6 @@ class PolymarketWebSocketClient(
|
||||
if (isConnected) {
|
||||
try {
|
||||
sendMessage("PING")
|
||||
logger.debug("已发送 PING: $sessionId")
|
||||
} catch (e: Exception) {
|
||||
logger.warn("发送 PING 失败: $sessionId, ${e.message}")
|
||||
break
|
||||
@@ -192,17 +183,14 @@ class PolymarketWebSocketClient(
|
||||
|
||||
// 检查是否应该重连
|
||||
if (!shouldReconnect) {
|
||||
logger.info("重连已禁用,停止重连: $sessionId")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// 如果已经连接,不需要重连
|
||||
if (isConnected) {
|
||||
logger.debug("连接已恢复,取消重连: $sessionId")
|
||||
return@launch
|
||||
}
|
||||
|
||||
logger.info("尝试重连 Polymarket WebSocket: $sessionId, 延迟: ${reconnectDelay}ms")
|
||||
|
||||
// 清理旧的连接
|
||||
webSocket = null
|
||||
@@ -239,7 +227,6 @@ class PolymarketWebSocketClient(
|
||||
webSocket?.close(1000, "正常关闭")
|
||||
webSocket = null
|
||||
isConnected = false
|
||||
logger.info("已关闭 WebSocket 连接: $sessionId")
|
||||
} catch (e: Exception) {
|
||||
logger.error("关闭连接失败: $sessionId, ${e.message}", e)
|
||||
}
|
||||
|
||||
-5
@@ -23,7 +23,6 @@ class PolymarketWebSocketHandler : WebSocketHandler {
|
||||
private val polymarketConnections = ConcurrentHashMap<String, PolymarketWebSocketClient>()
|
||||
|
||||
override fun afterConnectionEstablished(session: WebSocketSession) {
|
||||
logger.info("客户端连接建立: ${session.id}")
|
||||
clientSessions[session.id] = session
|
||||
|
||||
try {
|
||||
@@ -42,7 +41,6 @@ class PolymarketWebSocketHandler : WebSocketHandler {
|
||||
// 异步连接,不阻塞
|
||||
try {
|
||||
polymarketClient.connect()
|
||||
logger.info("正在连接到 Polymarket RTDS: ${session.id}")
|
||||
} catch (e: Exception) {
|
||||
logger.error("启动 Polymarket 连接失败: ${e.message}", e)
|
||||
// 连接失败时清理资源
|
||||
@@ -64,7 +62,6 @@ class PolymarketWebSocketHandler : WebSocketHandler {
|
||||
}
|
||||
|
||||
override fun handleMessage(session: WebSocketSession, message: WebSocketMessage<*>) {
|
||||
logger.debug("收到客户端消息: ${session.id}, ${message.payload}")
|
||||
|
||||
val polymarketClient = polymarketConnections[session.id]
|
||||
if (polymarketClient != null) {
|
||||
@@ -89,7 +86,6 @@ class PolymarketWebSocketHandler : WebSocketHandler {
|
||||
}
|
||||
|
||||
override fun afterConnectionClosed(session: WebSocketSession, closeStatus: CloseStatus) {
|
||||
logger.info("客户端连接关闭: ${session.id}, 状态: $closeStatus")
|
||||
cleanup(session.id)
|
||||
}
|
||||
|
||||
@@ -132,7 +128,6 @@ class PolymarketWebSocketHandler : WebSocketHandler {
|
||||
|
||||
// 移除客户端会话
|
||||
clientSessions.remove(sessionId)
|
||||
logger.debug("已清理资源: $sessionId")
|
||||
} catch (e: Exception) {
|
||||
logger.error("清理资源时发生错误: ${sessionId}, ${e.message}", e)
|
||||
}
|
||||
|
||||
@@ -40,19 +40,16 @@ class UnifiedWebSocketHandler(
|
||||
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
logger.info("统一 WebSocket 处理器已初始化,心跳超时: ${heartbeatTimeout}ms")
|
||||
startCleanupTask()
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
logger.info("停止统一 WebSocket 处理器")
|
||||
cleanupJob?.cancel()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
override fun afterConnectionEstablished(session: WebSocketSession) {
|
||||
logger.info("WebSocket 客户端连接建立: ${session.id}")
|
||||
clientSessions[session.id] = session
|
||||
lastActivityTime[session.id] = System.currentTimeMillis()
|
||||
|
||||
@@ -70,7 +67,6 @@ class UnifiedWebSocketHandler(
|
||||
lastActivityTime[session.id] = System.currentTimeMillis()
|
||||
try {
|
||||
session.sendMessage(TextMessage("PONG"))
|
||||
logger.debug("收到心跳并响应: ${session.id}")
|
||||
} catch (e: Exception) {
|
||||
logger.error("发送心跳响应失败: ${session.id}, ${e.message}", e)
|
||||
}
|
||||
@@ -127,7 +123,6 @@ class UnifiedWebSocketHandler(
|
||||
}
|
||||
|
||||
override fun afterConnectionClosed(session: WebSocketSession, closeStatus: CloseStatus) {
|
||||
logger.info("WebSocket 客户端连接关闭: ${session.id}, 状态: $closeStatus")
|
||||
cleanup(session.id)
|
||||
}
|
||||
|
||||
@@ -166,11 +161,9 @@ class UnifiedWebSocketHandler(
|
||||
try {
|
||||
session.close(CloseStatus.NORMAL)
|
||||
} catch (e: Exception) {
|
||||
logger.debug("关闭会话失败: $sessionId, ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("已清理 WebSocket 资源: $sessionId")
|
||||
} catch (e: Exception) {
|
||||
logger.error("清理 WebSocket 资源时发生错误: $sessionId, ${e.message}", e)
|
||||
}
|
||||
@@ -212,7 +205,6 @@ class UnifiedWebSocketHandler(
|
||||
}
|
||||
|
||||
if (inactiveSessions.isNotEmpty()) {
|
||||
logger.info("已清理 ${inactiveSessions.size} 个不活跃连接")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
# 跟单系统前端需求文档
|
||||
|
||||
## 1. 页面概述
|
||||
|
||||
基于订单跟踪与统计设计,前端需要实现以下页面和功能:
|
||||
- 跟单关系统计页面
|
||||
- 买入订单列表页面
|
||||
- 卖出订单列表页面
|
||||
- 匹配关系列表页面
|
||||
|
||||
## 2. 跟单关系统计页面
|
||||
|
||||
### 2.1 页面路径
|
||||
`/copy-trading/statistics/:copyTradingId`
|
||||
|
||||
### 2.2 显示内容
|
||||
|
||||
#### 2.2.1 基本信息卡片
|
||||
- 账户名称
|
||||
- Leader 名称
|
||||
- 模板名称
|
||||
- 跟单状态(启用/禁用)
|
||||
|
||||
#### 2.2.2 买入统计卡片
|
||||
- **总买入数量**:所有买入订单的数量总和
|
||||
- **总买入金额**:所有买入订单的金额总和(数量 × 价格)
|
||||
- **总买入订单数**:买入订单的数量
|
||||
- **平均买入价格**:总买入金额 / 总买入数量
|
||||
|
||||
#### 2.2.3 卖出统计卡片
|
||||
- **总卖出数量**:所有卖出订单的数量总和
|
||||
- **总卖出金额**:所有卖出订单的金额总和
|
||||
- **总卖出订单数**:卖出订单的数量
|
||||
|
||||
#### 2.2.4 持仓统计卡片
|
||||
- **当前持仓数量**:未匹配的买入数量总和
|
||||
- **当前持仓价值**:当前持仓数量 × 当前市场价格
|
||||
- **平均买入价格**:已买入订单的平均价格
|
||||
|
||||
#### 2.2.5 盈亏统计卡片
|
||||
- **总已实现盈亏**:所有已匹配订单的盈亏总和
|
||||
- 颜色:盈利绿色,亏损红色
|
||||
- 图标:盈利↑,亏损↓
|
||||
- **总未实现盈亏**:当前持仓的盈亏(持仓数量 × (当前价格 - 平均买入价格))
|
||||
- 颜色:盈利绿色,亏损红色
|
||||
- **总盈亏**:已实现盈亏 + 未实现盈亏
|
||||
- 颜色:盈利绿色,亏损红色
|
||||
- 图标:盈利↑,亏损↓
|
||||
- **总盈亏百分比**:总盈亏 / 总买入金额 × 100%
|
||||
- 颜色:盈利绿色,亏损红色
|
||||
|
||||
### 2.3 UI 布局
|
||||
|
||||
**桌面端**:
|
||||
- 使用 `Row` 和 `Col` 布局,每行 3-4 个统计卡片
|
||||
- 卡片使用 `Statistic` 组件显示数据
|
||||
|
||||
**移动端**:
|
||||
- 每行 1-2 个统计卡片
|
||||
- 卡片内容简化,重要数据突出显示
|
||||
|
||||
### 2.4 数据格式化
|
||||
|
||||
- **数量**:使用 `formatUSDC` 格式化(最多 4 位小数,自动去除尾随零)
|
||||
- **金额**:使用 `formatUSDC` 格式化,后缀 "USDC"
|
||||
- **百分比**:显示 2 位小数,后缀 "%"
|
||||
- **价格**:使用 `formatUSDC` 格式化
|
||||
|
||||
## 3. 买入订单列表页面
|
||||
|
||||
### 3.1 页面路径
|
||||
`/copy-trading/orders/buy/:copyTradingId`
|
||||
|
||||
### 3.2 表格列
|
||||
|
||||
| 列名 | 字段 | 说明 |
|
||||
|------|------|------|
|
||||
| 订单ID | buyOrderId | 跟单买入订单ID(可点击查看详情) |
|
||||
| Leader 交易ID | leaderBuyTradeId | Leader 的买入交易ID |
|
||||
| 市场 | marketId | 市场地址(可点击查看市场详情) |
|
||||
| 方向 | side | YES/NO 标签 |
|
||||
| 买入数量 | quantity | 使用 formatUSDC 格式化 |
|
||||
| 买入价格 | price | 使用 formatUSDC 格式化 |
|
||||
| 买入金额 | amount | quantity × price,使用 formatUSDC 格式化 |
|
||||
| 已匹配数量 | matchedQuantity | 已匹配的卖出数量,使用 formatUSDC 格式化 |
|
||||
| 剩余数量 | remainingQuantity | 未匹配的数量,使用 formatUSDC 格式化 |
|
||||
| 订单状态 | status | 标签显示:filled(已完成)、partially_matched(部分匹配)、fully_matched(完全匹配) |
|
||||
| 创建时间 | createdAt | 时间戳转换为可读格式 |
|
||||
|
||||
### 3.3 状态标签颜色
|
||||
|
||||
- `filled`:蓝色(processing)
|
||||
- `partially_matched`:橙色(warning)
|
||||
- `fully_matched`:绿色(success)
|
||||
|
||||
### 3.4 功能
|
||||
|
||||
- **分页**:支持分页查询
|
||||
- **排序**:默认按创建时间倒序
|
||||
- **筛选**:可按市场、方向、状态筛选
|
||||
- **详情**:点击订单ID查看详情(可选)
|
||||
|
||||
## 4. 卖出订单列表页面
|
||||
|
||||
### 4.1 页面路径
|
||||
`/copy-trading/orders/sell/:copyTradingId`
|
||||
|
||||
### 4.2 表格列
|
||||
|
||||
| 列名 | 字段 | 说明 |
|
||||
|------|------|------|
|
||||
| 订单ID | sellOrderId | 跟单卖出订单ID(可点击查看详情) |
|
||||
| Leader 交易ID | leaderSellTradeId | Leader 的卖出交易ID |
|
||||
| 市场 | marketId | 市场地址(可点击查看市场详情) |
|
||||
| 方向 | side | YES/NO 标签 |
|
||||
| 卖出数量 | quantity | 使用 formatUSDC 格式化 |
|
||||
| 卖出价格 | price | 使用 formatUSDC 格式化 |
|
||||
| 卖出金额 | amount | quantity × price,使用 formatUSDC 格式化 |
|
||||
| 已实现盈亏 | realizedPnl | 该卖出订单的盈亏,使用 formatUSDC 格式化,颜色:盈利绿色,亏损红色 |
|
||||
| 创建时间 | createdAt | 时间戳转换为可读格式 |
|
||||
|
||||
### 4.3 功能
|
||||
|
||||
- **分页**:支持分页查询
|
||||
- **排序**:默认按创建时间倒序
|
||||
- **筛选**:可按市场、方向筛选
|
||||
- **详情**:点击订单ID查看匹配明细(可选)
|
||||
|
||||
## 5. 匹配关系列表页面
|
||||
|
||||
### 5.1 页面路径
|
||||
`/copy-trading/orders/matched/:copyTradingId`
|
||||
|
||||
### 5.2 表格列
|
||||
|
||||
| 列名 | 字段 | 说明 |
|
||||
|------|------|------|
|
||||
| 卖出订单ID | sellOrderId | 跟单卖出订单ID(可点击查看详情) |
|
||||
| 买入订单ID | buyOrderId | 匹配的买入订单ID(可点击查看详情) |
|
||||
| 匹配数量 | matchedQuantity | 匹配的数量,使用 formatUSDC 格式化 |
|
||||
| 买入价格 | buyPrice | 买入价格,使用 formatUSDC 格式化 |
|
||||
| 卖出价格 | sellPrice | 卖出价格,使用 formatUSDC 格式化 |
|
||||
| 盈亏 | realizedPnl | (卖出价格 - 买入价格) × 匹配数量,使用 formatUSDC 格式化,颜色:盈利绿色,亏损红色 |
|
||||
| 匹配时间 | matchedAt | 时间戳转换为可读格式 |
|
||||
|
||||
### 5.3 功能
|
||||
|
||||
- **分页**:支持分页查询
|
||||
- **排序**:默认按匹配时间倒序
|
||||
- **筛选**:可按卖出订单ID、买入订单ID筛选
|
||||
- **详情**:点击订单ID查看详情(可选)
|
||||
|
||||
## 6. 跟单列表页面增强
|
||||
|
||||
### 6.1 在跟单列表中添加统计入口
|
||||
|
||||
在 `CopyTradingList` 页面中,每个跟单关系添加:
|
||||
- **查看统计**按钮:跳转到统计页面
|
||||
- **查看订单**按钮:跳转到订单列表页面(可选择买入/卖出/匹配)
|
||||
|
||||
### 6.2 快速统计显示
|
||||
|
||||
在跟单列表表格中,可添加快速统计列:
|
||||
- **总盈亏**:显示该跟单关系的总盈亏(颜色标识)
|
||||
- **订单数**:买入订单数 / 卖出订单数
|
||||
- **持仓**:当前持仓数量
|
||||
|
||||
## 7. 类型定义
|
||||
|
||||
### 7.1 跟单关系统计响应
|
||||
|
||||
```typescript
|
||||
export interface CopyTradingStatistics {
|
||||
copyTradingId: number
|
||||
accountId: number
|
||||
accountName: string
|
||||
leaderId: number
|
||||
leaderName: string
|
||||
templateId: number
|
||||
templateName: string
|
||||
|
||||
// 买入统计
|
||||
totalBuyQuantity: string
|
||||
totalBuyOrders: number
|
||||
totalBuyAmount: string
|
||||
|
||||
// 卖出统计
|
||||
totalSellQuantity: string
|
||||
totalSellOrders: number
|
||||
totalSellAmount: string
|
||||
|
||||
// 持仓统计
|
||||
currentPositionQuantity: string
|
||||
currentPositionValue: string
|
||||
avgBuyPrice: string
|
||||
|
||||
// 盈亏统计
|
||||
totalRealizedPnl: string
|
||||
totalUnrealizedPnl: string
|
||||
totalPnl: string
|
||||
totalPnlPercent: string
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 买入订单信息
|
||||
|
||||
```typescript
|
||||
export interface BuyOrderInfo {
|
||||
orderId: string
|
||||
leaderTradeId: string
|
||||
marketId: string
|
||||
side: string
|
||||
quantity: string
|
||||
price: string
|
||||
amount: string
|
||||
matchedQuantity: string
|
||||
remainingQuantity: string
|
||||
status: 'filled' | 'partially_matched' | 'fully_matched'
|
||||
createdAt: number
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 卖出订单信息
|
||||
|
||||
```typescript
|
||||
export interface SellOrderInfo {
|
||||
orderId: string
|
||||
leaderTradeId: string
|
||||
marketId: string
|
||||
side: string
|
||||
quantity: string
|
||||
price: string
|
||||
amount: string
|
||||
realizedPnl: string
|
||||
createdAt: number
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4 匹配订单信息
|
||||
|
||||
```typescript
|
||||
export interface MatchedOrderInfo {
|
||||
sellOrderId: string
|
||||
buyOrderId: string
|
||||
matchedQuantity: string
|
||||
buyPrice: string
|
||||
sellPrice: string
|
||||
realizedPnl: string
|
||||
matchedAt: number
|
||||
}
|
||||
```
|
||||
|
||||
## 8. API 接口
|
||||
|
||||
### 8.1 查询跟单统计
|
||||
|
||||
```
|
||||
POST /api/copy-trading/statistics/detail
|
||||
Request: { copyTradingId: number }
|
||||
Response: ApiResponse<CopyTradingStatistics>
|
||||
```
|
||||
|
||||
### 8.2 查询买入订单列表
|
||||
|
||||
```
|
||||
POST /api/copy-trading/orders/tracking
|
||||
Request: {
|
||||
copyTradingId: number
|
||||
type: 'buy'
|
||||
page?: number
|
||||
limit?: number
|
||||
marketId?: string
|
||||
side?: string
|
||||
status?: string
|
||||
}
|
||||
Response: ApiResponse<{ list: BuyOrderInfo[], total: number }>
|
||||
```
|
||||
|
||||
### 8.3 查询卖出订单列表
|
||||
|
||||
```
|
||||
POST /api/copy-trading/orders/tracking
|
||||
Request: {
|
||||
copyTradingId: number
|
||||
type: 'sell'
|
||||
page?: number
|
||||
limit?: number
|
||||
marketId?: string
|
||||
side?: string
|
||||
}
|
||||
Response: ApiResponse<{ list: SellOrderInfo[], total: number }>
|
||||
```
|
||||
|
||||
### 8.4 查询匹配关系列表
|
||||
|
||||
```
|
||||
POST /api/copy-trading/orders/tracking
|
||||
Request: {
|
||||
copyTradingId: number
|
||||
type: 'matched'
|
||||
page?: number
|
||||
limit?: number
|
||||
sellOrderId?: string
|
||||
buyOrderId?: string
|
||||
}
|
||||
Response: ApiResponse<{ list: MatchedOrderInfo[], total: number }>
|
||||
```
|
||||
|
||||
## 9. UI/UX 要求
|
||||
|
||||
### 9.1 移动端适配
|
||||
|
||||
- **响应式布局**:使用 `useMediaQuery` 检测移动端
|
||||
- **表格优化**:移动端使用卡片布局或横向滚动
|
||||
- **统计卡片**:移动端每行 1-2 个,简化显示
|
||||
|
||||
### 9.2 数据格式化
|
||||
|
||||
- **统一使用 `formatUSDC`**:所有 USDC 金额显示
|
||||
- **时间格式化**:使用相对时间或标准时间格式
|
||||
- **百分比显示**:保留 2 位小数
|
||||
|
||||
### 9.3 颜色规范
|
||||
|
||||
- **盈利**:绿色(#3f8600)
|
||||
- **亏损**:红色(#cf1322)
|
||||
- **状态标签**:
|
||||
- filled: 蓝色
|
||||
- partially_matched: 橙色
|
||||
- fully_matched: 绿色
|
||||
|
||||
### 9.4 交互优化
|
||||
|
||||
- **加载状态**:使用 `loading` 属性显示加载中
|
||||
- **错误处理**:使用 `message.error` 显示错误信息
|
||||
- **空状态**:显示友好的空状态提示
|
||||
- **分页**:支持每页数量调整
|
||||
|
||||
## 10. 实现优先级
|
||||
|
||||
### Phase 1: 核心功能
|
||||
1. 跟单关系统计页面(基础统计)
|
||||
2. 买入订单列表页面
|
||||
3. 卖出订单列表页面
|
||||
|
||||
### Phase 2: 增强功能
|
||||
4. 匹配关系列表页面
|
||||
5. 跟单列表页面增强(快速统计)
|
||||
6. 订单详情页面(可选)
|
||||
|
||||
### Phase 3: 优化功能
|
||||
7. 数据可视化(图表展示)
|
||||
8. 导出功能(导出统计报表)
|
||||
9. 高级筛选和搜索
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
# 跟单订单跟踪与统计设计文档
|
||||
|
||||
## 1. 方案概述
|
||||
|
||||
采用**订单跟踪匹配方案**,精确追踪每笔买入订单,当 Leader 卖出时进行精确匹配,实现:
|
||||
- 精确的买入-卖出匹配关系
|
||||
- 准确的盈亏计算(已实现/未实现)
|
||||
- 完整的订单统计信息
|
||||
- 多维度数据统计
|
||||
|
||||
## 2. 核心思路
|
||||
|
||||
### 2.1 事件监听
|
||||
|
||||
**当前监听的事件类型**:**交易事件(trade)**
|
||||
|
||||
- **事件来源**:
|
||||
- WebSocket User Channel:`event_type = "trade"`
|
||||
- 轮询 CLOB API:`GET /trades?user={leaderAddress}`
|
||||
- **触发时机**:交易已成交
|
||||
- **数据字段**:`id`(trade_id)、`market`、`side`(BUY/SELL)、`price`、`size`、`timestamp`
|
||||
- **去重标识**:`leader_id + trade_id`(trade.id)
|
||||
|
||||
**说明**:
|
||||
- 只监听已成交的交易事件,不监听订单创建事件
|
||||
- 交易事件表示 Leader 已经完成买入或卖出操作
|
||||
- 通过 `trade.id` 进行去重,确保同一笔交易只处理一次
|
||||
|
||||
### 2.2 买入订单跟踪
|
||||
|
||||
当 Leader 买入时(通过交易事件):
|
||||
1. 检测到 `side = "BUY"` 的交易事件
|
||||
2. 创建跟单买入订单
|
||||
3. 记录到 `copy_order_tracking` 表
|
||||
4. 记录买入数量、价格、状态等信息
|
||||
|
||||
### 2.3 卖出订单匹配
|
||||
|
||||
当 Leader 卖出时(通过交易事件):
|
||||
1. 检测到 `side = "SELL"` 的交易事件
|
||||
2. 查找未匹配的买入订单(FIFO 策略)
|
||||
3. 按比例匹配卖出数量
|
||||
4. 更新买入订单的匹配状态
|
||||
5. 记录匹配关系到 `sell_match_record` 和 `sell_match_detail`
|
||||
|
||||
### 2.4 匹配策略
|
||||
|
||||
- **FIFO(先进先出)**:按买入时间顺序匹配
|
||||
- **部分匹配**:支持一个买入订单被多次卖出匹配
|
||||
- **状态管理**:`filled` → `partially_matched` → `fully_matched`
|
||||
|
||||
## 3. 数据模型
|
||||
|
||||
### 3.1 订单跟踪表(copy_order_tracking)
|
||||
|
||||
```sql
|
||||
CREATE TABLE copy_order_tracking (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
copy_trading_id BIGINT NOT NULL, -- 跟单关系ID
|
||||
account_id BIGINT NOT NULL,
|
||||
leader_id BIGINT NOT NULL,
|
||||
template_id BIGINT NOT NULL,
|
||||
market_id VARCHAR(100) NOT NULL,
|
||||
side VARCHAR(10) NOT NULL, -- YES/NO
|
||||
buy_order_id VARCHAR(100) NOT NULL, -- 跟单买入订单ID
|
||||
leader_buy_trade_id VARCHAR(100) NOT NULL, -- Leader 买入交易ID
|
||||
quantity DECIMAL(20, 8) NOT NULL, -- 买入数量
|
||||
price DECIMAL(20, 8) NOT NULL, -- 买入价格
|
||||
matched_quantity DECIMAL(20, 8) NOT NULL DEFAULT 0, -- 已匹配卖出数量
|
||||
remaining_quantity DECIMAL(20, 8) NOT NULL, -- 剩余未匹配数量
|
||||
status VARCHAR(20) NOT NULL, -- filled, fully_matched, partially_matched
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
INDEX idx_copy_trading (copy_trading_id),
|
||||
INDEX idx_remaining (remaining_quantity, status)
|
||||
);
|
||||
```
|
||||
|
||||
### 3.2 卖出匹配记录表(sell_match_record)
|
||||
|
||||
```sql
|
||||
CREATE TABLE sell_match_record (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
copy_trading_id BIGINT NOT NULL,
|
||||
sell_order_id VARCHAR(100) NOT NULL, -- 跟单卖出订单ID
|
||||
leader_sell_trade_id VARCHAR(100) NOT NULL, -- Leader 卖出交易ID
|
||||
market_id VARCHAR(100) NOT NULL,
|
||||
side VARCHAR(10) NOT NULL,
|
||||
total_matched_quantity DECIMAL(20, 8) NOT NULL, -- 总匹配数量
|
||||
sell_price DECIMAL(20, 8) NOT NULL, -- 卖出价格
|
||||
total_realized_pnl DECIMAL(20, 8) NOT NULL, -- 总已实现盈亏
|
||||
created_at BIGINT NOT NULL,
|
||||
INDEX idx_copy_trading (copy_trading_id)
|
||||
);
|
||||
```
|
||||
|
||||
### 3.3 匹配明细表(sell_match_detail)
|
||||
|
||||
```sql
|
||||
CREATE TABLE sell_match_detail (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
match_record_id BIGINT NOT NULL, -- 关联 sell_match_record.id
|
||||
tracking_id BIGINT NOT NULL, -- 关联 copy_order_tracking.id
|
||||
buy_order_id VARCHAR(100) NOT NULL,
|
||||
matched_quantity DECIMAL(20, 8) NOT NULL, -- 匹配的数量
|
||||
buy_price DECIMAL(20, 8) NOT NULL,
|
||||
sell_price DECIMAL(20, 8) NOT NULL,
|
||||
realized_pnl DECIMAL(20, 8) NOT NULL, -- 盈亏 = (sell_price - buy_price) * matched_quantity
|
||||
created_at BIGINT NOT NULL,
|
||||
FOREIGN KEY (match_record_id) REFERENCES sell_match_record(id),
|
||||
FOREIGN KEY (tracking_id) REFERENCES copy_order_tracking(id)
|
||||
);
|
||||
```
|
||||
|
||||
## 4. 核心流程
|
||||
|
||||
### 4.1 买入订单跟踪流程
|
||||
|
||||
```
|
||||
Leader 买入交易
|
||||
↓
|
||||
检测到交易,计算跟单数量
|
||||
↓
|
||||
根据模板模式计算:
|
||||
- RATIO 模式: 数量 = Leader 数量 × copyRatio
|
||||
- FIXED 模式: 数量 = fixedAmount / 买入价格
|
||||
↓
|
||||
创建跟单买入订单
|
||||
↓
|
||||
记录到 copy_order_tracking
|
||||
- quantity: 买入数量
|
||||
- price: 买入价格
|
||||
- remaining_quantity: 初始等于 quantity
|
||||
- status: "filled"
|
||||
```
|
||||
|
||||
### 4.2 卖出订单匹配流程
|
||||
|
||||
```
|
||||
Leader 卖出交易
|
||||
↓
|
||||
查找未匹配的买入订单(remaining_quantity > 0)
|
||||
↓
|
||||
按 FIFO 顺序匹配
|
||||
↓
|
||||
计算匹配数量(统一按比例,不区分模式)
|
||||
- 需要匹配数量 = Leader 卖出数量 × copyRatio
|
||||
- 实际匹配数量 = min(需要匹配数量, 剩余持仓数量)
|
||||
↓
|
||||
更新买入订单状态
|
||||
- matched_quantity += 匹配数量
|
||||
- remaining_quantity -= 匹配数量
|
||||
- status: 根据剩余数量更新
|
||||
↓
|
||||
记录匹配关系
|
||||
- sell_match_record: 卖出订单记录
|
||||
- sell_match_detail: 匹配明细(每笔买入订单的匹配)
|
||||
```
|
||||
|
||||
**重要说明**:
|
||||
- **买入时**:根据模板的 `copyMode` 计算(RATIO 按比例,FIXED 按固定金额)
|
||||
- **卖出时**:统一按比例计算(`Leader 卖出数量 × copyRatio`),不区分模式
|
||||
- **固定金额模式**:只影响买入时的计算,卖出时仍然按比例
|
||||
|
||||
### 4.3 匹配计算示例
|
||||
|
||||
#### 示例1:比例模式
|
||||
|
||||
```
|
||||
场景(比例模式,copyRatio = 100%):
|
||||
- 买入订单1: quantity=100, remaining=100
|
||||
- 买入订单2: quantity=50, remaining=50
|
||||
- Leader 卖出: 120
|
||||
|
||||
匹配过程:
|
||||
1. 计算需要匹配:120 × 100% = 120
|
||||
2. 订单1: 匹配 min(100, 120) = 100,剩余需匹配 = 20
|
||||
3. 订单2: 匹配 min(50, 20) = 20,剩余需匹配 = 0
|
||||
|
||||
结果:
|
||||
- 订单1: remaining = 0, status = "fully_matched"
|
||||
- 订单2: remaining = 30, status = "partially_matched"
|
||||
- 跟单卖出: 120
|
||||
```
|
||||
|
||||
#### 示例2:固定金额模式
|
||||
|
||||
```
|
||||
场景(固定金额模式,fixedAmount = 15 USDC,copyRatio = 100%):
|
||||
- Leader 买入: 100 数量,价格 0.5
|
||||
- 跟单买入: 15 / 0.5 = 30 数量(固定金额)
|
||||
- Leader 卖出: 50 数量,价格 0.7
|
||||
|
||||
匹配过程:
|
||||
1. 计算需要匹配:50 × 100% = 50(按比例,不按固定金额)
|
||||
2. 订单1: 匹配 min(30, 50) = 30,剩余需匹配 = 20
|
||||
|
||||
结果:
|
||||
- 订单1: remaining = 0, status = "fully_matched"
|
||||
- 跟单卖出: 30(不超过持仓)
|
||||
- 注意:虽然买入时是固定金额,但卖出时按比例计算
|
||||
```
|
||||
|
||||
#### 示例3:部分比例模式
|
||||
|
||||
```
|
||||
场景(比例模式,copyRatio = 30%):
|
||||
- Leader 买入: 100 数量
|
||||
- 跟单买入: 100 × 30% = 30 数量
|
||||
- Leader 卖出: 50 数量
|
||||
|
||||
匹配过程:
|
||||
1. 计算需要匹配:50 × 30% = 15
|
||||
2. 订单1: 匹配 min(30, 15) = 15,剩余需匹配 = 0
|
||||
|
||||
结果:
|
||||
- 订单1: remaining = 15, status = "partially_matched"
|
||||
- 跟单卖出: 15
|
||||
```
|
||||
|
||||
## 5. 统计功能
|
||||
|
||||
### 5.1 跟单关系统计
|
||||
|
||||
**统计维度**:
|
||||
- 总买入数量/金额/订单数
|
||||
- 总卖出数量/金额/订单数
|
||||
- 当前持仓数量
|
||||
- 平均买入价格
|
||||
- 总已实现盈亏
|
||||
- 总未实现盈亏(持仓盈亏)
|
||||
- 总盈亏及百分比
|
||||
|
||||
**计算方式**:
|
||||
```kotlin
|
||||
// 使用 util 方法进行数值计算
|
||||
val totalBuyQuantity = buyOrders.sumOf { it.quantity.toSafeBigDecimal() }
|
||||
val totalSellQuantity = sellOrders.sumOf { it.quantity.toSafeBigDecimal() }
|
||||
val currentPosition = buyOrders.sumOf { it.remainingQuantity.toSafeBigDecimal() }
|
||||
|
||||
// 已实现盈亏
|
||||
val totalRealizedPnl = matchDetails.sumOf { it.realizedPnl.toSafeBigDecimal() }
|
||||
|
||||
// 未实现盈亏(需要当前市场价格)
|
||||
val currentPrice = getMarketCurrentPrice(marketId)
|
||||
val avgBuyPrice = totalBuyAmount.div(totalBuyQuantity)
|
||||
val unrealizedPnl = currentPosition.multi(currentPrice.subtract(avgBuyPrice))
|
||||
|
||||
// 总盈亏
|
||||
val totalPnl = totalRealizedPnl.add(totalUnrealizedPnl)
|
||||
```
|
||||
|
||||
### 5.2 订单信息
|
||||
|
||||
**买入订单列表**:
|
||||
- 订单ID、Leader 交易ID
|
||||
- 市场、方向、数量、价格
|
||||
- 已匹配数量、剩余数量
|
||||
- 订单状态
|
||||
|
||||
**卖出订单列表**:
|
||||
- 订单ID、Leader 交易ID
|
||||
- 市场、方向、数量、价格
|
||||
- 已实现盈亏
|
||||
|
||||
**匹配关系列表**:
|
||||
- 卖出订单ID
|
||||
- 匹配的买入订单ID
|
||||
- 匹配数量
|
||||
- 买入价格、卖出价格
|
||||
- 盈亏
|
||||
|
||||
## 6. 数值计算规范
|
||||
|
||||
**使用 util 扩展方法**:
|
||||
- `toSafeBigDecimal()`: 安全转换为 BigDecimal
|
||||
- `multi()`: 乘法运算
|
||||
- `div()`: 除法运算
|
||||
- `eq()`, `lt()`, `gt()`, `gte()`, `lte()`: 比较运算
|
||||
|
||||
**示例**:
|
||||
```kotlin
|
||||
// 计算匹配数量
|
||||
val matchedQty = min(remainingQty.toSafeBigDecimal(), needMatchQty.toSafeBigDecimal())
|
||||
|
||||
// 计算盈亏
|
||||
val pnl = sellPrice.toSafeBigDecimal()
|
||||
.subtract(buyPrice.toSafeBigDecimal())
|
||||
.multi(matchedQty)
|
||||
|
||||
// 比较数量
|
||||
if (remainingQty.toSafeBigDecimal().gt(BigDecimal.ZERO)) {
|
||||
// 还有剩余
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 关键实现点
|
||||
|
||||
### 7.1 买入数量计算
|
||||
|
||||
```kotlin
|
||||
// 买入时根据模式计算
|
||||
fun calculateBuyQuantity(leaderTrade: Trade, template: CopyTradingTemplate): BigDecimal {
|
||||
return when (template.copyMode) {
|
||||
"RATIO" -> {
|
||||
// 比例模式:Leader 数量 × 比例
|
||||
leaderTrade.size.toSafeBigDecimal()
|
||||
.multi(template.copyRatio)
|
||||
}
|
||||
"FIXED" -> {
|
||||
// 固定金额模式:固定金额 / 买入价格
|
||||
val fixedAmount = template.fixedAmount?.toSafeBigDecimal()
|
||||
?: throw IllegalStateException("固定金额模式下 fixedAmount 不能为空")
|
||||
val buyPrice = leaderTrade.price.toSafeBigDecimal()
|
||||
fixedAmount.div(buyPrice)
|
||||
}
|
||||
else -> throw IllegalArgumentException("不支持的 copyMode: ${template.copyMode}")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 卖出匹配算法
|
||||
|
||||
```kotlin
|
||||
// 卖出时统一按比例计算(不区分模式)
|
||||
fun matchSellOrder(leaderSellTrade: Trade, copyTrading: CopyTrading, template: CopyTradingTemplate): BigDecimal {
|
||||
// 统一按比例计算,不区分 RATIO 或 FIXED 模式
|
||||
val needMatch = leaderSellTrade.size.toSafeBigDecimal()
|
||||
.multi(template.copyRatio)
|
||||
|
||||
val unmatchedOrders = findUnmatchedBuyOrders(copyTrading.id, leaderSellTrade.market, leaderSellTrade.side)
|
||||
var totalMatched = BigDecimal.ZERO
|
||||
var remaining = needMatch
|
||||
|
||||
for (order in unmatchedOrders) {
|
||||
if (remaining.lte(BigDecimal.ZERO)) break
|
||||
|
||||
val matchQty = min(order.remainingQuantity.toSafeBigDecimal(), remaining)
|
||||
totalMatched = totalMatched.add(matchQty)
|
||||
remaining = remaining.subtract(matchQty)
|
||||
|
||||
updateOrderTracking(order, matchQty)
|
||||
recordMatchDetail(order, matchQty, leaderSellTrade)
|
||||
}
|
||||
|
||||
return totalMatched
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 状态更新
|
||||
|
||||
```kotlin
|
||||
fun updateOrderStatus(tracking: CopyOrderTracking) {
|
||||
when {
|
||||
tracking.remainingQuantity.toSafeBigDecimal().eq(BigDecimal.ZERO) -> {
|
||||
tracking.status = "fully_matched"
|
||||
}
|
||||
tracking.matchedQuantity.toSafeBigDecimal().gt(BigDecimal.ZERO) -> {
|
||||
tracking.status = "partially_matched"
|
||||
}
|
||||
else -> {
|
||||
tracking.status = "filled"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 8. API 设计
|
||||
|
||||
### 8.1 查询跟单统计
|
||||
|
||||
```
|
||||
POST /api/copy-trading/statistics/detail
|
||||
Request: { copyTradingId: Long }
|
||||
Response: CopyTradingStatisticsResponse
|
||||
```
|
||||
|
||||
### 8.2 查询订单列表
|
||||
|
||||
```
|
||||
POST /api/copy-trading/orders/tracking
|
||||
Request: { copyTradingId: Long, type: "buy" | "sell" | "matched" }
|
||||
Response: OrderListResponse
|
||||
```
|
||||
|
||||
## 9. 优势
|
||||
|
||||
1. **精确匹配**:每笔卖出都能追溯到对应的买入订单
|
||||
2. **准确盈亏**:可以精确计算每笔交易的盈亏
|
||||
3. **完整统计**:支持多维度数据统计和分析
|
||||
4. **可追溯性**:完整的买入-卖出匹配关系,便于审计
|
||||
|
||||
## 10. WebSocket 与轮询去重机制
|
||||
|
||||
### 10.1 同时运行策略
|
||||
|
||||
**WebSocket 和轮询可以同时运行**:
|
||||
- **WebSocket**:作为主要数据源,实时接收交易推送
|
||||
- **轮询**:作为补充数据源,定期查询确保不遗漏
|
||||
- **去重机制**:通过 trade_id 确保同一笔交易只处理一次
|
||||
|
||||
### 10.2 去重数据模型
|
||||
|
||||
#### 已处理交易表(processed_trade)
|
||||
|
||||
```sql
|
||||
CREATE TABLE processed_trade (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
leader_id BIGINT NOT NULL,
|
||||
leader_trade_id VARCHAR(100) NOT NULL, -- Leader 的交易ID(trade.id,唯一标识)
|
||||
trade_type VARCHAR(10) NOT NULL, -- BUY 或 SELL
|
||||
source VARCHAR(20) NOT NULL, -- 'websocket' 或 'polling'
|
||||
processed_at BIGINT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
UNIQUE KEY uk_leader_trade (leader_id, leader_trade_id),
|
||||
INDEX idx_processed_at (processed_at)
|
||||
);
|
||||
```
|
||||
|
||||
**唯一标识**:`leader_id + leader_trade_id` 组合作为唯一键
|
||||
|
||||
**重要说明**:
|
||||
- `leader_trade_id` 对应 `TradeResponse.id`(交易ID)
|
||||
- 交易事件(trade)只有 `id` 字段,没有 `order_id` 字段
|
||||
- 通过 `trade.id` 进行去重,确保同一笔交易只处理一次
|
||||
|
||||
### 10.3 去重流程
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* 处理交易事件(WebSocket 或轮询)
|
||||
*/
|
||||
suspend fun processTrade(leaderId: Long, trade: TradeResponse, source: String) {
|
||||
// 1. 检查是否已处理(去重)
|
||||
// 使用 trade.id 作为唯一标识(TradeResponse 只有 id 字段,没有 order_id)
|
||||
val isProcessed = processedTradeRepository.existsByLeaderIdAndLeaderTradeId(
|
||||
leaderId,
|
||||
trade.id // trade.id 是交易ID,用于去重
|
||||
)
|
||||
|
||||
if (isProcessed) {
|
||||
logger.debug("交易已处理,跳过: leaderId=$leaderId, tradeId=${trade.id}, source=$source")
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 处理交易逻辑
|
||||
try {
|
||||
// 根据 side 判断是买入还是卖出
|
||||
when (trade.side.uppercase()) {
|
||||
"BUY" -> processBuyTrade(leaderId, trade)
|
||||
"SELL" -> processSellTrade(leaderId, trade)
|
||||
else -> {
|
||||
logger.warn("未知的交易方向: ${trade.side}")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 标记为已处理
|
||||
val processed = ProcessedTrade(
|
||||
leaderId = leaderId,
|
||||
leaderTradeId = trade.id, // 使用 trade.id 作为唯一标识
|
||||
tradeType = trade.side,
|
||||
source = source,
|
||||
processedAt = System.currentTimeMillis()
|
||||
)
|
||||
processedTradeRepository.save(processed)
|
||||
|
||||
logger.info("成功处理交易: leaderId=$leaderId, tradeId=${trade.id}, source=$source, side=${trade.side}")
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理交易失败: leaderId=$leaderId, tradeId=${trade.id}", e)
|
||||
// 失败时不标记为已处理,允许重试
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10.4 并发安全
|
||||
|
||||
**使用数据库唯一约束保证并发安全**:
|
||||
- 数据库唯一约束:`UNIQUE KEY uk_leader_trade (leader_id, leader_trade_id)`
|
||||
- 如果 WebSocket 和轮询同时收到同一笔交易:
|
||||
- 第一个请求:成功处理并插入记录
|
||||
- 第二个请求:插入失败(唯一约束),跳过处理
|
||||
|
||||
**或者使用分布式锁**:
|
||||
```kotlin
|
||||
// 使用 Redis 分布式锁
|
||||
val lockKey = "trade:${leaderId}:${trade.id}"
|
||||
if (redisLock.tryLock(lockKey, 5, TimeUnit.SECONDS)) {
|
||||
try {
|
||||
if (!isProcessed(leaderId, trade.id)) {
|
||||
processTrade(leaderId, trade)
|
||||
markAsProcessed(leaderId, trade.id)
|
||||
}
|
||||
} finally {
|
||||
redisLock.unlock(lockKey)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10.5 清理策略
|
||||
|
||||
**定期清理过期记录**:
|
||||
```kotlin
|
||||
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨 2 点
|
||||
fun cleanupProcessedTrades() {
|
||||
val expireTime = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7) // 保留 7 天
|
||||
processedTradeRepository.deleteByProcessedAtBefore(expireTime)
|
||||
}
|
||||
```
|
||||
|
||||
### 10.6 优势
|
||||
|
||||
1. **高可用性**:WebSocket 断开时,轮询继续工作
|
||||
2. **数据完整性**:轮询确保不遗漏任何交易
|
||||
3. **实时性**:WebSocket 提供实时推送
|
||||
4. **去重保证**:通过唯一标识确保不重复处理
|
||||
|
||||
## 11. 注意事项
|
||||
|
||||
1. **匹配策略**:默认使用 FIFO,可根据需求调整
|
||||
2. **部分匹配**:支持一个买入订单被多次卖出匹配
|
||||
3. **数量计算**:使用 util 方法确保数值计算安全
|
||||
4. **状态同步**:及时更新订单状态,确保数据一致性
|
||||
5. **模式区别**:
|
||||
- **买入时**:RATIO 模式按比例计算,FIXED 模式按固定金额计算
|
||||
- **卖出时**:统一按比例计算(`Leader 卖出数量 × copyRatio`),不区分模式
|
||||
- **固定金额模式**:只影响买入时的计算,卖出时仍然按比例
|
||||
6. **去重机制**:
|
||||
- WebSocket 和轮询可以同时运行
|
||||
- 使用 `leader_id + leader_trade_id` 作为唯一标识去重
|
||||
- 数据库唯一约束保证并发安全
|
||||
- 定期清理过期记录(建议保留 7 天)
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Polymarket 跟单系统</title>
|
||||
<title>PolyHermes</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -18,6 +18,10 @@ import TemplateAdd from './pages/TemplateAdd'
|
||||
import TemplateEdit from './pages/TemplateEdit'
|
||||
import CopyTradingList from './pages/CopyTradingList'
|
||||
import CopyTradingAdd from './pages/CopyTradingAdd'
|
||||
import CopyTradingStatistics from './pages/CopyTradingStatistics'
|
||||
import CopyTradingBuyOrders from './pages/CopyTradingBuyOrders'
|
||||
import CopyTradingSellOrders from './pages/CopyTradingSellOrders'
|
||||
import CopyTradingMatchedOrders from './pages/CopyTradingMatchedOrders'
|
||||
import { wsManager } from './services/websocket'
|
||||
import type { OrderPushMessage } from './types'
|
||||
|
||||
@@ -136,6 +140,10 @@ function App() {
|
||||
<Route path="/templates/edit/:id" element={<TemplateEdit />} />
|
||||
<Route path="/copy-trading" element={<CopyTradingList />} />
|
||||
<Route path="/copy-trading/add" element={<CopyTradingAdd />} />
|
||||
<Route path="/copy-trading/statistics/:copyTradingId" element={<CopyTradingStatistics />} />
|
||||
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<CopyTradingBuyOrders />} />
|
||||
<Route path="/copy-trading/orders/sell/:copyTradingId" element={<CopyTradingSellOrders />} />
|
||||
<Route path="/copy-trading/orders/matched/:copyTradingId" element={<CopyTradingMatchedOrders />} />
|
||||
<Route path="/config" element={<ConfigPage />} />
|
||||
<Route path="/positions" element={<PositionList />} />
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
|
||||
@@ -35,7 +35,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
// 获取当前应该打开的父菜单
|
||||
const getInitialOpenKeys = (): string[] => {
|
||||
const path = location.pathname
|
||||
if (path.startsWith('/templates') || path.startsWith('/copy-trading')) {
|
||||
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading')) {
|
||||
return ['/copy-trading-management']
|
||||
}
|
||||
return []
|
||||
@@ -46,7 +46,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
// 当路径变化时,自动打开对应的父菜单
|
||||
useEffect(() => {
|
||||
const path = location.pathname
|
||||
if (path.startsWith('/templates') || path.startsWith('/copy-trading')) {
|
||||
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading')) {
|
||||
setOpenKeys(['/copy-trading-management'])
|
||||
}
|
||||
}, [location.pathname])
|
||||
@@ -57,16 +57,16 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
icon: <WalletOutlined />,
|
||||
label: '账户管理'
|
||||
},
|
||||
{
|
||||
key: '/leaders',
|
||||
icon: <UserOutlined />,
|
||||
label: 'Leader 管理'
|
||||
},
|
||||
{
|
||||
key: '/copy-trading-management',
|
||||
icon: <AppstoreOutlined />,
|
||||
label: '跟单管理',
|
||||
label: '跟单交易',
|
||||
children: [
|
||||
{
|
||||
key: '/leaders',
|
||||
icon: <UserOutlined />,
|
||||
label: 'Leader 管理'
|
||||
},
|
||||
{
|
||||
key: '/templates',
|
||||
icon: <FileTextOutlined />,
|
||||
@@ -118,7 +118,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<div style={{ color: '#fff', fontSize: '18px', fontWeight: 'bold' }}>
|
||||
Polymarket 跟单
|
||||
PolyHermes
|
||||
</div>
|
||||
<Button
|
||||
type="text"
|
||||
@@ -179,7 +179,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
fontWeight: 'bold',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
Polymarket 跟单
|
||||
PolyHermes
|
||||
</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Card, Table, Button, Tag, Select, Input, message, Space, Divider, Spin } from 'antd'
|
||||
import { LeftOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../services/api'
|
||||
import { formatUSDC } from '../utils'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import type { BuyOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../types'
|
||||
|
||||
const { Option } = Select
|
||||
|
||||
const CopyTradingBuyOrdersPage: React.FC = () => {
|
||||
const { copyTradingId } = useParams<{ copyTradingId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [orders, setOrders] = useState<BuyOrderInfo[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [limit, setLimit] = useState(20)
|
||||
const [filters, setFilters] = useState<{
|
||||
marketId?: string
|
||||
side?: string
|
||||
status?: string
|
||||
}>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (copyTradingId) {
|
||||
fetchOrders()
|
||||
}
|
||||
}, [copyTradingId, page, limit, filters])
|
||||
|
||||
const fetchOrders = async () => {
|
||||
if (!copyTradingId) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const request: OrderTrackingRequest = {
|
||||
copyTradingId: parseInt(copyTradingId),
|
||||
type: 'buy',
|
||||
page,
|
||||
limit,
|
||||
...filters
|
||||
}
|
||||
|
||||
const response = await apiService.orderTracking.list(request)
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
const data = response.data.data as OrderTrackingListResponse
|
||||
setOrders((data.list || []) as BuyOrderInfo[])
|
||||
setTotal(data.total || 0)
|
||||
} else {
|
||||
message.error(response.data.msg || '获取买入订单列表失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '获取买入订单列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
filled: { color: 'processing', text: '已完成' },
|
||||
partially_matched: { color: 'warning', text: '部分匹配' },
|
||||
fully_matched: { color: 'success', text: '完全匹配' }
|
||||
}
|
||||
const config = statusMap[status] || { color: 'default', text: status }
|
||||
return <Tag color={config.color}>{config.text}</Tag>
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '订单ID',
|
||||
dataIndex: 'orderId',
|
||||
key: 'orderId',
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Leader 交易ID',
|
||||
dataIndex: 'leaderTradeId',
|
||||
key: 'leaderTradeId',
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '市场',
|
||||
dataIndex: 'marketId',
|
||||
key: 'marketId',
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '方向',
|
||||
dataIndex: 'side',
|
||||
key: 'side',
|
||||
width: isMobile ? 60 : 80,
|
||||
render: (side: string) => {
|
||||
// 将0/1转换为YES/NO
|
||||
const displaySide = side === '0' ? 'YES' : side === '1' ? 'NO' : side
|
||||
return <Tag style={{ fontSize: isMobile ? 11 : 12 }}>{displaySide}</Tag>
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '买入数量',
|
||||
dataIndex: 'quantity',
|
||||
key: 'quantity',
|
||||
width: isMobile ? 80 : 100,
|
||||
render: (value: string) => (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '买入价格',
|
||||
dataIndex: 'price',
|
||||
key: 'price',
|
||||
width: isMobile ? 80 : 100,
|
||||
render: (value: string) => (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '买入金额',
|
||||
key: 'amount',
|
||||
width: isMobile ? 100 : 120,
|
||||
render: (_: any, record: BuyOrderInfo) => {
|
||||
const amount = (parseFloat(record.quantity) * parseFloat(record.price)).toString()
|
||||
return (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>
|
||||
{isMobile ? formatUSDC(amount) : `${formatUSDC(amount)} USDC`}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '已匹配',
|
||||
dataIndex: 'matchedQuantity',
|
||||
key: 'matchedQuantity',
|
||||
width: isMobile ? 70 : 90,
|
||||
render: (value: string) => (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '剩余',
|
||||
dataIndex: 'remainingQuantity',
|
||||
key: 'remainingQuantity',
|
||||
width: isMobile ? 70 : 90,
|
||||
render: (value: string) => (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: isMobile ? 80 : 100,
|
||||
render: (status: string) => getStatusTag(status)
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: isMobile ? 120 : 160,
|
||||
render: (timestamp: number) => (
|
||||
<span style={{ fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
? new Date(timestamp).toLocaleDateString('zh-CN')
|
||||
: new Date(timestamp).toLocaleString('zh-CN')
|
||||
}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Button icon={<LeftOutlined />} onClick={() => navigate(`/copy-trading/statistics/${copyTradingId}`)}>
|
||||
返回统计
|
||||
</Button>
|
||||
<h2 style={{ margin: 0 }}>买入订单列表</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<Input
|
||||
placeholder="筛选市场ID"
|
||||
allowClear
|
||||
style={{ width: isMobile ? '100%' : 200 }}
|
||||
value={filters.marketId}
|
||||
onChange={(e) => setFilters({ ...filters, marketId: e.target.value || undefined })}
|
||||
/>
|
||||
|
||||
<Select
|
||||
placeholder="筛选方向"
|
||||
allowClear
|
||||
style={{ width: isMobile ? '100%' : 150 }}
|
||||
value={filters.side}
|
||||
onChange={(value) => setFilters({ ...filters, side: value || undefined })}
|
||||
>
|
||||
<Option value="0">YES</Option>
|
||||
<Option value="1">NO</Option>
|
||||
<Option value="YES">YES</Option>
|
||||
<Option value="NO">NO</Option>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
placeholder="筛选状态"
|
||||
allowClear
|
||||
style={{ width: isMobile ? '100%' : 150 }}
|
||||
value={filters.status}
|
||||
onChange={(value) => setFilters({ ...filters, status: value || undefined })}
|
||||
>
|
||||
<Option value="filled">已完成</Option>
|
||||
<Option value="partially_matched">部分匹配</Option>
|
||||
<Option value="fully_matched">完全匹配</Option>
|
||||
</Select>
|
||||
|
||||
<Button onClick={fetchOrders}>查询</Button>
|
||||
</div>
|
||||
|
||||
{isMobile ? (
|
||||
// 移动端卡片布局
|
||||
<div>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : orders.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
|
||||
暂无买入订单
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{orders.map((order) => {
|
||||
const date = new Date(order.createdAt)
|
||||
const formattedDate = date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString()
|
||||
const displaySide = order.side === '0' ? 'YES' : order.side === '1' ? 'NO' : order.side
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={order.orderId}
|
||||
style={{
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||
border: '1px solid #e8e8e8'
|
||||
}}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
>
|
||||
{/* 订单ID和状态 */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
{order.orderId.slice(0, 8)}...{order.orderId.slice(-6)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
||||
<Tag>{displaySide}</Tag>
|
||||
{getStatusTag(order.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
{/* 买入信息 */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>买入信息</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500' }}>
|
||||
数量: {formatUSDC(order.quantity)} | 价格: {formatUSDC(order.price)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500', marginTop: '4px' }}>
|
||||
金额: {formatUSDC(amount)} USDC
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 匹配信息 */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>匹配信息</div>
|
||||
<div style={{ fontSize: '13px', color: '#333' }}>
|
||||
已匹配: {formatUSDC(order.matchedQuantity)} | 剩余: {formatUSDC(order.remainingQuantity)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leader 交易ID */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>Leader 交易ID</div>
|
||||
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
|
||||
{order.leaderTradeId.slice(0, 8)}...{order.leaderTradeId.slice(-6)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 市场ID */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>市场ID</div>
|
||||
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
|
||||
{order.marketId.slice(0, 8)}...{order.marketId.slice(-6)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 创建时间 */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#999' }}>
|
||||
创建时间: {formattedDate}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// 桌面端表格布局
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={orders}
|
||||
rowKey="orderId"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: limit,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (newPage, newLimit) => {
|
||||
setPage(newPage)
|
||||
setLimit(newLimit)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CopyTradingBuyOrdersPage
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, Table, Button, Space, Tag, Popconfirm, Switch, message, Select, Input } from 'antd'
|
||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||
import { Card, Table, Button, Space, Tag, Popconfirm, Switch, message, Select, Input, Dropdown, Divider, Spin } from 'antd'
|
||||
import { PlusOutlined, DeleteOutlined, BarChartOutlined, UnorderedListOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'
|
||||
import type { MenuProps } from 'antd'
|
||||
import { apiService } from '../services/api'
|
||||
import { useAccountStore } from '../store/accountStore'
|
||||
import type { CopyTrading, Account, Leader, CopyTradingTemplate } from '../types'
|
||||
import type { CopyTrading, Account, Leader, CopyTradingTemplate, CopyTradingStatistics } from '../types'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { formatUSDC } from '../utils'
|
||||
|
||||
const { Option } = Select
|
||||
|
||||
@@ -17,6 +19,8 @@ const CopyTradingList: React.FC = () => {
|
||||
const [leaders, setLeaders] = useState<Leader[]>([])
|
||||
const [templates, setTemplates] = useState<CopyTradingTemplate[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [statisticsMap, setStatisticsMap] = useState<Record<number, CopyTradingStatistics>>({})
|
||||
const [loadingStatistics, setLoadingStatistics] = useState<Set<number>>(new Set())
|
||||
const [filters, setFilters] = useState<{
|
||||
accountId?: number
|
||||
templateId?: number
|
||||
@@ -62,7 +66,12 @@ const CopyTradingList: React.FC = () => {
|
||||
try {
|
||||
const response = await apiService.copyTrading.list(filters)
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setCopyTradings(response.data.data.list || [])
|
||||
const list = response.data.data.list || []
|
||||
setCopyTradings(list)
|
||||
// 为每个跟单关系获取统计信息
|
||||
list.forEach((ct: CopyTrading) => {
|
||||
fetchStatistics(ct.id)
|
||||
})
|
||||
} else {
|
||||
message.error(response.data.msg || '获取跟单列表失败')
|
||||
}
|
||||
@@ -73,6 +82,50 @@ const CopyTradingList: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchStatistics = async (copyTradingId: number) => {
|
||||
// 如果正在加载或已有数据,跳过
|
||||
if (loadingStatistics.has(copyTradingId) || statisticsMap[copyTradingId]) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoadingStatistics(prev => new Set(prev).add(copyTradingId))
|
||||
try {
|
||||
const response = await apiService.statistics.detail({ copyTradingId })
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setStatisticsMap(prev => ({
|
||||
...prev,
|
||||
[copyTradingId]: response.data.data
|
||||
}))
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`获取跟单统计失败: copyTradingId=${copyTradingId}`, error)
|
||||
} finally {
|
||||
setLoadingStatistics(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(copyTradingId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const getPnlColor = (value: string): string => {
|
||||
const num = parseFloat(value)
|
||||
if (isNaN(num)) return '#666'
|
||||
return num >= 0 ? '#3f8600' : '#cf1322'
|
||||
}
|
||||
|
||||
const getPnlIcon = (value: string) => {
|
||||
const num = parseFloat(value)
|
||||
if (isNaN(num)) return null
|
||||
return num >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />
|
||||
}
|
||||
|
||||
const formatPercent = (value: string): string => {
|
||||
const num = parseFloat(value)
|
||||
if (isNaN(num)) return '-'
|
||||
return `${num >= 0 ? '+' : ''}${num.toFixed(2)}%`
|
||||
}
|
||||
|
||||
const handleToggleStatus = async (copyTrading: CopyTrading) => {
|
||||
try {
|
||||
const response = await apiService.copyTrading.updateStatus({
|
||||
@@ -108,11 +161,17 @@ const CopyTradingList: React.FC = () => {
|
||||
{
|
||||
title: '钱包',
|
||||
key: 'account',
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (_: any, record: CopyTrading) => (
|
||||
<div>
|
||||
<div>{record.accountName || `账户 ${record.accountId}`}</div>
|
||||
<div style={{ fontSize: 12, color: '#999' }}>
|
||||
{record.walletAddress.slice(0, 6)}...{record.walletAddress.slice(-4)}
|
||||
<div style={{ fontSize: isMobile ? 13 : 14, fontWeight: 500 }}>
|
||||
{record.accountName || `账户 ${record.accountId}`}
|
||||
</div>
|
||||
<div style={{ fontSize: isMobile ? 11 : 12, color: '#999', marginTop: 2 }}>
|
||||
{isMobile
|
||||
? `${record.walletAddress.slice(0, 4)}...${record.walletAddress.slice(-3)}`
|
||||
: `${record.walletAddress.slice(0, 6)}...${record.walletAddress.slice(-4)}`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -121,16 +180,25 @@ const CopyTradingList: React.FC = () => {
|
||||
title: '模板',
|
||||
dataIndex: 'templateName',
|
||||
key: 'templateName',
|
||||
render: (text: string) => <strong>{text}</strong>
|
||||
width: isMobile ? 100 : 120,
|
||||
render: (text: string) => (
|
||||
<strong style={{ fontSize: isMobile ? 13 : 14 }}>{text}</strong>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Leader',
|
||||
key: 'leader',
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (_: any, record: CopyTrading) => (
|
||||
<div>
|
||||
<div>{record.leaderName || `Leader ${record.leaderId}`}</div>
|
||||
<div style={{ fontSize: 12, color: '#999' }}>
|
||||
{record.leaderAddress.slice(0, 6)}...{record.leaderAddress.slice(-4)}
|
||||
<div style={{ fontSize: isMobile ? 13 : 14, fontWeight: 500 }}>
|
||||
{record.leaderName || `Leader ${record.leaderId}`}
|
||||
</div>
|
||||
<div style={{ fontSize: isMobile ? 11 : 12, color: '#999', marginTop: 2 }}>
|
||||
{isMobile
|
||||
? `${record.leaderAddress.slice(0, 4)}...${record.leaderAddress.slice(-3)}`
|
||||
: `${record.leaderAddress.slice(0, 6)}...${record.leaderAddress.slice(-4)}`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -139,6 +207,7 @@ const CopyTradingList: React.FC = () => {
|
||||
title: '状态',
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
width: isMobile ? 80 : 100,
|
||||
render: (enabled: boolean, record: CopyTrading) => (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
@@ -148,27 +217,137 @@ const CopyTradingList: React.FC = () => {
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '总盈亏',
|
||||
key: 'totalPnl',
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (_: any, record: CopyTrading) => {
|
||||
const stats = statisticsMap[record.id]
|
||||
if (!stats) {
|
||||
return loadingStatistics.has(record.id) ? (
|
||||
<span style={{ fontSize: isMobile ? 11 : 12 }}>加载中...</span>
|
||||
) : (
|
||||
<span style={{ fontSize: isMobile ? 11 : 12 }}>-</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
color: getPnlColor(stats.totalPnl),
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
fontSize: isMobile ? 12 : 14
|
||||
}}>
|
||||
{getPnlIcon(stats.totalPnl)}
|
||||
{isMobile ? formatUSDC(stats.totalPnl) : `${formatUSDC(stats.totalPnl)} USDC`}
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: getPnlColor(stats.totalPnlPercent),
|
||||
marginTop: 4
|
||||
}}>
|
||||
{formatPercent(stats.totalPnlPercent)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: isMobile ? 80 : 100,
|
||||
render: (_: any, record: CopyTrading) => (
|
||||
<Popconfirm
|
||||
title="确定要删除这个跟单关系吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)
|
||||
width: isMobile ? 100 : 200,
|
||||
fixed: 'right' as const,
|
||||
render: (_: any, record: CopyTrading) => {
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'statistics',
|
||||
label: '查看统计',
|
||||
icon: <BarChartOutlined />,
|
||||
onClick: () => navigate(`/copy-trading/statistics/${record.id}`)
|
||||
},
|
||||
{
|
||||
key: 'buyOrders',
|
||||
label: '买入订单',
|
||||
icon: <UnorderedListOutlined />,
|
||||
onClick: () => navigate(`/copy-trading/orders/buy/${record.id}`)
|
||||
},
|
||||
{
|
||||
key: 'sellOrders',
|
||||
label: '卖出订单',
|
||||
icon: <UnorderedListOutlined />,
|
||||
onClick: () => navigate(`/copy-trading/orders/sell/${record.id}`)
|
||||
},
|
||||
{
|
||||
key: 'matchedOrders',
|
||||
label: '匹配关系',
|
||||
icon: <UnorderedListOutlined />,
|
||||
onClick: () => navigate(`/copy-trading/orders/matched/${record.id}`)
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: (
|
||||
<Popconfirm
|
||||
title="确定要删除这个跟单关系吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
>
|
||||
<span style={{ color: '#ff4d4f' }}>删除</span>
|
||||
</Popconfirm>
|
||||
),
|
||||
danger: true
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<Space size={isMobile ? 'small' : 'middle'} wrap>
|
||||
{!isMobile && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<BarChartOutlined />}
|
||||
onClick={() => navigate(`/copy-trading/statistics/${record.id}`)}
|
||||
>
|
||||
统计
|
||||
</Button>
|
||||
)}
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['click']}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<UnorderedListOutlined />}
|
||||
>
|
||||
{isMobile ? '' : '订单'}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
{!isMobile && (
|
||||
<Popconfirm
|
||||
title="确定要删除这个跟单关系吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -241,18 +420,205 @@ const CopyTradingList: React.FC = () => {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={copyTradings}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
pageSize: isMobile ? 10 : 20,
|
||||
showSizeChanger: !isMobile,
|
||||
showTotal: (total) => `共 ${total} 条`
|
||||
}}
|
||||
scroll={{ x: isMobile ? 800 : 'auto' }}
|
||||
/>
|
||||
{isMobile ? (
|
||||
// 移动端卡片布局
|
||||
<div>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : copyTradings.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
|
||||
暂无跟单配置
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{copyTradings.map((record) => {
|
||||
const stats = statisticsMap[record.id]
|
||||
const date = new Date(record.createdAt)
|
||||
const formattedDate = date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={record.id}
|
||||
style={{
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||
border: '1px solid #e8e8e8'
|
||||
}}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
>
|
||||
{/* 基本信息 */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
color: '#1890ff'
|
||||
}}>
|
||||
{record.templateName}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
||||
<Tag color={record.enabled ? 'green' : 'red'}>
|
||||
{record.enabled ? '启用' : '禁用'}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
{/* 账户信息 */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>账户</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500' }}>
|
||||
{record.accountName || `账户 ${record.accountId}`}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#999', marginTop: '2px' }}>
|
||||
{record.walletAddress.slice(0, 6)}...{record.walletAddress.slice(-4)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leader 信息 */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>Leader</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500' }}>
|
||||
{record.leaderName || `Leader ${record.leaderId}`}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#999', marginTop: '2px' }}>
|
||||
{record.leaderAddress.slice(0, 6)}...{record.leaderAddress.slice(-4)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 总盈亏 */}
|
||||
{stats && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>总盈亏</div>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: getPnlColor(stats.totalPnl),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}>
|
||||
{getPnlIcon(stats.totalPnl)}
|
||||
{formatUSDC(stats.totalPnl)} USDC
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: getPnlColor(stats.totalPnlPercent),
|
||||
marginTop: '4px'
|
||||
}}>
|
||||
{formatPercent(stats.totalPnlPercent)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingStatistics.has(record.id) && (
|
||||
<div style={{ marginBottom: '12px', fontSize: '12px', color: '#999' }}>
|
||||
加载统计中...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 创建时间 */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#999' }}>
|
||||
创建时间: {formattedDate}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<BarChartOutlined />}
|
||||
onClick={() => navigate(`/copy-trading/statistics/${record.id}`)}
|
||||
style={{ flex: 1, minWidth: '80px' }}
|
||||
>
|
||||
统计
|
||||
</Button>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'statistics',
|
||||
label: '查看统计',
|
||||
icon: <BarChartOutlined />,
|
||||
onClick: () => navigate(`/copy-trading/statistics/${record.id}`)
|
||||
},
|
||||
{
|
||||
key: 'buyOrders',
|
||||
label: '买入订单',
|
||||
icon: <UnorderedListOutlined />,
|
||||
onClick: () => navigate(`/copy-trading/orders/buy/${record.id}`)
|
||||
},
|
||||
{
|
||||
key: 'sellOrders',
|
||||
label: '卖出订单',
|
||||
icon: <UnorderedListOutlined />,
|
||||
onClick: () => navigate(`/copy-trading/orders/sell/${record.id}`)
|
||||
},
|
||||
{
|
||||
key: 'matchedOrders',
|
||||
label: '匹配关系',
|
||||
icon: <UnorderedListOutlined />,
|
||||
onClick: () => navigate(`/copy-trading/orders/matched/${record.id}`)
|
||||
}
|
||||
]
|
||||
}}
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<UnorderedListOutlined />}
|
||||
style={{ flex: 1, minWidth: '80px' }}
|
||||
>
|
||||
订单
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<Popconfirm
|
||||
title="确定要删除这个跟单关系吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
style={{ flex: 1, minWidth: '80px' }}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// 桌面端表格布局
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={copyTradings}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
pageSize: 20,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Card, Table, Button, Input, message, Divider, Spin } from 'antd'
|
||||
import { LeftOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../services/api'
|
||||
import { formatUSDC } from '../utils'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import type { MatchedOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../types'
|
||||
|
||||
const CopyTradingMatchedOrdersPage: React.FC = () => {
|
||||
const { copyTradingId } = useParams<{ copyTradingId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [orders, setOrders] = useState<MatchedOrderInfo[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [limit, setLimit] = useState(20)
|
||||
const [filters, setFilters] = useState<{
|
||||
sellOrderId?: string
|
||||
buyOrderId?: string
|
||||
}>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (copyTradingId) {
|
||||
fetchOrders()
|
||||
}
|
||||
}, [copyTradingId, page, limit, filters])
|
||||
|
||||
const fetchOrders = async () => {
|
||||
if (!copyTradingId) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const request: OrderTrackingRequest = {
|
||||
copyTradingId: parseInt(copyTradingId),
|
||||
type: 'matched',
|
||||
page,
|
||||
limit,
|
||||
...filters
|
||||
}
|
||||
|
||||
const response = await apiService.orderTracking.list(request)
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
const data = response.data.data as OrderTrackingListResponse
|
||||
setOrders((data.list || []) as MatchedOrderInfo[])
|
||||
setTotal(data.total || 0)
|
||||
} else {
|
||||
message.error(response.data.msg || '获取匹配关系列表失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '获取匹配关系列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getPnlColor = (value: string): string => {
|
||||
const num = parseFloat(value)
|
||||
if (isNaN(num)) return '#666'
|
||||
return num >= 0 ? '#3f8600' : '#cf1322'
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '卖出订单ID',
|
||||
dataIndex: 'sellOrderId',
|
||||
key: 'sellOrderId',
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '买入订单ID',
|
||||
dataIndex: 'buyOrderId',
|
||||
key: 'buyOrderId',
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '匹配数量',
|
||||
dataIndex: 'matchedQuantity',
|
||||
key: 'matchedQuantity',
|
||||
width: isMobile ? 80 : 100,
|
||||
render: (value: string) => (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '买入价格',
|
||||
dataIndex: 'buyPrice',
|
||||
key: 'buyPrice',
|
||||
width: isMobile ? 80 : 100,
|
||||
render: (value: string) => (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '卖出价格',
|
||||
dataIndex: 'sellPrice',
|
||||
key: 'sellPrice',
|
||||
width: isMobile ? 80 : 100,
|
||||
render: (value: string) => (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '盈亏',
|
||||
dataIndex: 'realizedPnl',
|
||||
key: 'realizedPnl',
|
||||
width: isMobile ? 100 : 120,
|
||||
render: (value: string) => (
|
||||
<span style={{
|
||||
color: getPnlColor(value),
|
||||
fontWeight: 500,
|
||||
fontSize: isMobile ? 12 : 14
|
||||
}}>
|
||||
{isMobile ? formatUSDC(value) : `${formatUSDC(value)} USDC`}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '匹配时间',
|
||||
dataIndex: 'matchedAt',
|
||||
key: 'matchedAt',
|
||||
width: isMobile ? 120 : 160,
|
||||
render: (timestamp: number) => (
|
||||
<span style={{ fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
? new Date(timestamp).toLocaleDateString('zh-CN')
|
||||
: new Date(timestamp).toLocaleString('zh-CN')
|
||||
}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Button icon={<LeftOutlined />} onClick={() => navigate(`/copy-trading/statistics/${copyTradingId}`)}>
|
||||
返回统计
|
||||
</Button>
|
||||
<h2 style={{ margin: 0 }}>匹配关系列表</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<Input
|
||||
placeholder="筛选卖出订单ID"
|
||||
allowClear
|
||||
style={{ width: isMobile ? '100%' : 200 }}
|
||||
value={filters.sellOrderId}
|
||||
onChange={(e) => setFilters({ ...filters, sellOrderId: e.target.value || undefined })}
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder="筛选买入订单ID"
|
||||
allowClear
|
||||
style={{ width: isMobile ? '100%' : 200 }}
|
||||
value={filters.buyOrderId}
|
||||
onChange={(e) => setFilters({ ...filters, buyOrderId: e.target.value || undefined })}
|
||||
/>
|
||||
|
||||
<Button onClick={fetchOrders}>查询</Button>
|
||||
</div>
|
||||
|
||||
{isMobile ? (
|
||||
// 移动端卡片布局
|
||||
<div>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : orders.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
|
||||
暂无匹配关系
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{orders.map((order) => {
|
||||
const date = new Date(order.matchedAt)
|
||||
const formattedDate = date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={`${order.sellOrderId}-${order.buyOrderId}-${order.matchedAt}`}
|
||||
style={{
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||
border: '1px solid #e8e8e8'
|
||||
}}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
>
|
||||
{/* 订单ID */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>卖出订单ID</div>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
fontFamily: 'monospace',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
{order.sellOrderId.slice(0, 8)}...{order.sellOrderId.slice(-6)}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>买入订单ID</div>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
{order.buyOrderId.slice(0, 8)}...{order.buyOrderId.slice(-6)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
{/* 匹配信息 */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>匹配数量</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500' }}>
|
||||
{formatUSDC(order.matchedQuantity)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 价格信息 */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>价格信息</div>
|
||||
<div style={{ fontSize: '13px', color: '#333' }}>
|
||||
买入: {formatUSDC(order.buyPrice)} | 卖出: {formatUSDC(order.sellPrice)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 盈亏 */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>盈亏</div>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: getPnlColor(order.realizedPnl)
|
||||
}}>
|
||||
{formatUSDC(order.realizedPnl)} USDC
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 匹配时间 */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#999' }}>
|
||||
匹配时间: {formattedDate}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// 桌面端表格布局
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={orders}
|
||||
rowKey={(record) => `${record.sellOrderId}-${record.buyOrderId}-${record.matchedAt}`}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: limit,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (newPage, newLimit) => {
|
||||
setPage(newPage)
|
||||
setLimit(newLimit)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CopyTradingMatchedOrdersPage
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Card, Table, Button, Tag, Select, Input, message, Divider, Spin } from 'antd'
|
||||
import { LeftOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../services/api'
|
||||
import { formatUSDC } from '../utils'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import type { SellOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../types'
|
||||
|
||||
const { Option } = Select
|
||||
|
||||
const CopyTradingSellOrdersPage: React.FC = () => {
|
||||
const { copyTradingId } = useParams<{ copyTradingId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [orders, setOrders] = useState<SellOrderInfo[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [limit, setLimit] = useState(20)
|
||||
const [filters, setFilters] = useState<{
|
||||
marketId?: string
|
||||
side?: string
|
||||
}>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (copyTradingId) {
|
||||
fetchOrders()
|
||||
}
|
||||
}, [copyTradingId, page, limit, filters])
|
||||
|
||||
const fetchOrders = async () => {
|
||||
if (!copyTradingId) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const request: OrderTrackingRequest = {
|
||||
copyTradingId: parseInt(copyTradingId),
|
||||
type: 'sell',
|
||||
page,
|
||||
limit,
|
||||
...filters
|
||||
}
|
||||
|
||||
const response = await apiService.orderTracking.list(request)
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
const data = response.data.data as OrderTrackingListResponse
|
||||
setOrders((data.list || []) as SellOrderInfo[])
|
||||
setTotal(data.total || 0)
|
||||
} else {
|
||||
message.error(response.data.msg || '获取卖出订单列表失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '获取卖出订单列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getPnlColor = (value: string): string => {
|
||||
const num = parseFloat(value)
|
||||
if (isNaN(num)) return '#666'
|
||||
return num >= 0 ? '#3f8600' : '#cf1322'
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '订单ID',
|
||||
dataIndex: 'orderId',
|
||||
key: 'orderId',
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Leader 交易ID',
|
||||
dataIndex: 'leaderTradeId',
|
||||
key: 'leaderTradeId',
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '市场',
|
||||
dataIndex: 'marketId',
|
||||
key: 'marketId',
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '方向',
|
||||
dataIndex: 'side',
|
||||
key: 'side',
|
||||
width: isMobile ? 60 : 80,
|
||||
render: (side: string) => {
|
||||
// 将0/1转换为YES/NO
|
||||
const displaySide = side === '0' ? 'YES' : side === '1' ? 'NO' : side
|
||||
return <Tag style={{ fontSize: isMobile ? 11 : 12 }}>{displaySide}</Tag>
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '卖出数量',
|
||||
dataIndex: 'quantity',
|
||||
key: 'quantity',
|
||||
width: isMobile ? 80 : 100,
|
||||
render: (value: string) => (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '卖出价格',
|
||||
dataIndex: 'price',
|
||||
key: 'price',
|
||||
width: isMobile ? 80 : 100,
|
||||
render: (value: string) => (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '卖出金额',
|
||||
key: 'amount',
|
||||
width: isMobile ? 100 : 120,
|
||||
render: (_: any, record: SellOrderInfo) => {
|
||||
const amount = (parseFloat(record.quantity) * parseFloat(record.price)).toString()
|
||||
return (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>
|
||||
{isMobile ? formatUSDC(amount) : `${formatUSDC(amount)} USDC`}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '已实现盈亏',
|
||||
dataIndex: 'realizedPnl',
|
||||
key: 'realizedPnl',
|
||||
width: isMobile ? 100 : 120,
|
||||
render: (value: string) => (
|
||||
<span style={{
|
||||
color: getPnlColor(value),
|
||||
fontWeight: 500,
|
||||
fontSize: isMobile ? 12 : 14
|
||||
}}>
|
||||
{isMobile ? formatUSDC(value) : `${formatUSDC(value)} USDC`}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: isMobile ? 120 : 160,
|
||||
render: (timestamp: number) => (
|
||||
<span style={{ fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
? new Date(timestamp).toLocaleDateString('zh-CN')
|
||||
: new Date(timestamp).toLocaleString('zh-CN')
|
||||
}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Button icon={<LeftOutlined />} onClick={() => navigate(`/copy-trading/statistics/${copyTradingId}`)}>
|
||||
返回统计
|
||||
</Button>
|
||||
<h2 style={{ margin: 0 }}>卖出订单列表</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<Input
|
||||
placeholder="筛选市场ID"
|
||||
allowClear
|
||||
style={{ width: isMobile ? '100%' : 200 }}
|
||||
value={filters.marketId}
|
||||
onChange={(e) => setFilters({ ...filters, marketId: e.target.value || undefined })}
|
||||
/>
|
||||
|
||||
<Select
|
||||
placeholder="筛选方向"
|
||||
allowClear
|
||||
style={{ width: isMobile ? '100%' : 150 }}
|
||||
value={filters.side}
|
||||
onChange={(value) => setFilters({ ...filters, side: value || undefined })}
|
||||
>
|
||||
<Option value="0">YES</Option>
|
||||
<Option value="1">NO</Option>
|
||||
<Option value="YES">YES</Option>
|
||||
<Option value="NO">NO</Option>
|
||||
</Select>
|
||||
|
||||
<Button onClick={fetchOrders}>查询</Button>
|
||||
</div>
|
||||
|
||||
{isMobile ? (
|
||||
// 移动端卡片布局
|
||||
<div>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : orders.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
|
||||
暂无卖出订单
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{orders.map((order) => {
|
||||
const date = new Date(order.createdAt)
|
||||
const formattedDate = date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString()
|
||||
const displaySide = order.side === '0' ? 'YES' : order.side === '1' ? 'NO' : order.side
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={order.orderId}
|
||||
style={{
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||
border: '1px solid #e8e8e8'
|
||||
}}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
>
|
||||
{/* 订单ID和方向 */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
{order.orderId.slice(0, 8)}...{order.orderId.slice(-6)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
||||
<Tag>{displaySide}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
{/* 卖出信息 */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>卖出信息</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500' }}>
|
||||
数量: {formatUSDC(order.quantity)} | 价格: {formatUSDC(order.price)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500', marginTop: '4px' }}>
|
||||
金额: {formatUSDC(amount)} USDC
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 已实现盈亏 */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>已实现盈亏</div>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: getPnlColor(order.realizedPnl)
|
||||
}}>
|
||||
{formatUSDC(order.realizedPnl)} USDC
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leader 交易ID */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>Leader 交易ID</div>
|
||||
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
|
||||
{order.leaderTradeId.slice(0, 8)}...{order.leaderTradeId.slice(-6)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 市场ID */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>市场ID</div>
|
||||
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
|
||||
{order.marketId.slice(0, 8)}...{order.marketId.slice(-6)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 创建时间 */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#999' }}>
|
||||
创建时间: {formattedDate}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// 桌面端表格布局
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={orders}
|
||||
rowKey="orderId"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: limit,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (newPage, newLimit) => {
|
||||
setPage(newPage)
|
||||
setLimit(newLimit)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CopyTradingSellOrdersPage
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Card, Row, Col, Statistic, Tag, Button, message, Spin } from 'antd'
|
||||
import { ArrowUpOutlined, ArrowDownOutlined, LeftOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../services/api'
|
||||
import { formatUSDC } from '../utils'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import type { CopyTradingStatistics } from '../types'
|
||||
|
||||
const CopyTradingStatisticsPage: React.FC = () => {
|
||||
const { copyTradingId } = useParams<{ copyTradingId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [statistics, setStatistics] = useState<CopyTradingStatistics | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (copyTradingId) {
|
||||
fetchStatistics()
|
||||
}
|
||||
}, [copyTradingId])
|
||||
|
||||
const fetchStatistics = async () => {
|
||||
if (!copyTradingId) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await apiService.statistics.detail({ copyTradingId: parseInt(copyTradingId) })
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setStatistics(response.data.data)
|
||||
} else {
|
||||
message.error(response.data.msg || '获取统计信息失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '获取统计信息失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getPnlColor = (value: string): string => {
|
||||
const num = parseFloat(value)
|
||||
if (isNaN(num)) return '#666'
|
||||
return num >= 0 ? '#3f8600' : '#cf1322'
|
||||
}
|
||||
|
||||
const getPnlIcon = (value: string) => {
|
||||
const num = parseFloat(value)
|
||||
if (isNaN(num)) return null
|
||||
return num >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />
|
||||
}
|
||||
|
||||
const formatPercent = (value: string): string => {
|
||||
const num = parseFloat(value)
|
||||
if (isNaN(num)) return '-'
|
||||
return `${num >= 0 ? '+' : ''}${num.toFixed(2)}%`
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '50px' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!statistics) {
|
||||
return (
|
||||
<Card>
|
||||
<div style={{ textAlign: 'center', padding: '50px' }}>
|
||||
<p>暂无统计数据</p>
|
||||
<Button onClick={() => navigate('/copy-trading')}>返回列表</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Button icon={<LeftOutlined />} onClick={() => navigate('/copy-trading')}>
|
||||
返回
|
||||
</Button>
|
||||
<h2 style={{ margin: 0 }}>跟单关系统计</h2>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button onClick={() => navigate(`/copy-trading/orders/buy/${copyTradingId}`)}>
|
||||
买入订单
|
||||
</Button>
|
||||
<Button onClick={() => navigate(`/copy-trading/orders/sell/${copyTradingId}`)}>
|
||||
卖出订单
|
||||
</Button>
|
||||
<Button onClick={() => navigate(`/copy-trading/orders/matched/${copyTradingId}`)}>
|
||||
匹配关系
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 基本信息卡片 */}
|
||||
<Card title="基本信息" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<div>
|
||||
<div style={{ color: '#999', fontSize: 14, marginBottom: 4 }}>账户名称</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{statistics.accountName || `账户 ${statistics.accountId}`}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<div>
|
||||
<div style={{ color: '#999', fontSize: 14, marginBottom: 4 }}>Leader 名称</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{statistics.leaderName || `Leader ${statistics.leaderId}`}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<div>
|
||||
<div style={{ color: '#999', fontSize: 14, marginBottom: 4 }}>模板名称</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{statistics.templateName || `模板 ${statistics.templateId}`}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<div>
|
||||
<div style={{ color: '#999', fontSize: 14, marginBottom: 4 }}>跟单状态</div>
|
||||
<div>
|
||||
<Tag color={statistics.enabled ? 'green' : 'red'}>
|
||||
{statistics.enabled ? '启用' : '禁用'}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 买入统计卡片 */}
|
||||
<Card title="买入统计" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Statistic
|
||||
title="总买入数量"
|
||||
value={formatUSDC(statistics.totalBuyQuantity)}
|
||||
suffix=""
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Statistic
|
||||
title="总买入金额"
|
||||
value={formatUSDC(statistics.totalBuyAmount)}
|
||||
suffix="USDC"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Statistic
|
||||
title="总买入订单数"
|
||||
value={statistics.totalBuyOrders}
|
||||
suffix="笔"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Statistic
|
||||
title="平均买入价格"
|
||||
value={formatUSDC(statistics.avgBuyPrice)}
|
||||
suffix=""
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 卖出统计卡片 */}
|
||||
<Card title="卖出统计" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Statistic
|
||||
title="总卖出数量"
|
||||
value={formatUSDC(statistics.totalSellQuantity)}
|
||||
suffix=""
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Statistic
|
||||
title="总卖出金额"
|
||||
value={formatUSDC(statistics.totalSellAmount)}
|
||||
suffix="USDC"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Statistic
|
||||
title="总卖出订单数"
|
||||
value={statistics.totalSellOrders}
|
||||
suffix="笔"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 持仓统计卡片 */}
|
||||
<Card title="持仓统计" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Statistic
|
||||
title="当前持仓数量"
|
||||
value={formatUSDC(statistics.currentPositionQuantity)}
|
||||
suffix=""
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Statistic
|
||||
title="当前持仓价值"
|
||||
value={formatUSDC(statistics.currentPositionValue)}
|
||||
suffix="USDC"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Statistic
|
||||
title="平均买入价格"
|
||||
value={formatUSDC(statistics.avgBuyPrice)}
|
||||
suffix=""
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 盈亏统计卡片 */}
|
||||
<Card title="盈亏统计">
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Statistic
|
||||
title="总已实现盈亏"
|
||||
value={formatUSDC(statistics.totalRealizedPnl)}
|
||||
valueStyle={{ color: getPnlColor(statistics.totalRealizedPnl) }}
|
||||
prefix={getPnlIcon(statistics.totalRealizedPnl)}
|
||||
suffix="USDC"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Statistic
|
||||
title="总未实现盈亏"
|
||||
value={formatUSDC(statistics.totalUnrealizedPnl)}
|
||||
valueStyle={{ color: getPnlColor(statistics.totalUnrealizedPnl) }}
|
||||
prefix={getPnlIcon(statistics.totalUnrealizedPnl)}
|
||||
suffix="USDC"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Statistic
|
||||
title="总盈亏"
|
||||
value={formatUSDC(statistics.totalPnl)}
|
||||
valueStyle={{ color: getPnlColor(statistics.totalPnl) }}
|
||||
prefix={getPnlIcon(statistics.totalPnl)}
|
||||
suffix="USDC"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Statistic
|
||||
title="总盈亏百分比"
|
||||
value={formatPercent(statistics.totalPnlPercent)}
|
||||
valueStyle={{ color: getPnlColor(statistics.totalPnlPercent) }}
|
||||
prefix={getPnlIcon(statistics.totalPnlPercent)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CopyTradingStatisticsPage
|
||||
|
||||
@@ -284,7 +284,24 @@ export const apiService = {
|
||||
* 获取分类统计
|
||||
*/
|
||||
category: (data: { category: string; startTime?: number; endTime?: number }) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/statistics/category', data)
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/statistics/category', data),
|
||||
|
||||
/**
|
||||
* 获取跟单关系统计详情
|
||||
*/
|
||||
detail: (data: { copyTradingId: number }) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/statistics/detail', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 订单跟踪 API
|
||||
*/
|
||||
orderTracking: {
|
||||
/**
|
||||
* 查询订单列表(买入/卖出/匹配)
|
||||
*/
|
||||
list: (data: any) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/orders/tracking', data)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -496,3 +496,108 @@ export interface RedeemablePositionsSummary {
|
||||
positions: RedeemablePositionInfo[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 跟单关系统计信息
|
||||
*/
|
||||
export interface CopyTradingStatistics {
|
||||
copyTradingId: number
|
||||
accountId: number
|
||||
accountName: string | null
|
||||
leaderId: number
|
||||
leaderName: string | null
|
||||
templateId: number
|
||||
templateName: string | null
|
||||
enabled: boolean
|
||||
|
||||
// 买入统计
|
||||
totalBuyQuantity: string
|
||||
totalBuyOrders: number
|
||||
totalBuyAmount: string
|
||||
avgBuyPrice: string
|
||||
|
||||
// 卖出统计
|
||||
totalSellQuantity: string
|
||||
totalSellOrders: number
|
||||
totalSellAmount: string
|
||||
|
||||
// 持仓统计
|
||||
currentPositionQuantity: string
|
||||
currentPositionValue: string
|
||||
|
||||
// 盈亏统计
|
||||
totalRealizedPnl: string
|
||||
totalUnrealizedPnl: string
|
||||
totalPnl: string
|
||||
totalPnlPercent: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 买入订单信息
|
||||
*/
|
||||
export interface BuyOrderInfo {
|
||||
orderId: string
|
||||
leaderTradeId: string
|
||||
marketId: string
|
||||
side: string
|
||||
quantity: string
|
||||
price: string
|
||||
amount: string
|
||||
matchedQuantity: string
|
||||
remainingQuantity: string
|
||||
status: 'filled' | 'partially_matched' | 'fully_matched'
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 卖出订单信息
|
||||
*/
|
||||
export interface SellOrderInfo {
|
||||
orderId: string
|
||||
leaderTradeId: string
|
||||
marketId: string
|
||||
side: string
|
||||
quantity: string
|
||||
price: string
|
||||
amount: string
|
||||
realizedPnl: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配订单信息
|
||||
*/
|
||||
export interface MatchedOrderInfo {
|
||||
sellOrderId: string
|
||||
buyOrderId: string
|
||||
matchedQuantity: string
|
||||
buyPrice: string
|
||||
sellPrice: string
|
||||
realizedPnl: string
|
||||
matchedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单跟踪列表响应
|
||||
*/
|
||||
export interface OrderTrackingListResponse {
|
||||
list: BuyOrderInfo[] | SellOrderInfo[] | MatchedOrderInfo[]
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单跟踪查询请求
|
||||
*/
|
||||
export interface OrderTrackingRequest {
|
||||
copyTradingId: number
|
||||
type: 'buy' | 'sell' | 'matched'
|
||||
page?: number
|
||||
limit?: number
|
||||
marketId?: string
|
||||
side?: string
|
||||
status?: string
|
||||
sellOrderId?: string
|
||||
buyOrderId?: string
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user