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.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()
|
||||
)
|
||||
|
||||
+12
@@ -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>
|
||||
}
|
||||
|
||||
+56
@@ -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))
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 {
|
||||
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<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 健康状态:仅保留「不可用」的项,用于强提醒 */
|
||||
const [binanceUnhealthy, setBinanceUnhealthy] = useState<Array<{ name: string; message: string }>>([])
|
||||
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) => (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
@@ -401,7 +465,8 @@ const CryptoTailStrategyList: React.FC = () => {
|
||||
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) => (
|
||||
<Typography.Text strong style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
||||
{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) => (
|
||||
<Typography.Text type="secondary" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
||||
{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) => (
|
||||
<Typography.Text style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
||||
{marketOptions.find((m) => m.slug === r.marketSlugPrefix)?.title ?? r.marketTitle ?? r.marketSlugPrefix ?? '-'}
|
||||
</Typography.Text>
|
||||
<div>
|
||||
<Typography.Text style={{ wordBreak: 'break-word', whiteSpace: 'normal', display: 'block' }}>
|
||||
{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'),
|
||||
key: 'timeWindow',
|
||||
width: isMobile ? 100 : 120,
|
||||
title: t('cryptoTailStrategy.list.config'),
|
||||
key: 'config',
|
||||
width: isMobile ? 100 : 140,
|
||||
render: (_: unknown, r: CryptoTailStrategyDto) => (
|
||||
<Typography.Text type="secondary" style={{ wordBreak: 'break-word', whiteSpace: 'pre-line' }}>
|
||||
{formatTimeWindow(r.windowStartSeconds, r.windowEndSeconds)}
|
||||
</Typography.Text>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('cryptoTailStrategy.list.priceRange'),
|
||||
key: 'priceRange',
|
||||
width: isMobile ? 90 : 120,
|
||||
render: (_: unknown, r: CryptoTailStrategyDto) => (
|
||||
<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>
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block' }}>
|
||||
{formatPriceRange(r.minPrice, r.maxPrice)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{(r.amountMode?.toUpperCase() ?? '') === 'RATIO'
|
||||
? `${t('cryptoTailStrategy.list.ratio')} ${formatNumber(r.amountValue, 2) || '0'}%`
|
||||
: `${t('cryptoTailStrategy.list.fixed')} ${formatUSDC(r.amountValue)}`}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
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 ? (
|
||||
<Typography.Text style={{ color, fontWeight: 500 }}>{text}</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text type="secondary">{text}</Typography.Text>
|
||||
return (
|
||||
<div>
|
||||
{color ? (
|
||||
<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'),
|
||||
key: 'actions',
|
||||
width: isMobile ? 120 : 200,
|
||||
width: isMobile ? 120 : 140,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, record: CryptoTailStrategyDto) => (
|
||||
<Space size="small" wrap>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEditModal(record)}>
|
||||
{t('cryptoTailStrategy.list.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<UnorderedListOutlined />}
|
||||
onClick={() => openTriggers(record.id)}
|
||||
>
|
||||
{t('cryptoTailStrategy.list.viewTriggers')}
|
||||
</Button>
|
||||
<Space size={4}>
|
||||
<Tooltip title={t('cryptoTailStrategy.list.edit')}>
|
||||
<div
|
||||
onClick={() => 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' }}
|
||||
>
|
||||
<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
|
||||
title={t('cryptoTailStrategy.list.deleteConfirm')}
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
{t('cryptoTailStrategy.list.delete')}
|
||||
</Button>
|
||||
<Tooltip title={t('cryptoTailStrategy.list.delete')}>
|
||||
<div
|
||||
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>
|
||||
</Space>
|
||||
)
|
||||
@@ -688,6 +804,12 @@ const CryptoTailStrategyList: React.FC = () => {
|
||||
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('cryptoTailStrategy.list.viewTriggers')}</span>
|
||||
</div>
|
||||
</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')}>
|
||||
<Tooltip title={t('cryptoTailStrategy.list.delete')}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
|
||||
@@ -707,7 +829,7 @@ const CryptoTailStrategyList: React.FC = () => {
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
pagination={{ pageSize: 20 }}
|
||||
scroll={{ x: 900 }}
|
||||
scroll={{ x: 720 }}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
@@ -736,6 +858,17 @@ const CryptoTailStrategyList: React.FC = () => {
|
||||
<p>{t('cryptoTailStrategy.redeemRequiredModal.description')}</p>
|
||||
</Modal>
|
||||
|
||||
<CryptoTailPnlCurveModal
|
||||
open={pnlCurveModalOpen}
|
||||
onClose={() => setPnlCurveModalOpen(false)}
|
||||
data={pnlCurveData}
|
||||
loading={pnlCurveLoading}
|
||||
strategyName={pnlCurveStrategyName}
|
||||
preset={pnlCurvePreset}
|
||||
onPresetChange={setPnlCurvePreset}
|
||||
onRefresh={loadPnlCurve}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingId ? t('cryptoTailStrategy.form.update') : t('cryptoTailStrategy.form.create')}
|
||||
open={formModalOpen}
|
||||
|
||||
@@ -494,6 +494,8 @@ export const apiService = {
|
||||
endDate?: number
|
||||
}) =>
|
||||
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: () =>
|
||||
apiClient.post<ApiResponse<import('../types').CryptoTailMarketOptionDto[]>>('/crypto-tail-strategy/market-options', {}),
|
||||
autoMinSpread: (data: { intervalSeconds: number }) =>
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密价差策略市场选项
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user