diff --git a/ANNOUNCEMENT_v1.0.1.md b/ANNOUNCEMENT_v1.0.1.md new file mode 100644 index 0000000..83622f2 --- /dev/null +++ b/ANNOUNCEMENT_v1.0.1.md @@ -0,0 +1,133 @@ +# 🎉 v1.0.1 版本发布公告 + +## 📅 发布日期 + +2025年12月(具体日期待定) + +## 🚀 主要更新 + +### 性能优化 + +#### 📊 订单簿请求优化 +- 优化订单簿获取逻辑,仅在需要时请求,避免不必要的 API 调用 +- 当未配置需要订单簿的过滤条件(价差、订单深度)时,跳过订单簿获取 +- 订单簿只请求一次,在过滤检查中复用,提高性能 + +#### 💰 卖出价格计算优化 +- 卖出改为市价卖出:优先使用订单簿的 bestBid(最高买入价) +- 如果 bestBid 获取失败,自动降级使用 Leader 的交易价格 +- 卖出价格固定按 90% 计算,不再使用价格容忍度配置 +- 提高卖出订单的成交率 + +#### ⚙️ 价格容忍度默认值 +- 如果价格容忍度配置为 0,自动使用默认值 5% +- 确保买入订单能够正常应用价格调整,提高成交率 + +### 代码优化 + +#### 🎯 使用枚举优化过滤逻辑 +- 新增 `FilterResult` 数据类和 `FilterStatus` 枚举 +- 使用类型安全的枚举替代字符串判断,提高代码可维护性 +- 移除无用字段 `isBuyOrder` 参数 +- 优化 `checkFilters` 方法返回值,使用数据类封装结果 + +#### 📝 代码结构优化 +- 减少 if-else 嵌套,使用 Kotlin 链式调用和空安全操作符 +- 优化卖出价格计算逻辑,使用 `runCatching` 和 `?.let` 简化代码 +- 提取公共方法 `calculateFallbackSellPrice`,提高代码复用性 + +### 功能改进 + +#### 🗑️ 移除最小订单簿深度功能 +- 移除 `minOrderbookDepth`(最小订单簿深度)配置项 +- 简化过滤逻辑,只保留 `minOrderDepth`(最小订单深度) +- 更新前端界面,移除相关配置项和提示文案 + +#### 📈 优化最小订单深度逻辑 +- 修改 `minOrderDepth` 检查逻辑,检查所有方向(买盘+卖盘)的总深度 +- 不再区分买卖方向,提供更全面的市场流动性评估 + +#### 🎯 市场结算判断优化 +- 添加市场已关闭时的结算判断逻辑 +- 如果市场已关闭且该 outcome 赢了(价格 >= 0.99),返回价格为 1 +- 如果市场已关闭且该 outcome 输了(价格 <= 0.01),返回价格为 0 +- 当没有仓位但有未完成订单时,正确判断市场结算状态并设置价格 + +### Bug 修复 + +- ✅ 修复盈亏和持仓价值计算逻辑未实现的问题 +- ✅ 修复过滤检查中订单簿可能重复请求的问题 +- ✅ 修复健康检查路径不一致问题(从 `/api/health` 改为 `/api/system/health`) + +## 📦 如何更新 + +### Docker 部署 + +```bash +# 拉取最新镜像 +docker pull wrbug/polyhermes:v1.0.1 + +# 或使用 latest 标签 +docker pull wrbug/polyhermes:latest + +# 重启容器 +docker-compose down +docker-compose up -d +``` + +### 源码部署 + +```bash +# 拉取最新代码 +git fetch origin +git checkout v1.0.1 + +# 重新构建 +cd backend +./gradlew bootJar + +# 重启服务 +# 根据您的部署方式重启服务 +``` + +## 📚 文档更新 + +- 更新前端多语言文案,优化提示信息 +- 移除最小订单簿深度相关的文档说明 + +## 🔗 相关链接 + +- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.0.1 +- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.0.0...v1.0.1 +- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes + +## ⚠️ 重要提醒 + +**请务必使用官方 Docker 镜像源,避免财产损失!** + +### ✅ 官方 Docker Hub 镜像 + +**官方镜像地址**:`wrbug/polyhermes` + +```bash +# ✅ 正确:使用官方镜像 +docker pull wrbug/polyhermes:v1.0.1 + +# ❌ 错误:不要使用其他来源的镜像 +# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗 +``` + +### 🔗 官方渠道 + +请通过以下**唯一官方渠道**获取 PolyHermes: + +* **GitHub 仓库**:https://github.com/WrBug/PolyHermes +* **Twitter**:@quant_tr +* **Telegram 群组**:加入群组 + +--- + +**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!** + +**💬 如有问题或建议,欢迎在 GitHub Issues 中反馈。** + diff --git a/RELEASE_v1.0.1.md b/RELEASE_v1.0.1.md new file mode 100644 index 0000000..b7b0f93 --- /dev/null +++ b/RELEASE_v1.0.1.md @@ -0,0 +1,55 @@ +# v1.0.1 + +## 🚀 性能优化 + +### 📊 订单簿请求优化 +* 优化订单簿获取逻辑,仅在需要时请求,避免不必要的 API 调用 +* 当未配置需要订单簿的过滤条件(价差、订单深度)时,跳过订单簿获取 +* 订单簿只请求一次,在过滤检查中复用,提高性能 + +### 💰 卖出价格计算优化 +* 卖出改为市价卖出:优先使用订单簿的 bestBid(最高买入价) +* 如果 bestBid 获取失败,自动降级使用 Leader 的交易价格 +* 卖出价格固定按 90% 计算,不再使用价格容忍度配置 +* 提高卖出订单的成交率 + +### ⚙️ 价格容忍度默认值 +* 如果价格容忍度配置为 0,自动使用默认值 5% +* 确保买入订单能够正常应用价格调整,提高成交率 + +## 🔧 代码优化 + +### 🎯 使用枚举优化过滤逻辑 +* 新增 `FilterResult` 数据类和 `FilterStatus` 枚举 +* 使用类型安全的枚举替代字符串判断,提高代码可维护性 +* 移除无用字段 `isBuyOrder` 参数 +* 优化 `checkFilters` 方法返回值,使用数据类封装结果 + +### 📝 代码结构优化 +* 减少 if-else 嵌套,使用 Kotlin 链式调用和空安全操作符 +* 优化卖出价格计算逻辑,使用 `runCatching` 和 `?.let` 简化代码 +* 提取公共方法 `calculateFallbackSellPrice`,提高代码复用性 + +## 🗑️ 功能移除 + +### 移除最小订单簿深度功能 +* 移除 `minOrderbookDepth`(最小订单簿深度)配置项 +* 简化过滤逻辑,只保留 `minOrderDepth`(最小订单深度) +* 更新前端界面,移除相关配置项和提示文案 + +### 优化最小订单深度逻辑 +* 修改 `minOrderDepth` 检查逻辑,检查所有方向(买盘+卖盘)的总深度 +* 不再区分买卖方向,提供更全面的市场流动性评估 + +## 🐛 Bug 修复 + +* 修复盈亏和持仓价值计算逻辑未实现的问题 +* 修复过滤检查中订单簿可能重复请求的问题 + +## 📚 文档更新 + +* 更新前端多语言文案,优化提示信息 +* 移除最小订单簿深度相关的文档说明 + +**Full Changelog**: https://github.com/WrBug/polymarket-bot/compare/v1.0.0...v1.0.1 + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/PositionCheckService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/PositionCheckService.kt index 438b747..6fc85ac 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/PositionCheckService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/PositionCheckService.kt @@ -192,7 +192,7 @@ class PositionCheckService( /** * 逻辑1:处理待赎回仓位 - * 按照以下逻辑处理: + https://clob.polymarket.com * 按照以下逻辑处理: * 1. 无待赎回仓位:跳过 * 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位:发送通知事件 * 3. (已配置) && 有待赎回的仓位:处理订单逻辑 @@ -392,11 +392,30 @@ class PositionCheckService( } } else { // 有仓位,按订单下单顺序(FIFO)更新状态 - // 如果仓位数量 >= 订单数量总和,所有订单完全成交 - // 如果仓位数量 < 订单数量总和,按FIFO顺序部分成交 + // 计算逻辑: + // 1. 总订单数量 = 所有未卖出订单的剩余数量总和 + // 2. 已成交数量 = 总订单数量 - 仓位数量(因为还有仓位,说明部分订单已卖出) + // 3. 如果已成交数量 = 0,说明订单还没有卖出,不修改订单状态 + // 4. 如果已成交数量 > 0,按FIFO顺序匹配订单 val positionQuantity = position.quantity.toSafeBigDecimal() + + // 计算总订单数量 + val totalOrderQuantity = orders.fold(BigDecimal.ZERO) { sum, order -> + sum.add(order.remainingQuantity.toSafeBigDecimal()) + } + + // 计算已成交数量 + val soldQuantity = totalOrderQuantity.subtract(positionQuantity) + + // 如果已成交数量 <= 0,说明订单还没有卖出,不修改订单状态 + if (soldQuantity <= BigDecimal.ZERO) { + logger.debug("仓位数量 >= 订单数量总和,订单尚未卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, positionQuantity=$positionQuantity, totalOrderQuantity=$totalOrderQuantity") + continue + } + + // 如果已成交数量 > 0,按FIFO顺序匹配订单 val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex) - updateOrdersAsSoldByFIFO(orders, positionQuantity, currentPrice, + updateOrdersAsSoldByFIFO(orders, soldQuantity, currentPrice, copyTrading.id, marketId, outcomeIndex) } } @@ -629,12 +648,22 @@ class PositionCheckService( /** * 按 FIFO 顺序更新订单状态为已卖出 - * 仓位数量小于订单数量总和时,按订单下单顺序更新 - * 同时创建卖出记录和匹配明细,用于统计 + * @param orders 订单列表(已按创建时间排序,FIFO) + * @param soldQuantity 已成交数量(总订单数量 - 仓位数量) + * @param sellPrice 卖出价格 + * @param copyTradingId 跟单配置ID + * @param marketId 市场ID + * @param outcomeIndex 结果索引 + * + * 逻辑说明: + * 1. 按订单创建时间顺序(FIFO)处理 + * 2. 如果订单剩余数量 <= 已成交数量,订单完全成交 + * 3. 如果订单剩余数量 > 已成交数量,订单部分成交 + * 4. 同时创建卖出记录和匹配明细,用于统计 */ private suspend fun updateOrdersAsSoldByFIFO( orders: List, - availableQuantity: BigDecimal, + soldQuantity: BigDecimal, sellPrice: BigDecimal, copyTradingId: Long, marketId: String, @@ -646,7 +675,7 @@ class PositionCheckService( try { // 订单已经按 createdAt ASC 排序(FIFO) - var remaining = availableQuantity + var remaining = soldQuantity var totalMatchedQuantity = BigDecimal.ZERO var totalRealizedPnl = BigDecimal.ZERO val matchDetails = mutableListOf() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cfa5819..8f6f9eb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -252,6 +252,7 @@ function App() { } /> } /> } /> + {/* 保留旧路由以保持向后兼容 */} } /> } /> } /> diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index fc1333e..e93db52 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -868,10 +868,11 @@ "totalPnl": "Total P&L", "statistics": "Statistics", "orders": "Orders", + "matchedOrders": "Matched Orders", + "ordersButton": "Orders", "viewStatistics": "View Statistics", "buyOrders": "Buy Orders", "sellOrders": "Sell Orders", - "matchedOrders": "Matched Orders", "filteredOrders": "Filtered Orders", "filterWallet": "Filter Wallet", "filterTemplate": "Filter Template", @@ -966,5 +967,72 @@ "collapse": "Collapse", "refresh": "Refresh", "list": "List" + }, + "copyTradingOrders": { + "title": "Order List", + "buyOrders": "Buy Orders", + "sellOrders": "Sell Orders", + "matchedOrders": "Matched Orders", + "filteredOrders": "Filtered Orders", + "statistics": "Copy Trading Statistics", + "orderId": "Order ID", + "leaderTradeId": "Leader Trade ID", + "market": "Market", + "marketId": "Market ID", + "side": "Side", + "buyQuantity": "Buy Quantity", + "buyPrice": "Buy Price", + "buyAmount": "Buy Amount", + "sellQuantity": "Sell Quantity", + "sellPrice": "Sell Price", + "sellAmount": "Sell Amount", + "matchedQuantity": "Matched", + "remainingQuantity": "Remaining", + "sellStatus": "Sell Status", + "status": "Status", + "statusFilled": "Filled", + "statusPartiallySold": "Partially Sold", + "statusFullySold": "Fully Sold", + "realizedPnl": "Realized PnL", + "createdAt": "Created At", + "matchedAt": "Matched At", + "sellOrderId": "Sell Order ID", + "buyOrderId": "Buy Order ID", + "priceInfo": "Price Info", + "buy": "Buy", + "sell": "Sell", + "buyInfo": "Buy Info", + "sellInfo": "Sell Info", + "matchInfo": "Match Info", + "matched": "Matched", + "remaining": "Remaining", + "quantity": "Quantity", + "price": "Price", + "amount": "Amount", + "filterMarketId": "Filter Market ID", + "filterSide": "Filter Side", + "filterStatus": "Filter Status", + "filterSellOrderId": "Filter Sell Order ID", + "filterBuyOrderId": "Filter Buy Order ID", + "noBuyOrders": "No buy orders", + "noSellOrders": "No sell orders", + "noMatchedOrders": "No matched orders", + "fetchBuyOrdersFailed": "Failed to fetch buy orders", + "fetchSellOrdersFailed": "Failed to fetch sell orders", + "fetchMatchedOrdersFailed": "Failed to fetch matched orders", + "fetchStatisticsFailed": "Failed to fetch statistics", + "noStatistics": "No statistics available", + "totalBuyOrders": "Total Buy Orders", + "totalSellOrders": "Total Sell Orders", + "totalMatchedOrders": "Total Matched Orders", + "totalBuyAmount": "Total Buy Amount", + "totalSellAmount": "Total Sell Amount", + "totalPnl": "Total PnL", + "totalRealizedPnl": "Total Realized PnL", + "totalUnrealizedPnl": "Total Unrealized PnL", + "winRate": "Win Rate", + "averagePnl": "Average PnL", + "maxPnl": "Max PnL", + "minPnl": "Min PnL" } } diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 9c3c3dc..9611ab6 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -785,10 +785,11 @@ "totalPnl": "总盈亏", "statistics": "统计", "orders": "订单", + "matchedOrders": "已成交订单", + "ordersButton": "订单", "viewStatistics": "查看统计", "buyOrders": "买入订单", "sellOrders": "卖出订单", - "matchedOrders": "匹配关系", "filteredOrders": "已过滤订单", "filterWallet": "筛选钱包", "filterTemplate": "筛选模板", @@ -883,5 +884,72 @@ "collapse": "收起", "refresh": "刷新", "list": "列表" + }, + "copyTradingOrders": { + "title": "订单列表", + "buyOrders": "买入订单", + "sellOrders": "卖出订单", + "matchedOrders": "匹配关系", + "filteredOrders": "已过滤订单", + "statistics": "跟单关系统计", + "orderId": "订单ID", + "leaderTradeId": "Leader 交易ID", + "market": "市场", + "marketId": "市场ID", + "side": "方向", + "buyQuantity": "买入数量", + "buyPrice": "买入价格", + "buyAmount": "买入金额", + "sellQuantity": "卖出数量", + "sellPrice": "卖出价格", + "sellAmount": "卖出金额", + "matchedQuantity": "已匹配", + "remainingQuantity": "剩余", + "sellStatus": "卖出状态", + "status": "状态", + "statusFilled": "已完成", + "statusPartiallySold": "部分成交", + "statusFullySold": "全部成交", + "realizedPnl": "已实现盈亏", + "createdAt": "创建时间", + "matchedAt": "匹配时间", + "sellOrderId": "卖出订单ID", + "buyOrderId": "买入订单ID", + "priceInfo": "价格信息", + "buy": "买入", + "sell": "卖出", + "buyInfo": "买入信息", + "sellInfo": "卖出信息", + "matchInfo": "匹配信息", + "matched": "已匹配", + "remaining": "剩余", + "quantity": "数量", + "price": "价格", + "amount": "金额", + "filterMarketId": "筛选市场ID", + "filterSide": "筛选方向", + "filterStatus": "筛选状态", + "filterSellOrderId": "筛选卖出订单ID", + "filterBuyOrderId": "筛选买入订单ID", + "noBuyOrders": "暂无买入订单", + "noSellOrders": "暂无卖出订单", + "noMatchedOrders": "暂无匹配关系", + "fetchBuyOrdersFailed": "获取买入订单列表失败", + "fetchSellOrdersFailed": "获取卖出订单列表失败", + "fetchMatchedOrdersFailed": "获取匹配关系列表失败", + "fetchStatisticsFailed": "获取统计信息失败", + "noStatistics": "暂无统计数据", + "totalBuyOrders": "总买入订单数", + "totalSellOrders": "总卖出订单数", + "totalMatchedOrders": "总匹配订单数", + "totalBuyAmount": "总买入金额", + "totalSellAmount": "总卖出金额", + "totalPnl": "总盈亏", + "totalRealizedPnl": "总已实现盈亏", + "totalUnrealizedPnl": "总未实现盈亏", + "winRate": "胜率", + "averagePnl": "平均盈亏", + "maxPnl": "最大盈亏", + "minPnl": "最小盈亏" } } diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index 6f69df8..f9cc7e3 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -868,10 +868,11 @@ "totalPnl": "總盈虧", "statistics": "統計", "orders": "訂單", + "matchedOrders": "已成交訂單", + "ordersButton": "訂單", "viewStatistics": "查看統計", "buyOrders": "買入訂單", "sellOrders": "賣出訂單", - "matchedOrders": "匹配關係", "filteredOrders": "被過濾訂單", "filterWallet": "篩選錢包", "filterTemplate": "篩選模板", @@ -966,5 +967,72 @@ "collapse": "收起", "refresh": "刷新", "list": "列表" + }, + "copyTradingOrders": { + "title": "訂單列表", + "buyOrders": "買入訂單", + "sellOrders": "賣出訂單", + "matchedOrders": "匹配關係", + "filteredOrders": "已過濾訂單", + "statistics": "跟單關系統計", + "orderId": "訂單ID", + "leaderTradeId": "Leader 交易ID", + "market": "市場", + "marketId": "市場ID", + "side": "方向", + "buyQuantity": "買入數量", + "buyPrice": "買入價格", + "buyAmount": "買入金額", + "sellQuantity": "賣出數量", + "sellPrice": "賣出價格", + "sellAmount": "賣出金額", + "matchedQuantity": "已匹配", + "remainingQuantity": "剩餘", + "sellStatus": "賣出狀態", + "status": "狀態", + "statusFilled": "已完成", + "statusPartiallySold": "部分成交", + "statusFullySold": "全部成交", + "realizedPnl": "已實現盈虧", + "createdAt": "創建時間", + "matchedAt": "匹配時間", + "sellOrderId": "賣出訂單ID", + "buyOrderId": "買入訂單ID", + "priceInfo": "價格信息", + "buy": "買入", + "sell": "賣出", + "buyInfo": "買入信息", + "sellInfo": "賣出信息", + "matchInfo": "匹配信息", + "matched": "已匹配", + "remaining": "剩餘", + "quantity": "數量", + "price": "價格", + "amount": "金額", + "filterMarketId": "篩選市場ID", + "filterSide": "篩選方向", + "filterStatus": "篩選狀態", + "filterSellOrderId": "篩選賣出訂單ID", + "filterBuyOrderId": "篩選買入訂單ID", + "noBuyOrders": "暫無買入訂單", + "noSellOrders": "暫無賣出訂單", + "noMatchedOrders": "暫無匹配關係", + "fetchBuyOrdersFailed": "獲取買入訂單列表失敗", + "fetchSellOrdersFailed": "獲取賣出訂單列表失敗", + "fetchMatchedOrdersFailed": "獲取匹配關係列表失敗", + "fetchStatisticsFailed": "獲取統計信息失敗", + "noStatistics": "暫無統計數據", + "totalBuyOrders": "總買入訂單數", + "totalSellOrders": "總賣出訂單數", + "totalMatchedOrders": "總匹配訂單數", + "totalBuyAmount": "總買入金額", + "totalSellAmount": "總賣出金額", + "totalPnl": "總盈虧", + "totalRealizedPnl": "總已實現盈虧", + "totalUnrealizedPnl": "總未實現盈虧", + "winRate": "勝率", + "averagePnl": "平均盈虧", + "maxPnl": "最大盈虧", + "minPnl": "最小盈虧" } } diff --git a/frontend/src/pages/AccountList.tsx b/frontend/src/pages/AccountList.tsx index c7b9bbd..0f34bd2 100644 --- a/frontend/src/pages/AccountList.tsx +++ b/frontend/src/pages/AccountList.tsx @@ -74,11 +74,54 @@ const AccountList: React.FC = () => { } const handleCopy = (text: string) => { - navigator.clipboard.writeText(text).then(() => { - message.success(t('accountList.copySuccess')) - }).catch(() => { - message.error(t('accountList.copyFailed')) - }) + if (!text) { + message.warning(t('accountList.copyFailed') || '复制失败:地址为空') + return + } + + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(() => { + message.success({ + content: t('accountList.copySuccess') || '已复制到剪贴板', + duration: 2 + }) + }).catch((err) => { + console.error('复制失败:', err) + // 降级方案:使用传统方法 + fallbackCopyTextToClipboard(text) + }) + } else { + // 降级方案:使用传统方法 + fallbackCopyTextToClipboard(text) + } + } + + const fallbackCopyTextToClipboard = (text: string) => { + const textArea = document.createElement('textarea') + textArea.value = text + textArea.style.position = 'fixed' + textArea.style.left = '-999999px' + textArea.style.top = '-999999px' + document.body.appendChild(textArea) + textArea.focus() + textArea.select() + + try { + const successful = document.execCommand('copy') + if (successful) { + message.success({ + content: t('accountList.copySuccess') || '已复制到剪贴板', + duration: 2 + }) + } else { + message.error(t('accountList.copyFailed') || '复制失败') + } + } catch (err) { + console.error('复制失败:', err) + message.error(t('accountList.copyFailed') || '复制失败') + } finally { + document.body.removeChild(textArea) + } } const handleShowDetail = async (account: Account) => { @@ -210,46 +253,45 @@ const AccountList: React.FC = () => { title: t('accountList.walletAddress'), dataIndex: 'walletAddress', key: 'walletAddress', - render: (text: string) => ( - - {text} - @@ -325,7 +320,10 @@ const CopyTradingList: React.FC = () => { type="link" size="small" icon={} - onClick={() => navigate(`/copy-trading/statistics/${record.id}`)} + onClick={() => { + setStatisticsModalCopyTradingId(record.id.toString()) + setStatisticsModalOpen(true) + }} > {t('copyTradingList.statistics') || '统计'} @@ -554,7 +552,10 @@ const CopyTradingList: React.FC = () => { type="primary" size="small" icon={} - onClick={() => navigate(`/copy-trading/edit/${record.id}`)} + onClick={() => { + setEditModalCopyTradingId(record.id.toString()) + setEditModalOpen(true) + }} style={{ flex: 1, minWidth: '80px' }} > {t('common.edit') || '编辑'} @@ -562,7 +563,10 @@ const CopyTradingList: React.FC = () => { + + + {isMobile ? ( +
+ {loading ? ( +
+ +
+ ) : orders.length === 0 ? ( +
+ {t('copyTradingOrders.noBuyOrders') || '暂无买入订单'} +
+ ) : ( +
+ {orders.map((order) => { + const date = new Date(order.createdAt) + const formattedDate = date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) + const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString() + const displaySide = order.side === '0' ? 'YES' : order.side === '1' ? 'NO' : order.side + + return ( + +
+
+ {order.orderId.slice(0, 8)}...{order.orderId.slice(-6)} +
+
+ {displaySide} + {getStatusTag(order.status)} +
+
+ + + +
+
{t('copyTradingOrders.buyInfo') || '买入信息'}
+
+ {t('copyTradingOrders.quantity') || '数量'}: {formatUSDC(order.quantity)} | {t('copyTradingOrders.price') || '价格'}: {formatUSDC(order.price)} +
+
+ {t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC +
+
+ +
+
{t('copyTradingOrders.matchInfo') || '匹配信息'}
+
+ {t('copyTradingOrders.matched') || '已匹配'}: {formatUSDC(order.matchedQuantity)} | {t('copyTradingOrders.remaining') || '剩余'}: {formatUSDC(order.remainingQuantity)} +
+
+ +
+
{t('copyTradingOrders.leaderTradeId') || 'Leader 交易ID'}
+
+ {order.leaderTradeId.slice(0, 8)}...{order.leaderTradeId.slice(-6)} +
+
+ +
+
{t('copyTradingOrders.marketId') || '市场ID'}
+
+ {order.marketId.slice(0, 8)}...{order.marketId.slice(-6)} +
+
+ +
+
+ {t('copyTradingOrders.createdAt') || '创建时间'}: {formattedDate} +
+
+
+ ) + })} +
+ )} +
+ ) : ( + `${t('common.total') || '共'} ${total} ${t('common.items') || '条'}`, + onChange: (newPage, newLimit) => { + setPage(newPage) + setLimit(newLimit) + } + }} + /> + )} + + ) +} + +export default BuyOrdersTab + diff --git a/frontend/src/pages/CopyTradingOrders/EditModal.tsx b/frontend/src/pages/CopyTradingOrders/EditModal.tsx new file mode 100644 index 0000000..34406c4 --- /dev/null +++ b/frontend/src/pages/CopyTradingOrders/EditModal.tsx @@ -0,0 +1,481 @@ +import { useEffect, useState } from 'react' +import { Modal, Form, Button, message, Radio, InputNumber, Divider, Spin, Select, Input, Space, Switch } from 'antd' +import { SaveOutlined } from '@ant-design/icons' +import { apiService } from '../../services/api' +import type { CopyTrading, CopyTradingUpdateRequest } from '../../types' +import { useTranslation } from 'react-i18next' + +const { Option } = Select + +interface EditModalProps { + open: boolean + onClose: () => void + copyTradingId: string + onSuccess?: () => void +} + +const EditModal: React.FC = ({ + open, + onClose, + copyTradingId, + onSuccess +}) => { + const { t } = useTranslation() + const [form] = Form.useForm() + const [loading, setLoading] = useState(false) + const [fetching, setFetching] = useState(true) + const [copyTrading, setCopyTrading] = useState(null) + const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO') + const [originalEnabled, setOriginalEnabled] = useState(true) + + useEffect(() => { + if (open && copyTradingId) { + fetchCopyTrading(parseInt(copyTradingId)) + } + }, [open, copyTradingId]) + + const fetchCopyTrading = async (copyTradingId: number) => { + setFetching(true) + try { + const response = await apiService.copyTrading.list({}) + if (response.data.code === 0 && response.data.data) { + const found = response.data.data.list.find((ct: CopyTrading) => ct.id === copyTradingId) + if (found) { + setCopyTrading(found) + setCopyMode(found.copyMode) + setOriginalEnabled(found.enabled) + form.setFieldsValue({ + accountId: found.accountId, + leaderId: found.leaderId, + copyMode: found.copyMode, + copyRatio: found.copyRatio ? parseFloat(found.copyRatio) * 100 : 100, + fixedAmount: found.fixedAmount ? parseFloat(found.fixedAmount) : undefined, + maxOrderSize: found.maxOrderSize ? parseFloat(found.maxOrderSize) : undefined, + minOrderSize: found.minOrderSize ? parseFloat(found.minOrderSize) : undefined, + maxDailyLoss: found.maxDailyLoss ? parseFloat(found.maxDailyLoss) : undefined, + maxDailyOrders: found.maxDailyOrders, + priceTolerance: found.priceTolerance ? parseFloat(found.priceTolerance) : undefined, + delaySeconds: found.delaySeconds, + pollIntervalSeconds: found.pollIntervalSeconds, + useWebSocket: found.useWebSocket, + websocketReconnectInterval: found.websocketReconnectInterval, + websocketMaxRetries: found.websocketMaxRetries, + supportSell: found.supportSell, + minOrderDepth: found.minOrderDepth ? parseFloat(found.minOrderDepth) : undefined, + maxSpread: found.maxSpread ? parseFloat(found.maxSpread) : undefined, + minPrice: found.minPrice ? parseFloat(found.minPrice) : undefined, + maxPrice: found.maxPrice ? parseFloat(found.maxPrice) : undefined, + configName: found.configName || '', + pushFailedOrders: found.pushFailedOrders ?? false + }) + } else { + message.error(t('copyTradingEdit.fetchFailed') || '跟单配置不存在') + onClose() + } + } else { + message.error(response.data.msg || t('copyTradingEdit.fetchFailed') || '获取跟单配置失败') + onClose() + } + } catch (error: any) { + message.error(error.message || t('copyTradingEdit.fetchFailed') || '获取跟单配置失败') + onClose() + } finally { + setFetching(false) + } + } + + const handleCopyModeChange = (mode: 'RATIO' | 'FIXED') => { + setCopyMode(mode) + } + + const handleSubmit = async (values: any) => { + if (values.copyMode === 'FIXED') { + if (!values.fixedAmount || Number(values.fixedAmount) < 1) { + message.error('固定金额必须 >= 1') + return + } + } + + if (values.copyMode === 'RATIO' && values.minOrderSize !== undefined && values.minOrderSize !== null && Number(values.minOrderSize) < 1) { + message.error('最小金额必须 >= 1') + return + } + + if (!copyTradingId) { + message.error('配置ID不存在') + return + } + + setLoading(true) + try { + const request: CopyTradingUpdateRequest = { + copyTradingId: parseInt(copyTradingId), + enabled: originalEnabled, + copyMode: values.copyMode, + copyRatio: values.copyMode === 'RATIO' && values.copyRatio ? (values.copyRatio / 100).toString() : undefined, + fixedAmount: values.copyMode === 'FIXED' ? values.fixedAmount?.toString() : undefined, + maxOrderSize: values.maxOrderSize?.toString(), + minOrderSize: values.minOrderSize?.toString(), + maxDailyLoss: values.maxDailyLoss?.toString(), + maxDailyOrders: values.maxDailyOrders, + priceTolerance: values.priceTolerance?.toString(), + delaySeconds: values.delaySeconds, + pollIntervalSeconds: values.pollIntervalSeconds, + useWebSocket: values.useWebSocket, + websocketReconnectInterval: values.websocketReconnectInterval, + websocketMaxRetries: values.websocketMaxRetries, + supportSell: values.supportSell, + minOrderDepth: values.minOrderDepth?.toString(), + maxSpread: values.maxSpread?.toString(), + minPrice: values.minPrice?.toString(), + maxPrice: values.maxPrice?.toString(), + configName: values.configName?.trim() || undefined, + pushFailedOrders: values.pushFailedOrders + } + + const response = await apiService.copyTrading.update(request) + + if (response.data.code === 0) { + message.success(t('copyTradingEdit.saveSuccess') || '更新跟单配置成功') + onClose() + if (onSuccess) { + onSuccess() + } + } else { + message.error(response.data.msg || t('copyTradingEdit.saveFailed') || '更新跟单配置失败') + } + } catch (error: any) { + message.error(error.message || t('copyTradingEdit.saveFailed') || '更新跟单配置失败') + } finally { + setLoading(false) + } + } + + return ( + + {fetching ? ( +
+ +
+ ) : !copyTrading ? ( +
+

{t('copyTradingEdit.fetchFailed') || '跟单配置不存在'}

+
+ ) : ( +
+ + + + + + + + + + + + + {t('copyTradingEdit.basicConfig') || '基础配置'} + + + handleCopyModeChange(e.target.value)}> + {t('copyTradingEdit.ratioMode') || '比例模式'} + {t('copyTradingEdit.fixedAmountMode') || '固定金额模式'} + + + + {copyMode === 'RATIO' && ( + + + + )} + + {copyMode === 'FIXED' && ( + { + if (value !== undefined && value !== null && value !== '') { + const amount = Number(value) + if (isNaN(amount)) { + return Promise.reject(new Error(t('copyTradingEdit.invalidNumber') || '请输入有效的数字')) + } + if (amount < 1) { + return Promise.reject(new Error(t('copyTradingEdit.fixedAmountMin') || '固定金额必须 >= 1')) + } + } + return Promise.resolve() + } + } + ]} + > + = 1'} + /> + + )} + + {copyMode === 'RATIO' && ( + <> + + + + + = 1'} + rules={[ + { + validator: (_, value) => { + if (value === undefined || value === null || value === '') { + return Promise.resolve() + } + if (typeof value === 'number' && value < 1) { + return Promise.reject(new Error(t('copyTradingEdit.minOrderSizeMin') || '最小金额必须 >= 1')) + } + return Promise.resolve() + } + } + ]} + > + = 1(可选)'} + /> + + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + {t('copyTradingEdit.priceRangeFilter') || '价格区间过滤'} + + + + + + + - + + + + + + + {t('copyTradingEdit.advancedSettings') || '高级设置'} + + + + + + + + + + + + + + + + + )} +
+ ) +} + +export default EditModal + diff --git a/frontend/src/pages/CopyTradingOrders/FilteredOrdersModal.tsx b/frontend/src/pages/CopyTradingOrders/FilteredOrdersModal.tsx new file mode 100644 index 0000000..040c799 --- /dev/null +++ b/frontend/src/pages/CopyTradingOrders/FilteredOrdersModal.tsx @@ -0,0 +1,282 @@ +import { useEffect, useState } from 'react' +import { Modal, Table, Tag, Select, Card, Divider, Spin } from 'antd' +import { useTranslation } from 'react-i18next' +import { apiService } from '../../services/api' +import type { FilteredOrder, FilteredOrderListResponse } from '../../types' +import { useMediaQuery } from 'react-responsive' +import { formatUSDC } from '../../utils' + +const { Option } = Select + +interface FilteredOrdersModalProps { + open: boolean + onClose: () => void + copyTradingId: string +} + +const FilteredOrdersModal: React.FC = ({ + open, + onClose, + copyTradingId +}) => { + const { t } = useTranslation() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const [loading, setLoading] = useState(false) + const [filteredOrders, setFilteredOrders] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [limit] = useState(20) + const [filterType, setFilterType] = useState(undefined) + + useEffect(() => { + if (open && copyTradingId) { + fetchFilteredOrders() + } + }, [open, copyTradingId, page, filterType]) + + const fetchFilteredOrders = async () => { + if (!copyTradingId) return + + setLoading(true) + try { + const response = await apiService.copyTrading.getFilteredOrders({ + copyTradingId: parseInt(copyTradingId), + filterType: filterType, + page: page, + limit: limit + }) + + if (response.data.code === 0 && response.data.data) { + const data: FilteredOrderListResponse = response.data.data + setFilteredOrders(data.list || []) + setTotal(data.total || 0) + } + } catch (error: any) { + console.error('获取被过滤订单列表失败:', error) + } finally { + setLoading(false) + } + } + + const getFilterTypeTag = (filterType: string) => { + const typeMap: Record = { + ORDER_DEPTH: { color: 'orange', text: t('filteredOrdersList.filterTypes.orderDepth') || '订单深度不足' }, + SPREAD: { color: 'red', text: t('filteredOrdersList.filterTypes.spread') || '价差过大' }, + ORDERBOOK_DEPTH: { color: 'purple', text: t('filteredOrdersList.filterTypes.orderbookDepth') || '订单簿深度不足' }, + PRICE_VALIDITY: { color: 'blue', text: t('filteredOrdersList.filterTypes.priceValidity') || '价格不合理' }, + MARKET_STATUS: { color: 'default', text: t('filteredOrdersList.filterTypes.marketStatus') || '市场状态不可交易' }, + ORDERBOOK_ERROR: { color: 'default', text: t('filteredOrdersList.filterTypes.orderbookError') || '订单簿获取失败' }, + ORDERBOOK_EMPTY: { color: 'default', text: t('filteredOrdersList.filterTypes.orderbookEmpty') || '订单簿为空' }, + PRICE_RANGE: { color: 'purple', text: t('filteredOrdersList.filterTypes.priceRange') || '价格区间不符' } + } + const config = typeMap[filterType] || { color: 'default', text: filterType } + return {config.text} + } + + const columns = [ + { + title: t('filteredOrdersList.market') || '市场', + dataIndex: 'marketTitle', + key: 'marketTitle', + width: isMobile ? 150 : 200, + ellipsis: true, + render: (text: string, record: FilteredOrder) => { + const marketTitle = text || record.marketId.slice(0, 10) + '...' + return {marketTitle} + } + }, + { + title: t('filteredOrdersList.side') || '方向', + dataIndex: 'side', + key: 'side', + width: isMobile ? 60 : 80, + render: (side: string) => ( + + {side === 'BUY' ? (t('order.buy') || '买入') : (t('order.sell') || '卖出')} + + ) + }, + { + title: t('filteredOrdersList.price') || '价格', + dataIndex: 'price', + key: 'price', + width: isMobile ? 80 : 100, + render: (value: string) => ( + {formatUSDC(value)} + ) + }, + { + title: t('filteredOrdersList.size') || '数量', + dataIndex: 'size', + key: 'size', + width: isMobile ? 80 : 100, + render: (value: string) => ( + {formatUSDC(value)} + ) + }, + { + title: t('filteredOrdersList.filterType') || '过滤类型', + dataIndex: 'filterType', + key: 'filterType', + width: isMobile ? 120 : 150, + render: (filterType: string) => getFilterTypeTag(filterType) + }, + { + title: t('filteredOrdersList.filterReason') || '过滤原因', + dataIndex: 'filterReason', + key: 'filterReason', + width: isMobile ? 150 : 250, + ellipsis: true, + render: (text: string) => ( + {text} + ) + }, + { + title: t('filteredOrdersList.createdAt') || '时间', + dataIndex: 'createdAt', + key: 'createdAt', + width: isMobile ? 120 : 160, + render: (timestamp: number) => { + const date = new Date(timestamp) + const format = isMobile + ? `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}` + : `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}` + return ( + + {format} + + ) + } + } + ] + + return ( + +
+ +
+ + {isMobile ? ( +
+ {loading ? ( +
+ +
+ ) : filteredOrders.length === 0 ? ( +
+ {t('filteredOrdersList.noFilteredOrders') || '暂无已过滤订单'} +
+ ) : ( +
+ {filteredOrders.map((order) => { + const date = new Date(order.createdAt) + const formattedDate = date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) + const marketTitle = order.marketTitle || order.marketId.slice(0, 10) + '...' + + return ( + +
+
+ {marketTitle} +
+
+ + {order.side === 'BUY' ? (t('order.buy') || '买入') : (t('order.sell') || '卖出')} + + {getFilterTypeTag(order.filterType)} +
+
+ + + +
+
{t('filteredOrdersList.orderDetails') || '订单详情'}
+
+ {t('filteredOrdersList.price') || '价格'}: {formatUSDC(order.price)} | {t('filteredOrdersList.size') || '数量'}: {formatUSDC(order.size)} +
+
+ +
+
{t('filteredOrdersList.filterReason') || '过滤原因'}
+
+ {order.filterReason} +
+
+ +
+
+ {t('filteredOrdersList.createdAt') || '时间'}: {formattedDate} +
+
+
+ ) + })} +
+ )} +
+ ) : ( +
`${t('common.total') || '共'} ${total} ${t('common.items') || '条'}`, + onChange: (newPage) => { + setPage(newPage) + } + }} + /> + )} + + ) +} + +export default FilteredOrdersModal + diff --git a/frontend/src/pages/CopyTradingOrders/MatchedOrdersTab.tsx b/frontend/src/pages/CopyTradingOrders/MatchedOrdersTab.tsx new file mode 100644 index 0000000..bcd27be --- /dev/null +++ b/frontend/src/pages/CopyTradingOrders/MatchedOrdersTab.tsx @@ -0,0 +1,287 @@ +import { useEffect, useState } from 'react' +import { Table, Input, Button, Card, Divider, Spin } from 'antd' +import { apiService } from '../../services/api' +import { formatUSDC } from '../../utils' +import { useMediaQuery } from 'react-responsive' +import { useTranslation } from 'react-i18next' +import type { MatchedOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types' + +interface MatchedOrdersTabProps { + copyTradingId: string +} + +const MatchedOrdersTab: React.FC = ({ copyTradingId }) => { + const { t } = useTranslation() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const [loading, setLoading] = useState(false) + const [orders, setOrders] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [limit, setLimit] = useState(20) + const [filters, setFilters] = useState<{ + sellOrderId?: string + buyOrderId?: string + }>({}) + + useEffect(() => { + if (copyTradingId) { + fetchOrders() + } + }, [copyTradingId, page, limit, filters]) + + const fetchOrders = async () => { + if (!copyTradingId) return + + setLoading(true) + try { + const request: OrderTrackingRequest = { + copyTradingId: parseInt(copyTradingId), + type: 'matched', + page, + limit, + ...filters + } + + const response = await apiService.orderTracking.list(request) + if (response.data.code === 0 && response.data.data) { + const data = response.data.data as OrderTrackingListResponse + setOrders((data.list || []) as MatchedOrderInfo[]) + setTotal(data.total || 0) + } + } catch (error: any) { + console.error('获取匹配关系列表失败:', error) + } finally { + setLoading(false) + } + } + + const getPnlColor = (value: string): string => { + const num = parseFloat(value) + if (isNaN(num)) return '#666' + return num >= 0 ? '#3f8600' : '#cf1322' + } + + const columns = [ + { + title: t('copyTradingOrders.sellOrderId') || '卖出订单ID', + dataIndex: 'sellOrderId', + key: 'sellOrderId', + width: isMobile ? 100 : 150, + render: (text: string) => ( + + {isMobile + ? `${text.slice(0, 6)}...${text.slice(-4)}` + : `${text.slice(0, 8)}...${text.slice(-6)}` + } + + ) + }, + { + title: t('copyTradingOrders.buyOrderId') || '买入订单ID', + dataIndex: 'buyOrderId', + key: 'buyOrderId', + width: isMobile ? 100 : 150, + render: (text: string) => ( + + {isMobile + ? `${text.slice(0, 6)}...${text.slice(-4)}` + : `${text.slice(0, 8)}...${text.slice(-6)}` + } + + ) + }, + { + title: t('copyTradingOrders.matchedQuantity') || '匹配数量', + dataIndex: 'matchedQuantity', + key: 'matchedQuantity', + width: isMobile ? 80 : 100, + render: (value: string) => ( + {formatUSDC(value)} + ) + }, + { + title: t('copyTradingOrders.buyPrice') || '买入价格', + dataIndex: 'buyPrice', + key: 'buyPrice', + width: isMobile ? 80 : 100, + render: (value: string) => ( + {formatUSDC(value)} + ) + }, + { + title: t('copyTradingOrders.sellPrice') || '卖出价格', + dataIndex: 'sellPrice', + key: 'sellPrice', + width: isMobile ? 80 : 100, + render: (value: string) => ( + {formatUSDC(value)} + ) + }, + { + title: t('copyTradingOrders.realizedPnl') || '盈亏', + dataIndex: 'realizedPnl', + key: 'realizedPnl', + width: isMobile ? 100 : 120, + render: (value: string) => ( + + {isMobile ? formatUSDC(value) : `${formatUSDC(value)} USDC`} + + ) + }, + { + title: t('copyTradingOrders.matchedAt') || '匹配时间', + dataIndex: 'matchedAt', + key: 'matchedAt', + width: isMobile ? 120 : 160, + render: (timestamp: number) => ( + + {isMobile + ? new Date(timestamp).toLocaleDateString('zh-CN') + : new Date(timestamp).toLocaleString('zh-CN') + } + + ) + } + ] + + return ( +
+
+ setFilters({ ...filters, sellOrderId: e.target.value || undefined })} + /> + + setFilters({ ...filters, buyOrderId: e.target.value || undefined })} + /> + + +
+ + {isMobile ? ( +
+ {loading ? ( +
+ +
+ ) : orders.length === 0 ? ( +
+ {t('copyTradingOrders.noMatchedOrders') || '暂无匹配关系'} +
+ ) : ( +
+ {orders.map((order) => { + const date = new Date(order.matchedAt) + const formattedDate = date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) + + return ( + +
+
{t('copyTradingOrders.sellOrderId') || '卖出订单ID'}
+
+ {order.sellOrderId.slice(0, 8)}...{order.sellOrderId.slice(-6)} +
+
{t('copyTradingOrders.buyOrderId') || '买入订单ID'}
+
+ {order.buyOrderId.slice(0, 8)}...{order.buyOrderId.slice(-6)} +
+
+ + + +
+
{t('copyTradingOrders.matchedQuantity') || '匹配数量'}
+
+ {formatUSDC(order.matchedQuantity)} +
+
+ +
+
{t('copyTradingOrders.priceInfo') || '价格信息'}
+
+ {t('copyTradingOrders.buy') || '买入'}: {formatUSDC(order.buyPrice)} | {t('copyTradingOrders.sell') || '卖出'}: {formatUSDC(order.sellPrice)} +
+
+ +
+
{t('copyTradingOrders.realizedPnl') || '盈亏'}
+
+ {formatUSDC(order.realizedPnl)} USDC +
+
+ +
+
+ {t('copyTradingOrders.matchedAt') || '匹配时间'}: {formattedDate} +
+
+
+ ) + })} +
+ )} +
+ ) : ( +
`${record.sellOrderId}-${record.buyOrderId}-${record.matchedAt}`} + loading={loading} + pagination={{ + current: page, + pageSize: limit, + total, + showSizeChanger: true, + showTotal: (total) => `${t('common.total') || '共'} ${total} ${t('common.items') || '条'}`, + onChange: (newPage, newLimit) => { + setPage(newPage) + setLimit(newLimit) + } + }} + /> + )} + + ) +} + +export default MatchedOrdersTab + diff --git a/frontend/src/pages/CopyTradingOrders/SellOrdersTab.tsx b/frontend/src/pages/CopyTradingOrders/SellOrdersTab.tsx new file mode 100644 index 0000000..d00ec53 --- /dev/null +++ b/frontend/src/pages/CopyTradingOrders/SellOrdersTab.tsx @@ -0,0 +1,328 @@ +import { useEffect, useState } from 'react' +import { Table, Tag, Select, Input, Button, Card, Divider, Spin } from 'antd' +import { apiService } from '../../services/api' +import { formatUSDC } from '../../utils' +import { useMediaQuery } from 'react-responsive' +import { useTranslation } from 'react-i18next' +import type { SellOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types' + +const { Option } = Select + +interface SellOrdersTabProps { + copyTradingId: string +} + +const SellOrdersTab: React.FC = ({ copyTradingId }) => { + const { t } = useTranslation() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const [loading, setLoading] = useState(false) + const [orders, setOrders] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [limit, setLimit] = useState(20) + const [filters, setFilters] = useState<{ + marketId?: string + side?: string + }>({}) + + useEffect(() => { + if (copyTradingId) { + fetchOrders() + } + }, [copyTradingId, page, limit, filters]) + + const fetchOrders = async () => { + if (!copyTradingId) return + + setLoading(true) + try { + const request: OrderTrackingRequest = { + copyTradingId: parseInt(copyTradingId), + type: 'sell', + page, + limit, + ...filters + } + + const response = await apiService.orderTracking.list(request) + if (response.data.code === 0 && response.data.data) { + const data = response.data.data as OrderTrackingListResponse + setOrders((data.list || []) as SellOrderInfo[]) + setTotal(data.total || 0) + } + } catch (error: any) { + console.error('获取卖出订单列表失败:', error) + } finally { + setLoading(false) + } + } + + const getPnlColor = (value: string): string => { + const num = parseFloat(value) + if (isNaN(num)) return '#666' + return num >= 0 ? '#3f8600' : '#cf1322' + } + + const columns = [ + { + title: t('copyTradingOrders.orderId') || '订单ID', + dataIndex: 'orderId', + key: 'orderId', + width: isMobile ? 100 : 150, + render: (text: string) => ( + + {isMobile + ? `${text.slice(0, 6)}...${text.slice(-4)}` + : `${text.slice(0, 8)}...${text.slice(-6)}` + } + + ) + }, + { + title: t('copyTradingOrders.leaderTradeId') || 'Leader 交易ID', + dataIndex: 'leaderTradeId', + key: 'leaderTradeId', + width: isMobile ? 100 : 150, + render: (text: string) => ( + + {isMobile + ? `${text.slice(0, 6)}...${text.slice(-4)}` + : `${text.slice(0, 8)}...${text.slice(-6)}` + } + + ) + }, + { + title: t('copyTradingOrders.market') || '市场', + dataIndex: 'marketId', + key: 'marketId', + width: isMobile ? 100 : 150, + render: (text: string) => ( + + {isMobile + ? `${text.slice(0, 6)}...${text.slice(-4)}` + : `${text.slice(0, 8)}...${text.slice(-6)}` + } + + ) + }, + { + title: t('copyTradingOrders.side') || '方向', + dataIndex: 'side', + key: 'side', + width: isMobile ? 60 : 80, + render: (side: string) => { + const displaySide = side === '0' ? 'YES' : side === '1' ? 'NO' : side + return {displaySide} + } + }, + { + title: t('copyTradingOrders.sellQuantity') || '卖出数量', + dataIndex: 'quantity', + key: 'quantity', + width: isMobile ? 80 : 100, + render: (value: string) => ( + {formatUSDC(value)} + ) + }, + { + title: t('copyTradingOrders.sellPrice') || '卖出价格', + dataIndex: 'price', + key: 'price', + width: isMobile ? 80 : 100, + render: (value: string) => ( + {formatUSDC(value)} + ) + }, + { + title: t('copyTradingOrders.sellAmount') || '卖出金额', + key: 'amount', + width: isMobile ? 100 : 120, + render: (_: any, record: SellOrderInfo) => { + const amount = (parseFloat(record.quantity) * parseFloat(record.price)).toString() + return ( + + {isMobile ? formatUSDC(amount) : `${formatUSDC(amount)} USDC`} + + ) + } + }, + { + title: t('copyTradingOrders.realizedPnl') || '已实现盈亏', + dataIndex: 'realizedPnl', + key: 'realizedPnl', + width: isMobile ? 100 : 120, + render: (value: string) => ( + + {isMobile ? formatUSDC(value) : `${formatUSDC(value)} USDC`} + + ) + }, + { + title: t('copyTradingOrders.createdAt') || '创建时间', + dataIndex: 'createdAt', + key: 'createdAt', + width: isMobile ? 120 : 160, + render: (timestamp: number) => ( + + {isMobile + ? new Date(timestamp).toLocaleDateString('zh-CN') + : new Date(timestamp).toLocaleString('zh-CN') + } + + ) + } + ] + + return ( +
+
+ setFilters({ ...filters, marketId: e.target.value || undefined })} + /> + + + + +
+ + {isMobile ? ( +
+ {loading ? ( +
+ +
+ ) : orders.length === 0 ? ( +
+ {t('copyTradingOrders.noSellOrders') || '暂无卖出订单'} +
+ ) : ( +
+ {orders.map((order) => { + const date = new Date(order.createdAt) + const formattedDate = date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) + const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString() + const displaySide = order.side === '0' ? 'YES' : order.side === '1' ? 'NO' : order.side + + return ( + +
+
+ {order.orderId.slice(0, 8)}...{order.orderId.slice(-6)} +
+
+ {displaySide} +
+
+ + + +
+
{t('copyTradingOrders.sellInfo') || '卖出信息'}
+
+ {t('copyTradingOrders.quantity') || '数量'}: {formatUSDC(order.quantity)} | {t('copyTradingOrders.price') || '价格'}: {formatUSDC(order.price)} +
+
+ {t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC +
+
+ +
+
{t('copyTradingOrders.realizedPnl') || '已实现盈亏'}
+
+ {formatUSDC(order.realizedPnl)} USDC +
+
+ +
+
{t('copyTradingOrders.leaderTradeId') || 'Leader 交易ID'}
+
+ {order.leaderTradeId.slice(0, 8)}...{order.leaderTradeId.slice(-6)} +
+
+ +
+
{t('copyTradingOrders.marketId') || '市场ID'}
+
+ {order.marketId.slice(0, 8)}...{order.marketId.slice(-6)} +
+
+ +
+
+ {t('copyTradingOrders.createdAt') || '创建时间'}: {formattedDate} +
+
+
+ ) + })} +
+ )} +
+ ) : ( +
`${t('common.total') || '共'} ${total} ${t('common.items') || '条'}`, + onChange: (newPage, newLimit) => { + setPage(newPage) + setLimit(newLimit) + } + }} + /> + )} + + ) +} + +export default SellOrdersTab + diff --git a/frontend/src/pages/CopyTradingOrders/StatisticsModal.tsx b/frontend/src/pages/CopyTradingOrders/StatisticsModal.tsx new file mode 100644 index 0000000..088522c --- /dev/null +++ b/frontend/src/pages/CopyTradingOrders/StatisticsModal.tsx @@ -0,0 +1,303 @@ +import { useEffect, useState } from 'react' +import { Modal, Row, Col, Statistic, Spin, message, Card } from 'antd' +import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons' +import { apiService } from '../../services/api' +import { formatUSDC } from '../../utils' +import { useTranslation } from 'react-i18next' +import { useMediaQuery } from 'react-responsive' +import type { CopyTradingStatistics } from '../../types' + +interface StatisticsModalProps { + open: boolean + onClose: () => void + copyTradingId: string +} + +const StatisticsModal: React.FC = ({ + open, + onClose, + copyTradingId +}) => { + const { t } = useTranslation() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const [loading, setLoading] = useState(false) + const [statistics, setStatistics] = useState(null) + + useEffect(() => { + if (open && copyTradingId) { + fetchStatistics() + } + }, [open, copyTradingId]) + + const fetchStatistics = async () => { + if (!copyTradingId) return + + setLoading(true) + try { + const response = await apiService.statistics.detail({ copyTradingId: parseInt(copyTradingId) }) + if (response.data.code === 0 && response.data.data) { + setStatistics(response.data.data) + } else { + message.error(response.data.msg || t('copyTradingOrders.fetchStatisticsFailed') || '获取统计信息失败') + } + } catch (error: any) { + message.error(error.message || t('copyTradingOrders.fetchStatisticsFailed') || '获取统计信息失败') + } finally { + setLoading(false) + } + } + + const getPnlColor = (value: string): string => { + const num = parseFloat(value) + if (isNaN(num)) return '#666' + return num >= 0 ? '#3f8600' : '#cf1322' + } + + const getPnlIcon = (value: string) => { + const num = parseFloat(value) + if (isNaN(num)) return null + return num >= 0 ? : + } + + const formatPercent = (value: string): string => { + const num = parseFloat(value) + if (isNaN(num)) return '-' + return `${num >= 0 ? '+' : ''}${num.toFixed(2)}%` + } + + return ( + + {loading ? ( +
+ +
+ ) : !statistics ? ( +
+

{t('copyTradingOrders.noStatistics') || '暂无统计数据'}

+
+ ) : isMobile ? ( +
+
+
+ {t('copyTradingOrders.totalBuyOrders') || '总买入订单数'} +
+
+ + {statistics.totalBuyOrders} +
+
+
+
+ {t('copyTradingOrders.totalSellOrders') || '总卖出订单数'} +
+
+ + {statistics.totalSellOrders} +
+
+
+
+ {t('copyTradingOrders.totalMatchedOrders') || '总匹配订单数'} +
+
+ {statistics.totalMatchedOrders || 0} +
+
+
+
+ {t('copyTradingOrders.totalBuyAmount') || '总买入金额'} +
+
+ + {formatUSDC(statistics.totalBuyAmount)} USDC +
+
+
+
+ {t('copyTradingOrders.totalSellAmount') || '总卖出金额'} +
+
+ + {formatUSDC(statistics.totalSellAmount)} USDC +
+
+
+
+ {t('copyTradingOrders.totalPnl') || '总盈亏'} +
+
+ {getPnlIcon(statistics.totalPnl)} + {formatUSDC(statistics.totalPnl)} USDC +
+
+
+
+ {t('copyTradingOrders.totalRealizedPnl') || '总已实现盈亏'} +
+
+ {getPnlIcon(statistics.totalRealizedPnl)} + {formatUSDC(statistics.totalRealizedPnl)} USDC +
+
+
+
+ {t('copyTradingOrders.totalUnrealizedPnl') || '总未实现盈亏'} +
+
+ {getPnlIcon(statistics.totalUnrealizedPnl)} + {formatUSDC(statistics.totalUnrealizedPnl)} USDC +
+
+
+
+ {t('copyTradingOrders.winRate') || '胜率'} +
+
+ {formatPercent(statistics.winRate)} +
+
+
+
+ {t('copyTradingOrders.averagePnl') || '平均盈亏'} +
+
+ {getPnlIcon(statistics.averagePnl)} + {formatUSDC(statistics.averagePnl)} USDC +
+
+
+
+ {t('copyTradingOrders.maxPnl') || '最大盈亏'} +
+
+ {getPnlIcon(statistics.maxPnl)} + {formatUSDC(statistics.maxPnl)} USDC +
+
+
+
+ {t('copyTradingOrders.minPnl') || '最小盈亏'} +
+
+ {getPnlIcon(statistics.minPnl)} + {formatUSDC(statistics.minPnl)} USDC +
+
+
+ ) : ( +
+ +
+ } + /> + + + } + /> + + + + + + } + /> + + + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + )} + + ) +} + +export default StatisticsModal + diff --git a/frontend/src/pages/CopyTradingOrders/index.tsx b/frontend/src/pages/CopyTradingOrders/index.tsx new file mode 100644 index 0000000..b3b21fe --- /dev/null +++ b/frontend/src/pages/CopyTradingOrders/index.tsx @@ -0,0 +1,69 @@ +import { useState, useEffect } from 'react' +import { useParams, useSearchParams } from 'react-router-dom' +import { Modal, Tabs } from 'antd' +import { useTranslation } from 'react-i18next' +import BuyOrdersTab from './BuyOrdersTab' +import SellOrdersTab from './SellOrdersTab' +import MatchedOrdersTab from './MatchedOrdersTab' + +type TabType = 'buy' | 'sell' | 'matched' + +interface CopyTradingOrdersModalProps { + open: boolean + onClose: () => void + copyTradingId: string + defaultTab?: TabType +} + +const CopyTradingOrdersModal: React.FC = ({ + open, + onClose, + copyTradingId, + defaultTab = 'buy' +}) => { + const { t } = useTranslation() + const [activeTab, setActiveTab] = useState(defaultTab) + + useEffect(() => { + if (open) { + setActiveTab(defaultTab) + } + }, [open, defaultTab]) + + return ( + + setActiveTab(key as TabType)} + items={[ + { + key: 'buy', + label: t('copyTradingOrders.buyOrders') || '买入订单', + children: + }, + { + key: 'sell', + label: t('copyTradingOrders.sellOrders') || '卖出订单', + children: + }, + { + key: 'matched', + label: t('copyTradingOrders.matchedOrders') || '匹配关系', + children: + } + ]} + /> + + ) +} + +export default CopyTradingOrdersModal +