Compare commits

..

13 Commits

Author SHA1 Message Date
WrBug a31ed31c98 feat: 优化系统配置和统计功能
- 系统配置支持显示完整的 Builder API Key(不再部分显示)
- Secret 和 Passphrase 使用密码输入框,支持眼睛图标切换显示/隐藏
- 移除已配置提示,简化用户界面
- 修复总卖出金额统计:使用 SellMatchDetail 计算,确保准确性
- 修复 matchSellOrder 中价格不一致问题:matchDetails 使用实际卖出价格
- 移动端 Leader 列表移除网站显示行
- 优化订单创建前订单簿匹配检查,避免 FAK 订单失败
2025-12-11 03:17:41 +08:00
WrBug 0625c62e36 feat: 优化跟单买入逻辑和错误处理
- 更新 Twitter 链接为 @polyhermes
- 简化订单创建失败日志,只保留 code 和 errorBody
- 修复 @Transactional 注解问题(类改为 open class)
- 改进唯一键冲突异常处理,支持并发场景
- 整理跟单买入和重试逻辑,使用常量配置
- 重试延迟从 1 秒调整为 3 秒
- 添加订单簿匹配检查,避免 FAK 订单失败
- 前端移除 Leader 管理的分类相关设置和显示
- 补充多语言翻译(AccountImport 和 CopyTradingList)
2025-12-11 02:58:50 +08:00
WrBug f8d7276654 fix: 修复前端编译错误
- 移除未使用的导入 useParams 和 useSearchParams
- 移除未使用的导入 Card
- 移除不存在的统计字段:totalMatchedOrders, winRate, averagePnl, maxPnl, minPnl
- 移除未使用的 formatPercent 函数
2025-12-09 06:33:16 +08:00
WrBug 442d5778a7 Merge pull request #3 from WrBug/pre_release
Release v1.0.1: 优化前端UI和功能
2025-12-09 06:27:42 +08:00
WrBug cc41df2f24 feat: 优化前端UI和功能
- 修复仓位轮询检查逻辑,增加2分钟延迟判断
- 统一订单页面为Tab切换模式,使用Modal展示
- 优化移动端统计UI,改为两列线性布局
- 简化过滤类型标签,移除括号说明
- 账户管理列表优化:钱包地址显示前6后4,移除API凭证和活跃订单列
- 修复复制按钮功能,添加降级方案和Toast提示
- 修复菜单层级问题,订单下拉直接显示已成交和已过滤订单
- 添加编辑功能Modal化
- 完善多语言支持
2025-12-09 06:23:51 +08:00
WrBug cdadce467e fix: 修复仓位轮询检查逻辑和时区问题
- 修复仓位轮询检查:当仓位不存在时,只有当订单创建时间超过2分钟才认为仓位被出售,避免刚创建的订单被误判
- 修复时区问题:所有时间格式化使用系统时区,移除硬编码的时区配置
  - DateUtils 添加 formatDateTime() 函数,使用 ZoneId.systemDefault()
  - TelegramNotificationService 所有时间格式化使用 DateUtils.formatDateTime()
  - Dockerfile 和启动脚本移除时区配置,自动使用系统时区
2025-12-09 04:48:25 +08:00
WrBug 6e4d4f1ef5 fix: 修复仓位轮询检查逻辑,避免刚创建的订单被误判为已卖出
- 当仓位不存在时,只有当订单创建时间超过30秒才认为仓位被出售
- 避免因API延迟导致刚创建的订单被误判为已卖出状态
2025-12-09 04:23:23 +08:00
WrBug 9fb295a2f4 Merge pull request #2 from WrBug/feature_struct_refactor
Feature struct refactor
2025-12-09 01:34:36 +08:00
WrBug db16dea333 feat: 添加市场结算判断逻辑,支持赢了和输了的价格处理
- 添加市场已关闭时的结算判断逻辑
- 如果市场已关闭且该 outcome 赢了(价格 >= 0.99),返回价格为 1
- 如果市场已关闭且该 outcome 输了(价格 <= 0.01),返回价格为 0
- 优先使用 outcomePrices(结算价格数组)判断
- 如果没有 outcomePrices,使用 bestBid 判断
- 支持多元市场的价格转换
- 当没有仓位但有未完成订单时,正确判断市场结算状态并设置价格
2025-12-09 01:12:53 +08:00
WrBug d6f0914eeb fix: 修复健康检查路径不一致问题
- 修复 start.sh 中健康检查路径:从 /api/health 改为 /api/system/health
- 修复 Dockerfile 中健康检查路径:从 /api/health 改为 /api/system/health
- 确保健康检查路径与实际 HealthController 端点一致
2025-12-09 01:03:21 +08:00
WrBug f0c40533cf 优化跟单过滤逻辑和卖出价格计算
- 优化订单簿请求:仅在需要时请求,避免不必要的API调用
- 卖出价格改为市价卖出:优先使用订单簿bestBid,失败则使用Leader价格,固定按90%计算
- 价格容忍度默认值:如果为0,自动使用5%
- 使用枚举和数据类优化checkFilters方法:
  - 新增FilterResult数据类和FilterStatus枚举
  - 移除无用字段isBuyOrder参数
  - 使用类型安全的枚举替代字符串判断
- 优化代码结构:减少if-else嵌套,使用链式调用
- 移除最小订单簿深度功能(minOrderbookDepth)
- 优化最小订单深度逻辑:检查所有方向(买盘+卖盘)的总深度
2025-12-09 00:52:50 +08:00
WrBug d393bd7b40 fix: 修复未实现盈亏和持仓价值计算逻辑
- 修复同一市场多个订单时未实现盈亏重复计算的问题
- 按市场聚合订单,计算加权平均买入价格
- 修复持仓价值重复计算的问题
- 确保总盈亏和总盈亏百分比计算正确
2025-12-08 23:10:03 +08:00
WrBug 7173d24fe1 refactor: 重构代码结构,按业务分类组织 Controller 和 Service
- 重构 API 路径为 /api/业务/xxx 格式
- 将 Controller 和 Service 按业务分类组织:
  * copytrading: 跟单相关(configs, leaders, statistics, templates)
  * accounts: 账户管理(公共模块)
  * markets: 市场数据(公共模块)
  * system: 系统管理(users, notifications, proxy, health)
  * auth: 认证
  * announcement: 公告
- 将所有完整类名引用改为使用 import 导入
- 修复 calculatePositionValue 方法实现
- 更新前端 API 调用路径
2025-12-08 23:04:40 +08:00
87 changed files with 3681 additions and 838 deletions
+7
View File
@@ -94,6 +94,13 @@ coverage/
test-results/
*.test.log
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
# Misc
*.bak
*.backup
+133
View File
@@ -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**@polyhermes
* **Telegram 群组**:加入群组
---
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
**💬 如有问题或建议,欢迎在 GitHub Issues 中反馈。**
+2 -2
View File
@@ -1,7 +1,7 @@
# PolyHermes
[![GitHub](https://img.shields.io/badge/GitHub-WrBug%2FPolyHermes-blue?logo=github)](https://github.com/WrBug/PolyHermes)
[![Twitter](https://img.shields.io/badge/Twitter-@quant__tr-blue?logo=twitter)](https://x.com/quant_tr)
[![Twitter](https://img.shields.io/badge/Twitter-@polyhermes-blue?logo=twitter)](https://x.com/polyhermes)
> 🌐 **Language**: [English](README_EN.md) | 中文
@@ -414,7 +414,7 @@ cd frontend
## 🔗 相关链接
- [GitHub 仓库](https://github.com/WrBug/PolyHermes)
- [Twitter](https://x.com/quant_tr)
- [Twitter](https://x.com/polyhermes)
- [Polymarket 官网](https://polymarket.com)
- [Polymarket API 文档](https://docs.polymarket.com)
+2 -2
View File
@@ -1,7 +1,7 @@
# PolyHermes
[![GitHub](https://img.shields.io/badge/GitHub-WrBug%2FPolyHermes-blue?logo=github)](https://github.com/WrBug/PolyHermes)
[![Twitter](https://img.shields.io/badge/Twitter-@quant__tr-blue?logo=twitter)](https://x.com/quant_tr)
[![Twitter](https://img.shields.io/badge/Twitter-@polyhermes-blue?logo=twitter)](https://x.com/polyhermes)
> 🌐 **Language**: English | [中文](README.md)
@@ -414,7 +414,7 @@ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file
## 🔗 Related Links
- [GitHub Repository](https://github.com/WrBug/PolyHermes)
- [Twitter](https://x.com/quant_tr)
- [Twitter](https://x.com/polyhermes)
- [Polymarket Official Website](https://polymarket.com)
- [Polymarket API Documentation](https://docs.polymarket.com)
+55
View File
@@ -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
+4 -4
View File
@@ -22,9 +22,9 @@ FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
# 安装必要的工具
# 安装必要的工具和时区数据
RUN apt-get update && \
apt-get install -y curl && \
apt-get install -y curl tzdata && \
rm -rf /var/lib/apt/lists/*
# 从构建阶段复制 JAR 文件
@@ -41,8 +41,8 @@ EXPOSE 8000
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD curl -f http://localhost:8000/api/health || exit 1
CMD curl -f http://localhost:8000/api/system/health || exit 1
# 启动应用
# 启动应用(自动使用系统时区)
ENTRYPOINT ["java", "-jar", "app.jar"]
@@ -227,7 +227,7 @@ data class CreateOrderRequest(
@Deprecated("使用 NewOrderRequest 代替")
data class CreateOrdersBatchRequest(
val orders: List<CreateOrderRequest>
val orders: List<NewOrderRequest>
)
data class CancelOrdersBatchRequest(
@@ -1,6 +1,6 @@
package com.wrbug.polymarketbot.config
import com.wrbug.polymarketbot.service.ProxyConfigService
import com.wrbug.polymarketbot.service.system.ProxyConfigService
import jakarta.annotation.PostConstruct
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
@@ -1,8 +1,8 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.accounts
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.AccountService
import com.wrbug.polymarketbot.service.accounts.AccountService
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
@@ -15,7 +15,7 @@ import java.math.BigDecimal
* 账户管理控制器
*/
@RestController
@RequestMapping("/api/copy-trading/accounts")
@RequestMapping("/api/accounts")
class AccountController(
private val accountService: AccountService,
private val messageSource: MessageSource
@@ -1,8 +1,8 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.announcement
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.AnnouncementService
import com.wrbug.polymarketbot.service.announcement.AnnouncementService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
@@ -1,8 +1,8 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.auth
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.AuthService
import com.wrbug.polymarketbot.service.auth.AuthService
import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
@@ -1,9 +1,9 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.copytrading.configs
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.CopyTradingService
import com.wrbug.polymarketbot.service.FilteredOrderService
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingService
import com.wrbug.polymarketbot.service.copytrading.configs.FilteredOrderService
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
@@ -13,7 +13,7 @@ import org.springframework.web.bind.annotation.*
* 跟单配置管理控制器钱包-模板关联
*/
@RestController
@RequestMapping("/api/copy-trading")
@RequestMapping("/api/copy-trading/configs")
class CopyTradingController(
private val copyTradingService: CopyTradingService,
private val filteredOrderService: FilteredOrderService,
@@ -1,8 +1,8 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.copytrading.leaders
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.LeaderService
import com.wrbug.polymarketbot.service.copytrading.leaders.LeaderService
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
@@ -1,8 +1,8 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.copytrading.statistics
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.CopyTradingStatisticsService
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyTradingStatisticsService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
@@ -1,8 +1,8 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.copytrading.templates
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.CopyTradingTemplateService
import com.wrbug.polymarketbot.service.copytrading.templates.CopyTradingTemplateService
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
@@ -1,10 +1,10 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.markets
import com.wrbug.polymarketbot.api.LatestPriceResponse
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.AccountService
import com.wrbug.polymarketbot.service.PolymarketClobService
import com.wrbug.polymarketbot.service.accounts.AccountService
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
@@ -16,7 +16,7 @@ import org.springframework.web.bind.annotation.*
* 提供市场相关的数据查询接口价格订单簿等
*/
@RestController
@RequestMapping("/api/copy-trading/markets")
@RequestMapping("/api/markets")
class MarketController(
private val accountService: AccountService,
private val clobService: PolymarketClobService,
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.system
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
@@ -11,7 +11,7 @@ import java.util.*
* 用于 Docker 健康检查和监控
*/
@RestController
@RequestMapping("/api/health")
@RequestMapping("/api/system/health")
class HealthController {
@GetMapping
@@ -1,9 +1,9 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.system
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.NotificationConfigService
import com.wrbug.polymarketbot.service.TelegramNotificationService
import com.wrbug.polymarketbot.service.system.NotificationConfigService
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
@@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.*
* 消息推送配置控制器
*/
@RestController
@RequestMapping("/api/notifications")
@RequestMapping("/api/system/notifications")
class NotificationController(
private val notificationConfigService: NotificationConfigService,
private val telegramNotificationService: TelegramNotificationService,
@@ -1,9 +1,9 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.system
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.ApiHealthCheckService
import com.wrbug.polymarketbot.service.ProxyConfigService
import com.wrbug.polymarketbot.service.system.ApiHealthCheckService
import com.wrbug.polymarketbot.service.system.ProxyConfigService
import jakarta.servlet.http.HttpServletRequest
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
@@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.*
* 代理配置控制器
*/
@RestController
@RequestMapping("/api/proxy-config")
@RequestMapping("/api/system/proxy")
class ProxyConfigController(
private val proxyConfigService: ProxyConfigService,
private val apiHealthCheckService: ApiHealthCheckService,
@@ -1,9 +1,9 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.system
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.SystemConfigService
import com.wrbug.polymarketbot.service.RelayClientService
import com.wrbug.polymarketbot.service.system.SystemConfigService
import com.wrbug.polymarketbot.service.system.RelayClientService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
@@ -1,8 +1,8 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.system
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.UserService
import com.wrbug.polymarketbot.service.system.UserService
import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
@@ -13,7 +13,7 @@ import org.springframework.web.bind.annotation.*
* 用户管理控制器
*/
@RestController
@RequestMapping("/api/users")
@RequestMapping("/api/system/users")
class UserController(
private val userService: UserService,
private val messageSource: MessageSource
@@ -36,6 +36,9 @@ data class SystemConfigDto(
val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置
val builderSecretConfigured: Boolean, // Builder Secret 是否已配置
val builderPassphraseConfigured: Boolean, // Builder Passphrase 是否已配置
val builderApiKeyDisplay: String? = null, // Builder API Key 显示值(部分显示,用于前端展示)
val builderSecretDisplay: String? = null, // Builder Secret 显示值(部分显示,用于前端展示)
val builderPassphraseDisplay: String? = null, // Builder Passphrase 显示值(部分显示,用于前端展示)
val autoRedeemEnabled: Boolean = true // 自动赎回(系统级别配置,默认开启)
)
@@ -32,7 +32,6 @@ data class CopyTradingCreateRequest(
// 过滤条件
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minOrderbookDepth: String? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
// 新增配置字段
@@ -64,7 +63,6 @@ data class CopyTradingUpdateRequest(
// 过滤条件
val minOrderDepth: String? = null,
val maxSpread: String? = null,
val minOrderbookDepth: String? = null,
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
// 新增配置字段
@@ -133,7 +131,6 @@ data class CopyTradingDto(
// 过滤条件
val minOrderDepth: String?,
val maxSpread: String?,
val minOrderbookDepth: String?,
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
// 新增配置字段
@@ -22,7 +22,6 @@ data class TemplateCreateRequest(
// 过滤条件
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minOrderbookDepth: String? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
)
@@ -50,7 +49,6 @@ data class TemplateUpdateRequest(
// 过滤条件
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minOrderbookDepth: String? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
)
@@ -85,7 +83,6 @@ data class TemplateCopyRequest(
// 过滤条件
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minOrderbookDepth: String? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
)
@@ -120,7 +117,6 @@ data class TemplateDto(
// 过滤条件
val minOrderDepth: String?,
val maxSpread: String?,
val minOrderbookDepth: String?,
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
val createdAt: Long,
@@ -78,9 +78,6 @@ data class CopyTrading(
@Column(name = "max_spread", precision = 20, scale = 8)
val maxSpread: BigDecimal? = null, // 最大价差(绝对价格),NULL表示不启用
@Column(name = "min_orderbook_depth", precision = 20, scale = 8)
val minOrderbookDepth: BigDecimal? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
@Column(name = "min_price", precision = 20, scale = 8)
val minPrice: BigDecimal? = null, // 最低价格(可选),NULL表示不限制最低价
@@ -66,9 +66,6 @@ data class CopyTradingTemplate(
@Column(name = "max_spread", precision = 20, scale = 8)
val maxSpread: BigDecimal? = null, // 最大价差(绝对价格),NULL表示不启用
@Column(name = "min_orderbook_depth", precision = 20, scale = 8)
val minOrderbookDepth: BigDecimal? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
@Column(name = "min_price", precision = 20, scale = 8)
val minPrice: BigDecimal? = null, // 最低价格(可选),NULL表示不限制最低价
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.accounts
import com.wrbug.polymarketbot.api.TradeResponse
import com.wrbug.polymarketbot.dto.*
@@ -8,6 +8,14 @@ import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.eq
import com.wrbug.polymarketbot.util.JsonUtils
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.service.common.BlockchainService
import com.wrbug.polymarketbot.service.common.PolymarketApiKeyService
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
import com.wrbug.polymarketbot.service.system.RelayClientService
import com.wrbug.polymarketbot.util.CryptoUtils
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@@ -27,7 +35,7 @@ class AccountService(
private val apiKeyService: PolymarketApiKeyService,
private val orderPushService: OrderPushService,
private val orderSigningService: OrderSigningService,
private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils,
private val cryptoUtils: CryptoUtils,
private val telegramNotificationService: TelegramNotificationService? = null, // 可选,避免循环依赖
private val relayClientService: RelayClientService
) {
@@ -1070,7 +1078,7 @@ class AccountService(
/**
* 从订单表获取最优价用于市价单
* 支持多元市场二元三元及以上
* 委托给 PolymarketClobService.getOptimalPrice 方法
* 委托给 com.wrbug.polymarketbot.service.common.PolymarketClobService.getOptimalPrice 方法
*
* @param tokenId token ID通过 marketId outcomeIndex 计算得出
* @param isSellOrder 是否为卖出订单true: 卖单需要 bestBidfalse: 买单需要 bestAsk
@@ -1167,7 +1175,7 @@ class AccountService(
/**
* 获取可赎回仓位统计
*/
suspend fun getRedeemablePositionsSummary(accountId: Long? = null): Result<com.wrbug.polymarketbot.dto.RedeemablePositionsSummary> {
suspend fun getRedeemablePositionsSummary(accountId: Long? = null): Result<RedeemablePositionsSummary> {
return try {
val positionsResult = getAllPositions()
positionsResult.fold(
@@ -1223,7 +1231,7 @@ class AccountService(
* 赎回仓位
* 支持多账户多仓位赎回自动按账户和市场分组
*/
suspend fun redeemPositions(request: com.wrbug.polymarketbot.dto.PositionRedeemRequest): Result<com.wrbug.polymarketbot.dto.PositionRedeemResponse> {
suspend fun redeemPositions(request: PositionRedeemRequest): Result<PositionRedeemResponse> {
return try {
// 检查 Builder API Key 是否已配置
if (!relayClientService.isBuilderApiKeyConfigured()) {
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.accounts
import com.wrbug.polymarketbot.dto.AccountPositionDto
import com.wrbug.polymarketbot.entity.CopyOrderTracking
@@ -18,6 +18,11 @@ import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import org.springframework.context.MessageSource
import org.springframework.context.i18n.LocaleContextHolder
import com.wrbug.polymarketbot.service.system.SystemConfigService
import com.wrbug.polymarketbot.service.system.RelayClientService
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.JsonUtils
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
@@ -39,7 +44,8 @@ class PositionCheckService(
private val relayClientService: RelayClientService,
private val telegramNotificationService: TelegramNotificationService?,
private val accountRepository: AccountRepository,
private val messageSource: MessageSource
private val messageSource: MessageSource,
private val retrofitFactory: RetrofitFactory
) {
private val logger = LoggerFactory.getLogger(PositionCheckService::class.java)
@@ -186,7 +192,7 @@ class PositionCheckService(
/**
* 逻辑1处理待赎回仓位
* 按照以下逻辑处理
https://clob.polymarket.com * 按照以下逻辑处理
* 1. 无待赎回仓位跳过
* 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位发送通知事件
* 3. (已配置) && 有待赎回的仓位处理订单逻辑
@@ -366,17 +372,51 @@ class PositionCheckService(
val position = positionsByAccountAndMarket[positionKey]?.firstOrNull()
if (position == null) {
// 仓位不存在,更新所有订单状态为已卖出
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
updateOrdersAsSold(orders, currentPrice, copyTrading.id!!, marketId, outcomeIndex)
// 仓位不存在,检查订单创建时间
// 只有当订单创建时间超过2分钟时,才认为仓位被出售了
// 这样可以避免刚创建的订单因为API延迟而被误判为已卖出
val now = System.currentTimeMillis()
val ordersToMarkAsSold = orders.filter { order ->
val orderAge = now - order.createdAt
orderAge > 120000 // 2分钟 = 120000毫秒
}
if (ordersToMarkAsSold.isNotEmpty()) {
// 有订单创建时间超过2分钟,认为仓位已被出售
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
updateOrdersAsSold(ordersToMarkAsSold, currentPrice, copyTrading.id, marketId, outcomeIndex)
logger.debug("仓位不存在且订单创建时间超过2分钟,标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${ordersToMarkAsSold.size}")
} else {
// 订单创建时间不足2分钟,可能是刚创建的订单,暂时不处理
logger.debug("仓位不存在但订单创建时间不足2分钟,暂不标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${orders.size}, oldestOrderAge=${orders.minOfOrNull { now - it.createdAt }?.let { "${it}ms" } ?: "N/A"}")
}
} else {
// 有仓位,按订单下单顺序(FIFO)更新状态
// 如果仓位数量 >= 订单数量总和,所有订单完全成交
// 如果仓位数量 < 订单数量总和,按FIFO顺序部分成交
val totalUnmatchedQuantity = orders.sumOf { it.remainingQuantity.toSafeBigDecimal() }
// 计算逻辑:
// 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, copyTrading.id!!, marketId, outcomeIndex)
updateOrdersAsSoldByFIFO(orders, soldQuantity, currentPrice,
copyTrading.id, marketId, outcomeIndex)
}
}
}
@@ -388,9 +428,40 @@ class PositionCheckService(
/**
* 获取当前市场最新价用于更新订单卖出价
* 优先使用 bestBid最优买价如果没有则使用 midpoint中间价
* 如果市场已关闭
* - outcome 赢了返回 1
* - outcome 输了返回 0
*/
private suspend fun getCurrentMarketPrice(marketId: String, outcomeIndex: Int): BigDecimal {
return try {
// 先获取市场信息,检查市场是否已关闭
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(marketId))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
val markets = marketResponse.body()!!
val market = markets.firstOrNull()
if (market != null && market.closed == true) {
// 市场已关闭,检查该 outcome 是赢了还是输了
val outcomeResult = checkOutcomeResult(market, outcomeIndex)
when (outcomeResult) {
OutcomeResult.WON -> {
logger.info("市场已关闭且该 outcome 赢了,返回价格为 1: marketId=$marketId, outcomeIndex=$outcomeIndex")
return BigDecimal.ONE
}
OutcomeResult.LOST -> {
logger.info("市场已关闭且该 outcome 输了,返回价格为 0: marketId=$marketId, outcomeIndex=$outcomeIndex")
return BigDecimal.ZERO
}
OutcomeResult.UNKNOWN -> {
// 无法判断,继续使用正常价格逻辑
}
}
}
}
// 如果市场未关闭或无法判断输赢,获取正常价格
val priceResult = accountService.getMarketPrice(marketId, outcomeIndex)
val marketPrice = priceResult.getOrNull()
if (marketPrice != null) {
@@ -406,6 +477,71 @@ class PositionCheckService(
}
}
/**
* Outcome 结果枚举
*/
private enum class OutcomeResult {
WON, // 赢了
LOST, // 输了
UNKNOWN // 无法判断
}
/**
* 检查该 outcome 的结果赢了输了或无法判断
* @param market 市场信息
* @param outcomeIndex outcome 索引
* @return OutcomeResult
*/
private fun checkOutcomeResult(market: com.wrbug.polymarketbot.api.MarketResponse, outcomeIndex: Int): OutcomeResult {
return try {
// 优先使用 outcomePrices(结算价格数组)
val outcomePrices = market.outcomePrices
if (outcomePrices != null && outcomePrices.isNotBlank()) {
val prices = JsonUtils.parseStringArray(outcomePrices)
if (outcomeIndex < prices.size) {
val price = prices[outcomeIndex].toSafeBigDecimal()
// 如果价格 >= 0.99,认为赢了
if (price >= BigDecimal("0.99")) {
return OutcomeResult.WON
}
// 如果价格 <= 0.01,认为输了
if (price <= BigDecimal("0.01")) {
return OutcomeResult.LOST
}
// 其他情况,无法判断
return OutcomeResult.UNKNOWN
}
}
// 如果没有 outcomePrices,使用 bestBid 和 bestAsk 判断
val bestBid = market.bestBid ?: 0.0
val bestAsk = market.bestAsk ?: 0.0
// 如果目标 outcome 不是第一个(index != 0),需要转换价格
val targetBid = if (outcomeIndex > 0) {
// 第二个 outcome 的 bestBid = 1 - 第一个 outcome 的 bestAsk
BigDecimal.ONE.subtract(BigDecimal.valueOf(bestAsk))
} else {
BigDecimal.valueOf(bestBid)
}
// 如果 bestBid >= 0.99,认为赢了
if (targetBid >= BigDecimal("0.99")) {
return OutcomeResult.WON
}
// 如果 bestBid <= 0.01,认为输了
if (targetBid <= BigDecimal("0.01")) {
return OutcomeResult.LOST
}
// 其他情况,无法判断
OutcomeResult.UNKNOWN
} catch (e: Exception) {
logger.warn("检查 outcome 结果失败: marketId=${market.conditionId}, outcomeIndex=$outcomeIndex, error=${e.message}", e)
OutcomeResult.UNKNOWN
}
}
/**
* 在仓位赎回成功后更新订单状态为已卖出
* 使用卖出逻辑更新所有订单状态未卖出订单的
@@ -512,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<CopyOrderTracking>,
availableQuantity: BigDecimal,
soldQuantity: BigDecimal,
sellPrice: BigDecimal,
copyTradingId: Long,
marketId: String,
@@ -529,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<SellMatchDetail>()
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.accounts
import com.wrbug.polymarketbot.dto.PositionListResponse
import jakarta.annotation.PostConstruct
@@ -1,6 +1,7 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.accounts
import com.wrbug.polymarketbot.dto.AccountPositionDto
import com.wrbug.polymarketbot.dto.PositionListResponse
import com.wrbug.polymarketbot.dto.PositionPushMessage
import com.wrbug.polymarketbot.dto.PositionPushMessageType
import com.wrbug.polymarketbot.dto.getPositionKey
@@ -155,7 +156,7 @@ class PositionPushService(
* 处理仓位更新事件
* 根据文档要求每次轮训完成后向订阅者发送全量数据
*/
private fun handlePositionUpdate(positions: com.wrbug.polymarketbot.dto.PositionListResponse) {
private fun handlePositionUpdate(positions: PositionListResponse) {
// 更新快照
lastCurrentPositions = positions.currentPositions.associateBy { it.getPositionKey() }
lastHistoryPositions = positions.historyPositions.associateBy { it.getPositionKey() }
@@ -1,6 +1,7 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.announcement
import com.wrbug.polymarketbot.api.GitHubApi
import com.wrbug.polymarketbot.api.GitHubCommentResponse
import com.wrbug.polymarketbot.dto.AnnouncementDto
import com.wrbug.polymarketbot.dto.AnnouncementListResponse
import com.wrbug.polymarketbot.util.RetrofitFactory
@@ -25,30 +26,30 @@ class AnnouncementService(
@Value("\${github.announcement.issue.number:1}")
private val issueNumber: Int
) {
private val logger = LoggerFactory.getLogger(AnnouncementService::class.java)
// GitHub API 客户端(懒加载)
private val githubApi: GitHubApi by lazy {
retrofitFactory.createGitHubApi()
}
// 需要排除的 Issue ID(从 issue_url 中提取)
private val excludedIssueId = "3703128976"
// 缓存数据(1分钟有效期)
private data class CachedData<T>(
val data: T,
val timestamp: Long
)
private var cachedList: CachedData<AnnouncementListResponse>? = null
private var cachedAssignees: CachedData<List<String>>? = null
private var cachedComments: CachedData<List<com.wrbug.polymarketbot.api.GitHubCommentResponse>>? = null
// 缓存有效期:10分钟(毫秒)
private val cacheExpiryTime = 10 * 60 * 1000L
/**
* 检查缓存是否有效
*/
@@ -57,7 +58,7 @@ class AnnouncementService(
val now = System.currentTimeMillis()
return (now - cached.timestamp) < cacheExpiryTime
}
/**
* 检查是否被限流
*/
@@ -70,7 +71,7 @@ class AnnouncementService(
val remaining = response.headers()["X-RateLimit-Remaining"]
return remaining == "0"
}
/**
* 获取 Issue assignees 列表通过 API 获取带缓存
* @return Pair<assignees列表, 是否使用了缓存>
@@ -81,14 +82,14 @@ class AnnouncementService(
logger.debug("使用缓存的 assignees")
return Pair(cachedAssignees!!.data, true)
}
return try {
val response = githubApi.getIssue(
owner = repoOwner,
repo = repoName,
issueNumber = issueNumber
)
// 如果被限流,使用缓存数据,不更新缓存
if (isRateLimited(response)) {
logger.warn("GitHub API 被限流,使用缓存的 assignees(不更新缓存)")
@@ -98,14 +99,14 @@ class AnnouncementService(
// 如果没有缓存,使用默认值
return Pair(listOf("WrBug"), false)
}
val assignees = if (response.isSuccessful && response.body() != null) {
response.body()!!.assignees.map { it.login }
} else {
logger.warn("获取 Issue assignees 失败,使用默认值: code=${response.code()}")
listOf("WrBug") // 默认值
}
// 更新缓存
cachedAssignees = CachedData(assignees, System.currentTimeMillis())
Pair(assignees, false) // 返回新数据,标记为未使用缓存
@@ -119,24 +120,24 @@ class AnnouncementService(
Pair(listOf("WrBug"), false) // 默认值
}
}
/**
* 获取 Issue 评论列表带缓存
* @return Pair<评论列表, 是否使用了缓存>
*/
private suspend fun getIssueComments(forceRefresh: Boolean = false): Pair<List<com.wrbug.polymarketbot.api.GitHubCommentResponse>, Boolean> {
private suspend fun getIssueComments(forceRefresh: Boolean = false): Pair<List<GitHubCommentResponse>, Boolean> {
// 检查缓存
if (!forceRefresh && isCacheValid(cachedComments)) {
logger.debug("使用缓存的评论列表")
return Pair(cachedComments!!.data, true)
}
val response = githubApi.getIssueComments(
owner = repoOwner,
repo = repoName,
issueNumber = issueNumber
)
// 如果被限流,使用缓存数据,不更新缓存
if (isRateLimited(response)) {
logger.warn("GitHub API 被限流,使用缓存的评论列表(不更新缓存)")
@@ -146,7 +147,7 @@ class AnnouncementService(
// 如果没有缓存,抛出异常
throw Exception("获取公告列表失败: GitHub API 被限流,且无缓存数据")
}
if (!response.isSuccessful || response.body() == null) {
logger.error("获取 GitHub Issue 评论失败: code=${response.code()}, message=${response.message()}")
// 如果缓存存在,使用缓存
@@ -156,14 +157,14 @@ class AnnouncementService(
}
throw Exception("获取公告列表失败: HTTP ${response.code()}")
}
val comments = response.body()!!
// 更新缓存
cachedComments = CachedData(comments, System.currentTimeMillis())
return Pair(comments, false) // 返回新数据,标记为未使用缓存
}
/**
* 获取公告列表最近10条
* @param forceRefresh 是否强制刷新缓存
@@ -174,12 +175,12 @@ class AnnouncementService(
logger.debug("使用缓存的公告列表")
return Result.success(cachedList!!.data)
}
return try {
// 强制刷新时,先尝试获取新数据
val (assigneeList, assigneesFromCache) = getAssignees(forceRefresh)
val (comments, commentsFromCache) = getIssueComments(forceRefresh)
// 如果强制刷新时使用了缓存(被限流),直接返回缓存数据,不更新缓存
if (forceRefresh && (assigneesFromCache || commentsFromCache)) {
logger.warn("强制刷新时被限流,返回缓存的公告列表(不更新缓存)")
@@ -187,48 +188,47 @@ class AnnouncementService(
return Result.success(cachedList!!.data)
}
}
// 筛选条件:
// 1. assignees 发布的评论
// 2. 排除 issueNumber 为 3703128976 的评论(从 issue_url 中提取)
val filteredComments = comments
.filter { comment ->
// 检查是否为 assignee
val isAssignee = assigneeList.contains(comment.user.login)
// 检查是否应该排除(从 issue_url 中提取 issue ID
val shouldExclude = comment.issue_url?.let { issueUrl ->
// issue_url 格式:https://api.github.com/repos/owner/repo/issues/3703128976
// 提取最后的数字
val issueId = issueUrl.split("/").lastOrNull()
issueId == excludedIssueId
} ?: false
isAssignee && !shouldExclude
}
val filteredComments = comments.filter { comment ->
// 检查是否为 assignee
val isAssignee = assigneeList.contains(comment.user.login)
// 检查是否应该排除(从 issue_url 中提取 issue ID
val shouldExclude = comment.issue_url?.let { issueUrl ->
// issue_url 格式:https://api.github.com/repos/owner/repo/issues/3703128976
// 提取最后的数字
val issueId = issueUrl.split("/").lastOrNull()
issueId == excludedIssueId
} ?: false
isAssignee && !shouldExclude
}
.sortedByDescending { comment ->
parseGitHubTime(comment.created_at)
}
val total = filteredComments.size
val hasMore = total > 10
// 取前10条
val latest10 = filteredComments.take(10).map { comment ->
toAnnouncementDto(comment)
}
val result = AnnouncementListResponse(
list = latest10,
hasMore = hasMore,
total = total
)
// 只有在数据正常返回时才更新缓存(不是从缓存获取的)
if (!assigneesFromCache && !commentsFromCache) {
cachedList = CachedData(result, System.currentTimeMillis())
}
Result.success(result)
} catch (e: Exception) {
logger.error("获取公告列表异常: ${e.message}", e)
@@ -240,7 +240,7 @@ class AnnouncementService(
Result.failure(e)
}
}
/**
* 获取公告详情
* @param id 评论ID如果为 null 则返回最新一条
@@ -249,19 +249,18 @@ class AnnouncementService(
suspend fun getAnnouncementDetail(id: Long?, forceRefresh: Boolean = false): Result<AnnouncementDto> {
return try {
// 获取 assignees
val (assigneeList, assigneesFromCache) = getAssignees(forceRefresh)
val (assigneeList, _) = getAssignees(forceRefresh)
// 获取评论列表
val (comments, commentsFromCache) = getIssueComments(forceRefresh)
val (comments, _) = getIssueComments(forceRefresh)
// 筛选条件:
// 1. assignees 发布的评论
// 2. 排除 issueNumber 为 3703128976 的评论(从 issue_url 中提取)
val filteredComments = comments
.filter { comment ->
val filteredComments = comments.filter { comment ->
// 检查是否为 assignee
val isAssignee = assigneeList.contains(comment.user.login)
// 检查是否应该排除(从 issue_url 中提取 issue ID
val shouldExclude = comment.issue_url?.let { issueUrl ->
// issue_url 格式:https://api.github.com/repos/owner/repo/issues/3703128976
@@ -269,37 +268,37 @@ class AnnouncementService(
val issueId = issueUrl.split("/").lastOrNull()
issueId == excludedIssueId
} ?: false
isAssignee && !shouldExclude
}
.sortedByDescending { comment ->
parseGitHubTime(comment.created_at)
}
val targetComment = if (id != null) {
filteredComments.find { it.id == id }
} else {
filteredComments.firstOrNull()
}
if (targetComment == null) {
return Result.failure(IllegalArgumentException("公告不存在"))
}
Result.success(toAnnouncementDto(targetComment))
} catch (e: Exception) {
logger.error("获取公告详情异常: ${e.message}", e)
Result.failure(e)
}
}
/**
* GitHub 评论转换为 AnnouncementDto
*/
private fun toAnnouncementDto(comment: com.wrbug.polymarketbot.api.GitHubCommentResponse): AnnouncementDto {
private fun toAnnouncementDto(comment: GitHubCommentResponse): AnnouncementDto {
// 提取标题(第一行,移除 Markdown 格式)
val title = extractTitle(comment.body)
// 转换 reactions 数据
val reactions = comment.reactions?.let { r ->
com.wrbug.polymarketbot.dto.ReactionsDto(
@@ -314,7 +313,7 @@ class AnnouncementService(
total = r.total_count
)
}
return AnnouncementDto(
id = comment.id,
title = title,
@@ -326,7 +325,7 @@ class AnnouncementService(
reactions = reactions
)
}
/**
* 从评论内容中提取标题第一行移除 Markdown 格式
* 支持的 Markdown 格式
@@ -342,49 +341,49 @@ class AnnouncementService(
if (body.isBlank()) {
return ""
}
// 获取第一行
val firstLine = body.lines().firstOrNull()?.trim() ?: ""
if (firstLine.isBlank()) {
return ""
}
// 移除 Markdown 格式
var title = firstLine
// 移除标题标记(# ## ### 等)
title = title.replace(Regex("^#{1,6}\\s+"), "")
// 移除粗体标记(**text** 或 __text__
title = title.replace(Regex("\\*\\*([^*]+)\\*\\*"), "$1")
title = title.replace(Regex("__([^_]+)__"), "$1")
// 移除斜体标记(*text* 或 _text_
title = title.replace(Regex("(?<!\\*)\\*([^*]+)\\*(?!\\*)"), "$1")
title = title.replace(Regex("(?<!_)_([^_]+)_(?!_)"), "$1")
// 移除代码标记(`code`
title = title.replace(Regex("`([^`]+)`"), "$1")
// 移除链接标记([text](url)
title = title.replace(Regex("\\[([^\\]]+)\\]\\([^\\)]+\\)"), "$1")
// 移除图片标记(![alt](url)
title = title.replace(Regex("!\\[([^\\]]*)\\]\\([^\\)]+\\)"), "$1")
// 移除删除线标记(~~text~~
title = title.replace(Regex("~~([^~]+)~~"), "$1")
// 移除引用标记(> text
title = title.replace(Regex("^>\\s+"), "")
// 移除列表标记(- * + 1. 等)
title = title.replace(Regex("^[-*+]\\s+"), "")
title = title.replace(Regex("^\\d+\\.\\s+"), "")
return title.trim()
}
/**
* 解析 GitHub 时间格式ISO 8601为时间戳毫秒
* GitHub API 返回的时间格式2025-12-07T14:30:00Z
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.auth
import com.wrbug.polymarketbot.dto.CheckFirstUseResponse
import com.wrbug.polymarketbot.dto.LoginResponse
@@ -10,6 +10,7 @@ import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import com.wrbug.polymarketbot.service.common.RateLimitService
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.common
import com.wrbug.polymarketbot.api.EthereumRpcApi
import com.wrbug.polymarketbot.api.JsonRpcRequest
@@ -11,6 +11,7 @@ import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import com.wrbug.polymarketbot.service.system.RelayClientService
import org.springframework.stereotype.Service
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.common
import com.wrbug.polymarketbot.api.ApiKeyResponse
import com.wrbug.polymarketbot.api.PolymarketClobApi
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.common
import com.wrbug.polymarketbot.api.*
import com.wrbug.polymarketbot.util.RetrofitFactory
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.common
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
@@ -1,9 +1,11 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.common
import com.wrbug.polymarketbot.dto.OrderPushMessage
import com.wrbug.polymarketbot.dto.PositionPushMessage
import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage
import com.wrbug.polymarketbot.dto.WebSocketMessageType
import com.wrbug.polymarketbot.service.accounts.PositionPushService
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.copytrading.configs
import com.wrbug.polymarketbot.api.OrderbookResponse
import com.wrbug.polymarketbot.entity.CopyTrading
@@ -7,6 +7,7 @@ import com.wrbug.polymarketbot.util.lt
import com.wrbug.polymarketbot.util.multi
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import org.springframework.stereotype.Service
import java.math.BigDecimal
@@ -24,85 +25,86 @@ class CopyTradingFilterService(
* 检查过滤条件
* @param copyTrading 跟单配置
* @param tokenId token ID用于获取订单簿
* @param isBuyOrder 是否为买入订单true=买入false=卖出
* @return Pair<是否通过, 失败原因>
* @param tradePrice Leader 交易价格用于价格区间检查
* @return 过滤结果
*/
suspend fun checkFilters(
copyTrading: CopyTrading,
tokenId: String,
isBuyOrder: Boolean,
tradePrice: BigDecimal? = null // Leader 交易价格,用于价格区间检查
): Pair<Boolean, String> {
): FilterResult {
// 1. 价格区间检查(如果配置了价格区间)
if (tradePrice != null) {
val priceRangeCheck = checkPriceRange(copyTrading, tradePrice)
if (!priceRangeCheck.first) {
return priceRangeCheck
if (!priceRangeCheck.isPassed) {
return FilterResult.priceRangeFailed(priceRangeCheck.reason)
}
}
// 2. 价格合理性检查(基础检查,无需配置)
// 这个检查在获取订单簿时进行,如果价格不在 0.01-0.99 范围内,订单簿获取会失败
// 2. 检查是否需要获取订单簿
// 只有在配置了需要订单簿的过滤条件时才获取
val needOrderbook = copyTrading.maxSpread != null || copyTrading.minOrderDepth != null
// 3. 获取订单簿
if (!needOrderbook) {
// 不需要订单簿,直接通过
return FilterResult.passed()
}
// 3. 获取订单簿(仅在需要时,只请求一次)
val orderbookResult = clobService.getOrderbookByTokenId(tokenId)
if (!orderbookResult.isSuccess) {
val error = orderbookResult.exceptionOrNull()
return Pair(false, "获取订单簿失败: ${error?.message ?: "未知错误"}")
return FilterResult.orderbookError("获取订单簿失败: ${error?.message ?: "未知错误"}")
}
val orderbook = orderbookResult.getOrNull()
if (orderbook == null) {
return Pair(false, "订单簿为空")
?: return FilterResult.orderbookEmpty()
// 4. 买一卖一价差过滤(如果配置了)
if (copyTrading.maxSpread != null) {
val spreadCheck = checkSpread(copyTrading, orderbook)
if (!spreadCheck.isPassed) {
return FilterResult.spreadFailed(spreadCheck.reason, orderbook)
}
}
// 4. 买一卖一价差过滤
val spreadCheck = checkSpread(copyTrading, orderbook)
if (!spreadCheck.first) {
return spreadCheck
// 5. 订单深度过滤(如果配置了,检查所有方向)
if (copyTrading.minOrderDepth != null) {
val depthCheck = checkOrderDepth(copyTrading, orderbook)
if (!depthCheck.isPassed) {
return FilterResult.orderDepthFailed(depthCheck.reason, orderbook)
}
}
// 5. 订单深度过滤
val depthCheck = checkOrderDepth(copyTrading, orderbook, isBuyOrder)
if (!depthCheck.first) {
return depthCheck
}
// 6. 最小订单簿深度过滤(可选)
val orderbookDepthCheck = checkOrderbookDepth(copyTrading, orderbook, isBuyOrder)
if (!orderbookDepthCheck.first) {
return orderbookDepthCheck
}
return Pair(true, "")
return FilterResult.passed(orderbook)
}
/**
* 检查价格区间
* @param copyTrading 跟单配置
* @param tradePrice Leader 交易价格
* @return Pair<是否通过, 失败原因>
* @return 过滤结果
*/
private fun checkPriceRange(
copyTrading: CopyTrading,
tradePrice: BigDecimal
): Pair<Boolean, String> {
): FilterResult {
// 如果未配置价格区间,直接通过
if (copyTrading.minPrice == null && copyTrading.maxPrice == null) {
return Pair(true, "")
return FilterResult.passed()
}
// 检查最低价格
if (copyTrading.minPrice != null && tradePrice.lt(copyTrading.minPrice)) {
return Pair(false, "价格低于最低限制: $tradePrice < ${copyTrading.minPrice}")
return FilterResult.priceRangeFailed("价格低于最低限制: $tradePrice < ${copyTrading.minPrice}")
}
// 检查最高价格
if (copyTrading.maxPrice != null && tradePrice.gt(copyTrading.maxPrice)) {
return Pair(false, "价格高于最高限制: $tradePrice > ${copyTrading.maxPrice}")
return FilterResult.priceRangeFailed("价格高于最高限制: $tradePrice > ${copyTrading.maxPrice}")
}
return Pair(true, "")
return FilterResult.passed()
}
/**
@@ -113,10 +115,10 @@ class CopyTradingFilterService(
private fun checkSpread(
copyTrading: CopyTrading,
orderbook: OrderbookResponse
): Pair<Boolean, String> {
): FilterResult {
// 如果未启用价差过滤,直接通过
if (copyTrading.maxSpread == null) {
return Pair(true, "")
return FilterResult.passed()
}
// 获取买盘中的最高价格(bestBid = bids 中的最大值)
@@ -130,84 +132,57 @@ class CopyTradingFilterService(
.minOrNull()
if (bestBid == null || bestAsk == null) {
return Pair(false, "订单簿缺少买一或卖一价格")
return FilterResult.spreadFailed("订单簿缺少买一或卖一价格", orderbook)
}
// 计算价差(绝对价格)
val spread = bestAsk.subtract(bestBid)
if (spread.gt(copyTrading.maxSpread)) {
return Pair(false, "价差过大: $spread > ${copyTrading.maxSpread}")
return FilterResult.spreadFailed("价差过大: $spread > ${copyTrading.maxSpread}", orderbook)
}
return Pair(true, "")
return FilterResult.passed()
}
/**
* 检查订单深度
* 检查订单深度检查所有方向买盘和卖盘的总深度
*/
private fun checkOrderDepth(
copyTrading: CopyTrading,
orderbook: OrderbookResponse,
isBuyOrder: Boolean
): Pair<Boolean, String> {
orderbook: OrderbookResponse
): FilterResult {
// 如果未启用订单深度过滤,直接通过
if (copyTrading.minOrderDepth == null) {
return Pair(true, "")
return FilterResult.passed()
}
// 对于买入订单,检查卖盘(asks)深度
// 对于卖出订单,检查买盘(bids)深度
val orders = if (isBuyOrder) orderbook.asks else orderbook.bids
// 计算总深度(累计订单金额)
var totalDepth = BigDecimal.ZERO
for (order in orders) {
// 计算买盘(bids深度
var bidsDepth = BigDecimal.ZERO
for (order in orderbook.bids) {
val price = order.price.toSafeBigDecimal()
val size = order.size.toSafeBigDecimal()
val orderAmount = price.multi(size)
totalDepth = totalDepth.add(orderAmount)
bidsDepth = bidsDepth.add(orderAmount)
}
// 计算卖盘(asks)总深度
var asksDepth = BigDecimal.ZERO
for (order in orderbook.asks) {
val price = order.price.toSafeBigDecimal()
val size = order.size.toSafeBigDecimal()
val orderAmount = price.multi(size)
asksDepth = asksDepth.add(orderAmount)
}
// 计算总深度(买盘 + 卖盘)
val totalDepth = bidsDepth.add(asksDepth)
if (totalDepth.lt(copyTrading.minOrderDepth)) {
return Pair(false, "订单深度不足: $totalDepth < ${copyTrading.minOrderDepth}")
return FilterResult.orderDepthFailed("订单深度不足: $totalDepth < ${copyTrading.minOrderDepth}", orderbook)
}
return Pair(true, "")
}
/**
* 检查最小订单簿深度 N 档深度
*/
private fun checkOrderbookDepth(
copyTrading: CopyTrading,
orderbook: OrderbookResponse,
isBuyOrder: Boolean
): Pair<Boolean, String> {
// 如果未启用最小订单簿深度过滤,直接通过
if (copyTrading.minOrderbookDepth == null) {
return Pair(true, "")
}
// 对于买入订单,检查卖盘(asks)前 3 档深度
// 对于卖出订单,检查买盘(bids)前 3 档深度
val orders = if (isBuyOrder) orderbook.asks else orderbook.bids
val topNOrders = orders.take(3) // 前 3 档
// 计算前 N 档总深度
var totalDepth = BigDecimal.ZERO
for (order in topNOrders) {
val price = order.price.toSafeBigDecimal()
val size = order.size.toSafeBigDecimal()
val orderAmount = price.multi(size)
totalDepth = totalDepth.add(orderAmount)
}
if (totalDepth.lt(copyTrading.minOrderbookDepth)) {
return Pair(false, "订单簿深度不足: $totalDepth < ${copyTrading.minOrderbookDepth}")
}
return Pair(true, "")
return FilterResult.passed()
}
}
@@ -1,11 +1,14 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.copytrading.configs
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.Account
import com.wrbug.polymarketbot.entity.CopyTrading
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingMonitorService
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@@ -82,7 +85,6 @@ class CopyTradingService(
supportSell = request.supportSell ?: template.supportSell,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: template.minOrderDepth,
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread,
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: template.minOrderbookDepth,
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice
)
@@ -109,7 +111,6 @@ class CopyTradingService(
supportSell = request.supportSell ?: true,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(),
maxSpread = request.maxSpread?.toSafeBigDecimal(),
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal(),
minPrice = request.minPrice?.toSafeBigDecimal(),
maxPrice = request.maxPrice?.toSafeBigDecimal()
)
@@ -136,7 +137,6 @@ class CopyTradingService(
supportSell = config.supportSell,
minOrderDepth = config.minOrderDepth,
maxSpread = config.maxSpread,
minOrderbookDepth = config.minOrderbookDepth,
minPrice = config.minPrice,
maxPrice = config.maxPrice,
configName = configName,
@@ -202,7 +202,6 @@ class CopyTradingService(
supportSell = request.supportSell ?: copyTrading.supportSell,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: copyTrading.minOrderDepth,
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: copyTrading.maxSpread,
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: copyTrading.minOrderbookDepth,
minPrice = request.minPrice?.toSafeBigDecimal() ?: copyTrading.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: copyTrading.maxPrice,
configName = configName,
@@ -339,7 +338,7 @@ class CopyTradingService(
fun getAccountTemplates(accountId: Long): Result<AccountTemplatesResponse> {
return try {
// 验证账户是否存在
val account = accountRepository.findById(accountId).orElse(null)
accountRepository.findById(accountId).orElse(null)
?: return Result.failure(IllegalArgumentException("账户不存在"))
val copyTradings = copyTradingRepository.findByAccountId(accountId)
@@ -380,8 +379,8 @@ class CopyTradingService(
*/
private fun toDto(
copyTrading: CopyTrading,
account: com.wrbug.polymarketbot.entity.Account,
leader: com.wrbug.polymarketbot.entity.Leader
account: Account,
leader: Leader
): CopyTradingDto {
return CopyTradingDto(
id = copyTrading.id!!,
@@ -408,7 +407,6 @@ class CopyTradingService(
supportSell = copyTrading.supportSell,
minOrderDepth = copyTrading.minOrderDepth?.toPlainString(),
maxSpread = copyTrading.maxSpread?.toPlainString(),
minOrderbookDepth = copyTrading.minOrderbookDepth?.toPlainString(),
minPrice = copyTrading.minPrice?.toPlainString(),
maxPrice = copyTrading.maxPrice?.toPlainString(),
configName = copyTrading.configName,
@@ -438,7 +436,6 @@ class CopyTradingService(
val supportSell: Boolean,
val minOrderDepth: BigDecimal?,
val maxSpread: BigDecimal?,
val minOrderbookDepth: BigDecimal?,
val minPrice: BigDecimal?,
val maxPrice: BigDecimal?
)
@@ -0,0 +1,78 @@
package com.wrbug.polymarketbot.service.copytrading.configs
import com.wrbug.polymarketbot.api.OrderbookResponse
/**
* 过滤结果状态枚举
*/
enum class FilterStatus {
/** 通过 */
PASSED,
/** 失败:价格区间 */
FAILED_PRICE_RANGE,
/** 失败:订单簿获取失败 */
FAILED_ORDERBOOK_ERROR,
/** 失败:订单簿为空 */
FAILED_ORDERBOOK_EMPTY,
/** 失败:价差过大 */
FAILED_SPREAD,
/** 失败:订单深度不足 */
FAILED_ORDER_DEPTH
}
/**
* 过滤结果
*/
data class FilterResult(
/** 过滤状态 */
val status: FilterStatus,
/** 失败原因(仅在失败时有效) */
val reason: String = "",
/** 订单簿(仅在需要时返回) */
val orderbook: OrderbookResponse? = null
) {
/** 是否通过 */
val isPassed: Boolean
get() = status == FilterStatus.PASSED
companion object {
/** 通过 */
fun passed(orderbook: OrderbookResponse? = null) = FilterResult(
status = FilterStatus.PASSED,
orderbook = orderbook
)
/** 价格区间失败 */
fun priceRangeFailed(reason: String) = FilterResult(
status = FilterStatus.FAILED_PRICE_RANGE,
reason = reason
)
/** 订单簿获取失败 */
fun orderbookError(reason: String) = FilterResult(
status = FilterStatus.FAILED_ORDERBOOK_ERROR,
reason = reason
)
/** 订单簿为空 */
fun orderbookEmpty() = FilterResult(
status = FilterStatus.FAILED_ORDERBOOK_EMPTY,
reason = "订单簿为空"
)
/** 价差过大 */
fun spreadFailed(reason: String, orderbook: OrderbookResponse) = FilterResult(
status = FilterStatus.FAILED_SPREAD,
reason = reason,
orderbook = orderbook
)
/** 订单深度不足 */
fun orderDepthFailed(reason: String, orderbook: OrderbookResponse) = FilterResult(
status = FilterStatus.FAILED_ORDER_DEPTH,
reason = reason,
orderbook = orderbook
)
}
}
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.copytrading.configs
import com.wrbug.polymarketbot.dto.FilteredOrderDto
import com.wrbug.polymarketbot.dto.FilteredOrderListRequest
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.copytrading.leaders
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.Leader
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.wrbug.polymarketbot.entity.CopyTrading
import com.wrbug.polymarketbot.entity.Leader
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.wrbug.polymarketbot.api.TradeResponse
import com.wrbug.polymarketbot.api.UserActivityResponse
@@ -9,6 +9,7 @@ import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
import org.springframework.stereotype.Service
import retrofit2.Response
import java.util.concurrent.ConcurrentHashMap
@@ -72,7 +73,7 @@ class CopyTradingPollingService(
return
}
val leaderId = leader.id!!
val leaderId = leader.id
monitoredLeaders[leaderId] = leader
// 初始化缓存的交易ID集合
cachedTradeIds[leaderId] = mutableSetOf()
@@ -178,7 +179,7 @@ class CopyTradingPollingService(
return
}
val leaderId = leader.id!!
val leaderId = leader.id
val leaderAddress = leader.leaderAddress
try {
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.google.gson.Gson
import com.google.gson.JsonObject
@@ -11,6 +11,7 @@ import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
@@ -65,7 +66,7 @@ class CopyTradingWebSocketService(
return
}
val leaderId = leader.id!!
val leaderId = leader.id
val leaderAddress = leader.leaderAddress.lowercase()
leaderAddresses[leaderId] = leaderAddress
@@ -1,6 +1,7 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.copytrading.orders
import com.fasterxml.jackson.databind.ObjectMapper
import com.wrbug.polymarketbot.api.MarketResponse
import com.wrbug.polymarketbot.dto.OrderDetailDto
import com.wrbug.polymarketbot.dto.OrderMessageDto
import com.wrbug.polymarketbot.dto.OrderPushMessage
@@ -13,6 +14,11 @@ import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.util.CryptoUtils
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
@@ -26,10 +32,10 @@ class OrderPushService(
private val objectMapper: ObjectMapper,
private val clobService: PolymarketClobService,
private val retrofitFactory: RetrofitFactory, // 用于创建 Gamma API 客户端(不需要认证)
private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils,
private val copyOrderTrackingRepository: com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository? = null, // 可选,避免循环依赖
private val copyTradingRepository: com.wrbug.polymarketbot.repository.CopyTradingRepository? = null, // 可选,避免循环依赖
private val leaderRepository: com.wrbug.polymarketbot.repository.LeaderRepository? = null // 可选,避免循环依赖
private val cryptoUtils: CryptoUtils,
private val copyOrderTrackingRepository: CopyOrderTrackingRepository? = null, // 可选,避免循环依赖
private val copyTradingRepository: CopyTradingRepository? = null, // 可选,避免循环依赖
private val leaderRepository: LeaderRepository? = null // 可选,避免循环依赖
) {
private val logger = LoggerFactory.getLogger(OrderPushService::class.java)
@@ -152,9 +158,9 @@ class OrderPushService(
return account.apiKey != null &&
account.apiSecret != null &&
account.apiPassphrase != null &&
account.apiKey!!.isNotBlank() &&
account.apiSecret!!.isNotBlank() &&
account.apiPassphrase!!.isNotBlank()
account.apiKey.isNotBlank() &&
account.apiSecret.isNotBlank() &&
account.apiPassphrase.isNotBlank()
}
/**
@@ -378,7 +384,7 @@ class OrderPushService(
/**
* 获取订单详情
* 通过 PolymarketClobService 获取订单详情
* 通过 com.wrbug.polymarketbot.service.common.PolymarketClobService 获取订单详情
*/
private suspend fun fetchOrderDetail(
account: Account,
@@ -391,7 +397,7 @@ class OrderPushService(
return null
}
// 通过 PolymarketClobService 获取订单详情(需要 L2 认证)
// 通过 com.wrbug.polymarketbot.service.common.PolymarketClobService 获取订单详情(需要 L2 认证)
// 解密 API 凭证
val apiSecret = try {
decryptApiSecret(account)
@@ -453,7 +459,7 @@ class OrderPushService(
* 使用 /markets 接口通过 condition_ids 查询参数获取市场信息
* 订单返回的 market 字段是 16 进制的 condition ID "0x..."
*/
private suspend fun fetchMarketInfo(conditionId: String): com.wrbug.polymarketbot.api.MarketResponse? {
private suspend fun fetchMarketInfo(conditionId: String): MarketResponse? {
return try {
// 创建 Gamma API 客户端(公开 API,不需要认证)
val gammaApi = retrofitFactory.createGammaApi()
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.copytrading.orders
import com.wrbug.polymarketbot.api.SignedOrderObject
import com.wrbug.polymarketbot.util.toSafeBigDecimal
@@ -77,10 +77,6 @@ class OrderSigningService {
): OrderAmounts {
val sizeDecimal = size.toSafeBigDecimal()
val priceDecimal = price.toSafeBigDecimal()
// 舍入价格
val roundedPrice = roundNormal(priceDecimal, roundConfig.price)
if (side.uppercase() == "BUY") {
// BUY: makerAmount = price * size (USDC), takerAmount = size (shares)
// makerAmount 是 USDC 金额,最多 2 位小数
@@ -1,6 +1,7 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.copytrading.statistics
import com.wrbug.polymarketbot.api.NewOrderRequest
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.api.TradeResponse
import com.wrbug.polymarketbot.entity.*
import com.wrbug.polymarketbot.repository.*
@@ -9,6 +10,15 @@ import com.wrbug.polymarketbot.util.*
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.dao.DuplicateKeyException
import java.sql.SQLException
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingFilterService
import com.wrbug.polymarketbot.service.copytrading.configs.FilterStatus
import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService
import com.wrbug.polymarketbot.service.common.BlockchainService
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
import com.wrbug.polymarketbot.util.CryptoUtils
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
@@ -19,7 +29,7 @@ import java.math.BigDecimal
* 实际创建订单并记录跟踪信息
*/
@Service
class CopyOrderTrackingService(
open class CopyOrderTrackingService(
private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
private val sellMatchRecordRepository: SellMatchRecordRepository,
private val sellMatchDetailRepository: SellMatchDetailRepository,
@@ -32,8 +42,9 @@ class CopyOrderTrackingService(
private val leaderRepository: LeaderRepository,
private val orderSigningService: OrderSigningService,
private val blockchainService: BlockchainService,
private val clobService: PolymarketClobService,
private val retrofitFactory: RetrofitFactory,
private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils,
private val cryptoUtils: CryptoUtils,
private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖
) {
@@ -41,6 +52,12 @@ class CopyOrderTrackingService(
// 协程作用域(用于异步发送通知)
private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// 订单创建重试配置
companion object {
private const val MAX_RETRY_ATTEMPTS = 2 // 最多重试次数(首次 + 1次重试)
private const val RETRY_DELAY_MS = 3000L // 重试前等待时间(毫秒,3秒)
}
/**
* 解密账户私钥
@@ -135,21 +152,38 @@ class CopyOrderTrackingService(
processedAt = System.currentTimeMillis()
)
processedTradeRepository.save(processed)
} catch (e: DataIntegrityViolationException) {
// 唯一约束冲突,说明已经处理过了(可能是并发请求
// 再次检查确认状态
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existing != null) {
if (existing.status == "FAILED") {
} catch (e: Exception) {
// 检查是否是唯一键冲突异常(可能是 DataIntegrityViolationException、DuplicateKeyException 或 SQLException
if (isUniqueConstraintViolation(e)) {
// 唯一约束冲突,说明已经处理过了(可能是并发请求)
// 再次检查确认状态
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existing != null) {
if (existing.status == "FAILED") {
logger.debug("交易已标记为失败,跳过处理: leaderId=$leaderId, tradeId=${trade.id}")
return Result.success(Unit)
}
logger.debug("交易已处理(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existing.status}")
return Result.success(Unit)
} else {
// 如果检查不到,可能是事务隔离级别问题,等待一下再查询
delay(100)
val existingAfterDelay =
processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existingAfterDelay != null) {
logger.debug("延迟查询到记录(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existingAfterDelay.status}")
return Result.success(Unit)
}
// 如果还是查询不到,记录警告但不抛出异常(可能是其他约束冲突)
logger.warn(
"保存ProcessedTrade时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
e
)
// 不抛出异常,避免影响其他交易的处理
return Result.success(Unit)
}
return Result.success(Unit)
} else {
// 如果检查不到,说明可能是其他约束冲突,重新抛出异常
logger.warn(
"保存ProcessedTrade时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
e
)
// 其他类型的异常,重新抛出
throw e
}
}
@@ -209,10 +243,12 @@ class CopyOrderTrackingService(
// 过滤条件检查(在计算订单参数之前)
// 传入 Leader 交易价格,用于价格区间检查
// 订单簿只请求一次,返回给后续逻辑使用
val tradePrice = trade.price.toSafeBigDecimal()
val filterCheck = filterService.checkFilters(copyTrading, tokenId, isBuyOrder = true, tradePrice = tradePrice)
if (!filterCheck.first) {
logger.warn("过滤条件检查失败,跳过创建订单: copyTradingId=${copyTrading.id}, reason=${filterCheck.second}")
val filterResult = filterService.checkFilters(copyTrading, tokenId, tradePrice = tradePrice)
val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
if (!filterResult.isPassed) {
logger.warn("过滤条件检查失败,跳过创建订单: copyTradingId=${copyTrading.id}, reason=${filterResult.reason}")
// 记录被过滤的订单并发送通知(异步,不阻塞)
notificationScope.launch {
@@ -236,8 +272,8 @@ class CopyOrderTrackingService(
val marketTitle = marketInfo?.question ?: trade.market
val marketSlug = marketInfo?.slug
// 从 filterReason 中提取 filterType
val filterType = extractFilterType(filterCheck.second)
// 从过滤结果中提取 filterType
val filterType = extractFilterType(filterResult.status, filterResult.reason)
// 计算买入数量(用于记录,即使被过滤也记录)
val calculatedQuantity = try {
@@ -262,7 +298,7 @@ class CopyOrderTrackingService(
price = trade.price.toSafeBigDecimal(),
size = trade.size.toSafeBigDecimal(),
calculatedQuantity = calculatedQuantity,
filterReason = filterCheck.second,
filterReason = filterResult.reason,
filterType = filterType
)
@@ -288,7 +324,7 @@ class CopyOrderTrackingService(
outcome = trade.outcome,
price = trade.price,
size = trade.size,
filterReason = filterCheck.second,
filterReason = filterResult.reason,
filterType = filterType,
accountName = account.accountName,
walletAddress = account.walletAddress,
@@ -347,6 +383,35 @@ class CopyOrderTrackingService(
// 计算价格(应用价格容忍度)
val buyPrice = calculateAdjustedPrice(trade.price.toSafeBigDecimal(), copyTrading, isBuy = true)
// 在创建订单前,检查订单簿中是否有可匹配的订单(避免 FAK 订单失败)
// 如果过滤检查时已经获取了订单簿,直接使用;否则重新获取
val orderbookForCheck = orderbook ?: run {
val orderbookResult = clobService.getOrderbookByTokenId(tokenId)
if (orderbookResult.isSuccess) {
orderbookResult.getOrNull()
} else {
null
}
}
// 检查是否有可匹配的卖单(asks)
if (orderbookForCheck != null) {
val bestAsk = orderbookForCheck.asks
.mapNotNull { it.price.toSafeBigDecimal() }
.minOrNull()
if (bestAsk == null) {
logger.warn("订单簿中没有卖单,跳过创建订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}")
continue
}
// 如果调整后的买入价格低于最佳卖单价格,无法匹配
if (buyPrice.lt(bestAsk)) {
logger.warn("调整后的买入价格 ($buyPrice) 低于最佳卖单价格 ($bestAsk),无法匹配,跳过创建订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}")
continue
}
}
// 解密 API 凭证
val apiSecret = try {
decryptApiSecret(account)
@@ -372,7 +437,9 @@ class CopyOrderTrackingService(
// 解密私钥
val decryptedPrivateKey = decryptPrivateKey(account)
// 调用API创建订单(带重试机制,重试时会重新生成salt并重新签名
// 调用API创建订单(带重试机制)
// 重试策略:最多重试 MAX_RETRY_ATTEMPTS 次,每次重试前等待 RETRY_DELAY_MS 毫秒
// 每次重试都会重新生成salt并重新签名,确保签名唯一性
val createOrderResult = createOrderWithRetry(
clobApi = clobApi,
privateKey = decryptedPrivateKey,
@@ -381,13 +448,14 @@ class CopyOrderTrackingService(
side = "BUY",
price = buyPrice.toString(),
size = finalBuyQuantity.toString(),
owner = account.apiKey!!,
owner = account.apiKey,
copyTradingId = copyTrading.id!!,
tradeId = trade.id
)
// 处理订单创建失败
if (createOrderResult.isFailure) {
// 创建订单失败,记录到失败表
// 提取错误信息(只保留 code 和 errorBody
val exception = createOrderResult.exceptionOrNull()
val errorMsg = buildFullErrorMessage(
exception,
@@ -396,16 +464,19 @@ class CopyOrderTrackingService(
finalBuyQuantity.toString(),
trade.id
)
// 记录失败交易到数据库
// retryCount = MAX_RETRY_ATTEMPTS - 1,表示已重试的次数
recordFailedTrade(
leaderId = leaderId,
trade = trade,
copyTradingId = copyTrading.id!!,
accountId = copyTrading.accountId,
side = "BUY", // 订单方向是BUY
side = "BUY",
price = buyPrice.toString(),
size = finalBuyQuantity.toString(),
errorMessage = errorMsg,
retryCount = 1 // 已重试
retryCount = MAX_RETRY_ATTEMPTS - 1 // 已重试次
)
// 发送订单失败通知(异步,不阻塞,仅在 pushFailedOrders 为 true 时发送)
@@ -416,7 +487,8 @@ class CopyOrderTrackingService(
val marketInfo = withContext(Dispatchers.IO) {
try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(trade.market))
val marketResponse =
gammaApi.listMarkets(conditionIds = listOf(trade.market))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
marketResponse.body()!!.firstOrNull()
} else {
@@ -533,7 +605,7 @@ class CopyOrderTrackingService(
} catch (e: Exception) {
java.util.Locale("zh", "CN") // 默认简体中文
}
// 获取 Leader 和跟单配置信息
val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null)
val leaderName = leader?.leaderName
@@ -637,8 +709,8 @@ class CopyOrderTrackingService(
* 卖出订单匹配
* 统一按比例计算不区分RATIO或FIXED模式
* 实际创建卖出订单并记录匹配关系
* 注意此方法在 @Transactional 方法中被调用会自动继承事务
*/
@Transactional
private suspend fun matchSellOrder(
copyTrading: CopyTrading,
leaderSellTrade: TradeResponse
@@ -682,7 +754,28 @@ class CopyOrderTrackingService(
return
}
// 4. 按FIFO顺序匹配,计算实际可以卖出的数量
// 4. 获取tokenId(直接使用outcomeIndex,支持多元市场)
val tokenIdResult = blockchainService.getTokenId(leaderSellTrade.market, leaderSellTrade.outcomeIndex)
if (tokenIdResult.isFailure) {
logger.error("获取tokenId失败: market=${leaderSellTrade.market}, outcomeIndex=${leaderSellTrade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}")
return
}
val tokenId = tokenIdResult.getOrNull() ?: return
// 5. 计算卖出价格(优先使用订单簿 bestBid,失败则使用 Leader 价格,固定按90%计算)
// 注意:需要先计算卖出价格,因为后续创建 matchDetails 需要使用实际卖出价格
val leaderPrice = leaderSellTrade.price.toSafeBigDecimal()
val sellPrice = runCatching {
clobService.getOrderbookByTokenId(tokenId)
.getOrNull()
?.let { calculateMarketSellPrice(it) }
}
.onFailure { e -> logger.warn("获取订单簿或计算 bestBid 失败,使用 Leader 价格: tokenId=$tokenId, error=${e.message}") }
.getOrNull()
?: calculateFallbackSellPrice(leaderPrice)
// 6. 按FIFO顺序匹配,计算实际可以卖出的数量
// 使用计算出的实际卖出价格(而不是 Leader 价格)来创建匹配明细
var totalMatched = BigDecimal.ZERO
var remaining = needMatch
val matchDetails = mutableListOf<SellMatchDetail>()
@@ -697,19 +790,18 @@ class CopyOrderTrackingService(
if (matchQty.lte(BigDecimal.ZERO)) continue
// 计算盈亏
// 计算盈亏(使用实际卖出价格)
val buyPrice = order.price.toSafeBigDecimal()
val sellPrice = leaderSellTrade.price.toSafeBigDecimal()
val realizedPnl = sellPrice.subtract(buyPrice).multi(matchQty)
// 创建匹配明细(稍后保存
// 创建匹配明细(使用实际卖出价格
val detail = SellMatchDetail(
matchRecordId = 0, // 稍后设置
trackingId = order.id!!,
buyOrderId = order.buyOrderId,
matchedQuantity = matchQty,
buyPrice = buyPrice,
sellPrice = sellPrice,
sellPrice = sellPrice, // 使用实际卖出价格,与 SellMatchRecord 保持一致
realizedPnl = realizedPnl
)
matchDetails.add(detail)
@@ -722,17 +814,6 @@ class CopyOrderTrackingService(
return
}
// 5. 获取tokenId(直接使用outcomeIndex,支持多元市场)
val tokenIdResult = blockchainService.getTokenId(leaderSellTrade.market, leaderSellTrade.outcomeIndex)
if (tokenIdResult.isFailure) {
logger.error("获取tokenId失败: market=${leaderSellTrade.market}, outcomeIndex=${leaderSellTrade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}")
return
}
val tokenId = tokenIdResult.getOrNull() ?: return
// 6. 计算卖出价格(应用价格容忍度)
val sellPrice = calculateAdjustedPrice(leaderSellTrade.price.toSafeBigDecimal(), copyTrading, isBuy = false)
// 7. 解密私钥(在方法开始时解密一次,后续复用)
val decryptedPrivateKey = decryptPrivateKey(account)
// 8. 创建并签名卖出订单
@@ -759,7 +840,7 @@ class CopyOrderTrackingService(
// 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配
val orderRequest = NewOrderRequest(
order = signedOrder,
owner = account.apiKey!!,
owner = account.apiKey,
orderType = "FAK", // Fill-And-Kill
deferExec = false
)
@@ -782,8 +863,8 @@ class CopyOrderTrackingService(
side = "SELL",
price = sellPrice.toString(),
size = totalMatched.toString(),
owner = account.apiKey!!,
copyTradingId = copyTrading.id!!,
owner = account.apiKey,
copyTradingId = copyTrading.id,
tradeId = leaderSellTrade.id
)
@@ -830,11 +911,11 @@ class CopyOrderTrackingService(
val totalRealizedPnl = matchDetails.sumOf { it.realizedPnl.toSafeBigDecimal() }
val matchRecord = SellMatchRecord(
copyTradingId = copyTrading.id!!,
copyTradingId = copyTrading.id,
sellOrderId = realSellOrderId, // 使用真实订单ID
leaderSellTradeId = leaderSellTrade.id,
marketId = leaderSellTrade.market,
side = leaderSellTrade.outcomeIndex?.toString() ?: "0", // 使用outcomeIndex作为side(兼容旧数据)
side = leaderSellTrade.outcomeIndex.toString(), // 使用outcomeIndex作为side(兼容旧数据)
outcomeIndex = leaderSellTrade.outcomeIndex, // 新增字段
totalMatchedQuantity = totalMatched,
sellPrice = sellPrice,
@@ -853,27 +934,42 @@ class CopyOrderTrackingService(
/**
* 创建订单带重试机制
* 失败后重试一次如果仍然失败则返回失败结果
* 注意重试时会重新生成salt并重新签名确保每次重试都是新的订单
*
* 重试策略
* - 最多重试 MAX_RETRY_ATTEMPTS 首次尝试 + 重试
* - 每次重试前等待 RETRY_DELAY_MS 毫秒
* - 每次重试都重新生成salt并重新签名确保签名唯一性
*
* @param clobApi CLOB API 客户端
* @param privateKey 私钥用于签名
* @param makerAddress 代理钱包地址
* @param tokenId Token ID
* @param side 订单方向BUY/SELL
* @param price 价格
* @param size 数量
* @param owner API Key用于owner字段
* @param copyTradingId 跟单配置ID用于日志
* @param tradeId Leader 交易ID用于日志
* @return 成功返回订单ID失败返回异常
*/
private suspend fun createOrderWithRetry(
clobApi: com.wrbug.polymarketbot.api.PolymarketClobApi,
clobApi: PolymarketClobApi,
privateKey: String,
makerAddress: String,
tokenId: String,
side: String,
price: String,
size: String,
owner: String, // API Key,用于owner字段
owner: String,
copyTradingId: Long,
tradeId: String
): Result<String> {
var lastError: Exception? = null
// 最多重试2次(首次 + 1次重试)
for (attempt in 1..2) {
// 重试循环:最多重试 MAX_RETRY_ATTEMPTS 次
for (attempt in 1..MAX_RETRY_ATTEMPTS) {
try {
// 每次重试都重新生成salt并重新签名
// 每次重试都重新生成salt并重新签名,确保签名唯一性
val signedOrder = orderSigningService.createAndSignOrder(
privateKey = privateKey,
makerAddress = makerAddress,
@@ -887,76 +983,118 @@ class CopyOrderTrackingService(
expiration = "0"
)
// 构建订单请求(每次重试都使用新签名的订单)
// 构建订单请求
// 跟单订单使用 FAK (Fill-And-Kill),允许部分成交,未成交部分立即取消
// 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配
val orderRequest = NewOrderRequest(
order = signedOrder,
owner = owner, // API Key
owner = owner,
orderType = "FAK", // Fill-And-Kill
deferExec = false
)
// 调用 API 创建订单
val orderResponse = clobApi.createOrder(orderRequest)
// 检查 HTTP 响应状态
if (!orderResponse.isSuccessful || orderResponse.body() == null) {
val errorBody = try {
orderResponse.errorBody()?.string()
} catch (e: Exception) {
null
}
val errorMsg =
"创建订单失败: copyTradingId=$copyTradingId, tradeId=$tradeId, attempt=$attempt, side=$side, price=$price, size=$size, tokenId=$tokenId, code=${orderResponse.code()}, message=${orderResponse.message()}${if (errorBody != null) ", errorBody=$errorBody" else ""}"
val errorMsg = "code=${orderResponse.code()}, errorBody=${errorBody ?: "null"}"
lastError = Exception(errorMsg)
// 所有失败都记录详细日志
logger.error(errorMsg)
if (attempt < 2) {
delay(1000) // 重试前等待1秒
// 记录错误日志
logger.error("创建订单失败 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg")
// 如果不是最后一次尝试,等待后重试
if (attempt < MAX_RETRY_ATTEMPTS) {
delay(RETRY_DELAY_MS)
continue
}
return Result.failure(lastError!!)
return Result.failure(lastError)
}
// 检查业务响应状态
val response = orderResponse.body()!!
if (!response.success || response.orderId == null) {
val errorMsg =
"创建订单失败: copyTradingId=$copyTradingId, tradeId=$tradeId, attempt=$attempt, side=$side, price=$price, size=$size, tokenId=$tokenId, errorMsg=${response.errorMsg}"
val errorMsg = "errorMsg=${response.errorMsg}"
lastError = Exception(errorMsg)
// 所有失败都记录详细日志
logger.error(errorMsg)
if (attempt < 2) {
delay(1000) // 重试前等待1秒
// 记录错误日志
logger.error("创建订单失败 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg")
// 如果不是最后一次尝试,等待后重试
if (attempt < MAX_RETRY_ATTEMPTS) {
delay(RETRY_DELAY_MS)
continue
}
return Result.failure(lastError!!)
return Result.failure(lastError)
}
// 成功
// 创建订单成功
logger.info("创建订单成功: copyTradingId=$copyTradingId, tradeId=$tradeId, orderId=${response.orderId}, attempt=$attempt")
return Result.success(response.orderId)
} catch (e: Exception) {
val errorMsg =
"调用创建订单API异常: copyTradingId=$copyTradingId, tradeId=$tradeId, attempt=$attempt, side=$side, price=$price, size=$size, tokenId=$tokenId, error=${e.message}"
val errorMsg = "error=${e.message}"
lastError = Exception(errorMsg, e)
// 所有失败都记录详细日志(包括堆栈)
logger.error(errorMsg, e)
if (attempt < 2) {
delay(1000) // 重试前等待1秒
// 记录错误日志(包含堆栈)
logger.error("创建订单异常 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg", e)
// 如果不是最后一次尝试,等待后重试
if (attempt < MAX_RETRY_ATTEMPTS) {
delay(RETRY_DELAY_MS)
continue
}
return Result.failure(lastError!!)
return Result.failure(lastError)
}
}
val finalError = lastError ?: Exception("创建订单失败:未知错误")
logger.error(
"创建订单失败(所有重试都失败): copyTradingId=$copyTradingId, tradeId=$tradeId, side=$side, price=$price, size=$size, tokenId=$tokenId",
finalError
)
// 所有重试都失败
val finalError = lastError ?: Exception("error=未知错误")
logger.error("创建订单失败(所有重试都失败): copyTradingId=$copyTradingId, tradeId=$tradeId, side=$side, price=$price, size=$size", finalError)
return Result.failure(finalError)
}
/**
* 构建完整的错误信息包括堆栈
* 检查是否是唯一键冲突异常
*/
private fun isUniqueConstraintViolation(e: Exception): Boolean {
// 检查是否是 DataIntegrityViolationException 或 DuplicateKeyException
if (e is DataIntegrityViolationException || e is DuplicateKeyException) {
return true
}
// 检查是否是 SQLExceptionMySQL 错误码 1062 表示重复键)
var cause: Throwable? = e.cause
while (cause != null) {
if (cause is SQLException) {
val sqlException = cause as SQLException
// MySQL 错误码 1062 表示重复键(Duplicate entry
if (sqlException.errorCode == 1062 || sqlException.sqlState == "23000") {
return true
}
}
// 检查异常消息中是否包含唯一键冲突的关键字
val message = cause.message ?: ""
if (message.contains("Duplicate entry") ||
message.contains("uk_leader_trade") ||
message.contains("UNIQUE constraint")
) {
return true
}
cause = cause.cause
}
return false
}
/**
* 构建简化的错误信息只保留 code errorBody
*/
private fun buildFullErrorMessage(
exception: Throwable?,
@@ -966,35 +1104,29 @@ class CopyOrderTrackingService(
tradeId: String
): String {
if (exception == null) {
return "创建订单失败: side=$side, price=$price, size=$size, tradeId=$tradeId, 未知错误"
return "code=未知, errorBody=null"
}
val errorMsg = StringBuilder()
errorMsg.append("创建订单失败: side=$side, price=$price, size=$size, tradeId=$tradeId")
errorMsg.append(", error=${exception.message}")
val exceptionMessage = exception.message ?: ""
// 添加堆栈信息(限制长度,避免过长)
val stackTrace = exception.stackTraceToString()
val maxLength = 2000 // 限制错误信息最大长度为2000字符
if (stackTrace.length > maxLength) {
errorMsg.append(", stackTrace=${stackTrace.substring(0, maxLength)}...")
} else {
errorMsg.append(", stackTrace=$stackTrace")
}
// 从错误信息中提取 code 和 errorBody
val codePattern = Regex("code=([^,}]+)")
val errorBodyPattern = Regex("errorBody=([^,}]+)")
// 如果有 cause,也添加
exception.cause?.let { cause ->
errorMsg.append(", cause=${cause.message}")
}
val codeMatch = codePattern.find(exceptionMessage)
val errorBodyMatch = errorBodyPattern.find(exceptionMessage)
return errorMsg.toString()
val code = codeMatch?.groupValues?.get(1)?.trim() ?: "未知"
val errorBody = errorBodyMatch?.groupValues?.get(1)?.trim() ?: "null"
return "code=$code, errorBody=$errorBody"
}
/**
* 记录失败交易到数据库
* 注意此方法在 @Transactional 方法中被调用会自动继承事务
*/
@Transactional
private fun recordFailedTrade(
private suspend fun recordFailedTrade(
leaderId: Long,
trade: TradeResponse,
copyTradingId: Long,
@@ -1045,21 +1177,35 @@ class CopyOrderTrackingService(
processedAt = System.currentTimeMillis()
)
processedTradeRepository.save(processed)
} catch (e: DataIntegrityViolationException) {
// 唯一约束冲突,说明已经处理过了(可能是并发请求)
// 检查现有记录的状态
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existing != null) {
if (existing.status == "SUCCESS") {
logger.warn("交易已成功处理,但尝试记录为失败(并发冲突): leaderId=$leaderId, tradeId=${trade.id}")
} catch (e: Exception) {
// 检查是否是唯一键冲突异常
if (isUniqueConstraintViolation(e)) {
// 唯一约束冲突,说明已经处理过了(可能是并发请求)
// 检查现有记录的状态
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existing != null) {
if (existing.status == "SUCCESS") {
logger.warn("交易已成功处理,但尝试记录为失败(并发冲突): leaderId=$leaderId, tradeId=${trade.id}")
} else {
logger.debug("交易已标记为失败(并发检测): leaderId=$leaderId, tradeId=${trade.id}")
}
} else {
logger.debug("交易已标记为失败(并发检测): leaderId=$leaderId, tradeId=${trade.id}")
// 如果查询不到,等待一下再查询(可能是事务隔离级别问题)
delay(100)
val existingAfterDelay =
processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existingAfterDelay != null) {
logger.debug("延迟查询到记录(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existingAfterDelay.status}")
} else {
logger.warn(
"保存ProcessedTrade失败记录时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
e
)
}
}
} else {
logger.warn(
"保存ProcessedTrade失败记录时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
e
)
// 其他类型的异常,记录但不抛出(避免影响其他交易的处理)
logger.warn("保存ProcessedTrade失败记录时发生异常: leaderId=$leaderId, tradeId=${trade.id}", e)
}
}
@@ -1105,7 +1251,7 @@ class CopyOrderTrackingService(
}
// 2. 检查每日亏损限制(需要计算今日已实现盈亏)
val todaySellRecords = sellMatchRecordRepository.findByCopyTradingId(copyTrading.id!!)
val todaySellRecords = sellMatchRecordRepository.findByCopyTradingId(copyTrading.id)
.filter { it.createdAt >= todayStart }
val todayRealizedPnl = todaySellRecords.sumOf { it.totalRealizedPnl.toSafeBigDecimal() }
@@ -1121,19 +1267,22 @@ class CopyOrderTrackingService(
/**
* 计算调整后的价格应用价格容忍度
* 如果价格容忍度为0使用默认值5%
*/
private fun calculateAdjustedPrice(
originalPrice: BigDecimal,
copyTrading: CopyTrading,
isBuy: Boolean
): BigDecimal {
// 如果价格容忍度为0直接返回原价格
if (copyTrading.priceTolerance.eq(BigDecimal.ZERO)) {
return originalPrice
// 如果价格容忍度为0使用默认值5%
val tolerance = if (copyTrading.priceTolerance.eq(BigDecimal.ZERO)) {
BigDecimal("5")
} else {
copyTrading.priceTolerance
}
// 计算价格调整范围(百分比)
val tolerancePercent = copyTrading.priceTolerance.div(100)
val tolerancePercent = tolerance.div(100)
val adjustment = originalPrice.multi(tolerancePercent)
return if (isBuy) {
@@ -1146,23 +1295,39 @@ class CopyOrderTrackingService(
}
/**
* 从过滤原因中提取过滤类型
* 计算市价卖出价格使用订单簿的 bestBid固定按90%计算
*/
private fun extractFilterType(filterReason: String): String {
return when {
filterReason.contains("价格低于最低限制", ignoreCase = true) || filterReason.contains("价格高于最高限制", ignoreCase = true) -> "PRICE_RANGE"
filterReason.contains("订单深度不足", ignoreCase = true) -> "ORDER_DEPTH"
filterReason.contains("价差过大", ignoreCase = true) -> "SPREAD"
filterReason.contains("订单簿深度不足", ignoreCase = true) -> "ORDERBOOK_DEPTH"
filterReason.contains("价格", ignoreCase = true) && filterReason.contains(
"合理",
ignoreCase = true
) -> "PRICE_VALIDITY"
private fun calculateMarketSellPrice(
orderbook: com.wrbug.polymarketbot.api.OrderbookResponse
): BigDecimal {
// 获取 bestBid(最高买入价)
val bestBid = orderbook.bids
.mapNotNull { it.price.toSafeBigDecimal() }
.maxOrNull()
?: throw IllegalStateException("订单簿 bids 为空,无法获取 bestBid")
filterReason.contains("市场状态", ignoreCase = true) -> "MARKET_STATUS"
filterReason.contains("获取订单簿失败", ignoreCase = true) -> "ORDERBOOK_ERROR"
filterReason.contains("订单簿为空", ignoreCase = true) -> "ORDERBOOK_EMPTY"
else -> "UNKNOWN"
// 卖出:bestBid * 0.9(固定按90%计算,确保能立即成交)
return calculateFallbackSellPrice(bestBid)
}
/**
* 计算降级卖出价格固定按90%计算
*/
private fun calculateFallbackSellPrice(price: BigDecimal): BigDecimal {
return price.multi(BigDecimal("0.9")).coerceAtLeast(BigDecimal("0.01"))
}
/**
* 从过滤结果中提取过滤类型
*/
private fun extractFilterType(status: FilterStatus, reason: String): String {
return when (status) {
FilterStatus.PASSED -> "PASSED"
FilterStatus.FAILED_PRICE_RANGE -> "PRICE_RANGE"
FilterStatus.FAILED_ORDERBOOK_ERROR -> "ORDERBOOK_ERROR"
FilterStatus.FAILED_ORDERBOOK_EMPTY -> "ORDERBOOK_EMPTY"
FilterStatus.FAILED_SPREAD -> "SPREAD"
FilterStatus.FAILED_ORDER_DEPTH -> "ORDER_DEPTH"
}
}
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.copytrading.statistics
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.*
@@ -13,6 +13,8 @@ import org.slf4j.LoggerFactory
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import com.wrbug.polymarketbot.service.accounts.AccountService
import com.wrbug.polymarketbot.service.common.BlockchainService
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.math.RoundingMode
@@ -30,7 +32,7 @@ class CopyTradingStatisticsService(
private val accountRepository: AccountRepository,
private val leaderRepository: LeaderRepository,
private val accountService: AccountService,
private val blockchainService: com.wrbug.polymarketbot.service.BlockchainService
private val blockchainService: BlockchainService
) {
private val logger = LoggerFactory.getLogger(CopyTradingStatisticsService::class.java)
@@ -69,7 +71,10 @@ class CopyTradingStatisticsService(
// 9. 计算未实现盈亏(使用链上实际持仓,而不是 remainingQuantity
val unrealizedPnl = calculateUnrealizedPnl(buyOrders, currentPrice, actualPositions)
// 9. 构建响应
// 10. 计算持仓价值(使用链上实际持仓和当前价格)
val positionValue = calculatePositionValue(buyOrders, currentPrice, actualPositions)
// 11. 构建响应
val response = CopyTradingStatisticsResponse(
copyTradingId = copyTradingId,
accountId = copyTrading.accountId,
@@ -85,7 +90,7 @@ class CopyTradingStatisticsService(
totalSellOrders = statistics.totalSellOrders,
totalSellAmount = statistics.totalSellAmount,
currentPositionQuantity = statistics.currentPositionQuantity,
currentPositionValue = calculatePositionValue(statistics.currentPositionQuantity, currentPrice),
currentPositionValue = positionValue,
totalRealizedPnl = statistics.totalRealizedPnl,
totalUnrealizedPnl = unrealizedPnl,
totalPnl = (statistics.totalRealizedPnl.toSafeBigDecimal().add(unrealizedPnl.toSafeBigDecimal())).toString(),
@@ -105,7 +110,7 @@ class CopyTradingStatisticsService(
fun getOrderList(request: OrderTrackingRequest): Result<OrderListResponse> {
return try {
// 1. 验证跟单关系
val copyTrading = copyTradingRepository.findById(request.copyTradingId).orElse(null)
copyTradingRepository.findById(request.copyTradingId).orElse(null)
?: return Result.failure(IllegalArgumentException("跟单关系不存在: ${request.copyTradingId}"))
// 2. 根据类型查询
@@ -293,8 +298,10 @@ class CopyTradingStatisticsService(
}
// 卖出统计
// 使用 SellMatchDetail 计算总卖出金额,确保准确性
// 因为每个明细都记录了准确的匹配数量和卖出价格
val totalSellQuantity = sellRecords.sumOf { it.totalMatchedQuantity.toSafeBigDecimal() }
val totalSellAmount = sellRecords.sumOf { it.totalMatchedQuantity.toSafeBigDecimal().multi(it.sellPrice) }
val totalSellAmount = matchDetails.sumOf { it.matchedQuantity.toSafeBigDecimal().multi(it.sellPrice) }
val totalSellOrders = sellRecords.size.toLong()
// 持仓统计
@@ -354,7 +361,7 @@ class CopyTradingStatisticsService(
* 获取链上实际持仓
* (marketId, outcomeIndex) 组合返回实际持仓数量
*/
private suspend fun getActualPositions(account: com.wrbug.polymarketbot.entity.Account?): Map<String, BigDecimal> {
private suspend fun getActualPositions(account: Account?): Map<String, BigDecimal> {
val positions = mutableMapOf<String, BigDecimal>()
if (account == null || account.proxyAddress.isBlank()) {
@@ -385,6 +392,7 @@ class CopyTradingStatisticsService(
/**
* 计算未实现盈亏
* 使用链上实际持仓数量而不是 remainingQuantity考虑手动卖出的情况
* 按市场聚合订单计算加权平均买入价格避免重复计算
*/
private fun calculateUnrealizedPnl(
buyOrders: List<CopyOrderTracking>,
@@ -393,6 +401,9 @@ class CopyTradingStatisticsService(
): String {
var totalUnrealizedPnl = BigDecimal.ZERO
// 按市场聚合订单,计算加权平均买入价格
val marketAggregates = mutableMapOf<String, Pair<BigDecimal, BigDecimal>>() // key -> (总持仓, 总成本)
for (order in buyOrders) {
// 如果没有 outcomeIndex,跳过(无法确定价格和持仓)
if (order.outcomeIndex == null) {
@@ -403,8 +414,32 @@ class CopyTradingStatisticsService(
// 使用 "marketId:outcomeIndex" 作为 key
val key = "${order.marketId}:${order.outcomeIndex}"
// 获取链上实际持仓数量(如果存在),否则使用 remainingQuantity
val actualQty = actualPositions[key] ?: order.remainingQuantity.toSafeBigDecimal()
// 获取订单的持仓数量(使用 remainingQuantity,因为这是该订单的持仓)
val orderQty = order.remainingQuantity.toSafeBigDecimal()
// 如果订单持仓 <= 0,跳过
if (orderQty.lte(BigDecimal.ZERO)) continue
val buyPrice = order.price.toSafeBigDecimal()
val orderCost = orderQty.multi(buyPrice)
// 聚合同一市场的订单
val existing = marketAggregates[key]
if (existing != null) {
val totalQty = existing.first.add(orderQty)
val totalCost = existing.second.add(orderCost)
marketAggregates[key] = Pair(totalQty, totalCost)
} else {
marketAggregates[key] = Pair(orderQty, orderCost)
}
}
// 计算每个市场的未实现盈亏
for ((key, aggregate) in marketAggregates) {
val (totalQty, totalCost) = aggregate
// 获取链上实际持仓数量(如果存在),否则使用聚合的持仓数量
val actualQty = actualPositions[key] ?: totalQty
// 如果实际持仓 <= 0,说明已全部卖出(包括手动卖出),跳过未实现盈亏计算
if (actualQty.lte(BigDecimal.ZERO)) continue
@@ -413,9 +448,15 @@ class CopyTradingStatisticsService(
val currentPrice = currentPrices[key]?.toSafeBigDecimal()
?: continue // 如果没有当前价格,跳过
val buyPrice = order.price.toSafeBigDecimal()
// 使用实际持仓数量计算未实现盈亏
val unrealizedPnl = currentPrice.subtract(buyPrice).multi(actualQty)
// 计算加权平均买入价格
val avgBuyPrice = if (totalQty.gt(BigDecimal.ZERO)) {
totalCost.div(totalQty)
} else {
continue
}
// 使用实际持仓数量和加权平均买入价格计算未实现盈亏
val unrealizedPnl = currentPrice.subtract(avgBuyPrice).multi(actualQty)
totalUnrealizedPnl = totalUnrealizedPnl.add(unrealizedPnl)
}
@@ -424,11 +465,44 @@ class CopyTradingStatisticsService(
/**
* 计算持仓价值
* 使用链上实际持仓数量和当前市场价格计算
* 按市场聚合避免重复计算
*/
private fun calculatePositionValue(positionQuantity: String, currentPrices: Map<String, String>): String {
// 这里简化处理,实际应该根据每个市场的持仓分别计算
// 暂时返回0,因为需要知道每个市场的持仓数量
return "0"
private fun calculatePositionValue(
buyOrders: List<CopyOrderTracking>,
currentPrices: Map<String, String>,
actualPositions: Map<String, BigDecimal>
): String {
var totalPositionValue = BigDecimal.ZERO
// 按市场聚合,获取所有不同的市场
val marketKeys = buyOrders
.filter { it.outcomeIndex != null }
.map { "${it.marketId}:${it.outcomeIndex}" }
.distinct()
for (key in marketKeys) {
// 获取链上实际持仓数量(如果存在)
val actualQty = actualPositions[key]
// 如果没有链上持仓,计算该市场的总持仓(所有订单的 remainingQuantity 之和)
val totalQty = actualQty ?: buyOrders
.filter { it.outcomeIndex != null && "${it.marketId}:${it.outcomeIndex}" == key }
.sumOf { it.remainingQuantity.toSafeBigDecimal() }
// 如果持仓 <= 0,跳过
if (totalQty.lte(BigDecimal.ZERO)) continue
// 获取当前市场价格
val currentPrice = currentPrices[key]?.toSafeBigDecimal()
?: continue // 如果没有当前价格,跳过
// 计算持仓价值:持仓数量 × 当前价格
val positionValue = totalQty.multi(currentPrice)
totalPositionValue = totalPositionValue.add(positionValue)
}
return totalPositionValue.toString()
}
/**
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.copytrading.templates
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.CopyTradingTemplate
@@ -61,7 +61,6 @@ class CopyTradingTemplateService(
supportSell = request.supportSell ?: true,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(),
maxSpread = request.maxSpread?.toSafeBigDecimal(),
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal(),
minPrice = request.minPrice?.toSafeBigDecimal(),
maxPrice = request.maxPrice?.toSafeBigDecimal()
)
@@ -120,7 +119,6 @@ class CopyTradingTemplateService(
supportSell = request.supportSell ?: template.supportSell,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: template.minOrderDepth,
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread,
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: template.minOrderbookDepth,
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
updatedAt = System.currentTimeMillis()
@@ -187,7 +185,6 @@ class CopyTradingTemplateService(
supportSell = request.supportSell ?: sourceTemplate.supportSell,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: sourceTemplate.minOrderDepth,
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: sourceTemplate.maxSpread,
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: sourceTemplate.minOrderbookDepth,
minPrice = request.minPrice?.toSafeBigDecimal() ?: sourceTemplate.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: sourceTemplate.maxPrice
)
@@ -261,7 +258,6 @@ class CopyTradingTemplateService(
supportSell = template.supportSell,
minOrderDepth = template.minOrderDepth?.toPlainString(),
maxSpread = template.maxSpread?.toPlainString(),
minOrderbookDepth = template.minOrderbookDepth?.toPlainString(),
minPrice = template.minPrice?.toPlainString(),
maxPrice = template.maxPrice?.toPlainString(),
createdAt = template.createdAt,
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.system
import com.wrbug.polymarketbot.dto.ApiHealthCheckDto
import com.wrbug.polymarketbot.dto.ApiHealthCheckResponse
@@ -12,6 +12,8 @@ import org.springframework.beans.BeansException
import org.springframework.context.ApplicationContext
import org.springframework.context.ApplicationContextAware
import org.springframework.beans.factory.annotation.Value
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingWebSocketService
import org.springframework.stereotype.Service
import java.util.concurrent.TimeUnit
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.system
import com.fasterxml.jackson.databind.ObjectMapper
import com.wrbug.polymarketbot.dto.*
@@ -1,8 +1,10 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.system
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.ProxyConfig
import com.wrbug.polymarketbot.repository.ProxyConfigRepository
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingWebSocketService
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
import com.wrbug.polymarketbot.util.ProxyConfigProvider
import com.wrbug.polymarketbot.util.TrustAllHostnameVerifier
import com.wrbug.polymarketbot.util.createSSLSocketFactory
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.system
import com.wrbug.polymarketbot.api.BuilderRelayerApi
import com.wrbug.polymarketbot.api.EthereumRpcApi
@@ -802,10 +802,10 @@ class RelayClientService(
safeTxs: List<SafeTransaction>
): Result<String> {
// 批量执行:将多个交易合并为一个 execTransaction 调用
// 当前实现:委托给 BlockchainService
// 当前实现:委托给 com.wrbug.polymarketbot.service.common.BlockchainService
return Result.failure(
UnsupportedOperationException(
"批量 Gasless 执行暂未实现。请使用 BlockchainService.redeemPositions() 方法。"
"批量 Gasless 执行暂未实现。请使用 com.wrbug.polymarketbot.service.common.BlockchainService.redeemPositions() 方法。"
)
)
}
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.system
import com.wrbug.polymarketbot.dto.SystemConfigDto
import com.wrbug.polymarketbot.dto.SystemConfigUpdateRequest
@@ -36,10 +36,38 @@ class SystemConfigService(
val builderPassphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)
val autoRedeem = isAutoRedeemEnabled()
// 获取完整的 API Key(用于前端展示)
val builderApiKeyDisplay = builderApiKey?.let {
try {
cryptoUtils.decrypt(it)
} catch (e: Exception) {
null
}
}
val builderSecretDisplay = builderSecret?.let {
try {
cryptoUtils.decrypt(it)
} catch (e: Exception) {
null
}
}
val builderPassphraseDisplay = builderPassphrase?.let {
try {
cryptoUtils.decrypt(it)
} catch (e: Exception) {
null
}
}
return SystemConfigDto(
builderApiKeyConfigured = builderApiKey != null,
builderSecretConfigured = builderSecret != null,
builderPassphraseConfigured = builderPassphrase != null,
builderApiKeyDisplay = builderApiKeyDisplay,
builderSecretDisplay = builderSecretDisplay,
builderPassphraseDisplay = builderPassphraseDisplay,
autoRedeemEnabled = autoRedeem
)
}
@@ -1,11 +1,13 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.system
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.dto.NotificationConfigData
import com.wrbug.polymarketbot.dto.TelegramConfigData
import com.wrbug.polymarketbot.util.createClient
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.DateUtils
import kotlinx.coroutines.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
@@ -65,7 +67,7 @@ class TelegramNotificationService(
side: String,
accountName: String? = null,
walletAddress: String? = null,
clobApi: com.wrbug.polymarketbot.api.PolymarketClobApi? = null,
clobApi: PolymarketClobApi? = null,
apiKey: String? = null,
apiSecret: String? = null,
apiPassphrase: String? = null,
@@ -307,7 +309,7 @@ class TelegramNotificationService(
}
}
val time = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(java.util.Date())
val time = DateUtils.formatDateTime()
// 转义 HTML 特殊字符
val escapedMarketTitle = marketTitle.replace("<", "&lt;").replace(">", "&gt;")
@@ -638,7 +640,7 @@ class TelegramNotificationService(
""
}
val time = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(java.util.Date())
val time = DateUtils.formatDateTime()
// 转义 HTML 特殊字符
val escapedMarketTitle = marketTitle.replace("<", "&lt;").replace(">", "&gt;")
@@ -759,7 +761,7 @@ class TelegramNotificationService(
}
}
val time = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(java.util.Date())
val time = DateUtils.formatDateTime()
// 错误信息已经是后端返回的 msg,不需要截断(但为了安全,限制长度)
val shortErrorMessage = if (errorMessage.length > 500) {
@@ -900,7 +902,7 @@ class TelegramNotificationService(
}
}
val time = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(java.util.Date())
val time = DateUtils.formatDateTime()
// 转义 HTML 特殊字符
val escapedAccountInfo = accountInfo.replace("<", "&lt;").replace(">", "&gt;")
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.system
import com.wrbug.polymarketbot.dto.UserCreateRequest
import com.wrbug.polymarketbot.dto.UserDto
@@ -1,6 +1,8 @@
package com.wrbug.polymarketbot.util
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
@@ -15,6 +17,13 @@ object DateUtils {
*/
private val isoFormatter = DateTimeFormatter.ISO_DATE_TIME
/**
* 使用系统时区的日期时间格式化器(用于显示)
* 格式:yyyy-MM-dd HH:mm:ss
*/
private val displayFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(ZoneId.systemDefault())
/**
* 将 ISO 8601 格式的日期字符串转换为时间戳(毫秒)
* @param dateString ISO 8601 格式的日期字符串,如 "2020-11-04T00:00:00Z"
@@ -61,5 +70,19 @@ object DateUtils {
null
}
}
/**
* 将时间戳(毫秒)格式化为可读的日期时间字符串(使用系统时区)
* @param timestamp 时间戳(毫秒),如果为 null 则使用当前时间
* @return 格式化的日期时间字符串,格式:yyyy-MM-dd HH:mm:ss
*/
fun formatDateTime(timestamp: Long? = null): String {
val instant = if (timestamp != null) {
Instant.ofEpochMilli(timestamp)
} else {
Instant.now()
}
return displayFormatter.format(instant)
}
}
@@ -3,7 +3,7 @@ package com.wrbug.polymarketbot.websocket
import com.fasterxml.jackson.databind.ObjectMapper
import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage
import com.wrbug.polymarketbot.dto.WebSocketMessageType
import com.wrbug.polymarketbot.service.WebSocketSubscriptionService
import com.wrbug.polymarketbot.service.common.WebSocketSubscriptionService
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
+2 -1
View File
@@ -54,13 +54,14 @@ trap cleanup SIGTERM SIGINT
# 启动后端服务(以 appuser 用户运行,后台运行)
echo "启动后端服务..."
# 自动使用系统时区
java -jar /app/app.jar --spring.profiles.active=${SPRING_PROFILES_ACTIVE:-prod} &
BACKEND_PID=$!
# 等待后端服务启动
echo "等待后端服务启动..."
for i in {1..60}; do
if curl -f http://localhost:8000/api/health > /dev/null 2>&1; then
if curl -f http://localhost:8000/api/system/health > /dev/null 2>&1; then
echo "后端服务已启动"
break
fi
+1 -1
View File
@@ -648,5 +648,5 @@ Vite uses `.env.production` file to inject environment variables during build. T
## Technical Support
If you have any questions, please submit an Issue to [GitHub](https://github.com/WrBug/PolyHermes) or contact [Twitter](https://x.com/quant_tr).
If you have any questions, please submit an Issue to [GitHub](https://github.com/WrBug/PolyHermes) or contact [Twitter](https://x.com/polyhermes).
+1 -1
View File
@@ -648,5 +648,5 @@ Vite 使用 `.env.production` 文件在构建时注入环境变量。构建脚
## 技术支持
如有问题,请提交 Issue 到 [GitHub](https://github.com/WrBug/PolyHermes) 或联系 [Twitter](https://x.com/quant_tr)。
如有问题,请提交 Issue 到 [GitHub](https://github.com/WrBug/PolyHermes) 或联系 [Twitter](https://x.com/polyhermes)。
+1
View File
@@ -252,6 +252,7 @@ function App() {
<Route path="/copy-trading/add" element={<ProtectedRoute><CopyTradingAdd /></ProtectedRoute>} />
<Route path="/copy-trading/edit/:id" element={<ProtectedRoute><CopyTradingEdit /></ProtectedRoute>} />
<Route path="/copy-trading/statistics/:copyTradingId" element={<ProtectedRoute><CopyTradingStatistics /></ProtectedRoute>} />
{/* 保留旧路由以保持向后兼容 */}
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<ProtectedRoute><CopyTradingBuyOrders /></ProtectedRoute>} />
<Route path="/copy-trading/orders/sell/:copyTradingId" element={<ProtectedRoute><CopyTradingSellOrders /></ProtectedRoute>} />
<Route path="/copy-trading/orders/matched/:copyTradingId" element={<ProtectedRoute><CopyTradingMatchedOrders /></ProtectedRoute>} />
+2 -2
View File
@@ -238,7 +238,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
<GithubOutlined />
</a>
<a
href="https://x.com/quant_tr"
href="https://x.com/polyhermes"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#fff', fontSize: '16px', display: 'flex', alignItems: 'center' }}
@@ -362,7 +362,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
<GithubOutlined />
</a>
<a
href="https://x.com/quant_tr"
href="https://x.com/polyhermes"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#fff', fontSize: '18px', display: 'flex', alignItems: 'center' }}
+79 -9
View File
@@ -597,10 +597,10 @@
"priceToleranceTooltip": "Allowed adjustment range for copy price based on Leader price, used to adjust price within Leader price ± tolerance range to improve fill rate. For example: set to 5%, Leader price is 0.5, then copy price can be in 0.475-0.525 range.",
"priceTolerancePlaceholder": "Default 5% (optional)",
"minOrderDepth": "Min Order Depth (USDC)",
"minOrderDepthTooltip": "Minimum order depth (USDC amount), NULL means this filter is not enabled. Ensures market has sufficient liquidity",
"minOrderDepthTooltip": "Check total order amount (bids + asks) in orderbook to ensure sufficient liquidity. Leave empty to disable",
"minOrderDepthPlaceholder": "For example: 100 (optional, leave empty to disable)",
"maxSpread": "Max Spread (Absolute Price)",
"maxSpreadTooltip": "Maximum spread (absolute price), NULL means this filter is not enabled. Avoid copying in markets with excessive spreads",
"maxSpreadTooltip": "Maximum spread (absolute price). Avoid copying in markets with excessive spreads. Leave empty to disable",
"maxSpreadPlaceholder": "For example: 0.05 (5 cents, optional, leave empty to disable)",
"minOrderbookDepth": "Min Orderbook Depth (USDC)",
"minOrderbookDepthTooltip": "Minimum orderbook depth (USDC amount), NULL means this filter is not enabled. Check depth of first N levels",
@@ -654,10 +654,10 @@
"priceToleranceTooltip": "Allowed adjustment range for copy price based on Leader price, used to adjust price within Leader price ± tolerance range to improve fill rate. For example: set to 5%, Leader price is 0.5, then copy price can be in 0.475-0.525 range.",
"priceTolerancePlaceholder": "Default 5% (optional)",
"minOrderDepth": "Min Order Depth (USDC)",
"minOrderDepthTooltip": "Minimum order depth (USDC amount), NULL means this filter is not enabled. Ensures market has sufficient liquidity",
"minOrderDepthTooltip": "Check total order amount (bids + asks) in orderbook to ensure sufficient liquidity. Leave empty to disable",
"minOrderDepthPlaceholder": "For example: 100 (optional, leave empty to disable)",
"maxSpread": "Max Spread (Absolute Price)",
"maxSpreadTooltip": "Maximum spread (absolute price), NULL means this filter is not enabled. Avoid copying in markets with excessive spreads",
"maxSpreadTooltip": "Maximum spread (absolute price). Avoid copying in markets with excessive spreads. Leave empty to disable",
"maxSpreadPlaceholder": "For example: 0.05 (5 cents, optional, leave empty to disable)",
"minOrderbookDepth": "Min Orderbook Depth (USDC)",
"minOrderbookDepthTooltip": "Minimum orderbook depth (USDC amount), NULL means this filter is not enabled. Check depth of first N levels",
@@ -719,10 +719,10 @@
"delaySecondsPlaceholder": "Default 0 (copy immediately)",
"filterConditions": "Filter Conditions (Optional)",
"minOrderDepth": "Min Order Depth (USDC)",
"minOrderDepthTooltip": "Minimum order depth (USDC amount), NULL means this filter is not enabled. Ensures market has sufficient liquidity",
"minOrderDepthTooltip": "Check total order amount (bids + asks) in orderbook to ensure sufficient liquidity. Leave empty to disable",
"minOrderDepthPlaceholder": "For example: 100 (optional, leave empty to disable)",
"maxSpread": "Max Spread (Absolute Price)",
"maxSpreadTooltip": "Maximum spread (absolute price), NULL means this filter is not enabled. Avoid copying in markets with excessive spreads",
"maxSpreadTooltip": "Maximum spread (absolute price). Avoid copying in markets with excessive spreads. Leave empty to disable",
"maxSpreadPlaceholder": "For example: 0.05 (5 cents, optional, leave empty to disable)",
"minOrderbookDepth": "Min Orderbook Depth (USDC)",
"minOrderbookDepthTooltip": "Minimum orderbook depth (USDC amount), NULL means this filter is not enabled. Check depth of first N levels",
@@ -794,10 +794,10 @@
"delaySecondsPlaceholder": "Default 0 (copy immediately)",
"filterConditions": "Filter Conditions (Optional)",
"minOrderDepth": "Min Order Depth (USDC)",
"minOrderDepthTooltip": "Minimum order depth (USDC amount), NULL means this filter is not enabled",
"minOrderDepthTooltip": "Check total order amount (bids + asks) in orderbook to ensure sufficient liquidity. Leave empty to disable",
"minOrderDepthPlaceholder": "For example: 100 (optional, leave empty to disable)",
"maxSpread": "Max Spread (Absolute Price)",
"maxSpreadTooltip": "Maximum spread (absolute price), NULL means this filter is not enabled",
"maxSpreadTooltip": "Maximum spread (absolute price). Avoid copying in markets with excessive spreads. Leave empty to disable",
"maxSpreadPlaceholder": "For example: 0.05 (5 cents, optional, leave empty to disable)",
"minOrderbookDepth": "Min Orderbook Depth (USDC)",
"minOrderbookDepthTooltip": "Minimum orderbook depth (USDC amount), NULL means this filter is not enabled",
@@ -856,6 +856,8 @@
"copyTradingList": {
"title": "Copy Trading Config Management",
"addCopyTrading": "Add Copy Trading",
"configName": "Config Name",
"configNameNotProvided": "Not Provided",
"wallet": "Wallet",
"account": "Account",
"template": "Template",
@@ -868,10 +870,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 +969,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"
}
}
+103 -14
View File
@@ -117,19 +117,40 @@
},
"accountImport": {
"title": "导入账户",
"back": "返回",
"securityTip": "安全提示",
"securityTipDesc": "私钥将存储在后端数据库中,请确保数据库访问安全。建议使用 HTTPS 连接。",
"importMethod": "导入方式",
"privateKey": "私钥",
"mnemonic": "助记词",
"privateKeyLabel": "私钥",
"privateKeyPlaceholder": "请输入私钥(64位十六进制字符串,可选0x前缀)",
"privateKeyRequired": "请输入私钥",
"privateKeyPlaceholder": "请输入或粘贴私钥",
"privateKeyHelp": "私钥将加密存储,仅用于签名交易",
"privateKeyInvalid": "私钥格式不正确(应为64位十六进制字符串)",
"walletAddress": "钱包地址",
"walletAddressPlaceholder": "钱包地址(将从私钥自动推导)",
"walletAddressRequired": "请输入钱包地址",
"walletAddressInvalid": "钱包地址格式不正确",
"walletAddressMismatch": "钱包地址与私钥不匹配",
"mnemonicLabel": "助记词",
"mnemonicPlaceholder": "请输入12或24个单词的助记词(用空格分隔)",
"mnemonicRequired": "请输入助记词",
"mnemonicInvalid": "助记词格式不正确(应为12或24个单词,用空格分隔)",
"walletAddressMismatchMnemonic": "钱包地址与助记词不匹配",
"accountName": "账户名称",
"accountNameRequired": "请输入账户名称",
"accountNamePlaceholder": "请输入账户名称",
"accountNamePlaceholder": "可选,用于标识账户",
"accountNameHelp": "用于标识账户,便于管理",
"privateKeyHelp": "私钥将加密存储,仅用于签名交易",
"submit": "导入",
"invalidPrivateKey": "无效的私钥",
"duplicateAccount": "账户已存在",
"importAccount": "导入账户",
"importSuccess": "导入账户成功",
"importFailed": "导入账户失败",
"invalidPrivateKey": "无效的私钥",
"duplicateAccount": "账户已存在"
"derivedAddress": "推导地址",
"addressError": "无法从私钥推导地址",
"addressErrorMnemonic": "无法从助记词推导地址"
},
"leader": {
"title": "Leader 管理",
@@ -512,10 +533,10 @@
"priceToleranceTooltip": "允许跟单价格在 Leader 价格基础上的调整范围,用于在 Leader 价格 ± 容忍度范围内调整价格,提高成交率。例如:设置为 5%,Leader 价格为 0.5,则跟单价格可在 0.475-0.525 范围内。",
"priceTolerancePlaceholder": "默认 5%(可选)",
"minOrderDepth": "最小订单深度 (USDC)",
"minOrderDepthTooltip": "最小订单深度(USDC金额),NULL表示不启用此过滤。确保市场有足够的流动性",
"minOrderDepthTooltip": "检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤",
"minOrderDepthPlaceholder": "例如:100(可选,不填写表示不启用)",
"maxSpread": "最大价差(绝对价格)",
"maxSpreadTooltip": "最大价差(绝对价格)NULL表示不启用此过滤。避免在价差过大的市场跟单",
"maxSpreadTooltip": "最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤",
"maxSpreadPlaceholder": "例如:0.05(5美分,可选,不填写表示不启用)",
"minOrderbookDepth": "最小订单簿深度 (USDC)",
"minOrderbookDepthTooltip": "最小订单簿深度(USDC金额),NULL表示不启用此过滤。检查前 N 档的深度",
@@ -569,10 +590,10 @@
"priceToleranceTooltip": "允许跟单价格在 Leader 价格基础上的调整范围,用于在 Leader 价格 ± 容忍度范围内调整价格,提高成交率。例如:设置为 5%,Leader 价格为 0.5,则跟单价格可在 0.475-0.525 范围内。",
"priceTolerancePlaceholder": "默认 5%(可选)",
"minOrderDepth": "最小订单深度 (USDC)",
"minOrderDepthTooltip": "最小订单深度(USDC金额),NULL表示不启用此过滤。确保市场有足够的流动性",
"minOrderDepthTooltip": "检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤",
"minOrderDepthPlaceholder": "例如:100(可选,不填写表示不启用)",
"maxSpread": "最大价差(绝对价格)",
"maxSpreadTooltip": "最大价差(绝对价格)NULL表示不启用此过滤。避免在价差过大的市场跟单",
"maxSpreadTooltip": "最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤",
"maxSpreadPlaceholder": "例如:0.05(5美分,可选,不填写表示不启用)",
"minOrderbookDepth": "最小订单簿深度 (USDC)",
"minOrderbookDepthTooltip": "最小订单簿深度(USDC金额),NULL表示不启用此过滤。检查前 N 档的深度",
@@ -646,10 +667,10 @@
"delaySecondsPlaceholder": "默认 0(立即跟单)",
"filterConditions": "过滤条件(可选)",
"minOrderDepth": "最小订单深度 (USDC)",
"minOrderDepthTooltip": "最小订单深度(USDC金额),NULL表示不启用此过滤。确保市场有足够的流动性",
"minOrderDepthTooltip": "检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤",
"minOrderDepthPlaceholder": "例如:100(可选,不填写表示不启用)",
"maxSpread": "最大价差(绝对价格)",
"maxSpreadTooltip": "最大价差(绝对价格)NULL表示不启用此过滤。避免在价差过大的市场跟单",
"maxSpreadTooltip": "最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤",
"maxSpreadPlaceholder": "例如:0.05(5美分,可选,不填写表示不启用)",
"minOrderbookDepth": "最小订单簿深度 (USDC)",
"minOrderbookDepthTooltip": "最小订单簿深度(USDC金额),NULL表示不启用此过滤。检查前 N 档的深度",
@@ -721,10 +742,10 @@
"delaySecondsPlaceholder": "默认 0(立即跟单)",
"filterConditions": "过滤条件(可选)",
"minOrderDepth": "最小订单深度 (USDC)",
"minOrderDepthTooltip": "最小订单深度(USDC金额),NULL表示不启用此过滤",
"minOrderDepthTooltip": "检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤",
"minOrderDepthPlaceholder": "例如:100(可选,不填写表示不启用)",
"maxSpread": "最大价差(绝对价格)",
"maxSpreadTooltip": "最大价差(绝对价格)NULL表示不启用此过滤",
"maxSpreadTooltip": "最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤",
"maxSpreadPlaceholder": "例如:0.05(5美分,可选,不填写表示不启用)",
"minOrderbookDepth": "最小订单簿深度 (USDC)",
"minOrderbookDepthTooltip": "最小订单簿深度(USDC金额),NULL表示不启用此过滤",
@@ -785,10 +806,11 @@
"totalPnl": "总盈亏",
"statistics": "统计",
"orders": "订单",
"matchedOrders": "已成交订单",
"ordersButton": "订单",
"viewStatistics": "查看统计",
"buyOrders": "买入订单",
"sellOrders": "卖出订单",
"matchedOrders": "匹配关系",
"filteredOrders": "已过滤订单",
"filterWallet": "筛选钱包",
"filterTemplate": "筛选模板",
@@ -883,5 +905,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": "最小盈亏"
}
}
+79 -9
View File
@@ -597,10 +597,10 @@
"priceToleranceTooltip": "允許跟單價格在 Leader 價格基礎上的調整範圍,用於在 Leader 價格 ± 容忍度範圍內調整價格,提高成交率。例如:設置為 5%,Leader 價格為 0.5,則跟單價格可在 0.475-0.525 範圍內。",
"priceTolerancePlaceholder": "默認 5%(可選)",
"minOrderDepth": "最小訂單深度 (USDC)",
"minOrderDepthTooltip": "最小訂單深度(USDC金額),NULL表示不啟用此過濾。確保市場有足夠的流動性",
"minOrderDepthTooltip": "檢查訂單簿的總訂單金額(買盤+賣盤),確保市場有足夠的流動性。不填寫則不啟用此過濾",
"minOrderDepthPlaceholder": "例如:100(可選,不填寫表示不啟用)",
"maxSpread": "最大價差(絕對價格)",
"maxSpreadTooltip": "最大價差(絕對價格)NULL表示不啟用此過濾。避免在價差過大的市場跟單",
"maxSpreadTooltip": "最大價差(絕對價格)。避免在價差過大的市場跟單。不填寫則不啟用此過濾",
"maxSpreadPlaceholder": "例如:0.05(5美分,可選,不填寫表示不啟用)",
"minOrderbookDepth": "最小訂單簿深度 (USDC)",
"minOrderbookDepthTooltip": "最小訂單簿深度(USDC金額),NULL表示不啟用此過濾。檢查前 N 檔的深度",
@@ -654,10 +654,10 @@
"priceToleranceTooltip": "允許跟單價格在 Leader 價格基礎上的調整範圍,用於在 Leader 價格 ± 容忍度範圍內調整價格,提高成交率。例如:設置為 5%,Leader 價格為 0.5,則跟單價格可在 0.475-0.525 範圍內。",
"priceTolerancePlaceholder": "默認 5%(可選)",
"minOrderDepth": "最小訂單深度 (USDC)",
"minOrderDepthTooltip": "最小訂單深度(USDC金額),NULL表示不啟用此過濾。確保市場有足夠的流動性",
"minOrderDepthTooltip": "檢查訂單簿的總訂單金額(買盤+賣盤),確保市場有足夠的流動性。不填寫則不啟用此過濾",
"minOrderDepthPlaceholder": "例如:100(可選,不填寫表示不啟用)",
"maxSpread": "最大價差(絕對價格)",
"maxSpreadTooltip": "最大價差(絕對價格)NULL表示不啟用此過濾。避免在價差過大的市場跟單",
"maxSpreadTooltip": "最大價差(絕對價格)。避免在價差過大的市場跟單。不填寫則不啟用此過濾",
"maxSpreadPlaceholder": "例如:0.05(5美分,可選,不填寫表示不啟用)",
"minOrderbookDepth": "最小訂單簿深度 (USDC)",
"minOrderbookDepthTooltip": "最小訂單簿深度(USDC金額),NULL表示不啟用此過濾。檢查前 N 檔的深度",
@@ -719,10 +719,10 @@
"delaySecondsPlaceholder": "默認 0(立即跟單)",
"filterConditions": "過濾條件(可選)",
"minOrderDepth": "最小訂單深度 (USDC)",
"minOrderDepthTooltip": "最小訂單深度(USDC金額),NULL表示不啟用此過濾。確保市場有足夠的流動性",
"minOrderDepthTooltip": "檢查訂單簿的總訂單金額(買盤+賣盤),確保市場有足夠的流動性。不填寫則不啟用此過濾",
"minOrderDepthPlaceholder": "例如:100(可選,不填寫表示不啟用)",
"maxSpread": "最大價差(絕對價格)",
"maxSpreadTooltip": "最大價差(絕對價格)NULL表示不啟用此過濾。避免在價差過大的市場跟單",
"maxSpreadTooltip": "最大價差(絕對價格)。避免在價差過大的市場跟單。不填寫則不啟用此過濾",
"maxSpreadPlaceholder": "例如:0.05(5美分,可選,不填寫表示不啟用)",
"minOrderbookDepth": "最小訂單簿深度 (USDC)",
"minOrderbookDepthTooltip": "最小訂單簿深度(USDC金額),NULL表示不啟用此過濾。檢查前 N 檔的深度",
@@ -794,10 +794,10 @@
"delaySecondsPlaceholder": "默認 0(立即跟單)",
"filterConditions": "過濾條件(可選)",
"minOrderDepth": "最小訂單深度 (USDC)",
"minOrderDepthTooltip": "最小訂單深度(USDC金額),NULL表示不啟用此過濾",
"minOrderDepthTooltip": "檢查訂單簿的總訂單金額(買盤+賣盤),確保市場有足夠的流動性。不填寫則不啟用此過濾",
"minOrderDepthPlaceholder": "例如:100(可選,不填寫表示不啟用)",
"maxSpread": "最大價差(絕對價格)",
"maxSpreadTooltip": "最大價差(絕對價格)NULL表示不啟用此過濾",
"maxSpreadTooltip": "最大價差(絕對價格)。避免在價差過大的市場跟單。不填寫則不啟用此過濾",
"maxSpreadPlaceholder": "例如:0.05(5美分,可選,不填寫表示不啟用)",
"minOrderbookDepth": "最小訂單簿深度 (USDC)",
"minOrderbookDepthTooltip": "最小訂單簿深度(USDC金額),NULL表示不啟用此過濾",
@@ -856,6 +856,8 @@
"copyTradingList": {
"title": "跟單配置管理",
"addCopyTrading": "新增跟單",
"configName": "配置名",
"configNameNotProvided": "未提供",
"wallet": "錢包",
"account": "賬戶",
"template": "模板",
@@ -868,10 +870,11 @@
"totalPnl": "總盈虧",
"statistics": "統計",
"orders": "訂單",
"matchedOrders": "已成交訂單",
"ordersButton": "訂單",
"viewStatistics": "查看統計",
"buyOrders": "買入訂單",
"sellOrders": "賣出訂單",
"matchedOrders": "匹配關係",
"filteredOrders": "被過濾訂單",
"filterWallet": "篩選錢包",
"filterTemplate": "篩選模板",
@@ -966,5 +969,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": "最小盈虧"
}
}
+99 -76
View File
@@ -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) => (
<Space>
<span style={{ fontFamily: 'monospace', fontSize: '12px' }}>{text}</span>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopy(text)}
title={t('accountList.walletAddress')}
/>
</Space>
)
render: (text: string) => {
const formatted = text ? `${text.slice(0, 6)}...${text.slice(-4)}` : '-'
return (
<Space>
<span style={{ fontFamily: 'monospace', fontSize: '12px' }}>{formatted}</span>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={(e) => {
e.stopPropagation()
handleCopy(text)
}}
title={t('accountList.walletAddress')}
/>
</Space>
)
}
},
{
title: t('accountList.proxyAddress'),
dataIndex: 'proxyAddress',
key: 'proxyAddress',
render: (address: string) => (
<Space>
<span style={{ fontFamily: 'monospace', fontSize: '12px' }}>{address}</span>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopy(address)}
title={t('accountList.proxyAddress')}
/>
</Space>
)
},
{
title: t('accountList.apiCredentials'),
key: 'apiCredentials',
render: (_: any, record: Account) => {
const allConfigured = record.apiKeyConfigured && record.apiSecretConfigured && record.apiPassphraseConfigured
const partialConfigured = record.apiKeyConfigured || record.apiSecretConfigured || record.apiPassphraseConfigured
render: (address: string) => {
const formatted = address ? `${address.slice(0, 6)}...${address.slice(-4)}` : '-'
return (
<Tag color={allConfigured ? 'success' : partialConfigured ? 'warning' : 'default'}>
{allConfigured ? t('accountList.fullConfig') : partialConfigured ? t('accountList.partialConfig') : t('accountList.notConfigured')}
</Tag>
<Space>
<span style={{ fontFamily: 'monospace', fontSize: '12px' }}>{formatted}</span>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={(e) => {
e.stopPropagation()
handleCopy(address)
}}
title={t('accountList.proxyAddress')}
/>
</Space>
)
}
},
@@ -266,17 +308,6 @@ const AccountList: React.FC = () => {
return balance && balance !== '-' && typeof balance === 'string' ? `${formatUSDC(balance)} USDC` : '-'
}
},
{
title: t('accountList.activeOrders'),
dataIndex: 'activeOrders',
key: 'activeOrders',
render: (_: any, record: Account) => {
if (record.activeOrders !== undefined && record.activeOrders !== null) {
return <Tag color={record.activeOrders > 0 ? 'orange' : 'default'}>{record.activeOrders}</Tag>
}
return <span style={{ color: '#999' }}>-</span>
}
},
{
title: t('accountList.action'),
key: 'action',
@@ -323,9 +354,6 @@ const AccountList: React.FC = () => {
title: t('accountList.accountName'),
key: 'info',
render: (_: any, record: Account) => {
const allConfigured = record.apiKeyConfigured && record.apiSecretConfigured && record.apiPassphraseConfigured
const partialConfigured = record.apiKeyConfigured || record.apiSecretConfigured || record.apiPassphraseConfigured
return (
<div style={{ padding: '8px 0' }}>
<div style={{
@@ -344,31 +372,32 @@ const AccountList: React.FC = () => {
lineHeight: '1.4'
}}>
<div style={{ marginBottom: '4px' }}>
<strong>{t('accountList.walletAddress')}:</strong> {record.walletAddress}
<strong>{t('accountList.walletAddress')}:</strong> {record.walletAddress ? `${record.walletAddress.slice(0, 6)}...${record.walletAddress.slice(-4)}` : '-'}
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopy(record.walletAddress)}
onClick={(e) => {
e.stopPropagation()
handleCopy(record.walletAddress)
}}
style={{ marginLeft: '4px', padding: '0 4px' }}
/>
</div>
<div>
<strong>{t('accountList.proxyAddress')}:</strong> {record.proxyAddress}
<strong>{t('accountList.proxyAddress')}:</strong> {record.proxyAddress ? `${record.proxyAddress.slice(0, 6)}...${record.proxyAddress.slice(-4)}` : '-'}
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopy(record.proxyAddress)}
onClick={(e) => {
e.stopPropagation()
handleCopy(record.proxyAddress)
}}
style={{ marginLeft: '4px', padding: '0 4px' }}
/>
</div>
</div>
<div style={{ marginBottom: '8px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
<Tag color={allConfigured ? 'success' : partialConfigured ? 'warning' : 'default'} style={{ margin: 0 }}>
{allConfigured ? t('accountList.fullConfig') : partialConfigured ? t('accountList.partialConfig') : t('accountList.notConfigured')}
</Tag>
</div>
<div style={{
fontSize: '14px',
fontWeight: '500',
@@ -391,18 +420,6 @@ const AccountList: React.FC = () => {
{t('accountList.available')}: {formatUSDC(balanceMap[record.id].available)} USDC | {t('accountList.position')}: {formatUSDC(balanceMap[record.id].position)} USDC
</div>
)}
{(record.activeOrders !== undefined && record.activeOrders !== null) && (
<div style={{
fontSize: '12px',
color: '#666',
marginTop: '4px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
{t('accountList.activeOrders')}: <Tag color={record.activeOrders > 0 ? 'orange' : 'default'} style={{ margin: 0 }}>{record.activeOrders}</Tag>
</div>
)}
</div>
)
}
@@ -598,7 +615,10 @@ const AccountList: React.FC = () => {
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopy(detailAccount.walletAddress || '')}
onClick={(e) => {
e.stopPropagation()
handleCopy(detailAccount.walletAddress || '')
}}
title={t('accountList.walletAddress')}
/>
</Space>
@@ -618,7 +638,10 @@ const AccountList: React.FC = () => {
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopy(detailAccount.proxyAddress || '')}
onClick={(e) => {
e.stopPropagation()
handleCopy(detailAccount.proxyAddress || '')
}}
title={t('accountList.proxyAddress')}
/>
</Space>
+2 -18
View File
@@ -84,7 +84,6 @@ const CopyTradingAdd: React.FC = () => {
supportSell: template.supportSell,
minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined,
maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined,
minOrderbookDepth: template.minOrderbookDepth ? parseFloat(template.minOrderbookDepth) : undefined,
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined
})
@@ -133,7 +132,6 @@ const CopyTradingAdd: React.FC = () => {
supportSell: values.supportSell !== false,
minOrderDepth: values.minOrderDepth?.toString(),
maxSpread: values.maxSpread?.toString(),
minOrderbookDepth: values.minOrderbookDepth?.toString(),
minPrice: values.minPrice?.toString(),
maxPrice: values.maxPrice?.toString(),
configName: values.configName?.trim(),
@@ -412,7 +410,7 @@ const CopyTradingAdd: React.FC = () => {
<Form.Item
label={t('copyTradingAdd.minOrderDepth') || '最小订单深度 (USDC)'}
name="minOrderDepth"
tooltip={t('copyTradingAdd.minOrderDepthTooltip') || '最小订单深度(USDC金额),NULL表示不启用此过滤。确保市场有足够的流动性'}
tooltip={t('copyTradingAdd.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
>
<InputNumber
min={0}
@@ -426,7 +424,7 @@ const CopyTradingAdd: React.FC = () => {
<Form.Item
label={t('copyTradingAdd.maxSpread') || '最大价差(绝对价格)'}
name="maxSpread"
tooltip={t('copyTradingAdd.maxSpreadTooltip') || '最大价差(绝对价格)NULL表示不启用此过滤。避免在价差过大的市场跟单'}
tooltip={t('copyTradingAdd.maxSpreadTooltip') || '最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤'}
>
<InputNumber
min={0}
@@ -437,20 +435,6 @@ const CopyTradingAdd: React.FC = () => {
/>
</Form.Item>
<Form.Item
label={t('copyTradingAdd.minOrderbookDepth') || '最小订单簿深度 (USDC)'}
name="minOrderbookDepth"
tooltip={t('copyTradingAdd.minOrderbookDepthTooltip') || '最小订单簿深度(USDC金额),NULL表示不启用此过滤。检查前 N 档的深度'}
>
<InputNumber
min={0}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingAdd.minOrderbookDepthPlaceholder') || '例如:50(可选,不填写表示不启用)'}
/>
</Form.Item>
<Divider>{t('copyTradingAdd.priceRangeFilter') || '价格区间过滤'}</Divider>
<Form.Item
+2 -18
View File
@@ -56,7 +56,6 @@ const CopyTradingEdit: React.FC = () => {
supportSell: found.supportSell,
minOrderDepth: found.minOrderDepth ? parseFloat(found.minOrderDepth) : undefined,
maxSpread: found.maxSpread ? parseFloat(found.maxSpread) : undefined,
minOrderbookDepth: found.minOrderbookDepth ? parseFloat(found.minOrderbookDepth) : undefined,
minPrice: found.minPrice ? parseFloat(found.minPrice) : undefined,
maxPrice: found.maxPrice ? parseFloat(found.maxPrice) : undefined,
configName: found.configName || '',
@@ -122,7 +121,6 @@ const CopyTradingEdit: React.FC = () => {
supportSell: values.supportSell,
minOrderDepth: values.minOrderDepth?.toString(),
maxSpread: values.maxSpread?.toString(),
minOrderbookDepth: values.minOrderbookDepth?.toString(),
minPrice: values.minPrice?.toString(),
maxPrice: values.maxPrice?.toString(),
configName: values.configName?.trim() || undefined,
@@ -381,7 +379,7 @@ const CopyTradingEdit: React.FC = () => {
<Form.Item
label={t('copyTradingEdit.minOrderDepth') || '最小订单深度 (USDC)'}
name="minOrderDepth"
tooltip={t('copyTradingEdit.minOrderDepthTooltip') || '最小订单深度(USDC金额),NULL表示不启用此过滤'}
tooltip={t('copyTradingEdit.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
>
<InputNumber
min={0}
@@ -395,7 +393,7 @@ const CopyTradingEdit: React.FC = () => {
<Form.Item
label={t('copyTradingEdit.maxSpread') || '最大价差(绝对价格)'}
name="maxSpread"
tooltip={t('copyTradingEdit.maxSpreadTooltip') || '最大价差(绝对价格)NULL表示不启用此过滤'}
tooltip={t('copyTradingEdit.maxSpreadTooltip') || '最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤'}
>
<InputNumber
min={0}
@@ -406,20 +404,6 @@ const CopyTradingEdit: React.FC = () => {
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.minOrderbookDepth') || '最小订单簿深度 (USDC)'}
name="minOrderbookDepth"
tooltip={t('copyTradingEdit.minOrderbookDepthTooltip') || '最小订单簿深度(USDC金额),NULL表示不启用此过滤'}
>
<InputNumber
min={0}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.minOrderbookDepthPlaceholder') || '例如:50(可选,不填写表示不启用)'}
/>
</Form.Item>
<Divider>{t('copyTradingEdit.priceRangeFilter') || '价格区间过滤'}</Divider>
<Form.Item
+77 -52
View File
@@ -9,6 +9,10 @@ import { useAccountStore } from '../store/accountStore'
import type { CopyTrading, Leader, CopyTradingStatistics } from '../types'
import { useMediaQuery } from 'react-responsive'
import { formatUSDC } from '../utils'
import CopyTradingOrdersModal from './CopyTradingOrders/index'
import StatisticsModal from './CopyTradingOrders/StatisticsModal'
import FilteredOrdersModal from './CopyTradingOrders/FilteredOrdersModal'
import EditModal from './CopyTradingOrders/EditModal'
const { Option } = Select
@@ -28,6 +32,17 @@ const CopyTradingList: React.FC = () => {
enabled?: boolean
}>({})
// Modal 状态
const [ordersModalOpen, setOrdersModalOpen] = useState(false)
const [ordersModalCopyTradingId, setOrdersModalCopyTradingId] = useState<string>('')
const [ordersModalTab, setOrdersModalTab] = useState<'buy' | 'sell' | 'matched'>('buy')
const [statisticsModalOpen, setStatisticsModalOpen] = useState(false)
const [statisticsModalCopyTradingId, setStatisticsModalCopyTradingId] = useState<string>('')
const [filteredOrdersModalOpen, setFilteredOrdersModalOpen] = useState(false)
const [filteredOrdersModalCopyTradingId, setFilteredOrdersModalCopyTradingId] = useState<string>('')
const [editModalOpen, setEditModalOpen] = useState(false)
const [editModalCopyTradingId, setEditModalCopyTradingId] = useState<string>('')
useEffect(() => {
fetchAccounts()
fetchLeaders()
@@ -265,47 +280,24 @@ const CopyTradingList: React.FC = () => {
fixed: 'right' as const,
render: (_: any, record: CopyTrading) => {
const menuItems: MenuProps['items'] = [
{
key: 'buyOrders',
label: t('copyTradingList.buyOrders') || '买入订单',
icon: <UnorderedListOutlined />,
onClick: () => navigate(`/copy-trading/orders/buy/${record.id}`)
},
{
key: 'sellOrders',
label: t('copyTradingList.sellOrders') || '卖出订单',
icon: <UnorderedListOutlined />,
onClick: () => navigate(`/copy-trading/orders/sell/${record.id}`)
},
{
key: 'matchedOrders',
label: t('copyTradingList.matchedOrders') || '匹配关系',
label: t('copyTradingList.matchedOrders') || '已成交订单',
icon: <UnorderedListOutlined />,
onClick: () => navigate(`/copy-trading/orders/matched/${record.id}`)
onClick: () => {
setOrdersModalCopyTradingId(record.id.toString())
setOrdersModalTab('buy')
setOrdersModalOpen(true)
}
},
{
key: 'filteredOrders',
label: t('copyTradingList.filteredOrders') || '已过滤订单',
icon: <UnorderedListOutlined />,
onClick: () => navigate(`/copy-trading/filtered-orders/${record.id}`)
},
{
type: 'divider'
},
{
key: 'delete',
label: (
<Popconfirm
title={t('copyTradingList.deleteConfirm') || '确定要删除这个跟单关系吗?'}
onConfirm={() => handleDelete(record.id)}
okText={t('common.confirm') || '确定'}
cancelText={t('common.cancel') || '取消'}
onCancel={(e) => e?.stopPropagation()}
>
<span style={{ color: '#ff4d4f' }}>{t('common.delete') || '删除'}</span>
</Popconfirm>
),
danger: true
onClick: () => {
setFilteredOrdersModalCopyTradingId(record.id.toString())
setFilteredOrdersModalOpen(true)
}
}
]
@@ -317,7 +309,10 @@ const CopyTradingList: React.FC = () => {
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => navigate(`/copy-trading/edit/${record.id}`)}
onClick={() => {
setEditModalCopyTradingId(record.id.toString())
setEditModalOpen(true)
}}
>
{t('common.edit') || '编辑'}
</Button>
@@ -325,7 +320,10 @@ const CopyTradingList: React.FC = () => {
type="link"
size="small"
icon={<BarChartOutlined />}
onClick={() => navigate(`/copy-trading/statistics/${record.id}`)}
onClick={() => {
setStatisticsModalCopyTradingId(record.id.toString())
setStatisticsModalOpen(true)
}}
>
{t('copyTradingList.statistics') || '统计'}
</Button>
@@ -554,7 +552,10 @@ const CopyTradingList: React.FC = () => {
type="primary"
size="small"
icon={<EditOutlined />}
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 = () => {
<Button
size="small"
icon={<BarChartOutlined />}
onClick={() => navigate(`/copy-trading/statistics/${record.id}`)}
onClick={() => {
setStatisticsModalCopyTradingId(record.id.toString())
setStatisticsModalOpen(true)
}}
style={{ flex: 1, minWidth: '80px' }}
>
{t('copyTradingList.statistics') || '统计'}
@@ -570,29 +574,24 @@ const CopyTradingList: React.FC = () => {
<Dropdown
menu={{
items: [
{
key: 'buyOrders',
label: t('copyTradingList.buyOrders') || '买入订单',
icon: <UnorderedListOutlined />,
onClick: () => navigate(`/copy-trading/orders/buy/${record.id}`)
},
{
key: 'sellOrders',
label: t('copyTradingList.sellOrders') || '卖出订单',
icon: <UnorderedListOutlined />,
onClick: () => navigate(`/copy-trading/orders/sell/${record.id}`)
},
{
key: 'matchedOrders',
label: t('copyTradingList.matchedOrders') || '匹配关系',
label: t('copyTradingList.matchedOrders') || '已成交订单',
icon: <UnorderedListOutlined />,
onClick: () => navigate(`/copy-trading/orders/matched/${record.id}`)
onClick: () => {
setOrdersModalCopyTradingId(record.id.toString())
setOrdersModalTab('buy')
setOrdersModalOpen(true)
}
},
{
key: 'filteredOrders',
label: t('copyTradingList.filteredOrders') || '已过滤订单',
icon: <UnorderedListOutlined />,
onClick: () => navigate(`/copy-trading/filtered-orders/${record.id}`)
onClick: () => {
setFilteredOrdersModalCopyTradingId(record.id.toString())
setFilteredOrdersModalOpen(true)
}
}
]
}}
@@ -643,6 +642,32 @@ const CopyTradingList: React.FC = () => {
/>
)}
</Card>
{/* Modal 组件 */}
<CopyTradingOrdersModal
open={ordersModalOpen}
onClose={() => setOrdersModalOpen(false)}
copyTradingId={ordersModalCopyTradingId}
defaultTab={ordersModalTab}
/>
<StatisticsModal
open={statisticsModalOpen}
onClose={() => setStatisticsModalOpen(false)}
copyTradingId={statisticsModalCopyTradingId}
/>
<FilteredOrdersModal
open={filteredOrdersModalOpen}
onClose={() => setFilteredOrdersModalOpen(false)}
copyTradingId={filteredOrdersModalCopyTradingId}
/>
<EditModal
open={editModalOpen}
onClose={() => setEditModalOpen(false)}
copyTradingId={editModalCopyTradingId}
onSuccess={() => {
fetchCopyTradings()
}}
/>
</div>
)
}
@@ -0,0 +1,352 @@
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 { BuyOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types'
const { Option } = Select
interface BuyOrdersTabProps {
copyTradingId: string
}
const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [loading, setLoading] = useState(false)
const [orders, setOrders] = useState<BuyOrderInfo[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
const [filters, setFilters] = useState<{
marketId?: string
side?: string
status?: 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: 'buy',
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 BuyOrderInfo[])
setTotal(data.total || 0)
}
} catch (error: any) {
console.error('获取买入订单列表失败:', error)
} finally {
setLoading(false)
}
}
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
filled: { color: 'processing', text: t('copyTradingOrders.statusFilled') || '已完成' },
partially_matched: { color: 'warning', text: t('copyTradingOrders.statusPartiallySold') || '部分成交' },
fully_matched: { color: 'success', text: t('copyTradingOrders.statusFullySold') || '全部成交' }
}
const config = statusMap[status] || { color: 'default', text: status }
return <Tag color={config.color}>{config.text}</Tag>
}
const columns = [
{
title: t('copyTradingOrders.orderId') || '订单ID',
dataIndex: 'orderId',
key: 'orderId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
},
{
title: t('copyTradingOrders.leaderTradeId') || 'Leader 交易ID',
dataIndex: 'leaderTradeId',
key: 'leaderTradeId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
},
{
title: t('copyTradingOrders.market') || '市场',
dataIndex: 'marketId',
key: 'marketId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
},
{
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 <Tag style={{ fontSize: isMobile ? 11 : 12 }}>{displaySide}</Tag>
}
},
{
title: t('copyTradingOrders.buyQuantity') || '买入数量',
dataIndex: 'quantity',
key: 'quantity',
width: isMobile ? 80 : 100,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: t('copyTradingOrders.buyPrice') || '买入价格',
dataIndex: 'price',
key: 'price',
width: isMobile ? 80 : 100,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: t('copyTradingOrders.buyAmount') || '买入金额',
key: 'amount',
width: isMobile ? 100 : 120,
render: (_: any, record: BuyOrderInfo) => {
const amount = (parseFloat(record.quantity) * parseFloat(record.price)).toString()
return (
<span style={{ fontSize: isMobile ? 12 : 14 }}>
{isMobile ? formatUSDC(amount) : `${formatUSDC(amount)} USDC`}
</span>
)
}
},
{
title: t('copyTradingOrders.matchedQuantity') || '已匹配',
dataIndex: 'matchedQuantity',
key: 'matchedQuantity',
width: isMobile ? 70 : 90,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: t('copyTradingOrders.remainingQuantity') || '剩余',
dataIndex: 'remainingQuantity',
key: 'remainingQuantity',
width: isMobile ? 70 : 90,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: t('copyTradingOrders.sellStatus') || '卖出状态',
dataIndex: 'status',
key: 'status',
width: isMobile ? 80 : 100,
render: (status: string) => getStatusTag(status)
},
{
title: t('copyTradingOrders.createdAt') || '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: isMobile ? 120 : 160,
render: (timestamp: number) => (
<span style={{ fontSize: isMobile ? 11 : 12 }}>
{isMobile
? new Date(timestamp).toLocaleDateString('zh-CN')
: new Date(timestamp).toLocaleString('zh-CN')
}
</span>
)
}
]
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<Input
placeholder={t('copyTradingOrders.filterMarketId') || '筛选市场ID'}
allowClear
style={{ width: isMobile ? '100%' : 200 }}
value={filters.marketId}
onChange={(e) => setFilters({ ...filters, marketId: e.target.value || undefined })}
/>
<Select
placeholder={t('copyTradingOrders.filterSide') || '筛选方向'}
allowClear
style={{ width: isMobile ? '100%' : 150 }}
value={filters.side}
onChange={(value) => setFilters({ ...filters, side: value || undefined })}
>
<Option value="0">YES</Option>
<Option value="1">NO</Option>
<Option value="YES">YES</Option>
<Option value="NO">NO</Option>
</Select>
<Select
placeholder={t('copyTradingOrders.filterStatus') || '筛选状态'}
allowClear
style={{ width: isMobile ? '100%' : 150 }}
value={filters.status}
onChange={(value) => setFilters({ ...filters, status: value || undefined })}
>
<Option value="filled">{t('copyTradingOrders.statusFilled') || '已完成'}</Option>
<Option value="partially_matched">{t('copyTradingOrders.statusPartiallySold') || '部分成交'}</Option>
<Option value="fully_matched">{t('copyTradingOrders.statusFullySold') || '全部成交'}</Option>
</Select>
<Button onClick={fetchOrders}>{t('common.search') || '查询'}</Button>
</div>
{isMobile ? (
<div>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
</div>
) : orders.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
{t('copyTradingOrders.noBuyOrders') || '暂无买入订单'}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{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 (
<Card
key={order.orderId}
style={{
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8'
}}
bodyStyle={{ padding: '16px' }}
>
<div style={{ marginBottom: '12px' }}>
<div style={{
fontSize: '14px',
fontWeight: 'bold',
marginBottom: '8px',
fontFamily: 'monospace'
}}>
{order.orderId.slice(0, 8)}...{order.orderId.slice(-6)}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
<Tag>{displaySide}</Tag>
{getStatusTag(order.status)}
</div>
</div>
<Divider style={{ margin: '12px 0' }} />
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.buyInfo') || '买入信息'}</div>
<div style={{ fontSize: '14px', fontWeight: '500' }}>
{t('copyTradingOrders.quantity') || '数量'}: {formatUSDC(order.quantity)} | {t('copyTradingOrders.price') || '价格'}: {formatUSDC(order.price)}
</div>
<div style={{ fontSize: '14px', fontWeight: '500', marginTop: '4px' }}>
{t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.matchInfo') || '匹配信息'}</div>
<div style={{ fontSize: '13px', color: '#333' }}>
{t('copyTradingOrders.matched') || '已匹配'}: {formatUSDC(order.matchedQuantity)} | {t('copyTradingOrders.remaining') || '剩余'}: {formatUSDC(order.remainingQuantity)}
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.leaderTradeId') || 'Leader 交易ID'}</div>
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
{order.leaderTradeId.slice(0, 8)}...{order.leaderTradeId.slice(-6)}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.marketId') || '市场ID'}</div>
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
{order.marketId.slice(0, 8)}...{order.marketId.slice(-6)}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#999' }}>
{t('copyTradingOrders.createdAt') || '创建时间'}: {formattedDate}
</div>
</div>
</Card>
)
})}
</div>
)}
</div>
) : (
<Table
columns={columns}
dataSource={orders}
rowKey="orderId"
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)
}
}}
/>
)}
</div>
)
}
export default BuyOrdersTab
@@ -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<EditModalProps> = ({
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<CopyTrading | null>(null)
const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO')
const [originalEnabled, setOriginalEnabled] = useState<boolean>(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 (
<Modal
title={t('copyTradingEdit.title') || '编辑跟单配置'}
open={open}
onCancel={onClose}
footer={null}
width="90%"
style={{ top: 20 }}
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 100px)', overflow: 'auto' }}
>
{fetching ? (
<div style={{ textAlign: 'center', padding: '50px' }}>
<Spin size="large" />
</div>
) : !copyTrading ? (
<div style={{ textAlign: 'center', padding: '50px' }}>
<p>{t('copyTradingEdit.fetchFailed') || '跟单配置不存在'}</p>
</div>
) : (
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
>
<Form.Item
label={t('copyTradingEdit.configName') || '配置名'}
name="configName"
rules={[
{ required: true, message: t('copyTradingEdit.configNameRequired') || '请输入配置名' },
{ whitespace: true, message: t('copyTradingEdit.configNameRequired') || '配置名不能为空' }
]}
tooltip={t('copyTradingEdit.configNameTooltip') || '为跟单配置设置一个名称,便于识别和管理'}
>
<Input
placeholder={t('copyTradingEdit.configNamePlaceholder') || '例如:跟单配置1'}
maxLength={255}
/>
</Form.Item>
<Form.Item
label={t('copyTradingAdd.selectWallet') || t('copyTradingEdit.selectWallet') || '钱包'}
name="accountId"
>
<Select disabled>
<Option value={copyTrading.accountId}>
{copyTrading.accountName || `账户 ${copyTrading.accountId}`} ({copyTrading.walletAddress.slice(0, 6)}...{copyTrading.walletAddress.slice(-4)})
</Option>
</Select>
</Form.Item>
<Form.Item
label={t('copyTradingAdd.selectLeader') || t('copyTradingEdit.selectLeader') || 'Leader'}
name="leaderId"
>
<Select disabled>
<Option value={copyTrading.leaderId}>
{copyTrading.leaderName || `Leader ${copyTrading.leaderId}`} ({copyTrading.leaderAddress.slice(0, 6)}...{copyTrading.leaderAddress.slice(-4)})
</Option>
</Select>
</Form.Item>
<Divider>{t('copyTradingEdit.basicConfig') || '基础配置'}</Divider>
<Form.Item
label={t('copyTradingEdit.copyMode') || '跟单金额模式'}
name="copyMode"
tooltip={t('copyTradingEdit.copyModeTooltip') || '选择跟单金额的计算方式'}
rules={[{ required: true }]}
>
<Radio.Group onChange={(e) => handleCopyModeChange(e.target.value)}>
<Radio value="RATIO">{t('copyTradingEdit.ratioMode') || '比例模式'}</Radio>
<Radio value="FIXED">{t('copyTradingEdit.fixedAmountMode') || '固定金额模式'}</Radio>
</Radio.Group>
</Form.Item>
{copyMode === 'RATIO' && (
<Form.Item
label={t('copyTradingEdit.copyRatio') || '跟单比例'}
name="copyRatio"
tooltip={t('copyTradingEdit.copyRatioTooltip') || '跟单比例表示跟单金额相对于 Leader 订单金额的百分比'}
>
<InputNumber
min={10}
max={1000}
step={1}
precision={0}
style={{ width: '100%' }}
addonAfter="%"
placeholder={t('copyTradingEdit.copyRatioPlaceholder') || '例如:100 表示 100%1:1 跟单)'}
/>
</Form.Item>
)}
{copyMode === 'FIXED' && (
<Form.Item
label={t('copyTradingEdit.fixedAmount') || '固定跟单金额 (USDC)'}
name="fixedAmount"
rules={[
{ required: true, message: t('copyTradingEdit.fixedAmountRequired') || '请输入固定跟单金额' },
{
validator: (_, value) => {
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()
}
}
]}
>
<InputNumber
min={1}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.fixedAmountPlaceholder') || '固定金额,不随 Leader 订单大小变化,必须 >= 1'}
/>
</Form.Item>
)}
{copyMode === 'RATIO' && (
<>
<Form.Item
label={t('copyTradingEdit.maxOrderSize') || '单笔订单最大金额 (USDC)'}
name="maxOrderSize"
tooltip={t('copyTradingEdit.maxOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最大金额上限'}
>
<InputNumber
min={0.0001}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxOrderSizePlaceholder') || '仅在比例模式下生效(可选)'}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.minOrderSize') || '单笔订单最小金额 (USDC)'}
name="minOrderSize"
tooltip={t('copyTradingEdit.minOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最小金额下限,必须 >= 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()
}
}
]}
>
<InputNumber
min={1}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.minOrderSizePlaceholder') || '仅在比例模式下生效,必须 >= 1(可选)'}
/>
</Form.Item>
</>
)}
<Form.Item
label={t('copyTradingEdit.maxDailyLoss') || '每日最大亏损限制 (USDC)'}
name="maxDailyLoss"
tooltip={t('copyTradingEdit.maxDailyLossTooltip') || '限制每日最大亏损金额,用于风险控制'}
>
<InputNumber
min={0}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxDailyLossPlaceholder') || '默认 10000 USDC(可选)'}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.maxDailyOrders') || '每日最大跟单订单数'}
name="maxDailyOrders"
tooltip={t('copyTradingEdit.maxDailyOrdersTooltip') || '限制每日最多跟单的订单数量'}
>
<InputNumber
min={1}
step={1}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxDailyOrdersPlaceholder') || '默认 100(可选)'}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.priceTolerance') || '价格容忍度 (%)'}
name="priceTolerance"
tooltip={t('copyTradingEdit.priceToleranceTooltip') || '允许跟单价格在 Leader 价格基础上的调整范围'}
>
<InputNumber
min={0}
max={100}
step={0.1}
precision={2}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.priceTolerancePlaceholder') || '默认 5%(可选)'}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.delaySeconds') || '跟单延迟 (秒)'}
name="delaySeconds"
tooltip={t('copyTradingEdit.delaySecondsTooltip') || '跟单延迟时间,0 表示立即跟单'}
>
<InputNumber
min={0}
step={1}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.delaySecondsPlaceholder') || '默认 0(立即跟单)'}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.minOrderDepth') || '最小订单深度 (USDC)'}
name="minOrderDepth"
tooltip={t('copyTradingEdit.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
>
<InputNumber
min={0}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.minOrderDepthPlaceholder') || '例如:100(可选,不填写表示不启用)'}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.maxSpread') || '最大价差(绝对价格)'}
name="maxSpread"
tooltip={t('copyTradingEdit.maxSpreadTooltip') || '最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤'}
>
<InputNumber
min={0}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxSpreadPlaceholder') || '例如:0.05(5美分,可选,不填写表示不启用)'}
/>
</Form.Item>
<Divider>{t('copyTradingEdit.priceRangeFilter') || '价格区间过滤'}</Divider>
<Form.Item
label={t('copyTradingEdit.priceRange') || '价格区间'}
name="priceRange"
tooltip={t('copyTradingEdit.priceRangeTooltip') || '配置价格区间,仅在指定价格区间内的订单才会下单。例如:0.11-0.89 表示区间在0.11和0.89之间;-0.89 表示0.89以下都可以;0.11- 表示0.11以上都可以'}
>
<Input.Group compact style={{ display: 'flex' }}>
<Form.Item name="minPrice" noStyle>
<InputNumber
min={0.01}
max={0.99}
step={0.0001}
precision={4}
style={{ width: '50%' }}
placeholder={t('copyTradingEdit.minPricePlaceholder') || '最低价(可选)'}
/>
</Form.Item>
<span style={{ display: 'inline-block', width: '20px', textAlign: 'center', lineHeight: '32px' }}>-</span>
<Form.Item name="maxPrice" noStyle>
<InputNumber
min={0.01}
max={0.99}
step={0.0001}
precision={4}
style={{ width: '50%' }}
placeholder={t('copyTradingEdit.maxPricePlaceholder') || '最高价(可选)'}
/>
</Form.Item>
</Input.Group>
</Form.Item>
<Divider>{t('copyTradingEdit.advancedSettings') || '高级设置'}</Divider>
<Form.Item
label={t('copyTradingEdit.supportSell') || '跟单卖出'}
name="supportSell"
tooltip={t('copyTradingEdit.supportSellTooltip') || '是否跟单 Leader 的卖出订单'}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('copyTradingEdit.pushFailedOrders') || '推送失败订单'}
name="pushFailedOrders"
tooltip={t('copyTradingEdit.pushFailedOrdersTooltip') || '开启后,失败的订单会推送到 Telegram'}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item>
<Space>
<Button
type="primary"
htmlType="submit"
icon={<SaveOutlined />}
loading={loading}
>
{t('copyTradingEdit.save') || '保存'}
</Button>
<Button onClick={onClose}>
{t('common.cancel') || '取消'}
</Button>
</Space>
</Form.Item>
</Form>
)}
</Modal>
)
}
export default EditModal
@@ -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<FilteredOrdersModalProps> = ({
open,
onClose,
copyTradingId
}) => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [loading, setLoading] = useState(false)
const [filteredOrders, setFilteredOrders] = useState<FilteredOrder[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [limit] = useState(20)
const [filterType, setFilterType] = useState<string | undefined>(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<string, { color: string; text: string }> = {
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 <Tag color={config.color}>{config.text}</Tag>
}
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 <span style={{ fontSize: isMobile ? 12 : 14 }}>{marketTitle}</span>
}
},
{
title: t('filteredOrdersList.side') || '方向',
dataIndex: 'side',
key: 'side',
width: isMobile ? 60 : 80,
render: (side: string) => (
<Tag color={side === 'BUY' ? 'green' : 'red'}>
{side === 'BUY' ? (t('order.buy') || '买入') : (t('order.sell') || '卖出')}
</Tag>
)
},
{
title: t('filteredOrdersList.price') || '价格',
dataIndex: 'price',
key: 'price',
width: isMobile ? 80 : 100,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: t('filteredOrdersList.size') || '数量',
dataIndex: 'size',
key: 'size',
width: isMobile ? 80 : 100,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
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) => (
<span style={{ fontSize: isMobile ? 11 : 12 }} title={text}>{text}</span>
)
},
{
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 (
<span style={{ fontSize: isMobile ? 11 : 12 }}>
{format}
</span>
)
}
}
]
return (
<Modal
title={t('copyTradingOrders.filteredOrders') || '已过滤订单'}
open={open}
onCancel={onClose}
footer={null}
width="90%"
style={{ top: 20 }}
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 100px)', overflow: 'auto' }}
>
<div style={{ marginBottom: 16 }}>
<Select
placeholder={t('filteredOrdersList.filterByType') || '按类型筛选'}
allowClear
style={{ width: isMobile ? '100%' : 200 }}
value={filterType}
onChange={(value) => setFilterType(value || undefined)}
>
<Option value="ORDER_DEPTH">{t('filteredOrdersList.filterTypes.orderDepth') || '订单深度不足'}</Option>
<Option value="SPREAD">{t('filteredOrdersList.filterTypes.spread') || '价差过大'}</Option>
<Option value="ORDERBOOK_DEPTH">{t('filteredOrdersList.filterTypes.orderbookDepth') || '订单簿深度不足'}</Option>
<Option value="PRICE_VALIDITY">{t('filteredOrdersList.filterTypes.priceValidity') || '价格不合理'}</Option>
<Option value="MARKET_STATUS">{t('filteredOrdersList.filterTypes.marketStatus') || '市场状态不可交易'}</Option>
<Option value="ORDERBOOK_ERROR">{t('filteredOrdersList.filterTypes.orderbookError') || '订单簿获取失败'}</Option>
<Option value="ORDERBOOK_EMPTY">{t('filteredOrdersList.filterTypes.orderbookEmpty') || '订单簿为空'}</Option>
<Option value="PRICE_RANGE">{t('filteredOrdersList.filterTypes.priceRange') || '价格区间不符'}</Option>
</Select>
</div>
{isMobile ? (
<div>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
</div>
) : filteredOrders.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
{t('filteredOrdersList.noFilteredOrders') || '暂无已过滤订单'}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{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 (
<Card
key={order.id}
style={{
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8'
}}
bodyStyle={{ padding: '16px' }}
>
<div style={{ marginBottom: '12px' }}>
<div style={{
fontSize: '16px',
fontWeight: 'bold',
marginBottom: '8px',
color: '#1890ff'
}}>
{marketTitle}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
<Tag color={order.side === 'BUY' ? 'green' : 'red'}>
{order.side === 'BUY' ? (t('order.buy') || '买入') : (t('order.sell') || '卖出')}
</Tag>
{getFilterTypeTag(order.filterType)}
</div>
</div>
<Divider style={{ margin: '12px 0' }} />
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('filteredOrdersList.orderDetails') || '订单详情'}</div>
<div style={{ fontSize: '13px', color: '#333' }}>
{t('filteredOrdersList.price') || '价格'}: {formatUSDC(order.price)} | {t('filteredOrdersList.size') || '数量'}: {formatUSDC(order.size)}
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('filteredOrdersList.filterReason') || '过滤原因'}</div>
<div style={{ fontSize: '12px', color: '#333' }} title={order.filterReason}>
{order.filterReason}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#999' }}>
{t('filteredOrdersList.createdAt') || '时间'}: {formattedDate}
</div>
</div>
</Card>
)
})}
</div>
)}
</div>
) : (
<Table
columns={columns}
dataSource={filteredOrders}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: limit,
total,
showSizeChanger: true,
showTotal: (total) => `${t('common.total') || '共'} ${total} ${t('common.items') || '条'}`,
onChange: (newPage) => {
setPage(newPage)
}
}}
/>
)}
</Modal>
)
}
export default FilteredOrdersModal
@@ -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<MatchedOrdersTabProps> = ({ copyTradingId }) => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [loading, setLoading] = useState(false)
const [orders, setOrders] = useState<MatchedOrderInfo[]>([])
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) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
},
{
title: t('copyTradingOrders.buyOrderId') || '买入订单ID',
dataIndex: 'buyOrderId',
key: 'buyOrderId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
},
{
title: t('copyTradingOrders.matchedQuantity') || '匹配数量',
dataIndex: 'matchedQuantity',
key: 'matchedQuantity',
width: isMobile ? 80 : 100,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: t('copyTradingOrders.buyPrice') || '买入价格',
dataIndex: 'buyPrice',
key: 'buyPrice',
width: isMobile ? 80 : 100,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: t('copyTradingOrders.sellPrice') || '卖出价格',
dataIndex: 'sellPrice',
key: 'sellPrice',
width: isMobile ? 80 : 100,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: t('copyTradingOrders.realizedPnl') || '盈亏',
dataIndex: 'realizedPnl',
key: 'realizedPnl',
width: isMobile ? 100 : 120,
render: (value: string) => (
<span style={{
color: getPnlColor(value),
fontWeight: 500,
fontSize: isMobile ? 12 : 14
}}>
{isMobile ? formatUSDC(value) : `${formatUSDC(value)} USDC`}
</span>
)
},
{
title: t('copyTradingOrders.matchedAt') || '匹配时间',
dataIndex: 'matchedAt',
key: 'matchedAt',
width: isMobile ? 120 : 160,
render: (timestamp: number) => (
<span style={{ fontSize: isMobile ? 11 : 12 }}>
{isMobile
? new Date(timestamp).toLocaleDateString('zh-CN')
: new Date(timestamp).toLocaleString('zh-CN')
}
</span>
)
}
]
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<Input
placeholder={t('copyTradingOrders.filterSellOrderId') || '筛选卖出订单ID'}
allowClear
style={{ width: isMobile ? '100%' : 200 }}
value={filters.sellOrderId}
onChange={(e) => setFilters({ ...filters, sellOrderId: e.target.value || undefined })}
/>
<Input
placeholder={t('copyTradingOrders.filterBuyOrderId') || '筛选买入订单ID'}
allowClear
style={{ width: isMobile ? '100%' : 200 }}
value={filters.buyOrderId}
onChange={(e) => setFilters({ ...filters, buyOrderId: e.target.value || undefined })}
/>
<Button onClick={fetchOrders}>{t('common.search') || '查询'}</Button>
</div>
{isMobile ? (
<div>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
</div>
) : orders.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
{t('copyTradingOrders.noMatchedOrders') || '暂无匹配关系'}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{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 (
<Card
key={`${order.sellOrderId}-${order.buyOrderId}-${order.matchedAt}`}
style={{
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8'
}}
bodyStyle={{ padding: '16px' }}
>
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.sellOrderId') || '卖出订单ID'}</div>
<div style={{
fontSize: '13px',
fontWeight: '500',
fontFamily: 'monospace',
marginBottom: '8px'
}}>
{order.sellOrderId.slice(0, 8)}...{order.sellOrderId.slice(-6)}
</div>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.buyOrderId') || '买入订单ID'}</div>
<div style={{
fontSize: '13px',
fontWeight: '500',
fontFamily: 'monospace'
}}>
{order.buyOrderId.slice(0, 8)}...{order.buyOrderId.slice(-6)}
</div>
</div>
<Divider style={{ margin: '12px 0' }} />
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.matchedQuantity') || '匹配数量'}</div>
<div style={{ fontSize: '14px', fontWeight: '500' }}>
{formatUSDC(order.matchedQuantity)}
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.priceInfo') || '价格信息'}</div>
<div style={{ fontSize: '13px', color: '#333' }}>
{t('copyTradingOrders.buy') || '买入'}: {formatUSDC(order.buyPrice)} | {t('copyTradingOrders.sell') || '卖出'}: {formatUSDC(order.sellPrice)}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.realizedPnl') || '盈亏'}</div>
<div style={{
fontSize: '16px',
fontWeight: 'bold',
color: getPnlColor(order.realizedPnl)
}}>
{formatUSDC(order.realizedPnl)} USDC
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#999' }}>
{t('copyTradingOrders.matchedAt') || '匹配时间'}: {formattedDate}
</div>
</div>
</Card>
)
})}
</div>
)}
</div>
) : (
<Table
columns={columns}
dataSource={orders}
rowKey={(record) => `${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)
}
}}
/>
)}
</div>
)
}
export default MatchedOrdersTab
@@ -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<SellOrdersTabProps> = ({ copyTradingId }) => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [loading, setLoading] = useState(false)
const [orders, setOrders] = useState<SellOrderInfo[]>([])
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) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
},
{
title: t('copyTradingOrders.leaderTradeId') || 'Leader 交易ID',
dataIndex: 'leaderTradeId',
key: 'leaderTradeId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
},
{
title: t('copyTradingOrders.market') || '市场',
dataIndex: 'marketId',
key: 'marketId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
},
{
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 <Tag style={{ fontSize: isMobile ? 11 : 12 }}>{displaySide}</Tag>
}
},
{
title: t('copyTradingOrders.sellQuantity') || '卖出数量',
dataIndex: 'quantity',
key: 'quantity',
width: isMobile ? 80 : 100,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: t('copyTradingOrders.sellPrice') || '卖出价格',
dataIndex: 'price',
key: 'price',
width: isMobile ? 80 : 100,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: t('copyTradingOrders.sellAmount') || '卖出金额',
key: 'amount',
width: isMobile ? 100 : 120,
render: (_: any, record: SellOrderInfo) => {
const amount = (parseFloat(record.quantity) * parseFloat(record.price)).toString()
return (
<span style={{ fontSize: isMobile ? 12 : 14 }}>
{isMobile ? formatUSDC(amount) : `${formatUSDC(amount)} USDC`}
</span>
)
}
},
{
title: t('copyTradingOrders.realizedPnl') || '已实现盈亏',
dataIndex: 'realizedPnl',
key: 'realizedPnl',
width: isMobile ? 100 : 120,
render: (value: string) => (
<span style={{
color: getPnlColor(value),
fontWeight: 500,
fontSize: isMobile ? 12 : 14
}}>
{isMobile ? formatUSDC(value) : `${formatUSDC(value)} USDC`}
</span>
)
},
{
title: t('copyTradingOrders.createdAt') || '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: isMobile ? 120 : 160,
render: (timestamp: number) => (
<span style={{ fontSize: isMobile ? 11 : 12 }}>
{isMobile
? new Date(timestamp).toLocaleDateString('zh-CN')
: new Date(timestamp).toLocaleString('zh-CN')
}
</span>
)
}
]
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<Input
placeholder={t('copyTradingOrders.filterMarketId') || '筛选市场ID'}
allowClear
style={{ width: isMobile ? '100%' : 200 }}
value={filters.marketId}
onChange={(e) => setFilters({ ...filters, marketId: e.target.value || undefined })}
/>
<Select
placeholder={t('copyTradingOrders.filterSide') || '筛选方向'}
allowClear
style={{ width: isMobile ? '100%' : 150 }}
value={filters.side}
onChange={(value) => setFilters({ ...filters, side: value || undefined })}
>
<Option value="0">YES</Option>
<Option value="1">NO</Option>
<Option value="YES">YES</Option>
<Option value="NO">NO</Option>
</Select>
<Button onClick={fetchOrders}>{t('common.search') || '查询'}</Button>
</div>
{isMobile ? (
<div>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
</div>
) : orders.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
{t('copyTradingOrders.noSellOrders') || '暂无卖出订单'}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{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 (
<Card
key={order.orderId}
style={{
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8'
}}
bodyStyle={{ padding: '16px' }}
>
<div style={{ marginBottom: '12px' }}>
<div style={{
fontSize: '14px',
fontWeight: 'bold',
marginBottom: '8px',
fontFamily: 'monospace'
}}>
{order.orderId.slice(0, 8)}...{order.orderId.slice(-6)}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
<Tag>{displaySide}</Tag>
</div>
</div>
<Divider style={{ margin: '12px 0' }} />
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.sellInfo') || '卖出信息'}</div>
<div style={{ fontSize: '14px', fontWeight: '500' }}>
{t('copyTradingOrders.quantity') || '数量'}: {formatUSDC(order.quantity)} | {t('copyTradingOrders.price') || '价格'}: {formatUSDC(order.price)}
</div>
<div style={{ fontSize: '14px', fontWeight: '500', marginTop: '4px' }}>
{t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.realizedPnl') || '已实现盈亏'}</div>
<div style={{
fontSize: '16px',
fontWeight: 'bold',
color: getPnlColor(order.realizedPnl)
}}>
{formatUSDC(order.realizedPnl)} USDC
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.leaderTradeId') || 'Leader 交易ID'}</div>
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
{order.leaderTradeId.slice(0, 8)}...{order.leaderTradeId.slice(-6)}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.marketId') || '市场ID'}</div>
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
{order.marketId.slice(0, 8)}...{order.marketId.slice(-6)}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#999' }}>
{t('copyTradingOrders.createdAt') || '创建时间'}: {formattedDate}
</div>
</div>
</Card>
)
})}
</div>
)}
</div>
) : (
<Table
columns={columns}
dataSource={orders}
rowKey="orderId"
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)
}
}}
/>
)}
</div>
)
}
export default SellOrdersTab
@@ -0,0 +1,215 @@
import { useEffect, useState } from 'react'
import { Modal, Row, Col, Statistic, Spin, message } 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<StatisticsModalProps> = ({
open,
onClose,
copyTradingId
}) => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [loading, setLoading] = useState(false)
const [statistics, setStatistics] = useState<CopyTradingStatistics | null>(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 ? <ArrowUpOutlined /> : <ArrowDownOutlined />
}
return (
<Modal
title={t('copyTradingOrders.statistics') || '跟单关系统计'}
open={open}
onCancel={onClose}
footer={null}
width="90%"
style={{ top: 20 }}
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 100px)', overflow: 'auto' }}
>
{loading ? (
<div style={{ textAlign: 'center', padding: '50px' }}>
<Spin size="large" />
</div>
) : !statistics ? (
<div style={{ textAlign: 'center', padding: '50px' }}>
<p>{t('copyTradingOrders.noStatistics') || '暂无统计数据'}</p>
</div>
) : isMobile ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
{t('copyTradingOrders.totalBuyOrders') || '总买入订单数'}
</div>
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333', flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
<ArrowUpOutlined style={{ color: '#1890ff', fontSize: '14px' }} />
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{statistics.totalBuyOrders}</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
{t('copyTradingOrders.totalSellOrders') || '总卖出订单数'}
</div>
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333', flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
<ArrowDownOutlined style={{ color: '#ff4d4f', fontSize: '14px' }} />
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{statistics.totalSellOrders}</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
{t('copyTradingOrders.totalBuyAmount') || '总买入金额'}
</div>
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333', flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
<ArrowUpOutlined style={{ color: '#1890ff', fontSize: '14px' }} />
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.totalBuyAmount)} USDC</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
{t('copyTradingOrders.totalSellAmount') || '总卖出金额'}
</div>
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333', flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
<ArrowDownOutlined style={{ color: '#ff4d4f', fontSize: '14px' }} />
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.totalSellAmount)} USDC</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
{t('copyTradingOrders.totalPnl') || '总盈亏'}
</div>
<div style={{ fontSize: '16px', fontWeight: 'bold', color: getPnlColor(statistics.totalPnl), flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
{getPnlIcon(statistics.totalPnl)}
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.totalPnl)} USDC</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
{t('copyTradingOrders.totalRealizedPnl') || '总已实现盈亏'}
</div>
<div style={{ fontSize: '16px', fontWeight: '500', color: getPnlColor(statistics.totalRealizedPnl), flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
{getPnlIcon(statistics.totalRealizedPnl)}
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.totalRealizedPnl)} USDC</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
{t('copyTradingOrders.totalUnrealizedPnl') || '总未实现盈亏'}
</div>
<div style={{ fontSize: '16px', fontWeight: '500', color: getPnlColor(statistics.totalUnrealizedPnl), flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
{getPnlIcon(statistics.totalUnrealizedPnl)}
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.totalUnrealizedPnl)} USDC</span>
</div>
</div>
</div>
) : (
<div>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} md={8}>
<Statistic
title={t('copyTradingOrders.totalBuyOrders') || '总买入订单数'}
value={statistics.totalBuyOrders}
prefix={<ArrowUpOutlined style={{ color: '#1890ff' }} />}
/>
</Col>
<Col xs={24} sm={12} md={8}>
<Statistic
title={t('copyTradingOrders.totalSellOrders') || '总卖出订单数'}
value={statistics.totalSellOrders}
prefix={<ArrowDownOutlined style={{ color: '#ff4d4f' }} />}
/>
</Col>
<Col xs={24} sm={12} md={8}>
<Statistic
title={t('copyTradingOrders.totalBuyAmount') || '总买入金额'}
value={formatUSDC(statistics.totalBuyAmount)}
suffix="USDC"
prefix={<ArrowUpOutlined style={{ color: '#1890ff' }} />}
/>
</Col>
<Col xs={24} sm={12} md={8}>
<Statistic
title={t('copyTradingOrders.totalSellAmount') || '总卖出金额'}
value={formatUSDC(statistics.totalSellAmount)}
suffix="USDC"
prefix={<ArrowDownOutlined style={{ color: '#ff4d4f' }} />}
/>
</Col>
<Col xs={24} sm={12} md={8}>
<Statistic
title={t('copyTradingOrders.totalPnl') || '总盈亏'}
value={formatUSDC(statistics.totalPnl)}
suffix="USDC"
valueStyle={{ color: getPnlColor(statistics.totalPnl) }}
prefix={getPnlIcon(statistics.totalPnl)}
/>
</Col>
<Col xs={24} sm={12} md={8}>
<Statistic
title={t('copyTradingOrders.totalRealizedPnl') || '总已实现盈亏'}
value={formatUSDC(statistics.totalRealizedPnl)}
suffix="USDC"
valueStyle={{ color: getPnlColor(statistics.totalRealizedPnl) }}
prefix={getPnlIcon(statistics.totalRealizedPnl)}
/>
</Col>
<Col xs={24} sm={12} md={8}>
<Statistic
title={t('copyTradingOrders.totalUnrealizedPnl') || '总未实现盈亏'}
value={formatUSDC(statistics.totalUnrealizedPnl)}
suffix="USDC"
valueStyle={{ color: getPnlColor(statistics.totalUnrealizedPnl) }}
prefix={getPnlIcon(statistics.totalUnrealizedPnl)}
/>
</Col>
</Row>
</div>
)}
</Modal>
)
}
export default StatisticsModal
@@ -0,0 +1,68 @@
import { useState, useEffect } from 'react'
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<CopyTradingOrdersModalProps> = ({
open,
onClose,
copyTradingId,
defaultTab = 'buy'
}) => {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<TabType>(defaultTab)
useEffect(() => {
if (open) {
setActiveTab(defaultTab)
}
}, [open, defaultTab])
return (
<Modal
title={t('copyTradingOrders.title') || '订单列表'}
open={open}
onCancel={onClose}
footer={null}
width="90%"
style={{ top: 20 }}
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 100px)', overflow: 'auto' }}
>
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as TabType)}
items={[
{
key: 'buy',
label: t('copyTradingOrders.buyOrders') || '买入订单',
children: <BuyOrdersTab copyTradingId={copyTradingId} />
},
{
key: 'sell',
label: t('copyTradingOrders.sellOrders') || '卖出订单',
children: <SellOrdersTab copyTradingId={copyTradingId} />
},
{
key: 'matched',
label: t('copyTradingOrders.matchedOrders') || '匹配关系',
children: <MatchedOrdersTab copyTradingId={copyTradingId} />
}
]}
/>
</Modal>
)
}
export default CopyTradingOrdersModal
+2 -15
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Form, Input, Button, Select, message, Typography, Space } from 'antd'
import { Card, Form, Input, Button, message, Typography, Space } from 'antd'
import { ArrowLeftOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import { useMediaQuery } from 'react-responsive'
@@ -8,7 +8,6 @@ import { useTranslation } from 'react-i18next'
import { isValidWalletAddress } from '../utils'
const { Title } = Typography
const { Option } = Select
const LeaderAdd: React.FC = () => {
const { t } = useTranslation()
@@ -24,8 +23,7 @@ const LeaderAdd: React.FC = () => {
leaderAddress: values.leaderAddress.trim(),
leaderName: values.leaderName?.trim() || undefined,
remark: values.remark?.trim() || undefined,
website: values.website?.trim() || undefined,
category: values.category || undefined
website: values.website?.trim() || undefined
})
if (response.data.code === 0) {
@@ -121,17 +119,6 @@ const LeaderAdd: React.FC = () => {
<Input placeholder={t('leaderAdd.websitePlaceholder') || '可选,例如:https://example.com'} />
</Form.Item>
<Form.Item
label={t('leaderAdd.category') || '分类筛选'}
name="category"
tooltip={t('leaderAdd.categoryTooltip') || '仅跟单该分类的交易,不选择则跟单所有分类(sports 或 crypto'}
>
<Select placeholder={t('leaderAdd.categoryPlaceholder') || '选择分类(可选)'} allowClear>
<Option value="sports">Sports</Option>
<Option value="crypto">Crypto</Option>
</Select>
</Form.Item>
<Form.Item>
<Space>
<Button
+3 -17
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { Card, Form, Input, Button, Select, message, Typography, Space, Spin } from 'antd'
import { Card, Form, Input, Button, message, Typography, Space, Spin } from 'antd'
import { ArrowLeftOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import { useMediaQuery } from 'react-responsive'
@@ -8,7 +8,6 @@ import { useTranslation } from 'react-i18next'
import type { Leader } from '../types'
const { Title } = Typography
const { Option } = Select
const LeaderEdit: React.FC = () => {
const { t } = useTranslation()
@@ -38,8 +37,7 @@ const LeaderEdit: React.FC = () => {
form.setFieldsValue({
leaderName: leader.leaderName || '',
remark: leader.remark || '',
website: leader.website || '',
category: leader.category || undefined
website: leader.website || ''
})
} else {
message.error(response.data.msg || t('leaderEdit.fetchFailed') || '获取 Leader 详情失败')
@@ -65,8 +63,7 @@ const LeaderEdit: React.FC = () => {
leaderId: parseInt(leaderId),
leaderName: values.leaderName?.trim() || undefined,
remark: values.remark?.trim() || undefined,
website: values.website?.trim() || undefined,
category: values.category || undefined
website: values.website?.trim() || undefined
})
if (response.data.code === 0) {
@@ -145,17 +142,6 @@ const LeaderEdit: React.FC = () => {
<Input placeholder={t('leaderEdit.websitePlaceholder') || '可选,例如:https://example.com'} />
</Form.Item>
<Form.Item
label={t('leaderEdit.category') || '分类筛选'}
name="category"
tooltip={t('leaderEdit.categoryTooltip') || '仅跟单该分类的交易,不选择则跟单所有分类(sports 或 crypto'}
>
<Select placeholder={t('leaderEdit.categoryPlaceholder') || '选择分类(可选)'} allowClear>
<Option value="sports">Sports</Option>
<Option value="crypto">Crypto</Option>
</Select>
</Form.Item>
<Form.Item>
<Space>
<Button
+3 -37
View File
@@ -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 } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, LinkOutlined, GlobalOutlined } from '@ant-design/icons'
import { PlusOutlined, EditOutlined, DeleteOutlined, GlobalOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { apiService } from '../services/api'
import type { Leader } from '../types'
@@ -67,14 +67,6 @@ const LeaderList: React.FC = () => {
</span>
)
},
{
title: t('leaderList.category') || '分类',
dataIndex: 'category',
key: 'category',
render: (category: string | undefined) => category ? (
<Tag color={category === 'sports' ? 'blue' : 'green'}>{category}</Tag>
) : <Tag>{t('leaderList.all') || '全部'}</Tag>
},
{
title: t('leaderList.remark') || '备注',
dataIndex: 'remark',
@@ -233,37 +225,11 @@ const LeaderList: React.FC = () => {
</div>
)}
{/* 网站 */}
{leader.website && (
<div style={{ marginBottom: '12px' }}>
<Text type="secondary" style={{ fontSize: '12px' }}>
{t('leaderList.website') || '网站'}
</Text>
<a
href={leader.website}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: '12px', marginLeft: '4px' }}
>
<LinkOutlined /> {leader.website}
</a>
</div>
)}
<Divider style={{ margin: '12px 0' }} />
{/* 分类和跟单关系数 */}
{/* 跟单关系数 */}
<div style={{ marginBottom: '12px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
{leader.category ? (
<Tag color={leader.category === 'sports' ? 'blue' : 'green'}>
{leader.category}
</Tag>
) : (
<Tag>{t('leaderList.all') || '全部'}</Tag>
)}
<Tag>{t('leaderList.copyTradingRelations', { count: leader.copyTradingCount }) || `${leader.copyTradingCount} 个跟单关系`}</Tag>
</div>
<Tag>{t('leaderList.copyTradingRelations', { count: leader.copyTradingCount }) || `${leader.copyTradingCount} 个跟单关系`}</Tag>
</div>
{/* 创建时间 */}
+14 -11
View File
@@ -348,10 +348,11 @@ const SystemSettings: React.FC = () => {
if (response.data.code === 0 && response.data.data) {
const config = response.data.data
setSystemConfig(config)
// 将已配置的值填充到输入框中
relayerForm.setFieldsValue({
builderApiKey: '',
builderSecret: '',
builderPassphrase: '',
builderApiKey: config.builderApiKeyDisplay || '',
builderSecret: config.builderSecretDisplay || '',
builderPassphrase: config.builderPassphraseDisplay || '',
})
autoRedeemForm.setFieldsValue({
autoRedeemEnabled: config.autoRedeemEnabled
@@ -685,30 +686,32 @@ const SystemSettings: React.FC = () => {
<Form.Item
label={t('builderApiKey.apiKey')}
name="builderApiKey"
help={systemConfig?.builderApiKeyConfigured ? t('builderApiKey.apiKeyHelp') : t('builderApiKey.apiKeyPlaceholder')}
>
<Input
placeholder={systemConfig?.builderApiKeyConfigured ? t('builderApiKey.apiKeyHelp') : t('builderApiKey.apiKeyPlaceholder')}
placeholder={t('builderApiKey.apiKeyPlaceholder')}
style={{ fontFamily: 'monospace' }}
/>
</Form.Item>
<Form.Item
label={t('builderApiKey.secret')}
name="builderSecret"
help={systemConfig?.builderSecretConfigured ? t('builderApiKey.secretHelp') : t('builderApiKey.secretPlaceholder')}
>
<Input
placeholder={systemConfig?.builderSecretConfigured ? t('builderApiKey.secretHelp') : t('builderApiKey.secretPlaceholder')}
<Input.Password
placeholder={t('builderApiKey.secretPlaceholder')}
style={{ fontFamily: 'monospace' }}
iconRender={(visible) => (visible ? <span>👁</span> : <span>👁🗨</span>)}
/>
</Form.Item>
<Form.Item
label={t('builderApiKey.passphrase')}
name="builderPassphrase"
help={systemConfig?.builderPassphraseConfigured ? t('builderApiKey.passphraseHelp') : t('builderApiKey.passphrasePlaceholder')}
>
<Input
placeholder={systemConfig?.builderPassphraseConfigured ? t('builderApiKey.passphraseHelp') : t('builderApiKey.passphrasePlaceholder')}
<Input.Password
placeholder={t('builderApiKey.passphrasePlaceholder')}
style={{ fontFamily: 'monospace' }}
iconRender={(visible) => (visible ? <span>👁</span> : <span>👁🗨</span>)}
/>
</Form.Item>
+2 -17
View File
@@ -54,7 +54,6 @@ const TemplateAdd: React.FC = () => {
supportSell: values.supportSell !== false,
minOrderDepth: values.minOrderDepth?.toString(),
maxSpread: values.maxSpread?.toString(),
minOrderbookDepth: values.minOrderbookDepth?.toString(),
minPrice: values.minPrice?.toString(),
maxPrice: values.maxPrice?.toString()
})
@@ -258,7 +257,7 @@ const TemplateAdd: React.FC = () => {
<Form.Item
label={t('templateAdd.minOrderDepth') || '最小订单深度 (USDC)'}
name="minOrderDepth"
tooltip={t('templateAdd.minOrderDepthTooltip') || '最小订单深度(USDC金额),NULL表示不启用此过滤。确保市场有足够的流动性'}
tooltip={t('templateAdd.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
>
<InputNumber
min={0}
@@ -272,7 +271,7 @@ const TemplateAdd: React.FC = () => {
<Form.Item
label={t('templateAdd.maxSpread') || '最大价差(绝对价格)'}
name="maxSpread"
tooltip={t('templateAdd.maxSpreadTooltip') || '最大价差(绝对价格)NULL表示不启用此过滤。避免在价差过大的市场跟单'}
tooltip={t('templateAdd.maxSpreadTooltip') || '最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤'}
>
<InputNumber
min={0}
@@ -283,20 +282,6 @@ const TemplateAdd: React.FC = () => {
/>
</Form.Item>
<Form.Item
label={t('templateAdd.minOrderbookDepth') || '最小订单簿深度 (USDC)'}
name="minOrderbookDepth"
tooltip={t('templateAdd.minOrderbookDepthTooltip') || '最小订单簿深度(USDC金额),NULL表示不启用此过滤。检查前 N 档的深度'}
>
<InputNumber
min={0}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('templateAdd.minOrderbookDepthPlaceholder') || '例如:50(可选,不填写表示不启用)'}
/>
</Form.Item>
<Divider>{t('templateAdd.priceRangeFilter') || '价格区间过滤'}</Divider>
<Form.Item
+2 -18
View File
@@ -40,7 +40,6 @@ const TemplateEdit: React.FC = () => {
priceTolerance: parseFloat(template.priceTolerance),
minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined,
maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined,
minOrderbookDepth: template.minOrderbookDepth ? parseFloat(template.minOrderbookDepth) : undefined,
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined
})
@@ -99,7 +98,6 @@ const TemplateEdit: React.FC = () => {
supportSell: values.supportSell,
minOrderDepth: values.minOrderDepth?.toString(),
maxSpread: values.maxSpread?.toString(),
minOrderbookDepth: values.minOrderbookDepth?.toString(),
minPrice: values.minPrice?.toString(),
maxPrice: values.maxPrice?.toString()
})
@@ -295,7 +293,7 @@ const TemplateEdit: React.FC = () => {
<Form.Item
label={t('templateEdit.minOrderDepth') || '最小订单深度 (USDC)'}
name="minOrderDepth"
tooltip={t('templateEdit.minOrderDepthTooltip') || '最小订单深度(USDC金额),NULL表示不启用此过滤。确保市场有足够的流动性'}
tooltip={t('templateEdit.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
>
<InputNumber
min={0}
@@ -309,7 +307,7 @@ const TemplateEdit: React.FC = () => {
<Form.Item
label={t('templateEdit.maxSpread') || '最大价差(绝对价格)'}
name="maxSpread"
tooltip={t('templateEdit.maxSpreadTooltip') || '最大价差(绝对价格)NULL表示不启用此过滤。避免在价差过大的市场跟单'}
tooltip={t('templateEdit.maxSpreadTooltip') || '最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤'}
>
<InputNumber
min={0}
@@ -320,20 +318,6 @@ const TemplateEdit: React.FC = () => {
/>
</Form.Item>
<Form.Item
label={t('templateEdit.minOrderbookDepth') || '最小订单簿深度 (USDC)'}
name="minOrderbookDepth"
tooltip={t('templateEdit.minOrderbookDepthTooltip') || '最小订单簿深度(USDC金额),NULL表示不启用此过滤。检查前 N 档的深度'}
>
<InputNumber
min={0}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('templateEdit.minOrderbookDepthPlaceholder') || '例如:50(可选,不填写表示不启用)'}
/>
</Form.Item>
<Divider>{t('templateEdit.priceRangeFilter') || '价格区间过滤'}</Divider>
<Form.Item
+2 -18
View File
@@ -74,7 +74,6 @@ const TemplateList: React.FC = () => {
supportSell: template.supportSell,
minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined,
maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined,
minOrderbookDepth: template.minOrderbookDepth ? parseFloat(template.minOrderbookDepth) : undefined,
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined
})
@@ -122,7 +121,6 @@ const TemplateList: React.FC = () => {
supportSell: values.supportSell !== false,
minOrderDepth: values.minOrderDepth?.toString(),
maxSpread: values.maxSpread?.toString(),
minOrderbookDepth: values.minOrderbookDepth?.toString(),
minPrice: values.minPrice?.toString(),
maxPrice: values.maxPrice?.toString()
})
@@ -613,7 +611,7 @@ const TemplateList: React.FC = () => {
<Form.Item
label="最小订单深度 (USDC)"
name="minOrderDepth"
tooltip="最小订单深度(USDC金额),NULL表示不启用此过滤。确保市场有足够的流动性"
tooltip="检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤"
>
<InputNumber
min={0}
@@ -627,7 +625,7 @@ const TemplateList: React.FC = () => {
<Form.Item
label="最大价差(绝对价格)"
name="maxSpread"
tooltip="最大价差(绝对价格)NULL表示不启用此过滤。避免在价差过大的市场跟单"
tooltip="最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤"
>
<InputNumber
min={0}
@@ -638,20 +636,6 @@ const TemplateList: React.FC = () => {
/>
</Form.Item>
<Form.Item
label="最小订单簿深度 (USDC)"
name="minOrderbookDepth"
tooltip="最小订单簿深度(USDC金额),NULL表示不启用此过滤。检查前 N 档的深度"
>
<InputNumber
min={0}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder="例如:50(可选,不填写表示不启用)"
/>
</Form.Item>
<Divider></Divider>
<Form.Item
+38 -38
View File
@@ -145,31 +145,31 @@ export const apiService = {
*
*/
list: () =>
apiClient.post<ApiResponse<any[]>>('/users/list', {}),
apiClient.post<ApiResponse<any[]>>('/system/users/list', {}),
/**
*
*/
create: (data: { username: string; password: string }) =>
apiClient.post<ApiResponse<any>>('/users/create', data),
apiClient.post<ApiResponse<any>>('/system/users/create', data),
/**
*
*/
updatePassword: (data: { userId: number; newPassword: string }) =>
apiClient.post<ApiResponse<void>>('/users/update-password', data),
apiClient.post<ApiResponse<void>>('/system/users/update-password', data),
/**
*
*/
delete: (data: { userId: number }) =>
apiClient.post<ApiResponse<void>>('/users/delete', data),
apiClient.post<ApiResponse<void>>('/system/users/delete', data),
/**
*
*/
updateOwnPassword: (data: { newPassword: string }) =>
apiClient.post<ApiResponse<void>>('/users/update-own-password', data)
apiClient.post<ApiResponse<void>>('/system/users/update-own-password', data)
},
/**
@@ -203,61 +203,61 @@ export const apiService = {
*
*/
import: (data: any) =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/import', data),
apiClient.post<ApiResponse<any>>('/accounts/import', data),
/**
*
*/
update: (data: any) =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/update', data),
apiClient.post<ApiResponse<any>>('/accounts/update', data),
/**
*
*/
delete: (data: { accountId: number }) =>
apiClient.post<ApiResponse<void>>('/copy-trading/accounts/delete', data),
apiClient.post<ApiResponse<void>>('/accounts/delete', data),
/**
*
*/
list: () =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/list', {}),
apiClient.post<ApiResponse<any>>('/accounts/list', {}),
/**
*
*/
detail: (data: { accountId?: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/detail', data),
apiClient.post<ApiResponse<any>>('/accounts/detail', data),
/**
*
*/
balance: (data: { accountId?: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/balance', data),
apiClient.post<ApiResponse<any>>('/accounts/balance', data),
/**
*
*/
positionsList: () =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/positions/list', {}),
apiClient.post<ApiResponse<any>>('/accounts/positions/list', {}),
/**
*
*/
sellPosition: (data: any) =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/positions/sell', data),
apiClient.post<ApiResponse<any>>('/accounts/positions/sell', data),
/**
*
*/
getRedeemableSummary: (data: { accountId?: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/positions/redeemable-summary', data),
apiClient.post<ApiResponse<any>>('/accounts/positions/redeemable-summary', data),
/**
*
*/
redeemPositions: (data: any) =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/positions/redeem', data),
apiClient.post<ApiResponse<any>>('/accounts/positions/redeem', data),
},
@@ -269,13 +269,13 @@ export const apiService = {
* Gamma API
*/
getMarketPrice: (data: { marketId: string; outcomeIndex?: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/markets/price', data),
apiClient.post<ApiResponse<any>>('/markets/price', data),
/**
*
*/
getLatestPrice: (data: { tokenId: string }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/markets/latest-price', data)
apiClient.post<ApiResponse<any>>('/markets/latest-price', data)
},
/**
@@ -365,37 +365,37 @@ export const apiService = {
* 2. templateId
*/
create: (data: any) =>
apiClient.post<ApiResponse<any>>('/copy-trading/create', data),
apiClient.post<ApiResponse<any>>('/copy-trading/configs/create', data),
/**
*
*/
update: (data: any) =>
apiClient.post<ApiResponse<any>>('/copy-trading/update', data),
apiClient.post<ApiResponse<any>>('/copy-trading/configs/update', data),
/**
*
*/
list: (data: { accountId?: number; leaderId?: number; enabled?: boolean } = {}) =>
apiClient.post<ApiResponse<any>>('/copy-trading/list', data),
apiClient.post<ApiResponse<any>>('/copy-trading/configs/list', data),
/**
*
*/
updateStatus: (data: { copyTradingId: number; enabled: boolean }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/update-status', data),
apiClient.post<ApiResponse<any>>('/copy-trading/configs/update-status', data),
/**
*
*/
delete: (data: { copyTradingId: number }) =>
apiClient.post<ApiResponse<void>>('/copy-trading/delete', data),
apiClient.post<ApiResponse<void>>('/copy-trading/configs/delete', data),
/**
*
*/
getAccountTemplates: (data: { accountId: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/account-templates', data),
apiClient.post<ApiResponse<any>>('/copy-trading/configs/account-templates', data),
/**
*
@@ -408,7 +408,7 @@ export const apiService = {
startTime?: number
endTime?: number
}) =>
apiClient.post<ApiResponse<any>>('/copy-trading/filtered-orders', data)
apiClient.post<ApiResponse<any>>('/copy-trading/configs/filtered-orders', data)
},
/**
@@ -476,13 +476,13 @@ export const apiService = {
*
*/
get: () =>
apiClient.post<ApiResponse<any>>('/proxy-config/get', {}),
apiClient.post<ApiResponse<any>>('/system/proxy/get', {}),
/**
*
*/
list: () =>
apiClient.post<ApiResponse<any[]>>('/proxy-config/list', {}),
apiClient.post<ApiResponse<any[]>>('/system/proxy/list', {}),
/**
* HTTP
@@ -494,7 +494,7 @@ export const apiService = {
username?: string
password?: string
}) =>
apiClient.post<ApiResponse<any>>('/proxy-config/http/save', data),
apiClient.post<ApiResponse<any>>('/system/proxy/http/save', data),
/**
*
@@ -504,13 +504,13 @@ export const apiService = {
success: boolean
message: string
responseTime?: number
}>>('/proxy-config/check', {}),
}>>('/system/proxy/check', {}),
/**
*
*/
delete: (data: { id: number }) =>
apiClient.post<ApiResponse<void>>('/proxy-config/delete', data),
apiClient.post<ApiResponse<void>>('/system/proxy/delete', data),
/**
* API
@@ -524,7 +524,7 @@ export const apiService = {
message: string
responseTime?: number
}>
}>>('/proxy-config/api-health-check', {})
}>>('/system/proxy/api-health-check', {})
},
/**
@@ -535,49 +535,49 @@ export const apiService = {
*
*/
list: (data?: { type?: string }) =>
apiClient.post<ApiResponse<NotificationConfig[]>>('/notifications/configs/list', data || {}),
apiClient.post<ApiResponse<NotificationConfig[]>>('/system/notifications/configs/list', data || {}),
/**
*
*/
detail: (data: { id: number }) =>
apiClient.post<ApiResponse<NotificationConfig>>('/notifications/configs/detail', data),
apiClient.post<ApiResponse<NotificationConfig>>('/system/notifications/configs/detail', data),
/**
*
*/
create: (data: NotificationConfigRequest) =>
apiClient.post<ApiResponse<NotificationConfig>>('/notifications/configs/create', data),
apiClient.post<ApiResponse<NotificationConfig>>('/system/notifications/configs/create', data),
/**
*
*/
update: (data: NotificationConfigUpdateRequest) =>
apiClient.post<ApiResponse<NotificationConfig>>('/notifications/configs/update', data),
apiClient.post<ApiResponse<NotificationConfig>>('/system/notifications/configs/update', data),
/**
*
*/
updateEnabled: (data: { id: number; enabled: boolean }) =>
apiClient.post<ApiResponse<NotificationConfig>>('/notifications/configs/update-enabled', data),
apiClient.post<ApiResponse<NotificationConfig>>('/system/notifications/configs/update-enabled', data),
/**
*
*/
delete: (data: { id: number }) =>
apiClient.post<ApiResponse<void>>('/notifications/configs/delete', data),
apiClient.post<ApiResponse<void>>('/system/notifications/configs/delete', data),
/**
*
*/
test: (data?: { message?: string }) =>
apiClient.post<ApiResponse<boolean>>('/notifications/test', data || {}),
apiClient.post<ApiResponse<boolean>>('/system/notifications/test', data || {}),
/**
* Telegram Chat IDs
*/
getTelegramChatIds: (data: { botToken: string }) =>
apiClient.post<ApiResponse<string[]>>('/notifications/telegram/get-chat-ids', data)
apiClient.post<ApiResponse<string[]>>('/system/notifications/telegram/get-chat-ids', data)
},
/**
+3 -4
View File
@@ -112,7 +112,6 @@ export interface CopyTradingTemplate {
// 过滤条件
minOrderDepth?: string
maxSpread?: string
minOrderbookDepth?: string
minPrice?: string // 最低价格(可选),NULL表示不限制最低价
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
createdAt: number
@@ -204,7 +203,6 @@ export interface CopyTrading {
// 过滤条件
minOrderDepth?: string
maxSpread?: string
minOrderbookDepth?: string
minPrice?: string // 最低价格(可选),NULL表示不限制最低价
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
// 新增配置字段
@@ -248,7 +246,6 @@ export interface CopyTradingCreateRequest {
// 过滤条件
minOrderDepth?: string
maxSpread?: string
minOrderbookDepth?: string
minPrice?: string // 最低价格(可选),NULL表示不限制最低价
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
// 新增配置字段
@@ -280,7 +277,6 @@ export interface CopyTradingUpdateRequest {
// 过滤条件
minOrderDepth?: string
maxSpread?: string
minOrderbookDepth?: string
minPrice?: string // 最低价格(可选),NULL表示不限制最低价
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
// 新增配置字段
@@ -774,6 +770,9 @@ export interface SystemConfig {
builderApiKeyConfigured: boolean
builderSecretConfigured: boolean
builderPassphraseConfigured: boolean
builderApiKeyDisplay?: string // Builder API Key 显示值(部分显示)
builderSecretDisplay?: string // Builder Secret 显示值(部分显示)
builderPassphraseDisplay?: string // Builder Passphrase 显示值(部分显示)
autoRedeemEnabled: boolean // 自动赎回(系统级别配置,默认开启)
}