feat: add real copy-trading pnl statistics

This commit is contained in:
codychen123
2026-04-26 15:32:06 +08:00
parent 04b7505094
commit ac1d6d64f2
11 changed files with 425 additions and 106 deletions
@@ -24,6 +24,7 @@ data class CopyTradingStatisticsResponse(
// 持仓统计
val currentPositionQuantity: String,
val currentPositionCost: String,
val currentPositionValue: String,
// 盈亏统计
@@ -0,0 +1,120 @@
package com.wrbug.polymarketbot.service.copytrading.statistics
import com.wrbug.polymarketbot.entity.CopyOrderTracking
import com.wrbug.polymarketbot.entity.SellMatchDetail
import com.wrbug.polymarketbot.entity.SellMatchRecord
import com.wrbug.polymarketbot.util.div
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.lte
import com.wrbug.polymarketbot.util.multi
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import java.math.BigDecimal
import java.math.RoundingMode
/**
* Pure calculator for copy-trading PnL.
*
* The statistics API used to expose totalPnl as realized-only PnL and hard-code
* unrealized PnL/current position value to zero. That makes active or expired
* open positions invisible. This calculator keeps the accounting explicit:
*
* - currentPositionCost: remaining shares at their tracked buy cost
* - currentPositionValue: remaining shares marked by current Polymarket price
* - totalUnrealizedPnl: current value - current cost
* - totalPnl: realized + unrealized
*/
object CopyTradingPnlCalculator {
fun calculate(
buyOrders: List<CopyOrderTracking>,
sellRecords: List<SellMatchRecord>,
matchDetails: List<SellMatchDetail>,
quotes: List<PositionValuationQuote> = emptyList()
): CopyTradingPnlStatistics {
val totalBuyQuantity = buyOrders.sumOf { it.quantity.toSafeBigDecimal() }
val totalBuyAmount = buyOrders.sumOf { it.quantity.toSafeBigDecimal().multi(it.price) }
val totalBuyOrders = buyOrders.size.toLong()
val avgBuyPrice = if (totalBuyQuantity.gt(BigDecimal.ZERO)) {
totalBuyAmount.div(totalBuyQuantity)
} else {
BigDecimal.ZERO
}
val totalSellQuantity = sellRecords.sumOf { it.totalMatchedQuantity.toSafeBigDecimal() }
val totalSellAmount = matchDetails.sumOf { it.matchedQuantity.toSafeBigDecimal().multi(it.sellPrice) }
val totalSellOrders = sellRecords.size.toLong()
val openOrders = buyOrders.filter { it.remainingQuantity.toSafeBigDecimal().gt(BigDecimal.ZERO) }
val currentPositionQuantity = openOrders.sumOf { it.remainingQuantity.toSafeBigDecimal() }
val currentPositionCost = openOrders.sumOf { it.remainingQuantity.toSafeBigDecimal().multi(it.price) }
val currentPositionValue = openOrders.sumOf { order ->
val currentPrice = findQuote(order, quotes)?.currentPrice ?: BigDecimal.ZERO
order.remainingQuantity.toSafeBigDecimal().multi(currentPrice)
}
val totalRealizedPnl = matchDetails.sumOf { it.realizedPnl.toSafeBigDecimal() }
val totalUnrealizedPnl = currentPositionValue.subtract(currentPositionCost)
val totalPnl = totalRealizedPnl.add(totalUnrealizedPnl)
return CopyTradingPnlStatistics(
totalBuyQuantity = totalBuyQuantity,
totalBuyOrders = totalBuyOrders,
totalBuyAmount = totalBuyAmount,
avgBuyPrice = avgBuyPrice,
totalSellQuantity = totalSellQuantity,
totalSellOrders = totalSellOrders,
totalSellAmount = totalSellAmount,
currentPositionQuantity = currentPositionQuantity,
currentPositionCost = currentPositionCost,
currentPositionValue = currentPositionValue,
totalRealizedPnl = totalRealizedPnl,
totalUnrealizedPnl = totalUnrealizedPnl,
totalPnl = totalPnl,
totalPnlPercent = calculatePnlPercent(totalBuyAmount, totalPnl)
)
}
private fun findQuote(
order: CopyOrderTracking,
quotes: List<PositionValuationQuote>
): PositionValuationQuote? {
return quotes.firstOrNull { quote ->
quote.marketId == order.marketId &&
order.outcomeIndex != null &&
quote.outcomeIndex == order.outcomeIndex
} ?: quotes.firstOrNull { quote ->
quote.marketId == order.marketId &&
order.outcomeIndex == null &&
!quote.side.isNullOrBlank() &&
quote.side.equals(order.side, ignoreCase = true)
}
}
private fun calculatePnlPercent(totalBuyAmount: BigDecimal, totalPnl: BigDecimal): BigDecimal {
if (totalBuyAmount.lte(BigDecimal.ZERO)) return BigDecimal.ZERO.setScale(2)
return totalPnl.div(totalBuyAmount).multi(100).setScale(2, RoundingMode.HALF_UP)
}
}
data class PositionValuationQuote(
val marketId: String,
val outcomeIndex: Int?,
val side: String?,
val currentPrice: BigDecimal
)
data class CopyTradingPnlStatistics(
val totalBuyQuantity: BigDecimal,
val totalBuyOrders: Long,
val totalBuyAmount: BigDecimal,
val avgBuyPrice: BigDecimal,
val totalSellQuantity: BigDecimal,
val totalSellOrders: Long,
val totalSellAmount: BigDecimal,
val currentPositionQuantity: BigDecimal,
val currentPositionCost: BigDecimal,
val currentPositionValue: BigDecimal,
val totalRealizedPnl: BigDecimal,
val totalUnrealizedPnl: BigDecimal,
val totalPnl: BigDecimal,
val totalPnlPercent: BigDecimal
)
@@ -7,13 +7,11 @@ import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.multi
import com.wrbug.polymarketbot.util.div
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.eq
import com.wrbug.polymarketbot.util.lte
import org.slf4j.LoggerFactory
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import com.wrbug.polymarketbot.service.accounts.AccountService
import com.wrbug.polymarketbot.service.common.BlockchainService
import org.springframework.stereotype.Service
import java.math.BigDecimal
@@ -31,7 +29,8 @@ class CopyTradingStatisticsService(
private val sellMatchDetailRepository: SellMatchDetailRepository,
private val accountRepository: AccountRepository,
private val leaderRepository: LeaderRepository,
private val marketService: com.wrbug.polymarketbot.service.common.MarketService
private val marketService: com.wrbug.polymarketbot.service.common.MarketService,
private val blockchainService: BlockchainService
) {
private val logger = LoggerFactory.getLogger(CopyTradingStatisticsService::class.java)
@@ -58,15 +57,14 @@ class CopyTradingStatisticsService(
// 5. 获取匹配明细
val matchDetails = sellMatchDetailRepository.findByCopyTradingId(copyTradingId)
// 6. 计算统计信息
val statistics = calculateStatistics(buyOrders, sellRecords, matchDetails)
// 6. 获取当前价格并计算真实口径统
// currentPositionCost 使用跟单系统记录的剩余仓位成本;currentPositionValue 使用
// Polymarket Data API 当前价格按剩余份额估值。若某个未平仓仓位没有报价,按 0
// 估值,避免已归零/待赎回仓位继续被统计成成本价。
val quotes = buildPositionValuationQuotes(account?.proxyAddress)
val statistics = CopyTradingPnlCalculator.calculate(buyOrders, sellRecords, matchDetails, quotes)
// 7. 不再计算未实现盈亏和持仓价值(优化性能
// 未实现盈亏计算需要查询链上持仓和市场价格,性能开销大
val unrealizedPnl = "0"
val positionValue = "0"
// 8. 构建响应(总盈亏 = 已实现盈亏)
// 7. 构建响应(总盈亏 = 已实现盈亏 + 未实现盈亏
val response = CopyTradingStatisticsResponse(
copyTradingId = copyTradingId,
accountId = copyTrading.accountId,
@@ -74,19 +72,20 @@ class CopyTradingStatisticsService(
leaderId = copyTrading.leaderId,
leaderName = leader?.leaderName,
enabled = copyTrading.enabled,
totalBuyQuantity = statistics.totalBuyQuantity,
totalBuyQuantity = statistics.totalBuyQuantity.toString(),
totalBuyOrders = statistics.totalBuyOrders,
totalBuyAmount = statistics.totalBuyAmount,
avgBuyPrice = statistics.avgBuyPrice,
totalSellQuantity = statistics.totalSellQuantity,
totalBuyAmount = statistics.totalBuyAmount.toString(),
avgBuyPrice = statistics.avgBuyPrice.toString(),
totalSellQuantity = statistics.totalSellQuantity.toString(),
totalSellOrders = statistics.totalSellOrders,
totalSellAmount = statistics.totalSellAmount,
currentPositionQuantity = statistics.currentPositionQuantity,
currentPositionValue = positionValue,
totalRealizedPnl = statistics.totalRealizedPnl,
totalUnrealizedPnl = unrealizedPnl,
totalPnl = statistics.totalRealizedPnl,
totalPnlPercent = calculatePnlPercentOnlyRealized(statistics.totalBuyAmount, statistics.totalRealizedPnl)
totalSellAmount = statistics.totalSellAmount.toString(),
currentPositionQuantity = statistics.currentPositionQuantity.toString(),
currentPositionCost = statistics.currentPositionCost.toString(),
currentPositionValue = statistics.currentPositionValue.toString(),
totalRealizedPnl = statistics.totalRealizedPnl.toString(),
totalUnrealizedPnl = statistics.totalUnrealizedPnl.toString(),
totalPnl = statistics.totalPnl.toString(),
totalPnlPercent = statistics.totalPnlPercent.toString()
)
Result.success(response)
@@ -96,6 +95,48 @@ class CopyTradingStatisticsService(
}
}
/**
* 获取账户当前仓位报价,用于给跟单系统中仍有 remainingQuantity 的订单做市值估算。
*
* 注意:报价只用于估值,不直接使用 Data API 的 size/currentValue 汇总;这样可以按
* copyTradingId 归因,避免同一钱包下多个 Leader 或手工仓位混在一起。
*/
private suspend fun buildPositionValuationQuotes(proxyAddress: String?): List<PositionValuationQuote> {
if (proxyAddress.isNullOrBlank()) return emptyList()
return try {
val positionsResult = blockchainService.getPositions(proxyAddress)
if (positionsResult.isFailure) {
logger.warn("获取持仓报价失败: proxyAddress=${proxyAddress.take(10)}..., error=${positionsResult.exceptionOrNull()?.message}")
return emptyList()
}
positionsResult.getOrNull().orEmpty().mapNotNull { position ->
val marketId = position.conditionId?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
val currentPrice = position.curPrice?.toSafeBigDecimal()
?: derivePriceFromPositionValue(position.currentValue, position.size)
?: BigDecimal.ZERO
PositionValuationQuote(
marketId = marketId,
outcomeIndex = position.outcomeIndex,
side = position.outcome,
currentPrice = currentPrice
)
}
} catch (e: Exception) {
logger.warn("获取持仓报价异常: proxyAddress=${proxyAddress.take(10)}..., error=${e.message}", e)
emptyList()
}
}
private fun derivePriceFromPositionValue(currentValue: Double?, size: Double?): BigDecimal? {
val value = currentValue?.toSafeBigDecimal() ?: return null
val quantity = size?.toSafeBigDecimal() ?: return null
if (quantity.lte(BigDecimal.ZERO)) return null
return value.div(quantity)
}
/**
* 查询订单列表
*/
@@ -337,65 +378,6 @@ class CopyTradingStatisticsService(
return Pair(list, total)
}
/**
* 计算统计信息
*/
private fun calculateStatistics(
buyOrders: List<CopyOrderTracking>,
sellRecords: List<SellMatchRecord>,
matchDetails: List<SellMatchDetail>
): StatisticsData {
// 买入统计
val totalBuyQuantity = buyOrders.sumOf { it.quantity.toSafeBigDecimal() }
val totalBuyAmount = buyOrders.sumOf { it.quantity.toSafeBigDecimal().multi(it.price) }
val totalBuyOrders = buyOrders.size.toLong()
val avgBuyPrice = if (totalBuyQuantity.gt(BigDecimal.ZERO)) {
totalBuyAmount.div(totalBuyQuantity)
} else {
BigDecimal.ZERO
}
// 卖出统计
// 使用 SellMatchDetail 计算总卖出金额,确保准确性
// 因为每个明细都记录了准确的匹配数量和卖出价格
val totalSellQuantity = sellRecords.sumOf { it.totalMatchedQuantity.toSafeBigDecimal() }
val totalSellAmount = matchDetails.sumOf { it.matchedQuantity.toSafeBigDecimal().multi(it.sellPrice) }
val totalSellOrders = sellRecords.size.toLong()
// 持仓统计
val currentPositionQuantity = buyOrders.sumOf { it.remainingQuantity.toSafeBigDecimal() }
// 已实现盈亏
val totalRealizedPnl = matchDetails.sumOf { it.realizedPnl.toSafeBigDecimal() }
return StatisticsData(
totalBuyQuantity = totalBuyQuantity.toString(),
totalBuyOrders = totalBuyOrders,
totalBuyAmount = totalBuyAmount.toString(),
avgBuyPrice = avgBuyPrice.toString(),
totalSellQuantity = totalSellQuantity.toString(),
totalSellOrders = totalSellOrders,
totalSellAmount = totalSellAmount.toString(),
currentPositionQuantity = currentPositionQuantity.toString(),
totalRealizedPnl = totalRealizedPnl.toString()
)
}
/**
* 计算盈亏百分比(仅基于已实现盈亏)
*/
private fun calculatePnlPercentOnlyRealized(
totalBuyAmount: String,
totalRealizedPnl: String
): String {
val buyAmount = totalBuyAmount.toSafeBigDecimal()
if (buyAmount.lte(BigDecimal.ZERO)) return "0"
val percent = totalRealizedPnl.toSafeBigDecimal().div(buyAmount).multi(100)
return percent.setScale(2, RoundingMode.HALF_UP).toString()
}
/**
* 获取全局统计
*/
@@ -556,21 +538,6 @@ class CopyTradingStatisticsService(
)
}
/**
* 统计数据结构
*/
private data class StatisticsData(
val totalBuyQuantity: String,
val totalBuyOrders: Long,
val totalBuyAmount: String,
val avgBuyPrice: String,
val totalSellQuantity: String,
val totalSellOrders: Long,
val totalSellAmount: String,
val currentPositionQuantity: String,
val totalRealizedPnl: String
)
/**
* 获取按市场分组的买入订单列表
*/
@@ -0,0 +1,147 @@
package com.wrbug.polymarketbot.service.copytrading.statistics
import com.wrbug.polymarketbot.entity.CopyOrderTracking
import com.wrbug.polymarketbot.entity.SellMatchDetail
import com.wrbug.polymarketbot.entity.SellMatchRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.math.BigDecimal
class CopyTradingPnlCalculatorTest {
@Test
fun `marks open positions with current prices and combines realized and unrealized pnl`() {
val buyOrders = listOf(
buyOrder(
id = 1,
marketId = "market-a",
outcomeIndex = 0,
quantity = "10",
price = "0.60",
matchedQuantity = "6",
remainingQuantity = "4"
),
buyOrder(
id = 2,
marketId = "market-b",
outcomeIndex = 1,
quantity = "5",
price = "0.20",
matchedQuantity = "0",
remainingQuantity = "5"
)
)
val sellRecords = listOf(
sellRecord(quantity = "6", price = "0.85", pnl = "1.50")
)
val matchDetails = listOf(
matchDetail(trackingId = 1, buyOrderId = "buy-1", quantity = "6", buyPrice = "0.60", sellPrice = "0.85", pnl = "1.50")
)
val quotes = listOf(
PositionValuationQuote(marketId = "market-a", outcomeIndex = 0, side = "0", currentPrice = bd("0.40")),
PositionValuationQuote(marketId = "market-b", outcomeIndex = 1, side = "1", currentPrice = bd("0.05"))
)
val stats = CopyTradingPnlCalculator.calculate(buyOrders, sellRecords, matchDetails, quotes)
assertEquals("3.40", stats.currentPositionCost.toPlainString())
assertEquals("1.85", stats.currentPositionValue.toPlainString())
assertEquals("-1.55", stats.totalUnrealizedPnl.toPlainString())
assertEquals("1.50", stats.totalRealizedPnl.toPlainString())
assertEquals("-0.05", stats.totalPnl.toPlainString())
assertEquals("-0.71", stats.totalPnlPercent.toPlainString())
}
@Test
fun `treats tracked open positions without a quote as zero current value`() {
val buyOrders = listOf(
buyOrder(
id = 1,
marketId = "expired-market",
outcomeIndex = 0,
quantity = "8",
price = "0.25",
matchedQuantity = "0",
remainingQuantity = "8"
)
)
val stats = CopyTradingPnlCalculator.calculate(
buyOrders = buyOrders,
sellRecords = emptyList(),
matchDetails = emptyList(),
quotes = emptyList()
)
assertEquals("2.00", stats.currentPositionCost.toPlainString())
assertEquals("0", stats.currentPositionValue.toPlainString())
assertEquals("-2.00", stats.totalUnrealizedPnl.toPlainString())
assertEquals("-2.00", stats.totalPnl.toPlainString())
assertEquals("-100.00", stats.totalPnlPercent.toPlainString())
}
private fun buyOrder(
id: Long,
marketId: String,
outcomeIndex: Int?,
quantity: String,
price: String,
matchedQuantity: String,
remainingQuantity: String
) = CopyOrderTracking(
id = id,
copyTradingId = 1,
accountId = 1,
leaderId = 1,
marketId = marketId,
side = outcomeIndex?.toString() ?: "YES",
outcomeIndex = outcomeIndex,
buyOrderId = "buy-$id",
leaderBuyTradeId = "leader-buy-$id",
leaderBuyQuantity = null,
quantity = bd(quantity),
price = bd(price),
matchedQuantity = bd(matchedQuantity),
remainingQuantity = bd(remainingQuantity),
status = if (bd(remainingQuantity).signum() == 0) "fully_matched" else "filled",
source = "test",
createdAt = id,
updatedAt = id
)
private fun sellRecord(quantity: String, price: String, pnl: String) = SellMatchRecord(
id = 1,
copyTradingId = 1,
sellOrderId = "sell-1",
leaderSellTradeId = "leader-sell-1",
marketId = "market-a",
side = "0",
outcomeIndex = 0,
totalMatchedQuantity = bd(quantity),
sellPrice = bd(price),
totalRealizedPnl = bd(pnl),
priceUpdated = true,
createdAt = 1
)
private fun matchDetail(
trackingId: Long,
buyOrderId: String,
quantity: String,
buyPrice: String,
sellPrice: String,
pnl: String
) = SellMatchDetail(
id = trackingId,
matchRecordId = 1,
trackingId = trackingId,
buyOrderId = buyOrderId,
matchedQuantity = bd(quantity),
buyPrice = bd(buyPrice),
sellPrice = bd(sellPrice),
realizedPnl = bd(pnl),
createdAt = 1
)
private fun bd(value: String) = BigDecimal(value)
}