feat(crypto-tail): 尾盘策略完整实现与优化
- 尾盘策略 CRUD、订单簿 WS 订阅、周期内触发下单 - 订单簿订阅日志增加市场 slug,便于排查 - 移除轮询,完全依赖 WebSocket(删除 CryptoTailStrategyScheduler) - FIXED 模式数量改为小数、向上取整,与签名服务一致 - 前端策略列表页、多语言与 API 对接 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -34,6 +34,7 @@ import RpcNodeSettings from './pages/RpcNodeSettings'
|
||||
import Announcements from './pages/Announcements'
|
||||
import BacktestList from './pages/BacktestList'
|
||||
import BacktestDetail from './pages/BacktestDetail'
|
||||
import CryptoTailStrategyList from './pages/CryptoTailStrategyList'
|
||||
import { wsManager } from './services/websocket'
|
||||
import type { OrderPushMessage } from './types'
|
||||
import { apiService } from './services/api'
|
||||
@@ -250,6 +251,7 @@ function App() {
|
||||
<Route path="/templates/add" element={<ProtectedRoute><TemplateAdd /></ProtectedRoute>} />
|
||||
<Route path="/templates/edit/:id" element={<ProtectedRoute><TemplateEdit /></ProtectedRoute>} />
|
||||
<Route path="/copy-trading" element={<ProtectedRoute><CopyTradingList /></ProtectedRoute>} />
|
||||
<Route path="/crypto-tail-strategy" element={<ProtectedRoute><CryptoTailStrategyList /></ProtectedRoute>} />
|
||||
<Route path="/copy-trading/statistics/:copyTradingId" element={<ProtectedRoute><CopyTradingStatistics /></ProtectedRoute>} />
|
||||
{/* 保留旧路由以保持向后兼容 */}
|
||||
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<ProtectedRoute><CopyTradingBuyOrders /></ProtectedRoute>} />
|
||||
|
||||
@@ -157,6 +157,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/crypto-tail-strategy',
|
||||
icon: <LineChartOutlined />,
|
||||
label: t('menu.cryptoTailStrategy')
|
||||
},
|
||||
{
|
||||
key: '/positions',
|
||||
icon: <UnorderedListOutlined />,
|
||||
|
||||
@@ -266,6 +266,7 @@
|
||||
"leaders": "Leader Management",
|
||||
"templates": "Templates",
|
||||
"copyTradingConfig": "Copy Trading Config",
|
||||
"cryptoTailStrategy": "Tail Strategy",
|
||||
"positions": "Position Management",
|
||||
"backtest": "Backtest",
|
||||
"statistics": "Statistics",
|
||||
@@ -1399,5 +1400,70 @@
|
||||
"rerunTaskNamePlaceholder": "New task name (leave empty for \"Original name (copy)\")",
|
||||
"rerunSuccess": "New backtest task created",
|
||||
"rerunFailed": "Re-run failed"
|
||||
},
|
||||
"cryptoTailStrategy": {
|
||||
"list": {
|
||||
"title": "Crypto Tail Strategy",
|
||||
"walletTip": "Use a dedicated wallet for tail strategy. Do not use it for manual trading or copy trading to avoid balance/position issues.",
|
||||
"addStrategy": "Add Strategy",
|
||||
"strategyName": "Strategy Name",
|
||||
"market": "Market",
|
||||
"timeWindow": "Time Window",
|
||||
"priceRange": "Price Range",
|
||||
"amountMode": "Amount Mode",
|
||||
"ratio": "Ratio",
|
||||
"fixed": "Fixed",
|
||||
"recentTrigger": "Last Trigger",
|
||||
"actions": "Actions",
|
||||
"edit": "Edit",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"delete": "Delete",
|
||||
"viewTriggers": "Trigger Records",
|
||||
"deleteConfirm": "Delete this strategy?",
|
||||
"fetchFailed": "Failed to fetch list"
|
||||
},
|
||||
"form": {
|
||||
"walletTip": "Use a dedicated wallet to avoid balance or order issues.",
|
||||
"strategyName": "Strategy Name",
|
||||
"strategyNamePlaceholder": "Auto",
|
||||
"selectAccount": "Select Account",
|
||||
"selectMarket": "Select Market",
|
||||
"timeWindowStart": "Window Start",
|
||||
"timeWindowEnd": "Window End",
|
||||
"minute": "min",
|
||||
"second": "sec",
|
||||
"minPrice": "Min Price",
|
||||
"maxPrice": "Max Price",
|
||||
"maxPricePlaceholder": "Default 1",
|
||||
"amountMode": "Amount Mode",
|
||||
"ratioPercent": "Ratio %",
|
||||
"fixedUsdc": "Fixed (USDC)",
|
||||
"enabled": "Enabled",
|
||||
"create": "Create",
|
||||
"update": "Update",
|
||||
"timeWindowStartLEEnd": "Window start must not be greater than end",
|
||||
"timeWindowExceed": "Time window must not exceed period length"
|
||||
},
|
||||
"redeemRequiredModal": {
|
||||
"title": "Configure Auto Redeem First",
|
||||
"description": "Tail strategy requires auto redeem. Please configure Builder API Key and enable auto redeem in System Settings.",
|
||||
"goToSettings": "Go to Settings",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"triggerRecords": {
|
||||
"title": "Trigger Records",
|
||||
"triggerTime": "Time",
|
||||
"market": "Market",
|
||||
"direction": "Direction",
|
||||
"up": "Up",
|
||||
"down": "Down",
|
||||
"triggerPrice": "Trigger Price",
|
||||
"amount": "Amount",
|
||||
"orderId": "Order ID",
|
||||
"status": "Status",
|
||||
"success": "Success",
|
||||
"fail": "Fail"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -265,6 +265,7 @@
|
||||
"leaders": "Leader 管理",
|
||||
"templates": "跟单模板",
|
||||
"copyTradingConfig": "跟单配置",
|
||||
"cryptoTailStrategy": "尾盘策略",
|
||||
"positions": "仓位管理",
|
||||
"backtest": "回测",
|
||||
"statistics": "统计信息",
|
||||
@@ -1398,5 +1399,70 @@
|
||||
"rerunTaskNamePlaceholder": "新任务名称(留空使用「原名称 (副本)」)",
|
||||
"rerunSuccess": "已创建新回测任务",
|
||||
"rerunFailed": "重新测试失败"
|
||||
},
|
||||
"cryptoTailStrategy": {
|
||||
"list": {
|
||||
"title": "加密尾盘策略",
|
||||
"walletTip": "请使用单独的钱包运行尾盘策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,造成策略执行异常。",
|
||||
"addStrategy": "新增策略",
|
||||
"strategyName": "策略名称",
|
||||
"market": "关联市场",
|
||||
"timeWindow": "时间区间",
|
||||
"priceRange": "价格区间",
|
||||
"amountMode": "投入方式",
|
||||
"ratio": "比例",
|
||||
"fixed": "固定金额",
|
||||
"recentTrigger": "最近触发",
|
||||
"actions": "操作",
|
||||
"edit": "编辑",
|
||||
"enable": "启用",
|
||||
"disable": "停用",
|
||||
"delete": "删除",
|
||||
"viewTriggers": "查看触发记录",
|
||||
"deleteConfirm": "确定删除该策略?",
|
||||
"fetchFailed": "获取列表失败"
|
||||
},
|
||||
"form": {
|
||||
"walletTip": "建议使用专用钱包,避免手动操作等导致余额或下单异常。",
|
||||
"strategyName": "策略名称",
|
||||
"strategyNamePlaceholder": "自动生成",
|
||||
"selectAccount": "选择账户",
|
||||
"selectMarket": "选择市场",
|
||||
"timeWindowStart": "区间开始",
|
||||
"timeWindowEnd": "区间结束",
|
||||
"minute": "分",
|
||||
"second": "秒",
|
||||
"minPrice": "最低价",
|
||||
"maxPrice": "最高价",
|
||||
"maxPricePlaceholder": "不填默认为 1",
|
||||
"amountMode": "投入方式",
|
||||
"ratioPercent": "比例 %",
|
||||
"fixedUsdc": "固定金额 (USDC)",
|
||||
"enabled": "启用",
|
||||
"create": "创建",
|
||||
"update": "更新",
|
||||
"timeWindowStartLEEnd": "时间区间开始不能大于结束",
|
||||
"timeWindowExceed": "时间区间不能超过周期长度"
|
||||
},
|
||||
"redeemRequiredModal": {
|
||||
"title": "请先配置自动赎回",
|
||||
"description": "尾盘策略依赖自动赎回功能,请先在系统设置中配置 Builder API Key 并开启自动赎回。",
|
||||
"goToSettings": "去配置",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"triggerRecords": {
|
||||
"title": "触发记录",
|
||||
"triggerTime": "触发时间",
|
||||
"market": "市场",
|
||||
"direction": "方向",
|
||||
"up": "Up",
|
||||
"down": "Down",
|
||||
"triggerPrice": "触发价格",
|
||||
"amount": "投入金额",
|
||||
"orderId": "订单 ID",
|
||||
"status": "状态",
|
||||
"success": "成功",
|
||||
"fail": "失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,6 +266,7 @@
|
||||
"leaders": "Leader 管理",
|
||||
"templates": "跟單模板",
|
||||
"copyTradingConfig": "跟單配置",
|
||||
"cryptoTailStrategy": "尾盤策略",
|
||||
"positions": "倉位管理",
|
||||
"backtest": "回測",
|
||||
"statistics": "統計信息",
|
||||
@@ -1399,5 +1400,70 @@
|
||||
"rerunTaskNamePlaceholder": "新任務名稱(留空使用「原名稱 (副本)」)",
|
||||
"rerunSuccess": "已創建新回測任務",
|
||||
"rerunFailed": "重新測試失敗"
|
||||
},
|
||||
"cryptoTailStrategy": {
|
||||
"list": {
|
||||
"title": "加密尾盤策略",
|
||||
"walletTip": "請使用單獨的錢包運行尾盤策略,避免該錢包用於手動交易、跟單等其他操作,否則可能導致餘額或倉位變化,造成策略執行異常。",
|
||||
"addStrategy": "新增策略",
|
||||
"strategyName": "策略名稱",
|
||||
"market": "關聯市場",
|
||||
"timeWindow": "時間區間",
|
||||
"priceRange": "價格區間",
|
||||
"amountMode": "投入方式",
|
||||
"ratio": "比例",
|
||||
"fixed": "固定金額",
|
||||
"recentTrigger": "最近觸發",
|
||||
"actions": "操作",
|
||||
"edit": "編輯",
|
||||
"enable": "啟用",
|
||||
"disable": "停用",
|
||||
"delete": "刪除",
|
||||
"viewTriggers": "查看觸發記錄",
|
||||
"deleteConfirm": "確定刪除該策略?",
|
||||
"fetchFailed": "獲取列表失敗"
|
||||
},
|
||||
"form": {
|
||||
"walletTip": "建議使用專用錢包,避免手動操作等導致餘額或下單異常。",
|
||||
"strategyName": "策略名稱",
|
||||
"strategyNamePlaceholder": "自動生成",
|
||||
"selectAccount": "選擇賬戶",
|
||||
"selectMarket": "選擇市場",
|
||||
"timeWindowStart": "區間開始",
|
||||
"timeWindowEnd": "區間結束",
|
||||
"minute": "分",
|
||||
"second": "秒",
|
||||
"minPrice": "最低價",
|
||||
"maxPrice": "最高價",
|
||||
"maxPricePlaceholder": "不填默認為 1",
|
||||
"amountMode": "投入方式",
|
||||
"ratioPercent": "比例 %",
|
||||
"fixedUsdc": "固定金額 (USDC)",
|
||||
"enabled": "啟用",
|
||||
"create": "創建",
|
||||
"update": "更新",
|
||||
"timeWindowStartLEEnd": "時間區間開始不能大於結束",
|
||||
"timeWindowExceed": "時間區間不能超過週期長度"
|
||||
},
|
||||
"redeemRequiredModal": {
|
||||
"title": "請先配置自動贖回",
|
||||
"description": "尾盤策略依賴自動贖回功能,請先在系統設置中配置 Builder API Key 並開啟自動贖回。",
|
||||
"goToSettings": "去配置",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"triggerRecords": {
|
||||
"title": "觸發記錄",
|
||||
"triggerTime": "觸發時間",
|
||||
"market": "市場",
|
||||
"direction": "方向",
|
||||
"up": "Up",
|
||||
"down": "Down",
|
||||
"triggerPrice": "觸發價格",
|
||||
"amount": "投入金額",
|
||||
"orderId": "訂單 ID",
|
||||
"status": "狀態",
|
||||
"success": "成功",
|
||||
"fail": "失敗"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Switch,
|
||||
message,
|
||||
Select,
|
||||
Modal,
|
||||
Alert,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Radio,
|
||||
Spin
|
||||
} from 'antd'
|
||||
import { PlusOutlined, EditOutlined, UnorderedListOutlined } 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 { formatUSDC } from '../utils'
|
||||
|
||||
const CryptoTailStrategyList: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const { accounts, fetchAccounts } = useAccountStore()
|
||||
const [list, setList] = useState<CryptoTailStrategyDto[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [filters, setFilters] = useState<{ accountId?: number; enabled?: boolean }>({})
|
||||
const [systemConfig, setSystemConfig] = useState<{ builderApiKeyConfigured?: boolean; autoRedeemEnabled?: boolean } | null>(null)
|
||||
const [redeemModalOpen, setRedeemModalOpen] = useState(false)
|
||||
const [formModalOpen, setFormModalOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [marketOptions, setMarketOptions] = useState<CryptoTailMarketOptionDto[]>([])
|
||||
const [triggersModalOpen, setTriggersModalOpen] = useState(false)
|
||||
const [, setTriggersStrategyId] = useState<number | null>(null)
|
||||
const [triggers, setTriggers] = useState<CryptoTailStrategyTriggerDto[]>([])
|
||||
const [, setTriggersTotal] = useState(0)
|
||||
const [triggersLoading, setTriggersLoading] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts()
|
||||
fetchSystemConfig()
|
||||
fetchMarketOptions()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchList()
|
||||
}, [filters])
|
||||
|
||||
const fetchSystemConfig = async () => {
|
||||
try {
|
||||
const res = await apiService.systemConfig.get()
|
||||
if (res.data.code === 0 && res.data.data) {
|
||||
setSystemConfig(res.data.data)
|
||||
}
|
||||
} catch {
|
||||
setSystemConfig(null)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMarketOptions = async () => {
|
||||
try {
|
||||
const res = await apiService.cryptoTailStrategy.marketOptions()
|
||||
if (res.data.code === 0 && res.data.data) {
|
||||
setMarketOptions(res.data.data)
|
||||
}
|
||||
} catch {
|
||||
setMarketOptions([])
|
||||
}
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await apiService.cryptoTailStrategy.list(filters)
|
||||
if (res.data.code === 0 && res.data.data) {
|
||||
setList(res.data.data.list ?? [])
|
||||
} else {
|
||||
message.error(res.data.msg || t('cryptoTailStrategy.list.fetchFailed'))
|
||||
}
|
||||
} catch (e) {
|
||||
message.error((e as Error).message || t('cryptoTailStrategy.list.fetchFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openAddModal = () => {
|
||||
const needApiKey = !systemConfig?.builderApiKeyConfigured
|
||||
const needAutoRedeem = !systemConfig?.autoRedeemEnabled
|
||||
if (needApiKey || needAutoRedeem) {
|
||||
setRedeemModalOpen(true)
|
||||
return
|
||||
}
|
||||
setEditingId(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({
|
||||
enabled: true,
|
||||
amountMode: 'RATIO',
|
||||
maxPrice: '1',
|
||||
windowStartMinutes: 0,
|
||||
windowStartSeconds: 0
|
||||
})
|
||||
setFormModalOpen(true)
|
||||
}
|
||||
|
||||
const openEditModal = (record: CryptoTailStrategyDto) => {
|
||||
setEditingId(record.id)
|
||||
form.setFieldsValue({
|
||||
accountId: record.accountId,
|
||||
name: record.name,
|
||||
marketSlugPrefix: record.marketSlugPrefix,
|
||||
intervalSeconds: record.intervalSeconds,
|
||||
windowStartMinutes: Math.floor(record.windowStartSeconds / 60),
|
||||
windowStartSeconds: record.windowStartSeconds % 60,
|
||||
windowEndMinutes: Math.floor(record.windowEndSeconds / 60),
|
||||
windowEndSeconds: record.windowEndSeconds % 60,
|
||||
minPrice: record.minPrice,
|
||||
maxPrice: record.maxPrice,
|
||||
amountMode: record.amountMode,
|
||||
amountValue: record.amountValue,
|
||||
enabled: record.enabled
|
||||
})
|
||||
setFormModalOpen(true)
|
||||
}
|
||||
|
||||
const handleFormSubmit = async () => {
|
||||
try {
|
||||
const v = await form.validateFields()
|
||||
const interval = (editingId ? v.intervalSeconds : marketOptions.find((m) => m.slug === v.marketSlugPrefix)?.intervalSeconds) ?? 300
|
||||
const windowStartSeconds = (v.windowStartMinutes ?? 0) * 60 + (v.windowStartSeconds ?? 0)
|
||||
const windowEndSeconds = (v.windowEndMinutes ?? 0) * 60 + (v.windowEndSeconds ?? 0)
|
||||
if (windowStartSeconds > windowEndSeconds) {
|
||||
message.error(t('cryptoTailStrategy.form.timeWindowStartLEEnd'))
|
||||
return
|
||||
}
|
||||
const maxWindow = interval
|
||||
if (windowEndSeconds > maxWindow) {
|
||||
message.error(t('cryptoTailStrategy.form.timeWindowExceed'))
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
accountId: v.accountId as number,
|
||||
name: v.name as string | undefined,
|
||||
marketSlugPrefix: v.marketSlugPrefix as string,
|
||||
intervalSeconds: interval,
|
||||
windowStartSeconds,
|
||||
windowEndSeconds,
|
||||
minPrice: String(v.minPrice ?? 0),
|
||||
maxPrice: v.maxPrice != null ? String(v.maxPrice) : undefined,
|
||||
amountMode: v.amountMode as string,
|
||||
amountValue: String(v.amountValue ?? 0),
|
||||
enabled: v.enabled !== false
|
||||
}
|
||||
if (editingId) {
|
||||
const res = await apiService.cryptoTailStrategy.update({
|
||||
strategyId: editingId,
|
||||
name: payload.name,
|
||||
windowStartSeconds: payload.windowStartSeconds,
|
||||
windowEndSeconds: payload.windowEndSeconds,
|
||||
minPrice: payload.minPrice,
|
||||
maxPrice: payload.maxPrice,
|
||||
amountMode: payload.amountMode,
|
||||
amountValue: payload.amountValue,
|
||||
enabled: payload.enabled
|
||||
})
|
||||
if (res.data.code === 0) {
|
||||
message.success(t('common.success'))
|
||||
setFormModalOpen(false)
|
||||
fetchList()
|
||||
} else {
|
||||
message.error(res.data.msg || t('common.failed'))
|
||||
}
|
||||
} else {
|
||||
const res = await apiService.cryptoTailStrategy.create(payload)
|
||||
if (res.data.code === 0) {
|
||||
message.success(t('common.success'))
|
||||
setFormModalOpen(false)
|
||||
fetchList()
|
||||
} else {
|
||||
message.error(res.data.msg || t('common.failed'))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if ((e as { errorFields?: unknown[] })?.errorFields) {
|
||||
return
|
||||
}
|
||||
message.error((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async (record: CryptoTailStrategyDto) => {
|
||||
try {
|
||||
const res = await apiService.cryptoTailStrategy.update({
|
||||
strategyId: record.id,
|
||||
enabled: !record.enabled
|
||||
})
|
||||
if (res.data.code === 0) {
|
||||
message.success(record.enabled ? t('cryptoTailStrategy.list.disable') : t('cryptoTailStrategy.list.enable'))
|
||||
fetchList()
|
||||
} else {
|
||||
message.error(res.data.msg)
|
||||
}
|
||||
} catch (e) {
|
||||
message.error((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (strategyId: number) => {
|
||||
try {
|
||||
const res = await apiService.cryptoTailStrategy.delete({ strategyId })
|
||||
if (res.data.code === 0) {
|
||||
message.success(t('common.success'))
|
||||
fetchList()
|
||||
} else {
|
||||
message.error(res.data.msg)
|
||||
}
|
||||
} catch (e) {
|
||||
message.error((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const openTriggers = async (strategyId: number) => {
|
||||
setTriggersStrategyId(strategyId)
|
||||
setTriggersModalOpen(true)
|
||||
setTriggersLoading(true)
|
||||
try {
|
||||
const res = await apiService.cryptoTailStrategy.triggers({ strategyId, page: 1, pageSize: 50 })
|
||||
if (res.data.code === 0 && res.data.data) {
|
||||
setTriggers(res.data.data.list ?? [])
|
||||
setTriggersTotal(res.data.data.total ?? 0)
|
||||
}
|
||||
} finally {
|
||||
setTriggersLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTimeWindow = (startSec: number, endSec: number): string => {
|
||||
const sm = Math.floor(startSec / 60)
|
||||
const ss = startSec % 60
|
||||
const em = Math.floor(endSec / 60)
|
||||
const es = endSec % 60
|
||||
return `${sm} ${t('cryptoTailStrategy.form.minute')} ${ss} ${t('cryptoTailStrategy.form.second')} ~ ${em} ${t('cryptoTailStrategy.form.minute')} ${es} ${t('cryptoTailStrategy.form.second')}`
|
||||
}
|
||||
|
||||
const formatLastTrigger = (ts?: number) => {
|
||||
if (ts == null) return '-'
|
||||
const d = new Date(ts)
|
||||
return d.toLocaleString()
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('cryptoTailStrategy.list.strategyName'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: isMobile ? 100 : 140,
|
||||
render: (name: string | undefined, r: CryptoTailStrategyDto) => name || (r.marketTitle ?? r.marketSlugPrefix) || '-'
|
||||
},
|
||||
{
|
||||
title: t('cryptoTailStrategy.list.market'),
|
||||
key: 'market',
|
||||
width: isMobile ? 120 : 200,
|
||||
render: (_: unknown, r: CryptoTailStrategyDto) =>
|
||||
marketOptions.find((m) => m.slug === r.marketSlugPrefix)?.title ?? r.marketTitle ?? r.marketSlugPrefix ?? '-'
|
||||
},
|
||||
{
|
||||
title: t('cryptoTailStrategy.list.timeWindow'),
|
||||
key: 'timeWindow',
|
||||
width: isMobile ? 140 : 180,
|
||||
render: (_: unknown, r: CryptoTailStrategyDto) => formatTimeWindow(r.windowStartSeconds, r.windowEndSeconds)
|
||||
},
|
||||
{
|
||||
title: t('cryptoTailStrategy.list.priceRange'),
|
||||
key: 'priceRange',
|
||||
width: isMobile ? 90 : 120,
|
||||
render: (_: unknown, r: CryptoTailStrategyDto) => `[${r.minPrice}, ${r.maxPrice}]`
|
||||
},
|
||||
{
|
||||
title: t('cryptoTailStrategy.list.amountMode'),
|
||||
key: 'amountMode',
|
||||
width: isMobile ? 90 : 120,
|
||||
render: (_: unknown, r: CryptoTailStrategyDto) =>
|
||||
r.amountMode === 'RATIO'
|
||||
? `${t('cryptoTailStrategy.list.ratio')} ${r.amountValue}%`
|
||||
: `${t('cryptoTailStrategy.list.fixed')} ${formatUSDC(r.amountValue)} USDC`
|
||||
},
|
||||
{
|
||||
title: t('common.status'),
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
width: 90,
|
||||
render: (enabled: boolean, record: CryptoTailStrategyDto) => (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={() => handleToggle(record)}
|
||||
checkedChildren={t('cryptoTailStrategy.list.enable')}
|
||||
unCheckedChildren={t('cryptoTailStrategy.list.disable')}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('cryptoTailStrategy.list.recentTrigger'),
|
||||
dataIndex: 'lastTriggerAt',
|
||||
key: 'lastTriggerAt',
|
||||
width: isMobile ? 100 : 160,
|
||||
render: (ts: number | undefined) => formatLastTrigger(ts)
|
||||
},
|
||||
{
|
||||
title: t('cryptoTailStrategy.list.actions'),
|
||||
key: 'actions',
|
||||
width: isMobile ? 120 : 200,
|
||||
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>
|
||||
<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>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
const selectedMarket = Form.useWatch('marketSlugPrefix', form)
|
||||
const intervalSeconds = marketOptions.find((m) => m.slug === selectedMarket)?.intervalSeconds ?? 300
|
||||
const maxMinutes = Math.floor(intervalSeconds / 60)
|
||||
|
||||
// 新建时:选择市场后,区间开始默认 0分0秒,区间结束默认 x分0秒(x=周期)
|
||||
useEffect(() => {
|
||||
if (!formModalOpen || editingId != null || !selectedMarket) return
|
||||
const intervalMin = Math.floor(intervalSeconds / 60)
|
||||
form.setFieldsValue({
|
||||
windowStartMinutes: 0,
|
||||
windowStartSeconds: 0,
|
||||
windowEndMinutes: intervalMin,
|
||||
windowEndSeconds: 0
|
||||
})
|
||||
}, [formModalOpen, editingId, selectedMarket, intervalSeconds])
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 12 : 24 }}>
|
||||
<h1 style={{ marginBottom: 16, fontSize: isMobile ? 20 : 24 }}>{t('cryptoTailStrategy.list.title')}</h1>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t('cryptoTailStrategy.list.walletTip')}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Card>
|
||||
<div style={{ marginBottom: 16, display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAddModal}>
|
||||
{t('cryptoTailStrategy.list.addStrategy')}
|
||||
</Button>
|
||||
<Select
|
||||
placeholder={t('cryptoTailStrategy.form.selectAccount')}
|
||||
allowClear
|
||||
style={{ minWidth: 160 }}
|
||||
onChange={(id) => setFilters((f) => ({ ...f, accountId: id ?? undefined }))}
|
||||
value={filters.accountId}
|
||||
options={accounts.map((a) => ({ label: a.accountName || `#${a.id}`, value: a.id }))}
|
||||
/>
|
||||
<Select
|
||||
placeholder={t('common.status')}
|
||||
allowClear
|
||||
style={{ width: 100 }}
|
||||
onChange={(en) => setFilters((f) => ({ ...f, enabled: en }))}
|
||||
value={filters.enabled}
|
||||
options={[
|
||||
{ label: t('common.enabled'), value: true },
|
||||
{ label: t('common.disabled'), value: false }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Spin spinning={loading}>
|
||||
{isMobile ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{list.map((item) => (
|
||||
<Card key={item.id} size="small">
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<strong>{item.name || item.marketSlugPrefix || '-'}</strong>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>
|
||||
{t('cryptoTailStrategy.list.timeWindow')}: {formatTimeWindow(item.windowStartSeconds, item.windowEndSeconds)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>
|
||||
{t('cryptoTailStrategy.list.priceRange')}: [{item.minPrice}, {item.maxPrice}]
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>
|
||||
{item.amountMode === 'RATIO' ? `${item.amountValue}%` : `${formatUSDC(item.amountValue)} USDC`}
|
||||
</div>
|
||||
<Space>
|
||||
<Switch
|
||||
checked={item.enabled}
|
||||
onChange={() => handleToggle(item)}
|
||||
size="small"
|
||||
/>
|
||||
<Button type="link" size="small" onClick={() => openEditModal(item)}>
|
||||
{t('cryptoTailStrategy.list.edit')}
|
||||
</Button>
|
||||
<Button type="link" size="small" onClick={() => openTriggers(item.id)}>
|
||||
{t('cryptoTailStrategy.list.viewTriggers')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={t('cryptoTailStrategy.list.deleteConfirm')}
|
||||
onConfirm={() => handleDelete(item.id)}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
{t('cryptoTailStrategy.list.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
pagination={{ pageSize: 20 }}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={t('cryptoTailStrategy.redeemRequiredModal.title')}
|
||||
open={redeemModalOpen}
|
||||
onCancel={() => setRedeemModalOpen(false)}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={() => setRedeemModalOpen(false)}>
|
||||
{t('cryptoTailStrategy.redeemRequiredModal.cancel')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="go"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setRedeemModalOpen(false)
|
||||
navigate('/system-settings')
|
||||
}}
|
||||
>
|
||||
{t('cryptoTailStrategy.redeemRequiredModal.goToSettings')}
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<p>{t('cryptoTailStrategy.redeemRequiredModal.description')}</p>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={editingId ? t('cryptoTailStrategy.form.update') : t('cryptoTailStrategy.form.create')}
|
||||
open={formModalOpen}
|
||||
onCancel={() => setFormModalOpen(false)}
|
||||
onOk={handleFormSubmit}
|
||||
width={isMobile ? '100%' : 520}
|
||||
destroyOnClose
|
||||
>
|
||||
<Alert type="warning" showIcon message={t('cryptoTailStrategy.form.walletTip')} style={{ marginBottom: 16 }} />
|
||||
<Form form={form} layout="vertical" initialValues={{ amountMode: 'RATIO', maxPrice: '1', enabled: true }}>
|
||||
<Form.Item name="accountId" label={t('cryptoTailStrategy.form.selectAccount')} rules={[{ required: true }]}>
|
||||
<Select
|
||||
placeholder={t('cryptoTailStrategy.form.selectAccount')}
|
||||
options={accounts.map((a) => ({ label: a.accountName || `#${a.id}`, value: a.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label={t('cryptoTailStrategy.form.strategyName')}>
|
||||
<Input placeholder={t('cryptoTailStrategy.form.strategyNamePlaceholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="marketSlugPrefix" label={t('cryptoTailStrategy.form.selectMarket')} rules={[{ required: true }]}>
|
||||
<Select
|
||||
placeholder={t('cryptoTailStrategy.form.selectMarket')}
|
||||
options={marketOptions.map((m) => ({ label: m.title, value: m.slug }))}
|
||||
disabled={!!editingId}
|
||||
/>
|
||||
</Form.Item>
|
||||
{selectedMarket && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('cryptoTailStrategy.form.timeWindowStart')}
|
||||
required
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<Space>
|
||||
<Form.Item name="windowStartMinutes" noStyle rules={[{ required: true }]}>
|
||||
<Select
|
||||
style={{ width: 70 }}
|
||||
options={Array.from({ length: maxMinutes + 1 }, (_, i) => ({ label: `${i}`, value: i }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<span>{t('cryptoTailStrategy.form.minute')}</span>
|
||||
<Form.Item name="windowStartSeconds" noStyle rules={[{ required: true }]}>
|
||||
<Select
|
||||
style={{ width: 70 }}
|
||||
options={Array.from({ length: 60 }, (_, i) => ({ label: `${i}`, value: i }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<span>{t('cryptoTailStrategy.form.second')}</span>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('cryptoTailStrategy.form.timeWindowEnd')}
|
||||
required
|
||||
>
|
||||
<Space>
|
||||
<Form.Item name="windowEndMinutes" noStyle rules={[{ required: true }]}>
|
||||
<Select
|
||||
style={{ width: 70 }}
|
||||
options={Array.from({ length: maxMinutes + 1 }, (_, i) => ({ label: `${i}`, value: i }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<span>{t('cryptoTailStrategy.form.minute')}</span>
|
||||
<Form.Item name="windowEndSeconds" noStyle rules={[{ required: true }]}>
|
||||
<Select
|
||||
style={{ width: 70 }}
|
||||
options={Array.from({ length: 60 }, (_, i) => ({ label: `${i}`, value: i }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<span>{t('cryptoTailStrategy.form.second')}</span>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Form.Item name="minPrice" label={t('cryptoTailStrategy.form.minPrice')} rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={1} step={0.01} style={{ width: '100%' }} stringMode />
|
||||
</Form.Item>
|
||||
<Form.Item name="maxPrice" label={t('cryptoTailStrategy.form.maxPrice')}>
|
||||
<InputNumber min={0} max={1} step={0.01} placeholder={t('cryptoTailStrategy.form.maxPricePlaceholder')} style={{ width: '100%' }} stringMode />
|
||||
</Form.Item>
|
||||
<Form.Item name="amountMode" label={t('cryptoTailStrategy.form.amountMode')} rules={[{ required: true }]}>
|
||||
<Radio.Group>
|
||||
<Radio value="RATIO">{t('cryptoTailStrategy.list.ratio')}</Radio>
|
||||
<Radio value="FIXED">{t('cryptoTailStrategy.list.fixed')}</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) => prev.amountMode !== curr.amountMode}
|
||||
>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue('amountMode') === 'RATIO' ? (
|
||||
<Form.Item name="amountValue" label={t('cryptoTailStrategy.form.ratioPercent')} rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={100} step={1} style={{ width: '100%' }} addonAfter="%" stringMode />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item name="amountValue" label={t('cryptoTailStrategy.form.fixedUsdc')} rules={[{ required: true }]}>
|
||||
<InputNumber min={1} style={{ width: '100%' }} addonAfter="USDC" stringMode />
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" valuePropName="checked">
|
||||
<Switch checkedChildren={t('common.enabled')} unCheckedChildren={t('common.disabled')} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={t('cryptoTailStrategy.triggerRecords.title')}
|
||||
open={triggersModalOpen}
|
||||
onCancel={() => setTriggersModalOpen(false)}
|
||||
footer={null}
|
||||
width={Math.min(800, window.innerWidth - 48)}
|
||||
>
|
||||
<Spin spinning={triggersLoading}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={triggers}
|
||||
columns={[
|
||||
{
|
||||
title: t('cryptoTailStrategy.triggerRecords.triggerTime'),
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (ts: number) => new Date(ts).toLocaleString()
|
||||
},
|
||||
{
|
||||
title: t('cryptoTailStrategy.triggerRecords.market'),
|
||||
dataIndex: 'marketTitle',
|
||||
key: 'marketTitle',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: t('cryptoTailStrategy.triggerRecords.direction'),
|
||||
dataIndex: 'outcomeIndex',
|
||||
key: 'outcomeIndex',
|
||||
render: (i: number) => (i === 0 ? t('cryptoTailStrategy.triggerRecords.up') : t('cryptoTailStrategy.triggerRecords.down'))
|
||||
},
|
||||
{
|
||||
title: t('cryptoTailStrategy.triggerRecords.triggerPrice'),
|
||||
dataIndex: 'triggerPrice',
|
||||
key: 'triggerPrice'
|
||||
},
|
||||
{
|
||||
title: t('cryptoTailStrategy.triggerRecords.amount'),
|
||||
dataIndex: 'amountUsdc',
|
||||
key: 'amountUsdc',
|
||||
render: (v: string) => `${formatUSDC(v)} USDC`
|
||||
},
|
||||
{
|
||||
title: t('cryptoTailStrategy.triggerRecords.orderId'),
|
||||
dataIndex: 'orderId',
|
||||
key: 'orderId',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: t('cryptoTailStrategy.triggerRecords.status'),
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (s: string) => (
|
||||
<Tag color={s === 'success' ? 'green' : 'red'}>
|
||||
{s === 'success' ? t('cryptoTailStrategy.triggerRecords.success') : t('cryptoTailStrategy.triggerRecords.fail')}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
]}
|
||||
pagination={false}
|
||||
scroll={{ x: 600 }}
|
||||
/>
|
||||
</Spin>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CryptoTailStrategyList
|
||||
@@ -429,6 +429,46 @@ export const apiService = {
|
||||
}) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/configs/filtered-orders', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 尾盘策略 API
|
||||
*/
|
||||
cryptoTailStrategy: {
|
||||
list: (data: { accountId?: number; enabled?: boolean } = {}) =>
|
||||
apiClient.post<ApiResponse<{ list: import('../types').CryptoTailStrategyDto[] }>>('/crypto-tail-strategy/list', data),
|
||||
create: (data: {
|
||||
accountId: number
|
||||
name?: string
|
||||
marketSlugPrefix: string
|
||||
intervalSeconds: number
|
||||
windowStartSeconds: number
|
||||
windowEndSeconds: number
|
||||
minPrice: string
|
||||
maxPrice?: string
|
||||
amountMode: string
|
||||
amountValue: string
|
||||
enabled?: boolean
|
||||
}) =>
|
||||
apiClient.post<ApiResponse<import('../types').CryptoTailStrategyDto>>('/crypto-tail-strategy/create', data),
|
||||
update: (data: {
|
||||
strategyId: number
|
||||
name?: string
|
||||
windowStartSeconds?: number
|
||||
windowEndSeconds?: number
|
||||
minPrice?: string
|
||||
maxPrice?: string
|
||||
amountMode?: string
|
||||
amountValue?: string
|
||||
enabled?: boolean
|
||||
}) =>
|
||||
apiClient.post<ApiResponse<import('../types').CryptoTailStrategyDto>>('/crypto-tail-strategy/update', data),
|
||||
delete: (data: { strategyId: number }) =>
|
||||
apiClient.post<ApiResponse<void>>('/crypto-tail-strategy/delete', data),
|
||||
triggers: (data: { strategyId: number; page?: number; pageSize?: number; status?: string }) =>
|
||||
apiClient.post<ApiResponse<{ list: import('../types').CryptoTailStrategyTriggerDto[]; total: number }>>('/crypto-tail-strategy/triggers', data),
|
||||
marketOptions: () =>
|
||||
apiClient.post<ApiResponse<import('../types').CryptoTailMarketOptionDto[]>>('/crypto-tail-strategy/market-options', {})
|
||||
},
|
||||
|
||||
/**
|
||||
* 订单管理 API
|
||||
|
||||
@@ -1034,3 +1034,53 @@ export interface BacktestTaskDto {
|
||||
executionStartedAt?: number
|
||||
executionFinishedAt?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 尾盘策略
|
||||
*/
|
||||
export interface CryptoTailStrategyDto {
|
||||
id: number
|
||||
accountId: number
|
||||
name?: string
|
||||
marketSlugPrefix: string
|
||||
marketTitle?: string
|
||||
intervalSeconds: number
|
||||
windowStartSeconds: number
|
||||
windowEndSeconds: number
|
||||
minPrice: string
|
||||
maxPrice: string
|
||||
amountMode: string
|
||||
amountValue: string
|
||||
enabled: boolean
|
||||
lastTriggerAt?: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 尾盘策略触发记录
|
||||
*/
|
||||
export interface CryptoTailStrategyTriggerDto {
|
||||
id: number
|
||||
strategyId: number
|
||||
periodStartUnix: number
|
||||
marketTitle?: string
|
||||
outcomeIndex: number
|
||||
triggerPrice: string
|
||||
amountUsdc: string
|
||||
orderId?: string
|
||||
status: string
|
||||
failReason?: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 尾盘策略市场选项
|
||||
*/
|
||||
export interface CryptoTailMarketOptionDto {
|
||||
slug: string
|
||||
title: string
|
||||
intervalSeconds: number
|
||||
periodStartUnix: number
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user