Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a31ed31c98 | |||
| 0625c62e36 | |||
| f8d7276654 | |||
| 442d5778a7 | |||
| cc41df2f24 | |||
| cdadce467e | |||
| 6e4d4f1ef5 | |||
| 9fb295a2f4 | |||
| db16dea333 | |||
| d6f0914eeb | |||
| f0c40533cf | |||
| d393bd7b40 | |||
| 7173d24fe1 |
@@ -94,6 +94,13 @@ coverage/
|
||||
test-results/
|
||||
*.test.log
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
|
||||
# Misc
|
||||
*.bak
|
||||
*.backup
|
||||
|
||||
@@ -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 中反馈。**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# PolyHermes
|
||||
|
||||
[](https://github.com/WrBug/PolyHermes)
|
||||
[](https://x.com/quant_tr)
|
||||
[](https://x.com/polyhermes)
|
||||
|
||||
> 🌐 **Language**: [English](README_EN.md) | 中文
|
||||
|
||||
@@ -414,7 +414,7 @@ cd frontend
|
||||
## 🔗 相关链接
|
||||
|
||||
- [GitHub 仓库](https://github.com/WrBug/PolyHermes)
|
||||
- [Twitter](https://x.com/quant_tr)
|
||||
- [Twitter](https://x.com/polyhermes)
|
||||
- [Polymarket 官网](https://polymarket.com)
|
||||
- [Polymarket API 文档](https://docs.polymarket.com)
|
||||
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
# PolyHermes
|
||||
|
||||
[](https://github.com/WrBug/PolyHermes)
|
||||
[](https://x.com/quant_tr)
|
||||
[](https://x.com/polyhermes)
|
||||
|
||||
> 🌐 **Language**: English | [中文](README.md)
|
||||
|
||||
@@ -414,7 +414,7 @@ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file
|
||||
## 🔗 Related Links
|
||||
|
||||
- [GitHub Repository](https://github.com/WrBug/PolyHermes)
|
||||
- [Twitter](https://x.com/quant_tr)
|
||||
- [Twitter](https://x.com/polyhermes)
|
||||
- [Polymarket Official Website](https://polymarket.com)
|
||||
- [Polymarket API Documentation](https://docs.polymarket.com)
|
||||
|
||||
|
||||
@@ -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
@@ -22,9 +22,9 @@ FROM eclipse-temurin:17-jre-jammy
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装必要的工具
|
||||
# 安装必要的工具和时区数据
|
||||
RUN apt-get update && \
|
||||
apt-get install -y curl && \
|
||||
apt-get install -y curl tzdata && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 从构建阶段复制 JAR 文件
|
||||
@@ -41,8 +41,8 @@ EXPOSE 8000
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/api/health || exit 1
|
||||
CMD curl -f http://localhost:8000/api/system/health || exit 1
|
||||
|
||||
# 启动应用
|
||||
# 启动应用(自动使用系统时区)
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
|
||||
|
||||
@@ -227,7 +227,7 @@ data class CreateOrderRequest(
|
||||
|
||||
@Deprecated("使用 NewOrderRequest 代替")
|
||||
data class CreateOrdersBatchRequest(
|
||||
val orders: List<CreateOrderRequest>
|
||||
val orders: List<NewOrderRequest>
|
||||
)
|
||||
|
||||
data class CancelOrdersBatchRequest(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.wrbug.polymarketbot.config
|
||||
|
||||
import com.wrbug.polymarketbot.service.ProxyConfigService
|
||||
import com.wrbug.polymarketbot.service.system.ProxyConfigService
|
||||
import jakarta.annotation.PostConstruct
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.accounts
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.AccountService
|
||||
import com.wrbug.polymarketbot.service.accounts.AccountService
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
@@ -15,7 +15,7 @@ import java.math.BigDecimal
|
||||
* 账户管理控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/copy-trading/accounts")
|
||||
@RequestMapping("/api/accounts")
|
||||
class AccountController(
|
||||
private val accountService: AccountService,
|
||||
private val messageSource: MessageSource
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.announcement
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.AnnouncementService
|
||||
import com.wrbug.polymarketbot.service.announcement.AnnouncementService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.auth
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.AuthService
|
||||
import com.wrbug.polymarketbot.service.auth.AuthService
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.copytrading.configs
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.CopyTradingService
|
||||
import com.wrbug.polymarketbot.service.FilteredOrderService
|
||||
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingService
|
||||
import com.wrbug.polymarketbot.service.copytrading.configs.FilteredOrderService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.http.ResponseEntity
|
||||
@@ -13,7 +13,7 @@ import org.springframework.web.bind.annotation.*
|
||||
* 跟单配置管理控制器(钱包-模板关联)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/copy-trading")
|
||||
@RequestMapping("/api/copy-trading/configs")
|
||||
class CopyTradingController(
|
||||
private val copyTradingService: CopyTradingService,
|
||||
private val filteredOrderService: FilteredOrderService,
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.copytrading.leaders
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.LeaderService
|
||||
import com.wrbug.polymarketbot.service.copytrading.leaders.LeaderService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.http.ResponseEntity
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.copytrading.statistics
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.CopyTradingStatisticsService
|
||||
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyTradingStatisticsService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.copytrading.templates
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.CopyTradingTemplateService
|
||||
import com.wrbug.polymarketbot.service.copytrading.templates.CopyTradingTemplateService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.http.ResponseEntity
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.markets
|
||||
|
||||
import com.wrbug.polymarketbot.api.LatestPriceResponse
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.AccountService
|
||||
import com.wrbug.polymarketbot.service.PolymarketClobService
|
||||
import com.wrbug.polymarketbot.service.accounts.AccountService
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
@@ -16,7 +16,7 @@ import org.springframework.web.bind.annotation.*
|
||||
* 提供市场相关的数据查询接口(价格、订单簿等)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/copy-trading/markets")
|
||||
@RequestMapping("/api/markets")
|
||||
class MarketController(
|
||||
private val accountService: AccountService,
|
||||
private val clobService: PolymarketClobService,
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.system
|
||||
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
@@ -11,7 +11,7 @@ import java.util.*
|
||||
* 用于 Docker 健康检查和监控
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/health")
|
||||
@RequestMapping("/api/system/health")
|
||||
class HealthController {
|
||||
|
||||
@GetMapping
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.system
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.NotificationConfigService
|
||||
import com.wrbug.polymarketbot.service.TelegramNotificationService
|
||||
import com.wrbug.polymarketbot.service.system.NotificationConfigService
|
||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
@@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.*
|
||||
* 消息推送配置控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/notifications")
|
||||
@RequestMapping("/api/system/notifications")
|
||||
class NotificationController(
|
||||
private val notificationConfigService: NotificationConfigService,
|
||||
private val telegramNotificationService: TelegramNotificationService,
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.system
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.ApiHealthCheckService
|
||||
import com.wrbug.polymarketbot.service.ProxyConfigService
|
||||
import com.wrbug.polymarketbot.service.system.ApiHealthCheckService
|
||||
import com.wrbug.polymarketbot.service.system.ProxyConfigService
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
@@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.*
|
||||
* 代理配置控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/proxy-config")
|
||||
@RequestMapping("/api/system/proxy")
|
||||
class ProxyConfigController(
|
||||
private val proxyConfigService: ProxyConfigService,
|
||||
private val apiHealthCheckService: ApiHealthCheckService,
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.system
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.SystemConfigService
|
||||
import com.wrbug.polymarketbot.service.RelayClientService
|
||||
import com.wrbug.polymarketbot.service.system.SystemConfigService
|
||||
import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.system
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.UserService
|
||||
import com.wrbug.polymarketbot.service.system.UserService
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
@@ -13,7 +13,7 @@ import org.springframework.web.bind.annotation.*
|
||||
* 用户管理控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
@RequestMapping("/api/system/users")
|
||||
class UserController(
|
||||
private val userService: UserService,
|
||||
private val messageSource: MessageSource
|
||||
@@ -36,6 +36,9 @@ data class SystemConfigDto(
|
||||
val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置
|
||||
val builderSecretConfigured: Boolean, // Builder Secret 是否已配置
|
||||
val builderPassphraseConfigured: Boolean, // Builder Passphrase 是否已配置
|
||||
val builderApiKeyDisplay: String? = null, // Builder API Key 显示值(部分显示,用于前端展示)
|
||||
val builderSecretDisplay: String? = null, // Builder Secret 显示值(部分显示,用于前端展示)
|
||||
val builderPassphraseDisplay: String? = null, // Builder Passphrase 显示值(部分显示,用于前端展示)
|
||||
val autoRedeemEnabled: Boolean = true // 自动赎回(系统级别配置,默认开启)
|
||||
)
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ data class CopyTradingCreateRequest(
|
||||
// 过滤条件
|
||||
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
||||
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||
val minOrderbookDepth: String? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
|
||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
// 新增配置字段
|
||||
@@ -64,7 +63,6 @@ data class CopyTradingUpdateRequest(
|
||||
// 过滤条件
|
||||
val minOrderDepth: String? = null,
|
||||
val maxSpread: String? = null,
|
||||
val minOrderbookDepth: String? = null,
|
||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
// 新增配置字段
|
||||
@@ -133,7 +131,6 @@ data class CopyTradingDto(
|
||||
// 过滤条件
|
||||
val minOrderDepth: String?,
|
||||
val maxSpread: String?,
|
||||
val minOrderbookDepth: String?,
|
||||
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
|
||||
// 新增配置字段
|
||||
|
||||
@@ -22,7 +22,6 @@ data class TemplateCreateRequest(
|
||||
// 过滤条件
|
||||
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
||||
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||
val minOrderbookDepth: String? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
|
||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
|
||||
)
|
||||
@@ -50,7 +49,6 @@ data class TemplateUpdateRequest(
|
||||
// 过滤条件
|
||||
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
||||
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||
val minOrderbookDepth: String? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
|
||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
|
||||
)
|
||||
@@ -85,7 +83,6 @@ data class TemplateCopyRequest(
|
||||
// 过滤条件
|
||||
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
||||
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||
val minOrderbookDepth: String? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
|
||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
|
||||
)
|
||||
@@ -120,7 +117,6 @@ data class TemplateDto(
|
||||
// 过滤条件
|
||||
val minOrderDepth: String?,
|
||||
val maxSpread: String?,
|
||||
val minOrderbookDepth: String?,
|
||||
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
|
||||
val createdAt: Long,
|
||||
|
||||
@@ -78,9 +78,6 @@ data class CopyTrading(
|
||||
@Column(name = "max_spread", precision = 20, scale = 8)
|
||||
val maxSpread: BigDecimal? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||
|
||||
@Column(name = "min_orderbook_depth", precision = 20, scale = 8)
|
||||
val minOrderbookDepth: BigDecimal? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
|
||||
|
||||
@Column(name = "min_price", precision = 20, scale = 8)
|
||||
val minPrice: BigDecimal? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
|
||||
|
||||
@@ -66,9 +66,6 @@ data class CopyTradingTemplate(
|
||||
@Column(name = "max_spread", precision = 20, scale = 8)
|
||||
val maxSpread: BigDecimal? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||
|
||||
@Column(name = "min_orderbook_depth", precision = 20, scale = 8)
|
||||
val minOrderbookDepth: BigDecimal? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
|
||||
|
||||
@Column(name = "min_price", precision = 20, scale = 8)
|
||||
val minPrice: BigDecimal? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
|
||||
|
||||
+13
-5
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.accounts
|
||||
|
||||
import com.wrbug.polymarketbot.api.TradeResponse
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
@@ -8,6 +8,14 @@ import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.wrbug.polymarketbot.util.eq
|
||||
import com.wrbug.polymarketbot.util.JsonUtils
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
import com.wrbug.polymarketbot.service.common.BlockchainService
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketApiKeyService
|
||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
|
||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService
|
||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||
import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -27,7 +35,7 @@ class AccountService(
|
||||
private val apiKeyService: PolymarketApiKeyService,
|
||||
private val orderPushService: OrderPushService,
|
||||
private val orderSigningService: OrderSigningService,
|
||||
private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils,
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val telegramNotificationService: TelegramNotificationService? = null, // 可选,避免循环依赖
|
||||
private val relayClientService: RelayClientService
|
||||
) {
|
||||
@@ -1070,7 +1078,7 @@ class AccountService(
|
||||
/**
|
||||
* 从订单表获取最优价(用于市价单)
|
||||
* 支持多元市场(二元、三元及以上)
|
||||
* 委托给 PolymarketClobService.getOptimalPrice 方法
|
||||
* 委托给 com.wrbug.polymarketbot.service.common.PolymarketClobService.getOptimalPrice 方法
|
||||
*
|
||||
* @param tokenId token ID(通过 marketId 和 outcomeIndex 计算得出)
|
||||
* @param isSellOrder 是否为卖出订单(true: 卖单,需要 bestBid;false: 买单,需要 bestAsk)
|
||||
@@ -1167,7 +1175,7 @@ class AccountService(
|
||||
/**
|
||||
* 获取可赎回仓位统计
|
||||
*/
|
||||
suspend fun getRedeemablePositionsSummary(accountId: Long? = null): Result<com.wrbug.polymarketbot.dto.RedeemablePositionsSummary> {
|
||||
suspend fun getRedeemablePositionsSummary(accountId: Long? = null): Result<RedeemablePositionsSummary> {
|
||||
return try {
|
||||
val positionsResult = getAllPositions()
|
||||
positionsResult.fold(
|
||||
@@ -1223,7 +1231,7 @@ class AccountService(
|
||||
* 赎回仓位
|
||||
* 支持多账户、多仓位赎回(自动按账户和市场分组)
|
||||
*/
|
||||
suspend fun redeemPositions(request: com.wrbug.polymarketbot.dto.PositionRedeemRequest): Result<com.wrbug.polymarketbot.dto.PositionRedeemResponse> {
|
||||
suspend fun redeemPositions(request: PositionRedeemRequest): Result<PositionRedeemResponse> {
|
||||
return try {
|
||||
// 检查 Builder API Key 是否已配置
|
||||
if (!relayClientService.isBuilderApiKeyConfigured()) {
|
||||
+160
-14
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.accounts
|
||||
|
||||
import com.wrbug.polymarketbot.dto.AccountPositionDto
|
||||
import com.wrbug.polymarketbot.entity.CopyOrderTracking
|
||||
@@ -18,6 +18,11 @@ import jakarta.annotation.PostConstruct
|
||||
import jakarta.annotation.PreDestroy
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.context.i18n.LocaleContextHolder
|
||||
import com.wrbug.polymarketbot.service.system.SystemConfigService
|
||||
import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.JsonUtils
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -39,7 +44,8 @@ class PositionCheckService(
|
||||
private val relayClientService: RelayClientService,
|
||||
private val telegramNotificationService: TelegramNotificationService?,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val messageSource: MessageSource
|
||||
private val messageSource: MessageSource,
|
||||
private val retrofitFactory: RetrofitFactory
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PositionCheckService::class.java)
|
||||
@@ -186,7 +192,7 @@ class PositionCheckService(
|
||||
|
||||
/**
|
||||
* 逻辑1:处理待赎回仓位
|
||||
* 按照以下逻辑处理:
|
||||
https://clob.polymarket.com * 按照以下逻辑处理:
|
||||
* 1. 无待赎回仓位:跳过
|
||||
* 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位:发送通知事件
|
||||
* 3. (已配置) && 有待赎回的仓位:处理订单逻辑
|
||||
@@ -366,17 +372,51 @@ class PositionCheckService(
|
||||
val position = positionsByAccountAndMarket[positionKey]?.firstOrNull()
|
||||
|
||||
if (position == null) {
|
||||
// 仓位不存在,更新所有订单状态为已卖出
|
||||
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
|
||||
updateOrdersAsSold(orders, currentPrice, copyTrading.id!!, marketId, outcomeIndex)
|
||||
// 仓位不存在,检查订单创建时间
|
||||
// 只有当订单创建时间超过2分钟时,才认为仓位被出售了
|
||||
// 这样可以避免刚创建的订单因为API延迟而被误判为已卖出
|
||||
val now = System.currentTimeMillis()
|
||||
val ordersToMarkAsSold = orders.filter { order ->
|
||||
val orderAge = now - order.createdAt
|
||||
orderAge > 120000 // 2分钟 = 120000毫秒
|
||||
}
|
||||
|
||||
if (ordersToMarkAsSold.isNotEmpty()) {
|
||||
// 有订单创建时间超过2分钟,认为仓位已被出售
|
||||
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
|
||||
updateOrdersAsSold(ordersToMarkAsSold, currentPrice, copyTrading.id, marketId, outcomeIndex)
|
||||
logger.debug("仓位不存在且订单创建时间超过2分钟,标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${ordersToMarkAsSold.size}")
|
||||
} else {
|
||||
// 订单创建时间不足2分钟,可能是刚创建的订单,暂时不处理
|
||||
logger.debug("仓位不存在但订单创建时间不足2分钟,暂不标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${orders.size}, oldestOrderAge=${orders.minOfOrNull { now - it.createdAt }?.let { "${it}ms" } ?: "N/A"}")
|
||||
}
|
||||
} else {
|
||||
// 有仓位,按订单下单顺序(FIFO)更新状态
|
||||
// 如果仓位数量 >= 订单数量总和,所有订单完全成交
|
||||
// 如果仓位数量 < 订单数量总和,按FIFO顺序部分成交
|
||||
val totalUnmatchedQuantity = orders.sumOf { it.remainingQuantity.toSafeBigDecimal() }
|
||||
// 计算逻辑:
|
||||
// 1. 总订单数量 = 所有未卖出订单的剩余数量总和
|
||||
// 2. 已成交数量 = 总订单数量 - 仓位数量(因为还有仓位,说明部分订单已卖出)
|
||||
// 3. 如果已成交数量 = 0,说明订单还没有卖出,不修改订单状态
|
||||
// 4. 如果已成交数量 > 0,按FIFO顺序匹配订单
|
||||
val positionQuantity = position.quantity.toSafeBigDecimal()
|
||||
|
||||
// 计算总订单数量
|
||||
val totalOrderQuantity = orders.fold(BigDecimal.ZERO) { sum, order ->
|
||||
sum.add(order.remainingQuantity.toSafeBigDecimal())
|
||||
}
|
||||
|
||||
// 计算已成交数量
|
||||
val soldQuantity = totalOrderQuantity.subtract(positionQuantity)
|
||||
|
||||
// 如果已成交数量 <= 0,说明订单还没有卖出,不修改订单状态
|
||||
if (soldQuantity <= BigDecimal.ZERO) {
|
||||
logger.debug("仓位数量 >= 订单数量总和,订单尚未卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, positionQuantity=$positionQuantity, totalOrderQuantity=$totalOrderQuantity")
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果已成交数量 > 0,按FIFO顺序匹配订单
|
||||
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
|
||||
updateOrdersAsSoldByFIFO(orders, positionQuantity, currentPrice, copyTrading.id!!, marketId, outcomeIndex)
|
||||
updateOrdersAsSoldByFIFO(orders, soldQuantity, currentPrice,
|
||||
copyTrading.id, marketId, outcomeIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -388,9 +428,40 @@ class PositionCheckService(
|
||||
/**
|
||||
* 获取当前市场最新价(用于更新订单卖出价)
|
||||
* 优先使用 bestBid(最优买价),如果没有则使用 midpoint(中间价)
|
||||
* 如果市场已关闭:
|
||||
* - 该 outcome 赢了,返回 1
|
||||
* - 该 outcome 输了,返回 0
|
||||
*/
|
||||
private suspend fun getCurrentMarketPrice(marketId: String, outcomeIndex: Int): BigDecimal {
|
||||
return try {
|
||||
// 先获取市场信息,检查市场是否已关闭
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(marketId))
|
||||
|
||||
if (marketResponse.isSuccessful && marketResponse.body() != null) {
|
||||
val markets = marketResponse.body()!!
|
||||
val market = markets.firstOrNull()
|
||||
|
||||
if (market != null && market.closed == true) {
|
||||
// 市场已关闭,检查该 outcome 是赢了还是输了
|
||||
val outcomeResult = checkOutcomeResult(market, outcomeIndex)
|
||||
when (outcomeResult) {
|
||||
OutcomeResult.WON -> {
|
||||
logger.info("市场已关闭且该 outcome 赢了,返回价格为 1: marketId=$marketId, outcomeIndex=$outcomeIndex")
|
||||
return BigDecimal.ONE
|
||||
}
|
||||
OutcomeResult.LOST -> {
|
||||
logger.info("市场已关闭且该 outcome 输了,返回价格为 0: marketId=$marketId, outcomeIndex=$outcomeIndex")
|
||||
return BigDecimal.ZERO
|
||||
}
|
||||
OutcomeResult.UNKNOWN -> {
|
||||
// 无法判断,继续使用正常价格逻辑
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果市场未关闭或无法判断输赢,获取正常价格
|
||||
val priceResult = accountService.getMarketPrice(marketId, outcomeIndex)
|
||||
val marketPrice = priceResult.getOrNull()
|
||||
if (marketPrice != null) {
|
||||
@@ -406,6 +477,71 @@ class PositionCheckService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome 结果枚举
|
||||
*/
|
||||
private enum class OutcomeResult {
|
||||
WON, // 赢了
|
||||
LOST, // 输了
|
||||
UNKNOWN // 无法判断
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查该 outcome 的结果(赢了、输了或无法判断)
|
||||
* @param market 市场信息
|
||||
* @param outcomeIndex outcome 索引
|
||||
* @return OutcomeResult
|
||||
*/
|
||||
private fun checkOutcomeResult(market: com.wrbug.polymarketbot.api.MarketResponse, outcomeIndex: Int): OutcomeResult {
|
||||
return try {
|
||||
// 优先使用 outcomePrices(结算价格数组)
|
||||
val outcomePrices = market.outcomePrices
|
||||
if (outcomePrices != null && outcomePrices.isNotBlank()) {
|
||||
val prices = JsonUtils.parseStringArray(outcomePrices)
|
||||
if (outcomeIndex < prices.size) {
|
||||
val price = prices[outcomeIndex].toSafeBigDecimal()
|
||||
// 如果价格 >= 0.99,认为赢了
|
||||
if (price >= BigDecimal("0.99")) {
|
||||
return OutcomeResult.WON
|
||||
}
|
||||
// 如果价格 <= 0.01,认为输了
|
||||
if (price <= BigDecimal("0.01")) {
|
||||
return OutcomeResult.LOST
|
||||
}
|
||||
// 其他情况,无法判断
|
||||
return OutcomeResult.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有 outcomePrices,使用 bestBid 和 bestAsk 判断
|
||||
val bestBid = market.bestBid ?: 0.0
|
||||
val bestAsk = market.bestAsk ?: 0.0
|
||||
|
||||
// 如果目标 outcome 不是第一个(index != 0),需要转换价格
|
||||
val targetBid = if (outcomeIndex > 0) {
|
||||
// 第二个 outcome 的 bestBid = 1 - 第一个 outcome 的 bestAsk
|
||||
BigDecimal.ONE.subtract(BigDecimal.valueOf(bestAsk))
|
||||
} else {
|
||||
BigDecimal.valueOf(bestBid)
|
||||
}
|
||||
|
||||
// 如果 bestBid >= 0.99,认为赢了
|
||||
if (targetBid >= BigDecimal("0.99")) {
|
||||
return OutcomeResult.WON
|
||||
}
|
||||
// 如果 bestBid <= 0.01,认为输了
|
||||
if (targetBid <= BigDecimal("0.01")) {
|
||||
return OutcomeResult.LOST
|
||||
}
|
||||
// 其他情况,无法判断
|
||||
OutcomeResult.UNKNOWN
|
||||
} catch (e: Exception) {
|
||||
logger.warn("检查 outcome 结果失败: marketId=${market.conditionId}, outcomeIndex=$outcomeIndex, error=${e.message}", e)
|
||||
OutcomeResult.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 在仓位赎回成功后,更新订单状态为已卖出
|
||||
* 使用卖出逻辑更新所有订单状态(未卖出订单的)
|
||||
@@ -512,12 +648,22 @@ class PositionCheckService(
|
||||
|
||||
/**
|
||||
* 按 FIFO 顺序更新订单状态为已卖出
|
||||
* 仓位数量小于订单数量总和时,按订单下单顺序更新
|
||||
* 同时创建卖出记录和匹配明细,用于统计
|
||||
* @param orders 订单列表(已按创建时间排序,FIFO)
|
||||
* @param soldQuantity 已成交数量(总订单数量 - 仓位数量)
|
||||
* @param sellPrice 卖出价格
|
||||
* @param copyTradingId 跟单配置ID
|
||||
* @param marketId 市场ID
|
||||
* @param outcomeIndex 结果索引
|
||||
*
|
||||
* 逻辑说明:
|
||||
* 1. 按订单创建时间顺序(FIFO)处理
|
||||
* 2. 如果订单剩余数量 <= 已成交数量,订单完全成交
|
||||
* 3. 如果订单剩余数量 > 已成交数量,订单部分成交
|
||||
* 4. 同时创建卖出记录和匹配明细,用于统计
|
||||
*/
|
||||
private suspend fun updateOrdersAsSoldByFIFO(
|
||||
orders: List<CopyOrderTracking>,
|
||||
availableQuantity: BigDecimal,
|
||||
soldQuantity: BigDecimal,
|
||||
sellPrice: BigDecimal,
|
||||
copyTradingId: Long,
|
||||
marketId: String,
|
||||
@@ -529,7 +675,7 @@ class PositionCheckService(
|
||||
|
||||
try {
|
||||
// 订单已经按 createdAt ASC 排序(FIFO)
|
||||
var remaining = availableQuantity
|
||||
var remaining = soldQuantity
|
||||
var totalMatchedQuantity = BigDecimal.ZERO
|
||||
var totalRealizedPnl = BigDecimal.ZERO
|
||||
val matchDetails = mutableListOf<SellMatchDetail>()
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.accounts
|
||||
|
||||
import com.wrbug.polymarketbot.dto.PositionListResponse
|
||||
import jakarta.annotation.PostConstruct
|
||||
+3
-2
@@ -1,6 +1,7 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.accounts
|
||||
|
||||
import com.wrbug.polymarketbot.dto.AccountPositionDto
|
||||
import com.wrbug.polymarketbot.dto.PositionListResponse
|
||||
import com.wrbug.polymarketbot.dto.PositionPushMessage
|
||||
import com.wrbug.polymarketbot.dto.PositionPushMessageType
|
||||
import com.wrbug.polymarketbot.dto.getPositionKey
|
||||
@@ -155,7 +156,7 @@ class PositionPushService(
|
||||
* 处理仓位更新事件
|
||||
* 根据文档要求:每次轮训完成后向订阅者发送全量数据
|
||||
*/
|
||||
private fun handlePositionUpdate(positions: com.wrbug.polymarketbot.dto.PositionListResponse) {
|
||||
private fun handlePositionUpdate(positions: PositionListResponse) {
|
||||
// 更新快照
|
||||
lastCurrentPositions = positions.currentPositions.associateBy { it.getPositionKey() }
|
||||
lastHistoryPositions = positions.historyPositions.associateBy { it.getPositionKey() }
|
||||
+74
-75
@@ -1,6 +1,7 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.announcement
|
||||
|
||||
import com.wrbug.polymarketbot.api.GitHubApi
|
||||
import com.wrbug.polymarketbot.api.GitHubCommentResponse
|
||||
import com.wrbug.polymarketbot.dto.AnnouncementDto
|
||||
import com.wrbug.polymarketbot.dto.AnnouncementListResponse
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
@@ -25,30 +26,30 @@ class AnnouncementService(
|
||||
@Value("\${github.announcement.issue.number:1}")
|
||||
private val issueNumber: Int
|
||||
) {
|
||||
|
||||
|
||||
private val logger = LoggerFactory.getLogger(AnnouncementService::class.java)
|
||||
|
||||
|
||||
// GitHub API 客户端(懒加载)
|
||||
private val githubApi: GitHubApi by lazy {
|
||||
retrofitFactory.createGitHubApi()
|
||||
}
|
||||
|
||||
|
||||
// 需要排除的 Issue ID(从 issue_url 中提取)
|
||||
private val excludedIssueId = "3703128976"
|
||||
|
||||
|
||||
// 缓存数据(1分钟有效期)
|
||||
private data class CachedData<T>(
|
||||
val data: T,
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
|
||||
private var cachedList: CachedData<AnnouncementListResponse>? = null
|
||||
private var cachedAssignees: CachedData<List<String>>? = null
|
||||
private var cachedComments: CachedData<List<com.wrbug.polymarketbot.api.GitHubCommentResponse>>? = null
|
||||
|
||||
|
||||
// 缓存有效期:10分钟(毫秒)
|
||||
private val cacheExpiryTime = 10 * 60 * 1000L
|
||||
|
||||
|
||||
/**
|
||||
* 检查缓存是否有效
|
||||
*/
|
||||
@@ -57,7 +58,7 @@ class AnnouncementService(
|
||||
val now = System.currentTimeMillis()
|
||||
return (now - cached.timestamp) < cacheExpiryTime
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 检查是否被限流
|
||||
*/
|
||||
@@ -70,7 +71,7 @@ class AnnouncementService(
|
||||
val remaining = response.headers()["X-RateLimit-Remaining"]
|
||||
return remaining == "0"
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取 Issue 的 assignees 列表(通过 API 获取,带缓存)
|
||||
* @return Pair<assignees列表, 是否使用了缓存>
|
||||
@@ -81,14 +82,14 @@ class AnnouncementService(
|
||||
logger.debug("使用缓存的 assignees")
|
||||
return Pair(cachedAssignees!!.data, true)
|
||||
}
|
||||
|
||||
|
||||
return try {
|
||||
val response = githubApi.getIssue(
|
||||
owner = repoOwner,
|
||||
repo = repoName,
|
||||
issueNumber = issueNumber
|
||||
)
|
||||
|
||||
|
||||
// 如果被限流,使用缓存数据,不更新缓存
|
||||
if (isRateLimited(response)) {
|
||||
logger.warn("GitHub API 被限流,使用缓存的 assignees(不更新缓存)")
|
||||
@@ -98,14 +99,14 @@ class AnnouncementService(
|
||||
// 如果没有缓存,使用默认值
|
||||
return Pair(listOf("WrBug"), false)
|
||||
}
|
||||
|
||||
|
||||
val assignees = if (response.isSuccessful && response.body() != null) {
|
||||
response.body()!!.assignees.map { it.login }
|
||||
} else {
|
||||
logger.warn("获取 Issue assignees 失败,使用默认值: code=${response.code()}")
|
||||
listOf("WrBug") // 默认值
|
||||
}
|
||||
|
||||
|
||||
// 更新缓存
|
||||
cachedAssignees = CachedData(assignees, System.currentTimeMillis())
|
||||
Pair(assignees, false) // 返回新数据,标记为未使用缓存
|
||||
@@ -119,24 +120,24 @@ class AnnouncementService(
|
||||
Pair(listOf("WrBug"), false) // 默认值
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取 Issue 评论列表(带缓存)
|
||||
* @return Pair<评论列表, 是否使用了缓存>
|
||||
*/
|
||||
private suspend fun getIssueComments(forceRefresh: Boolean = false): Pair<List<com.wrbug.polymarketbot.api.GitHubCommentResponse>, Boolean> {
|
||||
private suspend fun getIssueComments(forceRefresh: Boolean = false): Pair<List<GitHubCommentResponse>, Boolean> {
|
||||
// 检查缓存
|
||||
if (!forceRefresh && isCacheValid(cachedComments)) {
|
||||
logger.debug("使用缓存的评论列表")
|
||||
return Pair(cachedComments!!.data, true)
|
||||
}
|
||||
|
||||
|
||||
val response = githubApi.getIssueComments(
|
||||
owner = repoOwner,
|
||||
repo = repoName,
|
||||
issueNumber = issueNumber
|
||||
)
|
||||
|
||||
|
||||
// 如果被限流,使用缓存数据,不更新缓存
|
||||
if (isRateLimited(response)) {
|
||||
logger.warn("GitHub API 被限流,使用缓存的评论列表(不更新缓存)")
|
||||
@@ -146,7 +147,7 @@ class AnnouncementService(
|
||||
// 如果没有缓存,抛出异常
|
||||
throw Exception("获取公告列表失败: GitHub API 被限流,且无缓存数据")
|
||||
}
|
||||
|
||||
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
logger.error("获取 GitHub Issue 评论失败: code=${response.code()}, message=${response.message()}")
|
||||
// 如果缓存存在,使用缓存
|
||||
@@ -156,14 +157,14 @@ class AnnouncementService(
|
||||
}
|
||||
throw Exception("获取公告列表失败: HTTP ${response.code()}")
|
||||
}
|
||||
|
||||
|
||||
val comments = response.body()!!
|
||||
|
||||
|
||||
// 更新缓存
|
||||
cachedComments = CachedData(comments, System.currentTimeMillis())
|
||||
return Pair(comments, false) // 返回新数据,标记为未使用缓存
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取公告列表(最近10条)
|
||||
* @param forceRefresh 是否强制刷新缓存
|
||||
@@ -174,12 +175,12 @@ class AnnouncementService(
|
||||
logger.debug("使用缓存的公告列表")
|
||||
return Result.success(cachedList!!.data)
|
||||
}
|
||||
|
||||
|
||||
return try {
|
||||
// 强制刷新时,先尝试获取新数据
|
||||
val (assigneeList, assigneesFromCache) = getAssignees(forceRefresh)
|
||||
val (comments, commentsFromCache) = getIssueComments(forceRefresh)
|
||||
|
||||
|
||||
// 如果强制刷新时使用了缓存(被限流),直接返回缓存数据,不更新缓存
|
||||
if (forceRefresh && (assigneesFromCache || commentsFromCache)) {
|
||||
logger.warn("强制刷新时被限流,返回缓存的公告列表(不更新缓存)")
|
||||
@@ -187,48 +188,47 @@ class AnnouncementService(
|
||||
return Result.success(cachedList!!.data)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 筛选条件:
|
||||
// 1. assignees 发布的评论
|
||||
// 2. 排除 issueNumber 为 3703128976 的评论(从 issue_url 中提取)
|
||||
val filteredComments = comments
|
||||
.filter { comment ->
|
||||
// 检查是否为 assignee
|
||||
val isAssignee = assigneeList.contains(comment.user.login)
|
||||
|
||||
// 检查是否应该排除(从 issue_url 中提取 issue ID)
|
||||
val shouldExclude = comment.issue_url?.let { issueUrl ->
|
||||
// issue_url 格式:https://api.github.com/repos/owner/repo/issues/3703128976
|
||||
// 提取最后的数字
|
||||
val issueId = issueUrl.split("/").lastOrNull()
|
||||
issueId == excludedIssueId
|
||||
} ?: false
|
||||
|
||||
isAssignee && !shouldExclude
|
||||
}
|
||||
val filteredComments = comments.filter { comment ->
|
||||
// 检查是否为 assignee
|
||||
val isAssignee = assigneeList.contains(comment.user.login)
|
||||
|
||||
// 检查是否应该排除(从 issue_url 中提取 issue ID)
|
||||
val shouldExclude = comment.issue_url?.let { issueUrl ->
|
||||
// issue_url 格式:https://api.github.com/repos/owner/repo/issues/3703128976
|
||||
// 提取最后的数字
|
||||
val issueId = issueUrl.split("/").lastOrNull()
|
||||
issueId == excludedIssueId
|
||||
} ?: false
|
||||
|
||||
isAssignee && !shouldExclude
|
||||
}
|
||||
.sortedByDescending { comment ->
|
||||
parseGitHubTime(comment.created_at)
|
||||
}
|
||||
|
||||
|
||||
val total = filteredComments.size
|
||||
val hasMore = total > 10
|
||||
|
||||
|
||||
// 取前10条
|
||||
val latest10 = filteredComments.take(10).map { comment ->
|
||||
toAnnouncementDto(comment)
|
||||
}
|
||||
|
||||
|
||||
val result = AnnouncementListResponse(
|
||||
list = latest10,
|
||||
hasMore = hasMore,
|
||||
total = total
|
||||
)
|
||||
|
||||
|
||||
// 只有在数据正常返回时才更新缓存(不是从缓存获取的)
|
||||
if (!assigneesFromCache && !commentsFromCache) {
|
||||
cachedList = CachedData(result, System.currentTimeMillis())
|
||||
}
|
||||
|
||||
|
||||
Result.success(result)
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取公告列表异常: ${e.message}", e)
|
||||
@@ -240,7 +240,7 @@ class AnnouncementService(
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取公告详情
|
||||
* @param id 评论ID,如果为 null 则返回最新一条
|
||||
@@ -249,19 +249,18 @@ class AnnouncementService(
|
||||
suspend fun getAnnouncementDetail(id: Long?, forceRefresh: Boolean = false): Result<AnnouncementDto> {
|
||||
return try {
|
||||
// 获取 assignees
|
||||
val (assigneeList, assigneesFromCache) = getAssignees(forceRefresh)
|
||||
|
||||
val (assigneeList, _) = getAssignees(forceRefresh)
|
||||
|
||||
// 获取评论列表
|
||||
val (comments, commentsFromCache) = getIssueComments(forceRefresh)
|
||||
|
||||
val (comments, _) = getIssueComments(forceRefresh)
|
||||
|
||||
// 筛选条件:
|
||||
// 1. assignees 发布的评论
|
||||
// 2. 排除 issueNumber 为 3703128976 的评论(从 issue_url 中提取)
|
||||
val filteredComments = comments
|
||||
.filter { comment ->
|
||||
val filteredComments = comments.filter { comment ->
|
||||
// 检查是否为 assignee
|
||||
val isAssignee = assigneeList.contains(comment.user.login)
|
||||
|
||||
|
||||
// 检查是否应该排除(从 issue_url 中提取 issue ID)
|
||||
val shouldExclude = comment.issue_url?.let { issueUrl ->
|
||||
// issue_url 格式:https://api.github.com/repos/owner/repo/issues/3703128976
|
||||
@@ -269,37 +268,37 @@ class AnnouncementService(
|
||||
val issueId = issueUrl.split("/").lastOrNull()
|
||||
issueId == excludedIssueId
|
||||
} ?: false
|
||||
|
||||
|
||||
isAssignee && !shouldExclude
|
||||
}
|
||||
.sortedByDescending { comment ->
|
||||
parseGitHubTime(comment.created_at)
|
||||
}
|
||||
|
||||
|
||||
val targetComment = if (id != null) {
|
||||
filteredComments.find { it.id == id }
|
||||
} else {
|
||||
filteredComments.firstOrNull()
|
||||
}
|
||||
|
||||
|
||||
if (targetComment == null) {
|
||||
return Result.failure(IllegalArgumentException("公告不存在"))
|
||||
}
|
||||
|
||||
|
||||
Result.success(toAnnouncementDto(targetComment))
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取公告详情异常: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 将 GitHub 评论转换为 AnnouncementDto
|
||||
*/
|
||||
private fun toAnnouncementDto(comment: com.wrbug.polymarketbot.api.GitHubCommentResponse): AnnouncementDto {
|
||||
private fun toAnnouncementDto(comment: GitHubCommentResponse): AnnouncementDto {
|
||||
// 提取标题(第一行,移除 Markdown 格式)
|
||||
val title = extractTitle(comment.body)
|
||||
|
||||
|
||||
// 转换 reactions 数据
|
||||
val reactions = comment.reactions?.let { r ->
|
||||
com.wrbug.polymarketbot.dto.ReactionsDto(
|
||||
@@ -314,7 +313,7 @@ class AnnouncementService(
|
||||
total = r.total_count
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
return AnnouncementDto(
|
||||
id = comment.id,
|
||||
title = title,
|
||||
@@ -326,7 +325,7 @@ class AnnouncementService(
|
||||
reactions = reactions
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 从评论内容中提取标题(第一行,移除 Markdown 格式)
|
||||
* 支持的 Markdown 格式:
|
||||
@@ -342,49 +341,49 @@ class AnnouncementService(
|
||||
if (body.isBlank()) {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
// 获取第一行
|
||||
val firstLine = body.lines().firstOrNull()?.trim() ?: ""
|
||||
if (firstLine.isBlank()) {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
// 移除 Markdown 格式
|
||||
var title = firstLine
|
||||
|
||||
|
||||
// 移除标题标记(# ## ### 等)
|
||||
title = title.replace(Regex("^#{1,6}\\s+"), "")
|
||||
|
||||
|
||||
// 移除粗体标记(**text** 或 __text__)
|
||||
title = title.replace(Regex("\\*\\*([^*]+)\\*\\*"), "$1")
|
||||
title = title.replace(Regex("__([^_]+)__"), "$1")
|
||||
|
||||
|
||||
// 移除斜体标记(*text* 或 _text_)
|
||||
title = title.replace(Regex("(?<!\\*)\\*([^*]+)\\*(?!\\*)"), "$1")
|
||||
title = title.replace(Regex("(?<!_)_([^_]+)_(?!_)"), "$1")
|
||||
|
||||
|
||||
// 移除代码标记(`code`)
|
||||
title = title.replace(Regex("`([^`]+)`"), "$1")
|
||||
|
||||
|
||||
// 移除链接标记([text](url))
|
||||
title = title.replace(Regex("\\[([^\\]]+)\\]\\([^\\)]+\\)"), "$1")
|
||||
|
||||
|
||||
// 移除图片标记()
|
||||
title = title.replace(Regex("!\\[([^\\]]*)\\]\\([^\\)]+\\)"), "$1")
|
||||
|
||||
|
||||
// 移除删除线标记(~~text~~)
|
||||
title = title.replace(Regex("~~([^~]+)~~"), "$1")
|
||||
|
||||
|
||||
// 移除引用标记(> text)
|
||||
title = title.replace(Regex("^>\\s+"), "")
|
||||
|
||||
|
||||
// 移除列表标记(- * + 1. 等)
|
||||
title = title.replace(Regex("^[-*+]\\s+"), "")
|
||||
title = title.replace(Regex("^\\d+\\.\\s+"), "")
|
||||
|
||||
|
||||
return title.trim()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 解析 GitHub 时间格式(ISO 8601)为时间戳(毫秒)
|
||||
* GitHub API 返回的时间格式:2025-12-07T14:30:00Z
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.auth
|
||||
|
||||
import com.wrbug.polymarketbot.dto.CheckFirstUseResponse
|
||||
import com.wrbug.polymarketbot.dto.LoginResponse
|
||||
@@ -10,6 +10,7 @@ import jakarta.servlet.http.HttpServletRequest
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
|
||||
import com.wrbug.polymarketbot.service.common.RateLimitService
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.common
|
||||
|
||||
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||
import com.wrbug.polymarketbot.api.JsonRpcRequest
|
||||
@@ -11,6 +11,7 @@ import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||
import org.springframework.stereotype.Service
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.common
|
||||
|
||||
import com.wrbug.polymarketbot.api.ApiKeyResponse
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.common
|
||||
|
||||
import com.wrbug.polymarketbot.api.*
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.common
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
+3
-1
@@ -1,9 +1,11 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.common
|
||||
|
||||
import com.wrbug.polymarketbot.dto.OrderPushMessage
|
||||
import com.wrbug.polymarketbot.dto.PositionPushMessage
|
||||
import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage
|
||||
import com.wrbug.polymarketbot.dto.WebSocketMessageType
|
||||
import com.wrbug.polymarketbot.service.accounts.PositionPushService
|
||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
+65
-90
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.copytrading.configs
|
||||
|
||||
import com.wrbug.polymarketbot.api.OrderbookResponse
|
||||
import com.wrbug.polymarketbot.entity.CopyTrading
|
||||
@@ -7,6 +7,7 @@ import com.wrbug.polymarketbot.util.lt
|
||||
import com.wrbug.polymarketbot.util.multi
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
|
||||
@@ -24,85 +25,86 @@ class CopyTradingFilterService(
|
||||
* 检查过滤条件
|
||||
* @param copyTrading 跟单配置
|
||||
* @param tokenId token ID(用于获取订单簿)
|
||||
* @param isBuyOrder 是否为买入订单(true=买入,false=卖出)
|
||||
* @return Pair<是否通过, 失败原因>
|
||||
* @param tradePrice Leader 交易价格,用于价格区间检查
|
||||
* @return 过滤结果
|
||||
*/
|
||||
suspend fun checkFilters(
|
||||
copyTrading: CopyTrading,
|
||||
tokenId: String,
|
||||
isBuyOrder: Boolean,
|
||||
tradePrice: BigDecimal? = null // Leader 交易价格,用于价格区间检查
|
||||
): Pair<Boolean, String> {
|
||||
): FilterResult {
|
||||
// 1. 价格区间检查(如果配置了价格区间)
|
||||
if (tradePrice != null) {
|
||||
val priceRangeCheck = checkPriceRange(copyTrading, tradePrice)
|
||||
if (!priceRangeCheck.first) {
|
||||
return priceRangeCheck
|
||||
if (!priceRangeCheck.isPassed) {
|
||||
return FilterResult.priceRangeFailed(priceRangeCheck.reason)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 价格合理性检查(基础检查,无需配置)
|
||||
// 这个检查在获取订单簿时进行,如果价格不在 0.01-0.99 范围内,订单簿获取会失败
|
||||
// 2. 检查是否需要获取订单簿
|
||||
// 只有在配置了需要订单簿的过滤条件时才获取
|
||||
val needOrderbook = copyTrading.maxSpread != null || copyTrading.minOrderDepth != null
|
||||
|
||||
// 3. 获取订单簿
|
||||
if (!needOrderbook) {
|
||||
// 不需要订单簿,直接通过
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
// 3. 获取订单簿(仅在需要时,只请求一次)
|
||||
val orderbookResult = clobService.getOrderbookByTokenId(tokenId)
|
||||
if (!orderbookResult.isSuccess) {
|
||||
val error = orderbookResult.exceptionOrNull()
|
||||
return Pair(false, "获取订单簿失败: ${error?.message ?: "未知错误"}")
|
||||
return FilterResult.orderbookError("获取订单簿失败: ${error?.message ?: "未知错误"}")
|
||||
}
|
||||
|
||||
val orderbook = orderbookResult.getOrNull()
|
||||
if (orderbook == null) {
|
||||
return Pair(false, "订单簿为空")
|
||||
?: return FilterResult.orderbookEmpty()
|
||||
|
||||
// 4. 买一卖一价差过滤(如果配置了)
|
||||
if (copyTrading.maxSpread != null) {
|
||||
val spreadCheck = checkSpread(copyTrading, orderbook)
|
||||
if (!spreadCheck.isPassed) {
|
||||
return FilterResult.spreadFailed(spreadCheck.reason, orderbook)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 买一卖一价差过滤
|
||||
val spreadCheck = checkSpread(copyTrading, orderbook)
|
||||
if (!spreadCheck.first) {
|
||||
return spreadCheck
|
||||
// 5. 订单深度过滤(如果配置了,检查所有方向)
|
||||
if (copyTrading.minOrderDepth != null) {
|
||||
val depthCheck = checkOrderDepth(copyTrading, orderbook)
|
||||
if (!depthCheck.isPassed) {
|
||||
return FilterResult.orderDepthFailed(depthCheck.reason, orderbook)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 订单深度过滤
|
||||
val depthCheck = checkOrderDepth(copyTrading, orderbook, isBuyOrder)
|
||||
if (!depthCheck.first) {
|
||||
return depthCheck
|
||||
}
|
||||
|
||||
// 6. 最小订单簿深度过滤(可选)
|
||||
val orderbookDepthCheck = checkOrderbookDepth(copyTrading, orderbook, isBuyOrder)
|
||||
if (!orderbookDepthCheck.first) {
|
||||
return orderbookDepthCheck
|
||||
}
|
||||
|
||||
return Pair(true, "")
|
||||
return FilterResult.passed(orderbook)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查价格区间
|
||||
* @param copyTrading 跟单配置
|
||||
* @param tradePrice Leader 交易价格
|
||||
* @return Pair<是否通过, 失败原因>
|
||||
* @return 过滤结果
|
||||
*/
|
||||
private fun checkPriceRange(
|
||||
copyTrading: CopyTrading,
|
||||
tradePrice: BigDecimal
|
||||
): Pair<Boolean, String> {
|
||||
): FilterResult {
|
||||
// 如果未配置价格区间,直接通过
|
||||
if (copyTrading.minPrice == null && copyTrading.maxPrice == null) {
|
||||
return Pair(true, "")
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
// 检查最低价格
|
||||
if (copyTrading.minPrice != null && tradePrice.lt(copyTrading.minPrice)) {
|
||||
return Pair(false, "价格低于最低限制: $tradePrice < ${copyTrading.minPrice}")
|
||||
return FilterResult.priceRangeFailed("价格低于最低限制: $tradePrice < ${copyTrading.minPrice}")
|
||||
}
|
||||
|
||||
// 检查最高价格
|
||||
if (copyTrading.maxPrice != null && tradePrice.gt(copyTrading.maxPrice)) {
|
||||
return Pair(false, "价格高于最高限制: $tradePrice > ${copyTrading.maxPrice}")
|
||||
return FilterResult.priceRangeFailed("价格高于最高限制: $tradePrice > ${copyTrading.maxPrice}")
|
||||
}
|
||||
|
||||
return Pair(true, "")
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,10 +115,10 @@ class CopyTradingFilterService(
|
||||
private fun checkSpread(
|
||||
copyTrading: CopyTrading,
|
||||
orderbook: OrderbookResponse
|
||||
): Pair<Boolean, String> {
|
||||
): FilterResult {
|
||||
// 如果未启用价差过滤,直接通过
|
||||
if (copyTrading.maxSpread == null) {
|
||||
return Pair(true, "")
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
// 获取买盘中的最高价格(bestBid = bids 中的最大值)
|
||||
@@ -130,84 +132,57 @@ class CopyTradingFilterService(
|
||||
.minOrNull()
|
||||
|
||||
if (bestBid == null || bestAsk == null) {
|
||||
return Pair(false, "订单簿缺少买一或卖一价格")
|
||||
return FilterResult.spreadFailed("订单簿缺少买一或卖一价格", orderbook)
|
||||
}
|
||||
|
||||
// 计算价差(绝对价格)
|
||||
val spread = bestAsk.subtract(bestBid)
|
||||
|
||||
if (spread.gt(copyTrading.maxSpread)) {
|
||||
return Pair(false, "价差过大: $spread > ${copyTrading.maxSpread}")
|
||||
return FilterResult.spreadFailed("价差过大: $spread > ${copyTrading.maxSpread}", orderbook)
|
||||
}
|
||||
|
||||
return Pair(true, "")
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查订单深度
|
||||
* 检查订单深度(检查所有方向:买盘和卖盘的总深度)
|
||||
*/
|
||||
private fun checkOrderDepth(
|
||||
copyTrading: CopyTrading,
|
||||
orderbook: OrderbookResponse,
|
||||
isBuyOrder: Boolean
|
||||
): Pair<Boolean, String> {
|
||||
orderbook: OrderbookResponse
|
||||
): FilterResult {
|
||||
// 如果未启用订单深度过滤,直接通过
|
||||
if (copyTrading.minOrderDepth == null) {
|
||||
return Pair(true, "")
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
// 对于买入订单,检查卖盘(asks)深度
|
||||
// 对于卖出订单,检查买盘(bids)深度
|
||||
val orders = if (isBuyOrder) orderbook.asks else orderbook.bids
|
||||
|
||||
// 计算总深度(累计订单金额)
|
||||
var totalDepth = BigDecimal.ZERO
|
||||
for (order in orders) {
|
||||
// 计算买盘(bids)总深度
|
||||
var bidsDepth = BigDecimal.ZERO
|
||||
for (order in orderbook.bids) {
|
||||
val price = order.price.toSafeBigDecimal()
|
||||
val size = order.size.toSafeBigDecimal()
|
||||
val orderAmount = price.multi(size)
|
||||
totalDepth = totalDepth.add(orderAmount)
|
||||
bidsDepth = bidsDepth.add(orderAmount)
|
||||
}
|
||||
|
||||
// 计算卖盘(asks)总深度
|
||||
var asksDepth = BigDecimal.ZERO
|
||||
for (order in orderbook.asks) {
|
||||
val price = order.price.toSafeBigDecimal()
|
||||
val size = order.size.toSafeBigDecimal()
|
||||
val orderAmount = price.multi(size)
|
||||
asksDepth = asksDepth.add(orderAmount)
|
||||
}
|
||||
|
||||
// 计算总深度(买盘 + 卖盘)
|
||||
val totalDepth = bidsDepth.add(asksDepth)
|
||||
|
||||
if (totalDepth.lt(copyTrading.minOrderDepth)) {
|
||||
return Pair(false, "订单深度不足: $totalDepth < ${copyTrading.minOrderDepth}")
|
||||
return FilterResult.orderDepthFailed("订单深度不足: $totalDepth < ${copyTrading.minOrderDepth}", orderbook)
|
||||
}
|
||||
|
||||
return Pair(true, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查最小订单簿深度(前 N 档深度)
|
||||
*/
|
||||
private fun checkOrderbookDepth(
|
||||
copyTrading: CopyTrading,
|
||||
orderbook: OrderbookResponse,
|
||||
isBuyOrder: Boolean
|
||||
): Pair<Boolean, String> {
|
||||
// 如果未启用最小订单簿深度过滤,直接通过
|
||||
if (copyTrading.minOrderbookDepth == null) {
|
||||
return Pair(true, "")
|
||||
}
|
||||
|
||||
// 对于买入订单,检查卖盘(asks)前 3 档深度
|
||||
// 对于卖出订单,检查买盘(bids)前 3 档深度
|
||||
val orders = if (isBuyOrder) orderbook.asks else orderbook.bids
|
||||
val topNOrders = orders.take(3) // 前 3 档
|
||||
|
||||
// 计算前 N 档总深度
|
||||
var totalDepth = BigDecimal.ZERO
|
||||
for (order in topNOrders) {
|
||||
val price = order.price.toSafeBigDecimal()
|
||||
val size = order.size.toSafeBigDecimal()
|
||||
val orderAmount = price.multi(size)
|
||||
totalDepth = totalDepth.add(orderAmount)
|
||||
}
|
||||
|
||||
if (totalDepth.lt(copyTrading.minOrderbookDepth)) {
|
||||
return Pair(false, "订单簿深度不足: $totalDepth < ${copyTrading.minOrderbookDepth}")
|
||||
}
|
||||
|
||||
return Pair(true, "")
|
||||
return FilterResult.passed()
|
||||
}
|
||||
}
|
||||
|
||||
+7
-10
@@ -1,11 +1,14 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.copytrading.configs
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.Account
|
||||
import com.wrbug.polymarketbot.entity.CopyTrading
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingMonitorService
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -82,7 +85,6 @@ class CopyTradingService(
|
||||
supportSell = request.supportSell ?: template.supportSell,
|
||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: template.minOrderDepth,
|
||||
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread,
|
||||
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: template.minOrderbookDepth,
|
||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice
|
||||
)
|
||||
@@ -109,7 +111,6 @@ class CopyTradingService(
|
||||
supportSell = request.supportSell ?: true,
|
||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(),
|
||||
maxSpread = request.maxSpread?.toSafeBigDecimal(),
|
||||
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal(),
|
||||
minPrice = request.minPrice?.toSafeBigDecimal(),
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal()
|
||||
)
|
||||
@@ -136,7 +137,6 @@ class CopyTradingService(
|
||||
supportSell = config.supportSell,
|
||||
minOrderDepth = config.minOrderDepth,
|
||||
maxSpread = config.maxSpread,
|
||||
minOrderbookDepth = config.minOrderbookDepth,
|
||||
minPrice = config.minPrice,
|
||||
maxPrice = config.maxPrice,
|
||||
configName = configName,
|
||||
@@ -202,7 +202,6 @@ class CopyTradingService(
|
||||
supportSell = request.supportSell ?: copyTrading.supportSell,
|
||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: copyTrading.minOrderDepth,
|
||||
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: copyTrading.maxSpread,
|
||||
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: copyTrading.minOrderbookDepth,
|
||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: copyTrading.minPrice,
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: copyTrading.maxPrice,
|
||||
configName = configName,
|
||||
@@ -339,7 +338,7 @@ class CopyTradingService(
|
||||
fun getAccountTemplates(accountId: Long): Result<AccountTemplatesResponse> {
|
||||
return try {
|
||||
// 验证账户是否存在
|
||||
val account = accountRepository.findById(accountId).orElse(null)
|
||||
accountRepository.findById(accountId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
|
||||
val copyTradings = copyTradingRepository.findByAccountId(accountId)
|
||||
@@ -380,8 +379,8 @@ class CopyTradingService(
|
||||
*/
|
||||
private fun toDto(
|
||||
copyTrading: CopyTrading,
|
||||
account: com.wrbug.polymarketbot.entity.Account,
|
||||
leader: com.wrbug.polymarketbot.entity.Leader
|
||||
account: Account,
|
||||
leader: Leader
|
||||
): CopyTradingDto {
|
||||
return CopyTradingDto(
|
||||
id = copyTrading.id!!,
|
||||
@@ -408,7 +407,6 @@ class CopyTradingService(
|
||||
supportSell = copyTrading.supportSell,
|
||||
minOrderDepth = copyTrading.minOrderDepth?.toPlainString(),
|
||||
maxSpread = copyTrading.maxSpread?.toPlainString(),
|
||||
minOrderbookDepth = copyTrading.minOrderbookDepth?.toPlainString(),
|
||||
minPrice = copyTrading.minPrice?.toPlainString(),
|
||||
maxPrice = copyTrading.maxPrice?.toPlainString(),
|
||||
configName = copyTrading.configName,
|
||||
@@ -438,7 +436,6 @@ class CopyTradingService(
|
||||
val supportSell: Boolean,
|
||||
val minOrderDepth: BigDecimal?,
|
||||
val maxSpread: BigDecimal?,
|
||||
val minOrderbookDepth: BigDecimal?,
|
||||
val minPrice: BigDecimal?,
|
||||
val maxPrice: BigDecimal?
|
||||
)
|
||||
+78
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.copytrading.configs
|
||||
|
||||
import com.wrbug.polymarketbot.dto.FilteredOrderDto
|
||||
import com.wrbug.polymarketbot.dto.FilteredOrderListRequest
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.copytrading.leaders
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||
|
||||
import com.wrbug.polymarketbot.entity.CopyTrading
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
+4
-3
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||
|
||||
import com.wrbug.polymarketbot.api.TradeResponse
|
||||
import com.wrbug.polymarketbot.api.UserActivityResponse
|
||||
@@ -9,6 +9,7 @@ import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
||||
import org.springframework.stereotype.Service
|
||||
import retrofit2.Response
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -72,7 +73,7 @@ class CopyTradingPollingService(
|
||||
return
|
||||
}
|
||||
|
||||
val leaderId = leader.id!!
|
||||
val leaderId = leader.id
|
||||
monitoredLeaders[leaderId] = leader
|
||||
// 初始化缓存的交易ID集合
|
||||
cachedTradeIds[leaderId] = mutableSetOf()
|
||||
@@ -178,7 +179,7 @@ class CopyTradingPollingService(
|
||||
return
|
||||
}
|
||||
|
||||
val leaderId = leader.id!!
|
||||
val leaderId = leader.id
|
||||
val leaderAddress = leader.leaderAddress
|
||||
|
||||
try {
|
||||
+3
-2
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonObject
|
||||
@@ -11,6 +11,7 @@ import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@@ -65,7 +66,7 @@ class CopyTradingWebSocketService(
|
||||
return
|
||||
}
|
||||
|
||||
val leaderId = leader.id!!
|
||||
val leaderId = leader.id
|
||||
val leaderAddress = leader.leaderAddress.lowercase()
|
||||
leaderAddresses[leaderId] = leaderAddress
|
||||
|
||||
+17
-11
@@ -1,6 +1,7 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.copytrading.orders
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.wrbug.polymarketbot.api.MarketResponse
|
||||
import com.wrbug.polymarketbot.dto.OrderDetailDto
|
||||
import com.wrbug.polymarketbot.dto.OrderMessageDto
|
||||
import com.wrbug.polymarketbot.dto.OrderPushMessage
|
||||
@@ -13,6 +14,11 @@ import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@@ -26,10 +32,10 @@ class OrderPushService(
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val clobService: PolymarketClobService,
|
||||
private val retrofitFactory: RetrofitFactory, // 用于创建 Gamma API 客户端(不需要认证)
|
||||
private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils,
|
||||
private val copyOrderTrackingRepository: com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository? = null, // 可选,避免循环依赖
|
||||
private val copyTradingRepository: com.wrbug.polymarketbot.repository.CopyTradingRepository? = null, // 可选,避免循环依赖
|
||||
private val leaderRepository: com.wrbug.polymarketbot.repository.LeaderRepository? = null // 可选,避免循环依赖
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val copyOrderTrackingRepository: CopyOrderTrackingRepository? = null, // 可选,避免循环依赖
|
||||
private val copyTradingRepository: CopyTradingRepository? = null, // 可选,避免循环依赖
|
||||
private val leaderRepository: LeaderRepository? = null // 可选,避免循环依赖
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(OrderPushService::class.java)
|
||||
@@ -152,9 +158,9 @@ class OrderPushService(
|
||||
return account.apiKey != null &&
|
||||
account.apiSecret != null &&
|
||||
account.apiPassphrase != null &&
|
||||
account.apiKey!!.isNotBlank() &&
|
||||
account.apiSecret!!.isNotBlank() &&
|
||||
account.apiPassphrase!!.isNotBlank()
|
||||
account.apiKey.isNotBlank() &&
|
||||
account.apiSecret.isNotBlank() &&
|
||||
account.apiPassphrase.isNotBlank()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -378,7 +384,7 @@ class OrderPushService(
|
||||
|
||||
/**
|
||||
* 获取订单详情
|
||||
* 通过 PolymarketClobService 获取订单详情
|
||||
* 通过 com.wrbug.polymarketbot.service.common.PolymarketClobService 获取订单详情
|
||||
*/
|
||||
private suspend fun fetchOrderDetail(
|
||||
account: Account,
|
||||
@@ -391,7 +397,7 @@ class OrderPushService(
|
||||
return null
|
||||
}
|
||||
|
||||
// 通过 PolymarketClobService 获取订单详情(需要 L2 认证)
|
||||
// 通过 com.wrbug.polymarketbot.service.common.PolymarketClobService 获取订单详情(需要 L2 认证)
|
||||
// 解密 API 凭证
|
||||
val apiSecret = try {
|
||||
decryptApiSecret(account)
|
||||
@@ -453,7 +459,7 @@ class OrderPushService(
|
||||
* 使用 /markets 接口,通过 condition_ids 查询参数获取市场信息
|
||||
* 订单返回的 market 字段是 16 进制的 condition ID(如 "0x...")
|
||||
*/
|
||||
private suspend fun fetchMarketInfo(conditionId: String): com.wrbug.polymarketbot.api.MarketResponse? {
|
||||
private suspend fun fetchMarketInfo(conditionId: String): MarketResponse? {
|
||||
return try {
|
||||
// 创建 Gamma API 客户端(公开 API,不需要认证)
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
+1
-5
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.copytrading.orders
|
||||
|
||||
import com.wrbug.polymarketbot.api.SignedOrderObject
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
@@ -77,10 +77,6 @@ class OrderSigningService {
|
||||
): OrderAmounts {
|
||||
val sizeDecimal = size.toSafeBigDecimal()
|
||||
val priceDecimal = price.toSafeBigDecimal()
|
||||
|
||||
// 舍入价格
|
||||
val roundedPrice = roundNormal(priceDecimal, roundConfig.price)
|
||||
|
||||
if (side.uppercase() == "BUY") {
|
||||
// BUY: makerAmount = price * size (USDC), takerAmount = size (shares)
|
||||
// makerAmount 是 USDC 金额,最多 2 位小数
|
||||
+304
-139
@@ -1,6 +1,7 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.copytrading.statistics
|
||||
|
||||
import com.wrbug.polymarketbot.api.NewOrderRequest
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.api.TradeResponse
|
||||
import com.wrbug.polymarketbot.entity.*
|
||||
import com.wrbug.polymarketbot.repository.*
|
||||
@@ -9,6 +10,15 @@ import com.wrbug.polymarketbot.util.*
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
import org.springframework.dao.DuplicateKeyException
|
||||
import java.sql.SQLException
|
||||
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingFilterService
|
||||
import com.wrbug.polymarketbot.service.copytrading.configs.FilterStatus
|
||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService
|
||||
import com.wrbug.polymarketbot.service.common.BlockchainService
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
@@ -19,7 +29,7 @@ import java.math.BigDecimal
|
||||
* 实际创建订单并记录跟踪信息
|
||||
*/
|
||||
@Service
|
||||
class CopyOrderTrackingService(
|
||||
open class CopyOrderTrackingService(
|
||||
private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
|
||||
private val sellMatchRecordRepository: SellMatchRecordRepository,
|
||||
private val sellMatchDetailRepository: SellMatchDetailRepository,
|
||||
@@ -32,8 +42,9 @@ class CopyOrderTrackingService(
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val orderSigningService: OrderSigningService,
|
||||
private val blockchainService: BlockchainService,
|
||||
private val clobService: PolymarketClobService,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils,
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖
|
||||
) {
|
||||
|
||||
@@ -41,6 +52,12 @@ class CopyOrderTrackingService(
|
||||
|
||||
// 协程作用域(用于异步发送通知)
|
||||
private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
// 订单创建重试配置
|
||||
companion object {
|
||||
private const val MAX_RETRY_ATTEMPTS = 2 // 最多重试次数(首次 + 1次重试)
|
||||
private const val RETRY_DELAY_MS = 3000L // 重试前等待时间(毫秒,3秒)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密账户私钥
|
||||
@@ -135,21 +152,38 @@ class CopyOrderTrackingService(
|
||||
processedAt = System.currentTimeMillis()
|
||||
)
|
||||
processedTradeRepository.save(processed)
|
||||
} catch (e: DataIntegrityViolationException) {
|
||||
// 唯一约束冲突,说明已经处理过了(可能是并发请求)
|
||||
// 再次检查确认状态
|
||||
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
|
||||
if (existing != null) {
|
||||
if (existing.status == "FAILED") {
|
||||
} catch (e: Exception) {
|
||||
// 检查是否是唯一键冲突异常(可能是 DataIntegrityViolationException、DuplicateKeyException 或 SQLException)
|
||||
if (isUniqueConstraintViolation(e)) {
|
||||
// 唯一约束冲突,说明已经处理过了(可能是并发请求)
|
||||
// 再次检查确认状态
|
||||
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
|
||||
if (existing != null) {
|
||||
if (existing.status == "FAILED") {
|
||||
logger.debug("交易已标记为失败,跳过处理: leaderId=$leaderId, tradeId=${trade.id}")
|
||||
return Result.success(Unit)
|
||||
}
|
||||
logger.debug("交易已处理(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existing.status}")
|
||||
return Result.success(Unit)
|
||||
} else {
|
||||
// 如果检查不到,可能是事务隔离级别问题,等待一下再查询
|
||||
delay(100)
|
||||
val existingAfterDelay =
|
||||
processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
|
||||
if (existingAfterDelay != null) {
|
||||
logger.debug("延迟查询到记录(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existingAfterDelay.status}")
|
||||
return Result.success(Unit)
|
||||
}
|
||||
// 如果还是查询不到,记录警告但不抛出异常(可能是其他约束冲突)
|
||||
logger.warn(
|
||||
"保存ProcessedTrade时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
|
||||
e
|
||||
)
|
||||
// 不抛出异常,避免影响其他交易的处理
|
||||
return Result.success(Unit)
|
||||
}
|
||||
return Result.success(Unit)
|
||||
} else {
|
||||
// 如果检查不到,说明可能是其他约束冲突,重新抛出异常
|
||||
logger.warn(
|
||||
"保存ProcessedTrade时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
|
||||
e
|
||||
)
|
||||
// 其他类型的异常,重新抛出
|
||||
throw e
|
||||
}
|
||||
}
|
||||
@@ -209,10 +243,12 @@ class CopyOrderTrackingService(
|
||||
|
||||
// 过滤条件检查(在计算订单参数之前)
|
||||
// 传入 Leader 交易价格,用于价格区间检查
|
||||
// 订单簿只请求一次,返回给后续逻辑使用
|
||||
val tradePrice = trade.price.toSafeBigDecimal()
|
||||
val filterCheck = filterService.checkFilters(copyTrading, tokenId, isBuyOrder = true, tradePrice = tradePrice)
|
||||
if (!filterCheck.first) {
|
||||
logger.warn("过滤条件检查失败,跳过创建订单: copyTradingId=${copyTrading.id}, reason=${filterCheck.second}")
|
||||
val filterResult = filterService.checkFilters(copyTrading, tokenId, tradePrice = tradePrice)
|
||||
val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
|
||||
if (!filterResult.isPassed) {
|
||||
logger.warn("过滤条件检查失败,跳过创建订单: copyTradingId=${copyTrading.id}, reason=${filterResult.reason}")
|
||||
|
||||
// 记录被过滤的订单并发送通知(异步,不阻塞)
|
||||
notificationScope.launch {
|
||||
@@ -236,8 +272,8 @@ class CopyOrderTrackingService(
|
||||
val marketTitle = marketInfo?.question ?: trade.market
|
||||
val marketSlug = marketInfo?.slug
|
||||
|
||||
// 从 filterReason 中提取 filterType
|
||||
val filterType = extractFilterType(filterCheck.second)
|
||||
// 从过滤结果中提取 filterType
|
||||
val filterType = extractFilterType(filterResult.status, filterResult.reason)
|
||||
|
||||
// 计算买入数量(用于记录,即使被过滤也记录)
|
||||
val calculatedQuantity = try {
|
||||
@@ -262,7 +298,7 @@ class CopyOrderTrackingService(
|
||||
price = trade.price.toSafeBigDecimal(),
|
||||
size = trade.size.toSafeBigDecimal(),
|
||||
calculatedQuantity = calculatedQuantity,
|
||||
filterReason = filterCheck.second,
|
||||
filterReason = filterResult.reason,
|
||||
filterType = filterType
|
||||
)
|
||||
|
||||
@@ -288,7 +324,7 @@ class CopyOrderTrackingService(
|
||||
outcome = trade.outcome,
|
||||
price = trade.price,
|
||||
size = trade.size,
|
||||
filterReason = filterCheck.second,
|
||||
filterReason = filterResult.reason,
|
||||
filterType = filterType,
|
||||
accountName = account.accountName,
|
||||
walletAddress = account.walletAddress,
|
||||
@@ -347,6 +383,35 @@ class CopyOrderTrackingService(
|
||||
// 计算价格(应用价格容忍度)
|
||||
val buyPrice = calculateAdjustedPrice(trade.price.toSafeBigDecimal(), copyTrading, isBuy = true)
|
||||
|
||||
// 在创建订单前,检查订单簿中是否有可匹配的订单(避免 FAK 订单失败)
|
||||
// 如果过滤检查时已经获取了订单簿,直接使用;否则重新获取
|
||||
val orderbookForCheck = orderbook ?: run {
|
||||
val orderbookResult = clobService.getOrderbookByTokenId(tokenId)
|
||||
if (orderbookResult.isSuccess) {
|
||||
orderbookResult.getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有可匹配的卖单(asks)
|
||||
if (orderbookForCheck != null) {
|
||||
val bestAsk = orderbookForCheck.asks
|
||||
.mapNotNull { it.price.toSafeBigDecimal() }
|
||||
.minOrNull()
|
||||
|
||||
if (bestAsk == null) {
|
||||
logger.warn("订单簿中没有卖单,跳过创建订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果调整后的买入价格低于最佳卖单价格,无法匹配
|
||||
if (buyPrice.lt(bestAsk)) {
|
||||
logger.warn("调整后的买入价格 ($buyPrice) 低于最佳卖单价格 ($bestAsk),无法匹配,跳过创建订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 解密 API 凭证
|
||||
val apiSecret = try {
|
||||
decryptApiSecret(account)
|
||||
@@ -372,7 +437,9 @@ class CopyOrderTrackingService(
|
||||
// 解密私钥
|
||||
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||
|
||||
// 调用API创建订单(带重试机制,重试时会重新生成salt并重新签名)
|
||||
// 调用API创建订单(带重试机制)
|
||||
// 重试策略:最多重试 MAX_RETRY_ATTEMPTS 次,每次重试前等待 RETRY_DELAY_MS 毫秒
|
||||
// 每次重试都会重新生成salt并重新签名,确保签名唯一性
|
||||
val createOrderResult = createOrderWithRetry(
|
||||
clobApi = clobApi,
|
||||
privateKey = decryptedPrivateKey,
|
||||
@@ -381,13 +448,14 @@ class CopyOrderTrackingService(
|
||||
side = "BUY",
|
||||
price = buyPrice.toString(),
|
||||
size = finalBuyQuantity.toString(),
|
||||
owner = account.apiKey!!,
|
||||
owner = account.apiKey,
|
||||
copyTradingId = copyTrading.id!!,
|
||||
tradeId = trade.id
|
||||
)
|
||||
|
||||
// 处理订单创建失败
|
||||
if (createOrderResult.isFailure) {
|
||||
// 创建订单失败,记录到失败表
|
||||
// 提取错误信息(只保留 code 和 errorBody)
|
||||
val exception = createOrderResult.exceptionOrNull()
|
||||
val errorMsg = buildFullErrorMessage(
|
||||
exception,
|
||||
@@ -396,16 +464,19 @@ class CopyOrderTrackingService(
|
||||
finalBuyQuantity.toString(),
|
||||
trade.id
|
||||
)
|
||||
|
||||
// 记录失败交易到数据库
|
||||
// retryCount = MAX_RETRY_ATTEMPTS - 1,表示已重试的次数
|
||||
recordFailedTrade(
|
||||
leaderId = leaderId,
|
||||
trade = trade,
|
||||
copyTradingId = copyTrading.id!!,
|
||||
accountId = copyTrading.accountId,
|
||||
side = "BUY", // 订单方向是BUY
|
||||
side = "BUY",
|
||||
price = buyPrice.toString(),
|
||||
size = finalBuyQuantity.toString(),
|
||||
errorMessage = errorMsg,
|
||||
retryCount = 1 // 已重试一次
|
||||
retryCount = MAX_RETRY_ATTEMPTS - 1 // 已重试次数
|
||||
)
|
||||
|
||||
// 发送订单失败通知(异步,不阻塞,仅在 pushFailedOrders 为 true 时发送)
|
||||
@@ -416,7 +487,8 @@ class CopyOrderTrackingService(
|
||||
val marketInfo = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(trade.market))
|
||||
val marketResponse =
|
||||
gammaApi.listMarkets(conditionIds = listOf(trade.market))
|
||||
if (marketResponse.isSuccessful && marketResponse.body() != null) {
|
||||
marketResponse.body()!!.firstOrNull()
|
||||
} else {
|
||||
@@ -533,7 +605,7 @@ class CopyOrderTrackingService(
|
||||
} catch (e: Exception) {
|
||||
java.util.Locale("zh", "CN") // 默认简体中文
|
||||
}
|
||||
|
||||
|
||||
// 获取 Leader 和跟单配置信息
|
||||
val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null)
|
||||
val leaderName = leader?.leaderName
|
||||
@@ -637,8 +709,8 @@ class CopyOrderTrackingService(
|
||||
* 卖出订单匹配
|
||||
* 统一按比例计算,不区分RATIO或FIXED模式
|
||||
* 实际创建卖出订单并记录匹配关系
|
||||
* 注意:此方法在 @Transactional 方法中被调用,会自动继承事务
|
||||
*/
|
||||
@Transactional
|
||||
private suspend fun matchSellOrder(
|
||||
copyTrading: CopyTrading,
|
||||
leaderSellTrade: TradeResponse
|
||||
@@ -682,7 +754,28 @@ class CopyOrderTrackingService(
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 按FIFO顺序匹配,计算实际可以卖出的数量
|
||||
// 4. 获取tokenId(直接使用outcomeIndex,支持多元市场)
|
||||
val tokenIdResult = blockchainService.getTokenId(leaderSellTrade.market, leaderSellTrade.outcomeIndex)
|
||||
if (tokenIdResult.isFailure) {
|
||||
logger.error("获取tokenId失败: market=${leaderSellTrade.market}, outcomeIndex=${leaderSellTrade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}")
|
||||
return
|
||||
}
|
||||
val tokenId = tokenIdResult.getOrNull() ?: return
|
||||
|
||||
// 5. 计算卖出价格(优先使用订单簿 bestBid,失败则使用 Leader 价格,固定按90%计算)
|
||||
// 注意:需要先计算卖出价格,因为后续创建 matchDetails 需要使用实际卖出价格
|
||||
val leaderPrice = leaderSellTrade.price.toSafeBigDecimal()
|
||||
val sellPrice = runCatching {
|
||||
clobService.getOrderbookByTokenId(tokenId)
|
||||
.getOrNull()
|
||||
?.let { calculateMarketSellPrice(it) }
|
||||
}
|
||||
.onFailure { e -> logger.warn("获取订单簿或计算 bestBid 失败,使用 Leader 价格: tokenId=$tokenId, error=${e.message}") }
|
||||
.getOrNull()
|
||||
?: calculateFallbackSellPrice(leaderPrice)
|
||||
|
||||
// 6. 按FIFO顺序匹配,计算实际可以卖出的数量
|
||||
// 使用计算出的实际卖出价格(而不是 Leader 价格)来创建匹配明细
|
||||
var totalMatched = BigDecimal.ZERO
|
||||
var remaining = needMatch
|
||||
val matchDetails = mutableListOf<SellMatchDetail>()
|
||||
@@ -697,19 +790,18 @@ class CopyOrderTrackingService(
|
||||
|
||||
if (matchQty.lte(BigDecimal.ZERO)) continue
|
||||
|
||||
// 计算盈亏
|
||||
// 计算盈亏(使用实际卖出价格)
|
||||
val buyPrice = order.price.toSafeBigDecimal()
|
||||
val sellPrice = leaderSellTrade.price.toSafeBigDecimal()
|
||||
val realizedPnl = sellPrice.subtract(buyPrice).multi(matchQty)
|
||||
|
||||
// 创建匹配明细(稍后保存)
|
||||
// 创建匹配明细(使用实际卖出价格)
|
||||
val detail = SellMatchDetail(
|
||||
matchRecordId = 0, // 稍后设置
|
||||
trackingId = order.id!!,
|
||||
buyOrderId = order.buyOrderId,
|
||||
matchedQuantity = matchQty,
|
||||
buyPrice = buyPrice,
|
||||
sellPrice = sellPrice,
|
||||
sellPrice = sellPrice, // 使用实际卖出价格,与 SellMatchRecord 保持一致
|
||||
realizedPnl = realizedPnl
|
||||
)
|
||||
matchDetails.add(detail)
|
||||
@@ -722,17 +814,6 @@ class CopyOrderTrackingService(
|
||||
return
|
||||
}
|
||||
|
||||
// 5. 获取tokenId(直接使用outcomeIndex,支持多元市场)
|
||||
val tokenIdResult = blockchainService.getTokenId(leaderSellTrade.market, leaderSellTrade.outcomeIndex)
|
||||
if (tokenIdResult.isFailure) {
|
||||
logger.error("获取tokenId失败: market=${leaderSellTrade.market}, outcomeIndex=${leaderSellTrade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}")
|
||||
return
|
||||
}
|
||||
val tokenId = tokenIdResult.getOrNull() ?: return
|
||||
|
||||
// 6. 计算卖出价格(应用价格容忍度)
|
||||
val sellPrice = calculateAdjustedPrice(leaderSellTrade.price.toSafeBigDecimal(), copyTrading, isBuy = false)
|
||||
|
||||
// 7. 解密私钥(在方法开始时解密一次,后续复用)
|
||||
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||
// 8. 创建并签名卖出订单
|
||||
@@ -759,7 +840,7 @@ class CopyOrderTrackingService(
|
||||
// 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配
|
||||
val orderRequest = NewOrderRequest(
|
||||
order = signedOrder,
|
||||
owner = account.apiKey!!,
|
||||
owner = account.apiKey,
|
||||
orderType = "FAK", // Fill-And-Kill
|
||||
deferExec = false
|
||||
)
|
||||
@@ -782,8 +863,8 @@ class CopyOrderTrackingService(
|
||||
side = "SELL",
|
||||
price = sellPrice.toString(),
|
||||
size = totalMatched.toString(),
|
||||
owner = account.apiKey!!,
|
||||
copyTradingId = copyTrading.id!!,
|
||||
owner = account.apiKey,
|
||||
copyTradingId = copyTrading.id,
|
||||
tradeId = leaderSellTrade.id
|
||||
)
|
||||
|
||||
@@ -830,11 +911,11 @@ class CopyOrderTrackingService(
|
||||
val totalRealizedPnl = matchDetails.sumOf { it.realizedPnl.toSafeBigDecimal() }
|
||||
|
||||
val matchRecord = SellMatchRecord(
|
||||
copyTradingId = copyTrading.id!!,
|
||||
copyTradingId = copyTrading.id,
|
||||
sellOrderId = realSellOrderId, // 使用真实订单ID
|
||||
leaderSellTradeId = leaderSellTrade.id,
|
||||
marketId = leaderSellTrade.market,
|
||||
side = leaderSellTrade.outcomeIndex?.toString() ?: "0", // 使用outcomeIndex作为side(兼容旧数据)
|
||||
side = leaderSellTrade.outcomeIndex.toString(), // 使用outcomeIndex作为side(兼容旧数据)
|
||||
outcomeIndex = leaderSellTrade.outcomeIndex, // 新增字段
|
||||
totalMatchedQuantity = totalMatched,
|
||||
sellPrice = sellPrice,
|
||||
@@ -853,27 +934,42 @@ class CopyOrderTrackingService(
|
||||
|
||||
/**
|
||||
* 创建订单(带重试机制)
|
||||
* 失败后重试一次,如果仍然失败则返回失败结果
|
||||
* 注意:重试时会重新生成salt并重新签名,确保每次重试都是新的订单
|
||||
*
|
||||
* 重试策略:
|
||||
* - 最多重试 MAX_RETRY_ATTEMPTS 次(首次尝试 + 重试)
|
||||
* - 每次重试前等待 RETRY_DELAY_MS 毫秒
|
||||
* - 每次重试都重新生成salt并重新签名,确保签名唯一性
|
||||
*
|
||||
* @param clobApi CLOB API 客户端
|
||||
* @param privateKey 私钥(用于签名)
|
||||
* @param makerAddress 代理钱包地址
|
||||
* @param tokenId Token ID
|
||||
* @param side 订单方向(BUY/SELL)
|
||||
* @param price 价格
|
||||
* @param size 数量
|
||||
* @param owner API Key(用于owner字段)
|
||||
* @param copyTradingId 跟单配置ID(用于日志)
|
||||
* @param tradeId Leader 交易ID(用于日志)
|
||||
* @return 成功返回订单ID,失败返回异常
|
||||
*/
|
||||
private suspend fun createOrderWithRetry(
|
||||
clobApi: com.wrbug.polymarketbot.api.PolymarketClobApi,
|
||||
clobApi: PolymarketClobApi,
|
||||
privateKey: String,
|
||||
makerAddress: String,
|
||||
tokenId: String,
|
||||
side: String,
|
||||
price: String,
|
||||
size: String,
|
||||
owner: String, // API Key,用于owner字段
|
||||
owner: String,
|
||||
copyTradingId: Long,
|
||||
tradeId: String
|
||||
): Result<String> {
|
||||
var lastError: Exception? = null
|
||||
|
||||
// 最多重试2次(首次 + 1次重试)
|
||||
for (attempt in 1..2) {
|
||||
// 重试循环:最多重试 MAX_RETRY_ATTEMPTS 次
|
||||
for (attempt in 1..MAX_RETRY_ATTEMPTS) {
|
||||
try {
|
||||
// 每次重试都重新生成salt并重新签名
|
||||
// 每次重试都重新生成salt并重新签名,确保签名唯一性
|
||||
val signedOrder = orderSigningService.createAndSignOrder(
|
||||
privateKey = privateKey,
|
||||
makerAddress = makerAddress,
|
||||
@@ -887,76 +983,118 @@ class CopyOrderTrackingService(
|
||||
expiration = "0"
|
||||
)
|
||||
|
||||
// 构建订单请求(每次重试都使用新签名的订单)
|
||||
// 构建订单请求
|
||||
// 跟单订单使用 FAK (Fill-And-Kill),允许部分成交,未成交部分立即取消
|
||||
// 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配
|
||||
val orderRequest = NewOrderRequest(
|
||||
order = signedOrder,
|
||||
owner = owner, // API Key
|
||||
owner = owner,
|
||||
orderType = "FAK", // Fill-And-Kill
|
||||
deferExec = false
|
||||
)
|
||||
|
||||
// 调用 API 创建订单
|
||||
val orderResponse = clobApi.createOrder(orderRequest)
|
||||
|
||||
// 检查 HTTP 响应状态
|
||||
if (!orderResponse.isSuccessful || orderResponse.body() == null) {
|
||||
val errorBody = try {
|
||||
orderResponse.errorBody()?.string()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
val errorMsg =
|
||||
"创建订单失败: copyTradingId=$copyTradingId, tradeId=$tradeId, attempt=$attempt, side=$side, price=$price, size=$size, tokenId=$tokenId, code=${orderResponse.code()}, message=${orderResponse.message()}${if (errorBody != null) ", errorBody=$errorBody" else ""}"
|
||||
val errorMsg = "code=${orderResponse.code()}, errorBody=${errorBody ?: "null"}"
|
||||
lastError = Exception(errorMsg)
|
||||
// 所有失败都记录详细日志
|
||||
logger.error(errorMsg)
|
||||
if (attempt < 2) {
|
||||
delay(1000) // 重试前等待1秒
|
||||
|
||||
// 记录错误日志
|
||||
logger.error("创建订单失败 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg")
|
||||
|
||||
// 如果不是最后一次尝试,等待后重试
|
||||
if (attempt < MAX_RETRY_ATTEMPTS) {
|
||||
delay(RETRY_DELAY_MS)
|
||||
continue
|
||||
}
|
||||
return Result.failure(lastError!!)
|
||||
return Result.failure(lastError)
|
||||
}
|
||||
|
||||
// 检查业务响应状态
|
||||
val response = orderResponse.body()!!
|
||||
if (!response.success || response.orderId == null) {
|
||||
val errorMsg =
|
||||
"创建订单失败: copyTradingId=$copyTradingId, tradeId=$tradeId, attempt=$attempt, side=$side, price=$price, size=$size, tokenId=$tokenId, errorMsg=${response.errorMsg}"
|
||||
val errorMsg = "errorMsg=${response.errorMsg}"
|
||||
lastError = Exception(errorMsg)
|
||||
// 所有失败都记录详细日志
|
||||
logger.error(errorMsg)
|
||||
if (attempt < 2) {
|
||||
delay(1000) // 重试前等待1秒
|
||||
|
||||
// 记录错误日志
|
||||
logger.error("创建订单失败 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg")
|
||||
|
||||
// 如果不是最后一次尝试,等待后重试
|
||||
if (attempt < MAX_RETRY_ATTEMPTS) {
|
||||
delay(RETRY_DELAY_MS)
|
||||
continue
|
||||
}
|
||||
return Result.failure(lastError!!)
|
||||
return Result.failure(lastError)
|
||||
}
|
||||
|
||||
// 成功
|
||||
// 创建订单成功
|
||||
logger.info("创建订单成功: copyTradingId=$copyTradingId, tradeId=$tradeId, orderId=${response.orderId}, attempt=$attempt")
|
||||
return Result.success(response.orderId)
|
||||
|
||||
} catch (e: Exception) {
|
||||
val errorMsg =
|
||||
"调用创建订单API异常: copyTradingId=$copyTradingId, tradeId=$tradeId, attempt=$attempt, side=$side, price=$price, size=$size, tokenId=$tokenId, error=${e.message}"
|
||||
val errorMsg = "error=${e.message}"
|
||||
lastError = Exception(errorMsg, e)
|
||||
// 所有失败都记录详细日志(包括堆栈)
|
||||
logger.error(errorMsg, e)
|
||||
if (attempt < 2) {
|
||||
delay(1000) // 重试前等待1秒
|
||||
|
||||
// 记录错误日志(包含堆栈)
|
||||
logger.error("创建订单异常 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg", e)
|
||||
|
||||
// 如果不是最后一次尝试,等待后重试
|
||||
if (attempt < MAX_RETRY_ATTEMPTS) {
|
||||
delay(RETRY_DELAY_MS)
|
||||
continue
|
||||
}
|
||||
return Result.failure(lastError!!)
|
||||
return Result.failure(lastError)
|
||||
}
|
||||
}
|
||||
|
||||
val finalError = lastError ?: Exception("创建订单失败:未知错误")
|
||||
logger.error(
|
||||
"创建订单失败(所有重试都失败): copyTradingId=$copyTradingId, tradeId=$tradeId, side=$side, price=$price, size=$size, tokenId=$tokenId",
|
||||
finalError
|
||||
)
|
||||
// 所有重试都失败
|
||||
val finalError = lastError ?: Exception("error=未知错误")
|
||||
logger.error("创建订单失败(所有重试都失败): copyTradingId=$copyTradingId, tradeId=$tradeId, side=$side, price=$price, size=$size", finalError)
|
||||
return Result.failure(finalError)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建完整的错误信息(包括堆栈)
|
||||
* 检查是否是唯一键冲突异常
|
||||
*/
|
||||
private fun isUniqueConstraintViolation(e: Exception): Boolean {
|
||||
// 检查是否是 DataIntegrityViolationException 或 DuplicateKeyException
|
||||
if (e is DataIntegrityViolationException || e is DuplicateKeyException) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查是否是 SQLException(MySQL 错误码 1062 表示重复键)
|
||||
var cause: Throwable? = e.cause
|
||||
while (cause != null) {
|
||||
if (cause is SQLException) {
|
||||
val sqlException = cause as SQLException
|
||||
// MySQL 错误码 1062 表示重复键(Duplicate entry)
|
||||
if (sqlException.errorCode == 1062 || sqlException.sqlState == "23000") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 检查异常消息中是否包含唯一键冲突的关键字
|
||||
val message = cause.message ?: ""
|
||||
if (message.contains("Duplicate entry") ||
|
||||
message.contains("uk_leader_trade") ||
|
||||
message.contains("UNIQUE constraint")
|
||||
) {
|
||||
return true
|
||||
}
|
||||
cause = cause.cause
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建简化的错误信息(只保留 code 和 errorBody)
|
||||
*/
|
||||
private fun buildFullErrorMessage(
|
||||
exception: Throwable?,
|
||||
@@ -966,35 +1104,29 @@ class CopyOrderTrackingService(
|
||||
tradeId: String
|
||||
): String {
|
||||
if (exception == null) {
|
||||
return "创建订单失败: side=$side, price=$price, size=$size, tradeId=$tradeId, 未知错误"
|
||||
return "code=未知, errorBody=null"
|
||||
}
|
||||
|
||||
val errorMsg = StringBuilder()
|
||||
errorMsg.append("创建订单失败: side=$side, price=$price, size=$size, tradeId=$tradeId")
|
||||
errorMsg.append(", error=${exception.message}")
|
||||
val exceptionMessage = exception.message ?: ""
|
||||
|
||||
// 添加堆栈信息(限制长度,避免过长)
|
||||
val stackTrace = exception.stackTraceToString()
|
||||
val maxLength = 2000 // 限制错误信息最大长度为2000字符
|
||||
if (stackTrace.length > maxLength) {
|
||||
errorMsg.append(", stackTrace=${stackTrace.substring(0, maxLength)}...")
|
||||
} else {
|
||||
errorMsg.append(", stackTrace=$stackTrace")
|
||||
}
|
||||
// 从错误信息中提取 code 和 errorBody
|
||||
val codePattern = Regex("code=([^,}]+)")
|
||||
val errorBodyPattern = Regex("errorBody=([^,}]+)")
|
||||
|
||||
// 如果有 cause,也添加
|
||||
exception.cause?.let { cause ->
|
||||
errorMsg.append(", cause=${cause.message}")
|
||||
}
|
||||
val codeMatch = codePattern.find(exceptionMessage)
|
||||
val errorBodyMatch = errorBodyPattern.find(exceptionMessage)
|
||||
|
||||
return errorMsg.toString()
|
||||
val code = codeMatch?.groupValues?.get(1)?.trim() ?: "未知"
|
||||
val errorBody = errorBodyMatch?.groupValues?.get(1)?.trim() ?: "null"
|
||||
|
||||
return "code=$code, errorBody=$errorBody"
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录失败交易到数据库
|
||||
* 注意:此方法在 @Transactional 方法中被调用,会自动继承事务
|
||||
*/
|
||||
@Transactional
|
||||
private fun recordFailedTrade(
|
||||
private suspend fun recordFailedTrade(
|
||||
leaderId: Long,
|
||||
trade: TradeResponse,
|
||||
copyTradingId: Long,
|
||||
@@ -1045,21 +1177,35 @@ class CopyOrderTrackingService(
|
||||
processedAt = System.currentTimeMillis()
|
||||
)
|
||||
processedTradeRepository.save(processed)
|
||||
} catch (e: DataIntegrityViolationException) {
|
||||
// 唯一约束冲突,说明已经处理过了(可能是并发请求)
|
||||
// 检查现有记录的状态
|
||||
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
|
||||
if (existing != null) {
|
||||
if (existing.status == "SUCCESS") {
|
||||
logger.warn("交易已成功处理,但尝试记录为失败(并发冲突): leaderId=$leaderId, tradeId=${trade.id}")
|
||||
} catch (e: Exception) {
|
||||
// 检查是否是唯一键冲突异常
|
||||
if (isUniqueConstraintViolation(e)) {
|
||||
// 唯一约束冲突,说明已经处理过了(可能是并发请求)
|
||||
// 检查现有记录的状态
|
||||
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
|
||||
if (existing != null) {
|
||||
if (existing.status == "SUCCESS") {
|
||||
logger.warn("交易已成功处理,但尝试记录为失败(并发冲突): leaderId=$leaderId, tradeId=${trade.id}")
|
||||
} else {
|
||||
logger.debug("交易已标记为失败(并发检测): leaderId=$leaderId, tradeId=${trade.id}")
|
||||
}
|
||||
} else {
|
||||
logger.debug("交易已标记为失败(并发检测): leaderId=$leaderId, tradeId=${trade.id}")
|
||||
// 如果查询不到,等待一下再查询(可能是事务隔离级别问题)
|
||||
delay(100)
|
||||
val existingAfterDelay =
|
||||
processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
|
||||
if (existingAfterDelay != null) {
|
||||
logger.debug("延迟查询到记录(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existingAfterDelay.status}")
|
||||
} else {
|
||||
logger.warn(
|
||||
"保存ProcessedTrade失败记录时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
"保存ProcessedTrade失败记录时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
|
||||
e
|
||||
)
|
||||
// 其他类型的异常,记录但不抛出(避免影响其他交易的处理)
|
||||
logger.warn("保存ProcessedTrade失败记录时发生异常: leaderId=$leaderId, tradeId=${trade.id}", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1105,7 +1251,7 @@ class CopyOrderTrackingService(
|
||||
}
|
||||
|
||||
// 2. 检查每日亏损限制(需要计算今日已实现盈亏)
|
||||
val todaySellRecords = sellMatchRecordRepository.findByCopyTradingId(copyTrading.id!!)
|
||||
val todaySellRecords = sellMatchRecordRepository.findByCopyTradingId(copyTrading.id)
|
||||
.filter { it.createdAt >= todayStart }
|
||||
|
||||
val todayRealizedPnl = todaySellRecords.sumOf { it.totalRealizedPnl.toSafeBigDecimal() }
|
||||
@@ -1121,19 +1267,22 @@ class CopyOrderTrackingService(
|
||||
|
||||
/**
|
||||
* 计算调整后的价格(应用价格容忍度)
|
||||
* 如果价格容忍度为0,使用默认值5%
|
||||
*/
|
||||
private fun calculateAdjustedPrice(
|
||||
originalPrice: BigDecimal,
|
||||
copyTrading: CopyTrading,
|
||||
isBuy: Boolean
|
||||
): BigDecimal {
|
||||
// 如果价格容忍度为0,直接返回原价格
|
||||
if (copyTrading.priceTolerance.eq(BigDecimal.ZERO)) {
|
||||
return originalPrice
|
||||
// 如果价格容忍度为0,使用默认值5%
|
||||
val tolerance = if (copyTrading.priceTolerance.eq(BigDecimal.ZERO)) {
|
||||
BigDecimal("5")
|
||||
} else {
|
||||
copyTrading.priceTolerance
|
||||
}
|
||||
|
||||
// 计算价格调整范围(百分比)
|
||||
val tolerancePercent = copyTrading.priceTolerance.div(100)
|
||||
val tolerancePercent = tolerance.div(100)
|
||||
val adjustment = originalPrice.multi(tolerancePercent)
|
||||
|
||||
return if (isBuy) {
|
||||
@@ -1146,23 +1295,39 @@ class CopyOrderTrackingService(
|
||||
}
|
||||
|
||||
/**
|
||||
* 从过滤原因中提取过滤类型
|
||||
* 计算市价卖出价格(使用订单簿的 bestBid,固定按90%计算)
|
||||
*/
|
||||
private fun extractFilterType(filterReason: String): String {
|
||||
return when {
|
||||
filterReason.contains("价格低于最低限制", ignoreCase = true) || filterReason.contains("价格高于最高限制", ignoreCase = true) -> "PRICE_RANGE"
|
||||
filterReason.contains("订单深度不足", ignoreCase = true) -> "ORDER_DEPTH"
|
||||
filterReason.contains("价差过大", ignoreCase = true) -> "SPREAD"
|
||||
filterReason.contains("订单簿深度不足", ignoreCase = true) -> "ORDERBOOK_DEPTH"
|
||||
filterReason.contains("价格", ignoreCase = true) && filterReason.contains(
|
||||
"合理",
|
||||
ignoreCase = true
|
||||
) -> "PRICE_VALIDITY"
|
||||
private fun calculateMarketSellPrice(
|
||||
orderbook: com.wrbug.polymarketbot.api.OrderbookResponse
|
||||
): BigDecimal {
|
||||
// 获取 bestBid(最高买入价)
|
||||
val bestBid = orderbook.bids
|
||||
.mapNotNull { it.price.toSafeBigDecimal() }
|
||||
.maxOrNull()
|
||||
?: throw IllegalStateException("订单簿 bids 为空,无法获取 bestBid")
|
||||
|
||||
filterReason.contains("市场状态", ignoreCase = true) -> "MARKET_STATUS"
|
||||
filterReason.contains("获取订单簿失败", ignoreCase = true) -> "ORDERBOOK_ERROR"
|
||||
filterReason.contains("订单簿为空", ignoreCase = true) -> "ORDERBOOK_EMPTY"
|
||||
else -> "UNKNOWN"
|
||||
// 卖出:bestBid * 0.9(固定按90%计算,确保能立即成交)
|
||||
return calculateFallbackSellPrice(bestBid)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算降级卖出价格(固定按90%计算)
|
||||
*/
|
||||
private fun calculateFallbackSellPrice(price: BigDecimal): BigDecimal {
|
||||
return price.multi(BigDecimal("0.9")).coerceAtLeast(BigDecimal("0.01"))
|
||||
}
|
||||
|
||||
/**
|
||||
* 从过滤结果中提取过滤类型
|
||||
*/
|
||||
private fun extractFilterType(status: FilterStatus, reason: String): String {
|
||||
return when (status) {
|
||||
FilterStatus.PASSED -> "PASSED"
|
||||
FilterStatus.FAILED_PRICE_RANGE -> "PRICE_RANGE"
|
||||
FilterStatus.FAILED_ORDERBOOK_ERROR -> "ORDERBOOK_ERROR"
|
||||
FilterStatus.FAILED_ORDERBOOK_EMPTY -> "ORDERBOOK_EMPTY"
|
||||
FilterStatus.FAILED_SPREAD -> "SPREAD"
|
||||
FilterStatus.FAILED_ORDER_DEPTH -> "ORDER_DEPTH"
|
||||
}
|
||||
}
|
||||
|
||||
+90
-16
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.copytrading.statistics
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.*
|
||||
@@ -13,6 +13,8 @@ import org.slf4j.LoggerFactory
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.domain.Sort
|
||||
import com.wrbug.polymarketbot.service.accounts.AccountService
|
||||
import com.wrbug.polymarketbot.service.common.BlockchainService
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
@@ -30,7 +32,7 @@ class CopyTradingStatisticsService(
|
||||
private val accountRepository: AccountRepository,
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val accountService: AccountService,
|
||||
private val blockchainService: com.wrbug.polymarketbot.service.BlockchainService
|
||||
private val blockchainService: BlockchainService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CopyTradingStatisticsService::class.java)
|
||||
@@ -69,7 +71,10 @@ class CopyTradingStatisticsService(
|
||||
// 9. 计算未实现盈亏(使用链上实际持仓,而不是 remainingQuantity)
|
||||
val unrealizedPnl = calculateUnrealizedPnl(buyOrders, currentPrice, actualPositions)
|
||||
|
||||
// 9. 构建响应
|
||||
// 10. 计算持仓价值(使用链上实际持仓和当前价格)
|
||||
val positionValue = calculatePositionValue(buyOrders, currentPrice, actualPositions)
|
||||
|
||||
// 11. 构建响应
|
||||
val response = CopyTradingStatisticsResponse(
|
||||
copyTradingId = copyTradingId,
|
||||
accountId = copyTrading.accountId,
|
||||
@@ -85,7 +90,7 @@ class CopyTradingStatisticsService(
|
||||
totalSellOrders = statistics.totalSellOrders,
|
||||
totalSellAmount = statistics.totalSellAmount,
|
||||
currentPositionQuantity = statistics.currentPositionQuantity,
|
||||
currentPositionValue = calculatePositionValue(statistics.currentPositionQuantity, currentPrice),
|
||||
currentPositionValue = positionValue,
|
||||
totalRealizedPnl = statistics.totalRealizedPnl,
|
||||
totalUnrealizedPnl = unrealizedPnl,
|
||||
totalPnl = (statistics.totalRealizedPnl.toSafeBigDecimal().add(unrealizedPnl.toSafeBigDecimal())).toString(),
|
||||
@@ -105,7 +110,7 @@ class CopyTradingStatisticsService(
|
||||
fun getOrderList(request: OrderTrackingRequest): Result<OrderListResponse> {
|
||||
return try {
|
||||
// 1. 验证跟单关系
|
||||
val copyTrading = copyTradingRepository.findById(request.copyTradingId).orElse(null)
|
||||
copyTradingRepository.findById(request.copyTradingId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("跟单关系不存在: ${request.copyTradingId}"))
|
||||
|
||||
// 2. 根据类型查询
|
||||
@@ -293,8 +298,10 @@ class CopyTradingStatisticsService(
|
||||
}
|
||||
|
||||
// 卖出统计
|
||||
// 使用 SellMatchDetail 计算总卖出金额,确保准确性
|
||||
// 因为每个明细都记录了准确的匹配数量和卖出价格
|
||||
val totalSellQuantity = sellRecords.sumOf { it.totalMatchedQuantity.toSafeBigDecimal() }
|
||||
val totalSellAmount = sellRecords.sumOf { it.totalMatchedQuantity.toSafeBigDecimal().multi(it.sellPrice) }
|
||||
val totalSellAmount = matchDetails.sumOf { it.matchedQuantity.toSafeBigDecimal().multi(it.sellPrice) }
|
||||
val totalSellOrders = sellRecords.size.toLong()
|
||||
|
||||
// 持仓统计
|
||||
@@ -354,7 +361,7 @@ class CopyTradingStatisticsService(
|
||||
* 获取链上实际持仓
|
||||
* 按 (marketId, outcomeIndex) 组合返回实际持仓数量
|
||||
*/
|
||||
private suspend fun getActualPositions(account: com.wrbug.polymarketbot.entity.Account?): Map<String, BigDecimal> {
|
||||
private suspend fun getActualPositions(account: Account?): Map<String, BigDecimal> {
|
||||
val positions = mutableMapOf<String, BigDecimal>()
|
||||
|
||||
if (account == null || account.proxyAddress.isBlank()) {
|
||||
@@ -385,6 +392,7 @@ class CopyTradingStatisticsService(
|
||||
/**
|
||||
* 计算未实现盈亏
|
||||
* 使用链上实际持仓数量,而不是 remainingQuantity(考虑手动卖出的情况)
|
||||
* 按市场聚合订单,计算加权平均买入价格,避免重复计算
|
||||
*/
|
||||
private fun calculateUnrealizedPnl(
|
||||
buyOrders: List<CopyOrderTracking>,
|
||||
@@ -393,6 +401,9 @@ class CopyTradingStatisticsService(
|
||||
): String {
|
||||
var totalUnrealizedPnl = BigDecimal.ZERO
|
||||
|
||||
// 按市场聚合订单,计算加权平均买入价格
|
||||
val marketAggregates = mutableMapOf<String, Pair<BigDecimal, BigDecimal>>() // key -> (总持仓, 总成本)
|
||||
|
||||
for (order in buyOrders) {
|
||||
// 如果没有 outcomeIndex,跳过(无法确定价格和持仓)
|
||||
if (order.outcomeIndex == null) {
|
||||
@@ -403,8 +414,32 @@ class CopyTradingStatisticsService(
|
||||
// 使用 "marketId:outcomeIndex" 作为 key
|
||||
val key = "${order.marketId}:${order.outcomeIndex}"
|
||||
|
||||
// 获取链上实际持仓数量(如果存在),否则使用 remainingQuantity
|
||||
val actualQty = actualPositions[key] ?: order.remainingQuantity.toSafeBigDecimal()
|
||||
// 获取订单的持仓数量(使用 remainingQuantity,因为这是该订单的持仓)
|
||||
val orderQty = order.remainingQuantity.toSafeBigDecimal()
|
||||
|
||||
// 如果订单持仓 <= 0,跳过
|
||||
if (orderQty.lte(BigDecimal.ZERO)) continue
|
||||
|
||||
val buyPrice = order.price.toSafeBigDecimal()
|
||||
val orderCost = orderQty.multi(buyPrice)
|
||||
|
||||
// 聚合同一市场的订单
|
||||
val existing = marketAggregates[key]
|
||||
if (existing != null) {
|
||||
val totalQty = existing.first.add(orderQty)
|
||||
val totalCost = existing.second.add(orderCost)
|
||||
marketAggregates[key] = Pair(totalQty, totalCost)
|
||||
} else {
|
||||
marketAggregates[key] = Pair(orderQty, orderCost)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算每个市场的未实现盈亏
|
||||
for ((key, aggregate) in marketAggregates) {
|
||||
val (totalQty, totalCost) = aggregate
|
||||
|
||||
// 获取链上实际持仓数量(如果存在),否则使用聚合的持仓数量
|
||||
val actualQty = actualPositions[key] ?: totalQty
|
||||
|
||||
// 如果实际持仓 <= 0,说明已全部卖出(包括手动卖出),跳过未实现盈亏计算
|
||||
if (actualQty.lte(BigDecimal.ZERO)) continue
|
||||
@@ -413,9 +448,15 @@ class CopyTradingStatisticsService(
|
||||
val currentPrice = currentPrices[key]?.toSafeBigDecimal()
|
||||
?: continue // 如果没有当前价格,跳过
|
||||
|
||||
val buyPrice = order.price.toSafeBigDecimal()
|
||||
// 使用实际持仓数量计算未实现盈亏
|
||||
val unrealizedPnl = currentPrice.subtract(buyPrice).multi(actualQty)
|
||||
// 计算加权平均买入价格
|
||||
val avgBuyPrice = if (totalQty.gt(BigDecimal.ZERO)) {
|
||||
totalCost.div(totalQty)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
// 使用实际持仓数量和加权平均买入价格计算未实现盈亏
|
||||
val unrealizedPnl = currentPrice.subtract(avgBuyPrice).multi(actualQty)
|
||||
totalUnrealizedPnl = totalUnrealizedPnl.add(unrealizedPnl)
|
||||
}
|
||||
|
||||
@@ -424,11 +465,44 @@ class CopyTradingStatisticsService(
|
||||
|
||||
/**
|
||||
* 计算持仓价值
|
||||
* 使用链上实际持仓数量和当前市场价格计算
|
||||
* 按市场聚合,避免重复计算
|
||||
*/
|
||||
private fun calculatePositionValue(positionQuantity: String, currentPrices: Map<String, String>): String {
|
||||
// 这里简化处理,实际应该根据每个市场的持仓分别计算
|
||||
// 暂时返回0,因为需要知道每个市场的持仓数量
|
||||
return "0"
|
||||
private fun calculatePositionValue(
|
||||
buyOrders: List<CopyOrderTracking>,
|
||||
currentPrices: Map<String, String>,
|
||||
actualPositions: Map<String, BigDecimal>
|
||||
): String {
|
||||
var totalPositionValue = BigDecimal.ZERO
|
||||
|
||||
// 按市场聚合,获取所有不同的市场
|
||||
val marketKeys = buyOrders
|
||||
.filter { it.outcomeIndex != null }
|
||||
.map { "${it.marketId}:${it.outcomeIndex}" }
|
||||
.distinct()
|
||||
|
||||
for (key in marketKeys) {
|
||||
// 获取链上实际持仓数量(如果存在)
|
||||
val actualQty = actualPositions[key]
|
||||
|
||||
// 如果没有链上持仓,计算该市场的总持仓(所有订单的 remainingQuantity 之和)
|
||||
val totalQty = actualQty ?: buyOrders
|
||||
.filter { it.outcomeIndex != null && "${it.marketId}:${it.outcomeIndex}" == key }
|
||||
.sumOf { it.remainingQuantity.toSafeBigDecimal() }
|
||||
|
||||
// 如果持仓 <= 0,跳过
|
||||
if (totalQty.lte(BigDecimal.ZERO)) continue
|
||||
|
||||
// 获取当前市场价格
|
||||
val currentPrice = currentPrices[key]?.toSafeBigDecimal()
|
||||
?: continue // 如果没有当前价格,跳过
|
||||
|
||||
// 计算持仓价值:持仓数量 × 当前价格
|
||||
val positionValue = totalQty.multi(currentPrice)
|
||||
totalPositionValue = totalPositionValue.add(positionValue)
|
||||
}
|
||||
|
||||
return totalPositionValue.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
+1
-5
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.copytrading.templates
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.CopyTradingTemplate
|
||||
@@ -61,7 +61,6 @@ class CopyTradingTemplateService(
|
||||
supportSell = request.supportSell ?: true,
|
||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(),
|
||||
maxSpread = request.maxSpread?.toSafeBigDecimal(),
|
||||
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal(),
|
||||
minPrice = request.minPrice?.toSafeBigDecimal(),
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal()
|
||||
)
|
||||
@@ -120,7 +119,6 @@ class CopyTradingTemplateService(
|
||||
supportSell = request.supportSell ?: template.supportSell,
|
||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: template.minOrderDepth,
|
||||
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread,
|
||||
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: template.minOrderbookDepth,
|
||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
@@ -187,7 +185,6 @@ class CopyTradingTemplateService(
|
||||
supportSell = request.supportSell ?: sourceTemplate.supportSell,
|
||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: sourceTemplate.minOrderDepth,
|
||||
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: sourceTemplate.maxSpread,
|
||||
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: sourceTemplate.minOrderbookDepth,
|
||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: sourceTemplate.minPrice,
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: sourceTemplate.maxPrice
|
||||
)
|
||||
@@ -261,7 +258,6 @@ class CopyTradingTemplateService(
|
||||
supportSell = template.supportSell,
|
||||
minOrderDepth = template.minOrderDepth?.toPlainString(),
|
||||
maxSpread = template.maxSpread?.toPlainString(),
|
||||
minOrderbookDepth = template.minOrderbookDepth?.toPlainString(),
|
||||
minPrice = template.minPrice?.toPlainString(),
|
||||
maxPrice = template.maxPrice?.toPlainString(),
|
||||
createdAt = template.createdAt,
|
||||
+3
-1
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.system
|
||||
|
||||
import com.wrbug.polymarketbot.dto.ApiHealthCheckDto
|
||||
import com.wrbug.polymarketbot.dto.ApiHealthCheckResponse
|
||||
@@ -12,6 +12,8 @@ import org.springframework.beans.BeansException
|
||||
import org.springframework.context.ApplicationContext
|
||||
import org.springframework.context.ApplicationContextAware
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
|
||||
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingWebSocketService
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.system
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
+3
-1
@@ -1,8 +1,10 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.system
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.ProxyConfig
|
||||
import com.wrbug.polymarketbot.repository.ProxyConfigRepository
|
||||
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingWebSocketService
|
||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
|
||||
import com.wrbug.polymarketbot.util.ProxyConfigProvider
|
||||
import com.wrbug.polymarketbot.util.TrustAllHostnameVerifier
|
||||
import com.wrbug.polymarketbot.util.createSSLSocketFactory
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.system
|
||||
|
||||
import com.wrbug.polymarketbot.api.BuilderRelayerApi
|
||||
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||
@@ -802,10 +802,10 @@ class RelayClientService(
|
||||
safeTxs: List<SafeTransaction>
|
||||
): Result<String> {
|
||||
// 批量执行:将多个交易合并为一个 execTransaction 调用
|
||||
// 当前实现:委托给 BlockchainService
|
||||
// 当前实现:委托给 com.wrbug.polymarketbot.service.common.BlockchainService
|
||||
return Result.failure(
|
||||
UnsupportedOperationException(
|
||||
"批量 Gasless 执行暂未实现。请使用 BlockchainService.redeemPositions() 方法。"
|
||||
"批量 Gasless 执行暂未实现。请使用 com.wrbug.polymarketbot.service.common.BlockchainService.redeemPositions() 方法。"
|
||||
)
|
||||
)
|
||||
}
|
||||
+29
-1
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.system
|
||||
|
||||
import com.wrbug.polymarketbot.dto.SystemConfigDto
|
||||
import com.wrbug.polymarketbot.dto.SystemConfigUpdateRequest
|
||||
@@ -36,10 +36,38 @@ class SystemConfigService(
|
||||
val builderPassphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)
|
||||
val autoRedeem = isAutoRedeemEnabled()
|
||||
|
||||
// 获取完整的 API Key(用于前端展示)
|
||||
val builderApiKeyDisplay = builderApiKey?.let {
|
||||
try {
|
||||
cryptoUtils.decrypt(it)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val builderSecretDisplay = builderSecret?.let {
|
||||
try {
|
||||
cryptoUtils.decrypt(it)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val builderPassphraseDisplay = builderPassphrase?.let {
|
||||
try {
|
||||
cryptoUtils.decrypt(it)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
return SystemConfigDto(
|
||||
builderApiKeyConfigured = builderApiKey != null,
|
||||
builderSecretConfigured = builderSecret != null,
|
||||
builderPassphraseConfigured = builderPassphrase != null,
|
||||
builderApiKeyDisplay = builderApiKeyDisplay,
|
||||
builderSecretDisplay = builderSecretDisplay,
|
||||
builderPassphraseDisplay = builderPassphraseDisplay,
|
||||
autoRedeemEnabled = autoRedeem
|
||||
)
|
||||
}
|
||||
+8
-6
@@ -1,11 +1,13 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.system
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.dto.NotificationConfigData
|
||||
import com.wrbug.polymarketbot.dto.TelegramConfigData
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.wrbug.polymarketbot.util.DateUtils
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -65,7 +67,7 @@ class TelegramNotificationService(
|
||||
side: String,
|
||||
accountName: String? = null,
|
||||
walletAddress: String? = null,
|
||||
clobApi: com.wrbug.polymarketbot.api.PolymarketClobApi? = null,
|
||||
clobApi: PolymarketClobApi? = null,
|
||||
apiKey: String? = null,
|
||||
apiSecret: String? = null,
|
||||
apiPassphrase: String? = null,
|
||||
@@ -307,7 +309,7 @@ class TelegramNotificationService(
|
||||
}
|
||||
}
|
||||
|
||||
val time = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(java.util.Date())
|
||||
val time = DateUtils.formatDateTime()
|
||||
|
||||
// 转义 HTML 特殊字符
|
||||
val escapedMarketTitle = marketTitle.replace("<", "<").replace(">", ">")
|
||||
@@ -638,7 +640,7 @@ class TelegramNotificationService(
|
||||
""
|
||||
}
|
||||
|
||||
val time = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(java.util.Date())
|
||||
val time = DateUtils.formatDateTime()
|
||||
|
||||
// 转义 HTML 特殊字符
|
||||
val escapedMarketTitle = marketTitle.replace("<", "<").replace(">", ">")
|
||||
@@ -759,7 +761,7 @@ class TelegramNotificationService(
|
||||
}
|
||||
}
|
||||
|
||||
val time = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(java.util.Date())
|
||||
val time = DateUtils.formatDateTime()
|
||||
|
||||
// 错误信息已经是后端返回的 msg,不需要截断(但为了安全,限制长度)
|
||||
val shortErrorMessage = if (errorMessage.length > 500) {
|
||||
@@ -900,7 +902,7 @@ class TelegramNotificationService(
|
||||
}
|
||||
}
|
||||
|
||||
val time = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(java.util.Date())
|
||||
val time = DateUtils.formatDateTime()
|
||||
|
||||
// 转义 HTML 特殊字符
|
||||
val escapedAccountInfo = accountInfo.replace("<", "<").replace(">", ">")
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.system
|
||||
|
||||
import com.wrbug.polymarketbot.dto.UserCreateRequest
|
||||
import com.wrbug.polymarketbot.dto.UserDto
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.DateTimeParseException
|
||||
|
||||
@@ -15,6 +17,13 @@ object DateUtils {
|
||||
*/
|
||||
private val isoFormatter = DateTimeFormatter.ISO_DATE_TIME
|
||||
|
||||
/**
|
||||
* 使用系统时区的日期时间格式化器(用于显示)
|
||||
* 格式:yyyy-MM-dd HH:mm:ss
|
||||
*/
|
||||
private val displayFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
.withZone(ZoneId.systemDefault())
|
||||
|
||||
/**
|
||||
* 将 ISO 8601 格式的日期字符串转换为时间戳(毫秒)
|
||||
* @param dateString ISO 8601 格式的日期字符串,如 "2020-11-04T00:00:00Z"
|
||||
@@ -61,5 +70,19 @@ object DateUtils {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将时间戳(毫秒)格式化为可读的日期时间字符串(使用系统时区)
|
||||
* @param timestamp 时间戳(毫秒),如果为 null 则使用当前时间
|
||||
* @return 格式化的日期时间字符串,格式:yyyy-MM-dd HH:mm:ss
|
||||
*/
|
||||
fun formatDateTime(timestamp: Long? = null): String {
|
||||
val instant = if (timestamp != null) {
|
||||
Instant.ofEpochMilli(timestamp)
|
||||
} else {
|
||||
Instant.now()
|
||||
}
|
||||
return displayFormatter.format(instant)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ package com.wrbug.polymarketbot.websocket
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage
|
||||
import com.wrbug.polymarketbot.dto.WebSocketMessageType
|
||||
import com.wrbug.polymarketbot.service.WebSocketSubscriptionService
|
||||
import com.wrbug.polymarketbot.service.common.WebSocketSubscriptionService
|
||||
import jakarta.annotation.PostConstruct
|
||||
import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
+2
-1
@@ -54,13 +54,14 @@ trap cleanup SIGTERM SIGINT
|
||||
|
||||
# 启动后端服务(以 appuser 用户运行,后台运行)
|
||||
echo "启动后端服务..."
|
||||
# 自动使用系统时区
|
||||
java -jar /app/app.jar --spring.profiles.active=${SPRING_PROFILES_ACTIVE:-prod} &
|
||||
BACKEND_PID=$!
|
||||
|
||||
# 等待后端服务启动
|
||||
echo "等待后端服务启动..."
|
||||
for i in {1..60}; do
|
||||
if curl -f http://localhost:8000/api/health > /dev/null 2>&1; then
|
||||
if curl -f http://localhost:8000/api/system/health > /dev/null 2>&1; then
|
||||
echo "后端服务已启动"
|
||||
break
|
||||
fi
|
||||
|
||||
@@ -648,5 +648,5 @@ Vite uses `.env.production` file to inject environment variables during build. T
|
||||
|
||||
## Technical Support
|
||||
|
||||
If you have any questions, please submit an Issue to [GitHub](https://github.com/WrBug/PolyHermes) or contact [Twitter](https://x.com/quant_tr).
|
||||
If you have any questions, please submit an Issue to [GitHub](https://github.com/WrBug/PolyHermes) or contact [Twitter](https://x.com/polyhermes).
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
|
||||
@@ -252,6 +252,7 @@ function App() {
|
||||
<Route path="/copy-trading/add" element={<ProtectedRoute><CopyTradingAdd /></ProtectedRoute>} />
|
||||
<Route path="/copy-trading/edit/:id" element={<ProtectedRoute><CopyTradingEdit /></ProtectedRoute>} />
|
||||
<Route path="/copy-trading/statistics/:copyTradingId" element={<ProtectedRoute><CopyTradingStatistics /></ProtectedRoute>} />
|
||||
{/* 保留旧路由以保持向后兼容 */}
|
||||
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<ProtectedRoute><CopyTradingBuyOrders /></ProtectedRoute>} />
|
||||
<Route path="/copy-trading/orders/sell/:copyTradingId" element={<ProtectedRoute><CopyTradingSellOrders /></ProtectedRoute>} />
|
||||
<Route path="/copy-trading/orders/matched/:copyTradingId" element={<ProtectedRoute><CopyTradingMatchedOrders /></ProtectedRoute>} />
|
||||
|
||||
@@ -238,7 +238,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
<GithubOutlined />
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/quant_tr"
|
||||
href="https://x.com/polyhermes"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#fff', fontSize: '16px', display: 'flex', alignItems: 'center' }}
|
||||
@@ -362,7 +362,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
<GithubOutlined />
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/quant_tr"
|
||||
href="https://x.com/polyhermes"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#fff', fontSize: '18px', display: 'flex', alignItems: 'center' }}
|
||||
|
||||
@@ -597,10 +597,10 @@
|
||||
"priceToleranceTooltip": "Allowed adjustment range for copy price based on Leader price, used to adjust price within Leader price ± tolerance range to improve fill rate. For example: set to 5%, Leader price is 0.5, then copy price can be in 0.475-0.525 range.",
|
||||
"priceTolerancePlaceholder": "Default 5% (optional)",
|
||||
"minOrderDepth": "Min Order Depth (USDC)",
|
||||
"minOrderDepthTooltip": "Minimum order depth (USDC amount), NULL means this filter is not enabled. Ensures market has sufficient liquidity",
|
||||
"minOrderDepthTooltip": "Check total order amount (bids + asks) in orderbook to ensure sufficient liquidity. Leave empty to disable",
|
||||
"minOrderDepthPlaceholder": "For example: 100 (optional, leave empty to disable)",
|
||||
"maxSpread": "Max Spread (Absolute Price)",
|
||||
"maxSpreadTooltip": "Maximum spread (absolute price), NULL means this filter is not enabled. Avoid copying in markets with excessive spreads",
|
||||
"maxSpreadTooltip": "Maximum spread (absolute price). Avoid copying in markets with excessive spreads. Leave empty to disable",
|
||||
"maxSpreadPlaceholder": "For example: 0.05 (5 cents, optional, leave empty to disable)",
|
||||
"minOrderbookDepth": "Min Orderbook Depth (USDC)",
|
||||
"minOrderbookDepthTooltip": "Minimum orderbook depth (USDC amount), NULL means this filter is not enabled. Check depth of first N levels",
|
||||
@@ -654,10 +654,10 @@
|
||||
"priceToleranceTooltip": "Allowed adjustment range for copy price based on Leader price, used to adjust price within Leader price ± tolerance range to improve fill rate. For example: set to 5%, Leader price is 0.5, then copy price can be in 0.475-0.525 range.",
|
||||
"priceTolerancePlaceholder": "Default 5% (optional)",
|
||||
"minOrderDepth": "Min Order Depth (USDC)",
|
||||
"minOrderDepthTooltip": "Minimum order depth (USDC amount), NULL means this filter is not enabled. Ensures market has sufficient liquidity",
|
||||
"minOrderDepthTooltip": "Check total order amount (bids + asks) in orderbook to ensure sufficient liquidity. Leave empty to disable",
|
||||
"minOrderDepthPlaceholder": "For example: 100 (optional, leave empty to disable)",
|
||||
"maxSpread": "Max Spread (Absolute Price)",
|
||||
"maxSpreadTooltip": "Maximum spread (absolute price), NULL means this filter is not enabled. Avoid copying in markets with excessive spreads",
|
||||
"maxSpreadTooltip": "Maximum spread (absolute price). Avoid copying in markets with excessive spreads. Leave empty to disable",
|
||||
"maxSpreadPlaceholder": "For example: 0.05 (5 cents, optional, leave empty to disable)",
|
||||
"minOrderbookDepth": "Min Orderbook Depth (USDC)",
|
||||
"minOrderbookDepthTooltip": "Minimum orderbook depth (USDC amount), NULL means this filter is not enabled. Check depth of first N levels",
|
||||
@@ -719,10 +719,10 @@
|
||||
"delaySecondsPlaceholder": "Default 0 (copy immediately)",
|
||||
"filterConditions": "Filter Conditions (Optional)",
|
||||
"minOrderDepth": "Min Order Depth (USDC)",
|
||||
"minOrderDepthTooltip": "Minimum order depth (USDC amount), NULL means this filter is not enabled. Ensures market has sufficient liquidity",
|
||||
"minOrderDepthTooltip": "Check total order amount (bids + asks) in orderbook to ensure sufficient liquidity. Leave empty to disable",
|
||||
"minOrderDepthPlaceholder": "For example: 100 (optional, leave empty to disable)",
|
||||
"maxSpread": "Max Spread (Absolute Price)",
|
||||
"maxSpreadTooltip": "Maximum spread (absolute price), NULL means this filter is not enabled. Avoid copying in markets with excessive spreads",
|
||||
"maxSpreadTooltip": "Maximum spread (absolute price). Avoid copying in markets with excessive spreads. Leave empty to disable",
|
||||
"maxSpreadPlaceholder": "For example: 0.05 (5 cents, optional, leave empty to disable)",
|
||||
"minOrderbookDepth": "Min Orderbook Depth (USDC)",
|
||||
"minOrderbookDepthTooltip": "Minimum orderbook depth (USDC amount), NULL means this filter is not enabled. Check depth of first N levels",
|
||||
@@ -794,10 +794,10 @@
|
||||
"delaySecondsPlaceholder": "Default 0 (copy immediately)",
|
||||
"filterConditions": "Filter Conditions (Optional)",
|
||||
"minOrderDepth": "Min Order Depth (USDC)",
|
||||
"minOrderDepthTooltip": "Minimum order depth (USDC amount), NULL means this filter is not enabled",
|
||||
"minOrderDepthTooltip": "Check total order amount (bids + asks) in orderbook to ensure sufficient liquidity. Leave empty to disable",
|
||||
"minOrderDepthPlaceholder": "For example: 100 (optional, leave empty to disable)",
|
||||
"maxSpread": "Max Spread (Absolute Price)",
|
||||
"maxSpreadTooltip": "Maximum spread (absolute price), NULL means this filter is not enabled",
|
||||
"maxSpreadTooltip": "Maximum spread (absolute price). Avoid copying in markets with excessive spreads. Leave empty to disable",
|
||||
"maxSpreadPlaceholder": "For example: 0.05 (5 cents, optional, leave empty to disable)",
|
||||
"minOrderbookDepth": "Min Orderbook Depth (USDC)",
|
||||
"minOrderbookDepthTooltip": "Minimum orderbook depth (USDC amount), NULL means this filter is not enabled",
|
||||
@@ -856,6 +856,8 @@
|
||||
"copyTradingList": {
|
||||
"title": "Copy Trading Config Management",
|
||||
"addCopyTrading": "Add Copy Trading",
|
||||
"configName": "Config Name",
|
||||
"configNameNotProvided": "Not Provided",
|
||||
"wallet": "Wallet",
|
||||
"account": "Account",
|
||||
"template": "Template",
|
||||
@@ -868,10 +870,11 @@
|
||||
"totalPnl": "Total P&L",
|
||||
"statistics": "Statistics",
|
||||
"orders": "Orders",
|
||||
"matchedOrders": "Matched Orders",
|
||||
"ordersButton": "Orders",
|
||||
"viewStatistics": "View Statistics",
|
||||
"buyOrders": "Buy Orders",
|
||||
"sellOrders": "Sell Orders",
|
||||
"matchedOrders": "Matched Orders",
|
||||
"filteredOrders": "Filtered Orders",
|
||||
"filterWallet": "Filter Wallet",
|
||||
"filterTemplate": "Filter Template",
|
||||
@@ -966,5 +969,72 @@
|
||||
"collapse": "Collapse",
|
||||
"refresh": "Refresh",
|
||||
"list": "List"
|
||||
},
|
||||
"copyTradingOrders": {
|
||||
"title": "Order List",
|
||||
"buyOrders": "Buy Orders",
|
||||
"sellOrders": "Sell Orders",
|
||||
"matchedOrders": "Matched Orders",
|
||||
"filteredOrders": "Filtered Orders",
|
||||
"statistics": "Copy Trading Statistics",
|
||||
"orderId": "Order ID",
|
||||
"leaderTradeId": "Leader Trade ID",
|
||||
"market": "Market",
|
||||
"marketId": "Market ID",
|
||||
"side": "Side",
|
||||
"buyQuantity": "Buy Quantity",
|
||||
"buyPrice": "Buy Price",
|
||||
"buyAmount": "Buy Amount",
|
||||
"sellQuantity": "Sell Quantity",
|
||||
"sellPrice": "Sell Price",
|
||||
"sellAmount": "Sell Amount",
|
||||
"matchedQuantity": "Matched",
|
||||
"remainingQuantity": "Remaining",
|
||||
"sellStatus": "Sell Status",
|
||||
"status": "Status",
|
||||
"statusFilled": "Filled",
|
||||
"statusPartiallySold": "Partially Sold",
|
||||
"statusFullySold": "Fully Sold",
|
||||
"realizedPnl": "Realized PnL",
|
||||
"createdAt": "Created At",
|
||||
"matchedAt": "Matched At",
|
||||
"sellOrderId": "Sell Order ID",
|
||||
"buyOrderId": "Buy Order ID",
|
||||
"priceInfo": "Price Info",
|
||||
"buy": "Buy",
|
||||
"sell": "Sell",
|
||||
"buyInfo": "Buy Info",
|
||||
"sellInfo": "Sell Info",
|
||||
"matchInfo": "Match Info",
|
||||
"matched": "Matched",
|
||||
"remaining": "Remaining",
|
||||
"quantity": "Quantity",
|
||||
"price": "Price",
|
||||
"amount": "Amount",
|
||||
"filterMarketId": "Filter Market ID",
|
||||
"filterSide": "Filter Side",
|
||||
"filterStatus": "Filter Status",
|
||||
"filterSellOrderId": "Filter Sell Order ID",
|
||||
"filterBuyOrderId": "Filter Buy Order ID",
|
||||
"noBuyOrders": "No buy orders",
|
||||
"noSellOrders": "No sell orders",
|
||||
"noMatchedOrders": "No matched orders",
|
||||
"fetchBuyOrdersFailed": "Failed to fetch buy orders",
|
||||
"fetchSellOrdersFailed": "Failed to fetch sell orders",
|
||||
"fetchMatchedOrdersFailed": "Failed to fetch matched orders",
|
||||
"fetchStatisticsFailed": "Failed to fetch statistics",
|
||||
"noStatistics": "No statistics available",
|
||||
"totalBuyOrders": "Total Buy Orders",
|
||||
"totalSellOrders": "Total Sell Orders",
|
||||
"totalMatchedOrders": "Total Matched Orders",
|
||||
"totalBuyAmount": "Total Buy Amount",
|
||||
"totalSellAmount": "Total Sell Amount",
|
||||
"totalPnl": "Total PnL",
|
||||
"totalRealizedPnl": "Total Realized PnL",
|
||||
"totalUnrealizedPnl": "Total Unrealized PnL",
|
||||
"winRate": "Win Rate",
|
||||
"averagePnl": "Average PnL",
|
||||
"maxPnl": "Max PnL",
|
||||
"minPnl": "Min PnL"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,19 +117,40 @@
|
||||
},
|
||||
"accountImport": {
|
||||
"title": "导入账户",
|
||||
"back": "返回",
|
||||
"securityTip": "安全提示",
|
||||
"securityTipDesc": "私钥将存储在后端数据库中,请确保数据库访问安全。建议使用 HTTPS 连接。",
|
||||
"importMethod": "导入方式",
|
||||
"privateKey": "私钥",
|
||||
"mnemonic": "助记词",
|
||||
"privateKeyLabel": "私钥",
|
||||
"privateKeyPlaceholder": "请输入私钥(64位十六进制字符串,可选0x前缀)",
|
||||
"privateKeyRequired": "请输入私钥",
|
||||
"privateKeyPlaceholder": "请输入或粘贴私钥",
|
||||
"privateKeyHelp": "私钥将加密存储,仅用于签名交易",
|
||||
"privateKeyInvalid": "私钥格式不正确(应为64位十六进制字符串)",
|
||||
"walletAddress": "钱包地址",
|
||||
"walletAddressPlaceholder": "钱包地址(将从私钥自动推导)",
|
||||
"walletAddressRequired": "请输入钱包地址",
|
||||
"walletAddressInvalid": "钱包地址格式不正确",
|
||||
"walletAddressMismatch": "钱包地址与私钥不匹配",
|
||||
"mnemonicLabel": "助记词",
|
||||
"mnemonicPlaceholder": "请输入12或24个单词的助记词(用空格分隔)",
|
||||
"mnemonicRequired": "请输入助记词",
|
||||
"mnemonicInvalid": "助记词格式不正确(应为12或24个单词,用空格分隔)",
|
||||
"walletAddressMismatchMnemonic": "钱包地址与助记词不匹配",
|
||||
"accountName": "账户名称",
|
||||
"accountNameRequired": "请输入账户名称",
|
||||
"accountNamePlaceholder": "请输入账户名称",
|
||||
"accountNamePlaceholder": "可选,用于标识账户",
|
||||
"accountNameHelp": "用于标识账户,便于管理",
|
||||
"privateKeyHelp": "私钥将加密存储,仅用于签名交易",
|
||||
"submit": "导入",
|
||||
"invalidPrivateKey": "无效的私钥",
|
||||
"duplicateAccount": "账户已存在",
|
||||
"importAccount": "导入账户",
|
||||
"importSuccess": "导入账户成功",
|
||||
"importFailed": "导入账户失败",
|
||||
"invalidPrivateKey": "无效的私钥",
|
||||
"duplicateAccount": "账户已存在"
|
||||
"derivedAddress": "推导地址",
|
||||
"addressError": "无法从私钥推导地址",
|
||||
"addressErrorMnemonic": "无法从助记词推导地址"
|
||||
},
|
||||
"leader": {
|
||||
"title": "Leader 管理",
|
||||
@@ -512,10 +533,10 @@
|
||||
"priceToleranceTooltip": "允许跟单价格在 Leader 价格基础上的调整范围,用于在 Leader 价格 ± 容忍度范围内调整价格,提高成交率。例如:设置为 5%,Leader 价格为 0.5,则跟单价格可在 0.475-0.525 范围内。",
|
||||
"priceTolerancePlaceholder": "默认 5%(可选)",
|
||||
"minOrderDepth": "最小订单深度 (USDC)",
|
||||
"minOrderDepthTooltip": "最小订单深度(USDC金额),NULL表示不启用此过滤。确保市场有足够的流动性",
|
||||
"minOrderDepthTooltip": "检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤",
|
||||
"minOrderDepthPlaceholder": "例如:100(可选,不填写表示不启用)",
|
||||
"maxSpread": "最大价差(绝对价格)",
|
||||
"maxSpreadTooltip": "最大价差(绝对价格),NULL表示不启用此过滤。避免在价差过大的市场跟单",
|
||||
"maxSpreadTooltip": "最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤",
|
||||
"maxSpreadPlaceholder": "例如:0.05(5美分,可选,不填写表示不启用)",
|
||||
"minOrderbookDepth": "最小订单簿深度 (USDC)",
|
||||
"minOrderbookDepthTooltip": "最小订单簿深度(USDC金额),NULL表示不启用此过滤。检查前 N 档的深度",
|
||||
@@ -569,10 +590,10 @@
|
||||
"priceToleranceTooltip": "允许跟单价格在 Leader 价格基础上的调整范围,用于在 Leader 价格 ± 容忍度范围内调整价格,提高成交率。例如:设置为 5%,Leader 价格为 0.5,则跟单价格可在 0.475-0.525 范围内。",
|
||||
"priceTolerancePlaceholder": "默认 5%(可选)",
|
||||
"minOrderDepth": "最小订单深度 (USDC)",
|
||||
"minOrderDepthTooltip": "最小订单深度(USDC金额),NULL表示不启用此过滤。确保市场有足够的流动性",
|
||||
"minOrderDepthTooltip": "检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤",
|
||||
"minOrderDepthPlaceholder": "例如:100(可选,不填写表示不启用)",
|
||||
"maxSpread": "最大价差(绝对价格)",
|
||||
"maxSpreadTooltip": "最大价差(绝对价格),NULL表示不启用此过滤。避免在价差过大的市场跟单",
|
||||
"maxSpreadTooltip": "最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤",
|
||||
"maxSpreadPlaceholder": "例如:0.05(5美分,可选,不填写表示不启用)",
|
||||
"minOrderbookDepth": "最小订单簿深度 (USDC)",
|
||||
"minOrderbookDepthTooltip": "最小订单簿深度(USDC金额),NULL表示不启用此过滤。检查前 N 档的深度",
|
||||
@@ -646,10 +667,10 @@
|
||||
"delaySecondsPlaceholder": "默认 0(立即跟单)",
|
||||
"filterConditions": "过滤条件(可选)",
|
||||
"minOrderDepth": "最小订单深度 (USDC)",
|
||||
"minOrderDepthTooltip": "最小订单深度(USDC金额),NULL表示不启用此过滤。确保市场有足够的流动性",
|
||||
"minOrderDepthTooltip": "检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤",
|
||||
"minOrderDepthPlaceholder": "例如:100(可选,不填写表示不启用)",
|
||||
"maxSpread": "最大价差(绝对价格)",
|
||||
"maxSpreadTooltip": "最大价差(绝对价格),NULL表示不启用此过滤。避免在价差过大的市场跟单",
|
||||
"maxSpreadTooltip": "最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤",
|
||||
"maxSpreadPlaceholder": "例如:0.05(5美分,可选,不填写表示不启用)",
|
||||
"minOrderbookDepth": "最小订单簿深度 (USDC)",
|
||||
"minOrderbookDepthTooltip": "最小订单簿深度(USDC金额),NULL表示不启用此过滤。检查前 N 档的深度",
|
||||
@@ -721,10 +742,10 @@
|
||||
"delaySecondsPlaceholder": "默认 0(立即跟单)",
|
||||
"filterConditions": "过滤条件(可选)",
|
||||
"minOrderDepth": "最小订单深度 (USDC)",
|
||||
"minOrderDepthTooltip": "最小订单深度(USDC金额),NULL表示不启用此过滤",
|
||||
"minOrderDepthTooltip": "检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤",
|
||||
"minOrderDepthPlaceholder": "例如:100(可选,不填写表示不启用)",
|
||||
"maxSpread": "最大价差(绝对价格)",
|
||||
"maxSpreadTooltip": "最大价差(绝对价格),NULL表示不启用此过滤",
|
||||
"maxSpreadTooltip": "最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤",
|
||||
"maxSpreadPlaceholder": "例如:0.05(5美分,可选,不填写表示不启用)",
|
||||
"minOrderbookDepth": "最小订单簿深度 (USDC)",
|
||||
"minOrderbookDepthTooltip": "最小订单簿深度(USDC金额),NULL表示不启用此过滤",
|
||||
@@ -785,10 +806,11 @@
|
||||
"totalPnl": "总盈亏",
|
||||
"statistics": "统计",
|
||||
"orders": "订单",
|
||||
"matchedOrders": "已成交订单",
|
||||
"ordersButton": "订单",
|
||||
"viewStatistics": "查看统计",
|
||||
"buyOrders": "买入订单",
|
||||
"sellOrders": "卖出订单",
|
||||
"matchedOrders": "匹配关系",
|
||||
"filteredOrders": "已过滤订单",
|
||||
"filterWallet": "筛选钱包",
|
||||
"filterTemplate": "筛选模板",
|
||||
@@ -883,5 +905,72 @@
|
||||
"collapse": "收起",
|
||||
"refresh": "刷新",
|
||||
"list": "列表"
|
||||
},
|
||||
"copyTradingOrders": {
|
||||
"title": "订单列表",
|
||||
"buyOrders": "买入订单",
|
||||
"sellOrders": "卖出订单",
|
||||
"matchedOrders": "匹配关系",
|
||||
"filteredOrders": "已过滤订单",
|
||||
"statistics": "跟单关系统计",
|
||||
"orderId": "订单ID",
|
||||
"leaderTradeId": "Leader 交易ID",
|
||||
"market": "市场",
|
||||
"marketId": "市场ID",
|
||||
"side": "方向",
|
||||
"buyQuantity": "买入数量",
|
||||
"buyPrice": "买入价格",
|
||||
"buyAmount": "买入金额",
|
||||
"sellQuantity": "卖出数量",
|
||||
"sellPrice": "卖出价格",
|
||||
"sellAmount": "卖出金额",
|
||||
"matchedQuantity": "已匹配",
|
||||
"remainingQuantity": "剩余",
|
||||
"sellStatus": "卖出状态",
|
||||
"status": "状态",
|
||||
"statusFilled": "已完成",
|
||||
"statusPartiallySold": "部分成交",
|
||||
"statusFullySold": "全部成交",
|
||||
"realizedPnl": "已实现盈亏",
|
||||
"createdAt": "创建时间",
|
||||
"matchedAt": "匹配时间",
|
||||
"sellOrderId": "卖出订单ID",
|
||||
"buyOrderId": "买入订单ID",
|
||||
"priceInfo": "价格信息",
|
||||
"buy": "买入",
|
||||
"sell": "卖出",
|
||||
"buyInfo": "买入信息",
|
||||
"sellInfo": "卖出信息",
|
||||
"matchInfo": "匹配信息",
|
||||
"matched": "已匹配",
|
||||
"remaining": "剩余",
|
||||
"quantity": "数量",
|
||||
"price": "价格",
|
||||
"amount": "金额",
|
||||
"filterMarketId": "筛选市场ID",
|
||||
"filterSide": "筛选方向",
|
||||
"filterStatus": "筛选状态",
|
||||
"filterSellOrderId": "筛选卖出订单ID",
|
||||
"filterBuyOrderId": "筛选买入订单ID",
|
||||
"noBuyOrders": "暂无买入订单",
|
||||
"noSellOrders": "暂无卖出订单",
|
||||
"noMatchedOrders": "暂无匹配关系",
|
||||
"fetchBuyOrdersFailed": "获取买入订单列表失败",
|
||||
"fetchSellOrdersFailed": "获取卖出订单列表失败",
|
||||
"fetchMatchedOrdersFailed": "获取匹配关系列表失败",
|
||||
"fetchStatisticsFailed": "获取统计信息失败",
|
||||
"noStatistics": "暂无统计数据",
|
||||
"totalBuyOrders": "总买入订单数",
|
||||
"totalSellOrders": "总卖出订单数",
|
||||
"totalMatchedOrders": "总匹配订单数",
|
||||
"totalBuyAmount": "总买入金额",
|
||||
"totalSellAmount": "总卖出金额",
|
||||
"totalPnl": "总盈亏",
|
||||
"totalRealizedPnl": "总已实现盈亏",
|
||||
"totalUnrealizedPnl": "总未实现盈亏",
|
||||
"winRate": "胜率",
|
||||
"averagePnl": "平均盈亏",
|
||||
"maxPnl": "最大盈亏",
|
||||
"minPnl": "最小盈亏"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,10 +597,10 @@
|
||||
"priceToleranceTooltip": "允許跟單價格在 Leader 價格基礎上的調整範圍,用於在 Leader 價格 ± 容忍度範圍內調整價格,提高成交率。例如:設置為 5%,Leader 價格為 0.5,則跟單價格可在 0.475-0.525 範圍內。",
|
||||
"priceTolerancePlaceholder": "默認 5%(可選)",
|
||||
"minOrderDepth": "最小訂單深度 (USDC)",
|
||||
"minOrderDepthTooltip": "最小訂單深度(USDC金額),NULL表示不啟用此過濾。確保市場有足夠的流動性",
|
||||
"minOrderDepthTooltip": "檢查訂單簿的總訂單金額(買盤+賣盤),確保市場有足夠的流動性。不填寫則不啟用此過濾",
|
||||
"minOrderDepthPlaceholder": "例如:100(可選,不填寫表示不啟用)",
|
||||
"maxSpread": "最大價差(絕對價格)",
|
||||
"maxSpreadTooltip": "最大價差(絕對價格),NULL表示不啟用此過濾。避免在價差過大的市場跟單",
|
||||
"maxSpreadTooltip": "最大價差(絕對價格)。避免在價差過大的市場跟單。不填寫則不啟用此過濾",
|
||||
"maxSpreadPlaceholder": "例如:0.05(5美分,可選,不填寫表示不啟用)",
|
||||
"minOrderbookDepth": "最小訂單簿深度 (USDC)",
|
||||
"minOrderbookDepthTooltip": "最小訂單簿深度(USDC金額),NULL表示不啟用此過濾。檢查前 N 檔的深度",
|
||||
@@ -654,10 +654,10 @@
|
||||
"priceToleranceTooltip": "允許跟單價格在 Leader 價格基礎上的調整範圍,用於在 Leader 價格 ± 容忍度範圍內調整價格,提高成交率。例如:設置為 5%,Leader 價格為 0.5,則跟單價格可在 0.475-0.525 範圍內。",
|
||||
"priceTolerancePlaceholder": "默認 5%(可選)",
|
||||
"minOrderDepth": "最小訂單深度 (USDC)",
|
||||
"minOrderDepthTooltip": "最小訂單深度(USDC金額),NULL表示不啟用此過濾。確保市場有足夠的流動性",
|
||||
"minOrderDepthTooltip": "檢查訂單簿的總訂單金額(買盤+賣盤),確保市場有足夠的流動性。不填寫則不啟用此過濾",
|
||||
"minOrderDepthPlaceholder": "例如:100(可選,不填寫表示不啟用)",
|
||||
"maxSpread": "最大價差(絕對價格)",
|
||||
"maxSpreadTooltip": "最大價差(絕對價格),NULL表示不啟用此過濾。避免在價差過大的市場跟單",
|
||||
"maxSpreadTooltip": "最大價差(絕對價格)。避免在價差過大的市場跟單。不填寫則不啟用此過濾",
|
||||
"maxSpreadPlaceholder": "例如:0.05(5美分,可選,不填寫表示不啟用)",
|
||||
"minOrderbookDepth": "最小訂單簿深度 (USDC)",
|
||||
"minOrderbookDepthTooltip": "最小訂單簿深度(USDC金額),NULL表示不啟用此過濾。檢查前 N 檔的深度",
|
||||
@@ -719,10 +719,10 @@
|
||||
"delaySecondsPlaceholder": "默認 0(立即跟單)",
|
||||
"filterConditions": "過濾條件(可選)",
|
||||
"minOrderDepth": "最小訂單深度 (USDC)",
|
||||
"minOrderDepthTooltip": "最小訂單深度(USDC金額),NULL表示不啟用此過濾。確保市場有足夠的流動性",
|
||||
"minOrderDepthTooltip": "檢查訂單簿的總訂單金額(買盤+賣盤),確保市場有足夠的流動性。不填寫則不啟用此過濾",
|
||||
"minOrderDepthPlaceholder": "例如:100(可選,不填寫表示不啟用)",
|
||||
"maxSpread": "最大價差(絕對價格)",
|
||||
"maxSpreadTooltip": "最大價差(絕對價格),NULL表示不啟用此過濾。避免在價差過大的市場跟單",
|
||||
"maxSpreadTooltip": "最大價差(絕對價格)。避免在價差過大的市場跟單。不填寫則不啟用此過濾",
|
||||
"maxSpreadPlaceholder": "例如:0.05(5美分,可選,不填寫表示不啟用)",
|
||||
"minOrderbookDepth": "最小訂單簿深度 (USDC)",
|
||||
"minOrderbookDepthTooltip": "最小訂單簿深度(USDC金額),NULL表示不啟用此過濾。檢查前 N 檔的深度",
|
||||
@@ -794,10 +794,10 @@
|
||||
"delaySecondsPlaceholder": "默認 0(立即跟單)",
|
||||
"filterConditions": "過濾條件(可選)",
|
||||
"minOrderDepth": "最小訂單深度 (USDC)",
|
||||
"minOrderDepthTooltip": "最小訂單深度(USDC金額),NULL表示不啟用此過濾",
|
||||
"minOrderDepthTooltip": "檢查訂單簿的總訂單金額(買盤+賣盤),確保市場有足夠的流動性。不填寫則不啟用此過濾",
|
||||
"minOrderDepthPlaceholder": "例如:100(可選,不填寫表示不啟用)",
|
||||
"maxSpread": "最大價差(絕對價格)",
|
||||
"maxSpreadTooltip": "最大價差(絕對價格),NULL表示不啟用此過濾",
|
||||
"maxSpreadTooltip": "最大價差(絕對價格)。避免在價差過大的市場跟單。不填寫則不啟用此過濾",
|
||||
"maxSpreadPlaceholder": "例如:0.05(5美分,可選,不填寫表示不啟用)",
|
||||
"minOrderbookDepth": "最小訂單簿深度 (USDC)",
|
||||
"minOrderbookDepthTooltip": "最小訂單簿深度(USDC金額),NULL表示不啟用此過濾",
|
||||
@@ -856,6 +856,8 @@
|
||||
"copyTradingList": {
|
||||
"title": "跟單配置管理",
|
||||
"addCopyTrading": "新增跟單",
|
||||
"configName": "配置名",
|
||||
"configNameNotProvided": "未提供",
|
||||
"wallet": "錢包",
|
||||
"account": "賬戶",
|
||||
"template": "模板",
|
||||
@@ -868,10 +870,11 @@
|
||||
"totalPnl": "總盈虧",
|
||||
"statistics": "統計",
|
||||
"orders": "訂單",
|
||||
"matchedOrders": "已成交訂單",
|
||||
"ordersButton": "訂單",
|
||||
"viewStatistics": "查看統計",
|
||||
"buyOrders": "買入訂單",
|
||||
"sellOrders": "賣出訂單",
|
||||
"matchedOrders": "匹配關係",
|
||||
"filteredOrders": "被過濾訂單",
|
||||
"filterWallet": "篩選錢包",
|
||||
"filterTemplate": "篩選模板",
|
||||
@@ -966,5 +969,72 @@
|
||||
"collapse": "收起",
|
||||
"refresh": "刷新",
|
||||
"list": "列表"
|
||||
},
|
||||
"copyTradingOrders": {
|
||||
"title": "訂單列表",
|
||||
"buyOrders": "買入訂單",
|
||||
"sellOrders": "賣出訂單",
|
||||
"matchedOrders": "匹配關係",
|
||||
"filteredOrders": "已過濾訂單",
|
||||
"statistics": "跟單關系統計",
|
||||
"orderId": "訂單ID",
|
||||
"leaderTradeId": "Leader 交易ID",
|
||||
"market": "市場",
|
||||
"marketId": "市場ID",
|
||||
"side": "方向",
|
||||
"buyQuantity": "買入數量",
|
||||
"buyPrice": "買入價格",
|
||||
"buyAmount": "買入金額",
|
||||
"sellQuantity": "賣出數量",
|
||||
"sellPrice": "賣出價格",
|
||||
"sellAmount": "賣出金額",
|
||||
"matchedQuantity": "已匹配",
|
||||
"remainingQuantity": "剩餘",
|
||||
"sellStatus": "賣出狀態",
|
||||
"status": "狀態",
|
||||
"statusFilled": "已完成",
|
||||
"statusPartiallySold": "部分成交",
|
||||
"statusFullySold": "全部成交",
|
||||
"realizedPnl": "已實現盈虧",
|
||||
"createdAt": "創建時間",
|
||||
"matchedAt": "匹配時間",
|
||||
"sellOrderId": "賣出訂單ID",
|
||||
"buyOrderId": "買入訂單ID",
|
||||
"priceInfo": "價格信息",
|
||||
"buy": "買入",
|
||||
"sell": "賣出",
|
||||
"buyInfo": "買入信息",
|
||||
"sellInfo": "賣出信息",
|
||||
"matchInfo": "匹配信息",
|
||||
"matched": "已匹配",
|
||||
"remaining": "剩餘",
|
||||
"quantity": "數量",
|
||||
"price": "價格",
|
||||
"amount": "金額",
|
||||
"filterMarketId": "篩選市場ID",
|
||||
"filterSide": "篩選方向",
|
||||
"filterStatus": "篩選狀態",
|
||||
"filterSellOrderId": "篩選賣出訂單ID",
|
||||
"filterBuyOrderId": "篩選買入訂單ID",
|
||||
"noBuyOrders": "暫無買入訂單",
|
||||
"noSellOrders": "暫無賣出訂單",
|
||||
"noMatchedOrders": "暫無匹配關係",
|
||||
"fetchBuyOrdersFailed": "獲取買入訂單列表失敗",
|
||||
"fetchSellOrdersFailed": "獲取賣出訂單列表失敗",
|
||||
"fetchMatchedOrdersFailed": "獲取匹配關係列表失敗",
|
||||
"fetchStatisticsFailed": "獲取統計信息失敗",
|
||||
"noStatistics": "暫無統計數據",
|
||||
"totalBuyOrders": "總買入訂單數",
|
||||
"totalSellOrders": "總賣出訂單數",
|
||||
"totalMatchedOrders": "總匹配訂單數",
|
||||
"totalBuyAmount": "總買入金額",
|
||||
"totalSellAmount": "總賣出金額",
|
||||
"totalPnl": "總盈虧",
|
||||
"totalRealizedPnl": "總已實現盈虧",
|
||||
"totalUnrealizedPnl": "總未實現盈虧",
|
||||
"winRate": "勝率",
|
||||
"averagePnl": "平均盈虧",
|
||||
"maxPnl": "最大盈虧",
|
||||
"minPnl": "最小盈虧"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,11 +74,54 @@ const AccountList: React.FC = () => {
|
||||
}
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
message.success(t('accountList.copySuccess'))
|
||||
}).catch(() => {
|
||||
message.error(t('accountList.copyFailed'))
|
||||
})
|
||||
if (!text) {
|
||||
message.warning(t('accountList.copyFailed') || '复制失败:地址为空')
|
||||
return
|
||||
}
|
||||
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
message.success({
|
||||
content: t('accountList.copySuccess') || '已复制到剪贴板',
|
||||
duration: 2
|
||||
})
|
||||
}).catch((err) => {
|
||||
console.error('复制失败:', err)
|
||||
// 降级方案:使用传统方法
|
||||
fallbackCopyTextToClipboard(text)
|
||||
})
|
||||
} else {
|
||||
// 降级方案:使用传统方法
|
||||
fallbackCopyTextToClipboard(text)
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackCopyTextToClipboard = (text: string) => {
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-999999px'
|
||||
textArea.style.top = '-999999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
|
||||
try {
|
||||
const successful = document.execCommand('copy')
|
||||
if (successful) {
|
||||
message.success({
|
||||
content: t('accountList.copySuccess') || '已复制到剪贴板',
|
||||
duration: 2
|
||||
})
|
||||
} else {
|
||||
message.error(t('accountList.copyFailed') || '复制失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err)
|
||||
message.error(t('accountList.copyFailed') || '复制失败')
|
||||
} finally {
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShowDetail = async (account: Account) => {
|
||||
@@ -210,46 +253,45 @@ const AccountList: React.FC = () => {
|
||||
title: t('accountList.walletAddress'),
|
||||
dataIndex: 'walletAddress',
|
||||
key: 'walletAddress',
|
||||
render: (text: string) => (
|
||||
<Space>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '12px' }}>{text}</span>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => handleCopy(text)}
|
||||
title={t('accountList.walletAddress')}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
render: (text: string) => {
|
||||
const formatted = text ? `${text.slice(0, 6)}...${text.slice(-4)}` : '-'
|
||||
return (
|
||||
<Space>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '12px' }}>{formatted}</span>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCopy(text)
|
||||
}}
|
||||
title={t('accountList.walletAddress')}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('accountList.proxyAddress'),
|
||||
dataIndex: 'proxyAddress',
|
||||
key: 'proxyAddress',
|
||||
render: (address: string) => (
|
||||
<Space>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '12px' }}>{address}</span>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => handleCopy(address)}
|
||||
title={t('accountList.proxyAddress')}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('accountList.apiCredentials'),
|
||||
key: 'apiCredentials',
|
||||
render: (_: any, record: Account) => {
|
||||
const allConfigured = record.apiKeyConfigured && record.apiSecretConfigured && record.apiPassphraseConfigured
|
||||
const partialConfigured = record.apiKeyConfigured || record.apiSecretConfigured || record.apiPassphraseConfigured
|
||||
render: (address: string) => {
|
||||
const formatted = address ? `${address.slice(0, 6)}...${address.slice(-4)}` : '-'
|
||||
return (
|
||||
<Tag color={allConfigured ? 'success' : partialConfigured ? 'warning' : 'default'}>
|
||||
{allConfigured ? t('accountList.fullConfig') : partialConfigured ? t('accountList.partialConfig') : t('accountList.notConfigured')}
|
||||
</Tag>
|
||||
<Space>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '12px' }}>{formatted}</span>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCopy(address)
|
||||
}}
|
||||
title={t('accountList.proxyAddress')}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -266,17 +308,6 @@ const AccountList: React.FC = () => {
|
||||
return balance && balance !== '-' && typeof balance === 'string' ? `${formatUSDC(balance)} USDC` : '-'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('accountList.activeOrders'),
|
||||
dataIndex: 'activeOrders',
|
||||
key: 'activeOrders',
|
||||
render: (_: any, record: Account) => {
|
||||
if (record.activeOrders !== undefined && record.activeOrders !== null) {
|
||||
return <Tag color={record.activeOrders > 0 ? 'orange' : 'default'}>{record.activeOrders}</Tag>
|
||||
}
|
||||
return <span style={{ color: '#999' }}>-</span>
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('accountList.action'),
|
||||
key: 'action',
|
||||
@@ -323,9 +354,6 @@ const AccountList: React.FC = () => {
|
||||
title: t('accountList.accountName'),
|
||||
key: 'info',
|
||||
render: (_: any, record: Account) => {
|
||||
const allConfigured = record.apiKeyConfigured && record.apiSecretConfigured && record.apiPassphraseConfigured
|
||||
const partialConfigured = record.apiKeyConfigured || record.apiSecretConfigured || record.apiPassphraseConfigured
|
||||
|
||||
return (
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
<div style={{
|
||||
@@ -344,31 +372,32 @@ const AccountList: React.FC = () => {
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
<div style={{ marginBottom: '4px' }}>
|
||||
<strong>{t('accountList.walletAddress')}:</strong> {record.walletAddress}
|
||||
<strong>{t('accountList.walletAddress')}:</strong> {record.walletAddress ? `${record.walletAddress.slice(0, 6)}...${record.walletAddress.slice(-4)}` : '-'}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => handleCopy(record.walletAddress)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCopy(record.walletAddress)
|
||||
}}
|
||||
style={{ marginLeft: '4px', padding: '0 4px' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{t('accountList.proxyAddress')}:</strong> {record.proxyAddress}
|
||||
<strong>{t('accountList.proxyAddress')}:</strong> {record.proxyAddress ? `${record.proxyAddress.slice(0, 6)}...${record.proxyAddress.slice(-4)}` : '-'}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => handleCopy(record.proxyAddress)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCopy(record.proxyAddress)
|
||||
}}
|
||||
style={{ marginLeft: '4px', padding: '0 4px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: '8px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
<Tag color={allConfigured ? 'success' : partialConfigured ? 'warning' : 'default'} style={{ margin: 0 }}>
|
||||
{allConfigured ? t('accountList.fullConfig') : partialConfigured ? t('accountList.partialConfig') : t('accountList.notConfigured')}
|
||||
</Tag>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
@@ -391,18 +420,6 @@ const AccountList: React.FC = () => {
|
||||
{t('accountList.available')}: {formatUSDC(balanceMap[record.id].available)} USDC | {t('accountList.position')}: {formatUSDC(balanceMap[record.id].position)} USDC
|
||||
</div>
|
||||
)}
|
||||
{(record.activeOrders !== undefined && record.activeOrders !== null) && (
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
marginTop: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
{t('accountList.activeOrders')}: <Tag color={record.activeOrders > 0 ? 'orange' : 'default'} style={{ margin: 0 }}>{record.activeOrders}</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -598,7 +615,10 @@ const AccountList: React.FC = () => {
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => handleCopy(detailAccount.walletAddress || '')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCopy(detailAccount.walletAddress || '')
|
||||
}}
|
||||
title={t('accountList.walletAddress')}
|
||||
/>
|
||||
</Space>
|
||||
@@ -618,7 +638,10 @@ const AccountList: React.FC = () => {
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => handleCopy(detailAccount.proxyAddress || '')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCopy(detailAccount.proxyAddress || '')
|
||||
}}
|
||||
title={t('accountList.proxyAddress')}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
@@ -84,7 +84,6 @@ const CopyTradingAdd: React.FC = () => {
|
||||
supportSell: template.supportSell,
|
||||
minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined,
|
||||
maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined,
|
||||
minOrderbookDepth: template.minOrderbookDepth ? parseFloat(template.minOrderbookDepth) : undefined,
|
||||
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
|
||||
maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined
|
||||
})
|
||||
@@ -133,7 +132,6 @@ const CopyTradingAdd: React.FC = () => {
|
||||
supportSell: values.supportSell !== false,
|
||||
minOrderDepth: values.minOrderDepth?.toString(),
|
||||
maxSpread: values.maxSpread?.toString(),
|
||||
minOrderbookDepth: values.minOrderbookDepth?.toString(),
|
||||
minPrice: values.minPrice?.toString(),
|
||||
maxPrice: values.maxPrice?.toString(),
|
||||
configName: values.configName?.trim(),
|
||||
@@ -412,7 +410,7 @@ const CopyTradingAdd: React.FC = () => {
|
||||
<Form.Item
|
||||
label={t('copyTradingAdd.minOrderDepth') || '最小订单深度 (USDC)'}
|
||||
name="minOrderDepth"
|
||||
tooltip={t('copyTradingAdd.minOrderDepthTooltip') || '最小订单深度(USDC金额),NULL表示不启用此过滤。确保市场有足够的流动性'}
|
||||
tooltip={t('copyTradingAdd.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
@@ -426,7 +424,7 @@ const CopyTradingAdd: React.FC = () => {
|
||||
<Form.Item
|
||||
label={t('copyTradingAdd.maxSpread') || '最大价差(绝对价格)'}
|
||||
name="maxSpread"
|
||||
tooltip={t('copyTradingAdd.maxSpreadTooltip') || '最大价差(绝对价格),NULL表示不启用此过滤。避免在价差过大的市场跟单'}
|
||||
tooltip={t('copyTradingAdd.maxSpreadTooltip') || '最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤'}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
@@ -437,20 +435,6 @@ const CopyTradingAdd: React.FC = () => {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingAdd.minOrderbookDepth') || '最小订单簿深度 (USDC)'}
|
||||
name="minOrderbookDepth"
|
||||
tooltip={t('copyTradingAdd.minOrderbookDepthTooltip') || '最小订单簿深度(USDC金额),NULL表示不启用此过滤。检查前 N 档的深度'}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={0.0001}
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingAdd.minOrderbookDepthPlaceholder') || '例如:50(可选,不填写表示不启用)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Divider>{t('copyTradingAdd.priceRangeFilter') || '价格区间过滤'}</Divider>
|
||||
|
||||
<Form.Item
|
||||
|
||||
@@ -56,7 +56,6 @@ const CopyTradingEdit: React.FC = () => {
|
||||
supportSell: found.supportSell,
|
||||
minOrderDepth: found.minOrderDepth ? parseFloat(found.minOrderDepth) : undefined,
|
||||
maxSpread: found.maxSpread ? parseFloat(found.maxSpread) : undefined,
|
||||
minOrderbookDepth: found.minOrderbookDepth ? parseFloat(found.minOrderbookDepth) : undefined,
|
||||
minPrice: found.minPrice ? parseFloat(found.minPrice) : undefined,
|
||||
maxPrice: found.maxPrice ? parseFloat(found.maxPrice) : undefined,
|
||||
configName: found.configName || '',
|
||||
@@ -122,7 +121,6 @@ const CopyTradingEdit: React.FC = () => {
|
||||
supportSell: values.supportSell,
|
||||
minOrderDepth: values.minOrderDepth?.toString(),
|
||||
maxSpread: values.maxSpread?.toString(),
|
||||
minOrderbookDepth: values.minOrderbookDepth?.toString(),
|
||||
minPrice: values.minPrice?.toString(),
|
||||
maxPrice: values.maxPrice?.toString(),
|
||||
configName: values.configName?.trim() || undefined,
|
||||
@@ -381,7 +379,7 @@ const CopyTradingEdit: React.FC = () => {
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.minOrderDepth') || '最小订单深度 (USDC)'}
|
||||
name="minOrderDepth"
|
||||
tooltip={t('copyTradingEdit.minOrderDepthTooltip') || '最小订单深度(USDC金额),NULL表示不启用此过滤'}
|
||||
tooltip={t('copyTradingEdit.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
@@ -395,7 +393,7 @@ const CopyTradingEdit: React.FC = () => {
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.maxSpread') || '最大价差(绝对价格)'}
|
||||
name="maxSpread"
|
||||
tooltip={t('copyTradingEdit.maxSpreadTooltip') || '最大价差(绝对价格),NULL表示不启用此过滤'}
|
||||
tooltip={t('copyTradingEdit.maxSpreadTooltip') || '最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤'}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
@@ -406,20 +404,6 @@ const CopyTradingEdit: React.FC = () => {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.minOrderbookDepth') || '最小订单簿深度 (USDC)'}
|
||||
name="minOrderbookDepth"
|
||||
tooltip={t('copyTradingEdit.minOrderbookDepthTooltip') || '最小订单簿深度(USDC金额),NULL表示不启用此过滤'}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={0.0001}
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.minOrderbookDepthPlaceholder') || '例如:50(可选,不填写表示不启用)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Divider>{t('copyTradingEdit.priceRangeFilter') || '价格区间过滤'}</Divider>
|
||||
|
||||
<Form.Item
|
||||
|
||||
@@ -9,6 +9,10 @@ import { useAccountStore } from '../store/accountStore'
|
||||
import type { CopyTrading, Leader, CopyTradingStatistics } from '../types'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { formatUSDC } from '../utils'
|
||||
import CopyTradingOrdersModal from './CopyTradingOrders/index'
|
||||
import StatisticsModal from './CopyTradingOrders/StatisticsModal'
|
||||
import FilteredOrdersModal from './CopyTradingOrders/FilteredOrdersModal'
|
||||
import EditModal from './CopyTradingOrders/EditModal'
|
||||
|
||||
const { Option } = Select
|
||||
|
||||
@@ -28,6 +32,17 @@ const CopyTradingList: React.FC = () => {
|
||||
enabled?: boolean
|
||||
}>({})
|
||||
|
||||
// Modal 状态
|
||||
const [ordersModalOpen, setOrdersModalOpen] = useState(false)
|
||||
const [ordersModalCopyTradingId, setOrdersModalCopyTradingId] = useState<string>('')
|
||||
const [ordersModalTab, setOrdersModalTab] = useState<'buy' | 'sell' | 'matched'>('buy')
|
||||
const [statisticsModalOpen, setStatisticsModalOpen] = useState(false)
|
||||
const [statisticsModalCopyTradingId, setStatisticsModalCopyTradingId] = useState<string>('')
|
||||
const [filteredOrdersModalOpen, setFilteredOrdersModalOpen] = useState(false)
|
||||
const [filteredOrdersModalCopyTradingId, setFilteredOrdersModalCopyTradingId] = useState<string>('')
|
||||
const [editModalOpen, setEditModalOpen] = useState(false)
|
||||
const [editModalCopyTradingId, setEditModalCopyTradingId] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts()
|
||||
fetchLeaders()
|
||||
@@ -265,47 +280,24 @@ const CopyTradingList: React.FC = () => {
|
||||
fixed: 'right' as const,
|
||||
render: (_: any, record: CopyTrading) => {
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'buyOrders',
|
||||
label: t('copyTradingList.buyOrders') || '买入订单',
|
||||
icon: <UnorderedListOutlined />,
|
||||
onClick: () => navigate(`/copy-trading/orders/buy/${record.id}`)
|
||||
},
|
||||
{
|
||||
key: 'sellOrders',
|
||||
label: t('copyTradingList.sellOrders') || '卖出订单',
|
||||
icon: <UnorderedListOutlined />,
|
||||
onClick: () => navigate(`/copy-trading/orders/sell/${record.id}`)
|
||||
},
|
||||
{
|
||||
key: 'matchedOrders',
|
||||
label: t('copyTradingList.matchedOrders') || '匹配关系',
|
||||
label: t('copyTradingList.matchedOrders') || '已成交订单',
|
||||
icon: <UnorderedListOutlined />,
|
||||
onClick: () => navigate(`/copy-trading/orders/matched/${record.id}`)
|
||||
onClick: () => {
|
||||
setOrdersModalCopyTradingId(record.id.toString())
|
||||
setOrdersModalTab('buy')
|
||||
setOrdersModalOpen(true)
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'filteredOrders',
|
||||
label: t('copyTradingList.filteredOrders') || '已过滤订单',
|
||||
icon: <UnorderedListOutlined />,
|
||||
onClick: () => navigate(`/copy-trading/filtered-orders/${record.id}`)
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: (
|
||||
<Popconfirm
|
||||
title={t('copyTradingList.deleteConfirm') || '确定要删除这个跟单关系吗?'}
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText={t('common.confirm') || '确定'}
|
||||
cancelText={t('common.cancel') || '取消'}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
>
|
||||
<span style={{ color: '#ff4d4f' }}>{t('common.delete') || '删除'}</span>
|
||||
</Popconfirm>
|
||||
),
|
||||
danger: true
|
||||
onClick: () => {
|
||||
setFilteredOrdersModalCopyTradingId(record.id.toString())
|
||||
setFilteredOrdersModalOpen(true)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -317,7 +309,10 @@ const CopyTradingList: React.FC = () => {
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => navigate(`/copy-trading/edit/${record.id}`)}
|
||||
onClick={() => {
|
||||
setEditModalCopyTradingId(record.id.toString())
|
||||
setEditModalOpen(true)
|
||||
}}
|
||||
>
|
||||
{t('common.edit') || '编辑'}
|
||||
</Button>
|
||||
@@ -325,7 +320,10 @@ const CopyTradingList: React.FC = () => {
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<BarChartOutlined />}
|
||||
onClick={() => navigate(`/copy-trading/statistics/${record.id}`)}
|
||||
onClick={() => {
|
||||
setStatisticsModalCopyTradingId(record.id.toString())
|
||||
setStatisticsModalOpen(true)
|
||||
}}
|
||||
>
|
||||
{t('copyTradingList.statistics') || '统计'}
|
||||
</Button>
|
||||
@@ -554,7 +552,10 @@ const CopyTradingList: React.FC = () => {
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => navigate(`/copy-trading/edit/${record.id}`)}
|
||||
onClick={() => {
|
||||
setEditModalCopyTradingId(record.id.toString())
|
||||
setEditModalOpen(true)
|
||||
}}
|
||||
style={{ flex: 1, minWidth: '80px' }}
|
||||
>
|
||||
{t('common.edit') || '编辑'}
|
||||
@@ -562,7 +563,10 @@ const CopyTradingList: React.FC = () => {
|
||||
<Button
|
||||
size="small"
|
||||
icon={<BarChartOutlined />}
|
||||
onClick={() => navigate(`/copy-trading/statistics/${record.id}`)}
|
||||
onClick={() => {
|
||||
setStatisticsModalCopyTradingId(record.id.toString())
|
||||
setStatisticsModalOpen(true)
|
||||
}}
|
||||
style={{ flex: 1, minWidth: '80px' }}
|
||||
>
|
||||
{t('copyTradingList.statistics') || '统计'}
|
||||
@@ -570,29 +574,24 @@ const CopyTradingList: React.FC = () => {
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'buyOrders',
|
||||
label: t('copyTradingList.buyOrders') || '买入订单',
|
||||
icon: <UnorderedListOutlined />,
|
||||
onClick: () => navigate(`/copy-trading/orders/buy/${record.id}`)
|
||||
},
|
||||
{
|
||||
key: 'sellOrders',
|
||||
label: t('copyTradingList.sellOrders') || '卖出订单',
|
||||
icon: <UnorderedListOutlined />,
|
||||
onClick: () => navigate(`/copy-trading/orders/sell/${record.id}`)
|
||||
},
|
||||
{
|
||||
key: 'matchedOrders',
|
||||
label: t('copyTradingList.matchedOrders') || '匹配关系',
|
||||
label: t('copyTradingList.matchedOrders') || '已成交订单',
|
||||
icon: <UnorderedListOutlined />,
|
||||
onClick: () => navigate(`/copy-trading/orders/matched/${record.id}`)
|
||||
onClick: () => {
|
||||
setOrdersModalCopyTradingId(record.id.toString())
|
||||
setOrdersModalTab('buy')
|
||||
setOrdersModalOpen(true)
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'filteredOrders',
|
||||
label: t('copyTradingList.filteredOrders') || '已过滤订单',
|
||||
icon: <UnorderedListOutlined />,
|
||||
onClick: () => navigate(`/copy-trading/filtered-orders/${record.id}`)
|
||||
onClick: () => {
|
||||
setFilteredOrdersModalCopyTradingId(record.id.toString())
|
||||
setFilteredOrdersModalOpen(true)
|
||||
}
|
||||
}
|
||||
]
|
||||
}}
|
||||
@@ -643,6 +642,32 @@ const CopyTradingList: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Modal 组件 */}
|
||||
<CopyTradingOrdersModal
|
||||
open={ordersModalOpen}
|
||||
onClose={() => setOrdersModalOpen(false)}
|
||||
copyTradingId={ordersModalCopyTradingId}
|
||||
defaultTab={ordersModalTab}
|
||||
/>
|
||||
<StatisticsModal
|
||||
open={statisticsModalOpen}
|
||||
onClose={() => setStatisticsModalOpen(false)}
|
||||
copyTradingId={statisticsModalCopyTradingId}
|
||||
/>
|
||||
<FilteredOrdersModal
|
||||
open={filteredOrdersModalOpen}
|
||||
onClose={() => setFilteredOrdersModalOpen(false)}
|
||||
copyTradingId={filteredOrdersModalCopyTradingId}
|
||||
/>
|
||||
<EditModal
|
||||
open={editModalOpen}
|
||||
onClose={() => setEditModalOpen(false)}
|
||||
copyTradingId={editModalCopyTradingId}
|
||||
onSuccess={() => {
|
||||
fetchCopyTradings()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Table, Tag, Select, Input, Button, Card, Divider, Spin } from 'antd'
|
||||
import { apiService } from '../../services/api'
|
||||
import { formatUSDC } from '../../utils'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BuyOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types'
|
||||
|
||||
const { Option } = Select
|
||||
|
||||
interface BuyOrdersTabProps {
|
||||
copyTradingId: string
|
||||
}
|
||||
|
||||
const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [orders, setOrders] = useState<BuyOrderInfo[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [limit, setLimit] = useState(20)
|
||||
const [filters, setFilters] = useState<{
|
||||
marketId?: string
|
||||
side?: string
|
||||
status?: string
|
||||
}>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (copyTradingId) {
|
||||
fetchOrders()
|
||||
}
|
||||
}, [copyTradingId, page, limit, filters])
|
||||
|
||||
const fetchOrders = async () => {
|
||||
if (!copyTradingId) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const request: OrderTrackingRequest = {
|
||||
copyTradingId: parseInt(copyTradingId),
|
||||
type: 'buy',
|
||||
page,
|
||||
limit,
|
||||
...filters
|
||||
}
|
||||
|
||||
const response = await apiService.orderTracking.list(request)
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
const data = response.data.data as OrderTrackingListResponse
|
||||
setOrders((data.list || []) as BuyOrderInfo[])
|
||||
setTotal(data.total || 0)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取买入订单列表失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
filled: { color: 'processing', text: t('copyTradingOrders.statusFilled') || '已完成' },
|
||||
partially_matched: { color: 'warning', text: t('copyTradingOrders.statusPartiallySold') || '部分成交' },
|
||||
fully_matched: { color: 'success', text: t('copyTradingOrders.statusFullySold') || '全部成交' }
|
||||
}
|
||||
const config = statusMap[status] || { color: 'default', text: status }
|
||||
return <Tag color={config.color}>{config.text}</Tag>
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('copyTradingOrders.orderId') || '订单ID',
|
||||
dataIndex: 'orderId',
|
||||
key: 'orderId',
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('copyTradingOrders.leaderTradeId') || 'Leader 交易ID',
|
||||
dataIndex: 'leaderTradeId',
|
||||
key: 'leaderTradeId',
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('copyTradingOrders.market') || '市场',
|
||||
dataIndex: 'marketId',
|
||||
key: 'marketId',
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('copyTradingOrders.side') || '方向',
|
||||
dataIndex: 'side',
|
||||
key: 'side',
|
||||
width: isMobile ? 60 : 80,
|
||||
render: (side: string) => {
|
||||
const displaySide = side === '0' ? 'YES' : side === '1' ? 'NO' : side
|
||||
return <Tag style={{ fontSize: isMobile ? 11 : 12 }}>{displaySide}</Tag>
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('copyTradingOrders.buyQuantity') || '买入数量',
|
||||
dataIndex: 'quantity',
|
||||
key: 'quantity',
|
||||
width: isMobile ? 80 : 100,
|
||||
render: (value: string) => (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('copyTradingOrders.buyPrice') || '买入价格',
|
||||
dataIndex: 'price',
|
||||
key: 'price',
|
||||
width: isMobile ? 80 : 100,
|
||||
render: (value: string) => (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('copyTradingOrders.buyAmount') || '买入金额',
|
||||
key: 'amount',
|
||||
width: isMobile ? 100 : 120,
|
||||
render: (_: any, record: BuyOrderInfo) => {
|
||||
const amount = (parseFloat(record.quantity) * parseFloat(record.price)).toString()
|
||||
return (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>
|
||||
{isMobile ? formatUSDC(amount) : `${formatUSDC(amount)} USDC`}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('copyTradingOrders.matchedQuantity') || '已匹配',
|
||||
dataIndex: 'matchedQuantity',
|
||||
key: 'matchedQuantity',
|
||||
width: isMobile ? 70 : 90,
|
||||
render: (value: string) => (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('copyTradingOrders.remainingQuantity') || '剩余',
|
||||
dataIndex: 'remainingQuantity',
|
||||
key: 'remainingQuantity',
|
||||
width: isMobile ? 70 : 90,
|
||||
render: (value: string) => (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('copyTradingOrders.sellStatus') || '卖出状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: isMobile ? 80 : 100,
|
||||
render: (status: string) => getStatusTag(status)
|
||||
},
|
||||
{
|
||||
title: t('copyTradingOrders.createdAt') || '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: isMobile ? 120 : 160,
|
||||
render: (timestamp: number) => (
|
||||
<span style={{ fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
? new Date(timestamp).toLocaleDateString('zh-CN')
|
||||
: new Date(timestamp).toLocaleString('zh-CN')
|
||||
}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<Input
|
||||
placeholder={t('copyTradingOrders.filterMarketId') || '筛选市场ID'}
|
||||
allowClear
|
||||
style={{ width: isMobile ? '100%' : 200 }}
|
||||
value={filters.marketId}
|
||||
onChange={(e) => setFilters({ ...filters, marketId: e.target.value || undefined })}
|
||||
/>
|
||||
|
||||
<Select
|
||||
placeholder={t('copyTradingOrders.filterSide') || '筛选方向'}
|
||||
allowClear
|
||||
style={{ width: isMobile ? '100%' : 150 }}
|
||||
value={filters.side}
|
||||
onChange={(value) => setFilters({ ...filters, side: value || undefined })}
|
||||
>
|
||||
<Option value="0">YES</Option>
|
||||
<Option value="1">NO</Option>
|
||||
<Option value="YES">YES</Option>
|
||||
<Option value="NO">NO</Option>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
placeholder={t('copyTradingOrders.filterStatus') || '筛选状态'}
|
||||
allowClear
|
||||
style={{ width: isMobile ? '100%' : 150 }}
|
||||
value={filters.status}
|
||||
onChange={(value) => setFilters({ ...filters, status: value || undefined })}
|
||||
>
|
||||
<Option value="filled">{t('copyTradingOrders.statusFilled') || '已完成'}</Option>
|
||||
<Option value="partially_matched">{t('copyTradingOrders.statusPartiallySold') || '部分成交'}</Option>
|
||||
<Option value="fully_matched">{t('copyTradingOrders.statusFullySold') || '全部成交'}</Option>
|
||||
</Select>
|
||||
|
||||
<Button onClick={fetchOrders}>{t('common.search') || '查询'}</Button>
|
||||
</div>
|
||||
|
||||
{isMobile ? (
|
||||
<div>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : orders.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
|
||||
{t('copyTradingOrders.noBuyOrders') || '暂无买入订单'}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{orders.map((order) => {
|
||||
const date = new Date(order.createdAt)
|
||||
const formattedDate = date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString()
|
||||
const displaySide = order.side === '0' ? 'YES' : order.side === '1' ? 'NO' : order.side
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={order.orderId}
|
||||
style={{
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||
border: '1px solid #e8e8e8'
|
||||
}}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
{order.orderId.slice(0, 8)}...{order.orderId.slice(-6)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
||||
<Tag>{displaySide}</Tag>
|
||||
{getStatusTag(order.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.buyInfo') || '买入信息'}</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500' }}>
|
||||
{t('copyTradingOrders.quantity') || '数量'}: {formatUSDC(order.quantity)} | {t('copyTradingOrders.price') || '价格'}: {formatUSDC(order.price)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500', marginTop: '4px' }}>
|
||||
{t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.matchInfo') || '匹配信息'}</div>
|
||||
<div style={{ fontSize: '13px', color: '#333' }}>
|
||||
{t('copyTradingOrders.matched') || '已匹配'}: {formatUSDC(order.matchedQuantity)} | {t('copyTradingOrders.remaining') || '剩余'}: {formatUSDC(order.remainingQuantity)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.leaderTradeId') || 'Leader 交易ID'}</div>
|
||||
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
|
||||
{order.leaderTradeId.slice(0, 8)}...{order.leaderTradeId.slice(-6)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.marketId') || '市场ID'}</div>
|
||||
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
|
||||
{order.marketId.slice(0, 8)}...{order.marketId.slice(-6)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#999' }}>
|
||||
{t('copyTradingOrders.createdAt') || '创建时间'}: {formattedDate}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={orders}
|
||||
rowKey="orderId"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: limit,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `${t('common.total') || '共'} ${total} ${t('common.items') || '条'}`,
|
||||
onChange: (newPage, newLimit) => {
|
||||
setPage(newPage)
|
||||
setLimit(newLimit)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BuyOrdersTab
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Modal, Form, Button, message, Radio, InputNumber, Divider, Spin, Select, Input, Space, Switch } from 'antd'
|
||||
import { SaveOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../../services/api'
|
||||
import type { CopyTrading, CopyTradingUpdateRequest } from '../../types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const { Option } = Select
|
||||
|
||||
interface EditModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
copyTradingId: string
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
const EditModal: React.FC<EditModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
copyTradingId,
|
||||
onSuccess
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [fetching, setFetching] = useState(true)
|
||||
const [copyTrading, setCopyTrading] = useState<CopyTrading | null>(null)
|
||||
const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO')
|
||||
const [originalEnabled, setOriginalEnabled] = useState<boolean>(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (open && copyTradingId) {
|
||||
fetchCopyTrading(parseInt(copyTradingId))
|
||||
}
|
||||
}, [open, copyTradingId])
|
||||
|
||||
const fetchCopyTrading = async (copyTradingId: number) => {
|
||||
setFetching(true)
|
||||
try {
|
||||
const response = await apiService.copyTrading.list({})
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
const found = response.data.data.list.find((ct: CopyTrading) => ct.id === copyTradingId)
|
||||
if (found) {
|
||||
setCopyTrading(found)
|
||||
setCopyMode(found.copyMode)
|
||||
setOriginalEnabled(found.enabled)
|
||||
form.setFieldsValue({
|
||||
accountId: found.accountId,
|
||||
leaderId: found.leaderId,
|
||||
copyMode: found.copyMode,
|
||||
copyRatio: found.copyRatio ? parseFloat(found.copyRatio) * 100 : 100,
|
||||
fixedAmount: found.fixedAmount ? parseFloat(found.fixedAmount) : undefined,
|
||||
maxOrderSize: found.maxOrderSize ? parseFloat(found.maxOrderSize) : undefined,
|
||||
minOrderSize: found.minOrderSize ? parseFloat(found.minOrderSize) : undefined,
|
||||
maxDailyLoss: found.maxDailyLoss ? parseFloat(found.maxDailyLoss) : undefined,
|
||||
maxDailyOrders: found.maxDailyOrders,
|
||||
priceTolerance: found.priceTolerance ? parseFloat(found.priceTolerance) : undefined,
|
||||
delaySeconds: found.delaySeconds,
|
||||
pollIntervalSeconds: found.pollIntervalSeconds,
|
||||
useWebSocket: found.useWebSocket,
|
||||
websocketReconnectInterval: found.websocketReconnectInterval,
|
||||
websocketMaxRetries: found.websocketMaxRetries,
|
||||
supportSell: found.supportSell,
|
||||
minOrderDepth: found.minOrderDepth ? parseFloat(found.minOrderDepth) : undefined,
|
||||
maxSpread: found.maxSpread ? parseFloat(found.maxSpread) : undefined,
|
||||
minPrice: found.minPrice ? parseFloat(found.minPrice) : undefined,
|
||||
maxPrice: found.maxPrice ? parseFloat(found.maxPrice) : undefined,
|
||||
configName: found.configName || '',
|
||||
pushFailedOrders: found.pushFailedOrders ?? false
|
||||
})
|
||||
} else {
|
||||
message.error(t('copyTradingEdit.fetchFailed') || '跟单配置不存在')
|
||||
onClose()
|
||||
}
|
||||
} else {
|
||||
message.error(response.data.msg || t('copyTradingEdit.fetchFailed') || '获取跟单配置失败')
|
||||
onClose()
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('copyTradingEdit.fetchFailed') || '获取跟单配置失败')
|
||||
onClose()
|
||||
} finally {
|
||||
setFetching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyModeChange = (mode: 'RATIO' | 'FIXED') => {
|
||||
setCopyMode(mode)
|
||||
}
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
if (values.copyMode === 'FIXED') {
|
||||
if (!values.fixedAmount || Number(values.fixedAmount) < 1) {
|
||||
message.error('固定金额必须 >= 1')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (values.copyMode === 'RATIO' && values.minOrderSize !== undefined && values.minOrderSize !== null && Number(values.minOrderSize) < 1) {
|
||||
message.error('最小金额必须 >= 1')
|
||||
return
|
||||
}
|
||||
|
||||
if (!copyTradingId) {
|
||||
message.error('配置ID不存在')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const request: CopyTradingUpdateRequest = {
|
||||
copyTradingId: parseInt(copyTradingId),
|
||||
enabled: originalEnabled,
|
||||
copyMode: values.copyMode,
|
||||
copyRatio: values.copyMode === 'RATIO' && values.copyRatio ? (values.copyRatio / 100).toString() : undefined,
|
||||
fixedAmount: values.copyMode === 'FIXED' ? values.fixedAmount?.toString() : undefined,
|
||||
maxOrderSize: values.maxOrderSize?.toString(),
|
||||
minOrderSize: values.minOrderSize?.toString(),
|
||||
maxDailyLoss: values.maxDailyLoss?.toString(),
|
||||
maxDailyOrders: values.maxDailyOrders,
|
||||
priceTolerance: values.priceTolerance?.toString(),
|
||||
delaySeconds: values.delaySeconds,
|
||||
pollIntervalSeconds: values.pollIntervalSeconds,
|
||||
useWebSocket: values.useWebSocket,
|
||||
websocketReconnectInterval: values.websocketReconnectInterval,
|
||||
websocketMaxRetries: values.websocketMaxRetries,
|
||||
supportSell: values.supportSell,
|
||||
minOrderDepth: values.minOrderDepth?.toString(),
|
||||
maxSpread: values.maxSpread?.toString(),
|
||||
minPrice: values.minPrice?.toString(),
|
||||
maxPrice: values.maxPrice?.toString(),
|
||||
configName: values.configName?.trim() || undefined,
|
||||
pushFailedOrders: values.pushFailedOrders
|
||||
}
|
||||
|
||||
const response = await apiService.copyTrading.update(request)
|
||||
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('copyTradingEdit.saveSuccess') || '更新跟单配置成功')
|
||||
onClose()
|
||||
if (onSuccess) {
|
||||
onSuccess()
|
||||
}
|
||||
} else {
|
||||
message.error(response.data.msg || t('copyTradingEdit.saveFailed') || '更新跟单配置失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('copyTradingEdit.saveFailed') || '更新跟单配置失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('copyTradingEdit.title') || '编辑跟单配置'}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width="90%"
|
||||
style={{ top: 20 }}
|
||||
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 100px)', overflow: 'auto' }}
|
||||
>
|
||||
{fetching ? (
|
||||
<div style={{ textAlign: 'center', padding: '50px' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : !copyTrading ? (
|
||||
<div style={{ textAlign: 'center', padding: '50px' }}>
|
||||
<p>{t('copyTradingEdit.fetchFailed') || '跟单配置不存在'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
>
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.configName') || '配置名'}
|
||||
name="configName"
|
||||
rules={[
|
||||
{ required: true, message: t('copyTradingEdit.configNameRequired') || '请输入配置名' },
|
||||
{ whitespace: true, message: t('copyTradingEdit.configNameRequired') || '配置名不能为空' }
|
||||
]}
|
||||
tooltip={t('copyTradingEdit.configNameTooltip') || '为跟单配置设置一个名称,便于识别和管理'}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('copyTradingEdit.configNamePlaceholder') || '例如:跟单配置1'}
|
||||
maxLength={255}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingAdd.selectWallet') || t('copyTradingEdit.selectWallet') || '钱包'}
|
||||
name="accountId"
|
||||
>
|
||||
<Select disabled>
|
||||
<Option value={copyTrading.accountId}>
|
||||
{copyTrading.accountName || `账户 ${copyTrading.accountId}`} ({copyTrading.walletAddress.slice(0, 6)}...{copyTrading.walletAddress.slice(-4)})
|
||||
</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingAdd.selectLeader') || t('copyTradingEdit.selectLeader') || 'Leader'}
|
||||
name="leaderId"
|
||||
>
|
||||
<Select disabled>
|
||||
<Option value={copyTrading.leaderId}>
|
||||
{copyTrading.leaderName || `Leader ${copyTrading.leaderId}`} ({copyTrading.leaderAddress.slice(0, 6)}...{copyTrading.leaderAddress.slice(-4)})
|
||||
</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Divider>{t('copyTradingEdit.basicConfig') || '基础配置'}</Divider>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.copyMode') || '跟单金额模式'}
|
||||
name="copyMode"
|
||||
tooltip={t('copyTradingEdit.copyModeTooltip') || '选择跟单金额的计算方式'}
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Radio.Group onChange={(e) => handleCopyModeChange(e.target.value)}>
|
||||
<Radio value="RATIO">{t('copyTradingEdit.ratioMode') || '比例模式'}</Radio>
|
||||
<Radio value="FIXED">{t('copyTradingEdit.fixedAmountMode') || '固定金额模式'}</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
{copyMode === 'RATIO' && (
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.copyRatio') || '跟单比例'}
|
||||
name="copyRatio"
|
||||
tooltip={t('copyTradingEdit.copyRatioTooltip') || '跟单比例表示跟单金额相对于 Leader 订单金额的百分比'}
|
||||
>
|
||||
<InputNumber
|
||||
min={10}
|
||||
max={1000}
|
||||
step={1}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="%"
|
||||
placeholder={t('copyTradingEdit.copyRatioPlaceholder') || '例如:100 表示 100%(1:1 跟单)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{copyMode === 'FIXED' && (
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.fixedAmount') || '固定跟单金额 (USDC)'}
|
||||
name="fixedAmount"
|
||||
rules={[
|
||||
{ required: true, message: t('copyTradingEdit.fixedAmountRequired') || '请输入固定跟单金额' },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
const amount = Number(value)
|
||||
if (isNaN(amount)) {
|
||||
return Promise.reject(new Error(t('copyTradingEdit.invalidNumber') || '请输入有效的数字'))
|
||||
}
|
||||
if (amount < 1) {
|
||||
return Promise.reject(new Error(t('copyTradingEdit.fixedAmountMin') || '固定金额必须 >= 1'))
|
||||
}
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
step={0.0001}
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.fixedAmountPlaceholder') || '固定金额,不随 Leader 订单大小变化,必须 >= 1'}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{copyMode === 'RATIO' && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.maxOrderSize') || '单笔订单最大金额 (USDC)'}
|
||||
name="maxOrderSize"
|
||||
tooltip={t('copyTradingEdit.maxOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最大金额上限'}
|
||||
>
|
||||
<InputNumber
|
||||
min={0.0001}
|
||||
step={0.0001}
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.maxOrderSizePlaceholder') || '仅在比例模式下生效(可选)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.minOrderSize') || '单笔订单最小金额 (USDC)'}
|
||||
name="minOrderSize"
|
||||
tooltip={t('copyTradingEdit.minOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最小金额下限,必须 >= 1'}
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (typeof value === 'number' && value < 1) {
|
||||
return Promise.reject(new Error(t('copyTradingEdit.minOrderSizeMin') || '最小金额必须 >= 1'))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
step={0.0001}
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.minOrderSizePlaceholder') || '仅在比例模式下生效,必须 >= 1(可选)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.maxDailyLoss') || '每日最大亏损限制 (USDC)'}
|
||||
name="maxDailyLoss"
|
||||
tooltip={t('copyTradingEdit.maxDailyLossTooltip') || '限制每日最大亏损金额,用于风险控制'}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={0.0001}
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.maxDailyLossPlaceholder') || '默认 10000 USDC(可选)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.maxDailyOrders') || '每日最大跟单订单数'}
|
||||
name="maxDailyOrders"
|
||||
tooltip={t('copyTradingEdit.maxDailyOrdersTooltip') || '限制每日最多跟单的订单数量'}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
step={1}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.maxDailyOrdersPlaceholder') || '默认 100(可选)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.priceTolerance') || '价格容忍度 (%)'}
|
||||
name="priceTolerance"
|
||||
tooltip={t('copyTradingEdit.priceToleranceTooltip') || '允许跟单价格在 Leader 价格基础上的调整范围'}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.1}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.priceTolerancePlaceholder') || '默认 5%(可选)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.delaySeconds') || '跟单延迟 (秒)'}
|
||||
name="delaySeconds"
|
||||
tooltip={t('copyTradingEdit.delaySecondsTooltip') || '跟单延迟时间,0 表示立即跟单'}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={1}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.delaySecondsPlaceholder') || '默认 0(立即跟单)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.minOrderDepth') || '最小订单深度 (USDC)'}
|
||||
name="minOrderDepth"
|
||||
tooltip={t('copyTradingEdit.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={0.0001}
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.minOrderDepthPlaceholder') || '例如:100(可选,不填写表示不启用)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.maxSpread') || '最大价差(绝对价格)'}
|
||||
name="maxSpread"
|
||||
tooltip={t('copyTradingEdit.maxSpreadTooltip') || '最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤'}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={0.0001}
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.maxSpreadPlaceholder') || '例如:0.05(5美分,可选,不填写表示不启用)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Divider>{t('copyTradingEdit.priceRangeFilter') || '价格区间过滤'}</Divider>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.priceRange') || '价格区间'}
|
||||
name="priceRange"
|
||||
tooltip={t('copyTradingEdit.priceRangeTooltip') || '配置价格区间,仅在指定价格区间内的订单才会下单。例如:0.11-0.89 表示区间在0.11和0.89之间;-0.89 表示0.89以下都可以;0.11- 表示0.11以上都可以'}
|
||||
>
|
||||
<Input.Group compact style={{ display: 'flex' }}>
|
||||
<Form.Item name="minPrice" noStyle>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
max={0.99}
|
||||
step={0.0001}
|
||||
precision={4}
|
||||
style={{ width: '50%' }}
|
||||
placeholder={t('copyTradingEdit.minPricePlaceholder') || '最低价(可选)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
<span style={{ display: 'inline-block', width: '20px', textAlign: 'center', lineHeight: '32px' }}>-</span>
|
||||
<Form.Item name="maxPrice" noStyle>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
max={0.99}
|
||||
step={0.0001}
|
||||
precision={4}
|
||||
style={{ width: '50%' }}
|
||||
placeholder={t('copyTradingEdit.maxPricePlaceholder') || '最高价(可选)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Input.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Divider>{t('copyTradingEdit.advancedSettings') || '高级设置'}</Divider>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.supportSell') || '跟单卖出'}
|
||||
name="supportSell"
|
||||
tooltip={t('copyTradingEdit.supportSellTooltip') || '是否跟单 Leader 的卖出订单'}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.pushFailedOrders') || '推送失败订单'}
|
||||
name="pushFailedOrders"
|
||||
tooltip={t('copyTradingEdit.pushFailedOrdersTooltip') || '开启后,失败的订单会推送到 Telegram'}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
icon={<SaveOutlined />}
|
||||
loading={loading}
|
||||
>
|
||||
{t('copyTradingEdit.save') || '保存'}
|
||||
</Button>
|
||||
<Button onClick={onClose}>
|
||||
{t('common.cancel') || '取消'}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditModal
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Modal, Table, Tag, Select, Card, Divider, Spin } from 'antd'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { apiService } from '../../services/api'
|
||||
import type { FilteredOrder, FilteredOrderListResponse } from '../../types'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { formatUSDC } from '../../utils'
|
||||
|
||||
const { Option } = Select
|
||||
|
||||
interface FilteredOrdersModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
copyTradingId: string
|
||||
}
|
||||
|
||||
const FilteredOrdersModal: React.FC<FilteredOrdersModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
copyTradingId
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [filteredOrders, setFilteredOrders] = useState<FilteredOrder[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [limit] = useState(20)
|
||||
const [filterType, setFilterType] = useState<string | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (open && copyTradingId) {
|
||||
fetchFilteredOrders()
|
||||
}
|
||||
}, [open, copyTradingId, page, filterType])
|
||||
|
||||
const fetchFilteredOrders = async () => {
|
||||
if (!copyTradingId) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await apiService.copyTrading.getFilteredOrders({
|
||||
copyTradingId: parseInt(copyTradingId),
|
||||
filterType: filterType,
|
||||
page: page,
|
||||
limit: limit
|
||||
})
|
||||
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
const data: FilteredOrderListResponse = response.data.data
|
||||
setFilteredOrders(data.list || [])
|
||||
setTotal(data.total || 0)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取被过滤订单列表失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getFilterTypeTag = (filterType: string) => {
|
||||
const typeMap: Record<string, { color: string; text: string }> = {
|
||||
ORDER_DEPTH: { color: 'orange', text: t('filteredOrdersList.filterTypes.orderDepth') || '订单深度不足' },
|
||||
SPREAD: { color: 'red', text: t('filteredOrdersList.filterTypes.spread') || '价差过大' },
|
||||
ORDERBOOK_DEPTH: { color: 'purple', text: t('filteredOrdersList.filterTypes.orderbookDepth') || '订单簿深度不足' },
|
||||
PRICE_VALIDITY: { color: 'blue', text: t('filteredOrdersList.filterTypes.priceValidity') || '价格不合理' },
|
||||
MARKET_STATUS: { color: 'default', text: t('filteredOrdersList.filterTypes.marketStatus') || '市场状态不可交易' },
|
||||
ORDERBOOK_ERROR: { color: 'default', text: t('filteredOrdersList.filterTypes.orderbookError') || '订单簿获取失败' },
|
||||
ORDERBOOK_EMPTY: { color: 'default', text: t('filteredOrdersList.filterTypes.orderbookEmpty') || '订单簿为空' },
|
||||
PRICE_RANGE: { color: 'purple', text: t('filteredOrdersList.filterTypes.priceRange') || '价格区间不符' }
|
||||
}
|
||||
const config = typeMap[filterType] || { color: 'default', text: filterType }
|
||||
return <Tag color={config.color}>{config.text}</Tag>
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('filteredOrdersList.market') || '市场',
|
||||
dataIndex: 'marketTitle',
|
||||
key: 'marketTitle',
|
||||
width: isMobile ? 150 : 200,
|
||||
ellipsis: true,
|
||||
render: (text: string, record: FilteredOrder) => {
|
||||
const marketTitle = text || record.marketId.slice(0, 10) + '...'
|
||||
return <span style={{ fontSize: isMobile ? 12 : 14 }}>{marketTitle}</span>
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('filteredOrdersList.side') || '方向',
|
||||
dataIndex: 'side',
|
||||
key: 'side',
|
||||
width: isMobile ? 60 : 80,
|
||||
render: (side: string) => (
|
||||
<Tag color={side === 'BUY' ? 'green' : 'red'}>
|
||||
{side === 'BUY' ? (t('order.buy') || '买入') : (t('order.sell') || '卖出')}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('filteredOrdersList.price') || '价格',
|
||||
dataIndex: 'price',
|
||||
key: 'price',
|
||||
width: isMobile ? 80 : 100,
|
||||
render: (value: string) => (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('filteredOrdersList.size') || '数量',
|
||||
dataIndex: 'size',
|
||||
key: 'size',
|
||||
width: isMobile ? 80 : 100,
|
||||
render: (value: string) => (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('filteredOrdersList.filterType') || '过滤类型',
|
||||
dataIndex: 'filterType',
|
||||
key: 'filterType',
|
||||
width: isMobile ? 120 : 150,
|
||||
render: (filterType: string) => getFilterTypeTag(filterType)
|
||||
},
|
||||
{
|
||||
title: t('filteredOrdersList.filterReason') || '过滤原因',
|
||||
dataIndex: 'filterReason',
|
||||
key: 'filterReason',
|
||||
width: isMobile ? 150 : 250,
|
||||
ellipsis: true,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontSize: isMobile ? 11 : 12 }} title={text}>{text}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('filteredOrdersList.createdAt') || '时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: isMobile ? 120 : 160,
|
||||
render: (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
const format = isMobile
|
||||
? `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
|
||||
return (
|
||||
<span style={{ fontSize: isMobile ? 11 : 12 }}>
|
||||
{format}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('copyTradingOrders.filteredOrders') || '已过滤订单'}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width="90%"
|
||||
style={{ top: 20 }}
|
||||
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 100px)', overflow: 'auto' }}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
placeholder={t('filteredOrdersList.filterByType') || '按类型筛选'}
|
||||
allowClear
|
||||
style={{ width: isMobile ? '100%' : 200 }}
|
||||
value={filterType}
|
||||
onChange={(value) => setFilterType(value || undefined)}
|
||||
>
|
||||
<Option value="ORDER_DEPTH">{t('filteredOrdersList.filterTypes.orderDepth') || '订单深度不足'}</Option>
|
||||
<Option value="SPREAD">{t('filteredOrdersList.filterTypes.spread') || '价差过大'}</Option>
|
||||
<Option value="ORDERBOOK_DEPTH">{t('filteredOrdersList.filterTypes.orderbookDepth') || '订单簿深度不足'}</Option>
|
||||
<Option value="PRICE_VALIDITY">{t('filteredOrdersList.filterTypes.priceValidity') || '价格不合理'}</Option>
|
||||
<Option value="MARKET_STATUS">{t('filteredOrdersList.filterTypes.marketStatus') || '市场状态不可交易'}</Option>
|
||||
<Option value="ORDERBOOK_ERROR">{t('filteredOrdersList.filterTypes.orderbookError') || '订单簿获取失败'}</Option>
|
||||
<Option value="ORDERBOOK_EMPTY">{t('filteredOrdersList.filterTypes.orderbookEmpty') || '订单簿为空'}</Option>
|
||||
<Option value="PRICE_RANGE">{t('filteredOrdersList.filterTypes.priceRange') || '价格区间不符'}</Option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isMobile ? (
|
||||
<div>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : filteredOrders.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
|
||||
{t('filteredOrdersList.noFilteredOrders') || '暂无已过滤订单'}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{filteredOrders.map((order) => {
|
||||
const date = new Date(order.createdAt)
|
||||
const formattedDate = date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
const marketTitle = order.marketTitle || order.marketId.slice(0, 10) + '...'
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={order.id}
|
||||
style={{
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||
border: '1px solid #e8e8e8'
|
||||
}}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
color: '#1890ff'
|
||||
}}>
|
||||
{marketTitle}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
||||
<Tag color={order.side === 'BUY' ? 'green' : 'red'}>
|
||||
{order.side === 'BUY' ? (t('order.buy') || '买入') : (t('order.sell') || '卖出')}
|
||||
</Tag>
|
||||
{getFilterTypeTag(order.filterType)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('filteredOrdersList.orderDetails') || '订单详情'}</div>
|
||||
<div style={{ fontSize: '13px', color: '#333' }}>
|
||||
{t('filteredOrdersList.price') || '价格'}: {formatUSDC(order.price)} | {t('filteredOrdersList.size') || '数量'}: {formatUSDC(order.size)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('filteredOrdersList.filterReason') || '过滤原因'}</div>
|
||||
<div style={{ fontSize: '12px', color: '#333' }} title={order.filterReason}>
|
||||
{order.filterReason}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#999' }}>
|
||||
{t('filteredOrdersList.createdAt') || '时间'}: {formattedDate}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredOrders}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: limit,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `${t('common.total') || '共'} ${total} ${t('common.items') || '条'}`,
|
||||
onChange: (newPage) => {
|
||||
setPage(newPage)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilteredOrdersModal
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Table, Input, Button, Card, Divider, Spin } from 'antd'
|
||||
import { apiService } from '../../services/api'
|
||||
import { formatUSDC } from '../../utils'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { MatchedOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types'
|
||||
|
||||
interface MatchedOrdersTabProps {
|
||||
copyTradingId: string
|
||||
}
|
||||
|
||||
const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) => {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [orders, setOrders] = useState<MatchedOrderInfo[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [limit, setLimit] = useState(20)
|
||||
const [filters, setFilters] = useState<{
|
||||
sellOrderId?: string
|
||||
buyOrderId?: string
|
||||
}>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (copyTradingId) {
|
||||
fetchOrders()
|
||||
}
|
||||
}, [copyTradingId, page, limit, filters])
|
||||
|
||||
const fetchOrders = async () => {
|
||||
if (!copyTradingId) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const request: OrderTrackingRequest = {
|
||||
copyTradingId: parseInt(copyTradingId),
|
||||
type: 'matched',
|
||||
page,
|
||||
limit,
|
||||
...filters
|
||||
}
|
||||
|
||||
const response = await apiService.orderTracking.list(request)
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
const data = response.data.data as OrderTrackingListResponse
|
||||
setOrders((data.list || []) as MatchedOrderInfo[])
|
||||
setTotal(data.total || 0)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取匹配关系列表失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getPnlColor = (value: string): string => {
|
||||
const num = parseFloat(value)
|
||||
if (isNaN(num)) return '#666'
|
||||
return num >= 0 ? '#3f8600' : '#cf1322'
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('copyTradingOrders.sellOrderId') || '卖出订单ID',
|
||||
dataIndex: 'sellOrderId',
|
||||
key: 'sellOrderId',
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('copyTradingOrders.buyOrderId') || '买入订单ID',
|
||||
dataIndex: 'buyOrderId',
|
||||
key: 'buyOrderId',
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('copyTradingOrders.matchedQuantity') || '匹配数量',
|
||||
dataIndex: 'matchedQuantity',
|
||||
key: 'matchedQuantity',
|
||||
width: isMobile ? 80 : 100,
|
||||
render: (value: string) => (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('copyTradingOrders.buyPrice') || '买入价格',
|
||||
dataIndex: 'buyPrice',
|
||||
key: 'buyPrice',
|
||||
width: isMobile ? 80 : 100,
|
||||
render: (value: string) => (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('copyTradingOrders.sellPrice') || '卖出价格',
|
||||
dataIndex: 'sellPrice',
|
||||
key: 'sellPrice',
|
||||
width: isMobile ? 80 : 100,
|
||||
render: (value: string) => (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('copyTradingOrders.realizedPnl') || '盈亏',
|
||||
dataIndex: 'realizedPnl',
|
||||
key: 'realizedPnl',
|
||||
width: isMobile ? 100 : 120,
|
||||
render: (value: string) => (
|
||||
<span style={{
|
||||
color: getPnlColor(value),
|
||||
fontWeight: 500,
|
||||
fontSize: isMobile ? 12 : 14
|
||||
}}>
|
||||
{isMobile ? formatUSDC(value) : `${formatUSDC(value)} USDC`}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('copyTradingOrders.matchedAt') || '匹配时间',
|
||||
dataIndex: 'matchedAt',
|
||||
key: 'matchedAt',
|
||||
width: isMobile ? 120 : 160,
|
||||
render: (timestamp: number) => (
|
||||
<span style={{ fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
? new Date(timestamp).toLocaleDateString('zh-CN')
|
||||
: new Date(timestamp).toLocaleString('zh-CN')
|
||||
}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<Input
|
||||
placeholder={t('copyTradingOrders.filterSellOrderId') || '筛选卖出订单ID'}
|
||||
allowClear
|
||||
style={{ width: isMobile ? '100%' : 200 }}
|
||||
value={filters.sellOrderId}
|
||||
onChange={(e) => setFilters({ ...filters, sellOrderId: e.target.value || undefined })}
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder={t('copyTradingOrders.filterBuyOrderId') || '筛选买入订单ID'}
|
||||
allowClear
|
||||
style={{ width: isMobile ? '100%' : 200 }}
|
||||
value={filters.buyOrderId}
|
||||
onChange={(e) => setFilters({ ...filters, buyOrderId: e.target.value || undefined })}
|
||||
/>
|
||||
|
||||
<Button onClick={fetchOrders}>{t('common.search') || '查询'}</Button>
|
||||
</div>
|
||||
|
||||
{isMobile ? (
|
||||
<div>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : orders.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
|
||||
{t('copyTradingOrders.noMatchedOrders') || '暂无匹配关系'}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{orders.map((order) => {
|
||||
const date = new Date(order.matchedAt)
|
||||
const formattedDate = date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={`${order.sellOrderId}-${order.buyOrderId}-${order.matchedAt}`}
|
||||
style={{
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||
border: '1px solid #e8e8e8'
|
||||
}}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.sellOrderId') || '卖出订单ID'}</div>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
fontFamily: 'monospace',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
{order.sellOrderId.slice(0, 8)}...{order.sellOrderId.slice(-6)}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.buyOrderId') || '买入订单ID'}</div>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
{order.buyOrderId.slice(0, 8)}...{order.buyOrderId.slice(-6)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.matchedQuantity') || '匹配数量'}</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500' }}>
|
||||
{formatUSDC(order.matchedQuantity)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.priceInfo') || '价格信息'}</div>
|
||||
<div style={{ fontSize: '13px', color: '#333' }}>
|
||||
{t('copyTradingOrders.buy') || '买入'}: {formatUSDC(order.buyPrice)} | {t('copyTradingOrders.sell') || '卖出'}: {formatUSDC(order.sellPrice)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.realizedPnl') || '盈亏'}</div>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: getPnlColor(order.realizedPnl)
|
||||
}}>
|
||||
{formatUSDC(order.realizedPnl)} USDC
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#999' }}>
|
||||
{t('copyTradingOrders.matchedAt') || '匹配时间'}: {formattedDate}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={orders}
|
||||
rowKey={(record) => `${record.sellOrderId}-${record.buyOrderId}-${record.matchedAt}`}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: limit,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `${t('common.total') || '共'} ${total} ${t('common.items') || '条'}`,
|
||||
onChange: (newPage, newLimit) => {
|
||||
setPage(newPage)
|
||||
setLimit(newLimit)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MatchedOrdersTab
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Table, Tag, Select, Input, Button, Card, Divider, Spin } from 'antd'
|
||||
import { apiService } from '../../services/api'
|
||||
import { formatUSDC } from '../../utils'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { SellOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types'
|
||||
|
||||
const { Option } = Select
|
||||
|
||||
interface SellOrdersTabProps {
|
||||
copyTradingId: string
|
||||
}
|
||||
|
||||
const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [orders, setOrders] = useState<SellOrderInfo[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [limit, setLimit] = useState(20)
|
||||
const [filters, setFilters] = useState<{
|
||||
marketId?: string
|
||||
side?: string
|
||||
}>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (copyTradingId) {
|
||||
fetchOrders()
|
||||
}
|
||||
}, [copyTradingId, page, limit, filters])
|
||||
|
||||
const fetchOrders = async () => {
|
||||
if (!copyTradingId) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const request: OrderTrackingRequest = {
|
||||
copyTradingId: parseInt(copyTradingId),
|
||||
type: 'sell',
|
||||
page,
|
||||
limit,
|
||||
...filters
|
||||
}
|
||||
|
||||
const response = await apiService.orderTracking.list(request)
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
const data = response.data.data as OrderTrackingListResponse
|
||||
setOrders((data.list || []) as SellOrderInfo[])
|
||||
setTotal(data.total || 0)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取卖出订单列表失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getPnlColor = (value: string): string => {
|
||||
const num = parseFloat(value)
|
||||
if (isNaN(num)) return '#666'
|
||||
return num >= 0 ? '#3f8600' : '#cf1322'
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('copyTradingOrders.orderId') || '订单ID',
|
||||
dataIndex: 'orderId',
|
||||
key: 'orderId',
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('copyTradingOrders.leaderTradeId') || 'Leader 交易ID',
|
||||
dataIndex: 'leaderTradeId',
|
||||
key: 'leaderTradeId',
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('copyTradingOrders.market') || '市场',
|
||||
dataIndex: 'marketId',
|
||||
key: 'marketId',
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('copyTradingOrders.side') || '方向',
|
||||
dataIndex: 'side',
|
||||
key: 'side',
|
||||
width: isMobile ? 60 : 80,
|
||||
render: (side: string) => {
|
||||
const displaySide = side === '0' ? 'YES' : side === '1' ? 'NO' : side
|
||||
return <Tag style={{ fontSize: isMobile ? 11 : 12 }}>{displaySide}</Tag>
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('copyTradingOrders.sellQuantity') || '卖出数量',
|
||||
dataIndex: 'quantity',
|
||||
key: 'quantity',
|
||||
width: isMobile ? 80 : 100,
|
||||
render: (value: string) => (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('copyTradingOrders.sellPrice') || '卖出价格',
|
||||
dataIndex: 'price',
|
||||
key: 'price',
|
||||
width: isMobile ? 80 : 100,
|
||||
render: (value: string) => (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('copyTradingOrders.sellAmount') || '卖出金额',
|
||||
key: 'amount',
|
||||
width: isMobile ? 100 : 120,
|
||||
render: (_: any, record: SellOrderInfo) => {
|
||||
const amount = (parseFloat(record.quantity) * parseFloat(record.price)).toString()
|
||||
return (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>
|
||||
{isMobile ? formatUSDC(amount) : `${formatUSDC(amount)} USDC`}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('copyTradingOrders.realizedPnl') || '已实现盈亏',
|
||||
dataIndex: 'realizedPnl',
|
||||
key: 'realizedPnl',
|
||||
width: isMobile ? 100 : 120,
|
||||
render: (value: string) => (
|
||||
<span style={{
|
||||
color: getPnlColor(value),
|
||||
fontWeight: 500,
|
||||
fontSize: isMobile ? 12 : 14
|
||||
}}>
|
||||
{isMobile ? formatUSDC(value) : `${formatUSDC(value)} USDC`}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('copyTradingOrders.createdAt') || '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: isMobile ? 120 : 160,
|
||||
render: (timestamp: number) => (
|
||||
<span style={{ fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
? new Date(timestamp).toLocaleDateString('zh-CN')
|
||||
: new Date(timestamp).toLocaleString('zh-CN')
|
||||
}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<Input
|
||||
placeholder={t('copyTradingOrders.filterMarketId') || '筛选市场ID'}
|
||||
allowClear
|
||||
style={{ width: isMobile ? '100%' : 200 }}
|
||||
value={filters.marketId}
|
||||
onChange={(e) => setFilters({ ...filters, marketId: e.target.value || undefined })}
|
||||
/>
|
||||
|
||||
<Select
|
||||
placeholder={t('copyTradingOrders.filterSide') || '筛选方向'}
|
||||
allowClear
|
||||
style={{ width: isMobile ? '100%' : 150 }}
|
||||
value={filters.side}
|
||||
onChange={(value) => setFilters({ ...filters, side: value || undefined })}
|
||||
>
|
||||
<Option value="0">YES</Option>
|
||||
<Option value="1">NO</Option>
|
||||
<Option value="YES">YES</Option>
|
||||
<Option value="NO">NO</Option>
|
||||
</Select>
|
||||
|
||||
<Button onClick={fetchOrders}>{t('common.search') || '查询'}</Button>
|
||||
</div>
|
||||
|
||||
{isMobile ? (
|
||||
<div>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : orders.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
|
||||
{t('copyTradingOrders.noSellOrders') || '暂无卖出订单'}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{orders.map((order) => {
|
||||
const date = new Date(order.createdAt)
|
||||
const formattedDate = date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString()
|
||||
const displaySide = order.side === '0' ? 'YES' : order.side === '1' ? 'NO' : order.side
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={order.orderId}
|
||||
style={{
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||
border: '1px solid #e8e8e8'
|
||||
}}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
{order.orderId.slice(0, 8)}...{order.orderId.slice(-6)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
||||
<Tag>{displaySide}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.sellInfo') || '卖出信息'}</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500' }}>
|
||||
{t('copyTradingOrders.quantity') || '数量'}: {formatUSDC(order.quantity)} | {t('copyTradingOrders.price') || '价格'}: {formatUSDC(order.price)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500', marginTop: '4px' }}>
|
||||
{t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.realizedPnl') || '已实现盈亏'}</div>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: getPnlColor(order.realizedPnl)
|
||||
}}>
|
||||
{formatUSDC(order.realizedPnl)} USDC
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.leaderTradeId') || 'Leader 交易ID'}</div>
|
||||
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
|
||||
{order.leaderTradeId.slice(0, 8)}...{order.leaderTradeId.slice(-6)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.marketId') || '市场ID'}</div>
|
||||
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
|
||||
{order.marketId.slice(0, 8)}...{order.marketId.slice(-6)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#999' }}>
|
||||
{t('copyTradingOrders.createdAt') || '创建时间'}: {formattedDate}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={orders}
|
||||
rowKey="orderId"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: limit,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `${t('common.total') || '共'} ${total} ${t('common.items') || '条'}`,
|
||||
onChange: (newPage, newLimit) => {
|
||||
setPage(newPage)
|
||||
setLimit(newLimit)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SellOrdersTab
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Modal, Row, Col, Statistic, Spin, message } from 'antd'
|
||||
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../../services/api'
|
||||
import { formatUSDC } from '../../utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import type { CopyTradingStatistics } from '../../types'
|
||||
|
||||
interface StatisticsModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
copyTradingId: string
|
||||
}
|
||||
|
||||
const StatisticsModal: React.FC<StatisticsModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
copyTradingId
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [statistics, setStatistics] = useState<CopyTradingStatistics | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open && copyTradingId) {
|
||||
fetchStatistics()
|
||||
}
|
||||
}, [open, copyTradingId])
|
||||
|
||||
const fetchStatistics = async () => {
|
||||
if (!copyTradingId) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await apiService.statistics.detail({ copyTradingId: parseInt(copyTradingId) })
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setStatistics(response.data.data)
|
||||
} else {
|
||||
message.error(response.data.msg || t('copyTradingOrders.fetchStatisticsFailed') || '获取统计信息失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('copyTradingOrders.fetchStatisticsFailed') || '获取统计信息失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getPnlColor = (value: string): string => {
|
||||
const num = parseFloat(value)
|
||||
if (isNaN(num)) return '#666'
|
||||
return num >= 0 ? '#3f8600' : '#cf1322'
|
||||
}
|
||||
|
||||
const getPnlIcon = (value: string) => {
|
||||
const num = parseFloat(value)
|
||||
if (isNaN(num)) return null
|
||||
return num >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('copyTradingOrders.statistics') || '跟单关系统计'}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width="90%"
|
||||
style={{ top: 20 }}
|
||||
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 100px)', overflow: 'auto' }}
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '50px' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : !statistics ? (
|
||||
<div style={{ textAlign: 'center', padding: '50px' }}>
|
||||
<p>{t('copyTradingOrders.noStatistics') || '暂无统计数据'}</p>
|
||||
</div>
|
||||
) : isMobile ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
|
||||
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
|
||||
{t('copyTradingOrders.totalBuyOrders') || '总买入订单数'}
|
||||
</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333', flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
|
||||
<ArrowUpOutlined style={{ color: '#1890ff', fontSize: '14px' }} />
|
||||
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{statistics.totalBuyOrders}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
|
||||
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
|
||||
{t('copyTradingOrders.totalSellOrders') || '总卖出订单数'}
|
||||
</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333', flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
|
||||
<ArrowDownOutlined style={{ color: '#ff4d4f', fontSize: '14px' }} />
|
||||
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{statistics.totalSellOrders}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
|
||||
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
|
||||
{t('copyTradingOrders.totalBuyAmount') || '总买入金额'}
|
||||
</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333', flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
|
||||
<ArrowUpOutlined style={{ color: '#1890ff', fontSize: '14px' }} />
|
||||
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.totalBuyAmount)} USDC</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
|
||||
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
|
||||
{t('copyTradingOrders.totalSellAmount') || '总卖出金额'}
|
||||
</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333', flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
|
||||
<ArrowDownOutlined style={{ color: '#ff4d4f', fontSize: '14px' }} />
|
||||
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.totalSellAmount)} USDC</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
|
||||
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
|
||||
{t('copyTradingOrders.totalPnl') || '总盈亏'}
|
||||
</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: 'bold', color: getPnlColor(statistics.totalPnl), flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
|
||||
{getPnlIcon(statistics.totalPnl)}
|
||||
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.totalPnl)} USDC</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
|
||||
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
|
||||
{t('copyTradingOrders.totalRealizedPnl') || '总已实现盈亏'}
|
||||
</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '500', color: getPnlColor(statistics.totalRealizedPnl), flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
|
||||
{getPnlIcon(statistics.totalRealizedPnl)}
|
||||
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.totalRealizedPnl)} USDC</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
|
||||
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
|
||||
{t('copyTradingOrders.totalUnrealizedPnl') || '总未实现盈亏'}
|
||||
</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '500', color: getPnlColor(statistics.totalUnrealizedPnl), flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
|
||||
{getPnlIcon(statistics.totalUnrealizedPnl)}
|
||||
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.totalUnrealizedPnl)} USDC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Statistic
|
||||
title={t('copyTradingOrders.totalBuyOrders') || '总买入订单数'}
|
||||
value={statistics.totalBuyOrders}
|
||||
prefix={<ArrowUpOutlined style={{ color: '#1890ff' }} />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Statistic
|
||||
title={t('copyTradingOrders.totalSellOrders') || '总卖出订单数'}
|
||||
value={statistics.totalSellOrders}
|
||||
prefix={<ArrowDownOutlined style={{ color: '#ff4d4f' }} />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Statistic
|
||||
title={t('copyTradingOrders.totalBuyAmount') || '总买入金额'}
|
||||
value={formatUSDC(statistics.totalBuyAmount)}
|
||||
suffix="USDC"
|
||||
prefix={<ArrowUpOutlined style={{ color: '#1890ff' }} />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Statistic
|
||||
title={t('copyTradingOrders.totalSellAmount') || '总卖出金额'}
|
||||
value={formatUSDC(statistics.totalSellAmount)}
|
||||
suffix="USDC"
|
||||
prefix={<ArrowDownOutlined style={{ color: '#ff4d4f' }} />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Statistic
|
||||
title={t('copyTradingOrders.totalPnl') || '总盈亏'}
|
||||
value={formatUSDC(statistics.totalPnl)}
|
||||
suffix="USDC"
|
||||
valueStyle={{ color: getPnlColor(statistics.totalPnl) }}
|
||||
prefix={getPnlIcon(statistics.totalPnl)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Statistic
|
||||
title={t('copyTradingOrders.totalRealizedPnl') || '总已实现盈亏'}
|
||||
value={formatUSDC(statistics.totalRealizedPnl)}
|
||||
suffix="USDC"
|
||||
valueStyle={{ color: getPnlColor(statistics.totalRealizedPnl) }}
|
||||
prefix={getPnlIcon(statistics.totalRealizedPnl)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Statistic
|
||||
title={t('copyTradingOrders.totalUnrealizedPnl') || '总未实现盈亏'}
|
||||
value={formatUSDC(statistics.totalUnrealizedPnl)}
|
||||
suffix="USDC"
|
||||
valueStyle={{ color: getPnlColor(statistics.totalUnrealizedPnl) }}
|
||||
prefix={getPnlIcon(statistics.totalUnrealizedPnl)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatisticsModal
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Modal, Tabs } from 'antd'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import BuyOrdersTab from './BuyOrdersTab'
|
||||
import SellOrdersTab from './SellOrdersTab'
|
||||
import MatchedOrdersTab from './MatchedOrdersTab'
|
||||
|
||||
type TabType = 'buy' | 'sell' | 'matched'
|
||||
|
||||
interface CopyTradingOrdersModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
copyTradingId: string
|
||||
defaultTab?: TabType
|
||||
}
|
||||
|
||||
const CopyTradingOrdersModal: React.FC<CopyTradingOrdersModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
copyTradingId,
|
||||
defaultTab = 'buy'
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [activeTab, setActiveTab] = useState<TabType>(defaultTab)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setActiveTab(defaultTab)
|
||||
}
|
||||
}, [open, defaultTab])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('copyTradingOrders.title') || '订单列表'}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width="90%"
|
||||
style={{ top: 20 }}
|
||||
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 100px)', overflow: 'auto' }}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key as TabType)}
|
||||
items={[
|
||||
{
|
||||
key: 'buy',
|
||||
label: t('copyTradingOrders.buyOrders') || '买入订单',
|
||||
children: <BuyOrdersTab copyTradingId={copyTradingId} />
|
||||
},
|
||||
{
|
||||
key: 'sell',
|
||||
label: t('copyTradingOrders.sellOrders') || '卖出订单',
|
||||
children: <SellOrdersTab copyTradingId={copyTradingId} />
|
||||
},
|
||||
{
|
||||
key: 'matched',
|
||||
label: t('copyTradingOrders.matchedOrders') || '匹配关系',
|
||||
children: <MatchedOrdersTab copyTradingId={copyTradingId} />
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default CopyTradingOrdersModal
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, Form, Input, Button, Select, message, Typography, Space } from 'antd'
|
||||
import { Card, Form, Input, Button, message, Typography, Space } from 'antd'
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../services/api'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
@@ -8,7 +8,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import { isValidWalletAddress } from '../utils'
|
||||
|
||||
const { Title } = Typography
|
||||
const { Option } = Select
|
||||
|
||||
const LeaderAdd: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
@@ -24,8 +23,7 @@ const LeaderAdd: React.FC = () => {
|
||||
leaderAddress: values.leaderAddress.trim(),
|
||||
leaderName: values.leaderName?.trim() || undefined,
|
||||
remark: values.remark?.trim() || undefined,
|
||||
website: values.website?.trim() || undefined,
|
||||
category: values.category || undefined
|
||||
website: values.website?.trim() || undefined
|
||||
})
|
||||
|
||||
if (response.data.code === 0) {
|
||||
@@ -121,17 +119,6 @@ const LeaderAdd: React.FC = () => {
|
||||
<Input placeholder={t('leaderAdd.websitePlaceholder') || '可选,例如:https://example.com'} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('leaderAdd.category') || '分类筛选'}
|
||||
name="category"
|
||||
tooltip={t('leaderAdd.categoryTooltip') || '仅跟单该分类的交易,不选择则跟单所有分类(sports 或 crypto)'}
|
||||
>
|
||||
<Select placeholder={t('leaderAdd.categoryPlaceholder') || '选择分类(可选)'} allowClear>
|
||||
<Option value="sports">Sports</Option>
|
||||
<Option value="crypto">Crypto</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Card, Form, Input, Button, Select, message, Typography, Space, Spin } from 'antd'
|
||||
import { Card, Form, Input, Button, message, Typography, Space, Spin } from 'antd'
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../services/api'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
@@ -8,7 +8,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import type { Leader } from '../types'
|
||||
|
||||
const { Title } = Typography
|
||||
const { Option } = Select
|
||||
|
||||
const LeaderEdit: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
@@ -38,8 +37,7 @@ const LeaderEdit: React.FC = () => {
|
||||
form.setFieldsValue({
|
||||
leaderName: leader.leaderName || '',
|
||||
remark: leader.remark || '',
|
||||
website: leader.website || '',
|
||||
category: leader.category || undefined
|
||||
website: leader.website || ''
|
||||
})
|
||||
} else {
|
||||
message.error(response.data.msg || t('leaderEdit.fetchFailed') || '获取 Leader 详情失败')
|
||||
@@ -65,8 +63,7 @@ const LeaderEdit: React.FC = () => {
|
||||
leaderId: parseInt(leaderId),
|
||||
leaderName: values.leaderName?.trim() || undefined,
|
||||
remark: values.remark?.trim() || undefined,
|
||||
website: values.website?.trim() || undefined,
|
||||
category: values.category || undefined
|
||||
website: values.website?.trim() || undefined
|
||||
})
|
||||
|
||||
if (response.data.code === 0) {
|
||||
@@ -145,17 +142,6 @@ const LeaderEdit: React.FC = () => {
|
||||
<Input placeholder={t('leaderEdit.websitePlaceholder') || '可选,例如:https://example.com'} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('leaderEdit.category') || '分类筛选'}
|
||||
name="category"
|
||||
tooltip={t('leaderEdit.categoryTooltip') || '仅跟单该分类的交易,不选择则跟单所有分类(sports 或 crypto)'}
|
||||
>
|
||||
<Select placeholder={t('leaderEdit.categoryPlaceholder') || '选择分类(可选)'} allowClear>
|
||||
<Option value="sports">Sports</Option>
|
||||
<Option value="crypto">Crypto</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider, Typography } from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, LinkOutlined, GlobalOutlined } from '@ant-design/icons'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, GlobalOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { apiService } from '../services/api'
|
||||
import type { Leader } from '../types'
|
||||
@@ -67,14 +67,6 @@ const LeaderList: React.FC = () => {
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('leaderList.category') || '分类',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
render: (category: string | undefined) => category ? (
|
||||
<Tag color={category === 'sports' ? 'blue' : 'green'}>{category}</Tag>
|
||||
) : <Tag>{t('leaderList.all') || '全部'}</Tag>
|
||||
},
|
||||
{
|
||||
title: t('leaderList.remark') || '备注',
|
||||
dataIndex: 'remark',
|
||||
@@ -233,37 +225,11 @@ const LeaderList: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 网站 */}
|
||||
{leader.website && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
{t('leaderList.website') || '网站'}:
|
||||
</Text>
|
||||
<a
|
||||
href={leader.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: '12px', marginLeft: '4px' }}
|
||||
>
|
||||
<LinkOutlined /> {leader.website}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
{/* 分类和跟单关系数 */}
|
||||
{/* 跟单关系数 */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
||||
{leader.category ? (
|
||||
<Tag color={leader.category === 'sports' ? 'blue' : 'green'}>
|
||||
{leader.category}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag>{t('leaderList.all') || '全部'}</Tag>
|
||||
)}
|
||||
<Tag>{t('leaderList.copyTradingRelations', { count: leader.copyTradingCount }) || `${leader.copyTradingCount} 个跟单关系`}</Tag>
|
||||
</div>
|
||||
<Tag>{t('leaderList.copyTradingRelations', { count: leader.copyTradingCount }) || `${leader.copyTradingCount} 个跟单关系`}</Tag>
|
||||
</div>
|
||||
|
||||
{/* 创建时间 */}
|
||||
|
||||
@@ -348,10 +348,11 @@ const SystemSettings: React.FC = () => {
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
const config = response.data.data
|
||||
setSystemConfig(config)
|
||||
// 将已配置的值填充到输入框中
|
||||
relayerForm.setFieldsValue({
|
||||
builderApiKey: '',
|
||||
builderSecret: '',
|
||||
builderPassphrase: '',
|
||||
builderApiKey: config.builderApiKeyDisplay || '',
|
||||
builderSecret: config.builderSecretDisplay || '',
|
||||
builderPassphrase: config.builderPassphraseDisplay || '',
|
||||
})
|
||||
autoRedeemForm.setFieldsValue({
|
||||
autoRedeemEnabled: config.autoRedeemEnabled
|
||||
@@ -685,30 +686,32 @@ const SystemSettings: React.FC = () => {
|
||||
<Form.Item
|
||||
label={t('builderApiKey.apiKey')}
|
||||
name="builderApiKey"
|
||||
help={systemConfig?.builderApiKeyConfigured ? t('builderApiKey.apiKeyHelp') : t('builderApiKey.apiKeyPlaceholder')}
|
||||
>
|
||||
<Input
|
||||
placeholder={systemConfig?.builderApiKeyConfigured ? t('builderApiKey.apiKeyHelp') : t('builderApiKey.apiKeyPlaceholder')}
|
||||
placeholder={t('builderApiKey.apiKeyPlaceholder')}
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('builderApiKey.secret')}
|
||||
name="builderSecret"
|
||||
help={systemConfig?.builderSecretConfigured ? t('builderApiKey.secretHelp') : t('builderApiKey.secretPlaceholder')}
|
||||
>
|
||||
<Input
|
||||
placeholder={systemConfig?.builderSecretConfigured ? t('builderApiKey.secretHelp') : t('builderApiKey.secretPlaceholder')}
|
||||
<Input.Password
|
||||
placeholder={t('builderApiKey.secretPlaceholder')}
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
iconRender={(visible) => (visible ? <span>👁️</span> : <span>👁️🗨️</span>)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('builderApiKey.passphrase')}
|
||||
name="builderPassphrase"
|
||||
help={systemConfig?.builderPassphraseConfigured ? t('builderApiKey.passphraseHelp') : t('builderApiKey.passphrasePlaceholder')}
|
||||
>
|
||||
<Input
|
||||
placeholder={systemConfig?.builderPassphraseConfigured ? t('builderApiKey.passphraseHelp') : t('builderApiKey.passphrasePlaceholder')}
|
||||
<Input.Password
|
||||
placeholder={t('builderApiKey.passphrasePlaceholder')}
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
iconRender={(visible) => (visible ? <span>👁️</span> : <span>👁️🗨️</span>)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
|
||||
@@ -54,7 +54,6 @@ const TemplateAdd: React.FC = () => {
|
||||
supportSell: values.supportSell !== false,
|
||||
minOrderDepth: values.minOrderDepth?.toString(),
|
||||
maxSpread: values.maxSpread?.toString(),
|
||||
minOrderbookDepth: values.minOrderbookDepth?.toString(),
|
||||
minPrice: values.minPrice?.toString(),
|
||||
maxPrice: values.maxPrice?.toString()
|
||||
})
|
||||
@@ -258,7 +257,7 @@ const TemplateAdd: React.FC = () => {
|
||||
<Form.Item
|
||||
label={t('templateAdd.minOrderDepth') || '最小订单深度 (USDC)'}
|
||||
name="minOrderDepth"
|
||||
tooltip={t('templateAdd.minOrderDepthTooltip') || '最小订单深度(USDC金额),NULL表示不启用此过滤。确保市场有足够的流动性'}
|
||||
tooltip={t('templateAdd.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
@@ -272,7 +271,7 @@ const TemplateAdd: React.FC = () => {
|
||||
<Form.Item
|
||||
label={t('templateAdd.maxSpread') || '最大价差(绝对价格)'}
|
||||
name="maxSpread"
|
||||
tooltip={t('templateAdd.maxSpreadTooltip') || '最大价差(绝对价格),NULL表示不启用此过滤。避免在价差过大的市场跟单'}
|
||||
tooltip={t('templateAdd.maxSpreadTooltip') || '最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤'}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
@@ -283,20 +282,6 @@ const TemplateAdd: React.FC = () => {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('templateAdd.minOrderbookDepth') || '最小订单簿深度 (USDC)'}
|
||||
name="minOrderbookDepth"
|
||||
tooltip={t('templateAdd.minOrderbookDepthTooltip') || '最小订单簿深度(USDC金额),NULL表示不启用此过滤。检查前 N 档的深度'}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={0.0001}
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('templateAdd.minOrderbookDepthPlaceholder') || '例如:50(可选,不填写表示不启用)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Divider>{t('templateAdd.priceRangeFilter') || '价格区间过滤'}</Divider>
|
||||
|
||||
<Form.Item
|
||||
|
||||
@@ -40,7 +40,6 @@ const TemplateEdit: React.FC = () => {
|
||||
priceTolerance: parseFloat(template.priceTolerance),
|
||||
minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined,
|
||||
maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined,
|
||||
minOrderbookDepth: template.minOrderbookDepth ? parseFloat(template.minOrderbookDepth) : undefined,
|
||||
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
|
||||
maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined
|
||||
})
|
||||
@@ -99,7 +98,6 @@ const TemplateEdit: React.FC = () => {
|
||||
supportSell: values.supportSell,
|
||||
minOrderDepth: values.minOrderDepth?.toString(),
|
||||
maxSpread: values.maxSpread?.toString(),
|
||||
minOrderbookDepth: values.minOrderbookDepth?.toString(),
|
||||
minPrice: values.minPrice?.toString(),
|
||||
maxPrice: values.maxPrice?.toString()
|
||||
})
|
||||
@@ -295,7 +293,7 @@ const TemplateEdit: React.FC = () => {
|
||||
<Form.Item
|
||||
label={t('templateEdit.minOrderDepth') || '最小订单深度 (USDC)'}
|
||||
name="minOrderDepth"
|
||||
tooltip={t('templateEdit.minOrderDepthTooltip') || '最小订单深度(USDC金额),NULL表示不启用此过滤。确保市场有足够的流动性'}
|
||||
tooltip={t('templateEdit.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
@@ -309,7 +307,7 @@ const TemplateEdit: React.FC = () => {
|
||||
<Form.Item
|
||||
label={t('templateEdit.maxSpread') || '最大价差(绝对价格)'}
|
||||
name="maxSpread"
|
||||
tooltip={t('templateEdit.maxSpreadTooltip') || '最大价差(绝对价格),NULL表示不启用此过滤。避免在价差过大的市场跟单'}
|
||||
tooltip={t('templateEdit.maxSpreadTooltip') || '最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤'}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
@@ -320,20 +318,6 @@ const TemplateEdit: React.FC = () => {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('templateEdit.minOrderbookDepth') || '最小订单簿深度 (USDC)'}
|
||||
name="minOrderbookDepth"
|
||||
tooltip={t('templateEdit.minOrderbookDepthTooltip') || '最小订单簿深度(USDC金额),NULL表示不启用此过滤。检查前 N 档的深度'}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={0.0001}
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('templateEdit.minOrderbookDepthPlaceholder') || '例如:50(可选,不填写表示不启用)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Divider>{t('templateEdit.priceRangeFilter') || '价格区间过滤'}</Divider>
|
||||
|
||||
<Form.Item
|
||||
|
||||
@@ -74,7 +74,6 @@ const TemplateList: React.FC = () => {
|
||||
supportSell: template.supportSell,
|
||||
minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined,
|
||||
maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined,
|
||||
minOrderbookDepth: template.minOrderbookDepth ? parseFloat(template.minOrderbookDepth) : undefined,
|
||||
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
|
||||
maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined
|
||||
})
|
||||
@@ -122,7 +121,6 @@ const TemplateList: React.FC = () => {
|
||||
supportSell: values.supportSell !== false,
|
||||
minOrderDepth: values.minOrderDepth?.toString(),
|
||||
maxSpread: values.maxSpread?.toString(),
|
||||
minOrderbookDepth: values.minOrderbookDepth?.toString(),
|
||||
minPrice: values.minPrice?.toString(),
|
||||
maxPrice: values.maxPrice?.toString()
|
||||
})
|
||||
@@ -613,7 +611,7 @@ const TemplateList: React.FC = () => {
|
||||
<Form.Item
|
||||
label="最小订单深度 (USDC)"
|
||||
name="minOrderDepth"
|
||||
tooltip="最小订单深度(USDC金额),NULL表示不启用此过滤。确保市场有足够的流动性"
|
||||
tooltip="检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
@@ -627,7 +625,7 @@ const TemplateList: React.FC = () => {
|
||||
<Form.Item
|
||||
label="最大价差(绝对价格)"
|
||||
name="maxSpread"
|
||||
tooltip="最大价差(绝对价格),NULL表示不启用此过滤。避免在价差过大的市场跟单"
|
||||
tooltip="最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
@@ -638,20 +636,6 @@ const TemplateList: React.FC = () => {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="最小订单簿深度 (USDC)"
|
||||
name="minOrderbookDepth"
|
||||
tooltip="最小订单簿深度(USDC金额),NULL表示不启用此过滤。检查前 N 档的深度"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={0.0001}
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="例如:50(可选,不填写表示不启用)"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Divider>价格区间过滤</Divider>
|
||||
|
||||
<Form.Item
|
||||
|
||||
@@ -145,31 +145,31 @@ export const apiService = {
|
||||
* 获取用户列表
|
||||
*/
|
||||
list: () =>
|
||||
apiClient.post<ApiResponse<any[]>>('/users/list', {}),
|
||||
apiClient.post<ApiResponse<any[]>>('/system/users/list', {}),
|
||||
|
||||
/**
|
||||
* 创建用户
|
||||
*/
|
||||
create: (data: { username: string; password: string }) =>
|
||||
apiClient.post<ApiResponse<any>>('/users/create', data),
|
||||
apiClient.post<ApiResponse<any>>('/system/users/create', data),
|
||||
|
||||
/**
|
||||
* 更新用户密码
|
||||
*/
|
||||
updatePassword: (data: { userId: number; newPassword: string }) =>
|
||||
apiClient.post<ApiResponse<void>>('/users/update-password', data),
|
||||
apiClient.post<ApiResponse<void>>('/system/users/update-password', data),
|
||||
|
||||
/**
|
||||
* 删除用户
|
||||
*/
|
||||
delete: (data: { userId: number }) =>
|
||||
apiClient.post<ApiResponse<void>>('/users/delete', data),
|
||||
apiClient.post<ApiResponse<void>>('/system/users/delete', data),
|
||||
|
||||
/**
|
||||
* 用户修改自己的密码
|
||||
*/
|
||||
updateOwnPassword: (data: { newPassword: string }) =>
|
||||
apiClient.post<ApiResponse<void>>('/users/update-own-password', data)
|
||||
apiClient.post<ApiResponse<void>>('/system/users/update-own-password', data)
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -203,61 +203,61 @@ export const apiService = {
|
||||
* 导入账户
|
||||
*/
|
||||
import: (data: any) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/import', data),
|
||||
apiClient.post<ApiResponse<any>>('/accounts/import', data),
|
||||
|
||||
/**
|
||||
* 更新账户
|
||||
*/
|
||||
update: (data: any) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/update', data),
|
||||
apiClient.post<ApiResponse<any>>('/accounts/update', data),
|
||||
|
||||
/**
|
||||
* 删除账户
|
||||
*/
|
||||
delete: (data: { accountId: number }) =>
|
||||
apiClient.post<ApiResponse<void>>('/copy-trading/accounts/delete', data),
|
||||
apiClient.post<ApiResponse<void>>('/accounts/delete', data),
|
||||
|
||||
/**
|
||||
* 查询账户列表
|
||||
*/
|
||||
list: () =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/list', {}),
|
||||
apiClient.post<ApiResponse<any>>('/accounts/list', {}),
|
||||
|
||||
/**
|
||||
* 查询账户详情
|
||||
*/
|
||||
detail: (data: { accountId?: number }) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/detail', data),
|
||||
apiClient.post<ApiResponse<any>>('/accounts/detail', data),
|
||||
|
||||
/**
|
||||
* 查询账户余额
|
||||
*/
|
||||
balance: (data: { accountId?: number }) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/balance', data),
|
||||
apiClient.post<ApiResponse<any>>('/accounts/balance', data),
|
||||
|
||||
/**
|
||||
* 查询所有账户的仓位列表
|
||||
*/
|
||||
positionsList: () =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/positions/list', {}),
|
||||
apiClient.post<ApiResponse<any>>('/accounts/positions/list', {}),
|
||||
|
||||
/**
|
||||
* 卖出仓位
|
||||
*/
|
||||
sellPosition: (data: any) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/positions/sell', data),
|
||||
apiClient.post<ApiResponse<any>>('/accounts/positions/sell', data),
|
||||
|
||||
/**
|
||||
* 获取可赎回仓位统计
|
||||
*/
|
||||
getRedeemableSummary: (data: { accountId?: number }) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/positions/redeemable-summary', data),
|
||||
apiClient.post<ApiResponse<any>>('/accounts/positions/redeemable-summary', data),
|
||||
|
||||
/**
|
||||
* 赎回仓位
|
||||
*/
|
||||
redeemPositions: (data: any) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/positions/redeem', data),
|
||||
apiClient.post<ApiResponse<any>>('/accounts/positions/redeem', data),
|
||||
|
||||
},
|
||||
|
||||
@@ -269,13 +269,13 @@ export const apiService = {
|
||||
* 获取市场价格(通过 Gamma API)
|
||||
*/
|
||||
getMarketPrice: (data: { marketId: string; outcomeIndex?: number }) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/markets/price', data),
|
||||
apiClient.post<ApiResponse<any>>('/markets/price', data),
|
||||
|
||||
/**
|
||||
* 获取最新价(从订单表获取,供前端下单时显示)
|
||||
*/
|
||||
getLatestPrice: (data: { tokenId: string }) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/markets/latest-price', data)
|
||||
apiClient.post<ApiResponse<any>>('/markets/latest-price', data)
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -365,37 +365,37 @@ export const apiService = {
|
||||
* 2. 不提供 templateId:手动输入所有配置参数
|
||||
*/
|
||||
create: (data: any) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/create', data),
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/configs/create', data),
|
||||
|
||||
/**
|
||||
* 更新跟单配置
|
||||
*/
|
||||
update: (data: any) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/update', data),
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/configs/update', data),
|
||||
|
||||
/**
|
||||
* 查询跟单列表
|
||||
*/
|
||||
list: (data: { accountId?: number; leaderId?: number; enabled?: boolean } = {}) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/list', data),
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/configs/list', data),
|
||||
|
||||
/**
|
||||
* 更新跟单状态(兼容旧接口)
|
||||
*/
|
||||
updateStatus: (data: { copyTradingId: number; enabled: boolean }) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/update-status', data),
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/configs/update-status', data),
|
||||
|
||||
/**
|
||||
* 删除跟单
|
||||
*/
|
||||
delete: (data: { copyTradingId: number }) =>
|
||||
apiClient.post<ApiResponse<void>>('/copy-trading/delete', data),
|
||||
apiClient.post<ApiResponse<void>>('/copy-trading/configs/delete', data),
|
||||
|
||||
/**
|
||||
* 查询钱包绑定的跟单配置(兼容旧接口)
|
||||
*/
|
||||
getAccountTemplates: (data: { accountId: number }) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/account-templates', data),
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/configs/account-templates', data),
|
||||
|
||||
/**
|
||||
* 查询被过滤订单列表
|
||||
@@ -408,7 +408,7 @@ export const apiService = {
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
}) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/filtered-orders', data)
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/configs/filtered-orders', data)
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -476,13 +476,13 @@ export const apiService = {
|
||||
* 获取当前代理配置
|
||||
*/
|
||||
get: () =>
|
||||
apiClient.post<ApiResponse<any>>('/proxy-config/get', {}),
|
||||
apiClient.post<ApiResponse<any>>('/system/proxy/get', {}),
|
||||
|
||||
/**
|
||||
* 获取所有代理配置
|
||||
*/
|
||||
list: () =>
|
||||
apiClient.post<ApiResponse<any[]>>('/proxy-config/list', {}),
|
||||
apiClient.post<ApiResponse<any[]>>('/system/proxy/list', {}),
|
||||
|
||||
/**
|
||||
* 保存 HTTP 代理配置
|
||||
@@ -494,7 +494,7 @@ export const apiService = {
|
||||
username?: string
|
||||
password?: string
|
||||
}) =>
|
||||
apiClient.post<ApiResponse<any>>('/proxy-config/http/save', data),
|
||||
apiClient.post<ApiResponse<any>>('/system/proxy/http/save', data),
|
||||
|
||||
/**
|
||||
* 检查代理是否可用
|
||||
@@ -504,13 +504,13 @@ export const apiService = {
|
||||
success: boolean
|
||||
message: string
|
||||
responseTime?: number
|
||||
}>>('/proxy-config/check', {}),
|
||||
}>>('/system/proxy/check', {}),
|
||||
|
||||
/**
|
||||
* 删除代理配置
|
||||
*/
|
||||
delete: (data: { id: number }) =>
|
||||
apiClient.post<ApiResponse<void>>('/proxy-config/delete', data),
|
||||
apiClient.post<ApiResponse<void>>('/system/proxy/delete', data),
|
||||
|
||||
/**
|
||||
* 检查所有 API 的健康状态
|
||||
@@ -524,7 +524,7 @@ export const apiService = {
|
||||
message: string
|
||||
responseTime?: number
|
||||
}>
|
||||
}>>('/proxy-config/api-health-check', {})
|
||||
}>>('/system/proxy/api-health-check', {})
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -535,49 +535,49 @@ export const apiService = {
|
||||
* 获取配置列表
|
||||
*/
|
||||
list: (data?: { type?: string }) =>
|
||||
apiClient.post<ApiResponse<NotificationConfig[]>>('/notifications/configs/list', data || {}),
|
||||
apiClient.post<ApiResponse<NotificationConfig[]>>('/system/notifications/configs/list', data || {}),
|
||||
|
||||
/**
|
||||
* 获取配置详情
|
||||
*/
|
||||
detail: (data: { id: number }) =>
|
||||
apiClient.post<ApiResponse<NotificationConfig>>('/notifications/configs/detail', data),
|
||||
apiClient.post<ApiResponse<NotificationConfig>>('/system/notifications/configs/detail', data),
|
||||
|
||||
/**
|
||||
* 创建配置
|
||||
*/
|
||||
create: (data: NotificationConfigRequest) =>
|
||||
apiClient.post<ApiResponse<NotificationConfig>>('/notifications/configs/create', data),
|
||||
apiClient.post<ApiResponse<NotificationConfig>>('/system/notifications/configs/create', data),
|
||||
|
||||
/**
|
||||
* 更新配置
|
||||
*/
|
||||
update: (data: NotificationConfigUpdateRequest) =>
|
||||
apiClient.post<ApiResponse<NotificationConfig>>('/notifications/configs/update', data),
|
||||
apiClient.post<ApiResponse<NotificationConfig>>('/system/notifications/configs/update', data),
|
||||
|
||||
/**
|
||||
* 更新启用状态
|
||||
*/
|
||||
updateEnabled: (data: { id: number; enabled: boolean }) =>
|
||||
apiClient.post<ApiResponse<NotificationConfig>>('/notifications/configs/update-enabled', data),
|
||||
apiClient.post<ApiResponse<NotificationConfig>>('/system/notifications/configs/update-enabled', data),
|
||||
|
||||
/**
|
||||
* 删除配置
|
||||
*/
|
||||
delete: (data: { id: number }) =>
|
||||
apiClient.post<ApiResponse<void>>('/notifications/configs/delete', data),
|
||||
apiClient.post<ApiResponse<void>>('/system/notifications/configs/delete', data),
|
||||
|
||||
/**
|
||||
* 测试通知
|
||||
*/
|
||||
test: (data?: { message?: string }) =>
|
||||
apiClient.post<ApiResponse<boolean>>('/notifications/test', data || {}),
|
||||
apiClient.post<ApiResponse<boolean>>('/system/notifications/test', data || {}),
|
||||
|
||||
/**
|
||||
* 获取 Telegram Chat IDs
|
||||
*/
|
||||
getTelegramChatIds: (data: { botToken: string }) =>
|
||||
apiClient.post<ApiResponse<string[]>>('/notifications/telegram/get-chat-ids', data)
|
||||
apiClient.post<ApiResponse<string[]>>('/system/notifications/telegram/get-chat-ids', data)
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -112,7 +112,6 @@ export interface CopyTradingTemplate {
|
||||
// 过滤条件
|
||||
minOrderDepth?: string
|
||||
maxSpread?: string
|
||||
minOrderbookDepth?: string
|
||||
minPrice?: string // 最低价格(可选),NULL表示不限制最低价
|
||||
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
|
||||
createdAt: number
|
||||
@@ -204,7 +203,6 @@ export interface CopyTrading {
|
||||
// 过滤条件
|
||||
minOrderDepth?: string
|
||||
maxSpread?: string
|
||||
minOrderbookDepth?: string
|
||||
minPrice?: string // 最低价格(可选),NULL表示不限制最低价
|
||||
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
|
||||
// 新增配置字段
|
||||
@@ -248,7 +246,6 @@ export interface CopyTradingCreateRequest {
|
||||
// 过滤条件
|
||||
minOrderDepth?: string
|
||||
maxSpread?: string
|
||||
minOrderbookDepth?: string
|
||||
minPrice?: string // 最低价格(可选),NULL表示不限制最低价
|
||||
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
|
||||
// 新增配置字段
|
||||
@@ -280,7 +277,6 @@ export interface CopyTradingUpdateRequest {
|
||||
// 过滤条件
|
||||
minOrderDepth?: string
|
||||
maxSpread?: string
|
||||
minOrderbookDepth?: string
|
||||
minPrice?: string // 最低价格(可选),NULL表示不限制最低价
|
||||
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
|
||||
// 新增配置字段
|
||||
@@ -774,6 +770,9 @@ export interface SystemConfig {
|
||||
builderApiKeyConfigured: boolean
|
||||
builderSecretConfigured: boolean
|
||||
builderPassphraseConfigured: boolean
|
||||
builderApiKeyDisplay?: string // Builder API Key 显示值(部分显示)
|
||||
builderSecretDisplay?: string // Builder Secret 显示值(部分显示)
|
||||
builderPassphraseDisplay?: string // Builder Passphrase 显示值(部分显示)
|
||||
autoRedeemEnabled: boolean // 自动赎回(系统级别配置,默认开启)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user