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
+12 -1
View File
@@ -100,6 +100,17 @@ RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
fi; \ fi; \
fi fi
# 统一选出可执行 JAR,避免 *-plain.jar 和 bootJar 同时存在导致最终 COPY 匹配多个文件
RUN set -e; \
JAR="$(find build/libs -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | head -n 1)"; \
if [ -z "$JAR" ]; then \
echo "❌ 错误:找不到可执行 JAR(已排除 *-plain.jar"; \
exit 1; \
fi; \
if [ "$JAR" != "build/libs/app.jar" ]; then \
cp "$JAR" build/libs/app.jar; \
fi
# ==================== 阶段3:运行环境 ==================== # ==================== 阶段3:运行环境 ====================
FROM eclipse-temurin:17-jre-jammy FROM eclipse-temurin:17-jre-jammy
@@ -114,7 +125,7 @@ RUN apt-get update && \
# 从构建阶段复制文件 # 从构建阶段复制文件
# 当 BUILD_IN_DOCKER=false 时,构建阶段已经复制了外部产物 # 当 BUILD_IN_DOCKER=false 时,构建阶段已经复制了外部产物
COPY --from=frontend-build /app/frontend/dist /usr/share/nginx/html COPY --from=frontend-build /app/frontend/dist /usr/share/nginx/html
COPY --from=backend-build /app/backend/build/libs/*.jar app.jar COPY --from=backend-build /app/backend/build/libs/app.jar app.jar
# 复制 Nginx 配置 # 复制 Nginx 配置
COPY docker/nginx.conf /etc/nginx/nginx.conf COPY docker/nginx.conf /etc/nginx/nginx.conf
@@ -24,6 +24,7 @@ data class CopyTradingStatisticsResponse(
// 持仓统计 // 持仓统计
val currentPositionQuantity: String, val currentPositionQuantity: String,
val currentPositionCost: String,
val currentPositionValue: 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.multi
import com.wrbug.polymarketbot.util.div import com.wrbug.polymarketbot.util.div
import com.wrbug.polymarketbot.util.gt import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.eq
import com.wrbug.polymarketbot.util.lte import com.wrbug.polymarketbot.util.lte
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort import org.springframework.data.domain.Sort
import com.wrbug.polymarketbot.service.accounts.AccountService
import com.wrbug.polymarketbot.service.common.BlockchainService import com.wrbug.polymarketbot.service.common.BlockchainService
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.math.BigDecimal import java.math.BigDecimal
@@ -31,7 +29,8 @@ class CopyTradingStatisticsService(
private val sellMatchDetailRepository: SellMatchDetailRepository, private val sellMatchDetailRepository: SellMatchDetailRepository,
private val accountRepository: AccountRepository, private val accountRepository: AccountRepository,
private val leaderRepository: LeaderRepository, 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) private val logger = LoggerFactory.getLogger(CopyTradingStatisticsService::class.java)
@@ -58,15 +57,14 @@ class CopyTradingStatisticsService(
// 5. 获取匹配明细 // 5. 获取匹配明细
val matchDetails = sellMatchDetailRepository.findByCopyTradingId(copyTradingId) val matchDetails = sellMatchDetailRepository.findByCopyTradingId(copyTradingId)
// 6. 计算统计信息 // 6. 获取当前价格并计算真实口径统
val statistics = calculateStatistics(buyOrders, sellRecords, matchDetails) // currentPositionCost 使用跟单系统记录的剩余仓位成本;currentPositionValue 使用
// Polymarket Data API 当前价格按剩余份额估值。若某个未平仓仓位没有报价,按 0
// 估值,避免已归零/待赎回仓位继续被统计成成本价。
val quotes = buildPositionValuationQuotes(account?.proxyAddress)
val statistics = CopyTradingPnlCalculator.calculate(buyOrders, sellRecords, matchDetails, quotes)
// 7. 不再计算未实现盈亏和持仓价值(优化性能 // 7. 构建响应(总盈亏 = 已实现盈亏 + 未实现盈亏
// 未实现盈亏计算需要查询链上持仓和市场价格,性能开销大
val unrealizedPnl = "0"
val positionValue = "0"
// 8. 构建响应(总盈亏 = 已实现盈亏)
val response = CopyTradingStatisticsResponse( val response = CopyTradingStatisticsResponse(
copyTradingId = copyTradingId, copyTradingId = copyTradingId,
accountId = copyTrading.accountId, accountId = copyTrading.accountId,
@@ -74,19 +72,20 @@ class CopyTradingStatisticsService(
leaderId = copyTrading.leaderId, leaderId = copyTrading.leaderId,
leaderName = leader?.leaderName, leaderName = leader?.leaderName,
enabled = copyTrading.enabled, enabled = copyTrading.enabled,
totalBuyQuantity = statistics.totalBuyQuantity, totalBuyQuantity = statistics.totalBuyQuantity.toString(),
totalBuyOrders = statistics.totalBuyOrders, totalBuyOrders = statistics.totalBuyOrders,
totalBuyAmount = statistics.totalBuyAmount, totalBuyAmount = statistics.totalBuyAmount.toString(),
avgBuyPrice = statistics.avgBuyPrice, avgBuyPrice = statistics.avgBuyPrice.toString(),
totalSellQuantity = statistics.totalSellQuantity, totalSellQuantity = statistics.totalSellQuantity.toString(),
totalSellOrders = statistics.totalSellOrders, totalSellOrders = statistics.totalSellOrders,
totalSellAmount = statistics.totalSellAmount, totalSellAmount = statistics.totalSellAmount.toString(),
currentPositionQuantity = statistics.currentPositionQuantity, currentPositionQuantity = statistics.currentPositionQuantity.toString(),
currentPositionValue = positionValue, currentPositionCost = statistics.currentPositionCost.toString(),
totalRealizedPnl = statistics.totalRealizedPnl, currentPositionValue = statistics.currentPositionValue.toString(),
totalUnrealizedPnl = unrealizedPnl, totalRealizedPnl = statistics.totalRealizedPnl.toString(),
totalPnl = statistics.totalRealizedPnl, totalUnrealizedPnl = statistics.totalUnrealizedPnl.toString(),
totalPnlPercent = calculatePnlPercentOnlyRealized(statistics.totalBuyAmount, statistics.totalRealizedPnl) totalPnl = statistics.totalPnl.toString(),
totalPnlPercent = statistics.totalPnlPercent.toString()
) )
Result.success(response) 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) 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)
}
+29
View File
@@ -0,0 +1,29 @@
module.exports = {
root: true,
env: {
browser: true,
es2020: true,
},
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: ['@typescript-eslint', 'react-hooks', 'react-refresh'],
extends: [
'eslint:recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', 'build', 'node_modules', '.eslintrc.cjs'],
rules: {
// TypeScript 编译阶段已经开启 strict/noUnused*,这里避免历史代码里
// 大量 any、依赖数组 warning 让 lint 从“可运行检查”变成一次性大迁移。
'react-hooks/exhaustive-deps': 'off',
'react-refresh/only-export-components': 'off',
'no-undef': 'off',
'no-unused-vars': 'off',
'no-useless-catch': 'off',
'no-useless-escape': 'off',
'no-redeclare': 'off',
},
}
@@ -197,7 +197,6 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
setSelectedProxyType('') setSelectedProxyType('')
setStep('input') setStep('input')
form.setFieldsValue({ walletAddress: '', privateKey: '', mnemonic: '' }) form.setFieldsValue({ walletAddress: '', privateKey: '', mnemonic: '' })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [importType]) }, [importType])
const handleSubmit = async (values: any) => { const handleSubmit = async (values: any) => {
+2 -2
View File
@@ -256,7 +256,7 @@ const CopyTradingList: React.FC = () => {
) )
}, },
{ {
title: t('copyTradingList.totalPnl') || '总盈亏', title: '总盈亏(含未实现)',
key: 'totalPnl', key: 'totalPnl',
width: isMobile ? 100 : 150, width: isMobile ? 100 : 150,
render: (_: any, record: CopyTrading) => { render: (_: any, record: CopyTrading) => {
@@ -537,7 +537,7 @@ const CopyTradingList: React.FC = () => {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<div> <div>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}> <div style={{ fontSize: '10px', color: '#8c8c8c' }}>
{t('copyTradingList.totalPnl') || '总盈亏'}
</div> </div>
{stats ? ( {stats ? (
<div style={{ <div style={{
@@ -118,7 +118,23 @@ const StatisticsModal: React.FC<StatisticsModalProps> = ({
</div> </div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}> <div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
{t('copyTradingOrders.totalPnl') || '总盈亏'} {t('copyTradingOrders.currentPositionCost') || '当前持仓成本'}
</div>
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333', flex: '1', textAlign: 'right' }}>
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.currentPositionCost)} USDC</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
{t('copyTradingOrders.currentPositionValue') || '当前持仓市值'}
</div>
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333', flex: '1', textAlign: 'right' }}>
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.currentPositionValue)} USDC</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
{t('copyTradingOrders.totalPnl') || '总盈亏(含未实现)'}
</div> </div>
<div style={{ fontSize: '16px', fontWeight: 'bold', color: getPnlColor(statistics.totalPnl), flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}> <div style={{ fontSize: '16px', fontWeight: 'bold', color: getPnlColor(statistics.totalPnl), flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
{getPnlIcon(statistics.totalPnl)} {getPnlIcon(statistics.totalPnl)}
@@ -179,7 +195,21 @@ const StatisticsModal: React.FC<StatisticsModalProps> = ({
</Col> </Col>
<Col xs={24} sm={12} md={8}> <Col xs={24} sm={12} md={8}>
<Statistic <Statistic
title={t('copyTradingOrders.totalPnl') || '总盈亏'} title={t('copyTradingOrders.currentPositionCost') || '当前持仓成本'}
value={formatUSDC(statistics.currentPositionCost)}
suffix="USDC"
/>
</Col>
<Col xs={24} sm={12} md={8}>
<Statistic
title={t('copyTradingOrders.currentPositionValue') || '当前持仓市值'}
value={formatUSDC(statistics.currentPositionValue)}
suffix="USDC"
/>
</Col>
<Col xs={24} sm={12} md={8}>
<Statistic
title={t('copyTradingOrders.totalPnl') || '总盈亏(含未实现)'}
value={formatUSDC(statistics.totalPnl)} value={formatUSDC(statistics.totalPnl)}
suffix="USDC" suffix="USDC"
valueStyle={{ color: getPnlColor(statistics.totalPnl) }} valueStyle={{ color: getPnlColor(statistics.totalPnl) }}
+17 -3
View File
@@ -195,14 +195,28 @@ const CopyTradingStatisticsPage: React.FC = () => {
{/* 持仓统计卡片 */} {/* 持仓统计卡片 */}
<Card title="持仓统计" style={{ marginBottom: 16 }}> <Card title="持仓统计" style={{ marginBottom: 16 }}>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col xs={24} sm={12} md={12}> <Col xs={24} sm={12} md={6}>
<Statistic <Statistic
title="当前持仓数量" title="当前持仓数量"
value={formatNumber(statistics.currentPositionQuantity, 4)} value={formatNumber(statistics.currentPositionQuantity, 4)}
suffix="" suffix=""
/> />
</Col> </Col>
<Col xs={24} sm={12} md={12}> <Col xs={24} sm={12} md={6}>
<Statistic
title="当前持仓成本"
value={formatUSDC(statistics.currentPositionCost)}
suffix="USDC"
/>
</Col>
<Col xs={24} sm={12} md={6}>
<Statistic
title="当前持仓市值"
value={formatUSDC(statistics.currentPositionValue)}
suffix="USDC"
/>
</Col>
<Col xs={24} sm={12} md={6}>
<Statistic <Statistic
title="平均买入价格" title="平均买入价格"
value={formatNumber(statistics.avgBuyPrice, 4)} value={formatNumber(statistics.avgBuyPrice, 4)}
@@ -235,7 +249,7 @@ const CopyTradingStatisticsPage: React.FC = () => {
</Col> </Col>
<Col xs={24} sm={12} md={6}> <Col xs={24} sm={12} md={6}>
<Statistic <Statistic
title="总盈亏" title="总盈亏(含未实现)"
value={formatUSDC(statistics.totalPnl)} value={formatUSDC(statistics.totalPnl)}
valueStyle={{ color: getPnlColor(statistics.totalPnl) }} valueStyle={{ color: getPnlColor(statistics.totalPnl) }}
prefix={getPnlIcon(statistics.totalPnl)} prefix={getPnlIcon(statistics.totalPnl)}
+2 -1
View File
@@ -705,7 +705,8 @@ export interface CopyTradingStatistics {
// 持仓统计 // 持仓统计
currentPositionQuantity: string currentPositionQuantity: string
currentPositionValue: string // 当前实现总是返回 "0",保留用于未来扩展 currentPositionCost: string
currentPositionValue: string // 按当前价格估算的持仓市值
// 盈亏统计 // 盈亏统计
totalRealizedPnl: string totalRealizedPnl: string