feat(crypto-tail): 加密价差策略收益曲线与交互优化

- 后端: 收益曲线 API (pnl-curve)、CryptoTailStrategyService.getPnlCurve、gt 扩展导入
- 前端: CryptoTailPnlCurveModal 弹窗,统计卡片+时间筛选+ECharts 累计收益图
- 策略列表: 桌面/移动端「收益曲线」入口,图标改为蓝色(#1890ff)
- 切换时间范围保留旧数据避免图表容器卸载导致空白
- 今日/7天/30天用折线、全部用平滑曲线
- 多语言: viewPnlCurve、pnlCurve.* (zh-CN/zh-TW/en)

Made-with: Cursor
This commit is contained in:
WrBug
2026-03-02 17:39:23 +08:00
parent 4ebfacfc21
commit 46e10ebdd8
11 changed files with 568 additions and 74 deletions
@@ -15,6 +15,8 @@ import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitRequest
import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitResponse
import com.wrbug.polymarketbot.dto.CryptoTailManualOrderRequest
import com.wrbug.polymarketbot.dto.CryptoTailManualOrderResponse
import com.wrbug.polymarketbot.dto.CryptoTailPnlCurveRequest
import com.wrbug.polymarketbot.dto.CryptoTailPnlCurveResponse
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyService
@@ -130,6 +132,26 @@ class CryptoTailStrategyController(
}
}
@PostMapping("/pnl-curve")
fun getPnlCurve(@RequestBody request: CryptoTailPnlCurveRequest): ResponseEntity<ApiResponse<CryptoTailPnlCurveResponse>> {
return try {
if (request.strategyId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
}
val result = cryptoTailStrategyService.getPnlCurve(request)
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("查询收益曲线失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("查询收益曲线异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
}
}
@PostMapping("/triggers")
fun getTriggerRecords(@RequestBody request: CryptoTailStrategyTriggerListRequest): ResponseEntity<ApiResponse<CryptoTailStrategyTriggerListResponse>> {
return try {
@@ -167,3 +167,45 @@ data class CryptoTailMarketOptionDto(
val periodStartUnix: Long = 0L,
val endDate: String? = null
)
/**
* 收益曲线请求
* @param strategyId 策略ID
* @param startDate 开始时间(毫秒时间戳),null 表示不限制
* @param endDate 结束时间(毫秒时间戳),null 表示不限制
*/
data class CryptoTailPnlCurveRequest(
val strategyId: Long = 0L,
val startDate: Long? = null,
val endDate: Long? = null
)
/**
* 收益曲线单点数据
*/
data class CryptoTailPnlCurvePoint(
/** 时间点(毫秒时间戳,结算时间或创建时间) */
val timestamp: Long = 0L,
/** 累计收益 USDC */
val cumulativePnl: String = "0",
/** 当笔收益 USDC */
val pointPnl: String = "0",
/** 截至该点累计已结算笔数 */
val settledCount: Long = 0L
)
/**
* 收益曲线响应
*/
data class CryptoTailPnlCurveResponse(
val strategyId: Long = 0L,
val strategyName: String = "",
/** 筛选范围内总已实现收益 USDC */
val totalRealizedPnl: String = "0",
val settledCount: Long = 0L,
val winCount: Long = 0L,
val winRate: String? = null,
/** 最大回撤 USDC(正数表示回撤幅度) */
val maxDrawdown: String? = null,
val curveData: List<CryptoTailPnlCurvePoint> = emptyList()
)
@@ -40,4 +40,16 @@ interface CryptoTailStrategyTriggerRepository : JpaRepository<CryptoTailStrategy
/** 策略已结算中赢的笔数(outcome_index = winner_outcome_index */
@Query("SELECT COUNT(t) FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true AND t.outcomeIndex = t.winnerOutcomeIndex")
fun countWinsByStrategyId(@Param("strategyId") strategyId: Long): Long
/** 收益曲线:已结算记录,按结算时间(无则创建时间)在区间内升序 */
@Query(
"SELECT t FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true " +
"AND COALESCE(t.settledAt, t.createdAt) >= :start AND COALESCE(t.settledAt, t.createdAt) <= :end " +
"ORDER BY COALESCE(t.settledAt, t.createdAt) ASC"
)
fun findResolvedByStrategyIdAndTimeRangeOrderBySettledAsc(
@Param("strategyId") strategyId: Long,
@Param("start") start: Long,
@Param("end") end: Long
): List<CryptoTailStrategyTrigger>
}
@@ -9,6 +9,7 @@ import com.wrbug.polymarketbot.enums.SpreadDirection
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import org.springframework.context.ApplicationEventPublisher
@@ -220,6 +221,61 @@ class CryptoTailStrategyService(
}
}
fun getPnlCurve(request: CryptoTailPnlCurveRequest): Result<CryptoTailPnlCurveResponse> {
return try {
val strategy = strategyRepository.findById(request.strategyId).orElse(null)
?: return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey))
val start = request.startDate ?: 0L
val end = request.endDate ?: Long.MAX_VALUE
val triggers = triggerRepository.findResolvedByStrategyIdAndTimeRangeOrderBySettledAsc(
request.strategyId, start, end
)
var cumulative = BigDecimal.ZERO
var peak = BigDecimal.ZERO
var maxDrawdown = BigDecimal.ZERO
var winCountInRange = 0L
val curveData = triggers.map { t ->
val pnl = t.realizedPnl ?: BigDecimal.ZERO
cumulative = cumulative.add(pnl)
if (cumulative.gt(peak)) peak = cumulative
val drawdown = peak.subtract(cumulative)
if (drawdown.gt(maxDrawdown)) maxDrawdown = drawdown
if (t.winnerOutcomeIndex != null && t.outcomeIndex == t.winnerOutcomeIndex) winCountInRange++
val ts = t.settledAt ?: t.createdAt
CryptoTailPnlCurvePoint(
timestamp = ts,
cumulativePnl = cumulative.toPlainString(),
pointPnl = pnl.toPlainString(),
settledCount = 0L
)
}.mapIndexed { index, p ->
p.copy(settledCount = (index + 1).toLong())
}
val totalPnl = if (curveData.isEmpty()) BigDecimal.ZERO else curveData.last().cumulativePnl.toSafeBigDecimal()
val settledCountInRange = curveData.size.toLong()
val winRateStr = if (settledCountInRange > 0L) {
BigDecimal(winCountInRange).divide(BigDecimal(settledCountInRange), 4, java.math.RoundingMode.HALF_UP).toPlainString()
} else null
Result.success(
CryptoTailPnlCurveResponse(
strategyId = request.strategyId,
strategyName = strategy.name ?: strategy.marketSlugPrefix,
totalRealizedPnl = totalPnl.toPlainString(),
settledCount = settledCountInRange,
winCount = winCountInRange,
winRate = winRateStr,
maxDrawdown = if (maxDrawdown.compareTo(BigDecimal.ZERO) > 0) maxDrawdown.toPlainString() else null,
curveData = curveData
)
)
} catch (e: IllegalArgumentException) {
Result.failure(e)
} catch (e: Exception) {
logger.error("查询收益曲线失败: ${e.message}", e)
Result.failure(e)
}
}
fun getTriggerRecords(request: CryptoTailStrategyTriggerListRequest): Result<CryptoTailStrategyTriggerListResponse> {
return try {
val page = PageRequest.of((request.page - 1).coerceAtLeast(0), request.pageSize.coerceIn(1, 100))