feat: add real copy-trading pnl statistics

This commit is contained in:
codychen123
2026-04-26 15:10:58 +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
# 统一选出可执行 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:运行环境 ====================
FROM eclipse-temurin:17-jre-jammy
@@ -114,7 +125,7 @@ RUN apt-get update && \
# 从构建阶段复制文件
# 当 BUILD_IN_DOCKER=false 时,构建阶段已经复制了外部产物
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 配置
COPY docker/nginx.conf /etc/nginx/nginx.conf
@@ -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)
}
+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('')
setStep('input')
form.setFieldsValue({ walletAddress: '', privateKey: '', mnemonic: '' })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [importType])
const handleSubmit = async (values: any) => {
+2 -2
View File
@@ -256,7 +256,7 @@ const CopyTradingList: React.FC = () => {
)
},
{
title: t('copyTradingList.totalPnl') || '总盈亏',
title: '总盈亏(含未实现)',
key: 'totalPnl',
width: isMobile ? 100 : 150,
render: (_: any, record: CopyTrading) => {
@@ -537,7 +537,7 @@ const CopyTradingList: React.FC = () => {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<div>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
{t('copyTradingList.totalPnl') || '总盈亏'}
</div>
{stats ? (
<div style={{
@@ -118,7 +118,23 @@ const StatisticsModal: React.FC<StatisticsModalProps> = ({
</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') || '总盈亏'}
{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 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)}
@@ -179,7 +195,21 @@ const StatisticsModal: React.FC<StatisticsModalProps> = ({
</Col>
<Col xs={24} sm={12} md={8}>
<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)}
suffix="USDC"
valueStyle={{ color: getPnlColor(statistics.totalPnl) }}
+17 -3
View File
@@ -195,14 +195,28 @@ const CopyTradingStatisticsPage: React.FC = () => {
{/* 持仓统计卡片 */}
<Card title="持仓统计" style={{ marginBottom: 16 }}>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} md={12}>
<Col xs={24} sm={12} md={6}>
<Statistic
title="当前持仓数量"
value={formatNumber(statistics.currentPositionQuantity, 4)}
suffix=""
/>
</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
title="平均买入价格"
value={formatNumber(statistics.avgBuyPrice, 4)}
@@ -235,7 +249,7 @@ const CopyTradingStatisticsPage: React.FC = () => {
</Col>
<Col xs={24} sm={12} md={6}>
<Statistic
title="总盈亏"
title="总盈亏(含未实现)"
value={formatUSDC(statistics.totalPnl)}
valueStyle={{ color: getPnlColor(statistics.totalPnl) }}
prefix={getPnlIcon(statistics.totalPnl)}
+2 -1
View File
@@ -705,7 +705,8 @@ export interface CopyTradingStatistics {
// 持仓统计
currentPositionQuantity: string
currentPositionValue: string // 当前实现总是返回 "0",保留用于未来扩展
currentPositionCost: string
currentPositionValue: string // 按当前价格估算的持仓市值
// 盈亏统计
totalRealizedPnl: string