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

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

Made-with: Cursor
This commit is contained in:
WrBug
2026-03-02 17:39:23 +08:00
parent 4ebfacfc21
commit 46e10ebdd8
11 changed files with 568 additions and 74 deletions
+17
View File
@@ -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": {
+17
View File
@@ -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": {
+17
View File
@@ -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
+207 -74
View File
@@ -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}
+2
View File
@@ -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 }) =>
+27
View File
@@ -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[]
}
/**
* 加密价差策略市场选项
*/