diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt index 4bc986f..87ea5b5 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt @@ -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> { + 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> { return try { diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt index 1ed4c24..7ae88f2 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt @@ -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 = emptyList() +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt index b248ff7..185efa6 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt @@ -40,4 +40,16 @@ interface CryptoTailStrategyTriggerRepository : JpaRepository= :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 } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt index aaac15f..c78df33 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt @@ -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 { + 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 { return try { val page = PageRequest.of((request.page - 1).coerceAtLeast(0), request.pageSize.coerceIn(1, 100)) diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index fbe3f3a..48fb31b 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -1504,7 +1504,9 @@ "strategyName": "Strategy Name", "account": "Account", "market": "Market", + "marketAndTime": "Market / Time", "timeWindow": "Time Window", + "config": "Config", "priceRange": "Price Range", "amountMode": "Amount Mode", "ratio": "Ratio", @@ -1517,6 +1519,7 @@ "disable": "Disable", "delete": "Delete", "viewTriggers": "Orders", + "viewPnlCurve": "PnL Curve", "deleteConfirm": "Delete this strategy?", "fetchFailed": "Failed to fetch list", "configGuide": "Configuration Guide" @@ -1584,6 +1587,20 @@ "emptySuccess": "No success records", "emptyFail": "No failed records", "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": { diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index c7f34bd..1509b80 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -1504,7 +1504,9 @@ "strategyName": "策略名称", "account": "账户", "market": "关联市场", + "marketAndTime": "市场/时间", "timeWindow": "时间区间", + "config": "配置", "priceRange": "价格区间", "amountMode": "投入方式", "ratio": "比例", @@ -1517,6 +1519,7 @@ "disable": "停用", "delete": "删除", "viewTriggers": "订单", + "viewPnlCurve": "收益曲线", "deleteConfirm": "确定删除该策略?", "fetchFailed": "获取列表失败", "configGuide": "配置指南" @@ -1584,6 +1587,20 @@ "emptySuccess": "暂无成功记录", "emptyFail": "暂无失败记录", "totalCount": "共 {count} 条" + }, + "pnlCurve": { + "title": "收益曲线", + "totalPnl": "总收益", + "settledCount": "已结算笔数", + "winRate": "胜率", + "maxDrawdown": "最大回撤", + "timeRange": "时间范围", + "today": "今日", + "last7Days": "近7天", + "last30Days": "近30天", + "all": "全部", + "customRange": "自定义", + "empty": "暂无已结算订单,无法展示收益曲线" } }, "cryptoTailMonitor": { diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index 7b149d0..62fba01 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -1504,7 +1504,9 @@ "strategyName": "策略名稱", "account": "賬戶", "market": "關聯市場", + "marketAndTime": "市場/時間", "timeWindow": "時間區間", + "config": "配置", "priceRange": "價格區間", "amountMode": "投入方式", "ratio": "比例", @@ -1517,6 +1519,7 @@ "disable": "停用", "delete": "刪除", "viewTriggers": "訂單", + "viewPnlCurve": "收益曲線", "deleteConfirm": "確定刪除該策略?", "fetchFailed": "獲取列表失敗", "configGuide": "配置指南" @@ -1584,6 +1587,20 @@ "emptySuccess": "暫無成功記錄", "emptyFail": "暫無失敗記錄", "totalCount": "共 {count} 條" + }, + "pnlCurve": { + "title": "收益曲線", + "totalPnl": "總收益", + "settledCount": "已結算筆數", + "winRate": "勝率", + "maxDrawdown": "最大回撤", + "timeRange": "時間範圍", + "today": "今日", + "last7Days": "近7天", + "last30Days": "近30天", + "all": "全部", + "customRange": "自定義", + "empty": "暫無已結算訂單,無法展示收益曲線" } }, "cryptoTailMonitor": { diff --git a/frontend/src/pages/CryptoTailPnlCurveModal.tsx b/frontend/src/pages/CryptoTailPnlCurveModal.tsx new file mode 100644 index 0000000..7732906 --- /dev/null +++ b/frontend/src/pages/CryptoTailPnlCurveModal.tsx @@ -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 = (props) => { + const { open, onClose, data, loading, strategyName, preset, onPresetChange, onRefresh } = props + const { t } = useTranslation() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const chartRef = useRef(null) + const chartInstance = useRef(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') + '
' + 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 ( + + + + + + + + + + + + + + + + + + + + + + + {!data && loading + ?
{t('common.loading')}
+ : !data?.curveData?.length + ? + :
} + + ) +} + +export default CryptoTailPnlCurveModal diff --git a/frontend/src/pages/CryptoTailStrategyList.tsx b/frontend/src/pages/CryptoTailStrategyList.tsx index f12f5e0..8005cbb 100644 --- a/frontend/src/pages/CryptoTailStrategyList.tsx +++ b/frontend/src/pages/CryptoTailStrategyList.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { Card, @@ -25,14 +25,15 @@ import { } from 'antd' import type { 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 { useMediaQuery } from 'react-responsive' import { apiService } from '../services/api' 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 { getVersionInfo } from '../utils/version' +import CryptoTailPnlCurveModal from './CryptoTailPnlCurveModal' const CryptoTailStrategyList: React.FC = () => { const { t, i18n } = useTranslation() @@ -58,6 +59,14 @@ const CryptoTailStrategyList: React.FC = () => { const [triggersLoading, setTriggersLoading] = useState(false) const [form] = Form.useForm() + const [pnlCurveModalOpen, setPnlCurveModalOpen] = useState(false) + const [pnlCurveStrategyId, setPnlCurveStrategyId] = useState(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(null) + const [pnlCurveLoading, setPnlCurveLoading] = useState(false) + /** 币安 API 健康状态:仅保留「不可用」的项,用于强提醒 */ const [binanceUnhealthy, setBinanceUnhealthy] = useState>([]) const [binanceCheckLoading, setBinanceCheckLoading] = useState(false) @@ -327,6 +336,61 @@ const CryptoTailStrategyList: React.FC = () => { 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 next = key === 'success' ? 'success' : 'fail' setTriggerTab(next) @@ -388,7 +452,7 @@ const CryptoTailStrategyList: React.FC = () => { title: t('common.status'), dataIndex: 'enabled', key: 'enabled', - width: 80, + width: 72, render: (enabled: boolean, record: CryptoTailStrategyDto) => ( { title: t('cryptoTailStrategy.list.strategyName'), dataIndex: 'name', key: 'name', - width: isMobile ? 100 : 160, + width: isMobile ? 100 : 140, + ellipsis: true, render: (name: string | undefined, r: CryptoTailStrategyDto) => ( {name || (r.marketTitle ?? r.marketSlugPrefix) || '-'} @@ -412,7 +477,8 @@ const CryptoTailStrategyList: React.FC = () => { title: t('cryptoTailStrategy.list.account'), dataIndex: 'accountId', key: 'accountId', - width: isMobile ? 90 : 120, + width: isMobile ? 90 : 100, + ellipsis: true, render: (_: unknown, r: CryptoTailStrategyDto) => ( {getAccountLabel(r.accountId)} @@ -420,99 +486,149 @@ const CryptoTailStrategyList: React.FC = () => { ) }, { - title: t('cryptoTailStrategy.list.market'), - key: 'market', - width: isMobile ? 120 : 200, + title: t('cryptoTailStrategy.list.marketAndTime'), + key: 'marketAndTime', + width: isMobile ? 120 : 180, render: (_: unknown, r: CryptoTailStrategyDto) => ( - - {marketOptions.find((m) => m.slug === r.marketSlugPrefix)?.title ?? r.marketTitle ?? r.marketSlugPrefix ?? '-'} - +
+ + {marketOptions.find((m) => m.slug === r.marketSlugPrefix)?.title ?? r.marketTitle ?? r.marketSlugPrefix ?? '-'} + + + {formatTimeWindow(r.windowStartSeconds, r.windowEndSeconds, false)} + +
) }, { - title: t('cryptoTailStrategy.list.timeWindow'), - key: 'timeWindow', - width: isMobile ? 100 : 120, + title: t('cryptoTailStrategy.list.config'), + key: 'config', + width: isMobile ? 100 : 140, render: (_: unknown, r: CryptoTailStrategyDto) => ( - - {formatTimeWindow(r.windowStartSeconds, r.windowEndSeconds)} - - ) - }, - { - title: t('cryptoTailStrategy.list.priceRange'), - key: 'priceRange', - width: isMobile ? 90 : 120, - render: (_: unknown, r: CryptoTailStrategyDto) => ( - - {formatPriceRange(r.minPrice, r.maxPrice)} - - ) - }, - { - title: t('cryptoTailStrategy.list.amountMode'), - key: 'amountMode', - width: isMobile ? 90 : 120, - render: (_: unknown, r: CryptoTailStrategyDto) => ( - - {(r.amountMode?.toUpperCase() ?? '') === 'RATIO' - ? `${t('cryptoTailStrategy.list.ratio')} ${formatNumber(r.amountValue, 2) || '0'}%` - : `${t('cryptoTailStrategy.list.fixed')} ${formatUSDC(r.amountValue)} USDC`} - +
+ + {formatPriceRange(r.minPrice, r.maxPrice)} + + + {(r.amountMode?.toUpperCase() ?? '') === 'RATIO' + ? `${t('cryptoTailStrategy.list.ratio')} ${formatNumber(r.amountValue, 2) || '0'}%` + : `${t('cryptoTailStrategy.list.fixed')} ${formatUSDC(r.amountValue)}`} + +
) }, { title: t('cryptoTailStrategy.list.totalRealizedPnl'), - key: 'totalRealizedPnl', + key: 'pnl', width: isMobile ? 90 : 120, 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) - return color ? ( - {text} - ) : ( - {text} + return ( +
+ {color ? ( + {text} + ) : ( + {text} + )} + + {r.winRate != null ? `${(Number(r.winRate) * 100).toFixed(1)}%` : '-'} + +
) } }, - { - title: t('cryptoTailStrategy.list.winRate'), - key: 'winRate', - width: isMobile ? 70 : 90, - render: (_: unknown, r: CryptoTailStrategyDto) => - r.winRate != null ? ( - {(Number(r.winRate) * 100).toFixed(1)}% - ) : ( - - - ) - }, { title: t('cryptoTailStrategy.list.actions'), key: 'actions', - width: isMobile ? 120 : 200, + width: isMobile ? 120 : 140, fixed: 'right' as const, render: (_: unknown, record: CryptoTailStrategyDto) => ( - - - + + +
openEditModal(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' }} + > + +
+
+ + +
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' }} + > + +
+
+ + +
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' }} + > + +
+
+ handleDelete(record.id)} okText={t('common.confirm')} cancelText={t('common.cancel')} > - + +
{ e.currentTarget.style.backgroundColor = '#fff1f0' }} + onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }} + > + +
+
) @@ -688,6 +804,12 @@ const CryptoTailStrategyList: React.FC = () => { {t('cryptoTailStrategy.list.viewTriggers')}
+ +
openPnlCurve(item)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}> + + {t('cryptoTailStrategy.list.viewPnlCurve')} +
+
handleDelete(item.id)} okText={t('common.confirm')} cancelText={t('common.cancel')}>
@@ -707,7 +829,7 @@ const CryptoTailStrategyList: React.FC = () => { columns={columns} dataSource={list} pagination={{ pageSize: 20 }} - scroll={{ x: 900 }} + scroll={{ x: 720 }} /> )} @@ -736,6 +858,17 @@ const CryptoTailStrategyList: React.FC = () => {

{t('cryptoTailStrategy.redeemRequiredModal.description')}

+ setPnlCurveModalOpen(false)} + data={pnlCurveData} + loading={pnlCurveLoading} + strategyName={pnlCurveStrategyName} + preset={pnlCurvePreset} + onPresetChange={setPnlCurvePreset} + onRefresh={loadPnlCurve} + /> + apiClient.post>('/crypto-tail-strategy/triggers', data), + pnlCurve: (data: import('../types').CryptoTailPnlCurveRequest) => + apiClient.post>('/crypto-tail-strategy/pnl-curve', data), marketOptions: () => apiClient.post>('/crypto-tail-strategy/market-options', {}), autoMinSpread: (data: { intervalSeconds: number }) => diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index edbd531..d9f150a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1097,6 +1097,33 @@ export interface CryptoTailStrategyTriggerDto { 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[] +} + /** * 加密价差策略市场选项 */