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
WrBugandGitHub 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
WrBugandGitHub 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-results/
*.test.log *.test.log
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
# Misc # Misc
*.bak *.bak
*.backup *.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 # PolyHermes
[![GitHub](https://img.shields.io/badge/GitHub-WrBug%2FPolyHermes-blue?logo=github)](https://github.com/WrBug/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) | 中文 > 🌐 **Language**: [English](README_EN.md) | 中文
@@ -414,7 +414,7 @@ cd frontend
## 🔗 相关链接 ## 🔗 相关链接
- [GitHub 仓库](https://github.com/WrBug/PolyHermes) - [GitHub 仓库](https://github.com/WrBug/PolyHermes)
- [Twitter](https://x.com/quant_tr) - [Twitter](https://x.com/polyhermes)
- [Polymarket 官网](https://polymarket.com) - [Polymarket 官网](https://polymarket.com)
- [Polymarket API 文档](https://docs.polymarket.com) - [Polymarket API 文档](https://docs.polymarket.com)
+2 -2
View File
@@ -1,7 +1,7 @@
# PolyHermes # PolyHermes
[![GitHub](https://img.shields.io/badge/GitHub-WrBug%2FPolyHermes-blue?logo=github)](https://github.com/WrBug/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) > 🌐 **Language**: English | [中文](README.md)
@@ -414,7 +414,7 @@ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file
## 🔗 Related Links ## 🔗 Related Links
- [GitHub Repository](https://github.com/WrBug/PolyHermes) - [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 Official Website](https://polymarket.com)
- [Polymarket API Documentation](https://docs.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 WORKDIR /app
# 安装必要的工具 # 安装必要的工具和时区数据
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y curl && \ apt-get install -y curl tzdata && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
# 从构建阶段复制 JAR 文件 # 从构建阶段复制 JAR 文件
@@ -41,8 +41,8 @@ EXPOSE 8000
# 健康检查 # 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ 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"] ENTRYPOINT ["java", "-jar", "app.jar"]
@@ -227,7 +227,7 @@ data class CreateOrderRequest(
@Deprecated("使用 NewOrderRequest 代替") @Deprecated("使用 NewOrderRequest 代替")
data class CreateOrdersBatchRequest( data class CreateOrdersBatchRequest(
val orders: List<CreateOrderRequest> val orders: List<NewOrderRequest>
) )
data class CancelOrdersBatchRequest( data class CancelOrdersBatchRequest(
@@ -1,6 +1,6 @@
package com.wrbug.polymarketbot.config package com.wrbug.polymarketbot.config
import com.wrbug.polymarketbot.service.ProxyConfigService import com.wrbug.polymarketbot.service.system.ProxyConfigService
import jakarta.annotation.PostConstruct import jakarta.annotation.PostConstruct
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component 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.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode 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 com.wrbug.polymarketbot.util.toSafeBigDecimal
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
@@ -15,7 +15,7 @@ import java.math.BigDecimal
* 账户管理控制器 * 账户管理控制器
*/ */
@RestController @RestController
@RequestMapping("/api/copy-trading/accounts") @RequestMapping("/api/accounts")
class AccountController( class AccountController(
private val accountService: AccountService, private val accountService: AccountService,
private val messageSource: MessageSource 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.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.AnnouncementService import com.wrbug.polymarketbot.service.announcement.AnnouncementService
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource 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.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode 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 jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource 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.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.CopyTradingService import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingService
import com.wrbug.polymarketbot.service.FilteredOrderService import com.wrbug.polymarketbot.service.copytrading.configs.FilteredOrderService
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
@@ -13,7 +13,7 @@ import org.springframework.web.bind.annotation.*
* 跟单配置管理控制器钱包-模板关联 * 跟单配置管理控制器钱包-模板关联
*/ */
@RestController @RestController
@RequestMapping("/api/copy-trading") @RequestMapping("/api/copy-trading/configs")
class CopyTradingController( class CopyTradingController(
private val copyTradingService: CopyTradingService, private val copyTradingService: CopyTradingService,
private val filteredOrderService: FilteredOrderService, 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.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode 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.slf4j.LoggerFactory
import org.springframework.context.MessageSource import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity 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.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode 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 kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource 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.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode 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.slf4j.LoggerFactory
import org.springframework.context.MessageSource import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity 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.api.LatestPriceResponse
import com.wrbug.polymarketbot.dto.* import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.AccountService import com.wrbug.polymarketbot.service.accounts.AccountService
import com.wrbug.polymarketbot.service.PolymarketClobService import com.wrbug.polymarketbot.service.common.PolymarketClobService
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource import org.springframework.context.MessageSource
@@ -16,7 +16,7 @@ import org.springframework.web.bind.annotation.*
* 提供市场相关的数据查询接口价格订单簿等 * 提供市场相关的数据查询接口价格订单簿等
*/ */
@RestController @RestController
@RequestMapping("/api/copy-trading/markets") @RequestMapping("/api/markets")
class MarketController( class MarketController(
private val accountService: AccountService, private val accountService: AccountService,
private val clobService: PolymarketClobService, 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.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
@@ -11,7 +11,7 @@ import java.util.*
* 用于 Docker 健康检查和监控 * 用于 Docker 健康检查和监控
*/ */
@RestController @RestController
@RequestMapping("/api/health") @RequestMapping("/api/system/health")
class HealthController { class HealthController {
@GetMapping @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.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.NotificationConfigService import com.wrbug.polymarketbot.service.system.NotificationConfigService
import com.wrbug.polymarketbot.service.TelegramNotificationService import com.wrbug.polymarketbot.service.system.TelegramNotificationService
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource import org.springframework.context.MessageSource
@@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.*
* 消息推送配置控制器 * 消息推送配置控制器
*/ */
@RestController @RestController
@RequestMapping("/api/notifications") @RequestMapping("/api/system/notifications")
class NotificationController( class NotificationController(
private val notificationConfigService: NotificationConfigService, private val notificationConfigService: NotificationConfigService,
private val telegramNotificationService: TelegramNotificationService, 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.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.ApiHealthCheckService import com.wrbug.polymarketbot.service.system.ApiHealthCheckService
import com.wrbug.polymarketbot.service.ProxyConfigService import com.wrbug.polymarketbot.service.system.ProxyConfigService
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
@@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.*
* 代理配置控制器 * 代理配置控制器
*/ */
@RestController @RestController
@RequestMapping("/api/proxy-config") @RequestMapping("/api/system/proxy")
class ProxyConfigController( class ProxyConfigController(
private val proxyConfigService: ProxyConfigService, private val proxyConfigService: ProxyConfigService,
private val apiHealthCheckService: ApiHealthCheckService, 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.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.SystemConfigService import com.wrbug.polymarketbot.service.system.SystemConfigService
import com.wrbug.polymarketbot.service.RelayClientService import com.wrbug.polymarketbot.service.system.RelayClientService
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource 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.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode 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 jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource import org.springframework.context.MessageSource
@@ -13,7 +13,7 @@ import org.springframework.web.bind.annotation.*
* 用户管理控制器 * 用户管理控制器
*/ */
@RestController @RestController
@RequestMapping("/api/users") @RequestMapping("/api/system/users")
class UserController( class UserController(
private val userService: UserService, private val userService: UserService,
private val messageSource: MessageSource private val messageSource: MessageSource
@@ -36,6 +36,9 @@ data class SystemConfigDto(
val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置 val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置
val builderSecretConfigured: Boolean, // Builder Secret 是否已配置 val builderSecretConfigured: Boolean, // Builder Secret 是否已配置
val builderPassphraseConfigured: Boolean, // Builder Passphrase 是否已配置 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 // 自动赎回(系统级别配置,默认开启) val autoRedeemEnabled: Boolean = true // 自动赎回(系统级别配置,默认开启)
) )
@@ -32,7 +32,6 @@ data class CopyTradingCreateRequest(
// 过滤条件 // 过滤条件
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用 val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用 val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minOrderbookDepth: String? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价 val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价 val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
// 新增配置字段 // 新增配置字段
@@ -64,7 +63,6 @@ data class CopyTradingUpdateRequest(
// 过滤条件 // 过滤条件
val minOrderDepth: String? = null, val minOrderDepth: String? = null,
val maxSpread: String? = null, val maxSpread: String? = null,
val minOrderbookDepth: String? = null,
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价 val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价 val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
// 新增配置字段 // 新增配置字段
@@ -133,7 +131,6 @@ data class CopyTradingDto(
// 过滤条件 // 过滤条件
val minOrderDepth: String?, val minOrderDepth: String?,
val maxSpread: String?, val maxSpread: String?,
val minOrderbookDepth: String?,
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价 val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价 val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
// 新增配置字段 // 新增配置字段
@@ -22,7 +22,6 @@ data class TemplateCreateRequest(
// 过滤条件 // 过滤条件
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用 val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用 val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minOrderbookDepth: String? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价 val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价 val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
) )
@@ -50,7 +49,6 @@ data class TemplateUpdateRequest(
// 过滤条件 // 过滤条件
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用 val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用 val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minOrderbookDepth: String? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价 val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价 val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
) )
@@ -85,7 +83,6 @@ data class TemplateCopyRequest(
// 过滤条件 // 过滤条件
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用 val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用 val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minOrderbookDepth: String? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价 val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价 val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
) )
@@ -120,7 +117,6 @@ data class TemplateDto(
// 过滤条件 // 过滤条件
val minOrderDepth: String?, val minOrderDepth: String?,
val maxSpread: String?, val maxSpread: String?,
val minOrderbookDepth: String?,
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价 val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价 val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
val createdAt: Long, val createdAt: Long,
@@ -78,9 +78,6 @@ data class CopyTrading(
@Column(name = "max_spread", precision = 20, scale = 8) @Column(name = "max_spread", precision = 20, scale = 8)
val maxSpread: BigDecimal? = null, // 最大价差(绝对价格),NULL表示不启用 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) @Column(name = "min_price", precision = 20, scale = 8)
val minPrice: BigDecimal? = null, // 最低价格(可选),NULL表示不限制最低价 val minPrice: BigDecimal? = null, // 最低价格(可选),NULL表示不限制最低价
@@ -66,9 +66,6 @@ data class CopyTradingTemplate(
@Column(name = "max_spread", precision = 20, scale = 8) @Column(name = "max_spread", precision = 20, scale = 8)
val maxSpread: BigDecimal? = null, // 最大价差(绝对价格),NULL表示不启用 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) @Column(name = "min_price", precision = 20, scale = 8)
val minPrice: BigDecimal? = null, // 最低价格(可选),NULL表示不限制最低价 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.api.TradeResponse
import com.wrbug.polymarketbot.dto.* 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.toSafeBigDecimal
import com.wrbug.polymarketbot.util.eq import com.wrbug.polymarketbot.util.eq
import com.wrbug.polymarketbot.util.JsonUtils 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 kotlinx.coroutines.*
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@@ -27,7 +35,7 @@ class AccountService(
private val apiKeyService: PolymarketApiKeyService, private val apiKeyService: PolymarketApiKeyService,
private val orderPushService: OrderPushService, private val orderPushService: OrderPushService,
private val orderSigningService: OrderSigningService, private val orderSigningService: OrderSigningService,
private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils, private val cryptoUtils: CryptoUtils,
private val telegramNotificationService: TelegramNotificationService? = null, // 可选,避免循环依赖 private val telegramNotificationService: TelegramNotificationService? = null, // 可选,避免循环依赖
private val relayClientService: RelayClientService 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 tokenId token ID通过 marketId outcomeIndex 计算得出
* @param isSellOrder 是否为卖出订单true: 卖单需要 bestBidfalse: 买单需要 bestAsk * @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 { return try {
val positionsResult = getAllPositions() val positionsResult = getAllPositions()
positionsResult.fold( 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 { return try {
// 检查 Builder API Key 是否已配置 // 检查 Builder API Key 是否已配置
if (!relayClientService.isBuilderApiKeyConfigured()) { 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.dto.AccountPositionDto
import com.wrbug.polymarketbot.entity.CopyOrderTracking import com.wrbug.polymarketbot.entity.CopyOrderTracking
@@ -18,6 +18,11 @@ import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy import jakarta.annotation.PreDestroy
import org.springframework.context.MessageSource import org.springframework.context.MessageSource
import org.springframework.context.i18n.LocaleContextHolder 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 org.springframework.stereotype.Service
import java.math.BigDecimal import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@@ -39,7 +44,8 @@ class PositionCheckService(
private val relayClientService: RelayClientService, private val relayClientService: RelayClientService,
private val telegramNotificationService: TelegramNotificationService?, private val telegramNotificationService: TelegramNotificationService?,
private val accountRepository: AccountRepository, private val accountRepository: AccountRepository,
private val messageSource: MessageSource private val messageSource: MessageSource,
private val retrofitFactory: RetrofitFactory
) { ) {
private val logger = LoggerFactory.getLogger(PositionCheckService::class.java) private val logger = LoggerFactory.getLogger(PositionCheckService::class.java)
@@ -186,7 +192,7 @@ class PositionCheckService(
/** /**
* 逻辑1处理待赎回仓位 * 逻辑1处理待赎回仓位
* 按照以下逻辑处理 https://clob.polymarket.com * 按照以下逻辑处理
* 1. 无待赎回仓位跳过 * 1. 无待赎回仓位跳过
* 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位发送通知事件 * 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位发送通知事件
* 3. (已配置) && 有待赎回的仓位处理订单逻辑 * 3. (已配置) && 有待赎回的仓位处理订单逻辑
@@ -366,17 +372,51 @@ class PositionCheckService(
val position = positionsByAccountAndMarket[positionKey]?.firstOrNull() val position = positionsByAccountAndMarket[positionKey]?.firstOrNull()
if (position == null) { if (position == null) {
// 仓位不存在,更新所有订单状态为已卖出 // 仓位不存在,检查订单创建时间
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex) // 只有当订单创建时间超过2分钟时,才认为仓位被出售了
updateOrdersAsSold(orders, currentPrice, copyTrading.id!!, marketId, outcomeIndex) // 这样可以避免刚创建的订单因为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 { } else {
// 有仓位,按订单下单顺序(FIFO)更新状态 // 有仓位,按订单下单顺序(FIFO)更新状态
// 如果仓位数量 >= 订单数量总和,所有订单完全成交 // 计算逻辑:
// 如果仓位数量 < 订单数量总和,按FIFO顺序部分成交 // 1. 总订单数量 = 所有未卖出订单的剩余数量总和
val totalUnmatchedQuantity = orders.sumOf { it.remainingQuantity.toSafeBigDecimal() } // 2. 已成交数量 = 总订单数量 - 仓位数量(因为还有仓位,说明部分订单已卖出)
// 3. 如果已成交数量 = 0,说明订单还没有卖出,不修改订单状态
// 4. 如果已成交数量 > 0,按FIFO顺序匹配订单
val positionQuantity = position.quantity.toSafeBigDecimal() 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) 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中间价 * 优先使用 bestBid最优买价如果没有则使用 midpoint中间价
* 如果市场已关闭
* - outcome 赢了返回 1
* - outcome 输了返回 0
*/ */
private suspend fun getCurrentMarketPrice(marketId: String, outcomeIndex: Int): BigDecimal { private suspend fun getCurrentMarketPrice(marketId: String, outcomeIndex: Int): BigDecimal {
return try { 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 priceResult = accountService.getMarketPrice(marketId, outcomeIndex)
val marketPrice = priceResult.getOrNull() val marketPrice = priceResult.getOrNull()
if (marketPrice != null) { 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 顺序更新订单状态为已卖出 * 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( private suspend fun updateOrdersAsSoldByFIFO(
orders: List<CopyOrderTracking>, orders: List<CopyOrderTracking>,
availableQuantity: BigDecimal, soldQuantity: BigDecimal,
sellPrice: BigDecimal, sellPrice: BigDecimal,
copyTradingId: Long, copyTradingId: Long,
marketId: String, marketId: String,
@@ -529,7 +675,7 @@ class PositionCheckService(
try { try {
// 订单已经按 createdAt ASC 排序(FIFO // 订单已经按 createdAt ASC 排序(FIFO
var remaining = availableQuantity var remaining = soldQuantity
var totalMatchedQuantity = BigDecimal.ZERO var totalMatchedQuantity = BigDecimal.ZERO
var totalRealizedPnl = BigDecimal.ZERO var totalRealizedPnl = BigDecimal.ZERO
val matchDetails = mutableListOf<SellMatchDetail>() 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 com.wrbug.polymarketbot.dto.PositionListResponse
import jakarta.annotation.PostConstruct 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.AccountPositionDto
import com.wrbug.polymarketbot.dto.PositionListResponse
import com.wrbug.polymarketbot.dto.PositionPushMessage import com.wrbug.polymarketbot.dto.PositionPushMessage
import com.wrbug.polymarketbot.dto.PositionPushMessageType import com.wrbug.polymarketbot.dto.PositionPushMessageType
import com.wrbug.polymarketbot.dto.getPositionKey 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() } lastCurrentPositions = positions.currentPositions.associateBy { it.getPositionKey() }
lastHistoryPositions = positions.historyPositions.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.GitHubApi
import com.wrbug.polymarketbot.api.GitHubCommentResponse
import com.wrbug.polymarketbot.dto.AnnouncementDto import com.wrbug.polymarketbot.dto.AnnouncementDto
import com.wrbug.polymarketbot.dto.AnnouncementListResponse import com.wrbug.polymarketbot.dto.AnnouncementListResponse
import com.wrbug.polymarketbot.util.RetrofitFactory import com.wrbug.polymarketbot.util.RetrofitFactory
@@ -124,7 +125,7 @@ class AnnouncementService(
* 获取 Issue 评论列表带缓存 * 获取 Issue 评论列表带缓存
* @return Pair<评论列表, 是否使用了缓存> * @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)) { if (!forceRefresh && isCacheValid(cachedComments)) {
logger.debug("使用缓存的评论列表") logger.debug("使用缓存的评论列表")
@@ -191,21 +192,20 @@ class AnnouncementService(
// 筛选条件: // 筛选条件:
// 1. assignees 发布的评论 // 1. assignees 发布的评论
// 2. 排除 issueNumber 为 3703128976 的评论(从 issue_url 中提取) // 2. 排除 issueNumber 为 3703128976 的评论(从 issue_url 中提取)
val filteredComments = comments val filteredComments = comments.filter { comment ->
.filter { comment -> // 检查是否为 assignee
// 检查是否为 assignee val isAssignee = assigneeList.contains(comment.user.login)
val isAssignee = assigneeList.contains(comment.user.login)
// 检查是否应该排除(从 issue_url 中提取 issue ID // 检查是否应该排除(从 issue_url 中提取 issue ID
val shouldExclude = comment.issue_url?.let { issueUrl -> val shouldExclude = comment.issue_url?.let { issueUrl ->
// issue_url 格式:https://api.github.com/repos/owner/repo/issues/3703128976 // issue_url 格式:https://api.github.com/repos/owner/repo/issues/3703128976
// 提取最后的数字 // 提取最后的数字
val issueId = issueUrl.split("/").lastOrNull() val issueId = issueUrl.split("/").lastOrNull()
issueId == excludedIssueId issueId == excludedIssueId
} ?: false } ?: false
isAssignee && !shouldExclude isAssignee && !shouldExclude
} }
.sortedByDescending { comment -> .sortedByDescending { comment ->
parseGitHubTime(comment.created_at) parseGitHubTime(comment.created_at)
} }
@@ -249,16 +249,15 @@ class AnnouncementService(
suspend fun getAnnouncementDetail(id: Long?, forceRefresh: Boolean = false): Result<AnnouncementDto> { suspend fun getAnnouncementDetail(id: Long?, forceRefresh: Boolean = false): Result<AnnouncementDto> {
return try { return try {
// 获取 assignees // 获取 assignees
val (assigneeList, assigneesFromCache) = getAssignees(forceRefresh) val (assigneeList, _) = getAssignees(forceRefresh)
// 获取评论列表 // 获取评论列表
val (comments, commentsFromCache) = getIssueComments(forceRefresh) val (comments, _) = getIssueComments(forceRefresh)
// 筛选条件: // 筛选条件:
// 1. assignees 发布的评论 // 1. assignees 发布的评论
// 2. 排除 issueNumber 为 3703128976 的评论(从 issue_url 中提取) // 2. 排除 issueNumber 为 3703128976 的评论(从 issue_url 中提取)
val filteredComments = comments val filteredComments = comments.filter { comment ->
.filter { comment ->
// 检查是否为 assignee // 检查是否为 assignee
val isAssignee = assigneeList.contains(comment.user.login) val isAssignee = assigneeList.contains(comment.user.login)
@@ -296,7 +295,7 @@ class AnnouncementService(
/** /**
* GitHub 评论转换为 AnnouncementDto * GitHub 评论转换为 AnnouncementDto
*/ */
private fun toAnnouncementDto(comment: com.wrbug.polymarketbot.api.GitHubCommentResponse): AnnouncementDto { private fun toAnnouncementDto(comment: GitHubCommentResponse): AnnouncementDto {
// 提取标题(第一行,移除 Markdown 格式) // 提取标题(第一行,移除 Markdown 格式)
val title = extractTitle(comment.body) val title = extractTitle(comment.body)
@@ -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.CheckFirstUseResponse
import com.wrbug.polymarketbot.dto.LoginResponse import com.wrbug.polymarketbot.dto.LoginResponse
@@ -10,6 +10,7 @@ import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import com.wrbug.polymarketbot.service.common.RateLimitService
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional 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.EthereumRpcApi
import com.wrbug.polymarketbot.api.JsonRpcRequest import com.wrbug.polymarketbot.api.JsonRpcRequest
@@ -11,6 +11,7 @@ import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient import com.wrbug.polymarketbot.util.createClient
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import com.wrbug.polymarketbot.service.system.RelayClientService
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory 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.ApiKeyResponse
import com.wrbug.polymarketbot.api.PolymarketClobApi 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.api.*
import com.wrbug.polymarketbot.util.RetrofitFactory 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.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value 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.OrderPushMessage
import com.wrbug.polymarketbot.dto.PositionPushMessage import com.wrbug.polymarketbot.dto.PositionPushMessage
import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage
import com.wrbug.polymarketbot.dto.WebSocketMessageType 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 kotlinx.coroutines.*
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service 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.api.OrderbookResponse
import com.wrbug.polymarketbot.entity.CopyTrading 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.multi
import com.wrbug.polymarketbot.util.toSafeBigDecimal import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.math.BigDecimal import java.math.BigDecimal
@@ -24,85 +25,86 @@ class CopyTradingFilterService(
* 检查过滤条件 * 检查过滤条件
* @param copyTrading 跟单配置 * @param copyTrading 跟单配置
* @param tokenId token ID用于获取订单簿 * @param tokenId token ID用于获取订单簿
* @param isBuyOrder 是否为买入订单true=买入false=卖出 * @param tradePrice Leader 交易价格用于价格区间检查
* @return Pair<是否通过, 失败原因> * @return 过滤结果
*/ */
suspend fun checkFilters( suspend fun checkFilters(
copyTrading: CopyTrading, copyTrading: CopyTrading,
tokenId: String, tokenId: String,
isBuyOrder: Boolean,
tradePrice: BigDecimal? = null // Leader 交易价格,用于价格区间检查 tradePrice: BigDecimal? = null // Leader 交易价格,用于价格区间检查
): Pair<Boolean, String> { ): FilterResult {
// 1. 价格区间检查(如果配置了价格区间) // 1. 价格区间检查(如果配置了价格区间)
if (tradePrice != null) { if (tradePrice != null) {
val priceRangeCheck = checkPriceRange(copyTrading, tradePrice) val priceRangeCheck = checkPriceRange(copyTrading, tradePrice)
if (!priceRangeCheck.first) { if (!priceRangeCheck.isPassed) {
return priceRangeCheck return FilterResult.priceRangeFailed(priceRangeCheck.reason)
} }
} }
// 2. 价格合理性检查(基础检查,无需配置) // 2. 检查是否需要获取订单簿
// 这个检查在获取订单簿时进行,如果价格不在 0.01-0.99 范围内,订单簿获取会失败 // 只有在配置了需要订单簿的过滤条件时才获取
val needOrderbook = copyTrading.maxSpread != null || copyTrading.minOrderDepth != null
// 3. 获取订单簿 if (!needOrderbook) {
// 不需要订单簿,直接通过
return FilterResult.passed()
}
// 3. 获取订单簿(仅在需要时,只请求一次)
val orderbookResult = clobService.getOrderbookByTokenId(tokenId) val orderbookResult = clobService.getOrderbookByTokenId(tokenId)
if (!orderbookResult.isSuccess) { if (!orderbookResult.isSuccess) {
val error = orderbookResult.exceptionOrNull() val error = orderbookResult.exceptionOrNull()
return Pair(false, "获取订单簿失败: ${error?.message ?: "未知错误"}") return FilterResult.orderbookError("获取订单簿失败: ${error?.message ?: "未知错误"}")
} }
val orderbook = orderbookResult.getOrNull() val orderbook = orderbookResult.getOrNull()
if (orderbook == null) { ?: return FilterResult.orderbookEmpty()
return Pair(false, "订单簿为空")
// 4. 买一卖一价差过滤(如果配置了)
if (copyTrading.maxSpread != null) {
val spreadCheck = checkSpread(copyTrading, orderbook)
if (!spreadCheck.isPassed) {
return FilterResult.spreadFailed(spreadCheck.reason, orderbook)
}
} }
// 4. 买一卖一价差过滤 // 5. 订单深度过滤(如果配置了,检查所有方向)
val spreadCheck = checkSpread(copyTrading, orderbook) if (copyTrading.minOrderDepth != null) {
if (!spreadCheck.first) { val depthCheck = checkOrderDepth(copyTrading, orderbook)
return spreadCheck if (!depthCheck.isPassed) {
return FilterResult.orderDepthFailed(depthCheck.reason, orderbook)
}
} }
// 5. 订单深度过滤 return FilterResult.passed(orderbook)
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, "")
} }
/** /**
* 检查价格区间 * 检查价格区间
* @param copyTrading 跟单配置 * @param copyTrading 跟单配置
* @param tradePrice Leader 交易价格 * @param tradePrice Leader 交易价格
* @return Pair<是否通过, 失败原因> * @return 过滤结果
*/ */
private fun checkPriceRange( private fun checkPriceRange(
copyTrading: CopyTrading, copyTrading: CopyTrading,
tradePrice: BigDecimal tradePrice: BigDecimal
): Pair<Boolean, String> { ): FilterResult {
// 如果未配置价格区间,直接通过 // 如果未配置价格区间,直接通过
if (copyTrading.minPrice == null && copyTrading.maxPrice == null) { if (copyTrading.minPrice == null && copyTrading.maxPrice == null) {
return Pair(true, "") return FilterResult.passed()
} }
// 检查最低价格 // 检查最低价格
if (copyTrading.minPrice != null && tradePrice.lt(copyTrading.minPrice)) { 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)) { 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( private fun checkSpread(
copyTrading: CopyTrading, copyTrading: CopyTrading,
orderbook: OrderbookResponse orderbook: OrderbookResponse
): Pair<Boolean, String> { ): FilterResult {
// 如果未启用价差过滤,直接通过 // 如果未启用价差过滤,直接通过
if (copyTrading.maxSpread == null) { if (copyTrading.maxSpread == null) {
return Pair(true, "") return FilterResult.passed()
} }
// 获取买盘中的最高价格(bestBid = bids 中的最大值) // 获取买盘中的最高价格(bestBid = bids 中的最大值)
@@ -130,84 +132,57 @@ class CopyTradingFilterService(
.minOrNull() .minOrNull()
if (bestBid == null || bestAsk == null) { if (bestBid == null || bestAsk == null) {
return Pair(false, "订单簿缺少买一或卖一价格") return FilterResult.spreadFailed("订单簿缺少买一或卖一价格", orderbook)
} }
// 计算价差(绝对价格) // 计算价差(绝对价格)
val spread = bestAsk.subtract(bestBid) val spread = bestAsk.subtract(bestBid)
if (spread.gt(copyTrading.maxSpread)) { 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( private fun checkOrderDepth(
copyTrading: CopyTrading, copyTrading: CopyTrading,
orderbook: OrderbookResponse, orderbook: OrderbookResponse
isBuyOrder: Boolean ): FilterResult {
): Pair<Boolean, String> {
// 如果未启用订单深度过滤,直接通过 // 如果未启用订单深度过滤,直接通过
if (copyTrading.minOrderDepth == null) { if (copyTrading.minOrderDepth == null) {
return Pair(true, "") return FilterResult.passed()
} }
// 对于买入订单,检查卖盘(asks)深度 // 计算买盘(bids深度
// 对于卖出订单,检查买盘(bids)深度 var bidsDepth = BigDecimal.ZERO
val orders = if (isBuyOrder) orderbook.asks else orderbook.bids for (order in orderbook.bids) {
// 计算总深度(累计订单金额)
var totalDepth = BigDecimal.ZERO
for (order in orders) {
val price = order.price.toSafeBigDecimal() val price = order.price.toSafeBigDecimal()
val size = order.size.toSafeBigDecimal() val size = order.size.toSafeBigDecimal()
val orderAmount = price.multi(size) 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)) { if (totalDepth.lt(copyTrading.minOrderDepth)) {
return Pair(false, "订单深度不足: $totalDepth < ${copyTrading.minOrderDepth}") return FilterResult.orderDepthFailed("订单深度不足: $totalDepth < ${copyTrading.minOrderDepth}", orderbook)
} }
return Pair(true, "") return FilterResult.passed()
}
/**
* 检查最小订单簿深度 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, "")
} }
} }
@@ -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.dto.*
import com.wrbug.polymarketbot.entity.Account
import com.wrbug.polymarketbot.entity.CopyTrading import com.wrbug.polymarketbot.entity.CopyTrading
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.AccountRepository import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
import com.wrbug.polymarketbot.repository.LeaderRepository import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingMonitorService
import com.wrbug.polymarketbot.util.toSafeBigDecimal import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@@ -82,7 +85,6 @@ class CopyTradingService(
supportSell = request.supportSell ?: template.supportSell, supportSell = request.supportSell ?: template.supportSell,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: template.minOrderDepth, minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: template.minOrderDepth,
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread, maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread,
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: template.minOrderbookDepth,
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice, minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice
) )
@@ -109,7 +111,6 @@ class CopyTradingService(
supportSell = request.supportSell ?: true, supportSell = request.supportSell ?: true,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(), minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(),
maxSpread = request.maxSpread?.toSafeBigDecimal(), maxSpread = request.maxSpread?.toSafeBigDecimal(),
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal(),
minPrice = request.minPrice?.toSafeBigDecimal(), minPrice = request.minPrice?.toSafeBigDecimal(),
maxPrice = request.maxPrice?.toSafeBigDecimal() maxPrice = request.maxPrice?.toSafeBigDecimal()
) )
@@ -136,7 +137,6 @@ class CopyTradingService(
supportSell = config.supportSell, supportSell = config.supportSell,
minOrderDepth = config.minOrderDepth, minOrderDepth = config.minOrderDepth,
maxSpread = config.maxSpread, maxSpread = config.maxSpread,
minOrderbookDepth = config.minOrderbookDepth,
minPrice = config.minPrice, minPrice = config.minPrice,
maxPrice = config.maxPrice, maxPrice = config.maxPrice,
configName = configName, configName = configName,
@@ -202,7 +202,6 @@ class CopyTradingService(
supportSell = request.supportSell ?: copyTrading.supportSell, supportSell = request.supportSell ?: copyTrading.supportSell,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: copyTrading.minOrderDepth, minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: copyTrading.minOrderDepth,
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: copyTrading.maxSpread, maxSpread = request.maxSpread?.toSafeBigDecimal() ?: copyTrading.maxSpread,
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: copyTrading.minOrderbookDepth,
minPrice = request.minPrice?.toSafeBigDecimal() ?: copyTrading.minPrice, minPrice = request.minPrice?.toSafeBigDecimal() ?: copyTrading.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: copyTrading.maxPrice, maxPrice = request.maxPrice?.toSafeBigDecimal() ?: copyTrading.maxPrice,
configName = configName, configName = configName,
@@ -339,7 +338,7 @@ class CopyTradingService(
fun getAccountTemplates(accountId: Long): Result<AccountTemplatesResponse> { fun getAccountTemplates(accountId: Long): Result<AccountTemplatesResponse> {
return try { return try {
// 验证账户是否存在 // 验证账户是否存在
val account = accountRepository.findById(accountId).orElse(null) accountRepository.findById(accountId).orElse(null)
?: return Result.failure(IllegalArgumentException("账户不存在")) ?: return Result.failure(IllegalArgumentException("账户不存在"))
val copyTradings = copyTradingRepository.findByAccountId(accountId) val copyTradings = copyTradingRepository.findByAccountId(accountId)
@@ -380,8 +379,8 @@ class CopyTradingService(
*/ */
private fun toDto( private fun toDto(
copyTrading: CopyTrading, copyTrading: CopyTrading,
account: com.wrbug.polymarketbot.entity.Account, account: Account,
leader: com.wrbug.polymarketbot.entity.Leader leader: Leader
): CopyTradingDto { ): CopyTradingDto {
return CopyTradingDto( return CopyTradingDto(
id = copyTrading.id!!, id = copyTrading.id!!,
@@ -408,7 +407,6 @@ class CopyTradingService(
supportSell = copyTrading.supportSell, supportSell = copyTrading.supportSell,
minOrderDepth = copyTrading.minOrderDepth?.toPlainString(), minOrderDepth = copyTrading.minOrderDepth?.toPlainString(),
maxSpread = copyTrading.maxSpread?.toPlainString(), maxSpread = copyTrading.maxSpread?.toPlainString(),
minOrderbookDepth = copyTrading.minOrderbookDepth?.toPlainString(),
minPrice = copyTrading.minPrice?.toPlainString(), minPrice = copyTrading.minPrice?.toPlainString(),
maxPrice = copyTrading.maxPrice?.toPlainString(), maxPrice = copyTrading.maxPrice?.toPlainString(),
configName = copyTrading.configName, configName = copyTrading.configName,
@@ -438,7 +436,6 @@ class CopyTradingService(
val supportSell: Boolean, val supportSell: Boolean,
val minOrderDepth: BigDecimal?, val minOrderDepth: BigDecimal?,
val maxSpread: BigDecimal?, val maxSpread: BigDecimal?,
val minOrderbookDepth: BigDecimal?,
val minPrice: BigDecimal?, val minPrice: BigDecimal?,
val maxPrice: 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.FilteredOrderDto
import com.wrbug.polymarketbot.dto.FilteredOrderListRequest 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.dto.*
import com.wrbug.polymarketbot.entity.Leader 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.CopyTrading
import com.wrbug.polymarketbot.entity.Leader 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.TradeResponse
import com.wrbug.polymarketbot.api.UserActivityResponse import com.wrbug.polymarketbot.api.UserActivityResponse
@@ -9,6 +9,7 @@ import jakarta.annotation.PreDestroy
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import retrofit2.Response import retrofit2.Response
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@@ -72,7 +73,7 @@ class CopyTradingPollingService(
return return
} }
val leaderId = leader.id!! val leaderId = leader.id
monitoredLeaders[leaderId] = leader monitoredLeaders[leaderId] = leader
// 初始化缓存的交易ID集合 // 初始化缓存的交易ID集合
cachedTradeIds[leaderId] = mutableSetOf() cachedTradeIds[leaderId] = mutableSetOf()
@@ -178,7 +179,7 @@ class CopyTradingPollingService(
return return
} }
val leaderId = leader.id!! val leaderId = leader.id
val leaderAddress = leader.leaderAddress val leaderAddress = leader.leaderAddress
try { 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.Gson
import com.google.gson.JsonObject import com.google.gson.JsonObject
@@ -11,6 +11,7 @@ import jakarta.annotation.PreDestroy
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@@ -65,7 +66,7 @@ class CopyTradingWebSocketService(
return return
} }
val leaderId = leader.id!! val leaderId = leader.id
val leaderAddress = leader.leaderAddress.lowercase() val leaderAddress = leader.leaderAddress.lowercase()
leaderAddresses[leaderId] = leaderAddress 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.fasterxml.jackson.databind.ObjectMapper
import com.wrbug.polymarketbot.api.MarketResponse
import com.wrbug.polymarketbot.dto.OrderDetailDto import com.wrbug.polymarketbot.dto.OrderDetailDto
import com.wrbug.polymarketbot.dto.OrderMessageDto import com.wrbug.polymarketbot.dto.OrderMessageDto
import com.wrbug.polymarketbot.dto.OrderPushMessage import com.wrbug.polymarketbot.dto.OrderPushMessage
@@ -13,6 +14,11 @@ import jakarta.annotation.PreDestroy
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value 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 org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@@ -26,10 +32,10 @@ class OrderPushService(
private val objectMapper: ObjectMapper, private val objectMapper: ObjectMapper,
private val clobService: PolymarketClobService, private val clobService: PolymarketClobService,
private val retrofitFactory: RetrofitFactory, // 用于创建 Gamma API 客户端(不需要认证) private val retrofitFactory: RetrofitFactory, // 用于创建 Gamma API 客户端(不需要认证)
private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils, private val cryptoUtils: CryptoUtils,
private val copyOrderTrackingRepository: com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository? = null, // 可选,避免循环依赖 private val copyOrderTrackingRepository: CopyOrderTrackingRepository? = null, // 可选,避免循环依赖
private val copyTradingRepository: com.wrbug.polymarketbot.repository.CopyTradingRepository? = null, // 可选,避免循环依赖 private val copyTradingRepository: CopyTradingRepository? = null, // 可选,避免循环依赖
private val leaderRepository: com.wrbug.polymarketbot.repository.LeaderRepository? = null // 可选,避免循环依赖 private val leaderRepository: LeaderRepository? = null // 可选,避免循环依赖
) { ) {
private val logger = LoggerFactory.getLogger(OrderPushService::class.java) private val logger = LoggerFactory.getLogger(OrderPushService::class.java)
@@ -152,9 +158,9 @@ class OrderPushService(
return account.apiKey != null && return account.apiKey != null &&
account.apiSecret != null && account.apiSecret != null &&
account.apiPassphrase != null && account.apiPassphrase != null &&
account.apiKey!!.isNotBlank() && account.apiKey.isNotBlank() &&
account.apiSecret!!.isNotBlank() && account.apiSecret.isNotBlank() &&
account.apiPassphrase!!.isNotBlank() account.apiPassphrase.isNotBlank()
} }
/** /**
@@ -378,7 +384,7 @@ class OrderPushService(
/** /**
* 获取订单详情 * 获取订单详情
* 通过 PolymarketClobService 获取订单详情 * 通过 com.wrbug.polymarketbot.service.common.PolymarketClobService 获取订单详情
*/ */
private suspend fun fetchOrderDetail( private suspend fun fetchOrderDetail(
account: Account, account: Account,
@@ -391,7 +397,7 @@ class OrderPushService(
return null return null
} }
// 通过 PolymarketClobService 获取订单详情(需要 L2 认证) // 通过 com.wrbug.polymarketbot.service.common.PolymarketClobService 获取订单详情(需要 L2 认证)
// 解密 API 凭证 // 解密 API 凭证
val apiSecret = try { val apiSecret = try {
decryptApiSecret(account) decryptApiSecret(account)
@@ -453,7 +459,7 @@ class OrderPushService(
* 使用 /markets 接口通过 condition_ids 查询参数获取市场信息 * 使用 /markets 接口通过 condition_ids 查询参数获取市场信息
* 订单返回的 market 字段是 16 进制的 condition ID "0x..." * 订单返回的 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 { return try {
// 创建 Gamma API 客户端(公开 API,不需要认证) // 创建 Gamma API 客户端(公开 API,不需要认证)
val gammaApi = retrofitFactory.createGammaApi() 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.api.SignedOrderObject
import com.wrbug.polymarketbot.util.toSafeBigDecimal import com.wrbug.polymarketbot.util.toSafeBigDecimal
@@ -77,10 +77,6 @@ class OrderSigningService {
): OrderAmounts { ): OrderAmounts {
val sizeDecimal = size.toSafeBigDecimal() val sizeDecimal = size.toSafeBigDecimal()
val priceDecimal = price.toSafeBigDecimal() val priceDecimal = price.toSafeBigDecimal()
// 舍入价格
val roundedPrice = roundNormal(priceDecimal, roundConfig.price)
if (side.uppercase() == "BUY") { if (side.uppercase() == "BUY") {
// BUY: makerAmount = price * size (USDC), takerAmount = size (shares) // BUY: makerAmount = price * size (USDC), takerAmount = size (shares)
// makerAmount 是 USDC 金额,最多 2 位小数 // 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.NewOrderRequest
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.api.TradeResponse import com.wrbug.polymarketbot.api.TradeResponse
import com.wrbug.polymarketbot.entity.* import com.wrbug.polymarketbot.entity.*
import com.wrbug.polymarketbot.repository.* import com.wrbug.polymarketbot.repository.*
@@ -9,6 +10,15 @@ import com.wrbug.polymarketbot.util.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.dao.DataIntegrityViolationException 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.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal import java.math.BigDecimal
@@ -19,7 +29,7 @@ import java.math.BigDecimal
* 实际创建订单并记录跟踪信息 * 实际创建订单并记录跟踪信息
*/ */
@Service @Service
class CopyOrderTrackingService( open class CopyOrderTrackingService(
private val copyOrderTrackingRepository: CopyOrderTrackingRepository, private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
private val sellMatchRecordRepository: SellMatchRecordRepository, private val sellMatchRecordRepository: SellMatchRecordRepository,
private val sellMatchDetailRepository: SellMatchDetailRepository, private val sellMatchDetailRepository: SellMatchDetailRepository,
@@ -32,8 +42,9 @@ class CopyOrderTrackingService(
private val leaderRepository: LeaderRepository, private val leaderRepository: LeaderRepository,
private val orderSigningService: OrderSigningService, private val orderSigningService: OrderSigningService,
private val blockchainService: BlockchainService, private val blockchainService: BlockchainService,
private val clobService: PolymarketClobService,
private val retrofitFactory: RetrofitFactory, private val retrofitFactory: RetrofitFactory,
private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils, private val cryptoUtils: CryptoUtils,
private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖 private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖
) { ) {
@@ -42,6 +53,12 @@ class CopyOrderTrackingService(
// 协程作用域(用于异步发送通知) // 协程作用域(用于异步发送通知)
private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) 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() processedAt = System.currentTimeMillis()
) )
processedTradeRepository.save(processed) processedTradeRepository.save(processed)
} catch (e: DataIntegrityViolationException) { } catch (e: Exception) {
// 唯一约束冲突,说明已经处理过了(可能是并发请求 // 检查是否是唯一键冲突异常(可能是 DataIntegrityViolationException、DuplicateKeyException 或 SQLException
// 再次检查确认状态 if (isUniqueConstraintViolation(e)) {
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id) // 唯一约束冲突,说明已经处理过了(可能是并发请求)
if (existing != null) { // 再次检查确认状态
if (existing.status == "FAILED") { 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)
} }
return Result.success(Unit)
} else { } else {
// 如果检查不到,说明可能是其他约束冲突,重新抛出异常 // 其他类型的异常,重新抛出
logger.warn(
"保存ProcessedTrade时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
e
)
throw e throw e
} }
} }
@@ -209,10 +243,12 @@ class CopyOrderTrackingService(
// 过滤条件检查(在计算订单参数之前) // 过滤条件检查(在计算订单参数之前)
// 传入 Leader 交易价格,用于价格区间检查 // 传入 Leader 交易价格,用于价格区间检查
// 订单簿只请求一次,返回给后续逻辑使用
val tradePrice = trade.price.toSafeBigDecimal() val tradePrice = trade.price.toSafeBigDecimal()
val filterCheck = filterService.checkFilters(copyTrading, tokenId, isBuyOrder = true, tradePrice = tradePrice) val filterResult = filterService.checkFilters(copyTrading, tokenId, tradePrice = tradePrice)
if (!filterCheck.first) { val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
logger.warn("过滤条件检查失败,跳过创建订单: copyTradingId=${copyTrading.id}, reason=${filterCheck.second}") if (!filterResult.isPassed) {
logger.warn("过滤条件检查失败,跳过创建订单: copyTradingId=${copyTrading.id}, reason=${filterResult.reason}")
// 记录被过滤的订单并发送通知(异步,不阻塞) // 记录被过滤的订单并发送通知(异步,不阻塞)
notificationScope.launch { notificationScope.launch {
@@ -236,8 +272,8 @@ class CopyOrderTrackingService(
val marketTitle = marketInfo?.question ?: trade.market val marketTitle = marketInfo?.question ?: trade.market
val marketSlug = marketInfo?.slug val marketSlug = marketInfo?.slug
// 从 filterReason 中提取 filterType // 从过滤结果中提取 filterType
val filterType = extractFilterType(filterCheck.second) val filterType = extractFilterType(filterResult.status, filterResult.reason)
// 计算买入数量(用于记录,即使被过滤也记录) // 计算买入数量(用于记录,即使被过滤也记录)
val calculatedQuantity = try { val calculatedQuantity = try {
@@ -262,7 +298,7 @@ class CopyOrderTrackingService(
price = trade.price.toSafeBigDecimal(), price = trade.price.toSafeBigDecimal(),
size = trade.size.toSafeBigDecimal(), size = trade.size.toSafeBigDecimal(),
calculatedQuantity = calculatedQuantity, calculatedQuantity = calculatedQuantity,
filterReason = filterCheck.second, filterReason = filterResult.reason,
filterType = filterType filterType = filterType
) )
@@ -288,7 +324,7 @@ class CopyOrderTrackingService(
outcome = trade.outcome, outcome = trade.outcome,
price = trade.price, price = trade.price,
size = trade.size, size = trade.size,
filterReason = filterCheck.second, filterReason = filterResult.reason,
filterType = filterType, filterType = filterType,
accountName = account.accountName, accountName = account.accountName,
walletAddress = account.walletAddress, walletAddress = account.walletAddress,
@@ -347,6 +383,35 @@ class CopyOrderTrackingService(
// 计算价格(应用价格容忍度) // 计算价格(应用价格容忍度)
val buyPrice = calculateAdjustedPrice(trade.price.toSafeBigDecimal(), copyTrading, isBuy = true) 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 凭证 // 解密 API 凭证
val apiSecret = try { val apiSecret = try {
decryptApiSecret(account) decryptApiSecret(account)
@@ -372,7 +437,9 @@ class CopyOrderTrackingService(
// 解密私钥 // 解密私钥
val decryptedPrivateKey = decryptPrivateKey(account) val decryptedPrivateKey = decryptPrivateKey(account)
// 调用API创建订单(带重试机制,重试时会重新生成salt并重新签名 // 调用API创建订单(带重试机制)
// 重试策略:最多重试 MAX_RETRY_ATTEMPTS 次,每次重试前等待 RETRY_DELAY_MS 毫秒
// 每次重试都会重新生成salt并重新签名,确保签名唯一性
val createOrderResult = createOrderWithRetry( val createOrderResult = createOrderWithRetry(
clobApi = clobApi, clobApi = clobApi,
privateKey = decryptedPrivateKey, privateKey = decryptedPrivateKey,
@@ -381,13 +448,14 @@ class CopyOrderTrackingService(
side = "BUY", side = "BUY",
price = buyPrice.toString(), price = buyPrice.toString(),
size = finalBuyQuantity.toString(), size = finalBuyQuantity.toString(),
owner = account.apiKey!!, owner = account.apiKey,
copyTradingId = copyTrading.id!!, copyTradingId = copyTrading.id!!,
tradeId = trade.id tradeId = trade.id
) )
// 处理订单创建失败
if (createOrderResult.isFailure) { if (createOrderResult.isFailure) {
// 创建订单失败,记录到失败表 // 提取错误信息(只保留 code 和 errorBody
val exception = createOrderResult.exceptionOrNull() val exception = createOrderResult.exceptionOrNull()
val errorMsg = buildFullErrorMessage( val errorMsg = buildFullErrorMessage(
exception, exception,
@@ -396,16 +464,19 @@ class CopyOrderTrackingService(
finalBuyQuantity.toString(), finalBuyQuantity.toString(),
trade.id trade.id
) )
// 记录失败交易到数据库
// retryCount = MAX_RETRY_ATTEMPTS - 1,表示已重试的次数
recordFailedTrade( recordFailedTrade(
leaderId = leaderId, leaderId = leaderId,
trade = trade, trade = trade,
copyTradingId = copyTrading.id!!, copyTradingId = copyTrading.id!!,
accountId = copyTrading.accountId, accountId = copyTrading.accountId,
side = "BUY", // 订单方向是BUY side = "BUY",
price = buyPrice.toString(), price = buyPrice.toString(),
size = finalBuyQuantity.toString(), size = finalBuyQuantity.toString(),
errorMessage = errorMsg, errorMessage = errorMsg,
retryCount = 1 // 已重试 retryCount = MAX_RETRY_ATTEMPTS - 1 // 已重试次
) )
// 发送订单失败通知(异步,不阻塞,仅在 pushFailedOrders 为 true 时发送) // 发送订单失败通知(异步,不阻塞,仅在 pushFailedOrders 为 true 时发送)
@@ -416,7 +487,8 @@ class CopyOrderTrackingService(
val marketInfo = withContext(Dispatchers.IO) { val marketInfo = withContext(Dispatchers.IO) {
try { try {
val gammaApi = retrofitFactory.createGammaApi() 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) { if (marketResponse.isSuccessful && marketResponse.body() != null) {
marketResponse.body()!!.firstOrNull() marketResponse.body()!!.firstOrNull()
} else { } else {
@@ -637,8 +709,8 @@ class CopyOrderTrackingService(
* 卖出订单匹配 * 卖出订单匹配
* 统一按比例计算不区分RATIO或FIXED模式 * 统一按比例计算不区分RATIO或FIXED模式
* 实际创建卖出订单并记录匹配关系 * 实际创建卖出订单并记录匹配关系
* 注意此方法在 @Transactional 方法中被调用会自动继承事务
*/ */
@Transactional
private suspend fun matchSellOrder( private suspend fun matchSellOrder(
copyTrading: CopyTrading, copyTrading: CopyTrading,
leaderSellTrade: TradeResponse leaderSellTrade: TradeResponse
@@ -682,7 +754,28 @@ class CopyOrderTrackingService(
return 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 totalMatched = BigDecimal.ZERO
var remaining = needMatch var remaining = needMatch
val matchDetails = mutableListOf<SellMatchDetail>() val matchDetails = mutableListOf<SellMatchDetail>()
@@ -697,19 +790,18 @@ class CopyOrderTrackingService(
if (matchQty.lte(BigDecimal.ZERO)) continue if (matchQty.lte(BigDecimal.ZERO)) continue
// 计算盈亏 // 计算盈亏(使用实际卖出价格)
val buyPrice = order.price.toSafeBigDecimal() val buyPrice = order.price.toSafeBigDecimal()
val sellPrice = leaderSellTrade.price.toSafeBigDecimal()
val realizedPnl = sellPrice.subtract(buyPrice).multi(matchQty) val realizedPnl = sellPrice.subtract(buyPrice).multi(matchQty)
// 创建匹配明细(稍后保存 // 创建匹配明细(使用实际卖出价格
val detail = SellMatchDetail( val detail = SellMatchDetail(
matchRecordId = 0, // 稍后设置 matchRecordId = 0, // 稍后设置
trackingId = order.id!!, trackingId = order.id!!,
buyOrderId = order.buyOrderId, buyOrderId = order.buyOrderId,
matchedQuantity = matchQty, matchedQuantity = matchQty,
buyPrice = buyPrice, buyPrice = buyPrice,
sellPrice = sellPrice, sellPrice = sellPrice, // 使用实际卖出价格,与 SellMatchRecord 保持一致
realizedPnl = realizedPnl realizedPnl = realizedPnl
) )
matchDetails.add(detail) matchDetails.add(detail)
@@ -722,17 +814,6 @@ class CopyOrderTrackingService(
return 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. 解密私钥(在方法开始时解密一次,后续复用) // 7. 解密私钥(在方法开始时解密一次,后续复用)
val decryptedPrivateKey = decryptPrivateKey(account) val decryptedPrivateKey = decryptPrivateKey(account)
// 8. 创建并签名卖出订单 // 8. 创建并签名卖出订单
@@ -759,7 +840,7 @@ class CopyOrderTrackingService(
// 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配 // 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配
val orderRequest = NewOrderRequest( val orderRequest = NewOrderRequest(
order = signedOrder, order = signedOrder,
owner = account.apiKey!!, owner = account.apiKey,
orderType = "FAK", // Fill-And-Kill orderType = "FAK", // Fill-And-Kill
deferExec = false deferExec = false
) )
@@ -782,8 +863,8 @@ class CopyOrderTrackingService(
side = "SELL", side = "SELL",
price = sellPrice.toString(), price = sellPrice.toString(),
size = totalMatched.toString(), size = totalMatched.toString(),
owner = account.apiKey!!, owner = account.apiKey,
copyTradingId = copyTrading.id!!, copyTradingId = copyTrading.id,
tradeId = leaderSellTrade.id tradeId = leaderSellTrade.id
) )
@@ -830,11 +911,11 @@ class CopyOrderTrackingService(
val totalRealizedPnl = matchDetails.sumOf { it.realizedPnl.toSafeBigDecimal() } val totalRealizedPnl = matchDetails.sumOf { it.realizedPnl.toSafeBigDecimal() }
val matchRecord = SellMatchRecord( val matchRecord = SellMatchRecord(
copyTradingId = copyTrading.id!!, copyTradingId = copyTrading.id,
sellOrderId = realSellOrderId, // 使用真实订单ID sellOrderId = realSellOrderId, // 使用真实订单ID
leaderSellTradeId = leaderSellTrade.id, leaderSellTradeId = leaderSellTrade.id,
marketId = leaderSellTrade.market, marketId = leaderSellTrade.market,
side = leaderSellTrade.outcomeIndex?.toString() ?: "0", // 使用outcomeIndex作为side(兼容旧数据) side = leaderSellTrade.outcomeIndex.toString(), // 使用outcomeIndex作为side(兼容旧数据)
outcomeIndex = leaderSellTrade.outcomeIndex, // 新增字段 outcomeIndex = leaderSellTrade.outcomeIndex, // 新增字段
totalMatchedQuantity = totalMatched, totalMatchedQuantity = totalMatched,
sellPrice = sellPrice, 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( private suspend fun createOrderWithRetry(
clobApi: com.wrbug.polymarketbot.api.PolymarketClobApi, clobApi: PolymarketClobApi,
privateKey: String, privateKey: String,
makerAddress: String, makerAddress: String,
tokenId: String, tokenId: String,
side: String, side: String,
price: String, price: String,
size: String, size: String,
owner: String, // API Key,用于owner字段 owner: String,
copyTradingId: Long, copyTradingId: Long,
tradeId: String tradeId: String
): Result<String> { ): Result<String> {
var lastError: Exception? = null var lastError: Exception? = null
// 最多重试2次(首次 + 1次重试) // 重试循环:最多重试 MAX_RETRY_ATTEMPTS 次
for (attempt in 1..2) { for (attempt in 1..MAX_RETRY_ATTEMPTS) {
try { try {
// 每次重试都重新生成salt并重新签名 // 每次重试都重新生成salt并重新签名,确保签名唯一性
val signedOrder = orderSigningService.createAndSignOrder( val signedOrder = orderSigningService.createAndSignOrder(
privateKey = privateKey, privateKey = privateKey,
makerAddress = makerAddress, makerAddress = makerAddress,
@@ -887,76 +983,118 @@ class CopyOrderTrackingService(
expiration = "0" expiration = "0"
) )
// 构建订单请求(每次重试都使用新签名的订单) // 构建订单请求
// 跟单订单使用 FAK (Fill-And-Kill),允许部分成交,未成交部分立即取消 // 跟单订单使用 FAK (Fill-And-Kill),允许部分成交,未成交部分立即取消
// 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配 // 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配
val orderRequest = NewOrderRequest( val orderRequest = NewOrderRequest(
order = signedOrder, order = signedOrder,
owner = owner, // API Key owner = owner,
orderType = "FAK", // Fill-And-Kill orderType = "FAK", // Fill-And-Kill
deferExec = false deferExec = false
) )
// 调用 API 创建订单
val orderResponse = clobApi.createOrder(orderRequest) val orderResponse = clobApi.createOrder(orderRequest)
// 检查 HTTP 响应状态
if (!orderResponse.isSuccessful || orderResponse.body() == null) { if (!orderResponse.isSuccessful || orderResponse.body() == null) {
val errorBody = try { val errorBody = try {
orderResponse.errorBody()?.string() orderResponse.errorBody()?.string()
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
val errorMsg = val errorMsg = "code=${orderResponse.code()}, errorBody=${errorBody ?: "null"}"
"创建订单失败: 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 ""}"
lastError = Exception(errorMsg) lastError = Exception(errorMsg)
// 所有失败都记录详细日志
logger.error(errorMsg) // 记录错误日志
if (attempt < 2) { logger.error("创建订单失败 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg")
delay(1000) // 重试前等待1秒
// 如果不是最后一次尝试,等待后重试
if (attempt < MAX_RETRY_ATTEMPTS) {
delay(RETRY_DELAY_MS)
continue continue
} }
return Result.failure(lastError!!) return Result.failure(lastError)
} }
// 检查业务响应状态
val response = orderResponse.body()!! val response = orderResponse.body()!!
if (!response.success || response.orderId == null) { if (!response.success || response.orderId == null) {
val errorMsg = val errorMsg = "errorMsg=${response.errorMsg}"
"创建订单失败: copyTradingId=$copyTradingId, tradeId=$tradeId, attempt=$attempt, side=$side, price=$price, size=$size, tokenId=$tokenId, errorMsg=${response.errorMsg}"
lastError = Exception(errorMsg) lastError = Exception(errorMsg)
// 所有失败都记录详细日志
logger.error(errorMsg) // 记录错误日志
if (attempt < 2) { logger.error("创建订单失败 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg")
delay(1000) // 重试前等待1秒
// 如果不是最后一次尝试,等待后重试
if (attempt < MAX_RETRY_ATTEMPTS) {
delay(RETRY_DELAY_MS)
continue 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) return Result.success(response.orderId)
} catch (e: Exception) { } catch (e: Exception) {
val errorMsg = val errorMsg = "error=${e.message}"
"调用创建订单API异常: copyTradingId=$copyTradingId, tradeId=$tradeId, attempt=$attempt, side=$side, price=$price, size=$size, tokenId=$tokenId, error=${e.message}"
lastError = Exception(errorMsg, e) lastError = Exception(errorMsg, e)
// 所有失败都记录详细日志(包括堆栈)
logger.error(errorMsg, e) // 记录错误日志(包含堆栈)
if (attempt < 2) { logger.error("创建订单异常 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg", e)
delay(1000) // 重试前等待1秒
// 如果不是最后一次尝试,等待后重试
if (attempt < MAX_RETRY_ATTEMPTS) {
delay(RETRY_DELAY_MS)
continue continue
} }
return Result.failure(lastError!!) return Result.failure(lastError)
} }
} }
val finalError = lastError ?: Exception("创建订单失败:未知错误") // 所有重试都失败
logger.error( val finalError = lastError ?: Exception("error=未知错误")
"创建订单失败(所有重试都失败): copyTradingId=$copyTradingId, tradeId=$tradeId, side=$side, price=$price, size=$size, tokenId=$tokenId", logger.error("创建订单失败(所有重试都失败): copyTradingId=$copyTradingId, tradeId=$tradeId, side=$side, price=$price, size=$size", finalError)
finalError
)
return Result.failure(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( private fun buildFullErrorMessage(
exception: Throwable?, exception: Throwable?,
@@ -966,35 +1104,29 @@ class CopyOrderTrackingService(
tradeId: String tradeId: String
): String { ): String {
if (exception == null) { if (exception == null) {
return "创建订单失败: side=$side, price=$price, size=$size, tradeId=$tradeId, 未知错误" return "code=未知, errorBody=null"
} }
val errorMsg = StringBuilder() val exceptionMessage = exception.message ?: ""
errorMsg.append("创建订单失败: side=$side, price=$price, size=$size, tradeId=$tradeId")
errorMsg.append(", error=${exception.message}")
// 添加堆栈信息(限制长度,避免过长) // 从错误信息中提取 code 和 errorBody
val stackTrace = exception.stackTraceToString() val codePattern = Regex("code=([^,}]+)")
val maxLength = 2000 // 限制错误信息最大长度为2000字符 val errorBodyPattern = Regex("errorBody=([^,}]+)")
if (stackTrace.length > maxLength) {
errorMsg.append(", stackTrace=${stackTrace.substring(0, maxLength)}...")
} else {
errorMsg.append(", stackTrace=$stackTrace")
}
// 如果有 cause,也添加 val codeMatch = codePattern.find(exceptionMessage)
exception.cause?.let { cause -> val errorBodyMatch = errorBodyPattern.find(exceptionMessage)
errorMsg.append(", cause=${cause.message}")
}
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 suspend fun recordFailedTrade(
private fun recordFailedTrade(
leaderId: Long, leaderId: Long,
trade: TradeResponse, trade: TradeResponse,
copyTradingId: Long, copyTradingId: Long,
@@ -1045,21 +1177,35 @@ class CopyOrderTrackingService(
processedAt = System.currentTimeMillis() processedAt = System.currentTimeMillis()
) )
processedTradeRepository.save(processed) processedTradeRepository.save(processed)
} catch (e: DataIntegrityViolationException) { } catch (e: Exception) {
// 唯一约束冲突,说明已经处理过了(可能是并发请求) // 检查是否是唯一键冲突异常
// 检查现有记录的状态 if (isUniqueConstraintViolation(e)) {
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id) // 唯一约束冲突,说明已经处理过了(可能是并发请求)
if (existing != null) { // 检查现有记录的状态
if (existing.status == "SUCCESS") { val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
logger.warn("交易已成功处理,但尝试记录为失败(并发冲突): leaderId=$leaderId, tradeId=${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 { } 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 { } else {
logger.warn( // 其他类型的异常,记录但不抛出(避免影响其他交易的处理)
"保存ProcessedTrade失败记录时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}", logger.warn("保存ProcessedTrade失败记录时发生异常: leaderId=$leaderId, tradeId=${trade.id}", e)
e
)
} }
} }
@@ -1105,7 +1251,7 @@ class CopyOrderTrackingService(
} }
// 2. 检查每日亏损限制(需要计算今日已实现盈亏) // 2. 检查每日亏损限制(需要计算今日已实现盈亏)
val todaySellRecords = sellMatchRecordRepository.findByCopyTradingId(copyTrading.id!!) val todaySellRecords = sellMatchRecordRepository.findByCopyTradingId(copyTrading.id)
.filter { it.createdAt >= todayStart } .filter { it.createdAt >= todayStart }
val todayRealizedPnl = todaySellRecords.sumOf { it.totalRealizedPnl.toSafeBigDecimal() } val todayRealizedPnl = todaySellRecords.sumOf { it.totalRealizedPnl.toSafeBigDecimal() }
@@ -1121,19 +1267,22 @@ class CopyOrderTrackingService(
/** /**
* 计算调整后的价格应用价格容忍度 * 计算调整后的价格应用价格容忍度
* 如果价格容忍度为0使用默认值5%
*/ */
private fun calculateAdjustedPrice( private fun calculateAdjustedPrice(
originalPrice: BigDecimal, originalPrice: BigDecimal,
copyTrading: CopyTrading, copyTrading: CopyTrading,
isBuy: Boolean isBuy: Boolean
): BigDecimal { ): BigDecimal {
// 如果价格容忍度为0直接返回原价格 // 如果价格容忍度为0使用默认值5%
if (copyTrading.priceTolerance.eq(BigDecimal.ZERO)) { val tolerance = if (copyTrading.priceTolerance.eq(BigDecimal.ZERO)) {
return originalPrice BigDecimal("5")
} else {
copyTrading.priceTolerance
} }
// 计算价格调整范围(百分比) // 计算价格调整范围(百分比)
val tolerancePercent = copyTrading.priceTolerance.div(100) val tolerancePercent = tolerance.div(100)
val adjustment = originalPrice.multi(tolerancePercent) val adjustment = originalPrice.multi(tolerancePercent)
return if (isBuy) { return if (isBuy) {
@@ -1146,23 +1295,39 @@ class CopyOrderTrackingService(
} }
/** /**
* 从过滤原因中提取过滤类型 * 计算市价卖出价格使用订单簿的 bestBid固定按90%计算
*/ */
private fun extractFilterType(filterReason: String): String { private fun calculateMarketSellPrice(
return when { orderbook: com.wrbug.polymarketbot.api.OrderbookResponse
filterReason.contains("价格低于最低限制", ignoreCase = true) || filterReason.contains("价格高于最高限制", ignoreCase = true) -> "PRICE_RANGE" ): BigDecimal {
filterReason.contains("订单深度不足", ignoreCase = true) -> "ORDER_DEPTH" // 获取 bestBid(最高买入价)
filterReason.contains("价差过大", ignoreCase = true) -> "SPREAD" val bestBid = orderbook.bids
filterReason.contains("订单簿深度不足", ignoreCase = true) -> "ORDERBOOK_DEPTH" .mapNotNull { it.price.toSafeBigDecimal() }
filterReason.contains("价格", ignoreCase = true) && filterReason.contains( .maxOrNull()
"合理", ?: throw IllegalStateException("订单簿 bids 为空,无法获取 bestBid")
ignoreCase = true
) -> "PRICE_VALIDITY"
filterReason.contains("市场状态", ignoreCase = true) -> "MARKET_STATUS" // 卖出:bestBid * 0.9(固定按90%计算,确保能立即成交)
filterReason.contains("获取订单簿失败", ignoreCase = true) -> "ORDERBOOK_ERROR" return calculateFallbackSellPrice(bestBid)
filterReason.contains("订单簿为空", ignoreCase = true) -> "ORDERBOOK_EMPTY" }
else -> "UNKNOWN"
/**
* 计算降级卖出价格固定按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.dto.*
import com.wrbug.polymarketbot.entity.* import com.wrbug.polymarketbot.entity.*
@@ -13,6 +13,8 @@ import org.slf4j.LoggerFactory
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort 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 org.springframework.stereotype.Service
import java.math.BigDecimal import java.math.BigDecimal
import java.math.RoundingMode import java.math.RoundingMode
@@ -30,7 +32,7 @@ class CopyTradingStatisticsService(
private val accountRepository: AccountRepository, private val accountRepository: AccountRepository,
private val leaderRepository: LeaderRepository, private val leaderRepository: LeaderRepository,
private val accountService: AccountService, private val accountService: AccountService,
private val blockchainService: com.wrbug.polymarketbot.service.BlockchainService private val blockchainService: BlockchainService
) { ) {
private val logger = LoggerFactory.getLogger(CopyTradingStatisticsService::class.java) private val logger = LoggerFactory.getLogger(CopyTradingStatisticsService::class.java)
@@ -69,7 +71,10 @@ class CopyTradingStatisticsService(
// 9. 计算未实现盈亏(使用链上实际持仓,而不是 remainingQuantity // 9. 计算未实现盈亏(使用链上实际持仓,而不是 remainingQuantity
val unrealizedPnl = calculateUnrealizedPnl(buyOrders, currentPrice, actualPositions) val unrealizedPnl = calculateUnrealizedPnl(buyOrders, currentPrice, actualPositions)
// 9. 构建响应 // 10. 计算持仓价值(使用链上实际持仓和当前价格)
val positionValue = calculatePositionValue(buyOrders, currentPrice, actualPositions)
// 11. 构建响应
val response = CopyTradingStatisticsResponse( val response = CopyTradingStatisticsResponse(
copyTradingId = copyTradingId, copyTradingId = copyTradingId,
accountId = copyTrading.accountId, accountId = copyTrading.accountId,
@@ -85,7 +90,7 @@ class CopyTradingStatisticsService(
totalSellOrders = statistics.totalSellOrders, totalSellOrders = statistics.totalSellOrders,
totalSellAmount = statistics.totalSellAmount, totalSellAmount = statistics.totalSellAmount,
currentPositionQuantity = statistics.currentPositionQuantity, currentPositionQuantity = statistics.currentPositionQuantity,
currentPositionValue = calculatePositionValue(statistics.currentPositionQuantity, currentPrice), currentPositionValue = positionValue,
totalRealizedPnl = statistics.totalRealizedPnl, totalRealizedPnl = statistics.totalRealizedPnl,
totalUnrealizedPnl = unrealizedPnl, totalUnrealizedPnl = unrealizedPnl,
totalPnl = (statistics.totalRealizedPnl.toSafeBigDecimal().add(unrealizedPnl.toSafeBigDecimal())).toString(), totalPnl = (statistics.totalRealizedPnl.toSafeBigDecimal().add(unrealizedPnl.toSafeBigDecimal())).toString(),
@@ -105,7 +110,7 @@ class CopyTradingStatisticsService(
fun getOrderList(request: OrderTrackingRequest): Result<OrderListResponse> { fun getOrderList(request: OrderTrackingRequest): Result<OrderListResponse> {
return try { return try {
// 1. 验证跟单关系 // 1. 验证跟单关系
val copyTrading = copyTradingRepository.findById(request.copyTradingId).orElse(null) copyTradingRepository.findById(request.copyTradingId).orElse(null)
?: return Result.failure(IllegalArgumentException("跟单关系不存在: ${request.copyTradingId}")) ?: return Result.failure(IllegalArgumentException("跟单关系不存在: ${request.copyTradingId}"))
// 2. 根据类型查询 // 2. 根据类型查询
@@ -293,8 +298,10 @@ class CopyTradingStatisticsService(
} }
// 卖出统计 // 卖出统计
// 使用 SellMatchDetail 计算总卖出金额,确保准确性
// 因为每个明细都记录了准确的匹配数量和卖出价格
val totalSellQuantity = sellRecords.sumOf { it.totalMatchedQuantity.toSafeBigDecimal() } 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() val totalSellOrders = sellRecords.size.toLong()
// 持仓统计 // 持仓统计
@@ -354,7 +361,7 @@ class CopyTradingStatisticsService(
* 获取链上实际持仓 * 获取链上实际持仓
* (marketId, outcomeIndex) 组合返回实际持仓数量 * (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>() val positions = mutableMapOf<String, BigDecimal>()
if (account == null || account.proxyAddress.isBlank()) { if (account == null || account.proxyAddress.isBlank()) {
@@ -385,6 +392,7 @@ class CopyTradingStatisticsService(
/** /**
* 计算未实现盈亏 * 计算未实现盈亏
* 使用链上实际持仓数量而不是 remainingQuantity考虑手动卖出的情况 * 使用链上实际持仓数量而不是 remainingQuantity考虑手动卖出的情况
* 按市场聚合订单计算加权平均买入价格避免重复计算
*/ */
private fun calculateUnrealizedPnl( private fun calculateUnrealizedPnl(
buyOrders: List<CopyOrderTracking>, buyOrders: List<CopyOrderTracking>,
@@ -393,6 +401,9 @@ class CopyTradingStatisticsService(
): String { ): String {
var totalUnrealizedPnl = BigDecimal.ZERO var totalUnrealizedPnl = BigDecimal.ZERO
// 按市场聚合订单,计算加权平均买入价格
val marketAggregates = mutableMapOf<String, Pair<BigDecimal, BigDecimal>>() // key -> (总持仓, 总成本)
for (order in buyOrders) { for (order in buyOrders) {
// 如果没有 outcomeIndex,跳过(无法确定价格和持仓) // 如果没有 outcomeIndex,跳过(无法确定价格和持仓)
if (order.outcomeIndex == null) { if (order.outcomeIndex == null) {
@@ -403,8 +414,32 @@ class CopyTradingStatisticsService(
// 使用 "marketId:outcomeIndex" 作为 key // 使用 "marketId:outcomeIndex" 作为 key
val key = "${order.marketId}:${order.outcomeIndex}" val key = "${order.marketId}:${order.outcomeIndex}"
// 获取链上实际持仓数量(如果存在),否则使用 remainingQuantity // 获取订单的持仓数量(使用 remainingQuantity,因为这是该订单的持仓)
val actualQty = actualPositions[key] ?: order.remainingQuantity.toSafeBigDecimal() 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,说明已全部卖出(包括手动卖出),跳过未实现盈亏计算 // 如果实际持仓 <= 0,说明已全部卖出(包括手动卖出),跳过未实现盈亏计算
if (actualQty.lte(BigDecimal.ZERO)) continue if (actualQty.lte(BigDecimal.ZERO)) continue
@@ -413,9 +448,15 @@ class CopyTradingStatisticsService(
val currentPrice = currentPrices[key]?.toSafeBigDecimal() val currentPrice = currentPrices[key]?.toSafeBigDecimal()
?: continue // 如果没有当前价格,跳过 ?: continue // 如果没有当前价格,跳过
val buyPrice = order.price.toSafeBigDecimal() // 计算加权平均买入价格
// 使用实际持仓数量计算未实现盈亏 val avgBuyPrice = if (totalQty.gt(BigDecimal.ZERO)) {
val unrealizedPnl = currentPrice.subtract(buyPrice).multi(actualQty) totalCost.div(totalQty)
} else {
continue
}
// 使用实际持仓数量和加权平均买入价格计算未实现盈亏
val unrealizedPnl = currentPrice.subtract(avgBuyPrice).multi(actualQty)
totalUnrealizedPnl = totalUnrealizedPnl.add(unrealizedPnl) totalUnrealizedPnl = totalUnrealizedPnl.add(unrealizedPnl)
} }
@@ -424,11 +465,44 @@ class CopyTradingStatisticsService(
/** /**
* 计算持仓价值 * 计算持仓价值
* 使用链上实际持仓数量和当前市场价格计算
* 按市场聚合避免重复计算
*/ */
private fun calculatePositionValue(positionQuantity: String, currentPrices: Map<String, String>): String { private fun calculatePositionValue(
// 这里简化处理,实际应该根据每个市场的持仓分别计算 buyOrders: List<CopyOrderTracking>,
// 暂时返回0,因为需要知道每个市场的持仓数量 currentPrices: Map<String, String>,
return "0" 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.dto.*
import com.wrbug.polymarketbot.entity.CopyTradingTemplate import com.wrbug.polymarketbot.entity.CopyTradingTemplate
@@ -61,7 +61,6 @@ class CopyTradingTemplateService(
supportSell = request.supportSell ?: true, supportSell = request.supportSell ?: true,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(), minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(),
maxSpread = request.maxSpread?.toSafeBigDecimal(), maxSpread = request.maxSpread?.toSafeBigDecimal(),
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal(),
minPrice = request.minPrice?.toSafeBigDecimal(), minPrice = request.minPrice?.toSafeBigDecimal(),
maxPrice = request.maxPrice?.toSafeBigDecimal() maxPrice = request.maxPrice?.toSafeBigDecimal()
) )
@@ -120,7 +119,6 @@ class CopyTradingTemplateService(
supportSell = request.supportSell ?: template.supportSell, supportSell = request.supportSell ?: template.supportSell,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: template.minOrderDepth, minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: template.minOrderDepth,
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread, maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread,
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: template.minOrderbookDepth,
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice, minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice, maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
updatedAt = System.currentTimeMillis() updatedAt = System.currentTimeMillis()
@@ -187,7 +185,6 @@ class CopyTradingTemplateService(
supportSell = request.supportSell ?: sourceTemplate.supportSell, supportSell = request.supportSell ?: sourceTemplate.supportSell,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: sourceTemplate.minOrderDepth, minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: sourceTemplate.minOrderDepth,
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: sourceTemplate.maxSpread, maxSpread = request.maxSpread?.toSafeBigDecimal() ?: sourceTemplate.maxSpread,
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: sourceTemplate.minOrderbookDepth,
minPrice = request.minPrice?.toSafeBigDecimal() ?: sourceTemplate.minPrice, minPrice = request.minPrice?.toSafeBigDecimal() ?: sourceTemplate.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: sourceTemplate.maxPrice maxPrice = request.maxPrice?.toSafeBigDecimal() ?: sourceTemplate.maxPrice
) )
@@ -261,7 +258,6 @@ class CopyTradingTemplateService(
supportSell = template.supportSell, supportSell = template.supportSell,
minOrderDepth = template.minOrderDepth?.toPlainString(), minOrderDepth = template.minOrderDepth?.toPlainString(),
maxSpread = template.maxSpread?.toPlainString(), maxSpread = template.maxSpread?.toPlainString(),
minOrderbookDepth = template.minOrderbookDepth?.toPlainString(),
minPrice = template.minPrice?.toPlainString(), minPrice = template.minPrice?.toPlainString(),
maxPrice = template.maxPrice?.toPlainString(), maxPrice = template.maxPrice?.toPlainString(),
createdAt = template.createdAt, 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.ApiHealthCheckDto
import com.wrbug.polymarketbot.dto.ApiHealthCheckResponse import com.wrbug.polymarketbot.dto.ApiHealthCheckResponse
@@ -12,6 +12,8 @@ import org.springframework.beans.BeansException
import org.springframework.context.ApplicationContext import org.springframework.context.ApplicationContext
import org.springframework.context.ApplicationContextAware import org.springframework.context.ApplicationContextAware
import org.springframework.beans.factory.annotation.Value 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 org.springframework.stereotype.Service
import java.util.concurrent.TimeUnit 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.fasterxml.jackson.databind.ObjectMapper
import com.wrbug.polymarketbot.dto.* 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.dto.*
import com.wrbug.polymarketbot.entity.ProxyConfig import com.wrbug.polymarketbot.entity.ProxyConfig
import com.wrbug.polymarketbot.repository.ProxyConfigRepository 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.ProxyConfigProvider
import com.wrbug.polymarketbot.util.TrustAllHostnameVerifier import com.wrbug.polymarketbot.util.TrustAllHostnameVerifier
import com.wrbug.polymarketbot.util.createSSLSocketFactory 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.BuilderRelayerApi
import com.wrbug.polymarketbot.api.EthereumRpcApi import com.wrbug.polymarketbot.api.EthereumRpcApi
@@ -802,10 +802,10 @@ class RelayClientService(
safeTxs: List<SafeTransaction> safeTxs: List<SafeTransaction>
): Result<String> { ): Result<String> {
// 批量执行:将多个交易合并为一个 execTransaction 调用 // 批量执行:将多个交易合并为一个 execTransaction 调用
// 当前实现:委托给 BlockchainService // 当前实现:委托给 com.wrbug.polymarketbot.service.common.BlockchainService
return Result.failure( return Result.failure(
UnsupportedOperationException( 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.SystemConfigDto
import com.wrbug.polymarketbot.dto.SystemConfigUpdateRequest import com.wrbug.polymarketbot.dto.SystemConfigUpdateRequest
@@ -36,10 +36,38 @@ class SystemConfigService(
val builderPassphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE) val builderPassphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)
val autoRedeem = isAutoRedeemEnabled() 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( return SystemConfigDto(
builderApiKeyConfigured = builderApiKey != null, builderApiKeyConfigured = builderApiKey != null,
builderSecretConfigured = builderSecret != null, builderSecretConfigured = builderSecret != null,
builderPassphraseConfigured = builderPassphrase != null, builderPassphraseConfigured = builderPassphrase != null,
builderApiKeyDisplay = builderApiKeyDisplay,
builderSecretDisplay = builderSecretDisplay,
builderPassphraseDisplay = builderPassphraseDisplay,
autoRedeemEnabled = autoRedeem 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.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.dto.NotificationConfigData import com.wrbug.polymarketbot.dto.NotificationConfigData
import com.wrbug.polymarketbot.dto.TelegramConfigData import com.wrbug.polymarketbot.dto.TelegramConfigData
import com.wrbug.polymarketbot.util.createClient import com.wrbug.polymarketbot.util.createClient
import com.wrbug.polymarketbot.util.toSafeBigDecimal import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.DateUtils
import kotlinx.coroutines.* import kotlinx.coroutines.*
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -65,7 +67,7 @@ class TelegramNotificationService(
side: String, side: String,
accountName: String? = null, accountName: String? = null,
walletAddress: String? = null, walletAddress: String? = null,
clobApi: com.wrbug.polymarketbot.api.PolymarketClobApi? = null, clobApi: PolymarketClobApi? = null,
apiKey: String? = null, apiKey: String? = null,
apiSecret: String? = null, apiSecret: String? = null,
apiPassphrase: 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 特殊字符 // 转义 HTML 特殊字符
val escapedMarketTitle = marketTitle.replace("<", "&lt;").replace(">", "&gt;") 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 特殊字符 // 转义 HTML 特殊字符
val escapedMarketTitle = marketTitle.replace("<", "&lt;").replace(">", "&gt;") 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,不需要截断(但为了安全,限制长度) // 错误信息已经是后端返回的 msg,不需要截断(但为了安全,限制长度)
val shortErrorMessage = if (errorMessage.length > 500) { 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 特殊字符 // 转义 HTML 特殊字符
val escapedAccountInfo = accountInfo.replace("<", "&lt;").replace(">", "&gt;") 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.UserCreateRequest
import com.wrbug.polymarketbot.dto.UserDto import com.wrbug.polymarketbot.dto.UserDto
@@ -1,6 +1,8 @@
package com.wrbug.polymarketbot.util package com.wrbug.polymarketbot.util
import java.time.Instant import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException import java.time.format.DateTimeParseException
@@ -15,6 +17,13 @@ object DateUtils {
*/ */
private val isoFormatter = DateTimeFormatter.ISO_DATE_TIME 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 格式的日期字符串转换为时间戳(毫秒) * 将 ISO 8601 格式的日期字符串转换为时间戳(毫秒)
* @param dateString ISO 8601 格式的日期字符串,如 "2020-11-04T00:00:00Z" * @param dateString ISO 8601 格式的日期字符串,如 "2020-11-04T00:00:00Z"
@@ -61,5 +70,19 @@ object DateUtils {
null 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.fasterxml.jackson.databind.ObjectMapper
import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage
import com.wrbug.polymarketbot.dto.WebSocketMessageType 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.PostConstruct
import jakarta.annotation.PreDestroy import jakarta.annotation.PreDestroy
import kotlinx.coroutines.* import kotlinx.coroutines.*
+2 -1
View File
@@ -54,13 +54,14 @@ trap cleanup SIGTERM SIGINT
# 启动后端服务(以 appuser 用户运行,后台运行) # 启动后端服务(以 appuser 用户运行,后台运行)
echo "启动后端服务..." echo "启动后端服务..."
# 自动使用系统时区
java -jar /app/app.jar --spring.profiles.active=${SPRING_PROFILES_ACTIVE:-prod} & java -jar /app/app.jar --spring.profiles.active=${SPRING_PROFILES_ACTIVE:-prod} &
BACKEND_PID=$! BACKEND_PID=$!
# 等待后端服务启动 # 等待后端服务启动
echo "等待后端服务启动..." echo "等待后端服务启动..."
for i in {1..60}; do 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 "后端服务已启动" echo "后端服务已启动"
break break
fi fi
+1 -1
View File
@@ -648,5 +648,5 @@ Vite uses `.env.production` file to inject environment variables during build. T
## Technical Support ## 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/add" element={<ProtectedRoute><CopyTradingAdd /></ProtectedRoute>} />
<Route path="/copy-trading/edit/:id" element={<ProtectedRoute><CopyTradingEdit /></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/statistics/:copyTradingId" element={<ProtectedRoute><CopyTradingStatistics /></ProtectedRoute>} />
{/* 保留旧路由以保持向后兼容 */}
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<ProtectedRoute><CopyTradingBuyOrders /></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/sell/:copyTradingId" element={<ProtectedRoute><CopyTradingSellOrders /></ProtectedRoute>} />
<Route path="/copy-trading/orders/matched/:copyTradingId" element={<ProtectedRoute><CopyTradingMatchedOrders /></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 /> <GithubOutlined />
</a> </a>
<a <a
href="https://x.com/quant_tr" href="https://x.com/polyhermes"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={{ color: '#fff', fontSize: '16px', display: 'flex', alignItems: 'center' }} style={{ color: '#fff', fontSize: '16px', display: 'flex', alignItems: 'center' }}
@@ -362,7 +362,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
<GithubOutlined /> <GithubOutlined />
</a> </a>
<a <a
href="https://x.com/quant_tr" href="https://x.com/polyhermes"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={{ color: '#fff', fontSize: '18px', display: 'flex', alignItems: 'center' }} 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.", "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)", "priceTolerancePlaceholder": "Default 5% (optional)",
"minOrderDepth": "Min Order Depth (USDC)", "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)", "minOrderDepthPlaceholder": "For example: 100 (optional, leave empty to disable)",
"maxSpread": "Max Spread (Absolute Price)", "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)", "maxSpreadPlaceholder": "For example: 0.05 (5 cents, optional, leave empty to disable)",
"minOrderbookDepth": "Min Orderbook Depth (USDC)", "minOrderbookDepth": "Min Orderbook Depth (USDC)",
"minOrderbookDepthTooltip": "Minimum orderbook depth (USDC amount), NULL means this filter is not enabled. Check depth of first N levels", "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.", "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)", "priceTolerancePlaceholder": "Default 5% (optional)",
"minOrderDepth": "Min Order Depth (USDC)", "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)", "minOrderDepthPlaceholder": "For example: 100 (optional, leave empty to disable)",
"maxSpread": "Max Spread (Absolute Price)", "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)", "maxSpreadPlaceholder": "For example: 0.05 (5 cents, optional, leave empty to disable)",
"minOrderbookDepth": "Min Orderbook Depth (USDC)", "minOrderbookDepth": "Min Orderbook Depth (USDC)",
"minOrderbookDepthTooltip": "Minimum orderbook depth (USDC amount), NULL means this filter is not enabled. Check depth of first N levels", "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)", "delaySecondsPlaceholder": "Default 0 (copy immediately)",
"filterConditions": "Filter Conditions (Optional)", "filterConditions": "Filter Conditions (Optional)",
"minOrderDepth": "Min Order Depth (USDC)", "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)", "minOrderDepthPlaceholder": "For example: 100 (optional, leave empty to disable)",
"maxSpread": "Max Spread (Absolute Price)", "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)", "maxSpreadPlaceholder": "For example: 0.05 (5 cents, optional, leave empty to disable)",
"minOrderbookDepth": "Min Orderbook Depth (USDC)", "minOrderbookDepth": "Min Orderbook Depth (USDC)",
"minOrderbookDepthTooltip": "Minimum orderbook depth (USDC amount), NULL means this filter is not enabled. Check depth of first N levels", "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)", "delaySecondsPlaceholder": "Default 0 (copy immediately)",
"filterConditions": "Filter Conditions (Optional)", "filterConditions": "Filter Conditions (Optional)",
"minOrderDepth": "Min Order Depth (USDC)", "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)", "minOrderDepthPlaceholder": "For example: 100 (optional, leave empty to disable)",
"maxSpread": "Max Spread (Absolute Price)", "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)", "maxSpreadPlaceholder": "For example: 0.05 (5 cents, optional, leave empty to disable)",
"minOrderbookDepth": "Min Orderbook Depth (USDC)", "minOrderbookDepth": "Min Orderbook Depth (USDC)",
"minOrderbookDepthTooltip": "Minimum orderbook depth (USDC amount), NULL means this filter is not enabled", "minOrderbookDepthTooltip": "Minimum orderbook depth (USDC amount), NULL means this filter is not enabled",
@@ -856,6 +856,8 @@
"copyTradingList": { "copyTradingList": {
"title": "Copy Trading Config Management", "title": "Copy Trading Config Management",
"addCopyTrading": "Add Copy Trading", "addCopyTrading": "Add Copy Trading",
"configName": "Config Name",
"configNameNotProvided": "Not Provided",
"wallet": "Wallet", "wallet": "Wallet",
"account": "Account", "account": "Account",
"template": "Template", "template": "Template",
@@ -868,10 +870,11 @@
"totalPnl": "Total P&L", "totalPnl": "Total P&L",
"statistics": "Statistics", "statistics": "Statistics",
"orders": "Orders", "orders": "Orders",
"matchedOrders": "Matched Orders",
"ordersButton": "Orders",
"viewStatistics": "View Statistics", "viewStatistics": "View Statistics",
"buyOrders": "Buy Orders", "buyOrders": "Buy Orders",
"sellOrders": "Sell Orders", "sellOrders": "Sell Orders",
"matchedOrders": "Matched Orders",
"filteredOrders": "Filtered Orders", "filteredOrders": "Filtered Orders",
"filterWallet": "Filter Wallet", "filterWallet": "Filter Wallet",
"filterTemplate": "Filter Template", "filterTemplate": "Filter Template",
@@ -966,5 +969,72 @@
"collapse": "Collapse", "collapse": "Collapse",
"refresh": "Refresh", "refresh": "Refresh",
"list": "List" "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": { "accountImport": {
"title": "导入账户", "title": "导入账户",
"back": "返回",
"securityTip": "安全提示",
"securityTipDesc": "私钥将存储在后端数据库中,请确保数据库访问安全。建议使用 HTTPS 连接。",
"importMethod": "导入方式",
"privateKey": "私钥", "privateKey": "私钥",
"mnemonic": "助记词",
"privateKeyLabel": "私钥",
"privateKeyPlaceholder": "请输入私钥(64位十六进制字符串,可选0x前缀)",
"privateKeyRequired": "请输入私钥", "privateKeyRequired": "请输入私钥",
"privateKeyPlaceholder": "请输入或粘贴私钥", "privateKeyInvalid": "私钥格式不正确(应为64位十六进制字符串)",
"privateKeyHelp": "私钥将加密存储,仅用于签名交易", "walletAddress": "钱包地址",
"walletAddressPlaceholder": "钱包地址(将从私钥自动推导)",
"walletAddressRequired": "请输入钱包地址",
"walletAddressInvalid": "钱包地址格式不正确",
"walletAddressMismatch": "钱包地址与私钥不匹配",
"mnemonicLabel": "助记词",
"mnemonicPlaceholder": "请输入12或24个单词的助记词(用空格分隔)",
"mnemonicRequired": "请输入助记词",
"mnemonicInvalid": "助记词格式不正确(应为12或24个单词,用空格分隔)",
"walletAddressMismatchMnemonic": "钱包地址与助记词不匹配",
"accountName": "账户名称", "accountName": "账户名称",
"accountNameRequired": "请输入账户名称", "accountNameRequired": "请输入账户名称",
"accountNamePlaceholder": "请输入账户名称", "accountNamePlaceholder": "可选,用于标识账户",
"accountNameHelp": "用于标识账户,便于管理", "accountNameHelp": "用于标识账户,便于管理",
"privateKeyHelp": "私钥将加密存储,仅用于签名交易",
"submit": "导入", "submit": "导入",
"invalidPrivateKey": "无效的私钥",
"duplicateAccount": "账户已存在",
"importAccount": "导入账户",
"importSuccess": "导入账户成功", "importSuccess": "导入账户成功",
"importFailed": "导入账户失败", "importFailed": "导入账户失败",
"invalidPrivateKey": "无效的私钥", "derivedAddress": "推导地址",
"duplicateAccount": "账户已存在" "addressError": "无法从私钥推导地址",
"addressErrorMnemonic": "无法从助记词推导地址"
}, },
"leader": { "leader": {
"title": "Leader 管理", "title": "Leader 管理",
@@ -512,10 +533,10 @@
"priceToleranceTooltip": "允许跟单价格在 Leader 价格基础上的调整范围,用于在 Leader 价格 ± 容忍度范围内调整价格,提高成交率。例如:设置为 5%,Leader 价格为 0.5,则跟单价格可在 0.475-0.525 范围内。", "priceToleranceTooltip": "允许跟单价格在 Leader 价格基础上的调整范围,用于在 Leader 价格 ± 容忍度范围内调整价格,提高成交率。例如:设置为 5%,Leader 价格为 0.5,则跟单价格可在 0.475-0.525 范围内。",
"priceTolerancePlaceholder": "默认 5%(可选)", "priceTolerancePlaceholder": "默认 5%(可选)",
"minOrderDepth": "最小订单深度 (USDC)", "minOrderDepth": "最小订单深度 (USDC)",
"minOrderDepthTooltip": "最小订单深度(USDC金额),NULL表示不启用此过滤。确保市场有足够的流动性", "minOrderDepthTooltip": "检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤",
"minOrderDepthPlaceholder": "例如:100(可选,不填写表示不启用)", "minOrderDepthPlaceholder": "例如:100(可选,不填写表示不启用)",
"maxSpread": "最大价差(绝对价格)", "maxSpread": "最大价差(绝对价格)",
"maxSpreadTooltip": "最大价差(绝对价格)NULL表示不启用此过滤。避免在价差过大的市场跟单", "maxSpreadTooltip": "最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤",
"maxSpreadPlaceholder": "例如:0.05(5美分,可选,不填写表示不启用)", "maxSpreadPlaceholder": "例如:0.05(5美分,可选,不填写表示不启用)",
"minOrderbookDepth": "最小订单簿深度 (USDC)", "minOrderbookDepth": "最小订单簿深度 (USDC)",
"minOrderbookDepthTooltip": "最小订单簿深度(USDC金额),NULL表示不启用此过滤。检查前 N 档的深度", "minOrderbookDepthTooltip": "最小订单簿深度(USDC金额),NULL表示不启用此过滤。检查前 N 档的深度",
@@ -569,10 +590,10 @@
"priceToleranceTooltip": "允许跟单价格在 Leader 价格基础上的调整范围,用于在 Leader 价格 ± 容忍度范围内调整价格,提高成交率。例如:设置为 5%,Leader 价格为 0.5,则跟单价格可在 0.475-0.525 范围内。", "priceToleranceTooltip": "允许跟单价格在 Leader 价格基础上的调整范围,用于在 Leader 价格 ± 容忍度范围内调整价格,提高成交率。例如:设置为 5%,Leader 价格为 0.5,则跟单价格可在 0.475-0.525 范围内。",
"priceTolerancePlaceholder": "默认 5%(可选)", "priceTolerancePlaceholder": "默认 5%(可选)",
"minOrderDepth": "最小订单深度 (USDC)", "minOrderDepth": "最小订单深度 (USDC)",
"minOrderDepthTooltip": "最小订单深度(USDC金额),NULL表示不启用此过滤。确保市场有足够的流动性", "minOrderDepthTooltip": "检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤",
"minOrderDepthPlaceholder": "例如:100(可选,不填写表示不启用)", "minOrderDepthPlaceholder": "例如:100(可选,不填写表示不启用)",
"maxSpread": "最大价差(绝对价格)", "maxSpread": "最大价差(绝对价格)",
"maxSpreadTooltip": "最大价差(绝对价格)NULL表示不启用此过滤。避免在价差过大的市场跟单", "maxSpreadTooltip": "最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤",
"maxSpreadPlaceholder": "例如:0.05(5美分,可选,不填写表示不启用)", "maxSpreadPlaceholder": "例如:0.05(5美分,可选,不填写表示不启用)",
"minOrderbookDepth": "最小订单簿深度 (USDC)", "minOrderbookDepth": "最小订单簿深度 (USDC)",
"minOrderbookDepthTooltip": "最小订单簿深度(USDC金额),NULL表示不启用此过滤。检查前 N 档的深度", "minOrderbookDepthTooltip": "最小订单簿深度(USDC金额),NULL表示不启用此过滤。检查前 N 档的深度",
@@ -646,10 +667,10 @@
"delaySecondsPlaceholder": "默认 0(立即跟单)", "delaySecondsPlaceholder": "默认 0(立即跟单)",
"filterConditions": "过滤条件(可选)", "filterConditions": "过滤条件(可选)",
"minOrderDepth": "最小订单深度 (USDC)", "minOrderDepth": "最小订单深度 (USDC)",
"minOrderDepthTooltip": "最小订单深度(USDC金额),NULL表示不启用此过滤。确保市场有足够的流动性", "minOrderDepthTooltip": "检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤",
"minOrderDepthPlaceholder": "例如:100(可选,不填写表示不启用)", "minOrderDepthPlaceholder": "例如:100(可选,不填写表示不启用)",
"maxSpread": "最大价差(绝对价格)", "maxSpread": "最大价差(绝对价格)",
"maxSpreadTooltip": "最大价差(绝对价格)NULL表示不启用此过滤。避免在价差过大的市场跟单", "maxSpreadTooltip": "最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤",
"maxSpreadPlaceholder": "例如:0.05(5美分,可选,不填写表示不启用)", "maxSpreadPlaceholder": "例如:0.05(5美分,可选,不填写表示不启用)",
"minOrderbookDepth": "最小订单簿深度 (USDC)", "minOrderbookDepth": "最小订单簿深度 (USDC)",
"minOrderbookDepthTooltip": "最小订单簿深度(USDC金额),NULL表示不启用此过滤。检查前 N 档的深度", "minOrderbookDepthTooltip": "最小订单簿深度(USDC金额),NULL表示不启用此过滤。检查前 N 档的深度",
@@ -721,10 +742,10 @@
"delaySecondsPlaceholder": "默认 0(立即跟单)", "delaySecondsPlaceholder": "默认 0(立即跟单)",
"filterConditions": "过滤条件(可选)", "filterConditions": "过滤条件(可选)",
"minOrderDepth": "最小订单深度 (USDC)", "minOrderDepth": "最小订单深度 (USDC)",
"minOrderDepthTooltip": "最小订单深度(USDC金额),NULL表示不启用此过滤", "minOrderDepthTooltip": "检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤",
"minOrderDepthPlaceholder": "例如:100(可选,不填写表示不启用)", "minOrderDepthPlaceholder": "例如:100(可选,不填写表示不启用)",
"maxSpread": "最大价差(绝对价格)", "maxSpread": "最大价差(绝对价格)",
"maxSpreadTooltip": "最大价差(绝对价格)NULL表示不启用此过滤", "maxSpreadTooltip": "最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤",
"maxSpreadPlaceholder": "例如:0.05(5美分,可选,不填写表示不启用)", "maxSpreadPlaceholder": "例如:0.05(5美分,可选,不填写表示不启用)",
"minOrderbookDepth": "最小订单簿深度 (USDC)", "minOrderbookDepth": "最小订单簿深度 (USDC)",
"minOrderbookDepthTooltip": "最小订单簿深度(USDC金额),NULL表示不启用此过滤", "minOrderbookDepthTooltip": "最小订单簿深度(USDC金额),NULL表示不启用此过滤",
@@ -785,10 +806,11 @@
"totalPnl": "总盈亏", "totalPnl": "总盈亏",
"statistics": "统计", "statistics": "统计",
"orders": "订单", "orders": "订单",
"matchedOrders": "已成交订单",
"ordersButton": "订单",
"viewStatistics": "查看统计", "viewStatistics": "查看统计",
"buyOrders": "买入订单", "buyOrders": "买入订单",
"sellOrders": "卖出订单", "sellOrders": "卖出订单",
"matchedOrders": "匹配关系",
"filteredOrders": "已过滤订单", "filteredOrders": "已过滤订单",
"filterWallet": "筛选钱包", "filterWallet": "筛选钱包",
"filterTemplate": "筛选模板", "filterTemplate": "筛选模板",
@@ -883,5 +905,72 @@
"collapse": "收起", "collapse": "收起",
"refresh": "刷新", "refresh": "刷新",
"list": "列表" "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 範圍內。", "priceToleranceTooltip": "允許跟單價格在 Leader 價格基礎上的調整範圍,用於在 Leader 價格 ± 容忍度範圍內調整價格,提高成交率。例如:設置為 5%,Leader 價格為 0.5,則跟單價格可在 0.475-0.525 範圍內。",
"priceTolerancePlaceholder": "默認 5%(可選)", "priceTolerancePlaceholder": "默認 5%(可選)",
"minOrderDepth": "最小訂單深度 (USDC)", "minOrderDepth": "最小訂單深度 (USDC)",
"minOrderDepthTooltip": "最小訂單深度(USDC金額),NULL表示不啟用此過濾。確保市場有足夠的流動性", "minOrderDepthTooltip": "檢查訂單簿的總訂單金額(買盤+賣盤),確保市場有足夠的流動性。不填寫則不啟用此過濾",
"minOrderDepthPlaceholder": "例如:100(可選,不填寫表示不啟用)", "minOrderDepthPlaceholder": "例如:100(可選,不填寫表示不啟用)",
"maxSpread": "最大價差(絕對價格)", "maxSpread": "最大價差(絕對價格)",
"maxSpreadTooltip": "最大價差(絕對價格)NULL表示不啟用此過濾。避免在價差過大的市場跟單", "maxSpreadTooltip": "最大價差(絕對價格)。避免在價差過大的市場跟單。不填寫則不啟用此過濾",
"maxSpreadPlaceholder": "例如:0.05(5美分,可選,不填寫表示不啟用)", "maxSpreadPlaceholder": "例如:0.05(5美分,可選,不填寫表示不啟用)",
"minOrderbookDepth": "最小訂單簿深度 (USDC)", "minOrderbookDepth": "最小訂單簿深度 (USDC)",
"minOrderbookDepthTooltip": "最小訂單簿深度(USDC金額),NULL表示不啟用此過濾。檢查前 N 檔的深度", "minOrderbookDepthTooltip": "最小訂單簿深度(USDC金額),NULL表示不啟用此過濾。檢查前 N 檔的深度",
@@ -654,10 +654,10 @@
"priceToleranceTooltip": "允許跟單價格在 Leader 價格基礎上的調整範圍,用於在 Leader 價格 ± 容忍度範圍內調整價格,提高成交率。例如:設置為 5%,Leader 價格為 0.5,則跟單價格可在 0.475-0.525 範圍內。", "priceToleranceTooltip": "允許跟單價格在 Leader 價格基礎上的調整範圍,用於在 Leader 價格 ± 容忍度範圍內調整價格,提高成交率。例如:設置為 5%,Leader 價格為 0.5,則跟單價格可在 0.475-0.525 範圍內。",
"priceTolerancePlaceholder": "默認 5%(可選)", "priceTolerancePlaceholder": "默認 5%(可選)",
"minOrderDepth": "最小訂單深度 (USDC)", "minOrderDepth": "最小訂單深度 (USDC)",
"minOrderDepthTooltip": "最小訂單深度(USDC金額),NULL表示不啟用此過濾。確保市場有足夠的流動性", "minOrderDepthTooltip": "檢查訂單簿的總訂單金額(買盤+賣盤),確保市場有足夠的流動性。不填寫則不啟用此過濾",
"minOrderDepthPlaceholder": "例如:100(可選,不填寫表示不啟用)", "minOrderDepthPlaceholder": "例如:100(可選,不填寫表示不啟用)",
"maxSpread": "最大價差(絕對價格)", "maxSpread": "最大價差(絕對價格)",
"maxSpreadTooltip": "最大價差(絕對價格)NULL表示不啟用此過濾。避免在價差過大的市場跟單", "maxSpreadTooltip": "最大價差(絕對價格)。避免在價差過大的市場跟單。不填寫則不啟用此過濾",
"maxSpreadPlaceholder": "例如:0.05(5美分,可選,不填寫表示不啟用)", "maxSpreadPlaceholder": "例如:0.05(5美分,可選,不填寫表示不啟用)",
"minOrderbookDepth": "最小訂單簿深度 (USDC)", "minOrderbookDepth": "最小訂單簿深度 (USDC)",
"minOrderbookDepthTooltip": "最小訂單簿深度(USDC金額),NULL表示不啟用此過濾。檢查前 N 檔的深度", "minOrderbookDepthTooltip": "最小訂單簿深度(USDC金額),NULL表示不啟用此過濾。檢查前 N 檔的深度",
@@ -719,10 +719,10 @@
"delaySecondsPlaceholder": "默認 0(立即跟單)", "delaySecondsPlaceholder": "默認 0(立即跟單)",
"filterConditions": "過濾條件(可選)", "filterConditions": "過濾條件(可選)",
"minOrderDepth": "最小訂單深度 (USDC)", "minOrderDepth": "最小訂單深度 (USDC)",
"minOrderDepthTooltip": "最小訂單深度(USDC金額),NULL表示不啟用此過濾。確保市場有足夠的流動性", "minOrderDepthTooltip": "檢查訂單簿的總訂單金額(買盤+賣盤),確保市場有足夠的流動性。不填寫則不啟用此過濾",
"minOrderDepthPlaceholder": "例如:100(可選,不填寫表示不啟用)", "minOrderDepthPlaceholder": "例如:100(可選,不填寫表示不啟用)",
"maxSpread": "最大價差(絕對價格)", "maxSpread": "最大價差(絕對價格)",
"maxSpreadTooltip": "最大價差(絕對價格)NULL表示不啟用此過濾。避免在價差過大的市場跟單", "maxSpreadTooltip": "最大價差(絕對價格)。避免在價差過大的市場跟單。不填寫則不啟用此過濾",
"maxSpreadPlaceholder": "例如:0.05(5美分,可選,不填寫表示不啟用)", "maxSpreadPlaceholder": "例如:0.05(5美分,可選,不填寫表示不啟用)",
"minOrderbookDepth": "最小訂單簿深度 (USDC)", "minOrderbookDepth": "最小訂單簿深度 (USDC)",
"minOrderbookDepthTooltip": "最小訂單簿深度(USDC金額),NULL表示不啟用此過濾。檢查前 N 檔的深度", "minOrderbookDepthTooltip": "最小訂單簿深度(USDC金額),NULL表示不啟用此過濾。檢查前 N 檔的深度",
@@ -794,10 +794,10 @@
"delaySecondsPlaceholder": "默認 0(立即跟單)", "delaySecondsPlaceholder": "默認 0(立即跟單)",
"filterConditions": "過濾條件(可選)", "filterConditions": "過濾條件(可選)",
"minOrderDepth": "最小訂單深度 (USDC)", "minOrderDepth": "最小訂單深度 (USDC)",
"minOrderDepthTooltip": "最小訂單深度(USDC金額),NULL表示不啟用此過濾", "minOrderDepthTooltip": "檢查訂單簿的總訂單金額(買盤+賣盤),確保市場有足夠的流動性。不填寫則不啟用此過濾",
"minOrderDepthPlaceholder": "例如:100(可選,不填寫表示不啟用)", "minOrderDepthPlaceholder": "例如:100(可選,不填寫表示不啟用)",
"maxSpread": "最大價差(絕對價格)", "maxSpread": "最大價差(絕對價格)",
"maxSpreadTooltip": "最大價差(絕對價格)NULL表示不啟用此過濾", "maxSpreadTooltip": "最大價差(絕對價格)。避免在價差過大的市場跟單。不填寫則不啟用此過濾",
"maxSpreadPlaceholder": "例如:0.05(5美分,可選,不填寫表示不啟用)", "maxSpreadPlaceholder": "例如:0.05(5美分,可選,不填寫表示不啟用)",
"minOrderbookDepth": "最小訂單簿深度 (USDC)", "minOrderbookDepth": "最小訂單簿深度 (USDC)",
"minOrderbookDepthTooltip": "最小訂單簿深度(USDC金額),NULL表示不啟用此過濾", "minOrderbookDepthTooltip": "最小訂單簿深度(USDC金額),NULL表示不啟用此過濾",
@@ -856,6 +856,8 @@
"copyTradingList": { "copyTradingList": {
"title": "跟單配置管理", "title": "跟單配置管理",
"addCopyTrading": "新增跟單", "addCopyTrading": "新增跟單",
"configName": "配置名",
"configNameNotProvided": "未提供",
"wallet": "錢包", "wallet": "錢包",
"account": "賬戶", "account": "賬戶",
"template": "模板", "template": "模板",
@@ -868,10 +870,11 @@
"totalPnl": "總盈虧", "totalPnl": "總盈虧",
"statistics": "統計", "statistics": "統計",
"orders": "訂單", "orders": "訂單",
"matchedOrders": "已成交訂單",
"ordersButton": "訂單",
"viewStatistics": "查看統計", "viewStatistics": "查看統計",
"buyOrders": "買入訂單", "buyOrders": "買入訂單",
"sellOrders": "賣出訂單", "sellOrders": "賣出訂單",
"matchedOrders": "匹配關係",
"filteredOrders": "被過濾訂單", "filteredOrders": "被過濾訂單",
"filterWallet": "篩選錢包", "filterWallet": "篩選錢包",
"filterTemplate": "篩選模板", "filterTemplate": "篩選模板",
@@ -966,5 +969,72 @@
"collapse": "收起", "collapse": "收起",
"refresh": "刷新", "refresh": "刷新",
"list": "列表" "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) => { const handleCopy = (text: string) => {
navigator.clipboard.writeText(text).then(() => { if (!text) {
message.success(t('accountList.copySuccess')) message.warning(t('accountList.copyFailed') || '复制失败:地址为空')
}).catch(() => { return
message.error(t('accountList.copyFailed')) }
})
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) => { const handleShowDetail = async (account: Account) => {
@@ -210,46 +253,45 @@ const AccountList: React.FC = () => {
title: t('accountList.walletAddress'), title: t('accountList.walletAddress'),
dataIndex: 'walletAddress', dataIndex: 'walletAddress',
key: 'walletAddress', key: 'walletAddress',
render: (text: string) => ( render: (text: string) => {
<Space> const formatted = text ? `${text.slice(0, 6)}...${text.slice(-4)}` : '-'
<span style={{ fontFamily: 'monospace', fontSize: '12px' }}>{text}</span> return (
<Button <Space>
type="text" <span style={{ fontFamily: 'monospace', fontSize: '12px' }}>{formatted}</span>
size="small" <Button
icon={<CopyOutlined />} type="text"
onClick={() => handleCopy(text)} size="small"
title={t('accountList.walletAddress')} icon={<CopyOutlined />}
/> onClick={(e) => {
</Space> e.stopPropagation()
) handleCopy(text)
}}
title={t('accountList.walletAddress')}
/>
</Space>
)
}
}, },
{ {
title: t('accountList.proxyAddress'), title: t('accountList.proxyAddress'),
dataIndex: 'proxyAddress', dataIndex: 'proxyAddress',
key: 'proxyAddress', key: 'proxyAddress',
render: (address: string) => ( render: (address: string) => {
<Space> const formatted = address ? `${address.slice(0, 6)}...${address.slice(-4)}` : '-'
<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
return ( return (
<Tag color={allConfigured ? 'success' : partialConfigured ? 'warning' : 'default'}> <Space>
{allConfigured ? t('accountList.fullConfig') : partialConfigured ? t('accountList.partialConfig') : t('accountList.notConfigured')} <span style={{ fontFamily: 'monospace', fontSize: '12px' }}>{formatted}</span>
</Tag> <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` : '-' 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'), title: t('accountList.action'),
key: 'action', key: 'action',
@@ -323,9 +354,6 @@ const AccountList: React.FC = () => {
title: t('accountList.accountName'), title: t('accountList.accountName'),
key: 'info', key: 'info',
render: (_: any, record: Account) => { render: (_: any, record: Account) => {
const allConfigured = record.apiKeyConfigured && record.apiSecretConfigured && record.apiPassphraseConfigured
const partialConfigured = record.apiKeyConfigured || record.apiSecretConfigured || record.apiPassphraseConfigured
return ( return (
<div style={{ padding: '8px 0' }}> <div style={{ padding: '8px 0' }}>
<div style={{ <div style={{
@@ -344,31 +372,32 @@ const AccountList: React.FC = () => {
lineHeight: '1.4' lineHeight: '1.4'
}}> }}>
<div style={{ marginBottom: '4px' }}> <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 <Button
type="text" type="text"
size="small" size="small"
icon={<CopyOutlined />} icon={<CopyOutlined />}
onClick={() => handleCopy(record.walletAddress)} onClick={(e) => {
e.stopPropagation()
handleCopy(record.walletAddress)
}}
style={{ marginLeft: '4px', padding: '0 4px' }} style={{ marginLeft: '4px', padding: '0 4px' }}
/> />
</div> </div>
<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 <Button
type="text" type="text"
size="small" size="small"
icon={<CopyOutlined />} icon={<CopyOutlined />}
onClick={() => handleCopy(record.proxyAddress)} onClick={(e) => {
e.stopPropagation()
handleCopy(record.proxyAddress)
}}
style={{ marginLeft: '4px', padding: '0 4px' }} style={{ marginLeft: '4px', padding: '0 4px' }}
/> />
</div> </div>
</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={{ <div style={{
fontSize: '14px', fontSize: '14px',
fontWeight: '500', 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 {t('accountList.available')}: {formatUSDC(balanceMap[record.id].available)} USDC | {t('accountList.position')}: {formatUSDC(balanceMap[record.id].position)} USDC
</div> </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> </div>
) )
} }
@@ -598,7 +615,10 @@ const AccountList: React.FC = () => {
type="text" type="text"
size="small" size="small"
icon={<CopyOutlined />} icon={<CopyOutlined />}
onClick={() => handleCopy(detailAccount.walletAddress || '')} onClick={(e) => {
e.stopPropagation()
handleCopy(detailAccount.walletAddress || '')
}}
title={t('accountList.walletAddress')} title={t('accountList.walletAddress')}
/> />
</Space> </Space>
@@ -618,7 +638,10 @@ const AccountList: React.FC = () => {
type="text" type="text"
size="small" size="small"
icon={<CopyOutlined />} icon={<CopyOutlined />}
onClick={() => handleCopy(detailAccount.proxyAddress || '')} onClick={(e) => {
e.stopPropagation()
handleCopy(detailAccount.proxyAddress || '')
}}
title={t('accountList.proxyAddress')} title={t('accountList.proxyAddress')}
/> />
</Space> </Space>
+2 -18
View File
@@ -84,7 +84,6 @@ const CopyTradingAdd: React.FC = () => {
supportSell: template.supportSell, supportSell: template.supportSell,
minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined, minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined,
maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined, maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined,
minOrderbookDepth: template.minOrderbookDepth ? parseFloat(template.minOrderbookDepth) : undefined,
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined, minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined
}) })
@@ -133,7 +132,6 @@ const CopyTradingAdd: React.FC = () => {
supportSell: values.supportSell !== false, supportSell: values.supportSell !== false,
minOrderDepth: values.minOrderDepth?.toString(), minOrderDepth: values.minOrderDepth?.toString(),
maxSpread: values.maxSpread?.toString(), maxSpread: values.maxSpread?.toString(),
minOrderbookDepth: values.minOrderbookDepth?.toString(),
minPrice: values.minPrice?.toString(), minPrice: values.minPrice?.toString(),
maxPrice: values.maxPrice?.toString(), maxPrice: values.maxPrice?.toString(),
configName: values.configName?.trim(), configName: values.configName?.trim(),
@@ -412,7 +410,7 @@ const CopyTradingAdd: React.FC = () => {
<Form.Item <Form.Item
label={t('copyTradingAdd.minOrderDepth') || '最小订单深度 (USDC)'} label={t('copyTradingAdd.minOrderDepth') || '最小订单深度 (USDC)'}
name="minOrderDepth" name="minOrderDepth"
tooltip={t('copyTradingAdd.minOrderDepthTooltip') || '最小订单深度(USDC金额),NULL表示不启用此过滤。确保市场有足够的流动性'} tooltip={t('copyTradingAdd.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
> >
<InputNumber <InputNumber
min={0} min={0}
@@ -426,7 +424,7 @@ const CopyTradingAdd: React.FC = () => {
<Form.Item <Form.Item
label={t('copyTradingAdd.maxSpread') || '最大价差(绝对价格)'} label={t('copyTradingAdd.maxSpread') || '最大价差(绝对价格)'}
name="maxSpread" name="maxSpread"
tooltip={t('copyTradingAdd.maxSpreadTooltip') || '最大价差(绝对价格)NULL表示不启用此过滤。避免在价差过大的市场跟单'} tooltip={t('copyTradingAdd.maxSpreadTooltip') || '最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤'}
> >
<InputNumber <InputNumber
min={0} min={0}
@@ -437,20 +435,6 @@ const CopyTradingAdd: React.FC = () => {
/> />
</Form.Item> </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> <Divider>{t('copyTradingAdd.priceRangeFilter') || '价格区间过滤'}</Divider>
<Form.Item <Form.Item
+2 -18
View File
@@ -56,7 +56,6 @@ const CopyTradingEdit: React.FC = () => {
supportSell: found.supportSell, supportSell: found.supportSell,
minOrderDepth: found.minOrderDepth ? parseFloat(found.minOrderDepth) : undefined, minOrderDepth: found.minOrderDepth ? parseFloat(found.minOrderDepth) : undefined,
maxSpread: found.maxSpread ? parseFloat(found.maxSpread) : undefined, maxSpread: found.maxSpread ? parseFloat(found.maxSpread) : undefined,
minOrderbookDepth: found.minOrderbookDepth ? parseFloat(found.minOrderbookDepth) : undefined,
minPrice: found.minPrice ? parseFloat(found.minPrice) : undefined, minPrice: found.minPrice ? parseFloat(found.minPrice) : undefined,
maxPrice: found.maxPrice ? parseFloat(found.maxPrice) : undefined, maxPrice: found.maxPrice ? parseFloat(found.maxPrice) : undefined,
configName: found.configName || '', configName: found.configName || '',
@@ -122,7 +121,6 @@ const CopyTradingEdit: React.FC = () => {
supportSell: values.supportSell, supportSell: values.supportSell,
minOrderDepth: values.minOrderDepth?.toString(), minOrderDepth: values.minOrderDepth?.toString(),
maxSpread: values.maxSpread?.toString(), maxSpread: values.maxSpread?.toString(),
minOrderbookDepth: values.minOrderbookDepth?.toString(),
minPrice: values.minPrice?.toString(), minPrice: values.minPrice?.toString(),
maxPrice: values.maxPrice?.toString(), maxPrice: values.maxPrice?.toString(),
configName: values.configName?.trim() || undefined, configName: values.configName?.trim() || undefined,
@@ -381,7 +379,7 @@ const CopyTradingEdit: React.FC = () => {
<Form.Item <Form.Item
label={t('copyTradingEdit.minOrderDepth') || '最小订单深度 (USDC)'} label={t('copyTradingEdit.minOrderDepth') || '最小订单深度 (USDC)'}
name="minOrderDepth" name="minOrderDepth"
tooltip={t('copyTradingEdit.minOrderDepthTooltip') || '最小订单深度(USDC金额),NULL表示不启用此过滤'} tooltip={t('copyTradingEdit.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
> >
<InputNumber <InputNumber
min={0} min={0}
@@ -395,7 +393,7 @@ const CopyTradingEdit: React.FC = () => {
<Form.Item <Form.Item
label={t('copyTradingEdit.maxSpread') || '最大价差(绝对价格)'} label={t('copyTradingEdit.maxSpread') || '最大价差(绝对价格)'}
name="maxSpread" name="maxSpread"
tooltip={t('copyTradingEdit.maxSpreadTooltip') || '最大价差(绝对价格)NULL表示不启用此过滤'} tooltip={t('copyTradingEdit.maxSpreadTooltip') || '最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤'}
> >
<InputNumber <InputNumber
min={0} min={0}
@@ -406,20 +404,6 @@ const CopyTradingEdit: React.FC = () => {
/> />
</Form.Item> </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> <Divider>{t('copyTradingEdit.priceRangeFilter') || '价格区间过滤'}</Divider>
<Form.Item <Form.Item
+77 -52
View File
@@ -9,6 +9,10 @@ import { useAccountStore } from '../store/accountStore'
import type { CopyTrading, Leader, CopyTradingStatistics } from '../types' import type { CopyTrading, Leader, CopyTradingStatistics } from '../types'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
import { formatUSDC } from '../utils' 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 const { Option } = Select
@@ -28,6 +32,17 @@ const CopyTradingList: React.FC = () => {
enabled?: boolean 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(() => { useEffect(() => {
fetchAccounts() fetchAccounts()
fetchLeaders() fetchLeaders()
@@ -265,47 +280,24 @@ const CopyTradingList: React.FC = () => {
fixed: 'right' as const, fixed: 'right' as const,
render: (_: any, record: CopyTrading) => { render: (_: any, record: CopyTrading) => {
const menuItems: MenuProps['items'] = [ 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', key: 'matchedOrders',
label: t('copyTradingList.matchedOrders') || '匹配关系', label: t('copyTradingList.matchedOrders') || '已成交订单',
icon: <UnorderedListOutlined />, icon: <UnorderedListOutlined />,
onClick: () => navigate(`/copy-trading/orders/matched/${record.id}`) onClick: () => {
setOrdersModalCopyTradingId(record.id.toString())
setOrdersModalTab('buy')
setOrdersModalOpen(true)
}
}, },
{ {
key: 'filteredOrders', key: 'filteredOrders',
label: t('copyTradingList.filteredOrders') || '已过滤订单', label: t('copyTradingList.filteredOrders') || '已过滤订单',
icon: <UnorderedListOutlined />, icon: <UnorderedListOutlined />,
onClick: () => navigate(`/copy-trading/filtered-orders/${record.id}`) onClick: () => {
}, setFilteredOrdersModalCopyTradingId(record.id.toString())
{ setFilteredOrdersModalOpen(true)
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
} }
] ]
@@ -317,7 +309,10 @@ const CopyTradingList: React.FC = () => {
type="link" type="link"
size="small" size="small"
icon={<EditOutlined />} icon={<EditOutlined />}
onClick={() => navigate(`/copy-trading/edit/${record.id}`)} onClick={() => {
setEditModalCopyTradingId(record.id.toString())
setEditModalOpen(true)
}}
> >
{t('common.edit') || '编辑'} {t('common.edit') || '编辑'}
</Button> </Button>
@@ -325,7 +320,10 @@ const CopyTradingList: React.FC = () => {
type="link" type="link"
size="small" size="small"
icon={<BarChartOutlined />} icon={<BarChartOutlined />}
onClick={() => navigate(`/copy-trading/statistics/${record.id}`)} onClick={() => {
setStatisticsModalCopyTradingId(record.id.toString())
setStatisticsModalOpen(true)
}}
> >
{t('copyTradingList.statistics') || '统计'} {t('copyTradingList.statistics') || '统计'}
</Button> </Button>
@@ -554,7 +552,10 @@ const CopyTradingList: React.FC = () => {
type="primary" type="primary"
size="small" size="small"
icon={<EditOutlined />} icon={<EditOutlined />}
onClick={() => navigate(`/copy-trading/edit/${record.id}`)} onClick={() => {
setEditModalCopyTradingId(record.id.toString())
setEditModalOpen(true)
}}
style={{ flex: 1, minWidth: '80px' }} style={{ flex: 1, minWidth: '80px' }}
> >
{t('common.edit') || '编辑'} {t('common.edit') || '编辑'}
@@ -562,7 +563,10 @@ const CopyTradingList: React.FC = () => {
<Button <Button
size="small" size="small"
icon={<BarChartOutlined />} icon={<BarChartOutlined />}
onClick={() => navigate(`/copy-trading/statistics/${record.id}`)} onClick={() => {
setStatisticsModalCopyTradingId(record.id.toString())
setStatisticsModalOpen(true)
}}
style={{ flex: 1, minWidth: '80px' }} style={{ flex: 1, minWidth: '80px' }}
> >
{t('copyTradingList.statistics') || '统计'} {t('copyTradingList.statistics') || '统计'}
@@ -570,29 +574,24 @@ const CopyTradingList: React.FC = () => {
<Dropdown <Dropdown
menu={{ menu={{
items: [ 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', key: 'matchedOrders',
label: t('copyTradingList.matchedOrders') || '匹配关系', label: t('copyTradingList.matchedOrders') || '已成交订单',
icon: <UnorderedListOutlined />, icon: <UnorderedListOutlined />,
onClick: () => navigate(`/copy-trading/orders/matched/${record.id}`) onClick: () => {
setOrdersModalCopyTradingId(record.id.toString())
setOrdersModalTab('buy')
setOrdersModalOpen(true)
}
}, },
{ {
key: 'filteredOrders', key: 'filteredOrders',
label: t('copyTradingList.filteredOrders') || '已过滤订单', label: t('copyTradingList.filteredOrders') || '已过滤订单',
icon: <UnorderedListOutlined />, 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> </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> </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 { useState } from 'react'
import { useNavigate } from 'react-router-dom' 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 { ArrowLeftOutlined } from '@ant-design/icons'
import { apiService } from '../services/api' import { apiService } from '../services/api'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
@@ -8,7 +8,6 @@ import { useTranslation } from 'react-i18next'
import { isValidWalletAddress } from '../utils' import { isValidWalletAddress } from '../utils'
const { Title } = Typography const { Title } = Typography
const { Option } = Select
const LeaderAdd: React.FC = () => { const LeaderAdd: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
@@ -24,8 +23,7 @@ const LeaderAdd: React.FC = () => {
leaderAddress: values.leaderAddress.trim(), leaderAddress: values.leaderAddress.trim(),
leaderName: values.leaderName?.trim() || undefined, leaderName: values.leaderName?.trim() || undefined,
remark: values.remark?.trim() || undefined, remark: values.remark?.trim() || undefined,
website: values.website?.trim() || undefined, website: values.website?.trim() || undefined
category: values.category || undefined
}) })
if (response.data.code === 0) { if (response.data.code === 0) {
@@ -121,17 +119,6 @@ const LeaderAdd: React.FC = () => {
<Input placeholder={t('leaderAdd.websitePlaceholder') || '可选,例如:https://example.com'} /> <Input placeholder={t('leaderAdd.websitePlaceholder') || '可选,例如:https://example.com'} />
</Form.Item> </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> <Form.Item>
<Space> <Space>
<Button <Button
+3 -17
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom' 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 { ArrowLeftOutlined } from '@ant-design/icons'
import { apiService } from '../services/api' import { apiService } from '../services/api'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
@@ -8,7 +8,6 @@ import { useTranslation } from 'react-i18next'
import type { Leader } from '../types' import type { Leader } from '../types'
const { Title } = Typography const { Title } = Typography
const { Option } = Select
const LeaderEdit: React.FC = () => { const LeaderEdit: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
@@ -38,8 +37,7 @@ const LeaderEdit: React.FC = () => {
form.setFieldsValue({ form.setFieldsValue({
leaderName: leader.leaderName || '', leaderName: leader.leaderName || '',
remark: leader.remark || '', remark: leader.remark || '',
website: leader.website || '', website: leader.website || ''
category: leader.category || undefined
}) })
} else { } else {
message.error(response.data.msg || t('leaderEdit.fetchFailed') || '获取 Leader 详情失败') message.error(response.data.msg || t('leaderEdit.fetchFailed') || '获取 Leader 详情失败')
@@ -65,8 +63,7 @@ const LeaderEdit: React.FC = () => {
leaderId: parseInt(leaderId), leaderId: parseInt(leaderId),
leaderName: values.leaderName?.trim() || undefined, leaderName: values.leaderName?.trim() || undefined,
remark: values.remark?.trim() || undefined, remark: values.remark?.trim() || undefined,
website: values.website?.trim() || undefined, website: values.website?.trim() || undefined
category: values.category || undefined
}) })
if (response.data.code === 0) { if (response.data.code === 0) {
@@ -145,17 +142,6 @@ const LeaderEdit: React.FC = () => {
<Input placeholder={t('leaderEdit.websitePlaceholder') || '可选,例如:https://example.com'} /> <Input placeholder={t('leaderEdit.websitePlaceholder') || '可选,例如:https://example.com'} />
</Form.Item> </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> <Form.Item>
<Space> <Space>
<Button <Button
+3 -37
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider, Typography } from 'antd' 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 { useTranslation } from 'react-i18next'
import { apiService } from '../services/api' import { apiService } from '../services/api'
import type { Leader } from '../types' import type { Leader } from '../types'
@@ -67,14 +67,6 @@ const LeaderList: React.FC = () => {
</span> </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') || '备注', title: t('leaderList.remark') || '备注',
dataIndex: 'remark', dataIndex: 'remark',
@@ -233,37 +225,11 @@ const LeaderList: React.FC = () => {
</div> </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' }} /> <Divider style={{ margin: '12px 0' }} />
{/* 分类和跟单关系数 */} {/* 跟单关系数 */}
<div style={{ marginBottom: '12px' }}> <div style={{ marginBottom: '12px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}> <Tag>{t('leaderList.copyTradingRelations', { count: leader.copyTradingCount }) || `${leader.copyTradingCount} 个跟单关系`}</Tag>
{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>
</div> </div>
{/* 创建时间 */} {/* 创建时间 */}
+14 -11
View File
@@ -348,10 +348,11 @@ const SystemSettings: React.FC = () => {
if (response.data.code === 0 && response.data.data) { if (response.data.code === 0 && response.data.data) {
const config = response.data.data const config = response.data.data
setSystemConfig(config) setSystemConfig(config)
// 将已配置的值填充到输入框中
relayerForm.setFieldsValue({ relayerForm.setFieldsValue({
builderApiKey: '', builderApiKey: config.builderApiKeyDisplay || '',
builderSecret: '', builderSecret: config.builderSecretDisplay || '',
builderPassphrase: '', builderPassphrase: config.builderPassphraseDisplay || '',
}) })
autoRedeemForm.setFieldsValue({ autoRedeemForm.setFieldsValue({
autoRedeemEnabled: config.autoRedeemEnabled autoRedeemEnabled: config.autoRedeemEnabled
@@ -685,30 +686,32 @@ const SystemSettings: React.FC = () => {
<Form.Item <Form.Item
label={t('builderApiKey.apiKey')} label={t('builderApiKey.apiKey')}
name="builderApiKey" name="builderApiKey"
help={systemConfig?.builderApiKeyConfigured ? t('builderApiKey.apiKeyHelp') : t('builderApiKey.apiKeyPlaceholder')}
> >
<Input <Input
placeholder={systemConfig?.builderApiKeyConfigured ? t('builderApiKey.apiKeyHelp') : t('builderApiKey.apiKeyPlaceholder')} placeholder={t('builderApiKey.apiKeyPlaceholder')}
style={{ fontFamily: 'monospace' }}
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t('builderApiKey.secret')} label={t('builderApiKey.secret')}
name="builderSecret" name="builderSecret"
help={systemConfig?.builderSecretConfigured ? t('builderApiKey.secretHelp') : t('builderApiKey.secretPlaceholder')}
> >
<Input <Input.Password
placeholder={systemConfig?.builderSecretConfigured ? t('builderApiKey.secretHelp') : t('builderApiKey.secretPlaceholder')} placeholder={t('builderApiKey.secretPlaceholder')}
style={{ fontFamily: 'monospace' }}
iconRender={(visible) => (visible ? <span>👁</span> : <span>👁🗨</span>)}
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t('builderApiKey.passphrase')} label={t('builderApiKey.passphrase')}
name="builderPassphrase" name="builderPassphrase"
help={systemConfig?.builderPassphraseConfigured ? t('builderApiKey.passphraseHelp') : t('builderApiKey.passphrasePlaceholder')}
> >
<Input <Input.Password
placeholder={systemConfig?.builderPassphraseConfigured ? t('builderApiKey.passphraseHelp') : t('builderApiKey.passphrasePlaceholder')} placeholder={t('builderApiKey.passphrasePlaceholder')}
style={{ fontFamily: 'monospace' }}
iconRender={(visible) => (visible ? <span>👁</span> : <span>👁🗨</span>)}
/> />
</Form.Item> </Form.Item>
+2 -17
View File
@@ -54,7 +54,6 @@ const TemplateAdd: React.FC = () => {
supportSell: values.supportSell !== false, supportSell: values.supportSell !== false,
minOrderDepth: values.minOrderDepth?.toString(), minOrderDepth: values.minOrderDepth?.toString(),
maxSpread: values.maxSpread?.toString(), maxSpread: values.maxSpread?.toString(),
minOrderbookDepth: values.minOrderbookDepth?.toString(),
minPrice: values.minPrice?.toString(), minPrice: values.minPrice?.toString(),
maxPrice: values.maxPrice?.toString() maxPrice: values.maxPrice?.toString()
}) })
@@ -258,7 +257,7 @@ const TemplateAdd: React.FC = () => {
<Form.Item <Form.Item
label={t('templateAdd.minOrderDepth') || '最小订单深度 (USDC)'} label={t('templateAdd.minOrderDepth') || '最小订单深度 (USDC)'}
name="minOrderDepth" name="minOrderDepth"
tooltip={t('templateAdd.minOrderDepthTooltip') || '最小订单深度(USDC金额),NULL表示不启用此过滤。确保市场有足够的流动性'} tooltip={t('templateAdd.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
> >
<InputNumber <InputNumber
min={0} min={0}
@@ -272,7 +271,7 @@ const TemplateAdd: React.FC = () => {
<Form.Item <Form.Item
label={t('templateAdd.maxSpread') || '最大价差(绝对价格)'} label={t('templateAdd.maxSpread') || '最大价差(绝对价格)'}
name="maxSpread" name="maxSpread"
tooltip={t('templateAdd.maxSpreadTooltip') || '最大价差(绝对价格)NULL表示不启用此过滤。避免在价差过大的市场跟单'} tooltip={t('templateAdd.maxSpreadTooltip') || '最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤'}
> >
<InputNumber <InputNumber
min={0} min={0}
@@ -283,20 +282,6 @@ const TemplateAdd: React.FC = () => {
/> />
</Form.Item> </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> <Divider>{t('templateAdd.priceRangeFilter') || '价格区间过滤'}</Divider>
<Form.Item <Form.Item
+2 -18
View File
@@ -40,7 +40,6 @@ const TemplateEdit: React.FC = () => {
priceTolerance: parseFloat(template.priceTolerance), priceTolerance: parseFloat(template.priceTolerance),
minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined, minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined,
maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined, maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined,
minOrderbookDepth: template.minOrderbookDepth ? parseFloat(template.minOrderbookDepth) : undefined,
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined, minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined
}) })
@@ -99,7 +98,6 @@ const TemplateEdit: React.FC = () => {
supportSell: values.supportSell, supportSell: values.supportSell,
minOrderDepth: values.minOrderDepth?.toString(), minOrderDepth: values.minOrderDepth?.toString(),
maxSpread: values.maxSpread?.toString(), maxSpread: values.maxSpread?.toString(),
minOrderbookDepth: values.minOrderbookDepth?.toString(),
minPrice: values.minPrice?.toString(), minPrice: values.minPrice?.toString(),
maxPrice: values.maxPrice?.toString() maxPrice: values.maxPrice?.toString()
}) })
@@ -295,7 +293,7 @@ const TemplateEdit: React.FC = () => {
<Form.Item <Form.Item
label={t('templateEdit.minOrderDepth') || '最小订单深度 (USDC)'} label={t('templateEdit.minOrderDepth') || '最小订单深度 (USDC)'}
name="minOrderDepth" name="minOrderDepth"
tooltip={t('templateEdit.minOrderDepthTooltip') || '最小订单深度(USDC金额),NULL表示不启用此过滤。确保市场有足够的流动性'} tooltip={t('templateEdit.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
> >
<InputNumber <InputNumber
min={0} min={0}
@@ -309,7 +307,7 @@ const TemplateEdit: React.FC = () => {
<Form.Item <Form.Item
label={t('templateEdit.maxSpread') || '最大价差(绝对价格)'} label={t('templateEdit.maxSpread') || '最大价差(绝对价格)'}
name="maxSpread" name="maxSpread"
tooltip={t('templateEdit.maxSpreadTooltip') || '最大价差(绝对价格)NULL表示不启用此过滤。避免在价差过大的市场跟单'} tooltip={t('templateEdit.maxSpreadTooltip') || '最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤'}
> >
<InputNumber <InputNumber
min={0} min={0}
@@ -320,20 +318,6 @@ const TemplateEdit: React.FC = () => {
/> />
</Form.Item> </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> <Divider>{t('templateEdit.priceRangeFilter') || '价格区间过滤'}</Divider>
<Form.Item <Form.Item
+2 -18
View File
@@ -74,7 +74,6 @@ const TemplateList: React.FC = () => {
supportSell: template.supportSell, supportSell: template.supportSell,
minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined, minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined,
maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined, maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined,
minOrderbookDepth: template.minOrderbookDepth ? parseFloat(template.minOrderbookDepth) : undefined,
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined, minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined
}) })
@@ -122,7 +121,6 @@ const TemplateList: React.FC = () => {
supportSell: values.supportSell !== false, supportSell: values.supportSell !== false,
minOrderDepth: values.minOrderDepth?.toString(), minOrderDepth: values.minOrderDepth?.toString(),
maxSpread: values.maxSpread?.toString(), maxSpread: values.maxSpread?.toString(),
minOrderbookDepth: values.minOrderbookDepth?.toString(),
minPrice: values.minPrice?.toString(), minPrice: values.minPrice?.toString(),
maxPrice: values.maxPrice?.toString() maxPrice: values.maxPrice?.toString()
}) })
@@ -613,7 +611,7 @@ const TemplateList: React.FC = () => {
<Form.Item <Form.Item
label="最小订单深度 (USDC)" label="最小订单深度 (USDC)"
name="minOrderDepth" name="minOrderDepth"
tooltip="最小订单深度(USDC金额),NULL表示不启用此过滤。确保市场有足够的流动性" tooltip="检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤"
> >
<InputNumber <InputNumber
min={0} min={0}
@@ -627,7 +625,7 @@ const TemplateList: React.FC = () => {
<Form.Item <Form.Item
label="最大价差(绝对价格)" label="最大价差(绝对价格)"
name="maxSpread" name="maxSpread"
tooltip="最大价差(绝对价格)NULL表示不启用此过滤。避免在价差过大的市场跟单" tooltip="最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤"
> >
<InputNumber <InputNumber
min={0} min={0}
@@ -638,20 +636,6 @@ const TemplateList: React.FC = () => {
/> />
</Form.Item> </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> <Divider></Divider>
<Form.Item <Form.Item
+38 -38
View File
@@ -145,31 +145,31 @@ export const apiService = {
* *
*/ */
list: () => list: () =>
apiClient.post<ApiResponse<any[]>>('/users/list', {}), apiClient.post<ApiResponse<any[]>>('/system/users/list', {}),
/** /**
* *
*/ */
create: (data: { username: string; password: string }) => 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 }) => 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 }) => delete: (data: { userId: number }) =>
apiClient.post<ApiResponse<void>>('/users/delete', data), apiClient.post<ApiResponse<void>>('/system/users/delete', data),
/** /**
* *
*/ */
updateOwnPassword: (data: { newPassword: string }) => 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) => import: (data: any) =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/import', data), apiClient.post<ApiResponse<any>>('/accounts/import', data),
/** /**
* *
*/ */
update: (data: any) => update: (data: any) =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/update', data), apiClient.post<ApiResponse<any>>('/accounts/update', data),
/** /**
* *
*/ */
delete: (data: { accountId: number }) => delete: (data: { accountId: number }) =>
apiClient.post<ApiResponse<void>>('/copy-trading/accounts/delete', data), apiClient.post<ApiResponse<void>>('/accounts/delete', data),
/** /**
* *
*/ */
list: () => list: () =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/list', {}), apiClient.post<ApiResponse<any>>('/accounts/list', {}),
/** /**
* *
*/ */
detail: (data: { accountId?: number }) => detail: (data: { accountId?: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/detail', data), apiClient.post<ApiResponse<any>>('/accounts/detail', data),
/** /**
* *
*/ */
balance: (data: { accountId?: number }) => balance: (data: { accountId?: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/balance', data), apiClient.post<ApiResponse<any>>('/accounts/balance', data),
/** /**
* *
*/ */
positionsList: () => positionsList: () =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/positions/list', {}), apiClient.post<ApiResponse<any>>('/accounts/positions/list', {}),
/** /**
* *
*/ */
sellPosition: (data: any) => 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 }) => 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) => 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 * Gamma API
*/ */
getMarketPrice: (data: { marketId: string; outcomeIndex?: number }) => 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 }) => 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 * 2. templateId
*/ */
create: (data: any) => create: (data: any) =>
apiClient.post<ApiResponse<any>>('/copy-trading/create', data), apiClient.post<ApiResponse<any>>('/copy-trading/configs/create', data),
/** /**
* *
*/ */
update: (data: any) => 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 } = {}) => 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 }) => 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 }) => 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 }) => 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 startTime?: number
endTime?: 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: () => get: () =>
apiClient.post<ApiResponse<any>>('/proxy-config/get', {}), apiClient.post<ApiResponse<any>>('/system/proxy/get', {}),
/** /**
* *
*/ */
list: () => list: () =>
apiClient.post<ApiResponse<any[]>>('/proxy-config/list', {}), apiClient.post<ApiResponse<any[]>>('/system/proxy/list', {}),
/** /**
* HTTP * HTTP
@@ -494,7 +494,7 @@ export const apiService = {
username?: string username?: string
password?: 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 success: boolean
message: string message: string
responseTime?: number responseTime?: number
}>>('/proxy-config/check', {}), }>>('/system/proxy/check', {}),
/** /**
* *
*/ */
delete: (data: { id: number }) => delete: (data: { id: number }) =>
apiClient.post<ApiResponse<void>>('/proxy-config/delete', data), apiClient.post<ApiResponse<void>>('/system/proxy/delete', data),
/** /**
* API * API
@@ -524,7 +524,7 @@ export const apiService = {
message: string message: string
responseTime?: number responseTime?: number
}> }>
}>>('/proxy-config/api-health-check', {}) }>>('/system/proxy/api-health-check', {})
}, },
/** /**
@@ -535,49 +535,49 @@ export const apiService = {
* *
*/ */
list: (data?: { type?: string }) => 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 }) => detail: (data: { id: number }) =>
apiClient.post<ApiResponse<NotificationConfig>>('/notifications/configs/detail', data), apiClient.post<ApiResponse<NotificationConfig>>('/system/notifications/configs/detail', data),
/** /**
* *
*/ */
create: (data: NotificationConfigRequest) => create: (data: NotificationConfigRequest) =>
apiClient.post<ApiResponse<NotificationConfig>>('/notifications/configs/create', data), apiClient.post<ApiResponse<NotificationConfig>>('/system/notifications/configs/create', data),
/** /**
* *
*/ */
update: (data: NotificationConfigUpdateRequest) => 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 }) => 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 }) => 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 }) => test: (data?: { message?: string }) =>
apiClient.post<ApiResponse<boolean>>('/notifications/test', data || {}), apiClient.post<ApiResponse<boolean>>('/system/notifications/test', data || {}),
/** /**
* Telegram Chat IDs * Telegram Chat IDs
*/ */
getTelegramChatIds: (data: { botToken: string }) => 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 minOrderDepth?: string
maxSpread?: string maxSpread?: string
minOrderbookDepth?: string
minPrice?: string // 最低价格(可选),NULL表示不限制最低价 minPrice?: string // 最低价格(可选),NULL表示不限制最低价
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价 maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
createdAt: number createdAt: number
@@ -204,7 +203,6 @@ export interface CopyTrading {
// 过滤条件 // 过滤条件
minOrderDepth?: string minOrderDepth?: string
maxSpread?: string maxSpread?: string
minOrderbookDepth?: string
minPrice?: string // 最低价格(可选),NULL表示不限制最低价 minPrice?: string // 最低价格(可选),NULL表示不限制最低价
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价 maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
// 新增配置字段 // 新增配置字段
@@ -248,7 +246,6 @@ export interface CopyTradingCreateRequest {
// 过滤条件 // 过滤条件
minOrderDepth?: string minOrderDepth?: string
maxSpread?: string maxSpread?: string
minOrderbookDepth?: string
minPrice?: string // 最低价格(可选),NULL表示不限制最低价 minPrice?: string // 最低价格(可选),NULL表示不限制最低价
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价 maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
// 新增配置字段 // 新增配置字段
@@ -280,7 +277,6 @@ export interface CopyTradingUpdateRequest {
// 过滤条件 // 过滤条件
minOrderDepth?: string minOrderDepth?: string
maxSpread?: string maxSpread?: string
minOrderbookDepth?: string
minPrice?: string // 最低价格(可选),NULL表示不限制最低价 minPrice?: string // 最低价格(可选),NULL表示不限制最低价
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价 maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
// 新增配置字段 // 新增配置字段
@@ -774,6 +770,9 @@ export interface SystemConfig {
builderApiKeyConfigured: boolean builderApiKeyConfigured: boolean
builderSecretConfigured: boolean builderSecretConfigured: boolean
builderPassphraseConfigured: boolean builderPassphraseConfigured: boolean
builderApiKeyDisplay?: string // Builder API Key 显示值(部分显示)
builderSecretDisplay?: string // Builder Secret 显示值(部分显示)
builderPassphraseDisplay?: string // Builder Passphrase 显示值(部分显示)
autoRedeemEnabled: boolean // 自动赎回(系统级别配置,默认开启) autoRedeemEnabled: boolean // 自动赎回(系统级别配置,默认开启)
} }