feat: add copy trading safety and leader pool
This commit is contained in:
@@ -14,6 +14,7 @@ import AccountImport from './pages/AccountImport'
|
||||
import AccountDetail from './pages/AccountDetail'
|
||||
import AccountEdit from './pages/AccountEdit'
|
||||
import LeaderList from './pages/LeaderList'
|
||||
import LeaderPool from './pages/LeaderPool'
|
||||
import LeaderAdd from './pages/LeaderAdd'
|
||||
import LeaderEdit from './pages/LeaderEdit'
|
||||
import ConfigPage from './pages/ConfigPage'
|
||||
@@ -265,6 +266,7 @@ function App() {
|
||||
<Route path="/accounts/detail" element={<ProtectedRoute><AccountDetail /></ProtectedRoute>} />
|
||||
<Route path="/accounts/edit" element={<ProtectedRoute><AccountEdit /></ProtectedRoute>} />
|
||||
<Route path="/leaders" element={<ProtectedRoute><LeaderList /></ProtectedRoute>} />
|
||||
<Route path="/leader-pool" element={<ProtectedRoute><LeaderPool /></ProtectedRoute>} />
|
||||
<Route path="/leaders/add" element={<ProtectedRoute><LeaderAdd /></ProtectedRoute>} />
|
||||
<Route path="/leaders/edit" element={<ProtectedRoute><LeaderEdit /></ProtectedRoute>} />
|
||||
<Route path="/templates" element={<ProtectedRoute><TemplateList /></ProtectedRoute>} />
|
||||
@@ -300,4 +302,3 @@ function App() {
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Alert, Button, Card, Col, Modal, Row, Space, Statistic, Table, Tag, message } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { apiService } from '../services/api'
|
||||
import type { CopyTradingStatistics } from '../types'
|
||||
import { formatUSDC } from '../utils'
|
||||
|
||||
interface Props {
|
||||
statistics: CopyTradingStatistics
|
||||
onApplied?: () => void
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const fieldLabels: Record<string, string> = {
|
||||
maxDailyOrders: '每日最大订单数',
|
||||
maxDailyLoss: '每日最大亏损',
|
||||
minPrice: '最低价格',
|
||||
maxPrice: '最高价格',
|
||||
maxPositionValue: '单市场最大仓位',
|
||||
minOrderDepth: '最小深度',
|
||||
maxSpread: '最大价差',
|
||||
priceTolerance: '价格容忍度'
|
||||
}
|
||||
|
||||
const statusText: Record<string, string> = {
|
||||
AVAILABLE: '报价可用',
|
||||
NO_MATCH: '有持仓未匹配到报价',
|
||||
UNAVAILABLE: '报价不可用'
|
||||
}
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
AVAILABLE: 'green',
|
||||
NO_MATCH: 'orange',
|
||||
UNAVAILABLE: 'red'
|
||||
}
|
||||
|
||||
type ApplyConservativeConfigPayload = Parameters<typeof apiService.safetyConfig.applyConservative>[0]
|
||||
|
||||
const CopyTradingRiskSeatbeltPanel: React.FC<Props> = ({ statistics, onApplied, compact = false }) => {
|
||||
const diagnosis = statistics.riskDiagnosis
|
||||
const [applying, setApplying] = useState(false)
|
||||
|
||||
if (!diagnosis) {
|
||||
return (
|
||||
<Card title="风险安全带" style={{ marginTop: 16 }}>
|
||||
<Alert type="info" showIcon message="暂无诊断数据" description="旧接口仍可展示基础统计,诊断数据生成后这里会显示亏损归因和风控建议。" />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const riskWarnings = diagnosis.riskWarnings || []
|
||||
const dangerousWarnings = riskWarnings.filter(item => item.severity === 'HIGH' || item.severity === 'MEDIUM')
|
||||
|
||||
const buildApplyPayload = (): ApplyConservativeConfigPayload => {
|
||||
const payload: ApplyConservativeConfigPayload = {
|
||||
copyTradingId: statistics.copyTradingId,
|
||||
confirm: true
|
||||
}
|
||||
riskWarnings.forEach(item => {
|
||||
if (item.suggestedValue === undefined || item.suggestedValue === null) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (item.field) {
|
||||
case 'maxDailyOrders':
|
||||
payload.maxDailyOrders = Number(item.suggestedValue)
|
||||
break
|
||||
case 'maxDailyLoss':
|
||||
payload.maxDailyLoss = item.suggestedValue
|
||||
break
|
||||
case 'minPrice':
|
||||
payload.minPrice = item.suggestedValue
|
||||
break
|
||||
case 'maxPrice':
|
||||
payload.maxPrice = item.suggestedValue
|
||||
break
|
||||
case 'maxPositionValue':
|
||||
payload.maxPositionValue = item.suggestedValue
|
||||
break
|
||||
case 'minOrderDepth':
|
||||
payload.minOrderDepth = item.suggestedValue
|
||||
break
|
||||
case 'maxSpread':
|
||||
payload.maxSpread = item.suggestedValue
|
||||
break
|
||||
case 'priceTolerance':
|
||||
payload.priceTolerance = item.suggestedValue
|
||||
break
|
||||
}
|
||||
})
|
||||
return payload
|
||||
}
|
||||
|
||||
const applyConservativeConfig = () => {
|
||||
if (dangerousWarnings.length === 0) {
|
||||
message.info('当前配置已经比较保守,无需应用安全带建议')
|
||||
return
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认应用保守配置?',
|
||||
content: (
|
||||
<div>
|
||||
<p>这只会修改风控白名单字段,不会启用/停用跟单,也不会更换 leader。</p>
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="field"
|
||||
dataSource={dangerousWarnings}
|
||||
columns={[
|
||||
{ title: '字段', dataIndex: 'field', render: (field: string) => fieldLabels[field] || field },
|
||||
{ title: '当前值', dataIndex: 'currentValue', render: (value: string | null) => value ?? '未设置' },
|
||||
{ title: '建议值', dataIndex: 'suggestedValue' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
okText: '确认应用',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
setApplying(true)
|
||||
try {
|
||||
const response = await apiService.safetyConfig.applyConservative(buildApplyPayload())
|
||||
if (response.data.code === 0) {
|
||||
message.success('已应用保守配置')
|
||||
onApplied?.()
|
||||
} else {
|
||||
message.error(response.data.msg || '应用保守配置失败')
|
||||
}
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="亏损归因与风险安全带" style={{ marginTop: compact ? 12 : 16 }}>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
{diagnosis.dataIncomplete && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="诊断数据不完整"
|
||||
description={`缺失来源:${diagnosis.missingSources.join('、') || '未知'}。系统不会把未知估值当作已确认归零。`}
|
||||
/>
|
||||
)}
|
||||
{diagnosis.lowConfidence && (
|
||||
<Alert type="info" showIcon message="低置信度" description={diagnosis.confidenceReason} />
|
||||
)}
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Statistic title="归零/未知持仓成本" value={formatUSDC(diagnosis.zeroValuePositionCost)} prefix="$" />
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Statistic title="已确认归零成本" value={formatUSDC(diagnosis.confirmedZeroValuePositionCost)} prefix="$" />
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Statistic title="卖出归零亏损" value={formatUSDC(diagnosis.zeroSellLoss)} prefix="$" />
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<div style={{ color: '#999', fontSize: 14, marginBottom: 4 }}>报价状态</div>
|
||||
<Tag color={statusColor[diagnosis.quoteOverallStatus] || 'default'}>
|
||||
{statusText[diagnosis.quoteOverallStatus] || diagnosis.quoteOverallStatus}
|
||||
</Tag>
|
||||
<div style={{ color: '#999', marginTop: 8, fontSize: 12 }}>
|
||||
可用 {diagnosis.quoteAvailableCount},未匹配 {diagnosis.quoteNoMatchCount},不可用 {diagnosis.quoteUnavailableCount}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div>
|
||||
<h4 style={{ marginBottom: 8 }}>亏损最大的市场</h4>
|
||||
{diagnosis.topLosingMarkets.length === 0 ? (
|
||||
<Alert type="success" showIcon message="暂无已实现亏损市场" />
|
||||
) : (
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="marketId"
|
||||
dataSource={diagnosis.topLosingMarkets}
|
||||
columns={[
|
||||
{ title: '市场', dataIndex: 'marketId' },
|
||||
{ title: '已实现 PnL', dataIndex: 'realizedPnl', render: (value: string) => `$${formatUSDC(value)}` },
|
||||
{ title: '匹配数', dataIndex: 'matchedOrders' }
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 style={{ marginBottom: 8 }}>风险配置体检</h4>
|
||||
{riskWarnings.length === 0 ? (
|
||||
<Alert type="success" showIcon message="当前配置已经比较保守" />
|
||||
) : (
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="field"
|
||||
dataSource={riskWarnings}
|
||||
columns={[
|
||||
{ title: '字段', dataIndex: 'field', render: (field: string) => fieldLabels[field] || field },
|
||||
{ title: '当前值', dataIndex: 'currentValue', render: (value: string | null) => value ?? '未设置' },
|
||||
{ title: '建议值', dataIndex: 'suggestedValue' },
|
||||
{ title: '级别', dataIndex: 'severity', render: (severity: string) => <Tag color={severity === 'HIGH' ? 'red' : 'orange'}>{severity}</Tag> },
|
||||
{ title: '原因', dataIndex: 'reason' }
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="primary" danger disabled={dangerousWarnings.length === 0} loading={applying} onClick={applyConservativeConfig}>
|
||||
应用保守配置
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default CopyTradingRiskSeatbeltPanel
|
||||
@@ -74,7 +74,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
const getInitialOpenKeys = (): string[] => {
|
||||
const path = location.pathname
|
||||
const keys: string[] = []
|
||||
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
|
||||
if (path.startsWith('/leaders') || path.startsWith('/leader-pool') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
|
||||
keys.push('/copy-trading-management')
|
||||
}
|
||||
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) {
|
||||
@@ -92,7 +92,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
useEffect(() => {
|
||||
const path = location.pathname
|
||||
const keys: string[] = []
|
||||
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
|
||||
if (path.startsWith('/leaders') || path.startsWith('/leader-pool') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
|
||||
keys.push('/copy-trading-management')
|
||||
}
|
||||
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) {
|
||||
@@ -148,6 +148,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
icon: <LinkOutlined />,
|
||||
label: t('menu.copyTradingConfig')
|
||||
},
|
||||
{
|
||||
key: '/leader-pool',
|
||||
icon: <TeamOutlined />,
|
||||
label: t('menu.leaderPool')
|
||||
},
|
||||
{
|
||||
key: '/leaders',
|
||||
icon: <UserOutlined />,
|
||||
@@ -504,4 +509,3 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
}
|
||||
|
||||
export default Layout
|
||||
|
||||
|
||||
@@ -314,6 +314,7 @@
|
||||
"leaders": "Leader Management",
|
||||
"templates": "Templates",
|
||||
"copyTradingConfig": "Copy Trading Config",
|
||||
"leaderPool": "Leader Pool",
|
||||
"cryptoSpreadStrategy": "Crypto Spread Strategy",
|
||||
"cryptoTailStrategy": "Strategy Config",
|
||||
"cryptoTailMonitor": "Real-time Monitor",
|
||||
@@ -552,7 +553,68 @@
|
||||
"deleteConfirmDesc": "This Leader has {{count}} copy trading relations, please delete them first",
|
||||
"openDetailFailed": "Failed to open detail",
|
||||
"noCopyTradings": "No copy trading configs",
|
||||
"noBacktests": "No backtests"
|
||||
"noBacktests": "No backtests",
|
||||
"addToPool": "Add to Leader Pool",
|
||||
"addToPoolSuccess": "Added to Leader Pool",
|
||||
"addToPoolExists": "This Leader is already in the pool",
|
||||
"addToPoolFailed": "Failed to add to Leader Pool",
|
||||
"goLeaderPool": "Go to Leader Pool"
|
||||
},
|
||||
"leaderPool": {
|
||||
"title": "Leader Pool",
|
||||
"subtitle": "Separate candidate, watch, trial, cooldown, and retired decisions from real copy trading configs.",
|
||||
"safetyHint": "Leader Pool will not auto-scale positions or auto-enable large copy trading.",
|
||||
"safetyDesc": "Trial configs created from the pool use conservative defaults and are disabled by default.",
|
||||
"fetchFailed": "Failed to fetch Leader Pool",
|
||||
"selectLeaderPlaceholder": "Select an existing Leader to add",
|
||||
"selectLeaderFirst": "Please select a Leader first",
|
||||
"addExistingLeader": "Add to Pool",
|
||||
"addSuccess": "Added to Leader Pool",
|
||||
"addFailed": "Failed to add to Leader Pool",
|
||||
"goLeaders": "Go to Leader Management",
|
||||
"noLeaders": "No Leaders yet",
|
||||
"totalCount": "Pool Size",
|
||||
"trialCount": "In Trial",
|
||||
"estimatedWorstExposure": "Worst Exposure",
|
||||
"pendingRiskCount": "Pending Risks",
|
||||
"filterStatus": "Filter by status",
|
||||
"emptyDesc": "The pool is empty. Add leaders from Leader Management or select an existing Leader here.",
|
||||
"leader": "Leader",
|
||||
"source": "Source",
|
||||
"suggestedConfig": "Suggested Config",
|
||||
"fixedAmount": "Fixed Amount",
|
||||
"maxDailyOrders": "Max Daily Orders",
|
||||
"maxDailyLoss": "Max Daily Loss",
|
||||
"minPrice": "Min Price",
|
||||
"maxPrice": "Max Price",
|
||||
"maxPositionValue": "Max Position",
|
||||
"priceRange": "Price Range",
|
||||
"copyTradingState": "Copy Trading State",
|
||||
"configs": "configs",
|
||||
"hasEnabled": "Has enabled config",
|
||||
"noEnabled": "No enabled config",
|
||||
"lastReviewedAt": "Last Reviewed",
|
||||
"updateStatus": "Update Status",
|
||||
"statusUpdated": "Status updated",
|
||||
"statusUpdateFailed": "Failed to update status",
|
||||
"cooldownUntil": "Cooldown Until",
|
||||
"locked": "Locked",
|
||||
"editPlan": "Edit Plan",
|
||||
"planUpdated": "Plan updated",
|
||||
"planUpdateFailed": "Failed to update plan",
|
||||
"notes": "Notes",
|
||||
"createTrial": "Create Trial Config",
|
||||
"trialConfirmTitle": "This creates a disabled FIXED small trial config",
|
||||
"trialConfirmDesc": "Confirm fixed amount, max daily orders, max daily loss, price range, and max position before submitting.",
|
||||
"account": "Account",
|
||||
"selectAccount": "Please select an account",
|
||||
"noAccounts": "No accounts yet",
|
||||
"trialCreated": "Trial config created",
|
||||
"trialCreateFailed": "Failed to create trial config",
|
||||
"goCopyTrading": "View Copy Trading",
|
||||
"removeConfirm": "This only removes the pool item. It will not delete the Leader address or existing copy trading configs. Continue?",
|
||||
"removeSuccess": "Pool item removed",
|
||||
"removeFailed": "Failed to remove pool item"
|
||||
},
|
||||
"leaderAdd": {
|
||||
"title": "Add Leader",
|
||||
@@ -1811,4 +1873,4 @@
|
||||
"accountGuideButton": "USDC.e → pUSD",
|
||||
"dismissGuide": "Got it"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,6 +314,7 @@
|
||||
"leaders": "Leader 管理",
|
||||
"templates": "跟单模板",
|
||||
"copyTradingConfig": "跟单配置",
|
||||
"leaderPool": "Leader 池",
|
||||
"cryptoSpreadStrategy": "加密价差策略",
|
||||
"cryptoTailStrategy": "策略配置",
|
||||
"cryptoTailMonitor": "实时监控",
|
||||
@@ -552,7 +553,68 @@
|
||||
"deleteConfirmDesc": "该 Leader 还有 {{count}} 个跟单关系,请先删除跟单关系",
|
||||
"openDetailFailed": "打开详情失败",
|
||||
"noCopyTradings": "暂无跟单配置",
|
||||
"noBacktests": "暂无回测"
|
||||
"noBacktests": "暂无回测",
|
||||
"addToPool": "加入 Leader 池",
|
||||
"addToPoolSuccess": "已加入 Leader 池",
|
||||
"addToPoolExists": "该 Leader 已在池子中",
|
||||
"addToPoolFailed": "加入 Leader 池失败",
|
||||
"goLeaderPool": "前往 Leader 池"
|
||||
},
|
||||
"leaderPool": {
|
||||
"title": "Leader 池",
|
||||
"subtitle": "把候选、观察、小额试跟、冷却和淘汰从真实跟单配置里拆出来,先决策,再下真钱。",
|
||||
"safetyHint": "Leader 池不会自动加仓,也不会自动启用大额跟单。",
|
||||
"safetyDesc": "从池子创建试跟配置时会使用保守默认值,并默认禁用。你可以之后在跟单配置页手动启用。",
|
||||
"fetchFailed": "获取 Leader 池失败",
|
||||
"selectLeaderPlaceholder": "选择已有 Leader 加入池子",
|
||||
"selectLeaderFirst": "请先选择一个 Leader",
|
||||
"addExistingLeader": "加入池子",
|
||||
"addSuccess": "加入 Leader 池成功",
|
||||
"addFailed": "加入 Leader 池失败",
|
||||
"goLeaders": "去 Leader 管理",
|
||||
"noLeaders": "暂无 Leader,请先添加",
|
||||
"totalCount": "池子人数",
|
||||
"trialCount": "试跟中人数",
|
||||
"estimatedWorstExposure": "估算最坏暴露",
|
||||
"pendingRiskCount": "待处理风险",
|
||||
"filterStatus": "按状态筛选",
|
||||
"emptyDesc": "池子还是空的,可以从 Leader 管理加入 leader,或在本页选择已有 leader 加入。",
|
||||
"leader": "Leader",
|
||||
"source": "来源",
|
||||
"suggestedConfig": "建议配置",
|
||||
"fixedAmount": "固定金额",
|
||||
"maxDailyOrders": "每日最大单数",
|
||||
"maxDailyLoss": "每日最大亏损",
|
||||
"minPrice": "最低价格",
|
||||
"maxPrice": "最高价格",
|
||||
"maxPositionValue": "最大持仓",
|
||||
"priceRange": "价格区间",
|
||||
"copyTradingState": "跟单配置状态",
|
||||
"configs": "个配置",
|
||||
"hasEnabled": "存在启用配置",
|
||||
"noEnabled": "无启用配置",
|
||||
"lastReviewedAt": "最后复核",
|
||||
"updateStatus": "更新状态",
|
||||
"statusUpdated": "状态已更新",
|
||||
"statusUpdateFailed": "状态更新失败",
|
||||
"cooldownUntil": "冷却截止时间",
|
||||
"locked": "锁定",
|
||||
"editPlan": "编辑建议",
|
||||
"planUpdated": "建议配置已更新",
|
||||
"planUpdateFailed": "建议配置更新失败",
|
||||
"notes": "备注",
|
||||
"createTrial": "创建试跟配置",
|
||||
"trialConfirmTitle": "将创建默认禁用的小额 FIXED 跟单配置",
|
||||
"trialConfirmDesc": "提交前请确认固定金额、每日最大单数、每日最大亏损、价格区间和最大持仓。",
|
||||
"account": "账户",
|
||||
"selectAccount": "请选择账户",
|
||||
"noAccounts": "暂无账户,请先添加账户",
|
||||
"trialCreated": "试跟配置已创建",
|
||||
"trialCreateFailed": "创建试跟配置失败",
|
||||
"goCopyTrading": "查看跟单配置",
|
||||
"removeConfirm": "只移除池子项,不会删除 Leader 地址或已有跟单配置。确定继续?",
|
||||
"removeSuccess": "池子项已移除",
|
||||
"removeFailed": "移除池子项失败"
|
||||
},
|
||||
"leaderAdd": {
|
||||
"title": "添加 Leader",
|
||||
@@ -1811,4 +1873,4 @@
|
||||
"accountGuideButton": "USDC.e → pUSD",
|
||||
"dismissGuide": "知道了"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,6 +314,7 @@
|
||||
"leaders": "Leader 管理",
|
||||
"templates": "跟單模板",
|
||||
"copyTradingConfig": "跟單配置",
|
||||
"leaderPool": "Leader 池",
|
||||
"cryptoSpreadStrategy": "加密價差策略",
|
||||
"cryptoTailStrategy": "策略配置",
|
||||
"cryptoTailMonitor": "即時監控",
|
||||
@@ -552,7 +553,68 @@
|
||||
"deleteConfirmDesc": "該 Leader 還有 {{count}} 個跟單關係,請先刪除跟單關係",
|
||||
"openDetailFailed": "打開詳情失敗",
|
||||
"noCopyTradings": "暫無跟單配置",
|
||||
"noBacktests": "暫無回測"
|
||||
"noBacktests": "暫無回測",
|
||||
"addToPool": "加入 Leader 池",
|
||||
"addToPoolSuccess": "已加入 Leader 池",
|
||||
"addToPoolExists": "該 Leader 已在池子中",
|
||||
"addToPoolFailed": "加入 Leader 池失敗",
|
||||
"goLeaderPool": "前往 Leader 池"
|
||||
},
|
||||
"leaderPool": {
|
||||
"title": "Leader 池",
|
||||
"subtitle": "把候選、觀察、小額試跟、冷卻和淘汰從真實跟單配置中拆出來,先決策,再下真錢。",
|
||||
"safetyHint": "Leader 池不會自動加倉,也不會自動啟用大額跟單。",
|
||||
"safetyDesc": "從池子創建試跟配置時會使用保守默認值,並默認禁用。你可以之後在跟單配置頁手動啟用。",
|
||||
"fetchFailed": "獲取 Leader 池失敗",
|
||||
"selectLeaderPlaceholder": "選擇已有 Leader 加入池子",
|
||||
"selectLeaderFirst": "請先選擇一個 Leader",
|
||||
"addExistingLeader": "加入池子",
|
||||
"addSuccess": "加入 Leader 池成功",
|
||||
"addFailed": "加入 Leader 池失敗",
|
||||
"goLeaders": "去 Leader 管理",
|
||||
"noLeaders": "暫無 Leader,請先添加",
|
||||
"totalCount": "池子人數",
|
||||
"trialCount": "試跟中人數",
|
||||
"estimatedWorstExposure": "估算最壞暴露",
|
||||
"pendingRiskCount": "待處理風險",
|
||||
"filterStatus": "按狀態篩選",
|
||||
"emptyDesc": "池子還是空的,可以從 Leader 管理加入 leader,或在本頁選擇已有 leader 加入。",
|
||||
"leader": "Leader",
|
||||
"source": "來源",
|
||||
"suggestedConfig": "建議配置",
|
||||
"fixedAmount": "固定金額",
|
||||
"maxDailyOrders": "每日最大單數",
|
||||
"maxDailyLoss": "每日最大虧損",
|
||||
"minPrice": "最低價格",
|
||||
"maxPrice": "最高價格",
|
||||
"maxPositionValue": "最大持倉",
|
||||
"priceRange": "價格區間",
|
||||
"copyTradingState": "跟單配置狀態",
|
||||
"configs": "個配置",
|
||||
"hasEnabled": "存在啟用配置",
|
||||
"noEnabled": "無啟用配置",
|
||||
"lastReviewedAt": "最後複核",
|
||||
"updateStatus": "更新狀態",
|
||||
"statusUpdated": "狀態已更新",
|
||||
"statusUpdateFailed": "狀態更新失敗",
|
||||
"cooldownUntil": "冷卻截止時間",
|
||||
"locked": "鎖定",
|
||||
"editPlan": "編輯建議",
|
||||
"planUpdated": "建議配置已更新",
|
||||
"planUpdateFailed": "建議配置更新失敗",
|
||||
"notes": "備註",
|
||||
"createTrial": "創建試跟配置",
|
||||
"trialConfirmTitle": "將創建默認禁用的小額 FIXED 跟單配置",
|
||||
"trialConfirmDesc": "提交前請確認固定金額、每日最大單數、每日最大虧損、價格區間和最大持倉。",
|
||||
"account": "賬戶",
|
||||
"selectAccount": "請選擇賬戶",
|
||||
"noAccounts": "暫無賬戶,請先添加賬戶",
|
||||
"trialCreated": "試跟配置已創建",
|
||||
"trialCreateFailed": "創建試跟配置失敗",
|
||||
"goCopyTrading": "查看跟單配置",
|
||||
"removeConfirm": "只移除池子項,不會刪除 Leader 地址或已有跟單配置。確定繼續?",
|
||||
"removeSuccess": "池子項已移除",
|
||||
"removeFailed": "移除池子項失敗"
|
||||
},
|
||||
"leaderAdd": {
|
||||
"title": "添加 Leader",
|
||||
@@ -1811,4 +1873,4 @@
|
||||
"accountGuideButton": "USDC.e → pUSD",
|
||||
"dismissGuide": "知道了"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { formatUSDC } from '../../utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import type { CopyTradingStatistics } from '../../types'
|
||||
import CopyTradingRiskSeatbeltPanel from '../../components/CopyTradingRiskSeatbeltPanel'
|
||||
|
||||
interface StatisticsModalProps {
|
||||
open: boolean
|
||||
@@ -159,6 +160,7 @@ const StatisticsModal: React.FC<StatisticsModalProps> = ({
|
||||
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>${formatUSDC(statistics.totalUnrealizedPnl)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<CopyTradingRiskSeatbeltPanel statistics={statistics} onApplied={fetchStatistics} compact />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
@@ -230,6 +232,7 @@ const StatisticsModal: React.FC<StatisticsModalProps> = ({
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<CopyTradingRiskSeatbeltPanel statistics={statistics} onApplied={fetchStatistics} compact />
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
@@ -237,4 +240,3 @@ const StatisticsModal: React.FC<StatisticsModalProps> = ({
|
||||
}
|
||||
|
||||
export default StatisticsModal
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { apiService } from '../services/api'
|
||||
import { formatUSDC, formatNumber } from '../utils'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import type { CopyTradingStatistics } from '../types'
|
||||
import CopyTradingRiskSeatbeltPanel from '../components/CopyTradingRiskSeatbeltPanel'
|
||||
|
||||
const CopyTradingStatisticsPage: React.FC = () => {
|
||||
const { copyTradingId } = useParams<{ copyTradingId: string }>()
|
||||
@@ -263,9 +264,10 @@ const CopyTradingStatisticsPage: React.FC = () => {
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<CopyTradingRiskSeatbeltPanel statistics={statistics} onApplied={fetchStatistics} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CopyTradingStatisticsPage
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider, Typography, Modal, Descriptions, Statistic, Row, Col, Tooltip, Badge } from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, GlobalOutlined, EyeOutlined, ReloadOutlined, WalletOutlined, CopyOutlined, LineChartOutlined } from '@ant-design/icons'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, GlobalOutlined, EyeOutlined, ReloadOutlined, WalletOutlined, CopyOutlined, LineChartOutlined, TeamOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { apiService } from '../services/api'
|
||||
import type { Leader, LeaderBalanceResponse } from '../types'
|
||||
@@ -18,6 +18,7 @@ const LeaderList: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [balanceMap, setBalanceMap] = useState<Record<number, { total: string; available: string; position: string }>>({})
|
||||
const [balanceLoading, setBalanceLoading] = useState<Record<number, boolean>>({})
|
||||
const [addingToPool, setAddingToPool] = useState<Record<number, boolean>>({})
|
||||
|
||||
// 详情 Modal
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false)
|
||||
@@ -95,6 +96,31 @@ const LeaderList: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddToPool = async (leader: Leader) => {
|
||||
setAddingToPool(prev => ({ ...prev, [leader.id]: true }))
|
||||
try {
|
||||
const response = await apiService.leaderPool.add({ leaderId: leader.id })
|
||||
if (response.data.code === 0) {
|
||||
message.success({
|
||||
content: (
|
||||
<Space>
|
||||
<span>{t('leaderList.addToPoolSuccess')}</span>
|
||||
<Button type="link" size="small" onClick={() => navigate('/leader-pool')}>
|
||||
{t('leaderList.goLeaderPool')}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
})
|
||||
} else {
|
||||
message.warning(response.data.msg || t('leaderList.addToPoolExists'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('leaderList.addToPoolFailed'))
|
||||
} finally {
|
||||
setAddingToPool(prev => ({ ...prev, [leader.id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleShowDetail = async (leader: Leader) => {
|
||||
try {
|
||||
setDetailModalVisible(true)
|
||||
@@ -386,6 +412,26 @@ const LeaderList: React.FC = () => {
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={t('leaderList.addToPool')}>
|
||||
<div
|
||||
onClick={() => !addingToPool[record.id] && handleAddToPool(record)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
cursor: addingToPool[record.id] ? 'wait' : 'pointer',
|
||||
borderRadius: '6px',
|
||||
transition: 'background-color 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<TeamOutlined style={{ fontSize: '16px', color: '#52c41a' }} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Popconfirm
|
||||
title={t('leaderList.deleteConfirm')}
|
||||
description={record.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: record.copyTradingCount }) : undefined}
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Col,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message
|
||||
} from 'antd'
|
||||
import { EyeOutlined, LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined } from '@ant-design/icons'
|
||||
import dayjs from 'dayjs'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { apiService } from '../services/api'
|
||||
import type { Account, Leader, LeaderPoolItem, LeaderPoolListResponse, LeaderPoolStatus } from '../types'
|
||||
|
||||
const { Text, Title, Paragraph } = Typography
|
||||
|
||||
const VISIBLE_STATUSES: Array<{ value: LeaderPoolStatus; color: string }> = [
|
||||
{ value: 'CANDIDATE', color: 'default' },
|
||||
{ value: 'WATCH', color: 'blue' },
|
||||
{ value: 'TRIAL', color: 'green' },
|
||||
{ value: 'COOLDOWN', color: 'orange' },
|
||||
{ value: 'RETIRED', color: 'red' }
|
||||
]
|
||||
|
||||
type LeaderPoolFilterValue = LeaderPoolStatus | 'ALL'
|
||||
|
||||
const statusLabels: Record<LeaderPoolStatus, string> = {
|
||||
CANDIDATE: '候选',
|
||||
WATCH: '观察',
|
||||
PAPER: '模拟',
|
||||
TRIAL: '小额试跟',
|
||||
ACTIVE: '活跃',
|
||||
COOLDOWN: '冷却',
|
||||
RETIRED: '淘汰'
|
||||
}
|
||||
|
||||
const formatDate = (timestamp?: number) => {
|
||||
if (!timestamp) return '-'
|
||||
return dayjs(timestamp).format('YYYY-MM-DD HH:mm')
|
||||
}
|
||||
|
||||
const LeaderPool: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [poolData, setPoolData] = useState<LeaderPoolListResponse>({
|
||||
summary: {
|
||||
totalCount: 0,
|
||||
trialCount: 0,
|
||||
estimatedWorstExposure: '0',
|
||||
pendingRiskCount: 0,
|
||||
defaultExperimentBudget: '50'
|
||||
},
|
||||
list: [],
|
||||
total: 0
|
||||
})
|
||||
const [leaders, setLeaders] = useState<Leader[]>([])
|
||||
const [accounts, setAccounts] = useState<Account[]>([])
|
||||
const [statusFilter, setStatusFilter] = useState<LeaderPoolStatus | undefined>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [creatingMap, setCreatingMap] = useState<Record<number, boolean>>({})
|
||||
const [selectedLeaderId, setSelectedLeaderId] = useState<number | undefined>()
|
||||
const [statusModalItem, setStatusModalItem] = useState<LeaderPoolItem | null>(null)
|
||||
const [planModalItem, setPlanModalItem] = useState<LeaderPoolItem | null>(null)
|
||||
const [trialModalItem, setTrialModalItem] = useState<LeaderPoolItem | null>(null)
|
||||
const [statusForm] = Form.useForm()
|
||||
const [planForm] = Form.useForm()
|
||||
const [trialForm] = Form.useForm()
|
||||
|
||||
const fetchPool = async (status = statusFilter) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await apiService.leaderPool.list(status ? { status } : {})
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setPoolData(response.data.data)
|
||||
} else {
|
||||
message.error(response.data.msg || t('leaderPool.fetchFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('leaderPool.fetchFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchLeaders = async () => {
|
||||
try {
|
||||
const response = await apiService.leaders.list()
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setLeaders(response.data.data.list || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载 Leader 列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAccounts = async () => {
|
||||
try {
|
||||
const response = await apiService.accounts.list()
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setAccounts(response.data.data.list || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载账户列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchPool()
|
||||
fetchLeaders()
|
||||
fetchAccounts()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchPool(statusFilter)
|
||||
}, [statusFilter])
|
||||
|
||||
const handleAddLeader = async () => {
|
||||
if (!selectedLeaderId) {
|
||||
message.warning(t('leaderPool.selectLeaderFirst'))
|
||||
return
|
||||
}
|
||||
setAdding(true)
|
||||
try {
|
||||
const response = await apiService.leaderPool.add({ leaderId: selectedLeaderId })
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('leaderPool.addSuccess'))
|
||||
setSelectedLeaderId(undefined)
|
||||
fetchPool()
|
||||
} else {
|
||||
message.warning(response.data.msg || t('leaderPool.addFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('leaderPool.addFailed'))
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openStatusModal = (item: LeaderPoolItem) => {
|
||||
setStatusModalItem(item)
|
||||
statusForm.setFieldsValue({
|
||||
status: item.status,
|
||||
cooldownUntil: item.cooldownUntil ? dayjs(item.cooldownUntil) : undefined,
|
||||
locked: item.locked
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdateStatus = async () => {
|
||||
if (!statusModalItem) return
|
||||
const values = await statusForm.validateFields()
|
||||
const response = await apiService.leaderPool.updateStatus({
|
||||
poolId: statusModalItem.id,
|
||||
status: values.status,
|
||||
cooldownUntil: values.cooldownUntil ? values.cooldownUntil.valueOf() : undefined,
|
||||
locked: values.locked
|
||||
})
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('leaderPool.statusUpdated'))
|
||||
setStatusModalItem(null)
|
||||
fetchPool()
|
||||
} else {
|
||||
message.error(response.data.msg || t('leaderPool.statusUpdateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const openPlanModal = (item: LeaderPoolItem) => {
|
||||
setPlanModalItem(item)
|
||||
planForm.setFieldsValue({
|
||||
suggestedFixedAmount: item.suggestedFixedAmount,
|
||||
suggestedMaxDailyOrders: item.suggestedMaxDailyOrders,
|
||||
suggestedMaxDailyLoss: item.suggestedMaxDailyLoss,
|
||||
suggestedMinPrice: item.suggestedMinPrice,
|
||||
suggestedMaxPrice: item.suggestedMaxPrice,
|
||||
suggestedMaxPositionValue: item.suggestedMaxPositionValue,
|
||||
notes: item.notes
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdatePlan = async () => {
|
||||
if (!planModalItem) return
|
||||
const values = await planForm.validateFields()
|
||||
const response = await apiService.leaderPool.updatePlan({
|
||||
poolId: planModalItem.id,
|
||||
suggestedFixedAmount: values.suggestedFixedAmount?.toString(),
|
||||
suggestedMaxDailyOrders: values.suggestedMaxDailyOrders,
|
||||
suggestedMaxDailyLoss: values.suggestedMaxDailyLoss?.toString(),
|
||||
suggestedMinPrice: values.suggestedMinPrice?.toString(),
|
||||
suggestedMaxPrice: values.suggestedMaxPrice?.toString(),
|
||||
suggestedMaxPositionValue: values.suggestedMaxPositionValue?.toString(),
|
||||
notes: values.notes
|
||||
})
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('leaderPool.planUpdated'))
|
||||
setPlanModalItem(null)
|
||||
fetchPool()
|
||||
} else {
|
||||
message.error(response.data.msg || t('leaderPool.planUpdateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const openTrialModal = (item: LeaderPoolItem) => {
|
||||
setTrialModalItem(item)
|
||||
trialForm.setFieldsValue({ accountId: accounts[0]?.id })
|
||||
}
|
||||
|
||||
const handleCreateTrialConfig = async () => {
|
||||
if (!trialModalItem) return
|
||||
if (creatingMap[trialModalItem.id]) return
|
||||
const values = await trialForm.validateFields()
|
||||
setCreatingMap(prev => ({ ...prev, [trialModalItem.id]: true }))
|
||||
try {
|
||||
const response = await apiService.leaderPool.createTrialConfig({
|
||||
poolId: trialModalItem.id,
|
||||
accountId: values.accountId,
|
||||
enableImmediately: false,
|
||||
confirm: false
|
||||
})
|
||||
if (response.data.code === 0) {
|
||||
message.success({
|
||||
content: (
|
||||
<Space>
|
||||
<span>{t('leaderPool.trialCreated')}</span>
|
||||
<Button type="link" size="small" onClick={() => navigate('/copy-trading')}>
|
||||
{t('leaderPool.goCopyTrading')}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
})
|
||||
setTrialModalItem(null)
|
||||
fetchPool()
|
||||
} else {
|
||||
message.error(response.data.msg || t('leaderPool.trialCreateFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('leaderPool.trialCreateFailed'))
|
||||
} finally {
|
||||
setCreatingMap(prev => ({ ...prev, [trialModalItem.id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (item: LeaderPoolItem) => {
|
||||
const response = await apiService.leaderPool.remove({ poolId: item.id })
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('leaderPool.removeSuccess'))
|
||||
fetchPool()
|
||||
} else {
|
||||
message.error(response.data.msg || t('leaderPool.removeFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('leaderPool.leader'),
|
||||
key: 'leader',
|
||||
width: 260,
|
||||
render: (_: unknown, item: LeaderPoolItem) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text strong>{item.leaderName || `Leader ${item.leaderId}`}</Text>
|
||||
<Text copyable style={{ fontSize: 12, fontFamily: 'monospace' }} type="secondary">
|
||||
{item.leaderAddress}
|
||||
</Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('common.status'),
|
||||
dataIndex: 'status',
|
||||
width: 120,
|
||||
render: (status: LeaderPoolStatus) => {
|
||||
const meta = VISIBLE_STATUSES.find(item => item.value === status)
|
||||
return <Tag color={meta?.color || 'default'}>{statusLabels[status] || status}</Tag>
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('leaderPool.source'),
|
||||
dataIndex: 'source',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: t('leaderPool.suggestedConfig'),
|
||||
key: 'plan',
|
||||
width: 220,
|
||||
render: (_: unknown, item: LeaderPoolItem) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text>{t('leaderPool.fixedAmount')}: {item.suggestedFixedAmount}</Text>
|
||||
<Text type="secondary">{t('leaderPool.maxDailyOrders')}: {item.suggestedMaxDailyOrders}</Text>
|
||||
<Text type="secondary">{t('leaderPool.maxDailyLoss')}: {item.suggestedMaxDailyLoss}</Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('leaderPool.copyTradingState'),
|
||||
key: 'copyTradingState',
|
||||
width: 160,
|
||||
render: (_: unknown, item: LeaderPoolItem) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text>{item.copyTradingCount} {t('leaderPool.configs')}</Text>
|
||||
<Tag color={item.hasEnabledCopyTrading ? 'green' : 'default'}>
|
||||
{item.hasEnabledCopyTrading ? t('leaderPool.hasEnabled') : t('leaderPool.noEnabled')}
|
||||
</Tag>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('leaderPool.lastReviewedAt'),
|
||||
dataIndex: 'lastReviewedAt',
|
||||
width: 150,
|
||||
render: (value?: number) => formatDate(value)
|
||||
},
|
||||
{
|
||||
title: t('common.actions'),
|
||||
key: 'actions',
|
||||
fixed: 'right' as const,
|
||||
width: 320,
|
||||
render: (_: unknown, item: LeaderPoolItem) => (
|
||||
<Space wrap size={4}>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => window.open(item.profileUrl, '_blank', 'noopener,noreferrer')}>
|
||||
Profile
|
||||
</Button>
|
||||
<Button size="small" onClick={() => openStatusModal(item)}>
|
||||
{t('leaderPool.updateStatus')}
|
||||
</Button>
|
||||
<Button size="small" onClick={() => openPlanModal(item)}>
|
||||
{t('leaderPool.editPlan')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
loading={creatingMap[item.id]}
|
||||
disabled={creatingMap[item.id]}
|
||||
onClick={() => openTrialModal(item)}
|
||||
>
|
||||
{t('leaderPool.createTrial')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={t('leaderPool.removeConfirm')}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={() => handleRemove(item)}
|
||||
>
|
||||
<Button size="small" danger>{t('common.delete')}</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Card>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Space align="start" style={{ justifyContent: 'space-between', width: '100%' }}>
|
||||
<div>
|
||||
<Title level={3} style={{ marginBottom: 4 }}>{t('leaderPool.title')}</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
{t('leaderPool.subtitle')}
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => fetchPool()}>{t('common.refresh')}</Button>
|
||||
</Space>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={t('leaderPool.safetyHint')}
|
||||
description={t('leaderPool.safetyDesc')}
|
||||
/>
|
||||
<Space wrap>
|
||||
<Select
|
||||
style={{ minWidth: 260 }}
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder={t('leaderPool.selectLeaderPlaceholder')}
|
||||
optionFilterProp="label"
|
||||
value={selectedLeaderId}
|
||||
onChange={setSelectedLeaderId}
|
||||
options={leaders.map(leader => ({
|
||||
label: `${leader.leaderName || `Leader ${leader.id}`} - ${leader.leaderAddress.slice(0, 8)}...`,
|
||||
value: leader.id
|
||||
}))}
|
||||
notFoundContent={<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('leaderPool.noLeaders')} />}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} loading={adding} onClick={handleAddLeader}>
|
||||
{t('leaderPool.addExistingLeader')}
|
||||
</Button>
|
||||
<Button icon={<LinkOutlined />} onClick={() => navigate('/leaders')}>
|
||||
{t('leaderPool.goLeaders')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title={t('leaderPool.totalCount')} value={poolData.summary.totalCount} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title={t('leaderPool.trialCount')} value={poolData.summary.trialCount} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic prefix="$" title={t('leaderPool.estimatedWorstExposure')} value={poolData.summary.estimatedWorstExposure} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title={t('leaderPool.pendingRiskCount')} value={poolData.summary.pendingRiskCount} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Select
|
||||
style={{ width: 220 }}
|
||||
placeholder={t('leaderPool.filterStatus')}
|
||||
value={statusFilter ?? 'ALL'}
|
||||
onChange={(value: LeaderPoolFilterValue) => setStatusFilter(value === 'ALL' ? undefined : value)}
|
||||
options={[
|
||||
{ value: 'ALL', label: t('common.all') },
|
||||
...VISIBLE_STATUSES.map(item => ({ value: item.value, label: statusLabels[item.value] }))
|
||||
]}
|
||||
/>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={poolData.list}
|
||||
scroll={{ x: 1280 }}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty
|
||||
description={t('leaderPool.emptyDesc')}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
|
||||
<Modal
|
||||
title={t('leaderPool.updateStatus')}
|
||||
open={!!statusModalItem}
|
||||
onCancel={() => setStatusModalItem(null)}
|
||||
onOk={handleUpdateStatus}
|
||||
>
|
||||
<Form form={statusForm} layout="vertical">
|
||||
<Form.Item name="status" label={t('common.status')} rules={[{ required: true }]}>
|
||||
<Select options={VISIBLE_STATUSES.map(item => ({ value: item.value, label: statusLabels[item.value] }))} />
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{({ getFieldValue }) => getFieldValue('status') === 'COOLDOWN' ? (
|
||||
<Form.Item name="cooldownUntil" label={t('leaderPool.cooldownUntil')}>
|
||||
<DatePicker showTime style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
</Form.Item>
|
||||
<Form.Item name="locked" label={t('leaderPool.locked')}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: false, label: t('common.no') },
|
||||
{ value: true, label: t('common.yes') }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={t('leaderPool.editPlan')}
|
||||
open={!!planModalItem}
|
||||
onCancel={() => setPlanModalItem(null)}
|
||||
onOk={handleUpdatePlan}
|
||||
>
|
||||
<Form form={planForm} layout="vertical">
|
||||
<Form.Item name="suggestedFixedAmount" label={t('leaderPool.fixedAmount')} rules={[{ required: true }]}>
|
||||
<InputNumber min={0.01} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="suggestedMaxDailyOrders" label={t('leaderPool.maxDailyOrders')} rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={100} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="suggestedMaxDailyLoss" label={t('leaderPool.maxDailyLoss')} rules={[{ required: true }]}>
|
||||
<InputNumber min={0.01} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="suggestedMinPrice" label={t('leaderPool.minPrice')}>
|
||||
<InputNumber min={0} max={1} step={0.01} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="suggestedMaxPrice" label={t('leaderPool.maxPrice')}>
|
||||
<InputNumber min={0} max={1} step={0.01} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="suggestedMaxPositionValue" label={t('leaderPool.maxPositionValue')}>
|
||||
<InputNumber min={0.01} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label={t('leaderPool.notes')}>
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={t('leaderPool.createTrial')}
|
||||
open={!!trialModalItem}
|
||||
onCancel={() => setTrialModalItem(null)}
|
||||
onOk={handleCreateTrialConfig}
|
||||
confirmLoading={trialModalItem ? creatingMap[trialModalItem.id] : false}
|
||||
>
|
||||
{trialModalItem && (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
message={t('leaderPool.trialConfirmTitle')}
|
||||
description={t('leaderPool.trialConfirmDesc')}
|
||||
/>
|
||||
<Form form={trialForm} layout="vertical">
|
||||
<Form.Item name="accountId" label={t('leaderPool.account')} rules={[{ required: true, message: t('leaderPool.selectAccount') }]}>
|
||||
<Select
|
||||
options={accounts.map(account => ({
|
||||
value: account.id,
|
||||
label: `${account.accountName || account.walletAddress} (${account.proxyAddress?.slice(0, 8)}...)`
|
||||
}))}
|
||||
notFoundContent={<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('leaderPool.noAccounts')} />}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Descriptions bordered size="small" column={1}>
|
||||
<Descriptions.Item label={t('leaderPool.fixedAmount')}>{trialModalItem.suggestedFixedAmount}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('leaderPool.maxDailyOrders')}>{trialModalItem.suggestedMaxDailyOrders}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('leaderPool.maxDailyLoss')}>{trialModalItem.suggestedMaxDailyLoss}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('leaderPool.priceRange')}>
|
||||
{trialModalItem.suggestedMinPrice || '-'} - {trialModalItem.suggestedMaxPrice || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('leaderPool.maxPositionValue')}>{trialModalItem.suggestedMaxPositionValue || '5'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('common.status')}>{t('common.disabled')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LeaderPool
|
||||
@@ -1,5 +1,20 @@
|
||||
import axios, { AxiosInstance, AxiosError } from 'axios'
|
||||
import type { ApiResponse, NotificationConfig, NotificationConfigRequest, NotificationConfigUpdateRequest, NotificationTemplate, TemplateTypeInfo, TemplateVariablesResponse } from '../types'
|
||||
import type {
|
||||
ApiResponse,
|
||||
LeaderPoolAddRequest,
|
||||
LeaderPoolCreateTrialConfigRequest,
|
||||
LeaderPoolItem,
|
||||
LeaderPoolListRequest,
|
||||
LeaderPoolListResponse,
|
||||
LeaderPoolUpdatePlanRequest,
|
||||
LeaderPoolUpdateStatusRequest,
|
||||
NotificationConfig,
|
||||
NotificationConfigRequest,
|
||||
NotificationConfigUpdateRequest,
|
||||
NotificationTemplate,
|
||||
TemplateTypeInfo,
|
||||
TemplateVariablesResponse
|
||||
} from '../types'
|
||||
import { getToken, setToken, removeToken } from '../utils'
|
||||
import { wsManager } from './websocket'
|
||||
import i18n from '../i18n/config'
|
||||
@@ -358,6 +373,29 @@ export const apiService = {
|
||||
balance: (data: { leaderId: number }) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/balance', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* Leader 池 API
|
||||
*/
|
||||
leaderPool: {
|
||||
list: (data: LeaderPoolListRequest = {}) =>
|
||||
apiClient.post<ApiResponse<LeaderPoolListResponse>>('/copy-trading/leader-pool/list', data),
|
||||
|
||||
add: (data: LeaderPoolAddRequest) =>
|
||||
apiClient.post<ApiResponse<LeaderPoolItem>>('/copy-trading/leader-pool/add', data),
|
||||
|
||||
updateStatus: (data: LeaderPoolUpdateStatusRequest) =>
|
||||
apiClient.post<ApiResponse<LeaderPoolItem>>('/copy-trading/leader-pool/update-status', data),
|
||||
|
||||
updatePlan: (data: LeaderPoolUpdatePlanRequest) =>
|
||||
apiClient.post<ApiResponse<LeaderPoolItem>>('/copy-trading/leader-pool/update-plan', data),
|
||||
|
||||
createTrialConfig: (data: LeaderPoolCreateTrialConfigRequest) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/leader-pool/create-trial-config', data),
|
||||
|
||||
remove: (data: { poolId: number }) =>
|
||||
apiClient.post<ApiResponse<void>>('/copy-trading/leader-pool/remove', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 跟单模板管理 API(子菜单:跟单模板)
|
||||
@@ -571,6 +609,21 @@ export const apiService = {
|
||||
detail: (data: { copyTradingId: number }) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/statistics/detail', data)
|
||||
},
|
||||
|
||||
safetyConfig: {
|
||||
applyConservative: (data: {
|
||||
copyTradingId: number
|
||||
confirm: boolean
|
||||
maxDailyOrders?: number
|
||||
maxDailyLoss?: string
|
||||
minPrice?: string
|
||||
maxPrice?: string
|
||||
maxPositionValue?: string
|
||||
minOrderDepth?: string
|
||||
maxSpread?: string
|
||||
priceTolerance?: string
|
||||
}) => apiClient.post<ApiResponse<any>>('/copy-trading/configs/apply-conservative-config', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 订单跟踪 API
|
||||
@@ -950,4 +1003,3 @@ export const backtestService = {
|
||||
*/
|
||||
rerun: (data: { id: number; taskName?: string }) => apiClient.post('/backtest/tasks/rerun', data)
|
||||
}
|
||||
|
||||
|
||||
@@ -308,6 +308,89 @@ export interface CopyTradingListResponse {
|
||||
total: number
|
||||
}
|
||||
|
||||
export type LeaderPoolStatus = 'CANDIDATE' | 'WATCH' | 'PAPER' | 'TRIAL' | 'ACTIVE' | 'COOLDOWN' | 'RETIRED'
|
||||
|
||||
export interface LeaderPoolSummary {
|
||||
totalCount: number
|
||||
trialCount: number
|
||||
estimatedWorstExposure: string
|
||||
pendingRiskCount: number
|
||||
defaultExperimentBudget: string
|
||||
}
|
||||
|
||||
export interface LeaderPoolItem {
|
||||
id: number
|
||||
leaderId: number
|
||||
leaderName?: string
|
||||
leaderAddress: string
|
||||
category?: string
|
||||
profileUrl: string
|
||||
status: LeaderPoolStatus
|
||||
source: string
|
||||
sourceRank?: number
|
||||
score?: string
|
||||
reason?: string
|
||||
notes?: string
|
||||
suggestedFixedAmount: string
|
||||
suggestedMaxDailyOrders: number
|
||||
suggestedMaxDailyLoss: string
|
||||
suggestedMinPrice?: string
|
||||
suggestedMaxPrice?: string
|
||||
suggestedMaxPositionValue?: string
|
||||
copyTradingCount: number
|
||||
hasEnabledCopyTrading: boolean
|
||||
estimatedWorstExposure: string
|
||||
lastReviewedAt?: number
|
||||
lastPromotedAt?: number
|
||||
cooldownUntil?: number
|
||||
locked: boolean
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface LeaderPoolListResponse {
|
||||
summary: LeaderPoolSummary
|
||||
list: LeaderPoolItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface LeaderPoolListRequest {
|
||||
status?: LeaderPoolStatus
|
||||
}
|
||||
|
||||
export interface LeaderPoolAddRequest {
|
||||
leaderId: number
|
||||
source?: string
|
||||
reason?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface LeaderPoolUpdateStatusRequest {
|
||||
poolId: number
|
||||
status: LeaderPoolStatus
|
||||
cooldownUntil?: number
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
export interface LeaderPoolUpdatePlanRequest {
|
||||
poolId: number
|
||||
suggestedFixedAmount?: string
|
||||
suggestedMaxDailyOrders?: number
|
||||
suggestedMaxDailyLoss?: string
|
||||
suggestedMinPrice?: string
|
||||
suggestedMaxPrice?: string
|
||||
suggestedMaxPositionValue?: string
|
||||
reason?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface LeaderPoolCreateTrialConfigRequest {
|
||||
poolId: number
|
||||
accountId: number
|
||||
enableImmediately?: boolean
|
||||
confirm?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 跟单创建请求
|
||||
* 所有配置参数都需要手动输入,模板仅用于前端快速填充表单
|
||||
@@ -707,6 +790,14 @@ export interface CopyTradingStatistics {
|
||||
currentPositionQuantity: string
|
||||
currentPositionCost: string
|
||||
currentPositionValue: string // 按当前价格估算的持仓市值
|
||||
zeroValuePositionCost?: string
|
||||
confirmedZeroValuePositionCost?: string
|
||||
quoteOverallStatus?: 'AVAILABLE' | 'NO_MATCH' | 'UNAVAILABLE'
|
||||
quoteAvailableCount?: number
|
||||
quoteNoMatchCount?: number
|
||||
quoteUnavailableCount?: number
|
||||
quoteIncomplete?: boolean
|
||||
riskDiagnosis?: CopyTradingRiskDiagnosis | null
|
||||
|
||||
// 盈亏统计
|
||||
totalRealizedPnl: string
|
||||
@@ -715,6 +806,45 @@ export interface CopyTradingStatistics {
|
||||
totalPnlPercent: string
|
||||
}
|
||||
|
||||
export interface CopyTradingRiskDiagnosis {
|
||||
copyTradingId: number
|
||||
totalRealizedPnl: string
|
||||
totalUnrealizedPnl: string
|
||||
totalPnl: string
|
||||
currentPositionCost: string
|
||||
currentPositionValue: string
|
||||
zeroValuePositionCost: string
|
||||
confirmedZeroValuePositionCost: string
|
||||
zeroSellLoss: string
|
||||
openPositionQuantity: string
|
||||
totalBuyOrders: number
|
||||
totalSellRecords: number
|
||||
totalMatchDetails: number
|
||||
filteredOrderCount: number
|
||||
sampleSize: number
|
||||
lowConfidence: boolean
|
||||
confidenceReason: string
|
||||
quoteOverallStatus: 'AVAILABLE' | 'NO_MATCH' | 'UNAVAILABLE'
|
||||
quoteAvailableCount: number
|
||||
quoteNoMatchCount: number
|
||||
quoteUnavailableCount: number
|
||||
dataIncomplete: boolean
|
||||
missingSources: string[]
|
||||
topLosingMarkets: Array<{
|
||||
marketId: string
|
||||
realizedPnl: string
|
||||
matchedOrders: number
|
||||
}>
|
||||
riskWarnings: Array<{
|
||||
field: string
|
||||
currentValue: string | null
|
||||
suggestedValue: string
|
||||
severity: 'LOW' | 'MEDIUM' | 'HIGH'
|
||||
reason: string
|
||||
}>
|
||||
generatedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 买入订单信息
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user