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:
+22
@@ -15,6 +15,8 @@ import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitRequest
|
|||||||
import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitResponse
|
import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitResponse
|
||||||
import com.wrbug.polymarketbot.dto.CryptoTailManualOrderRequest
|
import com.wrbug.polymarketbot.dto.CryptoTailManualOrderRequest
|
||||||
import com.wrbug.polymarketbot.dto.CryptoTailManualOrderResponse
|
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.enums.ErrorCode
|
||||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
|
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
|
||||||
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyService
|
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")
|
@PostMapping("/triggers")
|
||||||
fun getTriggerRecords(@RequestBody request: CryptoTailStrategyTriggerListRequest): ResponseEntity<ApiResponse<CryptoTailStrategyTriggerListResponse>> {
|
fun getTriggerRecords(@RequestBody request: CryptoTailStrategyTriggerListRequest): ResponseEntity<ApiResponse<CryptoTailStrategyTriggerListResponse>> {
|
||||||
return try {
|
return try {
|
||||||
|
|||||||
@@ -167,3 +167,45 @@ data class CryptoTailMarketOptionDto(
|
|||||||
val periodStartUnix: Long = 0L,
|
val periodStartUnix: Long = 0L,
|
||||||
val endDate: String? = null
|
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()
|
||||||
|
)
|
||||||
|
|||||||
+12
@@ -40,4 +40,16 @@ interface CryptoTailStrategyTriggerRepository : JpaRepository<CryptoTailStrategy
|
|||||||
/** 策略已结算中赢的笔数(outcome_index = winner_outcome_index) */
|
/** 策略已结算中赢的笔数(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")
|
@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
|
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>
|
||||||
}
|
}
|
||||||
|
|||||||
+56
@@ -9,6 +9,7 @@ import com.wrbug.polymarketbot.enums.SpreadDirection
|
|||||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
||||||
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
|
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
|
||||||
|
import com.wrbug.polymarketbot.util.gt
|
||||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
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> {
|
fun getTriggerRecords(request: CryptoTailStrategyTriggerListRequest): Result<CryptoTailStrategyTriggerListResponse> {
|
||||||
return try {
|
return try {
|
||||||
val page = PageRequest.of((request.page - 1).coerceAtLeast(0), request.pageSize.coerceIn(1, 100))
|
val page = PageRequest.of((request.page - 1).coerceAtLeast(0), request.pageSize.coerceIn(1, 100))
|
||||||
|
|||||||
@@ -1504,7 +1504,9 @@
|
|||||||
"strategyName": "Strategy Name",
|
"strategyName": "Strategy Name",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"market": "Market",
|
"market": "Market",
|
||||||
|
"marketAndTime": "Market / Time",
|
||||||
"timeWindow": "Time Window",
|
"timeWindow": "Time Window",
|
||||||
|
"config": "Config",
|
||||||
"priceRange": "Price Range",
|
"priceRange": "Price Range",
|
||||||
"amountMode": "Amount Mode",
|
"amountMode": "Amount Mode",
|
||||||
"ratio": "Ratio",
|
"ratio": "Ratio",
|
||||||
@@ -1517,6 +1519,7 @@
|
|||||||
"disable": "Disable",
|
"disable": "Disable",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"viewTriggers": "Orders",
|
"viewTriggers": "Orders",
|
||||||
|
"viewPnlCurve": "PnL Curve",
|
||||||
"deleteConfirm": "Delete this strategy?",
|
"deleteConfirm": "Delete this strategy?",
|
||||||
"fetchFailed": "Failed to fetch list",
|
"fetchFailed": "Failed to fetch list",
|
||||||
"configGuide": "Configuration Guide"
|
"configGuide": "Configuration Guide"
|
||||||
@@ -1584,6 +1587,20 @@
|
|||||||
"emptySuccess": "No success records",
|
"emptySuccess": "No success records",
|
||||||
"emptyFail": "No failed records",
|
"emptyFail": "No failed records",
|
||||||
"totalCount": "{count} record(s)"
|
"totalCount": "{count} record(s)"
|
||||||
|
},
|
||||||
|
"pnlCurve": {
|
||||||
|
"title": "PnL Curve",
|
||||||
|
"totalPnl": "Total PnL",
|
||||||
|
"settledCount": "Settled",
|
||||||
|
"winRate": "Win Rate",
|
||||||
|
"maxDrawdown": "Max Drawdown",
|
||||||
|
"timeRange": "Time Range",
|
||||||
|
"today": "Today",
|
||||||
|
"last7Days": "Last 7 Days",
|
||||||
|
"last30Days": "Last 30 Days",
|
||||||
|
"all": "All",
|
||||||
|
"customRange": "Custom",
|
||||||
|
"empty": "No settled orders yet, cannot show PnL curve"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cryptoTailMonitor": {
|
"cryptoTailMonitor": {
|
||||||
|
|||||||
@@ -1504,7 +1504,9 @@
|
|||||||
"strategyName": "策略名称",
|
"strategyName": "策略名称",
|
||||||
"account": "账户",
|
"account": "账户",
|
||||||
"market": "关联市场",
|
"market": "关联市场",
|
||||||
|
"marketAndTime": "市场/时间",
|
||||||
"timeWindow": "时间区间",
|
"timeWindow": "时间区间",
|
||||||
|
"config": "配置",
|
||||||
"priceRange": "价格区间",
|
"priceRange": "价格区间",
|
||||||
"amountMode": "投入方式",
|
"amountMode": "投入方式",
|
||||||
"ratio": "比例",
|
"ratio": "比例",
|
||||||
@@ -1517,6 +1519,7 @@
|
|||||||
"disable": "停用",
|
"disable": "停用",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"viewTriggers": "订单",
|
"viewTriggers": "订单",
|
||||||
|
"viewPnlCurve": "收益曲线",
|
||||||
"deleteConfirm": "确定删除该策略?",
|
"deleteConfirm": "确定删除该策略?",
|
||||||
"fetchFailed": "获取列表失败",
|
"fetchFailed": "获取列表失败",
|
||||||
"configGuide": "配置指南"
|
"configGuide": "配置指南"
|
||||||
@@ -1584,6 +1587,20 @@
|
|||||||
"emptySuccess": "暂无成功记录",
|
"emptySuccess": "暂无成功记录",
|
||||||
"emptyFail": "暂无失败记录",
|
"emptyFail": "暂无失败记录",
|
||||||
"totalCount": "共 {count} 条"
|
"totalCount": "共 {count} 条"
|
||||||
|
},
|
||||||
|
"pnlCurve": {
|
||||||
|
"title": "收益曲线",
|
||||||
|
"totalPnl": "总收益",
|
||||||
|
"settledCount": "已结算笔数",
|
||||||
|
"winRate": "胜率",
|
||||||
|
"maxDrawdown": "最大回撤",
|
||||||
|
"timeRange": "时间范围",
|
||||||
|
"today": "今日",
|
||||||
|
"last7Days": "近7天",
|
||||||
|
"last30Days": "近30天",
|
||||||
|
"all": "全部",
|
||||||
|
"customRange": "自定义",
|
||||||
|
"empty": "暂无已结算订单,无法展示收益曲线"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cryptoTailMonitor": {
|
"cryptoTailMonitor": {
|
||||||
|
|||||||
@@ -1504,7 +1504,9 @@
|
|||||||
"strategyName": "策略名稱",
|
"strategyName": "策略名稱",
|
||||||
"account": "賬戶",
|
"account": "賬戶",
|
||||||
"market": "關聯市場",
|
"market": "關聯市場",
|
||||||
|
"marketAndTime": "市場/時間",
|
||||||
"timeWindow": "時間區間",
|
"timeWindow": "時間區間",
|
||||||
|
"config": "配置",
|
||||||
"priceRange": "價格區間",
|
"priceRange": "價格區間",
|
||||||
"amountMode": "投入方式",
|
"amountMode": "投入方式",
|
||||||
"ratio": "比例",
|
"ratio": "比例",
|
||||||
@@ -1517,6 +1519,7 @@
|
|||||||
"disable": "停用",
|
"disable": "停用",
|
||||||
"delete": "刪除",
|
"delete": "刪除",
|
||||||
"viewTriggers": "訂單",
|
"viewTriggers": "訂單",
|
||||||
|
"viewPnlCurve": "收益曲線",
|
||||||
"deleteConfirm": "確定刪除該策略?",
|
"deleteConfirm": "確定刪除該策略?",
|
||||||
"fetchFailed": "獲取列表失敗",
|
"fetchFailed": "獲取列表失敗",
|
||||||
"configGuide": "配置指南"
|
"configGuide": "配置指南"
|
||||||
@@ -1584,6 +1587,20 @@
|
|||||||
"emptySuccess": "暫無成功記錄",
|
"emptySuccess": "暫無成功記錄",
|
||||||
"emptyFail": "暫無失敗記錄",
|
"emptyFail": "暫無失敗記錄",
|
||||||
"totalCount": "共 {count} 條"
|
"totalCount": "共 {count} 條"
|
||||||
|
},
|
||||||
|
"pnlCurve": {
|
||||||
|
"title": "收益曲線",
|
||||||
|
"totalPnl": "總收益",
|
||||||
|
"settledCount": "已結算筆數",
|
||||||
|
"winRate": "勝率",
|
||||||
|
"maxDrawdown": "最大回撤",
|
||||||
|
"timeRange": "時間範圍",
|
||||||
|
"today": "今日",
|
||||||
|
"last7Days": "近7天",
|
||||||
|
"last30Days": "近30天",
|
||||||
|
"all": "全部",
|
||||||
|
"customRange": "自定義",
|
||||||
|
"empty": "暫無已結算訂單,無法展示收益曲線"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cryptoTailMonitor": {
|
"cryptoTailMonitor": {
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { Modal, Row, Col, Statistic, Button, Space, Empty } from 'antd'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useMediaQuery } from 'react-responsive'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
import type { EChartsOption } from 'echarts'
|
||||||
|
import { formatUSDC } from '../utils'
|
||||||
|
import type { CryptoTailPnlCurveResponse } from '../types'
|
||||||
|
|
||||||
|
export interface CryptoTailPnlCurveModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
data: CryptoTailPnlCurveResponse | null
|
||||||
|
loading: boolean
|
||||||
|
strategyName: string
|
||||||
|
preset: 'today' | '7d' | '30d' | 'all'
|
||||||
|
onPresetChange: (preset: 'today' | '7d' | '30d' | 'all') => void
|
||||||
|
onRefresh: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CryptoTailPnlCurveModal: React.FC<CryptoTailPnlCurveModalProps> = (props) => {
|
||||||
|
const { open, onClose, data, loading, strategyName, preset, onPresetChange, onRefresh } = props
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||||
|
const chartRef = useRef<HTMLDivElement>(null)
|
||||||
|
const chartInstance = useRef<echarts.ECharts | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !data?.curveData?.length || !chartRef.current) return
|
||||||
|
if (chartInstance.current) {
|
||||||
|
const dom = chartInstance.current.getDom()
|
||||||
|
if (!dom || !document.contains(dom)) {
|
||||||
|
chartInstance.current.dispose()
|
||||||
|
chartInstance.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!chartInstance.current) chartInstance.current = echarts.init(chartRef.current)
|
||||||
|
const option: EChartsOption = {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
formatter: (params: unknown) => {
|
||||||
|
const arr = params as Array<{ value: [number, number] }>
|
||||||
|
const v = arr[0]?.value
|
||||||
|
if (!v) return ''
|
||||||
|
const d = data.curveData.find((p) => p.timestamp === v[0])
|
||||||
|
if (!d) return ''
|
||||||
|
return dayjs(v[0]).format('YYYY-MM-DD HH:mm') + '<br/>' + t('cryptoTailStrategy.pnlCurve.totalPnl') + ': ' + formatUSDC(d.cumulativePnl) + ' USDC'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: { left: '3%', right: '4%', bottom: '3%', top: '10%', containLabel: true },
|
||||||
|
xAxis: { type: 'time' },
|
||||||
|
yAxis: { type: 'value', axisLabel: { formatter: (val: number) => String(val) + ' USDC' } },
|
||||||
|
series: [{
|
||||||
|
name: t('cryptoTailStrategy.pnlCurve.totalPnl'),
|
||||||
|
type: 'line',
|
||||||
|
data: data.curveData.map((p) => [p.timestamp, parseFloat(p.cumulativePnl)]),
|
||||||
|
smooth: preset === 'all',
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 4,
|
||||||
|
lineStyle: { width: 2, color: '#1890ff' },
|
||||||
|
areaStyle: {
|
||||||
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: 'rgba(24, 144, 255, 0.3)' },
|
||||||
|
{ offset: 1, color: 'rgba(24, 144, 255, 0.05)' }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
chartInstance.current.setOption(option, true)
|
||||||
|
chartInstance.current.resize()
|
||||||
|
}, [open, data, preset, t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
chartInstance.current?.dispose()
|
||||||
|
chartInstance.current = null
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !chartInstance.current) return
|
||||||
|
const handleResize = () => chartInstance.current?.resize()
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
return () => window.removeEventListener('resize', handleResize)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const pnlColor = (value: string | null | undefined): string | undefined => {
|
||||||
|
if (value == null || value === '') return undefined
|
||||||
|
const num = parseFloat(value)
|
||||||
|
if (Number.isNaN(num)) return undefined
|
||||||
|
if (num > 0) return '#52c41a'
|
||||||
|
if (num < 0) return '#ff4d4f'
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={t('cryptoTailStrategy.pnlCurve.title') + ' - ' + strategyName}
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
width={Math.min(800, window.innerWidth - 48)}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Statistic
|
||||||
|
title={t('cryptoTailStrategy.pnlCurve.totalPnl')}
|
||||||
|
value={data?.totalRealizedPnl != null ? formatUSDC(data.totalRealizedPnl) : '-'}
|
||||||
|
suffix="USDC"
|
||||||
|
valueStyle={{ color: pnlColor(data?.totalRealizedPnl ?? null) }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Statistic title={t('cryptoTailStrategy.pnlCurve.settledCount')} value={data?.settledCount ?? 0} />
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Statistic
|
||||||
|
title={t('cryptoTailStrategy.pnlCurve.winRate')}
|
||||||
|
value={data?.winRate != null ? (Number(data.winRate) * 100).toFixed(1) + '%' : '-'}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Statistic
|
||||||
|
title={t('cryptoTailStrategy.pnlCurve.maxDrawdown')}
|
||||||
|
value={data?.maxDrawdown != null ? '-' + formatUSDC(data.maxDrawdown) : '-'}
|
||||||
|
suffix="USDC"
|
||||||
|
valueStyle={{ color: data?.maxDrawdown ? '#ff4d4f' : undefined }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Space wrap style={{ marginBottom: 16 }}>
|
||||||
|
<Button size="small" type={preset === 'today' ? 'primary' : 'default'} onClick={() => onPresetChange('today')}>{t('cryptoTailStrategy.pnlCurve.today')}</Button>
|
||||||
|
<Button size="small" type={preset === '7d' ? 'primary' : 'default'} onClick={() => onPresetChange('7d')}>{t('cryptoTailStrategy.pnlCurve.last7Days')}</Button>
|
||||||
|
<Button size="small" type={preset === '30d' ? 'primary' : 'default'} onClick={() => onPresetChange('30d')}>{t('cryptoTailStrategy.pnlCurve.last30Days')}</Button>
|
||||||
|
<Button size="small" type={preset === 'all' ? 'primary' : 'default'} onClick={() => onPresetChange('all')}>{t('cryptoTailStrategy.pnlCurve.all')}</Button>
|
||||||
|
<Button size="small" onClick={onRefresh} loading={loading}>{t('common.refresh')}</Button>
|
||||||
|
</Space>
|
||||||
|
{!data && loading
|
||||||
|
? <div style={{ height: 320, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>{t('common.loading')}</div>
|
||||||
|
: !data?.curveData?.length
|
||||||
|
? <Empty description={t('cryptoTailStrategy.pnlCurve.empty')} style={{ marginTop: 24 }} />
|
||||||
|
: <div ref={chartRef} style={{ width: '100%', height: isMobile ? 260 : 320 }} />}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CryptoTailPnlCurveModal
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useRef } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -25,14 +25,15 @@ import {
|
|||||||
} from 'antd'
|
} from 'antd'
|
||||||
import type { Dayjs } from 'dayjs'
|
import type { Dayjs } from 'dayjs'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { PlusOutlined, EditOutlined, UnorderedListOutlined, InfoCircleOutlined, WarningOutlined, CalendarOutlined, FileTextOutlined, DeleteOutlined } from '@ant-design/icons'
|
import { PlusOutlined, EditOutlined, UnorderedListOutlined, LineChartOutlined, InfoCircleOutlined, WarningOutlined, CalendarOutlined, FileTextOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
import { apiService } from '../services/api'
|
import { apiService } from '../services/api'
|
||||||
import { useAccountStore } from '../store/accountStore'
|
import { useAccountStore } from '../store/accountStore'
|
||||||
import type { CryptoTailStrategyDto, CryptoTailStrategyTriggerDto, CryptoTailMarketOptionDto } from '../types'
|
import type { CryptoTailStrategyDto, CryptoTailStrategyTriggerDto, CryptoTailMarketOptionDto, CryptoTailPnlCurveResponse } from '../types'
|
||||||
import { formatUSDC, formatNumber } from '../utils'
|
import { formatUSDC, formatNumber } from '../utils'
|
||||||
import { getVersionInfo } from '../utils/version'
|
import { getVersionInfo } from '../utils/version'
|
||||||
|
import CryptoTailPnlCurveModal from './CryptoTailPnlCurveModal'
|
||||||
|
|
||||||
const CryptoTailStrategyList: React.FC = () => {
|
const CryptoTailStrategyList: React.FC = () => {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
@@ -58,6 +59,14 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
const [triggersLoading, setTriggersLoading] = useState(false)
|
const [triggersLoading, setTriggersLoading] = useState(false)
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
const [pnlCurveModalOpen, setPnlCurveModalOpen] = useState(false)
|
||||||
|
const [pnlCurveStrategyId, setPnlCurveStrategyId] = useState<number | null>(null)
|
||||||
|
const [pnlCurveStrategyName, setPnlCurveStrategyName] = useState('')
|
||||||
|
const [pnlCurvePreset, setPnlCurvePreset] = useState<'today' | '7d' | '30d' | 'all'>('all')
|
||||||
|
const [pnlCurveCustomRange, setPnlCurveCustomRange] = useState<[Dayjs | null, Dayjs | null]>([null, null])
|
||||||
|
const [pnlCurveData, setPnlCurveData] = useState<CryptoTailPnlCurveResponse | null>(null)
|
||||||
|
const [pnlCurveLoading, setPnlCurveLoading] = useState(false)
|
||||||
|
|
||||||
/** 币安 API 健康状态:仅保留「不可用」的项,用于强提醒 */
|
/** 币安 API 健康状态:仅保留「不可用」的项,用于强提醒 */
|
||||||
const [binanceUnhealthy, setBinanceUnhealthy] = useState<Array<{ name: string; message: string }>>([])
|
const [binanceUnhealthy, setBinanceUnhealthy] = useState<Array<{ name: string; message: string }>>([])
|
||||||
const [binanceCheckLoading, setBinanceCheckLoading] = useState(false)
|
const [binanceCheckLoading, setBinanceCheckLoading] = useState(false)
|
||||||
@@ -327,6 +336,61 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
await loadTriggerRecords(strategyId, 'success', { page: 1, pageSize: 20, dateRange: [null, null] })
|
await loadTriggerRecords(strategyId, 'success', { page: 1, pageSize: 20, dateRange: [null, null] })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getPnlCurveTimeRange = (): { startDate?: number; endDate?: number } => {
|
||||||
|
if (pnlCurvePreset === 'all') return {}
|
||||||
|
const now = dayjs()
|
||||||
|
if (pnlCurvePreset === 'today') {
|
||||||
|
return { startDate: now.startOf('day').valueOf(), endDate: now.valueOf() }
|
||||||
|
}
|
||||||
|
if (pnlCurvePreset === '7d') {
|
||||||
|
return { startDate: now.subtract(7, 'day').startOf('day').valueOf(), endDate: now.valueOf() }
|
||||||
|
}
|
||||||
|
if (pnlCurvePreset === '30d') {
|
||||||
|
return { startDate: now.subtract(30, 'day').startOf('day').valueOf(), endDate: now.valueOf() }
|
||||||
|
}
|
||||||
|
if (pnlCurveCustomRange[0] != null && pnlCurveCustomRange[1] != null) {
|
||||||
|
return {
|
||||||
|
startDate: pnlCurveCustomRange[0].startOf('day').valueOf(),
|
||||||
|
endDate: pnlCurveCustomRange[1].endOf('day').valueOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPnlCurve = async () => {
|
||||||
|
if (pnlCurveStrategyId == null) return
|
||||||
|
setPnlCurveLoading(true)
|
||||||
|
try {
|
||||||
|
const { startDate, endDate } = getPnlCurveTimeRange()
|
||||||
|
const res = await apiService.cryptoTailStrategy.pnlCurve({
|
||||||
|
strategyId: pnlCurveStrategyId,
|
||||||
|
startDate,
|
||||||
|
endDate
|
||||||
|
})
|
||||||
|
if (res.data.code === 0 && res.data.data) {
|
||||||
|
setPnlCurveData(res.data.data)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load PnL curve:', e)
|
||||||
|
} finally {
|
||||||
|
setPnlCurveLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPnlCurve = (record: CryptoTailStrategyDto) => {
|
||||||
|
setPnlCurveStrategyId(record.id)
|
||||||
|
setPnlCurveStrategyName(record.name ?? record.marketTitle ?? record.marketSlugPrefix ?? '')
|
||||||
|
setPnlCurvePreset('all')
|
||||||
|
setPnlCurveCustomRange([null, null])
|
||||||
|
setPnlCurveModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pnlCurveModalOpen && pnlCurveStrategyId != null) {
|
||||||
|
loadPnlCurve()
|
||||||
|
}
|
||||||
|
}, [pnlCurveModalOpen, pnlCurveStrategyId, pnlCurvePreset, pnlCurveCustomRange])
|
||||||
|
|
||||||
const onTriggerTabChange = (key: string) => {
|
const onTriggerTabChange = (key: string) => {
|
||||||
const next = key === 'success' ? 'success' : 'fail'
|
const next = key === 'success' ? 'success' : 'fail'
|
||||||
setTriggerTab(next)
|
setTriggerTab(next)
|
||||||
@@ -388,7 +452,7 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
title: t('common.status'),
|
title: t('common.status'),
|
||||||
dataIndex: 'enabled',
|
dataIndex: 'enabled',
|
||||||
key: 'enabled',
|
key: 'enabled',
|
||||||
width: 80,
|
width: 72,
|
||||||
render: (enabled: boolean, record: CryptoTailStrategyDto) => (
|
render: (enabled: boolean, record: CryptoTailStrategyDto) => (
|
||||||
<Switch
|
<Switch
|
||||||
checked={enabled}
|
checked={enabled}
|
||||||
@@ -401,7 +465,8 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
title: t('cryptoTailStrategy.list.strategyName'),
|
title: t('cryptoTailStrategy.list.strategyName'),
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
key: 'name',
|
key: 'name',
|
||||||
width: isMobile ? 100 : 160,
|
width: isMobile ? 100 : 140,
|
||||||
|
ellipsis: true,
|
||||||
render: (name: string | undefined, r: CryptoTailStrategyDto) => (
|
render: (name: string | undefined, r: CryptoTailStrategyDto) => (
|
||||||
<Typography.Text strong style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
<Typography.Text strong style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
||||||
{name || (r.marketTitle ?? r.marketSlugPrefix) || '-'}
|
{name || (r.marketTitle ?? r.marketSlugPrefix) || '-'}
|
||||||
@@ -412,7 +477,8 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
title: t('cryptoTailStrategy.list.account'),
|
title: t('cryptoTailStrategy.list.account'),
|
||||||
dataIndex: 'accountId',
|
dataIndex: 'accountId',
|
||||||
key: 'accountId',
|
key: 'accountId',
|
||||||
width: isMobile ? 90 : 120,
|
width: isMobile ? 90 : 100,
|
||||||
|
ellipsis: true,
|
||||||
render: (_: unknown, r: CryptoTailStrategyDto) => (
|
render: (_: unknown, r: CryptoTailStrategyDto) => (
|
||||||
<Typography.Text type="secondary" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
<Typography.Text type="secondary" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
||||||
{getAccountLabel(r.accountId)}
|
{getAccountLabel(r.accountId)}
|
||||||
@@ -420,99 +486,149 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('cryptoTailStrategy.list.market'),
|
title: t('cryptoTailStrategy.list.marketAndTime'),
|
||||||
key: 'market',
|
key: 'marketAndTime',
|
||||||
width: isMobile ? 120 : 200,
|
width: isMobile ? 120 : 180,
|
||||||
render: (_: unknown, r: CryptoTailStrategyDto) => (
|
render: (_: unknown, r: CryptoTailStrategyDto) => (
|
||||||
<Typography.Text style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
<div>
|
||||||
{marketOptions.find((m) => m.slug === r.marketSlugPrefix)?.title ?? r.marketTitle ?? r.marketSlugPrefix ?? '-'}
|
<Typography.Text style={{ wordBreak: 'break-word', whiteSpace: 'normal', display: 'block' }}>
|
||||||
</Typography.Text>
|
{marketOptions.find((m) => m.slug === r.marketSlugPrefix)?.title ?? r.marketTitle ?? r.marketSlugPrefix ?? '-'}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-word', whiteSpace: 'pre-line' }}>
|
||||||
|
{formatTimeWindow(r.windowStartSeconds, r.windowEndSeconds, false)}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('cryptoTailStrategy.list.timeWindow'),
|
title: t('cryptoTailStrategy.list.config'),
|
||||||
key: 'timeWindow',
|
key: 'config',
|
||||||
width: isMobile ? 100 : 120,
|
width: isMobile ? 100 : 140,
|
||||||
render: (_: unknown, r: CryptoTailStrategyDto) => (
|
render: (_: unknown, r: CryptoTailStrategyDto) => (
|
||||||
<Typography.Text type="secondary" style={{ wordBreak: 'break-word', whiteSpace: 'pre-line' }}>
|
<div>
|
||||||
{formatTimeWindow(r.windowStartSeconds, r.windowEndSeconds)}
|
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block' }}>
|
||||||
</Typography.Text>
|
{formatPriceRange(r.minPrice, r.maxPrice)}
|
||||||
)
|
</Typography.Text>
|
||||||
},
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
{
|
{(r.amountMode?.toUpperCase() ?? '') === 'RATIO'
|
||||||
title: t('cryptoTailStrategy.list.priceRange'),
|
? `${t('cryptoTailStrategy.list.ratio')} ${formatNumber(r.amountValue, 2) || '0'}%`
|
||||||
key: 'priceRange',
|
: `${t('cryptoTailStrategy.list.fixed')} ${formatUSDC(r.amountValue)}`}
|
||||||
width: isMobile ? 90 : 120,
|
</Typography.Text>
|
||||||
render: (_: unknown, r: CryptoTailStrategyDto) => (
|
</div>
|
||||||
<Typography.Text type="secondary" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
|
||||||
{formatPriceRange(r.minPrice, r.maxPrice)}
|
|
||||||
</Typography.Text>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('cryptoTailStrategy.list.amountMode'),
|
|
||||||
key: 'amountMode',
|
|
||||||
width: isMobile ? 90 : 120,
|
|
||||||
render: (_: unknown, r: CryptoTailStrategyDto) => (
|
|
||||||
<Typography.Text type="secondary" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
|
||||||
{(r.amountMode?.toUpperCase() ?? '') === 'RATIO'
|
|
||||||
? `${t('cryptoTailStrategy.list.ratio')} ${formatNumber(r.amountValue, 2) || '0'}%`
|
|
||||||
: `${t('cryptoTailStrategy.list.fixed')} ${formatUSDC(r.amountValue)} USDC`}
|
|
||||||
</Typography.Text>
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('cryptoTailStrategy.list.totalRealizedPnl'),
|
title: t('cryptoTailStrategy.list.totalRealizedPnl'),
|
||||||
key: 'totalRealizedPnl',
|
key: 'pnl',
|
||||||
width: isMobile ? 90 : 120,
|
width: isMobile ? 90 : 120,
|
||||||
render: (_: unknown, r: CryptoTailStrategyDto) => {
|
render: (_: unknown, r: CryptoTailStrategyDto) => {
|
||||||
const text = r.totalRealizedPnl != null ? `${formatUSDC(r.totalRealizedPnl)} USDC` : '-'
|
const text = r.totalRealizedPnl != null ? formatUSDC(r.totalRealizedPnl) : '-'
|
||||||
const color = pnlColor(r.totalRealizedPnl)
|
const color = pnlColor(r.totalRealizedPnl)
|
||||||
return color ? (
|
return (
|
||||||
<Typography.Text style={{ color, fontWeight: 500 }}>{text}</Typography.Text>
|
<div>
|
||||||
) : (
|
{color ? (
|
||||||
<Typography.Text type="secondary">{text}</Typography.Text>
|
<Typography.Text style={{ color, fontWeight: 500 }}>{text}</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">{text}</Typography.Text>
|
||||||
|
)}
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block' }}>
|
||||||
|
{r.winRate != null ? `${(Number(r.winRate) * 100).toFixed(1)}%` : '-'}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: t('cryptoTailStrategy.list.winRate'),
|
|
||||||
key: 'winRate',
|
|
||||||
width: isMobile ? 70 : 90,
|
|
||||||
render: (_: unknown, r: CryptoTailStrategyDto) =>
|
|
||||||
r.winRate != null ? (
|
|
||||||
<Tag color="blue">{(Number(r.winRate) * 100).toFixed(1)}%</Tag>
|
|
||||||
) : (
|
|
||||||
<Typography.Text type="secondary">-</Typography.Text>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: t('cryptoTailStrategy.list.actions'),
|
title: t('cryptoTailStrategy.list.actions'),
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
width: isMobile ? 120 : 200,
|
width: isMobile ? 120 : 140,
|
||||||
fixed: 'right' as const,
|
fixed: 'right' as const,
|
||||||
render: (_: unknown, record: CryptoTailStrategyDto) => (
|
render: (_: unknown, record: CryptoTailStrategyDto) => (
|
||||||
<Space size="small" wrap>
|
<Space size={4}>
|
||||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEditModal(record)}>
|
<Tooltip title={t('cryptoTailStrategy.list.edit')}>
|
||||||
{t('cryptoTailStrategy.list.edit')}
|
<div
|
||||||
</Button>
|
onClick={() => openEditModal(record)}
|
||||||
<Button
|
style={{
|
||||||
type="link"
|
display: 'flex',
|
||||||
size="small"
|
alignItems: 'center',
|
||||||
icon={<UnorderedListOutlined />}
|
justifyContent: 'center',
|
||||||
onClick={() => openTriggers(record.id)}
|
width: '32px',
|
||||||
>
|
height: '32px',
|
||||||
{t('cryptoTailStrategy.list.viewTriggers')}
|
cursor: 'pointer',
|
||||||
</Button>
|
borderRadius: '6px',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f0f0f0' }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
|
||||||
|
>
|
||||||
|
<EditOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title={t('cryptoTailStrategy.list.viewTriggers')}>
|
||||||
|
<div
|
||||||
|
onClick={() => openTriggers(record.id)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f0f0f0' }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
|
||||||
|
>
|
||||||
|
<UnorderedListOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title={t('cryptoTailStrategy.list.viewPnlCurve')}>
|
||||||
|
<div
|
||||||
|
onClick={() => openPnlCurve(record)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f0f0f0' }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
|
||||||
|
>
|
||||||
|
<LineChartOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={t('cryptoTailStrategy.list.deleteConfirm')}
|
title={t('cryptoTailStrategy.list.deleteConfirm')}
|
||||||
onConfirm={() => handleDelete(record.id)}
|
onConfirm={() => handleDelete(record.id)}
|
||||||
okText={t('common.confirm')}
|
okText={t('common.confirm')}
|
||||||
cancelText={t('common.cancel')}
|
cancelText={t('common.cancel')}
|
||||||
>
|
>
|
||||||
<Button type="link" size="small" danger>
|
<Tooltip title={t('cryptoTailStrategy.list.delete')}>
|
||||||
{t('cryptoTailStrategy.list.delete')}
|
<div
|
||||||
</Button>
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#fff1f0' }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
|
||||||
|
>
|
||||||
|
<DeleteOutlined style={{ fontSize: '16px', color: '#ff4d4f' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
@@ -688,6 +804,12 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('cryptoTailStrategy.list.viewTriggers')}</span>
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('cryptoTailStrategy.list.viewTriggers')}</span>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip title={t('cryptoTailStrategy.list.viewPnlCurve')}>
|
||||||
|
<div onClick={() => openPnlCurve(item)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
|
||||||
|
<LineChartOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
|
||||||
|
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('cryptoTailStrategy.list.viewPnlCurve')}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
<Popconfirm title={t('cryptoTailStrategy.list.deleteConfirm')} onConfirm={() => handleDelete(item.id)} okText={t('common.confirm')} cancelText={t('common.cancel')}>
|
<Popconfirm title={t('cryptoTailStrategy.list.deleteConfirm')} onConfirm={() => handleDelete(item.id)} okText={t('common.confirm')} cancelText={t('common.cancel')}>
|
||||||
<Tooltip title={t('cryptoTailStrategy.list.delete')}>
|
<Tooltip title={t('cryptoTailStrategy.list.delete')}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
|
||||||
@@ -707,7 +829,7 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={list}
|
dataSource={list}
|
||||||
pagination={{ pageSize: 20 }}
|
pagination={{ pageSize: 20 }}
|
||||||
scroll={{ x: 900 }}
|
scroll={{ x: 720 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Spin>
|
</Spin>
|
||||||
@@ -736,6 +858,17 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
<p>{t('cryptoTailStrategy.redeemRequiredModal.description')}</p>
|
<p>{t('cryptoTailStrategy.redeemRequiredModal.description')}</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<CryptoTailPnlCurveModal
|
||||||
|
open={pnlCurveModalOpen}
|
||||||
|
onClose={() => setPnlCurveModalOpen(false)}
|
||||||
|
data={pnlCurveData}
|
||||||
|
loading={pnlCurveLoading}
|
||||||
|
strategyName={pnlCurveStrategyName}
|
||||||
|
preset={pnlCurvePreset}
|
||||||
|
onPresetChange={setPnlCurvePreset}
|
||||||
|
onRefresh={loadPnlCurve}
|
||||||
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={editingId ? t('cryptoTailStrategy.form.update') : t('cryptoTailStrategy.form.create')}
|
title={editingId ? t('cryptoTailStrategy.form.update') : t('cryptoTailStrategy.form.create')}
|
||||||
open={formModalOpen}
|
open={formModalOpen}
|
||||||
|
|||||||
@@ -494,6 +494,8 @@ export const apiService = {
|
|||||||
endDate?: number
|
endDate?: number
|
||||||
}) =>
|
}) =>
|
||||||
apiClient.post<ApiResponse<{ list: import('../types').CryptoTailStrategyTriggerDto[]; total: number }>>('/crypto-tail-strategy/triggers', data),
|
apiClient.post<ApiResponse<{ list: import('../types').CryptoTailStrategyTriggerDto[]; total: number }>>('/crypto-tail-strategy/triggers', data),
|
||||||
|
pnlCurve: (data: import('../types').CryptoTailPnlCurveRequest) =>
|
||||||
|
apiClient.post<ApiResponse<import('../types').CryptoTailPnlCurveResponse>>('/crypto-tail-strategy/pnl-curve', data),
|
||||||
marketOptions: () =>
|
marketOptions: () =>
|
||||||
apiClient.post<ApiResponse<import('../types').CryptoTailMarketOptionDto[]>>('/crypto-tail-strategy/market-options', {}),
|
apiClient.post<ApiResponse<import('../types').CryptoTailMarketOptionDto[]>>('/crypto-tail-strategy/market-options', {}),
|
||||||
autoMinSpread: (data: { intervalSeconds: number }) =>
|
autoMinSpread: (data: { intervalSeconds: number }) =>
|
||||||
|
|||||||
@@ -1097,6 +1097,33 @@ export interface CryptoTailStrategyTriggerDto {
|
|||||||
createdAt: number
|
createdAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 收益曲线请求 */
|
||||||
|
export interface CryptoTailPnlCurveRequest {
|
||||||
|
strategyId: number
|
||||||
|
startDate?: number
|
||||||
|
endDate?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收益曲线单点 */
|
||||||
|
export interface CryptoTailPnlCurvePoint {
|
||||||
|
timestamp: number
|
||||||
|
cumulativePnl: string
|
||||||
|
pointPnl: string
|
||||||
|
settledCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收益曲线响应 */
|
||||||
|
export interface CryptoTailPnlCurveResponse {
|
||||||
|
strategyId: number
|
||||||
|
strategyName: string
|
||||||
|
totalRealizedPnl: string
|
||||||
|
settledCount: number
|
||||||
|
winCount: number
|
||||||
|
winRate: string | null
|
||||||
|
maxDrawdown: string | null
|
||||||
|
curveData: CryptoTailPnlCurvePoint[]
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加密价差策略市场选项
|
* 加密价差策略市场选项
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user